diff --git a/docs/AUDIT-PARKED-QUESTIONS.md b/docs/AUDIT-PARKED-QUESTIONS.md new file mode 100644 index 00000000..8c8717c6 --- /dev/null +++ b/docs/AUDIT-PARKED-QUESTIONS.md @@ -0,0 +1,6 @@ +# Parked questions — needs product/business decision + +Items from the 33-agent audit that I deliberately left for you to decide on. Each one has the finding, why I parked it, and the proposed options. + +--- + diff --git a/package.json b/package.json index 43298205..a2c43a4d 100644 --- a/package.json +++ b/package.json @@ -108,7 +108,7 @@ "@types/archiver": "^7.0.0", "@types/iso-3166-2": "^1.0.4", "@types/mailparser": "^3.4.6", - "@types/node": "^25.6.2", + "@types/node": "^20.19.0", "@types/nodemailer": "^8.0.0", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7911dcd7..2cccc5c9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -249,8 +249,8 @@ importers: specifier: ^3.4.6 version: 3.4.6 '@types/node': - specifier: ^25.6.2 - version: 25.6.2 + specifier: ^20.19.0 + version: 20.19.41 '@types/nodemailer': specifier: ^8.0.0 version: 8.0.0 @@ -310,7 +310,7 @@ importers: version: 6.0.3 vitest: specifier: ^4.1.5 - version: 4.1.5(@types/node@25.6.2)(@vitest/coverage-v8@4.1.5)(vite@8.0.5(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@types/node@25.6.2)(esbuild@0.27.7)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.8.4)) + version: 4.1.5(@types/node@20.19.41)(@vitest/coverage-v8@4.1.5)(vite@8.0.5(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@types/node@20.19.41)(esbuild@0.27.7)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.8.4)) packages: @@ -1857,11 +1857,8 @@ packages: '@types/mailparser@3.4.6': resolution: {integrity: sha512-wVV3cnIKzxTffaPH8iRnddX1zahbYB1ZEoAxyhoBo3TBCBuK6nZ8M8JYO/RhsCuuBVOw/DEN/t/ENbruwlxn6Q==} - '@types/node@22.19.18': - resolution: {integrity: sha512-9v00a+dn2yWVsYDEunWC4g/TcRKVq3r8N5FuZp7u0SGrPvdN9c2yXI9bBuf5Fl0hNCb+QTIePTn5pJs2pwBOQQ==} - - '@types/node@25.6.2': - resolution: {integrity: sha512-sokuT28dxf9JT5Kady1fsXOvI4HVpjZa95NKT5y9PNTIrs2AsobR4GFAA90ZG8M+nxVRLysCXsVj6eGC7Vbrlw==} + '@types/node@20.19.41': + resolution: {integrity: sha512-ECymXOukMnOoVkC2bb1Vc/w/836DXncOg5m8Xj1RH7xSHZJWNYY6Zh7EH477vcnD5egKNNfy2RpNOmuChhFPgQ==} '@types/nodemailer@8.0.0': resolution: {integrity: sha512-fyf8jWULsCo0d0BuoQ75i6IeoHs47qcqxWc7yUdUcV0pOZGjUTTOvwdG1PRXUDqN/8A64yQdQdnA2pZgcdi+cA==} @@ -4907,9 +4904,6 @@ packages: undici-types@6.21.0: resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} - undici-types@7.19.2: - resolution: {integrity: sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg==} - unicode-properties@1.4.1: resolution: {integrity: sha512-CLjCCLQ6UuMxWnbIylkisbRj31qxHPAurvena/0iwSVbQ2G1VY5/HjV0IRabOEbDHlzZlRdCrD4NhB0JtU40Pg==} @@ -6506,7 +6500,7 @@ snapshots: '@types/cors@2.8.19': dependencies: - '@types/node': 22.19.18 + '@types/node': 20.19.41 '@types/d3-array@3.2.2': {} @@ -6544,24 +6538,20 @@ snapshots: '@types/mailparser@3.4.6': dependencies: - '@types/node': 22.19.18 + '@types/node': 20.19.41 iconv-lite: 0.6.3 - '@types/node@22.19.18': + '@types/node@20.19.41': dependencies: undici-types: 6.21.0 - '@types/node@25.6.2': - dependencies: - undici-types: 7.19.2 - '@types/nodemailer@8.0.0': dependencies: - '@types/node': 22.19.18 + '@types/node': 20.19.41 '@types/pdfkit@0.17.6': dependencies: - '@types/node': 22.19.18 + '@types/node': 20.19.41 '@types/react-dom@19.2.3(@types/react@19.2.14)': dependencies: @@ -6573,7 +6563,7 @@ snapshots: '@types/readdir-glob@1.1.5': dependencies: - '@types/node': 22.19.18 + '@types/node': 20.19.41 '@types/trusted-types@2.0.7': optional: true @@ -6590,7 +6580,7 @@ snapshots: '@types/ws@8.18.1': dependencies: - '@types/node': 22.19.18 + '@types/node': 20.19.41 '@typescript-eslint/eslint-plugin@8.59.2(@typescript-eslint/parser@8.59.2(eslint@9.39.4(jiti@1.21.7))(typescript@6.0.3))(eslint@9.39.4(jiti@1.21.7))(typescript@6.0.3)': dependencies: @@ -6754,7 +6744,7 @@ snapshots: obug: 2.1.1 std-env: 4.1.0 tinyrainbow: 3.1.0 - vitest: 4.1.5(@types/node@25.6.2)(@vitest/coverage-v8@4.1.5)(vite@8.0.5(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@types/node@25.6.2)(esbuild@0.27.7)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.8.4)) + vitest: 4.1.5(@types/node@20.19.41)(@vitest/coverage-v8@4.1.5)(vite@8.0.5(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@types/node@20.19.41)(esbuild@0.27.7)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.8.4)) '@vitest/expect@4.1.5': dependencies: @@ -6765,13 +6755,13 @@ snapshots: chai: 6.2.2 tinyrainbow: 3.1.0 - '@vitest/mocker@4.1.5(vite@8.0.5(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@types/node@25.6.2)(esbuild@0.27.7)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.8.4))': + '@vitest/mocker@4.1.5(vite@8.0.5(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@types/node@20.19.41)(esbuild@0.27.7)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.8.4))': dependencies: '@vitest/spy': 4.1.5 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 8.0.5(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@types/node@25.6.2)(esbuild@0.27.7)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.8.4) + vite: 8.0.5(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@types/node@20.19.41)(esbuild@0.27.7)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.8.4) '@vitest/pretty-format@4.1.5': dependencies: @@ -7060,7 +7050,7 @@ snapshots: next: 15.5.18(@playwright/test@1.59.1)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) react: 19.2.6 react-dom: 19.2.6(react@19.2.6) - vitest: 4.1.5(@types/node@25.6.2)(@vitest/coverage-v8@4.1.5)(vite@8.0.5(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@types/node@25.6.2)(esbuild@0.27.7)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.8.4)) + vitest: 4.1.5(@types/node@20.19.41)(@vitest/coverage-v8@4.1.5)(vite@8.0.5(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@types/node@20.19.41)(esbuild@0.27.7)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.8.4)) transitivePeerDependencies: - '@cloudflare/workers-types' - '@opentelemetry/api' @@ -7484,7 +7474,7 @@ snapshots: engine.io@6.6.7: dependencies: '@types/cors': 2.8.19 - '@types/node': 22.19.18 + '@types/node': 20.19.41 '@types/ws': 8.18.1 accepts: 1.3.8 base64id: 2.0.0 @@ -9820,8 +9810,6 @@ snapshots: undici-types@6.21.0: {} - undici-types@7.19.2: {} - unicode-properties@1.4.1: dependencies: base64-js: 1.5.1 @@ -9915,7 +9903,7 @@ snapshots: d3-time: 3.1.0 d3-timer: 3.0.1 - vite@8.0.5(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@types/node@25.6.2)(esbuild@0.27.7)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.8.4): + vite@8.0.5(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@types/node@20.19.41)(esbuild@0.27.7)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.8.4): dependencies: lightningcss: 1.32.0 picomatch: 4.0.4 @@ -9923,7 +9911,7 @@ snapshots: rolldown: 1.0.0-rc.12(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0) tinyglobby: 0.2.16 optionalDependencies: - '@types/node': 25.6.2 + '@types/node': 20.19.41 esbuild: 0.27.7 fsevents: 2.3.3 jiti: 1.21.7 @@ -9933,10 +9921,10 @@ snapshots: - '@emnapi/core' - '@emnapi/runtime' - vitest@4.1.5(@types/node@25.6.2)(@vitest/coverage-v8@4.1.5)(vite@8.0.5(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@types/node@25.6.2)(esbuild@0.27.7)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.8.4)): + vitest@4.1.5(@types/node@20.19.41)(@vitest/coverage-v8@4.1.5)(vite@8.0.5(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@types/node@20.19.41)(esbuild@0.27.7)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.8.4)): dependencies: '@vitest/expect': 4.1.5 - '@vitest/mocker': 4.1.5(vite@8.0.5(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@types/node@25.6.2)(esbuild@0.27.7)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.8.4)) + '@vitest/mocker': 4.1.5(vite@8.0.5(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@types/node@20.19.41)(esbuild@0.27.7)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.8.4)) '@vitest/pretty-format': 4.1.5 '@vitest/runner': 4.1.5 '@vitest/snapshot': 4.1.5 @@ -9953,10 +9941,10 @@ snapshots: tinyexec: 1.1.2 tinyglobby: 0.2.16 tinyrainbow: 3.1.0 - vite: 8.0.5(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@types/node@25.6.2)(esbuild@0.27.7)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.8.4) + vite: 8.0.5(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@types/node@20.19.41)(esbuild@0.27.7)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.8.4) why-is-node-running: 2.3.0 optionalDependencies: - '@types/node': 25.6.2 + '@types/node': 20.19.41 '@vitest/coverage-v8': 4.1.5(vitest@4.1.5) transitivePeerDependencies: - msw diff --git a/src/app/api/public/interests/route.ts b/src/app/api/public/interests/route.ts index db9bc462..a11ce483 100644 --- a/src/app/api/public/interests/route.ts +++ b/src/app/api/public/interests/route.ts @@ -84,11 +84,18 @@ export async function POST(req: NextRequest) { // ─── Transactional trio creation ──────────────────────────────────────── const result = await withTransaction(async (tx) => { - // 1. Find or create client by email (case-sensitive contact match, same - // behavior as before the refactor). + // 1. Find or create client by email. The inquiry-funnel audit + // flagged that the previous exact match was case-sensitive — + // capital-letter resubmissions spawned duplicate client+yacht+ + // interest rows. Match LOWER(value) instead so foo@x.com and + // Foo@X.COM dedupe to the same client. let clientId: string; + const normalizedEmail = data.email.trim().toLowerCase(); const existingContact = await tx.query.clientContacts.findFirst({ - where: and(eq(clientContacts.channel, 'email'), eq(clientContacts.value, data.email)), + where: and( + eq(clientContacts.channel, 'email'), + sql`LOWER(${clientContacts.value}) = ${normalizedEmail}`, + ), }); if (existingContact) { const existingClient = await tx.query.clients.findFirst({ @@ -320,7 +327,9 @@ async function createClientInTx( await tx.insert(clientContacts).values({ clientId, channel: 'email', - value: data.email, + // Store lowercased so the case-insensitive dedup match above always + // hits on subsequent submissions. + value: data.email.trim().toLowerCase(), isPrimary: true, }); diff --git a/src/app/api/v1/alerts/route.ts b/src/app/api/v1/alerts/route.ts index b2806322..b14ec219 100644 --- a/src/app/api/v1/alerts/route.ts +++ b/src/app/api/v1/alerts/route.ts @@ -1,11 +1,15 @@ import { NextRequest, NextResponse } from 'next/server'; -import { withAuth } from '@/lib/api/helpers'; +import { withAuth, withPermission } from '@/lib/api/helpers'; import { listAlertsForPort } from '@/lib/services/alerts.service'; type AlertStatus = 'open' | 'dismissed' | 'resolved'; -export const GET = withAuth(async (req: NextRequest, ctx) => { +// Tier-4 (authz-auditor): alerts include permission_denied + audit-adjacent +// signals. Gated on admin.view_audit_log — same permission the audit log +// page uses. +export const GET = withAuth( + withPermission('admin', 'view_audit_log', async (req: NextRequest, ctx) => { const url = new URL(req.url); const status = (url.searchParams.get('status') ?? 'open') as AlertStatus; @@ -23,4 +27,5 @@ export const GET = withAuth(async (req: NextRequest, ctx) => { }); return NextResponse.json({ data: filtered }); -}); + }), +); diff --git a/src/app/api/webhooks/documenso/route.ts b/src/app/api/webhooks/documenso/route.ts index 9cff7c33..2d88a7e2 100644 --- a/src/app/api/webhooks/documenso/route.ts +++ b/src/app/api/webhooks/documenso/route.ts @@ -15,6 +15,7 @@ import { import { logger } from '@/lib/logger'; import { createAuditLog } from '@/lib/audit'; import { checkRateLimit, rateLimiters } from '@/lib/rate-limit'; +import { captureErrorEvent } from '@/lib/services/error-events.service'; // BR-024: Dedup via signatureHash unique index on documentEvents // Always return 200 from webhook (webhook best practice) @@ -263,6 +264,15 @@ export async function POST(req: NextRequest): Promise { } } catch (err) { logger.error({ err, event }, 'Error processing Documenso webhook'); + // The audit caught that webhook handlers were the only API surface + // bypassing the platform-error pipeline — admin/errors was silent on + // Documenso webhook crashes. Pipe them in so they surface alongside + // every other 5xx. + void captureErrorEvent({ + statusCode: 500, + error: err, + metadata: { source: 'webhook', provider: 'documenso', event }, + }); } return NextResponse.json({ ok: true }, { status: 200 }); diff --git a/src/lib/db/migrations/0056_audit_hardening.sql b/src/lib/db/migrations/0056_audit_hardening.sql new file mode 100644 index 00000000..96bbaf41 --- /dev/null +++ b/src/lib/db/migrations/0056_audit_hardening.sql @@ -0,0 +1,51 @@ +-- 0056_audit_hardening.sql +-- ---------------------------------------------------------------------------- +-- Address several Tier-4/5 audit findings in one migration: +-- +-- 1. user_permission_overrides.user_id had no FK at all (data-model H1). +-- Add an explicit reference to user(id) with onDelete='cascade' so a +-- deleted user can't leave dangling override rows. +-- +-- 2. user_email_changes lacked a partial unique index on pending rows +-- (concurrency H + GDPR follow-up). Without this, a malicious or +-- confused admin can spam the email-change endpoint to generate +-- multiple pending tokens, each emailing the operator's inbox. +-- +-- 3. user_port_roles.userId previously had no FK either — see data-model +-- H1. Add the same cascade. +-- +-- Each statement is wrapped in DO blocks so the migration is replayable +-- (idempotent) and tolerant of being run more than once. + +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM information_schema.table_constraints + WHERE constraint_name = 'fk_user_permission_overrides_user' + AND table_name = 'user_permission_overrides' + ) THEN + ALTER TABLE user_permission_overrides + ADD CONSTRAINT fk_user_permission_overrides_user + FOREIGN KEY (user_id) REFERENCES "user"(id) ON DELETE CASCADE; + END IF; +END $$; + +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM information_schema.table_constraints + WHERE constraint_name = 'fk_user_port_roles_user' + AND table_name = 'user_port_roles' + ) THEN + ALTER TABLE user_port_roles + ADD CONSTRAINT fk_user_port_roles_user + FOREIGN KEY (user_id) REFERENCES "user"(id) ON DELETE CASCADE; + END IF; +END $$; + +-- Partial unique index: at most one pending row per user. Pending = both +-- `applied_at` and `cancelled_at` are NULL. Lets old / completed rows +-- accumulate as history without ever blocking a fresh change. +CREATE UNIQUE INDEX IF NOT EXISTS idx_user_email_changes_one_pending + ON user_email_changes (user_id) + WHERE applied_at IS NULL AND cancelled_at IS NULL; diff --git a/src/lib/email/shell.ts b/src/lib/email/shell.ts index 079b45fc..e44a33cf 100644 --- a/src/lib/email/shell.ts +++ b/src/lib/email/shell.ts @@ -78,3 +78,42 @@ export function renderShell({ title, body, branding }: ShellOpts): string { export function brandingPrimaryColor(branding?: BrandingShell | null): string { return branding?.primaryColor ?? DEFAULT_PRIMARY_COLOR; } + +/** + * URL-safe escaper for `href="..."` interpolations inside email + * templates. The email-deliverability audit flagged that every template + * inlined `${data.link}` directly into href + visible text without + * escaping — a `"` (or worse, a `javascript:` scheme) would break out + * of the attribute or trigger an XSS when the recipient opens the email + * in a webmail client that runs scripts. + * + * Two-step defense: + * 1. Scheme allow-list — only http(s), mailto, tel survive; everything + * else (javascript:, data:, vbscript:, file:, …) is rewritten to + * `about:blank`. + * 2. HTML-attribute escape on `"`, `<`, `>`, `&`, `'`, backtick. + */ +export function safeUrl(url: string | null | undefined): string { + if (!url) return 'about:blank'; + const trimmed = String(url).trim(); + // Block dangerous schemes. The allow-list is intentionally short. + const lower = trimmed.toLowerCase(); + const ok = + lower.startsWith('http://') || + lower.startsWith('https://') || + lower.startsWith('mailto:') || + lower.startsWith('tel:') || + // Relative or root-relative paths are also acceptable — they + // resolve against the host the email links to (rare in transactional + // mail but used by tracking pixels and unsubscribe headers). + lower.startsWith('/') || + lower.startsWith('#'); + if (!ok) return 'about:blank'; + return trimmed + .replace(/&/g, '&') + .replace(/"/g, '"') + .replace(/'/g, ''') + .replace(//g, '>') + .replace(/`/g, '`'); +} diff --git a/src/lib/email/templates/admin-email-change.ts b/src/lib/email/templates/admin-email-change.ts index 736a5d41..c03f4f90 100644 --- a/src/lib/email/templates/admin-email-change.ts +++ b/src/lib/email/templates/admin-email-change.ts @@ -1,4 +1,4 @@ -import { brandingPrimaryColor, renderShell, type BrandingShell } from '@/lib/email/shell'; +import { brandingPrimaryColor, renderShell, safeUrl, type BrandingShell } from '@/lib/email/shell'; interface AdminEmailChangeData { recipientName?: string; @@ -45,7 +45,7 @@ export function adminEmailChangeEmail( ${ data.loginUrl ? `

