kanban-dev-webapp/src/lib/cards/client-patch.ts

125 lines
No EOL
3.6 KiB
TypeScript

/**
* 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<string, unknown> | null {
if (typeof body !== "object" || body === null || Array.isArray(body)) {
return null;
}
return body as Record<string, unknown>;
}
/**
* 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 };
}