Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion apps/insights/src/generation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"],
},
};

Expand Down
39 changes: 34 additions & 5 deletions packages/ai/src/ai/tools/annotations.ts
Original file line number Diff line number Diff line change
@@ -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");
Expand Down Expand Up @@ -87,18 +88,45 @@ 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;
Comment on lines +102 to +104

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Domain→UUID mapping silently misses defaultWebsiteId-only contexts

Branch 2 returns ctx.websiteId, but the codebase uses defaultWebsiteId ?? websiteId as the canonical primary identifier (see requireWebsiteId in context.ts). When ctx.websiteId is falsy but ctx.defaultWebsiteId holds the correct UUID — a valid AppContext for non-insights chat agents — the guard && ctx.websiteId causes a fallthrough to the accessibleWebsites scan. If that list is also empty (common in lightweight contexts), the raw domain string passes through to the RPC layer, reproducing the same NOT_FOUND error this PR was written to fix. The fix is one line: return ctx.websiteId ?? ctx.defaultWebsiteId instead of just ctx.websiteId.

}
// Input matches an accessible website's domain → return its UUID.
const byDomain = (ctx.accessibleWebsites ?? []).find(
(w) => w.domain === inputId
);
Comment on lines +103 to +109

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Domain comparisons use strict equality, making them case-sensitive. LLMs occasionally return domains with mixed capitalisation (e.g. AbcEducation.edu.au vs the stored abceducation.edu.au), which would cause both branch 2 and branch 3 to miss and silently fall through to returning the raw domain string.

Suggested change
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 (ctx.websiteDomain && inputId.toLowerCase() === ctx.websiteDomain.toLowerCase() && ctx.websiteId) {
return ctx.websiteId;
}
// Input matches an accessible website's domain → return its UUID.
const byDomain = (ctx.accessibleWebsites ?? []).find(
(w) => w.domain?.toLowerCase() === inputId.toLowerCase()
);

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:
"List annotations for a chart context (metadata, text, tags, timing).",
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 {
Expand All @@ -107,7 +135,7 @@ export function createAnnotationTools() {
};
} catch (error) {
logger.error("Failed to list annotations", {
websiteId,
websiteId: resolvedWebsiteId,
chartType,
error,
});
Expand Down Expand Up @@ -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})`;
Expand All @@ -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,
Expand All @@ -170,7 +199,7 @@ export function createAnnotationTools() {
"annotations",
"create",
{
websiteId,
websiteId: resolvedWebsiteId,
chartType,
chartContext,
annotationType,
Expand All @@ -192,7 +221,7 @@ export function createAnnotationTools() {
};
} catch (error) {
logger.error("Failed to create annotation", {
websiteId,
websiteId: resolvedWebsiteId,
chartType,
text,
error,
Expand Down
18 changes: 17 additions & 1 deletion packages/rpc/src/procedures/with-workspace.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,18 @@ export interface WithWorkspaceOptions<R extends ResourceType = "organization"> {
permissions?: PermissionFor<R>[];
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;
}

Expand Down Expand Up @@ -278,11 +290,15 @@ export async function withWorkspace<R extends ResourceType = "organization">(
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 };
Expand Down
6 changes: 6 additions & 0 deletions packages/rpc/src/routers/annotations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -321,6 +325,7 @@ export const annotationsRouter = {
await withWorkspace(context, {
websiteId: annotation.websiteId,
permissions: ["update"],
scopeResource: "organization",
});

const updateData: {
Expand Down Expand Up @@ -384,6 +389,7 @@ export const annotationsRouter = {
await withWorkspace(context, {
websiteId: annotation.websiteId,
permissions: ["delete"],
scopeResource: "organization",
});

await context.db
Expand Down
Loading