← 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 →