fix(audit-tier-6): validation, perms, ops/infra, per-port webhook secret

Final audit polish — closes the remaining LOW + MED items the previous
tiers didn't reach:

* Validation hardening: me.preferences uses .strict() + 8KB cap
  instead of unbounded .passthrough(); files.uploadFile gains
  magic-byte verification (jpeg/png/gif/webp/pdf/doc/xlsx); OCR scan
  endpoint enforces 10MB cap + magic-byte check on receipt images;
  port logoUrl + me.avatarUrl reject javascript:/data: schemes via
  a shared httpUrl refinement.
* Permission gates: document-sends/{brochure,berth-pdf} now require
  email.send (was withAuth-only); document-sends/{preview,list} on
  email.view; ai/email-draft on email.send; documents/[id]/send
  uses send_for_signing (was create); expenses/export/parent-company
  flips from hard isSuperAdmin to expenses.export for parity;
  admin/users/options gated on reminders.assign_others (was withAuth).
* Envelope hygiene: auth/set-password switches the third {message}
  variant to errorResponse + {data: {email}}; ai/email-draft wraps
  jobId in {data: {jobId}}.
* UI polish: reports-list.handleDownload surfaces failures via
  toastError (was console-only).
* Ops/infra: pin pnpm@10.33.2 across all three Dockerfiles +
  packageManager field in package.json; Dockerfile.worker re-orders
  user creation BEFORE pnpm install so node_modules / .cache dirs
  are worker-owned (fixes tesseract.js + sharp EACCES at first PDF
  parse); add Redis-ping HEALTHCHECK to the worker container.
* Public health endpoint: returns full env+appUrl payload only when
  the caller presents X-Intake-Secret, otherwise a minimal {status}
  so generic uptime monitors still work but anonymous internet
  doesn't get deployment fingerprints.
* Per-port Documenso webhook secret: new system_settings key
  + listDocumensoWebhookSecrets() helper.  The webhook receiver
  iterates every configured per-port secret with timing-safe
  comparison + falls back to env, then forwards the resolved portId
  into handleDocumentExpired so two ports sharing a documensoId
  cannot cross-mutate.

Deferred (handled in dedicated follow-up PRs):
* Tier 5.1 — direct service tests for portal-auth / users /
  email-accounts / document-sends / sales-email-config.  MED, large
  test-writing scope.
* The {ok: true} → {data: null} envelope migration across
  alerts/expenses/admin-ocr-settings/storage routes.  Mechanical but
  needs coordinated client + test updates.
* CSP-nonce migration (drop unsafe-inline) — needs middleware-level
  nonce generation that the Next 15 router has to thread through.
* Idempotency-Key header on Documenso createDocument.  Requires
  schema column on documents to persist the key; deferred so it
  doesn't bundle a migration into this commit.
* The 16 better-auth user_id FKs — separate dedicated migration
  with care (some columns are NOT NULL today and cascade decisions
  matter).
* PermissionGate / Skeleton / EmptyState wraps across 5 admin lists
  (auditor-H §§36–37) and the residential-clients filter bar.

Test status: 1175/1175 vitest, tsc clean.

Refs: docs/audit-comprehensive-2026-05-05.md MED §§28,29,30 + LOW §§32–43
+ HIGH §9 (Documenso secrets follow-up).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Matt Ciaccio
2026-05-05 21:03:31 +02:00
parent 4bab6de8be
commit 83239104e0
22 changed files with 402 additions and 176 deletions

View File

@@ -1,7 +1,7 @@
import { NextResponse } from 'next/server';
import { and, eq } from 'drizzle-orm';
import { withAuth } from '@/lib/api/helpers';
import { withAuth, withPermission } from '@/lib/api/helpers';
import { db } from '@/lib/db';
import { systemSettings } from '@/lib/db/schema/system';
import { requestEmailDraft } from '@/lib/services/email-draft.service';
@@ -9,29 +9,37 @@ import { parseBody } from '@/lib/api/route-helpers';
import { requestDraftSchema } from '@/lib/validators/ai';
import { CodedError, errorResponse } from '@/lib/errors';
export const POST = withAuth(async (req, ctx) => {
try {
// Feature flag check
const flag = await db.query.systemSettings.findFirst({
where: and(eq(systemSettings.key, 'ai_email_drafts'), eq(systemSettings.portId, ctx.portId)),
});
if (flag?.value !== true) {
throw new CodedError('NOT_FOUND', {
internalMessage: 'AI email-draft feature flag disabled for this port',
// Gated on `email.send` — the draft endpoint spends OpenAI tokens and
// renders client/interest-scoped content; only roles permitted to send
// emails should be able to mint drafts (auditor-A3 §7).
export const POST = withAuth(
withPermission('email', 'send', async (req, ctx) => {
try {
// Feature flag check
const flag = await db.query.systemSettings.findFirst({
where: and(
eq(systemSettings.key, 'ai_email_drafts'),
eq(systemSettings.portId, ctx.portId),
),
});
if (flag?.value !== true) {
throw new CodedError('NOT_FOUND', {
internalMessage: 'AI email-draft feature flag disabled for this port',
});
}
const body = await parseBody(req, requestDraftSchema);
const { jobId } = await requestEmailDraft(ctx.userId, {
interestId: body.interestId,
clientId: body.clientId,
portId: ctx.portId,
context: body.context,
additionalInstructions: body.additionalInstructions,
});
return NextResponse.json({ data: { jobId } }, { status: 202 });
} catch (error) {
return errorResponse(error);
}
const body = await parseBody(req, requestDraftSchema);
const { jobId } = await requestEmailDraft(ctx.userId, {
interestId: body.interestId,
clientId: body.clientId,
portId: ctx.portId,
context: body.context,
additionalInstructions: body.additionalInstructions,
});
return NextResponse.json({ jobId }, { status: 202 });
} catch (error) {
return errorResponse(error);
}
});
}),
);