fix(audit-tier-2-routes): manual NextResponse.json error sweep + admin form banners

Two final waves of error-surface hygiene closing the audit's MED §12 +
HIGH §15 + HIGH §17 findings:

* 50 route files swept (61 sites): manual NextResponse.json({error,
  status: 4xx|5xx}) early-returns replaced by typed throws +
  errorResponse(err) at the catch.
  - Super-admin gates (13 sites) use new requireSuperAdmin(ctx, action)
    helper from src/lib/api/helpers.ts so denials hit the audit log.
  - Path-param + body validation 400s become ValidationError throws.
  - 404s become NotFoundError or CodedError('NOT_FOUND') for AI
    feature-flag paths.
  - 11 manual 5xx returns now re-throw so error_events captures the
    request-id (the admin error inspector becomes usable from real
    incidents).
  - website-analytics 200-with-error anti-pattern flipped to 409 +
    UMAMI_NOT_CONFIGURED. 502 upstream paths use UMAMI_UPSTREAM_ERROR.
  - 11 sites intentionally preserved: storage/[token] anti-enumeration
    token-failure paths, webhook-secret 401, "Unknown port" 400 in
    public intake.

* 7 admin forms (roles, users, ports, webhooks, custom-fields,
  document-templates, tags) gain a formatErrorBanner() helper from
  src/lib/api/toast-error.ts that builds a multi-line "Error code / Reference ID"
  banner — the rep can copy the request id when reporting a failed
  save.  Banners get whitespace-pre-line so newlines render.

Test status: 1168/1168 vitest, tsc clean.

Refs: docs/audit-comprehensive-2026-05-05.md MED §12 (auditor-F Issue 1)
+ HIGH §15 (auditor-F Issue 2) + HIGH §17 (auditor-H Issue 2).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Matt Ciaccio
2026-05-05 20:36:59 +02:00
parent fc7595faf8
commit d3a6a9beef
58 changed files with 529 additions and 558 deletions

View File

