From 0caf6dc320b5bbd100ff944aa1aeaf16afad74c0 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 6 Aug 2026 04:22:07 +0000 Subject: [PATCH 1/2] fix(runtime): unknown /auth sub-paths get a clean 404 instead of a leaked internal TypeError MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The dispatcher plugin mounted one legacy explicit route, POST ${prefix}/auth/login, which handed better-auth the adapter's INTERNAL IHttpRequest (headers is a plain object, not Headers). better-auth's fetch-style handler opens with request.headers.get(...), so the route answered HTTP 500 with the raw 'request.headers.get is not a function' in the response body. /login is not a better-auth endpoint at all, so the mount could never work for any caller. - dispatcher-plugin.ts: delete the legacy route. Unknown auth sub-paths now fall to the /auth/* wildcard the namespace owner mounts on the raw Hono app, which forwards a real Fetch Request and yields better-auth's own clean 404. - domains/auth.ts: a throw out of IAuthService.handleRequest is unattributable here, so its message is withheld unconditionally (#5437/#5464/#5489 discipline) — 500 INTERNAL_ERROR with the original error on the server log. Refs #5085 Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01DWUR56YsttL5sTF72Q75TQ --- packages/runtime/src/dispatcher-plugin.ts | 49 +++++++++++++++-------- packages/runtime/src/domains/auth.ts | 41 ++++++++++++++++++- 2 files changed, 73 insertions(+), 17 deletions(-) diff --git a/packages/runtime/src/dispatcher-plugin.ts b/packages/runtime/src/dispatcher-plugin.ts index b56963bf55..8da2a5f8df 100644 --- a/packages/runtime/src/dispatcher-plugin.ts +++ b/packages/runtime/src/dispatcher-plugin.ts @@ -728,26 +728,43 @@ export function createDispatcherPlugin(config: DispatcherPluginConfig = {}): Plu } }); - // ── Auth ──────────────────────────────────────────────────── - // NOTE: /auth/* wildcard is mounted by AuthProxyPlugin (cloud) - // or AuthPlugin (single-tenant) directly on the raw Hono app — + // ── Auth: DELIBERATELY NOT MOUNTED ────────────────────────── + // The /auth/* wildcard is mounted by AuthProxyPlugin (cloud) or + // AuthPlugin (single-tenant) directly on the raw Hono app — // those handlers can return native Web `Response` objects which // is what better-auth produces. The dispatcher cannot represent // a streaming Response cleanly through `IHttpServer.send`, so - // we deliberately do NOT register a dispatcher wildcard here. + // this plugin registers NO auth route at all. // - // Legacy explicit /auth/login retained for self-hosted clients - // that still POST there; superseded by the wildcard above for - // the better-auth surface (sign-up/email, sign-in/email, …). - server.post(`${prefix}/auth/login`, async (req: any, res: any) => { - try { - const result = await dispatcher.handleAuth('login', 'POST', req.body, { request: req }); - sendResult(result, res); - } catch (err: any) { - errorResponse(err, res); - } - }); - + // [#5085] It used to register exactly one — a "legacy explicit + // `POST ${prefix}/auth/login` retained for self-hosted clients" + // — and that single mount was the only producer in this repo that + // handed better-auth a NON-Fetch request. `IHttpServer` gives a + // handler the adapter's internal `IHttpRequest`, whose `headers` + // is a PLAIN OBJECT (`HonoHttpServer.runHandler` builds it from + // `c.req.header()`); `handleAuthRequest` forwards + // `context.request` whole to `IAuthService.handleRequest(request: + // Request)`, and better-auth's fetch-style handler opens with + // `request.headers.get(…)`. Measured on a real showcase boot: + // `POST /api/v1/auth/login` → HTTP 500 with the raw + // `request.headers.get is not a function` in the response body, + // while `POST /api/v1/auth/sign-in/email` — the same forwarding + // layer, reached through the raw-app wildcard with `c.req.raw` — + // answered 200. + // + // The route could not work for any caller: `/login` is not a + // better-auth endpoint (it is absent from `plugin-auth`'s + // `auth-route-ledger.ts`, and `content/docs/api/ + // plugin-endpoints.mdx` says in as many words "There is no + // `/auth/login` route"), and `handleAuthRequest` does not route on + // the sub-path at all (#4113) — so the ONLY thing this mount ever + // added over the wildcard was a 500 where the wildcard yields + // better-auth's own clean 404. Converting the internal request + // into a Fetch `Request` here would be the consumer-side + // accommodation Prime Directive #12 rejects, and would buy nothing + // but a more expensive 404. So it is deleted, and every unknown + // auth sub-path now falls to the namespace owner exactly like + // every other one. // ── Analytics ─────────────────────────────────────────────── // [#3891 follow-through / ADR-0076 D11] The /analytics wire diff --git a/packages/runtime/src/domains/auth.ts b/packages/runtime/src/domains/auth.ts index 064883231d..2961668e20 100644 --- a/packages/runtime/src/domains/auth.ts +++ b/packages/runtime/src/domains/auth.ts @@ -4,8 +4,11 @@ * `/auth` domain — extracted dispatcher body (ADR-0076 D11 step ③, PR-7). * Bridges to the `auth` service's contract handler. With no auth service * registered the domain answers 501 — it never fabricates a session (#4113). + * A THROW out of that handler is an unattributable server fault and never + * ships its own words to the client (#5085 — see the try/catch below). */ +import { INTERNAL_ERROR_MESSAGE } from '@objectstack/types'; import { CoreServiceName } from '@objectstack/spec/system'; import type { HttpProtocolContext, HttpDispatcherResult } from '../http-dispatcher.js'; import type { DomainHandlerDeps, DomainRoute } from '../domain-handler-registry.js'; @@ -52,7 +55,43 @@ export async function handleAuthRequest(deps: DomainHandlerDeps, _path: string, // now gets the auth service; #4113 removed the mock entirely (see below). const authService = await deps.getService(context, CoreServiceName.enum.auth); if (authService && typeof authService.handleRequest === 'function') { - const response = await authService.handleRequest(context.request as Request); + // [#5085] The auth service owns the routing, so whatever it THROWS is + // unattributable here: this domain never inspected the sub-path, never + // parsed the body, and cannot tell a caller mistake from a handler bug. + // Until now the thrown message reached the client verbatim, and the + // measured leak was a plain `TypeError` — `request.headers.get is not a + // function`, raised inside better-auth's fetch-style handler when a + // transport handed it a non-Fetch request. Neither dispatcher exit + // catches that: both sanitise only on `looksLikeInternalErrorLeak`, a + // SQL/driver-dump heuristic that says nothing about a TypeError, and + // #5462 already recorded that a negative from a keyword heuristic is + // not evidence of safety. + // + // So the message is withheld UNCONDITIONALLY — the discipline + // #5437/#5464 established one boundary up, and #5489 wrote down for + // `mapDataError`'s terminal branch (`UNCLASSIFIED_FAULT`), where a + // handler `TypeError` is named as the very shape that lands there. + // The answer is a plain 500 with the catalog's floor code for "500 with + // no more specific code" (`INTERNAL_ERROR`, derived from the status by + // `deps.error`) and the original error goes to the server log, which is + // where an operator reads it. + // + // This costs the honest paths nothing: better-auth answers its own + // failures with a `Response` rather than by throwing (the reason + // `AuthPlugin`'s wildcard logs >=500 responses proactively), so a real + // 401/403/404/422 is still returned below with its own body untouched. + let response: Response; + try { + response = await authService.handleRequest(context.request as Request); + } catch (err) { + const logger = deps.logger ?? console; + logger?.error?.( + '[auth] the auth service threw while handling the request; the client was answered ' + + 'with a sanitised 500 (#5085)', + err instanceof Error ? err : new Error(String(err)), + ); + return { handled: true, response: deps.error(INTERNAL_ERROR_MESSAGE, 500) }; + } return { handled: true, result: response }; } From f1375282f9ae3f5c754daa6199303de923d7e99b Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 6 Aug 2026 04:39:54 +0000 Subject: [PATCH 2/2] test(runtime): pin both halves of the #5085 /auth forwarding fix + changeset MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - auth-forward-fault-sanitization.test.ts: the dispatcher plugin mounts NO route under ${prefix}/auth (the specific legacy mount AND the general invariant), and a throw out of IAuthService.handleRequest is a sanitised 500 whose body carries none of the thrown text, with the original error on the server log. Positive controls: a better-auth Response passes through untouched (same object), its own 401 body is not sanitised, and an empty auth slot still 501s. - auth-unknown-subpath.hono.integration.test.ts: a real hono boot, with a fake auth service that is better-auth-SHAPED (reads request.headers.get + new URL(request.url) first thing, so an internal request object explodes here the way it did in production). POST /auth/login → 404, sign-in/email → 200 with its set-cookie. Refs #5085 Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01DWUR56YsttL5sTF72Q75TQ --- .changeset/auth-unknown-subpath-clean-404.md | 52 ++++ .../auth-forward-fault-sanitization.test.ts | 239 ++++++++++++++++++ ...h-unknown-subpath.hono.integration.test.ts | 184 ++++++++++++++ 3 files changed, 475 insertions(+) create mode 100644 .changeset/auth-unknown-subpath-clean-404.md create mode 100644 packages/runtime/src/auth-forward-fault-sanitization.test.ts create mode 100644 packages/runtime/src/auth-unknown-subpath.hono.integration.test.ts diff --git a/.changeset/auth-unknown-subpath-clean-404.md b/.changeset/auth-unknown-subpath-clean-404.md new file mode 100644 index 0000000000..99aacba102 --- /dev/null +++ b/.changeset/auth-unknown-subpath-clean-404.md @@ -0,0 +1,52 @@ +--- +"@objectstack/runtime": patch +--- + +fix(runtime): unknown `/auth` sub-paths answer a clean 404 instead of leaking an internal `TypeError` (#5085) + +Measured on a real showcase boot: + +``` +POST /api/v1/auth/login +→ HTTP 500 +{"success":false,"error":{"code":"INTERNAL_ERROR", + "message":"request.headers.get is not a function","httpStatus":500}} +``` + +`/auth/login` is an obvious guess — it is the industry-habitual name — and any +integrator who tried it got a 500 naming an internal function call. The positive +control `POST /api/v1/auth/sign-in/email`, a real better-auth route reached +through the same forwarding layer on the same boot, answered 200 all along. + +**The producer.** `createDispatcherPlugin` mounted one legacy explicit route, +`POST ${prefix}/auth/login`, and it was the only place in this repo that handed +better-auth a **non-Fetch** request. `IHttpServer` gives a handler the adapter's +internal `IHttpRequest`, whose `headers` is a plain object built from +`c.req.header()`; the `/auth` domain forwards `context.request` whole to +`IAuthService.handleRequest(request: Request)`, and better-auth's fetch-style +handler opens with `request.headers.get(…)`. + +That route could not work for any caller: `/login` is not a better-auth endpoint +(it appears in neither `plugin-auth`'s route ledger nor the documented endpoint +list, which already stated "There is no `/auth/login` route"), and the domain +does not route on the sub-path at all. Its only effect over the `/auth/*` +wildcard the auth plugin mounts on the raw app was a 500 where the wildcard +yields better-auth's own clean 404. **It is deleted** — per Prime Directive #12 +the fix belongs at the producer, not in a consumer-side conversion that would buy +nothing but a more expensive 404. Every unknown auth sub-path now falls to the +namespace owner exactly like every other one. + +**The exit.** A **throw** out of `IAuthService.handleRequest` is unattributable +in the `/auth` domain: it never inspected the sub-path, never parsed the body, +and cannot tell a caller mistake from a handler bug. Its message used to reach +the client verbatim, because both dispatcher exits sanitise only on +`looksLikeInternalErrorLeak` — a SQL/driver-dump heuristic with nothing to say +about a `TypeError`. The message is now withheld **unconditionally**, following +the same discipline as `mapDataError`'s terminal `UNCLASSIFIED_FAULT` branch: +HTTP 500 with the catalog's `INTERNAL_ERROR` / `Internal server error`, and the +original error handed to the server log where an operator reads it. + +Nothing changes for the honest paths. better-auth answers its own failures with a +`Response` rather than by throwing, so a real 401/403/404/422 is still returned +with its own body untouched, and `POST /auth/sign-in/email` still answers 200 +with its `set-cookie`. diff --git a/packages/runtime/src/auth-forward-fault-sanitization.test.ts b/packages/runtime/src/auth-forward-fault-sanitization.test.ts new file mode 100644 index 0000000000..4097d354c8 --- /dev/null +++ b/packages/runtime/src/auth-forward-fault-sanitization.test.ts @@ -0,0 +1,239 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * #5085 — the two halves of the `/auth/*` forwarding defect, pinned at the two + * layers that own them. + * + * ## What was measured + * + * On a real showcase boot (`pnpm dev -- --fresh --seed-admin`): + * + * ``` + * POST /api/v1/auth/login + * → HTTP 500 + * {"success":false,"error":{"code":"INTERNAL_ERROR", + * "message":"request.headers.get is not a function","httpStatus":500}} + * ``` + * + * …while the positive control `POST /api/v1/auth/sign-in/email` — a REAL + * better-auth route, same boot, same forwarding layer — answered 200 with a + * `set-cookie`. + * + * ## ① The producer: `createDispatcherPlugin` mounts NO auth route + * + * It used to mount exactly one — `POST ${prefix}/auth/login`, "legacy explicit + * … retained for self-hosted clients" — and that mount was the only place in + * this repo that handed better-auth a NON-Fetch request. `IHttpServer` gives a + * handler the adapter's internal `IHttpRequest` (`headers` is a plain object, + * built by `HonoHttpServer.runHandler` from `c.req.header()`), the auth domain + * forwards `context.request` whole to `IAuthService.handleRequest(request: + * Request)`, and better-auth's fetch-style handler opens with + * `request.headers.get(…)`. + * + * The route could not work for anyone: `/login` is not a better-auth endpoint + * (absent from `plugin-auth`'s `auth-route-ledger.ts`; `content/docs/api/ + * plugin-endpoints.mdx` says so outright), and the domain does not route on the + * sub-path at all (#4113) — so the mount's ONLY effect was a 500 where the + * raw-app `/auth/*` wildcard yields better-auth's own clean 404. + * + * ## ② The exit: a throw out of the auth service never ships its own words + * + * The leak was a plain `TypeError`. Both dispatcher exits sanitise on + * `looksLikeInternalErrorLeak`, a SQL/driver-dump heuristic that has nothing to + * say about a `TypeError`, and #5462 recorded that a negative from a keyword + * heuristic is not evidence of safety. The domain now withholds the message + * UNCONDITIONALLY, per the #5437/#5464 discipline and #5489's + * `UNCLASSIFIED_FAULT` — which names a handler `TypeError` as exactly the shape + * that lands there. + * + * Both halves are pinned through the REAL `HttpDispatcher` / real plugin rather + * than against a hand-built `DomainHandlerDeps`, so the assertions are about + * what a caller receives rather than about an internal seam. + */ + +import { describe, it, expect, vi } from 'vitest'; + +import { HttpDispatcher } from './http-dispatcher.js'; +import { createDispatcherPlugin } from './dispatcher-plugin.js'; + +/** The exact message the showcase boot leaked (#5085). */ +const LEAKED = 'request.headers.get is not a function'; + +// ───────────────────────────── ① route table ───────────────────────────── + +function makeFakeServer() { + const routes: string[] = []; + const rec = (verb: string) => (path: string, _handler: any) => { + routes.push(`${verb} ${path}`); + }; + return { + routes, + server: { + get: rec('GET'), + post: rec('POST'), + put: rec('PUT'), + delete: rec('DELETE'), + patch: rec('PATCH'), + }, + }; +} + +function makeCtx(fakeServer: any) { + const kernel = { + getService: () => undefined, + getServiceAsync: async () => undefined, + }; + return { + getKernel: () => kernel, + getService: (name: string) => (name === 'http.server' ? fakeServer : undefined), + environmentId: undefined, + logger: { info() {}, warn() {}, error() {}, debug() {} }, + hook: () => {}, + on: () => {}, + } as any; +} + +describe('#5085 ① the dispatcher plugin owns no /auth route', () => { + it('mounts nothing under ${prefix}/auth — the namespace belongs to the raw-app wildcard', async () => { + const { server, routes } = makeFakeServer(); + const plugin = createDispatcherPlugin({ prefix: '/api/v1', securityHeaders: false }); + await plugin.start?.(makeCtx(server)); + + // The specific legacy mount… + expect(routes).not.toContain('POST /api/v1/auth/login'); + // …and the general invariant it violated: an `IHttpServer` route can + // only ever be handed an `IHttpRequest`, so ANY auth mount here would + // reintroduce the same non-Fetch forwarding. + expect(routes.filter((r) => r.includes('/api/v1/auth'))).toEqual([]); + + // Sanity that start() actually ran and registered its other routes — an + // empty route table would satisfy the assertions above vacuously. + expect(routes).toContain('GET /api/v1/health'); + expect(routes).toContain('GET /api/v1/i18n/locales'); + }); +}); + +// ──────────────────────── ② the auth-domain error exit ──────────────────── + +/** + * A dispatcher whose `auth` slot is filled by a service behaving as `impl` + * says. `api.getSession` is present so the mock is shaped like the real + * better-auth-backed `AuthManager` rather than only like the one method under + * test. + */ +function makeDispatcher(impl: Partial<{ handleRequest: (r: Request) => Promise }>) { + const auth = { + api: { getSession: async () => ({ user: { id: 'u1' } }) }, + ...impl, + }; + const svc = (name: string) => (name === 'auth' ? auth : undefined); + const kernel: any = { + getService: svc, + getServiceAsync: async (name: string) => svc(name), + }; + return new HttpDispatcher(kernel); +} + +describe('#5085 ② a throw out of the auth service is a sanitised 500', () => { + it('withholds the TypeError the forwarding defect produced', async () => { + const dispatcher = makeDispatcher({ + handleRequest: async () => { throw new TypeError(LEAKED); }, + }); + + const result = await dispatcher.handleAuth('login', 'POST', {}, { request: {} }); + + expect(result.handled).toBe(true); + expect(result.response?.status).toBe(500); + expect(result.response?.body?.error?.message).toBe('Internal server error'); + // The catalog's floor code for "500 with no more specific code" — + // unchanged from what the leaking response already carried, so only the + // prose moves. + expect(result.response?.body?.error?.code).toBe('INTERNAL_ERROR'); + // Asserted over the WHOLE serialized body, not one field: a leak that + // moved into `details` would still be a leak. + expect(JSON.stringify(result.response?.body)).not.toContain('headers.get'); + expect(JSON.stringify(result.response?.body)).not.toContain('is not a function'); + }); + + it('withholds unconditionally — not only what looksLikeInternalErrorLeak recognises', async () => { + // The predicate matches SQL/driver dumps. This message is neither, and + // that is the whole point: before #5085 anything it did not recognise + // reached the client verbatim. + const dispatcher = makeDispatcher({ + handleRequest: async () => { throw new Error('better-auth secret rotation failed for tenant acme'); }, + }); + + const result = await dispatcher.handleAuth('sign-in/email', 'POST', {}, { request: {} }); + + expect(result.response?.status).toBe(500); + expect(JSON.stringify(result.response?.body)).not.toContain('acme'); + expect(result.response?.body?.error?.message).toBe('Internal server error'); + }); + + it('hands the ORIGINAL error to the server log — withholding costs no diagnostics', async () => { + // `deps.logger` is undefined on a bare `HttpDispatcher` (no host logger + // attached), so the domain falls back to `console` — the same + // `deps.logger ?? console` shape `domains/packages.ts` uses. Spying + // there is what a real single-tenant boot would actually write to. + const boom = new TypeError(LEAKED); + const dispatcher = makeDispatcher({ + handleRequest: async () => { throw boom; }, + }); + const spy = vi.spyOn(console, 'error').mockImplementation(() => {}); + try { + await dispatcher.handleAuth('login', 'POST', {}, { request: {} }); + + expect(spy).toHaveBeenCalledTimes(1); + // The UNTOUCHED error object, not a re-worded copy — this is the + // only place the leaked text is still allowed to exist. + expect(spy.mock.calls[0]?.[1]).toBe(boom); + expect((spy.mock.calls[0]?.[1] as Error).message).toBe(LEAKED); + } finally { + spy.mockRestore(); + } + }); + + // ── positive control ──────────────────────────────────────────────────── + it('passes a real better-auth Response through untouched, cookie and all', async () => { + const response = new Response(JSON.stringify({ token: 'tok' }), { + status: 200, + headers: { 'set-cookie': 'better-auth.session_token=tok; Path=/; HttpOnly' }, + }); + const dispatcher = makeDispatcher({ handleRequest: async () => response }); + + const result = await dispatcher.handleAuth( + 'sign-in/email', + 'POST', + { email: 'a@b.c' }, + { request: new Request('http://x/api/v1/auth/sign-in/email', { method: 'POST' }) }, + ); + + expect(result.handled).toBe(true); + // Same object — the domain must not re-wrap or re-envelope a success. + expect(result.result).toBe(response); + expect(result.response).toBeUndefined(); + }); + + it('leaves better-auth\'s OWN error responses alone — only a throw is withheld', async () => { + // better-auth answers a bad credential with a `Response`, not a throw + // (which is why `AuthPlugin`'s wildcard logs >=500 RESPONSES rather than + // catching). Sanitising that would swallow a deliberate 401 body. + const unauthorized = new Response(JSON.stringify({ message: 'Invalid email or password' }), { status: 401 }); + const dispatcher = makeDispatcher({ handleRequest: async () => unauthorized }); + + const result = await dispatcher.handleAuth('sign-in/email', 'POST', {}, { request: {} }); + + expect(result.result).toBe(unauthorized); + expect(await (result.result as Response).clone().json()).toEqual({ message: 'Invalid email or password' }); + }); + + it('still answers 501 when no auth service is registered (#4113 unchanged)', async () => { + const kernel: any = { getService: () => undefined, getServiceAsync: async () => undefined }; + const dispatcher = new HttpDispatcher(kernel); + + const result = await dispatcher.handleAuth('sign-in/email', 'POST', {}, { request: {} }); + + expect(result.response?.status).toBe(501); + expect(JSON.stringify(result.response?.body)).toContain('plugin-auth'); + }); +}); diff --git a/packages/runtime/src/auth-unknown-subpath.hono.integration.test.ts b/packages/runtime/src/auth-unknown-subpath.hono.integration.test.ts new file mode 100644 index 0000000000..b196c239e7 --- /dev/null +++ b/packages/runtime/src/auth-unknown-subpath.hono.integration.test.ts @@ -0,0 +1,184 @@ +// 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'; + +/** + * End-to-end regression for #5085 — the `/auth/*` forwarding layer handed + * better-auth an INTERNAL request object. + * + * ## What was measured + * + * On a real showcase boot (`pnpm dev -- --fresh --seed-admin`): + * + * ``` + * POST /api/v1/auth/login + * → HTTP 500 + * {"success":false,"error":{"code":"INTERNAL_ERROR", + * "message":"request.headers.get is not a function","httpStatus":500}} + * ``` + * + * …while the positive control `POST /api/v1/auth/sign-in/email` — a REAL + * better-auth route, same boot, same forwarding layer — answered 200 with a + * `set-cookie`. Two defects stacked: the wrong error CLASS (a path better-auth + * does not implement is a 404, not a 500) and a raw internal `TypeError` in the + * response body. + * + * ## Why this suite is shaped the way it is + * + * `HonoHttpServer` hands a route handler the adapter's own `IHttpRequest`, whose + * `headers` is a PLAIN OBJECT built from `c.req.header()` — not a `Headers`. + * `createDispatcherPlugin` used to mount one auth route, + * `POST ${prefix}/auth/login`, forwarding that object straight into + * `dispatcher.handleAuth(…, { request: req })`; the domain hands + * `context.request` whole to `IAuthService.handleRequest(request: Request)`, and + * better-auth's fetch-style handler opens with `request.headers.get(…)`. + * + * So the fake auth service below is deliberately better-auth-SHAPED rather than + * convenient: it reads `request.headers.get('cookie')` and `new URL(request.url)` + * first thing, which is what makes an internal request object explode here the + * way it exploded in production. A fake that tolerated `headers` as a plain + * object would have kept this suite green through the whole defect. + * + * The `rawApp.all('/api/v1/auth/*')` mount mirrors what `AuthPlugin` (and + * cloud's `AuthProxyPlugin`) register on the raw Hono app, including the #4088 + * "a 404 means better-auth does not own this path, so yield" behaviour — + * MODELLED here rather than depended on, so `packages/runtime`'s test-time + * dependency set does not grow a better-auth stack. + */ + +const SIGN_IN_PATH = '/api/v1/auth/sign-in/email'; +const UNKNOWN_PATH = '/api/v1/auth/login'; + +/** + * A better-auth-shaped `auth` service: it can ONLY be driven by a real Fetch + * `Request`, exactly like the real one. + */ +function fakeBetterAuthPlugin(): Plugin { + return { + name: 'com.objectstack.test.fake-better-auth', + version: '1.0.0', + init: async (ctx: PluginContext) => { + ctx.registerService('auth', { + handleRequest: async (request: Request): Promise => { + // Both reads are ones better-auth performs before it routes + // anything — and both are TypeErrors on an `IHttpRequest`. + request.headers.get('cookie'); + const { pathname } = new URL(request.url); + + if (pathname === SIGN_IN_PATH) { + return new Response(JSON.stringify({ token: 'tok_test', user: { id: 'usr_1' } }), { + status: 200, + headers: { + 'content-type': 'application/json', + 'set-cookie': 'better-auth.session_token=tok_test; Path=/; HttpOnly', + }, + }); + } + // better-auth's own answer for a path it does not implement. + return new Response(JSON.stringify({ message: 'Not Found' }), { + status: 404, + headers: { 'content-type': 'application/json' }, + }); + }, + }); + }, + }; +} + +/** Mirrors `AuthPlugin`'s terminal-but-yielding `rawApp.all(`${basePath}/*`)`. */ +function mountAuthWildcard(kernel: LiteKernel): void { + const httpServer = kernel.getService('http.server'); + const rawApp = (httpServer as unknown as { getRawApp(): any }).getRawApp(); + rawApp.all('/api/v1/auth/*', async (c: any, next: any) => { + const service = kernel.getService<{ handleRequest(r: Request): Promise }>('auth'); + const response = await service.handleRequest(c.req.raw); + if (response.status === 404) { + await next(); + if (c.res && c.res.status !== 404) return; + c.res = response; + return; + } + return response; + }); +} + +describe('/auth/* forwarding over a real hono server (integration, #5085)', () => { + let kernel: LiteKernel; + let baseUrl: string; + + beforeAll(async () => { + // `LiteKernel`, like `route-parity.integration.test.ts`: this suite is + // about the HTTP forwarding seam, and a full `ObjectKernel` would demand + // the critical `data` service (a driver + ObjectQL) that no assertion + // here reads. + kernel = new LiteKernel(); + kernel.use(fakeBetterAuthPlugin()); + // port 0 → OS-assigned free port; resolved via getPort() after listening. + kernel.use(new HonoServerPlugin({ port: 0, cors: false })); + kernel.use(createDispatcherPlugin({ prefix: '/api/v1', securityHeaders: false, requireAuth: false })); + + await kernel.bootstrap(); + + // Registered AFTER bootstrap so the dispatcher's own mounts are already + // on the app — i.e. the wildcard is the LAST matcher, the least + // favourable order for this fix. A surviving dispatcher `/auth/*` route + // would win the match outright and this suite would go red. + mountAuthWildcard(kernel); + + const httpServer = kernel.getService('http.server'); + baseUrl = `http://127.0.0.1:${httpServer.getPort!()}`; + }, 30_000); + + afterAll(async () => { + if (kernel) { + await Promise.race([ + kernel.shutdown(), + new Promise((resolve) => setTimeout(resolve, 10_000)), + ]); + } + }, 30_000); + + // ── ① the defect: an unknown auth sub-path is a clean 404, not a 500 ──── + it('answers an unknown auth sub-path with better-auth\'s own 404 — no 500, no TypeError', async () => { + const res = await fetch(`${baseUrl}${UNKNOWN_PATH}`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ email: 'a@b.c', password: 'x' }), + }); + const text = await res.text(); + + expect(res.status).toBe(404); + // ② the raw internal error never reaches the wire — asserted on the + // WHOLE body, not on a parsed field, so a leak through any envelope + // shape is caught. + expect(text).not.toContain('headers.get'); + expect(text).not.toContain('is not a function'); + expect(text).not.toContain('TypeError'); + }); + + // ── ③ positive control: the real better-auth route is untouched ───────── + it('keeps POST /auth/sign-in/email at 200 with its set-cookie', async () => { + const res = await fetch(`${baseUrl}${SIGN_IN_PATH}`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ email: 'a@b.c', password: 'x' }), + }); + + expect(res.status).toBe(200); + expect(res.headers.get('set-cookie')).toContain('better-auth.session_token='); + expect((await res.json()).token).toBe('tok_test'); + }); + + // The legacy mount is gone from the route table, so a verb it never had a + // handler for cannot be answered by a stale one either. + it('does not resurrect /auth/login for any verb', async () => { + const res = await fetch(`${baseUrl}${UNKNOWN_PATH}`, { method: 'GET' }); + expect(res.status).toBe(404); + expect(await res.text()).not.toContain('is not a function'); + }); +});