demo-public-accessbreakdown.mdscope.md behind this batch.The demo tenant is presenter-led today: it lives on platform.sustentus.com, and a human signs in
as the demo admin and drives the View As switcher through the storyline personas. Jamie wants the
same tenant served at its own address, demo.sustentus.com, and wants a visitor to land inside it
already signed in as that admin — so a prospect can walk the platform without a presenter and
without credentials. The demo world itself does not change: same tenant, same isDemo flag, same
storyline, same nightly reset, same View As hierarchy and persona pins. What changes is the front
door (a second host with its own entry route), how a session starts (a Clerk sign-in token instead
of a password), and the blast radius — an admin session anyone on the internet can open needs the
destructive, billable and outbound-email affordances taken off it.
Technical demo environment (technical/demo-environment) and deployment (technical/deployment).
Touches apps/web/proxy.ts and the (auth) route group, the View As gate in apps/web/lib/auth.ts,
turbo.json → globalEnv, and the demo guards in packages/services/src/db/services/tenant/.
apps/web at demo.sustentus.com and route that host to its own public entry page — depends-on: noneStub 3 was demo-public-guardrails — narrow what a public demo session may do — until 2026-08-21,
when Jamie redirected it: the demo is not to be public at all. Guardrails on an open session were
never going to stop a visitor reading the product off the screen, so the batch now closes the host
to a curated roster instead and leaves the demo admin's affordances alone. The retired stub's
content is superseded, not deferred; the archived runs for stubs 1 and 2 still name it, and are
history rather than live pointers.
isDemo semantics, the reset's purge/keep
policy. demo-data-quality and demo-experience-fixes own that ground and are unaffected._done/demo-access-gate.mdThis stub replaced demo-public-guardrails on 2026-08-21, on Jamie's direction. The original
premise was that demo.sustentus.com would be open to the internet and the admin session it hands
out therefore had to be narrowed — no invitations, no outbound email, no live agent, no reset. The
premise is wrong: a visitor who reaches the demo sees the whole product, the roadmap it implies and
the way the platform works, and none of that should be readable by anyone who guesses the URL.
Narrowing what the session may do never addressed what it shows.
So the problem is access, not blast radius. platform.sustentus.com is behind Clerk; the demo host
needs to be behind something stronger, because the account it opens is a superuser admin nobody
signs in to. DEMO_AUTO_SIGN_IN is off in Production today precisely because nothing stands in
front of it — the demo cannot be used at all until something does.
Put a per-person authenticator gate in front of the whole demo host. A curated roster — the
internal team plus named partners — each enrol a TOTP secret in their authenticator app once. On
demo.sustentus.com an ungated request sees nothing but the challenge: a handle and a six-digit
code. Passing it sets a short-lived, signed, host-scoped gate cookie; everything behind it is the
demo exactly as it is today, automatic admin sign-in included.
The demo session itself is not narrowed. Access is restricted instead, so the affordances the original stub would have removed stay where they are.
platform.sustentus.com is untouched.DEMO_AUTO_SIGN_IN must remain off in Production, because the reason is
gone.demo-public-guardrails stub proposed — invitations, outbound email
suppression, the live agent toggle, populate and reset. Jamie's call on 2026-08-21: with access
restricted to trusted people, the demo admin keeps every affordance it has today.The gate belongs on the host, not on the entry route alone: the ask is that the URL is dark to anyone not on the roster, not merely that they cannot start a session.
Copy the shape that already works twice in this epic. apps/web/lib/demo-host.ts parses a
comma-separated allowlist out of the environment and re-reads it per request;
apps/web/lib/demo-sign-in.ts keeps the pure, unit-testable half away from the route handler that
performs the side effect, and already owns a token-bucket limiter worth reusing.
Note the one convention this feature has to invert: DEMO_HOSTS and DEMO_AUTO_SIGN_IN are both
written so that an unset variable behaves as though the feature never existed. For the gate, "as
before" means wide open, so it must fail closed instead.
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/.
_done/demo-auto-sign-in.mdtechnical/demo-environment states today's rule outright: "presenter-led access — no auth bypass
exists or is planned this batch". A prospect therefore cannot see the platform without a presenter
driving it and a password nobody wants to hand out. The demo is the strongest asset for onboarding
vendor partners and it is gated behind a diary invite.
Entry on the demo host mints a short-lived, single-use Clerk sign-in token for the demo admin server-side, completes the sign-in in the browser, activates the demo organisation, and lands the visitor on the admin dashboard. The session is a genuine Clerk session on the real demo admin user — not a bypass — so tenant resolution, route policies, permissions, the View As switcher with its pinned storyline personas, and canned agent mode all work unchanged, exactly as they do for the presenter today.
Entry is a deliberate act — an "Enter the demo" control on the entry page — rather than a redirect on first byte, so someone who already has their own session is offered the choice instead of being silently swapped onto the demo account. The route is refused on any host but the demo host, sits behind a kill switch, and is rate-limited.
auth() resolves the demo organisation and the admin role, so the View As switcher offers the
pinned storyline personas and each persona's dashboard renders as it does for the presenter.technical/demo-environment is corrected — the "no auth bypass" statement and the access
section both describe the new front door.demo-public-guardrails.The mechanism is confirmed against the installed Clerk, not assumed: @clerk/nextjs 7.3.1 resolves
@clerk/backend 3.4.5, which exposes clerkClient().signInTokens.createSignInToken({ userId, expiresInSeconds }), and sign-in tokens are single-use. In Clerk 7's signals API the prebuilt
<SignIn/> does not consume __clerk_ticket on its own — the flow is useSignIn() →
signIn.ticket({ ticket }) → signIn.finalize() → setActive({ organization }).
setActive({ organization }) is load-bearing: without an active organisation, auth() returns no
orgRole, mapClerkRoleToSystemRole yields null in apps/web/proxy.ts, and the visitor lands on
/no-role instead of the demo.
DEMO_TENANT_CLERK_ORG_ID is today a CI-only GitHub Actions variable, read only by the flag
migrations (1786492800000-flag-demo-tenant.ts and 1786579200000-reflag-demo-tenant-preview.ts);
the runbook says plainly that "no app-runtime code reads this variable today". This feature changes
that, so it needs a Vercel value and an entry in turbo.json → globalEnv, alongside the demo
admin's Clerk user id and the kill switch.
The preview environment already carries its own demo organisation and its own isDemo tenant, so
the whole flow is exercisable on a preview deployment before it reaches production. touches:
apps/web/app/, apps/web/proxy.ts, turbo.json, apps/docs/app/technical/demo-environment/.
_done/demo-host-serving.mdThe demo tenant is only reachable at platform.sustentus.com, behind the ordinary sign-in — there
is no address to hand a prospect, and nothing that says "this is the demo". The app has no notion
of which host it is being served from either, so there is nowhere for demo-only behaviour to hang.
Serve the existing apps/web deployment at demo.sustentus.com as a second domain on the same
Vercel project, and teach the app which host it is answering on. On the demo host an unauthenticated
request goes to a public demo entry page instead of the sign-in screen; on the platform host nothing
changes. The demo host is kept out of search indexes.
The entry page is deliberately a placeholder in this feature — it introduces the demo and links to the ordinary sign-in — so the host is provably live, and the demo-only branch provably isolated, before any automatic sign-in exists.
demo.sustentus.com serves the same deployment as platform.sustentus.com./sign-in.platform.sustentus.com is unchanged: no redirect to the demo entry, sign-in flow untouched.noindex and absent from the marketing sitemap.demo-auto-sign-in.demo-public-guardrails.The host branch belongs in apps/web/proxy.ts, at the existing unauthenticated redirect to
/sign-in; the demo entry path also has to join isPublicRoute there, next to the cron and Clerk
webhook exemptions.
Clerk needs no domain configuration for this: session cookies are shared across subdomains of the
same root domain by default, and satellite domains are only for different root domains. Confirm the
production instance's allowed-origins list, if it is restricted, includes the new host. One
consequence to record rather than solve here: because the cookie is shared, a session started on
either host is visible on the other — demo-auto-sign-in is where that has to be handled.
Any new host variable goes into turbo.json → globalEnv in the same PR; turbo runs in strict env
mode and turbo/no-undeclared-env-vars fails lint otherwise. touches: apps/web/proxy.ts,
apps/web/app/, turbo.json, apps/docs/app/technical/{deployment,demo-environment}/.