UNPKG

@blundergoat/goat-flow

Version:

AI coding agent harness and local dashboard for Claude Code, OpenAI Codex, Google Antigravity, and GitHub Copilot - setup audits, guardrails, structured skills, deny hooks, and persistent learning loops.

405 lines 15.6 kB
const MAX_PROJECT_TITLE_LENGTH = 120; // Storage limit: dense dashboard rows cannot absorb long custom aliases. /** Build a decoder error result. */ function err(path, message) { return { ok: false, error: message, path }; } /** Parse JSON; reports malformed bodies as typed path errors instead of throwing. */ function parseJson(body, path) { try { return { ok: true, value: JSON.parse(body) }; } catch (e) { const message = e instanceof Error ? e.message : String(e); return err(path, `invalid JSON: ${message}`); } } /** Treat arrays as invalid objects because every decoded payload expects named fields. */ function isRecord(candidate) { return (typeof candidate === "object" && candidate !== null && !Array.isArray(candidate)); } /** Decode dashboard string lists; omitted optional lists become empty for older state files. */ function decodeStringArrayField(raw, key, options) { if (!Object.hasOwn(raw, key)) { return options?.required ? err(`body.${key}`, "is required") : { ok: true, value: [], }; } if (!Array.isArray(raw[key])) { return err(`body.${key}`, "must be an array"); } const values = []; for (const [index, item] of raw[key].entries()) { if (typeof item !== "string") { return err(`body.${key}[${index}]`, "must be a string"); } values.push(item); } return { ok: true, value: values }; } /** Decode terminal-create strings; missing values become empty to launch against defaults. */ function decodeOptionalStringField(raw, key) { if (!Object.hasOwn(raw, key)) { return { ok: true, value: "" }; } return typeof raw[key] === "string" ? { ok: true, value: raw[key] } : err(`body.${key}`, "must be a string"); } /** * Decode POST /api/terminal/create without defaulting invalid runner names. * * @param body Raw request body. * @param options Runner allow-list plus the fallback used only when `runner` is absent. * @returns Typed terminal-create payload or a path-specific decoder error. */ export function decodeTerminalCreateBody(body, options) { const parsed = parseJson(body, "body"); if (!parsed.ok) return parsed; const raw = parsed.value; if (!isRecord(raw)) return err("body", "must be a JSON object"); // Empty prompt is valid: the terminal route opens an idle shell in that case. const prompt = decodeOptionalStringField(raw, "prompt"); if (!prompt.ok) return prompt; const projectPath = decodeOptionalStringField(raw, "projectPath"); if (!projectPath.ok) return projectPath; // targetPath: optional string. When present, the runner cwd can differ from // the selected project being analysed. const targetPath = decodeOptionalStringField(raw, "targetPath"); if (!targetPath.ok) return targetPath; // Invalid runner names stay errors; the default only applies when the field is absent. let runner = options.defaultRunner; if (Object.hasOwn(raw, "runner")) { if (typeof raw.runner !== "string") { return err("body.runner", "must be a string"); } if (!options.validRunners.has(raw.runner)) { return err("body.runner", `unknown runner: ${raw.runner}. Valid: ${Array.from(options.validRunners).join(", ")}`); } runner = raw.runner; } return { ok: true, value: { prompt: prompt.value, projectPath: projectPath.value, targetPath: targetPath.value, runner, }, }; } /** * Decode POST /api/projects/list while preserving dashboard state-file fallbacks. * * @param body Raw request body. * @returns Typed project-list payload or a path-specific decoder error. */ export function decodeProjectsListBody(body) { const parsed = parseJson(body, "body"); if (!parsed.ok) return parsed; const raw = parsed.value; if (!isRecord(raw)) return err("body", "must be a JSON object"); const paths = decodeStringArrayField(raw, "paths", { required: true }); if (!paths.ok) return paths; const favorites = decodeStringArrayField(raw, "favorites"); if (!favorites.ok) return favorites; const projectTitles = decodeProjectTitles(raw); if (!projectTitles.ok) return projectTitles; return { ok: true, value: { paths: paths.value, favorites: favorites.value, projectTitles: projectTitles.value, }, }; } /** * Decode a body carrying the target project path for a dashboard write action. * * @param body Raw request body. * @returns Typed project-path payload or a path-specific decoder error. */ export function decodeProjectPathBody(body) { const parsed = parseJson(body, "body"); if (!parsed.ok) return parsed; const raw = parsed.value; if (!isRecord(raw)) return err("body", "must be a JSON object"); if (!Object.hasOwn(raw, "path")) return err("body.path", "is required"); return typeof raw.path === "string" ? { ok: true, value: { path: raw.path } } : err("body.path", "must be a string"); } /** * Decode POST /api/hooks/:hookId/toggle. * * @param body - raw JSON request body from the dashboard route * @returns decoded hook toggle payload or a field-specific validation error */ export function decodeHookToggleBody(body) { const parsed = parseJson(body, "body"); if (!parsed.ok) return parsed; const raw = parsed.value; if (!isRecord(raw)) return err("body", "must be a JSON object"); if (typeof raw.enabled !== "boolean") { return err("body.enabled", "must be a boolean"); } return { ok: true, value: { enabled: raw.enabled } }; } /** * Decode POST /api/terminal/:id/upload-image before content safety checks. * * The handler enforces MIME and byte limits after this structural pass; this * decoder only proves the request has named base64 entries and a valid count. * * @param body Raw request body. * @param options Request-level upload limits from the route. * @returns Typed upload payload or a path-specific decoder error. */ // eslint-disable-next-line complexity -- intentional: flat boundary checks preserve one precise error path per rejected upload field. export function decodeTerminalUploadBody(body, options) { const parsed = parseJson(body, "body"); if (!parsed.ok) return parsed; const raw = parsed.value; if (!isRecord(raw)) return err("body", "must be a JSON object"); if (!Array.isArray(raw.files)) { return err("body.files", "must be an array"); } if (raw.files.length === 0) { return err("body.files", "must contain at least one file"); } if (raw.files.length > options.maxFiles) { return err("body.files", `must contain at most ${options.maxFiles} file(s) per request`); } const files = []; for (const [index, item] of raw.files.entries()) { if (!isRecord(item)) { return err(`body.files[${index}]`, "must be an object"); } if (typeof item.name !== "string" || item.name.length === 0) { return err(`body.files[${index}].name`, "must be a non-empty string"); } if (typeof item.data !== "string" || item.data.length === 0) { return err(`body.files[${index}].data`, "must be a non-empty base64 string"); } files.push({ name: item.name, data: item.data }); } return { ok: true, value: { files } }; } /** Decode the optional `projectTitles` map: project path → custom display name. * Empty / whitespace-only titles are dropped so clearing a title round-trips * to the path-derived fallback without leaving a zombie entry in the file. */ function decodeProjectTitles(raw) { if (!Object.hasOwn(raw, "projectTitles")) { return { ok: true, value: {} }; } const projectTitles = raw.projectTitles; if (!isRecord(projectTitles)) { return err("body.projectTitles", "must be an object"); } const result = {}; for (const [key, entry] of Object.entries(projectTitles)) { if (typeof entry !== "string") { return err(`body.projectTitles[${JSON.stringify(key)}]`, "must be a string"); } const trimmed = entry.trim().slice(0, MAX_PROJECT_TITLE_LENGTH); if (trimmed.length === 0) continue; result[key] = trimmed; } return { ok: true, value: result }; } /** * Decode a terminal WebSocket frame with one branch per supported message type. * * The socket handler sends these errors back to the client, so the explicit * input and resize branches intentionally preserve the exact rejected field. * * @param raw Raw WebSocket frame text. * @returns Typed client message or a path-specific decoder error. */ export function decodeClientMessage(raw) { const parsed = parseJson(raw, "message"); if (!parsed.ok) return parsed; const obj = parsed.value; if (!isRecord(obj)) return err("message", "must be a JSON object"); if (obj.type === "input") { if (typeof obj.data !== "string") { return err("message.data", "must be a string on input messages"); } return { ok: true, value: { type: "input", data: obj.data } }; } if (obj.type === "resize") { if (typeof obj.cols !== "number" || !Number.isFinite(obj.cols)) { return err("message.cols", "must be a finite number on resize messages"); } if (typeof obj.rows !== "number" || !Number.isFinite(obj.rows)) { return err("message.rows", "must be a finite number on resize messages"); } return { ok: true, value: { type: "resize", cols: obj.cols, rows: obj.rows }, }; } return err("message.type", `must be "input" or "resize" (got ${JSON.stringify(obj.type)})`); } export const MAX_EVALUATE_CONTENT_BYTES = 256 * 1024; const MAX_EVALUATE_NAME_BYTES = 200; const MAX_EVALUATE_FILES = 32; const MAX_EVALUATE_FILENAME_BYTES = 256; /** Measure limits in bytes because HTTP caps count UTF-8 bytes, not JS characters. */ function utf8ByteLength(text) { return Buffer.byteLength(text, "utf8"); } /** * Decode the optional evaluate fields shared by both accepted payload shapes. * * This stays separate because single-content and multi-file requests must * report identical path errors for `suggestedName` and `kind`. */ function decodeEvaluateOptionals(obj) { let suggestedName; if (obj.suggestedName !== undefined) { if (typeof obj.suggestedName !== "string") { return err("body.suggestedName", "must be a string"); } if (utf8ByteLength(obj.suggestedName) > MAX_EVALUATE_NAME_BYTES) { return err("body.suggestedName", `must be at most ${MAX_EVALUATE_NAME_BYTES} bytes`); } suggestedName = obj.suggestedName; } let kind; if (obj.kind !== undefined) { if (obj.kind !== "skill" && obj.kind !== "shared-reference") { return err("body.kind", 'must be "skill" or "shared-reference"'); } kind = obj.kind; } return { ok: true, value: { suggestedName, kind } }; } /** * Decode the `files` array on a multi-file evaluate body. * * The bundle path uses the same aggregate byte cap as pasted content, preventing * many small files from bypassing the route-level request budget. */ // eslint-disable-next-line complexity -- intentional: per-file boundary checks preserve exact error paths for rejected bundle entries. function decodeEvaluateFiles(raw) { if (!Array.isArray(raw)) return err("body.files", "must be an array"); if (raw.length === 0) return err("body.files", "must contain at least one file"); if (raw.length > MAX_EVALUATE_FILES) { return err("body.files", `must contain at most ${MAX_EVALUATE_FILES} files`); } const files = []; let totalBytes = 0; const seenNames = new Set(); for (const [index, item] of raw.entries()) { if (!isRecord(item)) { return err(`body.files[${index}]`, "must be an object"); } if (typeof item.name !== "string" || item.name.length === 0) { return err(`body.files[${index}].name`, "must be a non-empty string"); } if (utf8ByteLength(item.name) > MAX_EVALUATE_FILENAME_BYTES) { return err(`body.files[${index}].name`, `must be at most ${MAX_EVALUATE_FILENAME_BYTES} bytes`); } if (item.name.includes("/") || item.name.includes("\\") || item.name.includes("\0")) { return err(`body.files[${index}].name`, "must be a bare filename (no path separators or NUL bytes)"); } if (seenNames.has(item.name)) { return err(`body.files[${index}].name`, `duplicate filename: ${JSON.stringify(item.name)}`); } seenNames.add(item.name); if (typeof item.content !== "string") { return err(`body.files[${index}].content`, "must be a string"); } totalBytes += utf8ByteLength(item.content); if (totalBytes > MAX_EVALUATE_CONTENT_BYTES) { return err("body.files", `combined content size exceeds ${MAX_EVALUATE_CONTENT_BYTES} bytes`); } files.push({ name: item.name, content: item.content }); } return { ok: true, value: files }; } /** * Decode and validate a `POST /api/quality/evaluate` request body. * * This stays explicit because the route accepts the current multi-file uploader * and the older single-text form; ambiguous bodies are rejected before quality * scoring. The deprecated `/api/quality/analyse` alias reuses the same shape. * * @param body Raw request body. * @returns Typed evaluate payload or a path-specific decoder error. */ export function decodeEvaluateBody(body) { const parsed = parseJson(body, "body"); if (!parsed.ok) return parsed; if (!isRecord(parsed.value)) { return err("body", "must be a JSON object"); } const obj = parsed.value; const hasContent = obj.content !== undefined; const hasFiles = obj.files !== undefined; if (hasContent === hasFiles) { return err("body", 'exactly one of "content" or "files" must be set'); } const optionals = decodeEvaluateOptionals(obj); if (!optionals.ok) return optionals; if (hasContent) { if (typeof obj.content !== "string" || obj.content.trim().length === 0) { return err("body.content", "must be a non-empty markdown string"); } if (utf8ByteLength(obj.content) > MAX_EVALUATE_CONTENT_BYTES) { return err("body.content", `must be at most ${MAX_EVALUATE_CONTENT_BYTES} bytes`); } return { ok: true, value: { content: obj.content, suggestedName: optionals.value.suggestedName, kind: optionals.value.kind, }, }; } const filesResult = decodeEvaluateFiles(obj.files); if (!filesResult.ok) return filesResult; return { ok: true, value: { files: filesResult.value, suggestedName: optionals.value.suggestedName, kind: optionals.value.kind, }, }; } //# sourceMappingURL=decoders.js.map