Skip to main content

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-line cmd/api/main.go god-file; request/response DTOs are Go structs with json: + 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-maintained RequestAction → URL map (packages/app-api/src/constants.ts) plus per-feature hand-written Zod schemas + types.

Proof of drift already in the codebase (recording):

FieldBackend (wire truth)Frontend (Zod)
ParticipantInfo.idint64 → numberz.string()
RecordingListItem.initiatedBy*int64 → number/nullz.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)

  1. 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.
  2. Keep Gin as the underlying router via the first-party humagin adapter. 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.
  3. 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.Unwrap for auth, no A→B convergence, no per-route coexistence ordering).
  4. Auth = native Huma middleware, API-wide (api.UseMiddleware). Reads the credential off huma.Context, branches on ctx.Operation().Security, attaches the principal via huma.WithValue. Public operations declare no Security and the middleware self-skips — single API, no group splitting. Existing auth logic (WorkOSTokenValidator, token.Manager, SessionService) is reused; only the plumbing moves from *gin.Context to huma.Context. Enabling refactor: token.Manager today does cookie get/set/delete against *gin.Context; it must first be decoupled onto a transport-neutral surface — reads from context.Context / request headers (huma.Context.Header(...), cookie params), writes Set-Cookie via 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.
  5. 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 future GuestMiddleware carve-out, out of scope here.
  6. Errors = RFC 9457 Problem Details (application/problem+json: type, title, status, instance, errors[]) — adopted with a strict detail-purge policy:
    • detail is 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-readable type URI plus title/status/instance and schema-derived validation errors[]. The frontend distinguishes two same-status errors via the type (RFC 9457's discriminator — §3.1.1; e.g. two 409s differ by type, no code field needed), never via detail — the same "cut the detail field" discipline we apply today (the gin HttpError.Cause is json:"-", 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}, where slug forms the type URI) is the single source that fills the wire fields; a central conversion layer (a thin register wrapper applied once per routes.go — the Huma analogue of today's ErrorMiddleware) turns any sentinel a handler returns into a huma.StatusError with no detail. This layer is required because Huma otherwise treats any non-StatusError return as a 500; it is not a per-handler errors.Is switch nor a toHumaError call inside the handler.
    • The global huma.NewErrorWithContext override is KEPT, not dropped — it is precisely the single place that stamps instance, purges detail (generic message for 5xx), and captures server errors to Sentry. (Huma writes its own response and never calls c.Error(), so this hook — not a gin middleware — is the only place that can observe Huma errors.)
    • Dropped: the bespoke RequestError enum body and the gin ErrorMiddleware; the middleware's Sentry 5xx-capture role moves into the global hook above. The detailed error/middleware design lives in the companion huma-integration-design.md (Accepted, 2026-06-15), which supersedes this decision where the two differ.
  7. main.go god-file dissolved → each feature exposes RegisterRoutes(api, deps), composed explicitly in the composition root. Keep wiring explicit (no init() self-registration), separate route declaration (routes.go) from DI (module.go), pass shared deps as a struct.
  8. Validation migrates from go-playground/validator tags to Huma's struct-tag vocabulary; complex cross-field rules move into a Huma Resolver.
  9. Frontend contract package @qubital/api-contract, generated from Huma's openapi.yaml: generated TS types (openapi-typescript) + message-free Zod schemas + a derived endpoint map (replaces RequestActionEndpoints). Published to GitHub Packages, semver-versioned, pinned by client repos. It does not generate a fetching client — the appApi registry + axios/IPC adapters stay.
  10. Versioning / drift detection = oasdiff drives 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.
  11. 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 existing useRequestError). Message-free generated Zod is therefore preferable. Zod v3 setErrorMap works today; zod/v4 (z.config/z.locales) is a later, optional upgrade.
  12. Spec generation: a cmd/openapi command dumps the spec at build time (no server/DB).
  13. 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.HandlerFunc middleware 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 type URI scheme (e.g. https://qubital.app/errors/{slug}).
  • Error plumbing (per decision #6): extend the existing sentinel map (pkg/middleware/errormap.go, today error → status) into error → {status, code/type, title}; add the central conversion layer — a thin wrapper around huma.Register (the maintainer- endorsed pattern, discussion #663) that maps a handler's raw returned sentinel → huma.StatusError with no detail (required because Huma 500s any non-StatusError return); install the global huma.NewErrorWithContext override (stamp instance, purge 5xx detail, 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 validateHuma 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)
emailformat:"email"
oneof=a b cenum:"a,b,c"
uuidformat:"uuid"
cross-field / conditionalimplement 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 reported NewWithGroup group-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. organization currently 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.tsopenapi-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 from paths via mapped types (replaces RequestActionEndpoints).
  • openapi.yaml — the spec itself, for reference/tooling.

Publishing & versioning (backend CI, on merge to main):

  1. go run ./cmd/openapi > api/openapi.yaml
  2. oasdiff new spec vs last published → breaking ⇒ major, additive ⇒ minor
  3. Run the Node codegen → assemble the package
  4. 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)

  1. Add the dependency: @qubital/app-api (and apps) pin @qubital/api-contract. Configure the GitHub Packages registry for the @qubital scope in .npmrc (pnpm honours //npm.pkg.github.com/:_authToken).
  2. Replace duplication:
    • Delete the hand-written RequestActionEndpoints map (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.
  3. Error handling → Problem Details:
    • Retire the bespoke RequestError enum body and the enum-keyed useRequestError map.
    • parseRequestError (apps/web/src/api/utils.ts) reads type / status / errors[] from application/problem+json.
    • Message layer maps Problem type → i18n key (the same central, code-keyed scheme as Zod).
  4. Zod localization (central error map):
    • Install/keep schemas message-free; register one global Zod error map (v3 z.setErrorMap, or zod/v4 z.config({customError}) later) that maps {issue.code, path, params} → i18n key (consider zod-i18n-map + i18next).
    • The interpolation params (minimum, maximum, expected, ...) come from the generated constraints — single source: Go tag → OpenAPI → Zod constraint → localized message.
  5. Adapters unchanged: the appApi registry 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 Details type scheme; pin exact Huma API names.
  • Phase 1 — Cross-cutting (backend): the token.Manager transport-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 global huma.NewErrorWithContext hook (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), add routes.go.
  • Phase 3 — Composition root (backend): rewrite main.go to infra-middleware + Huma API + RegisterRoutes calls; delete old route block, the gin ErrorMiddleware (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 — a registerNoSub variant or an Operation.Extensions flag the mw reads (mirrors registerPublic for auth). Not built in this PR. (design §3.6)
  • Operation.Extensions for spec-visibility (RBAC/subscription). Operation.Middlewares guards are enforcement-only and do not appear in the published OpenAPI spec. Do we add documentation-only x-required-permission / x-required-plan extensions so the contract shows them (costs a second place to keep in sync), or keep enforcement-only? (design §3.6.1)
  • type URI scheme — pick day-0, it's immutable. https://qubital.app/errors/{slug} (kept non-dereferenced) vs urn:qubital:error:{slug}. RFC 9457: switching later is a breaking change.
  • Reconcile humagin.New (root engine, plan/walkthrough) vs humagin.NewWithGroup (design §4 sketch). Walkthrough §9.3 + this plan use root-engine + absolute paths (issue #684 still open); the design §4 wiring sketch still shows NewWithGroup. Align on one.

Decided (research-backed, flagged for awareness — not reopening): error identifier = type-only (no code field; type is RFC 9457's discriminator) · RBAC = Operation.Middlewares (not Metadata, not Security scopes) · gatekeeper chain = auth → subscription → rbac (auth + subscription global, rbac per-op) · registration = secure-by-default (register/registerPublic).

8b. Other deferred items

  • Zod v3 → zod/v4 timing (error-map API is nicer in v4, but not on the critical path; v4 is not a drop-in — z.email(), error vs message, .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/string drift then).