Skip to content

Carry the unsigned and no-redirect flags into the behavior model - #283

Merged
jeremy merged 74 commits into
mainfrom
behavior-model-oauth-flags
Oct 24, 2026
Merged

jeremy merged 74 commits into
mainfrom
behavior-model-oauth-flags

Conversation

@jeremy

@jeremy jeremy commented Oct 24, 2026 •

Copy link
Copy Markdown
Member

Follows #278, which added @heyUnsigned and @heyNoRedirect and put them into openapi.json for the SDK generators, but not into
behavior-model.json. A consumer that reads only the model, such as the MCP toolkit or an agent's
policy layer, saw CreateToken and RevokeToken as ordinary writes. This adds both flags to the model:

  • unsigned: HEY takes the operation without the caller's credentials. A 401 is its answer,
    not a reason to refresh.
  • no_redirect: a 3xx is the operation's answer, and nothing is sent to its Location.

Every operation carries both keys, false unless it declares the trait, the way readonly and
destructive are always present. Only CreateToken and RevokeToken are true today.

  • Neither trait becomes mandatory, since neither is an effect.
  • The generators keep reading x-hey-unsigned and x-hey-no-redirect from openapi.json. Every
    generator's drift check passes, so no generated code changes.
  • Every reader of the model looks keys up rather than rejecting unknown ones, so the new keys
    break none of them. That covers the Rust, Kotlin (ignoreUnknownKeys), Swift and TypeScript
    generators, merge-behavior-model, and basecamp/mcp's catalog (json.Unmarshal).

Testing

  • Red first. A new case in typescript/tests/generator.test.ts checks that the model's
    unsigned and no_redirect agree with openapi.json's extensions for every operation, and that
    both token operations carry them. It failed on undefined before the generator change and
    passes after it.
  • Gates. These all pass locally: smithy-check, behavior-model-check, behavior-traits-test,
    drift-check-mvp, url-routes-check, the Go, Rust, Kotlin and Swift drift checks, and ts-check.

jeremy added 30 commits October 21, 2026 23:50
POST /oauth/tokens grants tokens for an authorization code or a refresh
token, and DELETE /oauth/tokens revokes one. Two traits say what sets
them apart from the rest of the API: @heyUnsigned (no Authorization, no
DPoP proof, and a 401 is the answer, never a reason to refresh) and
@heyNoRedirect (a 3xx is the answer; the body's secrets never follow a
307 to its Location).

The body goes as JSON at /oauth/tokens.json: haystack's token
controller reads its params from either encoding, as its own token
tests confirm when run with JSON bodies. The model carries only the
params HEY reads, so no client_secret.

RevokeToken is a DELETE with a body, as the token must stay out of the
query, where logs would keep it; HttpMethodSemantics is suppressed on it
for that. The drift check now holds /oauth/tokens to the model and
excludes only the browser's authorization pages and web-session handoff.

conformance/tests/oauth.json pins the behaviour every SDK must share,
with a new headerAbsent assertion. The generated SDK trees follow in
the commits after this one.
The model now marks an operation x-hey-unsigned (sent without the
client's credentials, a 401 the answer) and x-hey-no-redirect (a 3xx
the answer). The client can only honour either if the route carries
it. Red on the generator as it stands:

- unsigned_and_no_redirect_are_carried_onto_the_route: the emitted
  Route has neither field.
- a_service_type_keeps_an_acronym_the_accessor_folds: the OAuth tag can
  be mapped to an `oauth` accessor, but its type then PascalCases to
  `Oauth` and the hooks hear that, where the crate's own OAuthClient and
  Kotlin's existing RevokeToken say OAuth. names.toml has no table to
  settle it.
…redirect (red)

CreateToken and RevokeToken carry x-hey-unsigned and x-hey-no-redirect.
Red on this commit, since the generator reads neither and the client has
no way to leave a request unsigned:

- createToken and revokeToken do not exist on HeyClient.
- ListBoxes flagged unsigned at runtime still sends Authorization, and
  its 401 is refreshed and sent again.
