- Status: done
- Date: 2026-06-03
- Specs touched:
docs/specs/BRAIN_UI_PROTOCOL.md(# API discipline → # Codegen + the locked-decisions bullet: past-tensed the client-side split now that TS-type generation landed; recorded the reproducible server-less emission + freshness gate). NoDECISIONS.md/NEXT.mdchange — the codegen direction was already locked (DECISIONS.md2026-05-15) and the stale NEXT timing item was already removed.
Closes issue #52. The brain↔UI API is malmo's public surface, but the Go server types and the browser client types were maintained by hand on both sides — web-ui/src/api.ts carried ~15 hand-rolled wire interfaces, drifting silently on every brain DTO change. This lands the codegen pipeline the schema was always meant to feed: a reproducible, server-less OpenAPI emitter; a committed spec artifact with a CI freshness gate; and a generated TypeScript client that replaces the hand-rolled interfaces.
Backend — reproducible, server-less spec emission.
internal/api: extractedregisterAll(api huma.API)as the single source of the REST surface, shared by the liveHandler()and a new exportedOpenAPIDocument() *huma.OpenAPIthat registers every route on a throwaway mux against a zero-valueServerand returns the document. No server, no port, no live dependencies —huma.Registeronly reflects the typed request/response structs, so handlers never run. The title/version are shared constants so the live server and the emitted spec describe the same API.cmd/openapi-gen(new): a tiny entrypoint (-o <dir>) that serializes the document toapi/openapi.json(indented, trailing newline) andapi/openapi.yaml. huma marshals with stable field ordering and Go sorts map keys, so the output is byte-stable across runs — which is what makes the freshness check meaningful (verified: two runs diff clean).make openapinow runs the emitter (was:curla running brain into an untracked rootopenapi.json).api/openapi.{json,yaml}are committed (25 paths, 38 schemas).
Backend — freshness gate.
make openapi-checkre-emits to a scratch dir and diffs against the committed copy, failing if stale (mirrorsfmt-check). Wired intomake checkand a new OpenAPI spec freshness step inci-go.yml;api/**added to the Go-CI path filter so a hand-edit to the spec also trips it. Verified both directions (passes fresh, fails on a tampered title).
Frontend — generated TS client.
- Added
openapi-typescript@7.13.0(dev dep, pinned exact).npm run gen:apigeneratesweb-ui/src/generated/openapi.tsfrom../api/openapi.json(committed). web-ui/src/api.ts: the hand-rolled wire interfaces are gone —User,Instance,Notification,Job,InstallPlan, … are now aliases ofcomponents["schemas"][…]under the same export names, so all 34api.*call sites and ~11 importing files are unchanged. The hand-writtenfetchwrapper (ApiErrormapping +/api/v1prefix + 401 handler +waitForJob) stays.make check-webandci-web.ymlregenerate the client and fail if the committed copy is stale (the frontend analogue ofopenapi-check);api/**added to the web-CI path filter.
Supply-chain handling (per the May 2026 npm-compromise caution). openapi-typescript@7.13.0 (Feb 2026) is clean, but its transitive ranges floated to three packages published 2026-05-25 (inside the compromise window): @redocly/openapi-core@1.34.15, @babel/code-frame@7.29.7, @babel/helper-validator-identifier@7.29.7. npm overrides pin them to the latest pre-May releases — @redocly/openapi-core@1.34.14 (exact-pinned subtree), @babel/code-frame@7.29.0, @babel/helper-validator-identifier@7.28.5 — so the entire added closure predates the window (a pre-May release can't be affected by a May compromise). Verified: a full sweep of every added lockfile package shows zero May-2026-or-later publishes; npm audit signatures passes (137 signatures, 45 attestations). All installs ran --ignore-scripts. The overrides rationale is captured in a comment:overrides key in package.json.
BRAIN_UI_PROTOCOL.md# Codegen — "Split. Server-side from day one; client-side deferred." Server-side was already realized via huma; this lands the client-side half (TS types) and makes server-side emission reproducible + committed. Honors the locked nuance that the wrapper was "designed to be swapped foropenapi-fetch" by deferring the client swap while adopting generated types.BRAIN_UI_PROTOCOL.md# CI enforcement — step 1 of the futureoasdiff breakingcheck is "build the brain and writeopenapi.json(amake openapitarget or a tiny Go binary that callsapi.OpenAPI())." That binary now exists (cmd/openapi-gen→api.OpenAPIDocument()); the committed artifact + freshness gate are its prerequisites.oasdiffitself stays future work (out of scope here).DECISIONS.md2026-05-15 (huma + generated OpenAPI) — no change; this realizes it.CLAUDE.md# Go discipline —internal/only; export-on-second-consumer (OpenAPIDocumentjoinsHandleras the second consumer of the route registrations, which is whyregisterAllwas extracted); a CLI's output isfmt, notslog.
openapi-fetchdeferred (deliberate). The issue listsopenapi-fetchas a new dep, but it also mandates "keep the existingApiErrormapping" and "types + tooling only, no behavior changes."openapi-fetch's{data, error}(no-throw) client would require rewriting all 34 call sites' error handling — forbidden by "types + tooling only" — and adding it unused is dead supply-chain surface. So onlyopenapi-typescript(the type generator the wrapper was designed around) was added; the typed-client swap is a clean follow-up.- Filed DTO mismatches (don't-fix-here, per the issue). huma emits several semantically-enum fields as plain
string(scope,severity,status,state,mode, folderscope) because the Go structs don't declare humaenumtags, and nil-able Go slices asT[] | null(permissions.folders/devices,SourceMenu.options, the list-wrapper arrays). The generated types are therefore more accurate than the hand-rolled ones were. Adopted as-is:Scopeis kept as a UI-side literal union (the dashboard controls it and indexesFolderSourcesby it), andInstallDialog.vuenormalizes the three nullable arrays it consumes via acomputed/helper. Follow-up for the Go side: declare humaenums on those fields (so the schema — andoasdiff— capture the closed sets) and consider non-nil slice initialization; not done here to avoid changing the wire contract in a types-only PR. - SSE types stay hand-maintained.
/api/v1/eventsand/api/v1/system/liveare registered raw (they bypass huma for curl-debuggability), so their event payloads aren't in the spec and aren't generated. A typed-SSE story is a separate follow-up. oasdiffbreaking-change CI not wired. This PR lands the spec artifact + a freshness gate (committed spec matches the code); the breaking-change diff (oasdiff breaking) described in # CI enforcement is still future work.
- Backend: emitter determinism (two runs byte-identical, JSON + YAML);
make openapi-checkverified to pass on a fresh tree and fail on a tampered spec;go vet+go test ./internal/api/green (theregisterAllrefactor rippled cleanly);gofmtclean. - Frontend:
npm run gen:apioutput matches the committed client;npm run build(vue-tsc --noEmittypecheck +vite build) green — the type swap +InstallDialognull-guards compile against every consumer;npm ciconfirms lockfile sync. - Supply-chain: full lockfile sweep (zero May-2026+ packages after overrides);
npm audit signaturespasses.
oasdiff breakingCI — the additive-minor breaking-change gate (BRAIN_UI_PROTOCOL.md# CI enforcement) now has its prerequisite (committed spec + emitter); wireoasdiff breaking origin/main HEADon PRs.- Typed
openapi-fetchclient — swap the hand-written wrapper for the generated typed client, migrating call sites offapi.get<T>()to path-typed calls (the deferred half of this issue). - Go-side enum + non-nil-slice tags — declare huma
enums on the string-widened fields and initialize slices, so the schema captures the closed sets and arrays stop being nullable. - Typed SSE — bring the event payloads into a typed surface (separate from the huma REST spec).