diff --git a/README.md b/README.md index 5d4365c7..720e551b 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@ - **REST API** - Every tool available via API with API key auth. Interactive docs at `/api/docs` - **Single container** - One `docker run`, no Redis, no Postgres, no external services - **Multi-arch** - Runs on AMD64 and ARM64 (Intel, Apple Silicon, Raspberry Pi) -- **Your data stays yours** - No telemetry, no tracking, no external calls. Images never leave your machine +- **Privacy first** - Your images never leave your machine. ashim asks once whether you'd like to share anonymous product analytics (which tools are used, errors encountered — never file data). Change anytime in Settings, or set `ANALYTICS_ENABLED=false` to disable completely ## Quick Start diff --git a/apps/api/drizzle/0009_analytics_consent.sql b/apps/api/drizzle/0009_analytics_consent.sql new file mode 100644 index 00000000..6a2fc503 --- /dev/null +++ b/apps/api/drizzle/0009_analytics_consent.sql @@ -0,0 +1,5 @@ +ALTER TABLE users ADD COLUMN analytics_enabled integer; +--> statement-breakpoint +ALTER TABLE users ADD COLUMN analytics_consent_shown_at integer; +--> statement-breakpoint +ALTER TABLE users ADD COLUMN analytics_consent_remind_at integer; diff --git a/apps/api/drizzle/meta/_journal.json b/apps/api/drizzle/meta/_journal.json index 5ccea82f..c27590fe 100644 --- a/apps/api/drizzle/meta/_journal.json +++ b/apps/api/drizzle/meta/_journal.json @@ -64,6 +64,13 @@ "when": 1745366600000, "tag": "0008_api_key_expiration", "breakpoints": true + }, + { + "idx": 9, + "version": "6", + "when": 1776855591000, + "tag": "0009_analytics_consent", + "breakpoints": true } ] } diff --git a/apps/api/package.json b/apps/api/package.json index 9684430d..36375b39 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -12,15 +12,16 @@ "clean": "rm -rf dist" }, "dependencies": { + "@ashim/ai": "workspace:*", + "@ashim/image-engine": "workspace:*", + "@ashim/shared": "workspace:*", "@fastify/cors": "^11.0.0", "@fastify/multipart": "^9.0.0", "@fastify/rate-limit": "^10.2.0", "@fastify/static": "^8.1.0", "@neplex/vectorizer": "^0.0.5", "@scalar/fastify-api-reference": "^1.49.5", - "@ashim/ai": "workspace:*", - "@ashim/image-engine": "workspace:*", - "@ashim/shared": "workspace:*", + "@sentry/node": "^10.49.0", "archiver": "^7.0.1", "better-sqlite3": "^11.7.0", "dotenv": "^16.4.0", @@ -33,6 +34,7 @@ "p-queue": "^9.1.0", "pdfkit": "^0.18.0", "piscina": "^5.1.4", + "posthog-node": "^5.29.5", "potrace": "^2.1.8", "qrcode": "^1.5.4", "sharp": "^0.33.0", diff --git a/apps/api/src/db/schema.ts b/apps/api/src/db/schema.ts index 557a76a9..7583edb0 100644 --- a/apps/api/src/db/schema.ts +++ b/apps/api/src/db/schema.ts @@ -13,6 +13,9 @@ export const users = sqliteTable("users", { updatedAt: integer("updated_at", { mode: "timestamp" }) .notNull() .$defaultFn(() => new Date()), + analyticsEnabled: integer("analytics_enabled", { mode: "boolean" }), + analyticsConsentShownAt: integer("analytics_consent_shown_at", { mode: "timestamp" }), + analyticsConsentRemindAt: integer("analytics_consent_remind_at", { mode: "timestamp" }), }); export const teams = sqliteTable("teams", { diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index 4d3d1b57..73491883 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -1,11 +1,14 @@ +import { randomUUID } from "node:crypto"; import { isGpuAvailable } from "@ashim/ai"; import { APP_VERSION } from "@ashim/shared"; import cors from "@fastify/cors"; import rateLimit from "@fastify/rate-limit"; +import { eq } from "drizzle-orm"; import Fastify from "fastify"; import { env } from "./config.js"; import { db, schema } from "./db/index.js"; import { runMigrations } from "./db/migrate.js"; +import { initAnalytics, shutdownAnalytics } from "./lib/analytics.js"; import { startCleanupCron } from "./lib/cleanup.js"; import { ensureAiDirs, recoverInterruptedInstalls } from "./lib/feature-status.js"; import { shutdownWorkerPool } from "./lib/worker-pool.js"; @@ -13,6 +16,7 @@ import { requirePermission } from "./permissions.js"; import { authMiddleware, authRoutes, ensureDefaultAdmin } from "./plugins/auth.js"; import { registerStatic } from "./plugins/static.js"; import { registerUpload } from "./plugins/upload.js"; +import { analyticsRoutes } from "./routes/analytics.js"; import { apiKeyRoutes } from "./routes/api-keys.js"; import { auditLogRoutes } from "./routes/audit-log.js"; import { registerBatchRoutes } from "./routes/batch.js"; @@ -35,6 +39,20 @@ console.log("Database initialized"); // Create default admin user if no users exist await ensureDefaultAdmin(); +function ensureInstanceId() { + const existing = db + .select() + .from(schema.settings) + .where(eq(schema.settings.key, "instance_id")) + .get(); + if (!existing) { + db.insert(schema.settings).values({ key: "instance_id", value: randomUUID() }).run(); + } +} + +ensureInstanceId(); +initAnalytics(); + // Mark any jobs left in processing/queued from a previous unclean shutdown recoverStaleJobs(); @@ -55,6 +73,12 @@ app.setErrorHandler((error: Error & { statusCode?: number }, request, reply) => { err: error, url: request.url, method: request.method }, "Unhandled request error", ); + try { + const Sentry = require("@sentry/node") as typeof import("@sentry/node"); + Sentry.captureException(error); + } catch { + // Sentry not available + } const isProduction = process.env.NODE_ENV === "production"; reply.status(statusCode).send({ error: statusCode >= 500 ? "Internal server error" : error.message, @@ -128,6 +152,9 @@ await apiKeyRoutes(app); // Settings routes await settingsRoutes(app); +// Analytics config and consent routes +await analyticsRoutes(app); + // Feature management routes (AI feature bundle install/uninstall) await registerFeatureRoutes(app); @@ -258,6 +285,13 @@ async function shutdown(signal: string) { // AI package may not be available } + try { + await shutdownAnalytics(); + console.log("Analytics flushed"); + } catch { + // analytics shutdown is best-effort + } + try { const { sqlite: sqliteConn } = await import("./db/index.js"); sqliteConn.close(); diff --git a/apps/api/src/lib/analytics.ts b/apps/api/src/lib/analytics.ts new file mode 100644 index 00000000..971f19d6 --- /dev/null +++ b/apps/api/src/lib/analytics.ts @@ -0,0 +1,131 @@ +import { eq } from "drizzle-orm"; +import type { FastifyRequest } from "fastify"; +import { env } from "../config.js"; +import { db, schema } from "../db/index.js"; +import { getAuthUser } from "../plugins/auth.js"; + +const FILE_EXT_PATTERN = + /\.(jpe?g|png|pdf|webp|gif|tiff?|bmp|svg|he[ic]f?|avif|raw|cr2|nef|arw|dng|psd|tga|exr|hdr)\b/gi; +const FILE_PATH_PATTERN = /\/(tmp\/workspace|data\/files|data\/ai)\//g; + +let posthogClient: import("posthog-node").PostHog | null = null; + +export function initAnalytics(): void { + if (!env.ANALYTICS_ENABLED || !env.POSTHOG_API_KEY) return; + + try { + const { PostHog } = require("posthog-node") as typeof import("posthog-node"); + posthogClient = new PostHog(env.POSTHOG_API_KEY, { + host: env.POSTHOG_HOST, + flushAt: 20, + flushInterval: 30000, + }); + } catch { + // posthog-node not available — analytics disabled + } + + if (env.SENTRY_DSN) { + try { + const Sentry = require("@sentry/node") as typeof import("@sentry/node"); + Sentry.init({ + dsn: env.SENTRY_DSN, + sendDefaultPii: false, + beforeSend(event) { + if (event.user) { + delete event.user.email; + delete event.user.username; + } + if (event.exception?.values) { + for (const ex of event.exception.values) { + if (ex.value) { + ex.value = ex.value + .replace(FILE_EXT_PATTERN, ".[REDACTED]") + .replace(FILE_PATH_PATTERN, "/[REDACTED]/"); + } + if (ex.stacktrace?.frames) { + for (const frame of ex.stacktrace.frames) { + if (frame.filename) { + frame.filename = frame.filename + .replace(FILE_EXT_PATTERN, ".[REDACTED]") + .replace(FILE_PATH_PATTERN, "/[REDACTED]/"); + } + } + } + } + } + return event; + }, + beforeBreadcrumb(breadcrumb) { + if (breadcrumb.message) { + breadcrumb.message = breadcrumb.message + .replace(FILE_EXT_PATTERN, ".[REDACTED]") + .replace(FILE_PATH_PATTERN, "/[REDACTED]/"); + } + return breadcrumb; + }, + }); + } catch { + // @sentry/node not available + } + } +} + +export async function shutdownAnalytics(): Promise { + if (posthogClient) { + await posthogClient.shutdown(); + posthogClient = null; + } + + try { + const Sentry = require("@sentry/node") as typeof import("@sentry/node"); + await Sentry.close(2000); + } catch { + // @sentry/node not available + } +} + +function getInstanceId(): string { + const row = db.select().from(schema.settings).where(eq(schema.settings.key, "instance_id")).get(); + return row?.value ?? "unknown"; +} + +function isUserOptedIn(userId: string): boolean { + if (!env.ANALYTICS_ENABLED) return false; + if (userId === "anonymous") return false; + const user = db.select().from(schema.users).where(eq(schema.users.id, userId)).get(); + return user?.analyticsEnabled === true; +} + +function isRequestOptedIn(request: FastifyRequest): boolean { + if (!env.ANALYTICS_ENABLED) return false; + const user = getAuthUser(request); + if (!user) return false; + if (user.id === "anonymous") { + const header = request.headers["x-analytics-consent"]; + return header === "true"; + } + return isUserOptedIn(user.id); +} + +function shouldSample(): boolean { + if (env.ANALYTICS_SAMPLE_RATE >= 1.0) return true; + if (env.ANALYTICS_SAMPLE_RATE <= 0.0) return false; + return Math.random() < env.ANALYTICS_SAMPLE_RATE; +} + +export function trackEvent( + request: FastifyRequest, + event: string, + properties: Record, +): void { + if (!posthogClient || !isRequestOptedIn(request) || !shouldSample()) return; + try { + posthogClient.capture({ + distinctId: getInstanceId(), + event, + properties, + }); + } catch { + // never throw from analytics + } +} diff --git a/apps/api/src/lib/env.ts b/apps/api/src/lib/env.ts index fc032eb2..67eba88f 100644 --- a/apps/api/src/lib/env.ts +++ b/apps/api/src/lib/env.ts @@ -44,6 +44,18 @@ const envSchema = z.object({ .enum(["true", "false"]) .default("true") .transform((v) => v === "true"), + ANALYTICS_ENABLED: z + .enum(["true", "false"]) + .default("true") + .transform((v) => v === "true"), + ANALYTICS_SAMPLE_RATE: z.coerce.number().min(0).max(1).default(1.0), + POSTHOG_API_KEY: z.string().default("phc_CVHjGivwWVzh76M5EjijTwP5LpiqWie3EbCzXU7w2Smy"), + POSTHOG_HOST: z.string().default("https://us.i.posthog.com"), + SENTRY_DSN: z + .string() + .default( + "https://2fd53fc3b3fdc59d02cac044a4f90b71@o4511263372738560.ingest.us.sentry.io/4511264620085248", + ), }); export type Env = z.infer; diff --git a/apps/api/src/plugins/auth.ts b/apps/api/src/plugins/auth.ts index 329cf575..1bebb587 100644 --- a/apps/api/src/plugins/auth.ts +++ b/apps/api/src/plugins/auth.ts @@ -208,6 +208,9 @@ export async function authRoutes(app: FastifyInstance): Promise { mustChangePassword: env.SKIP_MUST_CHANGE_PASSWORD ? false : user.mustChangePassword, permissions: getPermissions(user.role), teamName: teamRow?.name ?? user.team, + analyticsEnabled: user.analyticsEnabled ?? null, + analyticsConsentShownAt: user.analyticsConsentShownAt?.getTime() ?? null, + analyticsConsentRemindAt: user.analyticsConsentRemindAt?.getTime() ?? null, }, expiresAt: expiresAt.toISOString(), }); @@ -255,6 +258,9 @@ export async function authRoutes(app: FastifyInstance): Promise { role: user.role, mustChangePassword: env.SKIP_MUST_CHANGE_PASSWORD ? false : user.mustChangePassword, permissions: getPermissions(user.role), + analyticsEnabled: user.analyticsEnabled ?? null, + analyticsConsentShownAt: user.analyticsConsentShownAt?.getTime() ?? null, + analyticsConsentRemindAt: user.analyticsConsentRemindAt?.getTime() ?? null, }, expiresAt: session.expiresAt.toISOString(), }); diff --git a/apps/api/src/routes/analytics.ts b/apps/api/src/routes/analytics.ts new file mode 100644 index 00000000..dbff6dfa --- /dev/null +++ b/apps/api/src/routes/analytics.ts @@ -0,0 +1,73 @@ +import { eq } from "drizzle-orm"; +import type { FastifyInstance } from "fastify"; +import { env } from "../config.js"; +import { db, schema } from "../db/index.js"; +import { requireAuth } from "../plugins/auth.js"; + +export async function analyticsRoutes(app: FastifyInstance): Promise { + app.get("/api/v1/config/analytics", async () => { + if (!env.ANALYTICS_ENABLED) { + return { + enabled: false, + posthogApiKey: "", + posthogHost: "", + sentryDsn: "", + sampleRate: 0, + instanceId: "", + }; + } + + const row = db + .select() + .from(schema.settings) + .where(eq(schema.settings.key, "instance_id")) + .get(); + + return { + enabled: true, + posthogApiKey: env.POSTHOG_API_KEY, + posthogHost: env.POSTHOG_HOST, + sentryDsn: env.SENTRY_DSN, + sampleRate: env.ANALYTICS_SAMPLE_RATE, + instanceId: row?.value ?? "", + }; + }); + + app.put("/api/v1/user/analytics", async (request, reply) => { + const user = requireAuth(request, reply); + if (!user) return; + + const body = request.body as { + enabled?: boolean; + remindLater?: boolean; + } | null; + + const now = new Date(); + + if (body?.remindLater) { + const remindAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000); + db.update(schema.users) + .set({ + analyticsEnabled: null, + analyticsConsentShownAt: now, + analyticsConsentRemindAt: remindAt, + updatedAt: now, + }) + .where(eq(schema.users.id, user.id)) + .run(); + return reply.send({ ok: true, analyticsEnabled: null }); + } + + const enabled = body?.enabled === true; + db.update(schema.users) + .set({ + analyticsEnabled: enabled, + analyticsConsentShownAt: now, + analyticsConsentRemindAt: null, + updatedAt: now, + }) + .where(eq(schema.users.id, user.id)) + .run(); + return reply.send({ ok: true, analyticsEnabled: enabled }); + }); +} diff --git a/apps/api/src/routes/features.ts b/apps/api/src/routes/features.ts index 439cc747..1dcedf2a 100644 --- a/apps/api/src/routes/features.ts +++ b/apps/api/src/routes/features.ts @@ -12,8 +12,9 @@ import crypto from "node:crypto"; import { existsSync, readdirSync, readFileSync, statSync, unlinkSync } from "node:fs"; import { join } from "node:path"; import { shutdownDispatcher } from "@ashim/ai"; -import { FEATURE_BUNDLES } from "@ashim/shared"; +import { ANALYTICS_EVENTS, FEATURE_BUNDLES } from "@ashim/shared"; import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify"; +import { trackEvent } from "../lib/analytics.js"; import { acquireInstallLock, getAiDir, @@ -133,6 +134,9 @@ export async function registerFeatureRoutes(app: FastifyInstance): Promise const manifestPath = getManifestPath(); const modelsDir = getModelsDir(); + const installStartTime = Date.now(); + const reqRef = request; + const child = spawn(pythonPath, [scriptPath, bundleId, manifestPath, modelsDir], { stdio: ["ignore", "pipe", "pipe"], env: { @@ -192,6 +196,11 @@ export async function registerFeatureRoutes(app: FastifyInstance): Promise shutdownDispatcher(); setInstallProgress(null, null, null); updateSingleFileProgress({ jobId, phase: "complete", percent: 100, stage: "Complete" }); + trackEvent(reqRef, ANALYTICS_EVENTS.AI_BUNDLE_ACTION, { + bundle_id: bundleId, + action: "installed", + duration_ms: Date.now() - installStartTime, + }); } else { const errorDetail = lastStderrLines.filter((l) => !l.startsWith("{")).join("\n") || stdoutBuffer.trim(); @@ -264,6 +273,12 @@ export async function registerFeatureRoutes(app: FastifyInstance): Promise markUninstalled(bundleId); shutdownDispatcher(); + trackEvent(request, ANALYTICS_EVENTS.AI_BUNDLE_ACTION, { + bundle_id: bundleId, + action: "uninstalled", + duration_ms: 0, + }); + return reply.send({ ok: true }); }, ); diff --git a/apps/api/src/routes/pipeline.ts b/apps/api/src/routes/pipeline.ts index e4e895e9..beb6e788 100644 --- a/apps/api/src/routes/pipeline.ts +++ b/apps/api/src/routes/pipeline.ts @@ -9,7 +9,7 @@ import { randomUUID } from "node:crypto"; import { writeFile } from "node:fs/promises"; import { join } from "node:path"; -import { getBundleForTool, TOOL_BUNDLE_MAP } from "@ashim/shared"; +import { ANALYTICS_EVENTS, getBundleForTool, TOOL_BUNDLE_MAP } from "@ashim/shared"; import archiver from "archiver"; import { eq } from "drizzle-orm"; import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify"; @@ -17,6 +17,7 @@ import PQueue from "p-queue"; import { z } from "zod"; import { env } from "../config.js"; import { db, schema } from "../db/index.js"; +import { trackEvent } from "../lib/analytics.js"; import { autoOrient } from "../lib/auto-orient.js"; import { resolveConcurrency } from "../lib/env.js"; import { formatZodErrors } from "../lib/errors.js"; @@ -204,6 +205,7 @@ export async function registerPipelineRoutes(app: FastifyInstance): Promise = []; @@ -244,6 +246,13 @@ export async function registerPipelineRoutes(app: FastifyInstance): Promise s.toolId), + is_batch: false, + duration_ms: Date.now() - startTime, + status: "failed", + }); return reply.status(422).send({ error: message, completedSteps: stepResults, @@ -260,6 +269,14 @@ export async function registerPipelineRoutes(app: FastifyInstance): Promise s.toolId), + is_batch: false, + duration_ms: Date.now() - startTime, + status: "completed", + }); + return reply.send({ jobId, downloadUrl: `/api/v1/download/${jobId}/${encodeURIComponent(currentFilename)}`, @@ -508,6 +525,7 @@ export async function registerPipelineRoutes(app: FastifyInstance): Promise s.toolId), + is_batch: true, + file_count: files.length, + duration_ms: Date.now() - batchStartTime, + status: "failed", + }); return reply.status(422).send({ error: "All files failed processing", errors: progress.errors, }); } + trackEvent(request, ANALYTICS_EVENTS.PIPELINE_EXECUTED, { + step_count: pipeline.steps.length, + tool_ids: pipeline.steps.map((s: { toolId: string }) => s.toolId), + is_batch: true, + file_count: files.length, + duration_ms: Date.now() - batchStartTime, + status: "completed", + }); + // ── Stream ZIP response ────────────────────────────────────────── reply.hijack(); reply.raw.writeHead(200, { diff --git a/apps/api/src/routes/tool-factory.ts b/apps/api/src/routes/tool-factory.ts index 45da7a95..78830bd8 100644 --- a/apps/api/src/routes/tool-factory.ts +++ b/apps/api/src/routes/tool-factory.ts @@ -1,12 +1,13 @@ import { randomUUID } from "node:crypto"; import { writeFile } from "node:fs/promises"; import { extname, join } from "node:path"; -import { getBundleForTool, TOOL_BUNDLE_MAP } from "@ashim/shared"; +import { ANALYTICS_EVENTS, getBundleForTool, TOOL_BUNDLE_MAP, TOOLS } from "@ashim/shared"; import { eq } from "drizzle-orm"; import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify"; import sharp from "sharp"; import type { z } from "zod"; import { db, schema } from "../db/index.js"; +import { trackEvent } from "../lib/analytics.js"; import { autoOrient } from "../lib/auto-orient.js"; import { formatZodErrors } from "../lib/errors.js"; import { isToolInstalled } from "../lib/feature-status.js"; @@ -242,6 +243,7 @@ export function createToolRoute(app: FastifyInstance, config: ToolRouteConfig } // Process the image (worker thread or main thread) + const startTime = Date.now(); try { let result: { buffer: Buffer; filename: string; contentType: string }; @@ -381,6 +383,14 @@ export function createToolRoute(app: FastifyInstance, config: ToolRouteConfig } } + trackEvent(request, ANALYTICS_EVENTS.TOOL_USED, { + tool_id: config.toolId, + status: "completed", + duration_ms: Date.now() - startTime, + category: TOOLS.find((t) => t.id === config.toolId)?.category ?? "unknown", + is_ai_tool: getBundleForTool(config.toolId) !== null, + }); + return reply.send({ jobId, downloadUrl: `/api/v1/download/${jobId}/${encodeURIComponent(result.filename)}`, @@ -393,6 +403,13 @@ export function createToolRoute(app: FastifyInstance, config: ToolRouteConfig // Catch Sharp / processing errors and return a clean API error const message = err instanceof Error ? err.message : "Image processing failed"; request.log.error({ err, toolId: config.toolId }, "Tool processing failed"); + trackEvent(request, ANALYTICS_EVENTS.TOOL_USED, { + tool_id: config.toolId, + status: "failed", + duration_ms: Date.now() - startTime, + category: TOOLS.find((t) => t.id === config.toolId)?.category ?? "unknown", + is_ai_tool: getBundleForTool(config.toolId) !== null, + }); return reply.status(422).send({ error: "Processing failed", details: message, diff --git a/apps/web/package.json b/apps/web/package.json index 82f25fff..5eb0e435 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -16,12 +16,14 @@ "@dnd-kit/core": "^6.3.1", "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", + "@sentry/react": "^10.49.0", "@use-gesture/react": "^10.3.1", "clsx": "^2.1.0", "fflate": "^0.8.2", "jszip": "^3.10.1", "leaflet": "^1.9.4", "lucide-react": "^0.469.0", + "posthog-js": "^1.370.0", "qr-code-styling": "^1.9.2", "react": "^19.0.0", "react-dom": "^19.0.0", diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 397ff5ff..e295a2de 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -1,9 +1,12 @@ -import { Component, type ErrorInfo, lazy, type ReactNode, Suspense } from "react"; +import { APP_VERSION } from "@ashim/shared"; +import { Component, type ErrorInfo, lazy, type ReactNode, Suspense, useEffect } from "react"; import { BrowserRouter, Navigate, Route, Routes, useLocation } from "react-router-dom"; import { Toaster } from "sonner"; import { ConnectionMonitor } from "./components/common/connection-monitor"; import { KeyboardShortcutProvider } from "./components/common/keyboard-shortcut-provider"; import { useAuth } from "./hooks/use-auth"; +import { identify, initAnalytics } from "./lib/analytics"; +import { useAnalyticsStore } from "./stores/analytics-store"; // Lazy-load all pages so each page's JS (and its icons/deps) is only // downloaded when the user navigates there, shrinking the main bundle. @@ -22,6 +25,9 @@ const LoginPage = lazy(() => import("./pages/login-page").then((m) => ({ default const PrivacyPolicyPage = lazy(() => import("./pages/privacy-policy-page").then((m) => ({ default: m.PrivacyPolicyPage })), ); +const AnalyticsConsentPage = lazy(() => + import("./pages/analytics-consent-page").then((m) => ({ default: m.AnalyticsConsentPage })), +); const ToolPage = lazy(() => import("./pages/tool-page").then((m) => ({ default: m.ToolPage }))); class ErrorBoundary extends Component< @@ -69,14 +75,43 @@ class ErrorBoundary extends Component< } function AuthGuard({ children }: { children: React.ReactNode }) { - const { loading, authEnabled, isAuthenticated, mustChangePassword } = useAuth(); + const { + loading, + authEnabled, + isAuthenticated, + mustChangePassword, + analyticsEnabled, + analyticsConsentShownAt, + } = useAuth(); + const storeConsent = useAnalyticsStore((s) => s.consent); + const setStoreConsent = useAnalyticsStore((s) => s.setConsent); const location = useLocation(); + // Hydrate the analytics store from session data on initial load. + // Only hydrate if the store is still in its initial state (user hasn't taken + // an explicit action like accepting/declining on the consent page). + useEffect(() => { + if ( + !loading && + analyticsEnabled !== undefined && + storeConsent.analyticsConsentShownAt === null && + storeConsent.analyticsEnabled === null + ) { + setStoreConsent({ + analyticsEnabled: analyticsEnabled ?? null, + analyticsConsentShownAt: analyticsConsentShownAt ?? null, + analyticsConsentRemindAt: null, + }); + } + // eslint-disable-next-line -- only hydrate on session load, not on store changes + }, [loading, analyticsEnabled, analyticsConsentShownAt, setStoreConsent]); + // Don't guard the login or change-password pages if ( location.pathname === "/login" || location.pathname === "/change-password" || - location.pathname === "/privacy" + location.pathname === "/privacy" || + location.pathname === "/analytics-consent" ) { return <>{children}; } @@ -101,6 +136,15 @@ function AuthGuard({ children }: { children: React.ReactNode }) { return ; } + // Check both session state (useAuth) and real-time store state. + // After the consent page calls acceptAnalytics(), the store updates immediately + // but useAuth won't re-fetch until the next session check. + const effectiveEnabled = storeConsent.analyticsEnabled ?? analyticsEnabled; + const effectiveShownAt = storeConsent.analyticsConsentShownAt ?? analyticsConsentShownAt; + if (authEnabled && effectiveEnabled === null && effectiveShownAt === null) { + return ; + } + return <>{children}; } @@ -114,6 +158,34 @@ function PageLoader() { } export function App() { + const analyticsConfig = useAnalyticsStore((s) => s.config); + const analyticsConfigLoaded = useAnalyticsStore((s) => s.configLoaded); + const fetchAnalyticsConfig = useAnalyticsStore((s) => s.fetchConfig); + const analyticsConsent = useAnalyticsStore((s) => s.consent); + + useEffect(() => { + fetchAnalyticsConfig(); + }, [fetchAnalyticsConfig]); + + useEffect(() => { + if (analyticsConfigLoaded && analyticsConfig?.enabled) { + initAnalytics(analyticsConfig); + } + }, [analyticsConfigLoaded, analyticsConfig]); + + useEffect(() => { + if ( + !analyticsConfigLoaded || + !analyticsConfig?.enabled || + analyticsConsent.analyticsEnabled !== true + ) + return; + identify(analyticsConfig.instanceId, { + $set: { version: APP_VERSION }, + $set_once: { instance_id: analyticsConfig.instanceId }, + }); + }, [analyticsConfigLoaded, analyticsConfig, analyticsConsent.analyticsEnabled]); + return ( @@ -137,6 +209,7 @@ export function App() { } /> } /> } /> + } /> } /> } /> diff --git a/apps/web/src/components/settings/settings-dialog.tsx b/apps/web/src/components/settings/settings-dialog.tsx index 87da6236..4eb17fde 100644 --- a/apps/web/src/components/settings/settings-dialog.tsx +++ b/apps/web/src/components/settings/settings-dialog.tsx @@ -30,6 +30,7 @@ import { Fragment, useCallback, useEffect, useMemo, useState } from "react"; import { useAuth } from "@/hooks/use-auth"; import { apiDelete, apiGet, apiPost, apiPut, clearToken, formatHeaders } from "@/lib/api"; import { cn, copyToClipboard } from "@/lib/utils"; +import { useAnalyticsStore } from "@/stores/analytics-store"; import { useSettingsStore } from "@/stores/settings-store"; import { GemLogo } from "../common/gem-logo"; import { AiFeaturesSection } from "./ai-features-section"; @@ -50,6 +51,7 @@ type Section = | "api-keys" | "ai-features" | "tools" + | "analytics" | "about"; interface NavItem { @@ -70,6 +72,7 @@ const NAV_ITEMS: NavItem[] = [ { id: "api-keys", label: "API Keys", icon: Key }, { id: "ai-features", label: "AI Features", icon: Sparkles, requiredPermission: "settings:write" }, { id: "tools", label: "Tools", icon: Wrench }, + { id: "analytics", label: "Product Analytics", icon: Eye }, { id: "about", label: "About", icon: Info }, ]; @@ -147,6 +150,7 @@ export function SettingsDialog({ open, onClose }: SettingsDialogProps) { {section === "api-keys" && } {section === "ai-features" && } {section === "tools" && } + {section === "analytics" && } {section === "about" && } @@ -2428,6 +2432,69 @@ function ToolsSection() { ); } +/* ────────────────────── Analytics ────────────────────── */ + +function AnalyticsSection() { + const { consent, config, configLoaded, fetchConfig, toggleAnalytics } = useAnalyticsStore(); + + useEffect(() => { + fetchConfig(); + }, [fetchConfig]); + + if (!configLoaded) return null; + + const disabled = !config?.enabled; + const enabled = consent.analyticsEnabled === true; + + return ( +
+
+