@@ -5,6 +5,7 @@ import { db } from '@/lib/db';
import { ports } from '@/lib/db/schema/ports';
import { berths, berthMapData } from '@/lib/db/schema/berths';
import { interestBerths, interests } from '@/lib/db/schema/interests';
import { errorResponse } from '@/lib/errors';
import { logger } from '@/lib/logger';
import { toPublicBerth } from '@/lib/services/public-berths';
@@ -32,77 +33,81 @@ export async function GET(
request: Request,
ctx: { params: Promise<{ mooringNumber: string }> },
): Promise<Response> {
const { mooringNumber } = await ctx.params;
const url = new URL(request.url);
const requestedSlug = url.searchParams.get('portSlug') ?? DEFAULT_PUBLIC_PORT_SLUG;
if (!PUBLIC_PORT_SLUGS.has(requestedSlug)) {
return NextResponse.json(
{ error: 'port is not part of the public berths feed', portSlug: requestedSlug },
{ status: 404, headers: { 'cache-control': 'no-store' } },
);
try {
const { mooringNumber } = await ctx.params;
const url = new URL(request.url);
const requestedSlug = url.searchParams.get('portSlug') ?? DEFAULT_PUBLIC_PORT_SLUG;
if (!PUBLIC_PORT_SLUGS.has(requestedSlug)) {
return NextResponse.json(
{ error: 'port is not part of the public berths feed', portSlug: requestedSlug },
{ status: 404, headers: { 'cache-control': 'no-store' } },
);
}
const portSlug = requestedSlug;
// Reject obviously malformed mooring numbers up front so cache poisoning
// / random-URL probing returns 400 rather than 404 (saves a DB hit).
if (!MOORING_PATTERN.test(mooringNumber)) {
return NextResponse.json(
{ error: 'invalid mooring number', mooringNumber },
{ status: 400, headers: { 'cache-control': 'no-store' } },
);
}
const [port] = await db
.select({ id: ports.id })
.from(ports)
.where(eq(ports.slug, portSlug))
.limit(1);
if (!port) {
return NextResponse.json(
{ error: 'port not found', portSlug },
{ status: 404, headers: { 'cache-control': 'no-store' } },
);
}
const [berth] = await db
.select()
.from(berths)
.where(and(eq(berths.portId, port.id), eq(berths.mooringNumber, mooringNumber)))
.limit(1);
if (!berth) {
return NextResponse.json(
{ error: 'berth not found', mooringNumber },
{ status: 404, headers: { 'cache-control': 'no-store' } },
);
}
const [mapData, specificInterestRows] = await Promise.all([
db.select().from(berthMapData).where(eq(berthMapData.berthId, berth.id)).limit(1),
db
.select({ berthId: interestBerths.berthId })
.from(interestBerths)
.innerJoin(interests, eq(interests.id, interestBerths.interestId))
.where(
and(
eq(interestBerths.berthId, berth.id),
eq(interestBerths.isSpecificInterest, true),
isNull(interests.archivedAt),
// Closed deals (won/lost/cancelled) don't promote to "Under
// Offer" - won flows through berths.status='sold' handled in
// derivePublicStatus; lost/cancelled means back on the market.
isNull(interests.outcome),
),
)
.limit(1),
]);
const out = toPublicBerth(berth, mapData[0] ?? null, specificInterestRows.length > 0);
if (out.Status !== 'Available' && out.Status !== 'Under Offer' && out.Status !== 'Sold') {
logger.error({ berthId: berth.id, status: out.Status }, 'Public berth status out of range');
throw new Error('Public berth status out of range');
}
return new Response(JSON.stringify(out), { headers: RESPONSE_HEADERS, status: 200 });
} catch (err) {
return errorResponse(err);
}
const portSlug = requestedSlug;
// Reject obviously malformed mooring numbers up front so cache poisoning
// / random-URL probing returns 400 rather than 404 (saves a DB hit).
if (!MOORING_PATTERN.test(mooringNumber)) {
return NextResponse.json(
{ error: 'invalid mooring number', mooringNumber },
{ status: 400, headers: { 'cache-control': 'no-store' } },
);
}
const [port] = await db
.select({ id: ports.id })
.from(ports)
.where(eq(ports.slug, portSlug))
.limit(1);
if (!port) {
return NextResponse.json(
{ error: 'port not found', portSlug },
{ status: 404, headers: { 'cache-control': 'no-store' } },
);
}
const [berth] = await db
.select()
.from(berths)
.where(and(eq(berths.portId, port.id), eq(berths.mooringNumber, mooringNumber)))
.limit(1);
if (!berth) {
return NextResponse.json(
{ error: 'berth not found', mooringNumber },
{ status: 404, headers: { 'cache-control': 'no-store' } },
);
}
const [mapData, specificInterestRows] = await Promise.all([
db.select().from(berthMapData).where(eq(berthMapData.berthId, berth.id)).limit(1),
db
.select({ berthId: interestBerths.berthId })
.from(interestBerths)
.innerJoin(interests, eq(interests.id, interestBerths.interestId))
.where(
and(
eq(interestBerths.berthId, berth.id),
eq(interestBerths.isSpecificInterest, true),
isNull(interests.archivedAt),
// Closed deals (won/lost/cancelled) don't promote to "Under
// Offer" - won flows through berths.status='sold' handled in
// derivePublicStatus; lost/cancelled means back on the market.
isNull(interests.outcome),
),
)
.limit(1),
]);
const out = toPublicBerth(berth, mapData[0] ?? null, specificInterestRows.length > 0);
if (out.Status !== 'Available' && out.Status !== 'Under Offer' && out.Status !== 'Sold') {
logger.error({ berthId: berth.id, status: out.Status }, 'Public berth status out of range');
return NextResponse.json({ error: 'internal' }, { status: 500 });
}
return new Response(JSON.stringify(out), { headers: RESPONSE_HEADERS, status: 200 });
}