From 399479600dd0a810d06f41bcfa322fd20ee2179d Mon Sep 17 00:00:00 2001 From: Jenny Ferries Date: Tue, 28 Jul 2026 16:54:25 -0700 Subject: [PATCH] daily-focus-board: guard external stateFile + reserve feed sentinel ids Addresses the Copilot review on board-core.mjs: an externally supplied stateFile pointing at an existing non-board file was silently normalized to an empty board and overwritten on first mutation. ensureStateFile now refuses an existing file unless it parses as JSON and looks like a board (a 'daily-focus-board' marker or the board schema); the marker is stamped on every write. Also reserve 'day'/'brain' as task ids so a task can't collide with the momentum/brain-dump feed delete routing. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: cb356aa8-0af2-48f3-b3c6-8086c69d5308 --- extensions/daily-focus-board/board-core.mjs | 34 ++++++++++++++++----- 1 file changed, 27 insertions(+), 7 deletions(-) diff --git a/extensions/daily-focus-board/board-core.mjs b/extensions/daily-focus-board/board-core.mjs index 6cf2673c..fe513304 100644 --- a/extensions/daily-focus-board/board-core.mjs +++ b/extensions/daily-focus-board/board-core.mjs @@ -40,7 +40,12 @@ export function isCrossSiteRequest(req) { // Task ids double as object keys and HTML data-attributes, so keep them tight. const ID_RE = /^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$/; const TAG_COLORS = ["new", "deadline", "career"]; -export function validId(s) { return typeof s === "string" && ID_RE.test(s); } +const BOARD_MARKER = "daily-focus-board"; +// "day"/"brain" are the momentum-feed and brain-dump sentinels used in the UI's +// delete routing, so a task may not claim them as an id — otherwise deleting a +// task note could splice the shared feed instead. +const RESERVED_IDS = new Set(["day", "brain"]); +export function validId(s) { return typeof s === "string" && ID_RE.test(s) && !RESERVED_IDS.has(s); } function text(s, max = 2000) { return typeof s === "string" ? s.slice(0, max) : ""; } function num(v) { if (v === undefined || v === null || v === "") return undefined; @@ -102,6 +107,7 @@ export function normalize(doc) { } } doc.progress = p; + doc.kind = BOARD_MARKER; return doc; } @@ -344,14 +350,28 @@ export async function resolveStateFile(p) { return join(process.cwd(), "focus-board-state.json"); } -// Create + seed the state file if it doesn't exist yet. Returns the resolved path. +// A parsed value we're willing to treat as an existing board — so we never adopt +// (and then overwrite on first mutation) an unrelated file the caller pointed at. +export function looksLikeBoard(o) { + return !!o && typeof o === "object" && !Array.isArray(o) + && (o.kind === BOARD_MARKER || (Array.isArray(o.tasks) && !!o.progress && typeof o.progress === "object")); +} + +// Create + seed the state file if it doesn't exist yet. If it DOES exist, refuse +// to use it unless it's clearly one of our boards (a marker or the board schema), +// so an externally supplied stateFile can't cause us to clobber an unrelated file. export async function ensureStateFile(inputPath, seed) { const file = await resolveStateFile(inputPath); - if (!existsSync(file)) { - const doc = normalize(seed && typeof seed === "object" ? seed : demoSeed()); - doc.updatedAt = new Date().toISOString(); - await mkdir(dirname(file), { recursive: true }).catch(() => {}); - await atomicWrite(file, doc); + if (existsSync(file)) { + let parsed; + try { parsed = JSON.parse(await readFile(file, "utf-8")); } + catch { throw new Error(`refusing to use existing non-JSON file as a focus board: ${file}`); } + if (!looksLikeBoard(parsed)) throw new Error(`refusing to use an existing file that is not a daily-focus-board: ${file}`); + return file; } + const doc = normalize(seed && typeof seed === "object" ? seed : demoSeed()); + doc.updatedAt = new Date().toISOString(); + await mkdir(dirname(file), { recursive: true }).catch(() => {}); + await atomicWrite(file, doc); return file; }