[object Object]

← back to Wallco Ai

feat(styleguides): open to public with per-IP render cap (5 images/day)

5a812ec5a0f7ad57931930bff7e0386a0b7f3da2 · 2026-06-01 11:33:35 -0700 · Steve Abrams

- Page now served publicly on the styleguides.wallco.ai vhost root (gate removed).
- Non-admin callers metered to SG_IMAGE_CAP=5 paid renders / 24h per IP:
  Generate clamps batch n to remaining (429 when 0); Recolor counts 1 each.
  Admins (cookie/?admin/localhost) unlimited.
- Recolor opened to the public ONLY via the page's X-SG header — main storefront
  PDP recolor stays 404 for non-admins. New GET /api/styleguides/quota.
- Page shows live 'N / 5 free renders left' pill, disables Generate/Apply at 0,
  reads remaining straight off each action response.

Files touched

Diff

commit 5a812ec5a0f7ad57931930bff7e0386a0b7f3da2
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Mon Jun 1 11:33:35 2026 -0700

    feat(styleguides): open to public with per-IP render cap (5 images/day)
    
    - Page now served publicly on the styleguides.wallco.ai vhost root (gate removed).
    - Non-admin callers metered to SG_IMAGE_CAP=5 paid renders / 24h per IP:
      Generate clamps batch n to remaining (429 when 0); Recolor counts 1 each.
      Admins (cookie/?admin/localhost) unlimited.
    - Recolor opened to the public ONLY via the page's X-SG header — main storefront
      PDP recolor stays 404 for non-admins. New GET /api/styleguides/quota.
    - Page shows live 'N / 5 free renders left' pill, disables Generate/Apply at 0,
      reads remaining straight off each action response.
---
 public/admin/styleguides.html | 38 +++++++++++++++++++-----
 server.js                     | 68 ++++++++++++++++++++++++++++++++++++++-----
 2 files changed, 91 insertions(+), 15 deletions(-)

diff --git a/public/admin/styleguides.html b/public/admin/styleguides.html
index c71ff57..14698ac 100644
--- a/public/admin/styleguides.html
+++ b/public/admin/styleguides.html
@@ -156,6 +156,7 @@
 <div class="bar">
   <div class="bar-in">
     <span class="selpill"><b id="sel-count">0</b> selected</span>
+    <span class="selpill" id="quota-pill" title="Free paid renders left for you today (per IP)" style="display:none"></span>
     <span class="selnames" id="sel-names">No houses selected</span>
     <div class="actions">
       <button class="btn" id="btn-clear">Clear all</button>
@@ -219,9 +220,14 @@
   const QS = new URLSearchParams(location.search);
   const ADMIN = QS.get('admin') || '';
   const authd = (p) => ADMIN ? p + (p.includes('?') ? '&' : '?') + 'admin=' + encodeURIComponent(ADMIN) : p;
-  const jfetch = (p, opts) => fetch(authd(p), Object.assign({ credentials:'same-origin' }, opts || {}));
+  // X-SG marks this page's paid calls so the server opens recolor to the
+  // public here (capped) without opening it on the main storefront.
+  const jfetch = (p, opts={}) => {
+    const headers = Object.assign({ 'X-SG':'1' }, opts.headers || {});
+    return fetch(authd(p), Object.assign({ credentials:'same-origin' }, opts, { headers }));
+  };
 
-  const state = { brands: { fashion: [], wallcovering: [] }, sel: new Set() };
+  const state = { brands: { fashion: [], wallcovering: [] }, sel: new Set(), quota: { admin:false, remaining:5, cap:5 } };
   const byKey = {};
 
   const $ = (s, r=document) => r.querySelector(s);
@@ -263,7 +269,23 @@
     $('#sel-count').textContent=n;
     const names=[...state.sel].map(k=>byKey[k]&&byKey[k].label).filter(Boolean);
     $('#sel-names').textContent = n? names.join(' · ') : 'No houses selected';
