Skip to content

Add POST /auth/wxyc/station-signup, the passcode-gated self-signup endpoint - #2375

Merged
jakebromberg merged 3 commits into
mainfrom
orchestrator/station-signup-post-auth-wxyc-station-signup-the-p
Sep 6, 2026
Merged

Add POST /auth/wxyc/station-signup, the passcode-gated self-signup endpoint#2375
jakebromberg merged 3 commits into
mainfrom
orchestrator/station-signup-post-auth-wxyc-station-signup-the-p

Conversation

@jakebromberg

@jakebromberg jakebromberg commented Sep 6, 2026

Copy link
Copy Markdown
Member

Summary

Mounts POST /auth/wxyc/station-signup ahead of the better-auth handler (alongside lookup-email and complete-onboarding, already under /auth/wxyc/): the public endpoint a DJ walks up to and uses to self-provision a dj-role account behind a station passcode.

Flow

The handler runs strictly in this order, and the ordering is the security argument for the whole PR:

  1. Pure env preconditions. The STATION_SIGNUP_ENABLED gate, then DEFAULT_ORG_SLUG. Both are env reads with nothing to learn from the request, so they run before any DB work. DEFAULT_ORG_SLUG in particular must not be checked after the claim: a deploy that forgot it would burn one use per attempt and brick the code inside 25 requests, which is the control-room lockout epic Epic: passcode-gated DJ self-signup for holiday breaks (server) #2365 forbids.
  2. All shape validation. validateUsername (on the normalized value), isValidEmail from @wxyc/shared/validation, the 8-character password floor mirroring minPasswordLength, and explicit maximum lengths: email/realName/djName at 255 to fit their varchar(255) columns, password at 128 to match better-auth's own maxPasswordLength default. No over-length value can reach provisionUser after a use has been claimed.
  3. The passcode MATCH. matchStationPasscode — cooldown check, decrypt every active row, constant-time compare, no early exit. Claims nothing. Failure is a generic 401 regardless of anything else in the request; cooldown is a 429 with a wait-time message.
  4. The email/username existence checks. internalAdapter.findUserByEmail plus a username lookup, now genuinely behind the gate. The 409s here are the enumeration oracle the issue body accepts by name — observable only to a caller who has already proven they hold a live code, on the same footing as /auth/wxyc/lookup-email.
  5. The passcode CLAIM. claimStationPasscode — the single atomic conditional UPDATE … WHERE use_count < max_uses AND revoked_at IS NULL AND expires_at > now() RETURNING id.
  6. provisionUser, then a best-effort SES deliverability probe.

Why the phases are split. The endpoint owes two properties that a fused match-and-claim cannot deliver at once: an unauthenticated caller must learn nothing (not even whether an email is registered) until they hold a live code, so the gate has to run before the existence checks; and a typo must never burn one of a code's 25 uses, so the claim has to run after them. verifyStationPasscode is therefore split in shared/authentication/src/station-passcode.ts into matchStationPasscode and claimStationPasscode, with the fused function kept as a thin wrapper over the two so its existing callers and tests keep binding the shared logic. The match/claim pair writes exactly one station_signup_attempt row per verification on every path — the match is deliberately quiet on success and the claim always writes one, so a successful signup never produces two rows.

Accepted residual: a code can reach its cap, be revoked, or expire between the match and the claim. The claim's own conditional UPDATE catches that, the caller gets the same generic 401 as any other refusal, and no use is burned. That race already existed inside the fused call; splitting the phases widens it by two indexed reads.

