audit: Tier 1/3/4/5/7 batch — SSE, gates, dedup, URL escape, FK constraints
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) <noreply@anthropic.com>
This commit is contained in:
176
src/lib/env.ts
176
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<typeof envSchema>;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user