- + Sign in

` diff --git a/src/lib/email/templates/crm-invite.ts b/src/lib/email/templates/crm-invite.ts index 8d8f0d9a..3dc5337c 100644 --- a/src/lib/email/templates/crm-invite.ts +++ b/src/lib/email/templates/crm-invite.ts @@ -1,4 +1,4 @@ -import { brandingPrimaryColor, renderShell, type BrandingShell } from '@/lib/email/shell'; +import { brandingPrimaryColor, renderShell, safeUrl, type BrandingShell } from '@/lib/email/shell'; interface InviteData { link: string; @@ -39,13 +39,13 @@ export function crmInviteEmail( link expires in ${data.ttlHours} hours.

- + Set up your account

If the button doesn't work, paste this link into your browser:
- ${data.link} + ${data.link}

Thank you,
diff --git a/src/lib/email/templates/document-signing.ts b/src/lib/email/templates/document-signing.ts index 97aa546a..6479e50f 100644 --- a/src/lib/email/templates/document-signing.ts +++ b/src/lib/email/templates/document-signing.ts @@ -26,7 +26,7 @@ * transformation from the raw Documenso URL. */ -import { brandingPrimaryColor, renderShell, type BrandingShell } from '@/lib/email/shell'; +import { brandingPrimaryColor, renderShell, safeUrl, type BrandingShell } from '@/lib/email/shell'; interface RenderOpts { subject?: string | null; @@ -89,13 +89,13 @@ export function signingInvitationEmail(

${leadCopy}

${customMessageBlock}

- + Review & sign

If the button doesn't work, paste this link into your browser:
- ${data.signingUrl} + ${data.signingUrl}

Signing happens directly inside our website — your data isn't sent to a third-party signing service. @@ -208,12 +208,12 @@ export function signingReminderEmail(

${customMessageBlock}

- + Sign now

- Direct link: ${data.signingUrl} + Direct link: ${data.signingUrl}

Thank you,
diff --git a/src/lib/email/templates/notification-digest.ts b/src/lib/email/templates/notification-digest.ts index b4a88130..f340f8c0 100644 --- a/src/lib/email/templates/notification-digest.ts +++ b/src/lib/email/templates/notification-digest.ts @@ -3,7 +3,7 @@ * Used by the notification-digest scheduler (queued in `email` worker). */ -import { brandingPrimaryColor, renderShell, type BrandingShell } from '@/lib/email/shell'; +import { brandingPrimaryColor, renderShell, safeUrl, type BrandingShell } from '@/lib/email/shell'; interface DigestData { portName: string; @@ -55,7 +55,7 @@ export function notificationDigestEmail( .map((item) => { const label = TYPE_LABELS[item.type] ?? item.type.replace(/_/g, ' '); const titleHtml = item.link - ? `${escapeHtml(item.title)}` + ? `${escapeHtml(item.title)}` : `${escapeHtml(item.title)}`; const desc = item.description ? `

