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
+37 -2
View File
@@ -15,6 +15,8 @@ import {
} from '@lawdesk/db';
import { verifyPassword } from '../auth/password';
import { logAudit } from '../lib/audit';
import { sendEmail, accountDeletedEmail } from '../lib/email';
import { deletePrefix, getSignedDownloadUrl } from '../lib/storage';
export async function accountRoutes(app: FastifyInstance) {
app.addHook('preHandler', app.requireAuth);
@@ -59,6 +61,19 @@ export async function accountRoutes(app: FastifyInstance) {
: [];
const docs = await db.select().from(documents).where(eq(documents.firmId, firmId));
// GDPR portability covers the files themselves, not just their metadata — attach a
// time-limited presigned download URL per document (valid 24h; re-export for fresh links).
const docsWithUrls = await Promise.all(
docs.map(async (d) => {
try {
const downloadUrl = await getSignedDownloadUrl(d.storageKey, d.name, 24 * 60 * 60);
return { ...d, downloadUrl, downloadUrlExpiresInHours: 24 };
} catch {
return { ...d, downloadUrl: null, downloadUrlExpiresInHours: null };
}
}),
);
dump.firm = firm;
dump.clients = firmClients;
dump.cases = firmCases;
@@ -67,7 +82,7 @@ export async function accountRoutes(app: FastifyInstance) {
...i,
items: items.filter((it) => it.invoiceId === i.id),
}));
dump.documents = docs;
dump.documents = docsWithUrls;
}
await logAudit({
@@ -101,10 +116,11 @@ export async function accountRoutes(app: FastifyInstance) {
if (!ok) return reply.code(401).send({ error: 'invalid_password' });
if (firmId) {
const [{ count }] = await db
const countRows = await db
.select({ count: sql<number>`count(*)::int` })
.from(users)
.where(eq(users.firmId, firmId));
const count = countRows[0]?.count ?? 0;
if (count > 1) {
return reply.code(409).send({
error: 'firm_has_other_users',
@@ -130,6 +146,25 @@ export async function accountRoutes(app: FastifyInstance) {
await tx.delete(users).where(eq(users.id, userId));
});
// GDPR erasure: the cascade above removed the document rows; now remove the files
// themselves. Storage keys are namespaced `${firmId}/...`, so a prefix delete catches
// everything, including any objects orphaned by earlier partial failures.
if (firmId) {
try {
const removed = await deletePrefix(`${firmId}/`);
app.log.info({ firmId, removed }, 'deleted firm storage on account deletion');
} catch (err) {
// The account is already gone — surface loudly so the sweep script can catch up.
app.log.error({ err, firmId }, 'FAILED to delete firm storage after account deletion');
}
}
// Deletion confirmation — the account row is gone, so use the details captured above.
const tpl = accountDeletedEmail(me.fullName);
sendEmail({ to: me.email, ...tpl }).catch((err) =>
app.log.warn({ err }, 'account deleted email failed'),
);
app.clearSessionCookie(reply);
app.clearCsrfCookie(reply);
return { ok: true };