@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
JavaScript
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