demo-otp-accessrun.mdlane/output/notes.mdapps/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.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:
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.proxy.ts runs this module on the edge).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:
readChallengeCookie rather than left to be rediscovered.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.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.after() so it runs past the response — uniform timing,
and a delivery that still completes on a serverless runtime.Domain), so neither is sent to the platform host.GATE_VERSION bumped v1 → v2: the payload is an address now, not a handle.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.
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.json — DEMO_ACCESS_ROSTER's meaning documented.