← back to CelebritySignatures

server.js

1227 lines

#!/usr/bin/env node
// Zero-dependency server for the CelebritySignatures grid + murals storefront.
// Adds lightweight email/password accounts (scrypt + cookie sessions, JSON-backed)
// so a visitor can SAVE a mural placement they set in the wall studio.
import { createServer } from 'node:http';
import { readFile, writeFile, appendFile, mkdir, unlink } from 'node:fs/promises';
import { extname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { scryptSync, randomBytes, timingSafeEqual, createHash, sign } from 'node:crypto';
import { readFileSync } from 'node:fs';
import { spawn } from 'node:child_process';

const ROOT = fileURLToPath(new URL('.', import.meta.url));
const PORT = process.env.PORT || 9920;
const DATA = join(ROOT, 'data');
const TYPES = {
  '.html': 'text/html', '.js': 'text/javascript', '.css': 'text/css',
  '.json': 'application/json', '.svg': 'image/svg+xml',
  '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg',
  '.webp': 'image/webp', '.gif': 'image/gif', '.ico': 'image/x-icon',
};

// ---- tiny JSON store --------------------------------------------------------
async function load(name, fallback) { try { return JSON.parse(await readFile(join(DATA, name), 'utf8')); } catch { return fallback; } }
async function store(name, val) { await writeFile(join(DATA, name), JSON.stringify(val, null, 2)); }
function readBody(req) {
  return new Promise((resolve, reject) => {
    let raw = '';
    req.on('data', c => { raw += c; if (raw.length > 2e5) reject(new Error('too large')); });
    req.on('end', () => { try { resolve(raw ? JSON.parse(raw) : {}); } catch { reject(new Error('bad json')); } });
    req.on('error', reject);
  });
}
function sendJSON(res, code, obj, headers = {}) { res.writeHead(code, { 'Content-Type': 'application/json', ...headers }); res.end(JSON.stringify(obj)); }
// larger body reader for signature uploads (base64 data URLs; ~4MB cap)
function readBodyBig(req) {
  return new Promise((resolve, reject) => {
    let raw = '';
    req.on('data', c => { raw += c; if (raw.length > 4.5e6) reject(new Error('too large')); });
    req.on('end', () => { try { resolve(raw ? JSON.parse(raw) : {}); } catch { reject(new Error('bad json')); } });
    req.on('error', reject);
  });
}

// ---- celebrity signature uploads (owned, tracked, NEVER shared raw) ---------
// Files live in data/uploads-private/ (blocked from static serving); the ONLY
// way a file leaves the server is the tracked /api/signature-file endpoint,
// which appends a commission entry to data/download-ledger.jsonl per download.
const UPLOADS_DIR = join(DATA, 'uploads-private');
const LB_HITS = new Map(); // per-IP leaderboard POST timestamps (rate limit)
// Stripe key resolver. TEST by default. LIVE (real money) is used ONLY when
// BOTH STRIPE_LIVE_ENABLED=1 AND a sk_live_ key are present — the deliberate
// Steve-gated go-live switch. Default (flag unset) = test mode, so real cards
// are never charged unless Steve explicitly turns it on.
function envVal(name) {
  if (process.env[name]) return process.env[name];
  try {
    const m = readFileSync(new URL('.env', import.meta.url), 'utf8').match(new RegExp('^' + name + '=(.+)$', 'm'));
    if (m) return m[1].trim().replace(/^["']|["']$/g, '');
  } catch {}
  return null;
}
const STRIPE_LIVE_ENABLED = envVal('STRIPE_LIVE_ENABLED') === '1';
const _testKey = (() => { const k = envVal('STRIPE_TEST_SECRET_KEY'); return k && k.startsWith('sk_test_') ? k : null; })();
const _liveKey = (() => { const k = envVal('STRIPE_LIVE_SECRET_KEY'); return k && /^(sk|rk)_live_/.test(k) ? k : null; })(); // rk_ = a scoped restricted live key (least-privilege)
const STRIPE_LIVE = STRIPE_LIVE_ENABLED && _liveKey;      // real-money mode active?
// PER-FLOW keys (Steve 2026-08-04: "go live on murals only, keep downloads in
// test until payouts exist"). MURALS charge real cards once the live switch is
// on; DOWNLOADS stay pinned to the TEST key until a celebrity-payout mechanism
// exists — so flipping the live switch can NEVER accidentally take real money
// for a download whose owner we cannot yet pay their commission.
const STRIPE_MURAL_KEY = STRIPE_LIVE ? _liveKey : _testKey;
const STRIPE_DOWNLOAD_KEY = _testKey;
const STRIPE_MODE = STRIPE_LIVE ? 'live' : 'test';
// WEAR gets its OWN live switch, deliberately separate from STRIPE_LIVE_ENABLED
// (Steve 2026-09-09, TK-10286): murals/downloads already flipped the shared
// flag live for an unrelated feature, so if wear reused STRIPE_MURAL_KEY, the
// moment WEAR_SALES_LIVE=1 landed it would charge REAL cards with zero chance
// to place a test order first. WEAR_STRIPE_LIVE_ENABLED lets Steve turn
// everything else about /wear on (Printify mapped, checkout functional, cart
// working) while still charging the TEST key — then flip this ONE additional
// switch, independently, once a real end-to-end test order looks right.
const WEAR_STRIPE_LIVE_ENABLED = envVal('WEAR_STRIPE_LIVE_ENABLED') === '1';
const WEAR_STRIPE_LIVE = WEAR_STRIPE_LIVE_ENABLED && _liveKey;
const WEAR_STRIPE_KEY = WEAR_STRIPE_LIVE ? _liveKey : _testKey;
const WEAR_STRIPE_MODE = WEAR_STRIPE_LIVE ? 'live' : 'test';
if (STRIPE_LIVE) console.log('⚠️  STRIPE LIVE MODE — murals charge REAL cards; downloads stay TEST'); else if (_testKey) console.log('Stripe test mode active (murals + downloads)');
if (WEAR_STRIPE_LIVE) console.log('⚠️  WEAR STRIPE LIVE MODE — apparel charges REAL cards'); else if (_testKey) console.log('Wear apparel: Stripe test mode (independent of the murals live flag)');
// ---- Google AdSense (Auto ads) ---------------------------------------------
// Driven by ONE env var, ADSENSE_PUB_ID (stored as `pub-XXXXXXXXXXXXXXXX`; a
// leading `ca-` is tolerated). When set, the loader is injected into every HTML
// response and /ads.txt is served; when unset, everything no-ops so nothing
// broken renders. Auto ads means Google places units itself — no per-slot IDs.
const ADSENSE_PUB_ID = (envVal('ADSENSE_PUB_ID') || '').trim().replace(/^ca-/, '');
const ADSENSE_TAG = /^pub-\d{10,}$/.test(ADSENSE_PUB_ID)
  ? `<script async src="https://pagead2.googlesyndication.com/pagead/js/adsbygoogle.js?client=ca-${ADSENSE_PUB_ID}" crossorigin="anonymous"></script>`
  : '';
// Hand-placed reserved ad ZONES (better fill than Auto ads alone on a dense
// grid). Each zone stays hidden — zero layout impact — until its per-zone slot
// id is set via env (post-approval: create the units in AdSense → paste the
// data-ad-slot numbers here). assets/ads.js reads this config and activates
// only the zones that have a real slot id, reserving height to avoid CLS.
const AD_SLOTS = { gallery: (envVal('ADSENSE_SLOT_GALLERY') || '').trim(), article: (envVal('ADSENSE_SLOT_ARTICLE') || '').trim(), game: (envVal('ADSENSE_SLOT_GAME') || '').trim() };
const ADSENSE_CONFIG = ADSENSE_TAG
  ? `<script>window.__ADPUB=${JSON.stringify('ca-' + ADSENSE_PUB_ID)};window.__ADSLOTS=${JSON.stringify(AD_SLOTS)};</script><script src="/assets/ads.js" defer></script>`
  : '';
const _injectAdTags = html => (ADSENSE_TAG && typeof html === 'string' && html.includes('</head>') && !html.includes('adsbygoogle.js'))
  ? html.replace('</head>', ADSENSE_TAG + ADSENSE_CONFIG + '\n</head>') : html;
// Site-wide footer — injected before </body> on every HTML response so the
// AdSense-required Privacy Policy link is discoverable on every page (Google
// rejects sites whose policy isn't linked). Kept in the ad chokepoint so all
// four page-serving call sites inherit it with no per-site edits.
const FOOTER = '<footer class="site-footer" style="margin:40px 0 0;padding:22px 16px;border-top:1px solid #e6e3dc;background:#f7f5f0;color:#6b6b6b;font:400 13px/1.7 -apple-system,BlinkMacSystemFont,\'Segoe UI\',Roboto,sans-serif;text-align:center">'
  + '<div>© 2026 CelebritySignatures · celebsignatures.com</div>'
  + '<nav style="margin-top:6px"><a href="/" style="color:#6b6b6b;text-decoration:none">Home</a> · '
  + '<a href="/game" style="color:#6b6b6b;text-decoration:none">Game</a> · '
  + '<a href="/wallpaper" style="color:#6b6b6b;text-decoration:none">Wallpaper</a> · '
  + '<a href="/privacy" style="color:#6b6b6b;text-decoration:none">Privacy Policy</a> · '
  + '<a href="mailto:info@designerwallcoverings.com" style="color:#6b6b6b;text-decoration:none">Contact</a></nav></footer>';
const injectFooter = html => (typeof html === 'string' && html.includes('</body>') && !html.includes('site-footer'))
  ? html.replace('</body>', FOOTER + '\n</body>') : html;
const injectAds = html => injectFooter(_injectAdTags(html));
if (ADSENSE_TAG) { const live = Object.entries(AD_SLOTS).filter(([, v]) => v).map(([k]) => k); console.log(`AdSense active (${ADSENSE_PUB_ID}) — reserved zones live: ${live.length ? live.join(', ') : 'none yet (awaiting slot ids)'}`); }
// Atomic in-process guard against concurrent double-credit for the same paid session
// (claimed synchronously before any await; the download-ledger is the restart-surviving backstop).
const USED_SIDS = new Set();

// Merged signatures feed (used by /api/signatures, the crawlable /a/:qid pages,
// and the sitemap). The server-side occupation gate keeps the Artists category
// clean regardless of what a build/merge pipeline writes to artists.json.
const qidOf = r => (r.wikidata || '').split('/').pop();
const ART_OCC = /(paint|sculpt|printmak|draughts|illustrat|photograph|architect|designer|engrav|\bartist\b|animat|ceramic|etcher|watercolo|muralist|lithograph|graphic|calligraph|craft|goldsmith|jewel|potter|weav|textile|cartoonist)/;
const ART_OCC_EXCLUDE = /(fashion designer|costume designer|couturier|game designer|sound designer|web designer)/;
const isArtOcc = o => { const s = String(o).toLowerCase(); return ART_OCC.test(s) && !ART_OCC_EXCLUDE.test(s); };
let _sigCache = null, _sigCacheAt = 0;
// Permanently excluded from EVERY surface (feed, grid, wear, sitemap) — brand safety (Steve 2026-09-09).
const SIG_EXCLUDE_QIDS = new Set(['Q352']); // Q352 = Adolf Hitler
async function mergedSignatures() {
  if (_sigCache && Date.now() - _sigCacheAt < 60000) return _sigCache; // 60s cache
  const base = await load('celebrity_signatures.json', []);
  const artists = await load('artists.json', []);
  const authors = await load('authors.json', []);
  const raw = await load('artists-raw.json', []);
  const artistQids = new Set(raw.filter(r => (r.occupations || []).some(isArtOcc)).map(qidOf));
  const cleanArtists = raw.length ? artists.filter(a => artistQids.has(qidOf(a))) : artists;
  const seenQ = new Set([...base.map(qidOf), ...cleanArtists.map(qidOf)]);
  const cleanAuthors = authors.filter(a => !seenQ.has(qidOf(a)));
  _sigCache = [...base.filter(r => r.category !== 'Artists' && r.category !== 'Authors'), ...cleanArtists, ...cleanAuthors].filter(r => !SIG_EXCLUDE_QIDS.has(qidOf(r)));
  _sigCacheAt = Date.now();
  return _sigCache;
}
// ---- /wear: trademark-cleared signature apparel ----------------------------
// A signature may be offered on apparel ONLY if it is LEGALLY CLEAR (Steve, hard
// rule 2026-08-06): commercially usable, not high-risk, AND its name returned a
// CLEAR trademark verdict against a COMPLETE screen of the USPTO register. A
// seed-only / partial index never makes anything sellable — absence of a match is
// not trust; it must be an affirmative clear from real register data.
let _tmCache = null, _tmCacheAt = 0;
async function tmClearance() {
  if (_tmCache && Date.now() - _tmCacheAt < 60000) return _tmCache;
  _tmCache = await load('tm-clearance.json', { indexComplete: false, indexSource: 'none', verdicts: {}, tally: {} });
  _tmCacheAt = Date.now();
  return _tmCache;
}
let _wearTplCache = null, _wearTplAt = 0;
async function wearTemplates() {
  if (_wearTplCache && Date.now() - _wearTplAt < 60000) return _wearTplCache;
  _wearTplCache = await load('wear-templates.json', { garments: [] });
  _wearTplAt = Date.now();
  return _wearTplCache;
}
const WEAR_POD_PROVIDERS = new Set(['printful', 'printify']);
function wearPodProvider() {
  const provider = String(envVal('WEAR_POD_PROVIDER') || 'printful').toLowerCase();
  return WEAR_POD_PROVIDERS.has(provider) ? provider : null;
}
function wearPodReady(tpl, provider) {
  if (!provider) return false;
  const credential = provider === 'printify' ? envVal('PRINTIFY_API_TOKEN') : envVal('PRINTFUL_API_KEY');
  if (!credential) return false;
  // Printify's sender (scripts/submit-pod-draft-printify.mjs) refuses to POST
  // unless it ALSO has a shop id + an explicit manual-approval acknowledgement
  // (5 gates total). Checkout readiness must mirror that, or a paid order can
  // sit forever as a DRAFT_UNSENT the sender will never touch.
  if (provider === 'printify') {
    if (!envVal('PRINTIFY_SHOP_ID')) return false;
    if (envVal('PRINTIFY_MANUAL_APPROVAL_CONFIRMED') !== '1') return false;
  }
  const offered = (tpl.garments || []).filter(g => g.saleEnabled !== false);
  return offered.length > 0 && offered.every(g => {
    const mapping = g[provider] || {};
    if (provider === 'printify' && (!mapping.blueprintId || !mapping.printProviderId)) return false;
    return (g.colors || []).every(c => (g.sizes || []).every(size => mapping.variants?.[`${c.id}/${size}`]));
  });
}
// v1 (2026-08-10, Steve GO — TK-10286): the USPTO live-trademark auto-screen is DEFERRED.
// A signature is sellable on apparel only if it is a clearly PUBLIC-DOMAIN HISTORICAL figure
// — deceased on/before WEAR_PD_DEATH_CUTOFF — where there is no live apparel-trademark or
// estate-licensing risk (Jefferson, Franklin, da Vinci…). Living / modern figures stay
// excluded. When the USPTO ODP key lands, swap wearEligible → wearEligibleTM below.
const WEAR_PD_DEATH_CUTOFF = 1900;
const wearDeathYear = sig => { const y = parseInt(String((sig && sig.death_date) || '').slice(0, 4), 10); return isNaN(y) ? null : y; };
// The single source of truth for "can this signature be sold on a shirt?"
function wearEligible(sig, clr) {
  if (!sig || sig.usable_in_commercial_collage !== 'yes') return false;
  if (sig.risk_level === 'high') return false;            // safe + medium only
  const dy = wearDeathYear(sig);
  return dy !== null && dy <= WEAR_PD_DEATH_CUTOFF;        // public-domain historical only
}
// Preserved for when the USPTO ODP key lands — the stricter live-register screen.
function wearEligibleTM(sig, clr) {
  if (!sig || sig.usable_in_commercial_collage !== 'yes' || sig.risk_level === 'high') return false;
  if (!clr || clr.indexComplete !== true) return false;
  const v = clr.verdicts[sig.full_name];
  return !!v && v.verdict === 'CLEAR';
}
async function wearSignatures() {
  const [sigs, clr] = await Promise.all([mergedSignatures(), tmClearance()]);
  return sigs.filter(s => s.signature_image_url && wearEligible(s, clr));
}
const esc = s => String(s == null ? '' : s).replace(/[&<>"]/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c]));
const DEFAULT_PRICE_USD = 20;           // download price (payments wired later — Steve-gated)
const DEFAULT_COMMISSION_PCT = 50;      // owner's cut per download
const WEAR_GARMENT_DEFAULT_PRICE_USD = 34; // fallback garment price when template omits priceUsd
const WEAR_ORDER_ID_SEED = 5000;        // wear orders start above 5000 to avoid colliding with mural/download IDs
const WEAR_CART_MAX_ITEMS = 20;         // sane cap on a single checkout — not a real limit concern, just guards against abuse
// Orders placed before the cart feature (2026-09-08) stored one item's fields
// directly on the order (qid/garment/color/size/...); the cart shape stores
// an items[] array instead. This normalizes either shape to items[] so
// /wear-success and the reconciliation script don't need two code paths.
function wearOrderItems(order) {
  if (Array.isArray(order.items)) return order.items;
  if (!order.qid) return [];
  return [{ qid: order.qid, signature_name: order.signature_name, garment: order.garment, garment_label: order.garment_label,
    color: order.color, color_label: order.color_label, size: order.size, placement: order.placement }];
}
// Mirrors public/wear.html's client-side luminance() — decides whether a
// garment color is dark enough that the source signature art (dark ink)
// needs recoloring to a light ink before it's actually printed.
const wearLuminance = hex => {
  const n = parseInt(hex.slice(1), 16), r = (n>>16)&255, g = (n>>8)&255, b = n&255;
  return (0.299*r + 0.587*g + 0.114*b) / 255;
};
// ---- per-signer ink color (Node port of public/assets/signature-ink.js) -----
// MUST stay in lockstep with the client inkColorForHue: same FNV-1a hash, same
// HSL, same WCAG-3:1-on-white contrast guarantee — so the color baked into the
// PRINTED art matches what the buyer saw in the on-shirt preview and the swatch.
const _hslToRgb = (h, s, l) => {
  if (s === 0) { const v = Math.round(l*255); return [v,v,v]; }
  const q = l<0.5 ? l*(1+s) : l+s-l*s, p = 2*l-q;
  const hk = t => { t=(t%1+1)%1;
    if (t<1/6) return p+(q-p)*6*t;
    if (t<1/2) return q;
    if (t<2/3) return p+(q-p)*(2/3-t)*6;
    return p; };
  return [Math.round(hk(h+1/3)*255), Math.round(hk(h)*255), Math.round(hk(h-1/3)*255)];
};
const _relLuminance = ([r,g,b]) => {
  const f = c => { c/=255; return c<=0.03928 ? c/12.92 : ((c+0.055)/1.055)**2.4; };
  return 0.2126*f(r)+0.7152*f(g)+0.0722*f(b);
};
const INK_MAX_LUMINANCE = 0.26;
const _rgbToHex = ([r,g,b]) => '#'+[r,g,b].map(v=>v.toString(16).padStart(2,'0')).join('');
function inkColorHexFor(key) {
  let h = 2166136261; const s = String(key||'');
  for (let i=0;i<s.length;i++){ h^=s.charCodeAt(i); h=Math.imul(h,16777619); }
  const hue = (h>>>0)%360;
  let l = 0.42, rgb = _hslToRgb(hue/360, 0.62, l);
  while (_relLuminance(rgb) > INK_MAX_LUMINANCE && l > 0.14) { l -= 0.02; rgb = _hslToRgb(hue/360, 0.62, l); }
  return _rgbToHex(rgb);
}
// Sanitize a client-supplied ink color before it can reach ImageMagick's -fill;
// only a literal #rrggbb is allowed, anything else falls back to the auto color.
const safeInkHex = v => (typeof v === 'string' && /^#[0-9a-fA-F]{6}$/.test(v)) ? v.toLowerCase() : null;
const RECOLOR_DIR = join(ROOT, 'public', 'assets', 'recolored-signatures');
const WEAR_SITE_ORIGIN = 'https://celebsignatures.com';
// Real print fix for the KNOWN ISSUE noted in wear-templates.json: Printify
// prints a design's own pixel colors as-is (no auto-recolor for dark
// garments), so a dark-ink signature on a dark colorway (e.g. the polo's
// black) would print near-invisible. For orders on a dark colorway, recolor
// the signature to a light ink — mirroring the on-site preview's own
// recolorToLight() — via ImageMagick (already on this box for other
// scripts), cached by URL hash so a re-ordered signature isn't reprocessed.
// FAILS OPEN: any error (magick missing, fetch failure, timeout) falls back
// to the original image rather than blocking order/draft creation — a
// slightly-low-contrast print is a quality issue, not a reason to lose the
// customer's order record.
async function recolorSignatureInk(imageUrl, hex) {
  const ink = safeInkHex(hex) || '#f5f3ee';   // fall back to the old light ink
  try {
    const hash = createHash('sha256').update(imageUrl + '|' + ink).digest('hex').slice(0, 24);
    const outPath = join(RECOLOR_DIR, `${hash}.png`);
    // FULLY QUALIFIED — this URL gets set as design_image_url on the draft,
    // which flows straight into the Printify order payload as images[0].src;
    // Printify fetches it from the public internet, so a relative path would
    // fail there (caught via a2a from codex-run-10286, 2026-09-09).
    const outUrl = `${WEAR_SITE_ORIGIN}/assets/recolored-signatures/${hash}.png`;
    try { await readFile(outPath); return outUrl; } catch {} // cache hit
    const src = await fetch(imageUrl);
    if (!src.ok) throw new Error(`fetch ${src.status}`);
    const buf = Buffer.from(await src.arrayBuffer());
    const out = await new Promise((resolve, reject) => {
      // 'convert' not 'magick': the Kamatera prod box only has ImageMagick 6
      // (convert), not the IMv7 'magick' unified CLI — verified via SSH.
      // 'convert' also exists on Mac2 (IMv7's back-compat shim, just prints a
      // deprecation warning to stderr, which this doesn't treat as failure).
      const proc = spawn('convert', ['-background', 'none', '-', '-channel', 'RGB', '-fill', ink, '-colorize', '100%', 'png:-']);
      const chunks = []; let errText = '';
      const timer = setTimeout(() => { proc.kill(); reject(new Error('magick timeout')); }, 8000);
      proc.stdout.on('data', c => chunks.push(c));
      proc.stderr.on('data', c => errText += c);
      proc.on('error', e => { clearTimeout(timer); reject(e); });
      proc.on('close', code => { clearTimeout(timer); code === 0 ? resolve(Buffer.concat(chunks)) : reject(new Error(errText || `magick exit ${code}`)); });
      proc.stdin.end(buf);
    });
    await mkdir(RECOLOR_DIR, { recursive: true });
    await writeFile(outPath, out);
    return outUrl;
  } catch (e) {
    console.error('recolorSignatureInk failed, using original image:', e.message);
    return imageUrl;
  }
}
async function adminToken() {
  // Bootstrap a local admin token on first use (data/admin-token.txt, gitignored
  // + deploy-protected). Steve reads it from the file to approve uploads.
  try { return (await readFile(join(DATA, 'admin-token.txt'), 'utf8')).trim(); }
  catch {
    const t = randomBytes(18).toString('hex');
    await writeFile(join(DATA, 'admin-token.txt'), t + '\n');
    console.log('upload admin token created → data/admin-token.txt');
    return t;
  }
}

// ---- auth helpers -----------------------------------------------------------
const COOKIE = 'cs_sess';
function hashPw(pw, salt) { return scryptSync(pw, salt, 64).toString('hex'); }
function verifyPw(pw, salt, hash) {
  const a = Buffer.from(hashPw(pw, salt), 'hex'), b = Buffer.from(hash, 'hex');
  return a.length === b.length && timingSafeEqual(a, b);
}
function parseCookies(req) {
  const out = {}; (req.headers.cookie || '').split(';').forEach(p => { const i = p.indexOf('='); if (i > 0) out[p.slice(0, i).trim()] = decodeURIComponent(p.slice(i + 1).trim()); });
  return out;
}
async function currentUser(req) {
  const sessions = await load('sessions.json', {});
  const tok = parseCookies(req)[COOKIE];
  const s = tok && sessions[tok];
  if (!s) return null;
  const users = await load('users.json', []);
  return users.find(u => u.id === s.userId) || null;
}
async function newSession(userId) {
  const sessions = await load('sessions.json', {});
  const tok = randomBytes(24).toString('hex');
  sessions[tok] = { userId, at: new Date().toISOString() };
  await store('sessions.json', sessions);
  return tok;
}
const setCookie = tok => `${COOKIE}=${tok}; HttpOnly; Path=/; SameSite=Lax; Max-Age=2592000`;
const clearCookie = `${COOKIE}=; HttpOnly; Path=/; Max-Age=0`;
const pubUser = u => ({ email: u.email, name: u.name || '' });

// ---- Sign in with Apple (web OAuth bridged into the app's WebView) ----------
// Celeb's iOS app is a hybrid WebView; Apple/Google reject OAuth INSIDE an
// embedded WebView, so the app runs sign-in in ASWebAuthenticationSession and
// replays a one-time token at /auth/handoff so the cs_sess cookie lands in the
// WebView's jar (the proven Charge & Explore pattern). Fully DORMANT until the
// Apple Services ID + sign-in key are configured — /api/auth-config reports it,
// and the site hides the button until then, so nothing shows broken.
const APPLE_TEAM_ID = process.env.APPLE_TEAM_ID || '';
const APPLE_SERVICES_ID = process.env.APPLE_SERVICES_ID || ''; // e.g. com.abrams.celebsignatures.signin
const APPLE_KEY_ID = process.env.APPLE_KEY_ID || '';
const APPLE_PRIVATE_KEY = process.env.APPLE_PRIVATE_KEY
  || (process.env.APPLE_PRIVATE_KEY_PATH
    ? (() => { try { return readFileSync(process.env.APPLE_PRIVATE_KEY_PATH, 'utf8'); } catch { return ''; } })()
    : '');
const APPLE_REDIRECT_URI = process.env.APPLE_REDIRECT_URI || 'https://celebsignatures.com/auth/apple/callback';
const APPLE_CONFIGURED = !!(APPLE_TEAM_ID && APPLE_SERVICES_ID && APPLE_KEY_ID && APPLE_PRIVATE_KEY);
const APP_SCHEME = 'celebsignatures';
const b64u = s => Buffer.from(s).toString('base64').replace(/=/g, '').replace(/\+/g, '-').replace(/\//g, '_');

// Apple client_secret: a short-lived ES256 JWT signed with the .p8 sign-in key.
function appleClientSecret() {
  const now = Math.floor(Date.now() / 1000);
  const head = b64u(JSON.stringify({ alg: 'ES256', kid: APPLE_KEY_ID }));
  const body = b64u(JSON.stringify({ iss: APPLE_TEAM_ID, iat: now, exp: now + 60 * 30, aud: 'https://appleid.apple.com', sub: APPLE_SERVICES_ID }));
  const sig = sign('sha256', Buffer.from(`${head}.${body}`), { key: APPLE_PRIVATE_KEY, dsaEncoding: 'ieee-p1363' });
  return `${head}.${body}.${b64u(sig)}`;
}
const appleStates = new Map();   // state -> { app, exp } — short-lived CSRF/flow state
const handoffTokens = new Map(); // token -> { setCookie, location, exp } — single-use native bridge
const HANDOFF_TTL_MS = 5 * 60 * 1000;

// Finish an Apple login: native app flow → stash the Set-Cookie under a one-time
// token + deep-link it back; web flow → set the cookie + 302.
function completeAppleLogin(res, cookieHeader, appFlow, location = '/') {
  if (appFlow) {
    const token = randomBytes(24).toString('hex');
    handoffTokens.set(token, { setCookie: cookieHeader, location, exp: Date.now() + HANDOFF_TTL_MS });
    res.writeHead(302, { location: `${APP_SCHEME}://auth?token=${token}` }); res.end(); return;
  }
  res.writeHead(302, { 'Set-Cookie': cookieHeader, location }); res.end();
}
function readRaw(req) {
  return new Promise((resolve, reject) => {
    let raw = '';
    req.on('data', c => { raw += c; if (raw.length > 2e5) reject(new Error('too large')); });
    req.on('end', () => resolve(raw));
    req.on('error', reject);
  });
}
// Find-or-create a Celeb account from a verified Apple identity (keyed by sub).
async function upsertAppleUser(sub, email, name) {
  const users = await load('users.json', []);
  const appleSub = `apple:${sub}`;
  let u = users.find(x => x.appleSub === appleSub) || (email && users.find(x => x.email === email));
  if (!u) {
    u = {
      id: createHash('sha256').update(appleSub + Date.now()).digest('hex').slice(0, 16),
      email: email || `${sub}@privaterelay.appleid.com`,
      name: name || '',
      appleSub,
      at: new Date().toISOString(),
    };
    users.push(u); await store('users.json', users);
  } else if (name && !u.name) {
    u.name = name; await store('users.json', users);
  }
  return u;
}

createServer(async (req, res) => {
  try {
    const url = new URL(req.url, 'http://x');
    let path = decodeURIComponent(url.pathname);
    const M = req.method;

    // ===== sensitive-path guard =====
    // data/ (accounts, sessions, ledgers, private uploads) and scripts/ are
    // NEVER directly fetchable; public JSON flows only through /api/* routes.
    // Block data/, scripts/, tmp_*, and dotfiles — EXCEPT /.well-known/ (Apple's
    // Sign in with Apple domain-association file is served from there).
    if (/^\/(data|scripts|tmp_)/.test(path)
        || (path.startsWith('/.') && !path.startsWith('/.well-known/'))
        || path.includes('..')) {
      res.writeHead(403, { 'Content-Type': 'text/plain' }).end('forbidden'); return;
    }

    // ===== AUTH =====
    if (path === '/api/auth/signup' && M === 'POST') {
      const b = await readBody(req);
      const email = String(b.email || '').trim().toLowerCase();
      const pw = String(b.password || '');
      if (!/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(email)) return sendJSON(res, 400, { ok: false, error: 'invalid email' });
      if (pw.length < 6) return sendJSON(res, 400, { ok: false, error: 'password must be at least 6 characters' });
      const users = await load('users.json', []);
      if (users.some(u => u.email === email)) return sendJSON(res, 409, { ok: false, error: 'account already exists — sign in instead' });
      const salt = randomBytes(16).toString('hex');
      const user = { id: createHash('sha256').update(email + Date.now()).digest('hex').slice(0, 16), email, name: String(b.name || '').slice(0, 80), salt, hash: hashPw(pw, salt), at: new Date().toISOString() };
      users.push(user); await store('users.json', users);
      const tok = await newSession(user.id);
      return sendJSON(res, 200, { ok: true, user: pubUser(user) }, { 'Set-Cookie': setCookie(tok) });
    }
    if (path === '/api/auth/login' && M === 'POST') {
      const b = await readBody(req);
      const email = String(b.email || '').trim().toLowerCase();
      const users = await load('users.json', []);
      const u = users.find(x => x.email === email);
      if (!u || !verifyPw(String(b.password || ''), u.salt, u.hash)) return sendJSON(res, 401, { ok: false, error: 'wrong email or password' });
      const tok = await newSession(u.id);
      return sendJSON(res, 200, { ok: true, user: pubUser(u) }, { 'Set-Cookie': setCookie(tok) });
    }
    if (path === '/api/auth/logout' && M === 'POST') {
      const sessions = await load('sessions.json', {});
      const tok = parseCookies(req)[COOKIE];
      if (tok) { delete sessions[tok]; await store('sessions.json', sessions); }
      return sendJSON(res, 200, { ok: true }, { 'Set-Cookie': clearCookie });
    }
    if (path === '/api/auth/me' && M === 'GET') {
      const u = await currentUser(req);
      return sendJSON(res, 200, u ? { ok: true, user: pubUser(u) } : { ok: true, user: null });
    }
    // Account deletion — Apple App Store Guideline 5.1.1(v). Cookie-authed via the
    // same currentUser(req) mechanism as the rest of /api/auth/*. Removes the user
    // and ALL their data (sessions, saved placements, uploaded signature files),
    // then clears the session cookie exactly like /api/auth/logout.
    if (path === '/api/auth/delete' && M === 'POST') {
      const u = await currentUser(req);
      if (!u) return sendJSON(res, 401, { ok: false, error: 'not signed in' });
      const uid = u.id;
      // a. drop the user object
      const users = await load('users.json', []);
      await store('users.json', users.filter(x => x.id !== uid));
      // b. drop every session belonging to this user
      const sessions = await load('sessions.json', {});
      for (const [tok, s] of Object.entries(sessions)) { if (s && s.userId === uid) delete sessions[tok]; }
      await store('sessions.json', sessions);
      // c. drop this user's saved placements
      const saves = await load('saves.json', []);
      await store('saves.json', saves.filter(s => s.userId !== uid));
      // d. delete this user's private upload files (best-effort), then drop the rows
      const ups = await load('celebrity-uploads.json', []);
      for (const x of ups.filter(e => e.userId === uid)) {
        if (x.file) { try { await unlink(join(UPLOADS_DIR, x.file)); } catch {} }
      }
      await store('celebrity-uploads.json', ups.filter(e => e.userId !== uid));
      // e. clear the session cookie (same header the logout route uses)
      return sendJSON(res, 200, { ok: true }, { 'Set-Cookie': clearCookie });
    }

    // ===== SIGN IN WITH APPLE (web OAuth, bridged into the app's WebView) =====
    // Which sign-in methods are live — the site hides the Apple button until its
    // creds are configured, so a dormant deploy never shows a broken button.
    if (path === '/api/auth-config' && M === 'GET') {
      return sendJSON(res, 200, { ok: true, apple: APPLE_CONFIGURED });
    }
    // Kick off Apple sign-in. The iOS app appends ?app=1 and runs this in
    // ASWebAuthenticationSession; the web appends nothing and runs it inline.
    if (path === '/auth/apple/login' && M === 'GET') {
      if (!APPLE_CONFIGURED) return sendJSON(res, 503, { ok: false, error: 'Sign in with Apple is not configured yet' });
      const state = randomBytes(16).toString('hex');
      appleStates.set(state, { app: url.searchParams.get('app') === '1', exp: Date.now() + 10 * 60 * 1000 });
      const a = new URL('https://appleid.apple.com/auth/authorize');
      a.searchParams.set('response_type', 'code');
      a.searchParams.set('client_id', APPLE_SERVICES_ID);
      a.searchParams.set('redirect_uri', APPLE_REDIRECT_URI);
      a.searchParams.set('scope', 'name email');
      a.searchParams.set('response_mode', 'form_post');
      a.searchParams.set('state', state);
      res.writeHead(302, { location: a.toString() }); return res.end();
    }
    // Apple posts the code back here (form_post). Exchange it, verify the
    // identity, upsert the account, start a cs_sess session, finish the login.
    if (path === '/auth/apple/callback' && M === 'POST') {
      const form = new URLSearchParams(await readRaw(req));
      const code = form.get('code'); const state = form.get('state');
      const st = state ? appleStates.get(state) : undefined;
      if (state) appleStates.delete(state);
      if (!code || !st || st.exp < Date.now()) return sendJSON(res, 400, { ok: false, error: 'invalid or expired apple state' });
      try {
        const body = new URLSearchParams({
          grant_type: 'authorization_code', code,
          client_id: APPLE_SERVICES_ID, client_secret: appleClientSecret(),
          redirect_uri: APPLE_REDIRECT_URI,
        });
        const tr = await fetch('https://appleid.apple.com/auth/token', {
          method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded' }, body,
        });
        const tok = await tr.json();
        if (!tok.id_token) return sendJSON(res, 502, { ok: false, error: 'apple token exchange failed' });
        const claims = JSON.parse(Buffer.from(tok.id_token.split('.')[1] || '', 'base64url').toString());
        if (!claims.sub) return sendJSON(res, 502, { ok: false, error: 'apple id_token missing subject' });
        const email = String(claims.email || '').toLowerCase();
        // Apple sends the human name ONLY on the first authorization (a `user` field).
        let name = '';
        try { const uf = form.get('user'); if (uf) { const j = JSON.parse(uf); name = `${j?.name?.firstName || ''} ${j?.name?.lastName || ''}`.trim(); } } catch { /* */ }
        const u = await upsertAppleUser(String(claims.sub), email, name);
        const tokc = await newSession(u.id);
        return completeAppleLogin(res, setCookie(tokc), !!st.app, '/');
      } catch { return sendJSON(res, 502, { ok: false, error: 'apple callback failed' }); }
    }
    // Native-app handoff: the app replays the one-time token HERE, inside its own
    // WebView, so the Set-Cookie lands in the WebView's cookie jar. Single-use.
    if (path === '/auth/handoff' && M === 'GET') {
      const token = url.searchParams.get('token') || '';
      const rec = handoffTokens.get(token); if (rec) handoffTokens.delete(token);
      if (!rec || rec.exp < Date.now()) { res.writeHead(302, { location: '/?signin=expired' }); return res.end(); }
      res.writeHead(302, { 'Set-Cookie': rec.setCookie, location: rec.location }); return res.end();
    }

    // ===== GAME LEADERBOARD (public, no account needed) =====
    // Top-20 per game+difficulty. HARDENED (Cody gate, yoloforever C2):
    //  - game/diff must be from a fixed WHITELIST → no arbitrary-key disk-fill
    //  - score capped at the real max any game can produce (250) → blocks 9999
    //  - per-IP rate limit (10 POST/min) → no spam-flood of leaderboard.json
    //  - each entry carries a submission id → deterministic rank (no name+score collision)
    if (path === '/api/leaderboard') {
      const board = await load('leaderboard.json', {});
      const GAMES = new Set(['whose', 'art', 'match', 'early', 'century', 'lightning']);
      const DIFFS = new Set(['icons', 'scholar', 'curator', 'deep']);
      const MAX_SCORE = 250; // > any legit run (10-20 rounds, ~20/round incl. streak+bonus)
      if (M === 'GET') {
        const g = String(url.searchParams.get('game') || ''), d = String(url.searchParams.get('diff') || '');
        if (!GAMES.has(g) || !DIFFS.has(d)) return sendJSON(res, 400, { ok: false, error: 'unknown game/diff' });
        return sendJSON(res, 200, { ok: true, top: (board[g + '|' + d] || []).slice(0, 20).map(({ id, ...e }) => e) });
      }
      if (M === 'POST') {
        // per-IP rate limit
        const ip = (req.headers['x-real-ip'] || req.headers['x-forwarded-for'] || req.socket.remoteAddress || '?').split(',')[0].trim();
        const now = Date.now();
        LB_HITS.set(ip, (LB_HITS.get(ip) || []).filter(t => now - t < 60000));
        if (LB_HITS.get(ip).length >= 10) return sendJSON(res, 429, { ok: false, error: 'slow down — try again in a minute' });
        LB_HITS.get(ip).push(now);
        const b = await readBody(req);
        const game = String(b.game || ''), diff = String(b.diff || '');
        if (!GAMES.has(game) || !DIFFS.has(diff)) return sendJSON(res, 400, { ok: false, error: 'unknown game/diff' });
        const score = Math.max(0, Math.min(MAX_SCORE, parseInt(b.score, 10) || 0));
        const name = (String(b.name || 'Anonymous').replace(/[<>&"]/g, '').trim() || 'Anonymous').slice(0, 24);
        const id = randomBytes(6).toString('hex');
        const key = game + '|' + diff;
        board[key] = board[key] || [];
        board[key].push({ id, name, score, at: new Date().toISOString() });
        board[key].sort((a, b2) => b2.score - a.score);
        board[key] = board[key].slice(0, 50);
        await store('leaderboard.json', board);
        const rank = board[key].findIndex(e => e.id === id) + 1;
        return sendJSON(res, 200, { ok: true, rank, id, top: board[key].slice(0, 20).map(({ id: _i, ...e }) => e) });
      }
    }

    // ===== SAVED PLACEMENTS (per account) =====
    if (path === '/api/saves') {
      const u = await currentUser(req);
      if (!u) return sendJSON(res, 401, { ok: false, error: 'sign in to save' });
      const all = await load('saves.json', []);
      if (M === 'GET') return sendJSON(res, 200, { ok: true, saves: all.filter(s => s.userId === u.id).sort((a, b) => b.at.localeCompare(a.at)) });
      if (M === 'POST') {
        const b = await readBody(req);
        if (!b.mural) return sendJSON(res, 400, { ok: false, error: 'mural required' });
        const id = randomBytes(8).toString('hex');
        const rec = { id, userId: u.id, at: new Date().toISOString(),
          mural: String(b.mural).slice(0, 60), mural_title: String(b.mural_title || '').slice(0, 120),
          wall_w: +b.wall_w || null, wall_h: +b.wall_h || null,
          placement: b.placement && typeof b.placement === 'object' ? { from_left_ft: +b.placement.from_left_ft || 0, off_floor_ft: +b.placement.off_floor_ft || 0 } : null,
          scene: String(b.scene || '').slice(0, 40), label: String(b.label || '').slice(0, 80) };
        all.push(rec); await store('saves.json', all);
        return sendJSON(res, 200, { ok: true, save: rec });
      }
      if (M === 'DELETE') {
        const id = url.searchParams.get('id');
        const kept = all.filter(s => !(s.id === id && s.userId === u.id));
        await store('saves.json', kept);
        return sendJSON(res, 200, { ok: true, removed: all.length - kept.length });
      }
    }

    // ===== made-to-order request (existing) =====
    if (path === '/api/mural-order' && M === 'POST') {
      const b = await readBody(req);
      if (!b.name || !b.email) return sendJSON(res, 400, { ok: false, error: 'name and email required' });
      const u = await currentUser(req);
      const list = await load('mural-orders.json', []);
      const id = (list.at(-1)?.id || 1000) + 1;
      list.push({ id, at: new Date().toISOString(), account: u ? u.email : null, ...b });
      await store('mural-orders.json', list);
      return sendJSON(res, 200, { ok: true, id });
    }

    // ===== STRIPE CHECKOUT (mural orders) — TEST MODE ONLY =====
    // Zero-dep: we call Stripe's REST API directly. Reads STRIPE_TEST_SECRET_KEY
    // (sk_test_…). The LIVE key is deliberately NOT read here — going live is a
    // separate Steve-gated switch (STRIPE_LIVE_ENABLED + a live key). If no test
    // key is configured, the endpoint reports so gracefully (the lead-form flow
    // still works as the fallback).
    if (path === '/api/mural-checkout' && M === 'POST') {
      if (!STRIPE_MURAL_KEY) return sendJSON(res, 503, { ok: false, error: 'payments not configured yet (awaiting Stripe key)' });
      const b = await readBody(req);
      const name = String(b.name || '').slice(0, 120);
      const email = String(b.email || '').trim();
      if (!/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(email)) return sendJSON(res, 400, { ok: false, error: 'valid email required' });
      const w = Math.max(4, Math.min(40, +b.widthFt || 0));
      const h = Math.max(3, Math.min(16, +b.heightFt || 0));
      const perSqFt = 10; // matches murals-catalog pricePerSqFt
      const amountCents = Math.round(w * h * perSqFt) * 100;
      if (amountCents < 500) return sendJSON(res, 400, { ok: false, error: 'invalid mural size' });
      const title = String(b.mural_title || 'Signature Mural').slice(0, 120);
      const u = await currentUser(req);
      // record a PENDING order first so we have it even before webhook/return
      const list = await load('mural-orders.json', []);
      const id = (list.at(-1)?.id || 1000) + 1;
      const order = { id, at: new Date().toISOString(), account: u ? u.email : null, name, email,
        mural: b.mural, mural_title: title, widthFt: w, heightFt: h, sqft: w * h,
        amountUsd: amountCents / 100, placement: b.placement || null, status: 'pending_payment', mode: STRIPE_MODE };
      list.push(order); await store('mural-orders.json', list);
      // create the Stripe Checkout Session via REST (form-encoded)
      const params = new URLSearchParams();
      params.set('mode', 'payment');
      params.set('success_url', 'https://celebsignatures.com/order-success?sid={CHECKOUT_SESSION_ID}');
      params.set('cancel_url', 'https://celebsignatures.com/murals');
      params.set('customer_email', email);
      params.set('client_reference_id', String(id));
      params.set('line_items[0][quantity]', '1');
      params.set('line_items[0][price_data][currency]', 'usd');
      params.set('line_items[0][price_data][unit_amount]', String(amountCents));
      params.set('line_items[0][price_data][product_data][name]', `${title} — ${h}×${w} ft signature mural`);
      params.set('line_items[0][price_data][product_data][description]', `Custom ${w * h} sq ft made-to-order signature mural`);
      params.set('metadata[order_id]', String(id));
      try {
        const sres = await fetch('https://api.stripe.com/v1/checkout/sessions', {
          method: 'POST',
          headers: { Authorization: `Bearer ${STRIPE_MURAL_KEY}`, 'Content-Type': 'application/x-www-form-urlencoded' },
          body: params.toString(),
        });
        const sj = await sres.json();
        if (!sres.ok) { console.error('stripe error', sj.error?.message); return sendJSON(res, 502, { ok: false, error: sj.error?.message || 'stripe error' }); }
        order.stripeSession = sj.id; await store('mural-orders.json', list);
        return sendJSON(res, 200, { ok: true, url: sj.url, orderId: id });
      } catch (e) { return sendJSON(res, 502, { ok: false, error: 'payment gateway unreachable' }); }
    }
    if (path === '/order-success' && M === 'GET') {
      const sid = url.searchParams.get('sid') || '';
      let paid = false, order = null;
      if (STRIPE_MURAL_KEY && /^cs_(test|live)_[A-Za-z0-9]+$/.test(sid)) {
        try {
          const s = await (await fetch(`https://api.stripe.com/v1/checkout/sessions/${sid}`, { headers: { Authorization: `Bearer ${STRIPE_MURAL_KEY}` } })).json();
          if (s.payment_status === 'paid') {
            paid = true;
            const list = await load('mural-orders.json', []);
            order = list.find(o => String(o.id) === String(s.metadata?.order_id || s.client_reference_id));
            if (order && order.status !== 'paid') { order.status = 'paid'; order.paidAt = new Date().toISOString(); await store('mural-orders.json', list); }
          }
        } catch {}
      }
      const body = `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>${paid ? 'Order confirmed' : 'Order'} — Celebrity Signatures</title><link rel="icon" href="/assets/favicon.png">
<link href="https://fonts.googleapis.com/css2?family=Playfair+Display:wght@600&display=swap" rel="stylesheet">
<style>body{margin:0;font:16px/1.6 -apple-system,sans-serif;background:#f7f5f0;color:#1a1a1a;text-align:center}
.w{max-width:520px;margin:0 auto;padding:70px 24px}h1{font:600 30px 'Playfair Display',serif}
.ok{font-size:52px}a.btn{display:inline-block;margin-top:22px;padding:12px 26px;border-radius:999px;background:#1a1a1a;color:#fff;text-decoration:none}
.sub{color:#6b6b6b}</style></head><body><div class="w">
${paid ? `<div class="ok">✓</div><h1>Order confirmed</h1>
<p>Thank you${order ? ', ' + esc(order.name) : ''} — your ${order ? esc(order.heightFt + '×' + order.widthFt + ' ft') : ''} signature mural is ordered${order ? ' (order #' + order.id + ', $' + order.amountUsd.toLocaleString() + ')' : ''}. We'll email your 150-DPI proof and shipping details.</p>
<p class="sub">A receipt is on its way from Stripe.</p>`
: `<h1>Order not completed</h1><p class="sub">No payment was captured. You can try again from the wall studio.</p>`}
<a class="btn" href="/murals">${paid ? 'Design another wall' : 'Back to the studio'}</a>
<p style="margin-top:20px"><a href="/">← Celebrity Signatures</a></p></div></body></html>`;
      res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
      res.end(injectAds(body)); return;
    }

    // ===== CELEBRITY SIGNATURE UPLOADS (owned + commission-tracked) =====
    // A celebrity uploads THEIR OWN signature as a digital file. The file is
    // tied to their account, stored privately, and every download is logged to
    // an append-only ledger that accrues their commission. Raw files are never
    // shared or statically served — the tracked endpoint is the only exit.
    if (path === '/api/signature-upload' && M === 'POST') {
      const u = await currentUser(req);
      if (!u) return sendJSON(res, 401, { ok: false, error: 'sign in to upload your signature' });
      const b = await readBodyBig(req);
      const name = String(b.celebrity_name || '').trim().slice(0, 120);
      if (name.length < 2) return sendJSON(res, 400, { ok: false, error: 'name required' });
      if (b.attest !== true) return sendJSON(res, 400, { ok: false, error: 'you must attest this is your own signature (or you are authorized to license it)' });
      const m = String(b.dataUrl || '').match(/^data:(image\/(png|jpeg|svg\+xml));base64,([A-Za-z0-9+/=]+)$/);
      if (!m) return sendJSON(res, 400, { ok: false, error: 'file must be a PNG, JPEG, or SVG data URL' });
      const buf = Buffer.from(m[3], 'base64');
      if (buf.length < 500 || buf.length > 3e6) return sendJSON(res, 400, { ok: false, error: 'file must be 0.5KB–3MB' });
      const ext = m[2] === 'svg+xml' ? 'svg' : m[2];
      const id = 'up_' + randomBytes(8).toString('hex');
      await mkdir(UPLOADS_DIR, { recursive: true });
      await writeFile(join(UPLOADS_DIR, `${id}.${ext}`), buf);
      const ups = await load('celebrity-uploads.json', []);
      ups.push({
        id, userId: u.id, ownerEmail: u.email, celebrity_name: name,
        file: `${id}.${ext}`, mime: m[1], bytes: buf.length,
        at: new Date().toISOString(), status: 'pending',
        priceUsd: DEFAULT_PRICE_USD, commissionPct: DEFAULT_COMMISSION_PCT, downloads: 0,
      });
      await store('celebrity-uploads.json', ups);
      return sendJSON(res, 200, { ok: true, id, status: 'pending', note: 'submitted for identity review — you will see it in your dashboard' });
    }
    if (path === '/api/my-signatures' && M === 'GET') {
      const u = await currentUser(req);
      if (!u) return sendJSON(res, 401, { ok: false, error: 'sign in' });
      const ups = (await load('celebrity-uploads.json', [])).filter(x => x.userId === u.id);
      const mine = ups.map(({ file, userId, ...pub }) => ({
        ...pub,
        earnedUsd: +(pub.downloads * pub.priceUsd * pub.commissionPct / 100).toFixed(2),
      }));
      return sendJSON(res, 200, { ok: true, uploads: mine, totalEarnedUsd: +mine.reduce((s, x) => s + x.earnedUsd, 0).toFixed(2) });
    }
    if (path === '/api/celebrity-signatures' && M === 'GET') {
      // public directory of APPROVED uploads — metadata only, no file paths
      const ups = await load('celebrity-uploads.json', []);
      return sendJSON(res, 200, ups.filter(x => x.status === 'approved')
        .map(x => ({ id: x.id, celebrity_name: x.celebrity_name, downloads: x.downloads, priceUsd: x.priceUsd, at: x.at })));
    }
    // ===== PAID SIGNATURE DOWNLOAD (Stripe TEST mode) =====
    // Create a Stripe TEST checkout for a single signature file. On payment,
    // Stripe redirects to /api/signature-file/:id?sid=… which verifies the paid
    // session before releasing the file. Inert (503) until a test key is set;
    // the LIVE key is deliberately never read here — going live is a separate gate.
    if (path === '/api/signature-checkout' && M === 'POST') {
      if (!STRIPE_DOWNLOAD_KEY) return sendJSON(res, 503, { ok: false, error: 'downloads not purchasable yet (awaiting Stripe test key)' });
      const b = await readBody(req);
      const ups = await load('celebrity-uploads.json', []);
      const x = ups.find(e => e.id === String(b.id || ''));
      if (!x || x.status !== 'approved') return sendJSON(res, 404, { ok: false, error: 'not available' });
      const params = new URLSearchParams();
      params.set('mode', 'payment');
      params.set('success_url', `https://celebsignatures.com/api/signature-file/${x.id}?sid={CHECKOUT_SESSION_ID}`);
      params.set('cancel_url', 'https://celebsignatures.com/');
      params.set('line_items[0][quantity]', '1');
      params.set('line_items[0][price_data][currency]', 'usd');
      params.set('line_items[0][price_data][unit_amount]', String(Math.round(x.priceUsd * 100)));
      params.set('line_items[0][price_data][product_data][name]', `${x.celebrity_name} — signature file`);
      params.set('metadata[upload_id]', x.id);
      try {
        const sres = await fetch('https://api.stripe.com/v1/checkout/sessions', {
          method: 'POST',
          headers: { Authorization: `Bearer ${STRIPE_DOWNLOAD_KEY}`, 'Content-Type': 'application/x-www-form-urlencoded' },
          body: params.toString(),
        });
        const sj = await sres.json();
        if (!sres.ok) return sendJSON(res, 502, { ok: false, error: sj.error?.message || 'stripe error' });
        return sendJSON(res, 200, { ok: true, url: sj.url });
      } catch (e) { return sendJSON(res, 502, { ok: false, error: 'payment gateway unreachable' }); }
    }
    const fm = path.match(/^\/api\/signature-file\/(up_[a-f0-9]+)$/);
    if (fm && M === 'GET') {
      const ups = await load('celebrity-uploads.json', []);
      const x = ups.find(e => e.id === fm[1]);
      if (!x || x.status !== 'approved') return sendJSON(res, 404, { ok: false, error: 'not available' });
      // PAYMENT GATE (TEST mode): require a PAID Stripe checkout session for this file.
      const sid = url.searchParams.get('sid') || '';
      if (!STRIPE_DOWNLOAD_KEY) return sendJSON(res, 503, { ok: false, error: 'downloads require payment — not configured yet' });
      if (!/^cs_(test|live)_[A-Za-z0-9]+$/.test(sid)) return sendJSON(res, 402, { ok: false, error: 'payment required — purchase this download first', purchase: '/api/signature-checkout' });
      let paid = false, sessionCreated = 0;
      try {
        const s = await (await fetch(`https://api.stripe.com/v1/checkout/sessions/${sid}`, { headers: { Authorization: `Bearer ${STRIPE_DOWNLOAD_KEY}` } })).json();
        // gate fulfillment on the settled session (status===complete), not just payment_status
        paid = s.status === 'complete' && s.payment_status === 'paid' && String(s.metadata?.upload_id) === x.id;
        sessionCreated = s.created || 0;
      } catch {}
      if (!paid) return sendJSON(res, 402, { ok: false, error: 'payment not verified' });
      // HOLE 5 FIX: a paid Stripe session stays paid forever, so a shared ?sid= URL
      // would be permanent free re-download. Gate the file to a SHORT WINDOW (1h from
      // purchase) AND a small per-session use cap (3), so a leaked/shared link dies
      // fast but a buyer whose download drops can retry. Beyond that → 410, contact support.
      if (sessionCreated && (Date.now() / 1000 - sessionCreated) > 3600) {
        return sendJSON(res, 410, { ok: false, error: 'this download link has expired (1-hour window) — contact info@designerwallcoverings.com to re-download' });
      }
      const sidUse = await load('download-sids.json', {});
      if ((sidUse[sid] || 0) >= 3) {
        return sendJSON(res, 410, { ok: false, error: 'this download link has been used its maximum times — contact info@designerwallcoverings.com to re-download' });
      }
      sidUse[sid] = (sidUse[sid] || 0) + 1;
      await store('download-sids.json', sidUse);
      // Idempotent credit: claim the sid ATOMICALLY in-process (closes the concurrent
      // double-credit race — has+add are synchronous, no await between them), then confirm
      // against the persistent ledger so a restart can't re-credit an already-paid sid.
      let firstUse = false;
      if (!USED_SIDS.has(sid)) {
        USED_SIDS.add(sid);
        let inLedger = false;
        try { inLedger = (await readFile(join(DATA, 'download-ledger.jsonl'), 'utf8')).includes(`"sid":"${sid}"`); } catch {}
        firstUse = !inLedger;
      }
      if (firstUse) {
        const dl = await currentUser(req); // downloader identity when signed in
        const commission = +(x.priceUsd * x.commissionPct / 100).toFixed(2);
        x.downloads++;
        await store('celebrity-uploads.json', ups);
        await appendFile(join(DATA, 'download-ledger.jsonl'), JSON.stringify({
          ts: new Date().toISOString(), uploadId: x.id, ownerEmail: x.ownerEmail,
          downloader: dl ? dl.email : null, priceUsd: x.priceUsd, commissionUsd: commission, sid,
        }) + '\n');
      }
      const buf = await readFile(join(UPLOADS_DIR, x.file));
      res.writeHead(200, {
        'Content-Type': x.mime,
        'Content-Disposition': `attachment; filename="${x.celebrity_name.replace(/[^a-zA-Z0-9 _-]/g, '')} signature.${extname(x.file).slice(1)}"`,
      });
      return res.end(buf);
    }
    // admin review (token from data/admin-token.txt, never in git or rsync)
    if (path === '/api/admin/signature-uploads' && M === 'GET') {
      if (url.searchParams.get('token') !== await adminToken()) return sendJSON(res, 403, { ok: false });
      return sendJSON(res, 200, { ok: true, uploads: await load('celebrity-uploads.json', []) });
    }
    if (path === '/api/admin/signature-review' && M === 'POST') {
      const b = await readBody(req);
      if (b.token !== await adminToken()) return sendJSON(res, 403, { ok: false });
      const ups = await load('celebrity-uploads.json', []);
      const x = ups.find(e => e.id === b.id);
      if (!x) return sendJSON(res, 404, { ok: false, error: 'no such upload' });
      if (!['approve', 'reject'].includes(b.action)) return sendJSON(res, 400, { ok: false, error: 'action approve|reject' });
      x.status = b.action === 'approve' ? 'approved' : 'rejected';
      x.reviewedAt = new Date().toISOString();
      await store('celebrity-uploads.json', ups);
      return sendJSON(res, 200, { ok: true, id: x.id, status: x.status });
    }

    // ===== signatures feed = hand-curated catalog + authoritative Artists set =====
    // artists.json is the SINGLE AUTHORITY for the Artists category (occupation-
    // filtered to real artists, famous-first — see scripts/filter-artists.mjs).
    // We DROP any 'Artists' that a roster pipeline physically merged into
    // celebrity_signatures.json, so non-artist museum constituents (portrait
    // SUBJECTS like Shakespeare / Newton / Lincoln) can't leak into the grid and
    // no artist double-lists with Politics. The base file owns only the
    // hand-curated categories (Oldest Signatures / Declaration / Politics).
    if (path === '/api/signatures' && M === 'GET') {
      return sendJSON(res, 200, await mergedSignatures(), { 'Cache-Control': 'no-cache' });
    }

    // ===== /wear — legally-cleared signature apparel =====
    // Returns ONLY signatures that passed the eligibility gate (commercially
    // usable, not high-risk, full-register trademark screen == CLEAR), plus the
    // garment templates and a disclosure describing the screen. If the screen is
    // not yet complete, `signatures` is empty by design (nothing is "legally clear"
    // until the register is actually screened).
    if (path === '/api/wear/signatures' && M === 'GET') {
      const clr = await tmClearance();
      const tpl = await wearTemplates();
      const sigs = (await wearSignatures()).map(s => ({
        qid: qidOf(s), full_name: s.full_name, category: s.category,
        signature_image_url: s.signature_image_url, image_license: s.image_license,
      }));
      return sendJSON(res, 200, {
        garments: tpl.garments || [], placement: tpl.placement || 'left_chest',
        disclosure: {
          basis: 'public-domain-historical', cutoffDeathYear: WEAR_PD_DEATH_CUTOFF,
          purchasable: envVal('WEAR_SALES_LIVE') === '1',
          note: 'Only public-domain historical signatures (figures deceased on or before ' + WEAR_PD_DEATH_CUTOFF + ') are offered — no living or modern figures. Live USPTO trademark screening is deferred.',
        },
        count: sigs.length, signatures: sigs,
      }, { 'Cache-Control': 'no-cache' });
    }

    // ===== /wear checkout (Stripe TEST mode only) — a real multi-item cart =====
    // Mirrors the mural-checkout flow but uses the TEST download key — going live
    // (real cards) AND actually submitting to a POD firm are both Steve-gated. On
    // payment, a POD-order draft is appended to data/pod-order-drafts.jsonl per
    // cart line item; NO POD API call is made here.
    if (path === '/api/wear-checkout' && M === 'POST') {
      // v1 soft-launch: catalog is public + browsable, but real sales stay OFF until Steve
      // flips WEAR_SALES_LIVE=1 (paired with live Stripe + a wired POD). Until then, no visitor
      // is sent to a test-mode checkout — they get a friendly "opening soon".
      if (envVal('WEAR_SALES_LIVE') !== '1') return sendJSON(res, 200, { ok: false, comingSoon: true, error: 'Apparel sales open soon — thanks for your interest!' });
      // Never charge a customer unless the selected POD provider has both a
      // credential and a complete color/size mapping for every offered garment.
      const podProvider = wearPodProvider();
      const podTemplates = await wearTemplates();
      if (!wearPodReady(podTemplates, podProvider)) return sendJSON(res, 503, { ok: false, error: 'apparel fulfillment is not configured' });
      // Apparel is a physical POD sale — charge through wear's OWN live-capable
      // resolver (WEAR_STRIPE_KEY: live only when WEAR_STRIPE_LIVE_ENABLED=1 AND
      // a sk_live_ key is present, else the test key). Deliberately NOT
      // STRIPE_MURAL_KEY — that flag is already live for an unrelated feature,
      // so reusing it would let real charges start before Steve ever placed a
      // test order through wear specifically.
      if (!WEAR_STRIPE_KEY) return sendJSON(res, 503, { ok: false, error: 'apparel not purchasable yet (awaiting Stripe key)' });
      const b = await readBody(req);
      const email = String(b.email || '').trim();
      if (!/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(email)) return sendJSON(res, 400, { ok: false, error: 'valid email required' });
      const cartIn = Array.isArray(b.items) ? b.items : (b.qid ? [b] : []); // back-compat: a bare single item still works
      if (!cartIn.length) return sendJSON(res, 400, { ok: false, error: 'cart is empty' });
      if (cartIn.length > WEAR_CART_MAX_ITEMS) return sendJSON(res, 400, { ok: false, error: `cart is limited to ${WEAR_CART_MAX_ITEMS} items` });
      const clr = await tmClearance();
      const allSigs = await mergedSignatures();
      const tpl = podTemplates;
      const resolvedItems = [];
      for (const it of cartIn) {
        const sig = allSigs.find(s => qidOf(s) === String(it.qid || ''));
        if (!sig || !wearEligible(sig, clr)) return sendJSON(res, 403, { ok: false, error: `${it.qid || 'a signature'} is not cleared for sale` });
        const garment = (tpl.garments || []).find(g => g.id === String(it.garment || ''));
        if (!garment) return sendJSON(res, 400, { ok: false, error: 'unknown garment' });
        if (garment.saleEnabled === false) return sendJSON(res, 503, { ok: false, error: `${garment.label} is coming soon` });
        const color = (garment.colors || []).find(c => c.id === String(it.color || '')) || garment.colors[0];
        if (!(garment.sizes || []).includes(String(it.size))) return sendJSON(res, 400, { ok: false, error: 'unknown size' });
        const size = String(it.size);
        // Base price + any size upcharge (e.g. 2XL costs Printful more; keep our margin flat).
        const baseUsd = garment.priceUsd || WEAR_GARMENT_DEFAULT_PRICE_USD;
        const upchargeUsd = (garment.sizeUpchargeUsd && garment.sizeUpchargeUsd[size]) || 0;
        const amountCents = Math.round((baseUsd + upchargeUsd) * 100);
        resolvedItems.push({ qid: qidOf(sig), signature_name: sig.full_name, garment: garment.id, garment_label: garment.label,
          color: color.id, color_label: color.label, size, placement: garment.placement || tpl.placement || 'left_chest',
          placementLabel: garment.placementLabel || 'Signature printed at the left chest.',
          inkColor: safeInkHex(it.inkColor), amountCents });   // buyer's chosen ink (validated; null = auto)
      }
      const u = await currentUser(req);
      const list = await load('wear-orders.json', []);
      const id = (list.at(-1)?.id || WEAR_ORDER_ID_SEED) + 1;
      const totalCents = resolvedItems.reduce((s, i) => s + i.amountCents, 0);
      const order = { id, at: new Date().toISOString(), account: u ? u.email : null, email,
        items: resolvedItems, amountUsd: totalCents / 100,
        // Pin the POD provider to the order at the moment of purchase. If
        // WEAR_POD_PROVIDER (or its readiness) changes before the customer lands
        // on /wear-success, the draft must still route to the provider that was
        // actually configured — and eligible — when the charge happened.
        status: 'pending_payment', mode: WEAR_STRIPE_MODE, provider: podProvider, pod_submitted: false };
      list.push(order); await store('wear-orders.json', list);
      const params = new URLSearchParams();
      params.set('mode', 'payment');
      // Physical POD product => collect a shipping address (Printful requires one).
      params.set('shipping_address_collection[allowed_countries][0]', 'US');
      params.set('success_url', 'https://celebsignatures.com/wear-success?sid={CHECKOUT_SESSION_ID}');
      params.set('cancel_url', 'https://celebsignatures.com/wear');
      params.set('customer_email', email);
      params.set('client_reference_id', String(id));
      resolvedItems.forEach((it, i) => {
        params.set(`line_items[${i}][quantity]`, '1');
        params.set(`line_items[${i}][price_data][currency]`, 'usd');
        params.set(`line_items[${i}][price_data][unit_amount]`, String(it.amountCents));
        params.set(`line_items[${i}][price_data][product_data][name]`, `${it.signature_name} signature — ${it.garment_label} (${it.color_label}, ${it.size})`);
        params.set(`line_items[${i}][price_data][product_data][description]`, `${it.placementLabel} Made to order.${WEAR_STRIPE_MODE === 'test' ? ' TEST checkout.' : ''}`);
      });
      params.set('metadata[order_id]', String(id));
      try {
        const sres = await fetch('https://api.stripe.com/v1/checkout/sessions', {
          method: 'POST',
          headers: { Authorization: `Bearer ${WEAR_STRIPE_KEY}`, 'Content-Type': 'application/x-www-form-urlencoded' },
          body: params.toString(),
        });
        const sj = await sres.json();
        if (!sres.ok) { console.error('stripe error', sj.error?.message); return sendJSON(res, 502, { ok: false, error: sj.error?.message || 'stripe error' }); }
        order.stripeSession = sj.id; await store('wear-orders.json', list);
        return sendJSON(res, 200, { ok: true, url: sj.url, orderId: id });
      } catch (e) { return sendJSON(res, 502, { ok: false, error: 'payment gateway unreachable' }); }
    }
    if (path === '/wear-success' && M === 'GET') {
      const sid = url.searchParams.get('sid') || '';
      let paid = false, order = null;
      if (WEAR_STRIPE_KEY && /^cs_(test|live)_[A-Za-z0-9]+$/.test(sid)) {
        try {
          const s = await (await fetch(`https://api.stripe.com/v1/checkout/sessions/${sid}`, { headers: { Authorization: `Bearer ${WEAR_STRIPE_KEY}` } })).json();
          if (s.payment_status === 'paid') {
            paid = true;
            const list = await load('wear-orders.json', []);
            order = list.find(o => String(o.id) === String(s.metadata?.order_id || s.client_reference_id));
            if (order && order.status !== 'paid') {
              order.status = 'paid'; order.paidAt = new Date().toISOString();
              // Capture the shipping address Stripe collected (Printful needs it).
              const sd = s.shipping_details || s.customer_details || {};
              const ad = sd.address || {};
              order.recipient = { name: sd.name || null, address1: ad.line1 || null, address2: ad.line2 || null,
                city: ad.city || null, state_code: ad.state || null, country_code: ad.country || null, zip: ad.postal_code || null };
              // DRAFT one POD order per cart line item — do NOT submit. The selected
              // provider's separately gated sender validates and transmits each one
              // later. Use the provider PINNED on the order at checkout time (falls
              // back to the live env for orders placed before that field existed) —
              // never re-read WEAR_POD_PROVIDER fresh here, or a provider switch
              // between checkout and this GET could route a paid order to a
              // provider whose variant map doesn't match what the customer bought.
              const provider = order.provider || wearPodProvider();
              if (!order.pod_submitted) {
                // Idempotency guard: if a prior run appended some draft lines but
                // crashed before persisting pod_submitted=true, don't re-append the
                // ones already written — check the append-only log itself, not
                // just the in-memory flag. Each cart line item gets its own
                // composite id ("<orderId>.<itemIndex>") so a multi-item order
                // produces one draft — and one eventual Printify order — per item.
                let existingLines = [];
                try { existingLines = (await readFile(join(DATA, 'pod-order-drafts.jsonl'), 'utf8')).trim().split('\n').filter(Boolean); } catch {}
                const alreadyDrafted = new Set(existingLines.map(l => { try { return JSON.parse(l).orderId; } catch { return null; } }));
                const sigsAll = await mergedSignatures();
                const draftTpl = await wearTemplates();
                const items = wearOrderItems(order);
                for (let i = 0; i < items.length; i++) {
                  const it = items[i];
                  const compositeId = `${order.id}.${i}`;
                  if (alreadyDrafted.has(compositeId)) continue;
                  const sig = sigsAll.find(x => qidOf(x) === it.qid);
                  let designUrl = sig ? sig.signature_image_url : null;
                  // Bake the ink color into the printed art (Printify prints the
                  // source pixels as-is): the buyer's chosen color if they picked
                  // one, else the auto rule — white on a dark garment (a color
                  // would vanish), the signer's own distinct color on a light one.
                  // Matches the on-shirt preview's inkHexFor() exactly.
                  if (designUrl) {
                    const g = (draftTpl.garments || []).find(x => x.id === it.garment);
                    const c = g && (g.colors || []).find(x => x.id === it.color);
                    const darkCloth = !!(c && wearLuminance(c.hex) < 0.4);
                    // Recolor ONLY when there's a reason: the buyer picked a color,
                    // or the cloth is dark (a dark signature would vanish → white).
                    // Otherwise keep the ORIGINAL art in its true source colors.
                    const ink = safeInkHex(it.inkColor) || (darkCloth ? '#ffffff' : null);
                    if (ink) designUrl = await recolorSignatureInk(designUrl, ink);
                  }
                  const draft = { draftedAt: new Date().toISOString(), orderId: compositeId, status: 'DRAFT_UNSENT', provider,
                    recipient_email: order.email, recipient: order.recipient, garment: it.garment, color: it.color, size: it.size,
                    placement: it.placement, signature_name: it.signature_name,
                    design_image_url: designUrl,
                    note: `NOT SENT — awaiting Steve approval + ${provider} credentials/mapping + WEAR_SALES_LIVE=1` };
                  await appendFile(join(DATA, 'pod-order-drafts.jsonl'), JSON.stringify(draft) + '\n');
                }
                order.pod_submitted = true;
              }
              await store('wear-orders.json', list);
            }
          }
        } catch (e) { console.error('wear-success', e.message); }
      }
      const body = `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>${paid ? 'Order received' : 'Order'} — Celebrity Signatures</title><link rel="icon" href="/assets/favicon.png">
<style>body{margin:0;font:16px/1.6 -apple-system,sans-serif;background:#f7f5f0;color:#1a1a1a;text-align:center}
.w{max-width:520px;margin:0 auto;padding:70px 24px}h1{font:600 28px 'Playfair Display',serif}
.ok{font-size:52px}a.btn{display:inline-block;margin-top:22px;padding:12px 26px;border-radius:999px;background:#1a1a1a;color:#fff;text-decoration:none}
.sub{color:#6b6b6b}</style></head><body><div class="w">
${paid ? `<div class="ok">✓</div><h1>Order received</h1>
<p>${order ? wearOrderItems(order).map(it => `${esc(it.signature_name)} — ${esc(it.garment_label)} (${esc(it.color_label || it.color)}, ${esc(it.size)})`).join('<br>') : 'Your order'} ${wearOrderItems(order || {}).length > 1 ? 'are' : 'is'} queued. (TEST order #${order ? order.id : ''}.)</p>
<p class="sub">This is a test checkout; nothing was charged and no print order has been submitted yet.</p>`
: `<h1>Order not completed</h1><p class="sub">No payment was captured.</p>`}
<a class="btn" href="/wear">Back to the shop</a></div></body></html>`;
      res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
      res.end(injectAds(body)); return;
    }

    // ===== static + page routes =====
    if (path === '/') path = '/public/index.html';
    if (path === '/murals') path = '/public/murals.html';
    if (path === '/wear') path = '/public/wear.html';
    if (path === '/wallpaper') path = '/public/wallpaper.html';
    if (path === '/upload') path = '/public/upload-signature.html';
    if (path === '/game') path = '/public/game.html';
    if (path === '/signature-edit') path = '/public/signature-edit.html';

    // ===== SEO: crawlable per-signature page =====
    // The grid is JS-rendered (Google can't index 7,000 cards). Each signature
    // also gets a real server-rendered HTML page at /a/<QID> with JSON-LD Person
    // schema, canonical, OG — the indexable surface the sitemap points at.
    const am = path.match(/^\/a\/(Q\d+)$/);
    if (am && M === 'GET') {
      const r = (await mergedSignatures()).find(x => qidOf(x) === am[1]);
      if (!r) { res.writeHead(404, { 'Content-Type': 'text/plain' }).end('signature not found'); return; }
      const url = `https://celebsignatures.com/a/${am[1]}`;
      const life = r.deceased === 'yes' ? `† ${(r.death_date || '').slice(0, 10)}` : 'living';
      const why = (r.reason_for_ranking || '').replace('Cross-wiki notability: ', '');
      const museums = Array.isArray(r.museums) ? r.museums : [];
      const jsonld = {
        '@context': 'https://schema.org', '@type': 'Person', name: r.full_name,
        sameAs: r.wikidata || undefined,
        image: r.signature_image_url,
        description: `Authentic signature of ${r.full_name}${r.death_date ? ', d. ' + String(r.death_date).slice(0, 4) : ''} — ${r.category}.`,
      };
      const html = `<!doctype html><html lang="en"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>${esc(r.full_name)} — signature | Celebrity Signatures</title>
<link rel="canonical" href="${url}">
<meta name="description" content="The authentic signature of ${esc(r.full_name)} (${esc(r.category)}${r.death_date ? ', d. ' + esc(String(r.death_date).slice(0, 4)) : ''}), traced to a real archival source.">
<link rel="icon" type="image/png" href="/assets/favicon.png">
<meta property="og:type" content="profile"><meta property="og:title" content="${esc(r.full_name)} — signature">
<meta property="og:description" content="Authentic signature of ${esc(r.full_name)}, on Celebrity Signatures.">
<meta property="og:url" content="${url}"><meta property="og:image" content="${esc(r.signature_image_url)}">
<meta name="twitter:card" content="summary_large_image">
<link href="https://fonts.googleapis.com/css2?family=Playfair+Display:ital,wght@0,600;1,400&display=swap" rel="stylesheet">
<script type="application/ld+json">${JSON.stringify(jsonld)}</script>
<style>body{margin:0;font:16px/1.6 -apple-system,BlinkMacSystemFont,sans-serif;color:#1a1a1a;background:#f7f5f0}
.wrap{max-width:640px;margin:0 auto;padding:30px 20px 70px}a{color:#1d7a36}
.mast{font:italic 600 22px 'Playfair Display',serif;text-decoration:none;color:#1a1a1a}
.sig{background:#fff;border:1px solid #e6e3dc;border-radius:12px;padding:26px;text-align:center;margin:20px 0}
.sig img{max-width:90%;max-height:150px;object-fit:contain}
h1{font:400 32px/1.2 'Playfair Display',serif;margin:6px 0}
.meta{color:#6b6b6b;font-size:14px}.sec{font-size:12px;text-transform:uppercase;letter-spacing:.06em;color:#6b6b6b;margin:18px 0 4px}
.cta{display:inline-block;margin-top:18px;padding:11px 24px;border-radius:999px;background:#1a1a1a;color:#fff;text-decoration:none}</style>
</head><body><div class="wrap">
<a class="mast" href="/">Celebrity Signatures</a>
<div class="sig"><img src="${esc(r.signature_image_url)}" alt="Signature of ${esc(r.full_name)}"></div>
<h1>${esc(r.full_name)}</h1>
<div class="meta">${esc(life)} · ${esc(r.category)}${why ? ' · ' + esc(why) : ''}</div>
${museums.length ? `<div class="sec">In the collections of</div><div class="meta">${esc(museums.join(' · '))}</div>` : ''}
<div class="sec">License &amp; source</div><div class="meta">${esc(r.image_license || '')} · <a href="${esc(r.wikidata)}" rel="nofollow noopener" target="_blank">Wikidata</a></div>
<div class="ad-zone" data-ad-zone="article"></div>
<p style="margin-top:22px"><a href="/?artist=${am[1]}">See ${esc(r.full_name)} in the full gallery →</a></p>
<a class="cta" href="/wallpaper?qid=${am[1]}&amp;name=${encodeURIComponent(r.full_name)}">Design wallpaper with this signature →</a>
<p style="margin-top:26px"><a href="/">← Browse all ${(await mergedSignatures()).length.toLocaleString()} signatures</a></p>
</div></body></html>`;
      res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'public, max-age=3600' });
      res.end(injectAds(html)); return;
    }
    if (path === '/sitemap.xml' && M === 'GET') {
      const sigs = await mergedSignatures();
      const B = 'https://celebsignatures.com';
      const urls = ['/', '/signature-edit', '/game', '/wallpaper', '/wear', '/murals', '/upload'].map(u => `<url><loc>${B}${u}</loc></url>`)
        .concat(sigs.map(r => `<url><loc>${B}/a/${qidOf(r)}</loc></url>`).filter(u => /\/a\/Q\d+/.test(u)));
      res.writeHead(200, { 'Content-Type': 'application/xml' });
      res.end(`<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n${urls.join('\n')}\n</urlset>`);
      return;
    }
    if (path === '/robots.txt' && M === 'GET') {
      res.writeHead(200, { 'Content-Type': 'text/plain' });
      res.end('User-agent: *\nAllow: /\nSitemap: https://celebsignatures.com/sitemap.xml\n');
      return;
    }
    if (path === '/llms.txt' && M === 'GET') {
      const count = (await mergedSignatures()).length;
      res.writeHead(200, { 'Content-Type': 'text/plain; charset=utf-8', 'Cache-Control': 'public, max-age=3600' });
      res.end(`# Celebrity Signatures\n\n> A sourced public-domain archive of ${count.toLocaleString()} notable signatures, with crawlable person pages and archival attribution.\n\n- Canonical site: https://celebsignatures.com/\n- Signature index: https://celebsignatures.com/sitemap.xml\n- Games: https://celebsignatures.com/game\n- Murals: https://celebsignatures.com/murals\n- Contributor upload: https://celebsignatures.com/upload\n\nEach /a/Q… page describes one person and includes Person JSON-LD, source/license fields, canonical metadata, and an image of the signature.\n`);
      return;
    }
    // AdSense ads.txt — required for the account to be authorized to serve ads.
    // Served only once a publisher id is configured (else 404, so no broken file).
    if (path === '/ads.txt' && M === 'GET') {
      if (!ADSENSE_PUB_ID) { res.writeHead(404, { 'Content-Type': 'text/plain' }).end('not found'); return; }
      res.writeHead(200, { 'Content-Type': 'text/plain' });
      res.end(`google.com, ${ADSENSE_PUB_ID}, DIRECT, f08c47fec0942fa0\n`);
      return;
    }

    if (path.startsWith('/assets/')) path = '/public' + path;
    if (path === '/favicon.ico') path = '/public/assets/favicon.png';
    if (path === '/privacy' || path === '/privacy.html') path = '/public/privacy.html';
    if (path === '/account.js') path = '/public/account.js';
    if (path === '/api/murals-catalog') path = '/data/murals-catalog.json';
    if (path === '/api/signature-evolution') path = '/data/signature-evolution.json';
    if (path === '/api/portraits') path = '/data/portraits.json';
    const SHORT = { d: 'declaration-of-independence', p: 'politics', s: 'sports', h: 'hollywood', c: 'movies-classic', t: 'tv', o: 'oldest-signatures', a: 'artists', w: 'authors' };
    const km = path.match(/^\/k\/([a-z0-9-]+)$/);
    if (km) path = `/output/collage-${SHORT[km[1]] || km[1]}-named.png`;

    const file = join(ROOT, path);
    if (!file.startsWith(ROOT)) { res.writeHead(403).end('forbidden'); return; }

    // Inject the account/save widget into the murals page WITHOUT editing murals.html.
    if (path === '/public/murals.html') {
      let html = await readFile(file, 'utf8');
      html = html.replace('</body>', '<script src="/account.js"></script>\n</body>');
      res.writeHead(200, { 'Content-Type': 'text/html', 'Cache-Control': 'no-cache' });
      res.end(injectAds(html)); return;
    }
    // HTML pages: serve as text so the AdSense loader can be injected into <head>.
    if (extname(file) === '.html') {
      const html = await readFile(file, 'utf8');
      res.writeHead(200, { 'Content-Type': 'text/html', 'Cache-Control': 'no-cache' });
      res.end(injectAds(html)); return;
    }

    const body = await readFile(file);
    res.writeHead(200, { 'Content-Type': TYPES[extname(file)] || 'application/octet-stream', 'Cache-Control': 'no-cache' });
    res.end(body);
  } catch (e) {
    if (e.message === 'bad json' || e.message === 'too large') { sendJSON(res, 400, { ok: false, error: e.message }); return; }
    res.writeHead(404, { 'Content-Type': 'text/plain' }).end('not found');
  }
}).listen(PORT, () => console.log(`CelebritySignatures → http://localhost:${PORT}`));