From 61d5fcae9051b70ddc895f6da66854c34fb2f21b Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 8 Jul 2026 10:41:52 +0000 Subject: [PATCH 1/2] fix(hono-server): return 405 + Allow for method mismatches instead of opaque 404 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hono routes a method mismatch to the same `notFound` sink as a genuinely missing path, so a POST to a PUT-only route (e.g. the metadata save endpoint `PUT /api/v1/meta/:type/:name`) returned a bare `{ "error": "Not found" }` 404 with no hint that the path exists under another verb (#2684). Track every `(method, pattern)` pair registered through `HonoHttpServer` and re-match the request path in the `notFound` handler: when the path lines up with routes under other methods, answer `405 Method Not Allowed` with an accurate `Allow` header and a descriptive body so callers can self-correct. A path that matches no registered route stays a 404. This is framework-wide — every registered endpoint benefits (metadata save, translations, data PATCH, …), not just the metadata surface. Static/SPA catch-alls registered straight on the raw Hono app are intentionally not tracked, so they never produce a spurious 405. Adds a `route-pattern` helper (Hono-style `:param` / `*` matching) with unit tests and an end-to-end test driving the real server through `app.fetch`. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01N9qJ2N2d1c9oCR5GPfBDaM --- .../plugins/plugin-hono-server/src/adapter.ts | 30 +++++++ .../plugin-hono-server/src/hono-plugin.ts | 29 +++++- .../plugins/plugin-hono-server/src/index.ts | 1 + .../src/notfound-405.test.ts | 90 +++++++++++++++++++ .../src/route-pattern.test.ts | 41 +++++++++ .../plugin-hono-server/src/route-pattern.ts | 45 ++++++++++ 6 files changed, 234 insertions(+), 2 deletions(-) create mode 100644 packages/plugins/plugin-hono-server/src/notfound-405.test.ts create mode 100644 packages/plugins/plugin-hono-server/src/route-pattern.test.ts create mode 100644 packages/plugins/plugin-hono-server/src/route-pattern.ts diff --git a/packages/plugins/plugin-hono-server/src/adapter.ts b/packages/plugins/plugin-hono-server/src/adapter.ts index 6e286105ca..245eaf206e 100644 --- a/packages/plugins/plugin-hono-server/src/adapter.ts +++ b/packages/plugins/plugin-hono-server/src/adapter.ts @@ -12,6 +12,7 @@ import { currentPerfTiming } from '@objectstack/observability'; import { Hono } from 'hono'; import { serve } from '@hono/node-server'; import { serveStatic } from '@hono/node-server/serve-static'; +import { matchesRoutePattern } from './route-pattern'; export interface HonoCorsOptions { enabled?: boolean; @@ -44,6 +45,14 @@ export class HonoHttpServer implements IHttpServer { private app: Hono; private server: any; private listeningPort: number | undefined; + /** + * Every `(method, pattern)` pair registered through this server, kept so + * the `notFound` handler can answer "the path exists but the method is + * wrong" with a `405` + `Allow` instead of an opaque `404`. Populated by + * the verb methods below; static/SPA catch-alls registered straight on the + * raw Hono app are intentionally NOT tracked, so they never produce a 405. + */ + private registeredRoutes: Array<{ method: string; pattern: string }> = []; constructor( private port: number = 3000, @@ -210,21 +219,42 @@ export class HonoHttpServer implements IHttpServer { } get(path: string, handler: RouteHandler) { + this.registeredRoutes.push({ method: 'GET', pattern: path }); this.app.get(path, this.wrap(handler)); } post(path: string, handler: RouteHandler) { + this.registeredRoutes.push({ method: 'POST', pattern: path }); this.app.post(path, this.wrap(handler)); } put(path: string, handler: RouteHandler) { + this.registeredRoutes.push({ method: 'PUT', pattern: path }); this.app.put(path, this.wrap(handler)); } delete(path: string, handler: RouteHandler) { + this.registeredRoutes.push({ method: 'DELETE', pattern: path }); this.app.delete(path, this.wrap(handler)); } patch(path: string, handler: RouteHandler) { + this.registeredRoutes.push({ method: 'PATCH', pattern: path }); this.app.patch(path, this.wrap(handler)); } + /** + * The HTTP methods registered for a concrete request `path`, ignoring the + * request's own method. Empty when no registered route matches the path at + * all (a genuine 404). Used by the `notFound` handler to build a `405` + * response with an accurate `Allow` header. `HEAD` is implied by `GET` + * (Hono answers HEAD from GET routes automatically). + */ + allowedMethodsForPath(path: string): string[] { + const methods = new Set(); + for (const route of this.registeredRoutes) { + if (matchesRoutePattern(route.pattern, path)) methods.add(route.method); + } + if (methods.has('GET')) methods.add('HEAD'); + return Array.from(methods).sort(); + } + use(pathOrHandler: string | Middleware, handler?: Middleware) { if (typeof pathOrHandler === 'string' && handler) { this.app.use(pathOrHandler, async (c, next) => { diff --git a/packages/plugins/plugin-hono-server/src/hono-plugin.ts b/packages/plugins/plugin-hono-server/src/hono-plugin.ts index dcbcf97cdb..d9d184d191 100644 --- a/packages/plugins/plugin-hono-server/src/hono-plugin.ts +++ b/packages/plugins/plugin-hono-server/src/hono-plugin.ts @@ -344,10 +344,35 @@ export class HonoServerPlugin implements Plugin { } // Catch-all: ensure unmatched requests always get a proper Response - // (prevents Hono "Context is not finalized" error) + // (prevents Hono "Context is not finalized" error). + // + // Hono routes a method mismatch to the SAME `notFound` sink as a + // genuinely missing path, so a `POST` to a `PUT`-only route (e.g. the + // metadata save endpoint, see #2684) used to return an opaque + // `{ error: 'Not found' }` 404 with no hint that the path exists under + // another verb. Here we re-match the request path against the set of + // registered route patterns: if it lines up with routes under other + // methods, answer `405 Method Not Allowed` with an accurate `Allow` + // header so callers can self-correct. A path that matches nothing + // stays a 404. This is framework-wide — every registered endpoint + // benefits, not just metadata. const rawAppForNotFound = this.server.getRawApp(); if (typeof rawAppForNotFound.notFound === 'function') { - rawAppForNotFound.notFound((c: any) => c.json({ error: 'Not found' }, 404)); + rawAppForNotFound.notFound((c: any) => { + const allowed = this.server.allowedMethodsForPath(c.req.path); + if (allowed.length > 0 && !allowed.includes(c.req.method)) { + c.header('Allow', allowed.join(', ')); + return c.json({ + error: 'Method Not Allowed', + code: 'METHOD_NOT_ALLOWED', + message: `${c.req.method} is not supported for ${c.req.path}. Allowed: ${allowed.join(', ')}.`, + method: c.req.method, + path: c.req.path, + allowed, + }, 405); + } + return c.json({ error: 'Not found' }, 404); + }); } // Register standard endpoints during kernel:ready so they're diff --git a/packages/plugins/plugin-hono-server/src/index.ts b/packages/plugins/plugin-hono-server/src/index.ts index 0c9d072862..776404ee78 100644 --- a/packages/plugins/plugin-hono-server/src/index.ts +++ b/packages/plugins/plugin-hono-server/src/index.ts @@ -3,4 +3,5 @@ export * from './hono-plugin'; export * from './adapter'; export * from './pattern-matcher'; +export * from './route-pattern'; diff --git a/packages/plugins/plugin-hono-server/src/notfound-405.test.ts b/packages/plugins/plugin-hono-server/src/notfound-405.test.ts new file mode 100644 index 0000000000..86ab97d3eb --- /dev/null +++ b/packages/plugins/plugin-hono-server/src/notfound-405.test.ts @@ -0,0 +1,90 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { HonoServerPlugin } from './hono-plugin'; +import { HonoHttpServer } from './adapter'; +import type { PluginContext } from '@objectstack/core'; + +// NB: this file deliberately does NOT mock './adapter' — it exercises the real +// HonoHttpServer + the plugin's notFound wiring end-to-end through app.fetch(). + +function makeCtx() { + const logger = { info: vi.fn(), debug: vi.fn(), warn: vi.fn(), error: vi.fn() }; + return { + logger, + getKernel: vi.fn().mockReturnValue({ plugins: new Map() }), + registerService: vi.fn(), + hook: vi.fn(), + getService: vi.fn(), + } as unknown as PluginContext; +} + +describe('HonoHttpServer.allowedMethodsForPath', () => { + let server: HonoHttpServer; + const noop = () => {}; + + beforeEach(() => { + server = new HonoHttpServer(0); + server.get('/api/v1/meta/:type/:name', noop); + server.put('/api/v1/meta/:type/:name', noop); + server.delete('/api/v1/meta/:type/:name', noop); + server.post('/api/v1/meta/:type/:name/publish', noop); + }); + + it('collects every method registered for a concrete path (HEAD implied by GET)', () => { + expect(server.allowedMethodsForPath('/api/v1/meta/view/my_view')) + .toEqual(['DELETE', 'GET', 'HEAD', 'PUT']); + }); + + it('does not leak sub-route methods onto the parent path', () => { + // /publish is POST-only and one segment deeper — must not appear here + expect(server.allowedMethodsForPath('/api/v1/meta/view/my_view')) + .not.toContain('POST'); + expect(server.allowedMethodsForPath('/api/v1/meta/view/my_view/publish')) + .toEqual(['POST']); + }); + + it('returns an empty list for a path that matches no route', () => { + expect(server.allowedMethodsForPath('/api/v1/nope')).toEqual([]); + }); +}); + +describe('notFound → 405 Method Not Allowed (#2684)', () => { + let plugin: HonoServerPlugin; + let server: HonoHttpServer; + + beforeEach(async () => { + const ctx = makeCtx(); + plugin = new HonoServerPlugin({ cors: false }); + await plugin.init(ctx); + await plugin.start(ctx); + server = (plugin as any).server as HonoHttpServer; + // Register the metadata save surface the same way rest-server does. + server.get('/api/v1/meta/:type/:name', (_req: any, res: any) => res.json({ ok: 'get' })); + server.put('/api/v1/meta/:type/:name', (_req: any, res: any) => res.json({ ok: 'put' })); + server.delete('/api/v1/meta/:type/:name', (_req: any, res: any) => res.json({ ok: 'delete' })); + }); + + const fetch = (method: string, path: string) => + server.getRawApp().fetch(new Request(`http://localhost${path}`, { method })); + + it('answers a POST to a PUT-only path with 405 + Allow header', async () => { + const res = await fetch('POST', '/api/v1/meta/view/my_view'); + expect(res.status).toBe(405); + const allow = (res.headers.get('Allow') || '').split(',').map((s) => s.trim()); + expect(allow).toEqual(expect.arrayContaining(['GET', 'PUT', 'DELETE'])); + const body = await res.json(); + expect(body.code).toBe('METHOD_NOT_ALLOWED'); + expect(body.allowed).toEqual(expect.arrayContaining(['PUT'])); + }); + + it('still routes the correct method to its handler (no false 405)', async () => { + const res = await fetch('PUT', '/api/v1/meta/view/my_view'); + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ ok: 'put' }); + }); + + it('keeps a genuine 404 for a path that matches no registered route', async () => { + const res = await fetch('POST', '/api/v1/does/not/exist'); + expect(res.status).toBe(404); + expect(await res.json()).toEqual({ error: 'Not found' }); + }); +}); diff --git a/packages/plugins/plugin-hono-server/src/route-pattern.test.ts b/packages/plugins/plugin-hono-server/src/route-pattern.test.ts new file mode 100644 index 0000000000..22cd5c899a --- /dev/null +++ b/packages/plugins/plugin-hono-server/src/route-pattern.test.ts @@ -0,0 +1,41 @@ +import { describe, it, expect } from 'vitest'; +import { compileRoutePattern, matchesRoutePattern } from './route-pattern'; + +describe('compileRoutePattern', () => { + it('compiles a literal path to an anchored regex', () => { + const re = compileRoutePattern('/api/v1/meta'); + expect(re.test('/api/v1/meta')).toBe(true); + expect(re.test('/api/v1/meta/view')).toBe(false); + expect(re.test('/api/v1/metadata')).toBe(false); + }); + + it('matches a named param as a single segment', () => { + const re = compileRoutePattern('/api/v1/meta/:type/:name'); + expect(re.test('/api/v1/meta/view/my_view')).toBe(true); + // :name must not swallow an extra segment (that is /publish territory) + expect(re.test('/api/v1/meta/view/my_view/publish')).toBe(false); + }); + + it('expands a trailing wildcard across segments', () => { + const re = compileRoutePattern('/api/v1/auth/*'); + expect(re.test('/api/v1/auth/session')).toBe(true); + expect(re.test('/api/v1/auth/oauth/callback/google')).toBe(true); + }); +}); + +describe('matchesRoutePattern', () => { + it('ignores a single trailing slash on either side', () => { + expect(matchesRoutePattern('/api/v1/meta/:type', '/api/v1/meta/view/')).toBe(true); + expect(matchesRoutePattern('/api/v1/meta/:type/', '/api/v1/meta/view')).toBe(true); + }); + + it('does not match when a param segment is empty', () => { + // POST /meta/view/?mode=draft — the :name segment is missing entirely + expect(matchesRoutePattern('/api/v1/meta/:type/:name', '/api/v1/meta/view/')).toBe(false); + }); + + it('treats regex metacharacters in the pattern as literals', () => { + expect(matchesRoutePattern('/api/v1/a.b', '/api/v1/a.b')).toBe(true); + expect(matchesRoutePattern('/api/v1/a.b', '/api/v1/axb')).toBe(false); + }); +}); diff --git a/packages/plugins/plugin-hono-server/src/route-pattern.ts b/packages/plugins/plugin-hono-server/src/route-pattern.ts new file mode 100644 index 0000000000..0c76cc017c --- /dev/null +++ b/packages/plugins/plugin-hono-server/src/route-pattern.ts @@ -0,0 +1,45 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Route-path pattern matching, used to answer "does this concrete request + * path correspond to a registered route pattern?" independent of HTTP method. + * + * This exists to support a proper `405 Method Not Allowed` response: Hono's + * router treats a method mismatch the same as a missing route (both fall + * through to `notFound`), so to distinguish "the path exists but the method is + * wrong" from "the path doesn't exist at all" we re-match the request path + * against the set of registered patterns ourselves. + * + * Supports the subset of Hono / Express path syntax the framework registers: + * - `:param` → a single non-empty path segment (`[^/]+`) + * - `*` → any remaining characters, including `/` (`.*`) + * Everything else is treated as a literal and regex-escaped. + */ + +/** Strip a single trailing slash so `/a/b` and `/a/b/` match the same pattern. */ +function normalize(path: string): string { + if (path.length > 1 && path.endsWith('/')) return path.slice(0, -1); + return path; +} + +/** + * Compile a Hono-style route pattern into an anchored `RegExp` that matches a + * concrete request path. Named params match one segment; `*` matches the rest. + */ +export function compileRoutePattern(pattern: string): RegExp { + const regexBody = normalize(pattern) + .split('/') + .map((segment) => { + if (segment.startsWith(':')) return '[^/]+'; + if (segment === '*') return '.*'; + // Escape regex metacharacters, then expand any inline `*` wildcard. + return segment.replace(/[.+?^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*'); + }) + .join('/'); + return new RegExp(`^${regexBody}$`); +} + +/** True when `path` is matched by the compiled route `pattern`. */ +export function matchesRoutePattern(pattern: string, path: string): boolean { + return compileRoutePattern(pattern).test(normalize(path)); +} From fcfcc35f64b8fdf2a725175ad15e78ceabaceb58 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 8 Jul 2026 10:45:36 +0000 Subject: [PATCH 2/2] chore: add changeset for hono-server 405 method-not-allowed fix Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01N9qJ2N2d1c9oCR5GPfBDaM --- .../hono-server-405-method-not-allowed.md | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) create mode 100644 .changeset/hono-server-405-method-not-allowed.md diff --git a/.changeset/hono-server-405-method-not-allowed.md b/.changeset/hono-server-405-method-not-allowed.md new file mode 100644 index 0000000000..ba2a932f16 --- /dev/null +++ b/.changeset/hono-server-405-method-not-allowed.md @@ -0,0 +1,17 @@ +--- +'@objectstack/plugin-hono-server': patch +--- + +Return `405 Method Not Allowed` (with an accurate `Allow` header and a +descriptive body) instead of an opaque `{"error":"Not found"}` 404 when a +request hits a registered path under the wrong HTTP method. + +Hono routes a method mismatch to the same `notFound` sink as a genuinely +missing path, so a `POST` to a `PUT`-only route (e.g. the metadata save +endpoint `PUT /api/v1/meta/:type/:name`) gave callers no hint that the path +exists under another verb (#2684). The server now tracks every registered +`(method, pattern)` pair and re-matches the request path in the `notFound` +handler: matching another method yields a 405; matching nothing stays a 404. +This is framework-wide — every registered endpoint benefits. Static/SPA +catch-alls registered straight on the raw Hono app are not tracked and never +produce a spurious 405.