-    ['btn-json','btn-export','btn-apply','btn-generate'].forEach(id=>$('#'+id).disabled = n===0);
+    const noQuota = !state.quota.admin && (state.quota.remaining|0) <= 0;
+    $('#btn-json').disabled = n===0;
+    $('#btn-export').disabled = n===0;
+    $('#btn-apply').disabled = n===0 || noQuota;
+    $('#btn-generate').disabled = n===0 || noQuota;
+    $('#btn-generate').textContent = noQuota ? 'Daily limit reached' : 'Generate Designs →';
+  }
+  function setQuota(q){ if(q) state.quota=q; renderQuota(); syncBar(); }
+  function renderQuota(){
+    const q=state.quota, pill=$('#quota-pill'); pill.style.display='';
+    if(q.admin){ pill.innerHTML='✦ Admin · unlimited'; pill.style.color='var(--gold)'; return; }
+    const r=Math.max(0,q.remaining|0);
+    pill.innerHTML='<b style="color:'+(r?'var(--gold)':'#d57')+'">'+r+'</b> / '+q.cap+' free renders left';
+  }
+  async function refreshQuota(){
+    try{ const r=await jfetch('/api/styleguides/quota'); const j=await r.json();
+      if(j.ok) setQuota({admin:!!j.admin, remaining:j.unlimited?Infinity:j.remaining, cap:j.cap}); }catch(e){}
   }
 
   // ── payload ───────────────────────────────────────────────────────────────
@@ -336,9 +358,10 @@
       const r=await jfetch('/api/generator/batch',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(p)});
       const j=await r.json();
       if(!j.ok) throw new Error(j.error||'failed');
-      $('#gen-status').innerHTML='✓ Batch <b>#'+j.run_id+'</b> running — '+(j.note||'')+' <a href="/designs" target="_blank" rel="noopener noreferrer" style="color:var(--gold)">watch the Studio ↗</a>';
+      if(typeof j.remaining==='number') setQuota({admin:false,remaining:j.remaining,cap:state.quota.cap}); else refreshQuota();
+      $('#gen-status').innerHTML='✓ Batch <b>#'+j.run_id+'</b> running ('+j.count+' image'+(j.count>1?'s':'')+') — '+(j.note||'')+' <a href="/designs" target="_blank" rel="noopener noreferrer" style="color:var(--gold)">watch the Studio ↗</a>';
       toast('Batch #'+j.run_id+' started');
-    }catch(e){ $('#gen-status').textContent='✗ '+e.message; }
+    }catch(e){ $('#gen-status').textContent='✗ '+e.message; refreshQuota(); }
     finally{ go.disabled=false; }
   });
 
@@ -370,12 +393,13 @@
       const r=await jfetch('/api/design/'+id+'/recolor',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({brand:applyBrand})});
       const j=await r.json();
       if(!j.ok) throw new Error(j.error||'failed');
+      if(typeof j.remaining==='number') setQuota({admin:false,remaining:j.remaining,cap:state.quota.cap}); else refreshQuota();
       const div=el('div','r');
       div.innerHTML='<a href="/design/'+j.new_id+'" target="_blank" rel="noopener noreferrer"><img src="'+j.new_image_url+'"></a><div class="cap">'+label+' · #'+j.new_id+'</div>';
       $('#apply-results').prepend(div);
       $('#apply-status').textContent='✓ #'+id+' → '+label+' (#'+j.new_id+')';
       toast('Recolored → #'+j.new_id);
-    }catch(e){ $('#apply-status').textContent='✗ '+e.message; }
+    }catch(e){ $('#apply-status').textContent='✗ '+e.message; refreshQuota(); }
   }
 
   // ── boot ──────────────────────────────────────────────────────────────────
@@ -384,7 +408,7 @@
       const r=await jfetch('/api/styleguides/brands'); const j=await r.json();
       state.brands.fashion=j.fashion||[]; state.brands.wallcovering=j.wallcovering||[];
       [...state.brands.fashion,...state.brands.wallcovering].forEach(b=>byKey[b.key]=b);
