Repository navigation
fix(runtime): on the standalone server, an anonymous request whose JSON body cannot be parsed gets the domain's 401, not the transport's 400 - #24250
Conversation
… domain reads it after its anonymous decision WIP: helper, mount sites, domain reads, pins. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QyoWKK9RwUNxBxmevy9Neb
… decision Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QyoWKK9RwUNxBxmevy9Neb
📓 Docs Drift CheckThis PR changes 1 package(s): 3 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 929febbe4bd24246d50c9e4cc2a994f7541792ba && git checkout 929febbe4bd24246d50c9e4cc2a994f7541792ba
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin f5ef054a72e743723101d7ff15120b35c7a19b68 8e9fd41b2b61f691703c359b87f5c504fcafecdf && git checkout -B drift-repro f5ef054a72e743723101d7ff15120b35c7a19b68 && git merge --no-ff 8e9fd41b2b61f691703c359b87f5c504fcafecdf
node scripts/docs-audit/affected-docs.mjs --json f5ef054a72e743723101d7ff15120b35c7a19b68
|
席位核对 — ACCEPT · PR #24250 at
|
Fixes #24151
Clause-②: no
What
On the standalone server (
os dev,os serve:HonoServerPlugin+ the dispatcher plugin), every mount inpackages/runtime/src/dispatcher-plugin.tsthat handeddispatch()req.bodynow hands itdeferRequestBody(req), and each domain reads the body withreadDispatchedBody(body)once its anonymous decision (ADR-0138 D2) has admitted the caller. An anonymous caller's answer no longer depends on whether its JSON body parses; an admitted caller with an unparseable body still gets the transport's400 INVALID_REQUEST.packages/runtime/src/deferred-request-body.ts(new, module-private to the package, not exported from the index): the two halves.deferRequestBody(req)returns a value that holds the request and reads nothing;readDispatchedBody(body)readsreq.bodyfor a deferred value and returns any other value unchanged (whatcreateHonoApp, tests and internal callers handdispatch()). A deferred value read WITHOUT being resolved throws anUnresolvedRequestBodyReaderror on every trap, so a domain that forgets the resolve fails loudly at its first read instead of acting on an invented empty body.dispatcher-plugin.ts: 24 call sites,req.bodybecomesdeferRequestBody(req)(the card's 20, plus 4 the card's grep missed, below). One note at the sharedsendResult/errorResponsehelpers says why.analytics,mcp,keys,notifications,automation(after the inbound-hook hand-off, which verifies raw bytes and never reads a parsed body),actions,ai,packages,security.aidecides per route: an admitted caller's body is read at the domain-wide decision, and an anonymous caller a route'sauth: falseadmits has it read in the route loop.packages/spec(IHttpRequest's body contract),packages/adapters/hono,plugin-hono-server's adapter, PR 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's fallback path, the domains' logic beyond when they read the body.H1: domain by domain (measured on a real socket, base
cbfa5e433, head8e9fd41b2)Anonymous request,
content-type: application/json, body{"name": "probe"(closing brace missing). "Anonymous first?" is read in the domain's source oncbfa5e433.cbfa5e433:1282POST /analytics/query,:1300POST /analytics/sqlanalyticsINVALID_REQUESTUNAUTHENTICATED:1326/mcp(POST, GET, DELETE)mcpisMcpServerEnabled()404 and the service probe 501 come first (finding below):1362/mcp/skill(POST, GET, DELETE)mcp/skill/discovery")METHOD_NOT_ALLOWED, its own answer to any non-GET:1395POST /keys,:1409/keys(GET, PUT, PATCH, DELETE)keys:1447POST /notifications/read,:1456POST /notifications/read/allnotifications:1676–:1857, the nine/automationmountsautomation:1947,:1955,:1963, the threePOST /actions/...shapesactions:1909the/ai/*wildcards (GET, POST, PUT, PATCH, DELETE), not in the card's grepaiauth: falseopens it:1553the/packagesregistrar (16 mounts), not in the card's greppackages:2006POST /security/_activation/:type/:name,:2020POST /security/permission-sets/:name/clone, not in the card's grepsecurityAt base the same anonymous requests with a well-formed body answered 401 everywhere (405 on
/mcp/skill): only the parse decided the 400. Base was measured on the unscoped prefix; the scoped (/environments/:environmentId) mounts are the same registrar code, and head pins both prefixes.H2, H3, H4
createHonoAppanswers 401 becausereadDispatchBodyhandsdispatch()either the parsed value orrefusedOnRead(...): a Proxy overObject.create(null)whose every trap throws the refusal and records the read, which the arm then answers. Not a getter or a thunk.dispatch()'sbodyparameter isanyanddispatch()never reads it (it passes it todomainRoute.handler), so the shape is accepted. But a standalone mount cannot build it: it can learn whether the body parsed only by readingreq.body, the one doorIHttpRequestdeclares, and that read IS the refusal. A lazily forwarding Proxy would have needed no domain change, but it is not faithful:Array.isArray,typeofand== nullread the target, fixed at creation, so a JSON array (an MCP JSON-RPC batch;@objectstack/mcppassesparsedBodyto the SDK, which branches onArray.isArray),nullor a primitive would reach the domain as an object. So the domains read the body explicitly, one statement each.get body()throws the refusal and recordsrefusedBodyRead; the adapter then answers that refusal whatever the handler did. Deferring the read into the domain needs no adapter change: all anonymous pins are green withadapter.tsuntouched.400 INVALID_REQUEST, message "The request body could not be parsed as JSON …" (the transport's refusal, unchanged) on every site whose domain reads a body once it admits the caller. Exceptions, both answers the domain gives without reading a body, as oncreateHonoApp:/mcp/skill(its 405; base answered 400, measured) and/mcpwhere MCP is switched off or not installed (404 / 501; base answered 400 by construction, the mount reading the body beforedispatch()).The four mounts outside the card's grep (bounded in-place fix)
The card's grep
dispatcher\.dispatch\([^)]*req\.bodystops at the first), so it missed the/ai/*wildcard (method.toUpperCase()precedesreq.body) and the three registrars that spell the call over several lines (/packages,/security/_activation,/security/permission-sets/:name/clone). Same defect class, same mechanical change, same file, same gate families. Evidence, basecbfa5e433, anonymous:POST /ai/chat,PUTandPATCH /ai/conversations/c1,POST /packages,PATCH /packages/pkg_x,PATCH /packages/pkg_x/enable,POST /packages/pkg_x/publish,POST /packages/pkg_x/rollback,POST /security/_activation/permission_set/ps_x,POST /security/permission-sets/ps_x/clone: each 400INVALID_REQUESTwith the unparseable body, 401UNAUTHENTICATEDwith the well-formed one. Head: 401 for both. Pinned below.Two
req.bodyreads remain in the file, both deliberately: the service-emitted route mount (the mount decidesroute.authbefore it reads the body, so an anonymous answer never depends on it), and the declarative-endpoint fallback, which is not adispatch()mount (see Acceptance notes).Tests
packages/runtime/src/dispatcher-body-after-anonymous-floor.hono.integration.test.ts(new): real Hono socket,LiteKernel, both prefixes for the registrarsenableProjectScopingmounts twice. 60 URLs, two pins each: anonymous + unparseable gets the domain's anonymous answer in the dispatcher's envelope, equal to the anonymous + well-formed answer; signed-in + unparseable gets the transport's refusal (or/mcp/skill's 405). 120 tests.packages/runtime/src/deferred-request-body.test.ts(new): the value's own contract, 5 tests: nothing is read until it is resolved; resolving returns the exact value (object, array,null, string, number, boolean); the transport's refusal propagates as the same object; values it did not defer pass through untouched; an unresolved read throwsUnresolvedRequestBodyReadon every trap and never reads the request.pnpm --filter @objectstack/runtime exec vitest run --project local --maxWorkers=2: 415 files passed, 6536 tests passed, 19 skipped (run atbde3873ed;8e9fd41b2adds only the changeset).--project repo(it readsdispatcher-plugin.tsas text): 3 files, 808 tests passed at8e9fd41b2.pnpm --filter @objectstack/runtime typecheck: exit 0;check:test-typecheckOK, the two new test files add no debt.Ablation (one, as asked).
deferRequestBodymutated toreturn req.body;(eager read again) throughscripts/ablation-replace.mjs: anchor 1 to 0, blob7d7452c90112todfaf76e93a8e. The test imports./dispatcher-plugin.jsfromsrcby relative path, so nodist/is on the path. Result: 62 failed, 58 passed of 120. Red: all 60 anonymous pins, and the two signed-in/mcp/skillpins, whose expected answer is the domain's own 405, which an eager read pre-empts with 400. Green: the 58 signed-in refusal controls. Restore proven: blob back to7d7452c90112equal to HEAD,git diff HEADempty.Gates (at
8e9fd41b2)node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack: 65 commands. 64 exit 0. 1 NOT MEASURED:pnpm check:dual-build-cjs-loadsexit 3, PREREQUISITE NOT MET (it reads every package'sdist/; only the runtime closure is built here).--ranwith exit codes: "65 derived famil(ies) accounted for — 64 run, 1 NOT-MEASURED". Also, for the roster families whose list lives under a changed directory:check:route-ledger-census,check:error-code-casing,check:authz-resolver,check:filter-alias-parity,check-changeset-fixed.mjs, each exit 0.pnpm exec eslint --no-inline-configover the 13 changed.tsfiles: exit 0. The repo-widepnpm lint, the type-check lanes, Test Core, Build Core and dogfood are CI's.Acceptance notes
/mcpdoes not decide anonymous first (H1).handleMcpRequestanswers404whenOS_MCP_SERVER_ENABLEDis off and501when no MCP service is installed, both ahead of its401. Measured in process on the standalone composition: anonymousPOST /api/v1/mcpanswers501 NOT_IMPLEMENTEDwith no MCP service and404 RESOURCE_NOT_FOUNDwith MCP off, for a well-formed and an unparseable body alike. Not changed here: the dispatch order says a domain that does not decide anonymous first is a finding, not something to add a check for.execution.request.bodyfromreq.bodybeforerunAppEndpointStepmatches an endpoint or appliesauthRequired. Measured: anonymousPOST /api/v1/apps/ns/xanswers400 INVALID_REQUESTwith an unparseable body and404 ENDPOINT_NOT_FOUNDwith a well-formed one; signed in, the same. A declaredauthRequired: trueendpoint was not driven, so whether an anonymous caller gets 400 where 401 is owed there is inferred, NOT MEASURED. Not adispatch()mount, so outside this change./mcp/skillis public by design and reads no body, so its row pins its own 405 rather than 401.Generated by Claude Code