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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .agents/skills/databuddy-internal/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
3 changes: 1 addition & 2 deletions apps/api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -44,7 +43,7 @@
"jszip": "^3.10.1",
"keypal": "0.2.0",
"lru-cache": "^11.2.7",
"resend": "^4.0.1",
"resend": "^6.20.0",
"supermemory": "^4.17.0",
"svix": "^1.84.1",
"zod": "catalog:"
Expand Down
46 changes: 46 additions & 0 deletions apps/api/src/http/cors.test.ts
Original file line number Diff line number Diff line change
@@ -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");
});
});
37 changes: 37 additions & 0 deletions apps/api/src/http/cors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand All @@ -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" } });
}
3 changes: 2 additions & 1 deletion apps/api/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -106,6 +106,7 @@ const app = new Elysia({ precompile: true })
})
)
.onBeforeHandle(({ request }) => enrichRequestAuthWideEvent(request))
.onRequest(({ request }) => rejectInvalidMcpOrigin(request))
.use(
cors({
credentials: true,
Expand Down
6 changes: 3 additions & 3 deletions apps/api/src/middleware/website-auth.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import {
getApiKeyFromHeader,
hasWebsiteScope,
hasWebsiteScopeForOrganization,
isApiKeyPresent,
} from "@databuddy/api-keys/resolve";
import { auth } from "@databuddy/auth";
Expand Down Expand Up @@ -125,7 +125,7 @@ function isPreflight(request: Request): boolean {
}

async function checkWebsiteAuth(
websiteId: string,
_websiteId: string,
sessionUser: SessionUser | null,
website: Awaited<ReturnType<typeof getCachedWebsite>> | null,
apiKey: Awaited<ReturnType<typeof getApiKeyFromHeader>> | null,
Expand Down Expand Up @@ -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,
Expand Down
2 changes: 1 addition & 1 deletion apps/api/src/routes/discovery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`,
Expand Down
83 changes: 24 additions & 59 deletions apps/api/src/routes/mcp.ts
Original file line number Diff line number Diff line change
@@ -1,32 +1,20 @@
import {
getAccessibleWebsiteIds,
getApiKeyFromHeader,
hasKeyScope,
hasWebsiteScope,
isApiKeyPresent,
} from "@databuddy/api-keys/resolve";
import {
createMcpUnauthorizedResponse,
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<Awaited<ReturnType<typeof getApiKeyFromHeader>>>
) {
return (
hasKeyScope(apiKey, "read:data") ||
getAccessibleWebsiteIds(apiKey).some((websiteId) =>
hasWebsiteScope(apiKey, websiteId, "read:data")
)
);
}

async function handleMcpRequest({
function handleMcpRequest({
request,
user,
apiKey,
Expand All @@ -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,
Expand All @@ -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 {
Expand All @@ -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);
25 changes: 11 additions & 14 deletions apps/api/src/routes/query.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
getApiKeyFromHeader,
hasGlobalAccess,
hasKeyScope,
hasWebsiteScopeForOrganization,
isApiKeyPresent,
} from "@databuddy/api-keys/resolve";
import { and, db, eq, inArray } from "@databuddy/db";
Expand Down Expand Up @@ -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;
}
Expand Down
Loading
Loading