# Task 1.4 — Runtime config endpoint **Phase:** 1 — Foundation **Status:** ⬜ Not started **Depends on:** 1.2 **Wiki refs:** `docs/wiki/entities/react-spa.md` §Auth pattern; `.planning/ROADMAP.md` design rule 7 ## Goal Establish the runtime-config pattern: a `/config.json` file fetched at app boot, validated with zod, made available via a typed React context. Per-environment values (Directus URL, processor WS URL, Google Maps key, feature flags) live there — not in build-time `import.meta.env`. One built image, many deploy environments. ## Deliverables - `src/config/schema.ts` — zod schema: ```ts export const RuntimeConfigSchema = z.object({ directusUrl: z.string().url(), // public-facing Directus URL (e.g. https://stage.trmtracking.org/api) liveWsUrl: z.string().url(), // public-facing Processor WS URL (wss://stage.trmtracking.org/ws-live) businessWsUrl: z.string().url(), // public-facing Directus WS URL (wss://stage.trmtracking.org/ws-business) googleMapsKey: z.string().optional(), // BYOK; if absent, Google tile sources are hidden in the UI env: z.enum(['dev', 'stage', 'prod']), // for log labels and feature flag conditionals }); export type RuntimeConfig = z.infer; ``` - `src/config/load.ts`: - `async function loadConfig(): Promise` — fetches `/config.json`, parses with zod, throws on validation failure. - On dev, the file is served directly from `public/config.json` by Vite. On stage/prod, the proxy serves it (Traefik file-middleware or a tiny nginx rule). - `src/config/context.tsx`: - `RuntimeConfigProvider` — fetches config in a `useEffect` on mount, holds it in state, passes via context. - `useRuntimeConfig()` hook — throws if used outside the provider, returns the typed config. - While loading, render a minimal "Loading…" placeholder (one centred div, no shadcn dependency). - On error, render an error state with the validation issues — clear enough that a misconfigured `config.json` doesn't masquerade as a generic crash. - `public/config.json` — checked-in file with dev defaults: ```json { "directusUrl": "/api", "liveWsUrl": "/ws-live", "businessWsUrl": "/ws-business", "env": "dev" } ``` Note: relative URLs work because the SPA always runs same-origin (Vite dev proxy or Traefik in stage/prod). Document this in the file's accompanying README. - `src/main.tsx` updated to wrap `` in ``. - `README.md` updated with a "Runtime config" section explaining: where the file lives in dev vs stage/prod, the schema, how to add a new field, the dev defaults. ## Specification ### Why runtime config not build-time Build-time means one bundle per environment. That breaks reproducibility (dev image ≠ stage image ≠ prod image), prevents promoting a known-good build to prod ("ship the stage build to prod after stage soak"), and forces a rebuild for any env-only change (add a feature flag → rebuild → reupload). Runtime config lets one image serve all environments — the proxy or stack overrides the file. The Directus-side runtime config (`DIRECTUS_PUBLIC_URL`, etc.) and Processor-side (`DIRECTUS_BASE_URL`, etc.) follow the same pattern. SPA consistency. ### How the file is overridden in stage/prod Two reasonable options: **Option A: Static file in the SPA's nginx serve config.** The Dockerfile (1.9) bakes a default `config.json` into the image. Override happens at deploy time: a Traefik file-middleware (or a sibling nginx rule) intercepts `/config.json` and serves a different file from a host volume. Requires zero rebuilds. **Option B: Server-rendered config.** Directus could serve `/config.json` via a custom endpoint that reads env vars. Rebuildable through Directus. **Pick Option A.** Simpler, zero new code, deploy-time override is clean. Document the override pattern in `trm/deploy/README.md` (1.10). ### What goes in config vs what doesn't In config: - URLs the SPA calls (Directus, Processor WS, business WS). - Optional API keys the operator brings (Google Maps). - Environment label for log/feature use. NOT in config: - Anything that varies per-user (the user's role, their subscriptions). - Anything operational secret (no JWT signing keys, no DB passwords). - Anything that changes during the session (that's state, not config). ### Failure modes - **`config.json` missing (404):** error state with "Runtime config not found at /config.json. Did the deployment skip its config volume?" Don't fall back to defaults silently — that hides misconfiguration. - **`config.json` invalid (zod fails):** error state listing the validation issues. Useful for catching typos in stage configs. - **Network failure on first load:** retry once after 500ms, then show error. Don't infinite-retry — the user can reload the page if the network heals. ### Why a context provider rather than a Zustand store Auth state needs Zustand (read in many places, granular subscribers). Runtime config is read once and never changes during the session — context is exactly right for that. Don't over-engineer. ## Acceptance criteria - [ ] `pnpm dev` shows a brief "Loading…" then renders the placeholder content. - [ ] Manually editing `public/config.json` to invalid shape and reloading shows the error state with the validation issues, not a blank screen. - [ ] Manually editing `public/config.json` to invalidate URL format (e.g. `directusUrl: "not-a-url"`) shows the same error state. - [ ] `useRuntimeConfig()` is callable from any descendant of `` and returns typed config. - [ ] Calling `useRuntimeConfig()` outside the provider throws a clear error (not "cannot read property X of undefined"). - [ ] The default `public/config.json` validates against the schema. ## Risks / open questions - **Same-origin assumption.** The relative URLs (`/api`, `/ws-live`) only work if everything is same-origin. If a deploy environment ever wants to point the SPA at a different-origin Directus (e.g. for a partner integration), the config schema accepts absolute URLs — but cookie auth breaks at that point. Document the constraint. - **Field projection in zod.** If we ever add a sensitive field (a per-tenant analytics key), it would be visible to anyone hitting `/config.json` directly. Don't put anything sensitive there. The schema's `RuntimeConfigSchema.optional()` discipline helps but doesn't enforce. - **Hot reload of config in dev.** Editing `public/config.json` requires a hard reload (Vite watches the file but the React Provider only fetches on mount). Acceptable for dev. ## Done Three files under `src/config/` plus the dev-defaults JSON: - `schema.ts` — `RuntimeConfigSchema` + inferred `RuntimeConfig` type. Custom `UrlOrAbsolutePath` validator accepts `http(s)://`, `ws(s)://`, or paths starting with `/` (so the dev defaults' relative paths validate while still catching obvious typos). - `load.ts` — `loadConfig()` fetches `/config.json` with `cache: 'no-store'`, `safeParse`s, throws `ConfigValidationError` on schema failure or a generic `Error` on network/non-2xx. - `context.ts` — `RuntimeConfigContext` + `useRuntimeConfig()` hook (split from the provider so the file has no component exports — keeps `react-refresh/only-export-components` happy without an eslint override). - `provider.tsx` — `RuntimeConfigProvider`. Three states (loading / ready / error). Network failure retries once after 500 ms; a second failure surfaces the error to the operator with a clear "reload or check the deployment" message. Validation failures render the zod issues so misconfigured `config.json` is debuggable, not a generic crash. - `public/config.json` — committed dev defaults: `{ directusUrl: '/api', liveWsUrl: '/ws-live', businessWsUrl: '/ws-business', env: 'dev' }`. Vite serves it from `public/` at `/config.json` automatically. `src/main.tsx` updated to wrap `` in ``. README "Runtime config" section added with the schema table + override pattern. **Deviation from spec:** the spec sketched the provider in `src/config/context.tsx` exporting both the provider and the hook. That triggers `react-refresh/only-export-components` because hooks aren't components. Split into `context.ts` (hook + context object, no JSX) and `provider.tsx` (component only). Cleaner than adding an eslint override and matches conventional React patterns. **Smoke check:** `pnpm typecheck`, `pnpm lint`, `pnpm format:check`, `pnpm build` all green. Bundle: 283KB raw / 87KB gzipped (up from 222KB / 70KB after zod entered the runtime path). Could not browser-smoke `useRuntimeConfig` without running `pnpm dev` interactively — flagged as a manual sanity step before 1.5 lands. Landed in `PENDING_SHA`.