Skip to content

Commit 36b738f

Browse files
os-zhuangclaude
andauthored
feat(runtime): dispatch /api/v1/share-links to per-env shareLinks service (#1986)
* feat(runtime): dispatch /api/v1/share-links to per-env shareLinks service Add HttpDispatcher.handleShareLinks — resolves the per-env shareLinks service (env from hostname) for create/list/revoke + public token resolve/messages, mirroring handleI18n/handleAI. Whitelist the public resolve/messages paths in enforceProjectMembership so a signed-in non-member opening a public link isn't 403'd. Register the share-link service in SharingServicePlugin regardless of the enforce flag. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(runtime): resolve share-link record/messages via the per-env kernel handleShareLinks fetched the record via resolveService('objectql', env), which can return a different (host/scoped) engine that lacks the per-env rows — so resolve/messages 410'd even though the record existed. Read objectql from this.kernel (the request's resolved per-env kernel), the same engine SharingServicePlugin bound the shareLinks service to. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent dcbca6c commit 36b738f

2 files changed

Lines changed: 248 additions & 26 deletions

File tree

packages/plugins/plugin-sharing/src/sharing-plugin.ts

Lines changed: 30 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -139,37 +139,41 @@ export class SharingServicePlugin implements Plugin {
139139
});
140140
ctx.registerService('sharing', this.service);
141141

142+
// Enforcement (read-filter middleware + sharing-rule hooks) is opt-out
143+
// via `enforce: false`. The share-link service below is registered
144+
// REGARDLESS — capability-token sharing does not depend on principal-
145+
// based RLS enforcement, and multi-tenant hosts mount this plugin purely
146+
// for the `shareLinks` service (per-env enforcement is applied elsewhere).
142147
if (this.options.enforce === false) {
143-
ctx.logger.info('SharingServicePlugin: enforcement disabled (enforce=false)');
144-
return;
145-
}
146-
147-
const mw = buildSharingMiddleware(this.service);
148-
if (typeof engine.registerMiddleware === 'function') {
149-
engine.registerMiddleware(mw, { object: '*' });
150-
ctx.logger.info('SharingServicePlugin: enforcement middleware installed');
148+
ctx.logger.info('SharingServicePlugin: enforcement disabled (enforce=false) — share-link service still registered');
151149
} else {
152-
ctx.logger.warn('SharingServicePlugin: engine has no registerMiddleware — enforcement not applied');
153-
}
150+
const mw = buildSharingMiddleware(this.service);
151+
if (typeof engine.registerMiddleware === 'function') {
152+
engine.registerMiddleware(mw, { object: '*' });
153+
ctx.logger.info('SharingServicePlugin: enforcement middleware installed');
154+
} else {
155+
ctx.logger.warn('SharingServicePlugin: engine has no registerMiddleware — enforcement not applied');
156+
}
154157

