Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .changeset/21806-runtime-ai-patch-mount.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
'@objectstack/runtime': patch
---

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).

Clause-②: no

- **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`.
- **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.
- **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.
Original file line number Diff line number Diff line change
@@ -0,0 +1,198 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* #21806 — `PATCH` reaches a declared AI route over HTTP, and a method the AI
* route table does not declare for a path still answers `405`.
*
* ## The defect
*
* `registerAIRoutes` mounted the `${base}/ai/*` method wildcards for `get`,
* `post`, `delete` and `put` only. On a host where the wildcards are the ONLY
* door into `/ai/**` — cloud's hosted composition, whose measured `405` listed
* `Allowed: DELETE, GET, HEAD, POST, PUT`, i.e. no concrete `PATCH` mount
* either — a `PATCH` never reached the dispatcher: Hono routed it to
* `notFound`, and the adapter's `unmatchedResponse()` answered `405` because
* the path matched the wildcards under the four other verbs. The declared
* `PATCH /api/v1/ai/conversations/:id` (the SDK's `ai.conversations.update`,
* the console's conversation rename) was therefore unreachable.
*
* ## Where the 405 came from, and why it needed a second producer
*
* Measured on `origin/main` before the fix, through this file: the `405` for
* an undeclared method was the ADAPTER's, and only for the one verb the
* wildcard did not mount. For the four mounted verbs the AI route table itself
* answered a method it does not declare with `404 ROUTE_NOT_FOUND` (its only
* miss exit). Mounting `patch` alone would therefore have moved an undeclared
* `PATCH` from the adapter's `405` to the table's `404` — so the table now
* tells the two misses apart, for every verb alike: a path declared under
* other methods answers `405 METHOD_NOT_ALLOWED` with an `Allow` header naming
* exactly the methods the table declares for it, and a path declared under
* none stays `404 ROUTE_NOT_FOUND`.
*
* ## The composition, and why the route table is installed AFTER boot
*
* `plugin-hono-server` + the dispatcher, scoping on under `auto`, so BOTH
* bases `registerAIRoutes` serves are mounted (the unscoped `${prefix}` and
* `${prefix}/environments/:environmentId`) and each case runs at each.
*
* The AI route table is written onto the kernel only once `bootstrap()` has
* returned, and no `ai:routes` hook ever fires. Either of those would make the
* dispatcher ALSO mount every declared route concretely (`mountAiRoute`), and
* a concrete `PATCH` mount would answer a `PATCH` whether or not the wildcard
* lets the verb through — the very door this file exists to measure would then
* be shadowed out of the reading. Installed late, the method wildcards are the
* only door, exactly as on the host where the defect was measured.
*
* Every route is declared `auth: false` so no session plumbing is needed; the
* route-level auth contract is pinned in `domains/ai-anonymous-deny-ordering.test.ts`
* and is not what this file measures.
*/

import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest';
import { LiteKernel } from '@objectstack/core';
import type { Plugin, PluginContext } from '@objectstack/core';
import { HonoServerPlugin } from '@objectstack/plugin-hono-server';
import type { IHttpServer } from '@objectstack/spec/contracts';

import { createDispatcherPlugin } from './dispatcher-plugin.js';

const PREFIX = '/api/v1';
const ENV_ID = 'env_alpha';

const BASES: Array<[string, string]> = [
['unscoped', PREFIX],
['scoped', `${PREFIX}/environments/${ENV_ID}`],
];

/** Every handler invocation, so a refusal can prove no handler ran. */
const calls: Array<{ route: string; params: Record<string, string>; body: any }> = [];

function route(method: string, path: string) {
return {
method,
path,
auth: false,
handler: async (req: any) => {
calls.push({ route: `${method} ${path}`, params: req.params, body: req.body });
return {
status: 200,
body: { success: true, data: { route: `${method} ${path}`, params: req.params, body: req.body ?? null } },
};
},
};
}

/**
* The AI route table. `/conversations/:id` carries the SDK's
* `ai.conversations.update` verb beside a read; `/models` is GET-only, the
* path the undeclared-method direction is asked on.
*/
const AI_ROUTES = [
route('GET', '/api/v1/ai/conversations/:id'),
route('PATCH', '/api/v1/ai/conversations/:id'),
route('GET', '/api/v1/ai/models'),
];

/** A serveable `ai` slot — the domain reads the route table only behind one. */
function fakeAiServicePlugin(): Plugin {
return {
name: 'com.objectstack.test.fake-ai-service',
version: '1.0.0',
init: async (ctx: PluginContext) => {
ctx.registerService('ai', { name: 'ai' });
},
};
}

let kernel: LiteKernel | undefined;
let baseUrl = '';

