diff --git a/.changeset/runtime-share-links-extraction.md b/.changeset/runtime-share-links-extraction.md new file mode 100644 index 0000000000..ec68601956 --- /dev/null +++ b/.changeset/runtime-share-links-extraction.md @@ -0,0 +1,15 @@ +--- +"@objectstack/runtime": minor +--- + +feat(runtime): extract the /share-links dispatcher domain body — ADR-0076 D11 step ③, PR-4 (#2462) + +The share-link capability-token surface (ADR-0047) moves out of +`HttpDispatcher` into `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 +the handler keeps working from the registry exactly as from the if-chain. +`DomainHandlerDeps` grows `getRequestKernelService` (reads off the +per-request RESOLVED kernel — the engine the shareLinks service is bound to) +and `routeNotFound` (the shared 404 envelope). Zero behavior change — locked +by http-conformance (41) and 5 new seam tests incl. token-resolve redaction. diff --git a/packages/runtime/src/domain-handler-registry.test.ts b/packages/runtime/src/domain-handler-registry.test.ts index f1abb64b2e..b8498e4b8c 100644 --- a/packages/runtime/src/domain-handler-registry.test.ts +++ b/packages/runtime/src/domain-handler-registry.test.ts @@ -301,3 +301,60 @@ describe('HttpDispatcher extracted domains (PR-3: keys/storage/ui)', () => { expect(missing.response?.status).toBe(503); }); }); + +// --------------------------------------------------------------------------- +// PR-4 — share-links extraction +// --------------------------------------------------------------------------- + +describe('HttpDispatcher extracted domains (PR-4: share-links)', () => { + it('/share-links responds 501 when the shareLinks service is absent', async () => { + const result = await makeDispatcher().dispatch('GET', '/share-links', undefined, {}, {} as any); + expect(result.handled).toBe(true); + expect(result.response?.status).toBe(501); + }); + + it('/share-linksfoo is NOT claimed (segment semantics)', async () => { + const shareLinks = { resolveToken: vi.fn(), createLink: vi.fn(), listLinks: vi.fn(), revokeLink: vi.fn() }; + const result = await makeDispatcher({ shareLinks }).dispatch('GET', '/share-linksfoo', undefined, {}, {} as any); + expect(shareLinks.listLinks).not.toHaveBeenCalled(); + expect(result.response?.status ?? 404).not.toBe(501); + }); + + it('management routes reject anonymous callers with UNAUTHENTICATED', async () => { + const shareLinks = { resolveToken: vi.fn(), createLink: vi.fn(), listLinks: vi.fn(), revokeLink: vi.fn() }; + const result = await makeDispatcher({ shareLinks }) + .handleShareLinks('', 'POST', { object: 'account', recordId: 'r1' }, {}, {} as any); + expect(result.response?.status).toBe(401); + expect(result.response?.body?.error?.details?.code).toBe('UNAUTHENTICATED'); + }); + + it('public resolve route serves the record through the request-kernel engine with redaction', async () => { + const resolveToken = vi.fn().mockResolvedValue({ + link: { id: 'l1', token: 't1', object_name: 'account', record_id: 'r1', permission: 'view', audience: 'anyone' }, + redactFields: ['secret'], + }); + const find = vi.fn().mockResolvedValue([{ id: 'r1', name: 'Acme', secret: 'hide-me' }]); + const dispatcher = makeDispatcher({ + shareLinks: { resolveToken }, + objectql: { + find, + getObjects: vi.fn().mockReturnValue({}), + registry: { getObject: vi.fn().mockReturnValue(null), getRegisteredTypes: vi.fn().mockReturnValue([]) }, + }, + }); + const result = await dispatcher.handleShareLinks('/t1/resolve', 'GET', undefined, {}, {} as any); + expect(result.response?.status).toBe(200); + const record = result.response?.body?.data?.record; + expect(record?.name).toBe('Acme'); + // redactFields stripped before the record leaves the server. + expect(record?.secret).toBeUndefined(); + }); + + it('unmatched sub-path returns the standard ROUTE_NOT_FOUND envelope', async () => { + const shareLinks = { resolveToken: vi.fn(), createLink: vi.fn(), listLinks: vi.fn(), revokeLink: vi.fn() }; + const context: any = { executionContext: { userId: 'u1' } }; + const result = await makeDispatcher({ shareLinks }).handleShareLinks('/a/b/c', 'GET', undefined, {}, context); + expect(result.response?.status).toBe(404); + expect(result.response?.body?.error?.type).toBe('ROUTE_NOT_FOUND'); + }); +}); diff --git a/packages/runtime/src/domain-handler-registry.ts b/packages/runtime/src/domain-handler-registry.ts index d227820cad..ffed464953 100644 --- a/packages/runtime/src/domain-handler-registry.ts +++ b/packages/runtime/src/domain-handler-registry.ts @@ -87,10 +87,21 @@ export interface DomainHandlerDeps { * /data /meta when they migrate) depend on this. */ getObjectQL(environmentId?: string): Promise; + /** + * Service lookup on the request's RESOLVED (per-environment) kernel — + * NOT the default kernel and NOT the scoped-factory path. Domains whose + * data must live in the same store as their service bindings (e.g. + * share-links: the token row and the shared record sit next to the + * `shareLinks` service's engine) read through this and fall back to + * `resolveService` themselves. + */ + getRequestKernelService(name: string): Promise; /** Standard success envelope. */ success(data: any, meta?: any): { status: number; body: any }; /** Standard error envelope. */ error(message: string, code?: number, details?: any): { status: number; body: any }; + /** Standard ROUTE_NOT_FOUND envelope (404 + discovery hint). */ + routeNotFound(route: string): { status: number; body: any }; } /** diff --git a/packages/runtime/src/domains/share-links.ts b/packages/runtime/src/domains/share-links.ts new file mode 100644 index 0000000000..f805d555bf --- /dev/null +++ b/packages/runtime/src/domains/share-links.ts @@ -0,0 +1,223 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * `/share-links` domain — extracted dispatcher body (ADR-0076 D11 step ③, + * PR-4). Share-link capability tokens — "anyone with the link" publication + * of a single record (ADR-0047). The `shareLinks` service is resolved from + * the request's environment kernel, so links live in (and resolve against) + * the same per-environment database that owns the record. This domain owns + * URL parsing and the auth/public split. + * + * NOTE (cross-repo, see #2462 step-① re-scope): for cloud's per-env kernels + * this is the DESIGNED PRIMARY surface (`registerShareLinkRoutes: false`; + * the host dispatcher serves it after kernel swap) — the handler must keep + * working from the registry exactly as it did from the if-chain. + * + * POST /share-links → create a link (authenticated) + * GET /share-links?object&recordId → list the caller's links (authenticated) + * DELETE /share-links/:idOrToken → revoke (authenticated) + * GET /share-links/:token/resolve → resolve token → record (PUBLIC) + * GET /share-links/:token/messages → ai_conversations messages (PUBLIC) + * + * The resolve / messages routes are intentionally public — the token IS + * the authorisation. The underlying record is fetched with a SYSTEM + * context (per-env RLS is bypassed because the token gates access), and + * `redactFields` are stripped before the record leaves the server. + */ + +import type { HttpProtocolContext, HttpDispatcherResult } from '../http-dispatcher.js'; +import type { DomainHandlerDeps, DomainRoute } from '../domain-handler-registry.js'; + +export function createShareLinksDomain(deps: DomainHandlerDeps): DomainRoute { + return { + prefix: '/share-links', + match: 'segment', + handler: (req, context) => + handleShareLinksRequest(deps, req.path.substring('/share-links'.length), req.method, req.body, req.query, context), + }; +} + +/** Body kept signature-compatible with the legacy `HttpDispatcher.handleShareLinks`. */ +export async function handleShareLinksRequest( + deps: DomainHandlerDeps, + subPath: string, + method: string, + body: any, + query: any, + context: HttpProtocolContext, +): Promise { + const svc: any = await deps.resolveService('shareLinks', context.environmentId); + if (!svc) { + return { handled: true, response: deps.error('Sharing is not configured for this environment', 501) }; + } + + const SYSTEM_CTX = { isSystem: true, positions: [], permissions: [] } as const; + const m = method.toUpperCase(); + const parts = subPath.replace(/^\/+/, '').split('/').filter(Boolean); + const ec: any = context.executionContext; + const callerCtx = { userId: ec?.userId as string | undefined, tenantId: ec?.tenantId as string | undefined }; + + const headerOf = (name: string): string | undefined => { + const h = context.request?.headers; + if (!h) return undefined; + const v = typeof h.get === 'function' ? h.get(name) : (h[name] ?? h[name.toLowerCase()]); + return Array.isArray(v) ? v[0] : (v ?? undefined); + }; + const sendErr = (status: number, code: string, msg: string): HttpDispatcherResult => ({ + handled: true, + response: deps.error(msg, status, { code }), + }); + // Engine for fetching the shared record + token probes — the same + // per-env ObjectQL the shareLinks service is bound to. Read from the + // request's RESOLVED (per-env) kernel first: `resolveService('objectql', + // env)` can hand back a different (host/scoped) engine that lacks the + // per-env rows. + const getEngine = async (): Promise => { + try { + const e = await deps.getRequestKernelService('objectql'); + if (e) return e; + } catch { /* fall through to scoped resolution */ } + return deps.resolveService('objectql', context.environmentId); + }; + const asArray = (rows: any): any[] => (Array.isArray(rows) ? rows : Array.isArray(rows?.value) ? rows.value : []); + const applyRedaction = (record: any, redactFields: string[]): any => { + if (!record || typeof record !== 'object' || redactFields.length === 0) return record; + const out: any = {}; + for (const [k, v] of Object.entries(record)) { + if (redactFields.includes(k)) continue; + out[k] = v; + } + return out; + }; + + try { + // ── PUBLIC: resolve a token → record ────────────────────────── + if (parts.length === 2 && parts[1] === 'resolve' && m === 'GET') { + const token = decodeURIComponent(parts[0]); + const signedInUserId = ec?.userId; + const recipientEmail = typeof query?.email === 'string' ? query.email : undefined; + const providedPassword = + typeof query?.password === 'string' ? (query.password as string) : headerOf('x-share-password'); + + const resolved = await svc.resolveToken(token, { signedInUserId, recipientEmail, providedPassword }); + if (!resolved) { + // Probe the row to return a more useful status (401 vs 410 vs 404). + const engine = await getEngine(); + const probe = engine + ? asArray(await engine.find('sys_share_link', { where: { token }, limit: 1, context: SYSTEM_CTX } as any)) + : []; + const row = probe[0] ?? null; + const live = row && !row.revoked_at && (!row.expires_at || Date.parse(row.expires_at) > Date.now()); + if (live && row.password_hash) { + return sendErr(401, providedPassword ? 'WRONG_PASSWORD' : 'NEEDS_PASSWORD', + providedPassword ? 'Incorrect password' : 'This link requires a password'); + } + if (live && row.audience === 'signed_in' && !signedInUserId) { + return sendErr(401, 'SIGN_IN_REQUIRED', 'Please sign in to view this link'); + } + if (row && (row.revoked_at || (row.expires_at && Date.parse(row.expires_at) <= Date.now()))) { + return sendErr(410, 'EXPIRED_OR_REVOKED', 'Share link has expired or been revoked'); + } + return sendErr(404, 'INVALID_OR_EXPIRED', 'Share link is invalid, expired, or revoked'); + } + + const engine = await getEngine(); + const rows = engine + ? asArray(await engine.find(resolved.link.object_name, { where: { id: resolved.link.record_id }, limit: 1, context: SYSTEM_CTX } as any)) + : []; + const record = rows[0] ?? null; + if (!record) return sendErr(410, 'RECORD_GONE', 'The shared record no longer exists'); + + return { + handled: true, + response: deps.success({ + record: applyRedaction(record, resolved.redactFields), + link: { + id: resolved.link.id, + token: resolved.link.token, + object_name: resolved.link.object_name, + record_id: resolved.link.record_id, + permission: resolved.link.permission, + audience: resolved.link.audience, + expires_at: resolved.link.expires_at, + label: resolved.link.label, + created_at: resolved.link.created_at, + }, + redactFields: resolved.redactFields, + }), + }; + } + + // ── PUBLIC: ai_conversations messages for a resolved token ──── + if (parts.length === 2 && parts[1] === 'messages' && m === 'GET') { + const token = decodeURIComponent(parts[0]); + const providedPassword = + typeof query?.password === 'string' ? (query.password as string) : headerOf('x-share-password'); + const resolved = await svc.resolveToken(token, { signedInUserId: ec?.userId, providedPassword }); + if (!resolved) return sendErr(404, 'NOT_FOUND', 'Share link not found'); + if (resolved.link.object_name !== 'ai_conversations') { + return sendErr(400, 'UNSUPPORTED', 'This share link does not expose messages'); + } + const engine = await getEngine(); + const rows = engine + ? asArray(await engine.find('ai_messages', { + where: { conversation_id: resolved.link.record_id }, + sort: [{ field: 'created_at', order: 'asc' }], + limit: 500, + context: SYSTEM_CTX, + } as any)) + : []; + return { handled: true, response: deps.success(rows) }; + } + + // ── AUTHENTICATED: create / list / revoke ───────────────────── + if (!callerCtx.userId) return sendErr(401, 'UNAUTHENTICATED', 'Sign in to manage share links'); + + // POST /share-links → create + if (parts.length === 0 && m === 'POST') { + const b: any = body ?? {}; + if (!b.object || !b.recordId) return sendErr(400, 'VALIDATION_FAILED', 'object and recordId are required'); + const link = await svc.createLink( + { + object: b.object, + recordId: b.recordId, + permission: b.permission, + audience: b.audience, + expiresAt: b.expiresAt ?? null, + emailAllowlist: b.emailAllowlist, + password: b.password, + redactFields: b.redactFields, + label: b.label, + }, + callerCtx, + ); + return { handled: true, response: { status: 201, body: { success: true, data: link, link } } }; + } + + // GET /share-links?object&recordId → list the caller's own links + if (parts.length === 0 && m === 'GET') { + const links = await svc.listLinks( + { + object: typeof query?.object === 'string' ? query.object : undefined, + recordId: typeof query?.recordId === 'string' ? query.recordId : undefined, + // Constrain to links the caller created so a guessed + // recordId can never enumerate another user's tokens. + createdBy: callerCtx.userId, + includeRevoked: query?.includeRevoked === 'true' || query?.includeRevoked === '1', + }, + callerCtx, + ); + return { handled: true, response: { status: 200, body: { success: true, data: links, links } } }; + } + + // DELETE /share-links/:idOrToken → revoke + if (parts.length === 1 && m === 'DELETE') { + await svc.revokeLink(decodeURIComponent(parts[0]), callerCtx); + return { handled: true, response: deps.success({ ok: true }) }; + } + + return { handled: true, response: deps.routeNotFound(`/share-links${subPath}`) }; + } catch (err: any) { + return sendErr(err?.status ?? 500, err?.code ?? 'INTERNAL', err?.message ?? 'Share link request failed'); + } +} diff --git a/packages/runtime/src/http-dispatcher.ts b/packages/runtime/src/http-dispatcher.ts index 1f6d119292..2c984b846d 100644 --- a/packages/runtime/src/http-dispatcher.ts +++ b/packages/runtime/src/http-dispatcher.ts @@ -22,6 +22,7 @@ import { createSecurityDomain, handleSecurityRequest } from './domains/security. import { createKeysDomains, handleKeysRequest } from './domains/keys.js'; import { createStorageDomain, handleStorageRequest } from './domains/storage.js'; import { createUiDomain, handleUiRequest } from './domains/ui.js'; +import { createShareLinksDomain, handleShareLinksRequest } from './domains/share-links.js'; /** Minimal local interface — full EnvironmentScopeManager was removed in Phase R. */ interface EnvironmentScopeManager { @@ -244,8 +245,17 @@ export class HttpDispatcher { // values anyway); the dispatcher method's parameter is the enum type. getService: (name) => this.getService(name as Parameters[0]), getObjectQL: (environmentId) => this.getObjectQLService(environmentId), + // Reads off the per-request RESOLVED kernel (`this.kernel` is set by + // dispatch() before any handler runs) — see the deps contract note. + getRequestKernelService: async (name) => { + const k: any = this.kernel; + return typeof k?.getServiceAsync === 'function' + ? k.getServiceAsync(name) + : k?.getService?.(name); + }, success: (data, meta) => this.success(data, meta), error: (message, code, details) => this.error(message, code, details), + routeNotFound: (route) => this.routeNotFound(route), }; /** @@ -293,6 +303,7 @@ export class HttpDispatcher { for (const route of createKeysDomains(this.domainDeps)) this.domainRegistry.register(route); this.domainRegistry.register(createStorageDomain(this.domainDeps)); this.domainRegistry.register(createUiDomain(this.domainDeps)); + this.domainRegistry.register(createShareLinksDomain(this.domainDeps)); } /** @@ -3972,25 +3983,7 @@ export class HttpDispatcher { }; } - /** - * Share-link capability tokens — "anyone with the link" publication of a - * single record (ADR-0047). Mirrors the per-env service-dispatch pattern - * used by {@link handleI18n} / {@link handleAI}: the `shareLinks` service - * is resolved from the request's environment kernel, so links live in (and - * resolve against) the same per-environment database that owns the record. - * This branch owns URL parsing and the auth/public split. - * - * POST /share-links → create a link (authenticated) - * GET /share-links?object&recordId → list the caller's links (authenticated) - * DELETE /share-links/:idOrToken → revoke (authenticated) - * GET /share-links/:token/resolve → resolve token → record (PUBLIC) - * GET /share-links/:token/messages → ai_conversations messages (PUBLIC) - * - * The resolve / messages routes are intentionally public — the token IS - * the authorisation. The underlying record is fetched with a SYSTEM - * context (per-env RLS is bypassed because the token gates access), and - * `redactFields` are stripped before the record leaves the server. - */ + /** Thin delegate — body extracted to `./domains/share-links.ts` (D11③ PR-4). */ async handleShareLinks( subPath: string, method: string, @@ -3998,185 +3991,7 @@ export class HttpDispatcher { query: any, context: HttpProtocolContext, ): Promise { - const svc: any = await this.resolveService('shareLinks', context.environmentId); - if (!svc) { - return { handled: true, response: this.error('Sharing is not configured for this environment', 501) }; - } - - const SYSTEM_CTX = { isSystem: true, positions: [], permissions: [] } as const; - const m = method.toUpperCase(); - const parts = subPath.replace(/^\/+/, '').split('/').filter(Boolean); - const ec: any = context.executionContext; - const callerCtx = { userId: ec?.userId as string | undefined, tenantId: ec?.tenantId as string | undefined }; - - const headerOf = (name: string): string | undefined => { - const h = context.request?.headers; - if (!h) return undefined; - const v = typeof h.get === 'function' ? h.get(name) : (h[name] ?? h[name.toLowerCase()]); - return Array.isArray(v) ? v[0] : (v ?? undefined); - }; - const sendErr = (status: number, code: string, msg: string): HttpDispatcherResult => ({ - handled: true, - response: this.error(msg, status, { code }), - }); - // Engine for fetching the shared record + token probes — the same - // per-env ObjectQL the shareLinks service is bound to. - const getEngine = async (): Promise => { - // Read objectql from the request's RESOLVED (per-env) kernel — the - // same engine SharingServicePlugin bound the shareLinks service to, - // so the shared record + messages live in the SAME store as - // sys_share_link. `resolveService('objectql', env)` can hand back a - // different (host/scoped) engine that lacks the per-env rows. - try { - const k: any = this.kernel; - const e = typeof k?.getServiceAsync === 'function' - ? await k.getServiceAsync('objectql') - : k?.getService?.('objectql'); - if (e) return e; - } catch { /* fall through to scoped resolution */ } - return this.resolveService('objectql', context.environmentId); - }; - const asArray = (rows: any): any[] => (Array.isArray(rows) ? rows : Array.isArray(rows?.value) ? rows.value : []); - const applyRedaction = (record: any, redactFields: string[]): any => { - if (!record || typeof record !== 'object' || redactFields.length === 0) return record; - const out: any = {}; - for (const [k, v] of Object.entries(record)) { - if (redactFields.includes(k)) continue; - out[k] = v; - } - return out; - }; - - try { - // ── PUBLIC: resolve a token → record ────────────────────────── - if (parts.length === 2 && parts[1] === 'resolve' && m === 'GET') { - const token = decodeURIComponent(parts[0]); - const signedInUserId = ec?.userId; - const recipientEmail = typeof query?.email === 'string' ? query.email : undefined; - const providedPassword = - typeof query?.password === 'string' ? (query.password as string) : headerOf('x-share-password'); - - const resolved = await svc.resolveToken(token, { signedInUserId, recipientEmail, providedPassword }); - if (!resolved) { - // Probe the row to return a more useful status (401 vs 410 vs 404). - const engine = await getEngine(); - const probe = engine - ? asArray(await engine.find('sys_share_link', { where: { token }, limit: 1, context: SYSTEM_CTX } as any)) - : []; - const row = probe[0] ?? null; - const live = row && !row.revoked_at && (!row.expires_at || Date.parse(row.expires_at) > Date.now()); - if (live && row.password_hash) { - return sendErr(401, providedPassword ? 'WRONG_PASSWORD' : 'NEEDS_PASSWORD', - providedPassword ? 'Incorrect password' : 'This link requires a password'); - } - if (live && row.audience === 'signed_in' && !signedInUserId) { - return sendErr(401, 'SIGN_IN_REQUIRED', 'Please sign in to view this link'); - } - if (row && (row.revoked_at || (row.expires_at && Date.parse(row.expires_at) <= Date.now()))) { - return sendErr(410, 'EXPIRED_OR_REVOKED', 'Share link has expired or been revoked'); - } - return sendErr(404, 'INVALID_OR_EXPIRED', 'Share link is invalid, expired, or revoked'); - } - - const engine = await getEngine(); - const rows = engine - ? asArray(await engine.find(resolved.link.object_name, { where: { id: resolved.link.record_id }, limit: 1, context: SYSTEM_CTX } as any)) - : []; - const record = rows[0] ?? null; - if (!record) return sendErr(410, 'RECORD_GONE', 'The shared record no longer exists'); - - return { - handled: true, - response: this.success({ - record: applyRedaction(record, resolved.redactFields), - link: { - id: resolved.link.id, - token: resolved.link.token, - object_name: resolved.link.object_name, - record_id: resolved.link.record_id, - permission: resolved.link.permission, - audience: resolved.link.audience, - expires_at: resolved.link.expires_at, - label: resolved.link.label, - created_at: resolved.link.created_at, - }, - redactFields: resolved.redactFields, - }), - }; - } - - // ── PUBLIC: ai_conversations messages for a resolved token ──── - if (parts.length === 2 && parts[1] === 'messages' && m === 'GET') { - const token = decodeURIComponent(parts[0]); - const providedPassword = - typeof query?.password === 'string' ? (query.password as string) : headerOf('x-share-password'); - const resolved = await svc.resolveToken(token, { signedInUserId: ec?.userId, providedPassword }); - if (!resolved) return sendErr(404, 'NOT_FOUND', 'Share link not found'); - if (resolved.link.object_name !== 'ai_conversations') { - return sendErr(400, 'UNSUPPORTED', 'This share link does not expose messages'); - } - const engine = await getEngine(); - const rows = engine - ? asArray(await engine.find('ai_messages', { - where: { conversation_id: resolved.link.record_id }, - sort: [{ field: 'created_at', order: 'asc' }], - limit: 500, - context: SYSTEM_CTX, - } as any)) - : []; - return { handled: true, response: this.success(rows) }; - } - - // ── AUTHENTICATED: create / list / revoke ───────────────────── - if (!callerCtx.userId) return sendErr(401, 'UNAUTHENTICATED', 'Sign in to manage share links'); - - // POST /share-links → create - if (parts.length === 0 && m === 'POST') { - const b: any = body ?? {}; - if (!b.object || !b.recordId) return sendErr(400, 'VALIDATION_FAILED', 'object and recordId are required'); - const link = await svc.createLink( - { - object: b.object, - recordId: b.recordId, - permission: b.permission, - audience: b.audience, - expiresAt: b.expiresAt ?? null, - emailAllowlist: b.emailAllowlist, - password: b.password, - redactFields: b.redactFields, - label: b.label, - }, - callerCtx, - ); - return { handled: true, response: { status: 201, body: { success: true, data: link, link } } }; - } - - // GET /share-links?object&recordId → list the caller's own links - if (parts.length === 0 && m === 'GET') { - const links = await svc.listLinks( - { - object: typeof query?.object === 'string' ? query.object : undefined, - recordId: typeof query?.recordId === 'string' ? query.recordId : undefined, - // Constrain to links the caller created so a guessed - // recordId can never enumerate another user's tokens. - createdBy: callerCtx.userId, - includeRevoked: query?.includeRevoked === 'true' || query?.includeRevoked === '1', - }, - callerCtx, - ); - return { handled: true, response: { status: 200, body: { success: true, data: links, links } } }; - } - - // DELETE /share-links/:idOrToken → revoke - if (parts.length === 1 && m === 'DELETE') { - await svc.revokeLink(decodeURIComponent(parts[0]), callerCtx); - return { handled: true, response: this.success({ ok: true }) }; - } - - return { handled: true, response: this.routeNotFound(`/share-links${subPath}`) }; - } catch (err: any) { - return sendErr(err?.status ?? 500, err?.code ?? 'INTERNAL', err?.message ?? 'Share link request failed'); - } + return handleShareLinksRequest(this.domainDeps, subPath, method, body, query, context); } /** @@ -4351,11 +4166,7 @@ export class HttpDispatcher { return this.handleAI(cleanPath, method, body, query, context); } - // Share links — capability-token record sharing, dispatched to the - // per-env `shareLinks` service (links + record live in the same kernel). - if (cleanPath === '/share-links' || cleanPath.startsWith('/share-links/')) { - return this.handleShareLinks(cleanPath.substring('/share-links'.length), method, body, query, context); - } + // /share-links moved to the domain registry (D11 step ③). // OpenAPI Specification if (cleanPath === '/openapi.json' && method === 'GET') {