Skip to content

Commit cb8322e

Browse files
os-zhuangclaude
andauthored
feat(runtime): extract /share-links dispatcher domain body — ADR-0076 D11 step ③ PR-4 (#2462) (#3533)
The ADR-0047 capability-token surface moves to domains/share-links.ts. This is cloud's designed PRIMARY surface for per-env kernels (registerShareLinkRoutes:false; host dispatcher serves after kernel swap — the #2462 step-① re-scope finding), so behavior preservation is the whole game here. - DomainHandlerDeps grows getRequestKernelService: reads off the per-request RESOLVED kernel (this.kernel), NOT the default kernel and NOT the scoped-factory path — the token row and shared record must live in the same store as the shareLinks service's engine. The domain-side getEngine keeps the original try/fallback shape. - deps also grows routeNotFound (the shared 404 envelope, 4 call sites in the dispatcher). - Dispatcher keeps a thin handleShareLinks delegate; duplicated JSDoc removed in favor of the domain module's copy. Verified: seam suite 30 tests (5 new: 501 absent-service, segment boundary, UNAUTHENTICATED, public token-resolve with redaction, ROUTE_NOT_FOUND envelope), runtime 635 green, http-conformance 41 green, dependent closure builds with DTS (--force). Co-authored-by: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent 99736a0 commit cb8322e

5 files changed

Lines changed: 320 additions & 203 deletions

File tree

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
---
2+
"@objectstack/runtime": minor
3+
---
4+
5+
feat(runtime): extract the /share-links dispatcher domain body — ADR-0076 D11 step ③, PR-4 (#2462)
6+
7+
The share-link capability-token surface (ADR-0047) moves out of
8+
`HttpDispatcher` into `domains/share-links.ts`. This is cloud's designed
9+
primary surface for per-env kernels (`registerShareLinkRoutes: false`, host
10+
dispatcher serves after kernel swap — the #2462 step-① re-scope finding), so
11+
the handler keeps working from the registry exactly as from the if-chain.
12+
`DomainHandlerDeps` grows `getRequestKernelService` (reads off the
13+
per-request RESOLVED kernel — the engine the shareLinks service is bound to)
14+
and `routeNotFound` (the shared 404 envelope). Zero behavior change — locked
15+
by http-conformance (41) and 5 new seam tests incl. token-resolve redaction.

packages/runtime/src/domain-handler-registry.test.ts

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -301,3 +301,60 @@ describe('HttpDispatcher extracted domains (PR-3: keys/storage/ui)', () => {
301301
expect(missing.response?.status).toBe(503);
302302
});
303303
});
304+
305+
// ---------------------------------------------------------------------------
306+
// PR-4 — share-links extraction
307+
// ---------------------------------------------------------------------------
308+
309+
describe('HttpDispatcher extracted domains (PR-4: share-links)', () => {
310+
it('/share-links responds 501 when the shareLinks service is absent', async () => {
311+
const result = await makeDispatcher().dispatch('GET', '/share-links', undefined, {}, {} as any);
312+
expect(result.handled).toBe(true);
313+
expect(result.response?.status).toBe(501);
314+
});
315+
316+
it('/share-linksfoo is NOT claimed (segment semantics)', async () => {
317+
const shareLinks = { resolveToken: vi.fn(), createLink: vi.fn(), listLinks: vi.fn(), revokeLink: vi.fn() };
318+
const result = await makeDispatcher({ shareLinks }).dispatch('GET', '/share-linksfoo', undefined, {}, {} as any);
319+
expect(shareLinks.listLinks).not.toHaveBeenCalled();
320+
expect(result.response?.status ?? 404).not.toBe(501);
321+
});
322+
323+
it('management routes reject anonymous callers with UNAUTHENTICATED', async () => {
324+
const shareLinks = { resolveToken: vi.fn(), createLink: vi.fn(), listLinks: vi.fn(), revokeLink: vi.fn() };
325+
const result = await makeDispatcher({ shareLinks })
326+
.handleShareLinks('', 'POST', { object: 'account', recordId: 'r1' }, {}, {} as any);
327+
expect(result.response?.status).toBe(401);
328+
expect(result.response?.body?.error?.details?.code).toBe('UNAUTHENTICATED');
329+
});
330+
331+
it('public resolve route serves the record through the request-kernel engine with redaction', async () => {
332+
const resolveToken = vi.fn().mockResolvedValue({
333+
link: { id: 'l1', token: 't1', object_name: 'account', record_id: 'r1', permission: 'view', audience: 'anyone' },
334+
redactFields: ['secret'],
335+
});
336+
const find = vi.fn().mockResolvedValue([{ id: 'r1', name: 'Acme', secret: 'hide-me' }]);
337+
const dispatcher = makeDispatcher({
338+
shareLinks: { resolveToken },
339+
objectql: {
340+
find,
341+
getObjects: vi.fn().mockReturnValue({}),
342+
registry: { getObject: vi.fn().mockReturnValue(null), getRegisteredTypes: vi.fn().mockReturnValue([]) },
343+
},
344+
});
345+
const result = await dispatcher.handleShareLinks('/t1/resolve', 'GET', undefined, {}, {} as any);
346+
expect(result.response?.status).toBe(200);
347+
const record = result.response?.body?.data?.record;
348+
expect(record?.name).toBe('Acme');
349+
// redactFields stripped before the record leaves the server.
350+
expect(record?.secret).toBeUndefined();
351+
});
352+
353+
it('unmatched sub-path returns the standard ROUTE_NOT_FOUND envelope', async () => {
354+
const shareLinks = { resolveToken: vi.fn(), createLink: vi.fn(), listLinks: vi.fn(), revokeLink: vi.fn() };
355+
const context: any = { executionContext: { userId: 'u1' } };
356+
const result = await makeDispatcher({ shareLinks }).handleShareLinks('/a/b/c', 'GET', undefined, {}, context);
357+
expect(result.response?.status).toBe(404);
358+
expect(result.response?.body?.error?.type).toBe('ROUTE_NOT_FOUND');
359+
});
360+
});

packages/runtime/src/domain-handler-registry.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,10 +87,21 @@ export interface DomainHandlerDeps {
8787
* /data /meta when they migrate) depend on this.
8888
*/
8989
getObjectQL(environmentId?: string): Promise<any>;
90+
/**
91+
* Service lookup on the request's RESOLVED (per-environment) kernel —
92+
* NOT the default kernel and NOT the scoped-factory path. Domains whose
93+
* data must live in the same store as their service bindings (e.g.
94+
* share-links: the token row and the shared record sit next to the
95+
* `shareLinks` service's engine) read through this and fall back to
96+
* `resolveService` themselves.
97+
*/
98+
getRequestKernelService(name: string): Promise<any>;
9099
/** Standard success envelope. */
91100
success(data: any, meta?: any): { status: number; body: any };
92101
/** Standard error envelope. */
93102
error(message: string, code?: number, details?: any): { status: number; body: any };
103+
/** Standard ROUTE_NOT_FOUND envelope (404 + discovery hint). */
104+
routeNotFound(route: string): { status: number; body: any };
94105
}
95106

