Skip to Content

← All archived runs

Run: demo-access-gate

run.md

Run: demo-access-gate

  • branch: claude/demo-guardrails-access-phkj9w
  • pr: #872

02_define/output/spec.md

Spec: Close the demo host behind a per-person authenticator gate

  • slug: demo-access-gate
  • personas: Admin
  • touches: apps/web/proxy.ts, apps/web/lib, apps/web/app/(auth)/demo, apps/web/app/api/demo, turbo.json, apps/docs/app/technical/demo-environment
  • complexity: standard

Problem

demo.sustentus.com is live and serves the demo tenant, but the door it was built for cannot be opened: DEMO_AUTO_SIGN_IN is pinned off in Production because the session behind it is a superuser admin nobody signs in to. The batch's original answer — the retired demo-public-guardrails stub — was to narrow what that session may do. Jamie redirected it on 2026-08-21: narrowing the session never addressed the real exposure, which is that anyone reaching the demo reads the whole product, its personas and the way the platform works off the screen. The demo is the strongest asset for onboarding vendor partners (Q2-2026 Objective 1 — Establish Product-Market Fit with Vendor Partners), and it is worth nothing while it is either unusable or open to whoever guesses the URL.

What is missing is a control on who reaches the host at all, stronger than the Clerk sign-in that protects platform.sustentus.com — because on the demo host there is no password to be wrong.

Proposed change

A per-person authenticator gate stands in front of the entire demo host.

A curated roster — the internal team plus named partners — enrol a TOTP secret in their authenticator app once, out of band. On the demo host, a request that has not passed the gate sees one thing: a challenge asking for a handle and the current six-digit code. Nothing else on the host is reachable or discoverable until it is answered — not the demo entry page, not the app, not the route that mints a session.

Answering it correctly sets a short-lived, signed, host-scoped gate cookie. Behind that cookie the demo is exactly what it is today: the entry page offers "enter the demo", automatic admin sign-in mints its Clerk session, and the demo admin keeps every affordance it currently has. The gate adds a layer in front; it changes nothing behind.

Two properties carry the feature. The gate is decided server-side from configuration re-read per request, so no cookie, header or crafted body lifts it — the same rule resolveRequestAgentMode and isDemoHost already follow. And it fails closed: a demo host whose gate is unconfigured admits nobody, which deliberately inverts the "unset behaves as before" convention the other demo variables use, because here "as before" would mean wide open.

platform.sustentus.com is untouched — every branch in this feature is reached only on a demo request.

How the gate is composed

The roster is DEMO_ACCESS_ROSTER: comma-separated handle:BASE32SECRET entries, parsed and re-read per request exactly as DEMO_HOSTS is. Handles are lowercase, unique and short (jamie, acme-partner); a duplicate or malformed entry is a configuration error that refuses rather than degrades. Adding or removing someone is an environment change, which is the right weight for a list this size and needs no database, migration or admin UI.

The challenge takes a handle and a code, and verifies the code as RFC 6238 TOTP — SHA-1, six digits, a thirty-second step, which is what every authenticator app defaults to — accepting the current step and one either side for clock skew, compared in constant time. Verification uses Web Crypto so one implementation serves both the proxy and the route handlers.

The gate cookie carries the handle and an expiry, signed HMAC-SHA-256 with DEMO_ACCESS_SECRET. It is HttpOnly, Secure, SameSite=Lax, host-only — no Domain attribute, so it is never sent to the platform host — and lives twelve hours. Verifying it also requires the handle to still be on the roster, so removing someone ends their access rather than waiting out their cookie. Rotating DEMO_ACCESS_SECRET invalidates every outstanding cookie at once, which is the emergency stop.

Enforcement happens twice, because a route handler must not trust the proxy alone. proxy.ts rewrites every ungated demo-host request to the challenge — a rewrite rather than a redirect, so the URL is preserved and the host discloses nothing about what paths exist. The two front-door paths are exempt: the challenge page itself, and the POST route that verifies a code. /api/demo/enter is left to pass the proxy and re-checks the gate itself, refusing as a fifth reason alongside the four it already has.

