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
21 changes: 21 additions & 0 deletions .changeset/endpoint-route-401-code-key.md
Original file line numberDiff line numberDiff line change
@@ -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.
163 changes: 163 additions & 0 deletions packages/runtime/src/dispatcher-plugin.route-auth-deny-body.test.ts
Original file line numberDiff line numberDiff line change
@@ -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<string, (req: any, res: any) => 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<string, unknown>, routeHandler: (req: any) => Promise<any>, 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<string, string>; 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<string, unknown>, 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);
});
});
15 changes: 9 additions & 6 deletions packages/runtime/src/dispatcher-plugin.ts
Original file line numberDiff line numberDiff line change
@@ -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';
Expand DownExpand Up@@ -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;
}

Expand Down
Loading