Runtime-derived Supabase origin (`VITE_SUPABASE_HOST`)
Status: accepted
Date: 2026-07-29
Relates to: ADR-0051 (two docs sites — the exception that keeps a baked hostname), ADR-0054 (chart
type-level defaults; the explicit-identity discipline applied here), the Argo build path
(infra/charts/argo-workflows/templates/build/argo-build-oci-workflow.yaml), and the frontend env
seam (packages/domain/src/auth.ts, apps/*/src/lib/env.ts).
Context
Section titled “Context”Vite inlines import.meta.env.VITE_* into the emitted bundle at build time. VITE_SUPABASE_URL
carried a complete origin (https://sb.dev.emergent-english.com), so the three frontend -data
images were environment-stamped: promoting a verified preprod build to production was not a
promotion at all, it was a rebuild from the same commit with different env — a different artifact,
re-verified by hope. The one thing a release pipeline must be able to say (“these exact bytes passed
preprod”) could not be said.
Everything else about the two environments is already isomorphic by construction: each is a full
cluster whose apps sit at the same labels under a different suffix — learner.<env>.example,
teacher.<env>.example, sb.<env>.example. The Supabase origin is therefore not independent
information; it is a function of the host the page was served from. The build was baking a
value that the browser could compute.
Three smaller build parameters rode along in the same workflow: vite-docs-domain (the docs sites
resolve their own hostname — see below), vite-admin-emails (no consumer anywhere in the tree), and
environment → ENVIRONMENT (no consumer either; the app code never reads it).
Decisions
Section titled “Decisions”-
One required variable,
VITE_SUPABASE_HOST, resolved at runtime. The build bakes a derivation rule, not an environment. Resolution happens againstglobalThis.location.hostin three mutually exclusive forms, checked in this precedence order:Form Trigger Result 1 contains ://the value verbatim — scheme and port preserved ( http://localhost:8787)2 contains .an entire hostname → https://<value>(sb.emergent-english.com)3 neither a bare label, replacing the first label of the current host: sbserved fromlearner.loc.emergent-english.com→https://sb.loc.emergent-english.comTrailing slashes are trimmed in every form. Form 3 never carries the page’s port and is always
https— a derived origin is a sibling service, not the page’s own listener. Form 1 exists so a dev machine, a preview server, or an e2e harness can pin an origin that no rule could derive. -
Required, with no default. An empty or missing value throws
Missing required environment variable: VITE_SUPABASE_HOSTat first use. A default ("sb"in the app code) would turn a forgotten build argument into a silent misconfiguration that only surfaces as failing network calls in the browser — the exact failure mode ADR-0054 decision 2 removed from the chart by refusing to let identity fall through to a default. The Argo workflow parameter carriessbas its default, because that is a build-pipeline convention where the naming scheme genuinely is uniform; the app itself never assumes it. -
One resolution seam, in
packages/domain.resolveSupabaseUrl(host, currentHost)is a pure exported function (independently tested);readRequiredSupabasePublicEnv({host, anonKey, currentHost})wraps it and returns the unchangedSupabasePublicEnvshape, so every downstream consumer is untouched.readCurrentHost()readsglobalThis.location?.host— aLocationin a tab, aWorkerLocationin the sync SharedWorker (whose script URL is same-origin with the tab that constructed it) — so one read covers both scopes, and returns""off-browser. -
Each app has exactly one env module, and it is the only reader of the variable.
apps/learner-web/src/lib/env.ts,apps/teacher-portal/src/lib/env.ts, and a newapps/admin-console/src/lib/env.tsexposegetSupabasePublicEnv/getSupabaseOrigin/getFunctionsBaseUrl(admin alsogetWorkflowBaseUrl) /isSupabaseConfigured. The nine admin-console and three teacher-portal feature clients that each re-readimport.meta.envand re-derived${url}/functions/v1/...now call these. Scattered resolution was survivable while the input was a literal; with a rule it would be nine copies of the rule. -
The publishable key stays shared across environments that share a build. It is a public routing token: it identifies the project to PostgREST/GoTrue and is exchanged, per cluster, for a real session by that cluster’s own auth service. It grants nothing on its own, is served to every anonymous visitor by definition, and is powerless against a cluster it was not issued for. The secret keys (
SUPABASE_SECRET_KEY, JWT signing material) remain strictly per-cluster and server-side — nothing about this decision moves a secret. Were the publishable key to differ per environment, it alone would re-stamp the artifact and undo decision 1. -
The docs sites keep baked canonical hostnames.
apps/docsandapps/docs-engnow hard-codehttps://docs.emergent-english.comandhttps://docs-eng.emergent-english.cominastro.config.mjs, replacing the*.emergent.exampleplaceholders and the deploy-time swap they implied.starlight-llms-txtemits absolute URLs intollms.txtand the sitemap at build time; those are canonical identifiers, not fetch targets, and cannot be runtime-derived from a static bundle. A non-indexed preprod serving production canonicals is the correct outcome — a canonical link should name the real site, and preprod is not indexed. -
Deletion set.
vite-docs-domain/VITE_DOCS_DOMAIN(superseded by decision 6),vite-admin-emails/VITE_ADMIN_EMAILSandenvironment/ENVIRONMENT(no consumer in the tree — the “actually used” test of ADR-0054 decision 3), and the deadVITE_SYNC_BASE_URL/VITE_WRITE_SERVER_URLlines in.env.development.local.example(zero consumers; the sync and write URLs are derived from the Supabase origin). The two submitter scripts lose the matching arguments.
Alternatives rejected
Section titled “Alternatives rejected”- Runtime
config.jsonfetch (the usual “runtime config for SPAs” answer): an extra network round-trip on the critical boot path, before the sync worker can even be told where to connect, plus a cache-invalidation surface and a new failure mode (200-with-HTML from a misconfigured front). The information needed is one label; the page already knows its own host. - Keep a full-URL variable and accept per-environment builds: this is the status quo, and it is precisely what makes “promote the verified artifact” impossible.
- Default the host to
sb: convenient, and it makes a missing build argument indistinguishable from a correct one until the first request fails in a user’s browser. Fail-fast at the seam beats a plausible guess (decision 2). - Derive from the app’s own subdomain by convention without a variable (always
sb.+ the parent domain): the rule then cannot be escaped where it does not hold — local dev, e2e previews, and any environment fronting Supabase on a different origin. The variable IS the escape hatch; forms 1 and 2 exist for exactly those cases. - Inject the origin server-side into
index.html: the frontends are static-databundle images served by a plain static web server (ADR-0051’s deployment shape); there is no render step to inject into without adding one.
Consequences
Section titled “Consequences”- The three frontend
-dataimages are environment-agnostic: the bytes verified in preprod are the bytes promoted to production. The build pipeline’s per-environment surface shrinks to the git version and the publishable key. - A new environment needs no build change at all, provided it follows the
<app>.<env>.<domain>/sb.<env>.<domain>shape — the same uniformity assumption the cluster already encodes. - The variable is now load-bearing for correctness in a way a wrong value shows up late: form 3
on a single-label host (
localhost) degenerates tohttps://sb, which is honest but useless. Local dev therefore uses form 1 or theloc.*proxy front; this is stated in.env.development.local.exampleand covered by a test. - Verification for this class of change is the golden render:
helm templateon the build chart before and after, with the diff limited to the enumerated parameter/env deltas. - ADR-0051 still refers to
VITE_DOCS_DOMAINas the docs hostname mechanism; decision 6 supersedes that sentence. The ADR text itself is left intact — ADRs record the decision as taken. - oxfmt skips
docs/adr/, so this file’s formatting is hand-maintained.