Add e2e auth test suite, retention crons, and email-verification UX
CI / build-and-test (push) Has been cancelled

- E2E suite (23 tests, `npm run test:e2e -w @lawdesk/api`): boots the real
  Fastify app against a disposable Dockerized Postgres (never a real DB) and
  covers signup/login/lockout/rate limits, CSRF (incl. forged-token
  rejection), logout, password reset, email verification, the superadmin
  verified-email promotion gate, and cross-firm tenancy isolation
- packages/db: DATABASE_SSL=disable opt-out for local/test databases that
  don't speak TLS; refused in production
- retention-sweep.ts cron enforcing Privacy Policy windows (sessions,
  tokens, login attempts, tool usage, contact messages, audit log) +
  sweep-orphaned-storage.ts Spaces reconciliation + scripts/README
- Expose emailVerified on the session user; in-app verify-email banner
  with resend, and verified=1|0 toasts on the login page
- Silence Fastify logger under NODE_ENV=test; fix footer resource link;
  document login-attempt/tool-usage retention in the Privacy Policy

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Leon Serfaty
2026-07-16 14:32:55 -04:00
co-authored by Claude Fable 5
parent 97e1d4c60b
commit d9b807662a
20 changed files with 983 additions and 6 deletions
+1
View File
@@ -10,6 +10,7 @@
"start": "tsx src/server.ts",
"test": "vitest run",
"test:watch": "vitest",
"test:e2e": "vitest run -c vitest.e2e.config.ts",
"typecheck": "tsc -p tsconfig.json --noEmit"
},
"dependencies": {
+2
View File
@@ -13,6 +13,7 @@ declare module 'fastify' {
role: string;
isSuperadmin: boolean;
isSuspended: boolean;
emailVerified: boolean;
};
}
interface FastifyInstance {
@@ -47,6 +48,7 @@ async function plugin(app: FastifyInstance) {
role: session.user.role,
isSuperadmin,
isSuspended: session.user.isSuspended,
emailVerified: Boolean(session.user.emailVerifiedAt),
};
});
+1 -1
View File
@@ -96,7 +96,7 @@ export async function accountRoutes(app: FastifyInstance) {
.header('Content-Type', 'application/json; charset=utf-8')
.header(
'Content-Disposition',
`attachment; filename="lawdesk-export-${new Date().toISOString().slice(0, 10)}.json"`,
`attachment; filename="elegal-export-${new Date().toISOString().slice(0, 10)}.json"`,
);
return JSON.stringify(dump, null, 2);
});
+2
View File
@@ -134,6 +134,7 @@ export async function authRoutes(app: FastifyInstance) {
role: user.role,
isSuperadmin,
isSuspended: user.isSuspended,
emailVerified: Boolean(user.emailVerifiedAt),
},
});
});
@@ -191,6 +192,7 @@ export async function authRoutes(app: FastifyInstance) {
role: user.role,
isSuperadmin,
isSuspended: user.isSuspended,
emailVerified: Boolean(user.emailVerifiedAt),
},
};
});
+4 -1
View File
@@ -33,7 +33,10 @@ initSentry();
export async function buildServer() {
const app = Fastify({
logger: isProd
logger:
env.NODE_ENV === 'test'
? false
: isProd
? { level: 'info' }
: { level: 'debug', transport: { target: 'pino-pretty', options: { colorize: true } } },
// Trust exactly ONE proxy hop (the Plesk/nginx reverse proxy in front of Passenger).
+595
View File
@@ -0,0 +1,595 @@
import crypto from 'node:crypto';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import type { FastifyInstance } from 'fastify';
import { E2E_DATABASE_URL } from './env';
type InjectResponse = Awaited<ReturnType<FastifyInstance['inject']>>;
// Loaded dynamically in beforeAll, AFTER the safety gate confirms we point at the
// disposable container — never at a real database.
let app: FastifyInstance;
let db: typeof import('@lawdesk/db');
let eq: typeof import('drizzle-orm').eq;
let ipCounter = 0;
/** Fresh source IP per scenario so per-IP rate limits never bleed between tests. */
function nextIp(): string {
ipCounter += 1;
return `10.99.${Math.floor(ipCounter / 250)}.${(ipCounter % 250) + 1}`;
}
let emailCounter = 0;
function nextEmail(): string {
emailCounter += 1;
return `e2e-user-${emailCounter}@example.com`;
}
const PASSWORD = 'correct-horse-battery';
function cookiesOf(res: InjectResponse): Record<string, string> {
const out: Record<string, string> = {};
for (const c of res.cookies) out[c.name] = c.value;
return out;
}
interface Session {
sid: string;
csrf: string;
user: { id: string; email: string; firmId: string; isSuperadmin: boolean };
ip: string;
}
async function signup(overrides: { email?: string; ip?: string } = {}): Promise<Session> {
const ip = overrides.ip ?? nextIp();
const email = overrides.email ?? nextEmail();
const res = await app.inject({
method: 'POST',
url: '/api/auth/signup',
remoteAddress: ip,
payload: { email, password: PASSWORD, fullName: 'E2e Tester', firmName: 'E2e Firm' },
});
expect(res.statusCode).toBe(201);
const cookies = cookiesOf(res);
expect(cookies.sid).toBeTruthy();
expect(cookies.csrf).toBeTruthy();
return { sid: cookies.sid, csrf: cookies.csrf, user: res.json().user, ip };
}
function authed(s: Session, extra: Record<string, string> = {}) {
return {
cookies: { sid: s.sid, csrf: s.csrf },
headers: { 'x-csrf-token': s.csrf, ...extra },
remoteAddress: s.ip,
};
}
function sha256(raw: string): string {
return crypto.createHash('sha256').update(raw).digest('hex');
}
beforeAll(async () => {
// Hard safety gate: this suite creates/mutates/deletes users. Refuse to run against
// anything but the disposable e2e container defined in ./env.ts.
if (process.env.DATABASE_URL !== E2E_DATABASE_URL) {
throw new Error(
'REFUSING to run e2e tests: DATABASE_URL is not the disposable e2e database. ' +
'Run via `npm run test:e2e` (vitest.e2e.config.ts), never against a real DB.',
);
}
db = await import('@lawdesk/db');
({ eq } = await import('drizzle-orm'));
const { buildServer } = await import('../src/server');
app = await buildServer();
await app.ready();
});
afterAll(async () => {
await app?.close();
});
describe('signup', () => {
it('creates a user + firm, sets session and csrf cookies, and /me works', async () => {
const ip = nextIp();
const email = nextEmail();
const res = await app.inject({
method: 'POST',
url: '/api/auth/signup',
remoteAddress: ip,
payload: { email, password: PASSWORD, fullName: 'Jane Doe', firmName: 'Doe Law' },
});
expect(res.statusCode).toBe(201);
const body = res.json();
expect(body.user.email).toBe(email);
expect(body.user.firmId).toBeTruthy();
expect(body.user.role).toBe('owner');
expect(body.user.isSuperadmin).toBe(false);
const setCookie = res.headers['set-cookie'] as string[] | string;
const raw = Array.isArray(setCookie) ? setCookie.join('\n') : String(setCookie);
expect(raw).toMatch(/sid=[^;]+;[^\n]*HttpOnly/i); // session cookie must be HttpOnly
const csrfLine = raw.split('\n').find((l) => l.startsWith('csrf='));
expect(csrfLine).toBeTruthy();
expect(csrfLine!).not.toMatch(/HttpOnly/i); // csrf cookie must be JS-readable
const cookies = cookiesOf(res);
const me = await app.inject({
method: 'GET',
url: '/api/auth/me',
cookies: { sid: cookies.sid },
remoteAddress: ip,
});
expect(me.statusCode).toBe(200);
expect(me.json().user.email).toBe(email);
});
it('rejects a duplicate email with 409', async () => {
const email = nextEmail();
await signup({ email });
const res = await app.inject({
method: 'POST',
url: '/api/auth/signup',
remoteAddress: nextIp(),
payload: { email, password: PASSWORD, fullName: 'Dup', firmName: 'Dup Firm' },
});
expect(res.statusCode).toBe(409);
expect(res.json().error).toBe('email_taken');
});
it('rejects passwords shorter than 10 chars with 400', async () => {
const res = await app.inject({
method: 'POST',
url: '/api/auth/signup',
remoteAddress: nextIp(),
payload: { email: nextEmail(), password: 'short', fullName: 'X', firmName: 'Y' },
});
expect(res.statusCode).toBe(400);
expect(res.json().error).toBe('validation');
});
it('rate-limits signup to 5 requests/hour per IP', async () => {
const ip = nextIp();
for (let i = 0; i < 5; i++) {
// Invalid payloads still count against the limiter and are cheap (no argon2).
const res = await app.inject({
method: 'POST',
url: '/api/auth/signup',
remoteAddress: ip,
payload: {},
});
expect(res.statusCode).toBe(400);
}
const sixth = await app.inject({
method: 'POST',
url: '/api/auth/signup',
remoteAddress: ip,
payload: {},
});
expect(sixth.statusCode).toBe(429);
});
});
describe('login', () => {
it('rejects a wrong password with 401 and accepts the right one', async () => {
const s = await signup();
const ip = nextIp();
const bad = await app.inject({
method: 'POST',
url: '/api/auth/login',
remoteAddress: ip,
payload: { email: s.user.email, password: 'definitely-wrong-pw' },
});
expect(bad.statusCode).toBe(401);
expect(bad.json().error).toBe('invalid_credentials');
const good = await app.inject({
method: 'POST',
url: '/api/auth/login',
remoteAddress: ip,
payload: { email: s.user.email, password: PASSWORD },
});
expect(good.statusCode).toBe(200);
const cookies = cookiesOf(good);
const me = await app.inject({
method: 'GET',
url: '/api/auth/me',
cookies: { sid: cookies.sid },
remoteAddress: ip,
});
expect(me.statusCode).toBe(200);
});
it('returns 401 for a nonexistent email (no enumeration via status)', async () => {
const res = await app.inject({
method: 'POST',
url: '/api/auth/login',
remoteAddress: nextIp(),
payload: { email: 'nobody-here@example.com', password: PASSWORD },
});
expect(res.statusCode).toBe(401);
expect(res.json().error).toBe('invalid_credentials');
});
it('blocks a suspended user at login and on API routes', async () => {
const s = await signup();
await db
.getDb()
.update(db.users)
.set({ isSuspended: true })
.where(eq(db.users.id, s.user.id));
const login = await app.inject({
method: 'POST',
url: '/api/auth/login',
remoteAddress: nextIp(),
payload: { email: s.user.email, password: PASSWORD },
});
expect(login.statusCode).toBe(403);
expect(login.json().error).toBe('account_suspended');
// Existing session must also be blocked by requireAuth on business routes.
const list = await app.inject({
method: 'GET',
url: '/api/clients',
cookies: { sid: s.sid },
remoteAddress: s.ip,
});
expect(list.statusCode).toBe(403);
expect(list.json().error).toBe('account_suspended');
});
it('locks out after 5 failed attempts per (email, IP) but not from another IP', async () => {
const s = await signup();
const attackerIp = nextIp();
for (let i = 0; i < 5; i++) {
const res = await app.inject({
method: 'POST',
url: '/api/auth/login',
remoteAddress: attackerIp,
payload: { email: s.user.email, password: `wrong-${i}-padding` },
});
expect(res.statusCode).toBe(401);
}
// 6th attempt from the same IP is locked out even with the CORRECT password.
const locked = await app.inject({
method: 'POST',
url: '/api/auth/login',
remoteAddress: attackerIp,
payload: { email: s.user.email, password: PASSWORD },
});
expect(locked.statusCode).toBe(429);
expect(locked.json().error).toBe('too_many_attempts');
// The legitimate owner on a different IP is NOT locked out by the attacker.
const owner = await app.inject({
method: 'POST',
url: '/api/auth/login',
remoteAddress: nextIp(),
payload: { email: s.user.email, password: PASSWORD },
});
expect(owner.statusCode).toBe(200);
});
it('rate-limits the login route to 10 requests/15min per IP', async () => {
const ip = nextIp();
for (let i = 0; i < 10; i++) {
const res = await app.inject({
method: 'POST',
url: '/api/auth/login',
remoteAddress: ip,
payload: { email: `ratelimit-${i}@example.com`, password: 'whatever-pw' },
});
expect(res.statusCode).toBe(401);
}
const eleventh = await app.inject({
method: 'POST',
url: '/api/auth/login',
remoteAddress: ip,
payload: { email: 'ratelimit-x@example.com', password: 'whatever-pw' },
});
expect(eleventh.statusCode).toBe(429);
});
});
describe('csrf', () => {
it('rejects an authenticated state-changing request without the CSRF header', async () => {
const s = await signup();
const res = await app.inject({
method: 'POST',
url: '/api/clients',
cookies: { sid: s.sid, csrf: s.csrf },
remoteAddress: s.ip,
payload: { name: 'No Header Client' },
});
expect(res.statusCode).toBe(403);
expect(res.json().error).toBe('csrf_failed');
});
it('rejects a mismatched cookie/header pair', async () => {
const s = await signup();
const res = await app.inject({
method: 'POST',
url: '/api/clients',
cookies: { sid: s.sid, csrf: s.csrf },
headers: { 'x-csrf-token': 'not-the-cookie-value' },
remoteAddress: s.ip,
payload: { name: 'Mismatch Client' },
});
expect(res.statusCode).toBe(403);
});
it('rejects an attacker-planted (unsigned) matching pair — signature required', async () => {
const s = await signup();
const planted = 'attacker-random.attacker-fake-signature';
const res = await app.inject({
method: 'POST',
url: '/api/clients',
cookies: { sid: s.sid, csrf: planted },
headers: { 'x-csrf-token': planted },
remoteAddress: s.ip,
payload: { name: 'Planted Client' },
});
expect(res.statusCode).toBe(403);
});
it('accepts the legitimate cookie+header pair', async () => {
const s = await signup();
const res = await app.inject({
method: 'POST',
url: '/api/clients',
...authed(s),
payload: { name: 'Legit Client' },
});
expect(res.statusCode).toBe(201);
expect(res.json().name).toBe('Legit Client');
});
});
describe('logout', () => {
it('destroys the session server-side', async () => {
const s = await signup();
const out = await app.inject({ method: 'POST', url: '/api/auth/logout', ...authed(s) });
expect(out.statusCode).toBe(200);
// Replaying the old cookie after logout must fail — session is gone from the DB.
const me = await app.inject({
method: 'GET',
url: '/api/auth/me',
cookies: { sid: s.sid },
remoteAddress: s.ip,
});
expect(me.statusCode).toBe(401);
});
});
describe('password reset', () => {
it('always returns ok (no account enumeration)', async () => {
const res = await app.inject({
method: 'POST',
url: '/api/auth/request-password-reset',
remoteAddress: nextIp(),
payload: { email: 'ghost-account@example.com' },
});
expect(res.statusCode).toBe(200);
expect(res.json().ok).toBe(true);
});
it('resets the password, revokes all sessions, and consumes the token', async () => {
const s = await signup();
const rawToken = crypto.randomBytes(32).toString('base64url');
await db.getDb().insert(db.passwordResets).values({
tokenHash: sha256(rawToken),
userId: s.user.id,
expiresAt: new Date(Date.now() + 60 * 60 * 1000),
});
const newPassword = 'brand-new-password-42';
const reset = await app.inject({
method: 'POST',
url: '/api/auth/reset-password',
remoteAddress: nextIp(),
payload: { token: rawToken, password: newPassword },
});
expect(reset.statusCode).toBe(200);
// All prior sessions revoked.
const me = await app.inject({
method: 'GET',
url: '/api/auth/me',
cookies: { sid: s.sid },
remoteAddress: s.ip,
});
expect(me.statusCode).toBe(401);
// Old password dead, new password works.
const oldLogin = await app.inject({
method: 'POST',
url: '/api/auth/login',
remoteAddress: nextIp(),
payload: { email: s.user.email, password: PASSWORD },
});
expect(oldLogin.statusCode).toBe(401);
const newLogin = await app.inject({
method: 'POST',
url: '/api/auth/login',
remoteAddress: nextIp(),
payload: { email: s.user.email, password: newPassword },
});
expect(newLogin.statusCode).toBe(200);
// Token is single-use.
const replay = await app.inject({
method: 'POST',
url: '/api/auth/reset-password',
remoteAddress: nextIp(),
payload: { token: rawToken, password: 'yet-another-password' },
});
expect(replay.statusCode).toBe(400);
expect(replay.json().error).toBe('invalid_or_used_token');
});
it('rejects an expired token', async () => {
const s = await signup();
const rawToken = crypto.randomBytes(32).toString('base64url');
await db.getDb().insert(db.passwordResets).values({
tokenHash: sha256(rawToken),
userId: s.user.id,
expiresAt: new Date(Date.now() - 1000),
});
const res = await app.inject({
method: 'POST',
url: '/api/auth/reset-password',
remoteAddress: nextIp(),
payload: { token: rawToken, password: 'whatever-new-pass' },
});
expect(res.statusCode).toBe(400);
expect(res.json().error).toBe('token_expired');
});
});
describe('email verification', () => {
it('verifies via the emailed token, exactly once', async () => {
const s = await signup();
const rawToken = crypto.randomBytes(32).toString('base64url');
await db.getDb().insert(db.emailVerifications).values({
tokenHash: sha256(rawToken),
userId: s.user.id,
expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000),
});
const ok = await app.inject({
method: 'GET',
url: `/api/auth/verify-email?token=${rawToken}`,
remoteAddress: nextIp(),
});
expect(ok.statusCode).toBe(302);
expect(ok.headers.location).toContain('verified=1');
const [row] = await db
.getDb()
.select()
.from(db.users)
.where(eq(db.users.id, s.user.id))
.limit(1);
expect(row!.emailVerifiedAt).not.toBeNull();
// Consumed token cannot be replayed.
const replay = await app.inject({
method: 'GET',
url: `/api/auth/verify-email?token=${rawToken}`,
remoteAddress: nextIp(),
});
expect(replay.statusCode).toBe(302);
expect(replay.headers.location).toContain('verified=0');
});
it('rejects a garbage token', async () => {
const res = await app.inject({
method: 'GET',
url: `/api/auth/verify-email?token=${crypto.randomBytes(32).toString('base64url')}`,
remoteAddress: nextIp(),
});
expect(res.statusCode).toBe(302);
expect(res.headers.location).toContain('verified=0');
});
});
describe('superadmin promotion gate', () => {
it('does NOT promote an allowlisted email until it is verified', async () => {
// superadmin-e2e@example.com is on SUPERADMIN_EMAILS (see env.ts).
const s = await signup({ email: 'superadmin-e2e@example.com' });
expect(s.user.isSuperadmin).toBe(false);
const denied = await app.inject({
method: 'GET',
url: '/api/admin/stats',
cookies: { sid: s.sid },
remoteAddress: s.ip,
});
expect(denied.statusCode).toBe(403);
// Simulate clicking the verification link, then the next request auto-promotes.
await db
.getDb()
.update(db.users)
.set({ emailVerifiedAt: new Date() })
.where(eq(db.users.id, s.user.id));
const me = await app.inject({
method: 'GET',
url: '/api/auth/me',
cookies: { sid: s.sid },
remoteAddress: s.ip,
});
expect(me.statusCode).toBe(200);
expect(me.json().user.isSuperadmin).toBe(true);
const allowed = await app.inject({
method: 'GET',
url: '/api/admin/stats',
cookies: { sid: s.sid },
remoteAddress: s.ip,
});
expect(allowed.statusCode).toBe(200);
});
it('keeps admin routes closed to normal users', async () => {
const s = await signup();
const res = await app.inject({
method: 'GET',
url: '/api/admin/stats',
cookies: { sid: s.sid },
remoteAddress: s.ip,
});
expect(res.statusCode).toBe(403);
expect(res.json().error).toBe('forbidden');
});
});
describe('tenancy isolation (auth-adjacent)', () => {
it('blocks unauthenticated access to business routes', async () => {
const res = await app.inject({ method: 'GET', url: '/api/clients', remoteAddress: nextIp() });
expect(res.statusCode).toBe(401);
});
it("prevents one firm from reading or writing another firm's data", async () => {
const alice = await signup();
const created = await app.inject({
method: 'POST',
url: '/api/clients',
...authed(alice),
payload: { name: 'Alice Secret Client' },
});
expect(created.statusCode).toBe(201);
const clientId = created.json().id;
const bob = await signup();
const read = await app.inject({
method: 'GET',
url: `/api/clients/${clientId}`,
cookies: { sid: bob.sid },
remoteAddress: bob.ip,
});
expect(read.statusCode).toBe(404);
const write = await app.inject({
method: 'PATCH',
url: `/api/clients/${clientId}`,
...authed(bob),
payload: { name: 'Bob Was Here' },
});
expect(write.statusCode).toBe(404);
// And Alice still sees her client untouched.
const alicRead = await app.inject({
method: 'GET',
url: `/api/clients/${clientId}`,
cookies: { sid: alice.sid },
remoteAddress: alice.ip,
});
expect(alicRead.statusCode).toBe(200);
expect(alicRead.json().name).toBe('Alice Secret Client');
});
});
+33
View File
@@ -0,0 +1,33 @@
// Shared constants for the e2e harness. The database is a DISPOSABLE Docker container —
// these tests must never point at a real database. global-setup.ts creates/destroys the
// container; auth.e2e.test.ts refuses to run unless DATABASE_URL matches this URL exactly.
export const E2E_CONTAINER = 'elegal-e2e-pg';
export const E2E_DB_PORT = 54329;
export const E2E_DB_NAME = 'elegal_e2e';
export const E2E_DB_PASSWORD = 'e2e_throwaway_password';
export const E2E_DATABASE_URL = `postgres://postgres:${E2E_DB_PASSWORD}@127.0.0.1:${E2E_DB_PORT}/${E2E_DB_NAME}`;
export const E2E_ENV: Record<string, string> = {
NODE_ENV: 'test',
DATABASE_URL: E2E_DATABASE_URL,
DATABASE_SSL: 'disable',
DATABASE_CA_CERT_PATH: '',
PUBLIC_URL: 'http://localhost:8080',
COOKIE_DOMAIN: '',
SESSION_SECRET: 'e2e-session-secret-not-real-0123456789abcdef',
CSRF_SECRET: 'e2e-csrf-secret-not-real-0123456789abcdef',
SUPERADMIN_EMAILS: 'superadmin-e2e@example.com',
// Dummy Spaces config — env.ts requires these; no storage calls happen in auth tests.
SPACES_ENDPOINT: 'https://e2e-invalid.example.com',
SPACES_REGION: 'e2e',
SPACES_BUCKET: 'e2e-bucket',
SPACES_KEY: 'e2e-key',
SPACES_SECRET: 'e2e-secret',
// Empty → email sends are skipped, Stripe/Sentry stay inert.
SMTP2GO_API_KEY: '',
STRIPE_SECRET_KEY: '',
STRIPE_WEBHOOK_SECRET: '',
SENTRY_DSN_API: '',
};
+67
View File
@@ -0,0 +1,67 @@
import { execSync } from 'node:child_process';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import pg from 'pg';
import { drizzle } from 'drizzle-orm/node-postgres';
import { migrate } from 'drizzle-orm/node-postgres/migrator';
import {
E2E_CONTAINER,
E2E_DATABASE_URL,
E2E_DB_NAME,
E2E_DB_PASSWORD,
E2E_DB_PORT,
} from './env';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const MIGRATIONS = path.resolve(__dirname, '../../../packages/db/migrations');
async function waitForPostgres(timeoutMs = 60_000): Promise<void> {
const deadline = Date.now() + timeoutMs;
let lastErr: unknown;
while (Date.now() < deadline) {
const client = new pg.Client({ connectionString: E2E_DATABASE_URL });
try {
await client.connect();
await client.query('select 1');
await client.end();
return;
} catch (err) {
lastErr = err;
await client.end().catch(() => {});
await new Promise((r) => setTimeout(r, 500));
}
}
throw new Error(`e2e postgres did not become ready in ${timeoutMs}ms: ${lastErr}`);
}
export default async function setup() {
try {
execSync(`docker rm -f ${E2E_CONTAINER}`, { stdio: 'ignore' });
} catch {
// no stale container — fine
}
execSync(
`docker run --rm -d --name ${E2E_CONTAINER} -p ${E2E_DB_PORT}:5432 ` +
`-e POSTGRES_PASSWORD=${E2E_DB_PASSWORD} -e POSTGRES_DB=${E2E_DB_NAME} postgres:16-alpine`,
{ stdio: 'inherit' },
);
try {
await waitForPostgres();
const pool = new pg.Pool({ connectionString: E2E_DATABASE_URL, max: 2 });
try {
await migrate(drizzle(pool), { migrationsFolder: MIGRATIONS });
} finally {
await pool.end();
}
} catch (err) {
execSync(`docker rm -f ${E2E_CONTAINER}`, { stdio: 'ignore' });
throw err;
}
return () => {
execSync(`docker rm -f ${E2E_CONTAINER}`, { stdio: 'ignore' });
};
}
+17
View File
@@ -0,0 +1,17 @@
import { defineConfig } from 'vitest/config';
import { E2E_ENV } from './test-e2e/env';
// E2E suite: real Fastify app + real (disposable, Dockerized) Postgres.
// Kept out of the default `npm test` (unit) config on purpose — requires Docker.
export default defineConfig({
test: {
include: ['test-e2e/**/*.e2e.test.ts'],
environment: 'node',
env: E2E_ENV,
globalSetup: ['./test-e2e/global-setup.ts'],
// One worker: tests share the app instance and rate-limiter state is IP-partitioned per test.
fileParallelism: false,
testTimeout: 60_000,
hookTimeout: 120_000,
},
});
@@ -2,6 +2,7 @@ import { Navigate, Outlet } from 'react-router-dom';
import { useMe } from '@/hooks/useAuth';
import { Sidebar } from './Sidebar';
import { Topbar } from './Topbar';
import { VerifyEmailBanner } from './VerifyEmailBanner';
export function AppLayout() {
const me = useMe();
@@ -19,6 +20,7 @@ export function AppLayout() {
<Sidebar />
<div className="flex-1 flex flex-col min-w-0">
<Topbar />
<VerifyEmailBanner />
<main className="flex-1 overflow-y-auto">
<Outlet />
</main>
@@ -0,0 +1,46 @@
import { useState } from 'react';
import { MailWarning } from 'lucide-react';
import { useMe } from '@/hooks/useAuth';
import { api } from '@/lib/api';
export function VerifyEmailBanner() {
const me = useMe();
const [state, setState] = useState<'idle' | 'sending' | 'sent' | 'error'>('idle');
// Only when the API explicitly says unverified — older cached sessions omit the flag.
if (!me.data || me.data.emailVerified !== false) return null;
async function resend() {
setState('sending');
try {
await api.post('/api/auth/resend-verification');
setState('sent');
} catch {
setState('error');
}
}
return (
<div className="border-b border-amber-200 bg-amber-50 px-4 py-2.5 flex flex-wrap items-center gap-x-3 gap-y-1 text-sm text-amber-900">
<MailWarning className="h-4 w-4 flex-none text-amber-600" />
<span>
Please verify your email we sent a link to <strong>{me.data.email}</strong>.
</span>
{state === 'sent' ? (
<span className="font-medium text-emerald-700">Verification email sent </span>
) : (
<button
type="button"
onClick={resend}
disabled={state === 'sending'}
className="font-semibold text-amber-800 underline underline-offset-2 hover:text-amber-950 disabled:opacity-60"
>
{state === 'sending' ? 'Sending…' : 'Resend email'}
</button>
)}
{state === 'error' && (
<span className="text-rose-700">Couldn&apos;t send try again in a few minutes.</span>
)}
</div>
);
}
+1 -1
View File
@@ -21,7 +21,7 @@ const COLUMNS = [
{
title: 'Resources',
links: [
{ href: '/resources', label: 'Resource Hub' },
{ href: '/#resources', label: 'Resource Hub' },
{ href: '/blog', label: 'Blog' },
],
},
+1
View File
@@ -9,6 +9,7 @@ export interface AuthUser {
role: string;
isSuperadmin?: boolean;
isSuspended?: boolean;
emailVerified?: boolean;
}
interface MeResponse {
+15
View File
@@ -25,6 +25,9 @@ export default function LoginPage() {
const me = useMe();
const login = useLogin();
// Landing target of the email-verification link: /login?verified=1|0
const verified = new URLSearchParams(location.search).get('verified');
const {
register,
handleSubmit,
@@ -60,6 +63,18 @@ export default function LoginPage() {
</>
}
>
{verified === '1' && (
<p className="mb-4 rounded-lg bg-emerald-50 px-3 py-2 text-sm text-emerald-700">
Email verified thanks! Sign in to continue.
</p>
)}
{verified === '0' && (
<p className="mb-4 rounded-lg bg-amber-50 px-3 py-2 text-sm text-amber-800">
That verification link is invalid or has expired. Sign in and use &ldquo;Resend
email&rdquo; to get a fresh one.
</p>
)}
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
<Field
label="Email"
+1
View File
@@ -151,6 +151,7 @@ export default function PrivacyPage() {
'Audit log — kept for 24 months.',
'Payment records — kept as required by tax and accounting laws (typically 7 years).',
'Server logs — kept for 30 days.',
'Login-attempt and free-tool usage records (email, IP address) — kept for 90 days, then deleted.',
'Contact-form messages — kept for 24 months, then deleted.',
]}
/>
+3
View File
@@ -19,6 +19,9 @@
"start": "node server.cjs",
"db:generate": "npm run generate -w packages/db",
"db:migrate": "npm run migrate -w packages/db",
"cron:retention": "tsx scripts/retention-sweep.ts",
"cron:overdue": "tsx scripts/send-overdue-reminders.ts",
"storage:sweep": "tsx scripts/sweep-orphaned-storage.ts",
"typecheck": "npm run typecheck --workspaces --if-present",
"test": "npm run test --workspaces --if-present"
},
+8 -1
View File
@@ -35,7 +35,14 @@ export function getPool(): pg.Pool {
// Strip sslmode from the URL so our explicit `ssl` option fully controls TLS behavior.
// Without this, pg merges URL-derived settings which can conflict with the options below.
let ssl: pg.PoolConfig['ssl'];
if (ca) {
if (process.env.DATABASE_SSL === 'disable') {
// Explicit opt-out for local dev/test databases that don't speak TLS at all (e.g. a
// disposable Docker Postgres). Refused in production — prod must always verify TLS.
if (isProd) {
throw new Error('DATABASE_SSL=disable is not allowed in production');
}
ssl = false;
} else if (ca) {
// Verified TLS against the managed-DB CA — the correct posture everywhere.
ssl = { ca, rejectUnauthorized: true };
} else if (isProd) {
+28
View File
@@ -0,0 +1,28 @@
# Operational scripts
All scripts load `.env` from the repo root and run against the **live** database/bucket —
there is no staging environment. Run them from the monorepo root.
## Scheduled jobs (set up as daily crons on the production server)
```cron
# Daily at 03:00 — enforce Privacy Policy retention windows
0 3 * * * cd /path/to/app && npm run cron:retention >> logs/retention.log 2>&1
# Daily at 08:00 — mark past-due invoices overdue + email client reminders
0 8 * * * cd /path/to/app && npm run cron:overdue >> logs/overdue.log 2>&1
```
| Script | npm alias | What it does |
| --- | --- | --- |
| `retention-sweep.ts` | `npm run cron:retention` | Purges expired sessions, consumed/expired reset & verification tokens, login attempts and tool usage > 90 days, contact messages and audit log > 24 months. |
| `send-overdue-reminders.ts` | `npm run cron:overdue` | Flips past-due `sent` invoices to `overdue` and emails the client once, on the transition. |
## Maintenance / one-off
| Script | npm alias | What it does |
| --- | --- | --- |
| `sweep-orphaned-storage.ts` | `npm run storage:sweep` | Reconciles DO Spaces against the `documents` table. Dry-run by default; add `-- --delete` to remove orphans. Only document-shaped keys (`uuid/uuid/uuid.ext`) are ever deleted. |
| `create-admin.ts` | — | Creates a superadmin user (credentials via env/argv). |
| `seed-demo.ts` | — | Seeds demo data. **Live DB — use with care.** |
| `migrate-storage-to-spaces.ts` | — | One-time migration of legacy local files to Spaces (historical). |
+79
View File
@@ -0,0 +1,79 @@
// Enforces the data-retention windows promised in the Privacy Policy. Run daily:
// npx tsx scripts/retention-sweep.ts
//
// Windows enforced:
// - sessions: expired → deleted
// - password resets / email verifications: consumed or expired → deleted
// - login attempts (email, IP): 90 days
// - tool usage (IP): 90 days
// - contact messages: 24 months
// - audit log: 24 months
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 { isNotNull, lt, or } from 'drizzle-orm';
import {
getDb,
getPool,
sessions,
passwordResets,
emailVerifications,
loginAttempts,
contactMessages,
toolUsage,
auditLog,
} from '@lawdesk/db';
function daysAgo(n: number): Date {
return new Date(Date.now() - n * 24 * 60 * 60 * 1000);
}
async function main() {
const db = getDb();
const now = new Date();
const report: Array<[string, number]> = [];
const run = async (label: string, fn: () => Promise<{ rowCount?: number | null }>) => {
const res = await fn();
report.push([label, res.rowCount ?? 0]);
};
await run('expired sessions', () => db.delete(sessions).where(lt(sessions.expiresAt, now)));
await run('consumed/expired password resets', () =>
db
.delete(passwordResets)
.where(or(isNotNull(passwordResets.consumedAt), lt(passwordResets.expiresAt, now))),
);
await run('consumed/expired email verifications', () =>
db
.delete(emailVerifications)
.where(or(isNotNull(emailVerifications.consumedAt), lt(emailVerifications.expiresAt, now))),
);
await run('login attempts > 90 days', () =>
db.delete(loginAttempts).where(lt(loginAttempts.attemptedAt, daysAgo(90))),
);
await run('tool usage > 90 days', () =>
db.delete(toolUsage).where(lt(toolUsage.createdAt, daysAgo(90))),
);
await run('contact messages > 24 months', () =>
db.delete(contactMessages).where(lt(contactMessages.createdAt, daysAgo(730))),
);
await run('audit log > 24 months', () =>
db.delete(auditLog).where(lt(auditLog.createdAt, daysAgo(730))),
);
console.log('Retention sweep complete:');
for (const [label, count] of report) console.log(` ${label}: ${count} row(s) deleted`);
await getPool().end();
}
main().catch((err) => {
console.error(err);
process.exit(1);
});
+75
View File
@@ -0,0 +1,75 @@
// Reconciles DO Spaces against the documents table and reports/deletes orphaned objects —
// files whose DB rows were removed before storage cleanup existed (deleted firms/cases/clients).
//
// Dry run (default): npx tsx scripts/sweep-orphaned-storage.ts
// Actually delete: npx tsx scripts/sweep-orphaned-storage.ts --delete
//
// Only keys matching the document layout `<uuid>/<uuid>/<uuid>.<ext>` are eligible for
// deletion; anything else in the bucket is reported but never touched.
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 { getDb, getPool, documents } from '@lawdesk/db';
import { listAllKeys, deleteFile } from '../apps/api/src/lib/storage';
const DELETE = process.argv.includes('--delete');
const UUID = '[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}';
const DOC_KEY = new RegExp(`^${UUID}/${UUID}/${UUID}(\\.[A-Za-z0-9]+)?$`, 'i');
async function main() {
const db = getDb();
console.log('Listing bucket contents...');
const bucketKeys = await listAllKeys();
console.log(` ${bucketKeys.length} object(s) in bucket.`);
const rows = await db.select({ storageKey: documents.storageKey }).from(documents);
const dbKeys = new Set(rows.map((r) => r.storageKey));
console.log(` ${dbKeys.size} document row(s) in database.`);
const orphans: string[] = [];
const unrecognized: string[] = [];
for (const key of bucketKeys) {
if (dbKeys.has(key)) continue;
if (DOC_KEY.test(key)) orphans.push(key);
else unrecognized.push(key);
}
// Reverse check: DB rows whose file is missing from the bucket (report only).
const bucketSet = new Set(bucketKeys);
const missing = [...dbKeys].filter((k) => !bucketSet.has(k));
console.log(`\nOrphaned objects (in bucket, no DB row): ${orphans.length}`);
for (const k of orphans) console.log(` ${k}`);
if (unrecognized.length) {
console.log(`\nUnrecognized keys (not document-shaped — never deleted): ${unrecognized.length}`);
for (const k of unrecognized) console.log(` ${k}`);
}
if (missing.length) {
console.log(`\nWARNING — DB rows whose file is MISSING from the bucket: ${missing.length}`);
for (const k of missing) console.log(` ${k}`);
}
if (!DELETE) {
console.log(`\nDry run — nothing deleted. Re-run with --delete to remove the ${orphans.length} orphan(s).`);
} else {
let deleted = 0;
for (const key of orphans) {
await deleteFile(key);
deleted++;
}
console.log(`\nDeleted ${deleted} orphaned object(s).`);
}
await getPool().end();
}
main().catch((err) => {
console.error(err);
process.exit(1);
});