Skip to content

Commit cbfa5e4

Browse files
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)
Fixes #24228 Clause-②: no Basis for the Clause-② line: every request whose answer changes is refused before and after. On the standalone server an anonymous non-`POST` request to `/api/v1/actions/...` moves from the HTTP adapter's `405` to the `actions` domain's `401`, and a request under a verb no route takes on `/keys`, the `/notifications` faces or `/mcp/skill` moves from the adapter's `405` (whose `Allow` listed refusal-only verbs) to the domain's own refusal. No verb that was refused is admitted, no route is added, and no accept set or export widens. ## What changed - `packages/runtime/src/dispatcher-plugin.ts`, three parts: 1. **A declaration of the paths whose domain decides every verb** (`domainDecidesEveryVerb`). Each one is the serving route's verb and pattern, made beside that route: the three `POST /actions` patterns in `registerActionRoutes` (so both the unscoped and the `/environments/:environmentId` base), `POST /keys`, the three `/notifications` faces and `GET /mcp/skill`. 2. **The dispatcher's existing fallback seam answers those paths.** `setFallbackHandler` runs only when the router matched no route, and before the adapter's own unmatched answer. When the transport's router resolves the request path, under a declared verb, to a declared pattern (`IHttpServer.resolveMountedRoute`, the router's own verdict, no second matcher), the request goes to `dispatch()` as `createHonoApp`'s catch-all sends it: `req.method`, the whole sub-path after the prefix (so `dispatch()` reads the environment off a scoped path, since an unrouted request has no route params), `req.query`, and no body. The domain answers. Its anonymous baseline is its first statement (ADR-0138 D2), and its `405` carries its own `Allow`. Any other unmatched request is handled as before: the declarative-endpoint step, then the adapter's 404/405. 3. **Absence is loud.** A transport with the fallback seam but no `resolveMountedRoute` keeps the adapter's answer on those paths, and the boot says so once, at `warn`, with the consequence and the remedy. The existing no-`setFallbackHandler` warning names the same consequence in one more clause. - Pins (real Hono socket, `HonoServerPlugin` + `createDispatcherPlugin`, CORS off): - new `packages/runtime/src/actions-every-verb.hono.integration.test.ts` (79 cases): three path shapes on both prefixes. For `GET`, `PUT`, `PATCH`, `DELETE` and `TRACE`, an anonymous call gets `401 UNAUTHENTICATED`, `error.httpStatus` 401, no `error.details` and no `Allow`. A signed-in call gets `405 METHOD_NOT_ALLOWED`, `Allow: POST`, `error.httpStatus` 405 and no `error.details`, which is the domain's door and not the adapter's. `HEAD` is checked by status and header. Anonymous `OPTIONS` gets the domain's `405` + `Allow: POST` (see Acceptance notes). Controls: anonymous `POST` 401 unchanged, and a four-segment path stays the adapter's 404. - `keys-every-verb.hono.integration.test.ts`: `OPTIONS` and `TRACE`, 4 cases (anonymous 401 no `Allow`; signed-in domain 405 with `Allow: POST`). - `notifications-every-verb.hono.integration.test.ts`: `OPTIONS` and `TRACE` on each face, 12 cases (anonymous 401; signed-in the dispatcher's `404 ROUTE_NOT_FOUND`, no `Allow`). - `mcp-skill-method-not-allowed.hono.integration.test.ts`: its `PUT` boundary case pinned the adapter's `Allow` `['DELETE', 'GET', 'HEAD', 'POST']`, which is the defect. It is replaced by `PUT`, `PATCH`, `OPTIONS` and `TRACE` getting the domain branch's `405` with `Allow: GET`. - `dispatcher-plugin.fallback-absence-warn.test.ts`: the new warning, once at `warn`, and silence when the resolver exists. - `domains/keys-actions-method-not-allowed-allow.test.ts` (PR #24227's pin, the control): **assertions unchanged, still green**. Its docblock and one title said the standalone `/actions` 405 is the adapter's because `GET` is not mounted. After this change that is false, so the wording now says it is the domain's, through the fallback seam. - `.changeset/24228-actions-every-verb-anonymous-floor.md`: `@objectstack/runtime` `patch`. Not touched: `packages/spec` (the `IHttpServer` contract line included), `packages/plugins/plugin-hono-server`, `domains/actions.ts`, the `dispatcher.dispatch(… req.body …)` sites (no existing mount changed), `readGatedSessionUser`, `route-ledger.ts` (no route is mounted, so no row and no census move). ## The route, and why it is not refusal-only mounts (H2, measured) - **The adapter cannot tell a refusal-only mount apart.** Its `Allow` is `allowedMethodsForPath` over `registeredRoutes`, and only `get`/`post`/`put`/`delete`/`patch(path, handler)` fill `registeredRoutes`. Nothing in that signature marks a mount as refusal-only. A marker would have to ride on the handler object, which is a cross-package protocol the contract does not declare, or be a new parameter, which is a `packages/spec` change. This PR takes neither. - **The adapter does not need to tell them apart.** Its unmatched answer is the only producer of the wrong `Allow`. That answer runs after the fallback seam declines. So if the domain answers every request the router cannot route on its paths, the adapter never builds an `Allow` there. This is the first candidate in H2. It needs no `packages/spec` change and no adapter change, and the contract line stays true where the adapter does answer. - **Why not the `/keys` shape for `/actions`.** With the five `IHttpServer` verbs mounted, a verb `IHttpServer` cannot register still reaches the adapter. That is `OPTIONS` when CORS is off. It is also `TRACE` or an extension method such as `PROPFIND` **with CORS on too**, because CORS answers only `OPTIONS`. The adapter's `Allow` there is every registered verb. So refusal-only mounts on `/actions` would turn today's accurate `Allow: POST` into `DELETE, GET, HEAD, PATCH, POST, PUT`. With the fallback the four mounts are not needed. One mechanism covers every verb on `/actions`, and it covers the verbs left over on the families that already have the mounts. ## Measured Standalone composition in process: `LiteKernel`, a fake `auth` that answers a session only for a test header, `HonoServerPlugin` on a real socket, `createDispatcherPlugin`, and `registerShareLinkRoutes` at `kernel:ready`. Four compositions: CORS off or on, each with project scoping off or on (`enableProjectScoping: true` mounts both prefixes). For each, 14 paths × 9 verbs (`GET HEAD POST PUT PATCH DELETE OPTIONS TRACE PROPFIND`) × anonymous and signed in, through raw `node:http`. That is 1008 requests at base `2bcd406a2` and 1008 at head `d60989609`. The implementation blob `24648a251fc8` has been the same from there to the final head `6c8625968`. 368 rows changed. Every changed row was the adapter's `405` at base and is the domain's answer at head: 148 now `401`, 172 now the domain's `405`, 30 now the dispatcher's `404`, and 18 `HEAD` rows went from `405` to `401`. No served answer moved. **H1, confirmed.** At base, every anonymous `GET`/`HEAD`/`PUT`/`PATCH`/`DELETE`/`TRACE`/`PROPFIND` on all three `/actions` shapes and both prefixes got `405` + `Allow: POST` with the adapter's `error.details`, CORS on and off. `OPTIONS` got the same with CORS off and `204` with CORS on. | `/api/v1/actions/task/close` (all shapes, both prefixes alike) | base | head | `createHonoApp` | |---|---|---|---| | anonymous `GET` `PUT` `PATCH` `DELETE` `TRACE`/`PROPFIND` | `405`, `Allow: POST`, adapter | `401 UNAUTHENTICATED`, no `Allow` | `401`, no `Allow` | | anonymous `HEAD` | `405`, `Allow: POST` | `401`, no `Allow` | `401` | | signed-in non-`POST` | `405`, `Allow: POST`, adapter envelope | `405`, `Allow: POST`, domain envelope | `405`, `Allow: POST` | | anonymous `OPTIONS`, CORS off | `405`, `Allow: POST`, adapter | `405`, `Allow: POST`, domain | `405`, `Allow: POST` | | `OPTIONS`, CORS on | `204` | `204` | `204` | The `createHonoApp` column: `createHonoApp({ kernel, prefix: '/api/v1' })` over the same fake `auth`, driven with `app.request`, CORS off and on. `PROPFIND` stands in for `TRACE` there, because the fetch `Request` refuses `TRACE`. **H3, the `Allow` each refusal-only family answers** (a verb no route takes: `OPTIONS` with CORS off, `TRACE`/`PROPFIND` with CORS either way, and on `/mcp/skill` also `PUT`/`PATCH`): | family | served | `Allow` at base | at head | |---|---|---|---| | `/keys` | `POST` | `DELETE, GET, HEAD, PATCH, POST, PUT` | anonymous `401` no `Allow`; signed-in domain `405` `Allow: POST` | | `/notifications` | `GET` | `DELETE, GET, HEAD, PATCH, POST, PUT` | anonymous `401`; signed-in `404 ROUTE_NOT_FOUND`; no `Allow` | | `/notifications/read` | `POST` | same six | same as above | | `/notifications/read/all` | `POST` | same six | same as above | | `/mcp/skill` | `GET` | `DELETE, GET, HEAD, POST` | domain `405` `Allow: GET` | | `/actions` (3 shapes × 2 prefixes) | `POST` | `POST` (accurate; the defect was the missing 401) | domain `Allow: POST` signed in; anonymous `401` | | `/share-links` (root) | `GET`, `POST` | `GET, HEAD, POST` | unchanged, measured-clean | | `/share-links/abc` | `DELETE` | `DELETE, GET, HEAD, PATCH, POST, PUT` | **unchanged**, see Acceptance notes | | `/share-links/abc/resolve` | `GET` | same six | **unchanged**, see Acceptance notes | The head column matches the `createHonoApp` answers row for row on `/actions`, `/keys`, `/notifications/read` and `/mcp/skill` for `GET`/`PUT`/`OPTIONS`/extension verbs. Two exceptions predate this PR and are listed in Acceptance notes. **H4, confirmed.** PR #24227's pin is green with its assertions unchanged. Its standalone `/actions` row (`405`, `Allow: POST`) now reads the domain's answer instead of the adapter's. ## Tests - Targeted pins at `194b25dc2`. The runtime tree at `194b25dc2` equals the final head for every file this PR touches, and the implementation blob is the same (`24648a251fc8`). Command: `pnpm --filter @objectstack/runtime exec vitest run --project local --maxWorkers=2` over the six touched test files plus `dispatcher-plugin.routes`, `share-links-every-path`, `dispatcher-plugin.endpoint-fallback` and `dispatcher-plugin.streaming-fallback`. Result: `Test Files 10 passed (10)`, `Tests 234 passed (234)`. - Full suite at the final head `6c8625968`, after merging `origin/main` and rebuilding (`pnpm install --frozen-lockfile`, `turbo run build --filter='@objectstack/hono...'`): `pnpm --filter @objectstack/runtime exec vitest run --project local --maxWorkers=2` gives `Test Files 413 passed (413)`, `Tests 6411 passed | 19 skipped (6430)`. - `pnpm --filter @objectstack/runtime typecheck` at `6c8625968`: exit 0, `check:test-typecheck: OK` with the ledger unchanged. All six touched test files are in the `tsconfig.test.json` program (`--listFiles`). - `pnpm exec eslint --no-inline-config` over the seven changed `.ts` files: exit 0. - `test:repo` (the `repo` vitest project) was not run locally. No file of that project changed, and CI's `Test Core` runs it. **Ablations** (one per member, not kept). Each ran after the fix was committed, through `node scripts/ablation-replace.mjs` in WRAP mode, inside a script that also carries an EXIT/INT/TERM trap restoring from `HEAD`. Every pin imports `./dispatcher-plugin.js` from source, and `@objectstack/plugin-hono-server` (read from its `dist/`) is not mutated, so no `dist/` of the mutated module sits on the resolution path. - Member 1: the three `/actions` declarations replaced by a marker. The anchor went 1 to 0 and the blob `24648a251fc8` to `4bcfc24a0ac7`. Result: `Tests 72 failed | 27 passed (99)` over the actions pin, the keys pin and PR #24227's control. The 72 are every anonymous, signed-in, `HEAD` and `OPTIONS` row of the actions pin (`expected 405 to be 401`, `expected undefined to be 405`). Green: its 6 `POST` controls, its 404 control, the keys pin and the control file. Restore: blob equals `HEAD` (`24648a251fc8`), `git diff HEAD` is empty, and the marker count is 0. - Member 2: the `/keys`, `/notifications` and `/mcp/skill` declarations, three nested anchors, each 1 to 0 (blob `24648a251fc8`, then `1bfef7b8cabc`, then `970cf8d3dba7`, then `258148332710`). Result: `Tests 20 failed | 124 passed (144)`. The 20 are the new `OPTIONS`/`TRACE` rows and the four `/mcp/skill` rows. Messages include `expected 'DELETE, GET, HEAD, PATCH, POST, PUT' to be 'POST'`, `expected 'DELETE, GET, HEAD, POST' to be 'GET'`, `expected 405 to be 401` and `expected 405 to be 404`. The actions pin stayed green. Restore was proven at each of the three levels (blob equals `HEAD`, `git diff HEAD` is empty). ## Gates `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` at the final head `6c8625968` derived 62 commands. Each was run with its exit code captured before any pipe and recorded as `COMMAND :: exit N`. `--ran` reconciles: `62 derived, 61 run, 1 NOT-MEASURED, 0 UNRUN`. The 61 that ran exit 0, `check:route-envelope` and `check:nul-bytes` among them. - **NOT MEASURED: `pnpm check:dual-build-cjs-loads`.** Reason: `PREREQUISITE NOT MET` (exit 3). It reads the built `dist/` of every package, 39 have none in this worktree, and the whole repository was not built locally. This diff changes no `exports`, no `package.json` and no build config. CI's `Lint & Repo Gates` runs it. - Not run: `node scripts/check-platform-checklist.mjs` (no exported symbol renamed or removed), and the local-only `check:dev-prereqs` and `check:skill-frame-freshness` (nothing under `packages/spec` or the skill frame changed). The repo-wide `pnpm lint` is the PR's `Lint & Repo Gates` check-run. ## Acceptance notes - **`/share-links/*` still lists refusal-only verbs in `Allow`.** On the standalone server `/share-links` is `plugin-sharing`'s family (`registerShareLinkRoutes`), not the dispatcher's. Its `${base}/*` catch-all is mounted for the five verbs. So `OPTIONS` with CORS off, and `TRACE`/`PROPFIND` with CORS either way, on `/api/v1/share-links/abc` (served: `DELETE`) and `/api/v1/share-links/abc/resolve` (served: `GET`) get the adapter's `405` with `Allow: DELETE, GET, HEAD, PATCH, POST, PUT`. This holds at base and at head. The route here does not reach it: the fallback seam is one handler and the dispatcher's, and a dispatcher answer on `/share-links` would be a second owner of that surface. `plugin-sharing` is outside this claim's file surface. Reported to the seat with the options. - **Anonymous `OPTIONS` on `/actions` (CORS off only)** gets the domain's `405` + `Allow: POST`. That is the same answer as on a `createHonoApp` host. The shared anonymous decision lets every `OPTIONS` through as a CORS preflight, and `domains/actions.ts` is read-only here. This is PR #24227's open question, unchanged. - **Answers the fallback writes skip the per-route instrumentation.** There is no `X-Request-Id` echo, because the seam is installed on the raw server. That is the same as the adapter answer it replaces, and the transport's response observation still labels them unmatched. - **Divergences that predate this PR and are unchanged by it:** - `HEAD /api/v1/mcp/skill` gets `501` on the standalone server (Hono serves `HEAD` from the `GET` route) and `405` + `Allow: GET` on `createHonoApp`. - The signed-in non-face `/notifications` 404 is `ROUTE_NOT_FOUND` on the standalone server and `RESOURCE_NOT_FOUND` on `createHonoApp`, as #24024's notes record. - **The adapter's own matcher and the Hono router disagree on the `/share-links` root.** Hono routes `PUT /api/v1/share-links` to the `/share-links/*` catch-all, but the adapter's `matchesRoutePattern` does not count that pattern for the root. So `Allow` there lists only `GET, HEAD, POST`, which happens to be accurate. Read from the measurement, no wrong answer. Carrier: none. --- _Generated by [Claude Code](https://claude.ai/code/session_01QyoWKK9RwUNxBxmevy9Neb)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 9975c26 commit cbfa5e4

