Storage→Spaces, security hardening, production-blocker fixes, tests + CI

Storage
- Migrate document/media storage from local disk to DigitalOcean Spaces (S3);
  lib/storage.ts now streams via the S3 SDK; SPACES_* env vars required.
- Add scripts/migrate-storage-to-spaces.ts (idempotent, one-time).

Security hardening (all report findings)
- DB pool fails closed in production when the CA cert is missing (no more
  silent unverified TLS); warns in dev.
- trustProxy: 1 (was true) so X-Forwarded-For can't be spoofed to evade rate limits.
- Login lockout keyed by (email, ip) so an attacker can't lock out a victim.
- Superadmin auto-grant now requires a verified email.
- CSRF tokens HMAC-signed; exact-path exemptions; logout no longer exempt.
- Upload content-sniffing (magic bytes) rejects spoofed MIME types.
- create-admin.ts reads creds from env/argv; seed-demo.ts guarded behind ALLOW_SEED.

Production-blocker fixes
- SPA deep-link/refresh no longer 500s (decorateReply fix); index.html served no-cache.
- Invoice numbering is transaction-safe (per-firm advisory lock + max sequence),
  eliminating concurrent collisions and delete-reuse — no schema change.
- Checkout guards against double-billing a firm already on a paid plan.
- Fix render-loop in CreateInvoiceDrawer / ManualEntryDrawer (unstable effect deps).

Honesty / trust
- Remove fabricated testimonials, stats, strikethrough "was" prices, contact SLA,
  and the login-panel stats; replace with non-fabricated copy.
- Fix cookie-policy consent-key mismatch. (Legal pages still need lawyer review.)

