Reflect the migration: AUTH_SPOKE_ORIGINS and AUTH_SWAP_MAX_AGE retired (replaced by TrustedSpokeDomain + createTrustedDomainRegistry; post-login guard reads CIVITAI_OWNED_DOMAINS). Phase 0, inventory, and Related-consolidations updated; corrected now-removed sync.ts references. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
7.7 KiB
Post-deploy: consolidate the domain / origin env vars
Status: planned follow-up (do after the auth-hub cutover deploy has stabilized).
Goal: collapse the several overlapping ways the app answers "what is my origin?" down to one
source of truth, and retire NEXT_PUBLIC_BASE_URL.
Why
The app is deployed once but served on multiple hosts — the color/domain system
(green/blue/red, each with a primary + aliases; see
multi-host-domain-aliases.md). On a multi-host deploy there is no
single canonical URL — the right origin is per-request (which host did this request arrive on?).
NEXT_PUBLIC_BASE_URL is a single static value, so every outbound URL built from it is wrong on
every host except the canonical one. It's also a NEXT_PUBLIC_ var (baked into the client bundle at
build time), so one build serving N hosts can't carry a correct value for all of them.
Exhibit A (already fixed): src/pages/api/auth/sync.ts built the cross-domain login callback from
NEXT_PUBLIC_BASE_URL, so test-auth.civitai.red emitted a callback to pr-2468.civitaic.com → the
hub 400'd it → redirect loop. The fix routed it through the color primary instead. That class of bug
recurs anywhere NEXT_PUBLIC_BASE_URL is used for an outbound URL (~27 files today).
Source of truth
The color/domain map (SERVER_DOMAIN_<COLOR> + _ALIASES) is the one place that knows which hosts
this deploy serves and their canonical primaries. Everything origin-shaped should derive from it:
- Server →
getBaseUrl(getRequestDomainColor(req))— the primary for the host the request came in on. Both helpers already exist:getRequestDomainColor(req)— src/server/utils/server-domain.tsgetBaseUrl(color)/serverDomainPrimaryMap— src/server/utils/url-helpers.ts
- Client →
window.location.origin— the runtime host, always correct, needs no env var.
Inventory (what to keep / retire)
| Input | Role | Action |
|---|---|---|
SERVER_DOMAIN_<COLOR> (+ _ALIASES) |
per-color host map | keep — the source of truth |
NEXT_PUBLIC_BASE_URL |
single static base URL | retire — redundant + wrong on multi-host |
getBaseUrl(color) / getRequestDomainColor |
per-request origin | keep — the canonical resolver |
NEXTAUTH_URL |
legacy hub/base URL | retire after cutover (sunset with legacy cookies) |
NEXTAUTH_COOKIE_DOMAIN |
legacy cookie domain | retire after legacy cookies age out |
AUTH_JWT_ISSUER |
hub origin / JWT iss |
keep (distinct concern — the hub identity) |
AUTH_COOKIE_DOMAIN |
session cookie Domain |
keep, but now defaulted (see below) |
AUTH_SPOKE_ORIGINS |
(was) hub's cross-site spoke allowlist | RETIRED — replaced by the TrustedSpokeDomain DB table (see below) |
AUTH_SWAP_MAX_AGE |
(was) swap-token TTL | RETIRED — the swap-token bridge was deleted; first-party login now rides the OAuth auth-code flow |
AUTH_JWT_AUDIENCE, AUTH_SESSION_COOKIE |
(never wired) | RETIRED — drop from any deploy env: the hub emits no aud, and the cookie name is the hardcoded civ-token constant |
Migration plan
Phase 0 — done
- Cross-domain login no longer touches
NEXT_PUBLIC_BASE_URL:sync.tswas removed with the swap-token bridge, and its replacement — the spoke/api/auth/callback— derives this spoke's origin from the ACTUAL request host (resolveSelfOrigin), which the hub validates againstTrustedSpokeDomain. AUTH_SPOKE_ORIGINSretired →TrustedSpokeDomainDB table +createTrustedDomainRegistry(see Related consolidations).- Hub cookie
Domainis defaulted via a singlecookieDomain()helper (apps/auth/src/lib/server/auth/cookie.ts):AUTH_COOKIE_DOMAINoverride, else.civitai.comon HTTPS, else host-only on localhost. Used bysetSessionCookie,clearSession, and the device cookie.
Phase 1 — one server-side resolver
- Add
getRequestBaseUrl(req) = getBaseUrl(getRequestDomainColor(req))(server util). - Point the server
NEXT_PUBLIC_BASE_URLsites at it (~8 files undersrc/pages/api+src/server). - Collapse
/login's hand-rolledx-forwarded-hostparsing (src/pages/login/index.tsx) into the same helper.
Phase 2 — client + request-less contexts
- Client sites (~9 in
src/components) →window.location.origin(or a relative URL). Watch SSR: a client component rendered server-side has nowindow— prefer relative URLs, or thread the host through props/context. - Request-less server code (OG images, email templates, sitemap, webhooks, cron) has no request to
resolve a color from. Give these an explicit canonical:
getBaseUrl(<defaultColor>)or a single newCANONICAL_URLvar. Decide the default color per deploy (the "primary color" of the box).
Phase 3 — delete the var
- Remove
NEXT_PUBLIC_BASE_URLfrom src/env/client-schema.ts and every.env/ deploy manifest.
Gotchas (the hard 20%)
getBaseUrl(color)hardcodeshttps://<primary>. For localhost multi-color dev that yieldshttps://localhost:<port>— fine for the cookie/issuer checks (hostname-only), but if you build a redirect target from it locally, gate the protocol (localhost → http) assync.ts's helper does.- Behind the edge proxy, the real host is in
x-forwarded-host(comma-joined) — resolvex-forwarded-host ?? hostand take the first segment before feedinggetRequestDomainColor. - An unrecognized inbound Host must not become an outbound origin (open-redirect / cookie-scope
risk). The resolver only maps a configured primary/alias; unknown hosts fall back to the configured
default — never the raw
Host. Preserve that invariant in every migrated site.
Related consolidations (same theme, optional)
AUTH_SPOKE_ORIGINS— RETIRED (done, 2026-06-22). The hand-kept env allowlist was replaced by theTrustedSpokeDomainDB table (migration20260622180000_add_trusted_spoke_domain), managed in-app via the hub/adminUI and read through a ~60s in-memory cache (createTrustedDomainRegistryin@civitai/auth). The hub resolves a first-party client by the request's redirect_uri origin against that registry (exact host, or any subdomain whenincludeSubdomains— e.g. thecivitaic.compreview wildcard for ephemeral PR hosts), so enabling a login host is now one DB row, not an env edit. This fixes the old F5 silent-per-color-login failure at the root (no parallel list to drift). The companion post-login redirect guardisCivitaiOriginreadsCIVITAI_OWNED_DOMAINSin@civitai/auth(the family eTLD+1s:civitai.com,civitai.red,civitaic.com) — keep that list aligned with the registry's owned domains, since the two gates answer different questions (registrable-domain ownership vs. per-host authorization) but must both recognize every family domain.AUTH_COOKIE_DOMAINnow has a sane default; a fuller version could derive the parent domain from the hub'sORIGINregistrable domain rather than the hardcoded.civitai.com.
Acceptance criteria
grep -r NEXT_PUBLIC_BASE_URL src→ 0 (outside this doc).pnpm run typecheck+ unit suite green.- Multi-host smoke: on each alias host, outbound URLs (sync callback, OG image,
<link rel=canonical>, referral/share links) point at that host's color primary — never the canonical-only value. - Rollback is trivial: re-add the env var + revert the per-site changes; no data/migration involved.