Skip to Content

← All archived runs

Run: demo-auto-sign-in

run.md

Run: demo-auto-sign-in

  • branch: claude/pipeline-demo-auto-sign-k9mcqv
  • pr: #865

02_define/output/spec.md

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

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

Problem

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

Proposed change

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:

  • Host — the handler refuses unless the request is a demo request, reusing isDemoRequest from apps/web/lib/demo-host.ts. On the platform host it is not reachable.
  • Kill switch — a new 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.
  • Configuration — the route refuses when 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.jsonglobalEnv, 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.

Acceptance criteria

  • From /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.
  • After entry, 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 sign-in token is minted server-side per visit with a 60-second expiry, is single-use, and appears only in the POST response body — never in a URL, a cookie, localStorage, or a cacheable response; the response carries Cache-Control: no-store.
  • Entry honours 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.
  • Repeated hits from one client IP are refused with 429 past the bucket's allowance, rather than minting a session per request.
  • A visitor holding a session for an account other than the demo admin sees, on /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.
  • A visitor already signed in as the demo admin is sent to their role home from /demo, with no prompt.
  • GET (and every other method) on /api/demo/enter is rejected; only POST mints.
  • The entry API route is reachable unauthenticated on a demo request — proxy.ts lets it through the !userId redirect on the demo branch only, and it stays unreachable on the platform host.
  • With 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.jsonglobalEnv, and pnpm lint is clean of turbo/no-undeclared-env-vars.
  • Unit tests cover the pure helpers: the 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.
  • Verified on the run's Vercel preview using ?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).

Out of scope

  • Signing in as any persona other than admin. The other five are reached through View As, which is the hierarchy the demo exists to show.
  • Narrowing what the resulting session may do — the destructive, billable and outbound-email affordances. That is demo-public-guardrails (stub 3), and it is the reason this run does not ship the demo host to production on its own.
  • Per-visitor isolated tenants or sandboxes; every visitor shares the one demo tenant.
  • A shared (Redis or MongoDB) rate-limit store. The in-memory bucket is the decided trade above; replacing it is a follow-on once there is real traffic to measure.
  • Reset cadence for public traffic, and the demo world itself — storyline content, the seeder, isDemo semantics, the nightly reset's purge/keep policy.
  • The shared session cookie across 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.
  • Marketing-site work: where the demo link is promoted, and any lead capture around it.

