diff --git a/.changeset/24024-notifications-share-links-anonymous-floor.md b/.changeset/24024-notifications-share-links-anonymous-floor.md new file mode 100644 index 00000000000..935634f1821 --- /dev/null +++ b/.changeset/24024-notifications-share-links-anonymous-floor.md @@ -0,0 +1,20 @@ +--- +'@objectstack/runtime': patch +'@objectstack/plugin-sharing': patch +--- + +fix(runtime, plugin-sharing): on the standalone server, an anonymous caller of any `/api/v1/notifications` verb or any `/api/v1/share-links` path off the two token faces is answered `401 UNAUTHENTICATED` in the dispatcher's standard envelope + +Clause-②: no + +The v18 text for these two doors reads "`/notifications`: `401 UNAUTHENTICATED` on every face" and "`/share-links`: `401 UNAUTHENTICATED` on the create, list and revoke faces and on every path the domain does not serve", in "the dispatcher's standard envelope … with the shared message every other dispatcher domain answers with". A host that reaches the dispatcher through `@objectstack/hono`'s catch-all already answered that way. The standalone server (the one `os dev` and `os serve` run) did not: + +- `/api/v1/notifications`, `/api/v1/notifications/read` and `/api/v1/notifications/read/all` were each mounted for the one verb they serve, so any other verb never reached the `notifications` domain and the HTTP adapter answered `405`, naming the allowed methods, to an anonymous caller as well. +- `/api/v1/share-links` is served there by `@objectstack/plugin-sharing`'s routes, not by the dispatcher. They registered five routes and nothing else, so another verb or path got the adapter's `405` or `404`, and the create, list and revoke faces answered `401` with a sentence of their own ("Sign in to list share links") and no `error.httpStatus`. + +What the standalone server answers now: + +- **Anonymous caller.** `401 UNAUTHENTICATED`, `{ success: false, error: { code: 'UNAUTHENTICATED', message: 'Authentication is required to access this endpoint.', httpStatus: 401 } }`, on every verb of the three notifications paths, and on every verb and path under `/api/v1/share-links` except `GET /share-links/:token/resolve` and `GET /share-links/:token/messages`, which the capability token still admits. A body the server cannot parse does not outrank this answer anywhere under `/api/v1/share-links`, nor on the notifications verbs no face serves. +- **Signed-in caller of a verb or path that is not served.** Still refused. On the notifications paths the domain answers it: `404 ROUTE_NOT_FOUND` from the dispatcher (or the `501` capability answer on a deployment with no inbox), where the adapter used to answer `405`. Under `/api/v1/share-links` the share-link routes answer it themselves: `404 ENDPOINT_NOT_FOUND`, naming the request, where the adapter used to answer `405` or `404`. No `Allow` header is sent on either. + +Every request that was served is served as before. A notifications sub-path no face names, such as `/api/v1/notifications/`, is still not mounted on the standalone server and is answered `404`. diff --git a/packages/plugins/plugin-sharing/src/share-link-routes.anonymous-floor.pin.test.ts b/packages/plugins/plugin-sharing/src/share-link-routes.anonymous-floor.pin.test.ts new file mode 100644 index 00000000000..dd2896536d2 --- /dev/null +++ b/packages/plugins/plugin-sharing/src/share-link-routes.anonymous-floor.pin.test.ts @@ -0,0 +1,217 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * ADR-0138 D2 — the share-link family answers an anonymous caller with the + * platform's shared `401 UNAUTHENTICATED`, in the dispatcher's standard + * envelope, on every route but the two token routes, before any of the family's + * own logic runs (#24024). + * + * ## What was open + * + * The published v18 text (changeset `23225-domain-floor-anonymous-deny`) says + * "`/share-links`: `401 UNAUTHENTICATED` on the create, list and revoke faces + * and on every path the domain does not serve", and that the refusal is "the + * dispatcher's standard envelope … with the shared message every other + * dispatcher domain answers with". On a standalone server this family is the + * `/share-links` surface, and it kept neither half: create, list and revoke + * each answered 401 with a sentence of their own and no `error.httpStatus`, + * and no route answered a verb or path the five did not serve, so the adapter + * did (405 or 404). The decision now sits in the family's door, once, and a + * per-verb catch-all registered last puts every other request in front of it. + * + * ## The census comes from the registration, not from a list typed here + * + * Every route is captured off a mock `IHttpServer` as `registerShareLinkRoutes` + * registers it, and every captured route is driven. A route added to the family + * later is driven anonymously the day it lands: through the door it answers 401 + * and this file stays green; mounted on `http` directly, past the door, it + * reaches the service and this file goes red. + * + * ## What each refusal asserts + * + * The ADR-0112 envelope — `status`, `error.code` and `error.httpStatus` — the + * shared message (the published text names it, so its identity is the + * contract: the exported constant, never a copy), no `data`, and a service and + * engine that were never called. A bare `toThrow()` would be no pin: the + * family never threw, it ANSWERED. + * + * ## Controls + * + * A floor that refused everybody would pass every refusal here. So the two + * token routes are driven anonymously and reach the token, and every route is + * driven signed in: the serving routes are served as before, and the catch-all + * answers the family's own 404 without reaching anything. + * + * Which router registration serves which concrete request is the router's + * question, not this file's: the wire half is + * `runtime/src/share-links-every-path.hono.integration.test.ts`. + */ + +import { describe, expect, it, vi } from 'vitest'; +import { ANONYMOUS_DENY_CODE, ANONYMOUS_DENY_MESSAGE, ANONYMOUS_DENY_STATUS } from '@objectstack/core'; +import type { IHttpServer, IHttpRequest, IHttpResponse, RouteHandler } from '@objectstack/spec/contracts'; +import type { ExecutionContext } from '@objectstack/spec/kernel'; +import { registerShareLinkRoutes } from './share-link-routes.js'; +import type { ShareLinkService } from './share-link-service.js'; +import type { SharingEngine } from './sharing-service.js'; + +const BASE = '/api/v1/share-links'; +const TOKEN_ROUTES = new Set([`GET ${BASE}/:token/resolve`, `GET ${BASE}/:token/messages`]); + +class MockHttp implements IHttpServer { + routes = new Map(); + private add(method: string, path: string, handler: RouteHandler) { + this.routes.set(`${method} ${path}`, handler); + } + get(path: string, h: RouteHandler) { this.add('GET', path, h); return this as any; } + post(path: string, h: RouteHandler) { this.add('POST', path, h); return this as any; } + put(path: string, h: RouteHandler) { this.add('PUT', path, h); return this as any; } + delete(path: string, h: RouteHandler) { this.add('DELETE', path, h); return this as any; } + patch(path: string, h: RouteHandler) { this.add('PATCH', path, h); return this as any; } + use() { return this as any; } + listen() { return Promise.resolve(); } + close() { return Promise.resolve(); } + getInstance() { return null; } +} + +const LINK_ROW = { + id: 'sl_1', + token: 'tok_abcdefgh', + object_name: 'ai_conversations', + record_id: 'conv_1', + permission: 'view', + audience: 'anyone', + expires_at: null, + label: null, + created_at: '2026-01-01T00:00:00.000Z', +}; + +/** A service and engine that would SUCCEED on every route, recording every call. */ +function mount(ctx: ExecutionContext) { + const service = { + createLink: vi.fn(async () => ({ ...LINK_ROW })), + listLinks: vi.fn(async () => [{ ...LINK_ROW }]), + revokeLink: vi.fn(async () => undefined), + resolveToken: vi.fn(async () => ({ link: { ...LINK_ROW }, redactFields: [] })), + }; + const engine = { + find: vi.fn(async () => [{ id: 'conv_1' }]), + getSchema: vi.fn(() => undefined), + insert: vi.fn(), update: vi.fn(), delete: vi.fn(), + }; + const http = new MockHttp(); + registerShareLinkRoutes(http, service as unknown as ShareLinkService, engine as unknown as SharingEngine, { + contextFromRequest: () => ctx, + }); + const calls = () => [ + ...Object.entries(service).filter(([, fn]) => fn.mock.calls.length > 0).map(([n]) => `service.${n}`), + ...(engine.find.mock.calls.length > 0 ? ['engine.find'] : []), + ]; + return { http, calls }; +} + +/** A concrete request for each registered pattern. */ +function requestFor(key: string): IHttpRequest { + const method = key.slice(0, key.indexOf(' ')); + const pattern = key.slice(key.indexOf(' ') + 1); + const params: Record = {}; + let path = pattern; + if (path.includes(':idOrToken')) { + params.idOrToken = 'sl_1'; + path = path.replace(':idOrToken', 'sl_1'); + } + if (path.includes(':token')) { + params.token = 'tok_abcdefgh'; + path = path.replace(':token', 'tok_abcdefgh'); + } + path = path.replace('*', 'abc/def'); + return { + params, + query: {}, + body: method === 'POST' ? { object: 'ai_conversations', recordId: 'conv_1' } : undefined, + headers: {}, + method, + path, + } as unknown as IHttpRequest; +} + +async function drive(http: MockHttp, key: string) { + const handler = http.routes.get(key); + if (!handler) throw new Error(`no handler for ${key}`); + const captured: { status: number; body: any } = { status: 200, body: undefined }; + const res: IHttpResponse = { + json: vi.fn((data: any) => { captured.body = data; }) as any, + send: vi.fn() as any, + status: vi.fn((code: number) => { captured.status = code; return res; }) as any, + header: vi.fn(() => res) as any, + }; + await handler(requestFor(key), res); + return captured; +} + +const census = [...mount({}).http.routes.keys()]; +const floored = census.filter((key) => !TOKEN_ROUTES.has(key)); + +describe('the share-link census — read off the registration', () => { + it('registers the five serving routes and one catch-all per verb, registered last', () => { + expect(census).toEqual([ + `POST ${BASE}`, + `GET ${BASE}`, + `DELETE ${BASE}/:idOrToken`, + `GET ${BASE}/:token/resolve`, + `GET ${BASE}/:token/messages`, + `GET ${BASE}/*`, + `POST ${BASE}/*`, + `PUT ${BASE}/*`, + `PATCH ${BASE}/*`, + `DELETE ${BASE}/*`, + ]); + }); +}); + +describe('ADR-0138 D2 floor — an anonymous caller is refused 401 on every route but the token routes', () => { + // Both anonymous shapes the plugin's resolver can hand the door: nothing at + // all (an unresolvable request), and an envelope with no user in it. + const ANONYMOUS: Array<[string, ExecutionContext]> = [ + ['an empty context', {}], + ['a context with no user', { isSystem: false, tenantId: 't_1' }], + ]; + for (const [name, ctx] of ANONYMOUS) { + it.each(floored)(`%s · ${name} → 401 in the dispatcher's envelope, nothing reached`, async (key) => { + const { http, calls } = mount(ctx); + const answer = await drive(http, key); + expect(answer.status).toBe(ANONYMOUS_DENY_STATUS); + expect(answer.body?.success).toBe(false); + expect(answer.body?.error?.code).toBe(ANONYMOUS_DENY_CODE); + expect(answer.body?.error?.httpStatus).toBe(ANONYMOUS_DENY_STATUS); + expect(answer.body?.error?.message).toBe(ANONYMOUS_DENY_MESSAGE); + expect(answer.body?.data).toBeUndefined(); + expect(calls()).toEqual([]); + }); + } + + it.each([...TOKEN_ROUTES])('%s · anonymous → admitted by the floor and judged by the token', async (key) => { + const { http, calls } = mount({}); + const answer = await drive(http, key); + expect(answer.status).toBe(200); + expect(answer.body?.success).toBe(true); + expect(calls()).toContain('service.resolveToken'); + }); +}); + +describe('CONTROL — a signed-in caller is not refused by the floor', () => { + it.each(census)('%s · signed in → served, or the catch-all\'s own 404', async (key) => { + const { http, calls } = mount({ userId: 'u_1', tenantId: 't_1' }); + const answer = await drive(http, key); + if (key.endsWith('/*')) { + expect(answer.status).toBe(404); + expect(answer.body?.success).toBe(false); + expect(answer.body?.error?.code).toBe('ENDPOINT_NOT_FOUND'); + expect(calls()).toEqual([]); + return; + } + expect(answer.status).toBe(key === `POST ${BASE}` ? 201 : 200); + expect(answer.body?.success).toBe(true); + expect(calls().length).toBeGreaterThan(0); + }); +}); diff --git a/packages/plugins/plugin-sharing/src/share-link-routes.auth-gate.pin.test.ts b/packages/plugins/plugin-sharing/src/share-link-routes.auth-gate.pin.test.ts index 30e2c5bb8e7..0bad6ae7ee9 100644 --- a/packages/plugins/plugin-sharing/src/share-link-routes.auth-gate.pin.test.ts +++ b/packages/plugins/plugin-sharing/src/share-link-routes.auth-gate.pin.test.ts @@ -34,8 +34,9 @@ * * ## Controls * - * The same caller un-held is served every route as before, and the family - * registers nothing a CORS preflight could reach. + * The same caller un-held is served every route as before (the refusal-only + * catch-all answering its own 404, #24024), and the family registers nothing a + * CORS preflight could reach. */ import { describe, expect, it, vi } from 'vitest'; @@ -110,13 +111,24 @@ function mount(contextFromRequest: (req: IHttpRequest) => ExecutionContext | Pro return { http, calls }; } -/** One case per route: the request, and what an un-held signed-in caller gets. */ -const CASES: Record; body?: unknown; okStatus: number; publicRoute?: boolean }> = { +/** + * One case per route: the request, and what an un-held signed-in caller gets. + * `refusalOnly` marks the catch-all (#24024), which serves nothing: its + * un-held signed-in answer is the family's 404, and it reaches nothing. + */ +const CASES: Record; body?: unknown; okStatus: number; publicRoute?: boolean; refusalOnly?: boolean; +}> = { [`POST ${BASE}`]: { body: { object: 'ai_conversations', recordId: 'conv_1' }, okStatus: 201 }, [`GET ${BASE}`]: { okStatus: 200 }, [`DELETE ${BASE}/:idOrToken`]: { params: { idOrToken: 'sl_1' }, okStatus: 200 }, [`GET ${BASE}/:token/resolve`]: { params: { token: 'tok_abcdefgh' }, okStatus: 200, publicRoute: true }, [`GET ${BASE}/:token/messages`]: { params: { token: 'tok_abcdefgh' }, okStatus: 200, publicRoute: true }, + [`GET ${BASE}/*`]: { okStatus: 404, refusalOnly: true }, + [`POST ${BASE}/*`]: { okStatus: 404, refusalOnly: true }, + [`PUT ${BASE}/*`]: { okStatus: 404, refusalOnly: true }, + [`PATCH ${BASE}/*`]: { okStatus: 404, refusalOnly: true }, + [`DELETE ${BASE}/*`]: { okStatus: 404, refusalOnly: true }, }; async function drive(http: MockHttp, key: string) { @@ -145,10 +157,11 @@ async function drive(http: MockHttp, key: string) { const census = [...mount(() => ({})).http.routes.keys()]; describe('the share-link census — read off the registration', () => { - it('registers exactly five routes, every one has a case below, and no middleware or preflight route', () => { + it('registers exactly ten routes, every one has a case below, and no middleware or preflight route', () => { const { http } = mount(() => ({})); - // ⭐ A count, on purpose: a sixth route has to come through this file. - expect(census).toHaveLength(5); + // ⭐ A count, on purpose: an eleventh route has to come through this file. + // Five serve; five are the per-verb catch-all (#24024). + expect(census).toHaveLength(10); expect([...census].sort()).toEqual(Object.keys(CASES).sort()); // Nothing here a CORS preflight could reach: no OPTIONS route and no // middleware, so a preflight is the host's answer, held or not. @@ -182,6 +195,13 @@ describe('CONTROL — the same caller un-held is served as before', () => { const { http, calls } = mount(() => ({ userId: 'u_1', tenantId: 't_1' })); const answer = await drive(http, key); expect(answer.status).toBe(CASES[key]!.okStatus); + if (CASES[key]!.refusalOnly) { + // The catch-all serves nothing, held or not: its answer is the family's 404. + expect(answer.body?.success).toBe(false); + expect(answer.body?.error?.code).toBe('ENDPOINT_NOT_FOUND'); + expect(calls()).toEqual([]); + return; + } expect(answer.body?.success).toBe(true); expect(calls().length).toBeGreaterThan(0); }); diff --git a/packages/plugins/plugin-sharing/src/share-link-routes.ts b/packages/plugins/plugin-sharing/src/share-link-routes.ts index a76078966f9..f4d24ce9611 100644 --- a/packages/plugins/plugin-sharing/src/share-link-routes.ts +++ b/packages/plugins/plugin-sharing/src/share-link-routes.ts @@ -9,6 +9,8 @@ * DELETE /api/v1/share-links/:idOrToken → revoke → data: { ok: true } * GET /api/v1/share-links/:token/resolve → resolve token → data: { record, link, redactFields } * GET /api/v1/share-links/:token/messages → conversation rows → data: ai_messages[] + * * /api/v1/share-links/* → every other verb and path: refused + * (anonymous 401, signed-in 404) * * Every body is the declared `{ success: true, data }` / `{ success: false, * error: { code, message } }` envelope, written by the shared `sendOk` / @@ -31,6 +33,12 @@ */ import type { IHttpServer, IHttpRequest, IHttpResponse, RouteHandler } from '@objectstack/spec/contracts'; +// ADR-0138 D2 — the platform's one anonymous decision and its one refusal: the +// same predicate and the same three constants every dispatcher domain answers +// with, so this family's 401 is the dispatcher twin's byte for byte. +import { + shouldDenyAnonymous, ANONYMOUS_DENY_STATUS, ANONYMOUS_DENY_CODE, ANONYMOUS_DENY_MESSAGE, +} from '@objectstack/core'; // The declared envelope is written in ONE place for the whole platform (#3973). // [#23174] The door's catch-all fault answers through `sendServerFault`: the // route's `failure` text on the wire, the caught error with its stack in the @@ -74,7 +82,7 @@ export interface ShareLinkRoutesOptions { * those verdicts: `accessible_org_ids` is the `group`-posture Layer 0 wall * (ADR-0105 D2) and denies when absent. The routes' OWN decision — is this * request authenticated at all? — is the only thing they read off it - * themselves, via {@link isAuthenticated}. + * themselves, via {@link refuseAnonymous}. */ contextFromRequest?: (req: IHttpRequest) => ExecutionContext | Promise; } @@ -85,18 +93,36 @@ export interface ShareLinkRoutesOptions { const defaultContext = (_req: IHttpRequest): ExecutionContext => ({}); /** - * [commit 8e13ca876, full-envelope ruling] The routes' own 401 gate — authenticated vs anonymous, and nothing - * more. + * ADR-0138 D2 — the family's anonymous floor. Returns `true` when the refusal + * was answered and the request must stop. * - * Typed to {@link ShareLinkExecutionContext} deliberately: that is the shape - * the contract retains for exactly this decision, and narrowing HERE (at the - * read) rather than at the resolver (at the production site) is the whole point - * of the ruling. The gate reads no authorization dimension, so it needs no - * authorization envelope — while the object the routes hand on to the service - * stays the complete one. + * The decision is the platform's shared one (`shouldDenyAnonymous`), asked + * about the user id alone, as the dispatcher twin (`runtime/src/domains/ + * share-links.ts`) asks it: a link is managed BY a user, so no system-context + * arm is read. The method rides along for the preflight pass; the request PATH + * deliberately does not, since the family's base path is the host's option and + * a base moved under an allow-listed prefix must not inherit an exemption this + * family never declared. + * + * The answer is the twin's too, byte for byte: the dispatcher's standard + * envelope, `{ success: false, error: { code: 'UNAUTHENTICATED', message, + * httpStatus: 401 } }`, with the shared message every dispatcher domain answers + * with. This family used to answer each management face with its own sentence + * ("Sign in to list share links") and no `httpStatus`, so the same anonymous + * request read differently depending on which of the two mounts served it. + * + * [commit 8e13ca876, full-envelope ruling] Authenticated vs anonymous, and + * nothing more. Typed to {@link ShareLinkExecutionContext} deliberately: that + * is the shape the contract retains for exactly this decision, and narrowing + * HERE (at the read) rather than at the resolver (at the production site) is + * the whole point of the ruling. The gate reads no authorization dimension, so + * it needs no authorization envelope — while the object the routes hand on to + * the service stays the complete one. */ -function isAuthenticated(ctx: ShareLinkExecutionContext): boolean { - return Boolean(ctx.userId); +function refuseAnonymous(res: IHttpResponse, method: string | undefined, ctx: ShareLinkExecutionContext): boolean { + if (!shouldDenyAnonymous({ userId: ctx.userId, method })) return false; + sendError(res, ANONYMOUS_DENY_STATUS, ANONYMOUS_DENY_CODE, ANONYMOUS_DENY_MESSAGE, { httpStatus: ANONYMOUS_DENY_STATUS }); + return true; } /** @@ -139,7 +165,9 @@ function refuseHeldSession(res: IHttpResponse, ctx: ExecutionContext): boolean { * writes for a throw — by the context resolver or by `serve` — that declares * no status of its own, in place of the caught error's text (#23174). * - `publicRoute` marks a token route: its no-store response headers are set - * on every outcome, the door's own refusal included. + * on every outcome, the door's own refusal included, and it is the one kind + * of route the door's anonymous floor ({@link refuseAnonymous}) admits — the + * capability token is what authorizes it (ADR-0138 D2 class 2). */ interface ShareLinkFamilyRoute { serve(req: IHttpRequest, res: IHttpResponse, ctx: ExecutionContext): Promise; @@ -272,19 +300,30 @@ export function registerShareLinkRoutes( * * In order, for every request: the token routes' response headers; the * caller's context (`contextFromRequest`); the held-session refusal - * ({@link refuseHeldSession}); then the route's own work with that context. - * A throw from any of them is answered by the route's own `catch` arm, as - * before — an authorization-store outage the resolver raises keeps its - * declared status and code. A throw that declares no status answers - * `500 INTERNAL` with the route's `failure` text, never its own text or - * code (#23174). + * ({@link refuseHeldSession}); the anonymous floor ({@link refuseAnonymous}) + * on every route but the two token routes; then the route's own work with + * that context. A throw from any of them is answered by the route's own + * `catch` arm, as before — an authorization-store outage the resolver raises + * keeps its declared status and code. A throw that declares no status + * answers `500 INTERNAL` with the route's `failure` text, never its own text + * or code (#23174). + * + * [#24024] The anonymous decision is the DOOR's, taken once for the family + * (ADR-0138 D2: once per domain, never face by face). It used to be three + * per-face checks inside create, list and revoke, each with its own + * sentence, and a route added later had to remember its own. */ - const mountFamilyRoute = (verb: 'get' | 'post' | 'delete', path: string, route: ShareLinkFamilyRoute): void => { + const mountFamilyRoute = ( + verb: 'get' | 'post' | 'put' | 'patch' | 'delete', + path: string, + route: ShareLinkFamilyRoute, + ): void => { http[verb](path, (async (req, res) => { if (route.publicRoute) setPublicResponseHeaders(res); try { const ctx = await ctxOf(req); if (refuseHeldSession(res, ctx)) return; + if (!route.publicRoute && refuseAnonymous(res, req.method, ctx)) return; await route.serve(req, res, ctx); } catch (err: any) { // [#23174] A throw that declares its own status keeps the envelope its @@ -314,8 +353,8 @@ export function registerShareLinkRoutes( // ── CREATE ───────────────────────────────────────────────────── mountFamilyRoute('post', base, { async serve(req, res, ctx) { - // [Finding-2] Managing links requires a verified principal. - if (!isAuthenticated(ctx)) return sendError(res, 401, 'UNAUTHENTICATED', 'Sign in to create share links'); + // [Finding-2] Managing links requires a verified principal: the door's + // anonymous floor refused every caller without one before this ran. const body: any = req.body ?? {}; if (!body.object || !body.recordId) { return sendError(res, 400, 'VALIDATION_FAILED', 'object and recordId are required'); @@ -346,7 +385,6 @@ export function registerShareLinkRoutes( // ── LIST ─────────────────────────────────────────────────────── mountFamilyRoute('get', base, { async serve(req, res, ctx) { - if (!isAuthenticated(ctx)) return sendError(res, 401, 'UNAUTHENTICATED', 'Sign in to list share links'); const q = req.query ?? {}; const links = await service.listLinks( { @@ -367,7 +405,6 @@ export function registerShareLinkRoutes( // ── REVOKE ───────────────────────────────────────────────────── mountFamilyRoute('delete', `${base}/:idOrToken`, { async serve(req, res, ctx) { - if (!isAuthenticated(ctx)) return sendError(res, 401, 'UNAUTHENTICATED', 'Sign in to revoke share links'); await service.revokeLink(req.params.idOrToken, ctx); // `{ ok: true }` moves from BEING the body to being its `data`. It was a // second word for `success` at the top level (#3689 retired that from @@ -592,4 +629,36 @@ export function registerShareLinkRoutes( }, failure: 'Failed to load messages', }); + + // ── EVERY OTHER VERB AND PATH ───────────────────────────────── + // + // [#24024] The published v18 text (changeset + // `23225-domain-floor-anonymous-deny`) answers an anonymous caller `401 + // UNAUTHENTICATED` "on the create, list and revoke faces and on every path the + // domain does not serve". The dispatcher twin keeps that by matching the whole + // `/share-links` segment. On a standalone server THIS family is the surface, + // and with only the five routes above registered, every other verb or path + // matched no route: the adapter answered first, `405` naming the allowed + // methods or `404`, to an anonymous caller as well. + // + // So one catch-all per verb, mounted through the same door and registered + // LAST: the router hands a request to the first registration that matches, so + // the five routes above keep every request they serve (the two token routes + // included), and the catch-all takes the rest, the bare base path under the + // three verbs it does not serve among them. The door refuses an anonymous + // caller before `serve` runs; a signed-in caller is told no route serves the + // request, with the status its twin answers (`404`), from this family rather + // than from the adapter. + // + // The body is never read, so a body the transport cannot parse costs nobody + // a `400` ahead of the door's answer. + const notServed: ShareLinkFamilyRoute = { + async serve(req, res) { + sendError(res, 404, 'ENDPOINT_NOT_FOUND', `No share-link route serves ${req.method} ${req.path}`); + }, + failure: 'Failed to answer the request', + }; + for (const verb of ['get', 'post', 'put', 'patch', 'delete'] as const) { + mountFamilyRoute(verb, `${base}/*`, notServed); + } } diff --git a/packages/qa/dogfood/test/authz-conformance.matrix.ts b/packages/qa/dogfood/test/authz-conformance.matrix.ts index c5d3442b8a7..8eea89ecacd 100644 --- a/packages/qa/dogfood/test/authz-conformance.matrix.ts +++ b/packages/qa/dogfood/test/authz-conformance.matrix.ts @@ -25,7 +25,7 @@ // dispatcher domain files. // // The population comes from `packages/rest/src/rest-route-ledger.ts` (81 rows -// / 18 families) and `packages/runtime/src/route-ledger.ts` (92 rows / 23 +// / 18 families) and `packages/runtime/src/route-ledger.ts` (105 rows / 23 // domains) because those two are enumerated from a RUNNING server and guarded // in both directions by their own conformance tests — so a new family or // domain cannot be silently absent from them, and therefore cannot be silently diff --git a/packages/qa/dogfood/test/authz-probe-blind-spot.census.ts b/packages/qa/dogfood/test/authz-probe-blind-spot.census.ts index 17d5f978ccd..67275d6ccc4 100644 --- a/packages/qa/dogfood/test/authz-probe-blind-spot.census.ts +++ b/packages/qa/dogfood/test/authz-probe-blind-spot.census.ts @@ -106,7 +106,7 @@ // `RestServer.getRoutes()` on a booted server and guarded per route by // `rest-route-ledger.conformance.test.ts`. It reaches all 17 registrars; // this table reaches 1. -// `packages/runtime/src/route-ledger.ts`: 92 rows over 23 domains. Its +// `packages/runtime/src/route-ledger.ts`: 105 rows over 23 domains. Its // machine contract is DOMAIN-level, by live registry introspection // (`domainRegistry.list()`), the per-route rows being documentation. It // covers all 15 `async handle*(` methods in `http-dispatcher.ts` and all @@ -477,11 +477,19 @@ export const PROBE_FILE_CENSUS: readonly ProbeFileReading[] = [ // `keys` stays 23 (23 distinct domains before and after, re-derived). // Controls moved with the rows ("route: '" and "domain: '" 88 -> 92, // RouteLedgerEntry unchanged at 2). - population: 92, - reachable: 92, + // [#24024] 92 -> 105: twelve refusal-only `/notifications` rows (the four + // verbs each of the three faces does not serve) and the `* /share-links/**` + // catch-all row arrived with their mounts (an anonymous caller is answered + // 401 on the standalone server). They carry `domain: '/notifications'` and + // `domain: '/share-links'`, EXISTING keys, so `reachable` moves with + // `population`, `blindSpot` stays 0 and `keys` stays 23 (23 distinct + // domains before and after, re-derived). Controls moved with the rows + // ("route: '" and "domain: '" 92 -> 105, RouteLedgerEntry unchanged at 2). + population: 105, + reachable: 105, blindSpot: 0, populationRule: 'ledger rows inside ROUTE_LEDGER; reachable = rows carrying a `domain` (each distinct value mints a key)', - controls: { "route: '": 92, "domain: '": 92, RouteLedgerEntry: 2 }, + controls: { "route: '": 105, "domain: '": 105, RouteLedgerEntry: 2 }, // [#22432] Re-derived on the merged ref: the note's two counts had stood at // 11 files / 5 classified / 16 baselined since before the `/analytics` // domain was classified (#21061), which named one more file and moved one diff --git a/packages/runtime/src/dispatcher-plugin.routes.test.ts b/packages/runtime/src/dispatcher-plugin.routes.test.ts index 9209829eca0..dcda7fd2ec1 100644 --- a/packages/runtime/src/dispatcher-plugin.routes.test.ts +++ b/packages/runtime/src/dispatcher-plugin.routes.test.ts @@ -133,6 +133,24 @@ describe('createDispatcherPlugin — HTTP route registration', () => { expect(routes).toContain('POST /api/v1/notifications/read/all'); }); + // Each notifications face carries all five verbs (#24024): it serves one, but + // the v18 contract answers an anonymous caller 401 on EVERY face, and the + // domain's floor can only answer a verb that is mounted. No sub-path beyond + // the three faces is mounted. The wire answer is pinned in + // `notifications-every-verb.hono.integration.test.ts`. + it('mounts every verb on each of the three /notifications faces, and no sub-path beyond them', async () => { + const { server, routes } = makeFakeServer(); + const plugin = createDispatcherPlugin({ prefix: '/api/v1', securityHeaders: false }); + await plugin.start?.(makeCtx(server)); + + for (const face of ['/notifications', '/notifications/read', '/notifications/read/all']) { + for (const verb of ['GET', 'POST', 'PUT', 'PATCH', 'DELETE']) { + expect(routes.filter((r) => r === `${verb} /api/v1${face}`), `${verb} ${face}`).toHaveLength(1); + } + } + expect(routes.filter((r) => r.includes('/notifications'))).toHaveLength(15); + }); + it('also mounts a known existing route (sanity that start() ran)', async () => { const { server, routes } = makeFakeServer(); const plugin = createDispatcherPlugin({ prefix: '/api/v1', securityHeaders: false }); diff --git a/packages/runtime/src/dispatcher-plugin.ts b/packages/runtime/src/dispatcher-plugin.ts index 05f460424d3..494637703be 100644 --- a/packages/runtime/src/dispatcher-plugin.ts +++ b/packages/runtime/src/dispatcher-plugin.ts @@ -1400,6 +1400,58 @@ export function createDispatcherPlugin(config: DispatcherPluginConfig = {}): Plu } }); + // [#24024] Each of the three faces is mounted for EVERY verb, though + // each serves one. The published v18 text is "`/notifications`: + // `401 UNAUTHENTICATED` on every face", and the domain keeps it as + // its first statement (`handleNotificationRequest`, ADR-0138 D2: the + // anonymous decision ahead of the inbox probe and of every face) — + // but a floor can only answer a verb that REACHES it. With the three + // served verbs as the only registrations, any other verb on these + // paths matched no route and the adapter's unmatched-route answer + // replied first: `405`, naming the allowed methods, to an anonymous + // caller as well. Same remedy as `/keys` above (#23909): register the + // verbs and the mismatch goes to the domain, which answers an + // anonymous caller 401, and a signed-in one what it answers on + // `createHonoApp`'s catch-all: the 501 capability answer on a + // deployment with no inbox, and otherwise the dispatcher's 404 for a + // route the domain does not serve. + // + // Exact paths only, like `/keys`: a sub-path no face names (say + // `/notifications/xyz`) is not mounted, and keeps the adapter's 404. + // No body is handed on: none of these verbs is a face, so the domain + // never reads one, and reading `req.body` here would answer a body + // the transport cannot parse with its `400` ahead of the floor's 401. + // The PUT and PATCH mounts are not wrapped by the per-route + // instrumentation (the observability Proxy wraps only + // get/post/delete), as the other PUT/PATCH mounts in this file are not. + const mountNotificationsVerb = ( + subPath: '/notifications' | '/notifications/read' | '/notifications/read/all', + method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE', + ) => { + const register = + method === 'GET' ? server.get + : method === 'POST' ? server.post + : method === 'PUT' ? server.put + : method === 'PATCH' ? server.patch + : server.delete; + register.call(server, `${prefix}${subPath}`, async (req: any, res: any) => { + try { + const result = await dispatcher.dispatch(method, subPath, undefined, req.query, { request: req }); + sendResult(result, res); + } catch (err: any) { + errorResponse(err, res); + } + }); + }; + for (const method of ['POST', 'PUT', 'PATCH', 'DELETE'] as const) { + mountNotificationsVerb('/notifications', method); + } + for (const subPath of ['/notifications/read', '/notifications/read/all'] as const) { + for (const method of ['GET', 'PUT', 'PATCH', 'DELETE'] as const) { + mountNotificationsVerb(subPath, method); + } + } + // ── Packages ──────────────────────────────────────────────── // Single pipeline (ADR-0076 D11 / OQ#9): every package route flows // through dispatch() — like analytics / i18n / automation / AI — diff --git a/packages/runtime/src/notifications-every-verb.hono.integration.test.ts b/packages/runtime/src/notifications-every-verb.hono.integration.test.ts new file mode 100644 index 00000000000..bfe497ea8c6 --- /dev/null +++ b/packages/runtime/src/notifications-every-verb.hono.integration.test.ts @@ -0,0 +1,174 @@ +// 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'; + +/** + * Every verb on each `/api/v1/notifications` face reaches the `notifications` + * domain, so its anonymous floor answers first (#24024) — over a real Hono + * socket. + * + * The published v18 text (changeset `23225-domain-floor-anonymous-deny`): + * "`/notifications`: `401 UNAUTHENTICATED` on every face". The domain keeps that + * promise as its first statement (`handleNotificationRequest`, ADR-0138 D2), + * ahead of the inbox probe and of every face. The standalone dispatcher did not: + * it mounted each face for the one verb it serves (`GET /notifications`, + * `POST /notifications/read`, `POST /notifications/read/all`), so any other verb + * matched no route and the adapter's `unmatchedResponse()` answered + * `405 METHOD_NOT_ALLOWED`, naming the allowed methods, to an anonymous caller. + * + * Which DOOR answered is read off the body and the headers, not the status + * alone, as in `keys-every-verb.hono.integration.test.ts`: the dispatcher's + * envelope fills `error.httpStatus` and carries no `error.details`; the + * adapter's 405 fills `error.details.{method,path,allowed}`, no `httpStatus`, + * and an `Allow` header. + * + * The signed-in rows are the control a floor that refused everybody would fail: + * the caller is still refused, by the DOMAIN, with what it answers on + * `createHonoApp`'s catch-all for a verb no face serves — `handled: false`, + * which the dispatcher plugin renders as its `404 ROUTE_NOT_FOUND`. The `auth` + * slot answers a session only when the request carries `x-test-session`, and a + * stub inbox fills the `notification` slot so the domain gets past its + * capability probe (without one a signed-in caller gets the 501 capability + * answer on every face, which the published text keeps). + */ + +const SESSION_HEADER = 'x-test-session'; +const FACES = ['/notifications', '/notifications/read', '/notifications/read/all'] as const; +/** The one verb each face serves. Every other verb is a new mount. */ +const SERVED: Record<(typeof FACES)[number], string> = { + '/notifications': 'GET', + '/notifications/read': 'POST', + '/notifications/read/all': 'POST', +}; +const VERBS = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'] as const; +const UNSERVED: Array<[string, string]> = FACES.flatMap((face) => + VERBS.filter((verb) => verb !== SERVED[face]).map((verb): [string, string] => [verb, face]), +); + +function fakeAuthAndInboxPlugin(): Plugin { + return { + name: 'com.objectstack.test.fake-auth-notifications-every-verb', + version: '1.0.0', + init: async (ctx: PluginContext) => { + ctx.registerService('auth', { + api: { + getSession: async ({ headers }: any) => + (headers?.get?.(SESSION_HEADER) ? { user: { id: 'u_notifications_verb' } } : undefined), + }, + }); + ctx.registerService('notification', { + send: async () => ({}), + sendBatch: async () => [], + listInbox: async () => ({ notifications: [], total: 0, unread: 0 }), + markRead: async () => ({ readCount: 0 }), + markAllRead: async () => ({ readCount: 0 }), + }); + }, + }; +} + +describe('every /api/v1/notifications verb reaches the notifications domain (integration, #24024)', () => { + let kernel: LiteKernel; + let baseUrl: string; + + beforeAll(async () => { + kernel = new LiteKernel(); + kernel.use(fakeAuthAndInboxPlugin()); + // port 0 → an OS-assigned free port, read back through getPort(). + kernel.use(new HonoServerPlugin({ port: 0, cors: false })); + kernel.use(createDispatcherPlugin({ prefix: '/api/v1', securityHeaders: false })); + await kernel.bootstrap(); + 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); + + /** Two body shapes can arrive on this path, so every check below is a runtime assertion on the wire. */ + async function call( + method: string, + face: string, + signedIn: boolean, + rawBody?: string, + ): Promise<{ res: Response; body: Record }> { + const hasBody = method === 'POST' || method === 'PUT' || method === 'PATCH'; + const res = await fetch(`${baseUrl}/api/v1${face}`, { + method, + headers: { + ...(hasBody ? { 'content-type': 'application/json' } : {}), + ...(signedIn ? { [SESSION_HEADER]: '1' } : {}), + }, + ...(hasBody ? { body: rawBody ?? JSON.stringify({ ids: ['n_1'] }) } : {}), + }); + return { res, body: (await res.json()) as Record }; + } + + // ── the defect: an anonymous unserved verb is refused 401, by the domain ── + for (const [method, face] of UNSERVED) { + it(`anonymous ${method} ${face} → 401 UNAUTHENTICATED in the dispatcher's envelope`, async () => { + const { res, body } = await call(method, face, false); + + expect(res.status).toBe(401); + expect(body.success).toBe(false); + expect(body.error.code).toBe('UNAUTHENTICATED'); + expect(body.error.httpStatus).toBe(401); + expect(body.error).not.toHaveProperty('details'); + expect(res.headers.get('allow')).toBeNull(); + }); + } + + // ── the mounts read no body, so an unparseable one cannot outrank the floor ── + it('anonymous PUT /notifications with a body the transport cannot parse → still the 401', async () => { + const { res, body } = await call('PUT', '/notifications', false, '{not json'); + + expect(res.status).toBe(401); + expect(body.error.code).toBe('UNAUTHENTICATED'); + expect(body.error.httpStatus).toBe(401); + }); + + // ── CONTROL: a signed-in unserved verb is still refused, by the DOMAIN ── + for (const [method, face] of UNSERVED) { + it(`signed-in ${method} ${face} → the dispatcher's 404 for a route the domain does not serve, not the adapter's 405`, async () => { + const { res, body } = await call(method, face, true); + + expect(res.status).toBe(404); + expect(body.success).toBe(false); + expect(body.error.code).toBe('ROUTE_NOT_FOUND'); + // The door: the dispatcher fills `httpStatus`; the adapter's 405 + // fills `details` and sends `Allow`. + expect(body.error.httpStatus).toBe(404); + expect(body.error).not.toHaveProperty('details'); + expect(res.headers.get('allow')).toBeNull(); + }); + } + + // ── control: the verb each face always served is answered as before ── + for (const face of FACES) { + it(`anonymous ${SERVED[face]} ${face} → 401 UNAUTHENTICATED (unchanged)`, async () => { + const { res, body } = await call(SERVED[face], face, false); + + expect(res.status).toBe(401); + expect(body.error.code).toBe('UNAUTHENTICATED'); + expect(body.error.httpStatus).toBe(401); + }); + + it(`signed-in ${SERVED[face]} ${face} → served (unchanged)`, async () => { + const { res, body } = await call(SERVED[face], face, true); + + expect(res.status).toBe(200); + expect(body.success).toBe(true); + }); + } +}); diff --git a/packages/runtime/src/route-ledger.ts b/packages/runtime/src/route-ledger.ts index 3414b0b7f71..5c6a4427ba7 100644 --- a/packages/runtime/src/route-ledger.ts +++ b/packages/runtime/src/route-ledger.ts @@ -277,7 +277,7 @@ export const NON_DISPATCH_MOUNT_PREFIXES = [ /** * The ledger. * - * CENSUS (generated): this list holds 92 rows. + * CENSUS (generated): this list holds 105 rows. * * ⛔ THAT NUMBER IS WRITTEN BY A TOOL — never by hand. * `pnpm check:route-ledger-census` counts the rows below and fails when the two @@ -340,6 +340,36 @@ export const ROUTE_LEDGER: readonly RouteLedgerEntry[] = [ { route: 'GET /notifications', domain: '/notifications', disposition: 'sdk', client: 'notifications.list' }, { route: 'POST /notifications/read', domain: '/notifications', disposition: 'sdk', client: 'notifications.markRead' }, { route: 'POST /notifications/read/all', domain: '/notifications', disposition: 'sdk', client: 'notifications.markAllRead' }, + // [#24024] Refusal-only mounts. Each face serves one verb, but the v18 text + // (changeset `23225-domain-floor-anonymous-deny`) answers an anonymous caller + // `401 UNAUTHENTICATED` on every face, and the domain's floor answers only a + // verb that is mounted. Exact rows, as for `/keys`, deliberately not a + // `* /notifications/**` family: no sub-path the three faces do not name is + // mounted on the standalone server. + { route: 'POST /notifications', domain: '/notifications', disposition: 'server-only', + note: 'refusal-only, mounted for the v18 every-face anonymous floor (changeset 23225): anonymous 401 UNAUTHENTICATED, signed-in the notifications domain\'s 404 (or its 501 with no inbox)' }, + { route: 'PUT /notifications', domain: '/notifications', disposition: 'server-only', + note: 'refusal-only, mounted for the v18 every-face anonymous floor (changeset 23225): anonymous 401 UNAUTHENTICATED, signed-in the notifications domain\'s 404 (or its 501 with no inbox)' }, + { route: 'PATCH /notifications', domain: '/notifications', disposition: 'server-only', + note: 'refusal-only, mounted for the v18 every-face anonymous floor (changeset 23225): anonymous 401 UNAUTHENTICATED, signed-in the notifications domain\'s 404 (or its 501 with no inbox)' }, + { route: 'DELETE /notifications', domain: '/notifications', disposition: 'server-only', + note: 'refusal-only, mounted for the v18 every-face anonymous floor (changeset 23225): anonymous 401 UNAUTHENTICATED, signed-in the notifications domain\'s 404 (or its 501 with no inbox)' }, + { route: 'GET /notifications/read', domain: '/notifications', disposition: 'server-only', + note: 'refusal-only, mounted for the v18 every-face anonymous floor (changeset 23225): anonymous 401 UNAUTHENTICATED, signed-in the notifications domain\'s 404 (or its 501 with no inbox)' }, + { route: 'PUT /notifications/read', domain: '/notifications', disposition: 'server-only', + note: 'refusal-only, mounted for the v18 every-face anonymous floor (changeset 23225): anonymous 401 UNAUTHENTICATED, signed-in the notifications domain\'s 404 (or its 501 with no inbox)' }, + { route: 'PATCH /notifications/read', domain: '/notifications', disposition: 'server-only', + note: 'refusal-only, mounted for the v18 every-face anonymous floor (changeset 23225): anonymous 401 UNAUTHENTICATED, signed-in the notifications domain\'s 404 (or its 501 with no inbox)' }, + { route: 'DELETE /notifications/read', domain: '/notifications', disposition: 'server-only', + note: 'refusal-only, mounted for the v18 every-face anonymous floor (changeset 23225): anonymous 401 UNAUTHENTICATED, signed-in the notifications domain\'s 404 (or its 501 with no inbox)' }, + { route: 'GET /notifications/read/all', domain: '/notifications', disposition: 'server-only', + note: 'refusal-only, mounted for the v18 every-face anonymous floor (changeset 23225): anonymous 401 UNAUTHENTICATED, signed-in the notifications domain\'s 404 (or its 501 with no inbox)' }, + { route: 'PUT /notifications/read/all', domain: '/notifications', disposition: 'server-only', + note: 'refusal-only, mounted for the v18 every-face anonymous floor (changeset 23225): anonymous 401 UNAUTHENTICATED, signed-in the notifications domain\'s 404 (or its 501 with no inbox)' }, + { route: 'PATCH /notifications/read/all', domain: '/notifications', disposition: 'server-only', + note: 'refusal-only, mounted for the v18 every-face anonymous floor (changeset 23225): anonymous 401 UNAUTHENTICATED, signed-in the notifications domain\'s 404 (or its 501 with no inbox)' }, + { route: 'DELETE /notifications/read/all', domain: '/notifications', disposition: 'server-only', + note: 'refusal-only, mounted for the v18 every-face anonymous floor (changeset 23225): anonymous 401 UNAUTHENTICATED, signed-in the notifications domain\'s 404 (or its 501 with no inbox)' }, // ── security (ADR-0090 suggested audience bindings) ─────────────────────── { route: 'GET /security/suggested-bindings', domain: '/security', disposition: 'sdk', client: 'security.suggestedBindings.list' }, @@ -421,6 +451,16 @@ export const ROUTE_LEDGER: readonly RouteLedgerEntry[] = [ { route: 'DELETE /share-links/:idOrToken', domain: '/share-links', disposition: 'sdk', client: 'shareLinks.revoke' }, { route: 'GET /share-links/:token/resolve', domain: '/share-links', disposition: 'public', note: 'unauthenticated token resolution for shared-record pages' }, { route: 'GET /share-links/:token/messages', domain: '/share-links', disposition: 'public', note: 'unauthenticated shared-conversation messages' }, + // [#24024] The refusal-only catch-all, a family row on purpose. The v18 text + // (changeset `23225-domain-floor-anonymous-deny`) answers an anonymous caller + // `401 UNAUTHENTICATED` "on every path the domain does not serve", which is a + // sub-path claim, not a verb list: the dispatcher domain matches the whole + // `/share-links` segment, and on the standalone server `plugin-sharing`'s + // `registerShareLinkRoutes` mounts `${base}/*` for every verb, after the five + // rows above, so the family's door answers every request under the base. The + // rows above stay exact and keep their own dispositions. + { route: '* /share-links/**', domain: '/share-links', disposition: 'server-only', + note: 'refusal-only catch-all for every verb and every path the five routes do not serve: anonymous 401 UNAUTHENTICATED, signed-in 404' }, // ── packages ────────────────────────────────────────────────────────────── // [#17431] The `/packages` READ rows carry a `responseSchema` again: the diff --git a/packages/runtime/src/share-links-every-path.hono.integration.test.ts b/packages/runtime/src/share-links-every-path.hono.integration.test.ts new file mode 100644 index 00000000000..422ebe3b2ba --- /dev/null +++ b/packages/runtime/src/share-links-every-path.hono.integration.test.ts @@ -0,0 +1,220 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { ANONYMOUS_DENY_MESSAGE, LiteKernel, Plugin, PluginContext } from '@objectstack/core'; +import { HonoServerPlugin } from '@objectstack/plugin-hono-server'; +import { registerShareLinkRoutes } from '@objectstack/plugin-sharing'; +import type { IHttpServer } from '@objectstack/spec/contracts'; + +import { createDispatcherPlugin } from './dispatcher-plugin.js'; + +/** + * On a standalone server every anonymous request under `/api/v1/share-links`, + * the two token faces apart, is answered `401 UNAUTHENTICATED` in the + * dispatcher's standard envelope with the shared message (#24024) — over a real + * Hono socket, composed as `os dev` composes it. + * + * The published v18 text (changeset `23225-domain-floor-anonymous-deny`): + * "`/share-links`: `401 UNAUTHENTICATED` on the create, list and revoke faces + * and on every path the domain does not serve", and "the refusal is the + * dispatcher's standard envelope … with the shared message every other + * dispatcher domain answers with". The dispatcher twin + * (`domains/share-links.ts`) keeps it, but on a standalone server it is never + * reached: the dispatcher mounts no `/share-links` route, and + * `plugin-sharing`'s `registerShareLinkRoutes` is the surface. That family + * registered its five routes and nothing else, so another verb or path matched + * no route and the adapter answered first (`405` naming the allowed methods, or + * `404`), and its own three management faces answered 401 with a sentence of + * their own and no `httpStatus`. + * + * The family is mounted here exactly as `SharingServicePlugin` mounts it: on the + * `http.server` slot at `kernel:ready`, after the dispatcher's `start()` has + * registered its routes. Its context resolver is reduced to the one thing these + * answers depend on, whether the request carries a session; the plugin's real + * resolver is pinned in plugin-sharing's own suites. + * + * Which DOOR answered is read off the body and headers, as in + * `keys-every-verb.hono.integration.test.ts`: the adapter's 405 fills + * `error.details` and an `Allow` header, its 404 is `ENDPOINT_NOT_FOUND` with + * the bare message `Not found`; the family's 404 names the request it does not + * serve. + */ + +const BASE = '/api/v1/share-links'; +const SESSION_HEADER = 'x-test-session'; + +/** Every request under the base that no token face serves. */ +const MANAGED_OR_UNSERVED: Array<[string, string]> = [ + // the three management faces (today's 401, with the family's own sentence) + ['GET', ''], + ['POST', ''], + ['DELETE', '/abc'], + // the base path under a verb it does not serve + ['PUT', ''], + ['PATCH', ''], + // one segment under a verb revoke does not take + ['GET', '/abc'], + ['POST', '/abc'], + ['PUT', '/abc'], + ['PATCH', '/abc'], + // the token faces' paths under a verb they do not take + ['POST', '/abc/resolve'], + ['DELETE', '/abc/resolve'], + ['PUT', '/abc/messages'], + ['DELETE', '/abc/messages'], + // paths nothing serves + ['GET', '/abc/def'], + ['GET', '/abc/def/ghi'], + ['POST', '/abc/def/ghi'], + ['DELETE', '/abc/def/ghi'], +]; +const SERVED = new Set(['GET ', 'POST ', 'DELETE /abc']); + +function fakeAuthPlugin(): Plugin { + return { + name: 'com.objectstack.test.fake-auth-share-links-every-path', + version: '1.0.0', + init: async (ctx: PluginContext) => { + ctx.registerService('auth', { + api: { + getSession: async ({ headers }: any) => + (headers?.get?.(SESSION_HEADER) ? { user: { id: 'u_share_links_path' } } : undefined), + }, + }); + }, + }; +} + +/** The share-link family, mounted the way `SharingServicePlugin` mounts it. */ +function shareLinkFamilyPlugin(): Plugin { + return { + name: 'com.objectstack.test.share-link-family', + version: '1.0.0', + init: async () => {}, + start: async (ctx: PluginContext) => { + ctx.hook('kernel:ready', async () => { + const http = ctx.getService('http.server'); + const service: any = { + createLink: async () => ({ id: 'sl_1', token: 'tok_1' }), + listLinks: async () => [], + revokeLink: async () => undefined, + resolveToken: async () => null, + }; + const engine: any = { find: async () => [], getSchema: () => undefined }; + registerShareLinkRoutes(http, service, engine, { + contextFromRequest: (req) => + (req.headers?.[SESSION_HEADER] ? { userId: 'u_share_links_path' } : {}), + }); + }); + }, + }; +} + +describe('every /api/v1/share-links verb and path reaches the share-link family door (integration, #24024)', () => { + let kernel: LiteKernel; + let baseUrl: string; + + beforeAll(async () => { + kernel = new LiteKernel(); + kernel.use(fakeAuthPlugin()); + // port 0 → an OS-assigned free port, read back through getPort(). + kernel.use(new HonoServerPlugin({ port: 0, cors: false })); + kernel.use(createDispatcherPlugin({ prefix: '/api/v1', securityHeaders: false })); + kernel.use(shareLinkFamilyPlugin()); + await kernel.bootstrap(); + 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); + + async function call( + method: string, + sub: string, + signedIn: boolean, + rawBody?: string, + ): Promise<{ res: Response; body: Record }> { + const hasBody = method === 'POST' || method === 'PUT' || method === 'PATCH'; + const res = await fetch(`${baseUrl}${BASE}${sub}`, { + method, + headers: { + ...(hasBody ? { 'content-type': 'application/json' } : {}), + ...(signedIn ? { [SESSION_HEADER]: '1' } : {}), + }, + ...(hasBody ? { body: rawBody ?? JSON.stringify({ object: 'crm_account', recordId: 'acc_1' }) } : {}), + }); + return { res, body: (await res.json()) as Record }; + } + + // ── the defect: one anonymous answer for every request off the token faces ── + for (const [method, sub] of MANAGED_OR_UNSERVED) { + it(`anonymous ${method} ${BASE}${sub} → 401 UNAUTHENTICATED, the dispatcher's envelope and message`, async () => { + const { res, body } = await call(method, sub, false); + + expect(res.status).toBe(401); + expect(body.success).toBe(false); + expect(body.error.code).toBe('UNAUTHENTICATED'); + expect(body.error.httpStatus).toBe(401); + // The published text names the shared message, so the identity of + // the sentence is the contract here: the exported constant, not a copy. + expect(body.error.message).toBe(ANONYMOUS_DENY_MESSAGE); + expect(body.error).not.toHaveProperty('details'); + expect(res.headers.get('allow')).toBeNull(); + }); + } + + it('anonymous PUT on the base with a body the transport cannot parse → still the 401', async () => { + const { res, body } = await call('PUT', '', false, '{not json'); + + expect(res.status).toBe(401); + expect(body.error.code).toBe('UNAUTHENTICATED'); + expect(body.error.httpStatus).toBe(401); + }); + + // ── control: the two token faces still admit an anonymous caller ── + it('anonymous GET /:token/resolve → judged by the token, not the floor (unchanged)', async () => { + const { res, body } = await call('GET', '/abc/resolve', false); + + expect(res.status).toBe(404); + expect(body.error.code).toBe('INVALID_OR_EXPIRED'); + expect(res.headers.get('cache-control')).toBe('no-store'); + }); + + it('anonymous GET /:token/messages → judged by the token, not the floor (unchanged)', async () => { + const { res, body } = await call('GET', '/abc/messages', false); + + expect(res.status).toBe(404); + expect(body.error.code).toBe('NOT_FOUND'); + expect(res.headers.get('cache-control')).toBe('no-store'); + }); + + // ── CONTROL: a signed-in caller of an unserved verb or path is still refused, by the FAMILY ── + for (const [method, sub] of MANAGED_OR_UNSERVED.filter(([m, s]) => !SERVED.has(`${m} ${s}`))) { + it(`signed-in ${method} ${BASE}${sub} → the family's 404, not the adapter's 405 or 404`, async () => { + const { res, body } = await call(method, sub, true); + + expect(res.status).toBe(404); + expect(body.success).toBe(false); + expect(body.error.code).toBe('ENDPOINT_NOT_FOUND'); + // The door: the family names the request; the adapter's 404 says + // `Not found` and its 405 fills `details` and sends `Allow`. + expect(body.error.message).toContain(`${method} ${BASE}${sub}`); + expect(body.error).not.toHaveProperty('details'); + expect(res.headers.get('allow')).toBeNull(); + }); + } + + // ── control: the management faces still serve a signed-in caller ── + it('signed-in POST, GET and DELETE are served as before', async () => { + expect((await call('POST', '', true)).res.status).toBe(201); + expect((await call('GET', '', true)).res.status).toBe(200); + expect((await call('DELETE', '/abc', true)).res.status).toBe(200); + }); +}); diff --git a/scripts/error-status-unpinned-baseline.json b/scripts/error-status-unpinned-baseline.json index 98a6cffd279..e1c58436c74 100644 --- a/scripts/error-status-unpinned-baseline.json +++ b/scripts/error-status-unpinned-baseline.json @@ -4,7 +4,6 @@ "CONCURRENT_MODIFICATION", "DUPLICATE_VALUE", "EMAIL_NOT_VERIFIED", - "ENDPOINT_NOT_FOUND", "EXPIRED_TOKEN", "FIELD_NOT_ACCESSIBLE", "FIELD_NOT_FOUND",