Skip to content

feat(client): carry declared auth end to end, from security schemes to client capabilities - #58

Merged
juicycleff merged 45 commits into
mainfrom
feat/client-generator-auth
Aug 17, 2026
Merged

feat(client): carry declared auth end to end, from security schemes to client capabilities#58
juicycleff merged 45 commits into
mainfrom
feat/client-generator-auth

Conversation

@juicycleff

@juicycleff juicycleff commented Aug 15, 2026

Copy link
Copy Markdown
Contributor

This started as one wrong line in spec_parser.go and grew into the whole path an auth requirement travels: what your service declares, what the router writes into the OpenAPI document, and what a generated client can do with it once it gets there. Authentication is the first half. Authorization is the second, and it is new here.

The bug it started as

The generated Go client could not do cookie or session auth. It turns out it could not do much else either: it sent X-API-Key for every apiKey scheme whatever the document declared, ignored query and cookie locations entirely, declared basic-auth fields that nothing read, and had no cookie jar, so a login-then-use flow was impossible.

One line caused most of it. spec_parser.go built the client IR like this:

secScheme := SecurityScheme{
    Type: scheme.Type,
    Name: name,        // the securitySchemes MAP KEY
    In:   scheme.In,
}

Name on the source type is the wire parameter name, the session_id in {type: apiKey, in: cookie, name: session_id}. The parser overwrote it with the map key and dropped it, and the IR had one Name field for two different concepts, so there was nowhere to put it even if the parser had wanted to keep it. With nothing to emit, the generator hardcoded.

The server side was fine the whole time. APIKeyProvider takes WithAPIKeyCookie(name) and emits {apiKey, cookie, <name>} correctly, and the OpenAPI generator puts it in components.securitySchemes. Your service was describing itself accurately and the client generator threw the answer away one step later.

Authentication: one field per declared scheme

SecurityScheme now carries Key and ParamName separately. That rename is the point: it made the compiler enumerate every read site so each could be checked rather than mechanically renamed.

The generated AuthConfig has one typed field per declared scheme, named from the scheme key:

type AuthConfig struct {
    BearerAuth  string           // http bearer   -> Authorization: Bearer <v>
    BasicAuth   BasicCredentials // http basic    -> Authorization: Basic <...>
    SessionAuth string           // apiKey cookie -> session_id
    TenantKey   string           // apiKey header -> X-Tenant-Key
    ListKey     string           // apiKey query  -> api_key
}

A single apply(header, url) puts each credential where the document says it goes, and REST, SSE, WebSocket and WebTransport all route through it. Before this, addAuth was REST-only and the WebSocket path carried its own bearer-only copy, which is how the two drifted apart.

Sessions are opt-in:

c := api.NewClient(base, api.WithSessionJar())
_ = c.Login(ctx, creds)   // the server sets the cookie
me, _ := c.Me(ctx)        // the jar replays it

A jar makes the client stateful, so the doc comment says plainly that it must not be shared between users. WithCookieJar(jar) takes your own if you want a per-user or persistent one.

On the TypeScript side the manifest emits a securitySchemes table plus per-operation scheme keys, so an AuthProvider can dispatch on meta.security the way transport.ts always said it would. RestTransportOptions gained credentials, threaded through to the generated fetch client, for cross-origin cookie sessions. Unset by default, so nothing changes unless you ask for it.

Authorization: declared once, carried the whole way

AuthContext now holds Roles and Permissions next to the scopes it already had, and a route's requirement is a value rather than a closure:

type Requirement struct {
    Providers   []string // authentication
    Scopes      []string // all of
    Roles       []string // any of
    Permissions []string // all of
    Mode        string   // "or" or "and", providers only
}

That constraint is deliberate. A value can be written into an OpenAPI document and read back by a generated client, and a closure cannot, which is what keeps resource-scoped questions (can this user edit document 42) inside handlers where they have the document in hand.

Registry.SetAuthorizer is the seam. Forge ships plain set membership and stops there: no role hierarchies, no permission inheritance, no wildcard matching. That is policy, and warden replaces the authorizer wholesale to provide it.

Routes and groups declare what they want:

router.DELETE("/users/:id", handler,
    forge.WithRequiredAuth("jwt"),
    forge.WithAnyRole("admin", "owner"),
    forge.WithAllPermissions("users:write", "users:delete"),
)

