Files
Leon SerfatyandClaude Fable 5 304f7f30c3 Security hardening: deps, tenancy quotas, auth, deploy, webhooks
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 <noreply@anthropic.com>
2026-07-17 13:34:33 -04:00

306 lines
12 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Seeds 10 demo firms, each with one owner user + realistic activity.
// Run from monorepo root: npx tsx scripts/seed-demo.ts
//
// Login: any of the emails below with password `Demo1234!`
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import dotenv from 'dotenv';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
dotenv.config({ path: path.resolve(__dirname, '../.env') });
import argon2 from 'argon2';
import {
getDb,
getPool,
firms,
users,
clients,
cases,
timeEntries,
invoices,
invoiceItems,
} from '@lawdesk/db';
// ─── Demo data pools ───────────────────────────────────────────────────────
const FIRMS = [
{ name: 'Hartwell & Associates', plan: 'pro' as const, area: 'Family Law' },
{ name: 'Brennan Law Group', plan: 'lifetime' as const, area: 'Personal Injury' },
{ name: 'Cohen Legal Solutions', plan: 'starter' as const, area: 'Criminal Defense' },
{ name: 'Davenport & Reed PLLC', plan: 'pro' as const, area: 'Real Estate' },
{ name: 'Eastman Law Firm', plan: 'starter' as const, area: 'Employment' },
{ name: 'Fairmont Legal Partners', plan: 'pro' as const, area: 'Estate Planning' },
{ name: 'Gallo & Whitfield LLP', plan: 'lifetime' as const, area: 'Corporate' },
{ name: 'Hayes Immigration Law', plan: 'starter' as const, area: 'Immigration' },
{ name: 'Iverson & Marsh', plan: 'pro' as const, area: 'Bankruptcy' },
{ name: 'Jensen Tax Law', plan: 'pro' as const, area: 'Tax' },
];
const FIRST_NAMES = ['Emma', 'Liam', 'Olivia', 'Noah', 'Ava', 'Ethan', 'Sophia', 'Mason', 'Isabella', 'James', 'Mia', 'Lucas', 'Charlotte', 'Henry', 'Amelia'];
const LAST_NAMES = ['Anderson', 'Brown', 'Carter', 'Davis', 'Evans', 'Foster', 'Garcia', 'Hill', 'Jackson', 'King', 'Lee', 'Morris', 'Nelson', 'Owens', 'Parker', 'Quinn', 'Rivera', 'Stone', 'Taylor', 'Walker'];
const CASE_TITLES: Record<string, string[]> = {
'Family Law': ['Divorce — assets division', 'Child custody modification', 'Prenuptial agreement', 'Adoption petition', 'Spousal support'],
'Personal Injury': ['Auto accident — rear-end', 'Slip and fall at retail', 'Workplace injury claim', 'Medical malpractice', 'Product liability'],
'Criminal Defense': ['DUI defense', 'Assault charge', 'Drug possession', 'White-collar fraud', 'Theft defense'],
'Real Estate': ['Commercial lease review', 'Title dispute', 'Zoning variance', 'Purchase agreement', 'Easement dispute'],
'Employment': ['Wrongful termination', 'Discrimination claim', 'Wage and hour dispute', 'Non-compete enforcement', 'Severance negotiation'],
'Estate Planning': ['Trust formation', 'Will drafting', 'Probate administration', 'Power of attorney', 'Estate dispute'],
'Corporate': ['M&A advisory', 'Shareholder agreement', 'Series A financing', 'IP licensing', 'Corporate restructuring'],
'Immigration': ['H-1B visa petition', 'Green card application', 'Asylum case', 'Naturalization', 'Family-based visa'],
'Bankruptcy': ['Chapter 7 filing', 'Chapter 13 reorganization', 'Creditor negotiation', 'Asset protection', 'Discharge defense'],
'Tax': ['IRS audit defense', 'Tax debt settlement', 'Estate tax planning', 'Business tax structuring', 'Tax court appeal'],
};
const TIME_DESCRIPTIONS = [
'Initial client consultation',
'Drafted demand letter',
'Reviewed discovery documents',
'Court appearance — motion hearing',
'Phone call with opposing counsel',
'Research case law',
'Prepared deposition outline',
'Client meeting — strategy review',
'Email correspondence',
'Filed motion to compel',
'Mediation session',
'Drafted settlement proposal',
'Reviewed contract terms',
'Prepared trial exhibits',
'Settlement conference',
];
// ─── Helpers ────────────────────────────────────────────────────────────────
function pick<T>(arr: T[]): T {
return arr[Math.floor(Math.random() * arr.length)]!;
}
function randInt(min: number, max: number): number {
return Math.floor(Math.random() * (max - min + 1)) + min;
}
function daysAgo(days: number): Date {
const d = new Date();
d.setDate(d.getDate() - days);
return d;
}
function slug(s: string): string {
return s.toLowerCase().replace(/[^a-z]+/g, '');
}
// ─── 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.
// 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 {
return new URL(process.env.DATABASE_URL ?? '').host || 'unknown';
} catch {
return 'unknown';
}
})();
console.error(
`Refusing to seed. This writes 10 demo firms (owner password "Demo1234!") to: ${host}\n` +
'That is the production database for this project. Re-run with ALLOW_SEED=1 only if you are sure:\n' +
' ALLOW_SEED=1 npx tsx scripts/seed-demo.ts',
);
process.exit(1);
}
// ─── Main ───────────────────────────────────────────────────────────────────
async function main() {
const db = getDb();
const passwordHash = await argon2.hash('Demo1234!', {
type: argon2.argon2id,
memoryCost: 64 * 1024,
timeCost: 3,
parallelism: 1,
});
console.log('Seeding 10 firms with activity…\n');
for (let i = 0; i < FIRMS.length; i++) {
const def = FIRMS[i]!;
const firstName = FIRST_NAMES[i]!;
const lastName = LAST_NAMES[i]!;
const fullName = `${firstName} ${lastName}`;
const email = `${firstName.toLowerCase()}.${lastName.toLowerCase()}@${slug(def.name)}.test`;
// Firm
const [firm] = await db.insert(firms).values({
name: def.name,
plan: def.plan,
watermarkEnabled: def.plan === 'starter',
}).returning();
// Owner user
const [user] = await db.insert(users).values({
firmId: firm.id,
email,
passwordHash,
fullName,
role: 'owner',
emailVerifiedAt: new Date(),
lastSeenAt: daysAgo(randInt(0, 5)),
}).returning();
// 35 clients
const numClients = randInt(3, 5);
const clientRows = [];
for (let c = 0; c < numClients; c++) {
const cName = `${pick(FIRST_NAMES)} ${pick(LAST_NAMES)}`;
const [client] = await db.insert(clients).values({
firmId: firm.id,
name: cName,
email: `${cName.toLowerCase().replace(' ', '.')}@example.com`,
phone: `(${randInt(200, 999)}) ${randInt(200, 999)}-${randInt(1000, 9999)}`,
}).returning();
clientRows.push(client);
}
// 58 cases
const numCases = randInt(5, 8);
const caseTitles = CASE_TITLES[def.area]!;
const caseRows = [];
for (let cc = 0; cc < numCases; cc++) {
const status = cc < numCases - 2 ? 'open' : (Math.random() > 0.5 ? 'pending' : 'closed');
const hourlyRate = randInt(150, 450);
const openedDaysAgo = randInt(7, 120);
const [kase] = await db.insert(cases).values({
firmId: firm.id,
clientId: pick(clientRows).id,
title: caseTitles[cc % caseTitles.length]!,
caseNumber: `${new Date().getFullYear()}-${String(cc + 1).padStart(4, '0')}`,
status,
practiceArea: def.area,
hourlyRate: String(hourlyRate),
openedAt: daysAgo(openedDaysAgo),
closedAt: status === 'closed' ? daysAgo(randInt(0, openedDaysAgo - 1)) : null,
}).returning();
caseRows.push(kase);
}
// 1530 time entries spread across cases
const numEntries = randInt(15, 30);
for (let e = 0; e < numEntries; e++) {
const kase = pick(caseRows);
const minutes = randInt(15, 240);
const startedDaysAgo = randInt(0, 60);
const startedAt = daysAgo(startedDaysAgo);
const endedAt = new Date(startedAt.getTime() + minutes * 60 * 1000);
await db.insert(timeEntries).values({
firmId: firm.id,
caseId: kase.id,
userId: user.id,
description: pick(TIME_DESCRIPTIONS),
startedAt,
endedAt,
minutes,
rate: kase.hourlyRate ?? '250',
billable: Math.random() > 0.15,
});
}
// 24 invoices
const numInvoices = randInt(2, 4);
for (let inv = 0; inv < numInvoices; inv++) {
const kase = pick(caseRows);
const client = clientRows.find((c) => c.id === kase.clientId)!;
const numItems = randInt(2, 5);
const items = [];
let subtotal = 0;
for (let it = 0; it < numItems; it++) {
const qty = Number((Math.random() * 8 + 0.5).toFixed(2));
const rate = Number(kase.hourlyRate ?? 250);
const amount = Number((qty * rate).toFixed(2));
subtotal += amount;
items.push({ description: pick(TIME_DESCRIPTIONS), quantity: qty, rate, amount, sortOrder: it });
}
const total = Number(subtotal.toFixed(2));
// Status mix: ~40% sent, ~25% paid, ~15% overdue, ~15% draft, ~5% void
const r = Math.random();
const status = r < 0.4 ? 'sent' : r < 0.65 ? 'paid' : r < 0.8 ? 'overdue' : r < 0.95 ? 'draft' : 'void';
const issuedDaysAgo = randInt(5, 90);
const issuedAt = status === 'draft' ? null : daysAgo(issuedDaysAgo);
const dueAt = issuedAt ? new Date(issuedAt.getTime() + 30 * 24 * 60 * 60 * 1000) : null;
const paidAt = status === 'paid' ? daysAgo(randInt(1, issuedDaysAgo - 1)) : null;
const [invoice] = await db.insert(invoices).values({
firmId: firm.id,
clientId: client.id,
caseId: kase.id,
number: `INV-${new Date().getFullYear()}-${String(inv + 1 + i * 10).padStart(4, '0')}`,
status,
subtotal: subtotal.toFixed(2),
taxRate: '0',
total: total.toFixed(2),
issuedAt,
dueAt,
paidAt,
}).returning();
for (const it of items) {
await db.insert(invoiceItems).values({
invoiceId: invoice.id,
description: it.description,
quantity: String(it.quantity),
rate: String(it.rate),
amount: String(it.amount),
sortOrder: it.sortOrder,
});
}
}
console.log(` ✓ ${def.name.padEnd(32)}${email} (${def.plan})`);
}
console.log('\nDone. All 10 users use password: Demo1234!');
await getPool().end();
}
main().catch((err) => {
console.error(err);
process.exit(1);
});