Add the station-signup manager API: reveal, rotate, revoke, clear-cooldown, status, and approve - #2380
Merged
jakebromberg merged 2 commits intoSep 6, 2026
Conversation
…ldown, status, approve (BS#2362) Mounts six manager operations in apps/auth behind the same admin-flag gate as /auth/admin/provision-user, adds the passcode state helpers they read, and covers the surface with an integration spec.
jakebromberg
force-pushed
the
orchestrator/station-signup-manager-api-for-reveal-rotate-revok
branch
from
September 6, 2026 22:08
b34f9e0 to
d3935d2
Compare
…ailures
The status endpoint's window-wide fields were both derived from a 100-row cap. `attempts.countsByOutcome` was built by iterating `readRecentSignupAttempts({ since, limit: 100 })`, which is `ORDER BY attempted_at DESC LIMIT 100`, so the per-outcome numbers saturated at a TOTAL of 100 for a window the payload documented as 24 hours — and could report fewer 24-hour `passcode_fail` than `cooldown.noMatchFailureCount`, which `evaluateSignupCooldown` aggregates in SQL over ten minutes. `countSignupAttemptOutcomes` (new, `SELECT outcome, count(*) ... GROUP BY outcome`) now answers that question in Postgres, and the row cap bounds only the `recent` display list, which says so.
`cooldown.lastClearedAt` was `attempts.find(outcome === 'cooldown_cleared')` off the same capped list, so it reverted to null as soon as 100 attempts landed after a clear. During a `cooldown_refused` storm that is a couple of minutes, and it is exactly when a manager is polling to confirm their clear took — the screen told them no clear had ever happened. It now comes from `readLastCooldownClearedAt`, the indexed `LIMIT 1` query `evaluateSignupCooldown` already runs as its floor, extracted so both callers ask the same question; the returned shape of `evaluateSignupCooldown` is unchanged.
Typed keyless failures. With `STATION_PASSCODE_KEY` unset, rotate reached `resolveStationPasscodeKey`'s bare Error and fell to the admin wrapper's code-less 500; reveal with an active row surfaced as a `StationPasscodeDecryptionError` with `reason: 'corrupt'`, whose operator remedy names `STATION_PASSCODE_KEY_PREVIOUS` — the one variable that cannot fix a missing current key; and reveal with no rows answered `200 {passcodes: []}`, which reads as "the station has no live code". `StationPasscodeKeyUnsetError` is thrown by an upfront presence check in reveal and rotate only, so the public signup path keeps failing closed exactly as it does today, and `apps/auth` maps it to 503 `passcode_key_unset`.
The six admin routes move onto one `express.Router` mounted at `/auth/admin/station-signup`, with the admin-flag gate on `router.use` rather than inside the per-route wrapper. Paths and response shapes are byte-identical; what changes is that "no route can be added without the gate" is structural instead of a convention held by whoever registers the next route.
Reveal now decrypts every active row before writing any audit row. Interleaving them left a `passcode_revealed` row for a code the manager never received when a later row would not decrypt, in the one place where the audit log IS the security control — it is what replaces show-once storage's structural guarantee. Error semantics are unchanged: an undecryptable active row still logs `passcode_unverifiable` and throws the typed 503, now with no `passcode_revealed` rows at all rather than a prefix of them.
Tests. The integration spec seeds a `cooldown_cleared` row followed by 120 failures and asserts the true totals, the surviving `lastClearedAt`, and the 100-row `recent` cap; `seedFailures` became a single `generate_series` insert so that fixture is cheap. A second integration case pins reveal's all-or-nothing audit. Unit coverage pins the counts and the clear coming from the aggregate rather than the display list, the key-unset throw from both operations, and the router/gate/mapping wiring.
4 tasks
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #2362.
Six manager operations for the station self-signup passcode, mounted in
apps/auth/app.tsbeside/auth/admin/provision-user, plus the read-only passcode-state helpers the status endpoint needs. Builds on #2375 (the signup endpoint itself), which shipped the two-phasematchStationPasscode/claimStationPasscodesplit and the CI passcode-key wiring this PR reuses unchanged — no new environment variables, no new migrations, and nothing here reads or flipsSTATION_SIGNUP_DOWNGRADE_ENABLEDorSTATION_SIGNUP_ENABLED.The six operations
POST /auth/admin/station-signup/revealreturns the current plaintext code(s). Readable storage exists precisely so a manager can read the code to a stranded DJ by phone without rotating — rotating under the two-row cap can invalidate the sticky note the rest of the room is still using. It is a POST rather than a GET because every reveal WRITES: see the audit row below.POST /auth/admin/station-signup/rotatemints a new code and returns its plaintext once, attributingcreated_byto the acting manager. It refuses a third active row with409andcode: 'passcode_cap_exceeded'rather than silently retiring a note the room is using; the caller revokes one first.POST /auth/admin/station-signup/revokeis the manual kill switch, settingrevoked_reason = 'manual'. That value is what distinguishes a manager's revoke from rotation's automatic retirement of an undecryptable active row, which the status endpoint reports separately asrevokedByKeyRotation. Revoking an unknown or already-revoked id is an idempotentrevoked: falseno-op, not an error.POST /auth/admin/station-signup/clear-cooldownis the anti-lockout escape hatch. It writes acooldown_clearedattempt row, whichevaluateSignupCooldownalready honours as a floor on the failure window, and it DELETES NOTHING — that log is simultaneously the cooldown's own input and the 30-day audit trail, so clearing by deletion would destroy the forensic record of the attack that caused the cooldown. Without this operation a sustained attacker can hold the endpoint in cooldown with nothing in the product able to lift it, and the failure mode becomes "wait for a manager who is not on site"; with it, one phone call. The floor is not a permanent exemption: if the attack resumes, the cooldown re-engages on the failures logged after the clear.GET /auth/admin/station-signup/statusanswers "is the gate working, and is anything waiting on me?" in one read — passcode state (active / revoked-with-reason / expired,last_used_at,use_count/max_uses, and anexhaustedflag for a row that is still active by the SQL predicate but whose every further use logspasscode_exhausted), cooldown state alongside the rule it is measured against, the 24-hour attempt log, and the pending-review queue. It writes nothing at all, which is what makes it safe to poll — and is the reason reveal is a separate operation rather than folded in here, since folding it in would either spam the audit log on every poll or hand out the code without a row.Two of that payload's fields are window-wide and are computed as such, in SQL, rather than off the row list beside them.
attempts.countsByOutcomeis aGROUP BY outcomeaggregate over everything at or aftersince, so it cannot saturate and cannot contradictcooldown.noMatchFailureCount, which is aggregated the same way over ten minutes;cooldown.lastClearedAtis the dedicated indexedLIMIT 1oncooldown_clearedthatevaluateSignupCooldownalready runs as its window floor, so the reported clear is definitionally the clear the counts were measured from.attempts.recentis the only capped field — the newest 100 rows in the window, a display list rather than a census, and documented as one. Deriving either of the first two from that list would have made a "24 hour" number a statement about the newest 100 rows, and would have blankedlastClearedAtagain a couple of minutes into any refusal storm, which is exactly when a manager polls to confirm their clear took.POST /auth/admin/station-signup/approveclears one account out of the manager review queue.The gate is the admin flag, stated precisely
All six routes live on one
express.Routermounted at/auth/admin/station-signup, with the same check/auth/admin/provision-useruses —session.user.role !== 'admin'→ 403 — applied once viarouter.use. The gate is therefore structural rather than a convention: a seventh route added to that router inherits it whether or not anyone remembers the wrapper.requirePermissionslives in the backend app and is not available inapps/auth.This is deliberately NOT "stationManager only": via
grantsAdminFlagthe flag also admits a strayadmin/ownermembership row, so this is a slightly wider set than the station-manager role. That is the right trade rather than an oversight, because/auth/admin/provision-useralready sits behind exactly this gate and creates accounts at ANY role,stationManagerincluded — so nothing here is a wider grant than what the same gate already protects. A true membership-role gate would need a read nothing inapps/authdoes today; if it is ever wanted it is its own change with its own test, applied to both endpoints at once.The routes are mounted unconditionally rather than behind
STATION_SIGNUP_ENABLED. Lighting the feature up needs a code in the table BEFORE the public endpoint opens, and two ordered flag flips to achieve that would be a worse operational story than one admin-gated surface that is inert until someone with the admin flag calls it.Reveal writes the audit row
Readable storage removes the structural guarantee show-once storage had for free — that only whoever rotated ever saw the code. The audit log is what replaces it: every reveal writes one
passcode_revealedattempt row per code revealed, carryingactor_user_id, which is what makes "who could have seen this?" answerable after a suspected leak. The token already exists inSTATION_SIGNUP_OUTCOMES; no schema change.That actor, and
approve's reviewer, are derived from the SESSION and never read from the request body — an audit trail a caller can forge names the wrong person. The integration spec asserts that a body-supplied actor is ignored rather than honoured.The audit write is all-or-nothing. Every active row is decrypted first and the
passcode_revealedrows are written only once all of them opened, so a later row that will not decrypt can never leave a log entry for a code the manager never received. The failure itself is unchanged:passcode_unverifiableis still logged and the typed 503 still thrown.A deployment with no
STATION_PASSCODE_KEYgets its own answer rather than a misleading one. Reveal and rotate check for the key upfront and throw a distinctStationPasscodeKeyUnsetError, which the wrapper maps to 503passcode_key_unset. Previously rotate fell through to a code-less 500, reveal with an active row surfaced as a decrypt failure whose remedy namesSTATION_PASSCODE_KEY_PREVIOUS(the one variable that cannot fix a missing current key), and reveal against an empty table answered200 {passcodes: []}— "there is no station code" when the truth was "this process cannot read one". The public signup path is untouched and keeps failing closed exactly as before.Approve semantics
Not via better-auth's public
POST /update-user. All three review columns carryinput: false, which blocks that route BY DESIGN — that is what stops a signed-in DJ approving their own pending signup. This writes through@wxyc/databasedirectly, which never reachesparseUserInput, exactly asjobs/station-signup-reviewalready does forself_signup_downgraded_at. A direct write is not merely one of the permitted bypasses here, it is the only one that can hold the optional role restore in the SAME transaction as the review stamp; the admin plugin'sadminUpdateUserwould put the two writes in different transactions and reintroduce the half-applied state the lock order exists to prevent.restoreDjRoleis a boolean, not a role parameter.'dj'is a literal in the UPDATE and the WHERE pins the source role to'member', so the only transition this endpoint can perform is the exact inverse of the 30-day actuator'sdj→member. An account sitting atmusicDirectoris left alone rather than silently demoted, and there is no input by which a caller could ask for any other role. Omitting the flag issues noauth_memberwrite at all: a manager reviewing a self-signup may well decide the person should not be a DJ, so the quiet default has to be "grant nothing".self_signup_downgraded_atis PRESERVED, never cleared, in both modes. It records what happened to the account, and approving is not a history edit; it also suppresses the actuator, so an approved account can never be auto-downgraded a second time.The review stamp is write-once — the UPDATE carries
self_signup_reviewed_at IS NULL, so a second approval does not re-attribute the review to a second manager — butrestoreDjRoleon that second call still applies, so "approve, then realise the role should come back" is one more call rather than a dead end.Lock order
The approve transaction takes
SELECT ... FOR UPDATEon theauth_userrow FIRST, then writesauth_member.jobs/station-signup-review'sapplyDowngradesholds the same order and names this endpoint in its docstring as the writer that has to match it; taking them the other way round deadlocks the pair, and the job's loser lands in itsfailed/ non-zero path rather than its benignracedone. Because both sides lock the same row first they simply serialize, and both directions are covered: approve-then-downgrade leaves the job's re-select filtering the row out intoracedhaving written nothing, and downgrade-then-approve leaves this call approving an account that is alreadymember— which is precisely whatrestoreDjRoleis for.Tests
tests/integration/station-signup-admin.spec.jsis the main harness: it drives the real endpoints over HTTP against real Postgres, since every property worth testing here is a side effect on a row rather than a return value. It covers the gate as a table across all six routes (401 unauthenticated, 403 plain-DJ, and that a rejected call writes nothing), the two-row cap, reveal's audit row and its unspoofable actor, revoke's reason and idempotence, clear-cooldown's floor-not-delete behaviour and its re-engagement, every field of the status response, and approve including the write-once stamp, the marker preservation, the role-restore guard, and both race directions driven against the job's REAL compiledapplyDowngrades.tests/unit/auth/station-signup-admin.test.tsadds a recording fake for the two properties that are about the shape of the calls rather than their result, and that a single-threaded test would otherwise never catch: that theauth_userread takesFOR UPDATEand does so before anyauth_memberwrite, and thatrestoreDjRoleoff issues noauth_memberstatement at all rather than one that happens to match nothing. It also pins the status aggregation — outcome counts, the days-pending floor and ordering, the minutes-not-milliseconds conversion of the cooldown rule, and the pending-cohort predicate — and that the counts and the last clear come from the SQL aggregate and the floor query rather than from the capped display list.Two properties are only reachable at the edges of those two tiers. The integration spec seeds a
cooldown_clearedrow followed by 120 failures, past the 100-row cap, and asserts the true per-outcome totals, the survivinglastClearedAt, and thatrecentis still exactly the newest 100; a second case inserts an undecryptable active row alongside a good one and asserts reveal writes no audit row at all. The key-unset branch is the opposite case — CI always hasSTATION_PASSCODE_KEYset, so it is covered where it can be: unit tests that both operations throw the typed error, plus source-text assertions that the router mounts the gate once and that the wrapper maps that error to its own 503.