-      renderGrids(); syncBar();
+      renderGrids(); syncBar(); refreshQuota();
     }catch(e){ $('#grid-fashion').innerHTML='<div class="skeleton">Failed to load houses: '+e.message+'</div>'; }
   })();
 })();
diff --git a/server.js b/server.js
index d106449..4c54280 100644
--- a/server.js
+++ b/server.js
@@ -1128,25 +1128,64 @@ setInterval(() => load(true), 30000);
 // check as a CRITICAL bypass (anyone could mint a token with {role:"admin"}).
 const { isAdmin, requireAdmin } = require('./src/admin-gate');
 
-// ── Style Guides — Brand Selector (admin) ──────────────────────────────────
+// ── Style Guides — Brand Selector (PUBLIC, metered) ────────────────────────
 // A curated picker of fashion + wallcovering houses whose palettes/signatures
 // seed the generative pipeline. Three live actions on the page:
 //   • Generate  → POST /api/generator/batch (new SDXL designs in the houses' look)
 //   • Apply     → POST /api/design/:id/recolor (restyle an existing studio design)
 //   • Export    → portable style-guide JSON / Figma block (client-side)
-// Lives behind the same admin gate as the curators. Reachable two ways:
-//   /admin/styleguides  AND  the bare root of the styleguides.wallco.ai vhost.
+// Open to the public (Steve 2026-06-01 "give user all abilities") but METERED:
+// every non-admin visitor gets SG_IMAGE_CAP paid renders per rolling 24h,
+// keyed by client IP. Generate counts its batch size; Recolor counts 1 each.
+// Admins (cookie / ?admin / localhost) are unlimited. The cap protects the
+// Replicate/Gemini bill from open-internet abuse.
+const SG_IMAGE_CAP = 5;
+const SG_WINDOW_MS = 24 * 60 * 60 * 1000;
+const _sgQuota = new Map(); // ip -> { used, resetAt }
+function sgClientIp(req) {
+  return String(req.headers['x-real-ip'] || '').trim()
+    || String(req.headers['x-forwarded-for'] || '').split(',')[0].trim()
+    || (req.socket && req.socket.remoteAddress) || 'unknown';
+}
+function sgBudget(req) {
+  const ip = sgClientIp(req), now = Date.now();
+  let e = _sgQuota.get(ip);
+  if (!e || now > e.resetAt) { e = { used: 0, resetAt: now + SG_WINDOW_MS }; _sgQuota.set(ip, e); }
+  return { ip, entry: e, used: e.used, cap: SG_IMAGE_CAP,
+           remaining: Math.max(0, SG_IMAGE_CAP - e.used), resetAt: e.resetAt };
+}
+function sgConsume(req, k) { sgBudget(req).entry.used += Math.max(0, k | 0); }
+// Header the styleguides page stamps on its paid calls, so opening recolor to
+// the public here does NOT open it on the main storefront PDPs (those stay 404
+// for non-admins). batch is already public, so it's capped for everyone non-admin.
+function fromSG(req) { return String(req.headers['x-sg'] || '') === '1'; }
+// Recolor gate: admin → unlimited; styleguides public within budget → allow;
+// anyone else → 404 (storefront recolor stays admin-only as before).
+function sgRecolorGate(req, res, next) {
+  if (isAdmin(req)) return next();
+  if (fromSG(req)) {
+    if (sgBudget(req).remaining <= 0) {
+      return res.status(429).json({ ok: false, error: `Free limit reached — ${SG_IMAGE_CAP} images per day. Come back tomorrow.` });
+    }
+    return next();
+  }
+  return res.status(404).type('html').send('<h1>404</h1>');
+}
+
 app.get('/', (req, res, next) => {
   if (/^styleguides\./i.test(req.hostname || '')) {
-    if (!isAdmin(req)) return res.status(404).type('html').send('<h1>404</h1>');
     return res.sendFile(path.join(__dirname, 'public', 'admin', 'styleguides.html'));
   }
   next();
 });
 app.get('/admin/styleguides', (req, res) => {
-  if (!isAdmin(req)) return res.status(404).type('html').send('<h1>404</h1>');
   res.sendFile(path.join(__dirname, 'public', 'admin', 'styleguides.html'));
 });
