fix(client)!: oauth.applications.register declares only the members /oauth2/create-client accepts - #17209
Conversation
…h2/create-client accepts `name`, `scopes` and `metadata` were never honoured by the route this method posts to. The vendor body schema (`@better-auth/oauth-provider@1.7.2`) has no `catchall`, so zod strips them: the caller got HTTP 201 and a client that quietly did not have the value. Driven on the card — absent from the response, from `applications.get`, from `applications.list`, and `null` in the `sys_oauth_application` row. The two near-misses were the vendor's RECORD vocabulary, not typos: `client_name` writes the DB column named `name`, and `scope` writes the column named `scopes`. `scopes` -> `scope` is therefore not a rename — the array form is refused (`400 [body.scope] Invalid input: expected string, received array`) and only a space-joined string passes. Claude-Session: https://claude.ai/code/session_015QE8qk46e5CHJxyQEUjbf8 Co-authored-by: Claude <noreply@anthropic.com>
…15447 Claude-Session: https://claude.ai/code/session_015QE8qk46e5CHJxyQEUjbf8 Co-authored-by: Claude <noreply@anthropic.com>
📓 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 — 14 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): |
Docs Drift Check — discharged, page by pageThe bot has no opinion (no anchor derived), which is ⛔ not a clean bill of health, so its caveats block is answered below rather than treated as a clearance. Read at The coarse fallback — all 14 pages it names, each with the reading that keeps it true
Caveat 1 — "1 name(s) were too generic to anchor anything (single lowercase words)"That name is Caveat 2 — the inputs-vs-emitter blind spot, and a row this run could not report⭐ Reported rather than cleared: ⭐ Second row the anchor pass could not reach: Neither of those is falsified by this PR. Both are recorded because a row that had to be reached by hand is worth more here than a row the tool cleared. Generated by Claude Code |
…the recorded 400 The third case in the #15447 pin paired a VALID request (the joined string the route accepts with 201) with the error the INVALID request produces, against a double that answers 400 unconditionally — so its title asserted two things the case could not see. It now sends the array form through a suppression, which is the body that actually drew the recorded refusal, and the title and comment say what it pins: the SDK's handling of that refusal, never the route's verdict. The suppression is load-bearing: after this card's narrowing the array form is no longer expressible through the declared type, and widening `scope` to accept one leaves the directive unused and this case red. Claude-Session: https://claude.ai/code/session_015QE8qk46e5CHJxyQEUjbf8 Co-authored-by: Claude <noreply@anthropic.com>
Patch round — the third pin case now sends the body that produced the recorded 400Head The defect, restated so the fix is checkableThe third What it is now
The assertion itself is untouched — it was correct, and it is what the case keeps. Re-measured on the new head, not carried over
Both ablation legs re-run on this head, because a citation that moved with the file is a citation that has to be re-taken. Directions predicted in writing again, before either leg ran:
Both legs ran from the committed state under a Also in this push#17215 — the Still a draft; auto-merge not armed. Both stay the PM seat's. Generated by Claude Code |
Fixes #15447
Clause-②: no
Declared
noby the dispatchingdomain:cliseat (#6024) and unchanged by the delivered diff: this is a pull-back of a published request type onto what the route it posts to already accepts, and the face ispackages/client/src/index.tsonly — nopackages/spec/src/**, no*.zod.ts, no error-code ledger. Breaking is not clause-②; the**BREAKING**banner is carried by the changeset instead.What changed
ObjectStackClient.oauth.applications.registerdeclared three members thatPOST /api/v1/auth/oauth2/create-clientnever honoured —name,scopes,metadata. They are removed. Nothing is added: no export, no key, no compatibility alias.The route is mounted verbatim from
@better-auth/oauth-provider@1.7.2. Its body schema declares 21 members and sets nocatchall, so it is zod's default strip: an unknown key is dropped rather than refused and the caller gets HTTP 201 plus a client that quietly does not have the value. There was no error to notice and no receipt to check.The removed members are the vendor's RECORD vocabulary, not typos
This is the part a reader is most likely to get wrong, so it is stated first and plainly.
nameandscopesare not misspellings ofclient_nameandscope— they are the names of the DB columns those two wire members write:sys_oauth_applicationcolumnclient_name: 'CTRL-CLIENT-NAME-15447'namescope: 'openid profile email'scopes, as the JSON array["openid","profile","email"]So the type used to offer the record spelling and the wire spelling side by side, and only the wire one worked. Triage's words on the disposition this PR carries out: 「a ruling that treats them as misspellings should say so knowingly.」 It does, here.
A consequence:
scopestoscopeis not a rename.scopeis one space-delimited string, and the array form is refused on the wire —400 [body.scope] Invalid input: expected string, received array. Only.join(' ')passes. The prescription in the changeset says so.metadatahas no reachable door at all: onlyPATCH /admin/oauth2/update-clienthonours it, and that endpoint isSERVER_ONLY, whichbetter-call's router skips — driven over HTTP it answers 404 with a zero-byte body.Measurement provenance — nothing here was re-driven
The rig verdict comes from the measurement round already on the card (issue #15447, comment 5559384773): real
betterAuthplus realoauthProviderover the real ObjectQL engine on a real TCP socket, driven through the realObjectStackClient. Each of the three came back absent from the response, absent fromoauth.applications.get, absent fromoauth.applications.list, andnullin the DB row. A second, independent barrier stands behind the strip — the handler funnels the parsed remainder into the opaque-metadata envelope and all three names sit inOPAQUE_METADATA_RESERVED_FIELDS— so loosening the SDK alone could never have made them arrive. The vendor member list has no drift at 1.7.2.The pin, and why it cannot assert on a 201
packages/client/src/oauth-applications-register-request-members.test.tscarries both halves.Acceptance ① — the pin fails on the DEFECT, not on the fix. It is type-level, because the route answers 201 either way; that is the entire reason this was invisible, and any runtime assertion on the status or the response body would have been green in both states. Two independent directions:
keyof RegisterRequest— red if a member returns, and equally red if a new one is added (which is also the STOP-CONDITION guard);@ts-expect-errorexcess-property probes, one per removed member.Ablation, direction predicted in writing before the run: RED, by both signatures. Driven, with the three members put back:
pnpm --filter @objectstack/client typecheckunder that mutation: exit 1, "4 type error(s) in a file the ledger does not cover".Acceptance ② — the negative control.
client_nameand a space-joinedscopemust still reach the server byte for byte. Asserted as full-string equality on the request body, nevertoContain— a body carrying a re-introduced member, or one the SDK had begun translating, would satisfy a containment check. Ablation, direction predicted in writing first: RED, and specifically 2 of 3, because the third case's transport double answers 400 without ever reading the request body. Driven, with the SDK made to filter the body:Both ablation legs ran from the committed state, mutated under a
trap ... EXIT INT TERMwith absolute paths, proved on disk by occurrence counts of the injected text and of the deleted text plus a blob hash off theHEADblob (06216277bf9f67bfdf6cb2b852ce034d4effad1a), and both restores were proved bygit diff HEADempty and a blob equal to HEAD's, withgit status --porcelainempty. No dist preflight applies: the subject resolves through the relative./indexsource import inside its own package, so no built artifact sits in the resolution path (pnpm check:test-source-aliasgreen confirms no unaliased dist import here).Verification
pnpm --filter @objectstack/client typecheck— exit 0; test layer compiles undertsconfig.test.json, 0 files / 0 errors held intest-typecheck-debt.json.pnpm --filter @objectstack/client test— exit 0; 39 files / 478 tests passed.node scripts/pm/dispatch-gates.mjs --commands(never a hand list) and reconciled:✓ dispatch-gates --ran: 58 derived famil(ies) accounted for — 58 run, 0 NOT-MEASURED. Every exit code captured by redirect-then-$?, never through a pipe. 56 of 58 green on the first pass; the two that were not are declared below, and neither is counted as a pass.pnpm --filter @objectstack/spec run check:skill-examples— first run exit 1 on its own prerequisite refusal (packages/client-react/dist holds no .d.ts declarations). NOT MEASURED, not a finding. Re-run afterpnpm --filter @objectstack/client-react --filter @objectstack/client build: exit 0, 258 prose examples type-check across 3 surfaces.pnpm check:type-check-debt— first run exit 3,PREREQUISITE NOT MET(the whole-repo tsc re-measure OOMed under the--max-old-space-size=4096this container's resource discipline sets, which the gate itself names as "the caller's NODE_OPTIONS, which is tighter"). NOT MEASURED. Re-run at 8192: exit 0 — "5 ledger entr(ies) re-measured in 102.9s, 55 raw tsc error(s) total, none above its recorded number".pnpm check:dual-build-cjs-loads— exit 3,PREREQUISITE NOT MET: it reads built output and 35 packages have nodist/, which needs a whole-repopnpm build. ⛔ Declared NOT MEASURED, never counted as a pass; CI checks out fresh and builds, so it runs there. This diff removes type annotations and adds a test file, so it emits no JavaScript change for that gate to read.node scripts/check-adr-0087-registration.mjs --base origin/main— exit 0, one declared-breaking changeset carryingnot-required (no-migration-prescription), claimed on a positive argument with its residual declared rather than hidden.node scripts/docs-audit/affected-docs.mjsreports 0 docs across 1 changed package (1 test file excluded) — but with no anchors derived, which is that tool's own coverage caveat and ⛔ not a clean bill of health. Swept by hand as well: the only hand-written page namingoauth.applications.registerisdocs/qa/platform-checklist/areas/identity-auth.json, which names the three registration doors and never the SDK request members, so it stays true unchanged.packages/client/README.mdlists namespaces only. No release-owned page touched.Single-writer measurement (acceptance ⑤)
Measured from the open PR list, never from remote branches, on 2026-09-09: all 18 open PRs enumerated, each one's file list read as its own three-dot diff against its own merge base.
packages/client/src/index.ts— held by no open PR. The two prior holders named on the card, fix(client): the scoped SDK reads metadata.prefix off the advertised routes instead of restating /meta #17122 and fix(client): a bearer-mode ObjectStackClient adopts the session token the three rotating auth routes hand it #17182, are both merged and absent from the open list; this branch is cut fromd61139f1ba, which is fix(client): a bearer-mode ObjectStackClient adopts the session token the three rotating auth routes hand it #17182's squash.packages/client/package.jsonandpackages/client/CHANGELOG.mdheld by open PR chore: version packages #17076 — inside the very package whosesrc/index.tscame back free, so the scan demonstrably reaches there. Second control, for multiplicity:packages/metadata/src/migrations/migrate-sys-notification-to-event.tsis reported held by two open PRs (refactor(metadata,spec)!: retire the adr-0030-notification-event migration — no operator door, no platform invoker #17194 and fix(types,metadata,cli): read the dialect text out ofcausefor operator-facing records #17073).Reported, deliberately NOT fixed here
redirect_urisis a fourth divergence on the same type and is left alone on the dispatching seat's instruction. The vendor declares it an optional array of string; this SDK declares it required. Outside this card's three, and widening the PR to take it would change a signature nobody asked to change. Reported to the PM, who overruled the not-filed call: it is filed as client SDK oauth.applications.register declares redirect_uris REQUIRED where the better-auth body schema declares it optional, so a legal vendor call cannot be expressed #17215 (bug/domain:cli/finding/priority:p3, unassigned) with the driven evidence. The direction is the safe one — the SDK is narrower than the producer, so nothing is silently lost and no receipt lies; what a caller loses is expressiveness, since a request the route would accept has no spelling here. That is why it grades p3, and it is ⛔ not why it would go unfiled: the declaration still does not match the route it posts to, which is client SDKoauth.applications.registerdeclares three request members the better-auth body schema silently strips (metadata,name,scopes) #15447's own family.oauth.applications.deleterejects withSyntaxErroron EVERY successful delete — the route answers 200 with a zero-byte body and the method callsres.json()on it #15451 (oauth.applications.delete, p1) is a sibling in the sameapplicationsblock and is ⛔ NOT pulled in: it is not dispatched, and a p2 must not drag a p1 into its review cycle. Observed while reading the file: its fix is already onmainat this base, carried by the long comment ondeleteand pinned byoauth-applications-delete.test.ts.namebeing honoured byPOST /api/v1/auth/sys-oauth-application/register— a different, ObjectStack-owned mount — is not an argument for keeping it on this method: no SDK method builds that URL, so no caller of this SDK can reach it by spellingnamehere. Filed separately as 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, with the driven evidence, rather than closed here.验收备注
origin/mainadvanced to22c94e53b6while this branch was in flight, and one file the derivation reads —scripts/pm/check-clause2-carriers.mjs— changed across that range (fix(pm): name PLACEMENT, not spelling, when a Clause-② key sits mid-line #17201). Read: the change is reporter-only. It adds a PLACEMENT near-miss message and its own docblock says 「⛔ This is a REPORTER, never a reader ...CLAUSE2_KEY_LINEis untouched」, so the accept set is unmoved and no gate family is added or removed by it. The 58-family list therefore still holds; noted rather than papered over.check-type-check-coverage's provenance line reportswalkedTestFilesmoving 3503 to 3526 against the record. That is the shared population, not this diff (which adds one test file), and the line says so itself: "only the floors decide", and the floor is 2800.os-devseat in sessionsession_015QE8qk46e5CHJxyQEUjbf8, dispatched by thedomain:cliexecution PM seat ([PM seat] domain:cli — 🟢 os-project-manager · session_015QE8qk46e5CHJxyQEUjbf8 · R71 · (consolidated-seat takeover of 09-09T01:4xZ withdrawn 06:3xZ — see brief) #6024).Generated by Claude Code