From 16ef609e1b5f4b378e63eaf5943e1b2be79e3293 Mon Sep 17 00:00:00 2001
From: Matt
Date: Tue, 12 May 2026 17:09:14 +0200
Subject: [PATCH] =?UTF-8?q?audit:=20Tier=201/3/4/5/7=20batch=20=E2=80=94?=
=?UTF-8?q?=20SSE,=20gates,=20dedup,=20URL=20escape,=20FK=20constraints?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Tier 1.6: S3Backend.put now sets ServerSideEncryption=AES256 — closes
the cleartext-at-rest gap for signed contracts, GDPR exports, pg_dumps.
Tier 3.7: New safeUrl() helper in lib/email/shell.ts. Scheme allow-list
(http/https/mailto/tel/relative only — javascript:/data:/vbscript:/file:
rewritten to about:blank) + HTML-attribute escape. Retrofitted across
all 7 transactional templates (crm-invite, portal-auth, document-signing,
notification-digest, residential-inquiry, admin-email-change).
Tier 4.2: /api/v1/alerts GET now gated on admin.view_audit_log.
Tier 4.3: Documenso webhook handler emits captureErrorEvent on catch.
Admin/errors no longer silent on webhook crashes.
Tier 4.6: Inquiry-funnel email dedup is now case-insensitive
(LOWER(value)) and stores normalized email on insert. Capital-letter
resubmissions no longer spawn duplicate client+yacht+interest rows.
Tier 5.6 + data-model H1: migration 0056 adds FK
user_permission_overrides.user_id → user(id) cascade, same for
user_port_roles.userId, plus partial unique index on
user_email_changes pending rows.
Tier 7.6: @types/node bumped from ^25 to ^20.19.0 — matches the runtime.
Co-Authored-By: Claude Opus 4.7 (1M context)
---
docs/AUDIT-PARKED-QUESTIONS.md | 6 +
package.json | 2 +-
pnpm-lock.yaml | 58 +++---
src/app/api/public/interests/route.ts | 17 +-
src/app/api/v1/alerts/route.ts | 11 +-
src/app/api/webhooks/documenso/route.ts | 10 +
.../db/migrations/0056_audit_hardening.sql | 51 +++++
src/lib/email/shell.ts | 39 ++++
src/lib/email/templates/admin-email-change.ts | 4 +-
src/lib/email/templates/crm-invite.ts | 6 +-
src/lib/email/templates/document-signing.ts | 10 +-
.../email/templates/notification-digest.ts | 6 +-
src/lib/email/templates/portal-auth.ts | 8 +-
.../email/templates/residential-inquiry.ts | 4 +-
src/lib/env.ts | 176 +++++++++---------
src/lib/storage/s3.ts | 7 +
16 files changed, 266 insertions(+), 149 deletions(-)
create mode 100644 docs/AUDIT-PARKED-QUESTIONS.md
create mode 100644 src/lib/db/migrations/0056_audit_hardening.sql
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})`,