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:
2026-05-12 17:09:14 +02:00
parent 0baca41693
commit 16ef609e1b
16 changed files with 266 additions and 149 deletions

View File

@@ -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;

View File

@@ -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, '&amp;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/`/g, '&#96;');
}

View File

@@ -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
? `<p style="text-align:center; margin:30px 0;">
<a href="${data.loginUrl}" style="display:inline-block; background-color:${accent}; color:#ffffff; text-decoration:none; padding:14px 35px; border-radius:5px; font-weight:bold; font-size:16px;">
<a href="${safeUrl(data.loginUrl)}" style="display:inline-block; background-color:${accent}; color:#ffffff; text-decoration:none; padding:14px 35px; border-radius:5px; font-weight:bold; font-size:16px;">
Sign in
</a>
</p>`

View File

@@ -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.
</p>
<p style="text-align:center; margin:30px 0;">
<a href="${data.link}" style="display:inline-block; background-color:${accent}; color:#ffffff; text-decoration:none; padding:14px 35px; border-radius:5px; font-weight:bold; font-size:16px;">
<a href="${safeUrl(data.link)}" style="display:inline-block; background-color:${accent}; color:#ffffff; text-decoration:none; padding:14px 35px; border-radius:5px; font-weight:bold; font-size:16px;">
Set up your account
</a>
</p>
<p style="font-size:14px; color:#666; line-height:1.5; padding:15px 0; border-top:1px solid #eee; margin-top:20px;">
If the button doesn't work, paste this link into your browser:<br />
<a href="${data.link}" style="color:${accent}; text-decoration:underline; word-break:break-all;">${data.link}</a>
<a href="${safeUrl(data.link)}" style="color:${accent}; text-decoration:underline; word-break:break-all;">${data.link}</a>
</p>
<p style="font-size:16px; margin-top:30px;">
Thank you,<br />

View File