beforeAll(async () => {
kernel = new LiteKernel();
kernel.use(fakeAiServicePlugin());
kernel.use(new HonoServerPlugin({ port: 0, cors: false }));
kernel.use(createDispatcherPlugin({
prefix: PREFIX,
scoping: { enableProjectScoping: true, projectResolution: 'auto' },
enforceProjectMembership: false,
securityHeaders: false,
}));
await kernel.bootstrap();
// AFTER boot, deliberately — see the header: no concrete mount may exist.
(kernel as any).__aiRoutes = AI_ROUTES;
const httpServer = kernel.getService<IHttpServer>('http.server');
baseUrl = `http://127.0.0.1:${httpServer.getPort!()}`;
}, 60_000);

afterAll(async () => {
if (!kernel) return;
await Promise.race([
kernel.shutdown(),
new Promise<void>((resolve) => setTimeout(resolve, 10_000)),
]);
}, 60_000);

beforeEach(() => {
calls.length = 0;
});

async function probe(method: string, path: string, body?: unknown): Promise<{ status: number; allow: string | null; body: any }> {
const res = await fetch(`${baseUrl}${path}`, {
method,
...(body !== undefined
? { headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) }
: {}),
});
let parsed: any;
try { parsed = await res.json(); } catch { parsed = undefined; }
return { status: res.status, allow: res.headers.get('allow'), body: parsed };
}

