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:
@@ -1,6 +1,6 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
import { withAuth } from '@/lib/api/helpers';
|
||||
import { requireSuperAdmin, withAuth } from '@/lib/api/helpers';
|
||||
import { errorResponse } from '@/lib/errors';
|
||||
import { runAlertEngineForPorts } from '@/lib/services/alert-engine';
|
||||
|
||||
@@ -14,9 +14,7 @@ import { runAlertEngineForPorts } from '@/lib/services/alert-engine';
|
||||
*/
|
||||
export const POST = withAuth(async (_req, ctx) => {
|
||||
try {
|
||||
if (!ctx.isSuperAdmin) {
|
||||
return NextResponse.json({ error: 'Super admin only' }, { status: 403 });
|
||||
}
|
||||
requireSuperAdmin(ctx, 'admin.alerts.run-engine');
|
||||
const summary = await runAlertEngineForPorts([ctx.portId]);
|
||||
return NextResponse.json({ data: summary });
|
||||
} catch (error) {
|
||||
|
||||
@@ -1,14 +1,12 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
import { withAuth } from '@/lib/api/helpers';
|
||||
import { requireSuperAdmin, withAuth } from '@/lib/api/helpers';
|
||||
import { errorResponse } from '@/lib/errors';
|
||||
import { getActiveConnections } from '@/lib/services/system-monitoring.service';
|
||||
|
||||
export const GET = withAuth(async (_req, ctx) => {
|
||||
try {
|
||||
if (!ctx.isSuperAdmin) {
|
||||
return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 });
|
||||
}
|
||||
requireSuperAdmin(ctx, 'admin.connections.read');
|
||||
|
||||
const connections = await getActiveConnections();
|
||||
return NextResponse.json({ data: connections });
|
||||
|
||||
@@ -4,7 +4,7 @@ import { and, eq, inArray } from 'drizzle-orm';
|
||||
import type { AuthContext } from '@/lib/api/helpers';
|
||||
import { db } from '@/lib/db';
|
||||
import { clients, clientMergeCandidates } from '@/lib/db/schema/clients';
|
||||
import { errorResponse, NotFoundError } from '@/lib/errors';
|
||||
import { errorResponse, NotFoundError, ValidationError } from '@/lib/errors';
|
||||
import {
|
||||
listPendingMergeCandidates,
|
||||
mergeClients,
|
||||
@@ -81,7 +81,7 @@ export async function confirmMergeHandler(
|
||||
fieldChoices?: MergeFieldChoices;
|
||||
};
|
||||
if (!body.winnerId) {
|
||||
return NextResponse.json({ error: 'winnerId required' }, { status: 400 });
|
||||
throw new ValidationError('winnerId is required');
|
||||
}
|
||||
|
||||
const [candidate] = await db
|
||||
@@ -103,10 +103,7 @@ export async function confirmMergeHandler(
|
||||
? candidate.clientAId
|
||||
: null;
|
||||
if (!loserId) {
|
||||
return NextResponse.json(
|
||||
{ error: 'winnerId must match one of the candidate clients' },
|
||||
{ status: 400 },
|
||||
);
|
||||
throw new ValidationError('winnerId must match one of the candidate clients');
|
||||
}
|
||||
|
||||
const result = await mergeClients({
|
||||
|
||||
@@ -1,14 +1,12 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
import { withAuth } from '@/lib/api/helpers';
|
||||
import { requireSuperAdmin, withAuth } from '@/lib/api/helpers';
|
||||
import { errorResponse } from '@/lib/errors';
|
||||
import { getRecentErrors } from '@/lib/services/system-monitoring.service';
|
||||
|
||||
export const GET = withAuth(async (req: NextRequest, ctx) => {
|
||||
try {
|
||||
if (!ctx.isSuperAdmin) {
|
||||
return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 });
|
||||
}
|
||||
requireSuperAdmin(ctx, 'admin.errors.list');
|
||||
|
||||
const url = new URL(req.url);
|
||||
const limit = Math.min(100, Math.max(1, parseInt(url.searchParams.get('limit') ?? '20', 10)));
|
||||
|
||||
@@ -1,14 +1,12 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
import { withAuth } from '@/lib/api/helpers';
|
||||
import { requireSuperAdmin, withAuth } from '@/lib/api/helpers';
|
||||
import { errorResponse } from '@/lib/errors';
|
||||
import { healthCheck } from '@/lib/services/system-monitoring.service';
|
||||
|
||||
export const GET = withAuth(async (_req, ctx) => {
|
||||
try {
|
||||
if (!ctx.isSuperAdmin) {
|
||||
return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 });
|
||||
}
|
||||
requireSuperAdmin(ctx, 'admin.health.read');
|
||||
|
||||
const status = await healthCheck();
|
||||
return NextResponse.json({ data: status });
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
import { withAuth, withPermission } from '@/lib/api/helpers';
|
||||
import { errorResponse, ForbiddenError } from '@/lib/errors';
|
||||
import { requireSuperAdmin, withAuth, withPermission } from '@/lib/api/helpers';
|
||||
import { errorResponse } from '@/lib/errors';
|
||||
import { resendCrmInvite } from '@/lib/services/crm-invite.service';
|
||||
|
||||
// Resend mints a fresh token + new email on a global invite row;
|
||||
@@ -10,9 +10,7 @@ import { resendCrmInvite } from '@/lib/services/crm-invite.service';
|
||||
export const POST = withAuth(
|
||||
withPermission('admin', 'manage_users', async (_req, ctx, params) => {
|
||||
try {
|
||||
if (!ctx.isSuperAdmin) {
|
||||
throw new ForbiddenError('Resending CRM invites requires super-admin');
|
||||
}
|
||||
requireSuperAdmin(ctx, 'admin.invitations.resend');
|
||||
const id = params.id ?? '';
|
||||
const result = await resendCrmInvite(id, {
|
||||
userId: ctx.userId,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
import { withAuth, withPermission } from '@/lib/api/helpers';
|
||||
import { errorResponse, ForbiddenError } from '@/lib/errors';
|
||||
import { requireSuperAdmin, withAuth, withPermission } from '@/lib/api/helpers';
|
||||
import { errorResponse } from '@/lib/errors';
|
||||
import { revokeCrmInvite } from '@/lib/services/crm-invite.service';
|
||||
|
||||
// Invites are a global resource (no portId column). Revoking a foreign
|
||||
@@ -10,9 +10,7 @@ import { revokeCrmInvite } from '@/lib/services/crm-invite.service';
|
||||
export const DELETE = withAuth(
|
||||
withPermission('admin', 'manage_users', async (_req, ctx, params) => {
|
||||
try {
|
||||
if (!ctx.isSuperAdmin) {
|
||||
throw new ForbiddenError('Revoking CRM invites requires super-admin');
|
||||
}
|
||||
requireSuperAdmin(ctx, 'admin.invitations.revoke');
|
||||
const id = params.id ?? '';
|
||||
await revokeCrmInvite(id, {
|
||||
userId: ctx.userId,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { withAuth, withPermission } from '@/lib/api/helpers';
|
||||
import { requireSuperAdmin, withAuth, withPermission } from '@/lib/api/helpers';
|
||||
import { parseBody } from '@/lib/api/route-helpers';
|
||||
import { errorResponse, ForbiddenError } from '@/lib/errors';
|
||||
import { createCrmInvite, listCrmInvites } from '@/lib/services/crm-invite.service';
|
||||
@@ -14,9 +14,7 @@ export const GET = withAuth(
|
||||
// port. Listing it cross-tenant would let a port-A director
|
||||
// enumerate pending invitee emails, names, and isSuperAdmin flags
|
||||
// for every other tenant. Restrict the listing to super-admins.
|
||||
if (!ctx.isSuperAdmin) {
|
||||
throw new ForbiddenError('Listing CRM invites requires super-admin');
|
||||
}
|
||||
requireSuperAdmin(ctx, 'admin.invitations.list');
|
||||
const data = await listCrmInvites();
|
||||
return NextResponse.json({ data });
|
||||
} catch (error) {
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { withAuth, withPermission } from '@/lib/api/helpers';
|
||||
import { requireSuperAdmin, withAuth, withPermission } from '@/lib/api/helpers';
|
||||
import { parseBody } from '@/lib/api/route-helpers';
|
||||
import { errorResponse } from '@/lib/errors';
|
||||
import { errorResponse, ValidationError } from '@/lib/errors';
|
||||
import { getPublicOcrConfig, saveOcrConfig, OCR_MODELS } from '@/lib/services/ocr-config.service';
|
||||
|
||||
const saveSchema = z.object({
|
||||
@@ -27,8 +27,8 @@ export const GET = withAuth(
|
||||
try {
|
||||
const url = new URL(req.url);
|
||||
const scope = url.searchParams.get('scope') ?? 'port';
|
||||
if (scope === 'global' && !ctx.isSuperAdmin) {
|
||||
return NextResponse.json({ error: 'Super admin only' }, { status: 403 });
|
||||
if (scope === 'global') {
|
||||
requireSuperAdmin(ctx, 'admin.ocr-settings.read.global');
|
||||
}
|
||||
const config = await getPublicOcrConfig(scope === 'global' ? null : ctx.portId);
|
||||
return NextResponse.json({ data: config, models: OCR_MODELS });
|
||||
@@ -42,15 +42,12 @@ export const PUT = withAuth(
|
||||
withPermission('admin', 'manage_settings', async (req, ctx) => {
|
||||
try {
|
||||
const body = await parseBody(req, saveSchema);
|
||||
if (body.scope === 'global' && !ctx.isSuperAdmin) {
|
||||
return NextResponse.json({ error: 'Super admin only' }, { status: 403 });
|
||||
if (body.scope === 'global') {
|
||||
requireSuperAdmin(ctx, 'admin.ocr-settings.write.global');
|
||||
}
|
||||
const validModels = OCR_MODELS[body.provider];
|
||||
if (!validModels.includes(body.model)) {
|
||||
return NextResponse.json(
|
||||
{ error: `Invalid model for provider ${body.provider}` },
|
||||
{ status: 400 },
|
||||
);
|
||||
throw new ValidationError(`Invalid model for provider ${body.provider}`);
|
||||
}
|
||||
await saveOcrConfig(
|
||||
body.scope === 'global' ? null : ctx.portId,
|
||||
|
||||
@@ -3,7 +3,7 @@ import { z } from 'zod';
|
||||
|
||||
import { withAuth, withPermission } from '@/lib/api/helpers';
|
||||
import { parseBody } from '@/lib/api/route-helpers';
|
||||
import { errorResponse } from '@/lib/errors';
|
||||
import { errorResponse, ValidationError } from '@/lib/errors';
|
||||
import { OCR_MODELS } from '@/lib/services/ocr-config.service';
|
||||
import { testProvider } from '@/lib/services/ocr-providers';
|
||||
|
||||
@@ -20,7 +20,7 @@ export const POST = withAuth(
|
||||
try {
|
||||
const body = await parseBody(req, schema);
|
||||
if (!OCR_MODELS[body.provider].includes(body.model)) {
|
||||
return NextResponse.json({ error: 'Invalid model' }, { status: 400 });
|
||||
throw new ValidationError('Invalid model');
|
||||
}
|
||||
const result = await testProvider(body.provider, body.apiKey, body.model);
|
||||
return NextResponse.json(result);
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
import { withAuth, withPermission } from '@/lib/api/helpers';
|
||||
import { requireSuperAdmin, withAuth, withPermission } from '@/lib/api/helpers';
|
||||
import { parseBody } from '@/lib/api/route-helpers';
|
||||
import { listPorts, createPort } from '@/lib/services/ports.service';
|
||||
import { createPortSchema } from '@/lib/validators/ports';
|
||||
import { errorResponse, ForbiddenError } from '@/lib/errors';
|
||||
import { errorResponse } from '@/lib/errors';
|
||||
|
||||
// Listing every tenant and creating new tenants are super-admin operations:
|
||||
// a port director must not be able to enumerate other ports (target
|
||||
@@ -13,9 +13,7 @@ import { errorResponse, ForbiddenError } from '@/lib/errors';
|
||||
export const GET = withAuth(
|
||||
withPermission('admin', 'manage_settings', async (_req, ctx) => {
|
||||
try {
|
||||
if (!ctx.isSuperAdmin) {
|
||||
throw new ForbiddenError('Listing all ports requires super-admin');
|
||||
}
|
||||
requireSuperAdmin(ctx, 'admin.ports.list');
|
||||
const data = await listPorts();
|
||||
return NextResponse.json({ data });
|
||||
} catch (error) {
|
||||
@@ -27,9 +25,7 @@ export const GET = withAuth(
|
||||
export const POST = withAuth(
|
||||
withPermission('admin', 'manage_settings', async (req, ctx) => {
|
||||
try {
|
||||
if (!ctx.isSuperAdmin) {
|
||||
throw new ForbiddenError('Creating ports requires super-admin');
|
||||
}
|
||||
requireSuperAdmin(ctx, 'admin.ports.create');
|
||||
const body = await parseBody(req, createPortSchema);
|
||||
const data = await createPort(body, {
|
||||
userId: ctx.userId,
|
||||
|
||||
@@ -1,24 +1,22 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
import { withAuth } from '@/lib/api/helpers';
|
||||
import { errorResponse } from '@/lib/errors';
|
||||
import { requireSuperAdmin, withAuth } from '@/lib/api/helpers';
|
||||
import { errorResponse, ValidationError } from '@/lib/errors';
|
||||
import { retryJob } from '@/lib/services/system-monitoring.service';
|
||||
import { QUEUE_CONFIGS, type QueueName } from '@/lib/queue';
|
||||
|
||||
export const POST = withAuth(async (_req, ctx, params) => {
|
||||
try {
|
||||
if (!ctx.isSuperAdmin) {
|
||||
return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 });
|
||||
}
|
||||
requireSuperAdmin(ctx, 'admin.queues.job.retry');
|
||||
|
||||
const queueName = params['queueName'];
|
||||
const jobId = params['jobId'];
|
||||
if (!queueName || !jobId) {
|
||||
return NextResponse.json({ error: 'Missing parameters' }, { status: 400 });
|
||||
throw new ValidationError('queueName and jobId are required');
|
||||
}
|
||||
const validQueues = Object.keys(QUEUE_CONFIGS) as QueueName[];
|
||||
if (!validQueues.includes(queueName as QueueName)) {
|
||||
return NextResponse.json({ error: 'Invalid queue name' }, { status: 400 });
|
||||
throw new ValidationError('Invalid queue name');
|
||||
}
|
||||
|
||||
await retryJob(queueName as QueueName, jobId, ctx.userId);
|
||||
|
||||
@@ -1,24 +1,22 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
import { withAuth } from '@/lib/api/helpers';
|
||||
import { errorResponse } from '@/lib/errors';
|
||||
import { requireSuperAdmin, withAuth } from '@/lib/api/helpers';
|
||||
import { errorResponse, ValidationError } from '@/lib/errors';
|
||||
import { deleteJob } from '@/lib/services/system-monitoring.service';
|
||||
import { QUEUE_CONFIGS, type QueueName } from '@/lib/queue';
|
||||
|
||||
export const DELETE = withAuth(async (_req, ctx, params) => {
|
||||
try {
|
||||
if (!ctx.isSuperAdmin) {
|
||||
return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 });
|
||||
}
|
||||
requireSuperAdmin(ctx, 'admin.queues.job.delete');
|
||||
|
||||
const queueName = params['queueName'];
|
||||
const jobId = params['jobId'];
|
||||
if (!queueName || !jobId) {
|
||||
return NextResponse.json({ error: 'Missing parameters' }, { status: 400 });
|
||||
throw new ValidationError('queueName and jobId are required');
|
||||
}
|
||||
const validQueues = Object.keys(QUEUE_CONFIGS) as QueueName[];
|
||||
if (!validQueues.includes(queueName as QueueName)) {
|
||||
return NextResponse.json({ error: 'Invalid queue name' }, { status: 400 });
|
||||
throw new ValidationError('Invalid queue name');
|
||||
}
|
||||
|
||||
await deleteJob(queueName as QueueName, jobId, ctx.userId);
|
||||
|
||||
@@ -1,20 +1,18 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
import { withAuth } from '@/lib/api/helpers';
|
||||
import { errorResponse } from '@/lib/errors';
|
||||
import { requireSuperAdmin, withAuth } from '@/lib/api/helpers';
|
||||
import { errorResponse, ValidationError } from '@/lib/errors';
|
||||
import { getQueueJobs } from '@/lib/services/system-monitoring.service';
|
||||
import { QUEUE_CONFIGS, type QueueName } from '@/lib/queue';
|
||||
|
||||
export const GET = withAuth(async (req: NextRequest, ctx, params) => {
|
||||
try {
|
||||
if (!ctx.isSuperAdmin) {
|
||||
return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 });
|
||||
}
|
||||
requireSuperAdmin(ctx, 'admin.queues.jobs.list');
|
||||
|
||||
const { queueName } = params;
|
||||
const validQueues = Object.keys(QUEUE_CONFIGS) as QueueName[];
|
||||
if (!validQueues.includes(queueName as QueueName)) {
|
||||
return NextResponse.json({ error: 'Invalid queue name' }, { status: 400 });
|
||||
throw new ValidationError('Invalid queue name');
|
||||
}
|
||||
|
||||
const url = new URL(req.url);
|
||||
|
||||
@@ -1,14 +1,12 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
import { withAuth } from '@/lib/api/helpers';
|
||||
import { requireSuperAdmin, withAuth } from '@/lib/api/helpers';
|
||||
import { errorResponse } from '@/lib/errors';
|
||||
import { getQueueDashboard } from '@/lib/services/system-monitoring.service';
|
||||
|
||||
export const GET = withAuth(async (_req, ctx) => {
|
||||
try {
|
||||
if (!ctx.isSuperAdmin) {
|
||||
return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 });
|
||||
}
|
||||
requireSuperAdmin(ctx, 'admin.queues.list');
|
||||
|
||||
const queues = await getQueueDashboard();
|
||||
return NextResponse.json({ data: queues });
|
||||
|
||||
@@ -10,9 +10,9 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { withAuth } from '@/lib/api/helpers';
|
||||
import { requireSuperAdmin, withAuth } from '@/lib/api/helpers';
|
||||
import { parseBody } from '@/lib/api/route-helpers';
|
||||
import { errorResponse, ForbiddenError } from '@/lib/errors';
|
||||
import { errorResponse, ValidationError } from '@/lib/errors';
|
||||
import { runMigration } from '@/lib/storage/migrate';
|
||||
|
||||
const schema = z.object({
|
||||
@@ -25,12 +25,10 @@ export const runtime = 'nodejs';
|
||||
|
||||
export const POST = withAuth(async (req, ctx) => {
|
||||
try {
|
||||
if (!ctx.isSuperAdmin) {
|
||||
throw new ForbiddenError('Super admin only');
|
||||
}
|
||||
requireSuperAdmin(ctx, 'admin.storage.migrate');
|
||||
const body = await parseBody(req, schema);
|
||||
if (body.from === body.to) {
|
||||
return NextResponse.json({ error: 'from and to must differ' }, { status: 400 });
|
||||
throw new ValidationError('from and to must differ');
|
||||
}
|
||||
const result = await runMigration({ ...body, userId: ctx.userId });
|
||||
return NextResponse.json({ data: result });
|
||||
|
||||
@@ -7,8 +7,8 @@
|
||||
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
import { withAuth } from '@/lib/api/helpers';
|
||||
import { errorResponse, ForbiddenError } from '@/lib/errors';
|
||||
import { requireSuperAdmin, withAuth } from '@/lib/api/helpers';
|
||||
import { errorResponse } from '@/lib/errors';
|
||||
import { TABLES_WITH_STORAGE_KEYS } from '@/lib/storage/migrate';
|
||||
import { getStorageBackend } from '@/lib/storage';
|
||||
import { S3Backend } from '@/lib/storage/s3';
|
||||
@@ -19,9 +19,7 @@ export const runtime = 'nodejs';
|
||||
|
||||
export const GET = withAuth(async (_req, ctx) => {
|
||||
try {
|
||||
if (!ctx.isSuperAdmin) {
|
||||
throw new ForbiddenError('Super admin only');
|
||||
}
|
||||
requireSuperAdmin(ctx, 'admin.storage.read');
|
||||
const backend = await getStorageBackend();
|
||||
|
||||
// Aggregate row count + total bytes across every storage-bearing table.
|
||||
@@ -54,9 +52,7 @@ export const GET = withAuth(async (_req, ctx) => {
|
||||
|
||||
export const POST = withAuth(async (_req, ctx) => {
|
||||
try {
|
||||
if (!ctx.isSuperAdmin) {
|
||||
throw new ForbiddenError('Super admin only');
|
||||
}
|
||||
requireSuperAdmin(ctx, 'admin.storage.test');
|
||||
const backend = await getStorageBackend();
|
||||
if (!(backend instanceof S3Backend)) {
|
||||
return NextResponse.json(
|
||||
|
||||
Reference in New Issue
Block a user