Skip to Content

← All archived runs

Run: demo-host-serving

run.md

Run: demo-host-serving

  • branch: claude/pipeline-demo-host-serving-6n3dcc
  • pr: #864

02_define/output/spec.md

Spec: Serve the demo tenant at its own address

  • slug: demo-host-serving
  • personas: Admin
  • touches: apps/web/proxy.ts, apps/web/lib/, apps/web/app/(auth)/, apps/web/next.config.ts, turbo.json, apps/docs/app/technical/{deployment,demo-environment}/
  • complexity: standard

Problem

The demo tenant is only reachable at platform.sustentus.com, behind the ordinary sign-in. There is no address to hand a prospect, nothing that says "this is the demo", and — because the app has no notion of which host it is answering on — nowhere for demo-only behaviour to hang. That missing front door is what blocks the presenter-free demo the batch is building toward, and with it the vendor-partner conversations that Refine the bridge / Q2-2026 Objective 1 — Establish Product-Market Fit with Vendor Partners depends on: today every prospect walkthrough costs a scheduled call and a human driver.

This is stub 1 of 3 in demo-public-access. It deliberately stops at the front door: the entry page is a placeholder, so the host is provably live and the demo-only branch provably isolated before demo-auto-sign-in puts a real session behind it.

Proposed change

Serve the existing apps/web deployment at a second domain, demo.sustentus.com, on the same Vercel project, and teach the app which host it is answering on.

Host resolution is configuration, not a literal. A new apps/web/lib/ module owns the whole question "is this a demo request?":

  • DEMO_HOSTS — a comma-separated allowlist of exact hostnames (production: demo.sustentus.com), read from the environment and declared in turbo.jsonglobalEnv. Unset or empty means no host is a demo host, and every behaviour below is exactly today's.
  • A non-production override, so a preview deployment can exercise the same branch without a domain: on a deployment where VERCEL_ENV is not "production", ?demo=1 marks the session as a demo request via a cookie (and ?demo=0 clears it). The override is hard-off in production — there, allowlist membership is the only thing that makes a request a demo request.

On a demo request, an unauthenticated visitor to a protected path is redirected to a new demo entry page at /demo instead of /sign-in, carrying the attempted path as redirect_url so demo-auto-sign-in can later land the visitor where they aimed. The entry page is a placeholder: a short introduction to the demo and a link to the ordinary sign-in. Signed in, /demo redirects to the user's role home, mirroring how app/page.tsx already behaves.

On the platform host nothing changes. /demo is not public there — an unauthenticated visitor is sent to /sign-in like any other protected path, which is what keeps the demo branch provably isolated to the demo host. The demo branch sits entirely in front of the !userId check in apps/web/proxy.ts, so once a session exists both hosts run the identical path: role resolution, route policies, View As, the expert-onboarding gate and the first-run onboarding gate are untouched.

Demo-host responses carry X-Robots-Tag: noindex, nofollow, declared in apps/web/next.config.ts via a host-matched headers() rule rather than in the proxy, so it covers every response without perturbing the proxy's control flow.

Three setup actions are the operator's, not the code's, and are written into the docs at Ship: adding the domain to the apps/web Vercel project plus its DNS record, setting DEMO_HOSTS in the Production environment, and confirming the production Clerk instance's allowed-origins list (if it is restricted) includes the new host. Clerk needs no further configuration — session cookies are shared across subdomains of one root domain, and satellite domains apply only to different root domains.

Acceptance criteria

  • On a demo request, an unauthenticated request to a protected path (e.g. /admin) redirects to /demo, not /sign-in, preserving the attempted path and query as redirect_url.
  • /demo renders to an unauthenticated demo-host visitor: an introduction to the demo and a working link to /sign-in.
  • Signed in, /demo redirects to the signed-in user's role home.
  • On the platform host, an unauthenticated /demo redirects to /sign-in (with redirect_url=/demo), and every other unauthenticated path redirects to /sign-in exactly as it does today — no demo entry page is reachable, and the sign-in flow is untouched.
  • A signed-in session on the demo host is routed identically to one on the platform host: role resolution, route policies, View As, the expert-onboarding gate and the first-run onboarding gate all behave the same, because no demo branch runs after the !userId check.
  • Responses on the demo host carry X-Robots-Tag: noindex, nofollow; platform-host responses do not.
  • The override is inert in production: with VERCEL_ENV set to "production", ?demo=1 and the override cookie have no effect — only DEMO_HOSTS membership makes a request a demo request.
  • With DEMO_HOSTS unset or empty, every route behaves exactly as it does today on every host.
  • DEMO_HOSTS is declared in turbo.jsonglobalEnv, and pnpm lint is clean of turbo/no-undeclared-env-vars.
  • Unit tests cover the host-resolution helper: allowlist hit and miss, multi-entry and empty/unset DEMO_HOSTS, override honoured off production, override ignored on production.
  • Verified on the run's Vercel preview using ?demo=1: the demo branch is exercised end to end (redirect → entry page → sign-in link) and the same preview without the override behaves as the platform host does.

