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
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}
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}- 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
? `
…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.
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.
- ${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