Add POST /auth/wxyc/station-signup, the passcode-gated self-signup endpoint - #2375
Merged
jakebromberg merged 3 commits intoSep 6, 2026
Conversation
…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.
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.
Summary
Mounts
POST /auth/wxyc/station-signupahead of the better-auth handler (alongsidelookup-emailandcomplete-onboarding, already under/auth/wxyc/): the public endpoint a DJ walks up to and uses to self-provision adj-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:
STATION_SIGNUP_ENABLEDgate, thenDEFAULT_ORG_SLUG. Both are env reads with nothing to learn from the request, so they run before any DB work.DEFAULT_ORG_SLUGin 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.validateUsername(on the normalized value),isValidEmailfrom@wxyc/shared/validation, the 8-character password floor mirroringminPasswordLength, and explicit maximum lengths: email/realName/djNameat 255 to fit theirvarchar(255)columns, password at 128 to match better-auth's ownmaxPasswordLengthdefault. No over-length value can reachprovisionUserafter a use has been claimed.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.internalAdapter.findUserByEmailplus 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.claimStationPasscode— the single atomic conditionalUPDATE … WHERE use_count < max_uses AND revoked_at IS NULL AND expires_at > now() RETURNING id.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.
verifyStationPasscodeis therefore split inshared/authentication/src/station-passcode.tsintomatchStationPasscodeandclaimStationPasscode, 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 onestation_signup_attemptrow 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
provisionUser, and the response. better-auth'susernameplugin lowercases on store and duplicate-checks the lowercased value, so a raw-case pre-check letNewDJsail past a storednewdj, claim a use, and then die inside the plugin's create hook.provisionUser's duplicate heuristic now also matchesalready 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.'dj') — never read from the request body.sendSetupInvite: false,hasCompletedOnboarding: true, andselfSignupAtstamped, mirroringprovisionUser's BS#2360 groundwork.emailVerified: true(set unconditionally byprovisionUser), so clicking the link changes no state — it's bounce detection ahead of the manager review that is the real check. CallssendVerificationEmailMessagedirectly rather than better-auth's ownsendVerificationEmailendpoint, which no-ops for an already-verified account.ProvisionErrormessages are mapped to curated client-safe strings rather than forwarded verbatim — the org-slug 404 embedsDEFAULT_ORG_SLUG.STATION_SIGNUP_ENABLEDoff 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_ENABLEDandSTATION_PASSCODE_KEYthroughdev_env/docker-compose.yml(ciauth+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_SLUGunset failing before either passcode phase is invoked; curatedProvisionErrormessages; 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 andrateLimitKeyFromRequestkey generator, its absence fromrateLimitedPaths, and theisStationSignupEnabled()gate on both the limiter and the route.isTestEnvdisables 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 ownUsername 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 onepasscode_okattempt row); wrong/expired/revoked/exhausted passcode (each assertinguse_countis unchanged); cooldown refusal; duplicate email/username and the case-variant duplicate (each asserting no use claimed); over-lengthrealName; weak password; lowercased storage and echo; a client-suppliedrolebeing ignored; a self-signed-up DJ session being unable to self-approve viaPOST /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 thepasscode_failrow the old order skipped entirely.npm run typecheckclean across all workspacesnpm run lintclean (0 errors; only pre-existing warnings elsewhere in the repo)npm run format:checkcleantests/unit/scripts/ci-env-surface-parity.test.tsupdated and passing (the two new auth-only env vars are allow-listed as workflow-only, mirroringDEFAULT_ORG_SLUG/EMAIL_ENABLED)Follow-up
cooldown_refusedis written per refused request rather than at most once per cooldown window (BS#2361 step 4). Pre-existing in the lifecycle module from Harden the additionalFields write-protection guard: OIDC rationale is inaccurate and the lock assertion is substring-based #2367; not introduced here.Closes #2361