diff --git a/.changeset/24245-head-dispatched-as-get.md b/.changeset/24245-head-dispatched-as-get.md new file mode 100644 index 00000000000..e0eaea0e4ef --- /dev/null +++ b/.changeset/24245-head-dispatched-as-get.md @@ -0,0 +1,13 @@ +--- +'@objectstack/hono': patch +'@objectstack/runtime': patch +--- + +fix(hono): on a `createHonoApp` host a `HEAD` is answered as its `GET` (no body), as the standalone server answers it; `HEAD /api/v1/mcp/skill` no longer answers `405` with `Allow: GET` + +Clause-②: no + +`createHonoApp`'s `${prefix}/*` catch-all handed the dispatcher the request's raw verb. Hono answers a `HEAD` from the `GET` route, but the verb still read `HEAD`, and the dispatcher's domains decide by comparing it with `GET`, so a `HEAD` was not answered as its `GET` on this host. Measured: `HEAD /api/v1/mcp/skill` answered `405 METHOD_NOT_ALLOWED` with `Allow: GET`; `/health`, `/ready` and the scoped root `/environments/:id` answered `404 ROUTE_NOT_FOUND`; signed in, `/analytics/meta` answered `404` and `/ai/agents` `501`; anonymous, the public `/share-links/:token/resolve` answered `401` where `GET` answers `501`. With an auth service, `HEAD /auth/config` answered `404` (it was forwarded to better-auth instead of answered as the `GET`). + +- **`@objectstack/hono`.** The catch-all now dispatches a `HEAD` as `GET`, and the `/auth/config` branch answers a `HEAD` as its `GET`. Every dispatcher route answers `HEAD` with its `GET` answer's status and headers, and Hono drops the body. A path that serves no `GET` keeps its `GET` answer for `HEAD` (its `405` and `Allow`, or `401` to an anonymous caller). The standalone server (`HonoServerPlugin` with the dispatcher plugin) already answered `HEAD` this way. Only the verb changes: a domain that forwards the request (the `/mcp` transport, the `/approvals/act` page) is still handed the client's `HEAD` request. +- **`@objectstack/runtime`.** The `/approvals/act` domain decides whether a request is a `HEAD` from the forwarded request's own method, not from the dispatcher's verb. So a `HEAD` still reaches the approvals member as a `GET` and is answered with no body. diff --git a/packages/adapters/hono/src/hono-head-dispatched-as-get.test.ts b/packages/adapters/hono/src/hono-head-dispatched-as-get.test.ts new file mode 100644 index 00000000000..0935a9b8c3a --- /dev/null +++ b/packages/adapters/hono/src/hono-head-dispatched-as-get.test.ts @@ -0,0 +1,174 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#24245] A `HEAD` reaches `dispatch()` as the `GET` it is answered from. + * + * Hono answers a `HEAD` from the `GET` route — it routes the request as `GET` + * and keeps that answer's status and headers with no body — but `c.req.method` + * still reads `HEAD`, and the catch-all handed that straight to `dispatch()`. + * Every dispatcher domain decides its verb by comparing it with `'GET'`, so a + * `HEAD` was not answered as its `GET` on this host (`HEAD /api/v1/mcp/skill`: + * `405` with `Allow: GET` while `GET` served the skill). The standalone server + * hands `dispatch()` a literal `'GET'` for the same request. + * + * What is pinned here, with the dispatcher a fixture (this package's config + * aliases `@objectstack/runtime` to a stub): + * 1. the verb `dispatch()` receives for `HEAD` is `GET`, and the request + * beside it is still the client's `HEAD` — only the verb moves; + * 2. the `HEAD` answer is the `GET` answer's status and headers with no body, + * a stream result and a `405` with `Allow` alike; + * 3. every other verb reaches `dispatch()` as it was sent; + * 4. the auth arm's `/auth/config` branch answers a `HEAD` as its `GET`. + * + * The same rows through the REAL dispatcher and both doors are + * `packages/qa/http-conformance/src/hono-head-as-get.conformance.test.ts`. + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +interface Seen { method: string; path: string; requestMethod: string } +const seen: Seen[] = []; + +/** What the fixture dispatcher answers, per sub-path. */ +function answer(path: string): any { + if (path === '/mcp/skill') { + // The `/mcp/skill` domain's result shape (`runtime/src/domains/mcp.ts`). + return { + handled: true, + result: { + type: 'stream', + status: 200, + contentType: 'text/markdown; charset=utf-8', + headers: { + 'content-type': 'text/markdown; charset=utf-8', + 'content-disposition': 'inline; filename="SKILL.md"', + 'cache-control': 'no-store', + }, + events: (async function* () { yield '# skill'; })(), + }, + }; + } + if (path === '/keys') { + return { + handled: true, + response: { + status: 405, + headers: { Allow: 'POST' }, + body: { success: false, error: { code: 'METHOD_NOT_ALLOWED', message: 'Method not allowed — use POST', httpStatus: 405 } }, + }, + }; + } + return { handled: true, response: { status: 200, body: { success: true, data: { path } } } }; +} + +const mockDispatcher = { + getDiscoveryInfo: vi.fn().mockReturnValue({ version: '1.0', routes: {} }), + handleAuth: vi.fn(async () => ({ handled: true, response: { status: 200, body: { ok: true } } })), + dispatch: vi.fn(async (method: string, path: string, _body: unknown, _query: unknown, ctx: { request: Request }) => { + seen.push({ method, path, requestMethod: ctx.request.method }); + return answer(path); + }), +}; + +vi.mock('@objectstack/runtime', () => ({ + HttpDispatcher: function HttpDispatcher() { return mockDispatcher; }, +})); + +import { createHonoApp } from './index'; + +const PREFIX = '/api/v1'; +const kernel = { name: 'test-kernel' } as any; + +/** Headers a transport adds on its own, independent of the route. */ +const TRANSPORT_HEADERS = new Set(['date', 'connection', 'keep-alive', 'content-length', 'transfer-encoding']); +async function snapshot(res: Response) { + return { + status: res.status, + headers: [...res.headers.entries()].filter(([k]) => !TRANSPORT_HEADERS.has(k)), + body: await res.text(), + }; +} + +beforeEach(() => { + seen.length = 0; + mockDispatcher.dispatch.mockClear(); + mockDispatcher.handleAuth.mockClear(); +}); + +describe('[#24245] the catch-all dispatches a HEAD as GET', () => { + it('HEAD /mcp/skill: dispatch() receives GET beside the client\'s HEAD request, and the answer is the GET\'s with no body', async () => { + const app = createHonoApp({ kernel, prefix: PREFIX, cors: false }); + + const head = await snapshot(await app.request(`http://hosted.test${PREFIX}/mcp/skill`, { method: 'HEAD' })); + const get = await snapshot(await app.request(`http://hosted.test${PREFIX}/mcp/skill`)); + + expect(seen).toEqual([ + { method: 'GET', path: '/mcp/skill', requestMethod: 'HEAD' }, + { method: 'GET', path: '/mcp/skill', requestMethod: 'GET' }, + ]); + expect(get.status).toBe(200); + expect(get.body).not.toBe(''); + expect(head.status).toBe(200); + expect(head.headers).toEqual(get.headers); + expect(head.headers.find(([k]) => k === 'content-type')?.[1]).toContain('text/markdown'); + expect(head.body).toBe(''); + }); + + it('HEAD on a path whose GET is refused: the GET\'s 405 and its Allow, no body', async () => { + const app = createHonoApp({ kernel, prefix: PREFIX, cors: false }); + + const getRes = await app.request(`http://hosted.test${PREFIX}/keys`); + const getBody = (await getRes.clone().json()) as { error: { code: string; httpStatus: number } }; + const get = await snapshot(getRes); + const head = await snapshot(await app.request(`http://hosted.test${PREFIX}/keys`, { method: 'HEAD' })); + + expect(get.status).toBe(405); + expect(getBody.error.code).toBe('METHOD_NOT_ALLOWED'); + expect(getBody.error.httpStatus).toBe(405); + expect(head.status).toBe(405); + expect(head.headers).toContainEqual(['allow', 'POST']); + expect(head.headers).toEqual(get.headers); + expect(head.body).toBe(''); + expect(seen.map((s) => s.method)).toEqual(['GET', 'GET']); + }); + + it('every other verb reaches dispatch() as it was sent', async () => { + const app = createHonoApp({ kernel, prefix: PREFIX, cors: false }); + + for (const method of ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS']) { + await app.request(`http://hosted.test${PREFIX}/data/task`, { method }); + } + expect(seen.map((s) => [s.method, s.requestMethod])).toEqual([ + ['GET', 'GET'], + ['POST', 'POST'], + ['PUT', 'PUT'], + ['PATCH', 'PATCH'], + ['DELETE', 'DELETE'], + ['OPTIONS', 'OPTIONS'], + ]); + }); +}); + +describe('[#24245] the auth arm answers HEAD /auth/config as its GET', () => { + it('the public config\'s status and headers with no body, and better-auth is not asked', async () => { + const handleRequest = vi.fn(async () => new Response('{"message":"Not found"}', { status: 404 })); + const authKernel = { + getServiceAsync: vi.fn().mockResolvedValue({ + getPublicConfig: () => ({ features: { sso: false } }), + handleRequest, + ownsRoute: async () => false, + }), + } as any; + const app = createHonoApp({ kernel: authKernel, prefix: PREFIX, cors: false }); + + const get = await snapshot(await app.request(`http://hosted.test${PREFIX}/auth/config`)); + const head = await snapshot(await app.request(`http://hosted.test${PREFIX}/auth/config`, { method: 'HEAD' })); + + expect(get.status).toBe(200); + expect(JSON.parse(get.body)).toEqual({ success: true, data: { features: { sso: false } } }); + expect(head.status).toBe(200); + expect(head.headers).toEqual(get.headers); + expect(head.body).toBe(''); + expect(handleRequest).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/adapters/hono/src/index.ts b/packages/adapters/hono/src/index.ts index a4b335ae969..4f962dbb6cf 100644 --- a/packages/adapters/hono/src/index.ts +++ b/packages/adapters/hono/src/index.ts @@ -537,6 +537,40 @@ function readDispatchQuery(c: Context): Record { ) as Record; } +/** + * [#24245] The verb the catch-all hands `dispatch()`, and the one the auth + * arm's `/auth/config` branch compares: the request's own, except that a + * `HEAD` goes as `GET`. + * + * Hono answers a `HEAD` from the `GET` route: it routes the request as `GET` + * and returns that answer's status and headers with no body. `c.req.method` + * still reads `HEAD`, though, and the dispatcher decides a verb by comparing it + * with `'GET'` (a domain's `methods`, its branches, the discovery root). So the + * catch-all handed `dispatch()` a verb nothing serves, and a `HEAD` was not + * answered as its `GET`. Measured through `app.request`, `GET` vs `HEAD`: + * `/mcp/skill` `200` vs `405` with `Allow: GET`; `/health`, `/ready` and the + * scoped root `/environments/:id` `200` vs `404 ROUTE_NOT_FOUND`; signed in, + * `/analytics/meta` `200` vs `404`, `/ai/agents` `200` vs `501`; anonymous, + * `/share-links/:token/resolve` (public) `501` vs `401`; with an auth service, + * `/auth/config` `200` vs `404`. + * + * The standalone server hands `dispatch()` `GET` for the same request: both + * `IHttpServer` transports route a `HEAD` to the `GET` handler (the + * conformance suite pins "answers HEAD from GET routes without a body"), and + * the dispatcher plugin's `GET` mounts pass a literal `'GET'`. This door now + * does the same, so every domain answers `HEAD` exactly as it answers `GET`: a + * path that serves no `GET` answers its `405` and `Allow`, an anonymous caller + * gets the anonymous `GET` answer. + * + * Only the verb moves. `{ request: c.req.raw }` stays the client's `HEAD` + * request, as the standalone mounts hand theirs, so a domain that forwards + * the request (the `/mcp` transport, the `/approvals/act` member) sees the + * method that was sent. The body is dropped by Hono's `HEAD` handling. + */ +function dispatchedMethod(method: string): string { + return method === 'HEAD' ? 'GET' : method; +} + /** * Creates a full-featured Hono app with all ObjectStack route dispatchers. * @@ -916,7 +950,10 @@ export function createHonoApp(options: ObjectStackHonoOptions): Hono { } // Handle /auth/config endpoint specifically (not handled by better-auth) - if (path === 'config' && method === 'GET' && authService) { + // [#24245] A `HEAD` is answered as this `GET` too (`dispatchedMethod`), as + // `plugin-auth`'s `GET` mount answers it on the standalone server; it was + // forwarded to better-auth instead and answered `404`. + if (path === 'config' && dispatchedMethod(method) === 'GET' && authService) { try { const config = (authService as any).getPublicConfig?.(); if (config) { @@ -1060,7 +1097,8 @@ export function createHonoApp(options: ObjectStackHonoOptions): Hono { // [#23214] A repeated name stays an array (`readDispatchQuery`). const queryParams = readDispatchQuery(c); - const result = await dispatcher.dispatch(method, subPath, read?.value, queryParams, { request: c.req.raw }, prefix); + // [#24245] A `HEAD` is dispatched as the `GET` it is answered from (`dispatchedMethod`). + const result = await dispatcher.dispatch(dispatchedMethod(method), subPath, read?.value, queryParams, { request: c.req.raw }, prefix); // [#23914] A body this adapter could not parse, READ by the domain: the // refusal is the answer, whatever the domain answered (see `readDispatchBody`). const refusal = read?.refusalRead(); diff --git a/packages/qa/http-conformance/src/hono-head-as-get.conformance.test.ts b/packages/qa/http-conformance/src/hono-head-as-get.conformance.test.ts new file mode 100644 index 00000000000..3b2bde6b9c5 --- /dev/null +++ b/packages/qa/http-conformance/src/hono-head-as-get.conformance.test.ts @@ -0,0 +1,213 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#24245] On a `createHonoApp` host, a `HEAD` is answered as its `GET`, with + * no body — through the host's own pipeline, against the standalone server. + * + * ## The defect + * + * `HEAD /api/v1/mcp/skill` answered `405 METHOD_NOT_ALLOWED` with `Allow: GET` + * on a `createHonoApp` host while `GET` served the skill (RFC 9110 §9.3.2: a + * server that serves `GET` on a resource serves `HEAD` on it). Hono answers a + * `HEAD` from the `GET` route, but the catch-all handed `dispatch()` the raw + * verb `HEAD`, and every dispatcher domain decides its verb by comparing it + * with `'GET'`. The skill domain refused it; `/health`, `/ready` and the scoped + * root answered `404 ROUTE_NOT_FOUND`; signed in, `/analytics/meta` answered + * `404` and `/ai/agents` `501`; anonymous, the public + * `/share-links/:token/resolve` answered `401` where `GET` answers `501`. + * + * The standalone server (`HonoServerPlugin` + the dispatcher plugin) answered + * every one of them as `GET`: its transport routes a `HEAD` to the `GET` + * mount, which hands `dispatch()` a literal `'GET'`. The catch-all now hands + * `dispatch()` `GET` for a `HEAD` too, and the request beside it stays the + * client's `HEAD`. + * + * ## What is pinned + * + * 1. Each row, on the hosted door (`app.request` into the Hono app + * `createHonoApp` returns — the host's own pipeline): `HEAD` has the `GET` + * answer's status and headers, and no body. A refused `GET` is read for its + * `code` and `status` (the `HEAD` carries no body to read). + * 2. A path that serves no `GET` (`/keys`, `/actions/...`) keeps the `GET` + * answer for `HEAD`: its `405` with `Allow: POST`, or, anonymous, its `401` + * — never a `GET` served there. + * 3. Where both doors serve the row, the hosted `HEAD` has the standalone + * `HEAD`'s status (and `Allow`). + * 4. Only the verb moves: the `/mcp` transport is handed the client's `HEAD` + * request on both doors. + * + * `LiteKernel`, fake services only (no storage): the `auth` slot answers a + * session only for a request carrying `x-test-session`. + */ + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { LiteKernel } from '@objectstack/core'; +import type { Plugin, PluginContext } from '@objectstack/core'; +import { HonoServerPlugin } from '@objectstack/plugin-hono-server'; +import { createDispatcherPlugin } from '@objectstack/runtime'; +import { createHonoApp } from '@objectstack/hono'; +import type { IHttpServer } from '@objectstack/spec/contracts'; + +const PREFIX = '/api/v1'; +const SESSION_HEADER = 'x-test-session'; + +/** The methods the fake `/mcp` transport was handed, in order. */ +const transportMethods: string[] = []; + +function fakeServicesPlugin(): Plugin { + return { + name: 'com.objectstack.test.fake-services-head-as-get', + version: '1.0.0', + init: async (ctx: PluginContext) => { + ctx.registerService('auth', { + api: { + getSession: async ({ headers }: { headers?: Headers }) => + (headers?.get?.(SESSION_HEADER) ? { user: { id: 'u_head_as_get' } } : undefined), + }, + }); + ctx.registerService('analytics', { query: async () => ({ rows: [] }), getMeta: async () => [] }); + ctx.registerService('mcp', { + handleHttpRequest: async (request: Request) => { + transportMethods.push(request.method); + return new Response('{}', { status: 200, headers: { 'content-type': 'application/json' } }); + }, + renderSkill: () => '# skill', + }); + }, + }; +} + +/** Headers a transport adds or drops on its own, independent of the route. */ +const TRANSPORT_HEADERS = new Set(['date', 'connection', 'keep-alive', 'content-length', 'transfer-encoding']); + +interface Snapshot { status: number; headers: Array<[string, string]>; body: string } +async function snapshot(res: Response): Promise { + return { + status: res.status, + headers: [...res.headers.entries()].filter(([k]) => !TRANSPORT_HEADERS.has(k)), + body: await res.text(), + }; +} + +/** What a `GET` answers on a row: a status, and for a refusal its envelope `code`. */ +interface Row { + path: string; + signedIn: boolean; + get: { status: number; code?: string; allow?: string }; + /** Both doors serve the row, so the hosted `HEAD` is compared with the standalone one. */ + standalone: boolean; +} + +const ROWS: Row[] = [ + // The card's row: public, served. + { path: '/mcp/skill', signedIn: false, get: { status: 200 }, standalone: true }, + { path: '/health', signedIn: false, get: { status: 200 }, standalone: true }, + { path: '/ready', signedIn: false, get: { status: 200 }, standalone: true }, + { path: '/analytics/meta', signedIn: true, get: { status: 200 }, standalone: true }, + { path: '/ai/agents', signedIn: true, get: { status: 200 }, standalone: true }, + // The scoped API root is the discovery route; the standalone server mounts no such path. + { path: '/environments/env_head', signedIn: true, get: { status: 200 }, standalone: false }, + // Public on GET: an anonymous HEAD must get the anonymous GET answer, not the floor's 401. + { path: '/share-links/tok_head/resolve', signedIn: false, get: { status: 501, code: 'NOT_IMPLEMENTED' }, standalone: false }, + // Paths that serve no GET: the GET answer, 405 + Allow, or the anonymous 401. + { path: '/keys', signedIn: true, get: { status: 405, code: 'METHOD_NOT_ALLOWED', allow: 'POST' }, standalone: true }, + { path: '/keys', signedIn: false, get: { status: 401, code: 'UNAUTHENTICATED' }, standalone: true }, + { path: '/actions/task/close', signedIn: true, get: { status: 405, code: 'METHOD_NOT_ALLOWED', allow: 'POST' }, standalone: true }, + { path: '/actions/task/close', signedIn: false, get: { status: 401, code: 'UNAUTHENTICATED' }, standalone: true }, + // Served to a signed-in caller only: the anonymous floor answers HEAD as it answers GET. + { path: '/mcp', signedIn: false, get: { status: 401, code: 'UNAUTHENTICATED' }, standalone: true }, +]; + +describe('[#24245] a HEAD is answered as its GET on a createHonoApp host (conformance)', () => { + let hostedKernel: LiteKernel; + let standaloneKernel: LiteKernel; + let hosted: (path: string, init: RequestInit) => Promise; + let standalone: (path: string, init: RequestInit) => Promise; + + beforeAll(async () => { + hostedKernel = new LiteKernel(); + hostedKernel.use(fakeServicesPlugin()); + await hostedKernel.bootstrap(); + const app = createHonoApp({ kernel: hostedKernel as any, prefix: PREFIX, cors: false }); + hosted = async (path, init) => app.request(`http://hosted.test${PREFIX}${path}`, init); + + standaloneKernel = new LiteKernel(); + standaloneKernel.use(fakeServicesPlugin()); + standaloneKernel.use(new HonoServerPlugin({ port: 0, cors: false })); + standaloneKernel.use(createDispatcherPlugin({ prefix: PREFIX, securityHeaders: false })); + await standaloneKernel.bootstrap(); + const base = `http://127.0.0.1:${standaloneKernel.getService('http.server').getPort!()}`; + standalone = (path, init) => fetch(`${base}${PREFIX}${path}`, init); + }, 30_000); + + afterAll(async () => { + for (const kernel of [hostedKernel, standaloneKernel]) { + if (!kernel) continue; + await Promise.race([kernel.shutdown(), new Promise((resolve) => setTimeout(resolve, 10_000))]); + } + }, 30_000); + + const init = (method: 'GET' | 'HEAD', signedIn: boolean): RequestInit => + ({ method, headers: signedIn ? { [SESSION_HEADER]: '1' } : {} }); + + for (const row of ROWS) { + const who = row.signedIn ? 'signed-in' : 'anonymous'; + + it(`${who} HEAD ${row.path}: the GET's ${row.get.status}${row.get.code ? ` ${row.get.code}` : ''} status and headers, no body`, async () => { + const getRes = await hosted(row.path, init('GET', row.signedIn)); + const get = await snapshot(getRes); + const head = await snapshot(await hosted(row.path, init('HEAD', row.signedIn))); + + expect(get.status).toBe(row.get.status); + if (row.get.code) { + const body = JSON.parse(get.body) as { success?: boolean; error?: { code?: string; httpStatus?: number } }; + expect(body.success).toBe(false); + expect(body.error?.code).toBe(row.get.code); + expect(body.error?.httpStatus).toBe(row.get.status); + } else { + expect(get.body).not.toBe(''); + } + + expect(head.status).toBe(get.status); + expect(head.headers).toEqual(get.headers); + expect(head.body).toBe(''); + const allow = head.headers.find(([k]) => k === 'allow')?.[1]; + expect(allow).toBe(row.get.allow); + }); + + if (row.standalone) { + it(`${who} HEAD ${row.path}: the hosted door answers the standalone server's HEAD`, async () => { + const viaHost = await hosted(row.path, init('HEAD', row.signedIn)); + const viaStandalone = await standalone(row.path, init('HEAD', row.signedIn)); + + expect(viaHost.status).toBe(viaStandalone.status); + expect(viaHost.headers.get('allow')).toBe(viaStandalone.headers.get('allow')); + expect(await viaHost.text()).toBe(''); + expect(await viaStandalone.text()).toBe(''); + }); + } + } + + it('HEAD /mcp/skill carries the skill\'s own headers on both doors', async () => { + const viaHost = await hosted('/mcp/skill', init('HEAD', false)); + const viaStandalone = await standalone('/mcp/skill', init('HEAD', false)); + + for (const res of [viaHost, viaStandalone]) { + expect(res.status).toBe(200); + expect(res.headers.get('content-type')).toContain('text/markdown'); + expect(res.headers.get('content-disposition')).toBe('inline; filename="SKILL.md"'); + expect(res.headers.get('cache-control')).toContain('no-store'); + expect(await res.text()).toBe(''); + } + }); + + it('only the verb moves: the /mcp transport is handed the client\'s HEAD on both doors', async () => { + transportMethods.length = 0; + const viaHost = await hosted('/mcp', init('HEAD', true)); + const viaStandalone = await standalone('/mcp', init('HEAD', true)); + + expect(viaHost.status).toBe(200); + expect(viaStandalone.status).toBe(200); + expect(transportMethods).toEqual(['HEAD', 'HEAD']); + }); +}); diff --git a/packages/runtime/src/domains/approvals-act.test.ts b/packages/runtime/src/domains/approvals-act.test.ts index 327ae16ac43..7a3a6b22bb3 100644 --- a/packages/runtime/src/domains/approvals-act.test.ts +++ b/packages/runtime/src/domains/approvals-act.test.ts @@ -156,6 +156,25 @@ describe('[#22576] /approvals/act forwards to the approvals member on a kernel w expect(res.body).toBeNull(); }); + // [#24245] The `@objectstack/hono` catch-all dispatches a `HEAD` as `GET` and + // forwards the client's `HEAD` request: the domain reads `HEAD` off the + // request the member would read, so the member is still handed a `GET`. + it('a HEAD request dispatched as GET is still forwarded as a GET and answered with no body', async () => { + const member = stubMember(); + const { dispatcher } = makeDispatcher({ approvals: { handleActionPage: member.handleActionPage } }); + const request = new Request(`${ORIGIN}/api/v1${ACT}?token=tok-head-as-get`, { method: 'HEAD' }); + + const result = await dispatcher.dispatch('GET', ACT, undefined, {}, ctx(request)); + + expect(member.handleActionPage).toHaveBeenCalledTimes(1); + expect(member.seen[0]!.method).toBe('GET'); + expect(member.tokens).toEqual(['tok-head-as-get']); + const res = result.result as Response; + expect(res.status).toBe(200); + expect(res.headers.get('x-page')).toBe('action'); + expect(res.body).toBeNull(); + }); + it('claims exactly /approvals/act for GET, HEAD and POST — no other method, no sub-path, no sibling', async () => { const member = stubMember(); const { dispatcher } = makeDispatcher({ approvals: { handleActionPage: member.handleActionPage } }); diff --git a/packages/runtime/src/domains/approvals.ts b/packages/runtime/src/domains/approvals.ts index 79509f04b80..67a8a4b44bf 100644 --- a/packages/runtime/src/domains/approvals.ts +++ b/packages/runtime/src/domains/approvals.ts @@ -56,8 +56,11 @@ * answered the way the self-hosted mount answers it: Hono serves a `HEAD` on a * `GET` route by running the `GET` handler and returning its status and * headers with no body, so this domain does the same against the member - * (forwarded as a `GET`, which never decides — it only renders). Every other - * method is not claimed and falls through as it does for any domain that + * (forwarded as a `GET`, which never decides — it only renders). Whether the + * request is a `HEAD` is read off the request the member would read, not off + * the dispatcher's verb: the `@objectstack/hono` catch-all dispatches a `HEAD` + * as `GET` (#24245) and forwards the client's `HEAD` request unchanged. Every + * other method is not claimed and falls through as it does for any domain that * declares its methods. */ @@ -94,10 +97,10 @@ export function createApprovalsActDomain(deps: DomainHandlerDeps): DomainRoute { * return its `Response` untouched. * * `method` is the dispatcher's; the member reads the method off the request - * itself. They are the same value on every transport that reaches this domain - * (the `@objectstack/hono` catch-all passes `c.req.method`, which IS the raw - * request's), and the one place they are made to differ is the `HEAD` branch - * below, on purpose. + * itself. They differ for a `HEAD`: the `@objectstack/hono` catch-all + * dispatches it as `GET` (#24245) and forwards the client's `HEAD` request. So + * the `HEAD` branch below asks the request, and it is the one place the member + * is handed a method the client did not send, on purpose. */ export async function handleApprovalsActRequest( deps: DomainHandlerDeps, @@ -144,7 +147,7 @@ export async function handleApprovalsActRequest( } try { - if (method === 'HEAD') { + if (request?.method === 'HEAD') { // Hono answers a HEAD on the self-hosted mount's GET route with // `new Response(null, getResponse)` — the GET's status and headers, // no body. Built here the same way, so the answer does not depend