Skip to Content

← All archived intake epics

Intake: demo-public-access

breakdown.md

Breakdown: Public demo tenant at demo.sustentus.com with automatic admin sign-in

  • scope-slug: demo-public-access · source: Jamie's request (2026-08-18) and the codebase research answered in that session; no scope.md behind this batch.
  • initiative: Refine the bridge / objective: Q2-2026 Objective 1 — Establish Product-Market Fit with Vendor Partners
  • personas: Admin (the demo session's own role) — CSM, SDM, Expert, Vendor and Customer are reached from it through View As

What I understood

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.

Where it sits

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.jsonglobalEnv, and the demo guards in packages/services/src/db/services/tenant/.

Build order

  1. demo-host-serving — serve apps/web at demo.sustentus.com and route that host to its own public entry page — depends-on: none
  2. demo-auto-sign-in — mint a Clerk sign-in token per visitor and open the demo tenant as its admin — depends-on: demo-host-serving
  3. demo-access-gate — close the demo host behind a per-person authenticator gate — depends-on: demo-auto-sign-in

Stub 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.

Out of scope (whole scope)

  • Narrowing what a demo session may do — invitations, outbound email, the live agent toggle, populate and reset. Dropped with the retired stub 3 on 2026-08-21; restricting who gets in is the control the batch now relies on.
  • The demo world itself — storyline content, the seeder, isDemo semantics, the reset's purge/keep policy. demo-data-quality and demo-experience-fixes own that ground and are unaffected.
  • Reset cadence for public traffic. The nightly 01:00 cron already converges the world; whether a public demo needs a shorter cycle or a visitor-facing reset is a judgement to make once there is real traffic to measure, and it is a follow-on stub rather than a guess now.
  • Per-visitor isolated tenants or sandboxes — every visitor shares the one demo tenant.
  • Moving the demo to a different root domain, which is the only case that would need Clerk satellite domains or a second Clerk instance.
  • Marketing-site work — where the demo link is promoted, and any lead capture around it.

_done/demo-access-gate.md

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

  • feature-slug: demo-access-gate
  • scope: demo-public-access
  • personas: Admin
  • initiative: Refine the bridge / objective: Q2-2026 Objective 1 — Establish Product-Market Fit with Vendor Partners
  • depends-on: demo-auto-sign-in
  • sequence: 3 of 3

Problem

This 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.

Proposed change

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.

Acceptance criteria (rough)

  • An ungated request to any path on the demo host sees only the challenge — no entry page, no app, no indication of what else exists there.
  • A roster member passes with their handle and a current code from their authenticator app, and lands in the demo as its admin, having typed no Sustentus credentials.
  • The gate is decided server-side; no cookie, header or crafted body lifts it.
  • An unconfigured gate refuses everyone, rather than falling open.
  • Removing someone from the roster ends their access.
  • Guessing is rate-limited and a used code cannot be replayed.
  • platform.sustentus.com is untouched.
  • The docs stop saying DEMO_AUTO_SIGN_IN must remain off in Production, because the reason is gone.

Out of scope (this feature)

  • Everything the retired 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.
  • A general read-only-admin permission model, or any new role.
  • Per-visitor isolated tenants, reset cadence, and marketing promotion of the demo — epic-level out of scope, unchanged.

Notes for Define

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.md

Stub: Open the demo tenant as its admin, with no credentials

  • feature-slug: demo-auto-sign-in
  • scope: demo-public-access
  • personas: Admin
  • initiative: Refine the bridge / objective: Q2-2026 Objective 1 — Establish Product-Market Fit with Vendor Partners
  • depends-on: demo-host-serving
  • sequence: 2 of 3

Problem

technical/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.

Proposed change

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.

Acceptance criteria (rough)

  • From the demo entry page, a visitor with no session reaches the demo tenant's admin dashboard signed in as the demo admin, having typed no credentials.
  • 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.
  • The token is minted server-side per visit, is short-lived and single-use, and is never left anywhere that outlives the entry redirect.
  • The entry route refuses on the platform host, and refuses when the kill switch is off.
  • Repeated hits are rate-limited rather than minting a session per request.
  • A visitor who already holds a session is told what will happen and chooses, rather than being swapped silently.
  • technical/demo-environment is corrected — the "no auth bypass" statement and the access section both describe the new front door.

Out of scope (this feature)

  • Signing in as any persona other than admin; the other five are reached through View As, which is the hierarchy the demo is meant to show.
  • Per-visitor isolated tenants.
  • Narrowing what the resulting session may do — that is demo-public-guardrails.

Notes for Define

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.jsonglobalEnv, 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.md

Stub: Serve the demo tenant at its own address

  • feature-slug: demo-host-serving
  • scope: demo-public-access
  • personas: Admin
  • initiative: Refine the bridge / objective: Q2-2026 Objective 1 — Establish Product-Market Fit with Vendor Partners
  • depends-on: none
  • sequence: 1 of 3

Problem

The 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.

Proposed change

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.

Acceptance criteria (rough)

  • demo.sustentus.com serves the same deployment as platform.sustentus.com.
  • An unauthenticated request to any path on the demo host lands on the demo entry page, not on /sign-in.
  • platform.sustentus.com is unchanged: no redirect to the demo entry, sign-in flow untouched.
  • A signed-in session on the demo host reaches the app exactly as it does on the platform host — role resolution, route policies, View As, and the onboarding gates all behave identically.
  • The demo host is noindex and absent from the marketing sitemap.
  • The host is configuration rather than a literal, so a preview deployment can exercise the same branch.

Out of scope (this feature)

  • Any automatic sign-in — that is demo-auto-sign-in.
  • Any narrowing of what the demo session may do — that is demo-public-guardrails.
  • A separate Vercel project, or a demo on a different root domain.

Notes for Define

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.jsonglobalEnv 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}/.