audit: 33-agent comprehensive audit + critical fixes

Full team audit run, all reports verbatim in docs/AUDIT-2026-05-12.md
(5900+ lines, 30+ critical findings). Already-fixed this commit:
- permission-overrides PUT: self-target block + RolePermissions allow-list + cross-tenant guard
- /api/auth/resolve-identifier: rate-limit + synthetic miss-email kill enumeration
- admin email-change: rotates account.accountId + revokes sessions
- middleware: token-gated email confirm/cancel routes whitelisted
- NAV_CATALOG: 10 dead-link sweeps to existing /admin/<x> targets

Feature work landing same commit: optional username sign-in
(migration 0054), per-user permission overrides (0055) with three-state
matrix tabbed inside UserForm, user disable button, role + outcome +
stage label normalisation across the platform, admin email-change
with auto-notification template.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-12 16:52:35 +02:00
parent 660553c074
commit 4b9743a594
31 changed files with 7042 additions and 81 deletions

View File

@@ -5,13 +5,28 @@ import { withAuth, type AuthContext } from '@/lib/api/helpers';
import { parseBody } from '@/lib/api/route-helpers';
import { db } from '@/lib/db';
import { userProfiles } from '@/lib/db/schema';
import { errorResponse, NotFoundError, ValidationError } from '@/lib/errors';
import { ConflictError, errorResponse, NotFoundError, ValidationError } from '@/lib/errors';
import { isReservedUsername, USERNAME_REGEX } from '@/lib/validators/username';
import { sql } from 'drizzle-orm';
import { z } from 'zod';
const updateProfileSchema = z.object({
firstName: z.string().min(1).max(120).nullable().optional(),
lastName: z.string().min(1).max(120).nullable().optional(),
displayName: z.string().min(1).max(200).optional(),
/**
* Optional sign-in alias. `null` clears the existing value; a string
* must match the 230 lowercase shape pinned by USERNAME_REGEX (also
* enforced by `chk_user_profiles_username_shape` in migration 0054).
* Uniqueness is checked below before the UPDATE — collisions surface
* as a 409 with a friendly message.
*/
username: z
.union([
z.string().transform((s) => s.trim().toLowerCase()),
z.null(),
])
.optional(),
phone: z.string().nullable().optional(),
// Refuse `javascript:` / `data:` schemes — z.string().url() lets them
// through and `<a href={avatarUrl}>` would otherwise be a stored-XSS
@@ -64,6 +79,7 @@ export const GET = withAuth(async (_req, ctx: AuthContext) => {
firstName: true,
lastName: true,
displayName: true,
username: true,
},
});
@@ -82,6 +98,7 @@ export const GET = withAuth(async (_req, ctx: AuthContext) => {
firstName: profile?.firstName ?? null,
lastName: profile?.lastName ?? null,
displayName: profile?.displayName ?? null,
username: profile?.username ?? null,
},
},
});
@@ -102,6 +119,32 @@ export const PATCH = withAuth(async (req, ctx: AuthContext) => {
if (body.displayName !== undefined) updates.displayName = body.displayName;
if (body.phone !== undefined) updates.phone = body.phone;
if (body.avatarUrl !== undefined) updates.avatarUrl = body.avatarUrl;
if (body.username !== undefined) {
// null clears; non-null must match the shape + not be reserved +
// be unique (case-insensitive) across all users in the install.
if (body.username === null || body.username === '') {
updates.username = null;
} else {
const candidate = body.username;
if (!USERNAME_REGEX.test(candidate)) {
throw new ValidationError(
'Username must be 230 lowercase letters, digits, dot, underscore, or hyphen.',
);
}
if (isReservedUsername(candidate)) {
throw new ValidationError('That username is reserved. Please pick another.');
}
const taken = await db
.select({ userId: userProfiles.userId })
.from(userProfiles)
.where(sql`LOWER(${userProfiles.username}) = ${candidate}`)
.limit(1);
if (taken.length > 0 && taken[0]!.userId !== ctx.userId) {
throw new ConflictError('That username is already taken.');
}
updates.username = candidate;
}
}
if (body.preferences !== undefined) {
// Allow-list — only retain keys defined in the strict schema. Pre-
// strict rows may carry extra keys from when the schema was
@@ -136,6 +179,7 @@ export const PATCH = withAuth(async (req, ctx: AuthContext) => {
firstName: updated!.firstName,
lastName: updated!.lastName,
displayName: updated!.displayName,
username: updated!.username,
phone: updated!.phone,
avatarUrl: updated!.avatarUrl,
preferences: updated!.preferences,