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:
@@ -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 2–30 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 2–30 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,
|
||||
|
||||
Reference in New Issue
Block a user