Skip to content

Commit 088428f

Browse files
fix(runtime): PATCH reaches a declared AI route through the /ai/* wildcards, and an undeclared method answers 405 (#21823)
Fixes #21806 Clause-②: no A declared `PATCH` AI route is reachable over HTTP, and a method the AI route table does not declare for a path still answers `405`. Both directions are pinned over HTTP at both bases `registerAIRoutes` serves. The declared `PATCH` route here is `PATCH /api/v1/ai/conversations/:id`: the SDK's `ai.conversations.update` and the console's conversation rename. ## What changed - **`packages/runtime/src/dispatcher-plugin.ts`.** `registerAIRoutes` mounts `patch` on the `/ai/*` method wildcards beside `get`, `post`, `delete` and `put`, at every base. There is no per-route case. The wildcard hands every verb to the AI route table. - **`packages/runtime/src/domains/ai.ts`.** The AI route table now tells its two misses apart, and the rule is the same for every verb: - A method the table does not declare on a path it does declare answers `405 METHOD_NOT_ALLOWED`. The `Allow` header names exactly the declared methods, and no handler runs. - A path the table declares under no method still answers `404 ROUTE_NOT_FOUND`. - The 405 body is built by `buildApiError`, which derives the code from the status (ADR-0112). It is hand-rolled only to add the `Allow` header, as `domains/meta.ts` and `domains/mcp.ts` do. No new error code. - **`scripts/check-route-envelope.mjs`.** The hand-built count for `ai.ts` goes from 1 to 2. The note gives the reason: the 405 has to carry `Allow`. - **Two sibling test headers, comments only.** `dispatcher-plugin.route-auth-deny-body.test.ts` and `dispatcher-plugin.streaming-fallback.test.ts` said that a PATCH route under `/ai/*` reaches the concrete hook-route mounts unshadowed. This PR makes that false, so the comments now describe the new behaviour. Their drives call the mounted handler directly, so what they pin is unchanged. - **`.changeset/21806-runtime-ai-patch-mount.md`.** `@objectstack/runtime` `patch`. ## Where the 405 came from (measured) Each reading below was taken over HTTP with the new pin file: `plugin-hono-server` plus the dispatcher, driven with real `fetch`, one committed state at a time. Both bases gave the same answers. | commit | declared PATCH | PATCH to a GET-only path | PUT to a GET+PATCH path | PATCH to an undeclared path | |---|---|---|---|---| | `49c3bfb1` (pins only, main's source) | adapter 405, `Allow: DELETE, GET, HEAD, POST, PUT` | adapter 405 | **AI table 404 `ROUTE_NOT_FOUND`** | adapter 405 | | `a687964e` (adds the `patch` mount only) | 200 from the handler | **AI table 404 `ROUTE_NOT_FOUND`** | AI table 404 | AI table 404 | | `41a9c5d7` onward (adds the table's 405) | 200 from the handler | 405 `METHOD_NOT_ALLOWED`, `Allow: GET` | 405, `Allow: GET, PATCH` | 404 `ROUTE_NOT_FOUND` | The first row reproduces the card's measurement exactly: same status, same code, same `Allow` list. The dispatch order listed five mechanism assumptions. Each was measured: - **H1 holds.** The wildcard list was `get`, `post`, `delete` and `put` only. - **H2 holds.** `IHttpServer.patch` is a required member. The new mount is really served: a real PATCH reaches the handler at both bases (row 2). - **H3.** `registerAIRoutes` serves two bases: `${prefix}` and `${prefix}/environments/:environmentId`. - Under `projectResolution: 'auto'` it serves both. - Under `required` it serves only the scoped base. - With scoping off it serves only the unscoped base. - The pin uses `auto`, so every case runs at both bases. - **H4.** Nothing in this repo produces an AI route table: `ai:routes` and `__aiRoutes` are written by cloud's `service-ai`. The pin installs a test table instead: - The table has `GET` and `PATCH` on `/api/v1/ai/conversations/:id` and `GET` on `/api/v1/ai/models`, all `auth: false`. - It is installed on the kernel after boot, so no concrete hook-route mount exists. The wildcards are therefore the only door, as on the host where the defect was measured. That host's `Allow` list had no `PATCH`, so it had no concrete mount either. - **H5 is falsified.** - The 405 for an undeclared method came from the server adapter (`plugin-hono-server`'s `unmatchedResponse()`), and only because PATCH was not mounted. - The AI table itself answered an undeclared method with 404 (row 1, the PUT column). - Mounting `patch` alone therefore turns an undeclared PATCH into a 404 (row 2), which breaks the ruling "An undeclared method still answers 405." - So the table now produces the 405 itself, the same way for every verb. A PATCH-only branch would have been a per-verb special case. ### What a caller sees change besides the declared PATCH - A GET, POST, PUT or DELETE to a declared AI path under the wrong method used to answer 404 `ROUTE_NOT_FOUND`. It now answers 405 `METHOD_NOT_ALLOWED` with `Allow`. - A PATCH to an AI path the table does not declare used to answer the adapter's 405. It now answers 404 `ROUTE_NOT_FOUND`. - No request that was refused before is served now, except a PATCH to a route the table declares, so no accept set widens (Clause-② no). Only refusal codes move, as listed above. ## Tests All readings come from commits on this branch. - **New `packages/runtime/src/dispatcher-plugin.ai-wildcard-methods.integration.test.ts`.** At each base it pins four cases: 1. A PATCH to the declared route answers 200 with the handler's body. The handler is called exactly once, with `params.id` and the JSON body. 2. A PATCH to the GET-only path answers 405. `error.code` is `METHOD_NOT_ALLOWED`, the header is `Allow: GET`, and no handler is called. 3. A PUT to the GET+PATCH path answers 405 with `Allow: GET, PATCH`, and no handler is called. 4. A PATCH to an undeclared path answers 404 `ROUTE_NOT_FOUND` with no `Allow` header, and no handler is called. At `f51a2b39` with `--reporter=verbose`, the result is `Tests 8 passed (8)`. - **Red before the fix.** Reading the first two rows of the table above as failing pins: `49c3bfb1` gave 8 failed of 8, and `a687964e` gave 4 failed and 4 passed. The 4 that failed there were the two 405 cases at each base. - **Ablating the mount from the finished state (`936c2ca7`).** - `scripts/ablation-replace.mjs` deleted the `patch` wildcard line. The anchor went from 1 to 0, and the file's blob went from `0e4af971f8ca` to `015022c8289e`. - Result: 6 failed and 2 passed. The two that passed are the PUT cases, which the table's 405 still holds. - The file was restored to the HEAD blob `0e4af971f8ca`, and `git diff HEAD` was empty afterwards. - The test imports the dispatcher from `src`, so there is no `dist` hop in the path being ablated. - **`@objectstack/runtime`, run at `936c2ca7`.** The only commit after it, `f51a2b39`, touches only `scripts/check-route-envelope.mjs`, and no runtime test reads that file. - `vitest run --project local`: `Test Files 326 passed (326)`, `Tests 4632 passed | 19 skipped (4651)`. - `--project repo`: `Test Files 3 passed (3)`, `Tests 751 passed (751)`. - `typecheck`: `tsc --noEmit` is green. `check:test-typecheck` reports OK, with the debt ledger unchanged. - `tsc -p tsconfig.test.json --listFiles` includes the new test file and reports zero errors for it. - **Gates at `f51a2b39`.** - `node scripts/pm/dispatch-gates.mjs --commands` derived 82 families. All 82 were run, all exited 0, and `--ran` reconciled the list: 82 derived, 82 run, 0 NOT-MEASURED, 0 UNRUN, with every family carrying its exit code. - `pnpm check:pm-dispatch-gates` was run detached, as its header prescribes: `dispatch-gates self-test: 1976 cases pass`. - `pnpm lint` (the full `eslint . --no-inline-config`) exited 0. ## Acceptance notes - **Anonymous callers and the table's miss exits.** The pre-existing 404 exit and the new 405 exit both answer without consulting the family-default anonymous gate. The pin drives them anonymously, against `auth: false` routes. - Before this PR, an anonymous PUT to a declared path got a 404, and an anonymous PATCH got the adapter's 405 listing the wildcard verbs. - It now gets a 405 whose `Allow` header lists the methods the table declares. - This posture is left unchanged here and raised to the seat as an open question. - **`mountRouteOnServer` has no `put` arm.** It mounts get, post, delete and patch, so a PUT route emitted through `ai:routes` is never mounted concretely. Under `/ai/*` the wildcard serves PUT, so this has no measured reach, and no producer of a PUT route outside `/ai/*` is named. Noted, not filed. - **HEAD and OPTIONS routes are unreachable through the wildcards.** Both are in `RouteDefinition.method`'s vocabulary, but the wildcards never dispatch either. Hono answers HEAD from the GET wildcard and dispatches it as GET. Dormant: no producer is measured. Noted, not filed. - **HEAD on the raw-method catch-all.** `createHonoApp`'s catch-all dispatches the raw method. On that composition, a HEAD to a GET-only AI path now answers 405 with `Allow: GET` instead of 404. This is the same table rule, not a special case. - **PATCH now takes the same request path as the other verbs.** The `patch` wildcard is registered before the concrete hook-route mounts, as the other four already were. A PATCH route that `ai:routes` also mounts concretely is now shadowed by the wildcard, so PATCH uses the ExecutionContext-backed `req.user` like the other verbs. - **NOT MEASURED: cloud's hosted composition.** There is no cloud checkout here, and per the card, staging egress is blocked. objectstack-ai/cloud#2622 moves its pin once this lands. --- _Generated by [Claude Code](https://claude.ai/code/session_01RWZbGvPFcRKvUqASZtunCU)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 07bf21f commit 088428f

7 files changed

Lines changed: 276 additions & 13 deletions
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
'@objectstack/runtime': patch
3+
---
4+
5+
A declared `PATCH` AI route is reachable over HTTP: the dispatcher's `/ai/*` method wildcards mount `patch` beside `get`, `post`, `put` and `delete`, so `PATCH /api/v1/ai/conversations/:id` (the SDK's `ai.conversations.update`, the console's conversation rename) reaches its handler instead of answering `405` (#21806).
6+
7+
Clause-②: no
8+
9+
- **Where it failed.** On a host where the wildcards are the only door into `/ai/**`, a `PATCH` never reached the dispatcher. The server adapter answered `405 METHOD_NOT_ALLOWED` with `Allow: DELETE, GET, HEAD, POST, PUT`, because the path matched the wildcards under the four other verbs. Both bases the wildcards serve are fixed: `/api/v1` and `/api/v1/environments/:environmentId`.
10+
- **An undeclared method still answers `405`.** The AI route table now tells its two misses apart. A method the table does not declare on a path it does declare answers `405 METHOD_NOT_ALLOWED`, with an `Allow` header that names exactly the declared methods, and no handler runs. A path the table declares under no method still answers `404 ROUTE_NOT_FOUND`. The rule is the same for every verb.
11+
- **What a caller sees change.** A `GET`, `POST`, `PUT` or `DELETE` that names a declared AI path under the wrong method used to answer `404 ROUTE_NOT_FOUND`. It now answers `405` with `Allow`. A `PATCH` to an AI path the table does not declare at all used to answer the adapter's `405`. It now answers `404 ROUTE_NOT_FOUND`. No request that was refused before is served now, except a `PATCH` to a route the table declares.
Lines changed: 198 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,198 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* #21806 — `PATCH` reaches a declared AI route over HTTP, and a method the AI
5+
* route table does not declare for a path still answers `405`.
6+
*
7+
* ## The defect
8+
*
9+
* `registerAIRoutes` mounted the `${base}/ai/*` method wildcards for `get`,
10+
* `post`, `delete` and `put` only. On a host where the wildcards are the ONLY
11+
* door into `/ai/**` — cloud's hosted composition, whose measured `405` listed
12+
* `Allowed: DELETE, GET, HEAD, POST, PUT`, i.e. no concrete `PATCH` mount
13+
* either — a `PATCH` never reached the dispatcher: Hono routed it to
14+
* `notFound`, and the adapter's `unmatchedResponse()` answered `405` because
15+
* the path matched the wildcards under the four other verbs. The declared
16+
* `PATCH /api/v1/ai/conversations/:id` (the SDK's `ai.conversations.update`,
17+
* the console's conversation rename) was therefore unreachable.
18+
*
19+
* ## Where the 405 came from, and why it needed a second producer
20+
*
21+
* Measured on `origin/main` before the fix, through this file: the `405` for
22+
* an undeclared method was the ADAPTER's, and only for the one verb the
23+
* wildcard did not mount. For the four mounted verbs the AI route table itself
24+
* answered a method it does not declare with `404 ROUTE_NOT_FOUND` (its only
25+
* miss exit). Mounting `patch` alone would therefore have moved an undeclared
26+
* `PATCH` from the adapter's `405` to the table's `404` — so the table now
27+
* tells the two misses apart, for every verb alike: a path declared under
28+
* other methods answers `405 METHOD_NOT_ALLOWED` with an `Allow` header naming
29+
* exactly the methods the table declares for it, and a path declared under
30+
* none stays `404 ROUTE_NOT_FOUND`.
31+
*
32+
* ## The composition, and why the route table is installed AFTER boot
33+
*
34+
* `plugin-hono-server` + the dispatcher, scoping on under `auto`, so BOTH
35+
* bases `registerAIRoutes` serves are mounted (the unscoped `${prefix}` and
36+
* `${prefix}/environments/:environmentId`) and each case runs at each.
37+
*
38+
* The AI route table is written onto the kernel only once `bootstrap()` has
39+
* returned, and no `ai:routes` hook ever fires. Either of those would make the
40+
* dispatcher ALSO mount every declared route concretely (`mountAiRoute`), and
41+
* a concrete `PATCH` mount would answer a `PATCH` whether or not the wildcard
42+
* lets the verb through — the very door this file exists to measure would then
43+
* be shadowed out of the reading. Installed late, the method wildcards are the
44+
* only door, exactly as on the host where the defect was measured.
45+
*
46+
* Every route is declared `auth: false` so no session plumbing is needed; the
47+
* route-level auth contract is pinned in `domains/ai-anonymous-deny-ordering.test.ts`
48+
* and is not what this file measures.
49+
*/
50+
51+
import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest';
52+
import { LiteKernel } from '@objectstack/core';
53+
import type { Plugin, PluginContext } from '@objectstack/core';
54+
import { HonoServerPlugin } from '@objectstack/plugin-hono-server';
55+
import type { IHttpServer } from '@objectstack/spec/contracts';
56+
57+
import { createDispatcherPlugin } from './dispatcher-plugin.js';
58+
59+
const PREFIX = '/api/v1';
60+
const ENV_ID = 'env_alpha';
61+
62+
const BASES: Array<[string, string]> = [
63+
['unscoped', PREFIX],
64+
['scoped', `${PREFIX}/environments/${ENV_ID}`],
65+
];
66+
67+
/** Every handler invocation, so a refusal can prove no handler ran. */
68+
const calls: Array<{ route: string; params: Record<string, string>; body: any }> = [];
69+
70+
function route(method: string, path: string) {
71+
return {
72+
method,
73+
path,
74+
auth: false,
75+
handler: async (req: any) => {
76+
calls.push({ route: `${method} ${path}`, params: req.params, body: req.body });
77+
return {
78+
status: 200,
79+
body: { success: true, data: { route: `${method} ${path}`, params: req.params, body: req.body ?? null } },
80+
};
81+
},
82+
};
83+
}
84+
85+
/**
86+
* The AI route table. `/conversations/:id` carries the SDK's
87+
* `ai.conversations.update` verb beside a read; `/models` is GET-only, the
88+
* path the undeclared-method direction is asked on.
89+
*/
90+
const AI_ROUTES = [
91+
route('GET', '/api/v1/ai/conversations/:id'),
92+
route('PATCH', '/api/v1/ai/conversations/:id'),
93+
route('GET', '/api/v1/ai/models'),
94+
];
95+
96+
/** A serveable `ai` slot — the domain reads the route table only behind one. */
97+
function fakeAiServicePlugin(): Plugin {
98+
return {
99+
name: 'com.objectstack.test.fake-ai-service',
100+
version: '1.0.0',
101+
init: async (ctx: PluginContext) => {
102+
ctx.registerService('ai', { name: 'ai' });
103+
},
104+
};
105+
}
106+
107+
let kernel: LiteKernel | undefined;
108+
let baseUrl = '';
109+
110+
beforeAll(async () => {
111+
kernel = new LiteKernel();
112+
kernel.use(fakeAiServicePlugin());
113+
kernel.use(new HonoServerPlugin({ port: 0, cors: false }));
114+
kernel.use(createDispatcherPlugin({
115+
prefix: PREFIX,
116+
scoping: { enableProjectScoping: true, projectResolution: 'auto' },
117+
enforceProjectMembership: false,
118+
securityHeaders: false,
119+
}));
120+
await kernel.bootstrap();
121+
// AFTER boot, deliberately — see the header: no concrete mount may exist.
122+
(kernel as any).__aiRoutes = AI_ROUTES;
123+
const httpServer = kernel.getService<IHttpServer>('http.server');
124+
baseUrl = `http://127.0.0.1:${httpServer.getPort!()}`;
125+
}, 60_000);
126+
127+
afterAll(async () => {
128+
if (!kernel) return;
129+
await Promise.race([
130+
kernel.shutdown(),
131+
new Promise<void>((resolve) => setTimeout(resolve, 10_000)),
132+
]);
133+
}, 60_000);
134+
135+
beforeEach(() => {
136+
calls.length = 0;
137+
});
138+
139+
async function probe(method: string, path: string, body?: unknown): Promise<{ status: number; allow: string | null; body: any }> {
140+
const res = await fetch(`${baseUrl}${path}`, {
141+
method,
142+
...(body !== undefined
143+
? { headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) }
144+
: {}),
145+
});
146+
let parsed: any;
147+
try { parsed = await res.json(); } catch { parsed = undefined; }
148+
return { status: res.status, allow: res.headers.get('allow'), body: parsed };
149+
}
150+
151+
describe.each(BASES)('#21806 — /ai/* method wildcards at the %s base', (_label, base) => {
152+
it('PATCH to a declared AI route reaches its handler', async () => {
153+
const r = await probe('PATCH', `${base}/ai/conversations/conv_1`, { title: 'Renamed' });
154+
155+
expect(r.status, JSON.stringify(r.body)).toBe(200);
156+
expect(r.body).toEqual({
157+
success: true,
158+
data: {
159+
route: 'PATCH /api/v1/ai/conversations/:id',
160+
params: { id: 'conv_1' },
161+
body: { title: 'Renamed' },
162+
},
163+
});
164+
expect(calls).toEqual([
165+
{ route: 'PATCH /api/v1/ai/conversations/:id', params: { id: 'conv_1' }, body: { title: 'Renamed' } },
166+
]);
167+
}, 60_000);
168+
169+
it('PATCH to a path the table declares under GET only answers 405 and runs no handler', async () => {
170+
const r = await probe('PATCH', `${base}/ai/models`, { anything: true });
171+
172+
expect(r.status, JSON.stringify(r.body)).toBe(405);
173+
expect(r.body?.success).toBe(false);
174+
expect(r.body?.error?.code).toBe('METHOD_NOT_ALLOWED');
175+
expect(r.allow).toBe('GET');
176+
expect(calls).toEqual([]);
177+
}, 60_000);
178+
179+
it('the same rule holds for a wildcard verb that is not PATCH — no per-verb case', async () => {
180+
const r = await probe('PUT', `${base}/ai/conversations/conv_1`, { title: 'Renamed' });
181+
182+
expect(r.status, JSON.stringify(r.body)).toBe(405);
183+
expect(r.body?.success).toBe(false);
184+
expect(r.body?.error?.code).toBe('METHOD_NOT_ALLOWED');
185+
expect(r.allow).toBe('GET, PATCH');
186+
expect(calls).toEqual([]);
187+
}, 60_000);
188+
189+
it('a path the table declares under no method stays 404 ROUTE_NOT_FOUND', async () => {
190+
const r = await probe('PATCH', `${base}/ai/not-a-route`, {});
191+
192+
expect(r.status, JSON.stringify(r.body)).toBe(404);
193+
expect(r.body?.success).toBe(false);
194+
expect(r.body?.error?.code).toBe('ROUTE_NOT_FOUND');
195+
expect(r.allow).toBeNull();
196+
expect(calls).toEqual([]);
197+
}, 60_000);
198+
});

