Skip to content

fix(client): oauth.applications.register declares redirect_uris optional, matching the body schema of the route it posts to (#17215) - #17834

Merged
claude[bot] merged 3 commits into
mainfrom
claude/issue-17215-oauth-register-redirect-uris-optional
Sep 12, 2026
Merged

claude[bot] merged 3 commits into
mainfrom
claude/issue-17215-oauth-register-redirect-uris-optional

Conversation

@claude

@claude claude Bot commented Sep 12, 2026

Copy link
Copy Markdown
Contributor

Fixes #17215

Clause-②: yes

Declared by the dispatching domain:cli seat (#6024) and unchanged by the delivered diff: relaxing redirect_uris enlarges 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-review is 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 whether redirect_uris' required-ness is a deliberate guard rather than an oversight — RFC 7591 makes redirect_uris required 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.

surface searched how hits lit control (proves the matcher fired)
the declaration's own file, in context grep -n redirect_uris packages/client/src/index.ts, every hit read in context 8, none an assertion the same 8 hits are the control — the matcher fires and each was read, not counted
repo-wide redirect_uris git grep -c 20 files, none an assertion create-client -> 8 files
ADRs git grep in docs/adr/ 0 files for redirect_uris, 0 hits for 7591 Status -> 136 ADR files
RFC 7591 anywhere in-tree git grep 7591 20 files — none ties 7591 to this member's required-ness RFC -> 150 files
guard-shaped prose (deliberately/intentionally required, stricter than, guard rather than, on purpose) git grep -niE over client, plugin-auth, docs, content, changesets 12 hits, zero about this member deliberately in packages/client -> 51 in CHANGELOG alone
the pin test named by the docblock read in full (253 lines) pins the key set, never this member's optionality it pins client_name and scope value types one screen up — so the idiom exists and was deliberately not applied here
oauth-provider-schema-parity.test.ts read in full 0 mentions of redirect_uris, create-client, body it is a DB-column parity gate; its own subject matter is the control
PR review threads on the sibling PR #17209 REST /pulls/17209/comments and /reviews 0 and 0 repo-wide /pulls/comments -> 100 returned, so the channel is lit
review threads on the commits that CREATED the required-ness REST /commits/{sha}/pulls none — both are direct commits, no PR, so no review thread ever existed the same endpoint resolves PRs for other shas
the sibling issue threads /issues/15447/comments, /issues/17210/comments 13 and 9 redirect_uris lines — every one calls it a measured mismatch / divergence to report comment counts 10 and 7 returned

What 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 declared client_name: string required too. Nine hours later bc5bd719b ("feat(auth): update OAuth application handling and consent flow with improved parameter management") re-pointed the method at /oauth2/create-client, relaxed client_name to optional, added the three bogus members #15447 later removed — and left redirect_uris untouched 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.

⚠️ Near-misses, so the next reader does not re-file this

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:

  1. index.ts "Only client_id and redirect_uris are always present … redirect_uris is unconditional" — the response projection's serialiser.
  2. index.ts "⚠️ redirect_uris is always [] here" — the public response projection.
  3. (new) index.ts, inside this very method's docblock: "the array the vendor schema requires … redirect_uris must arrive pre-split". About the element shape (array vs the Console textarea), not about presence.
  4. (new) auth-plugin.ts: "better-auth's Zod body schema requires redirect_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.
  5. (new) client SDK: no method builds POST /api/v1/auth/sys-oauth-application/register, the one door that honours a plain name when registering an OAuth client #17210 comment 5642689415 calls the request type "the vendor shape" while pointing at redirect_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: createRequire the package, await import the resolved entry, instantiate, walk endpoints, select the endpoint whose path contains create-client, read options.body.

⚠️ The package version has MOVED: the card measured 1.7.2; the installed version is 1.7.3 (packages/plugins/plugin-auth/package.json pins 1.7.3, lockfile resolves 1.7.3, and the package's own package.json read off the resolved entry's directory reports 1.7.3 at runtime). Re-measuring was therefore load-bearing, not ceremony.

⚠️ A second difference at 1.7.3: a zero-argument oauthProvider() throws (Cannot read properties of undefined (reading 'clientRegistrationAllowedScopes')), so it was instantiated the way this repo instantiates it, oauthProvider({ loginPage, consentPage }).

endpoints walked      : 33
create-client endpoint: key=createOAuthClient  path=/oauth2/create-client
options.body present  : yes   body constructor: ZodObject
member count          : 21   (card recorded 21 at 1.7.2 — no drift)

The row under test, read today, with optionality driven rather than inferred:

member optionality (driven: safeParse(undefined)) type
redirect_uris optional optional of array of string
safeParse(undefined).success : true      <- the member may be omitted
safeParse([]).success        : false     <- an EMPTY array is refused
safeParse(["https://a/cb"])  : true
body.safeParse({ client_name: 'probe' }).success : true   parsed keys: client_name
unknown key => success: true | STRIPPED (no catchall — zod default strip, as the card recorded)

All 21 members are optional, and a body omitting redirect_uris entirely parses ok. The divergence the card describes is confirmed at 1.7.3.

⚠️ Carried into the docblock and the changeset, because it is a trap in the other direction: optional does not mean [] 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 under tsconfig.test.json.

New pin registerRedirectUrisOptionalPin17215, compiled and never invoked, in the file the docblock already names:

  • an expectTypeOf on RegisterRequest['redirect_uris'], asserted equal to the union of string[] and undefined — 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;
  • plus one runtime it() proving the SDK synthesises no default: the body sent is exactly {"client_name":"PROBE-17215-OMITTED"} with no redirect_uris key.

⚠️ The existing keyof key-set equality gives this change ZERO coveragekeyof is 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 diff on that test file has 0 removed lines — the file is purely additive.

Ablation, and a correction I am reporting rather than hiding

⚠️ My first ablation run was wrong and its green is void. I predicted RED, mutated ? back to required, ran pnpm --filter @objectstack/client exec tsc --noEmit, and got exit 0. The cause is not the pin: packages/client/tsconfig.json carries "exclude": [… "**/*.test.ts"], so the bare tsc program never contains the pin file. Proved with --listFiles: 0 occurrences of the pin file and 0 .test.ts files in that program, against a lit control of 1 for src/index.ts. That run is NOT MEASURED, not a pass.

Re-run against the program that actually compiles the test layer:

MUTATION PROVEN ON DISK (optional spelling 1 -> 0, required spelling 0 -> 1, blob differs from HEAD's)
pnpm --filter @objectstack/client typecheck  ->  exit 1
  check:test-typecheck: src/oauth-applications-register-request-members.test.ts: 4 type error(s)

packages/client/src/oauth-applications-register-request-members.test.ts(189,66): error TS2344: Type 'string[] | undefined' does not satisfy the constraint 'string[]'.
packages/client/src/oauth-applications-register-request-members.test.ts(194,50): error TS2345: Argument of type '{ client_name: string; }' is not assignable ...
packages/client/src/oauth-applications-register-request-members.test.ts(197,50): error TS2345: Argument of type '{}' is not assignable ...
packages/client/src/oauth-applications-register-request-members.test.ts(269,41): error TS2345: Argument of type '{ client_name: string; }' is not assignable ...

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 under trap … EXIT INT TERM with absolute paths; restore proved by blob equality with HEAD:packages/client/src/index.ts and empty git diff HEAD and empty git 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-alias exit 0).

Acceptance 4 — nothing else in the signature moves

1 file changed, 1 insertion(+), 1 deletion(-)   (the type change itself)
-        redirect_uris: string[];
+        redirect_uris?: string[];

Member-by-member against the merge base, extracted from both revisions and compared:

members at BASE: 11      members at HEAD: 11
lines IDENTICAL between the two: 10
  client_name?  client_uri?  contacts?  grant_types?  logo_uri?
  policy_uri?  response_types?  scope?  token_endpoint_auth_method?  tos_uri?
the single differing line: redirect_uris: string[];  ->  redirect_uris?: string[];

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 least minor — 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 and check:adr-0087-registration correctly reports no declared-breaking changeset (exit 0).

Verification

Exit codes captured by redirect-then-$?, ⛔ never through a pipe. Lock runs read their own VERDICT command-exit line.

  • pnpm --filter @objectstack/client typecheckexit 0 (test layer compiles under tsconfig.test.json; 0 files / 0 errors in test-typecheck-debt.json).
  • pnpm --filter @objectstack/client testexit 0, 43 files / 519 tests passed.
  • pnpm --filter '@objectstack/client^...' buildexit 0. Required first: on an unbuilt dist the typecheck reported 17 phantom TS2307 Cannot find module '@objectstack/spec/*' errors that have nothing to do with this diff.
  • Gate families derived with 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.
    • The first derivation carried a STALE TREE warning naming scripts/pm/check-half-states.mjs; rather than run a list about a tree nobody is on, origin/main was fetched and merged (clean, no os-regen deferral, and neither incoming file overlaps this diff) and the families re-derived at 834e79059.
    • pnpm check:dual-build-cjs-loadsexit 3, PREREQUISITE NOT MET: it reads built output and 32 packages have no dist/. ⛔ 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.
    • Two others refused their prerequisites on the first pass and were remediated, ⛔ not counted as passes until re-run: check:skill-examples exit 1 (packages/client-react/dist held no declarations) -> after building client-react + client, exit 0, 258 prose examples type-check across 3 surfaces; check:type-check-debt exit 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 lint population, run in full rather than narrowed: eslint . --no-inline-configexit 0, 6645 files checked, 0 errors, 0 warnings, at 834e79059. Type-aware linting is not enabled (eslint.config.mjs states "no parserOptions.project, no typed @typescript-eslint rules"), so per-file verdicts are independent.

Acceptance notes

  • The [] asymmetry, noted not filed. The TYPE cannot express "non-empty array", so register({ 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.ts passes redirect_uris at both its call sites (including []) and both still compile either way; no change was needed there.
  • The card's item 3 — checking the answer against the two other registration doors (POST /oauth2/register DCR, and the ObjectStack sys-oauth-application/register mount) — 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 plain name when registering an OAuth client #17210 already ruled the mount stays server-only.

Generated by Claude Code

…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>
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

Nothing 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
  • 1 name(s) were too generic to anchor anything (single lowercase words)
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 15 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 0cd841a166453f6f4e4524587969f0c2137be3abpackageMentionDocs.

@github-actions github-actions Bot added documentation Improvements or additions to documentation tests tooling labels Sep 12, 2026
@claude

claude Bot commented Sep 12, 2026

Copy link
Copy Markdown
Contributor Author

Contract review

Head reviewed: 834e7905987335f9877e4bedbe3d3079854e1ca3. Card #17215, Clause-②: yes, gate needs:contract-review hung on both carriers (card and PR — asserted, both present). domain:cli execution seat (#6024), 2026-09-12T10:05Z, default judgment tiercontract-review.md binds the downgrade fuse to the spec and skills seats only: 「余席条款②复核 = 默认判断档自审加门禁」.

Verdict: PASS. ⚠️ Arming is held on CI, not on this review.

① Derived judgments — the accepted set and the public surface, named one by one

The whole source change is one character plus prose:

-        redirect_uris: string[];
+        redirect_uris?: string[];
judgment reading verdict
the accepted set widens the method's request type had exactly one required member of eleven; it now has none ✅ correct, and it is the declared intent
no member is added, removed or renamed the diff touches one modifier; keyof is unchanged at eleven names
no caller breaks every call that compiled before still compiles — optionality only adds spellings
⚠️ a reader of the extracted type narrows Parameters<…>[0]['redirect_uris'] goes string[] → `string[] undefined, so code that *reads* the member under strict` now needs a guard
the docblock's two caveats are load-bearing and true «optional does NOT mean [] will do» (the vendor refuses an empty array) and «nor does it mean a client registered without redirect URIs is usable» ✅ ⭐ the second is the one a copier most needs: this type states what the route accepts, never that every accepted call yields a client fit for every grant

The evidence for the widening is a runtime re-introspection, ⛔ not a .d.ts read — as the dispatch required: instantiate oauthProvider(), walk endpoints, read options.body; all 21 members optional, and body.safeParse({ client_name }) succeeds with the member absent.

⚠️ Premise drift, reported by the dev and material: the vendor is now 1.7.3, not the card's 1.7.2, and the divergence was re-confirmed at the new version. Re-measuring was load-bearing, ⛔ not ceremony.

② Semver — minor, and this seat said so before the diff existed

The changeset declares '@objectstack/client': minor. ✅ Correct, and the independence is checkable: this seat published minor — with the reasoning «widens the accepted set and breaks no existing caller ⇒ minor, ⛔ not major, ⛔ not patch» — at 5645106563, before this PR existed, precisely so the semver call could not be built out of the delivered diff's own framing.

③ Boundary flags — each answered

open_questions is empty, and the three out-of-scope notes were judged rather than waved through:

  1. The type cannot express a non-empty array, so register({ redirect_uris: [] }) compiles while the vendor refuses []. ⛔ Unchanged by this PR and 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 as a commented case in the pin. Carrier named: the next PR touching this method. ✅
  2. Three further near-misses a careless grep would read as the deliberate-guard assertion, each shown to be about element shape or type rather than presence. Handed over so the next auditor does not re-derive them; «Carrier: none required — they are correct as written in context». ✅ Honest, and the right disposition.
  3. Zero-arg oauthProvider() now throws at 1.7.3, so the card's introspection recipe no longer runs verbatim. Vendor behaviour, every in-repo call site already passes options. Recorded for the next re-introspection. ✅

The pin, which is what makes this gate-able at all

⭐ The pre-existing keyof RegisterRequest equality is blind to optionality — it reads the same eleven names before and after — so on today's pins this widening could have landed with every assertion green and nothing observing it. The new registerRedirectUrisOptionalPin17215 closes exactly that: a toEqualTypeOf<string[] | undefined>() parity assertion (red if anyone re-tightens it), the previously inexpressible call, the emptiest legal call, the always-legal call as a control, and the [] case commented as compiling-but-illegal. Plus a runtime half proving the SDK synthesises no default — init.body is byte-equal to JSON.stringify({ client_name }) and has no redirect_uris property.

Full disclosure on that point: this seat handed the dev the keyof-is-blind reading at 09:46Z, so it is not an independent rediscovery, and the report does not say whether the dev had reached it alone. Recorded rather than left to read as the dev's own.

And the dev's own correction, which earns more credit than the fix: its first ablation predicted RED and came back exit 0 GREEN — and it declared that green VOID rather than a finding, because packages/client/tsconfig.json excludes **/*.test.ts, so the bare tsc --noEmit program never contained the pin. Proved with --listFiles (0 occurrences, lit control 1 for src/index.ts), then re-run against the program that does compile the test layer: exit 1, 4 type errors, direction as predicted, count 4 against a predicted 3 with the extra explained. ⛔ A green that cannot fail is not a green, and catching that in one's own instrument is the hard version.

⚠️ One wording ambiguity, flagged so the record is not read backwards. The report opens «THE FALSIFIER CAME BACK NEGATIVE, so I proceeded», where the dispatch used negative for the opposite polarity — «if the route genuinely requires redirect_uris, the card's premise is false». Substance is unambiguous and the two agree: the route accepts the member absent, so the card's premise holds and the change is warranted.

Independence pair

Implemented-by: claude/issue-17215-oauth-register-redirect-uris-optional
Reviewed-by:    session_01TSf4DV7ziu4V5j73e46b7c

Stated plainly rather than left for a reader to discover: the implementer ran as a mode:subagent dev of this session, which is why the protocol has it record its branch (a subagent has no session of its own). This is default-tier seat review plus gates, the model contract-review.md prescribes for a non-spec seat — ⛔ not an arm's-length second opinion, and it should not be read as one. The one genuinely independent artefact is the ② baseline above, published before the diff existed.

What this review does NOT authorise

It verifies the widening is the one the card and triage describe, that it is declared correctly, and that it is now observable by a test. ⛔ It does not authorise the widening — the card's grading and its Clause-②: yes route did that.


Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/m tests tooling

Projects

None yet

1 participant