diff --git a/.github/workflows/docker-build.yml b/.github/workflows/docker-build.yml index fcfac62d3..666b5171f 100644 --- a/.github/workflows/docker-build.yml +++ b/.github/workflows/docker-build.yml @@ -36,6 +36,19 @@ jobs: lint-and-test: runs-on: ubuntu-latest services: + postgres: + image: postgres:16-alpine + ports: + - 5432:5432 + env: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + POSTGRES_DB: postgres + options: >- + --health-cmd "pg_isready -U postgres" + --health-interval 5s + --health-timeout 3s + --health-retries 20 redis: image: redis:7-alpine ports: @@ -45,6 +58,20 @@ jobs: --health-interval 5s --health-timeout 3s --health-retries 20 + clickhouse: + image: clickhouse/clickhouse-server:26.1.3.52 + ports: + - 8123:8123 + env: + CLICKHOUSE_DB: openpanel + CLICKHOUSE_USER: default + CLICKHOUSE_PASSWORD: "" + CLICKHOUSE_DEFAULT_ACCESS_MANAGEMENT: 1 + options: >- + --health-cmd "wget -qO- http://localhost:8123/ping || exit 1" + --health-interval 5s + --health-timeout 3s + --health-retries 20 steps: - uses: actions/checkout@v4 @@ -75,15 +102,27 @@ jobs: - name: Codegen run: pnpm codegen + - name: Migrate database + run: pnpm migrate:deploy + env: + DATABASE_URL: postgresql://postgres:postgres@localhost:5432/postgres?schema=public + DATABASE_URL_DIRECT: postgresql://postgres:postgres@localhost:5432/postgres?schema=public + CLICKHOUSE_URL: http://localhost:8123/openpanel + SELF_HOSTED: "true" + + - name: Run tests + run: pnpm test + env: + DATABASE_URL: postgresql://postgres:postgres@localhost:5432/postgres?schema=public + CLICKHOUSE_URL: http://localhost:8123/openpanel + REDIS_URL: redis://localhost:6379 + # - name: Run Biome # run: pnpm lint # - name: Run TypeScript checks # run: pnpm typecheck - # - name: Run tests - # run: pnpm test - build-and-push-api: permissions: packages: write diff --git a/apps/api/package.json b/apps/api/package.json index 5f63bc5d9..aabf084c0 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -9,6 +9,8 @@ "build": "rm -rf dist && tsdown", "gen:bots": "jiti scripts/get-bots.ts && biome format --write src/bots/bots.ts", "test:manage": "jiti scripts/test-manage-api.ts", + "test": "vitest", + "test:run": "vitest run", "typecheck": "tsc --noEmit" }, "dependencies": { @@ -18,6 +20,8 @@ "@fastify/cookie": "^11.0.2", "@fastify/cors": "^11.1.0", "@fastify/rate-limit": "^10.3.0", + "@fastify/swagger": "^9.7.0", + "@fastify/swagger-ui": "^5.2.5", "@fastify/websocket": "^11.2.0", "@node-rs/argon2": "^2.0.2", "@openpanel/auth": "workspace:^", @@ -28,6 +32,7 @@ "@openpanel/integrations": "workspace:^", "@openpanel/json": "workspace:*", "@openpanel/logger": "workspace:*", + "@openpanel/mcp": "workspace:*", "@openpanel/payments": "workspace:*", "@openpanel/queue": "workspace:*", "@openpanel/redis": "workspace:*", @@ -39,6 +44,7 @@ "fastify": "^5.6.1", "fastify-metrics": "^12.1.0", "fastify-raw-body": "^5.0.0", + "fastify-zod-openapi": "^5.6.1", "groupmq": "catalog:", "jsonwebtoken": "^9.0.2", "ramda": "^0.29.1", @@ -63,6 +69,7 @@ "@types/ws": "^8.5.14", "js-yaml": "^4.1.0", "tsdown": "0.14.2", - "typescript": "catalog:" + "typescript": "catalog:", + "vitest": "^1.0.0" } } \ No newline at end of file diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts new file mode 100644 index 000000000..442b0a6c3 --- /dev/null +++ b/apps/api/src/app.ts @@ -0,0 +1,268 @@ +/** biome-ignore-all lint/suspicious/useAwait: fastify need async or done callbacks */ +import compress from '@fastify/compress'; +import cookie from '@fastify/cookie'; +import cors, { type FastifyCorsOptions } from '@fastify/cors'; +import fastifySwagger from '@fastify/swagger'; +import fastifySwaggerUI from '@fastify/swagger-ui'; +import { + EMPTY_SESSION, + type SessionValidationResult, + decodeSessionToken, + validateSessionToken, +} from '@openpanel/auth'; +import { generateId } from '@openpanel/common'; +import { type IServiceClientWithProject, runWithAlsSession } from '@openpanel/db'; +import type { AppRouter } from '@openpanel/trpc'; +import { appRouter, createContext } from '@openpanel/trpc'; +import type { FastifyTRPCPluginOptions } from '@trpc/server/adapters/fastify'; +import { fastifyTRPCPlugin } from '@trpc/server/adapters/fastify'; +import type { FastifyBaseLogger, FastifyInstance, FastifyRequest } from 'fastify'; +import Fastify from 'fastify'; +import metricsPlugin from 'fastify-metrics'; +import { + fastifyZodOpenApiPlugin, + fastifyZodOpenApiTransformers, + serializerCompiler, + validatorCompiler, +} from 'fastify-zod-openapi'; +import { + healthcheck, + liveness, + readiness, +} from './controllers/healthcheck.controller'; +import { ipHook } from './hooks/ip.hook'; +import { requestIdHook } from './hooks/request-id.hook'; +import { requestLoggingHook } from './hooks/request-logging.hook'; +import { timestampHook } from './hooks/timestamp.hook'; +import aiRouter from './routes/ai.router'; +import eventRouter from './routes/event.router'; +import exportRouter from './routes/export.router'; +import gscCallbackRouter from './routes/gsc-callback.router'; +import importRouter from './routes/import.router'; +import insightsRouter from './routes/insights.router'; +import liveRouter from './routes/live.router'; +import manageRouter from './routes/manage.router'; +import mcpRouter from './routes/mcp.router'; +import miscRouter from './routes/misc.router'; +import oauthRouter from './routes/oauth-callback.router'; +import profileRouter from './routes/profile.router'; +import trackRouter from './routes/track.router'; +import webhookRouter from './routes/webhook.router'; +import { HttpError } from './utils/errors'; +import { logger } from './utils/logger'; + +declare module 'fastify' { + interface FastifyRequest { + client: IServiceClientWithProject | null; + clientIp: string; + clientIpHeader: string; + timestamp?: number; + session: SessionValidationResult; + } +} + +export interface BuildAppOptions { + /** Set to true when running under Vitest — disables logging and Prometheus metrics */ + testing?: boolean; +} + +export async function buildApp( + options: BuildAppOptions = {}, +): Promise { + const { testing = false } = options; + + const fastify = Fastify({ + maxParamLength: 15_000, + bodyLimit: 1_048_576 * 500, + disableRequestLogging: true, + genReqId: (req) => + req.headers['request-id'] + ? String(req.headers['request-id']) + : generateId(), + ...(testing + ? { logger: false } + : { loggerInstance: logger as unknown as FastifyBaseLogger }), + }); + + fastify.setValidatorCompiler(validatorCompiler); + fastify.setSerializerCompiler(serializerCompiler); + + fastify.register(cors, () => { + return ( + req: FastifyRequest, + callback: (error: Error | null, options: FastifyCorsOptions) => void, + ) => { + const corsPaths = ['/trpc', '/live', '/webhook', '/oauth', '/misc', '/ai', '/mcp']; + const isPrivatePath = corsPaths.some((p) => req.url.startsWith(p)); + + if (isPrivatePath) { + const allowedOrigins = [ + process.env.DASHBOARD_URL || process.env.NEXT_PUBLIC_DASHBOARD_URL, + ...(process.env.API_CORS_ORIGINS?.split(',') ?? []), + ].filter(Boolean); + const origin = req.headers.origin; + const isAllowed = origin && allowedOrigins.includes(origin); + return callback(null, { origin: isAllowed ? origin : false, credentials: true }); + } + + return callback(null, { origin: '*', maxAge: 86_400 * 7 }); + }; + }); + + await fastify.register(import('fastify-raw-body'), { global: false }); + + fastify.addHook('onRequest', requestIdHook); + fastify.addHook('onRequest', timestampHook); + fastify.addHook('onRequest', ipHook); + fastify.addHook('onResponse', requestLoggingHook); + + fastify.register(compress, { global: false, encodings: ['gzip', 'deflate'] }); + + // Dashboard API + fastify.register(async (instance) => { + instance.register(cookie, { + secret: process.env.COOKIE_SECRET ?? '', + hook: 'onRequest', + parseOptions: {}, + }); + + instance.addHook('onRequest', async (req) => { + if (req.cookies?.session) { + try { + const sessionId = decodeSessionToken(req.cookies?.session); + const session = await runWithAlsSession(sessionId, () => + validateSessionToken(req.cookies.session), + ); + req.session = session; + } catch { + req.session = EMPTY_SESSION; + } + } else if (process.env.DEMO_USER_ID) { + try { + const session = await runWithAlsSession('1', () => + validateSessionToken(null), + ); + req.session = session; + } catch { + req.session = EMPTY_SESSION; + } + } else { + req.session = EMPTY_SESSION; + } + }); + + instance.register(fastifyTRPCPlugin, { + prefix: '/trpc', + trpcOptions: { + router: appRouter, + createContext, + onError(ctx) { + if (ctx.error.code === 'UNAUTHORIZED' && ctx.path === 'organization.list') { + return; + } + ctx.req.log.error('trpc error', { + error: ctx.error, + path: ctx.path, + input: ctx.input, + type: ctx.type, + session: ctx.ctx?.session, + }); + }, + } satisfies FastifyTRPCPluginOptions['trpcOptions'], + }); + + instance.register(liveRouter, { prefix: '/live' }); + instance.register(webhookRouter, { prefix: '/webhook' }); + instance.register(oauthRouter, { prefix: '/oauth' }); + instance.register(gscCallbackRouter, { prefix: '/gsc' }); + instance.register(miscRouter, { prefix: '/misc' }); + instance.register(aiRouter, { prefix: '/ai' }); + instance.register(mcpRouter, { prefix: '/mcp' }); + }); + + // Public API + fastify.register(async (instance) => { + await instance.register(fastifyZodOpenApiPlugin); + await instance.register(fastifySwagger, { + openapi: { + info: { title: 'OpenPanel API', version: '1.0.0' }, + openapi: '3.1.0', + tags: [ + { name: 'Track', description: 'Track events and sessions' }, + { name: 'Profile', description: 'Identify and update user profiles' }, + { name: 'Export', description: 'Export data' }, + { name: 'Import', description: 'Import historical data' }, + { name: 'Insights', description: 'Query analytics data' }, + { name: 'Manage', description: 'Manage projects and clients' }, + { name: 'Event', description: 'Legacy event ingestion (deprecated, use /track)' }, + ], + }, + ...fastifyZodOpenApiTransformers, + transform(args) { + if (args.url === '/metrics') { + return { schema: { ...args.schema, hide: true }, url: args.url }; + } + return fastifyZodOpenApiTransformers.transform(args); + }, + }); + await instance.register(fastifySwaggerUI, { routePrefix: '/documentation' }); + + // Prometheus metrics: skip in tests (causes global state conflicts across test runs) + if (!testing) { + instance.register(metricsPlugin, { endpoint: '/metrics' }); + } + + instance.register(eventRouter, { prefix: '/event' }); + instance.register(profileRouter, { prefix: '/profile' }); + instance.register(exportRouter, { prefix: '/export' }); + instance.register(importRouter, { prefix: '/import' }); + instance.register(insightsRouter, { prefix: '/insights' }); + instance.register(trackRouter, { prefix: '/track' }); + instance.register(manageRouter, { prefix: '/manage' }); + + instance.get('/healthcheck', { schema: { hide: true } }, healthcheck); + instance.get('/healthz/live', { schema: { hide: true } }, liveness); + instance.get('/healthz/ready', { schema: { hide: true } }, readiness); + instance.get('/', { schema: { hide: true } }, (_request, reply) => + reply.send({ status: 'ok', message: 'Successfully running OpenPanel.dev API' }), + ); + }); + + const SKIP_LOG_ERRORS = ['UNAUTHORIZED', 'FST_ERR_CTP_INVALID_MEDIA_TYPE']; + fastify.setErrorHandler((error, request, reply) => { + if (error.statusCode === 429) { + return reply.status(429).send({ + status: 429, + error: 'Too Many Requests', + message: 'You have exceeded the rate limit for this endpoint.', + }); + } + + if (error instanceof HttpError) { + if (!SKIP_LOG_ERRORS.includes(error.code)) { + request.log.error('internal server error', { error }); + } + if (process.env.NODE_ENV === 'production' && error.status === 500) { + return reply.status(500).send('Internal server error'); + } + return reply.status(error.status).send({ + status: error.status, + error: error.error, + message: error.message, + }); + } + + if (!SKIP_LOG_ERRORS.includes(error.code)) { + request.log.error('request error', { error }); + } + + const status = error?.statusCode ?? 500; + if (process.env.NODE_ENV === 'production' && status === 500) { + return reply.status(500).send('Internal server error'); + } + + return reply.status(status).send({ status, error, message: error.message }); + }); + + return fastify; +} diff --git a/apps/api/src/controllers/export.controller.ts b/apps/api/src/controllers/export.controller.ts index d568439ba..d1f8de94a 100644 --- a/apps/api/src/controllers/export.controller.ts +++ b/apps/api/src/controllers/export.controller.ts @@ -12,7 +12,6 @@ import { zChartEvent, zReport } from '@openpanel/validation'; import type { FastifyReply, FastifyRequest } from 'fastify'; import { z } from 'zod'; import { HttpError } from '@/utils/errors'; -import { parseQueryString } from '@/utils/parse-zod-query-string'; async function getProjectId( request: FastifyRequest<{ @@ -61,7 +60,7 @@ async function getProjectId( return projectId; } -const eventsScheme = z.object({ +export const eventsScheme = z.object({ project_id: z.string().optional(), projectId: z.string().optional(), profileId: z.string().optional(), @@ -96,39 +95,22 @@ export async function events( }>, reply: FastifyReply ) { - const query = eventsScheme.safeParse(request.query); - - if (query.success === false) { - return reply.status(400).send({ - error: 'Bad Request', - message: 'Invalid query parameters', - details: query.error.errors, - }); - } - const projectId = await getProjectId(request); - const limit = query.data.limit; - const page = Math.max(query.data.page, 1); + const { limit, page: rawPage, event, start, end, profileId, includes } = request.query; const take = Math.max(Math.min(limit, 1000), 1); - const cursor = page - 1; + const cursor = Math.max(rawPage, 1) - 1; const options: GetEventListOptions = { projectId, - events: (Array.isArray(query.data.event) - ? query.data.event - : [query.data.event] - ).filter((s): s is string => typeof s === 'string'), - startDate: query.data.start ? new Date(query.data.start) : undefined, - endDate: query.data.end ? new Date(query.data.end) : undefined, + events: (Array.isArray(event) ? event : [event]).filter((s): s is string => typeof s === 'string'), + startDate: start ? new Date(start) : undefined, + endDate: end ? new Date(end) : undefined, cursor, take, - profileId: query.data.profileId, + profileId, select: { profile: false, meta: false, - ...query.data.includes?.reduce( - (acc, key) => ({ ...acc, [key]: true }), - {} - ), + ...includes?.reduce((acc, key) => ({ ...acc, [key]: true }), {}), }, }; @@ -148,7 +130,7 @@ export async function events( }); } -const chartSchemeFull = zReport +export const chartSchemeFull = zReport .pick({ breakdowns: true, interval: true, @@ -185,23 +167,13 @@ const chartSchemeFull = zReport export async function charts( request: FastifyRequest<{ - Querystring: Record; + Querystring: z.infer; }>, reply: FastifyReply ) { - const query = chartSchemeFull.safeParse(parseQueryString(request.query)); - - if (query.success === false) { - return reply.status(400).send({ - error: 'Bad Request', - message: 'Invalid query parameters', - details: query.error.errors, - }); - } - const projectId = await getProjectId(request); const { timezone } = await getSettingsForProject(projectId); - const { events, series, ...rest } = query.data; + const { events, series, ...rest } = request.query; // Use series if available, otherwise fall back to events (backward compat) const eventSeries = (series ?? events ?? []).map((event: any) => ({ diff --git a/apps/api/src/controllers/insights.controller.ts b/apps/api/src/controllers/insights.controller.ts index b8051ae38..6ad02a44c 100644 --- a/apps/api/src/controllers/insights.controller.ts +++ b/apps/api/src/controllers/insights.controller.ts @@ -1,64 +1,215 @@ -import { parseQueryString } from '@/utils/parse-zod-query-string'; import { getDefaultIntervalByDates } from '@openpanel/constants'; +import type { IServiceClientWithProject } from '@openpanel/db'; import { eventBuffer, + findGroupsCore, + findProfilesCore, + getAnalyticsOverviewCore, getChartStartEndDate, + getEngagementCore, + getEntryExitPagesCore, + getEventPropertyValuesCore, + getFunnelCore, + getGroupCore, + getPagePerformanceCore, + getProfileMetricsCore, + getProfileSessionsCore, + getProfileWithEvents, + getReportDataCore, + getRetentionCohortCore, + getRollingActiveUsersCore, getSettingsForProject, + getTopPagesCore, + getTrafficBreakdownCore, + getUserFlowCore, + getWeeklyRetentionSeriesCore, + gscGetCannibalizationCore, + gscGetOverviewCore, + gscGetPageDetailsCore, + gscGetQueryDetailsCore, + gscGetQueryOpportunitiesCore, + gscGetTopPagesCore, + gscGetTopQueriesCore, + listDashboardsCore, + listEventNamesCore, + listEventPropertiesCore, + listGroupTypesCore, + listReportsCore, overviewService, + queryEventsCore, + querySessionsCore, + resolveDateRange, + resolveClientProjectId, + type TrafficColumn, } from '@openpanel/db'; import { zChartEventFilter, zRange } from '@openpanel/validation'; import type { FastifyReply, FastifyRequest } from 'fastify'; import { z } from 'zod'; -const zGetMetricsQuery = z.object({ +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +export const zDateRange = z.object({ + startDate: z.string().optional(), + endDate: z.string().optional(), + range: zRange.optional(), +}); + +type DateRangeInput = z.infer; + +type RequestWithProjectParam = FastifyRequest<{ + Params: { projectId?: string }; +}>; + +function getProjectId(req: RequestWithProjectParam): Promise { + const client = req.client!; + return resolveClientProjectId({ + clientType: client.type === 'root' ? 'root' : 'read', + clientProjectId: client.projectId ?? null, + organizationId: client.organizationId, + inputProjectId: req.params.projectId, + }); +} + +function getOrgId(req: RequestWithProjectParam): string { + return req.client!.organizationId; +} + +async function resolveDates( + projectId: string, + data: DateRangeInput +): Promise<{ startDate: string; endDate: string }> { + if (!data.range || data.startDate) { + return resolveDateRange(data.startDate, data.endDate); + } + const { timezone } = await getSettingsForProject(projectId); + return getChartStartEndDate( + { startDate: data.startDate, endDate: data.endDate, range: data.range! }, + timezone + ); +} + +// --------------------------------------------------------------------------- +// Analytics — overview +// --------------------------------------------------------------------------- + +export const zOverviewQuery = zDateRange.extend({ + interval: z.enum(['hour', 'day', 'week', 'month']).optional(), +}); + +export async function getOverview( + req: FastifyRequest<{ + Params: { projectId?: string }; + Querystring: z.infer; + }>, + reply: FastifyReply +) { + const projectId = await getProjectId(req as RequestWithProjectParam); + const { startDate, endDate } = await resolveDates(projectId, req.query); + return reply.send( + await getAnalyticsOverviewCore({ projectId, startDate, endDate, interval: req.query.interval }) + ); +} + +// --------------------------------------------------------------------------- +// Analytics — metrics (legacy) +// --------------------------------------------------------------------------- + +export const zGetMetricsQuery = z.object({ startDate: z.string().nullish(), endDate: z.string().nullish(), range: zRange.default('7d'), filters: z.array(zChartEventFilter).default([]), }); -// Website stats - main metrics overview + export async function getMetrics( - request: FastifyRequest<{ + req: FastifyRequest<{ Params: { projectId: string }; Querystring: z.infer; }>, - reply: FastifyReply, + reply: FastifyReply ) { - const { timezone } = await getSettingsForProject(request.params.projectId); - const parsed = zGetMetricsQuery.safeParse(parseQueryString(request.query)); - - if (parsed.success === false) { - return reply.status(400).send({ - error: 'Bad Request', - message: 'Invalid query parameters', - details: parsed.error, - }); - } - - const { startDate, endDate } = getChartStartEndDate(parsed.data, timezone); - + const projectId = await getProjectId(req as RequestWithProjectParam); + const { timezone } = await getSettingsForProject(projectId); + const { startDate, endDate } = getChartStartEndDate(req.query, timezone); reply.send( await overviewService.getMetrics({ - projectId: request.params.projectId, - filters: parsed.data.filters, - startDate: startDate, - endDate: endDate, + projectId, + filters: req.query.filters, + startDate, + endDate, interval: getDefaultIntervalByDates(startDate, endDate) ?? 'day', timezone, - }), + }) ); } -// Live visitors (real-time) +// --------------------------------------------------------------------------- +// Analytics — live visitors +// --------------------------------------------------------------------------- + export async function getLiveVisitors( - request: FastifyRequest<{ - Params: { projectId: string }; + req: FastifyRequest<{ Params: { projectId: string } }>, + reply: FastifyReply +) { + const projectId = await getProjectId(req as RequestWithProjectParam); + reply.send({ visitors: await eventBuffer.getActiveVisitorCount(projectId) }); +} + +// --------------------------------------------------------------------------- +// Analytics — active users +// --------------------------------------------------------------------------- + +export const zActiveUsersQuery = z.object({ + days: z.number().int().min(1).max(90).default(7), +}); + +export async function getActiveUsers( + req: FastifyRequest<{ + Params: { projectId?: string }; + Querystring: z.infer; }>, - reply: FastifyReply, + reply: FastifyReply ) { - reply.send({ - visitors: await eventBuffer.getActiveVisitorCount(request.params.projectId), - }); + const projectId = await getProjectId(req as RequestWithProjectParam); + return reply.send(await getRollingActiveUsersCore({ projectId, days: req.query.days })); +} + +// --------------------------------------------------------------------------- +// Analytics — retention +// --------------------------------------------------------------------------- + +export async function getRetentionSeries( + req: FastifyRequest<{ Params: { projectId?: string } }>, + reply: FastifyReply +) { + const projectId = await getProjectId(req as RequestWithProjectParam); + return reply.send(await getWeeklyRetentionSeriesCore(projectId)); +} + +export async function getRetentionCohort( + req: FastifyRequest<{ Params: { projectId?: string } }>, + reply: FastifyReply +) { + const projectId = await getProjectId(req as RequestWithProjectParam); + return reply.send(await getRetentionCohortCore(projectId)); +} + +// --------------------------------------------------------------------------- +// Analytics — pages +// --------------------------------------------------------------------------- + +export async function getTopPages( + req: FastifyRequest<{ + Params: { projectId?: string }; + Querystring: z.infer; + }>, + reply: FastifyReply +) { + const projectId = await getProjectId(req as RequestWithProjectParam); + const { startDate, endDate } = await resolveDates(projectId, req.query); + return reply.send(await getTopPagesCore({ projectId, startDate, endDate })); } export const zGetTopPagesQuery = z.object({ @@ -70,105 +221,488 @@ export const zGetTopPagesQuery = z.object({ limit: z.number().default(10), }); -// Page views with top pages export async function getPages( - request: FastifyRequest<{ + req: FastifyRequest<{ Params: { projectId: string }; Querystring: z.infer; + }> +) { + const projectId = await getProjectId(req as RequestWithProjectParam); + const { timezone } = await getSettingsForProject(projectId); + const { startDate, endDate } = getChartStartEndDate(req.query, timezone); + return overviewService.getTopPages({ projectId, filters: req.query.filters, startDate, endDate, timezone }); +} + +export const zEntryExitQuery = zDateRange.extend({ + mode: z.enum(['entry', 'exit']).default('entry'), +}); + +export async function getEntryExitPages( + req: FastifyRequest<{ + Params: { projectId?: string }; + Querystring: z.infer; }>, - reply: FastifyReply, -) { - const { timezone } = await getSettingsForProject(request.params.projectId); - const { startDate, endDate } = getChartStartEndDate(request.query, timezone); - const parsed = zGetTopPagesQuery.safeParse(parseQueryString(request.query)); - - if (parsed.success === false) { - return reply.status(400).send({ - error: 'Bad Request', - message: 'Invalid query parameters', - details: parsed.error, - }); - } + reply: FastifyReply +) { + const projectId = await getProjectId(req as RequestWithProjectParam); + const { startDate, endDate } = await resolveDates(projectId, req.query); + return reply.send(await getEntryExitPagesCore({ projectId, startDate, endDate, mode: req.query.mode })); +} - return overviewService.getTopPages({ - projectId: request.params.projectId, - filters: parsed.data.filters, - startDate: startDate, - endDate: endDate, - timezone, - }); +export const zPagePerfQuery = zDateRange.extend({ + search: z.string().optional(), + sortBy: z.enum(['sessions', 'pageviews', 'bounce_rate', 'avg_duration']).optional(), + sortOrder: z.enum(['asc', 'desc']).optional(), + limit: z.number().int().min(1).max(500).default(50), +}); + +export async function getPagePerformance( + req: FastifyRequest<{ + Params: { projectId?: string }; + Querystring: z.infer; + }>, + reply: FastifyReply +) { + const projectId = await getProjectId(req as RequestWithProjectParam); + const { startDate, endDate } = await resolveDates(projectId, req.query); + return reply.send(await getPagePerformanceCore({ projectId, startDate, endDate, ...req.query })); } -const zGetOverviewGenericQuery = z.object({ +// --------------------------------------------------------------------------- +// Analytics — overview generic (legacy) +// --------------------------------------------------------------------------- + +export const overviewColumns = [ + 'referrer', + 'referrer_name', + 'referrer_type', + 'utm_source', + 'utm_medium', + 'utm_campaign', + 'utm_term', + 'utm_content', + 'region', + 'country', + 'city', + 'device', + 'brand', + 'model', + 'browser', + 'browser_version', + 'os', + 'os_version', +] as const; + +export type OverviewColumn = (typeof overviewColumns)[number]; + +export const zOverviewGenericQuerystring = z.object({ filters: z.array(zChartEventFilter).default([]), startDate: z.string().nullish(), endDate: z.string().nullish(), range: zRange.default('7d'), - column: z.enum([ - // Referrers - 'referrer', - 'referrer_name', - 'referrer_type', - 'utm_source', - 'utm_medium', - 'utm_campaign', - 'utm_term', - 'utm_content', - // Geo - 'region', - 'country', - 'city', - // Device - 'device', - 'brand', - 'model', - 'browser', - 'browser_version', - 'os', - 'os_version', - ]), cursor: z.number().optional(), limit: z.number().default(10), }); -export function getOverviewGeneric( - column: z.infer['column'], -) { +export function getOverviewGeneric(column: OverviewColumn) { return async ( - request: FastifyRequest<{ - Params: { projectId: string; key: string }; - Querystring: z.infer; + req: FastifyRequest<{ + Params: { projectId: string }; + Querystring: z.infer; }>, - reply: FastifyReply, + reply: FastifyReply ) => { - const { timezone } = await getSettingsForProject(request.params.projectId); - const { startDate, endDate } = getChartStartEndDate( - request.query, - timezone, - ); - const parsed = zGetOverviewGenericQuery.safeParse({ - ...parseQueryString(request.query), - column, - }); - - if (parsed.success === false) { - return reply.status(400).send({ - error: 'Bad Request', - message: 'Invalid query parameters', - details: parsed.error, - }); - } - - // TODO: Implement overview generic endpoint + const projectId = await getProjectId(req as RequestWithProjectParam); + const { timezone } = await getSettingsForProject(projectId); + const { startDate, endDate } = getChartStartEndDate(req.query, timezone); reply.send( await overviewService.getTopGeneric({ column, - projectId: request.params.projectId, - filters: parsed.data.filters, - startDate: startDate, - endDate: endDate, + projectId, + filters: req.query.filters, + startDate, + endDate, timezone, - }), + }) ); }; } + +// --------------------------------------------------------------------------- +// Analytics — funnel +// --------------------------------------------------------------------------- + +export const zFunnelQuery = zDateRange.extend({ + steps: z + .union([z.array(z.string()), z.string().transform((s) => [s])]) + .refine((a) => a.length >= 2 && a.length <= 10, { + message: 'steps must have between 2 and 10 items', + }), + windowHours: z.number().int().min(1).max(720).default(24), + groupBy: z.enum(['session_id', 'profile_id']).default('session_id'), +}); + +export async function getFunnel( + req: FastifyRequest<{ + Params: { projectId?: string }; + Querystring: z.infer; + }>, + reply: FastifyReply +) { + const projectId = await getProjectId(req as RequestWithProjectParam); + const { startDate, endDate } = await resolveDates(projectId, req.query); + return reply.send( + await getFunnelCore({ projectId, startDate, endDate, steps: req.query.steps, windowHours: req.query.windowHours, groupBy: req.query.groupBy }) + ); +} + +// --------------------------------------------------------------------------- +// Analytics — traffic +// --------------------------------------------------------------------------- + +const referrerColumns = ['referrer_name', 'referrer_type', 'referrer', 'utm_source', 'utm_medium', 'utm_campaign'] as const; +const geoColumns = ['country', 'region', 'city'] as const; +const deviceColumns = ['device', 'browser', 'os'] as const; + +export const zReferrerQuery = zDateRange.extend({ breakdown: z.enum(referrerColumns).default('referrer_name') }); +export const zGeoQuery = zDateRange.extend({ breakdown: z.enum(geoColumns).default('country') }); +export const zDeviceQuery = zDateRange.extend({ breakdown: z.enum(deviceColumns).default('device') }); + +export async function getTrafficReferrers( + req: FastifyRequest<{ Params: { projectId?: string }; Querystring: z.infer }>, + reply: FastifyReply +) { + const projectId = await getProjectId(req as RequestWithProjectParam); + const { startDate, endDate } = await resolveDates(projectId, req.query); + return reply.send(await getTrafficBreakdownCore({ projectId, startDate, endDate, column: req.query.breakdown as TrafficColumn })); +} + +export async function getTrafficGeo( + req: FastifyRequest<{ Params: { projectId?: string }; Querystring: z.infer }>, + reply: FastifyReply +) { + const projectId = await getProjectId(req as RequestWithProjectParam); + const { startDate, endDate } = await resolveDates(projectId, req.query); + return reply.send(await getTrafficBreakdownCore({ projectId, startDate, endDate, column: req.query.breakdown as TrafficColumn })); +} + +export async function getTrafficDevices( + req: FastifyRequest<{ Params: { projectId?: string }; Querystring: z.infer }>, + reply: FastifyReply +) { + const projectId = await getProjectId(req as RequestWithProjectParam); + const { startDate, endDate } = await resolveDates(projectId, req.query); + return reply.send(await getTrafficBreakdownCore({ projectId, startDate, endDate, column: req.query.breakdown as TrafficColumn })); +} + +// --------------------------------------------------------------------------- +// Analytics — user flow & engagement +// --------------------------------------------------------------------------- + +export const zUserFlowQuery = zDateRange.extend({ + startEvent: z.string(), + endEvent: z.string().optional(), + mode: z.enum(['after', 'before', 'between']).default('after'), + steps: z.number().int().min(2).max(10).default(5), + exclude: z.union([z.array(z.string()), z.string().transform((s) => [s])]).optional(), + include: z.union([z.array(z.string()), z.string().transform((s) => [s])]).optional(), +}); + +export async function getUserFlow( + req: FastifyRequest<{ Params: { projectId?: string }; Querystring: z.infer }>, + reply: FastifyReply +) { + const projectId = await getProjectId(req as RequestWithProjectParam); + const { startDate, endDate } = await resolveDates(projectId, req.query); + return reply.send(await getUserFlowCore({ projectId, startDate, endDate, ...req.query })); +} + +export async function getEngagement( + req: FastifyRequest<{ Params: { projectId?: string } }>, + reply: FastifyReply +) { + const projectId = await getProjectId(req as RequestWithProjectParam); + return reply.send(await getEngagementCore(projectId)); +} + +// --------------------------------------------------------------------------- +// Events +// --------------------------------------------------------------------------- + +export const zEventsQuery = zDateRange.extend({ + eventNames: z.union([z.array(z.string()), z.string().transform((s) => [s])]).optional(), + path: z.string().optional(), + country: z.string().optional(), + city: z.string().optional(), + device: z.string().optional(), + browser: z.string().optional(), + os: z.string().optional(), + referrer: z.string().optional(), + referrerName: z.string().optional(), + referrerType: z.string().optional(), + profileId: z.string().optional(), + properties: z.record(z.string(), z.string()).optional(), + limit: z.number().int().min(1).max(100).default(20), +}); + +export async function queryEvents( + req: FastifyRequest<{ Params: { projectId?: string }; Querystring: z.infer }>, + reply: FastifyReply +) { + const projectId = await getProjectId(req as RequestWithProjectParam); + return reply.send(await queryEventsCore({ projectId, ...req.query })); +} + +export async function listEventNames( + req: FastifyRequest<{ Params: { projectId?: string } }>, + reply: FastifyReply +) { + const projectId = await getProjectId(req as RequestWithProjectParam); + return reply.send(await listEventNamesCore(projectId)); +} + +export const zEventPropertiesQuery = z.object({ eventName: z.string().optional() }); + +export async function listEventProperties( + req: FastifyRequest<{ Params: { projectId?: string }; Querystring: z.infer }>, + reply: FastifyReply +) { + const projectId = await getProjectId(req as RequestWithProjectParam); + return reply.send(await listEventPropertiesCore({ projectId, eventName: req.query.eventName })); +} + +export const zPropertyValuesQuery = z.object({ eventName: z.string(), propertyKey: z.string() }); + +export async function getEventPropertyValues( + req: FastifyRequest<{ Params: { projectId?: string }; Querystring: z.infer }>, + reply: FastifyReply +) { + const projectId = await getProjectId(req as RequestWithProjectParam); + return reply.send(await getEventPropertyValuesCore({ projectId, ...req.query })); +} + +// --------------------------------------------------------------------------- +// Profiles +// --------------------------------------------------------------------------- + +export const zProfilesQuery = z.object({ + name: z.string().optional(), + email: z.string().optional(), + country: z.string().optional(), + city: z.string().optional(), + device: z.string().optional(), + browser: z.string().optional(), + inactiveDays: z.number().int().min(1).optional(), + minSessions: z.number().int().min(1).optional(), + performedEvent: z.string().optional(), + sortOrder: z.enum(['asc', 'desc']).default('desc'), + limit: z.number().int().min(1).max(100).default(20), +}); + +export async function findProfiles( + req: FastifyRequest<{ Params: { projectId?: string }; Querystring: z.infer }>, + reply: FastifyReply +) { + const projectId = await getProjectId(req as RequestWithProjectParam); + return reply.send(await findProfilesCore({ projectId, ...req.query })); +} + +export const zGetProfileQuery = z.object({ eventLimit: z.number().int().min(1).max(100).default(20) }); + +export async function getProfile( + req: FastifyRequest<{ Params: { projectId?: string; profileId: string }; Querystring: z.infer }>, + reply: FastifyReply +) { + const projectId = await getProjectId(req as RequestWithProjectParam); + const result = await getProfileWithEvents(projectId, req.params.profileId, req.query.eventLimit); + if (!result.profile) { + return reply.status(404).send({ error: 'Profile not found', profileId: req.params.profileId }); + } + return reply.send({ profile: result.profile, recentEvents: result.recent_events }); +} + +export const zProfileSessionsQuery = z.object({ limit: z.number().int().min(1).max(100).default(20) }); + +export async function getProfileSessions( + req: FastifyRequest<{ Params: { projectId?: string; profileId: string }; Querystring: z.infer }>, + reply: FastifyReply +) { + const projectId = await getProjectId(req as RequestWithProjectParam); + return reply.send(await getProfileSessionsCore(projectId, req.params.profileId, req.query.limit)); +} + +export async function getProfileMetrics( + req: FastifyRequest<{ Params: { projectId?: string; profileId: string } }>, + reply: FastifyReply +) { + const projectId = await getProjectId(req as RequestWithProjectParam); + return reply.send(await getProfileMetricsCore({ projectId, profileId: req.params.profileId })); +} + +// --------------------------------------------------------------------------- +// Sessions +// --------------------------------------------------------------------------- + +export const zSessionsQuery = zDateRange.extend({ + country: z.string().optional(), + city: z.string().optional(), + device: z.string().optional(), + browser: z.string().optional(), + os: z.string().optional(), + referrer: z.string().optional(), + referrerName: z.string().optional(), + referrerType: z.string().optional(), + profileId: z.string().optional(), + limit: z.number().int().min(1).max(100).default(20), +}); + +export async function querySessions( + req: FastifyRequest<{ Params: { projectId?: string }; Querystring: z.infer }>, + reply: FastifyReply +) { + const projectId = await getProjectId(req as RequestWithProjectParam); + return reply.send(await querySessionsCore({ projectId, ...req.query })); +} + +// --------------------------------------------------------------------------- +// Groups +// --------------------------------------------------------------------------- + +export async function listGroupTypes( + req: FastifyRequest<{ Params: { projectId?: string } }>, + reply: FastifyReply +) { + const projectId = await getProjectId(req as RequestWithProjectParam); + return reply.send(await listGroupTypesCore(projectId)); +} + +export const zGroupsQuery = z.object({ + type: z.string().optional(), + search: z.string().optional(), + limit: z.number().int().min(1).max(100).default(20), +}); + +export async function findGroups( + req: FastifyRequest<{ Params: { projectId?: string }; Querystring: z.infer }>, + reply: FastifyReply +) { + const projectId = await getProjectId(req as RequestWithProjectParam); + return reply.send(await findGroupsCore({ projectId, ...req.query })); +} + +export const zGetGroupQuery = z.object({ memberLimit: z.number().int().min(1).max(50).default(10) }); + +export async function getGroup( + req: FastifyRequest<{ Params: { projectId?: string; groupId: string }; Querystring: z.infer }>, + reply: FastifyReply +) { + const projectId = await getProjectId(req as RequestWithProjectParam); + return reply.send(await getGroupCore({ projectId, groupId: req.params.groupId, memberLimit: req.query.memberLimit })); +} + +// --------------------------------------------------------------------------- +// Dashboards & reports +// --------------------------------------------------------------------------- + +export async function listDashboards( + req: FastifyRequest<{ Params: { projectId?: string } }>, + reply: FastifyReply +) { + const projectId = await getProjectId(req as RequestWithProjectParam); + return reply.send(await listDashboardsCore({ projectId, organizationId: getOrgId(req as RequestWithProjectParam) })); +} + +export async function listReports( + req: FastifyRequest<{ Params: { projectId?: string; dashboardId: string } }>, + reply: FastifyReply +) { + const projectId = await getProjectId(req as RequestWithProjectParam); + return reply.send(await listReportsCore({ projectId, dashboardId: req.params.dashboardId, organizationId: getOrgId(req as RequestWithProjectParam) })); +} + +export async function getReportData( + req: FastifyRequest<{ Params: { projectId?: string; reportId: string } }>, + reply: FastifyReply +) { + const projectId = await getProjectId(req as RequestWithProjectParam); + return reply.send(await getReportDataCore({ projectId, reportId: req.params.reportId, organizationId: getOrgId(req as RequestWithProjectParam) })); +} + +// --------------------------------------------------------------------------- +// Google Search Console +// --------------------------------------------------------------------------- + +export const zGscOverviewQuery = zDateRange.extend({ + interval: z.enum(['day', 'week', 'month']).default('day'), +}); + +export async function gscOverview( + req: FastifyRequest<{ Params: { projectId?: string }; Querystring: z.infer }>, + reply: FastifyReply +) { + const projectId = await getProjectId(req as RequestWithProjectParam); + const { startDate, endDate } = await resolveDates(projectId, req.query); + return reply.send(await gscGetOverviewCore({ projectId, startDate, endDate, interval: req.query.interval })); +} + +export const zGscLimitQuery = zDateRange.extend({ limit: z.number().int().min(1).max(1000).default(100) }); + +export async function gscPages( + req: FastifyRequest<{ Params: { projectId?: string }; Querystring: z.infer }>, + reply: FastifyReply +) { + const projectId = await getProjectId(req as RequestWithProjectParam); + const { startDate, endDate } = await resolveDates(projectId, req.query); + return reply.send(await gscGetTopPagesCore({ projectId, startDate, endDate, limit: req.query.limit })); +} + +export const zGscPageDetailsQuery = zDateRange.extend({ page: z.string().url() }); + +export async function gscPageDetails( + req: FastifyRequest<{ Params: { projectId?: string }; Querystring: z.infer }>, + reply: FastifyReply +) { + const projectId = await getProjectId(req as RequestWithProjectParam); + const { startDate, endDate } = await resolveDates(projectId, req.query); + return reply.send(await gscGetPageDetailsCore({ projectId, startDate, endDate, page: req.query.page })); +} + +export async function gscQueries( + req: FastifyRequest<{ Params: { projectId?: string }; Querystring: z.infer }>, + reply: FastifyReply +) { + const projectId = await getProjectId(req as RequestWithProjectParam); + const { startDate, endDate } = await resolveDates(projectId, req.query); + return reply.send(await gscGetTopQueriesCore({ projectId, startDate, endDate, limit: req.query.limit })); +} + +export const zGscQueryDetailsQuery = zDateRange.extend({ query: z.string() }); + +export async function gscQueryDetails( + req: FastifyRequest<{ Params: { projectId?: string }; Querystring: z.infer }>, + reply: FastifyReply +) { + const projectId = await getProjectId(req as RequestWithProjectParam); + const { startDate, endDate } = await resolveDates(projectId, req.query); + return reply.send(await gscGetQueryDetailsCore({ projectId, startDate, endDate, query: req.query.query })); +} + +export const zGscOpportunitiesQuery = zDateRange.extend({ minImpressions: z.number().int().min(1).default(50) }); + +export async function gscQueryOpportunities( + req: FastifyRequest<{ Params: { projectId?: string }; Querystring: z.infer }>, + reply: FastifyReply +) { + const projectId = await getProjectId(req as RequestWithProjectParam); + const { startDate, endDate } = await resolveDates(projectId, req.query); + return reply.send(await gscGetQueryOpportunitiesCore({ projectId, startDate, endDate, minImpressions: req.query.minImpressions })); +} + +export async function gscCannibalization( + req: FastifyRequest<{ Params: { projectId?: string }; Querystring: z.infer }>, + reply: FastifyReply +) { + const projectId = await getProjectId(req as RequestWithProjectParam); + const { startDate, endDate } = await resolveDates(projectId, req.query); + return reply.send(await gscGetCannibalizationCore({ projectId, startDate, endDate })); +} diff --git a/apps/api/src/controllers/manage.controller.ts b/apps/api/src/controllers/manage.controller.ts index 1d162851a..028955f9a 100644 --- a/apps/api/src/controllers/manage.controller.ts +++ b/apps/api/src/controllers/manage.controller.ts @@ -11,8 +11,8 @@ import type { FastifyReply, FastifyRequest } from 'fastify'; import { z } from 'zod'; import { HttpError } from '@/utils/errors'; -// Validation schemas -const zCreateProject = z.object({ +// Validation schemas (exported for use in router) +export const zCreateProject = z.object({ name: z.string().min(1), domain: z.string().url().or(z.literal('')).or(z.null()).optional(), cors: z.array(z.string()).default([]), @@ -23,7 +23,7 @@ const zCreateProject = z.object({ .default([]), }); -const zUpdateProject = z.object({ +export const zUpdateProject = z.object({ name: z.string().min(1).optional(), domain: z.string().url().or(z.literal('')).or(z.null()).optional(), cors: z.array(z.string()).optional(), @@ -31,24 +31,24 @@ const zUpdateProject = z.object({ allowUnsafeRevenueTracking: z.boolean().optional(), }); -const zCreateClient = z.object({ +export const zCreateClient = z.object({ name: z.string().min(1), projectId: z.string().optional(), type: z.enum(['read', 'write', 'root']).optional().default('write'), }); -const zUpdateClient = z.object({ +export const zUpdateClient = z.object({ name: z.string().min(1).optional(), }); -const zCreateReference = z.object({ +export const zCreateReference = z.object({ projectId: z.string(), title: z.string().min(1), description: z.string().optional(), datetime: z.string(), }); -const zUpdateReference = z.object({ +export const zUpdateReference = z.object({ title: z.string().min(1).optional(), description: z.string().optional(), datetime: z.string().optional(), @@ -94,17 +94,7 @@ export async function createProject( request: FastifyRequest<{ Body: z.infer }>, reply: FastifyReply ) { - const parsed = zCreateProject.safeParse(request.body); - - if (parsed.success === false) { - return reply.status(400).send({ - error: 'Bad Request', - message: 'Invalid request body', - details: parsed.error.errors, - }); - } - - const { name, domain, cors, crossDomain, types } = parsed.data; + const { name, domain, cors, crossDomain, types } = request.body; // Generate a default client secret const secret = `sec_${crypto.randomBytes(10).toString('hex')}`; @@ -164,15 +154,7 @@ export async function updateProject( }>, reply: FastifyReply ) { - const parsed = zUpdateProject.safeParse(request.body); - - if (parsed.success === false) { - return reply.status(400).send({ - error: 'Bad Request', - message: 'Invalid request body', - details: parsed.error.errors, - }); - } + const body = request.body; // Verify project exists and belongs to organization const existing = await db.project.findFirst({ @@ -194,23 +176,22 @@ export async function updateProject( } const updateData: any = {}; - if (parsed.data.name !== undefined) { - updateData.name = parsed.data.name; + if (body.name !== undefined) { + updateData.name = body.name; } - if (parsed.data.domain !== undefined) { - updateData.domain = parsed.data.domain - ? stripTrailingSlash(parsed.data.domain) + if (body.domain !== undefined) { + updateData.domain = body.domain + ? stripTrailingSlash(body.domain) : null; } - if (parsed.data.cors !== undefined) { - updateData.cors = parsed.data.cors.map((c) => stripTrailingSlash(c)); + if (body.cors !== undefined) { + updateData.cors = body.cors.map((c) => stripTrailingSlash(c)); } - if (parsed.data.crossDomain !== undefined) { - updateData.crossDomain = parsed.data.crossDomain; + if (body.crossDomain !== undefined) { + updateData.crossDomain = body.crossDomain; } - if (parsed.data.allowUnsafeRevenueTracking !== undefined) { - updateData.allowUnsafeRevenueTracking = - parsed.data.allowUnsafeRevenueTracking; + if (body.allowUnsafeRevenueTracking !== undefined) { + updateData.allowUnsafeRevenueTracking = body.allowUnsafeRevenueTracking; } const project = await db.project.update({ @@ -314,17 +295,7 @@ export async function createClient( request: FastifyRequest<{ Body: z.infer }>, reply: FastifyReply ) { - const parsed = zCreateClient.safeParse(request.body); - - if (parsed.success === false) { - return reply.status(400).send({ - error: 'Bad Request', - message: 'Invalid request body', - details: parsed.error.errors, - }); - } - - const { name, projectId, type } = parsed.data; + const { name, projectId, type } = request.body; // If projectId is provided, verify it belongs to organization if (projectId) { @@ -370,16 +341,6 @@ export async function updateClient( }>, reply: FastifyReply ) { - const parsed = zUpdateClient.safeParse(request.body); - - if (parsed.success === false) { - return reply.status(400).send({ - error: 'Bad Request', - message: 'Invalid request body', - details: parsed.error.errors, - }); - } - // Verify client exists and belongs to organization const existing = await db.client.findFirst({ where: { @@ -393,8 +354,8 @@ export async function updateClient( } const updateData: any = {}; - if (parsed.data.name !== undefined) { - updateData.name = parsed.data.name; + if (request.body.name !== undefined) { + updateData.name = request.body.name; } const client = await db.client.update({ @@ -512,17 +473,7 @@ export async function createReference( request: FastifyRequest<{ Body: z.infer }>, reply: FastifyReply ) { - const parsed = zCreateReference.safeParse(request.body); - - if (parsed.success === false) { - return reply.status(400).send({ - error: 'Bad Request', - message: 'Invalid request body', - details: parsed.error.errors, - }); - } - - const { projectId, title, description, datetime } = parsed.data; + const { projectId, title, description, datetime } = request.body; // Verify project belongs to organization const project = await db.project.findFirst({ @@ -555,15 +506,7 @@ export async function updateReference( }>, reply: FastifyReply ) { - const parsed = zUpdateReference.safeParse(request.body); - - if (parsed.success === false) { - return reply.status(400).send({ - error: 'Bad Request', - message: 'Invalid request body', - details: parsed.error.errors, - }); - } + const body = request.body; // Verify reference exists and belongs to organization const existing = await db.reference.findUnique({ @@ -588,14 +531,14 @@ export async function updateReference( } const updateData: any = {}; - if (parsed.data.title !== undefined) { - updateData.title = parsed.data.title; + if (body.title !== undefined) { + updateData.title = body.title; } - if (parsed.data.description !== undefined) { - updateData.description = parsed.data.description ?? null; + if (body.description !== undefined) { + updateData.description = body.description ?? null; } - if (parsed.data.datetime !== undefined) { - updateData.date = new Date(parsed.data.datetime); + if (body.datetime !== undefined) { + updateData.date = new Date(body.datetime); } const reference = await db.reference.update({ diff --git a/apps/api/src/controllers/track.controller.ts b/apps/api/src/controllers/track.controller.ts index 8c7e6ceb1..e0d53dd33 100644 --- a/apps/api/src/controllers/track.controller.ts +++ b/apps/api/src/controllers/track.controller.ts @@ -13,16 +13,15 @@ import { getEventsGroupQueueShard, } from '@openpanel/queue'; import { getRedisCache } from '@openpanel/redis'; -import { - type IAssignGroupPayload, - type IDecrementPayload, - type IGroupPayload, - type IIdentifyPayload, - type IIncrementPayload, - type IReplayPayload, - type ITrackHandlerPayload, - type ITrackPayload, - zTrackHandlerPayload, +import type { + IAssignGroupPayload, + IDecrementPayload, + IGroupPayload, + IIdentifyPayload, + IIncrementPayload, + IReplayPayload, + ITrackHandlerPayload, + ITrackPayload, } from '@openpanel/validation'; import type { FastifyReply, FastifyRequest } from 'fastify'; import { assocPath, pathOr, pick } from 'ramda'; @@ -373,18 +372,7 @@ export async function handler( }>, reply: FastifyReply ) { - // Validate request body with Zod - const validationResult = zTrackHandlerPayload.safeParse(request.body); - if (!validationResult.success) { - return reply.status(400).send({ - status: 400, - error: 'Bad Request', - message: 'Validation failed', - errors: validationResult.error.errors, - }); - } - - const validatedBody = validationResult.data; + const validatedBody = request.body; // Handle alias (not supported) if (validatedBody.type === 'alias') { diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index cb5cdece3..dd956374b 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -1,67 +1,14 @@ /** biome-ignore-all lint/suspicious/useAwait: fastify need async or done callbacks */ process.env.TZ = 'UTC'; -import compress from '@fastify/compress'; -import cookie from '@fastify/cookie'; -import cors, { type FastifyCorsOptions } from '@fastify/cors'; -import { - decodeSessionToken, - EMPTY_SESSION, - type SessionValidationResult, - validateSessionToken, -} from '@openpanel/auth'; -import { generateId } from '@openpanel/common'; -import { - type IServiceClientWithProject, - runWithAlsSession, -} from '@openpanel/db'; -import { getRedisPub } from '@openpanel/redis'; -import type { AppRouter } from '@openpanel/trpc'; -import { appRouter, createContext } from '@openpanel/trpc'; -import type { FastifyTRPCPluginOptions } from '@trpc/server/adapters/fastify'; -import { fastifyTRPCPlugin } from '@trpc/server/adapters/fastify'; -import type { FastifyBaseLogger, FastifyRequest } from 'fastify'; -import Fastify from 'fastify'; -import metricsPlugin from 'fastify-metrics'; import sourceMapSupport from 'source-map-support'; -import { - healthcheck, - liveness, - readiness, -} from './controllers/healthcheck.controller'; -import { ipHook } from './hooks/ip.hook'; -import { requestIdHook } from './hooks/request-id.hook'; -import { requestLoggingHook } from './hooks/request-logging.hook'; -import { timestampHook } from './hooks/timestamp.hook'; -import aiRouter from './routes/ai.router'; -import eventRouter from './routes/event.router'; -import exportRouter from './routes/export.router'; -import gscCallbackRouter from './routes/gsc-callback.router'; -import importRouter from './routes/import.router'; -import insightsRouter from './routes/insights.router'; -import liveRouter from './routes/live.router'; -import manageRouter from './routes/manage.router'; -import miscRouter from './routes/misc.router'; -import oauthRouter from './routes/oauth-callback.router'; -import profileRouter from './routes/profile.router'; -import trackRouter from './routes/track.router'; -import webhookRouter from './routes/webhook.router'; -import { HttpError } from './utils/errors'; +import { buildApp } from './app'; import { shutdown } from './utils/graceful-shutdown'; import { logger } from './utils/logger'; +import { getRedisPub } from '@openpanel/redis'; sourceMapSupport.install(); -declare module 'fastify' { - interface FastifyRequest { - client: IServiceClientWithProject | null; - clientIp: string; - clientIpHeader: string; - timestamp?: number; - session: SessionValidationResult; - } -} - const port = Number.parseInt(process.env.API_PORT || '3000', 10); const host = process.env.API_HOST || @@ -70,200 +17,7 @@ const host = const startServer = async () => { logger.info('Starting server'); try { - const fastify = Fastify({ - maxParamLength: 15_000, - bodyLimit: 1_048_576 * 500, // 500MB - loggerInstance: logger as unknown as FastifyBaseLogger, - disableRequestLogging: true, - genReqId: (req) => - req.headers['request-id'] - ? String(req.headers['request-id']) - : generateId(), - }); - - fastify.register(cors, () => { - return ( - req: FastifyRequest, - callback: (error: Error | null, options: FastifyCorsOptions) => void - ) => { - // TODO: set prefix on dashboard routes - const corsPaths = [ - '/trpc', - '/live', - '/webhook', - '/oauth', - '/misc', - '/ai', - ]; - - const isPrivatePath = corsPaths.some((path) => - req.url.startsWith(path) - ); - - if (isPrivatePath) { - // Allow multiple dashboard domains - const allowedOrigins = [ - process.env.DASHBOARD_URL || process.env.NEXT_PUBLIC_DASHBOARD_URL, - ...(process.env.API_CORS_ORIGINS?.split(',') ?? []), - ].filter(Boolean); - - const origin = req.headers.origin; - const isAllowed = origin && allowedOrigins.includes(origin); - - return callback(null, { - origin: isAllowed ? origin : false, - credentials: true, - }); - } - - return callback(null, { - origin: '*', - maxAge: 86_400 * 7, // cache preflight for 7 days - }); - }; - }); - - await fastify.register(import('fastify-raw-body'), { - global: false, - }); - - fastify.addHook('onRequest', requestIdHook); - fastify.addHook('onRequest', timestampHook); - fastify.addHook('onRequest', ipHook); - fastify.addHook('onResponse', requestLoggingHook); - - fastify.register(compress, { - global: false, - encodings: ['gzip', 'deflate'], - }); - - // Dashboard API - fastify.register(async (instance) => { - instance.register(cookie, { - secret: process.env.COOKIE_SECRET ?? '', - hook: 'onRequest', - parseOptions: {}, - }); - - instance.addHook('onRequest', async (req) => { - if (req.cookies?.session) { - try { - const sessionId = decodeSessionToken(req.cookies?.session); - const session = await runWithAlsSession(sessionId, () => - validateSessionToken(req.cookies.session) - ); - req.session = session; - } catch { - req.session = EMPTY_SESSION; - } - } else if (process.env.DEMO_USER_ID) { - try { - const session = await runWithAlsSession('1', () => - validateSessionToken(null) - ); - req.session = session; - } catch { - req.session = EMPTY_SESSION; - } - } else { - req.session = EMPTY_SESSION; - } - }); - - instance.register(fastifyTRPCPlugin, { - prefix: '/trpc', - trpcOptions: { - router: appRouter, - createContext, - onError(ctx) { - if ( - ctx.error.code === 'UNAUTHORIZED' && - ctx.path === 'organization.list' - ) { - return; - } - ctx.req.log.error('trpc error', { - error: ctx.error, - path: ctx.path, - input: ctx.input, - type: ctx.type, - session: ctx.ctx?.session, - }); - }, - } satisfies FastifyTRPCPluginOptions['trpcOptions'], - }); - instance.register(liveRouter, { prefix: '/live' }); - instance.register(webhookRouter, { prefix: '/webhook' }); - instance.register(oauthRouter, { prefix: '/oauth' }); - instance.register(gscCallbackRouter, { prefix: '/gsc' }); - instance.register(miscRouter, { prefix: '/misc' }); - instance.register(aiRouter, { prefix: '/ai' }); - }); - - // Public API - fastify.register(async (instance) => { - instance.register(metricsPlugin, { endpoint: '/metrics' }); - instance.register(eventRouter, { prefix: '/event' }); - instance.register(profileRouter, { prefix: '/profile' }); - instance.register(exportRouter, { prefix: '/export' }); - instance.register(importRouter, { prefix: '/import' }); - instance.register(insightsRouter, { prefix: '/insights' }); - instance.register(trackRouter, { prefix: '/track' }); - instance.register(manageRouter, { prefix: '/manage' }); - // Keep existing endpoints for backward compatibility - instance.get('/healthcheck', healthcheck); - // New Kubernetes-style health endpoints - instance.get('/healthz/live', liveness); - instance.get('/healthz/ready', readiness); - instance.get('/', (_request, reply) => - reply.send({ - status: 'ok', - message: 'Successfully running OpenPanel.dev API', - }) - ); - }); - - const SKIP_LOG_ERRORS = ['UNAUTHORIZED', 'FST_ERR_CTP_INVALID_MEDIA_TYPE']; - fastify.setErrorHandler((error, request, reply) => { - if (error.statusCode === 429) { - return reply.status(429).send({ - status: 429, - error: 'Too Many Requests', - message: 'You have exceeded the rate limit for this endpoint.', - }); - } - - if (error instanceof HttpError) { - if (!SKIP_LOG_ERRORS.includes(error.code)) { - request.log.error('internal server error', { error }); - } - - if (process.env.NODE_ENV === 'production' && error.status === 500) { - return reply.status(500).send('Internal server error'); - } - - return reply.status(error.status).send({ - status: error.status, - error: error.error, - message: error.message, - }); - } - - if (!SKIP_LOG_ERRORS.includes(error.code)) { - request.log.error('request error', { error }); - } - - const status = error?.statusCode ?? 500; - if (process.env.NODE_ENV === 'production' && status === 500) { - return reply.status(500).send('Internal server error'); - } - - return reply.status(status).send({ - status, - error, - message: error.message, - }); - }); + const fastify = await buildApp(); if (process.env.NODE_ENV === 'production') { logger.info('Registering graceful shutdown handlers'); @@ -282,12 +36,11 @@ const startServer = async () => { await fastify.listen({ host, port }); try { - // Notify when keys expires await getRedisPub().config('SET', 'notify-keyspace-events', 'Ex'); } catch (error) { logger.warn('Failed to set redis notify-keyspace-events', error); logger.warn( - 'If you use a managed Redis service, you may need to set this manually.' + 'If you use a managed Redis service, you may need to set this manually.', ); logger.warn('Otherwise some functions may not work as expected.'); } diff --git a/apps/api/src/routes/event.router.ts b/apps/api/src/routes/event.router.ts index 5efa52add..f1b791393 100644 --- a/apps/api/src/routes/event.router.ts +++ b/apps/api/src/routes/event.router.ts @@ -1,11 +1,10 @@ +import type { FastifyPluginAsyncZodOpenApi } from 'fastify-zod-openapi'; import * as controller from '@/controllers/event.controller'; -import type { FastifyPluginCallback } from 'fastify'; - import { clientHook } from '@/hooks/client.hook'; import { duplicateHook } from '@/hooks/duplicate.hook'; import { isBotHook } from '@/hooks/is-bot.hook'; -const eventRouter: FastifyPluginCallback = async (fastify) => { +const eventRouter: FastifyPluginAsyncZodOpenApi = async (fastify) => { fastify.addHook('preValidation', duplicateHook); fastify.addHook('preHandler', clientHook); fastify.addHook('preHandler', isBotHook); @@ -13,6 +12,10 @@ const eventRouter: FastifyPluginCallback = async (fastify) => { fastify.route({ method: 'POST', url: '/', + schema: { + tags: ['Event'], + description: 'Deprecated direct event ingestion endpoint. Use /track instead.', + }, handler: controller.postEvent, }); }; diff --git a/apps/api/src/routes/export.router.ts b/apps/api/src/routes/export.router.ts index 3dafb42b8..4440e7428 100644 --- a/apps/api/src/routes/export.router.ts +++ b/apps/api/src/routes/export.router.ts @@ -1,15 +1,19 @@ +import { Prisma } from '@openpanel/db'; +import type { FastifyRequest } from 'fastify'; +import type { FastifyPluginAsyncZodOpenApi } from 'fastify-zod-openapi'; +import { + chartSchemeFull, + eventsScheme, +} from '@/controllers/export.controller'; import * as controller from '@/controllers/export.controller'; import { validateExportRequest } from '@/utils/auth'; +import { parseQueryString } from '@/utils/parse-zod-query-string'; import { activateRateLimiter } from '@/utils/rate-limiter'; -import { Prisma } from '@openpanel/db'; -import type { FastifyPluginCallback, FastifyRequest } from 'fastify'; -const exportRouter: FastifyPluginCallback = async (fastify) => { - await activateRateLimiter({ - fastify, - max: 100, - timeWindow: '10 seconds', - }); +const TAGS = ['Export'] as const; + +const exportRouter: FastifyPluginAsyncZodOpenApi = async (fastify) => { + await activateRateLimiter({ fastify, max: 100, timeWindow: '10 seconds' }); fastify.addHook('preHandler', async (req: FastifyRequest, reply) => { try { @@ -17,33 +21,38 @@ const exportRouter: FastifyPluginCallback = async (fastify) => { req.client = client; } catch (e) { if (e instanceof Prisma.PrismaClientKnownRequestError) { - return reply.status(401).send({ - error: 'Unauthorized', - message: 'Client ID seems to be malformed', - }); + return reply.status(401).send({ error: 'Unauthorized', message: 'Client ID seems to be malformed' }); } - if (e instanceof Error) { - return reply - .status(401) - .send({ error: 'Unauthorized', message: e.message }); + return reply.status(401).send({ error: 'Unauthorized', message: e.message }); } - - return reply - .status(401) - .send({ error: 'Unauthorized', message: 'Unexpected error' }); + return reply.status(401).send({ error: 'Unauthorized', message: 'Unexpected error' }); } }); + fastify.addHook('preValidation', async (req) => { + req.query = parseQueryString(req.query as Record) as typeof req.query; + }); + fastify.route({ method: 'GET', url: '/events', + schema: { + tags: TAGS, + description: 'Export a paginated list of raw events with optional filtering by date, profile, and event type.', + querystring: eventsScheme, + }, handler: controller.events, }); fastify.route({ method: 'GET', url: '/charts', + schema: { + tags: TAGS, + description: 'Export aggregated chart/analytics data for a series of events over a time range.', + querystring: chartSchemeFull, + }, handler: controller.charts, }); }; diff --git a/apps/api/src/routes/import.router.ts b/apps/api/src/routes/import.router.ts index fbad804dc..5c55765a9 100644 --- a/apps/api/src/routes/import.router.ts +++ b/apps/api/src/routes/import.router.ts @@ -32,6 +32,10 @@ const importRouter: FastifyPluginCallback = async (fastify) => { fastify.route({ method: 'POST', url: '/events', + schema: { + tags: ['Import'], + description: 'Bulk import historical events.', + }, handler: controller.importEvents, }); }; diff --git a/apps/api/src/routes/insights.router.test.ts b/apps/api/src/routes/insights.router.test.ts new file mode 100644 index 000000000..c91a8f087 --- /dev/null +++ b/apps/api/src/routes/insights.router.test.ts @@ -0,0 +1,382 @@ +/** + * Integration tests for the /insights/* REST routes. + * + * Auth is mocked (getClientByIdCached, verifyPassword, getCache). + * ClickHouse and Postgres are real — uses the local Docker instance (pnpm dock:up). + * + * Fixture data (see apps/api/src/tests/setup.ts): + * Alice — 3 events: session_start, page_view(/home), session_end — 2 days ago — Chrome / US + * Charlie — 5 events: session_start, screen_view, page_view(/shop), purchase, session_end — 5 days ago — Firefox + * 2 sessions (sess-charlie-1 5d ago, sess-charlie-2 10d ago) + */ + +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; + +// ─── Module mocks (hoisted before imports) ──────────────────────────────────── + +// Mock only getClientByIdCached so auth can be controlled per-test. +// importOriginal is fine here because real Postgres is available. +vi.mock('@openpanel/db', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, getClientByIdCached: vi.fn() }; +}); + +// Password verification is always truthy in tests. +vi.mock('@openpanel/common/server', async (importOriginal) => { + const actual = + await importOriginal(); + return { ...actual, verifyPassword: vi.fn().mockResolvedValue(true) }; +}); + +// Bypass Redis caching — no real ioredis connections in tests. +// getRedisCache must return a truthy object so that @trpc-limiter/redis's +// RateLimiterRedis constructor doesn't throw "storeClient is not set". +vi.mock('@openpanel/redis', async (importOriginal) => { + const actual = await importOriginal(); + const fakeRedisClient = new Proxy( + {}, + { + get: (_t, p) => + p === 'status' ? 'ready' : vi.fn().mockResolvedValue(null), + } + ); + return { + ...actual, + getCache: async (_key: string, _ttl: number, fn: () => Promise) => + fn(), + getRedisCache: vi.fn().mockReturnValue(fakeRedisClient), + }; +}); + +// ─── Imports (after mocks) ──────────────────────────────────────────────────── + +import { ClientType, getClientByIdCached } from '@openpanel/db'; +import type { FastifyInstance } from 'fastify'; +import { buildApp } from '../app'; +import { FIXTURE, TEST_ORG_ID, TEST_PROJECT_ID } from '../../../../test/global-setup'; + +// ─── Test client constants ──────────────────────────────────────────────────── + +const CLIENT_ID = '00000000-0000-0000-0000-000000000099'; +const CLIENT_SECRET = 'test-secret'; + +const AUTH = { + 'openpanel-client-id': CLIENT_ID, + 'openpanel-client-secret': CLIENT_SECRET, +}; + +/** Minimal shape that satisfies validateExportRequest */ +const READ_CLIENT = { + id: CLIENT_ID, + type: ClientType.read, + projectId: TEST_PROJECT_ID, + organizationId: TEST_ORG_ID, + secret: 'hashed-secret', + name: 'Test Client', + cors: null, + description: '', + ignoreCorsAndSecret: false, + createdAt: new Date(), + updatedAt: new Date(), + project: null, +}; + +// ─── Lifecycle ──────────────────────────────────────────────────────────────── + +let app: FastifyInstance; + +beforeAll(async () => { + vi.mocked(getClientByIdCached).mockResolvedValue(READ_CLIENT as any); + app = await buildApp({ testing: true }); + await app.ready(); +}, 30_000); + +afterAll(async () => { + await app.close(); +}, 10_000); + +// ─── Helpers ────────────────────────────────────────────────────────────────── + +function get(path: string, headers: Record = AUTH) { + return app.inject({ method: 'GET', url: path, headers }); +} + +// ─── Auth ───────────────────────────────────────────────────────────────────── + +describe('auth', () => { + it('returns 401 when no client-id header is present', async () => { + const res = await get(`/insights/${TEST_PROJECT_ID}/events/names`, {}); + expect(res.statusCode).toBe(401); + }); + + it('returns 401 when client-id is not a valid UUID', async () => { + const res = await get(`/insights/${TEST_PROJECT_ID}/events/names`, { + 'openpanel-client-id': 'not-a-uuid', + 'openpanel-client-secret': CLIENT_SECRET, + }); + expect(res.statusCode).toBe(401); + }); + + it('returns 200 with valid credentials', async () => { + const res = await get(`/insights/${TEST_PROJECT_ID}/events/names`); + expect(res.statusCode).toBe(200); + }); +}); + +// ─── Events ─────────────────────────────────────────────────────────────────── + +describe('GET /insights/:projectId/events/names', () => { + it('returns event_names array', async () => { + const res = await get(`/insights/${TEST_PROJECT_ID}/events/names`); + expect(res.statusCode).toBe(200); + const body = res.json(); + expect(Array.isArray(body)).toBe(true); + }); + + it('includes events from fixture data', async () => { + const res = await get(`/insights/${TEST_PROJECT_ID}/events/names`); + const body = res.json(); + expect(body).toContain('session_start'); + expect(body).toContain('page_view'); + expect(body).toContain('session_end'); + }); +}); + +describe('GET /insights/:projectId/events', () => { + it('returns events array', async () => { + const res = await get(`/insights/${TEST_PROJECT_ID}/events`); + expect(res.statusCode).toBe(200); + const body = res.json(); + expect(Array.isArray(body)).toBe(true); + }); + + it('respects limit parameter', async () => { + const res = await get(`/insights/${TEST_PROJECT_ID}/events?limit=2`); + expect(res.statusCode).toBe(200); + const body = res.json(); + expect(body.length).toBeLessThanOrEqual(2); + }); + + it('filters by eventNames', async () => { + const res = await get( + `/insights/${TEST_PROJECT_ID}/events?eventNames=purchase` + ); + expect(res.statusCode).toBe(200); + const body = res.json(); + expect(body.every((e: any) => e.name === 'purchase')).toBe(true); + }); + + it('returns 400 when limit is out of range', async () => { + const res = await get(`/insights/${TEST_PROJECT_ID}/events?limit=9999`); + expect(res.statusCode).toBe(400); + }); +}); + +describe('GET /insights/:projectId/events/properties', () => { + it('returns properties array', async () => { + const res = await get(`/insights/${TEST_PROJECT_ID}/events/properties`); + expect(res.statusCode).toBe(200); + const body = res.json(); + expect(Array.isArray(body.properties)).toBe(true); + }); +}); + +describe('GET /insights/:projectId/events/property_values', () => { + it('returns values for a known property', async () => { + const res = await get( + `/insights/${TEST_PROJECT_ID}/events/property_values?eventName=page_view&propertyKey=path` + ); + expect(res.statusCode).toBe(200); + const body = res.json(); + expect(Array.isArray(body.values)).toBe(true); + }); + + it('returns 400 when required params are missing', async () => { + const res = await get( + `/insights/${TEST_PROJECT_ID}/events/property_values?eventName=page_view` + ); + expect(res.statusCode).toBe(400); + }); +}); + +// ─── Profiles ───────────────────────────────────────────────────────────────── + +describe('GET /insights/:projectId/profiles', () => { + it('returns profiles array', async () => { + const res = await get(`/insights/${TEST_PROJECT_ID}/profiles`); + expect(res.statusCode).toBe(200); + const body = res.json(); + expect(Array.isArray(body)).toBe(true); + }); + + it('includes fixture profiles', async () => { + const res = await get(`/insights/${TEST_PROJECT_ID}/profiles`); + const body = res.json(); + const emails = body.map((p: any) => p.email); + expect(emails).toContain('alice@example.com'); + expect(emails).toContain('charlie@example.com'); + }); + + it('filters by browser via query params', async () => { + const res = await get( + `/insights/${TEST_PROJECT_ID}/profiles?browser=Firefox` + ); + expect(res.statusCode).toBe(200); + const body = res.json(); + // Charlie uses Firefox; Alice uses Chrome — only Charlie should appear + const emails = body.map((p: any) => p.email); + expect(emails).toContain('charlie@example.com'); + expect(emails).not.toContain('alice@example.com'); + }); +}); + +describe('GET /insights/:projectId/profiles/:profileId', () => { + it('returns 404 for unknown profile', async () => { + const res = await get( + `/insights/${TEST_PROJECT_ID}/profiles/does-not-exist` + ); + expect(res.statusCode).toBe(404); + }); + + it('returns profile data for known profile', async () => { + const res = await get( + `/insights/${TEST_PROJECT_ID}/profiles/${FIXTURE.profiles.alice}` + ); + expect(res.statusCode).toBe(200); + const body = res.json(); + expect(body).toHaveProperty('profile'); + expect(body.profile.email).toBe('alice@example.com'); + }); +}); + +describe('GET /insights/:projectId/profiles/:profileId/sessions', () => { + it('returns sessions for charlie', async () => { + const res = await get( + `/insights/${TEST_PROJECT_ID}/profiles/${FIXTURE.profiles.charlie}/sessions` + ); + expect(res.statusCode).toBe(200); + const body = res.json(); + expect(Array.isArray(body)).toBe(true); + expect(body.length).toBeGreaterThanOrEqual(1); + }); +}); + +// ─── Sessions ───────────────────────────────────────────────────────────────── + +describe('GET /insights/:projectId/sessions', () => { + it('returns sessions array', async () => { + const res = await get(`/insights/${TEST_PROJECT_ID}/sessions`); + expect(res.statusCode).toBe(200); + const body = res.json(); + expect(Array.isArray(body)).toBe(true); + }); + + it('fixture has at least 3 sessions (alice-1, charlie-1, charlie-2)', async () => { + const res = await get(`/insights/${TEST_PROJECT_ID}/sessions?limit=100`); + const body = res.json(); + expect(body.length).toBeGreaterThanOrEqual(3); + }); +}); + +// ─── Analytics overview ─────────────────────────────────────────────────────── + +describe('GET /insights/:projectId/overview', () => { + it('returns analytics overview', async () => { + const res = await get(`/insights/${TEST_PROJECT_ID}/overview`); + expect(res.statusCode).toBe(200); + const body = res.json(); + // Overview returns an object with at least some metrics + expect(typeof body).toBe('object'); + }); + + it('accepts interval param', async () => { + const res = await get(`/insights/${TEST_PROJECT_ID}/overview?interval=day`); + expect(res.statusCode).toBe(200); + }); + + it('returns 400 for invalid interval', async () => { + const res = await get( + `/insights/${TEST_PROJECT_ID}/overview?interval=invalid` + ); + expect(res.statusCode).toBe(400); + }); +}); + +// ─── Funnel ─────────────────────────────────────────────────────────────────── + +describe('GET /insights/:projectId/funnel', () => { + it('returns funnel data for valid steps', async () => { + const res = await get( + `/insights/${TEST_PROJECT_ID}/funnel?steps=session_start&steps=session_end` + ); + expect(res.statusCode).toBe(200); + const body = res.json(); + expect(typeof body).toBe('object'); + }); + + it('returns 400 when fewer than 2 steps are provided', async () => { + const res = await get( + `/insights/${TEST_PROJECT_ID}/funnel?steps[]=session_start` + ); + expect(res.statusCode).toBe(400); + }); + + it('returns 400 when steps param is missing entirely', async () => { + const res = await get(`/insights/${TEST_PROJECT_ID}/funnel`); + expect(res.statusCode).toBe(400); + }); +}); + +// ─── Pages ──────────────────────────────────────────────────────────────────── + +describe('GET /insights/:projectId/pages/top', () => { + it('returns top pages', async () => { + const res = await get(`/insights/${TEST_PROJECT_ID}/pages/top`); + expect(res.statusCode).toBe(200); + const body = res.json(); + expect(Array.isArray(body)).toBe(true); + }); +}); + +describe('GET /insights/:projectId/pages/entry_exit', () => { + it('defaults to entry mode', async () => { + const res = await get(`/insights/${TEST_PROJECT_ID}/pages/entry_exit`); + expect(res.statusCode).toBe(200); + }); + + it('accepts mode=exit', async () => { + const res = await get( + `/insights/${TEST_PROJECT_ID}/pages/entry_exit?mode=exit` + ); + expect(res.statusCode).toBe(200); + }); +}); + +// ─── Traffic ────────────────────────────────────────────────────────────────── + +describe('GET /insights/:projectId/traffic/referrers', () => { + it('returns referrer breakdown', async () => { + const res = await get(`/insights/${TEST_PROJECT_ID}/traffic/referrers`); + expect(res.statusCode).toBe(200); + const body = res.json(); + expect(Array.isArray(body)).toBe(true); + }); +}); + +describe('GET /insights/:projectId/traffic/geo', () => { + it('returns geo breakdown', async () => { + const res = await get(`/insights/${TEST_PROJECT_ID}/traffic/geo`); + expect(res.statusCode).toBe(200); + const body = res.json(); + expect(Array.isArray(body)).toBe(true); + }); +}); + +describe('GET /insights/:projectId/traffic/devices', () => { + it('returns device breakdown', async () => { + const res = await get(`/insights/${TEST_PROJECT_ID}/traffic/devices`); + expect(res.statusCode).toBe(200); + const body = res.json(); + expect(Array.isArray(body)).toBe(true); + }); +}); diff --git a/apps/api/src/routes/insights.router.ts b/apps/api/src/routes/insights.router.ts index ccd2dd390..79bbb7a1e 100644 --- a/apps/api/src/routes/insights.router.ts +++ b/apps/api/src/routes/insights.router.ts @@ -1,15 +1,51 @@ -import * as controller from '@/controllers/insights.controller'; +import { Prisma } from '@openpanel/db'; +import type { FastifyRequest } from 'fastify'; +import type { FastifyPluginAsyncZodOpenApi } from 'fastify-zod-openapi'; +import { z } from 'zod'; +import * as c from '@/controllers/insights.controller'; +import { + overviewColumns, + zActiveUsersQuery, + zDateRange, + zDeviceQuery, + zEntryExitQuery, + zEventsQuery, + zEventPropertiesQuery, + zFunnelQuery, + zGeoQuery, + zGetGroupQuery, + zGetMetricsQuery, + zGetProfileQuery, + zGetTopPagesQuery, + zGroupsQuery, + zGscLimitQuery, + zGscOpportunitiesQuery, + zGscOverviewQuery, + zGscPageDetailsQuery, + zGscQueryDetailsQuery, + zOverviewGenericQuerystring, + zOverviewQuery, + zPagePerfQuery, + zProfilesQuery, + zProfileSessionsQuery, + zPropertyValuesQuery, + zReferrerQuery, + zSessionsQuery, + zUserFlowQuery, +} from '@/controllers/insights.controller'; import { validateExportRequest } from '@/utils/auth'; +import { parseQueryString } from '@/utils/parse-zod-query-string'; import { activateRateLimiter } from '@/utils/rate-limiter'; -import { Prisma } from '@openpanel/db'; -import type { FastifyPluginCallback, FastifyRequest } from 'fastify'; -const insightsRouter: FastifyPluginCallback = async (fastify) => { - await activateRateLimiter({ - fastify, - max: 100, - timeWindow: '10 seconds', - }); +const projectIdParam = z.object({ projectId: z.string() }); +const profileParam = z.object({ projectId: z.string(), profileId: z.string() }); +const groupParam = z.object({ projectId: z.string(), groupId: z.string() }); +const reportParam = z.object({ projectId: z.string(), reportId: z.string() }); + +const TAGS = ['Insights'] as const; + +const insightsRouter: FastifyPluginAsyncZodOpenApi = async (fastify) => { + await activateRateLimiter({ fastify, max: 100, timeWindow: '10 seconds' }); fastify.addHook('preHandler', async (req: FastifyRequest, reply) => { try { @@ -17,72 +53,506 @@ const insightsRouter: FastifyPluginCallback = async (fastify) => { req.client = client; } catch (e) { if (e instanceof Prisma.PrismaClientKnownRequestError) { - return reply.status(401).send({ - error: 'Unauthorized', - message: 'Client ID seems to be malformed', - }); + return reply.status(401).send({ error: 'Unauthorized', message: 'Client ID seems to be malformed' }); } - if (e instanceof Error) { - return reply - .status(401) - .send({ error: 'Unauthorized', message: e.message }); + return reply.status(401).send({ error: 'Unauthorized', message: e.message }); } - - return reply - .status(401) - .send({ error: 'Unauthorized', message: 'Unexpected error' }); + return reply.status(401).send({ error: 'Unauthorized', message: 'Unexpected error' }); } + + }); + + // Run parseQueryString before Fastify schema validation so coercion + // (string→number, JSON-encoded arrays, etc.) is handled automatically. + fastify.addHook('preValidation', async (req) => { + req.query = parseQueryString(req.query as Record) as typeof req.query; + }); + + // --------------------------------------------------------------------------- + // Analytics — overview + // --------------------------------------------------------------------------- + + fastify.route({ + method: 'GET', + url: '/:projectId/overview', + schema: { + tags: TAGS, + description: 'Get an overview of key metrics for the project (sessions, pageviews, bounce rate, duration).', + params: projectIdParam, + querystring: zOverviewQuery, + }, + handler: c.getOverview, + }); + + fastify.route({ + method: 'GET', + url: '/:projectId/active_users', + schema: { + tags: TAGS, + description: 'Get rolling active user counts over the last N days.', + params: projectIdParam, + querystring: zActiveUsersQuery, + }, + handler: c.getActiveUsers, + }); + + fastify.route({ + method: 'GET', + url: '/:projectId/retention', + schema: { + tags: TAGS, + description: 'Get weekly retention series data.', + params: projectIdParam, + }, + handler: c.getRetentionSeries, + }); + + fastify.route({ + method: 'GET', + url: '/:projectId/retention/cohort', + schema: { + tags: TAGS, + description: 'Get retention cohort data.', + params: projectIdParam, + }, + handler: c.getRetentionCohort, + }); + + // --------------------------------------------------------------------------- + // Analytics — pages + // --------------------------------------------------------------------------- + + fastify.route({ + method: 'GET', + url: '/:projectId/pages/top', + schema: { + tags: TAGS, + description: 'Get the top pages by pageviews for the given date range.', + params: projectIdParam, + querystring: zDateRange, + }, + handler: c.getTopPages, + }); + + fastify.route({ + method: 'GET', + url: '/:projectId/pages/entry_exit', + schema: { + tags: TAGS, + description: 'Get entry or exit pages ranked by session count.', + params: projectIdParam, + querystring: zEntryExitQuery, + }, + handler: c.getEntryExitPages, + }); + + fastify.route({ + method: 'GET', + url: '/:projectId/pages/performance', + schema: { + tags: TAGS, + description: 'Get page-level performance metrics (bounce rate, avg duration, sessions).', + params: projectIdParam, + querystring: zPagePerfQuery, + }, + handler: c.getPagePerformance, }); - // Website stats - main metrics overview + // --------------------------------------------------------------------------- + // Analytics — metrics overview (legacy insights routes) + // --------------------------------------------------------------------------- + fastify.route({ method: 'GET', url: '/:projectId/metrics', - handler: controller.getMetrics, + schema: { + tags: TAGS, + description: 'Get aggregated website metrics including sessions, pageviews, and bounce rate.', + params: projectIdParam, + querystring: zGetMetricsQuery, + }, + handler: c.getMetrics, }); - // Live visitors (real-time) fastify.route({ method: 'GET', url: '/:projectId/live', - handler: controller.getLiveVisitors, + schema: { + tags: TAGS, + description: 'Get the current number of live (active) visitors.', + params: projectIdParam, + }, + handler: c.getLiveVisitors, }); - // Page views with top pages fastify.route({ method: 'GET', url: '/:projectId/pages', - handler: controller.getPages, - }); - - const overviewMetrics = [ - 'referrer_name', - 'referrer', - 'referrer_type', - 'utm_source', - 'utm_medium', - 'utm_campaign', - 'utm_term', - 'utm_content', - 'device', - 'browser', - 'browser_version', - 'os', - 'os_version', - 'brand', - 'model', - 'country', - 'region', - 'city', - ] as const; - - overviewMetrics.forEach((key) => { + schema: { + tags: TAGS, + description: 'Get top pages with pageview counts for the selected date range.', + params: projectIdParam, + querystring: zGetTopPagesQuery, + }, + handler: c.getPages, + }); + + for (const column of overviewColumns) { fastify.route({ method: 'GET', - url: `/:projectId/${key}`, - handler: controller.getOverviewGeneric(key), + url: `/:projectId/${column}`, + schema: { + tags: TAGS, + description: `Get top values for the "${column}" dimension.`, + params: projectIdParam, + querystring: zOverviewGenericQuerystring, + }, + handler: c.getOverviewGeneric(column), }); + } + + // --------------------------------------------------------------------------- + // Analytics — funnel + // --------------------------------------------------------------------------- + + fastify.route({ + method: 'GET', + url: '/:projectId/funnel', + schema: { + tags: TAGS, + description: 'Get funnel conversion rates for a sequence of events.', + params: projectIdParam, + querystring: zFunnelQuery, + }, + handler: c.getFunnel, + }); + + // --------------------------------------------------------------------------- + // Analytics — traffic + // --------------------------------------------------------------------------- + + fastify.route({ + method: 'GET', + url: '/:projectId/traffic/referrers', + schema: { + tags: TAGS, + description: 'Get traffic breakdown by referrer source.', + params: projectIdParam, + querystring: zReferrerQuery, + }, + handler: c.getTrafficReferrers, + }); + + fastify.route({ + method: 'GET', + url: '/:projectId/traffic/geo', + schema: { + tags: TAGS, + description: 'Get traffic breakdown by geographic dimension (country, region, city).', + params: projectIdParam, + querystring: zGeoQuery, + }, + handler: c.getTrafficGeo, + }); + + fastify.route({ + method: 'GET', + url: '/:projectId/traffic/devices', + schema: { + tags: TAGS, + description: 'Get traffic breakdown by device type, browser, or OS.', + params: projectIdParam, + querystring: zDeviceQuery, + }, + handler: c.getTrafficDevices, + }); + + // --------------------------------------------------------------------------- + // Analytics — user flow & engagement + // --------------------------------------------------------------------------- + + fastify.route({ + method: 'GET', + url: '/:projectId/user_flow', + schema: { + tags: TAGS, + description: 'Get user flow paths before, after, or between specified events.', + params: projectIdParam, + querystring: zUserFlowQuery, + }, + handler: c.getUserFlow, + }); + + fastify.route({ + method: 'GET', + url: '/:projectId/engagement', + schema: { + tags: TAGS, + description: 'Get engagement metrics for the project.', + params: projectIdParam, + }, + handler: c.getEngagement, + }); + + // --------------------------------------------------------------------------- + // Events + // --------------------------------------------------------------------------- + + fastify.route({ + method: 'GET', + url: '/:projectId/events', + schema: { + tags: TAGS, + description: 'Query events with optional filters for date range, profile, and properties.', + params: projectIdParam, + querystring: zEventsQuery, + }, + handler: c.queryEvents, + }); + + fastify.route({ + method: 'GET', + url: '/:projectId/events/names', + schema: { + tags: TAGS, + description: 'List all distinct event names tracked in the project.', + params: projectIdParam, + }, + handler: c.listEventNames, + }); + + fastify.route({ + method: 'GET', + url: '/:projectId/events/properties', + schema: { + tags: TAGS, + description: 'List all property keys for a given event name.', + params: projectIdParam, + querystring: zEventPropertiesQuery, + }, + handler: c.listEventProperties, + }); + + fastify.route({ + method: 'GET', + url: '/:projectId/events/property_values', + schema: { + tags: TAGS, + description: 'Get the top values for a specific event property key.', + params: projectIdParam, + querystring: zPropertyValuesQuery, + }, + handler: c.getEventPropertyValues, + }); + + // --------------------------------------------------------------------------- + // Profiles + // --------------------------------------------------------------------------- + + fastify.route({ + method: 'GET', + url: '/:projectId/profiles', + schema: { + tags: TAGS, + description: 'Search and filter user profiles.', + params: projectIdParam, + querystring: zProfilesQuery, + }, + handler: c.findProfiles, + }); + + fastify.route({ + method: 'GET', + url: '/:projectId/profiles/:profileId', + schema: { + tags: TAGS, + description: 'Get a single user profile with their recent events.', + params: profileParam, + querystring: zGetProfileQuery, + }, + handler: c.getProfile, + }); + + fastify.route({ + method: 'GET', + url: '/:projectId/profiles/:profileId/sessions', + schema: { + tags: TAGS, + description: 'Get sessions for a specific user profile.', + params: profileParam, + querystring: zProfileSessionsQuery, + }, + handler: c.getProfileSessions, + }); + + fastify.route({ + method: 'GET', + url: '/:projectId/profiles/:profileId/metrics', + schema: { + tags: TAGS, + description: 'Get aggregated metrics for a specific user profile.', + params: profileParam, + }, + handler: c.getProfileMetrics, + }); + + // --------------------------------------------------------------------------- + // Sessions + // --------------------------------------------------------------------------- + + fastify.route({ + method: 'GET', + url: '/:projectId/sessions', + schema: { + tags: TAGS, + description: 'Query sessions with optional filters.', + params: projectIdParam, + querystring: zSessionsQuery, + }, + handler: c.querySessions, + }); + + // --------------------------------------------------------------------------- + // Groups + // --------------------------------------------------------------------------- + + fastify.route({ + method: 'GET', + url: '/:projectId/groups/types', + schema: { + tags: TAGS, + description: 'List all group types defined in the project.', + params: projectIdParam, + }, + handler: c.listGroupTypes, + }); + + fastify.route({ + method: 'GET', + url: '/:projectId/groups', + schema: { + tags: TAGS, + description: 'Search and filter groups.', + params: projectIdParam, + querystring: zGroupsQuery, + }, + handler: c.findGroups, + }); + + fastify.route({ + method: 'GET', + url: '/:projectId/groups/:groupId', + schema: { + tags: TAGS, + description: 'Get a single group with its members.', + params: groupParam, + querystring: zGetGroupQuery, + }, + handler: c.getGroup, + }); + + // --------------------------------------------------------------------------- + // Dashboards & reports + // --------------------------------------------------------------------------- + + fastify.route({ + method: 'GET', + url: '/:projectId/reports/:reportId/data', + schema: { + tags: TAGS, + description: 'Get the data for a saved report.', + params: reportParam, + }, + handler: c.getReportData, + }); + + // --------------------------------------------------------------------------- + // Google Search Console + // --------------------------------------------------------------------------- + + fastify.route({ + method: 'GET', + url: '/:projectId/gsc/overview', + schema: { + tags: TAGS, + description: 'Get a Google Search Console performance overview (clicks, impressions, CTR, position).', + params: projectIdParam, + querystring: zGscOverviewQuery, + }, + handler: c.gscOverview, + }); + + fastify.route({ + method: 'GET', + url: '/:projectId/gsc/pages', + schema: { + tags: TAGS, + description: 'Get top pages from Google Search Console.', + params: projectIdParam, + querystring: zGscLimitQuery, + }, + handler: c.gscPages, + }); + + fastify.route({ + method: 'GET', + url: '/:projectId/gsc/pages/details', + schema: { + tags: TAGS, + description: 'Get detailed GSC metrics for a specific page URL.', + params: projectIdParam, + querystring: zGscPageDetailsQuery, + }, + handler: c.gscPageDetails, + }); + + fastify.route({ + method: 'GET', + url: '/:projectId/gsc/queries', + schema: { + tags: TAGS, + description: 'Get top search queries from Google Search Console.', + params: projectIdParam, + querystring: zGscLimitQuery, + }, + handler: c.gscQueries, + }); + + fastify.route({ + method: 'GET', + url: '/:projectId/gsc/queries/details', + schema: { + tags: TAGS, + description: 'Get detailed GSC metrics for a specific search query.', + params: projectIdParam, + querystring: zGscQueryDetailsQuery, + }, + handler: c.gscQueryDetails, + }); + + fastify.route({ + method: 'GET', + url: '/:projectId/gsc/queries/opportunities', + schema: { + tags: TAGS, + description: 'Get GSC query opportunities (high impressions, low CTR).', + params: projectIdParam, + querystring: zGscOpportunitiesQuery, + }, + handler: c.gscQueryOpportunities, + }); + + fastify.route({ + method: 'GET', + url: '/:projectId/gsc/cannibalization', + schema: { + tags: TAGS, + description: 'Detect keyword cannibalization across pages in Google Search Console.', + params: projectIdParam, + querystring: zDateRange, + }, + handler: c.gscCannibalization, }); }; diff --git a/apps/api/src/routes/manage.router.ts b/apps/api/src/routes/manage.router.ts index 70ecfd478..815028509 100644 --- a/apps/api/src/routes/manage.router.ts +++ b/apps/api/src/routes/manage.router.ts @@ -1,10 +1,23 @@ +import { Prisma, resolveClientProjectId } from '@openpanel/db'; +import type { FastifyRequest } from 'fastify'; +import type { FastifyPluginAsyncZodOpenApi } from 'fastify-zod-openapi'; +import { z } from 'zod'; import * as controller from '@/controllers/manage.controller'; +import { listDashboards, listReports } from '@/controllers/insights.controller'; +import { + zCreateClient, + zCreateProject, + zCreateReference, + zUpdateClient, + zUpdateProject, + zUpdateReference, +} from '@/controllers/manage.controller'; import { validateManageRequest } from '@/utils/auth'; import { activateRateLimiter } from '@/utils/rate-limiter'; -import { Prisma } from '@openpanel/db'; -import type { FastifyPluginCallback, FastifyRequest } from 'fastify'; -const manageRouter: FastifyPluginCallback = async (fastify) => { +const idParam = z.object({ id: z.string() }); + +const manageRouter: FastifyPluginAsyncZodOpenApi = async (fastify) => { await activateRateLimiter({ fastify, max: 20, @@ -33,36 +46,57 @@ const manageRouter: FastifyPluginCallback = async (fastify) => { .status(401) .send({ error: 'Unauthorized', message: 'Unexpected error' }); } + + // Validate :projectId URL param belongs to this client's organization. + const client = req.client!; + const params = req.params as { projectId?: string }; + if (params.projectId) { + try { + await resolveClientProjectId({ + clientType: 'root', + clientProjectId: null, + organizationId: client.organizationId, + inputProjectId: params.projectId, + }); + } catch { + return reply.status(403).send({ error: 'Forbidden', message: 'Project does not belong to your organization' }); + } + } }); // Projects routes fastify.route({ method: 'GET', url: '/projects', + schema: { tags: ['Manage'], description: 'List all projects for the organization.' }, handler: controller.listProjects, }); fastify.route({ method: 'GET', url: '/projects/:id', + schema: { params: idParam, tags: ['Manage'], description: 'Get a single project by ID.' }, handler: controller.getProject, }); fastify.route({ method: 'POST', url: '/projects', + schema: { body: zCreateProject, tags: ['Manage'], description: 'Create a new project and its first write client.' }, handler: controller.createProject, }); fastify.route({ method: 'PATCH', url: '/projects/:id', + schema: { params: idParam, body: zUpdateProject, tags: ['Manage'], description: 'Update project settings (name, domain, CORS, tracking options).' }, handler: controller.updateProject, }); fastify.route({ method: 'DELETE', url: '/projects/:id', + schema: { params: idParam, tags: ['Manage'], description: 'Soft-delete a project (scheduled for removal in 24 hours).' }, handler: controller.deleteProject, }); @@ -70,30 +104,35 @@ const manageRouter: FastifyPluginCallback = async (fastify) => { fastify.route({ method: 'GET', url: '/clients', + schema: { tags: ['Manage'], description: 'List all API clients for the organization, optionally filtered by project.' }, handler: controller.listClients, }); fastify.route({ method: 'GET', url: '/clients/:id', + schema: { params: idParam, tags: ['Manage'], description: 'Get a single API client by ID.' }, handler: controller.getClient, }); fastify.route({ method: 'POST', url: '/clients', + schema: { body: zCreateClient, tags: ['Manage'], description: 'Create a new API client (read, write, or root type) and return its generated secret.' }, handler: controller.createClient, }); fastify.route({ method: 'PATCH', url: '/clients/:id', + schema: { params: idParam, body: zUpdateClient, tags: ['Manage'], description: 'Update an API client name.' }, handler: controller.updateClient, }); fastify.route({ method: 'DELETE', url: '/clients/:id', + schema: { params: idParam, tags: ['Manage'], description: 'Delete an API client.' }, handler: controller.deleteClient, }); @@ -101,32 +140,60 @@ const manageRouter: FastifyPluginCallback = async (fastify) => { fastify.route({ method: 'GET', url: '/references', + schema: { tags: ['Manage'], description: 'List annotation references for a project.' }, handler: controller.listReferences, }); fastify.route({ method: 'GET', url: '/references/:id', + schema: { params: idParam, tags: ['Manage'], description: 'Get a single annotation reference by ID.' }, handler: controller.getReference, }); fastify.route({ method: 'POST', url: '/references', + schema: { body: zCreateReference, tags: ['Manage'] }, handler: controller.createReference, }); fastify.route({ method: 'PATCH', url: '/references/:id', + schema: { params: idParam, body: zUpdateReference, tags: ['Manage'] }, handler: controller.updateReference, }); fastify.route({ method: 'DELETE', url: '/references/:id', + schema: { params: idParam, tags: ['Manage'] }, handler: controller.deleteReference, }); + + // Dashboards & reports + fastify.route({ + method: 'GET', + url: '/projects/:projectId/dashboards', + schema: { + params: z.object({ projectId: z.string() }), + tags: ['Manage'], + description: 'List all dashboards for a project.', + }, + handler: listDashboards, + }); + + fastify.route({ + method: 'GET', + url: '/projects/:projectId/dashboards/:dashboardId/reports', + schema: { + params: z.object({ projectId: z.string(), dashboardId: z.string() }), + tags: ['Manage'], + description: 'List all reports in a dashboard.', + }, + handler: listReports, + }); }; export default manageRouter; diff --git a/apps/api/src/routes/mcp.router.ts b/apps/api/src/routes/mcp.router.ts new file mode 100644 index 000000000..99a8e79d6 --- /dev/null +++ b/apps/api/src/routes/mcp.router.ts @@ -0,0 +1,81 @@ +import { McpAuthError, authenticateToken, extractToken, handleMcpGet, handleMcpPost, SessionManager } from '@openpanel/mcp'; +import type { FastifyPluginAsync } from 'fastify'; +import { activateRateLimiter } from '@/utils/rate-limiter'; + +/** + * Singleton session manager — lives for the lifetime of the API process. + * Exported so graceful shutdown can clean it up. + */ +export const mcpSessionManager = new SessionManager(); + +const mcpRouter: FastifyPluginAsync = async (fastify) => { + await activateRateLimiter({ fastify, max: 60, timeWindow: '1 minute' }); + + /** + * POST /mcp + * + * Handles both session initialization (no Mcp-Session-Id header) and + * subsequent JSON-RPC messages within an existing session. + * + * First request: authenticate via ?token= query param or Authorization: Bearer. + * Subsequent requests: route by Mcp-Session-Id header. + */ + await fastify.post('/', async (req, reply) => { + // Hand off full response control to the MCP transport + reply.hijack(); + await handleMcpPost( + mcpSessionManager, + req.raw, + reply.raw, + req.body, + req.query as Record, + ); + }); + + /** + * GET /mcp + * + * Establishes an SSE stream for server-to-client notifications. + * Requires Mcp-Session-Id header from a previously initialized session. + */ + await fastify.get('/', async (req, reply) => { + reply.hijack(); + await handleMcpGet(mcpSessionManager, req.raw, reply.raw); + }); + + /** + * DELETE /mcp + * + * Explicitly close an MCP session and free its resources. + * Requires the same auth token used to create the session — verified against + * the session's organizationId to prevent one client closing another's session. + */ + await fastify.delete('/', async (req, reply) => { + const sessionId = req.headers['mcp-session-id'] as string | undefined; + if (!sessionId) { + return reply.status(400).send({ error: 'Mcp-Session-Id header is required' }); + } + + const token = extractToken(req.query as Record, req.headers.authorization); + let callerContext; + try { + callerContext = await authenticateToken(token); + } catch (err) { + return reply.status(401).send({ error: err instanceof McpAuthError ? err.message : 'Unauthorized' }); + } + + const context = await mcpSessionManager.getContext(sessionId); + if (!context) { + return reply.status(404).send({ error: 'Session not found' }); + } + + if (context.organizationId !== callerContext.organizationId) { + return reply.status(403).send({ error: 'Forbidden' }); + } + + await mcpSessionManager.close(sessionId); + return reply.status(200).send({ ok: true }); + }); +}; + +export default mcpRouter; diff --git a/apps/api/src/routes/profile.router.ts b/apps/api/src/routes/profile.router.ts index 990f8b10f..dcdba4e32 100644 --- a/apps/api/src/routes/profile.router.ts +++ b/apps/api/src/routes/profile.router.ts @@ -1,29 +1,41 @@ +import type { FastifyPluginAsyncZodOpenApi } from 'fastify-zod-openapi'; import * as controller from '@/controllers/profile.controller'; import { clientHook } from '@/hooks/client.hook'; import { isBotHook } from '@/hooks/is-bot.hook'; -import type { FastifyPluginCallback } from 'fastify'; -const eventRouter: FastifyPluginCallback = async (fastify) => { +const profileRouter: FastifyPluginAsyncZodOpenApi = async (fastify) => { fastify.addHook('preHandler', clientHook); fastify.addHook('preHandler', isBotHook); fastify.route({ method: 'POST', url: '/', + schema: { + tags: ['Profile'], + description: 'Identify or update a user profile.', + }, handler: controller.updateProfile, }); fastify.route({ method: 'POST', url: '/increment', + schema: { + tags: ['Profile'], + description: 'Increment a numeric property on a user profile.', + }, handler: controller.incrementProfileProperty, }); fastify.route({ method: 'POST', url: '/decrement', + schema: { + tags: ['Profile'], + description: 'Decrement a numeric property on a user profile.', + }, handler: controller.decrementProfileProperty, }); }; -export default eventRouter; +export default profileRouter; diff --git a/apps/api/src/routes/track.router.ts b/apps/api/src/routes/track.router.ts index 1bb04c4b9..77d0bed38 100644 --- a/apps/api/src/routes/track.router.ts +++ b/apps/api/src/routes/track.router.ts @@ -1,36 +1,50 @@ -import type { FastifyPluginCallback } from 'fastify'; +import { zTrackHandlerPayload } from '@openpanel/validation'; +import type { FastifyPluginAsyncZodOpenApi } from 'fastify-zod-openapi'; +import { z } from 'zod'; import { fetchDeviceId, handler } from '@/controllers/track.controller'; import { clientHook } from '@/hooks/client.hook'; import { duplicateHook } from '@/hooks/duplicate.hook'; import { isBotHook } from '@/hooks/is-bot.hook'; -const trackRouter: FastifyPluginCallback = async (fastify) => { +const trackRouter: FastifyPluginAsyncZodOpenApi = async (fastify) => { fastify.addHook('preValidation', duplicateHook); fastify.addHook('preHandler', clientHook); fastify.addHook('preHandler', isBotHook); - fastify.route({ + await fastify.route({ method: 'POST', url: '/', + schema: { + body: zTrackHandlerPayload, + tags: ['Track'], + description: + 'Ingest a tracking event (track, identify, group, increment, decrement, replay).', + response: { + 200: z.object({ + deviceId: z.string(), + sessionId: z.string(), + }), + }, + }, handler, }); - fastify.route({ + await fastify.route({ method: 'GET', url: '/device-id', - handler: fetchDeviceId, schema: { + tags: ['Track'], + description: + 'Get or generate a stable device ID and session ID for the current visitor.', response: { - 200: { - type: 'object', - properties: { - deviceId: { type: 'string' }, - sessionId: { type: 'string' }, - message: { type: 'string', optional: true }, - }, - }, + 200: z.object({ + deviceId: z.string(), + sessionId: z.string(), + message: z.string().optional(), + }), }, }, + handler: fetchDeviceId, }); }; diff --git a/apps/api/src/utils/graceful-shutdown.ts b/apps/api/src/utils/graceful-shutdown.ts index 276762ae6..1b2e970e0 100644 --- a/apps/api/src/utils/graceful-shutdown.ts +++ b/apps/api/src/utils/graceful-shutdown.ts @@ -14,6 +14,7 @@ import { } from '@openpanel/redis'; import type { FastifyInstance } from 'fastify'; import { logger } from './logger'; +import { mcpSessionManager } from '@/routes/mcp.router'; let shuttingDown = false; @@ -29,7 +30,7 @@ export function isShuttingDown() { export async function shutdown( fastify: FastifyInstance, signal: string, - exitCode = 0, + exitCode = 0 ) { if (isShuttingDown()) { logger.warn('Shutdown already in progress, ignoring signal', { signal }); @@ -40,11 +41,11 @@ export async function shutdown( setShuttingDown(true); - // Step 2: Wait for load balancer to stop sending traffic (matches preStop sleep) + // Step 1: Wait for load balancer to stop sending traffic (matches preStop sleep) const gracePeriod = Number(process.env.SHUTDOWN_GRACE_PERIOD_MS || '5000'); await new Promise((resolve) => setTimeout(resolve, gracePeriod)); - // Step 3: Close Fastify to drain in-flight requests + // Step 2: Close Fastify to drain in-flight requests try { await fastify.close(); logger.info('Fastify server closed'); @@ -52,6 +53,14 @@ export async function shutdown( logger.error('Error closing Fastify server', error); } + // Step 3: Destroy MCP sessions + try { + await mcpSessionManager.destroy(); + logger.info('MCP sessions closed'); + } catch (error) { + logger.error('Error closing MCP sessions', error); + } + // Step 4: Close database connections try { await db.$disconnect(); @@ -96,7 +105,7 @@ export async function shutdown( if (redis.status === 'ready') { await redis.quit(); } - }), + }) ); logger.info('Redis connections closed'); } catch (error) { diff --git a/apps/api/src/utils/parse-zod-query-string.ts b/apps/api/src/utils/parse-zod-query-string.ts index b7e976858..fd100afd3 100644 --- a/apps/api/src/utils/parse-zod-query-string.ts +++ b/apps/api/src/utils/parse-zod-query-string.ts @@ -1,23 +1,26 @@ import { getSafeJson } from '@openpanel/json'; +const parseScalar = (v: any): any => { + if (Array.isArray(v)) return v.map(parseScalar); + if (typeof v === 'object' && v !== null) return parseQueryString(v); + if ( + typeof v === 'string' && + /^-?[0-9]+(\.[0-9]+)?$/i.test(v) && + !Number.isNaN(Number.parseFloat(v)) + ) + return Number.parseFloat(v); + if (v === 'true') return true; + if (v === 'false') return false; + if (typeof v === 'string') { + const json = getSafeJson(v); + if (json !== null) return json; + return v; + } + return null; +}; + export const parseQueryString = (obj: Record): any => { return Object.fromEntries( - Object.entries(obj).map(([k, v]) => { - if (typeof v === 'object') return [k, parseQueryString(v)]; - if ( - /^-?[0-9]+(\.[0-9]+)?$/i.test(v) && - !Number.isNaN(Number.parseFloat(v)) - ) - return [k, Number.parseFloat(v)]; - if (v === 'true') return [k, true]; - if (v === 'false') return [k, false]; - if (typeof v === 'string') { - if (getSafeJson(v) !== null) { - return [k, getSafeJson(v)]; - } - return [k, v]; - } - return [k, null]; - }), + Object.entries(obj).map(([k, v]) => [k, parseScalar(v)]), ); }; diff --git a/apps/api/src/utils/rate-limiter.ts b/apps/api/src/utils/rate-limiter.ts index f0cff50bf..491a5fd62 100644 --- a/apps/api/src/utils/rate-limiter.ts +++ b/apps/api/src/utils/rate-limiter.ts @@ -22,7 +22,8 @@ export async function activateRateLimiter({ message: 'You have exceeded the rate limit for this endpoint.', }; }, - redis: getRedisCache(), + // In test mode use in-memory storage so tests don't need a running Redis + redis: process.env.NODE_ENV !== 'test' ? getRedisCache() : undefined, keyGenerator(req) { if (keyGenerator) { const key = keyGenerator(req as T); diff --git a/apps/api/tsconfig.json b/apps/api/tsconfig.json index aaab48cc2..7c29b89c2 100644 --- a/apps/api/tsconfig.json +++ b/apps/api/tsconfig.json @@ -8,6 +8,6 @@ "tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json", "strictNullChecks": true }, - "include": ["."], + "include": [".", "../../test"], "exclude": ["node_modules", "dist"] } diff --git a/apps/api/vitest.config.ts b/apps/api/vitest.config.ts new file mode 100644 index 000000000..f87a2039f --- /dev/null +++ b/apps/api/vitest.config.ts @@ -0,0 +1,3 @@ +import { getSharedVitestConfig } from '../../vitest.shared'; + +export default getSharedVitestConfig({ __dirname }); diff --git a/apps/public/content/docs/api-reference/authentication.mdx b/apps/public/content/docs/api-reference/authentication.mdx new file mode 100644 index 000000000..87b1cfcfb --- /dev/null +++ b/apps/public/content/docs/api-reference/authentication.mdx @@ -0,0 +1,29 @@ +--- +title: Authentication +description: How to authenticate with the OpenPanel API +--- + +## Client ID & Secret + +OpenPanel uses client credentials for authentication. Every API client has a **Client ID** and a **Client Secret** that you generate from the dashboard. + +Pass them as request headers: + +```http +openpanel-client-id: +openpanel-client-secret: +``` + +## API Client Types + +| Type | Description | +| ------- | ------------------------------------------------ | +| `write` | Can ingest events and profile updates | +| `read` | Can query analytics data (insights, export, etc) | +| `root` | Full access — use only for server-side admin | + +## Creating a Client + +Go to **Settings → API Clients** in your dashboard and create a client with the appropriate type. Copy the secret immediately — it is only shown once. + +For more details on authentication, permissions, and security best practices, see the [Authentication guide](/docs/api/authentication). diff --git a/apps/public/content/docs/api-reference/rate-limits.mdx b/apps/public/content/docs/api-reference/rate-limits.mdx new file mode 100644 index 000000000..2e86324aa --- /dev/null +++ b/apps/public/content/docs/api-reference/rate-limits.mdx @@ -0,0 +1,30 @@ +--- +title: Rate Limits +description: Request limits and how to handle them +--- + +## Limits + +| Endpoint group | Limit | +| -------------- | ------------------- | +| Insights | 100 req / 10 seconds | +| Export | 100 req / 10 seconds | +| Manage | 20 req / 10 seconds | + +Limits are applied per **Client ID**. + +Track, Profile, and Import endpoints do not have a rate limit applied. + +## Handling 429s + +When you exceed the limit the API returns `429 Too Many Requests`: + +```json +{ + "status": 429, + "error": "Too Many Requests", + "message": "You have exceeded the rate limit for this endpoint." +} +``` + +Wait for the rate limit window to reset before retrying. diff --git a/apps/public/content/docs/api/export.mdx b/apps/public/content/docs/api/export.mdx index bb7250463..2ccb9fa6a 100644 --- a/apps/public/content/docs/api/export.mdx +++ b/apps/public/content/docs/api/export.mdx @@ -1,441 +1,25 @@ --- title: Export -description: The Export API allows you to retrieve event data and chart data from your OpenPanel projects for analysis, reporting, and data integration. +description: Retrieve raw events and aggregated chart data from your projects. --- ## Authentication -To authenticate with the Export API, you need to use your `clientId` and `clientSecret`. Make sure your client has `read` or `root` mode. The default client does not have access to the Export API. - -For detailed authentication information, see the [Authentication](/docs/api/authentication) guide. - -Include the following headers with your requests: -- `openpanel-client-id`: Your OpenPanel client ID -- `openpanel-client-secret`: Your OpenPanel client secret - -## Base URL - -All Export API requests should be made to: - -``` -https://api.openpanel.dev/export -``` - -## Common Query Parameters - -Most endpoints support the following query parameters: - -| Parameter | Type | Description | Default | -|-----------|------|-------------|---------| -| `projectId` | string | The ID of the project (alternative: `project_id`) | Required | -| `startDate` | string | Start date (ISO format: YYYY-MM-DD) | Based on range | -| `endDate` | string | End date (ISO format: YYYY-MM-DD) | Based on range | -| `range` | string | Predefined date range (`7d`, `30d`, `today`, etc.) | None | +Requires a `read` or `root` client — the default `write` client does not have access. See the [Authentication](/docs/api/authentication) guide. ## Endpoints -### Get Events - -Retrieve individual events from a specific project within a date range. This endpoint provides raw event data with optional filtering and pagination. - -``` -GET /export/events -``` - -#### Query Parameters - -| Parameter | Type | Description | Example | -|-----------|------|-------------|---------| -| `projectId` | string | The ID of the project to fetch events from | `abc123` | -| `profileId` | string | Filter events by specific profile/user ID | `user_123` | -| `event` | string or string[] | Event name(s) to filter | `screen_view` or `["screen_view","button_click"]` | -| `start` | string | Start date for the event range (ISO format) | `2024-04-15` | -| `end` | string | End date for the event range (ISO format) | `2024-04-18` | -| `page` | number | Page number for pagination (default: 1) | `2` | -| `limit` | number | Number of events per page (default: 50, max: 1000) | `100` | -| `includes` | string or string[] | Additional fields to include in the response. Pass multiple as comma-separated (`profile,meta`) or repeated params (`includes=profile&includes=meta`). | `profile` or `profile,meta` | - -#### Include Options - -The `includes` parameter allows you to fetch additional related data. When using query parameters, you can pass multiple values in either of these ways: - -- **Comma-separated**: `?includes=profile,meta` (include both profile and meta in the response) -- **Repeated parameter**: `?includes=profile&includes=meta` (same result; useful when building URLs programmatically) - -Supported values (any of these can be combined; names match the response keys): - -**Related data** (adds nested objects or extra lookups): - -- `profile` — User profile for the event (id, email, firstName, lastName, etc.) -- `meta` — Event metadata from project config (name, description, conversion flag) - -**Event fields** (optional columns; these are in addition to the default fields): - -- `properties` — Custom event properties -- `region`, `longitude`, `latitude` — Extra geo (default already has `city`, `country`) -- `osVersion`, `browserVersion`, `device`, `brand`, `model` — Extra device (default already has `os`, `browser`) -- `origin`, `referrer`, `referrerName`, `referrerType` — Referrer/navigation -- `revenue` — Revenue amount -- `importedAt`, `sdkName`, `sdkVersion` — Import/SDK info - -The response always includes: `id`, `name`, `deviceId`, `profileId`, `sessionId`, `projectId`, `createdAt`, `path`, `duration`, `city`, `country`, `os`, `browser`. Use `includes` to add any of the values above. - -#### Example Request - -```bash -curl 'https://api.openpanel.dev/export/events?projectId=abc123&event=screen_view&start=2024-04-15&end=2024-04-18&page=1&limit=100&includes=profile,meta' \ - -H 'openpanel-client-id: YOUR_CLIENT_ID' \ - -H 'openpanel-client-secret: YOUR_CLIENT_SECRET' -``` - -#### Response - -```json -{ - "meta": { - "count": 50, - "totalCount": 1250, - "pages": 25, - "current": 1 - }, - "data": [ - { - "id": "evt_123456789", - "name": "screen_view", - "deviceId": "device_abc123", - "profileId": "user_789", - "projectId": "abc123", - "sessionId": "session_xyz", - "properties": { - "path": "/dashboard", - "title": "Dashboard", - "url": "https://example.com/dashboard" - }, - "createdAt": "2024-04-15T10:30:00.000Z", - "country": "United States", - "city": "New York", - "region": "New York", - "os": "macOS", - "browser": "Chrome", - "device": "Desktop", - "duration": 0, - "path": "/dashboard", - "origin": "https://example.com", - "profile": { - "id": "user_789", - "email": "user@example.com", - "firstName": "John", - "lastName": "Doe", - "isExternal": true, - "createdAt": "2024-04-01T08:00:00.000Z" - }, - "meta": { - "name": "screen_view", - "description": "Page view tracking", - "conversion": false - } - } - ] -} -``` - -### Get Charts - -Retrieve aggregated chart data for analytics and visualization. This endpoint provides time-series data with advanced filtering, breakdowns, and comparison capabilities. - -``` -GET /export/charts -``` - -**Note:** The endpoint accepts either `series` or `events` for the event configuration; `series` is the preferred parameter name. Both use the same structure. - -#### Query Parameters - -| Parameter | Type | Description | Example | -|-----------|------|-------------|---------| -| `projectId` | string | The ID of the project to fetch chart data from | `abc123` | -| `series` | object[] | Array of event/series configurations to analyze (preferred over `events`) | `[{"name":"screen_view","filters":[]}]` | -| `events` | object[] | Array of event configurations (deprecated in favor of `series`) | `[{"name":"screen_view","filters":[]}]` | -| `breakdowns` | object[] | Array of breakdown dimensions | `[{"name":"country"}]` | -| `interval` | string | Time interval for data points | `day` | -| `range` | string | Predefined date range | `7d` | -| `previous` | boolean | Include data from the previous period for comparison | `true` | -| `startDate` | string | Custom start date (ISO format) | `2024-04-01` | -| `endDate` | string | Custom end date (ISO format) | `2024-04-30` | - -#### Event Configuration - -Each item in the `series` or `events` array supports the following properties: - -| Property | Type | Description | Required | Default | -|----------|------|-------------|----------|---------| -| `name` | string | Name of the event to track | Yes | - | -| `filters` | Filter[] | Array of filters to apply to the event | No | `[]` | -| `segment` | string | Type of segmentation | No | `event` | -| `property` | string | Property name for property-based segments | No | - | - -#### Segmentation Options - -- `event`: Count individual events (default) -- `user`: Count unique users/profiles -- `session`: Count unique sessions -- `user_average`: Average events per user -- `one_event_per_user`: One event per user (deduplicated) -- `property_sum`: Sum of a numeric property -- `property_average`: Average of a numeric property -- `property_min`: Minimum value of a numeric property -- `property_max`: Maximum value of a numeric property - -#### Filter Configuration - -Each filter in the `filters` array supports: - -| Property | Type | Description | Required | -|----------|------|-------------|----------| -| `name` | string | Property name to filter on | Yes | -| `operator` | string | Comparison operator | Yes | -| `value` | array | Array of values to compare against | Yes | - -#### Filter Operators - -- `is`: Exact match -- `isNot`: Not equal to -- `contains`: Contains substring -- `doesNotContain`: Does not contain substring -- `startsWith`: Starts with -- `endsWith`: Ends with -- `regex`: Regular expression match -- `isNull`: Property is null or empty -- `isNotNull`: Property has a value - -#### Breakdown Dimensions - -Common breakdown dimensions include: - -| Dimension | Description | Example Values | -|-----------|-------------|----------------| -| `country` | User's country | `United States`, `Canada` | -| `region` | User's region/state | `California`, `New York` | -| `city` | User's city | `San Francisco`, `New York` | -| `device` | Device type | `Desktop`, `Mobile`, `Tablet` | -| `browser` | Browser name | `Chrome`, `Firefox`, `Safari` | -| `os` | Operating system | `macOS`, `Windows`, `iOS` | -| `referrer` | Referrer URL | `google.com`, `facebook.com` | -| `path` | Page path | `/`, `/dashboard`, `/pricing` | - -#### Time Intervals - -- `minute`: Minute-by-minute data -- `hour`: Hourly aggregation -- `day`: Daily aggregation (default) -- `week`: Weekly aggregation -- `month`: Monthly aggregation - -#### Date Ranges - -- `30min`: Last 30 minutes -- `lastHour`: Last hour -- `today`: Current day -- `yesterday`: Previous day -- `7d`: Last 7 days -- `30d`: Last 30 days -- `6m`: Last 6 months -- `12m`: Last 12 months -- `monthToDate`: Current month to date -- `lastMonth`: Previous month -- `yearToDate`: Current year to date -- `lastYear`: Previous year - -#### Example Request - -```bash -curl 'https://api.openpanel.dev/export/charts?projectId=abc123&series=[{"name":"screen_view","segment":"user"}]&breakdowns=[{"name":"country"}]&interval=day&range=30d&previous=true' \ - -H 'openpanel-client-id: YOUR_CLIENT_ID' \ - -H 'openpanel-client-secret: YOUR_CLIENT_SECRET' -``` - -You can use `events` instead of `series` in the query for backward compatibility; both accept the same structure. - -#### Example Advanced Request - -```bash -curl 'https://api.openpanel.dev/export/charts' \ - -H 'openpanel-client-id: YOUR_CLIENT_ID' \ - -H 'openpanel-client-secret: YOUR_CLIENT_SECRET' \ - -G \ - --data-urlencode 'projectId=abc123' \ - --data-urlencode 'series=[{"name":"purchase","segment":"property_sum","property":"properties.total","filters":[{"name":"properties.total","operator":"isNotNull","value":[]}]}]' \ - --data-urlencode 'breakdowns=[{"name":"country"}]' \ - --data-urlencode 'interval=day' \ - --data-urlencode 'range=30d' -``` - -#### Response - -```json -{ - "series": [ - { - "id": "screen_view-united-states", - "names": ["screen_view", "United States"], - "event": { - "id": "evt1", - "name": "screen_view" - }, - "metrics": { - "sum": 1250, - "average": 41.67, - "min": 12, - "max": 89, - "previous": { - "sum": { - "value": 1100, - "change": 13.64 - }, - "average": { - "value": 36.67, - "change": 13.64 - } - } - }, - "data": [ - { - "date": "2024-04-01T00:00:00.000Z", - "count": 45, - "previous": { - "value": 38, - "change": 18.42 - } - }, - { - "date": "2024-04-02T00:00:00.000Z", - "count": 52, - "previous": { - "value": 41, - "change": 26.83 - } - } - ] - } - ], - "metrics": { - "sum": 1250, - "average": 41.67, - "min": 12, - "max": 89, - "previous": { - "sum": { - "value": 1100, - "change": 13.64 - } - } - } -} -``` - -## Error Handling - -The API uses standard HTTP response codes. Common error responses: - -### 400 Bad Request - -```json -{ - "error": "Bad Request", - "message": "Invalid query parameters", - "details": [ - { - "path": ["events", 0, "name"], - "message": "Required" - } - ] -} -``` - -### 401 Unauthorized - -```json -{ - "error": "Unauthorized", - "message": "Invalid client credentials" -} -``` - -### 403 Forbidden - -```json -{ - "error": "Forbidden", - "message": "You do not have access to this project" -} -``` - -### 404 Not Found - -```json -{ - "error": "Not Found", - "message": "Project not found" -} -``` - -### 429 Too Many Requests - -Rate limiting response includes headers indicating your rate limit status. - -## Rate Limiting - -The Export API implements rate limiting: -- **100 requests per 10 seconds** per client -- Rate limit headers included in responses -- Implement exponential backoff for retries - -## Data Types and Formats - -### Event Properties - -Event properties are stored as key-value pairs and can include: - -- **Built-in properties**: `path`, `origin`, `title`, `url`, `hash` -- **UTM parameters**: `utm_source`, `utm_medium`, `utm_campaign`, `utm_term`, `utm_content` -- **Custom properties**: Any custom data you track with your events - -### Property Access - -Properties can be accessed in filters and breakdowns using dot notation: - -- `properties.custom_field`: Access custom properties -- `profile.properties.user_type`: Access profile properties -- `properties.__query.utm_source`: Access query parameters - -### Date Handling - -- All dates are in ISO 8601 format -- Timezone handling is done server-side based on project settings -- Date ranges are inclusive of start and end dates - -### Geographic Data - -Geographic information is automatically collected when available: - -- `country`: Full country name -- `region`: State/province/region -- `city`: City name -- `longitude`/`latitude`: Coordinates (when available) - -### Device Information +| Endpoint | Description | +|----------|-------------| +| `GET /export/events` | Paginated list of raw events with optional filtering | +| `GET /export/charts` | Aggregated time-series data with breakdowns | -Device data is collected from user agents: +### Filtering events -- `device`: Device type (Desktop, Mobile, Tablet) -- `browser`: Browser name and version -- `os`: Operating system and version -- `brand`/`model`: Device brand and model (mobile devices) +The `/export/events` endpoint accepts filters, pagination, and an `includes` parameter to attach related data (profile, meta, properties, geo, device, referrer). -## Notes +### Chart series -- Event data is typically available within seconds of tracking -- All timezone handling is done server-side based on project settings -- Property names are case-sensitive in filters and breakdowns +The `/export/charts` endpoint accepts a `series` array where each item can specify an event name, filters, and a segment type (`event`, `user`, `session`, `property_sum`, etc.). -Remember to replace `YOUR_CLIENT_ID` and `YOUR_CLIENT_SECRET` with your actual OpenPanel API credentials. \ No newline at end of file +For full query parameter and response schemas, see the [API Reference](/docs/api-reference/export). diff --git a/apps/public/content/docs/api/insights.mdx b/apps/public/content/docs/api/insights.mdx index 9d800ac1e..1a744e8be 100644 --- a/apps/public/content/docs/api/insights.mdx +++ b/apps/public/content/docs/api/insights.mdx @@ -1,405 +1,30 @@ --- title: Insights -description: The Insights API provides access to website analytics data including metrics, page views, visitor statistics, and detailed breakdowns by various dimensions. +description: Query analytics data including metrics, pages, referrers, devices, and geo breakdowns. --- ## Authentication -To authenticate with the Insights API, you need to use your `clientId` and `clientSecret`. Make sure your client has `read` or `root` mode. The default client does not have access to the Insights API. - -For detailed authentication information, see the [Authentication](/docs/api/authentication) guide. - -Include the following headers with your requests: -- `openpanel-client-id`: Your OpenPanel client ID -- `openpanel-client-secret`: Your OpenPanel client secret +Requires a `read` or `root` client — the default `write` client does not have access. See the [Authentication](/docs/api/authentication) guide. ## Base URL -All Insights API requests should be made to: - -``` -https://api.openpanel.dev/insights -``` - -## Common Query Parameters - -Most endpoints support the following query parameters: - -| Parameter | Type | Description | Default | -|-----------|------|-------------|---------| -| `startDate` | string | Start date (ISO format: YYYY-MM-DD) | Based on range | -| `endDate` | string | End date (ISO format: YYYY-MM-DD) | Based on range | -| `range` | string | Predefined date range (`7d`, `30d`, `90d`, etc.) | `7d` | -| `filters` | array | Event filters to apply | `[]` | -| `cursor` | number | Page number for pagination | `1` | -| `limit` | number | Number of results per page (max: 50) | `10` | - -### Filter Configuration - -Filters can be applied to narrow down results. Each filter has the following structure: - -```json -{ - "name": "property_name", - "operator": "is|isNot|contains|doesNotContain|startsWith|endsWith|regex", - "value": ["value1", "value2"] -} -``` - -## Endpoints - -### Get Metrics - -Retrieve comprehensive website metrics including visitors, sessions, page views, and engagement data. - -``` -GET /insights/{projectId}/metrics -``` - -#### Query Parameters - -| Parameter | Type | Description | Example | -|-----------|------|-------------|---------| -| `startDate` | string | Start date for metrics | `2024-01-01` | -| `endDate` | string | End date for metrics | `2024-01-31` | -| `range` | string | Predefined range | `7d` | -| `filters` | array | Event filters | `[{"name":"path","operator":"is","value":["/home"]}]` | - -#### Example Request - -```bash -curl 'https://api.openpanel.dev/insights/abc123/metrics?range=30d&filters=[{"name":"path","operator":"contains","value":["/product"]}]' \ - -H 'openpanel-client-id: YOUR_CLIENT_ID' \ - -H 'openpanel-client-secret: YOUR_CLIENT_SECRET' -``` - -#### Response - -```json -{ - "metrics": { - "bounce_rate": 45.2, - "unique_visitors": 1250, - "total_sessions": 1580, - "avg_session_duration": 185.5, - "total_screen_views": 4230, - "views_per_session": 2.67 - }, - "series": [ - { - "date": "2024-01-01T00:00:00.000Z", - "bounce_rate": 42.1, - "unique_visitors": 85, - "total_sessions": 98, - "avg_session_duration": 195.2, - "total_screen_views": 156, - "views_per_session": 1.59 - } - ] -} -``` - -### Get Live Visitors - -Get the current number of active visitors on your website in real-time. - -``` -GET /insights/{projectId}/live -``` - -#### Example Request - -```bash -curl 'https://api.openpanel.dev/insights/abc123/live' \ - -H 'openpanel-client-id: YOUR_CLIENT_ID' \ - -H 'openpanel-client-secret: YOUR_CLIENT_SECRET' -``` - -#### Response - -```json -{ - "visitors": 23 -} -``` - -### Get Top Pages - -Retrieve the most visited pages with detailed analytics including session count, bounce rate, and average time on page. - -``` -GET /insights/{projectId}/pages -``` - -#### Query Parameters - -| Parameter | Type | Description | Example | -|-----------|------|-------------|---------| -| `startDate` | string | Start date | `2024-01-01` | -| `endDate` | string | End date | `2024-01-31` | -| `range` | string | Predefined range | `7d` | -| `filters` | array | Event filters | `[]` | -| `cursor` | number | Page number | `1` | -| `limit` | number | Results per page (max: 50) | `10` | - -#### Example Request - -```bash -curl 'https://api.openpanel.dev/insights/abc123/pages?range=7d&limit=20' \ - -H 'openpanel-client-id: YOUR_CLIENT_ID' \ - -H 'openpanel-client-secret: YOUR_CLIENT_SECRET' -``` - -#### Response - -```json -[ - { - "title": "Homepage - Example Site", - "origin": "https://example.com", - "path": "/", - "sessions": 456, - "bounce_rate": 35.2, - "avg_duration": 125.8 - }, - { - "title": "About Us", - "origin": "https://example.com", - "path": "/about", - "sessions": 234, - "bounce_rate": 45.1, - "avg_duration": 89.3 - } -] -``` - -### Get Referrer Data - -Retrieve referrer analytics to understand where your traffic is coming from. - -``` -GET /insights/{projectId}/referrer -GET /insights/{projectId}/referrer_name -GET /insights/{projectId}/referrer_type -``` - -#### Example Request - -```bash -curl 'https://api.openpanel.dev/insights/abc123/referrer?range=30d&limit=15' \ - -H 'openpanel-client-id: YOUR_CLIENT_ID' \ - -H 'openpanel-client-secret: YOUR_CLIENT_SECRET' -``` - -#### Response - -```json -[ - { - "name": "google.com", - "sessions": 567, - "bounce_rate": 42.1, - "avg_session_duration": 156.7 - }, - { - "name": "facebook.com", - "sessions": 234, - "bounce_rate": 38.9, - "avg_session_duration": 189.2 - } -] -``` - -### Get UTM Campaign Data - -Analyze your marketing campaigns with UTM parameter breakdowns. - -``` -GET /insights/{projectId}/utm_source -GET /insights/{projectId}/utm_medium -GET /insights/{projectId}/utm_campaign -GET /insights/{projectId}/utm_term -GET /insights/{projectId}/utm_content -``` - -#### Example Request - -```bash -curl 'https://api.openpanel.dev/insights/abc123/utm_source?range=30d' \ - -H 'openpanel-client-id: YOUR_CLIENT_ID' \ - -H 'openpanel-client-secret: YOUR_CLIENT_SECRET' -``` - -#### Response - -```json -[ - { - "name": "google", - "sessions": 890, - "bounce_rate": 35.4, - "avg_session_duration": 178.9 - }, - { - "name": "facebook", - "sessions": 456, - "bounce_rate": 41.2, - "avg_session_duration": 142.3 - } -] -``` - -### Get Geographic Data - -Understand your audience location with country, region, and city breakdowns. - -``` -GET /insights/{projectId}/country -GET /insights/{projectId}/region -GET /insights/{projectId}/city -``` - -#### Example Request - -```bash -curl 'https://api.openpanel.dev/insights/abc123/country?range=30d&limit=20' \ - -H 'openpanel-client-id: YOUR_CLIENT_ID' \ - -H 'openpanel-client-secret: YOUR_CLIENT_SECRET' -``` - -#### Response - -```json -[ - { - "name": "United States", - "sessions": 1234, - "bounce_rate": 38.7, - "avg_session_duration": 167.4 - }, - { - "name": "United Kingdom", - "sessions": 567, - "bounce_rate": 42.1, - "avg_session_duration": 145.8 - } -] -``` - -For region and city endpoints, an additional `prefix` field may be included: - -```json -[ - { - "prefix": "United States", - "name": "California", - "sessions": 456, - "bounce_rate": 35.2, - "avg_session_duration": 172.1 - } -] -``` - -### Get Device & Technology Data - -Analyze visitor devices, browsers, and operating systems. - -``` -GET /insights/{projectId}/device -GET /insights/{projectId}/browser -GET /insights/{projectId}/browser_version -GET /insights/{projectId}/os -GET /insights/{projectId}/os_version -GET /insights/{projectId}/brand -GET /insights/{projectId}/model ``` - -#### Example Request - -```bash -curl 'https://api.openpanel.dev/insights/abc123/browser?range=7d' \ - -H 'openpanel-client-id: YOUR_CLIENT_ID' \ - -H 'openpanel-client-secret: YOUR_CLIENT_SECRET' -``` - -#### Response - -```json -[ - { - "name": "Chrome", - "sessions": 789, - "bounce_rate": 36.4, - "avg_session_duration": 162.3 - }, - { - "name": "Firefox", - "sessions": 234, - "bounce_rate": 41.7, - "avg_session_duration": 148.9 - } -] +https://api.openpanel.dev/insights/{projectId} ``` -For version-specific endpoints (browser_version, os_version), a `prefix` field shows the parent: - -```json -[ - { - "prefix": "Chrome", - "name": "118.0.0.0", - "sessions": 456, - "bounce_rate": 35.8, - "avg_session_duration": 165.7 - } -] -``` - -## Error Handling - -The API uses standard HTTP response codes. Common error responses: - -### 400 Bad Request - -```json -{ - "error": "Bad Request", - "message": "Invalid query parameters", - "details": { - "issues": [ - { - "path": ["range"], - "message": "Invalid enum value" - } - ] - } -} -``` - -### 401 Unauthorized - -```json -{ - "error": "Unauthorized", - "message": "Invalid client credentials" -} -``` - -### 429 Too Many Requests - -Rate limiting response includes headers indicating your rate limit status. - -## Rate Limiting +## Available endpoints -The Insights API implements rate limiting: -- **100 requests per 10 seconds** per client -- Rate limit headers included in responses -- Implement exponential backoff for retries +| Endpoint | Description | +|----------|-------------| +| `GET /metrics` | Visitors, sessions, bounce rate, and engagement | +| `GET /live` | Current active visitor count | +| `GET /pages` | Top pages by sessions | +| `GET /referrer` | Traffic sources | +| `GET /country`, `/region`, `/city` | Geographic breakdown | +| `GET /device`, `/browser`, `/os` | Device and technology breakdown | +| `GET /utm_source`, `/utm_campaign`, … | UTM parameter breakdown | -## Notes +Most endpoints accept `startDate`, `endDate`, `range`, `filters`, `cursor`, and `limit` query parameters. -- All dates are returned in ISO 8601 format -- Durations are in seconds -- Bounce rates and percentages are returned as decimal numbers (e.g., 45.2 = 45.2%) -- Session duration is the average time spent on the website -- All timezone handling is done server-side based on project settings +For full schemas and all available endpoints, see the [API Reference](/docs/api-reference/insights). diff --git a/apps/public/content/docs/api/manage/clients.mdx b/apps/public/content/docs/api/manage/clients.mdx index 91c9fd2eb..ec014d0a7 100644 --- a/apps/public/content/docs/api/manage/clients.mdx +++ b/apps/public/content/docs/api/manage/clients.mdx @@ -5,328 +5,32 @@ description: Manage API clients for your OpenPanel projects. Create, read, updat ## Authentication -To authenticate with the Clients API, you need to use your `clientId` and `clientSecret` from a root client. Root clients have organization-wide access. - -For detailed authentication information, see the [Authentication](/docs/api/authentication) guide. - -Include the following headers with your requests: -- `openpanel-client-id`: Your OpenPanel root client ID -- `openpanel-client-secret`: Your OpenPanel root client secret +Requires a `root` client. See the [Authentication](/docs/api/authentication) guide. ## Base URL -All Clients API requests should be made to: - ``` https://api.openpanel.dev/manage/clients ``` -## Client Types +## Client types -OpenPanel supports three client types with different access levels: - -| Type | Description | Use Case | -|------|-------------|----------| -| `read` | Read-only access | Export data, view insights, read-only operations | -| `write` | Write access | Track events, send data to OpenPanel | -| `root` | Full access | Manage resources, access Manage API | - -**Note**: Only `root` clients can access the Manage API. +| Type | Description | +|------|-------------| +| `write` | Ingest events and profile updates | +| `read` | Query analytics data (insights, export) | +| `root` | Full access — manage API, read, and write | ## Endpoints -### List Clients - -Retrieve all clients in your organization, optionally filtered by project. - -``` -GET /manage/clients -``` - -#### Query Parameters - -| Parameter | Type | Description | -|-----------|------|-------------| -| `projectId` | string | Optional. Filter clients by project ID | - -#### Example Request - -```bash -# List all clients -curl 'https://api.openpanel.dev/manage/clients' \ - -H 'openpanel-client-id: YOUR_ROOT_CLIENT_ID' \ - -H 'openpanel-client-secret: YOUR_ROOT_CLIENT_SECRET' - -# List clients for a specific project -curl 'https://api.openpanel.dev/manage/clients?projectId=my-project' \ - -H 'openpanel-client-id: YOUR_ROOT_CLIENT_ID' \ - -H 'openpanel-client-secret: YOUR_ROOT_CLIENT_SECRET' -``` - -#### Response - -```json -{ - "data": [ - { - "id": "fa0c2780-55f2-4d9e-bea0-da2e02c7b1a9", - "name": "First client", - "type": "write", - "projectId": "my-project", - "organizationId": "org_123", - "ignoreCorsAndSecret": false, - "createdAt": "2024-01-15T10:30:00.000Z", - "updatedAt": "2024-01-15T10:30:00.000Z" - }, - { - "id": "b8904453-863d-4e04-8ebc-8abae30ffb1a", - "name": "Read-only Client", - "type": "read", - "projectId": "my-project", - "organizationId": "org_123", - "ignoreCorsAndSecret": false, - "createdAt": "2024-01-15T11:00:00.000Z", - "updatedAt": "2024-01-15T11:00:00.000Z" - } - ] -} -``` - -**Note**: Client secrets are never returned in list or get responses for security reasons. - -### Get Client - -Retrieve a specific client by ID. - -``` -GET /manage/clients/{id} -``` - -#### Path Parameters - -| Parameter | Type | Description | -|-----------|------|-------------| -| `id` | string | The ID of the client (UUID) | - -#### Example Request - -```bash -curl 'https://api.openpanel.dev/manage/clients/fa0c2780-55f2-4d9e-bea0-da2e02c7b1a9' \ - -H 'openpanel-client-id: YOUR_ROOT_CLIENT_ID' \ - -H 'openpanel-client-secret: YOUR_ROOT_CLIENT_SECRET' -``` - -#### Response - -```json -{ - "data": { - "id": "fa0c2780-55f2-4d9e-bea0-da2e02c7b1a9", - "name": "First client", - "type": "write", - "projectId": "my-project", - "organizationId": "org_123", - "ignoreCorsAndSecret": false, - "createdAt": "2024-01-15T10:30:00.000Z", - "updatedAt": "2024-01-15T10:30:00.000Z" - } -} -``` - -### Create Client - -Create a new API client. A secure secret is automatically generated and returned once. - -``` -POST /manage/clients -``` - -#### Request Body - -| Parameter | Type | Required | Description | -|-----------|------|----------|-------------| -| `name` | string | Yes | Client name (minimum 1 character) | -| `projectId` | string | No | Associate client with a specific project | -| `type` | string | No | Client type: `read`, `write`, or `root` (default: `write`) | - -#### Example Request - -```bash -curl -X POST 'https://api.openpanel.dev/manage/clients' \ - -H 'openpanel-client-id: YOUR_ROOT_CLIENT_ID' \ - -H 'openpanel-client-secret: YOUR_ROOT_CLIENT_SECRET' \ - -H 'Content-Type: application/json' \ - -d '{ - "name": "My API Client", - "projectId": "my-project", - "type": "read" - }' -``` - -#### Response - -```json -{ - "data": { - "id": "b8904453-863d-4e04-8ebc-8abae30ffb1a", - "name": "My API Client", - "type": "read", - "projectId": "my-project", - "organizationId": "org_123", - "ignoreCorsAndSecret": false, - "createdAt": "2024-01-15T11:00:00.000Z", - "updatedAt": "2024-01-15T11:00:00.000Z", - "secret": "sec_b2521ca283bf903b46b3" - } -} -``` - -**Important**: The `secret` field is only returned once when the client is created. Store it securely immediately. You cannot retrieve the secret later - if lost, you'll need to delete and recreate the client. - -### Update Client - -Update an existing client's name. - -``` -PATCH /manage/clients/{id} -``` - -#### Path Parameters - -| Parameter | Type | Description | -|-----------|------|-------------| -| `id` | string | The ID of the client (UUID) | - -#### Request Body - -| Parameter | Type | Required | Description | -|-----------|------|----------|-------------| -| `name` | string | No | New client name (minimum 1 character) | - -**Note**: Currently, only the `name` field can be updated. To change the client type or project association, delete and recreate the client. - -#### Example Request - -```bash -curl -X PATCH 'https://api.openpanel.dev/manage/clients/b8904453-863d-4e04-8ebc-8abae30ffb1a' \ - -H 'openpanel-client-id: YOUR_ROOT_CLIENT_ID' \ - -H 'openpanel-client-secret: YOUR_ROOT_CLIENT_SECRET' \ - -H 'Content-Type: application/json' \ - -d '{ - "name": "Updated Client Name" - }' -``` - -#### Response - -```json -{ - "data": { - "id": "b8904453-863d-4e04-8ebc-8abae30ffb1a", - "name": "Updated Client Name", - "type": "read", - "projectId": "my-project", - "organizationId": "org_123", - "ignoreCorsAndSecret": false, - "createdAt": "2024-01-15T11:00:00.000Z", - "updatedAt": "2024-01-15T11:30:00.000Z" - } -} -``` - -### Delete Client - -Permanently delete a client. This action cannot be undone. - -``` -DELETE /manage/clients/{id} -``` - -#### Path Parameters - -| Parameter | Type | Description | -|-----------|------|-------------| -| `id` | string | The ID of the client (UUID) | - -#### Example Request - -```bash -curl -X DELETE 'https://api.openpanel.dev/manage/clients/b8904453-863d-4e04-8ebc-8abae30ffb1a' \ - -H 'openpanel-client-id: YOUR_ROOT_CLIENT_ID' \ - -H 'openpanel-client-secret: YOUR_ROOT_CLIENT_SECRET' -``` - -#### Response - -```json -{ - "success": true -} -``` - -**Warning**: Deleting a client is permanent. Any applications using this client will immediately lose access. Make sure to update your applications before deleting a client. - -## Error Handling - -The API uses standard HTTP response codes. Common error responses: - -### 400 Bad Request - -```json -{ - "error": "Bad Request", - "message": "Invalid request body", - "details": [ - { - "path": ["name"], - "message": "String must contain at least 1 character(s)" - } - ] -} -``` - -### 401 Unauthorized - -```json -{ - "error": "Unauthorized", - "message": "Manage: Only root clients are allowed to manage resources" -} -``` - -### 404 Not Found - -```json -{ - "error": "Not Found", - "message": "Client not found" -} -``` - -### 429 Too Many Requests - -Rate limiting response includes headers indicating your rate limit status. - -## Rate Limiting - -The Clients API implements rate limiting: -- **20 requests per 10 seconds** per client -- Rate limit headers included in responses -- Implement exponential backoff for retries - -## Security Best Practices - -1. **Store Secrets Securely**: Client secrets are only shown once on creation. Store them in secure credential management systems -2. **Use Appropriate Client Types**: Use the minimum required access level for each use case -3. **Rotate Secrets Regularly**: Delete old clients and create new ones to rotate secrets -4. **Never Expose Secrets**: Never commit client secrets to version control or expose them in client-side code -5. **Monitor Client Usage**: Regularly review and remove unused clients +| Method | Path | Description | +|--------|------|-------------| +| `GET` | `/manage/clients` | List all clients (optionally filter by `projectId`) | +| `GET` | `/manage/clients/{id}` | Get a specific client | +| `POST` | `/manage/clients` | Create a new client | +| `PATCH` | `/manage/clients/{id}` | Update client name | +| `DELETE` | `/manage/clients/{id}` | Permanently delete a client | -## Notes +Client secrets are only returned once at creation time and are never retrievable afterwards. -- Client IDs are UUIDs (Universally Unique Identifiers) -- Client secrets are automatically generated with the format `sec_` followed by random hex characters -- Secrets are hashed using argon2 before storage -- Clients can be associated with a project or exist at the organization level -- Clients are scoped to your organization - you can only manage clients in your organization -- The `ignoreCorsAndSecret` field is an advanced setting that bypasses CORS and secret validation (use with caution) +For full request/response schemas, see the [API Reference](/docs/api-reference/manage). diff --git a/apps/public/content/docs/api/manage/index.mdx b/apps/public/content/docs/api/manage/index.mdx index 64b7ed89b..e3bf7fa6b 100644 --- a/apps/public/content/docs/api/manage/index.mdx +++ b/apps/public/content/docs/api/manage/index.mdx @@ -1,140 +1,28 @@ --- title: Manage API Overview -description: Programmatically manage projects, clients, and references in your OpenPanel organization using the Manage API. +description: Programmatically manage projects, clients, and references in your OpenPanel organization. --- -## Overview - -The Manage API provides programmatic access to manage your OpenPanel resources including projects, clients, and references. This API is designed for automation, infrastructure-as-code, and administrative tasks. - ## Authentication -The Manage API requires a **root client** for authentication. Root clients have organization-wide access and can manage all resources within their organization. - -To authenticate with the Manage API, you need: -- A client with `type: 'root'` -- Your `clientId` and `clientSecret` - -For detailed authentication information, see the [Authentication](/docs/api/authentication) guide. - -Include the following headers with your requests: -- `openpanel-client-id`: Your OpenPanel root client ID -- `openpanel-client-secret`: Your OpenPanel root client secret +The Manage API requires a **root** client. Root clients have organization-wide access and can manage all resources. See the [Authentication](/docs/api/authentication) guide. ## Base URL -All Manage API requests should be made to: - ``` https://api.openpanel.dev/manage ``` -## Available Resources - -The Manage API provides CRUD operations for three resource types: - -### Projects - -Manage your analytics projects programmatically: -- **[Projects Documentation](/docs/api/manage/projects)** - Create, read, update, and delete projects -- Automatically creates a default write client when creating a project -- Supports project configuration including domains, CORS settings, and project types - -### Clients +## Resources -Manage API clients for your projects: -- **[Clients Documentation](/docs/api/manage/clients)** - Create, read, update, and delete clients -- Supports different client types: `read`, `write`, and `root` -- Auto-generates secure secrets on creation (returned once) +| Resource | Description | +|----------|-------------| +| Projects | Create, update, and delete analytics projects | +| Clients | Manage API clients (read / write / root) and their secrets | +| References | Mark important dates or events on your analytics timeline | -### References - -Manage reference points for your analytics: -- **[References Documentation](/docs/api/manage/references)** - Create, read, update, and delete references -- Useful for marking important dates or events in your analytics timeline -- Can be filtered by project - -## Common Features - -All endpoints share these common characteristics: - -### Organization Scope - -All operations are scoped to your organization. You can only manage resources that belong to your organization. - -### Response Format - -Successful responses follow this structure: - -```json -{ - "data": { - // Resource data - } -} -``` - -For list endpoints: - -```json -{ - "data": [ - // Array of resources - ] -} -``` - -### Error Handling - -The API uses standard HTTP response codes: - -- `200 OK` - Request successful -- `400 Bad Request` - Invalid request parameters -- `401 Unauthorized` - Authentication failed -- `404 Not Found` - Resource not found -- `429 Too Many Requests` - Rate limit exceeded - -## Rate Limiting - -The Manage API implements rate limiting: -- **20 requests per 10 seconds** per client -- Rate limit headers included in responses -- Implement exponential backoff for retries - -## Use Cases - -The Manage API is ideal for: - -- **Infrastructure as Code**: Manage OpenPanel resources alongside your application infrastructure -- **Automation**: Automatically create projects and clients for new deployments -- **Bulk Operations**: Programmatically manage multiple resources -- **CI/CD Integration**: Set up projects and clients as part of your deployment pipeline -- **Administrative Tools**: Build custom admin interfaces - -## Security Best Practices - -1. **Root Clients Only**: Only root clients can access the Manage API -2. **Store Credentials Securely**: Never expose root client credentials in client-side code -3. **Use HTTPS**: Always use HTTPS for API requests -4. **Rotate Credentials**: Regularly rotate your root client credentials -5. **Limit Access**: Restrict root client creation to trusted administrators - -## Getting Started - -1. **Create a Root Client**: Use the dashboard to create a root client in your organization -2. **Store Credentials**: Securely store your root client ID and secret -3. **Make Your First Request**: Start with listing projects to verify authentication - -Example: - -```bash -curl 'https://api.openpanel.dev/manage/projects' \ - -H 'openpanel-client-id: YOUR_ROOT_CLIENT_ID' \ - -H 'openpanel-client-secret: YOUR_ROOT_CLIENT_SECRET' -``` +## Rate limiting -## Next Steps +20 requests per 10 seconds per client. -- Read the [Projects documentation](/docs/api/manage/projects) to manage projects -- Read the [Clients documentation](/docs/api/manage/clients) to manage API clients -- Read the [References documentation](/docs/api/manage/references) to manage reference points +For full endpoint schemas, see the [API Reference](/docs/api-reference/manage). diff --git a/apps/public/content/docs/api/manage/projects.mdx b/apps/public/content/docs/api/manage/projects.mdx index d12e2e293..035650ce0 100644 --- a/apps/public/content/docs/api/manage/projects.mdx +++ b/apps/public/content/docs/api/manage/projects.mdx @@ -5,323 +5,24 @@ description: Manage your OpenPanel projects programmatically. Create, read, upda ## Authentication -To authenticate with the Projects API, you need to use your `clientId` and `clientSecret` from a root client. Root clients have organization-wide access. - -For detailed authentication information, see the [Authentication](/docs/api/authentication) guide. - -Include the following headers with your requests: -- `openpanel-client-id`: Your OpenPanel root client ID -- `openpanel-client-secret`: Your OpenPanel root client secret +Requires a `root` client. See the [Authentication](/docs/api/authentication) guide. ## Base URL -All Projects API requests should be made to: - ``` https://api.openpanel.dev/manage/projects ``` ## Endpoints -### List Projects - -Retrieve all projects in your organization. - -``` -GET /manage/projects -``` - -#### Example Request - -```bash -curl 'https://api.openpanel.dev/manage/projects' \ - -H 'openpanel-client-id: YOUR_ROOT_CLIENT_ID' \ - -H 'openpanel-client-secret: YOUR_ROOT_CLIENT_SECRET' -``` - -#### Response - -```json -{ - "data": [ - { - "id": "my-project", - "name": "My Project", - "organizationId": "org_123", - "domain": "https://example.com", - "cors": ["https://example.com", "https://www.example.com"], - "crossDomain": false, - "allowUnsafeRevenueTracking": false, - "filters": [], - "types": ["website"], - "eventsCount": 0, - "createdAt": "2024-01-15T10:30:00.000Z", - "updatedAt": "2024-01-15T10:30:00.000Z", - "deleteAt": null - } - ] -} -``` - -### Get Project - -Retrieve a specific project by ID. - -``` -GET /manage/projects/{id} -``` - -#### Path Parameters - -| Parameter | Type | Description | -|-----------|------|-------------| -| `id` | string | The ID of the project | - -#### Example Request - -```bash -curl 'https://api.openpanel.dev/manage/projects/my-project' \ - -H 'openpanel-client-id: YOUR_ROOT_CLIENT_ID' \ - -H 'openpanel-client-secret: YOUR_ROOT_CLIENT_SECRET' -``` - -#### Response - -```json -{ - "data": { - "id": "my-project", - "name": "My Project", - "organizationId": "org_123", - "domain": "https://example.com", - "cors": ["https://example.com"], - "crossDomain": false, - "allowUnsafeRevenueTracking": false, - "filters": [], - "types": ["website"], - "eventsCount": 0, - "createdAt": "2024-01-15T10:30:00.000Z", - "updatedAt": "2024-01-15T10:30:00.000Z", - "deleteAt": null - } -} -``` - -### Create Project - -Create a new project in your organization. A default write client is automatically created with the project. - -``` -POST /manage/projects -``` - -#### Request Body - -| Parameter | Type | Required | Description | -|-----------|------|----------|-------------| -| `name` | string | Yes | Project name (minimum 1 character) | -| `domain` | string \| null | No | Primary domain for the project (URL format or empty string) | -| `cors` | string[] | No | Array of allowed CORS origins (default: `[]`) | -| `crossDomain` | boolean | No | Enable cross-domain tracking (default: `false`) | -| `types` | string[] | No | Project types: `website`, `app`, `backend` (default: `[]`) | - -#### Project Types - -- `website`: Web-based project -- `app`: Mobile application -- `backend`: Backend/server-side project - -#### Example Request - -```bash -curl -X POST 'https://api.openpanel.dev/manage/projects' \ - -H 'openpanel-client-id: YOUR_ROOT_CLIENT_ID' \ - -H 'openpanel-client-secret: YOUR_ROOT_CLIENT_SECRET' \ - -H 'Content-Type: application/json' \ - -d '{ - "name": "My New Project", - "domain": "https://example.com", - "cors": ["https://example.com", "https://www.example.com"], - "crossDomain": false, - "types": ["website"] - }' -``` - -#### Response - -```json -{ - "data": { - "id": "my-new-project", - "name": "My New Project", - "organizationId": "org_123", - "domain": "https://example.com", - "cors": ["https://example.com", "https://www.example.com"], - "crossDomain": false, - "allowUnsafeRevenueTracking": false, - "filters": [], - "types": ["website"], - "eventsCount": 0, - "createdAt": "2024-01-15T10:30:00.000Z", - "updatedAt": "2024-01-15T10:30:00.000Z", - "deleteAt": null, - "client": { - "id": "fa0c2780-55f2-4d9e-bea0-da2e02c7b1a9", - "secret": "sec_6c8ae85a092d6c66b242" - } - } -} -``` - -**Important**: The `client.secret` is only returned once when the project is created. Store it securely immediately. - -### Update Project - -Update an existing project's configuration. - -``` -PATCH /manage/projects/{id} -``` - -#### Path Parameters - -| Parameter | Type | Description | -|-----------|------|-------------| -| `id` | string | The ID of the project | - -#### Request Body - -All fields are optional. Only include fields you want to update. - -| Parameter | Type | Description | -|-----------|------|-------------| -| `name` | string | Project name (minimum 1 character) | -| `domain` | string \| null | Primary domain (URL format, empty string, or null) | -| `cors` | string[] | Array of allowed CORS origins | -| `crossDomain` | boolean | Enable cross-domain tracking | -| `allowUnsafeRevenueTracking` | boolean | Allow revenue tracking without client secret | - -#### Example Request - -```bash -curl -X PATCH 'https://api.openpanel.dev/manage/projects/my-project' \ - -H 'openpanel-client-id: YOUR_ROOT_CLIENT_ID' \ - -H 'openpanel-client-secret: YOUR_ROOT_CLIENT_SECRET' \ - -H 'Content-Type: application/json' \ - -d '{ - "name": "Updated Project Name", - "crossDomain": true, - "allowUnsafeRevenueTracking": false - }' -``` - -#### Response - -```json -{ - "data": { - "id": "my-project", - "name": "Updated Project Name", - "organizationId": "org_123", - "domain": "https://example.com", - "cors": ["https://example.com"], - "crossDomain": true, - "allowUnsafeRevenueTracking": false, - "filters": [], - "types": ["website"], - "eventsCount": 0, - "createdAt": "2024-01-15T10:30:00.000Z", - "updatedAt": "2024-01-15T11:00:00.000Z", - "deleteAt": null - } -} -``` - -### Delete Project - -Soft delete a project. The project will be scheduled for deletion after 24 hours. - -``` -DELETE /manage/projects/{id} -``` - -#### Path Parameters - -| Parameter | Type | Description | -|-----------|------|-------------| -| `id` | string | The ID of the project | - -#### Example Request - -```bash -curl -X DELETE 'https://api.openpanel.dev/manage/projects/my-project' \ - -H 'openpanel-client-id: YOUR_ROOT_CLIENT_ID' \ - -H 'openpanel-client-secret: YOUR_ROOT_CLIENT_SECRET' -``` - -#### Response - -```json -{ - "success": true -} -``` - -**Note**: Projects are soft-deleted. The `deleteAt` field is set to 24 hours in the future. You can cancel deletion by updating the project before the deletion time. - -## Error Handling - -The API uses standard HTTP response codes. Common error responses: - -### 400 Bad Request - -```json -{ - "error": "Bad Request", - "message": "Invalid request body", - "details": [ - { - "path": ["name"], - "message": "String must contain at least 1 character(s)" - } - ] -} -``` - -### 401 Unauthorized - -```json -{ - "error": "Unauthorized", - "message": "Manage: Only root clients are allowed to manage resources" -} -``` - -### 404 Not Found - -```json -{ - "error": "Not Found", - "message": "Project not found" -} -``` - -### 429 Too Many Requests - -Rate limiting response includes headers indicating your rate limit status. - -## Rate Limiting - -The Projects API implements rate limiting: -- **20 requests per 10 seconds** per client -- Rate limit headers included in responses -- Implement exponential backoff for retries +| Method | Path | Description | +|--------|------|-------------| +| `GET` | `/manage/projects` | List all projects in your organization | +| `GET` | `/manage/projects/{id}` | Get a specific project | +| `POST` | `/manage/projects` | Create a new project | +| `PATCH` | `/manage/projects/{id}` | Update a project | +| `DELETE` | `/manage/projects/{id}` | Soft-delete a project (24h grace period) | -## Notes +When you create a project, a default `write` client is automatically created and returned with the response. The client secret is only shown once. -- Project IDs are automatically generated from the project name using a slug format -- If a project ID already exists, a numeric suffix is added -- CORS domains are automatically normalized (trailing slashes removed) -- The default client created with a project has `type: 'write'` -- Projects are scoped to your organization - you can only manage projects in your organization -- Soft-deleted projects are excluded from list endpoints +For full request/response schemas, see the [API Reference](/docs/api-reference/manage). diff --git a/apps/public/content/docs/api/manage/references.mdx b/apps/public/content/docs/api/manage/references.mdx index 54b799a06..263971d84 100644 --- a/apps/public/content/docs/api/manage/references.mdx +++ b/apps/public/content/docs/api/manage/references.mdx @@ -1,344 +1,30 @@ --- title: References -description: Manage reference points for your OpenPanel projects. References are useful for marking important dates or events in your analytics timeline. +description: Manage reference points for your OpenPanel projects. References mark important dates or events on your analytics timeline. --- ## Authentication -To authenticate with the References API, you need to use your `clientId` and `clientSecret` from a root client. Root clients have organization-wide access. - -For detailed authentication information, see the [Authentication](/docs/api/authentication) guide. - -Include the following headers with your requests: -- `openpanel-client-id`: Your OpenPanel root client ID -- `openpanel-client-secret`: Your OpenPanel root client secret +Requires a `root` client. See the [Authentication](/docs/api/authentication) guide. ## Base URL -All References API requests should be made to: - ``` https://api.openpanel.dev/manage/references ``` -## What are References? - -References are markers you can add to your analytics timeline to track important events such as: -- Product launches -- Marketing campaign start dates -- Feature releases -- Website redesigns -- Major announcements +## What are references? -References appear in your analytics charts and help you correlate changes in metrics with specific events. +References are markers on your analytics timeline — useful for product launches, campaign start dates, feature releases, or any event you want to correlate with changes in your metrics. ## Endpoints -### List References - -Retrieve all references in your organization, optionally filtered by project. - -``` -GET /manage/references -``` - -#### Query Parameters - -| Parameter | Type | Description | -|-----------|------|-------------| -| `projectId` | string | Optional. Filter references by project ID | - -#### Example Request - -```bash -# List all references -curl 'https://api.openpanel.dev/manage/references' \ - -H 'openpanel-client-id: YOUR_ROOT_CLIENT_ID' \ - -H 'openpanel-client-secret: YOUR_ROOT_CLIENT_SECRET' - -# List references for a specific project -curl 'https://api.openpanel.dev/manage/references?projectId=my-project' \ - -H 'openpanel-client-id: YOUR_ROOT_CLIENT_ID' \ - -H 'openpanel-client-secret: YOUR_ROOT_CLIENT_SECRET' -``` - -#### Response - -```json -{ - "data": [ - { - "id": "1af09627-2dd7-4b37-9e5c-6ffa7e450e85", - "title": "Product Launch", - "description": "Version 2.0 released", - "date": "2024-01-15T10:00:00.000Z", - "projectId": "my-project", - "createdAt": "2024-01-10T08:00:00.000Z", - "updatedAt": "2024-01-10T08:00:00.000Z" - }, - { - "id": "2bf19738-3ee8-4c48-af6d-7ggb8f561f96", - "title": "Marketing Campaign Start", - "description": "Q1 2024 campaign launched", - "date": "2024-01-20T09:00:00.000Z", - "projectId": "my-project", - "createdAt": "2024-01-18T10:00:00.000Z", - "updatedAt": "2024-01-18T10:00:00.000Z" - } - ] -} -``` - -### Get Reference - -Retrieve a specific reference by ID. - -``` -GET /manage/references/{id} -``` - -#### Path Parameters - -| Parameter | Type | Description | -|-----------|------|-------------| -| `id` | string | The ID of the reference (UUID) | - -#### Example Request - -```bash -curl 'https://api.openpanel.dev/manage/references/1af09627-2dd7-4b37-9e5c-6ffa7e450e85' \ - -H 'openpanel-client-id: YOUR_ROOT_CLIENT_ID' \ - -H 'openpanel-client-secret: YOUR_ROOT_CLIENT_SECRET' -``` - -#### Response - -```json -{ - "data": { - "id": "1af09627-2dd7-4b37-9e5c-6ffa7e450e85", - "title": "Product Launch", - "description": "Version 2.0 released", - "date": "2024-01-15T10:00:00.000Z", - "projectId": "my-project", - "createdAt": "2024-01-10T08:00:00.000Z", - "updatedAt": "2024-01-10T08:00:00.000Z" - } -} -``` - -### Create Reference - -Create a new reference point for a project. - -``` -POST /manage/references -``` - -#### Request Body - -| Parameter | Type | Required | Description | -|-----------|------|----------|-------------| -| `projectId` | string | Yes | The ID of the project this reference belongs to | -| `title` | string | Yes | Reference title (minimum 1 character) | -| `description` | string | No | Optional description or notes | -| `datetime` | string | Yes | Date and time for the reference (ISO 8601 format) | - -#### Example Request - -```bash -curl -X POST 'https://api.openpanel.dev/manage/references' \ - -H 'openpanel-client-id: YOUR_ROOT_CLIENT_ID' \ - -H 'openpanel-client-secret: YOUR_ROOT_CLIENT_SECRET' \ - -H 'Content-Type: application/json' \ - -d '{ - "projectId": "my-project", - "title": "Product Launch", - "description": "Version 2.0 released with new features", - "datetime": "2024-01-15T10:00:00.000Z" - }' -``` - -#### Response - -```json -{ - "data": { - "id": "1af09627-2dd7-4b37-9e5c-6ffa7e450e85", - "title": "Product Launch", - "description": "Version 2.0 released with new features", - "date": "2024-01-15T10:00:00.000Z", - "projectId": "my-project", - "createdAt": "2024-01-10T08:00:00.000Z", - "updatedAt": "2024-01-10T08:00:00.000Z" - } -} -``` - -**Note**: The `date` field in the response is parsed from the `datetime` string you provided. - -### Update Reference - -Update an existing reference. - -``` -PATCH /manage/references/{id} -``` - -#### Path Parameters - -| Parameter | Type | Description | -|-----------|------|-------------| -| `id` | string | The ID of the reference (UUID) | - -#### Request Body - -All fields are optional. Only include fields you want to update. - -| Parameter | Type | Description | -|-----------|------|-------------| -| `title` | string | Reference title (minimum 1 character) | -| `description` | string \| null | Description or notes (set to `null` to clear) | -| `datetime` | string | Date and time for the reference (ISO 8601 format) | - -#### Example Request - -```bash -curl -X PATCH 'https://api.openpanel.dev/manage/references/1af09627-2dd7-4b37-9e5c-6ffa7e450e85' \ - -H 'openpanel-client-id: YOUR_ROOT_CLIENT_ID' \ - -H 'openpanel-client-secret: YOUR_ROOT_CLIENT_SECRET' \ - -H 'Content-Type: application/json' \ - -d '{ - "title": "Product Launch v2.1", - "description": "Updated: Version 2.1 released with bug fixes", - "datetime": "2024-01-15T10:00:00.000Z" - }' -``` - -#### Response - -```json -{ - "data": { - "id": "1af09627-2dd7-4b37-9e5c-6ffa7e450e85", - "title": "Product Launch v2.1", - "description": "Updated: Version 2.1 released with bug fixes", - "date": "2024-01-15T10:00:00.000Z", - "projectId": "my-project", - "createdAt": "2024-01-10T08:00:00.000Z", - "updatedAt": "2024-01-10T09:30:00.000Z" - } -} -``` - -### Delete Reference - -Permanently delete a reference. This action cannot be undone. - -``` -DELETE /manage/references/{id} -``` - -#### Path Parameters - -| Parameter | Type | Description | -|-----------|------|-------------| -| `id` | string | The ID of the reference (UUID) | - -#### Example Request - -```bash -curl -X DELETE 'https://api.openpanel.dev/manage/references/1af09627-2dd7-4b37-9e5c-6ffa7e450e85' \ - -H 'openpanel-client-id: YOUR_ROOT_CLIENT_ID' \ - -H 'openpanel-client-secret: YOUR_ROOT_CLIENT_SECRET' -``` - -#### Response - -```json -{ - "success": true -} -``` - -## Error Handling - -The API uses standard HTTP response codes. Common error responses: - -### 400 Bad Request - -```json -{ - "error": "Bad Request", - "message": "Invalid request body", - "details": [ - { - "path": ["title"], - "message": "String must contain at least 1 character(s)" - } - ] -} -``` - -### 401 Unauthorized - -```json -{ - "error": "Unauthorized", - "message": "Manage: Only root clients are allowed to manage resources" -} -``` - -### 404 Not Found - -```json -{ - "error": "Not Found", - "message": "Reference not found" -} -``` - -This error can occur if: -- The reference ID doesn't exist -- The reference belongs to a different organization - -### 429 Too Many Requests - -Rate limiting response includes headers indicating your rate limit status. - -## Rate Limiting - -The References API implements rate limiting: -- **20 requests per 10 seconds** per client -- Rate limit headers included in responses -- Implement exponential backoff for retries - -## Date Format - -References use ISO 8601 date format. Examples: - -- `2024-01-15T10:00:00.000Z` - UTC timezone -- `2024-01-15T10:00:00-05:00` - Eastern Time (UTC-5) -- `2024-01-15` - Date only (time defaults to 00:00:00) - -The `datetime` field in requests is converted to a `date` field in responses, stored as a timestamp. - -## Use Cases - -References are useful for: - -- **Product Launches**: Mark when new versions or features are released -- **Marketing Campaigns**: Track campaign start and end dates -- **Website Changes**: Note when major redesigns or updates occur -- **Business Events**: Record important business milestones -- **A/B Testing**: Mark when experiments start or end -- **Seasonal Events**: Track holidays, sales periods, or seasonal changes - -## Notes +| Method | Path | Description | +|--------|------|-------------| +| `GET` | `/manage/references` | List all references (optionally filter by `projectId`) | +| `GET` | `/manage/references/{id}` | Get a specific reference | +| `POST` | `/manage/references` | Create a new reference | +| `PATCH` | `/manage/references/{id}` | Update a reference | +| `DELETE` | `/manage/references/{id}` | Delete a reference | -- Reference IDs are UUIDs (Universally Unique Identifiers) -- References are scoped to projects - each reference belongs to a specific project -- References are scoped to your organization - you can only manage references for projects in your organization -- The `description` field is optional and can be set to `null` to clear it -- References appear in analytics charts to help correlate metrics with events -- When filtering by `projectId`, the project must exist and belong to your organization +For full request/response schemas, see the [API Reference](/docs/api-reference/manage). diff --git a/apps/public/content/docs/api/meta.json b/apps/public/content/docs/api/meta.json index 3e49c2529..6683673ec 100644 --- a/apps/public/content/docs/api/meta.json +++ b/apps/public/content/docs/api/meta.json @@ -1,4 +1,10 @@ { "title": "API", - "pages": ["track", "export", "insights", "manage"] -} + "pages": [ + "authentication", + "track", + "export", + "insights", + "manage" + ] +} \ No newline at end of file diff --git a/apps/public/content/docs/api/track.mdx b/apps/public/content/docs/api/track.mdx index 033f8cfb9..6c75ad587 100644 --- a/apps/public/content/docs/api/track.mdx +++ b/apps/public/content/docs/api/track.mdx @@ -1,203 +1,43 @@ --- title: Track -description: This guide demonstrates how to interact with the OpenPanel API using cURL. These examples provide a low-level understanding of the API endpoints and can be useful for testing or for integrations where a full SDK isn't available. +description: How to send events, identify users, and manage groups via the HTTP API. --- ## Good to know -- If you want to track **geo location** you'll need to pass the `ip` property as a header `x-client-ip` -- If you want to track **device information** you'll need to pass the `user-agent` property as a header `user-agent` +- Pass the `x-client-ip` header to enable geo location tracking +- Pass the `user-agent` header to enable device detection ## Authentication -All requests to the OpenPanel API require authentication. You'll need to include your `clientId` and `clientSecret` in the headers of each request. +All requests require a `write` or `root` client. See the [Authentication](/docs/api/authentication) guide. ```bash -H "openpanel-client-id: YOUR_CLIENT_ID" \ -H "openpanel-client-secret: YOUR_CLIENT_SECRET" ``` -## Usage - -### Base URL - -All API requests should be made to: +## Base URL ``` https://api.openpanel.dev ``` -### Tracking Events - -To track an event: - -```bash -curl -X POST https://api.openpanel.dev/track \ --H "Content-Type: application/json" \ --H "openpanel-client-id: YOUR_CLIENT_ID" \ --H "openpanel-client-secret: YOUR_CLIENT_SECRET" \ --d '{ - "type": "track", - "payload": { - "name": "my_event", - "properties": { - "foo": "bar" - } - } -}' -``` - -### Identifying Users - -To identify a user: - -```bash -curl -X POST https://api.openpanel.dev/track \ --H "Content-Type: application/json" \ --H "openpanel-client-id: YOUR_CLIENT_ID" \ --H "openpanel-client-secret: YOUR_CLIENT_SECRET" \ --d '{ - "type": "identify", - "payload": { - "profileId": "123", - "firstName": "Joe", - "lastName": "Doe", - "email": "joe@doe.com", - "properties": { - "tier": "premium" - } - } -}' -``` - -### Incrementing Properties -To increment a numeric property: - -```bash -curl -X POST https://api.openpanel.dev/track \ --H "Content-Type: application/json" \ --H "openpanel-client-id: YOUR_CLIENT_ID" \ --H "openpanel-client-secret: YOUR_CLIENT_SECRET" \ --d '{ - "type": "increment", - "payload": { - "profileId": "1", - "property": "visits", - "value": 1 - } -}' -``` - -### Decrementing Properties -To decrement a numeric property: - -```bash -curl -X POST https://api.openpanel.dev/track \ --H "Content-Type: application/json" \ --H "openpanel-client-id: YOUR_CLIENT_ID" \ --H "openpanel-client-secret: YOUR_CLIENT_SECRET" \ --d '{ - "type": "decrement", - "payload": { - "profileId": "1", - "property": "visits", - "value": 1 - } -}' -``` - -### Creating or updating a group - -```bash -curl -X POST https://api.openpanel.dev/track \ --H "Content-Type: application/json" \ --H "openpanel-client-id: YOUR_CLIENT_ID" \ --H "openpanel-client-secret: YOUR_CLIENT_SECRET" \ --d '{ - "type": "group", - "payload": { - "id": "org_acme", - "type": "company", - "name": "Acme Inc", - "properties": { - "plan": "enterprise", - "seats": 25 - } - } -}' -``` - -| Field | Type | Required | Description | -|-------|------|----------|-------------| -| `id` | `string` | Yes | Unique identifier for the group | -| `type` | `string` | Yes | Category of group (e.g. `"company"`, `"workspace"`) | -| `name` | `string` | Yes | Display name | -| `properties` | `object` | No | Custom metadata | - -### Assigning a user to a group - -Links a profile to one or more groups. This updates the profile record but does not auto-attach groups to future events — you still need to pass `groups` explicitly on each track call. - -```bash -curl -X POST https://api.openpanel.dev/track \ --H "Content-Type: application/json" \ --H "openpanel-client-id: YOUR_CLIENT_ID" \ --H "openpanel-client-secret: YOUR_CLIENT_SECRET" \ --d '{ - "type": "assign_group", - "payload": { - "profileId": "user_123", - "groupIds": ["org_acme"] - } -}' -``` - -| Field | Type | Required | Description | -|-------|------|----------|-------------| -| `profileId` | `string` | No | Profile to assign. Falls back to the device ID if omitted | -| `groupIds` | `string[]` | Yes | Group IDs to link to the profile | +## Event types -### Tracking events with groups +The `/track` endpoint accepts a `type` field that determines what gets recorded: -Groups are never auto-populated on events — even if the profile has been assigned to a group via `assign_group`. Pass `groups` on every track event where you want group data. - -```bash -curl -X POST https://api.openpanel.dev/track \ --H "Content-Type: application/json" \ --H "openpanel-client-id: YOUR_CLIENT_ID" \ --H "openpanel-client-secret: YOUR_CLIENT_SECRET" \ --d '{ - "type": "track", - "payload": { - "name": "report_exported", - "profileId": "user_123", - "groups": ["org_acme"], - "properties": { - "format": "pdf" - } - } -}' -``` - -Unlike the SDK, where `setGroup()` stores group IDs on the instance and attaches them to every subsequent `track()` call, the API has no such state. You must pass `groups` on each event. - -### Error Handling -The API uses standard HTTP response codes to indicate the success or failure of requests. In case of an error, the response body will contain more information about the error. -Example error response: - -```json -{ - "error": "Invalid client credentials", - "status": 401 -} -``` +| Type | Description | +|------|-------------| +| `track` | Record a named event with optional properties | +| `identify` | Create or update a user profile | +| `increment` | Increment a numeric profile property | +| `decrement` | Decrement a numeric profile property | +| `group` | Create or update a group | +| `assign_group` | Link a profile to one or more groups | -### Rate Limiting +### Groups and events -The API implements rate limiting to prevent abuse. If you exceed the rate limit, you'll receive a 429 (Too Many Requests) response. The response will include headers indicating your rate limit status. +Groups are never auto-populated on events — even after `assign_group`. Pass `groups` explicitly on each `track` call where you need group data. -Best Practices - 1. Always use HTTPS to ensure secure communication. - 2. Store your clientId and clientSecret securely and never expose them in client-side code. - 3. Implement proper error handling in your applications. - 4. Respect rate limits and implement exponential backoff for retries. \ No newline at end of file +For full request/response schemas for every event type, see the [API Reference](/docs/api-reference/track/post). diff --git a/apps/public/content/docs/mcp/index.mdx b/apps/public/content/docs/mcp/index.mdx new file mode 100644 index 000000000..4b17b3000 --- /dev/null +++ b/apps/public/content/docs/mcp/index.mdx @@ -0,0 +1,179 @@ +--- +title: MCP Server +description: Connect AI assistants to your OpenPanel analytics data using the Model Context Protocol. +--- + +OpenPanel exposes an [MCP (Model Context Protocol)](https://modelcontextprotocol.io) server that lets AI assistants — Claude, Cursor, Windsurf, and others — query your analytics data directly in conversation. + +## Endpoint + +``` +https://api.openpanel.dev/mcp +``` + +## Authentication + +### Why `?token=` instead of headers + +MCP clients establish a long-lived SSE connection to the server. Most MCP clients do not support setting custom HTTP headers on SSE connections — only on the initial HTTP request. Because of this, the token is passed as a **query parameter** instead: + +``` +https://api.openpanel.dev/mcp?token=YOUR_TOKEN +``` + +The `Authorization: Bearer` header is also accepted as a fallback, but `?token=` is the recommended approach for MCP clients. + +### Token format + +The token is a **base64-encoded** string of your client ID and client secret joined by a colon: + +``` +base64(clientId:clientSecret) +``` + +Generate it in your terminal: + +```bash +echo -n "YOUR_CLIENT_ID:YOUR_CLIENT_SECRET" | base64 +``` + +Then append it to the MCP URL: + +``` +https://api.openpanel.dev/mcp?token= +``` + +### Required client type + +Only `read` and `root` clients can authenticate with MCP. Write-only clients are rejected. + +| Client type | Access | +|-------------|--------| +| `read` | Scoped to a single project (the one the client belongs to) | +| `root` | Can query any project in your organization | + +Use a `read` client if you want to limit the AI assistant to one project. Use a `root` client if you need cross-project access or want to list all projects. + +Go to **Settings → API Clients** in your dashboard to create a client. See the [Authentication guide](/docs/api/authentication) for more details. + +## Connecting to Claude Desktop + +Add the following to your `claude_desktop_config.json`: + +```json +{ + "mcpServers": { + "openpanel": { + "type": "streamable-http", + "url": "https://api.openpanel.dev/mcp?token=YOUR_TOKEN" + } + } +} +``` + +## Connecting with Claude Code CLI + +With the Claude CLI you can use the `--header` flag to pass the token via `Authorization: Bearer` rather than embedding it in the URL: + +```bash +claude mcp add --transport http openpanel https://api.openpanel.dev/mcp \ + --header "Authorization: Bearer YOUR_TOKEN" +``` + +Or with the token in the URL (equivalent): + +```bash +claude mcp add --transport http openpanel "https://api.openpanel.dev/mcp?token=YOUR_TOKEN" +``` + +## Available tools + +### Project access + +| Tool | Description | +|------|-------------| +| `list_projects` | List all projects accessible with your credentials. Root clients see all organization projects; read clients see only their own. | +| `get_dashboard_urls` | Get clickable dashboard links for the current project — overview, events, profiles, sessions, and deep-links to specific items. | + +### Dashboards & reports + +| Tool | Description | +|------|-------------| +| `list_dashboards` | List all dashboards for a project. | +| `list_reports` | List all reports in a dashboard with their chart types and tracked events. | +| `get_report_data` | Execute a saved report and return its data (time-series, funnel, metric, etc.). | + +### Discovery + +| Tool | Description | +|------|-------------| +| `list_event_names` | Get the top 50 most common event names in the project. Call this first if you don't know exact event names. | +| `list_event_properties` | List all property keys tracked for an event (or across all events). | +| `get_event_property_values` | Get all distinct values for a specific event property. | + +### Events & sessions + +| Tool | Description | +|------|-------------| +| `query_events` | Query raw events with optional filters. Returns individual records with path, device, country, referrer, and custom properties. | +| `query_sessions` | Query sessions with optional filters. Each session includes duration, entry/exit pages, bounce status, and attribution data. | + +### Profiles (users) + +| Tool | Description | +|------|-------------| +| `find_profiles` | Search and filter user profiles by name, email, location, inactivity, session count, or whether they performed a specific event. | +| `get_profile` | Get a specific user profile with their most recent events. | +| `get_profile_sessions` | Get all sessions for a user profile, ordered by most recent first. | +| `get_profile_metrics` | Get computed lifetime metrics for a user: sessions, pageviews, bounce rate, revenue, and more. | + +### Groups (B2B) + +| Tool | Description | +|------|-------------| +| `list_group_types` | List all group types defined in the project (e.g. `company`, `team`). Call this first before querying groups. | +| `find_groups` | Search for groups by name, ID, or type. | +| `get_group` | Get a specific group with its properties and member profiles. | + +### Aggregated metrics + +| Tool | Description | +|------|-------------| +| `get_analytics_overview` | Key metrics for a date range: visitors, pageviews, sessions, bounce rate, and avg session duration. | +| `get_rolling_active_users` | Time series of active users using a rolling window — DAU (1 day), WAU (7 days), or MAU (30 days). | +| `get_top_pages` | Most visited pages ranked by pageviews. | +| `get_page_performance` | Per-page bounce rate, avg session duration, sessions, and pageviews. | +| `get_page_conversions` | Pages ranked by how many visitors went on to convert after viewing them. | +| `get_entry_exit_pages` | Most common entry pages (session start) or exit pages (session end). | +| `get_top_referrers` | Top traffic sources broken down by referrer name and type. | +| `get_country_breakdown` | Visitor counts by country, region, or city. | +| `get_device_breakdown` | Visitor counts by device type, browser, or OS. | + +### User behavior + +| Tool | Description | +|------|-------------| +| `get_funnel` | Analyze a conversion funnel between 2+ events — sign-up flows, checkout, onboarding. | +| `get_retention_cohort` | Weekly user retention cohort table showing long-term product stickiness. | +| `get_weekly_retention_series` | Week-over-week retention as a time series. | +| `get_user_last_seen_distribution` | Histogram of user recency — useful for churn analysis. | +| `get_user_flow` | Visualize user navigation flows as a Sankey diagram (before/after/between events). | +| `get_engagement_metrics` | Engagement metrics over time. | + +### Google Search Console + +These tools require GSC to be connected for the project in your dashboard settings. + +| Tool | Description | +|------|-------------| +| `gsc_get_overview` | GSC performance over time: clicks, impressions, CTR, and avg position. | +| `gsc_get_top_pages` | Top-performing pages from GSC ranked by clicks. | +| `gsc_get_page_details` | Detailed GSC performance for a specific page including all queries driving traffic to it. | +| `gsc_get_top_queries` | Top search queries ranked by clicks. | +| `gsc_get_query_opportunities` | Low-hanging-fruit SEO opportunities: queries ranking 4–20 with meaningful search volume. | +| `gsc_get_query_details` | Detailed GSC data for a specific search query with all pages that rank for it. | +| `gsc_get_cannibalization` | Queries where multiple pages on your site compete against each other in Google. | + +## Rate limiting + +60 requests per minute per client. diff --git a/apps/public/content/docs/meta.json b/apps/public/content/docs/meta.json index 654afdb98..6302f6b94 100644 --- a/apps/public/content/docs/meta.json +++ b/apps/public/content/docs/meta.json @@ -8,6 +8,8 @@ "...(tracking)", "---API---", "...api", + "---MCP---", + "...mcp", "---Dashboard---", "...dashboard", "---Self-hosting---", diff --git a/apps/public/next.config.mjs b/apps/public/next.config.mjs index 5da54f4c8..2fb90a3d8 100644 --- a/apps/public/next.config.mjs +++ b/apps/public/next.config.mjs @@ -9,7 +9,7 @@ const config = { unoptimized: true, domains: ['localhost', 'openpanel.dev', 'api.openpanel.dev'], }, - serverExternalPackages: ['@hyperdx/node-opentelemetry', '@openpanel/geo'], + serverExternalPackages: ['@hyperdx/node-opentelemetry', '@openpanel/geo', 'shiki'], redirects: [ { source: '/articles/top-7-open-source-web-analytics-tools', diff --git a/apps/public/package.json b/apps/public/package.json index 8819edacc..4105672a7 100644 --- a/apps/public/package.json +++ b/apps/public/package.json @@ -33,9 +33,10 @@ "clsx": "2.1.1", "dotted-map": "2.2.3", "framer-motion": "12.23.25", - "fumadocs-core": "16.2.2", - "fumadocs-mdx": "14.0.4", - "fumadocs-ui": "16.2.2", + "fumadocs-core": "16.7.11", + "fumadocs-mdx": "14.2.11", + "fumadocs-openapi": "^10.6.7", + "fumadocs-ui": "16.7.11", "geist": "1.5.1", "lucide-react": "^0.555.0", "next": "16.0.7", @@ -45,6 +46,7 @@ "react-markdown": "^10.1.0", "recharts": "^2.15.0", "rehype-external-links": "3.0.0", + "shiki": "^4.0.2", "tailwind-merge": "3.4.0", "tailwindcss-animate": "1.0.7", "zod": "catalog:" diff --git a/apps/public/source.config.ts b/apps/public/source.config.ts index 552c64bd3..a967cef5a 100644 --- a/apps/public/source.config.ts +++ b/apps/public/source.config.ts @@ -89,6 +89,12 @@ export const guideMeta = defineCollections({ schema: zGuide, }); +export const apiRefCollection = defineCollections({ + type: 'doc', + dir: './content/docs/api-reference', + schema: frontmatterSchema, +}); + export default defineConfig({ mdxOptions: { // MDX options diff --git a/apps/public/src/app/docs/[[...slug]]/page.tsx b/apps/public/src/app/docs/(docs)/[[...slug]]/page.tsx similarity index 100% rename from apps/public/src/app/docs/[[...slug]]/page.tsx rename to apps/public/src/app/docs/(docs)/[[...slug]]/page.tsx diff --git a/apps/public/src/app/docs/(docs)/layout.tsx b/apps/public/src/app/docs/(docs)/layout.tsx new file mode 100644 index 000000000..34949bee2 --- /dev/null +++ b/apps/public/src/app/docs/(docs)/layout.tsx @@ -0,0 +1,29 @@ +import { DocsLayout } from 'fumadocs-ui/layouts/docs'; +import { BookOpenIcon, CodeIcon } from 'lucide-react'; +import { baseOptions } from '@/lib/layout.shared'; +import { API_REFERENCE_BASE_URL } from '@/lib/openapi'; +import { source } from '@/lib/source'; + +export default function Layout({ children }: { children: React.ReactNode }) { + const tabs = [ + { + title: 'Documentation', + description: 'Guides and references', + url: '/docs', + icon: , + $folder: source.pageTree as never, + }, + { + title: 'API Reference', + description: 'REST API endpoints', + url: API_REFERENCE_BASE_URL, + icon: , + }, + ]; + + return ( + + {children} + + ); +} diff --git a/apps/public/src/app/docs/api-reference/[[...slug]]/page.tsx b/apps/public/src/app/docs/api-reference/[[...slug]]/page.tsx new file mode 100644 index 000000000..71dac17c1 --- /dev/null +++ b/apps/public/src/app/docs/api-reference/[[...slug]]/page.tsx @@ -0,0 +1,71 @@ +import React from 'react'; +import { getMDXComponents } from '@/mdx-components'; +import { getApiReferenceSource, openapi } from '@/lib/openapi'; +import { createAPIPage } from 'fumadocs-openapi/ui'; +import { + DocsBody, + DocsDescription, + DocsPage, + DocsTitle, +} from 'fumadocs-ui/page'; +import { notFound, redirect } from 'next/navigation'; + +const APIPage = createAPIPage(openapi); + +interface PageProps { + params: Promise<{ slug?: string[] }>; +} + +export default async function Page(props: PageProps) { + const params = await props.params; + const source = await getApiReferenceSource(); + + if (!params.slug) { + const first = source.getPages()[0]; + if (first) redirect(first.url); + notFound(); + } + + const page = source.getPage(params.slug); + if (!page) notFound(); + + const data = page.data as Record; + + // Static MDX page + if (typeof data.body === 'function') { + const MDX = data.body as React.FC<{ components?: Record }>; + const toc = data.toc as React.ComponentProps['toc']; + return ( + + {page.data.title} + {page.data.description && ( + {page.data.description} + )} + + + + + ); + } + + // OpenAPI generated page + const { getAPIPageProps } = data as { getAPIPageProps: () => React.ComponentProps }; + return ( + + {page.data.title} + {page.data.description && ( + {page.data.description} + )} + + + + + ); +} + +export async function generateStaticParams() { + const source = await getApiReferenceSource(); + return source.generateParams(); +} + +export const dynamic = 'force-dynamic'; diff --git a/apps/public/src/app/docs/api-reference/layout.tsx b/apps/public/src/app/docs/api-reference/layout.tsx new file mode 100644 index 000000000..0949a62ce --- /dev/null +++ b/apps/public/src/app/docs/api-reference/layout.tsx @@ -0,0 +1,34 @@ +import { DocsLayout } from 'fumadocs-ui/layouts/docs'; +import { BookOpenIcon, CodeIcon } from 'lucide-react'; +import { baseOptions } from '@/lib/layout.shared'; +import { API_REFERENCE_BASE_URL, getApiReferenceSource } from '@/lib/openapi'; + +export default async function Layout({ + children, +}: { + children: React.ReactNode; +}) { + const apiSource = await getApiReferenceSource(); + + const tabs = [ + { + title: 'Documentation', + description: 'Guides and references', + url: '/docs', + icon: , + }, + { + title: 'API Reference', + description: 'REST API endpoints', + url: API_REFERENCE_BASE_URL, + icon: , + $folder: apiSource.pageTree as never, + }, + ]; + + return ( + + {children} + + ); +} diff --git a/apps/public/src/app/docs/layout.tsx b/apps/public/src/app/docs/layout.tsx deleted file mode 100644 index f5052057b..000000000 --- a/apps/public/src/app/docs/layout.tsx +++ /dev/null @@ -1,11 +0,0 @@ -import { baseOptions } from '@/lib/layout.shared'; -import { source } from '@/lib/source'; -import { DocsLayout } from 'fumadocs-ui/layouts/docs'; - -export default function Layout({ children }: { children: React.ReactNode }) { - return ( - - {children} - - ); -} diff --git a/apps/public/src/app/global.css b/apps/public/src/app/global.css index a5ce430eb..7a6310a29 100644 --- a/apps/public/src/app/global.css +++ b/apps/public/src/app/global.css @@ -1,6 +1,7 @@ @import 'tailwindcss'; @import 'fumadocs-ui/css/neutral.css'; @import 'fumadocs-ui/css/preset.css'; +@import 'fumadocs-openapi/css/preset.css'; @custom-variant dark (&:is(.dark *)); @custom-variant light (&:is(.light *)); diff --git a/apps/public/src/components/navbar.tsx b/apps/public/src/components/navbar.tsx index 33a8209d2..bf848dfb4 100644 --- a/apps/public/src/components/navbar.tsx +++ b/apps/public/src/components/navbar.tsx @@ -9,7 +9,7 @@ import { GithubButton } from './github-button'; import { Logo } from './logo'; import { SignUpButton } from './sign-up-button'; import { Button } from './ui/button'; -import { baseOptions } from '@/lib/layout.shared'; +import { baseOptions, siteName } from '@/lib/layout.shared'; import { cn } from '@/lib/utils'; const LINKS = [ @@ -76,7 +76,7 @@ const Navbar = () => { - {baseOptions().nav?.title} + {siteName} diff --git a/apps/public/src/lib/openapi.ts b/apps/public/src/lib/openapi.ts new file mode 100644 index 000000000..5a11318c7 --- /dev/null +++ b/apps/public/src/lib/openapi.ts @@ -0,0 +1,60 @@ +import { loader } from 'fumadocs-core/source'; +import { + createOpenAPI, + openapiPlugin, + openapiSource, +} from 'fumadocs-openapi/server'; +import { apiRefCollection } from 'fumadocs-mdx:collections/server'; +import { toFumadocsSource } from 'fumadocs-mdx/runtime/server'; +import path from 'node:path'; +import { cache } from 'react'; + +const API_URL = + process.env.NODE_ENV === 'production' + ? 'https://api.openpanel.dev' + : (process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:3333'); + +export const openapi = createOpenAPI({ + input: [`${API_URL}/documentation/json`], +}); + +export const API_REFERENCE_BASE_URL = '/docs/api-reference'; + +export const getApiReferenceSource = cache(async () => { + const openapiFiles = await openapiSource(openapi, { + groupBy: 'tag', + meta: { folderStyle: 'separator' }, + }).catch(() => ({ files: [] as never[] })); + + const staticSource = toFumadocsSource(apiRefCollection, []); + + // Collect the slugs of static pages so we can inject them into the + // OpenAPI-generated root meta.json (which only lists the tag groups). + const staticSlugs = staticSource.files + .filter((f): f is typeof f & { type: 'page' } => f.type === 'page') + .map((f) => path.basename(f.path, path.extname(f.path))); + + // Inject static page slugs at the top of the root meta.json that + // openapiSource generates for the tag separator groups. + const patchedOpenapiFiles = openapiFiles.files.map((f) => { + if (f.type === 'meta' && (f.path === 'meta.json' || f.path === '/meta.json')) { + const data = f.data as { pages?: string[] }; + return { + ...f, + data: { + ...data, + pages: [...staticSlugs, ...(data.pages ?? [])], + }, + }; + } + return f; + }); + + return loader({ + baseUrl: API_REFERENCE_BASE_URL, + source: { + files: [...staticSource.files, ...patchedOpenapiFiles], + }, + plugins: [openapiPlugin()], + }); +}); diff --git a/apps/public/src/mdx-components.tsx b/apps/public/src/mdx-components.tsx index c43915820..06ff02708 100644 --- a/apps/public/src/mdx-components.tsx +++ b/apps/public/src/mdx-components.tsx @@ -24,16 +24,6 @@ export function getMDXComponents(components?: MDXComponents) { } satisfies MDXComponents; } -declare module 'mdx/types.js' { - // Augment the MDX types to make it understand React. - // eslint-disable-next-line @typescript-eslint/no-namespace - namespace JSX { - type Element = React.JSX.Element; - type ElementClass = React.JSX.ElementClass; - type ElementType = React.JSX.ElementType; - type IntrinsicElements = React.JSX.IntrinsicElements; - } -} declare global { type MDXProvidedComponents = ReturnType; diff --git a/apps/worker/src/jobs/import.ts b/apps/worker/src/jobs/import.ts index 95ada90ec..fc898f95e 100644 --- a/apps/worker/src/jobs/import.ts +++ b/apps/worker/src/jobs/import.ts @@ -33,7 +33,7 @@ const RESUMABLE_STEPS = ['creating_sessions', 'moving', 'backfilling_sessions']; export async function importJob(job: Job) { const { importId } = job.data.payload; - const record = await db.$primary().import.findUniqueOrThrow({ + const record = await db.import.findUniqueOrThrow({ where: { id: importId }, include: { project: true }, }); diff --git a/biome.json b/biome.json index ce71e9e01..4db27e00a 100644 --- a/biome.json +++ b/biome.json @@ -60,13 +60,15 @@ }, "correctness": { "useExhaustiveDependencies": "off", - "noUnreachable": "off" + "noUnreachable": "off", + "noGlobalDirnameFilename": "off" }, "performance": { "noDelete": "off", "noAccumulatingSpread": "off", "noBarrelFile": "off", - "noNamespaceImport": "off" + "noNamespaceImport": "off", + "useTopLevelRegex": "off" }, "suspicious": { "noExplicitAny": "off", diff --git a/docker-compose.yml b/docker-compose.yml index 0511328fd..42fd315f2 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -30,6 +30,9 @@ services: - ./docker/clickhouse/clickhouse-config.xml:/etc/clickhouse-server/config.d/op-config.xml - ./docker/clickhouse/clickhouse-user-config.xml:/etc/clickhouse-server/users.d/op-user-config.xml - ./docker/clickhouse/init-db.sh:/docker-entrypoint-initdb.d/init-db.sh:ro + environment: + CLICKHOUSE_DB: openpanel + CLICKHOUSE_SKIP_USER_SETUP: 1 ulimits: nofile: soft: 262144 diff --git a/docker/clickhouse/clickhouse-user-config.xml b/docker/clickhouse/clickhouse-user-config.xml index c8fdca290..56e2b31b3 100644 --- a/docker/clickhouse/clickhouse-user-config.xml +++ b/docker/clickhouse/clickhouse-user-config.xml @@ -8,7 +8,7 @@ - default + ::/0 diff --git a/packages/auth/src/session.ts b/packages/auth/src/session.ts index 3d4bcebc2..652060dcf 100644 --- a/packages/auth/src/session.ts +++ b/packages/auth/src/session.ts @@ -1,5 +1,5 @@ import crypto from 'node:crypto'; -import { type Session, type User, db } from '@openpanel/db'; +import { db, type Session, type User } from '@openpanel/db'; import { sha256 } from '@oslojs/crypto/sha2'; import { encodeBase32LowerCaseNoPadding, @@ -15,7 +15,7 @@ export function generateSessionToken(): string { export async function createSession( token: string, - userId: string, + userId: string ): Promise { const sessionId = encodeHexLowerCase(sha256(new TextEncoder().encode(token))); const session: Session = { @@ -38,7 +38,7 @@ export const EMPTY_SESSION: SessionValidationResult = { }; export async function createDemoSession( - userId: string, + userId: string ): Promise { const user = await db.user.findUniqueOrThrow({ where: { @@ -66,7 +66,7 @@ export const decodeSessionToken = (token: string): string | null => { }; export async function validateSessionToken( - token: string | null | undefined, + token: string | null | undefined ): Promise { if (process.env.DEMO_USER_ID) { return createDemoSession(process.env.DEMO_USER_ID); @@ -79,7 +79,7 @@ export async function validateSessionToken( if (!sessionId) { return EMPTY_SESSION; } - const result = await db.$primary().session.findUnique({ + const result = await db.session.findUnique({ where: { id: sessionId, }, diff --git a/packages/db/index.ts b/packages/db/index.ts index 2eb494b80..571233fce 100644 --- a/packages/db/index.ts +++ b/packages/db/index.ts @@ -11,10 +11,12 @@ export * from './src/services/chart.service'; export * from './src/services/clients.service'; export * from './src/services/conversion.service'; export * from './src/services/dashboard.service'; +export * from './src/services/date.service'; export * from './src/services/delete.service'; export * from './src/services/event.service'; export * from './src/services/funnel.service'; export * from './src/services/group.service'; +export * from './src/services/gsc.service'; export * from './src/services/id.service'; export * from './src/services/import.service'; export * from './src/services/insights'; diff --git a/packages/db/src/engine/index.ts b/packages/db/src/engine/index.ts index 0e5d72add..b6da4fd4d 100644 --- a/packages/db/src/engine/index.ts +++ b/packages/db/src/engine/index.ts @@ -7,10 +7,8 @@ import type { IReportInput, } from '@openpanel/validation'; import { chQuery } from '../clickhouse/client'; -import { - getAggregateChartSql, - getChartPrevStartEndDate, -} from '../services/chart.service'; +import { getAggregateChartSql } from '../services/chart.service'; +import { getChartPrevStartEndDate } from '../services/date.service'; import { getOrganizationSubscriptionChartEndDate, getSettingsForProject, diff --git a/packages/db/src/engine/normalize.ts b/packages/db/src/engine/normalize.ts index f77818ae1..94fffac70 100644 --- a/packages/db/src/engine/normalize.ts +++ b/packages/db/src/engine/normalize.ts @@ -5,7 +5,7 @@ import type { IReportInput, IReportInputWithDates, } from '@openpanel/validation'; -import { getChartStartEndDate } from '../services/chart.service'; +import { getChartStartEndDate } from '../services/date.service'; import { getSettingsForProject } from '../services/organization.service'; import type { SeriesDefinition } from './types'; diff --git a/packages/db/src/prisma-client.ts b/packages/db/src/prisma-client.ts index befe3f742..ece87ad1a 100644 --- a/packages/db/src/prisma-client.ts +++ b/packages/db/src/prisma-client.ts @@ -1,12 +1,4 @@ -import { createLogger } from '@openpanel/logger'; -import { readReplicas } from '@prisma/extension-read-replicas'; -import { - type Organization, - Prisma, - PrismaClient, -} from './generated/prisma/client'; -import { logger } from './logger'; -import { sessionConsistency } from './session-consistency'; +import { type Organization, PrismaClient } from './generated/prisma/client'; export * from './generated/prisma/client'; @@ -14,7 +6,7 @@ const isWillBeCanceled = ( organization: Pick< Organization, 'subscriptionStatus' | 'subscriptionCanceledAt' | 'subscriptionEndsAt' - >, + > ) => organization.subscriptionStatus === 'active' && organization.subscriptionCanceledAt && @@ -24,7 +16,7 @@ const isCanceled = ( organization: Pick< Organization, 'subscriptionStatus' | 'subscriptionCanceledAt' - >, + > ) => organization.subscriptionStatus === 'canceled' && organization.subscriptionCanceledAt && @@ -33,254 +25,228 @@ const isCanceled = ( const getPrismaClient = () => { const prisma = new PrismaClient({ log: ['error'], - }) - .$extends({ - query: { - async $allOperations({ operation, model, args, query }) { - if ( - operation === 'create' || - operation === 'update' || - operation === 'delete' - ) { - // logger.info('Prisma operation', { - // operation, - // args, - // model, - // }); - } - return query(args); - }, - }, - }) + }).$extends({ + result: { + organization: { + subscriptionStatus: { + needs: { subscriptionStatus: true, subscriptionCanceledAt: true }, + compute(org) { + if (process.env.SELF_HOSTED === 'true') { + return 'active'; + } - .$extends(sessionConsistency()) - .$extends({ - result: { - organization: { - subscriptionStatus: { - needs: { subscriptionStatus: true, subscriptionCanceledAt: true }, - compute(org) { - if (process.env.SELF_HOSTED === 'true') { - return 'active'; - } - - return org.subscriptionStatus || 'trialing'; - }, + return org.subscriptionStatus || 'trialing'; }, - hasSubscription: { - needs: { subscriptionStatus: true, subscriptionEndsAt: true }, - compute(org) { - if (process.env.SELF_HOSTED === 'true') { - return false; - } + }, + hasSubscription: { + needs: { subscriptionStatus: true, subscriptionEndsAt: true }, + compute(org) { + if (process.env.SELF_HOSTED === 'true') { + return false; + } - if ( - [null, 'canceled', 'trialing'].includes(org.subscriptionStatus) - ) { - return false; - } + if ( + [null, 'canceled', 'trialing'].includes(org.subscriptionStatus) + ) { + return false; + } - return true; - }, + return true; }, - slug: { - needs: { id: true }, - compute(org) { - return org.id; - }, + }, + slug: { + needs: { id: true }, + compute(org) { + return org.id; + }, + }, + subscriptionChartEndDate: { + needs: { + subscriptionEndsAt: true, + subscriptionPeriodEventsCountExceededAt: true, }, - subscriptionChartEndDate: { - needs: { - subscriptionEndsAt: true, - subscriptionPeriodEventsCountExceededAt: true, - }, - compute(org) { - if (process.env.SELF_HOSTED === 'true') { - return null; - } + compute(org) { + if (process.env.SELF_HOSTED === 'true') { + return null; + } - if ( - org.subscriptionEndsAt && + if ( + org.subscriptionEndsAt && + org.subscriptionPeriodEventsCountExceededAt + ) { + return org.subscriptionEndsAt > org.subscriptionPeriodEventsCountExceededAt - ) { - return org.subscriptionEndsAt > - org.subscriptionPeriodEventsCountExceededAt - ? org.subscriptionPeriodEventsCountExceededAt - : org.subscriptionEndsAt; - } + ? org.subscriptionPeriodEventsCountExceededAt + : org.subscriptionEndsAt; + } - if (org.subscriptionEndsAt) { - return org.subscriptionEndsAt; - } + if (org.subscriptionEndsAt) { + return org.subscriptionEndsAt; + } - // Hedge against edge cases :D - return new Date(Date.now() + 1000 * 60 * 60 * 24); - }, + // Hedge against edge cases :D + return new Date(Date.now() + 1000 * 60 * 60 * 24); }, - isActive: { - needs: { - subscriptionStatus: true, - subscriptionEndsAt: true, - subscriptionCanceledAt: true, - }, - compute(org) { - return ( - org.subscriptionStatus === 'active' && - org.subscriptionEndsAt && - org.subscriptionEndsAt > new Date() && - !isCanceled(org) && - !isWillBeCanceled(org) - ); - }, + }, + isActive: { + needs: { + subscriptionStatus: true, + subscriptionEndsAt: true, + subscriptionCanceledAt: true, }, - isTrial: { - needs: { subscriptionStatus: true, subscriptionEndsAt: true }, - compute(org) { - const isSubscriptionInFuture = - org.subscriptionEndsAt && org.subscriptionEndsAt > new Date(); - return ( - (org.subscriptionStatus === 'trialing' || - org.subscriptionStatus === null) && - isSubscriptionInFuture - ); - }, + compute(org) { + return ( + org.subscriptionStatus === 'active' && + org.subscriptionEndsAt && + org.subscriptionEndsAt > new Date() && + !isCanceled(org) && + !isWillBeCanceled(org) + ); }, - isCanceled: { - needs: { subscriptionStatus: true, subscriptionCanceledAt: true }, - compute(org) { - if (process.env.SELF_HOSTED === 'true') { - return false; - } + }, + isTrial: { + needs: { subscriptionStatus: true, subscriptionEndsAt: true }, + compute(org) { + const isSubscriptionInFuture = + org.subscriptionEndsAt && org.subscriptionEndsAt > new Date(); + return ( + (org.subscriptionStatus === 'trialing' || + org.subscriptionStatus === null) && + isSubscriptionInFuture + ); + }, + }, + isCanceled: { + needs: { subscriptionStatus: true, subscriptionCanceledAt: true }, + compute(org) { + if (process.env.SELF_HOSTED === 'true') { + return false; + } - return isCanceled(org); - }, + return isCanceled(org); }, - isWillBeCanceled: { - needs: { - subscriptionStatus: true, - subscriptionCanceledAt: true, - subscriptionEndsAt: true, - }, - compute(org) { - if (process.env.SELF_HOSTED === 'true') { - return false; - } + }, + isWillBeCanceled: { + needs: { + subscriptionStatus: true, + subscriptionCanceledAt: true, + subscriptionEndsAt: true, + }, + compute(org) { + if (process.env.SELF_HOSTED === 'true') { + return false; + } - return isWillBeCanceled(org); - }, + return isWillBeCanceled(org); + }, + }, + isExpired: { + needs: { + subscriptionEndsAt: true, + subscriptionStatus: true, + subscriptionCanceledAt: true, }, - isExpired: { - needs: { - subscriptionEndsAt: true, - subscriptionStatus: true, - subscriptionCanceledAt: true, - }, - compute(org) { - if (process.env.SELF_HOSTED === 'true') { - return false; - } + compute(org) { + if (process.env.SELF_HOSTED === 'true') { + return false; + } - if (isCanceled(org)) { - return false; - } + if (isCanceled(org)) { + return false; + } - if (isWillBeCanceled(org)) { - return false; - } + if (isWillBeCanceled(org)) { + return false; + } - return ( - org.subscriptionEndsAt && org.subscriptionEndsAt < new Date() - ); - }, + return ( + org.subscriptionEndsAt && org.subscriptionEndsAt < new Date() + ); + }, + }, + isExceeded: { + needs: { + subscriptionPeriodEventsCount: true, + subscriptionPeriodEventsLimit: true, }, - isExceeded: { - needs: { - subscriptionPeriodEventsCount: true, - subscriptionPeriodEventsLimit: true, - }, - compute(org) { - if (process.env.SELF_HOSTED === 'true') { - return false; - } + compute(org) { + if (process.env.SELF_HOSTED === 'true') { + return false; + } - return ( - org.subscriptionPeriodEventsCount > - org.subscriptionPeriodEventsLimit - ); - }, + return ( + org.subscriptionPeriodEventsCount > + org.subscriptionPeriodEventsLimit + ); }, - subscriptionCurrentPeriodStart: { - needs: { subscriptionStartsAt: true, subscriptionInterval: true }, - compute(org) { - if (process.env.SELF_HOSTED === 'true') { - return null; - } + }, + subscriptionCurrentPeriodStart: { + needs: { subscriptionStartsAt: true, subscriptionInterval: true }, + compute(org) { + if (process.env.SELF_HOSTED === 'true') { + return null; + } - if (!org.subscriptionStartsAt) { - return null; - } + if (!org.subscriptionStartsAt) { + return null; + } - if (org.subscriptionInterval === 'year') { - const startDay = org.subscriptionStartsAt.getUTCDate(); - const now = new Date(); - return new Date( - Date.UTC( - now.getUTCFullYear(), - now.getUTCMonth(), - startDay, - 0, - 0, - 0, - 0, - ), - ); - } + if (org.subscriptionInterval === 'year') { + const startDay = org.subscriptionStartsAt.getUTCDate(); + const now = new Date(); + return new Date( + Date.UTC( + now.getUTCFullYear(), + now.getUTCMonth(), + startDay, + 0, + 0, + 0, + 0 + ) + ); + } - return org.subscriptionStartsAt; - }, + return org.subscriptionStartsAt; }, - subscriptionCurrentPeriodEnd: { - needs: { - subscriptionStartsAt: true, - subscriptionEndsAt: true, - subscriptionInterval: true, - }, - compute(org) { - if (process.env.SELF_HOSTED === 'true') { - return null; - } + }, + subscriptionCurrentPeriodEnd: { + needs: { + subscriptionStartsAt: true, + subscriptionEndsAt: true, + subscriptionInterval: true, + }, + compute(org) { + if (process.env.SELF_HOSTED === 'true') { + return null; + } - if (!org.subscriptionStartsAt) { - return null; - } + if (!org.subscriptionStartsAt) { + return null; + } - if (org.subscriptionInterval === 'year') { - const startDay = org.subscriptionStartsAt.getUTCDate(); - const now = new Date(); - return new Date( - Date.UTC( - now.getUTCFullYear(), - now.getUTCMonth() + 1, - startDay - 1, - 0, - 0, - 0, - 0, - ), - ); - } + if (org.subscriptionInterval === 'year') { + const startDay = org.subscriptionStartsAt.getUTCDate(); + const now = new Date(); + return new Date( + Date.UTC( + now.getUTCFullYear(), + now.getUTCMonth() + 1, + startDay - 1, + 0, + 0, + 0, + 0 + ) + ); + } - return org.subscriptionEndsAt; - }, + return org.subscriptionEndsAt; }, }, }, - }) - .$extends( - readReplicas({ - url: process.env.DATABASE_URL_REPLICA ?? process.env.DATABASE_URL!, - }), - ); + }, + }); return prisma; }; diff --git a/packages/db/src/services/access.service.ts b/packages/db/src/services/access.service.ts index 0b66ae64d..53cf364a3 100644 --- a/packages/db/src/services/access.service.ts +++ b/packages/db/src/services/access.service.ts @@ -4,13 +4,7 @@ import { getProjectById } from './project.service'; export const getProjectAccess = cacheable( 'getProjectAccess', - async ({ - userId, - projectId, - }: { - userId: string; - projectId: string; - }) => { + async ({ userId, projectId }: { userId: string; projectId: string }) => { try { // Check if user has access to the project const project = await getProjectById(projectId); @@ -19,13 +13,13 @@ export const getProjectAccess = cacheable( } const [projectAccess, member] = await Promise.all([ - db.$primary().projectAccess.findMany({ + db.projectAccess.findMany({ where: { userId, organizationId: project.organizationId, }, }), - db.$primary().member.findFirst({ + db.member.findFirst({ where: { organizationId: project.organizationId, userId, @@ -42,7 +36,7 @@ export const getProjectAccess = cacheable( return false; } }, - 60 * 5, + 60 * 5 ); export const getOrganizationAccess = cacheable( @@ -54,14 +48,14 @@ export const getOrganizationAccess = cacheable( userId: string; organizationId: string; }) => { - return db.$primary().member.findFirst({ + return db.member.findFirst({ where: { userId, organizationId, }, }); }, - 60 * 5, + 60 * 5 ); export async function getClientAccess({ diff --git a/packages/db/src/services/chart.service.ts b/packages/db/src/services/chart.service.ts index 4083cee07..1f9291d5b 100644 --- a/packages/db/src/services/chart.service.ts +++ b/packages/db/src/services/chart.service.ts @@ -1,8 +1,7 @@ /** biome-ignore-all lint/style/useDefaultSwitchClause: switch cases are exhaustive by design */ -import { DateTime, stripLeadingAndTrailingSlashes } from '@openpanel/common'; +import { stripLeadingAndTrailingSlashes } from '@openpanel/common'; import type { IChartEventFilter, - IChartRange, IGetChartDataInput, IReportInput, } from '@openpanel/validation'; @@ -1106,239 +1105,3 @@ export function getEventFiltersWhereClause( return where; } - -export function getChartStartEndDate( - { - startDate, - endDate, - range, - }: Pick, - timezone: string -) { - if (startDate && endDate) { - return { startDate, endDate }; - } - - const ranges = getDatesFromRange(range, timezone); - if (!startDate && endDate) { - return { startDate: ranges.startDate, endDate }; - } - - return ranges; -} - -export function getDatesFromRange(range: IChartRange, timezone: string) { - if (range === '30min' || range === 'lastHour') { - const minutes = range === '30min' ? 30 : 60; - const startDate = DateTime.now() - .minus({ minute: minutes }) - .startOf('minute') - .setZone(timezone) - .toFormat('yyyy-MM-dd HH:mm:ss'); - const endDate = DateTime.now() - .setZone(timezone) - .endOf('minute') - .toFormat('yyyy-MM-dd HH:mm:ss'); - - return { - startDate, - endDate, - }; - } - - if (range === 'today') { - const startDate = DateTime.now() - .setZone(timezone) - .startOf('day') - .toFormat('yyyy-MM-dd HH:mm:ss'); - const endDate = DateTime.now() - .setZone(timezone) - .endOf('day') - .toFormat('yyyy-MM-dd HH:mm:ss'); - - return { - startDate, - endDate, - }; - } - - if (range === 'yesterday') { - const startDate = DateTime.now() - .minus({ day: 1 }) - .setZone(timezone) - .startOf('day') - .toFormat('yyyy-MM-dd HH:mm:ss'); - const endDate = DateTime.now() - .minus({ day: 1 }) - .setZone(timezone) - .endOf('day') - .toFormat('yyyy-MM-dd HH:mm:ss'); - return { - startDate, - endDate, - }; - } - - if (range === '7d') { - const startDate = DateTime.now() - .minus({ day: 7 }) - .setZone(timezone) - .startOf('day') - .toFormat('yyyy-MM-dd HH:mm:ss'); - const endDate = DateTime.now() - .setZone(timezone) - .endOf('day') - .plus({ millisecond: 1 }) - .toFormat('yyyy-MM-dd HH:mm:ss'); - - return { - startDate, - endDate, - }; - } - - if (range === '6m') { - const startDate = DateTime.now() - .minus({ month: 6 }) - .setZone(timezone) - .startOf('day') - .toFormat('yyyy-MM-dd HH:mm:ss'); - const endDate = DateTime.now() - .setZone(timezone) - .endOf('day') - .plus({ millisecond: 1 }) - .toFormat('yyyy-MM-dd HH:mm:ss'); - - return { - startDate, - endDate, - }; - } - - if (range === '12m') { - const startDate = DateTime.now() - .minus({ month: 12 }) - .setZone(timezone) - .startOf('month') - .toFormat('yyyy-MM-dd HH:mm:ss'); - const endDate = DateTime.now() - .setZone(timezone) - .endOf('month') - .plus({ millisecond: 1 }) - .toFormat('yyyy-MM-dd HH:mm:ss'); - - return { - startDate, - endDate, - }; - } - - if (range === 'monthToDate') { - const startDate = DateTime.now() - .setZone(timezone) - .startOf('month') - .toFormat('yyyy-MM-dd HH:mm:ss'); - const endDate = DateTime.now() - .setZone(timezone) - .endOf('day') - .plus({ millisecond: 1 }) - .toFormat('yyyy-MM-dd HH:mm:ss'); - - return { - startDate, - endDate, - }; - } - - if (range === 'lastMonth') { - const month = DateTime.now() - .minus({ month: 1 }) - .setZone(timezone) - .startOf('month'); - - const startDate = month.toFormat('yyyy-MM-dd HH:mm:ss'); - const endDate = month - .endOf('month') - .plus({ millisecond: 1 }) - .toFormat('yyyy-MM-dd HH:mm:ss'); - - return { - startDate, - endDate, - }; - } - - if (range === 'yearToDate') { - const startDate = DateTime.now() - .setZone(timezone) - .startOf('year') - .toFormat('yyyy-MM-dd HH:mm:ss'); - const endDate = DateTime.now() - .setZone(timezone) - .endOf('day') - .plus({ millisecond: 1 }) - .toFormat('yyyy-MM-dd HH:mm:ss'); - - return { - startDate, - endDate, - }; - } - - if (range === 'lastYear') { - const year = DateTime.now().minus({ year: 1 }).setZone(timezone); - const startDate = year.startOf('year').toFormat('yyyy-MM-dd HH:mm:ss'); - const endDate = year.endOf('year').toFormat('yyyy-MM-dd HH:mm:ss'); - - return { - startDate, - endDate, - }; - } - - // range === '30d' - const startDate = DateTime.now() - .minus({ day: 30 }) - .setZone(timezone) - .startOf('day') - .toFormat('yyyy-MM-dd HH:mm:ss'); - const endDate = DateTime.now() - .setZone(timezone) - .endOf('day') - .plus({ millisecond: 1 }) - .toFormat('yyyy-MM-dd HH:mm:ss'); - - return { - startDate, - endDate, - }; -} - -export function getChartPrevStartEndDate({ - startDate, - endDate, -}: { - startDate: string; - endDate: string; -}) { - let diff = DateTime.fromFormat(endDate, 'yyyy-MM-dd HH:mm:ss').diff( - DateTime.fromFormat(startDate, 'yyyy-MM-dd HH:mm:ss') - ); - - // this will make sure our start and end date's are correct - // otherwise if a day ends with 23:59:59.999 and starts with 00:00:00.000 - // the diff will be 23:59:59.999 and that will make the start date wrong - // so we add 1 millisecond to the diff - if ((diff.milliseconds / 1000) % 2 !== 0) { - diff = diff.plus({ millisecond: 1 }); - } - - return { - startDate: DateTime.fromFormat(startDate, 'yyyy-MM-dd HH:mm:ss') - .minus({ millisecond: diff.milliseconds }) - .toFormat('yyyy-MM-dd HH:mm:ss'), - endDate: DateTime.fromFormat(endDate, 'yyyy-MM-dd HH:mm:ss') - .minus({ millisecond: diff.milliseconds }) - .toFormat('yyyy-MM-dd HH:mm:ss'), - }; -} diff --git a/packages/db/src/services/dashboard.service.ts b/packages/db/src/services/dashboard.service.ts index 1cc070c46..d4d392afe 100644 --- a/packages/db/src/services/dashboard.service.ts +++ b/packages/db/src/services/dashboard.service.ts @@ -38,3 +38,14 @@ export function getDashboardsByProjectId(projectId: string) { }, }); } + +export async function listDashboardsCore(input: { + projectId: string; + organizationId: string; +}) { + return db.dashboard.findMany({ + where: { projectId: input.projectId }, + orderBy: { createdAt: 'desc' }, + select: { id: true, name: true, projectId: true }, + }); +} diff --git a/packages/db/src/services/date.service.ts b/packages/db/src/services/date.service.ts new file mode 100644 index 000000000..d10a8580b --- /dev/null +++ b/packages/db/src/services/date.service.ts @@ -0,0 +1,245 @@ +import { DateTime } from '@openpanel/common'; +import type { IChartRange, IReportInput } from '@openpanel/validation'; + +export function resolveDateRange( + startDate?: string, + endDate?: string +): { startDate: string; endDate: string } { + const end = endDate ?? new Date().toISOString().slice(0, 10); + const start = + startDate ?? + new Date(Date.now() - 30 * 86_400_000).toISOString().slice(0, 10); + return { startDate: start, endDate: end }; +} + +export function getDatesFromRange(range: IChartRange, timezone: string) { + if (range === '30min' || range === 'lastHour') { + const minutes = range === '30min' ? 30 : 60; + const startDate = DateTime.now() + .minus({ minute: minutes }) + .startOf('minute') + .setZone(timezone) + .toFormat('yyyy-MM-dd HH:mm:ss'); + const endDate = DateTime.now() + .setZone(timezone) + .endOf('minute') + .toFormat('yyyy-MM-dd HH:mm:ss'); + + return { + startDate, + endDate, + }; + } + + if (range === 'today') { + const startDate = DateTime.now() + .setZone(timezone) + .startOf('day') + .toFormat('yyyy-MM-dd HH:mm:ss'); + const endDate = DateTime.now() + .setZone(timezone) + .endOf('day') + .toFormat('yyyy-MM-dd HH:mm:ss'); + + return { + startDate, + endDate, + }; + } + + if (range === 'yesterday') { + const startDate = DateTime.now() + .minus({ day: 1 }) + .setZone(timezone) + .startOf('day') + .toFormat('yyyy-MM-dd HH:mm:ss'); + const endDate = DateTime.now() + .minus({ day: 1 }) + .setZone(timezone) + .endOf('day') + .toFormat('yyyy-MM-dd HH:mm:ss'); + return { + startDate, + endDate, + }; + } + + if (range === '7d') { + const startDate = DateTime.now() + .minus({ day: 7 }) + .setZone(timezone) + .startOf('day') + .toFormat('yyyy-MM-dd HH:mm:ss'); + const endDate = DateTime.now() + .setZone(timezone) + .endOf('day') + .plus({ millisecond: 1 }) + .toFormat('yyyy-MM-dd HH:mm:ss'); + + return { + startDate, + endDate, + }; + } + + if (range === '6m') { + const startDate = DateTime.now() + .minus({ month: 6 }) + .setZone(timezone) + .startOf('day') + .toFormat('yyyy-MM-dd HH:mm:ss'); + const endDate = DateTime.now() + .setZone(timezone) + .endOf('day') + .plus({ millisecond: 1 }) + .toFormat('yyyy-MM-dd HH:mm:ss'); + + return { + startDate, + endDate, + }; + } + + if (range === '12m') { + const startDate = DateTime.now() + .minus({ month: 12 }) + .setZone(timezone) + .startOf('month') + .toFormat('yyyy-MM-dd HH:mm:ss'); + const endDate = DateTime.now() + .setZone(timezone) + .endOf('month') + .plus({ millisecond: 1 }) + .toFormat('yyyy-MM-dd HH:mm:ss'); + + return { + startDate, + endDate, + }; + } + + if (range === 'monthToDate') { + const startDate = DateTime.now() + .setZone(timezone) + .startOf('month') + .toFormat('yyyy-MM-dd HH:mm:ss'); + const endDate = DateTime.now() + .setZone(timezone) + .endOf('day') + .plus({ millisecond: 1 }) + .toFormat('yyyy-MM-dd HH:mm:ss'); + + return { + startDate, + endDate, + }; + } + + if (range === 'lastMonth') { + const month = DateTime.now() + .minus({ month: 1 }) + .setZone(timezone) + .startOf('month'); + + const startDate = month.toFormat('yyyy-MM-dd HH:mm:ss'); + const endDate = month + .endOf('month') + .plus({ millisecond: 1 }) + .toFormat('yyyy-MM-dd HH:mm:ss'); + + return { + startDate, + endDate, + }; + } + + if (range === 'yearToDate') { + const startDate = DateTime.now() + .setZone(timezone) + .startOf('year') + .toFormat('yyyy-MM-dd HH:mm:ss'); + const endDate = DateTime.now() + .setZone(timezone) + .endOf('day') + .plus({ millisecond: 1 }) + .toFormat('yyyy-MM-dd HH:mm:ss'); + + return { + startDate, + endDate, + }; + } + + if (range === 'lastYear') { + const year = DateTime.now().minus({ year: 1 }).setZone(timezone); + const startDate = year.startOf('year').toFormat('yyyy-MM-dd HH:mm:ss'); + const endDate = year.endOf('year').toFormat('yyyy-MM-dd HH:mm:ss'); + + return { + startDate, + endDate, + }; + } + + // range === '30d' + const startDate = DateTime.now() + .minus({ day: 30 }) + .setZone(timezone) + .startOf('day') + .toFormat('yyyy-MM-dd HH:mm:ss'); + const endDate = DateTime.now() + .setZone(timezone) + .endOf('day') + .plus({ millisecond: 1 }) + .toFormat('yyyy-MM-dd HH:mm:ss'); + + return { + startDate, + endDate, + }; +} + +export function getChartStartEndDate( + { + startDate, + endDate, + range, + }: Pick, + timezone: string +) { + if (startDate && endDate) { + return { startDate, endDate }; + } + + const ranges = getDatesFromRange(range, timezone); + if (!startDate && endDate) { + return { startDate: ranges.startDate, endDate }; + } + + return ranges; +} + +export function getChartPrevStartEndDate({ + startDate, + endDate, +}: { + startDate: string; + endDate: string; +}) { + let diff = DateTime.fromFormat(endDate, 'yyyy-MM-dd HH:mm:ss').diff( + DateTime.fromFormat(startDate, 'yyyy-MM-dd HH:mm:ss') + ); + + if ((diff.milliseconds / 1000) % 2 !== 0) { + diff = diff.plus({ millisecond: 1 }); + } + + return { + startDate: DateTime.fromFormat(startDate, 'yyyy-MM-dd HH:mm:ss') + .minus({ millisecond: diff.milliseconds }) + .toFormat('yyyy-MM-dd HH:mm:ss'), + endDate: DateTime.fromFormat(endDate, 'yyyy-MM-dd HH:mm:ss') + .minus({ millisecond: diff.milliseconds }) + .toFormat('yyyy-MM-dd HH:mm:ss'), + }; +} diff --git a/packages/db/src/services/event.service.ts b/packages/db/src/services/event.service.ts index 50d96b1c6..e57ac9b59 100644 --- a/packages/db/src/services/event.service.ts +++ b/packages/db/src/services/event.service.ts @@ -1138,3 +1138,155 @@ class EventService { } export const eventService = new EventService(ch); + +import { getCache } from '@openpanel/redis'; +import { resolveDateRange } from './date.service'; + +export async function getTopEventNames(projectId: string): Promise { + return getCache(`mcp:event-names:${projectId}`, 60 * 10, async () => { + const rows = await clix(ch) + .select(['name', 'count() as count']) + .from(TABLE_NAMES.event_names_mv) + .where('project_id', '=', projectId) + .groupBy(['name']) + .orderBy('count', 'DESC') + .limit(50) + .execute(); + + return rows.map((r) => r.name); + }); +} + +export const listEventNamesCore = (projectId: string): Promise => + getTopEventNames(projectId); + +export async function listEventPropertiesCore(input: { + projectId: string; + eventName?: string; +}): Promise<{ properties: Array<{ property_key: string; event_name: string }> }> { + const builder = clix(ch) + .select<{ property_key: string; event_name: string }>([ + 'distinct property_key', + 'name as event_name', + ]) + .from(TABLE_NAMES.event_property_values_mv) + .where('project_id', '=', input.projectId) + .orderBy('property_key', 'ASC') + .limit(500); + + if (input.eventName) { + builder.where('name', '=', input.eventName); + } + + const rows = await builder.execute(); + return { properties: rows }; +} + +export async function getEventPropertyValuesCore(input: { + projectId: string; + eventName: string; + propertyKey: string; +}): Promise<{ event: string; property: string; values: string[] }> { + const rows = await clix(ch) + .select<{ value: string }>(['property_value as value']) + .from(TABLE_NAMES.event_property_values_mv) + .where('project_id', '=', input.projectId) + .where('name', '=', input.eventName) + .where('property_key', '=', input.propertyKey) + .orderBy('created_at', 'DESC') + .limit(200) + .execute(); + + return { + event: input.eventName, + property: input.propertyKey, + values: rows.map((r) => r.value), + }; +} + +export interface QueryEventsInput { + projectId: string; + startDate?: string; + endDate?: string; + eventNames?: string[]; + path?: string; + country?: string; + city?: string; + device?: string; + browser?: string; + os?: string; + referrer?: string; + referrerName?: string; + referrerType?: string; + profileId?: string; + properties?: Record; + limit?: number; +} + +export async function queryEventsCore( + input: QueryEventsInput, +): Promise { + const builder = clix(ch) + .select([]) + .from(TABLE_NAMES.events) + .where('project_id', '=', input.projectId); + + if (input.profileId) { + builder.where('profile_id', '=', input.profileId); + } + + if (input.eventNames?.length) { + builder.where('name', 'IN', input.eventNames); + } + + if (input.path) { + builder.where('path', '=', input.path); + } + + if (input.referrer) { + builder.where('referrer', '=', input.referrer); + } + + if (input.referrerName) { + builder.where('referrer_name', '=', input.referrerName); + } + + if (input.referrerType) { + builder.where('referrer_type', '=', input.referrerType); + } + + if (input.device) { + builder.where('device', '=', input.device); + } + + if (input.country) { + builder.where('country', '=', input.country); + } + + if (input.city) { + builder.where('city', '=', input.city); + } + + if (input.os) { + builder.where('os', '=', input.os); + } + + if (input.browser) { + builder.where('browser', '=', input.browser); + } + + if (input.properties) { + for (const [key, value] of Object.entries(input.properties)) { + builder.rawWhere(`properties[${sqlstring.escape(key)}] = ${sqlstring.escape(value)}`); + } + } + + const { startDate: start, endDate: end } = resolveDateRange(input.startDate, input.endDate); + + builder.where('created_at', 'BETWEEN', [ + clix.datetime(start), + clix.datetime(end), + ]); + + return builder.limit(input.limit ?? 20).execute(); +} diff --git a/packages/db/src/services/funnel.service.ts b/packages/db/src/services/funnel.service.ts index 17ad964cb..4d587aadb 100644 --- a/packages/db/src/services/funnel.service.ts +++ b/packages/db/src/services/funnel.service.ts @@ -412,3 +412,78 @@ export class FunnelService { } export const funnelService = new FunnelService(ch); + +import { getSettingsForProject } from './organization.service'; + +export async function getFunnelCore(input: { + projectId: string; + startDate: string; + endDate: string; + steps: string[]; + windowHours?: number; + groupBy?: 'session_id' | 'profile_id'; +}) { + const { timezone } = await getSettingsForProject(input.projectId); + const eventSeries = input.steps.map((name, index) => ({ + id: String(index + 1), + type: 'event' as const, + name, + displayName: name, + segment: 'user' as const, + filters: [], + })); + + const result = await funnelService.getFunnel({ + projectId: input.projectId, + startDate: input.startDate, + endDate: input.endDate, + series: eventSeries, + breakdowns: [], + chartType: 'funnel', + interval: 'day', + range: 'custom', + previous: false, + metric: 'sum', + options: { + type: 'funnel', + funnelWindow: input.windowHours ?? 24, + funnelGroup: input.groupBy ?? 'session_id', + }, + timezone, + }); + + const primarySeries = result[0]; + if (!primarySeries) { + return { + steps: [], + totalUsers: 0, + completedUsers: 0, + overallConversionRate: 0, + }; + } + + const steps = primarySeries.steps.map((step, index) => ({ + step: index + 1, + eventName: step.event.displayName || step.event.name, + users: step.count, + conversionRateFromStart: Math.round(step.percent * 100) / 100, + dropoffPercent: + step.dropoffPercent != null + ? Math.round(step.dropoffPercent * 100) / 100 + : null, + isHighestDropoff: step.isHighestDropoff, + })); + + const totalUsers = steps[0]?.users ?? 0; + const completedUsers = steps[steps.length - 1]?.users ?? 0; + + return { + steps, + totalUsers, + completedUsers, + overallConversionRate: + totalUsers > 0 + ? Math.round((completedUsers / totalUsers) * 10000) / 100 + : 0, + }; +} diff --git a/packages/db/src/services/group.service.ts b/packages/db/src/services/group.service.ts index 03cb3547f..baa79b8ff 100644 --- a/packages/db/src/services/group.service.ts +++ b/packages/db/src/services/group.service.ts @@ -350,3 +350,47 @@ export async function getGroupMemberProfiles({ .filter(Boolean) as IServiceProfile[]; return { data, count }; } + +export async function listGroupTypesCore(projectId: string) { + const types = await getGroupTypes(projectId); + return { types }; +} + +export async function findGroupsCore(input: { + projectId: string; + type?: string; + search?: string; + limit?: number; +}) { + return getGroupList({ + projectId: input.projectId, + type: input.type, + search: input.search, + take: input.limit ?? 20, + }); +} + +export async function getGroupCore(input: { + projectId: string; + groupId: string; + memberLimit?: number; +}) { + const [group, members] = await Promise.all([ + getGroupById(input.groupId, input.projectId), + getGroupMemberProfiles({ + projectId: input.projectId, + groupId: input.groupId, + take: input.memberLimit ?? 10, + }), + ]); + + if (!group) { + throw new Error(`Group not found: ${input.groupId}`); + } + + return { + group, + member_count: members.count, + members: members.data, + }; +} diff --git a/packages/db/src/services/gsc.service.ts b/packages/db/src/services/gsc.service.ts new file mode 100644 index 000000000..8314ef361 --- /dev/null +++ b/packages/db/src/services/gsc.service.ts @@ -0,0 +1,194 @@ +import { getGscCannibalization, getGscOverview, getGscPageDetails, getGscPages, getGscQueryDetails, getGscQueries } from '../gsc'; + +export interface GscQueryOpportunity { + query: string; + clicks: number; + impressions: number; + ctr: number; + position: number; + opportunity_score: number; + reason: string; +} + +function computeOpportunities( + queries: Array<{ + query: string; + clicks: number; + impressions: number; + ctr: number; + position: number; + }>, +): GscQueryOpportunity[] { + const ctrBenchmarks: Record = { + '1': 0.28, + '2': 0.15, + '3': 0.11, + '4-6': 0.065, + '7-10': 0.035, + '11-20': 0.012, + }; + + function getBenchmark(position: number): number { + if (position <= 1) return ctrBenchmarks['1'] ?? 0.28; + if (position <= 2) return ctrBenchmarks['2'] ?? 0.15; + if (position <= 3) return ctrBenchmarks['3'] ?? 0.11; + if (position <= 6) return ctrBenchmarks['4-6'] ?? 0.065; + if (position <= 10) return ctrBenchmarks['7-10'] ?? 0.035; + return ctrBenchmarks['11-20'] ?? 0.012; + } + + return queries + .filter((q) => q.position >= 4 && q.position <= 20 && q.impressions >= 50) + .map((q) => { + const benchmark = getBenchmark(q.position); + const ctrGap = Math.max(0, benchmark - q.ctr); + const opportunity_score = + Math.round(q.impressions * (1 / q.position) * (1 + ctrGap) * 100) / + 100; + + let reason: string; + if (q.position <= 6) { + reason = `Position ${q.position.toFixed(1)} — one rank improvement could significantly boost clicks`; + } else if (q.ctr < benchmark * 0.5) { + reason = `CTR (${(q.ctr * 100).toFixed(1)}%) is well below expected ${(benchmark * 100).toFixed(1)}% — title/meta optimization may help`; + } else { + reason = `Position ${q.position.toFixed(1)} with ${q.impressions} impressions — push to page 1 for major gains`; + } + + return { + query: q.query, + clicks: q.clicks, + impressions: q.impressions, + ctr: Math.round(q.ctr * 10000) / 100, + position: Math.round(q.position * 10) / 10, + opportunity_score, + reason, + }; + }) + .sort((a, b) => b.opportunity_score - a.opportunity_score) + .slice(0, 50); +} + +export async function gscGetOverviewCore(input: { + projectId: string; + startDate: string; + endDate: string; + interval?: 'day' | 'week' | 'month'; +}) { + const data = await getGscOverview( + input.projectId, + input.startDate, + input.endDate, + input.interval ?? 'day', + ); + return { + data, + summary: { + total_clicks: data.reduce((s, r) => s + r.clicks, 0), + total_impressions: data.reduce((s, r) => s + r.impressions, 0), + avg_ctr: + data.length > 0 + ? Math.round( + (data.reduce((s, r) => s + r.ctr, 0) / data.length) * 10000, + ) / 100 + : 0, + avg_position: + data.length > 0 + ? Math.round( + (data.reduce((s, r) => s + r.position, 0) / data.length) * 10, + ) / 10 + : 0, + }, + }; +} + +export async function gscGetTopPagesCore(input: { + projectId: string; + startDate: string; + endDate: string; + limit?: number; +}) { + return getGscPages( + input.projectId, + input.startDate, + input.endDate, + input.limit ?? 100, + ); +} + +export async function gscGetPageDetailsCore(input: { + projectId: string; + startDate: string; + endDate: string; + page: string; +}) { + return getGscPageDetails( + input.projectId, + input.page, + input.startDate, + input.endDate, + ); +} + +export async function gscGetTopQueriesCore(input: { + projectId: string; + startDate: string; + endDate: string; + limit?: number; +}) { + return getGscQueries( + input.projectId, + input.startDate, + input.endDate, + input.limit ?? 100, + ); +} + +export async function gscGetQueryOpportunitiesCore(input: { + projectId: string; + startDate: string; + endDate: string; + minImpressions?: number; +}) { + const queries = await getGscQueries( + input.projectId, + input.startDate, + input.endDate, + 5000, + ); + const filtered = queries.filter( + (q) => q.impressions >= (input.minImpressions ?? 50), + ); + const opportunities = computeOpportunities(filtered); + return { + opportunities, + total_analyzed: filtered.length, + min_impressions: input.minImpressions ?? 50, + }; +} + +export async function gscGetQueryDetailsCore(input: { + projectId: string; + startDate: string; + endDate: string; + query: string; +}) { + return getGscQueryDetails( + input.projectId, + input.query, + input.startDate, + input.endDate, + ); +} + +export async function gscGetCannibalizationCore(input: { + projectId: string; + startDate: string; + endDate: string; +}) { + return getGscCannibalization( + input.projectId, + input.startDate, + input.endDate, + ); +} diff --git a/packages/db/src/services/organization.service.ts b/packages/db/src/services/organization.service.ts index ce8876351..ddb0f70c9 100644 --- a/packages/db/src/services/organization.service.ts +++ b/packages/db/src/services/organization.service.ts @@ -6,7 +6,7 @@ import type { Invite, Prisma, ProjectAccess, User } from '../prisma-client'; import { db } from '../prisma-client'; import { createSqlBuilder } from '../sql-builder'; import { getOrganizationAccess, getProjectAccess } from './access.service'; -import { type IServiceProject, getProjectById } from './project.service'; +import type { IServiceProject } from './project.service'; export type IServiceOrganization = Awaited< ReturnType >; @@ -17,7 +17,9 @@ export type IServiceMember = Prisma.MemberGetPayload<{ export type IServiceProjectAccess = ProjectAccess; export async function getOrganizations(userId: string | null) { - if (!userId) return []; + if (!userId) { + return []; + } const organizations = await db.organization.findMany({ where: { @@ -62,7 +64,7 @@ export async function getOrganizationByProjectId(projectId: string) { export const getOrganizationByProjectIdCached = cacheable( getOrganizationByProjectId, - 60 * 5, + 60 * 5 ); export async function getInvites(organizationId: string) { @@ -141,7 +143,7 @@ export async function connectUserToOrganization({ }) { // Use primary since before this we might have just created the invite // If we use replica it might not find the invite - const invite = await db.$primary().invite.findUnique({ + const invite = await db.invite.findUnique({ where: { id: inviteId, }, @@ -202,13 +204,15 @@ export async function connectUserToOrganization({ * current subscription period for an organization */ export async function getOrganizationBillingEventsCount( - organization: IServiceOrganization & { projects: IServiceProject[] }, + organization: IServiceOrganization & { projects: IServiceProject[] } ) { // Dont count events if the organization has no subscription // Since we only use this for billing purposes if ( - !organization.subscriptionCurrentPeriodStart || - !organization.subscriptionCurrentPeriodEnd + !( + organization.subscriptionCurrentPeriodStart && + organization.subscriptionCurrentPeriodEnd + ) ) { return 0; } @@ -232,7 +236,7 @@ export async function getOrganizationBillingEventsCountSerie( }: { startDate: Date; endDate: Date; - }, + } ) { const interval = 'day'; const { sb, getSql } = createSqlBuilder(); @@ -251,12 +255,12 @@ export async function getOrganizationBillingEventsCountSerie( export const getOrganizationBillingEventsCountSerieCached = cacheable( getOrganizationBillingEventsCountSerie, - 60 * 10, + 60 * 10 ); export async function getOrganizationSubscriptionChartEndDate( projectId: string, - endDate: string, + endDate: string ) { const organization = await getOrganizationByProjectIdCached(projectId); if (!organization) { diff --git a/packages/db/src/services/overview.service.ts b/packages/db/src/services/overview.service.ts index d62b83a3d..a0b30ab16 100644 --- a/packages/db/src/services/overview.service.ts +++ b/packages/db/src/services/overview.service.ts @@ -1441,3 +1441,67 @@ export class OverviewService { } export const overviewService = new OverviewService(ch); + +import { getSettingsForProject } from './organization.service'; + +export type TrafficColumn = + | 'referrer' + | 'referrer_name' + | 'referrer_type' + | 'utm_source' + | 'utm_medium' + | 'utm_campaign' + | 'country' + | 'region' + | 'city' + | 'device' + | 'browser' + | 'os'; + +export async function getTrafficBreakdownCore(input: { + projectId: string; + startDate: string; + endDate: string; + column: TrafficColumn; +}) { + const { timezone } = await getSettingsForProject(input.projectId); + return overviewService.getTopGeneric({ + projectId: input.projectId, + filters: [], + startDate: input.startDate, + endDate: input.endDate, + column: input.column, + timezone, + }); +} + +export interface GetAnalyticsOverviewInput { + projectId: string; + startDate: string; + endDate: string; + interval?: 'hour' | 'day' | 'week' | 'month'; +} + +export async function getAnalyticsOverviewCore( + input: GetAnalyticsOverviewInput, +) { + const { timezone } = await getSettingsForProject(input.projectId); + const interval = input.interval ?? 'day'; + + const result = await overviewService.getMetrics({ + projectId: input.projectId, + filters: [], + startDate: input.startDate, + endDate: input.endDate, + interval, + timezone, + }); + + return { + summary: result.metrics, + series: result.series, + interval, + startDate: input.startDate, + endDate: input.endDate, + }; +} diff --git a/packages/db/src/services/pages.service.ts b/packages/db/src/services/pages.service.ts index e3bf54318..663aaa9db 100644 --- a/packages/db/src/services/pages.service.ts +++ b/packages/db/src/services/pages.service.ts @@ -1,5 +1,6 @@ import type { IInterval } from '@openpanel/validation'; -import { ch, TABLE_NAMES } from '../clickhouse/client'; +import sqlstring from 'sqlstring'; +import { ch, TABLE_NAMES, chQuery } from '../clickhouse/client'; import { clix } from '../clickhouse/query-builder'; export interface IGetPagesInput { @@ -172,3 +173,144 @@ export class PagesService { } export const pagesService = new PagesService(ch); + +import { OverviewService } from './overview.service'; +import { getSettingsForProject } from './organization.service'; + +const _overviewServiceForPages = new OverviewService(ch); + +export async function getTopPagesCore(input: { + projectId: string; + startDate: string; + endDate: string; + limit?: number; +}) { + const { timezone } = await getSettingsForProject(input.projectId); + return _overviewServiceForPages.getTopPages({ + projectId: input.projectId, + filters: [], + startDate: input.startDate, + endDate: input.endDate, + timezone, + }); +} + +export async function getEntryExitPagesCore(input: { + projectId: string; + startDate: string; + endDate: string; + mode: 'entry' | 'exit'; +}) { + const { timezone } = await getSettingsForProject(input.projectId); + return _overviewServiceForPages.getTopEntryExit({ + projectId: input.projectId, + filters: [], + startDate: input.startDate, + endDate: input.endDate, + mode: input.mode, + timezone, + }); +} + +export async function getPagePerformanceCore(input: { + projectId: string; + startDate: string; + endDate: string; + search?: string; + sortBy?: 'sessions' | 'pageviews' | 'bounce_rate' | 'avg_duration'; + sortOrder?: 'asc' | 'desc'; + limit?: number; +}) { + const { timezone } = await getSettingsForProject(input.projectId); + const pages = await pagesService.getTopPages({ + projectId: input.projectId, + startDate: input.startDate, + endDate: input.endDate, + timezone, + search: input.search, + limit: 1000, + }); + + const col = input.sortBy ?? 'sessions'; + const dir = input.sortOrder === 'asc' ? 1 : -1; + const sorted = [...pages].sort( + (a, b) => dir * ((a[col] ?? 0) < (b[col] ?? 0) ? -1 : 1), + ); + const results = sorted.slice(0, input.limit ?? 50); + + const annotated = results.map((p) => ({ + ...p, + seo_signals: { + high_bounce: p.bounce_rate > 70, + low_engagement: p.avg_duration < 1, + good_landing_page: p.bounce_rate < 40 && p.avg_duration > 2, + }, + })); + + return { + total_pages: pages.length, + shown: annotated.length, + pages: annotated, + }; +} + +export interface IPageConversionRow { + path: string; + origin: string; + unique_converters: number; + total_visitors: number; + conversion_rate: number; +} + +export async function getPageConversionsCore(input: { + projectId: string; + startDate: string; + endDate: string; + conversionEvent: string; + windowHours?: number; + limit?: number; +}): Promise { + const { projectId, startDate, endDate, conversionEvent, windowHours = 24, limit = 100 } = input; + const sql = ` + WITH + conversion_events AS ( + SELECT profile_id, created_at AS conv_time + FROM events + WHERE project_id = ${sqlstring.escape(projectId)} + AND name = ${sqlstring.escape(conversionEvent)} + AND created_at BETWEEN toDateTime(${sqlstring.escape(startDate)}) AND toDateTime(${sqlstring.escape(endDate)}) + ), + views_before_conversions AS ( + SELECT DISTINCT e.profile_id, e.path, e.origin + FROM events AS e + INNER JOIN conversion_events AS c ON e.profile_id = c.profile_id + WHERE e.project_id = ${sqlstring.escape(projectId)} + AND e.name = 'screen_view' + AND e.path != '' + AND e.created_at BETWEEN toDateTime(${sqlstring.escape(startDate)}) AND toDateTime(${sqlstring.escape(endDate)}) + AND e.created_at < c.conv_time + AND e.created_at >= c.conv_time - INTERVAL ${Number(windowHours)} HOUR + ), + total_visitors AS ( + SELECT path, origin, uniq(session_id) AS visitors + FROM events + WHERE project_id = ${sqlstring.escape(projectId)} + AND name = 'screen_view' + AND path != '' + AND created_at BETWEEN toDateTime(${sqlstring.escape(startDate)}) AND toDateTime(${sqlstring.escape(endDate)}) + GROUP BY path, origin + ) + SELECT + vbc.path, + vbc.origin, + count() AS unique_converters, + any(tv.visitors) AS total_visitors, + round(100.0 * count() / any(tv.visitors), 2) AS conversion_rate + FROM views_before_conversions AS vbc + LEFT JOIN total_visitors AS tv ON vbc.path = tv.path AND vbc.origin = tv.origin + GROUP BY vbc.path, vbc.origin + ORDER BY unique_converters DESC + LIMIT ${Number(limit)} + `; + return chQuery(sql); +} diff --git a/packages/db/src/services/profile.service.ts b/packages/db/src/services/profile.service.ts index bdd677a73..ec4ed02bc 100644 --- a/packages/db/src/services/profile.service.ts +++ b/packages/db/src/services/profile.service.ts @@ -5,13 +5,17 @@ import { uniq } from 'ramda'; import sqlstring from 'sqlstring'; import { profileBuffer } from '../buffers'; import { + ch, chQuery, convertClickhouseDateToJs, formatClickhouseDate, isClickhouseDefaultMinDate, TABLE_NAMES, } from '../clickhouse/client'; +import { clix } from '../clickhouse/query-builder'; import { createSqlBuilder } from '../sql-builder'; +import type { IClickhouseEvent } from './event.service'; +import type { IClickhouseSession } from './session.service'; export interface IProfileMetrics { lastSeen: Date | null; @@ -325,3 +329,168 @@ export function upsertProfile( return profileBuffer.add(profile, isFromEvent); } + +const PROFILE_COLUMNS = + 'id, first_name, last_name, email, avatar, properties, project_id, is_external, created_at, groups'; + +export interface FindProfilesInput { + projectId: string; + name?: string; + email?: string; + country?: string; + city?: string; + device?: string; + browser?: string; + inactiveDays?: number; + minSessions?: number; + performedEvent?: string; + sortBy?: 'created_at'; + sortOrder?: 'asc' | 'desc'; + limit?: number; +} + +export function findProfilesCore( + input: FindProfilesInput +): Promise { + const pid = sqlstring.escape(input.projectId); + const conditions: string[] = [`project_id = ${pid}`]; + + if (input.email) { + conditions.push(`email LIKE ${sqlstring.escape(`%${input.email}%`)}`); + } + if (input.name) { + const escaped = sqlstring.escape(`%${input.name}%`); + conditions.push( + `(first_name LIKE ${escaped} OR last_name LIKE ${escaped})` + ); + } + if (input.country) { + conditions.push( + `properties['country'] = ${sqlstring.escape(input.country)}` + ); + } + if (input.city) { + conditions.push(`properties['city'] = ${sqlstring.escape(input.city)}`); + } + if (input.device) { + conditions.push(`properties['device'] = ${sqlstring.escape(input.device)}`); + } + if (input.browser) { + conditions.push( + `properties['browser'] = ${sqlstring.escape(input.browser)}` + ); + } + + if (input.inactiveDays !== undefined) { + const days = Math.floor(input.inactiveDays); + conditions.push(`id NOT IN ( + SELECT DISTINCT profile_id FROM ${TABLE_NAMES.events} + WHERE project_id = ${pid} + AND profile_id != '' + AND created_at >= now() - INTERVAL ${days} DAY + )`); + } + + if (input.minSessions !== undefined) { + const min = Math.floor(input.minSessions); + conditions.push(`id IN ( + SELECT profile_id FROM ${TABLE_NAMES.sessions} + WHERE project_id = ${pid} + AND sign = 1 + AND profile_id != '' + GROUP BY profile_id + HAVING count() >= ${min} + )`); + } + + if (input.performedEvent) { + conditions.push(`id IN ( + SELECT DISTINCT profile_id FROM ${TABLE_NAMES.events} + WHERE project_id = ${pid} + AND name = ${sqlstring.escape(input.performedEvent)} + )`); + } + + const orderDir = input.sortOrder === 'asc' ? 'ASC' : 'DESC'; + const limit = Math.min(input.limit ?? 20, 100); + + const sql = ` + SELECT ${PROFILE_COLUMNS} + FROM ${TABLE_NAMES.profiles} + WHERE ${conditions.join(' AND ')} + ORDER BY created_at ${orderDir} + LIMIT ${limit} + `; + + return chQuery(sql); +} + +export async function getProfileWithEvents( + projectId: string, + profileId: string, + eventLimit = 10 +): Promise<{ + profile: IClickhouseProfile | null; + recent_events: IClickhouseEvent[]; +}> { + const [profiles, recent_events] = await Promise.all([ + chQuery(` + SELECT ${PROFILE_COLUMNS} + FROM ${TABLE_NAMES.profiles} + WHERE project_id = ${sqlstring.escape(projectId)} AND id = ${sqlstring.escape(profileId)} + LIMIT 1 + `), + clix(ch) + .select([]) + .from(TABLE_NAMES.events) + .where('project_id', '=', projectId) + .where('profile_id', '=', profileId) + .orderBy('created_at', 'DESC') + .limit(eventLimit) + .execute(), + ]); + + return { profile: profiles[0] ?? null, recent_events }; +} + +export function getProfileSessionsCore( + projectId: string, + profileId: string, + limit = 20 +): Promise { + return clix(ch) + .select([]) + .from(TABLE_NAMES.sessions) + .where('project_id', '=', projectId) + .where('profile_id', '=', profileId) + .where('sign', '=', 1) + .orderBy('created_at', 'DESC') + .limit(limit) + .execute(); +} + +export async function getProfileMetricsCore(input: { + projectId: string; + profileId: string; +}) { + const raw = await getProfileMetrics(input.profileId, input.projectId); + if (!raw) { + throw new Error(`Profile not found or has no events: ${input.profileId}`); + } + return { + profileId: input.profileId, + firstSeen: raw.firstSeen, + lastSeen: raw.lastSeen, + sessions: raw.sessions, + screenViews: raw.screenViews, + totalEvents: raw.totalEvents, + conversionEvents: raw.conversionEvents, + uniqueDaysActive: raw.uniqueDaysActive, + avgSessionDurationMin: raw.durationAvg, + p90SessionDurationMin: raw.durationP90, + avgEventsPerSession: raw.avgEventsPerSession, + avgTimeBetweenSessionsSec: raw.avgTimeBetweenSessions, + bounceRate: raw.bounceRate, + revenue: raw.revenue, + }; +} diff --git a/packages/db/src/services/project.service.ts b/packages/db/src/services/project.service.ts index 3144adcf9..f550c1b0b 100644 --- a/packages/db/src/services/project.service.ts +++ b/packages/db/src/services/project.service.ts @@ -1,7 +1,7 @@ import { cacheable } from '@openpanel/redis'; import sqlstring from 'sqlstring'; import { chQuery, TABLE_NAMES } from '../clickhouse/client'; -import type { Prisma, Project } from '../prisma-client'; +import { ClientType, type Prisma, type Project } from '../prisma-client'; import { db } from '../prisma-client'; export type IServiceProject = Project; @@ -29,7 +29,7 @@ export async function getProjectById(id: string) { export const getProjectByIdCached = cacheable(getProjectById, 60 * 60 * 24); export async function getProjectWithClients(id: string) { - const res = await db.$primary().project.findUnique({ + const res = await db.project.findUnique({ where: { id, }, @@ -109,3 +109,87 @@ export const getProjectEventsCount = async (projectId: string) => { ); return res[0]?.count; }; + +/** + * Resolve and validate a projectId for an API client. + * + * - Read clients: returns the fixed projectId from the client (ignores any supplied value). + * - Root clients: validates that the supplied projectId belongs to the client's organization. + * + * Throws if the project is not found or does not belong to the organization. + * Use this as the single source of truth for projectId resolution across the API and MCP. + */ +export async function resolveClientProjectId({ + clientType, + clientProjectId, + organizationId, + inputProjectId, +}: { + clientType: 'read' | 'root'; + clientProjectId: string | null; + organizationId: string; + inputProjectId: string | undefined; +}): Promise { + if (clientType !== 'root') { + if (!clientProjectId) { + throw new Error('Client is not associated with a project'); + } + return clientProjectId; + } + + if (!inputProjectId) { + throw new Error('projectId is required when using a root (organization-level) client'); + } + + const project = await db.project.findFirst({ + where: { id: inputProjectId, organizationId }, + select: { id: true }, + }); + + if (!project) { + throw new Error('Project not found or does not belong to your organization'); + } + + return inputProjectId; +} + +export async function listProjectsCore(input: { + clientType: 'root' | 'read'; + organizationId: string; + projectId: string | null; +}) { + if (input.clientType === 'root') { + const projects = await db.project.findMany({ + where: { organizationId: input.organizationId }, + orderBy: { eventsCount: 'desc' }, + select: { + id: true, + name: true, + organizationId: true, + eventsCount: true, + domain: true, + types: true, + }, + }); + return { clientType: 'root', projects }; + } + + const project = input.projectId + ? await db.project.findUnique({ + where: { id: input.projectId }, + select: { + id: true, + name: true, + organizationId: true, + eventsCount: true, + domain: true, + types: true, + }, + }) + : null; + + return { + clientType: 'read', + projects: project ? [project] : [], + }; +} diff --git a/packages/db/src/services/reports.service.ts b/packages/db/src/services/reports.service.ts index ff205dc31..2f5d6d8ca 100644 --- a/packages/db/src/services/reports.service.ts +++ b/packages/db/src/services/reports.service.ts @@ -123,3 +123,78 @@ export async function getReportById(id: string) { return transformReport(report); } + +import { AggregateChartEngine, ChartEngine } from '../engine'; +import { getDashboardById } from './dashboard.service'; +import { getChartStartEndDate } from './date.service'; +import { funnelService } from './funnel.service'; +import { getSettingsForProject } from './organization.service'; + +export async function listReportsCore(input: { + projectId: string; + dashboardId: string; + organizationId: string; +}) { + const dashboard = await getDashboardById(input.dashboardId, input.projectId); + if (!dashboard) { + return []; + } + const reports = await getReportsByDashboardId(input.dashboardId); + return reports.map((r) => ({ + id: r.id, + name: r.name, + chartType: r.chartType, + range: r.range, + interval: r.interval, + metric: r.metric, + series: r.series.map((s) => + s.type === 'formula' + ? { type: 'formula', id: s.id, formula: s.formula } + : { type: 'event', id: s.id, name: s.name, displayName: s.displayName, segment: s.segment }, + ), + breakdowns: r.breakdowns, + })); +} + +export async function getReportDataCore(input: { + projectId: string; + reportId: string; + organizationId: string; +}) { + const rawReport = await db.report.findUnique({ + where: { id: input.reportId, projectId: input.projectId }, + include: { layout: true }, + }); + + if (!rawReport) { + throw new Error(`Report not found: ${input.reportId}`); + } + + const report = transformReport(rawReport); + const { timezone } = await getSettingsForProject(input.projectId); + const { startDate, endDate } = getChartStartEndDate(report, timezone); + const chartInput = { ...report, startDate, endDate, timezone }; + + const meta = { + id: report.id, + name: report.name, + chartType: report.chartType, + range: report.range, + interval: report.interval, + startDate, + endDate, + }; + + if (report.chartType === 'funnel') { + const result = await funnelService.getFunnel(chartInput); + return { ...meta, data: result }; + } + + if (report.chartType === 'metric') { + const result = await AggregateChartEngine.execute(chartInput); + return { ...meta, data: result }; + } + + const result = await ChartEngine.execute(chartInput); + return { ...meta, data: result }; +} diff --git a/packages/db/src/services/retention.service.ts b/packages/db/src/services/retention.service.ts index d164b2c2e..b337ee8fe 100644 --- a/packages/db/src/services/retention.service.ts +++ b/packages/db/src/services/retention.service.ts @@ -156,3 +156,63 @@ export function getRetentionLastSeenSeries({ users: number; }>(sql); } + +export async function getRollingActiveUsersCore(input: { + projectId: string; + days: number; +}) { + const data = await getRollingActiveUsers(input); + return { + window_days: input.days, + label: + input.days === 1 + ? 'DAU' + : input.days === 7 + ? 'WAU' + : input.days === 30 + ? 'MAU' + : `${input.days}d active`, + series: data, + }; +} + +export async function getWeeklyRetentionSeriesCore(projectId: string) { + return getRetentionSeries({ projectId }); +} + +export async function getRetentionCohortCore(projectId: string) { + return getRetentionCohortTable({ projectId }); +} + +export async function getEngagementCore(projectId: string) { + const raw = await getRetentionLastSeenSeries({ projectId }); + + let active_0_7 = 0; + let active_8_14 = 0; + let active_15_30 = 0; + let active_31_60 = 0; + let churned_60_plus = 0; + + for (const row of raw) { + if (row.days <= 7) active_0_7 += row.users; + else if (row.days <= 14) active_8_14 += row.users; + else if (row.days <= 30) active_15_30 += row.users; + else if (row.days <= 60) active_31_60 += row.users; + else churned_60_plus += row.users; + } + + const total = + active_0_7 + active_8_14 + active_15_30 + active_31_60 + churned_60_plus; + + return { + summary: { + total_identified_users: total, + active_last_7_days: active_0_7, + active_8_to_14_days: active_8_14, + active_15_to_30_days: active_15_30, + inactive_31_to_60_days: active_31_60, + churned_60_plus_days: churned_60_plus, + }, + distribution: raw, + }; +} diff --git a/packages/db/src/services/sankey.service.ts b/packages/db/src/services/sankey.service.ts index 64417c446..af1844612 100644 --- a/packages/db/src/services/sankey.service.ts +++ b/packages/db/src/services/sankey.service.ts @@ -781,3 +781,56 @@ export class SankeyService { } export const sankeyService = new SankeyService(ch); + +import { getSettingsForProject } from './organization.service'; + +function toChartEvent(name: string) { + return { + id: name, + name, + displayName: name, + type: 'event' as const, + segment: 'event' as const, + filters: [], + }; +} + +export async function getUserFlowCore(input: { + projectId: string; + startDate: string; + endDate: string; + startEvent: string; + endEvent?: string; + mode: 'after' | 'before' | 'between'; + steps?: number; + exclude?: string[]; + include?: string[]; +}) { + if (input.mode === 'between' && !input.endEvent) { + throw new Error('endEvent is required when mode is "between"'); + } + + const { timezone } = await getSettingsForProject(input.projectId); + const result = await sankeyService.getSankey({ + projectId: input.projectId, + startDate: input.startDate, + endDate: input.endDate, + steps: input.steps ?? 5, + mode: input.mode, + startEvent: toChartEvent(input.startEvent), + endEvent: input.endEvent ? toChartEvent(input.endEvent) : undefined, + exclude: input.exclude ?? [], + include: input.include, + timezone, + }); + + return { + mode: input.mode, + startEvent: input.startEvent, + endEvent: input.endEvent, + node_count: result.nodes.length, + link_count: result.links.length, + nodes: result.nodes, + links: result.links, + }; +} diff --git a/packages/db/src/services/session.service.ts b/packages/db/src/services/session.service.ts index 636cf452d..05e0c5899 100644 --- a/packages/db/src/services/session.service.ts +++ b/packages/db/src/services/session.service.ts @@ -462,3 +462,76 @@ class SessionService { } export const sessionService = new SessionService(ch); + +import { resolveDateRange } from './date.service'; + +export interface QuerySessionsInput { + projectId: string; + startDate?: string; + endDate?: string; + country?: string; + city?: string; + device?: string; + browser?: string; + os?: string; + referrer?: string; + referrerName?: string; + referrerType?: string; + profileId?: string; + limit?: number; +} + +export async function querySessionsCore( + input: QuerySessionsInput, +): Promise { + const builder = clix(ch) + .select([]) + .from(TABLE_NAMES.sessions) + .where('project_id', '=', input.projectId) + .where('sign', '=', 1); + + if (input.profileId) { + builder.where('profile_id', '=', input.profileId); + } + + if (input.referrer) { + builder.where('referrer', '=', input.referrer); + } + + if (input.referrerName) { + builder.where('referrer_name', '=', input.referrerName); + } + + if (input.referrerType) { + builder.where('referrer_type', '=', input.referrerType); + } + + if (input.device) { + builder.where('device', '=', input.device); + } + + if (input.country) { + builder.where('country', '=', input.country); + } + + if (input.city) { + builder.where('city', '=', input.city); + } + + if (input.os) { + builder.where('os', '=', input.os); + } + + if (input.browser) { + builder.where('browser', '=', input.browser); + } + + const { startDate: start, endDate: end } = resolveDateRange(input.startDate, input.endDate); + + builder.where('created_at', 'BETWEEN', [ + clix.datetime(start), + clix.datetime(end), + ]); + + return builder.limit(input.limit ?? 20).execute(); +} diff --git a/packages/importer/src/providers/mixpanel.ts b/packages/importer/src/providers/mixpanel.ts index 43cf6eb82..0b4a02b02 100644 --- a/packages/importer/src/providers/mixpanel.ts +++ b/packages/importer/src/providers/mixpanel.ts @@ -15,7 +15,7 @@ import { BaseImportProvider } from '../base-provider'; export const zMixpanelRawEvent = z.object({ event: z.string(), - properties: z.record(z.unknown()), + properties: z.record(z.string(), z.unknown()), }); export type MixpanelRawEvent = z.infer; @@ -23,7 +23,7 @@ export type MixpanelRawEvent = z.infer; /** Engage API profile: https://docs.mixpanel.com/docs/export-methods#exporting-profiles */ export const zMixpanelRawProfile = z.object({ $distinct_id: z.union([z.string(), z.number()]), - $properties: z.record(z.unknown()).optional().default({}), + $properties: z.record(z.string(), z.unknown()).optional().default({}), }); export type MixpanelRawProfile = z.infer; diff --git a/packages/logger/index.ts b/packages/logger/index.ts index 938e76656..8c68a2382 100644 --- a/packages/logger/index.ts +++ b/packages/logger/index.ts @@ -64,6 +64,14 @@ export function createLogger({ name }: { name: string }): ILogger { 'apiKey', ]; + const sensitiveUrlParamPattern = new RegExp( + `([?&])(${sensitiveKeys.join('|')})=([^&]*)`, + 'gi', + ); + + const redactUrl = (value: string): string => + value.replace(sensitiveUrlParamPattern, '$1$2=[REDACTED]'); + const redactSensitiveInfo = winston.format((info) => { const redactObject = (obj: any): any => { if (!obj || typeof obj !== 'object') { @@ -74,6 +82,8 @@ export function createLogger({ name }: { name: string }): ILogger { const lowerKey = key.toLowerCase(); if (sensitiveKeys.some((k) => lowerKey.includes(k))) { acc[key] = '[REDACTED]'; + } else if (typeof obj[key] === 'string') { + acc[key] = redactUrl(obj[key]); } else if (typeof obj[key] === 'object') { if (obj[key] instanceof Date) { acc[key] = obj[key].toISOString(); diff --git a/packages/mcp/index.ts b/packages/mcp/index.ts new file mode 100644 index 000000000..577d576ef --- /dev/null +++ b/packages/mcp/index.ts @@ -0,0 +1,5 @@ +export { createMcpServer } from './src/server'; +export { SessionManager } from './src/session-manager'; +export { authenticateToken, McpAuthError, extractToken } from './src/auth'; +export { handleMcpGet, handleMcpPost } from './src/handler'; +export type { McpAuthContext } from './src/auth'; diff --git a/packages/mcp/package.json b/packages/mcp/package.json new file mode 100644 index 000000000..442e002ab --- /dev/null +++ b/packages/mcp/package.json @@ -0,0 +1,29 @@ +{ + "name": "@openpanel/mcp", + "version": "0.0.1", + "type": "module", + "main": "index.ts", + "exports": { + ".": "./index.ts" + }, + "scripts": { + "typecheck": "tsc --noEmit", + "test": "vitest", + "test:run": "vitest run" + }, + "dependencies": { + "@modelcontextprotocol/sdk": "^1.12.0", + "@openpanel/common": "workspace:*", + "@openpanel/db": "workspace:*", + "@openpanel/logger": "workspace:*", + "@openpanel/redis": "workspace:*", + "@openpanel/validation": "workspace:*", + "zod": "catalog:" + }, + "devDependencies": { + "@openpanel/tsconfig": "workspace:*", + "@types/node": "catalog:", + "typescript": "catalog:", + "vitest": "^1.0.0" + } +} diff --git a/packages/mcp/src/auth.test.ts b/packages/mcp/src/auth.test.ts new file mode 100644 index 000000000..775e26ed1 --- /dev/null +++ b/packages/mcp/src/auth.test.ts @@ -0,0 +1,159 @@ +import { describe, expect, it, vi, beforeEach } from 'vitest'; + +const mockGetClientByIdCached = vi.hoisted(() => vi.fn()); +const mockVerifyPassword = vi.hoisted(() => vi.fn()); +const mockGetCache = vi.hoisted(() => vi.fn()); + +vi.mock('@openpanel/db', () => ({ + ClientType: { write: 'write', read: 'read', root: 'root' }, + getClientByIdCached: mockGetClientByIdCached, +})); + +vi.mock('@openpanel/common/server', () => ({ + verifyPassword: mockVerifyPassword, +})); + +vi.mock('@openpanel/redis', () => ({ + getCache: mockGetCache, +})); + +import { McpAuthError, authenticateToken, extractToken } from './auth'; + +const VALID_CLIENT_ID = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890'; +const VALID_SECRET = 'mysecret'; +const VALID_TOKEN = Buffer.from(`${VALID_CLIENT_ID}:${VALID_SECRET}`).toString('base64'); + +const baseClient = { + id: VALID_CLIENT_ID, + secret: 'hashed_secret', + type: 'read', + projectId: 'proj-123', + organizationId: 'org-456', +}; + +beforeEach(() => { + vi.clearAllMocks(); + // Default: cache calls through to the fn + mockGetCache.mockImplementation((_key: string, _ttl: number, fn: () => Promise) => fn()); + mockVerifyPassword.mockResolvedValue(true); +}); + +// --------------------------------------------------------------------------- +// extractToken +// --------------------------------------------------------------------------- + +describe('extractToken', () => { + it('returns token from ?token= query param', () => { + expect(extractToken({ token: 'abc' }, undefined)).toBe('abc'); + }); + + it('returns token from Authorization Bearer header', () => { + expect(extractToken({}, 'Bearer mytoken')).toBe('mytoken'); + }); + + it('prefers query param over header', () => { + expect(extractToken({ token: 'from-query' }, 'Bearer from-header')).toBe('from-query'); + }); + + it('returns undefined when neither is present', () => { + expect(extractToken({}, undefined)).toBeUndefined(); + }); + + it('returns undefined for non-Bearer auth header', () => { + expect(extractToken({}, 'Basic abc123')).toBeUndefined(); + }); +}); + +// --------------------------------------------------------------------------- +// authenticateToken +// --------------------------------------------------------------------------- + +describe('authenticateToken', () => { + it('throws McpAuthError when token is missing', async () => { + await expect(authenticateToken(undefined)).rejects.toThrow(McpAuthError); + await expect(authenticateToken(undefined)).rejects.toThrow('Missing authentication token'); + }); + + it('throws McpAuthError for non-base64 token', async () => { + // Buffer.from with invalid base64 doesn't throw — but the decoded result won't have a colon + await expect(authenticateToken('!!!invalid!!!')).rejects.toThrow(McpAuthError); + }); + + it('throws McpAuthError when token has no colon separator', async () => { + const token = Buffer.from('nodivider').toString('base64'); + await expect(authenticateToken(token)).rejects.toThrow('Invalid token format'); + }); + + it('throws McpAuthError when clientId is not a UUID', async () => { + const token = Buffer.from('not-a-uuid:secret').toString('base64'); + await expect(authenticateToken(token)).rejects.toThrow('Invalid client ID format'); + }); + + it('throws McpAuthError when clientSecret is empty', async () => { + const token = Buffer.from(`${VALID_CLIENT_ID}:`).toString('base64'); + await expect(authenticateToken(token)).rejects.toThrow('Client secret is required'); + }); + + it('throws McpAuthError when client is not found', async () => { + mockGetClientByIdCached.mockResolvedValue(null); + await expect(authenticateToken(VALID_TOKEN)).rejects.toThrow('Invalid credentials'); + }); + + it('throws McpAuthError when client has no stored secret', async () => { + mockGetClientByIdCached.mockResolvedValue({ ...baseClient, secret: null }); + await expect(authenticateToken(VALID_TOKEN)).rejects.toThrow('no secret'); + }); + + it('throws McpAuthError for write-only clients', async () => { + mockGetClientByIdCached.mockResolvedValue({ ...baseClient, type: 'write' }); + await expect(authenticateToken(VALID_TOKEN)).rejects.toThrow('Write-only clients'); + }); + + it('throws McpAuthError when password verification fails', async () => { + mockGetClientByIdCached.mockResolvedValue(baseClient); + mockVerifyPassword.mockResolvedValue(false); + await expect(authenticateToken(VALID_TOKEN)).rejects.toThrow('Invalid credentials'); + }); + + it('returns read client context on success', async () => { + mockGetClientByIdCached.mockResolvedValue(baseClient); + const ctx = await authenticateToken(VALID_TOKEN); + expect(ctx).toEqual({ + projectId: 'proj-123', + organizationId: 'org-456', + clientType: 'read', + }); + }); + + it('returns root client context with null projectId', async () => { + mockGetClientByIdCached.mockResolvedValue({ ...baseClient, type: 'root', projectId: null }); + const ctx = await authenticateToken(VALID_TOKEN); + expect(ctx).toEqual({ + projectId: null, + organizationId: 'org-456', + clientType: 'root', + }); + }); + + it('uses cache for password verification', async () => { + mockGetClientByIdCached.mockResolvedValue(baseClient); + // Simulate cache returning true without calling verifyPassword + mockGetCache.mockResolvedValue(true); + const ctx = await authenticateToken(VALID_TOKEN); + expect(ctx.clientType).toBe('read'); + expect(mockVerifyPassword).not.toHaveBeenCalled(); + }); + + it('cache key uses SHA-256 hash, not raw secret', async () => { + mockGetClientByIdCached.mockResolvedValue(baseClient); + let capturedKey = ''; + mockGetCache.mockImplementation((key: string, _ttl: number, fn: () => Promise) => { + capturedKey = key; + return fn(); + }); + await authenticateToken(VALID_TOKEN); + expect(capturedKey).toContain(`mcp:auth:${VALID_CLIENT_ID}:`); + expect(capturedKey).not.toContain(VALID_SECRET); + expect(capturedKey).not.toContain(Buffer.from(VALID_SECRET).toString('base64')); + }); +}); diff --git a/packages/mcp/src/auth.ts b/packages/mcp/src/auth.ts new file mode 100644 index 000000000..e16273c38 --- /dev/null +++ b/packages/mcp/src/auth.ts @@ -0,0 +1,124 @@ +import { createHash } from 'node:crypto'; +import { verifyPassword } from '@openpanel/common/server'; +import { ClientType, getClientByIdCached } from '@openpanel/db'; +import { getCache } from '@openpanel/redis'; + +export interface McpAuthContext { + /** + * Fixed project ID for read clients. + * null for root clients — they can query any project in their organization. + */ + projectId: string | null; + organizationId: string; + clientType: 'read' | 'root'; +} + +export class McpAuthError extends Error { + constructor(message: string) { + super(message); + this.name = 'McpAuthError'; + } +} + +/** + * Authenticate an MCP token. + * + * Token format: base64(clientId:clientSecret) + * Accepted via ?token= query param or Authorization: Bearer header. + * + * - write-only clients are rejected (no read access) + * - read clients get a fixed projectId + * - root clients get null projectId + organizationId (multi-project access) + */ +export async function authenticateToken( + token: string | undefined, +): Promise { + if (!token) { + throw new McpAuthError('Missing authentication token'); + } + + let decoded: string; + try { + decoded = Buffer.from(token, 'base64').toString('utf-8'); + } catch { + throw new McpAuthError('Invalid token encoding'); + } + + const colonIndex = decoded.indexOf(':'); + if (colonIndex === -1) { + throw new McpAuthError( + 'Invalid token format — expected base64(clientId:clientSecret)', + ); + } + + const clientId = decoded.slice(0, colonIndex); + const clientSecret = decoded.slice(colonIndex + 1); + + if ( + !/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/.test( + clientId, + ) + ) { + throw new McpAuthError('Invalid client ID format'); + } + + if (!clientSecret) { + throw new McpAuthError('Client secret is required'); + } + + const client = await getClientByIdCached(clientId); + if (!client) { + throw new McpAuthError('Invalid credentials'); + } + + if (!client.secret) { + throw new McpAuthError( + 'This client has no secret — only clients with a secret can use MCP', + ); + } + + if (client.type === ClientType.write) { + throw new McpAuthError( + 'Write-only clients cannot use MCP — use a read or root client', + ); + } + + const secretHash = createHash('sha256').update(clientSecret).digest('hex').slice(0, 16); + const cacheKey = `mcp:auth:${clientId}:${secretHash}`; + const isVerified = await getCache( + cacheKey, + 60 * 5, + async () => await verifyPassword(clientSecret, client.secret!), + true, + ); + + if (!isVerified) { + throw new McpAuthError('Invalid credentials'); + } + + const isRoot = client.type === ClientType.root; + + return { + projectId: isRoot ? null : (client.projectId ?? null), + organizationId: client.organizationId, + clientType: isRoot ? 'root' : 'read', + }; +} + +/** + * Extract the MCP token from a request. + * Checks ?token= query param first, then Authorization: Bearer header. + */ +export function extractToken( + query: Record, + authHeader: string | undefined, +): string | undefined { + if (typeof query['token'] === 'string') { + return query['token']; + } + if (authHeader?.startsWith('Bearer ')) { + return authHeader.slice(7); + } + return undefined; +} + diff --git a/packages/mcp/src/handler.ts b/packages/mcp/src/handler.ts new file mode 100644 index 000000000..9fcff9074 --- /dev/null +++ b/packages/mcp/src/handler.ts @@ -0,0 +1,141 @@ +import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'; +import type { IncomingMessage, ServerResponse } from 'node:http'; +import { createLogger } from '@openpanel/logger'; +import { McpAuthError, authenticateToken, extractToken } from './auth'; +import { createMcpServer } from './server'; +import type { SessionManager } from './session-manager'; + +const logger = createLogger({ name: 'mcp:handler' }); + +/** + * Handle a POST /mcp request. + * + * - If Mcp-Session-Id is present and the session is local: route to existing transport. + * - If Mcp-Session-Id is present but not local: check Redis — if context found, + * recreate server+transport on this instance (cross-instance migration). + * - Otherwise authenticate via token and create a new session. + * + * Writes directly to `res` (caller must have hijacked the Fastify reply). + */ +export async function handleMcpPost( + sessionManager: SessionManager, + req: IncomingMessage, + res: ServerResponse, + body: unknown, + query: Record, +): Promise { + const sessionId = req.headers['mcp-session-id'] as string | undefined; + + if (sessionId) { + // Fast path: session is already on this instance + const local = sessionManager.getLocal(sessionId); + if (local) { + await sessionManager.touchContext(sessionId); + await local.transport.handleRequest(req, res, body); + return; + } + + // Slow path: session exists on another instance — retrieve context from Redis + const context = await sessionManager.getContext(sessionId); + if (!context) { + res.writeHead(404, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Session not found or expired' })); + return; + } + + logger.info('MCP session migrated to this instance', { sessionId }); + await attachSession(sessionManager, sessionId, context, req, res, body); + return; + } + + // New session — authenticate first + const token = extractToken(query, req.headers.authorization); + + try { + const context = await authenticateToken(token); + await attachSession(sessionManager, null, context, req, res, body); + } catch (err) { + if (err instanceof McpAuthError) { + res.writeHead(401, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: err.message })); + } else { + logger.error('MCP session creation error', { err }); + res.writeHead(500, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Internal server error' })); + } + } +} + +/** + * Create (or recreate) a server+transport for the given context, handle the + * request, and register the session locally + in Redis. + * + * @param fixedSessionId When migrating an existing session, pass its ID so we + * reuse the same session ID rather than generating a new one. + */ +async function attachSession( + sessionManager: SessionManager, + fixedSessionId: string | null, + context: Parameters[0], + req: IncomingMessage, + res: ServerResponse, + body: unknown, +): Promise { + const server = createMcpServer(context); + + const transport = new StreamableHTTPServerTransport({ + sessionIdGenerator: fixedSessionId + ? () => fixedSessionId + : () => sessionManager.generateId(), + onsessioninitialized: async (id: string) => { + sessionManager.setLocal(id, { server, transport }); + await sessionManager.setContext(id, context); + logger.info('MCP session initialized', { + sessionId: id, + clientType: context.clientType, + organizationId: context.organizationId, + projectId: context.projectId, + }); + }, + }); + + await server.connect(transport); + await transport.handleRequest(req, res, body); +} + +/** + * Handle a GET /mcp request (SSE stream for an existing session). + * + * SSE streams are tied to the instance they started on. If the session is not + * local (i.e., it was started on a different instance), return 404 so the + * client reconnects and establishes a fresh session on this instance. + */ +export async function handleMcpGet( + sessionManager: SessionManager, + req: IncomingMessage, + res: ServerResponse, +): Promise { + const sessionId = req.headers['mcp-session-id'] as string | undefined; + if (!sessionId) { + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Mcp-Session-Id header is required' })); + return; + } + + const session = sessionManager.getLocal(sessionId); + if (!session) { + res.writeHead(404, { 'Content-Type': 'application/json' }); + res.end( + JSON.stringify({ + error: 'Session not found on this instance — reconnect to start a new session', + }), + ); + return; + } + + try { + await session.transport.handleRequest(req, res); + } catch (err) { + logger.error('MCP SSE stream error', { err, sessionId }); + } +} diff --git a/packages/mcp/src/integration/tools.test.ts b/packages/mcp/src/integration/tools.test.ts new file mode 100644 index 000000000..7af6927e0 --- /dev/null +++ b/packages/mcp/src/integration/tools.test.ts @@ -0,0 +1,691 @@ +/** + * Integration tests for MCP tools against a real ClickHouse instance. + * + * CLICKHOUSE_URL is pinned to http://localhost:8123 in vitest.shared.ts — + * always targets local Docker, never production. Start with: pnpm dock:up + * + * Fixture data (inserted by globalSetup in setup.ts): + * Alice — 3 events: session_start, page_view(/home), session_end — 2 days ago — country: US, browser: Chrome + * Bob — 0 events (inactive) — profile created 90 days ago — country: SE + * Charlie — 5 events: session_start, screen_view, page_view(/shop), purchase, session_end — 5 days ago — browser: Firefox + * 2 sessions (sess-charlie-1 5d ago, sess-charlie-2 10d ago) + * + * For tools that also call getSettingsForProject (Postgres), we mock only + * that function — all ClickHouse queries still run for real. + */ + +import { describe, expect, it, vi } from 'vitest'; + +// Bypass Redis caching — prevents ioredis TCP connections that hang the process +vi.mock('@openpanel/redis', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + getCache: async (_key: string, _ttl: number, fn: () => Promise) => + fn(), + }; +}); + +import { FIXTURE, TEST_PROJECT_ID } from '../../../../test/global-setup'; +import { registerActiveUserTools } from '../tools/analytics/active-users'; +import { registerEngagementTools } from '../tools/analytics/engagement'; +import { registerEventNameTools } from '../tools/analytics/event-names'; +import { registerEventTools } from '../tools/analytics/events'; +import { registerFunnelTools } from '../tools/analytics/funnel'; +import { registerGroupTools } from '../tools/analytics/groups'; +import { registerOverviewTools } from '../tools/analytics/overview'; +import { registerPagePerformanceTools } from '../tools/analytics/page-performance'; +import { registerPageTools } from '../tools/analytics/pages'; +import { registerProfileMetricTools } from '../tools/analytics/profile-metrics'; +import { registerProfileTools } from '../tools/analytics/profiles'; +import { registerPropertyValueTools } from '../tools/analytics/property-values'; +import { registerRetentionTools } from '../tools/analytics/retention'; +import { registerSessionTools } from '../tools/analytics/sessions'; +import { registerTrafficTools } from '../tools/analytics/traffic'; +import { registerUserFlowTools } from '../tools/analytics/user-flow'; + +const CTX = { + projectId: TEST_PROJECT_ID, + organizationId: 'org-test', + clientType: 'read' as const, +}; + +function makeServer() { + const handlers = new Map Promise>(); + return { + tool: ( + name: string, + _desc: string, + _schema: unknown, + fn: (input: unknown) => Promise + ) => { + handlers.set(name, fn); + }, + invoke: async (name: string, input: unknown) => { + const handler = handlers.get(name); + if (!handler) { + throw new Error(`Tool not registered: ${name}`); + } + const result = (await handler(input)) as any; + const text = result.content[0].text as string; + if (result.isError) { + return { error: text.replace(/^Error:\s*/, '') }; + } + return JSON.parse(text); + }, + }; +} + +// ─── Discovery ──────────────────────────────────────────────────────────────── + +describe('list_event_names', () => { + it('returns { event_names: string[] }', async () => { + const server = makeServer(); + registerEventNameTools(server as any, CTX); + const res = await server.invoke('list_event_names', { + projectId: TEST_PROJECT_ID, + }); + expect(Array.isArray(res.event_names)).toBe(true); + }); +}); + +describe('list_event_properties', () => { + it('returns { properties: array }', async () => { + const server = makeServer(); + registerPropertyValueTools(server as any, CTX); + const res = await server.invoke('list_event_properties', { + projectId: TEST_PROJECT_ID, + }); + expect(Array.isArray(res.properties)).toBe(true); + }); +}); + +describe('get_event_property_values', () => { + it('returns { event, property, values }', async () => { + const server = makeServer(); + registerPropertyValueTools(server as any, CTX); + const res = await server.invoke('get_event_property_values', { + projectId: TEST_PROJECT_ID, + eventName: 'purchase', + propertyKey: 'plan', + }); + expect(res.event).toBe('purchase'); + expect(res.property).toBe('plan'); + expect(Array.isArray(res.values)).toBe(true); + }); +}); + +// ─── Raw data ───────────────────────────────────────────────────────────────── + +describe('query_events', () => { + it('returns all 8 fixture events', async () => { + const server = makeServer(); + registerEventTools(server as any, CTX); + const res = await server.invoke('query_events', { + projectId: TEST_PROJECT_ID, + startDate: '2000-01-01', + endDate: '2099-01-01', + }); + expect(res.length).toBe(8); + }); + + it('filters by eventName — only returns purchase events', async () => { + const server = makeServer(); + registerEventTools(server as any, CTX); + const res = await server.invoke('query_events', { + projectId: TEST_PROJECT_ID, + startDate: '2000-01-01', + endDate: '2099-01-01', + eventNames: ['purchase'], + }); + expect(res.length).toBe(1); + expect(res[0].name).toBe('purchase'); + expect(res[0].profile_id).toBe(FIXTURE.profiles.charlie); + expect(res[0].revenue).toBe(9900); + }); + + it('filters by profileId — returns only alice events', async () => { + const server = makeServer(); + registerEventTools(server as any, CTX); + const res = await server.invoke('query_events', { + projectId: TEST_PROJECT_ID, + startDate: '2000-01-01', + endDate: '2099-01-01', + profileId: FIXTURE.profiles.alice, + }); + expect(res.length).toBe(3); + expect(res.every((e: any) => e.profile_id === FIXTURE.profiles.alice)).toBe( + true + ); + }); + + it('filters by browser', async () => { + const server = makeServer(); + registerEventTools(server as any, CTX); + const res = await server.invoke('query_events', { + projectId: TEST_PROJECT_ID, + startDate: '2000-01-01', + endDate: '2099-01-01', + browser: 'Firefox', + }); + expect(res.length).toBe(5); + expect(res.every((e: any) => e.browser === 'Firefox')).toBe(true); + }); + + // Note: read-context resolveProjectId ignores the input projectId and always + // uses CTX.projectId — so there is no way to query another project's data. +}); + +describe('query_sessions', () => { + it('returns all 3 fixture sessions', async () => { + const server = makeServer(); + registerSessionTools(server as any, CTX); + const res = await server.invoke('query_sessions', { + projectId: TEST_PROJECT_ID, + startDate: '2000-01-01', + endDate: '2099-01-01', + }); + expect(res.length).toBe(3); + }); + + it('filters by profileId — charlie has 2 sessions', async () => { + const server = makeServer(); + registerSessionTools(server as any, CTX); + const res = await server.invoke('query_sessions', { + projectId: TEST_PROJECT_ID, + startDate: '2000-01-01', + endDate: '2099-01-01', + profileId: FIXTURE.profiles.charlie, + }); + expect(res.length).toBe(2); + expect( + res.every((s: any) => s.profile_id === FIXTURE.profiles.charlie) + ).toBe(true); + }); + + it('filters by browser', async () => { + const server = makeServer(); + registerSessionTools(server as any, CTX); + const res = await server.invoke('query_sessions', { + projectId: TEST_PROJECT_ID, + startDate: '2000-01-01', + endDate: '2099-01-01', + browser: 'Chrome', + }); + expect(res.length).toBe(1); + expect(res[0].profile_id).toBe(FIXTURE.profiles.alice); + }); +}); + +// ─── Profile tools ──────────────────────────────────────────────────────────── + +describe('find_profiles', () => { + it('returns all 3 fixture profiles', async () => { + const server = makeServer(); + registerProfileTools(server as any, CTX); + const res = await server.invoke('find_profiles', { + projectId: TEST_PROJECT_ID, + }); + expect(res.length).toBe(3); + }); + + it('filters by email partial match', async () => { + const server = makeServer(); + registerProfileTools(server as any, CTX); + const res = await server.invoke('find_profiles', { + projectId: TEST_PROJECT_ID, + email: 'alice@', + }); + expect(res.length).toBe(1); + expect(res[0].email).toBe('alice@example.com'); + }); + + it('filters by name — matches first_name and last_name', async () => { + const server = makeServer(); + registerProfileTools(server as any, CTX); + const byFirst = await server.invoke('find_profiles', { + projectId: TEST_PROJECT_ID, + name: 'Charlie', + }); + expect(byFirst.length).toBe(1); + expect(byFirst[0].first_name).toBe('Charlie'); + + const byLast = await server.invoke('find_profiles', { + projectId: TEST_PROJECT_ID, + name: 'Smith', + }); + expect(byLast.length).toBe(1); + expect(byLast[0].last_name).toBe('Smith'); + }); + + it('filters by country property', async () => { + const server = makeServer(); + registerProfileTools(server as any, CTX); + const res = await server.invoke('find_profiles', { + projectId: TEST_PROJECT_ID, + country: 'SE', + }); + expect(res.length).toBe(1); + expect(res[0].email).toBe('bob@example.com'); + }); + + it('inactiveDays=7 excludes alice (active 2 days ago) but includes bob (no events)', async () => { + const server = makeServer(); + registerProfileTools(server as any, CTX); + const res = await server.invoke('find_profiles', { + projectId: TEST_PROJECT_ID, + inactiveDays: 7, + }); + const emails = res.map((p: any) => p.email); + expect(emails).not.toContain('alice@example.com'); + expect(emails).not.toContain('charlie@example.com'); + expect(emails).toContain('bob@example.com'); + }); + + it('minSessions=2 returns only charlie (has 2 sessions)', async () => { + const server = makeServer(); + registerProfileTools(server as any, CTX); + const res = await server.invoke('find_profiles', { + projectId: TEST_PROJECT_ID, + minSessions: 2, + }); + expect(res.length).toBe(1); + expect(res[0].first_name).toBe('Charlie'); + }); + + it('performedEvent=purchase returns only charlie', async () => { + const server = makeServer(); + registerProfileTools(server as any, CTX); + const res = await server.invoke('find_profiles', { + projectId: TEST_PROJECT_ID, + performedEvent: 'purchase', + }); + expect(res.length).toBe(1); + expect(res[0].first_name).toBe('Charlie'); + }); + + // Note: read-context resolveProjectId ignores the input projectId and always + // uses CTX.projectId — so there is no way to query another project's data. +}); + +describe('get_profile', () => { + it('returns correct profile and events for charlie', async () => { + const server = makeServer(); + registerProfileTools(server as any, CTX); + const res = await server.invoke('get_profile', { + projectId: TEST_PROJECT_ID, + profileId: FIXTURE.profiles.charlie, + }); + expect(res.profile.first_name).toBe('Charlie'); + expect(res.profile.email).toBe('charlie@example.com'); + expect(Array.isArray(res.recent_events)).toBe(true); + expect(res.recent_events.length).toBe(5); // all charlie events + }); +}); + +describe('get_profile_sessions', () => { + it('returns 2 sessions for charlie', async () => { + const server = makeServer(); + registerProfileTools(server as any, CTX); + const res = await server.invoke('get_profile_sessions', { + projectId: TEST_PROJECT_ID, + profileId: FIXTURE.profiles.charlie, + }); + expect(res.sessions.length).toBe(2); + expect( + res.sessions.every((s: any) => s.profile_id === FIXTURE.profiles.charlie) + ).toBe(true); + }); +}); + +describe('get_profile_metrics', () => { + it('returns exact metrics for charlie', async () => { + const server = makeServer(); + registerProfileMetricTools(server as any, CTX); + const res = await server.invoke('get_profile_metrics', { + projectId: TEST_PROJECT_ID, + profileId: FIXTURE.profiles.charlie, + }); + // No error — bug was getProfileMetrics returns single object, not array + expect(res.error).toBeUndefined(); + expect(res.profileId).toBe(FIXTURE.profiles.charlie); + expect(res.sessions).toBe(1); // 1 session_start event + expect(res.screenViews).toBe(1); // 1 screen_view event + expect(res.totalEvents).toBe(5); // session_start + screen_view + page_view + purchase + session_end + expect(res.conversionEvents).toBe(2); // page_view + purchase (excludes session_start/screen_view/session_end) + expect(res.uniqueDaysActive).toBe(1); // all on the same day + expect(res.firstSeen).not.toBeNull(); + expect(res.lastSeen).not.toBeNull(); + }); + + it('returns metrics for alice', async () => { + const server = makeServer(); + registerProfileMetricTools(server as any, CTX); + const res = await server.invoke('get_profile_metrics', { + projectId: TEST_PROJECT_ID, + profileId: FIXTURE.profiles.alice, + }); + expect(res.error).toBeUndefined(); + expect(res.sessions).toBe(1); + expect(res.totalEvents).toBe(3); // session_start + page_view + session_end + expect(res.conversionEvents).toBe(1); // page_view only + expect(res.screenViews).toBe(0); + }); +}); + +// ─── Groups ─────────────────────────────────────────────────────────────────── + +describe('list_group_types', () => { + it('returns { types: [] } (no groups in fixtures)', async () => { + const server = makeServer(); + registerGroupTools(server as any, CTX); + const res = await server.invoke('list_group_types', { + projectId: TEST_PROJECT_ID, + }); + expect(Array.isArray(res.types)).toBe(true); + expect(res.types).toHaveLength(0); + }); +}); + +describe('find_groups', () => { + it('returns empty array (no groups in fixtures)', async () => { + const server = makeServer(); + registerGroupTools(server as any, CTX); + const res = await server.invoke('find_groups', { + projectId: TEST_PROJECT_ID, + }); + expect(Array.isArray(res)).toBe(true); + }); +}); + +describe('get_group', () => { + it('returns not-found error for unknown group', async () => { + const server = makeServer(); + registerGroupTools(server as any, CTX); + const res = await server.invoke('get_group', { + projectId: TEST_PROJECT_ID, + groupId: 'nonexistent', + }); + expect(res.error).toBe('Group not found'); + expect(res.groupId).toBe('nonexistent'); + }); +}); + +// ─── Aggregated metrics ─────────────────────────────────────────────────────── + +describe('get_analytics_overview', () => { + it('returns summary with numeric metric fields and a series array', async () => { + const server = makeServer(); + registerOverviewTools(server as any, CTX); + const res = await server.invoke('get_analytics_overview', { + projectId: TEST_PROJECT_ID, + startDate: '2000-01-01', + endDate: '2099-01-01', + }); + expect(res).toHaveProperty('summary'); + expect(res).toHaveProperty('series'); + expect(Array.isArray(res.series)).toBe(true); + }); +}); + +describe('get_top_pages', () => { + it('returns array including /shop and /home from fixtures', async () => { + const server = makeServer(); + registerPageTools(server as any, CTX); + const res = await server.invoke('get_top_pages', { + projectId: TEST_PROJECT_ID, + startDate: '2000-01-01', + endDate: '2099-01-01', + }); + expect(Array.isArray(res)).toBe(true); + const paths = res.map((p: any) => p.path); + // getTopPages queries screen_view events only — Charlie's /shop appears; + // Alice's /home is a page_view (not screen_view) so it won't show here. + expect(paths).toContain('/shop'); + }); +}); + +describe('get_entry_exit_pages', () => { + it('returns entry pages array', async () => { + const server = makeServer(); + registerPageTools(server as any, CTX); + const res = await server.invoke('get_entry_exit_pages', { + projectId: TEST_PROJECT_ID, + startDate: '2000-01-01', + endDate: '2099-01-01', + mode: 'entry', + }); + expect(Array.isArray(res)).toBe(true); + }); + + it('returns exit pages array', async () => { + const server = makeServer(); + registerPageTools(server as any, CTX); + const res = await server.invoke('get_entry_exit_pages', { + projectId: TEST_PROJECT_ID, + startDate: '2000-01-01', + endDate: '2099-01-01', + mode: 'exit', + }); + expect(Array.isArray(res)).toBe(true); + }); +}); + +describe('get_page_performance', () => { + it('returns pages array with seo_signals on each page', async () => { + const server = makeServer(); + registerPagePerformanceTools(server as any, CTX); + const res = await server.invoke('get_page_performance', { + projectId: TEST_PROJECT_ID, + startDate: '2000-01-01', + endDate: '2099-01-01', + }); + expect(typeof res.total_pages).toBe('number'); + expect(typeof res.shown).toBe('number'); + expect(Array.isArray(res.pages)).toBe(true); + for (const page of res.pages) { + expect(page).toHaveProperty('seo_signals'); + expect(typeof page.seo_signals.high_bounce).toBe('boolean'); + expect(typeof page.seo_signals.low_engagement).toBe('boolean'); + expect(typeof page.seo_signals.good_landing_page).toBe('boolean'); + } + }); +}); + +describe('get_top_referrers', () => { + it('returns array', async () => { + const server = makeServer(); + registerTrafficTools(server as any, CTX); + const res = await server.invoke('get_top_referrers', { + projectId: TEST_PROJECT_ID, + startDate: '2000-01-01', + endDate: '2099-01-01', + }); + expect(Array.isArray(res)).toBe(true); + }); +}); + +describe('get_country_breakdown', () => { + it('returns US as country in fixtures', async () => { + const server = makeServer(); + registerTrafficTools(server as any, CTX); + const res = await server.invoke('get_country_breakdown', { + projectId: TEST_PROJECT_ID, + startDate: '2000-01-01', + endDate: '2099-01-01', + }); + expect(Array.isArray(res)).toBe(true); + // getTopGeneric returns { name, sessions, pageviews } — field is 'name' + const countries = res.map((r: any) => r.name); + expect(countries).toContain('US'); + }); +}); + +describe('get_device_breakdown', () => { + it('returns desktop in fixtures', async () => { + const server = makeServer(); + registerTrafficTools(server as any, CTX); + const res = await server.invoke('get_device_breakdown', { + projectId: TEST_PROJECT_ID, + startDate: '2000-01-01', + endDate: '2099-01-01', + }); + expect(Array.isArray(res)).toBe(true); + // getTopGeneric returns { name, sessions, pageviews } — field is 'name' + const devices = res.map((r: any) => r.name); + expect(devices).toContain('desktop'); + }); +}); + +// ─── User behavior ──────────────────────────────────────────────────────────── + +describe('get_funnel', () => { + it('detects charlie completing session_start → purchase', async () => { + const server = makeServer(); + registerFunnelTools(server as any, CTX); + const res = await server.invoke('get_funnel', { + projectId: TEST_PROJECT_ID, + startDate: '2000-01-01', + endDate: '2099-01-01', + steps: ['session_start', 'purchase'], + }); + expect(res).toHaveProperty('steps'); + expect(res.steps.length).toBe(2); + expect(res.steps[0].eventName).toBe('session_start'); + expect(res.steps[1].eventName).toBe('purchase'); + expect(res.totalUsers).toBeGreaterThanOrEqual(1); + expect(res.completedUsers).toBeGreaterThanOrEqual(1); + expect(res.overallConversionRate).toBeGreaterThan(0); + // Each step has the required fields + for (const step of res.steps) { + expect(typeof step.step).toBe('number'); + expect(typeof step.users).toBe('number'); + expect(typeof step.conversionRateFromStart).toBe('number'); + } + }); + + it('returns zero completions for an impossible funnel order', async () => { + const server = makeServer(); + registerFunnelTools(server as any, CTX); + const res = await server.invoke('get_funnel', { + projectId: TEST_PROJECT_ID, + startDate: '2000-01-01', + endDate: '2099-01-01', + steps: ['purchase', 'session_start'], // reversed — nobody completes this + }); + expect(res.completedUsers).toBe(0); + }); +}); + +describe('get_user_flow', () => { + it('returns nodes and links for flow after session_start', async () => { + const server = makeServer(); + registerUserFlowTools(server as any, CTX); + const res = await server.invoke('get_user_flow', { + projectId: TEST_PROJECT_ID, + startDate: '2000-01-01', + endDate: '2099-01-01', + startEvent: 'session_start', + mode: 'after', + }); + expect(res.mode).toBe('after'); + expect(res.startEvent).toBe('session_start'); + expect(Array.isArray(res.nodes)).toBe(true); + expect(Array.isArray(res.links)).toBe(true); + expect(typeof res.node_count).toBe('number'); + expect(typeof res.link_count).toBe('number'); + }); + + it('returns error when mode=between without endEvent', async () => { + const server = makeServer(); + registerUserFlowTools(server as any, CTX); + const res = await server.invoke('get_user_flow', { + projectId: TEST_PROJECT_ID, + startDate: '2000-01-01', + endDate: '2099-01-01', + startEvent: 'session_start', + mode: 'between', + // endEvent intentionally omitted + }); + expect(res.error).toContain('endEvent'); + }); +}); + +describe('get_rolling_active_users', () => { + it('returns DAU series (may be empty — dau_mv not auto-populated)', async () => { + const server = makeServer(); + registerActiveUserTools(server as any, CTX); + const res = await server.invoke('get_rolling_active_users', { + projectId: TEST_PROJECT_ID, + days: 1, + }); + expect(res.label).toBe('DAU'); + expect(res.window_days).toBe(1); + expect(Array.isArray(res.series)).toBe(true); + }); + + it('uses correct label for WAU and MAU', async () => { + const server = makeServer(); + registerActiveUserTools(server as any, CTX); + const wau = await server.invoke('get_rolling_active_users', { + projectId: TEST_PROJECT_ID, + days: 7, + }); + expect(wau.label).toBe('WAU'); + const mau = await server.invoke('get_rolling_active_users', { + projectId: TEST_PROJECT_ID, + days: 30, + }); + expect(mau.label).toBe('MAU'); + }); +}); + +describe('get_weekly_retention_series', () => { + it('returns array of { date, active_users, retained_users, retention } rows', async () => { + const server = makeServer(); + registerActiveUserTools(server as any, CTX); + const res = await server.invoke('get_weekly_retention_series', { + projectId: TEST_PROJECT_ID, + }); + expect(Array.isArray(res)).toBe(true); + if (res.length > 0) { + expect(res[0]).toHaveProperty('date'); + expect(res[0]).toHaveProperty('active_users'); + expect(res[0]).toHaveProperty('retained_users'); + expect(res[0]).toHaveProperty('retention'); + } + }); +}); + +describe('get_retention_cohort', () => { + it('returns array of cohort rows with period_0..period_9', async () => { + const server = makeServer(); + registerRetentionTools(server as any, CTX); + const res = await server.invoke('get_retention_cohort', { + projectId: TEST_PROJECT_ID, + }); + expect(Array.isArray(res)).toBe(true); + if (res.length > 0) { + expect(res[0]).toHaveProperty('first_seen'); + expect(res[0]).toHaveProperty('period_0'); + } + }); +}); + +describe('get_user_last_seen_distribution', () => { + it('returns alice and charlie in active_last_7_days bucket', async () => { + const server = makeServer(); + registerEngagementTools(server as any, CTX); + const res = await server.invoke('get_user_last_seen_distribution', { + projectId: TEST_PROJECT_ID, + }); + // Alice: last event 2 days ago → 0-7 bucket + // Charlie: last event 5 days ago → 0-7 bucket + // Bob: no events → not counted + expect(res.summary.total_identified_users).toBe(2); + expect(res.summary.active_last_7_days).toBe(2); + expect(res.summary.active_8_to_14_days).toBe(0); + expect(res.summary.churned_60_plus_days).toBe(0); + expect(Array.isArray(res.distribution)).toBe(true); + }); +}); diff --git a/packages/mcp/src/server.ts b/packages/mcp/src/server.ts new file mode 100644 index 000000000..c1ccae70a --- /dev/null +++ b/packages/mcp/src/server.ts @@ -0,0 +1,30 @@ +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import type { McpAuthContext } from './auth'; +import { registerAllTools } from './tools/index'; + +const SERVER_NAME = 'OpenPanel'; +const SERVER_VERSION = '1.0.0'; + +/** + * Create a fully configured McpServer instance for a given auth context. + * + * Each authenticated session gets its own server instance with tools + * pre-bound to the session's project/organization context. + */ +export function createMcpServer(context: McpAuthContext): McpServer { + const server = new McpServer( + { + name: SERVER_NAME, + version: SERVER_VERSION, + }, + { + capabilities: { + tools: {}, + }, + }, + ); + + registerAllTools(server, context); + + return server; +} diff --git a/packages/mcp/src/session-manager.test.ts b/packages/mcp/src/session-manager.test.ts new file mode 100644 index 000000000..b5ab162a7 --- /dev/null +++ b/packages/mcp/src/session-manager.test.ts @@ -0,0 +1,161 @@ +import { describe, expect, it, vi, beforeEach } from 'vitest'; +import type { McpAuthContext } from './auth'; + +const mockSetJson = vi.hoisted(() => vi.fn().mockResolvedValue(undefined)); +const mockGetJson = vi.hoisted(() => vi.fn()); +const mockExpire = vi.hoisted(() => vi.fn().mockResolvedValue(undefined)); +const mockDel = vi.hoisted(() => vi.fn().mockResolvedValue(undefined)); + +vi.mock('@openpanel/redis', () => ({ + getRedisCache: () => ({ + setJson: mockSetJson, + getJson: mockGetJson, + expire: mockExpire, + del: mockDel, + }), +})); + +vi.mock('@openpanel/logger', () => ({ + createLogger: () => ({ + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }), +})); + +import { SessionManager } from './session-manager'; + +const CTX: McpAuthContext = { + projectId: 'proj-1', + organizationId: 'org-1', + clientType: 'read', +}; + +const mockTransport = { + close: vi.fn().mockResolvedValue(undefined), +}; + +const mockServer = {} as any; + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe('SessionManager', () => { + describe('generateId', () => { + it('generates unique UUIDs', () => { + const sm = new SessionManager(); + const a = sm.generateId(); + const b = sm.generateId(); + expect(a).not.toBe(b); + expect(a).toMatch(/^[0-9a-f-]{36}$/); + }); + }); + + describe('context (Redis)', () => { + it('stores context in Redis with TTL', async () => { + const sm = new SessionManager(); + await sm.setContext('sess-1', CTX); + expect(mockSetJson).toHaveBeenCalledWith('mcp:session:sess-1', 30 * 60, CTX); + }); + + it('retrieves context from Redis', async () => { + const sm = new SessionManager(); + mockGetJson.mockResolvedValue(CTX); + const result = await sm.getContext('sess-1'); + expect(result).toEqual(CTX); + expect(mockGetJson).toHaveBeenCalledWith('mcp:session:sess-1'); + }); + + it('returns null for missing session', async () => { + const sm = new SessionManager(); + mockGetJson.mockResolvedValue(null); + const result = await sm.getContext('missing'); + expect(result).toBeNull(); + }); + + it('touches TTL on touchContext', async () => { + const sm = new SessionManager(); + await sm.touchContext('sess-1'); + expect(mockExpire).toHaveBeenCalledWith('mcp:session:sess-1', 30 * 60); + }); + + it('deletes context from Redis', async () => { + const sm = new SessionManager(); + await sm.deleteContext('sess-1'); + expect(mockDel).toHaveBeenCalledWith('mcp:session:sess-1'); + }); + }); + + describe('local transport', () => { + it('stores and retrieves local session', () => { + const sm = new SessionManager(); + sm.setLocal('sess-1', { server: mockServer, transport: mockTransport as any }); + expect(sm.getLocal('sess-1')).toBeDefined(); + }); + + it('returns undefined for unknown session', () => { + const sm = new SessionManager(); + expect(sm.getLocal('unknown')).toBeUndefined(); + }); + + it('deletes local session', () => { + const sm = new SessionManager(); + sm.setLocal('sess-1', { server: mockServer, transport: mockTransport as any }); + sm.deleteLocal('sess-1'); + expect(sm.getLocal('sess-1')).toBeUndefined(); + }); + + it('tracks localSize correctly', () => { + const sm = new SessionManager(); + expect(sm.localSize).toBe(0); + sm.setLocal('a', { server: mockServer, transport: mockTransport as any }); + sm.setLocal('b', { server: mockServer, transport: mockTransport as any }); + expect(sm.localSize).toBe(2); + sm.deleteLocal('a'); + expect(sm.localSize).toBe(1); + }); + }); + + describe('close', () => { + it('closes transport, removes local session, and deletes Redis context', async () => { + const sm = new SessionManager(); + sm.setLocal('sess-1', { server: mockServer, transport: mockTransport as any }); + await sm.close('sess-1'); + + expect(mockTransport.close).toHaveBeenCalled(); + expect(sm.getLocal('sess-1')).toBeUndefined(); + expect(mockDel).toHaveBeenCalledWith('mcp:session:sess-1'); + }); + + it('still removes Redis context even when no local session exists', async () => { + const sm = new SessionManager(); + await sm.close('no-local-sess'); + expect(mockDel).toHaveBeenCalledWith('mcp:session:no-local-sess'); + expect(mockTransport.close).not.toHaveBeenCalled(); + }); + + it('does not throw if transport.close fails', async () => { + const sm = new SessionManager(); + const failingTransport = { close: vi.fn().mockRejectedValue(new Error('already closed')) }; + sm.setLocal('sess-1', { server: mockServer, transport: failingTransport as any }); + await expect(sm.close('sess-1')).resolves.toBeUndefined(); + }); + }); + + describe('destroy', () => { + it('closes all local sessions', async () => { + const sm = new SessionManager(); + const t1 = { close: vi.fn().mockResolvedValue(undefined) }; + const t2 = { close: vi.fn().mockResolvedValue(undefined) }; + sm.setLocal('a', { server: mockServer, transport: t1 as any }); + sm.setLocal('b', { server: mockServer, transport: t2 as any }); + + await sm.destroy(); + + expect(t1.close).toHaveBeenCalled(); + expect(t2.close).toHaveBeenCalled(); + expect(sm.localSize).toBe(0); + }); + }); +}); diff --git a/packages/mcp/src/session-manager.ts b/packages/mcp/src/session-manager.ts new file mode 100644 index 000000000..535a7c635 --- /dev/null +++ b/packages/mcp/src/session-manager.ts @@ -0,0 +1,102 @@ +import { randomUUID } from 'node:crypto'; +import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import type { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'; +import { createLogger } from '@openpanel/logger'; +import { getRedisCache } from '@openpanel/redis'; +import type { McpAuthContext } from './auth'; + +const logger = createLogger({ name: 'mcp:sessions' }); + +const SESSION_TTL_SECONDS = 30 * 60; // 30 minutes + +function redisKey(id: string) { + return `mcp:session:${id}`; +} + +interface McpLocalSession { + server: McpServer; + transport: StreamableHTTPServerTransport; +} + +/** + * Hybrid session manager: + * - Auth context is stored in Redis (shared across all API instances, TTL 30 min) + * - Active transport/server are kept in a local Map (in-process only — they hold live HTTP connections) + * + * This means POST requests can be handled by any instance: if the transport + * isn't local, we retrieve the context from Redis and recreate it here. + * SSE (GET) streams are inherently tied to the instance they started on; + * when that instance goes down the client reconnects and gets a fresh session. + */ +export class SessionManager { + private readonly local = new Map(); + + generateId(): string { + return randomUUID(); + } + + // --- context (Redis) --- + + async setContext(id: string, context: McpAuthContext): Promise { + await getRedisCache().setJson(redisKey(id), SESSION_TTL_SECONDS, context); + logger.info('MCP session context stored', { + sessionId: id, + clientType: context.clientType, + organizationId: context.organizationId, + projectId: context.projectId, + }); + } + + getContext(id: string): Promise { + return getRedisCache().getJson(redisKey(id)); + } + + async touchContext(id: string): Promise { + await getRedisCache().expire(redisKey(id), SESSION_TTL_SECONDS); + } + + async deleteContext(id: string): Promise { + await getRedisCache().del(redisKey(id)); + } + + // --- transport/server (local) --- + + setLocal(id: string, session: McpLocalSession): void { + this.local.set(id, session); + } + + getLocal(id: string): McpLocalSession | undefined { + return this.local.get(id); + } + + deleteLocal(id: string): void { + this.local.delete(id); + } + + // --- combined ops --- + + async close(id: string): Promise { + const session = this.local.get(id); + this.local.delete(id); + await this.deleteContext(id); + + if (session) { + try { + await session.transport.close(); + } catch (err) { + logger.warn('Error closing MCP transport', { sessionId: id, err }); + } + } + + logger.info('MCP session closed', { sessionId: id }); + } + + async destroy(): Promise { + const ids = [...this.local.keys()]; + await Promise.all(ids.map((id) => this.close(id))); + } + + get localSize(): number { + return this.local.size; + } +} diff --git a/packages/mcp/src/tools/analytics/active-users.ts b/packages/mcp/src/tools/analytics/active-users.ts new file mode 100644 index 000000000..b48806cb6 --- /dev/null +++ b/packages/mcp/src/tools/analytics/active-users.ts @@ -0,0 +1,52 @@ +import { getRollingActiveUsers, getRetentionSeries } from '@openpanel/db'; +import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { z } from 'zod'; +import type { McpAuthContext } from '../../auth'; +import { + projectIdSchema, + + withErrorHandling, + resolveProjectId +} from '../shared'; + +export function registerActiveUserTools( + server: McpServer, + context: McpAuthContext, +) { + server.tool( + 'get_rolling_active_users', + 'Get a time series of unique active users using a rolling window. Use days=1 for DAU, days=7 for WAU, days=30 for MAU. Shows how your active user count trends over time.', + { + projectId: projectIdSchema(context), + days: z + .number() + .int() + .min(1) + .max(90) + .describe('Rolling window in days. 1 = DAU, 7 = WAU, 30 = MAU.'), + }, + async ({ projectId: inputProjectId, days }) => + withErrorHandling(async () => { + const projectId = await resolveProjectId(context, inputProjectId); + const data = await getRollingActiveUsers({ projectId, days }); + return { + window_days: days, + label: days === 1 ? 'DAU' : days === 7 ? 'WAU' : days === 30 ? 'MAU' : `${days}d active`, + series: data, + }; + }), + ); + + server.tool( + 'get_weekly_retention_series', + 'Get week-over-week user retention as a time series. For each week, shows how many users were active that week and how many returned the following week. Useful for understanding whether your product retains users.', + { + projectId: projectIdSchema(context), + }, + async ({ projectId: inputProjectId }) => + withErrorHandling(async () => { + const projectId = await resolveProjectId(context, inputProjectId); + return getRetentionSeries({ projectId }); + }), + ); +} diff --git a/packages/mcp/src/tools/analytics/engagement.test.ts b/packages/mcp/src/tools/analytics/engagement.test.ts new file mode 100644 index 000000000..3912e7c43 --- /dev/null +++ b/packages/mcp/src/tools/analytics/engagement.test.ts @@ -0,0 +1,84 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const mockGetRetentionLastSeenSeries = vi.hoisted(() => vi.fn()); + +vi.mock('@openpanel/db', () => ({ + getRetentionLastSeenSeries: mockGetRetentionLastSeenSeries, + resolveClientProjectId: vi.fn(({ clientProjectId }: { clientProjectId: string }) => Promise.resolve(clientProjectId)), +})); + +// Import after mock is set up +import { registerEngagementTools } from './engagement'; + +// Helper: directly invoke the bucketing logic by importing it through a minimal mock server +// We test the bucketing by calling the tool handler directly via a test double McpServer. +function makeServer() { + let handler: ((input: unknown) => Promise) | null = null; + return { + tool: (_name: string, _desc: string, _schema: unknown, fn: (input: unknown) => Promise) => { + handler = fn; + }, + invoke: (input: unknown) => { + if (!handler) throw new Error('tool not registered'); + return handler(input); + }, + }; +} + +const READ_CTX = { projectId: 'proj-1', organizationId: 'org-1', clientType: 'read' as const }; + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe('get_user_last_seen_distribution — bucketing', () => { + it('correctly buckets users into recency segments', async () => { + mockGetRetentionLastSeenSeries.mockResolvedValue([ + { days: 0, users: 10 }, + { days: 3, users: 20 }, + { days: 7, users: 5 }, // still in 0-7 + { days: 10, users: 8 }, // 8-14 + { days: 14, users: 2 }, // 8-14 + { days: 20, users: 12 }, // 15-30 + { days: 45, users: 6 }, // 31-60 + { days: 90, users: 3 }, // 60+ + ]); + + const server = makeServer() as any; + registerEngagementTools(server, READ_CTX); + const result = await server.invoke({ projectId: READ_CTX.projectId }) as any; + const content = JSON.parse(result.content[0].text); + + expect(content.summary.active_last_7_days).toBe(10 + 20 + 5); // 35 + expect(content.summary.active_8_to_14_days).toBe(8 + 2); // 10 + expect(content.summary.active_15_to_30_days).toBe(12); + expect(content.summary.inactive_31_to_60_days).toBe(6); + expect(content.summary.churned_60_plus_days).toBe(3); + expect(content.summary.total_identified_users).toBe(66); + }); + + it('returns zero counts when no data', async () => { + mockGetRetentionLastSeenSeries.mockResolvedValue([]); + + const server = makeServer() as any; + registerEngagementTools(server, READ_CTX); + const result = await server.invoke({ projectId: READ_CTX.projectId }) as any; + const content = JSON.parse(result.content[0].text); + + expect(content.summary.total_identified_users).toBe(0); + expect(content.summary.active_last_7_days).toBe(0); + expect(content.summary.churned_60_plus_days).toBe(0); + }); + + it('passes raw distribution alongside the summary', async () => { + const raw = [{ days: 1, users: 5 }]; + mockGetRetentionLastSeenSeries.mockResolvedValue(raw); + + const server = makeServer() as any; + registerEngagementTools(server, READ_CTX); + const result = await server.invoke({ projectId: READ_CTX.projectId }) as any; + const content = JSON.parse(result.content[0].text); + + expect(content.distribution).toEqual(raw); + }); +}); diff --git a/packages/mcp/src/tools/analytics/engagement.ts b/packages/mcp/src/tools/analytics/engagement.ts new file mode 100644 index 000000000..435227c00 --- /dev/null +++ b/packages/mcp/src/tools/analytics/engagement.ts @@ -0,0 +1,53 @@ +import { getRetentionLastSeenSeries } from '@openpanel/db'; +import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import type { McpAuthContext } from '../../auth'; +import { projectIdSchema, withErrorHandling, + resolveProjectId +} from '../shared'; + +export function registerEngagementTools( + server: McpServer, + context: McpAuthContext, +) { + server.tool( + 'get_user_last_seen_distribution', + 'Get a histogram of how many users were last active N days ago. Shows the distribution of user recency — how many users are still fresh (0-7 days), somewhat stale (8-30 days), or churned (30+ days). Great for churn analysis and understanding overall engagement health.', + { + projectId: projectIdSchema(context), + }, + async ({ projectId: inputProjectId }) => + withErrorHandling(async () => { + const projectId = await resolveProjectId(context, inputProjectId); + const raw = await getRetentionLastSeenSeries({ projectId }); + + // Bucket into meaningful segments for easier reading + let active_0_7 = 0; + let active_8_14 = 0; + let active_15_30 = 0; + let active_31_60 = 0; + let churned_60_plus = 0; + + for (const row of raw) { + if (row.days <= 7) active_0_7 += row.users; + else if (row.days <= 14) active_8_14 += row.users; + else if (row.days <= 30) active_15_30 += row.users; + else if (row.days <= 60) active_31_60 += row.users; + else churned_60_plus += row.users; + } + + const total = active_0_7 + active_8_14 + active_15_30 + active_31_60 + churned_60_plus; + + return { + summary: { + total_identified_users: total, + active_last_7_days: active_0_7, + active_8_to_14_days: active_8_14, + active_15_to_30_days: active_15_30, + inactive_31_to_60_days: active_31_60, + churned_60_plus_days: churned_60_plus, + }, + distribution: raw, + }; + }), + ); +} diff --git a/packages/mcp/src/tools/analytics/event-names.ts b/packages/mcp/src/tools/analytics/event-names.ts new file mode 100644 index 000000000..6b547d6d8 --- /dev/null +++ b/packages/mcp/src/tools/analytics/event-names.ts @@ -0,0 +1,28 @@ +import { getTopEventNames } from '@openpanel/db'; +import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import type { McpAuthContext } from '../../auth'; +import { + projectIdSchema, + + withErrorHandling, + resolveProjectId +} from '../shared'; + +export function registerEventNameTools( + server: McpServer, + context: McpAuthContext, +) { + server.tool( + 'list_event_names', + 'Get the top 50 most common event names tracked in this project. Always call this before querying events if you are unsure of the exact event name.', + { + projectId: projectIdSchema(context), + }, + async ({ projectId: inputProjectId }) => + withErrorHandling(async () => { + const projectId = await resolveProjectId(context, inputProjectId); + const names = await getTopEventNames(projectId); + return { event_names: names }; + }), + ); +} diff --git a/packages/mcp/src/tools/analytics/events.ts b/packages/mcp/src/tools/analytics/events.ts new file mode 100644 index 000000000..1df91a07b --- /dev/null +++ b/packages/mcp/src/tools/analytics/events.ts @@ -0,0 +1,73 @@ +import { queryEventsCore } from '@openpanel/db'; +import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { z } from 'zod'; +import type { McpAuthContext } from '../../auth'; +import { + projectIdSchema, + resolveDateRange, + + withErrorHandling, + zDateRange, + resolveProjectId +} from '../shared'; + +export function registerEventTools(server: McpServer, context: McpAuthContext) { + server.tool( + 'query_events', + 'Query raw analytics events with optional filters. Returns individual event records including path, device, country, referrer, and custom properties. Defaults to the last 30 days.', + { + projectId: projectIdSchema(context), + ...zDateRange, + eventNames: z + .array(z.string()) + .optional() + .describe( + 'Filter by event names (e.g. ["screen_view", "session_start"])', + ), + path: z.string().optional().describe('Filter by exact page path'), + country: z + .string() + .optional() + .describe('Filter by ISO 3166-1 alpha-2 country code (e.g. US, GB)'), + city: z.string().optional().describe('Filter by city name'), + device: z + .string() + .optional() + .describe('Filter by device type (e.g. desktop, mobile, tablet)'), + browser: z + .string() + .optional() + .describe('Filter by browser name (e.g. Chrome, Firefox)'), + os: z.string().optional().describe('Filter by OS name (e.g. Windows, macOS)'), + referrer: z.string().optional().describe('Filter by referrer URL'), + referrerName: z + .string() + .optional() + .describe('Filter by referrer name (e.g. Google, Twitter)'), + referrerType: z + .string() + .optional() + .describe('Filter by referrer type (e.g. search, social, email)'), + profileId: z + .string() + .optional() + .describe('Filter events for a specific user profile ID'), + properties: z + .record(z.string(), z.string()) + .optional() + .describe('Filter by custom event properties (key-value pairs)'), + limit: z + .number() + .min(1) + .max(100) + .default(20) + .optional() + .describe('Maximum number of events to return (1-100, default 20)'), + }, + async ({ projectId: inputProjectId, ...input }) => + withErrorHandling(async () => { + const projectId = await resolveProjectId(context, inputProjectId); + return queryEventsCore({ projectId, ...input }); + }), + ); +} diff --git a/packages/mcp/src/tools/analytics/funnel.ts b/packages/mcp/src/tools/analytics/funnel.ts new file mode 100644 index 000000000..b8dedc2c1 --- /dev/null +++ b/packages/mcp/src/tools/analytics/funnel.ts @@ -0,0 +1,63 @@ +import { getFunnelCore } from '@openpanel/db'; + +import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { z } from 'zod'; +import type { McpAuthContext } from '../../auth'; +import { + projectIdSchema, + resolveDateRange, + + withErrorHandling, + zDateRange, + resolveProjectId +} from '../shared'; + +export function registerFunnelTools( + server: McpServer, + context: McpAuthContext, +) { + server.tool( + 'get_funnel', + 'Analyze a conversion funnel between 2 or more events. Returns step-by-step conversion rates and drop-off percentages. For example, analyze sign-up flows, checkout funnels, or onboarding sequences.', + { + projectId: projectIdSchema(context), + ...zDateRange, + steps: z + .array(z.string()) + .min(2) + .max(10) + .describe( + 'Ordered list of event names forming the funnel steps (minimum 2, maximum 10)', + ), + windowHours: z + .number() + .min(1) + .max(720) + .default(24) + .optional() + .describe( + 'Time window in hours within which all steps must occur (default: 24 hours)', + ), + groupBy: z + .enum(['session_id', 'profile_id']) + .default('session_id') + .optional() + .describe( + '"session_id" counts within-session completions, "profile_id" counts cross-session completions (default: session_id)', + ), + }, + async ({ projectId: inputProjectId, startDate: sd, endDate: ed, steps, windowHours, groupBy }) => + withErrorHandling(async () => { + const projectId = await resolveProjectId(context, inputProjectId); + const { startDate, endDate } = resolveDateRange(sd, ed); + return getFunnelCore({ + projectId, + startDate, + endDate, + steps, + windowHours, + groupBy, + }); + }), + ); +} diff --git a/packages/mcp/src/tools/analytics/groups.ts b/packages/mcp/src/tools/analytics/groups.ts new file mode 100644 index 000000000..91c8808a7 --- /dev/null +++ b/packages/mcp/src/tools/analytics/groups.ts @@ -0,0 +1,94 @@ +import { getGroupById, getGroupList, getGroupMemberProfiles, getGroupTypes } from '@openpanel/db'; +import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { z } from 'zod'; +import type { McpAuthContext } from '../../auth'; +import { projectIdSchema, withErrorHandling, + resolveProjectId +} from '../shared'; + +export function registerGroupTools( + server: McpServer, + context: McpAuthContext, +) { + server.tool( + 'list_group_types', + 'List all group types defined in this project (e.g. "company", "team", "account"). Groups represent B2B entities. Call this first to discover what group types exist before querying groups.', + { + projectId: projectIdSchema(context), + }, + async ({ projectId: inputProjectId }) => + withErrorHandling(async () => { + const projectId = await resolveProjectId(context, inputProjectId); + const types = await getGroupTypes(projectId); + return { types }; + }), + ); + + server.tool( + 'find_groups', + 'Search for groups (companies, teams, accounts) by name, ID, or type. Groups are B2B entities that profiles (users) belong to.', + { + projectId: projectIdSchema(context), + type: z + .string() + .optional() + .describe('Filter by group type (e.g. "company", "team"). Use list_group_types to discover available types.'), + search: z + .string() + .optional() + .describe('Partial match against group name or ID'), + limit: z + .number() + .int() + .min(1) + .max(100) + .default(20) + .optional() + .describe('Maximum number of groups to return (default 20)'), + }, + async ({ projectId: inputProjectId, type, search, limit }) => + withErrorHandling(async () => { + const projectId = await resolveProjectId(context, inputProjectId); + return getGroupList({ projectId, type, search, take: limit ?? 20 }); + }), + ); + + server.tool( + 'get_group', + 'Get a specific group by ID including its properties, and fetch the member profiles (users) that belong to it.', + { + projectId: projectIdSchema(context), + groupId: z.string().describe('The group ID to look up'), + memberLimit: z + .number() + .int() + .min(1) + .max(50) + .default(10) + .optional() + .describe('Max number of member profiles to include (default 10)'), + }, + async ({ projectId: inputProjectId, groupId, memberLimit }) => + withErrorHandling(async () => { + const projectId = await resolveProjectId(context, inputProjectId); + const [group, members] = await Promise.all([ + getGroupById(groupId, projectId), + getGroupMemberProfiles({ + projectId, + groupId, + take: memberLimit ?? 10, + }), + ]); + + if (!group) { + return { error: 'Group not found', groupId }; + } + + return { + group, + member_count: members.count, + members: members.data, + }; + }), + ); +} diff --git a/packages/mcp/src/tools/analytics/overview.ts b/packages/mcp/src/tools/analytics/overview.ts new file mode 100644 index 000000000..227d9e20d --- /dev/null +++ b/packages/mcp/src/tools/analytics/overview.ts @@ -0,0 +1,42 @@ +import { getAnalyticsOverviewCore } from '@openpanel/db'; +import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { z } from 'zod'; +import type { McpAuthContext } from '../../auth'; +import { + projectIdSchema, + resolveDateRange, + + withErrorHandling, + zDateRange, + resolveProjectId +} from '../shared'; + +export function registerOverviewTools( + server: McpServer, + context: McpAuthContext, +) { + server.tool( + 'get_analytics_overview', + 'Get key analytics metrics for a date range: unique visitors, total pageviews, sessions, bounce rate, average session duration, and views per session. Optionally includes a time-series breakdown by interval.', + { + projectId: projectIdSchema(context), + ...zDateRange, + interval: z + .enum(['hour', 'day', 'week', 'month']) + .default('day') + .optional() + .describe('Time interval for the series breakdown (default: day)'), + }, + async ({ projectId: inputProjectId, startDate: sd, endDate: ed, interval }) => + withErrorHandling(async () => { + const projectId = await resolveProjectId(context, inputProjectId); + const { startDate, endDate } = resolveDateRange(sd, ed); + return getAnalyticsOverviewCore({ + projectId, + startDate, + endDate, + interval, + }); + }), + ); +} diff --git a/packages/mcp/src/tools/analytics/page-conversions.test.ts b/packages/mcp/src/tools/analytics/page-conversions.test.ts new file mode 100644 index 000000000..ca623ffe8 --- /dev/null +++ b/packages/mcp/src/tools/analytics/page-conversions.test.ts @@ -0,0 +1,205 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const mockGetPageConversionsCore = vi.hoisted(() => vi.fn()); + +vi.mock('@openpanel/db', () => ({ + getPageConversionsCore: mockGetPageConversionsCore, + resolveClientProjectId: vi.fn(({ clientProjectId }: { clientProjectId: string }) => + Promise.resolve(clientProjectId), + ), +})); + +import { registerPageConversionTools } from './page-conversions'; + +function makeServer() { + let handler: ((input: unknown) => Promise) | null = null; + return { + tool: ( + _name: string, + _desc: string, + _schema: unknown, + fn: (input: unknown) => Promise, + ) => { + handler = fn; + }, + invoke: (input: unknown) => { + if (!handler) throw new Error('tool not registered'); + return handler(input); + }, + }; +} + +const READ_CTX = { projectId: 'proj-1', organizationId: 'org-1', clientType: 'read' as const }; + +function makePage(overrides: Record = {}) { + return { + path: '/pricing', + origin: 'https://example.com', + unique_converters: 10, + total_visitors: 200, + conversion_rate: 5.0, + ...overrides, + }; +} + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe('get_page_conversions — output structure', () => { + it('returns pages with all required fields', async () => { + mockGetPageConversionsCore.mockResolvedValue([makePage()]); + + const server = makeServer() as any; + registerPageConversionTools(server, READ_CTX); + const result = (await server.invoke({ + projectId: READ_CTX.projectId, + conversionEvent: 'sign_up', + })) as any; + const content = JSON.parse(result.content[0].text); + + expect(content.pages[0]).toMatchObject({ + path: '/pricing', + origin: 'https://example.com', + unique_converters: 10, + total_visitors: 200, + conversion_rate: 5.0, + }); + }); + + it('includes metadata fields in response', async () => { + mockGetPageConversionsCore.mockResolvedValue([makePage()]); + + const server = makeServer() as any; + registerPageConversionTools(server, READ_CTX); + const result = (await server.invoke({ + projectId: READ_CTX.projectId, + conversionEvent: 'purchase', + })) as any; + const content = JSON.parse(result.content[0].text); + + expect(content.conversion_event).toBe('purchase'); + expect(content.window_hours).toBe(24); + expect(content.total_pages).toBe(1); + }); + + it('returns empty pages array when no conversions found', async () => { + mockGetPageConversionsCore.mockResolvedValue([]); + + const server = makeServer() as any; + registerPageConversionTools(server, READ_CTX); + const result = (await server.invoke({ + projectId: READ_CTX.projectId, + conversionEvent: 'sign_up', + })) as any; + const content = JSON.parse(result.content[0].text); + + expect(content.pages).toEqual([]); + expect(content.total_pages).toBe(0); + }); +}); + +describe('get_page_conversions — arguments forwarding', () => { + it('passes conversionEvent to core function', async () => { + mockGetPageConversionsCore.mockResolvedValue([]); + + const server = makeServer() as any; + registerPageConversionTools(server, READ_CTX); + await server.invoke({ + projectId: READ_CTX.projectId, + conversionEvent: 'trial_started', + }); + + expect(mockGetPageConversionsCore).toHaveBeenCalledWith( + expect.objectContaining({ conversionEvent: 'trial_started' }), + ); + }); + + it('defaults windowHours to 24 when not provided', async () => { + mockGetPageConversionsCore.mockResolvedValue([]); + + const server = makeServer() as any; + registerPageConversionTools(server, READ_CTX); + await server.invoke({ + projectId: READ_CTX.projectId, + conversionEvent: 'sign_up', + }); + + expect(mockGetPageConversionsCore).toHaveBeenCalledWith( + expect.objectContaining({ windowHours: 24 }), + ); + }); + + it('passes custom windowHours through', async () => { + mockGetPageConversionsCore.mockResolvedValue([]); + + const server = makeServer() as any; + registerPageConversionTools(server, READ_CTX); + await server.invoke({ + projectId: READ_CTX.projectId, + conversionEvent: 'sign_up', + windowHours: 168, + }); + + expect(mockGetPageConversionsCore).toHaveBeenCalledWith( + expect.objectContaining({ windowHours: 168 }), + ); + const result = (await server.invoke({ + projectId: READ_CTX.projectId, + conversionEvent: 'sign_up', + windowHours: 168, + })) as any; + const content = JSON.parse(result.content[0].text); + expect(content.window_hours).toBe(168); + }); + + it('defaults limit to 50 when not provided', async () => { + mockGetPageConversionsCore.mockResolvedValue([]); + + const server = makeServer() as any; + registerPageConversionTools(server, READ_CTX); + await server.invoke({ + projectId: READ_CTX.projectId, + conversionEvent: 'sign_up', + }); + + expect(mockGetPageConversionsCore).toHaveBeenCalledWith( + expect.objectContaining({ limit: 50 }), + ); + }); + + it('passes projectId from context when not specified', async () => { + mockGetPageConversionsCore.mockResolvedValue([]); + + const server = makeServer() as any; + registerPageConversionTools(server, READ_CTX); + await server.invoke({ + projectId: READ_CTX.projectId, + conversionEvent: 'sign_up', + }); + + expect(mockGetPageConversionsCore).toHaveBeenCalledWith( + expect.objectContaining({ projectId: 'proj-1' }), + ); + }); +}); + +describe('get_page_conversions — total_pages count', () => { + it('reflects the number of pages returned by core', async () => { + const pages = Array.from({ length: 7 }, (_, i) => + makePage({ path: `/page-${i}`, unique_converters: 10 - i }), + ); + mockGetPageConversionsCore.mockResolvedValue(pages); + + const server = makeServer() as any; + registerPageConversionTools(server, READ_CTX); + const result = (await server.invoke({ + projectId: READ_CTX.projectId, + conversionEvent: 'sign_up', + })) as any; + const content = JSON.parse(result.content[0].text); + + expect(content.total_pages).toBe(7); + expect(content.pages).toHaveLength(7); + }); +}); diff --git a/packages/mcp/src/tools/analytics/page-conversions.ts b/packages/mcp/src/tools/analytics/page-conversions.ts new file mode 100644 index 000000000..a18d86f42 --- /dev/null +++ b/packages/mcp/src/tools/analytics/page-conversions.ts @@ -0,0 +1,71 @@ +import { getPageConversionsCore } from '@openpanel/db'; +import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { z } from 'zod'; +import type { McpAuthContext } from '../../auth'; +import { + projectIdSchema, + resolveDateRange, + withErrorHandling, + zDateRange, + resolveProjectId, +} from '../shared'; + +export function registerPageConversionTools(server: McpServer, context: McpAuthContext) { + server.tool( + 'get_page_conversions', + 'Find which pages drive the most conversions. Given a conversion event (e.g. "sign_up", "purchase"), returns pages ranked by how many unique visitors went on to convert within a configurable time window after the page view. Includes total_visitors and conversion_rate per page. Useful for identifying high-value content and optimizing landing pages.', + { + projectId: projectIdSchema(context), + ...zDateRange, + conversionEvent: z + .string() + .describe( + 'The event name that counts as a conversion (e.g. "sign_up", "purchase", "trial_started"). Use list_event_names to discover available events.', + ), + windowHours: z + .number() + .min(1) + .max(720) + .default(24) + .optional() + .describe( + 'How many hours after a page view a conversion still counts (default: 24). Use 1 for same-session, 168 for 7-day window.', + ), + limit: z + .number() + .min(1) + .max(500) + .default(50) + .optional() + .describe( + 'Maximum pages to return, sorted by unique_converters descending (default: 50)', + ), + }, + async ({ + projectId: inputProjectId, + startDate: sd, + endDate: ed, + conversionEvent, + windowHours, + limit, + }) => + withErrorHandling(async () => { + const projectId = await resolveProjectId(context, inputProjectId); + const { startDate, endDate } = resolveDateRange(sd, ed); + const pages = await getPageConversionsCore({ + projectId, + startDate, + endDate, + conversionEvent, + windowHours: windowHours ?? 24, + limit: limit ?? 50, + }); + return { + conversion_event: conversionEvent, + window_hours: windowHours ?? 24, + total_pages: pages.length, + pages, + }; + }), + ); +} diff --git a/packages/mcp/src/tools/analytics/page-performance.test.ts b/packages/mcp/src/tools/analytics/page-performance.test.ts new file mode 100644 index 000000000..8f4e25b10 --- /dev/null +++ b/packages/mcp/src/tools/analytics/page-performance.test.ts @@ -0,0 +1,196 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const mockGetTopPages = vi.hoisted(() => vi.fn()); +const mockGetSettingsForProject = vi.hoisted(() => + vi.fn().mockResolvedValue({ timezone: 'UTC' }), +); + +vi.mock('@openpanel/db', () => ({ + PagesService: vi.fn().mockImplementation(() => ({ + getTopPages: mockGetTopPages, + })), + ch: {}, + getSettingsForProject: mockGetSettingsForProject, + resolveClientProjectId: vi.fn(({ clientProjectId }: { clientProjectId: string }) => Promise.resolve(clientProjectId)), +})); + +import { registerPagePerformanceTools } from './page-performance'; + +function makeServer() { + let handler: ((input: unknown) => Promise) | null = null; + return { + tool: (_name: string, _desc: string, _schema: unknown, fn: (input: unknown) => Promise) => { + handler = fn; + }, + invoke: (input: unknown) => { + if (!handler) throw new Error('tool not registered'); + return handler(input); + }, + }; +} + +const READ_CTX = { projectId: 'proj-1', organizationId: 'org-1', clientType: 'read' as const }; + +function makePage(overrides: Record = {}) { + return { + path: '/page', + title: 'Page', + sessions: 100, + pageviews: 200, + bounce_rate: 50, + avg_duration: 2, + ...overrides, + }; +} + +beforeEach(() => { + vi.clearAllMocks(); + mockGetSettingsForProject.mockResolvedValue({ timezone: 'UTC' }); +}); + +describe('get_page_performance — seo_signals annotation', () => { + it('marks high_bounce when bounce_rate > 70', async () => { + mockGetTopPages.mockResolvedValue([makePage({ bounce_rate: 80 })]); + + const server = makeServer() as any; + registerPagePerformanceTools(server, READ_CTX); + const result = await server.invoke({ projectId: READ_CTX.projectId }) as any; + const content = JSON.parse(result.content[0].text); + + expect(content.pages[0].seo_signals.high_bounce).toBe(true); + expect(content.pages[0].seo_signals.low_engagement).toBe(false); + expect(content.pages[0].seo_signals.good_landing_page).toBe(false); + }); + + it('does not mark high_bounce when bounce_rate is exactly 70', async () => { + mockGetTopPages.mockResolvedValue([makePage({ bounce_rate: 70 })]); + + const server = makeServer() as any; + registerPagePerformanceTools(server, READ_CTX); + const result = await server.invoke({ projectId: READ_CTX.projectId }) as any; + const content = JSON.parse(result.content[0].text); + + expect(content.pages[0].seo_signals.high_bounce).toBe(false); + }); + + it('marks low_engagement when avg_duration < 1', async () => { + mockGetTopPages.mockResolvedValue([makePage({ avg_duration: 0.5, bounce_rate: 30 })]); + + const server = makeServer() as any; + registerPagePerformanceTools(server, READ_CTX); + const result = await server.invoke({ projectId: READ_CTX.projectId }) as any; + const content = JSON.parse(result.content[0].text); + + expect(content.pages[0].seo_signals.low_engagement).toBe(true); + }); + + it('marks good_landing_page when bounce_rate < 40 and avg_duration > 2', async () => { + mockGetTopPages.mockResolvedValue([makePage({ bounce_rate: 25, avg_duration: 3 })]); + + const server = makeServer() as any; + registerPagePerformanceTools(server, READ_CTX); + const result = await server.invoke({ projectId: READ_CTX.projectId }) as any; + const content = JSON.parse(result.content[0].text); + + expect(content.pages[0].seo_signals.good_landing_page).toBe(true); + expect(content.pages[0].seo_signals.high_bounce).toBe(false); + expect(content.pages[0].seo_signals.low_engagement).toBe(false); + }); + + it('does not mark good_landing_page when bounce_rate is exactly 40', async () => { + mockGetTopPages.mockResolvedValue([makePage({ bounce_rate: 40, avg_duration: 3 })]); + + const server = makeServer() as any; + registerPagePerformanceTools(server, READ_CTX); + const result = await server.invoke({ projectId: READ_CTX.projectId }) as any; + const content = JSON.parse(result.content[0].text); + + expect(content.pages[0].seo_signals.good_landing_page).toBe(false); + }); +}); + +describe('get_page_performance — sorting', () => { + const pages = [ + makePage({ path: '/a', bounce_rate: 20, sessions: 10 }), + makePage({ path: '/b', bounce_rate: 80, sessions: 50 }), + makePage({ path: '/c', bounce_rate: 50, sessions: 30 }), + ]; + + it('sorts by sessions descending by default', async () => { + mockGetTopPages.mockResolvedValue([...pages]); + + const server = makeServer() as any; + registerPagePerformanceTools(server, READ_CTX); + const result = await server.invoke({ projectId: READ_CTX.projectId }) as any; + const content = JSON.parse(result.content[0].text); + const paths = content.pages.map((p: any) => p.path); + + expect(paths).toEqual(['/b', '/c', '/a']); + }); + + it('sorts by bounce_rate descending', async () => { + mockGetTopPages.mockResolvedValue([...pages]); + + const server = makeServer() as any; + registerPagePerformanceTools(server, READ_CTX); + const result = await server.invoke({ projectId: READ_CTX.projectId, sortBy: 'bounce_rate', sortOrder: 'desc' }) as any; + const content = JSON.parse(result.content[0].text); + const paths = content.pages.map((p: any) => p.path); + + expect(paths).toEqual(['/b', '/c', '/a']); + }); + + it('sorts by bounce_rate ascending', async () => { + mockGetTopPages.mockResolvedValue([...pages]); + + const server = makeServer() as any; + registerPagePerformanceTools(server, READ_CTX); + const result = await server.invoke({ projectId: READ_CTX.projectId, sortBy: 'bounce_rate', sortOrder: 'asc' }) as any; + const content = JSON.parse(result.content[0].text); + const paths = content.pages.map((p: any) => p.path); + + expect(paths).toEqual(['/a', '/c', '/b']); + }); + + it('respects limit', async () => { + mockGetTopPages.mockResolvedValue([...pages]); + + const server = makeServer() as any; + registerPagePerformanceTools(server, READ_CTX); + const result = await server.invoke({ projectId: READ_CTX.projectId, limit: 2 }) as any; + const content = JSON.parse(result.content[0].text); + + expect(content.pages).toHaveLength(2); + expect(content.shown).toBe(2); + expect(content.total_pages).toBe(3); + }); +}); + +describe('get_page_performance — metadata', () => { + it('returns total_pages and shown counts', async () => { + const manyPages = Array.from({ length: 10 }, (_, i) => + makePage({ path: `/page-${i}` }), + ); + mockGetTopPages.mockResolvedValue(manyPages); + + const server = makeServer() as any; + registerPagePerformanceTools(server, READ_CTX); + const result = await server.invoke({ projectId: READ_CTX.projectId, limit: 5 }) as any; + const content = JSON.parse(result.content[0].text); + + expect(content.total_pages).toBe(10); + expect(content.shown).toBe(5); + }); + + it('returns empty pages array when no data', async () => { + mockGetTopPages.mockResolvedValue([]); + + const server = makeServer() as any; + registerPagePerformanceTools(server, READ_CTX); + const result = await server.invoke({ projectId: READ_CTX.projectId }) as any; + const content = JSON.parse(result.content[0].text); + + expect(content.pages).toEqual([]); + expect(content.total_pages).toBe(0); + }); +}); diff --git a/packages/mcp/src/tools/analytics/page-performance.ts b/packages/mcp/src/tools/analytics/page-performance.ts new file mode 100644 index 000000000..416a12bd1 --- /dev/null +++ b/packages/mcp/src/tools/analytics/page-performance.ts @@ -0,0 +1,86 @@ +import { PagesService, ch, getSettingsForProject } from '@openpanel/db'; +import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { z } from 'zod'; +import type { McpAuthContext } from '../../auth'; +import { + projectIdSchema, + resolveDateRange, + + withErrorHandling, + zDateRange, + resolveProjectId +} from '../shared'; + +const pagesService = new PagesService(ch); + +export function registerPagePerformanceTools( + server: McpServer, + context: McpAuthContext, +) { + server.tool( + 'get_page_performance', + 'Get per-page performance metrics including bounce rate, avg session duration, sessions, and pageviews. Sort by bounce_rate to find high-bounce landing pages, or by avg_duration to find low-engagement content. Essential for SEO and CRO analysis.', + { + projectId: projectIdSchema(context), + ...zDateRange, + search: z + .string() + .optional() + .describe('Filter pages by path or title (partial match)'), + sortBy: z + .enum(['sessions', 'pageviews', 'bounce_rate', 'avg_duration']) + .default('sessions') + .optional() + .describe('Sort results by this metric (default: sessions)'), + sortOrder: z + .enum(['asc', 'desc']) + .default('desc') + .optional() + .describe('Sort direction (default: desc)'), + limit: z + .number() + .int() + .min(1) + .max(500) + .default(50) + .optional() + .describe('Maximum number of pages to return (default 50)'), + }, + async ({ projectId: inputProjectId, startDate: sd, endDate: ed, search, sortBy, sortOrder, limit }) => + withErrorHandling(async () => { + const projectId = await resolveProjectId(context, inputProjectId); + const { startDate, endDate } = resolveDateRange(sd, ed); + const { timezone } = await getSettingsForProject(projectId); + + const pages = await pagesService.getTopPages({ + projectId, + startDate, + endDate, + timezone, + search, + limit: 1000, // fetch more, sort+slice in memory for flexibility + }); + + const col = sortBy ?? 'sessions'; + const dir = sortOrder === 'asc' ? 1 : -1; + const sorted = [...pages].sort((a, b) => dir * ((a[col] ?? 0) < (b[col] ?? 0) ? -1 : 1)); + const results = sorted.slice(0, limit ?? 50); + + // Annotate with SEO signals + const annotated = results.map((p) => ({ + ...p, + seo_signals: { + high_bounce: p.bounce_rate > 70, + low_engagement: p.avg_duration < 1, + good_landing_page: p.bounce_rate < 40 && p.avg_duration > 2, + }, + })); + + return { + total_pages: pages.length, + shown: annotated.length, + pages: annotated, + }; + }), + ); +} diff --git a/packages/mcp/src/tools/analytics/pages.ts b/packages/mcp/src/tools/analytics/pages.ts new file mode 100644 index 000000000..24fae6556 --- /dev/null +++ b/packages/mcp/src/tools/analytics/pages.ts @@ -0,0 +1,50 @@ +import { getEntryExitPagesCore, getTopPagesCore } from '@openpanel/db'; + +import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { z } from 'zod'; +import type { McpAuthContext } from '../../auth'; +import { + projectIdSchema, + resolveDateRange, + + withErrorHandling, + zDateRange, + resolveProjectId +} from '../shared'; + +export function registerPageTools(server: McpServer, context: McpAuthContext) { + server.tool( + 'get_top_pages', + 'Get the most visited pages ranked by page views, with unique visitor counts and other engagement metrics.', + { + projectId: projectIdSchema(context), + ...zDateRange, + }, + async ({ projectId: inputProjectId, startDate: sd, endDate: ed }) => + withErrorHandling(async () => { + const projectId = await resolveProjectId(context, inputProjectId); + const { startDate, endDate } = resolveDateRange(sd, ed); + return getTopPagesCore({ projectId, startDate, endDate }); + }), + ); + + server.tool( + 'get_entry_exit_pages', + 'Get the most common entry pages (first page in a session) or exit pages (last page in a session).', + { + projectId: projectIdSchema(context), + ...zDateRange, + mode: z + .enum(['entry', 'exit']) + .describe( + '"entry" for pages visitors land on first, "exit" for pages they leave from', + ), + }, + async ({ projectId: inputProjectId, startDate: sd, endDate: ed, mode }) => + withErrorHandling(async () => { + const projectId = await resolveProjectId(context, inputProjectId); + const { startDate, endDate } = resolveDateRange(sd, ed); + return getEntryExitPagesCore({ projectId, startDate, endDate, mode }); + }), + ); +} diff --git a/packages/mcp/src/tools/analytics/profile-metrics.ts b/packages/mcp/src/tools/analytics/profile-metrics.ts new file mode 100644 index 000000000..b1a81f177 --- /dev/null +++ b/packages/mcp/src/tools/analytics/profile-metrics.ts @@ -0,0 +1,48 @@ +import { getProfileMetrics } from '@openpanel/db'; +import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { z } from 'zod'; +import type { McpAuthContext } from '../../auth'; +import { + projectIdSchema, + + withErrorHandling, + resolveProjectId +} from '../shared'; + +export function registerProfileMetricTools( + server: McpServer, + context: McpAuthContext, +) { + server.tool( + 'get_profile_metrics', + 'Get computed lifetime metrics for a specific user: sessions, screen views, total events, avg session duration (p50/p90), bounce rate, unique active days, conversion events, avg time between sessions, and total revenue. Useful for understanding individual user health at a glance.', + { + projectId: projectIdSchema(context), + profileId: z.string().describe('The profile ID to get metrics for'), + }, + async ({ projectId: inputProjectId, profileId }) => + withErrorHandling(async () => { + const projectId = await resolveProjectId(context, inputProjectId); + const raw = await getProfileMetrics(profileId, projectId); + if (!raw) { + return { error: 'Profile not found or has no events', profileId }; + } + return { + profileId, + firstSeen: raw.firstSeen, + lastSeen: raw.lastSeen, + sessions: raw.sessions, + screenViews: raw.screenViews, + totalEvents: raw.totalEvents, + conversionEvents: raw.conversionEvents, + uniqueDaysActive: raw.uniqueDaysActive, + avgSessionDurationMin: raw.durationAvg, + p90SessionDurationMin: raw.durationP90, + avgEventsPerSession: raw.avgEventsPerSession, + avgTimeBetweenSessionsSec: raw.avgTimeBetweenSessions, + bounceRate: raw.bounceRate, + revenue: raw.revenue, + }; + }), + ); +} diff --git a/packages/mcp/src/tools/analytics/profiles.test.ts b/packages/mcp/src/tools/analytics/profiles.test.ts new file mode 100644 index 000000000..a59336e9e --- /dev/null +++ b/packages/mcp/src/tools/analytics/profiles.test.ts @@ -0,0 +1,162 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const mockChQuery = vi.hoisted(() => vi.fn().mockResolvedValue([])); + +// Mock the ClickHouse client WITHOUT importOriginal — using importOriginal +// causes the real module to be bound into profile.service.ts before the mock +// factory result is visible, bypassing the chQuery replacement. +vi.mock('../../../../db/src/clickhouse/client', () => { + return { + chQuery: mockChQuery, + chQueryWithMeta: vi.fn().mockResolvedValue({ data: [], rows: 0 }), + ch: {}, + originalCh: {}, + createClient: () => ({}), + withRetry: vi.fn().mockImplementation((fn: () => unknown) => fn()), + isClickhouseClustered: () => false, + getReplicatedTableName: (name: string) => name, + CLICKHOUSE_OPTIONS: {}, + TABLE_NAMES: { + events: 'events', + profiles: 'profiles', + alias: 'profile_aliases', + self_hosting: 'self_hosting', + events_bots: 'events_bots', + dau_mv: 'dau_mv', + event_names_mv: 'distinct_event_names_mv', + event_property_values_mv: 'event_property_values_mv', + cohort_events_mv: 'cohort_events_mv', + sessions: 'sessions', + events_imports: 'events_imports', + session_replay_chunks: 'session_replay_chunks', + gsc_daily: 'gsc_daily', + gsc_pages_daily: 'gsc_pages_daily', + gsc_queries_daily: 'gsc_queries_daily', + groups: 'groups', + }, + formatClickhouseDate: (date: Date | string, skipTime = false) => { + if (skipTime) return new Date(date).toISOString().split('T')[0]; + return new Date(date).toISOString().replace('T', ' ').replace(/(\.\d{3})?Z+$/, ''); + }, + toDate: (str: string) => str, + convertClickhouseDateToJs: (date: string) => new Date(`${date.replace(' ', 'T')}Z`), + isClickhouseDefaultMinDate: (date: string) => date.startsWith('1970-01-01') || date.startsWith('1969-12-31'), + toNullIfDefaultMinDate: () => null, + }; +}); + +import { findProfilesCore } from '../../../../db/src/services/profile.service'; + +function capturedSql(): string { + return mockChQuery.mock.calls[0]?.[0] as string; +} + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe('findProfilesCore — SQL conditions', () => { + it('always includes project_id condition', async () => { + await findProfilesCore({ projectId: 'proj-1' }); + expect(capturedSql()).toContain("project_id = 'proj-1'"); + }); + + it('adds email LIKE condition when email is provided', async () => { + await findProfilesCore({ projectId: 'proj-1', email: 'carl@' }); + expect(capturedSql()).toContain("email LIKE '%carl@%'"); + }); + + it('searches both first_name and last_name for name filter', async () => { + await findProfilesCore({ projectId: 'proj-1', name: 'Carl' }); + const sql = capturedSql(); + expect(sql).toContain('first_name LIKE'); + expect(sql).toContain('last_name LIKE'); + expect(sql).toContain('%Carl%'); + }); + + it('adds country property condition', async () => { + await findProfilesCore({ projectId: 'proj-1', country: 'SE' }); + expect(capturedSql()).toContain("properties['country'] = 'SE'"); + }); + + it('adds inactiveDays NOT IN subquery', async () => { + await findProfilesCore({ projectId: 'proj-1', inactiveDays: 14 }); + const sql = capturedSql(); + expect(sql).toContain('NOT IN'); + expect(sql).toContain('INTERVAL 14 DAY'); + }); + + it('floors inactiveDays to integer (prevents SQL injection via floats)', async () => { + await findProfilesCore({ projectId: 'proj-1', inactiveDays: 14.9 }); + expect(capturedSql()).toContain('INTERVAL 14 DAY'); + expect(capturedSql()).not.toContain('14.9'); + }); + + it('adds minSessions HAVING subquery', async () => { + await findProfilesCore({ projectId: 'proj-1', minSessions: 5 }); + const sql = capturedSql(); + expect(sql).toContain('HAVING count() >= 5'); + }); + + it('adds performedEvent IN subquery', async () => { + await findProfilesCore({ projectId: 'proj-1', performedEvent: 'purchase' }); + expect(capturedSql()).toContain("name = 'purchase'"); + }); + + it('defaults to ORDER BY created_at DESC', async () => { + await findProfilesCore({ projectId: 'proj-1' }); + expect(capturedSql()).toContain('ORDER BY created_at DESC'); + }); + + it('respects sortOrder: asc', async () => { + await findProfilesCore({ projectId: 'proj-1', sortOrder: 'asc' }); + expect(capturedSql()).toContain('ORDER BY created_at ASC'); + }); + + it('defaults limit to 20', async () => { + await findProfilesCore({ projectId: 'proj-1' }); + expect(capturedSql()).toContain('LIMIT 20'); + }); + + it('caps limit at 100 regardless of input', async () => { + await findProfilesCore({ projectId: 'proj-1', limit: 9999 }); + expect(capturedSql()).toContain('LIMIT 100'); + expect(capturedSql()).not.toContain('LIMIT 9999'); + }); +}); + +describe('findProfilesCore — SQL injection protection', () => { + it('escapes single quotes in string values', async () => { + await findProfilesCore({ projectId: "proj'; DROP TABLE profiles;--" }); + // The projectId must be escaped — raw SQL injection string must not appear + expect(capturedSql()).not.toContain("proj'; DROP TABLE profiles;--"); + }); + + it('escapes single quotes in name search', async () => { + await findProfilesCore({ projectId: 'proj-1', name: "O'Brien" }); + // Unescaped apostrophe in the SQL would break the query + const sql = capturedSql(); + expect(sql).not.toMatch(/LIKE '%O'Brien%'/); + }); + + it('escapes backslashes in email', async () => { + await findProfilesCore({ projectId: 'proj-1', email: 'test\\@x.com' }); + // Raw backslash in ClickHouse SQL needs escaping + expect(capturedSql()).not.toContain("'%test\\@x.com%'"); + }); +}); + +describe('findProfilesCore — return value', () => { + it('returns whatever chQuery resolves with', async () => { + const fakeProfiles = [{ id: 'p1', first_name: 'Alice' }]; + mockChQuery.mockResolvedValueOnce(fakeProfiles); + const result = await findProfilesCore({ projectId: 'proj-1' }); + expect(result).toEqual(fakeProfiles); + }); + + it('returns empty array when no profiles found', async () => { + mockChQuery.mockResolvedValueOnce([]); + const result = await findProfilesCore({ projectId: 'proj-1' }); + expect(result).toEqual([]); + }); +}); diff --git a/packages/mcp/src/tools/analytics/profiles.ts b/packages/mcp/src/tools/analytics/profiles.ts new file mode 100644 index 000000000..9a24b436d --- /dev/null +++ b/packages/mcp/src/tools/analytics/profiles.ts @@ -0,0 +1,142 @@ +import { findProfilesCore, getProfileSessionsCore, getProfileWithEvents } from '@openpanel/db'; + +import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { z } from 'zod'; +import type { McpAuthContext } from '../../auth'; +import { profileUrl, sessionUrl } from '../dashboard-links'; +import { + projectIdSchema, + + withErrorHandling, + resolveProjectId +} from '../shared'; + +export function registerProfileTools( + server: McpServer, + context: McpAuthContext, +) { + server.tool( + 'find_profiles', + 'Search and filter user profiles. Supports filtering by name, email, location, inactivity, session count, and whether they performed a specific event. Defaults to the 20 most recently created profiles.', + { + projectId: projectIdSchema(context), + name: z + .string() + .optional() + .describe('Partial match against first name or last name (e.g. "Carl")'), + email: z + .string() + .optional() + .describe('Partial email match'), + country: z + .string() + .optional() + .describe('Filter by ISO 3166-1 alpha-2 country code (e.g. US, SE)'), + city: z.string().optional().describe('Filter by city name'), + device: z + .string() + .optional() + .describe('Filter by device type (desktop, mobile, tablet)'), + browser: z.string().optional().describe('Filter by browser name'), + inactiveDays: z + .number() + .int() + .min(1) + .optional() + .describe( + 'Return only profiles with no activity (events) in the last N days. E.g. 14 = inactive for 2+ weeks.', + ), + minSessions: z + .number() + .int() + .min(1) + .optional() + .describe('Return only profiles with at least N total sessions'), + performedEvent: z + .string() + .optional() + .describe( + 'Return only profiles that have performed this event at least once (e.g. "purchase", "sign_up")', + ), + sortOrder: z + .enum(['asc', 'desc']) + .default('desc') + .optional() + .describe('Sort direction for created_at (default: desc = newest first)'), + limit: z + .number() + .min(1) + .max(100) + .default(20) + .optional() + .describe('Maximum number of profiles to return (1-100, default 20)'), + }, + async ({ projectId: inputProjectId, ...input }) => + withErrorHandling(async () => { + const projectId = await resolveProjectId(context, inputProjectId); + const profiles = await findProfilesCore({ projectId, ...input }); + return profiles.map((p) => ({ + ...p, + dashboard_url: profileUrl(context.organizationId, projectId, p.id), + })); + }), + ); + + server.tool( + 'get_profile', + 'Get a specific user profile by ID along with their most recent events. Useful for understanding an individual user journey.', + { + projectId: projectIdSchema(context), + profileId: z.string().describe('The profile ID to look up'), + eventLimit: z + .number() + .min(1) + .max(100) + .default(20) + .optional() + .describe('Number of recent events to include (1-100, default 20)'), + }, + async ({ projectId: inputProjectId, profileId, eventLimit }) => + withErrorHandling(async () => { + const projectId = await resolveProjectId(context, inputProjectId); + const result = await getProfileWithEvents(projectId, profileId, eventLimit); + if (!result.profile) { + return { error: 'Profile not found', profileId }; + } + return { + ...result, + dashboard_url: profileUrl(context.organizationId, projectId, profileId), + }; + }), + ); + + server.tool( + 'get_profile_sessions', + 'Get all sessions for a specific user profile, ordered by most recent first. Each session includes duration, entry/exit pages, device info, and referrer.', + { + projectId: projectIdSchema(context), + profileId: z.string().describe('The profile ID to fetch sessions for'), + limit: z + .number() + .min(1) + .max(100) + .default(20) + .optional() + .describe('Maximum number of sessions to return (1-100, default 20)'), + }, + async ({ projectId: inputProjectId, profileId, limit }) => + withErrorHandling(async () => { + const projectId = await resolveProjectId(context, inputProjectId); + const sessions = await getProfileSessionsCore(projectId, profileId, limit); + return { + profileId, + dashboard_url: profileUrl(context.organizationId, projectId, profileId), + session_count: sessions.length, + sessions: sessions.map((s) => ({ + ...s, + dashboard_url: sessionUrl(context.organizationId, projectId, s.id), + })), + }; + }), + ); +} diff --git a/packages/mcp/src/tools/analytics/property-values.ts b/packages/mcp/src/tools/analytics/property-values.ts new file mode 100644 index 000000000..9b5dc4ae0 --- /dev/null +++ b/packages/mcp/src/tools/analytics/property-values.ts @@ -0,0 +1,80 @@ +import { TABLE_NAMES, ch, clix } from '@openpanel/db'; +import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { z } from 'zod'; +import type { McpAuthContext } from '../../auth'; +import { + projectIdSchema, + + withErrorHandling, + resolveProjectId +} from '../shared'; + +export function registerPropertyValueTools( + server: McpServer, + context: McpAuthContext, +) { + server.tool( + 'list_event_properties', + 'List all property keys that have been tracked for a specific event (or across all events). Use this to discover what data is available before filtering or breaking down by a property.', + { + projectId: projectIdSchema(context), + eventName: z + .string() + .optional() + .describe('Filter to a specific event name. Omit to list properties across all events.'), + }, + async ({ projectId: inputProjectId, eventName }) => + withErrorHandling(async () => { + const projectId = await resolveProjectId(context, inputProjectId); + const builder = clix(ch) + .select<{ property_key: string; event_name: string }>([ + 'distinct property_key', + 'name as event_name', + ]) + .from(TABLE_NAMES.event_property_values_mv) + .where('project_id', '=', projectId) + .orderBy('property_key', 'ASC') + .limit(500); + + if (eventName) { + builder.where('name', '=', eventName); + } + + const rows = await builder.execute(); + return { properties: rows }; + }), + ); + + server.tool( + 'get_event_property_values', + 'Get all distinct values for a specific event property. Use this to understand what values exist before filtering (e.g. what plans exist in "plan" property, what countries, what status values).', + { + projectId: projectIdSchema(context), + eventName: z + .string() + .describe('The event name to look up property values for (e.g. "subscription_created")'), + propertyKey: z + .string() + .describe('The property key to get values for (e.g. "plan", "country", "status")'), + }, + async ({ projectId: inputProjectId, eventName, propertyKey }) => + withErrorHandling(async () => { + const projectId = await resolveProjectId(context, inputProjectId); + const rows = await clix(ch) + .select<{ value: string }>(['property_value as value']) + .from(TABLE_NAMES.event_property_values_mv) + .where('project_id', '=', projectId) + .where('name', '=', eventName) + .where('property_key', '=', propertyKey) + .orderBy('created_at', 'DESC') + .limit(200) + .execute(); + + return { + event: eventName, + property: propertyKey, + values: rows.map((r) => r.value), + }; + }), + ); +} diff --git a/packages/mcp/src/tools/analytics/reports.ts b/packages/mcp/src/tools/analytics/reports.ts new file mode 100644 index 000000000..97192c9b4 --- /dev/null +++ b/packages/mcp/src/tools/analytics/reports.ts @@ -0,0 +1,137 @@ +import { + AggregateChartEngine, + ChartEngine, + db, + funnelService, + getChartStartEndDate, + getReportById, + getReportsByDashboardId, + getSettingsForProject} from '@openpanel/db'; +import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { z } from 'zod'; +import type { McpAuthContext } from '../../auth'; +import { dashboardBaseUrl } from '../dashboard-links'; +import { projectIdSchema, resolveProjectId, withErrorHandling } from '../shared'; + +function reportUrl(organizationId: string, projectId: string, reportId: string) { + return `${dashboardBaseUrl()}/${organizationId}/${projectId}/reports/${reportId}`; +} + +function dashboardUrl(organizationId: string, projectId: string, dashboardId: string) { + return `${dashboardBaseUrl()}/${organizationId}/${projectId}/dashboards/${dashboardId}`; +} + +export function registerReportTools( + server: McpServer, + context: McpAuthContext, +) { + server.tool( + 'list_dashboards', + 'List all dashboards for a project. Returns dashboard IDs and names. Use these IDs with list_reports to see what reports each dashboard contains.', + { + projectId: projectIdSchema(context), + }, + async ({ projectId: inputProjectId }) => + withErrorHandling(async () => { + const projectId = await resolveProjectId(context, inputProjectId); + const dashboards = await db.dashboard.findMany({ + where: { projectId }, + orderBy: { createdAt: 'desc' }, + select: { id: true, name: true, projectId: true }, + }); + return dashboards.map((d) => ({ + ...d, + dashboard_url: dashboardUrl(context.organizationId, projectId, d.id), + })); + }), + ); + + server.tool( + 'list_reports', + 'List all reports in a dashboard. Returns report IDs, names, chart types, and the events/metrics they track. Use get_report_data to execute a report and retrieve its actual data.', + { + projectId: projectIdSchema(context), + dashboardId: z.string().describe('The dashboard ID to list reports for'), + }, + async ({ projectId: inputProjectId, dashboardId }) => + withErrorHandling(async () => { + const projectId = await resolveProjectId(context, inputProjectId); + const reports = await getReportsByDashboardId(dashboardId); + if (reports.some((r) => r.projectId !== projectId)) { + throw new Error('Dashboard does not belong to this project'); + } + return reports.map((r) => ({ + id: r.id, + name: r.name, + chartType: r.chartType, + range: r.range, + interval: r.interval, + metric: r.metric, + series: r.series.map((s) => + s.type === 'formula' + ? { type: 'formula', id: s.id, formula: s.formula } + : { + type: 'event', + id: s.id, + name: s.name, + displayName: s.displayName, + segment: s.segment, + }, + ), + breakdowns: r.breakdowns, + dashboard_url: reportUrl(context.organizationId, projectId, r.id), + })); + }), + ); + + server.tool( + 'get_report_data', + 'Execute a saved report and return its data. Works for all chart types: linear/bar/area/pie/map (time-series or breakdowns), metric (aggregate numbers), and funnel (conversion steps). Pass the report ID from list_reports.', + { + projectId: projectIdSchema(context), + reportId: z.string().describe('The report ID to execute'), + }, + async ({ projectId: inputProjectId, reportId }) => + withErrorHandling(async () => { + const projectId = await resolveProjectId(context, inputProjectId); + const report = await getReportById(reportId); + + if (!report) { + return { error: 'Report not found', reportId }; + } + + if (report.projectId !== projectId) { + return { error: 'Report does not belong to this project', reportId }; + } + + const { timezone } = await getSettingsForProject(projectId); + const { startDate, endDate } = getChartStartEndDate(report, timezone); + const chartInput = { ...report, startDate, endDate, timezone }; + + const meta = { + id: report.id, + name: report.name, + chartType: report.chartType, + range: report.range, + interval: report.interval, + startDate, + endDate, + dashboard_url: reportUrl(context.organizationId, projectId, reportId), + }; + + if (report.chartType === 'funnel') { + const result = await funnelService.getFunnel(chartInput); + return { ...meta, data: result }; + } + + if (report.chartType === 'metric') { + const result = await AggregateChartEngine.execute(chartInput); + return { ...meta, data: result }; + } + + // linear, bar, histogram, pie, area, map, etc. + const result = await ChartEngine.execute(chartInput); + return { ...meta, data: result }; + }), + ); +} diff --git a/packages/mcp/src/tools/analytics/retention.ts b/packages/mcp/src/tools/analytics/retention.ts new file mode 100644 index 000000000..e831bab05 --- /dev/null +++ b/packages/mcp/src/tools/analytics/retention.ts @@ -0,0 +1,27 @@ +import { getRetentionCohortTable } from '@openpanel/db'; +import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import type { McpAuthContext } from '../../auth'; +import { + projectIdSchema, + + withErrorHandling, + resolveProjectId +} from '../shared'; + +export function registerRetentionTools( + server: McpServer, + context: McpAuthContext, +) { + server.tool( + 'get_retention_cohort', + 'Get a weekly user retention cohort table. Shows what percentage of users who first visited in a given week returned in subsequent weeks. Useful for understanding long-term user engagement and product stickiness.', + { + projectId: projectIdSchema(context), + }, + async ({ projectId: inputProjectId }) => + withErrorHandling(async () => { + const projectId = await resolveProjectId(context, inputProjectId); + return getRetentionCohortTable({ projectId }); + }), + ); +} diff --git a/packages/mcp/src/tools/analytics/sessions.ts b/packages/mcp/src/tools/analytics/sessions.ts new file mode 100644 index 000000000..4b1b40e1c --- /dev/null +++ b/packages/mcp/src/tools/analytics/sessions.ts @@ -0,0 +1,63 @@ +import { querySessionsCore } from '@openpanel/db'; +import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { z } from 'zod'; +import type { McpAuthContext } from '../../auth'; +import { sessionUrl } from '../dashboard-links'; +import { + projectIdSchema, + + withErrorHandling, + zDateRange, + resolveProjectId +} from '../shared'; + +export function registerSessionTools( + server: McpServer, + context: McpAuthContext, +) { + server.tool( + 'query_sessions', + 'Query user sessions with optional filters. Each session represents a single visit with duration, entry/exit pages, bounce status, and attribution data. Defaults to the last 30 days.', + { + projectId: projectIdSchema(context), + ...zDateRange, + country: z + .string() + .optional() + .describe('Filter by ISO 3166-1 alpha-2 country code'), + city: z.string().optional().describe('Filter by city name'), + device: z + .string() + .optional() + .describe('Filter by device type (desktop, mobile, tablet)'), + browser: z.string().optional().describe('Filter by browser name'), + os: z.string().optional().describe('Filter by OS name'), + referrer: z.string().optional().describe('Filter by referrer URL'), + referrerName: z.string().optional().describe('Filter by referrer name'), + referrerType: z + .string() + .optional() + .describe('Filter by referrer type (search, social, email, direct)'), + profileId: z + .string() + .optional() + .describe('Filter sessions for a specific user profile ID'), + limit: z + .number() + .min(1) + .max(100) + .default(20) + .optional() + .describe('Maximum number of sessions to return (1-100, default 20)'), + }, + async ({ projectId: inputProjectId, ...input }) => + withErrorHandling(async () => { + const projectId = await resolveProjectId(context, inputProjectId); + const sessions = await querySessionsCore({ projectId, ...input }); + return sessions.map((s) => ({ + ...s, + dashboard_url: sessionUrl(context.organizationId, projectId, s.id), + })); + }), + ); +} diff --git a/packages/mcp/src/tools/analytics/traffic.ts b/packages/mcp/src/tools/analytics/traffic.ts new file mode 100644 index 000000000..1c9fa2147 --- /dev/null +++ b/packages/mcp/src/tools/analytics/traffic.ts @@ -0,0 +1,96 @@ +import { getTrafficBreakdownCore, type TrafficColumn } from '@openpanel/db'; +import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { z } from 'zod'; +import type { McpAuthContext } from '../../auth'; +import { + projectIdSchema, + resolveDateRange, + + withErrorHandling, + zDateRange, + resolveProjectId +} from '../shared'; + +export function registerTrafficTools( + server: McpServer, + context: McpAuthContext, +) { + server.tool( + 'get_top_referrers', + 'Get the top traffic sources driving visitors to the site, broken down by referrer name and type.', + { + projectId: projectIdSchema(context), + ...zDateRange, + breakdown: z + .enum(['referrer_name', 'referrer_type', 'referrer', 'utm_source', 'utm_medium', 'utm_campaign']) + .default('referrer_name') + .optional() + .describe( + 'How to group referrers: by name (Google, Twitter), type (search, social), full URL, or UTM params', + ), + }, + async ({ projectId: inputProjectId, startDate: sd, endDate: ed, breakdown }) => + withErrorHandling(async () => { + const projectId = await resolveProjectId(context, inputProjectId); + const { startDate, endDate } = resolveDateRange(sd, ed); + return getTrafficBreakdownCore({ + projectId, + startDate, + endDate, + column: (breakdown ?? 'referrer_name') as TrafficColumn, + }); + }), + ); + + server.tool( + 'get_country_breakdown', + 'Get visitor counts broken down by country, region, or city.', + { + projectId: projectIdSchema(context), + ...zDateRange, + breakdown: z + .enum(['country', 'region', 'city']) + .default('country') + .optional() + .describe('Geographic grouping level (default: country)'), + }, + async ({ projectId: inputProjectId, startDate: sd, endDate: ed, breakdown }) => + withErrorHandling(async () => { + const projectId = await resolveProjectId(context, inputProjectId); + const { startDate, endDate } = resolveDateRange(sd, ed); + return getTrafficBreakdownCore({ + projectId, + startDate, + endDate, + column: (breakdown ?? 'country') as TrafficColumn, + }); + }), + ); + + server.tool( + 'get_device_breakdown', + 'Get visitor counts broken down by device type, browser, or operating system.', + { + projectId: projectIdSchema(context), + ...zDateRange, + breakdown: z + .enum(['device', 'browser', 'os']) + .default('device') + .optional() + .describe( + 'Device dimension: "device" (desktop/mobile/tablet), "browser" (Chrome/Firefox), or "os" (Windows/macOS)', + ), + }, + async ({ projectId: inputProjectId, startDate: sd, endDate: ed, breakdown }) => + withErrorHandling(async () => { + const projectId = await resolveProjectId(context, inputProjectId); + const { startDate, endDate } = resolveDateRange(sd, ed); + return getTrafficBreakdownCore({ + projectId, + startDate, + endDate, + column: (breakdown ?? 'device') as TrafficColumn, + }); + }), + ); +} diff --git a/packages/mcp/src/tools/analytics/user-flow.ts b/packages/mcp/src/tools/analytics/user-flow.ts new file mode 100644 index 000000000..48e9035ea --- /dev/null +++ b/packages/mcp/src/tools/analytics/user-flow.ts @@ -0,0 +1,62 @@ +import { getUserFlowCore } from '@openpanel/db'; + +import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { z } from 'zod'; +import type { McpAuthContext } from '../../auth'; +import { + projectIdSchema, + resolveDateRange, + + withErrorHandling, + zDateRange, + resolveProjectId +} from '../shared'; + +export function registerUserFlowTools( + server: McpServer, + context: McpAuthContext, +) { + server.tool( + 'get_user_flow', + 'Visualize user navigation flows as a Sankey diagram. Shows what events/pages users visit in sequence. Use mode "after" to see what happens after an event, "before" to see what leads up to it, or "between" to map paths from one event to another.', + { + projectId: projectIdSchema(context), + ...zDateRange, + startEvent: z + .string() + .describe('The anchor event name. For "after"/"before" mode this is the pivot event; for "between" it is the start.'), + endEvent: z + .string() + .optional() + .describe('Required for "between" mode: the destination event name.'), + mode: z + .enum(['after', 'before', 'between']) + .default('after') + .describe( + '"after" = what users do after startEvent; "before" = what leads up to startEvent; "between" = paths from startEvent to endEvent.', + ), + steps: z + .number() + .int() + .min(2) + .max(10) + .default(5) + .optional() + .describe('Number of steps to show in the flow (2-10, default 5)'), + exclude: z + .array(z.string()) + .optional() + .describe('Event names to exclude from the flow (e.g. noisy system events)'), + include: z + .array(z.string()) + .optional() + .describe('If set, only show these event names in the flow'), + }, + async ({ projectId: inputProjectId, startDate: sd, endDate: ed, startEvent, endEvent, mode, steps, exclude, include }) => + withErrorHandling(async () => { + const projectId = await resolveProjectId(context, inputProjectId); + const { startDate, endDate } = resolveDateRange(sd, ed); + return getUserFlowCore({ projectId, startDate, endDate, startEvent, endEvent, mode, steps, exclude, include }); + }), + ); +} diff --git a/packages/mcp/src/tools/dashboard-links.ts b/packages/mcp/src/tools/dashboard-links.ts new file mode 100644 index 000000000..c38294a80 --- /dev/null +++ b/packages/mcp/src/tools/dashboard-links.ts @@ -0,0 +1,72 @@ +import { } from '@openpanel/db'; +import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { z } from 'zod'; +import type { McpAuthContext } from '../auth'; +import { projectIdSchema, withErrorHandling, + resolveProjectId +} from './shared'; + +export function dashboardBaseUrl() { + return ( + process.env.DASHBOARD_URL || + process.env.NEXT_PUBLIC_DASHBOARD_URL || + 'https://dashboard.openpanel.dev' + ).replace(/\/$/, ''); +} + +export function profileUrl(organizationId: string, projectId: string, profileId: string) { + return `${dashboardBaseUrl()}/${organizationId}/${projectId}/profiles/${profileId}`; +} + +export function sessionUrl(organizationId: string, projectId: string, sessionId: string) { + return `${dashboardBaseUrl()}/${organizationId}/${projectId}/sessions/${sessionId}`; +} + +export function registerDashboardLinkTools( + server: McpServer, + context: McpAuthContext, +) { + server.tool( + 'get_dashboard_urls', + 'Get clickable dashboard URLs for the current project. Returns links to all main sections (overview, events, profiles, sessions, etc.) and optionally deep-links to a specific profile, session, dashboard, or report when their IDs are provided. Use these links to let the user navigate directly to relevant pages.', + { + projectId: projectIdSchema(context), + profileId: z.string().optional().describe('Profile ID to get a direct link to that profile'), + sessionId: z.string().optional().describe('Session ID to get a direct link to that session'), + dashboardId: z.string().optional().describe('Dashboard ID to get a direct link to that dashboard'), + reportId: z.string().optional().describe('Report ID to get a direct link to that report'), + }, + async ({ projectId: inputProjectId, profileId, sessionId, dashboardId, reportId }) => + withErrorHandling(async () => { + const projectId = await resolveProjectId(context, inputProjectId); + const base = `${dashboardBaseUrl()}/${context.organizationId}/${projectId}`; + + const urls: Record = { + overview: base, + events: `${base}/events`, + profiles: `${base}/profiles`, + sessions: `${base}/sessions`, + dashboards: `${base}/dashboards`, + reports: `${base}/reports`, + realtime: `${base}/realtime`, + pages: `${base}/pages`, + insights: `${base}/insights`, + }; + + if (profileId) { + urls.profile = `${base}/profiles/${profileId}`; + } + if (sessionId) { + urls.session = `${base}/sessions/${sessionId}`; + } + if (dashboardId) { + urls.dashboard = `${base}/dashboards/${dashboardId}`; + } + if (reportId) { + urls.report = `${base}/reports/${reportId}`; + } + + return urls; + }), + ); +} diff --git a/packages/mcp/src/tools/gsc/cannibalization.ts b/packages/mcp/src/tools/gsc/cannibalization.ts new file mode 100644 index 000000000..cdd79e7ba --- /dev/null +++ b/packages/mcp/src/tools/gsc/cannibalization.ts @@ -0,0 +1,31 @@ +import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { getGscCannibalization } from '@openpanel/db'; +import type { McpAuthContext } from '../../auth'; +import { + projectIdSchema, + resolveDateRange, + + withErrorHandling, + zDateRange, + resolveProjectId +} from '../shared'; + +export function registerGscCannibalizationTools( + server: McpServer, + context: McpAuthContext +) { + server.tool( + 'gsc_get_cannibalization', + 'Identify keyword cannibalization: search queries where multiple pages on your site compete against each other in Google. Returns queries where 2+ pages rank, sorted by total impressions. High cannibalization can hurt rankings.', + { + projectId: projectIdSchema(context), + ...zDateRange, + }, + async ({ projectId: inputProjectId, startDate: sd, endDate: ed }) => + withErrorHandling(async () => { + const projectId = await resolveProjectId(context, inputProjectId); + const { startDate, endDate } = resolveDateRange(sd, ed); + return getGscCannibalization(projectId, startDate, endDate); + }) + ); +} diff --git a/packages/mcp/src/tools/gsc/overview.ts b/packages/mcp/src/tools/gsc/overview.ts new file mode 100644 index 000000000..9fd74238f --- /dev/null +++ b/packages/mcp/src/tools/gsc/overview.ts @@ -0,0 +1,67 @@ +import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { getGscOverview } from '@openpanel/db'; +import { z } from 'zod'; +import type { McpAuthContext } from '../../auth'; +import { + projectIdSchema, + resolveDateRange, + + withErrorHandling, + zDateRange, + resolveProjectId +} from '../shared'; + +export function registerGscOverviewTools( + server: McpServer, + context: McpAuthContext +) { + server.tool( + 'gsc_get_overview', + 'Get Google Search Console performance over time: clicks, impressions, CTR, and average position. Requires GSC to be connected for the project.', + { + projectId: projectIdSchema(context), + ...zDateRange, + interval: z + .enum(['day', 'week', 'month']) + .default('day') + .optional() + .describe('Time interval for aggregation (default: day)'), + }, + async ({ + projectId: inputProjectId, + startDate: sd, + endDate: ed, + interval, + }) => + withErrorHandling(async () => { + const projectId = await resolveProjectId(context, inputProjectId); + const { startDate, endDate } = resolveDateRange(sd, ed); + const data = await getGscOverview( + projectId, + startDate, + endDate, + interval ?? 'day' + ); + return { + data, + summary: { + total_clicks: data.reduce((s, r) => s + r.clicks, 0), + total_impressions: data.reduce((s, r) => s + r.impressions, 0), + avg_ctr: + data.length > 0 + ? Math.round( + (data.reduce((s, r) => s + r.ctr, 0) / data.length) * 10_000 + ) / 100 + : 0, + avg_position: + data.length > 0 + ? Math.round( + (data.reduce((s, r) => s + r.position, 0) / data.length) * + 10 + ) / 10 + : 0, + }, + }; + }) + ); +} diff --git a/packages/mcp/src/tools/gsc/pages.ts b/packages/mcp/src/tools/gsc/pages.ts new file mode 100644 index 000000000..0602e0d5e --- /dev/null +++ b/packages/mcp/src/tools/gsc/pages.ts @@ -0,0 +1,58 @@ +import { getGscPageDetails, getGscPages } from '@openpanel/db'; +import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { z } from 'zod'; +import type { McpAuthContext } from '../../auth'; +import { + projectIdSchema, + resolveDateRange, + + withErrorHandling, + zDateRange, + resolveProjectId +} from '../shared'; + +export function registerGscPageTools( + server: McpServer, + context: McpAuthContext, +) { + server.tool( + 'gsc_get_top_pages', + 'Get the top-performing pages from Google Search Console, ranked by clicks. Includes impressions, CTR, and average position for each page.', + { + projectId: projectIdSchema(context), + ...zDateRange, + limit: z + .number() + .min(1) + .max(1000) + .default(100) + .optional() + .describe('Maximum number of pages to return (1-1000, default 100)'), + }, + async ({ projectId: inputProjectId, startDate: sd, endDate: ed, limit }) => + withErrorHandling(async () => { + const projectId = await resolveProjectId(context, inputProjectId); + const { startDate, endDate } = resolveDateRange(sd, ed); + return getGscPages(projectId, startDate, endDate, limit ?? 100); + }), + ); + + server.tool( + 'gsc_get_page_details', + 'Get detailed Search Console performance for a specific page: time-series of clicks/impressions/CTR/position plus all queries that drive traffic to that page.', + { + projectId: projectIdSchema(context), + ...zDateRange, + page: z + .string() + .url() + .describe('The full page URL to get details for (e.g. https://example.com/blog/post)'), + }, + async ({ projectId: inputProjectId, startDate: sd, endDate: ed, page }) => + withErrorHandling(async () => { + const projectId = await resolveProjectId(context, inputProjectId); + const { startDate, endDate } = resolveDateRange(sd, ed); + return getGscPageDetails(projectId, page, startDate, endDate); + }), + ); +} diff --git a/packages/mcp/src/tools/gsc/queries.ts b/packages/mcp/src/tools/gsc/queries.ts new file mode 100644 index 000000000..64a4e1713 --- /dev/null +++ b/packages/mcp/src/tools/gsc/queries.ts @@ -0,0 +1,149 @@ +import { getGscQueryDetails, getGscQueries } from '@openpanel/db'; +import type { GscQueryOpportunity } from '@openpanel/db'; +import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { z } from 'zod'; +import type { McpAuthContext } from '../../auth'; +import { + projectIdSchema, + resolveDateRange, + + withErrorHandling, + zDateRange, + resolveProjectId +} from '../shared'; + +function computeOpportunities( + queries: Array<{ + query: string; + clicks: number; + impressions: number; + ctr: number; + position: number; + }>, +): GscQueryOpportunity[] { + const ctrBenchmarks: Record = { + '1': 0.28, + '2': 0.15, + '3': 0.11, + '4-6': 0.065, + '7-10': 0.035, + '11-20': 0.012, + }; + + function getBenchmark(position: number): number { + if (position <= 1) return ctrBenchmarks['1'] ?? 0.28; + if (position <= 2) return ctrBenchmarks['2'] ?? 0.15; + if (position <= 3) return ctrBenchmarks['3'] ?? 0.11; + if (position <= 6) return ctrBenchmarks['4-6'] ?? 0.065; + if (position <= 10) return ctrBenchmarks['7-10'] ?? 0.035; + return ctrBenchmarks['11-20'] ?? 0.012; + } + + return queries + .filter((q) => q.position >= 4 && q.position <= 20 && q.impressions >= 50) + .map((q) => { + const benchmark = getBenchmark(q.position); + const ctrGap = Math.max(0, benchmark - q.ctr); + const opportunity_score = + Math.round(q.impressions * (1 / q.position) * (1 + ctrGap) * 100) / + 100; + + let reason: string; + if (q.position <= 6) { + reason = `Position ${q.position.toFixed(1)} — one rank improvement could significantly boost clicks`; + } else if (q.ctr < benchmark * 0.5) { + reason = `CTR (${(q.ctr * 100).toFixed(1)}%) is well below expected ${(benchmark * 100).toFixed(1)}% — title/meta optimization may help`; + } else { + reason = `Position ${q.position.toFixed(1)} with ${q.impressions} impressions — push to page 1 for major gains`; + } + + return { + query: q.query, + clicks: q.clicks, + impressions: q.impressions, + ctr: Math.round(q.ctr * 10000) / 100, + position: Math.round(q.position * 10) / 10, + opportunity_score, + reason, + }; + }) + .sort((a, b) => b.opportunity_score - a.opportunity_score) + .slice(0, 50); +} + +export function registerGscQueryTools( + server: McpServer, + context: McpAuthContext, +) { + server.tool( + 'gsc_get_top_queries', + 'Get the top search queries driving traffic from Google Search, ranked by clicks. Includes impressions, CTR, and average position for each query.', + { + projectId: projectIdSchema(context), + ...zDateRange, + limit: z + .number() + .min(1) + .max(1000) + .default(100) + .optional() + .describe('Maximum number of queries to return (1-1000, default 100)'), + }, + async ({ projectId: inputProjectId, startDate: sd, endDate: ed, limit }) => + withErrorHandling(async () => { + const projectId = await resolveProjectId(context, inputProjectId); + const { startDate, endDate } = resolveDateRange(sd, ed); + return getGscQueries(projectId, startDate, endDate, limit ?? 100); + }), + ); + + server.tool( + 'gsc_get_query_opportunities', + 'Identify low-hanging-fruit SEO opportunities: queries ranking on positions 4-20 with meaningful search volume where small improvements could yield significant traffic gains. Ranked by opportunity score.', + { + projectId: projectIdSchema(context), + ...zDateRange, + minImpressions: z + .number() + .min(1) + .default(50) + .optional() + .describe( + 'Minimum impression threshold to filter out low-volume queries (default: 50)', + ), + }, + async ({ projectId: inputProjectId, startDate: sd, endDate: ed, minImpressions }) => + withErrorHandling(async () => { + const projectId = await resolveProjectId(context, inputProjectId); + const { startDate, endDate } = resolveDateRange(sd, ed); + const queries = await getGscQueries(projectId, startDate, endDate, 5000); + const filtered = queries.filter( + (q) => q.impressions >= (minImpressions ?? 50), + ); + const opportunities = computeOpportunities(filtered); + return { + opportunities, + total_analyzed: filtered.length, + min_impressions: minImpressions ?? 50, + }; + }), + ); + + server.tool( + 'gsc_get_query_details', + 'Get detailed Search Console data for a specific search query: time-series performance plus all pages that rank for that query.', + { + projectId: projectIdSchema(context), + ...zDateRange, + query: z + .string() + .describe('The search query to get details for (e.g. "best analytics tools")'), + }, + async ({ projectId: inputProjectId, startDate: sd, endDate: ed, query }) => + withErrorHandling(async () => { + const projectId = await resolveProjectId(context, inputProjectId); + const { startDate, endDate } = resolveDateRange(sd, ed); + return getGscQueryDetails(projectId, query, startDate, endDate); + }), + ); +} diff --git a/packages/mcp/src/tools/index.ts b/packages/mcp/src/tools/index.ts new file mode 100644 index 000000000..40acdcbcf --- /dev/null +++ b/packages/mcp/src/tools/index.ts @@ -0,0 +1,72 @@ +import { resolveClientProjectId } from '@openpanel/db'; +import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import type { McpAuthContext } from '../auth'; +import { registerActiveUserTools } from './analytics/active-users'; +import { registerEngagementTools } from './analytics/engagement'; +import { registerEventNameTools } from './analytics/event-names'; +import { registerEventTools } from './analytics/events'; +import { registerFunnelTools } from './analytics/funnel'; +import { registerGroupTools } from './analytics/groups'; +import { registerOverviewTools } from './analytics/overview'; +import { registerPageConversionTools } from './analytics/page-conversions'; +import { registerPagePerformanceTools } from './analytics/page-performance'; +import { registerPageTools } from './analytics/pages'; +import { registerProfileMetricTools } from './analytics/profile-metrics'; +import { registerProfileTools } from './analytics/profiles'; +import { registerPropertyValueTools } from './analytics/property-values'; +import { registerReportTools } from './analytics/reports'; +import { registerRetentionTools } from './analytics/retention'; +import { registerSessionTools } from './analytics/sessions'; +import { registerTrafficTools } from './analytics/traffic'; +import { registerUserFlowTools } from './analytics/user-flow'; +import { registerGscCannibalizationTools } from './gsc/cannibalization'; +import { registerGscOverviewTools } from './gsc/overview'; +import { registerGscPageTools } from './gsc/pages'; +import { registerGscQueryTools } from './gsc/queries'; +import { registerDashboardLinkTools } from './dashboard-links'; +import { registerProjectTools } from './projects'; + +export function registerAllTools( + server: McpServer, + context: McpAuthContext, +): void { + // Project access — always call first to discover available projects + registerProjectTools(server, context); + registerDashboardLinkTools(server, context); + registerReportTools(server, context); + + // Analytics — discovery (call these first to understand the data) + registerEventNameTools(server, context); + registerPropertyValueTools(server, context); + + // Analytics — event data + registerEventTools(server, context); + registerSessionTools(server, context); + + // Analytics — profiles + registerProfileTools(server, context); + registerProfileMetricTools(server, context); + + // Analytics — groups (B2B) + registerGroupTools(server, context); + + // Analytics — aggregated metrics + registerOverviewTools(server, context); + registerActiveUserTools(server, context); + registerPageTools(server, context); + registerPagePerformanceTools(server, context); + registerPageConversionTools(server, context); + registerTrafficTools(server, context); + + // Analytics — user behavior + registerFunnelTools(server, context); + registerRetentionTools(server, context); + registerEngagementTools(server, context); + registerUserFlowTools(server, context); + + // Google Search Console + registerGscOverviewTools(server, context); + registerGscPageTools(server, context); + registerGscQueryTools(server, context); + registerGscCannibalizationTools(server, context); +} diff --git a/packages/mcp/src/tools/projects.ts b/packages/mcp/src/tools/projects.ts new file mode 100644 index 000000000..c21110865 --- /dev/null +++ b/packages/mcp/src/tools/projects.ts @@ -0,0 +1,54 @@ +import { db } from '@openpanel/db'; +import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import type { McpAuthContext } from '../auth'; +import { withErrorHandling } from './shared'; + +export function registerProjectTools( + server: McpServer, + context: McpAuthContext, +) { + server.tool( + 'list_projects', + context.clientType === 'root' + ? 'List all projects in your organization. Use the returned project IDs when calling other tools that require a projectId.' + : 'Returns the single project this client has access to.', + {}, + async () => + withErrorHandling(async () => { + if (context.clientType === 'root') { + const projects = await db.project.findMany({ + where: { organizationId: context.organizationId }, + orderBy: { eventsCount: 'desc' }, + select: { + id: true, + name: true, + organizationId: true, + eventsCount: true, + domain: true, + types: true, + }, + }); + return { clientType: 'root', projects }; + } + + const project = context.projectId + ? await db.project.findUnique({ + where: { id: context.projectId }, + select: { + id: true, + name: true, + organizationId: true, + eventsCount: true, + domain: true, + types: true, + }, + }) + : null; + + return { + clientType: 'read', + projects: project ? [project] : [], + }; + }), + ); +} diff --git a/packages/mcp/src/tools/shared.test.ts b/packages/mcp/src/tools/shared.test.ts new file mode 100644 index 000000000..20c232dbd --- /dev/null +++ b/packages/mcp/src/tools/shared.test.ts @@ -0,0 +1,74 @@ +import { resolveClientProjectId } from '@openpanel/db'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { McpAuthContext } from '../auth'; +import { resolveDateRange } from './shared'; + +const READ_CTX: McpAuthContext = { + projectId: 'proj-abc', + organizationId: 'org-1', + clientType: 'read', +}; + +const ROOT_CTX: McpAuthContext = { + projectId: null, + organizationId: 'org-1', + clientType: 'root', +}; + +describe('resolveDateRange', () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2024-03-15T12:00:00.000Z')); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('passes through explicit dates unchanged', () => { + const result = resolveDateRange('2024-01-01', '2024-02-28'); + expect(result).toEqual({ startDate: '2024-01-01', endDate: '2024-02-28' }); + }); + + it('defaults endDate to today when omitted', () => { + const { endDate } = resolveDateRange('2024-01-01'); + expect(endDate).toBe('2024-03-15'); + }); + + it('defaults startDate to 30 days ago when omitted', () => { + const { startDate } = resolveDateRange(undefined, '2024-03-15'); + expect(startDate).toBe('2024-02-14'); + }); + + it('defaults both to last 30 days when neither is provided', () => { + const result = resolveDateRange(); + expect(result.endDate).toBe('2024-03-15'); + expect(result.startDate).toBe('2024-02-14'); + }); +}); + +describe('resolveClientProjectId', () => { + it('returns the context projectId for read clients, ignoring any input', async () => { + await expect(resolveClientProjectId({ + clientType: READ_CTX.clientType, + clientProjectId: READ_CTX.projectId, + organizationId: READ_CTX.organizationId, + inputProjectId: undefined, + })).resolves.toBe('proj-abc'); + await expect(resolveClientProjectId({ + clientType: READ_CTX.clientType, + clientProjectId: READ_CTX.projectId, + organizationId: READ_CTX.organizationId, + inputProjectId: 'other-proj', + })).resolves.toBe('proj-abc'); + }); + + it('throws for root clients when no projectId is provided', async () => { + await expect(resolveClientProjectId({ + clientType: ROOT_CTX.clientType, + clientProjectId: ROOT_CTX.projectId, + organizationId: ROOT_CTX.organizationId, + inputProjectId: undefined, + })).rejects.toThrow('projectId is required'); + }); +}); diff --git a/packages/mcp/src/tools/shared.ts b/packages/mcp/src/tools/shared.ts new file mode 100644 index 000000000..3e26a2e15 --- /dev/null +++ b/packages/mcp/src/tools/shared.ts @@ -0,0 +1,102 @@ +import { resolveClientProjectId } from '@openpanel/db'; +import { createLogger } from '@openpanel/logger'; +import { z } from 'zod'; +import type { McpAuthContext } from '../auth'; + +const logger = createLogger({ name: 'mcp' }); + +/** + * Resolve the effective projectId from context + optional tool input. + * Thin adapter so tool files don't repeat the full argument object every call. + */ +export function resolveProjectId( + context: McpAuthContext, + inputProjectId: string | undefined, +): Promise { + return resolveClientProjectId({ + clientType: context.clientType, + clientProjectId: context.projectId, + organizationId: context.organizationId, + inputProjectId, + }); +} + +/** + * Build the projectId portion of an input schema. + * + * - Root clients must supply a projectId per call (multi-project access). + * - Read clients have it fixed in context — it's not included in the schema. + */ +export function projectIdSchema(context: McpAuthContext) { + return context.projectId === null + ? z + .string() + .describe( + 'Project ID to query (required for organization-level access)' + ) + : z.string().optional(); +} + + +/** + * Zod schema for common date range inputs. Both fields are optional and + * default to the last 30 days when omitted. + */ +export const zDateRange = { + startDate: z + .string() + .optional() + .describe( + 'Start date in YYYY-MM-DD format (e.g. 2024-01-01). Defaults to 30 days ago.' + ), + endDate: z + .string() + .optional() + .describe( + 'End date in YYYY-MM-DD format (e.g. 2024-03-31). Defaults to today.' + ), +}; + +/** + * Resolve a date range, defaulting to the last 30 days if not provided. + */ +export function resolveDateRange( + startDate?: string, + endDate?: string +): { startDate: string; endDate: string } { + const end = endDate ?? new Date().toISOString().slice(0, 10); + const start = + startDate ?? + new Date(Date.now() - 30 * 86_400_000).toISOString().slice(0, 10); + return { startDate: start, endDate: end }; +} + +/** + * Serialize a tool result to MCP content format. + */ +export function toText(data: unknown): { + content: [{ type: 'text'; text: string }]; +} { + return { + content: [{ type: 'text' as const, text: JSON.stringify(data, null, 2) }], + }; +} + +/** + * Wrap a tool handler to catch errors and return them as MCP error content. + */ +export async function withErrorHandling( + fn: () => Promise +): Promise<{ content: [{ type: 'text'; text: string }]; isError?: boolean }> { + try { + const result = await fn(); + return toText(result); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + logger.error(`MCP tool error: ${message}`, { err }); + return { + content: [{ type: 'text' as const, text: `Error: ${message}` }], + isError: true, + }; + } +} diff --git a/packages/mcp/tsconfig.json b/packages/mcp/tsconfig.json new file mode 100644 index 000000000..2148d7758 --- /dev/null +++ b/packages/mcp/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "@openpanel/tsconfig/base.json", + "compilerOptions": { + "baseUrl": ".", + "tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json", + "strictNullChecks": true + }, + "include": [".", "../../test"], + "exclude": ["node_modules"] +} diff --git a/packages/mcp/vitest.config.ts b/packages/mcp/vitest.config.ts new file mode 100644 index 000000000..f87a2039f --- /dev/null +++ b/packages/mcp/vitest.config.ts @@ -0,0 +1,3 @@ +import { getSharedVitestConfig } from '../../vitest.shared'; + +export default getSharedVitestConfig({ __dirname }); diff --git a/packages/trpc/src/routers/widget.ts b/packages/trpc/src/routers/widget.ts index 6e54b4f5b..acfbbfb3a 100644 --- a/packages/trpc/src/routers/widget.ts +++ b/packages/trpc/src/routers/widget.ts @@ -1,22 +1,15 @@ -import ShortUniqueId from 'short-unique-id'; -import { z } from 'zod'; - import { - TABLE_NAMES, ch, clix, db, eventBuffer, getSettingsForProject, + TABLE_NAMES, } from '@openpanel/db'; import { getCache } from '@openpanel/redis'; -import { - zCounterWidgetOptions, - zRealtimeWidgetOptions, - zWidgetOptions, - zWidgetType, -} from '@openpanel/validation'; - +import { zWidgetOptions, zWidgetType } from '@openpanel/validation'; +import ShortUniqueId from 'short-unique-id'; +import { z } from 'zod'; import { TRPCNotFoundError } from '../errors'; import { createTRPCRouter, protectedProcedure, publicProcedure } from '../trpc'; @@ -24,11 +17,11 @@ const uid = new ShortUniqueId({ length: 6 }); // Helper to find widget by projectId and type async function findWidgetByType(projectId: string, type: string) { - const widgets = await db.$primary().shareWidget.findMany({ + const widgets = await db.shareWidget.findMany({ where: { projectId }, }); return widgets.find( - (w) => (w.options as z.infer)?.type === type, + (w) => (w.options as z.infer)?.type === type ); } @@ -54,7 +47,7 @@ export const widgetRouter = createTRPCRouter({ organizationId: z.string(), type: zWidgetType, enabled: z.boolean(), - }), + }) ) .mutation(async ({ input }) => { const existing = await findWidgetByType(input.projectId, input.type); @@ -95,12 +88,12 @@ export const widgetRouter = createTRPCRouter({ projectId: z.string(), organizationId: z.string(), options: zWidgetOptions, - }), + }) ) .mutation(async ({ input }) => { const existing = await findWidgetByType( input.projectId, - input.options.type, + input.options.type ); if (existing) { @@ -131,7 +124,7 @@ export const widgetRouter = createTRPCRouter({ }, }); - if (!widget || !widget.public) { + if (!(widget && widget.public)) { throw TRPCNotFoundError('Widget not found'); } @@ -154,7 +147,7 @@ export const widgetRouter = createTRPCRouter({ }, }); - if (!widget || !widget.public) { + if (!(widget && widget.public)) { throw TRPCNotFoundError('Widget not found'); } @@ -179,7 +172,7 @@ export const widgetRouter = createTRPCRouter({ const result = await uniqueVisitorsQuery.execute(); return result[0]?.count || 0; - }, + } ); return { @@ -206,7 +199,7 @@ export const widgetRouter = createTRPCRouter({ }, }); - if (!widget || !widget.public) { + if (!(widget && widget.public)) { throw TRPCNotFoundError('Widget not found'); } @@ -245,7 +238,7 @@ export const widgetRouter = createTRPCRouter({ .fill( clix.exp('toStartOfMinute(now() - INTERVAL 30 MINUTE)'), clix.exp('toStartOfMinute(now())'), - clix.exp('INTERVAL 1 MINUTE'), + clix.exp('INTERVAL 1 MINUTE') ); // Conditionally fetch countries diff --git a/packages/trpc/src/trpc.ts b/packages/trpc/src/trpc.ts index 20835bd9f..ba8005031 100644 --- a/packages/trpc/src/trpc.ts +++ b/packages/trpc/src/trpc.ts @@ -2,7 +2,7 @@ import { TRPCError, initTRPC } from '@trpc/server'; import type { CreateFastifyContextOptions } from '@trpc/server/adapters/fastify'; import { has } from 'ramda'; import superjson from 'superjson'; -import { ZodError } from 'zod'; +import { ZodError, z } from 'zod'; import { COOKIE_OPTIONS, type SessionValidationResult } from '@openpanel/auth'; import { runWithAlsSession } from '@openpanel/db'; @@ -68,7 +68,7 @@ const t = initTRPC.context().create({ data: { ...shape.data, zodError: - error.cause instanceof ZodError ? error.cause.flatten() : null, + error.cause instanceof ZodError ? z.flattenError(error.cause) : null, }, }; }, diff --git a/packages/validation/src/index.ts b/packages/validation/src/index.ts index 2b27593e5..a9897002f 100644 --- a/packages/validation/src/index.ts +++ b/packages/validation/src/index.ts @@ -360,14 +360,14 @@ export const zSlackConfig = z .object({ type: z.literal('slack'), }) - .merge(zSlackAuthResponse); + .extend(zSlackAuthResponse.shape); export type ISlackConfig = z.infer; export const zWebhookConfig = z.object({ type: z.literal('webhook'), url: z.string().url(), - headers: z.record(z.string()), + headers: z.record(z.string(), z.string()), payload: z.record(z.string(), z.unknown()).optional(), mode: z.enum(['message', 'javascript']).default('message'), javascriptTemplate: z.string().optional(), @@ -405,17 +405,13 @@ const zCreateIntegration = z.object({ export const zCreateSlackIntegration = zCreateIntegration; -export const zCreateWebhookIntegration = zCreateIntegration.merge( - z.object({ - config: zWebhookConfig, - }), -); +export const zCreateWebhookIntegration = zCreateIntegration.extend({ + config: zWebhookConfig, +}); -export const zCreateDiscordIntegration = zCreateIntegration.merge( - z.object({ - config: zDiscordConfig, - }), -); +export const zCreateDiscordIntegration = zCreateIntegration.extend({ + config: zDiscordConfig, +}); export const zNotificationRuleEventConfig = z.object({ type: z.literal('events'), @@ -553,7 +549,7 @@ export const zCreateGroup = z.object({ projectId: z.string(), type: z.string().min(1), name: z.string().min(1), - properties: z.record(z.string()).default({}), + properties: z.record(z.string(), z.string()).default({}), }); export type ICreateGroup = z.infer; @@ -562,7 +558,7 @@ export const zUpdateGroup = z.object({ projectId: z.string(), type: z.string().min(1).optional(), name: z.string().min(1).optional(), - properties: z.record(z.string()).optional(), + properties: z.record(z.string(), z.string()).optional(), }); export type IUpdateGroup = z.infer; diff --git a/packages/validation/src/track.validation.ts b/packages/validation/src/track.validation.ts index 0bb9dcc24..7ea8c1fd8 100644 --- a/packages/validation/src/track.validation.ts +++ b/packages/validation/src/track.validation.ts @@ -6,7 +6,7 @@ export const zGroupPayload = z.object({ id: z.string().min(1), type: z.string().min(1), name: z.string().min(1), - properties: z.record(z.unknown()).optional(), + properties: z.record(z.string(), z.unknown()).optional(), }); export const zAssignGroupPayload = z.object({ @@ -56,7 +56,7 @@ export const zIdentifyPayload = z.object({ lastName: z.string().optional(), email: z.string().email().optional(), avatar: z.string().url().optional(), - properties: z.record(z.unknown()).optional(), + properties: z.record(z.string(), z.unknown()).optional(), }); export const zIncrementPayload = z.object({ @@ -86,38 +86,54 @@ export const zReplayPayload = z.object({ }); export const zTrackHandlerPayload = z.discriminatedUnion('type', [ - z.object({ - type: z.literal('track'), - payload: zTrackPayload, - }), - z.object({ - type: z.literal('identify'), - payload: zIdentifyPayload, - }), - z.object({ - type: z.literal('increment'), - payload: zIncrementPayload, - }), - z.object({ - type: z.literal('decrement'), - payload: zDecrementPayload, - }), - z.object({ - type: z.literal('alias'), - payload: zAliasPayload, - }), - z.object({ - type: z.literal('replay'), - payload: zReplayPayload, - }), - z.object({ - type: z.literal('group'), - payload: zGroupPayload, - }), - z.object({ - type: z.literal('assign_group'), - payload: zAssignGroupPayload, - }), + z + .object({ + type: z.enum(['track']), + payload: zTrackPayload, + }) + .meta({ title: 'Track' }), + z + .object({ + type: z.enum(['identify']), + payload: zIdentifyPayload, + }) + .meta({ title: 'Identify' }), + z + .object({ + type: z.enum(['increment']), + payload: zIncrementPayload, + }) + .meta({ title: 'Increment' }), + z + .object({ + type: z.enum(['decrement']), + payload: zDecrementPayload, + }) + .meta({ title: 'Decrement' }), + z + .object({ + type: z.enum(['alias']), + payload: zAliasPayload, + }) + .meta({ title: 'Alias' }), + z + .object({ + type: z.enum(['replay']), + payload: zReplayPayload, + }) + .meta({ title: 'Replay' }), + z + .object({ + type: z.enum(['group']), + payload: zGroupPayload, + }) + .meta({ title: 'Group' }), + z + .object({ + type: z.enum(['assign_group']), + payload: zAssignGroupPayload, + }) + .meta({ title: 'Assign Group' }), ]); export type ITrackPayload = z.infer; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 46a4f9acb..fb9abb712 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -28,8 +28,8 @@ catalogs: specifier: ^5.9.3 version: 5.9.3 zod: - specifier: ^3.24.2 - version: 3.24.2 + specifier: ^4.0.0 + version: 4.1.13 overrides: rolldown: 1.0.0-beta.43 @@ -117,10 +117,10 @@ importers: dependencies: '@ai-sdk/anthropic': specifier: ^1.2.10 - version: 1.2.10(zod@3.24.2) + version: 1.2.10(zod@4.1.13) '@ai-sdk/openai': specifier: ^1.3.12 - version: 1.3.12(zod@3.24.2) + version: 1.3.12(zod@4.1.13) '@fastify/compress': specifier: ^8.1.0 version: 8.1.0 @@ -133,6 +133,12 @@ importers: '@fastify/rate-limit': specifier: ^10.3.0 version: 10.3.0 + '@fastify/swagger': + specifier: ^9.7.0 + version: 9.7.0 + '@fastify/swagger-ui': + specifier: ^5.2.5 + version: 5.2.5 '@fastify/websocket': specifier: ^11.2.0 version: 11.2.0 @@ -163,6 +169,9 @@ importers: '@openpanel/logger': specifier: workspace:* version: link:../../packages/logger + '@openpanel/mcp': + specifier: workspace:* + version: link:../../packages/mcp '@openpanel/payments': specifier: workspace:* version: link:../../packages/payments @@ -183,7 +192,7 @@ importers: version: 11.6.0(typescript@5.9.3) ai: specifier: ^4.2.10 - version: 4.2.10(react@19.2.3)(zod@3.24.2) + version: 4.2.10(react@19.2.3)(zod@4.1.13) fast-json-stable-hash: specifier: ^1.0.3 version: 1.0.3 @@ -196,6 +205,9 @@ importers: fastify-raw-body: specifier: ^5.0.0 version: 5.0.0 + fastify-zod-openapi: + specifier: ^5.6.1 + version: 5.6.1(@fastify/swagger-ui@5.2.5)(@fastify/swagger@9.7.0)(fastify@5.6.1)(zod@4.1.13) groupmq: specifier: 'catalog:' version: 2.0.0-next.1(ioredis@5.8.2) @@ -228,7 +240,7 @@ importers: version: 9.0.1 zod: specifier: 'catalog:' - version: 3.24.2 + version: 4.1.13 devDependencies: '@faker-js/faker': specifier: ^9.0.1 @@ -262,10 +274,13 @@ importers: version: 4.1.0 tsdown: specifier: 0.14.2 - version: 0.14.2(typescript@5.9.3) + version: 0.14.2(oxc-resolver@11.19.1(@emnapi/core@1.7.1)(@emnapi/runtime@1.7.1))(typescript@5.9.3) typescript: specifier: 'catalog:' version: 5.9.3 + vitest: + specifier: ^1.0.0 + version: 1.6.1(@types/node@24.10.1)(jsdom@26.1.0)(lightningcss@1.30.2)(terser@5.27.1) apps/public: dependencies: @@ -324,14 +339,17 @@ importers: specifier: 12.23.25 version: 12.23.25(@emotion/is-prop-valid@0.8.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) fumadocs-core: - specifier: 16.2.2 - version: 16.2.2(@tanstack/react-router@1.132.47(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(@types/react@19.2.7)(lucide-react@0.555.0(react@19.2.3))(next@16.0.7(@opentelemetry/api@1.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-dom@19.2.3(react@19.2.3))(react-router@7.13.1(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react@19.2.3) + specifier: 16.7.11 + version: 16.7.11(@mdx-js/mdx@3.1.1)(@tanstack/react-router@1.132.47(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(@types/estree-jsx@1.0.5)(@types/hast@3.0.4)(@types/mdast@4.0.4)(@types/react@19.2.7)(lucide-react@0.555.0(react@19.2.3))(next@16.0.7(@opentelemetry/api@1.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-dom@19.2.3(react@19.2.3))(react-router@7.13.1(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react@19.2.3)(zod@4.1.13) fumadocs-mdx: - specifier: 14.0.4 - version: 14.0.4(fumadocs-core@16.2.2(@tanstack/react-router@1.132.47(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(@types/react@19.2.7)(lucide-react@0.555.0(react@19.2.3))(next@16.0.7(@opentelemetry/api@1.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-dom@19.2.3(react@19.2.3))(react-router@7.13.1(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react@19.2.3))(next@16.0.7(@opentelemetry/api@1.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react@19.2.3)(vite@7.3.0(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.27.1)(tsx@4.20.5)(yaml@2.8.2)) + specifier: 14.2.11 + version: 14.2.11(@types/mdast@4.0.4)(@types/mdx@2.0.13)(@types/react@19.2.7)(fumadocs-core@16.7.11(@mdx-js/mdx@3.1.1)(@tanstack/react-router@1.132.47(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(@types/estree-jsx@1.0.5)(@types/hast@3.0.4)(@types/mdast@4.0.4)(@types/react@19.2.7)(lucide-react@0.555.0(react@19.2.3))(next@16.0.7(@opentelemetry/api@1.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-dom@19.2.3(react@19.2.3))(react-router@7.13.1(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react@19.2.3)(zod@4.1.13))(next@16.0.7(@opentelemetry/api@1.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react@19.2.3)(vite@7.3.0(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.27.1)(tsx@4.20.5)(yaml@2.8.2)) + fumadocs-openapi: + specifier: ^10.6.7 + version: 10.6.7(20b279af7e6f0aee6451333d6680f64b) fumadocs-ui: - specifier: 16.2.2 - version: 16.2.2(@tanstack/react-router@1.132.47(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(lucide-react@0.555.0(react@19.2.3))(next@16.0.7(@opentelemetry/api@1.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-dom@19.2.3(react@19.2.3))(react-router@7.13.1(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react@19.2.3)(tailwindcss@4.1.17) + specifier: 16.7.11 + version: 16.7.11(@emnapi/core@1.7.1)(@emnapi/runtime@1.7.1)(@emotion/is-prop-valid@0.8.8)(@types/mdx@2.0.13)(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(fumadocs-core@16.7.11(@mdx-js/mdx@3.1.1)(@tanstack/react-router@1.132.47(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(@types/estree-jsx@1.0.5)(@types/hast@3.0.4)(@types/mdast@4.0.4)(@types/react@19.2.7)(lucide-react@0.555.0(react@19.2.3))(next@16.0.7(@opentelemetry/api@1.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-dom@19.2.3(react@19.2.3))(react-router@7.13.1(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react@19.2.3)(zod@4.1.13))(next@16.0.7(@opentelemetry/api@1.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(shiki@4.0.2)(tailwindcss@4.1.17) geist: specifier: 1.5.1 version: 1.5.1(next@16.0.7(@opentelemetry/api@1.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)) @@ -359,6 +377,9 @@ importers: rehype-external-links: specifier: 3.0.0 version: 3.0.0 + shiki: + specifier: ^4.0.2 + version: 4.0.2 tailwind-merge: specifier: 3.4.0 version: 3.4.0 @@ -367,7 +388,7 @@ importers: version: 1.0.7(tailwindcss@4.1.17) zod: specifier: 'catalog:' - version: 3.24.2 + version: 4.1.13 devDependencies: '@tailwindcss/postcss': specifier: ^4.1.17 @@ -404,7 +425,7 @@ importers: dependencies: '@ai-sdk/react': specifier: ^1.2.5 - version: 1.2.5(react@19.2.3)(zod@3.24.2) + version: 1.2.5(react@19.2.3)(zod@4.1.13) '@codemirror/commands': specifier: ^6.7.0 version: 6.10.1 @@ -617,7 +638,7 @@ importers: version: 7.4.3 ai: specifier: ^4.2.10 - version: 4.2.10(react@19.2.3)(zod@3.24.2) + version: 4.2.10(react@19.2.3)(zod@4.1.13) bind-event-listener: specifier: ^3.0.0 version: 3.0.0 @@ -812,7 +833,7 @@ importers: version: 5.1.4(typescript@5.9.3)(vite@6.3.5(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.27.1)(tsx@4.20.5)(yaml@2.8.2)) zod: specifier: 'catalog:' - version: 3.24.2 + version: 4.1.13 devDependencies: '@biomejs/biome': specifier: 1.9.4 @@ -1004,7 +1025,7 @@ importers: version: 9.0.8 tsdown: specifier: 0.14.2 - version: 0.14.2(typescript@5.9.3) + version: 0.14.2(oxc-resolver@11.19.1(@emnapi/core@1.7.1)(@emnapi/runtime@1.7.1))(typescript@5.9.3) typescript: specifier: 'catalog:' version: 5.9.3 @@ -1180,7 +1201,7 @@ importers: version: 9.0.1 zod: specifier: 'catalog:' - version: 3.24.2 + version: 4.1.13 devDependencies: '@openpanel/tsconfig': specifier: workspace:* @@ -1226,7 +1247,7 @@ importers: version: 0.0.5(react-email@3.0.4(@opentelemetry/api@1.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react@19.2.3) zod: specifier: 'catalog:' - version: 3.24.2 + version: 4.1.13 devDependencies: '@openpanel/tsconfig': specifier: workspace:* @@ -1297,7 +1318,7 @@ importers: version: 9.0.1 zod: specifier: 'catalog:' - version: 3.24.2 + version: 4.1.13 devDependencies: '@openpanel/logger': specifier: workspace:* @@ -1400,6 +1421,43 @@ importers: specifier: 'catalog:' version: 5.9.3 + packages/mcp: + dependencies: + '@modelcontextprotocol/sdk': + specifier: ^1.12.0 + version: 1.27.1(zod@4.1.13) + '@openpanel/common': + specifier: workspace:* + version: link:../common + '@openpanel/db': + specifier: workspace:* + version: link:../db + '@openpanel/logger': + specifier: workspace:* + version: link:../logger + '@openpanel/redis': + specifier: workspace:* + version: link:../redis + '@openpanel/validation': + specifier: workspace:* + version: link:../validation + zod: + specifier: 'catalog:' + version: 4.1.13 + devDependencies: + '@openpanel/tsconfig': + specifier: workspace:* + version: link:../../tooling/typescript + '@types/node': + specifier: 'catalog:' + version: 24.10.1 + typescript: + specifier: 'catalog:' + version: 5.9.3 + vitest: + specifier: ^1.0.0 + version: 1.6.1(@types/node@24.10.1)(jsdom@26.1.0)(lightningcss@1.30.2)(terser@5.27.1) + packages/payments: dependencies: '@polar-sh/sdk': @@ -1753,7 +1811,7 @@ importers: version: 9.0.1 zod: specifier: 'catalog:' - version: 3.24.2 + version: 4.1.13 devDependencies: '@openpanel/tsconfig': specifier: workspace:* @@ -1781,7 +1839,7 @@ importers: version: link:../constants zod: specifier: 'catalog:' - version: 3.24.2 + version: 4.1.13 devDependencies: '@openpanel/tsconfig': specifier: workspace:* @@ -3411,12 +3469,6 @@ packages: cpu: [ppc64] os: [aix] - '@esbuild/aix-ppc64@0.27.1': - resolution: {integrity: sha512-HHB50pdsBX6k47S4u5g/CaLjqS3qwaOVE5ILsq64jyzgMhLuCuZ8rGzM9yhsAjfjkbgUPMzZEPa7DAp7yz6vuA==} - engines: {node: '>=18'} - cpu: [ppc64] - os: [aix] - '@esbuild/aix-ppc64@0.27.3': resolution: {integrity: sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==} engines: {node: '>=18'} @@ -3465,12 +3517,6 @@ packages: cpu: [arm64] os: [android] - '@esbuild/android-arm64@0.27.1': - resolution: {integrity: sha512-45fuKmAJpxnQWixOGCrS+ro4Uvb4Re9+UTieUY2f8AEc+t7d4AaZ6eUJ3Hva7dtrxAAWHtlEFsXFMAgNnGU9uQ==} - engines: {node: '>=18'} - cpu: [arm64] - os: [android] - '@esbuild/android-arm64@0.27.3': resolution: {integrity: sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==} engines: {node: '>=18'} @@ -3519,12 +3565,6 @@ packages: cpu: [arm] os: [android] - '@esbuild/android-arm@0.27.1': - resolution: {integrity: sha512-kFqa6/UcaTbGm/NncN9kzVOODjhZW8e+FRdSeypWe6j33gzclHtwlANs26JrupOntlcWmB0u8+8HZo8s7thHvg==} - engines: {node: '>=18'} - cpu: [arm] - os: [android] - '@esbuild/android-arm@0.27.3': resolution: {integrity: sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==} engines: {node: '>=18'} @@ -3573,12 +3613,6 @@ packages: cpu: [x64] os: [android] - '@esbuild/android-x64@0.27.1': - resolution: {integrity: sha512-LBEpOz0BsgMEeHgenf5aqmn/lLNTFXVfoWMUox8CtWWYK9X4jmQzWjoGoNb8lmAYml/tQ/Ysvm8q7szu7BoxRQ==} - engines: {node: '>=18'} - cpu: [x64] - os: [android] - '@esbuild/android-x64@0.27.3': resolution: {integrity: sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==} engines: {node: '>=18'} @@ -3627,12 +3661,6 @@ packages: cpu: [arm64] os: [darwin] - '@esbuild/darwin-arm64@0.27.1': - resolution: {integrity: sha512-veg7fL8eMSCVKL7IW4pxb54QERtedFDfY/ASrumK/SbFsXnRazxY4YykN/THYqFnFwJ0aVjiUrVG2PwcdAEqQQ==} - engines: {node: '>=18'} - cpu: [arm64] - os: [darwin] - '@esbuild/darwin-arm64@0.27.3': resolution: {integrity: sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==} engines: {node: '>=18'} @@ -3681,12 +3709,6 @@ packages: cpu: [x64] os: [darwin] - '@esbuild/darwin-x64@0.27.1': - resolution: {integrity: sha512-+3ELd+nTzhfWb07Vol7EZ+5PTbJ/u74nC6iv4/lwIU99Ip5uuY6QoIf0Hn4m2HoV0qcnRivN3KSqc+FyCHjoVQ==} - engines: {node: '>=18'} - cpu: [x64] - os: [darwin] - '@esbuild/darwin-x64@0.27.3': resolution: {integrity: sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==} engines: {node: '>=18'} @@ -3735,12 +3757,6 @@ packages: cpu: [arm64] os: [freebsd] - '@esbuild/freebsd-arm64@0.27.1': - resolution: {integrity: sha512-/8Rfgns4XD9XOSXlzUDepG8PX+AVWHliYlUkFI3K3GB6tqbdjYqdhcb4BKRd7C0BhZSoaCxhv8kTcBrcZWP+xg==} - engines: {node: '>=18'} - cpu: [arm64] - os: [freebsd] - '@esbuild/freebsd-arm64@0.27.3': resolution: {integrity: sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==} engines: {node: '>=18'} @@ -3789,12 +3805,6 @@ packages: cpu: [x64] os: [freebsd] - '@esbuild/freebsd-x64@0.27.1': - resolution: {integrity: sha512-GITpD8dK9C+r+5yRT/UKVT36h/DQLOHdwGVwwoHidlnA168oD3uxA878XloXebK4Ul3gDBBIvEdL7go9gCUFzQ==} - engines: {node: '>=18'} - cpu: [x64] - os: [freebsd] - '@esbuild/freebsd-x64@0.27.3': resolution: {integrity: sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==} engines: {node: '>=18'} @@ -3843,12 +3853,6 @@ packages: cpu: [arm64] os: [linux] - '@esbuild/linux-arm64@0.27.1': - resolution: {integrity: sha512-W9//kCrh/6in9rWIBdKaMtuTTzNj6jSeG/haWBADqLLa9P8O5YSRDzgD5y9QBok4AYlzS6ARHifAb75V6G670Q==} - engines: {node: '>=18'} - cpu: [arm64] - os: [linux] - '@esbuild/linux-arm64@0.27.3': resolution: {integrity: sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==} engines: {node: '>=18'} @@ -3897,12 +3901,6 @@ packages: cpu: [arm] os: [linux] - '@esbuild/linux-arm@0.27.1': - resolution: {integrity: sha512-ieMID0JRZY/ZeCrsFQ3Y3NlHNCqIhTprJfDgSB3/lv5jJZ8FX3hqPyXWhe+gvS5ARMBJ242PM+VNz/ctNj//eA==} - engines: {node: '>=18'} - cpu: [arm] - os: [linux] - '@esbuild/linux-arm@0.27.3': resolution: {integrity: sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==} engines: {node: '>=18'} @@ -3951,12 +3949,6 @@ packages: cpu: [ia32] os: [linux] - '@esbuild/linux-ia32@0.27.1': - resolution: {integrity: sha512-VIUV4z8GD8rtSVMfAj1aXFahsi/+tcoXXNYmXgzISL+KB381vbSTNdeZHHHIYqFyXcoEhu9n5cT+05tRv13rlw==} - engines: {node: '>=18'} - cpu: [ia32] - os: [linux] - '@esbuild/linux-ia32@0.27.3': resolution: {integrity: sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==} engines: {node: '>=18'} @@ -4005,12 +3997,6 @@ packages: cpu: [loong64] os: [linux] - '@esbuild/linux-loong64@0.27.1': - resolution: {integrity: sha512-l4rfiiJRN7sTNI//ff65zJ9z8U+k6zcCg0LALU5iEWzY+a1mVZ8iWC1k5EsNKThZ7XCQ6YWtsZ8EWYm7r1UEsg==} - engines: {node: '>=18'} - cpu: [loong64] - os: [linux] - '@esbuild/linux-loong64@0.27.3': resolution: {integrity: sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==} engines: {node: '>=18'} @@ -4059,12 +4045,6 @@ packages: cpu: [mips64el] os: [linux] - '@esbuild/linux-mips64el@0.27.1': - resolution: {integrity: sha512-U0bEuAOLvO/DWFdygTHWY8C067FXz+UbzKgxYhXC0fDieFa0kDIra1FAhsAARRJbvEyso8aAqvPdNxzWuStBnA==} - engines: {node: '>=18'} - cpu: [mips64el] - os: [linux] - '@esbuild/linux-mips64el@0.27.3': resolution: {integrity: sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==} engines: {node: '>=18'} @@ -4113,12 +4093,6 @@ packages: cpu: [ppc64] os: [linux] - '@esbuild/linux-ppc64@0.27.1': - resolution: {integrity: sha512-NzdQ/Xwu6vPSf/GkdmRNsOfIeSGnh7muundsWItmBsVpMoNPVpM61qNzAVY3pZ1glzzAxLR40UyYM23eaDDbYQ==} - engines: {node: '>=18'} - cpu: [ppc64] - os: [linux] - '@esbuild/linux-ppc64@0.27.3': resolution: {integrity: sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==} engines: {node: '>=18'} @@ -4167,12 +4141,6 @@ packages: cpu: [riscv64] os: [linux] - '@esbuild/linux-riscv64@0.27.1': - resolution: {integrity: sha512-7zlw8p3IApcsN7mFw0O1Z1PyEk6PlKMu18roImfl3iQHTnr/yAfYv6s4hXPidbDoI2Q0pW+5xeoM4eTCC0UdrQ==} - engines: {node: '>=18'} - cpu: [riscv64] - os: [linux] - '@esbuild/linux-riscv64@0.27.3': resolution: {integrity: sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==} engines: {node: '>=18'} @@ -4221,12 +4189,6 @@ packages: cpu: [s390x] os: [linux] - '@esbuild/linux-s390x@0.27.1': - resolution: {integrity: sha512-cGj5wli+G+nkVQdZo3+7FDKC25Uh4ZVwOAK6A06Hsvgr8WqBBuOy/1s+PUEd/6Je+vjfm6stX0kmib5b/O2Ykw==} - engines: {node: '>=18'} - cpu: [s390x] - os: [linux] - '@esbuild/linux-s390x@0.27.3': resolution: {integrity: sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==} engines: {node: '>=18'} @@ -4275,12 +4237,6 @@ packages: cpu: [x64] os: [linux] - '@esbuild/linux-x64@0.27.1': - resolution: {integrity: sha512-z3H/HYI9MM0HTv3hQZ81f+AKb+yEoCRlUby1F80vbQ5XdzEMyY/9iNlAmhqiBKw4MJXwfgsh7ERGEOhrM1niMA==} - engines: {node: '>=18'} - cpu: [x64] - os: [linux] - '@esbuild/linux-x64@0.27.3': resolution: {integrity: sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==} engines: {node: '>=18'} @@ -4311,12 +4267,6 @@ packages: cpu: [arm64] os: [netbsd] - '@esbuild/netbsd-arm64@0.27.1': - resolution: {integrity: sha512-wzC24DxAvk8Em01YmVXyjl96Mr+ecTPyOuADAvjGg+fyBpGmxmcr2E5ttf7Im8D0sXZihpxzO1isus8MdjMCXQ==} - engines: {node: '>=18'} - cpu: [arm64] - os: [netbsd] - '@esbuild/netbsd-arm64@0.27.3': resolution: {integrity: sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==} engines: {node: '>=18'} @@ -4365,12 +4315,6 @@ packages: cpu: [x64] os: [netbsd] - '@esbuild/netbsd-x64@0.27.1': - resolution: {integrity: sha512-1YQ8ybGi2yIXswu6eNzJsrYIGFpnlzEWRl6iR5gMgmsrR0FcNoV1m9k9sc3PuP5rUBLshOZylc9nqSgymI+TYg==} - engines: {node: '>=18'} - cpu: [x64] - os: [netbsd] - '@esbuild/netbsd-x64@0.27.3': resolution: {integrity: sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==} engines: {node: '>=18'} @@ -4401,12 +4345,6 @@ packages: cpu: [arm64] os: [openbsd] - '@esbuild/openbsd-arm64@0.27.1': - resolution: {integrity: sha512-5Z+DzLCrq5wmU7RDaMDe2DVXMRm2tTDvX2KU14JJVBN2CT/qov7XVix85QoJqHltpvAOZUAc3ndU56HSMWrv8g==} - engines: {node: '>=18'} - cpu: [arm64] - os: [openbsd] - '@esbuild/openbsd-arm64@0.27.3': resolution: {integrity: sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==} engines: {node: '>=18'} @@ -4455,12 +4393,6 @@ packages: cpu: [x64] os: [openbsd] - '@esbuild/openbsd-x64@0.27.1': - resolution: {integrity: sha512-Q73ENzIdPF5jap4wqLtsfh8YbYSZ8Q0wnxplOlZUOyZy7B4ZKW8DXGWgTCZmF8VWD7Tciwv5F4NsRf6vYlZtqg==} - engines: {node: '>=18'} - cpu: [x64] - os: [openbsd] - '@esbuild/openbsd-x64@0.27.3': resolution: {integrity: sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==} engines: {node: '>=18'} @@ -4479,12 +4411,6 @@ packages: cpu: [arm64] os: [openharmony] - '@esbuild/openharmony-arm64@0.27.1': - resolution: {integrity: sha512-ajbHrGM/XiK+sXM0JzEbJAen+0E+JMQZ2l4RR4VFwvV9JEERx+oxtgkpoKv1SevhjavK2z2ReHk32pjzktWbGg==} - engines: {node: '>=18'} - cpu: [arm64] - os: [openharmony] - '@esbuild/openharmony-arm64@0.27.3': resolution: {integrity: sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==} engines: {node: '>=18'} @@ -4533,12 +4459,6 @@ packages: cpu: [x64] os: [sunos] - '@esbuild/sunos-x64@0.27.1': - resolution: {integrity: sha512-IPUW+y4VIjuDVn+OMzHc5FV4GubIwPnsz6ubkvN8cuhEqH81NovB53IUlrlBkPMEPxvNnf79MGBoz8rZ2iW8HA==} - engines: {node: '>=18'} - cpu: [x64] - os: [sunos] - '@esbuild/sunos-x64@0.27.3': resolution: {integrity: sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==} engines: {node: '>=18'} @@ -4587,12 +4507,6 @@ packages: cpu: [arm64] os: [win32] - '@esbuild/win32-arm64@0.27.1': - resolution: {integrity: sha512-RIVRWiljWA6CdVu8zkWcRmGP7iRRIIwvhDKem8UMBjPql2TXM5PkDVvvrzMtj1V+WFPB4K7zkIGM7VzRtFkjdg==} - engines: {node: '>=18'} - cpu: [arm64] - os: [win32] - '@esbuild/win32-arm64@0.27.3': resolution: {integrity: sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==} engines: {node: '>=18'} @@ -4641,12 +4555,6 @@ packages: cpu: [ia32] os: [win32] - '@esbuild/win32-ia32@0.27.1': - resolution: {integrity: sha512-2BR5M8CPbptC1AK5JbJT1fWrHLvejwZidKx3UMSF0ecHMa+smhi16drIrCEggkgviBwLYd5nwrFLSl5Kho96RQ==} - engines: {node: '>=18'} - cpu: [ia32] - os: [win32] - '@esbuild/win32-ia32@0.27.3': resolution: {integrity: sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==} engines: {node: '>=18'} @@ -4695,12 +4603,6 @@ packages: cpu: [x64] os: [win32] - '@esbuild/win32-x64@0.27.1': - resolution: {integrity: sha512-d5X6RMYv6taIymSk8JBP+nxv8DQAMY6A51GPgusqLdK9wBz5wWIXy1KjTck6HnjE9hqJzJRdk+1p/t5soSbCtw==} - engines: {node: '>=18'} - cpu: [x64] - os: [win32] - '@esbuild/win32-x64@0.27.3': resolution: {integrity: sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==} engines: {node: '>=18'} @@ -4809,6 +4711,9 @@ packages: '@fastify/cors@11.1.0': resolution: {integrity: sha512-sUw8ed8wP2SouWZTIbA7V2OQtMNpLj2W6qJOYhNdcmINTu6gsxVYXjQiM9mdi8UUDlcoDDJ/W2syPo1WB2QjYA==} + '@fastify/deepmerge@3.2.1': + resolution: {integrity: sha512-N5Oqvltoa2r9z1tbx4xjky0oRR60v+T47Ic4J1ukoVQcptLOrIdRnCSdTGmOmajZuHVKlTnfcmrjyqsGEW1ztA==} + '@fastify/error@4.0.0': resolution: {integrity: sha512-OO/SA8As24JtT1usTUTKgGH7uLvhfwZPwlptRi2Dp5P4KKmJI3gvsZ8MIHnNwDs4sLf/aai5LzTyl66xr7qMxA==} @@ -4827,6 +4732,18 @@ packages: '@fastify/rate-limit@10.3.0': resolution: {integrity: sha512-eIGkG9XKQs0nyynatApA3EVrojHOuq4l6fhB4eeCk4PIOeadvOJz9/4w3vGI44Go17uaXOWEcPkaD8kuKm7g6Q==} + '@fastify/send@4.1.0': + resolution: {integrity: sha512-TMYeQLCBSy2TOFmV95hQWkiTYgC/SEx7vMdV+wnZVX4tt8VBLKzmH8vV9OzJehV0+XBfg+WxPMt5wp+JBUKsVw==} + + '@fastify/static@9.1.0': + resolution: {integrity: sha512-EPRNQYqEYEYTK8yyGbcM0iHpyJaupb94bey5O6iCQfLTADr02kaZU+qeHSdd9H9TiMwTBVkrMa59V8CMbn3avQ==} + + '@fastify/swagger-ui@5.2.5': + resolution: {integrity: sha512-ky3I0LAkXKX/prwSDpoQ3kscBKsj2Ha6Gp1/JfgQSqyx0bm9F2bE//XmGVGj2cR9l5hUjZYn60/hqn7e+OLgWQ==} + + '@fastify/swagger@9.7.0': + resolution: {integrity: sha512-Vp1SC1GC2Hrkd3faFILv86BzUNyFz5N4/xdExqtCgkGASOzn/x+eMe4qXIGq7cdT6wif/P/oa6r1Ruqx19paZA==} + '@fastify/websocket@11.2.0': resolution: {integrity: sha512-3HrDPbAG1CzUCqnslgJxppvzaAZffieOVbLp1DAy1huCSynUWPifSvfdEDUR8HlJLp3sp1A36uOM2tJogADS8w==} @@ -4845,8 +4762,38 @@ packages: '@floating-ui/utils@0.2.1': resolution: {integrity: sha512-9TANp6GPoMtYzQdt54kfAyMmz1+osLlXdg2ENroU7zzrtflTLrrC/lgrIfaSe+Wu0b89GKccT7vxXA0MoAIO+Q==} - '@formatjs/intl-localematcher@0.6.2': - resolution: {integrity: sha512-XOMO2Hupl0wdd172Y06h6kLpBz6Dv+J4okPLl4LPtzbr8f66WbIoy4ev98EBuZ6ZK4h5ydTN6XneT4QVpD7cdA==} + '@formatjs/fast-memoize@3.1.1': + resolution: {integrity: sha512-CbNbf+tlJn1baRnPkNePnBqTLxGliG6DDgNa/UtV66abwIjwsliPMOt0172tzxABYzSuxZBZfcp//qI8AvBWPg==} + + '@formatjs/intl-localematcher@0.8.2': + resolution: {integrity: sha512-q05KMYGJLyqFNFtIb8NhWLF5X3aK/k0wYt7dnRFuy6aLQL+vUwQ1cg5cO4qawEiINybeCPXAWlprY2mSBjSXAQ==} + + '@fumadocs/tailwind@0.0.3': + resolution: {integrity: sha512-/FWcggMz9BhoX+13xBoZLX+XX9mYvJ50dkTqy3IfocJqua65ExcsKfxwKH8hgTO3vA5KnWv4+4jU7LaW2AjAmQ==} + peerDependencies: + tailwindcss: ^4.0.0 + peerDependenciesMeta: + tailwindcss: + optional: true + + '@fumari/json-schema-ts@0.0.2': + resolution: {integrity: sha512-A2x8nj45r8Kc3Gqa+HpWRF9uzIMc9dySB6L2R2kiyjLHXWBsZUX99Atj5+Yup/iRQXQ9s8AX+uAPwPze7Xn05A==} + engines: {node: '>=20.0.0'} + peerDependencies: + json-schema-typed: ^8.0.2 + peerDependenciesMeta: + json-schema-typed: + optional: true + + '@fumari/stf@1.0.4': + resolution: {integrity: sha512-ozyRDo4GjOEuE+XZlcMSP/7lwoAMwP4tZI+hdwhVWS1MeCSJqVpHFWIKucocXUUhusX2oqZb3K5ip1J79mR6zw==} + peerDependencies: + '@types/react': '*' + react: ^19.2.0 + react-dom: ^19.2.0 + peerDependenciesMeta: + '@types/react': + optional: true '@gar/promisify@1.1.3': resolution: {integrity: sha512-k2Ty1JcVojjJFwrg/ThKi2ujJ7XNLYaFGNB/bWT9wGR+oSMJHMa5w+CUq6p/pVrKeNNgA7pCqEcjSnHVoqJQFw==} @@ -4871,6 +4818,12 @@ packages: '@hapi/topo@5.1.0': resolution: {integrity: sha512-foQZKJig7Ob0BMAYBfcJk8d77QtOe7Wo4ox7ff1lQYoNNAb6jwcY1ncdoy2e9wQZzvNy7ODZCYJkK8kzmcAnAg==} + '@hono/node-server@1.19.11': + resolution: {integrity: sha512-dr8/3zEaB+p0D2n/IUrlPF1HZm586qgJNXK1a9fhg/PzdtkK7Ksd5l312tJX2yBuALqDYBlG20QEbayqPyxn+g==} + engines: {node: '>=18.14.1'} + peerDependencies: + hono: ^4 + '@hookform/resolvers@3.3.4': resolution: {integrity: sha512-o5cgpGOuJYrd+iMKvkttOclgwRW86EsWJZZRC23prf0uU2i48Htq4PuT73AVb9ionFyZrwYEITuOFGF+BydEtQ==} peerDependencies: @@ -5265,6 +5218,16 @@ packages: '@mdx-js/mdx@3.1.1': resolution: {integrity: sha512-f6ZO2ifpwAQIpzGWaBQT2TXxPv6z3RBzQKpVftEWN78Vl/YweF1uwussDx8ECAXVtr3Rs89fKyG9YlzUs9DyGQ==} + '@modelcontextprotocol/sdk@1.27.1': + resolution: {integrity: sha512-sr6GbP+4edBwFndLbM60gf07z0FQ79gaExpnsjMGePXqFcSSb7t6iscpjk9DhFhwd+mTEQrzNafGP8/iGGFYaA==} + engines: {node: '>=18'} + peerDependencies: + '@cfworker/json-schema': ^4.1.1 + zod: ^3.25 || ^4.0 + peerDependenciesMeta: + '@cfworker/json-schema': + optional: true + '@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.2': resolution: {integrity: sha512-9bfjwDxIDWmmOKusUcqdS4Rw+SETlp9Dy39Xui9BEGEk19dDwH0jhipwFzEff/pFg95NKymc6TOTbRKcWeRqyQ==} cpu: [arm64] @@ -5301,6 +5264,12 @@ packages: '@napi-rs/wasm-runtime@1.1.0': resolution: {integrity: sha512-Fq6DJW+Bb5jaWE69/qOE0D1TUN9+6uWhCeZpdnSBk14pjLcCWR7Q8n49PTSPHazM37JqrsdpEthXy2xn6jWWiA==} + '@napi-rs/wasm-runtime@1.1.3': + resolution: {integrity: sha512-xK9sGVbJWYb08+mTJt3/YV24WxvxpXcXtP6B172paPZ+Ts69Re9dAr7lKwJoeIx8OoeuimEiRZ7umkiUVClmmQ==} + peerDependencies: + '@emnapi/core': ^1.7.1 + '@emnapi/runtime': ^1.7.1 + '@next/env@15.0.3': resolution: {integrity: sha512-t9Xy32pjNOvVn2AS+Utt6VmyrshbpfUMhIjFO60gI58deSo/KgLOp31XZ4O+kY/Is8WAGYwA5gR7kOb1eORDBA==} @@ -6409,8 +6378,8 @@ packages: peerDependencies: '@opentelemetry/api': ^1.1.0 - '@orama/orama@3.1.16': - resolution: {integrity: sha512-scSmQBD8eANlMUOglxHrN1JdSW8tDghsPuS83otqealBiIeMukCQMOf/wc0JJjDXomqwNdEQFLXLGHrU6PGxuA==} + '@orama/orama@3.1.18': + resolution: {integrity: sha512-a61ljmRVVyG5MC/698C8/FfFDw5a8LOIvyOLW5fztgUXqUpc1jOfQzOitSCbge657OgXXThmY3Tk8fpiDb4UcA==} engines: {node: '>= 20.0.0'} '@oslojs/asn1@1.0.0': @@ -6520,101 +6489,323 @@ packages: cpu: [x64] os: [win32] + '@oxc-parser/binding-android-arm-eabi@0.124.0': + resolution: {integrity: sha512-+R9zCafSL8ovjokdPtorUp3sXrh8zQ2AC2L0ivXNvlLR0WS+5WdPkNVrnENq5UvzagM4Xgl0NPsJKz3Hv9+y8g==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [android] + '@oxc-parser/binding-android-arm64@0.102.0': resolution: {integrity: sha512-pD2if3w3cxPvYbsBSTbhxAYGDaG6WVwnqYG0mYRQ142D6SJ6BpNs7YVQrqpRA2AJQCmzaPP5TRp/koFLebagfQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [android] + '@oxc-parser/binding-android-arm64@0.124.0': + resolution: {integrity: sha512-ULHC/gVZ+nP4pd3kNNQTYaQ/e066BW/KuY5qUsvwkVWwOUQGDg+WpfyVOmQ4xfxoue6cMlkKkJ+ntdzfDXpNlg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + '@oxc-parser/binding-darwin-arm64@0.102.0': resolution: {integrity: sha512-RzMN6f6MrjjpQC2Dandyod3iOscofYBpHaTecmoRRbC5sJMwsurkqUMHzoJX9F6IM87kn8m/JcClnoOfx5Sesw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [darwin] + '@oxc-parser/binding-darwin-arm64@0.124.0': + resolution: {integrity: sha512-fGJ2hw7bnbUYn6UvTjp0m4WJ9zXz3cohgcwcgeo7gUZehpPNpvcVEVeIVHNmHnAuAw/ysf4YJR8DA1E+xCA4Lw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + '@oxc-parser/binding-darwin-x64@0.102.0': resolution: {integrity: sha512-Sr2/3K6GEcejY+HgWp5HaxRPzW5XHe9IfGKVn9OhLt8fzVLnXbK5/GjXj7JjMCNKI3G3ZPZDG2Dgm6CX3MaHCA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [darwin] + '@oxc-parser/binding-darwin-x64@0.124.0': + resolution: {integrity: sha512-j0+re9pgps5BH2Tk3fm59Hi3QuLP3C4KhqXi6A+wRHHHJWDFR8mc/KI9mBrfk2JRT+15doGo+zv1eN75/9DuOw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + '@oxc-parser/binding-freebsd-x64@0.102.0': resolution: {integrity: sha512-s9F2N0KJCGEpuBW6ChpFfR06m2Id9ReaHSl8DCca4HvFNt8SJFPp8fq42n2PZy68rtkremQasM0JDrK2BoBeBQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [freebsd] + '@oxc-parser/binding-freebsd-x64@0.124.0': + resolution: {integrity: sha512-0k5mS0npnrhKy72UfF51lpOZ2ESoPWn6gdFw+RdeRWcokraDW1O2kSx3laQ+yk7cCEavQdJSpWCYS/GvBbUCXQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + '@oxc-parser/binding-linux-arm-gnueabihf@0.102.0': resolution: {integrity: sha512-zRCIOWzLbqhfY4g8KIZDyYfO2Fl5ltxdQI1v2GlePj66vFWRl8cf4qcBGzxKfsH3wCZHAhmWd1Ht59mnrfH/UQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [linux] + '@oxc-parser/binding-linux-arm-gnueabihf@0.124.0': + resolution: {integrity: sha512-P/i4eguRWvAUfGdfhQYg1jpwYkyUV6D3gefIH7HhmRl1Ph6P4IqTIEVcyJr1i/3vr1V5OHU4wonH6/ue/Qzvrw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@oxc-parser/binding-linux-arm-musleabihf@0.124.0': + resolution: {integrity: sha512-/ameqFQH5fFP+66Atr8Ynv/2rYe4utcU7L4MoWS5JtrFLVO78g4qDLavyIlJxa6caSwYOvG/eO3c/DXqY5/6Rw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + '@oxc-parser/binding-linux-arm64-gnu@0.102.0': resolution: {integrity: sha512-5n5RbHgfjulRhKB0pW5p0X/NkQeOpI4uI9WHgIZbORUDATGFC8yeyPA6xYGEs+S3MyEAFxl4v544UEIWwqAgsA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] + '@oxc-parser/binding-linux-arm64-gnu@0.124.0': + resolution: {integrity: sha512-gNeyEcXTtfrRCbj2EfxWU85Fs0wIX3p44Y3twnvuMfkWlLrb9M1Z25AYNSKjJM+fdAjeeQCjw0on47zFuBYwQw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + '@oxc-parser/binding-linux-arm64-musl@0.102.0': resolution: {integrity: sha512-/XWcmglH/VJ4yKAGTLRgPKSSikh3xciNxkwGiURt8dS30b+3pwc4ZZmudMu0tQ3mjSu0o7V9APZLMpbHK8Bp5w==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] + '@oxc-parser/binding-linux-arm64-musl@0.124.0': + resolution: {integrity: sha512-uvG7v4Tz9S8/PVqY0SP0DLHxo4hZGe+Pv2tGVnwcsjKCCUPjplbrFVvDzXq+kOaEoUkiCY0Kt1hlZ6FDJ1LKNQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + + '@oxc-parser/binding-linux-ppc64-gnu@0.124.0': + resolution: {integrity: sha512-t7KZaaUhfp2au0MRpoENEFqwLKYDdptEry6V7pTAVdPEcFG4P6ii8yeGU9m6p5vb+b8WEKmdpGMNXBEYy7iJdw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + '@oxc-parser/binding-linux-riscv64-gnu@0.102.0': resolution: {integrity: sha512-2jtIq4nswvy6xdqv1ndWyvVlaRpS0yqomLCvvHdCFx3pFXo5Aoq4RZ39kgvFWrbAtpeYSYeAGFnwgnqjx9ftdw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [riscv64] os: [linux] + '@oxc-parser/binding-linux-riscv64-gnu@0.124.0': + resolution: {integrity: sha512-eurGGaxHZiIQ+fBSageS8TAkRqZgdOiBeqNrWAqAPup9hXBTmQ0WcBjwsLElf+3jvDL9NhnX0dOgOqPfsjSjdg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [riscv64] + os: [linux] + + '@oxc-parser/binding-linux-riscv64-musl@0.124.0': + resolution: {integrity: sha512-d1V7/ll1i/LhqE/gZy6Wbz6evlk0egh2XKkwMI3epiojtbtUwQSLIER0Y3yDBBocPuWOjJdvmjtEmPTTLXje/w==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [riscv64] + os: [linux] + '@oxc-parser/binding-linux-s390x-gnu@0.102.0': resolution: {integrity: sha512-Yp6HX/574mvYryiqj0jNvNTJqo4pdAsNP2LPBTxlDQ1cU3lPd7DUA4MQZadaeLI8+AGB2Pn50mPuPyEwFIxeFg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [s390x] os: [linux] + '@oxc-parser/binding-linux-s390x-gnu@0.124.0': + resolution: {integrity: sha512-w1+cBvriUteOpox6ATqCFVkpGL47PFdcfCPGmgUZbd78Fw44U0gQkc+kVGvAOTvGrptMYgwomD1c6OTVvkrpGg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + '@oxc-parser/binding-linux-x64-gnu@0.102.0': resolution: {integrity: sha512-R4b0xZpDRhoNB2XZy0kLTSYm0ZmWeKjTii9fcv1Mk3/SIGPrrglwt4U6zEtwK54Dfi4Bve5JnQYduigR/gyDzw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] + '@oxc-parser/binding-linux-x64-gnu@0.124.0': + resolution: {integrity: sha512-RRB1evQiXRtMCsQQiAh9U0H3HzguLpE0ytfStuhRgmOj7tqUCOVxkHsvM9geZjAax6NqVRj7VXx32qjjkZPsBw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + '@oxc-parser/binding-linux-x64-musl@0.102.0': resolution: {integrity: sha512-xM5A+03Ti3jvWYZoqaBRS3lusvnvIQjA46Fc9aBE/MHgvKgHSkrGEluLWg/33QEwBwxupkH25Pxc1yu97oZCtg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] + '@oxc-parser/binding-linux-x64-musl@0.124.0': + resolution: {integrity: sha512-asVYN0qmSHlCU8H9Q47SmeJ/Z5EG4IWCC+QGxkfFboI5qh15aLlJnHmnrV61MwQRPXGnVC/sC3qKhrUyqGxUqw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + '@oxc-parser/binding-openharmony-arm64@0.102.0': resolution: {integrity: sha512-AieLlsliblyaTFq7Iw9Nc618tgwV02JT4fQ6VIUd/3ZzbluHIHfPjIXa6Sds+04krw5TvCS8lsegtDYAyzcyhg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [openharmony] + '@oxc-parser/binding-openharmony-arm64@0.124.0': + resolution: {integrity: sha512-nhwuxm6B8pn9lzAzMUfa571L5hCXYwQo8C8cx5aGOuHWCzruR8gPJnRRXGBci+uGaIIQEZDyU/U6HDgrSp/JlQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + '@oxc-parser/binding-wasm32-wasi@0.102.0': resolution: {integrity: sha512-w6HRyArs1PBb9rDsQSHlooe31buUlUI2iY8sBzp62jZ1tmvaJo9EIVTQlRNDkwJmk9DF9uEyIJ82EkZcCZTs9A==} engines: {node: '>=14.0.0'} cpu: [wasm32] + '@oxc-parser/binding-wasm32-wasi@0.124.0': + resolution: {integrity: sha512-LWuq4Dl9tff7n+HjJcqoBjDlVCtruc0shgtdtGM+rTUIE9aFxHA/P+wCYR+aWMjN8m9vNaRME/sKXErmhmeKrA==} + engines: {node: '>=14.0.0'} + cpu: [wasm32] + '@oxc-parser/binding-win32-arm64-msvc@0.102.0': resolution: {integrity: sha512-pqP5UuLiiFONQxqGiUFMdsfybaK1EOK4AXiPlvOvacLaatSEPObZGpyCkAcj9aZcvvNwYdeY9cxGM9IT3togaA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [win32] + '@oxc-parser/binding-win32-arm64-msvc@0.124.0': + resolution: {integrity: sha512-aOh3Lf3AeH0dgzT4yBXcArFZ8VhqNXwZ/xlN0GqBtgVaGoHOOqL2YHlcVIgT+ghsXPVR2PTtYgBiQ1CNK7jp5A==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@oxc-parser/binding-win32-ia32-msvc@0.124.0': + resolution: {integrity: sha512-sib5xC0nz/+SCpaETBuHBz4SXS02KuG5HtyOcHsO/SK5ZvLRGhOZx0elDKawjb6adFkD7dQCqpXUS25wY6ELKQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ia32] + os: [win32] + '@oxc-parser/binding-win32-x64-msvc@0.102.0': resolution: {integrity: sha512-ntMcL35wuLR1A145rLSmm7m7j8JBZGkROoB9Du0KFIFcfi/w1qk75BdCeiTl3HAKrreAnuhW3QOGs6mJhntowA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [win32] + '@oxc-parser/binding-win32-x64-msvc@0.124.0': + resolution: {integrity: sha512-UgojtjGUgZgAZQYt7SC6VO65OVdxEkRe2q+2vbHJO//18qw3Hrk6UvHGQKldsQKgbVcIBT/YBrt85YberiYIPQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + '@oxc-project/types@0.102.0': resolution: {integrity: sha512-8Skrw405g+/UJPKWJ1twIk3BIH2nXdiVlVNtYT23AXVwpsd79es4K+KYt06Fbnkc5BaTvk/COT2JuCLYdwnCdA==} + '@oxc-project/types@0.124.0': + resolution: {integrity: sha512-VBFWMTBvHxS11Z5Lvlr3IWgrwhMTXV+Md+EQF0Xf60+wAdsGFTBx7X7K/hP4pi8N7dcm1RvcHwDxZ16Qx8keUg==} + '@oxc-project/types@0.94.0': resolution: {integrity: sha512-+UgQT/4o59cZfH6Cp7G0hwmqEQ0wE+AdIwhikdwnhWI9Dp8CgSY081+Q3O67/wq3VJu8mgUEB93J9EHHn70fOw==} + '@oxc-resolver/binding-android-arm-eabi@11.19.1': + resolution: {integrity: sha512-aUs47y+xyXHUKlbhqHUjBABjvycq6YSD7bpxSW7vplUmdzAlJ93yXY6ZR0c1o1x5A/QKbENCvs3+NlY8IpIVzg==} + cpu: [arm] + os: [android] + + '@oxc-resolver/binding-android-arm64@11.19.1': + resolution: {integrity: sha512-oolbkRX+m7Pq2LNjr/kKgYeC7bRDMVTWPgxBGMjSpZi/+UskVo4jsMU3MLheZV55jL6c3rNelPl4oD60ggYmqA==} + cpu: [arm64] + os: [android] + + '@oxc-resolver/binding-darwin-arm64@11.19.1': + resolution: {integrity: sha512-nUC6d2i3R5B12sUW4O646qD5cnMXf2oBGPLIIeaRfU9doJRORAbE2SGv4eW6rMqhD+G7nf2Y8TTJTLiiO3Q/dQ==} + cpu: [arm64] + os: [darwin] + + '@oxc-resolver/binding-darwin-x64@11.19.1': + resolution: {integrity: sha512-cV50vE5+uAgNcFa3QY1JOeKDSkM/9ReIcc/9wn4TavhW/itkDGrXhw9jaKnkQnGbjJ198Yh5nbX/Gr2mr4Z5jQ==} + cpu: [x64] + os: [darwin] + + '@oxc-resolver/binding-freebsd-x64@11.19.1': + resolution: {integrity: sha512-xZOQiYGFxtk48PBKff+Zwoym7ScPAIVp4c14lfLxizO2LTTTJe5sx9vQNGrBymrf/vatSPNMD4FgsaaRigPkqw==} + cpu: [x64] + os: [freebsd] + + '@oxc-resolver/binding-linux-arm-gnueabihf@11.19.1': + resolution: {integrity: sha512-lXZYWAC6kaGe/ky2su94e9jN9t6M0/6c+GrSlCqL//XO1cxi5lpAhnJYdyrKfm0ZEr/c7RNyAx3P7FSBcBd5+A==} + cpu: [arm] + os: [linux] + + '@oxc-resolver/binding-linux-arm-musleabihf@11.19.1': + resolution: {integrity: sha512-veG1kKsuK5+t2IsO9q0DErYVSw2azvCVvWHnfTOS73WE0STdLLB7Q1bB9WR+yHPQM76ASkFyRbogWo1GR1+WbQ==} + cpu: [arm] + os: [linux] + + '@oxc-resolver/binding-linux-arm64-gnu@11.19.1': + resolution: {integrity: sha512-heV2+jmXyYnUrpUXSPugqWDRpnsQcDm2AX4wzTuvgdlZfoNYO0O3W2AVpJYaDn9AG4JdM6Kxom8+foE7/BcSig==} + cpu: [arm64] + os: [linux] + + '@oxc-resolver/binding-linux-arm64-musl@11.19.1': + resolution: {integrity: sha512-jvo2Pjs1c9KPxMuMPIeQsgu0mOJF9rEb3y3TdpsrqwxRM+AN6/nDDwv45n5ZrUnQMsdBy5gIabioMKnQfWo9ew==} + cpu: [arm64] + os: [linux] + + '@oxc-resolver/binding-linux-ppc64-gnu@11.19.1': + resolution: {integrity: sha512-vLmdNxWCdN7Uo5suays6A/+ywBby2PWBBPXctWPg5V0+eVuzsJxgAn6MMB4mPlshskYbppjpN2Zg83ArHze9gQ==} + cpu: [ppc64] + os: [linux] + + '@oxc-resolver/binding-linux-riscv64-gnu@11.19.1': + resolution: {integrity: sha512-/b+WgR+VTSBxzgOhDO7TlMXC1ufPIMR6Vj1zN+/x+MnyXGW7prTLzU9eW85Aj7Th7CCEG9ArCbTeqxCzFWdg2w==} + cpu: [riscv64] + os: [linux] + + '@oxc-resolver/binding-linux-riscv64-musl@11.19.1': + resolution: {integrity: sha512-YlRdeWb9j42p29ROh+h4eg/OQ3dTJlpHSa+84pUM9+p6i3djtPz1q55yLJhgW9XfDch7FN1pQ/Vd6YP+xfRIuw==} + cpu: [riscv64] + os: [linux] + + '@oxc-resolver/binding-linux-s390x-gnu@11.19.1': + resolution: {integrity: sha512-EDpafVOQWF8/MJynsjOGFThcqhRHy417sRyLfQmeiamJ8qVhSKAn2Dn2VVKUGCjVB9C46VGjhNo7nOPUi1x6uA==} + cpu: [s390x] + os: [linux] + + '@oxc-resolver/binding-linux-x64-gnu@11.19.1': + resolution: {integrity: sha512-NxjZe+rqWhr+RT8/Ik+5ptA3oz7tUw361Wa5RWQXKnfqwSSHdHyrw6IdcTfYuml9dM856AlKWZIUXDmA9kkiBQ==} + cpu: [x64] + os: [linux] + + '@oxc-resolver/binding-linux-x64-musl@11.19.1': + resolution: {integrity: sha512-cM/hQwsO3ReJg5kR+SpI69DMfvNCp+A/eVR4b4YClE5bVZwz8rh2Nh05InhwI5HR/9cArbEkzMjcKgTHS6UaNw==} + cpu: [x64] + os: [linux] + + '@oxc-resolver/binding-openharmony-arm64@11.19.1': + resolution: {integrity: sha512-QF080IowFB0+9Rh6RcD19bdgh49BpQHUW5TajG1qvWHvmrQznTZZjYlgE2ltLXyKY+qs4F/v5xuX1XS7Is+3qA==} + cpu: [arm64] + os: [openharmony] + + '@oxc-resolver/binding-wasm32-wasi@11.19.1': + resolution: {integrity: sha512-w8UCKhX826cP/ZLokXDS6+milN8y4X7zidsAttEdWlVoamTNf6lhBJldaWr3ukTDiye7s4HRcuPEPOXNC432Vg==} + engines: {node: '>=14.0.0'} + cpu: [wasm32] + + '@oxc-resolver/binding-win32-arm64-msvc@11.19.1': + resolution: {integrity: sha512-nJ4AsUVZrVKwnU/QRdzPCCrO0TrabBqgJ8pJhXITdZGYOV28TIYystV1VFLbQ7DtAcaBHpocT5/ZJnF78YJPtQ==} + cpu: [arm64] + os: [win32] + + '@oxc-resolver/binding-win32-ia32-msvc@11.19.1': + resolution: {integrity: sha512-EW+ND5q2Tl+a3pH81l1QbfgbF3HmqgwLfDfVithRFheac8OTcnbXt/JxqD2GbDkb7xYEqy1zNaVFRr3oeG8npA==} + cpu: [ia32] + os: [win32] + + '@oxc-resolver/binding-win32-x64-msvc@11.19.1': + resolution: {integrity: sha512-6hIU3RQu45B+VNTY4Ru8ppFwjVS/S5qwYyGhBotmjxfEKk41I2DlGtRfGJndZ5+6lneE2pwloqunlOyZuX/XAw==} + cpu: [x64] + os: [win32] + '@oxc-transform/binding-android-arm64@0.102.0': resolution: {integrity: sha512-JLBT7EiExsGmB6LuBBnm6qTfg0rLSxBU+F7xjqy6UXYpL7zhqelGJL7IAq6Pu5UYFT55zVlXXmgzLOXQfpQjXA==} engines: {node: ^20.19.0 || >=22.12.0} @@ -8401,6 +8592,22 @@ packages: '@rrweb/utils@2.0.0-alpha.20': resolution: {integrity: sha512-MTQOmhPRe39C0fYaCnnVYOufQsyGzwNXpUStKiyFSfGLUJrzuwhbRoUAKR5w6W2j5XuA0bIz3ZDIBztkquOhLw==} + '@scalar/helpers@0.4.3': + resolution: {integrity: sha512-Gv2V7SFreLx3DltzF2lKXdaJSH5cP1LOyt9PxON1cSWGxkrs3sg93c1taEJsW24E9ckfYXkL5hjCAVLfAN3wQw==} + engines: {node: '>=22'} + + '@scalar/json-magic@0.12.5': + resolution: {integrity: sha512-MkGOjodEeQ7V7M78W6Oq+t3q1LaUR+SRLZLqFbU6s26Gc+12T+v89JXcHvd+3ug0xFVMg/kdczZ3O6miBhyNsA==} + engines: {node: '>=22'} + + '@scalar/openapi-types@0.7.0': + resolution: {integrity: sha512-kN0PwlJW0de4bwQ4ib+mBHzKJUvBCyR/gwU4zLEq6SCbj+GfgYUh+2a0/yl1WYVUiSkkwFsHjfmQ8KjhR3HK0Q==} + engines: {node: '>=22'} + + '@scalar/openapi-upgrader@0.2.4': + resolution: {integrity: sha512-AcrF7BMxKCTHnT82SHbHun6dJO4XC9tS5gD7EJsr/7YwFkx9JtbtZCryJXtqWJ5c7i1v1KH4PRRjDga/hCULTQ==} + engines: {node: '>=22'} + '@segment/loosely-validate-event@2.0.0': resolution: {integrity: sha512-ZMCSfztDBqwotkl848ODgVcAmN4OItEWDCkshcKz0/W6gGSQayuuCtWV/MlodFivAZD793d6UgANd6wCXUfrIw==} @@ -8482,62 +8689,56 @@ packages: '@shikijs/core@3.17.0': resolution: {integrity: sha512-/HjeOnbc62C+n33QFNFrAhUlIADKwfuoS50Ht0pxujxP4QjZAlFp5Q+OkDo531SCTzivx5T18khwyBdKoPdkuw==} - '@shikijs/core@3.19.0': - resolution: {integrity: sha512-L7SrRibU7ZoYi1/TrZsJOFAnnHyLTE1SwHG1yNWjZIVCqjOEmCSuK2ZO9thnRbJG6TOkPp+Z963JmpCNw5nzvA==} - - '@shikijs/core@3.3.0': - resolution: {integrity: sha512-CovkFL2WVaHk6PCrwv6ctlmD4SS1qtIfN8yEyDXDYWh4ONvomdM9MaFw20qHuqJOcb8/xrkqoWQRJ//X10phOQ==} + '@shikijs/core@4.0.2': + resolution: {integrity: sha512-hxT0YF4ExEqB8G/qFdtJvpmHXBYJ2lWW7qTHDarVkIudPFE6iCIrqdgWxGn5s+ppkGXI0aEGlibI0PAyzP3zlw==} + engines: {node: '>=20'} '@shikijs/engine-javascript@3.17.0': resolution: {integrity: sha512-WwF99xdP8KfuDrIbT4wxyypfhoIxMeeOCp1AiuvzzZ6JT5B3vIuoclL8xOuuydA6LBeeNXUF/XV5zlwwex1jlA==} - '@shikijs/engine-javascript@3.19.0': - resolution: {integrity: sha512-ZfWJNm2VMhKkQIKT9qXbs76RRcT0SF/CAvEz0+RkpUDAoDaCx0uFdCGzSRiD9gSlhm6AHkjdieOBJMaO2eC1rQ==} - - '@shikijs/engine-javascript@3.3.0': - resolution: {integrity: sha512-XlhnFGv0glq7pfsoN0KyBCz9FJU678LZdQ2LqlIdAj6JKsg5xpYKay3DkazXWExp3DTJJK9rMOuGzU2911pg7Q==} + '@shikijs/engine-javascript@4.0.2': + resolution: {integrity: sha512-7PW0Nm49DcoUIQEXlJhNNBHyoGMjalRETTCcjMqEaMoJRLljy1Bi/EGV3/qLBgLKQejdspiiYuHGQW6dX94Nag==} + engines: {node: '>=20'} '@shikijs/engine-oniguruma@3.17.0': resolution: {integrity: sha512-flSbHZAiOZDNTrEbULY8DLWavu/TyVu/E7RChpLB4WvKX4iHMfj80C6Hi3TjIWaQtHOW0KC6kzMcuB5TO1hZ8Q==} - '@shikijs/engine-oniguruma@3.19.0': - resolution: {integrity: sha512-1hRxtYIJfJSZeM5ivbUXv9hcJP3PWRo5prG/V2sWwiubUKTa+7P62d2qxCW8jiVFX4pgRHhnHNp+qeR7Xl+6kg==} - - '@shikijs/engine-oniguruma@3.3.0': - resolution: {integrity: sha512-l0vIw+GxeNU7uGnsu6B+Crpeqf+WTQ2Va71cHb5ZYWEVEPdfYwY5kXwYqRJwHrxz9WH+pjSpXQz+TJgAsrkA5A==} + '@shikijs/engine-oniguruma@4.0.2': + resolution: {integrity: sha512-UpCB9Y2sUKlS9z8juFSKz7ZtysmeXCgnRF0dlhXBkmQnek7lAToPte8DkxmEYGNTMii72zU/lyXiCB6StuZeJg==} + engines: {node: '>=20'} '@shikijs/langs@3.17.0': resolution: {integrity: sha512-icmur2n5Ojb+HAiQu6NEcIIJ8oWDFGGEpiqSCe43539Sabpx7Y829WR3QuUW2zjTM4l6V8Sazgb3rrHO2orEAw==} - '@shikijs/langs@3.19.0': - resolution: {integrity: sha512-dBMFzzg1QiXqCVQ5ONc0z2ebyoi5BKz+MtfByLm0o5/nbUu3Iz8uaTCa5uzGiscQKm7lVShfZHU1+OG3t5hgwg==} + '@shikijs/langs@4.0.2': + resolution: {integrity: sha512-KaXby5dvoeuZzN0rYQiPMjFoUrz4hgwIE+D6Du9owcHcl6/g16/yT5BQxSW5cGt2MZBz6Hl0YuRqf12omRfUUg==} + engines: {node: '>=20'} - '@shikijs/langs@3.3.0': - resolution: {integrity: sha512-zt6Kf/7XpBQKSI9eqku+arLkAcDQ3NHJO6zFjiChI8w0Oz6Jjjay7pToottjQGjSDCFk++R85643WbyINcuL+g==} + '@shikijs/primitive@4.0.2': + resolution: {integrity: sha512-M6UMPrSa3fN5ayeJwFVl9qWofl273wtK1VG8ySDZ1mQBfhCpdd8nEx7nPZ/tk7k+TYcpqBZzj/AnwxT9lO+HJw==} + engines: {node: '>=20'} - '@shikijs/rehype@3.19.0': - resolution: {integrity: sha512-pzp/JVxrTd95HgMimHgYb9lCGSzVYEp1BweWUprFAEgGOF15d9IyX+IVW/+1Z5ZxdT9IUUF27UbC5YdA5oCzjw==} + '@shikijs/rehype@4.0.2': + resolution: {integrity: sha512-cmPlKLD8JeojasNFoY64162ScpEdEdQUMuVodPCrv1nx1z3bjmGwoKWDruQWa/ejSznImlaeB0Ty6Q3zPaVQAA==} + engines: {node: '>=20'} '@shikijs/themes@3.17.0': resolution: {integrity: sha512-/xEizMHLBmMHwtx4JuOkRf3zwhWD2bmG5BRr0IPjpcWpaq4C3mYEuTk/USAEglN0qPrTwEHwKVpSu/y2jhferA==} - '@shikijs/themes@3.19.0': - resolution: {integrity: sha512-H36qw+oh91Y0s6OlFfdSuQ0Ld+5CgB/VE6gNPK+Hk4VRbVG/XQgkjnt4KzfnnoO6tZPtKJKHPjwebOCfjd6F8A==} - - '@shikijs/themes@3.3.0': - resolution: {integrity: sha512-tXeCvLXBnqq34B0YZUEaAD1lD4lmN6TOHAhnHacj4Owh7Ptb/rf5XCDeROZt2rEOk5yuka3OOW2zLqClV7/SOg==} + '@shikijs/themes@4.0.2': + resolution: {integrity: sha512-mjCafwt8lJJaVSsQvNVrJumbnnj1RI8jbUKrPKgE6E3OvQKxnuRoBaYC51H4IGHePsGN/QtALglWBU7DoKDFnA==} + engines: {node: '>=20'} - '@shikijs/transformers@3.19.0': - resolution: {integrity: sha512-e6vwrsyw+wx4OkcrDbL+FVCxwx8jgKiCoXzakVur++mIWVcgpzIi8vxf4/b4dVTYrV/nUx5RjinMf4tq8YV8Fw==} + '@shikijs/transformers@4.0.2': + resolution: {integrity: sha512-1+L0gf9v+SdDXs08vjaLb3mBFa8U7u37cwcBQIv/HCocLwX69Tt6LpUCjtB+UUTvQxI7BnjZKhN/wMjhHBcJGg==} + engines: {node: '>=20'} '@shikijs/types@3.17.0': resolution: {integrity: sha512-wjLVfutYWVUnxAjsWEob98xgyaGv0dTEnMZDruU5mRjVN7szcGOfgO+997W2yR6odp+1PtSBNeSITRRTfUzK/g==} - '@shikijs/types@3.19.0': - resolution: {integrity: sha512-Z2hdeEQlzuntf/BZpFG8a+Fsw9UVXdML7w0o3TgSXV3yNESGon+bs9ITkQb3Ki7zxoXOOu5oJWqZ2uto06V9iQ==} - - '@shikijs/types@3.3.0': - resolution: {integrity: sha512-KPCGnHG6k06QG/2pnYGbFtFvpVJmC3uIpXrAiPrawETifujPBv0Se2oUxm5qYgjCvGJS9InKvjytOdN+bGuX+Q==} + '@shikijs/types@4.0.2': + resolution: {integrity: sha512-qzbeRooUTPnLE+sHD/Z8DStmaDgnbbc/pMrU203950aRqjX/6AFHeDYT+j00y2lPdz0ywJKx7o/7qnqTivtlXg==} + engines: {node: '>=20'} '@shikijs/vscode-textmate@10.0.2': resolution: {integrity: sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==} @@ -8855,6 +9056,9 @@ packages: '@standard-schema/spec@1.0.0': resolution: {integrity: sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==} + '@standard-schema/spec@1.1.0': + resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + '@standard-schema/utils@0.3.0': resolution: {integrity: sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==} @@ -9616,6 +9820,9 @@ packages: '@types/js-yaml@4.0.9': resolution: {integrity: sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg==} + '@types/json-schema@7.0.15': + resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} + '@types/jsonwebtoken@8.5.9': resolution: {integrity: sha512-272FMnFGzAVMGtu9tkr29hRL6bZj4Zs1KZNeHLnKqAvp06tAIcarTMwOh8/8bz4FmKRcMxZhZNeUAQsNLoiPhg==} @@ -9868,6 +10075,10 @@ packages: '@types/yargs@17.0.32': resolution: {integrity: sha512-xQ67Yc/laOG5uMfX/093MRlGGCIBzZMarVa+gfNKJxWAIgykYpVGkBdbqEzGDDfCrVUj6Hiff4mTZ5BA6TmAog==} + '@typescript-eslint/types@8.58.1': + resolution: {integrity: sha512-io/dV5Aw5ezwzfPBBWLoT+5QfVtP8O7q4Kftjn5azJ88bYyp/ZMCsyW1lpKK46EXJcaYMZ1JtYj+s/7TdzmQMw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@ungap/structured-clone@1.2.0': resolution: {integrity: sha512-zuVdFrMJiuCDQUMCzQaD6KL28MjnqqN8XnAqiEq9PNm/hCPTSGfrXCOfwj1ow4LFb/tNymJPwsNbVePc1xFqrQ==} @@ -10183,8 +10394,8 @@ packages: ajv: optional: true - ajv@8.12.0: - resolution: {integrity: sha512-sRu1kpcO9yLtYxBKvqfTeh9KzZEwO3STyX1HT+4CaDzC6HpTGYhIhPIzj9XuKU7KYDwnaeh5hcOwjy1QuJzBPA==} + ajv@8.18.0: + resolution: {integrity: sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==} alien-signals@3.1.1: resolution: {integrity: sha512-ogkIWbVrLwKtHY6oOAXaYkAxP+cTH7V5FZ5+Tm4NZFd8VDZ6uNMDrfzqctTZ42eTMCSR3ne3otpcxmqSnFfPYA==} @@ -12043,11 +12254,6 @@ packages: engines: {node: '>=18'} hasBin: true - esbuild@0.27.1: - resolution: {integrity: sha512-yY35KZckJJuVVPXpvjgxiCuVEJT67F6zDeVTv4rizyPrfGBUpZQsvmxnN+C371c2esD/hNMjj4tpBhuueLN7aA==} - engines: {node: '>=18'} - hasBin: true - esbuild@0.27.3: resolution: {integrity: sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==} engines: {node: '>=18'} @@ -12088,6 +12294,9 @@ packages: engines: {node: '>=4'} hasBin: true + esrap@2.2.4: + resolution: {integrity: sha512-suICpxAmZ9A8bzJjEl/+rLJiDKC0X4gYWUxT6URAWBLvlXmtbZd5ySMu/N2ZGEtMCAmflUDPSehrP9BQcsGcSg==} + estree-util-attach-comments@3.0.0: resolution: {integrity: sha512-cKUwm/HUcTDsYh/9FgnuFqpfquUbwIqwKM26BVCGDPVgvaCl/nDCCjUfiLlx6lsEZ3Z4RFxNbOQ60pkaEwFxGw==} @@ -12143,6 +12352,14 @@ packages: resolution: {integrity: sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==} engines: {node: '>=0.8.x'} + eventsource-parser@3.0.6: + resolution: {integrity: sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg==} + engines: {node: '>=18.0.0'} + + eventsource@3.0.7: + resolution: {integrity: sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==} + engines: {node: '>=18.0.0'} + exec-async@2.2.0: resolution: {integrity: sha512-87OpwcEiMia/DeiKFzaQNBNFeN3XkkpYIh9FyOqq5mS2oKv3CBE67PXoEKcr6nodWdXNogTiQ0jE2NGuoffXPw==} @@ -12209,6 +12426,12 @@ packages: resolution: {integrity: sha512-lTqIrKOUTKHLdTuAaJzZihi1v7F8Ix1dOXVWMpToDy9zPC/s+fet0fbyXdFUxYsCUyuEDIB9tvejrTYZk8Hm0Q==} hasBin: true + express-rate-limit@8.3.1: + resolution: {integrity: sha512-D1dKN+cmyPWuvB+G2SREQDzPY1agpBIcTa9sJxOPMCNeH3gwzhqJRDWCXW3gg0y//+LQ/8j52JbMROWyrKdMdw==} + engines: {node: '>= 16'} + peerDependencies: + express: '>= 4.11' + express@4.18.2: resolution: {integrity: sha512-5/PsL6iGPdfQ/lKM1UuielYgv3BUoJfz1aUwU9vHZ+J7gyvwdQXFEBIEIaxeGf0GIcreATNyBExtalisDbuMqQ==} engines: {node: '>= 0.10.0'} @@ -12246,6 +12469,9 @@ packages: resolution: {integrity: sha512-h5+1OzzfCC3Ef7VbtKdcv7zsstUQwUDlYpUTvjeUsJAssPgLn7QzbboPtL5ro04Mq0rPOsMzl7q5hIbRs2wD1A==} engines: {node: '>=8.0.0'} + fast-content-type-parse@3.0.0: + resolution: {integrity: sha512-ZvLdcY8P+N8mGQJahJV5G4U88CSvT1rP8ApL6uETe88MBXrBHAkZlSEySdUlyztF7ccb+Znos3TFqaepHxdhBg==} + fast-decode-uri-component@1.0.1: resolution: {integrity: sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg==} @@ -12304,6 +12530,9 @@ packages: fast-uri@3.0.6: resolution: {integrity: sha512-Atfo14OibSv5wAp4VWNsFYE1AchQRTv9cBGWET4pZWHzYshFSS9NQI6I57rdKn9croWVMbYFbLhJ+yJvmZIIHw==} + fast-xml-builder@1.1.4: + resolution: {integrity: sha512-f2jhpN4Eccy0/Uz9csxh3Nu6q4ErKxf0XIsasomfOihuSUa3/xw6w8dnOtCDgEItQFJG8KyXPzQXzcODDrrbOg==} + fast-xml-parser@4.3.4: resolution: {integrity: sha512-utnwm92SyozgA3hhH2I8qldf2lBqm6qHOICawRNRFu1qMe3+oqr+GcXjGqTmXTMGE5T4eC03kr/rlh5C1IRdZA==} hasBin: true @@ -12312,6 +12541,10 @@ packages: resolution: {integrity: sha512-EFd6afGmXlCx8H8WTZHhAoDaWaGyuIBoZJ2mknrNxug+aZKjkp0a0dlars9Izl+jF+7Gu1/5f/2h68cQpe0IiA==} hasBin: true + fast-xml-parser@5.5.10: + resolution: {integrity: sha512-go2J2xODMc32hT+4Xr/bBGXMaIoiCwrwp2mMtAvKyvEFW6S/v5Gn2pBmE4nvbwNjGhpcAiOwEv7R6/GZ6XRa9w==} + hasBin: true + fastify-metrics@12.1.0: resolution: {integrity: sha512-EpbT+W1jm8kMbkCPvfW4j23y3BZlXGOcO6+75EFTKDxbJIyXbldrFIoVoP0oD4CsqrKeIARvrOjbZNqK5MdRwQ==} peerDependencies: @@ -12324,6 +12557,20 @@ packages: resolution: {integrity: sha512-2qfoaQ3BQDhZ1gtbkKZd6n0kKxJISJGM6u/skD9ljdWItAscjXrtZ1lnjr7PavmXX9j4EyCPmBDiIsLn07d5vA==} engines: {node: '>= 10'} + fastify-zod-openapi@5.6.1: + resolution: {integrity: sha512-K0tzRYEViPuCV3aKu5Zcgqsew8k0OGzEqu0p1+7P+EvNGXGP7MvcyWNVoq31LUadaT0HiUtD+65tM4sKEQz0Qg==} + engines: {node: '>=20'} + peerDependencies: + '@fastify/swagger': ^9.0.0 + '@fastify/swagger-ui': ^5.0.1 + fastify: '5' + zod: ^3.25.74 || ^4.0.0 + peerDependenciesMeta: + '@fastify/swagger': + optional: true + '@fastify/swagger-ui': + optional: true + fastify@5.6.1: resolution: {integrity: sha512-WjjlOciBF0K8pDUPZoGPhqhKrQJ02I8DKaDIfO51EL0kbSMwQFl85cRwhOvmSDWoukNOdTo27gLN549pLCcH7Q==} @@ -12482,6 +12729,9 @@ packages: for-each@0.3.3: resolution: {integrity: sha512-jqYfLp7mo9vIyQf8ykW2v7A+2N4QjeCeI5+Dz9XraiO1ign81wjiH7Fb9vSOWvQfNtmSa4H2RoQTrrXivdUZmw==} + foreach@2.0.6: + resolution: {integrity: sha512-k6GAGDyqLe9JaebCsFCoudPPWfihKu8pylYXRlqP1J7ms39iPoTtk2fviNglIeQEwdh0bQeKJ01ZPyuyQvKzwg==} + foreground-child@3.1.1: resolution: {integrity: sha512-TMKDUnIte6bfb5nWv7V/caI169OHgvwjb7V4WkeUvbQQdjr5rWKqHFiKWb/fcOwB+CzBT+qbWjvj+DVwRskpIg==} engines: {node: '>=14'} @@ -12558,6 +12808,20 @@ packages: react-dom: optional: true + framer-motion@12.38.0: + resolution: {integrity: sha512-rFYkY/pigbcswl1XQSb7q424kSTQ8q6eAC+YUsSKooHQYuLdzdHjrt6uxUC+PRAO++q5IS7+TamgIw1AphxR+g==} + peerDependencies: + '@emotion/is-prop-valid': '*' + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@emotion/is-prop-valid': + optional: true + react: + optional: true + react-dom: + optional: true + freeport-async@2.0.0: resolution: {integrity: sha512-K7od3Uw45AJg00XUmy15+Hae2hOcgKcmN3/EF6Y7i01O0gaqiRx8sUSpsb9+BRNL8RPBrhzPsVfy8q9ADlJuWQ==} engines: {node: '>=8'} @@ -12597,31 +12861,53 @@ packages: engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} os: [darwin] - fumadocs-core@16.2.2: - resolution: {integrity: sha512-CMU/jp/Gb6lr/qvRrTMRv1FX2VuAixHaqop4yguCwKt/iqkgJP4MJ2SpXcFheSUraJ2hIgDyYVoXIK1onKqagw==} + fuma-cli@0.0.3: + resolution: {integrity: sha512-DefY1l9+PdairAjOeMfHMCHwkP9kRo7pqqpqb2qFpjb3Vxa8XIpPVVrFGR+9eMfE7Bp6rxsg8NAE3XRPLMWiDA==} + + fumadocs-core@16.7.11: + resolution: {integrity: sha512-v09UEizAi7NfqwYEq2hvDimicj6ZX7xBYcOjp3vGwXZr9Vn/S2bI76HM6YWr38DmIcj1ed8zFTf1lWJsJRYN+w==} peerDependencies: - '@mixedbread/sdk': ^0.19.0 + '@mdx-js/mdx': '*' + '@mixedbread/sdk': ^0.46.0 '@orama/core': 1.x.x + '@oramacloud/client': 2.x.x '@tanstack/react-router': 1.x.x + '@types/estree-jsx': '*' + '@types/hast': '*' + '@types/mdast': '*' '@types/react': '*' algoliasearch: 5.x.x + flexsearch: '*' lucide-react: '*' next: 16.x.x react: ^19.2.0 react-dom: ^19.2.0 react-router: 7.x.x - waku: ^0.26.0 || ^0.27.0 + waku: ^0.26.0 || ^0.27.0 || ^1.0.0 + zod: 4.x.x peerDependenciesMeta: + '@mdx-js/mdx': + optional: true '@mixedbread/sdk': optional: true '@orama/core': optional: true + '@oramacloud/client': + optional: true '@tanstack/react-router': optional: true + '@types/estree-jsx': + optional: true + '@types/hast': + optional: true + '@types/mdast': + optional: true '@types/react': optional: true algoliasearch: optional: true + flexsearch: + optional: true lucide-react: optional: true next: @@ -12634,19 +12920,33 @@ packages: optional: true waku: optional: true + zod: + optional: true - fumadocs-mdx@14.0.4: - resolution: {integrity: sha512-q8g/cnFByFkdxvkUgHLsn7QrT4uHY3XkBFd5YJrbpI8cxlV8v64lS6Yrkmu/gigiuvLkysZN6zXVVIbdZcoZvw==} + fumadocs-mdx@14.2.11: + resolution: {integrity: sha512-j0gHKs45c62ARteE8/yBM2Nu2I8AE2Cs37ktPEdc/8EX7TL66XP74un5OpHp6itLyWTu8Jur0imOiiIDq8+rDg==} hasBin: true peerDependencies: '@fumadocs/mdx-remote': ^1.4.0 + '@types/mdast': '*' + '@types/mdx': '*' + '@types/react': '*' fumadocs-core: ^15.0.0 || ^16.0.0 + mdast-util-directive: '*' next: ^15.3.0 || ^16.0.0 react: '*' - vite: 6.x.x || 7.x.x + vite: 6.x.x || 7.x.x || 8.x.x peerDependenciesMeta: '@fumadocs/mdx-remote': optional: true + '@types/mdast': + optional: true + '@types/mdx': + optional: true + '@types/react': + optional: true + mdast-util-directive: + optional: true next: optional: true react: @@ -12654,20 +12954,48 @@ packages: vite: optional: true - fumadocs-ui@16.2.2: - resolution: {integrity: sha512-qYvPbVRMMFiuzrsmvGYpEj/cT5XyGzvwrrRklrHPMegywY+jxQ0TUeRKHzQgxkkTl0MDPnejRbHHAfafz01/TQ==} + fumadocs-openapi@10.6.7: + resolution: {integrity: sha512-WxjX0U6SG4PFDCSwUkuzA8LQ2X+Ou5vYA2RtE4t4i9oXstQAZKTV4llA4BI2ZbU+QVjzbzDXtG0HIykYIembgg==} peerDependencies: + '@scalar/api-client-react': '*' '@types/react': '*' + fumadocs-core: ^16.7.0 + fumadocs-ui: ^16.7.0 + json-schema-typed: '*' + react: ^19.2.0 + react-dom: ^19.2.0 + shiki: '*' + peerDependenciesMeta: + '@scalar/api-client-react': + optional: true + '@types/react': + optional: true + json-schema-typed: + optional: true + shiki: + optional: true + + fumadocs-ui@16.7.11: + resolution: {integrity: sha512-vEo4bGuWhhM3BBX/vRYDSpF666WJ+EbPke50LgdAdPlQUstRsvkOjWkta/GA9Vggph+aPKSk0AK8isJiMu4t8Q==} + peerDependencies: + '@takumi-rs/image-response': '*' + '@types/mdx': '*' + '@types/react': '*' + fumadocs-core: 16.7.11 next: 16.x.x react: ^19.2.0 react-dom: ^19.2.0 - tailwindcss: ^4.0.0 + shiki: '*' peerDependenciesMeta: + '@takumi-rs/image-response': + optional: true + '@types/mdx': + optional: true '@types/react': optional: true next: optional: true - tailwindcss: + shiki: optional: true function-bind@1.1.2: @@ -12816,11 +13144,11 @@ packages: glob@7.1.6: resolution: {integrity: sha512-LwaxwyZ72Lk7vZINtNNrywX0ZuLyStrdDtabefZKAY5ZGJhVtgdznluResxNmPitE0SAO+O26sWTHeKSI2wMBA==} - deprecated: Glob versions prior to v9 are no longer supported + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me glob@7.2.3: resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==} - deprecated: Glob versions prior to v9 are no longer supported + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me glob@9.3.5: resolution: {integrity: sha512-e1LleDykUz2Iu+MTYdkSsuWX8lvAjAcs0Xef0lNIu0S2wOAzuTxCJtcd9S3cijlwYF18EsU3rzb8jPVobxDh9Q==} @@ -12975,9 +13303,6 @@ packages: hast-util-to-estree@3.1.3: resolution: {integrity: sha512-48+B/rJWAp0jamNbAAf9M7Uf//UVqAoMmgXhBdxTDJLGKY+LRnZ99qcG+Qjl5HfMpYNzS5v4EAwVEF34LeAj7w==} - hast-util-to-html@9.0.3: - resolution: {integrity: sha512-M17uBDzMJ9RPCqLMO92gNNUDuBSq10a25SDBI08iCCxmorf4Yy6sYHK57n9WAbRAAaU+DuR4W6GN9K4DFZesYg==} - hast-util-to-html@9.0.5: resolution: {integrity: sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw==} @@ -13028,6 +13353,10 @@ packages: resolution: {integrity: sha512-eSmmWE5bZTK2Nou4g0AI3zZ9rswp7GRKoKXS1BLUkvPviOqs4YTN1djQIqrXy9k5gEtdLPy86JjRwsNM9tnDcA==} engines: {node: '>=0.10.0'} + hono@4.12.9: + resolution: {integrity: sha512-wy3T8Zm2bsEvxKZM5w21VdHDDcwVS1yUFFY6i8UobSsKfFceT7TOwhbhfKsDyx7tYQlmRM5FLpIuYvNFyjctiA==} + engines: {node: '>=16.9.0'} + hookable@5.5.3: resolution: {integrity: sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==} @@ -13227,6 +13556,10 @@ packages: resolution: {integrity: sha512-C6uC+kleiIMmjViJINWk80sOQw5lEzse1ZmvD+S/s8p8CWapftSaC+kocGTx6xrbrJ4WmYQGC08ffHLr6ToR6Q==} engines: {node: '>=12.22.0'} + ip-address@10.1.0: + resolution: {integrity: sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==} + engines: {node: '>= 12'} + ip-regex@2.1.0: resolution: {integrity: sha512-58yWmlHpp7VYfcdTwMTvwMmqx/Elfxjd9RXTDyMsbL7lLWmhMylLEqiYVLKuLzOZqVgiWXD9MfR62Vv89VRxkw==} engines: {node: '>=4'} @@ -13618,6 +13951,9 @@ packages: join-component@1.1.0: resolution: {integrity: sha512-bF7vcQxbODoGK1imE2P9GS9aw4zD0Sd+Hni68IMZLj7zRnquH7dXUmMw9hDI5S/Jzt7q+IyTXN0rSg2GI0IKhQ==} + jose@6.2.2: + resolution: {integrity: sha512-d7kPDd34KO/YnzaDOlikGpOurfF0ByC2sEV4cANCtdqLlTfBlw2p14O/5d/zv40gJPbIQxfES3nSx1/oYNyuZQ==} + joycon@3.1.1: resolution: {integrity: sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==} engines: {node: '>=10'} @@ -13688,6 +14024,9 @@ packages: json-parse-even-better-errors@2.3.1: resolution: {integrity: sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==} + json-pointer@0.6.2: + resolution: {integrity: sha512-vLWcKbOaXlO+jvRy4qNd+TI1QUPZzfJj1tpJ3vAXDych5XJf93ftpUKe5pKCrzyIIwgBJcOcCVRUfqQP25afBw==} + json-schema-deref-sync@0.13.0: resolution: {integrity: sha512-YBOEogm5w9Op337yb6pAT6ZXDqlxAsQCanM3grid8lMWNxRJO/zWEJi3ZzqDL8boWfwhTFym5EFrNgWwpqcBRg==} engines: {node: '>=6.0.0'} @@ -13695,9 +14034,16 @@ packages: json-schema-ref-resolver@2.0.1: resolution: {integrity: sha512-HG0SIB9X4J8bwbxCbnd5FfPEbcXAJYTi1pBJeP/QPON+w8ovSME8iRG+ElHNxZNX2Qh6eYn1GdzJFS4cDFfx0Q==} + json-schema-resolver@3.0.0: + resolution: {integrity: sha512-HqMnbz0tz2DaEJ3ntsqtx3ezzZyDE7G56A/pPY/NGmrPu76UzsWquOpHFRAf5beTNXoH2LU5cQePVvRli1nchA==} + engines: {node: '>=20'} + json-schema-traverse@1.0.0: resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} + json-schema-typed@8.0.2: + resolution: {integrity: sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==} + json-schema@0.4.0: resolution: {integrity: sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==} @@ -14147,6 +14493,11 @@ packages: peerDependencies: react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0 + lucide-react@1.7.0: + resolution: {integrity: sha512-yI7BeItCLZJTXikmK4KNUGCKoGzSvbKlfCvw44bU4fXAL6v3gYS4uHD1jzsLkfwODYwI6Drw5Tu9Z5ulDe0TSg==} + peerDependencies: + react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0 + luxon@3.7.2: resolution: {integrity: sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew==} engines: {node: '>=12'} @@ -14165,9 +14516,6 @@ packages: magic-string@0.30.17: resolution: {integrity: sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA==} - magic-string@0.30.19: - resolution: {integrity: sha512-2N21sPY9Ws53PZvsEpVtNuSW+ScYbQdp4b9qUaL+9QkHUrGFKo56Lg9Emg5s9V/qrtNBmiR01sYhUOwu3H+VOw==} - magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} @@ -14282,9 +14630,6 @@ packages: mdast-util-to-hast@13.1.0: resolution: {integrity: sha512-/e2l/6+OdGp/FB+ctrJ9Avz71AN/GRH3oi/3KAx/kMnoUsD6q0woXlDT8lLEeViVKE7oZxE7RXzvO3T8kF2/sA==} - mdast-util-to-markdown@2.1.1: - resolution: {integrity: sha512-OrkcCoqAkEg9b1ykXBrA0ehRc8H4fGU/03cACmW2xXzau1+dIdS+qJugh1Cqex3hMumSBgSE/5pc7uqP12nLAw==} - mdast-util-to-markdown@2.1.2: resolution: {integrity: sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA==} @@ -14700,12 +15045,18 @@ packages: motion-dom@12.23.23: resolution: {integrity: sha512-n5yolOs0TQQBRUFImrRfs/+6X4p3Q4n1dUEqt/H58Vx7OW6RF+foWEgmTVDhIWJIMXOuNNL0apKH2S16en9eiA==} + motion-dom@12.38.0: + resolution: {integrity: sha512-pdkHLD8QYRp8VfiNLb8xIBJis1byQ9gPT3Jnh2jqfFtAsWUA3dEepDlsWe/xMpO8McV+VdpKVcp+E+TGJEtOoA==} + motion-utils@11.18.1: resolution: {integrity: sha512-49Kt+HKjtbJKLtgO/LKj9Ld+6vw9BjH5d9sc40R/kVyH8GLAXgT42M2NnuPcJNuA3s9ZfZBUcwIgpmZWGEE+hA==} motion-utils@12.23.6: resolution: {integrity: sha512-eAWoPgr4eFEOFfg2WjIsMoqJTW6Z8MTUCgn/GZ3VRpClWBdnbjryiA3ZSNLyxCTmCQx4RmYX6jX1iWHbenUPNQ==} + motion-utils@12.36.0: + resolution: {integrity: sha512-eHWisygbiwVvf6PZ1vhaHCLamvkSbPIeAYxWUuL3a2PD/TROgE7FvfHWTIH4vMl798QLfMw15nRqIaRDXTlYRg==} + motion@11.18.2: resolution: {integrity: sha512-JLjvFDuFr42NFtcVoMAyC2sEjnpA8xpy6qWPyzQvCloznAyQ8FIXioxWfHiLtgYhoVpfUqSWpn1h9++skj9+Wg==} peerDependencies: @@ -14720,6 +15071,20 @@ packages: react-dom: optional: true + motion@12.38.0: + resolution: {integrity: sha512-uYfXzeHlgThchzwz5Te47dlv5JOUC7OB4rjJ/7XTUgtBZD8CchMN8qEJ4ZVsUmTyYA44zjV0fBwsiktRuFnn+w==} + peerDependencies: + '@emotion/is-prop-valid': '*' + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@emotion/is-prop-valid': + optional: true + react: + optional: true + react-dom: + optional: true + mrmime@2.0.1: resolution: {integrity: sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==} engines: {node: '>=10'} @@ -15128,15 +15493,9 @@ packages: resolution: {integrity: sha512-1FlR+gjXK7X+AsAHso35MnyN5KqGwJRi/31ft6x0M194ht7S+rWAvd7PHss9xSKMzE0asv1pyIHaJYq+BbacAQ==} engines: {node: '>=12'} - oniguruma-parser@0.12.0: - resolution: {integrity: sha512-fD9o5ebCmEAA9dLysajdQvuKzLL7cj+w7DQjuO3Cb6IwafENfx6iL+RGkmyW82pVRsvgzixsWinHvgxTMJvdIA==} - oniguruma-parser@0.12.1: resolution: {integrity: sha512-8Unqkvk1RYc6yq2WBYRj4hdnsAxVze8i7iPfQr8e4uSP3tRv0rpZcbGUDvxfQQcdwHt/e9PrMvGCsa8OqG9X3w==} - oniguruma-to-es@4.3.1: - resolution: {integrity: sha512-VtX1kepWO+7HG7IWV5v72JhiqofK7XsiHmtgnvurnNOTdIvE5mrdWYtsOrQyrXCv1L2Ckm08hywp+MFO7rC4Ug==} - oniguruma-to-es@4.3.4: resolution: {integrity: sha512-3VhUGN3w2eYxnTzHn+ikMI+fp/96KoRSVK9/kMTcFqj1NRDh2IhQCKvYxDnWePKRXY/AqH+Fuiyb7VHSzBjHfA==} @@ -15156,6 +15515,12 @@ packages: resolution: {integrity: sha512-7x81NCL719oNbsq/3mh+hVrAWmFuEYUqrq/Iw3kUzH8ReypT9QQ0BLoJS7/G9k6N81XjW4qHWtjWwe/9eLy1EQ==} engines: {node: '>=12'} + openapi-sampler@1.7.2: + resolution: {integrity: sha512-OKytvqB5XIaTgA9xtw8W8UTar+uymW2xPVpFN0NihMtuHPdPTGxBEhGnfFnJW5g/gOSIvkP+H0Xh3XhVI9/n7g==} + + openapi-types@12.1.3: + resolution: {integrity: sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw==} + ora@3.4.0: resolution: {integrity: sha512-eNwHudNbO1folBP3JsZ19v9azXWtQZjICdr3Q0TDPIaeBQ3mXLrh54wM+er0+hSp+dWKf+Z8KM58CYzEyIYxYg==} engines: {node: '>=6'} @@ -15188,6 +15553,13 @@ packages: resolution: {integrity: sha512-xMiyHgr2FZsphQ12ZCsXRvSYzmKXCm1ejmyG4GDZIiKOmhyt5iKtWq0klOfFsEQ6jcgbwrUdwcCVYzr1F+h5og==} engines: {node: ^20.19.0 || >=22.12.0} + oxc-parser@0.124.0: + resolution: {integrity: sha512-h07SFj/tp2U3cf3+LFX6MmOguQiM9ahwpGs0ZK5CGhgL8p4kk24etrJKsEzhXAvo7mfvoKTZooZ5MLKAPRmJ1g==} + engines: {node: ^20.19.0 || >=22.12.0} + + oxc-resolver@11.19.1: + resolution: {integrity: sha512-qE/CIg/spwrTBFt5aKmwe3ifeDdLfA2NESN30E42X/lII5ClF8V7Wt6WIJhcGZjp0/Q+nQ+9vgxGk//xZNX2hg==} + oxc-transform@0.102.0: resolution: {integrity: sha512-MR5ohiBS6/kvxRpmUZ3LIDTTJBEC4xLAEZXfYr7vrA0eP7WHewQaNQPFDgT4Bee89TdmVQ5ZKrifGwxLjSyHHw==} engines: {node: ^20.19.0 || >=22.12.0} @@ -15263,6 +15635,9 @@ packages: package-manager-detector@1.2.0: resolution: {integrity: sha512-PutJepsOtsqVfUsxCzgTTpyXmiAgvKptIgY4th5eq5UXXFhj5PxfQ9hnGkypMeovpAvVshFRItoFHYO18TCOqA==} + package-manager-detector@1.6.0: + resolution: {integrity: sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA==} + pako@0.2.9: resolution: {integrity: sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA==} @@ -15332,6 +15707,10 @@ packages: resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} engines: {node: '>=8'} + path-expression-matcher@1.4.0: + resolution: {integrity: sha512-s4DQMxIdhj3jLFWd9LxHOplj4p9yQ4ffMGowFf3cpEgrrJjEhN0V5nxw4Ye1EViAGDoL4/1AeO6qHpqYPOzE4Q==} + engines: {node: '>=14.0.0'} + path-is-absolute@1.0.1: resolution: {integrity: sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==} engines: {node: '>=0.10.0'} @@ -15375,6 +15754,9 @@ packages: path-to-regexp@8.3.0: resolution: {integrity: sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==} + path-to-regexp@8.4.2: + resolution: {integrity: sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==} + path-type@4.0.0: resolution: {integrity: sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==} engines: {node: '>=8'} @@ -15438,6 +15820,10 @@ packages: resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==} engines: {node: '>=12'} + picomatch@4.0.4: + resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} + engines: {node: '>=12'} + pify@4.0.1: resolution: {integrity: sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g==} engines: {node: '>=6'} @@ -15459,6 +15845,10 @@ packages: resolution: {integrity: sha512-saLsH7WeYYPiD25LDuLRRY/i+6HaPYr6G1OUlN39otzkSTxKnubR9RTxS3/Kk50s1g2JTgFwWQDQyplC5/SHZg==} engines: {node: '>= 6'} + pkce-challenge@5.0.1: + resolution: {integrity: sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==} + engines: {node: '>=16.20.0'} + pkg-dir@3.0.0: resolution: {integrity: sha512-/E57AYkoeQ25qkxMj5PBOVgF8Kiu/h7cYS30Z5+R7WaiCCBfLq58ZI/dSeaEKb9WVJV5n/03QwrN3IeWIFllvw==} engines: {node: '>=6'} @@ -16016,6 +16406,12 @@ packages: peerDependencies: react: ^16.8.0 || ^17 || ^18 + react-hook-form@7.72.1: + resolution: {integrity: sha512-RhwBoy2ygeVZje+C+bwJ8g0NjTdBmDlJvAUHTxRjTmSUKPYsKfMphkS2sgEMotsY03bP358yEYlnUeZy//D9Ig==} + engines: {node: '>=18.0.0'} + peerDependencies: + react: ^16.8.0 || ^17 || ^18 || ^19 + react-in-viewport@1.0.0-beta.8: resolution: {integrity: sha512-vcQLHOBNHdbB9sIdtDW6LnbGPlY+WDDYbv/uWL3x/Fk61vMK1ugw1cwDwo9hpD4oNF6SAaToXLbMJ8l8KOntXQ==} peerDependencies: @@ -16040,8 +16436,8 @@ packages: '@types/react': '>=18' react: '>=18' - react-medium-image-zoom@5.4.0: - resolution: {integrity: sha512-BsE+EnFVQzFIlyuuQrZ9iTwyKpKkqdFZV1ImEQN573QPqGrIUuNni7aF+sZwDcxlsuOMayCr6oO/PZR/yJnbRg==} + react-medium-image-zoom@5.4.3: + resolution: {integrity: sha512-cDIwdn35fRUPsGnnj/cG6Pacll+z+Mfv6EWU2wDO5ngbZjg5uLRb2ZhEnh92ufbXCJDFvXHekb8G3+oKqUcv5g==} peerDependencies: react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 @@ -16092,16 +16488,6 @@ packages: resolution: {integrity: sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==} engines: {node: '>=0.10.0'} - react-remove-scroll-bar@2.3.6: - resolution: {integrity: sha512-DtSYaao4mBmX+HDo5YWYdBWQwYIQQshUV/dVxFxK+KM26Wjwp1gZ6rv6OC3oujI6Bfu6Xyg3TwK533AQutsn/g==} - engines: {node: '>=10'} - peerDependencies: - '@types/react': ^16.8.0 || ^17.0.0 || ^18.0.0 - react: ^16.8.0 || ^17.0.0 || ^18.0.0 - peerDependenciesMeta: - '@types/react': - optional: true - react-remove-scroll-bar@2.3.8: resolution: {integrity: sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==} engines: {node: '>=10'} @@ -16132,6 +16518,16 @@ packages: '@types/react': optional: true + react-remove-scroll@2.7.2: + resolution: {integrity: sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + react-resizable@3.0.5: resolution: {integrity: sha512-vKpeHhI5OZvYn82kXOs1bC8aOXktGU5AmKAgaZS4F5JPburCtbmDPqE7Pzp+1kN4+Wb81LlF33VpGwWwtXem+w==} peerDependencies: @@ -16178,16 +16574,6 @@ packages: react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 - react-style-singleton@2.2.1: - resolution: {integrity: sha512-ZWj0fHEMyWkHzKYUr2Bs/4zU6XLmq9HsgBURm7g5pAVfyn49DgUiNgY2d4lXRlYSiCif9YBGpQleewkcqddc7g==} - engines: {node: '>=10'} - peerDependencies: - '@types/react': ^16.8.0 || ^17.0.0 || ^18.0.0 - react: ^16.8.0 || ^17.0.0 || ^18.0.0 - peerDependenciesMeta: - '@types/react': - optional: true - react-style-singleton@2.2.3: resolution: {integrity: sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==} engines: {node: '>=10'} @@ -16895,11 +17281,9 @@ packages: shiki@3.17.0: resolution: {integrity: sha512-lUZfWsyW7czITYTdo/Tb6ZM4VfyXlzmKYBQBjTz+pBzPPkP08RgIt00Ls1Z50Cl3SfwJsue6WbJeF3UgqLVI9Q==} - shiki@3.19.0: - resolution: {integrity: sha512-77VJr3OR/VUZzPiStyRhADmO2jApMM0V2b1qf0RpfWya8Zr1PeZev5AEpPGAAKWdiYUtcZGBE4F5QvJml1PvWA==} - - shiki@3.3.0: - resolution: {integrity: sha512-j0Z1tG5vlOFGW8JVj0Cpuatzvshes7VJy5ncDmmMaYcmnGW0Js1N81TOW98ivTFNZfKRn9uwEg/aIm638o368g==} + shiki@4.0.2: + resolution: {integrity: sha512-eAVKTMedR5ckPo4xne/PjYQYrU3qx78gtJZ+sHlXEg5IHhhoQhMfZVzetTYuaJS0L2Ef3AcCRzCHV8T0WI6nIQ==} + engines: {node: '>=20'} shimmer@1.2.1: resolution: {integrity: sha512-sQTKC1Re/rM6XyFM6fIAGHRPVGvyXfgzIDvzoq608vM+jeyVD0Tu1E6Np0Kc2zAIFWIj963V2800iF/9LPieQw==} @@ -17225,6 +17609,9 @@ packages: strnum@2.1.2: resolution: {integrity: sha512-l63NF9y/cLROq/yqKXSLtcMeeyOfnSQlfMSlzFt/K73oIaD8DGaQWd7Z34X9GPiKqP5rbSh84Hl4bOlLcjiSrQ==} + strnum@2.2.3: + resolution: {integrity: sha512-oKx6RUCuHfT3oyVjtnrmn19H1SiCqgJSg+54XqURKp5aCMbrXrhLjRN9TjuwMjiYstZ0MzDrHqkGZ5dFTKd+zg==} + structured-clone-es@1.0.0: resolution: {integrity: sha512-FL8EeKFFyNQv5cMnXI31CIMCsFarSVI2bF0U0ImeNE3g/F1IvJQyqzOXxPBRXiwQfyBTlbNe88jh1jFW0O/jiQ==} @@ -17346,6 +17733,9 @@ packages: tailwind-merge@3.4.0: resolution: {integrity: sha512-uSaO4gnW+b3Y2aWoWfFpX62vn2sR3skfhbjsEnaBI81WD1wBLlHZe5sWf0AqjksNdYTbGBEd0UasQMT3SNV15g==} + tailwind-merge@3.5.0: + resolution: {integrity: sha512-I8K9wewnVDkL1NTGoqWmVEIlUcB9gFriAEkXkfCjX5ib8ezGxtR3xD7iZIxrfArjEsH7F1CHD4RFUtxefdqV/A==} + tailwindcss-animate@1.0.7: resolution: {integrity: sha512-bl6mpH3T7I3UFxuvDEXLxy/VuFxBk5bbzplh7tXI68mwMokNYd1t9qPBHlnyTwfa4JGC4zP516I1hYYtQ/vspA==} peerDependencies: @@ -17489,6 +17879,10 @@ packages: resolution: {integrity: sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==} engines: {node: '>=18'} + tinyexec@1.1.1: + resolution: {integrity: sha512-VKS/ZaQhhkKFMANmAOhhXVoIfBXblQxGX1myCQ2faQrfmobMftXeJPcZGp0gS07ocvGJWDLZGyOZDadDBqYIJg==} + engines: {node: '>=18'} + tinyglobby@0.2.13: resolution: {integrity: sha512-mEwzpUgrLySlveBwEVDMKk5B57bhLPYovRfPAXD5gA/98Opn0rCDj3GtLwFvCvH5RK9uPCExUROW5NjDwvqkxw==} engines: {node: '>=12.0.0'} @@ -17497,6 +17891,10 @@ packages: resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} engines: {node: '>=12.0.0'} + tinyglobby@0.2.16: + resolution: {integrity: sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==} + engines: {node: '>=12.0.0'} + tinypool@0.8.4: resolution: {integrity: sha512-i11VH5gS6IFeLY3gMBQ00/MmLncVP7JLXOw1vlgkytLmJK7QnEr7NXf0LBdxfmNPAeyetukOk0bOYrJrFGjYJQ==} engines: {node: '>=14.0.0'} @@ -17935,6 +18333,9 @@ packages: unist-util-visit@5.0.0: resolution: {integrity: sha512-MR04uvD+07cwl/yhVuVWAtw+3GOR/knlL55Nd/wAdblk27GCVt3lqpTivy/tkJcZoNPzTwS1Y+KMojlLDhoTzg==} + unist-util-visit@5.1.0: + resolution: {integrity: sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==} + universalify@0.1.2: resolution: {integrity: sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==} engines: {node: '>= 4.0.0'} @@ -18183,9 +18584,6 @@ packages: uqr@0.1.2: resolution: {integrity: sha512-MJu7ypHq6QasgF5YRTjqscSzQp/W11zoUk6kvmlH+fmWEs63Y0Eib13hYFwAzagRJcVY8WVnlV+eBDUGMJ5IbA==} - uri-js@4.4.1: - resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} - url-join@4.0.0: resolution: {integrity: sha512-EGXjXJZhIHiQMK2pQukuFcL303nskqIRzWvPvV5O8miOfwoUb9G+a/Cld60kUyeaybEI94wvVClT10DtfeAExA==} @@ -18199,16 +18597,6 @@ packages: urlpattern-polyfill@10.1.0: resolution: {integrity: sha512-IGjKp/o0NL3Bso1PymYURCJxMPNAf/ILOpendP9f5B6e1rTJgdgiOvgfoT8VxCAdY+Wisb9uhGaJJf3yZ2V9nw==} - use-callback-ref@1.3.1: - resolution: {integrity: sha512-Lg4Vx1XZQauB42Hw3kK7JM6yjVjgFmFC5/Ab797s79aARomD2nEErc4mCgM8EZrARLmmbWpi5DGCadmK50DcAQ==} - engines: {node: '>=10'} - peerDependencies: - '@types/react': ^16.8.0 || ^17.0.0 || ^18.0.0 - react: ^16.8.0 || ^17.0.0 || ^18.0.0 - peerDependenciesMeta: - '@types/react': - optional: true - use-callback-ref@1.3.3: resolution: {integrity: sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==} engines: {node: '>=10'} @@ -18225,16 +18613,6 @@ packages: peerDependencies: react: '*' - use-sidecar@1.1.2: - resolution: {integrity: sha512-epTbsLuzZ7lPClpz2TyryBfztm7m+28DlEv2ZCQ3MDr5ssiwyOwGH/e5F9CkfWjJ1t4clvI58yF822/GUkjjhw==} - engines: {node: '>=10'} - peerDependencies: - '@types/react': ^16.9.0 || ^17.0.0 || ^18.0.0 - react: ^16.8.0 || ^17.0.0 || ^18.0.0 - peerDependenciesMeta: - '@types/react': - optional: true - use-sidecar@1.1.3: resolution: {integrity: sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ==} engines: {node: '>=10'} @@ -18888,6 +19266,10 @@ packages: resolution: {integrity: sha512-kCz5k7J7XbJtjABOvkc5lJmkiDh8VhjVCGNiqdKCscmVpdVUpEAyXv1xmCLkQJ5dsHqx3IPO4XW+NTDhU/fatA==} engines: {node: '>=10.0.0'} + xml-js@1.6.11: + resolution: {integrity: sha512-7rVi2KMfwfWFl+GpPg6m80IVMWXLRjO+PxTq7V2CDhoGak0wzYzFgUY2m4XJ47OGdXd8eLE8EmwfAmdjw7lC1g==} + hasBin: true + xml-name-validator@5.0.0: resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==} engines: {node: '>=18'} @@ -18943,10 +19325,6 @@ packages: resolution: {integrity: sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==} engines: {node: '>= 6'} - yaml@2.3.4: - resolution: {integrity: sha512-8aAvwVUSHpfEqTQ4w/KMlf3HcRdt50E5ODIQJBw1fQ5RL34xabzxtUlzTXVqc4rkZsPbvrXKWnABCD7kWSmocA==} - engines: {node: '>= 14'} - yaml@2.8.2: resolution: {integrity: sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==} engines: {node: '>= 14.6'} @@ -19027,56 +19405,67 @@ packages: zip@1.2.0: resolution: {integrity: sha512-8B4Z9BXJKkI8BkHhKvQan4rwCzUENnj95YHFYrI7F1NbqKCIdW86kujctzEB+kJ6XapHPiAhiZ9xi5GbW5SPdw==} + zod-openapi@5.4.6: + resolution: {integrity: sha512-P2jsOOBAq/6hCwUsMCjUATZ8szkMsV5VAwZENfyxp2Hc/XPJQpVwAgevWZc65xZauCwWB9LAn7zYeiCJFAEL+A==} + engines: {node: '>=20'} + peerDependencies: + zod: ^3.25.74 || ^4.0.0 + zod-to-json-schema@3.24.5: resolution: {integrity: sha512-/AuWwMP+YqiPbsJx5D6TfgRTc4kTLjsh5SOcd4bLsfUg2RcEXrFMJl1DGgdHy2aCfsIA/cr/1JM0xcB2GZji8g==} peerDependencies: zod: ^3.24.1 + zod-to-json-schema@3.25.1: + resolution: {integrity: sha512-pM/SU9d3YAggzi6MtR4h7ruuQlqKtad8e9S0fmxcMi+ueAK5Korys/aWcV9LIIHTVbj01NdzxcnXSN+O74ZIVA==} + peerDependencies: + zod: ^3.25 || ^4 + zod-to-ts@1.2.0: resolution: {integrity: sha512-x30XE43V+InwGpvTySRNz9kB7qFU8DlyEy7BsSTCHPH1R0QasMmHWZDCzYm6bVXtj/9NNJAZF3jW8rzFvH5OFA==} peerDependencies: typescript: ^4.9.4 || ^5.0.2 zod: ^3 - zod@3.24.2: - resolution: {integrity: sha512-lY7CDW43ECgW9u1TcT3IoXHflywfVqDYze4waEz812jR/bZ8FHDsl7pFQoSZTz5N+2NqRXs8GBwnAwo3ZNxqhQ==} - zod@3.25.76: resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==} zod@4.1.13: resolution: {integrity: sha512-AvvthqfqrAhNH9dnfmrfKzX5upOdjUVJYFqNSlkmGf64gRaTzlPwz99IHYnVs28qYAybvAlBV+H7pn0saFY4Ig==} + zod@4.3.6: + resolution: {integrity: sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==} + zwitch@2.0.4: resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==} snapshots: - '@ai-sdk/anthropic@1.2.10(zod@3.24.2)': + '@ai-sdk/anthropic@1.2.10(zod@4.1.13)': dependencies: '@ai-sdk/provider': 1.1.3 - '@ai-sdk/provider-utils': 2.2.7(zod@3.24.2) - zod: 3.24.2 + '@ai-sdk/provider-utils': 2.2.7(zod@4.1.13) + zod: 4.1.13 - '@ai-sdk/openai@1.3.12(zod@3.24.2)': + '@ai-sdk/openai@1.3.12(zod@4.1.13)': dependencies: '@ai-sdk/provider': 1.1.3 - '@ai-sdk/provider-utils': 2.2.7(zod@3.24.2) - zod: 3.24.2 + '@ai-sdk/provider-utils': 2.2.7(zod@4.1.13) + zod: 4.1.13 - '@ai-sdk/provider-utils@2.2.3(zod@3.24.2)': + '@ai-sdk/provider-utils@2.2.3(zod@4.1.13)': dependencies: '@ai-sdk/provider': 1.1.0 nanoid: 3.3.11 secure-json-parse: 2.7.0 - zod: 3.24.2 + zod: 4.1.13 - '@ai-sdk/provider-utils@2.2.7(zod@3.24.2)': + '@ai-sdk/provider-utils@2.2.7(zod@4.1.13)': dependencies: '@ai-sdk/provider': 1.1.3 nanoid: 3.3.11 secure-json-parse: 2.7.0 - zod: 3.24.2 + zod: 4.1.13 '@ai-sdk/provider@1.1.0': dependencies: @@ -19086,22 +19475,22 @@ snapshots: dependencies: json-schema: 0.4.0 - '@ai-sdk/react@1.2.5(react@19.2.3)(zod@3.24.2)': + '@ai-sdk/react@1.2.5(react@19.2.3)(zod@4.1.13)': dependencies: - '@ai-sdk/provider-utils': 2.2.3(zod@3.24.2) - '@ai-sdk/ui-utils': 1.2.4(zod@3.24.2) + '@ai-sdk/provider-utils': 2.2.3(zod@4.1.13) + '@ai-sdk/ui-utils': 1.2.4(zod@4.1.13) react: 19.2.3 swr: 2.3.3(react@19.2.3) throttleit: 2.1.0 optionalDependencies: - zod: 3.24.2 + zod: 4.1.13 - '@ai-sdk/ui-utils@1.2.4(zod@3.24.2)': + '@ai-sdk/ui-utils@1.2.4(zod@4.1.13)': dependencies: '@ai-sdk/provider': 1.1.0 - '@ai-sdk/provider-utils': 2.2.3(zod@3.24.2) - zod: 3.24.2 - zod-to-json-schema: 3.24.5(zod@3.24.2) + '@ai-sdk/provider-utils': 2.2.3(zod@4.1.13) + zod: 4.1.13 + zod-to-json-schema: 3.24.5(zod@4.1.13) '@alloc/quick-lru@5.2.0': {} @@ -19949,7 +20338,7 @@ snapshots: '@babel/traverse': 7.28.5 '@babel/types': 7.28.5 convert-source-map: 2.0.0 - debug: 4.4.1 + debug: 4.4.3 gensync: 1.0.0-beta.2 json5: 2.2.3 semver: 6.3.1 @@ -19969,7 +20358,7 @@ snapshots: '@babel/traverse': 7.28.5 '@babel/types': 7.28.2 convert-source-map: 2.0.0 - debug: 4.4.1 + debug: 4.4.3 gensync: 1.0.0-beta.2 json5: 2.2.3 semver: 6.3.1 @@ -22233,9 +22622,6 @@ snapshots: '@esbuild/aix-ppc64@0.27.0': optional: true - '@esbuild/aix-ppc64@0.27.1': - optional: true - '@esbuild/aix-ppc64@0.27.3': optional: true @@ -22260,9 +22646,6 @@ snapshots: '@esbuild/android-arm64@0.27.0': optional: true - '@esbuild/android-arm64@0.27.1': - optional: true - '@esbuild/android-arm64@0.27.3': optional: true @@ -22287,9 +22670,6 @@ snapshots: '@esbuild/android-arm@0.27.0': optional: true - '@esbuild/android-arm@0.27.1': - optional: true - '@esbuild/android-arm@0.27.3': optional: true @@ -22314,9 +22694,6 @@ snapshots: '@esbuild/android-x64@0.27.0': optional: true - '@esbuild/android-x64@0.27.1': - optional: true - '@esbuild/android-x64@0.27.3': optional: true @@ -22341,9 +22718,6 @@ snapshots: '@esbuild/darwin-arm64@0.27.0': optional: true - '@esbuild/darwin-arm64@0.27.1': - optional: true - '@esbuild/darwin-arm64@0.27.3': optional: true @@ -22368,9 +22742,6 @@ snapshots: '@esbuild/darwin-x64@0.27.0': optional: true - '@esbuild/darwin-x64@0.27.1': - optional: true - '@esbuild/darwin-x64@0.27.3': optional: true @@ -22395,9 +22766,6 @@ snapshots: '@esbuild/freebsd-arm64@0.27.0': optional: true - '@esbuild/freebsd-arm64@0.27.1': - optional: true - '@esbuild/freebsd-arm64@0.27.3': optional: true @@ -22422,9 +22790,6 @@ snapshots: '@esbuild/freebsd-x64@0.27.0': optional: true - '@esbuild/freebsd-x64@0.27.1': - optional: true - '@esbuild/freebsd-x64@0.27.3': optional: true @@ -22449,9 +22814,6 @@ snapshots: '@esbuild/linux-arm64@0.27.0': optional: true - '@esbuild/linux-arm64@0.27.1': - optional: true - '@esbuild/linux-arm64@0.27.3': optional: true @@ -22476,9 +22838,6 @@ snapshots: '@esbuild/linux-arm@0.27.0': optional: true - '@esbuild/linux-arm@0.27.1': - optional: true - '@esbuild/linux-arm@0.27.3': optional: true @@ -22503,9 +22862,6 @@ snapshots: '@esbuild/linux-ia32@0.27.0': optional: true - '@esbuild/linux-ia32@0.27.1': - optional: true - '@esbuild/linux-ia32@0.27.3': optional: true @@ -22530,9 +22886,6 @@ snapshots: '@esbuild/linux-loong64@0.27.0': optional: true - '@esbuild/linux-loong64@0.27.1': - optional: true - '@esbuild/linux-loong64@0.27.3': optional: true @@ -22557,9 +22910,6 @@ snapshots: '@esbuild/linux-mips64el@0.27.0': optional: true - '@esbuild/linux-mips64el@0.27.1': - optional: true - '@esbuild/linux-mips64el@0.27.3': optional: true @@ -22584,9 +22934,6 @@ snapshots: '@esbuild/linux-ppc64@0.27.0': optional: true - '@esbuild/linux-ppc64@0.27.1': - optional: true - '@esbuild/linux-ppc64@0.27.3': optional: true @@ -22611,9 +22958,6 @@ snapshots: '@esbuild/linux-riscv64@0.27.0': optional: true - '@esbuild/linux-riscv64@0.27.1': - optional: true - '@esbuild/linux-riscv64@0.27.3': optional: true @@ -22638,9 +22982,6 @@ snapshots: '@esbuild/linux-s390x@0.27.0': optional: true - '@esbuild/linux-s390x@0.27.1': - optional: true - '@esbuild/linux-s390x@0.27.3': optional: true @@ -22665,9 +23006,6 @@ snapshots: '@esbuild/linux-x64@0.27.0': optional: true - '@esbuild/linux-x64@0.27.1': - optional: true - '@esbuild/linux-x64@0.27.3': optional: true @@ -22683,9 +23021,6 @@ snapshots: '@esbuild/netbsd-arm64@0.27.0': optional: true - '@esbuild/netbsd-arm64@0.27.1': - optional: true - '@esbuild/netbsd-arm64@0.27.3': optional: true @@ -22710,9 +23045,6 @@ snapshots: '@esbuild/netbsd-x64@0.27.0': optional: true - '@esbuild/netbsd-x64@0.27.1': - optional: true - '@esbuild/netbsd-x64@0.27.3': optional: true @@ -22728,9 +23060,6 @@ snapshots: '@esbuild/openbsd-arm64@0.27.0': optional: true - '@esbuild/openbsd-arm64@0.27.1': - optional: true - '@esbuild/openbsd-arm64@0.27.3': optional: true @@ -22755,9 +23084,6 @@ snapshots: '@esbuild/openbsd-x64@0.27.0': optional: true - '@esbuild/openbsd-x64@0.27.1': - optional: true - '@esbuild/openbsd-x64@0.27.3': optional: true @@ -22767,9 +23093,6 @@ snapshots: '@esbuild/openharmony-arm64@0.27.0': optional: true - '@esbuild/openharmony-arm64@0.27.1': - optional: true - '@esbuild/openharmony-arm64@0.27.3': optional: true @@ -22794,9 +23117,6 @@ snapshots: '@esbuild/sunos-x64@0.27.0': optional: true - '@esbuild/sunos-x64@0.27.1': - optional: true - '@esbuild/sunos-x64@0.27.3': optional: true @@ -22821,9 +23141,6 @@ snapshots: '@esbuild/win32-arm64@0.27.0': optional: true - '@esbuild/win32-arm64@0.27.1': - optional: true - '@esbuild/win32-arm64@0.27.3': optional: true @@ -22848,9 +23165,6 @@ snapshots: '@esbuild/win32-ia32@0.27.0': optional: true - '@esbuild/win32-ia32@0.27.1': - optional: true - '@esbuild/win32-ia32@0.27.3': optional: true @@ -22875,9 +23189,6 @@ snapshots: '@esbuild/win32-x64@0.27.0': optional: true - '@esbuild/win32-x64@0.27.1': - optional: true - '@esbuild/win32-x64@0.27.3': optional: true @@ -23189,8 +23500,8 @@ snapshots: '@fastify/ajv-compiler@4.0.2': dependencies: - ajv: 8.12.0 - ajv-formats: 3.0.1(ajv@8.12.0) + ajv: 8.18.0 + ajv-formats: 3.0.1(ajv@8.18.0) fast-uri: 3.0.6 '@fastify/compress@8.1.0': @@ -23214,6 +23525,8 @@ snapshots: fastify-plugin: 5.0.1 toad-cache: 3.7.0 + '@fastify/deepmerge@3.2.1': {} + '@fastify/error@4.0.0': {} '@fastify/fast-json-stringify-compiler@5.0.2': @@ -23237,6 +23550,41 @@ snapshots: fastify-plugin: 5.0.1 toad-cache: 3.7.0 + '@fastify/send@4.1.0': + dependencies: + '@lukeed/ms': 2.0.2 + escape-html: 1.0.3 + fast-decode-uri-component: 1.0.1 + http-errors: 2.0.1 + mime: 3.0.0 + + '@fastify/static@9.1.0': + dependencies: + '@fastify/accept-negotiator': 2.0.1 + '@fastify/send': 4.1.0 + content-disposition: 1.0.1 + fastify-plugin: 5.0.1 + fastq: 1.17.1 + glob: 13.0.3 + + '@fastify/swagger-ui@5.2.5': + dependencies: + '@fastify/static': 9.1.0 + fastify-plugin: 5.0.1 + openapi-types: 12.1.3 + rfdc: 1.4.1 + yaml: 2.8.2 + + '@fastify/swagger@9.7.0': + dependencies: + fastify-plugin: 5.0.1 + json-schema-resolver: 3.0.0 + openapi-types: 12.1.3 + rfdc: 1.4.1 + yaml: 2.8.2 + transitivePeerDependencies: + - supports-color + '@fastify/websocket@11.2.0': dependencies: duplexify: 4.1.3 @@ -23263,9 +23611,30 @@ snapshots: '@floating-ui/utils@0.2.1': {} - '@formatjs/intl-localematcher@0.6.2': + '@formatjs/fast-memoize@3.1.1': {} + + '@formatjs/intl-localematcher@0.8.2': dependencies: - tslib: 2.8.1 + '@formatjs/fast-memoize': 3.1.1 + + '@fumadocs/tailwind@0.0.3(tailwindcss@4.1.17)': + dependencies: + postcss-selector-parser: 7.1.1 + optionalDependencies: + tailwindcss: 4.1.17 + + '@fumari/json-schema-ts@0.0.2(json-schema-typed@8.0.2)': + dependencies: + esrap: 2.2.4 + optionalDependencies: + json-schema-typed: 8.0.2 + + '@fumari/stf@1.0.4(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': + dependencies: + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) + optionalDependencies: + '@types/react': 19.2.7 '@gar/promisify@1.1.3': {} @@ -23291,6 +23660,10 @@ snapshots: dependencies: '@hapi/hoek': 9.3.0 + '@hono/node-server@1.19.11(hono@4.12.9)': + dependencies: + hono: 4.12.9 + '@hookform/resolvers@3.3.4(react-hook-form@7.50.1(react@19.2.3))': dependencies: react-hook-form: 7.50.1(react@19.2.3) @@ -23723,11 +24096,33 @@ snapshots: unified: 11.0.5 unist-util-position-from-estree: 2.0.0 unist-util-stringify-position: 4.0.0 - unist-util-visit: 5.0.0 + unist-util-visit: 5.1.0 vfile: 6.0.3 transitivePeerDependencies: - supports-color + '@modelcontextprotocol/sdk@1.27.1(zod@4.1.13)': + dependencies: + '@hono/node-server': 1.19.11(hono@4.12.9) + ajv: 8.18.0 + ajv-formats: 3.0.1(ajv@8.18.0) + content-type: 1.0.5 + cors: 2.8.5 + cross-spawn: 7.0.6 + eventsource: 3.0.7 + eventsource-parser: 3.0.6 + express: 5.2.1 + express-rate-limit: 8.3.1(express@5.2.1) + hono: 4.12.9 + jose: 6.2.2 + json-schema-typed: 8.0.2 + pkce-challenge: 5.0.1 + raw-body: 3.0.2 + zod: 4.1.13 + zod-to-json-schema: 3.25.1(zod@4.1.13) + transitivePeerDependencies: + - supports-color + '@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.2': optional: true @@ -23760,6 +24155,13 @@ snapshots: '@tybys/wasm-util': 0.10.1 optional: true + '@napi-rs/wasm-runtime@1.1.3(@emnapi/core@1.7.1)(@emnapi/runtime@1.7.1)': + dependencies: + '@emnapi/core': 1.7.1 + '@emnapi/runtime': 1.7.1 + '@tybys/wasm-util': 0.10.1 + optional: true + '@next/env@15.0.3': {} '@next/env@15.0.4': {} @@ -24355,7 +24757,7 @@ snapshots: consola: 3.4.2 cssnano: 7.1.2(postcss@8.5.6) defu: 6.1.4 - esbuild: 0.27.1 + esbuild: 0.27.3 escape-string-regexp: 5.0.0 exsolve: 1.0.8 get-port-please: 3.2.0 @@ -25388,7 +25790,7 @@ snapshots: '@opentelemetry/api': 1.9.0 '@opentelemetry/core': 1.30.1(@opentelemetry/api@1.9.0) - '@orama/orama@3.1.16': {} + '@orama/orama@3.1.18': {} '@oslojs/asn1@1.0.0': dependencies: @@ -25456,57 +25858,189 @@ snapshots: '@oxc-minify/binding-win32-x64-msvc@0.102.0': optional: true + '@oxc-parser/binding-android-arm-eabi@0.124.0': + optional: true + '@oxc-parser/binding-android-arm64@0.102.0': optional: true + '@oxc-parser/binding-android-arm64@0.124.0': + optional: true + '@oxc-parser/binding-darwin-arm64@0.102.0': optional: true + '@oxc-parser/binding-darwin-arm64@0.124.0': + optional: true + '@oxc-parser/binding-darwin-x64@0.102.0': optional: true + '@oxc-parser/binding-darwin-x64@0.124.0': + optional: true + '@oxc-parser/binding-freebsd-x64@0.102.0': optional: true + '@oxc-parser/binding-freebsd-x64@0.124.0': + optional: true + '@oxc-parser/binding-linux-arm-gnueabihf@0.102.0': optional: true + '@oxc-parser/binding-linux-arm-gnueabihf@0.124.0': + optional: true + + '@oxc-parser/binding-linux-arm-musleabihf@0.124.0': + optional: true + '@oxc-parser/binding-linux-arm64-gnu@0.102.0': optional: true + '@oxc-parser/binding-linux-arm64-gnu@0.124.0': + optional: true + '@oxc-parser/binding-linux-arm64-musl@0.102.0': optional: true + '@oxc-parser/binding-linux-arm64-musl@0.124.0': + optional: true + + '@oxc-parser/binding-linux-ppc64-gnu@0.124.0': + optional: true + '@oxc-parser/binding-linux-riscv64-gnu@0.102.0': optional: true + '@oxc-parser/binding-linux-riscv64-gnu@0.124.0': + optional: true + + '@oxc-parser/binding-linux-riscv64-musl@0.124.0': + optional: true + '@oxc-parser/binding-linux-s390x-gnu@0.102.0': optional: true + '@oxc-parser/binding-linux-s390x-gnu@0.124.0': + optional: true + '@oxc-parser/binding-linux-x64-gnu@0.102.0': optional: true + '@oxc-parser/binding-linux-x64-gnu@0.124.0': + optional: true + '@oxc-parser/binding-linux-x64-musl@0.102.0': optional: true + '@oxc-parser/binding-linux-x64-musl@0.124.0': + optional: true + '@oxc-parser/binding-openharmony-arm64@0.102.0': optional: true + '@oxc-parser/binding-openharmony-arm64@0.124.0': + optional: true + '@oxc-parser/binding-wasm32-wasi@0.102.0': dependencies: '@napi-rs/wasm-runtime': 1.1.0 optional: true + '@oxc-parser/binding-wasm32-wasi@0.124.0(@emnapi/core@1.7.1)(@emnapi/runtime@1.7.1)': + dependencies: + '@napi-rs/wasm-runtime': 1.1.3(@emnapi/core@1.7.1)(@emnapi/runtime@1.7.1) + transitivePeerDependencies: + - '@emnapi/core' + - '@emnapi/runtime' + optional: true + '@oxc-parser/binding-win32-arm64-msvc@0.102.0': optional: true + '@oxc-parser/binding-win32-arm64-msvc@0.124.0': + optional: true + + '@oxc-parser/binding-win32-ia32-msvc@0.124.0': + optional: true + '@oxc-parser/binding-win32-x64-msvc@0.102.0': optional: true + '@oxc-parser/binding-win32-x64-msvc@0.124.0': + optional: true + '@oxc-project/types@0.102.0': {} + '@oxc-project/types@0.124.0': {} + '@oxc-project/types@0.94.0': {} + '@oxc-resolver/binding-android-arm-eabi@11.19.1': + optional: true + + '@oxc-resolver/binding-android-arm64@11.19.1': + optional: true + + '@oxc-resolver/binding-darwin-arm64@11.19.1': + optional: true + + '@oxc-resolver/binding-darwin-x64@11.19.1': + optional: true + + '@oxc-resolver/binding-freebsd-x64@11.19.1': + optional: true + + '@oxc-resolver/binding-linux-arm-gnueabihf@11.19.1': + optional: true + + '@oxc-resolver/binding-linux-arm-musleabihf@11.19.1': + optional: true + + '@oxc-resolver/binding-linux-arm64-gnu@11.19.1': + optional: true + + '@oxc-resolver/binding-linux-arm64-musl@11.19.1': + optional: true + + '@oxc-resolver/binding-linux-ppc64-gnu@11.19.1': + optional: true + + '@oxc-resolver/binding-linux-riscv64-gnu@11.19.1': + optional: true + + '@oxc-resolver/binding-linux-riscv64-musl@11.19.1': + optional: true + + '@oxc-resolver/binding-linux-s390x-gnu@11.19.1': + optional: true + + '@oxc-resolver/binding-linux-x64-gnu@11.19.1': + optional: true + + '@oxc-resolver/binding-linux-x64-musl@11.19.1': + optional: true + + '@oxc-resolver/binding-openharmony-arm64@11.19.1': + optional: true + + '@oxc-resolver/binding-wasm32-wasi@11.19.1(@emnapi/core@1.7.1)(@emnapi/runtime@1.7.1)': + dependencies: + '@napi-rs/wasm-runtime': 1.1.3(@emnapi/core@1.7.1)(@emnapi/runtime@1.7.1) + transitivePeerDependencies: + - '@emnapi/core' + - '@emnapi/runtime' + optional: true + + '@oxc-resolver/binding-win32-arm64-msvc@11.19.1': + optional: true + + '@oxc-resolver/binding-win32-ia32-msvc@11.19.1': + optional: true + + '@oxc-resolver/binding-win32-x64-msvc@11.19.1': + optional: true + '@oxc-transform/binding-android-arm64@0.102.0': optional: true @@ -27439,6 +27973,20 @@ snapshots: '@rrweb/utils@2.0.0-alpha.20': {} + '@scalar/helpers@0.4.3': {} + + '@scalar/json-magic@0.12.5': + dependencies: + '@scalar/helpers': 0.4.3 + pathe: 2.0.3 + yaml: 2.8.2 + + '@scalar/openapi-types@0.7.0': {} + + '@scalar/openapi-upgrader@0.2.4': + dependencies: + '@scalar/openapi-types': 0.7.0 + '@segment/loosely-validate-event@2.0.0': dependencies: component-type: 1.2.2 @@ -27576,16 +28124,10 @@ snapshots: '@types/hast': 3.0.4 hast-util-to-html: 9.0.5 - '@shikijs/core@3.19.0': + '@shikijs/core@4.0.2': dependencies: - '@shikijs/types': 3.19.0 - '@shikijs/vscode-textmate': 10.0.2 - '@types/hast': 3.0.4 - hast-util-to-html: 9.0.5 - - '@shikijs/core@3.3.0': - dependencies: - '@shikijs/types': 3.3.0 + '@shikijs/primitive': 4.0.2 + '@shikijs/types': 4.0.2 '@shikijs/vscode-textmate': 10.0.2 '@types/hast': 3.0.4 hast-util-to-html: 9.0.5 @@ -27596,82 +28138,64 @@ snapshots: '@shikijs/vscode-textmate': 10.0.2 oniguruma-to-es: 4.3.4 - '@shikijs/engine-javascript@3.19.0': + '@shikijs/engine-javascript@4.0.2': dependencies: - '@shikijs/types': 3.19.0 + '@shikijs/types': 4.0.2 '@shikijs/vscode-textmate': 10.0.2 oniguruma-to-es: 4.3.4 - '@shikijs/engine-javascript@3.3.0': - dependencies: - '@shikijs/types': 3.3.0 - '@shikijs/vscode-textmate': 10.0.2 - oniguruma-to-es: 4.3.1 - '@shikijs/engine-oniguruma@3.17.0': dependencies: '@shikijs/types': 3.17.0 '@shikijs/vscode-textmate': 10.0.2 - '@shikijs/engine-oniguruma@3.19.0': + '@shikijs/engine-oniguruma@4.0.2': dependencies: - '@shikijs/types': 3.19.0 - '@shikijs/vscode-textmate': 10.0.2 - - '@shikijs/engine-oniguruma@3.3.0': - dependencies: - '@shikijs/types': 3.3.0 + '@shikijs/types': 4.0.2 '@shikijs/vscode-textmate': 10.0.2 '@shikijs/langs@3.17.0': dependencies: '@shikijs/types': 3.17.0 - '@shikijs/langs@3.19.0': + '@shikijs/langs@4.0.2': dependencies: - '@shikijs/types': 3.19.0 + '@shikijs/types': 4.0.2 - '@shikijs/langs@3.3.0': + '@shikijs/primitive@4.0.2': dependencies: - '@shikijs/types': 3.3.0 + '@shikijs/types': 4.0.2 + '@shikijs/vscode-textmate': 10.0.2 + '@types/hast': 3.0.4 - '@shikijs/rehype@3.19.0': + '@shikijs/rehype@4.0.2': dependencies: - '@shikijs/types': 3.19.0 + '@shikijs/types': 4.0.2 '@types/hast': 3.0.4 hast-util-to-string: 3.0.1 - shiki: 3.19.0 + shiki: 4.0.2 unified: 11.0.5 - unist-util-visit: 5.0.0 + unist-util-visit: 5.1.0 '@shikijs/themes@3.17.0': dependencies: '@shikijs/types': 3.17.0 - '@shikijs/themes@3.19.0': - dependencies: - '@shikijs/types': 3.19.0 - - '@shikijs/themes@3.3.0': + '@shikijs/themes@4.0.2': dependencies: - '@shikijs/types': 3.3.0 + '@shikijs/types': 4.0.2 - '@shikijs/transformers@3.19.0': + '@shikijs/transformers@4.0.2': dependencies: - '@shikijs/core': 3.19.0 - '@shikijs/types': 3.19.0 + '@shikijs/core': 4.0.2 + '@shikijs/types': 4.0.2 '@shikijs/types@3.17.0': dependencies: '@shikijs/vscode-textmate': 10.0.2 '@types/hast': 3.0.4 - '@shikijs/types@3.19.0': - dependencies: - '@shikijs/vscode-textmate': 10.0.2 - '@types/hast': 3.0.4 - - '@shikijs/types@3.3.0': + '@shikijs/types@4.0.2': dependencies: '@shikijs/vscode-textmate': 10.0.2 '@types/hast': 3.0.4 @@ -28170,6 +28694,8 @@ snapshots: '@standard-schema/spec@1.0.0': {} + '@standard-schema/spec@1.1.0': {} + '@standard-schema/utils@0.3.0': {} '@swc/counter@0.1.3': {} @@ -28192,7 +28718,7 @@ snapshots: enhanced-resolve: 5.18.3 jiti: 2.6.1 lightningcss: 1.30.1 - magic-string: 0.30.17 + magic-string: 0.30.21 source-map-js: 1.2.1 tailwindcss: 4.1.12 @@ -29126,6 +29652,8 @@ snapshots: '@types/js-yaml@4.0.9': {} + '@types/json-schema@7.0.15': {} + '@types/jsonwebtoken@8.5.9': dependencies: '@types/node': 20.19.24 @@ -29422,6 +29950,8 @@ snapshots: dependencies: '@types/yargs-parser': 21.0.3 + '@typescript-eslint/types@8.58.1': {} + '@ungap/structured-clone@1.2.0': {} '@unhead/vue@2.0.19(vue@3.5.25(typescript@5.9.3))': @@ -29553,7 +30083,7 @@ snapshots: '@vitest/snapshot@1.6.1': dependencies: - magic-string: 0.30.19 + magic-string: 0.30.21 pathe: 1.1.2 pretty-format: 29.7.0 @@ -29836,28 +30366,28 @@ snapshots: clean-stack: 2.2.0 indent-string: 4.0.0 - ai@4.2.10(react@19.2.3)(zod@3.24.2): + ai@4.2.10(react@19.2.3)(zod@4.1.13): dependencies: '@ai-sdk/provider': 1.1.0 - '@ai-sdk/provider-utils': 2.2.3(zod@3.24.2) - '@ai-sdk/react': 1.2.5(react@19.2.3)(zod@3.24.2) - '@ai-sdk/ui-utils': 1.2.4(zod@3.24.2) + '@ai-sdk/provider-utils': 2.2.3(zod@4.1.13) + '@ai-sdk/react': 1.2.5(react@19.2.3)(zod@4.1.13) + '@ai-sdk/ui-utils': 1.2.4(zod@4.1.13) '@opentelemetry/api': 1.9.0 jsondiffpatch: 0.6.0 - zod: 3.24.2 + zod: 4.1.13 optionalDependencies: react: 19.2.3 - ajv-formats@3.0.1(ajv@8.12.0): + ajv-formats@3.0.1(ajv@8.18.0): optionalDependencies: - ajv: 8.12.0 + ajv: 8.18.0 - ajv@8.12.0: + ajv@8.18.0: dependencies: fast-deep-equal: 3.1.3 + fast-uri: 3.0.6 json-schema-traverse: 1.0.0 require-from-string: 2.0.2 - uri-js: 4.4.1 alien-signals@3.1.1: {} @@ -30077,7 +30607,7 @@ snapshots: prompts: 2.4.2 rehype: 13.0.2 semver: 7.7.1 - shiki: 3.3.0 + shiki: 3.17.0 tinyexec: 0.3.2 tinyglobby: 0.2.13 tsconfck: 3.1.5(typescript@5.9.3) @@ -31759,7 +32289,9 @@ snapshots: dset@3.1.4: {} - dts-resolver@2.1.2: {} + dts-resolver@2.1.2(oxc-resolver@11.19.1(@emnapi/core@1.7.1)(@emnapi/runtime@1.7.1)): + optionalDependencies: + oxc-resolver: 11.19.1(@emnapi/core@1.7.1)(@emnapi/runtime@1.7.1) dunder-proto@1.0.1: dependencies: @@ -32249,35 +32781,6 @@ snapshots: '@esbuild/win32-ia32': 0.27.0 '@esbuild/win32-x64': 0.27.0 - esbuild@0.27.1: - optionalDependencies: - '@esbuild/aix-ppc64': 0.27.1 - '@esbuild/android-arm': 0.27.1 - '@esbuild/android-arm64': 0.27.1 - '@esbuild/android-x64': 0.27.1 - '@esbuild/darwin-arm64': 0.27.1 - '@esbuild/darwin-x64': 0.27.1 - '@esbuild/freebsd-arm64': 0.27.1 - '@esbuild/freebsd-x64': 0.27.1 - '@esbuild/linux-arm': 0.27.1 - '@esbuild/linux-arm64': 0.27.1 - '@esbuild/linux-ia32': 0.27.1 - '@esbuild/linux-loong64': 0.27.1 - '@esbuild/linux-mips64el': 0.27.1 - '@esbuild/linux-ppc64': 0.27.1 - '@esbuild/linux-riscv64': 0.27.1 - '@esbuild/linux-s390x': 0.27.1 - '@esbuild/linux-x64': 0.27.1 - '@esbuild/netbsd-arm64': 0.27.1 - '@esbuild/netbsd-x64': 0.27.1 - '@esbuild/openbsd-arm64': 0.27.1 - '@esbuild/openbsd-x64': 0.27.1 - '@esbuild/openharmony-arm64': 0.27.1 - '@esbuild/sunos-x64': 0.27.1 - '@esbuild/win32-arm64': 0.27.1 - '@esbuild/win32-ia32': 0.27.1 - '@esbuild/win32-x64': 0.27.1 - esbuild@0.27.3: optionalDependencies: '@esbuild/aix-ppc64': 0.27.3 @@ -32325,6 +32828,11 @@ snapshots: esprima@4.0.1: {} + esrap@2.2.4: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + '@typescript-eslint/types': 8.58.1 + estree-util-attach-comments@3.0.0: dependencies: '@types/estree': 1.0.8 @@ -32384,6 +32892,12 @@ snapshots: events@3.3.0: {} + eventsource-parser@3.0.6: {} + + eventsource@3.0.7: + dependencies: + eventsource-parser: 3.0.6 + exec-async@2.2.0: {} execa@1.0.0: @@ -32410,7 +32924,7 @@ snapshots: execa@8.0.1: dependencies: - cross-spawn: 7.0.3 + cross-spawn: 7.0.6 get-stream: 8.0.1 human-signals: 5.0.0 is-stream: 3.0.0 @@ -32505,6 +33019,11 @@ snapshots: - supports-color - utf-8-validate + express-rate-limit@8.3.1(express@5.2.1): + dependencies: + express: 5.2.1 + ip-address: 10.1.0 + express@4.18.2: dependencies: accepts: 1.3.8 @@ -32675,6 +33194,8 @@ snapshots: dependencies: pure-rand: 6.1.0 + fast-content-type-parse@3.0.0: {} + fast-decode-uri-component@1.0.1: {} fast-deep-equal@2.0.1: {} @@ -32734,11 +33255,11 @@ snapshots: fast-json-stringify@6.0.1: dependencies: '@fastify/merge-json-schemas': 0.2.1 - ajv: 8.12.0 - ajv-formats: 3.0.1(ajv@8.12.0) + ajv: 8.18.0 + ajv-formats: 3.0.1(ajv@8.18.0) fast-uri: 3.0.6 json-schema-ref-resolver: 2.0.1 - rfdc: 1.3.1 + rfdc: 1.4.1 fast-npm-meta@0.4.7: {} @@ -32752,6 +33273,10 @@ snapshots: fast-uri@3.0.6: {} + fast-xml-builder@1.1.4: + dependencies: + path-expression-matcher: 1.4.0 + fast-xml-parser@4.3.4: dependencies: strnum: 1.0.5 @@ -32760,6 +33285,12 @@ snapshots: dependencies: strnum: 2.1.2 + fast-xml-parser@5.5.10: + dependencies: + fast-xml-builder: 1.1.4 + path-expression-matcher: 1.4.0 + strnum: 2.2.3 + fastify-metrics@12.1.0(fastify@5.6.1): dependencies: fastify: 5.6.1 @@ -32774,6 +33305,18 @@ snapshots: raw-body: 3.0.0 secure-json-parse: 2.7.0 + fastify-zod-openapi@5.6.1(@fastify/swagger-ui@5.2.5)(@fastify/swagger@9.7.0)(fastify@5.6.1)(zod@4.1.13): + dependencies: + '@fastify/error': 4.0.0 + fast-json-stringify: 6.0.1 + fastify: 5.6.1 + fastify-plugin: 5.0.1 + zod: 4.1.13 + zod-openapi: 5.4.6(zod@4.1.13) + optionalDependencies: + '@fastify/swagger': 9.7.0 + '@fastify/swagger-ui': 5.2.5 + fastify@5.6.1: dependencies: '@fastify/ajv-compiler': 4.0.2 @@ -32832,6 +33375,10 @@ snapshots: optionalDependencies: picomatch: 4.0.3 + fdir@6.5.0(picomatch@4.0.4): + optionalDependencies: + picomatch: 4.0.4 + fecha@4.2.3: {} fetch-retry@4.1.1: {} @@ -32988,6 +33535,8 @@ snapshots: dependencies: is-callable: 1.2.7 + foreach@2.0.6: {} + foreground-child@3.1.1: dependencies: cross-spawn: 7.0.6 @@ -33061,6 +33610,16 @@ snapshots: react: 19.2.3 react-dom: 19.2.3(react@19.2.3) + framer-motion@12.38.0(@emotion/is-prop-valid@0.8.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3): + dependencies: + motion-dom: 12.38.0 + motion-utils: 12.36.0 + tslib: 2.8.1 + optionalDependencies: + '@emotion/is-prop-valid': 0.8.8 + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) + freeport-async@2.0.0: {} fresh@0.5.2: {} @@ -33098,67 +33657,131 @@ snapshots: fsevents@2.3.3: optional: true - fumadocs-core@16.2.2(@tanstack/react-router@1.132.47(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(@types/react@19.2.7)(lucide-react@0.555.0(react@19.2.3))(next@16.0.7(@opentelemetry/api@1.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-dom@19.2.3(react@19.2.3))(react-router@7.13.1(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react@19.2.3): + fuma-cli@0.0.3(@emnapi/core@1.7.1)(@emnapi/runtime@1.7.1): dependencies: - '@formatjs/intl-localematcher': 0.6.2 - '@orama/orama': 3.1.16 - '@shikijs/rehype': 3.19.0 - '@shikijs/transformers': 3.19.0 + magic-string: 0.30.21 + oxc-parser: 0.124.0(@emnapi/core@1.7.1)(@emnapi/runtime@1.7.1) + oxc-resolver: 11.19.1(@emnapi/core@1.7.1)(@emnapi/runtime@1.7.1) + package-manager-detector: 1.6.0 + picocolors: 1.1.1 + tinyexec: 1.1.1 + zod: 4.3.6 + transitivePeerDependencies: + - '@emnapi/core' + - '@emnapi/runtime' + + fumadocs-core@16.7.11(@mdx-js/mdx@3.1.1)(@tanstack/react-router@1.132.47(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(@types/estree-jsx@1.0.5)(@types/hast@3.0.4)(@types/mdast@4.0.4)(@types/react@19.2.7)(lucide-react@0.555.0(react@19.2.3))(next@16.0.7(@opentelemetry/api@1.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-dom@19.2.3(react@19.2.3))(react-router@7.13.1(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react@19.2.3)(zod@4.1.13): + dependencies: + '@formatjs/intl-localematcher': 0.8.2 + '@orama/orama': 3.1.18 + '@shikijs/rehype': 4.0.2 + '@shikijs/transformers': 4.0.2 estree-util-value-to-estree: 3.5.0 github-slugger: 2.0.0 hast-util-to-estree: 3.1.3 hast-util-to-jsx-runtime: 2.3.6 image-size: 2.0.2 + mdast-util-mdx: 3.0.0 + mdast-util-to-markdown: 2.1.2 negotiator: 1.0.0 npm-to-yarn: 3.0.1 - path-to-regexp: 8.3.0 + path-to-regexp: 8.4.2 remark: 15.0.1 remark-gfm: 4.0.1 remark-rehype: 11.1.2 scroll-into-view-if-needed: 3.1.0 - shiki: 3.19.0 - unist-util-visit: 5.0.0 + shiki: 4.0.2 + tinyglobby: 0.2.16 + unified: 11.0.5 + unist-util-visit: 5.1.0 + vfile: 6.0.3 optionalDependencies: + '@mdx-js/mdx': 3.1.1 '@tanstack/react-router': 1.132.47(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@types/estree-jsx': 1.0.5 + '@types/hast': 3.0.4 + '@types/mdast': 4.0.4 '@types/react': 19.2.7 lucide-react: 0.555.0(react@19.2.3) next: 16.0.7(@opentelemetry/api@1.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) react: 19.2.3 react-dom: 19.2.3(react@19.2.3) react-router: 7.13.1(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + zod: 4.1.13 transitivePeerDependencies: - supports-color - fumadocs-mdx@14.0.4(fumadocs-core@16.2.2(@tanstack/react-router@1.132.47(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(@types/react@19.2.7)(lucide-react@0.555.0(react@19.2.3))(next@16.0.7(@opentelemetry/api@1.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-dom@19.2.3(react@19.2.3))(react-router@7.13.1(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react@19.2.3))(next@16.0.7(@opentelemetry/api@1.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react@19.2.3)(vite@7.3.0(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.27.1)(tsx@4.20.5)(yaml@2.8.2)): + fumadocs-mdx@14.2.11(@types/mdast@4.0.4)(@types/mdx@2.0.13)(@types/react@19.2.7)(fumadocs-core@16.7.11(@mdx-js/mdx@3.1.1)(@tanstack/react-router@1.132.47(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(@types/estree-jsx@1.0.5)(@types/hast@3.0.4)(@types/mdast@4.0.4)(@types/react@19.2.7)(lucide-react@0.555.0(react@19.2.3))(next@16.0.7(@opentelemetry/api@1.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-dom@19.2.3(react@19.2.3))(react-router@7.13.1(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react@19.2.3)(zod@4.1.13))(next@16.0.7(@opentelemetry/api@1.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react@19.2.3)(vite@7.3.0(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.27.1)(tsx@4.20.5)(yaml@2.8.2)): dependencies: '@mdx-js/mdx': 3.1.1 - '@standard-schema/spec': 1.0.0 + '@standard-schema/spec': 1.1.0 chokidar: 5.0.0 - esbuild: 0.27.0 + esbuild: 0.27.3 estree-util-value-to-estree: 3.5.0 - fumadocs-core: 16.2.2(@tanstack/react-router@1.132.47(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(@types/react@19.2.7)(lucide-react@0.555.0(react@19.2.3))(next@16.0.7(@opentelemetry/api@1.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-dom@19.2.3(react@19.2.3))(react-router@7.13.1(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react@19.2.3) + fumadocs-core: 16.7.11(@mdx-js/mdx@3.1.1)(@tanstack/react-router@1.132.47(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(@types/estree-jsx@1.0.5)(@types/hast@3.0.4)(@types/mdast@4.0.4)(@types/react@19.2.7)(lucide-react@0.555.0(react@19.2.3))(next@16.0.7(@opentelemetry/api@1.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-dom@19.2.3(react@19.2.3))(react-router@7.13.1(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react@19.2.3)(zod@4.1.13) js-yaml: 4.1.1 - lru-cache: 11.2.2 + mdast-util-mdx: 3.0.0 mdast-util-to-markdown: 2.1.2 picocolors: 1.1.1 picomatch: 4.0.3 - remark-mdx: 3.1.1 - tinyexec: 1.0.2 + tinyexec: 1.1.1 tinyglobby: 0.2.15 unified: 11.0.5 unist-util-remove-position: 5.0.0 - unist-util-visit: 5.0.0 + unist-util-visit: 5.1.0 vfile: 6.0.3 - zod: 4.1.13 + zod: 4.3.6 optionalDependencies: + '@types/mdast': 4.0.4 + '@types/mdx': 2.0.13 + '@types/react': 19.2.7 next: 16.0.7(@opentelemetry/api@1.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) react: 19.2.3 vite: 7.3.0(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.27.1)(tsx@4.20.5)(yaml@2.8.2) transitivePeerDependencies: - supports-color - fumadocs-ui@16.2.2(@tanstack/react-router@1.132.47(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(lucide-react@0.555.0(react@19.2.3))(next@16.0.7(@opentelemetry/api@1.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-dom@19.2.3(react@19.2.3))(react-router@7.13.1(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react@19.2.3)(tailwindcss@4.1.17): + fumadocs-openapi@10.6.7(20b279af7e6f0aee6451333d6680f64b): dependencies: + '@fastify/deepmerge': 3.2.1 + '@fumari/json-schema-ts': 0.0.2(json-schema-typed@8.0.2) + '@fumari/stf': 1.0.4(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-accordion': 1.2.12(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-dialog': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-select': 2.2.6(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-slot': 1.2.4(@types/react@19.2.7)(react@19.2.3) + '@scalar/json-magic': 0.12.5 + '@scalar/openapi-upgrader': 0.2.4 + ajv: 8.18.0 + chokidar: 5.0.0 + class-variance-authority: 0.7.1 + fast-content-type-parse: 3.0.0 + fumadocs-core: 16.7.11(@mdx-js/mdx@3.1.1)(@tanstack/react-router@1.132.47(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(@types/estree-jsx@1.0.5)(@types/hast@3.0.4)(@types/mdast@4.0.4)(@types/react@19.2.7)(lucide-react@0.555.0(react@19.2.3))(next@16.0.7(@opentelemetry/api@1.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-dom@19.2.3(react@19.2.3))(react-router@7.13.1(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react@19.2.3)(zod@4.1.13) + fumadocs-ui: 16.7.11(@emnapi/core@1.7.1)(@emnapi/runtime@1.7.1)(@emotion/is-prop-valid@0.8.8)(@types/mdx@2.0.13)(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(fumadocs-core@16.7.11(@mdx-js/mdx@3.1.1)(@tanstack/react-router@1.132.47(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(@types/estree-jsx@1.0.5)(@types/hast@3.0.4)(@types/mdast@4.0.4)(@types/react@19.2.7)(lucide-react@0.555.0(react@19.2.3))(next@16.0.7(@opentelemetry/api@1.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-dom@19.2.3(react@19.2.3))(react-router@7.13.1(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react@19.2.3)(zod@4.1.13))(next@16.0.7(@opentelemetry/api@1.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(shiki@4.0.2)(tailwindcss@4.1.17) + github-slugger: 2.0.0 + hast-util-to-jsx-runtime: 2.3.6 + js-yaml: 4.1.1 + lucide-react: 1.7.0(react@19.2.3) + next-themes: 0.4.6(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + openapi-sampler: 1.7.2 + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) + react-hook-form: 7.72.1(react@19.2.3) + remark: 15.0.1 + remark-rehype: 11.1.2 + tailwind-merge: 3.5.0 + xml-js: 1.6.11 + optionalDependencies: + '@types/react': 19.2.7 + json-schema-typed: 8.0.2 + shiki: 4.0.2 + transitivePeerDependencies: + - '@types/react-dom' + - supports-color + + fumadocs-ui@16.7.11(@emnapi/core@1.7.1)(@emnapi/runtime@1.7.1)(@emotion/is-prop-valid@0.8.8)(@types/mdx@2.0.13)(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(fumadocs-core@16.7.11(@mdx-js/mdx@3.1.1)(@tanstack/react-router@1.132.47(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(@types/estree-jsx@1.0.5)(@types/hast@3.0.4)(@types/mdast@4.0.4)(@types/react@19.2.7)(lucide-react@0.555.0(react@19.2.3))(next@16.0.7(@opentelemetry/api@1.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-dom@19.2.3(react@19.2.3))(react-router@7.13.1(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react@19.2.3)(zod@4.1.13))(next@16.0.7(@opentelemetry/api@1.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(shiki@4.0.2)(tailwindcss@4.1.17): + dependencies: + '@fumadocs/tailwind': 0.0.3(tailwindcss@4.1.17) '@radix-ui/react-accordion': 1.2.12(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) '@radix-ui/react-collapsible': 1.1.12(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) '@radix-ui/react-dialog': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) @@ -33170,29 +33793,30 @@ snapshots: '@radix-ui/react-slot': 1.2.4(@types/react@19.2.7)(react@19.2.3) '@radix-ui/react-tabs': 1.1.13(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) class-variance-authority: 0.7.1 - fumadocs-core: 16.2.2(@tanstack/react-router@1.132.47(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(@types/react@19.2.7)(lucide-react@0.555.0(react@19.2.3))(next@16.0.7(@opentelemetry/api@1.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-dom@19.2.3(react@19.2.3))(react-router@7.13.1(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react@19.2.3) - lodash.merge: 4.6.2 + fuma-cli: 0.0.3(@emnapi/core@1.7.1)(@emnapi/runtime@1.7.1) + fumadocs-core: 16.7.11(@mdx-js/mdx@3.1.1)(@tanstack/react-router@1.132.47(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(@types/estree-jsx@1.0.5)(@types/hast@3.0.4)(@types/mdast@4.0.4)(@types/react@19.2.7)(lucide-react@0.555.0(react@19.2.3))(next@16.0.7(@opentelemetry/api@1.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-dom@19.2.3(react@19.2.3))(react-router@7.13.1(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react@19.2.3)(zod@4.1.13) + lucide-react: 1.7.0(react@19.2.3) + motion: 12.38.0(@emotion/is-prop-valid@0.8.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) next-themes: 0.4.6(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - postcss-selector-parser: 7.1.1 react: 19.2.3 react-dom: 19.2.3(react@19.2.3) - react-medium-image-zoom: 5.4.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + react-medium-image-zoom: 5.4.3(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + react-remove-scroll: 2.7.2(@types/react@19.2.7)(react@19.2.3) + rehype-raw: 7.0.0 scroll-into-view-if-needed: 3.1.0 - tailwind-merge: 3.4.0 + tailwind-merge: 3.5.0 + unist-util-visit: 5.1.0 optionalDependencies: + '@types/mdx': 2.0.13 '@types/react': 19.2.7 next: 16.0.7(@opentelemetry/api@1.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - tailwindcss: 4.1.17 + shiki: 4.0.2 transitivePeerDependencies: - - '@mixedbread/sdk' - - '@orama/core' - - '@tanstack/react-router' + - '@emnapi/core' + - '@emnapi/runtime' + - '@emotion/is-prop-valid' - '@types/react-dom' - - algoliasearch - - lucide-react - - react-router - - supports-color - - waku + - tailwindcss function-bind@1.1.2: {} @@ -33563,7 +34187,7 @@ snapshots: mdast-util-to-hast: 13.1.0 parse5: 7.3.0 unist-util-position: 5.0.0 - unist-util-visit: 5.0.0 + unist-util-visit: 5.1.0 vfile: 6.0.3 web-namespaces: 2.0.1 zwitch: 2.0.4 @@ -33589,20 +34213,6 @@ snapshots: transitivePeerDependencies: - supports-color - hast-util-to-html@9.0.3: - dependencies: - '@types/hast': 3.0.4 - '@types/unist': 3.0.2 - ccount: 2.0.1 - comma-separated-tokens: 2.0.3 - hast-util-whitespace: 3.0.0 - html-void-elements: 3.0.0 - mdast-util-to-hast: 13.1.0 - property-information: 6.4.1 - space-separated-tokens: 2.0.2 - stringify-entities: 4.0.3 - zwitch: 2.0.4 - hast-util-to-html@9.0.5: dependencies: '@types/hast': 3.0.4 @@ -33704,6 +34314,8 @@ snapshots: dependencies: parse-passwd: 1.0.0 + hono@4.12.9: {} + hookable@5.5.3: {} hosted-git-info@3.0.8: @@ -33763,7 +34375,7 @@ snapshots: http-proxy-agent@7.0.2: dependencies: agent-base: 7.1.1 - debug: 4.4.1 + debug: 4.4.3 transitivePeerDependencies: - supports-color @@ -33779,7 +34391,7 @@ snapshots: https-proxy-agent@7.0.6: dependencies: agent-base: 7.1.4 - debug: 4.4.1 + debug: 4.4.3 transitivePeerDependencies: - supports-color @@ -33938,6 +34550,8 @@ snapshots: transitivePeerDependencies: - supports-color + ip-address@10.1.0: {} + ip-regex@2.1.0: {} ipaddr.js@1.9.1: {} @@ -34290,6 +34904,8 @@ snapshots: join-component@1.1.0: {} + jose@6.2.2: {} + joycon@3.1.1: {} js-beautify@1.15.1: @@ -34413,6 +35029,10 @@ snapshots: json-parse-even-better-errors@2.3.1: {} + json-pointer@0.6.2: + dependencies: + foreach: 2.0.6 + json-schema-deref-sync@0.13.0: dependencies: clone: 2.1.2 @@ -34428,8 +35048,18 @@ snapshots: dependencies: dequal: 2.0.3 + json-schema-resolver@3.0.0: + dependencies: + debug: 4.4.3 + fast-uri: 3.0.6 + rfdc: 1.4.1 + transitivePeerDependencies: + - supports-color + json-schema-traverse@1.0.0: {} + json-schema-typed@8.0.2: {} + json-schema@0.4.0: {} json-stringify-safe@5.0.1: {} @@ -34823,6 +35453,10 @@ snapshots: dependencies: react: 19.2.3 + lucide-react@1.7.0(react@19.2.3): + dependencies: + react: 19.2.3 + luxon@3.7.2: {} lz-string@1.5.0: {} @@ -34845,10 +35479,6 @@ snapshots: dependencies: '@jridgewell/sourcemap-codec': 1.5.0 - magic-string@0.30.19: - dependencies: - '@jridgewell/sourcemap-codec': 1.5.5 - magic-string@0.30.21: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 @@ -35011,7 +35641,7 @@ snapshots: mdast-util-gfm-strikethrough: 2.0.0 mdast-util-gfm-table: 2.0.0 mdast-util-gfm-task-list-item: 2.0.0 - mdast-util-to-markdown: 2.1.1 + mdast-util-to-markdown: 2.1.2 transitivePeerDependencies: - supports-color @@ -35022,7 +35652,7 @@ snapshots: devlop: 1.1.0 longest-streak: 3.1.0 mdast-util-from-markdown: 2.0.2 - mdast-util-to-markdown: 2.1.1 + mdast-util-to-markdown: 2.1.2 unist-util-remove-position: 5.0.0 transitivePeerDependencies: - supports-color @@ -35093,18 +35723,6 @@ snapshots: unist-util-visit: 5.0.0 vfile: 6.0.3 - mdast-util-to-markdown@2.1.1: - dependencies: - '@types/mdast': 4.0.4 - '@types/unist': 3.0.2 - longest-streak: 3.1.0 - mdast-util-phrasing: 4.1.0 - mdast-util-to-string: 4.0.0 - micromark-util-classify-character: 2.0.0 - micromark-util-decode-string: 2.0.0 - unist-util-visit: 5.0.0 - zwitch: 2.0.4 - mdast-util-to-markdown@2.1.2: dependencies: '@types/mdast': 4.0.4 @@ -35114,7 +35732,7 @@ snapshots: mdast-util-to-string: 4.0.0 micromark-util-classify-character: 2.0.0 micromark-util-decode-string: 2.0.0 - unist-util-visit: 5.0.0 + unist-util-visit: 5.1.0 zwitch: 2.0.4 mdast-util-to-string@4.0.0: @@ -35779,10 +36397,16 @@ snapshots: dependencies: motion-utils: 12.23.6 + motion-dom@12.38.0: + dependencies: + motion-utils: 12.36.0 + motion-utils@11.18.1: {} motion-utils@12.23.6: {} + motion-utils@12.36.0: {} + motion@11.18.2(@emotion/is-prop-valid@0.8.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3): dependencies: framer-motion: 11.18.2(@emotion/is-prop-valid@0.8.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) @@ -35792,6 +36416,15 @@ snapshots: react: 19.2.3 react-dom: 19.2.3(react@19.2.3) + motion@12.38.0(@emotion/is-prop-valid@0.8.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3): + dependencies: + framer-motion: 12.38.0(@emotion/is-prop-valid@0.8.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + tslib: 2.8.1 + optionalDependencies: + '@emotion/is-prop-valid': 0.8.8 + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) + mrmime@2.0.1: {} ms@2.0.0: {} @@ -36007,7 +36640,7 @@ snapshots: klona: 2.0.6 knitwork: 1.2.0 listhen: 1.9.0 - magic-string: 0.30.19 + magic-string: 0.30.21 magicast: 0.3.5 mime: 4.1.0 mlly: 1.8.0 @@ -36027,7 +36660,7 @@ snapshots: serve-placeholder: 2.0.2 serve-static: 2.2.0 source-map: 0.7.6 - std-env: 3.9.0 + std-env: 3.10.0 ufo: 1.6.1 ultrahtml: 1.6.0 uncrypto: 0.1.3 @@ -36479,16 +37112,8 @@ snapshots: dependencies: mimic-fn: 4.0.0 - oniguruma-parser@0.12.0: {} - oniguruma-parser@0.12.1: {} - oniguruma-to-es@4.3.1: - dependencies: - oniguruma-parser: 0.12.0 - regex: 6.0.1 - regex-recursion: 6.0.2 - oniguruma-to-es@4.3.4: dependencies: oniguruma-parser: 0.12.1 @@ -36517,6 +37142,14 @@ snapshots: is-docker: 2.2.1 is-wsl: 2.2.0 + openapi-sampler@1.7.2: + dependencies: + '@types/json-schema': 7.0.15 + fast-xml-parser: 5.5.10 + json-pointer: 0.6.2 + + openapi-types@12.1.3: {} + ora@3.4.0: dependencies: chalk: 2.4.2 @@ -36587,6 +37220,60 @@ snapshots: '@oxc-parser/binding-win32-arm64-msvc': 0.102.0 '@oxc-parser/binding-win32-x64-msvc': 0.102.0 + oxc-parser@0.124.0(@emnapi/core@1.7.1)(@emnapi/runtime@1.7.1): + dependencies: + '@oxc-project/types': 0.124.0 + optionalDependencies: + '@oxc-parser/binding-android-arm-eabi': 0.124.0 + '@oxc-parser/binding-android-arm64': 0.124.0 + '@oxc-parser/binding-darwin-arm64': 0.124.0 + '@oxc-parser/binding-darwin-x64': 0.124.0 + '@oxc-parser/binding-freebsd-x64': 0.124.0 + '@oxc-parser/binding-linux-arm-gnueabihf': 0.124.0 + '@oxc-parser/binding-linux-arm-musleabihf': 0.124.0 + '@oxc-parser/binding-linux-arm64-gnu': 0.124.0 + '@oxc-parser/binding-linux-arm64-musl': 0.124.0 + '@oxc-parser/binding-linux-ppc64-gnu': 0.124.0 + '@oxc-parser/binding-linux-riscv64-gnu': 0.124.0 + '@oxc-parser/binding-linux-riscv64-musl': 0.124.0 + '@oxc-parser/binding-linux-s390x-gnu': 0.124.0 + '@oxc-parser/binding-linux-x64-gnu': 0.124.0 + '@oxc-parser/binding-linux-x64-musl': 0.124.0 + '@oxc-parser/binding-openharmony-arm64': 0.124.0 + '@oxc-parser/binding-wasm32-wasi': 0.124.0(@emnapi/core@1.7.1)(@emnapi/runtime@1.7.1) + '@oxc-parser/binding-win32-arm64-msvc': 0.124.0 + '@oxc-parser/binding-win32-ia32-msvc': 0.124.0 + '@oxc-parser/binding-win32-x64-msvc': 0.124.0 + transitivePeerDependencies: + - '@emnapi/core' + - '@emnapi/runtime' + + oxc-resolver@11.19.1(@emnapi/core@1.7.1)(@emnapi/runtime@1.7.1): + optionalDependencies: + '@oxc-resolver/binding-android-arm-eabi': 11.19.1 + '@oxc-resolver/binding-android-arm64': 11.19.1 + '@oxc-resolver/binding-darwin-arm64': 11.19.1 + '@oxc-resolver/binding-darwin-x64': 11.19.1 + '@oxc-resolver/binding-freebsd-x64': 11.19.1 + '@oxc-resolver/binding-linux-arm-gnueabihf': 11.19.1 + '@oxc-resolver/binding-linux-arm-musleabihf': 11.19.1 + '@oxc-resolver/binding-linux-arm64-gnu': 11.19.1 + '@oxc-resolver/binding-linux-arm64-musl': 11.19.1 + '@oxc-resolver/binding-linux-ppc64-gnu': 11.19.1 + '@oxc-resolver/binding-linux-riscv64-gnu': 11.19.1 + '@oxc-resolver/binding-linux-riscv64-musl': 11.19.1 + '@oxc-resolver/binding-linux-s390x-gnu': 11.19.1 + '@oxc-resolver/binding-linux-x64-gnu': 11.19.1 + '@oxc-resolver/binding-linux-x64-musl': 11.19.1 + '@oxc-resolver/binding-openharmony-arm64': 11.19.1 + '@oxc-resolver/binding-wasm32-wasi': 11.19.1(@emnapi/core@1.7.1)(@emnapi/runtime@1.7.1) + '@oxc-resolver/binding-win32-arm64-msvc': 11.19.1 + '@oxc-resolver/binding-win32-ia32-msvc': 11.19.1 + '@oxc-resolver/binding-win32-x64-msvc': 11.19.1 + transitivePeerDependencies: + - '@emnapi/core' + - '@emnapi/runtime' + oxc-transform@0.102.0: optionalDependencies: '@oxc-transform/binding-android-arm64': 0.102.0 @@ -36671,6 +37358,8 @@ snapshots: package-manager-detector@1.2.0: {} + package-manager-detector@1.6.0: {} + pako@0.2.9: {} parent-module@1.0.1: @@ -36764,6 +37453,8 @@ snapshots: path-exists@4.0.0: {} + path-expression-matcher@1.4.0: {} + path-is-absolute@1.0.1: {} path-key@2.0.1: {} @@ -36794,6 +37485,8 @@ snapshots: path-to-regexp@8.3.0: {} + path-to-regexp@8.4.2: {} + path-type@4.0.0: {} path-type@6.0.0: {} @@ -36840,6 +37533,8 @@ snapshots: picomatch@4.0.3: {} + picomatch@4.0.4: {} + pify@4.0.1: {} pino-abstract-transport@1.2.0: @@ -36869,6 +37564,8 @@ snapshots: pirates@4.0.6: {} + pkce-challenge@5.0.1: {} + pkg-dir@3.0.0: dependencies: find-up: 3.0.0 @@ -36939,7 +37636,7 @@ snapshots: postcss-load-config@4.0.2(postcss@8.5.6): dependencies: lilconfig: 3.1.0 - yaml: 2.3.4 + yaml: 2.8.2 optionalDependencies: postcss: 8.5.6 @@ -37459,6 +38156,10 @@ snapshots: dependencies: react: 19.2.3 + react-hook-form@7.72.1(react@19.2.3): + dependencies: + react: 19.2.3 + react-in-viewport@1.0.0-beta.8(react-dom@19.2.3(react@19.2.3))(react@19.2.3): dependencies: hoist-non-react-statics: 3.3.2 @@ -37491,7 +38192,7 @@ snapshots: transitivePeerDependencies: - supports-color - react-medium-image-zoom@5.4.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3): + react-medium-image-zoom@5.4.3(react-dom@19.2.3(react@19.2.3))(react@19.2.3): dependencies: react: 19.2.3 react-dom: 19.2.3(react@19.2.3) @@ -37624,34 +38325,37 @@ snapshots: react-refresh@0.17.0: {} - react-remove-scroll-bar@2.3.6(@types/react@19.2.7)(react@19.2.3): + react-remove-scroll-bar@2.3.8(@types/react@19.2.7)(react@19.2.3): dependencies: react: 19.2.3 - react-style-singleton: 2.2.1(@types/react@19.2.7)(react@19.2.3) + react-style-singleton: 2.2.3(@types/react@19.2.7)(react@19.2.3) tslib: 2.8.1 optionalDependencies: '@types/react': 19.2.7 - react-remove-scroll-bar@2.3.8(@types/react@19.2.7)(react@19.2.3): + react-remove-scroll@2.5.4(@types/react@19.2.7)(react@19.2.3): dependencies: react: 19.2.3 + react-remove-scroll-bar: 2.3.8(@types/react@19.2.7)(react@19.2.3) react-style-singleton: 2.2.3(@types/react@19.2.7)(react@19.2.3) tslib: 2.8.1 + use-callback-ref: 1.3.3(@types/react@19.2.7)(react@19.2.3) + use-sidecar: 1.1.3(@types/react@19.2.7)(react@19.2.3) optionalDependencies: '@types/react': 19.2.7 - react-remove-scroll@2.5.4(@types/react@19.2.7)(react@19.2.3): + react-remove-scroll@2.7.1(@types/react@19.2.7)(react@19.2.3): dependencies: react: 19.2.3 - react-remove-scroll-bar: 2.3.6(@types/react@19.2.7)(react@19.2.3) - react-style-singleton: 2.2.1(@types/react@19.2.7)(react@19.2.3) + react-remove-scroll-bar: 2.3.8(@types/react@19.2.7)(react@19.2.3) + react-style-singleton: 2.2.3(@types/react@19.2.7)(react@19.2.3) tslib: 2.8.1 - use-callback-ref: 1.3.1(@types/react@19.2.7)(react@19.2.3) - use-sidecar: 1.1.2(@types/react@19.2.7)(react@19.2.3) + use-callback-ref: 1.3.3(@types/react@19.2.7)(react@19.2.3) + use-sidecar: 1.1.3(@types/react@19.2.7)(react@19.2.3) optionalDependencies: '@types/react': 19.2.7 - react-remove-scroll@2.7.1(@types/react@19.2.7)(react@19.2.3): + react-remove-scroll@2.7.2(@types/react@19.2.7)(react@19.2.3): dependencies: react: 19.2.3 react-remove-scroll-bar: 2.3.8(@types/react@19.2.7)(react@19.2.3) @@ -37716,15 +38420,6 @@ snapshots: react-dom: 19.2.3(react@19.2.3) react-transition-group: 4.4.5(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - react-style-singleton@2.2.1(@types/react@19.2.7)(react@19.2.3): - dependencies: - get-nonce: 1.0.1 - invariant: 2.2.4 - react: 19.2.3 - tslib: 2.8.1 - optionalDependencies: - '@types/react': 19.2.7 - react-style-singleton@2.2.3(@types/react@19.2.7)(react@19.2.3): dependencies: get-nonce: 1.0.1 @@ -37999,7 +38694,7 @@ snapshots: rehype-stringify@10.0.1: dependencies: '@types/hast': 3.0.4 - hast-util-to-html: 9.0.3 + hast-util-to-html: 9.0.5 unified: 11.0.5 rehype@13.0.2: @@ -38072,7 +38767,7 @@ snapshots: remark-stringify@11.0.0: dependencies: '@types/mdast': 4.0.4 - mdast-util-to-markdown: 2.1.1 + mdast-util-to-markdown: 2.1.2 unified: 11.0.5 remark@15.0.1: @@ -38234,7 +38929,7 @@ snapshots: robust-predicates@3.0.2: {} - rolldown-plugin-dts@0.15.9(rolldown@1.0.0-beta.43)(typescript@5.9.3): + rolldown-plugin-dts@0.15.9(oxc-resolver@11.19.1(@emnapi/core@1.7.1)(@emnapi/runtime@1.7.1))(rolldown@1.0.0-beta.43)(typescript@5.9.3): dependencies: '@babel/generator': 7.28.3 '@babel/parser': 7.28.5 @@ -38242,7 +38937,7 @@ snapshots: ast-kit: 2.1.2 birpc: 2.5.0 debug: 4.4.1 - dts-resolver: 2.1.2 + dts-resolver: 2.1.2(oxc-resolver@11.19.1(@emnapi/core@1.7.1)(@emnapi/runtime@1.7.1)) get-tsconfig: 4.10.1 rolldown: 1.0.0-beta.43 optionalDependencies: @@ -38524,12 +39219,12 @@ snapshots: escape-html: 1.0.3 etag: 1.8.1 fresh: 2.0.0 - http-errors: 2.0.0 + http-errors: 2.0.1 mime-types: 3.0.1 ms: 2.1.3 on-finished: 2.4.1 range-parser: 1.2.1 - statuses: 2.0.1 + statuses: 2.0.2 transitivePeerDependencies: - supports-color @@ -38689,25 +39384,14 @@ snapshots: '@shikijs/vscode-textmate': 10.0.2 '@types/hast': 3.0.4 - shiki@3.19.0: + shiki@4.0.2: dependencies: - '@shikijs/core': 3.19.0 - '@shikijs/engine-javascript': 3.19.0 - '@shikijs/engine-oniguruma': 3.19.0 - '@shikijs/langs': 3.19.0 - '@shikijs/themes': 3.19.0 - '@shikijs/types': 3.19.0 - '@shikijs/vscode-textmate': 10.0.2 - '@types/hast': 3.0.4 - - shiki@3.3.0: - dependencies: - '@shikijs/core': 3.3.0 - '@shikijs/engine-javascript': 3.3.0 - '@shikijs/engine-oniguruma': 3.3.0 - '@shikijs/langs': 3.3.0 - '@shikijs/themes': 3.3.0 - '@shikijs/types': 3.3.0 + '@shikijs/core': 4.0.2 + '@shikijs/engine-javascript': 4.0.2 + '@shikijs/engine-oniguruma': 4.0.2 + '@shikijs/langs': 4.0.2 + '@shikijs/themes': 4.0.2 + '@shikijs/types': 4.0.2 '@shikijs/vscode-textmate': 10.0.2 '@types/hast': 3.0.4 @@ -39055,6 +39739,8 @@ snapshots: strnum@2.1.2: {} + strnum@2.2.3: {} + structured-clone-es@1.0.0: {} structured-headers@0.4.1: {} @@ -39198,6 +39884,8 @@ snapshots: tailwind-merge@3.4.0: {} + tailwind-merge@3.5.0: {} + tailwindcss-animate@1.0.7(tailwindcss@4.1.12): dependencies: tailwindcss: 4.1.12 @@ -39368,6 +40056,8 @@ snapshots: tinyexec@1.0.2: {} + tinyexec@1.1.1: {} + tinyglobby@0.2.13: dependencies: fdir: 6.4.4(picomatch@4.0.2) @@ -39378,6 +40068,11 @@ snapshots: fdir: 6.5.0(picomatch@4.0.3) picomatch: 4.0.3 + tinyglobby@0.2.16: + dependencies: + fdir: 6.5.0(picomatch@4.0.4) + picomatch: 4.0.4 + tinypool@0.8.4: {} tinypool@1.0.2: {} @@ -39456,7 +40151,7 @@ snapshots: optionalDependencies: typescript: 5.9.3 - tsdown@0.14.2(typescript@5.9.3): + tsdown@0.14.2(oxc-resolver@11.19.1(@emnapi/core@1.7.1)(@emnapi/runtime@1.7.1))(typescript@5.9.3): dependencies: ansis: 4.2.0 cac: 6.7.14 @@ -39466,7 +40161,7 @@ snapshots: empathic: 2.0.0 hookable: 5.5.3 rolldown: 1.0.0-beta.43 - rolldown-plugin-dts: 0.15.9(rolldown@1.0.0-beta.43)(typescript@5.9.3) + rolldown-plugin-dts: 0.15.9(oxc-resolver@11.19.1(@emnapi/core@1.7.1)(@emnapi/runtime@1.7.1))(rolldown@1.0.0-beta.43)(typescript@5.9.3) semver: 7.7.2 tinyexec: 1.0.1 tinyglobby: 0.2.15 @@ -39764,7 +40459,7 @@ snapshots: extend: 3.0.2 is-plain-obj: 4.1.0 trough: 2.2.0 - vfile: 6.0.1 + vfile: 6.0.3 unifont@0.4.1: dependencies: @@ -39831,7 +40526,7 @@ snapshots: unist-util-remove-position@5.0.0: dependencies: '@types/unist': 3.0.2 - unist-util-visit: 5.0.0 + unist-util-visit: 5.1.0 unist-util-stringify-position@4.0.0: dependencies: @@ -39852,6 +40547,12 @@ snapshots: unist-util-is: 6.0.0 unist-util-visit-parents: 6.0.1 + unist-util-visit@5.1.0: + dependencies: + '@types/unist': 3.0.2 + unist-util-is: 6.0.0 + unist-util-visit-parents: 6.0.1 + universalify@0.1.2: {} universalify@1.0.0: {} @@ -39993,10 +40694,6 @@ snapshots: uqr@0.1.2: {} - uri-js@4.4.1: - dependencies: - punycode: 2.3.1 - url-join@4.0.0: {} url-metadata@5.4.1: @@ -40014,13 +40711,6 @@ snapshots: urlpattern-polyfill@10.1.0: {} - use-callback-ref@1.3.1(@types/react@19.2.7)(react@19.2.3): - dependencies: - react: 19.2.3 - tslib: 2.8.1 - optionalDependencies: - '@types/react': 19.2.7 - use-callback-ref@1.3.3(@types/react@19.2.7)(react@19.2.3): dependencies: react: 19.2.3 @@ -40032,14 +40722,6 @@ snapshots: dependencies: react: 19.2.3 - use-sidecar@1.1.2(@types/react@19.2.7)(react@19.2.3): - dependencies: - detect-node-es: 1.1.0 - react: 19.2.3 - tslib: 2.8.1 - optionalDependencies: - '@types/react': 19.2.7 - use-sidecar@1.1.3(@types/react@19.2.7)(react@19.2.3): dependencies: detect-node-es: 1.1.0 @@ -40136,7 +40818,7 @@ snapshots: vite-node@1.6.1(@types/node@20.19.24)(lightningcss@1.30.2)(terser@5.27.1): dependencies: cac: 6.7.14 - debug: 4.4.1 + debug: 4.4.3 pathe: 1.1.2 picocolors: 1.1.1 vite: 5.4.21(@types/node@20.19.24)(lightningcss@1.30.2)(terser@5.27.1) @@ -40151,6 +40833,24 @@ snapshots: - supports-color - terser + vite-node@1.6.1(@types/node@24.10.1)(lightningcss@1.30.2)(terser@5.27.1): + dependencies: + cac: 6.7.14 + debug: 4.4.3 + pathe: 1.1.2 + picocolors: 1.1.1 + vite: 5.4.21(@types/node@24.10.1)(lightningcss@1.30.2)(terser@5.27.1) + transitivePeerDependencies: + - '@types/node' + - less + - lightningcss + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + vite-node@2.1.9(@types/node@24.10.1)(lightningcss@1.30.2)(terser@5.27.1): dependencies: cac: 6.7.14 @@ -40336,13 +41036,13 @@ snapshots: '@vitest/utils': 1.6.1 acorn-walk: 8.3.2 chai: 4.5.0 - debug: 4.4.1 + debug: 4.4.3 execa: 8.0.1 local-pkg: 0.5.1 - magic-string: 0.30.19 + magic-string: 0.30.21 pathe: 1.1.2 picocolors: 1.1.1 - std-env: 3.9.0 + std-env: 3.10.0 strip-literal: 2.1.1 tinybench: 2.9.0 tinypool: 0.8.4 @@ -40362,6 +41062,41 @@ snapshots: - supports-color - terser + vitest@1.6.1(@types/node@24.10.1)(jsdom@26.1.0)(lightningcss@1.30.2)(terser@5.27.1): + dependencies: + '@vitest/expect': 1.6.1 + '@vitest/runner': 1.6.1 + '@vitest/snapshot': 1.6.1 + '@vitest/spy': 1.6.1 + '@vitest/utils': 1.6.1 + acorn-walk: 8.3.2 + chai: 4.5.0 + debug: 4.4.3 + execa: 8.0.1 + local-pkg: 0.5.1 + magic-string: 0.30.21 + pathe: 1.1.2 + picocolors: 1.1.1 + std-env: 3.10.0 + strip-literal: 2.1.1 + tinybench: 2.9.0 + tinypool: 0.8.4 + vite: 5.4.21(@types/node@24.10.1)(lightningcss@1.30.2)(terser@5.27.1) + vite-node: 1.6.1(@types/node@24.10.1)(lightningcss@1.30.2)(terser@5.27.1) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 24.10.1 + jsdom: 26.1.0 + transitivePeerDependencies: + - less + - lightningcss + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + vitest@2.1.9(@types/node@24.10.1)(jsdom@26.1.0)(lightningcss@1.30.2)(terser@5.27.1): dependencies: '@vitest/expect': 2.1.9 @@ -40709,6 +41444,10 @@ snapshots: simple-plist: 1.3.1 uuid: 7.0.3 + xml-js@1.6.11: + dependencies: + sax: 1.4.3 + xml-name-validator@5.0.0: {} xml2js@0.6.0: @@ -40747,8 +41486,6 @@ snapshots: yaml@1.10.2: {} - yaml@2.3.4: {} - yaml@2.8.2: {} yargs-parser@18.1.3: @@ -40875,23 +41612,31 @@ snapshots: dependencies: bops: 0.1.1 - zod-to-json-schema@3.24.5(zod@3.24.2): + zod-openapi@5.4.6(zod@4.1.13): dependencies: - zod: 3.24.2 + zod: 4.1.13 zod-to-json-schema@3.24.5(zod@3.25.76): dependencies: zod: 3.25.76 + zod-to-json-schema@3.24.5(zod@4.1.13): + dependencies: + zod: 4.1.13 + + zod-to-json-schema@3.25.1(zod@4.1.13): + dependencies: + zod: 4.1.13 + zod-to-ts@1.2.0(typescript@5.9.3)(zod@3.25.76): dependencies: typescript: 5.9.3 zod: 3.25.76 - zod@3.24.2: {} - zod@3.25.76: {} zod@4.1.13: {} + zod@4.3.6: {} + zwitch@2.0.4: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 8fee52f47..936fbe8e5 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -6,7 +6,7 @@ packages: # Define a catalog of version ranges. catalog: - zod: ^3.24.2 + zod: ^4.0.0 react: ^19.2.3 "@types/react": ^19.2.3 "react-dom": ^19.2.3 diff --git a/test/fixtures.ts b/test/fixtures.ts new file mode 100644 index 000000000..f85d901d6 --- /dev/null +++ b/test/fixtures.ts @@ -0,0 +1,441 @@ +/** + * Shared ClickHouse fixture builder for integration tests. + * + * Call setupFixtures(projectId) / teardownFixtures(projectId) from any test + * suite. Each suite uses its own project ID so suites can run concurrently + * without stomping on each other's data. + * + * Fixture dataset (3 users, 8 events, 3 sessions): + * + * Alice — created 60 days ago, browser: Chrome, country: US + * 3 events 2 days ago: session_start → page_view(/home) → session_end + * 1 session (sess-alice-1, 2d ago, Chrome) + * + * Bob — created 90 days ago, browser: Chrome, country: SE — NO events (inactive) + * + * Charlie — created 30 days ago, browser: Firefox, country: US + * 5 events 5 days ago: session_start → screen_view → page_view(/shop) → purchase → session_end + * 2 sessions (sess-charlie-1 5d ago Firefox, sess-charlie-2 10d ago Firefox bounce) + * + * Event UUIDs live in the 00000000-0000-0000-0000-xxxxxxxxxxxx namespace. + * Because events are scoped by project_id, the same UUIDs are safe across + * different project IDs (ClickHouse's MergeTree ordering includes project_id). + */ + +import { createClient } from '../packages/db/src/clickhouse/client'; +import { PrismaClient } from '../packages/db/src/generated/prisma/client'; + +// Lazily create a Prisma client so DATABASE_URL is read at call time, +// not at module-import time (globalSetup runs before env is configured). +function getDb() { + const url = + process.env.DATABASE_URL ?? + 'postgresql://postgres:postgres@localhost:5432/postgres?schema=public'; + return new PrismaClient({ datasources: { db: { url } } }); +} + +// --------------------------------------------------------------------------- +// Well-known fixture IDs — import these in tests instead of hard-coding strings +// --------------------------------------------------------------------------- + +export const FIXTURE = { + profiles: { + alice: 'profile-alice', + bob: 'profile-bob', + charlie: 'profile-charlie', + }, + sessions: { + alice1: 'sess-alice-1', + charlie1: 'sess-charlie-1', + charlie2: 'sess-charlie-2', + }, + events: { + alice: { + sessionStart: '00000000-0000-0000-0000-000000000001', + pageView: '00000000-0000-0000-0000-000000000002', + sessionEnd: '00000000-0000-0000-0000-000000000003', + }, + charlie: { + sessionStart: '00000000-0000-0000-0000-000000000004', + screenView: '00000000-0000-0000-0000-000000000005', + pageView: '00000000-0000-0000-0000-000000000006', + purchase: '00000000-0000-0000-0000-000000000007', + sessionEnd: '00000000-0000-0000-0000-000000000008', + }, + }, +} as const; + +// --------------------------------------------------------------------------- +// Internal helpers +// --------------------------------------------------------------------------- + +type ChClient = ReturnType; + +function getClient() { + const url = process.env.CLICKHOUSE_URL ?? 'http://localhost:8123'; + return createClient({ url }); +} + +function timeAgo(now: Date, days: number, minutesOffset = 0) { + return ( + new Date(now.getTime() - days * 86_400_000 - minutesOffset * 60_000) + .toISOString() + .replace('T', ' ') + // biome-ignore lint/performance/useTopLevelRegex: test setup + .replace(/\.\d+Z$/, '') + ); +} + +function buildEvent( + now: Date, + projectId: string, + id: string, + name: string, + profileId: string, + sessionId: string, + daysBack: number, + minutesOffset = 0, + overrides: Record = {} +) { + return { + id, + project_id: projectId, + profile_id: profileId, + name, + session_id: sessionId, + device_id: `dev-${profileId.replace('profile-', '')}`, + created_at: timeAgo(now, daysBack, minutesOffset), + path: '/', + origin: 'https://example.com', + referrer: '', + referrer_name: '', + referrer_type: '', + revenue: 0, + duration: 0, + properties: {}, + groups: [], + country: 'US', + city: '', + region: '', + sdk_name: 'web', + sdk_version: '1.0.0', + os: '', + os_version: '', + browser: 'Chrome', + browser_version: '', + device: 'desktop', + brand: '', + model: '', + ...overrides, + }; +} + +function buildSession( + now: Date, + projectId: string, + id: string, + profileId: string, + daysBack: number, + overrides: Record = {} +) { + return { + id, + project_id: projectId, + profile_id: profileId, + device_id: `dev-${profileId.replace('profile-', '')}`, + created_at: timeAgo(now, daysBack), + ended_at: timeAgo(now, daysBack), + is_bounce: false, + entry_origin: 'https://example.com', + entry_path: '/home', + exit_origin: 'https://example.com', + exit_path: '/home', + screen_view_count: 1, + revenue: 0, + event_count: 1, + duration: 120, + country: 'US', + region: '', + city: '', + device: 'desktop', + brand: '', + model: '', + browser: 'Chrome', + browser_version: '', + os: '', + os_version: '', + utm_medium: '', + utm_source: '', + utm_campaign: '', + utm_content: '', + utm_term: '', + referrer: '', + referrer_name: '', + referrer_type: '', + sign: 1, + version: 1, + properties: {}, + ...overrides, + }; +} + +async function insertFixtures(client: ChClient, projectId: string) { + const now = new Date(); + + await client.insert({ + table: 'openpanel.profiles', + values: [ + { + id: FIXTURE.profiles.alice, + project_id: projectId, + first_name: 'Alice', + last_name: 'Smith', + email: 'alice@example.com', + avatar: '', + is_external: false, + // browser/country in properties so tests can filter profiles by these fields + properties: { browser: 'Chrome', country: 'US', device: 'desktop' }, + groups: [], + created_at: timeAgo(now, 60), + }, + { + id: FIXTURE.profiles.bob, + project_id: projectId, + first_name: 'Bob', + last_name: "O'Brien", + email: 'bob@example.com', + avatar: '', + is_external: false, + // Bob is intentionally inactive (no events) — useful for inactiveDays tests + properties: { browser: 'Chrome', country: 'SE', device: 'desktop' }, + groups: [], + created_at: timeAgo(now, 90), + }, + { + id: FIXTURE.profiles.charlie, + project_id: projectId, + first_name: 'Charlie', + last_name: 'Brown', + email: 'charlie@example.com', + avatar: '', + is_external: false, + properties: { browser: 'Firefox', country: 'US', device: 'desktop' }, + groups: [], + created_at: timeAgo(now, 30), + }, + ], + format: 'JSONEachRow', + }); + + // Alice: session_start → page_view → session_end (2 days ago, spaced 2 min apart) + // Charlie: session_start → screen_view → page_view → purchase → session_end (5 days ago, spaced 5 min apart) + // Events are spaced so windowFunnel strict_increase mode works correctly. + await client.insert({ + table: 'openpanel.events', + values: [ + buildEvent( + now, + projectId, + FIXTURE.events.alice.sessionStart, + 'session_start', + FIXTURE.profiles.alice, + FIXTURE.sessions.alice1, + 2, + 4 + ), + buildEvent( + now, + projectId, + FIXTURE.events.alice.pageView, + 'page_view', + FIXTURE.profiles.alice, + FIXTURE.sessions.alice1, + 2, + 2, + { path: '/home', browser: 'Chrome' } + ), + buildEvent( + now, + projectId, + FIXTURE.events.alice.sessionEnd, + 'session_end', + FIXTURE.profiles.alice, + FIXTURE.sessions.alice1, + 2, + 0, + { duration: 120_000 } + ), + + buildEvent( + now, + projectId, + FIXTURE.events.charlie.sessionStart, + 'session_start', + FIXTURE.profiles.charlie, + FIXTURE.sessions.charlie1, + 5, + 20, + { browser: 'Firefox' } + ), + buildEvent( + now, + projectId, + FIXTURE.events.charlie.screenView, + 'screen_view', + FIXTURE.profiles.charlie, + FIXTURE.sessions.charlie1, + 5, + 15, + { path: '/shop', browser: 'Firefox' } + ), + buildEvent( + now, + projectId, + FIXTURE.events.charlie.pageView, + 'page_view', + FIXTURE.profiles.charlie, + FIXTURE.sessions.charlie1, + 5, + 10, + { path: '/shop', browser: 'Firefox' } + ), + buildEvent( + now, + projectId, + FIXTURE.events.charlie.purchase, + 'purchase', + FIXTURE.profiles.charlie, + FIXTURE.sessions.charlie1, + 5, + 5, + { path: '/checkout', revenue: 9900, browser: 'Firefox' } + ), + buildEvent( + now, + projectId, + FIXTURE.events.charlie.sessionEnd, + 'session_end', + FIXTURE.profiles.charlie, + FIXTURE.sessions.charlie1, + 5, + 0, + { duration: 300_000, browser: 'Firefox' } + ), + ], + format: 'JSONEachRow', + }); + + await client.insert({ + table: 'openpanel.sessions', + values: [ + buildSession( + now, + projectId, + FIXTURE.sessions.alice1, + FIXTURE.profiles.alice, + 2 + ), + buildSession( + now, + projectId, + FIXTURE.sessions.charlie1, + FIXTURE.profiles.charlie, + 5, + { + browser: 'Firefox', + entry_path: '/shop', + exit_path: '/checkout', + revenue: 9900, + duration: 300, + screen_view_count: 2, + event_count: 5, + } + ), + buildSession( + now, + projectId, + FIXTURE.sessions.charlie2, + FIXTURE.profiles.charlie, + 10, + { + browser: 'Firefox', + is_bounce: true, + entry_path: '/shop', + exit_path: '/shop', + duration: 15, + } + ), + ], + format: 'JSONEachRow', + }); +} + +async function deleteFixtures(client: ChClient, projectId: string) { + await Promise.all([ + client.command({ + query: `DELETE FROM openpanel.profiles WHERE project_id = '${projectId}'`, + }), + client.command({ + query: `DELETE FROM openpanel.events WHERE project_id = '${projectId}'`, + }), + client.command({ + query: `DELETE FROM openpanel.sessions WHERE project_id = '${projectId}'`, + }), + client.command({ + query: `ALTER TABLE openpanel.distinct_event_names_mv DELETE WHERE project_id = '${projectId}'`, + }), + ]); +} + +// --------------------------------------------------------------------------- +// Public API +// --------------------------------------------------------------------------- + +export async function setupPostgresFixtures( + projectId: string, + orgId: string +): Promise { + const db = getDb(); + try { + await db.organization.upsert({ + where: { id: orgId }, + create: { id: orgId, name: 'Test Org', timezone: 'UTC' }, + update: { timezone: 'UTC' }, + }); + await db.project.upsert({ + where: { id: projectId }, + create: { id: projectId, name: 'Test Project', organizationId: orgId }, + update: {}, + }); + } finally { + await db.$disconnect(); + } +} + +export async function teardownPostgresFixtures( + projectId: string, + orgId: string +): Promise { + const db = getDb(); + try { + await db.project.deleteMany({ where: { id: projectId } }); + await db.organization.deleteMany({ where: { id: orgId } }); + } finally { + await db.$disconnect(); + } +} + + + +export async function setupFixtures(projectId: string): Promise { + const client = getClient(); + await deleteFixtures(client, projectId); + await insertFixtures(client, projectId); + await client.close(); +} + +export async function teardownFixtures(projectId: string): Promise { + const client = getClient(); + await deleteFixtures(client, projectId); + await client.close(); +} diff --git a/test/global-setup.ts b/test/global-setup.ts new file mode 100644 index 000000000..595d7bf79 --- /dev/null +++ b/test/global-setup.ts @@ -0,0 +1,30 @@ +import { + setupFixtures, + setupPostgresFixtures, + teardownFixtures, + teardownPostgresFixtures, +} from './fixtures'; + +export { FIXTURE } from './fixtures'; +export const TEST_PROJECT_ID = 'integration-test'; +export const TEST_ORG_ID = 'integration-org'; + +// globalSetup runs in the parent process before vitest workers start, +// so vitest's `env` config is not applied — set defaults explicitly. +function setEnvDefaults() { + process.env.DATABASE_URL ??= + 'postgresql://postgres:postgres@localhost:5432/postgres?schema=public'; + process.env.CLICKHOUSE_URL ??= 'http://localhost:8123/openpanel'; +} + +export async function setup() { + setEnvDefaults(); + await setupPostgresFixtures(TEST_PROJECT_ID, TEST_ORG_ID); + await setupFixtures(TEST_PROJECT_ID); +} + +export async function teardown() { + setEnvDefaults(); + await teardownFixtures(TEST_PROJECT_ID); + await teardownPostgresFixtures(TEST_PROJECT_ID, TEST_ORG_ID); +} diff --git a/test/test-setup.ts b/test/test-setup.ts new file mode 100644 index 000000000..056557f80 --- /dev/null +++ b/test/test-setup.ts @@ -0,0 +1,27 @@ +/** + * Shared afterAll cleanup registered via setupFiles in vitest.shared.ts. + * + * Closes the ClickHouse keep-alive pool and disconnects Prisma after every + * test file so worker threads can exit cleanly. + * + * Uses dynamic imports + try-catch so this is safe in packages that mock + * these modules or don't use real connections. + */ +import { afterAll } from 'vitest'; + +afterAll(async () => { + await Promise.allSettled([ + (async () => { + const { originalCh } = await import( + '../packages/db/src/clickhouse/client' + ); + if (typeof originalCh?.close === 'function') { + await originalCh.close(); + } + })(), + (async () => { + const { db } = await import('../packages/db/src/prisma-client'); + await db.$disconnect(); + })(), + ]); +}); diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 000000000..bd99c6f76 --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + globalSetup: ['./test/global-setup.ts'], + }, +}); diff --git a/vitest.shared.ts b/vitest.shared.ts index 2551b83c0..c44c4dc6a 100644 --- a/vitest.shared.ts +++ b/vitest.shared.ts @@ -1,9 +1,15 @@ import * as path from 'node:path'; import { defineConfig } from 'vitest/config'; +// Absolute path to the root test-setup — used as setupFiles so every package +// gets connection-pool cleanup without needing a per-package file. +const rootTestSetup = (dirname: string) => path.resolve(dirname, '../../test/test-setup.ts'); + export const getSharedVitestConfig = ({ __dirname: dirname, -}: { __dirname: string }) => { +}: { + __dirname: string; +}) => { return defineConfig({ resolve: { alias: { @@ -11,9 +17,13 @@ export const getSharedVitestConfig = ({ }, }, test: { + setupFiles: [rootTestSetup(dirname)], env: { - // Not used, just so prisma is happy - DATABASE_URL: 'postgresql://u:p@127.0.0.1:5432/db', + // Always point at local Docker — never production, regardless of .env + DATABASE_URL: 'postgresql://postgres:postgres@localhost:5432/postgres?schema=public', + CLICKHOUSE_URL: 'http://localhost:8123/openpanel', + REDIS_URL: 'redis://localhost:6379', + SELF_HOSTED: 'true', }, include: ['**/*.test.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'], browser: {