- There is no NoCredentials, and a client given no token at all is
  built anyway, failing only on its first send.
- RevokeToken is a DELETE the behavior model calls neither readonly nor
  idempotent, and the generator, unlike the other SDKs', does not let
  the verb stand in, so its 429/503 retry policy would never apply.
- The conformance contract does not know headerAbsent.

The check that a signed operation's 401 is still refreshed passes before
the change and guards it.
…Auth()

Regenerating from the contract adds CreateToken and RevokeToken to the
generated client, with their request and token types. The template now
reads x-hey-unsigned and x-hey-no-redirect into each operation's
metadata as Unsigned and NoRedirect, the way it reads idempotency: data
only, which the commits after this one put to work.

client.OAuth() is the hand-written service that reaches them, as every
generated operation is reached from go/pkg/hey (go-check-drift):
CreateToken answers the token, and RevokeToken answers nothing but an
error. NoCredentials is the strategy of a client that holds no
credentials and refuses to sign anything.

The generator does not map x-hey-sensitive onto SensitiveString for any
field today, so the credential members are plain strings, as
Contact.email_address is.
HEY's token endpoint is modelled now (CreateToken and RevokeToken), with
two route traits no Kotlin route has carried: x-hey-unsigned and
x-hey-no-redirect. These tests come first and fail: the generator reads
neither extension, names the OAuth tag's service nothing but "Oauth",
the SDK has no generated OAuth service, a Route has no fields for the
flags, there is no NoCredentials strategy, and the conformance runner
knows no headerAbsent assertion.

What they hold the implementation to: an unsigned route carries no
Authorization or DPoP proof and never consults the provider or the
strategy; its 401 is the answer, sent once and never refreshed; a 3xx on
a no-redirect route is an error with its status, sent once, with
nothing reaching the Location; the flags come from any route that
carries them rather than from OAuth; a NoCredentials client sends the
unsigned operations and refuses a signed one before sending it; and the
generated methods send the documented method, path and JSON body.

revokeToken keeps its tests, now expecting the generated RevokeToken's
wire shape: /oauth/tokens.json with a JSON body rather than a form.
CreateToken and RevokeToken carry two new route traits: unsigned (no
credentials, the strategy never consulted, a 401 the answer and never a
refresh) and no-redirect (a 3xx is an error carrying its status, its body
unread, nothing sent to the Location). These tests pin that behaviour
before the generator and the client learn it: through the generated
OAuth service, through a hand-marked ordinary route so the flags rather
than the path decide, against the same route unmarked, and through an
account-scoped client, which must not scope the token endpoint.

NoCredentials is the deliberate way to build a client for those
operations alone; a signed operation on it fails auth_required before
anything is sent. The generator tests read both extensions and the
OAuth service's names, and the conformance runner's tests cover the new
headerAbsent assertion.
…edirect routes

The generator reads x-hey-unsigned and x-hey-no-redirect into each
Route as `unsigned` and `no_redirect`, the way x-hey-lenient-success
already flows, so the client can honour them for any route that carries
them rather than special-casing the token endpoint.

The OAuth tag becomes `client.oauth()`: [services] maps it to `oauth`
rather than the `o_auth` snake-casing would give. A new [service_types]
table names the handle's type, and the service the hooks hear, where
PascalCase of the accessor would fold the acronym: the type is `OAuth`,
as the crate's own OAuthClient and Kotlin's RevokeToken already spell
it, not `Oauth`. The hooks report the operations' resource as `token`,
as Kotlin's RevokeToken does.

Route gains two public fields. Route is request-side (a caller may
write one for Client::operation), so a literal without them stops
compiling: cargo semver-checks will call this a break, and it ships as
a 0.MINOR under the breaking label.
…direct

The generator reads x-hey-unsigned and x-hey-no-redirect into each
route's metadata as unsigned and noRedirect, the way lenient success
flows, and the transport honours them for whichever route carries them:

