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:
co-authored by
Claude Fable 5
parent
310568690b
commit
97e1d4c60b
@@ -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 });
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user