← Corpus / chroma-decks / plan
Install Auth Surface in chroma-decks (from calmstorm pattern, with Session-2 additions baked in)
Chroma-decks is greenfield for auth. This plan installs the full calmstorm-validated auth surface (middleware, sessions, magic-link redemption, /access UI, AuthEvent log) AND the four Session-2 additions from the Shared-Auth exploration (OAuth via arctic, Organization+Membership, lossless_id UUIDv7, app_slug) — all in one go, since there's no legacy schema to migrate. Sequencing was intentionally NOT made into 'chroma-decks is the reference'; calmstorm still gets a parallel decision later. For now: ship auth on chroma; calmstorm's app_slug diff sits unstaged.
- Path
- plans/Install-Auth-Surface-from-Calmstorm-Pattern.md
- Authors
- Michael Staton
- Augmented with
- Claude Code (Opus 4.7, 1M context)
- Tags
- Shared-Auth · Chroma-Decks · Greenfield-Install · Calmstorm-Pattern · Session-2-Additions
Install Auth Surface in chroma-decks (from calmstorm pattern, with Session-2 additions baked in)
Why this is its own plan (not “Session 2” of the parent)
The Shared-Auth exploration’s three-session plan has calmstorm-decks as the validated reference (Session 2) and chroma-decks as the second consumer post-extraction (Session 3). On 2026-05-17 the user redirected: do the work in chroma-decks instead, and explicitly chose option “Just chroma-decks for now — we’ll decide about calmstorm later.”
That means:
- The four Session-2 additions get installed in chroma-decks, not calmstorm.
- They are NOT additions to existing code (chroma has no auth) — they’re baked into the initial schema alongside the existing calmstorm surface. No migrations from a TEXT
orgcolumn; nocs_sessioncookies to preserve. - Calmstorm’s existing auth stays as-is. The
app_slugdiff already produced in calmstorm sits unstaged ondevelopmentuntil the user decides what to do with it. - The “validated reference for package extraction” question is explicitly deferred. This plan does not declare chroma the reference.
Pre-conditions (already verified)
- chroma-decks current state:
output: "static", Vercel adapter, no@astrojs/db, no@libsql/client, no.env(only.env.example). - chroma-decks uses
@dididecks/shellintegration — no conflict with auth (shell adds/toc/,/play/,/data-assets/; auth adds/access/*,/api/access/*). - Brand: see
chroma-decks/DESIGN.md(anddraft-palette__Chroma.json). The chroma-themed/accessUI departs from calmstorm’s cobalt-blue gradient. - Calmstorm reference files live at
../calmstorm-decks/src/{lib/auth,middleware.ts,pages/access,pages/api/access}and../calmstorm-decks/db/config.ts. Port — don’t symlink.
Architectural decisions locked into this plan
output: "server"with NOprerenderon gated routes. Underoutput: "server"on Vercel, prerendered pages are CDN-served as static HTML and the middleware function NEVER runs in production — exactly the trap calmstorm fell into and fixed today (commit 677417f). For a gated deck this means anyone with a URL bypasses the gate. Chroma follows the post-fix calmstorm pattern: every gated route SSRs (noprerender = truedirective); only future genuinely-public surfaces would prerender. The ~50ms per-request SSR cost is acceptable for fundraise-deck traffic.@astrojs/dbover libSQL, embedded for dev + Turso remote for prod. Mirrors calmstorm exactly.- All
@dididecks/shellroutes (/toc/*,/play/*,/data-assets/*) are gated by the middleware. Public prefixes are:/access,/api/access,/_image,/_astro, plus/changelogif a public changelog exists. No other public surfaces. - Cookie name:
cd_session(vs calmstorm’scs_session). Per-app cookie keeps sessions isolated when both sites are open in the same browser. APP_SLUG = "chroma-decks"on every AuthEvent insert. Constant exported fromsrc/lib/auth/types.ts, will become consumer config on package extraction.- No
firm_profileon the Chroma organization row (Chroma is the company, not a VC firm — it’s a portco-style operating company; thefirm_profileextension is nullable per the parent exploration’s “firm == org dual-vocabulary” decision). If a future role needs acompany_profileextension instead, that’s a separate concern. - GitHub OAuth only in Phase 6. Google Workspace deferred — chroma’s audience is broader than a single Google Workspace domain, and the GitHub identity-pool covers the technical-investor audience we’re shipping to first.
- No legacy backfill required. No existing rows; no Identity-with-TEXT-org-column to migrate. The schema lands clean.
Phases (each its own commit)
Phase 1 — Infrastructure: SSR + @astrojs/db + env
astro.config.mjs— addoutput: "server", add the@astrojs/dbintegration import + entry, keep Vercel adapter, keep@dididecks/shell.package.json— add@astrojs/db,@libsql/client. (Don’t addarcticyet — Phase 6.)- Deck-page-level: identify the prerendered routes (most pages under
/src/pages/) and addexport const prerender = truewhere they don’t already have a server-side responsibility. Auth routes getexport const prerender = falseexplicitly. .env.example— addSESSION_SECRET,ADMIN_PASSCODE,VIEWER_PASSCODE,ASTRO_DATABASE_FILE(dev),ASTRO_DB_REMOTE_URL+ASTRO_DB_APP_TOKEN(prod-Turso). Do NOT add OAuth client IDs yet.- Verify:
pnpm install,pnpm exec astro sync,pnpm exec astro checkclean.
Commit message draft: feat(chroma-decks, infra, auth-prep): switch to SSR output and wire @astrojs/db prereqs for auth surface
Phase 2 — Schema with Session-2 additions baked in
db/config.ts defines, in this order (calmstorm tables first, then additions):
Identity— calmstorm columns PLUSlossless_id(text, unique, indexed),primary_email(text, indexed). The legacyemailcolumn from calmstorm is renamedprimary_emailin this fresh install (no backward-compat needed).MintedToken— straight from calmstorm.Session— straight from calmstorm.AuthEvent— calmstorm columns PLUSapp_slug(text, default"chroma-decks").OAuthAccount— NEW:id,identity_id(FK→Identity),provider(githubfor now),provider_subject,provider_email,linked_at,last_used_at.Organization— NEW:id,slug,name,created_at.FirmProfile— NEW:id(= Organization.id),firm_kind,portfolio_path,aum_tier,brand_assets_path. All optional; absence means “not a firm.” Per decision 6, the Chroma Org won’t have one.Membership— NEW:id,identity_id(FK→Identity),organization_id(FK→Organization),role(superuser|org_owner|org_admin|editor|viewer),joined_at,revoked_at.PageView,Action— port from calmstorm (engagement-tracking sibling concern, not auth, but consumers of the same DB).
db/seed.ts — one Organization row for Chroma (slug: "chroma", name: "Chroma"), no FirmProfile. Memberships seeded only as actual users sign in.
Commit message draft: feat(chroma-decks, db, auth-schema): define auth surface with lossless_id, Organization/Membership, OAuthAccount, app_slug
Phase 3 — Port auth helpers (src/lib/auth/)
types.ts— port; changeSESSION_COOKIE = "cd_session", addAPP_SLUG = "chroma-decks".Roleenum extended to the 5-role set (superuser | org_owner | org_admin | editor | viewer) per the parent exploration.session.ts— port unchanged except for cookie-name references (already via theSESSION_COOKIEconstant; should require no changes).token.ts— port unchanged.passcode.ts— port unchanged (matching logic only; env-var reading is viaimport.meta.env).lossless_id.ts— NEW. Two exports:mintLosslessId()returns a fresh UUIDv7;resolveOrCreateIdentity(primary_email)returns the existing Identity row or creates one with a freshlossless_id. Both called from the credential-success paths (verify route’s success branch; link redemption’s success branch; later, OAuth callback).
Commit message draft: feat(chroma-decks, lib/auth): port session/token/passcode helpers from calmstorm; add lossless_id minting
Phase 4 — Middleware
Port src/middleware.ts from calmstorm, with:
PUBLIC_PREFIXESupdated for chroma’s surfaces.- The 2026-05-17 prerender-bypass fix preserved (reference:
dididecks-ai/changelog/2026-05-17_02.md). - DB-unavailable graceful degradation preserved.
- Rolling-refresh on every authenticated request preserved.
Commit message draft: feat(chroma-decks, middleware): port session gate with prerender-aware bypass-fix
Phase 5 — /access UI + /api/access routes
src/pages/access/index.astro— chroma-themed gate UI. Same form, different brand. Cobalt → chroma palette.src/pages/access/link/[token].astro— port; the bot-detection list and OG-preview HTML port unchanged. Brand-themedMetaTagsfor the OG-preview branch.src/pages/api/access/verify.ts— port;APP_SLUGstamped on every AuthEvent insert. Hook intoresolveOrCreateIdentityfrom Phase 3.src/pages/api/access/logout.ts— port;APP_SLUGstamped.src/pages/api/access/debug-cookie.ts— port (dev-only diagnostic).
Commit message draft: feat(chroma-decks, access): port /access UI + /api/access routes; chroma-themed
Phase 6 — OAuth via arctic (GitHub)
- Add
arcticdep. .env.example— addGITHUB_OAUTH_CLIENT_ID,GITHUB_OAUTH_CLIENT_SECRET,OAUTH_GITHUB_REDIRECT_URI.src/pages/access/oauth/github/start.ts— initiate the auth-code flow via arctic; persist state to a short-lived HTTP-only cookie.src/pages/access/oauth/github/callback.ts— validate state, exchange code for token, fetch user identity, upsertOAuthAccount, upsertIdentity(byprovider_email), mintlossless_idif new, createSessionwithtier: "oauth_github", setcd_sessioncookie, 302 to originalredirect./access/index.astro— add a “Continue with GitHub” button alongside the passcode form.
Defer Google Workspace OAuth and “lossless-group org → superuser fast-path” to a follow-up.
Commit message draft: feat(chroma-decks, oauth): add GitHub OAuth via arctic; unified with cd_session issuance
Out of scope for this plan (named so they don’t drift in)
- Google Workspace OAuth (deferred — chroma’s audience is broader than one Workspace domain).
- “lossless-group GitHub org → superuser fast-path” (deferred; lives in the parent exploration’s open-ideas list).
- Magic-link sending domain per-firm.
- Account-merge UI.
- TrackerScript / PageView / Action engagement-tracking surface (calmstorm has it; chroma can copy it later as a sibling concern — NOT auth).
- The
/admin/activity.astropanel (port later if needed; not required for shipping the gate). - Extracting any of this into a shared package. This plan is consumer-local code only.
Risks + open questions
- Brand styling of
/access. The chroma palette isn’t fully specified yet — Phase 5 may need a quickDESIGN.mdread or a small palette decision. Defer authoring until we get there. - Cookie collision with calmstorm. If a developer has both calmstorm and chroma open in localhost on different ports, the
cs_sessionandcd_sessioncookies are scoped per-host so there’s no collision — but cookie path/domain config has to confirm this. Test in dev before merging. - Vercel SSR cold-start. Switching
output: "static"→"server"introduces function cold-starts on cache-miss. For fundraise-deck traffic patterns (low concurrency, returning visitors with sessions) this is fine; flag if it shows up in lighthouse. - The
lossless_idminted in chroma is incompatible with any future merge with calmstorm’s pre-existing identities. If a person uses both decks, we mint twolossless_ids by email match later. Acceptable per the parent exploration’s “lazy re-stitch on email change” stance. - OAuth callback URL discipline. GitHub OAuth needs a registered callback per environment (
localhost, preview, prod). Configure when Phase 6 starts.
Status / next step
Status: Active. Phase 1 begins immediately after this plan lands.
Next step: Execute Phase 1.
Cross-references
[[../../../../../context-v/explorations/Shared-Auth-for-Applied-AI-Labs]]— parent exploration[[../../../../context-v/specs/Calmstorm-Auth-Inventory]]— Session 1 deliverable; file inventory + future-package map../../../calmstorm-decks/src/middleware.tsand siblings — reference implementation we port fromdididecks-ai/changelog/2026-05-17_02.md— the prerender-bypass-middleware fix on the calmstorm side that this plan inherits- Arctic OAuth library — used in Phase 6