@@ -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(
<p style="margin-bottom:18px; font-size:16px; line-height:1.6;">${leadCopy}</p>
${customMessageBlock}
<p style="text-align:center; margin:30px 0;">
<a href="${data.signingUrl}" style="display:inline-block; background-color:${accent}; color:#ffffff; text-decoration:none; padding:14px 36px; border-radius:5px; font-weight:bold; font-size:16px;">
<a href="${safeUrl(data.signingUrl)}" style="display:inline-block; background-color:${accent}; color:#ffffff; text-decoration:none; padding:14px 36px; border-radius:5px; font-weight:bold; font-size:16px;">
Review &amp; sign
</a>
</p>
<p style="font-size:13px; color:#666; line-height:1.5; padding:14px 0; border-top:1px solid #eee; margin-top:24px;">
If the button doesn't work, paste this link into your browser:<br />
<a href="${data.signingUrl}" style="color:${accent}; text-decoration:underline; word-break:break-all;">${data.signingUrl}</a>
<a href="${safeUrl(data.signingUrl)}" style="color:${accent}; text-decoration:underline; word-break:break-all;">${data.signingUrl}</a>
</p>
<p style="font-size:14px; color:#666; line-height:1.5; margin-top:18px;">
Signing happens directly inside our website — your data isn't sent to a third-party signing service.
@@ -208,12 +208,12 @@ export function signingReminderEmail(
</p>
${customMessageBlock}
<p style="text-align:center; margin:30px 0;">
<a href="${data.signingUrl}" style="display:inline-block; background-color:${accent}; color:#ffffff; text-decoration:none; padding:14px 36px; border-radius:5px; font-weight:bold; font-size:16px;">
<a href="${safeUrl(data.signingUrl)}" style="display:inline-block; background-color:${accent}; color:#ffffff; text-decoration:none; padding:14px 36px; border-radius:5px; font-weight:bold; font-size:16px;">
Sign now
</a>
</p>
<p style="font-size:13px; color:#666; line-height:1.5; padding:14px 0; border-top:1px solid #eee; margin-top:24px;">
Direct link: <a href="${data.signingUrl}" style="color:${accent}; text-decoration:underline; word-break:break-all;">${data.signingUrl}</a>
Direct link: <a href="${safeUrl(data.signingUrl)}" style="color:${accent}; text-decoration:underline; word-break:break-all;">${data.signingUrl}</a>
</p>
<p style="font-size:16px; margin-top:30px;">
Thank you,<br />

View File

@@ -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
? `<a href="${item.link}" style="color:${accent}; text-decoration:none;"><strong>${escapeHtml(item.title)}</strong></a>`
? `<a href="${safeUrl(item.link)}" style="color:${accent}; text-decoration:none;"><strong>${escapeHtml(item.title)}</strong></a>`
: `<strong>${escapeHtml(item.title)}</strong>`;
const desc = item.description
? `<div style="font-size:13px; color:#666; margin-top:4px;">${escapeHtml(item.description)}</div>`
@@ -71,7 +71,7 @@ export function notificationDigestEmail(
const tail =
data.totalUnread > data.items.length
? `<p style="margin-top:14px; font-size:13px; color:#666;">…and ${data.totalUnread - data.items.length} more.
<a href="${data.inboxLink}" style="color:${accent};">Open the inbox</a> to see everything.</p>`
<a href="${safeUrl(data.inboxLink)}" style="color:${accent};">Open the inbox</a> to see everything.</p>`
: '';
const greeting = data.recipientName ? `Hi ${escapeHtml(data.recipientName)},` : 'Hi,';

View File

@@ -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.
</p>
<p style="text-align:center; margin:30px 0;">
<a href="${data.link}" style="display:inline-block; background-color:${accent}; color:#ffffff; text-decoration:none; padding:14px 35px; border-radius:5px; font-weight:bold; font-size:16px;">
<a href="${safeUrl(data.link)}" style="display:inline-block; background-color:${accent}; color:#ffffff; text-decoration:none; padding:14px 35px; border-radius:5px; font-weight:bold; font-size:16px;">
Activate account
</a>
</p>
<p style="font-size:14px; color:#666; line-height:1.5; padding:15px 0; border-top:1px solid #eee; margin-top:20px;">
If the button doesn't work, paste this link into your browser:<br />
<a href="${data.link}" style="color:${accent}; text-decoration:underline; word-break:break-all;">${data.link}</a>
<a href="${safeUrl(data.link)}" style="color:${accent}; text-decoration:underline; word-break:break-all;">${data.link}</a>
</p>
<p style="font-size:16px; margin-top:30px;">
Thank you,<br />
@@ -103,7 +103,7 @@ export function resetEmail(
The link expires in ${data.ttlMinutes} minutes.
</p>
<p style="text-align:center; margin:30px 0;">
<a href="${data.link}" style="display:inline-block; background-color:${accent}; color:#ffffff; text-decoration:none; padding:14px 35px; border-radius:5px; font-weight:bold; font-size:16px;">
<a href="${safeUrl(data.link)}" style="display:inline-block; background-color:${accent}; color:#ffffff; text-decoration:none; padding:14px 35px; border-radius:5px; font-weight:bold; font-size:16px;">
Reset password
</a>
</p>

View File

@@ -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 ? `<tr><td style="color:#666;">Preferences</td><td>${escapeHtml(data.preferences)}</td></tr>` : ''}
${data.notes ? `<tr><td style="color:#666;">Notes</td><td>${escapeHtml(data.notes)}</td></tr>` : ''}
</table>
${data.crmDeepLink ? `<p style="text-align:center; margin:24px 0;"><a href="${data.crmDeepLink}" style="display:inline-block; background-color:${accent}; color:#ffffff; text-decoration:none; padding:12px 28px; border-radius:5px; font-weight:bold;">Open in CRM</a></p>` : ''}
${data.crmDeepLink ? `<p style="text-align:center; margin:24px 0;"><a href="${safeUrl(data.crmDeepLink)}" style="display:inline-block; background-color:${accent}; color:#ffffff; text-decoration:none; padding:12px 28px; border-radius:5px; font-weight:bold;">Open in CRM</a></p>` : ''}
<p style="font-size:14px; color:#666;">- ${escapeHtml(portName)} CRM</p>`;
return {
subject,

View File

@@ -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>;

View File

@@ -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})`,