Huma Flow Walkthrough — the Go backend after the refactor
Status: Worked example (companion to huma-integration-design.md)
Date: 2026-06-16
Audience: backend devs about to write/review the migration PRs.
This is the concrete demo the design doc promised: one real request traced through every
hop — transport middleware → gatekeeper middleware → handler → service → error chain → the
RFC 9457 wire body. Every snippet is the after shape, paired with the before it replaces
(grounded in the current pkg/middleware/*.go, pkg/error, internal/apperr, and
internal/features/room/api/auth_handler.go).
The Huma API surface used here was validated against Huma v2.38.0 (2026-05). Where this walkthrough refines the two design docs, it's called out in §9 Adaptations.
The example operation is the current POST /api/v1/livekit/room-auth
(room.RoomHandler.Handle), which the contract redesign renames to
POST /realtime/conference-token (op get-conference-token). Same handler logic, new
shape — perfect for showing before/after without inventing anything.
1. The request we're tracing
POST /realtime/conference-token
Cookie: access_token=<jwt>
Content-Type: application/json
{ "username": "ada", "spaceId": "5f3c…" }
Two paths through the same wiring:
- Happy path →
200 { "token": "<livekit-jwt>" } - Sad path → caller's org doesn't own the space → service returns
…: %w apperr.ErrPermissionDenied→403 application/problem+jsonwith a purgeddetail.
Both are walked end-to-end in §7–§8.
2. The layer cake (after)
HTTP request
│
┌────────────▼─────────────────────────────────────────────┐
│ TRANSPORT (gin, router-level, OUTERMOST — unchanged) │
│ sentrygin → panic recovery + per-request hub │
│ LogContext → trace_id/request_id onto request.Context() │
│ DefaultLogger → access log │
└────────────┬─────────────────────────────────────────────┘
│ humagin adapter — ctx == c.Request.Context()
┌────────────▼─────────────────────────────────────────────┐
│ GATEKEEPERS — chain on protected requests: │
│ auth (global) → validate cookie → WithValue(id) │
│ subscription (global) → require active subscription │
│ rbac (per-op Middlewares) → permission per endpoint │
└────────────┬─────────────────────────────────────────────┘
│
┌────────────▼─────────────────────────────────────────────┐
│ HANDLER func(ctx, *Input) (*Output, error) │
│ Huma already bound+validated Input (struct tags) │
│ IdentityFromContext(ctx) → service call → return Output │
│ on failure: `return nil, err` (raw sentinel) │
└────────────┬─────────────────────────────────────────────┘
│ err
┌────────────▼─────────────────────────────────────────────┐
│ ERROR CHAIN (the heart of the demo — §6) │
│ register-wrapper → apperr.ToHumaError(err) │
│ sentinel table → huma.StatusError(code,no detail) │
│ huma.NewErrorWithContext hook → stamp instance, │
│ purge 5xx detail, Sentry-capture 5xx │
└────────────┬─────────────────────────────────────────────┘
│
application/problem+json (RFC 9457)
Rule of thumb that decides the layer: does it need huma.Context (identity, operation
metadata, typed short-circuit)? → gatekeeper (Huma). Does it only need the raw request and must
wrap everything (panic recovery, hub lifecycle, access log)? → transport (gin, outermost).
3. Composition root (cmd/api/main.go) — the god-file dissolved
The current main.go registers ~50 routes inline with per-route
middleware.RequirePermission(...) (see lines 234–266 today). After:
// --- TRANSPORT: gin, router-level, outermost, PERMANENT ---
r := gin.New()
base := r.Group("/")
base.Use(
sentrygin.New(sentrygin.Options{Repanic: true}), // outermost: recovers panics, owns the hub
middleware.LogContextMiddleware(), // trace_id/request_id → request.Context()
middleware.DefaultLogger(), // access log
)
// Non-Huma endpoints stay plain gin (health, metrics, signature-verified webhooks)
r.GET("/health", HealthCheck)
r.POST("/webhook/reconcile-recordings", deps.RecordingApiHandler.Reconcile.Handle)
// --- HUMA on the root engine (absolute paths — see §9.3 for why NOT NewWithGroup) ---
cfg := huma.DefaultConfig("Qubital API", "1.0")
cfg.Components.SecuritySchemes = map[string]*huma.SecurityScheme{
SchemeCookieAuth: {Type: "apiKey", In: "cookie", Name: AccessTokenCookie}, // shared consts (§6.2)
}
api := humagin.New(r, cfg)
// --- GATEKEEPERS: chain on protected requests = auth → subscription → rbac ---
// Auth + Subscription are GLOBAL (uniform across all protected ops; both self-skip public ops).
api.UseMiddleware(authmw.New(api, authDeps)) // 1. authn — injects identity via huma.WithValue
api.UseMiddleware(subscriptionmw.New(api, deps.Subs)) // 2. requireSubscription — all protected ops
// 3. RBAC is PER-OPERATION (Operation.Middlewares), attached by the register wrapper next to each
// route (the permission differs per endpoint). It runs after both globals. See §5.2-§5.3 / design §3.6.1.
// (Guests won't need a subscription → future GuestMiddleware carve-out, out of scope here.)
// --- ERROR HOOK: RFC 9457 polish + Sentry 5xx capture (installed once) ---
apperr.InstallHumaErrorHook(api)
// --- ROUTES: one line per feature, DI lives in each module ---
realtimemodule.RegisterRoutes(api, deps.RealtimeHandlers)
recordingmodule.RegisterRoutes(api, deps.RecordingHandlers, deps.RBAC)
organizationmodule.RegisterRoutes(api, deps.OrganizationHandlers, deps.RBAC)
// …
What changed vs today: the inline protected := protected.Group(...) block with per-route
RequirePermission is gone; auth is no longer a gin middleware on a group; route declaration
moves into each feature's routes.go.
4. Transport middleware — unchanged, and why
These stay exactly as the current pkg/middleware gin handlers. They are not rewritten as
Huma middleware:
| Middleware | Why it stays gin/transport |
|---|---|
sentrygin | Must be outermost to recover panics in everything below (including Huma handlers, which run in the same goroutine) and to own the per-request hub lifecycle. Rewriting it loses both. |
LogContextMiddleware | Writes trace_id/request_id via c.Request = c.Request.WithContext(...). Because humagin's ctx == c.Request.Context(), this flows into Huma handlers for free — logger.*Ctx(ctx, …) just works. |
DefaultLogger | Access log needs only the raw request; no huma.Context value-add. |
Verified: humagin's
ginCtx.Context()returnsc.orig.Request.Context(). So request-context values cross into Huma handlers; ginc.Set()Keys do not — which is exactly why identity moves tohuma.WithValue(§5.1).
5. Gatekeeper middleware — gin → Huma
5.1 Auth — pkg/middleware/auth.go → internal/features/.../authmw
Before (today, abridged from auth.go — 200 lines, talks to *gin.Context):
func AuthMiddleware(v token.WorkOSTokenValidator, tm *token.Manager, …) gin.HandlerFunc {
return func(c *gin.Context) {
accessToken, err := tm.SessionAccessToken.Get(c) // reads *gin.Context
if err != nil { _ = c.Error(httperror.NewStatusUnauthorizedError(…)); c.Abort(); return }
// … validate / refresh / license …
c.Set(WorkOSTokenInfoContextKey, accessTokenData) // gin Keys — invisible to huma
c.Next()
}
}
// handlers later: middleware.GetWorkOSTokenInfo(c) // reads gin Keys
After — a Huma middleware. The auth logic (validate, WorkOS refresh, license, cookie
rotation) is reused verbatim; only the plumbing moves off *gin.Context. This is gated by the
one enabling refactor: token.Manager must read from context.Context/headers and write
Set-Cookie via a header writer (design doc §3.2).
package authmw
type ctxKey struct{ name string }
var identityKey = ctxKey{"workos-token-info"}
func New(api huma.API, d Deps) func(huma.Context, func(huma.Context)) {
return func(ctx huma.Context, next func(huma.Context)) {
// public ops declare no Security → middleware self-skips, single API, no group split
if !requiresAuth(ctx.Operation()) {
next(ctx)
return
}
tok, err := d.Authenticate(ctx) // transport-neutral: cookie read, validate, refresh, license, rotate
if err != nil {
// detail purged; the sentinel drives the `type` via the global hook (§6.3)
_ = huma.WriteErr(api, ctx, http.StatusUnauthorized, "", apperr.ErrUnauthenticated)
return // do NOT call next → chain halts
}
next(huma.WithValue(ctx, identityKey, tok)) // identity flows via context.Context — no bridge
}
}
func requiresAuth(op *huma.Operation) bool {
for _, scheme := range op.Security { // Operation.Security: []map[string][]string
if _, ok := scheme[SchemeCookieAuth]; ok {
return true
}
}
return false
}
The typed accessor handlers use (no gin import anywhere downstream):
func IdentityFromContext(ctx context.Context) (*token.AccessTokenData, error) {
t, ok := ctx.Value(identityKey).(*token.AccessTokenData)
if !ok {
return nil, apperr.ErrUnauthenticated // → 401 via the error chain
}
return t, nil
}
This replaces middleware.GetWorkOSTokenInfo(c) (auth.go:205), which read gin Keys and
returned an httperror.HttpError.
5.2 RBAC — pkg/middleware/permission.go → internal/features/.../rbacmw
Before (today — wired per route in main.go, e.g. RequirePermission(deps.RBAC, permissions.StartRecording)):
func RequirePermission(authorizer Authorizer, requiredPermission string) gin.HandlerFunc {
return func(c *gin.Context) {
tokenInfo, err := GetWorkOSTokenInfo(c) // gin Keys
if err != nil { _ = c.Error(err); c.Abort(); return }
if !authorizer.Can(tokenInfo.Role, requiredPermission) {
_ = c.Error(httperror.NewStatusForbiddenError(httperror.ErrorConfig{}))
c.Abort(); return
}
c.Next()
}
}
After — a per-operation Huma guard. RequirePermission returns a middleware attached to the
op via Operation.Middlewares (the register wrapper appends it after the global auth mw). Not a
global middleware, and not Metadata — see §9.2 / design §3.6.1 for why:
package rbacmw
func RequirePermission(api huma.API, rbac Authorizer, perm permissions.Permission) func(huma.Context, func(huma.Context)) {
return func(ctx huma.Context, next func(huma.Context)) {
tok, _ := authmw.IdentityFromContext(ctx.Context())
if tok == nil || !rbac.Can(tok.Role, perm) {
_ = huma.WriteErr(api, ctx, http.StatusForbidden, "", apperr.ErrPermissionDenied)
return // halt
}
next(ctx)
}
}
Ordering: the global middlewares (auth, then subscription) run before any per-op
Operation.Middlewares, so identity is present when RBAC runs. Confirm global-before-per-op order against the pinned Huma version.
5.3 Subscription (Stripe) — a global middleware
The subscription gate is uniform: every protected request requires the caller's org to have an
active subscription. So it's a global api.UseMiddleware, registered after auth (needs the
injected identity) and before RBAC. It self-skips public ops exactly like auth (no cookieAuth in
Operation.Security → next), and short-circuits with 402 Payment Required:
package subscriptionmw
func New(api huma.API, subs SubscriptionChecker) func(huma.Context, func(huma.Context)) {
return func(ctx huma.Context, next func(huma.Context)) {
if !requiresAuth(ctx.Operation()) { next(ctx); return } // public op → skip (same gate as auth)
tok, _ := authmw.IdentityFromContext(ctx.Context())
if tok == nil || !subs.HasActive(ctx.Context(), tok.OrgID) {
_ = huma.WriteErr(api, ctx, http.StatusPaymentRequired, "", apperr.ErrQuotaExceeded)
return
}
next(ctx)
}
}
Carve-out (open, mostly out of scope here). Some protected ops must NOT require a subscription: guests (future
GuestMiddleware) and likely the billing/account bootstrap ops (you must reach "manage subscription" without a subscription). Mechanism to settle with the team — a per-op opt-out the global mw reads (mirroringregisterPublicfor auth, e.g. aregisterNoSubvariant or anOperation.Extensionsflag). Not built in this PR.
6. The error chain — the centerpiece
Four pieces. Devs touch only the first (wrap a sentinel) — everything else is central and written once.
6.0 How devs raise errors — unchanged from today
// repository
return nil, fmt.Errorf("space %s not found for org %s: %w", spaceID, orgID, apperr.ErrNotFound)
// service (re-wrap at the trust boundary)
return "", fmt.Errorf("space does not belong to caller's org: %w", apperr.ErrPermissionDenied)
The wrapped free text is for logs/Sentry. It is never put on the wire (the same discipline
as today's httperror.HttpError.Cause being json:"-").
6.1 The sentinel table — extend, don't replace
Today pkg/middleware/errormap.go maps error → int (status only). Extend it to carry the
stable slug that forms the type URI, and move it next to the sentinels (internal/apperr) so
it feeds the new conversion layer:
// internal/apperr/table.go (extends today's domainToHTTPCode)
var domainTable = map[error]struct {
Status int
Slug string // forms the type URI ("…/errors/" + Slug); the frontend switches on `type`
}{
ErrInvalidInput: {400, "invalid-input"},
ErrUnauthenticated: {401, "unauthenticated"},
ErrQuotaExceeded: {402, "quota-exceeded"},
ErrPermissionDenied: {403, "permission-denied"},
ErrNotFound: {404, "not-found"},
ErrTimeout: {408, "timeout"},
ErrConflict: {409, "conflict"},
ErrAlreadyExists: {409, "already-exists"},
ErrExpired: {410, "expired"},
ErrContentTooLarge: {413, "content-too-large"},
ErrValidationFailed: {422, "validation-failed"},
ErrFailedDependency: {424, "failed-dependency"},
ErrRateLimited: {429, "rate-limited"},
}
title is not stored — it's derived centrally via http.StatusText(status) (matches the
existing mapDomainError which already sets Message: http.StatusText(code)).
6.2 ToHumaError + the secure-by-default register-wrappers
Huma treats any non-StatusError return from a handler as a 500. So a raw
apperr.ErrPermissionDenied returned from a handler would 500 unless converted. We convert in
one place — a thin wrapper around huma.Register (the maintainer-endorsed pattern,
discussion #663) — so handlers stay free of errors.Is switches. The same wrapper also injects
the auth Security requirement, making registration secure-by-default: a route is protected
unless it opts out via registerPublic.
// internal/apperr/huma.go
// No custom error struct: huma.ErrorModel is already RFC 9457 (Type/Title/Status/Detail/Instance/
// Errors[]) and already implements huma.StatusError. `type` is the discriminator, so there is NO
// `code` extension (a code slug would just duplicate the type URI's last segment).
// ToHumaError walks the sentinel table and builds an RFC 9457 error with NO detail.
func ToHumaError(err error) huma.StatusError {
for sentinel, m := range domainTable {
if errors.Is(err, sentinel) {
return &huma.ErrorModel{
Type: "https://qubital.app/errors/" + m.Slug, // the discriminator the FE switches on
Title: http.StatusText(m.Status),
Status: m.Status,
// Detail intentionally empty — purged by policy
}
}
}
// Unmatched → 500; cause is logged/captured by the hook, never echoed.
return huma.Error500InternalServerError("")
}
// internal/apihttp/register.go — the shared registration seam, imported by every routes.go.
// Holds the identifiers reused by the scheme (§3), the auth middleware (§5.1) and the token.Manager —
// one definition, no hardcoded strings scattered around.
const (
SchemeCookieAuth = "cookieAuth" // OpenAPI security-scheme key
AccessTokenCookie = "access_token" // HttpOnly cookie name the token.Manager sets/reads
)
// 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); subscription is a separate global mw
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 funnels every handler's raw returned sentinel through apperr.ToHumaError.
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) // raw sentinel → huma.StatusError (type-only, no detail)
}
return out, nil
}
}
This replaces the gin ErrorMiddleware's domain-sentinel branch (error.go:55–64 +
mapDomainError) and the hand-written per-route Security map (now injected by register).
Note: errors Huma raises itself — request-body validation (422), unmatched-route — never pass
through ToHumaError; they're already StatusErrors and go straight to the hook below.
6.3 The global hook — huma.NewErrorWithContext
Runs for every Huma error (native 422 validation and the ones ToHumaError builds). One
place to stamp instance, enforce the 5xx detail-purge, and capture 5xx to Sentry — necessary
because Huma writes its own response and never calls c.Error(), so the gin ErrorMiddleware's
Sentry capture (error.go:71) can't see Huma errors.
// internal/apperr/hook.go
func InstallHumaErrorHook(_ huma.API) {
prev := huma.NewErrorWithContext
huma.NewErrorWithContext = func(ctx huma.Context, status int, msg string, errs ...error) huma.StatusError {
if status >= 500 {
if hub := sentry.GetHubFromContext(ctx.Context()); hub != nil { // std-context hub works in Huma
hub.CaptureException(fmt.Errorf("huma %d: %s", status, msg))
}
msg = "" // purge: never echo internal 5xx detail
}
se := prev(ctx, status, msg, errs...)
if em, ok := se.(*huma.ErrorModel); ok {
em.Instance = ctx.Operation().Path // stamp which occurrence (or trace id)
}
return se
}
}
sentry.GetHubFromContext(ctx.Context())(std-context variant) — notsentrygin.GetHubFromContext(c)— because inside Huma we only have the request context. sentrygin put the hub there too, so this resolves.
7. Happy path — traced
| Hop | What happens |
|---|---|
| sentrygin / logctx / logger | hub attached; trace_id onto request.Context(); access log opens |
| humagin | ctx := c.Request.Context() — carries trace_id + hub |
| auth mw | requiresAuth(op) true (cookieAuth in Security) → Authenticate validates cookie → next(huma.WithValue(ctx, identityKey, tok)) |
| rbac | no RequirePermission guard on get-conference-token (any authed user may mint their own token) → not gated |
| Huma bind/validate | binds body to ConferenceTokenInput, validates tags (minLength etc.) |
| handler | IdentityFromContext(ctx) → service.GenerateToken(...) → &Output{Body: …} |
| serialize | 200 application/json { "token": "<jwt>" } |
Handler, before → after:
// BEFORE — internal/features/room/api/auth_handler.go (gin)
func (h *RoomHandler) Handle(c *gin.Context) {
req, err := middleware.BindAndValidate[models.RoomJoinRequest](c, h.validator)
if err != nil { return } // gin error attached inside helper
accessTokenData, err := middleware.GetWorkOSTokenInfo(c) // gin Keys
if err != nil { _ = c.Error(err); return }
livekitToken, err := h.service.GenerateToken(c.Request.Context(),
req.Username, req.RoomID, accessTokenData.UserID, accessTokenData.OrgID, accessTokenData.Email)
if err != nil { _ = c.Error(err); return }
c.JSON(http.StatusOK, gin.H{"token": livekitToken})
}
// AFTER — internal/features/realtime/api/conference_token_handler.go (huma)
func (h *Handler) ConferenceToken(ctx context.Context, in *ConferenceTokenInput) (*ConferenceTokenOutput, error) {
id, err := authmw.IdentityFromContext(ctx)
if err != nil {
return nil, err // raw — register-wrapper converts (no per-handler mapping)
}
tok, err := h.service.GenerateToken(ctx, in.Body.Username, in.Body.SpaceID, id.UserID, id.OrgID, id.Email)
if err != nil {
return nil, err // ditto — return the raw sentinel
}
return &ConferenceTokenOutput{Body: ConferenceTokenBody{Token: tok}}, nil
}
Gone from the handler: ShouldBindJSON/BindAndValidate, validator.Struct,
GetWorkOSTokenInfo, c.JSON, every c.Error branch, the swaggo annotation block. Huma does
bind+validate+serialize from the types:
// internal/features/realtime/models/conference_token.go
type ConferenceTokenInput struct {
Body struct {
Username string `json:"username" minLength:"1" maxLength:"64"` // was validate:"required,max=64"
SpaceID string `json:"spaceId" format:"uuid"` // was validate:"required,uuid"
}
}
type ConferenceTokenBody struct {
Token string `json:"token"`
}
type ConferenceTokenOutput struct {
Body ConferenceTokenBody
}
Route registration (per-feature routes.go, replacing the inline main.go line
livekitGroup.POST("/room-auth", deps.RoomApiHandler.RoomAuth.Handle)):
// internal/features/realtime/module/routes.go
func RegisterRoutes(api huma.API, h *api.Handler) {
register(api, huma.Operation{ // register = secure-by-default wrapper (§6.2): injects Security + error conv
OperationID: "get-conference-token",
Method: http.MethodPost,
Path: "/realtime/conference-token", // absolute path (§9.3)
Summary: "Mint a realtime conference token",
Tags: []string{"Realtime"},
}, h.ConferenceToken)
}
For an op that does need a permission (e.g. start-recording, today
RequirePermission(deps.RBAC, permissions.StartRecording)), add one metadata line — the rbac
middleware (§5.2) reads it:
register(api, huma.Operation{
OperationID: "start-recording",
Method: http.MethodPost,
Path: "/recordings/start",
Tags: []string{"Recordings"},
}, h.StartRecording, rbacmw.RequirePermission(api, deps.RBAC, permissions.StartRecording)) // per-op RBAC guard (subscription is enforced globally, §5.3)
// A public op opts out explicitly — no Security → the auth middleware self-skips it:
registerPublic(api, huma.Operation{
OperationID: "sign-in", Method: http.MethodPost, Path: "/auth/sign-in", Tags: []string{"Auth"},
}, h.SignIn)
8. Sad path — ErrPermissionDenied, traced to the wire
Caller is authenticated, but the space belongs to another org.
| Hop | What happens |
|---|---|
| auth mw | passes — caller has a valid session, identity injected |
| rbac mw | no permission metadata on this op → passes (ownership is a service check, not a static role) |
| handler | service.GenerateToken returns fmt.Errorf("space not in caller org: %w", apperr.ErrPermissionDenied); handler does return nil, err |
| register-wrapper | ToHumaError(err) → errors.Is matches ErrPermissionDenied → &huma.ErrorModel{Type:".../permission-denied", Title:"Forbidden", Status:403} — no detail, no code |
| hook | status 403 (< 500) → no Sentry capture, no purge needed; stamps Instance: "/realtime/conference-token" |
| serialize | 403 application/problem+json |
Wire body:
{
"type": "https://qubital.app/errors/permission-denied", // the FE switches on this
"title": "Forbidden",
"status": 403,
"instance": "/realtime/conference-token"
// NO "code" — `type` IS the discriminator; NO "detail" — "space not in caller org" stayed in logs/Sentry
}
Contrast the two other error origins (same wire shape, different producer):
- Validation (422) —
spaceIdnot a UUID. Huma rejects it before the handler runs; the body is Huma-native and schema-derived (safe to expose):{ "type":"https://qubital.app/errors/validation-failed", "title":"Unprocessable Entity","status":422,"errors":[{ "location":"body.spaceId", "message":"expected string to match format \"uuid\"", "value":"foo" }],"instance":"/realtime/conference-token" } - Unexpected 500 — service returns a non-sentinel error (e.g. LiveKit SDK failure).
ToHumaErrordoesn't match the table →Error500; the hook captures the cause to Sentry and blanksdetail. Client sees a bare generic 500; the cause lives only in Sentry.
This is the same three-way priority the current gin ErrorMiddleware implements
(HttpError → domain sentinel → unmanaged 500, error.go:40–78) — now expressed as
StatusError → sentinel-table → Error500, and reused identically by Huma-native validation.
9. Adaptations — where this refines the two design docs
The walkthrough is faithful to huma-integration-design.md and
the migration plan, with these deliberate sharpenings (all validated against Huma v2.38.0):
- Conversion lives in a
Registerwrapper, not in the handler. The design doc §2.3 showsapperr.ToHumaError(err)called inside the handler; the migration plan §4.3 says handlers return the raw sentinel and a central wrapper converts. This doc takes the plan's form (§6.2) — handlers never callToHumaError— because it removes a line of boilerplate from every handler and makes "return the raw sentinel" the single rule. Reconcile the two design docs to this. - RBAC (and the future Stripe gatekeeper) via per-op
Operation.Middlewares— notMetadata, notSecurityscopes. Settled by research:Operation.Metadatais not serialized into the spec (yaml:"-"), so the earlier "shows up in the OpenAPI doc" rationale was false; and overloadingOperation.Securityscopes doesn't scale once a third gatekeeper (Stripe) sits between auth and RBAC. The faithful-to-Huma choice is the maintainer-endorsedOperation.Middlewares(Discussion #389) for the per-op guards, with auth alone staying a globalUseMiddleware+Operation.Security. Full rationale + the optionalOperation.Extensionsspec-visibility annotation: design doc §3.6.1. All three docs are aligned on this. NewWithGroupis still avoided. Issue #684 (group prefix wrong in the spec/docs) is still open as of 2026-06. Mount Huma on the root engine with absolute paths in everyRegister(as shown). The transport chain is inherited via gin's outer group, not viaNewWithGroup.- Type-only error contract — no
codeextension, no custom struct. RFC 9457'stypeURI is the discriminator (§3.1.1), so two same-status errors (e.g. two 409s) differ bytype— acodeslug would merely duplicate the URI's last segment. We therefore dropcodeand return Huma's built-in*huma.ErrorModeldirectly (it already hasTypeand implementshuma.StatusError), so noproblemstruct is needed. The frontend switches ontype(constant compare or last segment). (Decision: type-only.) - Versions to pin at Phase 0: Huma v2.38.0, go.mod
go 1.25.0(the module's declared minimum).huma.WithValue(v2.8.0+),WriteErr,NewErrorWithContext,humagin.New,Operation.Security/Metadata,huma.Middlewares— all verified present and current. - Secure-by-default registration + shared identifiers. Routes register through
register/registerPublic(§6.2):registerinjects thecookieAuthSecurityrequirement, so a route is protected unless it explicitly opts out — a forgotten endpoint fails closed, not open. The scheme key and cookie name are shared constants (SchemeCookieAuth/AccessTokenCookie), not strings repeated across scheme, middleware, register and token.Manager. Reconcile both design docs to this — drop the per-routeSecuritymap and the hardcoded strings.
10. Before / after at a glance
| Concern | Today (gin) | After (Huma) |
|---|---|---|
| Bind + validate | middleware.BindAndValidate[T] + validator tags, in every handler | Huma, from struct tags; handler does neither |
| Identity in | c.Set Keys + GetWorkOSTokenInfo(c) | huma.WithValue + IdentityFromContext(ctx) |
| Auth | gin middleware on a group | api.UseMiddleware, Security-aware; register injects it (secure-by-default), registerPublic opts out |
| RBAC | per-route RequirePermission(rbac, perm) in main.go | per-op Operation.Middlewares guard, attached by the register wrapper |
| Error → status | ErrorMiddleware + mapDomainError (status only) | register/registerPublic wrapper + ToHumaError (type discriminator + status + title) |
| Error body | gin.H{"code":int,"message":str} (bespoke) | RFC 9457 application/problem+json |
| 5xx → Sentry | ErrorMiddleware captures via sentrygin.GetHubFromContext(c) | NewErrorWithContext hook captures via sentry.GetHubFromContext(ctx.Context()) |
| Routes | ~50 lines inline in main.go | per-feature routes.go via register/registerPublic — no per-route Security map |
| Spec | ~51 swaggo annotations (Swagger 2.0, unverified) | OpenAPI 3.1 derived from handler signatures — correct by construction |
11. Build order (mirrors design doc §5 / plan §7)
- Error contract first, transport-independent. Extend the sentinel table to
{status, slug}; addToHumaError+ theregister/registerPublicwrappers; retrofit the ginErrorMiddlewareto emit the same RFC 9457 body. → all current gin routes already speak RFC 9457; frontend adapts here. - Decouple
token.Managerfrom*gin.Context(the one enabling refactor). Isolated PR. - Stand up Huma on the root engine; install the error hook; port auth + rbac to Huma mw.
- Pilot
get-conference-tokenend-to-end (this doc's example) — input struct + validation- identity + RFC 9457 + Sentry.
- Migrate feature by feature. When the last gin route is gone, delete
ErrorMiddleware,BindAndValidate, andGetWorkOSTokenInfo.