Repository navigation
fix(runtime): mount every /api/v1/keys verb so an anonymous caller is answered 401 on the standalone server - #24009
Conversation
…answers it
The standalone dispatcher mounted ${prefix}/keys for POST alone, so GET,
PUT, PATCH and DELETE never reached the keys domain: the adapter's
unmatched-route 405 answered first, naming the allowed method, to an
anonymous caller too. The four verbs are now mounted and dispatched with
their own method, so an anonymous caller gets the domain's 401
UNAUTHENTICATED and a signed-in one the domain's 405 in the dispatcher's
envelope.
Claude-Session: https://claude.ai/code/session_018t6a5rvqPRvEDMCVVW9kYT
Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018t6a5rvqPRvEDMCVVW9kYT 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 807c0b8bc1918416984d3b871149716f8baa8ad3 && git checkout 807c0b8bc1918416984d3b871149716f8baa8ad3
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 92fd343bcf986fbd8c7cbc9a3824d8096573f3d5 5e3b39fcd3d5a73a3f4615498a1a888888158adc && git checkout -B drift-repro 92fd343bcf986fbd8c7cbc9a3824d8096573f3d5 && git merge --no-ff 5e3b39fcd3d5a73a3f4615498a1a888888158adc
node scripts/docs-audit/affected-docs.mjs --json 92fd343bcf986fbd8c7cbc9a3824d8096573f3d5
|
Claude-Session: https://claude.ai/code/session_018t6a5rvqPRvEDMCVVW9kYT Co-authored-by: Claude <noreply@anthropic.com>
GET, PUT, PATCH and DELETE /keys are mounted so the domain's anonymous floor answers every verb, as the v18 text states. They serve only refusals, so they are ledgered as exact server-only rows; the census count moves 88 -> 92. Claude-Session: https://claude.ai/code/session_018t6a5rvqPRvEDMCVVW9kYT Co-authored-by: Claude <noreply@anthropic.com>
席位核对ACCEPT · #23909 ·
Filed from this card's findings (seat): the 405 with no Landing: Generated by Claude Code |
Fixes #23909
Clause-②: no
Basis for the Clause-② line: no request that was accepted becomes refused, and none that was refused becomes accepted. An anonymous non-POST call to
/api/v1/keyswas refused405and is now refused401. A signed-in one was refused405and still is, now in the dispatcher's standard envelope instead of the adapter's. The401is already published text: changeset23225-domain-floor-anonymous-deny(in.changeset/pre/for v18) says "/keys:401 UNAUTHENTICATEDfor every verb. A verb other thanPOSTused to be answered405to an anonymous caller; a signed-in caller still gets the 405."The route ledger (seat ruling
6222005517)The four new mounts are ledgered as four exact
server-onlyrows,GET,PUT,PATCHandDELETE /keys. Their note says they are refusal-only: the anonymous floor answers 401, and a signed-in caller gets the domain's 405. The census count moves 88 → 92. There is no wildcard row: no/keyssub-path is mounted.route-ledger-live-mount-parity.dogfood.test.tsis 8/8 locally at5e3b39fcd. It was 7/8 at41fc6bb39, where "every mounted route is ledgered" failed on these four mounts; the dogfood shards do not run on a PR. The claim's original "no new row" line was wrong, because #7649's/mcp/skillwas covered by the* /mcp/**wildcard; the correction is6222005517on the card.What changed
packages/runtime/src/dispatcher-plugin.ts:GET,PUT,PATCHandDELETEare mounted on${prefix}/keysbeside the unchangedPOST, each dispatching its own method to/keys, in the shape of the/mcpand/mcp/skillhelpers above it. The comment says why (the domain's floor answers only a verb that reaches it, precedent mcp-http-surface (a): POST /api/v1/mcp/skill returns a hand-rolled 405 envelope, not the standard API error shape #7649) and that thePUTandPATCHmounts are not wrapped by the per-route instrumentation, like the otherPUT/PATCHmounts in the file.packages/runtime/src/dispatcher-plugin.routes.test.ts: the/keysexpectation goes fromPOSTalone to the five verbs.packages/runtime/src/keys-every-verb.hono.integration.test.ts(new pin): a real Hono socket. For each anonymous non-POST verb it assertsstatus401,error.codeUNAUTHENTICATED,error.httpStatus401 and noerror.details. For each signed-in non-POST verb it asserts 405,METHOD_NOT_ALLOWED,error.httpStatus405 and noerror.details. That last pair tells the domain's answer from the adapter's, which fillsdetailsand nohttpStatus. AnonymousPOSTis the unchanged control..changeset/23909-keys-every-verb-anonymous-floor.md:@objectstack/runtimepatch.Not touched, as ruled:
domains/keys.ts, the observability Proxy's verb list,plugin-hono-server/src/adapter.ts, the/notificationsand/share-linksmounts, anddocs/qa/.route-ledger.tsgains the four rows above, by the seat's corrected ruling.Measured on a live boot
Stock showcase,
pnpm dev -- --fresh -p PORT, built from basef901746acand from head41fc6bb39; signed in as the seeded admin.GET/PUT/PATCH/DELETE/api/v1/keys405 METHOD_NOT_ALLOWED,Allow: POST, adapter body{"success":false,"error":{"code":"METHOD_NOT_ALLOWED","message":"GET is not supported for /api/v1/keys. Allowed: POST.","details":{...}}}401 UNAUTHENTICATED,{"success":false,"error":{"code":"UNAUTHENTICATED","message":"Authentication is required to access this endpoint.","httpStatus":401}}GET/PUT/PATCH/DELETE/api/v1/keys405,Allow: POST, adapter body405 METHOD_NOT_ALLOWED,{"success":false,"error":{"code":"METHOD_NOT_ALLOWED","message":"Method not allowed","httpStatus":405}}, noAllowheaderPOST /api/v1/keys401 UNAUTHENTICATED401 UNAUTHENTICATED(unchanged)HEAD /api/v1/keys405,Allow: POST401GET /api/v1/keys/anything(either caller)404 ENDPOINT_NOT_FOUND404 ENDPOINT_NOT_FOUND(unchanged)The cloud-host shape (
@objectstack/hono'screateHonoAppcatch-all, driven in process withapp.requestover aLiteKerneland a fakeauthservice, base build) already answered every anonymous verb401 UNAUTHENTICATEDand every signed-in non-POST verb the domain's405with noAllow. This PR makes the standalone server answer the same.Tests
41fc6bb39:pnpm --filter @objectstack/runtime exec vitest run --maxWorkers=2 src/keys-every-verb.hono.integration.test.ts src/dispatcher-plugin.routes.test.ts→Test Files 2 passed (2),Tests 25 passed (25).node scripts/ablation-replace.mjsreplaced the fourmountKeys(...)calls (anchor 1 → 0, marker 0 → 1, blob05f460424d3c→89de55c9ef5d). The same run gaveTests 9 failed | 16 passed (25): the 4 anonymous rows failed withexpected 405 to be 401, the 4 signed-in rows withexpected undefined to be 405(the adapter's body has nohttpStatus), and the route-list case failed. AnonymousPOSTstayed green. Restore: blob == HEAD05f460424d3c,git diff HEADempty. The test imports./dispatcher-plugin.jsfrom source, so nodist/sits on its resolution path.pnpm exec turbo run build --filter='@objectstack/runtime...' --concurrency=2→ exit 0.pnpm --filter @objectstack/runtime typecheck→ exit 0.pnpm --filter @objectstack/runtime test→ exit 0,Test Files 404 passed (404),Tests 6139 passed | 19 skipped (6158).pnpm exec eslint --no-inline-configover the three changed.tsfiles → exit 0.Gates
node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstackderived 62 commands for this diff. All 62 exit 0 at head41fc6bb39, and--ranreconciles:62 derived, 62 run, 0 NOT-MEASURED, 0 UNRUN. Also run, all exit 0: the five roster families whose roster sits under a changed directory (check-changeset-fixed,check:authz-resolver,check:error-code-casing,check:filter-alias-parity,check:route-ledger-census). The repo-wide lint is the PR'sLint & Repo Gatescheck-run.Acceptance notes (seen on the way, not changed here)
The domain's 405 carries no
Allowheader. A signed-in non-POST call used to get the adapter's405withAllow: POST. Now it gets thekeysdomain's405, built throughdeps.errorwith no header. The cloud host already answered this way.domains/mcp.ts,domains/meta.tsanddomains/ai.tshand-rollAllowon their 405s;domains/keys.tsanddomains/actions.tsdo not.PUTandPATCHmounts are not instrumented. On the live head,GETandDELETE /api/v1/keysechoX-Request-IdandPUTandPATCHdo not. The observability Proxy wraps onlyget,postanddelete. The other mounts it misses arePATCHon/packages/:id,/packages/:id/enableand/packages/:id/disable,PUT /automation/:name, thePUTandPATCH/ai/*wildcards, and service-emittedPATCHroutes throughmountRouteOnServer.The same mount gap on sibling doors of the v18 anonymous-floor text, on the standalone server (base and head alike):
DELETE/PUT /api/v1/notifications→405,Allow: GET, HEAD;PUT/DELETE /api/v1/share-links→405,Allow: GET, HEAD, POST;/api/v1/share-linkssub-path →404 ENDPOINT_NOT_FOUND.The cloud-host probe answered
401for anonymousDELETE /api/v1/notificationsand for an unserved/api/v1/share-linkssub-path (the other verbs above were not probed there). On the standalone server,/api/v1/share-linksis served byplugin-sharing's own routes, not the dispatcher domain, so its anonymous401carries that plugin's message and nohttpStatus.Generated by Claude Code