Repository navigation
fix(runtime): on the standalone server every /actions verb reaches the actions domain, so an anonymous caller gets 401, and no Allow lists a verb mounted only to refuse - #24244
Conversation
…sport's Allow never answers a path whose domain decides every verb Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QyoWKK9RwUNxBxmevy9Neb
…n a real socket Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QyoWKK9RwUNxBxmevy9Neb
Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QyoWKK9RwUNxBxmevy9Neb
📓 Docs Drift CheckThis PR changes 1 package(s): 9 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 — 26 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 bb40fe50ccccc2c95f69e0f0a9cc56a20d627c73 && git checkout bb40fe50ccccc2c95f69e0f0a9cc56a20d627c73
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin af284b05e55c171718c456195620094fe058b267 6c8625968682d5c24c67e5132aad2ada42e98ca9 && git checkout -B drift-repro af284b05e55c171718c456195620094fe058b267 && git merge --no-ff 6c8625968682d5c24c67e5132aad2ada42e98ca9
node scripts/docs-audit/affected-docs.mjs --json af284b05e55c171718c456195620094fe058b267
|
席位核对 — ACCEPT · PR #24244 at
|
Brings in cbfa5e4 (#24244, packages/runtime only); no path overlaps this branch. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QyoWKK9RwUNxBxmevy9Neb
Fixes #24228
Clause-②: no
Basis for the Clause-② line: every request whose answer changes is refused before and after. On the standalone server an anonymous non-
POSTrequest to/api/v1/actions/...moves from the HTTP adapter's405to theactionsdomain's401, and a request under a verb no route takes on/keys, the/notificationsfaces or/mcp/skillmoves from the adapter's405(whoseAllowlisted refusal-only verbs) to the domain's own refusal. No verb that was refused is admitted, no route is added, and no accept set or export widens.What changed
packages/runtime/src/dispatcher-plugin.ts, three parts:domainDecidesEveryVerb). Each one is the serving route's verb and pattern, made beside that route: the threePOST /actionspatterns inregisterActionRoutes(so both the unscoped and the/environments/:environmentIdbase),POST /keys, the three/notificationsfaces andGET /mcp/skill.setFallbackHandlerruns only when the router matched no route, and before the adapter's own unmatched answer. When the transport's router resolves the request path, under a declared verb, to a declared pattern (IHttpServer.resolveMountedRoute, the router's own verdict, no second matcher), the request goes todispatch()ascreateHonoApp's catch-all sends it:req.method, the whole sub-path after the prefix (sodispatch()reads the environment off a scoped path, since an unrouted request has no route params),req.query, and no body. The domain answers. Its anonymous baseline is its first statement (ADR-0138 D2), and its405carries its ownAllow. Any other unmatched request is handled as before: the declarative-endpoint step, then the adapter's 404/405.resolveMountedRoutekeeps the adapter's answer on those paths, and the boot says so once, atwarn, with the consequence and the remedy. The existing no-setFallbackHandlerwarning names the same consequence in one more clause.HonoServerPlugin+createDispatcherPlugin, CORS off):packages/runtime/src/actions-every-verb.hono.integration.test.ts(79 cases): three path shapes on both prefixes. ForGET,PUT,PATCH,DELETEandTRACE, an anonymous call gets401 UNAUTHENTICATED,error.httpStatus401, noerror.detailsand noAllow. A signed-in call gets405 METHOD_NOT_ALLOWED,Allow: POST,error.httpStatus405 and noerror.details, which is the domain's door and not the adapter's.HEADis checked by status and header. AnonymousOPTIONSgets the domain's405+Allow: POST(see Acceptance notes). Controls: anonymousPOST401 unchanged, and a four-segment path stays the adapter's 404.keys-every-verb.hono.integration.test.ts:OPTIONSandTRACE, 4 cases (anonymous 401 noAllow; signed-in domain 405 withAllow: POST).notifications-every-verb.hono.integration.test.ts:OPTIONSandTRACEon each face, 12 cases (anonymous 401; signed-in the dispatcher's404 ROUTE_NOT_FOUND, noAllow).mcp-skill-method-not-allowed.hono.integration.test.ts: itsPUTboundary case pinned the adapter'sAllow['DELETE', 'GET', 'HEAD', 'POST'], which is the defect. It is replaced byPUT,PATCH,OPTIONSandTRACEgetting the domain branch's405withAllow: GET.dispatcher-plugin.fallback-absence-warn.test.ts: the new warning, once atwarn, and silence when the resolver exists.domains/keys-actions-method-not-allowed-allow.test.ts(PR fix(runtime): the keys and actions domains answer their 405 with an Allow header naming POST #24227's pin, the control): assertions unchanged, still green. Its docblock and one title said the standalone/actions405 is the adapter's becauseGETis not mounted. After this change that is false, so the wording now says it is the domain's, through the fallback seam..changeset/24228-actions-every-verb-anonymous-floor.md:@objectstack/runtimepatch.Not touched:
packages/spec(theIHttpServercontract line included),packages/plugins/plugin-hono-server,domains/actions.ts, thedispatcher.dispatch(… req.body …)sites (no existing mount changed),readGatedSessionUser,route-ledger.ts(no route is mounted, so no row and no census move).The route, and why it is not refusal-only mounts (H2, measured)
AllowisallowedMethodsForPathoverregisteredRoutes, and onlyget/post/put/delete/patch(path, handler)fillregisteredRoutes. Nothing in that signature marks a mount as refusal-only. A marker would have to ride on the handler object, which is a cross-package protocol the contract does not declare, or be a new parameter, which is apackages/specchange. This PR takes neither.Allow. That answer runs after the fallback seam declines. So if the domain answers every request the router cannot route on its paths, the adapter never builds anAllowthere. This is the first candidate in H2. It needs nopackages/specchange and no adapter change, and the contract line stays true where the adapter does answer./keysshape for/actions. With the fiveIHttpSerververbs mounted, a verbIHttpServercannot register still reaches the adapter. That isOPTIONSwhen CORS is off. It is alsoTRACEor an extension method such asPROPFINDwith CORS on too, because CORS answers onlyOPTIONS. The adapter'sAllowthere is every registered verb. So refusal-only mounts on/actionswould turn today's accurateAllow: POSTintoDELETE, GET, HEAD, PATCH, POST, PUT. With the fallback the four mounts are not needed. One mechanism covers every verb on/actions, and it covers the verbs left over on the families that already have the mounts.Measured
Standalone composition in process:
LiteKernel, a fakeauththat answers a session only for a test header,HonoServerPluginon a real socket,createDispatcherPlugin, andregisterShareLinkRoutesatkernel:ready. Four compositions: CORS off or on, each with project scoping off or on (enableProjectScoping: truemounts both prefixes). For each, 14 paths × 9 verbs (GET HEAD POST PUT PATCH DELETE OPTIONS TRACE PROPFIND) × anonymous and signed in, through rawnode:http. That is 1008 requests at base2bcd406a2and 1008 at headd60989609. The implementation blob24648a251fc8has been the same from there to the final head6c8625968. 368 rows changed. Every changed row was the adapter's405at base and is the domain's answer at head: 148 now401, 172 now the domain's405, 30 now the dispatcher's404, and 18HEADrows went from405to401. No served answer moved.H1, confirmed. At base, every anonymous
GET/HEAD/PUT/PATCH/DELETE/TRACE/PROPFINDon all three/actionsshapes and both prefixes got405+Allow: POSTwith the adapter'serror.details, CORS on and off.OPTIONSgot the same with CORS off and204with CORS on./api/v1/actions/task/close(all shapes, both prefixes alike)createHonoAppGETPUTPATCHDELETETRACE/PROPFIND405,Allow: POST, adapter401 UNAUTHENTICATED, noAllow401, noAllowHEAD405,Allow: POST401, noAllow401POST405,Allow: POST, adapter envelope405,Allow: POST, domain envelope405,Allow: POSTOPTIONS, CORS off405,Allow: POST, adapter405,Allow: POST, domain405,Allow: POSTOPTIONS, CORS on204204204The
createHonoAppcolumn:createHonoApp({ kernel, prefix: '/api/v1' })over the same fakeauth, driven withapp.request, CORS off and on.PROPFINDstands in forTRACEthere, because the fetchRequestrefusesTRACE.H3, the
Alloweach refusal-only family answers (a verb no route takes:OPTIONSwith CORS off,TRACE/PROPFINDwith CORS either way, and on/mcp/skillalsoPUT/PATCH):Allowat base/keysPOSTDELETE, GET, HEAD, PATCH, POST, PUT401noAllow; signed-in domain405Allow: POST/notificationsGETDELETE, GET, HEAD, PATCH, POST, PUT401; signed-in404 ROUTE_NOT_FOUND; noAllow/notifications/readPOST/notifications/read/allPOST/mcp/skillGETDELETE, GET, HEAD, POST405Allow: GET/actions(3 shapes × 2 prefixes)POSTPOST(accurate; the defect was the missing 401)Allow: POSTsigned in; anonymous401/share-links(root)GET,POSTGET, HEAD, POST/share-links/abcDELETEDELETE, GET, HEAD, PATCH, POST, PUT/share-links/abc/resolveGETThe head column matches the
createHonoAppanswers row for row on/actions,/keys,/notifications/readand/mcp/skillforGET/PUT/OPTIONS/extension verbs. Two exceptions predate this PR and are listed in Acceptance notes.H4, confirmed. PR #24227's pin is green with its assertions unchanged. Its standalone
/actionsrow (405,Allow: POST) now reads the domain's answer instead of the adapter's.Tests
194b25dc2. The runtime tree at194b25dc2equals the final head for every file this PR touches, and the implementation blob is the same (24648a251fc8). Command:pnpm --filter @objectstack/runtime exec vitest run --project local --maxWorkers=2over the six touched test files plusdispatcher-plugin.routes,share-links-every-path,dispatcher-plugin.endpoint-fallbackanddispatcher-plugin.streaming-fallback. Result:Test Files 10 passed (10),Tests 234 passed (234).6c8625968, after mergingorigin/mainand rebuilding (pnpm install --frozen-lockfile,turbo run build --filter='@objectstack/hono...'):pnpm --filter @objectstack/runtime exec vitest run --project local --maxWorkers=2givesTest Files 413 passed (413),Tests 6411 passed | 19 skipped (6430).pnpm --filter @objectstack/runtime typecheckat6c8625968: exit 0,check:test-typecheck: OKwith the ledger unchanged. All six touched test files are in thetsconfig.test.jsonprogram (--listFiles).pnpm exec eslint --no-inline-configover the seven changed.tsfiles: exit 0.test:repo(therepovitest project) was not run locally. No file of that project changed, and CI'sTest Coreruns it.Ablations (one per member, not kept). Each ran after the fix was committed, through
node scripts/ablation-replace.mjsin WRAP mode, inside a script that also carries an EXIT/INT/TERM trap restoring fromHEAD. Every pin imports./dispatcher-plugin.jsfrom source, and@objectstack/plugin-hono-server(read from itsdist/) is not mutated, so nodist/of the mutated module sits on the resolution path./actionsdeclarations replaced by a marker. The anchor went 1 to 0 and the blob24648a251fc8to4bcfc24a0ac7. Result:Tests 72 failed | 27 passed (99)over the actions pin, the keys pin and PR fix(runtime): the keys and actions domains answer their 405 with an Allow header naming POST #24227's control. The 72 are every anonymous, signed-in,HEADandOPTIONSrow of the actions pin (expected 405 to be 401,expected undefined to be 405). Green: its 6POSTcontrols, its 404 control, the keys pin and the control file. Restore: blob equalsHEAD(24648a251fc8),git diff HEADis empty, and the marker count is 0./keys,/notificationsand/mcp/skilldeclarations, three nested anchors, each 1 to 0 (blob24648a251fc8, then1bfef7b8cabc, then970cf8d3dba7, then258148332710). Result:Tests 20 failed | 124 passed (144). The 20 are the newOPTIONS/TRACErows and the four/mcp/skillrows. Messages includeexpected 'DELETE, GET, HEAD, PATCH, POST, PUT' to be 'POST',expected 'DELETE, GET, HEAD, POST' to be 'GET',expected 405 to be 401andexpected 405 to be 404. The actions pin stayed green. Restore was proven at each of the three levels (blob equalsHEAD,git diff HEADis empty).Gates
node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstackat the final head6c8625968derived 62 commands. Each was run with its exit code captured before any pipe and recorded asCOMMAND :: exit N.--ranreconciles:62 derived, 61 run, 1 NOT-MEASURED, 0 UNRUN. The 61 that ran exit 0,check:route-envelopeandcheck:nul-bytesamong them.pnpm check:dual-build-cjs-loads. Reason:PREREQUISITE NOT MET(exit 3). It reads the builtdist/of every package, 39 have none in this worktree, and the whole repository was not built locally. This diff changes noexports, nopackage.jsonand no build config. CI'sLint & Repo Gatesruns it.node scripts/check-platform-checklist.mjs(no exported symbol renamed or removed), and the local-onlycheck:dev-prereqsandcheck:skill-frame-freshness(nothing underpackages/specor the skill frame changed). The repo-widepnpm lintis the PR'sLint & Repo Gatescheck-run.Acceptance notes
/share-links/*still lists refusal-only verbs inAllow. On the standalone server/share-linksisplugin-sharing's family (registerShareLinkRoutes), not the dispatcher's. Its${base}/*catch-all is mounted for the five verbs. SoOPTIONSwith CORS off, andTRACE/PROPFINDwith CORS either way, on/api/v1/share-links/abc(served:DELETE) and/api/v1/share-links/abc/resolve(served:GET) get the adapter's405withAllow: DELETE, GET, HEAD, PATCH, POST, PUT. This holds at base and at head. The route here does not reach it: the fallback seam is one handler and the dispatcher's, and a dispatcher answer on/share-linkswould be a second owner of that surface.plugin-sharingis outside this claim's file surface. Reported to the seat with the options.OPTIONSon/actions(CORS off only) gets the domain's405+Allow: POST. That is the same answer as on acreateHonoApphost. The shared anonymous decision lets everyOPTIONSthrough as a CORS preflight, anddomains/actions.tsis read-only here. This is PR fix(runtime): the keys and actions domains answer their 405 with an Allow header naming POST #24227's open question, unchanged.X-Request-Idecho, because the seam is installed on the raw server. That is the same as the adapter answer it replaces, and the transport's response observation still labels them unmatched.HEAD /api/v1/mcp/skillgets501on the standalone server (Hono servesHEADfrom theGETroute) and405+Allow: GEToncreateHonoApp./notifications404 isROUTE_NOT_FOUNDon the standalone server andRESOURCE_NOT_FOUNDoncreateHonoApp, as runtime/plugin-sharing: on the standalone server, anonymous callers to unmounted /notifications and /share-links verbs and paths get 405 or 404 where the v18 floor promises 401 #24024's notes record./share-linksroot. Hono routesPUT /api/v1/share-linksto the/share-links/*catch-all, but the adapter'smatchesRoutePatterndoes not count that pattern for the root. SoAllowthere lists onlyGET, HEAD, POST, which happens to be accurate. Read from the measurement, no wrong answer. Carrier: none.Generated by Claude Code