The names carry the combining rule because a name that hides and-versus-or sends you to the source to find out. Declaring records a requirement on the route's metadata and nothing more; enforcement happens when that requirement reaches MiddlewareWithRequirement. The docs used to read as though the option itself did the checking, and no longer do.

From there the OpenAPI generator carries roles and permissions as x-forge-authz, which is how the client generator learns about them without importing the auth extension.

What the clients do with it

Both generated clients get a capability surface. In Go:

c.SetPrincipal(api.Principal{
    Roles:       []api.Role{api.RoleAdmin},
    Permissions: []api.Permission{api.PermissionUsersWrite},
})

if c.CanCall(api.OperationNameDeleteUser) {
    // render the button
}

missing := c.MissingCapabilities(api.OperationNameDeleteUser)

TypeScript gets the same vocabulary in its own idiom: setPrincipal, can, hasRole, hasPermission, canCall, missingCapabilities, and a requiredCapabilities table you can read directly. It also has capabilitiesKnown(), so a UI can tell "this principal may not" apart from "nobody has told us yet" instead of rendering a disabled button at a user who is merely still loading.

None of this is a gate. Every predicate answers from a principal you handed the client, so it exists to hide a button the server would refuse anyway. The generated file says so in its own header, and the review that added it kept finding places where the wording implied more than that.

Two names that meant two opposite things

auth.roles was route metadata for the roles a route requires, and also the context key for the roles the subject holds. Same for auth.scopes. Different maps, so nothing ever collided at runtime, but one literal covering both requirement and possession is a trap for whoever reads it next.

The context keys are now auth.subject.roles and auth.subject.scopes. The route metadata keys are untouched, since the OpenAPI and client generators read them. Both interceptors still honour the old key as a deprecated fallback, so an application that sets it from its own middleware keeps working.

Breaking change

AuthConfig.BearerToken and AuthConfig.APIKey are gone.

// before
client.WithAuth(api.AuthConfig{BearerToken: tok, APIKey: key})

// after: one field per declared scheme
client.WithAuth(api.AuthConfig{BearerAuth: tok, TenantKey: key, SessionAuth: sid})

Regenerate and rename at the call site. There is no compatibility flag, because the old shape could not express what the specs already declared.

The generated client compiles now, which it did not before

This was not planned. While verifying the auth work, we wrote generated output into a scratch module and ran a real build. It failed, and had been failing for a while: client.go, rest.go and types.go emitted imports that go unused for some specs, client.go and websocket.go referenced c.auth when no AuthConfig was declared at all, and webtransport.go destructured the dial as (*Session, *http.Response, error) when every version of webtransport-go returns those two the other way round.

All of that is fixed here, and six more of the same shape once we went looking: dead imports as soon as a streaming endpoint declares no payload schema, a time in websocket.go nobody had noticed, a doc comment that a multi-line inline struct broke out of, a go.mod that never required webtransport-go at all, and a Dialer the package had renamed to Transport several versions earlier. ConnectionState and the reconnect helpers moved into streaming.go on the way, because sse.go used all four and only websocket.go declared them, so an SSE-only spec generated a client naming identifiers nothing had defined.

Two gates stand behind that now. Every emitted file gets parsed and each import checked for a real user, which needs no module cache and runs everywhere. Then TestGoGeneratorGeneratedModuleBuilds writes a generated client into a temporary module and runs the actual compiler over six configurations, because an undefined symbol and an unresolvable module are both perfectly valid syntax with no unused imports, and only a compiler finds them. It skips rather than fails when the environment cannot support a build, under -short, with no go on PATH, or when the module cache cannot resolve offline. Compile errors are never skipped, so a green run offline still means everything checkable was checked.

Six client-core gaps, closed along the way

Not auth, but on the branch: entity garbage collection, container identity across a refetch, a WebTransport adapter, hydration for Vue and Angular, and a streamed SSR payload. Each was written down in the README as deliberately left rather than overlooked, so that list is six shorter.

The refetch one is the behaviour change to know about. A refetch used to hand back a new root even when no record moved, because a second skeleton gets built and the container memos are keyed by node identity. read() now takes the previous value and reuses a container whose children are all identical to it. That changes what useQuery gives you, and the React test asserting the old contract asserts its converse now.

