From 7dffa23303f13d1866ab601ecf004c991b6f28a6 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 19 Aug 2026 04:24:32 +0000 Subject: [PATCH] =?UTF-8?q?fix(runtime):=20endpoint-route=20401=20arm=20wr?= =?UTF-8?q?ites=20the=20shared=20ANONYMOUS=5FDENY=5FBODY=20=E2=80=94=20cod?= =?UTF-8?q?e=20key=20included=20(#9823)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The mountRouteOnServer 401 arm wrote an inline { error, message } copy of the flat deny body, so #9487's additive code key never reached it. The arm now writes @objectstack/core's ANONYMOUS_DENY_BODY / ANONYMOUS_DENY_STATUS verbatim (additive only per the #9487 maintainer ruling), and a pin asserts this arm's exact body — the existing integration test covers the OTHER (http-dispatcher, nested-envelope) path, not this one. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01WeN7F6jQFpcqW2BN56RdPa --- .changeset/endpoint-route-401-code-key.md | 21 +++ ...atcher-plugin.route-auth-deny-body.test.ts | 163 ++++++++++++++++++ packages/runtime/src/dispatcher-plugin.ts | 15 +- 3 files changed, 193 insertions(+), 6 deletions(-) create mode 100644 .changeset/endpoint-route-401-code-key.md create mode 100644 packages/runtime/src/dispatcher-plugin.route-auth-deny-body.test.ts diff --git a/.changeset/endpoint-route-401-code-key.md b/.changeset/endpoint-route-401-code-key.md new file mode 100644 index 0000000000..0e4017db98 --- /dev/null +++ b/.changeset/endpoint-route-401-code-key.md @@ -0,0 +1,21 @@ +--- +"@objectstack/runtime": minor +--- + +feat(security): the endpoint-route 401 anonymous-deny body carries `code: "UNAUTHENTICATED"` alongside the existing `error` / `message` keys (#9823) + +Service-declared endpoint routes (`RouteDefinition` emitted via hooks, e.g. +`buildAIRoutes()` — any route whose `auth` is not explicitly `false`) answered +an anonymous caller `401 { error, message }` with no `code` key: the +`mountRouteOnServer` 401 arm wrote an inline copy of the flat deny body, so +the #9487 constant change (`@objectstack/core`'s `ANONYMOUS_DENY_BODY`) never +reached it, and through `@objectstack/client` a caller's `err.code` stayed +`undefined` for exactly these 401s. + +The arm now writes the shared `ANONYMOUS_DENY_BODY` / `ANONYMOUS_DENY_STATUS` +verbatim, so the body gains `code: "UNAUTHENTICATED"` and this seam can no +longer drift from the constant it was copied from. **Additive only** +(maintainer-ruled on #9487): no key is removed or moved — `error` keeps +holding the same code value it always has, so every existing reader keeps +working. This does not settle ADR-0112 D5 (flat vs nested envelope +convergence, #9559); the envelope family of this arm is unchanged in kind. diff --git a/packages/runtime/src/dispatcher-plugin.route-auth-deny-body.test.ts b/packages/runtime/src/dispatcher-plugin.route-auth-deny-body.test.ts new file mode 100644 index 0000000000..a9c344ec68 --- /dev/null +++ b/packages/runtime/src/dispatcher-plugin.route-auth-deny-body.test.ts @@ -0,0 +1,163 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * #9823 — the `mountRouteOnServer` 401 arm answers the SHARED flat deny body, + * `code` key included. + * + * Which path this pins, re-derived rather than assumed: + * `dispatcher-plugin.endpoint-fallback.integration.test.ts` asserts a NESTED + * `body.error.code` — that is the http-dispatcher wildcard path (`/ai/*` via + * `dispatch()`), a different 401 producer answering the WRAPPED envelope + * family. THIS file pins the concrete hook-route mounts (`RouteDefinition[]` + * emitted via `ai:routes`, recovered here through the `__aiRoutes` cache), + * whose 401 arm writes the FLAT family. The arm is live on the wire, not just + * in principle: `registerAIRoutes` mounts wildcards for get/post/delete/put + * only, so a PATCH route under `/ai/*` — a legal `RouteDefinition.method` — + * reaches these concrete mounts unshadowed, as does any emitted path outside + * `/ai/*`. + * + * Until #9823 the arm wrote an inline `{ error, message }` copy of + * `ANONYMOUS_DENY_BODY`, which is exactly why #9487's additive `code` key + * (maintainer-ruled: additive only, no key removed or moved) never reached + * it. The exact-body pin below spells every key literally — deliberately NOT + * via the constant — so this seam can neither drift from the constant again + * nor change envelope family unnoticed; whether the flat family survives at + * all stays ADR-0112 D5 territory (#9559) and is not settled here. + */ + +import { describe, it, expect, vi } from 'vitest'; +import { ANONYMOUS_DENY_BODY, ANONYMOUS_DENY_STATUS } from '@objectstack/core'; + +import { createDispatcherPlugin } from './dispatcher-plugin.js'; + +const ROUTE_PATH = '/ai/agents/:name'; +const MOUNTED = `PATCH /api/v1${ROUTE_PATH}`; + +function makeFakeServer() { + const handlers: Record any> = {}; + const rec = (verb: string) => (path: string, handler: any) => { + handlers[`${verb} ${path}`] = handler; + }; + return { + handlers, + server: { + get: rec('GET'), + post: rec('POST'), + put: rec('PUT'), + delete: rec('DELETE'), + patch: rec('PATCH'), + }, + }; +} + +/** + * `session === undefined` is the anonymous caller — `resolveSessionData` + * forwards whatever `auth.api.getSession` answers, and every failure mode of + * that lookup resolves to `undefined` too, so this is the exact input the 401 + * arm keys off. + */ +function makeCtx(fakeServer: any, route: Record, routeHandler: (req: any) => Promise, session?: any) { + const kernel: any = { + getService: () => undefined, + getServiceAsync: async () => undefined, + // The AIServicePlugin's cross-plugin cache the dispatcher recovers + // routes from when the `ai:routes` hook fired before it was listening. + __aiRoutes: [{ ...route, handler: routeHandler }], + }; + const authService: any = { api: { getSession: async () => session } }; + return { + getKernel: () => kernel, + getService: (name: string) => + name === 'http.server' ? fakeServer.server : name === 'auth' ? authService : undefined, + environmentId: undefined, + logger: { info() {}, warn() {}, error() {}, debug() {} }, + hook: () => {}, + on: () => {}, + } as any; +} + +function recordingRes() { + const rec: { status?: number; body?: unknown; headers: Record; ended: boolean } = { + headers: {}, + ended: false, + }; + const res: any = { + status(code: number) { + rec.status = code; + return res; + }, + header(k: string, v: string) { + rec.headers[k] = v; + return res; + }, + json(body: unknown) { + rec.body = body; + return res; + }, + end() { + rec.ended = true; + return res; + }, + }; + return { rec, res }; +} + +async function mountAndCall(route: Record, session?: any) { + const fakeServer = makeFakeServer(); + const routeHandler = vi.fn(async () => ({ status: 200, body: { ok: true } })); + const ctx = makeCtx(fakeServer, route, routeHandler, session); + const plugin = createDispatcherPlugin({ prefix: '/api/v1', securityHeaders: false }); + await plugin.start?.(ctx); + + const handler = fakeServer.handlers[MOUNTED]; + expect(handler).toBeTypeOf('function'); + const { rec, res } = recordingRes(); + await handler({ headers: {}, body: {}, params: { name: 'support_bot' }, query: {} }, res); + return { rec, routeHandler }; +} + +const AUTH_ROUTE = { method: 'PATCH', path: ROUTE_PATH, description: 'pin', auth: true }; + +describe('#9823 — mountRouteOnServer 401 arm: the flat deny body carries the code key', () => { + it('anonymous caller → 401 with the EXACT flat body — error, code and message, spelled literally', async () => { + const { rec, routeHandler } = await mountAndCall(AUTH_ROUTE); + + expect(rec.status).toBe(401); + // The pin: every key, literal. `code` is the #9487 maintainer-ruled + // additive key ("one documented key identifies the machine code on + // every error family, 401 included"); `error` keeps carrying the same + // code value it always has, so no existing reader breaks. + expect(rec.body).toEqual({ + error: 'UNAUTHENTICATED', + code: 'UNAUTHENTICATED', + message: 'Authentication is required to access this endpoint.', + }); + // The refusal is a refusal: the route handler never ran. + expect(routeHandler).not.toHaveBeenCalled(); + }); + + it('…and that exact body IS the shared constant — the seam no longer owns an inline copy', async () => { + const { rec } = await mountAndCall(AUTH_ROUTE); + + expect(rec.status).toBe(ANONYMOUS_DENY_STATUS); + expect(rec.body).toEqual(ANONYMOUS_DENY_BODY); + }); + + it('the same request with a session is served — the deny targets anonymity, not the route', async () => { + const { rec, routeHandler } = await mountAndCall(AUTH_ROUTE, { + user: { id: 'usr_1', email: 'u1@example.com' }, + session: {}, + }); + + expect(rec.status).toBe(200); + expect(rec.body).toEqual({ ok: true }); + expect(routeHandler).toHaveBeenCalledTimes(1); + }); + + it('a route declaring auth: false stays open to anonymous callers — it opts out by declaration', async () => { + const { rec, routeHandler } = await mountAndCall({ ...AUTH_ROUTE, auth: false }); + + expect(rec.status).toBe(200); + expect(routeHandler).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/runtime/src/dispatcher-plugin.ts b/packages/runtime/src/dispatcher-plugin.ts index 8fd4bcc979..9daa38e701 100644 --- a/packages/runtime/src/dispatcher-plugin.ts +++ b/packages/runtime/src/dispatcher-plugin.ts @@ -1,6 +1,6 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. -import { Plugin, PluginContext, IHttpServer } from '@objectstack/core'; +import { Plugin, PluginContext, IHttpServer, ANONYMOUS_DENY_BODY, ANONYMOUS_DENY_STATUS } from '@objectstack/core'; import { looksLikeInternalErrorLeak, declaresServerFault, INTERNAL_ERROR_MESSAGE, resolveThrownHttpError, demotedDeclaredCode } from '@objectstack/types'; import { DispatcherErrorCode } from '@objectstack/spec/api'; import type { IAuthService, IMetadataService } from '@objectstack/spec/contracts'; @@ -210,14 +210,17 @@ function mountRouteOnServer( // opt-out is retired, so only a route declaring `auth: false` opens // itself, and it does so by declaration. if (route.auth !== false && !user) { - res.status(401); + res.status(ANONYMOUS_DENY_STATUS); if (securityHeaders) { for (const [k, v] of Object.entries(securityHeaders)) res.header(k, v); } - res.json({ - error: 'UNAUTHENTICATED', - message: 'Authentication is required to access this endpoint.', - }); + // [#9823] The shared flat deny body from @objectstack/core — + // this used to be an inline `{ error, message }` copy, which is + // exactly why #9487's additive `code` key never reached it. + // Writing the constant keeps this seam from drifting again; + // the wrapper question (flat vs nested, ADR-0112 D5) is not + // settled here. + res.json(ANONYMOUS_DENY_BODY); return; }