fix(plugin-auth): register the MCP resource so RFC 8707 authorize can succeed - #16780
Conversation
The predecessor check asserted `opts.validAudiences` on the options object captured from a mocked `oauthProvider`. The provider never consumes that object, so the assertion was green whether or not the installed version read the option -- and 1.7.2 does not read it at all. An assertion that cannot fail is indistinguishable from one that passed. Replace it with checks whose subject is what the REAL provider does: - an option-surface liveness scan over the INSTALLED provider dist, carrying a two-way control so a 0-hit reading is a measurement rather than silence; - an end-to-end block that boots a real authorization server from the exact options AuthManager produces and drives discovery -> DCR -> `authorize?resource=<mcp url>` -> consent -> token; - a guard that the per-client resource check stays ON, so satisfying the flow by switching a security check off turns this red instead. This commit is deliberately red: it is the reproduction. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012zTkyNHJ7TkuN2oXtP5x37
… succeed `@better-auth/oauth-provider` 1.7.2 resolves a requested `resource` from the `oauthResource` table and, with `enforcePerClientResources` at its `true` default, requires the client to be linked in `oauthClientResource`. Neither row was ever written, so every MCP client that sends `resource=` was refused at `/oauth2/authorize` with `invalid_target: requested resource <mcp url> is not configured`. No token could be minted on 17.3.0. Route (a): declare the resource rather than relax the check. - `resources: [mcpResourceUrl]` seeds the sys_oauth_resource row from the provider's own `init`, idempotently and `insertOnly`, so an admin's later policy edits survive a restart. - `clientRegistrationDefaultResources: [mcpResourceUrl]` links every newly registered client inside the DCR transaction -- the only place the link can happen, since a client registers anonymously about a second before login. - `enforcePerClientResources` stays at its `true` default. A client with no link row is still refused, and a test asserts that. Two dead options removed. Neither `validAudiences` nor `silenceWarnings` occurs anywhere in the installed `@better-auth/oauth-provider` or `better-auth` (0 hits each, against positive controls that fire), and the `oauthAuthServerConfig` notice `silenceWarnings` claimed to suppress no longer exists in 1.7.2 either. A field that is passed and read by nobody looks like configuration and enforces nothing -- that is how this defect survived a version bump, so the new option-surface liveness check refuses any such field rather than allowlisting these two. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012zTkyNHJ7TkuN2oXtP5x37
…p-oauth-resource-registration
…fallback by modelName Registering the MCP resource made the oauth-provider seed a `sys_oauth_resource` row from its plugin `init` — the first write this package ever performs during better-auth construction. Two latent boot-path defects became reachable as soon as it did, and both are fixed here: * `betterAuth()` returns synchronously and runs plugin `init` behind `auth.$context`, so anything a plugin does at init was a promise nobody held. A failure escaped as an UNHANDLED REJECTION (fatal to the process by default) and, in tests, as a boot write racing its engine teardown. `createAuthInstance` now awaits `$context`, making the seed part of "the instance is ready" and a boot failure a rejection of the call that asked for it. * The no-`dataEngine` fallback handed better-auth no `database` at all, which makes it build an in-memory store keyed by the schema KEY while every read resolves by `modelName`. Measured on better-auth 1.7.2: every renamed model — `user`/`sys_user` included, not just the oauth ones — answered "Model <name> not found" on that path. The fallback now builds the store itself, keyed the way the adapter reads it. Production is unaffected: it returns the ObjectQL adapter factory above this branch. The pin that asserted `database === undefined` is replaced rather than edited — it pinned exactly the branch this removes, and it read the value we passed rather than what that value does. Its successor drives the factory and asks the adapter for a renamed model. Two suites had their measurement windows corrected, not their assertions weakened: the sign-up refusal test now drains boot writes before arming its insert recorder, and the membership-policy double stops filing every insert as a membership regardless of which object it named. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012zTkyNHJ7TkuN2oXtP5x37
…p-oauth-resource-registration
…p-oauth-resource-registration
📓 Docs Drift CheckThis PR changes 1 package(s): 10 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 4 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 12 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 da728414bc6d90ae65f4a0d79e598deed4ff9349 && git checkout da728414bc6d90ae65f4a0d79e598deed4ff9349
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin a5d4e286b6dc85a52e9686052b060d7cdb7fbfe9 28c7da91ede7c30b04e37d07049873cc5f03297b && git checkout -B drift-repro a5d4e286b6dc85a52e9686052b060d7cdb7fbfe9 && git merge --no-ff 28c7da91ede7c30b04e37d07049873cc5f03297b
node scripts/docs-audit/affected-docs.mjs --json a5d4e286b6dc85a52e9686052b060d7cdb7fbfe9
|
Contract review (
|
… changeset minor Contract review of PR #16780 returned CHANGES REQUIRED on two points. F1 — the changeset level. The PR declares `Clause-②: yes` (the accept set of `/oauth2/authorize` grows) while grading `@objectstack/plugin-auth` `patch`. The maintainer's 2026-09-04 ruling (decision batch #35, the WHICH LEVEL prose in `pr-automation.yml`) settles the order between that and "a bug fix in a released package takes `patch`": a purely additive widening of a published package's public surface — "a new accepted key or value" — takes at least `minor`, and the commit type never lowers the bump below what the act requires. Graded `minor`. F2 — the missing negative control. The body claimed "a request naming any other resource is still refused exactly as before" and nothing tested it: `authorizeWithResource`'s `resource` parameter was never varied. Without that control a green suite cannot tell "the MCP resource is registered" from "resource checking is off" — the same axis as this card's original defect, where the assertion read the options we passed rather than what the provider does. The control is written as a DIFFERENTIAL against the real provider: one run, one booted AS, one DCR client, one session, and two authorize requests that differ only in `resource`. The registered MCP resource must reach consent; an identifier that was never registered must answer `invalid_target`. That shape reddens from both sides — remove the registration and the granted half fails, seed the second resource and link clients to it and the refused half does — where a bare refusal assertion would stay green under either. It asserts nothing about the options object; the resource inventory it checks at the end is read out of the AS's own store. A second control covers the token leg: a code bound to the MCP resource at authorize, redeemed with a `resource` the grant never carried, must be refused `invalid_target` and mint nothing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012zTkyNHJ7TkuN2oXtP5x37
…p-oauth-resource-registration
Contract review (
|
| item | status | evidence |
|---|---|---|
F1 — changeset level contradicted Clause-②: yes |
discharged | .changeset/mcp-oauth-resource-registration.md:2 now reads "@objectstack/plugin-auth": minor (was patch; 11854b605). Consistent with pr-automation.yml:667-682 WHICH LEVEL ("a new accepted key or value takes at least minor; the commit type may raise a bump but never lower it"). No BREAKING banner and no ADR-0087 marker owed: the seed sets allowedScopes: null, disabled: false (vendor string-form seed), so nothing previously accepted is refused. check-changeset-no-major.mjs:822 PUBLISHED_SOURCE_PATH = /^packages\/([^/]+)\/src\// still cannot see packages/plugins/plugin-auth/src/**, so judgeLevel (:933-953) has an empty grown set here and its green is the #16713 blind spot, not a clearance — but Check Changeset is not among the seven required status checks on main, so it neither gates nor misgates this merge. |
| F2 — wrong-resource control missing | discharged | auth-manager.mcp-oauth-resource.test.ts:441-485 is a real differential: one booted AS (bootRealAuthorizationServer, :145), one DCR client (:448), one session (:450), two /oauth2/authorize requests through the same authorizeWithResource (:248) differing only in resource — MCP_RESOURCE must not contain invalid_target and must reach /oauth/consent (:458-460); UNREGISTERED_RESOURCE (:71, …/api/v1/other) must contain invalid_target and not reach consent (:462-475); the resource inventory is read from the AS's own store, not the options (:481-484). Token leg :487-522: a code bound to the MCP resource redeemed with resource=UNREGISTERED_RESOURCE must be non-200, error === 'invalid_target', no access_token — the vendor rule it exercises is index.mjs:81 (requestedResources.some(r => !boundResourceSet.has(r)) → invalid_target). Mutation reasoning, by reading: removal of resources/clientRegistrationDefaultResources empties the resource table, so the granted half (:459) reddens; an over-broad seed that also links the second identifier makes resolveResourcePolicy (introspect-*.mjs:449) resolve it and assertClientLinkedToResources (:538) pass, so the refused half (:468) and the inventory (:481) redden, while the token leg stays green because the code was bound at authorize to the MCP resource alone — exactly what the body reports. No .skip/.only/.todo/xit in any of the five changed test files. |
| F3 — "security boundary untouched" overstated | discharged | Body now says which sentence is true. Re-derived: resolveEnforcePerClientResources (introspect-*.mjs:619-625) returns {value: true, source: "default"} when the option is undefined; auth-manager.ts:3532-3533 passes resources and clientRegistrationDefaultResources and does not pass enforcePerClientResources (grep of the file at the ref: only comment mentions). resolveClientRegistrationResources (authorize-*.mjs:1664-1685) always merges opts.clientRegistrationDefaultResources into the link set after getResource confirms the row exists and is not disabled; called at :1957 for every client created through the provider's registration path. allowUnauthenticatedClientRegistration: dcr (auth-manager.ts:3541) is byte-identical to origin/main:3501; resolveDcrEnabled (:340) unchanged. So: the per-client check is untouched, and every client the provider registers now holds the MCP audience — the body's corrected sentence is true. Widening is bounded to exactly one identifier, getMcpResourceUrl(), the same accessor the RFC 9728 document (:6069) and verifyMcpAccessToken (:6117) use. This is route (a) as ordered in the dispatch (card comment 5578514682: route (a) preferred, route (b) is the maintainer floor); the card is not a decision-box card (triage 5578245700). |
| F4 — one number not reproduced | discharged | Measured here: 17 files, 608,522 bytes. Occurrences: validAudiences 0, silenceWarnings 0, oauthAuthServerConfig 0, enforcePerClientResources 13, clientRegistrationDefaultResources 5, resourceSeedMode 4, invalid_target 29 — the body's table reproduces exactly on the occurrence basis it now states. |
Other checks asked of this seat
- Boot path,
await $context(auth-manager.ts:2438): a plugin-initfailure previously escaped as an unhandled rejection — process-fatal under Node's default and unattributable; it now rejects the call that asked for the instance. Every caller isgetOrCreateAuth()(:1236), which assignsthis.authonly on success, so a failed build is retried rather than cached. The two boot-time callers inauth-plugin.tsare already wrapped:registerOidcDiscoveryRoutesis invoked asvoid …().catch(…)(:3090) andresolveInstantiatedSocialProviderssits intry/catch(:373-380);handleRequest(:5437) surfaces it as a request failure. Unprovisioned-table case: vendorMISSING_TABLE_PATTERN(introspect-*.mjs:757,/no such table|relation.*does not exist|table.*does(?: not|n't) exist/i) defers to the lazy seed at:833/:852. This is consistent with the fail-fast boot posture ADR-0115's amendment records (initPluginWithTimeoutdoes not catch;bootstrap()rethrows) and strictly more attributable than before. Correct. - No-
dataEnginefallback (auth-manager.ts:3851-3860):getAuthTables(options)keys →db[table.modelName ?? key] = []→memoryAdapter(db)(options). The replacement pin (auth-manager.test.ts:344-370) drives the produced factory (capturedConfig.database(capturedConfig)) and asks for the renamed modelsys_user, expectingnullrather thanModel … not found. Both dynamic imports resolve against declared exact deps (package.json:29@better-auth/core1.7.2,:40better-auth1.7.2, unchanged from main). - Test corrections — scoping, not loosening.
signup-existing-address-refusal.test.ts:284builds the instance beforeinstrumentInserts, so the "nothing attempted" assertion stays total over the sign-up window (and only works because of the$contextawait).membership-policy-setting.test.ts:70-75files onlysys_memberinserts and:267-268/:310-311assert nosys_memberinsert plus_membersempty; see N3. - CI on
28c7da91e: 43 check runs, allcompleted— 37 success, 6 skipped (Packed-tarball ×2, Auto Label, Check PR Size, Build Docs, Console Pin Gate), 0 failure, 0 in progress; combined statussuccess(Vercel). All seven required contexts onmain's ruleset are green on this head: TypeScript Type Check, Test Core, Dogfood Regression Gate, Build Core, Temporal Conformance, Lint & Repo Gates, Governed Surface Queue Guard.mergeable_state: blockedis branch protection, not a red or running check: the ruleset carries amerge_queuerule (squash, ALLGREEN), the PR is a draft with zero reviews, and theneeds:contract-reviewcarrier holds it out of the queue. Nothing red, nothing running. - Diff outside the stated surface: none.
content/docs/releases/**untouched; the docs-drift bot's four release-page rows are advisory read-only hits, not edits.
New findings
- N1 (record) —
clientRegistrationDefaultResourceslinks every client created through the provider's registration path, not only RFC 7591 DCR in the narrow sense:authorize-*.mjs:1957callsresolveClientRegistrationResourcesfor everyregistrationSource, anddefaultResources(:1665) is merged in unconditionally; only the requested resources are gated onregistrationSource === "dynamic". The body's "any client that registers" is the accurate sentence; the changeset's "DCR-registered clients" is slightly narrower than the mechanism. No action. - N2 (observation) — one options-object assertion remains in the e2e test (
auth-manager.mcp-oauth-resource.test.ts:356-359,opts.resourcesmust contain the advertised identifier). It is a discovery↔seed drift check and is immediately backed by behaviour (:321store row identifier,:374-375authorize with the advertised string), so it is not the retired shape. No action. - N3 (observation) —
membership-policy-setting.test.tsnarrowedexpect(engine.insert).not.toHaveBeenCalled()to "nosys_memberinsert +_membersempty". The sibling test kept its total assertion by draining boot writes first (await manager.getAuthInstance()); the same move would have kept this one total too. The membership claim the test makes is still fully pinned. No action. - N4 (observation) —
$contextis a better-auth internal (dist/auth/base.mjs), guarded atauth-manager.ts:2438by optional chaining so a mocked or host-supplied instance without it is a no-op. A future vendor bump that renames it silently reverts to the pre-PR posture; the test atauth-manager.mcp-oauth-resource.test.ts:186also depends on it, so a rename would go red there. No action. - N5 (observation) — what this seat did not do: re-execute the suite or the two ablation legs (CI's Test Core shards are the execution evidence; the mutation logic was verified by reading the vendor resolution path above). The dist was measured read-only from a sibling worktree's store.
Maintainer-only merge: no by governed path (none touched). The auth-boundary widening is bounded to one resource identifier, is the route the dispatch seat ordered, and the dispatch seat recorded route (b) — not (a) — as the maintainer floor. That said, this is the one paragraph in the PR that changes who can hold an audience (F3), so a maintainer reading that paragraph once before the needs:contract-review carrier is cleared is warranted even though no rule requires it.
Generated by Claude Code
Fixes #16530
MCP OAuth could not complete at all on 17.3.0.
plugin-authconfigured@better-auth/oauth-providerwithvalidAudiences, an option the pinned 1.7.2 does not read. In 1.7.2 an RFC 8707resourceis resolved from theoauthResourcetable andenforcePerClientResourcesdefaults totrue, so a client must also be linked inoauthClientResource. Neither row was ever written, so everyresource=request was refused at/oauth2/authorize. Six Connect attempts over three days, zero tokens minted.Route taken: (a), the per-client check stays ON
resources: [mcpResourceUrl]seeds thesys_oauth_resourcerow from the provider's owninit;clientRegistrationDefaultResources: [mcpResourceUrl]links each newly registered client inside the DCR transaction — the only place the link can be made, since an MCP client registers anonymously about a second before the browser login and no administrator can insert a row in between.enforcePerClientResourcesis deliberately not passed, so it stays at itstruedefault. Route (b) — switching that check off — would have made the same flow pass by relaxing "which client may hold this audience". It was not needed: (a) is achievable on 1.7.2, measured. A test asserts the check is still live by behaviour: a client whose link row is removed is still refused.What that does and does not claim. The per-client check is untouched. The set of clients that end up holding the MCP audience is not, and those are two different sentences — the earlier wording, "the security boundary is untouched", ran them together and overstated the first.
clientRegistrationDefaultResourceslinks every dynamically registered client, andallowUnauthenticatedClientRegistrationis on and unchanged, so after this PR "which client may hold the MCP audience" is "any client that registers". The per-client check goes on discriminating only for clients that did not arrive through DCR — administrator-created and trusted ones. That is the designed outcome for public MCP clients and it is what route (a) is: the user still consents per client, the minted token is bound to user, scopes and audience, and DCR is rate-limited. Route (b) would have been a different act — removing the check that still governs the non-DCR population.The vendor reading, taken here rather than inherited
Triage stated verbatim that it could not verify this half — its environment had no
node_modules/@better-auth/oauth-provider/dist/. Measured independently against the installed 1.7.2 (dist/, 17 files, 608,522 bytes — the earlier revision of this body said 18, a miscount; the byte total was right and the review's 17 is the correct file count). Counts below are OCCURRENCES, not matching lines, which is whyclientRegistrationDefaultResourcesreads 5 here and 4 where lines were counted:dist/validAudiencessilenceWarningsoauthAuthServerConfigenforcePerClientResourcesclientRegistrationDefaultResourcesresourceSeedModeoauthResource/oauthClientResourceinvalid_targetThe controls fire in the runtime
.mjs, not only in the.d.mtstype surface, so a zero is a reading and not a broken search. Widened to the whole pnpm store,validAudiencesoccurs in 0 files whileclientRegistrationDefaultResourcesoccurs in 2 — the same method, both answers.silenceWarningswas removed on the same evidence: neither the option name, nor itsoauthAuthServerConfigkey, nor the notice text it claimed to suppress occurs anywhere in@better-auth/oauth-providerorbetter-auth1.7.2. It was the identical dead-option shape as the defect this card is about.Making the check able to fail, before fixing the code
The old assertion mocked
oauthProvider, tookmock.calls.at(-1)[0], and asserted that the options object we passed in containedvalidAudiences. The provider never consumed it, so it was green whether or not 1.7.2 read the option. It is gone.auth-manager.mcp-oauth-resource.test.tsboots a real authorization server from the exact optionsAuthManagerproduces and drives discovery → DCR →authorize?resource=→ consent → token, and the surviving mock-side block is renamed to say what it can and cannot answer.Ablation, on the committed tree — the fix reverted to its pre-fix shape, mutation proven on disk before the run (
git hash-object7368f501…→61643efb…; anchorsresources1→0,clientRegistrationDefaultResources1→0,validAudiences0→1), restore proven by state afterwards (bytes match theHEADblob,git diff HEADempty). Overauth-manager.mcp-oauth-resource.test.ts:The refusal it reproduces is the reported one, verbatim:
The per-client guard stays green under that mutation, which is what distinguishes it from a route-(b) change. It and the two-way dist scan are the two survivors above.
The wrong-resource control is a DIFFERENTIAL, and that is the whole point
Before this PR the AS answered
invalid_targetfor every resource, so a bare "an unregistered resource is refused" assertion is green on both sides of the fix and measures nothing. The control is therefore written as a differential: one booted AS, one DCR client, one session, two/oauth2/authorizerequests that differ only inresource. The registered MCP resource must reach consent;https://acme.example.com/api/v1/other, which nothing ever registered, must answerinvalid_target. A second control redeems a code bound to the MCP resource against that other identifier at/oauth2/tokenand requiresinvalid_targetwith nothing minted.It asserts nothing about the options object. The resource inventory it checks at the end is read out of the running AS's own store — "assert the option we passed" is the shape this card exists to retire, and re-introducing it here would have re-introduced the defect one layer up.
Both refutation directions were run on the committed tree. Each leg proves its mutation on disk before the run (anchor
grep -cplusgit hash-objectdiffering from theHEADblob) and proves its restore by state afterwards (git checkout HEAD -- ABSPATHunder atrap … EXIT INT TERM; blob back to theHEADblob,git diff HEADempty):auth-manager.ts(HEADblob7368f501…)61643efb…;resources1→0,clientRegistrationDefaultResources1→0,validAudiences0→1the registered MCP resource must still be granted: expected 'http://localhost:56789/callback?error…' not to contain 'invalid_target'— 7 failed / 2 passed0defb8ab…;resources1→0,clientRegistrationDefaultResources1→0,/api/v1/other0→2…/api/v1/otheroff to/_console/oauth/consentinstead of refusing it — 4 failed / 5 passedThe removal leg says the control cannot be vacuous. The over-broad leg says it is the specific registration being measured, not "resource checking is on somewhere" — and only that leg separates "we registered the MCP resource" from "the AS grants whatever it is asked for". Under the over-broad seed the token-leg control stays green, correctly: the code is still bound at authorize to the MCP resource alone, so the subset rule at redemption still refuses the other identifier. The per-client guard stays green there too — over-broad seeding is not the same act as switching the per-client check off, and the two controls separate those as well.
Table counts, before and after
Counted in the harness the same way the report counted them, keyed by the platform table names:
sys_oauth_resourcesys_oauth_client_resourcesys_oauth_access_tokensys_oauth_refresh_tokensys_oauth_access_tokenlegitimately stays 0. In 1.7.2isJwtAccessToken = audienceClaim && !opts.disableJwtPlugin, and only the opaque branch (createOpaqueAccessToken) persists a row. An MCP token carries an audience and the jwt plugin is on, so the access token is a signed JWT — the minted-token evidence is the JWT itself, whoseaudthe test asserts contains the MCP resource and whoseissis the issuer. The persisted evidence of a completed grant is the refresh row, whichoffline_accessearns.The end-to-end test starts from the RFC 9728 discovery document and never re-types the resource: it asserts the advertised
resourceis the same string the AS is seeded with, then drives authorize and token with that value. An AS that seeds one spelling while advertising another reproduces this defect class, and a flow that hard-codes the identifier cannot see it.Two boot-path defects the seed uncovered
Seeding is the first write this package performs from a plugin
init, and it made two latent problems reachable. Both are fixed here because this change is what makes them fire.betterAuth()returns before its plugininithooks settle, running them behindauth.$context. Anything a plugin did at init was a promise nobody held: a failure escaped as an unhandled rejection — fatal to the process under Node's default — and, in tests, as a boot write racing its engine's teardown.createAuthInstancenow awaits$context, so a boot failure rejects the call that asked for the instance. Measured: 68 unhandled rejections before, 0 after.The no-
dataEnginedevelopment fallback passed better-auth nodatabase, which makes it build an in-memory store keyed by the schema key while every read resolves bymodelName. Measured on 1.7.2:getAuthTables()returns{ oauthResource: { modelName: 'sys_oauth_resource' } }and the store gets the keyoauthResource, so the adapter's request forsys_oauth_resourceanswersModel … not found. Every model this package renames was unreachable on that path —user/sys_useras much as the oauth ones. It stayed invisible only because nothing had ever touched a renamed model during boot. Production never reaches this branch; it returns the ObjectQL adapter factory above it.Why the seed is safe at boot in production:
sys_oauth_resourceis registered by plugin-auth itself (authIdentityObjects), and the auth instance is built lazily on the first request, long after plugin registration. If the table has not been provisioned yet, the provider's ownMISSING_TABLE_PATTERNmatches the driver's "no such table" and defers to its lazy seed on first resource access.Three existing checks were corrected rather than weakened:
database === undefinedis replaced, not edited — it pinned exactly the branch removed, and it read the value passed rather than what that value does. Its successor drives the factory and asks the adapter for a renamed model.signup-existing-address-refusal.test.tsdrains boot writes before arming its insert recorder, so its "nothing was written, nothing was even attempted" assertion stays total and now really measures the sign-up.membership-policy-setting.test.tsdouble stopped filing every insert as a membership regardless of which object it named; asys_oauth_resourcerow was arriving in_membersas{organization_id: undefined, user_id: undefined}.Verification
Run through
scripts/pm/os-verify-lock.sh; verdicts read off the wrapper's ownVERDICTline, exit codes captured before any pipe.pnpm --filter @objectstack/plugin-auth test— 106 files, 2215 tests passed,VERDICT command-exit 0.pnpm --filter @objectstack/plugin-auth typecheck—VERDICT command-exit 0(all three legs:tsc, the examples project,check:test-typecheck, the last reporting the test layer compiles undertsconfig.test.jsonwith its ledger held).pnpm --filter '@objectstack/plugin-auth^...' buildandpnpm --filter @objectstack/plugin-auth build— both exit 0.node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commandsafter mergingorigin/main— the first derivation reportedSTALE TREE(50 commits behind, 19 files it derives from changed), so it was discarded rather than reported. Post-merge: 57 derived, 57 run, 0 UNRUN; the command list is byte-identical to the pre-merge one. 56 exit 0.check:type-check-debtfirst came back exit 3 =PREREQUISITE NOT MET(four workspace dependencies had no built type entry point), so it was not reported as a pass: the four were built and it was re-run. It then OOM'd at the caller's--max-old-space-size=4096, which the gate itself names as the binding ceiling; re-run at 8192 it is exit 0 — 5 ledger entries re-measured in 85.1s, 55 raw tsc errors, none above its recorded number.check:dual-build-cjs-loadsremains exit 3 =PREREQUISITE NOT MET— NOT MEASURED, and explicitly not reported as a pass. It reads built output for 40 packages that have nodist/here; satisfying it is a whole-repopnpm build, which is CI's run, not a narrowing this PR can make.check:route-envelopedoes not apply and was not run: it is in dispatch-gates' silent bucket, and this diff adds no routes module and contains zeroc.json(/res.json(call sites.Measurements above were taken at the head of this branch after the
origin/mainmerge.验收备注
分诊席
5578245700与派发席5578514682的验收口径,逐条:authorize?resource=MCP_URL不再答invalid_target。消融证明它能红(该文件 9 条中 7 条转红,含逐字复现的生产报错);并补一条错误 resource 的差分对照,两个方向各自跑过一腿。aud指向 MCP resource;三张表修复前后行数见上表(含sys_oauth_access_token为何合法地保持 0 的机制说明)。enforcePerClientResources保持true,不放宽安全面;(b) 未被采用,因为实测 (a) 在 1.7.2 上可行。validAudiences已删;同一形态的silenceWarnings一并删除,依据是同一次带阳性对照的 dist 读数。⛔ 不在本卡:CLI 启动横幅从监听端口拼
http://localhost:4001/api/v1/mcp—— 分诊席已单独归档,是packages/cli的独立面,本 PR 未触碰。Contract review — patch round
Review: #16780 (comment) (isolated
CONTRACT_REVIEW_TIERseat, verdict CHANGES REQUIRED at5cd3b999a). Its security reading came back clean; two things were owed and one was recorded.minor, see the Gate section below for the ruling applied. The review's aside that the LEVEL-axis gate could not seepackages/plugins/*(itsPUBLISHED_SOURCE_PATHregex) is that gate's card, [finding] The changeset LEVEL axis is blind to every NESTED package:packages/*/src/**matches one segment, so 51 of 74 workspace packages (all drivers/services/adapters) can pairClause-②: yeswithpatchand stay green #16713, and is deliberately not touched here.Gate
Clause-②: yes, re-derived from the actual surface rather than inherited. The accept set of/oauth2/authorizestrictly grows — a request carryingresource=MCP_URLfrom a DCR-registered client moves from always-refused to accepted — and it is externally observable at a published protocol endpoint third-party clients consume. It is a pure widening: seeding by the string form setsallowedScopes: null,disabled: false,dpopBoundAccessTokensRequired: false, so nothing previously accepted becomes refused, and a request naming any other resource is still refused exactly as before — that last clause is no longer only a claim: it is the differential control above, red under an over-broad seed. The contract review has since run and concluded (CHANGES REQUIRED at5cd3b999a, addressed above), soneeds:contract-reviewis currently off both carriers; re-hanging it against this patched head is the PM seat's act, not this branch's. This PR stays in draft.Changeset:
minoron@objectstack/plugin-auth. It waspatch, citingAGENTS.md:1029("a bug fix in a released package takespatch"), which contradicted this PR's ownClause-②: yes. The maintainer's ruling of 2026-09-04 (decision batch #35 on #15294, the WHICH LEVEL prose inpr-automation.yml) settles the order between those two rules: a purely additive widening of a published package's public surface — "a new accepted key or value" — takes at leastminor, and the commit type "may raise a bump but never lower it below what the act requires". The accept set of a published endpoint grew and this body says so, sominoris the level. Notskip-changeset: this publishes. Nothing breaking, so no ADR-0087 marker is owed.🤖 Generated with Claude Code
https://claude.ai/code/session_012zTkyNHJ7TkuN2oXtP5x37
Generated by Claude Code