Operator steps, outside the diff, recorded in the docs at Ship and required before the production host is live: add demo.sustentus.com to the apps/web Vercel project and its DNS record; set DEMO_HOSTS=demo.sustentus.com in the Production environment; confirm the production Clerk instance's allowed-origins list includes the new host.

Out of scope

  • Any automatic sign-in — demo-auto-sign-in (stub 2). This feature's entry page is a placeholder by design.
  • Any narrowing of what a demo session may do — demo-public-guardrails (stub 3).
  • A separate Vercel project, a demo on a different root domain, or Clerk satellite domains.
  • The demo world itself — storyline content, the seeder, isDemo semantics, the nightly reset's purge/keep policy — and the reset cadence for public traffic.
  • Per-visitor isolated tenants; every visitor shares the one demo tenant.
  • Marketing-site work: where the demo link is promoted, and any lead capture around it.
  • The stub's "absent from the marketing sitemap" criterion is dropped as vacuous — the repo has no sitemap.* or robots.* in any app (verified across apps/web and apps/marketing), so there is nothing listing the demo host. X-Robots-Tag above is what actually delivers the intent; this run introduces no sitemap or robots artefact.
  • Because the session cookie is shared across subdomains of sustentus.com, a session started on either host is visible on the other. Recorded, not solved here — demo-auto-sign-in owns it.
  • Adding the missing apps/web (and apps/demo) rows to the per-app Vercel table in technical/deployment, which predates this run.

Open questions

  • none — the two decisions this stage held open (how a preview exercises the branch; what platform.sustentus.com/demo does) were settled in the Define conversation and are written into Proposed change above.

03_build/output/notes.md

Build notes: demo-host-serving

  • commits: feat: demo-host-serving — serve the demo tenant at its own host
  • ci: GREEN on a1f182c — every blocking check and all six Vercel previews passed. (The first ci-status.sh call returned PENDING on its 900s window with three previews still building; nothing had failed, and the re-run settled GREEN.)

What changed

  • apps/web/lib/demo-host.ts (new): the whole "is this a demo request?" question in one module — the DEMO_HOSTS allowlist, the non-production ?demo=1 override, the entry route constant. Pure and env-driven, so next.config.ts, proxy.ts and the page all answer the question the same way and the tests can drive it directly.
  • apps/web/proxy.ts: three additions, all in front of the !userId check so nothing downstream can behave differently on the two hosts — (1) ?demo=1/demo=0 sets or clears the override cookie and redirects to the same URL without the param, so the flag lives in one cookie rather than every link; (2) the demo entry path returns early only on a demo request, which is what keeps the page off the platform host; (3) the unauthenticated redirect targets the entry page on a demo request and /sign-in everywhere else, carrying redirect_url identically in both cases.
  • apps/web/app/(auth)/demo/page.tsx (new): the placeholder entry page — introduces the demo, links to /sign-in, and redirects a signed-in visitor to their role home the way app/page.tsx already does.
  • apps/web/next.config.ts: X-Robots-Tag: noindex, nofollow on demo-host responses, via a host-matched headers() rule. Chosen over a proxy branch because it covers every response, including the paths the proxy matcher skips, and leaves the proxy's control flow alone. A cookie-matched twin covers the preview door, and is emitted only off production so a forged cookie can never put the header on a platform-host response.
  • turbo.json: DEMO_HOSTS and VERCEL_ENV declared in globalEnv — turbo runs in strict env mode, so an undeclared read is stripped from the build and absent from the cache key, and turbo/no-undeclared-env-vars fails lint.
  • apps/web/lib/demo-host.test.ts (new): unit tests written from the acceptance criteria.

