From 304f7f30c3cd4d25ae9636670a5ab0e4a929183f Mon Sep 17 00:00:00 2001 From: Leon Serfaty <80597822+silkoserfo@users.noreply.github.com> Date: Fri, 17 Jul 2026 13:34:33 -0400 Subject: [PATCH] Security hardening: deps, tenancy quotas, auth, deploy, webhooks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses the findings from the platform security audit. Verified green: all-workspace typecheck, web build, 16 API unit tests, 23 e2e auth tests, and 0 high/critical production dependency vulnerabilities. Dependencies (High): - Bump drizzle-orm 0.36→0.45.2 (GHSA-gpj5-g38j-94v9 SQLi-via-identifier) and drizzle-kit→0.31.10; npm audit fix cleared fast-uri path-traversal and the react-router open-redirect. Remaining audit items are dev-only build tooling (esbuild/vite), not shipped at runtime. AI cost control + storage quota (new ai_usage table, migration 0002): - Per-firm monthly AI token budget enforced before each completion (429), with every completion recorded to an ai_usage ledger (lib/ai-usage.ts). - Enforce per-plan storage quota on upload (402) and maintain storage_bytes_used on upload/delete (lib/storage-quota.ts); widen the column int→bigint so 8GB/50GB plans don't overflow. Auth (defense-in-depth): - Constant-time login: verify against a dummy argon2 hash when the account doesn't exist, closing the timing/enumeration oracle (verifyPasswordSafe). - Enforce suspension on requireSuperadmin, /auth/me, /auth/resend-verification. Web: - Validate the post-login ?next= redirect to same-origin paths only (open-redirect / phishing). Deploy hardening: - docker-compose: memory/CPU limits so a spike can't OOM the Dokploy host. - .dockerignore: keep destructive one-off scripts (seed-demo, create-admin, migrate-storage) out of the runtime image; retain the cron scripts. - seed-demo.ts: hard-refuse NODE_ENV=production and the prod DB host. Webhooks / config: - Stripe idempotency via a stripe_events ledger (skip already-processed events; record only after successful processing so a transient failure still retries); make the plan-upgraded email non-blocking. - Rate-limit account export and invoice PDF; cap invoice item arrays at 200. - Require TURNSTILE_SECRET_KEY in production (bot protection no longer fails open on a forgotten key); don't load .env under NODE_ENV=test so the suite is hermetic. Co-Authored-By: Claude Fable 5 --- .dockerignore | 19 +- Dockerfile | 11 + apps/api/package.json | 2 +- apps/api/src/auth/password.ts | 22 + apps/api/src/auth/plugin.ts | 1 + apps/api/src/env.ts | 17 +- apps/api/src/lib/ai-usage.ts | 68 + apps/api/src/lib/storage-quota.ts | 36 + apps/api/src/routes/account.ts | 5 +- apps/api/src/routes/ai.ts | 39 + apps/api/src/routes/auth.ts | 8 +- apps/api/src/routes/documents.ts | 26 +- apps/api/src/routes/invoices.ts | 9 +- apps/api/src/routes/webhooks-stripe.ts | 28 +- apps/api/test-e2e/env.ts | 5 +- apps/web/src/pages/LoginPage.tsx | 12 +- docker-compose.yml | 10 + package-lock.json | 1004 +++++---- .../db/migrations/0002_daily_chronomancer.sql | 25 + .../db/migrations/meta/0002_snapshot.json | 1822 +++++++++++++++++ packages/db/migrations/meta/_journal.json | 7 + packages/db/package.json | 4 +- packages/db/src/schema/firms.ts | 5 +- packages/db/src/schema/misc.ts | 32 +- scripts/seed-demo.ts | 39 +- 25 files changed, 2854 insertions(+), 402 deletions(-) create mode 100644 apps/api/src/lib/ai-usage.ts create mode 100644 apps/api/src/lib/storage-quota.ts create mode 100644 packages/db/migrations/0002_daily_chronomancer.sql create mode 100644 packages/db/migrations/meta/0002_snapshot.json diff --git a/.dockerignore b/.dockerignore index a8b5b7f..c1b3412 100644 --- a/.dockerignore +++ b/.dockerignore @@ -34,9 +34,26 @@ tmp storage uploads -# Tests aren't needed in the runtime image. +# Tests and test tooling aren't needed in the runtime image. apps/api/test **/*.test.ts +**/*.spec.ts +**/vitest.config.ts +**/vitest.*.config.ts + +# Destructive/privileged one-off scripts must NOT ship in the runtime image: an attacker with +# code-exec in the container has DATABASE_URL in-env, so keeping these off disk removes the sharpest +# RCE-amplification tools. Root-anchored to the top-level scripts/ only (apps/web/scripts, used by the +# web build, is a different directory and is kept). DB migrations run via `npm run db:migrate` +# (packages/db), not from scripts/, so this does not affect builds or deploys. +# +# The routine, non-destructive cron scripts (retention-sweep, send-overdue-reminders, +# sweep-orphaned-storage) are intentionally KEPT so Dokploy scheduled jobs can invoke them inside +# the container (e.g. `npm run cron:retention`, which enforces Privacy-Policy retention windows). +/scripts/seed-demo.ts +/scripts/create-admin.ts +/scripts/migrate-storage-to-spaces.ts +/scripts/plesk-deploy.sh # Note: certs/ is intentionally NOT ignored — the Postgres CA cert (if committed) is baked in # so production TLS verification works. See DEPLOY-DOKPLOY.md. diff --git a/Dockerfile b/Dockerfile index 5a6470c..e789576 100644 --- a/Dockerfile +++ b/Dockerfile @@ -49,6 +49,17 @@ RUN apt-get update \ # Bring over the fully-installed, already-built app (node_modules incl. the compiled argon2 binary # and workspace symlinks, apps/web/dist, TS source run by tsx, and certs/ if the CA cert is present). +# +# Attack-surface note / future hardening: the runtime executes TypeScript source directly through the +# tsx ESM loader (see server.js), so this image MUST ship tsx (a prod dependency of apps/api) plus the +# TS sources and the full node_modules from the builder. node_modules is copied whole rather than +# pruned because tsx and its transitive prod deps are resolved at runtime, and an aggressive +# `npm prune --omit=dev` here risks breaking that resolution — correctness of the running container +# takes priority. The destructive operational scripts/ dir and test files are already excluded from +# the build context (see .dockerignore), so they never reach this image. +# Future improvement: precompile the API to plain JS (tsc/esbuild) in the builder stage and run it via +# plain `node dist/server.js`. That removes the tsx runtime dependency and lets the runtime install +# prod-only deps (`npm ci --omit=dev`), further shrinking the image and its attack surface. COPY --from=builder --chown=app:app /app /app USER app diff --git a/apps/api/package.json b/apps/api/package.json index 0822cfb..630b506 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -27,7 +27,7 @@ "@sentry/node": "^8.45.0", "argon2": "^0.41.1", "dotenv": "^16.4.5", - "drizzle-orm": "^0.36.4", + "drizzle-orm": "^0.45.2", "fastify": "^5.1.0", "fastify-plugin": "^5.0.1", "fastify-type-provider-zod": "^4.0.2", diff --git a/apps/api/src/auth/password.ts b/apps/api/src/auth/password.ts index ba3d3f7..dda62ab 100644 --- a/apps/api/src/auth/password.ts +++ b/apps/api/src/auth/password.ts @@ -1,4 +1,5 @@ import argon2 from 'argon2'; +import crypto from 'node:crypto'; const ARGON2_OPTIONS: argon2.Options = { type: argon2.argon2id, @@ -14,3 +15,24 @@ export function hashPassword(password: string): Promise { export function verifyPassword(hash: string, password: string): Promise { return argon2.verify(hash, password); } + +// Precomputed dummy argon2id hash for constant-time login. When an account doesn't exist we +// still run a full verify against this hash so the nonexistent-account path costs the same as a +// real (failing) password check — closing the timing/enumeration oracle. Computed once at module +// load from a random throwaway secret; the promise is cached so the hash cost is paid a single time. +const dummyHashPromise: Promise = hashPassword(crypto.randomBytes(32).toString('hex')); + +// Verifies `password` against `hash` when present, otherwise against the dummy hash so the +// account-exists and account-missing paths do equal argon2 work. Always resolves to a boolean and +// never throws (a null/undefined or malformed hash simply resolves to false). +export async function verifyPasswordSafe( + hash: string | null | undefined, + password: string, +): Promise { + const target = hash ?? (await dummyHashPromise); + try { + return await argon2.verify(target, password); + } catch { + return false; + } +} diff --git a/apps/api/src/auth/plugin.ts b/apps/api/src/auth/plugin.ts index 41d0b8f..b2ab7b7 100644 --- a/apps/api/src/auth/plugin.ts +++ b/apps/api/src/auth/plugin.ts @@ -65,6 +65,7 @@ async function plugin(app: FastifyInstance) { app.decorate('requireSuperadmin', async (req: FastifyRequest, reply: FastifyReply) => { if (!req.user) return reply.code(401).send({ error: 'unauthorized' }); + if (req.user.isSuspended) return reply.code(403).send({ error: 'account_suspended' }); if (!req.user.isSuperadmin) return reply.code(403).send({ error: 'forbidden' }); }); diff --git a/apps/api/src/env.ts b/apps/api/src/env.ts index 475e540..c409ade 100644 --- a/apps/api/src/env.ts +++ b/apps/api/src/env.ts @@ -4,8 +4,12 @@ import dotenv from 'dotenv'; import { z } from 'zod'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); -// Load .env from the monorepo root regardless of cwd -dotenv.config({ path: path.resolve(__dirname, '../../../.env') }); +// Load .env from the monorepo root regardless of cwd — but NEVER under test. The test suites +// inject their own env explicitly; loading a developer's/CI's real .env here makes tests +// non-hermetic (e.g. a real TURNSTILE_SECRET_KEY would switch on CAPTCHA and break auth tests). +if (process.env.NODE_ENV !== 'test') { + dotenv.config({ path: path.resolve(__dirname, '../../../.env') }); +} const envSchema = z.object({ NODE_ENV: z.enum(['development', 'production', 'test']).default('development'), @@ -41,6 +45,15 @@ const envSchema = z.object({ const parsed = envSchema.parse(process.env); +// Production guard: Turnstile bot protection fails open when TURNSTILE_SECRET_KEY is unset +// (correct for dev/test, but in production a forgotten key silently disables all CAPTCHA/bot +// protection on signup/login/password-reset/contact). Fail fast at boot rather than run exposed. +if (parsed.NODE_ENV === 'production' && !parsed.TURNSTILE_SECRET_KEY) { + throw new Error( + 'TURNSTILE_SECRET_KEY is required in production: without it, bot protection fails open and CAPTCHA verification is skipped entirely. Set TURNSTILE_SECRET_KEY in the environment.', + ); +} + export const env = { ...parsed, superadminEmails: parsed.SUPERADMIN_EMAILS.split(',') diff --git a/apps/api/src/lib/ai-usage.ts b/apps/api/src/lib/ai-usage.ts new file mode 100644 index 0000000..4538ee4 --- /dev/null +++ b/apps/api/src/lib/ai-usage.ts @@ -0,0 +1,68 @@ +// Per-firm AI usage metering and monthly token quotas. Keeps LLM spend bounded by +// capping how many tokens a firm can consume per calendar month, and records every +// completion into the `aiUsage` ledger for cost accounting. +import { and, eq, gte, sql } from 'drizzle-orm'; +import { getDb, aiUsage } from '@lawdesk/db'; +import type { PlanName } from './plan-limits'; + +// Monthly token budget (inputTokens + outputTokens) per plan. Tunable — bump these as +// pricing/usage patterns settle. Unknown plans fall back to the starter budget. +export const AI_MONTHLY_TOKEN_BUDGET: Record = { + starter: 100_000, + pro: 2_000_000, + lifetime: 10_000_000, +}; + +export class AiQuotaError extends Error { + constructor() { + super('ai_quota_exceeded'); + this.name = 'AiQuotaError'; + } +} + +/** + * Throws AiQuotaError if the firm has met or exceeded its monthly token budget. + * Sums tokens used since the start of the current calendar month. + */ +export async function assertAiQuota(firmId: string, plan: string): Promise { + const budget = AI_MONTHLY_TOKEN_BUDGET[plan as PlanName] ?? AI_MONTHLY_TOKEN_BUDGET.starter; + + const monthStart = new Date(); + monthStart.setDate(1); + monthStart.setHours(0, 0, 0, 0); + + const [row] = await getDb() + .select({ + total: sql`coalesce(sum(${aiUsage.inputTokens} + ${aiUsage.outputTokens}), 0)::bigint`, + }) + .from(aiUsage) + .where(and(eq(aiUsage.firmId, firmId), gte(aiUsage.createdAt, monthStart))); + + const used = Number(row?.total ?? 0); + if (used >= budget) throw new AiQuotaError(); +} + +/** + * Records one completion into the AI usage ledger. Best-effort: a failed metering write + * must never fail the underlying request, so errors are swallowed (and logged). + */ +export async function recordAiUsage(opts: { + firmId: string; + userId: string; + feature: string; + model: string; + usage: { inputTokens: number; outputTokens: number }; +}): Promise { + try { + await getDb().insert(aiUsage).values({ + firmId: opts.firmId, + userId: opts.userId, + feature: opts.feature, + model: opts.model, + inputTokens: opts.usage.inputTokens, + outputTokens: opts.usage.outputTokens, + }); + } catch (err) { + console.warn('[ai-usage] failed to record usage', err); + } +} diff --git a/apps/api/src/lib/storage-quota.ts b/apps/api/src/lib/storage-quota.ts new file mode 100644 index 0000000..93ffafd --- /dev/null +++ b/apps/api/src/lib/storage-quota.ts @@ -0,0 +1,36 @@ +import { eq, sql } from 'drizzle-orm'; +import { getDb, firms } from '@lawdesk/db'; +import { PLAN_LIMITS, PlanLimitError, type PlanName } from './plan-limits'; + +/** + * Throw a PlanLimitError('storageBytes', plan) if accepting `additionalBytes` more + * would push the firm past its plan's storage cap. Plans with a null cap are unlimited. + */ +export async function assertWithinStorageQuota( + firmId: string, + plan: PlanName, + additionalBytes: number, +): Promise { + const limit = PLAN_LIMITS[plan].storageBytes; + if (limit === null) return; + + const [row] = await getDb() + .select({ used: firms.storageBytesUsed }) + .from(firms) + .where(eq(firms.id, firmId)) + .limit(1); + + const used = row?.used ?? 0; + if (used + additionalBytes > limit) throw new PlanLimitError('storageBytes', plan); +} + +/** + * Atomically adjust the firm's tracked storage usage by `deltaBytes` (may be negative). + * Clamped at 0 so a decrement can never drive the counter below zero. + */ +export async function incrementStorageUsed(firmId: string, deltaBytes: number): Promise { + await getDb() + .update(firms) + .set({ storageBytesUsed: sql`GREATEST(0, ${firms.storageBytesUsed} + ${deltaBytes})` }) + .where(eq(firms.id, firmId)); +} diff --git a/apps/api/src/routes/account.ts b/apps/api/src/routes/account.ts index 6ab2704..0626307 100644 --- a/apps/api/src/routes/account.ts +++ b/apps/api/src/routes/account.ts @@ -22,7 +22,10 @@ export async function accountRoutes(app: FastifyInstance) { app.addHook('preHandler', app.requireAuth); // GDPR data export — full JSON dump of everything tied to the user's firm. - app.get('/api/account/export', async (req, reply) => { + app.get( + '/api/account/export', + { config: { rateLimit: { max: 5, timeWindow: '1 hour' } } }, + async (req, reply) => { const userId = req.user!.id; const firmId = req.user!.firmId; const db = getDb(); diff --git a/apps/api/src/routes/ai.ts b/apps/api/src/routes/ai.ts index 3bdaa47..772aebf 100644 --- a/apps/api/src/routes/ai.ts +++ b/apps/api/src/routes/ai.ts @@ -3,6 +3,8 @@ import { z } from 'zod'; import { and, desc, eq } from 'drizzle-orm'; import { getDb, cases, clients, timeEntries, documents, invoices } from '@lawdesk/db'; import { aiComplete, isAiEnabled, AiDisabledError, AiUnavailableError, AI_MODEL } from '../lib/ai'; +import { assertAiQuota, recordAiUsage, AiQuotaError } from '../lib/ai-usage'; +import { loadFirm } from '../lib/firm'; import { getObjectStream, FileNotFoundError } from '../lib/storage'; // The one non-negotiable framing for a legal-tech product: the model assists with @@ -23,6 +25,9 @@ const AI_MIME = { const MAX_AI_DOC_BYTES = 15 * 1024 * 1024; // base64 expansion must stay under the 32MB request cap function sendAiError(reply: FastifyReply, err: unknown): FastifyReply { + if (err instanceof AiQuotaError) { + return reply.code(429).send({ error: 'ai_quota_exceeded' }); + } if (err instanceof AiDisabledError) { return reply.code(503).send({ error: 'ai_not_configured' }); } @@ -115,7 +120,11 @@ export async function aiRoutes(app: FastifyInstance) { .filter((line) => line !== '') .join('\n'); + const firm = await loadFirm(firmId); + if (!firm) return reply.code(403).send({ error: 'firm_missing' }); + try { + await assertAiQuota(firmId, firm.plan); const result = await aiComplete({ system: `${BASE_SYSTEM} Write a case brief for the attorney working this case, as plain text (no markdown syntax) with these section headings on their own lines: @@ -126,6 +135,13 @@ GAPS & FOLLOW-UPS — anything the records suggest needs attention (stale activi Keep it under 300 words.`, content: context, }); + await recordAiUsage({ + firmId, + userId: req.user!.id, + feature: 'case_summary', + model: AI_MODEL, + usage: result.usage, + }); return { summary: result.text, disclaimer: AI_DISCLAIMER, usage: result.usage }; } catch (err) { return sendAiError(reply, err); @@ -201,8 +217,19 @@ Keep it under 300 words.`; { type: 'text' as const, text: instruction }, ]); + const firm = await loadFirm(firmId); + if (!firm) return reply.code(403).send({ error: 'firm_missing' }); + try { + await assertAiQuota(firmId, firm.plan); const result = await aiComplete({ system: BASE_SYSTEM, content }); + await recordAiUsage({ + firmId, + userId: req.user!.id, + feature: 'document_summary', + model: AI_MODEL, + usage: result.usage, + }); return { summary: result.text, disclaimer: AI_DISCLAIMER, usage: result.usage }; } catch (err) { return sendAiError(reply, err); @@ -216,6 +243,7 @@ Keep it under 300 words.`; '/api/ai/polish', { config: { rateLimit: { max: 60, timeWindow: '1 hour' } } }, async (req, reply) => { + const firmId = req.user!.firmId!; const body = z .object({ text: z.string().min(1).max(10_000), @@ -234,7 +262,11 @@ Keep it under 300 words.`; 'Rewrite as a professional, warm message from a law firm to its client. Plain language, no legalese.', }; + const firm = await loadFirm(firmId); + if (!firm) return reply.code(403).send({ error: 'firm_missing' }); + try { + await assertAiQuota(firmId, firm.plan); const result = await aiComplete({ system: `${BASE_SYSTEM} ${KIND_GUIDANCE[body.kind]} @@ -242,6 +274,13 @@ Return ONLY the rewritten text — no preamble, no quotes, no commentary. Preser content: body.text, maxTokens: 800, }); + await recordAiUsage({ + firmId, + userId: req.user!.id, + feature: 'polish', + model: AI_MODEL, + usage: result.usage, + }); return { text: result.text, usage: result.usage }; } catch (err) { return sendAiError(reply, err); diff --git a/apps/api/src/routes/auth.ts b/apps/api/src/routes/auth.ts index fe1a775..2e08bfc 100644 --- a/apps/api/src/routes/auth.ts +++ b/apps/api/src/routes/auth.ts @@ -11,7 +11,7 @@ import { emailVerifications, sessions as sessionsTable, } from '@lawdesk/db'; -import { hashPassword, verifyPassword } from '../auth/password'; +import { hashPassword, verifyPasswordSafe } from '../auth/password'; import { SESSION_COOKIE, createSession, destroySession } from '../auth/sessions'; import { ensureSuperadminFlag } from '../auth/superadmin'; import { generateCsrfToken } from '../auth/csrf'; @@ -167,7 +167,9 @@ export async function authRoutes(app: FastifyInstance) { const [user] = await db.select().from(users).where(eq(users.email, body.email)).limit(1); - const ok = user ? await verifyPassword(user.passwordHash, body.password) : false; + // Always run an argon2 verify — against the real hash if the account exists, else against a + // dummy hash — so both paths take equal time and can't be used to enumerate valid emails. + const ok = await verifyPasswordSafe(user?.passwordHash, body.password); await db.insert(loginAttempts).values({ email: body.email, ip, success: ok }); @@ -220,6 +222,7 @@ export async function authRoutes(app: FastifyInstance) { app.get('/api/auth/me', async (req, reply) => { if (!req.user) return reply.code(401).send({ error: 'unauthorized' }); + if (req.user.isSuspended) return reply.code(403).send({ error: 'account_suspended' }); return { user: req.user }; }); @@ -361,6 +364,7 @@ export async function authRoutes(app: FastifyInstance) { { config: { rateLimit: { max: 3, timeWindow: '15 minutes' } } }, async (req, reply) => { if (!req.user) return reply.code(401).send({ error: 'unauthorized' }); + if (req.user.isSuspended) return reply.code(403).send({ error: 'account_suspended' }); const db = getDb(); const [user] = await db.select().from(users).where(eq(users.id, req.user.id)).limit(1); diff --git a/apps/api/src/routes/documents.ts b/apps/api/src/routes/documents.ts index 2deffaf..f67f272 100644 --- a/apps/api/src/routes/documents.ts +++ b/apps/api/src/routes/documents.ts @@ -6,6 +6,9 @@ import { and, desc, eq } from 'drizzle-orm'; import { getDb, documents, cases } from '@lawdesk/db'; import { saveFile, deleteFile, getObjectStream, FileNotFoundError } from '../lib/storage'; import { verifyFileSignature } from '../lib/file-signature'; +import { loadFirm } from '../lib/firm'; +import { PlanLimitError } from '../lib/plan-limits'; +import { assertWithinStorageQuota, incrementStorageUsed } from '../lib/storage-quota'; const ALLOWED_MIME = new Set([ 'application/pdf', @@ -74,6 +77,21 @@ export async function documentsRoutes(app: FastifyInstance) { return reply.code(400).send({ error: 'file_content_mismatch' }); } + const size = buf.length; + + // Enforce the firm's per-plan storage quota before committing the object to storage, + // so a rejected upload leaves no orphaned Spaces object and never touches the counter. + const firm = await loadFirm(firmId); + if (!firm) return reply.code(403).send({ error: 'firm_missing' }); + try { + await assertWithinStorageQuota(firmId, firm.plan, size); + } catch (e) { + if (e instanceof PlanLimitError) { + return reply.code(402).send({ error: e.message, plan: firm.plan }); + } + throw e; + } + const docId = randomUUID(); const ext = path.extname(data.filename); const storageKey = `${firmId}/${caseId}/${docId}${ext}`; @@ -88,7 +106,7 @@ export async function documentsRoutes(app: FastifyInstance) { name: data.filename, storageKey, mimeType: data.mimetype, - sizeBytes: buf.length, + sizeBytes: size, }).returning(); if (!doc) { @@ -97,6 +115,9 @@ export async function documentsRoutes(app: FastifyInstance) { return reply.code(500).send({ error: 'upload_failed' }); } + // Row committed — account the stored bytes against the firm's quota. + await incrementStorageUsed(firmId, size); + return reply.code(201).send({ id: doc.id, name: doc.name, @@ -149,6 +170,9 @@ export async function documentsRoutes(app: FastifyInstance) { req.log.warn({ err, storageKey: doc.storageKey }, 'orphaned file after delete'); } + // Row is gone — reclaim its bytes from the firm's tracked usage (clamped at 0). + await incrementStorageUsed(firmId, -doc.sizeBytes); + return reply.code(204).send(); }); } diff --git a/apps/api/src/routes/invoices.ts b/apps/api/src/routes/invoices.ts index ae23047..b291dfd 100644 --- a/apps/api/src/routes/invoices.ts +++ b/apps/api/src/routes/invoices.ts @@ -31,8 +31,8 @@ const createBody = z.object({ taxRate: z.coerce.number().min(0).max(100).default(0), dueAt: z.string().datetime().nullable().optional(), // Either provide explicit items or supply timeEntryIds to generate items from time entries. - items: z.array(itemBody).optional(), - timeEntryIds: z.array(z.string().uuid()).optional(), + items: z.array(itemBody).max(200).optional(), + timeEntryIds: z.array(z.string().uuid()).max(200).optional(), }); const updateBody = z.object({ @@ -548,7 +548,10 @@ export async function invoicesRoutes(app: FastifyInstance) { }); // PDF download - app.get('/api/invoices/:id/pdf', async (req, reply) => { + app.get( + '/api/invoices/:id/pdf', + { config: { rateLimit: { max: 60, timeWindow: '1 hour' } } }, + async (req, reply) => { const firmId = req.user!.firmId!; const { id } = z.object({ id: z.string().uuid() }).parse(req.params); const db = getDb(); diff --git a/apps/api/src/routes/webhooks-stripe.ts b/apps/api/src/routes/webhooks-stripe.ts index cd760e1..8af019f 100644 --- a/apps/api/src/routes/webhooks-stripe.ts +++ b/apps/api/src/routes/webhooks-stripe.ts @@ -1,7 +1,7 @@ import type { FastifyInstance } from 'fastify'; import type Stripe from 'stripe'; import { and, eq } from 'drizzle-orm'; -import { getDb, firms, users } from '@lawdesk/db'; +import { getDb, firms, users, stripeEvents } from '@lawdesk/db'; import { env } from '../env'; import { getStripe } from '../lib/stripe'; import { @@ -38,7 +38,27 @@ export async function stripeWebhookRoute(app: FastifyInstance) { } try { + // Idempotency for Stripe's at-least-once delivery. Skip events we've already fully + // processed so retries don't re-send emails or re-write audit rows. + const [seen] = await getDb() + .select({ id: stripeEvents.id }) + .from(stripeEvents) + .where(eq(stripeEvents.id, event.id)) + .limit(1); + if (seen) { + app.log.info({ id: event.id, type: event.type }, 'stripe webhook duplicate event ignored'); + return { received: true, duplicate: true }; + } + await handleEvent(event, app); + + // Record only AFTER successful processing: a transient handler failure (→ 500 → Stripe + // retry) then re-processes instead of being skipped forever. applyPlan is idempotent, so + // the narrow check-then-insert race on truly concurrent redeliveries is harmless. + await getDb() + .insert(stripeEvents) + .values({ id: event.id, type: event.type }) + .onConflictDoNothing(); } catch (err) { app.log.error({ err, type: event.type }, 'stripe webhook handler failed'); // Return 200 anyway for some failures? No — let Stripe retry on transient failures. @@ -65,7 +85,11 @@ async function handleEvent(event: Stripe.Event, app: FastifyInstance) { typeof session.subscription === 'string' ? session.subscription : session.subscription?.id ?? null; await applyPlan(firmId, plan, { customerId, subscriptionId }); - await sendPlanUpgradedNotice(firmId, plan); + // Fire-and-forget: an email failure must not throw out of the handler (→ 500 → Stripe + // redelivery → duplicate processing). The plan (DB writes above) is already applied. + sendPlanUpgradedNotice(firmId, plan).catch((err) => + app.log.warn({ err, firmId }, 'plan upgraded email failed'), + ); break; } diff --git a/apps/api/test-e2e/env.ts b/apps/api/test-e2e/env.ts index a080ae2..f22b5ea 100644 --- a/apps/api/test-e2e/env.ts +++ b/apps/api/test-e2e/env.ts @@ -25,9 +25,12 @@ export const E2E_ENV: Record = { SPACES_BUCKET: 'e2e-bucket', SPACES_KEY: 'e2e-key', SPACES_SECRET: 'e2e-secret', - // Empty → email sends are skipped, Stripe/Sentry stay inert. + // Empty → email sends are skipped, Stripe/Sentry stay inert, Turnstile CAPTCHA and AI are + // disabled so auth tests submit without a captcha token and AI endpoints report "not configured". SMTP2GO_API_KEY: '', STRIPE_SECRET_KEY: '', STRIPE_WEBHOOK_SECRET: '', SENTRY_DSN_API: '', + TURNSTILE_SECRET_KEY: '', + ANTHROPIC_API_KEY: '', }; diff --git a/apps/web/src/pages/LoginPage.tsx b/apps/web/src/pages/LoginPage.tsx index 798b4a8..d196d89 100644 --- a/apps/web/src/pages/LoginPage.tsx +++ b/apps/web/src/pages/LoginPage.tsx @@ -15,6 +15,16 @@ const schema = z.object({ type FormValues = z.infer; +// Guard against open-redirects: only accept same-origin internal paths like +// "/app" or "/app/cases". Reject protocol-relative ("//evil.com"), backslash +// tricks ("/\\evil.com"), and absolute URLs ("https://evil.com"). +function safeNext(raw: string | null): string { + if (!raw || !raw.startsWith('/') || raw.startsWith('//') || raw.startsWith('/\\') || raw.includes('://')) { + return '/app'; + } + return raw; +} + const ERROR_COPY: Record = { invalid_credentials: 'Email or password is incorrect.', too_many_attempts: 'Too many attempts. Try again in a few minutes.', @@ -54,7 +64,7 @@ export default function LoginPage() { setCaptchaReset((n) => n + 1); return; } - const next = new URLSearchParams(location.search).get('next') ?? '/app'; + const next = safeNext(new URLSearchParams(location.search).get('next')); navigate(next, { replace: true }); } diff --git a/docker-compose.yml b/docker-compose.yml index 497b0ea..5c915e2 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -17,6 +17,16 @@ services: VITE_TURNSTILE_SITE_KEY: ${VITE_TURNSTILE_SITE_KEY:-} VITE_SENTRY_DSN: ${VITE_SENTRY_DSN:-} restart: unless-stopped + # Resource guardrails so a memory leak or an upload/AI spike can't OOM the whole + # Dokploy host (a shared Swarm node). Dokploy applies Compose via Swarm-style deploy, + # so the limits/reservations below are the effective form. Tune the values as needed. + deploy: + resources: + limits: + cpus: "1.0" + memory: 1g + reservations: + memory: 512m environment: NODE_ENV: production PORT: 8080 diff --git a/package-lock.json b/package-lock.json index e5883fc..96785f5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -35,7 +35,7 @@ "@sentry/node": "^8.45.0", "argon2": "^0.41.1", "dotenv": "^16.4.5", - "drizzle-orm": "^0.36.4", + "drizzle-orm": "^0.45.2", "fastify": "^5.1.0", "fastify-plugin": "^5.0.1", "fastify-type-provider-zod": "^4.0.2", @@ -55,6 +55,131 @@ "vitest": "^3.2.7" } }, + "apps/api/node_modules/drizzle-orm": { + "version": "0.45.2", + "resolved": "https://registry.npmjs.org/drizzle-orm/-/drizzle-orm-0.45.2.tgz", + "integrity": "sha512-kY0BSaTNYWnoDMVoyY8uxmyHjpJW1geOmBMdSSicKo9CIIWkSxMIj2rkeSR51b8KAPB7m+qysjuHme5nKP+E5Q==", + "license": "Apache-2.0", + "peerDependencies": { + "@aws-sdk/client-rds-data": ">=3", + "@cloudflare/workers-types": ">=4", + "@electric-sql/pglite": ">=0.2.0", + "@libsql/client": ">=0.10.0", + "@libsql/client-wasm": ">=0.10.0", + "@neondatabase/serverless": ">=0.10.0", + "@op-engineering/op-sqlite": ">=2", + "@opentelemetry/api": "^1.4.1", + "@planetscale/database": ">=1.13", + "@prisma/client": "*", + "@tidbcloud/serverless": "*", + "@types/better-sqlite3": "*", + "@types/pg": "*", + "@types/sql.js": "*", + "@upstash/redis": ">=1.34.7", + "@vercel/postgres": ">=0.8.0", + "@xata.io/client": "*", + "better-sqlite3": ">=7", + "bun-types": "*", + "expo-sqlite": ">=14.0.0", + "gel": ">=2", + "knex": "*", + "kysely": "*", + "mysql2": ">=2", + "pg": ">=8", + "postgres": ">=3", + "sql.js": ">=1", + "sqlite3": ">=5" + }, + "peerDependenciesMeta": { + "@aws-sdk/client-rds-data": { + "optional": true + }, + "@cloudflare/workers-types": { + "optional": true + }, + "@electric-sql/pglite": { + "optional": true + }, + "@libsql/client": { + "optional": true + }, + "@libsql/client-wasm": { + "optional": true + }, + "@neondatabase/serverless": { + "optional": true + }, + "@op-engineering/op-sqlite": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@planetscale/database": { + "optional": true + }, + "@prisma/client": { + "optional": true + }, + "@tidbcloud/serverless": { + "optional": true + }, + "@types/better-sqlite3": { + "optional": true + }, + "@types/pg": { + "optional": true + }, + "@types/sql.js": { + "optional": true + }, + "@upstash/redis": { + "optional": true + }, + "@vercel/postgres": { + "optional": true + }, + "@xata.io/client": { + "optional": true + }, + "better-sqlite3": { + "optional": true + }, + "bun-types": { + "optional": true + }, + "expo-sqlite": { + "optional": true + }, + "gel": { + "optional": true + }, + "knex": { + "optional": true + }, + "kysely": { + "optional": true + }, + "mysql2": { + "optional": true + }, + "pg": { + "optional": true + }, + "postgres": { + "optional": true + }, + "prisma": { + "optional": true + }, + "sql.js": { + "optional": true + }, + "sqlite3": { + "optional": true + } + } + }, "apps/web": { "name": "@lawdesk/web", "version": "0.1.0", @@ -445,12 +570,13 @@ } }, "node_modules/@babel/code-frame": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", - "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", "dev": true, + "license": "MIT", "dependencies": { - "@babel/helper-validator-identifier": "^7.28.5", + "@babel/helper-validator-identifier": "^7.29.7", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" }, @@ -459,29 +585,31 @@ } }, "node_modules/@babel/compat-data": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz", - "integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", "dev": true, + "license": "MIT", "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/core": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", - "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", "dev": true, + "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.29.0", - "@babel/generator": "^7.29.0", - "@babel/helper-compilation-targets": "^7.28.6", - "@babel/helper-module-transforms": "^7.28.6", - "@babel/helpers": "^7.28.6", - "@babel/parser": "^7.29.0", - "@babel/template": "^7.28.6", - "@babel/traverse": "^7.29.0", - "@babel/types": "^7.29.0", + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", @@ -507,13 +635,14 @@ } }, "node_modules/@babel/generator": { - "version": "7.29.1", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", - "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz", + "integrity": "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==", "dev": true, + "license": "MIT", "dependencies": { - "@babel/parser": "^7.29.0", - "@babel/types": "^7.29.0", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" @@ -523,13 +652,14 @@ } }, "node_modules/@babel/helper-compilation-targets": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", - "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", "dev": true, + "license": "MIT", "dependencies": { - "@babel/compat-data": "^7.28.6", - "@babel/helper-validator-option": "^7.27.1", + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" @@ -543,41 +673,45 @@ "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", "dev": true, + "license": "ISC", "bin": { "semver": "bin/semver.js" } }, "node_modules/@babel/helper-globals": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", - "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", "dev": true, + "license": "MIT", "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-module-imports": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", - "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", "dev": true, + "license": "MIT", "dependencies": { - "@babel/traverse": "^7.28.6", - "@babel/types": "^7.28.6" + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-module-transforms": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", - "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", "dev": true, + "license": "MIT", "dependencies": { - "@babel/helper-module-imports": "^7.28.6", - "@babel/helper-validator-identifier": "^7.28.5", - "@babel/traverse": "^7.28.6" + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -596,52 +730,57 @@ } }, "node_modules/@babel/helper-string-parser": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", - "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", "dev": true, + "license": "MIT", "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-validator-identifier": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", - "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", "dev": true, + "license": "MIT", "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-validator-option": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", - "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", "dev": true, + "license": "MIT", "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helpers": { - "version": "7.29.2", - "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.2.tgz", - "integrity": "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", "dev": true, + "license": "MIT", "dependencies": { - "@babel/template": "^7.28.6", - "@babel/types": "^7.29.0" + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/parser": { - "version": "7.29.2", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.2.tgz", - "integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", + "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", "dev": true, + "license": "MIT", "dependencies": { - "@babel/types": "^7.29.0" + "@babel/types": "^7.29.7" }, "bin": { "parser": "bin/babel-parser.js" @@ -689,31 +828,33 @@ } }, "node_modules/@babel/template": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", - "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", "dev": true, + "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.28.6", - "@babel/parser": "^7.28.6", - "@babel/types": "^7.28.6" + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/traverse": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", - "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.7.tgz", + "integrity": "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==", "dev": true, + "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.29.0", - "@babel/generator": "^7.29.0", - "@babel/helper-globals": "^7.28.0", - "@babel/parser": "^7.29.0", - "@babel/template": "^7.28.6", - "@babel/types": "^7.29.0", + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7", "debug": "^4.3.1" }, "engines": { @@ -721,13 +862,14 @@ } }, "node_modules/@babel/types": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", - "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", + "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", "dev": true, + "license": "MIT", "dependencies": { - "@babel/helper-string-parser": "^7.27.1", - "@babel/helper-validator-identifier": "^7.28.5" + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -1151,275 +1293,292 @@ } }, "node_modules/@esbuild/aix-ppc64": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.19.12.tgz", - "integrity": "sha512-bmoCYyWdEL3wDQIVbcyzRyeKLgk2WtWLTWz1ZIAZF/EGbNOwSA6ew3PftJ1PqMiOOGu0OyFMzG53L0zqIpPeNA==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", "cpu": [ "ppc64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "aix" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/android-arm": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.19.12.tgz", - "integrity": "sha512-qg/Lj1mu3CdQlDEEiWrlC4eaPZ1KztwGJ9B6J+/6G+/4ewxJg7gqj8eVYWvao1bXrqGiW2rsBZFSX3q2lcW05w==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", "cpu": [ "arm" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "android" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/android-arm64": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.19.12.tgz", - "integrity": "sha512-P0UVNGIienjZv3f5zq0DP3Nt2IE/3plFzuaS96vihvD0Hd6H/q4WXUGpCxD/E8YrSXfNyRPbpTq+T8ZQioSuPA==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", "cpu": [ "arm64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "android" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/android-x64": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.19.12.tgz", - "integrity": "sha512-3k7ZoUW6Q6YqhdhIaq/WZ7HwBpnFBlW905Fa4s4qWJyiNOgT1dOqDiVAQFwBH7gBRZr17gLrlFCRzF6jFh7Kew==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", "cpu": [ "x64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "android" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/darwin-arm64": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.19.12.tgz", - "integrity": "sha512-B6IeSgZgtEzGC42jsI+YYu9Z3HKRxp8ZT3cqhvliEHovq8HSX2YX8lNocDn79gCKJXOSaEot9MVYky7AKjCs8g==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", "cpu": [ "arm64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "darwin" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/darwin-x64": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.19.12.tgz", - "integrity": "sha512-hKoVkKzFiToTgn+41qGhsUJXFlIjxI/jSYeZf3ugemDYZldIXIxhvwN6erJGlX4t5h417iFuheZ7l+YVn05N3A==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", "cpu": [ "x64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "darwin" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/freebsd-arm64": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.19.12.tgz", - "integrity": "sha512-4aRvFIXmwAcDBw9AueDQ2YnGmz5L6obe5kmPT8Vd+/+x/JMVKCgdcRwH6APrbpNXsPz+K653Qg8HB/oXvXVukA==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", "cpu": [ "arm64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "freebsd" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/freebsd-x64": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.19.12.tgz", - "integrity": "sha512-EYoXZ4d8xtBoVN7CEwWY2IN4ho76xjYXqSXMNccFSx2lgqOG/1TBPW0yPx1bJZk94qu3tX0fycJeeQsKovA8gg==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", "cpu": [ "x64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "freebsd" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/linux-arm": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.19.12.tgz", - "integrity": "sha512-J5jPms//KhSNv+LO1S1TX1UWp1ucM6N6XuL6ITdKWElCu8wXP72l9MM0zDTzzeikVyqFE6U8YAV9/tFyj0ti+w==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", "cpu": [ "arm" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/linux-arm64": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.19.12.tgz", - "integrity": "sha512-EoTjyYyLuVPfdPLsGVVVC8a0p1BFFvtpQDB/YLEhaXyf/5bczaGeN15QkR+O4S5LeJ92Tqotve7i1jn35qwvdA==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", "cpu": [ "arm64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/linux-ia32": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.19.12.tgz", - "integrity": "sha512-Thsa42rrP1+UIGaWz47uydHSBOgTUnwBwNq59khgIwktK6x60Hivfbux9iNR0eHCHzOLjLMLfUMLCypBkZXMHA==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", "cpu": [ "ia32" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/linux-loong64": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.19.12.tgz", - "integrity": "sha512-LiXdXA0s3IqRRjm6rV6XaWATScKAXjI4R4LoDlvO7+yQqFdlr1Bax62sRwkVvRIrwXxvtYEHHI4dm50jAXkuAA==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", "cpu": [ "loong64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/linux-mips64el": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.19.12.tgz", - "integrity": "sha512-fEnAuj5VGTanfJ07ff0gOA6IPsvrVHLVb6Lyd1g2/ed67oU1eFzL0r9WL7ZzscD+/N6i3dWumGE1Un4f7Amf+w==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", "cpu": [ "mips64el" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/linux-ppc64": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.19.12.tgz", - "integrity": "sha512-nYJA2/QPimDQOh1rKWedNOe3Gfc8PabU7HT3iXWtNUbRzXS9+vgB0Fjaqr//XNbd82mCxHzik2qotuI89cfixg==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", "cpu": [ "ppc64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/linux-riscv64": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.19.12.tgz", - "integrity": "sha512-2MueBrlPQCw5dVJJpQdUYgeqIzDQgw3QtiAHUC4RBz9FXPrskyyU3VI1hw7C0BSKB9OduwSJ79FTCqtGMWqJHg==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", "cpu": [ "riscv64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/linux-s390x": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.19.12.tgz", - "integrity": "sha512-+Pil1Nv3Umes4m3AZKqA2anfhJiVmNCYkPchwFJNEJN5QxmTs1uzyy4TvmDrCRNT2ApwSari7ZIgrPeUx4UZDg==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", "cpu": [ "s390x" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/linux-x64": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.19.12.tgz", - "integrity": "sha512-B71g1QpxfwBvNrfyJdVDexenDIt1CiDN1TIXLbhOw0KhJzE78KIFGX6OJ9MrtC0oOqMWf+0xop4qEU8JrJTwCg==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", "cpu": [ "x64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/netbsd-arm64": { @@ -1438,19 +1597,20 @@ } }, "node_modules/@esbuild/netbsd-x64": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.19.12.tgz", - "integrity": "sha512-3ltjQ7n1owJgFbuC61Oj++XhtzmymoCihNFgT84UAmJnxJfm4sYCiSLTXZtE00VWYpPMYc+ZQmB6xbSdVh0JWA==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", "cpu": [ "x64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "netbsd" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/openbsd-arm64": { @@ -1469,19 +1629,20 @@ } }, "node_modules/@esbuild/openbsd-x64": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.19.12.tgz", - "integrity": "sha512-RbrfTB9SWsr0kWmb9srfF+L933uMDdu9BIzdA7os2t0TXhCRjrQyCeOt6wVxr79CKD4c+p+YhCj31HBkYcXebw==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", "cpu": [ "x64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "openbsd" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/openharmony-arm64": { @@ -1500,67 +1661,71 @@ } }, "node_modules/@esbuild/sunos-x64": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.19.12.tgz", - "integrity": "sha512-HKjJwRrW8uWtCQnQOz9qcU3mUZhTUQvi56Q8DPTLLB+DawoiQdjsYq+j+D3s9I8VFtDr+F9CjgXKKC4ss89IeA==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", "cpu": [ "x64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "sunos" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/win32-arm64": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.19.12.tgz", - "integrity": "sha512-URgtR1dJnmGvX864pn1B2YUYNzjmXkuJOIqG2HdU62MVS4EHpU2946OZoTMnRUHklGtJdJZ33QfzdjGACXhn1A==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", "cpu": [ "arm64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "win32" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/win32-ia32": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.19.12.tgz", - "integrity": "sha512-+ZOE6pUkMOJfmxmBZElNOx72NKpIa/HFOMGzu8fqzQJ5kgf6aTGrcJaFsNiVMH4JKpMipyK+7k0n2UXN7a8YKQ==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", "cpu": [ "ia32" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "win32" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/win32-x64": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.19.12.tgz", - "integrity": "sha512-T1QyPSDCyMXaO3pzBkF96E8xMkiRYbUEZADd29SyPGabqxMViNoii+NcK7eWJAEoU6RZyEm5lVSIjTmcdoB9HA==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", "cpu": [ "x64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "win32" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@fastify/accept-negotiator": { @@ -1968,6 +2133,7 @@ "version": "1.30.1", "resolved": "https://registry.npmjs.org/@opentelemetry/context-async-hooks/-/context-async-hooks-1.30.1.tgz", "integrity": "sha512-s5vvxXPVdjqS3kTLKMeBMvop9hbWkwzBpu+mUO2M7sZtlkyDJGwFe33wRKnbaYDo8ExRVBIIdwIGrqpxHuKttA==", + "license": "Apache-2.0", "engines": { "node": ">=14" }, @@ -2579,9 +2745,10 @@ } }, "node_modules/@remix-run/router": { - "version": "1.23.2", - "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.2.tgz", - "integrity": "sha512-Ic6m2U/rMjTkhERIa/0ZtXJP17QUi2CbWE7cqx4J58M8aA3QTfW+2UlQ4psvTX9IO1RfNVhK3pcpdjej7L+t2w==", + "version": "1.23.3", + "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.3.tgz", + "integrity": "sha512-4An71tdz9X8+3sI4Qqqd2LWd9vS39J7sqd9EU4Scw7TJE/qB10Flv/UuqbPVgfQV9XoK8Np6jNquZitnZq5i+Q==", + "license": "MIT", "engines": { "node": ">=14.0.0" } @@ -2987,9 +3154,10 @@ } }, "node_modules/@sentry/node": { - "version": "8.55.1", - "resolved": "https://registry.npmjs.org/@sentry/node/-/node-8.55.1.tgz", - "integrity": "sha512-s8ydn/OxZFIxc9Fvt23gJkrXkCvPnUu2bDKwjQBCx0M1b4DdNdp4FaimT6B9reya7buj+tsNkZAoT11KqFhG/g==", + "version": "8.55.2", + "resolved": "https://registry.npmjs.org/@sentry/node/-/node-8.55.2.tgz", + "integrity": "sha512-x3Whryb4TytiIhH9ABLVuASfBvwA50v6PpJYvq0Y9dUMi9Eb0cfuqvRCB3e+oVntZHQpnXor2U/gRBIdG2jp4w==", + "license": "MIT", "dependencies": { "@opentelemetry/api": "^1.9.0", "@opentelemetry/context-async-hooks": "^1.30.1", @@ -3023,20 +3191,30 @@ "@opentelemetry/sdk-trace-base": "^1.30.1", "@opentelemetry/semantic-conventions": "^1.28.0", "@prisma/instrumentation": "5.22.0", - "@sentry/core": "8.55.1", - "@sentry/opentelemetry": "8.55.1", + "@sentry/core": "8.55.2", + "@sentry/opentelemetry": "8.55.2", "import-in-the-middle": "^1.11.2" }, "engines": { "node": ">=14.18" } }, + "node_modules/@sentry/node/node_modules/@sentry/core": { + "version": "8.55.2", + "resolved": "https://registry.npmjs.org/@sentry/core/-/core-8.55.2.tgz", + "integrity": "sha512-YlEBwybUcOQ/KjMHDmof1vwweVnBtBxYlQp7DE3fOdtW4pqqdHWTnTntQs4VgYfxzjJYgtkd9LHlGtg8qy+JVQ==", + "license": "MIT", + "engines": { + "node": ">=14.18" + } + }, "node_modules/@sentry/opentelemetry": { - "version": "8.55.1", - "resolved": "https://registry.npmjs.org/@sentry/opentelemetry/-/opentelemetry-8.55.1.tgz", - "integrity": "sha512-ipiM/k3Hzt8visoBfkDb4AQBWHkJeou3SjoPec7NlDabH/Jj8x6VlK5Hex4z+WOv99rRy+5MUtga/CZnOjvh0A==", + "version": "8.55.2", + "resolved": "https://registry.npmjs.org/@sentry/opentelemetry/-/opentelemetry-8.55.2.tgz", + "integrity": "sha512-pbhXi4cS1W4l392yEfIx3UD28OYAl9JkYOmh/Cpm6cPTtRMPxi3hWeujGbcXV9T/RkWYjqd+JdUDJjqsWSww9A==", + "license": "MIT", "dependencies": { - "@sentry/core": "8.55.1" + "@sentry/core": "8.55.2" }, "engines": { "node": ">=14.18" @@ -3050,6 +3228,15 @@ "@opentelemetry/semantic-conventions": "^1.28.0" } }, + "node_modules/@sentry/opentelemetry/node_modules/@sentry/core": { + "version": "8.55.2", + "resolved": "https://registry.npmjs.org/@sentry/core/-/core-8.55.2.tgz", + "integrity": "sha512-YlEBwybUcOQ/KjMHDmof1vwweVnBtBxYlQp7DE3fOdtW4pqqdHWTnTntQs4VgYfxzjJYgtkd9LHlGtg8qy+JVQ==", + "license": "MIT", + "engines": { + "node": ">=14.18" + } + }, "node_modules/@sentry/react": { "version": "8.55.1", "resolved": "https://registry.npmjs.org/@sentry/react/-/react-8.55.1.tgz", @@ -3359,13 +3546,13 @@ "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 }, "node_modules/@types/react": { "version": "18.3.28", "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.28.tgz", "integrity": "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==", - "devOptional": true, + "dev": true, "dependencies": { "@types/prop-types": "*", "csstype": "^3.2.2" @@ -3794,9 +3981,10 @@ "license": "MIT" }, "node_modules/brace-expansion": { - "version": "5.0.5", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", - "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", + "version": "5.0.7", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz", + "integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==", + "license": "MIT", "dependencies": { "balanced-match": "^4.0.2" }, @@ -4419,144 +4607,21 @@ } }, "node_modules/drizzle-kit": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/drizzle-kit/-/drizzle-kit-0.28.1.tgz", - "integrity": "sha512-JimOV+ystXTWMgZkLHYHf2w3oS28hxiH1FR0dkmJLc7GHzdGJoJAQtQS5DRppnabsRZwE2U1F6CuezVBgmsBBQ==", + "version": "0.31.10", + "resolved": "https://registry.npmjs.org/drizzle-kit/-/drizzle-kit-0.31.10.tgz", + "integrity": "sha512-7OZcmQUrdGI+DUNNsKBn1aW8qSoKuTH7d0mYgSP8bAzdFzKoovxEFnoGQp2dVs82EOJeYycqRtciopszwUf8bw==", "dev": true, + "license": "MIT", "dependencies": { "@drizzle-team/brocli": "^0.10.2", "@esbuild-kit/esm-loader": "^2.5.5", - "esbuild": "^0.19.7", - "esbuild-register": "^3.5.0" + "esbuild": "^0.25.4", + "tsx": "^4.21.0" }, "bin": { "drizzle-kit": "bin.cjs" } }, - "node_modules/drizzle-orm": { - "version": "0.36.4", - "resolved": "https://registry.npmjs.org/drizzle-orm/-/drizzle-orm-0.36.4.tgz", - "integrity": "sha512-1OZY3PXD7BR00Gl61UUOFihslDldfH4NFRH2MbP54Yxi0G/PKn4HfO65JYZ7c16DeP3SpM3Aw+VXVG9j6CRSXA==", - "peerDependencies": { - "@aws-sdk/client-rds-data": ">=3", - "@cloudflare/workers-types": ">=3", - "@electric-sql/pglite": ">=0.2.0", - "@libsql/client": ">=0.10.0", - "@libsql/client-wasm": ">=0.10.0", - "@neondatabase/serverless": ">=0.10.0", - "@op-engineering/op-sqlite": ">=2", - "@opentelemetry/api": "^1.4.1", - "@planetscale/database": ">=1", - "@prisma/client": "*", - "@tidbcloud/serverless": "*", - "@types/better-sqlite3": "*", - "@types/pg": "*", - "@types/react": ">=18", - "@types/sql.js": "*", - "@vercel/postgres": ">=0.8.0", - "@xata.io/client": "*", - "better-sqlite3": ">=7", - "bun-types": "*", - "expo-sqlite": ">=14.0.0", - "knex": "*", - "kysely": "*", - "mysql2": ">=2", - "pg": ">=8", - "postgres": ">=3", - "react": ">=18", - "sql.js": ">=1", - "sqlite3": ">=5" - }, - "peerDependenciesMeta": { - "@aws-sdk/client-rds-data": { - "optional": true - }, - "@cloudflare/workers-types": { - "optional": true - }, - "@electric-sql/pglite": { - "optional": true - }, - "@libsql/client": { - "optional": true - }, - "@libsql/client-wasm": { - "optional": true - }, - "@neondatabase/serverless": { - "optional": true - }, - "@op-engineering/op-sqlite": { - "optional": true - }, - "@opentelemetry/api": { - "optional": true - }, - "@planetscale/database": { - "optional": true - }, - "@prisma/client": { - "optional": true - }, - "@tidbcloud/serverless": { - "optional": true - }, - "@types/better-sqlite3": { - "optional": true - }, - "@types/pg": { - "optional": true - }, - "@types/react": { - "optional": true - }, - "@types/sql.js": { - "optional": true - }, - "@vercel/postgres": { - "optional": true - }, - "@xata.io/client": { - "optional": true - }, - "better-sqlite3": { - "optional": true - }, - "bun-types": { - "optional": true - }, - "expo-sqlite": { - "optional": true - }, - "knex": { - "optional": true - }, - "kysely": { - "optional": true - }, - "mysql2": { - "optional": true - }, - "pg": { - "optional": true - }, - "postgres": { - "optional": true - }, - "prisma": { - "optional": true - }, - "react": { - "optional": true - }, - "sql.js": { - "optional": true - }, - "sqlite3": { - "optional": true - } - } - }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", @@ -4639,53 +4704,96 @@ } }, "node_modules/esbuild": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.19.12.tgz", - "integrity": "sha512-aARqgq8roFBj054KvQr5f1sFu0D65G+miZRCuJyJ0G13Zwx7vRar5Zhn2tkQNzIXcBrNVsv/8stehpj+GAjgbg==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", "dev": true, "hasInstallScript": true, + "license": "MIT", "bin": { "esbuild": "bin/esbuild" }, "engines": { - "node": ">=12" + "node": ">=18" }, "optionalDependencies": { - "@esbuild/aix-ppc64": "0.19.12", - "@esbuild/android-arm": "0.19.12", - "@esbuild/android-arm64": "0.19.12", - "@esbuild/android-x64": "0.19.12", - "@esbuild/darwin-arm64": "0.19.12", - "@esbuild/darwin-x64": "0.19.12", - "@esbuild/freebsd-arm64": "0.19.12", - "@esbuild/freebsd-x64": "0.19.12", - "@esbuild/linux-arm": "0.19.12", - "@esbuild/linux-arm64": "0.19.12", - "@esbuild/linux-ia32": "0.19.12", - "@esbuild/linux-loong64": "0.19.12", - "@esbuild/linux-mips64el": "0.19.12", - "@esbuild/linux-ppc64": "0.19.12", - "@esbuild/linux-riscv64": "0.19.12", - "@esbuild/linux-s390x": "0.19.12", - "@esbuild/linux-x64": "0.19.12", - "@esbuild/netbsd-x64": "0.19.12", - "@esbuild/openbsd-x64": "0.19.12", - "@esbuild/sunos-x64": "0.19.12", - "@esbuild/win32-arm64": "0.19.12", - "@esbuild/win32-ia32": "0.19.12", - "@esbuild/win32-x64": "0.19.12" + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" } }, - "node_modules/esbuild-register": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/esbuild-register/-/esbuild-register-3.6.0.tgz", - "integrity": "sha512-H2/S7Pm8a9CL1uhp9OvjwrBh5Pvx0H8qVOxNu8Wed9Y7qv56MPtq+GGM8RJpq6glYJn9Wspr8uw7l55uyinNeg==", + "node_modules/esbuild/node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], "dev": true, - "dependencies": { - "debug": "^4.3.4" - }, - "peerDependencies": { - "esbuild": ">=0.12 <1" + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/esbuild/node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/esbuild/node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" } }, "node_modules/escalade": { @@ -4841,9 +4949,9 @@ "license": "Unlicense" }, "node_modules/fast-uri": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", - "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==", + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.3.tgz", + "integrity": "sha512-i70LwGWUduXqzicKXWshooq+sWL1K3WUU5rKZNG/0i3a1OSoX3HqhH5WbWwTmqWfor4urUakGPiRQcleRZTwOg==", "funding": [ { "type": "github", @@ -4853,7 +4961,8 @@ "type": "opencollective", "url": "https://opencollective.com/fastify" } - ] + ], + "license": "BSD-3-Clause" }, "node_modules/fastify": { "version": "5.8.5", @@ -5656,6 +5765,7 @@ "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", "dev": true, + "license": "MIT", "bin": { "jsesc": "bin/jsesc" }, @@ -5809,6 +5919,7 @@ "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", "dev": true, + "license": "ISC", "dependencies": { "yallist": "^3.0.2" } @@ -6597,11 +6708,13 @@ } }, "node_modules/qs": { - "version": "6.15.1", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.1.tgz", - "integrity": "sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg==", + "version": "6.15.3", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz", + "integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==", + "license": "BSD-3-Clause", "dependencies": { - "side-channel": "^1.1.0" + "es-define-property": "^1.0.1", + "side-channel": "^1.1.1" }, "engines": { "node": ">=0.6" @@ -6688,11 +6801,12 @@ } }, "node_modules/react-router": { - "version": "6.30.3", - "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.3.tgz", - "integrity": "sha512-XRnlbKMTmktBkjCLE8/XcZFlnHvr2Ltdr1eJX4idL55/9BbORzyZEaIkBFDhFGCEWBBItsVrDxwx3gnisMitdw==", + "version": "6.30.4", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.4.tgz", + "integrity": "sha512-SVUsDe+DybHM/WmYKIVYhZh1o5Dcuf16yM6WjG02Q9XVFMZIJyHYhwrr6bFBXZkVP6z69kNkMyBCujt8FaFLJA==", + "license": "MIT", "dependencies": { - "@remix-run/router": "1.23.2" + "@remix-run/router": "1.23.3" }, "engines": { "node": ">=14.0.0" @@ -6702,12 +6816,13 @@ } }, "node_modules/react-router-dom": { - "version": "6.30.3", - "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.30.3.tgz", - "integrity": "sha512-pxPcv1AczD4vso7G4Z3TKcvlxK7g7TNt3/FNGMhfqyntocvYKj+GCatfigGDjbLozC4baguJ0ReCigoDJXb0ag==", + "version": "6.30.4", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.30.4.tgz", + "integrity": "sha512-q4HvNl+mmDdkS0g+MqiBZNteQJCuimWoOyHMy4T/RQLAn9Z29+E91QXRaxOujeMl2HTzRSS0KFPd7lxX3PjV0Q==", + "license": "MIT", "dependencies": { - "@remix-run/router": "1.23.2", - "react-router": "6.30.3" + "@remix-run/router": "1.23.3", + "react-router": "6.30.4" }, "engines": { "node": ">=14.0.0" @@ -7151,13 +7266,14 @@ "integrity": "sha512-sQTKC1Re/rM6XyFM6fIAGHRPVGvyXfgzIDvzoq608vM+jeyVD0Tu1E6Np0Kc2zAIFWIj963V2800iF/9LPieQw==" }, "node_modules/side-channel": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", - "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", + "license": "MIT", "dependencies": { "es-errors": "^1.3.0", - "object-inspect": "^1.13.3", - "side-channel-list": "^1.0.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" }, @@ -8817,7 +8933,8 @@ "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", - "dev": true + "dev": true, + "license": "ISC" }, "node_modules/zod": { "version": "3.25.76", @@ -8840,15 +8957,140 @@ "version": "0.1.0", "dependencies": { "dotenv": "^16.4.5", - "drizzle-orm": "^0.36.4", + "drizzle-orm": "^0.45.2", "pg": "^8.13.1" }, "devDependencies": { "@types/pg": "^8.11.10", - "drizzle-kit": "^0.28.1", + "drizzle-kit": "^0.31.10", "tsx": "^4.19.2", "typescript": "^5.6.3" } + }, + "packages/db/node_modules/drizzle-orm": { + "version": "0.45.2", + "resolved": "https://registry.npmjs.org/drizzle-orm/-/drizzle-orm-0.45.2.tgz", + "integrity": "sha512-kY0BSaTNYWnoDMVoyY8uxmyHjpJW1geOmBMdSSicKo9CIIWkSxMIj2rkeSR51b8KAPB7m+qysjuHme5nKP+E5Q==", + "license": "Apache-2.0", + "peerDependencies": { + "@aws-sdk/client-rds-data": ">=3", + "@cloudflare/workers-types": ">=4", + "@electric-sql/pglite": ">=0.2.0", + "@libsql/client": ">=0.10.0", + "@libsql/client-wasm": ">=0.10.0", + "@neondatabase/serverless": ">=0.10.0", + "@op-engineering/op-sqlite": ">=2", + "@opentelemetry/api": "^1.4.1", + "@planetscale/database": ">=1.13", + "@prisma/client": "*", + "@tidbcloud/serverless": "*", + "@types/better-sqlite3": "*", + "@types/pg": "*", + "@types/sql.js": "*", + "@upstash/redis": ">=1.34.7", + "@vercel/postgres": ">=0.8.0", + "@xata.io/client": "*", + "better-sqlite3": ">=7", + "bun-types": "*", + "expo-sqlite": ">=14.0.0", + "gel": ">=2", + "knex": "*", + "kysely": "*", + "mysql2": ">=2", + "pg": ">=8", + "postgres": ">=3", + "sql.js": ">=1", + "sqlite3": ">=5" + }, + "peerDependenciesMeta": { + "@aws-sdk/client-rds-data": { + "optional": true + }, + "@cloudflare/workers-types": { + "optional": true + }, + "@electric-sql/pglite": { + "optional": true + }, + "@libsql/client": { + "optional": true + }, + "@libsql/client-wasm": { + "optional": true + }, + "@neondatabase/serverless": { + "optional": true + }, + "@op-engineering/op-sqlite": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@planetscale/database": { + "optional": true + }, + "@prisma/client": { + "optional": true + }, + "@tidbcloud/serverless": { + "optional": true + }, + "@types/better-sqlite3": { + "optional": true + }, + "@types/pg": { + "optional": true + }, + "@types/sql.js": { + "optional": true + }, + "@upstash/redis": { + "optional": true + }, + "@vercel/postgres": { + "optional": true + }, + "@xata.io/client": { + "optional": true + }, + "better-sqlite3": { + "optional": true + }, + "bun-types": { + "optional": true + }, + "expo-sqlite": { + "optional": true + }, + "gel": { + "optional": true + }, + "knex": { + "optional": true + }, + "kysely": { + "optional": true + }, + "mysql2": { + "optional": true + }, + "pg": { + "optional": true + }, + "postgres": { + "optional": true + }, + "prisma": { + "optional": true + }, + "sql.js": { + "optional": true + }, + "sqlite3": { + "optional": true + } + } } } } diff --git a/packages/db/migrations/0002_daily_chronomancer.sql b/packages/db/migrations/0002_daily_chronomancer.sql new file mode 100644 index 0000000..53f013a --- /dev/null +++ b/packages/db/migrations/0002_daily_chronomancer.sql @@ -0,0 +1,25 @@ +CREATE TABLE IF NOT EXISTS "ai_usage" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "firm_id" uuid NOT NULL, + "user_id" uuid, + "feature" text NOT NULL, + "model" text NOT NULL, + "input_tokens" integer DEFAULT 0 NOT NULL, + "output_tokens" integer DEFAULT 0 NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE IF NOT EXISTS "stripe_events" ( + "id" text PRIMARY KEY NOT NULL, + "type" text NOT NULL, + "processed_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +ALTER TABLE "firms" ALTER COLUMN "storage_bytes_used" SET DATA TYPE bigint;--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "ai_usage" ADD CONSTRAINT "ai_usage_firm_id_firms_id_fk" FOREIGN KEY ("firm_id") REFERENCES "public"."firms"("id") ON DELETE cascade ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "ai_usage_firm_idx" ON "ai_usage" USING btree ("firm_id","created_at"); \ No newline at end of file diff --git a/packages/db/migrations/meta/0002_snapshot.json b/packages/db/migrations/meta/0002_snapshot.json new file mode 100644 index 0000000..8655d70 --- /dev/null +++ b/packages/db/migrations/meta/0002_snapshot.json @@ -0,0 +1,1822 @@ +{ + "id": "8eb2d41d-e450-4734-8e64-49254a08631d", + "prevId": "ac75302b-ea07-473a-a01c-4af66bafc145", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.audit_log": { + "name": "audit_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "firm_id": { + "name": "firm_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "meta": { + "name": "meta", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ip": { + "name": "ip", + "type": "inet", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "audit_firm_idx": { + "name": "audit_firm_idx", + "columns": [ + { + "expression": "firm_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "audit_log_user_id_users_id_fk": { + "name": "audit_log_user_id_users_id_fk", + "tableFrom": "audit_log", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "audit_log_firm_id_firms_id_fk": { + "name": "audit_log_firm_id_firms_id_fk", + "tableFrom": "audit_log", + "tableTo": "firms", + "columnsFrom": [ + "firm_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.email_verifications": { + "name": "email_verifications", + "schema": "", + "columns": { + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "consumed_at": { + "name": "consumed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "email_verifications_user_id_users_id_fk": { + "name": "email_verifications_user_id_users_id_fk", + "tableFrom": "email_verifications", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.login_attempts": { + "name": "login_attempts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "ip": { + "name": "ip", + "type": "inet", + "primaryKey": false, + "notNull": false + }, + "success": { + "name": "success", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "attempted_at": { + "name": "attempted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "login_attempts_email_idx": { + "name": "login_attempts_email_idx", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "attempted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "login_attempts_ip_idx": { + "name": "login_attempts_ip_idx", + "columns": [ + { + "expression": "ip", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "attempted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.password_resets": { + "name": "password_resets", + "schema": "", + "columns": { + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "consumed_at": { + "name": "consumed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "password_resets_user_id_users_id_fk": { + "name": "password_resets_user_id_users_id_fk", + "tableFrom": "password_resets", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sessions": { + "name": "sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "ip": { + "name": "ip", + "type": "inet", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_seen_at": { + "name": "last_seen_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "sessions_user_idx": { + "name": "sessions_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sessions_expires_idx": { + "name": "sessions_expires_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "sessions_user_id_users_id_fk": { + "name": "sessions_user_id_users_id_fk", + "tableFrom": "sessions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "firm_id": { + "name": "firm_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "password_hash": { + "name": "password_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "full_name": { + "name": "full_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'owner'" + }, + "is_superadmin": { + "name": "is_superadmin", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "is_suspended": { + "name": "is_suspended", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "email_verified_at": { + "name": "email_verified_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "totp_secret_enc": { + "name": "totp_secret_enc", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "totp_enabled": { + "name": "totp_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "last_seen_at": { + "name": "last_seen_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "users_firm_idx": { + "name": "users_firm_idx", + "columns": [ + { + "expression": "firm_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "users_firm_id_firms_id_fk": { + "name": "users_firm_id_firms_id_fk", + "tableFrom": "users", + "tableTo": "firms", + "columnsFrom": [ + "firm_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "users_email_unique": { + "name": "users_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.firms": { + "name": "firms", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "plan": { + "name": "plan", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'starter'" + }, + "watermark_enabled": { + "name": "watermark_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "storage_bytes_used": { + "name": "storage_bytes_used", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "stripe_customer_id": { + "name": "stripe_customer_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_subscription_id": { + "name": "stripe_subscription_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "trial_ends_at": { + "name": "trial_ends_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.clients": { + "name": "clients", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "firm_id": { + "name": "firm_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "phone": { + "name": "phone", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "address": { + "name": "address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "clients_firm_idx": { + "name": "clients_firm_idx", + "columns": [ + { + "expression": "firm_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "clients_firm_id_firms_id_fk": { + "name": "clients_firm_id_firms_id_fk", + "tableFrom": "clients", + "tableTo": "firms", + "columnsFrom": [ + "firm_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.cases": { + "name": "cases", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "firm_id": { + "name": "firm_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "case_number": { + "name": "case_number", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'open'" + }, + "practice_area": { + "name": "practice_area", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "hourly_rate": { + "name": "hourly_rate", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "opened_at": { + "name": "opened_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "closed_at": { + "name": "closed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "cases_firm_idx": { + "name": "cases_firm_idx", + "columns": [ + { + "expression": "firm_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "cases_client_idx": { + "name": "cases_client_idx", + "columns": [ + { + "expression": "client_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "cases_status_idx": { + "name": "cases_status_idx", + "columns": [ + { + "expression": "firm_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "cases_firm_id_firms_id_fk": { + "name": "cases_firm_id_firms_id_fk", + "tableFrom": "cases", + "tableTo": "firms", + "columnsFrom": [ + "firm_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "cases_client_id_clients_id_fk": { + "name": "cases_client_id_clients_id_fk", + "tableFrom": "cases", + "tableTo": "clients", + "columnsFrom": [ + "client_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.time_entries": { + "name": "time_entries", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "firm_id": { + "name": "firm_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "case_id": { + "name": "case_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "ended_at": { + "name": "ended_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "minutes": { + "name": "minutes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "rate": { + "name": "rate", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": true + }, + "billable": { + "name": "billable", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "invoice_item_id": { + "name": "invoice_item_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "time_firm_idx": { + "name": "time_firm_idx", + "columns": [ + { + "expression": "firm_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "time_case_idx": { + "name": "time_case_idx", + "columns": [ + { + "expression": "case_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "time_user_idx": { + "name": "time_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "time_unbilled_idx": { + "name": "time_unbilled_idx", + "columns": [ + { + "expression": "firm_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "invoice_item_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "time_entries_firm_id_firms_id_fk": { + "name": "time_entries_firm_id_firms_id_fk", + "tableFrom": "time_entries", + "tableTo": "firms", + "columnsFrom": [ + "firm_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "time_entries_case_id_cases_id_fk": { + "name": "time_entries_case_id_cases_id_fk", + "tableFrom": "time_entries", + "tableTo": "cases", + "columnsFrom": [ + "case_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "time_entries_user_id_users_id_fk": { + "name": "time_entries_user_id_users_id_fk", + "tableFrom": "time_entries", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.documents": { + "name": "documents", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "firm_id": { + "name": "firm_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "case_id": { + "name": "case_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "uploaded_by": { + "name": "uploaded_by", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "storage_key": { + "name": "storage_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "mime_type": { + "name": "mime_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "size_bytes": { + "name": "size_bytes", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "parent_id": { + "name": "parent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "docs_firm_idx": { + "name": "docs_firm_idx", + "columns": [ + { + "expression": "firm_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "docs_case_idx": { + "name": "docs_case_idx", + "columns": [ + { + "expression": "case_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "documents_firm_id_firms_id_fk": { + "name": "documents_firm_id_firms_id_fk", + "tableFrom": "documents", + "tableTo": "firms", + "columnsFrom": [ + "firm_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "documents_case_id_cases_id_fk": { + "name": "documents_case_id_cases_id_fk", + "tableFrom": "documents", + "tableTo": "cases", + "columnsFrom": [ + "case_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "documents_uploaded_by_users_id_fk": { + "name": "documents_uploaded_by_users_id_fk", + "tableFrom": "documents", + "tableTo": "users", + "columnsFrom": [ + "uploaded_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.invoice_items": { + "name": "invoice_items", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "invoice_id": { + "name": "invoice_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "quantity": { + "name": "quantity", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": true, + "default": "'1'" + }, + "rate": { + "name": "rate", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": true + }, + "amount": { + "name": "amount", + "type": "numeric(12, 2)", + "primaryKey": false, + "notNull": true + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + } + }, + "indexes": { + "invoice_items_invoice_idx": { + "name": "invoice_items_invoice_idx", + "columns": [ + { + "expression": "invoice_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "invoice_items_invoice_id_invoices_id_fk": { + "name": "invoice_items_invoice_id_invoices_id_fk", + "tableFrom": "invoice_items", + "tableTo": "invoices", + "columnsFrom": [ + "invoice_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.invoices": { + "name": "invoices", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "firm_id": { + "name": "firm_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "case_id": { + "name": "case_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "number": { + "name": "number", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'draft'" + }, + "subtotal": { + "name": "subtotal", + "type": "numeric(12, 2)", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "tax_rate": { + "name": "tax_rate", + "type": "numeric(5, 2)", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "total": { + "name": "total", + "type": "numeric(12, 2)", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "issued_at": { + "name": "issued_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "due_at": { + "name": "due_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "paid_at": { + "name": "paid_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "invoices_firm_idx": { + "name": "invoices_firm_idx", + "columns": [ + { + "expression": "firm_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "invoices_status_idx": { + "name": "invoices_status_idx", + "columns": [ + { + "expression": "firm_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "invoices_firm_id_firms_id_fk": { + "name": "invoices_firm_id_firms_id_fk", + "tableFrom": "invoices", + "tableTo": "firms", + "columnsFrom": [ + "firm_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "invoices_client_id_clients_id_fk": { + "name": "invoices_client_id_clients_id_fk", + "tableFrom": "invoices", + "tableTo": "clients", + "columnsFrom": [ + "client_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "invoices_case_id_cases_id_fk": { + "name": "invoices_case_id_cases_id_fk", + "tableFrom": "invoices", + "tableTo": "cases", + "columnsFrom": [ + "case_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.ai_usage": { + "name": "ai_usage", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "firm_id": { + "name": "firm_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "feature": { + "name": "feature", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "input_tokens": { + "name": "input_tokens", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "output_tokens": { + "name": "output_tokens", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "ai_usage_firm_idx": { + "name": "ai_usage_firm_idx", + "columns": [ + { + "expression": "firm_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "ai_usage_firm_id_firms_id_fk": { + "name": "ai_usage_firm_id_firms_id_fk", + "tableFrom": "ai_usage", + "tableTo": "firms", + "columnsFrom": [ + "firm_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.contact_messages": { + "name": "contact_messages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "full_name": { + "name": "full_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "ip": { + "name": "ip", + "type": "inet", + "primaryKey": false, + "notNull": false + }, + "resolved_at": { + "name": "resolved_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.stripe_events": { + "name": "stripe_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "processed_at": { + "name": "processed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.tool_usage": { + "name": "tool_usage", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "tool": { + "name": "tool", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ip": { + "name": "ip", + "type": "inet", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "tool_usage_tool_idx": { + "name": "tool_usage_tool_idx", + "columns": [ + { + "expression": "tool", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/packages/db/migrations/meta/_journal.json b/packages/db/migrations/meta/_journal.json index c38c818..5a2bc55 100644 --- a/packages/db/migrations/meta/_journal.json +++ b/packages/db/migrations/meta/_journal.json @@ -15,6 +15,13 @@ "when": 1777169307877, "tag": "0001_orange_jamie_braddock", "breakpoints": true + }, + { + "idx": 2, + "version": "7", + "when": 1784308478719, + "tag": "0002_daily_chronomancer", + "breakpoints": true } ] } \ No newline at end of file diff --git a/packages/db/package.json b/packages/db/package.json index eac2b63..4bf9ab6 100644 --- a/packages/db/package.json +++ b/packages/db/package.json @@ -18,12 +18,12 @@ }, "dependencies": { "dotenv": "^16.4.5", - "drizzle-orm": "^0.36.4", + "drizzle-orm": "^0.45.2", "pg": "^8.13.1" }, "devDependencies": { "@types/pg": "^8.11.10", - "drizzle-kit": "^0.28.1", + "drizzle-kit": "^0.31.10", "tsx": "^4.19.2", "typescript": "^5.6.3" } diff --git a/packages/db/src/schema/firms.ts b/packages/db/src/schema/firms.ts index 483b7ea..3487513 100644 --- a/packages/db/src/schema/firms.ts +++ b/packages/db/src/schema/firms.ts @@ -1,4 +1,4 @@ -import { pgTable, uuid, text, timestamp, boolean, integer } from 'drizzle-orm/pg-core'; +import { pgTable, uuid, text, timestamp, boolean, bigint } from 'drizzle-orm/pg-core'; export const firms = pgTable('firms', { id: uuid('id').defaultRandom().primaryKey(), @@ -7,7 +7,8 @@ export const firms = pgTable('firms', { .notNull() .default('starter'), watermarkEnabled: boolean('watermark_enabled').notNull().default(true), - storageBytesUsed: integer('storage_bytes_used').notNull().default(0), + // bigint (not integer): plan quotas reach 50 GB, well past the ~2.1 GB int4 ceiling. + storageBytesUsed: bigint('storage_bytes_used', { mode: 'number' }).notNull().default(0), stripeCustomerId: text('stripe_customer_id'), stripeSubscriptionId: text('stripe_subscription_id'), trialEndsAt: timestamp('trial_ends_at', { withTimezone: true }), diff --git a/packages/db/src/schema/misc.ts b/packages/db/src/schema/misc.ts index 17179bd..8d0e9f5 100644 --- a/packages/db/src/schema/misc.ts +++ b/packages/db/src/schema/misc.ts @@ -1,4 +1,5 @@ -import { pgTable, uuid, text, timestamp, inet, index } from 'drizzle-orm/pg-core'; +import { pgTable, uuid, text, timestamp, inet, integer, index } from 'drizzle-orm/pg-core'; +import { firms } from './firms'; export const contactMessages = pgTable('contact_messages', { id: uuid('id').defaultRandom().primaryKey(), @@ -23,3 +24,32 @@ export const toolUsage = pgTable( toolIdx: index('tool_usage_tool_idx').on(t.tool, t.createdAt), }), ); + +// Per-firm AI usage ledger — enables monthly token quotas and cost accounting. +// One row per successful AI completion; aggregated over the current month for quota checks. +export const aiUsage = pgTable( + 'ai_usage', + { + id: uuid('id').defaultRandom().primaryKey(), + firmId: uuid('firm_id') + .notNull() + .references(() => firms.id, { onDelete: 'cascade' }), + userId: uuid('user_id'), + feature: text('feature').notNull(), // 'case_summary' | 'document_summary' | 'polish' + model: text('model').notNull(), + inputTokens: integer('input_tokens').notNull().default(0), + outputTokens: integer('output_tokens').notNull().default(0), + createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), + }, + (t) => ({ + firmIdx: index('ai_usage_firm_idx').on(t.firmId, t.createdAt), + }), +); + +// Stripe webhook idempotency ledger. The event id is the PK; an INSERT that hits the +// unique constraint means we've already processed this event and can skip re-running side effects. +export const stripeEvents = pgTable('stripe_events', { + id: text('id').primaryKey(), // Stripe event.id + type: text('type').notNull(), + processedAt: timestamp('processed_at', { withTimezone: true }).notNull().defaultNow(), +}); diff --git a/scripts/seed-demo.ts b/scripts/seed-demo.ts index 77797ff..233e31e 100644 --- a/scripts/seed-demo.ts +++ b/scripts/seed-demo.ts @@ -93,7 +93,44 @@ function slug(s: string): string { // ─── Safety guard ───────────────────────────────────────────────────────────── // This inserts 10 demo firms whose owner logins all use the public password `Demo1234!` // into whatever DATABASE_URL points at — which for this project is the PRODUCTION database. -// Require an explicit opt-in so it can never run by accident. + +// Hard stop: demo data must NEVER be seeded into production. This runs FIRST and cannot be +// overridden by ALLOW_SEED — a single env opt-in is too weak a guard for planting +// known-credential login accounts in prod. +{ + const dbHost = (() => { + try { + return new URL(process.env.DATABASE_URL ?? '').host || 'unknown'; + } catch { + return 'unknown'; + } + })(); + + // Required refusal: never seed when running in a production environment. + if (process.env.NODE_ENV === 'production') { + console.error( + 'Refusing to seed: NODE_ENV=production.\n' + + 'seed-demo.ts creates ~10 demo owner accounts with the public password "Demo1234!".\n' + + 'Demo data must NEVER be seeded into production under any circumstances.\n' + + 'This refusal is absolute and cannot be overridden with ALLOW_SEED.\n' + + `Target database host: ${dbHost}`, + ); + process.exit(1); + } + + // Belt-and-suspenders: also refuse if DATABASE_URL points at the known production DB host, + // even if NODE_ENV was left unset. Production Postgres lives on DigitalOcean managed DBs. + if (dbHost.endsWith('.db.ondigitalocean.com')) { + console.error( + `Refusing to seed: DATABASE_URL host "${dbHost}" is the production database.\n` + + 'seed-demo.ts plants known-credential demo accounts and must never touch production.\n' + + 'This refusal cannot be overridden with ALLOW_SEED.', + ); + process.exit(1); + } +} + +// Require an explicit opt-in so it can never run by accident (second gate, non-production only). if (process.env.ALLOW_SEED !== '1') { const host = (() => { try {