${escapeHtml(item.description)}
` @@ -71,7 +71,7 @@ export function notificationDigestEmail( const tail = data.totalUnread > data.items.length ? `

…and ${data.totalUnread - data.items.length} more. - Open the inbox to see everything.

` + Open the inbox to see everything.

` : ''; const greeting = data.recipientName ? `Hi ${escapeHtml(data.recipientName)},` : 'Hi,'; diff --git a/src/lib/email/templates/portal-auth.ts b/src/lib/email/templates/portal-auth.ts index 25c839d2..6868afda 100644 --- a/src/lib/email/templates/portal-auth.ts +++ b/src/lib/email/templates/portal-auth.ts @@ -1,4 +1,4 @@ -import { brandingPrimaryColor, renderShell, type BrandingShell } from '@/lib/email/shell'; +import { brandingPrimaryColor, renderShell, safeUrl, type BrandingShell } from '@/lib/email/shell'; interface ActivationData { portName: string; @@ -47,13 +47,13 @@ export function activationEmail( The link expires in ${data.ttlHours} hours.

- + Activate account

If the button doesn't work, paste this link into your browser:
- ${data.link} + ${data.link}

Thank you,
@@ -103,7 +103,7 @@ export function resetEmail( The link expires in ${data.ttlMinutes} minutes.

- + Reset password

diff --git a/src/lib/email/templates/residential-inquiry.ts b/src/lib/email/templates/residential-inquiry.ts index 25fa5fab..be62f180 100644 --- a/src/lib/email/templates/residential-inquiry.ts +++ b/src/lib/email/templates/residential-inquiry.ts @@ -1,4 +1,4 @@ -import { brandingPrimaryColor, renderShell, type BrandingShell } from '@/lib/email/shell'; +import { brandingPrimaryColor, renderShell, safeUrl, type BrandingShell } from '@/lib/email/shell'; interface RenderOpts { branding?: BrandingShell | null; @@ -73,7 +73,7 @@ export function residentialSalesAlert(data: ResidentialSalesAlertData, overrides ${data.preferences ? `Preferences${escapeHtml(data.preferences)}` : ''} ${data.notes ? `Notes${escapeHtml(data.notes)}` : ''} - ${data.crmDeepLink ? `

Open in CRM

` : ''} + ${data.crmDeepLink ? `

Open in CRM

` : ''}

- ${escapeHtml(portName)} CRM

`; return { subject, diff --git a/src/lib/env.ts b/src/lib/env.ts index 6101a042..d1789976 100644 --- a/src/lib/env.ts +++ b/src/lib/env.ts @@ -1,102 +1,104 @@ import { z } from 'zod'; -const envSchema = z.object({ - // Database - DATABASE_URL: z.string().url().startsWith('postgresql://'), +const envSchema = z + .object({ + // Database + DATABASE_URL: z.string().url().startsWith('postgresql://'), - // Redis - REDIS_URL: z.string().url().startsWith('redis://'), + // Redis + REDIS_URL: z.string().url().startsWith('redis://'), - // Auth - BETTER_AUTH_SECRET: z.string().min(32), - BETTER_AUTH_URL: z.string().url(), - CSRF_SECRET: z.string().min(32), + // Auth + BETTER_AUTH_SECRET: z.string().min(32), + BETTER_AUTH_URL: z.string().url(), + CSRF_SECRET: z.string().min(32), - // MinIO - MINIO_ENDPOINT: z.string().min(1), - MINIO_PORT: z.coerce.number().int().positive(), - MINIO_ACCESS_KEY: z.string().min(1), - MINIO_SECRET_KEY: z.string().min(1), - MINIO_BUCKET: z.string().min(1), - MINIO_USE_SSL: z.enum(['true', 'false']).transform((v) => v === 'true'), + // MinIO + MINIO_ENDPOINT: z.string().min(1), + MINIO_PORT: z.coerce.number().int().positive(), + MINIO_ACCESS_KEY: z.string().min(1), + MINIO_SECRET_KEY: z.string().min(1), + MINIO_BUCKET: z.string().min(1), + MINIO_USE_SSL: z.enum(['true', 'false']).transform((v) => v === 'true'), - // Documenso - DOCUMENSO_API_URL: z.string().url(), - DOCUMENSO_API_KEY: z.string().min(1), - DOCUMENSO_API_VERSION: z.enum(['v1', 'v2']).default('v1'), - DOCUMENSO_WEBHOOK_SECRET: z.string().min(16), - DOCUMENSO_TEMPLATE_ID_EOI: z.coerce.number().int().positive().default(8), - DOCUMENSO_CLIENT_RECIPIENT_ID: z.coerce.number().int().positive().default(192), - DOCUMENSO_DEVELOPER_RECIPIENT_ID: z.coerce.number().int().positive().default(193), - DOCUMENSO_APPROVAL_RECIPIENT_ID: z.coerce.number().int().positive().default(194), + // Documenso + DOCUMENSO_API_URL: z.string().url(), + DOCUMENSO_API_KEY: z.string().min(1), + DOCUMENSO_API_VERSION: z.enum(['v1', 'v2']).default('v1'), + DOCUMENSO_WEBHOOK_SECRET: z.string().min(16), + DOCUMENSO_TEMPLATE_ID_EOI: z.coerce.number().int().positive().default(8), + DOCUMENSO_CLIENT_RECIPIENT_ID: z.coerce.number().int().positive().default(192), + DOCUMENSO_DEVELOPER_RECIPIENT_ID: z.coerce.number().int().positive().default(193), + DOCUMENSO_APPROVAL_RECIPIENT_ID: z.coerce.number().int().positive().default(194), - // Email - SMTP_HOST: z.string().min(1), - SMTP_PORT: z.coerce.number().int().positive(), - SMTP_USER: z.string().optional(), - SMTP_PASS: z.string().optional(), - SMTP_FROM: z.string().optional(), - // Dev/test safety net: when set, sendEmail redirects every outbound message - // to this address regardless of the requested recipient. Leave empty in prod. - EMAIL_REDIRECT_TO: z.string().email().optional(), + // Email + SMTP_HOST: z.string().min(1), + SMTP_PORT: z.coerce.number().int().positive(), + SMTP_USER: z.string().optional(), + SMTP_PASS: z.string().optional(), + SMTP_FROM: z.string().optional(), + // Dev/test safety net: when set, sendEmail redirects every outbound message + // to this address regardless of the requested recipient. Leave empty in prod. + EMAIL_REDIRECT_TO: z.string().email().optional(), - // Encryption - EMAIL_CREDENTIAL_KEY: z - .string() - .length(64) - .regex(/^[0-9a-f]+$/i, 'Must be a 64-character hex string'), + // Encryption + EMAIL_CREDENTIAL_KEY: z + .string() + .length(64) + .regex(/^[0-9a-f]+$/i, 'Must be a 64-character hex string'), - // Google OAuth (optional) - GOOGLE_CLIENT_ID: z.string().optional(), - GOOGLE_CLIENT_SECRET: z.string().optional(), + // Google OAuth (optional) + GOOGLE_CLIENT_ID: z.string().optional(), + GOOGLE_CLIENT_SECRET: z.string().optional(), - // Shared secret used by the marketing website's server-side dual-write - // helper (POST to /api/public/website-inquiries). Set the SAME value on - // the website's CRM_INTAKE_SECRET env. Leave unset in dev/staging until - // the website's CRM_INTAKE_URL is also set — without this, the public - // intake endpoint refuses every request. - WEBSITE_INTAKE_SECRET: z.string().min(16).optional(), + // Shared secret used by the marketing website's server-side dual-write + // helper (POST to /api/public/website-inquiries). Set the SAME value on + // the website's CRM_INTAKE_SECRET env. Leave unset in dev/staging until + // the website's CRM_INTAKE_URL is also set — without this, the public + // intake endpoint refuses every request. + WEBSITE_INTAKE_SECRET: z.string().min(16).optional(), - // OpenAI (optional) - OPENAI_API_KEY: z.string().optional(), + // OpenAI (optional) + OPENAI_API_KEY: z.string().optional(), - // App - APP_URL: z.string().url(), - PUBLIC_SITE_URL: z.string().url(), - NODE_ENV: z.enum(['development', 'production', 'test']).default('development'), - LOG_LEVEL: z.enum(['fatal', 'error', 'warn', 'info', 'debug', 'trace']).default('info'), - /** - * HTTP listener port. zod-coerced from PORT so a typo (`PORT=foo`) hard- - * fails at boot rather than silently listening on an ephemeral port. - */ - PORT: z.coerce.number().int().positive().default(3000), - /** - * When true, the filesystem storage backend refuses to start (per - * src/lib/storage/filesystem.ts:192). Reading via the zod schema means - * a typo on the env var hard-fails at boot rather than silently - * disabling the multi-node guard. Per CLAUDE.md, multi-node deploys - * MUST use the s3-compatible backend. - */ - MULTI_NODE_DEPLOYMENT: z - .enum(['true', 'false']) - .default('false') - .transform((v) => v === 'true'), -}).superRefine((env, ctx) => { - // CRITICAL safety net: EMAIL_REDIRECT_TO is a dev/test feature that - // silently rewrites every outbound recipient. Leaving it set in prod - // funnels every customer email (invites, EOIs, portal magic links, - // contracts) to a single inbox. The audit caught this had only a - // `logger.debug` line as forensic trail. Refuse boot when both are - // simultaneously set in production. - if (env.NODE_ENV === 'production' && env.EMAIL_REDIRECT_TO) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - path: ['EMAIL_REDIRECT_TO'], - message: - 'EMAIL_REDIRECT_TO must NOT be set in production — it silently rewrites every outbound email recipient. Unset it before deploying.', - }); - } -}); + // App + APP_URL: z.string().url(), + PUBLIC_SITE_URL: z.string().url(), + NODE_ENV: z.enum(['development', 'production', 'test']).default('development'), + LOG_LEVEL: z.enum(['fatal', 'error', 'warn', 'info', 'debug', 'trace']).default('info'), + /** + * HTTP listener port. zod-coerced from PORT so a typo (`PORT=foo`) hard- + * fails at boot rather than silently listening on an ephemeral port. + */ + PORT: z.coerce.number().int().positive().default(3000), + /** + * When true, the filesystem storage backend refuses to start (per + * src/lib/storage/filesystem.ts:192). Reading via the zod schema means + * a typo on the env var hard-fails at boot rather than silently + * disabling the multi-node guard. Per CLAUDE.md, multi-node deploys + * MUST use the s3-compatible backend. + */ + MULTI_NODE_DEPLOYMENT: z + .enum(['true', 'false']) + .default('false') + .transform((v) => v === 'true'), + }) + .superRefine((env, ctx) => { + // CRITICAL safety net: EMAIL_REDIRECT_TO is a dev/test feature that + // silently rewrites every outbound recipient. Leaving it set in prod + // funnels every customer email (invites, EOIs, portal magic links, + // contracts) to a single inbox. The audit caught this had only a + // `logger.debug` line as forensic trail. Refuse boot when both are + // simultaneously set in production. + if (env.NODE_ENV === 'production' && env.EMAIL_REDIRECT_TO) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ['EMAIL_REDIRECT_TO'], + message: + 'EMAIL_REDIRECT_TO must NOT be set in production — it silently rewrites every outbound email recipient. Unset it before deploying.', + }); + } + }); export type Env = z.infer; diff --git a/src/lib/storage/s3.ts b/src/lib/storage/s3.ts index d95a8166..89e4434e 100644 --- a/src/lib/storage/s3.ts +++ b/src/lib/storage/s3.ts @@ -190,6 +190,13 @@ export class S3Backend implements StorageBackend { await withTimeout( this.client.putObject(this.bucket, key, buffer, buffer.length, { 'Content-Type': opts.contentType, + // Force server-side encryption for every blob — signed contracts, + // GDPR exports, pg_dumps, EOI PDFs all otherwise land at rest in + // cleartext unless the bucket has default-encryption configured. + // The audit's S3-pathing CRITICAL was that this was missing. + // SSE-S3 (AES-256) is the right baseline; SSE-KMS can be a future + // upgrade for tenants that need their own keys. + 'x-amz-server-side-encryption': 'AES256', }), STORAGE_DEFAULT_TIMEOUT_MS, `putObject(${key})`,