Current Limitation
The officer-portal frontend split runtime configuration across two disconnected mechanisms: Vite's import.meta.env (VITE_* vars baked at build time / read from .env in dev) and window.__APP_CONFIG__, populated by a static public/runtime-env.js placeholder that the Go backend overwrote per-request, but only in production. runtimeConfig.ts's getEnv had to fall back between both sources, and this dual-source design had real consequences:
- Local dev and production resolved config completely differently, with no way to verify parity between them.
- The backend never actually served
/runtime-env.js in native dev — each agency's dev config.yaml set web.dir: /nonexistent, which disabled the entire route registration (SPA serving and runtime-env serving together) — so config.yaml's web.runtime section (branding, IdP settings, etc.) sat unused for every dev agency.
- Running multiple agencies in parallel via
start-dev.sh all relied entirely on start-dev.sh exporting 8 separate VITE_* env vars per agency into each pnpm run dev process. That config lived only in a bash script, duplicated from (and able to drift from) each agency's backend config.yaml.
- While investigating a related question (branding), we found
frontend/public/configs/<agency>.branding.json files are gitignored and are never generated anywhere in the actual build/deploy pipeline (CI workflows, root Dockerfile, Helm chart, compose) — so every real agency deployment today silently falls back to generic "default" branding instead of that agency's actual logo/favicon/portal name.
Suggested Improvement
Unify all frontend runtime configuration onto a single backend-served endpoint, /config.js (renamed from /runtime-env.js), sourced from each agency's backend/config/<agency>/config.yaml web.runtime section — the same source of truth in dev and prod alike:
- Backend (
internal/web, cmd/server/main.go): decoupled /config.js serving from SPA-asset serving. web.NewHandler no longer errors when the SPA asset dir is missing — it now always serves /config.js, and only conditionally serves the SPA (Handler.ServesSPA()) when the asset dir actually exists. Config.Validate() now runs unconditionally at startup, so web.runtime must be valid in every deployment, dev included.
- Backend config: populated
web.runtime in all 6 per-agency dev config.yaml files (branding name, API/IdP URLs, client id, expected OU, scopes) — values that previously lived only in start-dev.sh.
- Frontend (
runtimeConfig.ts): dropped the import.meta.env fallback entirely — getEnv now reads only window.__APP_CONFIG__.
- Frontend (
vite.config.ts): the dev server now proxies /config.js to the paired backend instance (target: VITE_API_BASE_URL), so dev reads the exact same live config the backend serves in prod. Running several agencies side by side via start-dev.sh all still gets each the right config, since each dev server process proxies to its own backend instance.
start-dev.sh: simplified — start_frontend now only passes VITE_PORT and VITE_API_BASE_URL (the proxy target) instead of 8 separate VITE_* vars; the CONFIG_* table dropped the now-unused IDP_CLIENT_ID/OU_HANDLE fields.
- Renamed
/runtime-env.js → /config.js throughout (Dockerfile, docs/deployment-openshift.md, backend comments/tests, config.example.yaml).
Verified live end-to-end during development: booted the npqs backend on a scratch port, confirmed /config.js served the correct per-agency payload sourced from config.yaml, then pointed a Vite dev server at it and confirmed the proxied /config.js matched exactly, with index.html's <script src="/config.js"> intact. go build/go vet/go test ./... all pass; frontend typechecks clean on both tsconfig.app.json and tsconfig.node.json.
Not yet implemented — flagged during design discussion as follow-ups:
- Branding content (
frontend/public/configs/*.branding.json) is still a separate, currently-broken static-file mechanism. Folding it directly into web.runtime/config.js was discussed as the fix (reusing the one delivery pipeline that actually works end-to-end in this repo), but not yet built.
- A future "configurable consignment table columns" feature should not go through
config.yaml/config.js if it needs to surface dynamic custom_data fields — those are already defined per-task-config via the artifact-loader system (consignmentFields, consignmentCustomDataSchemaPath), and duplicating that list into config.yaml would create a second, driftable source of truth for what fields exist.
Version
Commit 5f0180a (HEAD of main) — the changes described above are uncommitted working-tree changes on top of this commit.
Additional Context
Changed files (uncommitted): Dockerfile, backend/cmd/server/main.go, backend/config.example.yaml, backend/config/{npqs,fcau,cda,slpa,customs,sltb}/config.yaml, backend/internal/web/{config.go,config_test.go,handler.go}, docs/deployment-openshift.md, frontend/.env.example, frontend/index.html, frontend/public/runtime-env.js → frontend/public/config.js (renamed), frontend/src/runtimeConfig.ts, frontend/vite.config.ts, start-dev.sh.
This issue tracks landing this work as a PR, plus the two follow-ups noted above.
Current Limitation
The officer-portal frontend split runtime configuration across two disconnected mechanisms: Vite's
import.meta.env(VITE_*vars baked at build time / read from.envin dev) andwindow.__APP_CONFIG__, populated by a staticpublic/runtime-env.jsplaceholder that the Go backend overwrote per-request, but only in production.runtimeConfig.ts'sgetEnvhad to fall back between both sources, and this dual-source design had real consequences:/runtime-env.jsin native dev — each agency's devconfig.yamlsetweb.dir: /nonexistent, which disabled the entire route registration (SPA serving and runtime-env serving together) — soconfig.yaml'sweb.runtimesection (branding, IdP settings, etc.) sat unused for every dev agency.start-dev.sh allrelied entirely onstart-dev.shexporting 8 separateVITE_*env vars per agency into eachpnpm run devprocess. That config lived only in a bash script, duplicated from (and able to drift from) each agency's backendconfig.yaml.frontend/public/configs/<agency>.branding.jsonfiles are gitignored and are never generated anywhere in the actual build/deploy pipeline (CI workflows, root Dockerfile, Helm chart, compose) — so every real agency deployment today silently falls back to generic "default" branding instead of that agency's actual logo/favicon/portal name.Suggested Improvement
Unify all frontend runtime configuration onto a single backend-served endpoint,
/config.js(renamed from/runtime-env.js), sourced from each agency'sbackend/config/<agency>/config.yamlweb.runtimesection — the same source of truth in dev and prod alike:internal/web,cmd/server/main.go): decoupled/config.jsserving from SPA-asset serving.web.NewHandlerno longer errors when the SPA asset dir is missing — it now always serves/config.js, and only conditionally serves the SPA (Handler.ServesSPA()) when the asset dir actually exists.Config.Validate()now runs unconditionally at startup, soweb.runtimemust be valid in every deployment, dev included.web.runtimein all 6 per-agency devconfig.yamlfiles (branding name, API/IdP URLs, client id, expected OU, scopes) — values that previously lived only instart-dev.sh.runtimeConfig.ts): dropped theimport.meta.envfallback entirely —getEnvnow reads onlywindow.__APP_CONFIG__.vite.config.ts): the dev server now proxies/config.jsto the paired backend instance (target:VITE_API_BASE_URL), so dev reads the exact same live config the backend serves in prod. Running several agencies side by side viastart-dev.sh allstill gets each the right config, since each dev server process proxies to its own backend instance.start-dev.sh: simplified —start_frontendnow only passesVITE_PORTandVITE_API_BASE_URL(the proxy target) instead of 8 separateVITE_*vars; theCONFIG_*table dropped the now-unusedIDP_CLIENT_ID/OU_HANDLEfields./runtime-env.js→/config.jsthroughout (Dockerfile,docs/deployment-openshift.md, backend comments/tests,config.example.yaml).Verified live end-to-end during development: booted the
npqsbackend on a scratch port, confirmed/config.jsserved the correct per-agency payload sourced fromconfig.yaml, then pointed a Vite dev server at it and confirmed the proxied/config.jsmatched exactly, withindex.html's<script src="/config.js">intact.go build/go vet/go test ./...all pass; frontend typechecks clean on bothtsconfig.app.jsonandtsconfig.node.json.Not yet implemented — flagged during design discussion as follow-ups:
frontend/public/configs/*.branding.json) is still a separate, currently-broken static-file mechanism. Folding it directly intoweb.runtime/config.jswas discussed as the fix (reusing the one delivery pipeline that actually works end-to-end in this repo), but not yet built.config.yaml/config.jsif it needs to surface dynamiccustom_datafields — those are already defined per-task-config via the artifact-loader system (consignmentFields,consignmentCustomDataSchemaPath), and duplicating that list intoconfig.yamlwould create a second, driftable source of truth for what fields exist.Version
Commit
5f0180a(HEAD ofmain) — the changes described above are uncommitted working-tree changes on top of this commit.Additional Context
Changed files (uncommitted):
Dockerfile,backend/cmd/server/main.go,backend/config.example.yaml,backend/config/{npqs,fcau,cda,slpa,customs,sltb}/config.yaml,backend/internal/web/{config.go,config_test.go,handler.go},docs/deployment-openshift.md,frontend/.env.example,frontend/index.html,frontend/public/runtime-env.js→frontend/public/config.js(renamed),frontend/src/runtimeConfig.ts,frontend/vite.config.ts,start-dev.sh.This issue tracks landing this work as a PR, plus the two follow-ups noted above.