Feature/227 submission ceiling #266

Merged
bermudalamb merged 4 commits from feature/227-submission-ceiling into main 2026-09-02 09:07:12 -05:00
10 changed files with 1181 additions and 4 deletions
+32 -2
View File
@@ -40,7 +40,24 @@ const DEFINITIONS = [
// changed by whoever runs the shop, not by whoever deploys it, and a redeploy
// to change an address would be absurd. Empty means do not notify, which is
// the default and a working configuration.
{ key: 'intake_notify_email', name: 'intakeNotifyEmail', type: 'text', fallback: '' }
{ key: 'intake_notify_email', name: 'intakeNotifyEmail', type: 'text', fallback: '' },
// The whole intake surface over a rolling 24 hours, across every link (#227).
// Per-link caps bound each link, but links accumulate — twenty links at the
// default 25 is five hundred submissions nobody decided to accept.
{ key: 'intake_daily_ceiling', name: 'intakeDailyCeiling', type: 'count', fallback: 100 },
// Well below the ceiling, because this is the one that catches a leaked link
// early — the case the revoke mechanism exists for, and which otherwise
// depends on somebody happening to look.
{
key: 'intake_link_alert_threshold',
name: 'intakeLinkAlertThreshold',
type: 'count',
fallback: 20
},
// An ISO timestamp, or empty. The count is derived from rows that exist, so a
// reset cannot delete anything — it moves the window's start instead, which
// makes it an auditable fact rather than a deletion.
{ key: 'intake_ceiling_reset_at', name: 'intakeCeilingResetAt', type: 'text', fallback: '' }
] as const;
type Definition = (typeof DEFINITIONS)[number];
@@ -50,10 +67,12 @@ export type SettingName = Definition['name'];
export type HoursSettingName = Extract<Definition, { type: 'hours' }>['name'];
export type TextSettingName = Extract<Definition, { type: 'text' }>['name'];
export type ChoiceSettingName = Extract<Definition, { type: 'choice' }>['name'];
export type CountSettingName = Extract<Definition, { type: 'count' }>['name'];
export type AdminSettings = Record<HoursSettingName, number> &
Record<TextSettingName, string> &
Record<ChoiceSettingName, string>;
Record<ChoiceSettingName, string> &
Record<CountSettingName, number>;
export const HOURS_SETTINGS: readonly HoursSettingName[] = DEFINITIONS.filter(
(d): d is Extract<Definition, { type: 'hours' }> => d.type === 'hours'
@@ -99,6 +118,15 @@ function resolveHours(raw: string | undefined, fallback: number): number {
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
}
// Whole submissions, so a ceiling of 12.5 is a typo rather than a preference.
// Falls back rather than yielding NaN for the same reason resolveHours does: a
// NaN ceiling compares false against everything and would silently disable the
// limit it was set to impose.
function resolveCount(raw: string | undefined, fallback: number): number {
const parsed = parseInt(raw ?? '', 10);
return Number.isInteger(parsed) && parsed > 0 ? parsed : fallback;
}
// A stored value that is no longer offered — a model retired since it was
// chosen — falls back rather than being handed on. Drafting with the default
// beats drafting with a model the API will refuse.
@@ -119,6 +147,8 @@ export async function getSettings(): Promise<AdminSettings> {
const raw = stored.get(definition.key);
if (definition.type === 'choice') {
settings[definition.name] = resolveChoice(definition.name, raw, definition.fallback);
} else if (definition.type === 'count') {
settings[definition.name] = resolveCount(raw, definition.fallback);
} else if (definition.type === 'text') {
settings[definition.name] = resolveText(raw, definition.fallback);
} else {
+74
View File
@@ -0,0 +1,74 @@
import { sendMail } from '../mailer';
import { getSettings } from '../adminSettings';
/**
* Abuse alerts, sent directly rather than through the editable templates.
*
* An abuse alert is not copy anyone will want to reword, and making it editable
* means it can be broken — a required placeholder removed from an alert nobody
* reads until an incident is a poor way to discover the validation.
*/
/** At most one of each kind per hour. */
const ALERT_INTERVAL_MS = 60 * 60 * 1000;
/**
* Throttled in memory rather than in the database.
*
* A restart loses it, so a deploy during an incident can send one extra alert.
* That is a far better trade than writing to admin_settings from the request
* path on every refused submission. This is a single-container deployment; were
* it ever replicated, each replica would alert once per window and this would
* have to move.
*/
const lastSent = new Map<string, number>();
function shouldSend(key: string, now: number): boolean {
const previous = lastSent.get(key);
if (previous !== undefined && now - previous < ALERT_INTERVAL_MS) return false;
lastSent.set(key, now);
return true;
}
/** Exported for tests, which need each case to start from silence. */
export function resetAlertThrottleForTests(): void {
lastSent.clear();
}
async function send(key: string, subject: string, html: string): Promise<void> {
const { intakeNotifyEmail } = await getSettings();
const to = intakeNotifyEmail?.trim();
if (!to) return;
if (!shouldSend(key, Date.now())) return;
await sendMail(to, subject, html);
}
export async function alertCeilingReached(used: number, ceiling: number): Promise<void> {
await send(
'ceiling',
'Intake submissions are being refused',
`<p>The intake surface has taken ${used} submissions in the last 24 hours, which is at or ` +
`over the ceiling of ${ceiling}. Further submissions are being refused until the window ` +
`rolls.</p>` +
`<p>The storefront, checkout and admin are unaffected. If this is legitimate, raise the ` +
`ceiling or reset the window. If it is not, revoke the link being used.</p>`
);
}
export async function alertLinkThreshold(
linkId: number,
label: string,
used: number,
threshold: number
): Promise<void> {
await send(
`link:${linkId}`,
`An upload link is being used heavily: ${label}`,
`<p>The link <strong>${label}</strong> has taken ${used} submissions in the last 24 hours, ` +
`past the alert threshold of ${threshold}.</p>` +
`<p>This is the signal that a link has been shared further than intended. If that is what ` +
`has happened, revoke it from the Upload links screen — the submissions already received ` +
`are kept, and are in the review queue.</p>`
);
}
+68
View File
@@ -0,0 +1,68 @@
import { pool } from '../db';
import { getSettings } from '../adminSettings';
/** A rolling day. */
export const WINDOW_MS = 24 * 60 * 60 * 1000;
/**
* Where the counting window starts.
*
* The later of "24 hours ago" and an explicit reset, so a reset forgives what
* came before it without deleting anything — those submissions are real and
* their items are sitting in the review queue either way.
*
* A reset older than the window, unparseable, or in the future falls back to
* the rolling day. The malformed case matters most: an Invalid Date compares
* false against everything, so a typo in this setting would silently disable
* the ceiling it was written to impose.
*/
export function windowStart(now: Date, resetAt: string): Date {
const rolling = new Date(now.getTime() - WINDOW_MS);
if (resetAt.trim() === '') return rolling;
const reset = new Date(resetAt);
if (Number.isNaN(reset.getTime())) return rolling;
if (reset > now) return rolling;
return reset > rolling ? reset : rolling;
}
/**
* Counted from the draft rows themselves rather than from a tally.
*
* Every submission creates exactly one item_drafts row, in the same transaction
* that creates the item, so the rows are the truth. A separate counter would be
* a second thing that can disagree with them — and the one that disagrees
* silently is always the counter.
*/
export async function countSince(start: Date): Promise<number> {
const { rows } = await pool.query<{ count: string }>(
`SELECT count(*)::text AS count FROM item_drafts WHERE created_at > $1`,
[start]
);
return Number(rows[0]?.count ?? 0);
}
export async function countForLinkSince(linkId: number, start: Date): Promise<number> {
const { rows } = await pool.query<{ count: string }>(
`SELECT count(*)::text AS count FROM item_drafts WHERE upload_link_id = $1 AND created_at > $2`,
[linkId, start]
);
return Number(rows[0]?.count ?? 0);
}
export interface CapacityVerdict {
allowed: boolean;
used: number;
ceiling: number;
start: Date;
}
/** Whether the intake surface as a whole has room for one more. */
export async function checkCapacity(now: Date = new Date()): Promise<CapacityVerdict> {
const { intakeDailyCeiling, intakeCeilingResetAt } = await getSettings();
const start = windowStart(now, intakeCeilingResetAt);
const used = await countSince(start);
return { allowed: used < intakeDailyCeiling, used, ceiling: intakeDailyCeiling, start };
}
+22
View File
@@ -94,6 +94,28 @@ router.post('/', asyncRoute(async (req: Request, res: Response) => {
res.status(201).json({ ...link, token, url: `${base}/submit/${token}` });
}));
/**
* Forgives the current ceiling window without deleting anything.
*
* The count is derived from item_drafts rows, which are real submissions with
* real items in the review queue — so a reset moves the window's start rather
* than removing anything. Recovery is automatic as the window rolls; this is
* for the case where the ceiling was hit legitimately and waiting is not
* acceptable.
*
* Declared above `/:id/revoke` deliberately: Express matches in order, and
* `reset-ceiling` would otherwise be read as an id.
*/
router.post('/reset-ceiling', asyncRoute(async (_req: Request, res: Response) => {
await pool.query(
`INSERT INTO admin_settings (key, value, updated_at)
VALUES ('intake_ceiling_reset_at', $1, now())
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = now()`,
[new Date().toISOString()]
);
res.json({ reset: true });
}));
router.post('/:id/revoke', asyncRoute(async (req: Request, res: Response) => {
// COALESCE so revoking twice keeps the original timestamp. The useful fact
// is when access ended, and a second click should neither rewrite that nor
+58
View File
@@ -5,6 +5,9 @@ import { hashToken } from '../uploadLinks';
import { uploadImages, verifyUploadedImages, insertItemImages } from '../imageUpload';
import { intakeViewLimiter, intakeSubmitLimiter } from '../rateLimit';
import { draftQueued } from '../intake/draftingWorker';
import { checkCapacity, countForLinkSince, windowStart } from '../intake/capacity';
import { alertCeilingReached, alertLinkThreshold } from '../intake/abuseAlert';
import { getSettings } from '../adminSettings';
const router = Router();
@@ -61,6 +64,53 @@ async function usableLink(token: string): Promise<LinkRow | null> {
* and the unlink would skip. Ordering this ahead of `uploadImages` is the
* whole mitigation, and a test asserts it.
*/
/**
* Refuses when the intake surface as a whole is over its ceiling.
*
* Ordered ahead of `uploadImages` for the same reason `requireUsableLink` is: a
* refused submission must write zero bytes to disk. Ordering it after would
* accept the upload, store the files and then throw them away, which is the
* expensive half of the work this exists to prevent.
*
* 503 rather than 403. The sender has done nothing wrong, their link is fine,
* and the condition clears by itself as the window rolls.
*/
/**
* Alerts when one link crosses its threshold.
*
* A named function rather than an inline IIFE in the handler. The wrapper guard
* flags any `async` inside a route registration that is not directly preceded
* by `asyncRoute(`, and it cannot tell an inner IIFE from an unwrapped handler —
* nor should it have to.
*/
async function alertIfLinkIsBusy(link: LinkRow): Promise<void> {
const { intakeLinkAlertThreshold, intakeCeilingResetAt } = await getSettings();
const used = await countForLinkSince(link.id, windowStart(new Date(), intakeCeilingResetAt));
if (used >= intakeLinkAlertThreshold) {
await alertLinkThreshold(link.id, link.label, used, intakeLinkAlertThreshold);
}
}
const requireCapacity = asyncRoute(
async (_req: Request, res: Response, next: NextFunction) => {
const verdict = await checkCapacity();
if (verdict.allowed) {
next();
return;
}
// Not awaited: an alert that fails must not become a failed request for
// somebody who has done nothing wrong, and the refusal is already decided.
void alertCeilingReached(verdict.used, verdict.ceiling).catch((err) =>
console.error('[intake] ceiling alert failed:', err)
);
res.status(503).json({
error: 'we are not able to accept submissions right now — please try again later'
});
}
);
const requireUsableLink = asyncRoute(
async (req: Request, res: Response, next: NextFunction) => {
const link = await usableLink(req.params.token as string);
@@ -86,6 +136,7 @@ router.post(
'/:token',
intakeSubmitLimiter,
requireUsableLink,
requireCapacity,
uploadImages,
asyncRoute(async (req: Request, res: Response) => {
// Set by requireUsableLink above. Re-checked rather than asserted non-null,
@@ -157,6 +208,13 @@ router.post(
// is a few minutes' delay rather than a lost submission.
void draftQueued(1).catch((err) => console.error('[drafting] after submission:', err));
// The signal that a link has been shared further than intended, which is
// the case the revoke mechanism exists for and which otherwise depends on
// somebody happening to look. Not awaited, for the same reason as above.
void alertIfLinkIsBusy(link).catch((err) =>
console.error('[intake] link threshold alert failed:', err)
);
// No item id in the response: the sender has no business knowing about
// the catalogue, and nothing they could do with it.
res.status(201).json({ ok: true });
@@ -33,7 +33,10 @@ describe('GET /api/admin/settings', () => {
draftingModel: 'claude-sonnet-5',
// Empty by default: nowhere to send the intake notification is a working
// configuration, and means simply do not send one (#224).
intakeNotifyEmail: ''
intakeNotifyEmail: '',
intakeDailyCeiling: 100,
intakeLinkAlertThreshold: 20,
intakeCeilingResetAt: ''
});
});
});
@@ -0,0 +1,143 @@
import request from 'supertest';
import app from '../../src/app';
import { pool } from '../../src/db';
import { resetDb, closeDb } from './setup/testDb';
import { resetAlertThrottleForTests } from '../../src/intake/abuseAlert';
const PNG = Buffer.from(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==',
'base64'
);
beforeEach(async () => {
await resetDb();
resetAlertThrottleForTests();
});
afterAll(async () => {
await pool.end();
await closeDb();
});
async function makeLink(): Promise<string> {
const res = await request(app).post('/api/admin/upload-links').send({ label: 'ceiling spec' });
expect(res.status).toBe(201);
return res.body.token;
}
/** Fills the window with drafts, as though earlier submissions had arrived. */
async function fillWindow(count: number): Promise<void> {
for (let i = 0; i < count; i++) {
const { rows } = await pool.query<{ id: number }>(
`INSERT INTO items (name, status) VALUES ('filler', 'pending') RETURNING id`
);
await pool.query(`INSERT INTO item_drafts (item_id) VALUES ($1)`, [rows[0]!.id]);
}
}
async function setCeiling(value: number): Promise<void> {
await pool.query(
`INSERT INTO admin_settings (key, value, updated_at) VALUES ('intake_daily_ceiling', $1, now())
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = now()`,
[String(value)]
);
}
const submit = (token: string) =>
request(app).post(`/api/intake/${token}`).attach('images', PNG, 'a.png');
const draftCount = async (): Promise<number> =>
(await pool.query<{ c: number }>(`SELECT count(*)::int AS c FROM item_drafts`)).rows[0]?.c ?? 0;
describe('the submission ceiling', () => {
it('accepts a submission below the ceiling', async () => {
await setCeiling(5);
const token = await makeLink();
expect((await submit(token)).status).toBe(201);
});
it('refuses with 503 once the ceiling is reached', async () => {
await setCeiling(2);
await fillWindow(2);
const token = await makeLink();
const res = await submit(token);
expect(res.status).toBe(503);
expect(res.body.error).toMatch(/later/i);
});
// Nothing the sender did is wrong and the condition clears by itself, so this
// must not read as a rejection of them or of their link.
it('leaves the link usable, so it works again when there is room', async () => {
await setCeiling(1);
await fillWindow(1);
const token = await makeLink();
expect((await submit(token)).status).toBe(503);
await setCeiling(50);
expect((await submit(token)).status).toBe(201);
});
// A refused submission must write nothing. The check is ordered ahead of
// uploadImages for the same reason requireUsableLink is.
it('stores no item when it refuses', async () => {
await setCeiling(1);
await fillWindow(1);
const token = await makeLink();
await submit(token);
expect(await draftCount()).toBe(1);
});
/**
* The constraint that matters most. Intake being throttled is an
* inconvenience; the shop being unable to add its own stock is an outage.
*/
it('never applies to the admin upload path', async () => {
await setCeiling(1);
await fillWindow(5);
const res = await request(app)
.post('/api/admin/items')
.field('name', 'admin adds stock')
.field('price', '42.00')
.attach('images', PNG, 'a.png');
// 200, not 201 — this route answers with the created item rather than a
// bare created status, unlike the intake route.
expect(res.status).toBe(200);
});
it('accepts again after the window is reset', async () => {
await setCeiling(1);
await fillWindow(1);
const token = await makeLink();
expect((await submit(token)).status).toBe(503);
const reset = await request(app).post('/api/admin/upload-links/reset-ceiling');
expect(reset.status).toBe(200);
expect((await submit(token)).status).toBe(201);
});
// A reset forgives; it does not erase. Those submissions are real and their
// items are in the review queue.
it('keeps the submissions it counted after a reset', async () => {
await fillWindow(3);
await request(app).post('/api/admin/upload-links/reset-ceiling');
expect(await draftCount()).toBe(3);
});
// reset-ceiling is declared above /:id/revoke, so Express must not read it as
// an id and try to revoke a link called "reset-ceiling".
it('does not collide with the revoke route', async () => {
const res = await request(app).post('/api/admin/upload-links/reset-ceiling');
expect(res.body).toEqual({ reset: true });
});
});
+7 -1
View File
@@ -40,7 +40,13 @@ export async function resetDb(): Promise<void> {
// it silently changes the mail every later suite asserts on. That is not
// hypothetical: a subject of "Gone" written by the template tests reached the
// favorite-alert tests and made five of them fail somewhere else entirely.
await testPool.query(`DELETE FROM admin_settings WHERE key LIKE 'email\_%'`);
//
// Same reasoning, same failure, for the intake settings (#227): a ceiling of
// 1 left behind by the ceiling suite makes every later submission refuse with
// a 503, in files that never mention a ceiling.
await testPool.query(
`DELETE FROM admin_settings WHERE key LIKE 'email\_%' OR key LIKE 'intake\_%'`
);
}
export async function closeDb(): Promise<void> {
+37
View File
@@ -0,0 +1,37 @@
import { windowStart, WINDOW_MS } from '../../src/intake/capacity';
const NOW = new Date('2026-09-01T12:00:00.000Z');
const DAY_AGO = new Date(NOW.getTime() - WINDOW_MS);
describe('windowStart', () => {
it('is 24 hours ago when there has been no reset', () => {
expect(windowStart(NOW, '').toISOString()).toBe(DAY_AGO.toISOString());
});
// A reset inside the window is the point of it: it forgives what came before
// without deleting anything.
it('is the reset when the reset is more recent', () => {
const reset = '2026-09-01T09:00:00.000Z';
expect(windowStart(NOW, reset).toISOString()).toBe(reset);
});
// An old reset must not widen the window beyond 24 hours, which would make
// the ceiling stricter over time rather than rolling.
it('ignores a reset older than the window', () => {
expect(windowStart(NOW, '2026-08-01T00:00:00.000Z').toISOString()).toBe(DAY_AGO.toISOString());
});
// A malformed stored value must not produce an Invalid Date, which compares
// false against everything and would silently disable the ceiling.
it('falls back to 24 hours ago for an unparseable reset', () => {
expect(windowStart(NOW, 'not-a-date').toISOString()).toBe(DAY_AGO.toISOString());
});
it('falls back for a reset in the future', () => {
expect(windowStart(NOW, '2027-01-01T00:00:00.000Z').toISOString()).toBe(DAY_AGO.toISOString());
});
it('tolerates whitespace around an empty setting', () => {
expect(windowStart(NOW, ' ').toISOString()).toBe(DAY_AGO.toISOString());
});
});
@@ -0,0 +1,736 @@
# Intake Submission Ceiling Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Submissions across every link are bounded over a rolling 24 hours, refusing with a `503` past the ceiling, and the admin is told when that happens — and told earlier when one link starts behaving like a leaked one.
**Architecture:** A count derived from `item_drafts.created_at` rather than a new tally, a middleware in front of the intake POST that refuses past the ceiling, and two alert emails sent directly through `sendMail`. Nothing else in the app is affected.
**Tech Stack:** Express 4, TypeScript, `pg`, nodemailer via the existing mailer, Jest + supertest.
**Spec:** issue #227 (follow-on to #220)
**Already verified against the tree, so no need to re-check:** the intake POST chain is `router.post('/:token', intakeSubmitLimiter, requireUsableLink, uploadImages, handler)` in `src/routes/intake.ts:85`, and `requireUsableLink` is deliberately ordered ahead of `uploadImages` so a refusal writes zero bytes. `item_drafts` has `created_at TIMESTAMPTZ NOT NULL DEFAULT now()` and `upload_link_id` (FK, nullable). `upload_links` has `label` and `submission_count`. Admin settings are rows in `DEFINITIONS` (`src/adminSettings.ts:23`) with types `hours | text | choice`, each resolved by its own module-level reader; `resolveHours` is `parseFloat` with a `> 0` guard. `sendMail(to, subject, html)` is exported from `src/mailer.ts:88` and callers use it fire-and-forget. `getSettings()` returns all settings resolved.
## Global Constraints
- **The ceiling must never apply to the admin upload path.** Intake being throttled is an inconvenience; the shop being unable to add stock is an outage. The check lives in the intake router only, and a test asserts the admin path is unaffected.
- **Count from `item_drafts.created_at`, never a separate tally.** The rows already exist, and a second counter is a second thing that can disagree with reality.
- **The refusal is a `503` saying to try again later**, not a `403`. Nothing the sender did is wrong, and the condition clears by itself.
- **The alert must be rate limited against itself.** An alert per submission during an incident is how a mailbox fills and the signal is lost.
- **Alerts use `sendMail` directly**, not the editable-template system. An abuse alert is not copy anyone will want to reword, and making it editable means it can be broken.
- **Recovery is automatic** as the window rolls. The manual reset is a convenience, not the mechanism.
- **Every route handler wrapped in `asyncRoute`** — `tests/unit/routesAreWrapped.test.ts` enforces it.
- **Commit style:** Conventional Commits, subject ending `(#227)`, no hard wrapping in bodies.
## Two decisions this plan makes that the issue leaves open
**What "manual reset" means when the count is derived.** The count comes from rows that exist, so a reset cannot delete anything — the submissions are real and their items are in the queue. Instead the reset stores a timestamp, and the window becomes "since the later of 24 hours ago and the reset". That keeps a single derived count with no second tally, and makes a reset an auditable fact rather than a deletion.
**Where the alert's own throttle lives.** In memory, module-level, not in the database. It is lost on restart, so a deploy during an incident could send one extra alert — which is an acceptable cost against writing to `admin_settings` from the request path on every refused submission. This is a single-container deployment; were it ever replicated, each replica would alert once per window and this would need moving.
## File Structure
**Created:**
- `backend/src/intake/capacity.ts` — the window, the counts, the decision.
- `backend/src/intake/abuseAlert.ts` — the two alerts and their throttle.
- `backend/tests/unit/capacity.test.ts`
- `backend/tests/integration/intakeCeiling.integration.test.ts`
**Modified:**
- `backend/src/adminSettings.ts` — a `count` type and three settings
- `backend/src/routes/intake.ts` — the middleware, before `uploadImages`
- `backend/src/routes/adminUploadLinks.ts` — the manual reset
---
### Task 1: The settings
**Files:**
- Modify: `backend/src/adminSettings.ts`
- Test: `backend/tests/integration/adminSettings.integration.test.ts`
**Interfaces:**
- Produces: `intakeDailyCeiling: number`, `intakeLinkAlertThreshold: number`, `intakeCeilingResetAt: string`
- [ ] **Step 1: Add a `count` type**
`resolveHours` is `parseFloat` with a `> 0` guard — correct behaviour for a ceiling, but calling a submission count "hours" would be a lie in the type name that the next reader has to decode. Add a reader beside it:
```ts
// Whole submissions, so a ceiling of 12.5 is a typo rather than a preference.
// Falls back rather than yielding NaN, for the same reason resolveHours does:
// a NaN ceiling compares false against everything and silently disables the
// limit it was set to impose.
function resolveCount(raw: string | undefined, fallback: number): number {
const parsed = parseInt(raw ?? '', 10);
return Number.isInteger(parsed) && parsed > 0 ? parsed : fallback;
}
```
Extend the type machinery — `CountSettingName`, the `AdminSettings` intersection, and the branch in `getSettings`:
```ts
export type CountSettingName = Extract<Definition, { type: 'count' }>['name'];
export type AdminSettings = Record<HoursSettingName, number> &
Record<TextSettingName, string> &
Record<ChoiceSettingName, string> &
Record<CountSettingName, number>;
export const COUNT_SETTINGS: readonly CountSettingName[] = DEFINITIONS.filter(
(d): d is Extract<Definition, { type: 'count' }> => d.type === 'count'
).map((d) => d.name);
```
and in `getSettings`, before the `else`:
```ts
} else if (definition.type === 'count') {
settings[definition.name] = resolveCount(raw, definition.fallback);
```
- [ ] **Step 2: Add the three settings**
```ts
// The whole intake surface over a rolling 24 hours, across every link.
// Per-link caps bound each link, but links accumulate — twenty links at the
// default 25 is five hundred submissions nobody decided to accept.
{ key: 'intake_daily_ceiling', name: 'intakeDailyCeiling', type: 'count', fallback: 100 },
// Well below the ceiling, because this is the one that catches a leaked link
// early — the case the whole revoke mechanism exists for, and which currently
// depends on somebody happening to look.
{
key: 'intake_link_alert_threshold',
name: 'intakeLinkAlertThreshold',
type: 'count',
fallback: 20
},
// An ISO timestamp, or empty. The count is derived from rows that exist, so a
// reset cannot delete anything — it moves the window's start instead, which
// makes it an auditable fact rather than a deletion.
{ key: 'intake_ceiling_reset_at', name: 'intakeCeilingResetAt', type: 'text', fallback: '' }
```
- [ ] **Step 3: Update the settings guard test**
`adminSettings.integration.test.ts` asserts the full resolved object, so it fails until the three are listed. Add to the expected object:
```ts
intakeDailyCeiling: 100,
intakeLinkAlertThreshold: 20,
intakeCeilingResetAt: ''
```
- [ ] **Step 4: Verify**
```bash
cd backend && npm run build && npm run test:unit
npx jest -c jest.integration.config.js --runInBand adminSettings
```
Expected: PASS.
- [ ] **Step 5: Commit**
```bash
git add backend/src/adminSettings.ts backend/tests/integration/adminSettings.integration.test.ts
git commit -m "feat(intake): settings for the submission ceiling (#227)"
```
---
### Task 1b: Stop the new settings leaking between suites
`resetDb` deliberately does **not** truncate `admin_settings` — it holds the seeded `cart_expiry_hours` other suites read. It deletes the `email_%` rows for exactly the reason this task exists: a stored value outliving the suite that wrote it silently changes what every later suite sees. The comment there records that happening once already, where a template subject of "Gone" reached the favorite-alert tests and failed five of them somewhere else entirely.
A ceiling of 1 left behind by these tests would do the same thing to `intake.integration.test.ts`, whose submissions would start refusing with a 503 for no reason visible in that file.
- [ ] **Step 1: Extend the cleanup**
In `backend/tests/integration/setup/testDb.ts`, widen the existing delete:
```ts
// Same reasoning as the email_ rows above, and the same failure. A ceiling of
// 1 left behind by the intake-ceiling suite makes every later submission
// refuse with a 503, in a file that never mentions a ceiling.
await testPool.query(
`DELETE FROM admin_settings WHERE key LIKE 'email\\_%' OR key LIKE 'intake\\_%'`
);
```
- [ ] **Step 2: Verify nothing else depended on those rows surviving**
```bash
cd backend && npx jest -c jest.integration.config.js --runInBand
```
Expected: PASS. `intake_notify_email` is cleared by this too, which is correct — the notification tests assert the no-recipient path.
- [ ] **Step 3: Commit**
```bash
git add backend/tests/integration/setup/testDb.ts
git commit -m "test(intake): clear intake settings between integration tests (#227)"
```
---
### Task 2: Counting and deciding
**Files:**
- Create: `backend/src/intake/capacity.ts`, `backend/tests/unit/capacity.test.ts`
**Interfaces:**
- Produces: `windowStart(now: Date, resetAt: string): Date`, `countSince(start: Date): Promise<number>`, `countForLinkSince(linkId: number, start: Date): Promise<number>`, `checkCapacity(): Promise<CapacityVerdict>`
- [ ] **Step 1: Write the failing unit test**
`windowStart` is the only part worth testing in isolation — the rest is a query. It is also the part most likely to be quietly wrong.
```ts
import { windowStart, WINDOW_MS } from '../../src/intake/capacity';
const NOW = new Date('2026-09-01T12:00:00.000Z');
const DAY_AGO = new Date(NOW.getTime() - WINDOW_MS);
describe('windowStart', () => {
it('is 24 hours ago when there has been no reset', () => {
expect(windowStart(NOW, '').toISOString()).toBe(DAY_AGO.toISOString());
});
// A reset inside the window is the point of it: it forgives what came before
// without deleting anything.
it('is the reset when the reset is more recent', () => {
const reset = '2026-09-01T09:00:00.000Z';
expect(windowStart(NOW, reset).toISOString()).toBe(reset);
});
// An old reset must not widen the window beyond 24 hours, which would make
// the ceiling stricter over time rather than rolling.
it('ignores a reset older than the window', () => {
expect(windowStart(NOW, '2026-08-01T00:00:00.000Z').toISOString()).toBe(DAY_AGO.toISOString());
});
// A malformed stored value must not produce an Invalid Date, which compares
// false against everything and would silently disable the ceiling.
it('falls back to 24 hours ago for an unparseable reset', () => {
expect(windowStart(NOW, 'not-a-date').toISOString()).toBe(DAY_AGO.toISOString());
});
it('falls back for a reset in the future', () => {
expect(windowStart(NOW, '2027-01-01T00:00:00.000Z').toISOString()).toBe(DAY_AGO.toISOString());
});
});
```
- [ ] **Step 2: Run to verify it fails**
```bash
cd backend && npx jest -c jest.unit.config.js capacity
```
Expected: FAIL — module not found.
- [ ] **Step 3: Write it**
```ts
import { pool } from '../db';
import { getSettings } from '../adminSettings';
/** A rolling day. */
export const WINDOW_MS = 24 * 60 * 60 * 1000;
/**
* Where the counting window starts.
*
* The later of "24 hours ago" and an explicit reset, so a reset forgives what
* came before it without deleting anything — the submissions are real and their
* items are sitting in the review queue either way.
*
* A reset older than the window, or unparseable, or in the future, falls back
* to the rolling day. The malformed case matters most: an Invalid Date compares
* false against everything, so a typo in this setting would silently disable
* the ceiling it was written to impose.
*/
export function windowStart(now: Date, resetAt: string): Date {
const rolling = new Date(now.getTime() - WINDOW_MS);
if (resetAt.trim() === '') return rolling;
const reset = new Date(resetAt);
if (Number.isNaN(reset.getTime())) return rolling;
if (reset > now) return rolling;
return reset > rolling ? reset : rolling;
}
/**
* Counted from the draft rows themselves rather than a tally.
*
* Every submission creates exactly one item_drafts row in the same transaction
* that creates the item, so the rows are the truth. A separate counter would be
* a second thing that can disagree with it, and the one that disagrees silently
* is always the counter.
*/
export async function countSince(start: Date): Promise<number> {
const { rows } = await pool.query<{ count: string }>(
`SELECT count(*)::text AS count FROM item_drafts WHERE created_at > $1`,
[start]
);
return Number(rows[0]?.count ?? 0);
}
export async function countForLinkSince(linkId: number, start: Date): Promise<number> {
const { rows } = await pool.query<{ count: string }>(
`SELECT count(*)::text AS count FROM item_drafts WHERE upload_link_id = $1 AND created_at > $2`,
[linkId, start]
);
return Number(rows[0]?.count ?? 0);
}
export interface CapacityVerdict {
allowed: boolean;
used: number;
ceiling: number;
start: Date;
}
/** Whether the intake surface as a whole has room for one more. */
export async function checkCapacity(now: Date = new Date()): Promise<CapacityVerdict> {
const { intakeDailyCeiling, intakeCeilingResetAt } = await getSettings();
const start = windowStart(now, intakeCeilingResetAt);
const used = await countSince(start);
return { allowed: used < intakeDailyCeiling, used, ceiling: intakeDailyCeiling, start };
}
```
- [ ] **Step 4: Run to verify it passes**
```bash
cd backend && npx jest -c jest.unit.config.js capacity
```
Expected: PASS, 5 tests.
- [ ] **Step 5: Commit**
```bash
git add backend/src/intake/capacity.ts backend/tests/unit/capacity.test.ts
git commit -m "feat(intake): count submissions across every link (#227)"
```
---
### Task 3: The alerts
**Files:**
- Create: `backend/src/intake/abuseAlert.ts`
**Interfaces:**
- Consumes: `countForLinkSince` (Task 2)
- Produces: `alertCeilingReached(used, ceiling)`, `alertLinkThreshold(linkId, label, used, threshold)`, `resetAlertThrottleForTests()`
- [ ] **Step 1: Write it**
```ts
import { sendMail } from '../mailer';
import { getSettings } from '../adminSettings';
/**
* Abuse alerts, sent directly rather than through the editable templates.
*
* An abuse alert is not copy anyone will want to reword, and making it editable
* means it can be broken — a required placeholder removed from an alert nobody
* reads until an incident is a bad way to discover the validation.
*/
/** At most one of each kind per hour. */
const ALERT_INTERVAL_MS = 60 * 60 * 1000;
/**
* Throttled in memory rather than in the database.
*
* A restart loses it, so a deploy during an incident can send one extra alert.
* That is a far better trade than writing to admin_settings from the request
* path on every refused submission. This is a single-container deployment; were
* it ever replicated each replica would alert once per window, and this would
* have to move.
*/
const lastSent = new Map<string, number>();
function shouldSend(key: string, now: number): boolean {
const previous = lastSent.get(key);
if (previous !== undefined && now - previous < ALERT_INTERVAL_MS) return false;
lastSent.set(key, now);
return true;
}
/** Exported for tests, which need each case to start from silence. */
export function resetAlertThrottleForTests(): void {
lastSent.clear();
}
async function send(key: string, subject: string, html: string): Promise<void> {
const { intakeNotifyEmail } = await getSettings();
const to = intakeNotifyEmail?.trim();
if (!to) return;
if (!shouldSend(key, Date.now())) return;
// Awaited rather than fire-and-forget, but the caller does not await this —
// an alert failing must never turn into a failed request for the sender, who
// has done nothing wrong.
await sendMail(to, subject, html);
}
export async function alertCeilingReached(used: number, ceiling: number): Promise<void> {
await send(
'ceiling',
'Intake submissions are being refused',
`<p>The intake surface has taken ${used} submissions in the last 24 hours, which is at or ` +
`over the ceiling of ${ceiling}. Further submissions are being refused with a 503 until ` +
`the window rolls.</p>` +
`<p>The storefront, checkout and admin are unaffected. If this is legitimate, raise the ` +
`ceiling or reset the window from the Upload links screen. If it is not, revoke the link ` +
`that is being used.</p>`
);
}
export async function alertLinkThreshold(
linkId: number,
label: string,
used: number,
threshold: number
): Promise<void> {
await send(
`link:${linkId}`,
`An upload link is being used heavily: ${label}`,
`<p>The link <strong>${label}</strong> has taken ${used} submissions in the last 24 hours, ` +
`past the alert threshold of ${threshold}.</p>` +
`<p>This is the signal that a link has been shared further than intended. If that is what ` +
`has happened, revoke it from the Upload links screen — the submissions already received ` +
`are kept and are in the review queue.</p>`
);
}
```
- [ ] **Step 2: Verify it compiles and lints**
```bash
cd backend && npm run build && npm run lint
```
- [ ] **Step 3: Commit**
```bash
git add backend/src/intake/abuseAlert.ts
git commit -m "feat(intake): alert the admin about abnormal submission volume (#227)"
```
---
### Task 4: Refusing, and telling someone
**Files:**
- Modify: `backend/src/routes/intake.ts`
- Create: `backend/tests/integration/intakeCeiling.integration.test.ts`
- [ ] **Step 1: Write the failing test**
```ts
import request from 'supertest';
import app from '../../src/app';
import { pool } from '../../src/db';
import { resetDb, closeDb } from './setup/testDb';
import { resetAlertThrottleForTests } from '../../src/intake/abuseAlert';
const PNG = Buffer.from(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==',
'base64'
);
beforeEach(async () => {
await resetDb();
resetAlertThrottleForTests();
});
afterAll(async () => {
await pool.end();
await closeDb();
});
async function makeLink(): Promise<string> {
const res = await request(app).post('/api/admin/upload-links').send({ label: 'ceiling spec' });
return res.body.token;
}
/** Fills the window with drafts that did not come through a link. */
async function fillWindow(count: number): Promise<void> {
for (let i = 0; i < count; i++) {
const { rows } = await pool.query<{ id: number }>(
`INSERT INTO items (name, status) VALUES ('filler', 'pending') RETURNING id`
);
await pool.query(`INSERT INTO item_drafts (item_id) VALUES ($1)`, [rows[0]!.id]);
}
}
async function setCeiling(value: number): Promise<void> {
await pool.query(
`INSERT INTO admin_settings (key, value, updated_at) VALUES ('intake_daily_ceiling', $1, now())
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value`,
[String(value)]
);
}
const submit = (token: string) =>
request(app).post(`/api/intake/${token}`).attach('images', PNG, 'a.png');
describe('the submission ceiling', () => {
it('accepts a submission below the ceiling', async () => {
await setCeiling(5);
const token = await makeLink();
const res = await submit(token);
expect(res.status).toBe(201);
});
it('refuses with 503 once the ceiling is reached', async () => {
await setCeiling(2);
await fillWindow(2);
const token = await makeLink();
const res = await submit(token);
expect(res.status).toBe(503);
expect(res.body.error).toMatch(/later/i);
});
// Nothing the sender did is wrong and the condition clears by itself, so this
// must not read as a rejection of them or of their link.
it('leaves the link usable, so it works again when the window rolls', async () => {
await setCeiling(1);
await fillWindow(1);
const token = await makeLink();
await submit(token);
await setCeiling(50);
expect((await submit(token)).status).toBe(201);
});
// A refused submission must write nothing to disk. The check is ordered ahead
// of uploadImages for the same reason requireUsableLink is.
it('stores no item when it refuses', async () => {
await setCeiling(1);
await fillWindow(1);
const token = await makeLink();
await submit(token);
const { rows } = await pool.query(`SELECT count(*)::int AS c FROM item_drafts`);
expect(rows[0]?.c).toBe(1);
});
/**
* The constraint that matters most. Intake being throttled is an
* inconvenience; the shop being unable to add stock is an outage.
*/
it('never applies to the admin upload path', async () => {
await setCeiling(1);
await fillWindow(5);
const res = await request(app)
.post('/api/admin/items')
.field('name', 'admin adds stock')
.field('price', '42.00')
.attach('images', PNG, 'a.png');
// 200, not 201 — this route answers with the created item rather than a
// bare created status, unlike the intake route beside it.
expect(res.status).toBe(200);
});
// A reset forgives what came before without deleting it.
it('accepts again after the window is reset', async () => {
await setCeiling(1);
await fillWindow(1);
const token = await makeLink();
expect((await submit(token)).status).toBe(503);
await request(app).post('/api/admin/upload-links/reset-ceiling');
expect((await submit(token)).status).toBe(201);
});
it('keeps the submissions it counted after a reset', async () => {
await fillWindow(3);
await request(app).post('/api/admin/upload-links/reset-ceiling');
const { rows } = await pool.query(`SELECT count(*)::int AS c FROM item_drafts`);
expect(rows[0]?.c).toBe(3);
});
});
```
- [ ] **Step 2: Run to verify it fails**
```bash
cd backend && npx jest -c jest.integration.config.js --runInBand intakeCeiling
```
Expected: FAIL — no ceiling, so nothing refuses, and no reset endpoint.
- [ ] **Step 3: Add the middleware**
In `src/routes/intake.ts`, after `requireUsableLink`:
```ts
/**
* Refuses when the intake surface as a whole is over its ceiling.
*
* Ordered ahead of `uploadImages` for the same reason `requireUsableLink` is: a
* refused submission must write zero bytes to disk. Ordering it after would
* accept the upload, store the files, and then throw them away — which is the
* expensive half of the work this exists to prevent.
*
* 503 rather than 403. The sender has done nothing wrong, their link is fine,
* and the condition clears by itself as the window rolls.
*/
const requireCapacity = asyncRoute(
async (req: Request, res: Response, next: NextFunction) => {
const verdict = await checkCapacity();
if (verdict.allowed) {
next();
return;
}
// Not awaited: an alert that fails must not turn into a failed request for
// somebody who has done nothing wrong, and the refusal is already decided.
void alertCeilingReached(verdict.used, verdict.ceiling).catch((err) =>
console.error('[intake] ceiling alert failed:', err)
);
res.status(503).json({
error: 'we are not able to accept submissions right now — please try again later'
});
}
);
```
with the imports:
```ts
import { checkCapacity, countForLinkSince, windowStart } from '../intake/capacity';
import { alertCeilingReached, alertLinkThreshold } from '../intake/abuseAlert';
import { getSettings } from '../adminSettings';
```
and put it in the chain, between the link check and the upload:
```ts
router.post(
'/:token',
intakeSubmitLimiter,
requireUsableLink,
requireCapacity,
uploadImages,
```
- [ ] **Step 4: Alert on a link crossing its threshold**
At the end of the POST handler, after the transaction commits and before the response — beside the existing `draftQueued` call, which is the same shape:
```ts
// The signal that a link has been shared further than intended, which is
// the case the revoke mechanism exists for and which otherwise depends on
// somebody happening to look. Not awaited, for the same reason as above.
void (async () => {
const { intakeLinkAlertThreshold, intakeCeilingResetAt } = await getSettings();
const used = await countForLinkSince(link.id, windowStart(new Date(), intakeCeilingResetAt));
if (used >= intakeLinkAlertThreshold) {
await alertLinkThreshold(link.id, link.label, used, intakeLinkAlertThreshold);
}
})().catch((err) => console.error('[intake] link threshold alert failed:', err));
```
- [ ] **Step 5: Run**
```bash
cd backend && npx jest -c jest.integration.config.js --runInBand intakeCeiling
```
Expected: every test but the two reset ones passes; those need Task 5.
- [ ] **Step 6: Commit**
```bash
git add backend/src/routes/intake.ts backend/tests/integration/intakeCeiling.integration.test.ts
git commit -m "feat(intake): refuse submissions past the daily ceiling (#227)"
```
---
### Task 5: The manual reset
**Files:**
- Modify: `backend/src/routes/adminUploadLinks.ts`
- [ ] **Step 1: Add the endpoint**
```ts
/**
* Forgives the current window without deleting anything.
*
* The count is derived from item_drafts rows, which are real submissions with
* real items in the review queue — so a reset moves the window's start rather
* than removing rows. Recovery is automatic as the window rolls; this exists
* for the case where the ceiling was hit legitimately and waiting is not
* acceptable.
*/
router.post('/reset-ceiling', asyncRoute(async (_req: Request, res: Response) => {
await pool.query(
`INSERT INTO admin_settings (key, value, updated_at)
VALUES ('intake_ceiling_reset_at', $1, now())
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = now()`,
[new Date().toISOString()]
);
res.json({ reset: true });
}));
```
Place it **above** the `/:id/revoke` route. Express matches in order, and `reset-ceiling` would otherwise be read as an `:id`.
- [ ] **Step 2: Run everything**
```bash
cd backend
npx jest -c jest.integration.config.js --runInBand intakeCeiling
npm run test:unit
npx jest -c jest.integration.config.js --runInBand
npm run lint && npm run build
```
Expected: all pass.
- [ ] **Step 3: Commit**
```bash
git add backend/src/routes/adminUploadLinks.ts
git commit -m "feat(intake): reset the ceiling window from the admin (#227)"
```
---
## Done when
- Submissions across every link are counted over a rolling 24 hours, from `item_drafts` rather than a tally.
- Past the ceiling, intake refuses with a `503` saying to try again later, and stores nothing.
- The admin upload path is unaffected, with a test saying so.
- An alert is sent when the ceiling is reached, and when one link crosses a threshold well below it, each at most once an hour.
- The window can be reset from the admin, and resetting deletes nothing.
- Unit, integration, lint and build all pass.
## Not in this plan
A frontend control for the reset and the two thresholds. The endpoint and the settings exist, so this is reachable, but putting them on the Upload links screen is its own piece of work — and the numbers are ones you set once rather than adjust often.