Skip to content

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

Merged
objectstack-fleet[bot] merged 5 commits into
mainfrom
claude/issue-24024-anonymous-floor-notifications-share-links
Oct 17, 2026
Merged

objectstack-fleet[bot] merged 5 commits into
mainfrom
claude/issue-24024-anonymous-floor-notifications-share-links

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

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 405 or 404 is now refused 401, which is already published text in changeset 23225-domain-floor-anonymous-deny (.changeset/pre/, v18): "/notifications: 401 UNAUTHENTICATED on every face", and "/share-links: 401 UNAUTHENTICATED on 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 (a 404 now, where the adapter answered 405 or 404). 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 /keys mounts (9e797a897). The new mounts hand no body on (none of these verbs is a face, so the domain never reads one, and reading req.body there would let the transport's 400 for 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 shared shouldDenyAnonymous and the shared constants. The answer is the dispatcher twin's envelope, { success: false, error: { code: 'UNAUTHENTICATED', message, httpStatus: 401 } }, with ANONYMOUS_DENY_MESSAGE. The three per-face checks in create, list and revoke (each with its own sentence and no httpStatus) 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 own 404 ENDPOINT_NOT_FOUND, naming the request; it never reads the body.
  • packages/runtime/src/route-ledger.ts: 12 exact server-only rows for the new notifications mounts, and one * /share-links/** server-only row for the catch-all; census 92 -> 105 by node scripts/check-route-ledger-census.mjs --fix.
  • Pins: dispatcher-plugin.routes.test.ts (every verb on each face, no sub-path); new notifications-every-verb.hono.integration.test.ts and share-links-every-path.hono.integration.test.ts (real Hono socket, standalone composition); new share-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.ts census 5 -> 10.
  • .changeset/24024-notifications-share-links-anonymous-floor.md: @objectstack/runtime and @objectstack/plugin-sharing, patch.

Two files outside the declared surface, both forced by the diff itself and both shrink or follow a count:

Which owner answers /share-links, measured

shape who answers /api/v1/share-links*
standalone (HonoServerPlugin + dispatcher plugin + registerShareLinkRoutes, and the booted showcase) plugin-sharing's routes only. The dispatcher mounts no /share-links route; resolveMountedRoute names the plugin's patterns.
createHonoApp (catch-all, LiteKernel, app.request) the runtime twin, domains/share-links.ts

So 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 in start(), before SharingServicePlugin mounts at kernel:ready, so any dispatcher wildcard under /share-links would 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 c1c0a9094 vs head, in process on a real Hono socket (LiteKernel + HonoServerPlugin + dispatcher plugin + registerShareLinkRoutes mounted at kernel:ready as the plugin mounts it), the same matrix through createHonoApp, and head on the booted showcase (bootShowcase, the dogfood harness):

anonymous call standalone, base standalone, head (and showcase, head) createHonoApp
POST/PUT/PATCH/DELETE /notifications 405, Allow: GET, HEAD, adapter body 401 UNAUTHENTICATED, httpStatus: 401 401
GET/PUT/DELETE /notifications/read, GET/DELETE /notifications/read/all 405, Allow: POST 401, same envelope 401
GET / POST /share-links 401, "Sign in to list/create share links", no httpStatus 401, shared message, httpStatus: 401 401
DELETE /share-links/abc 401, "Sign in to revoke share links", no httpStatus 401, shared envelope 401
PUT/PATCH/DELETE /share-links 405, Allow: GET, HEAD, POST 401, shared envelope 401
GET/POST/PUT/PATCH /share-links/abc 405, Allow: DELETE 401, shared envelope 401
POST /share-links/abc/resolve, DELETE /share-links/abc/messages 405, Allow: GET, HEAD 401, shared envelope 401
GET /share-links/abc/def, GET/POST /share-links/abc/def/ghi 404 ENDPOINT_NOT_FOUND 401, shared envelope 401
GET /share-links/abc/resolve and /messages token-judged 404 token-judged 404 (unchanged) token-judged 404
GET /notifications/xyz 404 404 (not mounted, see open question) 401
signed-in call standalone, base standalone, head
a notifications verb no face serves (inbox present) 405 + Allow, adapter body 404 ROUTE_NOT_FOUND, httpStatus: 404, from the dispatcher (the domain's handled: false); createHonoApp answers 404 too
the same with no inbox 405 + Allow 501 capability answer, as the published text says and as createHonoApp answers
a share-link verb or path not served 405 + Allow, or 404 404 ENDPOINT_NOT_FOUND, "No share-link route serves VERB PATH", from the family; the twin answers 404 ROUTE_NOT_FOUND

PM mechanism assumptions

  1. Confirmed: both floors are the domains' first statement and answer an anonymous caller 401 for any verb or path that reaches them (createHonoApp column).
  2. Confirmed: standalone /share-links is plugin-sharing's (about :349), dispatcher-plugin.ts has no /share-links mount, and createHonoApp reaches the runtime twin.
  3. Falsified in part: a signed-in caller of a new mount stays refused, so no accept set moves, but not with a 405. The notifications domain has no 405 branch; it answers handled: false, rendered 404 ROUTE_NOT_FOUND (or its 501 with no inbox). The share-link catch-all answers its own 404, the status its twin answers.

Tests

Every run went through scripts/pm/os-verify-lock.sh. The package suites ran at 29c37e223, whose packages/runtime and packages/plugins/plugin-sharing trees equal head 99ce467e3 (git diff 29c37e223 HEAD over both is empty); everything else ran at head 99ce467e3.

  • 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 test tsc program (--listFiles).
  • Dogfood at head 99ce467e3, after pnpm 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 was 8 passed (8) at 05ac40cb3. On the first run authz-conformance.test.ts had 2 failures, the ledger-figure pins named above.
  • Targeted pins at head 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-config over the eight changed .ts files -> exit 0.

Ablations (one-off, not kept; each through node scripts/ablation-replace.mjs, restore proven blob == HEAD and git diff HEAD empty):

  • Notifications mounts moved off their paths (anchor register.call(server, `${prefix}${subPath}` 1 -> 0): notifications-every-verb + routes test Tests 26 failed | 22 passed (48) (expected 405 to be 401, expected 405 to be 404, the route list). The test imports ./dispatcher-plugin.js from source. A first attempt was refused by the tool (its replacement still contained the anchor); nothing ran.
  • The door's anonymous floor removed (plugin-sharing, source-resolved pins): Tests 20 failed | 68 passed (88) (expected 200/201/404 to be 401).
  • The catch-all removed, unit leg: the two census pins fail.
  • The catch-all removed, wire leg (the runtime test resolves @objectstack/plugin-sharing through its dist/): rebuilt, ablation-dist-preflight.mjs marker present in 2 built files -> share-links-every-path Tests 29 failed | 6 passed (35); restored, rebuilt, preflight --absent and tree clean -> 35 passed (35).

Gates

node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack (no paths) at head 99ce467e3 derived 83 commands. All 83 exit 0, each exit captured before any pipe, and --ran reconciles: 83 derived, 83 run, 0 NOT-MEASURED, 0 UNRUN. On the first pass (head b184e3a92) two were not green: check:dual-build-cjs-loads exited 3 (PREREQUISITE NOT MET, nine packages unbuilt in this worktree; green after pnpm exec turbo run build --filter='!@objectstack/docs' --concurrency=2), and check:error-status-conformance exited 1 on the ENDPOINT_NOT_FOUND baseline row named above. Also run: node scripts/check-route-ledger-census.mjs (red 92 vs 105 before --fix, green after). The lint family is not in the derived list; the eslint line under Tests covers the changed files, and the repo-wide pnpm lint is the PR's Lint & Repo Gates check-run. test:repo was not run locally (no file of that project changed); CI's Test Core runs it.

Acceptance notes (seen on the way, not changed here)


Generated by Claude Code

…re-links path reaches its anonymous floor (#24024)

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BVm1pAXVXQ3PjC42j1kPFf
…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
@github-actions github-actions Bot added size/l documentation Improvements or additions to documentation tests tooling labels Oct 17, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/plugin-sharing, @objectstack/runtime, touching 8 documentable anchor(s).

4 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/api/error-catalog.mdx (via ENDPOINT_NOT_FOUND (literal, a string literal in registerShareLinkRoutes))
  • content/docs/deployment/production-readiness.mdx (via createDispatcherPlugin (symbol, a top-level function))
  • content/docs/plugins/packages.mdx (via createDispatcherPlugin (symbol, a top-level function))
  • content/docs/protocol/kernel/error-handling.mdx (via /api/v1/share-links/* (route, a path literal in a comment on a changed line))

⛔ 1 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/v17/17-6.mdx (via ENDPOINT_NOT_FOUND (literal, a string literal in registerShareLinkRoutes))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • 1 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 54 of 206 client-bound route-ledger rows — the other 152 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 152: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 97 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 31 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 265ef64ba7047919460862c4e798cd5651dd92a7 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 08633dbbdd493ca31f6e5e87745acef4688e0e9f — the merge of head 99ce467e329cdb345754db0a90704214148dacc1 into base 265ef64ba7047919460862c4e798cd5651dd92a7, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# 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

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 265ef64ba7047919460862c4e798cd5651dd92a7 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

席位核对 — ACCEPT · domain:cli seat 1 · os-sales · session session_01BVm1pAXVXQ3PjC42j1kPFf · 2026-10-17T04:56Z

Head 99ce467e3. This is the seat's review of record for #24024 (claim 6228915137), read against GitHub and origin/main, ⛔ not against the report.

  • Form: draft → main, assignee os-sales. The first line is Fixes #24024, the body's only closing keyword (scanned). Clause-②: no is line-initial and matches the claim.
  • No accept set moves, read in the code:
    • The door's new floor asks shouldDenyAnonymous (packages/core/src/security/anonymous-deny.ts on origin/main). For these routes that is the same decision as the removed Boolean(ctx.userId); only a CORS preflight differs, and the family mounts no OPTIONS route.
    • Both token routes carry publicRoute: true on the head (share-link-routes.ts :422-:423, :597-:598), so the floor skips them, as the claim's hold-off on the open and visit doors requires.
    • The catch-all ${base}/* is registered last, so the five routes keep every request they serve.
    • Every new mount answers a request the server refused before (405 or 404 from the adapter) with another refusal (401, or the domain's 404 / 501). Clause-②: no stands.
  • The shared answer, not a re-spelling: the 401 uses ANONYMOUS_DENY_STATUS, ANONYMOUS_DENY_CODE and ANONYMOUS_DENY_MESSAGE from @objectstack/core, which plugin-sharing already depends on (workspace:*). No error code is minted. ENDPOINT_NOT_FOUND is an existing code. The ROUTE_NOT_FOUND alternative would have been a packages/spec ledger edit, correctly held off.
  • The owner choice: plugin-side for /share-links, by measurement. The dispatcher registers in start(), before plugin-sharing mounts at kernel:ready, so a dispatcher wildcard would have taken the token routes. No route owner or published path changes.
  • Ledger: 12 exact server-only rows for the notifications verbs, plus one * /share-links/** family row; the census moves 92 → 105.
    • That is the inherited ruling 6222005517 answer 1, letter A, applied with its reason. The wildcard is justified where the reason did not reach: "every path the domain does not serve" is a sub-path claim.
  • Surface: 12 files. 9 are inside the claim. 3 are forced, derived co-changes, accepted as such: authz-conformance.matrix.ts docblock 92 → 105, authz-probe-blind-spot.census.ts 92 → 105, and error-status-unpinned-baseline.json, which drops its ENDPOINT_NOT_FOUND row (shrink-only, because the catch-all now declares its 404).
  • The open question, answered by the seat: A, keep exact rows on /notifications. The published text promises "every face" for /notifications, and adds "every path the domain does not serve" only for /share-links. A sub-path no face names is not a face, and that is the reading /keys got under 6222005517. There is no change in this PR and no ruling owed.
  • PM assumption 3, partly falsified, accepted: a signed-in caller of a new notifications mount gets the domain's 404 ROUTE_NOT_FOUND (or 501 with no inbox), not a 405. It is still refused.
  • Changeset (@objectstack/runtime and @objectstack/plugin-sharing patches), against the diff:
    • "anonymous … 401 UNAUTHENTICATED, … httpStatus: 401": the shared constants. ✓
    • "except GET /share-links/:token/resolve and GET /share-links/:token/messages": publicRoute. ✓
    • "404 ENDPOINT_NOT_FOUND, naming the request": notServed. ✓
    • "Every request that was served is served as before": registration order and the token flags. ✓
    • "A notifications sub-path no face names … is answered 404": exact paths only. ✓
    • The signed-in notifications answer ("404 ROUTE_NOT_FOUND … or the 501") is the domain's, read from the report's measurement. The seat did not re-measure it.
  • Tests: 4 ablations, all red as predicted. Notifications mounts: 26 failed; door floor: 20 failed; catch-all, unit leg: the 2 census pins; catch-all, dist leg: 29 failed after a rebuild with the preflight. Every restore is proven by blob and by an empty git diff HEAD. The 5 dogfood suites read 212/212 on the head; route-ledger-live-mount-parity is among them.
  • Out of scope, one line each:
  • Landing: not governed (check-governed-merges --pr 24149: 0 of 12 paths, 911 lines ≤ 3000). At this record, Lint & Repo Gates is success, while TypeScript Type Check and the six Test Core shards are still running. The PR goes ready with auto-merge armed once every check on the head is green.

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 17, 2026 05:17
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge October 17, 2026 05:18
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 17, 2026
Merged via the queue into main with commit 6dede7a Oct 17, 2026
42 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-24024-anonymous-floor-notifications-share-links branch October 17, 2026 05:40
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/l tests tooling

Projects

None yet

2 participants