Acceptance criteria status

  • Unauthenticated protected path on a demo request → /demo, not /sign-in, with redirect_urlproxy.ts !userId branch.
  • /demo renders unauthenticated on the demo host, with a link to /sign-in — the demo-only early return plus the new page.
  • Signed in, /demo → role home — the page's own auth() check.
  • Platform host unchanged: /demo/sign-in?redirect_url=/demo, every other path exactly as before. /demo is deliberately not in isPublicRoute and has no route-policies.ts entry — deny-by-default is what makes it unreachable there, and that absence is the mechanism, not an oversight.
  • A signed-in session routes identically on both hosts — the entire demo branch sits in front of the !userId check; nothing after it reads the host.
  • X-Robots-Tag: noindex, nofollow on the demo host only — next.config.ts.
  • Override inert in production — readDemoOverride and isDemoRequest both gate on isDemoOverrideAllowed(); covered by tests.
  • DEMO_HOSTS unset or empty → today's behaviour on every host — parseDemoHosts returns [] and isDemoHost is false for every host; covered by tests.
  • DEMO_HOSTS declared in turbo.jsonglobalEnv.
  • Unit tests cover allowlist hit/miss, multi-entry, empty/unset, and the override on and off production.
  • Preview smoke with ?demo=1 — Verify's to run on the deploy preview; it is an operator check, not something Build can assert.

Notes for Verify

  • Smoke path on the preview: open …vercel.app/admin?demo=1 → expect a redirect to /demo?redirect_url=%2Fadmin, the entry page, and its sign-in link working. Then …/admin?demo=0 → expect the ordinary /sign-in redirect, i.e. the same preview behaving as the platform host. curl -I the preview with the cookie set to confirm X-Robots-Tag.
  • The one thing to look at closely: next.config.ts now imports ./lib/demo-host. Next compiles the config and supports local TS imports, but it is the only place in this repo that does it — if the Vercel build trips, that import is the first suspect and the fallback is to inline the parse there.
  • The session cookie is shared across subdomains of sustentus.com, so a session started on either host is visible on the other. Known and recorded in the spec; demo-auto-sign-in owns it. Not a defect in this run.
  • The production host is not live until the operator steps in the spec are done (Vercel domain + DNS, DEMO_HOSTS in Production, Clerk allowed origins). Those are written into the docs at Ship.

04_verify/output/verify.md

