demo-auto-sign-inrun.md02_define/output/spec.mddemo-host-serving (stub 1, merged as #864) built the front door: demo.sustentus.com serves the
apps/web deployment, and an unauthenticated visitor lands on a /demo entry page. That page is a
placeholder by design — its only control is a link to the ordinary sign-in — so the demo is still
presenter-led, exactly as technical/demo-environment states outright: "presenter-led access — no
auth bypass exists or is planned this batch". A prospect therefore cannot see the platform without a
scheduled call, a human driver, and a password nobody wants to hand out.
The demo is the strongest asset we have for onboarding vendor partners, and it is gated behind a diary invite. Removing that gate is what Refine the bridge / Q2-2026 Objective 1 — Establish Product-Market Fit with Vendor Partners needs: today every prospect walkthrough costs a scheduled call and a presenter's hour.
This is stub 2 of 3 in demo-public-access. It puts a real session behind the entry page. Narrowing
what that session may do is stub 3, demo-public-guardrails.
An "Enter the demo" control on the existing /demo entry page starts a genuine Clerk session on
the demo tenant's admin user, and lands the visitor on the admin dashboard having typed nothing.
The mint is server-side. A new route handler at /api/demo/enter (POST only) mints a Clerk
sign-in token for the demo admin via clerkClient().signInTokens.createSignInToken({ userId, expiresInSeconds }) and returns it, with the demo organisation id, in the response body. Sign-in
tokens are single-use, and this one is short-lived (60 seconds). It is never put in a URL, a cookie,
localStorage, or a cacheable GET response; the response carries Cache-Control: no-store. The
organisation id is read from the server environment and passed down in the same body, so no new
NEXT_PUBLIC_ variable is introduced.
The browser completes it. Clerk 7's prebuilt <SignIn/> does not consume __clerk_ticket on its
own, so a client component runs the signals flow explicitly: useSignIn() → signIn.ticket({ ticket }) → signIn.finalize() → setActive({ organization }). setActive({ organization }) is
load-bearing, not decoration: 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.
Because the session is a real Clerk session on the real demo admin — not a bypass — everything downstream works unchanged: tenant resolution, route policies, permissions, the View As switcher with its pinned storyline personas, and canned agent mode all behave exactly as they do for the presenter today.
Where the visitor lands. proxy.ts already carries the attempted path to /demo as
redirect_url. After setActive resolves, the visitor is sent there when it is a same-origin
relative path (a leading /, not //), and to the admin role home otherwise or when it is absent.
Entry is a deliberate act. Nothing redirects on first byte. A visitor who already holds their own
session is shown, on the entry page, what entering will cost — it signs them out of their current
account — and chooses: continue to their own workspace, or sign out and enter the demo. Only on that
confirmation does the flow sign them out and run the ticket. A visitor already signed in as the
demo admin is not asked a pointless question: they go straight to their role home, which is what
/demo does for them today.
Three refusals guard the route, each returning a JSON error rather than a session:
isDemoRequest from
apps/web/lib/demo-host.ts. On the platform host it is not reachable.DEMO_AUTO_SIGN_IN variable. The route refuses unless it is explicitly
"1". Unset means off, so every existing deployment behaves exactly as it does today, and the
switch can be thrown to stop automatic sign-in without unsetting DEMO_HOSTS, which would
take the whole demo host down with it.DEMO_ADMIN_CLERK_USER_ID or
DEMO_TENANT_CLERK_ORG_ID is unset, rather than minting a token against a missing user.Rate limiting is an in-memory token bucket in the route handler's module scope, keyed by client
IP, refusing with 429 past its allowance. Deliberately per-instance and reset by a cold start: on
Vercel that is a weaker guarantee than a shared store, and it is the right trade here because every
mint targets the same demo admin user, so abuse costs Clerk API calls and session churn rather
than per-visitor MAU billing. The trade, and what would justify a shared store later, is recorded in
the docs at Ship. No new collection, migration or vendor dependency is added for it.
Environment. DEMO_TENANT_CLERK_ORG_ID is today a CI-only GitHub Actions variable read only by
the flag migrations (1786492800000-flag-demo-tenant.ts,
1786579200000-reflag-demo-tenant-preview.ts); technical/development/preview-database says plainly
that no app-runtime code reads it. This feature changes that, so it needs a Vercel value and an entry
in turbo.json → globalEnv, alongside DEMO_ADMIN_CLERK_USER_ID and DEMO_AUTO_SIGN_IN.
This supersedes one merged criterion. demo-host-serving specified "Signed in, /demo redirects
to the signed-in user's role home". That still holds for the demo admin, but a visitor signed in as
anyone else now gets the choice described above instead of a silent redirect. The change is
deliberate and is the stub's "told what will happen and chooses" criterion.
/demo on a demo request, a visitor with no session clicks one control and 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.localStorage, or a
cacheable response; the response carries Cache-Control: no-store.redirect_url when it is a same-origin relative path, and falls back to the
admin role home when it is absent, absolute, protocol-relative (//), or otherwise not a
same-origin path.POST /api/demo/enter refuses on the platform host (no demo host, no override cookie) and
mints nothing.POST /api/demo/enter refuses when DEMO_AUTO_SIGN_IN is unset or not "1", and refuses
when DEMO_ADMIN_CLERK_USER_ID or DEMO_TENANT_CLERK_ORG_ID is unset — in each case without
calling Clerk.429 past the bucket's allowance, rather
than minting a session per request./demo,
what entering will do to their current session, and chooses: continue to their own workspace,
or sign out and enter the demo. Nothing swaps their session until they confirm./demo, with no
prompt.GET (and every other method) on /api/demo/enter is rejected; only POST mints.proxy.ts lets it
through the !userId redirect on the demo branch only, and it stays unreachable on the
platform host.DEMO_HOSTS unset or empty, and with DEMO_AUTO_SIGN_IN unset, every route on every
host behaves exactly as it does today.DEMO_TENANT_CLERK_ORG_ID, DEMO_ADMIN_CLERK_USER_ID and DEMO_AUTO_SIGN_IN are declared in
turbo.json → globalEnv, and pnpm lint is clean of turbo/no-undeclared-env-vars.redirect_url same-origin guard (relative pass,
absolute/protocol-relative/absent fail), the token bucket (allowance, refusal past it, refill),
and the route's refusal matrix (wrong host, switch off, each variable unset).technical/demo-environment is corrected: the "presenter-led access — no auth bypass exists or
is planned this batch" statement and the Access section both describe the new front door, its
three refusals, the rate-limiting trade, and the three variables.?demo=1: entry from the placeholder page reaches
the admin dashboard, View As walks the storyline personas, and the same preview with the kill
switch off refuses.Operator steps, outside the diff, recorded in the docs at Ship and required before the production
demo is live: set DEMO_TENANT_CLERK_ORG_ID, DEMO_ADMIN_CLERK_USER_ID and DEMO_AUTO_SIGN_IN=1
on the apps/web Vercel project (Production, and Preview to exercise it there).
demo-public-guardrails (stub 3), and it is the reason this run does not
ship the demo host to production on its own.isDemo semantics, the nightly reset's purge/keep policy.sustentus.com subdomains, recorded but not solved by
demo-host-serving: a demo session is visible on the platform host and vice versa. This run makes
the swap explicit and consented on the demo side, which is as far as it goes without
cookie-domain or Clerk-instance changes that the batch put out of scope.03_build/output/notes.md86ad6f8 (feature), 1339e8c (Clerk signals API + lint ceiling)1339e8c — Quality Project and all six Vercel previews passapps/web/lib/demo-sign-in.ts (new): the pure half of the flow — kill switch and config
resolution, the redirect_url same-origin guard, the client-IP reader, and the in-memory token
bucket. No Clerk or Next imports, so it is unit-testable.apps/web/lib/demo-sign-in.test.ts (new): unit tests written from the acceptance criteria.apps/web/app/api/demo/enter/route.ts (new): POST-only. Mints a 60-second single-use Clerk
sign-in token for the demo admin and returns it with the demo organisation id, no-store. Four
refusals: wrong host (404), kill switch off (404), either id unset (503), rate limited (429). The
first three are checked before Clerk is called, so a refused request costs nothing and a refused
host never spends a visitor's rate-limit allowance.apps/web/app/(auth)/demo/_components/demo-entry.tsx (new, client): completes the sign-in in the
browser — signIn.ticket() → signIn.finalize() → setActive({ organization }) — then does a
full navigation so the proxy sees the new cookie on a fresh request.apps/web/app/(auth)/demo/page.tsx: offers the entry control; shows the existing-session choice;
sends a visitor who is already the demo admin straight to their role home. With the switch off
or either id unset it renders the placeholder demo-host-serving shipped, unchanged.apps/web/lib/demo-host.ts: DEMO_ENTRY_API_ROUTE added beside DEMO_ENTRY_ROUTE;
isDemoEntryPath now covers both, so the mint is public on the demo host and nowhere else.apps/web/proxy.ts: comment only — the widened isDemoEntryPath is what lets an unauthenticated
visitor reach the mint, and the early return still sits in front of the !userId check.turbo.json: DEMO_AUTO_SIGN_IN, DEMO_ADMIN_CLERK_USER_ID, DEMO_TENANT_CLERK_ORG_ID.No route-policies.ts entry, deliberately. The proxy returns early for the demo front door
before the policy table is consulted, so an entry is unnecessary on the demo host — and on the
platform host deny-by-default is exactly the behaviour wanted. Adding one would have granted the
route there.
/demo — one control, no credentialsauth() resolves the demo organisation and admin role — setActive({ organization }) is what
makes View As and the persona dashboards workCache-Control: no-storeredirect_url honoured only as a same-origin relative path; absolute, // and /\ fall backPOST /api/demo/enter refuses on the platform host and mints nothingDEMO_AUTO_SIGN_IN is not "1", or either id is unset — before calling Clerk429 (5 per minute, token bucket)POST is exported, so Next answers every other method with 405DEMO_HOSTS and DEMO_AUTO_SIGN_IN unset, every route behaves as it does todayturbo.json → globalEnv; lint clean (CI green)technical/demo-environment corrected — Ship's job, via docs-sync, in this same PR
(_shared/knowledge-map.md: Build reads docs, Ship writes them). Nothing reaches main
with the "no auth bypass" line still standing.?demo=1 — Verify's DoD smoke, and it needs the three
variables set on the Preview environment first (see below)DEMO_AUTO_SIGN_IN=1,
DEMO_ADMIN_CLERK_USER_ID and DEMO_TENANT_CLERK_ORG_ID must be set on the apps/web Vercel
Preview environment. Without them the entry page correctly renders the old placeholder and the
route refuses — which is the specified behaviour, not a bug, but it is also not a smoke test.useSignIn() in Clerk 7 returns { signIn, errors, fetchStatus } — no isLoaded, no
setActive (that comes from useClerk()), and ticket()/finalize() report failure by
returning { error } rather than throwing. Worth confirming on the preview that a failed ticket
surfaces the error state rather than a blank screen.spec.md and in the module comment. Ship records the trade in the docs.demo-host-serving specified "signed in, /demo
redirects to the user's role home". That now holds only for the demo admin; anyone else gets the
choice. Deliberate, and called out in spec.md → Proposed change.04_verify/output/verify.mdc9064e9 — settled via ci-status.sh after the last pushapps/web and turbo.json)standard) — 6 findings, 4 fixed on branch, 2 acceptedisDemoRequest gate at
route.ts:52, and proxy.ts returns early only when demo is true (agent)route.ts:60-73; unit-covered in demo-sign-in.test.ts (agent)redirect_url guard rejects absolute, protocol-relative, backslash and
control-character bypasses — unit-covered, 8 cases (agent)demo-host-serving placeholder — traced in
page.tsx:50-72 (agent)7502aaf. Three defects found after that report
changed the entry flow itself (see Findings 1–3), so that confirmation does not carry
forward to c9064e9. Needs re-running: enter the demo → admin dashboard, View As through
the storyline personas, and the existing-session choice.inviteUser, which does send mail; that is
Finding 5, not a notification this run emits.resolveEntryRedirect
rejected only literal // and /\. Browsers strip tab/CR/LF while parsing, so
?redirect_url=/<tab>/evil.example passed the guard and reached location.assign as
//evil.example — off-origin, on an attacker-supplied param, fired the moment a real session
existed. **Fixed** (11c5dca): strip those characters, then decide by parsing against an
opaque base and requiring the origin to survive. Four regression tests added.signOut() navigated away mid-handler (code-review) — the no-argument form performs
Clerk's own post-sign-out navigation, tearing down the entry handler before it could mint.
The "sign out and enter the demo" path could not have worked. Fixed (11c5dca): callback
form.finalize() redirected before setActive (code-review) — the default post-sign-in
redirect ran before the organisation was activated, so the proxy saw a session with no
orgRole and routed to /no-role — precisely the outcome the spec called load-bearing.
Fixed (11c5dca): finalize({ navigate: () => {} }), then setActive, then our own
navigation.11c5dca).inviteUser sends real Clerk invitations
granting permanent org membership that the nightly reset never revokes, and
setDemoAgentMode flips the AI agent to live and spends real budget. Accepted, not fixed
here: this is demo-public-guardrails (stub 3) verbatim — spec.md → Out of scope lists
"narrowing what the resulting session may do", and the batch breakdown gives it as the reason
this stub does not ship the demo host on its own. Fixing it here would be scope creep into the
next stub. The binding control is that DEMO_AUTO_SIGN_IN is off unless exactly "1".prune evicted only buckets
already past a full refill window, so a spread of fresh keys grew the map past maxKeys
despite the comment claiming otherwise. Fixed (11c5dca): evict least-recently-used once
over the cap, with a test.x-forwarded-for spoofing (code-review) — accepted, not a defect here. Vercel
overwrites this header at the edge and does not forward external values, so the left-most hop
is the real client. Confirmed against Vercel's documented behaviour during the readiness pass.DEMO_ADMIN_CLERK_USER_ID would hand visitors a session on whatever account it
names. Not fixed — one extra Clerk call on this run's own route, so it is arguably in
scope, but it is hardening against a config error rather than a defect, and the value is set
once by hand. Recommended for intake alongside stub 3.web has password/SSO/trusted-IP protection all disabled. Operator action, not a code fix:
enable Vercel Authentication for Preview before setting them, or set them only for the smoke
and unset after.technical/demo-environment says "no auth
bypass exists or is planned this batch". Ship's docs-sync owns this, in this same PR; it
must not reach main uncorrected. Tracked as an unticked acceptance criterion.Context budget: within the Inputs table. The readiness pass read beyond it by design (that is its job) — permissions defaults, the invite action, the demo-reset service and Vercel project settings.
05_ship/output/changelog.mdThe demo's front door now opens on its own. Where the entry page used to hand you to the ordinary sign-in screen, it now offers Enter the demo — one click takes you into the demo workspace as its administrator, with nothing to type and no account to set up. From there the View As switcher works as it always has, so you can look around as a CSM, a delivery manager, an expert, a vendor or a customer without signing in again.
Entering is always your choice. If you are already signed in to your own workspace, the page tells you that entering the demo will sign you out and lets you decide — nothing changes underneath you, and you can open the demo in a private window instead if you would rather keep both.
The demo workspace itself is unchanged: the same sample customers, experts and projects, and the same nightly refresh back to a clean starting point.
This is switched on per environment, so the demo address opens this way only where it has been enabled.
05_ship/output/investor-update.mdWho it's for: Prospective vendor partners What shipped: On the demo address, one click opens the demo workspace as its administrator — no credentials typed. Why it matters: Refine the bridge / Q2-2026 Objective 1 — Establish Product-Market Fit with Vendor Partners: prospects can now explore without booking a presenter.
Off until switched on per environment.
Dig deeper: https://github.com/sustentus/sustentus/pull/865 · https://help.sustentus.com/changelog/2026-08-19-demo-auto-sign-in
05_ship/output/release.md8e8ad45, settled via ci-status.sh after Verify's last push; re-established on the
head this stage pushes before the mergeapps/docs/app/technical/demo-environment/page.mdx — the "no auth bypass exists
or is planned this batch" claim and the "No auth is bypassed" line both corrected, and a new
Automatic sign-in section added covering the entry control, the mint, the four refusals, the
per-instance rate limit and its trade, the three variables, and the do-not-enable-in-Production
warningapps/docs/archive/pipeline-runs/demo-auto-sign-in/. The
demo-public-access epic is not finished — demo-public-guardrails is still active in
.icm/intake/demo-public-access/, so the epic stays put./demo reaches the demo tenant's admin dashboard with no credentials — code
complete; the signed-in demonstration is the one line Verify left with the operatorauth() resolves the demo organisation and admin role — setActive({ organization }) after
finalize({ navigate }); the ordering bug that would have sent visitors to /no-role was
found and fixed at VerifyCache-Control: no-storeredirect_url honoured only as a same-origin relative path — including the control-character
bypass found and fixed at VerifyPOST mints; every other method 405sturbo.json → globalEnv; lint cleantechnical/demo-environment corrected — this stage, aboveverify.md, not
demonstrated by the agent. Jamie authorised the merge with that line open; it is not claimed
here as done.Two findings from Verify are demo-public-guardrails (stub 3), not defects of this run: the minted
session is a hard-superuser admin that can send Clerk invitations granting permanent organisation
membership, and can flip the AI agent to live. DEMO_AUTO_SIGN_IN must stay off in Production
until that stub ships — now written into technical/demo-environment as well as here.
Suggested for intake alongside it: verify at mint time that the demo admin belongs only to the demo organisation, and revisit the rate limiter's per-instance store once there is real traffic.