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
20 changes: 20 additions & 0 deletions .changeset/24024-notifications-share-links-anonymous-floor.md
Original file line number Diff line number Diff line change
@@ -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/<anything>`, is still not mounted on the standalone server and is answered `404`.
Original file line number Diff line number Diff line change
@@ -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<string, RouteHandler>();
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<string, string> = {};
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);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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<string, { params?: Record<string, string>; 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<string, {
params?: Record<string, string>; 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) {
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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);
});
Expand Down
Loading
Loading