Verify: demo-host-serving

  • ci: GREEN — settled via ci-status.sh after the last push of this stage (SHA recorded in the Ship commit, which is the next push on this branch)
  • previews smoked: web (https://web-git-claude-pipeline-demo-host-serving-6n3dcc-sustentus.vercel.app) — all six projects built for the build head; the demo/docs/help/marketing/storybook previews are irrelevant to this diff, which touches apps/web only
  • production-readiness: run (env vars in diff) — one finding, recorded below
  • code-review: medium (spec complexity: standard) — one finding, fixed on branch
  • security-review: run (auth / route-policy surface in diff) — no HIGH or MEDIUM findings
  • playwright: TODO — manual DoD smoke performed instead

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

Unauthenticated criteria were demonstrated against the live preview with curl, using the ?demo=1 override that stands in for the not-yet-existing demo domain.

  • Demo request → entry page, not sign-in: GET /admin with the demo cookie → 307 location: /demo?redirect_url=%2Fadmin (agent)
  • Entry page renders unauthenticated with a working sign-in link: GET /demo with the cookie → 200, body contains "The Sustentus demo" and href="/sign-in" (agent)
  • Platform host unchanged: GET /admin with no cookie → 307 location: /sign-in?redirect_url=%2Fadmin, byte-identical to today's behaviour (agent)
  • Entry page unreachable off the demo branch: GET /demo with no cookie → 307 location: /sign-in?redirect_url=%2Fdemo (agent)
  • X-Robots-Tag: noindex, nofollow on a demo response, and NOT on a non-demo one — the differential is clean because Vercel's own preview header is the bare value noindex, so noindex, nofollow can only be this feature's rule (agent)
  • The off-switch works: GET /admin?demo=0 → the cookie is cleared with a 1970 expiry (agent)
  • Override inert in production — unit tests only; it cannot be demonstrated on a preview, because a preview is by definition not VERCEL_ENV=production. readDemoOverride and isDemoRequest both gate on isDemoOverrideAllowed() (agent, by test + code reading)
  • auth: signed-in sign-in + dashboard on the preview — not demonstrated. No operator pass was run for this stage. The argument that it is unaffected is structural, not empirical: the entire demo branch sits in front of the !userId check, and nothing after that check reads the host. Recorded as not verified rather than assumed.
  • payments: not touched by this diff
  • notifications: none expected — this diff sends nothing

Findings & cleanup

  • DEMO_HOSTS is read at build time, so setting it in Vercel requires a redeploy. next.config.ts evaluates headers() when the app is built, which bakes the host-matched noindex rule into the routing manifest; the middleware's read is inlined by the same build. Setting the variable in the Vercel dashboard and waiting will therefore do nothing — the operator must set it and then redeploy. This is the one way the rollout can look broken while everything is correct, and it belongs in the runbook at Ship. Not a code change.
  • Test cleanup wrote the string "undefined" into process.envafterEach restored the saved values by assignment, and assigning undefined to process.env.X coerces to "undefined", leaking a bogus value into every later test in the same worker. Fixed on branch: restore now deletes the key when it was unset to begin with.
  • Carry-forward for demo-auto-sign-in, not a defect here: redirect_url is built from nextUrl.pathname + search, so a request to //evil.com yields a protocol-relative value. This is pre-existing behaviour of the /sign-in branch and unchanged by this run — but demo-auto-sign-in will be the first code to actually consume redirect_url, and must validate it as a same-origin path before redirecting.
  • The session cookie is shared across subdomains of sustentus.com, so a session started on either host is visible on the other. Known, recorded in the spec, owned by demo-auto-sign-in.
  • The production host is not live until the operator adds the Vercel domain + DNS, sets DEMO_HOSTS, redeploys, and confirms Clerk's allowed origins. With DEMO_HOSTS unset the feature is inert and every host behaves exactly as it does today — the rollout fails safe.

05_ship/output/investor-update.md

The demo tenant now has its own front door

Who it's for: Admin, and every prospect given the demo What shipped: The demo tenant now answers at demo.sustentus.com — an unauthenticated visitor meets a public entry page, not sign-in. Why it matters: Refine the bridge — an address to hand a prospect, before the auto sign-in drops the presenter.

Live once DNS and DEMO_HOSTS are set.

Dig deeper: https://github.com/sustentus/sustentus/pull/864

05_ship/output/release.md

Ship: demo-host-serving

  • pr: #864 · https://github.com/sustentus/sustentus/pull/864 · merge: authorised — Ready to merge ticked; this commit rides the squash
  • CI: GREEN, settled via ci-status.sh on the head this commit is pushed onto (recorded in the conversation; the merge itself is refused on anything but a settled green)
  • technical docs: technical/demo-environment (the two hosts, the ?demo=1 preview override, and a "Standing the demo host up" runbook including the build-time read of DEMO_HOSTS) · technical/deployment (custom domains now names the second web domain and the redeploy caveat)
  • business docs: no business docs impact — no persona gains or loses a capability; the demo world, its personas and its journey are unchanged
  • release notes: ship-note-only — recorded "no end-user note" deliberately. The entry page is a placeholder and demo.sustentus.com does not resolve until the operator adds DNS, sets DEMO_HOSTS and redeploys, so a help-centre changelog entry announcing the address would be false on the day it published. demo-auto-sign-in (stub 2) is the run that makes this visible to a user, and is where the end-user note belongs.
  • sent: ship note queued to #product-update by the merge (ship-note.yaml)
  • close-out: archives .icm/runs/demo-host-serving/ to apps/docs/archive/pipeline-runs/. The demo-public-access epic is not finished — demo-auto-sign-in and demo-public-guardrails are still in intake — so the epic folder stays put.

Acceptance check (vs spec)

  • Unauthenticated protected path on a demo request → /demo with redirect_url — demonstrated on the preview: GET /admin + demo cookie → 307 /demo?redirect_url=%2Fadmin
  • Entry page renders unauthenticated with a working sign-in link — 200, body carries "The Sustentus demo" and href="/sign-in"
  • Signed in, /demo → role home — the page's own auth() check (code-read; no operator pass)
  • Platform host unchanged — GET /admin with no cookie → 307 /sign-in?redirect_url=%2Fadmin; GET /demo with no cookie → 307 /sign-in?redirect_url=%2Fdemo
  • Signed-in session routes identically on both hosts — structural: the whole demo branch sits in front of the !userId check (code-read; not demonstrated by an operator)
  • X-Robots-Tag: noindex, nofollow on the demo host only — demonstrated, and distinguishable from Vercel's own bare noindex preview header
  • Override inert in production — unit tests; undemonstrable on a preview by definition
  • DEMO_HOSTS unset or empty → today's behaviour everywhere — unit tests
  • DEMO_HOSTS declared in turbo.jsonglobalEnv — lint green in CI
  • Unit tests cover the host-resolution helper — apps/web/lib/demo-host.test.ts, run by the Quality workflow
  • Preview smoke with ?demo=1 end to end — performed in Verify, including the ?demo=0 off-switch clearing the cookie