diff --git a/apps/insights/src/generation.ts b/apps/insights/src/generation.ts index 2450f0d81..dcc3014d0 100644 --- a/apps/insights/src/generation.ts +++ b/apps/insights/src/generation.ts @@ -314,7 +314,10 @@ async function runInsightsAgent(params: { chatId: `insights:${params.organizationId}:${params.websiteId}`, serviceAuth: { organizationId: params.organizationId, - scopes: ["read:data"], + // read:data for analytics queries; manage:config for annotation + // create/update/delete (which use scopeResource="organization" → + // "organization"+"update" maps to manage:config). + scopes: ["read:data", "manage:config"], }, }; diff --git a/packages/ai/src/ai/tools/annotations.ts b/packages/ai/src/ai/tools/annotations.ts index 096f40461..119d779d8 100644 --- a/packages/ai/src/ai/tools/annotations.ts +++ b/packages/ai/src/ai/tools/annotations.ts @@ -1,6 +1,7 @@ import { tool } from "ai"; import dayjs from "dayjs"; import { z } from "zod"; +import type { AppContext } from "../config/context"; import { callRPCProcedure, createToolLogger, getAppContext } from "./utils"; const logger = createToolLogger("Annotations Tools"); @@ -87,6 +88,32 @@ const deleteAnnotationInputSchema = z.object({ confirmed: z.boolean().describe("false=preview, true=delete"), }); +/** + * The insights agent receives the website domain (e.g. "example.com") as its + * primary identifier, so LLMs routinely pass a domain name where the backend + * expects a UUID. This helper maps a domain name back to the UUID that the RPC + * layer understands, using the app context that is injected at agent run time. + */ +function resolveWebsiteId(ctx: AppContext, inputId: string): string { + // Direct match against context UUID — already correct. + if (inputId === ctx.websiteId || inputId === ctx.defaultWebsiteId) { + return inputId; + } + // Input is the context domain → return the context UUID. + if (ctx.websiteDomain && inputId === ctx.websiteDomain && ctx.websiteId) { + return ctx.websiteId; + } + // Input matches an accessible website's domain → return its UUID. + const byDomain = (ctx.accessibleWebsites ?? []).find( + (w) => w.domain === inputId + ); + if (byDomain) { + return byDomain.id; + } + // Pass through — might already be a valid UUID or will fail at the RPC layer. + return inputId; +} + export function createAnnotationTools() { const listAnnotationsTool = tool({ description: @@ -94,11 +121,12 @@ export function createAnnotationTools() { inputSchema: listAnnotationsInputSchema, execute: async ({ websiteId, chartType, chartContext }, options) => { const context = getAppContext(options); + const resolvedWebsiteId = resolveWebsiteId(context, websiteId); try { const result = await callRPCProcedure( "annotations", "list", - { websiteId, chartType, chartContext }, + { websiteId: resolvedWebsiteId, chartType, chartContext }, context ); return { @@ -107,7 +135,7 @@ export function createAnnotationTools() { }; } catch (error) { logger.error("Failed to list annotations", { - websiteId, + websiteId: resolvedWebsiteId, chartType, error, }); @@ -140,6 +168,7 @@ export function createAnnotationTools() { options ) => { const context = getAppContext(options); + const resolvedWebsiteId = resolveWebsiteId(context, websiteId); try { if (!confirmed) { const dateRangePreview = `${chartContext.dateRange.start_date} to ${chartContext.dateRange.end_date} (${chartContext.dateRange.granularity})`; @@ -149,7 +178,7 @@ export function createAnnotationTools() { message: "Please review the annotation details below and confirm if you want to create it:", annotation: { - websiteId, + websiteId: resolvedWebsiteId, chartType, dateRange: dateRangePreview, annotationType, @@ -170,7 +199,7 @@ export function createAnnotationTools() { "annotations", "create", { - websiteId, + websiteId: resolvedWebsiteId, chartType, chartContext, annotationType, @@ -192,7 +221,7 @@ export function createAnnotationTools() { }; } catch (error) { logger.error("Failed to create annotation", { - websiteId, + websiteId: resolvedWebsiteId, chartType, text, error, diff --git a/packages/rpc/src/procedures/with-workspace.ts b/packages/rpc/src/procedures/with-workspace.ts index 423fc0bca..14a5fabc0 100644 --- a/packages/rpc/src/procedures/with-workspace.ts +++ b/packages/rpc/src/procedures/with-workspace.ts @@ -33,6 +33,18 @@ export interface WithWorkspaceOptions { permissions?: PermissionFor[]; requiredPlans?: PlanId[]; resource?: R; + /** + * Override the resource type used for API-key scope resolution only. + * When provided, scope checks use this resource instead of the automatically + * derived `effectiveResource` (which is always "website" when `websiteId` is + * given). Role-based user permission checks are unaffected. + * + * Use this when the logical permission granularity for an endpoint differs + * from "website management". For example, annotation CRUD is semantically a + * config-level operation (`manage:config`) even though it operates on a + * website-scoped entity. + */ + scopeResource?: string; websiteId?: string; } @@ -278,11 +290,15 @@ export async function withWorkspace( if (context.apiKey) { const plan = await planPromise; requirePlan(plan, requiredPlans); + // Use scopeResource (when provided) for API-key scope resolution so that + // endpoints like annotation CRUD can require manage:config rather than + // the manage:websites scope that effectiveResource="website" would imply. + const apiKeyScopeResource = options.scopeResource ?? effectiveResource; const ws = resolveApiKeyWorkspace( context, organizationId, plan, - effectiveResource, + apiKeyScopeResource, effectivePermissions ); return { ...ws, website, getCreatedBy }; diff --git a/packages/rpc/src/routers/annotations.ts b/packages/rpc/src/routers/annotations.ts index 582c45edc..d00e79df5 100644 --- a/packages/rpc/src/routers/annotations.ts +++ b/packages/rpc/src/routers/annotations.ts @@ -254,6 +254,10 @@ export const annotationsRouter = { const workspace = await withWorkspace(context, { websiteId: input.websiteId, permissions: ["update"], + // Annotation CRUD is a config-level operation; API keys need + // manage:config rather than manage:websites (the default scope + // for effectiveResource="website"+"update"). + scopeResource: "organization", }); const createdBy = await workspace.getCreatedBy(); @@ -321,6 +325,7 @@ export const annotationsRouter = { await withWorkspace(context, { websiteId: annotation.websiteId, permissions: ["update"], + scopeResource: "organization", }); const updateData: { @@ -384,6 +389,7 @@ export const annotationsRouter = { await withWorkspace(context, { websiteId: annotation.websiteId, permissions: ["delete"], + scopeResource: "organization", }); await context.db