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>
50 lines
1.9 KiB
TypeScript
50 lines
1.9 KiB
TypeScript
import { NextResponse } from 'next/server';
|
|
import { z } from 'zod';
|
|
|
|
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';
|
|
|
|
export const GET = withAuth(
|
|
withPermission('admin', 'manage_users', async (_req, ctx) => {
|
|
try {
|
|
// crm_user_invites is a global table (no per-port column) - invites
|
|
// mint better-auth users that may later be assigned roles in any
|
|
// 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.
|
|
requireSuperAdmin(ctx, 'admin.invitations.list');
|
|
const data = await listCrmInvites();
|
|
return NextResponse.json({ data });
|
|
} catch (error) {
|
|
return errorResponse(error);
|
|
}
|
|
}),
|
|
);
|
|
|
|
const createInviteSchema = z.object({
|
|
email: z.string().email(),
|
|
name: z.string().min(1).max(200).optional(),
|
|
isSuperAdmin: z.boolean().optional().default(false),
|
|
});
|
|
|
|
export const POST = withAuth(
|
|
withPermission('admin', 'manage_users', async (req, ctx) => {
|
|
try {
|
|
const body = await parseBody(req, createInviteSchema);
|
|
// Only existing super-admins can mint super-admin invitations. The
|
|
// manage_users permission is granted to port-scoped director roles,
|
|
// which must not be able to elevate themselves cross-tenant by
|
|
// inviting a fresh super_admin.
|
|
if (body.isSuperAdmin && !ctx.isSuperAdmin) {
|
|
throw new ForbiddenError('Only super admins can mint super-admin invitations');
|
|
}
|
|
const result = await createCrmInvite({ ...body, invitedBy: ctx });
|
|
return NextResponse.json({ data: result }, { status: 201 });
|
|
} catch (error) {
|
|
return errorResponse(error);
|
|
}
|
|
}),
|
|
);
|