Skip to content

Commit 194b25d

Browse files
committed
test(runtime): pin every /actions verb and the domain-decided Allow on a real socket
Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QyoWKK9RwUNxBxmevy9Neb
1 parent d609896 commit 194b25d

7 files changed

Lines changed: 442 additions & 28 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+
});

‎packages/runtime/src/domains/keys-actions-method-not-allowed-allow.test.ts‎

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -25,10 +25,11 @@
2525
* standalone `os dev` / `os serve` composition), read through `fetch`.
2626
* `/api/v1/keys` is mounted for every verb, so a signed-in `GET` reaches
2727
* the domain and its 405 is the one on the wire. `/actions` is mounted for
28-
* `POST` alone there, so a `GET` never reaches the domain: the adapter's
29-
* own unmatched-method 405 answers it, and that one already names `POST`.
30-
* The row is kept so "both hosts" is a statement about the wire for both
31-
* domains, not about one handler.
28+
* `POST` alone there; since #24228 the dispatcher's fallback seam hands the
29+
* domain every other verb, so its 405 is the one on the wire too (before
30+
* that, the adapter's own unmatched-method 405 answered, also naming
31+
* `POST`). The row is kept so "both hosts" is a statement about the wire
32+
* for both domains, not about one handler.
3233
*
3334
* ## The anonymous baseline stays first (ADR-0138 D2)
3435
*
@@ -176,7 +177,7 @@ describe('the keys and actions 405s carry `Allow` on the wire of the standalone
176177
expect(res.headers.get('allow')).toBeNull();
177178
});
178179

179-
it(`signed-in GET /api/v1/actions/task/close → 405 with \`Allow: ${ALLOW}\` (the adapter's: GET is not mounted)`, async () => {
180+
it(`signed-in GET /api/v1/actions/task/close → 405 with \`Allow: ${ALLOW}\` (the domain's, through the fallback seam since #24228)`, async () => {
180181
const { res, body } = await call('GET', '/actions/task/close', true);
181182

182183
expect(res.status).toBe(405);

‎packages/runtime/src/keys-every-verb.hono.integration.test.ts‎

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
22

33
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
4+
import { request as httpRequest } from 'node:http';
45
import { LiteKernel, Plugin, PluginContext } from '@objectstack/core';
56
import { HonoServerPlugin } from '@objectstack/plugin-hono-server';
67
import type { IHttpServer } from '@objectstack/spec/contracts';
@@ -40,6 +41,12 @@ import { createDispatcherPlugin } from './dispatcher-plugin.js';
4041
const KEYS_PATH = '/api/v1/keys';
4142
const SESSION_HEADER = 'x-test-session';
4243
const NON_POST_VERBS = ['GET', 'PUT', 'PATCH', 'DELETE'] as const;
44+
/**
45+
* [#24228] Verbs `IHttpServer` cannot register, so no `/keys` mount takes them:
46+
* `OPTIONS` (CORS is off in this boot; with it on, CORS answers it) and `TRACE`
47+
* (the transport routes it whatever CORS says).
48+
*/
49+
const UNREGISTRABLE_VERBS = ['OPTIONS', 'TRACE'] as const;
4350

4451
function fakeAuthPlugin(): Plugin {
4552
return {
@@ -125,6 +132,63 @@ describe('every /api/v1/keys verb reaches the keys domain (integration, #23909)'
125132
});
126133
}
127134

135+
// ── [#24228] a verb no mount takes reaches the domain too ──
136+
// The transport used to answer these itself: `405` with an `Allow` built
137+
// from the verbs REGISTERED on the path, `DELETE, GET, HEAD, PATCH, POST,
138+
// PUT`, listing the four refusal-only mounts above as supported. The
139+
// dispatcher's fallback seam now hands them to the domain, whose `Allow`
140+
// names the one verb it serves. Raw `node:http`: `fetch` refuses `TRACE`.
141+
function rawCall(method: string, signedIn: boolean): Promise<{ status: number; allow: string | null; body: Record<string, any> }> {
142+
const { hostname, port } = new URL(baseUrl);
143+
return new Promise((resolve, reject) => {
144+
const req = httpRequest({
145+
host: hostname,
146+
port,
147+
method,
148+
path: KEYS_PATH,
149+
headers: signedIn ? { [SESSION_HEADER]: '1' } : {},
150+
}, (res) => {
151+
let text = '';
152+
res.setEncoding('utf8');
153+
res.on('data', (chunk) => { text += chunk; });
154+
res.on('end', () => {
155+
const allow = res.headers.allow;
156+
resolve({
157+
status: res.statusCode ?? 0,
158+
allow: typeof allow === 'string' ? allow : null,
159+
body: JSON.parse(text) as Record<string, any>,
160+
});
161+
});
162+
});
163+
req.on('error', reject);
164+
req.end();
165+
});
166+
}
167+
168+
for (const method of UNREGISTRABLE_VERBS) {
169+
it(`anonymous ${method} → 401 UNAUTHENTICATED, no Allow — not the transport's 405 (#24228)`, async () => {
170+
const { status, allow, body } = await rawCall(method, false);
171+
172+
expect(status).toBe(401);
173+
expect(allow).toBeNull();
174+
expect(body.error.code).toBe('UNAUTHENTICATED');
175+
expect(body.error.httpStatus).toBe(401);
176+
expect(body.error).not.toHaveProperty('details');
177+
});
178+
179+
it(`signed-in ${method} → the domain's 405, whose \`Allow\` names POST alone (#24228)`, async () => {
180+
const { status, allow, body } = await rawCall(method, true);
181+
182+
expect(status).toBe(405);
183+
// Not `DELETE, GET, HEAD, PATCH, POST, PUT`: the four mounts above
184+
// exist only to refuse, and `Allow` names supported methods.
185+
expect(allow).toBe('POST');
186+
expect(body.error.code).toBe('METHOD_NOT_ALLOWED');
187+
expect(body.error.httpStatus).toBe(405);
188+
expect(body.error).not.toHaveProperty('details');
189+
});
190+
}
191+
128192
// ── control: the verb the route always served is answered as before ──
129193
it('anonymous POST → 401 UNAUTHENTICATED (unchanged)', async () => {
130194
const { res, body } = await call('POST', false);

0 commit comments

Comments
 (0)