← back to Dw Signup Fulfillment
TK: cap guarded auto-approve at 50/day, falling back to the review card
a4afa27ee327886417053fb5a731824a84849738 · 2026-09-10 10:18:43 -0700 · Steve Abrams
Steve: "yes cap it" — the optional daily cap the dw-trade-auto-approve memo
offered but left off.
Auto-approve instantly grants a real trade account (trade pricing + free memo
samples) to ANY valid submission, and its dedupe only stops SAME-email
re-submits, so the memo's own "known limits" conceded a sprayer using unique
emails is bounded only by the 5/hr/IP rate limit.
TRADE_AUTO_APPROVE_DAILY_CAP (default 50) bounds it per UTC day. Past the cap a
signup falls back to the existing review card rather than being refused, so a
burst degrades to "a human looks at it" instead of dropping applicants on the
floor. The count is taken at DISPATCH, not on success — an approve() that later
fails has still consumed a slot, which errs toward under-approving, the safe
direction for a control that exists to bound abuse.
lib/auto-approve-ledger.js persists a per-UTC-day count to
data/auto-approve-ledger.json, modeled on lib/mint-ledger.js (the gift-card
money backstop) — same shape, same UTC keying, same restart durability. An
in-memory counter would reset on every pm2 reload and deploy, i.e. exactly when
a cap must not reset.
DEPLOY SAFETY: added /data/auto-approve-ledger.json to RSYNC_EXTRA_EXCLUDES.
deploy.sh runs rsync --delete, so without it a deploy would ship this machine's
copy over prod's and RESET the day's count — silently defeating the cap. Also
added /data/mint-ledger.json, which had the same gap already: it is the
gift-card liability cap and was unprotected. Latent only because the file does
not exist on prod yet; it would have bitten on the first real mint. Verified by
a live --dry-run against prod: nothing under data/ is touched.
Tests: verification/tk11285/auto-approve-cap-test.js, 9 checks — counts, caps at
>= (not >), survives a restart, a previous day does not cap today, the default
is finite, and the REAL ledger is never written (the test points the module at a
throwaway path via AUTO_APPROVE_LEDGER_PATH and cleans up in a finally).
Off-by-one sensitivity demonstrated: with > instead of >=, a 4th auto-approve
slips through at cap=3.
Suite: trade-approval 27/27, commitRows 9/9, sendonly 7/7, cap 9/9, selftest pass.
NOT DEPLOYED. Gate 1 of the auto-approve memo is still Steve's call, and that
deploy would turn auto-approve ON in production.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Files touched
M .deploy.confA lib/auto-approve-ledger.jsM lib/config.jsM server.jsA verification/tk11285/auto-approve-cap-test.js
Diff
commit a4afa27ee327886417053fb5a731824a84849738
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Thu Sep 10 10:18:43 2026 -0700
TK: cap guarded auto-approve at 50/day, falling back to the review card
Steve: "yes cap it" — the optional daily cap the dw-trade-auto-approve memo
offered but left off.
Auto-approve instantly grants a real trade account (trade pricing + free memo
samples) to ANY valid submission, and its dedupe only stops SAME-email
re-submits, so the memo's own "known limits" conceded a sprayer using unique
emails is bounded only by the 5/hr/IP rate limit.
TRADE_AUTO_APPROVE_DAILY_CAP (default 50) bounds it per UTC day. Past the cap a
signup falls back to the existing review card rather than being refused, so a
burst degrades to "a human looks at it" instead of dropping applicants on the
floor. The count is taken at DISPATCH, not on success — an approve() that later
fails has still consumed a slot, which errs toward under-approving, the safe
direction for a control that exists to bound abuse.
lib/auto-approve-ledger.js persists a per-UTC-day count to
data/auto-approve-ledger.json, modeled on lib/mint-ledger.js (the gift-card
money backstop) — same shape, same UTC keying, same restart durability. An
in-memory counter would reset on every pm2 reload and deploy, i.e. exactly when
a cap must not reset.
DEPLOY SAFETY: added /data/auto-approve-ledger.json to RSYNC_EXTRA_EXCLUDES.
deploy.sh runs rsync --delete, so without it a deploy would ship this machine's
copy over prod's and RESET the day's count — silently defeating the cap. Also
added /data/mint-ledger.json, which had the same gap already: it is the
gift-card liability cap and was unprotected. Latent only because the file does
not exist on prod yet; it would have bitten on the first real mint. Verified by
a live --dry-run against prod: nothing under data/ is touched.
Tests: verification/tk11285/auto-approve-cap-test.js, 9 checks — counts, caps at
>= (not >), survives a restart, a previous day does not cap today, the default
is finite, and the REAL ledger is never written (the test points the module at a
throwaway path via AUTO_APPROVE_LEDGER_PATH and cleans up in a finally).
Off-by-one sensitivity demonstrated: with > instead of >=, a 4th auto-approve
slips through at cap=3.
Suite: trade-approval 27/27, commitRows 9/9, sendonly 7/7, cap 9/9, selftest pass.
NOT DEPLOYED. Gate 1 of the auto-approve memo is still Steve's call, and that
deploy would turn auto-approve ON in production.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
---
.deploy.conf | 6 ++-
lib/auto-approve-ledger.js | 41 +++++++++++++++++++
lib/config.js | 4 ++
server.js | 17 +++++++-
verification/tk11285/auto-approve-cap-test.js | 59 +++++++++++++++++++++++++++
5 files changed, 124 insertions(+), 3 deletions(-)
diff --git a/.deploy.conf b/.deploy.conf
index d62490d..492cd29 100644
--- a/.deploy.conf
+++ b/.deploy.conf
@@ -9,7 +9,11 @@ INSTALL_CMD="npm ci --omit=dev"
# trade applications with this machine's fixture copy and delete the rollback
# maps outright. Measured 2026-09-10: prod trade-applications.jsonl was 19064
# bytes / 23 rows / 16 pending; the local copy was 727 bytes / 2 test rows.
+# The two *-ledger.json files are DAILY-CAP state: auto-approve-ledger.json bounds how many
+# trade accounts can be auto-granted per UTC day, mint-ledger.json bounds gift-card liability.
+# Shipping this machine's copy over prod's would RESET the day's count and defeat the cap, so
+# they are excluded like any other runtime state.
# Keep this as ONE assignment — a second RSYNC_EXTRA_EXCLUDES silently shadows it.
# /verification is local test evidence — prod has no use for it, and shipping it
# trips deploy.sh's delete-guard (its risky-pattern regex matches "verificat.*\.json").
-RSYNC_EXTRA_EXCLUDES="/verification /data/trade-applications.jsonl /data/trade-applications.jsonl.* /data/recovery-*.json /data/trade-notify-failures.jsonl /data/latest.json /data/honor-reissue-ledger.jsonl /data/honor-reissue-ledger.jsonl.*"
+RSYNC_EXTRA_EXCLUDES="/verification /data/auto-approve-ledger.json /data/mint-ledger.json /data/trade-applications.jsonl /data/trade-applications.jsonl.* /data/recovery-*.json /data/trade-notify-failures.jsonl /data/latest.json /data/honor-reissue-ledger.jsonl /data/honor-reissue-ledger.jsonl.*"
diff --git a/lib/auto-approve-ledger.js b/lib/auto-approve-ledger.js
new file mode 100644
index 0000000..0c23701
--- /dev/null
+++ b/lib/auto-approve-ledger.js
@@ -0,0 +1,41 @@
+'use strict';
+// Daily auto-approve ledger — the volume backstop for guarded auto-approve.
+// TRADE_AUTO_APPROVE instantly grants a `trade` account (trade pricing + free memo
+// samples) to ANY valid submission, and the only other volume control is the 5/hr/IP
+// rate limit — so a sprayer using unique emails from rotating IPs is otherwise
+// unbounded. Persists a per-UTC-day {count} to data/auto-approve-ledger.json so a
+// restart or a pm2 reload cannot reset the cap mid-day, and so there is an auditable
+// record of how many trade accounts were auto-granted each day.
+//
+// Modeled deliberately on lib/mint-ledger.js (the gift-card money backstop) — same
+// shape, same UTC-day keying, same restart-durability. Do not "simplify" this to an
+// in-memory counter: that resets on every deploy.
+const fs = require('fs');
+const path = require('path');
+
+// Path is overridable ONLY so tests can point at a throwaway file — production never
+// sets this. A test that wrote to the real ledger would corrupt the live day's count
+// (and an earlier test in this repo did exactly that class of damage, TK-11377).
+const P = process.env.AUTO_APPROVE_LEDGER_PATH
+ || path.join(__dirname, '..', 'data', 'auto-approve-ledger.json');
+
+function today() { return new Date().toISOString().slice(0, 10); } // UTC YYYY-MM-DD
+function read() { try { return JSON.parse(fs.readFileSync(P, 'utf8')); } catch { return {}; } }
+function write(o) { fs.mkdirSync(path.dirname(P), { recursive: true }); fs.writeFileSync(P, JSON.stringify(o, null, 2)); }
+
+function todayCount() { const d = read()[today()]; return d ? d.count : 0; }
+
+// Record one auto-approve; returns the updated day record {count}.
+// Counted at DISPATCH, not on success: an approve() that later fails has still consumed
+// a slot. That errs toward under-approving, which is the safe direction for a control
+// whose whole job is to bound how many trade accounts a bad actor can mint.
+function record() {
+ const o = read();
+ const d = today();
+ o[d] = o[d] || { count: 0 };
+ o[d].count += 1;
+ write(o);
+ return o[d];
+}
+
+module.exports = { today, todayCount, record, PATH: P };
diff --git a/lib/config.js b/lib/config.js
index 237128e..96caa6b 100644
--- a/lib/config.js
+++ b/lib/config.js
@@ -135,6 +135,10 @@ const config = {
// DRY_RUN the approve() call only simulates (no live tag/email), like every flow here.
TRADE_AUTO_APPROVE: !(String(process.env.TRADE_AUTO_APPROVE || '1').toLowerCase() === '0'
|| String(process.env.TRADE_AUTO_APPROVE || '1').toLowerCase() === 'false'),
+ // Volume backstop for auto-approve. Beyond this many auto-approvals in one UTC day,
+ // new signups fall back to the legacy review card instead of being granted instantly.
+ // 0 disables auto-approve entirely; the ledger is durable across restarts/deploys.
+ TRADE_AUTO_APPROVE_DAILY_CAP: parseInt(process.env.TRADE_AUTO_APPROVE_DAILY_CAP || '50', 10),
// --- Trade-application notify + one-click email approval ---
// Every new /trade/apply emails a review card (via George) to this office inbox
diff --git a/server.js b/server.js
index 00ad0cb..ea48327 100644
--- a/server.js
+++ b/server.js
@@ -17,6 +17,7 @@ const giftcard = require('./lib/giftcard'); // legacy alternate — s
const retailCode = require('./lib/retail-code'); // legacy alternate — shared function code
const giftcodeDiscount = require('./lib/giftcode-discount'); // legacy alternate — collection-scoped code (unsafe: samples share a product with the roll)
const trade = require('./lib/trade');
+const autoApproveLedger = require('./lib/auto-approve-ledger');
const { createRateLimiter } = require('./lib/rate-limit'); // shared per-IP sliding-window limiter
const reps = require('./lib/reps');
const email = require('./lib/email');
@@ -181,10 +182,22 @@ app.post('/trade/apply', tradeCors, async (req, res) => {
// POST isn't blocked on Shopify+George; DRY_RUN-safe (approve() only simulates in dev).
// On success approve() emails the applicant "you're approved" + a staff FYI goes to the
// office — so we DON'T also send the separate designer-welcome (avoids a redundant email).
- if (config.TRADE_AUTO_APPROVE) {
+ // DAILY CAP (Steve, 2026-09-10): auto-approve grants a real trade account, and dedupe
+ // only stops SAME-email re-submits — a sprayer with unique emails is otherwise bounded
+ // only by 5/hr/IP. Past the cap we fall back to the review card rather than refusing the
+ // applicant, so a burst degrades to "a human looks at it" instead of dropping signups.
+ // Counted at dispatch (see lib/auto-approve-ledger.js) so a burst can't race past it.
+ const autoApproveCapped = config.TRADE_AUTO_APPROVE
+ && autoApproveLedger.todayCount() >= config.TRADE_AUTO_APPROVE_DAILY_CAP;
+ if (autoApproveCapped) {
+ console.error(`[trade] AUTO-APPROVE DAILY CAP HIT (${autoApproveLedger.todayCount()}/${config.TRADE_AUTO_APPROVE_DAILY_CAP}) — ${created.id} (${created.email}) parked for review instead of auto-granting.`);
+ }
+ if (config.TRADE_AUTO_APPROVE && !autoApproveCapped) {
+ autoApproveLedger.record();
autoApproveAndNotify(created).catch(e => console.error('[trade] auto-approve failed:', e.message));
} else {
- // Legacy path (TRADE_AUTO_APPROVE=0): park pending + email the review card + welcome letter.
+ // Review-card path — reached when auto-approve is OFF (TRADE_AUTO_APPROVE=0) OR the
+ // daily cap is exhausted: park pending + email the review card + welcome letter.
notifyTradeApplication(created).catch(e => console.error('[trade] notify failed:', e.message));
(async () => {
const first = created.first_name || (created.contact_name ? String(created.contact_name).split(' ')[0] : '') || (created.email ? created.email.split('@')[0] : '');
diff --git a/verification/tk11285/auto-approve-cap-test.js b/verification/tk11285/auto-approve-cap-test.js
new file mode 100644
index 0000000..0e69bf9
--- /dev/null
+++ b/verification/tk11285/auto-approve-cap-test.js
@@ -0,0 +1,59 @@
+'use strict';
+// TRADE_AUTO_APPROVE_DAILY_CAP — the volume backstop on guarded auto-approve.
+// Never touches the real data/auto-approve-ledger.json: points the module at a
+// throwaway file via AUTO_APPROVE_LEDGER_PATH and removes it in a finally.
+const fs = require('fs');
+const os = require('os');
+const path = require('path');
+
+const DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'tk-cap-'));
+process.env.AUTO_APPROVE_LEDGER_PATH = path.join(DIR, 'ledger.json');
+
+const ROOT = path.join(__dirname, '..', '..');
+const REAL_LEDGER = path.join(ROOT, 'data', 'auto-approve-ledger.json');
+const realBefore = fs.existsSync(REAL_LEDGER) ? fs.readFileSync(REAL_LEDGER) : null;
+
+const check = (name, ok, detail) => ({ name, verdict: ok ? 'PASS' : 'FAIL', detail });
+let results = [];
+try {
+ const ledger = require(path.join(ROOT, 'lib', 'auto-approve-ledger'));
+ const CAP = 3;
+ const capped = () => ledger.todayCount() >= CAP; // the exact server.js predicate
+
+ results.push(check('starts empty', ledger.todayCount() === 0, 'count=' + ledger.todayCount()));
+ results.push(check('not capped while under', !capped()));
+
+ ledger.record(); ledger.record();
+ results.push(check('counts each auto-approve', ledger.todayCount() === 2, 'count=' + ledger.todayCount()));
+ results.push(check('still under cap at 2/3', !capped()));
+
+ ledger.record();
+ results.push(check('capped exactly AT the cap (>=, not >)', capped(), 'count=' + ledger.todayCount()));
+
+ // Durability: a pm2 reload / deploy must not reset the day's count.
+ delete require.cache[require.resolve(path.join(ROOT, 'lib', 'auto-approve-ledger'))];
+ const reloaded = require(path.join(ROOT, 'lib', 'auto-approve-ledger'));
+ results.push(check('survives a restart (re-read from disk)', reloaded.todayCount() === 3, 'count=' + reloaded.todayCount()));
+
+ // Day rollover: yesterday's exhausted count must not cap today.
+ fs.writeFileSync(process.env.AUTO_APPROVE_LEDGER_PATH, JSON.stringify({ '2020-01-01': { count: 9999 } }));
+ delete require.cache[require.resolve(path.join(ROOT, 'lib', 'auto-approve-ledger'))];
+ const fresh = require(path.join(ROOT, 'lib', 'auto-approve-ledger'));
+ results.push(check('a previous day does not cap today', fresh.todayCount() === 0, 'count=' + fresh.todayCount()));
+
+ // The default must be a real bound, not unlimited.
+ delete require.cache[require.resolve(path.join(ROOT, 'lib', 'config'))];
+ const cfg = require(path.join(ROOT, 'lib', 'config'));
+ results.push(check('cap defaults to a finite number', Number.isFinite(cfg.TRADE_AUTO_APPROVE_DAILY_CAP) && cfg.TRADE_AUTO_APPROVE_DAILY_CAP > 0,
+ 'default=' + cfg.TRADE_AUTO_APPROVE_DAILY_CAP));
+
+ const realAfter = fs.existsSync(REAL_LEDGER) ? fs.readFileSync(REAL_LEDGER) : null;
+ results.push(check('the REAL ledger was never written',
+ String(realBefore) === String(realAfter), 'before=' + (realBefore ? realBefore.length : 'absent') + ' after=' + (realAfter ? realAfter.length : 'absent')));
+
+ const failed = results.filter((r) => r.verdict === 'FAIL');
+ console.log(JSON.stringify({ suite: 'auto-approve daily cap', passed: results.length - failed.length, failed: failed.length, results }, null, 2));
+ process.exitCode = failed.length ? 1 : 0;
+} finally {
+ try { fs.rmSync(DIR, { recursive: true, force: true }); } catch { /* best effort */ }
+}
← c90789d TK-11361: archive both retired DW Free Samples (fn 01a0475d)
·
back to Dw Signup Fulfillment
·
TK-11366: store-wide signal survives cart-mix control - but f050f30 →