Skip to content

fix(client)!: oauth.applications.register declares only the members /oauth2/create-client accepts - #17209

Merged
os-project-manager merged 3 commits into
mainfrom
claude/issue-15447-oauth-register-declared-members
Sep 9, 2026
Merged

fix(client)!: oauth.applications.register declares only the members /oauth2/create-client accepts#17209
os-project-manager merged 3 commits into
mainfrom
claude/issue-15447-oauth-register-declared-members

Conversation

@claude

@claude claude Bot commented Sep 9, 2026

Copy link
Copy Markdown
Contributor

Fixes #15447

Clause-②: no

Declared no by the dispatching domain:cli seat (#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 is packages/client/src/index.ts only — no packages/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.register declared three members that POST /api/v1/auth/oauth2/create-client never 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 no catchall, 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. name and scopes are not misspellings of client_name and scope — they are the names of the DB columns those two wire members write:

posted on the wire lands in sys_oauth_application column
client_name: 'CTRL-CLIENT-NAME-15447' column literally named name
scope: 'openid profile email' column literally named 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: scopes to scope is not a rename. scope is 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.

metadata has no reachable door at all: only PATCH /admin/oauth2/update-client honours it, and that endpoint is SERVER_ONLY, which better-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 betterAuth plus real oauthProvider over the real ObjectQL engine on a real TCP socket, driven through the real ObjectStackClient. Each of the three came back absent from the response, absent from oauth.applications.get, absent from oauth.applications.list, and null in 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 in OPAQUE_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.ts carries 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:

  • a key-set equality on keyof RegisterRequest — red if a member returns, and equally red if a new one is added (which is also the STOP-CONDITION guard);
  • three @ts-expect-error excess-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:

src/oauth-applications-register-request-members.test.ts(122,5): error TS2344: Type '"client_name" | ... | "response_types"' does not satisfy the constraint '"Expected: literal string: client_name, Actual: literal string: name" | ... | "Expected: literal string: client_name, Actual: literal string: scopes"'
src/oauth-applications-register-request-members.test.ts(147,5): error TS2578: Unused '@ts-expect-error' directive.
src/oauth-applications-register-request-members.test.ts(152,5): error TS2578: Unused '@ts-expect-error' directive.
src/oauth-applications-register-request-members.test.ts(157,5): error TS2578: Unused '@ts-expect-error' directive.

pnpm --filter @objectstack/client typecheck under that mutation: exit 1, "4 type error(s) in a file the ledger does not cover".

Acceptance ② — the negative control. client_name and a space-joined scope must still reach the server byte for byte. Asserted as full-string equality on the request body, never toContain — 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:

 × sends `client_name` and a space-joined `scope` byte for byte
 × is a pass-through: every surviving member arrives unchanged and nothing is added
 Tests  2 failed | 1 passed (3)

Both ablation legs ran from the committed state, mutated under a trap ... EXIT INT TERM with absolute paths, proved on disk by occurrence counts of the injected text and of the deleted text plus a blob hash off the HEAD blob (06216277bf9f67bfdf6cb2b852ce034d4effad1a), and both restores were proved by git diff HEAD empty and a blob equal to HEAD's, with git status --porcelain empty. No dist preflight applies: the subject resolves through the relative ./index source import inside its own package, so no built artifact sits in the resolution path (pnpm check:test-source-alias green confirms no unaliased dist import here).

Verification

  • pnpm --filter @objectstack/client typecheckexit 0; test layer compiles under tsconfig.test.json, 0 files / 0 errors held in test-typecheck-debt.json.
  • pnpm --filter @objectstack/client testexit 0; 39 files / 478 tests passed.
  • Gate family derived with 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 after pnpm --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=4096 this 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-loadsexit 3, PREREQUISITE NOT MET: it reads built output and 35 packages have no dist/, which needs a whole-repo pnpm 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/mainexit 0, one declared-breaking changeset carrying not-required (no-migration-prescription), claimed on a positive argument with its residual declared rather than hidden.
  • Docs drift: node scripts/docs-audit/affected-docs.mjs reports 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 naming oauth.applications.register is docs/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.md lists 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.

Reported, deliberately NOT fixed here

验收备注

  • Gate-family derivation carries a STALE TREE warning: origin/main advanced to 22c94e53b6 while 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_LINE is 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 reports walkedTestFiles moving 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.
  • The docs-audit tool's "no anchors derived" caveat is recorded above as a coverage hole rather than as a clearance, per its own caveats block.
  • Authored by the os-dev seat in session session_015QE8qk46e5CHJxyQEUjbf8, dispatched by the domain:cli execution 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

…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>
@github-actions github-actions Bot added the size/m label Sep 9, 2026
@github-actions

github-actions Bot commented Sep 9, 2026

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 — 14 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 4261fbc80e67b1715d62e02417f959430ad2666dpackageMentionDocs.

@claude

claude Bot commented Sep 9, 2026

Copy link
Copy Markdown
Contributor Author

Docs Drift Check — discharged, page by page

The 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 22c94e53b6.

The coarse fallback — all 14 pages it names, each with the reading that keeps it true

node scripts/docs-audit/affected-docs.mjs --json 22c94e53b652d95289e93aaa78c71e9c8436ec92packageMentionDocs. Two of the 14 mention OAuth at all; the other twelve mention @objectstack/client and nothing this diff touches.

page reading
content/docs/api/client-sdk.mdx Names the oauth namespace in one sentence and then defers to index.ts "for the full surface". It enumerates no request member of any method, so a member leaving one cannot falsify it. Structurally immune here, by that deferral.
content/docs/permissions/authentication.mdx Its OAuth content is about sign-in providers (Google/GitHub social login, POST /api/v1/auth/sign-in/social, the /api/v1/auth/callback/... return) — the opposite side of OAuth from registering a client of our own provider. It never names oauth2/create-client, oauth.applications.register, or any of the three removed members. True before and after.
content/docs/api/data-flow.mdx · api/environment-routing.mdx · api/error-catalog.mdx · getting-started/your-first-project.mdx · kernel/runtime-services/data-service.mdx · kernel/runtime-services/index.mdx · kernel/runtime-services/storage-service.mdx · plugins/packages.mdx · protocol/kernel/realtime-protocol.mdx Zero occurrences of oauth.applications, applications.register or create-client. They mention the package, which is the pre-#9192 wide predicate doing its job; nothing in them is about this method.
content/docs/releases/implementation-status.mdx · releases/v16.mdx · releases/v17/17-3.mdx Release-owned — ⛔ never edited in a code PR, and none of the three names this surface anyway. This PR's input to the release pages is its changeset, as the guardrail requires.

Caveat 1 — "1 name(s) were too generic to anchor anything (single lowercase words)"

That name is name, one of the three members removed. It is precisely the token the anchor derivation cannot use, so this run's silence about it carries no information at all. Swept by hand instead, over content/, docs/, skills/, examples/ and every published README, for applications.register, oauth2/create-client, client_name, scopes and the Record-typed member: the only hand-written page that names the SDK method is docs/qa/platform-checklist/areas/identity-auth.json, and it names the three registration doors, never a request member. True unchanged.

Caveat 2 — the inputs-vs-emitter blind spot, and a row this run could not report

⭐ Reported rather than cleared: docs/qa/platform-checklist/areas/identity-auth.json is outside this tool's corpus entirely. The tool walks content/docs; the platform checklist lives under docs/. So its absence from every list above is structural, not evidence. It is the one page in the repo that maps oauth2/create-client to oauth.applications.register by name (lines 1008, 1017, 1078) — exactly the kind of page the check exists to catch — and it can never appear on a run of this tool. Read by hand: it describes doors and route mappings, not request members, so this diff leaves it true.

⭐ Second row the anchor pass could not reach: content/docs/protocol/objectui/actions.mdx (line 424) documents the Console's create_oauth_application action posting to /api/v1/auth/sys-oauth-application/register. It states the rule by its YAML inputs, sharing no identifier with packages/client/src/index.ts, which is caveat 2's shape exactly — an emitter-only diff cannot list it. Read by hand: it documents the Console door's action shape and never the SDK method, so this diff leaves it true. It is, however, the page a reviewer of #17210 wants, since that card is about exactly this door having no SDK method.

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>
@claude

claude Bot commented Sep 9, 2026

Copy link
Copy Markdown
Contributor Author

Patch round — the third pin case now sends the body that produced the recorded 400

Head 5e3838531c. One case changed, nothing else.

The defect, restated so the fix is checkable

The third it() sent scope: 'openid profile email' — the joined string the route accepts with 201 — against a transport double that answers 400 unconditionally, and asserted the array-form refusal. So it paired a valid request with the invalid request's answer, and its title claimed two things the case could not witness: the route's acceptance of the joined form (the double never 201s) and its refusal of the array form (no array was ever sent). ⛔ A brand-new pin whose title is false at birth is not a follow-up card.

What it is now

  • Retitled: surfaces the route's refusal of an array-form scope rather than swallowing it. It names what the case can see — the SDK's handling — and no longer claims the route's verdict.
  • Fixture made self-consistent: the request now carries the array form, spelled through a @ts-expect-error, mirroring direction 2 of the type pin. That is the body which actually drew the recorded 400, so the recorded answer is now the answer to the request this case makes.
  • Comment says the 400 is RECORDED, not produced: replayed verbatim from the driven run (issue client SDK oauth.applications.register declares three request members the better-auth body schema silently strips (metadata, name, scopes) #15447, comment 5559384773), with the clause that makes the pairing coherent rather than merely relabelled — after this card's narrowing the array form is no longer expressible through the declared type at all, the removed scopes having been the member that invited it.
  • The suppression is load-bearing in a second direction: widen scope to accept an array and the directive goes unused, so the case goes red.
  • One line outside the case, declared rather than slipped in: the file header said "The space-joined case below is that prescription, pinned", which after the retitle pointed at a case that no longer sends the joined form. It now names the first case, which does. Same defect class as the one this round is about, which is why it was not left for later.

The assertion itself is untouched — it was correct, and it is what the case keeps.

Re-measured on the new head, not carried over

packages/client/src/index.ts is untouched: its blob at this head is 06216277bf9f67bfdf6cb2b852ce034d4effad1a, byte-identical to the previous head's. So is the changeset.

  • pnpm --filter @objectstack/client typecheckexit 0; check:test-typecheck: OK ... 0 file(s) / 0 error(s).
  • pnpm --filter @objectstack/client testexit 0; Test Files 39 passed (39) / Tests 478 passed (478).
  • node scripts/pm/check-clause2-carriers.mjs --pair 17209exit 0 after the push and the body edit.
  • Gate family re-derived and reconciled on this head: same 3 paths, 58 derived famil(ies) accounted for - 58 run, 0 NOT-MEASURED. The eleven source-text-sensitive families were re-run individually (check:nul-bytes, check:cross-package-test-inputs, check:test-source-alias, comment-mask adoption and corpus, keyed-text-bounds, undeclared-dep-imports, registry-log-declared, empty-changeset, adr-0087-registration, type-check-coverage) — all exit 0, every code captured by redirect-then-$?.

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:

  • Leg 1 (the pin) — predicted RED with the signature unchanged at 4 errors, and specifically that the new fourth suppression must not appear as a TS2578, since leg 1 restores scopes rather than widening scope. Driven: typecheck exit 1, "4 type error(s) in a file the ledger does not cover" — four, not five.
  • Leg 2 (the negative control) — the load-bearing one, because this PR body cites "2 of 3". Predicted RED and still exactly 2 of 3, the retitled case staying green because it reads the rejection and never the request body. Driven: Tests 2 failed | 1 passed (3). The citation in the body is still true on this head.

Both legs ran from the committed state under a trap ... EXIT INT TERM with absolute paths, proved on disk by occurrence counts of injected and deleted text plus a blob differing from HEAD's, and both restores proved by a blob equal to HEAD's with git diff HEAD empty and git status --porcelain empty.

Also in this push

#17215 — the redirect_uris divergence, filed as instructed: the SDK declares it required where the vendor declares it optional, so a legal vendor call is inexpressible. bug / domain:cli / finding / priority:p3, unassigned, no pm: label, labels verified by a compare-style read-back. The acceptance-notes bullet in this PR's body no longer says "not filed" — it names the card.

Still a draft; auto-merge not armed. Both stay the PM seat's.


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

Development

Successfully merging this pull request may close these issues.

client SDK oauth.applications.register declares three request members the better-auth body schema silently strips (metadata, name, scopes)

2 participants