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>
This commit is contained in:
co-authored by
Claude Fable 5
parent
d1d96e4dd2
commit
304f7f30c3
+18
-1
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
@@ -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(',')
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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));
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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: '',
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Generated
+623
-381
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
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 }),
|
||||||
|
|||||||
@@ -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
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user