feat(intake): settings for the submission ceiling (#227)

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 <noreply@anthropic.com>
This commit is contained in:
2026-09-01 16:19:45 -05:00
co-authored by Claude Opus 5
parent 722ff91378
commit 22215f32eb
3 changed files with 43 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 // 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 // to change an address would be absurd. Empty means do not notify, which is
// the default and a working configuration. // 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; ] as const;
type Definition = (typeof DEFINITIONS)[number]; type Definition = (typeof DEFINITIONS)[number];
@@ -50,10 +67,12 @@ export type SettingName = Definition['name'];
export type HoursSettingName = Extract<Definition, { type: 'hours' }>['name']; export type HoursSettingName = Extract<Definition, { type: 'hours' }>['name'];
export type TextSettingName = Extract<Definition, { type: 'text' }>['name']; export type TextSettingName = Extract<Definition, { type: 'text' }>['name'];
export type ChoiceSettingName = Extract<Definition, { type: 'choice' }>['name']; export type ChoiceSettingName = Extract<Definition, { type: 'choice' }>['name'];
export type CountSettingName = Extract<Definition, { type: 'count' }>['name'];
export type AdminSettings = Record<HoursSettingName, number> & export type AdminSettings = Record<HoursSettingName, number> &
Record<TextSettingName, string> & Record<TextSettingName, string> &
Record<ChoiceSettingName, string>; Record<ChoiceSettingName, string> &
Record<CountSettingName, number>;
export const HOURS_SETTINGS: readonly HoursSettingName[] = DEFINITIONS.filter( export const HOURS_SETTINGS: readonly HoursSettingName[] = DEFINITIONS.filter(
(d): d is Extract<Definition, { type: 'hours' }> => d.type === 'hours' (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; 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 // 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 // chosen — falls back rather than being handed on. Drafting with the default
// beats drafting with a model the API will refuse. // 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); const raw = stored.get(definition.key);
if (definition.type === 'choice') { if (definition.type === 'choice') {
settings[definition.name] = resolveChoice(definition.name, raw, definition.fallback); 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') { } else if (definition.type === 'text') {
settings[definition.name] = resolveText(raw, definition.fallback); settings[definition.name] = resolveText(raw, definition.fallback);
} else { } else {
@@ -33,7 +33,10 @@ describe('GET /api/admin/settings', () => {
draftingModel: 'claude-sonnet-5', draftingModel: 'claude-sonnet-5',
// Empty by default: nowhere to send the intake notification is a working // Empty by default: nowhere to send the intake notification is a working
// configuration, and means simply do not send one (#224). // configuration, and means simply do not send one (#224).
intakeNotifyEmail: '' intakeNotifyEmail: '',
intakeDailyCeiling: 100,
intakeLinkAlertThreshold: 20,
intakeCeilingResetAt: ''
}); });
}); });
}); });
+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 // 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 // hypothetical: a subject of "Gone" written by the template tests reached the
// favorite-alert tests and made five of them fail somewhere else entirely. // 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> { export async function closeDb(): Promise<void> {