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
+7 -3
View File
@@ -8,9 +8,13 @@
"dev": "tsx watch src/server.ts",
"build": "tsc -p tsconfig.json --noEmit",
"start": "tsx src/server.ts",
"test": "vitest run",
"test:watch": "vitest",
"typecheck": "tsc -p tsconfig.json --noEmit"
},
"dependencies": {
"@aws-sdk/client-s3": "^3.1088.0",
"@aws-sdk/s3-request-presigner": "^3.1088.0",
"@fastify/cookie": "^11.0.1",
"@fastify/cors": "^10.0.1",
"@fastify/helmet": "^12.0.1",
@@ -25,10 +29,9 @@
"fastify": "^5.1.0",
"fastify-plugin": "^5.0.1",
"fastify-type-provider-zod": "^4.0.2",
"pg": "^8.13.1",
"pdfkit": "^0.15.0",
"pg": "^8.13.1",
"pino": "^9.5.0",
"resend": "^4.0.1",
"stripe": "^17.4.0",
"tsx": "^4.19.2",
"zod": "^3.23.8"
@@ -38,6 +41,7 @@
"@types/pdfkit": "^0.13.5",
"@types/pg": "^8.11.10",
"pino-pretty": "^11.3.0",
"typescript": "^5.6.3"
"typescript": "^5.6.3",
"vitest": "^3.2.7"
}
}
+43 -9
View File
@@ -10,12 +10,42 @@ const TOKEN_BYTES = 32;
const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']);
// Routes that legitimately bypass CSRF they receive their own auth (signature check)
// or have no session yet, so a CSRF attack against them is meaningless.
const CSRF_EXEMPT_PREFIXES = ['/api/auth/', '/api/contact', '/api/webhooks/', '/api/tool-usage'];
// Exact routes that legitimately bypass CSRF: they run pre-session (login/signup/reset) or carry
// their own authentication (Stripe signature), so a CSRF attack against them is meaningless.
// Exact-match only — no prefix matching, so nothing new is silently exempted, and authenticated
// state-changing routes like /api/auth/logout are NOT exempt (the browser client sends the token).
const CSRF_EXEMPT_PATHS = new Set([
'/api/auth/login',
'/api/auth/signup',
'/api/auth/request-password-reset',
'/api/auth/reset-password',
'/api/contact',
'/api/tool-usage',
'/api/webhooks/stripe',
]);
// CSRF tokens are HMAC-signed with CSRF_SECRET: `${random}.${sig}`. Signing means a token can't be
// forged by a party that doesn't hold the secret, so an attacker on a sibling/compromised subdomain
// cannot plant a self-consistent cookie+header pair (the classic weakness of naive double-submit).
function signCsrf(value: string): string {
return crypto.createHmac('sha256', env.CSRF_SECRET).update(value).digest('base64url');
}
export function generateCsrfToken(): string {
return crypto.randomBytes(TOKEN_BYTES).toString('base64url');
const random = crypto.randomBytes(TOKEN_BYTES).toString('base64url');
return `${random}.${signCsrf(random)}`;
}
function isValidCsrfToken(token: string): boolean {
const dot = token.lastIndexOf('.');
if (dot <= 0) return false;
const random = token.slice(0, dot);
const sig = token.slice(dot + 1);
const expected = signCsrf(random);
const a = Buffer.from(sig);
const b = Buffer.from(expected);
if (a.length !== b.length) return false;
return crypto.timingSafeEqual(a, b);
}
function constantTimeEqual(a: string, b: string): boolean {
@@ -52,11 +82,13 @@ async function plugin(app: FastifyInstance) {
});
});
// Auto-mint a CSRF token whenever an authenticated session exists but no CSRF cookie is set.
// This makes the protection self-bootstrapping after sessions created before CSRF was enabled.
// Auto-mint a CSRF token whenever an authenticated session exists but no valid CSRF cookie is
// set. Self-bootstrapping for sessions created before CSRF existed, and self-healing: a stale or
// unsigned cookie (fails signature) is replaced with a fresh signed one instead of wedging.
app.addHook('onRequest', async (req, reply) => {
if (!req.cookies?.[SESSION_COOKIE]) return;
if (req.cookies?.[CSRF_COOKIE]) return;
const existing = req.cookies?.[CSRF_COOKIE];
if (existing && isValidCsrfToken(existing)) return;
const token = generateCsrfToken();
app.setCsrfCookie(reply, token);
req.cookies = { ...req.cookies, [CSRF_COOKIE]: token };
@@ -67,11 +99,13 @@ async function plugin(app: FastifyInstance) {
if (SAFE_METHODS.has(req.method)) return;
if (!req.cookies?.[SESSION_COOKIE]) return; // unauthenticated → nothing to protect
const url = req.routeOptions.url || req.url;
if (CSRF_EXEMPT_PREFIXES.some((p) => url.startsWith(p))) return;
if (CSRF_EXEMPT_PATHS.has(url)) return;
const cookie = req.cookies?.[CSRF_COOKIE];
const header = (req.headers[CSRF_HEADER] as string | undefined) ?? '';
if (!cookie || !header || !constantTimeEqual(cookie, header)) {
// Require: cookie and header present, they match (double-submit), and the token carries a
// valid signature (proves it was minted by this server, not planted by another origin).
if (!cookie || !header || !constantTimeEqual(cookie, header) || !isValidCsrfToken(cookie)) {
return reply.code(403).send({ error: 'csrf_failed' });
}
});
+1
View File
@@ -37,6 +37,7 @@ async function plugin(app: FastifyInstance) {
session.user.id,
session.user.email,
session.user.isSuperadmin,
session.user.emailVerifiedAt,
);
req.user = {
+28 -7
View File
@@ -6,11 +6,32 @@ export function isSuperadminEmail(email: string): boolean {
return env.superadminEmails.includes(email.toLowerCase());
}
// Promote any user whose email is on the SUPERADMIN_EMAILS list. Idempotent.
// Called on signup/login so the assignment happens automatically as soon as the user shows up.
export async function ensureSuperadminFlag(userId: string, email: string, currentFlag: boolean) {
const shouldBe = isSuperadminEmail(email);
if (shouldBe === currentFlag) return shouldBe;
await getDb().update(users).set({ isSuperadmin: shouldBe, updatedAt: new Date() }).where(eq(users.id, userId));
return shouldBe;
// Reconcile a user's superadmin flag against the SUPERADMIN_EMAILS allowlist. Idempotent.
// Called on signup/login/every request.
//
// Security: promotion (false -> true) requires a VERIFIED email. Public signup never sets
// emailVerifiedAt, so an attacker who registers a listed address before its owner does NOT
// silently become superadmin. Legitimate superadmins are provisioned via scripts/create-admin.ts
// (which sets isSuperadmin + emailVerifiedAt directly) or on an already-verified account.
// Demotion (list removal) still happens immediately, regardless of verification.
export async function ensureSuperadminFlag(
userId: string,
email: string,
currentFlag: boolean,
emailVerifiedAt: Date | null,
) {
const onList = isSuperadminEmail(email);
if (!onList) {
if (currentFlag) {
await getDb().update(users).set({ isSuperadmin: false, updatedAt: new Date() }).where(eq(users.id, userId));
}
return false;
}
if (currentFlag) return true; // already a superadmin — keep it
if (!emailVerifiedAt) return false; // on the list but unverified — do NOT auto-promote
await getDb().update(users).set({ isSuperadmin: true, updatedAt: new Date() }).where(eq(users.id, userId));
return true;
}
+11 -2
View File
@@ -19,8 +19,17 @@ const envSchema = z.object({
WEB_DIST_PATH: z.string().optional(),
SUPERADMIN_EMAILS: z.string().optional().default(''),
SENTRY_DSN_API: z.string().optional().default(''),
STORAGE_PATH: z.string().min(1).default('./storage'),
RESEND_API_KEY: z.string().optional().default(''),
// Object storage — DigitalOcean Spaces (S3-compatible), the platform's sole storage backend.
// Required: the API must not boot into a state where uploads silently have nowhere to go.
SPACES_ENDPOINT: z.string().url(),
SPACES_REGION: z.string().min(1),
SPACES_BUCKET: z.string().min(1),
SPACES_KEY: z.string().min(1),
SPACES_SECRET: z.string().min(1),
SPACES_PUBLIC_BASE: z.string().optional().default(''),
// Legacy local path — read only by the one-time migration script, not the running app.
STORAGE_PATH: z.string().optional().default('./storage'),
SMTP2GO_API_KEY: z.string().optional().default(''),
EMAIL_FROM: z.string().optional().default('eLegal Software <noreply@elegalsoftware.com>'),
STRIPE_SECRET_KEY: z.string().optional().default(''),
STRIPE_WEBHOOK_SECRET: z.string().optional().default(''),
+374 -69
View File
@@ -1,13 +1,7 @@
import { Resend } from 'resend';
import { env } from '../env';
let _resend: Resend | null = null;
function getResend(): Resend | null {
if (!env.RESEND_API_KEY) return null;
if (!_resend) _resend = new Resend(env.RESEND_API_KEY);
return _resend;
}
// SMTP2GO HTTP API — https://apidoc.smtp2go.com (POST /email/send)
const SMTP2GO_SEND_URL = 'https://api.smtp2go.com/v3/email/send';
export interface EmailOptions {
to: string;
@@ -25,100 +19,338 @@ export interface SendResult {
error?: string;
}
const MIME_TYPES: Record<string, string> = {
pdf: 'application/pdf',
png: 'image/png',
jpg: 'image/jpeg',
jpeg: 'image/jpeg',
csv: 'text/csv',
txt: 'text/plain',
html: 'text/html',
zip: 'application/zip',
};
function mimeTypeFor(filename: string): string {
const ext = filename.split('.').pop()?.toLowerCase() ?? '';
return MIME_TYPES[ext] ?? 'application/octet-stream';
}
interface Smtp2goResponse {
data?: {
succeeded?: number;
email_id?: string;
error?: string;
failures?: string[];
};
}
export async function sendEmail(opts: EmailOptions): Promise<SendResult> {
const resend = getResend();
if (!resend) {
// Logged but not sent — useful in dev when RESEND_API_KEY isn't set.
if (!env.SMTP2GO_API_KEY) {
// Logged but not sent — useful in dev when SMTP2GO_API_KEY isn't set.
console.log(`[email skipped] to=${opts.to} subject="${opts.subject}"`);
return { ok: true, skipped: true };
}
try {
const res = await resend.emails.send({
from: env.EMAIL_FROM,
to: opts.to,
subject: opts.subject,
html: opts.html,
text: opts.text,
replyTo: opts.replyTo,
attachments: opts.attachments?.map((a) => ({
filename: a.filename,
content: typeof a.content === 'string' ? a.content : a.content.toString('base64'),
})),
const res = await fetch(SMTP2GO_SEND_URL, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Smtp2go-Api-Key': env.SMTP2GO_API_KEY,
},
body: JSON.stringify({
sender: env.EMAIL_FROM,
to: [opts.to],
subject: opts.subject,
html_body: opts.html,
text_body: opts.text,
...(opts.replyTo ? { custom_headers: [{ header: 'Reply-To', value: opts.replyTo }] } : {}),
...(opts.attachments?.length
? {
attachments: opts.attachments.map((a) => ({
filename: a.filename,
fileblob:
typeof a.content === 'string'
? Buffer.from(a.content).toString('base64')
: a.content.toString('base64'),
mimetype: mimeTypeFor(a.filename),
})),
}
: {}),
}),
});
if (res.error) return { ok: false, error: res.error.message };
return { ok: true, id: res.data?.id };
const json = (await res.json().catch(() => null)) as Smtp2goResponse | null;
if (!res.ok || !json?.data || json.data.error || !json.data.succeeded) {
const error = json?.data?.error ?? json?.data?.failures?.join('; ') ?? `HTTP ${res.status}`;
return { ok: false, error };
}
return { ok: true, id: json.data.email_id };
} catch (err) {
return { ok: false, error: (err as Error).message };
}
}
// ─────────────────────────── Templates ───────────────────────────
// Kept simple. Brand-blue header bar + readable body. Plain-text version always provided
// since some clients (and good practice) require it.
// Light theme: soft gray-blue canvas, white card, brand-blue accents, logo above the card.
// Table-based layout so Outlook/Gmail render consistently. Every template returns a
// { subject, html, text } pair — the plain-text alternative is always provided.
const BRAND = '#0052FF';
const INK = '#1f2430';
const SOFT = '#697281';
const FAINT = '#8a93a3';
const CANVAS = '#f4f6fa';
const BORDER = '#e6eaf2';
const PANEL = '#f5f7fb';
function shell(bodyHtml: string): string {
const APP_URL = env.PUBLIC_URL.replace(/\/+$/, '');
const LOGO_URL = `${APP_URL}/logo-dark.png`;
/** Escape user-provided strings before interpolating into HTML bodies. */
function esc(s: string): string {
return s
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
function firstName(name: string | null | undefined): string {
const first = name?.trim().split(/\s+/)[0];
return first ? esc(first) : 'there';
}
// ── Building blocks ──
function heading(text: string): string {
return `<h1 style="margin:0 0 14px;font-size:21px;line-height:1.3;font-weight:700;color:${INK};letter-spacing:-0.01em;">${text}</h1>`;
}
function p(html: string): string {
return `<p style="margin:0 0 16px;font-size:15px;line-height:1.6;color:${INK};">${html}</p>`;
}
function muted(html: string): string {
return `<p style="margin:0 0 14px;font-size:13px;line-height:1.55;color:${SOFT};">${html}</p>`;
}
function btn(url: string, label: string): string {
return `<table role="presentation" cellpadding="0" cellspacing="0" style="margin:6px 0 22px;"><tr>
<td style="border-radius:12px;background:${BRAND};">
<a href="${url}" style="display:inline-block;padding:13px 26px;font-size:15px;font-weight:600;color:#ffffff;text-decoration:none;border-radius:12px;">${label}</a>
</td>
</tr></table>`;
}
function linkFallback(url: string): string {
return `<p style="margin:0 0 14px;font-size:12px;line-height:1.5;color:${FAINT};word-break:break-all;">If the button doesn't work, paste this link into your browser:<br><a href="${url}" style="color:${BRAND};">${url}</a></p>`;
}
function panel(html: string): string {
return `<div style="background:${PANEL};border:1px solid ${BORDER};border-radius:12px;padding:16px 18px;margin:0 0 16px;">${html}</div>`;
}
/** Big invoice/receipt figure with a label above it. */
function amountBlock(label: string, amount: string, sub?: string): string {
return panel(
`<p style="margin:0 0 4px;font-size:12px;font-weight:600;letter-spacing:0.04em;text-transform:uppercase;color:${SOFT};">${label}</p>
<p style="margin:0;font-size:26px;font-weight:700;color:${INK};letter-spacing:-0.01em;">${amount}</p>
${sub ? `<p style="margin:6px 0 0;font-size:13px;color:${SOFT};">${sub}</p>` : ''}`,
);
}
function signoff(): string {
return p('— The eLegal Software team');
}
function shell(bodyHtml: string, preheader = ''): string {
const pre = preheader
? `<div style="display:none;max-height:0;overflow:hidden;mso-hide:all;">${preheader}&nbsp;&#8204;&nbsp;&#8204;&nbsp;&#8204;&nbsp;&#8204;&nbsp;&#8204;&nbsp;&#8204;</div>`
: '';
return `<!doctype html>
<html><head><meta charset="utf-8"><title>eLegal Software</title></head>
<body style="margin:0;padding:0;background:#f6f7f9;font-family:-apple-system,Segoe UI,Roboto,Helvetica,Arial,sans-serif;color:#23272e;">
<div style="max-width:560px;margin:32px auto;background:#fff;border-radius:16px;overflow:hidden;border:1px solid #eceef2;">
<div style="background:${BRAND};padding:18px 24px;color:#fff;font-weight:700;letter-spacing:-0.01em;font-size:18px;">eLegal Software</div>
<div style="padding:28px 24px;line-height:1.55;font-size:15px;">${bodyHtml}</div>
<div style="border-top:1px solid #eceef2;padding:14px 24px;color:#7c8595;font-size:12px;">© ${new Date().getFullYear()} eLegal Software. You're receiving this because of activity on your account.</div>
</div>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<meta name="color-scheme" content="light">
<meta name="supported-color-schemes" content="light">
<title>eLegal Software</title>
</head>
<body style="margin:0;padding:0;background:${CANVAS};font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif;">
${pre}
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background:${CANVAS};">
<tr><td align="center" style="padding:36px 16px;">
<table role="presentation" width="560" cellpadding="0" cellspacing="0" style="width:560px;max-width:100%;">
<tr><td align="left" style="padding:0 8px 18px;">
<a href="${APP_URL}" style="text-decoration:none;"><img src="${LOGO_URL}" alt="eLegal Software" width="200" height="20" style="display:block;border:0;height:20px;width:auto;"></a>
</td></tr>
<tr><td style="background:#ffffff;border:1px solid ${BORDER};border-radius:16px;padding:32px;">
${bodyHtml}
</td></tr>
<tr><td align="center" style="padding:22px 8px 0;">
<p style="margin:0;font-size:12px;line-height:1.7;color:${FAINT};">© ${new Date().getFullYear()} eLegal Software · Practice management for solo attorneys &amp; small firms<br>You're receiving this because of activity related to an eLegal Software account.</p>
</td></tr>
</table>
</td></tr>
</table>
</body></html>`;
}
// ── Account lifecycle ──
export function welcomeEmail(toName: string | null, verifyUrl: string | null) {
const name = toName?.split(' ')[0] ?? 'there';
const name = firstName(toName);
const verifyBlock = verifyUrl
? `<p>Please confirm your email address so we can send you important updates:</p>
<p><a href="${verifyUrl}" style="display:inline-block;background:${BRAND};color:#fff;padding:12px 20px;border-radius:10px;text-decoration:none;font-weight:600;">Verify my email</a></p>
<p style="color:#5b6473;font-size:13px;">Or paste this link into your browser: ${verifyUrl}</p>`
? `${p('First, please confirm your email address so we can send you important account updates:')}
${btn(verifyUrl, 'Verify my email')}
${linkFallback(verifyUrl)}`
: '';
return {
subject: 'Welcome to eLegal Software',
html: shell(
`<p>Hi ${name},</p>
<p>Welcome to eLegal Software. Your account is set up and you're ready to add your first client and case.</p>
`${heading(`Welcome aboard, ${name} 👋`)}
${p("Your account is set up and you're ready to add your first client and case.")}
${verifyBlock}
<p>If you have questions, just reply to this email — a real person will see it.</p>
<p>— The eLegal Software team</p>`,
${panel(
`<p style="margin:0 0 8px;font-size:14px;font-weight:600;color:${INK};">Get started in three steps</p>
<p style="margin:0;font-size:14px;line-height:1.8;color:${SOFT};">1. Add a client &nbsp;→&nbsp; 2. Open a case &nbsp;→&nbsp; 3. Track time &amp; send your first invoice</p>`,
)}
${p('If you have questions, just reply to this email — a real person will see it.')}
${signoff()}`,
'Your account is ready — add your first client and case.',
),
text: `Hi ${name},\n\nWelcome to eLegal Software. Your account is set up and you're ready to add your first client and case.\n\n${verifyUrl ? `Please confirm your email: ${verifyUrl}\n\n` : ''}If you have questions, just reply to this email.\n\n— The eLegal Software team`,
text: `Hi ${name},\n\nWelcome to eLegal Software. Your account is set up and you're ready to add your first client and case.\n\n${verifyUrl ? `Please confirm your email: ${verifyUrl}\n\n` : ''}Get started: 1. Add a client → 2. Open a case → 3. Track time & send your first invoice.\n\nIf you have questions, just reply to this email.\n\n— The eLegal Software team`,
};
}
export function verifyEmailEmail(toName: string | null, verifyUrl: string) {
const name = firstName(toName);
return {
subject: 'Confirm your email address',
html: shell(
`${heading('Confirm your email')}
${p(`Hi ${name},`)}
${p('Please confirm the email address on your eLegal Software account:')}
${btn(verifyUrl, 'Verify my email')}
${linkFallback(verifyUrl)}
${muted("This link expires in 24 hours. If you didn't create an eLegal Software account, you can safely ignore this email.")}`,
'One click to confirm the email on your account.',
),
text: `Hi ${name},\n\nPlease confirm the email address on your eLegal Software account:\n\n${verifyUrl}\n\nThis link expires in 24 hours. If you didn't create an account, ignore this email.`,
};
}
export function passwordResetEmail(toName: string | null, resetUrl: string) {
const name = toName?.split(' ')[0] ?? 'there';
const name = firstName(toName);
return {
subject: 'Reset your eLegal Software password',
html: shell(
`<p>Hi ${name},</p>
<p>We got a request to reset the password on your eLegal Software account. Click below to choose a new one:</p>
<p><a href="${resetUrl}" style="display:inline-block;background:${BRAND};color:#fff;padding:12px 20px;border-radius:10px;text-decoration:none;font-weight:600;">Reset password</a></p>
<p style="color:#5b6473;font-size:13px;">Or paste this link into your browser: ${resetUrl}</p>
<p style="color:#5b6473;font-size:13px;">This link expires in 1 hour. If you didn't request a reset, you can safely ignore this email.</p>`,
`${heading('Reset your password')}
${p(`Hi ${name},`)}
${p('We got a request to reset the password on your eLegal Software account. Click below to choose a new one:')}
${btn(resetUrl, 'Reset password')}
${linkFallback(resetUrl)}
${muted("This link expires in 1 hour. If you didn't request a reset, you can safely ignore this email — your password won't change.")}`,
'Choose a new password for your account.',
),
text: `Hi ${name},\n\nWe got a request to reset your eLegal Software password.\n\nReset it here: ${resetUrl}\n\nThis link expires in 1 hour. If you didn't request a reset, ignore this email.`,
};
}
export function passwordChangedEmail(toName: string | null) {
const name = firstName(toName);
const secureUrl = `${APP_URL}/forgot-password`;
return {
subject: 'Your eLegal Software password was changed',
html: shell(
`${heading('Your password was changed')}
${p(`Hi ${name},`)}
${p('The password on your eLegal Software account was just changed, and all other sessions were signed out. If this was you, no further action is needed.')}
${panel(
`<p style="margin:0;font-size:14px;line-height:1.6;color:${INK};"><strong>Didn't do this?</strong> Someone else may have access to your account. Reset your password immediately and reply to this email so we can help.</p>`,
)}
${btn(secureUrl, 'Secure my account')}`,
'The password on your account was just changed.',
),
text: `Hi ${name},\n\nThe password on your eLegal Software account was just changed, and all other sessions were signed out. If this was you, no further action is needed.\n\nIf you DIDN'T do this, reset your password immediately: ${secureUrl}\nThen reply to this email so we can help.`,
};
}
export function accountDeletedEmail(toName: string | null) {
const name = firstName(toName);
return {
subject: 'Your eLegal Software account has been deleted',
html: shell(
`${heading('Account deleted')}
${p(`Hi ${name},`)}
${p('As requested, your eLegal Software account and all associated firm data — clients, cases, time entries, documents, and invoices — have been permanently deleted. This cannot be undone.')}
${p("We're sorry to see you go. You're welcome back anytime — creating a new account takes less than a minute.")}
${muted("If you didn't request this deletion, reply to this email immediately.")}
${signoff()}`,
'Your account and all firm data were permanently deleted.',
),
text: `Hi ${name},\n\nAs requested, your eLegal Software account and all associated firm data — clients, cases, time entries, documents, and invoices — have been permanently deleted. This cannot be undone.\n\nWe're sorry to see you go. You're welcome back anytime.\n\nIf you didn't request this deletion, reply to this email immediately.\n\n— The eLegal Software team`,
};
}
// ── Billing ──
export function planUpgradedEmail(toName: string | null, plan: string) {
const name = toName?.split(' ')[0] ?? 'there';
const name = firstName(toName);
return {
subject: `You're on eLegal Software ${plan}`,
html: shell(
`<p>Hi ${name},</p>
<p>Thanks for upgrading. Your firm is now on the <strong>${plan}</strong> plan and the limits and watermarks have been lifted.</p>
<p><a href="${process.env.PUBLIC_URL ?? 'https://app.elegalsoftware.com'}/app" style="display:inline-block;background:${BRAND};color:#fff;padding:12px 20px;border-radius:10px;text-decoration:none;font-weight:600;">Open eLegal Software</a></p>
<p>Manage your subscription anytime from Settings → Billing.</p>`,
`${heading(`Welcome to ${esc(plan)} 🎉`)}
${p(`Hi ${name},`)}
${p(`Thanks for upgrading. Your firm is now on the <strong>${esc(plan)}</strong> plan — plan limits and invoice watermarks have been lifted.`)}
${btn(`${APP_URL}/app`, 'Open eLegal Software')}
${muted('Manage your subscription anytime from Settings → Billing.')}`,
`Your firm is now on the ${plan} plan.`,
),
text: `Hi ${name},\n\nThanks for upgrading. Your firm is now on the ${plan} plan and the limits and watermarks have been lifted.\n\nManage your subscription from Settings → Billing.`,
text: `Hi ${name},\n\nThanks for upgrading. Your firm is now on the ${plan} plan — plan limits and invoice watermarks have been lifted.\n\nManage your subscription from Settings → Billing.`,
};
}
export function paymentFailedEmail(toName: string | null, amount?: string | null) {
const name = firstName(toName);
const billingUrl = `${APP_URL}/app/settings`;
return {
subject: 'Payment failed — action needed',
html: shell(
`${heading('We couldnt process your payment')}
${p(`Hi ${name},`)}
${p(`Your latest payment${amount ? ` of <strong>${esc(amount)}</strong>` : ''} for eLegal Software Pro didn't go through. This is usually an expired card or a bank decline.`)}
${p("We'll retry automatically over the next few days — to keep your Pro features active, please update your payment method:")}
${btn(billingUrl, 'Update payment method')}
${muted('If payments keep failing, your firm will be moved to the free Starter plan. Your data is never deleted.')}`,
'Your subscription payment didnt go through — update your card to keep Pro active.',
),
text: `Hi ${name},\n\nYour latest payment${amount ? ` of ${amount}` : ''} for eLegal Software Pro didn't go through. This is usually an expired card or a bank decline.\n\nWe'll retry automatically over the next few days. To keep Pro active, update your payment method: ${billingUrl}\n\nIf payments keep failing, your firm will be moved to the free Starter plan. Your data is never deleted.`,
};
}
export function subscriptionEndedEmail(toName: string | null) {
const name = firstName(toName);
const billingUrl = `${APP_URL}/app/settings`;
return {
subject: 'Your Pro subscription has ended',
html: shell(
`${heading('Your Pro subscription has ended')}
${p(`Hi ${name},`)}
${p('Your eLegal Software Pro subscription has ended and your firm was moved to the free <strong>Starter</strong> plan.')}
${panel(
`<p style="margin:0;font-size:14px;line-height:1.7;color:${SOFT};">What changes on Starter: plan limits apply again and invoices include a watermark. <strong style="color:${INK};">All your data — clients, cases, documents, invoices — is untouched.</strong></p>`,
)}
${p('You can reactivate Pro anytime:')}
${btn(billingUrl, 'Reactivate Pro')}
${signoff()}`,
'Your firm was moved to the free Starter plan — your data is untouched.',
),
text: `Hi ${name},\n\nYour eLegal Software Pro subscription has ended and your firm was moved to the free Starter plan.\n\nWhat changes: plan limits apply again and invoices include a watermark. All your data is untouched.\n\nReactivate Pro anytime: ${billingUrl}\n\n— The eLegal Software team`,
};
}
// ── Client-facing invoices (sent on behalf of a firm) ──
export function invoiceEmail(opts: {
clientName: string;
firmName: string;
@@ -127,33 +359,106 @@ export function invoiceEmail(opts: {
dueDate?: string | null;
notes?: string | null;
}) {
const dueLine = opts.dueDate ? `<p>Due on <strong>${opts.dueDate}</strong>.</p>` : '';
const notesLine = opts.notes
? `<p style="background:#f6f7f9;border-radius:10px;padding:12px;color:#5b6473;font-size:13px;">${opts.notes}</p>`
const firm = esc(opts.firmName);
const num = esc(opts.invoiceNumber);
const notesBlock = opts.notes
? panel(`<p style="margin:0;font-size:13px;line-height:1.6;color:${SOFT};">${esc(opts.notes)}</p>`)
: '';
return {
subject: `Invoice ${opts.invoiceNumber} from ${opts.firmName}`,
html: shell(
`<p>Hi ${opts.clientName.split(' ')[0]},</p>
<p>${opts.firmName} sent you a new invoice.</p>
<p style="font-size:18px;"><strong>${opts.invoiceNumber}</strong> — <strong>${opts.total}</strong></p>
${dueLine}
${notesLine}
<p>The PDF is attached. Reply to this email if you have any questions.</p>`,
`${heading(`New invoice from ${firm}`)}
${p(`Hi ${firstName(opts.clientName)},`)}
${p(`${firm} sent you a new invoice. The PDF is attached to this email.`)}
${amountBlock(`Invoice ${num}`, esc(opts.total), opts.dueDate ? `Due ${esc(opts.dueDate)}` : undefined)}
${notesBlock}
${p('Reply to this email if you have any questions about this invoice.')}`,
`Invoice ${opts.invoiceNumber}${opts.total}${opts.dueDate ? `, due ${opts.dueDate}` : ''}.`,
),
text: `Hi ${opts.clientName},\n\n${opts.firmName} sent you a new invoice: ${opts.invoiceNumber}${opts.total}.${opts.dueDate ? ` Due on ${opts.dueDate}.` : ''}\n\nThe PDF is attached.${opts.notes ? `\n\nNotes: ${opts.notes}` : ''}`,
text: `Hi ${opts.clientName},\n\n${opts.firmName} sent you a new invoice: ${opts.invoiceNumber}${opts.total}.${opts.dueDate ? ` Due on ${opts.dueDate}.` : ''}\n\nThe PDF is attached.${opts.notes ? `\n\nNotes: ${opts.notes}` : ''}\n\nReply to this email if you have any questions.`,
};
}
export function invoicePaidEmail(opts: {
clientName: string;
firmName: string;
invoiceNumber: string;
total: string;
}) {
const firm = esc(opts.firmName);
const num = esc(opts.invoiceNumber);
return {
subject: `Payment received — invoice ${opts.invoiceNumber} from ${opts.firmName}`,
html: shell(
`${heading('Payment received — thank you')}
${p(`Hi ${firstName(opts.clientName)},`)}
${p(`${firm} has recorded your payment for invoice <strong>${num}</strong>. You're all settled up.`)}
${amountBlock('Amount paid', esc(opts.total), `Invoice ${num} · Paid in full`)}
${muted('Keep this email for your records. Reply if anything looks off.')}`,
`Your payment for invoice ${opts.invoiceNumber} was received.`,
),
text: `Hi ${opts.clientName},\n\n${opts.firmName} has recorded your payment for invoice ${opts.invoiceNumber}${opts.total}. You're all settled up.\n\nKeep this email for your records. Reply if anything looks off.`,
};
}
export function invoiceOverdueEmail(opts: {
clientName: string;
firmName: string;
invoiceNumber: string;
total: string;
dueDate: string;
}) {
const firm = esc(opts.firmName);
const num = esc(opts.invoiceNumber);
return {
subject: `Reminder: invoice ${opts.invoiceNumber} from ${opts.firmName} is past due`,
html: shell(
`${heading('Friendly payment reminder')}
${p(`Hi ${firstName(opts.clientName)},`)}
${p(`This is a friendly reminder that invoice <strong>${num}</strong> from ${firm} was due on <strong>${esc(opts.dueDate)}</strong> and is still outstanding.`)}
${amountBlock('Amount due', esc(opts.total), `Invoice ${num} · Due ${esc(opts.dueDate)}`)}
${p('If youve already sent payment, please disregard this notice — and thank you. Otherwise, reply to this email to arrange payment or ask any questions.')}`,
`Invoice ${opts.invoiceNumber} (${opts.total}) was due ${opts.dueDate}.`,
),
text: `Hi ${opts.clientName},\n\nThis is a friendly reminder that invoice ${opts.invoiceNumber} from ${opts.firmName} was due on ${opts.dueDate} and is still outstanding.\n\nAmount due: ${opts.total}\n\nIf you've already sent payment, please disregard this notice. Otherwise, reply to this email to arrange payment or ask any questions.`,
};
}
// ── Contact form ──
export function contactAckEmail(toName: string) {
const name = toName.split(' ')[0];
const name = firstName(toName);
return {
subject: "Got your message — we'll be in touch",
html: shell(
`<p>Hi ${name},</p>
<p>Thanks for reaching out to eLegal Software. We've received your message and one of us will reply within one business day.</p>
<p>— The eLegal Software team</p>`,
`${heading('We got your message')}
${p(`Hi ${name},`)}
${p("Thanks for reaching out to eLegal Software. We've received your message and one of us will reply within one business day.")}
${signoff()}`,
'Thanks for reaching out — we reply within one business day.',
),
text: `Hi ${name},\n\nThanks for reaching out to eLegal Software. We've received your message and one of us will reply within one business day.\n\n— The eLegal Software team`,
};
}
/** Internal notification to the team when the public contact form is submitted. */
export function contactNotifyEmail(opts: {
fromName: string;
fromEmail: string;
message: string;
ip?: string | null;
}) {
const messageHtml = esc(opts.message).replace(/\n/g, '<br>');
return {
subject: `New contact message from ${opts.fromName}`,
html: shell(
`${heading('New contact form message')}
${panel(`<p style="margin:0;font-size:14px;line-height:1.7;color:${INK};white-space:pre-wrap;">${messageHtml}</p>`)}
${muted(`From: <strong>${esc(opts.fromName)}</strong> &lt;${esc(opts.fromEmail)}&gt;${opts.ip ? ` · IP ${esc(opts.ip)}` : ''}`)}
${p('Reply directly to this email to answer them.')}
${muted(`Also visible in the <a href="${APP_URL}/admin/contact" style="color:${BRAND};">admin panel</a>.`)}`,
`${opts.fromName}: ${opts.message.slice(0, 90)}`,
),
text: `New contact form message\n\nFrom: ${opts.fromName} <${opts.fromEmail}>${opts.ip ? `\nIP: ${opts.ip}` : ''}\n\n${opts.message}\n\nReply directly to this email to answer them. Also visible at ${APP_URL}/admin/contact`,
};
}
+55
View File
@@ -0,0 +1,55 @@
// Magic-byte validation for uploads. The upload route already checks the client-supplied
// Content-Type, but that header is attacker-controlled — a malicious file can claim to be a PDF.
// This verifies the actual leading bytes match the declared type, so a spoofed MIME (e.g. an
// HTML page or executable labelled image/png) is rejected before it's stored and later served
// back with that Content-Type.
function startsWith(buf: Buffer, sig: number[]): boolean {
if (buf.length < sig.length) return false;
for (let i = 0; i < sig.length; i++) {
if (buf[i] !== sig[i]) return false;
}
return true;
}
// OOXML (docx/xlsx) and other Office-2007+ files are ZIP containers.
function isZip(buf: Buffer): boolean {
return (
startsWith(buf, [0x50, 0x4b, 0x03, 0x04]) || // normal
startsWith(buf, [0x50, 0x4b, 0x05, 0x06]) || // empty archive
startsWith(buf, [0x50, 0x4b, 0x07, 0x08]) // spanned
);
}
// Legacy Office (.doc/.xls) uses the OLE2 compound-file header.
const OLE2 = [0xd0, 0xcf, 0x11, 0xe0, 0xa1, 0xb1, 0x1a, 0xe1];
// text/plain has no signature. Accept only if the content is plausibly text: no NUL bytes in the
// first few KB (NUL is the classic marker of a binary/executable masquerading as text).
function looksLikeText(buf: Buffer): boolean {
const n = Math.min(buf.length, 4096);
for (let i = 0; i < n; i++) {
if (buf[i] === 0) return false;
}
return true;
}
const CHECKS: Record<string, (buf: Buffer) => boolean> = {
'application/pdf': (b) => startsWith(b, [0x25, 0x50, 0x44, 0x46]), // %PDF
'image/png': (b) => startsWith(b, [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
'image/jpeg': (b) => startsWith(b, [0xff, 0xd8, 0xff]),
'image/webp': (b) =>
startsWith(b, [0x52, 0x49, 0x46, 0x46]) && b.length >= 12 && b.subarray(8, 12).toString('latin1') === 'WEBP',
'application/msword': (b) => startsWith(b, OLE2),
'application/vnd.ms-excel': (b) => startsWith(b, OLE2),
'application/vnd.openxmlformats-officedocument.wordprocessingml.document': isZip,
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': isZip,
'text/plain': looksLikeText,
};
// Returns true only if the bytes are consistent with the declared MIME type.
export function verifyFileSignature(buf: Buffer, declaredMime: string): boolean {
const check = CHECKS[declaredMime];
if (!check) return false; // unknown declared type → reject (upload route already allowlists)
return check(buf);
}
+20 -10
View File
@@ -1,20 +1,30 @@
import { sql } from 'drizzle-orm';
import { eq, and, like } from 'drizzle-orm';
import { and, eq, like, sql } from 'drizzle-orm';
import { getDb, invoices } from '@lawdesk/db';
// Format: INV-YYYY-NNNN, scoped per firm.
// Uses a count-based sequence — the unique-on-(firm_id, number) constraint isn't enforced
// at the DB level yet, so two near-simultaneous creates could collide. For a v1 single-user
// firm this is fine; if it becomes a problem, add a per-firm Postgres sequence.
export async function nextInvoiceNumber(firmId: string): Promise<string> {
// The transaction handle passed by db.transaction(async (tx) => ...).
type Tx = Parameters<Parameters<ReturnType<typeof getDb>['transaction']>[0]>[0];
// Format: INV-YYYY-NNNN, scoped per firm. Collision-safe:
// 1. pg_advisory_xact_lock serialises number generation per firm for the life of the
// transaction, so two concurrent creates can't read the same value and pick the same number.
// 2. The next value is max(sequence)+1, not count(*) — deleting a draft can never make a later
// invoice reuse a number that still exists in the table.
//
// MUST be called inside the same transaction that inserts the invoice, so the advisory lock is
// held until the new row is committed.
export async function nextInvoiceNumber(tx: Tx, firmId: string): Promise<string> {
const year = new Date().getUTCFullYear();
const prefix = `INV-${year}-`;
const [row] = await getDb()
.select({ count: sql<number>`count(*)::int` })
// Per-firm, transaction-scoped lock; released automatically on commit or rollback.
await tx.execute(sql`select pg_advisory_xact_lock(hashtext(${firmId}))`);
// Highest existing sequence for this firm + year (the trailing NNNN of INV-YYYY-NNNN).
const [row] = await tx
.select({ maxNum: sql<number>`coalesce(max(split_part(${invoices.number}, '-', 3)::int), 0)` })
.from(invoices)
.where(and(eq(invoices.firmId, firmId), like(invoices.number, `${prefix}%`)));
const next = (row?.count ?? 0) + 1;
const next = (row?.maxNum ?? 0) + 1;
return `${prefix}${String(next).padStart(4, '0')}`;
}
+151 -15
View File
@@ -1,29 +1,165 @@
import fs from 'node:fs';
import path from 'node:path';
// Object storage — DigitalOcean Spaces (S3-compatible), the platform's sole storage backend.
// Uploads go through the API (buffer -> PutObject); downloads stream the object body back
// so tenant/ownership checks stay server-side and the bucket is never exposed directly.
import type { Readable } from 'node:stream';
import {
S3Client,
PutObjectCommand,
GetObjectCommand,
DeleteObjectCommand,
DeleteObjectsCommand,
ListObjectsV2Command,
type GetObjectCommandOutput,
} from '@aws-sdk/client-s3';
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
import { env } from '../env';
function root(): string {
return path.resolve(env.STORAGE_PATH);
let _s3: S3Client | null = null;
function getS3(): S3Client {
if (_s3) return _s3;
_s3 = new S3Client({
endpoint: env.SPACES_ENDPOINT,
region: env.SPACES_REGION,
credentials: { accessKeyId: env.SPACES_KEY, secretAccessKey: env.SPACES_SECRET },
// Virtual-hosted-style (bucket.region.digitaloceanspaces.com) — matches the Spaces URL scheme.
forcePathStyle: false,
});
return _s3;
}
function resolve(key: string): string {
const abs = path.resolve(root(), key);
if (!abs.startsWith(root() + path.sep) && abs !== root()) {
// Storage keys are generated server-side (`${firmId}/${caseId}/${docId}${ext}`), but validate
// defensively: reject absolute paths and any '..' traversal segment before it reaches the bucket.
function assertSafeKey(key: string): void {
if (
!key ||
key.startsWith('/') ||
key.includes('\\') ||
key.split('/').some((seg) => seg === '..' || seg === '.')
) {
throw new Error('invalid_storage_key');
}
return abs;
}
export async function saveFile(key: string, data: Buffer): Promise<void> {
const dest = resolve(key);
await fs.promises.mkdir(path.dirname(dest), { recursive: true });
await fs.promises.writeFile(dest, data);
export class FileNotFoundError extends Error {
constructor(public key: string) {
super('file_not_found');
this.name = 'FileNotFoundError';
}
}
function isNotFound(err: unknown): boolean {
const e = err as { name?: string; $metadata?: { httpStatusCode?: number } };
return e?.name === 'NoSuchKey' || e?.name === 'NotFound' || e?.$metadata?.httpStatusCode === 404;
}
export async function saveFile(key: string, data: Buffer, contentType: string): Promise<void> {
assertSafeKey(key);
await getS3().send(
new PutObjectCommand({
Bucket: env.SPACES_BUCKET,
Key: key,
Body: data,
ContentType: contentType,
// Private by default — objects are only reachable through authenticated API routes.
ACL: 'private',
}),
);
}
export async function deleteFile(key: string): Promise<void> {
await fs.promises.unlink(resolve(key));
assertSafeKey(key);
await getS3().send(new DeleteObjectCommand({ Bucket: env.SPACES_BUCKET, Key: key }));
}
export function createReadStream(key: string): fs.ReadStream {
return fs.createReadStream(resolve(key));
// Deletes every object under a prefix (e.g. `${firmId}/` on account deletion, or
// `${firmId}/${caseId}/` on case deletion). GDPR erasure depends on this: DB cascades remove
// the document rows, and this removes the actual files. Paginated + batched (S3 caps
// DeleteObjects at 1000 keys). Returns the number of objects deleted.
export async function deletePrefix(prefix: string): Promise<number> {
assertSafeKey(prefix);
if (!prefix.endsWith('/')) throw new Error('prefix_must_end_with_slash');
const s3 = getS3();
let deleted = 0;
let continuationToken: string | undefined;
do {
const page = await s3.send(
new ListObjectsV2Command({
Bucket: env.SPACES_BUCKET,
Prefix: prefix,
ContinuationToken: continuationToken,
}),
);
const keys = (page.Contents ?? []).flatMap((o) => (o.Key ? [{ Key: o.Key }] : []));
if (keys.length > 0) {
await s3.send(
new DeleteObjectsCommand({
Bucket: env.SPACES_BUCKET,
Delete: { Objects: keys, Quiet: true },
}),
);
deleted += keys.length;
}
continuationToken = page.IsTruncated ? page.NextContinuationToken : undefined;
} while (continuationToken);
return deleted;
}
// Lists every object key under a prefix (or the whole bucket with ''). Used by the
// orphan-sweep script to reconcile Spaces contents against the documents table.
export async function listAllKeys(prefix = ''): Promise<string[]> {
const s3 = getS3();
const keys: string[] = [];
let continuationToken: string | undefined;
do {
const page = await s3.send(
new ListObjectsV2Command({
Bucket: env.SPACES_BUCKET,
Prefix: prefix || undefined,
ContinuationToken: continuationToken,
}),
);
for (const o of page.Contents ?? []) if (o.Key) keys.push(o.Key);
continuationToken = page.IsTruncated ? page.NextContinuationToken : undefined;
} while (continuationToken);
return keys;
}
// Returns a readable stream of the object body. Throws FileNotFoundError when the key is absent
// so callers can map it to a clean 404 instead of a 500.
export async function getObjectStream(key: string): Promise<Readable> {
assertSafeKey(key);
let out: GetObjectCommandOutput;
try {
out = await getS3().send(new GetObjectCommand({ Bucket: env.SPACES_BUCKET, Key: key }));
} catch (err) {
if (isNotFound(err)) throw new FileNotFoundError(key);
throw err;
}
if (!out.Body) throw new FileNotFoundError(key);
return out.Body as Readable;
}
// Presigned time-limited GET URL — for future direct-download / CDN use. Not used by the
// current streaming download route, but handy for large files or client-side rendering.
export async function getSignedDownloadUrl(
key: string,
filename: string,
expiresInSeconds = 300,
): Promise<string> {
assertSafeKey(key);
return getSignedUrl(
getS3(),
new GetObjectCommand({
Bucket: env.SPACES_BUCKET,
Key: key,
ResponseContentDisposition: `attachment; filename="${encodeURIComponent(filename)}"`,
}),
{ expiresIn: expiresInSeconds },
);
}
+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 };
+123 -14
View File
@@ -2,12 +2,26 @@ import crypto from 'node:crypto';
import type { FastifyInstance } from 'fastify';
import { z } from 'zod';
import { and, eq, gte, isNull, sql } from 'drizzle-orm';
import { getDb, users, firms, loginAttempts, passwordResets, sessions as sessionsTable } from '@lawdesk/db';
import {
getDb,
users,
firms,
loginAttempts,
passwordResets,
emailVerifications,
sessions as sessionsTable,
} from '@lawdesk/db';
import { hashPassword, verifyPassword } from '../auth/password';
import { SESSION_COOKIE, createSession, destroySession } from '../auth/sessions';
import { ensureSuperadminFlag } from '../auth/superadmin';
import { generateCsrfToken } from '../auth/csrf';
import { sendEmail, passwordResetEmail, welcomeEmail } from '../lib/email';
import {
sendEmail,
passwordResetEmail,
passwordChangedEmail,
welcomeEmail,
verifyEmailEmail,
} from '../lib/email';
import { env } from '../env';
const signupBody = z.object({
@@ -24,19 +38,33 @@ const loginBody = z.object({
const MAX_FAILS_PER_15_MIN = 5;
// Mints an email-verification token (stored hashed, like password resets) and returns the
// clickable URL. The link hits the API directly — the vite dev proxy and the prod same-origin
// setup both route /api/* to this server.
async function createVerifyUrl(userId: string): Promise<string> {
const rawToken = crypto.randomBytes(32).toString('base64url');
const tokenHash = crypto.createHash('sha256').update(rawToken).digest('hex');
const expiresAt = new Date(Date.now() + 24 * 60 * 60 * 1000); // 24 hours
await getDb().insert(emailVerifications).values({ tokenHash, userId, expiresAt });
return `${env.PUBLIC_URL}/api/auth/verify-email?token=${rawToken}`;
}
async function recentFailedAttempts(email: string, ip: string | null): Promise<number> {
const since = new Date(Date.now() - 15 * 60 * 1000);
const db = getDb();
const conditions = [
eq(loginAttempts.email, email),
eq(loginAttempts.success, false),
gte(loginAttempts.attemptedAt, since),
];
// Key the lockout on (email, ip). A single IP that keeps failing against an account gets
// throttled, but an attacker firing bad passwords from another IP can no longer lock the
// legitimate owner out of their own account (previously this counted by email alone).
if (ip) conditions.push(eq(loginAttempts.ip, ip));
const rows = await db
.select({ count: sql<number>`count(*)::int` })
.from(loginAttempts)
.where(
and(
eq(loginAttempts.email, email),
eq(loginAttempts.success, false),
gte(loginAttempts.attemptedAt, since),
),
);
.where(and(...conditions));
return rows[0]?.count ?? 0;
}
@@ -70,7 +98,12 @@ export async function authRoutes(app: FastifyInstance) {
.returning();
if (!user) return reply.code(500).send({ error: 'user_create_failed' });
const isSuperadmin = await ensureSuperadminFlag(user.id, user.email, user.isSuperadmin);
const isSuperadmin = await ensureSuperadminFlag(
user.id,
user.email,
user.isSuperadmin,
user.emailVerifiedAt,
);
const { token, expiresAt } = await createSession({
userId: user.id,
@@ -80,9 +113,17 @@ export async function authRoutes(app: FastifyInstance) {
app.setSessionCookie(reply, token, expiresAt);
app.setCsrfCookie(reply, generateCsrfToken());
// Fire-and-forget welcome email (no blocking)
const welcome = welcomeEmail(user.fullName, null);
sendEmail({ to: user.email, ...welcome }).catch((err) => app.log.warn({ err }, 'welcome email failed'));
// Fire-and-forget welcome email with a verification link (no blocking)
createVerifyUrl(user.id)
.catch((err) => {
app.log.warn({ err }, 'verify token create failed — sending welcome without link');
return null;
})
.then((verifyUrl) => {
const welcome = welcomeEmail(user.fullName, verifyUrl);
return sendEmail({ to: user.email, ...welcome });
})
.catch((err) => app.log.warn({ err }, 'welcome email failed'));
return reply.code(201).send({
user: {
@@ -124,7 +165,12 @@ export async function authRoutes(app: FastifyInstance) {
return reply.code(403).send({ error: 'account_suspended' });
}
const isSuperadmin = await ensureSuperadminFlag(user.id, user.email, user.isSuperadmin);
const isSuperadmin = await ensureSuperadminFlag(
user.id,
user.email,
user.isSuperadmin,
user.emailVerifiedAt,
);
await db.update(users).set({ lastSeenAt: new Date() }).where(eq(users.id, user.id));
@@ -238,6 +284,69 @@ export async function authRoutes(app: FastifyInstance) {
await tx.delete(sessionsTable).where(eq(sessionsTable.userId, user.id));
});
// Security notice — lets the real owner react fast if the reset wasn't theirs.
const tpl = passwordChangedEmail(user.fullName);
sendEmail({ to: user.email, ...tpl }).catch((err) =>
app.log.warn({ err }, 'password changed email failed'),
);
return { ok: true };
},
);
// ─────────────────────────── Email verification ───────────────────────────
// Landing endpoint for the link in welcome/verification emails. Redirects to the web app
// either way; ?verified=1|0 lets the UI show a toast.
app.get('/api/auth/verify-email', async (req, reply) => {
const parsed = z.object({ token: z.string().min(20).max(200) }).safeParse(req.query);
if (!parsed.success) return reply.redirect(`${env.PUBLIC_URL}/login?verified=0`);
const tokenHash = crypto.createHash('sha256').update(parsed.data.token).digest('hex');
const db = getDb();
const [row] = await db
.select()
.from(emailVerifications)
.where(and(eq(emailVerifications.tokenHash, tokenHash), isNull(emailVerifications.consumedAt)))
.limit(1);
if (!row || row.expiresAt.getTime() < Date.now()) {
return reply.redirect(`${env.PUBLIC_URL}/login?verified=0`);
}
await db.transaction(async (tx) => {
await tx
.update(users)
.set({ emailVerifiedAt: new Date(), updatedAt: new Date() })
.where(and(eq(users.id, row.userId), isNull(users.emailVerifiedAt)));
await tx
.update(emailVerifications)
.set({ consumedAt: new Date() })
.where(eq(emailVerifications.tokenHash, tokenHash));
});
return reply.redirect(`${env.PUBLIC_URL}/login?verified=1`);
});
// Re-send the verification email for the logged-in user.
app.post(
'/api/auth/resend-verification',
{ config: { rateLimit: { max: 3, timeWindow: '15 minutes' } } },
async (req, reply) => {
if (!req.user) return reply.code(401).send({ error: 'unauthorized' });
const db = getDb();
const [user] = await db.select().from(users).where(eq(users.id, req.user.id)).limit(1);
if (!user) return reply.code(404).send({ error: 'user_not_found' });
if (user.emailVerifiedAt) return { ok: true, alreadyVerified: true };
const verifyUrl = await createVerifyUrl(user.id);
const tpl = verifyEmailEmail(user.fullName, verifyUrl);
sendEmail({ to: user.email, ...tpl }).catch((err) =>
app.log.warn({ err }, 'verification email failed'),
);
return { ok: true };
},
);
+9
View File
@@ -38,6 +38,15 @@ export async function billingRoutes(app: FastifyInstance) {
const [firm] = await db.select().from(firms).where(eq(firms.id, firmId)).limit(1);
if (!firm) return reply.code(404).send({ error: 'firm_not_found' });
// Guard against double-billing: a firm already on a paid plan (or with a live subscription)
// must not be able to open a second Checkout session. Send them to the portal instead.
if (firm.plan !== 'starter' || firm.stripeSubscriptionId) {
return reply.code(409).send({
error: 'already_on_paid_plan',
hint: 'Manage or change your current plan from the billing portal.',
});
}
const stripe = getStripe();
// Reuse the customer if we've made one before; otherwise let Checkout create one and we'll
+7
View File
@@ -4,6 +4,7 @@ import { and, desc, eq, ilike, or, sql } from 'drizzle-orm';
import { getDb, cases, clients, timeEntries } from '@lawdesk/db';
import { loadFirm } from '../lib/firm';
import { assertCanCreateCase, PlanLimitError } from '../lib/plan-limits';
import { deletePrefix } from '../lib/storage';
const STATUSES = ['open', 'pending', 'closed', 'archived'] as const;
@@ -172,6 +173,12 @@ export async function casesRoutes(app: FastifyInstance) {
.where(and(eq(cases.id, id), eq(cases.firmId, firmId)))
.returning({ id: cases.id });
if (!row) return reply.code(404).send({ error: 'not_found' });
// The cascade removed the document rows; remove the files too (GDPR erasure).
deletePrefix(`${firmId}/${id}/`).catch((err) =>
req.log.error({ err, firmId, caseId: id }, 'FAILED to delete case storage'),
);
return { ok: true };
});
}
+17 -1
View File
@@ -4,6 +4,7 @@ import { and, desc, eq, ilike, or, sql } from 'drizzle-orm';
import { getDb, clients, cases } from '@lawdesk/db';
import { loadFirm } from '../lib/firm';
import { assertCanCreateClient, PlanLimitError } from '../lib/plan-limits';
import { deletePrefix } from '../lib/storage';
const createBody = z.object({
name: z.string().min(1).max(160).trim(),
@@ -111,12 +112,27 @@ export async function clientsRoutes(app: FastifyInstance) {
app.delete('/api/clients/:id', async (req, reply) => {
const firmId = req.user!.firmId!;
const { id } = z.object({ id: z.string().uuid() }).parse(req.params);
const db = getDb();
const [row] = await getDb()
// Capture the client's case ids before the cascade removes them — their storage
// prefixes must be cleaned up after the delete (GDPR erasure).
const clientCases = await db
.select({ id: cases.id })
.from(cases)
.where(and(eq(cases.clientId, id), eq(cases.firmId, firmId)));
const [row] = await db
.delete(clients)
.where(and(eq(clients.id, id), eq(clients.firmId, firmId)))
.returning({ id: clients.id });
if (!row) return reply.code(404).send({ error: 'not_found' });
for (const c of clientCases) {
deletePrefix(`${firmId}/${c.id}/`).catch((err) =>
req.log.error({ err, firmId, caseId: c.id }, 'FAILED to delete case storage'),
);
}
return { ok: true };
});
}
+14 -1
View File
@@ -1,7 +1,8 @@
import type { FastifyInstance } from 'fastify';
import { z } from 'zod';
import { getDb, contactMessages } from '@lawdesk/db';
import { sendEmail, contactAckEmail } from '../lib/email';
import { sendEmail, contactAckEmail, contactNotifyEmail } from '../lib/email';
import { env } from '../env';
const contactBody = z.object({
fullName: z.string().min(1).max(120).trim(),
@@ -27,6 +28,18 @@ export async function contactRoutes(app: FastifyInstance) {
sendEmail({ to: body.email, ...tpl }).catch((err) =>
app.log.warn({ err }, 'contact ack email failed'),
);
// Notify the team — reply-to points at the submitter so answering is one click.
const notify = contactNotifyEmail({
fromName: body.fullName,
fromEmail: body.email,
message: body.message,
ip: req.ip ?? null,
});
for (const admin of env.superadminEmails) {
sendEmail({ to: admin, ...notify, replyTo: body.email }).catch((err) =>
app.log.warn({ err }, 'contact notify email failed'),
);
}
return reply.code(201).send({ ok: true });
},
);
+23 -3
View File
@@ -4,7 +4,8 @@ import type { FastifyInstance } from 'fastify';
import { z } from 'zod';
import { and, desc, eq } from 'drizzle-orm';
import { getDb, documents, cases } from '@lawdesk/db';
import { saveFile, deleteFile, createReadStream } from '../lib/storage';
import { saveFile, deleteFile, getObjectStream, FileNotFoundError } from '../lib/storage';
import { verifyFileSignature } from '../lib/file-signature';
const ALLOWED_MIME = new Set([
'application/pdf',
@@ -66,11 +67,18 @@ export async function documentsRoutes(app: FastifyInstance) {
}
const buf = await data.toBuffer();
// Content-type sniffing: the declared mimetype passed the allowlist above, but verify the
// actual bytes match it so a spoofed header can't smuggle in a different (e.g. executable) file.
if (!verifyFileSignature(buf, data.mimetype)) {
return reply.code(400).send({ error: 'file_content_mismatch' });
}
const docId = randomUUID();
const ext = path.extname(data.filename);
const storageKey = `${firmId}/${caseId}/${docId}${ext}`;
await saveFile(storageKey, buf);
await saveFile(storageKey, buf, data.mimetype);
const [doc] = await db.insert(documents).values({
id: docId,
@@ -83,6 +91,12 @@ export async function documentsRoutes(app: FastifyInstance) {
sizeBytes: buf.length,
}).returning();
if (!doc) {
// Row insert failed after the file was written — clean up the orphaned object.
await deleteFile(storageKey).catch(() => {});
return reply.code(500).send({ error: 'upload_failed' });
}
return reply.code(201).send({
id: doc.id,
name: doc.name,
@@ -102,7 +116,13 @@ export async function documentsRoutes(app: FastifyInstance) {
.where(and(eq(documents.id, docId), eq(documents.firmId, firmId))).limit(1);
if (!doc) return reply.code(404).send({ error: 'not_found' });
const stream = createReadStream(doc.storageKey);
let stream;
try {
stream = await getObjectStream(doc.storageKey);
} catch (err) {
if (err instanceof FileNotFoundError) return reply.code(404).send({ error: 'file_missing' });
throw err;
}
return reply
.header('Content-Type', doc.mimeType)
.header('Content-Disposition', `attachment; filename="${encodeURIComponent(doc.name)}"`)
+30 -2
View File
@@ -14,7 +14,7 @@ import { loadFirm } from '../lib/firm';
import { assertCanCreateInvoice, PlanLimitError } from '../lib/plan-limits';
import { nextInvoiceNumber } from '../lib/invoice-numbering';
import { renderInvoicePdf } from '../lib/invoice-pdf';
import { sendEmail, invoiceEmail } from '../lib/email';
import { sendEmail, invoiceEmail, invoicePaidEmail } from '../lib/email';
const STATUSES = ['draft', 'sent', 'paid', 'overdue', 'void'] as const;
@@ -262,9 +262,11 @@ export async function invoicesRoutes(app: FastifyInstance) {
const taxRate = body.taxRate;
const totals = computeTotals(accumulated, taxRate);
const number = await nextInvoiceNumber(firmId);
const created = await db.transaction(async (tx) => {
// Generate the number inside the transaction: the advisory lock it takes must be held
// until this insert commits, so concurrent creates serialise and never collide.
const number = await nextInvoiceNumber(tx, firmId);
const [inv] = await tx
.insert(invoices)
.values({
@@ -461,6 +463,32 @@ export async function invoicesRoutes(app: FastifyInstance) {
.set({ status: 'paid', paidAt: now, updatedAt: now })
.where(eq(invoices.id, id))
.returning();
// Payment receipt to the client (best-effort).
if (row) {
try {
const [client] = await db.select().from(clients).where(eq(clients.id, row.clientId)).limit(1);
const [firm] = await db.select().from(firms).where(eq(firms.id, firmId)).limit(1);
if (client?.email && firm) {
const totalFmt = new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD',
}).format(Number(row.total));
const tpl = invoicePaidEmail({
clientName: client.name,
firmName: firm.name,
invoiceNumber: row.number,
total: totalFmt,
});
sendEmail({ to: client.email, ...tpl }).catch((err) =>
app.log.warn({ err, invoiceId: id }, 'invoice paid email failed'),
);
}
} catch (err) {
app.log.warn({ err, invoiceId: id }, 'failed to send invoice paid email');
}
}
return row;
});
+43 -7
View File
@@ -1,10 +1,15 @@
import type { FastifyInstance } from 'fastify';
import type Stripe from 'stripe';
import { eq } from 'drizzle-orm';
import { and, eq } from 'drizzle-orm';
import { getDb, firms, users } from '@lawdesk/db';
import { env } from '../env';
import { getStripe } from '../lib/stripe';
import { sendEmail, planUpgradedEmail } from '../lib/email';
import {
sendEmail,
planUpgradedEmail,
paymentFailedEmail,
subscriptionEndedEmail,
} from '../lib/email';
import { logAudit } from '../lib/audit';
// Registered as a sub-app so its own buffer-only content-type parser doesn't affect the rest of
@@ -80,13 +85,40 @@ async function handleEvent(event: Stripe.Event, app: FastifyInstance) {
const firmId = (sub.metadata?.firmId as string | undefined) ?? null;
if (!firmId) return;
await applyPlan(firmId, 'starter', { subscriptionId: null });
for (const u of await firmOwners(firmId)) {
const tpl = subscriptionEndedEmail(u.fullName);
sendEmail({ to: u.email, ...tpl }).catch((err) =>
app.log.warn({ err, firmId }, 'subscription ended email failed'),
);
}
break;
}
case 'invoice.payment_failed': {
// Optional: surface to the user via email later. For now, just log.
const invoice = event.data.object as Stripe.Invoice;
app.log.warn({ invoice: invoice.id, customer: invoice.customer }, 'stripe invoice payment failed');
const customerId = typeof invoice.customer === 'string' ? invoice.customer : invoice.customer?.id;
if (!customerId) break;
const [firm] = await getDb()
.select({ id: firms.id })
.from(firms)
.where(eq(firms.stripeCustomerId, customerId))
.limit(1);
if (!firm) break;
const amount = invoice.amount_due
? new Intl.NumberFormat('en-US', {
style: 'currency',
currency: (invoice.currency ?? 'usd').toUpperCase(),
}).format(invoice.amount_due / 100)
: null;
for (const u of await firmOwners(firm.id)) {
const tpl = paymentFailedEmail(u.fullName, amount);
sendEmail({ to: u.email, ...tpl }).catch((err) =>
app.log.warn({ err, firmId: firm.id }, 'payment failed email failed'),
);
}
break;
}
@@ -117,13 +149,17 @@ async function applyPlan(
});
}
async function sendPlanUpgradedNotice(firmId: string, plan: 'pro' | 'lifetime') {
const owners = await getDb()
// Billing emails go to owners only — staff shouldn't get payment notices.
async function firmOwners(firmId: string) {
return getDb()
.select({ email: users.email, fullName: users.fullName })
.from(users)
.where(eq(users.firmId, firmId));
.where(and(eq(users.firmId, firmId), eq(users.role, 'owner')));
}
async function sendPlanUpgradedNotice(firmId: string, plan: 'pro' | 'lifetime') {
const label = plan === 'pro' ? 'Professional' : 'Lifetime';
for (const u of owners) {
for (const u of await firmOwners(firmId)) {
const tpl = planUpgradedEmail(u.fullName, label);
await sendEmail({ to: u.email, ...tpl });
}
+16 -5
View File
@@ -36,14 +36,17 @@ export async function buildServer() {
logger: isProd
? { level: 'info' }
: { level: 'debug', transport: { target: 'pino-pretty', options: { colorize: true } } },
trustProxy: true,
// Trust exactly ONE proxy hop (the Plesk/nginx reverse proxy in front of Passenger).
// `true` would trust the entire X-Forwarded-For chain, letting any client spoof req.ip and
// evade the IP-keyed rate limits (including auth brute-force protection).
trustProxy: 1,
bodyLimit: 5 * 1024 * 1024,
});
// Register the global error handler EARLY so it wins over plugin-default handlers and
// catches ZodErrors thrown by .parse() inside route handlers.
app.setErrorHandler((err, req, reply) => {
if (err instanceof ZodError || (err as { validation?: unknown }).validation || err.name === 'ZodError') {
if (err instanceof ZodError || (err as { validation?: unknown }).validation || (err as Error).name === 'ZodError') {
req.log.info({ err }, 'validation error');
return reply
.code(400)
@@ -123,15 +126,23 @@ export async function buildServer() {
cacheControl: true,
maxAge: '1y',
immutable: true,
decorateReply: false,
// Must NOT be `false`: the SPA fallback below calls reply.sendFile, which only exists when
// @fastify/static decorates the reply. With it disabled, every deep-link/refresh to a
// non-/api route (e.g. /dashboard, emailed /reset-password links) 500s in production.
});
// SPA fallback: any non-/api path returns index.html
// SPA fallback: any non-/api path returns index.html. index.html itself must not be cached
// for a year (unlike the hashed assets) or clients keep a stale app shell after each deploy.
app.setNotFoundHandler((req, reply) => {
if (req.raw.url?.startsWith('/api/')) {
return reply.code(404).send({ error: 'not_found' });
}
return reply.type('text/html').sendFile('index.html', webDist);
// cacheControl:false stops @fastify/static from stamping its own 1y immutable header
// (which would otherwise override the no-cache below and pin a stale app shell).
return reply
.header('Cache-Control', 'no-cache')
.type('text/html')
.sendFile('index.html', webDist, { cacheControl: false });
});
} else {
app.log.warn({ webDist }, 'web/dist not found — SPA assets will not be served');
+70
View File
@@ -0,0 +1,70 @@
import { describe, it, expect } from 'vitest';
import { verifyFileSignature } from '../src/lib/file-signature.js';
// Helper: build a buffer from a leading byte signature plus optional trailing filler.
function bytes(sig: number[], pad = 0): Buffer {
return Buffer.concat([Buffer.from(sig), Buffer.alloc(pad, 0x20)]);
}
const PDF = bytes([0x25, 0x50, 0x44, 0x46, 0x2d, 0x31, 0x2e, 0x37]); // %PDF-1.7
const PNG = bytes([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a], 16);
const JPEG = bytes([0xff, 0xd8, 0xff, 0xe0, 0x00, 0x10, 0x4a, 0x46], 16);
describe('verifyFileSignature', () => {
it('accepts a real PDF declared as application/pdf', () => {
expect(verifyFileSignature(PDF, 'application/pdf')).toBe(true);
});
it('accepts a real PNG declared as image/png', () => {
expect(verifyFileSignature(PNG, 'image/png')).toBe(true);
});
it('accepts a real JPEG declared as image/jpeg', () => {
expect(verifyFileSignature(JPEG, 'image/jpeg')).toBe(true);
});
it('rejects an HTML page spoofed as image/png', () => {
const html = Buffer.from('<!DOCTYPE html><html><body>hi</body></html>', 'utf8');
expect(verifyFileSignature(html, 'image/png')).toBe(false);
});
it('rejects an HTML page spoofed as image/jpeg', () => {
const html = Buffer.from('<html><script>alert(1)</script></html>', 'utf8');
expect(verifyFileSignature(html, 'image/jpeg')).toBe(false);
});
it('rejects a Windows executable (MZ header) spoofed as text/plain', () => {
// MZ header (0x4D 0x5A) followed by a NUL — NUL bytes disqualify it as text.
const exe = Buffer.from([0x4d, 0x5a, 0x90, 0x00, 0x03, 0x00, 0x00, 0x00]);
expect(verifyFileSignature(exe, 'text/plain')).toBe(false);
});
it('rejects a PDF whose bytes are actually an executable', () => {
const exe = Buffer.from([0x4d, 0x5a, 0x90, 0x00]);
expect(verifyFileSignature(exe, 'application/pdf')).toBe(false);
});
it('rejects an unknown / non-allowlisted declared MIME type', () => {
expect(verifyFileSignature(PDF, 'application/x-shockwave-flash')).toBe(false);
expect(verifyFileSignature(PDF, 'image/svg+xml')).toBe(false);
});
it('accepts genuine text declared as text/plain', () => {
const text = Buffer.from('Dear client, please find the attached invoice.\n', 'utf8');
expect(verifyFileSignature(text, 'text/plain')).toBe(true);
});
it('rejects a buffer too short to contain the signature', () => {
expect(verifyFileSignature(Buffer.from([0x25, 0x50]), 'application/pdf')).toBe(false);
});
it('accepts a real WEBP image declared as image/webp', () => {
// RIFF....WEBP
const webp = Buffer.concat([
Buffer.from([0x52, 0x49, 0x46, 0x46]),
Buffer.from([0x00, 0x00, 0x00, 0x00]),
Buffer.from('WEBP', 'latin1'),
]);
expect(verifyFileSignature(webp, 'image/webp')).toBe(true);
});
});
+33
View File
@@ -0,0 +1,33 @@
import { describe, it, expect } from 'vitest';
import { hashPassword, verifyPassword } from '../src/auth/password.js';
describe('password hashing (argon2)', () => {
it('verifies a correct password against its hash', async () => {
const hash = await hashPassword('correct horse battery staple');
expect(await verifyPassword(hash, 'correct horse battery staple')).toBe(true);
});
it('rejects a wrong password', async () => {
const hash = await hashPassword('correct horse battery staple');
expect(await verifyPassword(hash, 'Tr0ub4dor&3')).toBe(false);
});
it('produces an argon2id hash, not plaintext', async () => {
const hash = await hashPassword('s3cret');
expect(hash).toMatch(/^\$argon2id\$/);
expect(hash).not.toContain('s3cret');
});
it('produces a different hash each time (random salt) but both verify', async () => {
const a = await hashPassword('same-password');
const b = await hashPassword('same-password');
expect(a).not.toBe(b);
expect(await verifyPassword(a, 'same-password')).toBe(true);
expect(await verifyPassword(b, 'same-password')).toBe(true);
});
it('is case-sensitive', async () => {
const hash = await hashPassword('CaseSensitive');
expect(await verifyPassword(hash, 'casesensitive')).toBe(false);
});
});
+11
View File
@@ -0,0 +1,11 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
// Unit tests only — no DB, no network, no server bootstrap.
include: ['test/**/*.test.ts'],
environment: 'node',
// Fail fast if a test accidentally reaches for the network/DB by hanging.
testTimeout: 15000,
},
});