From 819308dfe3488e23b21f3c7b196c9134fcf52038 Mon Sep 17 00:00:00 2001 From: iza <59828082+izadoesdev@users.noreply.github.com> Date: Fri, 21 Aug 2026 12:07:54 +0300 Subject: [PATCH 01/88] fix(rpc): scope insight audit targets after authorization --- packages/rpc/src/routers/insight-generation.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/rpc/src/routers/insight-generation.ts b/packages/rpc/src/routers/insight-generation.ts index 8e03d832b6..c7f57043db 100644 --- a/packages/rpc/src/routers/insight-generation.ts +++ b/packages/rpc/src/routers/insight-generation.ts @@ -383,12 +383,12 @@ async function resolveOrganization( if (!organizationId) { throw rpcError.badRequest("Organization ID is required"); } - setAuditOrganization(context, organizationId); await withWorkspace(context, { organizationId, resource: "organization", permissions: [permission], }); + setAuditOrganization(context, organizationId); return organizationId; } From a29641907a6073320c184150649ec13c28f000db Mon Sep 17 00:00:00 2001 From: iza <59828082+izadoesdev@users.noreply.github.com> Date: Fri, 21 Aug 2026 12:09:32 +0300 Subject: [PATCH 02/88] fix(rpc): scope insight audit targets after authorization --- packages/rpc/src/routers/insights.ts | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/packages/rpc/src/routers/insights.ts b/packages/rpc/src/routers/insights.ts index 6c6c80b660..5c924af4fa 100644 --- a/packages/rpc/src/routers/insights.ts +++ b/packages/rpc/src/routers/insights.ts @@ -508,14 +508,13 @@ export async function appendInvestigationReply( if (!insight) { throw rpcError.notFound("insight", parsed.insightId); } - setAuditOrganization(context, insight.organizationId); - await withWorkspace(context, { allowCrossOrg: true, organizationId: insight.organizationId, permissions: ["update"], websiteId: insight.websiteId, }); + setAuditOrganization(context, insight.organizationId); const author = replyAuthor(context, authorName); const createdAt = new Date(); @@ -726,8 +725,6 @@ export async function applyInsightAction(input: { if (!target) { throw rpcError.notFound("insight", parsed.insightId); } - setAuditOrganization(context, target.organizationId); - const [latestObservation] = await db .select({ outcome: insightObservations.outcome, @@ -763,6 +760,7 @@ export async function applyInsightAction(input: { permissions: initialAction.operation === "delete" ? ["delete"] : ["update"], websiteId: target.websiteId, }); + setAuditOrganization(context, target.organizationId); const author = replyAuthor(context); const completed = await db.transaction(async (tx) => { @@ -1664,13 +1662,13 @@ export const insightsRouter = { if (!reply) { throw rpcError.notFound("insight reply", input.replyId); } - setAuditOrganization(context, reply.organizationId); await withWorkspace(context, { allowCrossOrg: true, organizationId: reply.organizationId, permissions: ["update"], websiteId: reply.websiteId, }); + setAuditOrganization(context, reply.organizationId); const pendingStatus = await db.transaction(async (tx) => { const insightCase = and( eq(analyticsInsights.organizationId, reply.organizationId), From 1fbd8cc60d6429265c17013d14e43add56dff70e Mon Sep 17 00:00:00 2001 From: iza <59828082+izadoesdev@users.noreply.github.com> Date: Fri, 21 Aug 2026 12:11:23 +0300 Subject: [PATCH 03/88] fix(sdk): preserve flag variant telemetry identity --- packages/sdk/src/core/flags/flags-manager.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/packages/sdk/src/core/flags/flags-manager.ts b/packages/sdk/src/core/flags/flags-manager.ts index e6837f0b4b..b46d3915c4 100644 --- a/packages/sdk/src/core/flags/flags-manager.ts +++ b/packages/sdk/src/core/flags/flags-manager.ts @@ -850,7 +850,14 @@ export class BrowserFlagsManager extends BaseFlagsManager { } protected override onFlagEvaluated(key: string, result: FlagResult): void { - const dedupeKey = `${key}:${String(result.value)}`; + let valueKey: string; + try { + valueKey = JSON.stringify(result.value) ?? String(result.value); + } catch { + // A malformed custom value should not prevent telemetry. + valueKey = String(result.value); + } + const dedupeKey = `${key}:${result.variant ?? ""}:${valueKey}`; if (this.trackedFlags.has(dedupeKey)) { return; } From 57df4f744aed8f52b6552c224acb501aeab391df Mon Sep 17 00:00:00 2001 From: iza <59828082+izadoesdev@users.noreply.github.com> Date: Fri, 21 Aug 2026 22:01:05 +0300 Subject: [PATCH 04/88] feat(dashboard): add MCP setup and quick access --- apps/api/src/routes/discovery.ts | 2 +- .../components/integrations-settings.tsx | 127 +++- .../components/layout/mobile-sidebar.tsx | 13 + apps/dashboard/components/layout/top-bar.tsx | 20 +- .../organizations/mcp-config.test.ts | 30 + .../components/organizations/mcp-config.ts | 28 + .../organizations/mcp-setup-sheet.tsx | 624 ++++++++++++++++++ apps/docs/content/docs/api/mcp.mdx | 8 +- packages/env/src/app.test.ts | 3 + packages/env/src/app.ts | 7 +- 10 files changed, 856 insertions(+), 6 deletions(-) create mode 100644 apps/dashboard/components/organizations/mcp-config.test.ts create mode 100644 apps/dashboard/components/organizations/mcp-config.ts create mode 100644 apps/dashboard/components/organizations/mcp-setup-sheet.tsx diff --git a/apps/api/src/routes/discovery.ts b/apps/api/src/routes/discovery.ts index c9e97c94f6..8fdacf1f11 100644 --- a/apps/api/src/routes/discovery.ts +++ b/apps/api/src/routes/discovery.ts @@ -28,7 +28,7 @@ const discoveryUrls = { dashboardUrl: config.urls.dashboard, openapiSpecUrl: `${SITE_URL}/openapi.json`, apiOpenapiSpecUrl: `${API_URL}/openapi.json`, - mcpServerUrl: `${API_URL}/v1/mcp/`, + mcpServerUrl: config.urls.mcp, mcpManifestUrl: `${SITE_URL}/.well-known/mcp.json`, apiCatalogUrl: `${API_URL}/.well-known/api-catalog`, protectedResourceMetadataUrl: `${API_URL}/.well-known/oauth-protected-resource`, diff --git a/apps/dashboard/app/(main)/organizations/components/integrations-settings.tsx b/apps/dashboard/app/(main)/organizations/components/integrations-settings.tsx index d2a1c7c53d..0fff4114c8 100644 --- a/apps/dashboard/app/(main)/organizations/components/integrations-settings.tsx +++ b/apps/dashboard/app/(main)/organizations/components/integrations-settings.tsx @@ -7,6 +7,12 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useSearchParams } from "next/navigation"; import { useEffect, useState } from "react"; import { toast } from "sonner"; +import { ApiKeySheet } from "@/components/organizations/api-key-sheet"; +import type { ApiKeyListItem } from "@/components/organizations/api-key-types"; +import { + McpConnectionDetails, + McpSetupSheet, +} from "@/components/organizations/mcp-setup-sheet"; import { TopBar } from "@/components/layout/top-bar"; import type { Organization } from "@/hooks/use-organizations"; import { orpc } from "@/lib/orpc"; @@ -79,6 +85,18 @@ const SLACK_ITEM: IntegrationCatalogItem = { name: "Slack", }; +const MCP_ITEM: IntegrationCatalogItem = { + accent: "#111827", + accentClassName: "bg-foreground/70", + category: "AI agent", + description: + "Ask Claude, Cursor, Windsurf, or another AI client about your Databuddy analytics.", + iconPath: + "M6 2a2 2 0 0 0-2 2v5a2 2 0 1 0 2 0V4h5a2 2 0 1 0-2-2H6Zm12 0a2 2 0 0 0-2 2v5H11a2 2 0 1 0 0 2h7a2 2 0 0 0 2-2V4a2 2 0 0 0-2-2ZM6 15a2 2 0 1 0 0 4v1a2 2 0 1 0 2 0v-1h5a2 2 0 1 0-2-2H8v-1a2 2 0 0 0-2-1Zm12 0a2 2 0 1 0 0 4h-5a2 2 0 1 0 0-2h5v-1a2 2 0 0 0-2-1Z", + id: "mcp", + name: "Databuddy MCP", +}; + const GITHUB_ITEM: IntegrationCatalogItem = { accent: "#181717", category: "Deployments", @@ -356,6 +374,8 @@ export function IntegrationsSettings({ + + (null); + const [manageOpen, setManageOpen] = useState(false); + + const keysQuery = useQuery({ + ...orpc.apikeys.list.queryOptions({ input: { organizationId } }), + }); + + const mcpKeys = ((keysQuery.data ?? []) as ApiKeyListItem[]).filter( + (key) => + key.type === "automation" && + (key.tags ?? []).some((tag) => tag.toLowerCase() === "mcp") + ); + const activeMcpKeys = mcpKeys.filter((key) => { + if (!key.enabled || key.revokedAt) { + return false; + } + return !key.expiresAt || dayjs(key.expiresAt).isAfter(dayjs()); + }); + + const statusBadge = keysQuery.isLoading ? ( + + Checking + + ) : activeMcpKeys.length > 0 ? ( + + {activeMcpKeys.length} connected + + ) : mcpKeys.length > 0 ? ( + + Needs attention + + ) : ( + + Not connected + + ); + + const openKey = (key: ApiKeyListItem) => { + setSelectedKey(key); + setManageOpen(true); + }; + + return ( + <> + setSetupOpen(true)} + size="sm" + variant="secondary" + > + + {mcpKeys.length > 0 ? "Add connection" : "Set up MCP"} + + } + badge={statusBadge} + defaultOpen={activeMcpKeys.length > 0} + item={MCP_ITEM} + > + + + + + queryClient.invalidateQueries({ + queryKey: orpc.apikeys.list.key(), + }) + } + onOpenChangeAction={setSetupOpen} + open={setupOpen} + organizationId={organizationId} + /> + + {selectedKey && ( + { + setManageOpen(open); + if (!open) { + setSelectedKey(null); + queryClient.invalidateQueries({ + queryKey: orpc.apikeys.list.key(), + }); + } + }} + open={manageOpen} + organizationId={organizationId} + /> + )} + + ); +} + function SlackIntegrationRow({ integrations, isLoading, @@ -927,7 +1044,10 @@ function IntegrationListRow({ if (!children) { return ( -
+
{header}
@@ -939,7 +1059,10 @@ function IntegrationListRow({ } return ( -
+
diff --git a/apps/dashboard/components/layout/mobile-sidebar.tsx b/apps/dashboard/components/layout/mobile-sidebar.tsx index 04c618b07b..c15b500934 100644 --- a/apps/dashboard/components/layout/mobile-sidebar.tsx +++ b/apps/dashboard/components/layout/mobile-sidebar.tsx @@ -17,6 +17,7 @@ import { MagnifyingGlassIcon, MonitorIcon, MoonIcon, + PlugIcon, SignOutIcon, SunIcon, } from "@databuddy/ui/icons"; @@ -342,6 +343,18 @@ export function MobileSidebar() {
+ {!isDemo && ( + + )} + )} +
+ ); + })} +
+ ); +} + +export function McpSetupSheet({ + organizationId, + open, + onCreated, + onOpenChangeAction, +}: { + organizationId: string; + open: boolean; + onCreated?: () => void; + onOpenChangeAction: (open: boolean) => void; +}) { + const queryClient = useQueryClient(); + const [client, setClient] = useState("cursor"); + const [name, setName] = useState(defaultConnectionName("cursor")); + const [allowInvestigationReplies, setAllowInvestigationReplies] = + useState(false); + const [selectedWebsiteIds, setSelectedWebsiteIds] = useState([]); + const [expiry, setExpiry] = useState("90d"); + const [newSecret, setNewSecret] = useState(null); + const [useEnvironmentVariable, setUseEnvironmentVariable] = useState(false); + + const websitesQuery = useQuery({ + ...orpc.websites.list.queryOptions({ + input: { organizationId }, + }), + enabled: open && !newSecret, + }); + + const createMutation = useMutation({ + ...orpc.apikeys.create.mutationOptions(), + onSuccess: (result) => { + setNewSecret(result.secret); + queryClient.invalidateQueries({ queryKey: orpc.apikeys.list.key() }); + onCreated?.(); + toast.success("MCP connection created"); + }, + onError: (error: Error) => { + toast.error( + getUserFacingErrorMessage(error, "Could not create the MCP connection.") + ); + }, + }); + + useEffect(() => { + if (!open) { + return; + } + setClient("cursor"); + setName(defaultConnectionName("cursor")); + setAllowInvestigationReplies(false); + setSelectedWebsiteIds([]); + setExpiry("90d"); + setNewSecret(null); + setUseEnvironmentVariable(false); + }, [open]); + + const selectedScopes = useMemo( + () => + allowInvestigationReplies + ? ["read:data", "manage:websites"] + : ["read:data"], + [allowInvestigationReplies] + ); + + const config = newSecret + ? createMcpConfig(newSecret, useEnvironmentVariable) + : ""; + + const handleClientChange = (nextClient: McpClient) => { + const previousDefault = defaultConnectionName(client); + if (name === previousDefault) { + setName(defaultConnectionName(nextClient)); + } + setClient(nextClient); + }; + + const toggleWebsite = (websiteId: string) => { + setSelectedWebsiteIds((current) => + current.includes(websiteId) + ? current.filter((id) => id !== websiteId) + : [...current, websiteId] + ); + }; + + const handleCreate = () => { + const trimmedName = name.trim(); + if (!trimmedName) { + toast.error("Give this connection a name first."); + return; + } + + const resources = + selectedWebsiteIds.length > 0 + ? Object.fromEntries( + selectedWebsiteIds.map((websiteId) => [ + `website:${websiteId}`, + selectedScopes, + ]) + ) + : undefined; + + createMutation.mutate({ + name: trimmedName, + description: `Databuddy MCP connection for ${CLIENT_LABELS[client]}`, + organizationId, + type: "automation", + scopes: resources ? [] : selectedScopes, + resources, + tags: ["MCP", CLIENT_LABELS[client]], + expiresAt: + expiry === "90d" ? dayjs().add(90, "day").toISOString() : undefined, + ratelimit: { enabled: true }, + }); + }; + + const handleClose = () => { + if (createMutation.isPending) { + return; + } + onOpenChangeAction(false); + }; + + return ( + + + +
+
+ +
+
+ + {newSecret ? "MCP is ready" : "Connect Databuddy MCP"} + + + {newSecret + ? "Copy the config into your AI client, then ask it to list your websites." + : "Give your AI tools a safe, scoped connection to Databuddy analytics."} + +
+
+
+ + + {newSecret ? ( + + ) : ( + <> + + Connection name + setName(event.target.value)} + value={name} + /> + + Use one connection per client or environment so each key can + be rotated independently. + + + +
+ AI client + ({ + label: option.label, + value: option.value, + }))} + size="sm" + value={client} + /> + + { + CLIENT_OPTIONS.find((option) => option.value === client) + ?.description + } + +
+ +
+
+
+ +
+
+ Read-only analytics + + Recommended. The client can discover websites and read + analytics, but cannot change your configuration. + +
+ + read:data + +
+
+ + setAllowInvestigationReplies(checked === true) + } + /> +
+
+ + + + + Website access + + {selectedWebsiteIds.length === 0 + ? "All websites" + : `${selectedWebsiteIds.length} selected`} + + + +
+ + Leave all websites unselected for organization-wide + access, or choose specific websites for a least-privilege + connection. + + {websitesQuery.isLoading ? ( +
+ + + Loading websites… + +
+ ) : websitesQuery.data && websitesQuery.data.length > 0 ? ( +
+ {websitesQuery.data.map((website) => ( +
+ toggleWebsite(website.id)} + /> +
+ ))} +
+ ) : ( +
+ + + No websites in this organization yet. + +
+ )} +
+
+
+ +
+ Key expiry + + + Keys are shown once and can be rotated or revoked from API + Keys. + +
+ +
+ + + The generated key authenticates an external AI client. Keep it + out of Git, screenshots, and shared prompts. + +
+ + )} +
+ + + {newSecret ? ( + + ) : ( + <> + + + + )} + +
+
+ ); +} + +function ConnectionCreated({ + client, + config, + onEnvironmentVariableChange, + secret, + useEnvironmentVariable, +}: { + client: McpClient; + config: string; + onEnvironmentVariableChange: (value: boolean) => void; + secret: string; + useEnvironmentVariable: boolean; +}) { + const clientDescription = CLIENT_OPTIONS.find( + (option) => option.value === client + )?.description; + + return ( +
+
+
+ +
+ + Connection created + + + {clientDescription} + +
+
+
+ +
+
+
+ Secret key + + Copy this now. It will not be shown again. + +
+ +
+
+ + + {secret} + +
+
+ +
+
+
+ Configuration + + Copy this into your client’s MCP settings. + +
+ +
+
+
+						{config}
+					
+ +
+
+ +
+ + onEnvironmentVariableChange(checked === true) + } + /> + + {MCP_ENV_VAR} + +
+ +
+ Test it +
+ + + List my Databuddy websites. + + +
+ + If the client returns a 401 or 403, rotate the key or check its access + in Organization Settings → API Keys. + +
+ +
+
+ + Server endpoint +
+
+ + {MCP_SERVER_URL} + + +
+
+
+ ); +} diff --git a/apps/docs/content/docs/api/mcp.mdx b/apps/docs/content/docs/api/mcp.mdx index b6e1c153d5..4dae84ce0f 100644 --- a/apps/docs/content/docs/api/mcp.mdx +++ b/apps/docs/content/docs/api/mcp.mdx @@ -51,9 +51,15 @@ Pass an API key with the `read:data` scope: /> - Get your API key from [Dashboard → Organization Settings → API Keys](https://app.databuddy.cc/organizations/settings#api-keys). The key needs at least the `read:data` scope. Add `manage:websites` to reply to investigations. + The quickest setup is [Dashboard → Organization Settings → Integrations](https://app.databuddy.cc/organizations/settings/integrations): choose **Databuddy MCP**, select the client and website access you want, then copy the generated config. The secret is shown only once. You can also create and manage keys from [API Keys](https://app.databuddy.cc/organizations/settings#api-keys). The key needs at least the `read:data` scope. Add `manage:websites` to reply to investigations. +### Dashboard setup + +The dashboard creates a dedicated automation key tagged `MCP` rather than requiring you to share a personal API key. You can create separate connections for Cursor, Claude, Windsurf, or another MCP client, scope a connection to specific websites, choose a 90-day expiry or no expiry, and rotate or revoke it later from **Organization Settings → API Keys**. + +For clients that support environment-variable interpolation in remote MCP headers, enable the environment-variable option in the setup sheet and set `DATABUDDY_API_KEY` before launching the client. Otherwise, paste the generated one-time config with the secret in the `x-api-key` header. + ## Client Setup ### Claude Code / Claude Desktop diff --git a/packages/env/src/app.test.ts b/packages/env/src/app.test.ts index ca9179019a..819eb7de5b 100644 --- a/packages/env/src/app.test.ts +++ b/packages/env/src/app.test.ts @@ -9,6 +9,7 @@ describe("createConfig", () => { basket: "http://localhost:4000", dashboard: "http://localhost:3000", links: "http://localhost:2500", + mcp: "http://localhost:3001/v1/mcp/", status: "http://localhost:3002", }, }); @@ -21,6 +22,7 @@ describe("createConfig", () => { basket: "https://basket.databuddy.cc", dashboard: "https://app.databuddy.cc", links: "https://dby.sh", + mcp: "https://api.databuddy.cc/v1/mcp/", status: "https://status.databuddy.cc", }, }); @@ -37,6 +39,7 @@ describe("createConfig", () => { urls: { api: "https://api.example.com", dashboard: "https://app.example.com", + mcp: "https://api.example.com/v1/mcp/", }, }); }); diff --git a/packages/env/src/app.ts b/packages/env/src/app.ts index dd1ea64886..da715ca3cc 100644 --- a/packages/env/src/app.ts +++ b/packages/env/src/app.ts @@ -35,6 +35,8 @@ const URLS = { }, } as const; +const MCP_SERVER_PATH = "/v1/mcp/"; + // Email sender defaults. Env fallback order works the same way as URLS. const EMAIL = { alertsFrom: { @@ -70,6 +72,7 @@ export interface Config { basket: string; dashboard: string; links: string; + mcp: string; status: string; }; } @@ -127,6 +130,7 @@ function readOrigins(values: Array): string[] { export function createConfig(env: Env = process.env): Config { const dashboardUrl = readUrl(env, URLS.dashboard); + const apiUrl = readUrl(env, URLS.api); return { cors: { @@ -145,10 +149,11 @@ export function createConfig(env: Env = process.env): Config { openAiAdsPixelId: readOptional(env, "NEXT_PUBLIC_OPENAI_ADS_PIXEL_ID"), }, urls: { - api: readUrl(env, URLS.api), + api: apiUrl, basket: readUrl(env, URLS.basket), dashboard: dashboardUrl, links: readUrl(env, URLS.links), + mcp: new URL(MCP_SERVER_PATH, apiUrl).toString(), status: readUrl(env, URLS.status), }, }; From b99bdc8dcbae40b05b0ce0ed794f9b9212ce26d8 Mon Sep 17 00:00:00 2001 From: iza <59828082+izadoesdev@users.noreply.github.com> Date: Fri, 21 Aug 2026 23:06:34 +0300 Subject: [PATCH 05/88] fix(api-keys): enforce organization ownership for scoped resources --- apps/api/src/middleware/website-auth.ts | 6 +- apps/api/src/routes/query.ts | 25 ++- packages/ai/src/lib/accessible-websites.ts | 10 +- packages/ai/src/lib/website-utils.ts | 11 +- packages/api-keys/src/resolve.test.ts | 52 +++++ packages/api-keys/src/resolve.ts | 17 ++ .../apikeys.resource-ownership.test.ts | 179 ++++++++++++++++++ packages/rpc/src/routers/apikeys.ts | 56 +++++- 8 files changed, 327 insertions(+), 29 deletions(-) create mode 100644 packages/rpc/src/routers/apikeys.resource-ownership.test.ts diff --git a/apps/api/src/middleware/website-auth.ts b/apps/api/src/middleware/website-auth.ts index 5d7000709f..0199fcbbd3 100644 --- a/apps/api/src/middleware/website-auth.ts +++ b/apps/api/src/middleware/website-auth.ts @@ -1,6 +1,6 @@ import { getApiKeyFromHeader, - hasWebsiteScope, + hasWebsiteScopeForOrganization, isApiKeyPresent, } from "@databuddy/api-keys/resolve"; import { auth } from "@databuddy/auth"; @@ -125,7 +125,7 @@ function isPreflight(request: Request): boolean { } async function checkWebsiteAuth( - websiteId: string, + _websiteId: string, sessionUser: SessionUser | null, website: Awaited> | null, apiKey: Awaited> | null, @@ -183,7 +183,7 @@ async function checkWebsiteAuth( code: "AUTH_REQUIRED", }); } - const ok = await hasWebsiteScope(apiKey, websiteId, "read:data"); + const ok = hasWebsiteScopeForOrganization(apiKey, website, "read:data"); if (!ok) { return json(403, { success: false, diff --git a/apps/api/src/routes/query.ts b/apps/api/src/routes/query.ts index 618b9f7ca7..83f43aab42 100644 --- a/apps/api/src/routes/query.ts +++ b/apps/api/src/routes/query.ts @@ -5,6 +5,7 @@ import { getApiKeyFromHeader, hasGlobalAccess, hasKeyScope, + hasWebsiteScopeForOrganization, isApiKeyPresent, } from "@databuddy/api-keys/resolve"; import { and, db, eq, inArray } from "@databuddy/db"; @@ -552,21 +553,17 @@ async function verifyWebsiteAccess( } if (ctx.apiKey) { - if (hasGlobalAccess(ctx.apiKey)) { - if (!ctx.apiKey.organizationId) { - mergeWideEvent({ access_result: "api_key_no_org" }); - return false; - } - const granted = website.organizationId === ctx.apiKey.organizationId; - mergeWideEvent({ - access_result: granted ? "api_key_global" : "api_key_denied", - }); - return granted; - } - - const granted = getAccessibleWebsiteIds(ctx.apiKey).includes(websiteId); + const granted = hasWebsiteScopeForOrganization( + ctx.apiKey, + website, + "read:data" + ); mergeWideEvent({ - access_result: granted ? "api_key_scoped" : "api_key_denied", + access_result: granted + ? hasGlobalAccess(ctx.apiKey) + ? "api_key_global" + : "api_key_scoped" + : "api_key_denied", }); return granted; } diff --git a/packages/ai/src/lib/accessible-websites.ts b/packages/ai/src/lib/accessible-websites.ts index 6d6310861c..ebd3ad67ae 100644 --- a/packages/ai/src/lib/accessible-websites.ts +++ b/packages/ai/src/lib/accessible-websites.ts @@ -108,13 +108,19 @@ export async function getAccessibleWebsites( const ids = getAccessibleWebsiteIds(authCtx.apiKey).filter((id) => hasWebsiteScope(authCtx.apiKey, id, "read:data") ); - if (ids.length === 0) { + if (ids.length === 0 || !authCtx.apiKey.organizationId) { return []; } return db .select(select) .from(websites) - .where(and(inArray(websites.id, ids), isNull(websites.deletedAt))) + .where( + and( + eq(websites.organizationId, authCtx.apiKey.organizationId), + inArray(websites.id, ids), + isNull(websites.deletedAt) + ) + ) .orderBy((t) => t.createdAt); } diff --git a/packages/ai/src/lib/website-utils.ts b/packages/ai/src/lib/website-utils.ts index d6146d0fe3..f5ec4632a5 100644 --- a/packages/ai/src/lib/website-utils.ts +++ b/packages/ai/src/lib/website-utils.ts @@ -1,7 +1,6 @@ import { - getAccessibleWebsiteIds, getApiKeyFromHeader, - hasWebsiteScope, + hasWebsiteScopeForOrganization, isApiKeyPresent, type ApiKeyRow, } from "@databuddy/api-keys/resolve"; @@ -173,7 +172,7 @@ async function deriveWithApiKey(request: Request) { return { user: null, session: null, website: site, timezone } as const; } - const canRead = await hasWebsiteScope(key, siteId, "read:data"); + const canRead = hasWebsiteScopeForOrganization(key, site, "read:data"); if (!canRead) { if (isKnownWebsiteForKey(key, site)) { throw jsonError(403, "Insufficient permissions", "FORBIDDEN"); @@ -185,11 +184,7 @@ async function deriveWithApiKey(request: Request) { } function isKnownWebsiteForKey(key: ApiKeyRow, site: Website): boolean { - return ( - (key.organizationId != null && - key.organizationId === site.organizationId) || - getAccessibleWebsiteIds(key).includes(site.id) - ); + return key.organizationId === site.organizationId; } async function deriveWithSession(request: Request) { diff --git a/packages/api-keys/src/resolve.test.ts b/packages/api-keys/src/resolve.test.ts index b3949e7552..c0d3836bc9 100644 --- a/packages/api-keys/src/resolve.test.ts +++ b/packages/api-keys/src/resolve.test.ts @@ -1,4 +1,5 @@ import { afterAll, beforeEach, describe, expect, mock, test } from "bun:test"; +import type { ApiKeyRow } from "./resolve"; interface SqlFragment { text: string; @@ -101,6 +102,7 @@ mock.module("@databuddy/redis", () => ({ const { API_KEY_LOOKUP_TIMEOUT_MS, API_KEY_STATEMENT_TIMEOUT_MS, + hasWebsiteScopeForOrganization, resolveApiKeySecret, } = await import("./resolve"); @@ -181,3 +183,53 @@ describe("API key database deadline", () => { expect(findApiKey).toHaveBeenCalledTimes(1); }); }); + +describe("website-scoped API keys", () => { + test("cannot use a resource entry to cross an organization boundary", () => { + const key = { + organizationId: "org-a", + scopes: [], + metadata: { resources: { "website:site-b": ["read:data"] } }, + } as unknown as ApiKeyRow; + + expect( + hasWebsiteScopeForOrganization( + key, + { id: "site-b", organizationId: "org-b" }, + "read:data" + ) + ).toBe(false); + }); + + test("accepts a resource entry for the key's own organization", () => { + const key = { + organizationId: "org-a", + scopes: [], + metadata: { resources: { "website:site-a": ["read:data"] } }, + } as unknown as ApiKeyRow; + + expect( + hasWebsiteScopeForOrganization( + key, + { id: "site-a", organizationId: "org-a" }, + "read:data" + ) + ).toBe(true); + }); + + test("preserves global scopes within the key's own organization", () => { + const key = { + organizationId: "org-a", + scopes: ["read:data"], + metadata: {}, + } as unknown as ApiKeyRow; + + expect( + hasWebsiteScopeForOrganization( + key, + { id: "site-a", organizationId: "org-a" }, + "read:data" + ) + ).toBe(true); + }); +}); diff --git a/packages/api-keys/src/resolve.ts b/packages/api-keys/src/resolve.ts index 8402963d6b..93a7b13669 100644 --- a/packages/api-keys/src/resolve.ts +++ b/packages/api-keys/src/resolve.ts @@ -266,6 +266,23 @@ export function hasWebsiteScope( return hasKeyScope(key, required, `website:${websiteId}`); } +/** + * Checks a website scope only after binding the website to the key's workspace. + * Resource metadata is user input, so its `website:` key is not proof of + * ownership by itself. + */ +export function hasWebsiteScopeForOrganization( + key: ApiKeyRow | null, + website: { id: string; organizationId: string | null }, + required: string +): boolean { + return Boolean( + key?.organizationId && + key.organizationId === website.organizationId && + hasWebsiteScope(key, website.id, required) + ); +} + export function hasWebsiteAnyScope( key: ApiKeyRow | null, websiteId: string, diff --git a/packages/rpc/src/routers/apikeys.resource-ownership.test.ts b/packages/rpc/src/routers/apikeys.resource-ownership.test.ts new file mode 100644 index 0000000000..c811ab578c --- /dev/null +++ b/packages/rpc/src/routers/apikeys.resource-ownership.test.ts @@ -0,0 +1,179 @@ +import { afterAll, beforeEach, describe, expect, it, mock } from "bun:test"; +import { createProcedureClient } from "@orpc/server"; +import { createKeys } from "keypal"; +import type { Context } from "../orpc"; + +const ORGANIZATION_A = "org-a"; +const WEBSITE_A = "site-a"; +const WEBSITE_B = "site-b"; + +const testKeys = createKeys({ prefix: "dbdy_", length: 48 }); +const mockWithWorkspace = mock(async () => ({ + organizationId: "org-a", + role: "admin", +})); +const mockAppendRpcAuditEvent = mock(async () => undefined); + +mock.module("@databuddy/auth", () => ({ + auth: { api: { getSession: async () => null } }, +})); +mock.module("@databuddy/api-keys/resolve", () => ({ + collectScopes: (key: { scopes: string[] }) => key.scopes, + getApiKeyFromHeader: async () => null, + keys: testKeys, + markApiKeyUsed: async () => undefined, + withApiKeyCacheInvalidation: async ( + _hashes: Array, + operation: () => Promise + ) => operation(), +})); +mock.module("../procedures/with-workspace", () => ({ + withWorkspace: mockWithWorkspace, +})); +mock.module("../lib/audit", () => ({ + appendRpcAuditEvent: mockAppendRpcAuditEvent, + getAuditActor: () => ({ id: "user-a", type: "user" }), + getAuditOrganizationId: () => ORGANIZATION_A, + getAuditRequestContext: () => ({}), +})); + +const { apikeysRouter } = await import("./apikeys"); + +function call(procedure: T, context: Context) { + return createProcedureClient(procedure as never, { context }); +} + +function apiKeyRow() { + const now = new Date("2026-08-21T00:00:00.000Z"); + return { + createdAt: now, + enabled: true, + expiresAt: null, + id: "key-a", + keyHash: "hash-a", + lastUsedAt: null, + metadata: {}, + name: "Existing key", + organizationId: ORGANIZATION_A, + prefix: "dbdy", + rateLimitEnabled: true, + rateLimitMax: null, + rateLimitTimeWindow: null, + revokedAt: null, + scopes: [], + start: "dbdy_abc", + type: "user" as const, + updatedAt: now, + userId: null, + }; +} + +function contextWithMatchedWebsites(matchedWebsiteIds: string[]): Context { + const key = apiKeyRow(); + const database = { + query: { + apikey: { + findFirst: async () => key, + }, + }, + select: () => ({ + from: () => ({ + where: async () => matchedWebsiteIds.map((id) => ({ id })), + }), + }), + transaction: async ( + callback: (transaction: { + insert: () => { + values: (values: Record) => { + returning: () => Promise[]>; + }; + }; + }) => Promise + ) => + callback({ + insert: () => ({ + values: (values) => ({ + returning: async () => [values], + }), + }), + }), + }; + + return { + auditOrganizationId: undefined, + anonymousId: null, + apiKey: undefined, + db: database, + getBilling: async () => undefined, + headers: new Headers(), + organizationId: ORGANIZATION_A, + session: undefined, + sessionId: null, + user: { + email: "admin@example.com", + id: "user-a", + name: "Admin", + }, + } as Context; +} + +describe("apikeys website resource ownership", () => { + beforeEach(() => { + mockWithWorkspace.mockClear(); + mockAppendRpcAuditEvent.mockClear(); + }); + + it("rejects create when a selected organization claims another organization's website", async () => { + await expect( + call( + apikeysRouter.create, + contextWithMatchedWebsites([]) + )({ + name: "Foreign website key", + organizationId: ORGANIZATION_A, + resources: { [`website:${WEBSITE_B}`]: ["read:data"] }, + scopes: [], + }) + ).rejects.toMatchObject({ + code: "BAD_REQUEST", + message: + "API key website resources must belong to the selected organization", + }); + }); + + it("rejects update when an existing key claims another organization's website", async () => { + await expect( + call( + apikeysRouter.update, + contextWithMatchedWebsites([]) + )({ + id: "key-a", + resources: { [`website:${WEBSITE_B}`]: ["read:data"] }, + }) + ).rejects.toMatchObject({ + code: "BAD_REQUEST", + message: + "API key website resources must belong to the selected organization", + }); + }); + + it("allows create for a website that belongs to the selected organization", async () => { + const result = await call( + apikeysRouter.create, + contextWithMatchedWebsites([WEBSITE_A]) + )({ + name: "Owned website key", + organizationId: ORGANIZATION_A, + resources: { [`website:${WEBSITE_A}`]: ["read:data"] }, + scopes: [], + }); + + expect(result.id).toBeString(); + expect(result.secret).toStartWith("dbdy_"); + expect(mockAppendRpcAuditEvent).toHaveBeenCalledTimes(1); + }); +}); + +afterAll(() => { + mock.restore(); +}); diff --git a/packages/rpc/src/routers/apikeys.ts b/packages/rpc/src/routers/apikeys.ts index ce6f48994b..805065c667 100644 --- a/packages/rpc/src/routers/apikeys.ts +++ b/packages/rpc/src/routers/apikeys.ts @@ -6,8 +6,8 @@ import { withApiKeyCacheInvalidation, } from "@databuddy/api-keys/resolve"; import { API_SCOPES } from "@databuddy/api-keys/scopes"; -import { desc, eq } from "@databuddy/db"; -import { apikey } from "@databuddy/db/schema"; +import { and, desc, eq, inArray, isNull } from "@databuddy/db"; +import { apikey, websites } from "@databuddy/db/schema"; import { auditActions } from "@databuddy/shared/audit"; import { ApiKeyErrorCode, @@ -56,6 +56,45 @@ function assertMetadataSize(meta: Record) { } } +async function assertResourceOwnership( + ctx: Context, + organizationId: string, + resources: Record | undefined +) { + const websiteIds = Object.keys(resources ?? {}).flatMap((resource) => { + if (!resource.startsWith("website:")) { + return []; + } + const websiteId = resource.slice("website:".length); + if (!websiteId) { + throw rpcError.badRequest( + "API key website resource scopes require a website ID" + ); + } + return [websiteId]; + }); + + if (websiteIds.length === 0) { + return; + } + + const ownedWebsites = await ctx.db + .select({ id: websites.id }) + .from(websites) + .where( + and( + eq(websites.organizationId, organizationId), + inArray(websites.id, websiteIds), + isNull(websites.deletedAt) + ) + ); + if (ownedWebsites.length !== websiteIds.length) { + throw rpcError.badRequest( + "API key website resources must belong to the selected organization" + ); + } +} + const rateLimitSchema = z.object({ enabled: z.boolean().optional(), max: z.number().int().positive().nullable().optional(), @@ -305,6 +344,11 @@ export const apikeysRouter = { "Change API key scopes" ); } + await assertResourceOwnership( + context, + input.organizationId, + input.resources + ); const nextMetadata = { resources: input.resources, @@ -416,6 +460,13 @@ export const apikeysRouter = { "Change API key scopes" ); } + if (input.resources !== undefined && input.resources !== null) { + await assertResourceOwnership( + context, + key.organizationId, + input.resources + ); + } const nextMetadata = { ...meta, @@ -573,6 +624,7 @@ export const apikeysRouter = { throw rpcError.internal("Organization key required for rotate"); } await assertOrgAdmin(context, ownerId, "Rotate API keys"); + await assertResourceOwnership(context, ownerId, meta.resources); const { key: secret, record } = await keys.create({ ownerId, From bb15bd61fb3b45ab8b3d34dcb978dba6923ac757 Mon Sep 17 00:00:00 2001 From: iza <59828082+izadoesdev@users.noreply.github.com> Date: Fri, 21 Aug 2026 23:07:39 +0300 Subject: [PATCH 06/88] feat(mcp): expand public workspace tool contract --- apps/api/package.json | 1 - apps/api/src/http/cors.test.ts | 46 +++ apps/api/src/http/cors.ts | 37 ++ apps/api/src/index.ts | 3 +- apps/api/src/routes/mcp.ts | 83 ++--- packages/ai/src/ai/mcp/define-tool.ts | 145 ++++---- packages/ai/src/ai/mcp/tool-context.ts | 15 +- packages/ai/src/ai/mcp/tool-contracts.ts | 66 ++++ packages/ai/src/ai/mcp/tools.test.ts | 411 +++++++++++++++++++-- packages/ai/src/ai/mcp/tools.ts | 206 ++++------- packages/ai/src/ai/mcp/workspace-tools.ts | 424 ++++++++++++++++++++++ packages/ai/src/mcp/http.ts | 111 ++---- packages/api-keys/src/scopes.test.ts | 10 +- packages/api-keys/src/scopes.ts | 10 + 14 files changed, 1196 insertions(+), 372 deletions(-) create mode 100644 apps/api/src/http/cors.test.ts create mode 100644 packages/ai/src/ai/mcp/tool-contracts.ts create mode 100644 packages/ai/src/ai/mcp/workspace-tools.ts diff --git a/apps/api/package.json b/apps/api/package.json index 7808ababe4..b165224748 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -28,7 +28,6 @@ "@databuddy/validation": "workspace:*", "@elysiajs/cors": "^1.4.1", "@elysiajs/server-timing": "^1.4.0", - "@modelcontextprotocol/sdk": "^1.26.0", "@opentelemetry/resources": "^2.4.0", "@opentelemetry/sdk-node": "0.219.0", "@opentelemetry/semantic-conventions": "^1.29.0", diff --git a/apps/api/src/http/cors.test.ts b/apps/api/src/http/cors.test.ts new file mode 100644 index 0000000000..b5b2e1fb26 --- /dev/null +++ b/apps/api/src/http/cors.test.ts @@ -0,0 +1,46 @@ +import cors from "@elysiajs/cors"; +import { Elysia } from "elysia"; +import { describe, expect, it } from "vitest"; +import { + isAllowedApiOrigin, + rejectInvalidMcpOrigin, + rejectUnsupportedMcpMethod, +} from "./cors"; + +describe("MCP CORS", () => { + it("rejects an invalid MCP preflight before CORS short-circuits it", async () => { + const app = new Elysia() + .onRequest(({ request }) => rejectInvalidMcpOrigin(request)) + .use(cors({ credentials: true, origin: isAllowedApiOrigin })); + + const response = await app.handle( + new Request("https://api.databuddy.test/v1/mcp", { + method: "OPTIONS", + headers: { + "access-control-request-method": "POST", + origin: "https://attacker.example", + }, + }) + ); + + expect(response.status).toBe(403); + expect(await response.json()).toMatchObject({ + error: { message: "Forbidden Origin" }, + id: null, + jsonrpc: "2.0", + }); + }); + + it("limits the MCP method guard to MCP transport routes", () => { + const discoveryResponse = rejectUnsupportedMcpMethod( + new Request("https://api.databuddy.test/.well-known/mcp") + ); + const mcpResponse = rejectUnsupportedMcpMethod( + new Request("https://api.databuddy.test/v1/mcp") + ); + + expect(discoveryResponse).toBeUndefined(); + expect(mcpResponse?.status).toBe(405); + expect(mcpResponse?.headers.get("allow")).toBe("POST"); + }); +}); diff --git a/apps/api/src/http/cors.ts b/apps/api/src/http/cors.ts index befdeca81e..4a9b0e78d3 100644 --- a/apps/api/src/http/cors.ts +++ b/apps/api/src/http/cors.ts @@ -2,6 +2,11 @@ import { config } from "@databuddy/env/app"; const DATABUDDY_HOST_RE = /(?:^|\.)databuddy\.cc$/; const allowedApiOrigins = new Set(config.cors.apiOrigins); +const MCP_PATHS = new Set(["/v1/mcp", "/v1/mcp/", "/mcp", "/mcp/"]); + +export function isMcpRequest(request: Request): boolean { + return MCP_PATHS.has(new URL(request.url).pathname); +} export function isAllowedApiOrigin(request: Request): boolean { const origin = request.headers.get("Origin"); @@ -18,3 +23,35 @@ export function isAllowedApiOrigin(request: Request): boolean { return false; } } + +export function rejectInvalidMcpOrigin(request: Request): Response | undefined { + if ( + !(isMcpRequest(request) && request.headers.has("origin")) || + isAllowedApiOrigin(request) + ) { + return; + } + + // policy-ignore http/no-custom-json-error-response: MCP transport errors must use a JSON-RPC envelope. + return Response.json( + { + jsonrpc: "2.0", + error: { code: -32_000, message: "Forbidden Origin" }, + id: null, + }, + { status: 403 } + ); +} + +export function rejectUnsupportedMcpMethod( + request: Request +): Response | undefined { + if ( + !isMcpRequest(request) || + request.method === "POST" || + request.method === "OPTIONS" + ) { + return; + } + return new Response(null, { status: 405, headers: { Allow: "POST" } }); +} diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index 203d237550..f82edb9a58 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -12,7 +12,7 @@ import { registerShutdownHooks, warmPostgresConnection, } from "@/bootstrap/shutdown"; -import { isAllowedApiOrigin } from "@/http/cors"; +import { isAllowedApiOrigin, rejectInvalidMcpOrigin } from "@/http/cors"; import { handleAppError } from "@/http/errors"; import { getRequestId } from "@/http/request-id"; import { AUTUMN_API_PREFIX } from "@/lib/autumn-mount"; @@ -106,6 +106,7 @@ const app = new Elysia({ precompile: true }) }) ) .onBeforeHandle(({ request }) => enrichRequestAuthWideEvent(request)) + .onRequest(({ request }) => rejectInvalidMcpOrigin(request)) .use( cors({ credentials: true, diff --git a/apps/api/src/routes/mcp.ts b/apps/api/src/routes/mcp.ts index 752556cc1d..ca178dc7a6 100644 --- a/apps/api/src/routes/mcp.ts +++ b/apps/api/src/routes/mcp.ts @@ -1,8 +1,5 @@ import { - getAccessibleWebsiteIds, getApiKeyFromHeader, - hasKeyScope, - hasWebsiteScope, isApiKeyPresent, } from "@databuddy/api-keys/resolve"; import { @@ -10,23 +7,14 @@ import { handleDatabuddyMcpRequest, } from "@databuddy/ai/mcp/http"; import { auth } from "@databuddy/auth"; -import { config } from "@databuddy/env/app"; import { Elysia } from "elysia"; +import { + rejectInvalidMcpOrigin, + rejectUnsupportedMcpMethod, +} from "@/http/cors"; +import { getResolvedAuth } from "@/lib/auth-wide-event"; -const PROTECTED_RESOURCE_METADATA_URL = `${config.urls.api}/.well-known/oauth-protected-resource`; - -function canReadMcp( - apiKey: NonNullable>> -) { - return ( - hasKeyScope(apiKey, "read:data") || - getAccessibleWebsiteIds(apiKey).some((websiteId) => - hasWebsiteScope(apiKey, websiteId, "read:data") - ) - ); -} - -async function handleMcpRequest({ +function handleMcpRequest({ request, user, apiKey, @@ -37,7 +25,7 @@ async function handleMcpRequest({ request: Request; user: { id: string } | null; }) { - return await handleDatabuddyMcpRequest({ + return handleDatabuddyMcpRequest({ request, requestHeaders: request.headers, userId: user?.id ?? null, @@ -47,23 +35,23 @@ async function handleMcpRequest({ } export const mcp = new Elysia({ name: "mcp" }) + .onRequest( + ({ request }) => + rejectInvalidMcpOrigin(request) ?? rejectUnsupportedMcpMethod(request) + ) .derive(async ({ request }) => { + const preResolved = getResolvedAuth(request.headers); const hasApiKey = isApiKeyPresent(request.headers); const apiKey = hasApiKey - ? await getApiKeyFromHeader(request.headers) + ? preResolved + ? (preResolved.apiKeyResult?.key ?? null) + : await getApiKeyFromHeader(request.headers) : null; const session = hasApiKey ? null - : await auth.api.getSession({ headers: request.headers }); - - if (hasApiKey && !(apiKey && canReadMcp(apiKey))) { - return { - user: null, - apiKey: null, - isAuthenticated: false, - organizationId: null, - }; - } + : preResolved + ? preResolved.session + : await auth.api.getSession({ headers: request.headers }); const user = session?.user ?? null; return { @@ -74,36 +62,13 @@ export const mcp = new Elysia({ name: "mcp" }) apiKey?.organizationId ?? session?.session.activeOrganizationId ?? null, }; }) - .onBeforeHandle(async ({ request, isAuthenticated, set }) => { + .onBeforeHandle(({ isAuthenticated, set }) => { if (!isAuthenticated) { set.status = 401; - return await createMcpUnauthorizedResponse(request, { - resourceMetadataUrl: PROTECTED_RESOURCE_METADATA_URL, - }); + return createMcpUnauthorizedResponse(); } }) - .all( - "/v1/mcp", - async ({ request, user, apiKey, organizationId }) => - await handleMcpRequest({ request, user, apiKey, organizationId }) - ) - .all( - "/v1/mcp/", - async ({ request, user, apiKey, organizationId }) => - await handleMcpRequest({ request, user, apiKey, organizationId }) - ) - .all( - "/mcp", - async ({ request, user, apiKey, organizationId }) => - await handleMcpRequest({ request, user, apiKey, organizationId }) - ) - .all( - "/mcp/", - async ({ request, user, apiKey, organizationId }) => - await handleMcpRequest({ request, user, apiKey, organizationId }) - ) - .all( - "/.well-known/mcp", - async ({ request, user, apiKey, organizationId }) => - await handleMcpRequest({ request, user, apiKey, organizationId }) - ); + .all("/v1/mcp", handleMcpRequest) + .all("/v1/mcp/", handleMcpRequest) + .all("/mcp", handleMcpRequest) + .all("/mcp/", handleMcpRequest); diff --git a/packages/ai/src/ai/mcp/define-tool.ts b/packages/ai/src/ai/mcp/define-tool.ts index 4c78d3c44b..ddd601a518 100644 --- a/packages/ai/src/ai/mcp/define-tool.ts +++ b/packages/ai/src/ai/mcp/define-tool.ts @@ -1,5 +1,11 @@ +import { + apiKeyScopeTargetForResource, + requiredScopesForResource, + type ApiKeyScopeTarget, +} from "@databuddy/api-keys/scopes"; import type { ApiKeyRow } from "@databuddy/api-keys/resolve"; import { getRateLimitHeaders, ratelimit } from "@databuddy/redis/rate-limit"; +import type { ApiScope } from "@databuddy/shared/api-scopes"; import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; import { ORPCError } from "@orpc/server"; import type { z } from "zod"; @@ -20,39 +26,6 @@ function stripAnsi(text: string): string { return text.replace(ANSI_RE, ""); } -function coerceMcpInput(input: unknown): unknown { - if (!input || typeof input !== "object" || Array.isArray(input)) { - return input; - } - const out: Record = {}; - for (const [key, value] of Object.entries(input as Record)) { - if (typeof value === "string") { - const trimmed = value.trim(); - if (trimmed === "true") { - out[key] = true; - continue; - } - if (trimmed === "false") { - out[key] = false; - continue; - } - if (trimmed.startsWith("[") || trimmed.startsWith("{")) { - try { - const parsed = JSON.parse(trimmed); - if (typeof parsed === "object" && parsed !== null) { - out[key] = parsed; - continue; - } - } catch { - // intentionally empty - } - } - } - out[key] = value; - } - return out; -} - export type McpErrorCode = | "invalid_input" | "unauthorized" @@ -91,25 +64,54 @@ export interface McpHandlerContext extends McpRequestContext { websiteId?: string; } -type McpToolCapability = "analytics" | "workspace"; type McpToolMutationKind = "read" | "write"; interface McpToolAccess { - confirmation?: "none" | "recommended" | "required"; + globalScopes: ApiScope[]; kind: McpToolMutationKind; - scopes?: string[]; + scopes: ApiScope[]; +} + +interface McpToolAccessInput { + kind?: McpToolMutationKind; + scopes?: ApiScope[]; + scopeTarget?: ApiKeyScopeTarget; } export interface McpToolMetadata { access: McpToolAccess; - capability: McpToolCapability; - evlogAction?: string; +} + +export interface McpToolMetadataInput { + access?: McpToolAccessInput; +} + +/** + * Derive MCP tool access metadata from the API-key scope source of truth. + * Keep this beside `defineMcpTool` so every tool module gets identical + * organization-vs-website scope behavior. + */ +export function metadataForResource( + resource: string, + permissions: readonly string[] +): McpToolMetadataInput { + return { + access: { + kind: permissions.every( + (permission) => permission === "read" || permission === "view_analytics" + ) + ? "read" + : "write", + scopeTarget: apiKeyScopeTargetForResource(resource), + scopes: requiredScopesForResource(resource, permissions), + }, + }; } export interface McpToolMeta { description: string; inputSchema: S; - metadata?: Partial; + metadata?: McpToolMetadataInput; name: string; /** * Optional Zod schema describing the successful response shape. @@ -117,7 +119,8 @@ export interface McpToolMeta { * it as `structuredContent` (MCP 2025-06-18 Tool Output Schemas), letting * clients consume native typed data instead of parsing JSON text. * The schema MUST validate an object — per MCP spec, `structuredContent` - * is an object. Prefer `z.object({...})` or `z.record(...)`. + * is an object. Prefer `z.object({...})` or `z.object({}).passthrough()`. + * Root `z.record(...)` schemas are not compatible with the installed MCP SDK. */ outputSchema?: z.ZodType>; ratelimit?: { limit: number; windowSec: number }; @@ -245,7 +248,10 @@ export function defineMcpTool( ); } - const metadata = normalizeToolMetadata(meta.metadata); + const metadata = normalizeToolMetadata( + meta.metadata, + Boolean(meta.resolveWebsite) + ); const hasOutputSchema = meta.outputSchema !== undefined; const build = (ctx: McpRequestContext): RegisteredMcpTool => ({ @@ -264,9 +270,7 @@ export function defineMcpTool( }); try { - const parseResult = meta.inputSchema.safeParse( - coerceMcpInput(rawInput ?? {}) - ); + const parseResult = meta.inputSchema.safeParse(rawInput ?? {}); if (!parseResult.success) { const issue = parseResult.error.issues[0]; const path = issue?.path.join(".") ?? "input"; @@ -328,15 +332,7 @@ export function defineMcpTool( const result = await handler(input, handlerCtx); - trackAgentEvent("agent_activity", { - action: metadata.evlogAction ?? "tool_completed", - source: "mcp", - tool: meta.name, - success: true, - tool_access_kind: metadata.access.kind, - tool_capability: metadata.capability, - ...attribution, - }); + trackMcpToolEvent(metadata, meta.name, true, attribution); mergeWideEvent({ mcp_status: "ok", mcp_duration_ms: Date.now() - start, @@ -358,15 +354,7 @@ export function defineMcpTool( captureError(err, { mcp_tool: meta.name }); } - trackAgentEvent("agent_activity", { - action: metadata.evlogAction ?? "tool_completed", - source: "mcp", - tool: meta.name, - success: false, - tool_access_kind: metadata.access.kind, - tool_capability: metadata.capability, - ...attribution, - }); + trackMcpToolEvent(metadata, meta.name, false, attribution); mergeWideEvent({ mcp_status: "error", mcp_error_code: toolError.code, @@ -381,15 +369,38 @@ export function defineMcpTool( } function normalizeToolMetadata( - metadata: Partial | undefined + metadata: McpToolMetadataInput | undefined, + resolvesWebsite: boolean ): McpToolMetadata { + const configuredScopes = metadata?.access?.scopes ?? []; + const scopes: ApiScope[] = [ + ...(resolvesWebsite ? (["read:data"] as const) : []), + ...configuredScopes, + ]; return { access: { - confirmation: metadata?.access?.confirmation ?? "none", + globalScopes: + metadata?.access?.scopeTarget === "global" ? configuredScopes : [], kind: metadata?.access?.kind ?? "read", - scopes: metadata?.access?.scopes ?? [], + scopes: [...new Set(scopes)], }, - capability: metadata?.capability ?? "analytics", - evlogAction: metadata?.evlogAction, }; } + +function trackMcpToolEvent( + metadata: McpToolMetadata, + tool: string, + success: boolean, + attribution: ReturnType +): void { + const kind = metadata.access.kind; + trackAgentEvent("agent_activity", { + action: kind === "write" ? "tool_mutation" : "tool_completed", + source: "mcp", + tool, + success, + tool_access_kind: kind, + tool_capability: kind === "write" ? "workspace" : "analytics", + ...attribution, + }); +} diff --git a/packages/ai/src/ai/mcp/tool-context.ts b/packages/ai/src/ai/mcp/tool-context.ts index 66ebdcca53..267784b196 100644 --- a/packages/ai/src/ai/mcp/tool-context.ts +++ b/packages/ai/src/ai/mcp/tool-context.ts @@ -5,7 +5,7 @@ import { import { type ApiKeyRow, hasKeyScope, - hasWebsiteScope, + hasWebsiteScopeForOrganization, } from "@databuddy/api-keys/resolve"; import { websitesApi } from "@databuddy/auth"; import { getRedisCache } from "@databuddy/redis"; @@ -14,7 +14,7 @@ import { getCachedWebsite, validateWebsite } from "../../lib/website-utils"; const PROTOCOL_RE = /^https?:\/\//; const ACCESSIBLE_WEBSITES_TTL_SEC = 30; -const ACCESSIBLE_WEBSITES_KEY_PREFIX = "mcp:accessible_websites:"; +const ACCESSIBLE_WEBSITES_KEY_PREFIX = "mcp:accessible_websites:v2:"; export interface WebsiteSelectorInput { websiteDomain?: string; @@ -40,10 +40,11 @@ export async function ensureWebsiteAccess( const { website } = validation; if (apiKey) { - const hasWebsiteAccess = - hasWebsiteScope(apiKey, websiteId, "read:data") || - (hasKeyScope(apiKey, "read:data") && - apiKey.organizationId === website.organizationId); + const hasWebsiteAccess = hasWebsiteScopeForOrganization( + apiKey, + website, + "read:data" + ); if (!hasWebsiteAccess) { return new Error("Access denied to this website"); } @@ -77,7 +78,7 @@ function accessibleWebsitesCacheKey( const organizationId = principal.organizationId ?? principal.apiKey?.organizationId; if (principal.apiKey) { - return `apikey:${(principal.apiKey as { id: string }).id}:org:${organizationId ?? "none"}`; + return `apikey:${principal.apiKey.id}:org:${organizationId ?? "none"}`; } if (principal.userId && organizationId) { return `user:${principal.userId}:org:${organizationId}`; diff --git a/packages/ai/src/ai/mcp/tool-contracts.ts b/packages/ai/src/ai/mcp/tool-contracts.ts new file mode 100644 index 0000000000..d2968c624b --- /dev/null +++ b/packages/ai/src/ai/mcp/tool-contracts.ts @@ -0,0 +1,66 @@ +import { analyticsDateRangeSchema } from "@databuddy/validation"; +import { z } from "zod"; +import { McpToolError, type McpHandlerContext } from "./define-tool"; + +const DateOnlySchema = z.iso.date(); + +export const McpDateRangeSchema = z + .object({ + from: DateOnlySchema.optional().describe( + "Start date YYYY-MM-DD (defaults to 30 days ago)" + ), + to: DateOnlySchema.optional().describe( + "End date YYYY-MM-DD (defaults to today)" + ), + }) + .superRefine((input, context) => { + const result = analyticsDateRangeSchema.safeParse({ + startDate: input.from, + endDate: input.to, + }); + for (const issue of result.error?.issues ?? []) { + if (issue.code === "custom") { + context.addIssue({ + code: "custom", + message: issue.message, + path: [issue.path[0] === "startDate" ? "from" : "to"], + }); + } + } + }); + +export const WebsiteSelectorSchema = { + websiteId: z.string().optional().describe("Website ID from list_websites"), + websiteName: z + .string() + .optional() + .describe("Website name. Alternative to websiteId."), + websiteDomain: z + .string() + .optional() + .describe("Website domain. Alternative to websiteId."), +} as const; + +export const WorkflowFilterSchema = z.object({ + field: z.string(), + operator: z.enum(["equals", "contains", "not_equals", "in", "not_in"]), + value: z.union([z.string(), z.array(z.string())]), +}); + +export const ConfirmedSchema = z.boolean().optional().default(false); +export const DynamicObjectSchema = z.object({}).passthrough(); +export const MutationResultSchema = z + .object({ + confirmationRequired: z.boolean().optional(), + message: z.string(), + preview: z.boolean().optional(), + success: z.boolean().optional(), + }) + .passthrough(); + +export function getResolvedWebsiteId(ctx: McpHandlerContext): string { + if (!ctx.websiteId) { + throw new McpToolError("internal", "Website was not resolved."); + } + return ctx.websiteId; +} diff --git a/packages/ai/src/ai/mcp/tools.test.ts b/packages/ai/src/ai/mcp/tools.test.ts index 066483b541..4f9ef068cd 100644 --- a/packages/ai/src/ai/mcp/tools.test.ts +++ b/packages/ai/src/ai/mcp/tools.test.ts @@ -1,8 +1,16 @@ import { createInternalPrincipal } from "@databuddy/rpc"; +import type { ApiScope } from "@databuddy/shared/api-scopes"; +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import type { AnySchema } from "@modelcontextprotocol/sdk/server/zod-compat.js"; import { describe, expect, test } from "bun:test"; import { z } from "zod"; -import { handleDatabuddyMcpRequest } from "../../mcp/http"; -import type { McpRequestContext } from "./define-tool"; +import { + createMcpUnauthorizedResponse, + handleDatabuddyMcpRequest, +} from "../../mcp/http"; +import { defineMcpTool, type McpRequestContext } from "./define-tool"; import { createMcpTools } from "./tools"; const ctx: McpRequestContext = { @@ -16,33 +24,137 @@ const tools = createMcpTools(ctx); const TOOL_NAME_RE = /^[a-z][a-z0-9_]*$/; const MAX_DESCRIPTION_LEN = 240; -describe("MCP tools/list JSON Schema rendering", () => { - test("registers at least one tool", () => { - expect(tools.length).toBeGreaterThan(0); - }); +describe("MCP transport", () => { + test("keeps API-key authentication separate from unimplemented OAuth", async () => { + const response = createMcpUnauthorizedResponse(); - for (const tool of tools) { - test(`${tool.name}: inputSchema renders to JSON Schema`, () => { - expect(() => - z.toJSONSchema(tool.inputSchema, { io: "input" }) - ).not.toThrow(); + expect(response.status).toBe(401); + expect(response.headers.get("www-authenticate")).not.toContain( + "resource_metadata" + ); + expect(await response.json()).toMatchObject({ + id: null, + jsonrpc: "2.0", }); - - if (tool.outputSchema) { - test(`${tool.name}: outputSchema renders to JSON Schema`, () => { - const outputSchema = tool.outputSchema; - if (!outputSchema) { - return; - } - expect(() => - z.toJSONSchema(outputSchema, { io: "output" }) - ).not.toThrow(); - }); - } - } + }); }); +async function listToolsForPrincipal( + principal: ReturnType +) { + const response = await handleDatabuddyMcpRequest({ + apiKey: principal.apiKey, + organizationId: "org-1", + request: new Request("https://api.databuddy.test/v1/mcp", { + body: JSON.stringify({ + id: 1, + jsonrpc: "2.0", + method: "tools/list", + params: {}, + }), + headers: { + accept: "application/json, text/event-stream", + "content-type": "application/json", + }, + method: "POST", + }), + requestHeaders: new Headers(), + userId: null, + }); + const body = (await response.json()) as { + result?: { + tools?: Array<{ + annotations?: Record; + name: string; + }>; + }; + }; + return { + response, + tools: body.result?.tools ?? [], + }; +} + +async function listToolsForScopes(scopes: ApiScope[]) { + return listToolsForPrincipal( + createInternalPrincipal({ organizationId: "org-1", scopes }) + ); +} + describe("MCP tool invariants", () => { + test("dynamic analytics output schemas work through the installed MCP SDK", async () => { + const dynamicTools = tools.filter((tool) => + ["get_funnel_analytics", "get_goal_analytics"].includes(tool.name) + ); + expect(dynamicTools).toHaveLength(2); + + const server = new McpServer({ name: "test", version: "1.0.0" }); + for (const tool of dynamicTools) { + server.registerTool( + tool.name, + { + inputSchema: z.object({}), + outputSchema: tool.outputSchema as AnySchema, + }, + () => ({ + content: [{ type: "text", text: '{"value":"ok"}' }], + structuredContent: { value: "ok" }, + }) + ); + } + + const [clientTransport, serverTransport] = + InMemoryTransport.createLinkedPair(); + const client = new Client({ name: "test", version: "1.0.0" }); + await server.connect(serverTransport); + await client.connect(clientTransport); + + try { + const listed = await client.listTools(); + for (const tool of dynamicTools) { + expect( + listed.tools.find((listedTool) => listedTool.name === tool.name) + ?.outputSchema + ).toBeDefined(); + const result = await client.callTool({ + arguments: {}, + name: tool.name, + }); + expect(result).not.toMatchObject({ isError: true }); + expect(result).toMatchObject({ + structuredContent: { value: "ok" }, + }); + } + } finally { + await server.close(); + } + }); + + test("preserves literal string tool arguments", async () => { + let received: { enabled: boolean; literal: string } | undefined; + const tool = defineMcpTool( + { + name: "literal_string_input", + description: "Test that literal string inputs reach the handler unchanged.", + inputSchema: z.object({ + enabled: z.boolean(), + literal: z.string(), + }), + }, + (input) => { + received = input; + return { ok: true }; + } + ).build(ctx); + + for (const literal of ["true", "false", '{"key":"value"}', "[1,2]"]) { + received = undefined; + const result = await tool.handler({ enabled: true, literal }); + expect(result).not.toMatchObject({ isError: true }); + expect(received).toEqual({ enabled: true, literal }); + } + }); + test("create_link matches the HTTP(S) and deep-link app contract", () => { const createLink = tools.find((tool) => tool.name === "create_link"); if (!createLink) { @@ -86,6 +198,67 @@ describe("MCP tool invariants", () => { ).toBe(true); }); + test("uses strict ISO dates for MCP date-only and timestamp inputs", () => { + const getFunnelAnalytics = tools.find( + (tool) => tool.name === "get_funnel_analytics" + ); + const createLink = tools.find((tool) => tool.name === "create_link"); + const createAnnotation = tools.find( + (tool) => tool.name === "create_annotation" + ); + if (!(getFunnelAnalytics && createLink && createAnnotation)) { + throw new Error("Expected date-bearing MCP tools to be registered"); + } + + expect( + getFunnelAnalytics.inputSchema.safeParse({ + funnelId: "funnel-1", + from: "2026-02-30", + to: "2026-03-02", + websiteId: "website-1", + }).success + ).toBe(false); + expect( + createLink.inputSchema.safeParse({ + confirmed: false, + expiresAt: "2026-02-30T12:00:00Z", + name: "Broken expiry", + targetUrl: "https://example.com", + websiteId: "website-1", + }).success + ).toBe(false); + expect( + createAnnotation.inputSchema.safeParse({ + annotationType: "point", + confirmed: false, + text: "Release", + websiteId: "website-1", + xValue: "2026-02-30T12:00:00Z", + }).success + ).toBe(false); + }); + + test("keeps mixed batch date errors inside the batch result", () => { + const getData = tools.find((tool) => tool.name === "get_data"); + if (!getData) { + throw new Error("Expected get_data to be registered"); + } + + expect( + getData.inputSchema.safeParse({ + queries: [ + { preset: "last_7d", type: "summary_metrics" }, + { + from: "2026-02-30", + to: "2026-03-02", + type: "summary_metrics", + }, + ], + websiteId: "website-1", + }).success + ).toBe(true); + }); + test("tool names are unique snake_case", () => { const names = tools.map((tool) => tool.name); expect(new Set(names).size).toBe(names.length); @@ -99,7 +272,6 @@ describe("MCP tool invariants", () => { expect(tool.description.length).toBeGreaterThan(0); expect(tool.description.length).toBeLessThanOrEqual(MAX_DESCRIPTION_LEN); expect(tool.metadata.access.kind).toMatch(/^(read|write)$/); - expect(tool.metadata.capability).toMatch(/^(analytics|workspace)$/); expect(typeof tool.handler).toBe("function"); } }); @@ -145,6 +317,195 @@ describe("MCP tool invariants", () => { }); describe("investigation tools", () => { + test("only advertises tools whose API-key scopes can satisfy their calls", async () => { + const readData = await listToolsForScopes(["read:data"]); + const readDataNames = new Set(readData.tools.map((tool) => tool.name)); + expect(readData.response.status).toBe(200); + expect(readDataNames.has("get_data")).toBe(true); + expect(readDataNames.has("get_funnel_analytics_by_referrer")).toBe(true); + expect(readDataNames.has("list_links")).toBe(false); + expect(readDataNames.has("create_link")).toBe(false); + expect(readDataNames.has("create_flag")).toBe(false); + + const flagManager = await listToolsForScopes([ + "read:data", + "manage:flags", + ]); + const flagManagerNames = new Set( + flagManager.tools.map((tool) => tool.name) + ); + for (const name of [ + "create_flag", + "update_flag", + "add_users_to_flag", + ]) { + expect(flagManagerNames.has(name)).toBe(true); + } + + const workspaceManager = await listToolsForScopes([ + "read:data", + "manage:websites", + ]); + const workspaceManagerNames = new Set( + workspaceManager.tools.map((tool) => tool.name) + ); + for (const name of [ + "update_goal", + "delete_goal", + "update_annotation", + "delete_annotation", + ]) { + expect(workspaceManagerNames.has(name)).toBe(true); + } + + const workspaceWriterWithoutRead = await listToolsForScopes([ + "manage:websites", + ]); + const workspaceWriterWithoutReadNames = new Set( + workspaceWriterWithoutRead.tools.map((tool) => tool.name) + ); + for (const name of [ + "update_goal", + "delete_goal", + "update_annotation", + "delete_annotation", + ]) { + expect(workspaceWriterWithoutReadNames.has(name)).toBe(false); + } + + const linkReader = await listToolsForScopes([ + "read:data", + "read:links", + ]); + expect( + new Set(linkReader.tools.map((tool) => tool.name)).has("list_links") + ).toBe(true); + expect( + new Set(linkReader.tools.map((tool) => tool.name)).has("update_link") + ).toBe(false); + + const linkWriterWithoutRead = await listToolsForScopes([ + "read:data", + "write:links", + ]); + const linkWriterWithoutReadNames = new Set( + linkWriterWithoutRead.tools.map((tool) => tool.name) + ); + for (const name of ["update_link", "delete_link"]) { + expect(linkWriterWithoutReadNames.has(name)).toBe(false); + } + + const linkWriter = await listToolsForScopes([ + "read:data", + "read:links", + "write:links", + ]); + const linkWriterNames = new Set( + linkWriter.tools.map((tool) => tool.name) + ); + for (const name of ["create_link", "update_link", "delete_link"]) { + expect(linkWriterNames.has(name)).toBe(true); + } + }); + + test("does not advertise org-wide link tools from website-only scopes", async () => { + const scopedKey = await listToolsForPrincipal( + createInternalPrincipal({ + metadata: { + resources: { + "website:site-1": [ + "read:data", + "read:links", + "write:links", + ], + }, + }, + organizationId: "org-1", + scopes: [], + }) + ); + const names = new Set(scopedKey.tools.map((tool) => tool.name)); + + for (const name of [ + "list_link_folders", + "list_links", + "search_links", + "create_link", + "update_link", + "delete_link", + ]) { + expect(names.has(name)).toBe(false); + } + }); + + test("combines global link scopes with website-scoped analytics", async () => { + const scopedKey = await listToolsForPrincipal( + createInternalPrincipal({ + metadata: { + resources: { "website:site-1": ["read:data"] }, + }, + organizationId: "org-1", + scopes: ["read:links", "write:links"], + }) + ); + const names = new Set(scopedKey.tools.map((tool) => tool.name)); + + expect(names.has("list_links")).toBe(true); + expect(names.has("create_link")).toBe(true); + }); + + test("uses conservative annotations for mutations", async () => { + const { tools: listed } = await listToolsForScopes([ + "read:data", + "read:links", + "write:links", + "manage:flags", + "manage:websites", + ]); + const byName = new Map(listed.map((tool) => [tool.name, tool])); + + expect(byName.get("get_data")?.annotations).toMatchObject({ + destructiveHint: false, + idempotentHint: true, + readOnlyHint: true, + }); + for (const name of [ + "create_link", + "update_link", + "delete_link", + "update_goal", + "delete_goal", + "update_flag", + "add_users_to_flag", + ]) { + expect(byName.get(name)?.annotations).toMatchObject({ + destructiveHint: true, + idempotentHint: false, + readOnlyHint: false, + }); + } + }); + + test("rejects unsupported standalone SSE methods", async () => { + const principal = createInternalPrincipal({ + organizationId: "org-1", + scopes: ["read:data"], + }); + const response = await handleDatabuddyMcpRequest({ + apiKey: principal.apiKey, + organizationId: "org-1", + request: new Request("https://api.databuddy.test/v1/mcp", { + headers: { accept: "text/event-stream" }, + method: "GET", + }), + requestHeaders: new Headers(), + userId: null, + }); + + expect(response.status).toBe(405); + expect(response.headers.get("allow")).toBe("POST"); + }); + test("publishes the investigation lifecycle to a website-scoped key", async () => { const principal = createInternalPrincipal({ metadata: { diff --git a/packages/ai/src/ai/mcp/tools.ts b/packages/ai/src/ai/mcp/tools.ts index fd9aa3537d..9ca67381ee 100644 --- a/packages/ai/src/ai/mcp/tools.ts +++ b/packages/ai/src/ai/mcp/tools.ts @@ -31,11 +31,10 @@ import { } from "../tools/link-catalog"; import { defineMcpTool, + metadataForResource, McpToolError, - type McpHandlerContext, type McpRequestContext, type McpToolFactory, - type McpToolMetadata, type RegisteredMcpTool, } from "./define-tool"; import { @@ -58,24 +57,27 @@ import { getOrganizationId, resolveOrganizationIds, } from "./tool-context"; +import { createMcpWorkspaceTools } from "./workspace-tools"; +import { + ConfirmedSchema, + DynamicObjectSchema, + getResolvedWebsiteId, + McpDateRangeSchema, + MutationResultSchema, + WebsiteSelectorSchema, + WorkflowFilterSchema, +} from "./tool-contracts"; const TIME_UNIT = ["minute", "hour", "day", "week", "month"] as const; - -const WebsiteSelectorSchema = { - websiteId: z.string().optional().describe("Website ID from list_websites"), - websiteName: z - .string() - .optional() - .describe("Website name. Alternative to websiteId."), - websiteDomain: z - .string() - .optional() - .describe("Website domain. Alternative to websiteId."), -} as const; +const DateTimeSchema = z.union([ + z.iso.date(), + z.iso.datetime({ offset: true }), +]); const QueryItemSchema = z.object({ type: z.string(), preset: z.enum(MCP_DATE_PRESETS as [string, ...string[]]).optional(), + // Batch queries report invalid ranges per item rather than rejecting every item. from: z.string().optional(), to: z.string().optional(), timeUnit: z.enum(TIME_UNIT).optional(), @@ -92,12 +94,6 @@ const WebsiteSummarySchema = z.object({ isPublic: z.boolean().nullable(), }); -const WorkflowFilterSchema = z.object({ - field: z.string(), - operator: z.enum(["equals", "contains", "not_equals", "in", "not_in"]), - value: z.union([z.string(), z.array(z.string())]), -}); - const FunnelStepSchema = z.object({ type: z.enum(["PAGE_VIEW", "EVENT", "CUSTOM"]), target: z.string().min(1), @@ -129,41 +125,6 @@ const FlagVariantSchema = variantSchema; const FlagStatusSchema = z.enum(["active", "inactive", "archived"]); const FlagTypeSchema = z.enum(["boolean", "rollout", "multivariant"]); -const ConfirmedSchema = z.boolean().optional().default(false); - -const MutationResultSchema = z - .object({ - confirmationRequired: z.boolean().optional(), - message: z.string(), - preview: z.boolean().optional(), - success: z.boolean().optional(), - }) - .passthrough(); - -const WRITE_METADATA = { - capability: "workspace", - access: { - confirmation: "recommended", - kind: "write", - }, - evlogAction: "tool_mutation", -} satisfies Partial; - -function writeMetadata(scopes: string[]): Partial { - return { - ...WRITE_METADATA, - access: { - ...WRITE_METADATA.access, - scopes, - }, - }; -} - -function assertValidDate(value: string | undefined, field: string): void { - if (value && !dayjs(value).isValid()) { - throw new McpToolError("invalid_input", `${field} must be a valid date`); - } -} function createChartContext(input: { from?: string; @@ -188,13 +149,6 @@ function asRecord(value: unknown): Record { : {}; } -function getResolvedWebsiteId(ctx: McpHandlerContext): string { - if (!ctx.websiteId) { - throw new McpToolError("internal", "Website was not resolved."); - } - return ctx.websiteId; -} - function createFlagUserRule( matchBy: "email" | "user_id", values: string[] @@ -213,12 +167,13 @@ const listWebsitesTool = defineMcpTool( { name: "list_websites", description: - "List websites the caller can access. Use only when the user hasn't named one — every other tool accepts websiteId, websiteName, or websiteDomain.", + "List accessible websites when the user hasn't named one. Most website-scoped tools accept websiteId, websiteName, or websiteDomain.", inputSchema: z.object({}), outputSchema: z.object({ websites: z.array(WebsiteSummarySchema), total: z.number(), }), + metadata: metadataForResource("organization", ["read"]), ratelimit: { limit: 60, windowSec: 60 }, }, async (_input, ctx) => { @@ -250,10 +205,7 @@ const listInsightsTool = defineMcpTool( hasMore: z.boolean(), insights: z.array(insightBriefItemSchema), }), - metadata: { - access: { kind: "read", scopes: ["read:data"] }, - capability: "analytics", - }, + metadata: metadataForResource("website", ["read"]), resolveWebsite: "optional", ratelimit: { limit: 60, windowSec: 60 }, }, @@ -300,10 +252,7 @@ const listInvestigationsTool = defineMcpTool( hasMore: z.boolean(), investigations: z.array(historyInsightSchema), }), - metadata: { - access: { kind: "read", scopes: ["read:data"] }, - capability: "analytics", - }, + metadata: metadataForResource("website", ["read"]), resolveWebsite: "optional", ratelimit: { limit: 60, windowSec: 60 }, }, @@ -349,10 +298,7 @@ const getInvestigationTool = defineMcpTool( investigation: historyInsightSchema.nullable(), timeline: z.array(insightTimelineItemSchema), }), - metadata: { - access: { kind: "read", scopes: ["read:data"] }, - capability: "analytics", - }, + metadata: metadataForResource("website", ["read"]), ratelimit: { limit: 60, windowSec: 60 }, }, async (input, ctx) => { @@ -392,7 +338,7 @@ const replyToInvestigationTool = defineMcpTool( ), }), outputSchema: z.object({ reply: insightTimelineReplySchema }), - metadata: writeMetadata(["manage:websites"]), + metadata: metadataForResource("website", ["update"]), ratelimit: { limit: 20, windowSec: 60 }, }, async (input, ctx) => { @@ -756,27 +702,17 @@ const getFunnelAnalyticsTool = defineMcpTool( name: "get_funnel_analytics", description: "Return per-step conversion, drop-off, and timing for one funnel. Use after list_funnels to analyze a specific funnelId.", - inputSchema: z.object({ + inputSchema: McpDateRangeSchema.safeExtend({ ...WebsiteSelectorSchema, funnelId: z.string().describe("Funnel ID from list_funnels"), - from: z - .string() - .optional() - .describe("Start date YYYY-MM-DD (defaults to 30 days ago)"), - to: z - .string() - .optional() - .describe("End date YYYY-MM-DD (defaults to today)"), }), // Passthrough from RPC — shape varies by funnel. Permissive by design. - outputSchema: z.record(z.string(), z.unknown()), + outputSchema: DynamicObjectSchema, resolveWebsite: true, ratelimit: { limit: 60, windowSec: 60 }, }, - async (input, ctx) => { - assertValidDate(input.from, "from"); - assertValidDate(input.to, "to"); - return await callRPCProcedure( + async (input, ctx) => + await callRPCProcedure( "funnels", "getAnalytics", { @@ -786,8 +722,7 @@ const getFunnelAnalyticsTool = defineMcpTool( endDate: input.to, }, buildRpcContext(ctx) - ); - } + ) ); const createFunnelTool = defineMcpTool( @@ -806,7 +741,7 @@ const createFunnelTool = defineMcpTool( }), outputSchema: MutationResultSchema, resolveWebsite: true, - metadata: writeMetadata(["manage:websites"]), + metadata: metadataForResource("website", ["update"]), ratelimit: { limit: 10, windowSec: 60 }, }, async (input, ctx) => { @@ -888,27 +823,17 @@ const getGoalAnalyticsTool = defineMcpTool( name: "get_goal_analytics", description: "Return entered/completed counts and conversion rate for one goalId. Use after list_goals.", - inputSchema: z.object({ + inputSchema: McpDateRangeSchema.safeExtend({ ...WebsiteSelectorSchema, goalId: z.string().describe("Goal ID from list_goals"), - from: z - .string() - .optional() - .describe("Start date YYYY-MM-DD (defaults to 30 days ago)"), - to: z - .string() - .optional() - .describe("End date YYYY-MM-DD (defaults to today)"), }), // Passthrough from RPC — shape varies by goal. Permissive by design. - outputSchema: z.record(z.string(), z.unknown()), + outputSchema: DynamicObjectSchema, resolveWebsite: true, ratelimit: { limit: 60, windowSec: 60 }, }, - async (input, ctx) => { - assertValidDate(input.from, "from"); - assertValidDate(input.to, "to"); - return await callRPCProcedure( + async (input, ctx) => + await callRPCProcedure( "goals", "getAnalytics", { @@ -918,8 +843,7 @@ const getGoalAnalyticsTool = defineMcpTool( endDate: input.to, }, buildRpcContext(ctx) - ); - } + ) ); const createGoalTool = defineMcpTool( @@ -939,7 +863,7 @@ const createGoalTool = defineMcpTool( }), outputSchema: MutationResultSchema, resolveWebsite: true, - metadata: writeMetadata(["manage:websites"]), + metadata: metadataForResource("website", ["update"]), ratelimit: { limit: 10, windowSec: 60 }, }, async (input, ctx) => { @@ -997,6 +921,7 @@ const listLinkFoldersTool = defineMcpTool( hint: z.string(), }), resolveWebsite: true, + metadata: metadataForResource("link", ["read"]), ratelimit: { limit: 60, windowSec: 60 }, }, async (_input, ctx) => { @@ -1039,6 +964,7 @@ const listLinksTool = defineMcpTool( hint: z.string().optional(), }), resolveWebsite: true, + metadata: metadataForResource("link", ["read"]), ratelimit: { limit: 60, windowSec: 60 }, }, async (_input, ctx) => { @@ -1105,6 +1031,7 @@ const searchLinksTool = defineMcpTool( hasMore: z.boolean(), }), resolveWebsite: true, + metadata: metadataForResource("link", ["read"]), ratelimit: { limit: 20, windowSec: 60 }, }, async (input, ctx) => { @@ -1150,7 +1077,7 @@ const createLinkTool = defineMcpTool( .max(50) .regex(/^[a-zA-Z0-9_-]+$/) .optional(), - expiresAt: z.string().optional(), + expiresAt: DateTimeSchema.optional(), expiredRedirectUrl: httpUrlSchema.optional(), ogTitle: z.string().max(200).optional(), ogDescription: z.string().max(500).optional(), @@ -1172,11 +1099,10 @@ const createLinkTool = defineMcpTool( }), outputSchema: MutationResultSchema, resolveWebsite: true, - metadata: writeMetadata(["write:links"]), + metadata: metadataForResource("link", ["read", "create"]), ratelimit: { limit: 20, windowSec: 60 }, }, async (input, ctx) => { - assertValidDate(input.expiresAt, "expiresAt"); const orgId = await getOrganizationId(getResolvedWebsiteId(ctx)); if (orgId instanceof Error) { throw new McpToolError("not_found", orgId.message); @@ -1244,12 +1170,9 @@ const createLinkTool = defineMcpTool( const listAnnotationsTool = defineMcpTool( { name: "list_annotations", - description: - "List chart annotations for a website over a date range. Defaults to the last 30 days.", + description: "List chart annotations for a website.", inputSchema: z.object({ ...WebsiteSelectorSchema, - from: z.string().optional(), - to: z.string().optional(), granularity: z.enum(["hourly", "daily", "weekly", "monthly"]).optional(), metrics: z.array(z.string()).optional(), chartContext: ChartContextSchema.optional(), @@ -1262,8 +1185,6 @@ const listAnnotationsTool = defineMcpTool( ratelimit: { limit: 60, windowSec: 60 }, }, async (input, ctx) => { - assertValidDate(input.from, "from"); - assertValidDate(input.to, "to"); const result = await callRPCProcedure( "annotations", "list", @@ -1284,27 +1205,35 @@ const createAnnotationTool = defineMcpTool( name: "create_annotation", description: "Create a chart annotation. Call with confirmed=false for preview before writing.", - inputSchema: z.object({ - ...WebsiteSelectorSchema, - chartContext: ChartContextSchema.optional(), - annotationType: z.enum(["point", "line", "range"]), - xValue: z.string(), - xEndValue: z.string().optional(), - yValue: z.number().optional(), - text: z.string().min(1).max(500), - tags: z.array(z.string()).optional(), - color: z.string().optional(), - isPublic: z.boolean().optional(), - confirmed: ConfirmedSchema, - }), + inputSchema: z + .object({ + ...WebsiteSelectorSchema, + chartContext: ChartContextSchema.optional(), + annotationType: z.enum(["point", "line", "range"]), + xValue: DateTimeSchema, + xEndValue: DateTimeSchema.optional(), + yValue: z.number().optional(), + text: z.string().min(1).max(500), + tags: z.array(z.string()).optional(), + color: z.string().optional(), + isPublic: z.boolean().optional(), + confirmed: ConfirmedSchema, + }) + .refine( + (input) => + !input.xEndValue || + new Date(input.xEndValue) >= new Date(input.xValue), + { + message: "xEndValue must be on or after xValue.", + path: ["xEndValue"], + } + ), outputSchema: MutationResultSchema, resolveWebsite: true, - metadata: writeMetadata(["manage:websites"]), + metadata: metadataForResource("website", ["update"]), ratelimit: { limit: 20, windowSec: 60 }, }, async (input, ctx) => { - assertValidDate(input.xValue, "xValue"); - assertValidDate(input.xEndValue, "xEndValue"); if (input.annotationType === "range" && !input.xEndValue) { throw new McpToolError( "invalid_input", @@ -1421,7 +1350,7 @@ const createFlagTool = defineMcpTool( }), outputSchema: MutationResultSchema, resolveWebsite: true, - metadata: writeMetadata(["manage:flags", "manage:websites"]), + metadata: metadataForResource("flag", ["create"]), ratelimit: { limit: 20, windowSec: 60 }, }, async (input, ctx) => { @@ -1500,7 +1429,7 @@ const updateFlagTool = defineMcpTool( confirmed: ConfirmedSchema, }), outputSchema: MutationResultSchema, - metadata: writeMetadata(["manage:flags", "manage:websites"]), + metadata: metadataForResource("flag", ["update"]), ratelimit: { limit: 20, windowSec: 60 }, }, async (input, ctx) => { @@ -1547,7 +1476,7 @@ const addUsersToFlagTool = defineMcpTool( }), outputSchema: MutationResultSchema, resolveWebsite: true, - metadata: writeMetadata(["manage:flags", "manage:websites"]), + metadata: metadataForResource("flag", ["update"]), ratelimit: { limit: 20, windowSec: 60 }, }, async (input, ctx) => { @@ -1605,6 +1534,7 @@ const addUsersToFlagTool = defineMcpTool( ); const TOOL_FACTORIES = [ + ...createMcpWorkspaceTools(), listWebsitesTool, listInsightsTool, listInvestigationsTool, diff --git a/packages/ai/src/ai/mcp/workspace-tools.ts b/packages/ai/src/ai/mcp/workspace-tools.ts new file mode 100644 index 0000000000..ece4407d81 --- /dev/null +++ b/packages/ai/src/ai/mcp/workspace-tools.ts @@ -0,0 +1,424 @@ +import { + DEEP_LINK_APP_IDS, + isDeepLinkTarget, +} from "@databuddy/shared/constants/deep-link-apps"; +import { LINK_SLUG_REGEX } from "@databuddy/shared/constants/links"; +import { httpUrlSchema } from "@databuddy/validation"; +import { z } from "zod"; +import { callRPCProcedure } from "../tools/utils"; +import { + LinkFolderSelectorSchema, + hasLinkFolderSelector, + listLinkFolders, + parseLinkRow, + resolveLinkFolderFromList, + summarizeLink, + summarizeLinkFolder, +} from "../tools/link-catalog"; +import { + defineMcpTool, + metadataForResource, + McpToolError, + type McpToolFactory, +} from "./define-tool"; +import { buildRpcContext, getOrganizationId } from "./tool-context"; +import { + ConfirmedSchema, + DynamicObjectSchema, + getResolvedWebsiteId, + McpDateRangeSchema, + MutationResultSchema, + WebsiteSelectorSchema, + WorkflowFilterSchema, +} from "./tool-contracts"; + +function omitUndefined( + input: Record +): Record { + return Object.fromEntries( + Object.entries(input).filter(([, value]) => value !== undefined) + ); +} + +const getFunnelAnalyticsByReferrerTool = defineMcpTool( + { + name: "get_funnel_analytics_by_referrer", + description: + "Return funnel conversion analytics broken down by referrer/source. Use after list_funnels to see which sources convert best.", + inputSchema: McpDateRangeSchema.safeExtend({ + ...WebsiteSelectorSchema, + funnelId: z.string().describe("Funnel ID from list_funnels"), + }), + outputSchema: DynamicObjectSchema, + resolveWebsite: true, + ratelimit: { limit: 60, windowSec: 60 }, + }, + async (input, ctx) => + await callRPCProcedure( + "funnels", + "getAnalyticsByReferrer", + { + funnelId: input.funnelId, + websiteId: getResolvedWebsiteId(ctx), + startDate: input.from, + endDate: input.to, + }, + buildRpcContext(ctx) + ) +); + +const updateGoalTool = defineMcpTool( + { + name: "update_goal", + description: + "Update a conversion goal. Call with confirmed=false to preview changes, then confirmed=true after explicit user approval.", + inputSchema: z.object({ + id: z.string(), + type: z.enum(["PAGE_VIEW", "EVENT", "CUSTOM"]).optional(), + target: z.string().min(1).optional(), + name: z.string().min(1).max(100).optional(), + description: z.string().nullable().optional(), + filters: z.array(WorkflowFilterSchema).optional(), + ignoreHistoricData: z.boolean().optional(), + isActive: z.boolean().optional(), + confirmed: ConfirmedSchema, + }), + outputSchema: MutationResultSchema, + // Preview loads the current goal before an update, so read:data is required too. + metadata: metadataForResource("website", ["read", "update"]), + ratelimit: { limit: 20, windowSec: 60 }, + }, + async ({ confirmed, id, ...input }, ctx) => { + const updates = omitUndefined(input); + const rpcContext = buildRpcContext(ctx); + const current = await callRPCProcedure( + "goals", + "getById", + { id }, + rpcContext + ); + + if (!confirmed) { + return { + preview: true, + message: + Object.keys(updates).length > 0 + ? "Review this goal update before applying it." + : "No changes detected. The goal will remain unchanged.", + confirmationRequired: Object.keys(updates).length > 0, + current, + updates, + }; + } + + if (Object.keys(updates).length === 0) { + return { + preview: true, + message: "No changes detected. The goal will remain unchanged.", + confirmationRequired: false, + current, + }; + } + + const goal = await callRPCProcedure( + "goals", + "update", + { id, ...updates }, + rpcContext + ); + return { success: true, message: "Goal updated successfully.", goal }; + } +); + +const deleteGoalTool = defineMcpTool( + { + name: "delete_goal", + description: + "Delete a conversion goal. Call with confirmed=false to preview, then confirmed=true after explicit user approval.", + inputSchema: z.object({ + id: z.string(), + confirmed: ConfirmedSchema, + }), + outputSchema: MutationResultSchema, + // Preview loads the current goal before deletion, so read:data is required too. + metadata: metadataForResource("website", ["read", "delete"]), + ratelimit: { limit: 10, windowSec: 60 }, + }, + async ({ confirmed, id }, ctx) => { + const rpcContext = buildRpcContext(ctx); + const goal = await callRPCProcedure("goals", "getById", { id }, rpcContext); + if (!confirmed) { + return { + preview: true, + message: "Review this goal deletion before applying it.", + confirmationRequired: true, + goal, + }; + } + + await callRPCProcedure("goals", "delete", { id }, rpcContext); + return { success: true, message: "Goal deleted successfully." }; + } +); + +const updateAnnotationTool = defineMcpTool( + { + name: "update_annotation", + description: + "Update annotation text, tags, color, or visibility. Preview changes before applying them.", + inputSchema: z.object({ + id: z.string(), + text: z.string().min(1).max(500).optional(), + tags: z.array(z.string()).optional(), + color: z.string().optional(), + isPublic: z.boolean().optional(), + confirmed: ConfirmedSchema, + }), + outputSchema: MutationResultSchema, + // Preview loads the current annotation before an update, so read:data is required too. + metadata: metadataForResource("website", ["read", "update"]), + ratelimit: { limit: 20, windowSec: 60 }, + }, + async ({ confirmed, id, ...input }, ctx) => { + const updates = omitUndefined(input); + const rpcContext = buildRpcContext(ctx); + const current = await callRPCProcedure( + "annotations", + "getById", + { id }, + rpcContext + ); + + if (!confirmed) { + return { + preview: true, + message: + Object.keys(updates).length > 0 + ? "Review this annotation update before applying it." + : "No changes detected. The annotation will remain unchanged.", + confirmationRequired: Object.keys(updates).length > 0, + current, + updates, + }; + } + + if (Object.keys(updates).length === 0) { + return { + preview: true, + message: "No changes detected. The annotation will remain unchanged.", + confirmationRequired: false, + current, + }; + } + + const annotation = await callRPCProcedure( + "annotations", + "update", + { id, ...updates }, + rpcContext + ); + return { + success: true, + message: "Annotation updated successfully.", + annotation, + }; + } +); + +const deleteAnnotationTool = defineMcpTool( + { + name: "delete_annotation", + description: + "Delete a chart annotation. Call with confirmed=false to preview, then confirmed=true after explicit user approval.", + inputSchema: z.object({ + id: z.string(), + confirmed: ConfirmedSchema, + }), + outputSchema: MutationResultSchema, + // Preview loads the current annotation before deletion, so read:data is required too. + metadata: metadataForResource("website", ["read", "delete"]), + ratelimit: { limit: 10, windowSec: 60 }, + }, + async ({ confirmed, id }, ctx) => { + const rpcContext = buildRpcContext(ctx); + const annotation = await callRPCProcedure( + "annotations", + "getById", + { id }, + rpcContext + ); + if (!confirmed) { + return { + preview: true, + message: "Review this annotation deletion before applying it.", + confirmationRequired: true, + annotation, + }; + } + + await callRPCProcedure("annotations", "delete", { id }, rpcContext); + return { success: true, message: "Annotation deleted successfully." }; + } +); + +const linkUpdateFields = { + name: z.string().min(1).max(255).optional(), + targetUrl: httpUrlSchema.optional(), + slug: z.string().min(3).max(50).regex(LINK_SLUG_REGEX).optional(), + expiresAt: z.iso.datetime({ offset: true }).nullable().optional(), + expiredRedirectUrl: httpUrlSchema.nullable().optional(), + ogTitle: z.string().max(200).nullable().optional(), + ogDescription: z.string().max(500).nullable().optional(), + ogImageUrl: httpUrlSchema.nullable().optional(), + externalId: z.string().max(255).nullable().optional(), + ...LinkFolderSelectorSchema.shape, + deepLinkApp: z.enum(DEEP_LINK_APP_IDS).nullable().optional(), +}; + +const updateLinkTool = defineMcpTool( + { + name: "update_link", + description: + "Update a short link. Call with confirmed=false to preview changes, then confirmed=true after explicit user approval.", + inputSchema: z.object({ + ...WebsiteSelectorSchema, + id: z.string(), + ...linkUpdateFields, + confirmed: ConfirmedSchema, + }), + outputSchema: MutationResultSchema, + resolveWebsite: true, + metadata: metadataForResource("link", ["read", "update"]), + ratelimit: { limit: 20, windowSec: 60 }, + }, + async ({ confirmed, id, folderId, folderSlug, ...input }, ctx) => { + const organizationId = await getOrganizationId(getResolvedWebsiteId(ctx)); + if (organizationId instanceof Error) { + throw new McpToolError("not_found", organizationId.message); + } + + const rpcContext = buildRpcContext(ctx); + const [current, folders] = await Promise.all([ + callRPCProcedure("links", "get", { id, organizationId }, rpcContext).then( + parseLinkRow + ), + listLinkFolders(rpcContext, organizationId), + ]); + const folderSelection = resolveLinkFolderFromList(folders, { + folderId, + folderSlug, + }); + if (!folderSelection.ok) { + throw new McpToolError("invalid_input", folderSelection.message); + } + + const effectiveDeepLinkApp = + input.deepLinkApp === undefined ? current.deepLinkApp : input.deepLinkApp; + const effectiveTargetUrl = input.targetUrl ?? current.targetUrl; + if ( + effectiveDeepLinkApp && + !isDeepLinkTarget(effectiveDeepLinkApp, effectiveTargetUrl) + ) { + throw new McpToolError( + "invalid_input", + "Deep link URLs must use HTTPS and match the selected app." + ); + } + + const updates = omitUndefined({ + ...input, + ...(hasLinkFolderSelector({ folderId, folderSlug }) + ? { folderId: folderSelection.folderId } + : {}), + }); + + if (!confirmed) { + return { + preview: true, + message: + Object.keys(updates).length > 0 + ? "Review this short-link update before applying it." + : "No changes detected. The short link will remain unchanged.", + confirmationRequired: Object.keys(updates).length > 0, + current: summarizeLink(current, folders), + updates, + availableFolders: folderSelection.folders.map(summarizeLinkFolder), + }; + } + + if (Object.keys(updates).length === 0) { + return { + preview: true, + message: "No changes detected. The short link will remain unchanged.", + confirmationRequired: false, + current: summarizeLink(current, folders), + }; + } + + const link = parseLinkRow( + await callRPCProcedure("links", "update", { id, ...updates }, rpcContext) + ); + return { + success: true, + message: `Short link "${link.name}" updated successfully.`, + link: summarizeLink(link, folderSelection.folders), + }; + } +); + +const deleteLinkTool = defineMcpTool( + { + name: "delete_link", + description: + "Delete a short link. Call with confirmed=false to preview, then confirmed=true after explicit user approval.", + inputSchema: z.object({ + ...WebsiteSelectorSchema, + id: z.string(), + confirmed: ConfirmedSchema, + }), + outputSchema: MutationResultSchema, + resolveWebsite: true, + metadata: metadataForResource("link", ["read", "delete"]), + ratelimit: { limit: 10, windowSec: 60 }, + }, + async ({ confirmed, id }, ctx) => { + const organizationId = await getOrganizationId(getResolvedWebsiteId(ctx)); + if (organizationId instanceof Error) { + throw new McpToolError("not_found", organizationId.message); + } + + const rpcContext = buildRpcContext(ctx); + const [link, folders] = await Promise.all([ + callRPCProcedure("links", "get", { id, organizationId }, rpcContext).then( + parseLinkRow + ), + listLinkFolders(rpcContext, organizationId), + ]); + if (!confirmed) { + return { + preview: true, + message: "Review this short-link deletion before applying it.", + confirmationRequired: true, + link: summarizeLink(link, folders), + }; + } + + await callRPCProcedure("links", "delete", { id }, rpcContext); + return { + success: true, + message: `Short link "${link.name}" deleted successfully.`, + }; + } +); + +export function createMcpWorkspaceTools(): McpToolFactory[] { + return [ + getFunnelAnalyticsByReferrerTool, + updateGoalTool, + deleteGoalTool, + updateAnnotationTool, + deleteAnnotationTool, + updateLinkTool, + deleteLinkTool, + ]; +} diff --git a/packages/ai/src/mcp/http.ts b/packages/ai/src/mcp/http.ts index ab528723ea..4a9dcfe326 100644 --- a/packages/ai/src/mcp/http.ts +++ b/packages/ai/src/mcp/http.ts @@ -1,7 +1,7 @@ import { getAccessibleWebsiteIds, - hasKeyScope, - hasWebsiteScope, + hasKeyAllScopes, + hasWebsiteAllScopes, } from "@databuddy/api-keys/resolve"; import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js"; @@ -16,63 +16,40 @@ import type { import { createMcpTools } from "../ai/mcp/tools"; import { GUIDE_MARKDOWN, GUIDE_URI, MCP_INSTRUCTIONS } from "./guide"; -const DEFAULT_MCP_SERVER_NAME = "databuddy"; -const DEFAULT_MCP_SERVER_VERSION = "1.0.0"; - export interface DatabuddyMcpHttpOptions extends McpRequestContext { request: Request; - serverName?: string; - serverVersion?: string; } -const UNAUTH_BODY_PARSE_CAP = 4096; - -export async function createMcpUnauthorizedResponse( - request: Request, - options?: { resourceMetadataUrl?: string } -): Promise { +export function createMcpUnauthorizedResponse(): Response { mergeWideEvent({ mcp_auth: "unauthorized" }); - const resourceMetadata = options?.resourceMetadataUrl - ? `, resource_metadata="${options.resourceMetadataUrl}"` - : ""; - return Response.json( { jsonrpc: "2.0", error: { code: -32_001, message: - "Authentication required. Use x-api-key or Authorization: Bearer with a key that has read:data scope.", + "Authentication required. Use x-api-key or Authorization: Bearer with a valid Databuddy API key.", }, - id: shouldReadUnauthId(request) ? await readJsonRpcId(request) : null, + id: null, }, { status: 401, headers: { - "WWW-Authenticate": `Bearer realm="databuddy", error="invalid_token", error_description="API key required (x-api-key or Authorization: Bearer)"${resourceMetadata}`, + "WWW-Authenticate": + 'Bearer realm="databuddy", error="invalid_token", error_description="API key required (x-api-key or Authorization: Bearer)"', }, } ); } -function shouldReadUnauthId(request: Request): boolean { - const contentType = request.headers.get("content-type") ?? ""; - if (!contentType.toLowerCase().includes("application/json")) { - return false; - } - const length = Number.parseInt( - request.headers.get("content-length") ?? "", - 10 - ); - return ( - Number.isFinite(length) && length > 0 && length <= UNAUTH_BODY_PARSE_CAP - ); -} - export async function handleDatabuddyMcpRequest( options: DatabuddyMcpHttpOptions ): Promise { + if (options.request.method !== "POST") { + return new Response(null, { status: 405, headers: { Allow: "POST" } }); + } + mergeWideEvent({ mcp_auth: options.userId ? "session" : "api_key", mcp_session: Boolean(options.userId), @@ -81,11 +58,10 @@ export async function handleDatabuddyMcpRequest( const server = new McpServer( { - name: options.serverName ?? DEFAULT_MCP_SERVER_NAME, - version: options.serverVersion ?? DEFAULT_MCP_SERVER_VERSION, + name: "databuddy", + version: "1.0.0", }, { - capabilities: { tools: {}, resources: {} }, instructions: MCP_INSTRUCTIONS, } ); @@ -93,9 +69,22 @@ export async function handleDatabuddyMcpRequest( registerGuideResource(server); for (const tool of createMcpTools(options)) { - if (apiKeyCanCallTool(options.apiKey, tool)) { - registerTool(server, tool); + if (!apiKeyCanCallTool(options.apiKey, tool)) { + continue; } + server.registerTool( + tool.name, + { + title: titleFromName(tool.name), + description: tool.description, + inputSchema: toMcpSchema(tool.inputSchema), + ...(tool.outputSchema && { + outputSchema: toMcpSchema(tool.outputSchema), + }), + annotations: deriveAnnotations(tool.metadata), + }, + tool.handler + ); } const transport = new WebStandardStreamableHTTPServerTransport({ @@ -126,27 +115,18 @@ function apiKeyCanCallTool( // Session-authenticated callers fall through to downstream role checks. return true; } - if (required.every((scope) => hasKeyScope(apiKey, scope))) { + const globalScopes = tool.metadata.access.globalScopes; + if (globalScopes.length && !hasKeyAllScopes(apiKey, globalScopes)) { + return false; + } + const websiteScopes = required.filter( + (scope) => !globalScopes.includes(scope) + ); + if (!websiteScopes.length || hasKeyAllScopes(apiKey, websiteScopes)) { return true; } return getAccessibleWebsiteIds(apiKey).some((websiteId) => - required.every((scope) => hasWebsiteScope(apiKey, websiteId, scope)) - ); -} - -function registerTool(server: McpServer, tool: RegisteredMcpTool): void { - server.registerTool( - tool.name, - { - title: titleFromName(tool.name), - description: tool.description, - inputSchema: toMcpSchema(tool.inputSchema), - ...(tool.outputSchema && { - outputSchema: toMcpSchema(tool.outputSchema), - }), - annotations: deriveAnnotations(tool.metadata), - }, - tool.handler + hasWebsiteAllScopes(apiKey, websiteId, websiteScopes) ); } @@ -159,12 +139,10 @@ function titleFromName(name: string): string { function deriveAnnotations(metadata: McpToolMetadata): ToolAnnotations { const isRead = metadata.access.kind === "read"; - const requiresConfirmation = metadata.access.confirmation === "required"; return { readOnlyHint: isRead, - destructiveHint: !isRead && requiresConfirmation, + destructiveHint: !isRead, idempotentHint: isRead, - openWorldHint: true, }; } @@ -194,16 +172,3 @@ function toMcpSchema(schema: RegisteredMcpTool["inputSchema"]): AnySchema { // The MCP SDK's zod-compat type targets a different Zod surface than this repo's Zod v4 types. return schema as unknown as AnySchema; } - -async function readJsonRpcId( - request: Request -): Promise { - try { - const body = (await request.clone().json()) as { id?: unknown }; - return typeof body.id === "string" || typeof body.id === "number" - ? body.id - : null; - } catch { - return null; - } -} diff --git a/packages/api-keys/src/scopes.test.ts b/packages/api-keys/src/scopes.test.ts index 2a244f613b..3b51d62cdb 100644 --- a/packages/api-keys/src/scopes.test.ts +++ b/packages/api-keys/src/scopes.test.ts @@ -1,5 +1,8 @@ import { describe, expect, test } from "bun:test"; -import { requiredScopesForResource } from "./scopes"; +import { + apiKeyScopeTargetForResource, + requiredScopesForResource, +} from "./scopes"; describe("requiredScopesForResource", () => { test("website read requires read:data", () => { @@ -54,6 +57,11 @@ describe("flag resource scopes", () => { }); describe("link resource scopes", () => { + test("uses global API-key scopes", () => { + expect(apiKeyScopeTargetForResource("link")).toBe("global"); + expect(apiKeyScopeTargetForResource("website")).toBe("website"); + }); + test("read requires read:links", () => { expect(requiredScopesForResource("link", ["read"])).toEqual([ "read:links", diff --git a/packages/api-keys/src/scopes.ts b/packages/api-keys/src/scopes.ts index abfedeaa21..1b83037d4a 100644 --- a/packages/api-keys/src/scopes.ts +++ b/packages/api-keys/src/scopes.ts @@ -11,6 +11,8 @@ type PermissionName = | "cancel" | "manage"; +export type ApiKeyScopeTarget = "global" | "website"; + const DEFAULT_SCOPE_MAP: Record = { read: "read:data", view_analytics: "read:data", @@ -78,3 +80,11 @@ export function requiredScopesForResource( return [...scopes]; } + +/** The metadata namespace where a resource's API-key scopes are evaluated. */ +export function apiKeyScopeTargetForResource( + resource: string +): ApiKeyScopeTarget { + // Links belong to an organization, not to an individual website. + return resource === "link" ? "global" : "website"; +} From 859fe13a9839b48f97b88d4238507e7f3ad11b32 Mon Sep 17 00:00:00 2001 From: iza <59828082+izadoesdev@users.noreply.github.com> Date: Fri, 21 Aug 2026 23:08:25 +0300 Subject: [PATCH 07/88] feat(dashboard): configure MCP action capabilities --- .agents/skills/databuddy-internal/SKILL.md | 1 + .../organizations/mcp-capabilities.test.ts | 41 +++++ .../organizations/mcp-capabilities.ts | 97 ++++++++++++ .../organizations/mcp-setup-sheet.tsx | 148 +++++++++++++----- apps/docs/content/docs/api/mcp.mdx | 30 +++- apps/docs/lib/agent-discovery.test.ts | 9 ++ packages/ai/src/mcp/guide.ts | 4 +- packages/shared/src/agent-discovery.test.ts | 16 ++ packages/shared/src/agent-discovery.ts | 2 - 9 files changed, 299 insertions(+), 49 deletions(-) create mode 100644 apps/dashboard/components/organizations/mcp-capabilities.test.ts create mode 100644 apps/dashboard/components/organizations/mcp-capabilities.ts diff --git a/.agents/skills/databuddy-internal/SKILL.md b/.agents/skills/databuddy-internal/SKILL.md index 37aa940211..691db5fb7e 100644 --- a/.agents/skills/databuddy-internal/SKILL.md +++ b/.agents/skills/databuddy-internal/SKILL.md @@ -144,6 +144,7 @@ Read [codebase-map.md](./references/codebase-map.md) when you need deeper routin - Do not centralize, relocate, or otherwise refactor dashboard E2E API route access gates during cleanup; keep test-only access checks local to each route unless iza explicitly asks for that change. - Integration catalog logos: use filled Simple Icons SVG path data (or equivalent filled brand SVG), store the path on each item as `iconPath`, render it through a shared logo tile with `bg-secondary/60`, `border-border/70`, `text-foreground`, and `fill="currentColor"`, then use brand color only as a small accent bar (`accent` or `accentClassName: "bg-foreground/70"` for black/near-black brands). Avoid raw brand-black icons or mixed line/filled icon sets that disappear in dark mode. - Organization integrations settings should stay list-first and operational: coming-soon integrations are static rows, Slack is the only expandable row for now, and connected integrations need obvious lifecycle controls such as uninstall/disconnect in the row details. +- MCP setup UI should mirror the governed write metadata in `packages/ai/src/ai/mcp/tools.ts`: default to `read:data`, then expose explicit action bundles for workspace actions, feature flags, and short links with their required scopes and confirmation behavior. - Dashboard UI must use `apps/dashboard/components/ds` primitives exactly; feature code must not use raw form/control elements (`button`, `input`, `select`, `textarea`, native dialogs), Base UI/Radix primitives, or ad hoc styled controls directly. If a variant is missing, add or extend the DS component first. For menu-style folder/status/filter/sort/action pickers, use `components/ds/dropdown-menu.tsx`; use `Select` only when the established pattern is explicitly a select/combobox. Read `apps/dashboard/components/ds/README.md` before creating new dashboard UI. - `DropdownMenu.GroupLabel` must be rendered inside `DropdownMenu.Group`; Base UI throws `MenuGroupRootContext is missing` when labels are placed directly under `DropdownMenu.Content`. - Traffic Trends chart annotations should use a chart-adjacent annotation rail for dense data; avoid in-plot labels, tall lines, or floating dots that compete with the chart tooltip/data layer. diff --git a/apps/dashboard/components/organizations/mcp-capabilities.test.ts b/apps/dashboard/components/organizations/mcp-capabilities.test.ts new file mode 100644 index 0000000000..5dd14d7b16 --- /dev/null +++ b/apps/dashboard/components/organizations/mcp-capabilities.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, test } from "bun:test"; +import { + getMcpScopeGrant, + getMcpScopeSummary, + getMcpScopes, +} from "./mcp-capabilities"; + +describe("MCP capabilities", () => { + test("always includes analytics read access", () => { + expect(getMcpScopes([])).toEqual(["read:data"]); + }); + + test("adds the scopes required by selected action bundles", () => { + expect(getMcpScopes(["workspace", "flags", "links"])).toEqual([ + "read:data", + "manage:websites", + "manage:flags", + "read:links", + "write:links", + ]); + }); + + test("keeps link scopes organization-wide when analytics is website-scoped", () => { + expect( + getMcpScopeGrant(["workspace", "links"], ["site-a", "site-b"]) + ).toEqual({ + scopes: ["read:links", "write:links"], + resources: { + "website:site-a": ["read:data", "manage:websites"], + "website:site-b": ["read:data", "manage:websites"], + }, + }); + }); + + test("summarizes action scopes without exposing implementation details", () => { + expect(getMcpScopeSummary(["read:data"])).toBe("Read-only analytics"); + expect( + getMcpScopeSummary(["read:data", "manage:websites", "manage:flags"]) + ).toBe("Analytics + Workspace actions, Feature flags"); + }); +}); diff --git a/apps/dashboard/components/organizations/mcp-capabilities.ts b/apps/dashboard/components/organizations/mcp-capabilities.ts new file mode 100644 index 0000000000..8b138fe877 --- /dev/null +++ b/apps/dashboard/components/organizations/mcp-capabilities.ts @@ -0,0 +1,97 @@ +import type { ApiScope } from "@databuddy/api-keys/scopes"; + +export type McpAction = "workspace" | "flags" | "links"; + +const MCP_ACTION_SCOPES: Record = { + workspace: ["manage:websites"], + flags: ["manage:flags"], + links: ["read:links", "write:links"], +}; + +export const MCP_ACTION_OPTIONS: Array<{ + description: string; + label: string; + scopes: readonly ApiScope[]; + value: McpAction; +}> = [ + { + value: "workspace", + label: "Workspace actions", + description: + "Create, update, and delete goals and annotations; create funnels and reply to investigations.", + scopes: MCP_ACTION_SCOPES.workspace, + }, + { + value: "flags", + label: "Feature flags", + description: + "Create, update, and target feature flags for the websites this key can access.", + scopes: MCP_ACTION_SCOPES.flags, + }, + { + value: "links", + label: "Short links", + description: + "Create, update, and delete short links across this organization.", + scopes: MCP_ACTION_SCOPES.links, + }, +]; + +export function getMcpScopes(actions: readonly McpAction[]): ApiScope[] { + const scopes = new Set(["read:data"]); + + for (const action of actions) { + for (const scope of MCP_ACTION_SCOPES[action]) { + scopes.add(scope); + } + } + + return [...scopes]; +} + +/** + * Links are organization-owned, while analytics, flags, and workspace actions + * can be constrained to individual websites. Keep that distinction at key + * creation so the setup UI never creates a connection that advertises links + * but cannot call link tools. + */ +export function getMcpScopeGrant( + actions: readonly McpAction[], + websiteIds: readonly string[] +): { resources?: Record; scopes: ApiScope[] } { + const scopes = getMcpScopes(actions); + if (websiteIds.length === 0) { + return { scopes }; + } + + const globalScopes = actions.includes("links") + ? [...MCP_ACTION_SCOPES.links] + : []; + const websiteScopes = scopes.filter((scope) => !globalScopes.includes(scope)); + + return { + scopes: globalScopes, + resources: Object.fromEntries( + websiteIds.map((websiteId) => [`website:${websiteId}`, websiteScopes]) + ), + }; +} + +export function getMcpScopeSummary(scopes: readonly string[]): string { + const scopeSet = new Set(scopes); + const actions: string[] = []; + + if (scopeSet.has("manage:websites")) { + actions.push("Workspace actions"); + } + if (scopeSet.has("manage:flags")) { + actions.push("Feature flags"); + } + if (scopeSet.has("write:links")) { + actions.push("Short links"); + } + + return actions.length > 0 + ? `Analytics + ${actions.join(", ")}` + : "Read-only analytics"; +} diff --git a/apps/dashboard/components/organizations/mcp-setup-sheet.tsx b/apps/dashboard/components/organizations/mcp-setup-sheet.tsx index 9b98aa294c..433bb22aa9 100644 --- a/apps/dashboard/components/organizations/mcp-setup-sheet.tsx +++ b/apps/dashboard/components/organizations/mcp-setup-sheet.tsx @@ -29,6 +29,13 @@ import { Badge, Button, Field, Input, Text, dayjs } from "@databuddy/ui"; import { orpc } from "@/lib/orpc"; import { getUserFacingErrorMessage } from "@/lib/user-facing-error"; import { createMcpConfig, MCP_ENV_VAR, MCP_SERVER_URL } from "./mcp-config"; +import { + getMcpScopeGrant, + getMcpScopeSummary, + getMcpScopes, + MCP_ACTION_OPTIONS, + type McpAction, +} from "./mcp-capabilities"; type McpClient = "cursor" | "claude" | "windsurf" | "other"; type McpExpiry = "90d" | "never"; @@ -72,18 +79,20 @@ function defaultConnectionName(client: McpClient) { } function scopeSummary(key: ApiKeyListItem) { - const globalScopes = key.scopes ?? []; const websiteCount = Object.keys(key.resources ?? {}).filter((key) => key.startsWith("website:") ).length; + const grantedScopes = [ + ...(key.scopes ?? []), + ...Object.values(key.resources ?? {}).flat(), + ]; + const capabilitySummary = getMcpScopeSummary(grantedScopes); if (websiteCount > 0) { - return `${websiteCount} website${websiteCount === 1 ? "" : "s"} · ${globalScopes.length > 0 ? `${globalScopes.length} global` : "scoped"}`; + return `${websiteCount} website${websiteCount === 1 ? "" : "s"} · ${capabilitySummary}`; } - return globalScopes.includes("manage:websites") - ? "Analytics + investigation replies" - : "Read-only analytics"; + return capabilitySummary; } function keyStatus(key: ApiKeyListItem) { @@ -185,10 +194,11 @@ export function McpSetupSheet({ }) { const queryClient = useQueryClient(); const [client, setClient] = useState("cursor"); - const [name, setName] = useState(defaultConnectionName("cursor")); - const [allowInvestigationReplies, setAllowInvestigationReplies] = - useState(false); + const [name, setName] = useState(() => defaultConnectionName("cursor")); + const [selectedActions, setSelectedActions] = useState([]); const [selectedWebsiteIds, setSelectedWebsiteIds] = useState([]); + const [allowOrganizationWideLinks, setAllowOrganizationWideLinks] = + useState(false); const [expiry, setExpiry] = useState("90d"); const [newSecret, setNewSecret] = useState(null); const [useEnvironmentVariable, setUseEnvironmentVariable] = useState(false); @@ -221,20 +231,24 @@ export function McpSetupSheet({ } setClient("cursor"); setName(defaultConnectionName("cursor")); - setAllowInvestigationReplies(false); + setSelectedActions([]); setSelectedWebsiteIds([]); + setAllowOrganizationWideLinks(false); setExpiry("90d"); setNewSecret(null); setUseEnvironmentVariable(false); }, [open]); const selectedScopes = useMemo( - () => - allowInvestigationReplies - ? ["read:data", "manage:websites"] - : ["read:data"], - [allowInvestigationReplies] + () => getMcpScopes(selectedActions), + [selectedActions] + ); + const selectedWebsiteSet = useMemo( + () => new Set(selectedWebsiteIds), + [selectedWebsiteIds] ); + const needsOrganizationWideLinkAcknowledgment = + selectedActions.includes("links") && selectedWebsiteIds.length > 0; const config = newSecret ? createMcpConfig(newSecret, useEnvironmentVariable) @@ -256,30 +270,40 @@ export function McpSetupSheet({ ); }; + const toggleAction = (action: McpAction) => { + setSelectedActions((current) => + current.includes(action) + ? current.filter((value) => value !== action) + : [...current, action] + ); + if (action === "links") { + setAllowOrganizationWideLinks(false); + } + }; + const handleCreate = () => { const trimmedName = name.trim(); if (!trimmedName) { toast.error("Give this connection a name first."); return; } + if ( + needsOrganizationWideLinkAcknowledgment && + !allowOrganizationWideLinks + ) { + toast.error("Confirm organization-wide Short links access first."); + return; + } - const resources = - selectedWebsiteIds.length > 0 - ? Object.fromEntries( - selectedWebsiteIds.map((websiteId) => [ - `website:${websiteId}`, - selectedScopes, - ]) - ) - : undefined; + const grant = getMcpScopeGrant(selectedActions, selectedWebsiteIds); createMutation.mutate({ name: trimmedName, description: `Databuddy MCP connection for ${CLIENT_LABELS[client]}`, organizationId, type: "automation", - scopes: resources ? [] : selectedScopes, - resources, + scopes: grant.scopes, + resources: grant.resources, tags: ["MCP", CLIENT_LABELS[client]], expiresAt: expiry === "90d" ? dayjs().add(90, "day").toISOString() : undefined, @@ -365,25 +389,53 @@ export function McpSetupSheet({
- Read-only analytics + Analytics access - Recommended. The client can discover websites and read - analytics, but cannot change your configuration. + Read access is always included. Add only the workspace + actions you want this connection to perform. +
+ {selectedScopes.map((scope) => ( + + {scope} + + ))} +
- - read:data + 0 ? "warning" : "success"} + > + {selectedActions.length > 0 + ? `${selectedActions.length} action${selectedActions.length === 1 ? "" : "s"}` + : "Read-only"}
- - setAllowInvestigationReplies(checked === true) - } - /> +
+
+ Optional actions + + MCP previews every change first and requires explicit + approval before applying it. + +
+
+ {MCP_ACTION_OPTIONS.map((option) => ( +
+ toggleAction(option.value)} + /> +
+ ))} +
+
@@ -404,6 +456,18 @@ export function McpSetupSheet({ access, or choose specific websites for a least-privilege connection. + {needsOrganizationWideLinkAcknowledgment ? ( +
+ + setAllowOrganizationWideLinks(checked === true) + } + /> +
+ ) : null} {websitesQuery.isLoading ? (
@@ -416,7 +480,7 @@ export function McpSetupSheet({ {websitesQuery.data.map((website) => (
toggleWebsite(website.id)} @@ -476,7 +540,11 @@ export function McpSetupSheet({ Cancel + ); +} + export default function AuditLogPage() { const { activeOrganization } = useOrganizations(); + const [includeTechnical, setIncludeTechnical] = useState(false); + const [outcomeFilter, setOutcomeFilter] = useState("all"); + const [targetFilter, setTargetFilter] = useState("all"); + const [selectedEvent, setSelectedEvent] = useState(null); const query = useInfiniteQuery({ - queryKey: [...orpc.audit.list.key(), activeOrganization?.id] as const, + queryKey: [ + ...orpc.audit.list.key(), + activeOrganization?.id, + includeTechnical, + outcomeFilter, + targetFilter, + ] as const, queryFn: ({ pageParam }) => orpc.audit.list.call({ + includeTechnical, limit: 50, organizationId: activeOrganization?.id, + ...(outcomeFilter === "all" ? {} : { outcome: outcomeFilter }), ...(pageParam ? { cursor: pageParam } : {}), + ...(targetFilter === "all" ? {} : { targetType: targetFilter }), }), initialPageParam: null as string | null, getNextPageParam: (lastPage) => @@ -110,55 +483,136 @@ export default function AuditLogPage() { return (
- +
Audit log - Privileged activity recorded for {activeOrganization.name}. + Human-readable history of changes made in{" "} + {activeOrganization.name}.
- +
+ + +
+ +
+
+
+ + + {outcomeFilterLabels[outcomeFilter]} + + + + {targetFilterLabels[targetFilter]} + + {outcomeFilter !== "all" || targetFilter !== "all" ? ( + + ) : null} +
+
{events.length === 0 ? ( } - title="No audit events yet" + title={ + outcomeFilter !== "all" || targetFilter !== "all" + ? "No matching activity" + : "No audit events yet" + } variant="minimal" /> ) : (
{events.map((event) => ( -
-
-
- - {formatAction(event.action)} - - - {event.outcome} - -
- - {event.actorDisplayName ?? event.actorId} · {event.source} - -
- - {formatDateTime(event.createdAt)} - -
+ onSelect={setSelectedEvent} + /> ))}
)} @@ -189,6 +643,16 @@ export default function AuditLogPage() { ) : null}
+ {selectedEvent ? ( + { + if (!open) { + setSelectedEvent(null); + } + }} + /> + ) : null}
); } diff --git a/apps/docs/app/.well-known/mcp/manifest.json/route.ts b/apps/docs/app/.well-known/mcp/manifest.json/route.ts deleted file mode 100644 index 4ffbd6ab5e..0000000000 --- a/apps/docs/app/.well-known/mcp/manifest.json/route.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { agentJsonResponse, createMcpManifest } from "@/lib/agent-discovery"; - -export const revalidate = 3600; - -export function GET() { - return agentJsonResponse(createMcpManifest()); -} diff --git a/apps/docs/app/.well-known/oauth-authorization-server/route.ts b/apps/docs/app/.well-known/oauth-authorization-server/route.ts deleted file mode 100644 index 1e5b9a5707..0000000000 --- a/apps/docs/app/.well-known/oauth-authorization-server/route.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { - agentJsonResponse, - createAuthorizationServerMetadata, -} from "@/lib/agent-discovery"; - -export const revalidate = 3600; - -export function GET() { - return agentJsonResponse(createAuthorizationServerMetadata()); -} diff --git a/apps/docs/app/.well-known/oauth-protected-resource/route.ts b/apps/docs/app/.well-known/oauth-protected-resource/route.ts deleted file mode 100644 index f6bb9cadfe..0000000000 --- a/apps/docs/app/.well-known/oauth-protected-resource/route.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { - agentJsonResponse, - createProtectedResourceMetadata, -} from "@/lib/agent-discovery"; -import { SITE_URL } from "@/app/util/constants"; - -export const revalidate = 3600; - -export function GET() { - return agentJsonResponse(createProtectedResourceMetadata(SITE_URL)); -} diff --git a/apps/docs/app/agent.md/route.ts b/apps/docs/app/agent.md/route.ts deleted file mode 100644 index 5f5ce795e3..0000000000 --- a/apps/docs/app/agent.md/route.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { createIndexMarkdown, markdownResponse } from "@/lib/agent-discovery"; - -export const revalidate = 3600; - -export function GET() { - return markdownResponse(createIndexMarkdown()); -} diff --git a/apps/docs/app/agents.md/route.ts b/apps/docs/app/agents.md/route.ts deleted file mode 100644 index 5f5ce795e3..0000000000 --- a/apps/docs/app/agents.md/route.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { createIndexMarkdown, markdownResponse } from "@/lib/agent-discovery"; - -export const revalidate = 3600; - -export function GET() { - return markdownResponse(createIndexMarkdown()); -} diff --git a/apps/docs/app/api.md/route.ts b/apps/docs/app/api.md/route.ts deleted file mode 100644 index 51e65a2b7f..0000000000 --- a/apps/docs/app/api.md/route.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { - createScopedLlmsText, - markdownResponse, -} from "@/lib/agent-discovery"; - -export const revalidate = 3600; - -export function GET() { - return markdownResponse(createScopedLlmsText("api")); -} diff --git a/apps/docs/app/developer.md/route.ts b/apps/docs/app/developer.md/route.ts deleted file mode 100644 index 47e2b097f7..0000000000 --- a/apps/docs/app/developer.md/route.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { - createScopedLlmsText, - markdownResponse, -} from "@/lib/agent-discovery"; - -export const revalidate = 3600; - -export function GET() { - return markdownResponse(createScopedLlmsText("developers")); -} diff --git a/apps/docs/app/developers.md/route.ts b/apps/docs/app/developers.md/route.ts deleted file mode 100644 index 47e2b097f7..0000000000 --- a/apps/docs/app/developers.md/route.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { - createScopedLlmsText, - markdownResponse, -} from "@/lib/agent-discovery"; - -export const revalidate = 3600; - -export function GET() { - return markdownResponse(createScopedLlmsText("developers")); -} diff --git a/apps/docs/app/llms.md/route.ts b/apps/docs/app/llms.md/route.ts deleted file mode 100644 index 5f5ce795e3..0000000000 --- a/apps/docs/app/llms.md/route.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { createIndexMarkdown, markdownResponse } from "@/lib/agent-discovery"; - -export const revalidate = 3600; - -export function GET() { - return markdownResponse(createIndexMarkdown()); -} diff --git a/apps/docs/app/mcp.json/route.ts b/apps/docs/app/mcp.json/route.ts deleted file mode 100644 index 4ffbd6ab5e..0000000000 --- a/apps/docs/app/mcp.json/route.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { agentJsonResponse, createMcpManifest } from "@/lib/agent-discovery"; - -export const revalidate = 3600; - -export function GET() { - return agentJsonResponse(createMcpManifest()); -} diff --git a/apps/docs/content/docs/api/authentication.mdx b/apps/docs/content/docs/api/authentication.mdx index c6950d5069..f2d4dc3b08 100644 --- a/apps/docs/content/docs/api/authentication.mdx +++ b/apps/docs/content/docs/api/authentication.mdx @@ -45,11 +45,9 @@ AI agents can discover Databuddy authentication without scraping this page: | Resource | URL | |----------|-----| | auth.md walkthrough | `https://www.databuddy.cc/auth.md` | -| OAuth Protected Resource Metadata | `https://api.databuddy.cc/.well-known/oauth-protected-resource` | -| Authorization Server Metadata | `https://api.databuddy.cc/.well-known/oauth-authorization-server` | | API catalog | `https://api.databuddy.cc/.well-known/api-catalog` | -Unauthenticated protected API probes return a `WWW-Authenticate` header with `Bearer resource_metadata="https://api.databuddy.cc/.well-known/oauth-protected-resource"`. Agents should fetch that metadata, choose the smallest required scope, and then retry with `x-api-key` or `Authorization: Bearer`. +Databuddy currently supports scoped API keys for agent access. OAuth is not available yet; agents should create a key in the dashboard and send it with `x-api-key` or `Authorization: Bearer`. ## API Key Scopes diff --git a/apps/docs/content/docs/api/errors.mdx b/apps/docs/content/docs/api/errors.mdx index 7a423a5983..35dd8512df 100644 --- a/apps/docs/content/docs/api/errors.mdx +++ b/apps/docs/content/docs/api/errors.mdx @@ -57,13 +57,13 @@ Other API families can define additional codes. Treat `code` as the programmatic ## Authentication response -Protected endpoints return a discovery hint in addition to the JSON body: +Protected endpoints return a JSON error when the API key is missing or invalid: ## Rate-limit response diff --git a/apps/docs/content/docs/api/mcp.mdx b/apps/docs/content/docs/api/mcp.mdx index b83b1a3aa0..a4825fd516 100644 --- a/apps/docs/content/docs/api/mcp.mdx +++ b/apps/docs/content/docs/api/mcp.mdx @@ -14,9 +14,7 @@ The Databuddy MCP server lets AI agents (Claude, Cursor, Windsurf, or any MCP-co | Production | `https://api.databuddy.cc/v1/mcp/` | | Local | `http://localhost:3001/v1/mcp/` | -The server uses the **Streamable HTTP** transport (JSON-RPC over HTTP). No SSE or WebSocket connection required. - -Alternate transport surfaces are also available at `https://api.databuddy.cc/mcp` and `https://api.databuddy.cc/.well-known/mcp` for WebMCP-capable agents. +The server uses the **Streamable HTTP** transport (JSON-RPC over HTTP). No SSE or WebSocket connection required. Configure clients with the canonical URL above. `/.well-known/mcp` is discovery metadata, not an MCP transport endpoint. ## Discovery Manifest @@ -51,7 +49,7 @@ Pass an API key with the `read:data` scope: /> - The quickest setup is [Dashboard → Organization Settings → Integrations](https://app.databuddy.cc/organizations/settings/integrations): choose **Databuddy MCP**, select the client, capabilities, and website access you want, then copy the generated config. The secret is shown only once. You can also create and manage keys from [API Keys](https://app.databuddy.cc/organizations/settings#api-keys). The key needs at least the `read:data` scope. Add `manage:websites` for workspace actions such as goals, funnels, annotations, and investigation replies; add `manage:flags` for feature-flag mutations; and add `read:links` plus `write:links` for the full short-link workflow. + The quickest setup is [Dashboard → Organization Settings → Integrations](https://app.databuddy.cc/organizations/settings/integrations): choose **Databuddy MCP**, select the client, capabilities, and website access you want, then copy the generated config. The secret is shown only once. You can also create and manage keys from [API Keys](https://app.databuddy.cc/organizations/settings#api-keys). The generated key starts with `read:data`. Add `manage:websites` for workspace actions such as goals, funnels, annotations, and investigation replies; add `manage:flags` for feature-flag mutations; and add organization-wide `read:links` plus `write:links` for short-link reads and mutations. ### Dashboard setup @@ -222,11 +220,10 @@ Tools are filtered based on your API key's scopes: | Scope | Tools | |-------|-------| -| `read:data` | Analytics, investigations, schema, and read-only tools | +| `read:data` | Analytics, investigations, schema/capability discovery, and website-scoped tools | | `manage:websites` | Investigation replies; create, update, and delete goals and annotations; create funnels | | `manage:flags` | Feature flag mutations | -| `read:links` | Read short links, folders, and link search results | -| `read:data` + `write:links` | Update or delete short links for an accessible website | -| `read:data` + `read:links` + `write:links` | Create short links | +| `read:links` | Organization-wide short-link, folder, and search reads; required by every link mutation for its preview | +| `write:links` | With `read:links`, create, update, and delete short links organization-wide | Session-authenticated users (via the dashboard) get access based on their organization role instead. diff --git a/apps/docs/lib/agent-discovery.test.ts b/apps/docs/lib/agent-discovery.test.ts deleted file mode 100644 index 0664a21a9b..0000000000 --- a/apps/docs/lib/agent-discovery.test.ts +++ /dev/null @@ -1,64 +0,0 @@ -import { describe, expect, it } from "bun:test"; -import { - createAgentJson, - createApiCatalog, - createAuthorizationServerMetadata, - createMcpManifest, - createMcpServerCard, - createProtectedResourceMetadata, - developerResources, -} from "./agent-discovery"; - -describe("agent discovery resources", () => { - it("lists Databuddy OpenAPI and MCP resources by name", () => { - const resourceText = developerResources - .map((resource) => `${resource.title} ${resource.url}`) - .join("\n"); - - expect(resourceText).toContain("Databuddy OpenAPI Spec"); - expect(resourceText).toContain("https://www.databuddy.cc/openapi.json"); - expect(resourceText).toContain("Databuddy MCP Server"); - expect(resourceText).toContain("https://www.databuddy.cc/.well-known/mcp.json"); - expect(resourceText).toContain("Databuddy API Catalog"); - expect(resourceText).toContain( - "https://www.databuddy.cc/.well-known/api-catalog" - ); - }); - - it("points MCP discovery at the Streamable HTTP server", () => { - const manifest = createMcpManifest(); - - expect(manifest.name).toBe("Databuddy"); - expect(manifest.server.url).toBe("https://api.databuddy.cc/v1/mcp/"); - expect(manifest.server.transport).toBe("streamable-http"); - expect(manifest.authentication.name).toBe("x-api-key"); - expect( - Object.hasOwn( - manifest.authentication, - "protected_resource_metadata_url" - ) - ).toBe(false); - expect(manifest.openapi_url).toBe("https://www.databuddy.cc/openapi.json"); - }); - - it("publishes agent, MCP card, API catalog, and auth metadata", () => { - const agent = createAgentJson(); - const serverCard = createMcpServerCard(); - const catalog = createApiCatalog(); - const prm = createProtectedResourceMetadata(); - const asMetadata = createAuthorizationServerMetadata(); - - expect(agent.endpoints.auth_md).toBe("https://www.databuddy.cc/auth.md"); - expect(serverCard.serverUrl).toBe("https://api.databuddy.cc/v1/mcp/"); - expect( - Object.hasOwn(serverCard.authentication, "protectedResourceMetadataUrl") - ).toBe(false); - expect(catalog.linkset[0]["service-desc"][0].href).toBe( - "https://www.databuddy.cc/openapi.json" - ); - expect(prm.authorization_servers).toContain("https://api.databuddy.cc"); - expect(asMetadata.agent_auth.identity_types_supported).toContain( - "identity_assertion" - ); - }); -}); diff --git a/apps/docs/lib/agent-discovery.ts b/apps/docs/lib/agent-discovery.ts index 0d9d2fdd4c..0e32c3bbba 100644 --- a/apps/docs/lib/agent-discovery.ts +++ b/apps/docs/lib/agent-discovery.ts @@ -4,13 +4,11 @@ import { createAgentJson as createSharedAgentJson, createApiCatalog as createSharedApiCatalog, createAuthMarkdown as createSharedAuthMarkdown, - createAuthorizationServerMetadata as createSharedAuthorizationServerMetadata, createDeveloperResources, createIndexMarkdown as createSharedIndexMarkdown, createMcpManifest as createSharedMcpManifest, createMcpServerCard as createSharedMcpServerCard, createNlwebAnswer as createSharedNlwebAnswer, - createProtectedResourceMetadata as createSharedProtectedResourceMetadata, createSchemaMapXml as createSharedSchemaMapXml, createScopedLlmsText as createSharedScopedLlmsText, createSoftwareJsonl as createSharedSoftwareJsonl, @@ -103,14 +101,6 @@ export function createApiCatalog() { return createSharedApiCatalog(discoveryUrls); } -export function createProtectedResourceMetadata(resource = API_URL) { - return createSharedProtectedResourceMetadata(discoveryUrls, resource); -} - -export function createAuthorizationServerMetadata() { - return createSharedAuthorizationServerMetadata(discoveryUrls); -} - export function createUcpProfile() { return createSharedUcpProfile(discoveryUrls); } diff --git a/apps/docs/lib/sitemap-generator.ts b/apps/docs/lib/sitemap-generator.ts index 6bd6d5f99b..6411ccd949 100644 --- a/apps/docs/lib/sitemap-generator.ts +++ b/apps/docs/lib/sitemap-generator.ts @@ -201,23 +201,15 @@ export async function generateSitemapEntries(): Promise { "/llms-full.txt", "/skill.md", "/openapi.json", - "/mcp.json", "/.well-known/mcp.json", - "/.well-known/mcp/manifest.json", "/.well-known/mcp/server-card.json", "/.well-known/agent.json", "/.well-known/agent-card.json", "/.well-known/api-catalog", - "/.well-known/oauth-protected-resource", - "/.well-known/oauth-authorization-server", "/.well-known/http-message-signatures-directory", "/.well-known/ucp", "/auth.md", - "/api.md", "/index.md", - "/llms.md", - "/agents.md", - "/developers.md", "/docs/llms.txt", "/api/llms.txt", "/ask", diff --git a/apps/docs/next.config.ts b/apps/docs/next.config.ts index 592711a104..128f2d8c5f 100644 --- a/apps/docs/next.config.ts +++ b/apps/docs/next.config.ts @@ -66,6 +66,17 @@ const config: NextConfig = { rewrites: async () => ({ beforeFiles: [ + { source: "/mcp.json", destination: "/.well-known/mcp.json" }, + { + source: "/.well-known/mcp/manifest.json", + destination: "/.well-known/mcp.json", + }, + { source: "/agent.md", destination: "/index.md" }, + { source: "/agents.md", destination: "/index.md" }, + { source: "/llms.md", destination: "/index.md" }, + { source: "/api.md", destination: "/api/llms.txt" }, + { source: "/developer.md", destination: "/developers/llms.txt" }, + { source: "/developers.md", destination: "/developers/llms.txt" }, { source: "/docs/:path*.md", destination: "/api/docs/raw/:path*", diff --git a/packages/ai/src/ai/mcp/define-tool.ts b/packages/ai/src/ai/mcp/define-tool.ts index ddd601a518..eaa5316e17 100644 --- a/packages/ai/src/ai/mcp/define-tool.ts +++ b/packages/ai/src/ai/mcp/define-tool.ts @@ -152,14 +152,17 @@ export interface McpToolFactory { } function toErrorResult(err: McpToolError): CallToolResult { + const isInternal = err.code === "internal"; const errorPayload: Record = { code: err.code, - message: stripAnsi(err.message), + message: isInternal + ? "An internal error occurred. Please try again." + : stripAnsi(err.message), }; - if (err.hint) { + if (!isInternal && err.hint) { errorPayload.hint = stripAnsi(err.hint); } - if (err.details) { + if (!isInternal && err.details) { errorPayload.details = err.details; } return { diff --git a/packages/ai/src/ai/mcp/tool-contracts.ts b/packages/ai/src/ai/mcp/tool-contracts.ts index d2968c624b..23544a16fc 100644 --- a/packages/ai/src/ai/mcp/tool-contracts.ts +++ b/packages/ai/src/ai/mcp/tool-contracts.ts @@ -1,19 +1,36 @@ import { analyticsDateRangeSchema } from "@databuddy/validation"; import { z } from "zod"; +import { + type DatePreset, + MCP_DATE_PRESETS, + resolveDatePreset, +} from "../../lib/date-presets"; import { McpToolError, type McpHandlerContext } from "./define-tool"; const DateOnlySchema = z.iso.date(); export const McpDateRangeSchema = z .object({ + preset: z + .enum(MCP_DATE_PRESETS as [DatePreset, ...DatePreset[]]) + .optional() + .describe("Date preset such as last_7d. Alternative to from/to."), from: DateOnlySchema.optional().describe( - "Start date YYYY-MM-DD (defaults to 30 days ago)" + "Start date YYYY-MM-DD. Use with to; alternative to preset." ), to: DateOnlySchema.optional().describe( - "End date YYYY-MM-DD (defaults to today)" + "End date YYYY-MM-DD. Use with from; alternative to preset." ), }) .superRefine((input, context) => { + if (input.preset && (input.from || input.to)) { + context.addIssue({ + code: "custom", + message: "Use either preset or from/to, not both.", + path: ["preset"], + }); + return; + } const result = analyticsDateRangeSchema.safeParse({ startDate: input.from, endDate: input.to, @@ -29,6 +46,18 @@ export const McpDateRangeSchema = z } }); +export function resolveMcpDateRange(input: { + from?: string; + preset?: DatePreset; + to?: string; +}): { from?: string; to?: string } { + if (input.preset) { + const { from, to } = resolveDatePreset(input.preset, "UTC"); + return { from, to }; + } + return { from: input.from, to: input.to }; +} + export const WebsiteSelectorSchema = { websiteId: z.string().optional().describe("Website ID from list_websites"), websiteName: z diff --git a/packages/ai/src/ai/mcp/tools.test.ts b/packages/ai/src/ai/mcp/tools.test.ts index 4f9ef068cd..24ee401490 100644 --- a/packages/ai/src/ai/mcp/tools.test.ts +++ b/packages/ai/src/ai/mcp/tools.test.ts @@ -11,6 +11,7 @@ import { handleDatabuddyMcpRequest, } from "../../mcp/http"; import { defineMcpTool, type McpRequestContext } from "./define-tool"; +import { resolveMcpDateRange } from "./tool-contracts"; import { createMcpTools } from "./tools"; const ctx: McpRequestContext = { @@ -155,6 +156,24 @@ describe("MCP tool invariants", () => { } }); + test("does not expose internal exception text", async () => { + const sentinel = "MCP_INTERNAL_SENTINEL_DO_NOT_EXPOSE"; + const tool = defineMcpTool( + { + name: "internal_error_test", + description: "Test that internal exception text is not returned to callers.", + inputSchema: z.object({}), + }, + () => { + throw new Error(sentinel); + } + ).build(ctx); + + const result = await tool.handler({}); + expect(result).toMatchObject({ isError: true }); + expect(JSON.stringify(result)).not.toContain(sentinel); + }); + test("create_link matches the HTTP(S) and deep-link app contract", () => { const createLink = tools.find((tool) => tool.name === "create_link"); if (!createLink) { @@ -236,6 +255,39 @@ describe("MCP tool invariants", () => { xValue: "2026-02-30T12:00:00Z", }).success ).toBe(false); + expect( + createAnnotation.inputSchema.safeParse({ + annotationType: "range", + confirmed: false, + text: "Release", + websiteId: "website-1", + xEndValue: "2026-03-02", + xValue: "2026-03-01", + }).success + ).toBe(true); + for (const xEndValue of [undefined, "2026-02-28"]) { + expect( + createAnnotation.inputSchema.safeParse({ + annotationType: "range", + confirmed: false, + text: "Release", + websiteId: "website-1", + xEndValue, + xValue: "2026-03-01", + }).success + ).toBe(false); + } + }); + + test("resolves MCP date presets instead of ignoring them", () => { + const { from, to } = resolveMcpDateRange({ preset: "last_30d" }); + expect(from).toMatch(/^\d{4}-\d{2}-\d{2}$/); + expect(to).toMatch(/^\d{4}-\d{2}-\d{2}$/); + expect( + (Date.parse(`${to}T00:00:00Z`) - Date.parse(`${from}T00:00:00Z`)) / + 86_400_000 + + 1 + ).toBe(30); }); test("keeps mixed batch date errors inside the batch result", () => { @@ -318,9 +370,16 @@ describe("MCP tool invariants", () => { describe("investigation tools", () => { test("only advertises tools whose API-key scopes can satisfy their calls", async () => { + const zeroScope = await listToolsForScopes([]); + const zeroScopeNames = new Set(zeroScope.tools.map((tool) => tool.name)); + expect(zeroScopeNames.has("capabilities")).toBe(false); + expect(zeroScopeNames.has("get_schema")).toBe(false); + const readData = await listToolsForScopes(["read:data"]); const readDataNames = new Set(readData.tools.map((tool) => tool.name)); expect(readData.response.status).toBe(200); + expect(readDataNames.has("capabilities")).toBe(true); + expect(readDataNames.has("get_schema")).toBe(true); expect(readDataNames.has("get_data")).toBe(true); expect(readDataNames.has("get_funnel_analytics_by_referrer")).toBe(true); expect(readDataNames.has("list_links")).toBe(false); diff --git a/packages/ai/src/ai/mcp/tools.ts b/packages/ai/src/ai/mcp/tools.ts index 9ca67381ee..9ef2ef5cb5 100644 --- a/packages/ai/src/ai/mcp/tools.ts +++ b/packages/ai/src/ai/mcp/tools.ts @@ -1,5 +1,6 @@ import dayjs from "dayjs"; import { z } from "zod"; +import { funnelStepSchema } from "@databuddy/rpc/funnel-steps"; import { historyInsightSchema, insightBriefItemSchema, @@ -10,8 +11,17 @@ import { DEEP_LINK_APP_IDS, isDeepLinkTarget, } from "@databuddy/shared/constants/deep-link-apps"; -import { httpUrlSchema } from "@databuddy/validation"; -import { userRuleSchema, variantSchema } from "@databuddy/shared/flags"; +import { + annotationChartContextSchema, + annotationCoordinateSchema, + httpUrlSchema, + isoDateOrOffsetDateTimeSchema, +} from "@databuddy/validation"; +import { + flagFormShape, + userRuleSchema, + variantSchema, +} from "@databuddy/shared/flags"; import { executeBatch } from "../../query"; import { runInvestigationAction } from "../tools/investigations"; import { callRPCProcedure } from "../tools/utils"; @@ -64,15 +74,12 @@ import { getResolvedWebsiteId, McpDateRangeSchema, MutationResultSchema, + resolveMcpDateRange, WebsiteSelectorSchema, WorkflowFilterSchema, } from "./tool-contracts"; const TIME_UNIT = ["minute", "hour", "day", "week", "month"] as const; -const DateTimeSchema = z.union([ - z.iso.date(), - z.iso.datetime({ offset: true }), -]); const QueryItemSchema = z.object({ type: z.string(), @@ -94,44 +101,18 @@ const WebsiteSummarySchema = z.object({ isPublic: z.boolean().nullable(), }); -const FunnelStepSchema = z.object({ - type: z.enum(["PAGE_VIEW", "EVENT", "CUSTOM"]), - target: z.string().min(1), - name: z.string().min(1), - conditions: z.record(z.string(), z.unknown()).optional(), -}); - -const ChartContextSchema = z.object({ - dateRange: z.object({ - start_date: z.string(), - end_date: z.string(), - granularity: z.enum(["hourly", "daily", "weekly", "monthly"]), - }), - filters: z - .array( - z.object({ - field: z.string(), - operator: z.enum(["eq", "ne", "gt", "lt", "contains"]), - value: z.string(), - }) - ) - .optional(), - metrics: z.array(z.string()).optional(), - tabId: z.string().optional(), -}); - const FlagRuleSchema = userRuleSchema; const FlagVariantSchema = variantSchema; -const FlagStatusSchema = z.enum(["active", "inactive", "archived"]); -const FlagTypeSchema = z.enum(["boolean", "rollout", "multivariant"]); +const FlagStatusSchema = flagFormShape.status; +const FlagTypeSchema = flagFormShape.type; function createChartContext(input: { from?: string; granularity?: "hourly" | "daily" | "weekly" | "monthly"; metrics?: string[]; to?: string; -}): z.infer { +}): z.infer { return { dateRange: { start_date: @@ -143,26 +124,6 @@ function createChartContext(input: { }; } -function asRecord(value: unknown): Record { - return value && typeof value === "object" && !Array.isArray(value) - ? (value as Record) - : {}; -} - -function createFlagUserRule( - matchBy: "email" | "user_id", - values: string[] -): z.infer { - return { - batch: true, - batchValues: values, - enabled: true, - operator: "in", - type: matchBy, - values, - }; -} - const listWebsitesTool = defineMcpTool( { name: "list_websites", @@ -482,6 +443,12 @@ const getDataTool = defineMcpTool( } const plan = buildBatchQueryRequests(items, websiteId, timezone); + if (items.length === 1 && plan.requests.length === 0) { + throw new McpToolError( + "invalid_input", + plan.invalid[0]?.error ?? "The query could not be executed." + ); + } // ctx.websiteDomain is guaranteed set by defineMcpTool when resolveWebsite is true const websiteDomain = ctx.websiteDomain ?? "unknown"; @@ -539,6 +506,7 @@ const getSchemaTool = defineMcpTool( sections: z.array(z.string()), bytes: z.number(), }), + metadata: metadataForResource("organization", ["read"]), ratelimit: { limit: 60, windowSec: 60 }, }, (input) => { @@ -618,6 +586,7 @@ const capabilitiesTool = defineMcpTool( queryTypes: z.record(z.string(), z.unknown()).optional(), hints: z.array(z.string()).optional(), }), + metadata: metadataForResource("organization", ["read"]), ratelimit: { limit: 60, windowSec: 60 }, }, (input) => { @@ -711,18 +680,20 @@ const getFunnelAnalyticsTool = defineMcpTool( resolveWebsite: true, ratelimit: { limit: 60, windowSec: 60 }, }, - async (input, ctx) => - await callRPCProcedure( + (input, ctx) => { + const { from, to } = resolveMcpDateRange(input); + return callRPCProcedure( "funnels", "getAnalytics", { funnelId: input.funnelId, websiteId: ctx.websiteId, - startDate: input.from, - endDate: input.to, + startDate: from, + endDate: to, }, buildRpcContext(ctx) - ) + ); + } ); const createFunnelTool = defineMcpTool( @@ -734,7 +705,7 @@ const createFunnelTool = defineMcpTool( ...WebsiteSelectorSchema, name: z.string().min(1).max(100), description: z.string().optional(), - steps: z.array(FunnelStepSchema).min(2).max(10), + steps: z.array(funnelStepSchema).min(2).max(10), filters: z.array(WorkflowFilterSchema).optional(), ignoreHistoricData: z.boolean().optional(), confirmed: ConfirmedSchema, @@ -832,18 +803,20 @@ const getGoalAnalyticsTool = defineMcpTool( resolveWebsite: true, ratelimit: { limit: 60, windowSec: 60 }, }, - async (input, ctx) => - await callRPCProcedure( + (input, ctx) => { + const { from, to } = resolveMcpDateRange(input); + return callRPCProcedure( "goals", "getAnalytics", { goalId: input.goalId, websiteId: ctx.websiteId, - startDate: input.from, - endDate: input.to, + startDate: from, + endDate: to, }, buildRpcContext(ctx) - ) + ); + } ); const createGoalTool = defineMcpTool( @@ -1077,7 +1050,7 @@ const createLinkTool = defineMcpTool( .max(50) .regex(/^[a-zA-Z0-9_-]+$/) .optional(), - expiresAt: DateTimeSchema.optional(), + expiresAt: isoDateOrOffsetDateTimeSchema.optional(), expiredRedirectUrl: httpUrlSchema.optional(), ogTitle: z.string().max(200).optional(), ogDescription: z.string().max(500).optional(), @@ -1175,7 +1148,7 @@ const listAnnotationsTool = defineMcpTool( ...WebsiteSelectorSchema, granularity: z.enum(["hourly", "daily", "weekly", "monthly"]).optional(), metrics: z.array(z.string()).optional(), - chartContext: ChartContextSchema.optional(), + chartContext: annotationChartContextSchema.optional(), }), outputSchema: z.object({ annotations: z.array(z.record(z.string(), z.unknown())), @@ -1205,42 +1178,22 @@ const createAnnotationTool = defineMcpTool( name: "create_annotation", description: "Create a chart annotation. Call with confirmed=false for preview before writing.", - inputSchema: z - .object({ - ...WebsiteSelectorSchema, - chartContext: ChartContextSchema.optional(), - annotationType: z.enum(["point", "line", "range"]), - xValue: DateTimeSchema, - xEndValue: DateTimeSchema.optional(), - yValue: z.number().optional(), - text: z.string().min(1).max(500), - tags: z.array(z.string()).optional(), - color: z.string().optional(), - isPublic: z.boolean().optional(), - confirmed: ConfirmedSchema, - }) - .refine( - (input) => - !input.xEndValue || - new Date(input.xEndValue) >= new Date(input.xValue), - { - message: "xEndValue must be on or after xValue.", - path: ["xEndValue"], - } - ), + inputSchema: annotationCoordinateSchema.safeExtend({ + ...WebsiteSelectorSchema, + chartContext: annotationChartContextSchema.optional(), + yValue: z.number().optional(), + text: z.string().min(1).max(500), + tags: z.array(z.string()).optional(), + color: z.string().optional(), + isPublic: z.boolean().optional(), + confirmed: ConfirmedSchema, + }), outputSchema: MutationResultSchema, resolveWebsite: true, metadata: metadataForResource("website", ["update"]), ratelimit: { limit: 20, windowSec: 60 }, }, async (input, ctx) => { - if (input.annotationType === "range" && !input.xEndValue) { - throw new McpToolError( - "invalid_input", - "Range annotations require xEndValue." - ); - } - const chartContext = input.chartContext ?? createChartContext({ @@ -1327,11 +1280,7 @@ const createFlagTool = defineMcpTool( "Create a feature flag. Defaults to inactive boolean flag until explicitly configured.", inputSchema: z.object({ ...WebsiteSelectorSchema, - key: z - .string() - .min(1) - .max(100) - .regex(/^[a-zA-Z0-9_-]+$/), + key: flagFormShape.key, name: z.string().min(1).max(100).optional(), description: z.string().optional(), type: FlagTypeSchema.optional(), @@ -1483,17 +1432,32 @@ const addUsersToFlagTool = defineMcpTool( const uniqueUsers = [ ...new Set(input.users.map((user) => user.trim())), ].filter(Boolean); - const currentFlag = asRecord( - await callRPCProcedure( - "flags", - "getById", - { id: input.flagId, websiteId: ctx.websiteId }, - buildRpcContext(ctx) - ) - ); - const currentRules = - z.array(FlagRuleSchema).safeParse(currentFlag.rules).data ?? []; - const nextRule = createFlagUserRule(input.matchBy, uniqueUsers); + const currentFlag = z + .object({ + id: z.string(), + key: z.string(), + name: z.string().nullable().optional(), + rules: z.array(FlagRuleSchema).optional(), + status: FlagStatusSchema.optional(), + }) + .passthrough() + .parse( + await callRPCProcedure( + "flags", + "getById", + { id: input.flagId, websiteId: ctx.websiteId }, + buildRpcContext(ctx) + ) + ); + const currentRules = currentFlag.rules ?? []; + const nextRule = { + batch: true, + batchValues: uniqueUsers, + enabled: true, + operator: "in", + type: input.matchBy, + values: uniqueUsers, + } satisfies z.infer; const nextRules = input.mode === "replace" ? [nextRule] : [...currentRules, nextRule]; diff --git a/packages/ai/src/ai/mcp/workspace-tools.ts b/packages/ai/src/ai/mcp/workspace-tools.ts index ece4407d81..a67bad6db0 100644 --- a/packages/ai/src/ai/mcp/workspace-tools.ts +++ b/packages/ai/src/ai/mcp/workspace-tools.ts @@ -28,6 +28,7 @@ import { getResolvedWebsiteId, McpDateRangeSchema, MutationResultSchema, + resolveMcpDateRange, WebsiteSelectorSchema, WorkflowFilterSchema, } from "./tool-contracts"; @@ -53,18 +54,20 @@ const getFunnelAnalyticsByReferrerTool = defineMcpTool( resolveWebsite: true, ratelimit: { limit: 60, windowSec: 60 }, }, - async (input, ctx) => - await callRPCProcedure( + (input, ctx) => { + const { from, to } = resolveMcpDateRange(input); + return callRPCProcedure( "funnels", "getAnalyticsByReferrer", { funnelId: input.funnelId, websiteId: getResolvedWebsiteId(ctx), - startDate: input.from, - endDate: input.to, + startDate: from, + endDate: to, }, buildRpcContext(ctx) - ) + ); + } ); const updateGoalTool = defineMcpTool( diff --git a/packages/ai/src/ai/tools/annotations.ts b/packages/ai/src/ai/tools/annotations.ts index ac3f46d278..9525f8af45 100644 --- a/packages/ai/src/ai/tools/annotations.ts +++ b/packages/ai/src/ai/tools/annotations.ts @@ -1,5 +1,8 @@ import { tool } from "ai"; -import dayjs from "dayjs"; +import { + annotationChartContextSchema, + annotationCoordinateSchema, +} from "@databuddy/validation"; import { z } from "zod"; import { callRPCProcedure, @@ -25,57 +28,23 @@ interface AnnotationRecord { } const chartTypeSchema = z.enum(["metrics"]); -const annotationTypeSchema = z.enum(["point", "line", "range"]); - -const chartContextSchema = z.object({ - dateRange: z.object({ - start_date: z.string(), - end_date: z.string(), - granularity: z.enum(["hourly", "daily", "weekly", "monthly"]), - }), - filters: z - .array( - z.object({ - field: z.string(), - operator: z.enum(["eq", "ne", "gt", "lt", "contains"]), - value: z.string(), - }) - ) - .optional(), - metrics: z.array(z.string()).optional(), - tabId: z.string().optional(), -}); -const isoDateSchema = z.string().refine((value) => dayjs(value).isValid(), { - message: - "Must be a valid ISO 8601 date string (e.g., '2024-01-15T10:30:00Z').", +const createAnnotationInputSchema = annotationCoordinateSchema.safeExtend({ + websiteId: z.string(), + chartType: chartTypeSchema, + chartContext: annotationChartContextSchema, + yValue: z.number().optional(), + text: z.string().min(1).max(500), + tags: z.array(z.string()).optional(), + color: z.string().optional(), + isPublic: z.boolean().optional(), + confirmed: z.boolean().describe("false=preview, true=apply"), }); -const createAnnotationInputSchema = z - .object({ - websiteId: z.string(), - chartType: chartTypeSchema, - chartContext: chartContextSchema, - annotationType: annotationTypeSchema, - xValue: isoDateSchema, - xEndValue: isoDateSchema.optional(), - yValue: z.number().optional(), - text: z.string().min(1).max(500), - tags: z.array(z.string()).optional(), - color: z.string().optional(), - isPublic: z.boolean().optional(), - confirmed: z.boolean().describe("false=preview, true=apply"), - }) - .refine((input) => input.annotationType !== "range" || input.xEndValue, { - message: - "Range annotations require an xEndValue to define the end of the time period.", - path: ["xEndValue"], - }); - const listAnnotationsInputSchema = z.object({ websiteId: z.string(), chartType: chartTypeSchema, - chartContext: chartContextSchema, + chartContext: annotationChartContextSchema, }); const updateAnnotationInputSchema = createAnnotationInputSchema .pick({ diff --git a/packages/ai/src/ai/tools/funnels.ts b/packages/ai/src/ai/tools/funnels.ts index 45d718b69a..2fb2d2bc79 100644 --- a/packages/ai/src/ai/tools/funnels.ts +++ b/packages/ai/src/ai/tools/funnels.ts @@ -1,5 +1,5 @@ import { tool } from "ai"; -import dayjs from "dayjs"; +import { analyticsDateRangeSchema } from "@databuddy/validation"; import { z } from "zod"; import { callRPCProcedure, @@ -10,6 +10,11 @@ import { const logger = createToolLogger("Funnels Tools"); +const funnelAnalyticsInputSchema = analyticsDateRangeSchema.safeExtend({ + funnelId: z.string(), + websiteId: z.string().optional(), +}); + export function createFunnelTools() { const listFunnelsTool = tool({ description: @@ -41,12 +46,7 @@ export function createFunnelTools() { const getFunnelAnalyticsTool = tool({ description: "Funnel step conversion and drop-offs for a chosen date range. Do not repeat an exact overall measurement already supplied by the caller.", - inputSchema: z.object({ - funnelId: z.string(), - websiteId: z.string().optional(), - startDate: z.string().optional(), - endDate: z.string().optional(), - }), + inputSchema: funnelAnalyticsInputSchema, execute: async ( { funnelId, websiteId: inputWebsiteId, startDate, endDate }, options @@ -54,17 +54,6 @@ export function createFunnelTools() { const context = getAppContext(options); const { websiteId } = resolveToolWebsite(context, inputWebsiteId); try { - if (startDate && !dayjs(startDate).isValid()) { - throw new Error( - "Start date must be in YYYY-MM-DD format (e.g., 2024-01-15)." - ); - } - if (endDate && !dayjs(endDate).isValid()) { - throw new Error( - "End date must be in YYYY-MM-DD format (e.g., 2024-01-15)." - ); - } - return await callRPCProcedure( "funnels", "getAnalytics", @@ -89,12 +78,7 @@ export function createFunnelTools() { const getFunnelAnalyticsByReferrerTool = tool({ description: "Funnel analytics broken down by referrer/source. Shows which sources convert best.", - inputSchema: z.object({ - funnelId: z.string(), - websiteId: z.string().optional(), - startDate: z.string().optional(), - endDate: z.string().optional(), - }), + inputSchema: funnelAnalyticsInputSchema, execute: async ( { funnelId, websiteId: inputWebsiteId, startDate, endDate }, options @@ -102,17 +86,6 @@ export function createFunnelTools() { const context = getAppContext(options); const { websiteId } = resolveToolWebsite(context, inputWebsiteId); try { - if (startDate && !dayjs(startDate).isValid()) { - throw new Error( - "Start date must be in YYYY-MM-DD format (e.g., 2024-01-15)." - ); - } - if (endDate && !dayjs(endDate).isValid()) { - throw new Error( - "End date must be in YYYY-MM-DD format (e.g., 2024-01-15)." - ); - } - return await callRPCProcedure( "funnels", "getAnalyticsByReferrer", diff --git a/packages/ai/src/ai/tools/goals.ts b/packages/ai/src/ai/tools/goals.ts index 2df6f80350..fd156a6718 100644 --- a/packages/ai/src/ai/tools/goals.ts +++ b/packages/ai/src/ai/tools/goals.ts @@ -1,5 +1,5 @@ import { tool } from "ai"; -import dayjs from "dayjs"; +import { analyticsDateRangeSchema } from "@databuddy/validation"; import { z } from "zod"; import { callRPCProcedure, @@ -16,6 +16,10 @@ const goalFilterSchema = z.object({ operator: z.enum(["equals", "contains", "not_equals", "in", "not_in"]), value: z.union([z.string(), z.array(z.string())]), }); +const goalAnalyticsInputSchema = analyticsDateRangeSchema.safeExtend({ + goalId: z.string(), + websiteId: z.string().optional(), +}); const createGoalInputSchema = z.object({ websiteId: z.string(), name: z.string().min(1).max(100), @@ -62,12 +66,7 @@ export function createGoalTools() { const getGoalAnalyticsTool = tool({ description: "Goal analytics for a chosen date range: conversion rate, users entered, and users completed. Do not repeat an exact measurement already supplied by the caller.", - inputSchema: z.object({ - goalId: z.string(), - websiteId: z.string().optional(), - startDate: z.string().optional(), - endDate: z.string().optional(), - }), + inputSchema: goalAnalyticsInputSchema, execute: async ( { goalId, websiteId: inputWebsiteId, startDate, endDate }, options @@ -75,17 +74,6 @@ export function createGoalTools() { const context = getAppContext(options); const { websiteId } = resolveToolWebsite(context, inputWebsiteId); try { - if (startDate && !dayjs(startDate).isValid()) { - throw new Error( - "Start date must be in YYYY-MM-DD format (e.g., 2024-01-15)." - ); - } - if (endDate && !dayjs(endDate).isValid()) { - throw new Error( - "End date must be in YYYY-MM-DD format (e.g., 2024-01-15)." - ); - } - return await callRPCProcedure( "goals", "getAnalytics", diff --git a/packages/ai/src/lib/date-presets.ts b/packages/ai/src/lib/date-presets.ts index c5bdb08df7..a22f50f9f6 100644 --- a/packages/ai/src/lib/date-presets.ts +++ b/packages/ai/src/lib/date-presets.ts @@ -16,73 +16,89 @@ export type DatePreset = keyof typeof DatePresets; export const MCP_DATE_PRESETS = Object.keys(DatePresets) as DatePreset[]; +const ROLLING_DATE_OFFSETS: Partial< + Record +> = { + today: [0, 0], + yesterday: [-1, -1], + last_7d: [-6, 0], + last_14d: [-13, 0], + last_30d: [-29, 0], + last_90d: [-89, 0], +}; + +function getCalendarDate(timezone: string, now: Date): Date { + const parts = Object.fromEntries( + new Intl.DateTimeFormat("en-CA", { + day: "2-digit", + month: "2-digit", + timeZone: timezone, + year: "numeric", + }) + .formatToParts(now) + .map(({ type, value }) => [type, value]) + ); + return new Date( + Date.UTC(Number(parts.year), Number(parts.month) - 1, Number(parts.day)) + ); +} + +function shiftCalendarDate(date: Date, days: number): Date { + const shifted = new Date(date); + shifted.setUTCDate(shifted.getUTCDate() + days); + return shifted; +} + export function resolveDatePreset( preset: DatePreset, - timezone: string + timezone: string, + now = new Date() ): { from: string; to: string; startDate: string; endDate: string } { - const today = new Date( - new Date().toLocaleDateString("en-CA", { timeZone: timezone }) - ); + const today = getCalendarDate(timezone, now); const fmt = (d: Date) => d.toISOString().split("T")[0] as string; - const result = (from: string, to: string) => ({ from, to, startDate: from, endDate: to, }); + const rollingOffsets = ROLLING_DATE_OFFSETS[preset]; + if (rollingOffsets) { + return result( + fmt(shiftCalendarDate(today, rollingOffsets[0])), + fmt(shiftCalendarDate(today, rollingOffsets[1])) + ); + } switch (preset) { - case "today": - return result(fmt(today), fmt(today)); - case "yesterday": { - const d = new Date(today); - d.setDate(d.getDate() - 1); - return result(fmt(d), fmt(d)); - } - case "last_7d": { - const d = new Date(today); - d.setDate(d.getDate() - 6); - return result(fmt(d), fmt(today)); - } - case "last_14d": { - const d = new Date(today); - d.setDate(d.getDate() - 13); - return result(fmt(d), fmt(today)); - } - case "last_30d": { - const d = new Date(today); - d.setDate(d.getDate() - 29); - return result(fmt(d), fmt(today)); - } - case "last_90d": { - const d = new Date(today); - d.setDate(d.getDate() - 89); - return result(fmt(d), fmt(today)); - } case "this_week": { - const d = new Date(today); - d.setDate(d.getDate() - d.getDay()); - return result(fmt(d), fmt(today)); + return result( + fmt(shiftCalendarDate(today, -today.getUTCDay())), + fmt(today) + ); } case "last_week": { - const end = new Date(today); - end.setDate(end.getDate() - end.getDay() - 1); - const start = new Date(end); - start.setDate(start.getDate() - 6); + const end = shiftCalendarDate(today, -today.getUTCDay() - 1); + const start = shiftCalendarDate(end, -6); return result(fmt(start), fmt(end)); } case "this_month": { - const d = new Date(today.getFullYear(), today.getMonth(), 1); + const d = new Date( + Date.UTC(today.getUTCFullYear(), today.getUTCMonth(), 1) + ); return result(fmt(d), fmt(today)); } case "last_month": { - const end = new Date(today.getFullYear(), today.getMonth(), 0); - const start = new Date(today.getFullYear(), today.getMonth() - 1, 1); + const end = new Date( + Date.UTC(today.getUTCFullYear(), today.getUTCMonth(), 0) + ); + const start = new Date( + Date.UTC(today.getUTCFullYear(), today.getUTCMonth() - 1, 1) + ); return result(fmt(start), fmt(end)); } case "this_year": { - const d = new Date(today.getFullYear(), 0, 1); + const d = new Date(Date.UTC(today.getUTCFullYear(), 0, 1)); return result(fmt(d), fmt(today)); } default: diff --git a/packages/ai/src/mcp/guide.ts b/packages/ai/src/mcp/guide.ts index 46df728306..6702d91339 100644 --- a/packages/ai/src/mcp/guide.ts +++ b/packages/ai/src/mcp/guide.ts @@ -43,5 +43,5 @@ Do not recreate an investigation with ad hoc anomaly math when a durable case al ## Mutations -Respect each tool's API-key scopes. Analytics reads require \`read:data\`; website writes and investigation replies require \`manage:websites\`; flag mutations require \`manage:flags\` (and website-scoped ones also require \`read:data\`); link catalog reads require \`read:links\`, while website-scoped link mutations require \`read:data\` plus \`write:links\` (and \`create_link\` also requires \`read:links\`). Preview goal, annotation, and link mutations with \`confirmed=false\`, then apply only after explicit approval with \`confirmed=true\`. +Analytics and schema/discovery tools require \`read:data\`. Website writes and investigation replies require \`manage:websites\`; flag mutations require \`manage:flags\`. Short-link reads and previews are organization-wide and require \`read:links\`; every link mutation also requires \`write:links\`. Preview goal, annotation, and link mutations with \`confirmed=false\`, then apply only after explicit approval with \`confirmed=true\`. `; diff --git a/packages/ai/src/mcp/http.ts b/packages/ai/src/mcp/http.ts index 4a9dcfe326..42463a5086 100644 --- a/packages/ai/src/mcp/http.ts +++ b/packages/ai/src/mcp/http.ts @@ -1,4 +1,5 @@ import { + API_KEY_AUTH_CHALLENGE, getAccessibleWebsiteIds, hasKeyAllScopes, hasWebsiteAllScopes, @@ -36,8 +37,7 @@ export function createMcpUnauthorizedResponse(): Response { { status: 401, headers: { - "WWW-Authenticate": - 'Bearer realm="databuddy", error="invalid_token", error_description="API key required (x-api-key or Authorization: Bearer)"', + "WWW-Authenticate": API_KEY_AUTH_CHALLENGE, }, } ); diff --git a/packages/api-keys/src/resolve.ts b/packages/api-keys/src/resolve.ts index 93a7b13669..63194adab8 100644 --- a/packages/api-keys/src/resolve.ts +++ b/packages/api-keys/src/resolve.ts @@ -20,6 +20,7 @@ export const keys = createKeys({ prefix: "dbdy_", length: 48 }); export const API_KEY_LOOKUP_TIMEOUT_MS = 5000; export const API_KEY_STATEMENT_TIMEOUT_MS = API_KEY_LOOKUP_TIMEOUT_MS; +export const API_KEY_AUTH_CHALLENGE = 'Bearer realm="databuddy"'; export type ApiKeyResolveOutcome = | "ok" diff --git a/packages/rpc/src/routers/annotations.ts b/packages/rpc/src/routers/annotations.ts index c55dd2882b..ad2721b03d 100644 --- a/packages/rpc/src/routers/annotations.ts +++ b/packages/rpc/src/routers/annotations.ts @@ -1,5 +1,9 @@ import { and, desc, eq, isNull, or, type SQL } from "@databuddy/db"; import { annotations } from "@databuddy/db/schema"; +import { + annotationChartContextSchema, + annotationCoordinateSchema, +} from "@databuddy/validation"; import { createDrizzleCache, invalidateAgentContextSnapshotsForWebsite, @@ -43,28 +47,9 @@ async function invalidateAnnotationCaches(websiteId: string): Promise { ]); } -const chartContextSchema = z.object({ - dateRange: z.object({ - start_date: z.string(), - end_date: z.string(), - granularity: z.enum(["hourly", "daily", "weekly", "monthly"]), - }), - filters: z - .array( - z.object({ - field: z.string(), - operator: z.enum(["eq", "ne", "gt", "lt", "contains"]), - value: z.string(), - }) - ) - .optional(), - metrics: z.array(z.string()).optional(), - tabId: z.string().optional(), -}); - const annotationOutputSchema = z.object({ annotationType: z.string(), - chartContext: chartContextSchema, + chartContext: annotationChartContextSchema, chartType: z.string(), color: z.string(), createdAt: z.coerce.date(), @@ -106,7 +91,7 @@ export const annotationsRouter = { z.object({ websiteId: z.string(), chartType: z.enum(["metrics"]), - chartContext: chartContextSchema, + chartContext: annotationChartContextSchema, }) ) .output(z.array(annotationOutputSchema)) @@ -249,13 +234,10 @@ export const annotationsRouter = { tags: ["Annotations"], }) .input( - z.object({ + annotationCoordinateSchema.safeExtend({ websiteId: z.string(), chartType: z.enum(["metrics"]), - chartContext: chartContextSchema, - annotationType: z.enum(["point", "line", "range"]), - xValue: z.string(), - xEndValue: z.string().optional(), + chartContext: annotationChartContextSchema, yValue: z.number().optional(), text: z.string().min(1).max(500), tags: z.array(z.string()).optional(), diff --git a/packages/rpc/src/routers/audit.ts b/packages/rpc/src/routers/audit.ts index 6fc9b499cd..87f25f390e 100644 --- a/packages/rpc/src/routers/audit.ts +++ b/packages/rpc/src/routers/audit.ts @@ -43,10 +43,12 @@ const auditListInputSchema = z.object({ action: z.enum(auditActionNames).optional(), actorId: z.string().min(1).optional(), cursor: z.string().min(1).optional(), + includeTechnical: z.boolean().default(false), limit: z.number().int().min(1).max(MAX_AUDIT_PAGE_SIZE).default(50), organizationId: z.string().min(1).optional(), outcome: z.enum(auditOutcomes).optional(), targetId: z.string().min(1).optional(), + targetType: z.string().min(1).optional(), }); const auditListOutputSchema = z.object({ @@ -99,10 +101,12 @@ export const auditRouter = { action: input.action, actorId: input.actorId, cursor, + includeTechnical: input.includeTechnical, limit: input.limit, organizationId, outcome: input.outcome, targetId: input.targetId, + targetType: input.targetType, }); const events = rows.slice(0, input.limit); const lastEvent = events.at(-1); diff --git a/packages/rpc/src/routers/autocomplete.ts b/packages/rpc/src/routers/autocomplete.ts index 697da3f827..e03a7a6aef 100644 --- a/packages/rpc/src/routers/autocomplete.ts +++ b/packages/rpc/src/routers/autocomplete.ts @@ -1,5 +1,9 @@ import { chQuery } from "@databuddy/db/clickhouse"; import { createDrizzleCache, redis } from "@databuddy/redis"; +import { + analyticsDateRangeSchema, + resolveAnalyticsDateRange, +} from "@databuddy/validation"; import { z } from "zod"; import { rpcError } from "../errors"; import { logger } from "../lib/logger"; @@ -11,13 +15,9 @@ const drizzleCache = createDrizzleCache({ redis, namespace: "autocomplete" }); const CACHE_TTL = 1800; -const getDefaultDateRange = () => { - const endDate = new Date().toISOString().split("T")[0]; - const startDate = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000) - .toISOString() - .split("T")[0]; - return { startDate, endDate }; -}; +const autocompleteInputSchema = analyticsDateRangeSchema.safeExtend({ + websiteId: z.string(), +}); const getAutocompleteQuery = () => ` SELECT 'customEvents' as category, event_name as value @@ -154,13 +154,7 @@ export const autocompleteRouter = { summary: "Get autocomplete", tags: ["Autocomplete"], }) - .input( - z.object({ - websiteId: z.string(), - startDate: z.string().optional(), - endDate: z.string().optional(), - }) - ) + .input(autocompleteInputSchema) .output(autocompleteOutputSchema) .handler(async ({ context, input }) => { const workspace = await withPublicWorkspace(context, { @@ -168,10 +162,7 @@ export const autocompleteRouter = { permissions: ["read"], }); - const { startDate, endDate } = - input.startDate && input.endDate - ? { startDate: input.startDate, endDate: input.endDate } - : getDefaultDateRange(); + const { startDate, endDate } = resolveAnalyticsDateRange(input); return drizzleCache.withCache({ key: scopedCacheKey( diff --git a/packages/rpc/src/routers/funnels.ts b/packages/rpc/src/routers/funnels.ts index 4ec5d0b645..4701fe389f 100644 --- a/packages/rpc/src/routers/funnels.ts +++ b/packages/rpc/src/routers/funnels.ts @@ -1,6 +1,10 @@ import { and, desc, eq, isNull, sql } from "@databuddy/db"; import { funnelDefinitions } from "@databuddy/db/schema"; import { GATED_FEATURES } from "@databuddy/shared/types/features"; +import { + analyticsDateRangeSchema, + resolveAnalyticsDateRange, +} from "@databuddy/validation"; import { randomUUIDv7 } from "bun"; import { z } from "zod"; import { rpcError } from "../errors"; @@ -46,13 +50,13 @@ const filterSchema = z.object({ type Filter = z.infer; -const getDefaultDateRange = () => { - const endDate = new Date().toISOString().split("T")[0]; - const startDate = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000) - .toISOString() - .split("T")[0]; - return { startDate, endDate }; -}; +const funnelAnalyticsInputSchema = analyticsDateRangeSchema.safeExtend({ + funnelId: z.string(), + websiteId: z.string(), +}); +const funnelAnalyticsByLinkInputSchema = funnelAnalyticsInputSchema.safeExtend({ + linkId: z.string(), +}); const getEffectiveStartDate = ( requestedStartDate: string, @@ -438,21 +442,11 @@ export const funnelsRouter = { summary: "Get funnel analytics", tags: ["Funnels"], }) - .input( - z.object({ - funnelId: z.string(), - websiteId: z.string(), - startDate: z.string().optional(), - endDate: z.string().optional(), - }) - ) + .input(funnelAnalyticsInputSchema) .output(funnelAnalyticsOutputSchema) .use(withWebsiteRead) .handler(async ({ context, input }) => { - const { startDate, endDate } = - input.startDate && input.endDate - ? { startDate: input.startDate, endDate: input.endDate } - : getDefaultDateRange(); + const { startDate, endDate } = resolveAnalyticsDateRange(input); const [funnel] = await context.db .select() @@ -507,21 +501,11 @@ export const funnelsRouter = { summary: "Get funnel analytics by referrer", tags: ["Funnels"], }) - .input( - z.object({ - funnelId: z.string(), - websiteId: z.string(), - startDate: z.string().optional(), - endDate: z.string().optional(), - }) - ) + .input(funnelAnalyticsInputSchema) .output(funnelAnalyticsByReferrerOutputSchema) .use(withWebsiteRead) .handler(async ({ context, input }) => { - const { startDate, endDate } = - input.startDate && input.endDate - ? { startDate: input.startDate, endDate: input.endDate } - : getDefaultDateRange(); + const { startDate, endDate } = resolveAnalyticsDateRange(input); const [funnel] = await context.db .select() @@ -576,22 +560,11 @@ export const funnelsRouter = { summary: "Get funnel analytics by link", tags: ["Funnels"], }) - .input( - z.object({ - funnelId: z.string(), - websiteId: z.string(), - linkId: z.string(), - startDate: z.string().optional(), - endDate: z.string().optional(), - }) - ) + .input(funnelAnalyticsByLinkInputSchema) .output(funnelAnalyticsOutputSchema) .use(withWebsiteRead) .handler(async ({ context, input }) => { - const { startDate, endDate } = - input.startDate && input.endDate - ? { startDate: input.startDate, endDate: input.endDate } - : getDefaultDateRange(); + const { startDate, endDate } = resolveAnalyticsDateRange(input); const [funnel] = await context.db .select() diff --git a/packages/rpc/src/routers/goals.ts b/packages/rpc/src/routers/goals.ts index a03349f13b..e7b82f3b3c 100644 --- a/packages/rpc/src/routers/goals.ts +++ b/packages/rpc/src/routers/goals.ts @@ -2,6 +2,10 @@ import { and, desc, eq, inArray, isNull } from "@databuddy/db"; import { goals } from "@databuddy/db/schema"; import { createDrizzleCache, redis } from "@databuddy/redis"; import { GATED_FEATURES } from "@databuddy/shared/types/features"; +import { + analyticsDateRangeSchema, + resolveAnalyticsDateRange, +} from "@databuddy/validation"; import { randomUUIDv7 } from "bun"; import { z } from "zod"; import { rpcError } from "../errors"; @@ -42,6 +46,17 @@ const filterSchema = z.object({ type Filter = z.infer; +const goalAnalyticsInputSchema = analyticsDateRangeSchema.safeExtend({ + filters: z.array(filterSchema).optional(), + goalId: z.string(), + websiteId: z.string(), +}); +const bulkGoalAnalyticsInputSchema = analyticsDateRangeSchema.safeExtend({ + filters: z.array(filterSchema).optional(), + goalIds: z.array(z.string()).min(1), + websiteId: z.string(), +}); + const goalOutputSchema = z.object({ id: z.string(), websiteId: z.string(), @@ -117,14 +132,6 @@ const goalAnalyticsResultSchema = z.discriminatedUnion("ok", [ type GoalAnalyticsResult = z.infer; -const getDefaultDateRange = () => { - const endDate = new Date().toISOString().split("T")[0]; - const startDate = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000) - .toISOString() - .split("T")[0]; - return { startDate, endDate }; -}; - const getEffectiveStartDate = ( requestedStartDate: string, createdAt: Date | null, @@ -376,22 +383,11 @@ export const goalsRouter = { description: "Returns conversion analytics for a single goal. Requires website read permission.", }) - .input( - z.object({ - goalId: z.string(), - websiteId: z.string(), - startDate: z.string().optional(), - endDate: z.string().optional(), - filters: z.array(filterSchema).optional(), - }) - ) + .input(goalAnalyticsInputSchema) .output(goalAnalyticsOutputSchema) .use(withWebsiteRead) .handler(async ({ context, input }) => { - const { startDate, endDate } = - input.startDate && input.endDate - ? { startDate: input.startDate, endDate: input.endDate } - : getDefaultDateRange(); + const { startDate, endDate } = resolveAnalyticsDateRange(input); const [goal] = await context.db .select() @@ -463,22 +459,11 @@ export const goalsRouter = { description: "Returns conversion analytics for multiple goals. Requires website read permission.", }) - .input( - z.object({ - websiteId: z.string(), - goalIds: z.array(z.string()).min(1), - startDate: z.string().optional(), - endDate: z.string().optional(), - filters: z.array(filterSchema).optional(), - }) - ) + .input(bulkGoalAnalyticsInputSchema) .output(z.record(z.string(), goalAnalyticsResultSchema)) .use(withWebsiteRead) .handler(async ({ context, input }) => { - const { startDate, endDate } = - input.startDate && input.endDate - ? { startDate: input.startDate, endDate: input.endDate } - : getDefaultDateRange(); + const { startDate, endDate } = resolveAnalyticsDateRange(input); const goalsList = await context.db .select() diff --git a/packages/services/src/audit.ts b/packages/services/src/audit.ts index 84f64e9dca..8a1ef5177a 100644 --- a/packages/services/src/audit.ts +++ b/packages/services/src/audit.ts @@ -4,7 +4,9 @@ import { asc, desc, eq, + inArray, lt, + not, or, type InferSelectModel, } from "@databuddy/db"; @@ -13,7 +15,10 @@ import { auditOutboxEvents, type AuditOutboxPayload, } from "@databuddy/db/schema"; -import { emitAuditMirror } from "@databuddy/shared/audit"; +import { + auditTechnicalActionNames, + emitAuditMirror, +} from "@databuddy/shared/audit"; import type { AuditActionDefinition, AuditActor, @@ -222,10 +227,12 @@ export interface ListAuditEventsInput { action?: string; actorId?: string; cursor?: AuditCursor; + includeTechnical?: boolean; limit: number; organizationId: string; outcome?: AuditOutcome; targetId?: string; + targetType?: string; } export const MAX_AUDIT_PAGE_SIZE = 100; @@ -248,6 +255,18 @@ export async function listAuditEvents( if (input.targetId) { conditions.push(eq(auditEvents.targetId, input.targetId)); } + if (input.targetType) { + conditions.push(eq(auditEvents.targetType, input.targetType)); + } + if (!input.includeTechnical) { + const technicalSuccessCondition = and( + inArray(auditEvents.action, auditTechnicalActionNames), + eq(auditEvents.outcome, "success") + ); + if (technicalSuccessCondition) { + conditions.push(not(technicalSuccessCondition)); + } + } if (input.cursor) { const cursorCondition = or( lt(auditEvents.createdAt, input.cursor.createdAt), diff --git a/packages/shared/src/agent-discovery.test.ts b/packages/shared/src/agent-discovery.test.ts index 6d16116fb5..8c77c62632 100644 --- a/packages/shared/src/agent-discovery.test.ts +++ b/packages/shared/src/agent-discovery.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from "bun:test"; import { API_SCOPES } from "./api-scopes"; import { type AgentDiscoveryUrls, - createAuthorizationServerMetadata, + createAgentJson, createMcpManifest, createMcpServerCard, parseNlwebAskBody, @@ -20,14 +20,13 @@ const urls = { } satisfies AgentDiscoveryUrls; describe("agent discovery builders", () => { - it("uses the shared API scope registry in auth metadata", () => { - const metadata = createAuthorizationServerMetadata(urls); + it("describes API-key authentication without unimplemented OAuth endpoints", () => { + const agent = createAgentJson(urls); expect(API_SCOPES).toContain("track:events"); - expect(metadata.scopes_supported).toBe(API_SCOPES); - expect(metadata.agent_auth.register_uri).toBe( - "https://api.databuddy.cc/agent-auth/register" - ); + expect(agent.authentication.scopes).toBe(API_SCOPES); + expect(agent.endpoints).not.toHaveProperty("protected_resource_metadata"); + expect(agent.endpoints).not.toHaveProperty("authorization_server_metadata"); }); it("advertises only the real MCP guide resource", () => { @@ -42,19 +41,13 @@ describe("agent discovery builders", () => { ]); }); - it("keeps API-key MCP discovery free of unimplemented OAuth metadata", () => { - const manifest = createMcpManifest(urls); - const card = createMcpServerCard(urls); + it("advertises one canonical Streamable HTTP endpoint", () => { + const expected = [ + { type: "streamable-http", url: "https://api.databuddy.cc/v1/mcp/" }, + ]; - expect( - Object.hasOwn( - manifest.authentication, - "protected_resource_metadata_url" - ) - ).toBe(false); - expect( - Object.hasOwn(card.authentication, "protectedResourceMetadataUrl") - ).toBe(false); + expect(createMcpManifest(urls).transports).toEqual(expected); + expect(createMcpServerCard(urls).transports).toEqual(expected); }); it("parses NLWeb ask bodies without casts", () => { diff --git a/packages/shared/src/agent-discovery.ts b/packages/shared/src/agent-discovery.ts index 783a522230..f30e1117fd 100644 --- a/packages/shared/src/agent-discovery.ts +++ b/packages/shared/src/agent-discovery.ts @@ -1,9 +1,9 @@ -import { z } from "zod"; +import z from "zod"; import { API_SCOPES } from "./api-scopes"; export { API_SCOPES } from "./api-scopes"; -export const AGENT_DISCOVERY_UPDATED = "2026-07-20"; +export const AGENT_DISCOVERY_UPDATED = "2026-08-22"; const CDN_SCRIPT_URL = "https://cdn.databuddy.cc/databuddy.js"; @@ -14,14 +14,12 @@ export interface AgentDiscoveryUrls { apiOpenapiSpecUrl: string; apiUrl: string; authMdUrl?: string; - authorizationServerMetadataUrl?: string; basketUrl: string; dashboardUrl: string; mcpManifestUrl: string; mcpServerCardUrl?: string; mcpServerUrl: string; openapiSpecUrl: string; - protectedResourceMetadataUrl?: string; siteUrl: string; } @@ -39,17 +37,11 @@ function discoveryUrls(urls: AgentDiscoveryUrls) { mcpServerCardUrl: urls.mcpServerCardUrl ?? `${urls.siteUrl}/.well-known/mcp/server-card.json`, - protectedResourceMetadataUrl: - urls.protectedResourceMetadataUrl ?? - `${urls.apiUrl}/.well-known/oauth-protected-resource`, - authorizationServerMetadataUrl: - urls.authorizationServerMetadataUrl ?? - `${urls.apiUrl}/.well-known/oauth-authorization-server`, }; } -export function createAuthDiscoveryHeader(urls: AgentDiscoveryUrls) { - return `Bearer resource_metadata="${discoveryUrls(urls).protectedResourceMetadataUrl}"`; +function mcpTransports(mcpServerUrl: string) { + return [{ type: "streamable-http" as const, url: mcpServerUrl }]; } export function createDeveloperResources(urls: AgentDiscoveryUrls) { @@ -180,16 +172,7 @@ export function createMcpManifest(urls: AgentDiscoveryUrls) { description: "Authenticated Streamable HTTP MCP server for Databuddy analytics, investigations, and mutations.", }, - transports: [ - { - type: "streamable-http", - url: resolved.mcpServerUrl, - }, - { - type: "streamable-http", - url: `${resolved.apiUrl}/mcp`, - }, - ], + transports: mcpTransports(resolved.mcpServerUrl), authentication: { type: "api_key", in: "header", @@ -245,10 +228,7 @@ export function createMcpServerCard(urls: AgentDiscoveryUrls) { "Databuddy MCP server for privacy-first analytics, errors, web vitals, feature flags, links, funnels, goals, and durable investigations.", version: "1.0.0", serverUrl: resolved.mcpServerUrl, - transports: [ - { type: "streamable-http", url: resolved.mcpServerUrl }, - { type: "streamable-http", url: `${resolved.apiUrl}/mcp` }, - ], + transports: mcpTransports(resolved.mcpServerUrl), authentication: { type: "api_key", header: "x-api-key", @@ -309,8 +289,6 @@ export function createAgentJson(urls: AgentDiscoveryUrls) { mcp_manifest: resolved.mcpManifestUrl, mcp_server_card: resolved.mcpServerCardUrl, auth_md: resolved.authMdUrl, - protected_resource_metadata: resolved.protectedResourceMetadataUrl, - authorization_server_metadata: resolved.authorizationServerMetadataUrl, llms_txt: `${resolved.siteUrl}/llms.txt`, llms_full_txt: `${resolved.siteUrl}/llms-full.txt`, skill_md: `${resolved.siteUrl}/skill.md`, @@ -438,56 +416,6 @@ export function createApiCatalog(urls: AgentDiscoveryUrls) { }; } -export function createProtectedResourceMetadata( - urls: AgentDiscoveryUrls, - resource = urls.apiUrl -) { - const resolved = discoveryUrls(urls); - - return { - resource, - resource_name: "Databuddy API", - resource_documentation: resolved.authMdUrl, - authorization_servers: [resolved.apiUrl], - scopes_supported: API_SCOPES, - bearer_methods_supported: ["header"], - jwks_uri: `${resolved.apiUrl}/.well-known/http-message-signatures-directory`, - }; -} - -export function createAuthorizationServerMetadata(urls: AgentDiscoveryUrls) { - const resolved = discoveryUrls(urls); - - return { - issuer: resolved.apiUrl, - authorization_endpoint: `${resolved.dashboardUrl}/login`, - token_endpoint: `${resolved.apiUrl}/agent-auth/claim`, - registration_endpoint: `${resolved.apiUrl}/agent-auth/register`, - revocation_endpoint: `${resolved.apiUrl}/agent-auth/revoke`, - response_types_supported: ["code"], - grant_types_supported: [ - "authorization_code", - "urn:ietf:params:oauth:grant-type:token-exchange", - ], - token_endpoint_auth_methods_supported: ["none", "client_secret_basic"], - scopes_supported: API_SCOPES, - agent_auth: { - register_uri: `${resolved.apiUrl}/agent-auth/register`, - claim_uri: `${resolved.apiUrl}/agent-auth/claim`, - revocation_uri: `${resolved.apiUrl}/agent-auth/revoke`, - skill: resolved.authMdUrl, - identity_types_supported: ["anonymous", "identity_assertion"], - anonymous: { - credential_types_supported: ["api_key"], - }, - identity_assertion: { - assertion_types_supported: ["urn:ietf:params:oauth:token-type:id-jag"], - credential_types_supported: ["api_key"], - }, - }, - }; -} - export function createWebBotAuthDirectory() { return { keys: [ @@ -552,21 +480,6 @@ export function createSandboxDiscovery(urls: AgentDiscoveryUrls) { }; } -export function createUnsupportedAgentAuthBody( - urls: AgentDiscoveryUrls, - action: string -) { - const resolved = discoveryUrls(urls); - - return { - success: false, - error: "Agent credential automation is not enabled for anonymous requests.", - code: "AGENT_AUTH_MANUAL_SETUP_REQUIRED", - action, - fix: `Create a scoped Databuddy API key from ${resolved.dashboardUrl}/organizations/settings#api-keys and follow ${resolved.authMdUrl}.`, - }; -} - export function createAcpErrorBody( urls: AgentDiscoveryUrls, code: string, @@ -658,29 +571,9 @@ export function createAuthMarkdown(urls: AgentDiscoveryUrls) { return `# auth.md -Databuddy supports agent authentication with scoped API keys. This file is the prose companion to Databuddy's OAuth Protected Resource Metadata at ${resolved.protectedResourceMetadataUrl}. Agents should use the metadata as the source of truth for endpoint URLs and this file for the step-by-step flow. - -## 1. Discover - -Fetch ${resolved.protectedResourceMetadataUrl}. The protected resource metadata advertises \`resource\`, \`authorization_servers\`, \`scopes_supported\`, and \`bearer_methods_supported\`. API 401 responses also include \`WWW-Authenticate: Bearer resource_metadata="${resolved.protectedResourceMetadataUrl}"\` so agents can recover from a cold unauthenticated probe. - -## 2. Pick a method - -Databuddy supports API-key credentials for agents. Use \`anonymous\` only for discovery and sandbox probes. Use \`identity_assertion\` when an agent provider can present an ID-JAG identity assertion for the user. The machine-readable \`agent_auth\` block is: - -\`\`\`json -${JSON.stringify({ agent_auth: createAuthorizationServerMetadata(urls).agent_auth }, null, 2)} -\`\`\` - -## 3. Register - -Use \`register_uri\`: ${resolved.apiUrl}/agent-auth/register. Production organization credentials are created from the Databuddy dashboard at ${resolved.dashboardUrl}/organizations/settings#api-keys. Choose the smallest scope set needed, usually \`read:data\` for analytics questions and only the specific write scope for mutations. - -## 4. Claim - -Use \`claim_uri\`: ${resolved.apiUrl}/agent-auth/claim. For \`identity_assertion\`, present an \`urn:ietf:params:oauth:token-type:id-jag\` assertion that identifies the user and requested organization. Databuddy returns structured JSON errors when a claim cannot be completed automatically. +Databuddy uses scoped API keys for REST and MCP. Create a key for the organization in ${resolved.dashboardUrl}/organizations/settings#api-keys, choose the smallest scope set needed, and store it securely. OAuth is not available yet. -## 5. Use the credential +## Use the credential Send the credential on every API or MCP request: @@ -703,13 +596,13 @@ For MCP clients: } \`\`\` -## 6. Errors +## Errors Databuddy API errors are JSON objects with \`success: false\`, an error \`code\`, a human-readable \`error\`, and where available a \`fix\` or \`hint\`. A 401 means the credential is missing or invalid. A 403 means the credential exists but lacks the requested organization or scope. -## 7. Revocation +## Revocation -Use \`revocation_uri\`: ${resolved.apiUrl}/agent-auth/revoke. Users can also revoke credentials from ${resolved.dashboardUrl}/organizations/settings#api-keys. Agents should stop using a credential immediately after a revocation response or any repeated 401 response. +Revoke credentials from ${resolved.dashboardUrl}/organizations/settings#api-keys. Agents should stop using a credential immediately after revocation or any repeated 401 response. ## Supported Scopes @@ -730,7 +623,7 @@ Databuddy exposes a REST API at ${resolved.apiUrl}, an OpenAPI spec at ${resolve ## Authentication -Read ${resolved.authMdUrl}. Unauthenticated protected API probes return \`WWW-Authenticate: Bearer resource_metadata="${resolved.protectedResourceMetadataUrl}"\`. +Read ${resolved.authMdUrl} and send a scoped API key in \`x-api-key\` or \`Authorization: Bearer\`. ## Primary Endpoints diff --git a/packages/shared/src/audit.test.ts b/packages/shared/src/audit.test.ts new file mode 100644 index 0000000000..d27dac7457 --- /dev/null +++ b/packages/shared/src/audit.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, test } from "bun:test"; +import { + auditTechnicalActionNames, + getAuditActionLabel, + getAuditTargetLabel, +} from "./audit"; + +describe("audit display vocabulary", () => { + test("uses human-readable labels for known actions", () => { + expect(getAuditActionLabel("api_key.deleted")).toBe("Deleted API key"); + expect(getAuditActionLabel("website.settings_updated")).toBe( + "Updated website settings" + ); + }); + + test("has a deterministic fallback for new action names", () => { + expect(getAuditActionLabel("monitor.paused")).toBe("Paused Monitor"); + }); + + test("keeps technical actions available for an explicit forensic view", () => { + expect(auditTechnicalActionNames).toContain("rpc.mutation"); + expect(getAuditTargetLabel("api_key")).toBe("API key"); + }); +}); diff --git a/packages/shared/src/audit.ts b/packages/shared/src/audit.ts index a709a6c54e..61af2c94c6 100644 --- a/packages/shared/src/audit.ts +++ b/packages/shared/src/audit.ts @@ -111,6 +111,85 @@ export type AuditActionDefinition = (typeof auditActions)[keyof typeof auditActions]; export type AuditActionName = AuditActionDefinition["action"]; +export const auditActionLabels = { + "api_key.created": "Created API key", + "api_key.deleted": "Deleted API key", + "api_key.revoked": "Revoked API key", + "api_key.rotated": "Rotated API key", + "api_key.updated": "Updated API key", + "audit_log.event_viewed": "Viewed audit event", + "audit_log.viewed": "Viewed audit log", + "flag.changed": "Changed feature flag", + "organization.created": "Created organization", + "organization.deleted": "Deleted organization", + "organization.invitation_accepted": "Accepted organization invitation", + "organization.invitation_cancelled": "Cancelled organization invitation", + "organization.invitation_created": "Created organization invitation", + "organization.invitation_rejected": "Rejected organization invitation", + "organization.member_added": "Added organization member", + "organization.member_removed": "Removed organization member", + "organization.member_role_updated": "Updated member role", + "organization.updated": "Updated organization", + "rpc.mutation": "System mutation", + "website.created": "Created website", + "website.deleted": "Deleted website", + "website.settings_updated": "Updated website settings", + "website.transferred": "Transferred website", + "website.updated": "Updated website", + "website.visibility_changed": "Changed website visibility", +} satisfies Record; + +export const auditTechnicalActionNames = [ + auditActions.AUDIT_LOG_EVENT_VIEWED.action, + auditActions.AUDIT_LOG_VIEWED.action, + auditActions.RPC_MUTATION.action, +] as const; + +export const auditSourceLabels: Record = { + better_auth: "Authentication", + orpc: "Dashboard", + public_api: "Public API", + worker: "Background job", +}; + +export const auditActorTypeLabels: Record = { + agent: "Agent", + api: "API key", + system: "System", + user: "User", +}; + +const auditTargetLabels: Record = { + audit_log: "Audit log", + api_key: "API key", + flag: "Feature flag", + invitation: "Invitation", + member: "Member", + organization: "Organization", + website: "Website", +}; + +function titleCase(value: string): string { + return value + .replaceAll("_", " ") + .replace(/\b\w/g, (character) => character.toUpperCase()); +} + +export function getAuditActionLabel(action: string): string { + if (action in auditActionLabels) { + return auditActionLabels[action as AuditActionName]; + } + + const [resource, verb] = action.split("."); + return verb + ? `${titleCase(verb)} ${titleCase(resource ?? "event")}` + : titleCase(action); +} + +export function getAuditTargetLabel(targetType: string): string { + return auditTargetLabels[targetType] ?? titleCase(targetType); +} + export interface AuditMirrorInput { action: TAction; actor: AuditActor; diff --git a/packages/validation/src/schemas/analytics.test.ts b/packages/validation/src/schemas/analytics.test.ts index cb87d405d4..ca58450028 100644 --- a/packages/validation/src/schemas/analytics.test.ts +++ b/packages/validation/src/schemas/analytics.test.ts @@ -1,5 +1,36 @@ import { describe, expect, it } from "bun:test"; -import { analyticsEventSchema } from "./analytics"; +import { + analyticsDateRangeSchema, + analyticsEventSchema, + resolveAnalyticsDateRange, +} from "./analytics"; + +describe("analyticsDateRangeSchema", () => { + it("preserves an explicit range or uses an inclusive seven-calendar-day default", () => { + const now = new Date("2026-04-11T12:00:00.000Z"); + expect( + resolveAnalyticsDateRange( + { startDate: "2026-02-01", endDate: "2026-02-28" }, + now + ) + ).toEqual({ startDate: "2026-02-01", endDate: "2026-02-28" }); + expect(resolveAnalyticsDateRange({}, now)).toEqual({ + startDate: "2026-04-05", + endDate: "2026-04-11", + }); + }); + + it("rejects invalid, partial, and reversed date ranges", () => { + for (const range of [ + { startDate: "2026-02-30", endDate: "2026-03-01" }, + { startDate: "2026-02-01" }, + { endDate: "2026-02-01" }, + { startDate: "2026-03-02", endDate: "2026-03-01" }, + ]) { + expect(analyticsDateRangeSchema.safeParse(range).success).toBe(false); + } + }); +}); const validEvent = { eventId: "test-id", diff --git a/packages/validation/src/schemas/analytics.ts b/packages/validation/src/schemas/analytics.ts index 79dd5c0e6e..7f3e30456f 100644 --- a/packages/validation/src/schemas/analytics.ts +++ b/packages/validation/src/schemas/analytics.ts @@ -7,6 +7,38 @@ import { } from "../regexes"; import { profileIdSchema } from "./identity"; +/** Date-only analytics inputs must be complete, valid, and ordered. */ +export const analyticsDateRangeSchema = z + .object({ + startDate: z.iso.date().optional(), + endDate: z.iso.date().optional(), + }) + .refine(({ startDate, endDate }) => Boolean(startDate) === Boolean(endDate), { + message: + "Provide both startDate and endDate, or omit both to use the default range.", + path: ["endDate"], + }) + .refine( + ({ startDate, endDate }) => !(startDate && endDate) || startDate <= endDate, + { message: "endDate must be on or after startDate.", path: ["endDate"] } + ); + +/** Resolves an already-validated explicit range or the inclusive seven-day default. */ +export function resolveAnalyticsDateRange( + { startDate, endDate }: { endDate?: string; startDate?: string }, + now = new Date() +): { endDate: string; startDate: string } { + if (startDate && endDate) { + return { startDate, endDate }; + } + const start = new Date(now); + start.setUTCDate(start.getUTCDate() - 6); + return { + startDate: start.toISOString().slice(0, 10), + endDate: now.toISOString().slice(0, 10), + }; +} + const resolutionSchema = z .string() .regex(RESOLUTION_REGEX, "Must be in the format 'WIDTHxHEIGHT'") diff --git a/packages/validation/src/schemas/annotations.ts b/packages/validation/src/schemas/annotations.ts new file mode 100644 index 0000000000..1a4562d2af --- /dev/null +++ b/packages/validation/src/schemas/annotations.ts @@ -0,0 +1,49 @@ +import z from "zod"; + +export const isoDateOrOffsetDateTimeSchema = z.union([ + z.iso.date(), + z.iso.datetime({ offset: true }), +]); + +export const annotationChartContextSchema = z.object({ + dateRange: z.object({ + start_date: z.string(), + end_date: z.string(), + granularity: z.enum(["hourly", "daily", "weekly", "monthly"]), + }), + filters: z + .array( + z.object({ + field: z.string(), + operator: z.enum(["eq", "ne", "gt", "lt", "contains"]), + value: z.string(), + }) + ) + .optional(), + metrics: z.array(z.string()).optional(), + tabId: z.string().optional(), +}); + +export const annotationCoordinateSchema = z + .object({ + annotationType: z.enum(["point", "line", "range"]), + xValue: isoDateOrOffsetDateTimeSchema, + xEndValue: isoDateOrOffsetDateTimeSchema.optional(), + }) + .superRefine((input, context) => { + if (input.annotationType === "range" && !input.xEndValue) { + context.addIssue({ + code: "custom", + message: + "Range annotations require an xEndValue to define the end of the time period.", + path: ["xEndValue"], + }); + } + if (input.xEndValue && new Date(input.xEndValue) < new Date(input.xValue)) { + context.addIssue({ + code: "custom", + message: "xEndValue must be on or after xValue.", + path: ["xEndValue"], + }); + } + }); diff --git a/packages/validation/src/schemas/index.ts b/packages/validation/src/schemas/index.ts index 177dbcbec6..04b38a3eb1 100644 --- a/packages/validation/src/schemas/index.ts +++ b/packages/validation/src/schemas/index.ts @@ -1,5 +1,6 @@ /** biome-ignore-all lint/performance/noBarrelFile: It's witerawwy just a bawel file*/ export * from "./analytics"; +export * from "./annotations"; export * from "./batch"; export * from "./custom-events"; export * from "./errors"; From 226c1ed595b24a5bcb0480d845a14123b3dc08af Mon Sep 17 00:00:00 2001 From: iza <59828082+izadoesdev@users.noreply.github.com> Date: Mon, 24 Aug 2026 22:44:34 +0300 Subject: [PATCH 10/88] fix(staging): harden audit export consistency --- packages/rpc/src/routers/audit.ts | 11 +++++++++++ packages/services/src/audit.ts | 18 +++++++++++++++++- 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/packages/rpc/src/routers/audit.ts b/packages/rpc/src/routers/audit.ts index 7054a374d3..85472b0485 100644 --- a/packages/rpc/src/routers/audit.ts +++ b/packages/rpc/src/routers/audit.ts @@ -199,6 +199,7 @@ export const auditRouter = { }); const events: AuditEvent[] = []; + let snapshotCursor: AuditCursor | undefined; let cursor: AuditCursor | undefined; let truncated = false; @@ -206,6 +207,7 @@ export const auditRouter = { const rows = await listAuditEvents(context.db, { action: input.action, actorId: input.actorId, + before: snapshotCursor, cursor, from: input.from, includeTechnical: input.includeTechnical, @@ -216,6 +218,15 @@ export const auditRouter = { targetType: input.targetType, to: input.to, }); + if (!snapshotCursor) { + const firstEvent = rows[0]; + if (firstEvent) { + snapshotCursor = { + createdAt: firstEvent.createdAt, + id: firstEvent.id, + }; + } + } const { hasMore, page, diff --git a/packages/services/src/audit.ts b/packages/services/src/audit.ts index 070e82ca7f..7d5d3baba1 100644 --- a/packages/services/src/audit.ts +++ b/packages/services/src/audit.ts @@ -22,6 +22,7 @@ import { emitAuditMirror, redactAuditChanges, redactAuditMetadata, + type AuditValue, } from "@databuddy/shared/audit"; import type { AuditActionDefinition, @@ -230,6 +231,7 @@ export function decodeAuditCursor(cursor: string): AuditCursor | null { export interface ListAuditEventsInput { action?: string; actorId?: string; + before?: AuditCursor; cursor?: AuditCursor; from?: Date; includeTechnical?: boolean; @@ -280,6 +282,18 @@ export async function listAuditEvents( conditions.push(not(technicalSuccessCondition)); } } + if (input.before) { + const beforeCondition = or( + lt(auditEvents.createdAt, input.before.createdAt), + and( + eq(auditEvents.createdAt, input.before.createdAt), + lte(auditEvents.id, input.before.id) + ) + ); + if (beforeCondition) { + conditions.push(beforeCondition); + } + } if (input.cursor) { const cursorCondition = or( lt(auditEvents.createdAt, input.cursor.createdAt), @@ -302,7 +316,9 @@ export async function listAuditEvents( .limit(limit + 1); } -function csvCell(value: unknown): string { +type AuditCsvValue = AuditChanges | AuditMetadata | AuditValue; + +function csvCell(value: AuditCsvValue): string { const rawText = value === null || value === undefined ? "" From b8124b28a589e9ad82067e8766ab8b16c1a882bb Mon Sep 17 00:00:00 2001 From: Akash Moradiya <64416825+akash3444@users.noreply.github.com> Date: Tue, 25 Aug 2026 02:07:06 +0530 Subject: [PATCH 11/88] fix(dashboard): align web vitals breakdown values (#652) --- .../_components/web-vitals-metric-cell.tsx | 19 +++++++++++++------ .../components/table/table-content.tsx | 2 +- 2 files changed, 14 insertions(+), 7 deletions(-) diff --git a/apps/dashboard/app/(main)/websites/[id]/_components/tabs/performance/_components/web-vitals-metric-cell.tsx b/apps/dashboard/app/(main)/websites/[id]/_components/tabs/performance/_components/web-vitals-metric-cell.tsx index f21ed745b2..5162acc7a6 100644 --- a/apps/dashboard/app/(main)/websites/[id]/_components/tabs/performance/_components/web-vitals-metric-cell.tsx +++ b/apps/dashboard/app/(main)/websites/[id]/_components/tabs/performance/_components/web-vitals-metric-cell.tsx @@ -65,15 +65,22 @@ export function WebVitalsMetricCell({ const formatted = metric === "cls" ? value.toFixed(3) : formatPerformanceTime(value); const { colorClass, isGood, isPoor } = getMetricStyles(value, metric); - const showIcon = isGood || isPoor; return ( -
+
+ {isGood ? ( +
); } diff --git a/apps/dashboard/components/table/table-content.tsx b/apps/dashboard/components/table/table-content.tsx index cdffb99aff..10b931090f 100644 --- a/apps/dashboard/components/table/table-content.tsx +++ b/apps/dashboard/components/table/table-content.tsx @@ -40,7 +40,7 @@ const DEFAULT_CELL_STYLE = { const COMPACT_COLUMN_WIDTHS: Record = { clicks: 88, - cls: 76, + cls: 78, current_time: 108, customers: 100, fcp: 88, From 44dcdcdd488bd62259fec72d2be2b4913834c0bd Mon Sep 17 00:00:00 2001 From: Akash Moradiya <64416825+akash3444@users.noreply.github.com> Date: Tue, 25 Aug 2026 02:07:34 +0530 Subject: [PATCH 12/88] fix(dashboard): correct appearance chart selects (#653) --- .../_components/chart-type-option.tsx | 15 +++++++++ .../app/(main)/settings/appearance/page.tsx | 31 +++++++++++++++---- packages/ui/src/components/select.tsx | 4 ++- 3 files changed, 43 insertions(+), 7 deletions(-) create mode 100644 apps/dashboard/app/(main)/settings/appearance/_components/chart-type-option.tsx diff --git a/apps/dashboard/app/(main)/settings/appearance/_components/chart-type-option.tsx b/apps/dashboard/app/(main)/settings/appearance/_components/chart-type-option.tsx new file mode 100644 index 0000000000..4e08a0c32e --- /dev/null +++ b/apps/dashboard/app/(main)/settings/appearance/_components/chart-type-option.tsx @@ -0,0 +1,15 @@ +import type { ChartBarIcon } from "@databuddy/ui/icons"; + +interface ChartTypeOptionProps { + icon: typeof ChartBarIcon; + label: string; +} + +export function ChartTypeOption({ icon: Icon, label }: ChartTypeOptionProps) { + return ( + + + {label} + + ); +} diff --git a/apps/dashboard/app/(main)/settings/appearance/page.tsx b/apps/dashboard/app/(main)/settings/appearance/page.tsx index c7899c4fd0..8c3d1eed1d 100644 --- a/apps/dashboard/app/(main)/settings/appearance/page.tsx +++ b/apps/dashboard/app/(main)/settings/appearance/page.tsx @@ -34,6 +34,7 @@ import { } from "@databuddy/ui/icons"; import { Select } from "@databuddy/ui/client"; import { Card, Text, Tooltip } from "@databuddy/ui"; +import { ChartTypeOption } from "./_components/chart-type-option"; const MOCK_CHART_DATA = [ { date: "2024-01-01", value: 186 }, @@ -68,6 +69,16 @@ const STEP_TYPE_OPTIONS: { id: ChartCurveType; name: string }[] = [ { id: "stepAfter", name: "Step After" }, ]; +const CHART_TYPE_ITEMS = CHART_TYPE_OPTIONS.map(({ id, name }) => ({ + label: name, + value: id, +})); + +const STEP_TYPE_ITEMS = STEP_TYPE_OPTIONS.map(({ id, name }) => ({ + label: name, + value: id, +})); + const DEFAULT_DATE_RANGE_OPTIONS: DefaultDateRangePreset[] = [ "24h", "7d", @@ -85,6 +96,11 @@ const LOCATION_ICONS: Record = { events: CursorClickIcon, }; +const CHART_LOCATION_ITEMS = CHART_LOCATIONS.map((value) => ({ + label: CHART_LOCATION_LABELS[value], + value, +})); + export default function AppearanceSettingsPage() { const { theme, setTheme } = useTheme(); const { defaultDateRange, setDefaultDateRange } = useDefaultDateRange(); @@ -191,6 +207,7 @@ export default function AppearanceSettingsPage() { {showGranular && ( updateAllPreferences({ chartType: v as ChartSeriesKind, @@ -250,14 +268,14 @@ export default function AppearanceSettingsPage() { {CHART_TYPE_OPTIONS.map(({ id, name, icon: OptIcon }) => ( - - {name} + ))} updateLocationPreferences(location, { chartType: v as ChartSeriesKind, @@ -367,11 +386,10 @@ export default function AppearanceSettingsPage() { {CHART_TYPE_OPTIONS.map( ({ id, name, icon: OptIcon }) => ( - - {name} ) )} @@ -379,6 +397,7 @@ export default function AppearanceSettingsPage() { - updateAllPreferences({ chartType: v }) - } - value={globalPrefs.chartType} - > - - - - - {CHART_TYPE_OPTIONS.map(({ id, name, icon: OptIcon }) => ( - -
- - {name} -
-
- ))} -
- - -
-
- - - - {showGranular ? ( -
- {CHART_LOCATIONS.map((location) => { - const prefs = preferences[location] ?? { - chartType: "area" as ChartSeriesKind, - chartStepType: "monotone" as ChartCurveType, - }; - const isBar = prefs.chartType === "bar"; - const Icon = LOCATION_ICONS[location]; - - return ( -
-
- - - {CHART_LOCATION_LABELS[location]} - -
-
- - -
-
- ); - })} -
- ) : null} -
-
- - ); -} - -function QuickActions() { - const handleCopyUrl = () => { - navigator.clipboard.writeText(window.location.href); - toast.success("URL copied to clipboard"); - }; - - const handleCopyState = () => { - const state = { - url: window.location.href, - timestamp: new Date().toISOString(), - userAgent: navigator.userAgent, - viewport: { - width: window.innerWidth, - height: window.innerHeight, - }, - }; - console.table(state); - navigator.clipboard.writeText(JSON.stringify(state, null, 2)); - toast.success("State copied to clipboard and logged to console"); - }; - - const handleClearConsole = () => { - console.clear(); - toast.success("Console cleared"); - }; - - const handleReload = () => { - window.location.reload(); - }; - - return ( -
-

- - Quick Actions -

-
- - - - -
-
- ); -} - -export function DevToolsDrawer() { - const [mounted, setMounted] = useState(false); - const [open, setOpen] = useState(false); - const [isLocalhost, setIsLocalhost] = useState(false); - - useEffect(() => { - setMounted(true); - const hostname = window.location.hostname; - setIsLocalhost(hostname === "localhost" || hostname === "127.0.0.1"); - }, []); - - useEffect(() => { - if (!isLocalhost) { - return; - } - - const handleKeyDown = (e: KeyboardEvent) => { - if ((e.metaKey || e.ctrlKey) && e.key === ".") { - e.preventDefault(); - setOpen((prev) => !prev); - } - }; - - window.addEventListener("keydown", handleKeyDown); - return () => window.removeEventListener("keydown", handleKeyDown); - }, [isLocalhost]); - - if (!(mounted && isLocalhost)) { - return null; - } - - return ( - <> - - - - - -
-
- - Dev Tools -
- - - -
- - Development tools and debugging utilities - -
- -
-
- - - - - - - - - - - - - - - -
-

- Tip: Press{" "} - - ⌘ - {" "} - - . - {" "} - to toggle this drawer -

-
-
-
-
-
- - ); -} diff --git a/apps/dashboard/components/empty-state.tsx b/apps/dashboard/components/empty-state.tsx deleted file mode 100644 index b7d74bc240..0000000000 --- a/apps/dashboard/components/empty-state.tsx +++ /dev/null @@ -1,292 +0,0 @@ -"use client"; - -import { - cloneElement, - memo, - type ReactElement, - type ReactNode, - type SVGProps, -} from "react"; -import { cn } from "@/lib/utils"; -import { PlusIcon } from "@databuddy/ui/icons"; -import { Button, Card } from "@databuddy/ui"; - -export interface EmptyStateAction { - label: string; - onClick: () => void; - size?: "sm" | "md" | "lg"; - tone?: "destructive"; - variant?: "primary" | "secondary" | "ghost"; -} - -export interface EmptyStateProps { - /** Primary action button */ - action?: EmptyStateAction; - /** Custom aria-label for screen readers */ - "aria-label"?: string; - /** Custom className */ - className?: string; - /** Description text */ - description?: string | ReactNode; - /** Main icon to display */ - icon: ReactElement< - SVGProps & { size?: number | string; weight?: string } - >; - /** Whether this is the main content area */ - isMainContent?: boolean; - /** Custom padding */ - padding?: "sm" | "md" | "lg"; - /** Custom role for accessibility (defaults to 'region') */ - role?: "region" | "complementary" | "main"; - /** Secondary action button */ - secondaryAction?: EmptyStateAction; - /** Whether to show the plus badge on the icon */ - showPlusBadge?: boolean; - /** Main heading */ - title: string; - /** Custom styling variants */ - variant?: "default" | "simple" | "minimal" | "error"; -} - -export const EmptyState = memo(function EmptyState({ - icon, - title, - description, - action, - secondaryAction, - variant = "minimal", - className, - showPlusBadge = true, - padding = "lg", - role = "region", - "aria-label": ariaLabel, - isMainContent = false, -}: EmptyStateProps) { - const getPadding = () => { - switch (padding) { - case "sm": - return "px-6 py-12"; - case "md": - return "px-8 py-14"; - case "lg": - return "px-8"; - default: - return "px-8"; - } - }; - - const renderIcon = () => { - if (!icon || typeof icon !== "object" || !("type" in icon)) { - return null; - } - - const iconProps = icon.props || {}; - - if (variant === "simple" || variant === "minimal" || variant === "error") { - return ( - - ); - } - - return ( -
- - {showPlusBadge && ( - - )} -
- ); - }; - - const renderCard = () => { - const cardClasses = cn( - variant === "default" && - "rounded-xl border-2 border-dashed bg-gradient-to-br from-background to-muted/10", - variant === "simple" && "rounded border-dashed bg-muted/10", - variant === "minimal" && - "flex flex-1 rounded border-none bg-transparent shadow-none", - variant === "error" && - "flex flex-1 rounded border-none bg-transparent shadow-none", - "safe-area-inset-4 sm:safe-area-inset-6 lg:safe-area-inset-8", - className - ); - - const contentClasses = cn( - "flex flex-1 flex-col items-center justify-center text-center", - getPadding(), - "px-6 sm:px-8 lg:px-12" - ); - - return ( - - - {renderIcon()} -
- {isMainContent ? ( -

- {title} -

- ) : ( -
-

- {title} -

-

{description}

-
- )} - {(action || secondaryAction) && ( -
- {action && ( - - )} - {secondaryAction && ( - - )} -
- )} -
-
-
- ); - }; - - return renderCard(); -}); - -EmptyState.displayName = "EmptyState"; - -export function FeatureEmptyState({ - icon, - title, - description, - actionLabel, - onAction, -}: { - icon: ReactElement< - SVGProps & { size?: number | string; weight?: string } - >; - title: string; - description: string; - actionLabel: string; - onAction: () => void; -}) { - return ( - - ); -} diff --git a/apps/dashboard/components/layout/help-dialog.tsx b/apps/dashboard/components/layout/help-dialog.tsx deleted file mode 100644 index 80ed58a37b..0000000000 --- a/apps/dashboard/components/layout/help-dialog.tsx +++ /dev/null @@ -1,158 +0,0 @@ -"use client"; - -import Link from "next/link"; -import { useState } from "react"; -import { KeyboardShortcuts } from "@/components/ui/keyboard-shortcuts"; -import { cn } from "@/lib/utils"; -import { - BookOpenIcon, - ChatTextIcon as ChatCircleIcon, - CommandIcon as KeyboardIcon, - PlayIcon, -} from "@databuddy/ui/icons"; -import { Button, Text } from "@databuddy/ui"; -import { Dialog } from "@databuddy/ui/client"; - -interface HelpDialogProps { - onOpenChangeAction: (open: boolean) => void; - open: boolean; -} - -const HELP_ITEMS = [ - { - href: "https://www.databuddy.cc/docs", - icon: BookOpenIcon, - title: "Documentation", - description: "Read guides and API references", - external: true, - }, - { - href: "mailto:support@databuddy.cc", - icon: ChatCircleIcon, - title: "Contact Support", - description: "Get help from our support team", - external: false, - }, - { - href: "https://www.youtube.com/@trydatabuddy", - icon: PlayIcon, - title: "Tutorials", - description: "Learn Databuddy step by step", - external: true, - }, -] as const; - -function HelpRow({ - children, - className, - ...rest -}: React.ButtonHTMLAttributes) { - return ( - - ); -} - -export function HelpDialog({ open, onOpenChangeAction }: HelpDialogProps) { - const [showShortcuts, setShowShortcuts] = useState(false); - - return ( - { - if (!o) { - setShowShortcuts(false); - } - onOpenChangeAction(o); - }} - open={open} - > - - - Help & Resources - - Get assistance and learn more about Databuddy - - - - - {showShortcuts ? ( -
-
- Keyboard Shortcuts - -
- -
- ) : ( -
- setShowShortcuts(true)}> -
- -
-
- Keyboard Shortcuts - - View all available keyboard shortcuts - -
-
- - {HELP_ITEMS.map((item) => { - const Icon = item.icon; - return ( - -
- -
-
- {item.title} - - {item.description} - -
- - ); - })} -
- )} -
-
-
- ); -} diff --git a/apps/dashboard/components/monitors/collapsible-section.tsx b/apps/dashboard/components/monitors/collapsible-section.tsx deleted file mode 100644 index a012d0302c..0000000000 --- a/apps/dashboard/components/monitors/collapsible-section.tsx +++ /dev/null @@ -1,66 +0,0 @@ -"use client"; - -import { AnimatePresence, motion } from "motion/react"; -import { cn } from "@/lib/utils"; -import { CaretDownIcon } from "@databuddy/ui/icons"; -import { Button } from "@databuddy/ui"; - -interface CollapsibleSectionProps { - badge?: number; - children: React.ReactNode; - icon: React.ComponentType<{ size?: number; weight?: "duotone" | "fill" }>; - isExpanded: boolean; - onToggleAction: () => void; - title: string; -} - -export function CollapsibleSection({ - icon: Icon, - title, - badge, - isExpanded, - onToggleAction, - children, -}: CollapsibleSectionProps) { - return ( -
- - - - {isExpanded && ( - -
{children}
-
- )} -
-
- ); -} diff --git a/apps/dashboard/components/ui/aspect-ratio.tsx b/apps/dashboard/components/ui/aspect-ratio.tsx deleted file mode 100644 index 956e8bb05d..0000000000 --- a/apps/dashboard/components/ui/aspect-ratio.tsx +++ /dev/null @@ -1,11 +0,0 @@ -"use client"; - -import { AspectRatio as AspectRatioPrimitive } from "radix-ui"; - -function AspectRatio({ - ...props -}: React.ComponentProps) { - return ; -} - -export { AspectRatio }; diff --git a/apps/dashboard/components/ui/badge.tsx b/apps/dashboard/components/ui/badge.tsx deleted file mode 100644 index 3c6f47bfdd..0000000000 --- a/apps/dashboard/components/ui/badge.tsx +++ /dev/null @@ -1,52 +0,0 @@ -import { Slot } from "@radix-ui/react-slot"; -import { cva, type VariantProps } from "class-variance-authority"; -import type * as React from "react"; - -import { cn } from "@/lib/utils"; - -const badgeVariants = cva( - "inline-flex w-fit shrink-0 items-center justify-center gap-1 overflow-hidden whitespace-nowrap rounded border px-2 py-0.5 font-medium text-xs transition-[color,box-shadow] focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3", - { - variants: { - variant: { - default: - "border border-brand-purple/35 bg-brand-purple text-white dark:border-brand-purple/55 dark:bg-brand-purple dark:text-white [a&]:hover:bg-brand-purple/90", - gray: "border border-border bg-muted text-muted-foreground dark:border-border dark:bg-secondary dark:text-muted-foreground [a&]:hover:bg-muted/90", - blue: "border border-brand-purple/25 bg-brand-purple/10 text-brand-purple dark:border-brand-purple/40 dark:bg-brand-purple/18 dark:text-[#C9BFE8] [a&]:hover:bg-brand-purple/15", - green: - "border border-emerald-600/25 bg-emerald-50 text-emerald-800 dark:border-emerald-500/35 dark:bg-emerald-950/50 dark:text-emerald-300 [a&]:hover:bg-emerald-100/90", - amber: - "border border-brand-amber/30 bg-brand-amber/12 text-amber-950 dark:border-brand-amber/40 dark:bg-brand-amber/14 dark:text-amber-300 [a&]:hover:bg-brand-amber/18", - secondary: - "border border-foreground/15 bg-foreground text-background dark:border-foreground/25 dark:bg-foreground dark:text-background [a&]:hover:bg-foreground/90", - destructive: - "border border-brand-coral/30 bg-brand-coral/12 text-brand-coral focus-visible:ring-brand-coral/20 dark:border-brand-coral/45 dark:bg-brand-coral/22 dark:text-[#E8A8BE] [a&]:hover:bg-brand-coral/18", - outline: - "border border-border text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground", - }, - }, - defaultVariants: { - variant: "default", - }, - } -); - -function Badge({ - className, - variant, - asChild = false, - ...props -}: React.ComponentProps<"span"> & - VariantProps & { asChild?: boolean }) { - const Comp = asChild ? Slot : "span"; - - return ( - - ); -} - -export { Badge, badgeVariants }; diff --git a/apps/dashboard/components/ui/card.tsx b/apps/dashboard/components/ui/card.tsx deleted file mode 100644 index aa9c6f8a63..0000000000 --- a/apps/dashboard/components/ui/card.tsx +++ /dev/null @@ -1,92 +0,0 @@ -import type * as React from "react"; - -import { cn } from "@/lib/utils"; - -function Card({ className, ...props }: React.ComponentProps<"div">) { - return ( -
- ); -} - -function CardHeader({ className, ...props }: React.ComponentProps<"div">) { - return ( -
- ); -} - -function CardTitle({ className, ...props }: React.ComponentProps<"div">) { - return ( -
- ); -} - -function CardDescription({ className, ...props }: React.ComponentProps<"div">) { - return ( -
- ); -} - -function CardAction({ className, ...props }: React.ComponentProps<"div">) { - return ( -
- ); -} - -function CardContent({ className, ...props }: React.ComponentProps<"div">) { - return ( -
- ); -} - -function CardFooter({ className, ...props }: React.ComponentProps<"div">) { - return ( -
- ); -} - -export { - Card, - CardAction, - CardContent, - CardDescription, - CardFooter, - CardHeader, - CardTitle, -}; diff --git a/apps/dashboard/components/ui/carousel.tsx b/apps/dashboard/components/ui/carousel.tsx deleted file mode 100644 index b4134c2143..0000000000 --- a/apps/dashboard/components/ui/carousel.tsx +++ /dev/null @@ -1,249 +0,0 @@ -"use client"; - -import useEmblaCarousel, { - type UseEmblaCarouselType, -} from "embla-carousel-react"; -import * as React from "react"; -import { cn } from "@/lib/utils"; -import { - ArrowLeftIcon, - ArrowRightIcon, -} from "@databuddy/ui/icons"; -import { Button } from "@databuddy/ui"; - -type CarouselApi = UseEmblaCarouselType[1]; -type UseCarouselParameters = Parameters; -type CarouselOptions = UseCarouselParameters[0]; -type CarouselPlugin = UseCarouselParameters[1]; - -type CarouselProps = { - opts?: CarouselOptions; - plugins?: CarouselPlugin; - orientation?: "horizontal" | "vertical"; - setApi?: (api: CarouselApi) => void; -}; - -type CarouselContextProps = { - carouselRef: ReturnType[0]; - api: ReturnType[1]; - scrollPrev: () => void; - scrollNext: () => void; - canScrollPrev: boolean; - canScrollNext: boolean; -} & CarouselProps; - -const CarouselContext = React.createContext(null); - -function useCarousel() { - const context = React.useContext(CarouselContext); - - if (!context) { - throw new Error("useCarousel must be used within a "); - } - - return context; -} - -function Carousel({ - orientation = "horizontal", - opts, - setApi, - plugins, - className, - children, - ...props -}: React.ComponentProps<"div"> & CarouselProps) { - const [carouselRef, api] = useEmblaCarousel( - { - ...opts, - axis: orientation === "horizontal" ? "x" : "y", - }, - plugins - ); - const [canScrollPrev, setCanScrollPrev] = React.useState(false); - const [canScrollNext, setCanScrollNext] = React.useState(false); - - const onSelect = React.useCallback((api: CarouselApi) => { - if (!api) { - return; - } - setCanScrollPrev(api.canScrollPrev()); - setCanScrollNext(api.canScrollNext()); - }, []); - - const scrollPrev = React.useCallback(() => { - api?.scrollPrev(); - }, [api]); - - const scrollNext = React.useCallback(() => { - api?.scrollNext(); - }, [api]); - - const handleKeyDown = React.useCallback( - (event: React.KeyboardEvent) => { - if (event.key === "ArrowLeft") { - event.preventDefault(); - scrollPrev(); - } else if (event.key === "ArrowRight") { - event.preventDefault(); - scrollNext(); - } - }, - [scrollPrev, scrollNext] - ); - - React.useEffect(() => { - if (!(api && setApi)) { - return; - } - setApi(api); - }, [api, setApi]); - - React.useEffect(() => { - if (!api) { - return; - } - onSelect(api); - api.on("reInit", onSelect); - api.on("select", onSelect); - - return () => { - api?.off("select", onSelect); - }; - }, [api, onSelect]); - - return ( - -
- {children} -
-
- ); -} - -function CarouselContent({ className, ...props }: React.ComponentProps<"div">) { - const { carouselRef, orientation } = useCarousel(); - - return ( -
-
-
- ); -} - -function CarouselItem({ className, ...props }: React.ComponentProps<"div">) { - const { orientation } = useCarousel(); - - return ( -
- ); -} - -function CarouselPrevious({ - className, - variant = "secondary", - size = "sm", - ...props -}: React.ComponentProps) { - const { orientation, scrollPrev, canScrollPrev } = useCarousel(); - - return ( - - ); -} - -function CarouselNext({ - className, - variant = "secondary", - size = "sm", - ...props -}: React.ComponentProps) { - const { orientation, scrollNext, canScrollNext } = useCarousel(); - - return ( - - ); -} - -export { - Carousel, - type CarouselApi, - CarouselContent, - CarouselItem, - CarouselNext, - CarouselPrevious, -}; diff --git a/apps/dashboard/components/ui/command.tsx b/apps/dashboard/components/ui/command.tsx deleted file mode 100644 index ab33153220..0000000000 --- a/apps/dashboard/components/ui/command.tsx +++ /dev/null @@ -1,174 +0,0 @@ -"use client"; - -import { Command as CommandPrimitive } from "cmdk"; -import type * as React from "react"; -import { cn } from "@/lib/utils"; -import { - MagnifyingGlassIcon, -} from "@databuddy/ui/icons"; -import { Dialog } from "@databuddy/ui/client"; - -function Command({ - className, - ...props -}: React.ComponentProps) { - return ( - - ); -} - -function CommandDialog({ - title = "Command Palette", - description = "Search for a command to run...", - children, - ...props -}: Omit, "children"> & { - title?: string; - description?: string; - children?: React.ReactNode; -}) { - return ( - - - - {title} - {description} - - - - {children} - - - - - ); -} - -function CommandInput({ - className, - ...props -}: React.ComponentProps) { - return ( -
- - -
- ); -} - -function CommandList({ - className, - ...props -}: React.ComponentProps) { - return ( - e.stopPropagation()} - onWheel={(e) => e.stopPropagation()} - {...props} - /> - ); -} - -function CommandEmpty({ - ...props -}: React.ComponentProps) { - return ( - - ); -} - -function CommandGroup({ - className, - ...props -}: React.ComponentProps) { - return ( - - ); -} - -function CommandSeparator({ - className, - ...props -}: React.ComponentProps) { - return ( - - ); -} - -function CommandItem({ - className, - ...props -}: React.ComponentProps) { - return ( - - ); -} - -function CommandShortcut({ - className, - ...props -}: React.ComponentProps<"span">) { - return ( - - ); -} - -export { - Command, - CommandDialog, - CommandEmpty, - CommandGroup, - CommandInput, - CommandItem, - CommandList, - CommandSeparator, - CommandShortcut, -}; diff --git a/apps/dashboard/components/ui/dialog.tsx b/apps/dashboard/components/ui/dialog.tsx deleted file mode 100644 index 485ee171d9..0000000000 --- a/apps/dashboard/components/ui/dialog.tsx +++ /dev/null @@ -1,146 +0,0 @@ -"use client"; - -import { XMarkIcon as XIcon } from "@databuddy/ui/icons"; -import { Dialog as DialogPrimitive } from "radix-ui"; -import type * as React from "react"; - -import { cn } from "@/lib/utils"; - -function Dialog({ - ...props -}: React.ComponentProps) { - return ; -} - -function DialogTrigger({ - ...props -}: React.ComponentProps) { - return ; -} - -function DialogPortal({ - ...props -}: React.ComponentProps) { - return ; -} - -function DialogClose({ - ...props -}: React.ComponentProps) { - return ; -} - -function DialogOverlay({ - className, - ...props -}: React.ComponentProps) { - return ( - - ); -} - -function DialogContent({ - className, - children, - showCloseButton = true, - ...props -}: React.ComponentProps & { - showCloseButton?: boolean; -}) { - return ( - - - - {children} - {showCloseButton && ( - - - Close - - )} - - - ); -} - -function DialogHeader({ className, ...props }: React.ComponentProps<"div">) { - return ( -
- ); -} - -function DialogFooter({ className, ...props }: React.ComponentProps<"div">) { - return ( -
- ); -} - -function DialogTitle({ - className, - ...props -}: React.ComponentProps) { - return ( - - ); -} - -function DialogDescription({ - className, - ...props -}: React.ComponentProps) { - return ( - - ); -} - -export { - Dialog, - DialogClose, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogOverlay, - DialogPortal, - DialogTitle, - DialogTrigger, -}; diff --git a/apps/dashboard/components/ui/elastic-slider.tsx b/apps/dashboard/components/ui/elastic-slider.tsx deleted file mode 100644 index 0e06f221b4..0000000000 --- a/apps/dashboard/components/ui/elastic-slider.tsx +++ /dev/null @@ -1,224 +0,0 @@ -"use client"; - -import { - motion, - useMotionValue, - useMotionValueEvent, - useTransform, -} from "motion/react"; -import { useCallback, useRef, useState } from "react"; -import { cn } from "@/lib/utils"; -import { - MinusIcon, - PlusIcon, -} from "@databuddy/ui/icons"; - -const MAX_OVERFLOW = 30; - -interface SliderProps { - className?: string; - disabled?: boolean; - leftIcon?: React.ReactNode; - max?: number; - min?: number; - onValueChange?: (value: number) => void; - rightIcon?: React.ReactNode; - showValue?: boolean; - step?: number; - value?: number; -} - -function decay(value: number, maxValue: number): number { - if (maxValue === 0) { - return 0; - } - const entry = value / maxValue; - const sigmoid = 2 * (1 / (1 + Math.exp(-entry)) - 0.5); - return sigmoid * maxValue; -} - -export function Slider({ - value = 0, - onValueChange, - min = 0, - max = 100, - step = 1, - className, - leftIcon = , - rightIcon = , - showValue = true, - disabled = false, -}: SliderProps) { - const [internalValue, setInternalValue] = useState(value); - const sliderRef = useRef(null); - const [region, setRegion] = useState<"left" | "middle" | "right">("middle"); - const [isDragging, setIsDragging] = useState(false); - - const clientX = useMotionValue(0); - const overflow = useMotionValue(0); - - const percentage = ((internalValue - min) / (max - min || 1)) * 100; - - useMotionValueEvent(clientX, "change", (latest: number) => { - if (!(sliderRef.current && isDragging)) { - return; - } - - const { left, right } = sliderRef.current.getBoundingClientRect(); - let newOverflow = 0; - - if (latest < left) { - setRegion("left"); - newOverflow = left - latest; - } else if (latest > right) { - setRegion("right"); - newOverflow = latest - right; - } else { - setRegion("middle"); - } - - overflow.jump(decay(newOverflow, MAX_OVERFLOW)); - }); - - const updateValue = useCallback( - (clientXPos: number) => { - if (!sliderRef.current) { - return; - } - - const { left, width } = sliderRef.current.getBoundingClientRect(); - let newValue = min + ((clientXPos - left) / width) * (max - min); - - if (step > 0) { - newValue = Math.round(newValue / step) * step; - } - - newValue = Math.min(Math.max(newValue, min), max); - setInternalValue(newValue); - onValueChange?.(newValue); - clientX.jump(clientXPos); - }, - [min, max, step, onValueChange, clientX] - ); - - const handlePointerDown = (e: React.PointerEvent) => { - if (disabled) { - return; - } - - setIsDragging(true); - updateValue(e.clientX); - e.currentTarget.setPointerCapture(e.pointerId); - document.body.style.cursor = "grabbing"; - }; - - const handlePointerMove = (e: React.PointerEvent) => { - if (!isDragging || disabled) { - return; - } - updateValue(e.clientX); - }; - - const handlePointerUp = () => { - setIsDragging(false); - setRegion("middle"); - overflow.jump(0); - document.body.style.cursor = ""; - }; - - return ( -
-
- - region === "left" ? -overflow.get() / 2 : 0 - ), - scale: region === "left" ? 1.3 : 1, - }} - > - {leftIcon} - - -
- { - if (!sliderRef.current) { - return 1; - } - const { width } = sliderRef.current.getBoundingClientRect(); - return 1 + overflow.get() / width; - }), - scaleY: useTransform(overflow, [0, MAX_OVERFLOW], [1, 0.7]), - transformOrigin: useTransform(() => { - if (!sliderRef.current) { - return "center"; - } - const { left, width } = - sliderRef.current.getBoundingClientRect(); - return clientX.get() < left + width / 2 ? "right" : "left"; - }), - }} - > -
-
-
- - - -
- - - region === "right" ? overflow.get() / 2 : 0 - ), - scale: region === "right" ? 1.3 : 1, - }} - > - {rightIcon} - -
- - {showValue && ( -
- - {Math.round(internalValue)} - {max === 100 && "%"} - -
- )} -
- ); -} diff --git a/apps/dashboard/components/ui/form-dialog.tsx b/apps/dashboard/components/ui/form-dialog.tsx deleted file mode 100644 index b60a7fd759..0000000000 --- a/apps/dashboard/components/ui/form-dialog.tsx +++ /dev/null @@ -1,147 +0,0 @@ -"use client"; - -import { - Drawer, - DrawerContent, - DrawerDescription, - DrawerFooter, - DrawerHeader, - DrawerTitle, -} from "@/components/ui/drawer"; -import { useIsMobile } from "@/hooks/use-mobile"; -import { Button } from "@databuddy/ui"; -import { Dialog } from "@databuddy/ui/client"; - -interface FormDialogProps { - cancelLabel?: string; - children: React.ReactNode; - description?: string; - icon?: React.ReactNode; - isSubmitting?: boolean; - onOpenChange: (open: boolean) => void; - onSubmit: () => void; - open: boolean; - size?: "sm" | "md" | "lg"; - submitDisabled?: boolean; - submitLabel?: string; - title: string; -} - -export function FormDialog({ - open, - onOpenChange, - title, - description, - children, - onSubmit, - submitLabel = "Save", - cancelLabel = "Cancel", - isSubmitting = false, - submitDisabled = false, - icon, - size = "md", -}: FormDialogProps) { - const isMobile = useIsMobile(); - - const sizeClasses = { - sm: "w-[95vw] max-w-sm sm:w-full", - md: "w-[95vw] max-w-md sm:w-full", - lg: "w-[95vw] max-w-lg sm:w-full", - }; - - const drawerHeaderContent = icon ? ( -
-
- {icon} -
-
- {title} - {description && ( - {description} - )} -
-
- ) : null; - - const formContent = ( -
- {children} -
- ); - - const footerContent = ( - <> - - - - ); - - if (isMobile) { - return ( - - - {icon ? ( - {drawerHeaderContent} - ) : ( - - {title} - {description && ( - {description} - )} - - )} -
{formContent}
- - {footerContent} - -
-
- ); - } - - return ( - - - - {icon ? ( -
-
- {icon} -
-
- {title} - {description && ( - {description} - )} -
-
- ) : ( - <> - {title} - {description && ( - {description} - )} - - )} -
- {formContent} - {footerContent} - -
-
- ); -} diff --git a/apps/dashboard/components/ui/form.tsx b/apps/dashboard/components/ui/form.tsx deleted file mode 100644 index f1dd097369..0000000000 --- a/apps/dashboard/components/ui/form.tsx +++ /dev/null @@ -1,167 +0,0 @@ -"use client"; - -import { type Label as LabelPrimitive, Slot as SlotPrimitive } from "radix-ui"; -import * as React from "react"; - -import { - Controller, - type ControllerProps, - type FieldPath, - type FieldValues, - FormProvider, - useFormContext, - useFormState, -} from "react-hook-form"; -import { cn } from "@/lib/utils"; -import { Field } from "@databuddy/ui"; - -const Form = FormProvider; - -type FormFieldContextValue< - TFieldValues extends FieldValues = FieldValues, - TName extends FieldPath = FieldPath, -> = { - name: TName; -}; - -const FormFieldContext = React.createContext( - {} as FormFieldContextValue -); - -const FormField = < - TFieldValues extends FieldValues = FieldValues, - TName extends FieldPath = FieldPath, ->({ - ...props -}: ControllerProps) => { - return ( - - - - ); -}; - -const useFormField = () => { - const fieldContext = React.useContext(FormFieldContext); - const itemContext = React.useContext(FormItemContext); - const { getFieldState } = useFormContext(); - const formState = useFormState({ name: fieldContext.name }); - const fieldState = getFieldState(fieldContext.name, formState); - - if (!fieldContext) { - throw new Error("useFormField should be used within "); - } - - const { id } = itemContext; - - return { - id, - name: fieldContext.name, - formItemId: `${id}-form-item`, - formDescriptionId: `${id}-form-item-description`, - formMessageId: `${id}-form-item-message`, - ...fieldState, - }; -}; - -type FormItemContextValue = { - id: string; -}; - -const FormItemContext = React.createContext( - {} as FormItemContextValue -); - -function FormItem({ className, ...props }: React.ComponentProps<"div">) { - const id = React.useId(); - - return ( - -
- - ); -} - -function FormLabel({ - className, - ...props -}: React.ComponentProps) { - const { error, formItemId } = useFormField(); - - return ( - - ); -} - -function FormControl({ - ...props -}: React.ComponentProps) { - const { error, formItemId, formDescriptionId, formMessageId } = - useFormField(); - - return ( - - ); -} - -function FormDescription({ className, ...props }: React.ComponentProps<"p">) { - const { formDescriptionId } = useFormField(); - - return ( -

- ); -} - -function FormMessage({ className, ...props }: React.ComponentProps<"p">) { - const { error, formMessageId } = useFormField(); - const body = error ? String(error?.message ?? "") : props.children; - - if (!body) { - return null; - } - - return ( -

- {body} -

- ); -} - -export { - Form, - FormControl, - FormDescription, - FormField, - FormItem, - FormLabel, - FormMessage, - useFormField, -}; diff --git a/apps/dashboard/components/ui/inline-toggle.tsx b/apps/dashboard/components/ui/inline-toggle.tsx deleted file mode 100644 index b8941f6eaa..0000000000 --- a/apps/dashboard/components/ui/inline-toggle.tsx +++ /dev/null @@ -1,60 +0,0 @@ -"use client"; - -import type { ReactNode } from "react"; -import { cn } from "@/lib/utils"; - -type InlineToggleOption = { - value: T; - label: ReactNode; - ariaLabel?: string; -}; - -type InlineToggleProps = { - options: InlineToggleOption[]; - value: T; - onValueChangeAction: (value: T) => void; - className?: string; - disabled?: boolean; -}; - -export function InlineToggle({ - options, - value, - onValueChangeAction, - className, - disabled = false, -}: InlineToggleProps) { - return ( -
- {options.map((option) => { - const isSelected = option.value === value; - return ( - - ); - })} -
- ); -} diff --git a/apps/dashboard/components/ui/input-group.tsx b/apps/dashboard/components/ui/input-group.tsx deleted file mode 100644 index 8ee6478bd6..0000000000 --- a/apps/dashboard/components/ui/input-group.tsx +++ /dev/null @@ -1,169 +0,0 @@ -"use client"; - -import { cva, type VariantProps } from "class-variance-authority"; -import type * as React from "react"; -import { Input } from "@/components/ui/input"; -import { Textarea, type TextareaProps } from "@/components/ui/textarea"; -import { cn } from "@/lib/utils"; -import { Button } from "@databuddy/ui"; - -function InputGroup({ className, ...props }: React.ComponentProps<"div">) { - return ( -
textarea]:h-auto", - - // Variants based on alignment. - "has-[>[data-align=inline-start]]:[&>input]:pl-2", - "has-[>[data-align=inline-end]]:[&>input]:pr-2", - "has-[>[data-align=block-start]]:h-auto has-[>[data-align=block-start]]:flex-col has-[>[data-align=block-start]]:[&>input]:pb-3", - "has-[>[data-align=block-end]]:h-auto has-[>[data-align=block-end]]:flex-col has-[>[data-align=block-end]]:[&>input]:pt-3", - - // Focus state. - "has-[[data-slot=input-group-control]:focus-visible]:border-ring has-[[data-slot=input-group-control]:focus-visible]:ring-[3px] has-[[data-slot=input-group-control]:focus-visible]:ring-ring/50", - - // Error state. - "has-[[data-slot][aria-invalid=true]]:border-destructive has-[[data-slot][aria-invalid=true]]:ring-destructive/20 dark:has-[[data-slot][aria-invalid=true]]:ring-destructive/40", - - className - )} - data-slot="input-group" - role="group" - {...props} - /> - ); -} - -const inputGroupAddonVariants = cva( - "flex h-auto cursor-text select-none items-center justify-center gap-2 py-1.5 font-medium text-muted-foreground text-sm group-data-[disabled=true]/input-group:opacity-50 [&>kbd]:rounded-[calc(var(--radius)-5px)] [&>svg:not([class*='size-'])]:size-4", - { - variants: { - align: { - "inline-start": - "order-first pl-3 has-[>button]:ml-[-0.45rem] has-[>kbd]:ml-[-0.35rem]", - "inline-end": - "order-last pr-3 has-[>button]:mr-[-0.45rem] has-[>kbd]:mr-[-0.35rem]", - "block-start": - "order-first w-full justify-start px-3 pt-3 group-has-[>input]/input-group:pt-2.5 [.border-b]:pb-3", - "block-end": - "order-last w-full justify-start px-3 pb-3 group-has-[>input]/input-group:pb-2.5 [.border-t]:pt-3", - }, - }, - defaultVariants: { - align: "inline-start", - }, - } -); - -function InputGroupAddon({ - className, - align = "inline-start", - ...props -}: React.ComponentProps<"div"> & VariantProps) { - return ( -
{ - if ((e.target as HTMLElement).closest("button")) { - return; - } - e.currentTarget.parentElement?.querySelector("input")?.focus(); - }} - role="group" - {...props} - /> - ); -} - -const inputGroupButtonVariants = cva( - "flex items-center gap-2 text-sm shadow-none", - { - variants: { - size: { - xs: "h-6 gap-1 rounded-[calc(var(--radius)-5px)] px-2 has-[>svg]:px-2 [&>svg:not([class*='size-'])]:size-3.5", - sm: "h-8 gap-1.5 rounded-md px-2.5 has-[>svg]:px-2.5", - "icon-xs": - "size-6 rounded-[calc(var(--radius)-5px)] p-0 has-[>svg]:p-0", - "icon-sm": "size-8 p-0 has-[>svg]:p-0", - }, - }, - defaultVariants: { - size: "xs", - }, - } -); - -function InputGroupButton({ - className, - type = "button", - variant = "ghost", - size = "xs", - ...props -}: Omit, "size"> & - VariantProps) { - return ( -