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
16 changes: 16 additions & 0 deletions .changeset/23909-keys-every-verb-anonymous-floor.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
---
'@objectstack/runtime': patch
---

fix(runtime): the standalone server hands every `/api/v1/keys` verb to the `keys` domain, so an anonymous caller is answered `401 UNAUTHENTICATED` whatever the verb

Clause-②: no

The v18 text for this door reads "`/keys`: `401 UNAUTHENTICATED` for every verb". The `keys` domain takes that decision first, but the standalone server (the one `os dev` and `os serve` run) mounted `/api/v1/keys` for `POST` only. Any other verb never reached the domain: the HTTP adapter answered it itself, with a `405` that named `POST` as the allowed method.

What a `GET`, `PUT`, `PATCH` or `DELETE` call to `/api/v1/keys` is answered on the standalone server:

- **Anonymous caller.** Before: `405 METHOD_NOT_ALLOWED` with `Allow: POST`, from the adapter. Now: `401 UNAUTHENTICATED` in the dispatcher's standard envelope, `{ success: false, error: { code: 'UNAUTHENTICATED', message, httpStatus: 401 } }`.
- **Signed-in caller.** Before: `405 METHOD_NOT_ALLOWED` with `Allow: POST`, from the adapter. Now: still `405 METHOD_NOT_ALLOWED`, answered by the `keys` domain in the same standard envelope (`error.httpStatus: 405`, no `error.details`), and without an `Allow` header.

`POST /api/v1/keys` is unchanged, and so is every answer on a host that reaches the dispatcher through `@objectstack/hono`'s catch-all, which already answered this way. A `HEAD` request follows `GET`: `401` anonymous, `405` signed in. A sub-path such as `/api/v1/keys/<anything>` is still not mounted on the standalone server and is answered `404`.
10 changes: 8 additions & 2 deletions packages/runtime/src/dispatcher-plugin.routes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,15 +48,21 @@ function makeCtx(fakeServer: any) {
}