+app.get('/api/styleguides/quota', (req, res) => {
+  if (isAdmin(req)) return res.json({ ok: true, admin: true, unlimited: true, cap: SG_IMAGE_CAP, remaining: SG_IMAGE_CAP, used: 0 });
+  const b = sgBudget(req);
+  res.json({ ok: true, admin: false, unlimited: false, cap: b.cap, used: b.used, remaining: b.remaining, resetAt: b.resetAt });
+});
 app.get('/api/styleguides/brands', (req, res) => {
   try {
     const { FASHION_PALETTES } = require('./scripts/fashion_palettes.js');
@@ -21199,7 +21238,16 @@ app.post('/api/generator/batch', (req, res) => {
   const { colors = '', elements = [], styles = [], patterns = [], extra_text = '' } = req.body || {};
   // Clamp the requested count to a sane 1–50 so a fat-fingered number can't
   // spawn a runaway render job.
-  const n = Math.max(1, Math.min(50, parseInt(req.body && req.body.n, 10) || 1));
+  let n = Math.max(1, Math.min(50, parseInt(req.body && req.body.n, 10) || 1));
+  // Public metering (Steve 2026-06-01): non-admin callers get SG_IMAGE_CAP
+  // paid renders / 24h per IP. Clamp n to what's left; refuse when exhausted.
+  if (!isAdmin(req)) {
+    const b = sgBudget(req);
+    if (b.remaining <= 0) {
+      return res.status(429).json({ ok: false, error: `Free limit reached — ${SG_IMAGE_CAP} images per day. Come back tomorrow.`, remaining: 0, cap: SG_IMAGE_CAP });
+    }
+    n = Math.min(n, b.remaining);
+  }
 
   // Build the prompt (one prompt drives the whole batch; each render gets its
   // own random seed so the N outputs are siblings, not duplicates).
@@ -21278,8 +21326,11 @@ app.post('/api/generator/batch', (req, res) => {
       } catch {}
     });
 
+    // Charge the public budget for the renders we just kicked off.
+    if (!isAdmin(req)) sgConsume(req, n);
     // Respond immediately — the batch renders in the background.
     res.json({ ok: true, run_id: runId, count: n, status: 'running', prompt,
+               remaining: isAdmin(req) ? null : sgBudget(req).remaining,
                note: `Rendering ${n} design${n>1?'s':''} in the background — watch the Recent Runs list. Safe to close this tab.` });
   } catch (e) { res.status(500).json({ ok:false, error: e.message }); }
 });
@@ -23729,7 +23780,7 @@ const HUE_REP_HEX = {
   neutral:'#9A8C78', charcoal:'#3A3A3A',
 };
 
-app.post('/api/design/:id/recolor', requireAdmin, express.json(), async (req, res) => {
+app.post('/api/design/:id/recolor', sgRecolorGate, express.json(), async (req, res) => {
   if (!isAdmin(req)) return res.status(404).json({ error: 'not found' });
   try {
     const id = parseInt(req.params.id, 10);
@@ -23941,7 +23992,8 @@ app.post('/api/design/:id/recolor', requireAdmin, express.json(), async (req, re
       tags: baseTags,
     });
 
-    res.json({ ok: true, parent_id: id, new_id: newId, new_image_url: '/designs/img/' + filename, hue: hueKey, elapsed_s: elapsed });
+    if (!isAdmin(req)) sgConsume(req, 1);
+    res.json({ ok: true, parent_id: id, new_id: newId, new_image_url: '/designs/img/' + filename, hue: hueKey, elapsed_s: elapsed, remaining: isAdmin(req) ? null : sgBudget(req).remaining });
   } catch (e) {
     res.status(500).json({ ok: false, error: e.message });
   }

← 0fbb86a feat(styleguides): Brand Selector admin tool wired to genera  ·  back to Wallco Ai  ·  Guard /api/design and /api/design/ (missing id) → redirect h 703d174 →