← 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
M server.jsM src/marketplace/ai.jsM src/marketplace/index.js
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 →