Skip to content

Commit 4094dd4

Browse files
committed
Merge commit '6dede7a51' into claude/issue-24119-runner-facts
2 parents d765481 + 6dede7a commit 4094dd4

12 files changed

Lines changed: 874 additions & 37 deletions
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
---
2+
'@objectstack/runtime': patch
3+
'@objectstack/plugin-sharing': patch
4+
---
5+
6+
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
7+
8+
Clause-②: no
9+
10+
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:
11+
12+
- `/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.
13+
- `/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`.
14+
15+
What the standalone server answers now:
16+
17+
- **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.
18+
- **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.
19+
20+
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`.
Lines changed: 217 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,217 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* ADR-0138 D2 — the share-link family answers an anonymous caller with the
5+
* platform's shared `401 UNAUTHENTICATED`, in the dispatcher's standard
6+
* envelope, on every route but the two token routes, before any of the family's
7+
* own logic runs (#24024).
8+
*
9+
* ## What was open
10+
*
11+
* The published v18 text (changeset `23225-domain-floor-anonymous-deny`) says
12+
* "`/share-links`: `401 UNAUTHENTICATED` on the create, list and revoke faces
13+
* and on every path the domain does not serve", and that the refusal is "the
14+
* dispatcher's standard envelope … with the shared message every other
15+
* dispatcher domain answers with". On a standalone server this family is the
16+
* `/share-links` surface, and it kept neither half: create, list and revoke
17+
* each answered 401 with a sentence of their own and no `error.httpStatus`,
18+
* and no route answered a verb or path the five did not serve, so the adapter
19+
* did (405 or 404). The decision now sits in the family's door, once, and a
20+
* per-verb catch-all registered last puts every other request in front of it.
21+
*
22+
* ## The census comes from the registration, not from a list typed here
23+
*
24+
* Every route is captured off a mock `IHttpServer` as `registerShareLinkRoutes`
25+
* registers it, and every captured route is driven. A route added to the family
26+
* later is driven anonymously the day it lands: through the door it answers 401
27+
* and this file stays green; mounted on `http` directly, past the door, it
28+
* reaches the service and this file goes red.
29+
*
30+
* ## What each refusal asserts
31+
*
32+
* The ADR-0112 envelope — `status`, `error.code` and `error.httpStatus` — the
33+
* shared message (the published text names it, so its identity is the
34+
* contract: the exported constant, never a copy), no `data`, and a service and
35+
* engine that were never called. A bare `toThrow()` would be no pin: the
36+
* family never threw, it ANSWERED.
37+
*
38+
* ## Controls
39+
*
40+
* A floor that refused everybody would pass every refusal here. So the two
41+
* token routes are driven anonymously and reach the token, and every route is
42+
* driven signed in: the serving routes are served as before, and the catch-all
43+
* answers the family's own 404 without reaching anything.
44+
*
45+
* Which router registration serves which concrete request is the router's
46+
* question, not this file's: the wire half is
47+
* `runtime/src/share-links-every-path.hono.integration.test.ts`.
48+
*/
49+
50+
import { describe, expect, it, vi } from 'vitest';
51+
import { ANONYMOUS_DENY_CODE, ANONYMOUS_DENY_MESSAGE, ANONYMOUS_DENY_STATUS } from '@objectstack/core';
52+
import type { IHttpServer, IHttpRequest, IHttpResponse, RouteHandler } from '@objectstack/spec/contracts';
53+
import type { ExecutionContext } from '@objectstack/spec/kernel';
54+
import { registerShareLinkRoutes } from './share-link-routes.js';
55+
import type { ShareLinkService } from './share-link-service.js';
56+
import type { SharingEngine } from './sharing-service.js';
57+
58+
const BASE = '/api/v1/share-links';
59+
const TOKEN_ROUTES = new Set([`GET ${BASE}/:token/resolve`, `GET ${BASE}/:token/messages`]);
60+
61+
class MockHttp implements IHttpServer {
62+
routes = new Map<string, RouteHandler>();
63+
private add(method: string, path: string, handler: RouteHandler) {
64+
this.routes.set(`${method} ${path}`, handler);
65+
}
66+
get(path: string, h: RouteHandler) { this.add('GET', path, h); return this as any; }
67+
post(path: string, h: RouteHandler) { this.add('POST', path, h); return this as any; }
68+
put(path: string, h: RouteHandler) { this.add('PUT', path, h); return this as any; }
69+
delete(path: string, h: RouteHandler) { this.add('DELETE', path, h); return this as any; }
70+
patch(path: string, h: RouteHandler) { this.add('PATCH', path, h); return this as any; }
71+
use() { return this as any; }
72+
listen() { return Promise.resolve(); }
73+
close() { return Promise.resolve(); }
74+
getInstance() { return null; }
75+
}
76+
77+
const LINK_ROW = {
78+
id: 'sl_1',
79+
token: 'tok_abcdefgh',
80+
object_name: 'ai_conversations',
81+
record_id: 'conv_1',
82+
permission: 'view',
83+
audience: 'anyone',
84+
expires_at: null,
85+
label: null,
86+
created_at: '2026-01-01T00:00:00.000Z',
87+
};
88+
89+
/** A service and engine that would SUCCEED on every route, recording every call. */
90+
function mount(ctx: ExecutionContext) {
91+
const service = {
92+
createLink: vi.fn(async () => ({ ...LINK_ROW })),
93+
listLinks: vi.fn(async () => [{ ...LINK_ROW }]),
94+
revokeLink: vi.fn(async () => undefined),
95+
resolveToken: vi.fn(async () => ({ link: { ...LINK_ROW }, redactFields: [] })),
96+
};
97+
const engine = {
98+
find: vi.fn(async () => [{ id: 'conv_1' }]),
99+
getSchema: vi.fn(() => undefined),
100+
insert: vi.fn(), update: vi.fn(), delete: vi.fn(),
101+
};
102+
const http = new MockHttp();
103+
registerShareLinkRoutes(http, service as unknown as ShareLinkService, engine as unknown as SharingEngine, {
104+
contextFromRequest: () => ctx,
105+
});
106+
const calls = () => [
107+
...Object.entries(service).filter(([, fn]) => fn.mock.calls.length > 0).map(([n]) => `service.${n}`),
108+
...(engine.find.mock.calls.length > 0 ? ['engine.find'] : []),
109+
];
110+
return { http, calls };
111+
}
112+
113+
/** A concrete request for each registered pattern. */
114+
function requestFor(key: string): IHttpRequest {
115+
const method = key.slice(0, key.indexOf(' '));
116+
const pattern = key.slice(key.indexOf(' ') + 1);
117+
const params: Record<string, string> = {};
118+
let path = pattern;
119+
if (path.includes(':idOrToken')) {
120+
params.idOrToken = 'sl_1';
121+
path = path.replace(':idOrToken', 'sl_1');
122+
}
123+
if (path.includes(':token')) {
124+
params.token = 'tok_abcdefgh';
125+
path = path.replace(':token', 'tok_abcdefgh');
126+
}
127+
path = path.replace('*', 'abc/def');
128+
return {
129+
params,
130+
query: {},
131+
body: method === 'POST' ? { object: 'ai_conversations', recordId: 'conv_1' } : undefined,
132+
headers: {},
133+
method,
134+
path,
135+
} as unknown as IHttpRequest;
136+
}
137+
138+
async function drive(http: MockHttp, key: string) {
139+
const handler = http.routes.get(key);
140+
if (!handler) throw new Error(`no handler for ${key}`);
141+
const captured: { status: number; body: any } = { status: 200, body: undefined };
142+
const res: IHttpResponse = {
143+
json: vi.fn((data: any) => { captured.body = data; }) as any,
144+
send: vi.fn() as any,
145+
status: vi.fn((code: number) => { captured.status = code; return res; }) as any,
146+
header: vi.fn(() => res) as any,
147+
};
148+
await handler(requestFor(key), res);
149+
return captured;
150+
}
151+
152+
const census = [...mount({}).http.routes.keys()];
153+
const floored = census.filter((key) => !TOKEN_ROUTES.has(key));
154+
155+
describe('the share-link census — read off the registration', () => {
156+
it('registers the five serving routes and one catch-all per verb, registered last', () => {
157+
expect(census).toEqual([
158+
`POST ${BASE}`,
159+
`GET ${BASE}`,
160+
`DELETE ${BASE}/:idOrToken`,
161+
`GET ${BASE}/:token/resolve`,
162+
`GET ${BASE}/:token/messages`,
163+
`GET ${BASE}/*`,
164+
`POST ${BASE}/*`,
165+
`PUT ${BASE}/*`,
166+
`PATCH ${BASE}/*`,
167+
`DELETE ${BASE}/*`,
168+
]);
169+
});
170+
});
171+
172+
describe('ADR-0138 D2 floor — an anonymous caller is refused 401 on every route but the token routes', () => {
173+
// Both anonymous shapes the plugin's resolver can hand the door: nothing at
174+
// all (an unresolvable request), and an envelope with no user in it.
175+
const ANONYMOUS: Array<[string, ExecutionContext]> = [
176+
['an empty context', {}],
177+
['a context with no user', { isSystem: false, tenantId: 't_1' }],
178+
];
179+
for (const [name, ctx] of ANONYMOUS) {
180+
it.each(floored)(`%s · ${name} → 401 in the dispatcher's envelope, nothing reached`, async (key) => {
181+
const { http, calls } = mount(ctx);
182+
const answer = await drive(http, key);
183+
expect(answer.status).toBe(ANONYMOUS_DENY_STATUS);
184+
expect(answer.body?.success).toBe(false);
185+
expect(answer.body?.error?.code).toBe(ANONYMOUS_DENY_CODE);
186+
expect(answer.body?.error?.httpStatus).toBe(ANONYMOUS_DENY_STATUS);
187+
expect(answer.body?.error?.message).toBe(ANONYMOUS_DENY_MESSAGE);
188+
expect(answer.body?.data).toBeUndefined();
189+
expect(calls()).toEqual([]);
190+
});
191+
}
192+
193+
it.each([...TOKEN_ROUTES])('%s · anonymous → admitted by the floor and judged by the token', async (key) => {
194+
const { http, calls } = mount({});
195+
const answer = await drive(http, key);
196+
expect(answer.status).toBe(200);
197+
expect(answer.body?.success).toBe(true);
198+
expect(calls()).toContain('service.resolveToken');
199+
});
200+
});
201+
202+
describe('CONTROL — a signed-in caller is not refused by the floor', () => {
203+
it.each(census)('%s · signed in → served, or the catch-all\'s own 404', async (key) => {
204+
const { http, calls } = mount({ userId: 'u_1', tenantId: 't_1' });
205+
const answer = await drive(http, key);
206+
if (key.endsWith('/*')) {
207+
expect(answer.status).toBe(404);
208+
expect(answer.body?.success).toBe(false);
209+
expect(answer.body?.error?.code).toBe('ENDPOINT_NOT_FOUND');
210+
expect(calls()).toEqual([]);
211+
return;
212+
}
213+
expect(answer.status).toBe(key === `POST ${BASE}` ? 201 : 200);
214+
expect(answer.body?.success).toBe(true);
215+
expect(calls().length).toBeGreaterThan(0);
216+
});
217+
});

‎packages/plugins/plugin-sharing/src/share-link-routes.auth-gate.pin.test.ts‎

Lines changed: 27 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -34,8 +34,9 @@
3434
*
3535
* ## Controls
3636
*
37-
* The same caller un-held is served every route as before, and the family
38-
* registers nothing a CORS preflight could reach.
37+
* The same caller un-held is served every route as before (the refusal-only
38+
* catch-all answering its own 404, #24024), and the family registers nothing a
39+
* CORS preflight could reach.
3940
*/
4041

4142
import { describe, expect, it, vi } from 'vitest';
@@ -110,13 +111,24 @@ function mount(contextFromRequest: (req: IHttpRequest) => ExecutionContext | Pro
110111
return { http, calls };
111112
}
112113

113-
/** One case per route: the request, and what an un-held signed-in caller gets. */
114-
const CASES: Record<string, { params?: Record<string, string>; body?: unknown; okStatus: number; publicRoute?: boolean }> = {
114+
/**
115+
* One case per route: the request, and what an un-held signed-in caller gets.
116+
* `refusalOnly` marks the catch-all (#24024), which serves nothing: its
117+
* un-held signed-in answer is the family's 404, and it reaches nothing.
118+
*/
119+
const CASES: Record<string, {
120+
params?: Record<string, string>; body?: unknown; okStatus: number; publicRoute?: boolean; refusalOnly?: boolean;
121+
}> = {
115122
[`POST ${BASE}`]: { body: { object: 'ai_conversations', recordId: 'conv_1' }, okStatus: 201 },
116123
[`GET ${BASE}`]: { okStatus: 200 },
117124
[`DELETE ${BASE}/:idOrToken`]: { params: { idOrToken: 'sl_1' }, okStatus: 200 },
118125
[`GET ${BASE}/:token/resolve`]: { params: { token: 'tok_abcdefgh' }, okStatus: 200, publicRoute: true },
119126
[`GET ${BASE}/:token/messages`]: { params: { token: 'tok_abcdefgh' }, okStatus: 200, publicRoute: true },
127+
[`GET ${BASE}/*`]: { okStatus: 404, refusalOnly: true },
128+
[`POST ${BASE}/*`]: { okStatus: 404, refusalOnly: true },
129+
[`PUT ${BASE}/*`]: { okStatus: 404, refusalOnly: true },
130+
[`PATCH ${BASE}/*`]: { okStatus: 404, refusalOnly: true },
131+
[`DELETE ${BASE}/*`]: { okStatus: 404, refusalOnly: true },
120132
};
121133

122134
async function drive(http: MockHttp, key: string) {
@@ -145,10 +157,11 @@ async function drive(http: MockHttp, key: string) {
145157
const census = [...mount(() => ({})).http.routes.keys()];
146158

147159
describe('the share-link census — read off the registration', () => {
148-
it('registers exactly five routes, every one has a case below, and no middleware or preflight route', () => {
160+
it('registers exactly ten routes, every one has a case below, and no middleware or preflight route', () => {
149161
const { http } = mount(() => ({}));
150-
// ⭐ A count, on purpose: a sixth route has to come through this file.
151-
expect(census).toHaveLength(5);
162+
// ⭐ A count, on purpose: an eleventh route has to come through this file.
163+
// Five serve; five are the per-verb catch-all (#24024).
164+
expect(census).toHaveLength(10);
152165
expect([...census].sort()).toEqual(Object.keys(CASES).sort());
153166
// Nothing here a CORS preflight could reach: no OPTIONS route and no
154167
// 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', () => {
182195
const { http, calls } = mount(() => ({ userId: 'u_1', tenantId: 't_1' }));
183196
const answer = await drive(http, key);
184197
expect(answer.status).toBe(CASES[key]!.okStatus);
198+
if (CASES[key]!.refusalOnly) {
199+
// The catch-all serves nothing, held or not: its answer is the family's 404.
200+
expect(answer.body?.success).toBe(false);
201+
expect(answer.body?.error?.code).toBe('ENDPOINT_NOT_FOUND');
202+
expect(calls()).toEqual([]);
203+
return;
204+
}
185205
expect(answer.body?.success).toBe(true);
186206
expect(calls().length).toBeGreaterThan(0);
187207
});

0 commit comments

Comments
 (0)