|
| 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