Verification

  • go build ./... and go vet clean; go test ./internal/client/... ./internal/router/... ./extensions/auth/... passes
  • packages/client-core: suite green, typecheck clean, all size budgets within limit
  • web-client/generated-package.mdx documents the generated auth surface and the breaking change

Two tests are worth reading rather than skimming. The end-to-end one feeds a document identical to what WithAPIKeyCookie produces and asserts the generated client sends that cookie, with a negative assertion that X-API-Key is absent; both fail against the pre-branch code. The parity one drives a deliberately unnormalized endpoint through the Go and TypeScript generators and compares the two emitted tables against each other rather than against a literal, because a table pinned to Go's output would have passed before that fix as happily as after it.

Worth knowing during review

The generator has two IR builders, spec_parser.go for documents on disk and introspector.go for a live router. Every defect fixed here existed in both, and the second one was easy to miss. Both now keep cookie parameters, warn on an unknown parameter location, sort schemes deterministically, and populate declared authorization, so a client generated from an introspected router behaves the same as one generated from a file.

Warnings rather than silence is the theme. A scheme type the generator cannot emit, two scheme keys that derive to the same Go field, two capability strings that derive to the same constant, a parameter location it does not recognise, a cookie parameter it will not put on the wire: each of those used to vanish, and each now reports through the generated client's Warnings.

Known gaps

Both of the ones this section used to list are closed.

An apiKey scheme whose in value the generator does not recognise used to get an AuthConfig field regardless, while apply encodes only header, cookie and query. You could set that credential and it went nowhere. OpenAPI 3.1 allows exactly those three locations, but neither IR builder validates what the document said, so a typo, an OpenAPI 2.0 body, or a scheme with no in all arrive at the generator. They warn and claim no field now, like every other unhandled scheme in that file. A credential that vanishes is the defect this work exists to remove; one that looks configured and goes nowhere is worse, because nothing at the call site suggests you should check.

The six unrelated compile bugs are fixed too, in the section above.

Downstream

xraph/authsome#51 is the first consumer, and it is blocked on this shipping. It stamps a session's roles onto the auth context, declares on its admin routes the permission its middleware already enforces, and declares the session cookie its provider has always accepted and never described. None of it compiles until AuthContext.Roles is in a release, so that branch sits red until this one merges.

Worth reading alongside this PR if you want to see whether the declaration path actually holds up: it is the same journey from a real service's own auth model out to a generated client, and it turned up two things this side got wrong on the first pass.

…introspected security schemes

The parameter switch in convertOperation only handled path/query/header,
so any `in: cookie` parameter vanished silently on the way into the IR.
Add a cookie case plus a default arm that reports an unrecognized
location via spec.Warnings instead of dropping it.

Also sort extractFromOpenAPI's spec.Security by Key in introspector.go,
matching the sort spec_parser.go already carries: both range the same
kind of map, so a client built from a live router had the same
nondeterministic AuthConfig field order the parser was already fixed for.
…der too

operationToEndpoint has its own path/query/header parameter switch,
separate from spec_parser.go's convertOperation, with the same gap: no
cookie case, unknown locations dropped silently. A client generated by
introspecting a live router lost cookie parameters that a client built
from a parsed OpenAPI file would keep -- a distinction no caller of the
generator can see coming. Fix it the same way: add the cookie case and
report unrecognized locations via spec.Warnings instead of dropping them.
…n location

AuthConfig used to carry a single BearerToken/APIKey pair and addAuth
hardcoded X-API-Key regardless of what the spec declared. generateAuthConfig
now emits one field per security scheme (named after the scheme key) and
generateAuthApply emits a single apply(header, url) that routes each
credential to its declared location: Authorization for bearer/basic/oauth2,
the declared header/cookie/query name for apiKey. websocket.go's handshake
path now goes through the same apply instead of its own bearer-only check,
so REST and WebSocket auth can't drift apart again.
Case-insensitive matching would skip a distinct, legal Go field (ApiKey vs
APIKEY compile fine side by side) and silently drop a declared credential,
which is worse than the confusing-but-harmless pair it was avoiding. Reverted
resolveAuthFields to exact-string matching and fixed the test fixture to use
two keys that actually collide (api_key and api-key both derive to ApiKey).

