Repository navigation
fix(runtime, plugin-sharing): on the standalone server, every anonymous /notifications verb and /share-links path is answered 401 in the dispatcher's envelope (#24024) - #24149
Conversation
…re-links path reaches its anonymous floor (#24024) Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BVm1pAXVXQ3PjC42j1kPFf
…nonymous floor (#24024) Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BVm1pAXVXQ3PjC42j1kPFf
…onymous-floor-notifications-share-links
…ime route ledger as the 105 rows it holds (#24024) Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BVm1pAXVXQ3PjC42j1kPFf
…aseline; the share-link catch-all now declares its 404 (#24024) Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BVm1pAXVXQ3PjC42j1kPFf
📓 Docs Drift CheckThis PR changes 2 package(s): 4 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 1 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 31 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 08633dbbdd493ca31f6e5e87745acef4688e0e9f && git checkout 08633dbbdd493ca31f6e5e87745acef4688e0e9f
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 265ef64ba7047919460862c4e798cd5651dd92a7 99ce467e329cdb345754db0a90704214148dacc1 && git checkout -B drift-repro 265ef64ba7047919460862c4e798cd5651dd92a7 && git merge --no-ff 99ce467e329cdb345754db0a90704214148dacc1
node scripts/docs-audit/affected-docs.mjs --json 265ef64ba7047919460862c4e798cd5651dd92a7
|
席位核对 — ACCEPT ·
|
Fixes #24024
Clause-②: no
Basis for the Clause-② line: no request that was accepted becomes refused, and none that was refused becomes accepted. An anonymous call that was refused
405or404is now refused401, which is already published text in changeset23225-domain-floor-anonymous-deny(.changeset/pre/, v18): "/notifications:401 UNAUTHENTICATEDon every face", and "/share-links:401 UNAUTHENTICATEDon the create, list and revoke faces and on every path the domain does not serve". A signed-in call of a verb or path that is not served was refused and still is (a404now, where the adapter answered405or404). No error code is minted. Every request that was served is served as before.What changed
packages/runtime/src/dispatcher-plugin.ts: each of the three notifications faces (/notifications,/notifications/read,/notifications/read/all) is mounted for every verb, beside its one unchanged served mount, each dispatching its own method to the domain, in the shape of the/keysmounts (9e797a897). The new mounts hand no body on (none of these verbs is a face, so the domain never reads one, and readingreq.bodythere would let the transport's400for an unparseable body outrank the floor's 401). Exact paths only: no notifications sub-path is mounted.packages/plugins/plugin-sharing/src/share-link-routes.ts: the anonymous decision moves into the family's one door (mountFamilyRoute), taken once for every route but the two token routes, with the platform's sharedshouldDenyAnonymousand the shared constants. The answer is the dispatcher twin's envelope,{ success: false, error: { code: 'UNAUTHENTICATED', message, httpStatus: 401 } }, withANONYMOUS_DENY_MESSAGE. The three per-face checks in create, list and revoke (each with its own sentence and nohttpStatus) are gone. After the five serving routes, one catch-all per verb is mounted on${base}/*through the same door, registered last so the router keeps every request the five serve (the token routes included). A signed-in caller of the catch-all gets the family's own404 ENDPOINT_NOT_FOUND, naming the request; it never reads the body.packages/runtime/src/route-ledger.ts: 12 exactserver-onlyrows for the new notifications mounts, and one* /share-links/**server-onlyrow for the catch-all; census92 -> 105bynode scripts/check-route-ledger-census.mjs --fix.dispatcher-plugin.routes.test.ts(every verb on each face, no sub-path); newnotifications-every-verb.hono.integration.test.tsandshare-links-every-path.hono.integration.test.ts(real Hono socket, standalone composition); newshare-link-routes.anonymous-floor.pin.test.ts(census read off the registration, both anonymous context shapes, token-route and signed-in controls);share-link-routes.auth-gate.pin.test.tscensus5 -> 10..changeset/24024-notifications-share-links-anonymous-floor.md:@objectstack/runtimeand@objectstack/plugin-sharing,patch.Two files outside the declared surface, both forced by the diff itself and both shrink or follow a count:
packages/qa/dogfood/test/authz-conformance.matrix.ts((92 rows / 23->(105 rows / 23) andpackages/qa/dogfood/test/authz-probe-blind-spot.census.ts(route-ledger rowpopulation/reachable/ controls92 -> 105,blindSpot0,keys23): they pin the runtime ledger's row count. They went red locally on the first run (docblock says 92 rows, the table holds 105); the same omission after fix(runtime): mount every /api/v1/keys verb so an anonymous caller is answered 401 on the standalone server #24009 left the main dogfood shards red until test(dogfood): the authz census and the matrix docblock read the runtime route ledger as the 92 rows it holds #24069. The dogfood shards do not run on a PR.scripts/error-status-unpinned-baseline.json:ENDPOINT_NOT_FOUNDleaves the shrink-only unpinned baseline, because the catch-all'ssendError(res, 404, 'ENDPOINT_NOT_FOUND', …)is now a producer that pins the doc's404.check:error-status-conformancenamed exactly that row; removed by hand (the result--updatewould write, without running the maintainer-marked flag).Which owner answers
/share-links, measured/api/v1/share-links*HonoServerPlugin+ dispatcher plugin +registerShareLinkRoutes, and the booted showcase)plugin-sharing's routes only. The dispatcher mounts no/share-linksroute;resolveMountedRoutenames the plugin's patterns.createHonoApp(catch-all,LiteKernel,app.request)domains/share-links.tsSo the answer was made plugin-side: the plugin is the standalone surface and the ADR-0138 D2 enforcer of class 2 (
registerShareLinkRoutes). A runtime-side mount was rejected on structure, not taste: the dispatcher registers instart(), beforeSharingServicePluginmounts atkernel:ready, so any dispatcher wildcard under/share-linkswould take the token routes from the plugin (a change of owner), and anything narrower leaves two owners for one surface. No route's owner and no published path changed.On the ledger: the catch-all is a sub-path claim, so it is ledgered as the family row that describes the mount (
* /share-links/**), not as exact rows. The mother card's reason against a wildcard (it "would claim sub-paths the standalone server answers 404") is specific to/keys; here the published promise is "every path the domain does not serve", and the dispatcher twin already matches the whole segment. Notifications stays exact rows, like/keys: its text says "every face", and no notifications sub-path is mounted.Measured
Base
c1c0a9094vs head, in process on a real Hono socket (LiteKernel+HonoServerPlugin+ dispatcher plugin +registerShareLinkRoutesmounted atkernel:readyas the plugin mounts it), the same matrix throughcreateHonoApp, and head on the booted showcase (bootShowcase, the dogfood harness):createHonoAppPOST/PUT/PATCH/DELETE /notifications405,Allow: GET, HEAD, adapter body401 UNAUTHENTICATED,httpStatus: 401401GET/PUT/DELETE /notifications/read,GET/DELETE /notifications/read/all405,Allow: POST401, same envelope401GET/POST /share-links401, "Sign in to list/create share links", nohttpStatus401, shared message,httpStatus: 401401DELETE /share-links/abc401, "Sign in to revoke share links", nohttpStatus401, shared envelope401PUT/PATCH/DELETE /share-links405,Allow: GET, HEAD, POST401, shared envelope401GET/POST/PUT/PATCH /share-links/abc405,Allow: DELETE401, shared envelope401POST /share-links/abc/resolve,DELETE /share-links/abc/messages405,Allow: GET, HEAD401, shared envelope401GET /share-links/abc/def,GET/POST /share-links/abc/def/ghi404 ENDPOINT_NOT_FOUND401, shared envelope401GET /share-links/abc/resolveand/messages404404(unchanged)404GET /notifications/xyz404404(not mounted, see open question)401405+Allow, adapter body404 ROUTE_NOT_FOUND,httpStatus: 404, from the dispatcher (the domain'shandled: false);createHonoAppanswers404too405+Allow501capability answer, as the published text says and ascreateHonoAppanswers405+Allow, or404404 ENDPOINT_NOT_FOUND, "No share-link route serves VERB PATH", from the family; the twin answers404 ROUTE_NOT_FOUNDPM mechanism assumptions
401for any verb or path that reaches them (createHonoAppcolumn)./share-linksisplugin-sharing's (about:349),dispatcher-plugin.tshas no/share-linksmount, andcreateHonoAppreaches the runtime twin.405. The notifications domain has no405branch; it answershandled: false, rendered404 ROUTE_NOT_FOUND(or its501with no inbox). The share-link catch-all answers its own404, the status its twin answers.Tests
Every run went through
scripts/pm/os-verify-lock.sh. The package suites ran at29c37e223, whosepackages/runtimeandpackages/plugins/plugin-sharingtrees equal head99ce467e3(git diff 29c37e223 HEADover both is empty); everything else ran at head99ce467e3.pnpm --filter @objectstack/runtime test->Test Files 407 passed (407),Tests 6242 passed | 19 skipped (6261);pnpm --filter @objectstack/runtime typecheck-> exit 0.pnpm --filter @objectstack/plugin-sharing test->Test Files 48 passed (48),Tests 1117 passed (1117);pnpm --filter @objectstack/plugin-sharing typecheck-> exit 0. The four new or edited test files are in their package's testtscprogram (--listFiles).99ce467e3, afterpnpm exec turbo run build --filter='@objectstack/dogfood^...' --concurrency=2(65/65):route-ledger-live-mount-parity.dogfood.test.ts,authz-conformance.test.ts,authz-probe-blind-spot.test.ts,anonymous-door-sweep.dogfood.test.ts,showcase-anonymous-deny-surfaces.dogfood.test.ts->Test Files 5 passed (5),Tests 212 passed (212). The parity file alone was8 passed (8)at05ac40cb3. On the first runauthz-conformance.test.tshad 2 failures, the ledger-figure pins named above.99ce467e3: runtime (notifications-every-verb,share-links-every-path,dispatcher-plugin.routes,route-ledger.conformance)Tests 89 passed (89); plugin-sharing (anonymous-floor.pin,auth-gate.pin)Tests 62 passed (62).pnpm exec eslint --no-inline-configover the eight changed.tsfiles -> exit 0.Ablations (one-off, not kept; each through
node scripts/ablation-replace.mjs, restore proven blob == HEAD andgit diff HEADempty):register.call(server, `${prefix}${subPath}`1 -> 0):notifications-every-verb+ routes testTests 26 failed | 22 passed (48)(expected 405 to be 401,expected 405 to be 404, the route list). The test imports./dispatcher-plugin.jsfrom source. A first attempt was refused by the tool (its replacement still contained the anchor); nothing ran.Tests 20 failed | 68 passed (88)(expected 200/201/404 to be 401).@objectstack/plugin-sharingthrough itsdist/): rebuilt,ablation-dist-preflight.mjsmarker present in 2 built files ->share-links-every-pathTests 29 failed | 6 passed (35); restored, rebuilt, preflight--absentand tree clean ->35 passed (35).Gates
node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack(no paths) at head99ce467e3derived 83 commands. All 83 exit 0, each exit captured before any pipe, and--ranreconciles:83 derived, 83 run, 0 NOT-MEASURED, 0 UNRUN. On the first pass (headb184e3a92) two were not green:check:dual-build-cjs-loadsexited 3 (PREREQUISITE NOT MET, nine packages unbuilt in this worktree; green afterpnpm exec turbo run build --filter='!@objectstack/docs' --concurrency=2), andcheck:error-status-conformanceexited 1 on theENDPOINT_NOT_FOUNDbaseline row named above. Also run:node scripts/check-route-ledger-census.mjs(red92vs105before--fix, green after). The lint family is not in the derived list; the eslint line under Tests covers the changed files, and the repo-widepnpm lintis the PR'sLint & Repo Gatescheck-run.test:repowas not run locally (no file of that project changed); CI'sTest Coreruns it.Acceptance notes (seen on the way, not changed here)
POST /api/v1/keys,PUT /api/v1/keysorPOST /api/v1/notifications/readwith a body the transport cannot parse is answered400 INVALID_REQUEST, not401: those mounts passreq.bodytodispatch(), and reading it throws the transport's refusal before the domain runs.createHonoAppanswers401(it refuses only a body the domain reads). Reported as a finding, not fixed here./notifications/xyzanswers404standalone and401oncreateHonoApp. Left as the/keysprecedent left/keys/anything; see the report's open question.ENDPOINT_NOT_FOUND, its runtime twinROUTE_NOT_FOUND, andcreateHonoApp's handled-false fallbackRESOURCE_NOT_FOUND. Same status, different codes.PUTandPATCHmounts are not instrumented by the observability Proxy (it wraps get/post/delete), as on/keys; that list is runtime(dispatcher): PUT and PATCH routes skip the observability Proxy, so they answer with no X-Request-Id and lose the 5xx error capture; only get/post/delete are wrapped #24025's.405withoutAllow: none is introduced here (the new refusals are401/404/501); the domains'405header gap is runtime: the keys and actions domains answer 405 METHOD_NOT_ALLOWED with no Allow header (deps.error), where RFC 9110 requires one and the mcp, meta and ai domains send it #24023's.Generated by Claude Code