8 files changed

Lines changed: 559 additions & 29 deletions
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
---
2+
'@objectstack/runtime': patch
3+
---
4+
5+
fix(runtime): on the standalone server, every verb on `/api/v1/actions/...` reaches the `actions` domain, so an anonymous caller is answered `401`; and an `Allow` header no longer lists verbs a route mounts only to refuse
6+
7+
Clause-②: no
8+
9+
On the standalone server (`os dev`, `os serve`: `HonoServerPlugin` with the dispatcher plugin), a request that matched no route under its verb was answered by the HTTP adapter: `405 METHOD_NOT_ALLOWED` with an `Allow` header built from the verbs registered on the path. Two answers were wrong.
10+
11+
- **`/api/v1/actions/...` (all three path shapes, unscoped and `/environments/:environmentId`-scoped).** Only `POST` is mounted, so an anonymous `GET`, `HEAD`, `PUT`, `PATCH`, `DELETE` or `TRACE` was answered `405` + `Allow: POST` instead of the published `401 UNAUTHENTICATED`. Every such request now reaches the `actions` domain, as it does on a `createHonoApp` host: an anonymous caller gets `401 UNAUTHENTICATED` with no `Allow`, and a signed-in caller gets the domain's `405` + `Allow: POST`. An anonymous `OPTIONS` (only reachable with CORS off) gets the domain's `405` + `Allow: POST` on both hosts, because the shared anonymous decision lets a CORS preflight through.
12+
- **`Allow` on `/api/v1/keys`, the three `/api/v1/notifications` faces and `/api/v1/mcp/skill`.** These paths mount verbs that exist only so the domain can refuse them. A verb that cannot be mounted (`OPTIONS` with CORS off, `TRACE`, an extension method), and `PUT`/`PATCH` on `/mcp/skill`, got the adapter's `405` with those refusal-only verbs listed as supported (`/keys`: `Allow: DELETE, GET, HEAD, PATCH, POST, PUT`). They now reach the domain too: `/keys` answers `401` anonymous and `405` + `Allow: POST` signed in, `/notifications` answers `401` anonymous and `404 ROUTE_NOT_FOUND` signed in, and `/mcp/skill` answers `405` + `Allow: GET`.
13+
14+
Every request that was served is served as before, and no route is added: the dispatcher's existing fallback seam hands these requests to the domain. A transport that implements `setFallbackHandler` but not `resolveMountedRoute` keeps the adapter's answer, and the dispatcher says so once at boot, at `warn`.
Lines changed: 214 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,214 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
4+
import { request as httpRequest } from 'node:http';
5+
import { LiteKernel, Plugin, PluginContext } from '@objectstack/core';
6+
import { HonoServerPlugin } from '@objectstack/plugin-hono-server';
7+
import type { IHttpServer } from '@objectstack/spec/contracts';
8+
9+
import { createDispatcherPlugin } from './dispatcher-plugin.js';
10+
11+
/**
12+
* Every verb on `/api/v1/actions/...` reaches the `actions` domain on the
13+
* standalone server, so its anonymous baseline answers first (#24228) — over
14+
* a real Hono socket.
15+
*
16+
* `handleActionsRequest` decides an anonymous caller as its FIRST statement,
17+
* ahead of its `405` (ADR-0138 D2: "Everything else answers 401
18+
* (UNAUTHENTICATED). The decision is taken once per domain, as the domain's
19+
* first statement"). The standalone dispatcher mounts the three `/actions`
20+
* paths for `POST` only, so every other verb matched no route and the
21+
* transport answered it itself: `405` + `Allow: POST`, to an anonymous caller
22+
* too. A `createHonoApp` host answers the same request `401`, because its
23+
* catch-all hands the domain every verb.
24+
*
25+
* The dispatcher now declares these paths as ones whose domain decides every
26+
* verb, and its fallback seam hands the domain each request the router could
27+
* not route there. Deliberately NOT refusal-only `GET`/`PUT`/`PATCH`/`DELETE`
28+
* mounts (the `/keys` shape): the transport builds `Allow` from the verbs
29+
* REGISTERED on a path, so each such mount would have been listed as supported
30+
* in the `405` the transport still answers for the verbs left over (`OPTIONS`
31+
* with CORS off, `TRACE`, …). The signed-in rows therefore pin `Allow` too.
32+
*
33+
* Which DOOR answered is read off the body, as in
34+
* `keys-every-verb.hono.integration.test.ts`: the dispatcher's envelope fills
35+
* `error.httpStatus` and carries no `error.details`; the transport's fills
36+
* `error.details.{method,path,allowed}` and no `httpStatus`. The signed-in
37+
* rows answered `405` + `Allow: POST` before the fix as well, so the door is
38+
* what tells them apart.
39+
*
40+
* Both prefixes: `enableProjectScoping` mounts the three paths under
41+
* `/api/v1` and under `/api/v1/environments/:environmentId`, through the same
42+
* `registerActionRoutes`, and the scoped request reaches `dispatch()` with its
43+
* environment read off the path rather than off route params (an unrouted
44+
* request has none) — so the scoped rows are not a copy of the unscoped ones.
45+
*
46+
* Raw `node:http`, not `fetch`: `fetch` refuses `TRACE`, and `TRACE` is the
47+
* verb the transport answers even with CORS on (CORS answers `OPTIONS`).
48+
* `LiteKernel`, no `objectql`: every answer under test comes before the domain
49+
* touches storage, so the signed-in `POST` (a real invocation) is not driven.
50+
*/
51+
52+
const SESSION_HEADER = 'x-test-session';
53+
const PREFIXES = ['/api/v1', '/api/v1/environments/env_actions_verb'] as const;
54+
const SHAPES = ['/actions//close_all', '/actions/task/close', '/actions/task/close/rec_1'] as const;
55+
/** Verbs that carry a JSON body on the wire; the rest are sent bodiless. */
56+
const BODY_VERBS = new Set(['PUT', 'PATCH']);
57+
/** Every verb but `POST` and the two whose answer differs in shape (below). */
58+
const REFUSED_VERBS = ['GET', 'PUT', 'PATCH', 'DELETE', 'TRACE'] as const;
59+
60+
function fakeAuthPlugin(): Plugin {
61+
return {
62+
name: 'com.objectstack.test.fake-auth-actions-every-verb',
63+
version: '1.0.0',
64+
init: async (ctx: PluginContext) => {
65+
ctx.registerService('auth', {
66+
api: {
67+
getSession: async ({ headers }: any) =>
68+
(headers?.get?.(SESSION_HEADER) ? { user: { id: 'u_actions_verb' } } : undefined),
69+
},
70+
});
71+
},
72+
};
73+
}
74+
75+
interface Answer {
76+
status: number;
77+
allow: string | null;
78+
/** Parsed JSON body; `undefined` for a bodiless answer (`HEAD`). */
79+
body: Record<string, any> | undefined;
80+
}
81+
82+
describe('every /api/v1/actions verb reaches the actions domain (integration, #24228)', () => {
83+
let kernel: LiteKernel;
84+
let port: number;
85+
86+
beforeAll(async () => {
87+
kernel = new LiteKernel();
88+
kernel.use(fakeAuthPlugin());
89+
// port 0 → an OS-assigned free port, read back through getPort().
90+
// CORS off: with it on, CORS answers every `OPTIONS` before routing.
91+
kernel.use(new HonoServerPlugin({ port: 0, cors: false }));
92+
kernel.use(createDispatcherPlugin({
93+
prefix: '/api/v1',
94+
securityHeaders: false,
95+
scoping: { enableProjectScoping: true, projectResolution: 'auto' },
96+
}));
97+
await kernel.bootstrap();
98+
port = kernel.getService<IHttpServer>('http.server').getPort!();
99+
}, 30_000);
100+
101+
afterAll(async () => {
102+
if (kernel) {
103+
await Promise.race([
104+
kernel.shutdown(),
105+
new Promise<void>((resolve) => setTimeout(resolve, 10_000)),
106+
]);
107+
}
108+
}, 30_000);
109+
110+
function call(method: string, path: string, signedIn: boolean): Promise<Answer> {
111+
return new Promise((resolve, reject) => {
112+
const payload = BODY_VERBS.has(method) ? JSON.stringify({ params: {} }) : undefined;
113+
const req = httpRequest({
114+
host: '127.0.0.1',
115+
port,
116+
method,
117+
path,
118+
headers: {
119+
...(payload ? { 'content-type': 'application/json' } : {}),
120+
...(signedIn ? { [SESSION_HEADER]: '1' } : {}),
121+
},
122+
}, (res) => {
123+
let text = '';
124+
res.setEncoding('utf8');
125+
res.on('data', (chunk) => { text += chunk; });
126+
res.on('end', () => {
127+
const allow = res.headers.allow;
128+
resolve({
129+
status: res.statusCode ?? 0,
130+
allow: typeof allow === 'string' ? allow : null,
131+
body: text.length > 0 ? JSON.parse(text) as Record<string, any> : undefined,
132+
});
133+
});
134+
});
135+
req.on('error', reject);
136+
req.end(payload);
137+
});
138+
}
139+
140+
for (const prefix of PREFIXES) {
141+
for (const shape of SHAPES) {
142+
const path = `${prefix}${shape}`;
143+
144+
// ── the defect: an anonymous non-POST verb is refused 401, by the domain ──
145+
for (const method of REFUSED_VERBS) {
146+
it(`anonymous ${method} ${path} → 401 UNAUTHENTICATED, no Allow, in the dispatcher's envelope`, async () => {
147+
const { status, allow, body } = await call(method, path, false);
148+
149+
expect(status).toBe(401);
150+
expect(allow).toBeNull();
151+
expect(body?.success).toBe(false);
152+
expect(body?.error?.code).toBe('UNAUTHENTICATED');
153+
expect(body?.error?.httpStatus).toBe(401);
154+
expect(body?.error).not.toHaveProperty('details');
155+
});
156+
}
157+
158+
// ── signed in: the DOMAIN's 405, naming only the verb it serves ──
159+
for (const method of REFUSED_VERBS) {
160+
it(`signed-in ${method} ${path} → the actions domain's 405 with \`Allow: POST\`, not the transport's`, async () => {
161+
const { status, allow, body } = await call(method, path, true);
162+
163+
expect(status).toBe(405);
164+
expect(allow).toBe('POST');
165+
expect(body?.error?.code).toBe('METHOD_NOT_ALLOWED');
166+
// The door: the domain fills `httpStatus`; the transport fills `details`.
167+
expect(body?.error?.httpStatus).toBe(405);
168+
expect(body?.error).not.toHaveProperty('details');
169+
});
170+
}
171+
172+
// HEAD has no body to read the door from; status and header say it.
173+
it(`HEAD ${path} → 401 with no Allow anonymously, 405 with \`Allow: POST\` signed in`, async () => {
174+
const anonymous = await call('HEAD', path, false);
175+
expect(anonymous.status).toBe(401);
176+
expect(anonymous.allow).toBeNull();
177+
178+
const signedIn = await call('HEAD', path, true);
179+
expect(signedIn.status).toBe(405);
180+
expect(signedIn.allow).toBe('POST');
181+
});
182+
183+
// `shouldDenyAnonymous` lets every `OPTIONS` through (a CORS preflight
184+
// carries no credentials), so the domain's 405 answers it — on a
185+
// `createHonoApp` host with CORS off as well. Pinned as the boundary:
186+
// the domain answers it, and `Allow` names `POST` alone.
187+
it(`anonymous OPTIONS ${path} (CORS off) → the domain's 405 with \`Allow: POST\` — the preflight exemption`, async () => {
188+
const { status, allow, body } = await call('OPTIONS', path, false);
189+
190+
expect(status).toBe(405);
191+
expect(allow).toBe('POST');
192+
expect(body?.error?.httpStatus).toBe(405);
193+
expect(body?.error).not.toHaveProperty('details');
194+
});
195+
196+
// ── control: the verb the route always served is answered as before ──
197+
it(`anonymous POST ${path} → 401 UNAUTHENTICATED (unchanged)`, async () => {
198+
const { status, body } = await call('POST', path, false);
199+
200+
expect(status).toBe(401);
201+
expect(body?.error?.code).toBe('UNAUTHENTICATED');
202+
expect(body?.error?.httpStatus).toBe(401);
203+
});
204+
}
205+
}
206+
207+
// ── the claim is narrow: a path no `/actions` route serves stays the transport's 404 ──
208+
it('a four-segment /actions path no route serves → the transport\'s 404, not the domain', async () => {
209+
const { status, body } = await call('GET', '/api/v1/actions/task/close/rec_1/extra', false);
210+
211+
expect(status).toBe(404);
212+
expect(body?.error?.code).toBe('ENDPOINT_NOT_FOUND');
213+
});
214+
});