Open questions

  • none — the two decisions this stage held open (the rate-limit mechanism; what "Enter the demo" does to a visitor's existing session) were settled in the Define conversation and are written into Proposed change above.

03_build/output/notes.md

Build notes: demo-auto-sign-in

  • commits: 86ad6f8 (feature), 1339e8c (Clerk signals API + lint ceiling)
  • ci: GREEN on 1339e8cQuality Project and all six Vercel previews pass

What changed

  • apps/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.

Acceptance criteria status

  • Visitor with no session reaches the admin dashboard from /demo — one control, no credentials
  • auth() resolves the demo organisation and admin role — setActive({ organization }) is what makes View As and the persona dashboards work
  • Token minted server-side per visit, 60s, single-use, POST body only, Cache-Control: no-store
  • redirect_url honoured only as a same-origin relative path; absolute, // and /\ fall back
  • POST /api/demo/enter refuses on the platform host and mints nothing
  • Refuses when DEMO_AUTO_SIGN_IN is not "1", or either id is unset — before calling Clerk
  • Repeated hits from one IP are refused with 429 (5 per minute, token bucket)
  • A visitor signed in as another account is warned and chooses; nothing swaps until they confirm
  • A visitor already signed in as the demo admin goes to their role home with no prompt
  • Only POST is exported, so Next answers every other method with 405
  • The entry API is reachable unauthenticated on a demo request and unreachable on the platform host
  • With DEMO_HOSTS and DEMO_AUTO_SIGN_IN unset, every route behaves as it does today
  • Three variables declared in turbo.jsonglobalEnv; lint clean (CI green)
  • Unit tests cover the redirect guard, the token bucket, the refusal matrix and the IP reader
  • 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.
  • Verified on the Vercel preview with ?demo=1Verify's DoD smoke, and it needs the three variables set on the Preview environment first (see below)

Notes for Verify

  • The preview smoke needs configuration that is not in the diff. 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.
  • Look hardest at the Clerk flow. It is the one part no unit test covers and it cost a CI round trip: 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.
  • The rate limiter is per-instance by decision, not oversight (settled at Define). It resets on a cold start and a burst spread across instances gets one allowance each. The reasoning — every mint targets the same demo admin user, so abuse costs Clerk calls rather than MAU billing — is in spec.md and in the module comment. Ship records the trade in the docs.
  • This run supersedes a merged criterion. 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.md

Verify: demo-auto-sign-in

  • ci: GREEN on c9064e9 — settled via ci-status.sh after the last push
  • previews smoked: web (https://web-git-claude-pipeline-demo-auto-sign-k9mcqv-sustentus.vercel.app) · demo, docs, help-centre, marketing, storybook all skipped for this diff (no preview built — correct, the diff touches only apps/web and turbo.json)
  • production-readiness: run — 2 blocking-before-announce findings, both out of scope by design (see below); 3 advisories; env/DB/route-policy sections clean
  • code-review: medium (spec complexity standard) — 6 findings, 4 fixed on branch, 2 accepted
  • security-review: run — 1 Medium fixed (open redirect), 1 High recorded as the known stub-3 boundary
  • playwright: TODO — manual DoD smoke performed instead

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

  • Entry route refuses off the demo host — traced: isDemoRequest gate at route.ts:52, and proxy.ts returns early only when demo is true (agent)
  • Kill switch and missing-id refusals return before Clerk is called — traced in 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)
  • Rate limiter allows a burst, refuses past it, refills proportionally, and holds its key cap — unit-covered, 6 cases (agent)
  • Only POST is exported, so every other method 405s — verified by inspection (agent)
  • With the switch off the entry page renders the demo-host-serving placeholder — traced in page.tsx:50-72 (agent)
  • Signed-in half — NOT re-verified after the fixes. Jamie reported "everything is working as expected" at 16:12Z, against 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.
  • auth: demo admin sign-in + dashboard on the preview (operator — pending, as above)
  • payments: not touched — no payment surface in the diff (agent)
  • notifications: none expected — the diff fires no notification and touches no template (agent). Note the session it creates can reach inviteUser, which does send mail; that is Finding 5, not a notification this run emits.

Findings & cleanup

  1. Open redirect via control characters (security-review, Medium) — 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.
  2. 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.
  3. 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.
  4. Stale props after a discarded session (code-review) — if the mint failed after sign-out, the page still told the visitor they were signed in and offered a dead "Continue to my workspace". Fixed (11c5dca).
  5. The demo admin is a hard superuser, and two of its powers escape the demo tenant (production-readiness ❌, security-review High) — 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".
  6. Rate-limit key cap did not bound memory (both reviews) — 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.
  7. 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.
  8. Mint does not verify the demo admin belongs only to the demo org (production-readiness ⚠️) — a mistyped 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.
  9. Setting the three variables on Vercel Preview opens every preview (production-readiness ⚠️) — Preview env vars apply to all preview deployments, branch aliases are deterministic, and 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.
  10. Docs still assert the opposite of what shipstechnical/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.md


title: Step into the demo without a password date: 2026-08-19T09:00:00Z personas: [admin] slug: demo-auto-sign-in pr: https://github.com/sustentus/sustentus/pull/865

Step into the demo without a password

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

The demo now opens itself — no presenter, no password

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

Ship: demo-auto-sign-in

  • pr: #865 (https://github.com/sustentus/sustentus/pull/865) · merge: authorised — Ready to merge ticked by Jamie; this commit rides the squash
  • CI: GREEN on 8e8ad45, settled via ci-status.sh after Verify's last push; re-established on the head this stage pushes before the merge
  • technical docs: apps/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 warning
  • business docs: no business docs impact — the demo world, the personas and the service journey are unchanged; only how a session starts on the demo host
  • release notes: both
  • sent: ship note queued to #product-update on merge (2026-08-19)
  • close-out: archives this run to apps/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.

Acceptance check (vs spec)

  • Entry from /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 operator
  • auth() 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 Verify
  • Token minted server-side, 60s, single-use, POST body only, Cache-Control: no-store
  • redirect_url honoured only as a same-origin relative path — including the control-character bypass found and fixed at Verify
  • Refuses on the platform host; refuses on the kill switch or a missing id, before calling Clerk
  • Rate-limited per client address with a real key cap
  • A visitor holding another account's session is warned and chooses; nothing swaps until they do
  • A visitor already signed in as the demo admin goes straight to their role home
  • Only POST mints; every other method 405s
  • The entry route is reachable unauthenticated on a demo request and unreachable on the platform host
  • With the switch unset, every route behaves as it did before this run
  • Three variables declared in turbo.jsonglobalEnv; lint clean
  • Unit tests cover the redirect guard, the token bucket, the refusal matrix and the IP reader
  • technical/demo-environment corrected — this stage, above
  • Preview smoke of the signed-in half — recorded as outstanding in verify.md, not demonstrated by the agent. Jamie authorised the merge with that line open; it is not claimed here as done.

Carried forward

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.