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

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

View File

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

View File

@@ -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<NextResponse> {
}
} 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 });