Also documents the websocket.go apply(header, nil) gap: apiKey-in-query
schemes aren't applied to the WS handshake yet since there's no *url.URL to
hand it from a string-typed url var; Task 5 owns wiring the real one through.
WebSocket applied auth with a literal nil *url.URL, so a query-located
scheme never reached the handshake; WebTransport never called apply at
all despite already carrying an AuthConfig. Both now route through the
same AuthConfig.apply as REST and SSE, with the WebSocket dial also
threading the client's cookie jar so session auth survives the upgrade.

WithCookieJar and WithSessionJar are new, unconditional client options
so a generated client can hold and replay a session cookie even when
the login endpoint that sets it isn't itself in securitySchemes.
Five pre-existing bugs kept the generated package from building: dead
imports in client.go, rest.go, and types.go; an undefined AuthConfig
reference in client.go, websocket.go, and webtransport.go whenever a
spec declares no security scheme; and a swapped return-value order on
the WebTransport dial call, verified against the real signature in
both v0.6.0 (what this generator's own go.mod pins) and v0.12.0 (what
this repo pins). All five predate this branch.

Fixed each by computing the affected import or field from the
generated body's actual content rather than a hand-maintained flag, so
none of it can drift out of sync again. A go/parser syntax check alone
would have passed on every one of these, since none is a syntax error,
so generator_test.go also gained an AST-based unused-import gate plus
two targeted tests for the two defects that gate still can't see on
its own: an undefined identifier, and a call whose arguments still
parse in the wrong order.
…lared

Carries client.SecurityScheme (Key, ParamName, Type, In, Scheme) into the
TypeScript manifest: a normalized securitySchemes table emitted once, and
each operation naming its schemes by key in a sorted security array. The
runtime's OperationMeta.security field (transport.ts) has waited for a
producer since it was declared; this is it.

Both the table and the per-operation field are omitted entirely when empty,
matching how entities/provides/invalidates already treat their own bundle
weight. Per-operation scheme keys are deduped and sorted for determinism,
since Endpoint.Security carries no ordering guarantee the way spec.Security
does.

Also strengthens the with-auth capability fixture, whose sessionAuth scheme
had Key and ParamName set to the same string by an earlier task -- which
could no longer prove this change reads ParamName rather than re-emitting
Key -- and updates the one byte-pinned golden test whose expected output
this change legitimately alters.
…lagged

operationSecurityKeys' dedup branch (a scheme repeated across two
OR-alternatives with different scopes) was reachable but untested --
the capabilitySpec fixture cited as covering it actually only exercised
the union-and-sort path across two DIFFERENT scheme names. Also add a
per-operation omission check (a secured and an unsecured op isolated by
a new opBlock helper, rather than a whole-file substring check that
either could satisfy) and an explicit assertion that the http scheme's
'scheme' field is emitted as a string, not just type-checked through
the tsc gate.
…ange

Adds the end-to-end regression test proving an apiKey-in-cookie scheme
survives from an OpenAPI document all the way to a generated Go client
that actually sends the cookie, and documents the new AuthConfig shape,
WithCookieJar/WithSessionJar, the TypeScript securitySchemes table, and
the credentials option for cross-origin sessions.
…d auth

Six issues only showed up once every task landed together. Multiple cookie
schemes collapsed to one under a jar, because net/http.Request.AddCookie reads
Header.Get("Cookie") (first value only) then Header.Set (replaces the whole
slice) - so apply() now merges into the existing Cookie value instead of
Add-ing a second one. WebSocket's handshake had the same problem one layer up:
gorilla copies the caller's header over the jar's cookies, so Connect now
seeds header from the jar before calling apply, and apply's merge appends to
that instead of wiping it out.

An unhandled scheme type or http scheme (digest, mutualTLS - both legal and
unvalidated on the way in from spec_parser.go/introspector.go) used to get
consumed by resolveAuthFields and then silently emit nothing; both switches in
auth.go now have a default, and generateAuthConfig warns with the scheme's key
and type/scheme. A non-auth in: cookie parameter had the same silent-drop
problem one layer further down - Endpoint.CookieParams already lands from both
IR builders, but nothing warned that the generator doesn't emit it, so
Generate now does.

