Compare commits

..
2 Commits
Author SHA1 Message Date
Leon SerfatyandClaude Fable 5 1f249dc126 SEO prerender pipeline + register AI routes and tighten CSP
CI / build-and-test (push) Has been cancelled
- Static prerender + sitemap/robots for marketing, blog, and tool pages
  (entry-server, Seo component, prerender/sitemap scripts, NotFoundPage)
- Register aiRoutes and extend CSP for Turnstile + Spaces image hosts

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 13:37:05 -04:00
Leon SerfatyandClaude Fable 5 304f7f30c3 Security hardening: deps, tenancy quotas, auth, deploy, webhooks
Addresses the findings from the platform security audit. Verified green:
all-workspace typecheck, web build, 16 API unit tests, 23 e2e auth tests,
and 0 high/critical production dependency vulnerabilities.

Dependencies (High):
- Bump drizzle-orm 0.36→0.45.2 (GHSA-gpj5-g38j-94v9 SQLi-via-identifier)
  and drizzle-kit→0.31.10; npm audit fix cleared fast-uri path-traversal
  and the react-router open-redirect. Remaining audit items are dev-only
  build tooling (esbuild/vite), not shipped at runtime.

AI cost control + storage quota (new ai_usage table, migration 0002):
- Per-firm monthly AI token budget enforced before each completion (429),
  with every completion recorded to an ai_usage ledger (lib/ai-usage.ts).
- Enforce per-plan storage quota on upload (402) and maintain
  storage_bytes_used on upload/delete (lib/storage-quota.ts); widen the
  column int→bigint so 8GB/50GB plans don't overflow.

Auth (defense-in-depth):
- Constant-time login: verify against a dummy argon2 hash when the account
  doesn't exist, closing the timing/enumeration oracle (verifyPasswordSafe).
- Enforce suspension on requireSuperadmin, /auth/me, /auth/resend-verification.

Web:
- Validate the post-login ?next= redirect to same-origin paths only
  (open-redirect / phishing).

Deploy hardening:
- docker-compose: memory/CPU limits so a spike can't OOM the Dokploy host.
- .dockerignore: keep destructive one-off scripts (seed-demo, create-admin,
  migrate-storage) out of the runtime image; retain the cron scripts.
- seed-demo.ts: hard-refuse NODE_ENV=production and the prod DB host.

Webhooks / config:
- Stripe idempotency via a stripe_events ledger (skip already-processed
  events; record only after successful processing so a transient failure
  still retries); make the plan-upgraded email non-blocking.
- Rate-limit account export and invoice PDF; cap invoice item arrays at 200.
- Require TURNSTILE_SECRET_KEY in production (bot protection no longer fails
  open on a forgotten key); don't load .env under NODE_ENV=test so the suite
  is hermetic.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 13:34:33 -04:00
