Skip to content

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

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 environmentENVIRONMENT (no consumer either; the app code never reads it).

  1. One required variable, VITE_SUPABASE_HOST, resolved at runtime. The build bakes a derivation rule, not an environment. Resolution happens against globalThis.location.host in 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: sb served from learner.loc.emergent-english.comhttps://sb.loc.emergent-english.com

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

  2. Required, with no default. An empty or missing value throws Missing required environment variable: VITE_SUPABASE_HOST at 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 carries sb as its default, because that is a build-pipeline convention where the naming scheme genuinely is uniform; the app itself never assumes it.

  3. 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 unchanged SupabasePublicEnv shape, so every downstream consumer is untouched. readCurrentHost() reads globalThis.location?.host — a Location in a tab, a WorkerLocation in 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.

  4. 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 new apps/admin-console/src/lib/env.ts expose getSupabasePublicEnv / getSupabaseOrigin / getFunctionsBaseUrl (admin also getWorkflowBaseUrl) / isSupabaseConfigured. The nine admin-console and three teacher-portal feature clients that each re-read import.meta.env and 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.

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

  6. The docs sites keep baked canonical hostnames. apps/docs and apps/docs-eng now hard-code https://docs.emergent-english.com and https://docs-eng.emergent-english.com in astro.config.mjs, replacing the *.emergent.example placeholders and the deploy-time swap they implied. starlight-llms-txt emits absolute URLs into llms.txt and 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.

  7. Deletion set. vite-docs-domain / VITE_DOCS_DOMAIN (superseded by decision 6), vite-admin-emails / VITE_ADMIN_EMAILS and environment / ENVIRONMENT (no consumer in the tree — the “actually used” test of ADR-0054 decision 3), and the dead VITE_SYNC_BASE_URL / VITE_WRITE_SERVER_URL lines 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.

  • Runtime config.json fetch (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 -data bundle images served by a plain static web server (ADR-0051’s deployment shape); there is no render step to inject into without adding one.
  • The three frontend -data images 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 to https://sb, which is honest but useless. Local dev therefore uses form 1 or the loc.* proxy front; this is stated in .env.development.local.example and covered by a test.
  • Verification for this class of change is the golden render: helm template on the build chart before and after, with the diff limited to the enumerated parameter/env deltas.
  • ADR-0051 still refers to VITE_DOCS_DOMAIN as 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.