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 { errorResponse, NotFoundError, ValidationError } from '@/lib/errors'; 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(), phone: z.string().nullable().optional(), // Refuse `javascript:` / `data:` schemes — z.string().url() lets them // through and `` 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, }, }); 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, }, }, }); }); 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 = { 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.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) ?? {}; 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, phone: updated!.phone, avatarUrl: updated!.avatarUrl, preferences: updated!.preferences, }, }); } catch (error) { return errorResponse(error); } });