‎packages/runtime/src/dispatcher-plugin.route-auth-deny-body.test.ts‎

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,10 +11,11 @@
1111
* family. THIS file pins the concrete hook-route mounts (`RouteDefinition[]`
1212
* emitted via `ai:routes`, recovered here through the `__aiRoutes` cache),
1313
* whose 401 arm writes the FLAT family. The arm is live on the wire, not just
14-
* in principle: `registerAIRoutes` mounts wildcards for get/post/delete/put
15-
* only, so a PATCH route under `/ai/*` — a legal `RouteDefinition.method` —
16-
* reaches these concrete mounts unshadowed, as does any emitted path outside
17-
* `/ai/*`.
14+
* in principle: any emitted path outside `/ai/*` reaches these concrete mounts
15+
* unshadowed. A PATCH route under `/ai/*` no longer does — since #21806
16+
* `registerAIRoutes` mounts its wildcard for `patch` too, and the wildcard is
17+
* registered first — but the drive below calls the mounted handler directly,
18+
* never through a router, so the PATCH fixture still exercises this arm.
1819
*
1920
* Until #9823 the arm wrote an inline `{ error, message }` copy of
2021
* `ANONYMOUS_DENY_BODY`, which is exactly why #9487's additive `code` key

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

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -176,9 +176,11 @@ describe('mountRouteOnServer write-less fallback (#9936)', () => {
176176
drained.value = true;
177177
})(),
178178
}));
179-
// PATCH: a legal RouteDefinition.method that only the concrete
180-
// hook-route mounts serve (the /ai/* wildcards cover get/post/delete/
181-
// put), so this pins the same arm that is live unshadowed on the wire.
179+
// PATCH: a legal RouteDefinition.method. Since #21806 the /ai/*
180+
// wildcards cover patch too, so on the wire this path is answered by
181+
// the wildcard; the concrete mount's arm stays live for emitted paths
182+
// outside /ai/*, and this drive calls the mounted handler directly, so
183+
// it pins that arm either way.
182184
const ctx = makeCtx(fakeServer, [{ method: 'PATCH', path: ROUTE, auth: false, handler }]);
183185
const plugin = createDispatcherPlugin({ prefix: '/api/v1', securityHeaders: false });
184186
await plugin.start?.(ctx);

