// outcome contract for audit Task input persistence. // // Doctrine: every audit Task records enough operator-meaningful information to // explain what happened, AND no audit Task contains secret material. Mechanism // is centralised here, not at the per-kind writer: form/action input // definitions tag secret fields at the source of truth, and writers strip // those fields via `redactSecrets` before persisting `inputs.` props // onto the Task node. // // Single file by design — no per-kind branching, no factory. A future // schema shape change extends `FormSchema` here; the contract stays one place. export type JsonValue = | string | number | boolean | null | JsonValue[] | { [key: string]: JsonValue }; export interface FormSchema { /** * Field names whose values are secret and must never land on a Task node. * Declared at the form definition (e.g. `cloudflare-setup-form`'s schema in * `cloudflare-setup-types.ts`), not at the writer. Writers pass the form * schema through unmodified — the contract is "secrets are declared once * where the form is defined, redaction is enforced once at write time." */ secretFields?: string[]; } export interface RedactResult { /** * Payload with every `secretFields` entry removed. Non-secret keys keep * their values intact (including null, false, 0, empty string). Keys not * mentioned in `secretFields` always pass through — secrets are an * allow-list of names to drop, not the entire universe. */ redacted: Record; /** Number of fields that were stripped. Callers log this when > 0. */ droppedFields: number; /** Names of fields that were stripped, sorted for stable log lines. */ droppedFieldNames: string[]; } /** * Strip every secret-tagged field from `payload` per `schema`. Pure: no IO, * no logging, no thrown errors. Caller is responsible for the * `[task] redacted ...` log line when `droppedFields > 0`. * * Edge-case behaviour: * - `payload` undefined / null → empty result, droppedFields=0. * - `schema` undefined / no `secretFields` → every key passes through. * - A secret field whose key is absent from `payload` → no-op (not counted). * - Non-string values on a secret field → key still dropped, value never * inspected. Redaction is by KEY, not by value-shape heuristic. */ export function redactSecrets( payload: Record | null | undefined, schema: FormSchema | null | undefined, ): RedactResult { if (!payload) { return { redacted: {}, droppedFields: 0, droppedFieldNames: [] }; } const secrets = new Set(schema?.secretFields ?? []); const redacted: Record = {}; const dropped: string[] = []; for (const [key, value] of Object.entries(payload)) { if (secrets.has(key)) { dropped.push(key); continue; } redacted[key] = value; } dropped.sort(); return { redacted, droppedFields: dropped.length, droppedFieldNames: dropped }; }