OpenAPI single-source-of-truth via Huma (big-bang cutover)
Date: 2026-06-13 Status: Approved direction, plan draft
1. Problem statement
Two hand-maintained API contracts have silently drifted:
- Backend (
qubital-backend, Go 1.24 + Gin): routes registered in a 467-linecmd/api/main.gogod-file; request/response DTOs are Go structs withjson:+validate:tags; ~51 swaggo annotations generate a Swagger 2.0 spec that nothing verifies against the actual routes. - Frontend (
qubital-apps, TS monorepo): a hand-maintainedRequestAction → URLmap (packages/app-api/src/constants.ts) plus per-feature hand-written Zod schemas + types.
Proof of drift already in the codebase (recording):
| Field | Backend (wire truth) | Frontend (Zod) |
|---|---|---|
ParticipantInfo.id | int64 → number | z.string() |
RecordingListItem.initiatedBy | *int64 → number/null | z.string().optional() |
Goal: make the Go backend the single source of truth, generate a versioned contract the
frontend pins to, and dissolve the main.go god-file — without abandoning the custom
appApi registry / Electron-IPC architecture.
2. Decisions (what we settled in discussion)
- Backend contract source = Huma (
danielgtaylor/huma, code-first typed router). The OpenAPI 3.1 spec is derived from handler signatures + struct tags, so it is correct-by-construction — it cannot describe an endpoint or shape the code doesn't use. - Keep Gin as the underlying router via the first-party
humaginadapter. Gin was never the problem; this lets us reuse existing infra middleware (CORS, gzip, recovery, log-context). Router swap (chi/stdlib) explicitly rejected — rewrite cost, zero payoff against our actual pains. - Big-bang cutover. No incremental coexistence of legacy Gin routes and Huma routes.
Everything migrates on a branch and ships together. → all transitional scaffolding is
dropped (no gin-AuthMiddleware+Huma-bridge pair, no
humagin.Unwrapfor auth, no A→B convergence, no per-route coexistence ordering). - Auth = native Huma middleware, API-wide (
api.UseMiddleware). Reads the credential offhuma.Context, branches onctx.Operation().Security, attaches the principal viahuma.WithValue. Public operations declare noSecurityand the middleware self-skips — single API, no group splitting. Existing auth logic (WorkOSTokenValidator,token.Manager,SessionService) is reused; only the plumbing moves from*gin.Contexttohuma.Context. Enabling refactor:token.Managertoday does cookie get/set/delete against*gin.Context; it must first be decoupled onto a transport-neutral surface — reads fromcontext.Context/ request headers (huma.Context.Header(...), cookie params), writesSet-Cookievia a header writer (huma.Context.SetHeader/AppendHeader). Isolated, well-tested PR; it is the single prerequisite that removes any need for a gin→huma bridge. - Gatekeeper chain on protected requests: auth → subscription → rbac. Auth and the
Subscription (Stripe) gate are global
api.UseMiddleware(uniform across all protected ops; both self-skip public ops). RBAC is the only per-operation gatekeeper —Operation.Middlewares, co-located with each route, since the required permission differs per endpoint (this per-op hook is exactly what oapi-codegen cannot do). Guests won't need a subscription — a futureGuestMiddlewarecarve-out, out of scope here. - Errors = RFC 9457 Problem Details (
application/problem+json:type,title,status,instance,errors[]) — adopted with a strictdetail-purge policy:detailis never written by backend devs and is purged before it reaches the client. Internal/free-text context (which may include sensitive data) stays in logs/Sentry only; the client gets the stable, machine-readabletypeURI plustitle/status/instanceand schema-derived validationerrors[]. The frontend distinguishes two same-status errors via thetype(RFC 9457's discriminator — §3.1.1; e.g. two 409s differ bytype, nocodefield needed), never viadetail— the same "cut the detail field" discipline we apply today (the ginHttpError.Causeisjson:"-", so the wrapped cause never leaves the backend).- Devs keep wrapping domain sentinels exactly as today, and handlers simply return the
raw error — they never map it themselves. A central sentinel table
(
error → {status, slug}, whereslugforms thetypeURI) is the single source that fills the wire fields; a central conversion layer (a thinregisterwrapper applied once perroutes.go— the Huma analogue of today'sErrorMiddleware) turns any sentinel a handler returns into ahuma.StatusErrorwith no detail. This layer is required because Huma otherwise treats any non-StatusErrorreturn as a 500; it is not a per-handlererrors.Isswitch nor atoHumaErrorcall inside the handler. - The global
huma.NewErrorWithContextoverride is KEPT, not dropped — it is precisely the single place that stampsinstance, purgesdetail(generic message for 5xx), and captures server errors to Sentry. (Huma writes its own response and never callsc.Error(), so this hook — not a gin middleware — is the only place that can observe Huma errors.) - Dropped: the bespoke
RequestErrorenum body and the ginErrorMiddleware; the middleware's Sentry 5xx-capture role moves into the global hook above. The detailed error/middleware design lives in the companionhuma-integration-design.md(Accepted, 2026-06-15), which supersedes this decision where the two differ.
main.gogod-file dissolved → each feature exposesRegisterRoutes(api, deps), composed explicitly in the composition root. Keep wiring explicit (noinit()self-registration), separate route declaration (routes.go) from DI (module.go), pass shared deps as a struct.- Validation migrates from
go-playground/validatortags to Huma's struct-tag vocabulary; complex cross-field rules move into a HumaResolver. - Frontend contract package
@qubital/api-contract, generated from Huma'sopenapi.yaml: generated TS types (openapi-typescript) + message-free Zod schemas + a derived endpoint map (replacesRequestActionEndpoints). Published to GitHub Packages, semver-versioned, pinned by client repos. It does not generate a fetching client — theappApiregistry + axios/IPC adapters stay. - Versioning / drift detection =
oasdiffdrives semver (breaking → major, additive → minor) via a reviewable Changesets / release-please "Version Packages" PR. Because backend and frontend are separate repos, the pinned version is the drift signal. - Zod localization: schemas carry no display strings; a central Zod error map resolves
{issue.code, path, params}→ i18n key → localized string (same code-keyed pattern as the existinguseRequestError). Message-free generated Zod is therefore preferable. Zod v3setErrorMapworks today;zod/v4(z.config/z.locales) is a later, optional upgrade. - Spec generation: a
cmd/openapicommand dumps the spec at build time (no server/DB). - Skip parity testing (spec-diff vs swaggo, response byte-parity) — req/response shapes are being refactored soon anyway, so investing in parity against the current shapes is wasted.
Rejected alternatives (and why)
- swaggo (status quo): annotations are a third hand-maintained artifact decoupled from both routes and structs → spec can lie; v1 is Swagger 2.0 only; v2 stuck in RC 2+ years.
- Fuego: no
gin.HandlerFuncmiddleware support (fatal for our auth/RBAC); slower maintenance. - oapi-codegen server stubs: per-endpoint middleware unsupported (open gap Aug 2025) → clashes with per-route RBAC. (Models-only mode adds nothing over Huma.)
- orval: no types-only mode. Optic: archived Jan 2026.
3. Architecture (end-state)
qubital-backend (Go)
┌──────────────────────────────────────────────────────────────┐
│ gin.Engine │
│ infra middleware (gin): CORS, gzip, recovery, log-context │
│ │ │
│ └─ humagin → huma.API │
│ UseMiddleware: AuthMiddleware (Security-aware) │
│ per feature: RegisterRoutes(api, deps) │
│ huma.Register(Operation{..., Middlewares:[RBAC]}, h) │
│ handler: func(ctx, *Input) (*Output, error) │
│ serves /openapi.yaml + /docs │
└───────────────┬────────────────────────────────────────────────┘
│ cmd/openapi → api/openapi.yaml (committed)
▼
CI: oasdiff (semver) → codegen → publish @qubital/api-contract (GitHub Packages)
│
▼
qubital-apps (TS) — pins @qubital/api-contract@^x.y.z
┌──────────────────────────────────────────────────────────────┐
│ @qubital/api-contract: types.gen + zod.gen + endpoints.gen │
│ app-api registry → web (axios) adapter / desktop (IPC) adapter│
│ central Zod error map → i18n keys ; Problem Details handler │
└──────────────────────────────────────────────────────────────┘
Unchanged by this migration: services, repositories, httperror (reused at the handler
boundary), WorkOS/LiveKit/RBAC infrastructure, gin infra middleware, the appApi registry and
its axios/IPC adapter split, the desktop IPC factory.
4. Backend migration (qubital-backend)
4.0 Foundations
- Bump Go 1.24 → 1.25 (Huma's current minimum).
- Add deps:
github.com/danielgtaylor/huma/v2+.../adapters/humagin. - Add
cmd/openapi/main.go(spec dump, §4.5). - Decide the Problem Details
typeURI scheme (e.g.https://qubital.app/errors/{slug}). - Error plumbing (per decision #6): extend the existing sentinel map
(
pkg/middleware/errormap.go, todayerror → status) intoerror → {status, code/type, title}; add the central conversion layer — a thin wrapper aroundhuma.Register(the maintainer- endorsed pattern, discussion #663) that maps a handler's raw returned sentinel →huma.StatusErrorwith no detail (required because Huma 500s any non-StatusErrorreturn); install the globalhuma.NewErrorWithContextoverride (stampinstance, purge 5xxdetail, capture 5xx to Sentry).
4.1 Auth middleware (native Huma, Security-aware)
// Shared identifiers — one definition, reused by the security scheme, the register-wrapper,
// this middleware and the token.Manager (no hardcoded string repeated across the codebase).
const (
SchemeCookieAuth = "cookieAuth" // OpenAPI security-scheme key
AccessTokenCookie = "access_token" // HttpOnly cookie name the token.Manager sets/reads
)
type authKey struct{}
func NewAuthMiddleware(api huma.API, deps AuthDeps) func(huma.Context, func(huma.Context)) {
return func(ctx huma.Context, next func(huma.Context)) {
required := false
for _, scheme := range ctx.Operation().Security {
if _, ok := scheme[SchemeCookieAuth]; ok { required = true; break }
}
if !required { // public op (no Security declared) → skip
next(ctx); return
}
// Reuse existing validation logic, reading the credential from huma.Context.
info, err := deps.Validate(ctx.Context(), ctx.Header("Cookie"))
if err != nil {
huma.WriteErr(api, ctx, http.StatusUnauthorized, "", err); return // detail purged; sentinel passed so the hook derives type/code
}
next(huma.WithValue(ctx, authKey{}, info))
}
}
// typed accessor used by handlers (no gin/humagin import in handlers)
func IdentityFromContext(ctx context.Context) (*token.AccessTokenData, error) {
v, ok := ctx.Value(authKey{}).(*token.AccessTokenData)
if !ok { return nil, apperr.ErrUnauthenticated }
return v, nil
}
Security scheme (documents auth in the spec → flows to the client package):
cfg := huma.DefaultConfig("Qubital API", "1.0")
cfg.Components.SecuritySchemes = map[string]*huma.SecurityScheme{
SchemeCookieAuth: {Type: "apiKey", In: "cookie", Name: AccessTokenCookie},
}
4.2 RBAC middleware (operation-level)
func (m Middleware) RequirePermission(p permissions.Permission) func(huma.Context, func(huma.Context)) {
return func(ctx huma.Context, next func(huma.Context)) {
info, _ := IdentityFromContext(ctx.Context())
if !m.rbac.Has(info.Role, p) {
huma.WriteErr(m.api, ctx, http.StatusForbidden, "", apperr.ErrPermissionDenied); return // detail purged; sentinel drives type/code
}
next(ctx)
}
}
4.3 Handler conversion — worked example: recording/list
Models (internal/features/recording/models/types.go) — Huma tags replace validate:
type ListRecordingsInput struct {
Body struct {
Limit int `json:"limit" minimum:"1" maximum:"100"` // was validate:"required,min=1,max=100"
Offset int `json:"offset" minimum:"0"` // was validate:"min=0"
}
}
type ListRecordingsOutput struct {
Body ListRecordingsResponse // existing response struct, unchanged
}
Handler (api/list_handler.go) — Huma handles bind + validate + serialize; the handler
shrinks to auth + service + return:
func (h *ListRecordingsHandler) Handle(ctx context.Context, in *models.ListRecordingsInput) (*models.ListRecordingsOutput, error) {
auth, err := IdentityFromContext(ctx)
if err != nil { return nil, err } // raw error — the central register-wrapper converts it (no per-handler mapping)
result, err := h.service.ListRecordings(ctx, auth.OrgID, auth.UserID, auth.Role, in.Body.Limit, in.Body.Offset)
if err != nil { return nil, err } // ditto — return the raw sentinel; conversion is centralized
return &models.ListRecordingsOutput{Body: *result}, nil
}
Deleted from every handler: ShouldBindJSON, validator.Struct, GetWorkOSTokenInfo
boilerplate, c.JSON, c.Error branches, and any per-handler error mapping — no errors.Is
switch and no toHumaError call inside the handler; handlers return the raw sentinel and the
central register-wrapper does the sentinel → huma.StatusError conversion.
4.4 Per-feature route registration (internal/features/<feature>/module/routes.go)
Two shared helpers (in a small shared package — internal/apihttp — imported by every routes.go)
wrap huma.Register once and carry the two cross-cutting concerns — secure-by-default and central error
conversion — so neither is ever hand-written per route:
// register: protected by default — injects the cookieAuth Security requirement + error conversion,
// and appends any per-op gatekeepers (billing, RBAC) which run after the global auth mw.
func register[I, O any](api huma.API, op huma.Operation, h func(context.Context, *I) (*O, error), guards ...func(huma.Context, func(huma.Context))) {
op.Security = []map[string][]string{{SchemeCookieAuth: {}}} // secure-by-default (authn)
op.Middlewares = append(op.Middlewares, guards...) // per-op guards (RBAC); auth + subscription are separate global mws
huma.Register(api, op, withErrConv(h))
}
// registerPublic: explicit opt-out — no Security → the auth middleware self-skips the op.
func registerPublic[I, O any](api huma.API, op huma.Operation, h func(context.Context, *I) (*O, error)) {
op.Security = nil // public on purpose
huma.Register(api, op, withErrConv(h))
}
// withErrConv turns a handler's raw returned sentinel into a huma.StatusError (detail purged) via
// the central sentinel table — the maintainer-endorsed register-wrapper (decision #6).
func withErrConv[I, O any](h func(context.Context, *I) (*O, error)) func(context.Context, *I) (*O, error) {
return func(ctx context.Context, in *I) (*O, error) {
out, err := h(ctx, in)
if err != nil { return nil, apperr.ToHumaError(err) } // sentinel → huma.StatusError, no detail
return out, nil
}
}
The per-feature routes.go then carries no Security map and no error plumbing — only the
operation shape:
func RegisterRoutes(api huma.API, h *Handlers, mw Middleware) {
register(api, huma.Operation{ // protected by default
OperationID: "list-recordings",
Method: http.MethodPost,
Path: "/recordings/list", // absolute path (see prefix note)
Summary: "List recordings",
Tags: []string{"Recordings"},
}, h.List.Handle)
register(api, huma.Operation{
OperationID: "start-recording",
Method: http.MethodPost,
Path: "/recordings/start",
Tags: []string{"Recordings"},
}, h.Start.Handle,
mw.RequirePermission(permissions.StartRecording)) // per-op RBAC guard (subscription is enforced globally — see composition root)
}
// e.g. auth/module/routes.go — public ops opt out explicitly:
func RegisterRoutes(api huma.API, h *Handlers) {
registerPublic(api, huma.Operation{
OperationID: "sign-in",
Method: http.MethodPost,
Path: "/auth/sign-in",
Tags: []string{"Auth"},
}, h.SignIn.Handle)
}
Convention: keep DI in module.go, route declaration in routes.go; pass shared deps as a
Middleware/RouteDeps struct (no package globals, no init() magic). Use register (protected)
by default; reach for registerPublic only for genuinely unauthenticated ops — so a forgotten
endpoint fails closed, not open.
4.5 Composition root (cmd/api/main.go) — god-file gone
r := gin.New()
r.Use(cors.New(corsCfg), gin.Recovery(), logContextMw, gzipMw)
// Non-Huma endpoints stay plain gin:
r.GET("/health", HealthCheck)
r.GET("/metrics", gin.WrapH(promhttp.HandlerFor(deps.PrometheusRegistry, promhttp.HandlerOpts{})))
// webhooks (signature-verified inside handlers) can stay gin or become public Huma ops
cfg := huma.DefaultConfig("Qubital API", "1.0")
cfg.Components.SecuritySchemes = map[string]*huma.SecurityScheme{ SchemeCookieAuth: {Type:"apiKey", In:"cookie", Name:AccessTokenCookie} }
api := humagin.New(r, cfg)
// Global gatekeepers (both self-skip public ops), in order: auth → subscription. RBAC is per-op (in routes).
api.UseMiddleware(NewAuthMiddleware(api, authDeps)) // authn — injects identity
api.UseMiddleware(NewSubscriptionMiddleware(api, subsDeps)) // requireSubscription — all protected ops (guest carve-out: future)
recordingmodule.RegisterRoutes(api, recordingHandlers, mw)
organizationmodule.RegisterRoutes(api, orgHandlers, mw)
calendarmodule.RegisterRoutes(api, calendarHandlers, mw)
authmodule.RegisterRoutes(api, authHandlers) // public ops use registerPublic → no Security → middleware self-skips
// ... one line per feature ...
4.6 Spec dump (cmd/openapi/main.go)
func main() {
_, api := buildAPI() // same wiring as the server, no ListenAndServe / DB
b, _ := api.OpenAPI().YAML()
os.Stdout.Write(b)
}
go run ./cmd/openapi > api/openapi.yaml # committed; CI regenerates and fails on uncommitted diff
4.7 validate → Huma tag mapping (applied across ~50 request structs)
go-playground validate | Huma struct tag |
|---|---|
required (body field) | non-pointer field is required by default; params use required:"true" |
min=N / max=N (number) | minimum:"N" / maximum:"N" |
min=N / max=N (string/slice len) | minLength:"N" / maxLength:"N" (string), minItems/maxItems (slice) |
email | format:"email" |
oneof=a b c | enum:"a,b,c" |
uuid | format:"uuid" |
| cross-field / conditional | implement Resolve(ctx huma.Context) []error on the Input (Huma Resolver) |
Backend notes / risks
- Absolute paths in
huma.Register+ mount API on the root engine — sidesteps the reportedNewWithGroupgroup-prefix-in-spec bug. - Exact Huma identifiers (
ctx.Operation(),ctx.Header,huma.WriteErr,huma.WithValue,api.OpenAPI().YAML(),Operation.Middlewares) are correct in shape but pin against the installed v2 version at Phase 0. - Module cohesion audit (e.g.
organizationcurrently mixes org CRUD + invitations + room CRUD) is a separate task — don't conflate with this migration.
5. Contract package (@qubital/api-contract)
Generated from api/openapi.yaml in the backend repo's CI. Contents:
types.gen.ts—openapi-typescript(paths+components, zero runtime).zod.gen.ts— message-free Zod schemas (hey-api Zod plugin or equivalent), shape + constraints only.endpoints.gen.ts— endpoint map derived frompathsvia mapped types (replacesRequestActionEndpoints).openapi.yaml— the spec itself, for reference/tooling.
Publishing & versioning (backend CI, on merge to main):
go run ./cmd/openapi > api/openapi.yamloasdiffnew spec vs last published → breaking ⇒ major, additive ⇒ minor- Run the Node codegen → assemble the package
- Bump + publish to GitHub Packages (
@qubital/...scope) via a reviewable Changesets / release-please "Version Packages" PR
Consumption: client repos pin @qubital/api-contract@^x.y.z. A breaking backend change bumps
the major; the pin doesn't move; bumping it surfaces the type errors to fix — the drift signal.
6. Frontend migration (qubital-apps)
- Add the dependency:
@qubital/app-api(and apps) pin@qubital/api-contract. Configure the GitHub Packages registry for the@qubitalscope in.npmrc(pnpm honours//npm.pkg.github.com/:_authToken). - Replace duplication:
- Delete the hand-written
RequestActionEndpointsmap (packages/app-api/src/constants.ts) → use the generated endpoint map. - Replace per-feature hand-written request/response types with generated types.
- Replace per-feature Zod schemas with generated message-free Zod schemas.
- Delete the hand-written
- Error handling → Problem Details:
- Retire the bespoke
RequestErrorenum body and the enum-keyeduseRequestErrormap. parseRequestError(apps/web/src/api/utils.ts) readstype/status/errors[]fromapplication/problem+json.- Message layer maps Problem
type→ i18n key (the same central, code-keyed scheme as Zod).
- Retire the bespoke
- Zod localization (central error map):
- Install/keep schemas message-free; register one global Zod error map
(v3
z.setErrorMap, orzod/v4z.config({customError})later) that maps{issue.code, path, params}→ i18n key (considerzod-i18n-map+ i18next). - The interpolation params (
minimum,maximum,expected, ...) come from the generated constraints — single source: Go tag → OpenAPI → Zod constraint → localized message.
- Install/keep schemas message-free; register one global Zod error map
(v3
- Adapters unchanged: the
appApiregistry and the web-axios / desktop-IPC implementations keep their shape; only the types they're typed against change to the generated ones.
7. Phased execution (develop on a branch, ship once)
- Phase 0 — Foundations (backend): Go 1.25 bump; add huma/humagin;
cmd/openapi; Problem Detailstypescheme; pin exact Huma API names. - Phase 1 — Cross-cutting (backend): the
token.Managertransport-neutral refactor (enabling step, decision #4); native auth middleware (Security-aware) + RBAC operation middleware + security scheme; the sentinel→{status,code,title}table, the central register-wrapper (handlers return raw errors), and the globalhuma.NewErrorWithContexthook (instance + 5xx detail-purge + Sentry). - Phase 2 — Feature conversion (backend, the bulk): per feature — convert handlers to
(ctx, *Input) (*Output, error), add Huma tags (validate→Huma mapping), addroutes.go. - Phase 3 — Composition root (backend): rewrite
main.goto infra-middleware + Huma API +RegisterRoutescalls; delete old route block, the ginErrorMiddleware(its sentinel-conversion role now in the register-wrapper, its Sentry 5xx-capture role in the global Huma hook), and the gin AuthMiddleware on the protected group. - Phase 4 — Contract pipeline (backend CI): codegen + oasdiff + publish
@qubital/api-contract. - Phase 5 — Frontend: pin the package; replace endpoint map / types / Zod; Problem Details error handling; central Zod error map.
- Cutover: single coordinated release of backend + the frontend pin bump.
Parity testing intentionally skipped — request/response shapes are being refactored next, so validating against current shapes would be throwaway work.
8. Open items / deferred decisions
8a. KEY DECISIONS TO DISCUSS WITH THE TEAM
These shape the error/middleware contract and are the ones to settle in review:
- Subscription carve-out (near-term). "Every protected request requires a subscription"
would also block the billing/account-bootstrap ops (you must reach "manage subscription"
without one). Plus guests (future
GuestMiddleware, out of scope). Decide the opt-out mechanism for the global subscription mw — aregisterNoSubvariant or anOperation.Extensionsflag the mw reads (mirrorsregisterPublicfor auth). Not built in this PR. (design §3.6) Operation.Extensionsfor spec-visibility (RBAC/subscription).Operation.Middlewaresguards are enforcement-only and do not appear in the published OpenAPI spec. Do we add documentation-onlyx-required-permission/x-required-planextensions so the contract shows them (costs a second place to keep in sync), or keep enforcement-only? (design §3.6.1)typeURI scheme — pick day-0, it's immutable.https://qubital.app/errors/{slug}(kept non-dereferenced) vsurn:qubital:error:{slug}. RFC 9457: switching later is a breaking change.- Reconcile
humagin.New(root engine, plan/walkthrough) vshumagin.NewWithGroup(design §4 sketch). Walkthrough §9.3 + this plan use root-engine + absolute paths (issue #684 still open); the design §4 wiring sketch still showsNewWithGroup. Align on one.
Decided (research-backed, flagged for awareness — not reopening): error identifier =
type-only (nocodefield;typeis RFC 9457's discriminator) · RBAC =Operation.Middlewares(notMetadata, notSecurityscopes) · gatekeeper chain = auth → subscription → rbac (auth + subscription global, rbac per-op) · registration = secure-by-default (register/registerPublic).
8b. Other deferred items
- Zod v3 →
zod/v4timing (error-map API is nicer in v4, but not on the critical path; v4 is not a drop-in —z.email(),errorvsmessage,.merge()deprecations). - Module cohesion audit (split fat feature modules) — separate task, post-migration.
- Public webhooks / auth routes: decide per-route whether they become public Huma operations
(no
Security) or stay plain gin handlers. - Upcoming req/response refactor — sequence it with or right after this migration so the
contract regenerates against the final shapes (and fix the known
int64/stringdrift then).