155-
// Rule evaluator + hot-rebindable lifecycle hooks.
156-
try {
157-
this.ruleService = new SharingRuleService({
158-
engine: engine as SharingEngine,
159-
sharing: this.service,
160-
logger: ctx.logger as any,
161-
});
162-
ctx.registerService('sharingRules', this.ruleService);
158+
// Rule evaluator + hot-rebindable lifecycle hooks.
159+
try {
160+
this.ruleService = new SharingRuleService({
161+
engine: engine as SharingEngine,
162+
sharing: this.service,
163+
logger: ctx.logger as any,
164+
});
165+
ctx.registerService('sharingRules', this.ruleService);
163166

164-
if (typeof engine.registerHook === 'function' && typeof engine.unregisterHooksByPackage === 'function') {
165-
const rules = await this.ruleService.listRules({ activeOnly: true }, { isSystem: true } as any);
166-
unbindAllRuleHooks(engine);
167-
bindRuleHooks(engine, this.ruleService, rules, ctx.logger as any);
168-
} else {
169-
ctx.logger.warn('SharingServicePlugin: engine has no hook API — sharing rule auto-evaluation disabled');
167+
if (typeof engine.registerHook === 'function' && typeof engine.unregisterHooksByPackage === 'function') {
168+
const rules = await this.ruleService.listRules({ activeOnly: true }, { isSystem: true } as any);
169+
unbindAllRuleHooks(engine);
170+
bindRuleHooks(engine, this.ruleService, rules, ctx.logger as any);
171+
} else {
172+
ctx.logger.warn('SharingServicePlugin: engine has no hook API — sharing rule auto-evaluation disabled');
173+
}
174+
} catch (err: any) {
175+
ctx.logger.warn('SharingServicePlugin: sharing-rule subsystem not started', { error: err?.message });
170176
}
171-
} catch (err: any) {
172-
ctx.logger.warn('SharingServicePlugin: sharing-rule subsystem not started', { error: err?.message });
173177
}
174178

175179
// ── Share-Link service (capability tokens) ────────────────

packages/runtime/src/http-dispatcher.ts

Lines changed: 218 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -702,6 +702,11 @@ export class HttpDispatcher {
702702
const skipPaths = ['/auth', '/cloud', '/health', '/discovery'];
703703
if (skipPaths.some(p => path.startsWith(p))) return null;
704704

705+
// Public share-link resolve/messages — the token IS the authorisation,
706+
// so never gate them on project membership (a signed-in non-member
707+
// opening a public link must not be 403'd before the token handler runs).
708+
if (/(^|\/)share-links\/[^/]+\/(resolve|messages)$/.test(path)) return null;
709+
705710
const environmentId = context.environmentId;
706711
if (!environmentId) return null; // Unscoped legacy routes fall through.
707712

@@ -2782,6 +2787,213 @@ export class HttpDispatcher {
27822787
};
27832788
}
27842789

2790+
/**
2791+
* Share-link capability tokens — "anyone with the link" publication of a
2792+
* single record (ADR-0047). Mirrors the per-env service-dispatch pattern
2793+
* used by {@link handleI18n} / {@link handleAI}: the `shareLinks` service
2794+
* is resolved from the request's environment kernel, so links live in (and
2795+
* resolve against) the same per-environment database that owns the record.
2796+
* This branch owns URL parsing and the auth/public split.
2797+
*
2798+
* POST /share-links → create a link (authenticated)
2799+
* GET /share-links?object&recordId → list the caller's links (authenticated)
2800+
* DELETE /share-links/:idOrToken → revoke (authenticated)
2801+
* GET /share-links/:token/resolve → resolve token → record (PUBLIC)
2802+
* GET /share-links/:token/messages → ai_conversations messages (PUBLIC)
2803+
*
2804+
* The resolve / messages routes are intentionally public — the token IS
2805+
* the authorisation. The underlying record is fetched with a SYSTEM
2806+
* context (per-env RLS is bypassed because the token gates access), and
2807+
* `redactFields` are stripped before the record leaves the server.
2808+
*/
2809+
async handleShareLinks(
2810+
subPath: string,
2811+
method: string,
2812+
body: any,
2813+
query: any,
2814+
context: HttpProtocolContext,
2815+
): Promise<HttpDispatcherResult> {
2816+
const svc: any = await this.resolveService('shareLinks', context.environmentId);
2817+
if (!svc) {
2818+
return { handled: true, response: this.error('Sharing is not configured for this environment', 501) };
2819+
}
2820+
2821+
const SYSTEM_CTX = { isSystem: true, roles: [], permissions: [] } as const;
2822+
const m = method.toUpperCase();
2823+
const parts = subPath.replace(/^\/+/, '').split('/').filter(Boolean);
2824+
const ec: any = context.executionContext;
2825+
const callerCtx = { userId: ec?.userId as string | undefined, tenantId: ec?.tenantId as string | undefined };
2826+
2827+
const headerOf = (name: string): string | undefined => {
2828+
const h = context.request?.headers;
2829+
if (!h) return undefined;
2830+
const v = typeof h.get === 'function' ? h.get(name) : (h[name] ?? h[name.toLowerCase()]);
2831+
return Array.isArray(v) ? v[0] : (v ?? undefined);
2832+
};
2833+
const sendErr = (status: number, code: string, msg: string): HttpDispatcherResult => ({
2834+
handled: true,
2835+
response: this.error(msg, status, { code }),
2836+
});
2837+
// Engine for fetching the shared record + token probes — the same
2838+
// per-env ObjectQL the shareLinks service is bound to.
2839+
const getEngine = async (): Promise<any> => {
2840+
// Read objectql from the request's RESOLVED (per-env) kernel — the
2841+
// same engine SharingServicePlugin bound the shareLinks service to,
2842+
// so the shared record + messages live in the SAME store as
2843+
// sys_share_link. `resolveService('objectql', env)` can hand back a
2844+
// different (host/scoped) engine that lacks the per-env rows.
2845+
try {
2846+
const k: any = this.kernel;
2847+
const e = typeof k?.getServiceAsync === 'function'
2848+
? await k.getServiceAsync('objectql')
2849+
: k?.getService?.('objectql');
2850+
if (e) return e;
2851+
} catch { /* fall through to scoped resolution */ }
2852+
return this.resolveService('objectql', context.environmentId);
2853+
};
2854+
const asArray = (rows: any): any[] => (Array.isArray(rows) ? rows : Array.isArray(rows?.value) ? rows.value : []);
2855+
const applyRedaction = (record: any, redactFields: string[]): any => {
2856+
if (!record || typeof record !== 'object' || redactFields.length === 0) return record;
2857+
const out: any = {};
2858+
for (const [k, v] of Object.entries(record)) {
2859+
if (redactFields.includes(k)) continue;
2860+
out[k] = v;
2861+
}
2862+
return out;
2863+
};
2864+
2865+
try {
2866+
// ── PUBLIC: resolve a token → record ──────────────────────────
2867+
if (parts.length === 2 && parts[1] === 'resolve' && m === 'GET') {
2868+
const token = decodeURIComponent(parts[0]);
2869+
const signedInUserId = ec?.userId;
2870+
const recipientEmail = typeof query?.email === 'string' ? query.email : undefined;
2871+
const providedPassword =
2872+
typeof query?.password === 'string' ? (query.password as string) : headerOf('x-share-password');
2873+
2874+
const resolved = await svc.resolveToken(token, { signedInUserId, recipientEmail, providedPassword });
2875+
if (!resolved) {
2876+
// Probe the row to return a more useful status (401 vs 410 vs 404).
2877+
const engine = await getEngine();
2878+
const probe = engine
2879+
? asArray(await engine.find('sys_share_link', { where: { token }, limit: 1, context: SYSTEM_CTX } as any))
2880+
: [];
2881+
const row = probe[0] ?? null;
2882+
const live = row && !row.revoked_at && (!row.expires_at || Date.parse(row.expires_at) > Date.now());
2883+
if (live && row.password_hash) {
2884+
return sendErr(401, providedPassword ? 'WRONG_PASSWORD' : 'NEEDS_PASSWORD',
2885+
providedPassword ? 'Incorrect password' : 'This link requires a password');
2886+
}
2887+
if (live && row.audience === 'signed_in' && !signedInUserId) {
2888+
return sendErr(401, 'SIGN_IN_REQUIRED', 'Please sign in to view this link');
2889+
}
2890+
if (row && (row.revoked_at || (row.expires_at && Date.parse(row.expires_at) <= Date.now()))) {
2891+
return sendErr(410, 'EXPIRED_OR_REVOKED', 'Share link has expired or been revoked');
2892+
}
2893+
return sendErr(404, 'INVALID_OR_EXPIRED', 'Share link is invalid, expired, or revoked');
2894+
}
2895+
2896+
const engine = await getEngine();
2897+
const rows = engine
2898+
? asArray(await engine.find(resolved.link.object_name, { where: { id: resolved.link.record_id }, limit: 1, context: SYSTEM_CTX } as any))
2899+
: [];
2900+
const record = rows[0] ?? null;
2901+
if (!record) return sendErr(410, 'RECORD_GONE', 'The shared record no longer exists');
2902+
2903+
return {
2904+
handled: true,
2905+
response: this.success({
2906+
record: applyRedaction(record, resolved.redactFields),
2907+
link: {
2908+
id: resolved.link.id,
2909+
token: resolved.link.token,
2910+
object_name: resolved.link.object_name,
2911+
record_id: resolved.link.record_id,
2912+
permission: resolved.link.permission,
2913+
audience: resolved.link.audience,
2914+
expires_at: resolved.link.expires_at,
2915+
label: resolved.link.label,
2916+
created_at: resolved.link.created_at,
2917+
},
2918+
redactFields: resolved.redactFields,
2919+
}),
2920+
};
2921+
}
2922+
2923+
// ── PUBLIC: ai_conversations messages for a resolved token ────
2924+
if (parts.length === 2 && parts[1] === 'messages' && m === 'GET') {
2925+
const token = decodeURIComponent(parts[0]);
2926+
const providedPassword =
2927+
typeof query?.password === 'string' ? (query.password as string) : headerOf('x-share-password');
2928+
const resolved = await svc.resolveToken(token, { signedInUserId: ec?.userId, providedPassword });
2929+
if (!resolved) return sendErr(404, 'NOT_FOUND', 'Share link not found');
2930+
if (resolved.link.object_name !== 'ai_conversations') {
2931+
return sendErr(400, 'UNSUPPORTED', 'This share link does not expose messages');
2932+
}
2933+
const engine = await getEngine();
2934+
const rows = engine
2935+
? asArray(await engine.find('ai_messages', {
2936+
where: { conversation_id: resolved.link.record_id },
2937+
sort: [{ field: 'created_at', order: 'asc' }],
2938+
limit: 500,
2939+
context: SYSTEM_CTX,
2940+
} as any))
2941+
: [];
2942+
return { handled: true, response: this.success(rows) };
2943+
}
2944+
2945+
// ── AUTHENTICATED: create / list / revoke ─────────────────────
2946+
if (!callerCtx.userId) return sendErr(401, 'UNAUTHENTICATED', 'Sign in to manage share links');
2947+
2948+
// POST /share-links → create
2949+
if (parts.length === 0 && m === 'POST') {
2950+
const b: any = body ?? {};
2951+
if (!b.object || !b.recordId) return sendErr(400, 'VALIDATION_FAILED', 'object and recordId are required');
2952+
const link = await svc.createLink(
2953+
{
2954+
object: b.object,
2955+
recordId: b.recordId,
2956+
permission: b.permission,
2957+
audience: b.audience,
2958+
expiresAt: b.expiresAt ?? null,
2959+
emailAllowlist: b.emailAllowlist,
2960+
password: b.password,
2961+
redactFields: b.redactFields,
2962+
label: b.label,
2963+
},
2964+
callerCtx,
2965+
);
2966+
return { handled: true, response: { status: 201, body: { success: true, data: link, link } } };
2967+
}
2968+
2969+
// GET /share-links?object&recordId → list the caller's own links
2970+
if (parts.length === 0 && m === 'GET') {
2971+
const links = await svc.listLinks(
2972+
{
2973+
object: typeof query?.object === 'string' ? query.object : undefined,
2974+
recordId: typeof query?.recordId === 'string' ? query.recordId : undefined,
2975+
// Constrain to links the caller created so a guessed
2976+
// recordId can never enumerate another user's tokens.
2977+
createdBy: callerCtx.userId,
2978+
includeRevoked: query?.includeRevoked === 'true' || query?.includeRevoked === '1',
2979+
},
2980+
callerCtx,
2981+
);
2982+
return { handled: true, response: { status: 200, body: { success: true, data: links, links } } };
2983+
}
2984+
2985+
// DELETE /share-links/:idOrToken → revoke
2986+
if (parts.length === 1 && m === 'DELETE') {
2987+
await svc.revokeLink(decodeURIComponent(parts[0]), callerCtx);
2988+
return { handled: true, response: this.success({ ok: true }) };
2989+
}
2990+
2991+
return { handled: true, response: this.routeNotFound(`/share-links${subPath}`) };
2992+
} catch (err: any) {
2993+
return sendErr(err?.status ?? 500, err?.code ?? 'INTERNAL', err?.message ?? 'Share link request failed');
2994+
}
2995+
}
2996+
27852997
/**
27862998
* Main Dispatcher Entry Point
27872999
* Routes the request to the appropriate handler based on path and precedence
@@ -2932,6 +3144,12 @@ export class HttpDispatcher {
29323144
return this.handleAI(cleanPath, method, body, query, context);
29333145
}
29343146

3147+
// Share links — capability-token record sharing, dispatched to the
3148+
// per-env `shareLinks` service (links + record live in the same kernel).
3149+
if (cleanPath === '/share-links' || cleanPath.startsWith('/share-links/')) {
3150+
return this.handleShareLinks(cleanPath.substring('/share-links'.length), method, body, query, context);
3151+
}
3152+
29353153
// OpenAPI Specification
29363154
if (cleanPath === '/openapi.json' && method === 'GET') {
29373155
try {

0 commit comments

Comments
 (0)