Files
pn-new-crm/src/app/api/v1/me/route.ts
Matt 4b9743a594 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>
2026-05-12 16:52:35 +02:00

192 lines
7.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { NextResponse } from 'next/server';
import { eq } from 'drizzle-orm';
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 { 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
// vector if any future renderer treated the value as a link.
avatarUrl: z
.string()
.url()
.refine((u) => /^https?:\/\//i.test(u), 'must be an http(s) URL')
.nullable()
.optional(),
// Strict allow-list — no `.passthrough()` here. The previous schema let
// arbitrary client-supplied keys survive validation and persist into
// `userProfiles.preferences` JSONB unbounded; auditor-E3 §28 caught this.
// Add new keys here as the UI surfaces them rather than letting the
// client mint them at will.
preferences: z
.object({
dark_mode: z.boolean().optional(),
locale: z.string().optional(),
timezone: z.string().optional(),
// Per-table column visibility. Keyed by entity type — entries
// with an empty `hiddenColumns` mean "all visible". The validator
// caps total entries / IDs so a malicious client can't bloat the
// 8 KB preferences blob; see merge step below for the byte cap.
tablePreferences: z
.record(
z.string().min(1).max(64),
z
.object({
hiddenColumns: z.array(z.string().min(1).max(64)).max(50).optional(),
})
.strict(),
)
.optional(),
})
.strict()
.optional(),
});
export const GET = withAuth(async (_req, ctx: AuthContext) => {
// Hydrate preferences from user_profiles so the client can read its
// saved table-column visibility (and other prefs) without a second
// round-trip on app boot.
const profile = await db.query.userProfiles.findFirst({
where: eq(userProfiles.userId, ctx.userId),
columns: {
preferences: true,
avatarFileId: true,
avatarUrl: true,
firstName: true,
lastName: true,
displayName: true,
username: true,
},
});
return NextResponse.json({
data: {
userId: ctx.userId,
portId: ctx.portId,
portSlug: ctx.portSlug,
permissions: ctx.permissions,
isSuperAdmin: ctx.isSuperAdmin,
user: ctx.user,
preferences: profile?.preferences ?? {},
profile: {
avatarFileId: profile?.avatarFileId ?? null,
avatarUrl: profile?.avatarUrl ?? null,
firstName: profile?.firstName ?? null,
lastName: profile?.lastName ?? null,
displayName: profile?.displayName ?? null,
username: profile?.username ?? null,
},
},
});
});
export const PATCH = withAuth(async (req, ctx: AuthContext) => {
try {
const body = await parseBody(req, updateProfileSchema);
const profile = await db.query.userProfiles.findFirst({
where: eq(userProfiles.userId, ctx.userId),
});
if (!profile) throw new NotFoundError('profile');
const updates: Record<string, unknown> = { updatedAt: new Date() };
if (body.firstName !== undefined) updates.firstName = body.firstName;
if (body.lastName !== undefined) updates.lastName = body.lastName;
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
// .passthrough(); the merge prunes them so legacy bloat doesn't
// accumulate forever, and a future schema regression that tries
// to ship arbitrary keys still gets dropped here at write time.
const ALLOWED_PREF_KEYS = new Set(['dark_mode', 'locale', 'timezone', 'tablePreferences']);
const existing = (profile.preferences as Record<string, unknown>) ?? {};
const merged = Object.fromEntries(
Object.entries({ ...existing, ...body.preferences }).filter(([k]) =>
ALLOWED_PREF_KEYS.has(k),
),
);
// Hard cap on the merged JSONB — defense in depth against any
// future schema growth that might re-introduce free-form keys.
const serialized = JSON.stringify(merged);
if (Buffer.byteLength(serialized, 'utf8') > 8 * 1024) {
throw new ValidationError('preferences exceeds 8KB');
}
updates.preferences = merged;
}
const [updated] = await db
.update(userProfiles)
.set(updates)
.where(eq(userProfiles.userId, ctx.userId))
.returning();
return NextResponse.json({
data: {
userId: updated!.userId,
firstName: updated!.firstName,
lastName: updated!.lastName,
displayName: updated!.displayName,
username: updated!.username,
phone: updated!.phone,
avatarUrl: updated!.avatarUrl,
preferences: updated!.preferences,
},
});
} catch (error) {
return errorResponse(error);
}
});