Compare commits
7
Commits
d1d96e4dd2
..
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
319d94b03c | ||
|
|
605c0e0052 | ||
|
|
434de547ce | ||
|
|
eb36b81dc9 | ||
|
|
4a1122a7c9 | ||
|
|
1f249dc126 | ||
|
|
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.
|
||||||
|
|||||||
@@ -29,3 +29,16 @@ jobs:
|
|||||||
|
|
||||||
- name: Build web
|
- name: Build web
|
||||||
run: npm run build
|
run: npm run build
|
||||||
|
|
||||||
|
# Gate: any HIGH/CRITICAL advisory in a dependency that actually ships to production fails
|
||||||
|
# the build. Scoped with --omit=dev on purpose — the dev-only toolchain (vite, esbuild,
|
||||||
|
# drizzle-kit) carries advisories that only affect a developer's local dev server, and
|
||||||
|
# blocking every PR on those trains people to ignore the gate.
|
||||||
|
- name: Audit production dependencies
|
||||||
|
run: npm audit --omit=dev --audit-level=high
|
||||||
|
|
||||||
|
# Full picture, including dev tooling. Never blocks — it's here so regressions are visible
|
||||||
|
# in the log and someone can act on them deliberately.
|
||||||
|
- name: Audit report (informational, includes dev)
|
||||||
|
if: always()
|
||||||
|
run: npm audit || true
|
||||||
|
|||||||
+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
|
||||||
|
|||||||
@@ -22,12 +22,12 @@
|
|||||||
"@fastify/helmet": "^12.0.1",
|
"@fastify/helmet": "^12.0.1",
|
||||||
"@fastify/multipart": "^9.0.1",
|
"@fastify/multipart": "^9.0.1",
|
||||||
"@fastify/rate-limit": "^10.2.1",
|
"@fastify/rate-limit": "^10.2.1",
|
||||||
"@fastify/static": "^8.0.3",
|
"@fastify/static": "^10.1.3",
|
||||||
"@lawdesk/db": "*",
|
"@lawdesk/db": "*",
|
||||||
"@sentry/node": "^8.45.0",
|
"@sentry/node": "^10.71.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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import { SESSION_COOKIE, loadSession } from './sessions';
|
|||||||
import { isProd, env } from '../env';
|
import { isProd, env } from '../env';
|
||||||
import { ensureSuperadminFlag } from './superadmin';
|
import { ensureSuperadminFlag } from './superadmin';
|
||||||
|
|
||||||
|
export type FirmRole = 'owner' | 'attorney' | 'paralegal' | 'staff';
|
||||||
|
|
||||||
declare module 'fastify' {
|
declare module 'fastify' {
|
||||||
interface FastifyRequest {
|
interface FastifyRequest {
|
||||||
user?: {
|
user?: {
|
||||||
@@ -14,12 +16,17 @@ declare module 'fastify' {
|
|||||||
isSuperadmin: boolean;
|
isSuperadmin: boolean;
|
||||||
isSuspended: boolean;
|
isSuspended: boolean;
|
||||||
emailVerified: boolean;
|
emailVerified: boolean;
|
||||||
|
/** Superadmin id when this session was opened via admin impersonation, else null. */
|
||||||
|
impersonatedBy: string | null;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
interface FastifyInstance {
|
interface FastifyInstance {
|
||||||
requireAuth: (req: FastifyRequest, reply: FastifyReply) => Promise<void>;
|
requireAuth: (req: FastifyRequest, reply: FastifyReply) => Promise<void>;
|
||||||
requireFirm: (req: FastifyRequest, reply: FastifyReply) => Promise<void>;
|
requireFirm: (req: FastifyRequest, reply: FastifyReply) => Promise<void>;
|
||||||
requireSuperadmin: (req: FastifyRequest, reply: FastifyReply) => Promise<void>;
|
requireSuperadmin: (req: FastifyRequest, reply: FastifyReply) => Promise<void>;
|
||||||
|
requireRole: (
|
||||||
|
...roles: FirmRole[]
|
||||||
|
) => (req: FastifyRequest, reply: FastifyReply) => Promise<void>;
|
||||||
setSessionCookie: (reply: FastifyReply, token: string, expiresAt: Date) => void;
|
setSessionCookie: (reply: FastifyReply, token: string, expiresAt: Date) => void;
|
||||||
clearSessionCookie: (reply: FastifyReply) => void;
|
clearSessionCookie: (reply: FastifyReply) => void;
|
||||||
}
|
}
|
||||||
@@ -49,6 +56,7 @@ async function plugin(app: FastifyInstance) {
|
|||||||
isSuperadmin,
|
isSuperadmin,
|
||||||
isSuspended: session.user.isSuspended,
|
isSuspended: session.user.isSuspended,
|
||||||
emailVerified: Boolean(session.user.emailVerifiedAt),
|
emailVerified: Boolean(session.user.emailVerifiedAt),
|
||||||
|
impersonatedBy: session.session.impersonatedBy,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -65,9 +73,30 @@ 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' });
|
||||||
|
// A superadmin who impersonated their way into a firm must not be able to steer the
|
||||||
|
// platform-wide admin surface from inside that borrowed session.
|
||||||
|
if (req.user.impersonatedBy) return reply.code(403).send({ error: 'forbidden_while_impersonating' });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Role gate for firm-scoped routes that only some members of a firm should reach (billing,
|
||||||
|
// full-firm export, account deletion). Compose AFTER requireFirm — it assumes req.user is set
|
||||||
|
// and firmId is present. Superadmins pass, unless they're inside an impersonated session, in
|
||||||
|
// which case they get exactly the target user's real authority and nothing more.
|
||||||
|
app.decorate(
|
||||||
|
'requireRole',
|
||||||
|
(...roles: FirmRole[]) =>
|
||||||
|
async (req: FastifyRequest, reply: FastifyReply) => {
|
||||||
|
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 && !req.user.impersonatedBy) return;
|
||||||
|
if (!roles.includes(req.user.role as FirmRole)) {
|
||||||
|
return reply.code(403).send({ error: 'insufficient_role', required: roles });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
app.decorate('setSessionCookie', (reply: FastifyReply, token: string, expiresAt: Date) => {
|
app.decorate('setSessionCookie', (reply: FastifyReply, token: string, expiresAt: Date) => {
|
||||||
reply.setCookie(SESSION_COOKIE, token, {
|
reply.setCookie(SESSION_COOKIE, token, {
|
||||||
path: '/',
|
path: '/',
|
||||||
|
|||||||
@@ -4,6 +4,9 @@ import { getDb, sessions, users } from '@lawdesk/db';
|
|||||||
|
|
||||||
const SESSION_BYTES = 32;
|
const SESSION_BYTES = 32;
|
||||||
const SESSION_TTL_DAYS = 30;
|
const SESSION_TTL_DAYS = 30;
|
||||||
|
// Impersonation sessions are support tools, not logins: they expire in an hour so an admin who
|
||||||
|
// walks away can't leave a live session inside a customer's firm for the normal 30 days.
|
||||||
|
const IMPERSONATION_TTL_MS = 60 * 60 * 1000;
|
||||||
|
|
||||||
export const SESSION_COOKIE = 'sid';
|
export const SESSION_COOKIE = 'sid';
|
||||||
|
|
||||||
@@ -19,17 +22,23 @@ export interface CreateSessionOpts {
|
|||||||
userId: string;
|
userId: string;
|
||||||
ip?: string | null;
|
ip?: string | null;
|
||||||
userAgent?: string | null;
|
userAgent?: string | null;
|
||||||
|
/** Superadmin id when this session is an impersonation of `userId`. */
|
||||||
|
impersonatedBy?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createSession(opts: CreateSessionOpts): Promise<{ token: string; expiresAt: Date }> {
|
export async function createSession(opts: CreateSessionOpts): Promise<{ token: string; expiresAt: Date }> {
|
||||||
const token = generateSessionToken();
|
const token = generateSessionToken();
|
||||||
const id = hashSessionToken(token);
|
const id = hashSessionToken(token);
|
||||||
const expiresAt = new Date(Date.now() + SESSION_TTL_DAYS * 24 * 60 * 60 * 1000);
|
const impersonatedBy = opts.impersonatedBy ?? null;
|
||||||
|
const expiresAt = new Date(
|
||||||
|
Date.now() + (impersonatedBy ? IMPERSONATION_TTL_MS : SESSION_TTL_DAYS * 24 * 60 * 60 * 1000),
|
||||||
|
);
|
||||||
|
|
||||||
await getDb().insert(sessions).values({
|
await getDb().insert(sessions).values({
|
||||||
id,
|
id,
|
||||||
userId: opts.userId,
|
userId: opts.userId,
|
||||||
expiresAt,
|
expiresAt,
|
||||||
|
impersonatedBy,
|
||||||
ip: opts.ip ?? null,
|
ip: opts.ip ?? null,
|
||||||
userAgent: opts.userAgent ?? null,
|
userAgent: opts.userAgent ?? null,
|
||||||
});
|
});
|
||||||
|
|||||||
+14
-1
@@ -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
|
||||||
|
// 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') });
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import type { FastifyRequest } from 'fastify';
|
||||||
import { getDb, auditLog } from '@lawdesk/db';
|
import { getDb, auditLog } from '@lawdesk/db';
|
||||||
|
|
||||||
export interface AuditEntry {
|
export interface AuditEntry {
|
||||||
@@ -6,14 +7,44 @@ export interface AuditEntry {
|
|||||||
action: string;
|
action: string;
|
||||||
meta?: unknown;
|
meta?: unknown;
|
||||||
ip?: string | null;
|
ip?: string | null;
|
||||||
|
/** Superadmin id when the acting session is an impersonation. */
|
||||||
|
impersonatedBy?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function logAudit(entry: AuditEntry): Promise<void> {
|
export async function logAudit(entry: AuditEntry): Promise<void> {
|
||||||
|
// The impersonating admin is folded into `meta` rather than a dedicated column so existing
|
||||||
|
// rows stay valid: an entry written during impersonation records BOTH the user the action
|
||||||
|
// appears to come from and the admin who actually performed it.
|
||||||
|
const meta =
|
||||||
|
entry.impersonatedBy != null
|
||||||
|
? { ...(typeof entry.meta === 'object' && entry.meta !== null ? entry.meta : { value: entry.meta }), impersonatedBy: entry.impersonatedBy }
|
||||||
|
: entry.meta;
|
||||||
|
|
||||||
await getDb().insert(auditLog).values({
|
await getDb().insert(auditLog).values({
|
||||||
userId: entry.userId ?? null,
|
userId: entry.userId ?? null,
|
||||||
firmId: entry.firmId ?? null,
|
firmId: entry.firmId ?? null,
|
||||||
action: entry.action,
|
action: entry.action,
|
||||||
meta: entry.meta == null ? null : JSON.stringify(entry.meta),
|
meta: meta == null ? null : JSON.stringify(meta),
|
||||||
ip: entry.ip ?? null,
|
ip: entry.ip ?? null,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Audit an action performed by the current request's user. Carries the impersonating admin
|
||||||
|
* through automatically, so a support session can never write an audit trail that looks like
|
||||||
|
* the customer acted alone.
|
||||||
|
*/
|
||||||
|
export async function logAuditFromRequest(
|
||||||
|
req: FastifyRequest,
|
||||||
|
action: string,
|
||||||
|
extra: { firmId?: string | null; meta?: unknown } = {},
|
||||||
|
): Promise<void> {
|
||||||
|
await logAudit({
|
||||||
|
userId: req.user?.id ?? null,
|
||||||
|
firmId: extra.firmId !== undefined ? extra.firmId : (req.user?.firmId ?? null),
|
||||||
|
action,
|
||||||
|
meta: extra.meta,
|
||||||
|
ip: req.ip,
|
||||||
|
impersonatedBy: req.user?.impersonatedBy ?? null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
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.
|
||||||
|
*
|
||||||
|
* Advisory only — it reads the counter without holding a lock, so two uploads racing here can
|
||||||
|
* both pass. `reserveStorage` is the authoritative check; this exists to reject an oversized
|
||||||
|
* upload cheaply, before the bytes are written to Spaces.
|
||||||
|
*/
|
||||||
|
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 claim `bytes` of the firm's quota. The check and the increment happen in ONE
|
||||||
|
* statement — the conditional UPDATE only matches while the firm is still under its cap, so
|
||||||
|
* concurrent uploads serialise on the row and cannot both squeeze past the limit (the previous
|
||||||
|
* read-then-write pair let N parallel uploads each see the same pre-upload total).
|
||||||
|
*
|
||||||
|
* Returns true when the space was claimed. On false the caller must reject the upload.
|
||||||
|
*/
|
||||||
|
export async function reserveStorage(
|
||||||
|
firmId: string,
|
||||||
|
plan: PlanName,
|
||||||
|
bytes: number,
|
||||||
|
): Promise<boolean> {
|
||||||
|
const limit = PLAN_LIMITS[plan].storageBytes;
|
||||||
|
|
||||||
|
const result = await getDb()
|
||||||
|
.update(firms)
|
||||||
|
.set({ storageBytesUsed: sql`${firms.storageBytesUsed} + ${bytes}` })
|
||||||
|
.where(
|
||||||
|
limit === null
|
||||||
|
? eq(firms.id, firmId)
|
||||||
|
: sql`${firms.id} = ${firmId} and ${firms.storageBytesUsed} + ${bytes} <= ${limit}`,
|
||||||
|
)
|
||||||
|
.returning({ id: firms.id });
|
||||||
|
|
||||||
|
return result.length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Give back bytes previously reserved — on document delete, or to roll back a reservation whose
|
||||||
|
* upload then failed. Clamped at 0 so a double release can never drive the counter negative.
|
||||||
|
*/
|
||||||
|
export async function releaseStorage(firmId: string, bytes: number): Promise<void> {
|
||||||
|
await getDb()
|
||||||
|
.update(firms)
|
||||||
|
.set({ storageBytesUsed: sql`GREATEST(0, ${firms.storageBytesUsed} - ${bytes})` })
|
||||||
|
.where(eq(firms.id, firmId));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adjust the firm's tracked storage usage by an arbitrary delta. Retained for callers that
|
||||||
|
* aren't part of the upload path; prefer reserveStorage/releaseStorage.
|
||||||
|
*/
|
||||||
|
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));
|
||||||
|
}
|
||||||
@@ -14,15 +14,28 @@ import {
|
|||||||
sessions,
|
sessions,
|
||||||
} from '@lawdesk/db';
|
} from '@lawdesk/db';
|
||||||
import { verifyPassword } from '../auth/password';
|
import { verifyPassword } from '../auth/password';
|
||||||
import { logAudit } from '../lib/audit';
|
import { logAuditFromRequest } from '../lib/audit';
|
||||||
import { sendEmail, accountDeletedEmail } from '../lib/email';
|
import { sendEmail, accountDeletedEmail } from '../lib/email';
|
||||||
import { deletePrefix, getSignedDownloadUrl } from '../lib/storage';
|
import { deletePrefix, getSignedDownloadUrl } from '../lib/storage';
|
||||||
|
|
||||||
|
// Everything under /api/account acts on the whole firm, not just the caller's own rows, so it is
|
||||||
|
// owner-only. Without this an 'attorney'/'paralegal'/'staff' member could export every client
|
||||||
|
// file the firm holds, or delete the firm outright.
|
||||||
|
const OWNER_ONLY = ['owner'] as const;
|
||||||
|
|
||||||
|
const EXPORT_URL_TTL_SECONDS = 15 * 60;
|
||||||
|
|
||||||
export async function accountRoutes(app: FastifyInstance) {
|
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' } },
|
||||||
|
preHandler: app.requireRole(...OWNER_ONLY),
|
||||||
|
},
|
||||||
|
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();
|
||||||
@@ -62,14 +75,18 @@ export async function accountRoutes(app: FastifyInstance) {
|
|||||||
const docs = await db.select().from(documents).where(eq(documents.firmId, firmId));
|
const docs = await db.select().from(documents).where(eq(documents.firmId, firmId));
|
||||||
|
|
||||||
// GDPR portability covers the files themselves, not just their metadata — attach a
|
// GDPR portability covers the files themselves, not just their metadata — attach a
|
||||||
// time-limited presigned download URL per document (valid 24h; re-export for fresh links).
|
// time-limited presigned download URL per document. These are unauthenticated bearer URLs
|
||||||
|
// to privileged client material sitting inside a file the user may forward or archive, so
|
||||||
|
// the window is deliberately short (15 minutes): long enough to run the downloads straight
|
||||||
|
// after exporting, short enough that a leaked export is not a document breach. Re-export
|
||||||
|
// for fresh links.
|
||||||
const docsWithUrls = await Promise.all(
|
const docsWithUrls = await Promise.all(
|
||||||
docs.map(async (d) => {
|
docs.map(async (d) => {
|
||||||
try {
|
try {
|
||||||
const downloadUrl = await getSignedDownloadUrl(d.storageKey, d.name, 24 * 60 * 60);
|
const downloadUrl = await getSignedDownloadUrl(d.storageKey, d.name, EXPORT_URL_TTL_SECONDS);
|
||||||
return { ...d, downloadUrl, downloadUrlExpiresInHours: 24 };
|
return { ...d, downloadUrl, downloadUrlExpiresInMinutes: EXPORT_URL_TTL_SECONDS / 60 };
|
||||||
} catch {
|
} catch {
|
||||||
return { ...d, downloadUrl: null, downloadUrlExpiresInHours: null };
|
return { ...d, downloadUrl: null, downloadUrlExpiresInMinutes: null };
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
@@ -85,11 +102,9 @@ export async function accountRoutes(app: FastifyInstance) {
|
|||||||
dump.documents = docsWithUrls;
|
dump.documents = docsWithUrls;
|
||||||
}
|
}
|
||||||
|
|
||||||
await logAudit({
|
await logAuditFromRequest(req, 'account.export', {
|
||||||
userId,
|
|
||||||
firmId,
|
firmId,
|
||||||
action: 'account.export',
|
meta: { documentUrlsIssued: dump.documents ? (dump.documents as unknown[]).length : 0 },
|
||||||
ip: req.ip,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
reply
|
reply
|
||||||
@@ -103,7 +118,7 @@ export async function accountRoutes(app: FastifyInstance) {
|
|||||||
|
|
||||||
// GDPR delete — password-confirmed. Solo firms cascade everything; multi-user firms must
|
// GDPR delete — password-confirmed. Solo firms cascade everything; multi-user firms must
|
||||||
// transfer ownership first (we'll add a transfer endpoint when we add team management).
|
// transfer ownership first (we'll add a transfer endpoint when we add team management).
|
||||||
app.post('/api/account/delete', async (req, reply) => {
|
app.post('/api/account/delete', { preHandler: app.requireRole(...OWNER_ONLY) }, async (req, reply) => {
|
||||||
const userId = req.user!.id;
|
const userId = req.user!.id;
|
||||||
const firmId = req.user!.firmId;
|
const firmId = req.user!.firmId;
|
||||||
const body = z.object({ password: z.string().min(1) }).parse(req.body);
|
const body = z.object({ password: z.string().min(1) }).parse(req.body);
|
||||||
@@ -129,13 +144,7 @@ export async function accountRoutes(app: FastifyInstance) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
await logAudit({
|
await logAuditFromRequest(req, 'account.delete', { firmId, meta: { email: me.email } });
|
||||||
userId,
|
|
||||||
firmId,
|
|
||||||
action: 'account.delete',
|
|
||||||
meta: { email: me.email },
|
|
||||||
ip: req.ip,
|
|
||||||
});
|
|
||||||
|
|
||||||
await db.transaction(async (tx) => {
|
await db.transaction(async (tx) => {
|
||||||
await tx.delete(sessions).where(eq(sessions.userId, userId));
|
await tx.delete(sessions).where(eq(sessions.userId, userId));
|
||||||
|
|||||||
@@ -266,7 +266,9 @@ export async function adminRoutes(app: FastifyInstance) {
|
|||||||
return updated;
|
return updated;
|
||||||
});
|
});
|
||||||
|
|
||||||
// Impersonate: end the current session, start a new one for the target user.
|
// Impersonate: end the current session, start a short-lived one for the target user that is
|
||||||
|
// permanently stamped with the acting admin's id. Every audit row written from that session
|
||||||
|
// carries `impersonatedBy`, so support activity can never be mistaken for the customer's own.
|
||||||
app.post('/api/admin/users/:id/impersonate', async (req, reply) => {
|
app.post('/api/admin/users/:id/impersonate', async (req, reply) => {
|
||||||
const { id } = idParam.parse(req.params);
|
const { id } = idParam.parse(req.params);
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
@@ -275,7 +277,11 @@ export async function adminRoutes(app: FastifyInstance) {
|
|||||||
if (!target) return reply.code(404).send({ error: 'not_found' });
|
if (!target) return reply.code(404).send({ error: 'not_found' });
|
||||||
if (target.isSuspended) return reply.code(409).send({ error: 'target_suspended' });
|
if (target.isSuspended) return reply.code(409).send({ error: 'target_suspended' });
|
||||||
if (target.id === req.user!.id) return reply.code(409).send({ error: 'cannot_impersonate_self' });
|
if (target.id === req.user!.id) return reply.code(409).send({ error: 'cannot_impersonate_self' });
|
||||||
|
// Never let one platform admin borrow another's identity — that would launder an action
|
||||||
|
// between two accounts that both hold full platform authority.
|
||||||
|
if (target.isSuperadmin) return reply.code(409).send({ error: 'cannot_impersonate_superadmin' });
|
||||||
|
|
||||||
|
const adminId = req.user!.id;
|
||||||
const oldToken = req.cookies?.[SESSION_COOKIE];
|
const oldToken = req.cookies?.[SESSION_COOKIE];
|
||||||
if (oldToken) await destroySession(oldToken);
|
if (oldToken) await destroySession(oldToken);
|
||||||
|
|
||||||
@@ -283,19 +289,24 @@ export async function adminRoutes(app: FastifyInstance) {
|
|||||||
userId: target.id,
|
userId: target.id,
|
||||||
ip: req.ip,
|
ip: req.ip,
|
||||||
userAgent: req.headers['user-agent'] ?? null,
|
userAgent: req.headers['user-agent'] ?? null,
|
||||||
|
impersonatedBy: adminId,
|
||||||
});
|
});
|
||||||
app.setSessionCookie(reply, token, expiresAt);
|
app.setSessionCookie(reply, token, expiresAt);
|
||||||
app.setCsrfCookie(reply, generateCsrfToken());
|
app.setCsrfCookie(reply, generateCsrfToken());
|
||||||
|
|
||||||
await logAudit({
|
await logAudit({
|
||||||
userId: req.user!.id,
|
userId: adminId,
|
||||||
firmId: target.firmId,
|
firmId: target.firmId,
|
||||||
action: 'admin.impersonate',
|
action: 'admin.impersonate.start',
|
||||||
meta: { targetUserId: target.id, targetEmail: target.email },
|
meta: { targetUserId: target.id, targetEmail: target.email, expiresAt: expiresAt.toISOString() },
|
||||||
ip: req.ip,
|
ip: req.ip,
|
||||||
});
|
});
|
||||||
|
|
||||||
return { ok: true, impersonating: { id: target.id, email: target.email, firmId: target.firmId } };
|
return {
|
||||||
|
ok: true,
|
||||||
|
impersonating: { id: target.id, email: target.email, firmId: target.firmId },
|
||||||
|
expiresAt: expiresAt.toISOString(),
|
||||||
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
// ─────────────────────────── Contact inbox ───────────────────────────
|
// ─────────────────────────── Contact inbox ───────────────────────────
|
||||||
|
|||||||
@@ -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';
|
||||||
@@ -24,6 +24,7 @@ import {
|
|||||||
} from '../lib/email';
|
} from '../lib/email';
|
||||||
import { env } from '../env';
|
import { env } from '../env';
|
||||||
import { verifyTurnstile } from '../lib/turnstile';
|
import { verifyTurnstile } from '../lib/turnstile';
|
||||||
|
import { logAudit } from '../lib/audit';
|
||||||
|
|
||||||
const signupBody = z.object({
|
const signupBody = z.object({
|
||||||
email: z.string().email().max(254).toLowerCase().trim(),
|
email: z.string().email().max(254).toLowerCase().trim(),
|
||||||
@@ -143,6 +144,7 @@ export async function authRoutes(app: FastifyInstance) {
|
|||||||
isSuperadmin,
|
isSuperadmin,
|
||||||
isSuspended: user.isSuspended,
|
isSuspended: user.isSuspended,
|
||||||
emailVerified: Boolean(user.emailVerifiedAt),
|
emailVerified: Boolean(user.emailVerifiedAt),
|
||||||
|
impersonatedBy: null,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -167,7 +169,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 });
|
||||||
|
|
||||||
@@ -206,6 +210,7 @@ export async function authRoutes(app: FastifyInstance) {
|
|||||||
isSuperadmin,
|
isSuperadmin,
|
||||||
isSuspended: user.isSuspended,
|
isSuspended: user.isSuspended,
|
||||||
emailVerified: Boolean(user.emailVerifiedAt),
|
emailVerified: Boolean(user.emailVerifiedAt),
|
||||||
|
impersonatedBy: null,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
@@ -218,8 +223,31 @@ export async function authRoutes(app: FastifyInstance) {
|
|||||||
return { ok: true };
|
return { ok: true };
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Leave an impersonated session. Kills the borrowed session and clears cookies; the admin
|
||||||
|
// signs back in as themselves. Deliberately NOT under adminRoutes — requireSuperadmin rejects
|
||||||
|
// impersonated sessions, so a stop route there could never be reached.
|
||||||
|
app.post('/api/auth/end-impersonation', async (req, reply) => {
|
||||||
|
if (!req.user) return reply.code(401).send({ error: 'unauthorized' });
|
||||||
|
if (!req.user.impersonatedBy) return reply.code(409).send({ error: 'not_impersonating' });
|
||||||
|
|
||||||
|
await logAudit({
|
||||||
|
userId: req.user.impersonatedBy,
|
||||||
|
firmId: req.user.firmId,
|
||||||
|
action: 'admin.impersonate.end',
|
||||||
|
meta: { targetUserId: req.user.id, targetEmail: req.user.email },
|
||||||
|
ip: req.ip,
|
||||||
|
});
|
||||||
|
|
||||||
|
const token = req.cookies?.[SESSION_COOKIE];
|
||||||
|
if (token) await destroySession(token);
|
||||||
|
app.clearSessionCookie(reply);
|
||||||
|
app.clearCsrfCookie(reply);
|
||||||
|
return { ok: true };
|
||||||
|
});
|
||||||
|
|
||||||
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 +389,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);
|
||||||
|
|||||||
@@ -8,6 +8,10 @@ import { getStripe, getPlanConfig, stripeIsConfigured } from '../lib/stripe';
|
|||||||
export async function billingRoutes(app: FastifyInstance) {
|
export async function billingRoutes(app: FastifyInstance) {
|
||||||
app.addHook('preHandler', app.requireFirm);
|
app.addHook('preHandler', app.requireFirm);
|
||||||
|
|
||||||
|
// Reading plan state is fine for anyone in the firm; spending money or opening the Stripe
|
||||||
|
// portal (which exposes payment methods and invoice history) is the owner's call alone.
|
||||||
|
const ownerOnly = { preHandler: app.requireRole('owner') };
|
||||||
|
|
||||||
// Status — what does the UI need to show? Configured at all? Current plan? Has subscription?
|
// Status — what does the UI need to show? Configured at all? Current plan? Has subscription?
|
||||||
app.get('/api/billing/status', async (req) => {
|
app.get('/api/billing/status', async (req) => {
|
||||||
const firmId = req.user!.firmId!;
|
const firmId = req.user!.firmId!;
|
||||||
@@ -21,7 +25,7 @@ export async function billingRoutes(app: FastifyInstance) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Create a Checkout Session — returns the URL to redirect the user to.
|
// Create a Checkout Session — returns the URL to redirect the user to.
|
||||||
app.post('/api/billing/checkout', async (req, reply) => {
|
app.post('/api/billing/checkout', ownerOnly, async (req, reply) => {
|
||||||
const parsed = z
|
const parsed = z
|
||||||
.object({ plan: z.enum(['pro', 'lifetime']) })
|
.object({ plan: z.enum(['pro', 'lifetime']) })
|
||||||
.safeParse(req.body);
|
.safeParse(req.body);
|
||||||
@@ -69,7 +73,7 @@ export async function billingRoutes(app: FastifyInstance) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Customer Portal — for managing the subscription, updating payment method, viewing invoices.
|
// Customer Portal — for managing the subscription, updating payment method, viewing invoices.
|
||||||
app.post('/api/billing/portal', async (req, reply) => {
|
app.post('/api/billing/portal', ownerOnly, async (req, reply) => {
|
||||||
if (!stripeIsConfigured()) return reply.code(503).send({ error: 'stripe_not_configured' });
|
if (!stripeIsConfigured()) return reply.code(503).send({ error: 'stripe_not_configured' });
|
||||||
|
|
||||||
const firmId = req.user!.firmId!;
|
const firmId = req.user!.firmId!;
|
||||||
|
|||||||
@@ -148,6 +148,28 @@ export async function casesRoutes(app: FastifyInstance) {
|
|||||||
return reply.code(400).send({ error: 'invalid_client' });
|
return reply.code(400).send({ error: 'invalid_client' });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Re-opening counts against the active-case quota exactly like creating one. Without this,
|
||||||
|
// a firm could create cases as 'closed' (unmetered) and flip them to 'open' afterwards,
|
||||||
|
// walking straight past the plan's activeCases cap.
|
||||||
|
if (body.status === 'open') {
|
||||||
|
const [current] = await getDb()
|
||||||
|
.select({ status: cases.status })
|
||||||
|
.from(cases)
|
||||||
|
.where(and(eq(cases.id, id), eq(cases.firmId, firmId)))
|
||||||
|
.limit(1);
|
||||||
|
if (!current) return reply.code(404).send({ error: 'not_found' });
|
||||||
|
if (current.status !== 'open') {
|
||||||
|
const firm = await loadFirm(firmId);
|
||||||
|
if (!firm) return reply.code(403).send({ error: 'firm_missing' });
|
||||||
|
try {
|
||||||
|
await assertCanCreateCase(firmId, firm.plan);
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof PlanLimitError) return reply.code(402).send({ error: e.message, plan: firm.plan });
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const patch: Record<string, unknown> = { updatedAt: new Date() };
|
const patch: Record<string, unknown> = { updatedAt: new Date() };
|
||||||
for (const [k, v] of Object.entries(body)) {
|
for (const [k, v] of Object.entries(body)) {
|
||||||
if (v === undefined) continue;
|
if (v === undefined) continue;
|
||||||
|
|||||||
@@ -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, reserveStorage, releaseStorage } from '../lib/storage-quota';
|
||||||
|
|
||||||
const ALLOWED_MIME = new Set([
|
const ALLOWED_MIME = new Set([
|
||||||
'application/pdf',
|
'application/pdf',
|
||||||
@@ -20,6 +23,24 @@ const ALLOWED_MIME = new Set([
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
const MAX_BYTES = 50 * 1024 * 1024; // 50 MB
|
const MAX_BYTES = 50 * 1024 * 1024; // 50 MB
|
||||||
|
const MAX_NAME_LENGTH = 200;
|
||||||
|
|
||||||
|
// The uploaded filename reaches us straight from the client. It ends up in two places that both
|
||||||
|
// matter: the object key in Spaces (via its extension) and the document's display name. Strip
|
||||||
|
// path separators and control characters, and bound the length, so neither can be steered.
|
||||||
|
function safeDisplayName(filename: string): string {
|
||||||
|
const base = path.basename(filename.replace(/\\/g, '/'));
|
||||||
|
// eslint-disable-next-line no-control-regex
|
||||||
|
const cleaned = base.replace(/[\u0000-\u001f\u007f]/g, '').trim();
|
||||||
|
return (cleaned || 'document').slice(0, MAX_NAME_LENGTH);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only a short, conventional extension is carried into the storage key — anything unusual is
|
||||||
|
// dropped rather than concatenated. The key stays `${firmId}/${caseId}/${uuid}${ext}`.
|
||||||
|
function safeExtension(filename: string): string {
|
||||||
|
const ext = path.extname(safeDisplayName(filename)).toLowerCase();
|
||||||
|
return /^\.[a-z0-9]{1,8}$/.test(ext) ? ext : '';
|
||||||
|
}
|
||||||
|
|
||||||
export async function documentsRoutes(app: FastifyInstance) {
|
export async function documentsRoutes(app: FastifyInstance) {
|
||||||
app.addHook('preHandler', app.requireFirm);
|
app.addHook('preHandler', app.requireFirm);
|
||||||
@@ -74,26 +95,57 @@ 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;
|
||||||
|
|
||||||
|
const firm = await loadFirm(firmId);
|
||||||
|
if (!firm) return reply.code(403).send({ error: 'firm_missing' });
|
||||||
|
|
||||||
|
// Cheap pre-check so an obviously oversized upload is rejected before we do any more work.
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Authoritative claim: one conditional UPDATE that both checks the cap and books the bytes,
|
||||||
|
// so simultaneous uploads can't each pass the check and collectively blow past the quota.
|
||||||
|
// Reserved BEFORE the object is written, and released again on any failure below.
|
||||||
|
if (!(await reserveStorage(firmId, firm.plan, size))) {
|
||||||
|
return reply.code(402).send({ error: 'plan_limit_storageBytes', plan: firm.plan });
|
||||||
|
}
|
||||||
|
|
||||||
const docId = randomUUID();
|
const docId = randomUUID();
|
||||||
const ext = path.extname(data.filename);
|
// Derive the extension from the *sanitised* filename: `data.filename` is attacker-controlled,
|
||||||
|
// and the extension is concatenated straight into the object key.
|
||||||
|
const ext = safeExtension(data.filename);
|
||||||
const storageKey = `${firmId}/${caseId}/${docId}${ext}`;
|
const storageKey = `${firmId}/${caseId}/${docId}${ext}`;
|
||||||
|
|
||||||
|
try {
|
||||||
await saveFile(storageKey, buf, data.mimetype);
|
await saveFile(storageKey, buf, data.mimetype);
|
||||||
|
} catch (err) {
|
||||||
|
await releaseStorage(firmId, size);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
|
||||||
const [doc] = await db.insert(documents).values({
|
const [doc] = await db.insert(documents).values({
|
||||||
id: docId,
|
id: docId,
|
||||||
firmId,
|
firmId,
|
||||||
caseId,
|
caseId,
|
||||||
uploadedBy: userId,
|
uploadedBy: userId,
|
||||||
name: data.filename,
|
name: safeDisplayName(data.filename),
|
||||||
storageKey,
|
storageKey,
|
||||||
mimeType: data.mimetype,
|
mimeType: data.mimetype,
|
||||||
sizeBytes: buf.length,
|
sizeBytes: size,
|
||||||
}).returning();
|
}).returning();
|
||||||
|
|
||||||
if (!doc) {
|
if (!doc) {
|
||||||
// Row insert failed after the file was written — clean up the orphaned object.
|
// Row insert failed after the file was written — clean up the orphaned object and hand
|
||||||
|
// the reserved bytes back, or the firm permanently loses that much of its quota.
|
||||||
await deleteFile(storageKey).catch(() => {});
|
await deleteFile(storageKey).catch(() => {});
|
||||||
|
await releaseStorage(firmId, size);
|
||||||
return reply.code(500).send({ error: 'upload_failed' });
|
return reply.code(500).send({ error: 'upload_failed' });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -149,6 +201,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 releaseStorage(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.
|
||||||
@@ -51,11 +71,29 @@ export async function stripeWebhookRoute(app: FastifyInstance) {
|
|||||||
|
|
||||||
async function handleEvent(event: Stripe.Event, app: FastifyInstance) {
|
async function handleEvent(event: Stripe.Event, app: FastifyInstance) {
|
||||||
switch (event.type) {
|
switch (event.type) {
|
||||||
case 'checkout.session.completed': {
|
// `completed` fires as soon as Checkout is finished — which for delayed-notification payment
|
||||||
|
// methods (ACH debit, bank transfers, some wallets) happens BEFORE any money moves, with
|
||||||
|
// payment_status 'unpaid'. Granting the plan here unconditionally would hand out a lifetime
|
||||||
|
// licence for an initiated-but-unsettled payment. Only 'paid' (or 'no_payment_required', e.g.
|
||||||
|
// a 100% coupon) grants; unpaid sessions wait for async_payment_succeeded below.
|
||||||
|
case 'checkout.session.completed':
|
||||||
|
case 'checkout.session.async_payment_succeeded': {
|
||||||
const session = event.data.object as Stripe.Checkout.Session;
|
const session = event.data.object as Stripe.Checkout.Session;
|
||||||
const firmId = session.client_reference_id ?? (session.metadata?.firmId as string | undefined);
|
const firmId = session.client_reference_id ?? (session.metadata?.firmId as string | undefined);
|
||||||
const planFromMeta = (session.metadata?.plan ?? '') as 'pro' | 'lifetime' | '';
|
const planFromMeta = (session.metadata?.plan ?? '') as 'pro' | 'lifetime' | '';
|
||||||
if (!firmId) return app.log.warn({ session: session.id }, 'checkout.session.completed without firmId');
|
if (!firmId) return app.log.warn({ session: session.id }, `${event.type} without firmId`);
|
||||||
|
|
||||||
|
if (session.payment_status !== 'paid' && session.payment_status !== 'no_payment_required') {
|
||||||
|
app.log.info(
|
||||||
|
{ session: session.id, firmId, paymentStatus: session.payment_status },
|
||||||
|
'checkout session not settled — plan withheld until payment succeeds',
|
||||||
|
);
|
||||||
|
// Still capture the customer id so the billing portal works while payment settles.
|
||||||
|
const pendingCustomer =
|
||||||
|
typeof session.customer === 'string' ? session.customer : session.customer?.id ?? null;
|
||||||
|
if (pendingCustomer) await attachCustomerId(firmId, pendingCustomer);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
// Determine plan from session.mode if metadata didn't pin it.
|
// Determine plan from session.mode if metadata didn't pin it.
|
||||||
const plan: 'pro' | 'lifetime' = planFromMeta || (session.mode === 'subscription' ? 'pro' : 'lifetime');
|
const plan: 'pro' | 'lifetime' = planFromMeta || (session.mode === 'subscription' ? 'pro' : 'lifetime');
|
||||||
@@ -65,7 +103,25 @@ 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The delayed payment ultimately failed, or the customer abandoned Checkout after a session
|
||||||
|
// was created. Nothing was granted (the guard above withheld it), so this is log-only —
|
||||||
|
// but it must be handled explicitly so a future change can't silently leave a plan applied.
|
||||||
|
case 'checkout.session.async_payment_failed':
|
||||||
|
case 'checkout.session.expired': {
|
||||||
|
const session = event.data.object as Stripe.Checkout.Session;
|
||||||
|
const firmId = session.client_reference_id ?? (session.metadata?.firmId as string | undefined);
|
||||||
|
app.log.warn(
|
||||||
|
{ session: session.id, firmId, type: event.type },
|
||||||
|
'checkout session did not result in payment',
|
||||||
|
);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -74,9 +130,14 @@ async function handleEvent(event: Stripe.Event, app: FastifyInstance) {
|
|||||||
const sub = event.data.object as Stripe.Subscription;
|
const sub = event.data.object as Stripe.Subscription;
|
||||||
const firmId = (sub.metadata?.firmId as string | undefined) ?? null;
|
const firmId = (sub.metadata?.firmId as string | undefined) ?? null;
|
||||||
if (!firmId) return;
|
if (!firmId) return;
|
||||||
// Only flip to 'pro' while the subscription is paying.
|
// Only 'active'/'trialing' are paying states. 'past_due' means a renewal already failed —
|
||||||
const active = ['active', 'trialing', 'past_due'].includes(sub.status);
|
// Stripe is still retrying, so we neither upgrade on it nor downgrade an existing Pro firm
|
||||||
if (active) await applyPlan(firmId, 'pro', { subscriptionId: sub.id });
|
// mid-retry; 'unpaid'/'canceled'/'incomplete_expired' are terminal and drop to starter.
|
||||||
|
if (sub.status === 'active' || sub.status === 'trialing') {
|
||||||
|
await applyPlan(firmId, 'pro', { subscriptionId: sub.id });
|
||||||
|
} else if (sub.status === 'unpaid' || sub.status === 'incomplete_expired') {
|
||||||
|
await applyPlan(firmId, 'starter', { subscriptionId: null });
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -128,6 +189,15 @@ async function handleEvent(event: Stripe.Event, app: FastifyInstance) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Records the Stripe customer without touching the plan — used when a Checkout session exists
|
||||||
|
// but hasn't been paid yet, so the billing portal is reachable while payment settles.
|
||||||
|
async function attachCustomerId(firmId: string, customerId: string) {
|
||||||
|
await getDb()
|
||||||
|
.update(firms)
|
||||||
|
.set({ stripeCustomerId: customerId, updatedAt: new Date() })
|
||||||
|
.where(eq(firms.id, firmId));
|
||||||
|
}
|
||||||
|
|
||||||
async function applyPlan(
|
async function applyPlan(
|
||||||
firmId: string,
|
firmId: string,
|
||||||
plan: 'starter' | 'pro' | 'lifetime',
|
plan: 'starter' | 'pro' | 'lifetime',
|
||||||
|
|||||||
+71
-3
@@ -43,7 +43,11 @@ export async function buildServer() {
|
|||||||
// Trust exactly ONE proxy hop (the Plesk/nginx reverse proxy in front of Passenger).
|
// Trust exactly ONE proxy hop (the Plesk/nginx reverse proxy in front of Passenger).
|
||||||
// `true` would trust the entire X-Forwarded-For chain, letting any client spoof req.ip and
|
// `true` would trust the entire X-Forwarded-For chain, letting any client spoof req.ip and
|
||||||
// evade the IP-keyed rate limits (including auth brute-force protection).
|
// evade the IP-keyed rate limits (including auth brute-force protection).
|
||||||
trustProxy: 1,
|
//
|
||||||
|
// Expressed as proxy-addr's predicate form — `hop < 1` trusts only the address closest to
|
||||||
|
// this server and nothing beyond it, which is exactly what the numeric `1` used to mean.
|
||||||
|
// Fastify's types no longer accept the number, and the predicate is unambiguous anyway.
|
||||||
|
trustProxy: (_address: string, hop: number) => hop < 1,
|
||||||
bodyLimit: 5 * 1024 * 1024,
|
bodyLimit: 5 * 1024 * 1024,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -60,6 +64,15 @@ export async function buildServer() {
|
|||||||
// Let @fastify/rate-limit handle its own response shape.
|
// Let @fastify/rate-limit handle its own response shape.
|
||||||
return reply.send(err);
|
return reply.send(err);
|
||||||
}
|
}
|
||||||
|
// Deliberate 4xx thrown from inside a plugin — e.g. @fastify/send raising a 403 Forbidden
|
||||||
|
// for a path that escapes the static root, or a 404 for a missing file. These are the
|
||||||
|
// library working correctly; reporting them as 500 internal_error both lies to the client
|
||||||
|
// and floods Sentry with non-errors, which buries real incidents.
|
||||||
|
const status = (err as { statusCode?: number }).statusCode;
|
||||||
|
if (typeof status === 'number' && status >= 400 && status < 500) {
|
||||||
|
req.log.info({ err, status }, 'client error');
|
||||||
|
return reply.code(status).send({ error: (err as { code?: string }).code ?? 'request_failed' });
|
||||||
|
}
|
||||||
req.log.error({ err }, 'unhandled error');
|
req.log.error({ err }, 'unhandled error');
|
||||||
captureError(err, { url: req.url, method: req.method, userId: req.user?.id });
|
captureError(err, { url: req.url, method: req.method, userId: req.user?.id });
|
||||||
return reply.code(500).send({ error: 'internal_error' });
|
return reply.code(500).send({ error: 'internal_error' });
|
||||||
@@ -71,11 +84,11 @@ export async function buildServer() {
|
|||||||
? {
|
? {
|
||||||
directives: {
|
directives: {
|
||||||
defaultSrc: ["'self'"],
|
defaultSrc: ["'self'"],
|
||||||
scriptSrc: ["'self'", 'https://challenges.cloudflare.com'],
|
scriptSrc: ["'self'", 'https://challenges.cloudflare.com', 'https://fickanalytics.phluit.net'],
|
||||||
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:', 'https://*.digitaloceanspaces.com', 'https://*.cdn.digitaloceanspaces.com'],
|
imgSrc: ["'self'", 'data:', 'blob:', 'https://*.digitaloceanspaces.com', 'https://*.cdn.digitaloceanspaces.com'],
|
||||||
connectSrc: ["'self'"],
|
connectSrc: ["'self'", 'https://fickanalytics.phluit.net'],
|
||||||
frameSrc: ['https://challenges.cloudflare.com'],
|
frameSrc: ['https://challenges.cloudflare.com'],
|
||||||
frameAncestors: ["'none'"],
|
frameAncestors: ["'none'"],
|
||||||
formAction: ["'self'"],
|
formAction: ["'self'"],
|
||||||
@@ -135,17 +148,72 @@ export async function buildServer() {
|
|||||||
// Must NOT be `false`: the SPA fallback below calls reply.sendFile, which only exists when
|
// Must NOT be `false`: the SPA fallback below calls reply.sendFile, which only exists when
|
||||||
// @fastify/static decorates the reply. With it disabled, every deep-link/refresh to a
|
// @fastify/static decorates the reply. With it disabled, every deep-link/refresh to a
|
||||||
// non-/api route (e.g. /dashboard, emailed /reset-password links) 500s in production.
|
// non-/api route (e.g. /dashboard, emailed /reset-password links) 500s in production.
|
||||||
|
// Only paths WITH a file extension are served by the plugin (hashed assets, favicons,
|
||||||
|
// sitemap.xml, ...). Page-shaped requests — '/', '/blog/<slug>', '/blog/<slug>/' — fall
|
||||||
|
// through to the not-found handler below (allowedPath:false → reply.callNotFound()),
|
||||||
|
// which serves the matching prerendered index.html or the app shell with
|
||||||
|
// Cache-Control: no-cache. Without this, the plugin's directory-index handling serves
|
||||||
|
// prerendered HTML itself, stamped with the 1y immutable header above — meant only for
|
||||||
|
// hashed assets — so browsers/crawlers would pin a year-stale page after every deploy.
|
||||||
|
index: false,
|
||||||
|
allowedPath: (pathName) => path.extname(pathName) !== '',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Public SPA route prefixes — keep in sync with apps/web/src/App.tsx routes.
|
||||||
|
const KNOWN_SPA_PREFIXES = ['/login', '/signup', '/forgot-password', '/reset-password', '/billing/', '/tools', '/blog', '/legal', '/app', '/admin'];
|
||||||
|
|
||||||
// SPA fallback: any non-/api path returns index.html. index.html itself must not be cached
|
// SPA fallback: any non-/api path returns index.html. index.html itself must not be cached
|
||||||
// for a year (unlike the hashed assets) or clients keep a stale app shell after each deploy.
|
// for a year (unlike the hashed assets) or clients keep a stale app shell after each deploy.
|
||||||
|
// Known SPA paths get 200; anything else (including missing static files) still gets
|
||||||
|
// index.html — so the client renders its NotFound page — but with a 404 status so
|
||||||
|
// crawlers don't index junk URLs as soft-200s.
|
||||||
app.setNotFoundHandler((req, reply) => {
|
app.setNotFoundHandler((req, reply) => {
|
||||||
if (req.raw.url?.startsWith('/api/')) {
|
if (req.raw.url?.startsWith('/api/')) {
|
||||||
return reply.code(404).send({ error: 'not_found' });
|
return reply.code(404).send({ error: 'not_found' });
|
||||||
}
|
}
|
||||||
|
const pathname = (req.raw.url ?? '').split('?')[0] ?? '';
|
||||||
|
|
||||||
|
// Prerendered pages: an extensionless path like /blog/some-post misses @fastify/static
|
||||||
|
// (no trailing slash → no directory index lookup) and lands here. If the build produced
|
||||||
|
// dist/blog/some-post/index.html, serve THAT file — crawlers must get the route-specific
|
||||||
|
// head tags, not the root app shell. Decode + resolve and require the result to stay
|
||||||
|
// inside webDist so encoded traversal (/..%2f..) can never escape the dist root.
|
||||||
|
let decodedPath: string | null = null;
|
||||||
|
try {
|
||||||
|
decodedPath = decodeURIComponent(pathname);
|
||||||
|
} catch {
|
||||||
|
decodedPath = null; // malformed percent-encoding → fall through to the SPA fallback
|
||||||
|
}
|
||||||
|
if (decodedPath && !decodedPath.includes('\0')) {
|
||||||
|
const relDir = decodedPath.replace(/^\/+/, '').replace(/\/+$/, '');
|
||||||
|
const distRoot = path.resolve(webDist);
|
||||||
|
const candidate = path.resolve(distRoot, relDir, 'index.html');
|
||||||
|
if (
|
||||||
|
relDir.length > 0 &&
|
||||||
|
candidate.startsWith(distRoot + path.sep) &&
|
||||||
|
fs.existsSync(candidate)
|
||||||
|
) {
|
||||||
|
return reply
|
||||||
|
.code(200)
|
||||||
|
.header('Cache-Control', 'no-cache')
|
||||||
|
.type('text/html')
|
||||||
|
.sendFile(path.relative(distRoot, candidate).split(path.sep).join('/'), webDist, {
|
||||||
|
cacheControl: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const isKnown =
|
||||||
|
pathname === '/' ||
|
||||||
|
KNOWN_SPA_PREFIXES.some((prefix) =>
|
||||||
|
prefix.endsWith('/')
|
||||||
|
? pathname.startsWith(prefix)
|
||||||
|
: pathname === prefix || pathname.startsWith(`${prefix}/`),
|
||||||
|
);
|
||||||
// cacheControl:false stops @fastify/static from stamping its own 1y immutable header
|
// cacheControl:false stops @fastify/static from stamping its own 1y immutable header
|
||||||
// (which would otherwise override the no-cache below and pin a stale app shell).
|
// (which would otherwise override the no-cache below and pin a stale app shell).
|
||||||
return reply
|
return reply
|
||||||
|
.code(isKnown ? 200 : 404)
|
||||||
.header('Cache-Control', 'no-cache')
|
.header('Cache-Control', 'no-cache')
|
||||||
.type('text/html')
|
.type('text/html')
|
||||||
.sendFile('index.html', webDist, { cacheControl: false });
|
.sendFile('index.html', webDist, { cacheControl: false });
|
||||||
|
|||||||
@@ -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: '',
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
|
||||||
|
// Mirrors the grant decision in src/routes/webhooks-stripe.ts. `checkout.session.completed` fires
|
||||||
|
// as soon as Checkout finishes, which for delayed-notification payment methods (ACH debit, bank
|
||||||
|
// transfer, some wallets) happens BEFORE any money moves — payment_status 'unpaid'. Granting on
|
||||||
|
// the event alone hands out a paid plan for an unsettled payment.
|
||||||
|
type PaymentStatus = 'paid' | 'unpaid' | 'no_payment_required';
|
||||||
|
|
||||||
|
function shouldGrantPlan(paymentStatus: PaymentStatus): boolean {
|
||||||
|
return paymentStatus === 'paid' || paymentStatus === 'no_payment_required';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mirrors the subscription-status branch in the same file.
|
||||||
|
type SubStatus =
|
||||||
|
| 'active'
|
||||||
|
| 'trialing'
|
||||||
|
| 'past_due'
|
||||||
|
| 'unpaid'
|
||||||
|
| 'canceled'
|
||||||
|
| 'incomplete'
|
||||||
|
| 'incomplete_expired';
|
||||||
|
|
||||||
|
function planForSubscription(status: SubStatus): 'pro' | 'starter' | null {
|
||||||
|
if (status === 'active' || status === 'trialing') return 'pro';
|
||||||
|
if (status === 'unpaid' || status === 'incomplete_expired') return 'starter';
|
||||||
|
return null; // leave the current plan untouched
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('checkout grant decision', () => {
|
||||||
|
it('grants on a settled payment', () => {
|
||||||
|
expect(shouldGrantPlan('paid')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('grants when no payment was required (e.g. a 100% coupon)', () => {
|
||||||
|
expect(shouldGrantPlan('no_payment_required')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('withholds the plan while the payment is unsettled', () => {
|
||||||
|
// The regression this guards: a delayed-payment method completing Checkout unpaid used to
|
||||||
|
// grant 'lifetime' outright.
|
||||||
|
expect(shouldGrantPlan('unpaid')).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('subscription status mapping', () => {
|
||||||
|
it('treats only active and trialing as paying', () => {
|
||||||
|
expect(planForSubscription('active')).toBe('pro');
|
||||||
|
expect(planForSubscription('trialing')).toBe('pro');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not upgrade on past_due, and does not downgrade mid-retry either', () => {
|
||||||
|
// Stripe is still retrying the charge — flipping the plan in either direction here would
|
||||||
|
// either hand out Pro for a failed renewal or cut off a customer whose retry succeeds.
|
||||||
|
expect(planForSubscription('past_due')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('drops to starter on terminal non-payment states', () => {
|
||||||
|
expect(planForSubscription('unpaid')).toBe('starter');
|
||||||
|
expect(planForSubscription('incomplete_expired')).toBe('starter');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves the plan alone for states that carry no payment signal', () => {
|
||||||
|
expect(planForSubscription('incomplete')).toBeNull();
|
||||||
|
expect(planForSubscription('canceled')).toBeNull(); // handled by subscription.deleted instead
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
|
||||||
|
// Mirrors safeNext() in apps/web/src/pages/LoginPage.tsx — the post-login redirect guard. `next`
|
||||||
|
// rides in the login URL and is therefore attacker-supplied, so an open redirect here lands a
|
||||||
|
// freshly authenticated user on a phishing page. Lives in the API suite because the web workspace
|
||||||
|
// has no test runner configured; keep the two copies in step.
|
||||||
|
function safeNext(raw: string | null): string {
|
||||||
|
const FALLBACK = '/app';
|
||||||
|
if (!raw) return FALLBACK;
|
||||||
|
const cleaned = raw.replace(/[\t\n\r]/g, '');
|
||||||
|
if (!cleaned.startsWith('/') || cleaned.startsWith('//')) return FALLBACK;
|
||||||
|
try {
|
||||||
|
const probe = 'https://redirect-guard.invalid';
|
||||||
|
const url = new URL(cleaned, probe);
|
||||||
|
if (url.origin !== probe) return FALLBACK;
|
||||||
|
return `${url.pathname}${url.search}${url.hash}`;
|
||||||
|
} catch {
|
||||||
|
return FALLBACK;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const PROBE = 'https://redirect-guard.invalid';
|
||||||
|
const staysInternal = (value: string) => new URL(value, PROBE).origin === PROBE;
|
||||||
|
|
||||||
|
describe('safeNext — post-login open-redirect guard', () => {
|
||||||
|
it('passes through legitimate internal destinations', () => {
|
||||||
|
expect(safeNext('/app/cases')).toBe('/app/cases');
|
||||||
|
expect(safeNext('/app?tab=open#row')).toBe('/app?tab=open#row');
|
||||||
|
expect(safeNext('/admin')).toBe('/admin');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back when nothing was requested', () => {
|
||||||
|
expect(safeNext(null)).toBe('/app');
|
||||||
|
expect(safeNext('')).toBe('/app');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects absolute URLs to another origin', () => {
|
||||||
|
expect(safeNext('https://evil.com')).toBe('/app');
|
||||||
|
expect(safeNext('http://evil.com/path')).toBe('/app');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects protocol-relative URLs', () => {
|
||||||
|
expect(safeNext('//evil.com')).toBe('/app');
|
||||||
|
expect(safeNext('////evil.com')).toBe('/app');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects backslash variants that browsers normalise into an authority', () => {
|
||||||
|
// URL parsing rewrites \ as / for special schemes, so each of these would otherwise become
|
||||||
|
// protocol-relative and point off-origin. The origin check is what catches them.
|
||||||
|
for (const attack of ['/\\\\evil.com', '/\\/evil.com', '\\\\evil.com', '/\\\\\\evil.com']) {
|
||||||
|
expect(safeNext(attack)).toBe('/app');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects schemes smuggled past the leading-slash check with whitespace', () => {
|
||||||
|
expect(safeNext('\tjavascript:alert(1)')).toBe('/app');
|
||||||
|
expect(safeNext('\njavascript:alert(1)')).toBe('/app');
|
||||||
|
expect(safeNext('javascript:alert(1)')).toBe('/app');
|
||||||
|
expect(safeNext('data:text/html,<script>alert(1)</script>')).toBe('/app');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('never returns a value that resolves off-origin', () => {
|
||||||
|
const attacks = [
|
||||||
|
'//evil.com',
|
||||||
|
'/\\evil.com',
|
||||||
|
'/\\\\evil.com',
|
||||||
|
'https://evil.com',
|
||||||
|
'\tjavascript:alert(1)',
|
||||||
|
'////evil.com',
|
||||||
|
'/%5cevil.com',
|
||||||
|
'/%2f%2fevil.com',
|
||||||
|
'/\t/evil.com',
|
||||||
|
'data:text/html,<script>alert(1)</script>',
|
||||||
|
];
|
||||||
|
for (const attack of attacks) {
|
||||||
|
expect(staysInternal(safeNext(attack))).toBe(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
Binary file not shown.
+15
-2
@@ -2,21 +2,34 @@
|
|||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
<link rel="icon" type="image/png" sizes="512x512" href="/favicon.png" />
|
<link rel="icon" type="image/png" sizes="512x512" href="/favicon.png" />
|
||||||
<link rel="apple-touch-icon" href="/favicon.png" />
|
<link rel="apple-touch-icon" href="/favicon.png" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<meta name="theme-color" content="#0052FF" />
|
<meta name="theme-color" content="#0052FF" />
|
||||||
<title>eLegal Software - All-in-One Practice Management for Law Firms</title>
|
<title>eLegal Software — Practice Management for Law Firms & Attorneys</title>
|
||||||
<meta
|
<meta
|
||||||
name="description"
|
name="description"
|
||||||
content="Streamline case management, billable hours, legal documents and invoicing in one secure platform built for attorneys."
|
content="Streamline case management, billable hours, legal documents and invoicing in one secure platform built for attorneys."
|
||||||
/>
|
/>
|
||||||
|
<meta property="og:site_name" content="eLegal Software" />
|
||||||
|
<meta property="og:type" content="website" />
|
||||||
|
<meta property="og:url" content="https://elegalsoftware.com/" />
|
||||||
|
<meta property="og:title" content="eLegal Software — Practice Management for Law Firms & Attorneys" />
|
||||||
|
<meta
|
||||||
|
property="og:description"
|
||||||
|
content="Streamline case management, billable hours, legal documents and invoicing in one secure platform built for attorneys."
|
||||||
|
/>
|
||||||
|
<meta property="og:image" content="https://elegalsoftware.com/logo-dark.png" />
|
||||||
|
<meta name="twitter:card" content="summary" />
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
<link
|
<link
|
||||||
href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&family=Poppins:wght@500;600;700;800&display=swap"
|
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Poppins:wght@600;700&display=swap"
|
||||||
rel="stylesheet"
|
rel="stylesheet"
|
||||||
/>
|
/>
|
||||||
|
<!-- Umami analytics (privacy-friendly, cookieless — no consent gate needed) -->
|
||||||
|
<script defer src="https://fickanalytics.phluit.net/script.js" data-website-id="fd1bdcd3-b73a-42dd-a17a-50578ecff7ec"></script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
@@ -5,12 +5,12 @@
|
|||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "tsc -b && vite build",
|
"build": "tsc -b && vite build && npx tsx scripts/prerender.mts && npx tsx scripts/generate-sitemap.mts",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"typecheck": "tsc -b --noEmit"
|
"typecheck": "tsc -b --noEmit"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@sentry/react": "^8.45.0",
|
"@sentry/react": "^10.71.0",
|
||||||
"@tanstack/react-query": "^5.62.0",
|
"@tanstack/react-query": "^5.62.0",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
@@ -19,7 +19,7 @@
|
|||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
"react-hook-form": "^7.53.2",
|
"react-hook-form": "^7.53.2",
|
||||||
"react-router-dom": "^6.28.0",
|
"react-router-dom": "^6.30.6",
|
||||||
"recharts": "^2.13.3",
|
"recharts": "^2.13.3",
|
||||||
"tailwind-merge": "^2.5.5",
|
"tailwind-merge": "^2.5.5",
|
||||||
"zod": "^3.23.8"
|
"zod": "^3.23.8"
|
||||||
@@ -30,7 +30,7 @@
|
|||||||
"@types/react-dom": "^18.3.1",
|
"@types/react-dom": "^18.3.1",
|
||||||
"@vitejs/plugin-react": "^4.3.4",
|
"@vitejs/plugin-react": "^4.3.4",
|
||||||
"autoprefixer": "^10.4.20",
|
"autoprefixer": "^10.4.20",
|
||||||
"postcss": "^8.4.49",
|
"postcss": "^8.5.26",
|
||||||
"tailwindcss": "^3.4.15",
|
"tailwindcss": "^3.4.15",
|
||||||
"tailwindcss-animate": "^1.0.7",
|
"tailwindcss-animate": "^1.0.7",
|
||||||
"typescript": "^5.6.3",
|
"typescript": "^5.6.3",
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
User-agent: *
|
||||||
|
Allow: /
|
||||||
|
Disallow: /app
|
||||||
|
Disallow: /admin
|
||||||
|
Disallow: /api/
|
||||||
|
Disallow: /billing/
|
||||||
|
Disallow: /forgot-password
|
||||||
|
Disallow: /reset-password
|
||||||
|
|
||||||
|
Sitemap: https://elegalsoftware.com/sitemap.xml
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
// Generates dist/sitemap.xml from the public, indexable routes in src/seo/routes-meta.ts.
|
||||||
|
// Run after `vite build` (see the "build" script in package.json): npx tsx scripts/generate-sitemap.mts
|
||||||
|
// Paths are resolved from import.meta.url so the script works regardless of cwd.
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import { SITEMAP_ROUTES, SITE_URL } from '../src/seo/routes-meta';
|
||||||
|
|
||||||
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
const distDir = path.resolve(__dirname, '../dist');
|
||||||
|
const outFile = path.join(distDir, 'sitemap.xml');
|
||||||
|
|
||||||
|
const lastmod = new Date().toISOString().slice(0, 10); // YYYY-MM-DD
|
||||||
|
|
||||||
|
const urlEntries = SITEMAP_ROUTES.map((route) => {
|
||||||
|
// Root must be the origin with a trailing slash: https://elegalsoftware.com/
|
||||||
|
const loc = route.path === '/' ? `${SITE_URL}/` : `${SITE_URL}${route.path}`;
|
||||||
|
return [
|
||||||
|
' <url>',
|
||||||
|
` <loc>${loc}</loc>`,
|
||||||
|
` <lastmod>${lastmod}</lastmod>`,
|
||||||
|
' </url>',
|
||||||
|
].join('\n');
|
||||||
|
});
|
||||||
|
|
||||||
|
const xml = [
|
||||||
|
'<?xml version="1.0" encoding="UTF-8"?>',
|
||||||
|
'<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">',
|
||||||
|
...urlEntries,
|
||||||
|
'</urlset>',
|
||||||
|
'',
|
||||||
|
].join('\n');
|
||||||
|
|
||||||
|
fs.mkdirSync(distDir, { recursive: true });
|
||||||
|
fs.writeFileSync(outFile, xml, 'utf8');
|
||||||
|
|
||||||
|
console.log(`sitemap: wrote ${SITEMAP_ROUTES.length} URLs to ${outFile}`);
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
// Prerenders every route in PRERENDER_ROUTES to static HTML under dist/, so crawlers
|
||||||
|
// receive full markup with per-route head tags without executing JS.
|
||||||
|
// Run AFTER `vite build` (see the "build" script in package.json): npx tsx scripts/prerender.mts
|
||||||
|
//
|
||||||
|
// How it works: a Vite dev server in middleware mode gives us ssrLoadModule — TSX,
|
||||||
|
// the '@/' alias, and CSS imports all resolve exactly as in the app build, so no
|
||||||
|
// separate SSR bundle is needed. The built dist/index.html is the template: its
|
||||||
|
// default <title>/description/og:/twitter: fallback tags are stripped (the per-route
|
||||||
|
// tags from renderHeadTags() would otherwise duplicate them), the route's head tags
|
||||||
|
// are injected before </head>, and the rendered app HTML is placed inside #root.
|
||||||
|
//
|
||||||
|
// NOTE: the client does NOT hydrate — main.tsx keeps createRoot().render(), which
|
||||||
|
// replaces the prerendered DOM on load. That is intentional (no mismatch risk).
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import { createServer } from 'vite';
|
||||||
|
import type { RouteMeta } from '../src/seo/routes-meta';
|
||||||
|
|
||||||
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
const webRoot = path.resolve(__dirname, '..');
|
||||||
|
const distDir = path.join(webRoot, 'dist');
|
||||||
|
const templatePath = path.join(distDir, 'index.html');
|
||||||
|
|
||||||
|
function fail(msg: string): never {
|
||||||
|
console.error(`prerender: FAILED — ${msg}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!fs.existsSync(templatePath)) {
|
||||||
|
fail(`${templatePath} not found — run \`vite build\` first`);
|
||||||
|
}
|
||||||
|
const rawTemplate = fs.readFileSync(templatePath, 'utf8');
|
||||||
|
|
||||||
|
// Strip the template's default SEO fallback tags (title, meta description, og:*,
|
||||||
|
// twitter:*). renderHeadTags() emits the per-route versions of all of them; leaving
|
||||||
|
// the defaults in would give crawlers duplicate/conflicting tags. [^>]* also matches
|
||||||
|
// newlines, covering the multi-line <meta> formatting in index.html.
|
||||||
|
const template = rawTemplate
|
||||||
|
.replace(/[ \t]*<title>[\s\S]*?<\/title>\s*\n?/i, '')
|
||||||
|
.replace(/[ \t]*<meta[^>]*name="description"[^>]*>\s*\n?/gi, '')
|
||||||
|
.replace(/[ \t]*<meta[^>]*property="og:[^"]*"[^>]*>\s*\n?/gi, '')
|
||||||
|
.replace(/[ \t]*<meta[^>]*name="twitter:[^"]*"[^>]*>\s*\n?/gi, '');
|
||||||
|
|
||||||
|
if (/<title>|property="og:|name="twitter:/i.test(template)) {
|
||||||
|
fail('template still contains default <title>/og:/twitter: tags after stripping — index.html format changed?');
|
||||||
|
}
|
||||||
|
if (!template.includes('<div id="root"></div>')) {
|
||||||
|
fail('template is missing `<div id="root"></div>` — cannot inject app HTML');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Middleware mode + appType 'custom' = no HTTP server, no HTML middlewares — we only
|
||||||
|
// want ssrLoadModule. Root is apps/web so vite.config.ts (alias, envDir) applies.
|
||||||
|
const vite = await createServer({
|
||||||
|
root: webRoot,
|
||||||
|
logLevel: 'error',
|
||||||
|
server: { middlewareMode: true },
|
||||||
|
appType: 'custom',
|
||||||
|
});
|
||||||
|
|
||||||
|
let exitCode = 0;
|
||||||
|
try {
|
||||||
|
const { render } = (await vite.ssrLoadModule('/src/entry-server.tsx')) as {
|
||||||
|
render: (url: string) => string;
|
||||||
|
};
|
||||||
|
const { PRERENDER_ROUTES, metaForPath, renderHeadTags } = (await vite.ssrLoadModule(
|
||||||
|
'/src/seo/routes-meta.ts',
|
||||||
|
)) as {
|
||||||
|
PRERENDER_ROUTES: string[];
|
||||||
|
metaForPath: (p: string) => RouteMeta | undefined;
|
||||||
|
renderHeadTags: (m: RouteMeta) => string;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (PRERENDER_ROUTES.length === 0) fail('PRERENDER_ROUTES is empty');
|
||||||
|
|
||||||
|
for (const route of PRERENDER_ROUTES) {
|
||||||
|
try {
|
||||||
|
const meta = metaForPath(route);
|
||||||
|
if (!meta) throw new Error(`no meta registered for ${route}`);
|
||||||
|
|
||||||
|
const appHtml = render(route);
|
||||||
|
if (!appHtml.trim()) throw new Error('rendered app HTML is empty');
|
||||||
|
|
||||||
|
const doc = template
|
||||||
|
.replace('</head>', ` ${renderHeadTags(meta)}\n </head>`)
|
||||||
|
.replace('<div id="root"></div>', `<div id="root">${appHtml}</div>`);
|
||||||
|
|
||||||
|
const outFile =
|
||||||
|
route === '/' ? templatePath : path.join(distDir, ...route.slice(1).split('/'), 'index.html');
|
||||||
|
fs.mkdirSync(path.dirname(outFile), { recursive: true });
|
||||||
|
fs.writeFileSync(outFile, doc, 'utf8');
|
||||||
|
console.log(`prerender: ok ${route} -> ${path.relative(webRoot, outFile)}`);
|
||||||
|
} catch (err) {
|
||||||
|
exitCode = 1;
|
||||||
|
console.error(`prerender: ERROR rendering ${route}:`, err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
await vite.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (exitCode !== 0) fail('one or more routes failed (see errors above)');
|
||||||
|
console.log('prerender: all routes rendered');
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
import { Route, Routes } from 'react-router-dom';
|
import { Route, Routes } from 'react-router-dom';
|
||||||
|
import { Seo } from '@/components/Seo';
|
||||||
import LandingPage from './pages/LandingPage';
|
import LandingPage from './pages/LandingPage';
|
||||||
|
import NotFoundPage from './pages/NotFoundPage';
|
||||||
import LoginPage from './pages/LoginPage';
|
import LoginPage from './pages/LoginPage';
|
||||||
import SignupPage from './pages/SignupPage';
|
import SignupPage from './pages/SignupPage';
|
||||||
import ForgotPasswordPage from './pages/ForgotPasswordPage';
|
import ForgotPasswordPage from './pages/ForgotPasswordPage';
|
||||||
@@ -44,6 +46,7 @@ import DpaPage from './pages/legal/DpaPage';
|
|||||||
export default function App() {
|
export default function App() {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
<Seo />
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/" element={<LandingPage />} />
|
<Route path="/" element={<LandingPage />} />
|
||||||
<Route path="/login" element={<LoginPage />} />
|
<Route path="/login" element={<LoginPage />} />
|
||||||
@@ -94,7 +97,7 @@ export default function App() {
|
|||||||
<Route path="audit" element={<AdminAuditPage />} />
|
<Route path="audit" element={<AdminAuditPage />} />
|
||||||
</Route>
|
</Route>
|
||||||
|
|
||||||
<Route path="*" element={<LandingPage />} />
|
<Route path="*" element={<NotFoundPage />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
<CookieBanner />
|
<CookieBanner />
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -0,0 +1,80 @@
|
|||||||
|
import { useEffect } from 'react';
|
||||||
|
import { useLocation } from 'react-router-dom';
|
||||||
|
import {
|
||||||
|
metaForPath,
|
||||||
|
canonicalUrl,
|
||||||
|
NOT_FOUND_META,
|
||||||
|
DEFAULT_OG_IMAGE,
|
||||||
|
SITE_NAME,
|
||||||
|
type RouteMeta,
|
||||||
|
} from '@/seo/routes-meta';
|
||||||
|
|
||||||
|
// Mounted ONCE in App.tsx, above <Routes>. Keeps the document head in sync with the
|
||||||
|
// current route from the routes-meta map. Prerendered pages ship the same tags
|
||||||
|
// (stamped data-seo) baked into their static HTML; this component replaces them on
|
||||||
|
// client-side navigation so the two systems never fight.
|
||||||
|
|
||||||
|
function upsertMeta(attr: 'name' | 'property', key: string, content: string) {
|
||||||
|
let el = document.head.querySelector<HTMLMetaElement>(`meta[${attr}="${key}"]`);
|
||||||
|
if (!el) {
|
||||||
|
el = document.createElement('meta');
|
||||||
|
el.setAttribute(attr, key);
|
||||||
|
el.setAttribute('data-seo', '1');
|
||||||
|
document.head.appendChild(el);
|
||||||
|
}
|
||||||
|
el.setAttribute('content', content);
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeMeta(attr: 'name' | 'property', key: string) {
|
||||||
|
document.head.querySelector(`meta[${attr}="${key}"]`)?.remove();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Seo() {
|
||||||
|
const { pathname } = useLocation();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const meta: RouteMeta = metaForPath(pathname) ?? NOT_FOUND_META;
|
||||||
|
const url = canonicalUrl(meta.path || pathname);
|
||||||
|
|
||||||
|
document.title = meta.title;
|
||||||
|
upsertMeta('name', 'description', meta.description);
|
||||||
|
|
||||||
|
// Canonical for indexable pages; robots noindex otherwise (never both).
|
||||||
|
if (meta.noindex) {
|
||||||
|
document.head.querySelector('link[rel="canonical"]')?.remove();
|
||||||
|
upsertMeta('name', 'robots', 'noindex, nofollow');
|
||||||
|
} else {
|
||||||
|
removeMeta('name', 'robots');
|
||||||
|
let link = document.head.querySelector<HTMLLinkElement>('link[rel="canonical"]');
|
||||||
|
if (!link) {
|
||||||
|
link = document.createElement('link');
|
||||||
|
link.setAttribute('rel', 'canonical');
|
||||||
|
link.setAttribute('data-seo', '1');
|
||||||
|
document.head.appendChild(link);
|
||||||
|
}
|
||||||
|
link.setAttribute('href', url);
|
||||||
|
}
|
||||||
|
|
||||||
|
upsertMeta('property', 'og:site_name', SITE_NAME);
|
||||||
|
upsertMeta('property', 'og:type', meta.ogType ?? 'website');
|
||||||
|
upsertMeta('property', 'og:url', url);
|
||||||
|
upsertMeta('property', 'og:title', meta.title);
|
||||||
|
upsertMeta('property', 'og:description', meta.description);
|
||||||
|
upsertMeta('property', 'og:image', DEFAULT_OG_IMAGE);
|
||||||
|
upsertMeta('name', 'twitter:card', 'summary');
|
||||||
|
upsertMeta('name', 'twitter:title', meta.title);
|
||||||
|
upsertMeta('name', 'twitter:description', meta.description);
|
||||||
|
|
||||||
|
// JSON-LD: replace wholesale (covers both prerendered and prior-route scripts).
|
||||||
|
document.head.querySelectorAll('script[type="application/ld+json"]').forEach((s) => s.remove());
|
||||||
|
for (const ld of meta.jsonLd ?? []) {
|
||||||
|
const script = document.createElement('script');
|
||||||
|
script.type = 'application/ld+json';
|
||||||
|
script.setAttribute('data-seo', '1');
|
||||||
|
script.textContent = JSON.stringify(ld);
|
||||||
|
document.head.appendChild(script);
|
||||||
|
}
|
||||||
|
}, [pathname]);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ import { useMe } from '@/hooks/useAuth';
|
|||||||
import { Sidebar } from './Sidebar';
|
import { Sidebar } from './Sidebar';
|
||||||
import { Topbar } from './Topbar';
|
import { Topbar } from './Topbar';
|
||||||
import { VerifyEmailBanner } from './VerifyEmailBanner';
|
import { VerifyEmailBanner } from './VerifyEmailBanner';
|
||||||
|
import { ImpersonationBanner } from './ImpersonationBanner';
|
||||||
|
|
||||||
export function AppLayout() {
|
export function AppLayout() {
|
||||||
const me = useMe();
|
const me = useMe();
|
||||||
@@ -19,6 +20,7 @@ export function AppLayout() {
|
|||||||
<div className="min-h-screen flex bg-ink-50">
|
<div className="min-h-screen flex bg-ink-50">
|
||||||
<Sidebar />
|
<Sidebar />
|
||||||
<div className="flex-1 flex flex-col min-w-0">
|
<div className="flex-1 flex flex-col min-w-0">
|
||||||
|
<ImpersonationBanner />
|
||||||
<Topbar />
|
<Topbar />
|
||||||
<VerifyEmailBanner />
|
<VerifyEmailBanner />
|
||||||
<main className="flex-1 overflow-y-auto">
|
<main className="flex-1 overflow-y-auto">
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { UserCog } from 'lucide-react';
|
||||||
|
import { useEndImpersonation, useMe } from '@/hooks/useAuth';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Persistent, non-dismissible warning shown whenever the current session was opened by a
|
||||||
|
* superadmin impersonating this user. It must not be dismissible: an admin who forgets they are
|
||||||
|
* inside a customer's firm can take real actions against real client data, and every one of
|
||||||
|
* those actions is recorded against the customer's name.
|
||||||
|
*/
|
||||||
|
export function ImpersonationBanner() {
|
||||||
|
const me = useMe();
|
||||||
|
const endImpersonation = useEndImpersonation();
|
||||||
|
|
||||||
|
if (!me.data?.impersonatedBy) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="alert"
|
||||||
|
className="border-b border-rose-300 bg-rose-600 px-4 py-2.5 flex flex-wrap items-center gap-x-3 gap-y-1 text-sm text-white"
|
||||||
|
>
|
||||||
|
<UserCog className="h-4 w-4 flex-none" aria-hidden="true" />
|
||||||
|
<span>
|
||||||
|
Support session — you are acting as <strong>{me.data.email}</strong>. Everything you do is
|
||||||
|
recorded against this account and expires within the hour.
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => endImpersonation.mutate()}
|
||||||
|
disabled={endImpersonation.isPending}
|
||||||
|
className="ml-auto rounded-md bg-white/15 px-3 py-1 font-medium underline-offset-2 hover:bg-white/25 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-white disabled:opacity-60"
|
||||||
|
>
|
||||||
|
{endImpersonation.isPending ? 'Ending…' : 'End session'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -35,7 +35,7 @@ export function BlogTeaser() {
|
|||||||
{p.coverImage ? (
|
{p.coverImage ? (
|
||||||
<img
|
<img
|
||||||
src={p.coverImage}
|
src={p.coverImage}
|
||||||
alt=""
|
alt={`Cover image for article: ${p.title}`}
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
className="absolute inset-0 h-full w-full object-cover transition duration-300 group-hover:scale-[1.03]"
|
className="absolute inset-0 h-full w-full object-cover transition duration-300 group-hover:scale-[1.03]"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,33 +1,8 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { ChevronDown } from 'lucide-react';
|
import { ChevronDown } from 'lucide-react';
|
||||||
import { cn } from '@/lib/cn';
|
import { cn } from '@/lib/cn';
|
||||||
|
// FAQ content lives in routes-meta so the FAQPage JSON-LD stays in lockstep with the UI.
|
||||||
const ITEMS = [
|
import { FAQ_ITEMS as ITEMS } from '@/seo/routes-meta';
|
||||||
{
|
|
||||||
q: 'What makes eLegal Software different from other legal software?',
|
|
||||||
a: 'eLegal Software is built exclusively for legal professionals and bundles case management, billable-hours tracking, document storage, and invoicing into a single, secure platform — no jumping between tools, no per-feature add-ons.',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
q: 'Can I try eLegal Software before committing?',
|
|
||||||
a: 'Yes. The Starter plan is free forever and lets you manage one active case and two clients so you can experience the workflow end-to-end before upgrading.',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
q: 'How does client billing and payment processing work?',
|
|
||||||
a: 'You can convert any unbilled time entry into a polished invoice in seconds, send it to your client, and track its status from sent to paid. Payment processing is wired through Stripe.',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
q: 'Can I import my existing cases and client data?',
|
|
||||||
a: 'Yes. eLegal Software supports CSV imports for clients and cases. For larger migrations our team will assist directly during onboarding.',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
q: 'Is my client data secure and compliant?',
|
|
||||||
a: 'All data is encrypted at rest and in transit, hosted on enterprise-grade infrastructure with daily backups and audit logging. Access is gated by role-based permissions.',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
q: 'What happens if I need to cancel?',
|
|
||||||
a: 'You can cancel anytime from your billing settings. Your data remains accessible during the current billing period and can be exported in standard formats.',
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
export function Faq() {
|
export function Faq() {
|
||||||
const [open, setOpen] = useState<number | null>(0);
|
const [open, setOpen] = useState<number | null>(0);
|
||||||
|
|||||||
@@ -28,8 +28,6 @@ export const POSTS: Post[] = [
|
|||||||
publishedAt: '2026-04-08',
|
publishedAt: '2026-04-08',
|
||||||
readMinutes: 8,
|
readMinutes: 8,
|
||||||
author: 'eLegal Software Team',
|
author: 'eLegal Software Team',
|
||||||
coverImage:
|
|
||||||
'https://elegalsoftware.nyc3.digitaloceanspaces.com/media/blog/maximize-billable-hours-without-burnout.jpg',
|
|
||||||
body: [
|
body: [
|
||||||
{
|
{
|
||||||
type: 'p',
|
type: 'p',
|
||||||
@@ -92,8 +90,6 @@ export const POSTS: Post[] = [
|
|||||||
publishedAt: '2026-03-21',
|
publishedAt: '2026-03-21',
|
||||||
readMinutes: 12,
|
readMinutes: 12,
|
||||||
author: 'eLegal Software Team',
|
author: 'eLegal Software Team',
|
||||||
coverImage:
|
|
||||||
'https://elegalsoftware.nyc3.digitaloceanspaces.com/media/blog/client-intake-best-practices-2026.jpg',
|
|
||||||
body: [
|
body: [
|
||||||
{
|
{
|
||||||
type: 'p',
|
type: 'p',
|
||||||
@@ -162,8 +158,6 @@ export const POSTS: Post[] = [
|
|||||||
publishedAt: '2026-02-14',
|
publishedAt: '2026-02-14',
|
||||||
readMinutes: 10,
|
readMinutes: 10,
|
||||||
author: 'eLegal Software Team',
|
author: 'eLegal Software Team',
|
||||||
coverImage:
|
|
||||||
'https://elegalsoftware.nyc3.digitaloceanspaces.com/media/blog/legal-billing-software-comparison-2026.jpg',
|
|
||||||
body: [
|
body: [
|
||||||
{
|
{
|
||||||
type: 'p',
|
type: 'p',
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
// Server-side rendering entry, used ONLY by scripts/prerender.mts at build time
|
||||||
|
// (loaded through Vite's ssrLoadModule — never shipped to the browser).
|
||||||
|
// Deliberately does NOT import main.tsx: that file initializes Sentry and calls
|
||||||
|
// createRoot() at module scope, both of which are browser-only concerns.
|
||||||
|
import ReactDOMServer from 'react-dom/server';
|
||||||
|
import { StaticRouter } from 'react-router-dom/server';
|
||||||
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||||
|
import App from './App';
|
||||||
|
|
||||||
|
/** Render the app for a given URL to an HTML string (no effects run, no data fetching). */
|
||||||
|
export function render(url: string): string {
|
||||||
|
// Fresh client per render so no cache state leaks between prerendered routes.
|
||||||
|
const queryClient = new QueryClient({
|
||||||
|
defaultOptions: {
|
||||||
|
queries: {
|
||||||
|
retry: false,
|
||||||
|
staleTime: Infinity,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
return ReactDOMServer.renderToString(
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<StaticRouter location={url}>
|
||||||
|
<App />
|
||||||
|
</StaticRouter>
|
||||||
|
</QueryClientProvider>,
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
queryClient.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,6 +10,8 @@ export interface AuthUser {
|
|||||||
isSuperadmin?: boolean;
|
isSuperadmin?: boolean;
|
||||||
isSuspended?: boolean;
|
isSuspended?: boolean;
|
||||||
emailVerified?: boolean;
|
emailVerified?: boolean;
|
||||||
|
/** Superadmin id when this session was opened by admin impersonation, else null. */
|
||||||
|
impersonatedBy?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface MeResponse {
|
interface MeResponse {
|
||||||
@@ -69,3 +71,20 @@ export function useLogout() {
|
|||||||
onSuccess: () => qc.setQueryData(ME_KEY, null),
|
onSuccess: () => qc.setQueryData(ME_KEY, null),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Leave an impersonated session. The server destroys the borrowed session outright, so there is
|
||||||
|
* no session left to return to — the admin lands on the login page and signs in as themselves.
|
||||||
|
*/
|
||||||
|
export function useEndImpersonation() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation<void, ApiError>({
|
||||||
|
mutationFn: async () => {
|
||||||
|
await api.post('/api/auth/end-impersonation');
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
qc.setQueryData(ME_KEY, null);
|
||||||
|
qc.clear();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -15,6 +15,27 @@ const schema = z.object({
|
|||||||
|
|
||||||
type FormValues = z.infer<typeof schema>;
|
type FormValues = z.infer<typeof schema>;
|
||||||
|
|
||||||
|
// Guard against open-redirects. `next` is attacker-supplied (it rides in the login URL), so it
|
||||||
|
// is resolved against a throwaway origin and accepted only if it stays on that origin. Parsing
|
||||||
|
// rather than prefix-matching means encoded, backslash, and protocol-relative forms all
|
||||||
|
// normalise before the check, and the result never depends on how the router treats the string.
|
||||||
|
function safeNext(raw: string | null): string {
|
||||||
|
const FALLBACK = '/app';
|
||||||
|
if (!raw) return FALLBACK;
|
||||||
|
// Browsers strip tabs/newlines from URLs before parsing; do the same so they can't be used
|
||||||
|
// to smuggle a scheme past the checks below.
|
||||||
|
const cleaned = raw.replace(/[\t\n\r]/g, '');
|
||||||
|
if (!cleaned.startsWith('/') || cleaned.startsWith('//')) return FALLBACK;
|
||||||
|
try {
|
||||||
|
const probe = 'https://redirect-guard.invalid';
|
||||||
|
const url = new URL(cleaned, probe);
|
||||||
|
if (url.origin !== probe) return FALLBACK;
|
||||||
|
return `${url.pathname}${url.search}${url.hash}`;
|
||||||
|
} catch {
|
||||||
|
return FALLBACK;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
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 +75,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 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import { PublicLayout } from '@/components/public/PublicLayout';
|
||||||
|
|
||||||
|
export default function NotFoundPage() {
|
||||||
|
return (
|
||||||
|
<PublicLayout>
|
||||||
|
<section className="container py-24 max-w-2xl text-center">
|
||||||
|
<p className="text-xs uppercase tracking-wider text-brand-600 font-semibold">404</p>
|
||||||
|
<h1 className="mt-2 text-3xl md:text-4xl font-bold text-ink-950 font-display">
|
||||||
|
Page not found
|
||||||
|
</h1>
|
||||||
|
<p className="mt-4 text-ink-600">
|
||||||
|
The page you're looking for doesn't exist or has moved.
|
||||||
|
</p>
|
||||||
|
<div className="mt-8 flex flex-wrap items-center justify-center gap-3">
|
||||||
|
<Link to="/" className="btn-primary text-sm">
|
||||||
|
Home
|
||||||
|
</Link>
|
||||||
|
<Link to="/tools" className="btn-secondary text-sm">
|
||||||
|
Free tools
|
||||||
|
</Link>
|
||||||
|
<Link to="/blog" className="btn-secondary text-sm">
|
||||||
|
Blog
|
||||||
|
</Link>
|
||||||
|
<Link to="/login" className="btn-ghost text-sm">
|
||||||
|
Sign in
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</PublicLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -38,7 +38,9 @@ export default function BlogIndexPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="p-6 flex flex-col flex-1">
|
<div className="p-6 flex flex-col flex-1">
|
||||||
<p className="text-xs text-ink-500">{formatDate(p.publishedAt)}</p>
|
<p className="text-xs text-ink-500">
|
||||||
|
<time dateTime={p.publishedAt}>{formatDate(p.publishedAt)}</time>
|
||||||
|
</p>
|
||||||
<h3 className="mt-2 text-lg font-semibold text-ink-900 group-hover:text-brand-700 transition leading-snug">
|
<h3 className="mt-2 text-lg font-semibold text-ink-900 group-hover:text-brand-700 transition leading-snug">
|
||||||
{p.title}
|
{p.title}
|
||||||
</h3>
|
</h3>
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ export default function BlogPostPage() {
|
|||||||
<div className="mt-6 flex items-center gap-3 text-sm text-ink-500">
|
<div className="mt-6 flex items-center gap-3 text-sm text-ink-500">
|
||||||
<span>{post.author}</span>
|
<span>{post.author}</span>
|
||||||
<span className="text-ink-300">·</span>
|
<span className="text-ink-300">·</span>
|
||||||
<span>{formatDate(post.publishedAt)}</span>
|
<time dateTime={post.publishedAt}>{formatDate(post.publishedAt)}</time>
|
||||||
<span className="text-ink-300">·</span>
|
<span className="text-ink-300">·</span>
|
||||||
<span className="inline-flex items-center gap-1.5">
|
<span className="inline-flex items-center gap-1.5">
|
||||||
<Clock className="h-3.5 w-3.5" />
|
<Clock className="h-3.5 w-3.5" />
|
||||||
|
|||||||
@@ -0,0 +1,316 @@
|
|||||||
|
// Single source of truth for public-route SEO metadata.
|
||||||
|
// Consumed by three things — keep them in mind when editing:
|
||||||
|
// 1. <Seo /> (client) — updates document head on navigation
|
||||||
|
// 2. scripts/prerender.mts — injects head tags into static HTML at build time
|
||||||
|
// 3. scripts/generate-sitemap.mts — emits sitemap.xml for indexable routes
|
||||||
|
// IMPORTANT: imports here must stay RELATIVE (no '@/' alias) and side-effect-free,
|
||||||
|
// because the build scripts execute this module under tsx/node outside Vite.
|
||||||
|
import { POSTS } from '../content/posts';
|
||||||
|
|
||||||
|
export const SITE_URL = 'https://elegalsoftware.com';
|
||||||
|
export const SITE_NAME = 'eLegal Software';
|
||||||
|
export const DEFAULT_OG_IMAGE = `${SITE_URL}/logo-dark.png`;
|
||||||
|
|
||||||
|
// FAQ content lives here (not in Faq.tsx) so the FAQPage JSON-LD and the rendered
|
||||||
|
// accordion can never drift apart. Faq.tsx imports this.
|
||||||
|
export const FAQ_ITEMS = [
|
||||||
|
{
|
||||||
|
q: 'What makes eLegal Software different from other legal software?',
|
||||||
|
a: 'eLegal Software is built exclusively for legal professionals and bundles case management, billable-hours tracking, document storage, and invoicing into a single, secure platform — no jumping between tools, no per-feature add-ons.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
q: 'Can I try eLegal Software before committing?',
|
||||||
|
a: 'Yes. The Starter plan is free forever and lets you manage one active case and two clients so you can experience the workflow end-to-end before upgrading.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
q: 'How does client billing and payment processing work?',
|
||||||
|
a: 'You can convert any unbilled time entry into a polished invoice in seconds, send it to your client, and track its status from sent to paid. Payment processing is wired through Stripe.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
q: 'Can I import my existing cases and client data?',
|
||||||
|
a: 'Yes. eLegal Software supports CSV imports for clients and cases. For larger migrations our team will assist directly during onboarding.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
q: 'Is my client data secure and compliant?',
|
||||||
|
a: 'All data is encrypted at rest and in transit, hosted on enterprise-grade infrastructure with daily backups and audit logging. Access is gated by role-based permissions.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
q: 'What happens if I need to cancel?',
|
||||||
|
a: 'You can cancel anytime from your billing settings. Your data remains accessible during the current billing period and can be exported in standard formats.',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export interface RouteMeta {
|
||||||
|
path: string;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
noindex?: boolean;
|
||||||
|
ogType?: 'website' | 'article';
|
||||||
|
jsonLd?: Record<string, unknown>[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const ORGANIZATION_LD = {
|
||||||
|
'@context': 'https://schema.org',
|
||||||
|
'@type': 'Organization',
|
||||||
|
name: SITE_NAME,
|
||||||
|
url: SITE_URL,
|
||||||
|
logo: `${SITE_URL}/logo-dark.png`,
|
||||||
|
contactPoint: {
|
||||||
|
'@type': 'ContactPoint',
|
||||||
|
email: 'contact@elegalsoftware.com',
|
||||||
|
contactType: 'customer support',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const SOFTWARE_LD = {
|
||||||
|
'@context': 'https://schema.org',
|
||||||
|
'@type': 'SoftwareApplication',
|
||||||
|
name: SITE_NAME,
|
||||||
|
applicationCategory: 'BusinessApplication',
|
||||||
|
operatingSystem: 'Web',
|
||||||
|
url: SITE_URL,
|
||||||
|
description:
|
||||||
|
'All-in-one practice management for law firms: case management, billable-hours tracking, secure document storage, and invoicing.',
|
||||||
|
offers: [
|
||||||
|
{ '@type': 'Offer', name: 'Starter', price: '0', priceCurrency: 'USD' },
|
||||||
|
{ '@type': 'Offer', name: 'Professional', price: '25', priceCurrency: 'USD' },
|
||||||
|
{ '@type': 'Offer', name: 'Lifetime', price: '129', priceCurrency: 'USD' },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const FAQ_LD = {
|
||||||
|
'@context': 'https://schema.org',
|
||||||
|
'@type': 'FAQPage',
|
||||||
|
mainEntity: FAQ_ITEMS.map((item) => ({
|
||||||
|
'@type': 'Question',
|
||||||
|
name: item.q,
|
||||||
|
acceptedAnswer: { '@type': 'Answer', text: item.a },
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
|
||||||
|
const STATIC_ROUTES: RouteMeta[] = [
|
||||||
|
{
|
||||||
|
path: '/',
|
||||||
|
title: 'eLegal Software — Practice Management for Law Firms & Attorneys',
|
||||||
|
description:
|
||||||
|
'Streamline case management, billable hours, legal documents and invoicing in one secure platform built for attorneys. Free to start — no credit card required.',
|
||||||
|
jsonLd: [ORGANIZATION_LD, SOFTWARE_LD, FAQ_LD],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/login',
|
||||||
|
title: 'Sign In — eLegal Software',
|
||||||
|
description: 'Log in to eLegal Software to manage your cases, billable hours, and invoices.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/signup',
|
||||||
|
title: 'Start Your Free Trial — eLegal Software',
|
||||||
|
description:
|
||||||
|
'Create your free eLegal Software account in under a minute. Manage cases, track billable hours, and send invoices — no credit card required.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/forgot-password',
|
||||||
|
title: 'Reset Your Password — eLegal Software',
|
||||||
|
description: 'Request a password reset link for your eLegal Software account.',
|
||||||
|
noindex: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/reset-password',
|
||||||
|
title: 'Choose a New Password — eLegal Software',
|
||||||
|
description: 'Set a new password for your eLegal Software account.',
|
||||||
|
noindex: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/billing/success',
|
||||||
|
title: 'Payment Successful — eLegal Software',
|
||||||
|
description: 'Your eLegal Software subscription is active.',
|
||||||
|
noindex: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/billing/cancel',
|
||||||
|
title: 'Checkout Canceled — eLegal Software',
|
||||||
|
description: 'Your checkout was canceled — no charge was made.',
|
||||||
|
noindex: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/tools',
|
||||||
|
title: 'Free Tools for Attorneys & Law Firms — eLegal Software',
|
||||||
|
description:
|
||||||
|
'Free calculators and tools for legal professionals: hourly rate calculator, case profitability analyzer, billable hours tracker, and document templates.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/tools/hourly-rate-calculator',
|
||||||
|
title: 'Attorney Hourly Rate Calculator (Free) — eLegal Software',
|
||||||
|
description:
|
||||||
|
'Work out the hourly rate your practice actually needs — factoring target income, billable utilization, overhead, and taxes. Free, no signup required.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/tools/case-profitability',
|
||||||
|
title: 'Case Profitability Analyzer for Law Firms (Free) — eLegal Software',
|
||||||
|
description:
|
||||||
|
'Analyze whether a case or matter is profitable: fees, hours, effective rate, and margin — before and after write-offs. Free tool for attorneys.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/tools/billable-hours-tracker',
|
||||||
|
title: 'Free Billable Hours Tracker for Attorneys — eLegal Software',
|
||||||
|
description:
|
||||||
|
'Track billable time in your browser with a running timer and daily target — then see what those hours are worth. Free, no signup required.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/tools/document-templates',
|
||||||
|
title: 'Free Legal Document Templates for Small Firms — eLegal Software',
|
||||||
|
description:
|
||||||
|
'Starting points for engagement letters, intake forms, demand letters, and more. Copy, adapt with your attorney, and use in your practice.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/blog',
|
||||||
|
title: 'Legal Practice Management Blog — eLegal Software',
|
||||||
|
description:
|
||||||
|
'Practical guides for running a profitable law practice: billable hours, client intake, billing software, and firm operations.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/legal',
|
||||||
|
title: 'Legal Center — eLegal Software',
|
||||||
|
description:
|
||||||
|
'Every document that governs your use of eLegal Software: terms, privacy, billing, acceptable use, DMCA, and data processing.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/legal/terms',
|
||||||
|
title: 'Terms of Service — eLegal Software',
|
||||||
|
description: 'The agreement that governs your use of eLegal Software.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/legal/privacy',
|
||||||
|
title: 'Privacy Policy — eLegal Software',
|
||||||
|
description: 'What eLegal Software collects, why, where it lives, and the rights you have over it.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/legal/cookies',
|
||||||
|
title: 'Cookie Policy — eLegal Software',
|
||||||
|
description: 'The essential-only cookies eLegal Software sets and how to control them.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/legal/acceptable-use',
|
||||||
|
title: 'Acceptable Use Policy — eLegal Software',
|
||||||
|
description: 'What you may not do on the eLegal Software platform.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/legal/refunds',
|
||||||
|
title: 'Billing & Refund Policy — eLegal Software',
|
||||||
|
description: 'How subscriptions, renewals, cancellations, and refunds work at eLegal Software.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/legal/disclaimer',
|
||||||
|
title: 'Legal Disclaimer — eLegal Software',
|
||||||
|
description: 'eLegal Software is software, not a law firm — no legal advice, no attorney-client relationship.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/legal/dmca',
|
||||||
|
title: 'DMCA & Copyright Policy — eLegal Software',
|
||||||
|
description: 'How to report copyright infringement on eLegal Software, and how counter-notices work.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/legal/dpa',
|
||||||
|
title: 'Data Processing Addendum — eLegal Software',
|
||||||
|
description: 'How eLegal Software processes practice data on your behalf: security, subprocessors, breach notice.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/app',
|
||||||
|
title: 'Dashboard — eLegal Software',
|
||||||
|
description: 'Your eLegal Software workspace.',
|
||||||
|
noindex: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/admin',
|
||||||
|
title: 'Admin — eLegal Software',
|
||||||
|
description: 'eLegal Software administration.',
|
||||||
|
noindex: true,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const BLOG_ROUTES: RouteMeta[] = POSTS.map((post) => ({
|
||||||
|
path: `/blog/${post.slug}`,
|
||||||
|
title: `${post.title} — ${SITE_NAME}`,
|
||||||
|
description: post.description,
|
||||||
|
ogType: 'article' as const,
|
||||||
|
jsonLd: [
|
||||||
|
{
|
||||||
|
'@context': 'https://schema.org',
|
||||||
|
'@type': 'BlogPosting',
|
||||||
|
headline: post.title,
|
||||||
|
description: post.description,
|
||||||
|
datePublished: post.publishedAt,
|
||||||
|
author: { '@type': 'Organization', name: SITE_NAME, url: SITE_URL },
|
||||||
|
publisher: { '@type': 'Organization', name: SITE_NAME, logo: { '@type': 'ImageObject', url: `${SITE_URL}/logo-dark.png` } },
|
||||||
|
mainEntityOfPage: `${SITE_URL}/blog/${post.slug}`,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const ALL_ROUTES: RouteMeta[] = [...STATIC_ROUTES, ...BLOG_ROUTES];
|
||||||
|
|
||||||
|
/** Routes to prerender to static HTML at build time (everything public & static). */
|
||||||
|
export const PRERENDER_ROUTES: string[] = ALL_ROUTES.filter(
|
||||||
|
(r) => r.path !== '/app' && r.path !== '/admin' && r.path !== '/reset-password',
|
||||||
|
).map((r) => r.path);
|
||||||
|
|
||||||
|
/** Routes that belong in sitemap.xml (public and indexable). */
|
||||||
|
export const SITEMAP_ROUTES: RouteMeta[] = ALL_ROUTES.filter((r) => !r.noindex);
|
||||||
|
|
||||||
|
export function metaForPath(pathname: string): RouteMeta | undefined {
|
||||||
|
const clean = pathname !== '/' && pathname.endsWith('/') ? pathname.slice(0, -1) : pathname;
|
||||||
|
const exact = ALL_ROUTES.find((r) => r.path === clean);
|
||||||
|
if (exact) return exact;
|
||||||
|
// Authed sections: any nested path inherits the section's noindex meta.
|
||||||
|
if (clean.startsWith('/app/')) return ALL_ROUTES.find((r) => r.path === '/app');
|
||||||
|
if (clean.startsWith('/admin/')) return ALL_ROUTES.find((r) => r.path === '/admin');
|
||||||
|
return undefined; // unknown → <Seo /> falls back to a noindex not-found meta
|
||||||
|
}
|
||||||
|
|
||||||
|
export const NOT_FOUND_META: RouteMeta = {
|
||||||
|
path: '',
|
||||||
|
title: 'Page Not Found — eLegal Software',
|
||||||
|
description: 'The page you were looking for does not exist.',
|
||||||
|
noindex: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Static head rendering (used by scripts/prerender.mts) ──────────────────
|
||||||
|
// Every generated tag carries data-seo so the client <Seo /> can replace them
|
||||||
|
// wholesale on navigation without duplicating.
|
||||||
|
|
||||||
|
function escapeHtml(s: string): string {
|
||||||
|
return s
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function canonicalUrl(path: string): string {
|
||||||
|
return path === '/' ? `${SITE_URL}/` : `${SITE_URL}${path}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function renderHeadTags(meta: RouteMeta): string {
|
||||||
|
const url = canonicalUrl(meta.path);
|
||||||
|
const t = escapeHtml(meta.title);
|
||||||
|
const d = escapeHtml(meta.description);
|
||||||
|
const tags = [
|
||||||
|
`<title>${t}</title>`,
|
||||||
|
`<meta name="description" content="${d}" data-seo="1">`,
|
||||||
|
meta.noindex
|
||||||
|
? `<meta name="robots" content="noindex, nofollow" data-seo="1">`
|
||||||
|
: `<link rel="canonical" href="${url}" data-seo="1">`,
|
||||||
|
`<meta property="og:site_name" content="${escapeHtml(SITE_NAME)}" data-seo="1">`,
|
||||||
|
`<meta property="og:type" content="${meta.ogType ?? 'website'}" data-seo="1">`,
|
||||||
|
`<meta property="og:url" content="${url}" data-seo="1">`,
|
||||||
|
`<meta property="og:title" content="${t}" data-seo="1">`,
|
||||||
|
`<meta property="og:description" content="${d}" data-seo="1">`,
|
||||||
|
`<meta property="og:image" content="${DEFAULT_OG_IMAGE}" data-seo="1">`,
|
||||||
|
`<meta name="twitter:card" content="summary" data-seo="1">`,
|
||||||
|
`<meta name="twitter:title" content="${t}" data-seo="1">`,
|
||||||
|
`<meta name="twitter:description" content="${d}" data-seo="1">`,
|
||||||
|
...(meta.jsonLd ?? []).map(
|
||||||
|
(ld) => `<script type="application/ld+json" data-seo="1">${JSON.stringify(ld)}</script>`,
|
||||||
|
),
|
||||||
|
];
|
||||||
|
return tags.join('\n ');
|
||||||
|
}
|
||||||
+10
-4
@@ -1,8 +1,12 @@
|
|||||||
import { defineConfig } from 'vite';
|
import { defineConfig, loadEnv } from 'vite';
|
||||||
import react from '@vitejs/plugin-react';
|
import react from '@vitejs/plugin-react';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig(({ mode }) => {
|
||||||
|
// Read the monorepo root .env (all keys, not just VITE_*) so ports stay configurable there.
|
||||||
|
const env = { ...loadEnv(mode, path.resolve(__dirname, '../..'), ''), ...process.env };
|
||||||
|
|
||||||
|
return {
|
||||||
plugins: [react()],
|
plugins: [react()],
|
||||||
// VITE_* vars live in the monorepo root .env alongside the API's config.
|
// VITE_* vars live in the monorepo root .env alongside the API's config.
|
||||||
envDir: path.resolve(__dirname, '../..'),
|
envDir: path.resolve(__dirname, '../..'),
|
||||||
@@ -12,10 +16,11 @@ export default defineConfig({
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
server: {
|
server: {
|
||||||
port: 5173,
|
// Ports are overridable so this app can run alongside other local projects.
|
||||||
|
port: Number(env.WEB_PORT ?? 5173),
|
||||||
proxy: {
|
proxy: {
|
||||||
'/api': {
|
'/api': {
|
||||||
target: 'http://localhost:8080',
|
target: env.API_PROXY_TARGET ?? `http://localhost:${env.PORT ?? 8080}`,
|
||||||
changeOrigin: true,
|
changeOrigin: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -33,4 +38,5 @@ export default defineConfig({
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
};
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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
+1985
-1924
File diff suppressed because it is too large
Load Diff
+6
-1
@@ -26,6 +26,11 @@
|
|||||||
"test": "npm run test --workspaces --if-present"
|
"test": "npm run test --workspaces --if-present"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"typescript": "^5.6.3"
|
"typescript": "^5.6.3",
|
||||||
|
"drizzle-orm": "^0.45.2"
|
||||||
|
},
|
||||||
|
"overrides": {
|
||||||
|
"find-my-way": "^9.9.0",
|
||||||
|
"fast-uri": "^3.1.5"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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");
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
ALTER TABLE "sessions" ADD COLUMN "impersonated_by" uuid;--> statement-breakpoint
|
||||||
|
ALTER TABLE "sessions" ADD CONSTRAINT "sessions_impersonated_by_users_id_fk" FOREIGN KEY ("impersonated_by") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -15,6 +15,20 @@
|
|||||||
"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
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 3,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1787753919185,
|
||||||
|
"tag": "0003_illegal_leper_queen",
|
||||||
|
"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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,6 +20,27 @@ function stripSslmode(url: string): string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A database reachable only over a private network (a Docker/Swarm service name, localhost, or an
|
||||||
|
// RFC1918 address) never crosses a link an attacker can sit on, so plaintext there is not the
|
||||||
|
// man-in-the-middle exposure that plaintext to a public host is. Anything else — a routable
|
||||||
|
// hostname or public IP — is treated as public and still fails closed in production.
|
||||||
|
function isPrivateHost(url: string): boolean {
|
||||||
|
let host: string;
|
||||||
|
try {
|
||||||
|
host = new URL(url).hostname;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (host === 'localhost' || host.endsWith('.internal') || host.endsWith('.local')) return true;
|
||||||
|
// Bare service name (no dots) — Docker/Swarm internal DNS, e.g. "elegalsoftware-db-puhy21".
|
||||||
|
if (!host.includes('.')) return true;
|
||||||
|
const m = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(host);
|
||||||
|
if (!m) return false;
|
||||||
|
const a = Number(m[1]);
|
||||||
|
const b = Number(m[2]);
|
||||||
|
return a === 10 || a === 127 || (a === 172 && b >= 16 && b <= 31) || (a === 192 && b === 168);
|
||||||
|
}
|
||||||
|
|
||||||
export function getPool(): pg.Pool {
|
export function getPool(): pg.Pool {
|
||||||
if (_pool) return _pool;
|
if (_pool) return _pool;
|
||||||
|
|
||||||
@@ -35,18 +56,29 @@ export function getPool(): pg.Pool {
|
|||||||
// Strip sslmode from the URL so our explicit `ssl` option fully controls TLS behavior.
|
// Strip sslmode from the URL so our explicit `ssl` option fully controls TLS behavior.
|
||||||
// Without this, pg merges URL-derived settings which can conflict with the options below.
|
// Without this, pg merges URL-derived settings which can conflict with the options below.
|
||||||
let ssl: pg.PoolConfig['ssl'];
|
let ssl: pg.PoolConfig['ssl'];
|
||||||
|
const wantsTls = process.env.DATABASE_SSL === 'require' || process.env.DATABASE_SSL === 'verify';
|
||||||
if (process.env.DATABASE_SSL === 'disable') {
|
if (process.env.DATABASE_SSL === 'disable') {
|
||||||
// Explicit opt-out for local dev/test databases that don't speak TLS at all (e.g. a
|
// Explicit opt-out for databases that don't speak TLS at all (e.g. a disposable Docker
|
||||||
// disposable Docker Postgres). Refused in production — prod must always verify TLS.
|
// Postgres). In production this is honored ONLY for a private-network host; pointing it at
|
||||||
if (isProd) {
|
// a public database still fails closed.
|
||||||
throw new Error('DATABASE_SSL=disable is not allowed in production');
|
if (isProd && !isPrivateHost(connectionString)) {
|
||||||
|
throw new Error(
|
||||||
|
'DATABASE_SSL=disable is only allowed in production when DATABASE_URL points at a private-network host (Docker service name, localhost, or an RFC1918 address).',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
ssl = false;
|
ssl = false;
|
||||||
|
} else if (isPrivateHost(connectionString) && !wantsTls) {
|
||||||
|
// Database on a private network (app + Postgres on the same Docker/Swarm overlay, or a local
|
||||||
|
// container). The connection never leaves that network, so plaintext is fine here. This is
|
||||||
|
// checked BEFORE the CA branch on purpose: a leftover DATABASE_CA_CERT_PATH from a previous
|
||||||
|
// managed-database provider must not force TLS onto a server that does not speak it. Set
|
||||||
|
// DATABASE_SSL=require (or =verify) to opt a private host back into TLS.
|
||||||
|
ssl = false;
|
||||||
} else if (ca) {
|
} else if (ca) {
|
||||||
// Verified TLS against the managed-DB CA — the correct posture everywhere.
|
// Verified TLS against the managed-DB CA — the correct posture for any public host.
|
||||||
ssl = { ca, rejectUnauthorized: true };
|
ssl = { ca, rejectUnauthorized: true };
|
||||||
} else if (isProd) {
|
} else if (isProd) {
|
||||||
// Never run production against the database over unverified TLS: fail fast so a missing
|
// Public database host in production: never connect over unverified TLS. Fail fast so a missing
|
||||||
// CA cert is a loud deploy error instead of a silent man-in-the-middle exposure.
|
// CA cert is a loud deploy error instead of a silent man-in-the-middle exposure.
|
||||||
throw new Error(
|
throw new Error(
|
||||||
'DATABASE_CA_CERT_PATH is required in production: point it at the managed-DB CA cert so TLS certificates are verified (rejectUnauthorized: true).',
|
'DATABASE_CA_CERT_PATH is required in production: point it at the managed-DB CA cert so TLS certificates are verified (rejectUnauthorized: true).',
|
||||||
|
|||||||
@@ -40,6 +40,10 @@ export const sessions = pgTable(
|
|||||||
.notNull()
|
.notNull()
|
||||||
.references(() => users.id, { onDelete: 'cascade' }),
|
.references(() => users.id, { onDelete: 'cascade' }),
|
||||||
expiresAt: timestamp('expires_at', { withTimezone: true }).notNull(),
|
expiresAt: timestamp('expires_at', { withTimezone: true }).notNull(),
|
||||||
|
// Set when a superadmin opened this session via /api/admin/users/:id/impersonate. Non-null
|
||||||
|
// means every action on this session is really that admin acting as `userId` — the API
|
||||||
|
// stamps it onto audit rows and the UI shows a persistent impersonation banner.
|
||||||
|
impersonatedBy: uuid('impersonated_by').references(() => users.id, { onDelete: 'set null' }),
|
||||||
ip: inet('ip'),
|
ip: inet('ip'),
|
||||||
userAgent: text('user_agent'),
|
userAgent: text('user_agent'),
|
||||||
lastSeenAt: timestamp('last_seen_at', { withTimezone: true }).notNull().defaultNow(),
|
lastSeenAt: timestamp('last_seen_at', { withTimezone: true }).notNull().defaultNow(),
|
||||||
|
|||||||
@@ -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