/** * The field whitelist for client-manager writes. * * This module is the entire reason /api/client/* exists. PocketBase API rules * are record-level: there is no rule that says "this user may update priority * but not column". So a client manager is denied all writes at the database, * and their requests pass through here instead, where only known-good fields * survive. * * The whitelist is positive, never a blocklist. A new card field added in a * later phase is inaccessible to clients by default, which is the failure * direction we want. */ import type { Priority } from "@/lib/pb/types"; export const CLIENT_EDITABLE_CARD_FIELDS = ["priority", "target_date"] as const; const PRIORITIES: readonly Priority[] = ["critical", "high", "medium", "low"]; /** PocketBase stores dates as strings; "" means unset. */ const ISO_DATE = /^\d{4}-\d{2}-\d{2}$/; export interface ClientCardPatch { priority?: Priority; target_date?: string; } export interface ClientCardCreate { title: string; description: string; } function asRecord(body: unknown): Record | null { if (typeof body !== "object" || body === null || Array.isArray(body)) { return null; } return body as Record; } /** * Split an untrusted PATCH body into the fields we will apply and the names * of everything we refused. Dropped names are returned so the caller can log * them — a client repeatedly sending `column` is worth noticing. */ export function pickClientCardPatch(body: unknown): { patch: ClientCardPatch; dropped: string[]; } { const record = asRecord(body); if (!record) return { patch: {}, dropped: [] }; const patch: ClientCardPatch = {}; const dropped: string[] = []; for (const [key, value] of Object.entries(record)) { if (key === "priority") { if (typeof value === "string" && PRIORITIES.includes(value as Priority)) { patch.priority = value as Priority; } else { dropped.push(key); } continue; } if (key === "target_date") { if (value === null || value === "") { patch.target_date = ""; } else if (typeof value === "string" && ISO_DATE.test(value)) { patch.target_date = value; } else { dropped.push(key); } continue; } dropped.push(key); } return { patch, dropped }; } /** * Split an untrusted create body the same way. * * Note what is absent: no column, no priority, no accepted. The route handler * sets those itself — Inbox, medium, false — so a client cannot create a card * straight into In Progress at critical priority. * * `input` is null when the body has no usable title. */ export function pickClientCardCreate(body: unknown): { input: ClientCardCreate | null; dropped: string[]; } { const record = asRecord(body); if (!record) return { input: null, dropped: [] }; const dropped: string[] = []; let title = ""; let description = ""; for (const [key, value] of Object.entries(record)) { if (key === "title") { if (typeof value === "string") title = value.trim().slice(0, 200); else dropped.push(key); continue; } if (key === "description") { if (typeof value === "string") description = value.slice(0, 50000); else dropped.push(key); continue; } // `project` is read from the body by the route handler before calling // this, so it is expected here and is not a violation. if (key === "project") continue; dropped.push(key); } if (!title) return { input: null, dropped }; return { input: { title, description }, dropped }; }