describe('createDispatcherPlugin — HTTP route registration', () => {
it('mounts /mcp (GET/POST/DELETE) and /keys (POST) so they reach dispatch()', async () => {
// /keys carries all five verbs (#23909): POST is the only one it serves, but
// the v18 contract answers an anonymous caller 401 for EVERY verb, and the
// domain's floor can only answer a verb that is mounted. The wire answer is
// pinned in `keys-every-verb.hono.integration.test.ts`.
it('mounts /mcp (GET/POST/DELETE) and /keys (GET/POST/PUT/PATCH/DELETE) so they reach dispatch()', async () => {
const { server, routes } = makeFakeServer();
const plugin = createDispatcherPlugin({ prefix: '/api/v1', securityHeaders: false });
await plugin.start?.(makeCtx(server));

expect(routes).toContain('POST /api/v1/mcp');
expect(routes).toContain('GET /api/v1/mcp');
expect(routes).toContain('DELETE /api/v1/mcp');
expect(routes).toContain('POST /api/v1/keys');
for (const verb of ['GET', 'POST', 'PUT', 'PATCH', 'DELETE']) {
expect(routes).toContain(`${verb} /api/v1/keys`);
}
});

// Regression (#7649): /mcp/skill was mounted for GET ONLY. The route serves
Expand Down
36 changes: 36 additions & 0 deletions packages/runtime/src/dispatcher-plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1318,6 +1318,23 @@ export function createDispatcherPlugin(config: DispatcherPluginConfig = {}): Plu
mountMcpSkill('POST');
mountMcpSkill('DELETE');

// [#23909] `/keys` is mounted for EVERY verb, though `POST` (mint)
// is the only one it serves. The published v18 text is "`/keys`:
// `401 UNAUTHENTICATED` for every verb", and the domain keeps it as
// its first statement (`handleKeysRequest`, ADR-0138 D2: the
// anonymous decision ahead of the method check) — but a floor can
// only answer a verb that REACHES it. With `POST` as the sole
// registration, any other verb matched no route and the adapter's
// unmatched-route answer replied first: `405`, naming the allowed
// method, to an anonymous caller as well. Same remedy as
// `/mcp/skill` above (#7649): register the verbs and the mismatch
// goes to the domain, which answers an anonymous caller 401 and a
// signed-in one its own 405 in the dispatcher's envelope.
//
// Unlike `/mcp/skill`, PUT and PATCH are mounted too, because the
// contract says every verb. Those two mounts are not wrapped by the
// per-route instrumentation (the observability Proxy wraps only
// get/post/delete), as the other PUT/PATCH mounts in this file are not.
server.post(`${prefix}/keys`, async (req: any, res: any) => {
try {
const result = await dispatcher.dispatch('POST', '/keys', req.body, req.query, { request: req });
Expand All @@ -1326,6 +1343,25 @@ export function createDispatcherPlugin(config: DispatcherPluginConfig = {}): Plu
errorResponse(err, res);
}
});
const mountKeys = (method: 'GET' | 'PUT' | 'PATCH' | 'DELETE') => {
const register =
method === 'GET' ? server.get
: method === 'PUT' ? server.put
: method === 'PATCH' ? server.patch
: server.delete;
register.call(server, `${prefix}/keys`, async (req: any, res: any) => {
try {
const result = await dispatcher.dispatch(method, '/keys', req.body, req.query, { request: req });
sendResult(result, res);
} catch (err: any) {
errorResponse(err, res);
}
});
};
mountKeys('GET');
mountKeys('PUT');
mountKeys('PATCH');
mountKeys('DELETE');

// ── In-app notifications (ADR-0030) ─────────────────────────
// The inbox list + receipt mark-read surface backed by the
Expand Down
136 changes: 136 additions & 0 deletions packages/runtime/src/keys-every-verb.hono.integration.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { LiteKernel, 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';

/**
* `/api/v1/keys` hands EVERY verb to the `keys` domain, so its anonymous floor
* answers first (#23909) — over a real Hono socket.
*
* The published v18 text (changeset `23225-domain-floor-anonymous-deny`):
* "`/keys`: `401 UNAUTHENTICATED` for every verb. A verb other than `POST` used
* to be answered `405` to an anonymous caller; a signed-in caller still gets
* the 405." The domain keeps that promise as its first statement
* (`handleKeysRequest`, ADR-0138 D2). The standalone dispatcher did not: it
* mounted `${prefix}/keys` for `POST` alone, so `GET` / `PUT` / `PATCH` /
* `DELETE` matched no route, and the adapter's `unmatchedResponse()` answered
* `405 METHOD_NOT_ALLOWED` — naming the allowed method — to an anonymous caller
* as well as to a signed-in one.
*
* Which DOOR answered is read off the body, not the status alone, exactly as in
* `mcp-skill-method-not-allowed.hono.integration.test.ts`: the dispatcher's
* envelope fills `error.httpStatus` and carries no `error.details`; the
* adapter's fills `error.details.{method,path,allowed}` and no `httpStatus`.
* The signed-in rows answer 405 before and after the fix, so a status-only
* assertion there could not fail against the old mount.
*
* `LiteKernel`, as in `dispatcher-plugin.anonymous-gate.integration.test.ts`:
* the subject is the mount seam. The `auth` slot answers a session only when
* the request carries `x-test-session`, so one boot serves both callers and the
* difference on the wire is that header. No `objectql` is registered: both
* answers under test (the floor, then the method check) come before the
* domain touches storage, so the signed-in `POST` (a real mint) is not driven
* here; `http-dispatcher.keys.test.ts` owns that path.
*/

const KEYS_PATH = '/api/v1/keys';
const SESSION_HEADER = 'x-test-session';
const NON_POST_VERBS = ['GET', 'PUT', 'PATCH', 'DELETE'] as const;

function fakeAuthPlugin(): Plugin {
return {
name: 'com.objectstack.test.fake-auth-keys-every-verb',
version: '1.0.0',
init: async (ctx: PluginContext) => {
ctx.registerService('auth', {
api: {
getSession: async ({ headers }: any) =>
(headers?.get?.(SESSION_HEADER) ? { user: { id: 'u_keys_verb' } } : undefined),
},
});
},
};
}

describe('every /api/v1/keys verb reaches the keys domain (integration, #23909)', () => {
let kernel: LiteKernel;
let baseUrl: string;

beforeAll(async () => {
kernel = new LiteKernel();
kernel.use(fakeAuthPlugin());
// port 0 → an OS-assigned free port, read back through getPort().
kernel.use(new HonoServerPlugin({ port: 0, cors: false }));
kernel.use(createDispatcherPlugin({ prefix: '/api/v1', securityHeaders: false }));
await kernel.bootstrap();
const httpServer = kernel.getService<IHttpServer>('http.server');
baseUrl = `http://127.0.0.1:${httpServer.getPort!()}`;
}, 30_000);

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

/**
* `Response.json()` is `unknown`, and two body shapes can arrive on this
* path, so the cast is to an open record and every check below is a
* runtime assertion on the wire.
*/
async function call(method: string, signedIn: boolean): Promise<{ res: Response; body: Record<string, any> }> {
const hasBody = method === 'POST' || method === 'PUT' || method === 'PATCH';
const res = await fetch(`${baseUrl}${KEYS_PATH}`, {
method,
headers: {
...(hasBody ? { 'content-type': 'application/json' } : {}),
...(signedIn ? { [SESSION_HEADER]: '1' } : {}),
},
...(hasBody ? { body: JSON.stringify({ name: 'probe' }) } : {}),
});
return { res, body: (await res.json()) as Record<string, any> };
}

// ── the defect: an anonymous non-POST verb is refused 401, by the domain ──
for (const method of NON_POST_VERBS) {
it(`anonymous ${method} → 401 UNAUTHENTICATED in the dispatcher's envelope`, async () => {
const { res, body } = await call(method, false);

expect(res.status).toBe(401);
expect(body.success).toBe(false);
expect(body.error.code).toBe('UNAUTHENTICATED');
expect(body.error.httpStatus).toBe(401);
expect(body.error).not.toHaveProperty('details');
});
}

// ── a signed-in non-POST verb gets the DOMAIN's 405, not the adapter's ──
for (const method of NON_POST_VERBS) {
it(`signed-in ${method} → the keys domain's 405, not the adapter's`, async () => {
const { res, body } = await call(method, true);

expect(res.status).toBe(405);
expect(body.success).toBe(false);
expect(body.error.code).toBe('METHOD_NOT_ALLOWED');
// The door: the domain fills `httpStatus`; the adapter fills `details`.
expect(body.error.httpStatus).toBe(405);
expect(body.error).not.toHaveProperty('details');
});
}

// ── control: the verb the route always served is answered as before ──
it('anonymous POST → 401 UNAUTHENTICATED (unchanged)', async () => {
const { res, body } = await call('POST', false);

expect(res.status).toBe(401);
expect(body.error.code).toBe('UNAUTHENTICATED');
expect(body.error.httpStatus).toBe(401);
});
});
15 changes: 14 additions & 1 deletion packages/runtime/src/route-ledger.ts
Original file line number Diff line number Diff line change
Expand Up @@ -277,7 +277,7 @@ export const NON_DISPATCH_MOUNT_PREFIXES = [
/**
* The ledger.
*
* CENSUS (generated): this list holds 88 rows.
* CENSUS (generated): this list holds 92 rows.
*
* ⛔ THAT NUMBER IS WRITTEN BY A TOOL — never by hand.
* `pnpm check:route-ledger-census` counts the rows below and fails when the two
Expand Down Expand Up @@ -381,6 +381,19 @@ export const ROUTE_LEDGER: readonly RouteLedgerEntry[] = [
// ── keys ──────────────────────────────────────────────────────────────────
{ route: 'POST /keys', domain: '/keys', disposition: 'sdk', client: 'keys.create',
note: 'raw secret returned once; user_id pinned server-side' },
// [#23909] Refusal-only mounts. `POST` is the one verb `/keys` serves, but the
// v18 text (changeset `23225-domain-floor-anonymous-deny`) answers an anonymous
// caller `401 UNAUTHENTICATED` for EVERY verb, and the domain's floor answers
// only a verb that is mounted. Exact rows, deliberately not a `* /keys/**`
// family: no `/keys` sub-path is mounted on the standalone server.
{ route: 'GET /keys', domain: '/keys', disposition: 'server-only',
note: 'refusal-only, mounted for the v18 every-verb anonymous floor (changeset 23225): anonymous 401 UNAUTHENTICATED, signed-in the keys domain\'s 405' },
{ route: 'PUT /keys', domain: '/keys', disposition: 'server-only',
note: 'refusal-only, mounted for the v18 every-verb anonymous floor (changeset 23225): anonymous 401 UNAUTHENTICATED, signed-in the keys domain\'s 405' },
{ route: 'PATCH /keys', domain: '/keys', disposition: 'server-only',
note: 'refusal-only, mounted for the v18 every-verb anonymous floor (changeset 23225): anonymous 401 UNAUTHENTICATED, signed-in the keys domain\'s 405' },
{ route: 'DELETE /keys', domain: '/keys', disposition: 'server-only',
note: 'refusal-only, mounted for the v18 every-verb anonymous floor (changeset 23225): anonymous 401 UNAUTHENTICATED, signed-in the keys domain\'s 405' },

// ── storage ───────────────────────────────────────────────────────────────
// RETIRED (#4087) — deliberately no rows, and the `/storage` domain is gone
Expand Down
Loading