Also closed two test gaps: the WebSocket routing assertion could pass even if
Connect stopped calling apply entirely (webtransport got a targeted version of
this after an earlier review; websocket didn't), and nothing asserted the
username:password separator survives into the generated Basic header.
@vercel

vercel Bot commented Aug 15, 2026

Copy link
Copy Markdown
Contributor

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
forge Ready Ready Preview Aug 17, 2026 6:38pm

Request Review

@github-actions

Copy link
Copy Markdown
Contributor

Conventional Commits Validation

PR Title: valid
Commits: all 15 follow conventional format

Six defects, all of them shapes the generator emits happily and the Go
compiler rejects. None of them were reachable from the fixtures the parse
and unused-import gates already ran, which is why they survived.

websocket.go, sse.go and webtransport.go each emitted an import with no
user left once a streaming endpoint declared no payload schema, or once
reconnection was off. Those imports are now conditional on the thing that
uses them. Extending the unused-import matrix with a schema-less spec is
what turned them into failures first, and it found a fifth the brief did
not list: websocket.go's "time".

webtransport.go spliced a rendered type name into a single-line doc
comment. An inline object schema renders as a multi-line anonymous struct,
so the struct body's newlines escaped the comment and the rest landed at
top level as stray tokens. docTypeName falls back to "message" when a
rendering has no single line, and the signature on the next line still
carries the full type.

The go.mod the generator writes did not require webtransport-go at all,
so a generated client importing it had nothing to resolve. That require
block is now rendered from getDependencies rather than listed a second
time, which is what stops the two drifting again.

getDependencies pinned v0.6.0 while the emitted code targeted a later
API. It pins v0.12.0 now, matching what this repo uses. That rename is
Dialer to Transport, and the same version bump turns Stream and
SendStream into structs with pointer receivers, so those fields are
pointers too.

That last pair is the reason for the new gate. TestGoGeneratorGeneratedModuleBuilds
writes a generated client into a temp module and runs a real go build,
because an undefined symbol and an unresolvable module are both valid
syntax with no unused imports and only a compiler finds them. It skips
rather than fails when the environment cannot support the build: under
-short, with no go on PATH, or when the module cache cannot resolve the
dependencies offline. Compile errors are never skipped.

Also lifts ConnectionState and the reconnect helpers out of websocket.go
into streaming.go. sse.go used all four, but only websocket.go declared
them, so an SSE-only spec generated a client naming identifiers nothing
had defined.
Entity garbage collection, container identity across a refetch, a
WebTransport adapter, hydration for Vue and Angular, and a streamed SSR
payload. Each one was written down as deliberately-left rather than
overlooked, so the README's gap list shrinks by the same six.

A refetch used to hand back a new root even when no record moved. The
records survive one on their own, because a deep-equal write is not a
write, but the skeleton does not: a refetch builds a second one and the
container memos are keyed by node identity. read() now takes the previous
value and reuses a container whose children are all identical to it. The
comparison is shallow and bottom-up, so by the time a container is checked
its children have already settled their own identity and === per child is
the whole answer. A read stays proportional to the skeleton rather than to
the response. That changes what useQuery hands you, so the React test
asserting the old contract now asserts its converse as well.

collect() drops every record no cached query and no pending overlay can
reach, and it hangs off the existing LRU reap, which is the only moment
entities can have become unreachable. Reachability is recomputed rather
than refcounted: a count would need maintaining at every write, eviction,
frame and rollback, and the one place it went wrong would free a record
something still points at.

Tombstones are now bounded by when they stop mattering. The cache tells
the store the frame reading of the oldest request still outstanding and
every stamp at or below it goes, so the 256 cap is a backstop rather than
the mechanism. A dispatch stays registered until its response has
committed, not until it arrives. racedSince is the reader a tombstone
exists for, and retiring earlier lets a response expire the stamp it is
about to consult, undoing the delete it straddles by its own arrival.

webTransportConnection is not the four-line literal the gap claimed. A
WebSocket hands you onMessage as a callback; datagrams arrive as a
ReadableStream, so it is a pull loop with a reader to release and a decode
that can fail one packet at a time. You pass a session that is already
open, so this package still opens nothing.

hydrateBoundary carries the two things every framework needs around
hydrate: walk a payload once per cache, and decide which refusals a page
survives. It lives in core because three copies of that second rule are
three security postures waiting to drift. React collapses onto it, Vue
gets the same component, and Angular gets a provider instead. Its content
model is why: a component wrapping ng-content does not own its children,
so injectQuery has already run by the time the wrapper's constructor does,
and the one guarantee a boundary owes is the one that shape cannot make.

streamingDehydrator emits the difference since the last flush. Calling
dehydrate per boundary works and also re-sends the whole cache each time,
so a page with ten boundaries ships its first query's records ten times.
A record goes again when its version moves; everything else goes once.

Three gaps stay, and the list says why. Per-field frame stamps need the
server to stamp fields. Denormalized entity cycles and negative zero both
need an encoding that is not JSON.
@github-actions

Copy link
Copy Markdown
Contributor

Conventional Commits Validation

PR Title: valid
Commits: all 17 follow conventional format

@github-actions github-actions Bot added fix and removed fix labels Aug 15, 2026
The pattern was scoped to extensions/, for the Tailwind builds. That left
the client packages and an npm install at the repo root turning up in
every `git status`, one careless `git add .` away from being committed. A
bare directory pattern matches at every level, so one line covers all
three. Nothing under node_modules was tracked, so the ignore takes effect
immediately.
@github-actions

Copy link
Copy Markdown
Contributor

Conventional Commits Validation

PR Title: valid
Commits: all 18 follow conventional format

…g work

4506389 adds ~709 lines to client-core/src (Angular SSR hydration, cache,
store and stream changes), which pushed two budgets over:

  entity store                     2.17 kB -> measured 2.32 kB
  query engine and REST transport  8.5 kB  -> measured 8.83 kB

Raised to 2.4 kB and 8.9 kB, and the README table now shows measured
figures rather than stale ones. The growth is deliberate feature work, which
is what these budgets are meant to let through; they exist to catch the
growth nobody intended.

Worth watching: the two application-facing budgets did not move but are
close now. core, REST only is 8.88 kB against 9 kB and core with streams is
13.73 kB against 14 kB, so roughly 120 B and 270 B of headroom respectively.
routeToEndpoint runs whenever router.OpenAPISpec() returns nil, and it
never set endpoint.Authorization, so an app with OpenAPI disabled
generated a client with roles and permissions silently missing:
CollectRoles and CollectPermissions came back empty and the Role and
Permission unions disappeared with no warning. Read auth.roles and
auth.permissions the same way auth.providers already is, and produce
the exact same shape resolveEndpointAuthz does for the OpenAPI path.
…ilities

TypeScript's canCall and missingCapabilities have existed since roles and
permissions landed there. Go only had Can, HasRole and HasPermission, so
a Go service calling another Go service had no way to ask "would this
call be refused" -- exactly the cross-language drift the shared
collectors in internal/client/auth.go exist to prevent.

Adds OperationName, a per-operation requirement table, and CanCall /
MissingCapabilities on *Client, gated on the spec having REST endpoints
the same way the TypeScript generator gates its equivalent. CanCall
mirrors writeOperationPredicates' semantics deliberately: scopes ALL-of
within an alternative and ORed across alternatives, roles ANY-of,
permissions ALL-of, and an operation absent from the table is callable.
Operation identifiers route through the same capabilityIdent/
resolveCapabilityConsts collision-warning path the other three unions
already use, rather than a second silent-skip.

Covered by emission tests, an omitted-when-no-endpoints test, the
existing go-build gate extended to this shape, and a runtime test that
builds and runs the generated code against a real Principal to check
the ANY-of/ALL-of semantics rather than only the source text.
…enerated comments

Neither function exists. The real options are WithAnyRole and
WithAllPermissions, and WithRequiredRoles is exactly the and/or-hiding
name the design doc rejected, so the old comment text taught readers
the wrong API twice over -- and it ships inside every generated client,
both languages.
…ything

Declaring either option just records a static requirement on the
route's (or group's) metadata. Nothing is enforced until that
requirement reaches MiddlewareWithRequirement, and the old wording
("requires the authenticated subject to hold...") read as though the
option itself did the checking.
…ueStrings