‎packages/runtime/src/dispatcher-plugin.fallback-absence-warn.test.ts‎

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -166,3 +166,37 @@ describe('dispatcher declarative-endpoint seam — absence is loud (#5400)', ()
166166
.toBe(true);
167167
}, 60_000);
168168
});
169+
170+
/**
171+
* [#24228] The fallback seam also hands a domain that decides every verb
172+
* (`/actions`, `/keys`, `/notifications`, `/mcp/skill`) the requests its
173+
* router could not route on that domain's paths. Which paths those are is the
174+
* router's verdict, asked through `IHttpServer.resolveMountedRoute` — OPTIONAL
175+
* on the contract like the seam itself. A transport that has the seam but not
176+
* the resolver answers those requests with its own 405, so its absence is
177+
* announced the same way, once, at `warn`: a functional gap (the anonymous
178+
* floor and the domain's `Allow` are skipped), nothing persisted.
179+
*/
180+
describe('dispatcher domain-decided verbs — a missing route resolver is loud (#24228)', () => {
181+
const resolverLines = (logs: LogLine[]) => logs.filter((l) => l.message.includes('resolveMountedRoute'));
182+
183+
it('announces a seam without `resolveMountedRoute` once, at `warn`, naming the consequence and the remedy', async () => {
184+
const logs = await bootWith(makeSeamedServer());
185+
186+
const lines = resolverLines(logs);
187+
expect(lines).toHaveLength(1);
188+
expect(lines[0].level).toBe('warn');
189+
expect(lines[0].message).toMatch(/\/actions/);
190+
expect(lines[0].message).toMatch(/instead of 401/);
191+
expect(lines[0].message).toMatch(/`Allow`/);
192+
expect(lines[0].message).toMatch(/@objectstack\/plugin-hono-server/);
193+
}, 60_000);
194+
195+
it('stays SILENT when the adapter exposes both the seam and the resolver', async () => {
196+
const server = makeSeamedServer();
197+
server.resolveMountedRoute = () => undefined;
198+
const logs = await bootWith(server);
199+
200+
expect(resolverLines(logs)).toEqual([]);
201+
}, 60_000);
202+
});

0 commit comments

Comments
 (0)