From 2aa749a2f0d64fd245b4d40904d72697960b29f6 Mon Sep 17 00:00:00 2001 From: Zimeng Xiong Date: Sat, 7 Feb 2026 15:51:27 -0800 Subject: [PATCH] prevent preview updates from overwriting drawings --- backend/docker-entrypoint.sh | 25 +- .../src/__tests__/auth-enabled.integration.ts | 94 + .../__tests__/csrf-cookie-stability.test.ts | 20 + .../__tests__/imports-compat.integration.ts | 4 +- .../preview-update-regression.test.ts | 55 + backend/src/__tests__/testUtils.ts | 13 +- backend/src/auth.ts | 46 +- backend/src/auth/accountRoutes.ts | 21 +- backend/src/auth/adminRoutes.ts | 3 +- backend/src/auth/coreRoutes.ts | 57 +- backend/src/auth/tokenSecurity.ts | 11 + backend/src/index.ts | 287 +- backend/src/routes/dashboard.ts | 81 +- backend/src/routes/importExport.ts | 67 +- docker-compose.prod.yml | 3 +- docker-compose.yml | 3 +- frontend/nginx.conf | 18 +- frontend/nginx.conf.template | 18 +- frontend/package-lock.json | 2612 +---------------- frontend/package.json | 2 +- frontend/src/context/AuthContext.test.tsx | 44 +- frontend/src/context/AuthContext.tsx | 3 + frontend/src/pages/Dashboard.tsx | 34 +- frontend/src/pages/Editor.tsx | 310 +- frontend/src/pages/editor/shared.test.ts | 29 + frontend/src/pages/editor/shared.ts | 31 + frontend/vite.config.ts | 40 +- 27 files changed, 1172 insertions(+), 2759 deletions(-) create mode 100644 backend/src/__tests__/auth-enabled.integration.ts create mode 100644 backend/src/__tests__/csrf-cookie-stability.test.ts create mode 100644 backend/src/__tests__/preview-update-regression.test.ts create mode 100644 backend/src/auth/tokenSecurity.ts diff --git a/backend/docker-entrypoint.sh b/backend/docker-entrypoint.sh index 9eff924..56cd0d3 100644 --- a/backend/docker-entrypoint.sh +++ b/backend/docker-entrypoint.sh @@ -2,6 +2,7 @@ set -e JWT_SECRET_FILE="/app/prisma/.jwt_secret" +CSRF_SECRET_FILE="/app/prisma/.csrf_secret" # Ensure JWT secret exists for production startup. # Backward compatibility: older installs may not have JWT_SECRET configured. @@ -25,6 +26,27 @@ fi export JWT_SECRET +# Ensure CSRF secret exists for stable token validation across restarts. +# (Still recommend setting explicitly for multi-instance deployments.) +if [ -z "${CSRF_SECRET:-}" ]; then + echo "CSRF_SECRET not provided, resolving persisted secret..." + if [ -f "${CSRF_SECRET_FILE}" ]; then + CSRF_SECRET="$(tr -d '\r\n' < "${CSRF_SECRET_FILE}")" + fi + + if [ -z "${CSRF_SECRET}" ]; then + echo "No persisted CSRF secret found. Generating a new secret..." + CSRF_SECRET="$(openssl rand -base64 32)" + umask 077 + printf "%s" "${CSRF_SECRET}" > "${CSRF_SECRET_FILE}" + fi +else + umask 077 + printf "%s" "${CSRF_SECRET}" > "${CSRF_SECRET_FILE}" +fi + +export CSRF_SECRET + # 1. Hydrate volume if empty (Running as root) if [ ! -f "/app/prisma/schema.prisma" ]; then echo "Mount is empty. Hydrating /app/prisma..." @@ -43,11 +65,12 @@ chown -R nodejs:nodejs /app/uploads chown -R nodejs:nodejs /app/prisma chmod 755 /app/uploads chmod 600 "${JWT_SECRET_FILE}" +chmod 600 "${CSRF_SECRET_FILE}" # Ensure database file has proper permissions if [ -f "/app/prisma/dev.db" ]; then echo "Database file found, ensuring write permissions..." - chmod 666 /app/prisma/dev.db + chmod 600 /app/prisma/dev.db fi # 3. Run Migrations (Drop privileges to nodejs) diff --git a/backend/src/__tests__/auth-enabled.integration.ts b/backend/src/__tests__/auth-enabled.integration.ts new file mode 100644 index 0000000..f2f9eb6 --- /dev/null +++ b/backend/src/__tests__/auth-enabled.integration.ts @@ -0,0 +1,94 @@ +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import request from "supertest"; +import bcrypt from "bcrypt"; +import jwt, { SignOptions } from "jsonwebtoken"; +import { StringValue } from "ms"; +import { PrismaClient } from "../generated/client"; +import { config } from "../config"; +import { getTestPrisma, setupTestDb } from "./testUtils"; + +describe("Auth Enabled Toggle Authorization", () => { + const userAgent = "vitest-auth-enabled"; + let prisma: PrismaClient; + let app: any; + let csrfHeaderName: string; + let csrfToken: string; + let regularUserToken: string; + + beforeAll(async () => { + setupTestDb(); + prisma = getTestPrisma(); + + ({ app } = await import("../index")); + + await prisma.systemConfig.upsert({ + where: { id: "default" }, + update: { + authEnabled: true, + registrationEnabled: false, + }, + create: { + id: "default", + authEnabled: true, + registrationEnabled: false, + }, + }); + + const passwordHash = await bcrypt.hash("password123", 10); + const user = await prisma.user.create({ + data: { + email: "regular-user@test.local", + passwordHash, + name: "Regular User", + role: "USER", + isActive: true, + }, + select: { + id: true, + email: true, + }, + }); + + const signOptions: SignOptions = { + expiresIn: config.jwtAccessExpiresIn as StringValue, + }; + regularUserToken = jwt.sign( + { userId: user.id, email: user.email, type: "access" }, + config.jwtSecret, + signOptions + ); + + const agent = request.agent(app); + const csrfRes = await agent + .get("/csrf-token") + .set("User-Agent", userAgent); + csrfHeaderName = csrfRes.body.header; + csrfToken = csrfRes.body.token; + }); + + afterAll(async () => { + await prisma.$disconnect(); + }); + + it("rejects unauthenticated auth-enabled toggle when auth is enabled", async () => { + const response = await request(app) + .post("/auth/auth-enabled") + .set("User-Agent", userAgent) + .set(csrfHeaderName, csrfToken) + .send({ enabled: false }); + + expect(response.status).toBe(401); + }); + + it("rejects non-admin auth-enabled toggle", async () => { + const response = await request(app) + .post("/auth/auth-enabled") + .set("User-Agent", userAgent) + .set("Authorization", `Bearer ${regularUserToken}`) + .set(csrfHeaderName, csrfToken) + .send({ enabled: false }); + + expect(response.status).toBe(403); + expect(response.body?.message).toContain("Admin access required"); + }); +}); diff --git a/backend/src/__tests__/csrf-cookie-stability.test.ts b/backend/src/__tests__/csrf-cookie-stability.test.ts new file mode 100644 index 0000000..514c4b6 --- /dev/null +++ b/backend/src/__tests__/csrf-cookie-stability.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from "vitest"; +import { createCsrfToken, validateCsrfToken } from "../security"; + +describe("CSRF client identity stability", () => { + it("keeps token validation stable when using cookie-based client IDs", () => { + const cookieClientId = "cookie:fixed-client-id"; + const token = createCsrfToken(cookieClientId); + + expect(validateCsrfToken(cookieClientId, token)).toBe(true); + }); + + it("shows why legacy IP-based IDs are unstable across proxy hops", () => { + const userAgent = "Mozilla/5.0 test"; + const clientIdViaProxyA = `10.0.0.5:${userAgent}`; + const clientIdViaProxyB = `10.0.0.6:${userAgent}`; + const token = createCsrfToken(clientIdViaProxyA); + + expect(validateCsrfToken(clientIdViaProxyB, token)).toBe(false); + }); +}); diff --git a/backend/src/__tests__/imports-compat.integration.ts b/backend/src/__tests__/imports-compat.integration.ts index ada8248..6d32e98 100644 --- a/backend/src/__tests__/imports-compat.integration.ts +++ b/backend/src/__tests__/imports-compat.integration.ts @@ -345,7 +345,9 @@ describe("Import compatibility (legacy exports)", () => { expect.arrayContaining(["legacy-drawing-1", "legacy-drawing-2", "legacy-drawing-trash"]) ); - const trash = await prisma.collection.findUnique({ where: { id: "trash" } }); + const trash = await prisma.collection.findUnique({ + where: { id: "trash:bootstrap-admin" }, + }); expect(trash).toBeTruthy(); }); diff --git a/backend/src/__tests__/preview-update-regression.test.ts b/backend/src/__tests__/preview-update-regression.test.ts new file mode 100644 index 0000000..bccac61 --- /dev/null +++ b/backend/src/__tests__/preview-update-regression.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it } from "vitest"; +import { sanitizeDrawingUpdateData } from "../index"; + +describe("sanitizeDrawingUpdateData regression", () => { + it("does not inject empty scene fields for preview-only updates", () => { + const payload: { + preview?: string | null; + elements?: unknown[]; + appState?: Record; + files?: Record; + } = { + preview: "", + }; + + const ok = sanitizeDrawingUpdateData(payload); + expect(ok).toBe(true); + expect(typeof payload.preview).toBe("string"); + expect(String(payload.preview)).toContain(" { + const payload: { + preview?: string | null; + elements?: any[]; + appState?: Record; + files?: Record; + } = { + elements: [ + { + id: "el-1", + type: "rectangle", + x: 0, + y: 0, + width: 100, + height: 100, + version: 1, + versionNonce: 1, + isDeleted: false, + }, + ], + appState: { viewBackgroundColor: "#ffffff" }, + files: {}, + preview: "", + }; + + const ok = sanitizeDrawingUpdateData(payload); + expect(ok).toBe(true); + expect(Array.isArray(payload.elements)).toBe(true); + expect(typeof payload.appState).toBe("object"); + }); +}); + diff --git a/backend/src/__tests__/testUtils.ts b/backend/src/__tests__/testUtils.ts index 715a511..2a0315e 100644 --- a/backend/src/__tests__/testUtils.ts +++ b/backend/src/__tests__/testUtils.ts @@ -98,11 +98,9 @@ export const setupTestDb = () => { * Clean up the test database between tests */ export const cleanupTestDb = async (prisma: PrismaClient) => { - // Delete all drawings and collections (except Trash) + // Delete all drawings and collections. await prisma.drawing.deleteMany({}); - await prisma.collection.deleteMany({ - where: { id: { not: "trash" } }, - }); + await prisma.collection.deleteMany({}); }; /** @@ -129,14 +127,15 @@ export const createTestUser = async (prisma: PrismaClient, email: string = "test export const initTestDb = async (prisma: PrismaClient) => { // Create a test user first const testUser = await createTestUser(prisma); + const trashCollectionId = `trash:${testUser.id}`; // Ensure Trash collection exists - const trash = await prisma.collection.findUnique({ - where: { id: "trash" }, + const trash = await prisma.collection.findFirst({ + where: { id: trashCollectionId, userId: testUser.id }, }); if (!trash) { await prisma.collection.create({ - data: { id: "trash", name: "Trash", userId: testUser.id }, + data: { id: trashCollectionId, name: "Trash", userId: testUser.id }, }); } diff --git a/backend/src/auth.ts b/backend/src/auth.ts index b01ea21..eac7169 100644 --- a/backend/src/auth.ts +++ b/backend/src/auth.ts @@ -224,12 +224,52 @@ const requireAdmin = ( return true; }; -const getClientId = (req: Request): string => { +const CSRF_CLIENT_COOKIE_NAME = "excalidash-csrf-client"; + +const parseCookies = (cookieHeader: string | undefined): Record => { + if (!cookieHeader) return {}; + const cookies: Record = {}; + for (const part of cookieHeader.split(";")) { + const [rawKey, ...rawValueParts] = part.split("="); + const key = rawKey?.trim(); + if (!key) continue; + const rawValue = rawValueParts.join("=").trim(); + try { + cookies[key] = decodeURIComponent(rawValue); + } catch { + cookies[key] = rawValue; + } + } + return cookies; +}; + +const getCsrfClientCookieValue = (req: Request): string | null => { + const cookies = parseCookies(req.headers.cookie); + const value = cookies[CSRF_CLIENT_COOKIE_NAME]; + if (!value) return null; + if (!/^[A-Za-z0-9_-]{16,128}$/.test(value)) return null; + return value; +}; + +const getLegacyClientId = (req: Request): string => { const ip = req.ip || req.connection.remoteAddress || "unknown"; const userAgent = req.headers["user-agent"] || "unknown"; return `${ip}:${userAgent}`.slice(0, 256); }; +const getCsrfValidationClientIds = (req: Request): string[] => { + const candidates: string[] = []; + const cookieValue = getCsrfClientCookieValue(req); + if (cookieValue) { + candidates.push(`cookie:${cookieValue}`); + } + const legacyClientId = getLegacyClientId(req); + if (!candidates.includes(legacyClientId)) { + candidates.push(legacyClientId); + } + return candidates; +}; + const requireCsrf = (req: Request, res: Response): boolean => { const headerName = getCsrfTokenHeader(); const tokenHeader = req.headers[headerName]; @@ -243,7 +283,9 @@ const requireCsrf = (req: Request, res: Response): boolean => { return false; } - if (!validateCsrfToken(getClientId(req), token)) { + const clientIds = getCsrfValidationClientIds(req); + const isValidToken = clientIds.some((clientId) => validateCsrfToken(clientId, token)); + if (!isValidToken) { res.status(403).json({ error: "CSRF token invalid", message: "Invalid or expired CSRF token. Please refresh and try again.", diff --git a/backend/src/auth/accountRoutes.ts b/backend/src/auth/accountRoutes.ts index fe0bc9a..2a21e3d 100644 --- a/backend/src/auth/accountRoutes.ts +++ b/backend/src/auth/accountRoutes.ts @@ -11,6 +11,7 @@ import { updateEmailSchema, updateProfileSchema, } from "./schemas"; +import { getTokenLookupCandidates, hashTokenForStorage } from "./tokenSecurity"; type RegisterAccountRoutesDeps = { router: express.Router; @@ -81,7 +82,7 @@ export const registerAccountRoutes = (deps: RegisterAccountRoutesDeps) => { }); await prisma.passwordResetToken.create({ - data: { userId: user.id, token: resetToken, expiresAt }, + data: { userId: user.id, token: hashTokenForStorage(resetToken), expiresAt }, }); if (config.enableAuditLogging) { @@ -137,8 +138,10 @@ export const registerAccountRoutes = (deps: RegisterAccountRoutesDeps) => { } const { token, password } = parsed.data; - const resetToken = await prisma.passwordResetToken.findUnique({ - where: { token }, + const resetToken = await prisma.passwordResetToken.findFirst({ + where: { + OR: getTokenLookupCandidates(token).map((candidate) => ({ token: candidate })), + }, include: { user: true }, }); @@ -348,7 +351,11 @@ export const registerAccountRoutes = (deps: RegisterAccountRoutesDeps) => { const expiresAt = getRefreshTokenExpiresAt(); try { await prisma.refreshToken.create({ - data: { userId: updatedUser.id, token: refreshToken, expiresAt }, + data: { + userId: updatedUser.id, + token: hashTokenForStorage(refreshToken), + expiresAt, + }, }); } catch { if (process.env.NODE_ENV === "development") { @@ -525,7 +532,11 @@ export const registerAccountRoutes = (deps: RegisterAccountRoutesDeps) => { const expiresAt = getRefreshTokenExpiresAt(); try { await prisma.refreshToken.create({ - data: { userId: updatedUser.id, token: refreshToken, expiresAt }, + data: { + userId: updatedUser.id, + token: hashTokenForStorage(refreshToken), + expiresAt, + }, }); } catch { if (process.env.NODE_ENV === "development") { diff --git a/backend/src/auth/adminRoutes.ts b/backend/src/auth/adminRoutes.ts index 1c39625..7e5af17 100644 --- a/backend/src/auth/adminRoutes.ts +++ b/backend/src/auth/adminRoutes.ts @@ -11,6 +11,7 @@ import { loginRateLimitUpdateSchema, registrationToggleSchema, } from "./schemas"; +import { hashTokenForStorage } from "./tokenSecurity"; type RegisterAdminRoutesDeps = { router: express.Router; @@ -610,7 +611,7 @@ export const registerAdminRoutes = (deps: RegisterAdminRoutesDeps) => { const expiresAt = getRefreshTokenExpiresAt(); try { await prisma.refreshToken.create({ - data: { userId: target.id, token: refreshToken, expiresAt }, + data: { userId: target.id, token: hashTokenForStorage(refreshToken), expiresAt }, }); } catch { if (process.env.NODE_ENV === "development") { diff --git a/backend/src/auth/coreRoutes.ts b/backend/src/auth/coreRoutes.ts index 1916b30..9d1b4a7 100644 --- a/backend/src/auth/coreRoutes.ts +++ b/backend/src/auth/coreRoutes.ts @@ -9,6 +9,7 @@ import { loginSchema, registerSchema, } from "./schemas"; +import { getTokenLookupCandidates, hashTokenForStorage } from "./tokenSecurity"; type RegisterCoreRoutesDeps = { router: express.Router; @@ -86,6 +87,7 @@ export const registerCoreRoutes = (deps: RegisterCoreRoutesDeps) => { bootstrapUserId, defaultSystemConfigId, } = deps; + const getUserTrashCollectionId = (userId: string): string => `trash:${userId}`; router.post("/register", loginAttemptRateLimiter, async (req: Request, res: Response) => { try { @@ -139,13 +141,14 @@ export const registerCoreRoutes = (deps: RegisterCoreRoutesDeps) => { }, }); - const existingTrash = await prisma.collection.findUnique({ - where: { id: "trash" }, + const trashCollectionId = getUserTrashCollectionId(user.id); + const existingTrash = await prisma.collection.findFirst({ + where: { id: trashCollectionId, userId: user.id }, }); if (!existingTrash) { await prisma.collection.create({ data: { - id: "trash", + id: trashCollectionId, name: "Trash", userId: user.id, }, @@ -157,7 +160,7 @@ export const registerCoreRoutes = (deps: RegisterCoreRoutesDeps) => { if (config.enableRefreshTokenRotation) { const expiresAt = getRefreshTokenExpiresAt(); await prisma.refreshToken.create({ - data: { userId: user.id, token: refreshToken, expiresAt }, + data: { userId: user.id, token: hashTokenForStorage(refreshToken), expiresAt }, }); } @@ -237,13 +240,14 @@ export const registerCoreRoutes = (deps: RegisterCoreRoutesDeps) => { }, }); - const existingTrash = await prisma.collection.findUnique({ - where: { id: "trash" }, + const trashCollectionId = getUserTrashCollectionId(user.id); + const existingTrash = await prisma.collection.findFirst({ + where: { id: trashCollectionId, userId: user.id }, }); if (!existingTrash) { await prisma.collection.create({ data: { - id: "trash", + id: trashCollectionId, name: "Trash", userId: user.id, }, @@ -259,7 +263,7 @@ export const registerCoreRoutes = (deps: RegisterCoreRoutesDeps) => { await prisma.refreshToken.create({ data: { userId: user.id, - token: refreshToken, + token: hashTokenForStorage(refreshToken), expiresAt, }, }); @@ -372,7 +376,7 @@ export const registerCoreRoutes = (deps: RegisterCoreRoutesDeps) => { await prisma.refreshToken.create({ data: { userId: user.id, - token: refreshToken, + token: hashTokenForStorage(refreshToken), expiresAt, }, }); @@ -464,8 +468,12 @@ export const registerCoreRoutes = (deps: RegisterCoreRoutesDeps) => { const expiresAt = getRefreshTokenExpiresAt(); await prisma.$transaction(async (tx) => { - const storedToken = await tx.refreshToken.findUnique({ - where: { token: oldRefreshToken }, + const storedToken = await tx.refreshToken.findFirst({ + where: { + OR: getTokenLookupCandidates(oldRefreshToken).map((candidate) => ({ + token: candidate, + })), + }, }); if (!storedToken || storedToken.userId !== user.id || storedToken.revoked) { @@ -487,7 +495,7 @@ export const registerCoreRoutes = (deps: RegisterCoreRoutesDeps) => { await tx.refreshToken.create({ data: { userId: user.id, - token: newRefreshToken, + token: hashTokenForStorage(newRefreshToken), expiresAt, }, }); @@ -638,9 +646,19 @@ export const registerCoreRoutes = (deps: RegisterCoreRoutesDeps) => { } }); - router.post("/auth-enabled", optionalAuth, async (req: Request, res: Response) => { + router.post("/auth-enabled", requireAuth, async (req: Request, res: Response) => { try { if (!requireCsrf(req, res)) return; + if (!req.user) { + return res + .status(401) + .json({ error: "Unauthorized", message: "User not authenticated" }); + } + if (req.user.role !== "ADMIN") { + return res + .status(403) + .json({ error: "Forbidden", message: "Admin access required" }); + } const parsed = authEnabledToggleSchema.safeParse(req.body); if (!parsed.success) { @@ -653,19 +671,6 @@ export const registerCoreRoutes = (deps: RegisterCoreRoutesDeps) => { const current = systemConfig.authEnabled; const next = parsed.data.enabled; - if (current && !next) { - if (!req.user) { - return res - .status(401) - .json({ error: "Unauthorized", message: "User not authenticated" }); - } - if (req.user.role !== "ADMIN") { - return res - .status(403) - .json({ error: "Forbidden", message: "Admin access required" }); - } - } - if (!current && next) { const bootstrap = await prisma.user.findUnique({ where: { id: bootstrapUserId }, diff --git a/backend/src/auth/tokenSecurity.ts b/backend/src/auth/tokenSecurity.ts new file mode 100644 index 0000000..712b4d4 --- /dev/null +++ b/backend/src/auth/tokenSecurity.ts @@ -0,0 +1,11 @@ +import crypto from "crypto"; + +export const hashTokenForStorage = (token: string): string => + crypto.createHash("sha256").update(token, "utf8").digest("hex"); + +export const getTokenLookupCandidates = (token: string): string[] => { + const candidates = new Set(); + candidates.add(token); + candidates.add(hashTokenForStorage(token)); + return [...candidates]; +}; diff --git a/backend/src/index.ts b/backend/src/index.ts index 1e94912..50690ae 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -202,23 +202,32 @@ const invalidateDrawingsCache = () => { drawingsCache.clear(); }; +const getUserTrashCollectionId = (userId: string): string => `trash:${userId}`; + const ensureTrashCollection = async ( db: Prisma.TransactionClient | PrismaClient, userId: string ): Promise => { - const trashCollection = await db.collection.findUnique({ - where: { id: "trash" }, + const trashCollectionId = getUserTrashCollectionId(userId); + const trashCollection = await db.collection.findFirst({ + where: { id: trashCollectionId, userId }, }); - + if (!trashCollection) { await db.collection.create({ data: { - id: "trash", + id: trashCollectionId, name: "Trash", userId, }, }); } + + // Legacy migration: move this user's drawings off global "trash". + await db.drawing.updateMany({ + where: { userId, collectionId: "trash" }, + data: { collectionId: trashCollectionId }, + }); }; setInterval(() => { @@ -375,13 +384,109 @@ app.use(generalRateLimiter); // CSRF Protection Middleware // Generates a unique client ID based on IP and User-Agent for token association -const getClientId = (req: express.Request): string => { +const CSRF_CLIENT_COOKIE_NAME = "excalidash-csrf-client"; +const CSRF_CLIENT_COOKIE_MAX_AGE_SECONDS = 60 * 60 * 24 * 30; // 30 days + +const parseCookies = (cookieHeader: string | undefined): Record => { + if (!cookieHeader) return {}; + const cookies: Record = {}; + for (const part of cookieHeader.split(";")) { + const [rawKey, ...rawValueParts] = part.split("="); + const key = rawKey?.trim(); + if (!key) continue; + const rawValue = rawValueParts.join("=").trim(); + try { + cookies[key] = decodeURIComponent(rawValue); + } catch { + cookies[key] = rawValue; + } + } + return cookies; +}; + +const getCsrfClientCookieValue = (req: express.Request): string | null => { + const cookies = parseCookies(req.headers.cookie); + const value = cookies[CSRF_CLIENT_COOKIE_NAME]; + if (!value) return null; + if (!/^[A-Za-z0-9_-]{16,128}$/.test(value)) return null; + return value; +}; + +const requestUsesHttps = (req: express.Request): boolean => { + if (req.secure) return true; + const forwardedProto = req.headers["x-forwarded-proto"]; + const raw = Array.isArray(forwardedProto) ? forwardedProto[0] : forwardedProto; + const firstHop = String(raw || "") + .split(",")[0] + .trim() + .toLowerCase(); + return firstHop === "https"; +}; + +const setCsrfClientCookie = (req: express.Request, res: express.Response, value: string): void => { + const secure = requestUsesHttps(req) ? "; Secure" : ""; + res.append( + "Set-Cookie", + `${CSRF_CLIENT_COOKIE_NAME}=${encodeURIComponent( + value + )}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${CSRF_CLIENT_COOKIE_MAX_AGE_SECONDS}${secure}` + ); +}; + +const getLegacyClientId = (req: express.Request): string => { const ip = req.ip || req.connection.remoteAddress || "unknown"; const userAgent = req.headers["user-agent"] || "unknown"; - const clientId = `${ip}:${userAgent}`.slice(0, 256); + return `${ip}:${userAgent}`.slice(0, 256); +}; + +const getClientIdForTokenIssue = ( + req: express.Request, + res: express.Response +): { clientId: string; strategy: "cookie" | "legacy-bootstrap" } => { + const existingCookieValue = getCsrfClientCookieValue(req); + if (existingCookieValue) { + return { + clientId: `cookie:${existingCookieValue}`, + strategy: "cookie", + }; + } + + // No cookie presented by client yet: + // - issue a token bound to legacy identity for compatibility with non-cookie clients + // - still set a cookie so subsequent browser requests can transition to cookie-bound tokens + const generatedCookieValue = uuidv4().replace(/-/g, ""); + setCsrfClientCookie(req, res, generatedCookieValue); + return { + clientId: getLegacyClientId(req), + strategy: "legacy-bootstrap", + }; +}; + +const getClientIdCandidatesForValidation = (req: express.Request): string[] => { + const candidates: string[] = []; + const cookieValue = getCsrfClientCookieValue(req); + if (cookieValue) { + candidates.push(`cookie:${cookieValue}`); + } + + const legacyClientId = getLegacyClientId(req); + if (!candidates.includes(legacyClientId)) { + candidates.push(legacyClientId); + } + + return candidates; +}; + +const getClientIdForTokenIssueDebug = ( + req: express.Request, + res: express.Response +): string => { + const { clientId, strategy } = getClientIdForTokenIssue(req, res); // Debug logging for CSRF troubleshooting (issue #38) if (process.env.DEBUG_CSRF === "true") { + const validationCandidates = getClientIdCandidatesForValidation(req); + const ip = req.ip || req.connection.remoteAddress || "unknown"; console.log("[CSRF DEBUG] getClientId", { method: req.method, path: req.path, @@ -389,9 +494,13 @@ const getClientId = (req: express.Request): string => { remoteAddress: req.connection.remoteAddress, "x-forwarded-for": req.headers["x-forwarded-for"], "x-real-ip": req.headers["x-real-ip"], - userAgent: userAgent.slice(0, 100), + hasCsrfCookie: Boolean(getCsrfClientCookieValue(req)), clientIdPreview: clientId.slice(0, 60) + "...", trustProxySetting: req.app.get("trust proxy"), + strategy, + validationCandidatesPreview: validationCandidates.map((candidate) => + `${candidate.slice(0, 60)}...` + ), }); } @@ -436,7 +545,7 @@ app.get("/csrf-token", (req, res) => { } } - const clientId = getClientId(req); + const clientId = getClientIdForTokenIssueDebug(req, res); const token = createCsrfToken(clientId); res.json({ @@ -487,7 +596,7 @@ const csrfProtectionMiddleware = ( // Some legitimate clients/proxies might strip these, so we don't block strictly on their absence, // but relying on the token is the primary defense. - const clientId = getClientId(req); + const clientIdCandidates = getClientIdCandidatesForValidation(req); const headerName = getCsrfTokenHeader(); const tokenHeader = req.headers[headerName]; const token = Array.isArray(tokenHeader) ? tokenHeader[0] : tokenHeader; @@ -499,7 +608,10 @@ const csrfProtectionMiddleware = ( }); } - if (!validateCsrfToken(clientId, token)) { + const isValidToken = clientIdCandidates.some((clientId) => + validateCsrfToken(clientId, token) + ); + if (!isValidToken) { return res.status(403).json({ error: "CSRF token invalid", message: "Invalid or expired CSRF token. Please refresh and try again.", @@ -555,52 +667,71 @@ const drawingCreateSchema = drawingBaseSchema } ); -const drawingUpdateSchema = drawingBaseSchema +const drawingUpdateSchemaBase = drawingBaseSchema .extend({ elements: elementSchema.array().optional(), appState: appStateSchema.optional(), files: filesFieldSchema, version: z.number().int().positive().optional(), - }) - .refine( - (data) => { - const needsSanitization = - data.elements !== undefined || - data.appState !== undefined || - data.files !== undefined || - data.preview !== undefined; + }); - try { - const sanitizedData = { ...data }; - if (needsSanitization) { - const fullData = { - elements: Array.isArray(data.elements) ? data.elements : [], - appState: - typeof data.appState === "object" && data.appState !== null - ? data.appState - : {}, - files: data.files || {}, - preview: data.preview, - name: data.name, - collectionId: data.collectionId, - }; - const sanitized = sanitizeDrawingData(fullData); - sanitizedData.elements = sanitized.elements; - sanitizedData.appState = sanitized.appState; - if (data.files !== undefined) sanitizedData.files = sanitized.files; - if (data.preview !== undefined) - sanitizedData.preview = sanitized.preview; - Object.assign(data, sanitizedData); - } - return true; - } catch (error) { - console.error("Sanitization failed:", error); - if (!needsSanitization) { - return true; - } - return false; - } - }, +export const sanitizeDrawingUpdateData = ( + data: { + elements?: unknown[]; + appState?: Record; + files?: Record; + preview?: string | null; + name?: string; + collectionId?: string | null; + } +): boolean => { + const hasSceneFields = + data.elements !== undefined || + data.appState !== undefined || + data.files !== undefined; + const hasPreviewField = data.preview !== undefined; + const needsSanitization = hasSceneFields || hasPreviewField; + + try { + const sanitizedData = { ...data }; + if (hasSceneFields) { + const fullData = { + elements: Array.isArray(data.elements) ? data.elements : [], + appState: + typeof data.appState === "object" && data.appState !== null + ? data.appState + : {}, + files: data.files || {}, + preview: data.preview, + name: data.name, + collectionId: data.collectionId, + }; + const sanitized = sanitizeDrawingData(fullData); + sanitizedData.elements = sanitized.elements; + sanitizedData.appState = sanitized.appState; + if (data.files !== undefined) sanitizedData.files = sanitized.files; + if (data.preview !== undefined) sanitizedData.preview = sanitized.preview; + Object.assign(data, sanitizedData); + } else if (hasPreviewField && typeof data.preview === "string") { + // Preview-only updates must not inject default scene fields. + data.preview = sanitizeSvg(data.preview); + Object.assign(data, { ...data, preview: data.preview }); + } else if (hasPreviewField && data.preview === null) { + // Explicitly allow clearing preview without touching scene data. + Object.assign(data, sanitizedData); + } + return true; + } catch (error) { + console.error("Sanitization failed:", error); + if (!needsSanitization) { + return true; + } + return false; + } +}; + +const drawingUpdateSchema = drawingUpdateSchemaBase.refine( + (data) => sanitizeDrawingUpdateData(data as any), { message: "Invalid or malicious drawing data detected", } @@ -726,6 +857,33 @@ const roomUsers = new Map(); // Track which authenticated user owns each socket for authorization checks const socketUserMap = new Map(); +const toPresenceName = (value: unknown): string => { + if (typeof value !== "string") return "User"; + const trimmed = value.trim().slice(0, 120); + return trimmed.length > 0 ? trimmed : "User"; +}; + +const toPresenceInitials = (name: string): string => { + const words = name + .split(/\s+/) + .map((part) => part.trim()) + .filter((part) => part.length > 0); + if (words.length === 0) return "U"; + const first = words[0]?.[0] ?? ""; + const second = words.length > 1 ? words[1]?.[0] ?? "" : ""; + const initials = `${first}${second}`.toUpperCase().slice(0, 2); + return initials.length > 0 ? initials : "U"; +}; + +const toPresenceColor = (value: unknown): string => { + if (typeof value !== "string") return "#4f46e5"; + const trimmed = value.trim(); + if (/^#[0-9a-fA-F]{3,8}$/.test(trimmed)) { + return trimmed; + } + return "#4f46e5"; +}; + /** * Verify JWT from Socket.io auth and check if auth is required. * When auth is disabled (single-user mode), all connections are allowed. @@ -815,10 +973,35 @@ io.on("connection", (socket) => { socket.join(roomId); authorizedDrawingIds.add(drawingId); - const newUser: User = { ...user, socketId: socket.id, isActive: true }; + let trustedUserId = + typeof user?.id === "string" && user.id.trim().length > 0 + ? user.id.trim().slice(0, 200) + : socket.id; + let trustedName = toPresenceName(user?.name); + + // In auth-enabled mode, identity should come from the authenticated account. + if (authenticatedUserId && authenticatedUserId !== "bootstrap-admin") { + const account = await prisma.user.findUnique({ + where: { id: authenticatedUserId }, + select: { id: true, name: true }, + }); + if (account) { + trustedUserId = account.id; + trustedName = toPresenceName(account.name); + } + } + + const newUser: User = { + id: trustedUserId, + name: trustedName, + initials: toPresenceInitials(trustedName), + color: toPresenceColor(user?.color), + socketId: socket.id, + isActive: true, + }; const currentUsers = roomUsers.get(roomId) || []; - const filteredUsers = currentUsers.filter((u) => u.id !== user.id); + const filteredUsers = currentUsers.filter((u) => u.id !== newUser.id); filteredUsers.push(newUser); roomUsers.set(roomId, filteredUsers); diff --git a/backend/src/routes/dashboard.ts b/backend/src/routes/dashboard.ts index c1f82fa..148eadb 100644 --- a/backend/src/routes/dashboard.ts +++ b/backend/src/routes/dashboard.ts @@ -79,11 +79,30 @@ export const registerDashboardRoutes = ( logAuditEvent, } = deps; + const getUserTrashCollectionId = (userId: string): string => `trash:${userId}`; + const isTrashCollectionId = ( + collectionId: string | null | undefined, + userId: string + ): boolean => + Boolean(collectionId) && + (collectionId === "trash" || collectionId === getUserTrashCollectionId(userId)); + const toInternalTrashCollectionId = ( + collectionId: string | null | undefined, + userId: string + ): string | null | undefined => + collectionId === "trash" ? getUserTrashCollectionId(userId) : collectionId; + const toPublicTrashCollectionId = ( + collectionId: string | null | undefined, + userId: string + ): string | null | undefined => + isTrashCollectionId(collectionId, userId) ? "trash" : collectionId; + app.get("/drawings", requireAuth, asyncHandler(async (req, res) => { if (!req.user) { return res.status(401).json({ error: "Unauthorized" }); } + const trashCollectionId = getUserTrashCollectionId(req.user.id); const { search, collectionId, includeData, limit, offset, sortField, sortDirection } = req.query; const where: Prisma.DrawingWhereInput = { userId: req.user.id }; const searchTerm = @@ -100,7 +119,7 @@ export const registerDashboardRoutes = ( } else if (collectionId) { const normalizedCollectionId = String(collectionId); if (normalizedCollectionId === "trash") { - where.collectionId = "trash"; + where.collectionId = { in: [trashCollectionId, "trash"] }; collectionFilterKey = "trash"; } else { const collection = await prisma.collection.findFirst({ @@ -113,7 +132,10 @@ export const registerDashboardRoutes = ( collectionFilterKey = `id:${normalizedCollectionId}`; } } else { - where.OR = [{ collectionId: { not: "trash" } }, { collectionId: null }]; + where.OR = [ + { collectionId: { notIn: [trashCollectionId, "trash"] } }, + { collectionId: null }, + ]; } const shouldIncludeData = @@ -188,10 +210,16 @@ export const registerDashboardRoutes = ( if (shouldIncludeData) { responsePayload = (drawings as any[]).map((d: any) => ({ ...d, + collectionId: toPublicTrashCollectionId(d.collectionId, req.user!.id), elements: parseJsonField(d.elements, []), appState: parseJsonField(d.appState, {}), files: parseJsonField(d.files, {}), })); + } else { + responsePayload = (drawings as any[]).map((d: any) => ({ + ...d, + collectionId: toPublicTrashCollectionId(d.collectionId, req.user!.id), + })); } const finalResponse = { @@ -223,6 +251,7 @@ export const registerDashboardRoutes = ( return res.json({ ...drawing, + collectionId: toPublicTrashCollectionId(drawing.collectionId, req.user.id), elements: parseJsonField(drawing.elements, []), appState: parseJsonField(drawing.appState, {}), files: parseJsonField(drawing.files, {}), @@ -254,14 +283,16 @@ export const registerDashboardRoutes = ( files?: Record; }; const drawingName = payload.name ?? "Untitled Drawing"; - const targetCollectionId = payload.collectionId === undefined ? null : payload.collectionId; + const targetCollectionIdRaw = payload.collectionId === undefined ? null : payload.collectionId; + const targetCollectionId = + toInternalTrashCollectionId(targetCollectionIdRaw, req.user.id) ?? null; - if (targetCollectionId && targetCollectionId !== "trash") { + if (targetCollectionId && !isTrashCollectionId(targetCollectionId, req.user.id)) { const collection = await prisma.collection.findFirst({ where: { id: targetCollectionId, userId: req.user.id }, }); if (!collection) return res.status(404).json({ error: "Collection not found" }); - } else if (targetCollectionId === "trash") { + } else if (targetCollectionIdRaw === "trash") { await ensureTrashCollection(prisma, req.user.id); } @@ -280,6 +311,7 @@ export const registerDashboardRoutes = ( return res.json({ ...newDrawing, + collectionId: toPublicTrashCollectionId(newDrawing.collectionId, req.user.id), elements: parseJsonField(newDrawing.elements, []), appState: parseJsonField(newDrawing.appState, {}), files: parseJsonField(newDrawing.files, {}), @@ -312,11 +344,14 @@ export const registerDashboardRoutes = ( files?: Record; version?: number; }; + const trashCollectionId = getUserTrashCollectionId(req.user.id); const isSceneUpdate = payload.elements !== undefined || payload.appState !== undefined || payload.files !== undefined; - const data: Prisma.DrawingUpdateInput = { version: { increment: 1 } }; + const data: Prisma.DrawingUpdateInput = isSceneUpdate + ? { version: { increment: 1 } } + : {}; if (payload.name !== undefined) data.name = payload.name; if (payload.elements !== undefined) data.elements = JSON.stringify(payload.elements); @@ -327,7 +362,7 @@ export const registerDashboardRoutes = ( if (payload.collectionId !== undefined) { if (payload.collectionId === "trash") { await ensureTrashCollection(prisma, req.user.id); - (data as Prisma.DrawingUncheckedUpdateInput).collectionId = "trash"; + (data as Prisma.DrawingUncheckedUpdateInput).collectionId = trashCollectionId; } else if (payload.collectionId) { const collection = await prisma.collection.findFirst({ where: { id: payload.collectionId, userId: req.user.id }, @@ -374,6 +409,7 @@ export const registerDashboardRoutes = ( return res.json({ ...updatedDrawing, + collectionId: toPublicTrashCollectionId(updatedDrawing.collectionId, req.user.id), elements: parseJsonField(updatedDrawing.elements, []), appState: parseJsonField(updatedDrawing.appState, {}), files: parseJsonField(updatedDrawing.files, {}), @@ -415,8 +451,10 @@ export const registerDashboardRoutes = ( const { id } = req.params; const original = await prisma.drawing.findFirst({ where: { id, userId: req.user.id } }); if (!original) return res.status(404).json({ error: "Original drawing not found" }); - if (original.collectionId === "trash") { + let duplicatedCollectionId = original.collectionId; + if (isTrashCollectionId(original.collectionId, req.user.id)) { await ensureTrashCollection(prisma, req.user.id); + duplicatedCollectionId = getUserTrashCollectionId(req.user.id); } const newDrawing = await prisma.drawing.create({ @@ -426,7 +464,7 @@ export const registerDashboardRoutes = ( appState: original.appState, files: original.files, userId: req.user.id, - collectionId: original.collectionId, + collectionId: duplicatedCollectionId, version: 1, }, }); @@ -434,6 +472,7 @@ export const registerDashboardRoutes = ( return res.json({ ...newDrawing, + collectionId: toPublicTrashCollectionId(newDrawing.collectionId, req.user.id), elements: parseJsonField(newDrawing.elements, []), appState: parseJsonField(newDrawing.appState, {}), files: parseJsonField(newDrawing.files, {}), @@ -442,11 +481,21 @@ export const registerDashboardRoutes = ( app.get("/collections", requireAuth, asyncHandler(async (req, res) => { if (!req.user) return res.status(401).json({ error: "Unauthorized" }); + const trashCollectionId = getUserTrashCollectionId(req.user.id); + await ensureTrashCollection(prisma, req.user.id); - const collections = await prisma.collection.findMany({ + const rawCollections = await prisma.collection.findMany({ where: { userId: req.user.id }, orderBy: { createdAt: "desc" }, }); + const hasInternalTrash = rawCollections.some((collection) => collection.id === trashCollectionId); + const collections = rawCollections + .filter((collection) => !(hasInternalTrash && collection.id === "trash")) + .map((collection) => + collection.id === trashCollectionId + ? { ...collection, id: "trash", name: "Trash" } + : collection + ); return res.json(collections); })); @@ -472,6 +521,12 @@ export const registerDashboardRoutes = ( if (!req.user) return res.status(401).json({ error: "Unauthorized" }); const { id } = req.params; + if (isTrashCollectionId(id, req.user.id)) { + return res.status(400).json({ + error: "Validation error", + message: "Trash collection cannot be renamed", + }); + } const existingCollection = await prisma.collection.findFirst({ where: { id, userId: req.user.id }, }); @@ -506,6 +561,12 @@ export const registerDashboardRoutes = ( if (!req.user) return res.status(401).json({ error: "Unauthorized" }); const { id } = req.params; + if (isTrashCollectionId(id, req.user.id)) { + return res.status(400).json({ + error: "Validation error", + message: "Trash collection cannot be deleted", + }); + } const collection = await prisma.collection.findFirst({ where: { id, userId: req.user.id }, }); diff --git a/backend/src/routes/importExport.ts b/backend/src/routes/importExport.ts index 33a8fcc..4206703 100644 --- a/backend/src/routes/importExport.ts +++ b/backend/src/routes/importExport.ts @@ -146,6 +146,21 @@ const normalizeNonEmptyId = (value: unknown): string | null => { return trimmed.length > 0 ? trimmed : null; }; +const getUserTrashCollectionId = (userId: string): string => `trash:${userId}`; + +const isTrashCollectionId = ( + collectionId: string | null | undefined, + userId: string +): boolean => + Boolean(collectionId) && + (collectionId === "trash" || collectionId === getUserTrashCollectionId(userId)); + +const toPublicTrashCollectionId = ( + collectionId: string | null | undefined, + userId: string +): string | null => + isTrashCollectionId(collectionId, userId) ? "trash" : collectionId ?? null; + const findSqliteTable = (tables: string[], candidates: string[]): string | null => { const byLower = new Map(tables.map((t) => [t.toLowerCase(), t])); for (const candidate of candidates) { @@ -264,6 +279,7 @@ export const registerImportExportRoutes = (deps: RegisterImportExportDeps) => { app.get("/export/excalidash", requireAuth, asyncHandler(async (req, res) => { if (!req.user) return res.status(401).json({ error: "Unauthorized" }); + const trashCollectionId = getUserTrashCollectionId(req.user.id); const extParam = typeof req.query.ext === "string" ? req.query.ext.toLowerCase() : ""; const zipSuffix = extParam === "zip"; @@ -281,10 +297,23 @@ export const registerImportExportRoutes = (deps: RegisterImportExportDeps) => { where: { userId: req.user.id }, }); - const hasTrashDrawings = drawings.some((d) => d.collectionId === "trash"); - const collectionsToExport = [...userCollections]; - if (hasTrashDrawings && !collectionsToExport.some((c) => c.id === "trash")) { - const trash = await prisma.collection.findUnique({ where: { id: "trash" } }); + const hasInternalTrashCollection = userCollections.some((collection) => collection.id === trashCollectionId); + const normalizedUserCollections = userCollections.filter( + (collection) => !(hasInternalTrashCollection && collection.id === "trash") + ); + const hasTrashDrawings = drawings.some((drawing) => + isTrashCollectionId(drawing.collectionId, req.user!.id) + ); + const collectionsToExport = [...normalizedUserCollections]; + if ( + hasTrashDrawings && + !collectionsToExport.some((collection) => + isTrashCollectionId(collection.id, req.user!.id) + ) + ) { + const trash = await prisma.collection.findFirst({ + where: { userId: req.user.id, id: { in: [trashCollectionId, "trash"] } }, + }); if (trash) collectionsToExport.push(trash); } @@ -309,13 +338,23 @@ export const registerImportExportRoutes = (deps: RegisterImportExportDeps) => { id: drawing.id, name: drawing.name, filePath: `${folder}/${fileName}`, - collectionId: drawing.collectionId ?? null, + collectionId: toPublicTrashCollectionId(drawing.collectionId, req.user!.id), version: drawing.version, createdAt: drawing.createdAt.toISOString(), updatedAt: drawing.updatedAt.toISOString(), }; }); + const manifestCollections = collectionsToExport + .map((collection) => ({ + id: toPublicTrashCollectionId(collection.id, req.user!.id) || collection.id, + name: isTrashCollectionId(collection.id, req.user!.id) ? "Trash" : collection.name, + folder: folderByCollectionId.get(collection.id) || sanitizePathSegment(collection.name, "Collection"), + createdAt: collection.createdAt.toISOString(), + updatedAt: collection.updatedAt.toISOString(), + })) + .filter((collection, index, all) => all.findIndex((c) => c.id === collection.id) === index); + const manifest = { format: "excalidash" as const, formatVersion: 1 as const, @@ -323,13 +362,7 @@ export const registerImportExportRoutes = (deps: RegisterImportExportDeps) => { excalidashBackendVersion: getBackendVersion(), userId: req.user.id, unorganizedFolder, - collections: collectionsToExport.map((c) => ({ - id: c.id, - name: c.name, - folder: folderByCollectionId.get(c.id) || sanitizePathSegment(c.name, "Collection"), - createdAt: c.createdAt.toISOString(), - updatedAt: c.updatedAt.toISOString(), - })), + collections: manifestCollections, drawings: drawingsManifest, }; @@ -657,6 +690,7 @@ Drawings: ${drawings.length} } const result = await prisma.$transaction(async (tx) => { + const trashCollectionId = getUserTrashCollectionId(req.user!.id); const collectionIdMap = new Map(); let collectionsCreated = 0; let collectionsUpdated = 0; @@ -672,7 +706,7 @@ Drawings: ${drawings.length} for (const c of manifest.collections) { if (c.id === "trash") { - collectionIdMap.set("trash", "trash"); + collectionIdMap.set("trash", trashCollectionId); continue; } @@ -707,7 +741,7 @@ Drawings: ${drawings.length} const resolveCollectionId = (collectionId: string | null): string | null => { if (!collectionId) return null; - if (collectionId === "trash") return "trash"; + if (collectionId === "trash") return trashCollectionId; return collectionIdMap.get(collectionId) || null; }; @@ -1006,6 +1040,7 @@ Drawings: ${drawings.length} } const result = await prisma.$transaction(async (tx) => { + const trashCollectionId = getUserTrashCollectionId(req.user!.id); const hasTrash = importedDrawings.some((d) => String(d.collectionId || "") === "trash"); if (hasTrash) await ensureTrashCollection(tx, req.user!.id); @@ -1022,7 +1057,7 @@ Drawings: ${drawings.length} const name = typeof c.name === "string" ? c.name : "Collection"; if (importedId === "trash" || name === "Trash") { - collectionIdMap.set(importedId || "trash", "trash"); + collectionIdMap.set(importedId || "trash", trashCollectionId); continue; } @@ -1071,7 +1106,7 @@ Drawings: ${drawings.length} const id = typeof rawCollectionId === "string" ? rawCollectionId : null; const name = typeof rawCollectionName === "string" ? rawCollectionName : null; - if (id === "trash" || name === "Trash") return "trash"; + if (id === "trash" || name === "Trash") return trashCollectionId; if (id && collectionIdMap.has(id)) return collectionIdMap.get(id)!; if (name && collectionIdMap.has(`__name:${name}`)) return collectionIdMap.get(`__name:${name}`)!; return null; diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index 268578a..6d12719 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -10,8 +10,7 @@ services: # if unset, backend auto-generates and persists one in the volume. # Recommended to set explicitly for portability and multi-instance setups. - JWT_SECRET=${JWT_SECRET} - # Required for horizontal scaling (k8s): uncomment and set to same value on all instances - # - CSRF_SECRET=${CSRF_SECRET} + - CSRF_SECRET=${CSRF_SECRET} volumes: - backend-data:/app/prisma networks: diff --git a/docker-compose.yml b/docker-compose.yml index 184d77c..01daa80 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -12,8 +12,7 @@ services: # if unset, backend auto-generates and persists one in the volume. # Recommended to set explicitly for portability and multi-instance setups. - JWT_SECRET=${JWT_SECRET} - # Required for horizontal scaling (k8s): uncomment and set to same value on all instances - # - CSRF_SECRET=${CSRF_SECRET} + - CSRF_SECRET=${CSRF_SECRET} volumes: - backend-data:/app/prisma networks: diff --git a/frontend/nginx.conf b/frontend/nginx.conf index 7dc06ea..64bfd59 100644 --- a/frontend/nginx.conf +++ b/frontend/nginx.conf @@ -6,6 +6,14 @@ http { include /etc/nginx/mime.types; default_type application/octet-stream; + # Preserve upstream TLS context (if present) when proxying to backend. + # Falls back to current scheme when no forwarded proto is provided. + map $http_x_forwarded_proto $forwarded_proto { + default $scheme; + ~*^https(?:,|$) https; + ~*^http(?:,|$) http; + } + sendfile on; keepalive_timeout 65; gzip on; @@ -21,6 +29,12 @@ http { root /usr/share/nginx/html; index index.html; + add_header X-Frame-Options "DENY" always; + add_header X-Content-Type-Options "nosniff" always; + add_header Referrer-Policy "strict-origin-when-cross-origin" always; + add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always; + add_header Content-Security-Policy "default-src 'self'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'; script-src 'self'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com data:; img-src 'self' data: blob: https:; connect-src 'self' https: ws: wss:;" always; + # API and WebSocket proxy to backend location /api/ { proxy_pass http://backend:8000/; @@ -31,7 +45,7 @@ http { proxy_cache_bypass $http_upgrade; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-Proto $forwarded_proto; # Buffer and timeout settings for large payloads proxy_buffering on; @@ -56,7 +70,7 @@ http { proxy_cache_bypass $http_upgrade; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-Proto $forwarded_proto; } # Frontend routes diff --git a/frontend/nginx.conf.template b/frontend/nginx.conf.template index df759ac..2945ae1 100644 --- a/frontend/nginx.conf.template +++ b/frontend/nginx.conf.template @@ -6,6 +6,14 @@ http { include /etc/nginx/mime.types; default_type application/octet-stream; + # Preserve upstream TLS context (if present) when proxying to backend. + # Falls back to current scheme when no forwarded proto is provided. + map $http_x_forwarded_proto $forwarded_proto { + default $scheme; + ~*^https(?:,|$) https; + ~*^http(?:,|$) http; + } + sendfile on; keepalive_timeout 65; gzip on; @@ -21,6 +29,12 @@ http { root /usr/share/nginx/html; index index.html; + add_header X-Frame-Options "DENY" always; + add_header X-Content-Type-Options "nosniff" always; + add_header Referrer-Policy "strict-origin-when-cross-origin" always; + add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always; + add_header Content-Security-Policy "default-src 'self'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'; script-src 'self'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com data:; img-src 'self' data: blob: https:; connect-src 'self' https: ws: wss:;" always; + # API and WebSocket proxy to backend # BACKEND_URL is substituted at container startup (default: backend:8000) location /api/ { @@ -32,7 +46,7 @@ http { proxy_cache_bypass $http_upgrade; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-Proto $forwarded_proto; # Buffer and timeout settings for large payloads proxy_buffering on; @@ -57,7 +71,7 @@ http { proxy_cache_bypass $http_upgrade; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-Proto $forwarded_proto; # Longer timeouts for WebSocket connections proxy_read_timeout 3600s; diff --git a/frontend/package-lock.json b/frontend/package-lock.json index da7738b..ccf45ed 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,16 +1,16 @@ { "name": "frontend", - "version": "0.4.1", + "version": "0.4.5", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "frontend", - "version": "0.4.1", + "version": "0.4.5", "dependencies": { "@dnd-kit/core": "^6.3.1", "@dnd-kit/utilities": "^3.2.2", - "@excalidraw/excalidraw": "^0.18.0", + "@excalidraw/excalidraw": "0.17.6", "@types/lodash": "^4.17.20", "axios": "^1.13.2", "clsx": "^2.1.1", @@ -369,6 +369,7 @@ "version": "7.28.4", "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.4.tgz", "integrity": "sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==", + "dev": true, "license": "MIT", "engines": { "node": ">=6.9.0" @@ -422,12 +423,6 @@ "node": ">=6.9.0" } }, - "node_modules/@braintree/sanitize-url": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/@braintree/sanitize-url/-/sanitize-url-6.0.2.tgz", - "integrity": "sha512-Tbsj02wXCbqGmzdnXNk0SOF19ChhRU70BsroIi4Pm6Ehp56in6vch94mfbdQ17DozxkL3BAVjbZ4Qc1a0HFRAg==", - "license": "MIT" - }, "node_modules/@csstools/color-helpers": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz", @@ -1202,438 +1197,15 @@ } }, "node_modules/@excalidraw/excalidraw": { - "version": "0.18.0", - "resolved": "https://registry.npmjs.org/@excalidraw/excalidraw/-/excalidraw-0.18.0.tgz", - "integrity": "sha512-QkIiS+5qdy8lmDWTKsuy0sK/fen/LRDtbhm2lc2xcFcqhv2/zdg95bYnl+wnwwXGHo7kEmP65BSiMHE7PJ3Zpw==", + "version": "0.17.6", + "resolved": "https://registry.npmjs.org/@excalidraw/excalidraw/-/excalidraw-0.17.6.tgz", + "integrity": "sha512-fyCl+zG/Z5yhHDh5Fq2ZGmphcrALmuOdtITm8gN4d8w4ntnaopTXcTfnAAaU3VleDC6LhTkoLOTG6P5kgREiIg==", "license": "MIT", - "dependencies": { - "@braintree/sanitize-url": "6.0.2", - "@excalidraw/laser-pointer": "1.3.1", - "@excalidraw/mermaid-to-excalidraw": "1.1.2", - "@excalidraw/random-username": "1.1.0", - "@radix-ui/react-popover": "1.1.6", - "@radix-ui/react-tabs": "1.0.2", - "browser-fs-access": "0.29.1", - "canvas-roundrect-polyfill": "0.0.1", - "clsx": "1.1.1", - "cross-env": "7.0.3", - "es6-promise-pool": "2.5.0", - "fractional-indexing": "3.2.0", - "fuzzy": "0.1.3", - "image-blob-reduce": "3.0.1", - "jotai": "2.11.0", - "jotai-scope": "0.7.2", - "lodash.debounce": "4.0.8", - "lodash.throttle": "4.1.1", - "nanoid": "3.3.3", - "open-color": "1.9.1", - "pako": "2.0.3", - "perfect-freehand": "1.2.0", - "pica": "7.1.1", - "png-chunk-text": "1.0.0", - "png-chunks-encode": "1.0.0", - "png-chunks-extract": "1.0.0", - "points-on-curve": "1.0.1", - "pwacompat": "2.0.17", - "roughjs": "4.6.4", - "sass": "1.51.0", - "tunnel-rat": "0.1.2" - }, "peerDependencies": { - "react": "^17.0.2 || ^18.2.0 || ^19.0.0", - "react-dom": "^17.0.2 || ^18.2.0 || ^19.0.0" + "react": "^17.0.2 || ^18.2.0", + "react-dom": "^17.0.2 || ^18.2.0" } }, - "node_modules/@excalidraw/excalidraw/node_modules/@radix-ui/primitive": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.0.0.tgz", - "integrity": "sha512-3e7rn8FDMin4CgeL7Z/49smCA3rFYY3Ha2rUQ7HRWFadS5iCRw08ZgVT1LaNTCNqgvrUiyczLflrVrF0SRQtNA==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.13.10" - } - }, - "node_modules/@excalidraw/excalidraw/node_modules/@radix-ui/react-tabs": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@radix-ui/react-tabs/-/react-tabs-1.0.2.tgz", - "integrity": "sha512-gOUwh+HbjCuL0UCo8kZ+kdUEG8QtpdO4sMQduJ34ZEz0r4922g9REOBM+vIsfwtGxSug4Yb1msJMJYN2Bk8TpQ==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.13.10", - "@radix-ui/primitive": "1.0.0", - "@radix-ui/react-context": "1.0.0", - "@radix-ui/react-direction": "1.0.0", - "@radix-ui/react-id": "1.0.0", - "@radix-ui/react-presence": "1.0.0", - "@radix-ui/react-primitive": "1.0.1", - "@radix-ui/react-roving-focus": "1.0.2", - "@radix-ui/react-use-controllable-state": "1.0.0" - }, - "peerDependencies": { - "react": "^16.8 || ^17.0 || ^18.0", - "react-dom": "^16.8 || ^17.0 || ^18.0" - } - }, - "node_modules/@excalidraw/excalidraw/node_modules/@radix-ui/react-tabs/node_modules/@radix-ui/react-context": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.0.0.tgz", - "integrity": "sha512-1pVM9RfOQ+n/N5PJK33kRSKsr1glNxomxONs5c49MliinBY6Yw2Q995qfBUUo0/Mbg05B/sGA0gkgPI7kmSHBg==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.13.10" - }, - "peerDependencies": { - "react": "^16.8 || ^17.0 || ^18.0" - } - }, - "node_modules/@excalidraw/excalidraw/node_modules/@radix-ui/react-tabs/node_modules/@radix-ui/react-direction": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.0.0.tgz", - "integrity": "sha512-2HV05lGUgYcA6xgLQ4BKPDmtL+QbIZYH5fCOTAOOcJ5O0QbWS3i9lKaurLzliYUDhORI2Qr3pyjhJh44lKA3rQ==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.13.10" - }, - "peerDependencies": { - "react": "^16.8 || ^17.0 || ^18.0" - } - }, - "node_modules/@excalidraw/excalidraw/node_modules/@radix-ui/react-tabs/node_modules/@radix-ui/react-id": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.0.0.tgz", - "integrity": "sha512-Q6iAB/U7Tq3NTolBBQbHTgclPmGWE3OlktGGqrClPozSw4vkQ1DfQAOtzgRPecKsMdJINE05iaoDUG8tRzCBjw==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.13.10", - "@radix-ui/react-use-layout-effect": "1.0.0" - }, - "peerDependencies": { - "react": "^16.8 || ^17.0 || ^18.0" - } - }, - "node_modules/@excalidraw/excalidraw/node_modules/@radix-ui/react-tabs/node_modules/@radix-ui/react-id/node_modules/@radix-ui/react-use-layout-effect": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.0.0.tgz", - "integrity": "sha512-6Tpkq+R6LOlmQb1R5NNETLG0B4YP0wc+klfXafpUCj6JGyaUc8il7/kUZ7m59rGbXGczE9Bs+iz2qloqsZBduQ==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.13.10" - }, - "peerDependencies": { - "react": "^16.8 || ^17.0 || ^18.0" - } - }, - "node_modules/@excalidraw/excalidraw/node_modules/@radix-ui/react-tabs/node_modules/@radix-ui/react-presence": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.0.0.tgz", - "integrity": "sha512-A+6XEvN01NfVWiKu38ybawfHsBjWum42MRPnEuqPsBZ4eV7e/7K321B5VgYMPv3Xx5An6o1/l9ZuDBgmcmWK3w==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.13.10", - "@radix-ui/react-compose-refs": "1.0.0", - "@radix-ui/react-use-layout-effect": "1.0.0" - }, - "peerDependencies": { - "react": "^16.8 || ^17.0 || ^18.0", - "react-dom": "^16.8 || ^17.0 || ^18.0" - } - }, - "node_modules/@excalidraw/excalidraw/node_modules/@radix-ui/react-tabs/node_modules/@radix-ui/react-presence/node_modules/@radix-ui/react-compose-refs": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.0.0.tgz", - "integrity": "sha512-0KaSv6sx787/hK3eF53iOkiSLwAGlFMx5lotrqD2pTjB18KbybKoEIgkNZTKC60YECDQTKGTRcDBILwZVqVKvA==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.13.10" - }, - "peerDependencies": { - "react": "^16.8 || ^17.0 || ^18.0" - } - }, - "node_modules/@excalidraw/excalidraw/node_modules/@radix-ui/react-tabs/node_modules/@radix-ui/react-presence/node_modules/@radix-ui/react-use-layout-effect": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.0.0.tgz", - "integrity": "sha512-6Tpkq+R6LOlmQb1R5NNETLG0B4YP0wc+klfXafpUCj6JGyaUc8il7/kUZ7m59rGbXGczE9Bs+iz2qloqsZBduQ==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.13.10" - }, - "peerDependencies": { - "react": "^16.8 || ^17.0 || ^18.0" - } - }, - "node_modules/@excalidraw/excalidraw/node_modules/@radix-ui/react-tabs/node_modules/@radix-ui/react-primitive": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-1.0.1.tgz", - "integrity": "sha512-fHbmislWVkZaIdeF6GZxF0A/NH/3BjrGIYj+Ae6eTmTCr7EB0RQAAVEiqsXK6p3/JcRqVSBQoceZroj30Jj3XA==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.13.10", - "@radix-ui/react-slot": "1.0.1" - }, - "peerDependencies": { - "react": "^16.8 || ^17.0 || ^18.0", - "react-dom": "^16.8 || ^17.0 || ^18.0" - } - }, - "node_modules/@excalidraw/excalidraw/node_modules/@radix-ui/react-tabs/node_modules/@radix-ui/react-primitive/node_modules/@radix-ui/react-slot": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.0.1.tgz", - "integrity": "sha512-avutXAFL1ehGvAXtPquu0YK5oz6ctS474iM3vNGQIkswrVhdrS52e3uoMQBzZhNRAIE0jBnUyXWNmSjGHhCFcw==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.13.10", - "@radix-ui/react-compose-refs": "1.0.0" - }, - "peerDependencies": { - "react": "^16.8 || ^17.0 || ^18.0" - } - }, - "node_modules/@excalidraw/excalidraw/node_modules/@radix-ui/react-tabs/node_modules/@radix-ui/react-primitive/node_modules/@radix-ui/react-slot/node_modules/@radix-ui/react-compose-refs": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.0.0.tgz", - "integrity": "sha512-0KaSv6sx787/hK3eF53iOkiSLwAGlFMx5lotrqD2pTjB18KbybKoEIgkNZTKC60YECDQTKGTRcDBILwZVqVKvA==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.13.10" - }, - "peerDependencies": { - "react": "^16.8 || ^17.0 || ^18.0" - } - }, - "node_modules/@excalidraw/excalidraw/node_modules/@radix-ui/react-tabs/node_modules/@radix-ui/react-roving-focus": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.0.2.tgz", - "integrity": "sha512-HLK+CqD/8pN6GfJm3U+cqpqhSKYAWiOJDe+A+8MfxBnOue39QEeMa43csUn2CXCHQT0/mewh1LrrG4tfkM9DMA==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.13.10", - "@radix-ui/primitive": "1.0.0", - "@radix-ui/react-collection": "1.0.1", - "@radix-ui/react-compose-refs": "1.0.0", - "@radix-ui/react-context": "1.0.0", - "@radix-ui/react-direction": "1.0.0", - "@radix-ui/react-id": "1.0.0", - "@radix-ui/react-primitive": "1.0.1", - "@radix-ui/react-use-callback-ref": "1.0.0", - "@radix-ui/react-use-controllable-state": "1.0.0" - }, - "peerDependencies": { - "react": "^16.8 || ^17.0 || ^18.0", - "react-dom": "^16.8 || ^17.0 || ^18.0" - } - }, - "node_modules/@excalidraw/excalidraw/node_modules/@radix-ui/react-tabs/node_modules/@radix-ui/react-roving-focus/node_modules/@radix-ui/react-collection": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.0.1.tgz", - "integrity": "sha512-uuiFbs+YCKjn3X1DTSx9G7BHApu4GHbi3kgiwsnFUbOKCrwejAJv4eE4Vc8C0Oaxt9T0aV4ox0WCOdx+39Xo+g==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.13.10", - "@radix-ui/react-compose-refs": "1.0.0", - "@radix-ui/react-context": "1.0.0", - "@radix-ui/react-primitive": "1.0.1", - "@radix-ui/react-slot": "1.0.1" - }, - "peerDependencies": { - "react": "^16.8 || ^17.0 || ^18.0", - "react-dom": "^16.8 || ^17.0 || ^18.0" - } - }, - "node_modules/@excalidraw/excalidraw/node_modules/@radix-ui/react-tabs/node_modules/@radix-ui/react-roving-focus/node_modules/@radix-ui/react-collection/node_modules/@radix-ui/react-slot": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.0.1.tgz", - "integrity": "sha512-avutXAFL1ehGvAXtPquu0YK5oz6ctS474iM3vNGQIkswrVhdrS52e3uoMQBzZhNRAIE0jBnUyXWNmSjGHhCFcw==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.13.10", - "@radix-ui/react-compose-refs": "1.0.0" - }, - "peerDependencies": { - "react": "^16.8 || ^17.0 || ^18.0" - } - }, - "node_modules/@excalidraw/excalidraw/node_modules/@radix-ui/react-tabs/node_modules/@radix-ui/react-roving-focus/node_modules/@radix-ui/react-compose-refs": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.0.0.tgz", - "integrity": "sha512-0KaSv6sx787/hK3eF53iOkiSLwAGlFMx5lotrqD2pTjB18KbybKoEIgkNZTKC60YECDQTKGTRcDBILwZVqVKvA==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.13.10" - }, - "peerDependencies": { - "react": "^16.8 || ^17.0 || ^18.0" - } - }, - "node_modules/@excalidraw/excalidraw/node_modules/@radix-ui/react-tabs/node_modules/@radix-ui/react-roving-focus/node_modules/@radix-ui/react-use-callback-ref": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.0.0.tgz", - "integrity": "sha512-GZtyzoHz95Rhs6S63D2t/eqvdFCm7I+yHMLVQheKM7nBD8mbZIt+ct1jz4536MDnaOGKIxynJ8eHTkVGVVkoTg==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.13.10" - }, - "peerDependencies": { - "react": "^16.8 || ^17.0 || ^18.0" - } - }, - "node_modules/@excalidraw/excalidraw/node_modules/@radix-ui/react-tabs/node_modules/@radix-ui/react-use-controllable-state": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.0.0.tgz", - "integrity": "sha512-FohDoZvk3mEXh9AWAVyRTYR4Sq7/gavuofglmiXB2g1aKyboUD4YtgWxKj8O5n+Uak52gXQ4wKz5IFST4vtJHg==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.13.10", - "@radix-ui/react-use-callback-ref": "1.0.0" - }, - "peerDependencies": { - "react": "^16.8 || ^17.0 || ^18.0" - } - }, - "node_modules/@excalidraw/excalidraw/node_modules/@radix-ui/react-tabs/node_modules/@radix-ui/react-use-controllable-state/node_modules/@radix-ui/react-use-callback-ref": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.0.0.tgz", - "integrity": "sha512-GZtyzoHz95Rhs6S63D2t/eqvdFCm7I+yHMLVQheKM7nBD8mbZIt+ct1jz4536MDnaOGKIxynJ8eHTkVGVVkoTg==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.13.10" - }, - "peerDependencies": { - "react": "^16.8 || ^17.0 || ^18.0" - } - }, - "node_modules/@excalidraw/excalidraw/node_modules/clsx": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/clsx/-/clsx-1.1.1.tgz", - "integrity": "sha512-6/bPho624p3S2pMyvP5kKBPXnI3ufHLObBFCfgx+LkeR5lg2XYy2hqZqUf45ypD8COn2bhgGJSUE+l5dhNBieA==", - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/@excalidraw/excalidraw/node_modules/immutable": { - "version": "4.3.7", - "resolved": "https://registry.npmjs.org/immutable/-/immutable-4.3.7.tgz", - "integrity": "sha512-1hqclzwYwjRDFLjcFxOM5AYkkG0rpFPpr1RLPMEuGczoS7YA8gLhy8SWXYRAA/XwfEHpfo3cw5JGioS32fnMRw==", - "license": "MIT" - }, - "node_modules/@excalidraw/excalidraw/node_modules/nanoid": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.3.tgz", - "integrity": "sha512-p1sjXuopFs0xg+fPASzQ28agW1oHD7xDsd9Xkf3T15H3c/cifrFHVwrh74PdoklAPi+i7MdRsE47vm2r6JoB+w==", - "license": "MIT", - "bin": { - "nanoid": "bin/nanoid.cjs" - }, - "engines": { - "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" - } - }, - "node_modules/@excalidraw/excalidraw/node_modules/sass": { - "version": "1.51.0", - "resolved": "https://registry.npmjs.org/sass/-/sass-1.51.0.tgz", - "integrity": "sha512-haGdpTgywJTvHC2b91GSq+clTKGbtkkZmVAb82jZQN/wTy6qs8DdFm2lhEQbEwrY0QDRgSQ3xDurqM977C3noA==", - "license": "MIT", - "dependencies": { - "chokidar": ">=3.0.0 <4.0.0", - "immutable": "^4.0.0", - "source-map-js": ">=0.6.2 <2.0.0" - }, - "bin": { - "sass": "sass.js" - }, - "engines": { - "node": ">=12.0.0" - } - }, - "node_modules/@excalidraw/laser-pointer": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/@excalidraw/laser-pointer/-/laser-pointer-1.3.1.tgz", - "integrity": "sha512-psA1z1N2qeAfsORdXc9JmD2y4CmDwmuMRxnNdJHZexIcPwaNEyIpNcelw+QkL9rz9tosaN9krXuKaRqYpRAR6g==", - "license": "MIT" - }, - "node_modules/@excalidraw/markdown-to-text": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/@excalidraw/markdown-to-text/-/markdown-to-text-0.1.2.tgz", - "integrity": "sha512-1nDXBNAojfi3oSFwJswKREkFm5wrSjqay81QlyRv2pkITG/XYB5v+oChENVBQLcxQwX4IUATWvXM5BcaNhPiIg==", - "license": "MIT" - }, - "node_modules/@excalidraw/mermaid-to-excalidraw": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@excalidraw/mermaid-to-excalidraw/-/mermaid-to-excalidraw-1.1.2.tgz", - "integrity": "sha512-hAFv/TTIsOdoy0dL5v+oBd297SQ+Z88gZ5u99fCIFuEMHfQuPgLhU/ztKhFSTs7fISwVo6fizny/5oQRR3d4tQ==", - "license": "MIT", - "dependencies": { - "@excalidraw/markdown-to-text": "0.1.2", - "mermaid": "10.9.3", - "nanoid": "4.0.2" - } - }, - "node_modules/@excalidraw/mermaid-to-excalidraw/node_modules/nanoid": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-4.0.2.tgz", - "integrity": "sha512-7ZtY5KTCNheRGfEFxnedV5zFiORN1+Y1N6zvPTnHQd8ENUvfaDBeuJDZb2bN/oXwXxu3qkTXDzy57W5vAmDTBw==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "bin": { - "nanoid": "bin/nanoid.js" - }, - "engines": { - "node": "^14 || ^16 || >=18" - } - }, - "node_modules/@excalidraw/random-username": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@excalidraw/random-username/-/random-username-1.1.0.tgz", - "integrity": "sha512-nULYsQxkWHnbmHvcs+efMkJ4/9TtvNyFeLyHdeGxW0zHs6P+jYVqcRff9A6Vq9w9JXeDRnRh2VKvTtS19GW2qA==", - "license": "MIT", - "engines": { - "node": ">=10" - } - }, - "node_modules/@floating-ui/core": { - "version": "1.7.3", - "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.3.tgz", - "integrity": "sha512-sGnvb5dmrJaKEZ+LDIpguvdX3bDlEllmv4/ClQ9awcmCZrlx5jQyyMWFM5kBI+EyNOCDDiKk8il0zeuX3Zlg/w==", - "license": "MIT", - "dependencies": { - "@floating-ui/utils": "^0.2.10" - } - }, - "node_modules/@floating-ui/dom": { - "version": "1.7.4", - "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.4.tgz", - "integrity": "sha512-OOchDgh4F2CchOX94cRVqhvy7b3AFb+/rQXyswmzmGakRfkMgoWVjfnLWkRirfLEfuD4ysVW16eXzwt3jHIzKA==", - "license": "MIT", - "dependencies": { - "@floating-ui/core": "^1.7.3", - "@floating-ui/utils": "^0.2.10" - } - }, - "node_modules/@floating-ui/react-dom": { - "version": "2.1.6", - "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.6.tgz", - "integrity": "sha512-4JX6rEatQEvlmgU80wZyq9RT96HZJa88q8hp0pBd+LrczeDI4o6uA2M+uvxngVHo4Ihr8uibXxH6+70zhAFrVw==", - "license": "MIT", - "dependencies": { - "@floating-ui/dom": "^1.7.4" - }, - "peerDependencies": { - "react": ">=16.8.0", - "react-dom": ">=16.8.0" - } - }, - "node_modules/@floating-ui/utils": { - "version": "0.2.10", - "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.10.tgz", - "integrity": "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==", - "license": "MIT" - }, "node_modules/@humanfs/core": { "version": "0.19.1", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", @@ -1790,416 +1362,6 @@ "node": ">=18" } }, - "node_modules/@radix-ui/primitive": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.1.tgz", - "integrity": "sha512-SJ31y+Q/zAyShtXJc8x83i9TYdbAfHZ++tUZnvjJJqFjzsdUnKsxPL6IEtBlxKkU7yzer//GQtZSV4GbldL3YA==", - "license": "MIT" - }, - "node_modules/@radix-ui/react-arrow": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.2.tgz", - "integrity": "sha512-G+KcpzXHq24iH0uGG/pF8LyzpFJYGD4RfLjCIBfGdSLXvjLHST31RUiRVrupIBMvIppMgSzQ6l66iAxl03tdlg==", - "license": "MIT", - "dependencies": { - "@radix-ui/react-primitive": "2.0.2" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-compose-refs": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.1.tgz", - "integrity": "sha512-Y9VzoRDSJtgFMUCoiZBDVo084VQ5hfpXxVE+NgkdNsjiDBByiImMZKKhxMwCbdHvhlENG6a833CbFkOQvTricw==", - "license": "MIT", - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-context": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.1.tgz", - "integrity": "sha512-UASk9zi+crv9WteK/NU4PLvOoL3OuE6BWVKNF6hPRBtYBDXQ2u5iu3O59zUlJiTVvkyuycnqrztsHVJwcK9K+Q==", - "license": "MIT", - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-dismissable-layer": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.5.tgz", - "integrity": "sha512-E4TywXY6UsXNRhFrECa5HAvE5/4BFcGyfTyK36gP+pAW1ed7UTK4vKwdr53gAJYwqbfCWC6ATvJa3J3R/9+Qrg==", - "license": "MIT", - "dependencies": { - "@radix-ui/primitive": "1.1.1", - "@radix-ui/react-compose-refs": "1.1.1", - "@radix-ui/react-primitive": "2.0.2", - "@radix-ui/react-use-callback-ref": "1.1.0", - "@radix-ui/react-use-escape-keydown": "1.1.0" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-focus-guards": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.1.tgz", - "integrity": "sha512-pSIwfrT1a6sIoDASCSpFwOasEwKTZWDw/iBdtnqKO7v6FeOzYJ7U53cPzYFVR3geGGXgVHaH+CdngrrAzqUGxg==", - "license": "MIT", - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-focus-scope": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-1.1.2.tgz", - "integrity": "sha512-zxwE80FCU7lcXUGWkdt6XpTTCKPitG1XKOwViTxHVKIJhZl9MvIl2dVHeZENCWD9+EdWv05wlaEkRXUykU27RA==", - "license": "MIT", - "dependencies": { - "@radix-ui/react-compose-refs": "1.1.1", - "@radix-ui/react-primitive": "2.0.2", - "@radix-ui/react-use-callback-ref": "1.1.0" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-id": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.0.tgz", - "integrity": "sha512-EJUrI8yYh7WOjNOqpoJaf1jlFIH2LvtgAl+YcFqNCa+4hj64ZXmPkAKOFs/ukjz3byN6bdb/AVUqHkI8/uWWMA==", - "license": "MIT", - "dependencies": { - "@radix-ui/react-use-layout-effect": "1.1.0" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-popover": { - "version": "1.1.6", - "resolved": "https://registry.npmjs.org/@radix-ui/react-popover/-/react-popover-1.1.6.tgz", - "integrity": "sha512-NQouW0x4/GnkFJ/pRqsIS3rM/k97VzKnVb2jB7Gq7VEGPy5g7uNV1ykySFt7eWSp3i2uSGFwaJcvIRJBAHmmFg==", - "license": "MIT", - "dependencies": { - "@radix-ui/primitive": "1.1.1", - "@radix-ui/react-compose-refs": "1.1.1", - "@radix-ui/react-context": "1.1.1", - "@radix-ui/react-dismissable-layer": "1.1.5", - "@radix-ui/react-focus-guards": "1.1.1", - "@radix-ui/react-focus-scope": "1.1.2", - "@radix-ui/react-id": "1.1.0", - "@radix-ui/react-popper": "1.2.2", - "@radix-ui/react-portal": "1.1.4", - "@radix-ui/react-presence": "1.1.2", - "@radix-ui/react-primitive": "2.0.2", - "@radix-ui/react-slot": "1.1.2", - "@radix-ui/react-use-controllable-state": "1.1.0", - "aria-hidden": "^1.2.4", - "react-remove-scroll": "^2.6.3" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-popper": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.2.2.tgz", - "integrity": "sha512-Rvqc3nOpwseCyj/rgjlJDYAgyfw7OC1tTkKn2ivhaMGcYt8FSBlahHOZak2i3QwkRXUXgGgzeEe2RuqeEHuHgA==", - "license": "MIT", - "dependencies": { - "@floating-ui/react-dom": "^2.0.0", - "@radix-ui/react-arrow": "1.1.2", - "@radix-ui/react-compose-refs": "1.1.1", - "@radix-ui/react-context": "1.1.1", - "@radix-ui/react-primitive": "2.0.2", - "@radix-ui/react-use-callback-ref": "1.1.0", - "@radix-ui/react-use-layout-effect": "1.1.0", - "@radix-ui/react-use-rect": "1.1.0", - "@radix-ui/react-use-size": "1.1.0", - "@radix-ui/rect": "1.1.0" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-portal": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.4.tgz", - "integrity": "sha512-sn2O9k1rPFYVyKd5LAJfo96JlSGVFpa1fS6UuBJfrZadudiw5tAmru+n1x7aMRQ84qDM71Zh1+SzK5QwU0tJfA==", - "license": "MIT", - "dependencies": { - "@radix-ui/react-primitive": "2.0.2", - "@radix-ui/react-use-layout-effect": "1.1.0" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-presence": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.2.tgz", - "integrity": "sha512-18TFr80t5EVgL9x1SwF/YGtfG+l0BS0PRAlCWBDoBEiDQjeKgnNZRVJp/oVBl24sr3Gbfwc/Qpj4OcWTQMsAEg==", - "license": "MIT", - "dependencies": { - "@radix-ui/react-compose-refs": "1.1.1", - "@radix-ui/react-use-layout-effect": "1.1.0" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-primitive": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.0.2.tgz", - "integrity": "sha512-Ec/0d38EIuvDF+GZjcMU/Ze6MxntVJYO/fRlCPhCaVUyPY9WTalHJw54tp9sXeJo3tlShWpy41vQRgLRGOuz+w==", - "license": "MIT", - "dependencies": { - "@radix-ui/react-slot": "1.1.2" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-slot": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.1.2.tgz", - "integrity": "sha512-YAKxaiGsSQJ38VzKH86/BPRC4rh+b1Jpa+JneA5LRE7skmLPNAyeG8kPJj/oo4STLvlrs8vkf/iYyc3A5stYCQ==", - "license": "MIT", - "dependencies": { - "@radix-ui/react-compose-refs": "1.1.1" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-use-callback-ref": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.0.tgz", - "integrity": "sha512-CasTfvsy+frcFkbXtSJ2Zu9JHpN8TYKxkgJGWbjiZhFivxaeW7rMeZt7QELGVLaYVfFMsKHjb7Ak0nMEe+2Vfw==", - "license": "MIT", - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-use-controllable-state": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.1.0.tgz", - "integrity": "sha512-MtfMVJiSr2NjzS0Aa90NPTnvTSg6C/JLCV7ma0W6+OMV78vd8OyRpID+Ng9LxzsPbLeuBnWBA1Nq30AtBIDChw==", - "license": "MIT", - "dependencies": { - "@radix-ui/react-use-callback-ref": "1.1.0" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-use-escape-keydown": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-escape-keydown/-/react-use-escape-keydown-1.1.0.tgz", - "integrity": "sha512-L7vwWlR1kTTQ3oh7g1O0CBF3YCyyTj8NmhLR+phShpyA50HCfBFKVJTpshm9PzLiKmehsrQzTYTpX9HvmC9rhw==", - "license": "MIT", - "dependencies": { - "@radix-ui/react-use-callback-ref": "1.1.0" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-use-layout-effect": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.0.tgz", - "integrity": "sha512-+FPE0rOdziWSrH9athwI1R0HDVbWlEhd+FR+aSDk4uWGmSJ9Z54sdZVDQPZAinJhJXwfT+qnj969mCsT2gfm5w==", - "license": "MIT", - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-use-rect": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-rect/-/react-use-rect-1.1.0.tgz", - "integrity": "sha512-0Fmkebhr6PiseyZlYAOtLS+nb7jLmpqTrJyv61Pe68MKYW6OWdRE2kI70TaYY27u7H0lajqM3hSMMLFq18Z7nQ==", - "license": "MIT", - "dependencies": { - "@radix-ui/rect": "1.1.0" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-use-size": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-size/-/react-use-size-1.1.0.tgz", - "integrity": "sha512-XW3/vWuIXHa+2Uwcc2ABSfcCledmXhhQPlGbfcRXbiUQI5Icjcg19BGCZVKKInYbvUCut/ufbbLLPFC5cbb1hw==", - "license": "MIT", - "dependencies": { - "@radix-ui/react-use-layout-effect": "1.1.0" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/rect": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/rect/-/rect-1.1.0.tgz", - "integrity": "sha512-A9+lCBZoaMJlVKcRBz2YByCG+Cp2t6nAnMnNba+XiWxnj6r4JUFqfsgwocMBZU9LPtdxC6wB56ySYpc7LQIoJg==", - "license": "MIT" - }, "node_modules/@rolldown/pluginutils": { "version": "1.0.0-beta.47", "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.47.tgz", @@ -2668,36 +1830,6 @@ "assertion-error": "^2.0.1" } }, - "node_modules/@types/d3-scale": { - "version": "4.0.9", - "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz", - "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==", - "license": "MIT", - "dependencies": { - "@types/d3-time": "*" - } - }, - "node_modules/@types/d3-scale-chromatic": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/@types/d3-scale-chromatic/-/d3-scale-chromatic-3.1.0.tgz", - "integrity": "sha512-iWMJgwkK7yTRmWqRB5plb1kadXyQ5Sj8V/zYlFGMUBbIPKQScw+Dku9cAAMgJG+z5GYDoMjWGLVOvjghDEFnKQ==", - "license": "MIT" - }, - "node_modules/@types/d3-time": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz", - "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==", - "license": "MIT" - }, - "node_modules/@types/debug": { - "version": "4.1.12", - "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.12.tgz", - "integrity": "sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==", - "license": "MIT", - "dependencies": { - "@types/ms": "*" - } - }, "node_modules/@types/deep-eql": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", @@ -2737,21 +1869,6 @@ "integrity": "sha512-H3MHACvFUEiujabxhaI/ImO6gUrd8oOurg7LQtS7mbwIXA/cUqWrvBsaeJ23aZEPk1TAYkurjfMbSELfoCXlGA==", "license": "MIT" }, - "node_modules/@types/mdast": { - "version": "3.0.15", - "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-3.0.15.tgz", - "integrity": "sha512-LnwD+mUEfxWMa1QpDraczIn6k0Ee3SMicuYSSzS6ZYl2gKS09EClnJYGd8Du6rfc5r/GZEk5o1mRb8TaTj03sQ==", - "license": "MIT", - "dependencies": { - "@types/unist": "^2" - } - }, - "node_modules/@types/ms": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", - "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", - "license": "MIT" - }, "node_modules/@types/node": { "version": "24.10.1", "resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.1.tgz", @@ -2766,14 +1883,14 @@ "version": "15.7.15", "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", - "devOptional": true, + "dev": true, "license": "MIT" }, "node_modules/@types/react": { "version": "18.3.1", "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.1.tgz", "integrity": "sha512-V0kuGBX3+prX+DQ/7r2qsv1NsdfnCLnTgnRJ1pYnxykBhGMz+qj+box5lq7XsO5mtZsBqpjwwTu/7wszPfMBcw==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "@types/prop-types": "*", @@ -2784,7 +1901,7 @@ "version": "18.3.1", "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.1.tgz", "integrity": "sha512-qW1Mfv8taImTthu4KoXgDfLuk4bydU6Q/TkADnDWWHwi4NX4BR+LWfTp2sVmTqRrsHvyDDTelgelxJ+SsejKKQ==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "@types/react": "*" @@ -2797,12 +1914,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@types/unist": { - "version": "2.0.11", - "resolved": "https://registry.npmjs.org/@types/unist/-/unist-2.0.11.tgz", - "integrity": "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==", - "license": "MIT" - }, "node_modules/@types/whatwg-mimetype": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/@types/whatwg-mimetype/-/whatwg-mimetype-3.0.2.tgz", @@ -3301,6 +2412,7 @@ "version": "3.1.3", "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, "license": "ISC", "dependencies": { "normalize-path": "^3.0.0", @@ -3324,18 +2436,6 @@ "dev": true, "license": "Python-2.0" }, - "node_modules/aria-hidden": { - "version": "1.2.6", - "resolved": "https://registry.npmjs.org/aria-hidden/-/aria-hidden-1.2.6.tgz", - "integrity": "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==", - "license": "MIT", - "dependencies": { - "tslib": "^2.0.0" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/aria-query": { "version": "5.3.0", "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", @@ -3442,6 +2542,7 @@ "version": "2.3.0", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -3465,6 +2566,7 @@ "version": "3.0.3", "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, "license": "MIT", "dependencies": { "fill-range": "^7.1.1" @@ -3473,12 +2575,6 @@ "node": ">=8" } }, - "node_modules/browser-fs-access": { - "version": "0.29.1", - "resolved": "https://registry.npmjs.org/browser-fs-access/-/browser-fs-access-0.29.1.tgz", - "integrity": "sha512-LSvVX5e21LRrXqVMhqtAwj5xPgDb+fXAIH80NsnCQ9xuZPs2xWsOREi24RKgZa1XOiQRbcmVrv87+ulOKsgjxw==", - "license": "Apache-2.0" - }, "node_modules/browserslist": { "version": "4.28.0", "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.0.tgz", @@ -3567,12 +2663,6 @@ ], "license": "CC-BY-4.0" }, - "node_modules/canvas-roundrect-polyfill": { - "version": "0.0.1", - "resolved": "https://registry.npmjs.org/canvas-roundrect-polyfill/-/canvas-roundrect-polyfill-0.0.1.tgz", - "integrity": "sha512-yWq+R3U3jE+coOeEb3a3GgE2j/0MMiDKM/QpLb6h9ihf5fGY9UXtvK9o4vNqjWXoZz7/3EaSVU3IX53TvFFUOw==", - "license": "MIT" - }, "node_modules/chai": { "version": "6.2.1", "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.1.tgz", @@ -3600,20 +2690,11 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, - "node_modules/character-entities": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/character-entities/-/character-entities-2.0.2.tgz", - "integrity": "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, "node_modules/chokidar": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dev": true, "license": "MIT", "dependencies": { "anymatch": "~3.1.2", @@ -3638,6 +2719,7 @@ "version": "5.1.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, "license": "ISC", "dependencies": { "is-glob": "^4.0.1" @@ -3687,15 +2769,6 @@ "node": ">= 0.8" } }, - "node_modules/commander": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/commander/-/commander-7.2.0.tgz", - "integrity": "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==", - "license": "MIT", - "engines": { - "node": ">= 10" - } - }, "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", @@ -3723,46 +2796,11 @@ "url": "https://opencollective.com/express" } }, - "node_modules/cose-base": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/cose-base/-/cose-base-1.0.3.tgz", - "integrity": "sha512-s9whTXInMSgAp/NVXVNuVxVKzGH2qck3aQlVHxDCdAEPgtMKwc4Wq6/QKhgdEdgbLSi9rBTAcPoRa6JpiG4ksg==", - "license": "MIT", - "dependencies": { - "layout-base": "^1.0.0" - } - }, - "node_modules/crc-32": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/crc-32/-/crc-32-0.3.0.tgz", - "integrity": "sha512-kucVIjOmMc1f0tv53BJ/5WIX+MGLcKuoBhnGqQrgKJNqLByb/sVMWfW/Aw6hw0jgcqjJ2pi9E5y32zOIpaUlsA==", - "license": "Apache-2.0", - "engines": { - "node": ">=0.8" - } - }, - "node_modules/cross-env": { - "version": "7.0.3", - "resolved": "https://registry.npmjs.org/cross-env/-/cross-env-7.0.3.tgz", - "integrity": "sha512-+/HKd6EgcQCJGh2PSjZuUitQBQynKor4wrFbRg4DtAgS1aWO+gU52xpH7M9ScGgXSYmAVS9bIJ8EzuaGw0oNAw==", - "license": "MIT", - "dependencies": { - "cross-spawn": "^7.0.1" - }, - "bin": { - "cross-env": "src/bin/cross-env.js", - "cross-env-shell": "src/bin/cross-env-shell.js" - }, - "engines": { - "node": ">=10.14", - "npm": ">=6", - "yarn": ">=1" - } - }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, "license": "MIT", "dependencies": { "path-key": "^3.1.0", @@ -3826,481 +2864,9 @@ "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "devOptional": true, + "dev": true, "license": "MIT" }, - "node_modules/cytoscape": { - "version": "3.33.1", - "resolved": "https://registry.npmjs.org/cytoscape/-/cytoscape-3.33.1.tgz", - "integrity": "sha512-iJc4TwyANnOGR1OmWhsS9ayRS3s+XQ185FmuHObThD+5AeJCakAAbWv8KimMTt08xCCLNgneQwFp+JRJOr9qGQ==", - "license": "MIT", - "engines": { - "node": ">=0.10" - } - }, - "node_modules/cytoscape-cose-bilkent": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/cytoscape-cose-bilkent/-/cytoscape-cose-bilkent-4.1.0.tgz", - "integrity": "sha512-wgQlVIUJF13Quxiv5e1gstZ08rnZj2XaLHGoFMYXz7SkNfCDOOteKBE6SYRfA9WxxI/iBc3ajfDoc6hb/MRAHQ==", - "license": "MIT", - "dependencies": { - "cose-base": "^1.0.0" - }, - "peerDependencies": { - "cytoscape": "^3.2.0" - } - }, - "node_modules/d3": { - "version": "7.9.0", - "resolved": "https://registry.npmjs.org/d3/-/d3-7.9.0.tgz", - "integrity": "sha512-e1U46jVP+w7Iut8Jt8ri1YsPOvFpg46k+K8TpCb0P+zjCkjkPnV7WzfDJzMHy1LnA+wj5pLT1wjO901gLXeEhA==", - "license": "ISC", - "dependencies": { - "d3-array": "3", - "d3-axis": "3", - "d3-brush": "3", - "d3-chord": "3", - "d3-color": "3", - "d3-contour": "4", - "d3-delaunay": "6", - "d3-dispatch": "3", - "d3-drag": "3", - "d3-dsv": "3", - "d3-ease": "3", - "d3-fetch": "3", - "d3-force": "3", - "d3-format": "3", - "d3-geo": "3", - "d3-hierarchy": "3", - "d3-interpolate": "3", - "d3-path": "3", - "d3-polygon": "3", - "d3-quadtree": "3", - "d3-random": "3", - "d3-scale": "4", - "d3-scale-chromatic": "3", - "d3-selection": "3", - "d3-shape": "3", - "d3-time": "3", - "d3-time-format": "4", - "d3-timer": "3", - "d3-transition": "3", - "d3-zoom": "3" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-array": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", - "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", - "license": "ISC", - "dependencies": { - "internmap": "1 - 2" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-axis": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/d3-axis/-/d3-axis-3.0.0.tgz", - "integrity": "sha512-IH5tgjV4jE/GhHkRV0HiVYPDtvfjHQlQfJHs0usq7M30XcSBvOotpmH1IgkcXsO/5gEQZD43B//fc7SRT5S+xw==", - "license": "ISC", - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-brush": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/d3-brush/-/d3-brush-3.0.0.tgz", - "integrity": "sha512-ALnjWlVYkXsVIGlOsuWH1+3udkYFI48Ljihfnh8FZPF2QS9o+PzGLBslO0PjzVoHLZ2KCVgAM8NVkXPJB2aNnQ==", - "license": "ISC", - "dependencies": { - "d3-dispatch": "1 - 3", - "d3-drag": "2 - 3", - "d3-interpolate": "1 - 3", - "d3-selection": "3", - "d3-transition": "3" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-chord": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/d3-chord/-/d3-chord-3.0.1.tgz", - "integrity": "sha512-VE5S6TNa+j8msksl7HwjxMHDM2yNK3XCkusIlpX5kwauBfXuyLAtNg9jCp/iHH61tgI4sb6R/EIMWCqEIdjT/g==", - "license": "ISC", - "dependencies": { - "d3-path": "1 - 3" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-color": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", - "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", - "license": "ISC", - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-contour": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/d3-contour/-/d3-contour-4.0.2.tgz", - "integrity": "sha512-4EzFTRIikzs47RGmdxbeUvLWtGedDUNkTcmzoeyg4sP/dvCexO47AaQL7VKy/gul85TOxw+IBgA8US2xwbToNA==", - "license": "ISC", - "dependencies": { - "d3-array": "^3.2.0" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-delaunay": { - "version": "6.0.4", - "resolved": "https://registry.npmjs.org/d3-delaunay/-/d3-delaunay-6.0.4.tgz", - "integrity": "sha512-mdjtIZ1XLAM8bm/hx3WwjfHt6Sggek7qH043O8KEjDXN40xi3vx/6pYSVTwLjEgiXQTbvaouWKynLBiUZ6SK6A==", - "license": "ISC", - "dependencies": { - "delaunator": "5" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-dispatch": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz", - "integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==", - "license": "ISC", - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-drag": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-3.0.0.tgz", - "integrity": "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==", - "license": "ISC", - "dependencies": { - "d3-dispatch": "1 - 3", - "d3-selection": "3" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-dsv": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/d3-dsv/-/d3-dsv-3.0.1.tgz", - "integrity": "sha512-UG6OvdI5afDIFP9w4G0mNq50dSOsXHJaRE8arAS5o9ApWnIElp8GZw1Dun8vP8OyHOZ/QJUKUJwxiiCCnUwm+Q==", - "license": "ISC", - "dependencies": { - "commander": "7", - "iconv-lite": "0.6", - "rw": "1" - }, - "bin": { - "csv2json": "bin/dsv2json.js", - "csv2tsv": "bin/dsv2dsv.js", - "dsv2dsv": "bin/dsv2dsv.js", - "dsv2json": "bin/dsv2json.js", - "json2csv": "bin/json2dsv.js", - "json2dsv": "bin/json2dsv.js", - "json2tsv": "bin/json2dsv.js", - "tsv2csv": "bin/dsv2dsv.js", - "tsv2json": "bin/dsv2json.js" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-ease": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", - "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", - "license": "BSD-3-Clause", - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-fetch": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/d3-fetch/-/d3-fetch-3.0.1.tgz", - "integrity": "sha512-kpkQIM20n3oLVBKGg6oHrUchHM3xODkTzjMoj7aWQFq5QEM+R6E4WkzT5+tojDY7yjez8KgCBRoj4aEr99Fdqw==", - "license": "ISC", - "dependencies": { - "d3-dsv": "1 - 3" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-force": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/d3-force/-/d3-force-3.0.0.tgz", - "integrity": "sha512-zxV/SsA+U4yte8051P4ECydjD/S+qeYtnaIyAs9tgHCqfguma/aAQDjo85A9Z6EKhBirHRJHXIgJUlffT4wdLg==", - "license": "ISC", - "dependencies": { - "d3-dispatch": "1 - 3", - "d3-quadtree": "1 - 3", - "d3-timer": "1 - 3" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-format": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.0.tgz", - "integrity": "sha512-YyUI6AEuY/Wpt8KWLgZHsIU86atmikuoOmCfommt0LYHiQSPjvX2AcFc38PX0CBpr2RCyZhjex+NS/LPOv6YqA==", - "license": "ISC", - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-geo": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/d3-geo/-/d3-geo-3.1.1.tgz", - "integrity": "sha512-637ln3gXKXOwhalDzinUgY83KzNWZRKbYubaG+fGVuc/dxO64RRljtCTnf5ecMyE1RIdtqpkVcq0IbtU2S8j2Q==", - "license": "ISC", - "dependencies": { - "d3-array": "2.5.0 - 3" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-hierarchy": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/d3-hierarchy/-/d3-hierarchy-3.1.2.tgz", - "integrity": "sha512-FX/9frcub54beBdugHjDCdikxThEqjnR93Qt7PvQTOHxyiNCAlvMrHhclk3cD5VeAaq9fxmfRp+CnWw9rEMBuA==", - "license": "ISC", - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-interpolate": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", - "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", - "license": "ISC", - "dependencies": { - "d3-color": "1 - 3" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-path": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz", - "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==", - "license": "ISC", - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-polygon": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/d3-polygon/-/d3-polygon-3.0.1.tgz", - "integrity": "sha512-3vbA7vXYwfe1SYhED++fPUQlWSYTTGmFmQiany/gdbiWgU/iEyQzyymwL9SkJjFFuCS4902BSzewVGsHHmHtXg==", - "license": "ISC", - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-quadtree": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/d3-quadtree/-/d3-quadtree-3.0.1.tgz", - "integrity": "sha512-04xDrxQTDTCFwP5H6hRhsRcb9xxv2RzkcsygFzmkSIOJy3PeRJP7sNk3VRIbKXcog561P9oU0/rVH6vDROAgUw==", - "license": "ISC", - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-random": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/d3-random/-/d3-random-3.0.1.tgz", - "integrity": "sha512-FXMe9GfxTxqd5D6jFsQ+DJ8BJS4E/fT5mqqdjovykEB2oFbTMDVdg1MGFxfQW+FBOGoB++k8swBrgwSHT1cUXQ==", - "license": "ISC", - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-sankey": { - "version": "0.12.3", - "resolved": "https://registry.npmjs.org/d3-sankey/-/d3-sankey-0.12.3.tgz", - "integrity": "sha512-nQhsBRmM19Ax5xEIPLMY9ZmJ/cDvd1BG3UVvt5h3WRxKg5zGRbvnteTyWAbzeSvlh3tW7ZEmq4VwR5mB3tutmQ==", - "license": "BSD-3-Clause", - "dependencies": { - "d3-array": "1 - 2", - "d3-shape": "^1.2.0" - } - }, - "node_modules/d3-sankey/node_modules/d3-array": { - "version": "2.12.1", - "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-2.12.1.tgz", - "integrity": "sha512-B0ErZK/66mHtEsR1TkPEEkwdy+WDesimkM5gpZr5Dsg54BiTA5RXtYW5qTLIAcekaS9xfZrzBLF/OAkB3Qn1YQ==", - "license": "BSD-3-Clause", - "dependencies": { - "internmap": "^1.0.0" - } - }, - "node_modules/d3-sankey/node_modules/d3-path": { - "version": "1.0.9", - "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-1.0.9.tgz", - "integrity": "sha512-VLaYcn81dtHVTjEHd8B+pbe9yHWpXKZUC87PzoFmsFrJqgFwDe/qxfp5MlfsfM1V5E/iVt0MmEbWQ7FVIXh/bg==", - "license": "BSD-3-Clause" - }, - "node_modules/d3-sankey/node_modules/d3-shape": { - "version": "1.3.7", - "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-1.3.7.tgz", - "integrity": "sha512-EUkvKjqPFUAZyOlhY5gzCxCeI0Aep04LwIRpsZ/mLFelJiUfnK56jo5JMDSE7yyP2kLSb6LtF+S5chMk7uqPqw==", - "license": "BSD-3-Clause", - "dependencies": { - "d3-path": "1" - } - }, - "node_modules/d3-sankey/node_modules/internmap": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/internmap/-/internmap-1.0.1.tgz", - "integrity": "sha512-lDB5YccMydFBtasVtxnZ3MRBHuaoE8GKsppq+EchKL2U4nK/DmEpPHNH8MZe5HkMtpSiTSOZwfN0tzYjO/lJEw==", - "license": "ISC" - }, - "node_modules/d3-scale": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", - "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==", - "license": "ISC", - "dependencies": { - "d3-array": "2.10.0 - 3", - "d3-format": "1 - 3", - "d3-interpolate": "1.2.0 - 3", - "d3-time": "2.1.1 - 3", - "d3-time-format": "2 - 4" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-scale-chromatic": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/d3-scale-chromatic/-/d3-scale-chromatic-3.1.0.tgz", - "integrity": "sha512-A3s5PWiZ9YCXFye1o246KoscMWqf8BsD9eRiJ3He7C9OBaxKhAd5TFCdEx/7VbKtxxTsu//1mMJFrEt572cEyQ==", - "license": "ISC", - "dependencies": { - "d3-color": "1 - 3", - "d3-interpolate": "1 - 3" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-selection": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", - "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", - "license": "ISC", - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-shape": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", - "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==", - "license": "ISC", - "dependencies": { - "d3-path": "^3.1.0" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-time": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz", - "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==", - "license": "ISC", - "dependencies": { - "d3-array": "2 - 3" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-time-format": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz", - "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", - "license": "ISC", - "dependencies": { - "d3-time": "1 - 3" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-timer": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", - "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", - "license": "ISC", - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-transition": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/d3-transition/-/d3-transition-3.0.1.tgz", - "integrity": "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==", - "license": "ISC", - "dependencies": { - "d3-color": "1 - 3", - "d3-dispatch": "1 - 3", - "d3-ease": "1 - 3", - "d3-interpolate": "1 - 3", - "d3-timer": "1 - 3" - }, - "engines": { - "node": ">=12" - }, - "peerDependencies": { - "d3-selection": "2 - 3" - } - }, - "node_modules/d3-zoom": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/d3-zoom/-/d3-zoom-3.0.0.tgz", - "integrity": "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==", - "license": "ISC", - "dependencies": { - "d3-dispatch": "1 - 3", - "d3-drag": "2 - 3", - "d3-interpolate": "1 - 3", - "d3-selection": "2 - 3", - "d3-transition": "2 - 3" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/dagre-d3-es": { - "version": "7.0.10", - "resolved": "https://registry.npmjs.org/dagre-d3-es/-/dagre-d3-es-7.0.10.tgz", - "integrity": "sha512-qTCQmEhcynucuaZgY5/+ti3X/rnszKZhEQH/ZdWdtP1tA/y3VoHJzcVrO9pjjJCNpigfscAtoUB5ONcd2wNn0A==", - "license": "MIT", - "dependencies": { - "d3": "^7.8.2", - "lodash-es": "^4.17.21" - } - }, "node_modules/data-urls": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-6.0.0.tgz", @@ -4335,16 +2901,11 @@ "url": "https://github.com/sponsors/kossnocorp" } }, - "node_modules/dayjs": { - "version": "1.11.19", - "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.19.tgz", - "integrity": "sha512-t5EcLVS6QPBNqM2z8fakk/NKel+Xzshgt8FFKAn+qwlD1pzZWxh0nVCrvFK7ZDb6XucZeF9z8C7CBWTRIVApAw==", - "license": "MIT" - }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, "license": "MIT", "dependencies": { "ms": "^2.1.3" @@ -4365,19 +2926,6 @@ "dev": true, "license": "MIT" }, - "node_modules/decode-named-character-reference": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.2.0.tgz", - "integrity": "sha512-c6fcElNV6ShtZXmsgNgFFV5tVX2PaV4g+MOAkb8eXHvn6sryJBrZa9r0zV6+dtTyoCKxtDy5tyQ5ZwQuidtd+Q==", - "license": "MIT", - "dependencies": { - "character-entities": "^2.0.0" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, "node_modules/deep-is": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", @@ -4385,15 +2933,6 @@ "dev": true, "license": "MIT" }, - "node_modules/delaunator": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/delaunator/-/delaunator-5.0.1.tgz", - "integrity": "sha512-8nvh+XBe96aCESrGOqMp/84b13H9cdKbG5P2ejQCh4d4sK9RL4371qou9drQjMhvnPmhWl5hnmqbEE0fXr9Xnw==", - "license": "ISC", - "dependencies": { - "robust-predicates": "^3.0.2" - } - }, "node_modules/delayed-stream": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", @@ -4407,17 +2946,12 @@ "version": "2.0.3", "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "dev": true, "license": "MIT", "engines": { "node": ">=6" } }, - "node_modules/detect-node-es": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/detect-node-es/-/detect-node-es-1.1.0.tgz", - "integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==", - "license": "MIT" - }, "node_modules/didyoumean": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", @@ -4425,15 +2959,6 @@ "dev": true, "license": "Apache-2.0" }, - "node_modules/diff": { - "version": "5.2.2", - "resolved": "https://registry.npmjs.org/diff/-/diff-5.2.2.tgz", - "integrity": "sha512-vtcDfH3TOjP8UekytvnHH1o1P4FcUdt4eQ1Y+Abap1tk/OB2MWQvcwS2ClCd1zuIhc3JKOx6p3kod8Vfys3E+A==", - "license": "BSD-3-Clause", - "engines": { - "node": ">=0.3.1" - } - }, "node_modules/dlv": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", @@ -4449,12 +2974,6 @@ "license": "MIT", "peer": true }, - "node_modules/dompurify": { - "version": "3.1.6", - "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.1.6.tgz", - "integrity": "sha512-cTOAhc36AalkjtBpfG6O8JimdTMWNXjiePT2xQH/ppBGi/4uIpmj8eKyIkMJErXWARyINV/sB38yf8JCLF5pbQ==", - "license": "(MPL-2.0 OR Apache-2.0)" - }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", @@ -4476,12 +2995,6 @@ "dev": true, "license": "ISC" }, - "node_modules/elkjs": { - "version": "0.9.3", - "resolved": "https://registry.npmjs.org/elkjs/-/elkjs-0.9.3.tgz", - "integrity": "sha512-f/ZeWvW/BCXbhGEf1Ujp29EASo/lk1FDnETgNKwJrsVvGZhUWCZyg3xLJjAsxfOmt8KjswHmI5EwCQcPMpOYhQ==", - "license": "EPL-2.0" - }, "node_modules/engine.io-client": { "version": "6.6.3", "resolved": "https://registry.npmjs.org/engine.io-client/-/engine.io-client-6.6.3.tgz", @@ -4586,15 +3099,6 @@ "node": ">= 0.4" } }, - "node_modules/es6-promise-pool": { - "version": "2.5.0", - "resolved": "https://registry.npmjs.org/es6-promise-pool/-/es6-promise-pool-2.5.0.tgz", - "integrity": "sha512-VHErXfzR/6r/+yyzPKeBvO0lgjfC5cbDCQWjWwMZWSb6YU39TGIl51OUmCfWCq4ylMdJSB8zkz2vIuIeIxXApA==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/esbuild": { "version": "0.25.12", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", @@ -4942,6 +3446,7 @@ "version": "7.1.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, "license": "MIT", "dependencies": { "to-regex-range": "^5.0.1" @@ -5038,19 +3543,11 @@ "url": "https://github.com/sponsors/rawify" } }, - "node_modules/fractional-indexing": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/fractional-indexing/-/fractional-indexing-3.2.0.tgz", - "integrity": "sha512-PcOxmqwYCW7O2ovKRU8OoQQj2yqTfEB/yeTYk4gPid6dN5ODRfU1hXd9tTVZzax/0NkO7AxpHykvZnT1aYp/BQ==", - "license": "CC0-1.0", - "engines": { - "node": "^14.13.1 || >=16.0.0" - } - }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, "hasInstallScript": true, "license": "MIT", "optional": true, @@ -5070,14 +3567,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/fuzzy": { - "version": "0.1.3", - "resolved": "https://registry.npmjs.org/fuzzy/-/fuzzy-0.1.3.tgz", - "integrity": "sha512-/gZffu4ykarLrCiP3Ygsa86UAo1E5vEVlvTrpkKywXSbP9Xhln3oSp9QSV57gEq3JFFpGJ4GZ+5zdEp3FcUh4w==", - "engines": { - "node": ">= 0.6.0" - } - }, "node_modules/gensync": { "version": "1.0.0-beta.2", "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", @@ -5112,15 +3601,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/get-nonce": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/get-nonce/-/get-nonce-1.0.1.tgz", - "integrity": "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==", - "license": "MIT", - "engines": { - "node": ">=6" - } - }, "node_modules/get-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", @@ -5160,12 +3640,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/glur": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/glur/-/glur-1.1.2.tgz", - "integrity": "sha512-l+8esYHTKOx2G/Aao4lEQ0bnHWg4fWtJbVoZZT9Knxi01pB8C80BR85nONLFwkkQoFRCmXY+BUcGZN3yZ2QsRA==", - "license": "MIT" - }, "node_modules/gopd": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", @@ -5185,12 +3659,6 @@ "dev": true, "license": "MIT" }, - "node_modules/hachure-fill": { - "version": "0.5.2", - "resolved": "https://registry.npmjs.org/hachure-fill/-/hachure-fill-0.5.2.tgz", - "integrity": "sha512-3GKBOn+m2LX9iq+JC1064cSFprJY4jL1jCXTcpnfER5HYE2l/4EfWSGzkPa/ZDBmYI0ZOEj5VHV/eKnPGkHuOg==", - "license": "MIT" - }, "node_modules/happy-dom": { "version": "20.0.11", "resolved": "https://registry.npmjs.org/happy-dom/-/happy-dom-20.0.11.tgz", @@ -5334,6 +3802,7 @@ "version": "0.6.3", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "dev": true, "license": "MIT", "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" @@ -5352,15 +3821,6 @@ "node": ">= 4" } }, - "node_modules/image-blob-reduce": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/image-blob-reduce/-/image-blob-reduce-3.0.1.tgz", - "integrity": "sha512-/VmmWgIryG/wcn4TVrV7cC4mlfUC/oyiKIfSg5eVM3Ten/c1c34RJhMYKCWTnoSMHSqXLt3tsrBR4Q2HInvN+Q==", - "license": "MIT", - "dependencies": { - "pica": "^7.1.0" - } - }, "node_modules/import-fresh": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", @@ -5398,25 +3858,11 @@ "node": ">=8" } }, - "node_modules/inherits": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", - "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", - "license": "ISC" - }, - "node_modules/internmap": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", - "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==", - "license": "ISC", - "engines": { - "node": ">=12" - } - }, "node_modules/is-binary-path": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, "license": "MIT", "dependencies": { "binary-extensions": "^2.0.0" @@ -5445,6 +3891,7 @@ "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -5454,6 +3901,7 @@ "version": "4.0.3", "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, "license": "MIT", "dependencies": { "is-extglob": "^2.1.1" @@ -5466,6 +3914,7 @@ "version": "7.0.0", "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, "license": "MIT", "engines": { "node": ">=0.12.0" @@ -5482,6 +3931,7 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, "license": "ISC" }, "node_modules/jiti": { @@ -5494,37 +3944,6 @@ "jiti": "bin/jiti.js" } }, - "node_modules/jotai": { - "version": "2.11.0", - "resolved": "https://registry.npmjs.org/jotai/-/jotai-2.11.0.tgz", - "integrity": "sha512-zKfoBBD1uDw3rljwHkt0fWuja1B76R7CjznuBO+mSX6jpsO1EBeWNRKpeaQho9yPI/pvCv4recGfgOXGxwPZvQ==", - "license": "MIT", - "engines": { - "node": ">=12.20.0" - }, - "peerDependencies": { - "@types/react": ">=17.0.0", - "react": ">=17.0.0" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "react": { - "optional": true - } - } - }, - "node_modules/jotai-scope": { - "version": "0.7.2", - "resolved": "https://registry.npmjs.org/jotai-scope/-/jotai-scope-0.7.2.tgz", - "integrity": "sha512-Gwed97f3dDObrO43++2lRcgOqw4O2sdr4JCjP/7eHK1oPACDJ7xKHGScpJX9XaflU+KBHXF+VhwECnzcaQiShg==", - "license": "MIT", - "peerDependencies": { - "jotai": ">=2.9.2", - "react": ">=17.0.0" - } - }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -5676,31 +4095,6 @@ "node": ">=6" } }, - "node_modules/katex": { - "version": "0.16.25", - "resolved": "https://registry.npmjs.org/katex/-/katex-0.16.25.tgz", - "integrity": "sha512-woHRUZ/iF23GBP1dkDQMh1QBad9dmr8/PAwNA54VrSOVYgI12MAcE14TqnDdQOdzyEonGzMepYnqBMYdsoAr8Q==", - "funding": [ - "https://opencollective.com/katex", - "https://github.com/sponsors/katex" - ], - "license": "MIT", - "dependencies": { - "commander": "^8.3.0" - }, - "bin": { - "katex": "cli.js" - } - }, - "node_modules/katex/node_modules/commander": { - "version": "8.3.0", - "resolved": "https://registry.npmjs.org/commander/-/commander-8.3.0.tgz", - "integrity": "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==", - "license": "MIT", - "engines": { - "node": ">= 12" - } - }, "node_modules/keyv": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", @@ -5711,26 +4105,6 @@ "json-buffer": "3.0.1" } }, - "node_modules/khroma": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/khroma/-/khroma-2.1.0.tgz", - "integrity": "sha512-Ls993zuzfayK269Svk9hzpeGUKob/sIgZzyHYdjQoAdQetRKpOLj+k/QQQ/6Qi0Yz65mlROrfd+Ev+1+7dz9Kw==" - }, - "node_modules/kleur": { - "version": "4.1.5", - "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz", - "integrity": "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==", - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/layout-base": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/layout-base/-/layout-base-1.0.2.tgz", - "integrity": "sha512-8h2oVEZNktL4BH2JCOI90iD1yXwL6iNW7KcCKT2QZgQJR2vbqDsldCTPRU9NifTCqHZci57XvQQ15YTu+sTYPg==", - "license": "MIT" - }, "node_modules/levn": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", @@ -5784,18 +4158,6 @@ "integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==", "license": "MIT" }, - "node_modules/lodash-es": { - "version": "4.17.23", - "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.23.tgz", - "integrity": "sha512-kVI48u3PZr38HdYz98UmfPnXl2DXrpdctLrFLCd3kOx1xUkOmpFPx7gCWWM5MPkL/fD8zb+Ph0QzjGFs4+hHWg==", - "license": "MIT" - }, - "node_modules/lodash.debounce": { - "version": "4.0.8", - "resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz", - "integrity": "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==", - "license": "MIT" - }, "node_modules/lodash.merge": { "version": "4.6.2", "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", @@ -5803,12 +4165,6 @@ "dev": true, "license": "MIT" }, - "node_modules/lodash.throttle": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/lodash.throttle/-/lodash.throttle-4.1.1.tgz", - "integrity": "sha512-wIkUCfVKpVsWo3JSZlc+8MB5it+2AN5W8J7YVMST30UrvcQNZ1Okbj+rbVniijTWE6FGYy4XJq/rHkas8qJMLQ==", - "license": "MIT" - }, "node_modules/loose-envify": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", @@ -5870,43 +4226,6 @@ "node": ">= 0.4" } }, - "node_modules/mdast-util-from-markdown": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/mdast-util-from-markdown/-/mdast-util-from-markdown-1.3.1.tgz", - "integrity": "sha512-4xTO/M8c82qBcnQc1tgpNtubGUW/Y1tBQ1B0i5CtSoelOLKFYlElIr3bvgREYYO5iRqbMY1YuqZng0GVOI8Qww==", - "license": "MIT", - "dependencies": { - "@types/mdast": "^3.0.0", - "@types/unist": "^2.0.0", - "decode-named-character-reference": "^1.0.0", - "mdast-util-to-string": "^3.1.0", - "micromark": "^3.0.0", - "micromark-util-decode-numeric-character-reference": "^1.0.0", - "micromark-util-decode-string": "^1.0.0", - "micromark-util-normalize-identifier": "^1.0.0", - "micromark-util-symbol": "^1.0.0", - "micromark-util-types": "^1.0.0", - "unist-util-stringify-position": "^3.0.0", - "uvu": "^0.5.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/mdast-util-to-string": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/mdast-util-to-string/-/mdast-util-to-string-3.2.0.tgz", - "integrity": "sha512-V4Zn/ncyN1QNSqSBxTrMOLpjr+IKdHl2v3KVLoWmDPscP4r9GcCi71gjgvUV1SFSKh92AjAG4peFuBl2/YgCJg==", - "license": "MIT", - "dependencies": { - "@types/mdast": "^3.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, "node_modules/mdn-data": { "version": "2.12.2", "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.12.2.tgz", @@ -5924,476 +4243,6 @@ "node": ">= 8" } }, - "node_modules/mermaid": { - "version": "10.9.3", - "resolved": "https://registry.npmjs.org/mermaid/-/mermaid-10.9.3.tgz", - "integrity": "sha512-V80X1isSEvAewIL3xhmz/rVmc27CVljcsbWxkxlWJWY/1kQa4XOABqpDl2qQLGKzpKm6WbTfUEKImBlUfFYArw==", - "license": "MIT", - "dependencies": { - "@braintree/sanitize-url": "^6.0.1", - "@types/d3-scale": "^4.0.3", - "@types/d3-scale-chromatic": "^3.0.0", - "cytoscape": "^3.28.1", - "cytoscape-cose-bilkent": "^4.1.0", - "d3": "^7.4.0", - "d3-sankey": "^0.12.3", - "dagre-d3-es": "7.0.10", - "dayjs": "^1.11.7", - "dompurify": "^3.0.5 <3.1.7", - "elkjs": "^0.9.0", - "katex": "^0.16.9", - "khroma": "^2.0.0", - "lodash-es": "^4.17.21", - "mdast-util-from-markdown": "^1.3.0", - "non-layered-tidy-tree-layout": "^2.0.2", - "stylis": "^4.1.3", - "ts-dedent": "^2.2.0", - "uuid": "^9.0.0", - "web-worker": "^1.2.0" - } - }, - "node_modules/micromark": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/micromark/-/micromark-3.2.0.tgz", - "integrity": "sha512-uD66tJj54JLYq0De10AhWycZWGQNUvDI55xPgk2sQM5kn1JYlhbCMTtEeT27+vAhW2FBQxLlOmS3pmA7/2z4aA==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT", - "dependencies": { - "@types/debug": "^4.0.0", - "debug": "^4.0.0", - "decode-named-character-reference": "^1.0.0", - "micromark-core-commonmark": "^1.0.1", - "micromark-factory-space": "^1.0.0", - "micromark-util-character": "^1.0.0", - "micromark-util-chunked": "^1.0.0", - "micromark-util-combine-extensions": "^1.0.0", - "micromark-util-decode-numeric-character-reference": "^1.0.0", - "micromark-util-encode": "^1.0.0", - "micromark-util-normalize-identifier": "^1.0.0", - "micromark-util-resolve-all": "^1.0.0", - "micromark-util-sanitize-uri": "^1.0.0", - "micromark-util-subtokenize": "^1.0.0", - "micromark-util-symbol": "^1.0.0", - "micromark-util-types": "^1.0.1", - "uvu": "^0.5.0" - } - }, - "node_modules/micromark-core-commonmark": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/micromark-core-commonmark/-/micromark-core-commonmark-1.1.0.tgz", - "integrity": "sha512-BgHO1aRbolh2hcrzL2d1La37V0Aoz73ymF8rAcKnohLy93titmv62E0gP8Hrx9PKcKrqCZ1BbLGbP3bEhoXYlw==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT", - "dependencies": { - "decode-named-character-reference": "^1.0.0", - "micromark-factory-destination": "^1.0.0", - "micromark-factory-label": "^1.0.0", - "micromark-factory-space": "^1.0.0", - "micromark-factory-title": "^1.0.0", - "micromark-factory-whitespace": "^1.0.0", - "micromark-util-character": "^1.0.0", - "micromark-util-chunked": "^1.0.0", - "micromark-util-classify-character": "^1.0.0", - "micromark-util-html-tag-name": "^1.0.0", - "micromark-util-normalize-identifier": "^1.0.0", - "micromark-util-resolve-all": "^1.0.0", - "micromark-util-subtokenize": "^1.0.0", - "micromark-util-symbol": "^1.0.0", - "micromark-util-types": "^1.0.1", - "uvu": "^0.5.0" - } - }, - "node_modules/micromark-factory-destination": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/micromark-factory-destination/-/micromark-factory-destination-1.1.0.tgz", - "integrity": "sha512-XaNDROBgx9SgSChd69pjiGKbV+nfHGDPVYFs5dOoDd7ZnMAE+Cuu91BCpsY8RT2NP9vo/B8pds2VQNCLiu0zhg==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT", - "dependencies": { - "micromark-util-character": "^1.0.0", - "micromark-util-symbol": "^1.0.0", - "micromark-util-types": "^1.0.0" - } - }, - "node_modules/micromark-factory-label": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/micromark-factory-label/-/micromark-factory-label-1.1.0.tgz", - "integrity": "sha512-OLtyez4vZo/1NjxGhcpDSbHQ+m0IIGnT8BoPamh+7jVlzLJBH98zzuCoUeMxvM6WsNeh8wx8cKvqLiPHEACn0w==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT", - "dependencies": { - "micromark-util-character": "^1.0.0", - "micromark-util-symbol": "^1.0.0", - "micromark-util-types": "^1.0.0", - "uvu": "^0.5.0" - } - }, - "node_modules/micromark-factory-space": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/micromark-factory-space/-/micromark-factory-space-1.1.0.tgz", - "integrity": "sha512-cRzEj7c0OL4Mw2v6nwzttyOZe8XY/Z8G0rzmWQZTBi/jjwyw/U4uqKtUORXQrR5bAZZnbTI/feRV/R7hc4jQYQ==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT", - "dependencies": { - "micromark-util-character": "^1.0.0", - "micromark-util-types": "^1.0.0" - } - }, - "node_modules/micromark-factory-title": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/micromark-factory-title/-/micromark-factory-title-1.1.0.tgz", - "integrity": "sha512-J7n9R3vMmgjDOCY8NPw55jiyaQnH5kBdV2/UXCtZIpnHH3P6nHUKaH7XXEYuWwx/xUJcawa8plLBEjMPU24HzQ==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT", - "dependencies": { - "micromark-factory-space": "^1.0.0", - "micromark-util-character": "^1.0.0", - "micromark-util-symbol": "^1.0.0", - "micromark-util-types": "^1.0.0" - } - }, - "node_modules/micromark-factory-whitespace": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/micromark-factory-whitespace/-/micromark-factory-whitespace-1.1.0.tgz", - "integrity": "sha512-v2WlmiymVSp5oMg+1Q0N1Lxmt6pMhIHD457whWM7/GUlEks1hI9xj5w3zbc4uuMKXGisksZk8DzP2UyGbGqNsQ==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT", - "dependencies": { - "micromark-factory-space": "^1.0.0", - "micromark-util-character": "^1.0.0", - "micromark-util-symbol": "^1.0.0", - "micromark-util-types": "^1.0.0" - } - }, - "node_modules/micromark-util-character": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-1.2.0.tgz", - "integrity": "sha512-lXraTwcX3yH/vMDaFWCQJP1uIszLVebzUa3ZHdrgxr7KEU/9mL4mVgCpGbyhvNLNlauROiNUq7WN5u7ndbY6xg==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT", - "dependencies": { - "micromark-util-symbol": "^1.0.0", - "micromark-util-types": "^1.0.0" - } - }, - "node_modules/micromark-util-chunked": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/micromark-util-chunked/-/micromark-util-chunked-1.1.0.tgz", - "integrity": "sha512-Ye01HXpkZPNcV6FiyoW2fGZDUw4Yc7vT0E9Sad83+bEDiCJ1uXu0S3mr8WLpsz3HaG3x2q0HM6CTuPdcZcluFQ==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT", - "dependencies": { - "micromark-util-symbol": "^1.0.0" - } - }, - "node_modules/micromark-util-classify-character": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/micromark-util-classify-character/-/micromark-util-classify-character-1.1.0.tgz", - "integrity": "sha512-SL0wLxtKSnklKSUplok1WQFoGhUdWYKggKUiqhX+Swala+BtptGCu5iPRc+xvzJ4PXE/hwM3FNXsfEVgoZsWbw==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT", - "dependencies": { - "micromark-util-character": "^1.0.0", - "micromark-util-symbol": "^1.0.0", - "micromark-util-types": "^1.0.0" - } - }, - "node_modules/micromark-util-combine-extensions": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/micromark-util-combine-extensions/-/micromark-util-combine-extensions-1.1.0.tgz", - "integrity": "sha512-Q20sp4mfNf9yEqDL50WwuWZHUrCO4fEyeDCnMGmG5Pr0Cz15Uo7KBs6jq+dq0EgX4DPwwrh9m0X+zPV1ypFvUA==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT", - "dependencies": { - "micromark-util-chunked": "^1.0.0", - "micromark-util-types": "^1.0.0" - } - }, - "node_modules/micromark-util-decode-numeric-character-reference": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/micromark-util-decode-numeric-character-reference/-/micromark-util-decode-numeric-character-reference-1.1.0.tgz", - "integrity": "sha512-m9V0ExGv0jB1OT21mrWcuf4QhP46pH1KkfWy9ZEezqHKAxkj4mPCy3nIH1rkbdMlChLHX531eOrymlwyZIf2iw==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT", - "dependencies": { - "micromark-util-symbol": "^1.0.0" - } - }, - "node_modules/micromark-util-decode-string": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/micromark-util-decode-string/-/micromark-util-decode-string-1.1.0.tgz", - "integrity": "sha512-YphLGCK8gM1tG1bd54azwyrQRjCFcmgj2S2GoJDNnh4vYtnL38JS8M4gpxzOPNyHdNEpheyWXCTnnTDY3N+NVQ==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT", - "dependencies": { - "decode-named-character-reference": "^1.0.0", - "micromark-util-character": "^1.0.0", - "micromark-util-decode-numeric-character-reference": "^1.0.0", - "micromark-util-symbol": "^1.0.0" - } - }, - "node_modules/micromark-util-encode": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/micromark-util-encode/-/micromark-util-encode-1.1.0.tgz", - "integrity": "sha512-EuEzTWSTAj9PA5GOAs992GzNh2dGQO52UvAbtSOMvXTxv3Criqb6IOzJUBCmEqrrXSblJIJBbFFv6zPxpreiJw==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT" - }, - "node_modules/micromark-util-html-tag-name": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/micromark-util-html-tag-name/-/micromark-util-html-tag-name-1.2.0.tgz", - "integrity": "sha512-VTQzcuQgFUD7yYztuQFKXT49KghjtETQ+Wv/zUjGSGBioZnkA4P1XXZPT1FHeJA6RwRXSF47yvJ1tsJdoxwO+Q==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT" - }, - "node_modules/micromark-util-normalize-identifier": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/micromark-util-normalize-identifier/-/micromark-util-normalize-identifier-1.1.0.tgz", - "integrity": "sha512-N+w5vhqrBihhjdpM8+5Xsxy71QWqGn7HYNUvch71iV2PM7+E3uWGox1Qp90loa1ephtCxG2ftRV/Conitc6P2Q==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT", - "dependencies": { - "micromark-util-symbol": "^1.0.0" - } - }, - "node_modules/micromark-util-resolve-all": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/micromark-util-resolve-all/-/micromark-util-resolve-all-1.1.0.tgz", - "integrity": "sha512-b/G6BTMSg+bX+xVCshPTPyAu2tmA0E4X98NSR7eIbeC6ycCqCeE7wjfDIgzEbkzdEVJXRtOG4FbEm/uGbCRouA==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT", - "dependencies": { - "micromark-util-types": "^1.0.0" - } - }, - "node_modules/micromark-util-sanitize-uri": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/micromark-util-sanitize-uri/-/micromark-util-sanitize-uri-1.2.0.tgz", - "integrity": "sha512-QO4GXv0XZfWey4pYFndLUKEAktKkG5kZTdUNaTAkzbuJxn2tNBOr+QtxR2XpWaMhbImT2dPzyLrPXLlPhph34A==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT", - "dependencies": { - "micromark-util-character": "^1.0.0", - "micromark-util-encode": "^1.0.0", - "micromark-util-symbol": "^1.0.0" - } - }, - "node_modules/micromark-util-subtokenize": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/micromark-util-subtokenize/-/micromark-util-subtokenize-1.1.0.tgz", - "integrity": "sha512-kUQHyzRoxvZO2PuLzMt2P/dwVsTiivCK8icYTeR+3WgbuPqfHgPPy7nFKbeqRivBvn/3N3GBiNC+JRTMSxEC7A==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT", - "dependencies": { - "micromark-util-chunked": "^1.0.0", - "micromark-util-symbol": "^1.0.0", - "micromark-util-types": "^1.0.0", - "uvu": "^0.5.0" - } - }, - "node_modules/micromark-util-symbol": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-1.1.0.tgz", - "integrity": "sha512-uEjpEYY6KMs1g7QfJ2eX1SQEV+ZT4rUD3UcF6l57acZvLNK7PBZL+ty82Z1qhK1/yXIY4bdx04FKMgR0g4IAag==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT" - }, - "node_modules/micromark-util-types": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/micromark-util-types/-/micromark-util-types-1.1.0.tgz", - "integrity": "sha512-ukRBgie8TIAcacscVHSiddHjO4k/q3pnedmzMQ4iwDcK0FtFCohKOlFbaOL/mPgfnPsL3C1ZyxJa4sbWrBl3jg==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT" - }, "node_modules/micromatch": { "version": "4.0.8", "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", @@ -6452,31 +4301,12 @@ "node": "*" } }, - "node_modules/mri": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz", - "integrity": "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==", - "license": "MIT", - "engines": { - "node": ">=4" - } - }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "license": "MIT" }, - "node_modules/multimath": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/multimath/-/multimath-2.0.0.tgz", - "integrity": "sha512-toRx66cAMJ+Ccz7pMIg38xSIrtnbozk0dchXezwQDMgQmbGpfxjtv68H+L00iFL8hxDaVjrmwAFSb3I6bg8Q2g==", - "license": "MIT", - "dependencies": { - "glur": "^1.1.2", - "object-assign": "^4.1.1" - } - }, "node_modules/mz": { "version": "2.7.0", "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", @@ -6522,16 +4352,11 @@ "dev": true, "license": "MIT" }, - "node_modules/non-layered-tidy-tree-layout": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/non-layered-tidy-tree-layout/-/non-layered-tidy-tree-layout-2.0.2.tgz", - "integrity": "sha512-gkXMxRzUH+PB0ax9dUN0yYF0S25BqeAYqhgMaLUFmpXLEk7Fcu8f4emJuOAY0V8kjDICxROIKsTAKsV/v355xw==", - "license": "MIT" - }, "node_modules/normalize-path": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -6551,6 +4376,7 @@ "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -6577,12 +4403,6 @@ ], "license": "MIT" }, - "node_modules/open-color": { - "version": "1.9.1", - "resolved": "https://registry.npmjs.org/open-color/-/open-color-1.9.1.tgz", - "integrity": "sha512-vCseG/EQ6/RcvxhUcGJiHViOgrtz4x0XbZepXvKik66TMGkvbmjeJrKFyBEx6daG5rNyyd14zYXhz0hZVwQFOw==", - "license": "MIT" - }, "node_modules/optionator": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", @@ -6633,12 +4453,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/pako": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/pako/-/pako-2.0.3.tgz", - "integrity": "sha512-WjR1hOeg+kki3ZIOjaf4b5WVcay1jaliKSYiEaB1XzwhMQZJxRdQRv0V31EKBYlxb4T7SK3hjfc/jxyU64BoSw==", - "license": "(MIT AND Zlib)" - }, "node_modules/parent-module": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", @@ -6665,12 +4479,6 @@ "url": "https://github.com/inikulin/parse5?sponsor=1" } }, - "node_modules/path-data-parser": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/path-data-parser/-/path-data-parser-0.1.0.tgz", - "integrity": "sha512-NOnmBpt5Y2RWbuv0LMzsayp3lVylAHLPUTut412ZA3l+C4uw4ZVkQbjShYCQ8TCpUMdPapr4YjUqLYD6v68j+w==", - "license": "MIT" - }, "node_modules/path-exists": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", @@ -6685,6 +4493,7 @@ "version": "3.1.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -6704,25 +4513,6 @@ "dev": true, "license": "MIT" }, - "node_modules/perfect-freehand": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/perfect-freehand/-/perfect-freehand-1.2.0.tgz", - "integrity": "sha512-h/0ikF1M3phW7CwpZ5MMvKnfpHficWoOEyr//KVNTxV4F6deRK1eYMtHyBKEAKFK0aXIEUK9oBvlF6PNXMDsAw==", - "license": "MIT" - }, - "node_modules/pica": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/pica/-/pica-7.1.1.tgz", - "integrity": "sha512-WY73tMvNzXWEld2LicT9Y260L43isrZ85tPuqRyvtkljSDLmnNFQmZICt4xUJMVulmcc6L9O7jbBrtx3DOz/YQ==", - "license": "MIT", - "dependencies": { - "glur": "^1.1.2", - "inherits": "^2.0.3", - "multimath": "^2.0.0", - "object-assign": "^4.1.1", - "webworkify": "^1.5.0" - } - }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -6734,6 +4524,7 @@ "version": "2.3.1", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, "license": "MIT", "engines": { "node": ">=8.6" @@ -6809,53 +4600,6 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, - "node_modules/png-chunk-text": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/png-chunk-text/-/png-chunk-text-1.0.0.tgz", - "integrity": "sha512-DEROKU3SkkLGWNMzru3xPVgxyd48UGuMSZvioErCure6yhOc/pRH2ZV+SEn7nmaf7WNf3NdIpH+UTrRdKyq9Lw==", - "license": "MIT" - }, - "node_modules/png-chunks-encode": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/png-chunks-encode/-/png-chunks-encode-1.0.0.tgz", - "integrity": "sha512-J1jcHgbQRsIIgx5wxW9UmCymV3wwn4qCCJl6KYgEU/yHCh/L2Mwq/nMOkRPtmV79TLxRZj5w3tH69pvygFkDqA==", - "license": "MIT", - "dependencies": { - "crc-32": "^0.3.0", - "sliced": "^1.0.1" - } - }, - "node_modules/png-chunks-extract": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/png-chunks-extract/-/png-chunks-extract-1.0.0.tgz", - "integrity": "sha512-ZiVwF5EJ0DNZyzAqld8BP1qyJBaGOFaq9zl579qfbkcmOwWLLO4I9L8i2O4j3HkI6/35i0nKG2n+dZplxiT89Q==", - "license": "MIT", - "dependencies": { - "crc-32": "^0.3.0" - } - }, - "node_modules/points-on-curve": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/points-on-curve/-/points-on-curve-1.0.1.tgz", - "integrity": "sha512-3nmX4/LIiyuwGLwuUrfhTlDeQFlAhi7lyK/zcRNGhalwapDWgAGR82bUpmn2mA03vII3fvNCG8jAONzKXwpxAg==", - "license": "MIT" - }, - "node_modules/points-on-path": { - "version": "0.2.1", - "resolved": "https://registry.npmjs.org/points-on-path/-/points-on-path-0.2.1.tgz", - "integrity": "sha512-25ClnWWuw7JbWZcgqY/gJ4FQWadKxGWk+3kR/7kD0tCaDtPPMj7oHu2ToLaVhfpnHrZzYby2w6tUA0eOIuUg8g==", - "license": "MIT", - "dependencies": { - "path-data-parser": "0.1.0", - "points-on-curve": "0.2.0" - } - }, - "node_modules/points-on-path/node_modules/points-on-curve": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/points-on-curve/-/points-on-curve-0.2.0.tgz", - "integrity": "sha512-0mYKnYYe9ZcqMCWhUjItv/oHjvgEsfKvnUTg8sAtnHr3GVy7rGkXCb6d5cSyqrWqL4k81b9CPg3urd+T7aop3A==", - "license": "MIT" - }, "node_modules/postcss": { "version": "8.4.35", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.35.tgz", @@ -7081,12 +4825,6 @@ "node": ">=6" } }, - "node_modules/pwacompat": { - "version": "2.0.17", - "resolved": "https://registry.npmjs.org/pwacompat/-/pwacompat-2.0.17.tgz", - "integrity": "sha512-6Du7IZdIy7cHiv7AhtDy4X2QRM8IAD5DII69mt5qWibC2d15ZU8DmBG1WdZKekG11cChSu4zkSUGPF9sweOl6w==", - "license": "Apache-2.0" - }, "node_modules/queue-microtask": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", @@ -7151,53 +4889,6 @@ "node": ">=0.10.0" } }, - "node_modules/react-remove-scroll": { - "version": "2.7.1", - "resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.1.tgz", - "integrity": "sha512-HpMh8+oahmIdOuS5aFKKY6Pyog+FNaZV/XyJOq7b4YFwsFHe5yYfdbIalI4k3vU2nSDql7YskmUseHsRrJqIPA==", - "license": "MIT", - "dependencies": { - "react-remove-scroll-bar": "^2.3.7", - "react-style-singleton": "^2.2.3", - "tslib": "^2.1.0", - "use-callback-ref": "^1.3.3", - "use-sidecar": "^1.1.3" - }, - "engines": { - "node": ">=10" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/react-remove-scroll-bar": { - "version": "2.3.8", - "resolved": "https://registry.npmjs.org/react-remove-scroll-bar/-/react-remove-scroll-bar-2.3.8.tgz", - "integrity": "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==", - "license": "MIT", - "dependencies": { - "react-style-singleton": "^2.2.2", - "tslib": "^2.0.0" - }, - "engines": { - "node": ">=10" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, "node_modules/react-router": { "version": "7.12.0", "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.12.0.tgz", @@ -7236,28 +4927,6 @@ "react-dom": ">=18" } }, - "node_modules/react-style-singleton": { - "version": "2.2.3", - "resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.3.tgz", - "integrity": "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==", - "license": "MIT", - "dependencies": { - "get-nonce": "^1.0.0", - "tslib": "^2.0.0" - }, - "engines": { - "node": ">=10" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, "node_modules/read-cache": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", @@ -7272,6 +4941,7 @@ "version": "3.6.0", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, "license": "MIT", "dependencies": { "picomatch": "^2.2.1" @@ -7346,12 +5016,6 @@ "node": ">=0.10.0" } }, - "node_modules/robust-predicates": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/robust-predicates/-/robust-predicates-3.0.2.tgz", - "integrity": "sha512-IXgzBWvWQwE6PrDI05OvmXUIruQTcoMDzRsOd5CDvHCVLcLHMTSYvOK5Cm46kWqlV3yAbuSpBZdJ5oP5OUoStg==", - "license": "Unlicense" - }, "node_modules/rollup": { "version": "4.53.3", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.53.3.tgz", @@ -7394,24 +5058,6 @@ "fsevents": "~2.3.2" } }, - "node_modules/roughjs": { - "version": "4.6.4", - "resolved": "https://registry.npmjs.org/roughjs/-/roughjs-4.6.4.tgz", - "integrity": "sha512-s6EZ0BntezkFYMf/9mGn7M8XGIoaav9QQBCnJROWB3brUWQ683Q2LbRD/hq0Z3bAJ/9NVpU/5LpiTWvQMyLDhw==", - "license": "MIT", - "dependencies": { - "hachure-fill": "^0.5.2", - "path-data-parser": "^0.1.0", - "points-on-curve": "^0.2.0", - "points-on-path": "^0.2.1" - } - }, - "node_modules/roughjs/node_modules/points-on-curve": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/points-on-curve/-/points-on-curve-0.2.0.tgz", - "integrity": "sha512-0mYKnYYe9ZcqMCWhUjItv/oHjvgEsfKvnUTg8sAtnHr3GVy7rGkXCb6d5cSyqrWqL4k81b9CPg3urd+T7aop3A==", - "license": "MIT" - }, "node_modules/run-parallel": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", @@ -7436,28 +5082,11 @@ "queue-microtask": "^1.2.2" } }, - "node_modules/rw": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/rw/-/rw-1.3.3.tgz", - "integrity": "sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==", - "license": "BSD-3-Clause" - }, - "node_modules/sade": { - "version": "1.8.1", - "resolved": "https://registry.npmjs.org/sade/-/sade-1.8.1.tgz", - "integrity": "sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==", - "license": "MIT", - "dependencies": { - "mri": "^1.1.0" - }, - "engines": { - "node": ">=6" - } - }, "node_modules/safer-buffer": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "dev": true, "license": "MIT" }, "node_modules/saxes": { @@ -7502,6 +5131,7 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, "license": "MIT", "dependencies": { "shebang-regex": "^3.0.0" @@ -7514,6 +5144,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -7526,12 +5157,6 @@ "dev": true, "license": "ISC" }, - "node_modules/sliced": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/sliced/-/sliced-1.0.1.tgz", - "integrity": "sha512-VZBmZP8WU3sMOZm1bdgTadsQbcscK0UM8oKxKVBs4XAhUo2Xxzm/OFMGBkPusxw9xL3Uy8LrzEqGqJhclsr0yA==", - "license": "MIT" - }, "node_modules/socket.io-client": { "version": "4.8.1", "resolved": "https://registry.npmjs.org/socket.io-client/-/socket.io-client-4.8.1.tgz", @@ -7608,6 +5233,7 @@ "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, "license": "BSD-3-Clause", "engines": { "node": ">=0.10.0" @@ -7653,12 +5279,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/stylis": { - "version": "4.3.6", - "resolved": "https://registry.npmjs.org/stylis/-/stylis-4.3.6.tgz", - "integrity": "sha512-yQ3rwFWRfwNUY7H5vpU0wfdkNSnvnJinhF9830Swlaxl03zsOjCfmX0ugac+3LtK0lYSgwL/KXc8oYL3mG4YFQ==", - "license": "MIT" - }, "node_modules/sucrase": { "version": "3.35.1", "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz", @@ -7895,6 +5515,7 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, "license": "MIT", "dependencies": { "is-number": "^7.0.0" @@ -7942,15 +5563,6 @@ "typescript": ">=4.8.4" } }, - "node_modules/ts-dedent": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/ts-dedent/-/ts-dedent-2.2.0.tgz", - "integrity": "sha512-q5W7tVM71e2xjHZTlgfTDoPF/SmqKG5hddq9SzR49CH2hayqRKJtQ4mtRlSxKaJlR/+9rEM+mnBHf7I2/BQcpQ==", - "license": "MIT", - "engines": { - "node": ">=6.10" - } - }, "node_modules/ts-interface-checker": { "version": "0.1.13", "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", @@ -7964,15 +5576,6 @@ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "license": "0BSD" }, - "node_modules/tunnel-rat": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/tunnel-rat/-/tunnel-rat-0.1.2.tgz", - "integrity": "sha512-lR5VHmkPhzdhrM092lI2nACsLO4QubF0/yoOhzX7c+wIpbN1GjHNzCc91QlpxBi+cnx8vVJ+Ur6vL5cEoQPFpQ==", - "license": "MIT", - "dependencies": { - "zustand": "^4.3.2" - } - }, "node_modules/type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", @@ -8031,19 +5634,6 @@ "dev": true, "license": "MIT" }, - "node_modules/unist-util-stringify-position": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/unist-util-stringify-position/-/unist-util-stringify-position-3.0.3.tgz", - "integrity": "sha512-k5GzIBZ/QatR8N5X2y+drfpWG8IDBzdnVj6OInRNWm1oXrzydiaAT2OQiA8DPRRZyAKb9b6I2a6PxYklZD0gKg==", - "license": "MIT", - "dependencies": { - "@types/unist": "^2.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, "node_modules/update-browserslist-db": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.4.tgz", @@ -8085,58 +5675,6 @@ "punycode": "^2.1.0" } }, - "node_modules/use-callback-ref": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/use-callback-ref/-/use-callback-ref-1.3.3.tgz", - "integrity": "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==", - "license": "MIT", - "dependencies": { - "tslib": "^2.0.0" - }, - "engines": { - "node": ">=10" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/use-sidecar": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/use-sidecar/-/use-sidecar-1.1.3.tgz", - "integrity": "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ==", - "license": "MIT", - "dependencies": { - "detect-node-es": "^1.1.0", - "tslib": "^2.0.0" - }, - "engines": { - "node": ">=10" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/use-sync-external-store": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", - "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", - "license": "MIT", - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" - } - }, "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", @@ -8144,37 +5682,6 @@ "dev": true, "license": "MIT" }, - "node_modules/uuid": { - "version": "9.0.1", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", - "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", - "funding": [ - "https://github.com/sponsors/broofa", - "https://github.com/sponsors/ctavan" - ], - "license": "MIT", - "bin": { - "uuid": "dist/bin/uuid" - } - }, - "node_modules/uvu": { - "version": "0.5.6", - "resolved": "https://registry.npmjs.org/uvu/-/uvu-0.5.6.tgz", - "integrity": "sha512-+g8ENReyr8YsOc6fv/NVJs2vFdHBnBNdfE49rshrTzDWOlUx4Gq7KOS2GD8eqhy2j+Ejq29+SbKH8yjkAqXqoA==", - "license": "MIT", - "dependencies": { - "dequal": "^2.0.0", - "diff": "^5.0.0", - "kleur": "^4.0.3", - "sade": "^1.7.3" - }, - "bin": { - "uvu": "bin.js" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/vite": { "version": "7.2.2", "resolved": "https://registry.npmjs.org/vite/-/vite-7.2.2.tgz", @@ -8414,12 +5921,6 @@ "node": ">=18" } }, - "node_modules/web-worker": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/web-worker/-/web-worker-1.5.0.tgz", - "integrity": "sha512-RiMReJrTAiA+mBjGONMnjVDP2u3p9R1vkcGz6gDIrOMT3oGuYwX2WRMYI9ipkphSuE5XKEhydbhNEJh4NY9mlw==", - "license": "Apache-2.0" - }, "node_modules/webidl-conversions": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.0.tgz", @@ -8430,12 +5931,6 @@ "node": ">=20" } }, - "node_modules/webworkify": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/webworkify/-/webworkify-1.5.0.tgz", - "integrity": "sha512-AMcUeyXAhbACL8S2hqqdqOLqvJ8ylmIbNwUIqQujRSouf4+eUFaXbG6F1Rbu+srlJMmxQWsiU7mOJi0nMBfM1g==", - "license": "MIT" - }, "node_modules/whatwg-encoding": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz", @@ -8477,6 +5972,7 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, "license": "ISC", "dependencies": { "isexe": "^2.0.0" @@ -8616,34 +6112,6 @@ "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" } - }, - "node_modules/zustand": { - "version": "4.5.7", - "resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.7.tgz", - "integrity": "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==", - "license": "MIT", - "dependencies": { - "use-sync-external-store": "^1.2.2" - }, - "engines": { - "node": ">=12.7.0" - }, - "peerDependencies": { - "@types/react": ">=16.8", - "immer": ">=9.0.6", - "react": ">=16.8" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "immer": { - "optional": true - }, - "react": { - "optional": true - } - } } } } diff --git a/frontend/package.json b/frontend/package.json index 5507bae..4105587 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -17,7 +17,7 @@ "dependencies": { "@dnd-kit/core": "^6.3.1", "@dnd-kit/utilities": "^3.2.2", - "@excalidraw/excalidraw": "^0.18.0", + "@excalidraw/excalidraw": "0.17.6", "@types/lodash": "^4.17.20", "axios": "^1.13.2", "clsx": "^2.1.1", diff --git a/frontend/src/context/AuthContext.test.tsx b/frontend/src/context/AuthContext.test.tsx index 0bf92da..6599069 100644 --- a/frontend/src/context/AuthContext.test.tsx +++ b/frontend/src/context/AuthContext.test.tsx @@ -1,7 +1,7 @@ import { render, screen, waitFor } from "@testing-library/react"; import axios from "axios"; import { MemoryRouter } from "react-router-dom"; -import { describe, expect, it, vi } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; import { AuthProvider, useAuth } from "./AuthContext"; const Probe = () => { @@ -15,6 +15,10 @@ const Probe = () => { }; describe("AuthProvider", () => { + beforeEach(() => { + vi.restoreAllMocks(); + }); + it("defaults to auth-enabled mode if /auth/status fails", async () => { const storage = new Map(); Object.defineProperty(window, "localStorage", { @@ -45,4 +49,42 @@ describe("AuthProvider", () => { }); expect(screen.getByTestId("auth-enabled").textContent).toBe("true"); }); + + it("clears stored auth state when backend reports auth disabled", async () => { + const storage = new Map([ + ["excalidash-access-token", "token"], + ["excalidash-refresh-token", "refresh"], + ["excalidash-user", JSON.stringify({ id: "u1" })], + ]); + Object.defineProperty(window, "localStorage", { + configurable: true, + value: { + getItem: (key: string) => storage.get(key) ?? null, + setItem: (key: string, value: string) => { + storage.set(key, value); + }, + removeItem: (key: string) => { + storage.delete(key); + }, + }, + }); + + vi.spyOn(axios, "get").mockResolvedValueOnce({ data: { authEnabled: false } }); + + render( + + + + + + ); + + await waitFor(() => { + expect(screen.getByTestId("loading").textContent).toBe("false"); + }); + expect(screen.getByTestId("auth-enabled").textContent).toBe("false"); + expect(storage.get("excalidash-access-token")).toBeUndefined(); + expect(storage.get("excalidash-refresh-token")).toBeUndefined(); + expect(storage.get("excalidash-user")).toBeUndefined(); + }); }); diff --git a/frontend/src/context/AuthContext.tsx b/frontend/src/context/AuthContext.tsx index 1d8ab8a..8848ee2 100644 --- a/frontend/src/context/AuthContext.tsx +++ b/frontend/src/context/AuthContext.tsx @@ -56,6 +56,9 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) => // In single-user mode, do not require login. if (!enabled) { + localStorage.removeItem(TOKEN_KEY); + localStorage.removeItem(REFRESH_TOKEN_KEY); + localStorage.removeItem(USER_KEY); setUser(null); return; } diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx index f00133f..8a35e38 100644 --- a/frontend/src/pages/Dashboard.tsx +++ b/frontend/src/pages/Dashboard.tsx @@ -340,7 +340,12 @@ export const Dashboard: React.FC = () => { const handleRenameDrawing = async (id: string, name: string) => { setDrawings(prev => prev.map(d => d.id === id ? { ...d, name } : d)); - await api.updateDrawing(id, { name }); + try { + await api.updateDrawing(id, { name }); + } catch (err) { + console.error("Failed to rename drawing:", err); + refreshData(); + } }; const handleDeleteDrawing = async (id: string) => { @@ -537,14 +542,24 @@ export const Dashboard: React.FC = () => { }; const handleCreateCollection = async (name: string) => { - await api.createCollection(name); - const newCollections = await api.getCollections(); - setCollections(newCollections); + try { + await api.createCollection(name); + const newCollections = await api.getCollections(); + setCollections(newCollections); + } catch (err) { + console.error("Failed to create collection:", err); + refreshData(); + } }; const handleEditCollection = async (id: string, name: string) => { setCollections(prev => prev.map(c => c.id === id ? { ...c, name } : c)); - await api.updateCollection(id, name); + try { + await api.updateCollection(id, name); + } catch (err) { + console.error("Failed to rename collection:", err); + refreshData(); + } }; const handleDeleteCollection = async (id: string) => { @@ -552,8 +567,13 @@ export const Dashboard: React.FC = () => { if (selectedCollectionId === id) { setSelectedCollectionId(undefined); } - await api.deleteCollection(id); - refreshData(); + try { + await api.deleteCollection(id); + refreshData(); + } catch (err) { + console.error("Failed to delete collection:", err); + refreshData(); + } }; const viewTitle = React.useMemo(() => { diff --git a/frontend/src/pages/Editor.tsx b/frontend/src/pages/Editor.tsx index b173993..19b51de 100644 --- a/frontend/src/pages/Editor.tsx +++ b/frontend/src/pages/Editor.tsx @@ -3,7 +3,6 @@ import { useParams, useNavigate } from 'react-router-dom'; import { ArrowLeft, Download, Loader2, ChevronUp, ChevronDown } from 'lucide-react'; import clsx from 'clsx'; import { Excalidraw, exportToSvg } from '@excalidraw/excalidraw'; -import '@excalidraw/excalidraw/index.css'; import debounce from 'lodash/debounce'; import throttle from 'lodash/throttle'; import { Toaster, toast } from 'sonner'; @@ -22,6 +21,8 @@ import { hasRenderableElements, haveSameElements, isSuspiciousEmptySnapshot, + isStaleEmptySnapshot, + isStaleNonRenderableSnapshot, } from './editor/shared'; import type { ElementVersionInfo } from './editor/shared'; @@ -123,12 +124,55 @@ export const Editor: React.FC = () => { const cursorBuffer = useRef>(new Map()); const animationFrameId = useRef(0); const latestElementsRef = useRef([]); + const initialSceneElementsRef = useRef([]); const latestFilesRef = useRef(null); const lastSyncedFilesRef = useRef>({}); const latestAppStateRef = useRef(null); const debouncedSaveRef = useRef<((drawingId: string, elements: readonly any[], appState: any, files?: Record) => void) | null>(null); const currentDrawingVersionRef = useRef(null); const lastPersistedElementsRef = useRef([]); + const saveQueueRef = useRef>(Promise.resolve()); + const patchedAddFilesApisRef = useRef>(new WeakSet()); + const suspiciousBlankLoadRef = useRef(false); + const hasSceneChangesSinceLoadRef = useRef(false); + + const getRenderableBaselineSnapshot = useCallback((): readonly any[] => { + if (hasRenderableElements(lastPersistedElementsRef.current)) { + return lastPersistedElementsRef.current; + } + if (hasRenderableElements(initialSceneElementsRef.current)) { + return initialSceneElementsRef.current; + } + return latestElementsRef.current; + }, []); + + const resolveSafeSnapshot = useCallback( + (candidateSnapshot: readonly any[] = []) => { + const baseline = getRenderableBaselineSnapshot(); + const staleEmptySnapshot = isStaleEmptySnapshot(baseline, candidateSnapshot); + const staleNonRenderableSnapshot = isStaleNonRenderableSnapshot( + baseline, + candidateSnapshot + ); + + if (staleEmptySnapshot || staleNonRenderableSnapshot) { + return { + snapshot: baseline, + prevented: true, + staleEmptySnapshot, + staleNonRenderableSnapshot, + } as const; + } + + return { + snapshot: candidateSnapshot, + prevented: false, + staleEmptySnapshot: false, + staleNonRenderableSnapshot: false, + } as const; + }, + [getRenderableBaselineSnapshot] + ); const emitFilesDeltaIfNeeded = useCallback( (nextFiles: Record) => { @@ -353,7 +397,8 @@ export const Editor: React.FC = () => { // Ensure file-only updates (e.g. pasted image dataURL arriving asynchronously) // are broadcast immediately even if Excalidraw doesn't trigger `onChange` for files. - if (api && typeof api.addFiles === "function") { + if (api && typeof api.addFiles === "function" && !patchedAddFilesApisRef.current.has(api as object)) { + patchedAddFilesApisRef.current.add(api as object); const originalAddFiles = api.addFiles.bind(api); api.addFiles = (files: Record) => { originalAddFiles(files); @@ -366,6 +411,7 @@ export const Editor: React.FC = () => { // Persist after file data becomes available so new tabs (tab3) load correctly. if (didEmit && id && latestAppStateRef.current && debouncedSaveRef.current) { + hasSceneChangesSinceLoadRef.current = true; debouncedSaveRef.current(id, latestElementsRef.current, latestAppStateRef.current, latestFilesRef.current || {}); } }; @@ -446,9 +492,30 @@ export const Editor: React.FC = () => { gridSize: appState?.gridSize || null, }; - const persistableElements = Array.isArray(elements) ? elements : []; - if (isSuspiciousEmptySnapshot(lastPersistedElementsRef.current, persistableElements)) { - console.warn("[Editor] Skipping suspicious empty snapshot save", { drawingId }); + const candidateElements = Array.isArray(elements) ? elements : []; + const { + snapshot: safeElements, + prevented, + staleEmptySnapshot, + staleNonRenderableSnapshot, + } = resolveSafeSnapshot(candidateElements); + const persistableElements = Array.from(safeElements); + if (suspiciousBlankLoadRef.current && !hasRenderableElements(persistableElements)) { + console.warn("[Editor] Blocking non-renderable save due to suspicious blank load", { + drawingId, + elementCount: persistableElements.length, + }); + return; + } + if (staleEmptySnapshot || staleNonRenderableSnapshot) { + console.warn("[Editor] Skipping stale snapshot save", { + drawingId, + candidateElementCount: candidateElements.length, + fallbackElementCount: persistableElements.length, + prevented, + staleEmptySnapshot, + staleNonRenderableSnapshot, + }); return; } const persistableFiles = files ?? latestFilesRef.current ?? {}; @@ -460,35 +527,93 @@ export const Editor: React.FC = () => { appState: persistableAppState, }); - const updated = await api.updateDrawing(drawingId, { - elements: persistableElements, - appState: persistableAppState, - files: persistableFiles, - version: currentDrawingVersionRef.current ?? undefined, - }); - if (typeof updated.version === "number") { - currentDrawingVersionRef.current = updated.version; - } - lastPersistedElementsRef.current = persistableElements; + const persistScene = async (attempt: number): Promise => { + try { + const updated = await api.updateDrawing(drawingId, { + elements: persistableElements, + appState: persistableAppState, + files: persistableFiles, + version: currentDrawingVersionRef.current ?? undefined, + }); + if (typeof updated.version === "number") { + currentDrawingVersionRef.current = updated.version; + } + lastPersistedElementsRef.current = persistableElements; + console.log("[Editor] Save complete", { drawingId }); + } catch (err) { + if (api.isAxiosError(err) && err.response?.status === 409) { + const reportedVersion = Number(err.response?.data?.currentVersion); + const hasReportedVersion = Number.isInteger(reportedVersion) && reportedVersion > 0; + if (hasReportedVersion) { + currentDrawingVersionRef.current = reportedVersion; + } - console.log("[Editor] Save complete", { drawingId }); + if (attempt === 0 && hasReportedVersion) { + console.warn("[Editor] Version conflict while saving drawing, retrying once", { + drawingId, + currentVersion: reportedVersion, + }); + await persistScene(1); + return; + } + + console.warn("[Editor] Version conflict while saving drawing", { drawingId }); + toast.error("Drawing changed in another tab. Refresh to load latest."); + return; + } + + throw err; + } + }; + + await persistScene(0); } catch (err) { - if (api.isAxiosError(err) && err.response?.status === 409) { - console.warn("[Editor] Version conflict while saving drawing", { drawingId }); - toast.error("Drawing changed in another tab. Refresh to load latest."); - return; - } console.error('Failed to save drawing', err); toast.error("Failed to save changes"); } }; + const enqueueSceneSave = useCallback( + (drawingId: string, elements: readonly any[], appState: any, files?: Record) => { + saveQueueRef.current = saveQueueRef.current + .catch(() => undefined) + .then(async () => { + if (!saveDataRef.current) return; + await saveDataRef.current(drawingId, elements, appState, files); + }); + return saveQueueRef.current; + }, + [] + ); + savePreviewRef.current = async (drawingId: string, elements: readonly any[], appState: any, files: any) => { if (!drawingId) return; try { - const currentSnapshot = latestElementsRef.current ?? elements; + const candidateSnapshot = latestElementsRef.current ?? elements; + const { + snapshot: currentSnapshot, + prevented: preventedPreviewOverwrite, + staleEmptySnapshot: staleEmptyPreview, + staleNonRenderableSnapshot: staleNonRenderablePreview, + } = resolveSafeSnapshot(candidateSnapshot); const currentFiles = latestFilesRef.current ?? files; + if (suspiciousBlankLoadRef.current && !hasRenderableElements(currentSnapshot)) { + console.warn("[Editor] Blocking non-renderable preview due to suspicious blank load", { + drawingId, + elementCount: currentSnapshot.length, + }); + return; + } + + if (preventedPreviewOverwrite) { + console.warn("[Editor] Prevented stale snapshot preview overwrite", { + drawingId, + staleEmptyPreview, + staleNonRenderablePreview, + fallbackElementCount: currentSnapshot.length, + }); + } const svg = await exportToSvg({ elements: currentSnapshot, @@ -528,11 +653,9 @@ export const Editor: React.FC = () => { const debouncedSave = useCallback( debounce((drawingId, elements, appState, files) => { - if (saveDataRef.current) { - saveDataRef.current(drawingId, elements, appState, files); - } + enqueueSceneSave(drawingId, elements, appState, files); }, 1000), - [] // Empty dependency array = Stable across renders + [enqueueSceneSave] // Stable queue wrapper avoids concurrent version conflicts ); // Allow non-hook code (e.g., Excalidraw API wrappers) to trigger debounced saves. debouncedSaveRef.current = debouncedSave; @@ -602,11 +725,15 @@ export const Editor: React.FC = () => { isBootstrappingScene.current = true; hasHydratedInitialScene.current = false; elementVersionMap.current.clear(); + saveQueueRef.current = Promise.resolve(); latestElementsRef.current = []; + initialSceneElementsRef.current = []; latestFilesRef.current = {}; lastSyncedFilesRef.current = {}; currentDrawingVersionRef.current = null; lastPersistedElementsRef.current = []; + suspiciousBlankLoadRef.current = false; + hasSceneChangesSinceLoadRef.current = false; excalidrawAPI.current = null; setIsReady(false); setIsSceneLoading(true); @@ -631,7 +758,20 @@ export const Editor: React.FC = () => { const elements = data.elements || []; const files = data.files || {}; + const hasPreview = typeof data.preview === "string" && data.preview.trim().length > 0; + const loadedRenderable = hasRenderableElements(elements); + suspiciousBlankLoadRef.current = !loadedRenderable && hasPreview; + hasSceneChangesSinceLoadRef.current = false; + console.log("[Editor] Loaded drawing", { + drawingId: id, + elementCount: elements.length, + loadedRenderable, + hasPreview, + version: data.version ?? null, + suspiciousBlankLoad: suspiciousBlankLoadRef.current, + }); latestElementsRef.current = elements; + initialSceneElementsRef.current = elements; latestFilesRef.current = files; lastSyncedFilesRef.current = files; currentDrawingVersionRef.current = typeof data.version === "number" ? data.version : null; @@ -677,10 +817,13 @@ export const Editor: React.FC = () => { } toast.error(message); latestElementsRef.current = []; + initialSceneElementsRef.current = []; latestFilesRef.current = {}; lastSyncedFilesRef.current = {}; currentDrawingVersionRef.current = null; lastPersistedElementsRef.current = []; + suspiciousBlankLoadRef.current = false; + hasSceneChangesSinceLoadRef.current = false; setLoadError(message); setInitialData(null); } finally { @@ -697,20 +840,34 @@ export const Editor: React.FC = () => { e.preventDefault(); if (excalidrawAPI.current && saveDataRef.current && savePreviewRef.current) { const elements = excalidrawAPI.current.getSceneElementsIncludingDeleted(); + const { + snapshot: safeElements, + prevented, + staleEmptySnapshot, + staleNonRenderableSnapshot, + } = resolveSafeSnapshot(elements); const appState = excalidrawAPI.current.getAppState(); const files = excalidrawAPI.current.getFiles() || {}; - latestElementsRef.current = elements; latestFilesRef.current = files; + if (prevented) { + console.warn("[Editor] Prevented stale Ctrl+S snapshot overwrite", { + drawingId: id, + staleEmptySnapshot, + staleNonRenderableSnapshot, + candidateElementCount: elements.length, + fallbackElementCount: safeElements.length, + }); + } if (!id) return; - await saveDataRef.current(id, elements, appState, files); - savePreviewRef.current(id, elements, appState, files); + await enqueueSceneSave(id, safeElements, appState, files); + savePreviewRef.current(id, safeElements, appState, files); toast.success("Saved changes to server"); } } }; window.addEventListener('keydown', handleKeyDown); return () => window.removeEventListener('keydown', handleKeyDown); - }, []); + }, [enqueueSceneSave, id, resolveSafeSnapshot]); const handleCanvasChange = useCallback((elements: readonly any[], appState: any, files?: Record) => { if (isUnmounting.current) { @@ -733,7 +890,29 @@ export const Editor: React.FC = () => { : elements; if (!hasHydratedInitialScene.current) { - const matchesInitialSnapshot = haveSameElements(allElements, latestElementsRef.current); + const matchesInitialSnapshot = haveSameElements( + allElements, + initialSceneElementsRef.current + ); + const transientHydrationEmpty = isSuspiciousEmptySnapshot( + initialSceneElementsRef.current, + allElements + ); + const transientHydrationNonRenderable = isStaleNonRenderableSnapshot( + initialSceneElementsRef.current, + allElements + ); + + if (transientHydrationEmpty || transientHydrationNonRenderable) { + console.log("[Editor] Skipping transient hydration snapshot", { + drawingId: id, + elementCount: allElements.length, + transientHydrationEmpty, + transientHydrationNonRenderable, + }); + return; + } + hasHydratedInitialScene.current = true; isBootstrappingScene.current = false; @@ -751,9 +930,35 @@ export const Editor: React.FC = () => { }); } - latestElementsRef.current = allElements; + const noFileChanges = + Object.keys(getFilesDelta(latestFilesRef.current || {}, currentFiles || {})).length === 0; + if (haveSameElements(allElements, latestElementsRef.current) && noFileChanges) { + return; + } + + const { + prevented: preventedCanvasOverwrite, + staleEmptySnapshot: staleEmptyCanvasSnapshot, + staleNonRenderableSnapshot: staleNonRenderableCanvasSnapshot, + } = resolveSafeSnapshot(allElements); + if (preventedCanvasOverwrite) { + console.warn("[Editor] Skipping stale non-renderable change", { + drawingId: id, + elementCount: allElements.length, + staleEmptyCanvasSnapshot, + staleNonRenderableCanvasSnapshot, + }); + return; + } const hasRenderable = hasRenderableElements(allElements); + if (hasRenderable && suspiciousBlankLoadRef.current) { + suspiciousBlankLoadRef.current = false; + console.log("[Editor] Cleared suspicious blank load guard after renderable edit", { + drawingId: id, + elementCount: allElements.length, + }); + } if (isBootstrappingScene.current && !hasRenderable) { console.log("[Editor] Bootstrapping guard active", { drawingId: id, @@ -761,6 +966,8 @@ export const Editor: React.FC = () => { }); return; } + latestElementsRef.current = allElements; + hasSceneChangesSinceLoadRef.current = true; // Trigger Sync (Throttled) broadcastChanges(allElements, currentFiles); @@ -786,7 +993,7 @@ export const Editor: React.FC = () => { if (id) { debouncedSavePreview(id, allElements, appState, filesSnapshot); } - }, [debouncedSave, debouncedSavePreview, broadcastChanges, id]); + }, [debouncedSave, debouncedSavePreview, broadcastChanges, id, resolveSafeSnapshot]); // Ensure file-only updates (e.g. pasted image dataURL arriving asynchronously) // are still broadcast to collaborators AND persisted to the server. @@ -804,6 +1011,7 @@ export const Editor: React.FC = () => { // Persist after file data becomes available (covers the "tab 3" case). if (didEmit && latestAppStateRef.current && debouncedSaveRef.current) { + hasSceneChangesSinceLoadRef.current = true; debouncedSaveRef.current(id, latestElementsRef.current, latestAppStateRef.current, nextFiles); } }, 1000); @@ -840,16 +1048,46 @@ export const Editor: React.FC = () => { // Save drawing and generate preview before navigating try { if (excalidrawAPI.current && saveDataRef.current && savePreviewRef.current) { + if (!hasSceneChangesSinceLoadRef.current) { + console.log("[Editor] Skipping back-navigation save: no scene changes since load", { + drawingId: id, + }); + navigate('/'); + return; + } if (!id) return; const elements = excalidrawAPI.current.getSceneElementsIncludingDeleted(); + const { + snapshot: safeElements, + prevented, + staleEmptySnapshot, + staleNonRenderableSnapshot, + } = resolveSafeSnapshot(elements); const appState = excalidrawAPI.current.getAppState(); const files = excalidrawAPI.current.getFiles() || {}; - latestElementsRef.current = elements; latestFilesRef.current = files; + if (prevented) { + console.warn("[Editor] Prevented stale back-navigation snapshot overwrite", { + drawingId: id, + staleEmptySnapshot, + staleNonRenderableSnapshot, + candidateElementCount: elements.length, + fallbackElementCount: safeElements.length, + }); + } + if (suspiciousBlankLoadRef.current && !hasRenderableElements(safeElements)) { + console.warn("[Editor] Blocking back-navigation save due to suspicious blank load", { + drawingId: id, + elementCount: safeElements.length, + }); + toast.warning("Blank scene detected on load. Skipping save to protect existing data."); + navigate('/'); + return; + } await Promise.all([ - saveDataRef.current(id, elements, appState, files), - savePreviewRef.current(id, elements, appState, files) + enqueueSceneSave(id, safeElements, appState, files), + savePreviewRef.current(id, safeElements, appState, files) ]); console.log("[Editor] Saved on back navigation", { drawingId: id }); } diff --git a/frontend/src/pages/editor/shared.test.ts b/frontend/src/pages/editor/shared.test.ts index c13aec6..a49b4e3 100644 --- a/frontend/src/pages/editor/shared.test.ts +++ b/frontend/src/pages/editor/shared.test.ts @@ -2,6 +2,8 @@ import { describe, expect, it } from "vitest"; import { hasRenderableElements, isSuspiciousEmptySnapshot, + isStaleEmptySnapshot, + isStaleNonRenderableSnapshot, } from "./shared"; describe("editor/shared scene guards", () => { @@ -29,4 +31,31 @@ describe("editor/shared scene guards", () => { const next = [{ id: "a", isDeleted: true }]; expect(isSuspiciousEmptySnapshot(previous, next)).toBe(false); }); + + it("flags stale empty snapshot when latest scene is non-empty", () => { + const latest = [{ id: "a", version: 2, versionNonce: 2, isDeleted: false }]; + expect(isStaleEmptySnapshot(latest, [])).toBe(true); + }); + + it("does not flag empty snapshot when latest scene is already empty", () => { + expect(isStaleEmptySnapshot([], [])).toBe(false); + }); + + it("does not flag identical empty snapshots", () => { + const latest = []; + const candidate = []; + expect(isStaleEmptySnapshot(latest, candidate)).toBe(false); + }); + + it("flags stale non-renderable snapshot when latest scene has renderable elements", () => { + const latest = [{ id: "a", version: 2, versionNonce: 2, isDeleted: false }]; + const candidate = [{ id: "a", version: 1, versionNonce: 1, isDeleted: true }]; + expect(isStaleNonRenderableSnapshot(latest, candidate)).toBe(true); + }); + + it("does not flag non-renderable snapshot when latest scene is already non-renderable", () => { + const latest = [{ id: "a", version: 2, versionNonce: 2, isDeleted: true }]; + const candidate = [{ id: "a", version: 1, versionNonce: 1, isDeleted: true }]; + expect(isStaleNonRenderableSnapshot(latest, candidate)).toBe(false); + }); }); diff --git a/frontend/src/pages/editor/shared.ts b/frontend/src/pages/editor/shared.ts index d05b08c..1973c65 100644 --- a/frontend/src/pages/editor/shared.ts +++ b/frontend/src/pages/editor/shared.ts @@ -32,6 +32,37 @@ export const isSuspiciousEmptySnapshot = ( return hasRenderableElements(previousPersisted); }; +/** + * Detects a stale empty snapshot that is older than the current in-memory scene. + * This prevents race conditions where an outdated empty `onChange` event can + * overwrite a newer non-empty scene. + */ +export const isStaleEmptySnapshot = ( + latestSnapshot: readonly any[] = [], + candidateSnapshot: readonly any[] = [] +): boolean => { + if (!Array.isArray(candidateSnapshot) || candidateSnapshot.length > 0) return false; + if (!hasRenderableElements(latestSnapshot)) return false; + return !haveSameElements(latestSnapshot, candidateSnapshot); +}; + +/** + * Detects a stale snapshot that has no renderable elements while the latest + * in-memory scene still has renderable content. + * + * This covers cases where Excalidraw emits a transient non-renderable scene + * (e.g. hydration race) that should not overwrite newer content. + */ +export const isStaleNonRenderableSnapshot = ( + latestSnapshot: readonly any[] = [], + candidateSnapshot: readonly any[] = [] +): boolean => { + if (!Array.isArray(candidateSnapshot)) return false; + if (hasRenderableElements(candidateSnapshot)) return false; + if (!hasRenderableElements(latestSnapshot)) return false; + return !haveSameElements(latestSnapshot, candidateSnapshot); +}; + const buildFileSignature = (file: any): string => { const mimeType = typeof file?.mimeType === "string" ? file.mimeType : ""; const id = typeof file?.id === "string" ? file.id : ""; diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index d4bbf8b..b2b6506 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -19,19 +19,33 @@ const appVersion = process.env.VITE_APP_VERSION?.trim() || versionFromFile; const buildLabel = process.env.VITE_APP_BUILD_LABEL?.trim() || "local development build"; // https://vite.dev/config/ -export default defineConfig({ - plugins: [react()], - define: { - 'import.meta.env.VITE_APP_VERSION': JSON.stringify(appVersion), - 'import.meta.env.VITE_APP_BUILD_LABEL': JSON.stringify(buildLabel), - }, - server: { - proxy: { - "/api": { - target: "http://localhost:8000", - changeOrigin: true, - rewrite: (path) => path.replace(/^\/api/, ""), +export default defineConfig(({ command }) => { + const nodeEnv = process.env.NODE_ENV || (command === "build" ? "production" : "development"); + const processEnvDefines = { + 'process.env.IS_PREACT': JSON.stringify("false"), + 'process.env.NODE_ENV': JSON.stringify(nodeEnv), + }; + + return { + plugins: [react()], + define: { + ...processEnvDefines, + 'import.meta.env.VITE_APP_VERSION': JSON.stringify(appVersion), + 'import.meta.env.VITE_APP_BUILD_LABEL': JSON.stringify(buildLabel), + }, + optimizeDeps: { + esbuildOptions: { + define: processEnvDefines, }, }, - }, + server: { + proxy: { + "/api": { + target: "http://localhost:8000", + changeOrigin: true, + rewrite: (path) => path.replace(/^\/api/, ""), + }, + }, + }, + }; });