From 22215f32ebe4749f263e17f8807677b87de5dc3b Mon Sep 17 00:00:00 2001 From: Thom Lamb Date: Tue, 1 Sep 2026 16:19:45 -0500 Subject: [PATCH] feat(intake): settings for the submission ceiling (#227) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A count type beside the existing hours, text and choice readers. resolveHours would have worked — it is parseFloat with a positive guard — but calling a submission ceiling an "hours" setting is a lie in the type name that every later reader has to decode. Whole numbers only, so a ceiling of 12.5 is a typo rather than a preference, and a malformed value falls back rather than yielding a NaN that compares false against everything and silently disables the limit. resetDb is widened to clear intake_ settings as well as email_ ones. It deliberately does not truncate admin_settings, so a ceiling of 1 left behind by one suite would make every later suite's submissions refuse with a 503, in files that never mention a ceiling. The comment there already records that exact failure happening once with an email template subject, which reached the favorite-alert tests and failed five of them somewhere else entirely. Co-Authored-By: Claude Opus 5 --- backend/src/adminSettings.ts | 34 +++++++++++++++++-- .../adminSettings.integration.test.ts | 5 ++- backend/tests/integration/setup/testDb.ts | 8 ++++- 3 files changed, 43 insertions(+), 4 deletions(-) diff --git a/backend/src/adminSettings.ts b/backend/src/adminSettings.ts index 660d406..fec8e07 100644 --- a/backend/src/adminSettings.ts +++ b/backend/src/adminSettings.ts @@ -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['name']; export type TextSettingName = Extract['name']; export type ChoiceSettingName = Extract['name']; +export type CountSettingName = Extract['name']; export type AdminSettings = Record & Record & - Record; + Record & + Record; export const HOURS_SETTINGS: readonly HoursSettingName[] = DEFINITIONS.filter( (d): d is Extract => 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 { 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 { diff --git a/backend/tests/integration/adminSettings.integration.test.ts b/backend/tests/integration/adminSettings.integration.test.ts index 63470e1..a76f720 100644 --- a/backend/tests/integration/adminSettings.integration.test.ts +++ b/backend/tests/integration/adminSettings.integration.test.ts @@ -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: '' }); }); }); diff --git a/backend/tests/integration/setup/testDb.ts b/backend/tests/integration/setup/testDb.ts index e5b191a..fee630c 100755 --- a/backend/tests/integration/setup/testDb.ts +++ b/backend/tests/integration/setup/testDb.ts @@ -40,7 +40,13 @@ export async function resetDb(): Promise { // 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 {