Quality
- Add Vitest unit tests (file-signature, password hashing) and GitHub Actions CI.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Leon Serfaty
2026-07-16 13:18:10 -04:00
co-authored by Claude Fable 5
parent 310568690b
commit 97e1d4c60b
51 changed files with 3640 additions and 660 deletions
+71
View File
@@ -0,0 +1,71 @@
// One-off: creates a superadmin user.
// Run from monorepo root: npx tsx scripts/create-admin.ts
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 { eq } from 'drizzle-orm';
import { getDb, getPool, users } from '@lawdesk/db';
// Credentials come from the environment or argv — never hardcode them in a tracked file.
// ADMIN_EMAIL=you@example.com ADMIN_PASSWORD='...' ADMIN_NAME='You' npx tsx scripts/create-admin.ts
// or: npx tsx scripts/create-admin.ts you@example.com 'password' 'Your Name'
const EMAIL = process.env.ADMIN_EMAIL ?? process.argv[2];
const PASSWORD = process.env.ADMIN_PASSWORD ?? process.argv[3];
const NAME = process.env.ADMIN_NAME ?? process.argv[4] ?? 'Admin';
if (!EMAIL || !PASSWORD) {
console.error(
'Missing credentials.\n' +
"Usage: ADMIN_EMAIL=you@example.com ADMIN_PASSWORD='...' [ADMIN_NAME='...'] npx tsx scripts/create-admin.ts",
);
process.exit(1);
}
if (PASSWORD.length < 10) {
console.error('ADMIN_PASSWORD must be at least 10 characters.');
process.exit(1);
}
async function main() {
const db = getDb();
const passwordHash = await argon2.hash(PASSWORD, {
type: argon2.argon2id,
memoryCost: 64 * 1024,
timeCost: 3,
parallelism: 1,
});
const existing = await db.select({ id: users.id }).from(users).where(eq(users.email, EMAIL));
if (existing.length > 0) {
await db.update(users).set({
passwordHash,
isSuperadmin: true,
isSuspended: false,
emailVerifiedAt: new Date(),
updatedAt: new Date(),
}).where(eq(users.email, EMAIL));
console.log(`Updated existing user → superadmin: ${EMAIL}`);
} else {
const [u] = await db.insert(users).values({
email: EMAIL,
passwordHash,
fullName: NAME,
role: 'owner',
isSuperadmin: true,
emailVerifiedAt: new Date(),
}).returning();
console.log(`Created superadmin: ${u.email} (id: ${u.id})`);
}
await getPool().end();
}
main().catch((err) => {
console.error(err);
process.exit(1);
});
+122
View File
@@ -0,0 +1,122 @@
// One-time migration: copy locally-stored documents into DigitalOcean Spaces.
// Idempotent — re-running skips objects already present in the bucket.
//
// npx tsx scripts/migrate-storage-to-spaces.ts # migrate
// npx tsx scripts/migrate-storage-to-spaces.ts --dry-run # report only, no writes
//
// After the app is switched to Spaces (lib/storage.ts), this exists only to lift any files
// that were written to the old local STORAGE_PATH on the server before the cutover.
import fs from 'node:fs';
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 {
S3Client,
PutObjectCommand,
HeadObjectCommand,
} from '@aws-sdk/client-s3';
import { getDb, getPool, documents } from '@lawdesk/db';
const DRY_RUN = process.argv.includes('--dry-run');
const BUCKET = process.env.SPACES_BUCKET!;
const STORAGE_PATH = process.env.STORAGE_PATH ?? './storage';
const s3 = new S3Client({
endpoint: process.env.SPACES_ENDPOINT,
region: process.env.SPACES_REGION,
credentials: {
accessKeyId: process.env.SPACES_KEY!,
secretAccessKey: process.env.SPACES_SECRET!,
},
forcePathStyle: false,
});
async function existsInBucket(key: string): Promise<boolean> {
try {
await s3.send(new HeadObjectCommand({ Bucket: BUCKET, Key: key }));
return true;
} catch {
return false;
}
}
async function main() {
console.log(`Migrating local documents → Spaces bucket "${BUCKET}"${DRY_RUN ? ' (dry run)' : ''}`);
console.log(`Local root: ${path.resolve(STORAGE_PATH)}\n`);
const db = getDb();
const rows = await db
.select({
storageKey: documents.storageKey,
name: documents.name,
mimeType: documents.mimeType,
sizeBytes: documents.sizeBytes,
})
.from(documents);
if (rows.length === 0) {
console.log('No document rows in the database — nothing to migrate.');
await getPool().end();
return;
}
let uploaded = 0;
let skipped = 0;
let missing = 0;
for (const row of rows) {
const localPath = path.resolve(STORAGE_PATH, row.storageKey);
if (await existsInBucket(row.storageKey)) {
skipped++;
console.log(` = already in bucket: ${row.storageKey}`);
continue;
}
if (!fs.existsSync(localPath)) {
missing++;
console.warn(` ! local file missing (nothing to upload): ${row.storageKey} [${row.name}]`);
continue;
}
if (DRY_RUN) {
console.log(` → would upload: ${row.storageKey} (${row.sizeBytes} bytes)`);
uploaded++;
continue;
}
const body = fs.readFileSync(localPath);
await s3.send(
new PutObjectCommand({
Bucket: BUCKET,
Key: row.storageKey,
Body: body,
ContentType: row.mimeType,
ACL: 'private',
}),
);
uploaded++;
console.log(` ✓ uploaded: ${row.storageKey} (${body.length} bytes)`);
}
console.log(
`\nDone. ${uploaded} ${DRY_RUN ? 'to upload' : 'uploaded'}, ${skipped} already present, ${missing} missing locally (of ${rows.length} document rows).`,
);
if (missing > 0) {
console.log(
'Missing files have DB rows but no bytes on disk or in the bucket — they were already lost before migration (local storage was never durable).',
);
}
await getPool().end();
}
main().catch((err) => {
console.error('Migration failed:', err);
process.exit(1);
});
+268
View File
@@ -0,0 +1,268 @@
// 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.
// Require an explicit opt-in so it can never run by accident.
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);
});
+87
View File
@@ -0,0 +1,87 @@
// Flips past-due 'sent' invoices to 'overdue' and emails the client a payment reminder.
// The email goes out only on the sent→overdue transition, so re-running never double-sends.
// Run daily from the monorepo root (cron / scheduled task): npx tsx scripts/send-overdue-reminders.ts
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 { and, eq, lt } from 'drizzle-orm';
import { getDb, getPool, invoices, clients, firms } from '@lawdesk/db';
import { sendEmail, invoiceOverdueEmail } from '../apps/api/src/lib/email';
async function main() {
const db = getDb();
const now = new Date();
const due = await db
.select({
id: invoices.id,
number: invoices.number,
total: invoices.total,
dueAt: invoices.dueAt,
clientName: clients.name,
clientEmail: clients.email,
firmName: firms.name,
})
.from(invoices)
.innerJoin(clients, eq(invoices.clientId, clients.id))
.innerJoin(firms, eq(invoices.firmId, firms.id))
.where(and(eq(invoices.status, 'sent'), lt(invoices.dueAt, now)));
console.log(`Found ${due.length} past-due invoice(s) to mark overdue.`);
let flipped = 0;
let emailed = 0;
for (const inv of due) {
// Guard on status='sent' so a concurrent run can't flip (and email) the same invoice twice.
const [row] = await db
.update(invoices)
.set({ status: 'overdue', updatedAt: new Date() })
.where(and(eq(invoices.id, inv.id), eq(invoices.status, 'sent')))
.returning({ id: invoices.id });
if (!row) continue;
flipped++;
if (!inv.clientEmail || !inv.dueAt) {
console.log(` ${inv.number}: marked overdue, no reminder (missing client email or due date)`);
continue;
}
const totalFmt = new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(
Number(inv.total),
);
const dueDate = inv.dueAt.toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
});
const tpl = invoiceOverdueEmail({
clientName: inv.clientName,
firmName: inv.firmName,
invoiceNumber: inv.number,
total: totalFmt,
dueDate,
});
const result = await sendEmail({ to: inv.clientEmail, ...tpl });
if (result.ok && !result.skipped) {
emailed++;
console.log(` ${inv.number}: marked overdue, reminder sent to ${inv.clientEmail}`);
} else {
console.log(` ${inv.number}: marked overdue, reminder ${result.skipped ? 'skipped (no API key)' : `FAILED: ${result.error}`}`);
}
}
console.log(`Done. ${flipped} invoice(s) marked overdue, ${emailed} reminder(s) sent.`);
await getPool().end();
}
main().catch((err) => {
console.error(err);
process.exit(1);
});