Other properties

  • Username case is normalized once, at the top of the handler, and the normalized value is used for validation, the existence lookup, provisionUser, and the response. better-auth's username plugin lowercases on store and duplicate-checks the lowercased value, so a raw-case pre-check let NewDJ sail past a stored newdj, claim a use, and then die inside the plugin's create hook. provisionUser's duplicate heuristic now also matches already taken (better-auth's own wording), and the 201 body reads the username back off the created row rather than echoing the request, as the email already did.
  • Role is a server-side constant ('dj') — never read from the request body.
  • Provisions with sendSetupInvite: false, hasCompletedOnboarding: true, and selfSignupAt stamped, mirroring provisionUser's BS#2360 groundwork.
  • The deliverability probe is precisely that: the account is already emailVerified: true (set unconditionally by provisionUser), so clicking the link changes no state — it's bounce detection ahead of the manager review that is the real check. Calls sendVerificationEmailMessage directly rather than better-auth's own sendVerificationEmail endpoint, which no-ops for an already-verified account.
  • Mints no session. The DJ signs in normally with the password they just chose.
  • The response stays generic on an invalid passcode (wrong/expired/revoked/exhausted are indistinguishable to the caller). ProvisionError messages are mapped to curated client-safe strings rather than forwarded verbatim — the org-slug 404 embeds DEFAULT_ORG_SLUG.
  • Dedicated 60s/120 rate limiter, not the shared 10/15min brute-force tier — every legitimate caller of this endpoint shares one IP (the control-room computer), so the brute-force tier would let a few fumbled codes lock the whole room out for fifteen minutes.
  • STATION_SIGNUP_ENABLED off does not mount the route or its limiter at all, so the path falls through to better-auth's catch-all and a disabled deployment is indistinguishable from one that never shipped the feature. The handler keeps its own 404 guard for direct callers.

Also plumbs STATION_SIGNUP_ENABLED and STATION_PASSCODE_KEY through dev_env/docker-compose.yml (ci auth + e2e-auth), .github/workflows/test.yml, .github/workflows/set-ec2-env-var.yml's allowlist, and .env.example, so the feature is actually reachable end to end (production light-up still requires an operator to push the real secrets).

Test plan

  • tests/unit/auth/station-signup.test.ts — feature-off 404; the full validation-before-passcode ordering including every length bound; the gate-before-enumeration property (a bad passcode yields an identical status/message/code whether the email is registered or not, and never queries the user table at all); duplicate email/username rejected after the match but before the claim; the claim losing its race after a good match; cooldown and invalid-passcode responses; username case normalization and the case-variant duplicate; the username read back off the created row; role-pinning; DEFAULT_ORG_SLUG unset failing before either passcode phase is invoked; curated ProvisionError messages; and the verification-probe send.
  • tests/unit/auth/rate-limiting.test.ts — source-text assertions pinning the limiter's 60s/120 shape, its exact path and rateLimitKeyFromRequest key generator, its absence from rateLimitedPaths, and the isStationSignupEnabled() gate on both the limiter and the route. isTestEnv disables every mounted limiter, so source text is the only thing that can catch a regression here.
  • tests/unit/auth/provision-user.test.ts — better-auth's own Username is already taken. Please try another. now maps to a 409 instead of escaping as an unhandled 500.
  • tests/integration/station-signup.spec.js — golden path (role, hasCompletedOnboarding, selfSignupAt, use-claim, no setup invite minted, exactly one passcode_ok attempt row); wrong/expired/revoked/exhausted passcode (each asserting use_count is unchanged); cooldown refusal; duplicate email/username and the case-variant duplicate (each asserting no use claimed); over-length realName; weak password; lowercased storage and echo; a client-supplied role being ignored; a self-signed-up DJ session being unable to self-approve via POST /update-user; and the ordering regression — a garbage passcode returns a byte-identical status and body for a registered and an unregistered email, and logs the passcode_fail row the old order skipped entirely.
  • npm run typecheck clean across all workspaces
  • npm run lint clean (0 errors; only pre-existing warnings elsewhere in the repo)
  • npm run format:check clean
  • tests/unit/scripts/ci-env-surface-parity.test.ts updated and passing (the two new auth-only env vars are allow-listed as workflow-only, mirroring DEFAULT_ORG_SLUG/EMAIL_ENABLED)

Follow-up

Closes #2361

…dpoint

Mounts the public endpoint a DJ walks up to and uses ahead of the better-auth handler: validates the whole request (username, email, password shape, and email/username existence) before ever calling verifyStationPasscode, since that call claims a use on a genuine match and validating only inside provisionUser would let an ordinary typo burn one of a code's limited uses on every retry. Provisions with role pinned server-side to 'dj', sendSetupInvite false, hasCompletedOnboarding true, and selfSignupAt stamped, then sends a best-effort SES deliverability probe (the account is already emailVerified, so this changes no state). No session is minted. A dedicated 60s/120 rate limiter guards the route instead of the shared brute-force tier, since every legitimate caller shares one control-room IP.

Plumbs STATION_SIGNUP_ENABLED and STATION_PASSCODE_KEY through docker-compose, the GHA test workflow, the EC2 env-var allowlist, and .env.example so the feature is actually reachable end to end.

Closes #2361
…merate

