demo-access-gaterun.md02_define/output/spec.mddemo.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.
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.
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.
/, /demo, an admin route, an unknown
path — serves the challenge, at the requested URL, disclosing nothing about what exists behind.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.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.DEMO_ACCESS_ROSTER or DEMO_ACCESS_SECRET unset or empty, the demo host admits nobody
and says so, rather than falling open.platform.sustentus.com is wholly unaffected: no challenge, no rewrite, no new cookie, and
the sign-in flow is unchanged.?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.turbo.json → globalEnv 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.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.otpauth:// URI.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.platform.sustentus.com, to Clerk configuration, or to the demo world — storyline,
seeder, isDemo semantics, nightly reset.03_build/output/notes.mdfeat: 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 boundaries3da81f5, settled via ci-status.sh — including the Run tests step, so the
RFC 6238 vectors and the cookie/roster/replay suites pass in CIapps/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.json → globalEnv: 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.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.
demo-auto-sign-inHttpOnly, Secure, SameSite=Lax, host-only, twelve hours; edited handle,
edited expiry or wrong signature refused — unit-testedDEMO_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 idDEMO_ACCESS_ROSTER / DEMO_ACCESS_SECRET admits nobody and says soplatform.sustentus.com wholly unaffected — every new branch is inside if (demo …)?demo=1 preview override still makes a preview a demo request — untouched, so the whole
flow is exercisable on this PR's previewturbo.json → globalEnvtechnical/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 didWorth 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.
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.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.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.demo-auto-sign-in recorded
for the entry route. The TOTP secret, not the limiter, is what makes guessing hopeless.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.md05df465 — settled via ci-status.sh after the last push of this stageReview diff against CONVENTIONS.md) is enabled and passed on
bcaeb05 with no comments posted and no review threads open — nothing to triageWorth 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.
proxy.ts; the rewrite preserves the URL and /api/** takes a bare 404DEMO_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 defectHttpOnly, 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/api/demo/enter refuses ungated requests before Clerk — agent: read in the handler, checked
ahead of the Clerk callplatform.sustentus.com wholly unaffected — agent: every new branch sits inside if (demo …)?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 withturbo.json → globalEnv — agent: read, and lint is green in CItechnical/demo-environment documents the gate — left to Ship, where docs-sync owns
apps/docs, as the two sibling runs in this epic did/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.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.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._next and static
extensions, so the "discloses nothing" claim is a shade strong. No data is exposed.demo.sustentus.com dark to everyone, the team included.DEMO_HOSTS is the immediate off-switch.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.mdWho 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.mdci-status.sh after the last push.
Verify's own head 05df465 was green before the docs and notes below were writtentechnical/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#product-update by the mergedemo-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.mdproxy.ts
during Verify; rewrite preserves the URL, /api/** takes a bare 404DEMO_ACCESS_*, so it is dark. Operator smoke, outstandingHttpOnly, Secure, SameSite=Lax, host-only, twelve hours; tampering
refused — unit-tested, green in CI/api/demo/enter refuses ungated requests before Clerk — read in the handler during Verify05df465platform.sustentus.com wholly unaffected — traced along all four paths in Verify?demo=1 preview override exercises the whole flow — not demonstrated, same blocker
as criterion 2turbo.json → globalEnv and lint clean — green in CItechnical/demo-environment documents the gate, enrolment and the fail-closed rule, and
retires the Production warning — done in this stage, aboveTwo acceptance criteria ship undemonstrated, and one operator act is outstanding. Recording them here rather than ticking them:
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.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.