fix(client): oauth.applications.register declares redirect_uris optional, matching the body schema of the route it posts to (#17215) - #17834
Conversation
…nal, matching the route's own body schema (#17215) `ObjectStackClient.oauth.applications.register` declared `redirect_uris` required. `POST /api/v1/auth/oauth2/create-client` is mounted verbatim from `@better-auth/oauth-provider`, whose body schema declares it optional, so a request the route accepts had no spelling through the SDK. Re-introspected at runtime rather than read off a `.d.ts`: instantiate `oauthProvider()`, walk `endpoints`, read `options.body`. At the installed 1.7.3 all 21 members are optional and `body.safeParse({ client_name })` succeeds with `redirect_uris` absent. The required-ness was never a deliberate guard: it is residue from the method's first commit, which declared `client_name` required too; the same-day follow-up relaxed `client_name` and left this one behind. No comment, test, ADR or review thread asserts a reason for it. Nothing else on the signature moves — the other ten members are byte-identical. Claude-Session: https://claude.ai/code/session_01TSf4DV7ziu4V5j73e46b7c Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TSf4DV7ziu4V5j73e46b7c Co-authored-by: Claude <noreply@anthropic.com>
…uth-register-redirect-uris-optional
📓 Docs Drift CheckNothing in this diff resolved to a documentable surface (no symbol, route or SDK anchor derived from 1 changed package(s)), so this run has no opinion about the docs. What this run could not see
Coarse fallback — 15 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): |
Contract reviewHead reviewed: Verdict: PASS. ① Derived judgments — the accepted set and the public surface, named one by oneThe whole source change is one character plus prose:
⭐ The evidence for the widening is a runtime re-introspection, ⛔ not a ② Semver —
|
Fixes #17215
Clause-②: yes
Declared by the dispatching
domain:cliseat (#6024) and unchanged by the delivered diff: relaxingredirect_urisenlarges a published SDK method's accepted set, which is the widening direction and is graded conservatively regardless of the fact that it restores parity with the vendor.needs:contract-reviewis on the card; this PR does not enqueue until an at-tier contract review of record exists, which is the PM seat's half and is ⛔ not satisfied by green CI.⭐ The falsifier, resolved FIRST
Triage (
5621351376) fenced this card: before writing the?, establish whetherredirect_uris' required-ness is a deliberate guard rather than an oversight — RFC 7591 makesredirect_urisrequired for authorization-code clients, so a wrapper stricter than a permissive vendor may be correct on purpose. An assertion found ⇒ stop, write no code.Verdict: no such assertion exists anywhere. Every search below is reported with its count and a lit control, because a bare "found nothing" is not a result.
grep -n redirect_uris packages/client/src/index.ts, every hit read in contextredirect_urisgit grep -ccreate-client-> 8 filesgit grepindocs/adr/redirect_uris, 0 hits for7591Status-> 136 ADR filesgit grep 7591RFC-> 150 filesdeliberately/intentionally required,stricter than,guard rather than,on purpose)git grep -niEover client, plugin-auth, docs, content, changesetsdeliberatelyinpackages/client-> 51 in CHANGELOG aloneclient_nameandscopevalue types one screen up — so the idiom exists and was deliberately not applied hereoauth-provider-schema-parity.test.tsredirect_uris,create-client,body/pulls/17209/commentsand/reviews/pulls/comments-> 100 returned, so the channel is lit/commits/{sha}/pulls/issues/15447/comments,/issues/17210/commentsredirect_urislines — every one calls it a measured mismatch / divergence to reportWhat the history shows instead of a guard. The member was required from the method's first commit,
170ecfa4c("feat: add OAuth application management features"), which declaredclient_name: stringrequired too. Nine hours laterbc5bd719b("feat(auth): update OAuth application handling and consent flow with improved parameter management") re-pointed the method at/oauth2/create-client, relaxedclient_nameto optional, added the three bogus members #15447 later removed — and leftredirect_urisuntouched as unchanged context, with no comment and a one-line commit message. Both are direct commits with no PR. Nothing, anywhere, ever stated a reason.⇒ Per triage's own branch: no assertion found ⇒ proceed; the
?restores parity.The dispatch order handed over two; reading every hit in context turned up three more. None speaks to whether the REQUEST member may be omitted:
index.ts"Onlyclient_idandredirect_urisare always present …redirect_urisis unconditional" — the response projection's serialiser.index.ts"redirect_urisis always[]here" — the public response projection.index.ts, inside this very method's docblock: "the array the vendor schema requires …redirect_urismust arrive pre-split". About the element shape (array vs the Console textarea), not about presence.auth-plugin.ts: "better-auth's Zod body schema requiresredirect_uris: string[]" — about the type, and it names its own failure mode,expected array, received string. It is the Console wrapper's split, in another package.namewhen registering an OAuth client #17210 comment5642689415calls the request type "the vendor shape" while pointing atredirect_uris: string[]— an acceptance row asserting that a prose-only PR moved no type.Acceptance 2 — the vendor schema re-introspected at RUNTIME
⛔ Not read from a
.d.ts. Method as the card prescribes:createRequirethe package,await importthe resolved entry, instantiate, walkendpoints, select the endpoint whosepathcontainscreate-client, readoptions.body.1.7.2; the installed version is1.7.3(packages/plugins/plugin-auth/package.jsonpins1.7.3, lockfile resolves1.7.3, and the package's ownpackage.jsonread off the resolved entry's directory reports1.7.3at runtime). Re-measuring was therefore load-bearing, not ceremony.oauthProvider()throws (Cannot read properties of undefined (reading 'clientRegistrationAllowedScopes')), so it was instantiated the way this repo instantiates it,oauthProvider({ loginPage, consentPage }).The row under test, read today, with optionality driven rather than inferred:
safeParse(undefined))redirect_urisAll 21 members are optional, and a body omitting
redirect_urisentirely parsesok. The divergence the card describes is confirmed at 1.7.3.[]will do — the vendor refuses an empty array, so omitting the member and passing[]are different requests and only the first is legal.Acceptance 3 — a DRIVEN reading that the call is now expressible
⛔ Not a type-level argument; compiled.
pnpm --filter @objectstack/client typecheck-> exit 0, and that script is two halves —tsc --noEmit && pnpm check:test-typecheck— the second of which compiles the test layer undertsconfig.test.json.New pin
registerRedirectUrisOptionalPin17215, compiled and never invoked, in the file the docblock already names:expectTypeOfonRegisterRequest['redirect_uris'], asserted equal to the union ofstring[]andundefined— the parity assertion, in the file's existing house idiom (spelled in words here because the body sanitiser eats angle-bracket fragments; the file carries the real generic syntax);register({ client_name: 'PROBE-17215-OMITTED' })— previously inexpressible;register({})— the emptiest legal call;register({ client_name, redirect_uris })— still compiles, unchanged;it()proving the SDK synthesises no default: the body sent is exactly{"client_name":"PROBE-17215-OMITTED"}with noredirect_uriskey.keyofkey-set equality gives this change ZERO coverage —keyofis insensitive to optionality, so it reads the same eleven names in both states. That is why a new assertion was owed rather than relying on the one already there, and it is stated in the new pin's docblock. ⛔ The equality itself is untouched:git diffon that test file has 0 removed lines — the file is purely additive.Ablation, and a correction I am reporting rather than hiding
?back to required, ranpnpm --filter @objectstack/client exec tsc --noEmit, and got exit 0. The cause is not the pin:packages/client/tsconfig.jsoncarries"exclude": [… "**/*.test.ts"], so the baretscprogram never contains the pin file. Proved with--listFiles: 0 occurrences of the pin file and 0.test.tsfiles in that program, against a lit control of 1 forsrc/index.ts. That run is NOT MEASURED, not a pass.Re-run against the program that actually compiles the test layer:
Direction RED as predicted; count 4 where I predicted 3, and the extra is nameable: the runtime
it()I added also omits the member, so it stops compiling too. Both legs ran from the committed state undertrap … EXIT INT TERMwith absolute paths; restore proved by blob equality withHEAD:packages/client/src/index.tsand emptygit diff HEADand emptygit status --porcelain. No dist preflight applies — the pin imports./index, a relative source specifier inside its own package, so no built artifact sits in the resolution path (check:test-source-aliasexit 0).Acceptance 4 — nothing else in the signature moves
Member-by-member against the merge base, extracted from both revisions and compared:
Acceptance 5 — changeset
.changeset/17215-oauth-register-redirect-uris-optional.md, minor: a published package's public surface moves, per the maintainer's ruling of 2026-09-04 (decision batch #35) that such a change takes at leastminor— the same grade the sibling #15447 carried. ⛔ Not breaking in this direction: relaxing a required member to optional keeps every existing call valid, so the changeset carries no BREAKING banner andcheck:adr-0087-registrationcorrectly reports no declared-breaking changeset (exit 0).Verification
Exit codes captured by redirect-then-
$?, ⛔ never through a pipe. Lock runs read their ownVERDICT command-exitline.pnpm --filter @objectstack/client typecheck— exit 0 (test layer compiles undertsconfig.test.json; 0 files / 0 errors intest-typecheck-debt.json).pnpm --filter @objectstack/client test— exit 0, 43 files / 519 tests passed.pnpm --filter '@objectstack/client^...' build— exit 0. Required first: on an unbuiltdistthe typecheck reported 17 phantomTS2307 Cannot find module '@objectstack/spec/*'errors that have nothing to do with this diff.node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack(⛔ never a hand list) and reconciled:✓ 60 derived famil(ies) accounted for — 59 run, 1 NOT-MEASURED, 0 UNRUN.scripts/pm/check-half-states.mjs; rather than run a list about a tree nobody is on,origin/mainwas fetched and merged (clean, noos-regendeferral, and neither incoming file overlaps this diff) and the families re-derived at834e79059.pnpm check:dual-build-cjs-loads— exit 3,PREREQUISITE NOT MET: it reads built output and 32 packages have nodist/. ⛔ Declared NOT MEASURED, never counted as a pass; its own text says "This is NOT a pass: nothing was measured". CI checks out fresh and builds. This diff changes one type annotation and adds test code, so it emits no JavaScript for that gate to read.check:skill-examplesexit 1 (packages/client-react/distheld no declarations) -> after buildingclient-react+client, exit 0, 258 prose examples type-check across 3 surfaces;check:type-check-debtexit 3 (the whole-repo re-measure OOMed under--max-old-space-size=4096) -> at 8192, exit 0, "5 ledger entr(ies) re-measured, 55 raw tsc error(s), none above its recorded number".pnpm lintpopulation, run in full rather than narrowed:eslint . --no-inline-config— exit 0, 6645 files checked, 0 errors, 0 warnings, at834e79059. Type-aware linting is not enabled (eslint.config.mjsstates "noparserOptions.project, no typed@typescript-eslintrules"), so per-file verdicts are independent.Acceptance notes
[]asymmetry, noted not filed. The TYPE cannot express "non-empty array", soregister({ redirect_uris: [] })compiles and the vendor refuses it at runtime. This is unchanged by this PR — it was equally true while the member was required, which is also why the old required-ness guarded nothing the vendor did not already guard better. Recorded in the docblock and in the new pin so the compiling call is not mistaken for a legal one.return-type-precision.test.tspassesredirect_urisat both its call sites (including[]) and both still compile either way; no change was needed there.POST /oauth2/registerDCR, and the ObjectStacksys-oauth-application/registermount) — is ⛔ deliberately not taken here: neither door's shape moves, and client SDK: no method builds POST /api/v1/auth/sys-oauth-application/register, the one door that honours a plainnamewhen registering an OAuth client #17210 already ruled the mount staysserver-only.Generated by Claude Code