guard.ts
Language TypeScript4.0 KB97 lines
/*** Write-prevention failsafe (defense in depth).** This server is read-only by contract. But the Plinth API is a single dispatch* endpoint where the `method` query param selects the action, and two tools* (get_product / get_inventory) accept a free-form passthrough param bag. That* combination means a caller (including an LLM that fabricates arguments, tries* to smuggle a write method through the passthrough bag, or guesses a real* write method name by chance) could otherwise attempt a mutation.** These guards make that structurally impossible. They are independent of the* TypeScript `ReadMethod` type on purpose: the type is a compile-time promise,* this is the runtime enforcement that still holds if someone casts to `any`,* edits a handler, or calls the client from plain JS. Every layer fails closed:* a violation stops the request rather than letting it through.** Layer 1 assertReadMethod: the dispatched method must be on the allowlist.* Layer 2 assertSafeParams: no reserved/override keys, no write-intent keys.* Layer 3 buildUrl (http.ts) sets method/api_key last and skips reserved* keys, so params can never override them.* Layer 4 assertUrlIsRead: final check on the built wire URL before fetch.*//** The ONLY methods this server may ever dispatch. Frozen source of truth. */export const READ_METHODS = Object.freeze(["orders.fetch","statuses.list","customers.fetch","orders.splitShipments","products.fetch","inventory.fetch",] as const);export type ReadMethod = (typeof READ_METHODS)[number];const READ_METHOD_SET: ReadonlySet<string> = new Set(READ_METHODS);/** Params the server sets itself; a caller may never provide or override them. */export const RESERVED_PARAM_KEYS: ReadonlySet<string> = new Set(["method", "api_key"]);/*** Param keys that signal a mutation. Read methods never take these, so their* presence is treated as an attempted write and rejected. Kept deliberately* narrow to avoid colliding with legitimate read params (e.g. `email`,* `status_code`, `shop`, `date_field`).*/const WRITE_INTENT_KEY =/^(set|insert|update|delete|destroy|drop|truncate|create|remove|write|save|approve|cancel|notify|adjust)/i;/** Raised whenever any failsafe layer detects an attempt to leave read-only territory. */export class WriteAttemptError extends Error {constructor(message: string) {super(`Write-prevention failsafe tripped: ${message}`);this.name = "WriteAttemptError";}}/** Layer 1: runtime backstop for the compile-time ReadMethod type. */export function assertReadMethod(method: string): asserts method is ReadMethod {if (!READ_METHOD_SET.has(method)) {throw new WriteAttemptError(`method "${method}" is not one of the ${READ_METHODS.length} allowed read methods ` +`(${READ_METHODS.join(", ")}).`,);}}/** Layer 2: reject reserved/override keys and any key signaling write intent. */export function assertSafeParams(params: Record<string, unknown>): void {for (const key of Object.keys(params)) {const k = key.trim().toLowerCase();if (RESERVED_PARAM_KEYS.has(k)) {throw new WriteAttemptError(`param "${key}" is reserved (the server sets it) and cannot be supplied by a caller.`,);}if (WRITE_INTENT_KEY.test(k)) {throw new WriteAttemptError(`param "${key}" signals write intent; only read parameters are permitted.`,);}}}/** Layer 4: final gate on the fully-built URL, immediately before fetch. */export function assertUrlIsRead(url: URL): void {const methods = url.searchParams.getAll("method");if (methods.length !== 1) {throw new WriteAttemptError(`built request URL must carry exactly one "method" param (found ${methods.length}).`,);}const [method] = methods;if (method === undefined || !READ_METHOD_SET.has(method)) {throw new WriteAttemptError(`built request URL method "${method}" is not an allowed read method.`);}}