Skip to main content

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 path200 { "token": "<livekit-jwt>" }
  • Sad path → caller's org doesn't own the space → service returns …: %w apperr.ErrPermissionDenied403 application/problem+json with a purged detail.

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:

MiddlewareWhy it stays gin/transport
sentryginMust 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.
LogContextMiddlewareWrites trace_id/request_id via c.Request = c.Request.WithContext(...). Because humagin's ctx == c.Request.Context(), this flows into Huma handlers for freelogger.*Ctx(ctx, …) just works.
DefaultLoggerAccess log needs only the raw request; no huma.Context value-add.

Verified: humagin's ginCtx.Context() returns c.orig.Request.Context(). So request-context values cross into Huma handlers; gin c.Set() Keys do not — which is exactly why identity moves to huma.WithValue (§5.1).


5. Gatekeeper middleware — gin → Huma

5.1 Auth — pkg/middleware/auth.gointernal/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.gointernal/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.Securitynext), 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 (mirroring registerPublic for auth, e.g. a registerNoSub variant or an Operation.Extensions flag). 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) — not sentrygin.GetHubFromContext(c) — because inside Huma we only have the request context. sentrygin put the hub there too, so this resolves.


7. Happy path — traced

HopWhat happens
sentrygin / logctx / loggerhub attached; trace_id onto request.Context(); access log opens
humaginctx := c.Request.Context() — carries trace_id + hub
auth mwrequiresAuth(op) true (cookieAuth in Security) → Authenticate validates cookie → next(huma.WithValue(ctx, identityKey, tok))
rbacno RequirePermission guard on get-conference-token (any authed user may mint their own token) → not gated
Huma bind/validatebinds body to ConferenceTokenInput, validates tags (minLength etc.)
handlerIdentityFromContext(ctx)service.GenerateToken(...)&Output{Body: …}
serialize200 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.

HopWhat happens
auth mwpasses — caller has a valid session, identity injected
rbac mwno permission metadata on this op → passes (ownership is a service check, not a static role)
handlerservice.GenerateToken returns fmt.Errorf("space not in caller org: %w", apperr.ErrPermissionDenied); handler does return nil, err
register-wrapperToHumaError(err)errors.Is matches ErrPermissionDenied&huma.ErrorModel{Type:".../permission-denied", Title:"Forbidden", Status:403}no detail, no code
hookstatus 403 (< 500) → no Sentry capture, no purge needed; stamps Instance: "/realtime/conference-token"
serialize403 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)spaceId not 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). ToHumaError doesn't match the table → Error500; the hook captures the cause to Sentry and blanks detail. 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):

  1. Conversion lives in a Register wrapper, not in the handler. The design doc §2.3 shows apperr.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 call ToHumaError — 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.
  2. RBAC (and the future Stripe gatekeeper) via per-op Operation.Middlewares — not Metadata, not Security scopes. Settled by research: Operation.Metadata is not serialized into the spec (yaml:"-"), so the earlier "shows up in the OpenAPI doc" rationale was false; and overloading Operation.Security scopes doesn't scale once a third gatekeeper (Stripe) sits between auth and RBAC. The faithful-to-Huma choice is the maintainer-endorsed Operation.Middlewares (Discussion #389) for the per-op guards, with auth alone staying a global UseMiddleware + Operation.Security. Full rationale + the optional Operation.Extensions spec-visibility annotation: design doc §3.6.1. All three docs are aligned on this.
  3. NewWithGroup is 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 every Register (as shown). The transport chain is inherited via gin's outer group, not via NewWithGroup.
  4. Type-only error contract — no code extension, no custom struct. RFC 9457's type URI is the discriminator (§3.1.1), so two same-status errors (e.g. two 409s) differ by type — a code slug would merely duplicate the URI's last segment. We therefore drop code and return Huma's built-in *huma.ErrorModel directly (it already has Type and implements huma.StatusError), so no problem struct is needed. The frontend switches on type (constant compare or last segment). (Decision: type-only.)
  5. 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.
  6. Secure-by-default registration + shared identifiers. Routes register through register / registerPublic (§6.2): register injects the cookieAuth Security requirement, 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-route Security map and the hardcoded strings.

10. Before / after at a glance

ConcernToday (gin)After (Huma)
Bind + validatemiddleware.BindAndValidate[T] + validator tags, in every handlerHuma, from struct tags; handler does neither
Identity inc.Set Keys + GetWorkOSTokenInfo(c)huma.WithValue + IdentityFromContext(ctx)
Authgin middleware on a groupapi.UseMiddleware, Security-aware; register injects it (secure-by-default), registerPublic opts out
RBACper-route RequirePermission(rbac, perm) in main.goper-op Operation.Middlewares guard, attached by the register wrapper
Error → statusErrorMiddleware + mapDomainError (status only)register/registerPublic wrapper + ToHumaError (type discriminator + status + title)
Error bodygin.H{"code":int,"message":str} (bespoke)RFC 9457 application/problem+json
5xx → SentryErrorMiddleware captures via sentrygin.GetHubFromContext(c)NewErrorWithContext hook captures via sentry.GetHubFromContext(ctx.Context())
Routes~50 lines inline in main.goper-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)

  1. Error contract first, transport-independent. Extend the sentinel table to {status, slug}; add ToHumaError + the register/registerPublic wrappers; retrofit the gin ErrorMiddleware to emit the same RFC 9457 body. → all current gin routes already speak RFC 9457; frontend adapts here.
  2. Decouple token.Manager from *gin.Context (the one enabling refactor). Isolated PR.
  3. Stand up Huma on the root engine; install the error hook; port auth + rbac to Huma mw.
  4. Pilot get-conference-token end-to-end (this doc's example) — input struct + validation
    • identity + RFC 9457 + Sentry.
  5. Migrate feature by feature. When the last gin route is gone, delete ErrorMiddleware, BindAndValidate, and GetWorkOSTokenInfo.