import { and, eq } from "drizzle-orm"; import { z } from "zod"; import { defineAction } from "../../action.js"; import { getAppConfig } from "../../app-config/index.js"; import { CORE_RESOURCE_SHARED_EMAIL_ID, renderTransactionalEmail, } from "../../email-catalog/templates.js"; import { getAppProductionUrl } from "../../server/app-url.js"; import { sendEmail, isEmailConfigured } from "../../server/email.js"; import { getRequestUserEmail } from "../../server/request-context.js"; import { track } from "../../tracking/registry.js"; import { getUserProfile } from "../../user-profile/store.js"; import { assertAccess } from "../access.js"; import { isAnyOrgAgent, orgAgentName } from "../agent-principals.js"; import { announceResourceAccessChange, grantResourceAccess, isEmailPrincipalId, isOrgPrincipal, normalizePrincipalId, principalIdMatches, } from "../grant.js"; import { requireShareableResource } from "../registry.js"; import type { ShareEmailExtras } from "../registry.js"; import { assertWidgetShareWriteGrant, widgetShareMessage, } from "../widget-grant.js"; import { resourceSharingChange } from "./change-result.js"; function appPath(path: string): string { if (!path.startsWith("/")) return path; const raw = process.env.VITE_APP_BASE_PATH || process.env.APP_BASE_PATH || ""; const base = raw.trim().replace(/^\/+/, "").replace(/\/+$/, ""); if (!base) return path; const normalizedBase = `/${base}`; if (path === normalizedBase || path.startsWith(`${normalizedBase}/`)) { return path; } return `${normalizedBase}${path}`; } function safeNotificationUrl(value: string, appUrl: string): string | null { const trimmed = value.trim(); if (!trimmed) return null; try { const base = new URL(appUrl); if (trimmed.startsWith("/")) { const path = appPath(trimmed); const basePath = base.pathname.replace(/\/+$/, ""); const alreadyIncludesBase = basePath && basePath !== "/" && path.startsWith(`${basePath}/`); const joined = alreadyIncludesBase ? `${base.origin}${path}` : `${appUrl.replace(/\/+$/, "")}${path}`; return new URL(joined).toString(); } const url = new URL(trimmed); if (!["http:", "https:"].includes(url.protocol)) return null; if (url.origin !== base.origin) return null; return url.toString(); } catch { return null; } } export function resolveShareNotificationUrl( explicitUrl: string | undefined, fallbackPath: string | undefined, appUrl = getAppProductionUrl(), ): string { for (const candidate of [explicitUrl, fallbackPath]) { if (!candidate) continue; const url = safeNotificationUrl(candidate, appUrl); if (url) return url; } return appUrl; } async function needsExternalShareApproval(args: { resourceType: string; resourceId: string; principalType: "user" | "group" | "org"; principalId: string; role: "viewer" | "commenter" | "editor" | "admin"; }): Promise { if (args.principalType === "group") return false; const reg = requireShareableResource(args.resourceType); if (reg.requireOrgMemberForUserShares) return false; const access = await assertAccess( args.resourceType, args.resourceId, "admin", undefined, { skipResourceBody: true }, ); const resourceOrgId = access.resource.orgId as string | null | undefined; const db = reg.getDb() as any; if (args.principalType === "org") { if (resourceOrgId && args.principalId === resourceOrgId) return false; } else { if (!isEmailPrincipalId(args.principalId)) return false; const recipient = normalizePrincipalId("user", args.principalId); // raw-sql-ok: only bound args; the flagged flow is the constant column list in listServicePrincipalPolicies. if (resourceOrgId && (await isOrgPrincipal(resourceOrgId, recipient))) { return false; } } const [existing] = await db .select({ role: reg.sharesTable.role }) .from(reg.sharesTable) .where( and( eq(reg.sharesTable.resourceId, args.resourceId), eq(reg.sharesTable.principalType, args.principalType), principalIdMatches( reg.sharesTable, args.principalType, normalizePrincipalId(args.principalType, args.principalId), ), ), ); return existing?.role !== args.role; } /** Agents are never emailed; an unreadable check counts as an agent. */ async function isAgentRecipient(email: string): Promise { try { return await isAnyOrgAgent(email); } catch (error) { // coercion-ok: skipping one share email is the safe outcome, and it is logged. console.error( "[share-resource] Could not check whether the recipient is an agent; not emailing it:", error, ); return true; } } export default defineAction({ description: "Grant a user, group, or org access to a shareable resource. Owner or admin role required.", toolCallable: false, schema: z.object({ resourceType: z .string() .describe("Registered resource type, e.g. 'document', 'form'."), resourceId: z.string().describe("Id of the resource to share."), principalType: z .enum(["user", "group", "org"]) .describe( "'user' for an individual, 'group' for an organization group, or 'org' for the whole organization.", ), principalId: z .string() .describe( "Email (user), group id (group), or org id (org) of the principal. An org agent is a user whose email is its service identity (svc-@service., from list-resource-shares `agents`); agents are never emailed.", ), role: z .enum(["viewer", "commenter", "editor", "admin"]) .default("viewer") .describe( "Role to grant: viewer can only read; commenter can read and add comments; editor can edit; admin can edit and manage access.", ), notify: z .boolean() .default(true) .describe( "Whether to email the user about a new individual share. Defaults to true.", ), resourceUrl: z .string() .optional() .describe( "Optional app-relative or same-origin URL recipients should open. External origins are ignored.", ), message: z .string() .trim() .max(500) .optional() .describe( "Optional short note included in the notification email to an individual recipient.", ), }), needsApproval: needsExternalShareApproval, mcpAnnotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true, }, run: async (args, ctx) => { assertWidgetShareWriteGrant(ctx, "share-resource", args); const reg = requireShareableResource(args.resourceType); const grant = await grantResourceAccess({ resourceType: args.resourceType, resourceId: args.resourceId, principalType: args.principalType, principalId: args.principalId, role: args.role, }); const actor = getRequestUserEmail()!; const { id, principalId, resource: accessResource } = grant; await announceResourceAccessChange( args.resourceType, args.resourceId, grant.extensionTargetsBefore, ); if (!grant.created) { return { id, updated: grant.updated, ...(grant.updated ? { change: resourceSharingChange( reg, accessResource, "updated", `${args.principalType}:${principalId} · ${args.role}`, ).change, } : {}), }; } const db = reg.getDb() as any; const shouldNotify = args.notify !== false && args.principalType === "user" && orgAgentName( principalId, accessResource.orgId as string | null | undefined, ) === null && !(await isAgentRecipient(principalId)) && (await isEmailConfigured()); let notified = false; if (shouldNotify) { try { const titleCol = reg.titleColumn ?? "title"; const [resource] = await db .select() .from(reg.resourceTable) .where(eq(reg.resourceTable.id, args.resourceId)); const resourceTitle: string = (resource?.[titleCol] as string | undefined) ?? args.resourceType; const appUrl = getAppProductionUrl(); const resourcePath = resource && reg.getResourcePath ? reg.getResourcePath(resource) : undefined; const notificationUrl = resolveShareNotificationUrl( args.resourceUrl, resourcePath, appUrl, ); const appName = process.env.APP_NAME || process.env.VITE_APP_NAME || "Agent-Native"; // config-ok: preserve legacy app-name aliases for existing deployments. let brandLogoUrl: string | undefined; if (reg.getLogoUrl) { try { brandLogoUrl = (await reg.getLogoUrl(resource)) ?? undefined; } catch (err) { console.error( "[share-resource] brand logo resolver failed; using default logo:", err, ); } } let brandName = appName; if (reg.getBrandName) { try { brandName = (await reg.getBrandName(resource))?.trim() || appName; } catch (err) { console.error( "[share-resource] brand name resolver failed; using app name:", err, ); } } const senderProfile = await getUserProfile(actor); const senderDisplayName = senderProfile.name?.trim() || actor; let fromName: string | undefined; let replyTo: string | undefined; if (reg.getSender) { try { const sender = await reg.getSender(resource, { sender: senderProfile, }); fromName = sender?.fromName?.trim() || undefined; replyTo = sender?.replyTo?.trim() || undefined; } catch (err) { console.error( "[share-resource] sender resolver failed; using default sender:", err, ); } } let heroHtml: string | undefined; if (reg.getHeroHtml) { try { heroHtml = (await reg.getHeroHtml(resource, { href: notificationUrl, alt: resourceTitle, })) ?? undefined; } catch (err) { console.error( "[share-resource] hero html resolver failed; omitting preview:", err, ); } } let extras: ShareEmailExtras | undefined; if (reg.getShareEmailExtras) { try { extras = (await reg.getShareEmailExtras(resource, { href: notificationUrl, sender: senderProfile, recipientEmail: principalId, })) ?? undefined; } catch (err) { console.error( "[share-resource] share email extras resolver failed; sending the plain notification:", err, ); } } const { subject, html, text } = await renderTransactionalEmail( CORE_RESOURCE_SHARED_EMAIL_ID, { recipientEmail: principalId, sender: { name: senderDisplayName, email: actor }, resource: { type: args.resourceType, label: reg.displayName, title: resourceTitle, url: notificationUrl, }, role: args.role, message: widgetShareMessage(ctx, args.message), app: { name: brandName, logoUrl: brandLogoUrl }, heroHtml, extras, }, ); const sent = await sendEmail({ to: principalId, subject, html, text, fromName, replyTo, templateId: CORE_RESOURCE_SHARED_EMAIL_ID, }); notified = sent.status === "sent"; } catch (err) { console.error( "[share-resource] failed to send share notification:", err, ); } } if (notified) { try { await db .update(reg.sharesTable) .set({ notifiedAt: new Date().toISOString() }) .where(eq(reg.sharesTable.id, id)); } catch (err) { console.error( "[share-resource] share email sent but notified_at was not recorded:", err, ); } } if (args.principalType === "user") { const app = getAppConfig().app.slug ?? "unknown"; track( "share_invite_sent", { app, template: app, resource_type: args.resourceType, resource_id: args.resourceId, principal_type: args.principalType, role: args.role, notified, }, { userId: actor }, ); } return { id, updated: false, change: resourceSharingChange( reg, accessResource, "created", `${args.principalType}:${principalId} · ${args.role}`, ).change, }; }, });