describe.each(BASES)('#21806 — /ai/* method wildcards at the %s base', (_label, base) => {
it('PATCH to a declared AI route reaches its handler', async () => {
const r = await probe('PATCH', `${base}/ai/conversations/conv_1`, { title: 'Renamed' });

expect(r.status, JSON.stringify(r.body)).toBe(200);
expect(r.body).toEqual({
success: true,
data: {
route: 'PATCH /api/v1/ai/conversations/:id',
params: { id: 'conv_1' },
body: { title: 'Renamed' },
},
});
expect(calls).toEqual([
{ route: 'PATCH /api/v1/ai/conversations/:id', params: { id: 'conv_1' }, body: { title: 'Renamed' } },
]);
}, 60_000);

it('PATCH to a path the table declares under GET only answers 405 and runs no handler', async () => {
const r = await probe('PATCH', `${base}/ai/models`, { anything: true });

expect(r.status, JSON.stringify(r.body)).toBe(405);
expect(r.body?.success).toBe(false);
expect(r.body?.error?.code).toBe('METHOD_NOT_ALLOWED');
expect(r.allow).toBe('GET');
expect(calls).toEqual([]);
}, 60_000);

it('the same rule holds for a wildcard verb that is not PATCH — no per-verb case', async () => {
const r = await probe('PUT', `${base}/ai/conversations/conv_1`, { title: 'Renamed' });

expect(r.status, JSON.stringify(r.body)).toBe(405);
expect(r.body?.success).toBe(false);
expect(r.body?.error?.code).toBe('METHOD_NOT_ALLOWED');
expect(r.allow).toBe('GET, PATCH');
expect(calls).toEqual([]);
}, 60_000);

it('a path the table declares under no method stays 404 ROUTE_NOT_FOUND', async () => {
const r = await probe('PATCH', `${base}/ai/not-a-route`, {});

expect(r.status, JSON.stringify(r.body)).toBe(404);
expect(r.body?.success).toBe(false);
expect(r.body?.error?.code).toBe('ROUTE_NOT_FOUND');
expect(r.allow).toBeNull();
expect(calls).toEqual([]);
}, 60_000);
});
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,11 @@
* family. THIS file pins the concrete hook-route mounts (`RouteDefinition[]`
* emitted via `ai:routes`, recovered here through the `__aiRoutes` cache),
* whose 401 arm writes the FLAT family. The arm is live on the wire, not just
* in principle: `registerAIRoutes` mounts wildcards for get/post/delete/put
* only, so a PATCH route under `/ai/*` — a legal `RouteDefinition.method` —
* reaches these concrete mounts unshadowed, as does any emitted path outside
* `/ai/*`.
* in principle: any emitted path outside `/ai/*` reaches these concrete mounts
* unshadowed. A PATCH route under `/ai/*` no longer does — since #21806
* `registerAIRoutes` mounts its wildcard for `patch` too, and the wildcard is
* registered first — but the drive below calls the mounted handler directly,
* never through a router, so the PATCH fixture still exercises this arm.
*
* Until #9823 the arm wrote an inline `{ error, message }` copy of
* `ANONYMOUS_DENY_BODY`, which is exactly why #9487's additive `code` key
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -176,9 +176,11 @@ describe('mountRouteOnServer write-less fallback (#9936)', () => {
drained.value = true;
})(),
}));
// PATCH: a legal RouteDefinition.method that only the concrete
// hook-route mounts serve (the /ai/* wildcards cover get/post/delete/
// put), so this pins the same arm that is live unshadowed on the wire.
// PATCH: a legal RouteDefinition.method. Since #21806 the /ai/*
// wildcards cover patch too, so on the wire this path is answered by
// the wildcard; the concrete mount's arm stays live for emitted paths
// outside /ai/*, and this drive calls the mounted handler directly, so
// it pins that arm either way.
const ctx = makeCtx(fakeServer, [{ method: 'PATCH', path: ROUTE, auth: false, handler }]);
const plugin = createDispatcherPlugin({ prefix: '/api/v1', securityHeaders: false });
await plugin.start?.(ctx);
Expand Down
12 changes: 11 additions & 1 deletion packages/runtime/src/dispatcher-plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1681,12 +1681,22 @@ export function createDispatcherPlugin(config: DispatcherPluginConfig = {}): Plu
// through `dispatcher.dispatch()` — that triggers the kernel
// swap and then routes via `handleAI`, which looks up the
// AI service on the current (project) kernel.
//
// [#21806] `patch` is mounted beside the other four: the SDK's
// `ai.conversations.update` sends it. The wildcard decides nothing
// per route — `handleAIRequest` matches the table, answering an
// undeclared method on a declared path `405` and an undeclared path
// `404`. A verb missing here makes every route the table declares
// under it unreachable: on a host where these wildcards are the
// only door into `/ai/**`, the adapter answers its own `405` before
// the table is ever consulted.
const registerAIRoutes = (base: string) => {
const wildcards: Array<['get'|'post'|'delete'|'put', string]> = [
const wildcards: Array<['get'|'post'|'delete'|'put'|'patch', string]> = [
['get', `${base}/ai/*`],
['post', `${base}/ai/*`],
['delete', `${base}/ai/*`],
['put', `${base}/ai/*`],
['patch', `${base}/ai/*`],
];
for (const [method, pattern] of wildcards) {
(server as any)![method](pattern, async (req: any, res: any) => {
Expand Down
40 changes: 39 additions & 1 deletion packages/runtime/src/domains/ai.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
import { isServiceServeable } from '../service-serveable.js';
import { actorUserFromExecutionContext, resolveActorDisplayName } from '../security/actor-user.js';
import { capabilityUnavailable } from './unavailable.js';
import { buildApiError } from '../error-envelope.js';
import type { IAIService } from '@objectstack/spec/contracts';
import type { HttpProtocolContext, HttpDispatcherResult } from '../http-dispatcher.js';
import type { DomainHandlerDeps, DomainRoute } from '../domain-handler-registry.js';
Expand Down Expand Up @@ -150,8 +151,16 @@ export async function handleAIRequest(deps: DomainHandlerDeps, subPath: string,
return { handled: true, response: deps.error('AI service routes not yet initialized', 503) };
}

// [#21806] The methods the table declares for THIS path under another verb,
// gathered while the loop looks for an exact match, so the miss exit below
// can tell "this path, not this method" (405) from "no such path" (404).
const declaredForPath = new Set<string>();

for (const route of routes) {
if (route.method !== method) continue;
if (route.method !== method) {
if (matchRoute(route.path, fullPath) !== null) declaredForPath.add(route.method);
continue;
}
const params = matchRoute(route.path, fullPath);
if (params === null) continue;

Expand Down Expand Up @@ -276,6 +285,35 @@ export async function handleAIRequest(deps: DomainHandlerDeps, subPath: string,
};
}

// [#21806] A method the table does not declare on a path it does declare is
// `405` with an `Allow` header naming exactly the declared methods, for
// every verb alike. This exit is the producer of that answer for `/ai/**`:
// the dispatcher plugin's `/ai/*` wildcards claim GET, POST, PUT, DELETE and
// PATCH, so for those the transport's own unmatched-method `405` (the
// `IHttpServer` contract in `@objectstack/spec/contracts`) never fires, and
// this table is the only place that knows which methods a path really
// declares. Hand-rolled rather than
// `deps.error(...)` only for the header, as in `domains/meta.ts` and
// `domains/mcp.ts`; the body goes through the one builder and the code is
// DERIVED from the status (`METHOD_NOT_ALLOWED`), never spelled here.
if (declaredForPath.size > 0) {
const allow = Array.from(declaredForPath).sort().join(', ');
return {
handled: true,
response: {
status: 405,
headers: { Allow: allow },
body: {
success: false,
error: buildApiError({
message: `${method} is not supported for ${subPath}. Allowed: ${allow}.`,
httpStatus: 405,
}),
},
},
};
}

return {
handled: true,
response: deps.routeNotFound(subPath),
Expand Down
11 changes: 7 additions & 4 deletions scripts/check-route-envelope.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -528,11 +528,14 @@ const DISPATCHER_DOMAINS = {
// here, plus one `deps.error` 501.
'auth.ts': { handBuilt: 0 },

// Kind 2 — the `{ agents: [] }` fallback moved onto `deps.success` in #4053;
// what remains is the passthrough of the AI service's own result.
// Kinds 1 and 2 together. Kind 2 — the `{ agents: [] }` fallback moved onto
// `deps.success` in #4053; what remains is the passthrough of the AI
// service's own result. [#21806] Was 1. The route table's method-mismatch
// miss answers 405 and must carry `Allow:` — the same reason `mcp.ts` and
// `meta.ts` hand-roll theirs.
'ai.ts': {
handBuilt: 1,
note: "passthrough of the AI service result (status and body are the service's, streaming included)",
handBuilt: 2,
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",
},
};

Expand Down
Loading