Guessing is bounded by the token-bucket limiter demo-sign-in.ts already owns, keyed both per client address and per handle, and a code once accepted cannot be replayed inside its window. Refusals are uniform whether the handle is unknown or the code is wrong, so the roster cannot be enumerated. The limiter's state is per-instance, the same accepted trade documented for the entry route in #865 — a brake, not a guarantee — and the TOTP secret, not the limiter, is what makes guessing hopeless.

Acceptance criteria

  • On the demo host with no gate cookie, every path — /, /demo, an admin route, an unknown path — serves the challenge, at the requested URL, disclosing nothing about what exists behind.
  • A roster member submits their handle and a current code from their authenticator app, is returned to the path they originally asked for, and from there reaches the demo tenant's admin dashboard having typed no Sustentus credentials.
  • The gate cookie is signed, HttpOnly, Secure, SameSite=Lax, host-only and twelve hours long; a cookie with an edited handle, an edited expiry or a wrong signature is refused.
  • A cookie whose handle has been removed from DEMO_ACCESS_ROSTER is refused on its next use.
  • /api/demo/enter refuses ungated requests server-side, before Clerk is called, and continues to refuse on the platform host, on the kill switch and on a missing id.
  • With DEMO_ACCESS_ROSTER or DEMO_ACCESS_SECRET unset or empty, the demo host admits nobody and says so, rather than falling open.
  • A wrong code is refused indistinguishably from an unknown handle; repeated attempts are rate limited per address and per handle; an accepted code is refused on replay within its window.
  • TOTP verification is unit-tested against the RFC 6238 test vectors, and the cookie, roster parsing, replay and refusal matrix are unit-tested alongside them.
  • platform.sustentus.com is wholly unaffected: no challenge, no rewrite, no new cookie, and the sign-in flow is unchanged.
  • The ?demo=1 preview override still makes a preview deployment a demo request, so the whole flow — challenge, cookie, entry, sign-in — is exercisable on a preview before Production.
  • The two new variables are declared in turbo.jsonglobalEnv and lint is clean.
  • technical/demo-environment documents the gate, the enrolment procedure and the fail-closed rule, and retires the "DEMO_AUTO_SIGN_IN must stay off in Production" warning together with its reference to the withdrawn demo-public-guardrails stub.

Out of scope

  • Everything the retired demo-public-guardrails stub proposed — refusing Clerk invitations, suppressing outbound email to the storyline personas, hiding the live agent toggle, and removing populate and reset. Jamie's decision on 2026-08-21: with access restricted to trusted people, the demo admin keeps every affordance it has today. Not deferred — withdrawn.
  • A general read-only-admin permission model or any new role. The access-control epic owns the permission registry.
  • Self-service enrolment: no in-app QR code, no roster admin screen. Enrolment is a documented one-off procedure that mints a secret and an otpauth:// URI.
  • A shared or durable store for the rate-limit and replay state. Per-instance memory, as #865 accepted for the same reason.
  • Turning DEMO_AUTO_SIGN_IN on in Production. This run removes the reason it is off and says so in the docs; flipping the variable is an operator's act, not a code change.
  • Any change to platform.sustentus.com, to Clerk configuration, or to the demo world — storyline, seeder, isDemo semantics, nightly reset.
  • Per-visitor isolated tenants, reset cadence for real traffic, and marketing promotion of the demo link. Epic-level out of scope, unchanged.

Open questions

  • Non-blocking: the roster lives in an environment variable because it is a handful of people and a change should be deliberate. If it ever outgrows that — enough partners that edits become routine, or a wish to see who entered when — moving it to a collection with an admin screen is the upgrade path, and a stub of its own. Nothing in this run's shape prevents it.

03_build/output/notes.md

Build notes: demo-access-gate

  • commits: feat: demo-access-gate — close the demo host behind a per-person authenticator gate · style: format the demo-access-gate sources · fix: demo-access-gate — pin the Uint8Array buffer type at the crypto boundaries
  • ci: GREEN on 3da81f5, settled via ci-status.sh — including the Run tests step, so the RFC 6238 vectors and the cookie/roster/replay suites pass in CI