96107
/**
Lines changed: 223 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,223 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* `/share-links` domain — extracted dispatcher body (ADR-0076 D11 step ③,
5+
* PR-4). Share-link capability tokens — "anyone with the link" publication
6+
* of a single record (ADR-0047). The `shareLinks` service is resolved from
7+
* the request's environment kernel, so links live in (and resolve against)
8+
* the same per-environment database that owns the record. This domain owns
9+
* URL parsing and the auth/public split.
10+
*
11+
* NOTE (cross-repo, see #2462 step-① re-scope): for cloud's per-env kernels
12+
* this is the DESIGNED PRIMARY surface (`registerShareLinkRoutes: false`;
13+
* the host dispatcher serves it after kernel swap) — the handler must keep
14+
* working from the registry exactly as it did from the if-chain.
15+
*
16+
* POST /share-links → create a link (authenticated)
17+
* GET /share-links?object&recordId → list the caller's links (authenticated)
18+
* DELETE /share-links/:idOrToken → revoke (authenticated)
19+
* GET /share-links/:token/resolve → resolve token → record (PUBLIC)
20+
* GET /share-links/:token/messages → ai_conversations messages (PUBLIC)
21+
*
22+
* The resolve / messages routes are intentionally public — the token IS
23+
* the authorisation. The underlying record is fetched with a SYSTEM
24+
* context (per-env RLS is bypassed because the token gates access), and
25+
* `redactFields` are stripped before the record leaves the server.
26+
*/
27+
28+
import type { HttpProtocolContext, HttpDispatcherResult } from '../http-dispatcher.js';
29+
import type { DomainHandlerDeps, DomainRoute } from '../domain-handler-registry.js';
30+
31+
export function createShareLinksDomain(deps: DomainHandlerDeps): DomainRoute {
32+
return {
33+
prefix: '/share-links',
34+
match: 'segment',
35+
handler: (req, context) =>
36+
handleShareLinksRequest(deps, req.path.substring('/share-links'.length), req.method, req.body, req.query, context),
37+
};
38+
}
39+
40+
/** Body kept signature-compatible with the legacy `HttpDispatcher.handleShareLinks`. */
41+
export async function handleShareLinksRequest(
42+
deps: DomainHandlerDeps,
43+
subPath: string,
44+
method: string,
45+
body: any,
46+
query: any,
47+
context: HttpProtocolContext,
48+
): Promise<HttpDispatcherResult> {
49+
const svc: any = await deps.resolveService('shareLinks', context.environmentId);
50+
if (!svc) {
51+
return { handled: true, response: deps.error('Sharing is not configured for this environment', 501) };
52+
}
53+
54+
const SYSTEM_CTX = { isSystem: true, positions: [], permissions: [] } as const;
55+
const m = method.toUpperCase();
56+
const parts = subPath.replace(/^\/+/, '').split('/').filter(Boolean);
57+
const ec: any = context.executionContext;
58+
const callerCtx = { userId: ec?.userId as string | undefined, tenantId: ec?.tenantId as string | undefined };
59+
60+
const headerOf = (name: string): string | undefined => {
61+
const h = context.request?.headers;
62+
if (!h) return undefined;
63+
const v = typeof h.get === 'function' ? h.get(name) : (h[name] ?? h[name.toLowerCase()]);
64+
return Array.isArray(v) ? v[0] : (v ?? undefined);
65+
};
66+
const sendErr = (status: number, code: string, msg: string): HttpDispatcherResult => ({
67+
handled: true,
68+
response: deps.error(msg, status, { code }),
69+
});
70+
// Engine for fetching the shared record + token probes — the same
71+
// per-env ObjectQL the shareLinks service is bound to. Read from the
72+
// request's RESOLVED (per-env) kernel first: `resolveService('objectql',
73+
// env)` can hand back a different (host/scoped) engine that lacks the
74+
// per-env rows.
75+
const getEngine = async (): Promise<any> => {
76+
try {
77+
const e = await deps.getRequestKernelService('objectql');
78+
if (e) return e;
79+
} catch { /* fall through to scoped resolution */ }
80+
return deps.resolveService('objectql', context.environmentId);
81+
};
82+
const asArray = (rows: any): any[] => (Array.isArray(rows) ? rows : Array.isArray(rows?.value) ? rows.value : []);
83+
const applyRedaction = (record: any, redactFields: string[]): any => {
84+
if (!record || typeof record !== 'object' || redactFields.length === 0) return record;
85+
const out: any = {};
86+
for (const [k, v] of Object.entries(record)) {
87+
if (redactFields.includes(k)) continue;
88+
out[k] = v;
89+
}
90+
return out;
91+
};
92+
93+
try {
94+
// ── PUBLIC: resolve a token → record ──────────────────────────
95+
if (parts.length === 2 && parts[1] === 'resolve' && m === 'GET') {
96+
const token = decodeURIComponent(parts[0]);
97+
const signedInUserId = ec?.userId;
98+
const recipientEmail = typeof query?.email === 'string' ? query.email : undefined;
99+
const providedPassword =
100+
typeof query?.password === 'string' ? (query.password as string) : headerOf('x-share-password');
101+
102+
const resolved = await svc.resolveToken(token, { signedInUserId, recipientEmail, providedPassword });
103+
if (!resolved) {
104+
// Probe the row to return a more useful status (401 vs 410 vs 404).
105+
const engine = await getEngine();
106+
const probe = engine
107+
? asArray(await engine.find('sys_share_link', { where: { token }, limit: 1, context: SYSTEM_CTX } as any))
108+
: [];
109+
const row = probe[0] ?? null;
110+
const live = row && !row.revoked_at && (!row.expires_at || Date.parse(row.expires_at) > Date.now());
111+
if (live && row.password_hash) {
112+
return sendErr(401, providedPassword ? 'WRONG_PASSWORD' : 'NEEDS_PASSWORD',
113+
providedPassword ? 'Incorrect password' : 'This link requires a password');
114+
}
115+
if (live && row.audience === 'signed_in' && !signedInUserId) {
116+
return sendErr(401, 'SIGN_IN_REQUIRED', 'Please sign in to view this link');
117+
}
118+
if (row && (row.revoked_at || (row.expires_at && Date.parse(row.expires_at) <= Date.now()))) {
119+
return sendErr(410, 'EXPIRED_OR_REVOKED', 'Share link has expired or been revoked');
120+
}
121+
return sendErr(404, 'INVALID_OR_EXPIRED', 'Share link is invalid, expired, or revoked');
122+
}
123+
124+
const engine = await getEngine();
125+
const rows = engine
126+
? asArray(await engine.find(resolved.link.object_name, { where: { id: resolved.link.record_id }, limit: 1, context: SYSTEM_CTX } as any))
127+
: [];
128+
const record = rows[0] ?? null;
129+
if (!record) return sendErr(410, 'RECORD_GONE', 'The shared record no longer exists');
130+
131+
return {
132+
handled: true,
133+
response: deps.success({
134+
record: applyRedaction(record, resolved.redactFields),
135+
link: {
136+
id: resolved.link.id,
137+
token: resolved.link.token,
138+
object_name: resolved.link.object_name,
139+
record_id: resolved.link.record_id,
140+
permission: resolved.link.permission,
141+
audience: resolved.link.audience,
142+
expires_at: resolved.link.expires_at,
143+
label: resolved.link.label,
144+
created_at: resolved.link.created_at,
145+
},
146+
redactFields: resolved.redactFields,
147+
}),
148+
};
149+
}
150+
151+
// ── PUBLIC: ai_conversations messages for a resolved token ────
152+
if (parts.length === 2 && parts[1] === 'messages' && m === 'GET') {
153+
const token = decodeURIComponent(parts[0]);
154+
const providedPassword =
155+
typeof query?.password === 'string' ? (query.password as string) : headerOf('x-share-password');
156+
const resolved = await svc.resolveToken(token, { signedInUserId: ec?.userId, providedPassword });
157+
if (!resolved) return sendErr(404, 'NOT_FOUND', 'Share link not found');
158+
if (resolved.link.object_name !== 'ai_conversations') {
159+
return sendErr(400, 'UNSUPPORTED', 'This share link does not expose messages');
160+
}
161+
const engine = await getEngine();
162+
const rows = engine
163+
? asArray(await engine.find('ai_messages', {
164+
where: { conversation_id: resolved.link.record_id },
165+
sort: [{ field: 'created_at', order: 'asc' }],
166+
limit: 500,
167+
context: SYSTEM_CTX,
168+
} as any))
169+
: [];
170+
return { handled: true, response: deps.success(rows) };
171+
}
172+
173+
// ── AUTHENTICATED: create / list / revoke ─────────────────────
174+
if (!callerCtx.userId) return sendErr(401, 'UNAUTHENTICATED', 'Sign in to manage share links');
175+
176+
// POST /share-links → create
177+
if (parts.length === 0 && m === 'POST') {
178+
const b: any = body ?? {};
179+
if (!b.object || !b.recordId) return sendErr(400, 'VALIDATION_FAILED', 'object and recordId are required');
180+
const link = await svc.createLink(
181+
{
182+
object: b.object,
183+
recordId: b.recordId,
184+
permission: b.permission,
185+
audience: b.audience,
186+
expiresAt: b.expiresAt ?? null,
187+
emailAllowlist: b.emailAllowlist,
188+
password: b.password,
189+
redactFields: b.redactFields,
190+
label: b.label,
191+
},
192+
callerCtx,
193+
);
194+
return { handled: true, response: { status: 201, body: { success: true, data: link, link } } };
195+
}
196+
197+
// GET /share-links?object&recordId → list the caller's own links
198+
if (parts.length === 0 && m === 'GET') {
199+
const links = await svc.listLinks(
200+
{
201+
object: typeof query?.object === 'string' ? query.object : undefined,
202+
recordId: typeof query?.recordId === 'string' ? query.recordId : undefined,
203+
// Constrain to links the caller created so a guessed
204+
// recordId can never enumerate another user's tokens.
205+
createdBy: callerCtx.userId,
206+
includeRevoked: query?.includeRevoked === 'true' || query?.includeRevoked === '1',
207+
},
208+
callerCtx,
209+
);
210+
return { handled: true, response: { status: 200, body: { success: true, data: links, links } } };
211+
}
212+
213+
// DELETE /share-links/:idOrToken → revoke
214+
if (parts.length === 1 && m === 'DELETE') {
215+
await svc.revokeLink(decodeURIComponent(parts[0]), callerCtx);
216+
return { handled: true, response: deps.success({ ok: true }) };
217+
}
218+
219+
return { handled: true, response: deps.routeNotFound(`/share-links${subPath}`) };
220+
} catch (err: any) {
221+
return sendErr(err?.status ?? 500, err?.code ?? 'INTERNAL', err?.message ?? 'Share link request failed');
222+
}
223+
}

0 commit comments

Comments
 (0)