WithGroupAnyRole and WithGroupAllPermissions are shipped public options
with no test anywhere; add the group-level mirrors of the existing
route-level metadata tests, including composition with WithGroupAuth.

sortedUniqueStrings was only ever exercised with []string literals
through processAuthzRequirements' tests. Add direct cases for []any of
strings, []any with non-strings filtered out, a wholly wrong type, and
nil, and assert the empty case is nil rather than just length zero --
the caller's omit-the-key logic depends on that distinction.
…gate

TestCapabilitiesNeededForRoleOrPermissionAloneCoversTheWidenedGate
claimed to cover role-or-permission-alone but only ever set Roles, so a
regression dropping CollectPermissions from the OR would still pass
under this test's name. Add the permissions-only fixture alongside the
existing role-only one.
"auth.scopes" meant two opposite things. As route metadata it is the
scopes a route REQUIRES, written by WithRequiredAuth and
WithGroupRequiredScopes and read by the OpenAPI and client generators.
As a context key it was the scopes the subject HOLDS, read by
RequireScopes and RequireAnyScope. Different maps, so nothing ever
collided at runtime, but one literal covering both requirement and
possession is a trap for whoever reads it next.

Scopes now get the same split roles got: the context key becomes
"auth.subject.scopes" and the route metadata key stays exactly as it
is.

