Skip to Content

← All archived runs

Run: demo-otp-access

run.md

Run: demo-otp-access

  • lane: tweak
  • branch: claude/demo-sustentus-otp-auth-bu9iaw
  • pr: #880

lane/output/notes.md

Tweak: demo-otp-access

  • change: apps/web/lib/demo-access.ts + the demo gate's routes and challenge UI: per-person TOTP (handle + authenticator code) → email one-time code (email → 6 digits mailed by Resend). DEMO_ACCESS_ROSTER changes meaning from handle:BASE32SECRET to a list of email addresses.
  • changelog: not warranted — the demo host is unlisted and its roster is the internal team plus named partners, so there is no end-user audience for a help-centre entry. The roster is being told directly.

Routed against the lane's own rules

The tweak contract excludes changes that touch auth or carry open decisions. This one does both, and the recommendation was /pipeline scope. The operator chose the tweak lane and settled the three open decisions in conversation, so it runs here with a single Ready to merge gate and no spec. Recorded because the lane's exclusion was overridden, not met.

Decisions taken by the operator, not by the agent:

  1. Autofill — OTP only, best-effort one-tap. Fully automatic fill from an inbox is not achievable on the web: WebOTP (navigator.credentials.get({otp})) is SMS-only in every shipping browser. What is implemented is autocomplete="one-time-code" on the OTP input, a plain-text part in the mail leading with the code, and auto-submit the instant the sixth digit lands — so on iOS/macOS the code usually appears above the keyboard once Mail has it, and a paste of the whole code needs no further click. The typed path stays first-class.
  2. Challenge state — stateless signed token, not a Mongo-backed store. Keeps the gate's existing zero-I/O, edge-compatible property (proxy.ts runs this module on the edge).
  3. Lane — tweak, per above.

What the stateless choice costs

The challenge cookie carries the address, an HMAC of the code, a nonce and an attempt counter, signed with DEMO_ACCESS_SECRET. Consequences worth knowing before merge:

  • The attempt counter bounds an honest browser exactly and a script not at all — a caller can replay the pristine cookie and never let it increment. What bounds a script is the per-IP and per-address rate limits over a six-digit space that expires in ten minutes. Documented on readChallengeCookie rather than left to be rediscovered.
  • Single-use is per-instance, via the same replay guard the TOTP gate used, now keyed by nonce. A shared store is the upgrade path if this ever needs to be a guarantee.
  • The code is never written into the cookie — only HMAC(secret, nonce:email:code). Binding to the nonce keeps two challenges that draw the same digits from sharing a fingerprint; binding to the address stops one member's cookie being replayed as another's.

Properties carried over from the TOTP gate

  • Fails closed: unset or malformed roster/secret admits nobody.
  • Uniform refusal — off-roster, wrong code and replayed code all return the same invalid/401, and the HMAC compare is awaited before it is folded in with the roster check so an off-roster challenge costs exactly what a wrong code costs. expired/410 is the one distinguishable refusal and is decided purely by a timestamp the caller was handed.
  • The request route never says whether an address is on the roster: same response, same cookie, both ways. The mail send is handed to after() so it runs past the response — uniform timing, and a delivery that still completes on a serverless runtime.
  • Roster re-checked on every gate-cookie verification, so removal ends access immediately.
  • Host-only cookies (no Domain), so neither is sent to the platform host.
  • GATE_VERSION bumped v1v2: the payload is an address now, not a handle.

Deploy note — this needs an environment change, not just a merge

DEMO_ACCESS_ROSTER must be replaced with a comma-separated list of email addresses before or with the deploy. The old handle:BASE32SECRET value does not parse under the new rules, so the gate fails closed and the demo host admits nobody until it is updated — correct behaviour, but it is a lockout, not a warning. pnpm demo:roster check --roster "<value>" says yes or no before you paste.

Sending the codes needs RESEND_API_KEY and EMAIL_FROM, both already declared in turbo.json and already set for the platform's own mail. A roster that parses with no configured sender means everyone is told to check an inbox nothing arrives in; the request route logs loudly when the send throws.

Files

  • apps/web/lib/demo-access.ts — TOTP/base32 out, code minting + challenge token in.
  • apps/web/lib/demo-access-email.ts — the code mail (new).
  • apps/web/app/api/demo/access/request/route.ts — mints and mails a code (new).
  • apps/web/app/api/demo/access/route.ts — verifies a code against the challenge cookie.
  • apps/web/app/(auth)/demo/access/** — two-step challenge UI.
  • apps/web/proxy.ts — the third front-door path.
  • apps/web/scripts/demo-roster.ts — no secrets to mint per person; secret replaces init.
  • apps/web/lib/demo-access.test.ts — suite rewritten onto the new primitives.
  • packages/services/src/email/send-html-email.ts — optional plain-text part.
  • turbo.jsonDEMO_ACCESS_ROSTER's meaning documented.