Dokploy deploy: Dockerfile, DB CA cert, blog images, CSP
CI / build-and-test (push) Has been cancelled

- Add Dockerfile (multi-stage Node 20), .dockerignore, docker-compose.yml, and
  DEPLOY-DOKPLOY.md for container deployment on Dokploy.
- Commit the DigitalOcean managed-Postgres Project CA cert (certs/ca-certificate.crt)
  so production TLS verification (fail-closed) works in-container. Public CA, safe to commit.
- Blog cover images served from DO Spaces; allow *.digitaloceanspaces.com in the prod CSP img-src.
- Includes the AI (case summaries) and Cloudflare Turnstile bot-protection features.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Leon Serfaty
2026-07-17 13:04:18 -04:00
co-authored by Claude Fable 5
parent d9b807662a
commit d1d96e4dd2
29 changed files with 1224 additions and 28 deletions
+1
View File
@@ -14,6 +14,7 @@
"typecheck": "tsc -p tsconfig.json --noEmit"
},
"dependencies": {
"@anthropic-ai/sdk": "^0.111.0",
"@aws-sdk/client-s3": "^3.1088.0",
"@aws-sdk/s3-request-presigner": "^3.1088.0",
"@fastify/cookie": "^11.0.1",
+2
View File
@@ -30,6 +30,8 @@ const envSchema = z.object({
// 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(''),
ANTHROPIC_API_KEY: z.string().optional().default(''),
TURNSTILE_SECRET_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(''),
+103
View File
@@ -0,0 +1,103 @@
// AI layer — Anthropic Claude. All platform AI features go through this module so the
// model choice, error mapping, and enablement check live in one place.
//
// Model: claude-haiku-4-5 — the cheapest current Claude model ($1/M input, $5/M output),
// chosen deliberately for cost; swap AI_MODEL to a bigger model if quality needs grow.
import Anthropic from '@anthropic-ai/sdk';
import { env } from '../env';
export const AI_MODEL = 'claude-haiku-4-5';
let _client: Anthropic | null = null;
function getClient(): Anthropic | null {
if (!env.ANTHROPIC_API_KEY) return null;
if (!_client) _client = new Anthropic({ apiKey: env.ANTHROPIC_API_KEY });
return _client;
}
export function isAiEnabled(): boolean {
return Boolean(env.ANTHROPIC_API_KEY);
}
export class AiDisabledError extends Error {
constructor() {
super('ai_not_configured');
this.name = 'AiDisabledError';
}
}
export class AiUnavailableError extends Error {
constructor(
message: string,
public retryable: boolean,
) {
super(message);
this.name = 'AiUnavailableError';
}
}
export interface AiUsage {
inputTokens: number;
outputTokens: number;
}
export interface AiResult {
text: string;
usage: AiUsage;
}
/**
* Single-turn completion. `content` is either plain text or prebuilt content blocks
* (e.g. a document block for PDF summarization).
*/
export async function aiComplete(opts: {
system: string;
content: string | Anthropic.ContentBlockParam[];
maxTokens?: number;
}): Promise<AiResult> {
const client = getClient();
if (!client) throw new AiDisabledError();
let response: Anthropic.Message;
try {
response = await client.messages.create({
model: AI_MODEL,
max_tokens: opts.maxTokens ?? 1500,
system: opts.system,
messages: [{ role: 'user', content: opts.content }],
});
} catch (err) {
if (err instanceof Anthropic.RateLimitError) {
throw new AiUnavailableError('ai_rate_limited', true);
}
if (err instanceof Anthropic.APIConnectionError) {
throw new AiUnavailableError('ai_connection_failed', true);
}
if (err instanceof Anthropic.APIError) {
// 4xx: our request is malformed (e.g. unsupported document) — not retryable.
// 5xx/overloaded: transient.
const retryable = typeof err.status === 'number' ? err.status >= 500 : true;
throw new AiUnavailableError(`ai_error_${err.status ?? 'unknown'}`, retryable);
}
throw err;
}
if (response.stop_reason === 'refusal') {
throw new AiUnavailableError('ai_refused', false);
}
const text = response.content
.filter((b): b is Anthropic.TextBlock => b.type === 'text')
.map((b) => b.text)
.join('\n')
.trim();
return {
text,
usage: {
inputTokens: response.usage.input_tokens,
outputTokens: response.usage.output_tokens,
},
};
}
+47
View File
@@ -0,0 +1,47 @@
// Cloudflare Turnstile server-side verification. Protects the public, abuse-prone
// endpoints (signup, login, password reset, contact form) from bots.
// With TURNSTILE_SECRET_KEY unset (local dev), verification is skipped entirely.
import { env } from '../env';
const VERIFY_URL = 'https://challenges.cloudflare.com/turnstile/v0/siteverify';
export function isTurnstileEnabled(): boolean {
return Boolean(env.TURNSTILE_SECRET_KEY);
}
interface SiteverifyResponse {
success: boolean;
'error-codes'?: string[];
}
/**
* Verifies a Turnstile token. Returns true when the check passes or Turnstile is
* disabled. Tokens are single-use — the widget must be reset after a failed submit.
*/
export async function verifyTurnstile(
token: string | undefined | null,
ip?: string | null,
): Promise<boolean> {
if (!isTurnstileEnabled()) return true;
if (!token) return false;
const body = new URLSearchParams({
secret: env.TURNSTILE_SECRET_KEY,
response: token,
});
if (ip) body.set('remoteip', ip);
try {
const res = await fetch(VERIFY_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body,
});
const json = (await res.json()) as SiteverifyResponse;
return json.success === true;
} catch {
// Cloudflare unreachable — fail closed. The client shows "try again" and the
// widget issues a fresh token on retry.
return false;
}
}
+251
View File
@@ -0,0 +1,251 @@
import type { FastifyInstance, FastifyReply } from 'fastify';
import { z } from 'zod';
import { and, desc, eq } from 'drizzle-orm';
import { getDb, cases, clients, timeEntries, documents, invoices } from '@lawdesk/db';
import { aiComplete, isAiEnabled, AiDisabledError, AiUnavailableError, AI_MODEL } from '../lib/ai';
import { getObjectStream, FileNotFoundError } from '../lib/storage';
// The one non-negotiable framing for a legal-tech product: the model assists with
// organization and drafting; it must never present itself as giving legal advice.
const BASE_SYSTEM = `You are an assistant inside eLegal Software, a practice-management tool used by attorneys.
You help organize and summarize the firm's own records. You do not give legal advice, cite law, or predict case outcomes.
Be factual and concise. Only state what is supported by the provided material; if something is unclear or missing, say so plainly.`;
const AI_DISCLAIMER =
'AI-generated from your case records — review for accuracy. Not legal advice.';
// Document types we can hand to the model directly.
const AI_MIME = {
pdf: 'application/pdf',
text: 'text/plain',
images: new Set(['image/jpeg', 'image/png', 'image/webp']),
} as const;
const MAX_AI_DOC_BYTES = 15 * 1024 * 1024; // base64 expansion must stay under the 32MB request cap
function sendAiError(reply: FastifyReply, err: unknown): FastifyReply {
if (err instanceof AiDisabledError) {
return reply.code(503).send({ error: 'ai_not_configured' });
}
if (err instanceof AiUnavailableError) {
return reply.code(err.retryable ? 503 : 422).send({ error: err.message });
}
throw err;
}
async function streamToBuffer(stream: NodeJS.ReadableStream): Promise<Buffer> {
const chunks: Buffer[] = [];
for await (const chunk of stream as AsyncIterable<Buffer | string>) {
chunks.push(typeof chunk === 'string' ? Buffer.from(chunk) : chunk);
}
return Buffer.concat(chunks);
}
export async function aiRoutes(app: FastifyInstance) {
app.addHook('preHandler', app.requireFirm);
// Lets the UI decide whether to render AI affordances at all.
app.get('/api/ai/status', async () => ({ enabled: isAiEnabled(), model: AI_MODEL }));
// ── Case summary ─────────────────────────────────────────────────────────
// Builds a brief from everything the firm has recorded on a case.
app.post(
'/api/cases/:id/ai/summary',
{ config: { rateLimit: { max: 30, timeWindow: '1 hour' } } },
async (req, reply) => {
const firmId = req.user!.firmId!;
const { id } = z.object({ id: z.string().uuid() }).parse(req.params);
const db = getDb();
const [c] = await db
.select()
.from(cases)
.where(and(eq(cases.id, id), eq(cases.firmId, firmId)))
.limit(1);
if (!c) return reply.code(404).send({ error: 'not_found' });
const [client] = await db
.select({ name: clients.name })
.from(clients)
.where(eq(clients.id, c.clientId))
.limit(1);
const entries = await db
.select({
description: timeEntries.description,
minutes: timeEntries.minutes,
billable: timeEntries.billable,
startedAt: timeEntries.startedAt,
})
.from(timeEntries)
.where(and(eq(timeEntries.caseId, id), eq(timeEntries.firmId, firmId)))
.orderBy(desc(timeEntries.startedAt))
.limit(50);
const docs = await db
.select({ name: documents.name, createdAt: documents.createdAt })
.from(documents)
.where(and(eq(documents.caseId, id), eq(documents.firmId, firmId)))
.orderBy(desc(documents.createdAt))
.limit(50);
const caseInvoices = await db
.select({ number: invoices.number, status: invoices.status, total: invoices.total })
.from(invoices)
.where(and(eq(invoices.caseId, id), eq(invoices.firmId, firmId)));
const fmtDate = (d: Date | null) => (d ? d.toISOString().slice(0, 10) : 'n/a');
const context = [
`CASE: ${c.title}${c.caseNumber ? ` (No. ${c.caseNumber})` : ''}`,
`Client: ${client?.name ?? 'unknown'} · Status: ${c.status} · Practice area: ${c.practiceArea ?? 'n/a'}`,
`Opened: ${fmtDate(c.openedAt)}${c.closedAt ? ` · Closed: ${fmtDate(c.closedAt)}` : ''}`,
c.description ? `Description: ${c.description}` : '',
'',
`TIME ENTRIES (most recent ${entries.length}):`,
...entries.map(
(e) =>
`- ${fmtDate(e.startedAt)} · ${e.minutes} min · ${e.billable ? 'billable' : 'non-billable'} · ${e.description}`,
),
'',
`DOCUMENTS (${docs.length}):`,
...docs.map((d) => `- ${d.name} (uploaded ${fmtDate(d.createdAt)})`),
'',
`INVOICES (${caseInvoices.length}):`,
...caseInvoices.map((i) => `- ${i.number} · ${i.status} · $${i.total}`),
]
.filter((line) => line !== '')
.join('\n');
try {
const result = await aiComplete({
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:
STATUS SNAPSHOT — 2-3 sentences on where the case stands based on the records.
RECENT ACTIVITY — the notable recent work, grouped, not a raw list.
BILLING PICTURE — hours logged, what's invoiced/outstanding.
GAPS & FOLLOW-UPS — anything the records suggest needs attention (stale activity, unbilled time, missing documents).
Keep it under 300 words.`,
content: context,
});
return { summary: result.text, disclaimer: AI_DISCLAIMER, usage: result.usage };
} catch (err) {
return sendAiError(reply, err);
}
},
);
// ── Document summary ─────────────────────────────────────────────────────
app.post(
'/api/documents/:docId/ai/summary',
{ config: { rateLimit: { max: 30, timeWindow: '1 hour' } } },
async (req, reply) => {
const firmId = req.user!.firmId!;
const { docId } = z.object({ docId: z.string().uuid() }).parse(req.params);
const db = getDb();
const [doc] = await db
.select()
.from(documents)
.where(and(eq(documents.id, docId), eq(documents.firmId, firmId)))
.limit(1);
if (!doc) return reply.code(404).send({ error: 'not_found' });
const supported =
doc.mimeType === AI_MIME.pdf ||
doc.mimeType === AI_MIME.text ||
AI_MIME.images.has(doc.mimeType);
if (!supported) {
return reply.code(400).send({
error: 'unsupported_for_ai',
hint: 'AI summaries support PDF, plain text, and image documents.',
});
}
if (doc.sizeBytes > MAX_AI_DOC_BYTES) {
return reply.code(400).send({ error: 'document_too_large_for_ai' });
}
let buf: Buffer;
try {
buf = await streamToBuffer(await getObjectStream(doc.storageKey));
} catch (err) {
if (err instanceof FileNotFoundError) return reply.code(404).send({ error: 'file_missing' });
throw err;
}
const instruction = `Summarize the attached document ("${doc.name}") for the attorney's case file, as plain text (no markdown syntax) with these section headings on their own lines:
WHAT IT IS — document type and apparent purpose.
KEY POINTS — parties, dates, amounts, deadlines, obligations that appear in it.
ANYTHING UNUSUAL — inconsistencies, missing signatures/pages, ambiguities worth a closer look.
Keep it under 300 words.`;
const content =
doc.mimeType === AI_MIME.text
? `${instruction}\n\n--- DOCUMENT CONTENT ---\n${buf.toString('utf8')}`
: ([
doc.mimeType === AI_MIME.pdf
? {
type: 'document' as const,
source: {
type: 'base64' as const,
media_type: 'application/pdf' as const,
data: buf.toString('base64'),
},
}
: {
type: 'image' as const,
source: {
type: 'base64' as const,
media_type: doc.mimeType as 'image/jpeg' | 'image/png' | 'image/webp',
data: buf.toString('base64'),
},
},
{ type: 'text' as const, text: instruction },
]);
try {
const result = await aiComplete({ system: BASE_SYSTEM, content });
return { summary: result.text, disclaimer: AI_DISCLAIMER, usage: result.usage };
} catch (err) {
return sendAiError(reply, err);
}
},
);
// ── Text polish ──────────────────────────────────────────────────────────
// Generic "make this professional" helper for notes, descriptions, and messages.
app.post(
'/api/ai/polish',
{ config: { rateLimit: { max: 60, timeWindow: '1 hour' } } },
async (req, reply) => {
const body = z
.object({
text: z.string().min(1).max(10_000),
kind: z.enum(['time_entry', 'invoice_note', 'case_description', 'client_message']),
})
.parse(req.body);
const KIND_GUIDANCE: Record<typeof body.kind, string> = {
time_entry:
'Rewrite as a professional billing narrative: past tense, specific, defensible to a client reviewing the invoice. One or two sentences.',
invoice_note:
'Rewrite as a courteous, professional note to appear on a client invoice. Brief.',
case_description:
'Rewrite as a clear internal case description: what the matter is, who is involved, current posture.',
client_message:
'Rewrite as a professional, warm message from a law firm to its client. Plain language, no legalese.',
};
try {
const result = await aiComplete({
system: `${BASE_SYSTEM}
${KIND_GUIDANCE[body.kind]}
Return ONLY the rewritten text — no preamble, no quotes, no commentary. Preserve all facts; never invent names, dates, or amounts.`,
content: body.text,
maxTokens: 800,
});
return { text: result.text, usage: result.usage };
} catch (err) {
return sendAiError(reply, err);
}
},
);
}
+26 -2
View File
@@ -23,17 +23,20 @@ import {
verifyEmailEmail,
} from '../lib/email';
import { env } from '../env';
import { verifyTurnstile } from '../lib/turnstile';
const signupBody = z.object({
email: z.string().email().max(254).toLowerCase().trim(),
password: z.string().min(10).max(200),
fullName: z.string().min(1).max(120).trim(),
firmName: z.string().min(1).max(160).trim(),
turnstileToken: z.string().max(3000).optional(),
});
const loginBody = z.object({
email: z.string().email().max(254).toLowerCase().trim(),
password: z.string().min(1).max(200),
turnstileToken: z.string().max(3000).optional(),
});
const MAX_FAILS_PER_15_MIN = 5;
@@ -74,6 +77,11 @@ export async function authRoutes(app: FastifyInstance) {
{ config: { rateLimit: { max: 5, timeWindow: '1 hour' } } },
async (req, reply) => {
const body = signupBody.parse(req.body);
if (!(await verifyTurnstile(body.turnstileToken, req.ip))) {
return reply.code(400).send({ error: 'captcha_failed' });
}
const db = getDb();
const existing = await db.select({ id: users.id }).from(users).where(eq(users.email, body.email)).limit(1);
@@ -144,6 +152,11 @@ export async function authRoutes(app: FastifyInstance) {
{ config: { rateLimit: { max: 10, timeWindow: '15 minutes' } } },
async (req, reply) => {
const body = loginBody.parse(req.body);
if (!(await verifyTurnstile(body.turnstileToken, req.ip))) {
return reply.code(400).send({ error: 'captcha_failed' });
}
const db = getDb();
const ip = req.ip ?? null;
@@ -216,10 +229,21 @@ export async function authRoutes(app: FastifyInstance) {
app.post(
'/api/auth/request-password-reset',
{ config: { rateLimit: { max: 5, timeWindow: '15 minutes' } } },
async (req) => {
const parsed = z.object({ email: z.string().email().max(254).toLowerCase().trim() }).safeParse(req.body);
async (req, reply) => {
const parsed = z
.object({
email: z.string().email().max(254).toLowerCase().trim(),
turnstileToken: z.string().max(3000).optional(),
})
.safeParse(req.body);
if (!parsed.success) return { ok: true };
// Bot check is orthogonal to email enumeration — a captcha failure is reported
// honestly; only account existence is concealed by the ok-always contract.
if (!(await verifyTurnstile(parsed.data.turnstileToken, req.ip))) {
return reply.code(400).send({ error: 'captcha_failed' });
}
const db = getDb();
const [user] = await db.select().from(users).where(eq(users.email, parsed.data.email)).limit(1);
if (!user || user.isSuspended) return { ok: true };
+7
View File
@@ -3,11 +3,13 @@ import { z } from 'zod';
import { getDb, contactMessages } from '@lawdesk/db';
import { sendEmail, contactAckEmail, contactNotifyEmail } from '../lib/email';
import { env } from '../env';
import { verifyTurnstile } from '../lib/turnstile';
const contactBody = z.object({
fullName: z.string().min(1).max(120).trim(),
email: z.string().email().max(254).toLowerCase().trim(),
message: z.string().min(1).max(5000).trim(),
turnstileToken: z.string().max(3000).optional(),
});
export async function contactRoutes(app: FastifyInstance) {
@@ -18,6 +20,11 @@ export async function contactRoutes(app: FastifyInstance) {
const parsed = contactBody.safeParse(req.body);
if (!parsed.success) return reply.code(400).send({ error: 'invalid_input' });
const body = parsed.data;
if (!(await verifyTurnstile(body.turnstileToken, req.ip))) {
return reply.code(400).send({ error: 'captcha_failed' });
}
await getDb().insert(contactMessages).values({
fullName: body.fullName,
email: body.email,
+5 -2
View File
@@ -24,6 +24,7 @@ import { accountRoutes } from './routes/account';
import { toolUsageRoutes } from './routes/tool-usage';
import { billingRoutes } from './routes/billing';
import { documentsRoutes } from './routes/documents';
import { aiRoutes } from './routes/ai';
import { stripeWebhookRoute } from './routes/webhooks-stripe';
const __filename = fileURLToPath(import.meta.url);
@@ -70,11 +71,12 @@ export async function buildServer() {
? {
directives: {
defaultSrc: ["'self'"],
scriptSrc: ["'self'"],
scriptSrc: ["'self'", 'https://challenges.cloudflare.com'],
styleSrc: ["'self'", "'unsafe-inline'", 'https://fonts.googleapis.com'],
fontSrc: ["'self'", 'https://fonts.gstatic.com', 'data:'],
imgSrc: ["'self'", 'data:', 'blob:'],
imgSrc: ["'self'", 'data:', 'blob:', 'https://*.digitaloceanspaces.com', 'https://*.cdn.digitaloceanspaces.com'],
connectSrc: ["'self'"],
frameSrc: ['https://challenges.cloudflare.com'],
frameAncestors: ["'none'"],
formAction: ["'self'"],
baseUri: ["'self'"],
@@ -119,6 +121,7 @@ export async function buildServer() {
await app.register(toolUsageRoutes);
await app.register(billingRoutes);
await app.register(documentsRoutes);
await app.register(aiRoutes);
// Serve the built SPA in production. In dev, the Vite dev server runs separately.
const webDist = env.WEB_DIST_PATH ?? path.resolve(__dirname, '../../web/dist');