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
+42
View File
@@ -0,0 +1,42 @@
# Keep the build context small and NEVER bake secrets or host-built artifacts into the image.
# Secrets — must never enter the image (env is injected by Dokploy at runtime).
.env
.env.*
!.env.example
# Dependencies + build outputs (reinstalled / rebuilt inside the image).
node_modules
**/node_modules
apps/web/dist
apps/api/dist
dist
build
.turbo
.cache
coverage
# VCS / CI / editor / OS noise.
.git
.gitignore
.github
.vscode
.idea
.DS_Store
Thumbs.db
# Logs and legacy Plesk/Passenger runtime scratch.
*.log
logs
tmp
# Local-only storage dirs (documents live in Spaces).
storage
uploads
# Tests aren't needed in the runtime image.
apps/api/test
**/*.test.ts
# Note: certs/ is intentionally NOT ignored — the Postgres CA cert (if committed) is baked in
# so production TLS verification works. See DEPLOY-DOKPLOY.md.
+14
View File
@@ -46,6 +46,20 @@ STORAGE_PATH=./storage
SMTP2GO_API_KEY= SMTP2GO_API_KEY=
EMAIL_FROM="eLegal Software <noreply@yourdomain.com>" EMAIL_FROM="eLegal Software <noreply@yourdomain.com>"
# ─────────────────────────────────────────────
# AI (Anthropic) — powers case summaries, document summaries, and text polish.
# Leave blank to disable AI features (endpoints return 503 ai_not_configured).
# ─────────────────────────────────────────────
ANTHROPIC_API_KEY=
# ─────────────────────────────────────────────
# Cloudflare Turnstile — bot protection on signup, login, password reset, and the
# contact form. Leave both blank to disable (dev). The site key is public (bundled
# into the web app); the secret key is server-only.
# ─────────────────────────────────────────────
TURNSTILE_SECRET_KEY=
VITE_TURNSTILE_SITE_KEY=
# ───────────────────────────────────────────── # ─────────────────────────────────────────────
# Stripe # Stripe
# ───────────────────────────────────────────── # ─────────────────────────────────────────────
+132
View File
@@ -0,0 +1,132 @@
# Deploying eLegal Software on Dokploy
This app was previously deployed on Plesk + Passenger. It now ships as a single Docker container
(built from the repo `Dockerfile`) that serves the built React SPA **and** the Fastify API on one
port. Documents live in DigitalOcean Spaces and logs go to stdout, so the container is **stateless**
— no volumes needed.
- **Runtime:** Node 20, one process, listens on `0.0.0.0:$PORT` (default `8080`).
- **Health check:** `GET /api/health``200 {"ok":true}` (no DB dependency). A deeper
`GET /api/health/db` verifies the database.
- **Database:** external DigitalOcean Managed Postgres (unchanged).
- **Storage:** external DigitalOcean Spaces (unchanged).
---
## 1. Create the application in Dokploy
1. **Project → Create Application.**
2. **Source → Git.** Point it at this repo (`https://tea.serfaty.co/admin/elegalsoftware`), branch
`master`. Add a deploy key / token in Dokploy if the repo is private.
3. **Build Type → `Dockerfile`.** Path: `./Dockerfile` (repo root). *(Alternatively use the
"Compose" type with the bundled `docker-compose.yml`, but Application + Dockerfile is simpler —
Dokploy wires Traefik and env for you.)*
4. **Port → `8080`.** This is the container port Dokploy/Traefik routes to.
## 2. Domain + TLS
- **Domains → Add** your hostname (e.g. `app.elegalsoftware.com`), container port `8080`, and
enable **HTTPS / Let's Encrypt**. Traefik terminates TLS and proxies to the container.
- Point the hostname's DNS at the Dokploy server first so the ACME challenge can succeed.
## 3. Runtime environment variables
Set these in **Environment** (Dokploy injects them at container start). Use `.env.example` as the
authoritative list. The essentials:
| Variable | Notes |
|---|---|
| `NODE_ENV` | `production` |
| `PORT` | `8080` (matches the exposed port) |
| `PUBLIC_URL` | Your public HTTPS URL, e.g. `https://app.elegalsoftware.com`. Used in emails and absolute links — **must** be the real domain in prod. |
| `COOKIE_DOMAIN` | Your apex/app domain (e.g. `elegalsoftware.com`). Leave blank only in local dev. |
| `SESSION_SECRET` / `CSRF_SECRET` | 32+ byte hex each. Generate: `node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"`. |
| `SUPERADMIN_EMAILS` | Comma-separated. Only **verified** accounts on this list become superadmin. |
| `DATABASE_URL` | DO Postgres URL (`...?sslmode=require`). |
| `DATABASE_CA_CERT_PATH` | Path to the DO CA cert **inside the container** — required in prod (see §4). |
| `SPACES_ENDPOINT` / `SPACES_REGION` / `SPACES_BUCKET` / `SPACES_KEY` / `SPACES_SECRET` | Object storage (required). |
| `SMTP2GO_API_KEY` / `EMAIL_FROM` | Transactional email. |
| `ANTHROPIC_API_KEY` | AI features (optional; blank disables them). |
| `TURNSTILE_SECRET_KEY` | Bot-protection server key (see §5 for the site key). |
| `STRIPE_SECRET_KEY` / `STRIPE_WEBHOOK_SECRET` / `STRIPE_PRICE_PRO` / `STRIPE_PRICE_LIFETIME` | Billing. |
| `SENTRY_DSN_API` | Optional error reporting. |
> The API **fails fast on boot** if a required variable is missing (secrets, `SPACES_*`, database).
> That's intentional — a misconfigured deploy stops loudly instead of running half-broken.
## 4. Database TLS — the one required extra step
In production the app **refuses to connect over unverified TLS** (no silent MITM exposure). You must
give it the DigitalOcean CA certificate:
1. In the DO control panel → your Postgres cluster → **Download CA certificate**.
2. Make it available in the container, either:
- **Commit it** as `certs/ca-certificate.crt` (a CA cert is public, safe to commit). The
Dockerfile bakes `certs/` into the image. Set `DATABASE_CA_CERT_PATH=./certs/ca-certificate.crt`.
- **Or** mount it via a Dokploy **Volume/Mount** (e.g. at `/app/certs/ca-certificate.crt`) and
set `DATABASE_CA_CERT_PATH` to that path.
Without this, the container boots and `/api/health` still passes, but any request that touches the
database will error. (`/api/health/db` will return `503` until the cert is in place.)
## 5. Build-time variables (Vite) — easy to miss
`VITE_*` values are **compiled into the browser bundle during `vite build`**, so they must be set as
**Build-time variables**, not runtime env:
- `VITE_TURNSTILE_SITE_KEY` — the public Turnstile site key.
- `VITE_SENTRY_DSN` — browser Sentry DSN (optional).
In Dokploy: **Build → Build-time variables** (passed as Docker build args). If you only set them as
runtime env, the browser bundle won't pick them up and Turnstile won't render.
## 6. Database migrations
Run migrations as a **deploy step**, not automatically on every container start. In Dokploy, use a
**Run Command** (or the app's terminal) after a deploy:
```bash
npm run db:migrate
```
Migrations are tracked (drizzle only applies pending ones) and safe to re-run. Keep the app at **1
replica** while migrating; the invoice-numbering advisory locks handle write concurrency, but schema
migrations should not run from multiple instances at once.
## 7. Stripe webhook
Add the endpoint in the Stripe dashboard:
```
https://<your-domain>/api/webhooks/stripe
```
The route reads the raw request body for signature verification. Unlike Plesk (which needed
`proxy_request_buffering off`), Traefik forwards the body fine — no extra proxy config required.
Set `STRIPE_WEBHOOK_SECRET` to the signing secret Stripe shows for that endpoint.
## 8. Deploy
Trigger a deploy in Dokploy (or enable auto-deploy on push). First deploy checklist:
- [ ] Runtime env vars set (§3), including `PUBLIC_URL` and `COOKIE_DOMAIN` on the real domain.
- [ ] DB CA cert in place and `DATABASE_CA_CERT_PATH` set (§4).
- [ ] `VITE_*` set as build-time variables (§5).
- [ ] Domain + Let's Encrypt configured (§2).
- [ ] Migrations run (§6).
- [ ] Stripe webhook pointed at the new URL (§7).
Verify: `https://<domain>/api/health``{"ok":true}`, then load the app, sign up, and open a
deep-link/refresh (e.g. `/app`) to confirm SPA routing.
---
## Notes
- **Local Docker test:** `docker build -t elegal . && docker run --rm -p 8080:8080 --env-file .env elegal`
then hit `http://localhost:8080/api/health`. (Uses your local `.env`; the image itself never
contains it — `.env` is in `.dockerignore`.)
- **Scaling:** the app is stateless (Spaces storage, stdout logs, Postgres sessions), so it scales
horizontally. Just run migrations as a single controlled step, not per-instance.
- **Legacy Plesk files** — `app.js`, `server.cjs`, `scripts/plesk-deploy.sh`, and `tmp/restart.txt`
are no longer used and can be deleted once the Dokploy cutover is confirmed.
+62
View File
@@ -0,0 +1,62 @@
# syntax=docker/dockerfile:1
#
# Container image for Dokploy (Docker + Traefik). Single Node process that serves the built
# React SPA and the Fastify API on one port. Documents live in DigitalOcean Spaces and logs go
# to stdout, so the container is stateless — no volumes required.
# ─── Builder ────────────────────────────────────────────────────────────────────
FROM node:20-bookworm-slim AS builder
WORKDIR /app
# Toolchain for native modules (argon2) in case no prebuilt binary is available for this platform.
RUN apt-get update \
&& apt-get install -y --no-install-recommends python3 make g++ \
&& rm -rf /var/lib/apt/lists/*
# Install dependencies first for better layer caching — copy every workspace manifest, then npm ci.
COPY package.json package-lock.json ./
COPY apps/api/package.json ./apps/api/
COPY apps/web/package.json ./apps/web/
COPY packages/db/package.json ./packages/db/
RUN npm ci
# Copy the rest of the source.
COPY . .
# Public config baked into the Vite bundle. Vite inlines VITE_* AT BUILD TIME, so these must be
# provided as build args (Dokploy → Build → Build-time variables), NOT as runtime env vars.
# Both are optional: an empty value simply disables that feature in the browser bundle.
ARG VITE_TURNSTILE_SITE_KEY=""
ARG VITE_SENTRY_DSN=""
ENV VITE_TURNSTILE_SITE_KEY=${VITE_TURNSTILE_SITE_KEY} \
VITE_SENTRY_DSN=${VITE_SENTRY_DSN}
# Build the SPA → apps/web/dist. The API runs from TypeScript source via the tsx loader, so there
# is no separate API build step.
RUN npm run build
# ─── Runtime ────────────────────────────────────────────────────────────────────
FROM node:20-bookworm-slim AS runtime
WORKDIR /app
ENV NODE_ENV=production \
PORT=8080
# ca-certificates for outbound TLS (Postgres, Spaces, Stripe, SMTP2GO, Anthropic).
RUN apt-get update \
&& apt-get install -y --no-install-recommends ca-certificates \
&& rm -rf /var/lib/apt/lists/* \
&& useradd -m -u 1001 app
# 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).
COPY --from=builder --chown=app:app /app /app
USER app
EXPOSE 8080
# Container liveness. Dokploy/Traefik can additionally health-check the /api/health HTTP path.
HEALTHCHECK --interval=30s --timeout=5s --start-period=25s --retries=3 \
CMD node -e "fetch('http://127.0.0.1:'+(process.env.PORT||8080)+'/api/health').then(r=>{if(!r.ok)process.exit(1)}).catch(()=>process.exit(1))"
# server.js registers the tsx ESM loader, builds the Fastify app, and listens on 0.0.0.0:$PORT.
CMD ["node", "server.js"]
+1
View File
@@ -14,6 +14,7 @@
"typecheck": "tsc -p tsconfig.json --noEmit" "typecheck": "tsc -p tsconfig.json --noEmit"
}, },
"dependencies": { "dependencies": {
"@anthropic-ai/sdk": "^0.111.0",
"@aws-sdk/client-s3": "^3.1088.0", "@aws-sdk/client-s3": "^3.1088.0",
"@aws-sdk/s3-request-presigner": "^3.1088.0", "@aws-sdk/s3-request-presigner": "^3.1088.0",
"@fastify/cookie": "^11.0.1", "@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. // Legacy local path — read only by the one-time migration script, not the running app.
STORAGE_PATH: z.string().optional().default('./storage'), STORAGE_PATH: z.string().optional().default('./storage'),
SMTP2GO_API_KEY: z.string().optional().default(''), 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>'), EMAIL_FROM: z.string().optional().default('eLegal Software <noreply@elegalsoftware.com>'),
STRIPE_SECRET_KEY: z.string().optional().default(''), STRIPE_SECRET_KEY: z.string().optional().default(''),
STRIPE_WEBHOOK_SECRET: 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, verifyEmailEmail,
} from '../lib/email'; } from '../lib/email';
import { env } from '../env'; import { env } from '../env';
import { verifyTurnstile } from '../lib/turnstile';
const signupBody = z.object({ const signupBody = z.object({
email: z.string().email().max(254).toLowerCase().trim(), email: z.string().email().max(254).toLowerCase().trim(),
password: z.string().min(10).max(200), password: z.string().min(10).max(200),
fullName: z.string().min(1).max(120).trim(), fullName: z.string().min(1).max(120).trim(),
firmName: z.string().min(1).max(160).trim(), firmName: z.string().min(1).max(160).trim(),
turnstileToken: z.string().max(3000).optional(),
}); });
const loginBody = z.object({ const loginBody = z.object({
email: z.string().email().max(254).toLowerCase().trim(), email: z.string().email().max(254).toLowerCase().trim(),
password: z.string().min(1).max(200), password: z.string().min(1).max(200),
turnstileToken: z.string().max(3000).optional(),
}); });
const MAX_FAILS_PER_15_MIN = 5; const MAX_FAILS_PER_15_MIN = 5;
@@ -74,6 +77,11 @@ export async function authRoutes(app: FastifyInstance) {
{ config: { rateLimit: { max: 5, timeWindow: '1 hour' } } }, { config: { rateLimit: { max: 5, timeWindow: '1 hour' } } },
async (req, reply) => { async (req, reply) => {
const body = signupBody.parse(req.body); 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 db = getDb();
const existing = await db.select({ id: users.id }).from(users).where(eq(users.email, body.email)).limit(1); 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' } } }, { config: { rateLimit: { max: 10, timeWindow: '15 minutes' } } },
async (req, reply) => { async (req, reply) => {
const body = loginBody.parse(req.body); 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 db = getDb();
const ip = req.ip ?? null; const ip = req.ip ?? null;
@@ -216,10 +229,21 @@ export async function authRoutes(app: FastifyInstance) {
app.post( app.post(
'/api/auth/request-password-reset', '/api/auth/request-password-reset',
{ config: { rateLimit: { max: 5, timeWindow: '15 minutes' } } }, { config: { rateLimit: { max: 5, timeWindow: '15 minutes' } } },
async (req) => { async (req, reply) => {
const parsed = z.object({ email: z.string().email().max(254).toLowerCase().trim() }).safeParse(req.body); 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 }; 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 db = getDb();
const [user] = await db.select().from(users).where(eq(users.email, parsed.data.email)).limit(1); const [user] = await db.select().from(users).where(eq(users.email, parsed.data.email)).limit(1);
if (!user || user.isSuspended) return { ok: true }; 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 { getDb, contactMessages } from '@lawdesk/db';
import { sendEmail, contactAckEmail, contactNotifyEmail } from '../lib/email'; import { sendEmail, contactAckEmail, contactNotifyEmail } from '../lib/email';
import { env } from '../env'; import { env } from '../env';
import { verifyTurnstile } from '../lib/turnstile';
const contactBody = z.object({ const contactBody = z.object({
fullName: z.string().min(1).max(120).trim(), fullName: z.string().min(1).max(120).trim(),
email: z.string().email().max(254).toLowerCase().trim(), email: z.string().email().max(254).toLowerCase().trim(),
message: z.string().min(1).max(5000).trim(), message: z.string().min(1).max(5000).trim(),
turnstileToken: z.string().max(3000).optional(),
}); });
export async function contactRoutes(app: FastifyInstance) { export async function contactRoutes(app: FastifyInstance) {
@@ -18,6 +20,11 @@ export async function contactRoutes(app: FastifyInstance) {
const parsed = contactBody.safeParse(req.body); const parsed = contactBody.safeParse(req.body);
if (!parsed.success) return reply.code(400).send({ error: 'invalid_input' }); if (!parsed.success) return reply.code(400).send({ error: 'invalid_input' });
const body = parsed.data; const body = parsed.data;
if (!(await verifyTurnstile(body.turnstileToken, req.ip))) {
return reply.code(400).send({ error: 'captcha_failed' });
}
await getDb().insert(contactMessages).values({ await getDb().insert(contactMessages).values({
fullName: body.fullName, fullName: body.fullName,
email: body.email, email: body.email,
+5 -2
View File
@@ -24,6 +24,7 @@ import { accountRoutes } from './routes/account';
import { toolUsageRoutes } from './routes/tool-usage'; import { toolUsageRoutes } from './routes/tool-usage';
import { billingRoutes } from './routes/billing'; import { billingRoutes } from './routes/billing';
import { documentsRoutes } from './routes/documents'; import { documentsRoutes } from './routes/documents';
import { aiRoutes } from './routes/ai';
import { stripeWebhookRoute } from './routes/webhooks-stripe'; import { stripeWebhookRoute } from './routes/webhooks-stripe';
const __filename = fileURLToPath(import.meta.url); const __filename = fileURLToPath(import.meta.url);
@@ -70,11 +71,12 @@ export async function buildServer() {
? { ? {
directives: { directives: {
defaultSrc: ["'self'"], defaultSrc: ["'self'"],
scriptSrc: ["'self'"], scriptSrc: ["'self'", 'https://challenges.cloudflare.com'],
styleSrc: ["'self'", "'unsafe-inline'", 'https://fonts.googleapis.com'], styleSrc: ["'self'", "'unsafe-inline'", 'https://fonts.googleapis.com'],
fontSrc: ["'self'", 'https://fonts.gstatic.com', 'data:'], fontSrc: ["'self'", 'https://fonts.gstatic.com', 'data:'],
imgSrc: ["'self'", 'data:', 'blob:'], imgSrc: ["'self'", 'data:', 'blob:', 'https://*.digitaloceanspaces.com', 'https://*.cdn.digitaloceanspaces.com'],
connectSrc: ["'self'"], connectSrc: ["'self'"],
frameSrc: ['https://challenges.cloudflare.com'],
frameAncestors: ["'none'"], frameAncestors: ["'none'"],
formAction: ["'self'"], formAction: ["'self'"],
baseUri: ["'self'"], baseUri: ["'self'"],
@@ -119,6 +121,7 @@ export async function buildServer() {
await app.register(toolUsageRoutes); await app.register(toolUsageRoutes);
await app.register(billingRoutes); await app.register(billingRoutes);
await app.register(documentsRoutes); await app.register(documentsRoutes);
await app.register(aiRoutes);
// Serve the built SPA in production. In dev, the Vite dev server runs separately. // 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'); const webDist = env.WEB_DIST_PATH ?? path.resolve(__dirname, '../../web/dist');
+98
View File
@@ -0,0 +1,98 @@
import { useEffect, useRef } from 'react';
// Cloudflare Turnstile widget (explicit render). Renders nothing when
// VITE_TURNSTILE_SITE_KEY is unset, so dev without keys just works.
declare global {
interface Window {
turnstile?: {
render: (
el: HTMLElement,
opts: {
sitekey: string;
callback: (token: string) => void;
'expired-callback'?: () => void;
'error-callback'?: () => void;
theme?: 'light' | 'dark' | 'auto';
},
) => string;
reset: (widgetId: string) => void;
remove: (widgetId: string) => void;
};
}
}
export const TURNSTILE_SITE_KEY: string = import.meta.env.VITE_TURNSTILE_SITE_KEY ?? '';
let scriptPromise: Promise<void> | null = null;
function loadScript(): Promise<void> {
if (window.turnstile) return Promise.resolve();
if (!scriptPromise) {
scriptPromise = new Promise<void>((resolve, reject) => {
const s = document.createElement('script');
s.src = 'https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit';
s.async = true;
s.onload = () => resolve();
s.onerror = () => {
scriptPromise = null;
reject(new Error('turnstile_script_failed'));
};
document.head.appendChild(s);
});
}
return scriptPromise;
}
export function Turnstile({
onToken,
resetSignal = 0,
className,
}: {
/** Called with a fresh token, or null when the token expires/errors. */
onToken: (token: string | null) => void;
/** Bump this number to force a widget reset (tokens are single-use). */
resetSignal?: number;
className?: string;
}) {
const containerRef = useRef<HTMLDivElement>(null);
const widgetIdRef = useRef<string | null>(null);
const onTokenRef = useRef(onToken);
onTokenRef.current = onToken;
useEffect(() => {
if (!TURNSTILE_SITE_KEY || !containerRef.current) return;
let cancelled = false;
loadScript()
.then(() => {
if (cancelled || !containerRef.current || widgetIdRef.current || !window.turnstile) return;
widgetIdRef.current = window.turnstile.render(containerRef.current, {
sitekey: TURNSTILE_SITE_KEY,
theme: 'light',
callback: (token) => onTokenRef.current(token),
'expired-callback': () => onTokenRef.current(null),
'error-callback': () => onTokenRef.current(null),
});
})
.catch(() => onTokenRef.current(null));
return () => {
cancelled = true;
if (widgetIdRef.current && window.turnstile) {
window.turnstile.remove(widgetIdRef.current);
widgetIdRef.current = null;
}
};
}, []);
useEffect(() => {
if (resetSignal > 0 && widgetIdRef.current && window.turnstile) {
window.turnstile.reset(widgetIdRef.current);
onTokenRef.current(null);
}
}, [resetSignal]);
if (!TURNSTILE_SITE_KEY) return null;
return <div ref={containerRef} className={className} />;
}
@@ -0,0 +1,75 @@
import { useState } from 'react';
import { Loader2, Sparkles } from 'lucide-react';
import { Card, CardBody, CardHeader } from '@/components/ui/Card';
import { Button } from '@/components/ui/Button';
import { api, type ApiError } from '@/lib/api';
interface AiSummaryResponse {
summary: string;
disclaimer: string;
}
const ERROR_COPY: Record<string, string> = {
ai_not_configured: 'AI features are not configured on this server.',
ai_rate_limited: 'AI is busy right now — try again in a minute.',
};
export function AiCaseSummary({ caseId }: { caseId: string }) {
const [result, setResult] = useState<AiSummaryResponse | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
async function generate() {
setLoading(true);
setError(null);
try {
const data = await api.post<AiSummaryResponse>(`/api/cases/${caseId}/ai/summary`);
setResult(data);
} catch (err) {
const code = (err as ApiError).code ?? '';
setError(ERROR_COPY[code] ?? 'Could not generate a summary — try again shortly.');
} finally {
setLoading(false);
}
}
return (
<Card>
<CardHeader
title="AI case brief"
action={
<Button variant="secondary" onClick={generate} disabled={loading}>
{loading ? (
<>
<Loader2 className="h-4 w-4 animate-spin" />
Summarizing
</>
) : (
<>
<Sparkles className="h-4 w-4" />
{result ? 'Regenerate' : 'Generate summary'}
</>
)}
</Button>
}
/>
<CardBody>
{error && <p className="rounded-lg bg-rose-50 px-3 py-2 text-sm text-rose-700">{error}</p>}
{!error && !result && !loading && (
<p className="text-sm text-ink-500">
Generate an AI brief of this case from its records status, recent activity, billing,
and suggested follow-ups.
</p>
)}
{result && (
<div className="space-y-3">
<p className="whitespace-pre-wrap text-sm leading-relaxed text-ink-800">
{result.summary}
</p>
<p className="text-xs text-ink-400">{result.disclaimer}</p>
</div>
)}
</CardBody>
</Card>
);
}
@@ -31,12 +31,21 @@ export function BlogTeaser() {
to={`/blog/${p.slug}`} to={`/blog/${p.slug}`}
className="group rounded-2xl border border-ink-100 bg-white overflow-hidden hover:border-brand-200 hover:shadow-lg hover:shadow-brand-500/5 transition flex flex-col" className="group rounded-2xl border border-ink-100 bg-white overflow-hidden hover:border-brand-200 hover:shadow-lg hover:shadow-brand-500/5 transition flex flex-col"
> >
<div className="aspect-[16/9] bg-gradient-to-br from-brand-100 via-brand-50 to-white relative"> <div className="aspect-[16/9] bg-gradient-to-br from-brand-100 via-brand-50 to-white relative overflow-hidden">
{p.coverImage ? (
<img
src={p.coverImage}
alt=""
loading="lazy"
className="absolute inset-0 h-full w-full object-cover transition duration-300 group-hover:scale-[1.03]"
/>
) : (
<div className="absolute inset-0 grid place-items-center px-6"> <div className="absolute inset-0 grid place-items-center px-6">
<span className="text-2xl font-bold text-brand-300/50 font-display select-none text-center leading-tight"> <span className="text-2xl font-bold text-brand-300/50 font-display select-none text-center leading-tight">
{p.title.split(' ').slice(0, 3).join(' ')} {p.title.split(' ').slice(0, 3).join(' ')}
</span> </span>
</div> </div>
)}
</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">{formatDate(p.publishedAt)}</p>
+14 -1
View File
@@ -1,12 +1,15 @@
import { useState } from 'react'; import { useState } from 'react';
import { Mail, Send, Clock } from 'lucide-react'; import { Mail, Send, Clock } from 'lucide-react';
import { api, type ApiError } from '@/lib/api'; import { api, type ApiError } from '@/lib/api';
import { Turnstile, TURNSTILE_SITE_KEY } from '@/components/Turnstile';
type State = 'idle' | 'submitting' | 'success' | 'error'; type State = 'idle' | 'submitting' | 'success' | 'error';
export function Contact() { export function Contact() {
const [state, setState] = useState<State>('idle'); const [state, setState] = useState<State>('idle');
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [captchaToken, setCaptchaToken] = useState<string | null>(null);
const [captchaReset, setCaptchaReset] = useState(0);
async function onSubmit(e: React.FormEvent<HTMLFormElement>) { async function onSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault(); e.preventDefault();
@@ -18,6 +21,7 @@ export function Contact() {
fullName: String(fd.get('fullName') ?? '').trim(), fullName: String(fd.get('fullName') ?? '').trim(),
email: String(fd.get('email') ?? '').trim(), email: String(fd.get('email') ?? '').trim(),
message: String(fd.get('message') ?? '').trim(), message: String(fd.get('message') ?? '').trim(),
turnstileToken: captchaToken ?? undefined,
}; };
try { try {
@@ -28,6 +32,9 @@ export function Contact() {
const apiErr = err as ApiError; const apiErr = err as ApiError;
setError(apiErr.code ?? apiErr.message); setError(apiErr.code ?? apiErr.message);
setState('error'); setState('error');
} finally {
// Tokens are single-use — reset the widget whether the send worked or not.
setCaptchaReset((n) => n + 1);
} }
} }
@@ -70,7 +77,13 @@ export function Contact() {
</div> </div>
</div> </div>
<button type="submit" disabled={state === 'submitting'} className="btn-primary mt-6 w-full"> <Turnstile onToken={setCaptchaToken} resetSignal={captchaReset} className="mt-6" />
<button
type="submit"
disabled={state === 'submitting' || (Boolean(TURNSTILE_SITE_KEY) && !captchaToken)}
className="btn-primary mt-6 w-full"
>
{state === 'submitting' ? 'Sending' : ( {state === 'submitting' ? 'Sending' : (
<> <>
Send Message Send Message
+7
View File
@@ -5,6 +5,7 @@ export interface Post {
publishedAt: string; // ISO date publishedAt: string; // ISO date
readMinutes: number; readMinutes: number;
author: string; author: string;
coverImage?: string; // absolute URL (served from DigitalOcean Spaces)
// Body is structured as an array of blocks for simple renderable JSX // Body is structured as an array of blocks for simple renderable JSX
body: Block[]; body: Block[];
} }
@@ -27,6 +28,8 @@ 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',
@@ -89,6 +92,8 @@ 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',
@@ -157,6 +162,8 @@ 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',
+6 -2
View File
@@ -36,7 +36,7 @@ export function useMe() {
export function useLogin() { export function useLogin() {
const qc = useQueryClient(); const qc = useQueryClient();
return useMutation<AuthUser, ApiError, { email: string; password: string }>({ return useMutation<AuthUser, ApiError, { email: string; password: string; turnstileToken?: string }>({
mutationFn: async (vars) => { mutationFn: async (vars) => {
const data = await api.post<MeResponse>('/api/auth/login', vars); const data = await api.post<MeResponse>('/api/auth/login', vars);
return data.user; return data.user;
@@ -47,7 +47,11 @@ export function useLogin() {
export function useSignup() { export function useSignup() {
const qc = useQueryClient(); const qc = useQueryClient();
return useMutation<AuthUser, ApiError, { email: string; password: string; fullName: string; firmName: string }>({ return useMutation<
AuthUser,
ApiError,
{ email: string; password: string; fullName: string; firmName: string; turnstileToken?: string }
>({
mutationFn: async (vars) => { mutationFn: async (vars) => {
const data = await api.post<MeResponse>('/api/auth/signup', vars); const data = await api.post<MeResponse>('/api/auth/signup', vars);
return data.user; return data.user;
+1 -1
View File
@@ -2,7 +2,7 @@ import { useMutation } from '@tanstack/react-query';
import { api, type ApiError } from '@/lib/api'; import { api, type ApiError } from '@/lib/api';
export function useRequestPasswordReset() { export function useRequestPasswordReset() {
return useMutation<{ ok: boolean }, ApiError, { email: string }>({ return useMutation<{ ok: boolean }, ApiError, { email: string; turnstileToken?: string }>({
mutationFn: (body) => api.post('/api/auth/request-password-reset', body), mutationFn: (body) => api.post('/api/auth/request-password-reset', body),
}); });
} }
+23 -2
View File
@@ -5,6 +5,7 @@ import { ArrowRight, MailCheck } from 'lucide-react';
import { z } from 'zod'; import { z } from 'zod';
import { AuthLayout } from '@/components/auth/AuthLayout'; import { AuthLayout } from '@/components/auth/AuthLayout';
import { Field } from '@/components/auth/Field'; import { Field } from '@/components/auth/Field';
import { Turnstile, TURNSTILE_SITE_KEY } from '@/components/Turnstile';
import { useRequestPasswordReset } from '@/hooks/useResetPassword'; import { useRequestPasswordReset } from '@/hooks/useResetPassword';
const schema = z.object({ email: z.string().email('Enter a valid email') }); const schema = z.object({ email: z.string().email('Enter a valid email') });
@@ -13,6 +14,8 @@ type FormValues = z.infer<typeof schema>;
export default function ForgotPasswordPage() { export default function ForgotPasswordPage() {
const [submitted, setSubmitted] = useState(false); const [submitted, setSubmitted] = useState(false);
const request = useRequestPasswordReset(); const request = useRequestPasswordReset();
const [captchaToken, setCaptchaToken] = useState<string | null>(null);
const [captchaReset, setCaptchaReset] = useState(0);
const { const {
register, register,
@@ -23,7 +26,13 @@ export default function ForgotPasswordPage() {
async function onSubmit(values: FormValues) { async function onSubmit(values: FormValues) {
const parsed = schema.safeParse(values); const parsed = schema.safeParse(values);
if (!parsed.success) return; if (!parsed.success) return;
await request.mutateAsync(parsed.data); try {
await request.mutateAsync({ ...parsed.data, turnstileToken: captchaToken ?? undefined });
} catch {
// Tokens are single-use — issue a fresh one for the retry.
setCaptchaReset((n) => n + 1);
return;
}
setSubmitted(true); setSubmitted(true);
} }
@@ -61,9 +70,21 @@ export default function ForgotPasswordPage() {
error={errors.email?.message} error={errors.email?.message}
{...register('email')} {...register('email')}
/> />
{request.isError && (
<p className="rounded-lg bg-rose-50 px-3 py-2 text-sm text-rose-700">
{request.error?.code === 'captcha_failed'
? 'Verification failed complete the check below and try again.'
: 'Something went wrong. Please try again.'}
</p>
)}
<Turnstile onToken={setCaptchaToken} resetSignal={captchaReset} />
<button <button
type="submit" type="submit"
disabled={isSubmitting || request.isPending} disabled={
isSubmitting || request.isPending || (Boolean(TURNSTILE_SITE_KEY) && !captchaToken)
}
className="btn-primary w-full" className="btn-primary w-full"
> >
{request.isPending ? 'Sending' : ( {request.isPending ? 'Sending' : (
+19 -3
View File
@@ -1,10 +1,11 @@
import { useEffect } from 'react'; import { useEffect, useState } from 'react';
import { Link, useNavigate, useLocation } from 'react-router-dom'; import { Link, useNavigate, useLocation } from 'react-router-dom';
import { useForm } from 'react-hook-form'; import { useForm } from 'react-hook-form';
import { z } from 'zod'; import { z } from 'zod';
import { ArrowRight } from 'lucide-react'; import { ArrowRight } from 'lucide-react';
import { AuthLayout } from '@/components/auth/AuthLayout'; import { AuthLayout } from '@/components/auth/AuthLayout';
import { Field } from '@/components/auth/Field'; import { Field } from '@/components/auth/Field';
import { Turnstile, TURNSTILE_SITE_KEY } from '@/components/Turnstile';
import { useLogin, useMe } from '@/hooks/useAuth'; import { useLogin, useMe } from '@/hooks/useAuth';
const schema = z.object({ const schema = z.object({
@@ -17,6 +18,7 @@ type FormValues = z.infer<typeof schema>;
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.',
captcha_failed: 'Verification failed — complete the check below and try again.',
}; };
export default function LoginPage() { export default function LoginPage() {
@@ -24,6 +26,8 @@ export default function LoginPage() {
const location = useLocation(); const location = useLocation();
const me = useMe(); const me = useMe();
const login = useLogin(); const login = useLogin();
const [captchaToken, setCaptchaToken] = useState<string | null>(null);
const [captchaReset, setCaptchaReset] = useState(0);
// Landing target of the email-verification link: /login?verified=1|0 // Landing target of the email-verification link: /login?verified=1|0
const verified = new URLSearchParams(location.search).get('verified'); const verified = new URLSearchParams(location.search).get('verified');
@@ -43,7 +47,13 @@ export default function LoginPage() {
async function onSubmit(values: FormValues) { async function onSubmit(values: FormValues) {
const parsed = schema.safeParse(values); const parsed = schema.safeParse(values);
if (!parsed.success) return; if (!parsed.success) return;
await login.mutateAsync(parsed.data); try {
await login.mutateAsync({ ...parsed.data, turnstileToken: captchaToken ?? undefined });
} catch {
// Tokens are single-use — issue a fresh one for the retry.
setCaptchaReset((n) => n + 1);
return;
}
const next = new URLSearchParams(location.search).get('next') ?? '/app'; const next = new URLSearchParams(location.search).get('next') ?? '/app';
navigate(next, { replace: true }); navigate(next, { replace: true });
} }
@@ -103,7 +113,13 @@ export default function LoginPage() {
<p className="rounded-lg bg-rose-50 px-3 py-2 text-sm text-rose-700">{apiError}</p> <p className="rounded-lg bg-rose-50 px-3 py-2 text-sm text-rose-700">{apiError}</p>
)} )}
<button type="submit" disabled={isSubmitting || login.isPending} className="btn-primary w-full"> <Turnstile onToken={setCaptchaToken} resetSignal={captchaReset} />
<button
type="submit"
disabled={isSubmitting || login.isPending || (Boolean(TURNSTILE_SITE_KEY) && !captchaToken)}
className="btn-primary w-full"
>
{login.isPending ? 'Signing in…' : ( {login.isPending ? 'Signing in…' : (
<> <>
Sign in Sign in
+19 -3
View File
@@ -1,10 +1,11 @@
import { useEffect } from 'react'; import { useEffect, useState } from 'react';
import { Link, useNavigate } from 'react-router-dom'; import { Link, useNavigate } from 'react-router-dom';
import { useForm } from 'react-hook-form'; import { useForm } from 'react-hook-form';
import { z } from 'zod'; import { z } from 'zod';
import { ArrowRight } from 'lucide-react'; import { ArrowRight } from 'lucide-react';
import { AuthLayout } from '@/components/auth/AuthLayout'; import { AuthLayout } from '@/components/auth/AuthLayout';
import { Field } from '@/components/auth/Field'; import { Field } from '@/components/auth/Field';
import { Turnstile, TURNSTILE_SITE_KEY } from '@/components/Turnstile';
import { useMe, useSignup } from '@/hooks/useAuth'; import { useMe, useSignup } from '@/hooks/useAuth';
const schema = z.object({ const schema = z.object({
@@ -18,12 +19,15 @@ type FormValues = z.infer<typeof schema>;
const ERROR_COPY: Record<string, string> = { const ERROR_COPY: Record<string, string> = {
email_taken: 'An account with that email already exists.', email_taken: 'An account with that email already exists.',
captcha_failed: 'Verification failed — complete the check below and try again.',
}; };
export default function SignupPage() { export default function SignupPage() {
const navigate = useNavigate(); const navigate = useNavigate();
const me = useMe(); const me = useMe();
const signup = useSignup(); const signup = useSignup();
const [captchaToken, setCaptchaToken] = useState<string | null>(null);
const [captchaReset, setCaptchaReset] = useState(0);
const { const {
register, register,
@@ -40,7 +44,13 @@ export default function SignupPage() {
async function onSubmit(values: FormValues) { async function onSubmit(values: FormValues) {
const parsed = schema.safeParse(values); const parsed = schema.safeParse(values);
if (!parsed.success) return; if (!parsed.success) return;
await signup.mutateAsync(parsed.data); try {
await signup.mutateAsync({ ...parsed.data, turnstileToken: captchaToken ?? undefined });
} catch {
// Tokens are single-use — issue a fresh one for the retry.
setCaptchaReset((n) => n + 1);
return;
}
navigate('/app', { replace: true }); navigate('/app', { replace: true });
} }
@@ -96,7 +106,13 @@ export default function SignupPage() {
<p className="rounded-lg bg-rose-50 px-3 py-2 text-sm text-rose-700">{apiError}</p> <p className="rounded-lg bg-rose-50 px-3 py-2 text-sm text-rose-700">{apiError}</p>
)} )}
<button type="submit" disabled={isSubmitting || signup.isPending} className="btn-primary w-full"> <Turnstile onToken={setCaptchaToken} resetSignal={captchaReset} />
<button
type="submit"
disabled={isSubmitting || signup.isPending || (Boolean(TURNSTILE_SITE_KEY) && !captchaToken)}
className="btn-primary w-full"
>
{signup.isPending ? 'Creating your account…' : ( {signup.isPending ? 'Creating your account…' : (
<> <>
Create account Create account
@@ -9,6 +9,7 @@ import { Input, Select, Textarea } from '@/components/ui/Input';
import { Badge } from '@/components/ui/Badge'; import { Badge } from '@/components/ui/Badge';
import { CaseTimeList } from '@/components/app/CaseTimeList'; import { CaseTimeList } from '@/components/app/CaseTimeList';
import { CreateInvoiceDrawer } from '@/components/app/CreateInvoiceDrawer'; import { CreateInvoiceDrawer } from '@/components/app/CreateInvoiceDrawer';
import { AiCaseSummary } from '@/components/app/AiCaseSummary';
import { useInvoices, type InvoiceStatus } from '@/hooks/useInvoices'; import { useInvoices, type InvoiceStatus } from '@/hooks/useInvoices';
import { formatBytes, formatDate, formatMoney } from '@/lib/format'; import { formatBytes, formatDate, formatMoney } from '@/lib/format';
import { useDocuments, useUploadDocument, useDeleteDocument } from '@/hooks/useDocuments'; import { useDocuments, useUploadDocument, useDeleteDocument } from '@/hooks/useDocuments';
@@ -188,6 +189,10 @@ export default function CaseDetailPage() {
</CardBody> </CardBody>
</Card> </Card>
<div className="lg:col-span-2">
<AiCaseSummary caseId={id!} />
</div>
<Card> <Card>
<CardHeader title="Client" /> <CardHeader title="Client" />
<CardBody> <CardBody>
+10 -1
View File
@@ -21,12 +21,21 @@ export default function BlogIndexPage() {
to={`/blog/${p.slug}`} to={`/blog/${p.slug}`}
className="group rounded-2xl border border-ink-100 bg-white overflow-hidden hover:border-brand-200 hover:shadow-lg hover:shadow-brand-500/5 transition flex flex-col" className="group rounded-2xl border border-ink-100 bg-white overflow-hidden hover:border-brand-200 hover:shadow-lg hover:shadow-brand-500/5 transition flex flex-col"
> >
<div className="aspect-[16/9] bg-gradient-to-br from-brand-100 via-brand-50 to-white relative"> <div className="aspect-[16/9] bg-gradient-to-br from-brand-100 via-brand-50 to-white relative overflow-hidden">
{p.coverImage ? (
<img
src={p.coverImage}
alt=""
loading="lazy"
className="absolute inset-0 h-full w-full object-cover transition duration-300 group-hover:scale-[1.03]"
/>
) : (
<div className="absolute inset-0 grid place-items-center"> <div className="absolute inset-0 grid place-items-center">
<span className="text-4xl font-bold text-brand-300/50 font-display select-none"> <span className="text-4xl font-bold text-brand-300/50 font-display select-none">
{p.title.split(' ').slice(0, 2).join(' ')} {p.title.split(' ').slice(0, 2).join(' ')}
</span> </span>
</div> </div>
)}
</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">{formatDate(p.publishedAt)}</p>
+8
View File
@@ -48,6 +48,14 @@ export default function BlogPostPage() {
</div> </div>
</header> </header>
{post.coverImage && (
<img
src={post.coverImage}
alt=""
className="mb-10 aspect-[16/9] w-full rounded-2xl border border-ink-100 object-cover"
/>
)}
<div className="space-y-5"> <div className="space-y-5">
{post.body.map((b, i) => ( {post.body.map((b, i) => (
<RenderBlock key={i} block={b} /> <RenderBlock key={i} block={b} />
+2
View File
@@ -4,6 +4,8 @@ import path from 'node:path';
export default defineConfig({ export default defineConfig({
plugins: [react()], plugins: [react()],
// VITE_* vars live in the monorepo root .env alongside the API's config.
envDir: path.resolve(__dirname, '../..'),
resolve: { resolve: {
alias: { alias: {
'@': path.resolve(__dirname, 'src'), '@': path.resolve(__dirname, 'src'),
+124
View File
@@ -0,0 +1,124 @@
-----BEGIN CERTIFICATE-----
MIIERDCCAqygAwIBAgIUT+Zwrrq80kuT4VpUr5nmPHR8eZ0wDQYJKoZIhvcNAQEM
BQAwOjE4MDYGA1UEAwwvOTFkNTNlYjctMDA5MC00Mzg5LWFjN2EtYjAxN2U2Mjli
NjY0IFByb2plY3QgQ0EwHhcNMjYwNDE2MTMwNzMzWhcNMzYwNDEzMTMwNzMzWjA6
MTgwNgYDVQQDDC85MWQ1M2ViNy0wMDkwLTQzODktYWM3YS1iMDE3ZTYyOWI2NjQg
UHJvamVjdCBDQTCCAaIwDQYJKoZIhvcNAQEBBQADggGPADCCAYoCggGBAKFiJhw2
DC3Xf0HzVb+glBzZajAPgkJP0EwRN4sdxPNR17ajN4mFuDXIjHuil/zhJiwVbByC
NA1NX+2wPNJ9MZVSVyYvB6G8xRhBFvlawS+u5KQQ6TqdJ02/398D5cY5L1vbRzNU
CZgrDtzuztOlER02dltmE8/mZmKN6rh+p7gyYVRNe+uXMn9VJQj5853fA0yw6OZh
1CJdf6xUqVCROf6PaTaeOKq0tu/1YkvKjY/cNioOgAHZe3WcKixdbnjXAwU3P4RU
A7CHunxfccIGh32lItz/pSwFGIvEaUcbEcu+343DtO0ADRELNQLXREOUduJTRaNs
kApA3YRGImi56CagCklrzL6kAGC6yxRqQDAMIcad11Msk6qreCOy0ozQeg8MRvSy
m9qNoOt1OsQXykI0CjsmG/R+lAO3DDL0Fgf34Vaq8BDDAYa4GhwIuWQKOkiotC8q
XDLkdc6AYvmGYEr8HfQ+r82Ydbbg4Sp2FSUeTKDoWLt2mgwsP4X+ouT16QIDAQAB
o0IwQDAdBgNVHQ4EFgQUnCW66rguV+CMWB4qWelnjPwMoV0wEgYDVR0TAQH/BAgw
BgEB/wIBADALBgNVHQ8EBAMCAQYwDQYJKoZIhvcNAQEMBQADggGBAC0JQDGjl+oY
GcvpvSMylDo7SY+WvvB1bGaW3lPoh97qPdVdlyAQKKSu4np/hygeJvaX3+I4Ongw
GVrdP0OsqIcB51W7c/ktg5BhBhKyXyiXDLHKHovIB0kyK3x4D9J2jHfjBjYzD/eX
Fy52AFyZnvVeiRjOh2ZCUpKdCKRjQwtX5c36NQEMn6APCy3toduSPoxjsTsnUan3
Kq1bAq2YPnwkNwfpNHE2IYqTnAhp+EjJDPmttcFtxoDQBbQ1V0Ug2oxWxj2N7w2S
x6rWF3XTf3jl7ZX5FNk2s6es4BXXw9A0Jylq80t1TZ3BKJdS9thCyWrLlrOcb//W
qaqxZtOfqyAG8FgFRO22cHJEEi0oANKU9ZNMG7zq83oEKkFFs5eGE+9eiBCXP/sx
oUpw/Le4D6oajrqHMp2R2bQXbX+tId2RCsYTwF+3MkRJk0cCQQa3d6d7S8/c/y8c
XgcnmXkdLOsegv6Xt7BsCxNn74YNxwJCP34LdUaFmkPIC2ZcVqY+Gg==
-----END CERTIFICATE-----
---
Server certificate
subject=ST=service, O=91d53eb7-0090-4389-ac7a-b017e629b664, CN=postgress18-cluster-phluit-4
issuer=CN=91d53eb7-0090-4389-ac7a-b017e629b664 Project CA
---
Acceptable client certificate CA names
O=None, OU=64c78045-23aa-4275-8aef-87518a39536e, CN=ROOT Aiven CA Certificate
CN=91d53eb7-0090-4389-ac7a-b017e629b664 Project CA
Requested Signature Algorithms: ECDSA+SHA256:ECDSA+SHA384:ECDSA+SHA512:ed25519:ed448:rsa_pss_pss_sha256:rsa_pss_pss_sha384:rsa_pss_pss_sha512:RSA-PSS+SHA256:RSA-PSS+SHA384:RSA-PSS+SHA512:RSA+SHA256:RSA+SHA384:RSA+SHA512:ECDSA+SHA224:RSA+SHA224
Shared Requested Signature Algorithms: ECDSA+SHA256:ECDSA+SHA384:ECDSA+SHA512:ed25519:ed448:rsa_pss_pss_sha256:rsa_pss_pss_sha384:rsa_pss_pss_sha512:RSA-PSS+SHA256:RSA-PSS+SHA384:RSA-PSS+SHA512:RSA+SHA256:RSA+SHA384:RSA+SHA512
Peer signing digest: SHA256
Peer signature type: rsa_pss_rsae_sha256
Peer Temp Key: X25519, 253 bits
---
SSL handshake has read 3980 bytes and written 1726 bytes
Verification error: self-signed certificate in certificate chain
---
New, TLSv1.3, Cipher is TLS_AES_256_GCM_SHA384
Protocol: TLSv1.3
Server public key is 3072 bit
This TLS version forbids renegotiation.
Compression: NONE
Expansion: NONE
No ALPN negotiated
Early data was not sent
Verify return code: 19 (self-signed certificate in certificate chain)
---
---
Post-Handshake New Session Ticket arrived:
SSL-Session:
Protocol : TLSv1.3
Cipher : TLS_AES_256_GCM_SHA384
Session-ID: DBFDB56316C16BACD7CDEF38F538BCF0D7AB03CC806F32D69039D653A5960C5B
Session-ID-ctx:
Resumption PSK: 1E99F47CD7CB57DA1F1FDE52B6D095F71500815125144362AAB5F13EFC87CC6C545343FFF6FD943E63EBBAEC946F5025
PSK identity: None
PSK identity hint: None
SRP username: None
TLS session ticket lifetime hint: 7200 (seconds)
TLS session ticket:
0000 - cd cd e0 c7 0b b5 c1 7a-e9 54 ba 03 89 7d 1a 01 .......z.T...}..
0010 - 38 df 01 03 c0 d9 ea ba-94 4a f6 9e 65 0c 46 dd 8........J..e.F.
0020 - 64 e6 c1 9a af e3 25 d2-bb 29 0a 8f a1 89 4e 9b d.....%..)....N.
0030 - b4 4c 41 6a 0b 96 a4 ee-a2 7f 3b 9f b8 80 fd b3 .LAj......;.....
0040 - 58 55 a4 c9 f6 71 df e9-a8 63 35 55 28 36 19 2e XU...q...c5U(6..
0050 - 21 c4 af 42 39 1e e4 b3-6c 11 42 77 31 c3 f0 81 !..B9...l.Bw1...
0060 - 5a 3b bb 94 61 61 32 0d-b2 72 dd 2f 8b 4b 68 63 Z;..aa2..r./.Khc
0070 - ee 0d fc 01 49 40 0f 2d-dc 65 1c 36 74 dd 89 29 ....I@.-.e.6t..)
0080 - 04 dd 5d 81 55 be 5e bf-04 b4 c6 21 a5 5c 03 5c ..].U.^....!.\.\
0090 - 7e 81 46 46 e9 7a 76 29-66 73 62 97 c9 22 21 b8 ~.FF.zv)fsb.."!.
00a0 - 81 90 fa 6e ac a2 26 55-0f de 2a 7c 6e 8b 94 d8 ...n..&U..*|n...
00b0 - 7e 15 9a 4f 31 06 45 d9-9c d7 05 4d 61 47 40 18 ~..O1.E....MaG@.
00c0 - 77 c3 5f 7e 66 74 58 c2-85 47 1c 67 4c f2 05 3c w._~ftX..G.gL..<
00d0 - 1a dc 8b b8 fd 00 b9 09-f8 bf a1 4e 8b 43 e0 7a ...........N.C.z
00e0 - 6b d7 ee 79 16 a8 7d eb-1b 64 ac a8 06 4b c3 ee k..y..}..d...K..
Start Time: 1784307743
Timeout : 7200 (sec)
Verify return code: 19 (self-signed certificate in certificate chain)
Extended master secret: no
Max Early Data: 0
---
read R BLOCK
---
Post-Handshake New Session Ticket arrived:
SSL-Session:
Protocol : TLSv1.3
Cipher : TLS_AES_256_GCM_SHA384
Session-ID: BAB01FA405C056F9F5291617C230611665884B1ECF63DF775A9851DC9E61DB9A
Session-ID-ctx:
Resumption PSK: 2EF7A5FCFE32882D91EDCBA48363D8AB406BEBE885E69C686CF91DE52227314C82A9C03D50D940F4B97D28367596683C
PSK identity: None
PSK identity hint: None
SRP username: None
TLS session ticket lifetime hint: 7200 (seconds)
TLS session ticket:
0000 - cd cd e0 c7 0b b5 c1 7a-e9 54 ba 03 89 7d 1a 01 .......z.T...}..
0010 - d2 3e 9a 3c 46 d9 35 4b-2b 89 c6 b6 99 41 f3 bb .>.<F.5K+....A..
0020 - 3b 65 15 0a d9 82 d5 f0-91 06 81 34 69 4f 6f b4 ;e.........4iOo.
0030 - 06 97 93 05 48 d9 cf a2-e4 b7 e3 86 06 33 22 a7 ....H........3".
0040 - a9 1a 44 be 08 14 11 bd-f8 f4 00 b9 ec 05 aa 37 ..D............7
0050 - e5 74 6c e3 a4 d1 0b 97-ab b9 91 2f b4 7d 25 b1 .tl......../.}%.
0060 - 5a 3c 33 fe ab 91 72 6f-19 94 95 08 3e 96 28 f2 Z<3...ro....>.(.
0070 - ad 80 eb ca e7 e7 b8 ce-01 ae 45 7b da 6c 02 8d ..........E{.l..
0080 - 52 fb 39 ce ee ee 19 27-42 4e 8c c1 fb c5 9e 82 R.9....'BN......
0090 - 61 94 0f 85 1b be 52 c3-76 65 b9 04 0d 66 f0 0f a.....R.ve...f..
00a0 - 82 5c d8 47 75 f0 da c2-0b 0c 3e ee a1 57 ea b2 .\.Gu.....>..W..
00b0 - 96 c6 e5 7d 4c 38 02 95-25 b1 59 e4 e0 c3 5a ac ...}L8..%.Y...Z.
00c0 - ce 22 9a 45 62 60 17 7b-fe eb 03 53 93 d1 b3 b1 .".Eb`.{...S....
00d0 - e6 3e 8b 0a ce 9d f4 24-67 63 ef bc 7a a8 1f 9d .>.....$gc..z...
00e0 - 38 e3 59 68 77 2c 9d e5-b7 a1 cc 33 6f 45 7c 90 8.Yhw,.....3oE|.
Start Time: 1784307743
Timeout : 7200 (sec)
Verify return code: 19 (self-signed certificate in certificate chain)
Extended master secret: no
Max Early Data: 0
---
read R BLOCK
+38
View File
@@ -0,0 +1,38 @@
# Optional Compose file for Dokploy's "Compose" deployment type.
#
# The RECOMMENDED path is a Dokploy "Application" pointed at this repo with build type = Dockerfile
# (Dokploy then manages Traefik routing, the domain, TLS, and env injection for you). This file is
# provided for teams who prefer a Compose deployment.
#
# Runtime env vars (DATABASE_URL, SESSION_SECRET, SPACES_*, etc.) come from Dokploy's Environment
# settings — do NOT hardcode them here. VITE_* values are build-time only (see build.args below).
services:
app:
build:
context: .
dockerfile: Dockerfile
args:
# Build-time public config inlined into the browser bundle by Vite.
VITE_TURNSTILE_SITE_KEY: ${VITE_TURNSTILE_SITE_KEY:-}
VITE_SENTRY_DSN: ${VITE_SENTRY_DSN:-}
restart: unless-stopped
environment:
NODE_ENV: production
PORT: 8080
# All other runtime variables are injected by Dokploy (Environment settings). Keep the full
# list from .env.example in sync there: PUBLIC_URL, COOKIE_DOMAIN, SESSION_SECRET, CSRF_SECRET,
# SUPERADMIN_EMAILS, DATABASE_URL, DATABASE_CA_CERT_PATH, SPACES_*, SMTP2GO_API_KEY, EMAIL_FROM,
# ANTHROPIC_API_KEY, TURNSTILE_SECRET_KEY, STRIPE_*, SENTRY_DSN_API.
expose:
- "8080"
healthcheck:
test:
- CMD
- node
- -e
- "fetch('http://127.0.0.1:8080/api/health').then(r=>{if(!r.ok)process.exit(1)}).catch(()=>process.exit(1))"
interval: 30s
timeout: 5s
retries: 3
start_period: 25s
+63
View File
@@ -22,6 +22,7 @@
"name": "@lawdesk/api", "name": "@lawdesk/api",
"version": "0.1.0", "version": "0.1.0",
"dependencies": { "dependencies": {
"@anthropic-ai/sdk": "^0.111.0",
"@aws-sdk/client-s3": "^3.1088.0", "@aws-sdk/client-s3": "^3.1088.0",
"@aws-sdk/s3-request-presigner": "^3.1088.0", "@aws-sdk/s3-request-presigner": "^3.1088.0",
"@fastify/cookie": "^11.0.1", "@fastify/cookie": "^11.0.1",
@@ -97,6 +98,27 @@
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
}, },
"node_modules/@anthropic-ai/sdk": {
"version": "0.111.0",
"resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.111.0.tgz",
"integrity": "sha512-1hUqKi+uJQoS5X90+InwHbFAXMvgq0DnsC5hVLEeSRaODiU5WvmqDAcVCmGS2wC0pN9Z8jtWCbWw7JLzeDdm/Q==",
"license": "MIT",
"dependencies": {
"json-schema-to-ts": "^3.1.1",
"standardwebhooks": "^1.0.0"
},
"bin": {
"anthropic-ai-sdk": "bin/cli"
},
"peerDependencies": {
"zod": "^3.25.0 || ^4.0.0"
},
"peerDependenciesMeta": {
"zod": {
"optional": true
}
}
},
"node_modules/@aws-sdk/checksums": { "node_modules/@aws-sdk/checksums": {
"version": "3.1000.18", "version": "3.1000.18",
"resolved": "https://registry.npmjs.org/@aws-sdk/checksums/-/checksums-3.1000.18.tgz", "resolved": "https://registry.npmjs.org/@aws-sdk/checksums/-/checksums-3.1000.18.tgz",
@@ -3125,6 +3147,12 @@
"node": ">=18.0.0" "node": ">=18.0.0"
} }
}, },
"node_modules/@stablelib/base64": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@stablelib/base64/-/base64-1.0.1.tgz",
"integrity": "sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==",
"license": "MIT"
},
"node_modules/@swc/helpers": { "node_modules/@swc/helpers": {
"version": "0.3.17", "version": "0.3.17",
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.3.17.tgz", "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.3.17.tgz",
@@ -4806,6 +4834,12 @@
"integrity": "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==", "integrity": "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==",
"dev": true "dev": true
}, },
"node_modules/fast-sha256": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/fast-sha256/-/fast-sha256-1.3.0.tgz",
"integrity": "sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==",
"license": "Unlicense"
},
"node_modules/fast-uri": { "node_modules/fast-uri": {
"version": "3.1.0", "version": "3.1.0",
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz",
@@ -5647,6 +5681,19 @@
"dequal": "^2.0.3" "dequal": "^2.0.3"
} }
}, },
"node_modules/json-schema-to-ts": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/json-schema-to-ts/-/json-schema-to-ts-3.1.1.tgz",
"integrity": "sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.18.3",
"ts-algebra": "^2.0.0"
},
"engines": {
"node": ">=16"
}
},
"node_modules/json-schema-traverse": { "node_modules/json-schema-traverse": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
@@ -7240,6 +7287,16 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/standardwebhooks": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/standardwebhooks/-/standardwebhooks-1.0.0.tgz",
"integrity": "sha512-BbHGOQK9olHPMvQNHWul6MYlrRTAOKn03rOe4A8O3CLWhNf4YHBqq2HJKKC+sfqpxiBY52pNeesD6jIiLDz8jg==",
"license": "MIT",
"dependencies": {
"@stablelib/base64": "^1.0.0",
"fast-sha256": "^1.3.0"
}
},
"node_modules/statuses": { "node_modules/statuses": {
"version": "2.0.2", "version": "2.0.2",
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
@@ -7564,6 +7621,12 @@
"node": ">=0.6" "node": ">=0.6"
} }
}, },
"node_modules/ts-algebra": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/ts-algebra/-/ts-algebra-2.0.0.tgz",
"integrity": "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==",
"license": "MIT"
},
"node_modules/ts-interface-checker": { "node_modules/ts-interface-checker": {
"version": "0.1.13", "version": "0.1.13",
"resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz",