[object Object]

← back to Wallco Ai

SEC-4: require auth on Gemini-burning endpoints + rate-limit /furnish

74da46f4b1a12c7302b3496cbfca5368d1f5400c · 2026-05-23 11:49:31 -0700 · Steve Abrams

Five endpoints were either fully unauthenticated or trivially abusable
to force paid Gemini calls:

src/marketplace/ai.js (4 routes, all real Gemini-cost endpoints):
  POST /api/marketplace/ai/generate-colorways    (6 parallel Gemini per call)
  POST /api/marketplace/ai/generate-stripe-plaid
  POST /api/marketplace/ai/tag-pattern
  POST /api/marketplace/ai/write-pattern-description

Added a shared requireAdminOrDesigner middleware in
src/marketplace/index.js — allows either an authenticated designer
cookie OR an admin gate (env ADMIN_TOKEN or src/admin-gate). All four
ai routes now go through it. Anonymous POST returns 401 instead of
burning the call. mount(app) signature extended to accept the gate via
deps; falls back to no-op so the module still imports standalone.

server.js POST /api/design/:id/furnish (15757):
  - Still public for the customer-facing /design/:id page (cache hits
    return immediately for anon users — no Gemini cost).
  - `?refresh=1` cache-bust is now admin-only (was anyone-can-force-
    fresh-Gemini-call). Without admin, refresh=1 is silently ignored.
  - express-rate-limit wrapper added — 20 req/min/IP. Prevents a single
    bad actor from forcing fresh Gemini calls by sweeping different
    design IDs.

Per-design × per-context cache file at data/furnish-cache/ continues
to serve the common path with zero LLM cost.

Files touched

Diff

commit 74da46f4b1a12c7302b3496cbfca5368d1f5400c
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Sat May 23 11:49:31 2026 -0700

    SEC-4: require auth on Gemini-burning endpoints + rate-limit /furnish
    
    Five endpoints were either fully unauthenticated or trivially abusable
    to force paid Gemini calls:
    
    src/marketplace/ai.js (4 routes, all real Gemini-cost endpoints):
      POST /api/marketplace/ai/generate-colorways    (6 parallel Gemini per call)
      POST /api/marketplace/ai/generate-stripe-plaid
      POST /api/marketplace/ai/tag-pattern
      POST /api/marketplace/ai/write-pattern-description
    
    Added a shared requireAdminOrDesigner middleware in
    src/marketplace/index.js — allows either an authenticated designer
    cookie OR an admin gate (env ADMIN_TOKEN or src/admin-gate). All four
    ai routes now go through it. Anonymous POST returns 401 instead of
    burning the call. mount(app) signature extended to accept the gate via
    deps; falls back to no-op so the module still imports standalone.
    
    server.js POST /api/design/:id/furnish (15757):
      - Still public for the customer-facing /design/:id page (cache hits
        return immediately for anon users — no Gemini cost).
      - `?refresh=1` cache-bust is now admin-only (was anyone-can-force-
        fresh-Gemini-call). Without admin, refresh=1 is silently ignored.
      - express-rate-limit wrapper added — 20 req/min/IP. Prevents a single
        bad actor from forcing fresh Gemini calls by sweeping different
        design IDs.
    
    Per-design × per-context cache file at data/furnish-cache/ continues
    to serve the common path with zero LLM cost.
---
 server.js                | 18 +++++++++++++++---
 src/marketplace/ai.js    | 15 ++++++++++-----
 src/marketplace/index.js | 23 ++++++++++++++++++++++-
 3 files changed, 47 insertions(+), 9 deletions(-)

diff --git a/server.js b/server.js
index 76bf56e..9cb6d9d 100644
--- a/server.js
+++ b/server.js
@@ -15754,7 +15754,16 @@ function furnishCachePath(id, ctx) {
   return path.join(FURNISH_CACHE_DIR, `${id}-${ctx}.json`);
 }
 
