[object Object]

← back to Wallco Ai

scope dw_auth admin cookie to .wallco.ai so admin login carries to subdomains (styleguides)

f1f5191166bc9dc942a89a4ea2cc89bf93885c7c · 2026-06-01 12:43:39 -0700 · Steve Abrams

Files touched

Diff

commit f1f5191166bc9dc942a89a4ea2cc89bf93885c7c
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Mon Jun 1 12:43:39 2026 -0700

    scope dw_auth admin cookie to .wallco.ai so admin login carries to subdomains (styleguides)
---
 server.js | 71 +++++++++++++++++++++++++++++++++++++++++++++++++--------------
 1 file changed, 55 insertions(+), 16 deletions(-)

diff --git a/server.js b/server.js
index bb2ec7f..2ed1f8c 100644
--- a/server.js
+++ b/server.js
@@ -4284,14 +4284,18 @@ app.post('/api/design/:id/rate', express.json({ limit: '4kb' }), (req, res) => {
 // among bluebonnets" rendered as ONLY bluebonnets — color/style/scale all fine,
 // prompt-match FAIL). So this is a distinct axis.
 //
-// Persistence: a new prod PG column (user_prompt_match smallint) is the right
-// long-term home, but that's a Steve-gated schema change. Until then we persist
-// to an append-only JSONL ledger (data/prompt-match-ratings.jsonl — same pattern
-// as the votes / cactus-decisions pipelines) and replay the LATEST entry per id
-// into an in-memory map so the surface shows current state. Promotion to a real
-// column is a one-line backfill from this ledger when Steve approves it.
+// Persistence (PROMOTED 2026-06-01, Steve-approved): PG is now the SOURCE OF
+// TRUTH. The rating lives in three additive columns on all_designs (surfaced
+// via spoon_all_designs) — user_prompt_match (smallint 1-5),
+// user_prompt_verdict (text 'approve'|'reject'), user_prompt_rated_at
+// (timestamptz). See migrations/20260601_prompt_match_columns.sql. The
+// append-only JSONL ledger (data/prompt-match-ratings.jsonl) is KEPT as an
+// audit trail — every write goes to PG first (strict, ON_ERROR_STOP) then
+// appends to the ledger. The in-memory map is no longer the truth; it survives
+// only as a backfill source on a fresh boot if PG ever lacks a row the ledger
+// has (defensive — normally PG and ledger agree).
 const PROMPT_MATCH_LEDGER = path.join(__dirname, 'data', 'prompt-match-ratings.jsonl');
-const PROMPT_MATCH_STATE = new Map();  // id -> { match, verdict, rated_at }
+const PROMPT_MATCH_STATE = new Map();  // id -> { match, verdict, rated_at } (audit replay only)
 (function loadPromptMatchLedger() {
   try {
     if (!fs.existsSync(PROMPT_MATCH_LEDGER)) return;
@@ -4324,19 +4328,20 @@ app.get('/api/admin/prompt-rate/list', (req, res) => {
       "local_path NOT LIKE '%midheal_%' AND local_path NOT LIKE '%edgeheal_%' AND local_path NOT LIKE '%smartfix_%'",
     ];
     if (cat) where.push(`category ILIKE '%${cat}%'`);
+    // PG is the source of truth for prompt-match — read the columns directly.
     const rawJson = psqlQuery(
       "SELECT COALESCE(json_agg(t ORDER BY t.created_at DESC), '[]'::json) FROM (" +
         "SELECT id, category, dominant_hex, prompt, generator, seed, " +
                "is_published, created_at, " +
+               "user_prompt_match, user_prompt_verdict, user_prompt_rated_at, " +
                "user_color_good, user_style_good, user_scale_good, user_stars " +
         "FROM spoon_all_designs " +
         "WHERE " + where.join(' AND ') + " " +
-        "LIMIT " + (limit * 3) +   // overscan; we filter unrated/promote in JS
+        "LIMIT " + (limit * 3) +   // overscan; we filter unrated in JS
       ") t;"
     );
     let items = JSON.parse(rawJson || '[]');
     items = items.map(d => {
-      const pm = PROMPT_MATCH_STATE.get(d.id) || {};
       return {
         id: d.id,
         category: d.category,
@@ -4348,9 +4353,9 @@ app.get('/api/admin/prompt-rate/list', (req, res) => {
         created_at: d.created_at,
         image_url: `/designs/img/by-id/${d.id}`,
         view_url: `/design/${d.id}`,
-        prompt_match: pm.match ?? null,     // 1-5 score
-        prompt_verdict: pm.verdict ?? null, // 'approve' | 'reject' | null
-        prompt_rated_at: pm.rated_at ?? null,
+        prompt_match: d.user_prompt_match ?? null,     // 1-5 score (PG truth)
+        prompt_verdict: d.user_prompt_verdict ?? null, // 'approve' | 'reject' | null (PG truth)
+        prompt_rated_at: d.user_prompt_rated_at ?? null,
         user_color_good: d.user_color_good,
         user_style_good: d.user_style_good,
         user_scale_good: d.user_scale_good,
@@ -4382,12 +4387,32 @@ app.post('/api/admin/prompt-rate/:id', express.json({ limit: '4kb' }), (req, res
   else if (b.verdict === 'approve' || b.verdict === 'reject') verdict = b.verdict;
   else if (b.verdict !== undefined) return res.status(400).json({ error: "verdict must be 'approve'|'reject'|null" });
   if (b.match === undefined && b.verdict === undefined) return res.status(400).json({ error: 'nothing to update' });
-  // Merge with prior so a verdict-only call keeps an earlier match and vice versa
-  const prev = PROMPT_MATCH_STATE.get(id) || {};
+  // Merge with prior so a verdict-only call keeps an earlier match and vice
+  // versa. PG is the truth, so read prior from PG (survives restarts), falling
+  // back to the in-memory replay map if the row read fails.
+  let prev = {};
+  try {
+    const prevRaw = psqlQuery(
+      "SELECT row_to_json(t) FROM (SELECT user_prompt_match AS match, user_prompt_verdict AS verdict " +
+      "FROM spoon_all_designs WHERE id=" + id + " LIMIT 1) t;"
+    );
+    if (prevRaw) prev = JSON.parse(prevRaw) || {};
+  } catch { prev = PROMPT_MATCH_STATE.get(id) || {}; }
   if (b.match === undefined) match = prev.match ?? null;
   if (b.verdict === undefined) verdict = prev.verdict ?? null;
   const ts = new Date().toISOString();
   try {
+    // PG FIRST (strict — throws on error so we never report a false success).
+    // pgEsc handles null/number/string safely. verdict is constrained above to
+    // 'approve'|'reject'|null, match to 1-5|null.
+    psqlExecLocal(
+      "UPDATE spoon_all_designs SET " +
+        "user_prompt_match=" + pgEsc(match) + ", " +
+        "user_prompt_verdict=" + pgEsc(verdict) + ", " +
+        "user_prompt_rated_at=" + pgEsc(ts) + " " +
+      "WHERE id=" + id + ";"
+    );
+    // Then the append-only audit trail + in-memory replay cache.
     fs.appendFileSync(PROMPT_MATCH_LEDGER, JSON.stringify({ id, match, verdict, ts }) + '\n');
     PROMPT_MATCH_STATE.set(id, { match, verdict, rated_at: ts });
     res.json({ ok: true, id, match, verdict, rated_at: ts });
@@ -4648,6 +4673,15 @@ function tradeProtoIsHttps(req) {
   const proto = (req.headers['x-forwarded-proto'] || req.protocol || 'http').split(',')[0].trim();
   return proto === 'https';
 }
+// Admin cookies are minted on wallco.ai but must also unlock the subdomains
+// (styleguides.wallco.ai, etc.). Scope `dw_auth` to `.wallco.ai` so one admin
+// login carries across every subdomain. On localhost/dev (or any non-wallco.ai
+// host) return undefined so the cookie defaults to the serving host and still
+// works — a `.wallco.ai` domain on a localhost cookie would be rejected.
+function wallcoCookieDomain(req) {
+  const h = String(req.hostname || '').toLowerCase();
+  return (h === 'wallco.ai' || h.endsWith('.wallco.ai')) ? '.wallco.ai' : undefined;
+}
 
 // GET /trade/login — minimal email-only form. `return` query param round-trips
 // the user back to /design/:id (or wherever they came from) after auth.
@@ -4993,7 +5027,8 @@ app.post('/api/trade/login-pw', async (req, res) => {
         sameSite: 'lax',
         secure: tradeProtoIsHttps(req),
         maxAge: 14 * 24 * 3600 * 1000,
-        path: '/'
+        path: '/',
+        domain: wallcoCookieDomain(req)
       });
       console.log(`[trade-login-pw] admin promotion: ${row.email} -> dw_auth minted`);
     }
@@ -5076,7 +5111,8 @@ app.get('/trade/auth', (req, res) => {
         sameSite: 'lax',
         secure: tradeProtoIsHttps(req),
         maxAge: 14 * 24 * 3600 * 1000,
-        path: '/'
+        path: '/',
+        domain: wallcoCookieDomain(req)
       });
       console.log(`[trade-login] admin promotion: ${row.email} → dw_auth minted`);
     }
@@ -5102,7 +5138,10 @@ app.get('/trade/auth', (req, res) => {
 // 2026-05-25: separate from /trade/logout (trade-only) so a single hamburger link
 // works whether the user is admin, trade, both, or just a localhost-auto-admin.
 app.get('/logout', (req, res) => {
+  // Clear both the host-scoped (legacy) and the `.wallco.ai`-scoped admin
+  // cookie so logout works regardless of which form the browser holds.
   res.clearCookie('dw_auth', { path: '/' });
+  res.clearCookie('dw_auth', { path: '/', domain: wallcoCookieDomain(req) });
   res.clearCookie('wallco_trade_session', { path: '/' });
   res.clearCookie('wallco_admin', { path: '/' });
   // Force localhost-auto-admin into public view so the change is visible without

← 25e596a migration: add user_prompt_match/verdict/rated_at to all_des  ·  back to Wallco Ai  ·  prompt-rate: PG columns are source of truth (read+write), JS 6d802cf →