Problem
The station-signup epic needs a single module owning the passcode's whole lifecycle: generation, encryption, verification, rotation, revocation, cooldown evaluation, and the attempt log. Four downstream issues consume its exported surface, so it must land before them and its API must be settled here rather than assumed by each caller.
Desired end state
shared/authentication/src/station-passcode.ts exists, exporting a named surface from the package barrel.
This is the repo's first encryption-at-rest code — no createCipheriv exists in Backend-Service today. Put the key handling and its threat model in a comment at the top of the module.
Encryption
AES-256-GCM, key from STATION_PASSCODE_KEY, stored as iv:tag:ciphertext base64 in one column.
Be precise about what this buys: the key ships to the EC2 host's .env via set-ec2-env-var.yml, the same file holding DB_PASSWORD. It protects a leaked dump or RDS snapshot; it does not protect against host compromise, where an attacker has both halves. Worth having — dumps outlive and travel further than host access — but not the broader guarantee the phrase usually implies.
Encrypted rather than hashed because the code is meant to be read back: a stationManager reveals the current code and reads it to a stranded DJ by phone, without rotating. Rotating under the two-row cap can invalidate the note everyone else in the room is using.
Key rotation is free and needs no re-encryption: rows live <=14 days and there are <=2, so set a new key and rotate the passcode. Old ciphertext becomes undecryptable garbage, harmless. A decrypt failure must fail closed — refuse — never fall through to a "no match" that would silently disable the gate if the key were wrong.
Generation
Eight characters from a 32-character unambiguous alphabet (no 0/O, 1/l/I): ~10^12, still legible on a sticky note. Generated, never manager-chosen — a manager-typed code will be wxyc2026.
Verification
Both active rows decrypted and compared in constant time, with no early exit; results combined at the end. Returning on first match leaks through response timing which of the two codes was used.
Rotation and the two-row cap
At most two rows active at once (revoked_at IS NULL AND expires_at > now()), enforced at rotation under a lock. Rotation is read-then-insert, so it must be serialized on a station-global constant pg_advisory_xact_lock key inside the rotate transaction. The key must be distinct from the repo's existing advisory-lock keys. Two concurrent rotations would otherwise yield three or four active rows.
The lock is station-global rather than per-passcode because the cap counts rows matching a predicate: the row being inserted does not exist yet, so there is nothing per-row to key on, and two rotations locking different existing rows would both count 2 and proceed.
SELECT ... FOR UPDATE was previously listed here as an alternative. It is not one, and has been removed: row locks cannot lock the absence of rows. With one active row, two rotations both SELECT ... FOR UPDATE; the loser blocks, the winner inserts its new row and commits, the loser then re-evaluates only the row it matched, never sees the phantom insert, counts one active row, and inserts. Three active rows. With zero active rows nothing is locked at all and N concurrent rotations yield N rows. Such an implementation passes every non-concurrent test while failing exactly the acceptance criterion this section states.
Note this is rotation's mechanism only. The use-claim is not lock-based: it is a single conditional UPDATE ... SET use_count = use_count + 1 WHERE id = $1 AND use_count < max_uses RETURNING id, which is atomic on its own and never touches the advisory-lock space. Rotation's lock is therefore the module's only advisory lock, with no deadlock surface.
The cap's rationale is not CPU — decryption is microseconds. It is that every active code is a live credential written on paper somewhere in the building. Two is a rotation overlap window; five is having lost track.
Cooldown
A failed attempt matches no passcode row, so there is nothing to attribute the failure to. Do not increment a per-row counter — incrementing every active row would let a few wrong guesses from anywhere on the internet revoke both live codes and lock out the control room, which is the outage this whole design exists to prevent.
Instead: count failures in station_signup_attempt within a window — but refusal and alerting count different row sets.
- Refusal: only genuine no-match failures (
passcode_fail) count. More than 20 in 10 minutes enters a 15-minute cooldown.
- Alerting: all failure outcomes count toward the digest email.
This split is load-bearing. The cooldown check runs before verification, so if a stale-sticky-note failure (a real code that is expired, revoked, or exhausted) counted toward refusal, a room of legitimate DJs would trip a station-global cooldown — and the manager's phoned-in correct code would then be refused too, requiring a second manager action (clear-cooldown) to undo a lockout that legitimate users caused. That is the failure shape #2365 forbids. Decoupling keeps the stale-note alert — the mechanism's primary designed use case — fully intact while never refusing service over it. Excluding these rows costs nothing against attackers: a guesser's random codes match nothing and still feed refusal exactly as before.
evaluateSignupCooldown must therefore return both counts (in-window no-match failures, and in-window all-failures). Settle that return shape here: #2361, #2362, #2363 and #2364 all consume this surface, which is why this module lands first. #2362's status endpoint should surface both so a manager can distinguish "the room is failing on a stale code" from "we are under a guessing flood".
The cooldown is self-healing, needs no manager, and never revokes anything. Revocation stays manual.
A manager clearing the cooldown writes a cooldown_cleared row, and evaluation counts failures only at or after the most recent one — the clear is a floor on the window, not a deletion. Clearing must never delete attempt rows: they are the detection record the cooldown derives from and the 30-day audit the prune job assumes.
Barrel exports
Name the surface in shared/authentication/src/index.ts — not export *, so lifecycle internals stay private, with a line saying why (the barrel already annotates why particular symbols are exported; see the grantsAdminFlag BS#2282 note):
generateStationPasscode, verifyStationPasscode, revealStationPasscode, rotateStationPasscode, revokeStationPasscode, evaluateSignupCooldown, clearSignupCooldown, readRecentSignupAttempts, pruneSignupAttempts
The last three have consumers outside this module: the admin API clears the cooldown and reads counts, and the prune runs from a job.
Tests — split by what they can actually prove
jest.unit.config.ts maps @wxyc/database and any shared/database/src/client path to a chainable stub with canned values, so no atomicity claim can be tested there — a mock enforces nothing and every concurrency case would pass vacuously.
tests/unit/authentication/station-passcode.test.ts (TypeScript, per jest.unit.config.ts:6): encrypt/decrypt round-trip, a wrong key failing closed, constant-time comparison, cooldown arithmetic, the clear-as-window-floor calculation, code generation and alphabet, no-early-exit combination logic, expiry and revocation predicates.
tests/integration/station-passcode.spec.js (plain JS, .spec.js — jest.config.json's testMatch is **/tests/integration/?(*.)+(spec).js and all 119 existing files there are .js): the two-row cap under concurrent rotation, and that a failed attempt mutates no passcode row.
Acceptance criteria
Context
Plan: ~/Downloads/wxyc-station-passcode-signup-plan.md.
Related
Blocked by the schema issue. Blocks the endpoint, admin API, and prune job.
Blocked by
Outcome vocabulary (settled)
passcode_fail must not cover two materially different events. The vocabulary is:
| outcome |
meaning |
passcode_id |
feeds refusal |
feeds alert |
passcode_ok |
valid code, use claimed |
set |
no |
no |
passcode_fail |
matched no row at all |
NULL |
yes |
yes |
passcode_expired |
matched a real row, past expires_at |
set |
no |
yes |
passcode_revoked |
matched a real row, revoked_at set |
set |
no |
yes |
passcode_exhausted |
matched a real row at its use cap, or lost the claim race |
set |
no |
yes |
cooldown_refused |
refused while in cooldown |
NULL |
no |
no |
cooldown_cleared |
manager cleared the cooldown |
NULL |
no |
no |
passcode_revealed |
manager revealed the code |
set |
no |
no |
passcode_exhausted also covers the claim-race loser — a correct code whose conditional UPDATE returned zero rows. It is a correct code, so it must never feed refusal.
Enforce the vocabulary with a TypeScript union in this module, not a DB CHECK. The table has exactly one writer by design; a CHECK costs a migration now and another on every future token, and compile-time enforcement catches the same misspelling class.
Classification requires reading inactive rows
Verification compares against the two active rows, so a stale code is otherwise indistinguishable from garbage. Classification must additionally decrypt recently-inactive rows over a bounded horizon (e.g. expires_at/revoked_at within the last 30 days, matching the audit horizon) — unbounded, the scan grows forever, since nothing prunes station_passcode.
Two decrypt-failure policies, not one:
- Active row decrypt failure fails closed — gate integrity.
- Inactive row decrypt failure is silently skipped — after a key rotation, old rows are undecryptable by design, and failing closed on them would break the endpoint for the entire classification horizon.
Classification must run identically on every failure path so failure kinds stay timing-indistinguishable to the client, and the client response stays generic per #2361. The split lives only in the log.
use_count concurrency (settled)
Single conditional statement, no advisory lock and no CHECK constraint:
UPDATE station_passcode SET use_count = use_count + 1
WHERE id = $1 AND use_count < max_uses
RETURNING id;
Under READ COMMITTED, concurrent statements on the same row serialize on the row lock and the loser re-evaluates the predicate against the winner's committed value, so over-issue is impossible by construction. Zero rows returned means the cap was hit → passcode_exhausted.
Validation still runs strictly before the claim — the ordering constraint from #2365 stands. An earlier design that claimed first let fumbled usernames burn the code.
No CHECK (use_count <= max_uses): the invariant that actually matters (two active rows) cannot be expressed as a CHECK anyway, the conditional UPDATE makes the constraint unreachable except through a future second writer, and the acceptance criteria already require a real-Postgres concurrent-claim test.
Production enablement
STATION_PASSCODE_KEY and STATION_SIGNUP_IP_HMAC_KEY are absent from .github/workflows/set-ec2-env-var.yml's secret allowlist (the resolve-step env block and the case "$SECRET_NAME", which hard-fails with "Unsupported secret_name"). Both must be added there in this issue, or the feature cannot be enabled in production.
The ip_hash HMAC derivation is specified in the ip_hash column comment in shared/database/src/schema.ts — reference it, do not restate it. Duplicated spec prose across files is unfixable drift once a migration freezes one copy.
Problem
The station-signup epic needs a single module owning the passcode's whole lifecycle: generation, encryption, verification, rotation, revocation, cooldown evaluation, and the attempt log. Four downstream issues consume its exported surface, so it must land before them and its API must be settled here rather than assumed by each caller.
Desired end state
shared/authentication/src/station-passcode.tsexists, exporting a named surface from the package barrel.This is the repo's first encryption-at-rest code — no
createCipherivexists in Backend-Service today. Put the key handling and its threat model in a comment at the top of the module.Encryption
AES-256-GCM, key from
STATION_PASSCODE_KEY, stored asiv:tag:ciphertextbase64 in one column.Be precise about what this buys: the key ships to the EC2 host's
.envviaset-ec2-env-var.yml, the same file holdingDB_PASSWORD. It protects a leaked dump or RDS snapshot; it does not protect against host compromise, where an attacker has both halves. Worth having — dumps outlive and travel further than host access — but not the broader guarantee the phrase usually implies.Encrypted rather than hashed because the code is meant to be read back: a
stationManagerreveals the current code and reads it to a stranded DJ by phone, without rotating. Rotating under the two-row cap can invalidate the note everyone else in the room is using.Key rotation is free and needs no re-encryption: rows live <=14 days and there are <=2, so set a new key and rotate the passcode. Old ciphertext becomes undecryptable garbage, harmless. A decrypt failure must fail closed — refuse — never fall through to a "no match" that would silently disable the gate if the key were wrong.
Generation
Eight characters from a 32-character unambiguous alphabet (no
0/O,1/l/I): ~10^12, still legible on a sticky note. Generated, never manager-chosen — a manager-typed code will bewxyc2026.Verification
Both active rows decrypted and compared in constant time, with no early exit; results combined at the end. Returning on first match leaks through response timing which of the two codes was used.
Rotation and the two-row cap
At most two rows active at once (
revoked_at IS NULL AND expires_at > now()), enforced at rotation under a lock. Rotation is read-then-insert, so it must be serialized on a station-global constantpg_advisory_xact_lockkey inside the rotate transaction. The key must be distinct from the repo's existing advisory-lock keys. Two concurrent rotations would otherwise yield three or four active rows.The lock is station-global rather than per-passcode because the cap counts rows matching a predicate: the row being inserted does not exist yet, so there is nothing per-row to key on, and two rotations locking different existing rows would both count 2 and proceed.
SELECT ... FOR UPDATEwas previously listed here as an alternative. It is not one, and has been removed: row locks cannot lock the absence of rows. With one active row, two rotations bothSELECT ... FOR UPDATE; the loser blocks, the winner inserts its new row and commits, the loser then re-evaluates only the row it matched, never sees the phantom insert, counts one active row, and inserts. Three active rows. With zero active rows nothing is locked at all and N concurrent rotations yield N rows. Such an implementation passes every non-concurrent test while failing exactly the acceptance criterion this section states.Note this is rotation's mechanism only. The use-claim is not lock-based: it is a single conditional
UPDATE ... SET use_count = use_count + 1 WHERE id = $1 AND use_count < max_uses RETURNING id, which is atomic on its own and never touches the advisory-lock space. Rotation's lock is therefore the module's only advisory lock, with no deadlock surface.The cap's rationale is not CPU — decryption is microseconds. It is that every active code is a live credential written on paper somewhere in the building. Two is a rotation overlap window; five is having lost track.
Cooldown
A failed attempt matches no passcode row, so there is nothing to attribute the failure to. Do not increment a per-row counter — incrementing every active row would let a few wrong guesses from anywhere on the internet revoke both live codes and lock out the control room, which is the outage this whole design exists to prevent.
Instead: count failures in
station_signup_attemptwithin a window — but refusal and alerting count different row sets.passcode_fail) count. More than 20 in 10 minutes enters a 15-minute cooldown.This split is load-bearing. The cooldown check runs before verification, so if a stale-sticky-note failure (a real code that is expired, revoked, or exhausted) counted toward refusal, a room of legitimate DJs would trip a station-global cooldown — and the manager's phoned-in correct code would then be refused too, requiring a second manager action (clear-cooldown) to undo a lockout that legitimate users caused. That is the failure shape #2365 forbids. Decoupling keeps the stale-note alert — the mechanism's primary designed use case — fully intact while never refusing service over it. Excluding these rows costs nothing against attackers: a guesser's random codes match nothing and still feed refusal exactly as before.
evaluateSignupCooldownmust therefore return both counts (in-window no-match failures, and in-window all-failures). Settle that return shape here: #2361, #2362, #2363 and #2364 all consume this surface, which is why this module lands first. #2362's status endpoint should surface both so a manager can distinguish "the room is failing on a stale code" from "we are under a guessing flood".The cooldown is self-healing, needs no manager, and never revokes anything. Revocation stays manual.
A manager clearing the cooldown writes a
cooldown_clearedrow, and evaluation counts failures only at or after the most recent one — the clear is a floor on the window, not a deletion. Clearing must never delete attempt rows: they are the detection record the cooldown derives from and the 30-day audit the prune job assumes.Barrel exports
Name the surface in
shared/authentication/src/index.ts— notexport *, so lifecycle internals stay private, with a line saying why (the barrel already annotates why particular symbols are exported; see thegrantsAdminFlagBS#2282 note):generateStationPasscode,verifyStationPasscode,revealStationPasscode,rotateStationPasscode,revokeStationPasscode,evaluateSignupCooldown,clearSignupCooldown,readRecentSignupAttempts,pruneSignupAttemptsThe last three have consumers outside this module: the admin API clears the cooldown and reads counts, and the prune runs from a job.
Tests — split by what they can actually prove
jest.unit.config.tsmaps@wxyc/databaseand anyshared/database/src/clientpath to a chainable stub with canned values, so no atomicity claim can be tested there — a mock enforces nothing and every concurrency case would pass vacuously.tests/unit/authentication/station-passcode.test.ts(TypeScript, perjest.unit.config.ts:6): encrypt/decrypt round-trip, a wrong key failing closed, constant-time comparison, cooldown arithmetic, the clear-as-window-floor calculation, code generation and alphabet, no-early-exit combination logic, expiry and revocation predicates.tests/integration/station-passcode.spec.js(plain JS,.spec.js—jest.config.json'stestMatchis**/tests/integration/?(*.)+(spec).jsand all 119 existing files there are.js): the two-row cap under concurrent rotation, and that a failed attempt mutates no passcode row.Acceptance criteria
Context
Plan:
~/Downloads/wxyc-station-passcode-signup-plan.md.Related
Blocked by the schema issue. Blocks the endpoint, admin API, and prune job.
Blocked by
Outcome vocabulary (settled)
passcode_failmust not cover two materially different events. The vocabulary is:passcode_idpasscode_okpasscode_failpasscode_expiredexpires_atpasscode_revokedrevoked_atsetpasscode_exhaustedcooldown_refusedcooldown_clearedpasscode_revealedpasscode_exhaustedalso covers the claim-race loser — a correct code whose conditional UPDATE returned zero rows. It is a correct code, so it must never feed refusal.Enforce the vocabulary with a TypeScript union in this module, not a DB
CHECK. The table has exactly one writer by design; a CHECK costs a migration now and another on every future token, and compile-time enforcement catches the same misspelling class.Classification requires reading inactive rows
Verification compares against the two active rows, so a stale code is otherwise indistinguishable from garbage. Classification must additionally decrypt recently-inactive rows over a bounded horizon (e.g.
expires_at/revoked_atwithin the last 30 days, matching the audit horizon) — unbounded, the scan grows forever, since nothing prunesstation_passcode.Two decrypt-failure policies, not one:
Classification must run identically on every failure path so failure kinds stay timing-indistinguishable to the client, and the client response stays generic per #2361. The split lives only in the log.
use_countconcurrency (settled)Single conditional statement, no advisory lock and no CHECK constraint:
Under READ COMMITTED, concurrent statements on the same row serialize on the row lock and the loser re-evaluates the predicate against the winner's committed value, so over-issue is impossible by construction. Zero rows returned means the cap was hit →
passcode_exhausted.Validation still runs strictly before the claim — the ordering constraint from #2365 stands. An earlier design that claimed first let fumbled usernames burn the code.
No
CHECK (use_count <= max_uses): the invariant that actually matters (two active rows) cannot be expressed as a CHECK anyway, the conditional UPDATE makes the constraint unreachable except through a future second writer, and the acceptance criteria already require a real-Postgres concurrent-claim test.Production enablement
STATION_PASSCODE_KEYandSTATION_SIGNUP_IP_HMAC_KEYare absent from.github/workflows/set-ec2-env-var.yml's secret allowlist (the resolve-step env block and thecase "$SECRET_NAME", which hard-fails with "Unsupported secret_name"). Both must be added there in this issue, or the feature cannot be enabled in production.The
ip_hashHMAC derivation is specified in theip_hashcolumn comment inshared/database/src/schema.ts— reference it, do not restate it. Duplicated spec prose across files is unfixable drift once a migration freezes one copy.