Repository navigation
fix(runtime): the declarative-endpoint fallback reads the body after the match and the policies - #24285
Conversation
…the match and the policies The fallback built runAppEndpointStep's request with `body: req.body` before the step matched an endpoint or ran its policy chain. The standard transport's `req.body` throws its 400 INVALID_REQUEST refusal when a body it could not parse is read, and answers the request with it, so an unmatched path answered 400 where 404 is owed, and an anonymous caller of an authRequired endpoint answered 400 where 401 is owed. The fallback now hands the step deferRequestBody(req), and the step reads it with readDispatchedBody once the policy chain admitted the caller, just before inputMapping. An admitted caller with an unparseable body still gets the transport's refusal; a readable body reaches the target. Claude-Session: https://claude.ai/code/session_01SYEJqDL3NXxrJRBVqaMu9T Co-authored-by: Claude <noreply@anthropic.com>
…'s body read order Claude-Session: https://claude.ai/code/session_01SYEJqDL3NXxrJRBVqaMu9T Co-authored-by: Claude <noreply@anthropic.com>
…le brackets Claude-Session: https://claude.ai/code/session_01SYEJqDL3NXxrJRBVqaMu9T Co-authored-by: Claude <noreply@anthropic.com>
…r-match Claude-Session: https://claude.ai/code/session_01SYEJqDL3NXxrJRBVqaMu9T Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 1 package(s): 2 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 a2673275f783c79eeb2416267223d0143d05c88a && git checkout a2673275f783c79eeb2416267223d0143d05c88a
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 93b4acb57ec23c4e94a7047133ce612f89a13838 8b5e3b7f483153c710d4eb034283e904348e727e && git checkout -B drift-repro 93b4acb57ec23c4e94a7047133ce612f89a13838 && git merge --no-ff 8b5e3b7f483153c710d4eb034283e904348e727e
node scripts/docs-audit/affected-docs.mjs --json 93b4acb57ec23c4e94a7047133ce612f89a13838
|
席位核对ACCEPT ·
Generated by Claude Code |
Fixes #24252
Clause-②: no
Contract basis for the line above: only the order of refusals moves (the
404and the401now answer ahead of the body's400). No request that was refused before is accepted, and no export moves.What was wrong
On the standalone server (
HonoServerPlugin+ the dispatcher plugin), the declarative-endpoint fallback (setFallbackHandlerinpackages/runtime/src/dispatcher-plugin.ts) builtrunAppEndpointStep's request withbody: req.body, before the step matched an endpoint or ran its policy chain. The transport'sreq.bodyis an accessor that throws its400 INVALID_REQUESTrefusal for a body it could not parse, records the refusal, and answers the request with it whatever the handler then writes (packages/plugins/plugin-hono-server/src/adapter.ts,runHandler). So:POSTto/api/v1/apps/ns/...that no declaration owns, with an unparseable JSON body, answered400where the same request with a readable body answers404 ENDPOINT_NOT_FOUND, signed in or not;POSTto anauthRequired: trueendpoint with an unparseable body answered400where401 UNAUTHENTICATEDis owed. The card listed this case as inferred; it is now measured (below).What changes
packages/runtime/src/dispatcher-plugin.ts: the fallback hands the stepdeferRequestBody(req)(the runtime(dispatcher): on the standalone server an anonymous request whose JSON body cannot be parsed is answered 400 INVALID_REQUEST ahead of the domain's 401 floor, because the mounts read req.body before dispatch (20 sites) #24151 pair,packages/runtime/src/deferred-request-body.ts) instead ofreq.body.packages/runtime/src/api-endpoint-step.ts: the step reads it withreadDispatchedBody(request.body)afterapplyEndpointPolicieshas admitted the caller (rate limit, auth gate,authRequired), just beforeinputMapping. That is the step's one body read, and nothing above it touches the body. A body that was not deferred (a direct caller, a unit test) passes through unchanged, and with no mapping declared the request still rides on by reference./apps/carve-out still returns before any resolution, and a miss still writes nothing.packages/runtime/src/dispatcher-plugin.endpoint-fallback-body-after-match.integration.test.ts..changeset/24252-endpoint-fallback-body-after-match.md:patchfor@objectstack/runtime.The deferred-body pair fits this seam without change: the step has exactly one body read, and it sits after the policy chain. The pair's guard also applies here: a future read of the deferred body before the resolve throws
UnresolvedRequestBodyReadinstead of quietly running the transport's refusal early.Measurements (dispatch assumptions A1 to A5)
Tree:
origin/main53ef75243(BASE), fix atd8dbf8b1d, headf23824394.A1, holds. On BASE:
setFallbackHandlerat:2151,runAppEndpointStep({at:2243,body: req.bodyat:2291, built afterresolveRequestScopeand the multi-tenant decline and before any match.A2, holds. In code, the adapter's
get body()throwsbodyReading.refusaland setsrefusedBodyRead; after the handler, a recorded refusal is the answer. On a socket, the before-readings below show it.A3, holds. Both functions exist.
runAppEndpointStepreads the body once, atapplyInputMapping(match.endpoint, request.body), after the policydenyreturn. Neither the matcher norapplyEndpointPoliciesreceives the body.A4, measured. Anonymous caller,
authRequired: trueendpoint, unparseable body:400 INVALID_REQUESTbefore the fix,401 UNAUTHENTICATEDafter.A5, no read before the match, in this repository.
resolveRequestScoperunsprepareResolverHints(path and route params only), the host'sKernelResolver, andresolveExecutionContext(no body reference). After the fix, the single-tenant socket pins answer404for an unmatched path, and that composition runsresolveRequestScopebefore the match. A throwaway probe (not committed) on the multi-tenant composition, with the existing multi-tenant test's stub resolver, read the same at the fixed head and with the fallback'sreq.bodyput back:req.body404, matcher asked 0 times404, matcher asked 0 times404404400 INVALID_REQUEST404 ENDPOINT_NOT_FOUNDauthRequired: falsepath (admitted)400 INVALID_REQUEST400 INVALID_REQUESTThe decline branch returns before the step's argument is built, so it never read the body. The real
KernelResolveris host-supplied (the cloud distribution) and has no implementation in this repository: whether it readscontext.request.bodyis NOT MEASURED here.Tests
New pins, on a real socket (LiteKernel +
HonoServerPluginon port 0 + the dispatcher plugin, with stubbedauth,metadata.matchEndpointandautomationslots). Refusal-shaped assertions checkstatusanderror.code. The transport refusal is told apart from a domainINVALID_REQUESTby its message, as #24151's pins do.53ef75243d8dbf8b1d404 ENDPOINT_NOT_FOUNDas for a readable body, matcher asked400404, as above400authRequired: true, unparseable body:401 UNAUTHENTICATEDas for a readable body, target never runs400authRequired: true, unparseable body: transport400refusal, target never runsauthRequired: false, unparseable body: transport400refusal, target never runsauthRequired: true, readable body:200, flow receivesparams: { days: 30 }Existing suites, all green at the fix, in one run (11 files, 231 tests,
VERDICT command-exit 0): the new file,dispatcher-plugin.endpoint-fallback.integration.test.ts,api-endpoint-step.test.ts, #24151'sdispatcher-body-after-anonymous-floor.hono.integration.test.tsanddeferred-request-body.test.ts,dispatcher-plugin.multi-tenant-endpoint.integration.test.ts,dispatcher-plugin.endpoint-principal-less-deny.integration.test.ts,dispatcher-plugin.streaming-fallback.test.ts,dispatcher-plugin.fallback-absence-warn.test.ts,endpoint-flow-dispatch-status.test.ts,endpoint-flow-elevated-door.test.ts.Full package:
pnpm --filter @objectstack/runtime testat50f9caa35(same code as the head; later commits touch only the changeset): 417 files passed, 6553 passed and 19 skipped,VERDICT command-exit 0.pnpm --filter @objectstack/runtime typecheck: exit 0.tsc --listFiles -p tsconfig.test.jsonlists the new test file, with noerror TSlines naming it.check:test-typecheckOK, ledger unchanged.Ablation (one time, both legs, on
d8dbf8b1d)Both subjects are imported relatively (
./dispatcher-plugin.js, which vitest transforms fromsrc/), so nodist/rebuild applies. Mutation and restore went throughscripts/ablation-replace.mjs, which uses a must-hit anchor and verifies blob hashes, with atraprestore as well.body: deferRequestBody(req),changed tobody: req.body,(anchor 1 to 0, blob718ef188ae82tob872160649cf). Red, the expected direction: the three404/404/401pins fail (expected 400 to be 404,expected 400 to be 401), and the three controls stay green. Restored: blob equals HEAD718ef188ae82,git diff HEADempty.readDispatchedBody(request.body)changed torequest.body(blob88d109cebdcfto22f289504261). Red, the expected direction: the three controls fail with500(the unresolved deferred body throws), and so do two existing fallback cases whose flow reads a body (runs a flow through the automation slot ...,[#22147] hands the flow executor the guest ...). The404/401pins stay green. Restored: blob equals HEAD88d109cebdcf,git diff HEADempty.Gates (local, at head
8b5e3b7f4)node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack, run with no paths (change set from merge base93b4acb57), derives 64 commands. All 64 ran, each exit code captured before any pipe, and all exited 0.--ranverdict: "64 derived famil(ies) accounted for — 64 run, 0 NOT-MEASURED (a DERIVED zero — all 64 recorded an exit code and none of them is 3)". Among them:check:route-envelope,check:slot-lookup("ratchet holds: 101 unswept site(s) in 25 file(s), none new"),check-system-context-census(OK),check:nul-bytes(OK),check:test-source-alias(OK) andcheck:cross-package-test-inputs(OK).check-changeset-fixed,check:authz-resolver,check:error-code-casing,check:filter-alias-parityandcheck:route-ledger-census.pnpm exec eslint --no-inline-configover the three changed source files exited 0 with no output. The repo-widepnpm lintis CI's.f23824394, two commands first exited 3 (prerequisite not met).check-plugin-teardown-shape --self-testneeded its pinned fixture commit621a4876, which this shallow clone lacked; after fetching it, the re-run exited 0.check:dual-build-cjs-loadsneeded unbuilt workspace packages; on8b5e3b7f4it measured that 111 require entry points across 69 packages load. The merge oforigin/main93b4acb57touched onlyscripts/pm/dispatch-gates*.repovitest project (test:repo; no file in this diff is in it), the workspace type-check lanes, Build Core, the Test Core shards and dogfood.check-platform-checklist --changed-fromis not owed, because no exported symbol is renamed or removed.Diff: 4 files, +246 / -4 (
dispatcher-plugin.ts+9/-1,api-endpoint-step.ts+20/-3, new pin file +201, changeset +16).Acceptance notes
EndpointExecutionRequest.body's doc line inpackages/runtime/src/endpoint-executor.ts("The fallback seam populates it exactly as a route does") still holds for what the executor receives, which is the body the step read. The deferral is documented where it happens: inAppEndpointExecutionInput.requestand at the step's read.endpoint-executor.tsis outside the claimed file surface, so it was left alone. Carrier: none.KernelResolverseescontext.requestbefore the match. No implementation in this repository reads the body; the cloud one is NOT MEASURED here. Carrier: none.dispatcher-plugin.ts's route-handler mount (body: req.bodynear:305) reads the body after its ownroute.authdecision, so it is in the right order and not this defect.Generated by Claude Code