Splits verifyStationPasscode into a match phase and a claim phase so the endpoint can put the passcode gate ahead of the email/username existence checks and the use-claim behind them. The shipped order ran the existence checks first, because the fused call claims a use on a genuine match and validating only inside provisionUser would let a typo burn one of a code's 25 uses per retry; the cost was that an unauthenticated caller with a garbage passcode read email-registration status straight off the status code (409 EMAIL_TAKEN, address echoed in the message, versus 401 INVALID_PASSCODE), and those pre-claim rejections wrote no station_signup_attempt row at all, so the probe was invisible to the cooldown and to #2362's status endpoint and #2364's digest, bounded only by the 60s/120 limiter. matchStationPasscode checks the cooldown, decrypts every active row and compares, and claims nothing; claimStationPasscode runs the same atomic conditional UPDATE it always did. The attempt-log invariant is preserved by making the match quiet on success and the claim always write exactly one row: one row per verification on every path, never two for a successful signup. verifyStationPasscode stays as a thin fused wrapper with an unchanged signature, so its existing callers and the concurrency spec keep binding the shared logic. A code exhausted, revoked, or expired between the two phases loses the claim and gets the same generic 401 — the accepted residual, and a strictly smaller race than the one already inside the fused call.

Normalizes the username to lowercase once at the top of the handler and uses it everywhere after. better-auth's username plugin lowercases on store and duplicate-checks the lowercased value, so a raw-case pre-check let 'NewDJ' sail past an existing 'newdj', claim a use, and then die inside the plugin's create hook with "Username is already taken. Please try another." — a message provisionUser's duplicate heuristic did not match, so it surfaced as a 500 with the use already burned. The heuristic now also matches 'already taken', and the 201 body reads the username back off the created row rather than echoing the request, as the email already did.

Hoists the DEFAULT_ORG_SLUG precondition above every DB read and the passcode itself. It is a pure env read, and checking it after the claim meant a deploy that forgot it burned one use per attempt and bricked the code inside 25 requests — the control-room lockout epic #2365 forbids. The unit test that claimed to cover this now asserts neither phase was invoked.

Adds the missing length bounds ahead of the match: email, realName and djName capped at 255 to fit their varchar(255) columns, and the password bounded at better-auth's own maxPasswordLength default of 128 as well as its minPasswordLength floor of 8, so no over-length value can reach provisionUser after a use has been claimed. Username keeps its 30-character cap through validateUsername.

Also: with STATION_SIGNUP_ENABLED off, app.ts no longer mounts the route or its dedicated limiter at all, so the path falls through to better-auth's catch-all and a disabled deployment is indistinguishable from one that never shipped the feature — the JSON 404 it used to return advertised both the endpoint and its response shape. ProvisionError messages are mapped to curated client-safe strings instead of forwarded verbatim, since the org-slug 404 embeds DEFAULT_ORG_SLUG. Fixes the unit test that destructured passcode while claiming to test a missing realName. Pins the limiter's 60s/120 shape, its exact path and key generator, and its absence from rateLimitedPaths with source-text assertions, since isTestEnv disables every mounted limiter and nothing else can catch a regression there. Integration coverage for the ordering property asserts a garbage passcode returns a byte-identical status and body for a registered and an unregistered email, and that it logs the passcode_fail row the old order skipped.
…field, fail the fused wrapper closed

A DEFAULT_ORG_SLUG that is set but names no organization row was only discovered inside provisionUser, after claimStationPasscode had incremented use_count -- one burned use per attempt on a misconfigured host, 25 requests brick the sticky-note code, the same control-room lockout class as the unset-env case that was hoisted in the previous commit, narrowed from unset to wrong. assertOrganizationExists now runs one indexed read behind the gate and ahead of the claim: config-driven only, varies with nothing in the request, fails as a generic 500 with no use claimed. The unit-test default adapter mock is keyed on model so the org row exists on happy paths while user lookups still find nothing.

The passcode field gets the same shape-phase cap as the other four inputs (128, generously above any real code) so the bound lives in this file rather than in express.json()'s 100 kB default; the match hashes both sides before comparing, so this is symmetry, not a security control.

verifyStationPasscode's refusal arm now hard-codes ok:false instead of echoing matched.ok -- behavior-identical on every reachable path, but if the type-impossible ok-without-passcodeId state ever appeared, a gate must fail closed rather than report success without a claimed use.
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: POST /auth/wxyc/station-signup, the passcode-gated self-signup endpoint

1 participant