-app.post('/api/design/:id/furnish', async (req, res) => {
+// SEC-4: per-IP rate-limit for /api/design/:id/furnish — credit-burn protection
+// for the customer-facing path (cache miss → Gemini text call). The route stays
+// public so the design-detail page UI works for anon users, but a single IP
+// can't flood Gemini with cache-busting requests.
+let __furnishLimiter;
+try {
+  const rateLimit = require('express-rate-limit');
+  __furnishLimiter = rateLimit({ windowMs: 60_000, max: 20, standardHeaders: 'draft-7', legacyHeaders: false });
+} catch (_) { __furnishLimiter = (_req, _res, next) => next(); }
+app.post('/api/design/:id/furnish', __furnishLimiter, async (req, res) => {
   try {
     const id = parseInt(req.params.id, 10);
     if (!Number.isFinite(id) || id < 1) return res.status(400).json({ ok: false, error: 'bad id' });
@@ -15762,9 +15771,12 @@ app.post('/api/design/:id/furnish', async (req, res) => {
     const validCtx = new Set(['residential','commercial','hospitality','healthcare']);
     if (!validCtx.has(ctx)) return res.status(400).json({ ok: false, error: 'bad context' });
 
-    // Cache hit — skip Gemini, return saved JSON. ?refresh=1 forces re-gen.
+    // SEC-4: only admin may bypass the disk cache. Without this, an anonymous
+    // caller could spam ?refresh=1 to force fresh Gemini calls on every hit.
+    const wantRefresh = req.query.refresh === '1' && isAdmin(req);
+    // Cache hit — skip Gemini, return saved JSON.
     const cachePath = furnishCachePath(id, ctx);
-    if (req.query.refresh !== '1' && fs.existsSync(cachePath)) {
+    if (!wantRefresh && fs.existsSync(cachePath)) {
       try {
         const cached = JSON.parse(fs.readFileSync(cachePath, 'utf8'));
         return res.json({ ...cached, cached: true });
diff --git a/src/marketplace/ai.js b/src/marketplace/ai.js
index 62e2050..df16946 100644
--- a/src/marketplace/ai.js
+++ b/src/marketplace/ai.js
@@ -371,8 +371,13 @@ async function persistCopy(patternId, copy) {
   );
 }
 
-function mount(app) {
-  app.post('/api/marketplace/ai/generate-colorways', async (req, res) => {
+function mount(app, deps = {}) {
+  // SEC-4 (2026-05-23): the four /api/marketplace/ai/* endpoints all burn
+  // Gemini credits per call. Previously fully unauthenticated — any anon
+  // POST forced a real Gemini hit (generate-colorways fires 6 in parallel).
+  // Now gated on admin OR authenticated designer cookie.
+  const gate = deps.requireAdminOrDesigner || ((_req, _res, next) => next());
+  app.post('/api/marketplace/ai/generate-colorways', gate, async (req, res) => {
     const body = req.body || {};
     if (!body.patternId && !body.imageUrl && !body.patternSlug) {
       return res.status(400).json({ error: 'patternId, patternSlug, or imageUrl required' });
@@ -472,7 +477,7 @@ function mount(app) {
   //   - imageUrl alone → tag without persistence
   //   - empty body → return MOCK_TAG_RESPONSE (legacy smoke-test contract)
   //   - Ollama unreachable → fall back to MOCK_TAG_RESPONSE + fallback_reason
-  app.post('/api/marketplace/ai/tag-pattern', async (req, res) => {
+  app.post('/api/marketplace/ai/tag-pattern', gate, async (req, res) => {
     const body = req.body || {};
     let pattern = null;
     try {
@@ -508,7 +513,7 @@ function mount(app) {
   // Write copy for a pattern via qwen3:14b. Body: { patternId? | patternSlug? | title? }.
   // Mirrors tag-pattern: persist on row match, mock fallback on outage, preserves
   // the legacy contract for empty bodies (returns shape, status:"mock").
-  app.post('/api/marketplace/ai/write-pattern-description', async (req, res) => {
+  app.post('/api/marketplace/ai/write-pattern-description', gate, async (req, res) => {
     const body = req.body || {};
     let pattern = null;
     try {
@@ -562,7 +567,7 @@ function mount(app) {
   // selected real-product texture. Persists each variant as a colorway row
   // (ai_generated=true) so they show up on the pattern detail page alongside
   // the recolor variants.
-  app.post('/api/marketplace/ai/generate-stripe-plaid', async (req, res) => {
+  app.post('/api/marketplace/ai/generate-stripe-plaid', gate, async (req, res) => {
     const body = req.body || {};
     const colors = Array.isArray(body.colors) ? body.colors.filter(c => /^#[0-9a-f]{6}$/i.test(c)).slice(0, 12) : [];
     const plaidTypes = Array.isArray(body.plaidTypes) ? body.plaidTypes.map(String).slice(0, 12) : [];
diff --git a/src/marketplace/index.js b/src/marketplace/index.js
index 7407c68..389c1a0 100644
--- a/src/marketplace/index.js
+++ b/src/marketplace/index.js
@@ -131,6 +131,27 @@ function requireAdmin(req, res, next) {
   next();
 }
 
+// SEC-4: shared gate for credit-burning Gemini routes — allow EITHER an
+// authenticated designer OR an admin. Caller (or anon user) without either
+// gets 401. Used by ai.mount() to wrap the four /api/marketplace/ai/* routes.
+async function requireAdminOrDesigner(req, res, next) {
+  // Admin path — same logic as requireAdmin
+  const admTok = process.env.ADMIN_TOKEN || '';
+  if (req.isAdmin === true || (admTok && (req.headers['x-admin-token'] === admTok || req.query?.admin === admTok))) {
+    return next();
+  }
+  // Designer path — same logic as requireDesigner
+  const did = verifyDesigner(readDesignerCookie(req));
+  if (did) {
+    const row = await one(`SELECT id, status FROM mp_designer_profiles WHERE id=$1`, [did]);
+    if (row && row.status !== 'rejected' && row.status !== 'suspended') {
+      req.designer = row;
+      return next();
+    }
+  }
+  return res.status(401).json({ error: 'designer login or admin required' });
+}
+
 // ── multer storage for pattern uploads
 const UPLOAD_DIR = path.join(__dirname, '..', '..', 'public', 'marketplace', 'uploads');
 fs.mkdirSync(UPLOAD_DIR, { recursive: true });
@@ -944,7 +965,7 @@ function mount(app) {
   });
 
   // ── AI stubs
-  require('./ai').mount(app);
+  require('./ai').mount(app, { requireAdminOrDesigner });
   require('./textures').mount(app);
 
   // ── Designer follow toggle + trade project board listing (split out to avoid index bloat).

← 72ddc0e SEC-3: strip payout_details / commission_rate from public de  ·  back to Wallco Ai  ·  SEC-5: drop local_path from public response paths dc21aab →