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:
@@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
import { withAuth, withPermission } from '@/lib/api/helpers';
|
||||
import { ALL_RANGES, type DateRange, type PresetDateRange } from '@/lib/analytics/range';
|
||||
import { CodedError, errorResponse, ValidationError } from '@/lib/errors';
|
||||
import {
|
||||
getActiveVisitors,
|
||||
getMetric,
|
||||
@@ -68,46 +69,48 @@ function parseRange(req: NextRequest): DateRange | { error: string } {
|
||||
|
||||
export const GET = withAuth(
|
||||
withPermission('reports', 'view_analytics', async (req: NextRequest, ctx) => {
|
||||
const url = new URL(req.url);
|
||||
const metric = url.searchParams.get('metric');
|
||||
if (!metric) {
|
||||
return NextResponse.json({ error: 'Missing metric' }, { status: 400 });
|
||||
}
|
||||
|
||||
const rangeOrError = parseRange(req);
|
||||
if (typeof rangeOrError === 'object' && 'error' in rangeOrError) {
|
||||
return NextResponse.json({ error: rangeOrError.error }, { status: 400 });
|
||||
}
|
||||
const range = rangeOrError as DateRange;
|
||||
|
||||
try {
|
||||
let data: unknown;
|
||||
const url = new URL(req.url);
|
||||
const metric = url.searchParams.get('metric');
|
||||
if (!metric) throw new ValidationError('Missing metric');
|
||||
|
||||
if (metric === 'stats') {
|
||||
data = await getStats(ctx.portId, range);
|
||||
} else if (metric === 'pageviews') {
|
||||
data = await getPageviewsSeries(ctx.portId, range);
|
||||
} else if (metric === 'active') {
|
||||
data = await getActiveVisitors(ctx.portId);
|
||||
} else if (TOP_METRIC_RX.test(metric)) {
|
||||
const type = metric.replace(/^top-/, '') as UmamiMetricType;
|
||||
const limit = Number(url.searchParams.get('limit') ?? 10);
|
||||
data = await getMetric(ctx.portId, range, type, limit);
|
||||
} else {
|
||||
return NextResponse.json({ error: `Unknown metric: ${metric}` }, { status: 400 });
|
||||
const rangeOrError = parseRange(req);
|
||||
if (typeof rangeOrError === 'object' && 'error' in rangeOrError) {
|
||||
throw new ValidationError(rangeOrError.error);
|
||||
}
|
||||
const range = rangeOrError as DateRange;
|
||||
|
||||
let data: unknown;
|
||||
try {
|
||||
if (metric === 'stats') {
|
||||
data = await getStats(ctx.portId, range);
|
||||
} else if (metric === 'pageviews') {
|
||||
data = await getPageviewsSeries(ctx.portId, range);
|
||||
} else if (metric === 'active') {
|
||||
data = await getActiveVisitors(ctx.portId);
|
||||
} else if (TOP_METRIC_RX.test(metric)) {
|
||||
const type = metric.replace(/^top-/, '') as UmamiMetricType;
|
||||
const limit = Number(url.searchParams.get('limit') ?? 10);
|
||||
data = await getMetric(ctx.portId, range, type, limit);
|
||||
} else {
|
||||
throw new ValidationError(`Unknown metric: ${metric}`);
|
||||
}
|
||||
} catch (err) {
|
||||
if (err instanceof ValidationError) throw err;
|
||||
// Upstream Umami failure - re-throw as a typed code so the user gets
|
||||
// a friendly message and the request id is captured to error_events.
|
||||
const internalMessage = err instanceof Error ? err.message : 'Unknown error';
|
||||
throw new CodedError('UMAMI_UPSTREAM_ERROR', { internalMessage });
|
||||
}
|
||||
|
||||
// `data === null` from the service means Umami isn't configured for
|
||||
// this port - surface that explicitly so the UI can render a
|
||||
// "configure your credentials" empty state instead of a chart.
|
||||
if (data === null) {
|
||||
return NextResponse.json({ error: 'umami_not_configured', metric, range }, { status: 200 });
|
||||
}
|
||||
if (data === null) throw new CodedError('UMAMI_NOT_CONFIGURED');
|
||||
|
||||
return NextResponse.json({ metric, range, data });
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Unknown error';
|
||||
return NextResponse.json({ error: message, metric, range }, { status: 502 });
|
||||
return errorResponse(err);
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user