Product Analytics

+

+ Share anonymous usage data to help improve ashim. +

+

Your images never leave your machine.

+
+ + {disabled ? ( +

+ Product analytics has been disabled by the server administrator. +

+ ) : ( +
+ + {enabled ? "Analytics enabled" : "Analytics disabled"} + + +
+ )} + + + Learn more + +
+ ); +} + /* ────────────────────── About ────────────────────── */ function AboutSection() { diff --git a/apps/web/src/hooks/use-auth.ts b/apps/web/src/hooks/use-auth.ts index f69e78e3..89e1b5d8 100644 --- a/apps/web/src/hooks/use-auth.ts +++ b/apps/web/src/hooks/use-auth.ts @@ -9,6 +9,9 @@ interface AuthState { mustChangePassword: boolean; role: string | null; permissions: string[]; + analyticsEnabled: boolean | null; + analyticsConsentShownAt: number | null; + analyticsConsentRemindAt: number | null; } const USER_PERMISSIONS = [ @@ -27,6 +30,9 @@ export function useAuth() { mustChangePassword: false, role: null, permissions: [], + analyticsEnabled: null, + analyticsConsentShownAt: null, + analyticsConsentRemindAt: null, }); useEffect(() => { @@ -46,6 +52,9 @@ export function useAuth() { mustChangePassword: false, role: "user", permissions: USER_PERMISSIONS, + analyticsEnabled: null, + analyticsConsentShownAt: null, + analyticsConsentRemindAt: null, }); return; } @@ -60,6 +69,9 @@ export function useAuth() { mustChangePassword: false, role: null, permissions: [], + analyticsEnabled: null, + analyticsConsentShownAt: null, + analyticsConsentRemindAt: null, }); return; } @@ -79,6 +91,9 @@ export function useAuth() { mustChangePassword: mustChange, role: session.user?.role ?? null, permissions: session.user?.permissions ?? [], + analyticsEnabled: session.user?.analyticsEnabled ?? null, + analyticsConsentShownAt: session.user?.analyticsConsentShownAt ?? null, + analyticsConsentRemindAt: session.user?.analyticsConsentRemindAt ?? null, }); } else { localStorage.removeItem("ashim-token"); @@ -90,6 +105,9 @@ export function useAuth() { mustChangePassword: false, role: null, permissions: [], + analyticsEnabled: null, + analyticsConsentShownAt: null, + analyticsConsentRemindAt: null, }); } } catch { diff --git a/apps/web/src/lib/analytics.ts b/apps/web/src/lib/analytics.ts new file mode 100644 index 00000000..04984148 --- /dev/null +++ b/apps/web/src/lib/analytics.ts @@ -0,0 +1,113 @@ +import type { AnalyticsConfig } from "@ashim/shared"; +import * as Sentry from "@sentry/react"; +import posthogJs from "posthog-js"; + +let posthog: import("posthog-js").PostHog | null = null; +let initialized = false; +let consentGranted = false; + +const FILE_EXT_PATTERN = + /\.(jpe?g|png|pdf|webp|gif|tiff?|bmp|svg|he[ic]f?|avif|raw|cr2|nef|arw|dng|psd|tga|exr|hdr)\b/gi; +const FILE_PATH_PATTERN = /\/(tmp\/workspace|data\/files|data\/ai|Users|home)\//g; + +function scrubString(str: string): string { + return str.replace(FILE_EXT_PATTERN, ".[REDACTED]").replace(FILE_PATH_PATTERN, "/[REDACTED]/"); +} + +export function initAnalytics(config: AnalyticsConfig): void { + if (initialized || !config.enabled) return; + initialized = true; + + try { + posthog = + posthogJs.init(config.posthogApiKey, { + api_host: config.posthogHost, + autocapture: false, + capture_pageview: true, + disable_session_recording: true, + session_recording: { + captureCanvas: { recordCanvas: false }, + maskAllInputs: true, + maskTextSelector: ".file-name, .file-path, [data-file-name]", + blockSelector: "[data-user-content]", + }, + ip: false, + persistence: "localStorage", + }) ?? null; + } catch { + // SDK blocked or unavailable — use null provider + } + + try { + if (config.sentryDsn) { + Sentry.init({ + dsn: config.sentryDsn, + sendDefaultPii: false, + beforeSend(event) { + if (!consentGranted) return null; + startErrorReplay(); + if (event.user) { + delete event.user.email; + delete event.user.username; + } + if (event.exception?.values) { + for (const ex of event.exception.values) { + if (ex.value) ex.value = scrubString(ex.value); + if (ex.stacktrace?.frames) { + for (const frame of ex.stacktrace.frames) { + if (frame.filename) frame.filename = scrubString(frame.filename); + if (frame.abs_path) frame.abs_path = scrubString(frame.abs_path); + } + } + } + } + return event; + }, + beforeBreadcrumb(breadcrumb) { + if (!consentGranted) return null; + if (breadcrumb.category === "ui.click") return null; + if (breadcrumb.category === "fetch" && breadcrumb.data?.url) { + if (FILE_EXT_PATTERN.test(breadcrumb.data.url as string)) return null; + } + if (breadcrumb.message) { + breadcrumb.message = scrubString(breadcrumb.message); + } + return breadcrumb; + }, + }); + } + } catch { + // Sentry blocked or unavailable + } +} + +export function setAnalyticsConsent(enabled: boolean): void { + consentGranted = enabled; +} + +export function identify(instanceId: string, properties: Record): void { + if (!posthog || !consentGranted) return; + try { + posthog.identify(instanceId, properties); + } catch { + // never throw + } +} + +export function track(event: string, properties?: Record): void { + if (!posthog || !consentGranted) return; + try { + posthog.capture(event, properties); + } catch { + // never throw + } +} + +export function startErrorReplay(): void { + if (!posthog || !consentGranted) return; + try { + posthog.startSessionRecording(); + } catch { + // never throw + } +} diff --git a/apps/web/src/lib/api.ts b/apps/web/src/lib/api.ts index 6d9dd242..1b5d0789 100644 --- a/apps/web/src/lib/api.ts +++ b/apps/web/src/lib/api.ts @@ -60,6 +60,16 @@ export function formatHeaders(init?: HeadersInit): Headers { if (token) { headers.set("Authorization", `Bearer ${token}`); } + if (!token) { + try { + const consent = localStorage.getItem("ashim-analytics-consent"); + if (consent === "true" || consent === "false") { + headers.set("X-Analytics-Consent", consent); + } + } catch { + // localStorage unavailable + } + } return headers; } diff --git a/apps/web/src/pages/analytics-consent-page.tsx b/apps/web/src/pages/analytics-consent-page.tsx new file mode 100644 index 00000000..0d3bc7b9 --- /dev/null +++ b/apps/web/src/pages/analytics-consent-page.tsx @@ -0,0 +1,107 @@ +import { en } from "@ashim/shared"; +import { Shield } from "lucide-react"; +import { useEffect } from "react"; +import { useNavigate } from "react-router-dom"; +import { useAnalyticsStore } from "@/stores/analytics-store"; + +const t = en.analytics; + +export function AnalyticsConsentPage() { + const navigate = useNavigate(); + const { config, configLoaded, fetchConfig, acceptAnalytics, remindLater } = useAnalyticsStore(); + + useEffect(() => { + fetchConfig(); + }, [fetchConfig]); + + useEffect(() => { + if (configLoaded && !config?.enabled) { + navigate("/", { replace: true }); + } + }, [configLoaded, config, navigate]); + + if (!configLoaded) { + return ( +
+
+
+ ); + } + + const handleAccept = async () => { + await acceptAnalytics(); + window.location.href = "/"; + }; + + const handleDecline = async () => { + await remindLater(); + window.location.href = "/"; + }; + + return ( +
+
+
+
+ +
+
+ +
+

{t.consentTitle}

+

{t.consentDescription}

+
+ +

{t.consentPrivacy}

+ +
+
+

{t.whatShared}

+
    + {t.whatSharedItems.map((item) => ( +
  • + · + {item} +
  • + ))} +
+
+
+

{t.whatNever}

+
    + {t.whatNeverItems.map((item) => ( +
  • + · + {item} +
  • + ))} +
+
+
+ +

+ {t.consentProviders} +
+ {t.consentChangeable} +

+ +
+ + +
+
+
+ ); +} diff --git a/apps/web/src/pages/fullscreen-grid-page.tsx b/apps/web/src/pages/fullscreen-grid-page.tsx index 3eacafa8..11090a76 100644 --- a/apps/web/src/pages/fullscreen-grid-page.tsx +++ b/apps/web/src/pages/fullscreen-grid-page.tsx @@ -1,9 +1,10 @@ import type { CategoryInfo, Tool } from "@ashim/shared"; -import { CATEGORIES, TOOLS } from "@ashim/shared"; +import { ANALYTICS_EVENTS, CATEGORIES, TOOLS } from "@ashim/shared"; import { Eye, EyeOff, FileImage, LayoutGrid, List, Search } from "lucide-react"; import { useEffect, useMemo, useState } from "react"; import { Link, useNavigate } from "react-router-dom"; import { GemLogo } from "@/components/common/gem-logo"; +import { track } from "@/lib/analytics"; import { apiGet } from "@/lib/api"; import { ICON_MAP } from "@/lib/icon-map"; import { cn } from "@/lib/utils"; @@ -51,6 +52,17 @@ export function FullscreenGridPage() { ); }, [search, visibleTools]); + useEffect(() => { + if (!search) return; + const timer = setTimeout(() => { + track(ANALYTICS_EVENTS.SEARCH, { + query: search, + results_count: filteredTools.length, + }); + }, 1000); + return () => clearTimeout(timer); + }, [search, filteredTools.length]); + const groupedTools = useMemo(() => { const groups = new Map(); for (const tool of filteredTools) { diff --git a/apps/web/src/pages/privacy-policy-page.tsx b/apps/web/src/pages/privacy-policy-page.tsx index 2fd26ff4..06e315bc 100644 --- a/apps/web/src/pages/privacy-policy-page.tsx +++ b/apps/web/src/pages/privacy-policy-page.tsx @@ -14,7 +14,7 @@ export function PrivacyPolicyPage() {

Privacy Policy

-

Last updated: March 29, 2026

+

Last updated: April 22, 2026

@@ -37,11 +37,52 @@ export function PrivacyPolicyPage() {
-

No Tracking or Analytics

+

Product Analytics

- ashim does not include any telemetry, analytics, or tracking. No data is collected - about your usage patterns, and no information is sent to ashim developers or any third - party. There are no cookies used for tracking purposes. + ashim includes optional, anonymous product analytics. When you choose to participate, + the following is collected: +

+
    +
  • Which tools you use (e.g., "crop tool used")
  • +
  • Error reports without file data
  • +
  • App version and performance metrics
  • +
+

What is never collected:

+
    +
  • Your images, PDFs, and files
  • +
  • File names and contents
  • +
  • Any personal information or IP addresses
  • +
+

+ Analytics data is sent to{" "} + + PostHog + {" "} + (usage analytics) and{" "} + + Sentry + {" "} + (error tracking) — both open-source projects. +

+
+ +
+

Your Choice

+

+ Each user is asked individually on first login whether to participate. You can change + your choice anytime in Settings. Server administrators can disable analytics entirely + by setting{" "} + ANALYTICS_ENABLED=false.

@@ -56,11 +97,12 @@ export function PrivacyPolicyPage() {
-

No Third-Party Services

+

Third-Party Services

- ashim does not integrate with or send data to any external services. AI-powered - features (background removal, upscaling, OCR) run locally using bundled models. No - cloud APIs are involved. + All image processing happens locally — your images are never sent anywhere. If you opt + in to product analytics, anonymous usage data is sent to PostHog and Sentry as + described above. AI-powered features run locally using bundled models. No other + external services are contacted.

diff --git a/apps/web/src/stores/analytics-store.ts b/apps/web/src/stores/analytics-store.ts new file mode 100644 index 00000000..7aaf65f2 --- /dev/null +++ b/apps/web/src/stores/analytics-store.ts @@ -0,0 +1,102 @@ +import type { AnalyticsConfig, ConsentState } from "@ashim/shared"; +import { create } from "zustand"; +import { setAnalyticsConsent } from "@/lib/analytics"; +import { apiPut } from "@/lib/api"; + +interface AnalyticsState { + config: AnalyticsConfig | null; + consent: ConsentState; + configLoaded: boolean; + fetchConfig: () => Promise; + setConsent: (consent: ConsentState) => void; + acceptAnalytics: () => Promise; + declineAnalytics: () => Promise; + remindLater: () => Promise; + toggleAnalytics: (enabled: boolean) => Promise; +} + +export const useAnalyticsStore = create((set, get) => ({ + config: null, + consent: { + analyticsEnabled: null, + analyticsConsentShownAt: null, + analyticsConsentRemindAt: null, + }, + configLoaded: false, + + fetchConfig: async () => { + if (get().configLoaded) return; + try { + const res = await fetch("/api/v1/config/analytics"); + const config: AnalyticsConfig = await res.json(); + set({ config, configLoaded: true }); + } catch { + set({ configLoaded: true }); + } + }, + + setConsent: (consent: ConsentState) => { + set({ consent }); + setAnalyticsConsent(consent.analyticsEnabled === true); + }, + + acceptAnalytics: async () => { + try { + await apiPut("/v1/user/analytics", { enabled: true }); + } catch { + localStorage.setItem("ashim-analytics-consent", "true"); + } + const now = Date.now(); + const consent: ConsentState = { + analyticsEnabled: true, + analyticsConsentShownAt: now, + analyticsConsentRemindAt: null, + }; + set({ consent }); + setAnalyticsConsent(true); + }, + + declineAnalytics: async () => { + try { + await apiPut("/v1/user/analytics", { enabled: false }); + } catch { + localStorage.setItem("ashim-analytics-consent", "false"); + } + const now = Date.now(); + const consent: ConsentState = { + analyticsEnabled: false, + analyticsConsentShownAt: now, + analyticsConsentRemindAt: null, + }; + set({ consent }); + setAnalyticsConsent(false); + }, + + remindLater: async () => { + try { + await apiPut("/v1/user/analytics", { remindLater: true }); + } catch { + localStorage.setItem("ashim-analytics-consent", "remind"); + } + const now = Date.now(); + const consent: ConsentState = { + analyticsEnabled: null, + analyticsConsentShownAt: now, + analyticsConsentRemindAt: now + 7 * 24 * 60 * 60 * 1000, + }; + set({ consent }); + setAnalyticsConsent(false); + }, + + toggleAnalytics: async (enabled: boolean) => { + try { + await apiPut("/v1/user/analytics", { enabled }); + } catch { + localStorage.setItem("ashim-analytics-consent", enabled ? "true" : "false"); + } + set((state) => ({ + consent: { ...state.consent, analyticsEnabled: enabled }, + })); + setAnalyticsConsent(enabled); + }, +})); diff --git a/packages/shared/src/analytics/consent.ts b/packages/shared/src/analytics/consent.ts new file mode 100644 index 00000000..82c58dda --- /dev/null +++ b/packages/shared/src/analytics/consent.ts @@ -0,0 +1,14 @@ +import type { ConsentState } from "./types.js"; + +export function shouldShowConsent(consent: ConsentState, serverEnabled: boolean): boolean { + if (!serverEnabled) return false; + if (consent.analyticsEnabled !== null) return false; + if (consent.analyticsConsentShownAt === null) return true; + if (consent.analyticsConsentRemindAt === null) return false; + return Date.now() >= consent.analyticsConsentRemindAt; +} + +export function isConsentEnabled(consent: ConsentState, serverEnabled: boolean): boolean { + if (!serverEnabled) return false; + return consent.analyticsEnabled === true; +} diff --git a/packages/shared/src/analytics/events.ts b/packages/shared/src/analytics/events.ts new file mode 100644 index 00000000..0dac225e --- /dev/null +++ b/packages/shared/src/analytics/events.ts @@ -0,0 +1,38 @@ +export const ANALYTICS_EVENTS = { + TOOL_USED: "tool_used", + SEARCH: "search", + PIPELINE_EXECUTED: "pipeline_executed", + AI_BUNDLE_ACTION: "ai_bundle_action", +} as const; + +export type AnalyticsEvent = (typeof ANALYTICS_EVENTS)[keyof typeof ANALYTICS_EVENTS]; + +export interface ToolUsedProperties { + tool_id: string; + status: "completed" | "failed"; + duration_ms: number; + category: string; + is_ai_tool: boolean; + params?: Record; +} + +export interface SearchProperties { + query: string; + results_count: number; + clicked_tool_id?: string; +} + +export interface PipelineExecutedProperties { + step_count: number; + tool_ids: string[]; + is_batch: boolean; + file_count?: number; + duration_ms: number; + status: "completed" | "failed"; +} + +export interface AiBundleActionProperties { + bundle_id: string; + action: "installed" | "uninstalled"; + duration_ms: number; +} diff --git a/packages/shared/src/analytics/types.ts b/packages/shared/src/analytics/types.ts new file mode 100644 index 00000000..fcd4fec0 --- /dev/null +++ b/packages/shared/src/analytics/types.ts @@ -0,0 +1,14 @@ +export interface AnalyticsConfig { + enabled: boolean; + posthogApiKey: string; + posthogHost: string; + sentryDsn: string; + sampleRate: number; + instanceId: string; +} + +export interface ConsentState { + analyticsEnabled: boolean | null; + analyticsConsentShownAt: number | null; + analyticsConsentRemindAt: number | null; +} diff --git a/packages/shared/src/i18n/en.ts b/packages/shared/src/i18n/en.ts index b4966619..d5db962f 100644 --- a/packages/shared/src/i18n/en.ts +++ b/packages/shared/src/i18n/en.ts @@ -301,6 +301,33 @@ export const en = { help: "Help", settings: "Settings", }, + analytics: { + consentTitle: "Make ashim better for you", + consentDescription: + 'Anonymous product analytics — like "crop tool used" or "an error occurred" — help us fix bugs faster and build the tools you actually want.', + consentPrivacy: "Your images never leave your machine. Ever.", + whatShared: "What's shared:", + whatSharedItems: [ + "Which tools you use", + "Error reports (no file data)", + "App version and performance", + ], + whatNever: "What NEVER leaves your machine:", + whatNeverItems: [ + "Your images, PDFs, and files", + "File names and contents", + "Any personal information", + ], + consentProviders: "Data is sent to PostHog (analytics) and Sentry (errors) — both open-source.", + consentChangeable: "You can change this anytime in Settings.", + acceptButton: "Sure, sounds good", + declineButton: "Maybe later", + settingsTitle: "Product Analytics", + settingsDescription: "Share anonymous usage data to help improve ashim.", + settingsPrivacy: "Your images never leave your machine.", + settingsDisabledByAdmin: "Product analytics has been disabled by the server administrator.", + learnMore: "Learn more", + }, } as const; export type TranslationKeys = typeof en; diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 848984c6..5e99a350 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -1,3 +1,6 @@ +export * from "./analytics/consent.js"; +export * from "./analytics/events.js"; +export * from "./analytics/types.js"; export * from "./constants.js"; export * from "./features.js"; export * from "./i18n/index.js"; diff --git a/playwright.analytics.config.ts b/playwright.analytics.config.ts new file mode 100644 index 00000000..b93cfbc7 --- /dev/null +++ b/playwright.analytics.config.ts @@ -0,0 +1,40 @@ +import path from "node:path"; +import { defineConfig, devices } from "@playwright/test"; + +const authFile = path.join(__dirname, "test-results", ".auth", "analytics-user.json"); + +const baseURL = process.env.BASE_URL ?? "http://localhost:1349"; +process.env.API_URL ??= baseURL; + +export default defineConfig({ + testDir: "./tests/e2e-docker", + timeout: 120_000, + expect: { + timeout: 30_000, + }, + fullyParallel: false, + retries: 0, + workers: 1, + reporter: [["html", { open: "never" }], ["list"]], + use: { + baseURL, + screenshot: "only-on-failure", + trace: "retain-on-failure", + }, + projects: [ + { + name: "setup", + testMatch: /auth\.setup\.ts/, + }, + { + name: "chromium", + use: { + ...devices["Desktop Chrome"], + storageState: authFile, + }, + dependencies: ["setup"], + }, + ], +}); + +export { authFile }; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 11166dc5..d4c177aa 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -108,6 +108,9 @@ importers: '@scalar/fastify-api-reference': specifier: ^1.49.5 version: 1.49.5 + '@sentry/node': + specifier: ^10.49.0 + version: 10.49.0 archiver: specifier: ^7.0.1 version: 7.0.1 @@ -119,7 +122,7 @@ importers: version: 16.6.1 drizzle-orm: specifier: ^0.38.0 - version: 0.38.4(@types/better-sqlite3@7.6.13)(@types/react@19.2.14)(better-sqlite3@11.10.0)(react@19.2.4) + version: 0.38.4(@opentelemetry/api@1.9.1)(@types/better-sqlite3@7.6.13)(@types/pg@8.15.6)(@types/react@19.2.14)(better-sqlite3@11.10.0)(react@19.2.4) exif-reader: specifier: ^2.0.3 version: 2.0.3 @@ -144,6 +147,9 @@ importers: piscina: specifier: ^5.1.4 version: 5.1.4 + posthog-node: + specifier: ^5.29.5 + version: 5.29.5 potrace: specifier: ^2.1.8 version: 2.1.8 @@ -217,6 +223,9 @@ importers: '@dnd-kit/utilities': specifier: ^3.2.2 version: 3.2.2(react@19.2.4) + '@sentry/react': + specifier: ^10.49.0 + version: 10.49.0(react@19.2.4) '@use-gesture/react': specifier: ^10.3.1 version: 10.3.1(react@19.2.4) @@ -235,6 +244,9 @@ importers: lucide-react: specifier: ^0.469.0 version: 0.469.0(react@19.2.4) + posthog-js: + specifier: ^1.370.0 + version: 1.370.0 qr-code-styling: specifier: ^1.9.2 version: 1.9.2 @@ -1440,6 +1452,11 @@ packages: '@fastify/multipart@9.4.0': resolution: {integrity: sha512-Z404bzZeLSXTBmp/trCBuoVFX28pM7rhv849Q5TsbTFZHuk1lc4QjQITTPK92DKVpXmNtJXeHSSc7GYvqFpxAQ==} + '@fastify/otel@0.18.0': + resolution: {integrity: sha512-3TASCATfw+ctICSb4ymrv7iCm0qJ0N9CarB+CZ7zIJ7KqNbwI5JjyDL1/sxoC0ccTO1Zyd1iQ+oqncPg5FJXaA==} + peerDependencies: + '@opentelemetry/api': ^1.9.0 + '@fastify/proxy-addr@5.1.0': resolution: {integrity: sha512-INS+6gh91cLUjB+PVHfu1UqcB76Sqtpyp7bnL+FYojhjygvOPA9ctiD/JDKsyD9Xgu4hUhCSJBPig/w7duNajw==} @@ -2170,6 +2187,256 @@ packages: '@octokit/types@16.0.0': resolution: {integrity: sha512-sKq+9r1Mm4efXW1FCk7hFSeJo4QKreL/tTbR0rz/qx/r1Oa2VV83LTA/H/MuCOX7uCIJmQVRKBcbmWoySjAnSg==} + '@opentelemetry/api-logs@0.207.0': + resolution: {integrity: sha512-lAb0jQRVyleQQGiuuvCOTDVspc14nx6XJjP4FspJ1sNARo3Regq4ZZbrc3rN4b1TYSuUCvgH+UXUPug4SLOqEQ==} + engines: {node: '>=8.0.0'} + + '@opentelemetry/api-logs@0.208.0': + resolution: {integrity: sha512-CjruKY9V6NMssL/T1kAFgzosF1v9o6oeN+aX5JB/C/xPNtmgIJqcXHG7fA82Ou1zCpWGl4lROQUKwUNE1pMCyg==} + engines: {node: '>=8.0.0'} + + '@opentelemetry/api-logs@0.212.0': + resolution: {integrity: sha512-TEEVrLbNROUkYY51sBJGk7lO/OLjuepch8+hmpM6ffMJQ2z/KVCjdHuCFX6fJj8OkJP2zckPjrJzQtXU3IAsFg==} + engines: {node: '>=8.0.0'} + + '@opentelemetry/api-logs@0.214.0': + resolution: {integrity: sha512-40lSJeqYO8Uz2Yj7u94/SJWE/wONa7rmMKjI1ZcIjgf3MHNHv1OZUCrCETGuaRF62d5pQD1wKIW+L4lmSMTzZA==} + engines: {node: '>=8.0.0'} + + '@opentelemetry/api@1.9.1': + resolution: {integrity: sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q==} + engines: {node: '>=8.0.0'} + + '@opentelemetry/core@2.2.0': + resolution: {integrity: sha512-FuabnnUm8LflnieVxs6eP7Z383hgQU4W1e3KJS6aOG3RxWxcHyBxH8fDMHNgu/gFx/M2jvTOW/4/PHhLz6bjWw==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.0.0 <1.10.0' + + '@opentelemetry/core@2.6.1': + resolution: {integrity: sha512-8xHSGWpJP9wBxgBpnqGL0R3PbdWQndL1Qp50qrg71+B28zK5OQmUgcDKLJgzyAAV38t4tOyLMGDD60LneR5W8g==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.0.0 <1.10.0' + + '@opentelemetry/core@2.7.0': + resolution: {integrity: sha512-DT12SXVwV2eoJrGf4nnsvZojxxeQo+LlNAsoYGRRObPWTeN6APiqZ2+nqDCQDvQX40eLi1AePONS0onoASp3yQ==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.0.0 <1.10.0' + + '@opentelemetry/exporter-logs-otlp-http@0.208.0': + resolution: {integrity: sha512-jOv40Bs9jy9bZVLo/i8FwUiuCvbjWDI+ZW13wimJm4LjnlwJxGgB+N/VWOZUTpM+ah/awXeQqKdNlpLf2EjvYg==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-amqplib@0.61.0': + resolution: {integrity: sha512-mCKoyTGfRNisge4br0NpOFSy2Z1NnEW8hbCJdUDdJFHrPqVzc4IIBPA/vX0U+LUcQqrQvJX+HMIU0dbDRe0i0Q==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-connect@0.57.0': + resolution: {integrity: sha512-FMEBChnI4FLN5TE9DHwfH7QpNir1JzXno1uz/TAucVdLCyrG0jTrKIcNHt/i30A0M2AunNBCkcd8Ei26dIPKdg==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-dataloader@0.31.0': + resolution: {integrity: sha512-f654tZFQXS5YeLDNb9KySrwtg7SnqZN119FauD7acBoTzuLduaiGTNz88ixcVSOOMGZ+EjJu/RFtx5klObC95g==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-fs@0.33.0': + resolution: {integrity: sha512-sCZWXGalQ01wr3tAhSR9ucqFJ0phidpAle6/17HVjD6gN8FLmZMK/8sKxdXYHy3PbnlV1P4zeiSVFNKpbFMNLA==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-generic-pool@0.57.0': + resolution: {integrity: sha512-orhmlaK+ZIW9hKU+nHTbXrCSXZcH83AescTqmpamHRobRmYSQwRbD0a1odc0yAzuzOtxYiHiXAnpnIpaSSY7Ow==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-graphql@0.62.0': + resolution: {integrity: sha512-3YNuLVPUxafXkH1jBAbGsKNsP3XVzcFDhCDCE3OqBwCwShlqQbLMRMFh1T/d5jaVZiGVmSsfof+ICKD2iOV8xg==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-hapi@0.60.0': + resolution: {integrity: sha512-aNljZKYrEa7obLAxd1bCEDxF7kzCLGXTuTJZ8lMR9rIVEjmuKBXN1gfqpm/OB//Zc2zP4iIve1jBp7sr3mQV6w==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-http@0.214.0': + resolution: {integrity: sha512-FlkDhZDRjDJDcO2LcSCtjRpkal1NJ8y0fBqBhTvfAR3JSYY2jAIj1kSS5IjmEBt4c3aWv+u/lqLuoCDrrKCSKg==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-ioredis@0.62.0': + resolution: {integrity: sha512-ZYt//zcPve8qklaZX+5Z4MkU7UpEkFRrxsf2cnaKYBitqDnsCN69CPAuuMOX6NYdW2rG9sFy7V/QWtBlP5XiNQ==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-kafkajs@0.23.0': + resolution: {integrity: sha512-4K+nVo+zI+aDz0Z85SObwbdixIbzS9moIuKJaYsdlzcHYnKOPtB7ya8r8Ezivy/GVIBHiKJVq4tv+BEkgOMLaQ==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-knex@0.58.0': + resolution: {integrity: sha512-Hc/o8fSsaWxZ8r1Yw4rNDLwTpUopTf4X32y4W6UhlHmW8Wizz8wfhgOKIelSeqFVTKBBPIDUOsQWuIMxBmu8Bw==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-koa@0.62.0': + resolution: {integrity: sha512-uVip0VuGUQXZ+vFxkKxAUNq8qNl+VFlyHDh/U6IQ8COOEDfbEchdaHnpFrMYF3psZRUuoSIgb7xOeXj00RdwDA==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.9.0 + + '@opentelemetry/instrumentation-lru-memoizer@0.58.0': + resolution: {integrity: sha512-6grM3TdMyHzlGY1cUA+mwoPueB1F3dYKgKtZIH6jOFXqfHAByyLTc+6PFjGM9tKh52CFBJaDwodNlL/Td39z7Q==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-mongodb@0.67.0': + resolution: {integrity: sha512-1WJp5N1lYfHq2IhECOTewFs5Tf2NfUOwQRqs/rZdXKTezArMlucxgzAaqcgp3A3YREXopXTpXHsxZTGHjNhMdQ==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-mongoose@0.60.0': + resolution: {integrity: sha512-8BahAZpKsOoc+lrZGb7Ofn4g3z8qtp5IxDfvAVpKXsEheQN7ONMH5djT5ihy6yf8yyeQJGS0gXFfpEAEeEHqQg==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-mysql2@0.60.0': + resolution: {integrity: sha512-m/5d3bxQALllCzezYDk/6vajh0tj5OijMMvOZGr+qN1NMXm1dzMNwyJ0gNZW7Fo3YFRyj/jJMxIw+W7d525dlw==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-mysql@0.60.0': + resolution: {integrity: sha512-08pO8GFPEIz2zquKDGteBZDNmwketdgH8hTe9rVYgW9kCJXq1Psj3wPQGx+VaX4ZJKCfPeoLMYup9+cxHvZyVQ==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-pg@0.66.0': + resolution: {integrity: sha512-KxfLGXBb7k2ueaPJfq2GXBDXBly8P+SpR/4Mj410hhNgmQF3sCqwXvUBQxZQkDAmsdBAoenM+yV1LhtsMRamcA==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-redis@0.62.0': + resolution: {integrity: sha512-y3pPpot7WzR/8JtHcYlTYsyY8g+pbFhAqbwAuG5bLPnR6v6pt1rQc0DpH0OlGP/9CZbWBP+Zhwp9yFoygf/ZXQ==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-tedious@0.33.0': + resolution: {integrity: sha512-Q6WQwAD01MMTub31GlejoiFACYNw26J426wyjvU7by7fDIr2nZXNW4vhTGs7i7F0TnXBO3xN688g1tdUgYwJ5w==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-undici@0.24.0': + resolution: {integrity: sha512-oKzZ3uvqP17sV0EsoQcJgjEfIp0kiZRbYu/eD8p13Cbahumf8lb/xpYeNr/hfAJ4owzEtIDcGIjprfLcYbIKBQ==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.7.0 + + '@opentelemetry/instrumentation@0.207.0': + resolution: {integrity: sha512-y6eeli9+TLKnznrR8AZlQMSJT7wILpXH+6EYq5Vf/4Ao+huI7EedxQHwRgVUOMLFbe7VFDvHJrX9/f4lcwnJsA==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation@0.212.0': + resolution: {integrity: sha512-IyXmpNnifNouMOe0I/gX7ENfv2ZCNdYTF0FpCsoBcpbIHzk81Ww9rQTYTnvghszCg7qGrIhNvWC8dhEifgX9Jg==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation@0.214.0': + resolution: {integrity: sha512-MHqEX5Dk59cqVah5LiARMACku7jXSVk9iVDWOea4x3cr7VfdByeDCURK6o1lntT1JS/Tsovw01UJrBhN3/uC5w==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/otlp-exporter-base@0.208.0': + resolution: {integrity: sha512-gMd39gIfVb2OgxldxUtOwGJYSH8P1kVFFlJLuut32L6KgUC4gl1dMhn+YC2mGn0bDOiQYSk/uHOdSjuKp58vvA==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/otlp-transformer@0.208.0': + resolution: {integrity: sha512-DCFPY8C6lAQHUNkzcNT9R+qYExvsk6C5Bto2pbNxgicpcSWbe2WHShLxkOxIdNcBiYPdVHv/e7vH7K6TI+C+fQ==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/redis-common@0.38.3': + resolution: {integrity: sha512-VCghU1JYs/4gP6Gqf/xro9MEsZ7LrMv2uONVsaESKL38ZOB9BqnI98FfS23wjMnHlpuE+TTaWSoAVNpTwYXzjw==} + engines: {node: ^18.19.0 || >=20.6.0} + + '@opentelemetry/resources@2.2.0': + resolution: {integrity: sha512-1pNQf/JazQTMA0BiO5NINUzH0cbLbbl7mntLa4aJNmCCXSj0q03T5ZXXL0zw4G55TjdL9Tz32cznGClf+8zr5A==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.3.0 <1.10.0' + + '@opentelemetry/resources@2.7.0': + resolution: {integrity: sha512-K+oi0hNMv94EpZbnW3eyu2X6SGVpD3O5DhG2NIp65Hc7lhAj9brRXTAVzh3wB82+q3ThakEf7Zd7RsFUqcTc7A==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.3.0 <1.10.0' + + '@opentelemetry/sdk-logs@0.208.0': + resolution: {integrity: sha512-QlAyL1jRpOeaqx7/leG1vJMp84g0xKP6gJmfELBpnI4O/9xPX+Hu5m1POk9Kl+veNkyth5t19hRlN6tNY1sjbA==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.4.0 <1.10.0' + + '@opentelemetry/sdk-metrics@2.2.0': + resolution: {integrity: sha512-G5KYP6+VJMZzpGipQw7Giif48h6SGQ2PFKEYCybeXJsOCB4fp8azqMAAzE5lnnHK3ZVwYQrgmFbsUJO/zOnwGw==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.9.0 <1.10.0' + + '@opentelemetry/sdk-trace-base@2.2.0': + resolution: {integrity: sha512-xWQgL0Bmctsalg6PaXExmzdedSp3gyKV8mQBwK/j9VGdCDu2fmXIb2gAehBKbkXCpJ4HPkgv3QfoJWRT4dHWbw==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.3.0 <1.10.0' + + '@opentelemetry/sdk-trace-base@2.7.0': + resolution: {integrity: sha512-Yg9zEXJB50DLVLpsKPk7NmNqlPlS+OvqhJGh0A8oawIOTPOwlm4eXs9BMJV7L79lvEwI+dWtAj+YjTyddV336A==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.3.0 <1.10.0' + + '@opentelemetry/semantic-conventions@1.40.0': + resolution: {integrity: sha512-cifvXDhcqMwwTlTK04GBNeIe7yyo28Mfby85QXFe1Yk8nmi36Ab/5UQwptOx84SsoGNRg+EVSjwzfSZMy6pmlw==} + engines: {node: '>=14'} + + '@opentelemetry/sql-common@0.41.2': + resolution: {integrity: sha512-4mhWm3Z8z+i508zQJ7r6Xi7y4mmoJpdvH0fZPFRkWrdp5fq7hhZ2HhYokEOLkfqSMgPR4Z9EyB3DBkbKGOqZiQ==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.1.0 + '@petamoriken/float16@3.9.3': resolution: {integrity: sha512-8awtpHXCx/bNpFt4mt2xdkgtgVvKqty8VbjHI/WWWQuEw+KLzFot3f4+LkQY9YmOtq7A5GdOnqoIC8Pdygjk2g==} @@ -2197,6 +2464,47 @@ packages: resolution: {integrity: sha512-h104Kh26rR8tm+a3Qkc5S4VLYint3FE48as7+/5oCEcKR2idC/pF1G6AhIXKI+eHPJa/3J9i5z0Al47IeGHPkA==} engines: {node: '>=12'} + '@posthog/core@1.26.0': + resolution: {integrity: sha512-q6pSkW7V9RC3rIXjJBo7IBXOH2KPJEh8o+xIcSqw7QD4vq7OYVzTJfUIvkD38+sm54RG/61/3QaPdpQZkDnOyg==} + + '@posthog/types@1.370.0': + resolution: {integrity: sha512-Hcan3j0L1JZwyaoi8SvbZk41phzHpj5u3/BRn0dFVBlfkbvfhkgXHPETKIZP5CPzjrotOXRLYI4LNRDHp28VsA==} + + '@prisma/instrumentation@7.6.0': + resolution: {integrity: sha512-ZPW2gRiwpPzEfgeZgaekhqXrbW+Y2RJKHVqUmlhZhKzRNCcvR6DykzylDrynpArKKRQtLxoZy36fK7U0p3pdgQ==} + peerDependencies: + '@opentelemetry/api': ^1.8 + + '@protobufjs/aspromise@1.1.2': + resolution: {integrity: sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==} + + '@protobufjs/base64@1.1.2': + resolution: {integrity: sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==} + + '@protobufjs/codegen@2.0.4': + resolution: {integrity: sha512-YyFaikqM5sH0ziFZCN3xDC7zeGaB/d0IUb9CATugHWbd1FRFwWwt4ld4OYMPWu5a3Xe01mGAULCdqhMlPl29Jg==} + + '@protobufjs/eventemitter@1.1.0': + resolution: {integrity: sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q==} + + '@protobufjs/fetch@1.1.0': + resolution: {integrity: sha512-lljVXpqXebpsijW71PZaCYeIcE5on1w5DlQy5WH6GLbFryLUrBD4932W/E2BSpfRJWseIL4v/KPgBFxDOIdKpQ==} + + '@protobufjs/float@1.0.2': + resolution: {integrity: sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==} + + '@protobufjs/inquire@1.1.0': + resolution: {integrity: sha512-kdSefcPdruJiFMVSbn801t4vFK7KB/5gd2fYvrxhuJYg8ILrmn9SKSX2tZdV6V+ksulWqS7aXjBcRXl3wHoD9Q==} + + '@protobufjs/path@1.1.2': + resolution: {integrity: sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==} + + '@protobufjs/pool@1.1.0': + resolution: {integrity: sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==} + + '@protobufjs/utf8@1.1.0': + resolution: {integrity: sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==} + '@rolldown/pluginutils@1.0.0-beta.27': resolution: {integrity: sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==} @@ -2410,6 +2718,73 @@ packages: peerDependencies: semantic-release: '>=20.1.0' + '@sentry-internal/browser-utils@10.49.0': + resolution: {integrity: sha512-n0QRx0Ysx6mPfIydTkz7VP0FmwM+/EqMZiRqdsU3aTYsngE9GmEDV0OL1bAy6a8N/C1xf9vntkuAtj6N/8Z51w==} + engines: {node: '>=18'} + + '@sentry-internal/feedback@10.49.0': + resolution: {integrity: sha512-JNsUBGv0faCFE7MeZUH99Y9lU9qq3LBALbLxpE1x7ngNrQnVYRlcFgdqaD/btNBKr8awjYL8gmcSkHBWskGqLQ==} + engines: {node: '>=18'} + + '@sentry-internal/replay-canvas@10.49.0': + resolution: {integrity: sha512-7D/NrgH1Qwx5trDYaaTSSJmCb1yVQQLqFG4G/S9x2ltzl9876lSGJL8UeW8ReNQgF3CDAcwbmm/9aXaVSBUNZA==} + engines: {node: '>=18'} + + '@sentry-internal/replay@10.49.0': + resolution: {integrity: sha512-IEy4lwHVMiRE3JAcn+kFKjsTgalDOCSTf20SoFd+nkt6rN/k1RDyr4xpdfF//Kj3UdeTmbuibYjK5H/FLhhnGg==} + engines: {node: '>=18'} + + '@sentry/browser@10.49.0': + resolution: {integrity: sha512-bGCHc+wK2Dx67YoSbmtlt04alqWfQ+dasD/GVipVOq50gvw/BBIDHTEWRJEjACl+LrvszeY54V+24p8z4IgysA==} + engines: {node: '>=18'} + + '@sentry/core@10.49.0': + resolution: {integrity: sha512-UaFeum3LUM1mB0d67jvKnqId1yWQjyqmaDV6kWngG03x+jqXb08tJdGpSoxjXZe13jFBbiBL/wKDDYIK7rCK4g==} + engines: {node: '>=18'} + + '@sentry/node-core@10.49.0': + resolution: {integrity: sha512-7WO0KuCDPSq3G54TVUSI1CKFJwB67LasG+n/gDMBqbrarzs/Yh/s34OOMU5gfVQpncxQAmQsy4nEboQms8iNqA==} + engines: {node: '>=18'} + peerDependencies: + '@opentelemetry/api': ^1.9.0 + '@opentelemetry/core': ^1.30.1 || ^2.1.0 + '@opentelemetry/exporter-trace-otlp-http': '>=0.57.0 <1' + '@opentelemetry/instrumentation': '>=0.57.1 <1' + '@opentelemetry/sdk-trace-base': ^1.30.1 || ^2.1.0 + '@opentelemetry/semantic-conventions': ^1.39.0 + peerDependenciesMeta: + '@opentelemetry/api': + optional: true + '@opentelemetry/core': + optional: true + '@opentelemetry/exporter-trace-otlp-http': + optional: true + '@opentelemetry/instrumentation': + optional: true + '@opentelemetry/sdk-trace-base': + optional: true + '@opentelemetry/semantic-conventions': + optional: true + + '@sentry/node@10.49.0': + resolution: {integrity: sha512-xr+HXABCiO5mgAJRQxsXRdNOLO0+Ee6CvXAAIqovL2A1GlhxNWc5ooPWeIrrLDJ/KGyT8zI91O5scpVXdXs0uQ==} + engines: {node: '>=18'} + + '@sentry/opentelemetry@10.49.0': + resolution: {integrity: sha512-XNLm4dXmtegXQf+EEE2Cs84Ymlo/f5wMx+lg2S2XS4qLbXaPN/HttjhwKftd8D+8iUNfmH+xNMCSshx4s1B/1w==} + engines: {node: '>=18'} + peerDependencies: + '@opentelemetry/api': ^1.9.0 + '@opentelemetry/core': ^1.30.1 || ^2.1.0 + '@opentelemetry/sdk-trace-base': ^1.30.1 || ^2.1.0 + '@opentelemetry/semantic-conventions': ^1.39.0 + + '@sentry/react@10.49.0': + resolution: {integrity: sha512-WdfJve0orTiumr25Ozgs2p2KaJR9xV82Z5V9IYBi0TadsurSWK6xI6SAFjw84tQht9Fp8q4UCn3QYCnApF4BfA==} + engines: {node: '>=18'} + peerDependencies: + react: ^16.14.0 || 17.x || 18.x || 19.x + '@shikijs/core@2.5.0': resolution: {integrity: sha512-uu/8RExTKtavlpH7XqnVYBrfBkUc20ngXiX9NSrBhOVZYv/7XQRKUyhtkeflY5QsxC0GbJThCerruZfsUaSldg==} @@ -2625,6 +3000,9 @@ packages: '@types/chai@5.2.3': resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + '@types/connect@3.4.38': + resolution: {integrity: sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==} + '@types/debug@4.1.13': resolution: {integrity: sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==} @@ -2664,6 +3042,9 @@ packages: '@types/ms@2.1.0': resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==} + '@types/mysql@2.15.27': + resolution: {integrity: sha512-YfWiV16IY0OeBfBCk8+hXKmdTKrKlwKN1MNKAPBu5JYxLwBEZl7QzeEpGnlZb3VMGJrrGmB84gXiH+ofs/TezA==} + '@types/node@16.9.1': resolution: {integrity: sha512-QpLcX9ZSsq3YYUUnD3nFDY8H7wctAhQj/TFKL8Ya8v5fMm3CFXxo8zStsLAl780ltoYoo1WvKUVGBQK+1ifr7g==} @@ -2676,6 +3057,12 @@ packages: '@types/pdfkit@0.17.5': resolution: {integrity: sha512-T3ZHnvF91HsEco5ClhBCOuBwobZfPcI2jaiSHybkkKYq4KhVIIurod94JVKvDIG0JXT6o3KiERC0X0//m8dyrg==} + '@types/pg-pool@2.0.7': + resolution: {integrity: sha512-U4CwmGVQcbEuqpyju8/ptOKg6gEC+Tqsvj2xS9o1g71bUh8twxnC6ZL5rZKCsGN0iyH0CwgUyc9VR5owNQF9Ng==} + + '@types/pg@8.15.6': + resolution: {integrity: sha512-NoaMtzhxOrubeL/7UZuNTrejB4MPAJ0RpxZqXQf2qXuVlTPuG6Y8p4u9dKRaue4yjmC7ZhzVO2/Yyyn25znrPQ==} + '@types/potrace@2.1.5': resolution: {integrity: sha512-Sgk3f5pv0LFlBHg5xlEJQZcqZLyzvdOHOuHb1lux1a1r4cEFgtqBDQx4igQEEb+Xddu0T9KXanPNjlJhpRKsXQ==} @@ -2693,6 +3080,12 @@ packages: '@types/readdir-glob@1.1.5': resolution: {integrity: sha512-raiuEPUYqXu+nvtY2Pe8s8FEmZ3x5yAH4VkLdihcPdalvsHltomrRC9BzuStrJ9yk06470hS0Crw0f1pXqD+Hg==} + '@types/tedious@4.0.14': + resolution: {integrity: sha512-KHPsfX/FoVbUGbyYvk1q9MMQHLPeRZhRJZdO45Q4YjvFkv4hMNghCWTvy7rdKessBsmtz4euWCWAB6/tVpI1Iw==} + + '@types/trusted-types@2.0.7': + resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==} + '@types/unist@3.0.3': resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==} @@ -2856,6 +3249,16 @@ packages: abstract-logging@2.0.1: resolution: {integrity: sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA==} + acorn-import-attributes@1.9.5: + resolution: {integrity: sha512-n02Vykv5uA3eHGM/Z2dQrcD56kL8TyDb2p1+0P83PClMnC/nc+anbQRhIOWnSq4Ke/KvDPrY3C9hDtC/A3eHnQ==} + peerDependencies: + acorn: ^8 + + acorn@8.16.0: + resolution: {integrity: sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==} + engines: {node: '>=0.4.0'} + hasBin: true + agent-base@7.1.4: resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==} engines: {node: '>= 14'} @@ -3154,6 +3557,9 @@ packages: chownr@1.1.4: resolution: {integrity: sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==} + cjs-module-lexer@2.2.0: + resolution: {integrity: sha512-4bHTS2YuzUvtoLjdy+98ykbNB5jS0+07EvFNXerqZQJ89F7DI6ET7OQo/HJuW6K0aVsKA9hj9/RVb2kQVOrPDQ==} + clean-stack@2.2.0: resolution: {integrity: sha512-4diC9HaTE+KRAMWhDhrGOECgWZxoevMc5TlkObMqNSsVU62PYzXZ/SMTjzyGAFF1YusgxGcSWTEXBhp0CPwQ1A==} engines: {node: '>=6'} @@ -3282,6 +3688,9 @@ packages: resolution: {integrity: sha512-7Vv6asjS4gMOuILabD3l739tsaxFQmC+a7pLZm02zyvs8p977bL3zEgq3yDk5rn9B0PbYgIv++jmHcuUab4RhA==} engines: {node: '>=18'} + core-js@3.49.0: + resolution: {integrity: sha512-es1U2+YTtzpwkxVLwAFdSpaIMyQaq0PBgm3YD1W3Qpsn1NAmO3KSgZfu+oGSWVu6NvLHoHCV/aYcsE5wiB7ALg==} + core-util-is@1.0.3: resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==} @@ -3390,6 +3799,9 @@ packages: dom-walk@0.1.2: resolution: {integrity: sha512-6QvTW9mrGeIegrFXdtQi9pk7O/nSK6lSdXW2eqUspN5LWD7UTji2Fqw5V2YLjBpHEoU9Xl/eUWNpDeZvoyOv2w==} + dompurify@3.4.1: + resolution: {integrity: sha512-JahakDAIg1gyOm7dlgWSDjV4n7Ip2PKR55NIT6jrMfIgLFgWo81vdr1/QGqWtFNRqXP9UV71oVePtjqS2ebnPw==} + dot-prop@5.3.0: resolution: {integrity: sha512-QM8q3zDe58hqUqjraQOmzZ1LIH9SWQJTlEKCH4kJ2oQvLZk7RbQXvtDM2XEq3fwkV9CCvvH4LA0AV+ogFsBM2Q==} engines: {node: '>=8'} @@ -3707,6 +4119,9 @@ packages: picomatch: optional: true + fflate@0.4.8: + resolution: {integrity: sha512-FJqqoDBR00Mdj9ppamLa/Y7vxm+PRmNWA67N846RvsoYVMKB4q3y/de5PA7gUmRMYK/8CMz2GDZQmCRN1wBcWA==} + fflate@0.8.2: resolution: {integrity: sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==} @@ -3776,6 +4191,9 @@ packages: resolution: {integrity: sha512-wzsgA6WOq+09wrU1tsJ09udeR/YZRaeArL9e1wPbFg3GG2yDnC2ldKpxs4xunpFF9DgqCqOIra3bc1HWrJ37Ww==} engines: {node: '>=0.4.x'} + forwarded-parse@2.1.2: + resolution: {integrity: sha512-alTFZZQDKMporBH77856pXgzhEzaUVmLCDk+egLgIgHst3Tpndzz8MnKe+GzRJRfvVdn69HhpW7cmXzvtLvJAw==} + from2@2.3.0: resolution: {integrity: sha512-OMcX/4IC/uqEPVgGeyfN22LJk6AZrMkRZHxcHBMBvHScDGgwTm2GT2Wkgtocyd3JfZffjj2kYUDXXII0Fk9W0g==} @@ -3965,6 +4383,13 @@ packages: resolution: {integrity: sha512-YVt14UZCgsX1vZQ3gKjkWVdBdHQ6eu3MPU1TBgL1H5orXe2+jWD006WCPPtOuwlQm10NuzOW5WawiF1Q9veW8g==} engines: {node: '>=18.20'} + import-in-the-middle@2.0.6: + resolution: {integrity: sha512-3vZV3jX0XRFW3EJDTwzWoZa+RH1b8eTTx6YOCjglrLyPuepwoBti1k3L2dKwdCUrnVEfc5CuRuGstaC/uQJJaw==} + + import-in-the-middle@3.0.1: + resolution: {integrity: sha512-pYkiyXVL2Mf3pozdlDGV6NAObxQx13Ae8knZk1UJRJ6uRW/ZRmTGHlQYtrsSl7ubuE5F8CD1z+s1n4RHNuTtuA==} + engines: {node: '>=18'} + import-meta-resolve@4.2.0: resolution: {integrity: sha512-Iqv2fzaTQN28s/FwZAoFq0ZSs/7hMAHJVX+w8PZl3cY19Pxk6jFFalxQoIfW2826i/fDLXv8IiEZRIT0lDuWcg==} @@ -4317,6 +4742,9 @@ packages: resolution: {integrity: sha512-9ie8ItPR6tjY5uYJh8K/Zrv/RMZ5VOlOWvtZdEHYSTFKZfIBPQa9tOAEeAWhd+AnIneLJ22w5fjOYtoutpWq5w==} engines: {node: '>=18'} + long@5.3.2: + resolution: {integrity: sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==} + longest-streak@3.1.0: resolution: {integrity: sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==} @@ -4553,6 +4981,9 @@ packages: resolution: {integrity: sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==} hasBin: true + module-details-from-path@1.0.4: + resolution: {integrity: sha512-EGWKgxALGMgzvxYF1UyGTy0HXX/2vHLkw6+NvDKW2jypWbHpjQuj4UMcqQWXHERJhVGKikolT06G3bcKe4fi7w==} + ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} @@ -4885,6 +5316,17 @@ packages: perfect-debounce@1.0.0: resolution: {integrity: sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==} + pg-int8@1.0.1: + resolution: {integrity: sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==} + engines: {node: '>=4.0.0'} + + pg-protocol@1.13.0: + resolution: {integrity: sha512-zzdvXfS6v89r6v7OcFCHfHlyG/wvry1ALxZo4LqgUoy7W9xhBDMaqOuMiF3qEV45VqsN6rdlcehHrfDtlCPc8w==} + + pg-types@2.2.0: + resolution: {integrity: sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==} + engines: {node: '>=4'} + phin@2.9.3: resolution: {integrity: sha512-CzFr90qM24ju5f88quFC/6qohjC144rehe5n6DH900lgXmUe86+xCKc10ev56gRKC4/BkHUoG4uSiQgBiIXwDA==} deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. @@ -4956,6 +5398,34 @@ packages: resolution: {integrity: sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==} engines: {node: ^10 || ^12 || >=14} + postgres-array@2.0.0: + resolution: {integrity: sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==} + engines: {node: '>=4'} + + postgres-bytea@1.0.1: + resolution: {integrity: sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==} + engines: {node: '>=0.10.0'} + + postgres-date@1.0.7: + resolution: {integrity: sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==} + engines: {node: '>=0.10.0'} + + postgres-interval@1.2.0: + resolution: {integrity: sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==} + engines: {node: '>=0.10.0'} + + posthog-js@1.370.0: + resolution: {integrity: sha512-0PQCUO6EGbXaOPgXYrcafrXa9B13nDaGc91xCiuwNPK80oovqTmeHfNUNCv1r+xCpEoxw3L1FyBuuLCt5Zz7rg==} + + posthog-node@5.29.5: + resolution: {integrity: sha512-mxM8+mXHHMgBKp8f4EhR5TR96tGJ9IqgYa4bPzKgSUW2uxL2vu9AVEz8dusbc913BXPRrDjw4fp+q/PoSsuQFQ==} + engines: {node: ^20.20.0 || >=22.22.0} + peerDependencies: + rxjs: ^7.0.0 + peerDependenciesMeta: + rxjs: + optional: true + potrace@2.1.8: resolution: {integrity: sha512-V9hI7UMJyEhNZjM8CbZaP/804ZRLgzWkCS9OOYnEZkszzj3zKR/erRdj0uFMcN3pp6x4B+AIZebmkQgGRinG/g==} @@ -4999,6 +5469,10 @@ packages: proto-list@1.2.4: resolution: {integrity: sha512-vtK/94akxsTMhe0/cbfpR+syPuszcuwhqVjJq26CuNDgFGj682oRBXOP5MJpv2r7JtE8MsiepGIqvvOTBwn2vA==} + protobufjs@7.5.5: + resolution: {integrity: sha512-3wY1AxV+VBNW8Yypfd1yQY9pXnqTAN+KwQxL8iYm3/BjKYMNg4i0owhEe26PWDOMaIrzeeF98Lqd5NGz4omiIg==} + engines: {node: '>=12.0.0'} + pump@3.0.4: resolution: {integrity: sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==} @@ -5022,6 +5496,9 @@ packages: engines: {node: '>=10.13.0'} hasBin: true + query-selector-shadow-dom@1.0.1: + resolution: {integrity: sha512-lT5yCqEBgfoMYpf3F2xQRK7zEr1rhIIZuceDK6+xRkJQ4NMbHTwXqk4NkwDwQMNqXgG9r9fyHnzwNVs6zV5KRw==} + quick-format-unescaped@4.0.4: resolution: {integrity: sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==} @@ -5145,6 +5622,10 @@ packages: resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} engines: {node: '>=0.10.0'} + require-in-the-middle@8.0.1: + resolution: {integrity: sha512-QT7FVMXfWOYFbeRBF6nu+I6tr2Tf3u0q8RIEjNob/heKY/nh7drD/k7eeMFmSQgnTtCzLDcCu/XEnpW2wk4xCQ==} + engines: {node: '>=9.3.0 || >=8.10.0 <9.0.0'} + require-main-filename@2.0.0: resolution: {integrity: sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==} @@ -5876,6 +6357,9 @@ packages: resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==} engines: {node: '>=18'} + web-vitals@5.2.0: + resolution: {integrity: sha512-i2z98bEmaCqSDiHEDu+gHl/dmR4Q+TxFmG3/13KkMO+o8UxQzCqWaDRCiLgEa41nlO4VpXSI0ASa1xWmO9sBlA==} + web-worker@1.5.0: resolution: {integrity: sha512-RiMReJrTAiA+mBjGONMnjVDP2u3p9R1vkcGz6gDIrOMT3oGuYwX2WRMYI9ipkphSuE5XKEhydbhNEJh4NY9mlw==} @@ -6850,6 +7334,16 @@ snapshots: fastify-plugin: 5.1.0 secure-json-parse: 4.1.0 + '@fastify/otel@0.18.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.7.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation': 0.212.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.40.0 + minimatch: 10.2.4 + transitivePeerDependencies: + - supports-color + '@fastify/proxy-addr@5.1.0': dependencies: '@fastify/forwarded': 3.0.1 @@ -7685,6 +8179,322 @@ snapshots: dependencies: '@octokit/openapi-types': 27.0.0 + '@opentelemetry/api-logs@0.207.0': + dependencies: + '@opentelemetry/api': 1.9.1 + + '@opentelemetry/api-logs@0.208.0': + dependencies: + '@opentelemetry/api': 1.9.1 + + '@opentelemetry/api-logs@0.212.0': + dependencies: + '@opentelemetry/api': 1.9.1 + + '@opentelemetry/api-logs@0.214.0': + dependencies: + '@opentelemetry/api': 1.9.1 + + '@opentelemetry/api@1.9.1': {} + + '@opentelemetry/core@2.2.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/semantic-conventions': 1.40.0 + + '@opentelemetry/core@2.6.1(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/semantic-conventions': 1.40.0 + + '@opentelemetry/core@2.7.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/semantic-conventions': 1.40.0 + + '@opentelemetry/exporter-logs-otlp-http@0.208.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/api-logs': 0.208.0 + '@opentelemetry/core': 2.2.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-exporter-base': 0.208.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-transformer': 0.208.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-logs': 0.208.0(@opentelemetry/api@1.9.1) + + '@opentelemetry/instrumentation-amqplib@0.61.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.7.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation': 0.214.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.40.0 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-connect@0.57.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.7.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation': 0.214.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.40.0 + '@types/connect': 3.4.38 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-dataloader@0.31.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/instrumentation': 0.214.0(@opentelemetry/api@1.9.1) + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-fs@0.33.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.7.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation': 0.214.0(@opentelemetry/api@1.9.1) + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-generic-pool@0.57.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/instrumentation': 0.214.0(@opentelemetry/api@1.9.1) + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-graphql@0.62.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/instrumentation': 0.214.0(@opentelemetry/api@1.9.1) + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-hapi@0.60.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.7.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation': 0.214.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.40.0 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-http@0.214.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.6.1(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation': 0.214.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.40.0 + forwarded-parse: 2.1.2 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-ioredis@0.62.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/instrumentation': 0.214.0(@opentelemetry/api@1.9.1) + '@opentelemetry/redis-common': 0.38.3 + '@opentelemetry/semantic-conventions': 1.40.0 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-kafkajs@0.23.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/instrumentation': 0.214.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.40.0 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-knex@0.58.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/instrumentation': 0.214.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.40.0 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-koa@0.62.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.7.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation': 0.214.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.40.0 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-lru-memoizer@0.58.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/instrumentation': 0.214.0(@opentelemetry/api@1.9.1) + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-mongodb@0.67.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/instrumentation': 0.214.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.40.0 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-mongoose@0.60.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.7.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation': 0.214.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.40.0 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-mysql2@0.60.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/instrumentation': 0.214.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.40.0 + '@opentelemetry/sql-common': 0.41.2(@opentelemetry/api@1.9.1) + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-mysql@0.60.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/instrumentation': 0.214.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.40.0 + '@types/mysql': 2.15.27 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-pg@0.66.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.7.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation': 0.214.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.40.0 + '@opentelemetry/sql-common': 0.41.2(@opentelemetry/api@1.9.1) + '@types/pg': 8.15.6 + '@types/pg-pool': 2.0.7 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-redis@0.62.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/instrumentation': 0.214.0(@opentelemetry/api@1.9.1) + '@opentelemetry/redis-common': 0.38.3 + '@opentelemetry/semantic-conventions': 1.40.0 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-tedious@0.33.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/instrumentation': 0.214.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.40.0 + '@types/tedious': 4.0.14 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-undici@0.24.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.7.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation': 0.214.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.40.0 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation@0.207.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/api-logs': 0.207.0 + import-in-the-middle: 2.0.6 + require-in-the-middle: 8.0.1 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation@0.212.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/api-logs': 0.212.0 + import-in-the-middle: 2.0.6 + require-in-the-middle: 8.0.1 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation@0.214.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/api-logs': 0.214.0 + import-in-the-middle: 3.0.1 + require-in-the-middle: 8.0.1 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/otlp-exporter-base@0.208.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.2.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-transformer': 0.208.0(@opentelemetry/api@1.9.1) + + '@opentelemetry/otlp-transformer@0.208.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/api-logs': 0.208.0 + '@opentelemetry/core': 2.2.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.2.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-logs': 0.208.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-metrics': 2.2.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-trace-base': 2.2.0(@opentelemetry/api@1.9.1) + protobufjs: 7.5.5 + + '@opentelemetry/redis-common@0.38.3': {} + + '@opentelemetry/resources@2.2.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.2.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.40.0 + + '@opentelemetry/resources@2.7.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.7.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.40.0 + + '@opentelemetry/sdk-logs@0.208.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/api-logs': 0.208.0 + '@opentelemetry/core': 2.2.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.2.0(@opentelemetry/api@1.9.1) + + '@opentelemetry/sdk-metrics@2.2.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.2.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.2.0(@opentelemetry/api@1.9.1) + + '@opentelemetry/sdk-trace-base@2.2.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.2.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.2.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.40.0 + + '@opentelemetry/sdk-trace-base@2.7.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.7.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.7.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.40.0 + + '@opentelemetry/semantic-conventions@1.40.0': {} + + '@opentelemetry/sql-common@0.41.2(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.7.0(@opentelemetry/api@1.9.1) + '@petamoriken/float16@3.9.3': {} '@pinojs/redact@0.4.0': {} @@ -7708,6 +8518,40 @@ snapshots: '@pnpm/network.ca-file': 1.0.2 config-chain: 1.1.13 + '@posthog/core@1.26.0': {} + + '@posthog/types@1.370.0': {} + + '@prisma/instrumentation@7.6.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/instrumentation': 0.207.0(@opentelemetry/api@1.9.1) + transitivePeerDependencies: + - supports-color + + '@protobufjs/aspromise@1.1.2': {} + + '@protobufjs/base64@1.1.2': {} + + '@protobufjs/codegen@2.0.4': {} + + '@protobufjs/eventemitter@1.1.0': {} + + '@protobufjs/fetch@1.1.0': + dependencies: + '@protobufjs/aspromise': 1.1.2 + '@protobufjs/inquire': 1.1.0 + + '@protobufjs/float@1.0.2': {} + + '@protobufjs/inquire@1.1.0': {} + + '@protobufjs/path@1.1.2': {} + + '@protobufjs/pool@1.1.0': {} + + '@protobufjs/utf8@1.1.0': {} + '@rolldown/pluginutils@1.0.0-beta.27': {} '@rollup/rollup-android-arm-eabi@4.59.1': @@ -7945,6 +8789,98 @@ snapshots: transitivePeerDependencies: - supports-color + '@sentry-internal/browser-utils@10.49.0': + dependencies: + '@sentry/core': 10.49.0 + + '@sentry-internal/feedback@10.49.0': + dependencies: + '@sentry/core': 10.49.0 + + '@sentry-internal/replay-canvas@10.49.0': + dependencies: + '@sentry-internal/replay': 10.49.0 + '@sentry/core': 10.49.0 + + '@sentry-internal/replay@10.49.0': + dependencies: + '@sentry-internal/browser-utils': 10.49.0 + '@sentry/core': 10.49.0 + + '@sentry/browser@10.49.0': + dependencies: + '@sentry-internal/browser-utils': 10.49.0 + '@sentry-internal/feedback': 10.49.0 + '@sentry-internal/replay': 10.49.0 + '@sentry-internal/replay-canvas': 10.49.0 + '@sentry/core': 10.49.0 + + '@sentry/core@10.49.0': {} + + '@sentry/node-core@10.49.0(@opentelemetry/api@1.9.1)(@opentelemetry/core@2.7.0(@opentelemetry/api@1.9.1))(@opentelemetry/instrumentation@0.214.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.7.0(@opentelemetry/api@1.9.1))(@opentelemetry/semantic-conventions@1.40.0)': + dependencies: + '@sentry/core': 10.49.0 + '@sentry/opentelemetry': 10.49.0(@opentelemetry/api@1.9.1)(@opentelemetry/core@2.7.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.7.0(@opentelemetry/api@1.9.1))(@opentelemetry/semantic-conventions@1.40.0) + import-in-the-middle: 3.0.1 + optionalDependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.7.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation': 0.214.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-trace-base': 2.7.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.40.0 + + '@sentry/node@10.49.0': + dependencies: + '@fastify/otel': 0.18.0(@opentelemetry/api@1.9.1) + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.7.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation': 0.214.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-amqplib': 0.61.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-connect': 0.57.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-dataloader': 0.31.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-fs': 0.33.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-generic-pool': 0.57.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-graphql': 0.62.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-hapi': 0.60.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-http': 0.214.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-ioredis': 0.62.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-kafkajs': 0.23.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-knex': 0.58.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-koa': 0.62.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-lru-memoizer': 0.58.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-mongodb': 0.67.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-mongoose': 0.60.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-mysql': 0.60.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-mysql2': 0.60.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-pg': 0.66.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-redis': 0.62.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-tedious': 0.33.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-undici': 0.24.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-trace-base': 2.7.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.40.0 + '@prisma/instrumentation': 7.6.0(@opentelemetry/api@1.9.1) + '@sentry/core': 10.49.0 + '@sentry/node-core': 10.49.0(@opentelemetry/api@1.9.1)(@opentelemetry/core@2.7.0(@opentelemetry/api@1.9.1))(@opentelemetry/instrumentation@0.214.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.7.0(@opentelemetry/api@1.9.1))(@opentelemetry/semantic-conventions@1.40.0) + '@sentry/opentelemetry': 10.49.0(@opentelemetry/api@1.9.1)(@opentelemetry/core@2.7.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.7.0(@opentelemetry/api@1.9.1))(@opentelemetry/semantic-conventions@1.40.0) + import-in-the-middle: 3.0.1 + transitivePeerDependencies: + - '@opentelemetry/exporter-trace-otlp-http' + - supports-color + + '@sentry/opentelemetry@10.49.0(@opentelemetry/api@1.9.1)(@opentelemetry/core@2.7.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.7.0(@opentelemetry/api@1.9.1))(@opentelemetry/semantic-conventions@1.40.0)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.7.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-trace-base': 2.7.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.40.0 + '@sentry/core': 10.49.0 + + '@sentry/react@10.49.0(react@19.2.4)': + dependencies: + '@sentry/browser': 10.49.0 + '@sentry/core': 10.49.0 + react: 19.2.4 + '@shikijs/core@2.5.0': dependencies: '@shikijs/engine-javascript': 2.5.0 @@ -8153,6 +9089,10 @@ snapshots: '@types/deep-eql': 4.0.2 assertion-error: 2.0.1 + '@types/connect@3.4.38': + dependencies: + '@types/node': 22.19.15 + '@types/debug@4.1.13': dependencies: '@types/ms': 2.1.0 @@ -8190,6 +9130,10 @@ snapshots: '@types/ms@2.1.0': {} + '@types/mysql@2.15.27': + dependencies: + '@types/node': 22.19.15 + '@types/node@16.9.1': {} '@types/node@22.19.15': @@ -8202,6 +9146,16 @@ snapshots: dependencies: '@types/node': 22.19.15 + '@types/pg-pool@2.0.7': + dependencies: + '@types/pg': 8.15.6 + + '@types/pg@8.15.6': + dependencies: + '@types/node': 22.19.15 + pg-protocol: 1.13.0 + pg-types: 2.2.0 + '@types/potrace@2.1.5': dependencies: '@types/node': 22.19.15 @@ -8225,6 +9179,13 @@ snapshots: dependencies: '@types/node': 22.19.15 + '@types/tedious@4.0.14': + dependencies: + '@types/node': 22.19.15 + + '@types/trusted-types@2.0.7': + optional: true + '@types/unist@3.0.3': {} '@types/web-bluetooth@0.0.21': {} @@ -8422,6 +9383,12 @@ snapshots: abstract-logging@2.0.1: {} + acorn-import-attributes@1.9.5(acorn@8.16.0): + dependencies: + acorn: 8.16.0 + + acorn@8.16.0: {} + agent-base@7.1.4: {} aggregate-error@3.1.0: @@ -8710,6 +9677,8 @@ snapshots: chownr@1.1.4: {} + cjs-module-lexer@2.2.0: {} + clean-stack@2.2.0: {} clean-stack@5.3.0: @@ -8849,6 +9818,8 @@ snapshots: dependencies: is-what: 5.5.0 + core-js@3.49.0: {} + core-util-is@1.0.3: {} cosmiconfig@9.0.1(typescript@5.9.3): @@ -8937,6 +9908,10 @@ snapshots: dom-walk@0.1.2: {} + dompurify@3.4.1: + optionalDependencies: + '@types/trusted-types': 2.0.7 + dot-prop@5.3.0: dependencies: is-obj: 2.0.0 @@ -8953,9 +9928,11 @@ snapshots: transitivePeerDependencies: - supports-color - drizzle-orm@0.38.4(@types/better-sqlite3@7.6.13)(@types/react@19.2.14)(better-sqlite3@11.10.0)(react@19.2.4): + drizzle-orm@0.38.4(@opentelemetry/api@1.9.1)(@types/better-sqlite3@7.6.13)(@types/pg@8.15.6)(@types/react@19.2.14)(better-sqlite3@11.10.0)(react@19.2.4): optionalDependencies: + '@opentelemetry/api': 1.9.1 '@types/better-sqlite3': 7.6.13 + '@types/pg': 8.15.6 '@types/react': 19.2.14 better-sqlite3: 11.10.0 react: 19.2.4 @@ -9290,6 +10267,8 @@ snapshots: optionalDependencies: picomatch: 4.0.3 + fflate@0.4.8: {} + fflate@0.8.2: {} figures@2.0.0: @@ -9361,6 +10340,8 @@ snapshots: format@0.2.2: {} + forwarded-parse@2.1.2: {} + from2@2.3.0: dependencies: inherits: 2.0.4 @@ -9571,6 +10552,20 @@ snapshots: transitivePeerDependencies: - supports-color + import-in-the-middle@2.0.6: + dependencies: + acorn: 8.16.0 + acorn-import-attributes: 1.9.5(acorn@8.16.0) + cjs-module-lexer: 2.2.0 + module-details-from-path: 1.0.4 + + import-in-the-middle@3.0.1: + dependencies: + acorn: 8.16.0 + acorn-import-attributes: 1.9.5(acorn@8.16.0) + cjs-module-lexer: 2.2.0 + module-details-from-path: 1.0.4 + import-meta-resolve@4.2.0: {} indent-string@4.0.0: {} @@ -9916,6 +10911,8 @@ snapshots: strip-ansi: 7.2.0 wrap-ansi: 9.0.2 + long@5.3.2: {} + longest-streak@3.1.0: {} loupe@3.2.1: {} @@ -10244,6 +11241,8 @@ snapshots: dependencies: minimist: 1.2.8 + module-details-from-path@1.0.4: {} + ms@2.1.3: {} mupdf@1.27.0: {} @@ -10475,6 +11474,18 @@ snapshots: perfect-debounce@1.0.0: {} + pg-int8@1.0.1: {} + + pg-protocol@1.13.0: {} + + pg-types@2.2.0: + dependencies: + pg-int8: 1.0.1 + postgres-array: 2.0.0 + postgres-bytea: 1.0.1 + postgres-date: 1.0.7 + postgres-interval: 1.2.0 + phin@2.9.3: {} phin@3.7.1: @@ -10544,6 +11555,36 @@ snapshots: picocolors: 1.1.1 source-map-js: 1.2.1 + postgres-array@2.0.0: {} + + postgres-bytea@1.0.1: {} + + postgres-date@1.0.7: {} + + postgres-interval@1.2.0: + dependencies: + xtend: 4.0.2 + + posthog-js@1.370.0: + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/api-logs': 0.208.0 + '@opentelemetry/exporter-logs-otlp-http': 0.208.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.7.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-logs': 0.208.0(@opentelemetry/api@1.9.1) + '@posthog/core': 1.26.0 + '@posthog/types': 1.370.0 + core-js: 3.49.0 + dompurify: 3.4.1 + fflate: 0.4.8 + preact: 10.29.0 + query-selector-shadow-dom: 1.0.1 + web-vitals: 5.2.0 + + posthog-node@5.29.5: + dependencies: + '@posthog/core': 1.26.0 + potrace@2.1.8: dependencies: jimp: 0.14.0 @@ -10591,6 +11632,21 @@ snapshots: proto-list@1.2.4: {} + protobufjs@7.5.5: + dependencies: + '@protobufjs/aspromise': 1.1.2 + '@protobufjs/base64': 1.1.2 + '@protobufjs/codegen': 2.0.4 + '@protobufjs/eventemitter': 1.1.0 + '@protobufjs/fetch': 1.1.0 + '@protobufjs/float': 1.0.2 + '@protobufjs/inquire': 1.1.0 + '@protobufjs/path': 1.1.2 + '@protobufjs/pool': 1.1.0 + '@protobufjs/utf8': 1.1.0 + '@types/node': 22.19.15 + long: 5.3.2 + pump@3.0.4: dependencies: end-of-stream: 1.4.5 @@ -10612,6 +11668,8 @@ snapshots: pngjs: 5.0.0 yargs: 15.4.1 + query-selector-shadow-dom@1.0.1: {} + quick-format-unescaped@4.0.4: {} rc@1.2.8: @@ -10770,6 +11828,13 @@ snapshots: require-from-string@2.0.2: {} + require-in-the-middle@8.0.1: + dependencies: + debug: 4.4.3 + module-details-from-path: 1.0.4 + transitivePeerDependencies: + - supports-color + require-main-filename@2.0.0: {} resolve-from@4.0.0: {} @@ -11598,6 +12663,8 @@ snapshots: dependencies: xml-name-validator: 5.0.0 + web-vitals@5.2.0: {} + web-worker@1.5.0: {} webidl-conversions@8.0.1: {} diff --git a/tests/e2e-docker/analytics-api.spec.ts b/tests/e2e-docker/analytics-api.spec.ts new file mode 100644 index 00000000..35344497 --- /dev/null +++ b/tests/e2e-docker/analytics-api.spec.ts @@ -0,0 +1,191 @@ +import { expect, test } from "@playwright/test"; + +// ─── Analytics API Endpoints ──────────────────────────────────────── +// Tests for the analytics config and user consent API endpoints. +// These run against the Docker container at localhost:1349. + +const BASE_URL = "http://localhost:1349"; +const UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + +/** Login and return a Bearer token for authenticated requests. */ +async function getAuthToken(): Promise { + const res = await fetch(`${BASE_URL}/api/auth/login`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ username: "admin", password: "admin" }), + }); + const data = await res.json(); + return data.token; +} + +test.describe("GET /api/v1/config/analytics (public)", () => { + test("returns 200 without auth token", async () => { + const res = await fetch(`${BASE_URL}/api/v1/config/analytics`); + expect(res.status).toBe(200); + }); + + test("response has correct analytics config shape", async () => { + const res = await fetch(`${BASE_URL}/api/v1/config/analytics`); + const config = await res.json(); + + expect(config).toHaveProperty("enabled"); + expect(config).toHaveProperty("posthogApiKey"); + expect(config).toHaveProperty("posthogHost"); + expect(config).toHaveProperty("sentryDsn"); + expect(config).toHaveProperty("sampleRate"); + expect(config).toHaveProperty("instanceId"); + + expect(typeof config.enabled).toBe("boolean"); + expect(typeof config.posthogApiKey).toBe("string"); + expect(typeof config.posthogHost).toBe("string"); + expect(typeof config.sentryDsn).toBe("string"); + expect(typeof config.sampleRate).toBe("number"); + expect(typeof config.instanceId).toBe("string"); + }); + + test("instanceId is a valid UUID when analytics enabled", async () => { + const res = await fetch(`${BASE_URL}/api/v1/config/analytics`); + const config = await res.json(); + + if (config.enabled) { + expect(config.instanceId).toMatch(UUID_REGEX); + } else { + // When disabled, instanceId is empty string + expect(config.instanceId).toBe(""); + } + }); + + test("instanceId is consistent across multiple fetches", async () => { + const res1 = await fetch(`${BASE_URL}/api/v1/config/analytics`); + const config1 = await res1.json(); + + const res2 = await fetch(`${BASE_URL}/api/v1/config/analytics`); + const config2 = await res2.json(); + + expect(config1.instanceId).toBe(config2.instanceId); + }); +}); + +test.describe("PUT /api/v1/user/analytics (auth required)", () => { + test("returns 401 without auth token", async () => { + const res = await fetch(`${BASE_URL}/api/v1/user/analytics`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ enabled: true }), + }); + expect(res.status).toBe(401); + }); + + test("accepts consent with enabled: true", async () => { + const token = await getAuthToken(); + + const res = await fetch(`${BASE_URL}/api/v1/user/analytics`, { + method: "PUT", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify({ enabled: true }), + }); + expect(res.status).toBe(200); + + const body = await res.json(); + expect(body).toEqual({ ok: true, analyticsEnabled: true }); + }); + + test("declines consent with enabled: false", async () => { + const token = await getAuthToken(); + + const res = await fetch(`${BASE_URL}/api/v1/user/analytics`, { + method: "PUT", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify({ enabled: false }), + }); + expect(res.status).toBe(200); + + const body = await res.json(); + expect(body).toEqual({ ok: true, analyticsEnabled: false }); + }); + + test("remind later sets analyticsEnabled to null", async () => { + const token = await getAuthToken(); + + const res = await fetch(`${BASE_URL}/api/v1/user/analytics`, { + method: "PUT", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify({ remindLater: true }), + }); + expect(res.status).toBe(200); + + const body = await res.json(); + expect(body).toEqual({ ok: true, analyticsEnabled: null }); + }); +}); + +test.describe("GET /api/auth/session includes analytics fields", () => { + test("session response contains analytics consent fields", async () => { + const token = await getAuthToken(); + + // First set a consent preference so the fields are populated + await fetch(`${BASE_URL}/api/v1/user/analytics`, { + method: "PUT", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify({ enabled: true }), + }); + + // Fetch session + const sessionRes = await fetch(`${BASE_URL}/api/auth/session`, { + headers: { Authorization: `Bearer ${token}` }, + }); + expect(sessionRes.status).toBe(200); + + const session = await sessionRes.json(); + + // The user object should include analytics fields + expect(session.user).toHaveProperty("analyticsEnabled"); + expect(session.user).toHaveProperty("analyticsConsentShownAt"); + expect(session.user).toHaveProperty("analyticsConsentRemindAt"); + + // After accepting, analyticsEnabled should be true + expect(session.user.analyticsEnabled).toBe(true); + // analyticsConsentShownAt should be a timestamp (number) + expect(typeof session.user.analyticsConsentShownAt).toBe("number"); + // analyticsConsentRemindAt should be null after explicit accept + expect(session.user.analyticsConsentRemindAt).toBeNull(); + }); + + test("session reflects remind-later state", async () => { + const token = await getAuthToken(); + + // Set remind later + await fetch(`${BASE_URL}/api/v1/user/analytics`, { + method: "PUT", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify({ remindLater: true }), + }); + + // Fetch session + const sessionRes = await fetch(`${BASE_URL}/api/auth/session`, { + headers: { Authorization: `Bearer ${token}` }, + }); + const session = await sessionRes.json(); + + expect(session.user.analyticsEnabled).toBeNull(); + expect(typeof session.user.analyticsConsentShownAt).toBe("number"); + // analyticsConsentRemindAt should be a future timestamp + expect(typeof session.user.analyticsConsentRemindAt).toBe("number"); + expect(session.user.analyticsConsentRemindAt).toBeGreaterThan(Date.now()); + }); +}); diff --git a/tests/e2e-docker/analytics-consent.spec.ts b/tests/e2e-docker/analytics-consent.spec.ts new file mode 100644 index 00000000..9ce76ca0 --- /dev/null +++ b/tests/e2e-docker/analytics-consent.spec.ts @@ -0,0 +1,88 @@ +import { expect, test } from "@playwright/test"; + +// ─── Analytics Consent Page Flow ──────────────────────────────────── +// These tests run against a Docker container at localhost:1349. +// The container must be started with SKIP_MUST_CHANGE_PASSWORD=true. +// +// IMPORTANT: There is only one admin user in the DB. Once consent is +// given/declined, the user's state changes. Tests run serially and +// each builds on the state left by the previous test. + +async function loginAndGetToHome(page: import("@playwright/test").Page) { + await page.goto("/login"); + await page.getByLabel("Username").fill("admin"); + await page.getByLabel("Password").fill("admin"); + await page.getByRole("button", { name: /login/i }).click(); + // May hit consent page or go straight to home + try { + const acceptBtn = page.getByRole("button", { name: /sure, sounds good/i }); + await acceptBtn.waitFor({ state: "visible", timeout: 5_000 }); + await acceptBtn.click(); + await page.waitForURL("/", { timeout: 30_000 }); + } catch { + await page.waitForURL("/", { timeout: 30_000 }); + } +} + +test.describe("Analytics consent page", () => { + test.use({ storageState: { cookies: [], origins: [] } }); + test.describe.configure({ mode: "serial" }); + + test("consent already accepted by auth setup — home loads without consent redirect", async ({ + page, + }) => { + // The auth setup project already accepted analytics consent for the admin + // user, so a fresh browser session logging in as admin should go straight + // to the home page without being redirected to /analytics-consent. + await loginAndGetToHome(page); + await expect(page).toHaveURL("/"); + + // Navigate away and back — consent page should NOT reappear + await page.goto("/resize"); + await page.waitForTimeout(1_000); + await page.goto("/"); + await page.waitForTimeout(1_000); + await expect(page).not.toHaveURL(/analytics-consent/); + + // Verify session has analyticsEnabled=true via API (using the in-browser token) + const sessionData = await page.evaluate(async () => { + const token = localStorage.getItem("ashim-token") ?? ""; + const res = await fetch("/api/auth/session", { + headers: { Authorization: `Bearer ${token}` }, + }); + return res.json(); + }); + expect(sessionData.user.analyticsEnabled).toBe(true); + expect(sessionData.user.analyticsConsentShownAt).toBeGreaterThan(0); + }); + + test("settings toggle works after accepting analytics", async ({ page }) => { + // User already accepted in previous test — login should go straight to home + await loginAndGetToHome(page); + await expect(page).toHaveURL("/"); + + // Open Settings dialog — look for the gear icon or settings button + const settingsButton = page + .locator("[data-testid='settings-button']") + .or(page.locator("button").filter({ has: page.locator("svg.lucide-settings") })); + await expect(settingsButton.first()).toBeVisible({ timeout: 10_000 }); + await settingsButton.first().click(); + + // Navigate to Product Analytics section in the settings nav + const analyticsNav = page.getByText("Product Analytics"); + await expect(analyticsNav).toBeVisible({ timeout: 5_000 }); + await analyticsNav.click(); + + // Verify toggle shows enabled state + await expect(page.getByText("Analytics enabled")).toBeVisible({ timeout: 5_000 }); + + // Click the toggle button to disable + const toggleButton = page.locator("button.rounded-full"); + await toggleButton.click(); + await expect(page.getByText("Analytics disabled")).toBeVisible({ timeout: 5_000 }); + + // Toggle back on + await toggleButton.click(); + await expect(page.getByText("Analytics enabled")).toBeVisible({ timeout: 5_000 }); + }); +}); diff --git a/tests/e2e-docker/analytics-disabled.spec.ts b/tests/e2e-docker/analytics-disabled.spec.ts new file mode 100644 index 00000000..55a7f42e --- /dev/null +++ b/tests/e2e-docker/analytics-disabled.spec.ts @@ -0,0 +1,88 @@ +import { expect, test } from "@playwright/test"; + +// ─── Analytics Disabled (ANALYTICS_ENABLED=false) ─────────────────── +// These tests verify behavior when the server has ANALYTICS_ENABLED=false. +// They need a Docker container started with ANALYTICS_ENABLED=false. +// +// If the container has analytics enabled, these tests will be skipped +// automatically by checking the config endpoint first. + +const BASE_URL = process.env.API_URL ?? "http://localhost:1349"; + +async function loginFresh(page: import("@playwright/test").Page) { + await page.goto("/login"); + await page.getByLabel("Username").fill("admin"); + await page.getByLabel("Password").fill("admin"); + await page.getByRole("button", { name: /login/i }).click(); +} + +test.describe("Analytics disabled by server", () => { + test.use({ storageState: { cookies: [], origins: [] } }); + + test.beforeEach(async ({ request: _request }, testInfo) => { + // Skip this suite if the container has analytics enabled + const res = await fetch(`${BASE_URL}/api/v1/config/analytics`); + const config = await res.json(); + if (config.enabled) { + testInfo.skip(); + } + }); + + test("config endpoint returns disabled with empty fields", async ({ request }) => { + const res = await request.get("/api/v1/config/analytics"); + expect(res.ok()).toBeTruthy(); + + const config = await res.json(); + expect(config).toEqual({ + enabled: false, + posthogApiKey: "", + posthogHost: "", + sentryDsn: "", + sampleRate: 0, + instanceId: "", + }); + }); + + test("no consent screen when analytics disabled — goes directly to home", async ({ page }) => { + await loginFresh(page); + + // Should go directly to home, NOT to /analytics-consent + await page.waitForURL("/", { timeout: 30_000 }); + await expect(page).toHaveURL("/"); + }); + + test("no outbound network requests to PostHog or Sentry", async ({ page }) => { + const analyticsRequests: string[] = []; + + // Intercept ALL network requests and log any that hit analytics domains + await page.route("**/*", (route) => { + const url = route.request().url(); + if ( + url.includes("posthog.com") || + url.includes("posthog") || + url.includes("sentry.io") || + url.includes("sentry") || + url.includes("us.i.posthog.com") || + url.includes("ingest.sentry.io") + ) { + analyticsRequests.push(url); + } + return route.continue(); + }); + + // Login + await loginFresh(page); + await page.waitForURL("/", { timeout: 30_000 }); + + // Navigate around + await page.goto("/resize"); + await page.waitForTimeout(2_000); + await page.goto("/compress"); + await page.waitForTimeout(2_000); + await page.goto("/"); + await page.waitForTimeout(2_000); + + // Assert ZERO analytics requests + expect(analyticsRequests).toEqual([]); + }); +}); diff --git a/tests/e2e-docker/analytics-no-data-leak.spec.ts b/tests/e2e-docker/analytics-no-data-leak.spec.ts new file mode 100644 index 00000000..90d1004a --- /dev/null +++ b/tests/e2e-docker/analytics-no-data-leak.spec.ts @@ -0,0 +1,276 @@ +import fs from "node:fs"; +import path from "node:path"; +import { expect, test } from "@playwright/test"; + +// ─── Analytics Privacy / No Data Leak ─────────────────────────────── +// CRITICAL privacy tests: verify that disabling analytics means +// absolutely zero data is sent to PostHog, Sentry, or any external +// analytics domain. Also verifies that tool functionality is not +// degraded when analytics are disabled. +// +// NOTE: The auth setup project already accepted analytics consent for +// the admin user, so the consent page won't appear on login. Instead, +// these tests login, then explicitly disable analytics via the API, +// then reload the page so the store picks up the new state. + +const SAMPLES_DIR = path.join(process.env.HOME ?? "/Users/sidd", "Downloads", "sample"); +const FIXTURES_DIR = path.join(process.cwd(), "tests", "fixtures"); + +/** Analytics-related domains to watch for in network traffic. */ +const ANALYTICS_DOMAINS = [ + "posthog.com", + "us.i.posthog.com", + "eu.i.posthog.com", + "sentry.io", + "ingest.sentry.io", + "o4508.ingest.us.sentry.io", +]; + +function isAnalyticsRequest(url: string): boolean { + return ANALYTICS_DOMAINS.some((domain) => url.includes(domain)); +} + +async function loginFresh(page: import("@playwright/test").Page) { + await page.goto("/login"); + await page.getByLabel("Username").fill("admin"); + await page.getByLabel("Password").fill("admin"); + await page.getByRole("button", { name: /login/i }).click(); + // May land on "/" or "/analytics-consent" depending on user state + try { + const acceptBtn = page.getByRole("button", { name: /sure, sounds good/i }); + await acceptBtn.waitFor({ state: "visible", timeout: 5_000 }); + await acceptBtn.click(); + await page.waitForURL("/", { timeout: 30_000 }); + } catch { + await page.waitForURL("/", { timeout: 30_000 }); + } +} + +/** + * Disable analytics for the admin user via the browser's existing auth token, + * then reload so the frontend store picks up analyticsEnabled=false. + * This avoids an extra /api/auth/login call (which counts toward rate limits). + */ +async function disableAnalytics(page: import("@playwright/test").Page): Promise { + const ok = await page.evaluate(async () => { + const token = localStorage.getItem("ashim-token") ?? ""; + const res = await fetch("/api/v1/user/analytics", { + method: "PUT", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify({ enabled: false }), + }); + return res.ok; + }); + expect(ok, "Failed to disable analytics via in-browser API call").toBe(true); + await page.reload(); + await page.waitForLoadState("networkidle"); +} + +/** + * Re-enable analytics for the admin user via the browser's existing auth token. + * Called in afterEach so subsequent tests/runs start with analytics enabled. + */ +async function enableAnalytics(page: import("@playwright/test").Page): Promise { + try { + await page.evaluate(async () => { + const token = localStorage.getItem("ashim-token") ?? ""; + await fetch("/api/v1/user/analytics", { + method: "PUT", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify({ enabled: true }), + }); + }); + } catch { + // Best-effort cleanup — page may already be closed + } +} + +function getFixture(name: string): string { + return path.join(FIXTURES_DIR, name); +} + +async function uploadFiles( + page: import("@playwright/test").Page, + filePaths: string[], +): Promise { + const fileChooserPromise = page.waitForEvent("filechooser"); + const dropzone = page.locator("[class*='border-dashed']").first(); + await dropzone.click(); + const fileChooser = await fileChooserPromise; + await fileChooser.setFiles(filePaths); + await page.waitForTimeout(3_000); +} + +async function waitForProcessingDone( + page: import("@playwright/test").Page, + timeoutMs = 60_000, +): Promise { + try { + const spinner = page.locator("[class*='animate-spin']"); + if (await spinner.isVisible({ timeout: 3_000 })) { + await spinner.waitFor({ state: "hidden", timeout: timeoutMs }); + } + } catch { + // No spinner — processing may have been instant + } + await page.waitForTimeout(500); +} + +test.describe("No data leak after disabling analytics", () => { + // Use fresh browser context — no saved auth state + test.use({ storageState: { cookies: [], origins: [] } }); + + // Re-enable analytics after each test so subsequent tests/runs start clean + test.afterEach(async ({ page }) => { + await enableAnalytics(page); + }); + + test("zero PostHog/Sentry traffic after explicitly disabling analytics", async ({ page }) => { + const analyticsRequests: string[] = []; + + // Login first (consent was already accepted by auth setup) + await loginFresh(page); + + // Disable analytics via API and reload + await disableAnalytics(page); + + // Set up network interception AFTER disabling analytics + await page.route("**/*", (route) => { + const url = route.request().url(); + if (isAnalyticsRequest(url)) { + analyticsRequests.push(url); + } + return route.continue(); + }); + + // Navigate to several pages + await page.goto("/resize"); + await page.waitForTimeout(2_000); + await page.goto("/compress"); + await page.waitForTimeout(2_000); + await page.goto("/fullscreen"); + await page.waitForTimeout(2_000); + await page.goto("/"); + await page.waitForTimeout(2_000); + + // Assert ZERO analytics requests were made + expect( + analyticsRequests, + `Expected zero analytics requests, but found: ${analyticsRequests.join(", ")}`, + ).toEqual([]); + }); + + test("zero PostHog/Sentry traffic after toggling analytics off", async ({ page }) => { + const analyticsRequests: string[] = []; + + // Login (consent was already accepted by auth setup) + await loginFresh(page); + + // Disable analytics via API and reload + await disableAnalytics(page); + + // Set up network interception + await page.route("**/*", (route) => { + const url = route.request().url(); + if (isAnalyticsRequest(url)) { + analyticsRequests.push(url); + } + return route.continue(); + }); + + // Browse around + await page.goto("/crop"); + await page.waitForTimeout(2_000); + await page.goto("/convert"); + await page.waitForTimeout(2_000); + await page.goto("/"); + await page.waitForTimeout(2_000); + + expect( + analyticsRequests, + `Expected zero analytics requests, but found: ${analyticsRequests.join(", ")}`, + ).toEqual([]); + }); + + test("tool processing works normally after disabling analytics", async ({ page }) => { + // Login and disable analytics + await loginFresh(page); + await disableAnalytics(page); + + // Navigate to resize tool + await page.goto("/resize"); + await page.waitForTimeout(2_000); + + // Upload a test image + const testImage = getFixture("test-200x150.png"); + await uploadFiles(page, [testImage]); + + // Set resize parameters + const widthInput = page.getByLabel("Width (px)"); + await widthInput.fill("100"); + + // Process the image + const processBtn = page.getByTestId("resize-submit"); + await expect(processBtn).toBeEnabled({ timeout: 15_000 }); + await processBtn.click(); + await waitForProcessingDone(page); + + // Verify no errors + const error = page.locator(".text-red-500"); + expect(await error.isVisible({ timeout: 2_000 }).catch(() => false)).toBe(false); + + // Verify a download link or result appeared + const downloadLink = page.locator( + "a[download], a[href*='download'], button:has-text('Download')", + ); + await expect(downloadLink.first()).toBeVisible({ timeout: 15_000 }); + }); + + test("tool processing works with sample portrait after disabling analytics", async ({ page }) => { + const portraitPath = path.join( + SAMPLES_DIR, + "portrait-of-a-smiling-man-with-glasses-and-a-beard-isolated.png", + ); + if (!fs.existsSync(portraitPath)) { + test.skip(); + return; + } + + // Login and disable analytics + await loginFresh(page); + await disableAnalytics(page); + + // Navigate to resize tool + await page.goto("/resize"); + await page.waitForTimeout(2_000); + + // Upload sample portrait + await uploadFiles(page, [portraitPath]); + + // Set resize width + const widthInput = page.getByLabel("Width (px)"); + await widthInput.fill("200"); + + // Process + const processBtn = page.getByTestId("resize-submit"); + await expect(processBtn).toBeEnabled({ timeout: 15_000 }); + await processBtn.click(); + await waitForProcessingDone(page); + + // Verify no errors + const error = page.locator(".text-red-500"); + expect(await error.isVisible({ timeout: 2_000 }).catch(() => false)).toBe(false); + + // Verify download appeared — proves functionality is not degraded + const downloadLink = page.locator( + "a[download], a[href*='download'], button:has-text('Download')", + ); + await expect(downloadLink.first()).toBeVisible({ timeout: 15_000 }); + }); +}); diff --git a/tests/e2e-docker/analytics-privacy-policy.spec.ts b/tests/e2e-docker/analytics-privacy-policy.spec.ts new file mode 100644 index 00000000..9da9fad2 --- /dev/null +++ b/tests/e2e-docker/analytics-privacy-policy.spec.ts @@ -0,0 +1,61 @@ +import { expect, test } from "@playwright/test"; + +// ─── Privacy Policy Page ──────────────────────────────────────────── +// Tests for the /privacy page content, verifying that the updated +// privacy policy reflects the analytics feature accurately. + +test.describe("Privacy policy page", () => { + test("privacy policy page is accessible and loads", async ({ page }) => { + await page.goto("/privacy"); + + // Verify the page loads with the correct title + await expect(page.getByRole("heading", { name: "Privacy Policy" })).toBeVisible({ + timeout: 10_000, + }); + }); + + test("Product Analytics section exists", async ({ page }) => { + await page.goto("/privacy"); + await expect(page.getByRole("heading", { name: "Product Analytics" })).toBeVisible({ + timeout: 10_000, + }); + }); + + test("Your Choice section exists", async ({ page }) => { + await page.goto("/privacy"); + await expect(page.getByRole("heading", { name: "Your Choice" })).toBeVisible({ + timeout: 10_000, + }); + }); + + test("Third-Party Services section exists", async ({ page }) => { + await page.goto("/privacy"); + await expect(page.getByRole("heading", { name: "Third-Party Services" })).toBeVisible({ + timeout: 10_000, + }); + }); + + test("mentions PostHog and Sentry by name", async ({ page }) => { + await page.goto("/privacy"); + await expect(page.getByRole("link", { name: "PostHog" })).toBeVisible({ timeout: 10_000 }); + await expect(page.getByRole("link", { name: "Sentry" })).toBeVisible({ timeout: 10_000 }); + }); + + test("shows correct last-updated date", async ({ page }) => { + await page.goto("/privacy"); + await expect(page.getByText("April 22, 2026")).toBeVisible({ timeout: 10_000 }); + }); + + test("does NOT contain old 'No Tracking or Analytics' text", async ({ page }) => { + await page.goto("/privacy"); + + // The old privacy policy had this text — it should be gone now + const oldText = page.getByText("No Tracking or Analytics"); + await expect(oldText).not.toBeVisible({ timeout: 5_000 }); + }); + + test("back-to-app link is present", async ({ page }) => { + await page.goto("/privacy"); + await expect(page.getByText("Back to app")).toBeVisible({ timeout: 10_000 }); + }); +}); diff --git a/tests/e2e-docker/auth.setup.ts b/tests/e2e-docker/auth.setup.ts index 23b3c279..da742790 100644 --- a/tests/e2e-docker/auth.setup.ts +++ b/tests/e2e-docker/auth.setup.ts @@ -1,12 +1,27 @@ +import path from "node:path"; import { expect, test as setup } from "@playwright/test"; -import { authFile } from "../../playwright.docker.config"; + +const authFile = path.join(__dirname, "..", "..", "test-results", ".auth", "analytics-user.json"); setup("authenticate", async ({ page }) => { await page.goto("/login"); await page.getByLabel("Username").fill("admin"); await page.getByLabel("Password").fill("admin"); await page.getByRole("button", { name: /login/i }).click(); - await page.waitForURL("/", { timeout: 30_000 }); + + // Wait for login to complete — page leaves "/login" + await page.waitForURL((url) => !url.pathname.startsWith("/login"), { + timeout: 30_000, + }); + + // If we landed on the consent page, accept it + if (page.url().includes("/analytics-consent")) { + await page.getByRole("button", { name: /sure, sounds good/i }).click(); + // Consent page does window.location.href = "/" (full reload) + await page.waitForURL("/", { timeout: 30_000 }); + } + + // At this point we should be on the home page await expect(page).toHaveURL("/"); await page.context().storageState({ path: authFile }); }); diff --git a/tests/unit/analytics-consent-extended.test.ts b/tests/unit/analytics-consent-extended.test.ts new file mode 100644 index 00000000..aed1fca8 --- /dev/null +++ b/tests/unit/analytics-consent-extended.test.ts @@ -0,0 +1,183 @@ +import type { ConsentState } from "@ashim/shared"; +import { isConsentEnabled, shouldShowConsent } from "@ashim/shared"; +import { describe, expect, it } from "vitest"; + +const SEVEN_DAYS_MS = 7 * 24 * 60 * 60 * 1000; + +describe("shouldShowConsent edge cases", () => { + it("returns true when remindAt is exactly equal to Date.now()", () => { + const now = Date.now(); + const state: ConsentState = { + analyticsEnabled: null, + analyticsConsentShownAt: now - SEVEN_DAYS_MS, + analyticsConsentRemindAt: now, + }; + // Date.now() >= remindAt should be true when they are equal + expect(shouldShowConsent(state, true)).toBe(true); + }); + + it("returns true when remindAt is set but consentShownAt is null (defensive)", () => { + const state: ConsentState = { + analyticsEnabled: null, + analyticsConsentShownAt: null, + analyticsConsentRemindAt: Date.now() - 1000, + }; + // consentShownAt is null -> returns true (fresh user path) + expect(shouldShowConsent(state, true)).toBe(true); + }); + + it("returns false when remindAt is 1ms in the future", () => { + const now = Date.now(); + const state: ConsentState = { + analyticsEnabled: null, + analyticsConsentShownAt: now - SEVEN_DAYS_MS, + analyticsConsentRemindAt: now + 100000, // safely in the future + }; + expect(shouldShowConsent(state, true)).toBe(false); + }); + + it("returns true after 'Maybe later' and 7 days have passed", () => { + const shownAt = Date.now() - SEVEN_DAYS_MS - 1000; + const remindAt = Date.now() - 1000; // remind time has passed + const state: ConsentState = { + analyticsEnabled: null, + analyticsConsentShownAt: shownAt, + analyticsConsentRemindAt: remindAt, + }; + expect(shouldShowConsent(state, true)).toBe(true); + }); + + it("returns false when server is disabled regardless of remindAt", () => { + const state: ConsentState = { + analyticsEnabled: null, + analyticsConsentShownAt: Date.now() - SEVEN_DAYS_MS, + analyticsConsentRemindAt: Date.now() - 1000, + }; + expect(shouldShowConsent(state, false)).toBe(false); + }); +}); + +describe("isConsentEnabled edge cases", () => { + it("returns false when analyticsEnabled is false (explicitly declined)", () => { + const state: ConsentState = { + analyticsEnabled: false, + analyticsConsentShownAt: Date.now(), + analyticsConsentRemindAt: null, + }; + expect(isConsentEnabled(state, true)).toBe(false); + }); + + it("returns false when analyticsEnabled is null (never decided)", () => { + const state: ConsentState = { + analyticsEnabled: null, + analyticsConsentShownAt: null, + analyticsConsentRemindAt: null, + }; + expect(isConsentEnabled(state, true)).toBe(false); + }); + + it("returns false when server disabled even if user opted in", () => { + const state: ConsentState = { + analyticsEnabled: true, + analyticsConsentShownAt: Date.now(), + analyticsConsentRemindAt: null, + }; + expect(isConsentEnabled(state, false)).toBe(false); + }); +}); + +describe("consent lifecycle simulations", () => { + it("fresh -> maybe later -> remind time passes -> show again -> accept", () => { + // Step 1: Fresh user -- never been asked + const fresh: ConsentState = { + analyticsEnabled: null, + analyticsConsentShownAt: null, + analyticsConsentRemindAt: null, + }; + expect(shouldShowConsent(fresh, true)).toBe(true); + expect(isConsentEnabled(fresh, true)).toBe(false); + + // Step 2: User clicks "Maybe later" -- shown timestamp set, remind in 7 days + const shownAt = Date.now() - SEVEN_DAYS_MS - 1000; + const maybeLater: ConsentState = { + analyticsEnabled: null, + analyticsConsentShownAt: shownAt, + analyticsConsentRemindAt: shownAt + SEVEN_DAYS_MS, + }; + // Remind time has now passed (shownAt + 7days < now) + expect(shouldShowConsent(maybeLater, true)).toBe(true); + expect(isConsentEnabled(maybeLater, true)).toBe(false); + + // Step 3: User accepts on second prompt + const accepted: ConsentState = { + analyticsEnabled: true, + analyticsConsentShownAt: Date.now(), + analyticsConsentRemindAt: null, + }; + expect(shouldShowConsent(accepted, true)).toBe(false); + expect(isConsentEnabled(accepted, true)).toBe(true); + }); + + it("fresh -> accept immediately -> never show again", () => { + // Step 1: Fresh user + const fresh: ConsentState = { + analyticsEnabled: null, + analyticsConsentShownAt: null, + analyticsConsentRemindAt: null, + }; + expect(shouldShowConsent(fresh, true)).toBe(true); + + // Step 2: User accepts immediately + const accepted: ConsentState = { + analyticsEnabled: true, + analyticsConsentShownAt: Date.now(), + analyticsConsentRemindAt: null, + }; + expect(shouldShowConsent(accepted, true)).toBe(false); + expect(isConsentEnabled(accepted, true)).toBe(true); + + // Verify it stays hidden even far in the future + expect(shouldShowConsent(accepted, true)).toBe(false); + }); + + it("fresh -> decline immediately -> never show again", () => { + // Step 1: Fresh user + const fresh: ConsentState = { + analyticsEnabled: null, + analyticsConsentShownAt: null, + analyticsConsentRemindAt: null, + }; + expect(shouldShowConsent(fresh, true)).toBe(true); + + // Step 2: User declines immediately + const declined: ConsentState = { + analyticsEnabled: false, + analyticsConsentShownAt: Date.now(), + analyticsConsentRemindAt: null, + }; + expect(shouldShowConsent(declined, true)).toBe(false); + expect(isConsentEnabled(declined, true)).toBe(false); + + // Verify it stays hidden and analytics stays disabled + expect(shouldShowConsent(declined, true)).toBe(false); + expect(isConsentEnabled(declined, true)).toBe(false); + }); + + it("fresh -> maybe later -> remind time NOT yet passed -> stay hidden", () => { + const fresh: ConsentState = { + analyticsEnabled: null, + analyticsConsentShownAt: null, + analyticsConsentRemindAt: null, + }; + expect(shouldShowConsent(fresh, true)).toBe(true); + + // User clicks maybe later, only 1 day ago + const maybeLater: ConsentState = { + analyticsEnabled: null, + analyticsConsentShownAt: Date.now() - 86400000, + analyticsConsentRemindAt: Date.now() + SEVEN_DAYS_MS - 86400000, + }; + expect(shouldShowConsent(maybeLater, true)).toBe(false); + expect(isConsentEnabled(maybeLater, true)).toBe(false); + }); +}); diff --git a/tests/unit/analytics-consent.test.ts b/tests/unit/analytics-consent.test.ts new file mode 100644 index 00000000..4a18c146 --- /dev/null +++ b/tests/unit/analytics-consent.test.ts @@ -0,0 +1,123 @@ +import { isConsentEnabled, shouldShowConsent } from "@ashim/shared"; +import { describe, expect, it } from "vitest"; + +describe("shouldShowConsent", () => { + it("returns false when server has analytics disabled", () => { + expect( + shouldShowConsent( + { + analyticsEnabled: null, + analyticsConsentShownAt: null, + analyticsConsentRemindAt: null, + }, + false, + ), + ).toBe(false); + }); + + it("returns true for a fresh user who has never been asked", () => { + expect( + shouldShowConsent( + { + analyticsEnabled: null, + analyticsConsentShownAt: null, + analyticsConsentRemindAt: null, + }, + true, + ), + ).toBe(true); + }); + + it("returns false when user already opted in", () => { + expect( + shouldShowConsent( + { + analyticsEnabled: true, + analyticsConsentShownAt: Date.now(), + analyticsConsentRemindAt: null, + }, + true, + ), + ).toBe(false); + }); + + it("returns false when user explicitly declined", () => { + expect( + shouldShowConsent( + { + analyticsEnabled: false, + analyticsConsentShownAt: Date.now(), + analyticsConsentRemindAt: null, + }, + true, + ), + ).toBe(false); + }); + + it("returns false when remind-at is in the future", () => { + expect( + shouldShowConsent( + { + analyticsEnabled: null, + analyticsConsentShownAt: Date.now() - 86400000, + analyticsConsentRemindAt: Date.now() + 86400000, + }, + true, + ), + ).toBe(false); + }); + + it("returns true when remind-at has passed", () => { + expect( + shouldShowConsent( + { + analyticsEnabled: null, + analyticsConsentShownAt: Date.now() - 86400000 * 8, + analyticsConsentRemindAt: Date.now() - 86400000, + }, + true, + ), + ).toBe(true); + }); +}); + +describe("isConsentEnabled", () => { + it("returns false when server disabled", () => { + expect( + isConsentEnabled( + { + analyticsEnabled: true, + analyticsConsentShownAt: Date.now(), + analyticsConsentRemindAt: null, + }, + false, + ), + ).toBe(false); + }); + + it("returns false when user has not consented", () => { + expect( + isConsentEnabled( + { + analyticsEnabled: null, + analyticsConsentShownAt: null, + analyticsConsentRemindAt: null, + }, + true, + ), + ).toBe(false); + }); + + it("returns true when user opted in and server enabled", () => { + expect( + isConsentEnabled( + { + analyticsEnabled: true, + analyticsConsentShownAt: Date.now(), + analyticsConsentRemindAt: null, + }, + true, + ), + ).toBe(true); + }); +}); diff --git a/tests/unit/analytics-events.test.ts b/tests/unit/analytics-events.test.ts new file mode 100644 index 00000000..f8835013 --- /dev/null +++ b/tests/unit/analytics-events.test.ts @@ -0,0 +1,61 @@ +import { ANALYTICS_EVENTS } from "@ashim/shared"; +import { describe, expect, it } from "vitest"; + +describe("ANALYTICS_EVENTS", () => { + it("has exactly 4 event keys", () => { + expect(Object.keys(ANALYTICS_EVENTS)).toHaveLength(4); + }); + + it("contains the expected keys", () => { + expect(ANALYTICS_EVENTS).toHaveProperty("TOOL_USED"); + expect(ANALYTICS_EVENTS).toHaveProperty("SEARCH"); + expect(ANALYTICS_EVENTS).toHaveProperty("PIPELINE_EXECUTED"); + expect(ANALYTICS_EVENTS).toHaveProperty("AI_BUNDLE_ACTION"); + }); + + it("all event values are strings", () => { + for (const value of Object.values(ANALYTICS_EVENTS)) { + expect(typeof value).toBe("string"); + } + }); + + it("TOOL_USED has the correct snake_case value", () => { + expect(ANALYTICS_EVENTS.TOOL_USED).toBe("tool_used"); + }); + + it("SEARCH has the correct snake_case value", () => { + expect(ANALYTICS_EVENTS.SEARCH).toBe("search"); + }); + + it("PIPELINE_EXECUTED has the correct snake_case value", () => { + expect(ANALYTICS_EVENTS.PIPELINE_EXECUTED).toBe("pipeline_executed"); + }); + + it("AI_BUNDLE_ACTION has the correct snake_case value", () => { + expect(ANALYTICS_EVENTS.AI_BUNDLE_ACTION).toBe("ai_bundle_action"); + }); + + it("all values follow snake_case convention", () => { + for (const value of Object.values(ANALYTICS_EVENTS)) { + expect(value).toMatch(/^[a-z][a-z0-9_]*$/); + } + }); + + it("is frozen (as const prevents mutation)", () => { + // as const produces a readonly object; Object.isFrozen checks runtime freezing. + // TypeScript enforces readonly at compile time, but at runtime the object + // defined with "as const" is a plain object unless explicitly frozen. + // We verify the values are stable by checking they haven't changed. + const snapshot = { ...ANALYTICS_EVENTS }; + expect(ANALYTICS_EVENTS.TOOL_USED).toBe(snapshot.TOOL_USED); + expect(ANALYTICS_EVENTS.SEARCH).toBe(snapshot.SEARCH); + expect(ANALYTICS_EVENTS.PIPELINE_EXECUTED).toBe(snapshot.PIPELINE_EXECUTED); + expect(ANALYTICS_EVENTS.AI_BUNDLE_ACTION).toBe(snapshot.AI_BUNDLE_ACTION); + }); + + it("all values are unique (no duplicate event names)", () => { + const values = Object.values(ANALYTICS_EVENTS); + const unique = new Set(values); + expect(unique.size).toBe(values.length); + }); +}); diff --git a/tests/unit/analytics-types.test.ts b/tests/unit/analytics-types.test.ts new file mode 100644 index 00000000..659dcd04 --- /dev/null +++ b/tests/unit/analytics-types.test.ts @@ -0,0 +1,129 @@ +import type { AnalyticsConfig, ConsentState } from "@ashim/shared"; +import { describe, expect, it } from "vitest"; + +describe("AnalyticsConfig type", () => { + it("accepts a fully populated config object", () => { + const config: AnalyticsConfig = { + enabled: true, + posthogApiKey: "phc_test123", + posthogHost: "https://us.i.posthog.com", + sentryDsn: "https://abc@sentry.io/123", + sampleRate: 1.0, + instanceId: "inst-abc-123", + }; + expect(config.enabled).toBe(true); + expect(config.posthogApiKey).toBe("phc_test123"); + expect(config.posthogHost).toBe("https://us.i.posthog.com"); + expect(config.sentryDsn).toBe("https://abc@sentry.io/123"); + expect(config.sampleRate).toBe(1.0); + expect(config.instanceId).toBe("inst-abc-123"); + }); + + it("accepts a config with analytics disabled", () => { + const config: AnalyticsConfig = { + enabled: false, + posthogApiKey: "", + posthogHost: "", + sentryDsn: "", + sampleRate: 0, + instanceId: "", + }; + expect(config.enabled).toBe(false); + expect(config.sampleRate).toBe(0); + }); + + it("accepts fractional sample rates", () => { + const config: AnalyticsConfig = { + enabled: true, + posthogApiKey: "key", + posthogHost: "https://host.com", + sentryDsn: "https://dsn", + sampleRate: 0.5, + instanceId: "id", + }; + expect(config.sampleRate).toBe(0.5); + }); + + it("has exactly the expected keys", () => { + const config: AnalyticsConfig = { + enabled: true, + posthogApiKey: "key", + posthogHost: "host", + sentryDsn: "dsn", + sampleRate: 1, + instanceId: "id", + }; + const keys = Object.keys(config).sort(); + expect(keys).toEqual( + ["enabled", "instanceId", "posthogApiKey", "posthogHost", "sampleRate", "sentryDsn"].sort(), + ); + }); +}); + +describe("ConsentState type", () => { + it("accepts all-null state (fresh user)", () => { + const state: ConsentState = { + analyticsEnabled: null, + analyticsConsentShownAt: null, + analyticsConsentRemindAt: null, + }; + expect(state.analyticsEnabled).toBeNull(); + expect(state.analyticsConsentShownAt).toBeNull(); + expect(state.analyticsConsentRemindAt).toBeNull(); + }); + + it("accepts opted-in state (analyticsEnabled = true)", () => { + const state: ConsentState = { + analyticsEnabled: true, + analyticsConsentShownAt: 1713800000000, + analyticsConsentRemindAt: null, + }; + expect(state.analyticsEnabled).toBe(true); + expect(state.analyticsConsentShownAt).toBe(1713800000000); + expect(state.analyticsConsentRemindAt).toBeNull(); + }); + + it("accepts declined state (analyticsEnabled = false)", () => { + const state: ConsentState = { + analyticsEnabled: false, + analyticsConsentShownAt: 1713800000000, + analyticsConsentRemindAt: null, + }; + expect(state.analyticsEnabled).toBe(false); + }); + + it("accepts deferred state (maybe later with remindAt set)", () => { + const shownAt = Date.now() - 86400000; + const remindAt = Date.now() + 86400000 * 6; + const state: ConsentState = { + analyticsEnabled: null, + analyticsConsentShownAt: shownAt, + analyticsConsentRemindAt: remindAt, + }; + expect(state.analyticsEnabled).toBeNull(); + expect(state.analyticsConsentShownAt).toBe(shownAt); + expect(state.analyticsConsentRemindAt).toBe(remindAt); + }); + + it("accepts mixed state with analyticsEnabled true and remindAt set", () => { + const state: ConsentState = { + analyticsEnabled: true, + analyticsConsentShownAt: 1713800000000, + analyticsConsentRemindAt: 1714400000000, + }; + expect(state.analyticsEnabled).toBe(true); + expect(state.analyticsConsentRemindAt).toBe(1714400000000); + }); + + it("has exactly the expected keys", () => { + const state: ConsentState = { + analyticsEnabled: null, + analyticsConsentShownAt: null, + analyticsConsentRemindAt: null, + }; + const keys = Object.keys(state).sort(); + expect(keys).toEqual( + ["analyticsConsentRemindAt", "analyticsConsentShownAt", "analyticsEnabled"].sort(), + ); + }); +}); diff --git a/tests/unit/api/analytics-env.test.ts b/tests/unit/api/analytics-env.test.ts new file mode 100644 index 00000000..2d8537d8 --- /dev/null +++ b/tests/unit/api/analytics-env.test.ts @@ -0,0 +1,154 @@ +import { afterAll, beforeEach, describe, expect, it } from "vitest"; + +describe("analytics env var validation", () => { + const originalEnv = { ...process.env }; + + // Keys that the Zod schema cares about -- we clear them before each test + // so defaults kick in unless explicitly set. + const analyticsKeys = [ + "ANALYTICS_ENABLED", + "ANALYTICS_SAMPLE_RATE", + "POSTHOG_API_KEY", + "POSTHOG_HOST", + "SENTRY_DSN", + ]; + + beforeEach(() => { + for (const key of analyticsKeys) { + delete process.env[key]; + } + }); + + afterAll(() => { + for (const key of Object.keys(process.env)) { + if (!(key in originalEnv)) delete process.env[key]; + } + Object.assign(process.env, originalEnv); + }); + + // ── ANALYTICS_ENABLED ─────────────────────────────────────────────────── + + it("ANALYTICS_ENABLED defaults to true", async () => { + const { loadEnv } = await import("../../../apps/api/src/lib/env.js"); + const env = loadEnv(); + expect(env.ANALYTICS_ENABLED).toBe(true); + }); + + it("ANALYTICS_ENABLED='false' transforms to boolean false", async () => { + process.env.ANALYTICS_ENABLED = "false"; + const { loadEnv } = await import("../../../apps/api/src/lib/env.js"); + expect(loadEnv().ANALYTICS_ENABLED).toBe(false); + }); + + it("ANALYTICS_ENABLED='true' transforms to boolean true", async () => { + process.env.ANALYTICS_ENABLED = "true"; + const { loadEnv } = await import("../../../apps/api/src/lib/env.js"); + expect(loadEnv().ANALYTICS_ENABLED).toBe(true); + }); + + it("ANALYTICS_ENABLED rejects non-enum values", async () => { + process.env.ANALYTICS_ENABLED = "yes"; + const { loadEnv } = await import("../../../apps/api/src/lib/env.js"); + expect(() => loadEnv()).toThrow(); + }); + + // ── ANALYTICS_SAMPLE_RATE ────────────────────────────────────────────── + + it("ANALYTICS_SAMPLE_RATE defaults to 1.0", async () => { + const { loadEnv } = await import("../../../apps/api/src/lib/env.js"); + const env = loadEnv(); + expect(env.ANALYTICS_SAMPLE_RATE).toBe(1.0); + }); + + it("ANALYTICS_SAMPLE_RATE=0.5 parses correctly", async () => { + process.env.ANALYTICS_SAMPLE_RATE = "0.5"; + const { loadEnv } = await import("../../../apps/api/src/lib/env.js"); + expect(loadEnv().ANALYTICS_SAMPLE_RATE).toBe(0.5); + }); + + it("ANALYTICS_SAMPLE_RATE=0 is valid (no sampling)", async () => { + process.env.ANALYTICS_SAMPLE_RATE = "0"; + const { loadEnv } = await import("../../../apps/api/src/lib/env.js"); + expect(loadEnv().ANALYTICS_SAMPLE_RATE).toBe(0); + }); + + it("ANALYTICS_SAMPLE_RATE=1 is valid (full sampling)", async () => { + process.env.ANALYTICS_SAMPLE_RATE = "1"; + const { loadEnv } = await import("../../../apps/api/src/lib/env.js"); + expect(loadEnv().ANALYTICS_SAMPLE_RATE).toBe(1); + }); + + it("ANALYTICS_SAMPLE_RATE > 1 fails validation", async () => { + process.env.ANALYTICS_SAMPLE_RATE = "1.5"; + const { loadEnv } = await import("../../../apps/api/src/lib/env.js"); + expect(() => loadEnv()).toThrow(); + }); + + it("ANALYTICS_SAMPLE_RATE < 0 fails validation", async () => { + process.env.ANALYTICS_SAMPLE_RATE = "-0.1"; + const { loadEnv } = await import("../../../apps/api/src/lib/env.js"); + expect(() => loadEnv()).toThrow(); + }); + + it("ANALYTICS_SAMPLE_RATE=2 fails validation", async () => { + process.env.ANALYTICS_SAMPLE_RATE = "2"; + const { loadEnv } = await import("../../../apps/api/src/lib/env.js"); + expect(() => loadEnv()).toThrow(); + }); + + // ── POSTHOG_API_KEY ──────────────────────────────────────────────────── + + it("POSTHOG_API_KEY defaults to the baked-in key", async () => { + const { loadEnv } = await import("../../../apps/api/src/lib/env.js"); + const env = loadEnv(); + expect(env.POSTHOG_API_KEY).toBe("phc_CVHjGivwWVzh76M5EjijTwP5LpiqWie3EbCzXU7w2Smy"); + }); + + it("POSTHOG_API_KEY can be overridden with a custom value", async () => { + process.env.POSTHOG_API_KEY = "phc_custom_key_123"; + const { loadEnv } = await import("../../../apps/api/src/lib/env.js"); + expect(loadEnv().POSTHOG_API_KEY).toBe("phc_custom_key_123"); + }); + + it("POSTHOG_API_KEY can be set to empty string", async () => { + process.env.POSTHOG_API_KEY = ""; + const { loadEnv } = await import("../../../apps/api/src/lib/env.js"); + expect(loadEnv().POSTHOG_API_KEY).toBe(""); + }); + + // ── POSTHOG_HOST ─────────────────────────────────────────────────────── + + it("POSTHOG_HOST defaults to the PostHog US endpoint", async () => { + const { loadEnv } = await import("../../../apps/api/src/lib/env.js"); + const env = loadEnv(); + expect(env.POSTHOG_HOST).toBe("https://us.i.posthog.com"); + }); + + it("POSTHOG_HOST can be overridden", async () => { + process.env.POSTHOG_HOST = "https://eu.posthog.com"; + const { loadEnv } = await import("../../../apps/api/src/lib/env.js"); + expect(loadEnv().POSTHOG_HOST).toBe("https://eu.posthog.com"); + }); + + // ── SENTRY_DSN ───────────────────────────────────────────────────────── + + it("SENTRY_DSN defaults to the baked-in DSN", async () => { + const { loadEnv } = await import("../../../apps/api/src/lib/env.js"); + const env = loadEnv(); + expect(env.SENTRY_DSN).toBe( + "https://2fd53fc3b3fdc59d02cac044a4f90b71@o4511263372738560.ingest.us.sentry.io/4511264620085248", + ); + }); + + it("SENTRY_DSN can be overridden with a custom value", async () => { + process.env.SENTRY_DSN = "https://custom@sentry.io/999"; + const { loadEnv } = await import("../../../apps/api/src/lib/env.js"); + expect(loadEnv().SENTRY_DSN).toBe("https://custom@sentry.io/999"); + }); + + it("SENTRY_DSN can be set to empty string to disable", async () => { + process.env.SENTRY_DSN = ""; + const { loadEnv } = await import("../../../apps/api/src/lib/env.js"); + expect(loadEnv().SENTRY_DSN).toBe(""); + }); +});