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
13 changes: 13 additions & 0 deletions .changeset/24245-head-dispatched-as-get.md
Original file line number Diff line number Diff line change
@@ -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.
174 changes: 174 additions & 0 deletions packages/adapters/hono/src/hono-head-dispatched-as-get.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
42 changes: 40 additions & 2 deletions packages/adapters/hono/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -537,6 +537,40 @@ function readDispatchQuery(c: Context): Record<string, string | string[]> {
) as Record<string, string | string[]>;
}

/**
* [#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.
*
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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();
Expand Down
Loading
Loading