- An unsigned route never asks the token provider for anything, sends no
  Authorization, carries no account filter (a scope filters what the
  caller's identity sees, and the request has none) and takes a 401 as
  the answer: what HEY judges is the code or token in the body, so there
  is nothing a refresh could fix.
- A no-redirect route takes a 3xx as an error with its status before
  the body is read, and releases the body unread. Every request already
  goes out with redirect: "manual", so nothing was ever sent to a
  Location; what changes is that the 3xx is never read, retried or
  treated as a 401.

TypeScript has no auth-strategy abstraction, so NoCredentials is a
TokenProvider whose getToken fails auth_required, with the message the
other SDKs give. Unsigned operations never call it; every other
operation fails before anything is sent. token stays required by type,
and a client given no token at all (a JavaScript caller's undefined) is
now refused with usage when it is built rather than failing with a
TypeError on its first send, so NoCredentials is the deliberate opt-in.

The generator now lets the verb stand in for idempotency when the
behavior model calls an operation neither readonly nor idempotent, as
the Go, Rust and Kotlin generators do. RevokeToken is the first DELETE
the model says that of, and without the verb its declared 429/503 retry
policy would never apply here; no other operation's safety changes.

The conformance runner gains headerAbsent, checked across every
recorded request; CreateToken and RevokeToken dispatch by name like
every other generated method, so they need no arm of their own. All six
oauth.json cases pass and no exclusion is added.
The generator reads x-hey-unsigned and x-hey-no-redirect onto each
operation, the way x-hey-lenient-success already flows, and writes them
onto the route as `unsigned` and `noRedirect`. Route gains both as public
fields defaulting to false, so a route the model does not mark, or one a
caller writes, is unchanged; a route writes only the flags it carries.

The OAuth tag becomes the `oauth` service, reached as `client.oauth`,
with `createToken` and `revokeToken`. A new [service_names] table in
names.toml keeps the acronym the PascalCase derivation would lose, so
the class is OAuthService and the hooks hear the service as OAuth, as
the tag and Kotlin's existing revocation name it, rather than Oauth.

This commit only carries the flags; the client honours them in the next.
…rect (red)

The tests run against client.OAuth() and the generated client, as a
caller reaches them. Red on the previous commit:

- CreateToken and RevokeToken go out with the client's Authorization
  header (and a strategy's DPoP proof), and the strategy or provider is
  asked to sign them.
- A 401 on either is refreshed and the request sent again; the generated
  client asks its AuthRefresher about it.
- A 301, 302, 303, 307 or 308 is followed, to another server or the same
  one, from the SDK's own HTTP client and from one the caller supplied.
- The generated parser reads a no-redirect operation's 3xx body.
- A NoCredentials client cannot send either operation.
- NewClient with neither a provider nor a strategy builds a client that
  panics at its first send instead of being refused.
- A client scoped to one account adds filtered_account_id to them.

Passing already, and pinning what the fix must keep: the documented
method, path (one .json) and JSON body of each; a 401 on an ordinary
operation still refreshes and resends; a NoCredentials client refuses
every signed request before sending it; only the two token operations
are unsigned or redirect-free in the metadata.
…ls (red)

The routes now say which go out unsigned and which follow no redirect;
these pin what the client has to do with that. Red on the client as it
stands:

- an_unsigned_route_goes_out_without_the_clients_token: CreateToken and
  RevokeToken carry the client's bearer, and the provider is asked.
- an_unsigned_route_carries_no_proof_from_the_clients_strategy: a
  DPoP-style strategy is asked and its Authorization and DPoP go out.
- a_401_on_an_unsigned_route_is_the_answer_and_refreshes_nothing: the
  token endpoint's 401 refreshes the client's credentials and resends
  the grant (2 requests).
- any_route_marked_unsigned_goes_out_unsigned: the same for a route of
  the test's own, so the flag is what counts, not the operation.
- a_redirect_on_create_token / revoke_token_is_an_error_with_its_status
  and any_route_marked_no_redirect_takes_the_redirect_for_its_answer:
  a 307, 302 or 308 is followed and the hop's answer taken.
- a_scoped_client_sends_an_unsigned_route_unscoped: RevokeToken from a
  scoped client carries filtered_account_id.
- no_credentials.rs does not compile: there is no NoCredentials.

The guards beside them pass already and must stay green: a 401 on a
signed route is still refreshed, an ordinary route still follows a
redirect, the generated methods send the documented method, path and
JSON body, and a builder with no credentials at all is still refused.
…red)

The natural TokenProvider::refresh for an application holding one
client sends CreateToken through that client. The refresh holds the
write half of the client's refresh lock while it runs, and every send
signs under the read half, so the grant waits for the refresh that is
waiting for it. Red: the call is still stuck after five seconds.

An unsigned send has nothing to sign, so it has no reason to take that
turn at all.
A route the model marks unsigned now goes out the way an unsigned()
storage request already did: the auth strategy is never asked, so no
Authorization, DPoP or other strategy header goes with it, the account
scope is left off, and a 401 is the answer, since the refresh is gated
on a request that carried credentials. The flag is read off the route,
so it holds for every route that carries it, not for OAuth paths.

A route marked noRedirect takes any 3xx as its answer: the transport is
told not to read the body, no hop is made, and the client throws an
error carrying the status, which no retry policy resends. Its body is a
credential that a 307 or 308 would carry wherever the Location points.

NoCredentials is the deliberate way to make a client with nothing to
sign with: it sends the unsigned routes, which never consult it, and
any other operation fails auth_required before anything is sent. The
initializers that take a token still refuse a blank one.

The redirect test now probes the bound the client hands the transport,
since an error's missing body alone would pass whether or not it was read.
An operation the model marks x-hey-unsigned is judged by what its body
carries, and the credentials a client would sign with may be the very
ones being exchanged or revoked. One marked x-hey-no-redirect carries
secrets a 307 or 308 would hand to the Location. CreateToken and
RevokeToken are both; nothing here names them, only the flags.

- The client's request editor neither asks the strategy or provider nor
  scopes the request to an account when the operation is unsigned; it
  sets only the User-Agent. An account-scoped client's transport leaves
  the request unscoped too, and the retry hook hears it so.
- The generated client never asks its AuthRefresher about an unsigned
  operation's 401: it is the answer, sent once.
- Every CheckRedirect the client installs, around a supplied client's
  policy as well, answers http.ErrUseLastResponse for a no-redirect
  operation, so the 3xx comes back as an error carrying its status. The
  generated parser closes that 3xx's body unread.
- NewClient refuses a client given neither a token provider nor a
  strategy, where it used to build one that panicked at its first send.
  NoCredentials is the deliberate way to build one with nothing to sign
  with.

The account-scope test now also checks the hooks hear a resend of the
revocation unscoped.

Verified: the tests red in the previous commit pass; make go-check.
The runner dispatches CreateToken and RevokeToken to the generated
client.oauth methods, on the same credentialed client every other case
gets, so the cases prove the token endpoint goes unsigned whatever the
client holds and that a 401 there is never refreshed.

headerAbsent passes when no recorded request carried the header, by any
case of its name and with any value, an empty one included. A failure
names the request, not the value, since the headers it guards are
credentials, and an assertion that names no header fails rather than
passing over nothing.
CreateToken and RevokeToken dispatch through client.OAuth(). Like every
operation the model marks unsigned, they run through the HEY client
rather than the bare generated one: whether a request goes out signed
is the HEY client's to decide, and the generated client signs with
whatever editors it is handed, as the runner's own does.

headerAbsent passes when no recorded request carried the header, under
any spelling of its name.

Against the previous commit's parent tree all six oauth.json cases fail;
with it, all 226 cases pass.
The README's Authenticate section says what client.oauth sends, why it
goes unsigned and follows no redirect, and how an app with no token yet
makes a client for it with NoCredentials; the snippet compiles against
the public API. AGENTS.md said swift/names.toml carries Kotlin's tables
less one; it now also carries [service_names], so that line says so.
…s routes

The generator reads x-hey-unsigned and x-hey-no-redirect onto each
operation, the way x-hey-lenient-success flows, and emits them as
Route.unsigned and Route.noRedirect. An Operation built from a route
takes both from it, so the client's existing unsigned and no-redirect
handling applies to every route the model marks rather than to OAuth
by name: no Authorization or DPoP proof, the strategy never asked to
sign, no account scope, a 401 that is never refreshed, and a 3xx that
is the answer, unread and unfollowed.

The hooks still hear a storage URL cut back to its origin, since it
carries its signature in the open, but hear a modelled unsigned
route's whole URL: HEY's token endpoint path carries nothing secret.

The OAuth tag's service would have come out "Oauth" (OauthService, the
hooks' "Oauth"). names.toml gains [service_names] to settle a
service's PascalCase name, so it is OAuthService, reached as
client.oauth, and heard as OAuth, as revokeToken was already heard.

NoCredentials is the strategy of a client that holds no token yet: it
sends the unsigned operations and refuses any other with auth_required
before anything goes out. A client with nothing configured is still
refused when built.

HeyClient.revokeToken stays, and now sends the generated RevokeToken:
a JSON body at /oauth/tokens.json rather than a form at /oauth/tokens.

The conformance runner dispatches CreateToken and RevokeToken and
checks headerAbsent across every recorded request.
The README said revokeToken sends a form to /oauth/tokens, which it no
longer does. It now describes the oauth service, what the unsigned and
no-redirect routes do, and how an app with no token builds a client
for them. AGENTS.md lists names.toml's new [service_names] table.
…the answer

A route marked unsigned is built and sent as it stands: the auth
strategy is not asked, so no Authorization or DPoP proof goes out and
a client without credentials can send it. Its 401 judges what is in the
body, not credentials the client could renew, so the hop counts as
unauthenticated and nothing is refreshed or resent. It goes out as no
identity, so a scoped client adds no filtered_account_id. And it does
not wait for the refresh lock's read half: a TokenProvider::refresh
that trades its refresh token through the same client sends CreateToken
from inside the write half, and would otherwise wait on itself.

A route marked no_redirect is not followed: its body carries a code and
verifier, a refresh token or the token to revoke, which a 307 or 308
would take to the Location. The 3xx is an error with its status, its
body unread, and not retried, since no route's policy names a 3xx.

Both are read from the route, so any route that carries the flags gets
the behaviour; nothing names the OAuth operations.

NoCredentials is the explicit way to build a client for the unsigned
operations alone. Its signing refuses with auth_required and no HTTP
status, before anything is sent. A builder given nothing at all is
still refused, now with a hint naming NoCredentials, so a forgotten
token is caught when the client is built.
CreateToken and RevokeToken dispatch to client.oauth(), on the runner's
usual client, which holds a token and, where a case asks, refreshable
credentials: the cases prove the token endpoint gets none of them.

headerAbsent passes when no recorded request carried the header. Every
request is checked, not just the first, since a header that arrived on
a resend is as much a leak as one on the first send; the header map's
lookup ignores case.

Before this, the six oauth.json cases failed on the unknown operations.
With the client made to sign unsigned routes again, three fail on
headerAbsent, so the assertion bites. Now 226 of 226 pass.
The Authenticate section now says what client.oauth() sends, that both
operations go out unsigned and follow no redirect, and how a client
with no token yet is built, with a snippet that refreshes and then
revokes. The snippet is the new oauth_tokens example as a whole
program, so CI compiles it with the others.

The retry section said any operation is resent after a 401 the
provider's refresh could answer; an operation HEY takes unsigned is
not, so it now says any operation HEY signs.
* oauth-rust:
  Document HEY's token operations and NoCredentials in the Rust README
  Run the OAuth conformance cases through the Rust runner
  Send unsigned routes without credentials and take no-redirect 3xx as the answer
  Test: a refresh may trade its token through the client it refreshes (red)
  Test: unsigned and no-redirect routes, and a client with no credentials (red)
  Generate HEY's OAuth token operations in Rust, with unsigned and no-redirect routes
  Test: the Rust generator carries unsigned and no-redirect routes (red)
* oauth-kotlin:
  Document Kotlin's OAuth operations and NoCredentials
  Generate Kotlin's OAuth service, sent unsigned and unredirected by its routes
  Test Kotlin's generated OAuth operations, unsigned and unredirected
* oauth-swift:
  Document the Swift OAuth service and NoCredentials
  Run the OAuth conformance cases against the Swift SDK
  Send unsigned routes bare and refuse no-redirect routes' 3xx in Swift
  Generate the Swift OAuth service with unsigned and no-redirect routes
  Test the Swift SDK's unsigned, no-redirect token endpoint
* oauth-typescript:
  Send HEY's token operations unsigned from TypeScript, following no redirect
  Test: TypeScript sends HEY's token operations unsigned, following no redirect (red)
…r check

The consumer check compiles the README's examples whole and names every
core public type beside Foundation, so the OAuth snippet and the new
NoCredentials strategy join them.
A provider whose refresh calls createToken with a refresh_token grant on
the same client is the natural way to renew HEY's tokens. The 401'd
request waits on that refresh, so an unsigned request that also waited
on the shared in-flight refresh would never be sent and the refresh
would never finish.

Green on arrival: an unsigned request never touches the credentials,
neither reading a token nor joining a refresh. Made to await the
in-flight refresh, the test times out at its 2 s bound instead of
hanging the suite.
A provider that renews by sending CreateToken on the same client sends
it while that client's refresh is in flight. Go signs nothing for an
unsigned operation, so the token request never enters the refresh gate
and cannot wait on the refresh it belongs to: the test passes as the
code stands. It fails within its 5 s bound, rather than hanging, if the
unsigned path is made to pass through the gate.

The hooks hear the token operations as OAuth.CreateToken and
OAuth.RevokeToken, resource type "token", as the other SDKs report them;
that test passes as well.
An app's token provider may refresh by calling client.oauth.createToken
with a refresh_token grant on the client it signs for. The refresh runs
holding the client's signing lock, so a token request that waited on
that lock would wait on itself; in Rust it did. In Swift an unsigned
request never signs and so never takes the lock, and the read completes:
401, the token request, then the read resent with the new token.

The read is bounded by an expectation's timeout, so a regression fails
in five seconds rather than hanging the suite. Making unsigned requests
take the lock for a moment fails the test that way.
jeremy added 21 commits October 22, 2026 00:36
When a failure's JSON body has a non-empty string error_description, it
joins the error's message after what the status says: "API error: 400:
install_id doesn't identify this install. Upgrade your client." The
error's class, code, status, hint and retryability are what they were,
and a body without a description keeps the message it always had.

The OAuth code needed no new accessor: it is the hint, as any body's
"error" already was, so a 401 invalid_grant (a dead refresh token) and
a 401 invalid_client (a misconfigured client) are told apart there.
The hint's documentation now says so.

The conformance runner checks errorMessageContains against the
caller's message, and fails a case whose operation did not fail.
A refusal whose JSON body carries a non-blank error_description now ends
its message with it: "API error: 400: install_id doesn't identify this
install. Upgrade your client." The code, status, retryability and hint
each status maps to are unchanged, so the OAuth code stays the hint,
where any body's `error` has always gone, and the whole body stays on
the error. A body without a description keeps the message it had.

The conformance runner gains errorMessageContains, read off the error's
message rather than its hint, and AGENTS.md's Swift section names the
route flags and NoCredentials.
…ent's redirects

HEY's token endpoint refuses with RFC 6749's {"error",
"error_description"}, and CheckResponse read no body but a 422's, so
"install_id doesn't identify this install" reached no caller and a 401
invalid_grant read the same as a 401 invalid_client.

CheckResponse now reads any other error status's body, bounded and put
back as a 422's is: a non-empty string error_description becomes the
error's Message, and the error member its OAuthCode. The code, status
and retryability stay the status's, and a body without a description
leaves the error as it was, since HEY's other refusals send {"error"},
{"errors"} or {"message"}.

The generated client handed a plain *http.Client, its default included,
now sends a no-redirect operation through a copy that stops at the first
redirect, so a caller using it alone no longer sends the code and its
verifier to a 307's Location. Unsigned stays a description there:
leaving the credentials off is the request editors' job, and the
OperationMetadata and CheckResponse docs now say only hey.Client's
editor does it.

Verified: the tests red in the previous commit pass; go test ./...
…TS.md

Operation.noRedirects() had one caller, the hand-written revocation,
which now sends the generated RevokeToken; a route's noRedirect is what
turns redirects off. AGENTS.md's Kotlin section lists unsigned and
noRedirect among what Routes.kt carries, says what they do, and names
NoCredentials. [service_names] was already in its names.toml list.
* oauth-kotlin:
  Drop Kotlin's unused noRedirects and describe the route flags in AGENTS.md
  Carry an OAuth refusal's description in Kotlin's error message
  Regenerate Kotlin's models for the token endpoint's refusals
  Test that Kotlin errors carry an OAuth refusal's description
HEY's token endpoint refuses with RFC 6749's {"error", "error_description"},
and the error took the code alone as its message: "install_id doesn't
identify this install. Upgrade your client." never reached the caller,
and a 401 invalid_grant could be told from invalid_client only by
reading the message.

- Any error body with a non-empty error_description now reads
  "<error>: <error_description>" (or the description alone). Bodies
  without one keep their messages; code, status and retryable are
  unchanged.
- HeyError gains oauthError, the RFC 6749 code, set only for an
  operation whose refusals take that shape. HeyError had no field for
  detail HEY sent outside a 422's fieldErrors, and elsewhere HEY's
  {"error"} is a sentence, so the code is not inferred from the body.
- The generator marks those operations oauthErrors: any non-2xx
  response whose schema carries error_description beside error. Today
  that is CreateToken and RevokeToken.

The conformance runner gains errorMessageContains; all seven oauth.json
cases pass. The refusal tests now fail if the call succeeds rather than
reading fields off a response, which also lets them typecheck.
VALIDATION.md's operation count was stale at 136; it is 139.
* oauth-typescript:
  Carry a TypeScript token refusal's sentence and OAuth code to the caller
  Test: a TypeScript token refusal carries HEY's sentence and its OAuth code (red)
  Regenerate the TypeScript tree for the token endpoint's OAuth refusals
The assertion needs the operation to have failed, and the error's text
to contain the expected words. Without the previous commit's
CheckResponse, three oauth.json cases fail on it; with it, all seven
pass, and all 227 cases.
The assertion passes when the operation failed and its error, as a
caller prints it (message and hint), contains the expected words.

Before it, three oauth.json cases failed on the unknown assertion; with
the client's previous error mapping, all three fail on it, reading
"authentication required: invalid_grant" where HEY said "Invalid
refresh token.". Now 227 of 227 pass.
* oauth-go:
  Check errorMessageContains in the Go conformance runner
  Surface a token refusal's description and code, and stop the bare client's redirects
  Test: Go surfaces a token refusal's description and code, and the bare client follows no redirect (red)
  Regenerate the Go client with the token endpoint's modelled refusals
* oauth-rust:
  Add errorMessageContains to the Rust conformance runner
  Say what Rust's prepare holds and what a Route carries now
  Read an OAuth refusal's description into the Rust error
  Test: an OAuth refusal reads as HEY's description, its code kept (red)
  Regenerate the Rust tree for the token endpoint's modelled refusals
…red)

exchangeCode and refreshToken talk to a token endpoint, which refuses in
RFC 6749's shape by definition. Since the description joined the
message, "invalid_grant" is no longer the whole message, and the grants
build their error without the oauth flag, so the code reaches the
caller nowhere: an app that signs out on invalid_grant stops signing
out. Red here: both grants' 401 invalid_grant and invalid_client
refusals carry no oauthError.

Discovery's {"error"} naming no OAuth code passes and guards it.
exchangeCode and refreshToken send to a token endpoint, so their
refusals are RFC 6749's and the body's error is the code a caller acts
on: they now build the error as an OAuth one, and a 401 invalid_grant
carries oauthError "invalid_grant" beside the description in the
message. Discovery shares the request path but is not a token endpoint,
so it passes nothing and its {"error"} stays a sentence.
oauth_refusal went in between server_message's doc comment and the
function, so it carried both comments and server_message had none.
…ts-modelled

* oauth-typescript:
  Keep the OAuth code on the hand-written grants' refusals
  Test: the hand-written OAuth grants keep their refusal's OAuth code (red)

* oauth-rust:
  Put server_message's doc comment back on server_message
error_description is optional in RFC 6749 and in the model, and a
refusal carrying only its code left OAuthCode empty, so a dead refresh
token read the same as a misconfigured client.
CheckResponse takes a body's "error" for an OAuth code only beside an
error_description, since HEY's other endpoints put a sentence there.
Every refusal the token endpoint answers is RFC 6749's, whose
description is optional, so the token operations keep the code
themselves.
…l is pending (red)

The client awaited the unread body's cancel before throwing the 3xx, and
a fetch that tees or clones its responses can leave that pending past
anything the request's timeout bounds.
The 3xx is known once its status is, so the client releases the unread
body without awaiting the cancel and swallows its rejection, rather than
holding the answer until a cancel settles.
Fails today: behavior-model.json says nothing about either, so a
consumer that reads only the model cannot tell the token endpoint's
operations from any other.
Every operation now says whether HEY takes it without the caller's
credentials and whether it follows no redirect, false unless it declares
@heyUnsigned or @heyNoRedirect. CreateToken and RevokeToken are the two
that do. The generators keep reading the traits from openapi.json, so no
generated code changes.
Copilot AI balanced review requested due to automatic review settings October 24, 2026 18:28
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Oct 24, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review 🔄 Running since 2026-10-24T22:07:33.147113Z 940395d New commits
🔒 Security Review ✅ Completed 2026-10-24T22:09:34.535815Z 940395d New commits
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 Approved

The additive metadata matches OpenAPI, existing readers tolerate the new keys, and no blocking issues remain.

No open issues 🎉

What changed in this PR

Completes #278’s metadata support by exposing credential and redirect flags to consumers that read only behavior-model.json, without changing SDK behavior.

Changes:

  • Emits unsigned and no_redirect for every operation, defaulting to false.
  • Sets both flags to true for CreateToken and RevokeToken.
  • Adds OpenAPI consistency tests and documents the model fields.

[!TIP]
If you aren't ready for review, convert to a draft PR.
Click "Convert to draft" or run gh pr ready --undo.
Click "Ready for review" or run gh pr ready to reengage.

File Description
typescript/​tests/​generator.test.ts Checks flag consistency with OpenAPI and token-operation values.
spec/​hey-traits.smithy Documents the traits’ behavior-model field names.
scripts/​generate-behavior-model Emits both flags from Smithy traits.
behavior-model.json Adds explicit flag values to every operation.
AGENTS.md Documents defaults, meaning, and generator data sources.

Review effort: Balanced

Base automatically changed from oauth-endpoints-modelled to main October 24, 2026 22:06
* origin/main:
  Generate HEY's OAuth token endpoint in every SDK, sent unsigned and unredirected (#278)
  Trust discovered OAuth metadata only from the issuer it names (#277)

# Conflicts:
#	behavior-model.json
#	spec/hey-traits.smithy
#	typescript/tests/generator.test.ts
@jeremy
jeremy merged commit 93b5ce9 into main Oct 24, 2026
35 checks passed
@jeremy
jeremy deleted the behavior-model-oauth-flags branch October 24, 2026 22:21
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants