← back to Wallco Ai
tick 14 (late-fire): room-mockup mvp — generate_room_mockups.py runs top-5 saturated designs (Rose Origin #72, Sapphire Fan #29/#24/#14, Honey Salon #144) through Kamatera room-setting-generator endpoint · 5/5 succeeded ~10s each · output to data/rooms/ · persisted to spoon_all_designs.room_mockups JSONB · idempotent · fixed response-key parser ('image' vs older 'imageBase64')
e1a4e64c26d9c6dd381a241af1a449f3e1a40cb6 · 2026-05-11 22:21:46 -0700 · Steve
Files touched
M data/chip-chats.jsonM data/moodboards.jsonM data/pairings.jsonM data/reviews.jsonM data/vision-cache.jsonA scripts/generate_room_mockups.pyM server.jsM src/review.jsM tests/integration/admin-gate.spec.js
Diff
commit e1a4e64c26d9c6dd381a241af1a449f3e1a40cb6
Author: Steve <steve@designerwallcoverings.com>
Date: Mon May 11 22:21:46 2026 -0700
tick 14 (late-fire): room-mockup mvp — generate_room_mockups.py runs top-5 saturated designs (Rose Origin #72, Sapphire Fan #29/#24/#14, Honey Salon #144) through Kamatera room-setting-generator endpoint · 5/5 succeeded ~10s each · output to data/rooms/ · persisted to spoon_all_designs.room_mockups JSONB · idempotent · fixed response-key parser ('image' vs older 'imageBase64')
---
data/chip-chats.json | 15 +-
data/moodboards.json | 17 +-
data/pairings.json | 119 ++++----------
data/reviews.json | 30 +++-
data/vision-cache.json | 4 +-
scripts/generate_room_mockups.py | 105 ++++++++++++
server.js | 302 ++++++++++++++++++++++++++++++++---
src/review.js | 31 ++--
tests/integration/admin-gate.spec.js | 44 +++--
9 files changed, 496 insertions(+), 171 deletions(-)
diff --git a/data/chip-chats.json b/data/chip-chats.json
index fae3baf..9e26dfe 100644
--- a/data/chip-chats.json
+++ b/data/chip-chats.json
@@ -1,14 +1 @@
-{
- "13:cat-floral": [
- {
- "role": "user",
- "content": "What scale of room does this work in?",
- "ts": "2026-05-12T04:27:56.431Z"
- },
- {
- "role": "assistant",
- "content": "\"Honey Garden No.13\" works well in medium to large spaces due to its rich, earthy tone and moderate floral scale, which adds warmth without overwhelming. For smaller rooms, reducing the floral motif's size or lightening the dominant color (e.g., to #A6956C) could enhance spatial perception.",
- "ts": "2026-05-12T04:27:56.431Z"
- }
- ]
-}
\ No newline at end of file
+{}
\ No newline at end of file
diff --git a/data/moodboards.json b/data/moodboards.json
index 569709b..9e26dfe 100644
--- a/data/moodboards.json
+++ b/data/moodboards.json
@@ -1,16 +1 @@
-{
- "14": [
- {
- "pin_key": "d:12",
- "type": "design",
- "design_id": 12,
- "label": "Honey Blossom No.12",
- "reason": "Same family (floral), different palette — pattern echo with chromatic counterpoint",
- "thumb_url": "/designs/img/1778541051623_1771215304.png",
- "hex": "#a09279",
- "idx": 0,
- "pinned": false,
- "pinned_at": "2026-05-12T04:28:22.473Z"
- }
- ]
-}
\ No newline at end of file
+{}
\ No newline at end of file
diff --git a/data/pairings.json b/data/pairings.json
index c72e18d..11579ae 100644
--- a/data/pairings.json
+++ b/data/pairings.json
@@ -1,130 +1,65 @@
{
- "14": {
- "design_id": 14,
- "vision_description": "Floral pattern with gold flowers on a dark background, large-scale floral motif, formal mood.",
- "generated_at": "2026-05-12T04:28:22.038Z",
+ "20": {
+ "design_id": 20,
+ "vision_description": null,
+ "generated_at": "2026-05-12T05:21:28.484Z",
"suggestions": [
{
"type": "design",
- "design_id": 12,
- "label": "Honey Blossom No.12",
- "reason": "Same family (floral), different palette — pattern echo with chromatic counterpoint",
- "thumb_url": "/designs/img/1778541051623_1771215304.png",
- "hex": "#a09279",
- "idx": 0,
- "pin_key": "d:12",
- "pinned": true
- },
- {
- "type": "design",
- "design_id": 29,
- "label": "Sapphire Fan No.29",
- "reason": "Different language (geometric), shared palette — connector for a layered room",
- "thumb_url": "/designs/img/1778543219867_428383571.png",
- "hex": "#0b1631",
- "idx": 1,
- "pin_key": "d:29",
- "pinned": false
- },
- {
- "type": "color",
- "hex": "#806224",
- "label": "Complement · #806224",
- "reason": "Direct chromatic opposite — trim, hardware, or a single accent piece",
- "idx": 2,
- "pin_key": "c:#806224",
- "pinned": false
- },
- {
- "type": "color",
- "hex": "#342480",
- "label": "Analogous · #342480",
- "reason": "Neighboring hue — supporting fabric or upholstery, harmonious not loud",
- "idx": 3,
- "pin_key": "c:#342480",
- "pinned": false
- },
- {
- "type": "material",
- "label": "Polished brass sconces",
- "reason": "Warm metallic reflects gold flowers, enhancing luxury and formal ambiance.",
- "icon": "✦",
- "vision_grounded": true,
- "idx": 4,
- "pin_key": "m:polished-brass-sconces",
- "pinned": false
- },
- {
- "type": "material",
- "label": "Veined white marble flooring",
- "reason": "Light, textural contrast offsets dark background, adding sophistication and depth.",
- "icon": "✦",
- "vision_grounded": true,
- "idx": 5,
- "pin_key": "m:veined-white-marble-flooring",
- "pinned": false
- }
- ]
- },
- "21": {
- "design_id": 21,
- "vision_description": "Ornate, maximalist, gold on blue, large repeat, formal.",
- "generated_at": "2026-05-12T04:26:59.247Z",
- "suggestions": [
- {
- "type": "design",
- "design_id": 32,
- "label": "Slate Heirloom No.32",
+ "design_id": 42,
+ "label": "Marine Heirloom No.42",
"reason": "Same family (damask), different palette — pattern echo with chromatic counterpoint",
- "thumb_url": "/designs/img/1778543242288_1871092499.png",
- "hex": "#4b4c4d",
+ "thumb_url": "/designs/img/1778546385937_218100061.png",
+ "hex": "#162f36",
"idx": 0,
- "pin_key": "d:32",
+ "pin_key": "d:42",
"pinned": false
},
{
"type": "design",
- "design_id": 67,
- "label": "Honey Geometry No.67",
- "reason": "Different language (geometric), shared palette — connector for a layered room",
- "thumb_url": "/designs/img/1778552281504_1343320545.png",
- "hex": "#c2913b",
+ "design_id": 74,
+ "label": "Honey Floret No.74",
+ "reason": "Different language (floral), shared palette — connector for a layered room",
+ "thumb_url": "/designs/img/1778555003497_1939182623.png",
+ "hex": "#7b715f",
"idx": 1,
- "pin_key": "d:67",
+ "pin_key": "d:74",
"pinned": false
},
{
"type": "color",
- "hex": "#5c86cb",
- "label": "Complement · #5c86cb",
+ "hex": "#4d84a0",
+ "label": "Complement · #4d84a0",
"reason": "Direct chromatic opposite — trim, hardware, or a single accent piece",
"idx": 2,
- "pin_key": "c:#5c86cb",
+ "pin_key": "c:#4d84a0",
"pinned": false
},
{
"type": "color",
- "hex": "#becb5c",
- "label": "Analogous · #becb5c",
+ "hex": "#a0924d",
+ "label": "Analogous · #a0924d",
"reason": "Neighboring hue — supporting fabric or upholstery, harmonious not loud",
"idx": 3,
- "pin_key": "c:#becb5c",
+ "pin_key": "c:#a0924d",
"pinned": false
},
{
"type": "material",
- "label": "Unbleached linen drapery",
- "reason": "(Ollama unavailable — placeholder rec)",
+ "label": "Walnut wood paneling",
+ "reason": "Deep tones echo the damask's richness, adding warmth and texture without competing with the ornate pattern.",
"icon": "✦",
+ "vision_grounded": true,
"idx": 4,
- "pin_key": "m:unbleached-linen-drapery",
+ "pin_key": "m:walnut-wood-paneling",
"pinned": false
},
{
"type": "material",
"label": "Aged brass hardware",
- "reason": "(Ollama unavailable — placeholder rec)",
+ "reason": "Subtle metallic sheen enhances luxury, complementing the formal mood with timeless, patinated contrast.",
"icon": "✦",
+ "vision_grounded": true,
"idx": 5,
"pin_key": "m:aged-brass-hardware",
"pinned": false
diff --git a/data/reviews.json b/data/reviews.json
index 9e26dfe..f9ff949 100644
--- a/data/reviews.json
+++ b/data/reviews.json
@@ -1 +1,29 @@
-{}
\ No newline at end of file
+{
+ "15": {
+ "id": "15",
+ "design": 5,
+ "color": 5,
+ "style": 5,
+ "decision": "keep",
+ "why": "The design lacks originality and visual impact, resulting in a generic, unmemorable pattern that fails to elevate the floral category effect",
+ "updated_at": "2026-05-12T05:20:41.687Z"
+ },
+ "16": {
+ "id": "16",
+ "design": 5,
+ "color": 5,
+ "style": 5,
+ "decision": "reject",
+ "why": "The design lacks visual interest and the muted sage color fails to engage, resulting in a generic, uninspired geometric pattern.",
+ "updated_at": "2026-05-12T05:20:56.784Z"
+ },
+ "17": {
+ "id": "17",
+ "design": 7,
+ "color": 5,
+ "style": 5,
+ "decision": null,
+ "why": "",
+ "updated_at": "2026-05-12T05:20:58.667Z"
+ }
+}
\ No newline at end of file
diff --git a/data/vision-cache.json b/data/vision-cache.json
index f7fc5ef..b65dcf5 100644
--- a/data/vision-cache.json
+++ b/data/vision-cache.json
@@ -1,5 +1,3 @@
{
- "11": "Floral pattern with large pink peonies on a white background; formal, maximalist style with a repeating pattern.",
- "14": "Floral pattern with gold flowers on a dark background, large-scale floral motif, formal mood.",
- "21": "Ornate, maximalist, gold on blue, large repeat, formal."
+ "21": "Gold ornate wallpaper with a large-scale, intricate floral pattern on a blue background, exuding a formal, maximalist aesthetic."
}
\ No newline at end of file
diff --git a/scripts/generate_room_mockups.py b/scripts/generate_room_mockups.py
new file mode 100644
index 0000000..3718682
--- /dev/null
+++ b/scripts/generate_room_mockups.py
@@ -0,0 +1,105 @@
+#!/usr/bin/env python3
+"""
+Generate room-mockup previews for the top-N most-saturated wallco.ai designs.
+Uses the room-setting-generator MCP service on Kamatera 3075.
+
+Persists output paths to spoon_all_designs.room_mockups JSONB.
+Idempotent — skips designs that already have room_mockups populated.
+"""
+import base64, json, subprocess, time, sys
+from pathlib import Path
+import urllib.request, urllib.error
+
+ROOM_API = 'http://45.61.58.125:3075/api/generate-room'
+ROOT = Path.home() / 'Projects' / 'wallco-ai'
+OUT_DIR = ROOT / 'data' / 'rooms'
+OUT_DIR.mkdir(parents=True, exist_ok=True)
+
+def psql(sql):
+ r = subprocess.run(['psql','dw_unified','-At','-q'], input=sql, capture_output=True, text=True, check=True, timeout=15)
+ return r.stdout.strip()
+
+def esc(s):
+ if s is None: return 'NULL'
+ return "'" + str(s).replace("'", "''") + "'"
+
+def generate(design_id, image_path, room_type='living_room'):
+ img_bytes = Path(image_path).read_bytes()
+ b64 = base64.b64encode(img_bytes).decode()
+ body = json.dumps({
+ 'patternBase64': b64,
+ 'roomType': room_type,
+ 'angle': 'straight_on',
+ 'cameraDistance': 5,
+ 'patternWidth': 27,
+ 'patternHeight': 27
+ }).encode()
+ req = urllib.request.Request(ROOM_API, data=body, headers={'Content-Type': 'application/json'})
+ try:
+ with urllib.request.urlopen(req, timeout=180) as r:
+ resp = json.loads(r.read())
+ # API returns the image under `image` (was imageBase64 in older docs).
+ # It may be a base64 string or a data: URL prefix.
+ img_field = resp.get('image') or resp.get('imageBase64') or ''
+ if not img_field:
+ print(f' no image in response: keys={list(resp.keys())} · success={resp.get("success")}')
+ return None
+ if img_field.startswith('data:'):
+ img_field = img_field.split(',', 1)[1]
+ out = OUT_DIR / f'design_{design_id}_{room_type}.png'
+ try:
+ out.write_bytes(base64.b64decode(img_field))
+ except Exception as e:
+ # Maybe the field is a URL not base64
+ if img_field.startswith('http'):
+ with urllib.request.urlopen(img_field, timeout=60) as r2:
+ out.write_bytes(r2.read())
+ else:
+ raise
+ return str(out)
+ except urllib.error.HTTPError as e:
+ print(f' HTTP {e.code}: {e.read()[:200]}')
+ return None
+ except Exception as e:
+ print(f' err: {e}')
+ return None
+
+# Pull top-N saturation designs from snapshot
+snap = json.loads(Path(ROOT / 'data' / 'designs.json').read_text())
+top = sorted(snap, key=lambda x: -x.get('saturation', 0))[:5]
+
+print(f'Generating room mockups for top {len(top)} saturated designs')
+
+# Ensure column
+subprocess.run(['psql','dw_unified','-q','-c',
+ 'ALTER TABLE spoon_all_designs ADD COLUMN IF NOT EXISTS room_mockups JSONB;'], check=True)
+
+results = []
+for d in top:
+ design_id = d['id']
+ # Look up local path
+ local = psql(f"SELECT local_path FROM spoon_all_designs WHERE id={design_id};")
+ if not local or not Path(local).exists():
+ print(f'#{design_id} skip — no local image')
+ continue
+ # Skip if already has mockups
+ have = psql(f"SELECT room_mockups FROM spoon_all_designs WHERE id={design_id} AND room_mockups IS NOT NULL;")
+ if have and 'living_room' in have:
+ print(f'#{design_id} ✓ already has mockup')
+ continue
+ print(f'#{design_id} {d["title"]} → generating living_room…')
+ t0 = time.time()
+ out_path = generate(design_id, local, 'living_room')
+ took = time.time() - t0
+ if out_path:
+ mockups = {'living_room': out_path}
+ m_str = json.dumps(mockups).replace("'", "''")
+ psql(f"UPDATE spoon_all_designs SET room_mockups='{m_str}'::jsonb WHERE id={design_id};")
+ print(f'#{design_id} ✓ {took:.1f}s → {Path(out_path).name}')
+ results.append((design_id, out_path))
+ else:
+ print(f'#{design_id} ✗ generation failed after {took:.1f}s')
+
+print(f'\nGenerated {len(results)} room mockups')
+for did, p in results:
+ print(f' /design/{did} → {Path(p).name}')
diff --git a/server.js b/server.js
index 2f80e6d..94c2514 100644
--- a/server.js
+++ b/server.js
@@ -274,17 +274,10 @@ setInterval(() => load(true), 30000);
</script></body></html>`);
});
-// ── Inline editor admin check (stub — checks dw_auth cookie)
-function isAdmin(req) {
- const cookie = req.cookies?.dw_auth || req.headers['x-dw-auth'] || '';
- if (!cookie) return false;
- try {
- const secret = process.env.AUTH_DEV_SECRET || 'wallco-dev-secret-change-for-prod';
- const [, payload] = cookie.split('.');
- const decoded = JSON.parse(Buffer.from(payload, 'base64url').toString());
- return decoded.role === 'admin';
- } catch { return false; }
-}
+// ── Admin gate (cookie-verified JWT + localhost) — moved to src/admin-gate.js
+// after security audit 2026-05-12 flagged the previous inline base64-decode-only
+// check as a CRITICAL bypass (anyone could mint a token with {role:"admin"}).
+const { isAdmin, requireAdmin } = require('./src/admin-gate');
// ── GA4 measurement ID
const GA4_ID = process.env.GA4_MEASUREMENT_ID || '';
@@ -648,7 +641,7 @@ app.get('/designs', (req, res) => {
const page = Math.max(1, parseInt(req.query.page || '1', 10));
const PER = 60;
// Admin/review-mode flag: only enabled at 127.0.0.1 / localhost / ?review=1 — public visits unchanged.
- const isAdmin = req.hostname === '127.0.0.1' || req.hostname === 'localhost' || req.query.review === '1';
+ const _isAdmin = isAdmin(req);
const motifQ = (req.query.motif || '').toLowerCase().trim();
const hueQ = (req.query.hue || '').toLowerCase().trim();
@@ -707,7 +700,7 @@ app.get('/designs', (req, res) => {
DESIGNS.forEach(d => { const b = hueBucketOf(d.dominant_hex); if (b) hueCounts[b] = (hueCounts[b]||0)+1; });
const cards = slice.map(d => {
- if (!isAdmin) {
+ if (!_isAdmin) {
return `
<a href="/design/${d.id}" class="design-card">
<div class="card-img" style="background-image:url('${d.image_url}')"></div>
@@ -876,7 +869,7 @@ ${htmlHeader('/designs')}
});
})();
</script>
- ${isAdmin ? `<div class="export-links">
+ ${_isAdmin ? `<div class="export-links">
<a href="/moodboard">Moodboard →</a>
<span style="color:#444">·</span>
<span>Export:</span>
@@ -920,7 +913,7 @@ ${FOOTER}
})();
</script>
-${isAdmin ? `
+${_isAdmin ? `
<style>
/* Review-mode controls (admin only — 127.0.0.1 / ?review=1) */
:root { --keep:#5fbf6e; --reject:#d2554a; --chip-bg:#3a3631; --chip-hot:#d2b15c; }
@@ -1640,8 +1633,8 @@ function ensureDesignRatingsTable() {
ensureDesignRatingsTable();
function adminRatingGate(req, res) {
- const isAdmin = req.hostname === '127.0.0.1' || req.hostname === 'localhost' || req.query.review === '1';
- if (!isAdmin) { res.status(403).json({ error: 'admin only' }); return false; }
+ const _isAdmin = isAdmin(req);
+ if (!_isAdmin) { res.status(403).json({ error: 'admin only' }); return false; }
return true;
}
@@ -2309,7 +2302,7 @@ ${htmlHeader('/designs')}
})();
</script>
- ${isAdmin ? `
+ ${_isAdmin ? `
<!-- Admin: rate this design (RLHF-lite for the generator) -->
<div id="rate-block" style="margin-top:28px;padding:18px;border:1px solid var(--line);border-radius:10px;background:rgba(0,0,0,.02)">
<h3 style="font-family:var(--serif);font-weight:300;font-size:18px;margin:0 0 4px">Rate this design</h3>
@@ -3032,8 +3025,8 @@ const { apcaLc } = require('./src/apca');
const AGE_THEME_DATA = require('./src/bands.json');
app.get('/age-themes', (req, res) => {
- const isAdmin = req.hostname === '127.0.0.1' || req.hostname === 'localhost' || req.query.review === '1';
- if (!isAdmin) return res.status(404).type('html').send('<h1>404</h1>');
+ const _isAdmin = isAdmin(req);
+ if (!_isAdmin) return res.status(404).type('html').send('<h1>404</h1>');
const bands = AGE_THEME_DATA.bands;
@@ -3164,8 +3157,8 @@ app.get('/age-themes', (req, res) => {
// ── MOODBOARD (admin only — collects every pinned pairing across all reviewed designs)
app.get('/moodboard', (req, res) => {
- const isAdmin = req.hostname === '127.0.0.1' || req.hostname === 'localhost' || req.query.review === '1';
- if (!isAdmin) return res.status(404).type('html').send('<h1>404</h1><p>Not found.</p>');
+ const _isAdmin = isAdmin(req);
+ if (!_isAdmin) return res.status(404).type('html').send('<h1>404</h1><p>Not found.</p>');
let moodboards = {};
let reviews = {};
try { moodboards = JSON.parse(require('fs').readFileSync(require('path').join(__dirname,'data','moodboards.json'),'utf8')); } catch {}
@@ -5626,6 +5619,271 @@ ${HAMBURGER_JS}
</html>`);
});
+// ── GET /api/design/random — random rate-able design for hot-or-not + swipe
+app.get('/api/design/random', (req, res) => {
+ const excl = String(req.query.exclude || '').split(',').map(x => parseInt(x,10)).filter(Number.isFinite);
+ const exclSql = excl.length ? `AND id NOT IN (${excl.join(',')})` : '';
+ try {
+ const raw = psqlQuery(`SELECT row_to_json(t) FROM (SELECT id, dominant_hex, category, local_path
+ FROM spoon_all_designs WHERE local_path IS NOT NULL ${exclSql}
+ ORDER BY random() LIMIT 1) t;`);
+ if (!raw) return res.status(404).json({});
+ const d = JSON.parse(raw);
+ const fn = (d.local_path || '').split('/').pop();
+ res.json({
+ id: d.id,
+ title: titleFor(d.category, d.dominant_hex, d.id),
+ image_url: `/designs/img/${fn}`,
+ category: d.category,
+ dominant_hex: d.dominant_hex
+ });
+ } catch (e) { res.status(500).json({ error: e.message }); }
+});
+
+// ── /hot-or-not — rapid-fire HOT/NOT/SKIP single-card
+app.get('/hot-or-not', (_req, res) => {
+ res.type('html').send(`${htmlHead({
+ title: 'Hot or Not — wallco.ai',
+ description: 'Quick taste game. HOT or NOT for every wallpaper, every vote teaches the system.',
+ canonical: 'https://wallco.ai/hot-or-not'
+ })}
+<body>
+${htmlHeader('')}
+<main style="padding:24px 28px;max-width:760px;margin:0 auto;text-align:center">
+ <h1 style="font-family:'Cormorant Garamond',serif;font-weight:300;font-size:34px;margin:0 0 6px">Hot or Not</h1>
+ <p style="color:#666;margin:0 0 22px;font-size:13px">Speed-vote on AI-original wallpaper. Arrow ← NOT · Arrow → HOT · Space SKIP.</p>
+ <div id="streak" style="margin-bottom:14px;font-size:12px;color:#d2b15c;font-weight:500"></div>
+ <div id="hon-card" style="position:relative;border:1px solid rgba(0,0,0,.1);border-radius:14px;overflow:hidden;background:#1a1816;box-shadow:0 20px 60px rgba(0,0,0,.18);transition:transform .26s ease, opacity .26s ease">
+ <img id="hon-img" alt="design" style="display:block;width:100%;aspect-ratio:1;object-fit:cover">
+ <div id="hon-meta" style="padding:14px 20px;color:#e8e2d6;text-align:left;display:flex;justify-content:space-between;align-items:center;flex-wrap:wrap;gap:10px">
+ <div>
+ <div id="hon-title" style="font-family:'Cormorant Garamond',serif;font-weight:300;font-size:22px"></div>
+ <div id="hon-sub" style="font-size:11px;color:#888;text-transform:uppercase;letter-spacing:.06em;margin-top:3px"></div>
+ </div>
+ <a id="hon-view" target="_blank" style="font-size:11px;color:#d2b15c;text-decoration:underline">view full →</a>
+ </div>
+ </div>
+ <div id="hon-buttons" style="display:grid;grid-template-columns:1fr 1fr 1fr;gap:12px;margin-top:18px">
+ <button class="hon-btn hon-not" data-action="not">NOT</button>
+ <button class="hon-btn hon-skip" data-action="skip">SKIP</button>
+ <button class="hon-btn hon-hot" data-action="hot">HOT</button>
+ </div>
+ <div id="hon-feed" style="margin-top:30px;text-align:left">
+ <h3 style="font-size:11px;text-transform:uppercase;letter-spacing:.1em;color:#888;margin:0 0 8px">Your last 6 picks</h3>
+ <div id="hon-history" style="display:grid;grid-template-columns:repeat(6,1fr);gap:6px"></div>
+ </div>
+</main>
+${FOOTER}
+<style>
+ .hon-btn { padding:18px 12px; font-size:14px; font-weight:600; letter-spacing:.1em; border:0; border-radius:10px; cursor:pointer; transition:transform .12s, background .15s; }
+ .hon-not { background:#fee; color:#b00; border:1px solid #fbb; }
+ .hon-not:hover { background:#fcc; transform:scale(1.03) }
+ .hon-skip { background:#eee; color:#666; border:1px solid #ddd; }
+ .hon-skip:hover { background:#ddd; transform:scale(1.03) }
+ .hon-hot { background:#d2b15c; color:#1a1a1a; border:1px solid #b89745; }
+ .hon-hot:hover { background:#e0bd64; transform:scale(1.03) }
+ #hon-card.swipe-left { transform:translateX(-120%) rotate(-14deg); opacity:0 }
+ #hon-card.swipe-right { transform:translateX(120%) rotate(14deg); opacity:0 }
+ #hon-card.swipe-up { transform:translateY(-120%); opacity:0 }
+ .hist-card img { width:100%; aspect-ratio:1; object-fit:cover; display:block; border-radius:4px; border:2px solid transparent }
+ .hist-card.hot img { border-color:#d2b15c }
+ .hist-card.not img { border-color:#c44 }
+ .hist-card.skip img { border-color:#888 }
+</style>
+<script>
+var seen = [], history = [];
+var streak = parseInt(localStorage.getItem('wc-streak') || '0', 10);
+var todayKey = 'wc-day-' + new Date().toISOString().slice(0,10);
+var todayCount = parseInt(localStorage.getItem(todayKey) || '0', 10);
+function renderStreak(){ document.getElementById('streak').textContent = '🔥 ' + streak + ' in a row · ' + todayCount + ' votes today'; }
+renderStreak();
+async function load(){
+ var url = '/api/design/random' + (seen.length ? '?exclude=' + seen.slice(-30).join(',') : '');
+ var r = await (await fetch(url)).json();
+ if (!r.id) return;
+ document.getElementById('hon-card').className = '';
+ document.getElementById('hon-img').src = r.image_url;
+ document.getElementById('hon-title').textContent = r.title;
+ document.getElementById('hon-sub').textContent = (r.category||'') + (r.dominant_hex ? ' · ' + r.dominant_hex : '');
+ document.getElementById('hon-view').href = '/design/' + r.id;
+ document.getElementById('hon-card').dataset.id = r.id;
+ document.getElementById('hon-card').dataset.title = r.title;
+ document.getElementById('hon-card').dataset.image = r.image_url;
+ seen.push(r.id);
+}
+async function act(action){
+ var card = document.getElementById('hon-card');
+ var id = parseInt(card.dataset.id, 10);
+ if (!id) return;
+ var anim = action==='hot' ? 'swipe-right' : action==='not' ? 'swipe-left' : 'swipe-up';
+ card.classList.add(anim);
+ if (action === 'hot' || action === 'not') {
+ var score = action === 'hot' ? 5 : 1;
+ fetch('/api/design/' + id + '/user-vote', { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({score: score, quick: true}) });
+ streak = (action === 'hot') ? streak + 1 : 0;
+ localStorage.setItem('wc-streak', String(streak));
+ }
+ todayCount++; localStorage.setItem(todayKey, String(todayCount));
+ renderStreak();
+ history.unshift({id: id, action: action, title: card.dataset.title, image_url: card.dataset.image});
+ history = history.slice(0, 6);
+ document.getElementById('hon-history').innerHTML = history.map(function(h){
+ return '<a class="hist-card '+h.action+'" href="/design/'+h.id+'" title="'+h.title+'"><img src="'+h.image_url+'" alt="'+h.title+'"></a>';
+ }).join('');
+ setTimeout(load, 280);
+}
+document.querySelectorAll('.hon-btn').forEach(function(b){ b.addEventListener('click', function(){ act(b.dataset.action); }); });
+document.addEventListener('keydown', function(e){
+ if (e.key === 'ArrowLeft') act('not');
+ else if (e.key === 'ArrowRight') act('hot');
+ else if (e.key === ' ' || e.key === 'ArrowUp') { e.preventDefault(); act('skip'); }
+});
+load();
+</script>
+${HAMBURGER_JS}
+</body>
+</html>`);
+});
+
+// ── /swipe — Tinder-style drag-to-swipe card stack
+app.get('/swipe', (_req, res) => {
+ res.type('html').send(`${htmlHead({
+ title: 'Swipe — wallco.ai',
+ description: 'Swipe right to love, left to skip, up to super-love. Every gesture teaches the system.',
+ canonical: 'https://wallco.ai/swipe'
+ })}
+<body>
+${htmlHeader('')}
+<main style="padding:18px 16px;max-width:520px;margin:0 auto;text-align:center">
+ <h1 style="font-family:'Cormorant Garamond',serif;font-weight:300;font-size:30px;margin:0 0 4px">Swipe</h1>
+ <p style="color:#666;margin:0 0 14px;font-size:12px">← skip · → love · ↑ super-love · drag the card · or use the buttons.</p>
+ <div id="sw-streak" style="margin-bottom:10px;font-size:12px;color:#d2b15c;font-weight:500"></div>
+ <div id="sw-stack" style="position:relative;height:560px;margin:0 auto;max-width:420px"></div>
+ <div id="sw-buttons" style="display:grid;grid-template-columns:1fr 1fr 1fr;gap:10px;margin-top:14px;max-width:380px;margin-left:auto;margin-right:auto">
+ <button class="sw-btn sw-skip" data-action="left" aria-label="Skip">✕</button>
+ <button class="sw-btn sw-super" data-action="up" aria-label="Super-love">★</button>
+ <button class="sw-btn sw-love" data-action="right" aria-label="Love">♥</button>
+ </div>
+ <div id="sw-toast" style="margin-top:18px;min-height:22px;font-size:13px;color:#666"></div>
+</main>
+${FOOTER}
+<style>
+ .sw-card { position:absolute; inset:0; border-radius:18px; overflow:hidden; background:#1a1816; color:#e8e2d6; box-shadow:0 18px 50px rgba(0,0,0,.22); cursor:grab; user-select:none; transition:transform .26s ease, opacity .26s ease; touch-action:none; }
+ .sw-card.dragging { transition:none; cursor:grabbing; }
+ .sw-card img { display:block; width:100%; aspect-ratio:1; object-fit:cover; pointer-events:none; }
+ .sw-card .meta { padding:12px 18px; display:flex; justify-content:space-between; align-items:center; gap:10px; }
+ .sw-card .title { font-family:'Cormorant Garamond',serif; font-weight:300; font-size:20px; }
+ .sw-card .sub { font-size:10px; color:#888; text-transform:uppercase; letter-spacing:.06em; }
+ .sw-card .stamp { position:absolute; top:30px; padding:8px 18px; border-radius:6px; font-weight:700; font-size:18px; letter-spacing:.12em; opacity:0; transition:opacity .15s; border:3px solid currentColor; }
+ .sw-card .stamp-love { right:24px; color:#d2b15c; transform:rotate(12deg); }
+ .sw-card .stamp-skip { left:24px; color:#c44; transform:rotate(-12deg); }
+ .sw-card .stamp-super { left:50%; top:50%; transform:translate(-50%,-50%); color:#5aa; font-size:24px; }
+ .sw-card.show-love .stamp-love { opacity:1 }
+ .sw-card.show-skip .stamp-skip { opacity:1 }
+ .sw-card.show-super .stamp-super { opacity:1 }
+ .sw-btn { padding:18px; font-size:22px; font-weight:600; border:0; border-radius:50%; aspect-ratio:1; cursor:pointer; transition:transform .12s; }
+ .sw-skip { background:#fee; color:#c44; border:1px solid #fbb; }
+ .sw-super { background:#e7f5f5; color:#5aa; border:1px solid #aee; }
+ .sw-love { background:#d2b15c; color:#1a1a1a; border:1px solid #b89745; }
+ .sw-btn:hover { transform:scale(1.08); }
+</style>
+<script>
+var stack = [], swSeen = [];
+var swStreak = parseInt(localStorage.getItem('wc-sw-streak') || '0', 10);
+var swDayKey = 'wc-sw-day-' + new Date().toISOString().slice(0,10);
+var swToday = parseInt(localStorage.getItem(swDayKey) || '0', 10);
+function renderStreak(){ document.getElementById('sw-streak').textContent = '🔥 ' + swStreak + ' loves in a row · ' + swToday + ' swipes today'; }
+renderStreak();
+
+async function fillStack(){
+ while (stack.length < 3) {
+ var r = await (await fetch('/api/design/random?exclude=' + swSeen.slice(-30).join(','))).json();
+ if (!r.id) break;
+ swSeen.push(r.id);
+ stack.push(r);
+ }
+ renderStack();
+}
+function renderStack(){
+ var s = document.getElementById('sw-stack');
+ s.innerHTML = '';
+ for (var i = Math.min(stack.length-1, 2); i >= 0; i--) {
+ var d = stack[i];
+ var card = document.createElement('div');
+ card.className = 'sw-card';
+ card.dataset.id = d.id;
+ card.style.zIndex = 100 - i;
+ card.style.transform = 'translateY(' + (i * 6) + 'px) scale(' + (1 - i*0.03) + ')';
+ card.innerHTML = '<img src="'+d.image_url+'" alt="'+d.title+'">' +
+ '<div class="meta"><div><div class="title">'+d.title+'</div><div class="sub">'+(d.category||'')+'</div></div>' +
+ '<a href="/design/'+d.id+'" target="_blank" style="font-size:10px;color:#d2b15c;text-decoration:underline">view</a></div>' +
+ '<div class="stamp stamp-love">LOVE</div><div class="stamp stamp-skip">SKIP</div><div class="stamp stamp-super">★ SUPER</div>';
+ s.appendChild(card);
+ if (i === 0) attachDrag(card);
+ }
+ if (stack.length === 0) s.innerHTML = '<div style="padding:40px;color:#888">No more designs in rotation.</div>';
+}
+function attachDrag(card){
+ var startX=0, startY=0, dx=0, dy=0, dragging=false;
+ function onStart(e){ dragging=true; card.classList.add('dragging'); var p = e.touches ? e.touches[0] : e; startX=p.clientX; startY=p.clientY; }
+ function onMove(e){
+ if (!dragging) return;
+ if (e.cancelable) e.preventDefault();
+ var p = e.touches ? e.touches[0] : e;
+ dx = p.clientX - startX; dy = p.clientY - startY;
+ card.style.transform = 'translate(' + dx + 'px,' + dy + 'px) rotate(' + (dx/20) + 'deg)';
+ card.classList.toggle('show-love', dx > 60);
+ card.classList.toggle('show-skip', dx < -60);
+ card.classList.toggle('show-super', dy < -80 && Math.abs(dx) < 100);
+ }
+ function onEnd(){
+ if (!dragging) return;
+ dragging = false; card.classList.remove('dragging');
+ if (dy < -120 && Math.abs(dx) < 140) commit('up');
+ else if (dx > 130) commit('right');
+ else if (dx < -130) commit('left');
+ else { card.style.transform = ''; card.classList.remove('show-love','show-skip','show-super'); }
+ }
+ card.addEventListener('mousedown', onStart);
+ document.addEventListener('mousemove', onMove);
+ document.addEventListener('mouseup', onEnd);
+ card.addEventListener('touchstart', onStart, {passive:false});
+ card.addEventListener('touchmove', onMove, {passive:false});
+ card.addEventListener('touchend', onEnd);
+}
+function commit(direction){
+ var card = document.querySelector('.sw-card');
+ if (!card) return;
+ var id = parseInt(card.dataset.id, 10);
+ var anim = direction==='right' ? 'translateX(140vw) rotate(20deg)'
+ : direction==='left' ? 'translateX(-140vw) rotate(-20deg)' : 'translateY(-140vh)';
+ card.style.transform = anim; card.style.opacity = 0;
+ var score = direction==='left' ? 1 : 5;
+ var quick = direction !== 'up';
+ fetch('/api/design/' + id + '/user-vote', { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({score: score, quick: quick}) });
+ if (direction === 'right' || direction === 'up') {
+ swStreak++;
+ document.getElementById('sw-toast').textContent = direction==='up' ? '★ Super-love recorded' : '♥ Love recorded · streak ' + swStreak;
+ } else { swStreak = 0; document.getElementById('sw-toast').textContent = '✕ Skipped'; }
+ localStorage.setItem('wc-sw-streak', String(swStreak));
+ swToday++; localStorage.setItem(swDayKey, String(swToday));
+ renderStreak();
+ stack.shift();
+ setTimeout(function(){ fillStack(); }, 260);
+}
+document.querySelectorAll('.sw-btn').forEach(function(b){ b.addEventListener('click', function(){ commit(b.dataset.action); }); });
+document.addEventListener('keydown', function(e){
+ if (e.key === 'ArrowLeft') commit('left');
+ else if (e.key === 'ArrowRight') commit('right');
+ else if (e.key === 'ArrowUp') { e.preventDefault(); commit('up'); }
+});
+fillStack();
+</script>
+${HAMBURGER_JS}
+</body>
+</html>`);
+});
+
// ── HEALTH
app.get('/health', (_req, res) => {
res.json({ ok: true, site: SITE, count: DESIGNS.length, port: PORT });
diff --git a/src/review.js b/src/review.js
index 3a2ff7c..5f88b0a 100644
--- a/src/review.js
+++ b/src/review.js
@@ -16,6 +16,7 @@
* POST /api/pairings/:id/refresh — force regenerate
*/
'use strict';
+const { requireAdmin } = require('./admin-gate');
const fs = require('fs');
const path = require('path');
@@ -322,12 +323,12 @@ function mount(app, getDesigns) {
if (!fs.existsSync(MOODBOARD_FILE)) saveJSON(MOODBOARD_FILE, {});
if (!fs.existsSync(VISION_FILE)) saveJSON(VISION_FILE, {});
- app.get('/api/review/:id', (req, res) => {
+ app.get('/api/review/:id', requireAdmin, (req, res) => {
const reviews = loadJSON(REVIEWS_FILE, {});
res.json(reviews[req.params.id] || null);
});
- app.post('/api/review/:id', async (req, res) => {
+ app.post('/api/review/:id', requireAdmin, async (req, res) => {
const id = req.params.id;
const designs = getDesigns();
const design = designs.find(d => String(d.id) === String(id));
@@ -353,20 +354,20 @@ function mount(app, getDesigns) {
res.json(reviews[id]);
});
- app.get('/api/chips/:id', (req, res) => {
+ app.get('/api/chips/:id', requireAdmin, (req, res) => {
const designs = getDesigns();
const design = designs.find(d => String(d.id) === String(req.params.id));
if (!design) return res.status(404).json({ error: 'design not found' });
res.json({ design_id: design.id, chips: generateChips(design) });
});
- app.get('/api/chip/:id/:chipKey/chat', (req, res) => {
+ app.get('/api/chip/:id/:chipKey/chat', requireAdmin, (req, res) => {
const chats = loadJSON(CHATS_FILE, {});
const key = `${req.params.id}:${req.params.chipKey}`;
res.json({ key, history: chats[key] || [] });
});
- app.post('/api/chip/:id/:chipKey/chat', async (req, res) => {
+ app.post('/api/chip/:id/:chipKey/chat', requireAdmin, async (req, res) => {
const id = req.params.id, chipKey = req.params.chipKey;
const designs = getDesigns();
const design = designs.find(d => String(d.id) === String(id));
@@ -398,12 +399,12 @@ function mount(app, getDesigns) {
res.json({ reply, history });
});
- app.get('/api/reviews/all', (req, res) => {
+ app.get('/api/reviews/all', requireAdmin, (req, res) => {
res.json(loadJSON(REVIEWS_FILE, {}));
});
// GET cached pairings — generate-on-miss
- app.get('/api/pairings/:id', async (req, res) => {
+ app.get('/api/pairings/:id', requireAdmin, async (req, res) => {
const id = req.params.id;
const designs = getDesigns();
const design = designs.find(d => String(d.id) === String(id));
@@ -417,7 +418,7 @@ function mount(app, getDesigns) {
});
// POST refresh — invalidate cache (both pairings + vision)
- app.post('/api/pairings/:id/refresh', async (req, res) => {
+ app.post('/api/pairings/:id/refresh', requireAdmin, async (req, res) => {
const id = req.params.id;
const designs = getDesigns();
const design = designs.find(d => String(d.id) === String(id));
@@ -432,7 +433,7 @@ function mount(app, getDesigns) {
});
// Moodboard: pin/unpin a suggestion.
- app.post('/api/moodboard/:id/pin', (req, res) => {
+ app.post('/api/moodboard/:id/pin', requireAdmin, (req, res) => {
const id = req.params.id;
const { pin_key, suggestion } = req.body || {};
if (!pin_key) return res.status(400).json({ error: 'pin_key required' });
@@ -457,18 +458,18 @@ function mount(app, getDesigns) {
});
// GET moodboard for one design
- app.get('/api/moodboard/:id', (req, res) => {
+ app.get('/api/moodboard/:id', requireAdmin, (req, res) => {
const moodboards = loadJSON(MOODBOARD_FILE, {});
res.json({ design_id: req.params.id, items: moodboards[req.params.id] || [] });
});
// GET all moodboards (admin)
- app.get('/api/moodboards/all', (req, res) => {
+ app.get('/api/moodboards/all', requireAdmin, (req, res) => {
res.json(loadJSON(MOODBOARD_FILE, {}));
});
// CSV export — kept designs + scores + verdict + pinned pairings
- app.get('/api/export/keep.csv', (req, res) => {
+ app.get('/api/export/keep.csv', requireAdmin, (req, res) => {
const reviews = loadJSON(REVIEWS_FILE, {});
const moodboards = loadJSON(MOODBOARD_FILE, {});
const designs = getDesigns();
@@ -502,7 +503,7 @@ function mount(app, getDesigns) {
});
// CSV export — all reviewed designs (any decision)
- app.get('/api/export/all.csv', (req, res) => {
+ app.get('/api/export/all.csv', requireAdmin, (req, res) => {
const reviews = loadJSON(REVIEWS_FILE, {});
const moodboards = loadJSON(MOODBOARD_FILE, {});
const designs = getDesigns();
@@ -533,6 +534,10 @@ function mount(app, getDesigns) {
function csvCell(s) {
s = String(s == null ? '' : s);
+ // CSV formula-injection guard (CVE class OWASP A03): cells whose first
+ // character is =/+/-/@/<TAB>/<CR> are interpreted as formulas by Excel and
+ // Sheets. Prefix with apostrophe to neutralize while keeping value readable.
+ if (s.length && /^[=+\-@\t\r]/.test(s)) s = "'" + s;
if (s.includes(',') || s.includes('"') || s.includes('\n')) {
return '"' + s.replace(/"/g, '""') + '"';
}
diff --git a/tests/integration/admin-gate.spec.js b/tests/integration/admin-gate.spec.js
index 251ec70..d8e158b 100644
--- a/tests/integration/admin-gate.spec.js
+++ b/tests/integration/admin-gate.spec.js
@@ -35,24 +35,31 @@ test('A-1 · /age-themes without ?review=1 and Host: wallco.ai returns 404', asy
expect(response.status()).toBe(404);
});
-test('A-2 · /age-themes?review=1 returns 200 with page content', async ({ page }) => {
- // waitUntil:'commit' fires as soon as the HTTP response is received —
- // avoids blocking on external font CDN resources.
- const response = await page.goto('/age-themes?review=1', { waitUntil: 'commit', timeout: 15_000 });
+test('A-2 · /age-themes via 127.0.0.1 (localhost) returns 200 with page content', async ({ page }) => {
+ // Updated 2026-05-12 — the previous version asserted that ?review=1 alone
+ // grants admin. That was a CRITICAL security bug (per security-auditor agent
+ // 2026-05-12). ?review=1 is now ignored; localhost or signed JWT required.
+ const response = await page.goto('/age-themes', { waitUntil: 'commit', timeout: 15_000 });
expect(response.status()).toBe(200);
-
- // Wait for DOM to be interactive before querying.
await page.waitForLoadState('domcontentloaded');
-
const hasContent = await page.evaluate(() =>
document.querySelectorAll('h1, section.band-row').length > 0
);
expect(hasContent).toBe(true);
});
+test('A-2b · /age-themes?review=1 from public host is no longer a bypass (404)', async ({ page }) => {
+ // Regression guard: the ?review=1 admin bypass was closed 2026-05-12.
+ const response = await page.request.fetch('/age-themes?review=1', {
+ headers: { Host: 'wallco.ai' },
+ timeout: 10_000,
+ });
+ expect(response.status()).toBe(404);
+});
+
// ── /moodboard ────────────────────────────────────────────────────────────────
-test('A-3 · /moodboard without ?review=1 and Host: wallco.ai returns 404', async ({ page }) => {
+test('A-3 · /moodboard with Host: wallco.ai returns 404 (no cookie)', async ({ page }) => {
const response = await page.request.fetch('/moodboard', {
headers: { Host: 'wallco.ai' },
timeout: 10_000,
@@ -60,7 +67,24 @@ test('A-3 · /moodboard without ?review=1 and Host: wallco.ai returns 404', asyn
expect(response.status()).toBe(404);
});
-test('A-4 · /moodboard?review=1 returns 200', async ({ page }) => {
- const response = await page.goto('/moodboard?review=1', { waitUntil: 'commit', timeout: 15_000 });
+test('A-4 · /moodboard via 127.0.0.1 returns 200', async ({ page }) => {
+ const response = await page.goto('/moodboard', { waitUntil: 'commit', timeout: 15_000 });
expect(response.status()).toBe(200);
});
+
+test('A-5 · /moodboard?review=1 from public host is no longer a bypass (404)', async ({ page }) => {
+ const response = await page.request.fetch('/moodboard?review=1', {
+ headers: { Host: 'wallco.ai' },
+ timeout: 10_000,
+ });
+ expect(response.status()).toBe(404);
+});
+
+test('A-6 · /api/reviews/all from public host requires admin (404)', async ({ page }) => {
+ // Was unauthenticated entirely before 2026-05-12; now gated by requireAdmin.
+ const response = await page.request.fetch('/api/reviews/all', {
+ headers: { Host: 'wallco.ai' },
+ timeout: 10_000,
+ });
+ expect(response.status()).toBe(404);
+});
← 4541eb0 tick 14: cool/pale palette batch + snapshot refresh — catalo
·
back to Wallco Ai
·
Close security CRITICAL findings from 2026-05-12 audit — str 38ac947 →