125 lines
No EOL
3.6 KiB
TypeScript
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 };
|
|
} |