Repository navigation
fix(runtime): the keys and actions domains answer their 405 with an Allow header naming POST - #24227
Conversation
…llow header naming POST RFC 9110 requires an Allow header on a 405. Both domains built theirs through deps.error, which carries no headers. They now hand-roll the response the way the mcp, meta and ai domains do, with the body still built by buildApiError so the code stays derived from the status. The anonymous baseline stays each handler's first statement. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QyoWKK9RwUNxBxmevy9Neb
…ader Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QyoWKK9RwUNxBxmevy9Neb
…llow Both domains now hand-roll their 405 so it can carry the Allow header deps.error cannot. The ledger names each as kind 1 (enveloped, built by hand only for the header), as it already does for mcp, meta and ai. 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): 1 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
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 f5d3b490edf77856b16bb3437fd5938df9ab4310 && git checkout f5d3b490edf77856b16bb3437fd5938df9ab4310
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin b9ac35cbfe60afcea88947ed4eaf8c50e3efc214 761b73cbdde1609ca3436b1355703b537c299d98 && git checkout -B drift-repro b9ac35cbfe60afcea88947ed4eaf8c50e3efc214 && git merge --no-ff 761b73cbdde1609ca3436b1355703b537c299d98
node scripts/docs-audit/affected-docs.mjs --json b9ac35cbfe60afcea88947ed4eaf8c50e3efc214
|
…ying Allow The keys and actions 405s now hand-roll their response with an Allow header and a buildApiError body, so the comment that said they went through deps.error was false. Comment-only. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QyoWKK9RwUNxBxmevy9Neb
席位核对 — ACCEPT · PR #24227 at
|
Fixes #24023
Clause-②: no
Basis for the Clause-② line: no request that was accepted becomes refused, and none that was refused becomes accepted. A refusal that already answered
405 METHOD_NOT_ALLOWEDnow also carries anAllow: POSTheader. Its status, body anderror.codedo not change.What changed
packages/runtime/src/domains/keys.tsandpackages/runtime/src/domains/actions.ts. The 405 for every verb butPOSTwasdeps.error('Method not allowed', 405).deps.errorreturns{ status, body }and nothing else. That was read off its definition (HttpDispatcher.error, which callsapiErrorResponseinerror-envelope.ts), not off the comment inmeta.ts. Each site now builds its response by hand, in the shapemcp.ts,meta.tsandai.tsalready use:status: 405,headers: { Allow: 'POST' }, and the body throughbuildApiError({ message, httpStatus: 405 }). So the code stays derived (METHOD_NOT_ALLOWED) and is not spelled. The message and the body are byte-identical to before. The anonymous-baseline statements stay the first statement of both handlers, above the 405.POST. Each domain servesPOSTalone, on every path it claims.handleKeysRequestnever reads its path.handleActionsRequestchecks the method before it parses the path, and its activation door (isActionActivationWrite) isPOSTtoo. Each value is one named constant (KEYS_ALLOWED_METHODS,ACTIONS_ALLOWED_METHODS).scripts/check-route-envelope.mjs: theDISPATCHER_DOMAINSledger now countskeys.ts1 to 2 hand-built responses andactions.ts0 to 1. Both are kind 1 (enveloped, built by hand only for the header), each with a note.pnpm check:route-envelopewent red without this entry, and its failure text asks for it ("raise the count and say which innote"). Themeta.tsandai.ts405s raised the same ledger. This file is outside the claim's listed file surface. It is the declaration the gate requires for this exact change, and the report says so too.packages/runtime/src/domains/keys-actions-method-not-allowed-allow.test.ts..changeset/24023-keys-actions-405-allow.md:@objectstack/runtimepatch.meta.tsis held by another lane, so a helper could not replace all five hand-built 405s in this PR. The dispatch rule allows a helper only if it replaces every one of them.The sweep (Done when, third line)
git grep -n 405 -- packages/runtime/src/domains ':!*.test.ts'at baseaa31569b2finds exactly twodeps.error(…, 405)sites,keys.ts:112andactions.ts:446. The other three 405 producers already sendAllow:mcp.ts:183,meta.ts:1583andai.ts:304. The same search at head71613b4effinds zerodeps.error(…, 405)sites and fivestatus: 405literals, and each of the five has aheaders: { Allow: … }line under it. Thedeps.errorcalls that take their status from a variable were checked too. No producer that feeds them has a 405: the only other non-comment405literals inpackages/**/srcare the data door's exposure refusal (Acceptance notes) and spec constants.Measured on both hosts (H4)
The build is head
e614b2adb, driven in process. ALiteKernelcarries a fakeauthservice that answers a session only when the request has a test header.createHonoApp({ kernel, prefix: '/api/v1' })is driven throughapp.request. The standalone server isHonoServerPluginpluscreateDispatcherPluginover a real socket andfetch. Both were run with CORS left at its default and with CORS off.createHonoAppGETorDELETE /api/v1/keys405,Allow: POST, domain envelope (httpStatus: 405)405,Allow: POST, domain envelopeGETorPUT /api/v1/actions/task/close405,Allow: POST, domain envelope405,Allow: POST, the adapter's envelope (error.details): onlyPOSTis mounted, so the domain is never reachedGET /api/v1/keys401 UNAUTHENTICATED, noAllow401 UNAUTHENTICATED, noAllowGET /api/v1/actions/task/close401 UNAUTHENTICATED, noAllow405,Allow: POST, the adapter's (see Acceptance notes)OPTIONSon either path, CORS default204from CORS204from CORSNeither host drops a domain's
headers.createHonoApp'stoResponsewritesresult.response.headers, and the standalonesendResultBasewrites them after the security headers. So the producer was the domain, as the card says, and there was nothing to fix indomain:cli's host code. The base answer (noAllowon either host's domain 405) is the filer's reading and PR #24009's. On this branch it is reproduced by the ablation below.Tests
dispatch(), the call both hosts make, per domain: a signed-inGETgets405,headers.Allow === 'POST',error.codeMETHOD_NOT_ALLOWED,error.httpStatus405, and the body parsed against the spec'sBaseResponseSchema/ApiErrorSchema. An anonymousGETgets401 UNAUTHENTICATEDwith noAllow. On the standalone socket: the keys405withallow: POSTon the wire and the domain's envelope, the anonymous keys401with noallow, and the actions405withallow: POST. That last answer is the adapter's and passes at base too. It is kept so that "both hosts" is a statement about the wire for both domains.node scripts/ablation-replace.mjs, nested, put both sites back to today'sreturn { handled: true, response: deps.error('Method not allowed', 405) };. Evidence: keys anchor 1 to 0, blobf6d9a5547690to0365f3e2c755; actions anchor 1 to 0, blobe15f8b9fff19tobda747ac32f9. The pin then gaveTests 3 failed | 4 passed (7). The 3 failures are the dispatch-level keys and actionsAllowrows and the socket keys row, eachexpected undefined to be 'POST'orexpected null to be 'POST'. The anonymous rows and the adapter row stayed green. Restore: blob equals HEAD for both files, andgit diff HEADis empty for both. The pin imports../http-dispatcher.jsand../dispatcher-plugin.jsfrom source, so nodist/of the mutated package sits on its resolution path.pnpm --filter @objectstack/runtime exec vitest run --project local --maxWorkers=2at3fbad0f81:Test Files 410 passed (410),Tests 6287 passed | 19 skipped (6306).packages/runtimehas no byte change from3fbad0f81to the head71613b4ef.pnpm --filter @objectstack/runtime typecheck: exit 0. The test-layer program (tsconfig.test.json) lists the new file (--listFiles: 1 hit among 413 test files), andcheck:test-typecheckholds its ledger unchanged.pnpm exec eslint --no-inline-configover the four changed code files: exit 0.keys-every-verb.hono.integration.test.ts,error-envelope.conformance.test.ts(itshandleActions(… 'GET' …)405 case) anddomains/anonymous-gate-actions-automation.test.ts.repovitest project (test:repo) was not run locally. This diff touches none of its files, and CI'sTest Coreruns it.Gates
node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstackat head71613b4ef, after the merge oforigin/main, derived 84 commands. Each was run with its exit code captured before any pipe.--ranreconciles:84 derived, 83 run, 1 NOT-MEASURED, 0 UNRUN. The 83 that ran all exit 0.pnpm check:dual-build-cjs-loads. Reason:PREREQUISITE NOT MET(exit 3). It reads the builtdist/of every package, 38 of them 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.pnpm check:route-envelopewas red on the first run, at3fbad0f81, for the two hand-built 405s. The ledger entry above turned it green, and it is green in the final run.node scripts/check-platform-checklist.mjs, because no exported symbol was renamed or removed. The local-only familiescheck:dev-prereqsandcheck:skill-frame-freshnesswere not run either, because nothing inpackages/specor the skill frame was touched.Acceptance notes
/actionspasses the anonymous decisionmethod(shouldDenyAnonymousletsOPTIONSthrough for CORS preflight). So an anonymousOPTIONSreaches the actions405, and with this change that answer carriesAllow: POST. Measured: this happens only on acreateHonoApphost with CORS off. With CORS on (the default on both hosts) everyOPTIONSis answered204before routing. The standalone server with CORS off already answers that same request405withAllow: POST, from the adapter. HoldingAllowback from that one caller would leave a 405 withoutAllow, which is what the card's first "Done when" line forbids./keysdecides an anonymous caller without the method, so its anonymousOPTIONSis401. This is in the report'sopen_questionsfor the seat to rule on if it reads the rule otherwise.domains/mcp.tsat about:174–:179: the comment still says the 405s inactions.tsandkeys.ts"go throughdeps.error", which this PR makes false. The fix is one comment edit, andmcp.tsis outside the claim's file surface. Carrier: none.error-envelope.conformance.test.tsdoes not listdomains/keys.ts,domains/actions.ts(nordomains/meta.ts) in its source-scanMODULES. So their hand-built bodies are held by this pin's envelope parse and bycheck:route-envelope's ledger, not by that scan. Carrier: none.deps.error(…, 405)search. That refusal is405 OBJECT_API_METHOD_NOT_ALLOWED, thrown inaction-execution.tsand answered throughdeps.errorFromThrownindomains/data.ts;RestServer's twin is the same. It carries noAllow, and itsallowedfield lists API operations, not HTTP methods. Read from source, not measured on the wire. Carrier: none.handleKeysRequestnever reads its path, so on acreateHonoApphost/api/v1/keys/ANYTHINGreaches the domain, andAllow: POSTis accurate there. The standalone server answers404for any/keyssub-path. Read from source, not measured. Carrier: none.origin/maingained one commit (b9ac35cbf, CI workflows and scripts only) while this branch was open. It was merged before the final gate run so that the gate list is read from the current workflows. The merge touched no package source, so the earlier package builds stand.Generated by Claude Code