41 changed files with 3544 additions and 442 deletions
+18 -1
View File
@@ -34,9 +34,26 @@ tmp
storage storage
uploads uploads
# Tests aren't needed in the runtime image. # Tests and test tooling aren't needed in the runtime image.
apps/api/test apps/api/test
**/*.test.ts **/*.test.ts
**/*.spec.ts
**/vitest.config.ts
**/vitest.*.config.ts
# Destructive/privileged one-off scripts must NOT ship in the runtime image: an attacker with
# code-exec in the container has DATABASE_URL in-env, so keeping these off disk removes the sharpest
# RCE-amplification tools. Root-anchored to the top-level scripts/ only (apps/web/scripts, used by the
# web build, is a different directory and is kept). DB migrations run via `npm run db:migrate`
# (packages/db), not from scripts/, so this does not affect builds or deploys.
#
# The routine, non-destructive cron scripts (retention-sweep, send-overdue-reminders,
# sweep-orphaned-storage) are intentionally KEPT so Dokploy scheduled jobs can invoke them inside
# the container (e.g. `npm run cron:retention`, which enforces Privacy-Policy retention windows).
/scripts/seed-demo.ts
/scripts/create-admin.ts
/scripts/migrate-storage-to-spaces.ts
/scripts/plesk-deploy.sh
# Note: certs/ is intentionally NOT ignored — the Postgres CA cert (if committed) is baked in # Note: certs/ is intentionally NOT ignored — the Postgres CA cert (if committed) is baked in
# so production TLS verification works. See DEPLOY-DOKPLOY.md. # so production TLS verification works. See DEPLOY-DOKPLOY.md.
+11
View File
@@ -49,6 +49,17 @@ RUN apt-get update \
# Bring over the fully-installed, already-built app (node_modules incl. the compiled argon2 binary # Bring over the fully-installed, already-built app (node_modules incl. the compiled argon2 binary
# and workspace symlinks, apps/web/dist, TS source run by tsx, and certs/ if the CA cert is present). # and workspace symlinks, apps/web/dist, TS source run by tsx, and certs/ if the CA cert is present).
#
# Attack-surface note / future hardening: the runtime executes TypeScript source directly through the
# tsx ESM loader (see server.js), so this image MUST ship tsx (a prod dependency of apps/api) plus the
# TS sources and the full node_modules from the builder. node_modules is copied whole rather than
# pruned because tsx and its transitive prod deps are resolved at runtime, and an aggressive
# `npm prune --omit=dev` here risks breaking that resolution — correctness of the running container
# takes priority. The destructive operational scripts/ dir and test files are already excluded from
# the build context (see .dockerignore), so they never reach this image.
# Future improvement: precompile the API to plain JS (tsc/esbuild) in the builder stage and run it via
# plain `node dist/server.js`. That removes the tsx runtime dependency and lets the runtime install
# prod-only deps (`npm ci --omit=dev`), further shrinking the image and its attack surface.
COPY --from=builder --chown=app:app /app /app COPY --from=builder --chown=app:app /app /app
USER app USER app
+1 -1
View File
@@ -27,7 +27,7 @@
"@sentry/node": "^8.45.0", "@sentry/node": "^8.45.0",
"argon2": "^0.41.1", "argon2": "^0.41.1",
"dotenv": "^16.4.5", "dotenv": "^16.4.5",
"drizzle-orm": "^0.36.4", "drizzle-orm": "^0.45.2",
"fastify": "^5.1.0", "fastify": "^5.1.0",
"fastify-plugin": "^5.0.1", "fastify-plugin": "^5.0.1",
"fastify-type-provider-zod": "^4.0.2", "fastify-type-provider-zod": "^4.0.2",
+22
View File
@@ -1,4 +1,5 @@
import argon2 from 'argon2'; import argon2 from 'argon2';
import crypto from 'node:crypto';
const ARGON2_OPTIONS: argon2.Options = { const ARGON2_OPTIONS: argon2.Options = {
type: argon2.argon2id, type: argon2.argon2id,
@@ -14,3 +15,24 @@ export function hashPassword(password: string): Promise<string> {
export function verifyPassword(hash: string, password: string): Promise<boolean> { export function verifyPassword(hash: string, password: string): Promise<boolean> {
return argon2.verify(hash, password); return argon2.verify(hash, password);
} }
// Precomputed dummy argon2id hash for constant-time login. When an account doesn't exist we
// still run a full verify against this hash so the nonexistent-account path costs the same as a
// real (failing) password check — closing the timing/enumeration oracle. Computed once at module
// load from a random throwaway secret; the promise is cached so the hash cost is paid a single time.
const dummyHashPromise: Promise<string> = hashPassword(crypto.randomBytes(32).toString('hex'));
// Verifies `password` against `hash` when present, otherwise against the dummy hash so the
// account-exists and account-missing paths do equal argon2 work. Always resolves to a boolean and
// never throws (a null/undefined or malformed hash simply resolves to false).
export async function verifyPasswordSafe(
hash: string | null | undefined,
password: string,
): Promise<boolean> {
const target = hash ?? (await dummyHashPromise);
try {
return await argon2.verify(target, password);
} catch {
return false;
}
}
+1
View File
@@ -65,6 +65,7 @@ async function plugin(app: FastifyInstance) {
app.decorate('requireSuperadmin', async (req: FastifyRequest, reply: FastifyReply) => { app.decorate('requireSuperadmin', async (req: FastifyRequest, reply: FastifyReply) => {
if (!req.user) return reply.code(401).send({ error: 'unauthorized' }); if (!req.user) return reply.code(401).send({ error: 'unauthorized' });
if (req.user.isSuspended) return reply.code(403).send({ error: 'account_suspended' });
if (!req.user.isSuperadmin) return reply.code(403).send({ error: 'forbidden' }); if (!req.user.isSuperadmin) return reply.code(403).send({ error: 'forbidden' });
}); });
+15 -2
View File
@@ -4,8 +4,12 @@ import dotenv from 'dotenv';
import { z } from 'zod'; import { z } from 'zod';
const __dirname = path.dirname(fileURLToPath(import.meta.url)); const __dirname = path.dirname(fileURLToPath(import.meta.url));
// Load .env from the monorepo root regardless of cwd // Load .env from the monorepo root regardless of cwd — but NEVER under test. The test suites
dotenv.config({ path: path.resolve(__dirname, '../../../.env') }); // inject their own env explicitly; loading a developer's/CI's real .env here makes tests
// non-hermetic (e.g. a real TURNSTILE_SECRET_KEY would switch on CAPTCHA and break auth tests).
if (process.env.NODE_ENV !== 'test') {
dotenv.config({ path: path.resolve(__dirname, '../../../.env') });
}
const envSchema = z.object({ const envSchema = z.object({
NODE_ENV: z.enum(['development', 'production', 'test']).default('development'), NODE_ENV: z.enum(['development', 'production', 'test']).default('development'),
@@ -41,6 +45,15 @@ const envSchema = z.object({
const parsed = envSchema.parse(process.env); const parsed = envSchema.parse(process.env);
// Production guard: Turnstile bot protection fails open when TURNSTILE_SECRET_KEY is unset
// (correct for dev/test, but in production a forgotten key silently disables all CAPTCHA/bot
// protection on signup/login/password-reset/contact). Fail fast at boot rather than run exposed.
if (parsed.NODE_ENV === 'production' && !parsed.TURNSTILE_SECRET_KEY) {
throw new Error(
'TURNSTILE_SECRET_KEY is required in production: without it, bot protection fails open and CAPTCHA verification is skipped entirely. Set TURNSTILE_SECRET_KEY in the environment.',
);
}
export const env = { export const env = {
...parsed, ...parsed,
superadminEmails: parsed.SUPERADMIN_EMAILS.split(',') superadminEmails: parsed.SUPERADMIN_EMAILS.split(',')
+68
View File
@@ -0,0 +1,68 @@
// Per-firm AI usage metering and monthly token quotas. Keeps LLM spend bounded by
// capping how many tokens a firm can consume per calendar month, and records every
// completion into the `aiUsage` ledger for cost accounting.
import { and, eq, gte, sql } from 'drizzle-orm';
import { getDb, aiUsage } from '@lawdesk/db';
import type { PlanName } from './plan-limits';
// Monthly token budget (inputTokens + outputTokens) per plan. Tunable — bump these as
// pricing/usage patterns settle. Unknown plans fall back to the starter budget.
export const AI_MONTHLY_TOKEN_BUDGET: Record<PlanName, number> = {
starter: 100_000,
pro: 2_000_000,
lifetime: 10_000_000,
};
export class AiQuotaError extends Error {
constructor() {
super('ai_quota_exceeded');
this.name = 'AiQuotaError';
}
}
/**
* Throws AiQuotaError if the firm has met or exceeded its monthly token budget.
* Sums tokens used since the start of the current calendar month.
*/
export async function assertAiQuota(firmId: string, plan: string): Promise<void> {
const budget = AI_MONTHLY_TOKEN_BUDGET[plan as PlanName] ?? AI_MONTHLY_TOKEN_BUDGET.starter;
const monthStart = new Date();
monthStart.setDate(1);
monthStart.setHours(0, 0, 0, 0);
const [row] = await getDb()
.select({
total: sql<number>`coalesce(sum(${aiUsage.inputTokens} + ${aiUsage.outputTokens}), 0)::bigint`,
})
.from(aiUsage)
.where(and(eq(aiUsage.firmId, firmId), gte(aiUsage.createdAt, monthStart)));
const used = Number(row?.total ?? 0);
if (used >= budget) throw new AiQuotaError();
}
/**
* Records one completion into the AI usage ledger. Best-effort: a failed metering write
* must never fail the underlying request, so errors are swallowed (and logged).
*/
export async function recordAiUsage(opts: {
firmId: string;
userId: string;
feature: string;
model: string;
usage: { inputTokens: number; outputTokens: number };
}): Promise<void> {
try {
await getDb().insert(aiUsage).values({
firmId: opts.firmId,
userId: opts.userId,
feature: opts.feature,
model: opts.model,
inputTokens: opts.usage.inputTokens,
outputTokens: opts.usage.outputTokens,
});
} catch (err) {
console.warn('[ai-usage] failed to record usage', err);
}
}
+36
View File
@@ -0,0 +1,36 @@
import { eq, sql } from 'drizzle-orm';
import { getDb, firms } from '@lawdesk/db';
import { PLAN_LIMITS, PlanLimitError, type PlanName } from './plan-limits';
/**
* Throw a PlanLimitError('storageBytes', plan) if accepting `additionalBytes` more
* would push the firm past its plan's storage cap. Plans with a null cap are unlimited.
*/
export async function assertWithinStorageQuota(
firmId: string,
plan: PlanName,
additionalBytes: number,
): Promise<void> {
const limit = PLAN_LIMITS[plan].storageBytes;
if (limit === null) return;
const [row] = await getDb()
.select({ used: firms.storageBytesUsed })
.from(firms)
.where(eq(firms.id, firmId))
.limit(1);
const used = row?.used ?? 0;
if (used + additionalBytes > limit) throw new PlanLimitError('storageBytes', plan);
}
/**
* Atomically adjust the firm's tracked storage usage by `deltaBytes` (may be negative).
* Clamped at 0 so a decrement can never drive the counter below zero.
*/
export async function incrementStorageUsed(firmId: string, deltaBytes: number): Promise<void> {
await getDb()
.update(firms)
.set({ storageBytesUsed: sql`GREATEST(0, ${firms.storageBytesUsed} + ${deltaBytes})` })
.where(eq(firms.id, firmId));
}
+4 -1
View File
@@ -22,7 +22,10 @@ export async function accountRoutes(app: FastifyInstance) {
app.addHook('preHandler', app.requireAuth); app.addHook('preHandler', app.requireAuth);
// GDPR data export — full JSON dump of everything tied to the user's firm. // GDPR data export — full JSON dump of everything tied to the user's firm.
app.get('/api/account/export', async (req, reply) => { app.get(
'/api/account/export',
{ config: { rateLimit: { max: 5, timeWindow: '1 hour' } } },
async (req, reply) => {
const userId = req.user!.id; const userId = req.user!.id;
const firmId = req.user!.firmId; const firmId = req.user!.firmId;
const db = getDb(); const db = getDb();
+39
View File
@@ -3,6 +3,8 @@ import { z } from 'zod';
import { and, desc, eq } from 'drizzle-orm'; import { and, desc, eq } from 'drizzle-orm';
import { getDb, cases, clients, timeEntries, documents, invoices } from '@lawdesk/db'; import { getDb, cases, clients, timeEntries, documents, invoices } from '@lawdesk/db';
import { aiComplete, isAiEnabled, AiDisabledError, AiUnavailableError, AI_MODEL } from '../lib/ai'; import { aiComplete, isAiEnabled, AiDisabledError, AiUnavailableError, AI_MODEL } from '../lib/ai';
import { assertAiQuota, recordAiUsage, AiQuotaError } from '../lib/ai-usage';
import { loadFirm } from '../lib/firm';
import { getObjectStream, FileNotFoundError } from '../lib/storage'; import { getObjectStream, FileNotFoundError } from '../lib/storage';
// The one non-negotiable framing for a legal-tech product: the model assists with // The one non-negotiable framing for a legal-tech product: the model assists with
@@ -23,6 +25,9 @@ const AI_MIME = {
const MAX_AI_DOC_BYTES = 15 * 1024 * 1024; // base64 expansion must stay under the 32MB request cap const MAX_AI_DOC_BYTES = 15 * 1024 * 1024; // base64 expansion must stay under the 32MB request cap
function sendAiError(reply: FastifyReply, err: unknown): FastifyReply { function sendAiError(reply: FastifyReply, err: unknown): FastifyReply {
if (err instanceof AiQuotaError) {
return reply.code(429).send({ error: 'ai_quota_exceeded' });
}
if (err instanceof AiDisabledError) { if (err instanceof AiDisabledError) {
return reply.code(503).send({ error: 'ai_not_configured' }); return reply.code(503).send({ error: 'ai_not_configured' });
} }
@@ -115,7 +120,11 @@ export async function aiRoutes(app: FastifyInstance) {
.filter((line) => line !== '') .filter((line) => line !== '')
.join('\n'); .join('\n');
const firm = await loadFirm(firmId);
if (!firm) return reply.code(403).send({ error: 'firm_missing' });
try { try {
await assertAiQuota(firmId, firm.plan);
const result = await aiComplete({ const result = await aiComplete({
system: `${BASE_SYSTEM} system: `${BASE_SYSTEM}
Write a case brief for the attorney working this case, as plain text (no markdown syntax) with these section headings on their own lines: Write a case brief for the attorney working this case, as plain text (no markdown syntax) with these section headings on their own lines:
@@ -126,6 +135,13 @@ GAPS & FOLLOW-UPS — anything the records suggest needs attention (stale activi
Keep it under 300 words.`, Keep it under 300 words.`,
content: context, content: context,
}); });
await recordAiUsage({
firmId,
userId: req.user!.id,
feature: 'case_summary',
model: AI_MODEL,
usage: result.usage,
});
return { summary: result.text, disclaimer: AI_DISCLAIMER, usage: result.usage }; return { summary: result.text, disclaimer: AI_DISCLAIMER, usage: result.usage };
} catch (err) { } catch (err) {
return sendAiError(reply, err); return sendAiError(reply, err);
@@ -201,8 +217,19 @@ Keep it under 300 words.`;
{ type: 'text' as const, text: instruction }, { type: 'text' as const, text: instruction },
]); ]);
const firm = await loadFirm(firmId);
if (!firm) return reply.code(403).send({ error: 'firm_missing' });
try { try {
await assertAiQuota(firmId, firm.plan);
const result = await aiComplete({ system: BASE_SYSTEM, content }); const result = await aiComplete({ system: BASE_SYSTEM, content });
await recordAiUsage({
firmId,
userId: req.user!.id,
feature: 'document_summary',
model: AI_MODEL,
usage: result.usage,
});
return { summary: result.text, disclaimer: AI_DISCLAIMER, usage: result.usage }; return { summary: result.text, disclaimer: AI_DISCLAIMER, usage: result.usage };
} catch (err) { } catch (err) {
return sendAiError(reply, err); return sendAiError(reply, err);
@@ -216,6 +243,7 @@ Keep it under 300 words.`;
'/api/ai/polish', '/api/ai/polish',
{ config: { rateLimit: { max: 60, timeWindow: '1 hour' } } }, { config: { rateLimit: { max: 60, timeWindow: '1 hour' } } },
async (req, reply) => { async (req, reply) => {
const firmId = req.user!.firmId!;
const body = z const body = z
.object({ .object({
text: z.string().min(1).max(10_000), text: z.string().min(1).max(10_000),
@@ -234,7 +262,11 @@ Keep it under 300 words.`;
'Rewrite as a professional, warm message from a law firm to its client. Plain language, no legalese.', 'Rewrite as a professional, warm message from a law firm to its client. Plain language, no legalese.',
}; };
const firm = await loadFirm(firmId);
if (!firm) return reply.code(403).send({ error: 'firm_missing' });
try { try {
await assertAiQuota(firmId, firm.plan);
const result = await aiComplete({ const result = await aiComplete({
system: `${BASE_SYSTEM} system: `${BASE_SYSTEM}
${KIND_GUIDANCE[body.kind]} ${KIND_GUIDANCE[body.kind]}
@@ -242,6 +274,13 @@ Return ONLY the rewritten text — no preamble, no quotes, no commentary. Preser
content: body.text, content: body.text,
maxTokens: 800, maxTokens: 800,
}); });
await recordAiUsage({
firmId,
userId: req.user!.id,
feature: 'polish',
model: AI_MODEL,
usage: result.usage,
});
return { text: result.text, usage: result.usage }; return { text: result.text, usage: result.usage };
} catch (err) { } catch (err) {
return sendAiError(reply, err); return sendAiError(reply, err);
+6 -2
View File
@@ -11,7 +11,7 @@ import {
emailVerifications, emailVerifications,
sessions as sessionsTable, sessions as sessionsTable,
} from '@lawdesk/db'; } from '@lawdesk/db';
import { hashPassword, verifyPassword } from '../auth/password'; import { hashPassword, verifyPasswordSafe } from '../auth/password';
import { SESSION_COOKIE, createSession, destroySession } from '../auth/sessions'; import { SESSION_COOKIE, createSession, destroySession } from '../auth/sessions';
import { ensureSuperadminFlag } from '../auth/superadmin'; import { ensureSuperadminFlag } from '../auth/superadmin';
import { generateCsrfToken } from '../auth/csrf'; import { generateCsrfToken } from '../auth/csrf';
@@ -167,7 +167,9 @@ export async function authRoutes(app: FastifyInstance) {
const [user] = await db.select().from(users).where(eq(users.email, body.email)).limit(1); const [user] = await db.select().from(users).where(eq(users.email, body.email)).limit(1);
const ok = user ? await verifyPassword(user.passwordHash, body.password) : false; // Always run an argon2 verify — against the real hash if the account exists, else against a
// dummy hash — so both paths take equal time and can't be used to enumerate valid emails.
const ok = await verifyPasswordSafe(user?.passwordHash, body.password);
await db.insert(loginAttempts).values({ email: body.email, ip, success: ok }); await db.insert(loginAttempts).values({ email: body.email, ip, success: ok });
@@ -220,6 +222,7 @@ export async function authRoutes(app: FastifyInstance) {
app.get('/api/auth/me', async (req, reply) => { app.get('/api/auth/me', async (req, reply) => {
if (!req.user) return reply.code(401).send({ error: 'unauthorized' }); if (!req.user) return reply.code(401).send({ error: 'unauthorized' });
if (req.user.isSuspended) return reply.code(403).send({ error: 'account_suspended' });
return { user: req.user }; return { user: req.user };
}); });
@@ -361,6 +364,7 @@ export async function authRoutes(app: FastifyInstance) {
{ config: { rateLimit: { max: 3, timeWindow: '15 minutes' } } }, { config: { rateLimit: { max: 3, timeWindow: '15 minutes' } } },
async (req, reply) => { async (req, reply) => {
if (!req.user) return reply.code(401).send({ error: 'unauthorized' }); if (!req.user) return reply.code(401).send({ error: 'unauthorized' });
if (req.user.isSuspended) return reply.code(403).send({ error: 'account_suspended' });
const db = getDb(); const db = getDb();
const [user] = await db.select().from(users).where(eq(users.id, req.user.id)).limit(1); const [user] = await db.select().from(users).where(eq(users.id, req.user.id)).limit(1);
+25 -1
View File
@@ -6,6 +6,9 @@ import { and, desc, eq } from 'drizzle-orm';
import { getDb, documents, cases } from '@lawdesk/db'; import { getDb, documents, cases } from '@lawdesk/db';
import { saveFile, deleteFile, getObjectStream, FileNotFoundError } from '../lib/storage'; import { saveFile, deleteFile, getObjectStream, FileNotFoundError } from '../lib/storage';
import { verifyFileSignature } from '../lib/file-signature'; import { verifyFileSignature } from '../lib/file-signature';
import { loadFirm } from '../lib/firm';
import { PlanLimitError } from '../lib/plan-limits';
import { assertWithinStorageQuota, incrementStorageUsed } from '../lib/storage-quota';
const ALLOWED_MIME = new Set([ const ALLOWED_MIME = new Set([
'application/pdf', 'application/pdf',
@@ -74,6 +77,21 @@ export async function documentsRoutes(app: FastifyInstance) {
return reply.code(400).send({ error: 'file_content_mismatch' }); return reply.code(400).send({ error: 'file_content_mismatch' });
} }
const size = buf.length;
// Enforce the firm's per-plan storage quota before committing the object to storage,
// so a rejected upload leaves no orphaned Spaces object and never touches the counter.
const firm = await loadFirm(firmId);
if (!firm) return reply.code(403).send({ error: 'firm_missing' });
try {
await assertWithinStorageQuota(firmId, firm.plan, size);
} catch (e) {
if (e instanceof PlanLimitError) {
return reply.code(402).send({ error: e.message, plan: firm.plan });
}
throw e;
}
const docId = randomUUID(); const docId = randomUUID();
const ext = path.extname(data.filename); const ext = path.extname(data.filename);
const storageKey = `${firmId}/${caseId}/${docId}${ext}`; const storageKey = `${firmId}/${caseId}/${docId}${ext}`;
@@ -88,7 +106,7 @@ export async function documentsRoutes(app: FastifyInstance) {
name: data.filename, name: data.filename,
storageKey, storageKey,
mimeType: data.mimetype, mimeType: data.mimetype,
sizeBytes: buf.length, sizeBytes: size,
}).returning(); }).returning();
if (!doc) { if (!doc) {
@@ -97,6 +115,9 @@ export async function documentsRoutes(app: FastifyInstance) {
return reply.code(500).send({ error: 'upload_failed' }); return reply.code(500).send({ error: 'upload_failed' });
} }
// Row committed — account the stored bytes against the firm's quota.
await incrementStorageUsed(firmId, size);
return reply.code(201).send({ return reply.code(201).send({
id: doc.id, id: doc.id,
name: doc.name, name: doc.name,
@@ -149,6 +170,9 @@ export async function documentsRoutes(app: FastifyInstance) {
req.log.warn({ err, storageKey: doc.storageKey }, 'orphaned file after delete'); req.log.warn({ err, storageKey: doc.storageKey }, 'orphaned file after delete');
} }
// Row is gone — reclaim its bytes from the firm's tracked usage (clamped at 0).
await incrementStorageUsed(firmId, -doc.sizeBytes);
return reply.code(204).send(); return reply.code(204).send();
}); });
} }
+6 -3
View File
@@ -31,8 +31,8 @@ const createBody = z.object({
taxRate: z.coerce.number().min(0).max(100).default(0), taxRate: z.coerce.number().min(0).max(100).default(0),
dueAt: z.string().datetime().nullable().optional(), dueAt: z.string().datetime().nullable().optional(),
// Either provide explicit items or supply timeEntryIds to generate items from time entries. // Either provide explicit items or supply timeEntryIds to generate items from time entries.
items: z.array(itemBody).optional(), items: z.array(itemBody).max(200).optional(),
timeEntryIds: z.array(z.string().uuid()).optional(), timeEntryIds: z.array(z.string().uuid()).max(200).optional(),
}); });
const updateBody = z.object({ const updateBody = z.object({
@@ -548,7 +548,10 @@ export async function invoicesRoutes(app: FastifyInstance) {
}); });
// PDF download // PDF download
app.get('/api/invoices/:id/pdf', async (req, reply) => { app.get(
'/api/invoices/:id/pdf',
{ config: { rateLimit: { max: 60, timeWindow: '1 hour' } } },
async (req, reply) => {
const firmId = req.user!.firmId!; const firmId = req.user!.firmId!;
const { id } = z.object({ id: z.string().uuid() }).parse(req.params); const { id } = z.object({ id: z.string().uuid() }).parse(req.params);
const db = getDb(); const db = getDb();
+26 -2
View File
@@ -1,7 +1,7 @@
import type { FastifyInstance } from 'fastify'; import type { FastifyInstance } from 'fastify';
import type Stripe from 'stripe'; import type Stripe from 'stripe';
import { and, eq } from 'drizzle-orm'; import { and, eq } from 'drizzle-orm';
import { getDb, firms, users } from '@lawdesk/db'; import { getDb, firms, users, stripeEvents } from '@lawdesk/db';
import { env } from '../env'; import { env } from '../env';
import { getStripe } from '../lib/stripe'; import { getStripe } from '../lib/stripe';
import { import {
@@ -38,7 +38,27 @@ export async function stripeWebhookRoute(app: FastifyInstance) {
} }
try { try {
// Idempotency for Stripe's at-least-once delivery. Skip events we've already fully
// processed so retries don't re-send emails or re-write audit rows.
const [seen] = await getDb()
.select({ id: stripeEvents.id })
.from(stripeEvents)
.where(eq(stripeEvents.id, event.id))
.limit(1);
if (seen) {
app.log.info({ id: event.id, type: event.type }, 'stripe webhook duplicate event ignored');
return { received: true, duplicate: true };
}
await handleEvent(event, app); await handleEvent(event, app);
// Record only AFTER successful processing: a transient handler failure (→ 500 → Stripe
// retry) then re-processes instead of being skipped forever. applyPlan is idempotent, so
// the narrow check-then-insert race on truly concurrent redeliveries is harmless.
await getDb()
.insert(stripeEvents)
.values({ id: event.id, type: event.type })
.onConflictDoNothing();
} catch (err) { } catch (err) {
app.log.error({ err, type: event.type }, 'stripe webhook handler failed'); app.log.error({ err, type: event.type }, 'stripe webhook handler failed');
// Return 200 anyway for some failures? No — let Stripe retry on transient failures. // Return 200 anyway for some failures? No — let Stripe retry on transient failures.
@@ -65,7 +85,11 @@ async function handleEvent(event: Stripe.Event, app: FastifyInstance) {
typeof session.subscription === 'string' ? session.subscription : session.subscription?.id ?? null; typeof session.subscription === 'string' ? session.subscription : session.subscription?.id ?? null;
await applyPlan(firmId, plan, { customerId, subscriptionId }); await applyPlan(firmId, plan, { customerId, subscriptionId });
await sendPlanUpgradedNotice(firmId, plan); // Fire-and-forget: an email failure must not throw out of the handler (→ 500 → Stripe
// redelivery → duplicate processing). The plan (DB writes above) is already applied.
sendPlanUpgradedNotice(firmId, plan).catch((err) =>
app.log.warn({ err, firmId }, 'plan upgraded email failed'),
);
break; break;
} }
+55
View File
@@ -135,17 +135,72 @@ export async function buildServer() {
// Must NOT be `false`: the SPA fallback below calls reply.sendFile, which only exists when // 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 // @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. // non-/api route (e.g. /dashboard, emailed /reset-password links) 500s in production.
// Only paths WITH a file extension are served by the plugin (hashed assets, favicons,
// sitemap.xml, ...). Page-shaped requests — '/', '/blog/<slug>', '/blog/<slug>/' — fall
// through to the not-found handler below (allowedPath:false → reply.callNotFound()),
// which serves the matching prerendered index.html or the app shell with
// Cache-Control: no-cache. Without this, the plugin's directory-index handling serves
// prerendered HTML itself, stamped with the 1y immutable header above — meant only for
// hashed assets — so browsers/crawlers would pin a year-stale page after every deploy.
index: false,
allowedPath: (pathName) => path.extname(pathName) !== '',
}); });
// Public SPA route prefixes — keep in sync with apps/web/src/App.tsx routes.
const KNOWN_SPA_PREFIXES = ['/login', '/signup', '/forgot-password', '/reset-password', '/billing/', '/tools', '/blog', '/legal', '/app', '/admin'];
// SPA fallback: any non-/api path returns index.html. index.html itself must not be cached // 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. // for a year (unlike the hashed assets) or clients keep a stale app shell after each deploy.
// Known SPA paths get 200; anything else (including missing static files) still gets
// index.html — so the client renders its NotFound page — but with a 404 status so
// crawlers don't index junk URLs as soft-200s.
app.setNotFoundHandler((req, reply) => { app.setNotFoundHandler((req, reply) => {
if (req.raw.url?.startsWith('/api/')) { if (req.raw.url?.startsWith('/api/')) {
return reply.code(404).send({ error: 'not_found' }); return reply.code(404).send({ error: 'not_found' });
} }
const pathname = (req.raw.url ?? '').split('?')[0] ?? '';
// Prerendered pages: an extensionless path like /blog/some-post misses @fastify/static
// (no trailing slash → no directory index lookup) and lands here. If the build produced
// dist/blog/some-post/index.html, serve THAT file — crawlers must get the route-specific
// head tags, not the root app shell. Decode + resolve and require the result to stay
// inside webDist so encoded traversal (/..%2f..) can never escape the dist root.
let decodedPath: string | null = null;
try {
decodedPath = decodeURIComponent(pathname);
} catch {
decodedPath = null; // malformed percent-encoding → fall through to the SPA fallback
}
if (decodedPath && !decodedPath.includes('\0')) {
const relDir = decodedPath.replace(/^\/+/, '').replace(/\/+$/, '');
const distRoot = path.resolve(webDist);
const candidate = path.resolve(distRoot, relDir, 'index.html');
if (
relDir.length > 0 &&
candidate.startsWith(distRoot + path.sep) &&
fs.existsSync(candidate)
) {
return reply
.code(200)
.header('Cache-Control', 'no-cache')
.type('text/html')
.sendFile(path.relative(distRoot, candidate).split(path.sep).join('/'), webDist, {
cacheControl: false,
});
}
}
const isKnown =
pathname === '/' ||
KNOWN_SPA_PREFIXES.some((prefix) =>
prefix.endsWith('/')
? pathname.startsWith(prefix)
: pathname === prefix || pathname.startsWith(`${prefix}/`),
);
// cacheControl:false stops @fastify/static from stamping its own 1y immutable header // 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). // (which would otherwise override the no-cache below and pin a stale app shell).
return reply return reply
.code(isKnown ? 200 : 404)
.header('Cache-Control', 'no-cache') .header('Cache-Control', 'no-cache')
.type('text/html') .type('text/html')
.sendFile('index.html', webDist, { cacheControl: false }); .sendFile('index.html', webDist, { cacheControl: false });
+4 -1
View File
@@ -25,9 +25,12 @@ export const E2E_ENV: Record<string, string> = {
SPACES_BUCKET: 'e2e-bucket', SPACES_BUCKET: 'e2e-bucket',
SPACES_KEY: 'e2e-key', SPACES_KEY: 'e2e-key',
SPACES_SECRET: 'e2e-secret', SPACES_SECRET: 'e2e-secret',
// Empty → email sends are skipped, Stripe/Sentry stay inert. // Empty → email sends are skipped, Stripe/Sentry stay inert, Turnstile CAPTCHA and AI are
// disabled so auth tests submit without a captcha token and AI endpoints report "not configured".
SMTP2GO_API_KEY: '', SMTP2GO_API_KEY: '',
STRIPE_SECRET_KEY: '', STRIPE_SECRET_KEY: '',
STRIPE_WEBHOOK_SECRET: '', STRIPE_WEBHOOK_SECRET: '',
SENTRY_DSN_API: '', SENTRY_DSN_API: '',
TURNSTILE_SECRET_KEY: '',
ANTHROPIC_API_KEY: '',
}; };
+13 -2
View File
@@ -2,19 +2,30 @@
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="icon" type="image/png" sizes="512x512" href="/favicon.png" /> <link rel="icon" type="image/png" sizes="512x512" href="/favicon.png" />
<link rel="apple-touch-icon" href="/favicon.png" /> <link rel="apple-touch-icon" href="/favicon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#0052FF" /> <meta name="theme-color" content="#0052FF" />
<title>eLegal Software - All-in-One Practice Management for Law Firms</title> <title>eLegal Software Practice Management for Law Firms & Attorneys</title>
<meta <meta
name="description" name="description"
content="Streamline case management, billable hours, legal documents and invoicing in one secure platform built for attorneys." content="Streamline case management, billable hours, legal documents and invoicing in one secure platform built for attorneys."
/> />
<meta property="og:site_name" content="eLegal Software" />
<meta property="og:type" content="website" />
<meta property="og:url" content="https://elegalsoftware.com/" />
<meta property="og:title" content="eLegal Software — Practice Management for Law Firms & Attorneys" />
<meta
property="og:description"
content="Streamline case management, billable hours, legal documents and invoicing in one secure platform built for attorneys."
/>
<meta property="og:image" content="https://elegalsoftware.com/logo-dark.png" />
<meta name="twitter:card" content="summary" />
<link rel="preconnect" href="https://fonts.googleapis.com" /> <link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin /> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link <link
href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&family=Poppins:wght@500;600;700;800&display=swap" href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Poppins:wght@600;700&display=swap"
rel="stylesheet" rel="stylesheet"
/> />
</head> </head>
+1 -1
View File
@@ -5,7 +5,7 @@
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"build": "tsc -b && vite build", "build": "tsc -b && vite build && npx tsx scripts/prerender.mts && npx tsx scripts/generate-sitemap.mts",
"preview": "vite preview", "preview": "vite preview",
"typecheck": "tsc -b --noEmit" "typecheck": "tsc -b --noEmit"
}, },
+10
View File
@@ -0,0 +1,10 @@
User-agent: *
Allow: /
Disallow: /app
Disallow: /admin
Disallow: /api/
Disallow: /billing/
Disallow: /forgot-password
Disallow: /reset-password
Sitemap: https://elegalsoftware.com/sitemap.xml
+37
View File
@@ -0,0 +1,37 @@
// Generates dist/sitemap.xml from the public, indexable routes in src/seo/routes-meta.ts.
// Run after `vite build` (see the "build" script in package.json): npx tsx scripts/generate-sitemap.mts
// Paths are resolved from import.meta.url so the script works regardless of cwd.
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { SITEMAP_ROUTES, SITE_URL } from '../src/seo/routes-meta';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const distDir = path.resolve(__dirname, '../dist');
const outFile = path.join(distDir, 'sitemap.xml');
const lastmod = new Date().toISOString().slice(0, 10); // YYYY-MM-DD
const urlEntries = SITEMAP_ROUTES.map((route) => {
// Root must be the origin with a trailing slash: https://elegalsoftware.com/
const loc = route.path === '/' ? `${SITE_URL}/` : `${SITE_URL}${route.path}`;
return [
' <url>',
` <loc>${loc}</loc>`,
` <lastmod>${lastmod}</lastmod>`,
' </url>',
].join('\n');
});
const xml = [
'<?xml version="1.0" encoding="UTF-8"?>',
'<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">',
...urlEntries,
'</urlset>',
'',
].join('\n');
fs.mkdirSync(distDir, { recursive: true });
fs.writeFileSync(outFile, xml, 'utf8');
console.log(`sitemap: wrote ${SITEMAP_ROUTES.length} URLs to ${outFile}`);
+103
View File
@@ -0,0 +1,103 @@
// Prerenders every route in PRERENDER_ROUTES to static HTML under dist/, so crawlers
// receive full markup with per-route head tags without executing JS.
// Run AFTER `vite build` (see the "build" script in package.json): npx tsx scripts/prerender.mts
//
// How it works: a Vite dev server in middleware mode gives us ssrLoadModule — TSX,
// the '@/' alias, and CSS imports all resolve exactly as in the app build, so no
// separate SSR bundle is needed. The built dist/index.html is the template: its
// default <title>/description/og:/twitter: fallback tags are stripped (the per-route
// tags from renderHeadTags() would otherwise duplicate them), the route's head tags
// are injected before </head>, and the rendered app HTML is placed inside #root.
//
// NOTE: the client does NOT hydrate — main.tsx keeps createRoot().render(), which
// replaces the prerendered DOM on load. That is intentional (no mismatch risk).
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { createServer } from 'vite';
import type { RouteMeta } from '../src/seo/routes-meta';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const webRoot = path.resolve(__dirname, '..');
const distDir = path.join(webRoot, 'dist');
const templatePath = path.join(distDir, 'index.html');
function fail(msg: string): never {
console.error(`prerender: FAILED — ${msg}`);
process.exit(1);
}
if (!fs.existsSync(templatePath)) {
fail(`${templatePath} not found — run \`vite build\` first`);
}
const rawTemplate = fs.readFileSync(templatePath, 'utf8');
// Strip the template's default SEO fallback tags (title, meta description, og:*,
// twitter:*). renderHeadTags() emits the per-route versions of all of them; leaving
// the defaults in would give crawlers duplicate/conflicting tags. [^>]* also matches
// newlines, covering the multi-line <meta> formatting in index.html.
const template = rawTemplate
.replace(/[ \t]*<title>[\s\S]*?<\/title>\s*\n?/i, '')
.replace(/[ \t]*<meta[^>]*name="description"[^>]*>\s*\n?/gi, '')
.replace(/[ \t]*<meta[^>]*property="og:[^"]*"[^>]*>\s*\n?/gi, '')
.replace(/[ \t]*<meta[^>]*name="twitter:[^"]*"[^>]*>\s*\n?/gi, '');
if (/<title>|property="og:|name="twitter:/i.test(template)) {
fail('template still contains default <title>/og:/twitter: tags after stripping — index.html format changed?');
}
if (!template.includes('<div id="root"></div>')) {
fail('template is missing `<div id="root"></div>` — cannot inject app HTML');
}
// Middleware mode + appType 'custom' = no HTTP server, no HTML middlewares — we only
// want ssrLoadModule. Root is apps/web so vite.config.ts (alias, envDir) applies.
const vite = await createServer({
root: webRoot,
logLevel: 'error',
server: { middlewareMode: true },
appType: 'custom',
});
let exitCode = 0;
try {
const { render } = (await vite.ssrLoadModule('/src/entry-server.tsx')) as {
render: (url: string) => string;
};
const { PRERENDER_ROUTES, metaForPath, renderHeadTags } = (await vite.ssrLoadModule(
'/src/seo/routes-meta.ts',
)) as {
PRERENDER_ROUTES: string[];
metaForPath: (p: string) => RouteMeta | undefined;
renderHeadTags: (m: RouteMeta) => string;
};
if (PRERENDER_ROUTES.length === 0) fail('PRERENDER_ROUTES is empty');
for (const route of PRERENDER_ROUTES) {
try {
const meta = metaForPath(route);
if (!meta) throw new Error(`no meta registered for ${route}`);
const appHtml = render(route);
if (!appHtml.trim()) throw new Error('rendered app HTML is empty');
const doc = template
.replace('</head>', ` ${renderHeadTags(meta)}\n </head>`)
.replace('<div id="root"></div>', `<div id="root">${appHtml}</div>`);
const outFile =
route === '/' ? templatePath : path.join(distDir, ...route.slice(1).split('/'), 'index.html');
fs.mkdirSync(path.dirname(outFile), { recursive: true });
fs.writeFileSync(outFile, doc, 'utf8');
console.log(`prerender: ok ${route} -> ${path.relative(webRoot, outFile)}`);
} catch (err) {
exitCode = 1;
console.error(`prerender: ERROR rendering ${route}:`, err);
}
}
} finally {
await vite.close();
}
if (exitCode !== 0) fail('one or more routes failed (see errors above)');
console.log('prerender: all routes rendered');
+4 -1
View File
@@ -1,5 +1,7 @@
import { Route, Routes } from 'react-router-dom'; import { Route, Routes } from 'react-router-dom';
import { Seo } from '@/components/Seo';
import LandingPage from './pages/LandingPage'; import LandingPage from './pages/LandingPage';
import NotFoundPage from './pages/NotFoundPage';
import LoginPage from './pages/LoginPage'; import LoginPage from './pages/LoginPage';
import SignupPage from './pages/SignupPage'; import SignupPage from './pages/SignupPage';
import ForgotPasswordPage from './pages/ForgotPasswordPage'; import ForgotPasswordPage from './pages/ForgotPasswordPage';
@@ -44,6 +46,7 @@ import DpaPage from './pages/legal/DpaPage';
export default function App() { export default function App() {
return ( return (
<> <>
<Seo />
<Routes> <Routes>
<Route path="/" element={<LandingPage />} /> <Route path="/" element={<LandingPage />} />
<Route path="/login" element={<LoginPage />} /> <Route path="/login" element={<LoginPage />} />
@@ -94,7 +97,7 @@ export default function App() {
<Route path="audit" element={<AdminAuditPage />} /> <Route path="audit" element={<AdminAuditPage />} />
</Route> </Route>
<Route path="*" element={<LandingPage />} /> <Route path="*" element={<NotFoundPage />} />
</Routes> </Routes>
<CookieBanner /> <CookieBanner />
</> </>
+80
View File
@@ -0,0 +1,80 @@
import { useEffect } from 'react';
import { useLocation } from 'react-router-dom';
import {
metaForPath,
canonicalUrl,
NOT_FOUND_META,
DEFAULT_OG_IMAGE,
SITE_NAME,
type RouteMeta,
} from '@/seo/routes-meta';
// Mounted ONCE in App.tsx, above <Routes>. Keeps the document head in sync with the
// current route from the routes-meta map. Prerendered pages ship the same tags
// (stamped data-seo) baked into their static HTML; this component replaces them on
// client-side navigation so the two systems never fight.
function upsertMeta(attr: 'name' | 'property', key: string, content: string) {
let el = document.head.querySelector<HTMLMetaElement>(`meta[${attr}="${key}"]`);
if (!el) {
el = document.createElement('meta');
el.setAttribute(attr, key);
el.setAttribute('data-seo', '1');
document.head.appendChild(el);
}
el.setAttribute('content', content);
}
function removeMeta(attr: 'name' | 'property', key: string) {
document.head.querySelector(`meta[${attr}="${key}"]`)?.remove();
}
export function Seo() {
const { pathname } = useLocation();
useEffect(() => {
const meta: RouteMeta = metaForPath(pathname) ?? NOT_FOUND_META;
const url = canonicalUrl(meta.path || pathname);
document.title = meta.title;
upsertMeta('name', 'description', meta.description);
// Canonical for indexable pages; robots noindex otherwise (never both).
if (meta.noindex) {
document.head.querySelector('link[rel="canonical"]')?.remove();
upsertMeta('name', 'robots', 'noindex, nofollow');
} else {
removeMeta('name', 'robots');
let link = document.head.querySelector<HTMLLinkElement>('link[rel="canonical"]');
if (!link) {
link = document.createElement('link');
link.setAttribute('rel', 'canonical');
link.setAttribute('data-seo', '1');
document.head.appendChild(link);
}
link.setAttribute('href', url);
}
upsertMeta('property', 'og:site_name', SITE_NAME);
upsertMeta('property', 'og:type', meta.ogType ?? 'website');
upsertMeta('property', 'og:url', url);
upsertMeta('property', 'og:title', meta.title);
upsertMeta('property', 'og:description', meta.description);
upsertMeta('property', 'og:image', DEFAULT_OG_IMAGE);
upsertMeta('name', 'twitter:card', 'summary');
upsertMeta('name', 'twitter:title', meta.title);
upsertMeta('name', 'twitter:description', meta.description);
// JSON-LD: replace wholesale (covers both prerendered and prior-route scripts).
document.head.querySelectorAll('script[type="application/ld+json"]').forEach((s) => s.remove());
for (const ld of meta.jsonLd ?? []) {
const script = document.createElement('script');
script.type = 'application/ld+json';
script.setAttribute('data-seo', '1');
script.textContent = JSON.stringify(ld);
document.head.appendChild(script);
}
}, [pathname]);
return null;
}
@@ -35,7 +35,7 @@ export function BlogTeaser() {
{p.coverImage ? ( {p.coverImage ? (
<img <img
src={p.coverImage} src={p.coverImage}
alt="" alt={`Cover image for article: ${p.title}`}
loading="lazy" loading="lazy"
className="absolute inset-0 h-full w-full object-cover transition duration-300 group-hover:scale-[1.03]" className="absolute inset-0 h-full w-full object-cover transition duration-300 group-hover:scale-[1.03]"
/> />
+2 -27
View File
@@ -1,33 +1,8 @@
import { useState } from 'react'; import { useState } from 'react';
import { ChevronDown } from 'lucide-react'; import { ChevronDown } from 'lucide-react';
import { cn } from '@/lib/cn'; import { cn } from '@/lib/cn';
// FAQ content lives in routes-meta so the FAQPage JSON-LD stays in lockstep with the UI.
const ITEMS = [ import { FAQ_ITEMS as ITEMS } from '@/seo/routes-meta';
{
q: 'What makes eLegal Software different from other legal software?',
a: 'eLegal Software is built exclusively for legal professionals and bundles case management, billable-hours tracking, document storage, and invoicing into a single, secure platform — no jumping between tools, no per-feature add-ons.',
},
{
q: 'Can I try eLegal Software before committing?',
a: 'Yes. The Starter plan is free forever and lets you manage one active case and two clients so you can experience the workflow end-to-end before upgrading.',
},
{
q: 'How does client billing and payment processing work?',
a: 'You can convert any unbilled time entry into a polished invoice in seconds, send it to your client, and track its status from sent to paid. Payment processing is wired through Stripe.',
},
{
q: 'Can I import my existing cases and client data?',
a: 'Yes. eLegal Software supports CSV imports for clients and cases. For larger migrations our team will assist directly during onboarding.',
},
{
q: 'Is my client data secure and compliant?',
a: 'All data is encrypted at rest and in transit, hosted on enterprise-grade infrastructure with daily backups and audit logging. Access is gated by role-based permissions.',
},
{
q: 'What happens if I need to cancel?',
a: 'You can cancel anytime from your billing settings. Your data remains accessible during the current billing period and can be exported in standard formats.',
},
];
export function Faq() { export function Faq() {
const [open, setOpen] = useState<number | null>(0); const [open, setOpen] = useState<number | null>(0);
-6
View File
@@ -28,8 +28,6 @@ export const POSTS: Post[] = [
publishedAt: '2026-04-08', publishedAt: '2026-04-08',
readMinutes: 8, readMinutes: 8,
author: 'eLegal Software Team', author: 'eLegal Software Team',
coverImage:
'https://elegalsoftware.nyc3.digitaloceanspaces.com/media/blog/maximize-billable-hours-without-burnout.jpg',
body: [ body: [
{ {
type: 'p', type: 'p',
@@ -92,8 +90,6 @@ export const POSTS: Post[] = [
publishedAt: '2026-03-21', publishedAt: '2026-03-21',
readMinutes: 12, readMinutes: 12,
author: 'eLegal Software Team', author: 'eLegal Software Team',
coverImage:
'https://elegalsoftware.nyc3.digitaloceanspaces.com/media/blog/client-intake-best-practices-2026.jpg',
body: [ body: [
{ {
type: 'p', type: 'p',
@@ -162,8 +158,6 @@ export const POSTS: Post[] = [
publishedAt: '2026-02-14', publishedAt: '2026-02-14',
readMinutes: 10, readMinutes: 10,
author: 'eLegal Software Team', author: 'eLegal Software Team',
coverImage:
'https://elegalsoftware.nyc3.digitaloceanspaces.com/media/blog/legal-billing-software-comparison-2026.jpg',
body: [ body: [
{ {
type: 'p', type: 'p',
+32
View File
@@ -0,0 +1,32 @@
// Server-side rendering entry, used ONLY by scripts/prerender.mts at build time
// (loaded through Vite's ssrLoadModule — never shipped to the browser).
// Deliberately does NOT import main.tsx: that file initializes Sentry and calls
// createRoot() at module scope, both of which are browser-only concerns.
import ReactDOMServer from 'react-dom/server';
import { StaticRouter } from 'react-router-dom/server';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import App from './App';
/** Render the app for a given URL to an HTML string (no effects run, no data fetching). */
export function render(url: string): string {
// Fresh client per render so no cache state leaks between prerendered routes.
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
staleTime: Infinity,
},
},
});
try {
return ReactDOMServer.renderToString(
<QueryClientProvider client={queryClient}>
<StaticRouter location={url}>
<App />
</StaticRouter>
</QueryClientProvider>,
);
} finally {
queryClient.clear();
}
}
+11 -1
View File
@@ -15,6 +15,16 @@ const schema = z.object({
type FormValues = z.infer<typeof schema>; type FormValues = z.infer<typeof schema>;
// Guard against open-redirects: only accept same-origin internal paths like
// "/app" or "/app/cases". Reject protocol-relative ("//evil.com"), backslash
// tricks ("/\\evil.com"), and absolute URLs ("https://evil.com").
function safeNext(raw: string | null): string {
if (!raw || !raw.startsWith('/') || raw.startsWith('//') || raw.startsWith('/\\') || raw.includes('://')) {
return '/app';
}
return raw;
}
const ERROR_COPY: Record<string, string> = { const ERROR_COPY: Record<string, string> = {
invalid_credentials: 'Email or password is incorrect.', invalid_credentials: 'Email or password is incorrect.',
too_many_attempts: 'Too many attempts. Try again in a few minutes.', too_many_attempts: 'Too many attempts. Try again in a few minutes.',
@@ -54,7 +64,7 @@ export default function LoginPage() {
setCaptchaReset((n) => n + 1); setCaptchaReset((n) => n + 1);
return; return;
} }
const next = new URLSearchParams(location.search).get('next') ?? '/app'; const next = safeNext(new URLSearchParams(location.search).get('next'));
navigate(next, { replace: true }); navigate(next, { replace: true });
} }
+32
View File
@@ -0,0 +1,32 @@
import { Link } from 'react-router-dom';
import { PublicLayout } from '@/components/public/PublicLayout';
export default function NotFoundPage() {
return (
<PublicLayout>
<section className="container py-24 max-w-2xl text-center">
<p className="text-xs uppercase tracking-wider text-brand-600 font-semibold">404</p>
<h1 className="mt-2 text-3xl md:text-4xl font-bold text-ink-950 font-display">
Page not found
</h1>
<p className="mt-4 text-ink-600">
The page you're looking for doesn't exist or has moved.
</p>
<div className="mt-8 flex flex-wrap items-center justify-center gap-3">
<Link to="/" className="btn-primary text-sm">
Home
</Link>
<Link to="/tools" className="btn-secondary text-sm">
Free tools
</Link>
<Link to="/blog" className="btn-secondary text-sm">
Blog
</Link>
<Link to="/login" className="btn-ghost text-sm">
Sign in
</Link>
</div>
</section>
</PublicLayout>
);
}
+3 -1
View File
@@ -38,7 +38,9 @@ export default function BlogIndexPage() {
)} )}
</div> </div>
<div className="p-6 flex flex-col flex-1"> <div className="p-6 flex flex-col flex-1">
<p className="text-xs text-ink-500">{formatDate(p.publishedAt)}</p> <p className="text-xs text-ink-500">
<time dateTime={p.publishedAt}>{formatDate(p.publishedAt)}</time>
</p>
<h3 className="mt-2 text-lg font-semibold text-ink-900 group-hover:text-brand-700 transition leading-snug"> <h3 className="mt-2 text-lg font-semibold text-ink-900 group-hover:text-brand-700 transition leading-snug">
{p.title} {p.title}
</h3> </h3>
+1 -1
View File
@@ -39,7 +39,7 @@ export default function BlogPostPage() {
<div className="mt-6 flex items-center gap-3 text-sm text-ink-500"> <div className="mt-6 flex items-center gap-3 text-sm text-ink-500">
<span>{post.author}</span> <span>{post.author}</span>
<span className="text-ink-300">·</span> <span className="text-ink-300">·</span>
<span>{formatDate(post.publishedAt)}</span> <time dateTime={post.publishedAt}>{formatDate(post.publishedAt)}</time>
<span className="text-ink-300">·</span> <span className="text-ink-300">·</span>
<span className="inline-flex items-center gap-1.5"> <span className="inline-flex items-center gap-1.5">
<Clock className="h-3.5 w-3.5" /> <Clock className="h-3.5 w-3.5" />
+316
View File
@@ -0,0 +1,316 @@
// Single source of truth for public-route SEO metadata.
// Consumed by three things — keep them in mind when editing:
// 1. <Seo /> (client) — updates document head on navigation
// 2. scripts/prerender.mts — injects head tags into static HTML at build time
// 3. scripts/generate-sitemap.mts — emits sitemap.xml for indexable routes
// IMPORTANT: imports here must stay RELATIVE (no '@/' alias) and side-effect-free,
// because the build scripts execute this module under tsx/node outside Vite.
import { POSTS } from '../content/posts';
export const SITE_URL = 'https://elegalsoftware.com';
export const SITE_NAME = 'eLegal Software';
export const DEFAULT_OG_IMAGE = `${SITE_URL}/logo-dark.png`;
// FAQ content lives here (not in Faq.tsx) so the FAQPage JSON-LD and the rendered
// accordion can never drift apart. Faq.tsx imports this.
export const FAQ_ITEMS = [
{
q: 'What makes eLegal Software different from other legal software?',
a: 'eLegal Software is built exclusively for legal professionals and bundles case management, billable-hours tracking, document storage, and invoicing into a single, secure platform — no jumping between tools, no per-feature add-ons.',
},
{
q: 'Can I try eLegal Software before committing?',
a: 'Yes. The Starter plan is free forever and lets you manage one active case and two clients so you can experience the workflow end-to-end before upgrading.',
},
{
q: 'How does client billing and payment processing work?',
a: 'You can convert any unbilled time entry into a polished invoice in seconds, send it to your client, and track its status from sent to paid. Payment processing is wired through Stripe.',
},
{
q: 'Can I import my existing cases and client data?',
a: 'Yes. eLegal Software supports CSV imports for clients and cases. For larger migrations our team will assist directly during onboarding.',
},
{
q: 'Is my client data secure and compliant?',
a: 'All data is encrypted at rest and in transit, hosted on enterprise-grade infrastructure with daily backups and audit logging. Access is gated by role-based permissions.',
},
{
q: 'What happens if I need to cancel?',
a: 'You can cancel anytime from your billing settings. Your data remains accessible during the current billing period and can be exported in standard formats.',
},
];
export interface RouteMeta {
path: string;
title: string;
description: string;
noindex?: boolean;
ogType?: 'website' | 'article';
jsonLd?: Record<string, unknown>[];
}
const ORGANIZATION_LD = {
'@context': 'https://schema.org',
'@type': 'Organization',
name: SITE_NAME,
url: SITE_URL,
logo: `${SITE_URL}/logo-dark.png`,
contactPoint: {
'@type': 'ContactPoint',
email: 'contact@elegalsoftware.com',
contactType: 'customer support',
},
};
const SOFTWARE_LD = {
'@context': 'https://schema.org',
'@type': 'SoftwareApplication',
name: SITE_NAME,
applicationCategory: 'BusinessApplication',
operatingSystem: 'Web',
url: SITE_URL,
description:
'All-in-one practice management for law firms: case management, billable-hours tracking, secure document storage, and invoicing.',
offers: [
{ '@type': 'Offer', name: 'Starter', price: '0', priceCurrency: 'USD' },
{ '@type': 'Offer', name: 'Professional', price: '25', priceCurrency: 'USD' },
{ '@type': 'Offer', name: 'Lifetime', price: '129', priceCurrency: 'USD' },
],
};
const FAQ_LD = {
'@context': 'https://schema.org',
'@type': 'FAQPage',
mainEntity: FAQ_ITEMS.map((item) => ({
'@type': 'Question',
name: item.q,
acceptedAnswer: { '@type': 'Answer', text: item.a },
})),
};
const STATIC_ROUTES: RouteMeta[] = [
{
path: '/',
title: 'eLegal Software — Practice Management for Law Firms & Attorneys',
description:
'Streamline case management, billable hours, legal documents and invoicing in one secure platform built for attorneys. Free to start — no credit card required.',
jsonLd: [ORGANIZATION_LD, SOFTWARE_LD, FAQ_LD],
},
{
path: '/login',
title: 'Sign In — eLegal Software',
description: 'Log in to eLegal Software to manage your cases, billable hours, and invoices.',
},
{
path: '/signup',
title: 'Start Your Free Trial — eLegal Software',
description:
'Create your free eLegal Software account in under a minute. Manage cases, track billable hours, and send invoices — no credit card required.',
},
{
path: '/forgot-password',
title: 'Reset Your Password — eLegal Software',
description: 'Request a password reset link for your eLegal Software account.',
noindex: true,
},
{
path: '/reset-password',
title: 'Choose a New Password — eLegal Software',
description: 'Set a new password for your eLegal Software account.',
noindex: true,
},
{
path: '/billing/success',
title: 'Payment Successful — eLegal Software',
description: 'Your eLegal Software subscription is active.',
noindex: true,
},
{
path: '/billing/cancel',
title: 'Checkout Canceled — eLegal Software',
description: 'Your checkout was canceled — no charge was made.',
noindex: true,
},
{
path: '/tools',
title: 'Free Tools for Attorneys & Law Firms — eLegal Software',
description:
'Free calculators and tools for legal professionals: hourly rate calculator, case profitability analyzer, billable hours tracker, and document templates.',
},
{
path: '/tools/hourly-rate-calculator',
title: 'Attorney Hourly Rate Calculator (Free) — eLegal Software',
description:
'Work out the hourly rate your practice actually needs — factoring target income, billable utilization, overhead, and taxes. Free, no signup required.',
},
{
path: '/tools/case-profitability',
title: 'Case Profitability Analyzer for Law Firms (Free) — eLegal Software',
description:
'Analyze whether a case or matter is profitable: fees, hours, effective rate, and margin — before and after write-offs. Free tool for attorneys.',
},
{
path: '/tools/billable-hours-tracker',
title: 'Free Billable Hours Tracker for Attorneys — eLegal Software',
description:
'Track billable time in your browser with a running timer and daily target — then see what those hours are worth. Free, no signup required.',
},
{
path: '/tools/document-templates',
title: 'Free Legal Document Templates for Small Firms — eLegal Software',
description:
'Starting points for engagement letters, intake forms, demand letters, and more. Copy, adapt with your attorney, and use in your practice.',
},
{
path: '/blog',
title: 'Legal Practice Management Blog — eLegal Software',
description:
'Practical guides for running a profitable law practice: billable hours, client intake, billing software, and firm operations.',
},
{
path: '/legal',
title: 'Legal Center — eLegal Software',
description:
'Every document that governs your use of eLegal Software: terms, privacy, billing, acceptable use, DMCA, and data processing.',
},
{
path: '/legal/terms',
title: 'Terms of Service — eLegal Software',
description: 'The agreement that governs your use of eLegal Software.',
},
{
path: '/legal/privacy',
title: 'Privacy Policy — eLegal Software',
description: 'What eLegal Software collects, why, where it lives, and the rights you have over it.',
},
{
path: '/legal/cookies',
title: 'Cookie Policy — eLegal Software',
description: 'The essential-only cookies eLegal Software sets and how to control them.',
},
{
path: '/legal/acceptable-use',
title: 'Acceptable Use Policy — eLegal Software',
description: 'What you may not do on the eLegal Software platform.',
},
{
path: '/legal/refunds',
title: 'Billing & Refund Policy — eLegal Software',
description: 'How subscriptions, renewals, cancellations, and refunds work at eLegal Software.',
},
{
path: '/legal/disclaimer',
title: 'Legal Disclaimer — eLegal Software',
description: 'eLegal Software is software, not a law firm — no legal advice, no attorney-client relationship.',
},
{
path: '/legal/dmca',
title: 'DMCA & Copyright Policy — eLegal Software',
description: 'How to report copyright infringement on eLegal Software, and how counter-notices work.',
},
{
path: '/legal/dpa',
title: 'Data Processing Addendum — eLegal Software',
description: 'How eLegal Software processes practice data on your behalf: security, subprocessors, breach notice.',
},
{
path: '/app',
title: 'Dashboard — eLegal Software',
description: 'Your eLegal Software workspace.',
noindex: true,
},
{
path: '/admin',
title: 'Admin — eLegal Software',
description: 'eLegal Software administration.',
noindex: true,
},
];
const BLOG_ROUTES: RouteMeta[] = POSTS.map((post) => ({
path: `/blog/${post.slug}`,
title: `${post.title}${SITE_NAME}`,
description: post.description,
ogType: 'article' as const,
jsonLd: [
{
'@context': 'https://schema.org',
'@type': 'BlogPosting',
headline: post.title,
description: post.description,
datePublished: post.publishedAt,
author: { '@type': 'Organization', name: SITE_NAME, url: SITE_URL },
publisher: { '@type': 'Organization', name: SITE_NAME, logo: { '@type': 'ImageObject', url: `${SITE_URL}/logo-dark.png` } },
mainEntityOfPage: `${SITE_URL}/blog/${post.slug}`,
},
],
}));
export const ALL_ROUTES: RouteMeta[] = [...STATIC_ROUTES, ...BLOG_ROUTES];
/** Routes to prerender to static HTML at build time (everything public & static). */
export const PRERENDER_ROUTES: string[] = ALL_ROUTES.filter(
(r) => r.path !== '/app' && r.path !== '/admin' && r.path !== '/reset-password',
).map((r) => r.path);
/** Routes that belong in sitemap.xml (public and indexable). */
export const SITEMAP_ROUTES: RouteMeta[] = ALL_ROUTES.filter((r) => !r.noindex);
export function metaForPath(pathname: string): RouteMeta | undefined {
const clean = pathname !== '/' && pathname.endsWith('/') ? pathname.slice(0, -1) : pathname;
const exact = ALL_ROUTES.find((r) => r.path === clean);
if (exact) return exact;
// Authed sections: any nested path inherits the section's noindex meta.
if (clean.startsWith('/app/')) return ALL_ROUTES.find((r) => r.path === '/app');
if (clean.startsWith('/admin/')) return ALL_ROUTES.find((r) => r.path === '/admin');
return undefined; // unknown → <Seo /> falls back to a noindex not-found meta
}
export const NOT_FOUND_META: RouteMeta = {
path: '',
title: 'Page Not Found — eLegal Software',
description: 'The page you were looking for does not exist.',
noindex: true,
};
// ── Static head rendering (used by scripts/prerender.mts) ──────────────────
// Every generated tag carries data-seo so the client <Seo /> can replace them
// wholesale on navigation without duplicating.
function escapeHtml(s: string): string {
return s
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
export function canonicalUrl(path: string): string {
return path === '/' ? `${SITE_URL}/` : `${SITE_URL}${path}`;
}
export function renderHeadTags(meta: RouteMeta): string {
const url = canonicalUrl(meta.path);
const t = escapeHtml(meta.title);
const d = escapeHtml(meta.description);
const tags = [
`<title>${t}</title>`,
`<meta name="description" content="${d}" data-seo="1">`,
meta.noindex
? `<meta name="robots" content="noindex, nofollow" data-seo="1">`
: `<link rel="canonical" href="${url}" data-seo="1">`,
`<meta property="og:site_name" content="${escapeHtml(SITE_NAME)}" data-seo="1">`,
`<meta property="og:type" content="${meta.ogType ?? 'website'}" data-seo="1">`,
`<meta property="og:url" content="${url}" data-seo="1">`,
`<meta property="og:title" content="${t}" data-seo="1">`,
`<meta property="og:description" content="${d}" data-seo="1">`,
`<meta property="og:image" content="${DEFAULT_OG_IMAGE}" data-seo="1">`,
`<meta name="twitter:card" content="summary" data-seo="1">`,
`<meta name="twitter:title" content="${t}" data-seo="1">`,
`<meta name="twitter:description" content="${d}" data-seo="1">`,
...(meta.jsonLd ?? []).map(
(ld) => `<script type="application/ld+json" data-seo="1">${JSON.stringify(ld)}</script>`,
),
];
return tags.join('\n ');
}
+10
View File
@@ -17,6 +17,16 @@ services:
VITE_TURNSTILE_SITE_KEY: ${VITE_TURNSTILE_SITE_KEY:-} VITE_TURNSTILE_SITE_KEY: ${VITE_TURNSTILE_SITE_KEY:-}
VITE_SENTRY_DSN: ${VITE_SENTRY_DSN:-} VITE_SENTRY_DSN: ${VITE_SENTRY_DSN:-}
restart: unless-stopped restart: unless-stopped
# Resource guardrails so a memory leak or an upload/AI spike can't OOM the whole
# Dokploy host (a shared Swarm node). Dokploy applies Compose via Swarm-style deploy,
# so the limits/reservations below are the effective form. Tune the values as needed.
deploy:
resources:
limits:
cpus: "1.0"
memory: 1g
reservations:
memory: 512m
environment: environment:
NODE_ENV: production NODE_ENV: production
PORT: 8080 PORT: 8080
+622 -380
View File
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,25 @@
CREATE TABLE IF NOT EXISTS "ai_usage" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"firm_id" uuid NOT NULL,
"user_id" uuid,
"feature" text NOT NULL,
"model" text NOT NULL,
"input_tokens" integer DEFAULT 0 NOT NULL,
"output_tokens" integer DEFAULT 0 NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE IF NOT EXISTS "stripe_events" (
"id" text PRIMARY KEY NOT NULL,
"type" text NOT NULL,
"processed_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
ALTER TABLE "firms" ALTER COLUMN "storage_bytes_used" SET DATA TYPE bigint;--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "ai_usage" ADD CONSTRAINT "ai_usage_firm_id_firms_id_fk" FOREIGN KEY ("firm_id") REFERENCES "public"."firms"("id") ON DELETE cascade ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "ai_usage_firm_idx" ON "ai_usage" USING btree ("firm_id","created_at");
File diff suppressed because it is too large Load Diff
@@ -15,6 +15,13 @@
"when": 1777169307877, "when": 1777169307877,
"tag": "0001_orange_jamie_braddock", "tag": "0001_orange_jamie_braddock",
"breakpoints": true "breakpoints": true
},
{
"idx": 2,
"version": "7",
"when": 1784308478719,
"tag": "0002_daily_chronomancer",
"breakpoints": true
} }
] ]
} }
+2 -2
View File
@@ -18,12 +18,12 @@
}, },
"dependencies": { "dependencies": {
"dotenv": "^16.4.5", "dotenv": "^16.4.5",
"drizzle-orm": "^0.36.4", "drizzle-orm": "^0.45.2",
"pg": "^8.13.1" "pg": "^8.13.1"
}, },
"devDependencies": { "devDependencies": {
"@types/pg": "^8.11.10", "@types/pg": "^8.11.10",
"drizzle-kit": "^0.28.1", "drizzle-kit": "^0.31.10",
"tsx": "^4.19.2", "tsx": "^4.19.2",
"typescript": "^5.6.3" "typescript": "^5.6.3"
} }
+3 -2
View File
@@ -1,4 +1,4 @@
import { pgTable, uuid, text, timestamp, boolean, integer } from 'drizzle-orm/pg-core'; import { pgTable, uuid, text, timestamp, boolean, bigint } from 'drizzle-orm/pg-core';
export const firms = pgTable('firms', { export const firms = pgTable('firms', {
id: uuid('id').defaultRandom().primaryKey(), id: uuid('id').defaultRandom().primaryKey(),
@@ -7,7 +7,8 @@ export const firms = pgTable('firms', {
.notNull() .notNull()
.default('starter'), .default('starter'),
watermarkEnabled: boolean('watermark_enabled').notNull().default(true), watermarkEnabled: boolean('watermark_enabled').notNull().default(true),
storageBytesUsed: integer('storage_bytes_used').notNull().default(0), // bigint (not integer): plan quotas reach 50 GB, well past the ~2.1 GB int4 ceiling.
storageBytesUsed: bigint('storage_bytes_used', { mode: 'number' }).notNull().default(0),
stripeCustomerId: text('stripe_customer_id'), stripeCustomerId: text('stripe_customer_id'),
stripeSubscriptionId: text('stripe_subscription_id'), stripeSubscriptionId: text('stripe_subscription_id'),
trialEndsAt: timestamp('trial_ends_at', { withTimezone: true }), trialEndsAt: timestamp('trial_ends_at', { withTimezone: true }),
+31 -1
View File
@@ -1,4 +1,5 @@
import { pgTable, uuid, text, timestamp, inet, index } from 'drizzle-orm/pg-core'; import { pgTable, uuid, text, timestamp, inet, integer, index } from 'drizzle-orm/pg-core';
import { firms } from './firms';
export const contactMessages = pgTable('contact_messages', { export const contactMessages = pgTable('contact_messages', {
id: uuid('id').defaultRandom().primaryKey(), id: uuid('id').defaultRandom().primaryKey(),
@@ -23,3 +24,32 @@ export const toolUsage = pgTable(
toolIdx: index('tool_usage_tool_idx').on(t.tool, t.createdAt), toolIdx: index('tool_usage_tool_idx').on(t.tool, t.createdAt),
}), }),
); );
// Per-firm AI usage ledger — enables monthly token quotas and cost accounting.
// One row per successful AI completion; aggregated over the current month for quota checks.
export const aiUsage = pgTable(
'ai_usage',
{
id: uuid('id').defaultRandom().primaryKey(),
firmId: uuid('firm_id')
.notNull()
.references(() => firms.id, { onDelete: 'cascade' }),
userId: uuid('user_id'),
feature: text('feature').notNull(), // 'case_summary' | 'document_summary' | 'polish'
model: text('model').notNull(),
inputTokens: integer('input_tokens').notNull().default(0),
outputTokens: integer('output_tokens').notNull().default(0),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
},
(t) => ({
firmIdx: index('ai_usage_firm_idx').on(t.firmId, t.createdAt),
}),
);
// Stripe webhook idempotency ledger. The event id is the PK; an INSERT that hits the
// unique constraint means we've already processed this event and can skip re-running side effects.
export const stripeEvents = pgTable('stripe_events', {
id: text('id').primaryKey(), // Stripe event.id
type: text('type').notNull(),
processedAt: timestamp('processed_at', { withTimezone: true }).notNull().defaultNow(),
});
+38 -1
View File
@@ -93,7 +93,44 @@ function slug(s: string): string {
// ─── Safety guard ───────────────────────────────────────────────────────────── // ─── Safety guard ─────────────────────────────────────────────────────────────
// This inserts 10 demo firms whose owner logins all use the public password `Demo1234!` // This inserts 10 demo firms whose owner logins all use the public password `Demo1234!`
// into whatever DATABASE_URL points at — which for this project is the PRODUCTION database. // into whatever DATABASE_URL points at — which for this project is the PRODUCTION database.
// Require an explicit opt-in so it can never run by accident.
// Hard stop: demo data must NEVER be seeded into production. This runs FIRST and cannot be
// overridden by ALLOW_SEED — a single env opt-in is too weak a guard for planting
// known-credential login accounts in prod.
{
const dbHost = (() => {
try {
return new URL(process.env.DATABASE_URL ?? '').host || 'unknown';
} catch {
return 'unknown';
}
})();
// Required refusal: never seed when running in a production environment.
if (process.env.NODE_ENV === 'production') {
console.error(
'Refusing to seed: NODE_ENV=production.\n' +
'seed-demo.ts creates ~10 demo owner accounts with the public password "Demo1234!".\n' +
'Demo data must NEVER be seeded into production under any circumstances.\n' +
'This refusal is absolute and cannot be overridden with ALLOW_SEED.\n' +
`Target database host: ${dbHost}`,
);
process.exit(1);
}
// Belt-and-suspenders: also refuse if DATABASE_URL points at the known production DB host,
// even if NODE_ENV was left unset. Production Postgres lives on DigitalOcean managed DBs.
if (dbHost.endsWith('.db.ondigitalocean.com')) {
console.error(
`Refusing to seed: DATABASE_URL host "${dbHost}" is the production database.\n` +
'seed-demo.ts plants known-credential demo accounts and must never touch production.\n' +
'This refusal cannot be overridden with ALLOW_SEED.',
);
process.exit(1);
}
}
// Require an explicit opt-in so it can never run by accident (second gate, non-production only).
if (process.env.ALLOW_SEED !== '1') { if (process.env.ALLOW_SEED !== '1') {
const host = (() => { const host = (() => {
try { try {