‎packages/runtime/src/dispatcher-plugin.ts‎

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1681,12 +1681,22 @@ export function createDispatcherPlugin(config: DispatcherPluginConfig = {}): Plu
16811681
// through `dispatcher.dispatch()` — that triggers the kernel
16821682
// swap and then routes via `handleAI`, which looks up the
16831683
// AI service on the current (project) kernel.
1684+
//
1685+
// [#21806] `patch` is mounted beside the other four: the SDK's
1686+
// `ai.conversations.update` sends it. The wildcard decides nothing
1687+
// per route — `handleAIRequest` matches the table, answering an
1688+
// undeclared method on a declared path `405` and an undeclared path
1689+
// `404`. A verb missing here makes every route the table declares
1690+
// under it unreachable: on a host where these wildcards are the
1691+
// only door into `/ai/**`, the adapter answers its own `405` before
1692+
// the table is ever consulted.
16841693
const registerAIRoutes = (base: string) => {
1685-
const wildcards: Array<['get'|'post'|'delete'|'put', string]> = [
1694+
const wildcards: Array<['get'|'post'|'delete'|'put'|'patch', string]> = [
16861695
['get', `${base}/ai/*`],
16871696
['post', `${base}/ai/*`],
16881697
['delete', `${base}/ai/*`],
16891698
['put', `${base}/ai/*`],
1699+
['patch', `${base}/ai/*`],
16901700
];
16911701
for (const [method, pattern] of wildcards) {
16921702
(server as any)![method](pattern, async (req: any, res: any) => {

‎packages/runtime/src/domains/ai.ts‎

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import {
1616
import { isServiceServeable } from '../service-serveable.js';
1717
import { actorUserFromExecutionContext, resolveActorDisplayName } from '../security/actor-user.js';
1818
import { capabilityUnavailable } from './unavailable.js';
19+
import { buildApiError } from '../error-envelope.js';
1920
import type { IAIService } from '@objectstack/spec/contracts';
2021
import type { HttpProtocolContext, HttpDispatcherResult } from '../http-dispatcher.js';
2122
import type { DomainHandlerDeps, DomainRoute } from '../domain-handler-registry.js';
@@ -150,8 +151,16 @@ export async function handleAIRequest(deps: DomainHandlerDeps, subPath: string,
150151
return { handled: true, response: deps.error('AI service routes not yet initialized', 503) };
151152
}
152153

154+
// [#21806] The methods the table declares for THIS path under another verb,
155+
// gathered while the loop looks for an exact match, so the miss exit below
156+
// can tell "this path, not this method" (405) from "no such path" (404).
157+
const declaredForPath = new Set<string>();
158+
153159
for (const route of routes) {
154-
if (route.method !== method) continue;
160+
if (route.method !== method) {
161+
if (matchRoute(route.path, fullPath) !== null) declaredForPath.add(route.method);
162+
continue;
163+
}
155164
const params = matchRoute(route.path, fullPath);
156165
if (params === null) continue;
157166

@@ -276,6 +285,35 @@ export async function handleAIRequest(deps: DomainHandlerDeps, subPath: string,
276285
};
277286
}
278287

288+
// [#21806] A method the table does not declare on a path it does declare is
289+
// `405` with an `Allow` header naming exactly the declared methods, for
290+
// every verb alike. This exit is the producer of that answer for `/ai/**`:
291+
// the dispatcher plugin's `/ai/*` wildcards claim GET, POST, PUT, DELETE and
292+
// PATCH, so for those the transport's own unmatched-method `405` (the
293+
// `IHttpServer` contract in `@objectstack/spec/contracts`) never fires, and
294+
// this table is the only place that knows which methods a path really
295+
// declares. Hand-rolled rather than
296+
// `deps.error(...)` only for the header, as in `domains/meta.ts` and
297+
// `domains/mcp.ts`; the body goes through the one builder and the code is
298+
// DERIVED from the status (`METHOD_NOT_ALLOWED`), never spelled here.
299+
if (declaredForPath.size > 0) {
300+
const allow = Array.from(declaredForPath).sort().join(', ');
301+
return {
302+
handled: true,
303+
response: {
304+
status: 405,
305+
headers: { Allow: allow },
306+
body: {
307+
success: false,
308+
error: buildApiError({
309+
message: `${method} is not supported for ${subPath}. Allowed: ${allow}.`,
310+
httpStatus: 405,
311+
}),
312+
},
313+
},
314+
};
315+
}
316+
279317
return {
280318
handled: true,
281319
response: deps.routeNotFound(subPath),

‎scripts/check-route-envelope.mjs‎

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -528,11 +528,14 @@ const DISPATCHER_DOMAINS = {
528528
// here, plus one `deps.error` 501.
529529
'auth.ts': { handBuilt: 0 },
530530

531-
// Kind 2 — the `{ agents: [] }` fallback moved onto `deps.success` in #4053;
532-
// what remains is the passthrough of the AI service's own result.
531+
// Kinds 1 and 2 together. Kind 2 — the `{ agents: [] }` fallback moved onto
532+
// `deps.success` in #4053; what remains is the passthrough of the AI
533+
// service's own result. [#21806] Was 1. The route table's method-mismatch
534+
// miss answers 405 and must carry `Allow:` — the same reason `mcp.ts` and
535+
// `meta.ts` hand-roll theirs.
533536
'ai.ts': {
534-
handBuilt: 1,
535-
note: "passthrough of the AI service result (status and body are the service's, streaming included)",
537+
handBuilt: 2,
538+
note: "passthrough of the AI service result (status and body are the service's, streaming included); one 405 for a method the AI route table does not declare on a path it does, which must carry an `Allow:` header (`deps.error` takes none) and emits the declared envelope, the code derived from the status",
536539
},
537540
};
538541

0 commit comments

Comments
 (0)