Skip to content

Add the station-signup manager API: reveal, rotate, revoke, clear-cooldown, status, and approve - #2380

Merged
jakebromberg merged 2 commits into
mainfrom
orchestrator/station-signup-manager-api-for-reveal-rotate-revok
Sep 6, 2026
Merged

Add the station-signup manager API: reveal, rotate, revoke, clear-cooldown, status, and approve#2380
jakebromberg merged 2 commits into
mainfrom
orchestrator/station-signup-manager-api-for-reveal-rotate-revok

Conversation

@jakebromberg

@jakebromberg jakebromberg commented Sep 6, 2026

Copy link
Copy Markdown
Member

Closes #2362.

Six manager operations for the station self-signup passcode, mounted in apps/auth/app.ts beside /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-phase matchStationPasscode / claimStationPasscode split and the CI passcode-key wiring this PR reuses unchanged — no new environment variables, no new migrations, and nothing here reads or flips STATION_SIGNUP_DOWNGRADE_ENABLED or STATION_SIGNUP_ENABLED.

The six operations

POST /auth/admin/station-signup/reveal returns 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/rotate mints a new code and returns its plaintext once, attributing created_by to the acting manager. It refuses a third active row with 409 and code: 'passcode_cap_exceeded' rather than silently retiring a note the room is using; the caller revokes one first.

POST /auth/admin/station-signup/revoke is the manual kill switch, setting revoked_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 as revokedByKeyRotation. Revoking an unknown or already-revoked id is an idempotent revoked: false no-op, not an error.

POST /auth/admin/station-signup/clear-cooldown is the anti-lockout escape hatch. It writes a cooldown_cleared attempt row, which evaluateSignupCooldown already 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/status answers "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 an exhausted flag for a row that is still active by the SQL predicate but whose every further use logs passcode_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.countsByOutcome is a GROUP BY outcome aggregate over everything at or after since, so it cannot saturate and cannot contradict cooldown.noMatchFailureCount, which is aggregated the same way over ten minutes; cooldown.lastClearedAt is the dedicated indexed LIMIT 1 on cooldown_cleared that evaluateSignupCooldown already runs as its window floor, so the reported clear is definitionally the clear the counts were measured from. attempts.recent is 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 blanked lastClearedAt again 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/approve clears one account out of the manager review queue.

The gate is the admin flag, stated precisely

All six routes live on one express.Router mounted at /auth/admin/station-signup, with the same check /auth/admin/provision-user uses — session.user.role !== 'admin' → 403 — applied once via router.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. requirePermissions lives in the backend app and is not available in apps/auth.

This is deliberately NOT "stationManager only": via grantsAdminFlag the flag also admits a stray admin / owner membership 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-user already sits behind exactly this gate and creates accounts at ANY role, stationManager included — so nothing here is a wider grant than what the same gate already protects. A true membership-role gate would need a read nothing in apps/auth does 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_revealed attempt row per code revealed, carrying actor_user_id, which is what makes "who could have seen this?" answerable after a suspected leak. The token already exists in STATION_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_revealed rows 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_unverifiable is still logged and the typed 503 still thrown.

A deployment with no STATION_PASSCODE_KEY gets its own answer rather than a misleading one. Reveal and rotate check for the key upfront and throw a distinct StationPasscodeKeyUnsetError, which the wrapper maps to 503 passcode_key_unset. Previously rotate fell through to a code-less 500, reveal with an active row surfaced as a decrypt failure whose remedy names STATION_PASSCODE_KEY_PREVIOUS (the one variable that cannot fix a missing current key), and reveal against an empty table answered 200 {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 carry input: false, which blocks that route BY DESIGN — that is what stops a signed-in DJ approving their own pending signup. This writes through @wxyc/database directly, which never reaches parseUserInput, exactly as jobs/station-signup-review already does for self_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's adminUpdateUser would put the two writes in different transactions and reintroduce the half-applied state the lock order exists to prevent.

restoreDjRole is 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's djmember. An account sitting at musicDirector is 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 no auth_member write 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_at is 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 — but restoreDjRole on 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 UPDATE on the auth_user row FIRST, then writes auth_member. jobs/station-signup-review's applyDowngrades holds 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 its failed / non-zero path rather than its benign raced one. 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 into raced having written nothing, and downgrade-then-approve leaves this call approving an account that is already member — which is precisely what restoreDjRole is for.

Tests

tests/integration/station-signup-admin.spec.js is 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 compiled applyDowngrades.

tests/unit/auth/station-signup-admin.test.ts adds 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 the auth_user read takes FOR UPDATE and does so before any auth_member write, and that restoreDjRole off issues no auth_member statement 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_cleared row followed by 120 failures, past the 100-row cap, and asserts the true per-outcome totals, the surviving lastClearedAt, and that recent is 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 has STATION_PASSCODE_KEY set, 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.

…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
jakebromberg force-pushed the orchestrator/station-signup-manager-api-for-reveal-rotate-revok branch from b34f9e0 to d3935d2 Compare September 6, 2026 22:08
…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.
@jakebromberg
jakebromberg merged commit 56a7593 into main Sep 6, 2026
6 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Station signup: manager API for reveal, rotate, revoke, clear-cooldown, status, and approve

1 participant