Nothing in this repository published subject scopes into the context
before now, so those two interceptors were reading a key only host
applications could have set. MiddlewareWithRequirement publishes
authCtx.Scopes alongside the roles it already published, above the
IsEmpty return so an authenticate-only route still fills it. The legacy
"auth.scopes" read survives as a deprecated fallback naming its
replacement, and both interceptors go through one subjectScopes helper
so the deprecation note lives in one place.

Tests cover the new key, the fallback and the deny path for both
interceptors, plus the producer side in the auth registry. The new key
wins when both are set, which each interceptor now asserts.
EndpointCapabilities hands back sorted, deduplicated scope alternatives.
EndpointAuthorization handed back whatever the endpoint happened to
hold. Every generator reads both, so that asymmetry left each language
to normalise roles and permissions for itself, and only TypeScript did.

No production spec reached the gap. resolveEndpointAuthz and
routeToEndpoint both sort, deduplicate and drop empties before an
Endpoint is built. A hand-built one does not, and an Endpoint carrying
Roles: []string{""} produced two different tables: TypeScript re-sorted
and dropped the empty, Go rendered it verbatim. An empty role is a role
no principal can hold, so Go's CanCall answered false for that operation
forever while TypeScript's canCall answered true for the same principal.

Normalise in EndpointAuthorization instead, reusing sortedUniqueScopes,
which authz.go and introspector.go already apply to roles and
permissions. It returns a copy rather than the endpoint's own pointer so
no caller can reach past it for the raw slices. The TypeScript generator
keeps its sortedUniqueStrings call as an order-determinism belt, but the
comment above it no longer claims to be the guard against divergence.

The new test drives one deliberately unnormalised Endpoint through both
generators and compares the two emitted tables against each other rather
than against a literal. An expectation pinned to Go's output would have
passed before this fix as happily as after it.
The Playwright MCP server writes console logs, traces and downloads
into .playwright-mcp/ beside whatever directory it started from, which
here is the repository root, and screenshots land next to it as loose
PNGs named for the page and viewport width.

The PNG rule is anchored with a leading slash. Every image this
repository ships lives under docs/, so an unanchored *.png would have
ignored docs/public/banner.png and the app icons along with the
screenshot.
@github-actions

Copy link
Copy Markdown
Contributor

Conventional Commits Validation

PR Title: valid
Commits: all 45 follow conventional format

@github-actions github-actions Bot added fix and removed fix labels Aug 17, 2026
@juicycleff juicycleff changed the title fix(client): honour every declared security scheme, and give the Go client sessions feat(client): carry declared auth end to end, from security schemes to client capabilities Aug 17, 2026
@github-actions

Copy link
Copy Markdown
Contributor

Conventional Commits Validation

PR Title: valid
Commits: all 45 follow conventional format

@github-actions github-actions Bot added feature and removed fix labels Aug 17, 2026
@juicycleff
juicycleff merged commit 82820e7 into main Aug 17, 2026
31 of 33 checks passed
@github-actions

Copy link
Copy Markdown
Contributor

Conventional Commits Validation

PR Title: valid
Commits: all 45 follow conventional format

@github-actions

Copy link
Copy Markdown
Contributor

Conventional Commits Validation

PR Title: valid
Commits: all 45 follow conventional format

@github-actions github-actions Bot added feature and removed feature labels Aug 18, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant