Repository navigation
fix(plugin-auth)!: the OAuth register door refuses an application type the provider does not judge with 400 INVALID_REQUEST, where it registered it as web - #25038
Conversation
…e the provider does not judge POST /api/v1/auth/sys-oauth-application/register now accepts exactly 'web' and 'native'. Any other value present in the body (user-agent-based, public, an unknown or empty string, a non-string) answers the route's own 400 INVALID_REQUEST before createOAuthClient is called, so nothing is stored; it was registered as 'web' with one server warning. An absent or null type stays the provider's default, 'web'. Claude-Session: https://claude.ai/code/session_01UmqzD97e14iws6hmVhgZ5y Co-authored-by: Claude <noreply@anthropic.com>
…ster door's 18.x refusal and the stored type column's readings; changeset for the plugin-auth narrowing Claude-Session: https://claude.ai/code/session_01UmqzD97e14iws6hmVhgZ5y Co-authored-by: Claude <noreply@anthropic.com>
… in its JavaScript, so it is named with a patch Claude-Session: https://claude.ai/code/session_01UmqzD97e14iws6hmVhgZ5y Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 2 package(s): 7 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 3 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 19 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 8c8b065c6641e9a2e227f94dd3d991539f9d0460 && git checkout 8c8b065c6641e9a2e227f94dd3d991539f9d0460
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin eddffc3390cf7e2cb9fd39960abc7fef4c40c902 48857fcc67456e5da3f9169285a9e49784c820f9 && git checkout -B drift-repro eddffc3390cf7e2cb9fd39960abc7fef4c40c902 && git merge --no-ff 48857fcc67456e5da3f9169285a9e49784c820f9
node scripts/docs-audit/affected-docs.mjs --json eddffc3390cf7e2cb9fd39960abc7fef4c40c902
|
…ister route's refusal of other application types The Application Type and register-form entries said the route registers user-agent-based / public as web with a warning and accepts the same values as before. In the same release the route refuses any type other than web / native with 400 INVALID_REQUEST, so those sentences are rewritten; every still-true sentence is kept. Claude-Session: https://claude.ai/code/session_01UmqzD97e14iws6hmVhgZ5y Co-authored-by: Claude <noreply@anthropic.com>
… from the last published state, without the unreleased warning Claude-Session: https://claude.ai/code/session_01UmqzD97e14iws6hmVhgZ5y Co-authored-by: Claude <noreply@anthropic.com>
…its unchanged parts against the last published release Claude-Session: https://claude.ai/code/session_01UmqzD97e14iws6hmVhgZ5y Co-authored-by: Claude <noreply@anthropic.com>
Contract reviewServed-tier: Isolated contract review (narrowing face) of PR #25038 for card #24773, rendered 2026-10-21T04:48Z. Inputs: the card's body and all 18 comments (rulings D ① Derived judgments1. The accept set of
2. The dev's reading that
3. The message — right.
4. The test file — the pins match the accept set.
5. The stored column — untouched, and the finding is routed.
6. The deliberate correction — confirmed, sentence by sentence. BEFORE is read at the merge base (
7. This PR's own note,
② Semver level
③ Boundary flags
Implemented-by: VERDICT: PASS |
席位核对ACCEPT · #24773 half 2c (the last half) ·
|
Fixes #24773
Clause-②: no (narrowing) —
POST /api/v1/auth/sys-oauth-application/registeraccepted anytypevalue and registered every one other thanweb/nativeas awebclient with a secret (with one server warning since half 2a); it now refuses each of those values with400 INVALID_REQUEST, so it accepts exactlyweb,nativeand no type.Half 2c, the last half of the maintainer's ruling D (
6293344532) on this card, under the claim6302570331as widened for half 2c. Half 1 landed as #24853, half 2a (the door forwardsapplication_typeand warns) as #24992, half 2b (the form offers Web / Native) as #25011. This PR turns half 2a's warning branch into the refusal, corrects the register action's code comment inplatform-objects, and carries one BREAKINGminorchangeset. It is a narrowing, so it owes a review atCONTRACT_REVIEW_TIERbefore the queue.「甲」 reading (amended)
6292005854, rule 2) shipped only the loud half in 18.x (the warning, fix(plugin-auth): the register form's Application Type reaches the OAuth provider as application_type #24992) and left the 400 for 19.0.6303865025, quoting 「暂时不考虑 v19,协议的调整原则上也是尽快,不考虑退役版本。」) changes rule 2: a narrowing that no ADR-0087 conversion carries also ships now, in 18.x, as a BREAKING minor in the shape of the24813-adopt-orphans-readonly-packagechangeset. No refusal waits for 19.0, and nopm:on-holdcard waits on a 19.0 line.not-required (no-migration-prescription), and it is shipped asminorwith!in the title,Clause-②: no (narrowing)and the BREAKING banner.check-adr-0087-registration --base origin/mainreads it as[BREAKING+bang+clause-②-narrowing] not-required (no-migration-prescription), exit 0.What changes
packages/plugins/plugin-auth/src/auth-plugin.ts, the register wrapper's application-type branch only:typein the body73432299c)web,nativeapplication_typenullweb, no warninguser-agent-based,public, any other string,'', a non-stringweb, with a client secret, plus onewarn400{ success: false, error: { code: 'INVALID_REQUEST', message } }, the route's existing envelope (itsredirectURLsrefusal uses the same one). The refusal comes beforecreateOAuthClientis called, so nothing is stored. No warning, no new error code.The message names the value (its first 64 characters, then an ellipsis when cut, as the warning truncated it; a non-string is named by its type, such as
(number)) and the two accepted types, and says to choosenativefor anhttp://localhost,http://127.0.0.1orhttp://[::1]redirect URI:packages/platform-objects/src/identity/sys-oauth-application.object.ts: thecreate_oauth_applicationaction's code comment only. It said the refusal "waits for a major version"; it now states the 18.x refusal and the stored column's readings (M2). No option, param or field changes. The comment keeps its line count. The generated bundles do not carry comments, so the i18n tooling regenerates nothing (check:i18ngreen). esbuild keeps comments, though: a rebuild changeddist/index.{js,mjs}anddist/identity/index.{js,mjs}(the comment text only; the.mapand.d.tsfiles are byte-identical).platform-objectsis notprivate, so its published output changes, and the changeset names it withpatch. That also puts this entry inplatform-objects' CHANGELOG, beside half 2b's entry.Absent,
nulland the empty stringMeasured at
73432299c: absent andnullregisteredwebwith no warning;''registeredwebwith the warning, like any other unhonoured value. This PR refuses''and keeps absent andnull:nullstayweb. Neither names a type: half 2a's branch readnullas absent, and JSONnullis the body's spelling of no value. The provider's own default isweb, and the form's declared default isweb. Refusingnullwould narrow the door past the ruling, which refuses present values.''is refused. It is a present string that names no type, and it already took the warning branch, whose text said "A future major version refuses this value". So the set this PR refuses is exactly the set that warned. No caller sends it: the Console dialog will not submit an empty required param (M3).Measured first
M1: the register door, value by value
Real stack in-process: real better-auth 1.7.3 with the OIDC provider on, behind the plugin's real route registration on a Hono app, over a real ObjectQL engine with
SqlDriver(better-sqlite3:memory:) and the auth manifest's objects. Stored rows were read at driver level. Each value was sent with an https redirect (https://app.example.com/callback) and with a loopback one (http://localhost:4001/callback). The probe was a throwaway file and is not in this PR.typewebwebOAUTH_REGISTER_FAILED(invalid_redirect_uri)nativenativenativeuser-agent-basedweb, 1 warningOAUTH_REGISTER_FAILED, 1 warningINVALID_REQUEST, nothing stored, no warningINVALID_REQUESTpublicweb, 1 warningOAUTH_REGISTER_FAILED, 1 warningINVALID_REQUESTINVALID_REQUESTspa(unknown string)web, 1 warningINVALID_REQUESTINVALID_REQUEST42,true,{ a: 1 }web, 1 warning each ((number)/(boolean)/(object))INVALID_REQUESTINVALID_REQUESTweb, no warningOAUTH_REGISTER_FAILEDnullweb, no warningOAUTH_REGISTER_FAILED''web, 1 warningINVALID_REQUESTINVALID_REQUESTweb, 1 warning (value cut to 64)INVALID_REQUEST, message carries 64 charactersINVALID_REQUESTEvery stored row, before and after, had
publicnull andtoken_endpoint_auth_methodclient_secret_basic, with a client secret.M2: the stored
sys_oauth_application.typecolumnNot narrowed: the select and the
publiccolumn are unchanged.@better-auth/oauth-provider1.7.3 writes onlywebornative, ornullfor a client-metadata-document client that names none (dist/authorize-9whjxVLJ.mjs:1956). Every creation path goes throughpersistOAuthClientRegistrationandcheckOAuthClient, which refuses any otherapplication_type(:1770): dynamic registration/oauth2/register(the MCP clients' route),/oauth2/create-client(this door and the SDK),/admin/oauth2/create-client, and the client-metadata document. Both update paths (/oauth2/update-client,/admin/oauth2/update-client) declareapplication_typeasweb | native(:2885,:2923). better-auth 1.7.3 itself ships nooidc-providerormcpplugin. The generic data API cannot write the table (apiMethods: ['get', 'list']), and no in-tree code writes the column except through the provider (theapplicationType: 'type'mapping inauth-schema-config.ts).type: safeType, withsafeTypeone ofweb/native/user-agent-basedor elseweb(fbb42d900^:auth-plugin.ts:3640). 1.7.x stripstype, so every row wasweb. Under the earlier lines below, the door sent notoken_endpoint_auth_method(a confidential client), souser-agent-basedwas refused there andpublicbecameweb.@better-auth/oauth-provider1.6.33 and 1.7.0-rc.2 (npm tarballs, read with grep only) declaredtypeasweb | native | user-agent-based, requirednativeoruser-agent-basedfor a public client andwebfor a confidential one, and wrotepublic: isPublic. plugin-auth'spackage.jsonhistory (245 commits, read through the commits API): the provider arrived as^1.6.9on 2026-05-08, ran^1.6.11–^1.6.23to 2026-07-16,1.7.0-rc.1from 2026-07-22 and1.7.0-rc.2from 2026-08-19, then^1.7.1from 2026-08-27. So a deployment that ran plugin-auth between 2026-05-08 and 2026-08-27 can hold rows withtype = 'user-agent-based'(a public client registered by name through dynamic registration or create-client) andpublicset.type = 'public'. No writer found. Nooauth-providerline declares it. better-auth 1.6.x's ownmcpplugin wrotetype: 'public', but plugin-auth never imported it:auth-manager.tshistory from 2026-02-01 to 2026-05-09, 31 commits read, 0 hits forplugins/mcpormcp((control:oidc-provider/oidcProvider(, 8 hits). The deprecated coreoidc-providerwrotewebon registration.web,native,useragentbased,public(half 2b's reading: the string-array shorthand strips hyphens). So the one non-web/nativevalue a writer could ever produce,user-agent-based, is not a declared value, and the declareduseragentbasedandpublichave no writer in the tree, in the installed provider or in the lines above.git grep: only the select declaration, the form and this wrapper). The installed 1.7.3 has nouser-agent-basedtoken (grep -c0) and reads nopublicfield; a public client istoken_endpoint_auth_method: 'none'. The earlier lines did branch, requiring PKCE fornative,user-agent-basedorpublic === true(1.6.33utils-Cdzs0w5-.mjs:420). One branch remains on legacy data, from source reading only: 1.7.3's two update paths re-runcheckOAuthClientover the stored client merged with the update, so a legacy row holdinguser-agent-basedwould answerapplication_type must be web or nativeto an update that does not also setapplication_type. That is not measured on a row (Acceptance notes).M3: who sends a type other than
web/nativeto this doorNone found, so this PR breaks no in-repo sender.
73432299c. Every caller of the door sends no type orweb:in-process-session-renewal.pin.test.ts,request-json-body-refusal.test.ts,oauth-register-refusal-status.test.ts(web) and this PR's test file. Docs:content/docs/protocol/objectui/actions.mdxshows the action with notypeparam; the platform checklist (identity-auth.json) registers with no type.examples/andpackages/qa: 0 hits for the door. The SDK never builds this URL (disposition: 'server-only'in the auth route ledger).webandnative, required, defaultweb. objectui'sActionParamDialogpre-fillsdefaultValueand refuses to submit a required param that isundefined,null, empty or whitespace (packages/app-shell/src/views/ActionParamDialog.tsx:503,:515,:537, the same at the pinned.objectui-shacf153eb2eand at objectuimaina91f6bbb1). So the form sendswebornativeonly.a91f6bbb1. 0 files for the door path,user-agent-basedorapplication_type(control:sys_oauth_application, 5 files).d723f6f. 0 files for the door path,user-agent-based,application_type,oauth2/create-clientorapplications.register(control:oauth, case-insensitive, 18 files).Tests
packages/plugins/plugin-auth/src/oauth-register-application-type.test.ts(half 2a's file, the same real stack as M1). The provider'screateOAuthClientis wrapped in a pass-through spy, and each request clears it.user-agent-based,public,spa,'',42,trueand an object, each with an https and with a loopback redirect:400, ADR-0112 envelope clean,error.codeINVALID_REQUEST, the provider never called, no row written, no warning and no error logged.'web','native',http://localhost,http://127.0.0.1andhttp://[::1]; a short value is named whole; a non-string is named(number).type: nullregisterswebwith no warning. Every accepted registration now also asserts exactly one provider call, carrying theapplication_typeit stored.web/nativeforwarding, the loopback rules, absent →web) pass unchanged.Results:
ee660f7cd(the fix, before mergingmain): this file plusoauth-register-refusal-status.test.ts:Test Files 2 passed (2),Tests 42 passed (42). The fullplugin-authsuite:Test Files 174 passed (174),Tests 3344 passed | 10 skipped (3354).platform-objects:Test Files 83 passed (83),Tests 1239 passed (1239), half 2b'soauth-register-form-types.test.tsincluded.platform-objectstypecheck: exit 0,check:test-typecheck: OK, 2 pinned signatures held.3f934a772(after mergingmain4d991588f, which touchedplugin-auth'sauth-manager.ts): a rebuilt closure, then the fullplugin-authsuite:Test Files 175 passed (175),Tests 3348 passed | 10 skipped (3358).plugin-authtypecheck: exit 0,check:test-typecheck: OK, 23 pinned signatures held. No debt was added, and the test file is in thetsconfig.test.jsonprogram (--listFilesOnly, 1 hit).platform-objectsis untouched by the merge.Ablation (one-shot; no permanent file)
Every leg went through
scripts/ablation-replace.mjsin WRAP mode, in a runner with its own EXIT/INT/TERM trap that re-proved each restore. Each anchor hit once and fell from 1 to 0, with the blob changed on disk. Each restore was proven as blobd065fc855== HEAD, withgit diff HEADempty andgit status --porcelainempty at the end. The test imports./auth-pluginfrom source, so there is nodistleg. The unmutated control: 25 passed.webexpected 200 to be 400; on loopback,expected 'OAUTH_REGISTER_FAILED' to be 'INVALID_REQUEST') and the message pinnativeinstead of refusedthe door still warns instead of (or as well as) refusing: expected [ [ …(2) ] ] to deeply equal []the door called the provider for a type it refuses: expected [ { client_name: 'Typed', …(2) } ] to deeply equal []the message echoes the value past its first 64 characters: expected 'type "aaaa…' not to contain 'TAIL'nullrefusednull→webpin,expected 400 to be 200nullpinsThe first runs of legs B and C were refused by the tool and ran nothing ("the anchor count moved 1 -> 1": the replacement contained its anchor). They were redone with an anchor the replacement does not contain. The figures above are from the redone runs.
Gates
node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstackat the final head3f934a772derived 74 commands (the seat's 57 at73432299c, plus 17 that this diff's paths and its changeset add). The diff is 190 changed lines (+138 / -52) across 4 files, under the 3000 human-merge threshold. Each command ran ascmd > log 2>&1; status=$?, recorded byte for byte with:: exit N.First pass. 72 of 74 exited 0.
check:dual-build-cjs-loadsandcheck:i18nexited 3 (PREREQUISITE NOT MET: no whole-workspacedist, and no built CLI), so they measured nothing. Afterturbo run build --filter=!@objectstack/docs --concurrency=2(76 tasks), both exited 0: "112 published require entry point(s) across 69 package(s)" andcheck-i18n-bundles: OK (10 package(s) — all bundles in sync, no undeclared authoring keys).Reconciliation.
--ran:Run reconciliation — 74 derived, 74 run, 0 NOT-MEASURED, 0 UNRUN.Verdicts among the 74.
check-adr-0087-registration --base origin/main:1 declared-breaking changeset(s), each carrying an ADR-0087 disposition, read as[BREAKING+bang+clause-②-narrowing] not-required (no-migration-prescription). Also green:check-changeset-no-major, andcheck-empty-changeset("No changeset from the merge base modified or deleted").check:error-status-conformance: "every derivable runtime status is documented, and every documented status is reachable".check:issue-citations,check:doc-authoring,check:nul-bytes,check:route-envelope,check:auth-mount-ledger,check:closing-keyword-parity,check:type-check-debtandcheck:engine-double-contractare green too.Not run locally.
test:repois CI'sTest Core.check:dev-prereqsandcheck:skill-frame-freshnessdo not apply: nopackages/specbuild input or frame file is touched. No exported symbol is renamed or removed, socheck-platform-checklist --changed-fromdoes not apply. The type-check lanes and the CI jobs thatdispatch-gateslists outside the 74 belong to CI.Dogfood. This PR has a
(narrowing)arm, so the full dogfood battery runs once on this head after the PR opens. Its reading is in the dev report on OAuth app registration rejects http://localhost and http://127.0.0.1 redirect URIs as "non-loopback", with HTTP 500 #24773.After the patch rounds:
check-empty-changesetis red by design at the final head48857fcc6(see## Pending release notes correctedbelow). The re-verification at2da95c6da, after mergingmaindfe60e3a0:plugin-authsuite:Test Files 175 passed (175),Tests 3348 passed | 10 skipped (3358);plugin-authtypecheck:check:test-typecheck: OK, 23 pinned signatures held;--ran:74 derived, 74 run, 0 NOT-MEASURED, 0 UNRUN. The 21 families that the edited changesets trigger were re-run at2da95c6da: 20 exit 0, andcheck-empty-changesetexits 1 as above. The other 53 are carried from3f934a772.r3,
8eba09f8c(prose only, this PR's own changeset)..changeset/24773-oauth-type-refusal.mdnow describes the change from the last published state, 18.0.0, where the door registered such a type aswebsilently. The FROM bullet drops ", and the server logged one warning naming the value", and the TO bullet drops ": the refusal replaces that warning": half 2a's warning never reached a published release.dispatch-gates --commandsover that one path derived the same 21 families. All 21 re-ran at8eba09f8c: 20 exit 0 (check-adr-0087-registration --base origin/main: "1 declared-breaking changeset(s), each carrying an ADR-0087 disposition"), andcheck-empty-changeset --base origin/mainexits 1, the expected red naming only the two corrected foreign changesets.--ran:74 derived, 74 run, 0 NOT-MEASURED, 0 UNRUN, with the other 53 carried from3f934a772.r4,
48857fcc6(prose only, this PR's own changeset)..changeset/24773-oauth-type-refusal.mdwas re-read against the last published state, 18.0.0. In 18.0.0 the door sent the type astype, which the provider stripped, so every client wasweb. Provider refusals answered500, and the form offered four types. Three phrases stated this release's intermediate behaviour as the FROM state, and were changed:400 OAUTH_REGISTER_FAILEDfor a loopback redirect URI" became "was refused by the web rule (OAUTH_REGISTER_FAILED) for a loopback redirect URI". In 18.0.0 that refusal answered500.webandnativeare forwarded as the provider'sapplication_type, and the provider's own redirect-URI rule decides. ... The other refusals keep their codes and statuses." was split into two bullets. The first is "Accepted:webandnativeare forwarded as the provider'sapplication_type(this release's Application Type entry), and the provider's own redirect-URI rule decides." The second is "Unchanged: ... The other refusals keep their codes (their statuses are this release's refusal-status entry)." The reason: in 18.0.0,nativewas judged and stored asweb, and the statuses change in this release's refusal-status entry.dispatch-gates --commandsover that path derived the same 21 families, re-run at48857fcc6. 20 exit 0 (check-adr-0087-registration --base origin/main: "1 declared-breaking changeset(s), each carrying an ADR-0087 disposition");check-empty-changeset --base origin/mainexits 1, the expected red naming only the two corrected foreign changesets.--ran:74 derived, 74 run, 0 NOT-MEASURED, 0 UNRUN, with the other 53 carried from3f934a772.The dogfood battery is not re-run. The merge moved
driver-sql,objectql,service-settings,service-messaging,cliandcore, none of it under this diff, and this round's delta is prose only.Pending release notes corrected
This PR's refusal made sentences in two of the card's own pending changesets false. They are corrected in
2da95c6da(final head48857fcc6). Every still-true sentence is kept, and each file keeps its frontmatter (package and bump).check-empty-changesetrefuses the edit as a foreign-changeset modification. That is its DELIBERATE CORRECTION class, red by design at the final head48857fcc6, as since2da95c6da(CICheck Changeset: "exists on the merge base and was not added by this PR"), for the seat's same-head contract review to confirm. The evidence for every correction is M1 AFTER above:user-agent-based,public,spa,''and non-strings answer400 INVALID_REQUEST, with no row stored and no warning logged..changeset/24773-oauth-application-type.md(@objectstack/plugin-auth: patch, unchanged)web, with a client secret. The server now logs one warning per such registration, naming the value."400 INVALID_REQUESTand registers nothing (this release's breaking entry for the route)."webclient is registered and no warning is logged.user-agent-basedandpublic, has noapplication_typein the provider." and "To use a loopback redirect URI, register the client asnative.".changeset/24773-oauth-form-types.md(@objectstack/platform-objects: patch, unchanged)no, because the form change itself moves no accept set. The parenthetical does not start with an arm word, so the line's reading is unchanged.webandnative, so the register route registers the other two asweb, with a client secret."webandnative, and the register route refuses any other type with400 INVALID_REQUEST."user-agent-basedandpublic." (the first sentence of the Unchanged. bullet)sys_oauth_application.typecolumn still lists all four values, so a row written under an earlier provider line keeps its value. Thepubliccolumn is unchanged.".changeset/24773-oauth-register-refusal-status.md(@objectstack/plugin-auth: patch) is not edited, because none of its sentences is false. Each one speaks of the refusals the provider raises (400forinvalid_redirect_uri,401for a missing session,500for a throw that declares no status, the body-schema400), and those are unchanged. The new type refusal comes before the provider is called. So do the route's ownname/redirectURLsrefusals, which this note never covered either. Its closing sentence, "awebclient still needs anhttpsURI on a non-loopback host", stays true.Acceptance notes
@better-auth/oauth-provider1.6.x / 1.7.0-rc (2026-05-08 to 2026-08-27) can holdtype = 'user-agent-based'and apublicvalue. 1.7.3's update paths re-validate the stored client, so such a row would answerapplication_type must be web or nativeto an update that does not also setapplication_type. This comes from reading source; it was not measured on a row, and no deployment's data is in reach.useragentbasedandpublic(M2). Reported to the seat in the dev report, not narrowed here.Generated by Claude Code