What changed

  • apps/web/lib/demo-access.ts (new): the whole gate as pure, unit-testable logic — roster parsing, RFC 6238 TOTP verification, the signed gate cookie, the replay guard and the two rate limits. Web Crypto rather than node:crypto, so one implementation serves both proxy.ts on the edge and the route handlers. It reuses createRateLimiter from demo-sign-in.ts rather than growing a second token bucket.
  • apps/web/proxy.ts: on a demo request without a satisfied gate, the challenge and its POST route pass through, /api/** gets a bare 404, and every other path is rewritten to the challenge carrying redirect_url. A rewrite rather than a redirect keeps the requested URL, so the host discloses nothing by the address bar either.
  • apps/web/app/(auth)/demo/access/page.tsx + _components/demo-access-challenge.tsx (new): the challenge. Says as little as it can — no product detail, and the same page whether the gate is unconfigured or the visitor simply has no cookie.
  • apps/web/app/api/demo/access/route.ts (new): checks an answer and issues the cookie. POST only, host-checked, fail-closed, rate-limited per address and per handle, replay-guarded, and every refusal is the same invalid at the same status so the roster cannot be enumerated.
  • apps/web/app/api/demo/enter/route.ts: a fifth refusal, not_gated, checked before Clerk is called. The proxy already refuses these, but a route that mints a superuser session does not trust the layer in front of it.
  • turbo.jsonglobalEnv: DEMO_ACCESS_ROSTER, DEMO_ACCESS_SECRET.
  • apps/web/scripts/demo-roster.ts + root pnpm demo:roster (new): the enrolment procedure the spec called for, as a command rather than prose. It imports the app's own parseRoster rather than reimplementing the rules, so its validation cannot drift from the validation guarding the host, and it refuses the two edits that silently lock everyone out — re-minting a secret for someone already enrolled, and init over an existing roster. Run through jiti, already a root devDependency for eslint.config.ts; no new dependency.

The one convention this feature inverts, deliberately

DEMO_HOSTS and DEMO_AUTO_SIGN_IN are both written so an unset variable behaves as though the feature never existed. For the gate that would mean the demo host wide open, so it fails closed: unset or malformed configuration admits nobody. A malformed roster entry refuses the whole roster rather than dropping a line — a half-parsed allowlist is what nobody notices until the wrong person is inside. Both are called out in the module header and in the turbo.json comment.

Acceptance criteria status

  • Ungated request to any path serves the challenge at the requested URL — proxy rewrite; the rendered result is Verify's preview check
  • A roster member passes with handle + code and reaches the demo admin dashboard — code complete end to end; the signed-in half is a preview smoke, same as demo-auto-sign-in
  • Cookie signed, HttpOnly, Secure, SameSite=Lax, host-only, twelve hours; edited handle, edited expiry or wrong signature refused — unit-tested
  • A cookie whose handle left DEMO_ACCESS_ROSTER is refused on its next use — the roster is re-checked at verify time, not only at issue time; unit-tested
  • /api/demo/enter refuses ungated requests server-side before Clerk, and still refuses on the platform host, the kill switch and a missing id
  • Unset or empty DEMO_ACCESS_ROSTER / DEMO_ACCESS_SECRET admits nobody and says so
  • Wrong code indistinguishable from unknown handle; rate limited per address and per handle; accepted code refused on replay — uniform refusal + constant-time compare, unit-tested
  • TOTP unit-tested against the RFC 6238 vectors, alongside the cookie, roster, replay and refusal matrix
  • platform.sustentus.com wholly unaffected — every new branch is inside if (demo …)
  • The ?demo=1 preview override still makes a preview a demo request — untouched, so the whole flow is exercisable on this PR's preview
  • Both variables declared in turbo.jsonglobalEnv
  • technical/demo-environment documents the gate, enrolment and the fail-closed rule, and retires the "DEMO_AUTO_SIGN_IN must stay off in Production" warning — left to Ship, where docs-sync owns apps/docs, exactly as the two sibling runs in this epic did

Two red rounds, and what caused them

Worth recording because it cost two CI cycles and will cost the next agent the same. This container had no node_modules at all, so neither Husky nor a local Prettier existed and the pre-commit formatter the factory relies on silently never ran — the first red round was format:check on five files. Running pnpm install restored the hooks, and lint-staged has formatted every commit since. The second red round was a real type error of mine: TypeScript 5.9 types Uint8Array as generic over its buffer, so the default ArrayBufferLike is not assignable to BufferSource; the crypto helpers now say Uint8Array<ArrayBuffer>, which is what every caller already produces.

Notes for Verify

  • Preview needs configuring before the flow can be walked. The preview deployment has no DEMO_ACCESS_ROSTER / DEMO_ACCESS_SECRET, so it will show "This environment is not available" until someone sets them — that is the fail-closed path working, not a defect. To configure it: pnpm demo:roster init <handle> prints both values and the enrolment for each person.
  • Enrolment prints the QR code itself. otpauth:// is a scheme authenticator apps register, not a web address — no browser opens it, and it is only useful as the payload of a QR code. So add and init shell out to qrencode -t ANSIUTF8 and draw the code straight into the terminal, which also keeps the secret off a third-party generator. qrencode is not a repo dependency, so a machine without it still gets a working command and the otpauth:// payload plus install instructions. The URI's label separator is a literal :; percent-encoding it to %3A is what several QR generators and apps choke on.
  • Look hardest at proxy.ts. The gate branch sits before the Clerk !userId redirect, so it is what a signed-in real user also hits on the demo host — intended, since "dark" has to mean dark for them too, but it is the branch most worth a second pair of eyes.
  • Rate-limit and replay state is per-instance, the same accepted trade demo-auto-sign-in recorded for the entry route. The TOTP secret, not the limiter, is what makes guessing hopeless.
  • Removing someone is an environment change, which on Vercel needs a redeploy to take effect. Worth knowing before it is relied on as the revocation path.
  • The two ops consequences of this run — turning DEMO_AUTO_SIGN_IN on in Production, and setting the two new variables there — are deliberately out of scope as operator acts, per spec.md.

04_verify/output/verify.md

Verify: demo-access-gate

  • ci: GREEN on 05df465 — settled via ci-status.sh after the last push of this stage
  • previews smoked: web (https://web-git-claude-demo-guardrails-access-phkj9w-sustentus.vercel.app) · demo, docs, help-centre, marketing, storybook all skipped for this diff, so nothing was demonstrated on them
  • production-readiness: run (triggered by auth + new env vars) — five findings, three fixed here, two are operator acts recorded below
  • code-review: the CI Claude review (Review diff against CONVENTIONS.md) is enabled and passed on bcaeb05 with no comments posted and no review threads open — nothing to triage
  • security-review: run (triggered by auth + route policies) — four findings, all fixed here
  • playwright: TODO — manual DoD smoke performed instead

What the reviews did not find

Worth recording, because it is most of the diff and it held up. RFC 6238 is implemented correctly including the dynamic-truncation offset and the ±1 step window; the gate cookie's signature is verified before its payload is decoded or parsed, which is the ordering that makes a forged payload unreachable; the roster is re-checked on every use rather than only at issue; and resolveEntryRedirect closes the open redirect properly — it strips ASCII tab, CR and LF before parsing, which is the case a startsWith("//") check misses. platform.sustentus.com was traced along all four paths a demo route could reach it by and is untouched. The roster script passes the otpauth:// URI to spawnSync as an argument with no shell, so a handle cannot become a command.

DoD smoke (on the preview — each line says who verified it)

  • Ungated request to any path serves the challenge at the requested URL — agent: traced in proxy.ts; the rewrite preserves the URL and /api/** takes a bare 404
  • A roster member passes with handle + code and reaches the demo admin dashboard — operator, and blocked until DEMO_ACCESS_ROSTER / DEMO_ACCESS_SECRET are set at Preview scope on the web project. Until then the preview shows "This environment is not available", which is the fail-closed path working, not a defect
  • Cookie signed, HttpOnly, Secure, SameSite=Lax, host-only, twelve hours; edited handle, edited expiry or wrong signature refused — agent: unit-tested, and the set call carries no domain
  • A cookie whose handle left the roster is refused on its next use — agent: unit-tested
  • /api/demo/enter refuses ungated requests before Clerk — agent: read in the handler, checked ahead of the Clerk call
  • Unset or empty configuration admits nobody — agent: unit-tested, and demonstrated live on the preview, which is unconfigured and dark
  • Wrong code indistinguishable from unknown handle; rate limited per address and per handle; replay refused — agent: unit-tested; the timing half of "indistinguishable" was a real gap and is fixed below
  • TOTP unit-tested against the RFC 6238 vectors — agent: six published vectors, green in CI
  • platform.sustentus.com wholly unaffected — agent: every new branch sits inside if (demo …)
  • The ?demo=1 preview override exercises the whole flow — operator, same blocker as criterion 2: the override reaches the gate, but the gate has nothing to admit anyone with
  • Both variables declared in turbo.jsonglobalEnv — agent: read, and lint is green in CI
  • technical/demo-environment documents the gate — left to Ship, where docs-sync owns apps/docs, as the two sibling runs in this epic did
  • auth: demo admin sign-in + dashboard — operator, behind the same preview blocker
  • payments: not touched
  • notifications: none expected — this diff sends nothing

Findings & cleanup

  • Cron and webhook routes were shadowed on the demo host — fixed on branch. The gate's bare 404 for /api/** sat before isPublicRoute, and demo.sustentus.com and platform.sustentus.com are two domains on one Vercel project. A cron invocation arriving with a demo Host would have taken the 404, stopping /api/cron/demo-reset — the demo host's own nightly reset — silently. Both that route and the Clerk webhook authenticate themselves in their handlers, so admitting them past the gate widens nothing. This was the one finding that would have shipped a defect.
  • A degenerate roster secret answered 500 instead of refusing — fixed on branch. decodeBase32("A") returned a zero-length array rather than null, so parseRoster accepted the entry and crypto.subtle.importKey threw at answer time. That broke two stated invariants at once: whole-roster-or-nothing, and the uniform refusal. Guarded, with tests at both levels.
  • An unknown handle was measurably faster to refuse than a known one — fixed on branch. The map lookup returned before any crypto ran, so response time enumerated the roster even though the status code did not. Unknown handles now verify against a fixed decoy secret whose result is discarded.
  • Two docstrings claimed the roster is re-read per request — fixed on branch. proxy.ts runs on the edge, where Next inlines process.env at build time, so revocation and the DEMO_ACCESS_SECRET emergency stop are both redeploys. The build notes had this right; the module docstrings, which are what someone reads during an incident, did not.
  • Static assets are served ungated — accepted. The proxy matcher skips _next and static extensions, so the "discloses nothing" claim is a shade strong. No data is exposed.
  • Both variables must be set in Vercel Production before the merge deploy — operator act, and a genuine prerequisite rather than a nicety. The gate fails closed, so merging without them makes demo.sustentus.com dark to everyone, the team included.
  • Rollback is clean — no migration; a revert restores prior behaviour, and unsetting DEMO_HOSTS is the immediate off-switch.
  • Changelog: not warranted — an internal demo host with no customer-visible surface.

Context budget: over. The two conditional passes both triggered, and confirming the cron finding meant reading vercel.json and the public-route matcher, none of which the Inputs list names.

05_ship/output/investor-update.md

Partners can be let into the demo without letting in everyone

Who it's for: The internal team and named vendor partners What shipped: demo.sustentus.com is dark until someone on a short roster enters a code from their authenticator app. Why it matters: The demo is our strongest asset for onboarding vendor partners — Objective 1: Establish Product-Market Fit with Vendor Partners — and it stayed switched off rather than open to anyone with the URL.

Dig deeper: https://github.com/sustentus/sustentus/pull/872

05_ship/output/release.md

Ship: demo-access-gate

  • pr: #872 · merge: authorised — Ready to merge ticked; this commit rides the squash
  • CI: GREEN on the head this file is pushed on, settled via ci-status.sh after the last push. Verify's own head 05df465 was green before the docs and notes below were written
  • technical docs: technical/demo-environment — added The access gate (composition, fail-closed rule, the redeploy requirement, the pnpm demo:roster enrolment procedure), added not_gated as a fifth entry-route refusal, and retired Do not enable this in Production yet together with its reference to the withdrawn demo-public-guardrails stub. That section is now What keeps this safe: the gate, not the kill switch, is what holds the door
  • business docs: no business docs impact — nothing a customer persona does changes; the demo host is an internal and named-partner surface
  • release notes: ship-note only — no end-user note. This is an access control on an internal demo host with no customer-visible surface, and no changelog entry is warranted
  • sent: ship note queued for #product-update by the merge
  • close-out: archives the run, and the demo-public-access epic with it — this is its last stub. demo-host-serving and demo-auto-sign-in are both already archived, and .icm/intake/demo-public-access/ holds nothing but _done/ and breakdown.md

Acceptance check (vs spec)

  • Ungated request to any path serves the challenge at the requested URL — traced in proxy.ts during Verify; rewrite preserves the URL, /api/** takes a bare 404
  • A roster member passes with handle + code and reaches the demo admin dashboard — not demonstrated: the preview has no DEMO_ACCESS_*, so it is dark. Operator smoke, outstanding
  • Cookie signed, HttpOnly, Secure, SameSite=Lax, host-only, twelve hours; tampering refused — unit-tested, green in CI
  • A cookie whose handle left the roster is refused on its next use — unit-tested
  • /api/demo/enter refuses ungated requests before Clerk — read in the handler during Verify
  • Unset or empty configuration admits nobody — unit-tested, and demonstrated live on the preview, which is unconfigured and shows the unavailable state
  • Wrong code indistinguishable from unknown handle; rate limited per address and per handle; replay refused — unit-tested. The timing half was a real gap, found in Verify's security pass and fixed in 05df465
  • TOTP unit-tested against the RFC 6238 vectors — six published vectors, green in CI
  • platform.sustentus.com wholly unaffected — traced along all four paths in Verify
  • The ?demo=1 preview override exercises the whole flow — not demonstrated, same blocker as criterion 2
  • Both variables declared in turbo.jsonglobalEnv and lint clean — green in CI
  • technical/demo-environment documents the gate, enrolment and the fail-closed rule, and retires the Production warning — done in this stage, above

Carried forward

Two acceptance criteria ship undemonstrated, and one operator act is outstanding. Recording them here rather than ticking them:

  • The signed-in half of the smoke was never run. Criteria 2 and 10 need DEMO_ACCESS_ROSTER / DEMO_ACCESS_SECRET at Preview scope on the web project, which the preview does not have. What that leaves unproven is the happy path end to end — a real authenticator code exchanged for a cookie, and the demo admin dashboard behind it. Everything refusing is proven; the thing admitting is not.
  • Production needs both variables set. Until they are, demo.sustentus.com serves the unavailable state — which is the fail-closed rule working, not a regression: DEMO_AUTO_SIGN_IN is off in Production today, so the host is not usable either way. Set the two variables, turn DEMO_AUTO_SIGN_IN on, redeploy, then enrol with pnpm demo:roster init <handle>.
  • decodeBase32 accepts short-but-valid secrets below twenty bytes (a two-character secret gives a one-byte HMAC key). Zero-length now refuses; a weak-but-decodable one does not. The roster script only ever mints twenty bytes, so this is operator-typo territory — worth a stub if the roster ever outgrows the environment variable, not worth widening this PR.

Context budget: over. Verify's two conditional passes and confirming the cron finding pulled in vercel.json, the public-route matcher and the Vercel project's domain list, none of which the Inputs list names.