Merge pull request #91 from ashim-hq/feat/analytics

feat: production-grade opt-in product analytics with PostHog and Sentry
This commit is contained in:
Ashim
2026-04-23 12:40:08 +08:00
committed by GitHub
41 changed files with 3482 additions and 24 deletions
+1 -1
View File
@@ -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
@@ -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;
+7
View File
@@ -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
}
]
}
+5 -3
View File
@@ -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",
+3
View File
@@ -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", {
+34
View File
@@ -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();
+131
View File
@@ -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<void> {
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<string, unknown>,
): void {
if (!posthogClient || !isRequestOptedIn(request) || !shouldSample()) return;
try {
posthogClient.capture({
distinctId: getInstanceId(),
event,
properties,
});
} catch {
// never throw from analytics
}
}
+12
View File
@@ -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<typeof envSchema>;
+6
View File
@@ -208,6 +208,9 @@ export async function authRoutes(app: FastifyInstance): Promise<void> {
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<void> {
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(),
});
+73
View File
@@ -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<void> {
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 });
});
}
+16 -1
View File
@@ -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<void>
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<void>
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<void>
markUninstalled(bundleId);
shutdownDispatcher();
trackEvent(request, ANALYTICS_EVENTS.AI_BUNDLE_ACTION, {
bundle_id: bundleId,
action: "uninstalled",
duration_ms: 0,
});
return reply.send({ ok: true });
},
);
+36 -1
View File
@@ -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<void
}
// Execute the pipeline: pass the buffer through each step sequentially
const startTime = Date.now();
let currentBuffer = fileBuffer;
let currentFilename = filename;
const stepResults: Array<{ step: number; toolId: string; size: number }> = [];
@@ -244,6 +246,13 @@ export async function registerPipelineRoutes(app: FastifyInstance): Promise<void
}
} catch (err) {
const message = err instanceof Error ? err.message : "Pipeline processing failed";
trackEvent(request, ANALYTICS_EVENTS.PIPELINE_EXECUTED, {
step_count: pipeline.steps.length,
tool_ids: pipeline.steps.map((s: { toolId: string }) => 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<void
const inputPath = join(workspacePath, "input", filename);
await writeFile(inputPath, fileBuffer);
trackEvent(request, ANALYTICS_EVENTS.PIPELINE_EXECUTED, {
step_count: pipeline.steps.length,
tool_ids: pipeline.steps.map((s: { toolId: string }) => 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<void
}
// ── Progress tracking ────────────────────────────────────────────
const batchStartTime = Date.now();
const jobId = clientJobId || randomUUID();
const progress: JobProgress = {
@@ -651,12 +669,29 @@ export async function registerPipelineRoutes(app: FastifyInstance): Promise<void
// If every file failed, return an error instead of an empty ZIP
if (progress.status === "failed") {
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: "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, {
+18 -1
View File
@@ -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<T>(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<T>(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<T>(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,
+2
View File
@@ -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",
+76 -3
View File
@@ -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 <Navigate to="/change-password" replace />;
}
// 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 <Navigate to="/analytics-consent" replace />;
}
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 (
<ErrorBoundary>
<ConnectionMonitor />
@@ -137,6 +209,7 @@ export function App() {
<Route path="/saturation" element={<Navigate to="/adjust-colors" replace />} />
<Route path="/color-channels" element={<Navigate to="/adjust-colors" replace />} />
<Route path="/color-effects" element={<Navigate to="/adjust-colors" replace />} />
<Route path="/analytics-consent" element={<AnalyticsConsentPage />} />
<Route path="/:toolId" element={<ToolPage />} />
<Route path="/" element={<HomePage />} />
</Routes>
@@ -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" && <ApiKeysSection />}
{section === "ai-features" && <AiFeaturesSection />}
{section === "tools" && <ToolsSection />}
{section === "analytics" && <AnalyticsSection />}
{section === "about" && <AboutSection />}
</div>
</div>
@@ -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 (
<div className="space-y-4">
<div>
<h3 className="text-sm font-medium text-foreground">Product Analytics</h3>
<p className="text-xs text-muted-foreground mt-1">
Share anonymous usage data to help improve ashim.
</p>
<p className="text-xs text-muted-foreground">Your images never leave your machine.</p>
</div>
{disabled ? (
<p className="text-xs text-muted-foreground italic">
Product analytics has been disabled by the server administrator.
</p>
) : (
<div className="flex items-center justify-between">
<span className="text-sm text-foreground">
{enabled ? "Analytics enabled" : "Analytics disabled"}
</span>
<button
type="button"
onClick={() => toggleAnalytics(!enabled)}
className={cn(
"relative inline-flex h-6 w-11 items-center rounded-full transition-colors",
enabled ? "bg-primary" : "bg-muted-foreground/30",
)}
>
<span
className={cn(
"inline-block h-4 w-4 rounded-full bg-white transition-transform",
enabled ? "translate-x-6" : "translate-x-1",
)}
/>
</button>
</div>
)}
<a
href="/privacy"
target="_blank"
rel="noopener noreferrer"
className="text-xs text-primary hover:underline"
>
Learn more
</a>
</div>
);
}
/* ────────────────────── About ────────────────────── */
function AboutSection() {
+18
View File
@@ -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 {
+113
View File
@@ -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<string, unknown>): void {
if (!posthog || !consentGranted) return;
try {
posthog.identify(instanceId, properties);
} catch {
// never throw
}
}
export function track(event: string, properties?: Record<string, unknown>): 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
}
}
+10
View File
@@ -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;
}
@@ -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 (
<div className="flex min-h-screen items-center justify-center bg-background">
<div className="h-8 w-8 border-2 border-primary border-t-transparent rounded-full animate-spin" />
</div>
);
}
const handleAccept = async () => {
await acceptAnalytics();
window.location.href = "/";
};
const handleDecline = async () => {
await remindLater();
window.location.href = "/";
};
return (
<div className="flex min-h-screen items-center justify-center bg-background p-6">
<div className="w-full max-w-lg space-y-6 rounded-2xl border border-border bg-card p-8 shadow-lg">
<div className="flex justify-center">
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-primary/10">
<Shield className="h-6 w-6 text-primary" />
</div>
</div>
<div className="space-y-2 text-center">
<h1 className="text-xl font-semibold text-foreground">{t.consentTitle}</h1>
<p className="text-sm text-muted-foreground">{t.consentDescription}</p>
</div>
<p className="text-center text-sm font-medium text-foreground">{t.consentPrivacy}</p>
<div className="grid grid-cols-2 gap-6 text-sm">
<div>
<p className="mb-2 font-medium text-foreground">{t.whatShared}</p>
<ul className="space-y-1 text-muted-foreground">
{t.whatSharedItems.map((item) => (
<li key={item} className="flex items-start gap-1.5">
<span className="mt-1 text-xs">·</span>
{item}
</li>
))}
</ul>
</div>
<div>
<p className="mb-2 font-medium text-foreground">{t.whatNever}</p>
<ul className="space-y-1 text-muted-foreground">
{t.whatNeverItems.map((item) => (
<li key={item} className="flex items-start gap-1.5">
<span className="mt-1 text-xs">·</span>
{item}
</li>
))}
</ul>
</div>
</div>
<p className="text-center text-xs text-muted-foreground">
{t.consentProviders}
<br />
{t.consentChangeable}
</p>
<div className="flex gap-3">
<button
type="button"
onClick={handleAccept}
className="flex-1 rounded-lg bg-primary px-4 py-2.5 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90"
>
{t.acceptButton}
</button>
<button
type="button"
onClick={handleDecline}
className="flex-1 rounded-lg border border-border px-4 py-2.5 text-sm font-medium text-foreground transition-colors hover:bg-muted"
>
{t.declineButton}
</button>
</div>
</div>
</div>
);
}
+13 -1
View File
@@ -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<string, Tool[]>();
for (const tool of filteredTools) {
+51 -9
View File
@@ -14,7 +14,7 @@ export function PrivacyPolicyPage() {
</Link>
<h1 className="text-3xl font-bold mb-2">Privacy Policy</h1>
<p className="text-sm text-muted-foreground mb-8">Last updated: March 29, 2026</p>
<p className="text-sm text-muted-foreground mb-8">Last updated: April 22, 2026</p>
<div className="space-y-6 text-sm leading-relaxed text-muted-foreground">
<section>
@@ -37,11 +37,52 @@ export function PrivacyPolicyPage() {
</section>
<section>
<h2 className="text-lg font-semibold text-foreground mb-2">No Tracking or Analytics</h2>
<h2 className="text-lg font-semibold text-foreground mb-2">Product Analytics</h2>
<p>
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:
</p>
<ul className="list-disc pl-5 mt-2 space-y-1">
<li>Which tools you use (e.g., "crop tool used")</li>
<li>Error reports without file data</li>
<li>App version and performance metrics</li>
</ul>
<p className="mt-2 font-medium">What is never collected:</p>
<ul className="list-disc pl-5 mt-2 space-y-1">
<li>Your images, PDFs, and files</li>
<li>File names and contents</li>
<li>Any personal information or IP addresses</li>
</ul>
<p className="mt-2">
Analytics data is sent to{" "}
<a
href="https://posthog.com"
className="text-primary hover:underline"
target="_blank"
rel="noopener noreferrer"
>
PostHog
</a>{" "}
(usage analytics) and{" "}
<a
href="https://sentry.io"
className="text-primary hover:underline"
target="_blank"
rel="noopener noreferrer"
>
Sentry
</a>{" "}
(error tracking) both open-source projects.
</p>
</section>
<section>
<h2 className="text-lg font-semibold text-foreground mb-2">Your Choice</h2>
<p>
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{" "}
<code className="text-xs bg-muted px-1 py-0.5 rounded">ANALYTICS_ENABLED=false</code>.
</p>
</section>
@@ -56,11 +97,12 @@ export function PrivacyPolicyPage() {
</section>
<section>
<h2 className="text-lg font-semibold text-foreground mb-2">No Third-Party Services</h2>
<h2 className="text-lg font-semibold text-foreground mb-2">Third-Party Services</h2>
<p>
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.
</p>
</section>
+102
View File
@@ -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<void>;
setConsent: (consent: ConsentState) => void;
acceptAnalytics: () => Promise<void>;
declineAnalytics: () => Promise<void>;
remindLater: () => Promise<void>;
toggleAnalytics: (enabled: boolean) => Promise<void>;
}
export const useAnalyticsStore = create<AnalyticsState>((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);
},
}));
+14
View File
@@ -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;
}
+38
View File
@@ -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<string, string | number | boolean>;
}
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;
}
+14
View File
@@ -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;
}
+27
View File
@@ -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;
+3
View File
@@ -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";
+40
View File
@@ -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 };
+1069 -2
View File
File diff suppressed because it is too large Load Diff
+191
View File
@@ -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<string> {
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());
});
});
@@ -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 });
});
});
@@ -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([]);
});
});
@@ -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<void> {
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<void> {
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<void> {
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<void> {
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 });
});
});
@@ -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 });
});
});
+17 -2
View File
@@ -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 });
});
@@ -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);
});
});
+123
View File
@@ -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);
});
});
+61
View File
@@ -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);
});
});
+129
View File
@@ -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(),
);
});
});
+154
View File
@@ -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("");
});
});