From ae58a217323926e6953a2c12ebc41fd3558f5b63 Mon Sep 17 00:00:00 2001 From: Jon Gallant Date: Tue, 7 Jul 2026 18:59:22 -0700 Subject: [PATCH] chore(canvas-kit): sync vendored canvas-kit to 2026-07-07.6 Re-vendor the canonical create-canvas-app kit (2026-07-05.1 -> 2026-07-07.6) into all six canvas extensions. This pulls in the recent kit hardening and feature work: - Strict action/state schema validation at the boundary (validate.mjs) - SSRF-safe fetch with per-redirect re-checks (net.mjs) - Atomic, concurrency-serialized storage tiers - githubStore for shared, multi-writer canvas state via the GitHub Contents API (github-store.mjs) Also update two smoke-test assertions to match the kit's stricter input validation (enum errors now carry the input path prefix; a present-but- undefined optional prop is now type-checked, so "rate current" omits the optional article key instead of passing undefined). Mechanical re-vendor from the canonical kit; no hand-edited kit logic. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../code-tutor/canvas-kit/.kit-version.json | 4 +- extensions/code-tutor/canvas-kit/client.mjs | 107 ++++++--- .../code-tutor/canvas-kit/github-store.mjs | 215 ++++++++++++++++++ extensions/code-tutor/canvas-kit/icons.mjs | 13 +- extensions/code-tutor/canvas-kit/net.mjs | 126 ++++++++++ extensions/code-tutor/canvas-kit/server.mjs | 139 ++++++++++- extensions/code-tutor/canvas-kit/storage.mjs | 83 ++++++- extensions/code-tutor/canvas-kit/validate.mjs | 146 ++++++++++++ extensions/code-tutor/canvas-kit/version.mjs | 2 +- extensions/code-tutor/test/smoke.test.mjs | 2 +- .../canvas-kit/.kit-version.json | 4 +- .../language-tutor/canvas-kit/client.mjs | 107 ++++++--- .../canvas-kit/github-store.mjs | 215 ++++++++++++++++++ .../language-tutor/canvas-kit/icons.mjs | 13 +- extensions/language-tutor/canvas-kit/net.mjs | 126 ++++++++++ .../language-tutor/canvas-kit/server.mjs | 139 ++++++++++- .../language-tutor/canvas-kit/storage.mjs | 83 ++++++- .../language-tutor/canvas-kit/validate.mjs | 146 ++++++++++++ .../language-tutor/canvas-kit/version.mjs | 2 +- .../canvas-kit/.kit-version.json | 4 +- .../news-aggregator/canvas-kit/client.mjs | 107 ++++++--- .../canvas-kit/github-store.mjs | 215 ++++++++++++++++++ .../news-aggregator/canvas-kit/icons.mjs | 13 +- extensions/news-aggregator/canvas-kit/net.mjs | 126 ++++++++++ .../news-aggregator/canvas-kit/server.mjs | 139 ++++++++++- .../news-aggregator/canvas-kit/storage.mjs | 83 ++++++- .../news-aggregator/canvas-kit/validate.mjs | 146 ++++++++++++ .../news-aggregator/canvas-kit/version.mjs | 2 +- .../canvas-kit/.kit-version.json | 4 +- .../random-animal/canvas-kit/client.mjs | 107 ++++++--- .../random-animal/canvas-kit/github-store.mjs | 215 ++++++++++++++++++ extensions/random-animal/canvas-kit/icons.mjs | 13 +- extensions/random-animal/canvas-kit/net.mjs | 126 ++++++++++ .../random-animal/canvas-kit/server.mjs | 139 ++++++++++- .../random-animal/canvas-kit/storage.mjs | 83 ++++++- .../random-animal/canvas-kit/validate.mjs | 146 ++++++++++++ .../random-animal/canvas-kit/version.mjs | 2 +- .../stock-ticker/canvas-kit/.kit-version.json | 4 +- extensions/stock-ticker/canvas-kit/client.mjs | 107 ++++++--- .../stock-ticker/canvas-kit/github-store.mjs | 215 ++++++++++++++++++ extensions/stock-ticker/canvas-kit/icons.mjs | 13 +- extensions/stock-ticker/canvas-kit/net.mjs | 126 ++++++++++ extensions/stock-ticker/canvas-kit/server.mjs | 139 ++++++++++- .../stock-ticker/canvas-kit/storage.mjs | 83 ++++++- .../stock-ticker/canvas-kit/validate.mjs | 146 ++++++++++++ .../stock-ticker/canvas-kit/version.mjs | 2 +- .../canvas-kit/.kit-version.json | 4 +- .../wiki-discover/canvas-kit/client.mjs | 107 ++++++--- .../wiki-discover/canvas-kit/github-store.mjs | 215 ++++++++++++++++++ extensions/wiki-discover/canvas-kit/icons.mjs | 13 +- extensions/wiki-discover/canvas-kit/net.mjs | 126 ++++++++++ .../wiki-discover/canvas-kit/server.mjs | 139 ++++++++++- .../wiki-discover/canvas-kit/storage.mjs | 83 ++++++- .../wiki-discover/canvas-kit/validate.mjs | 146 ++++++++++++ .../wiki-discover/canvas-kit/version.mjs | 2 +- extensions/wiki-discover/test/smoke.test.mjs | 2 +- 56 files changed, 4724 insertions(+), 290 deletions(-) create mode 100644 extensions/code-tutor/canvas-kit/github-store.mjs create mode 100644 extensions/code-tutor/canvas-kit/net.mjs create mode 100644 extensions/code-tutor/canvas-kit/validate.mjs create mode 100644 extensions/language-tutor/canvas-kit/github-store.mjs create mode 100644 extensions/language-tutor/canvas-kit/net.mjs create mode 100644 extensions/language-tutor/canvas-kit/validate.mjs create mode 100644 extensions/news-aggregator/canvas-kit/github-store.mjs create mode 100644 extensions/news-aggregator/canvas-kit/net.mjs create mode 100644 extensions/news-aggregator/canvas-kit/validate.mjs create mode 100644 extensions/random-animal/canvas-kit/github-store.mjs create mode 100644 extensions/random-animal/canvas-kit/net.mjs create mode 100644 extensions/random-animal/canvas-kit/validate.mjs create mode 100644 extensions/stock-ticker/canvas-kit/github-store.mjs create mode 100644 extensions/stock-ticker/canvas-kit/net.mjs create mode 100644 extensions/stock-ticker/canvas-kit/validate.mjs create mode 100644 extensions/wiki-discover/canvas-kit/github-store.mjs create mode 100644 extensions/wiki-discover/canvas-kit/net.mjs create mode 100644 extensions/wiki-discover/canvas-kit/validate.mjs diff --git a/extensions/code-tutor/canvas-kit/.kit-version.json b/extensions/code-tutor/canvas-kit/.kit-version.json index 769d8b2..6ef1c15 100644 --- a/extensions/code-tutor/canvas-kit/.kit-version.json +++ b/extensions/code-tutor/canvas-kit/.kit-version.json @@ -1,5 +1,5 @@ { - "version": "2026-07-05.1", - "syncedAt": "2026-07-06T05:14:31.801Z", + "version": "2026-07-07.6", + "syncedAt": "2026-07-08T01:54:39.058Z", "source": "create-canvas-app/kit" } diff --git a/extensions/code-tutor/canvas-kit/client.mjs b/extensions/code-tutor/canvas-kit/client.mjs index 75e6e65..6f4cd9c 100644 --- a/extensions/code-tutor/canvas-kit/client.mjs +++ b/extensions/code-tutor/canvas-kit/client.mjs @@ -85,25 +85,21 @@ export function pollWhileVisible(tick, seconds, { whenVisible = true, immediate } /** - * Mount a canvas view and keep it live. - * @param {object} opts - * @param {(model:{state:any, invoke:Function, connected:boolean})=>any} opts.view - * Returns an htm/Preact vnode. Re-invoked on every state push. - * @param {HTMLElement} [opts.mount] defaults to #app or - * @param {(state:any)=>void} [opts.onState] - * @param {PollOptions} [opts.poll] built-in fixed-interval visibility-gated auto-refresh. - * For an interval bound to live state, use `pollWhileVisible` in a useEffect instead. - * @returns {{invoke:Function, refresh:Function, stopPoll:Function, get state():any}} + * DOM-FREE transport for a canvas: owns the loopback wiring (GET /state, GET + * /events SSE, POST /action) and the derived `state`/`connected`, with no Preact + * and no DOM. `mountCanvas` composes this with a render loop; keeping it separate + * makes the reconnect/invoke glue unit-testable without a browser (see + * test/client.test.mjs). Both callbacks receive the latest `(state, connected)`. + * @param {object} [opts] + * @param {(state:any, connected:boolean)=>void} [opts.onState] fired on the initial /state and every SSE push + * @param {(connected:boolean)=>void} [opts.onConnected] fired when the SSE stream opens/errors + * @param {typeof EventSource} [opts.EventSourceImpl] override the SSE impl (tests); defaults to the global + * @returns {{invoke:Function, refresh:Function, get state():any, get connected():boolean}} */ -export function mountCanvas({ view, mount, onState, poll } = {}) { - const root = mount || document.getElementById("app") || document.body; - // Preact's render() diffs against — but does not clear — pre-existing DOM in - // the container, so a static no-JS placeholder (e.g.

Loading…

in the - // HTML shell) would linger as a sibling. Clear it once so Preact owns an empty - // root; the view's own loading branch covers the gap until first state. - root.replaceChildren(); +export function connectCanvas({ onState, onConnected, EventSourceImpl } = {}) { let state = null; let connected = false; + const ES = EventSourceImpl || (typeof EventSource !== "undefined" ? EventSource : null); async function invoke(actionName, input) { const res = await fetch("./action", { @@ -118,20 +114,16 @@ export function mountCanvas({ view, mount, onState, poll } = {}) { return data.result; } - function rerender() { - render(view({ state, invoke, connected }), root); - } - async function refresh() { try { state = await (await fetch("./state")).json(); - onState?.(state); - rerender(); + onState?.(state, connected); } catch { /* offline; SSE will recover */ } } function connect() { - const es = new EventSource("./events"); + if (!ES) return; // no EventSource (e.g. a non-browser host); refresh() still works + const es = new ES("./events"); es.onmessage = (e) => { let next; try { @@ -139,20 +131,69 @@ export function mountCanvas({ view, mount, onState, poll } = {}) { } catch { return; // ignore a malformed SSE frame; the next push recovers } - // Update + render OUTSIDE the try so a bug in onState/the view surfaces as - // a real error instead of being silently mislabeled a "malformed frame". + // Update OUTSIDE the try so a bug in onState surfaces as a real error + // instead of being silently mislabeled a "malformed frame". state = next; connected = true; - onState?.(state); - rerender(); + onState?.(state, connected); }; - es.onopen = () => { connected = true; rerender(); }; - es.onerror = () => { connected = false; rerender(); /* EventSource auto-reconnects */ }; + es.onopen = () => { connected = true; onConnected?.(connected); }; + es.onerror = () => { connected = false; onConnected?.(connected); /* EventSource auto-reconnects */ }; } refresh(); connect(); + return { + invoke, + refresh, + get state() { return state; }, + get connected() { return connected; }, + }; +} + +/** + * Mount a canvas view and keep it live. + * @param {object} opts + * @param {(model:{state:any, invoke:Function, connected:boolean})=>any} opts.view + * Returns an htm/Preact vnode. Re-invoked on every state push. + * @param {HTMLElement} [opts.mount] defaults to #app or + * @param {(state:any)=>void} [opts.onState] + * @param {PollOptions} [opts.poll] built-in fixed-interval visibility-gated auto-refresh. + * For an interval bound to live state, use `pollWhileVisible` in a useEffect instead. + * @returns {{invoke:Function, refresh:Function, stopPoll:Function, get state():any}} + */ +export function mountCanvas({ view, mount, onState, poll } = {}) { + const root = mount || document.getElementById("app") || document.body; + // Preact's render() diffs against — but does not clear — pre-existing DOM in + // the container, so a static no-JS placeholder (e.g.

Loading…

in the + // HTML shell) would linger as a sibling. Clear it once so Preact owns an empty + // root; the view's own loading branch covers the gap until first state. + root.replaceChildren(); + + let latestState = null; + let latestConnected = false; + let client; + + function rerender() { + render(view({ state: latestState, invoke: client.invoke, connected: latestConnected }), root); + } + + // The transport is DOM-free (connectCanvas); this wrapper only adds the Preact + // render on each state/connection change. + client = connectCanvas({ + onState: (state, connected) => { + latestState = state; + latestConnected = connected; + onState?.(state); + rerender(); + }, + onConnected: (connected) => { + latestConnected = connected; + rerender(); + }, + }); + // Built-in fixed-interval auto-refresh, delegating to the shared // visibility-gated primitive. Pass `poll: { action, seconds, immediate }`. // @@ -164,7 +205,7 @@ export function mountCanvas({ view, mount, onState, poll } = {}) { // @property {boolean} [immediate=false] fire one tick right after mount function startPoll({ action, seconds, input, whenVisible = true, immediate = false } = {}) { return pollWhileVisible( - () => (action ? invoke(action, input) : refresh()), + () => (action ? client.invoke(action, input) : client.refresh()), seconds, { whenVisible, immediate } ); @@ -174,10 +215,10 @@ export function mountCanvas({ view, mount, onState, poll } = {}) { if (poll) stopPoll = startPoll(poll); return { - invoke, - refresh, + invoke: client.invoke, + refresh: client.refresh, stopPoll: () => stopPoll(), - get state() { return state; }, + get state() { return latestState; }, }; } diff --git a/extensions/code-tutor/canvas-kit/github-store.mjs b/extensions/code-tutor/canvas-kit/github-store.mjs new file mode 100644 index 0000000..d89a136 --- /dev/null +++ b/extensions/code-tutor/canvas-kit/github-store.mjs @@ -0,0 +1,215 @@ +// canvas-kit/github-store.mjs +// +// A SHARED, multi-writer durable store backed by a file in a GitHub repository. +// Where userStore/sessionStore/workspaceStore (storage.mjs) persist to local disk +// — private to one machine — githubStore persists the same JSON to a file in a +// repo via the Contents API, so every collaborator who can push to that repo edits +// ONE shared document. GitHub is the backing store AND the access-control layer +// (invite collaborators to a private repo); there is no server to run. +// +// It exposes the same { load, save } shape the runtime's loadState/saveState +// expect, plus poll() for cheap change-detection so a canvas can pull other +// people's edits live (wire it to server.mjs's syncState + syncIntervalMs). +// +// Concurrency: every write carries the blob sha it read (optimistic lock). A +// concurrent commit makes the PUT 409; save() re-reads to refresh the sha and +// retries. The default policy is last-writer-wins for the whole document; pass a +// merge(remoteState, myState) to resolve conflicts field-by-field instead (e.g. +// union a board's concerns by id). Reads use an ETag If-None-Match so an unchanged +// poll is a cheap 304 with no body. +// +// Token: resolved once from opts.token (string | async () => string), then +// GH_TOKEN / GITHUB_TOKEN, then `gh auth token`. Needs the `repo` scope for a +// private repo. The token is only ever sent as an Authorization header — never +// logged, never written to the repo. + +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; + +const execFileAsync = promisify(execFile); + +const API = "https://api.github.com"; +const UA = "canvas-kit-github-store"; +// Hard deadline for every GitHub API call so a hung request (stalled TLS, dropped +// connection) can't freeze a user action or dead-lock the sync loop (mirrors the +// AbortSignal.timeout pattern in net.mjs safeFetch). +const DEFAULT_TIMEOUT_MS = 15000; +// Refuse an implausibly large state file rather than allocate it on every poll. A +// board's JSON is tens of KB; this is a safety ceiling, not a real size limit. +const MAX_RESPONSE_BYTES = 8 * 1024 * 1024; +// Bounded save retries on an optimistic-lock conflict (409) before failing loud. +const MAX_SAVE_RETRIES = 3; + +// Resolve a GitHub token lazily and memoize it. A 401 (expired/rotated) clears the +// cache via invalidate() so the next call re-resolves. +function makeTokenResolver(tokenOpt) { + let cached = null; + async function fromGhCli() { + try { + const { stdout } = await execFileAsync("gh", ["auth", "token"], { windowsHide: true }); + return String(stdout).trim() || null; + } catch { + return null; // gh missing or not logged in → fall through to the no-token error + } + } + return { + async get() { + if (cached) return cached; + let t = null; + if (typeof tokenOpt === "function") t = await tokenOpt(); + else if (typeof tokenOpt === "string" && tokenOpt) t = tokenOpt; + if (!t) t = process.env.GH_TOKEN || process.env.GITHUB_TOKEN || null; + if (!t) t = await fromGhCli(); + if (!t) throw new Error("githubStore: no GitHub token (set GH_TOKEN or run `gh auth login`)"); + cached = t; + return cached; + }, + invalidate() { cached = null; }, + }; +} + +function encodePath(path) { + // Keep the slashes as path separators in the Contents API URL; encode each + // segment so spaces / unicode in a filename can't break the request. + return String(path).split("/").filter(Boolean).map(encodeURIComponent).join("/"); +} + +/** + * A GitHub-repo-backed durable store: one JSON file, many writers. + * @param {object} opts + * @param {string} opts.owner repo owner (user or org) + * @param {string} opts.repo repo name + * @param {string} opts.path path to the JSON file in the repo (e.g. "state/board.json") + * @param {string} [opts.branch="main"] + * @param {string|(()=>Promise|string)} [opts.token] token or async resolver + * @param {(remote:any,mine:any)=>any} [opts.merge] conflict resolver (default: last-writer-wins) + * @param {(state:any)=>string} [opts.message] commit message from the state (default: a timestamp) + * @param {string} [opts.apiBase=API] + * @returns {{file:string, load:(fallback?:any)=>Promise, poll:()=>Promise<{changed:boolean,state?:any}>, save:(state:any)=>Promise}} + */ +export function githubStore(opts) { + const { owner, repo, path, branch = "main", token, merge, message, apiBase = API } = opts ?? {}; + if (!owner || !repo || !path) throw new Error("githubStore: owner, repo and path are required"); + // apiBase is a trusted, fixed host by design — GitHub's public API by default, or a + // GitHub Enterprise host the canvas author sets at construction (never runtime + // input). That's why we call fetch() directly rather than the kit's safeFetch SSRF + // guard, which would add a DNS-resolution check on every call for a host that can't + // vary per request. Still, require https for any custom apiBase as defense-in-depth. + if (apiBase !== API) { + let base; + try { base = new URL(apiBase); } catch { throw new Error("githubStore: apiBase must be a valid URL"); } + if (base.protocol !== "https:") throw new Error("githubStore: apiBase must be https"); + } + + const tok = makeTokenResolver(token); + const contentsUrl = `${apiBase}/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/contents/${encodePath(path)}`; + const label = `${owner}/${repo}:${path}`; + + let sha = null; // last-seen blob sha — sent on write as the optimistic lock + let etag = null; // last-seen ETag — sent on poll as If-None-Match + let lastText = null; // last-seen decoded content — guards against no-op broadcasts + + async function api(url, init = {}, allow = []) { + const t = await tok.get(); + const res = await fetch(url, { + ...init, + signal: AbortSignal.timeout(DEFAULT_TIMEOUT_MS), + headers: { + Authorization: `Bearer ${t}`, + Accept: "application/vnd.github+json", + "X-GitHub-Api-Version": "2022-11-28", + "User-Agent": UA, + ...(init.headers ?? {}), + }, + }); + // An expired token surfaces as 401 — drop the memoized token so a retry can + // re-resolve (e.g. after `gh auth refresh`), then surface the failure. + if (res.status === 401) { tok.invalidate(); } + // ok, or an expected status the caller handles (404 fresh-file, 304 unchanged, + // 409 write conflict), pass through; anything else is a hard error. + if (res.ok || allow.includes(res.status)) return res; + const body = await res.text().catch(() => ""); + throw new Error(`githubStore ${label}: ${init.method ?? "GET"} ${res.status} ${body.slice(0, 200)}`); + } + + function decode(json) { + // Contents API returns base64 (wrapped at 60 cols); Buffer handles the newlines. + return Buffer.from(json.content ?? "", "base64").toString("utf8"); + } + + async function readJson(res) { + // Bound the allocation: refuse an oversized response before reading it, so a + // huge state file can't be pulled into memory on every poll tick. + const len = Number(res.headers.get("content-length") || 0); + if (len > MAX_RESPONSE_BYTES) { + throw new Error(`githubStore ${label}: response too large (${len} bytes)`); + } + return res.json(); + } + + async function load(fallback = null) { + const res = await api(`${contentsUrl}?ref=${encodeURIComponent(branch)}`, {}, [404]); + if (res.status === 404) { sha = null; etag = null; lastText = null; return fallback; } + const json = await readJson(res); + sha = json.sha ?? null; + etag = res.headers.get("etag"); + const text = decode(json); + lastText = text; + return text.trim() ? JSON.parse(text) : fallback; + } + + async function poll() { + const headers = etag ? { "If-None-Match": etag } : {}; + const res = await api(`${contentsUrl}?ref=${encodeURIComponent(branch)}`, { headers }, [304, 404]); + if (res.status === 304) return { changed: false }; + if (res.status === 404) { + if (sha === null && lastText === null) return { changed: false }; + sha = null; etag = null; lastText = null; + return { changed: true, state: null }; + } + const json = await readJson(res); + const text = decode(json); + sha = json.sha ?? null; + etag = res.headers.get("etag"); + if (text === lastText) return { changed: false }; + lastText = text; + return { changed: true, state: text.trim() ? JSON.parse(text) : null }; + } + + async function save(state) { + let toWrite = state; + for (let attempt = 0; ; attempt++) { + const text = JSON.stringify(toWrite, null, 2); + const body = { + message: (message ? message(toWrite) : `Update ${path}`) || `Update ${path}`, + content: Buffer.from(text, "utf8").toString("base64"), + branch, + ...(sha ? { sha } : {}), + }; + const res = await api(contentsUrl, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }, [409, 404]); + if (res.ok) { + const json = await res.json(); + sha = json.content?.sha ?? null; + etag = null; // PUT's ETag isn't the content GET's — force a fresh poll baseline + lastText = text; // our own write must not read back as a change + return; + } + // 409 (or a 404 if the file/branch vanished): the sha we held is stale. + // Re-read to refresh sha, optionally merge the remote with our intended + // write, and retry. Bounded so a persistent conflict fails loudly. + if ((res.status === 409 || res.status === 404) && attempt < MAX_SAVE_RETRIES) { + const remote = await load(null); + toWrite = merge ? merge(remote, state) : state; // default: last-writer-wins + continue; + } + const errBody = await res.text().catch(() => ""); + throw new Error(`githubStore ${label}: save failed ${res.status} ${errBody.slice(0, 200)}`); + } + } + + return { file: label, load, poll, save }; +} diff --git a/extensions/code-tutor/canvas-kit/icons.mjs b/extensions/code-tutor/canvas-kit/icons.mjs index 9dd30ea..04eb2e9 100644 --- a/extensions/code-tutor/canvas-kit/icons.mjs +++ b/extensions/code-tutor/canvas-kit/icons.mjs @@ -23,7 +23,18 @@ const VIEWBOX = "0 0 24 24"; const STROKE_WIDTH = 2; function resolve(name) { - return LUCIDE[name] || LUCIDE[aliases[name]] || null; + // Own-property lookups only. Bracket access on a plain object reaches inherited + // Object.prototype members, so a name like "toString"/"constructor"/ + // "hasOwnProperty" would otherwise resolve to a function (making hasIcon() lie + // and Icon()/lucideSVG() throw in nodeToString). Object.hasOwn keeps resolution + // to the real, vendored icon set. + if (typeof name !== "string") return null; + if (Object.hasOwn(LUCIDE, name)) return LUCIDE[name]; + if (Object.hasOwn(aliases, name)) { + const target = aliases[name]; + if (Object.hasOwn(LUCIDE, target)) return LUCIDE[target]; + } + return null; } function kebab(k) { diff --git a/extensions/code-tutor/canvas-kit/net.mjs b/extensions/code-tutor/canvas-kit/net.mjs new file mode 100644 index 0000000..fc1618c --- /dev/null +++ b/extensions/code-tutor/canvas-kit/net.mjs @@ -0,0 +1,126 @@ +// canvas-kit/net.mjs +// +// Server-side network safety for canvases that fetch external data. A canvas +// action runs on the loopback runtime with the app's network reach, so a +// caller-influenced URL is an SSRF risk: it could target cloud metadata +// (169.254.169.254), loopback, or the private network. This module is the kit's +// sanctioned egress primitive — validate a URL, then fetch it with a hard +// timeout — so every data canvas guards the same way instead of re-inlining the +// check. SERVER-ONLY (uses node:dns / node:net): import it from the SDK-free +// canvas.mjs, never from the browser view. +// +// Defense-in-depth, not a guarantee: a determined attacker could DNS-rebind +// between the resolve check and the connect. For a canvas pointed at a source +// you choose this is adequate; treat any fetched content as untrusted and render +// it as TEXT (never innerHTML). + +import { lookup } from "node:dns/promises"; +import { isIP } from "node:net"; + +/** + * True for addresses a server-side fetch should never reach: loopback, + * link-local (incl. cloud metadata 169.254.169.254), and private/CGNAT ranges. + * @param {string} ip + * @returns {boolean} + */ +export function isBlockedAddress(ip) { + const lower = ip.toLowerCase(); + // Decode an IPv4-mapped/compatible IPv6 literal down to its embedded IPv4 so + // the IPv4 range checks apply. Covers BOTH the dotted tail (::ffff:127.0.0.1) + // and the HEX tail that `new URL()` normalizes literals to (::ffff:7f00:1) — + // without the decode, `[::ffff:127.0.0.1]` reaches loopback and + // `[::ffff:a9fe:a9fe]` reaches cloud metadata. Only matches when the high bits + // are all zero (leading "::"), so a normal public v6 (e.g. 2001:db8::7f00:1) is + // never misread as IPv4. + const addr = embeddedIPv4(lower) ?? ip; + if (isIP(addr) === 4) { + const [a, b] = addr.split(".").map(Number); + if (a === 0 || a === 127 || a === 10) return true; // this-host / loopback / private + if (a === 169 && b === 254) return true; // link-local (incl. cloud IMDS) + if (a === 172 && b >= 16 && b <= 31) return true; // private + if (a === 192 && b === 168) return true; // private + if (a === 100 && b >= 64 && b <= 127) return true; // CGNAT + return false; + } + return lower === "::1" || lower === "::" || lower.startsWith("fe80") || lower.startsWith("fc") || lower.startsWith("fd"); +} + +// Decode an IPv4-mapped/compatible IPv6 literal (all-zero high groups, i.e. a +// leading "::", optionally "::ffff:") to its dotted IPv4; else null. Accepts the +// dotted tail (127.0.0.1) and the two-hex-group tail (7f00:1) that URL +// normalization produces. Anchored on "::" so it can't misread a public v6. +function embeddedIPv4(addr) { + const m = addr.match(/^::(?:ffff:)?((?:\d{1,3}\.){3}\d{1,3}|[0-9a-f]{1,4}:[0-9a-f]{1,4})$/); + if (!m) return null; + const tail = m[1]; + if (tail.includes(".")) return isIP(tail) === 4 ? tail : null; + const [h1, h2] = tail.split(":"); + const hi = parseInt(h1, 16), lo = parseInt(h2, 16); + return `${hi >> 8}.${hi & 255}.${lo >> 8}.${lo & 255}`; +} + +/** + * Allow only http/https to a PUBLIC host. Rejects every address the hostname + * resolves to, so a public name can't be pointed at an internal IP. Throws on a + * blocked/invalid URL; resolves to void when the URL is safe to fetch. + * @param {string} url + */ +export async function assertPublicUrl(url) { + let u; + try { u = new URL(url); } catch { throw new Error("Invalid source URL"); } + if (u.protocol !== "http:" && u.protocol !== "https:") { + throw new Error("Blocked URL protocol: " + u.protocol); + } + let host = u.hostname; + if (host.startsWith("[") && host.endsWith("]")) host = host.slice(1, -1); // IPv6 brackets + if (host.toLowerCase() === "localhost") throw new Error("Blocked host: localhost"); + const addrs = isIP(host) ? [host] : (await lookup(host, { all: true })).map((r) => r.address); + if (!addrs.length) throw new Error("Could not resolve host: " + host); + for (const ip of addrs) { + if (isBlockedAddress(ip)) throw new Error("Blocked private/loopback address: " + ip); + } +} + +/** + * SSRF-guarded fetch with a mandatory timeout. Runs assertPublicUrl on the URL + * and on EVERY redirect hop, so a chosen public host can't 30x-redirect the + * request into loopback/metadata/private space. Returns the final Response as-is + * (does NOT throw on a non-2xx status — the caller checks res.ok), so it is a + * drop-in for a guarded fetch(). + * + * Redirects are followed MANUALLY (redirect:"manual") rather than by fetch's + * default redirect:"follow": the default would chase a 3xx Location without + * re-running the guard, which silently undoes every check in this module. Here + * each hop's target is re-validated before we connect to it. + * @param {string} url + * @param {object} [opts] + * @param {number} [opts.timeoutMs=12000] abort the WHOLE operation (all hops) after this many ms + * @param {number} [opts.maxRedirects=5] how many 3xx hops to follow before giving up + * @param {object} [opts.headers] request headers (merged as-is) + * @param {RequestInit} [opts.rest] any other fetch init (method, body, …) + * @returns {Promise} + */ +export async function safeFetch(url, { timeoutMs = 12000, maxRedirects = 5, headers, ...rest } = {}) { + // One timeout signal bounds the entire operation, redirects included, so a + // chain of slow hops can't multiply the deadline. + const signal = AbortSignal.timeout(timeoutMs); + let current = url; + for (let hop = 0; ; hop++) { + await assertPublicUrl(current); + const res = await fetch(current, { + ...rest, + headers: headers ?? {}, + redirect: "manual", + signal, + }); + if (res.status >= 300 && res.status < 400 && res.headers.has("location")) { + if (hop >= maxRedirects) throw new Error("Too many redirects"); + const next = new URL(res.headers.get("location"), current).href; + // Discard the redirect body so the connection can be freed before the next hop. + try { await res.body?.cancel(); } catch { /* no body / already consumed */ } + current = next; + continue; + } + return res; + } +} diff --git a/extensions/code-tutor/canvas-kit/server.mjs b/extensions/code-tutor/canvas-kit/server.mjs index bf3a07f..eb533c9 100644 --- a/extensions/code-tutor/canvas-kit/server.mjs +++ b/extensions/code-tutor/canvas-kit/server.mjs @@ -20,6 +20,7 @@ import { createServer } from "node:http"; import { readFile } from "node:fs/promises"; import { join, normalize, extname, sep } from "node:path"; import { fileURLToPath } from "node:url"; +import { validate } from "./validate.mjs"; const KIT_DIR = fileURLToPath(new URL(".", import.meta.url)); @@ -52,7 +53,10 @@ export class CanvasKitError extends Error { * @param {(ctx:object)=>any|Promise} [config.createInitialState] * @param {(domainId:string)=>any|Promise} [config.loadState] * @param {(domainId:string, state:any)=>void|Promise} [config.saveState] + * @param {(domainId:string)=>{changed:boolean,state?:any}|Promise<{changed:boolean,state?:any}>} [config.syncState] optional delta-poll of a SHARED durable source (e.g. githubStore.poll); when set with syncIntervalMs, remote changes are adopted + broadcast to viewers live + * @param {number} [config.syncIntervalMs] poll cadence for syncState; only polled while a domain has >=1 connected viewer * @param {Record} config.actions + * @param {object} [config.stateSchema] optional JSON-Schema-subset for the durable state; when set, a mutation that violates it is rolled back and fails (500) * @param {string} config.assetsDir absolute path to the canvas web/ folder * @param {(ctx:object,state:any)=>string} [config.statusLine] */ @@ -112,14 +116,110 @@ export function createCanvasRuntime(config) { } } + // ---- optional shared-state sync (repo-backed multiplayer) ---------------- + // When config.syncState + config.syncIntervalMs are set, poll the durable + // source on an interval and adopt+broadcast remote changes, so collaborators + // editing a SHARED store (e.g. githubStore) see each other's edits live. We + // only poll a domain while at least one SSE client is watching it — no viewers, + // no network — so API usage stays proportional to real use. syncState returns + // { changed:boolean, state? }; a cheap unchanged poll (ETag 304) is changed:false. + const syncing = new Set(); // domainIds with an in-flight poll (prevents overlap) + let syncTimer = null; + + function domainHasViewers(domainId) { + for (const inst of instances.values()) { + if (inst.domainId === domainId && inst.clients.size > 0) return true; + } + return false; + } + + async function syncDomain(domainId) { + if (syncing.has(domainId)) return; // last tick still in flight — skip + syncing.add(domainId); + try { + const out = await config.syncState(domainId); + // Adopt only a real, non-null remote state. A null (file deleted upstream) + // is ignored so a transient upstream gap can't blank a live board. + if (out?.changed && out.state != null) { + const d = domains.get(domainId); + if (d) { + // Adopted remote state must clear the SAME stateSchema gate the invoke() + // path enforces. A collaborator (or a hand-edit on github.com) can push a + // shape that violates the schema; adopting it unvalidated would broadcast a + // corrupt shape to every viewer AND poison the next invoke()'s rollback + // baseline (structuredClone would snapshot the already-invalid state). + // Reject (skip) an invalid remote instead — the next valid write reconciles. + if (config.stateSchema && validate(config.stateSchema, out.state, "sync-remote").length) { + return; + } + d.state = out.state; + broadcast(domainId); + } + } + } catch { + // A transient network/API error must not kill the timer; retry next tick. + } finally { + syncing.delete(domainId); + } + } + + function syncTick() { + for (const domainId of domains.keys()) { + if (domainHasViewers(domainId)) syncDomain(domainId); + } + } + + // Floor the poll cadence: a mistakenly tiny interval (e.g. 1ms) would hammer the + // durable source — for githubStore that means burning the GitHub API rate limit in + // seconds. 2s is well below any real collaboration-latency need. + const MIN_SYNC_INTERVAL_MS = 2000; + + function startSync() { + if (syncTimer || typeof config.syncState !== "function") return; + const requested = Number(config.syncIntervalMs) || 0; + if (requested <= 0) return; + const ms = Math.max(requested, MIN_SYNC_INTERVAL_MS); + syncTimer = setInterval(syncTick, ms); + syncTimer.unref?.(); // never keep the process alive just to poll + } + + function stopSync() { + if (syncTimer) { clearInterval(syncTimer); syncTimer = null; } + } + // Core action invoker — the single code path behind both agent and UI actions. async function invoke(actionName, input, ctx) { - const action = config.actions[actionName]; + // Own-property lookup: `config.actions[actionName]` via bracket access would + // otherwise reach inherited members (e.g. "constructor", "toString"). The + // handler-typeof check below already rejects those, but resolving by + // Object.hasOwn keeps the boundary explicit and consistent with validate.mjs. + const action = + typeof actionName === "string" && Object.hasOwn(config.actions, actionName) + ? config.actions[actionName] + : null; if (!action || typeof action.handler !== "function") { throw new CanvasKitError("unknown_action", `Unknown action: ${actionName}`); } + // Enforce the declared inputSchema at the boundary (agent OR ui). The schema + // is a contract authors already write; validating it here turns "declared but + // unchecked" into a typed boundary and stops a malformed/typo'd payload from + // reaching the handler. A shape violation is the CALLER's fault → invalid_input + // (HTTP 400). Business rules ("title can't be blank") still live in the handler + // and surface as a 500, so a schema-valid-but-empty string reaches the handler. + if (action.inputSchema) { + const errs = validate(action.inputSchema, input ?? {}, "input"); + if (errs.length) { + throw new CanvasKitError("invalid_input", `Invalid input for '${actionName}': ${errs.join("; ")}`); + } + } const domainId = ctx?.domainId ?? "default"; const d = await getDomain(domainId, ctx); + // Deep snapshot for stateSchema rollback: a handler may mutate state IN PLACE + // and return the same object, so a reference copy (prevState = d.state) would + // point at the same (now-corrupt) object and restore nothing. structuredClone + // gives a real pre-mutation copy. Durable state is JSON-shaped, so it clones + // cleanly. Only pay the clone when a stateSchema is actually configured. + const prevState = config.stateSchema ? structuredClone(d.state) : undefined; let mutated = false; const api = { get state() { return d.state; }, @@ -161,6 +261,17 @@ export function createCanvasRuntime(config) { }; const result = await action.handler(api); if (mutated) { + // Optional stateSchema guards the durable shape: if a handler produced an + // invalid state, roll back the in-memory mutation and fail LOUD (a 500 — + // this is a handler bug, not caller input) instead of persisting/broadcasting + // corrupt state. Absent stateSchema, anything goes (opt-in). + if (config.stateSchema) { + const errs = validate(config.stateSchema, d.state, "state"); + if (errs.length) { + d.state = prevState; // roll back so in-memory stays consistent + throw new Error(`Action '${actionName}' produced invalid state: ${errs.join("; ")}`); + } + } if (config.saveState) await config.saveState(domainId, d.state); broadcast(domainId); } @@ -202,6 +313,12 @@ export function createCanvasRuntime(config) { // local client can't force unbounded memory growth on the loopback runtime. const MAX_BODY_BYTES = 1 << 20; // 1 MiB + // Cap concurrent SSE subscribers PER INSTANCE. A canvas panel needs only one + // /events stream (a couple across reopens); a runaway reconnect loop or a + // hostile local process hitting the loopback port could otherwise accumulate + // unbounded response handles + keep-alive timers. 64 is far above any real use. + const MAX_SSE_CLIENTS = 64; + function readBody(req) { return new Promise((resolve, reject) => { const chunks = []; @@ -240,6 +357,13 @@ export function createCanvasRuntime(config) { // GET /events — Server-Sent Events stream of state if (req.method === "GET" && path === "/events") { + // Refuse once an instance is saturated, so subscribers can't grow without + // bound (each holds a response handle + a keep-alive interval). + if (inst && inst.clients.size >= MAX_SSE_CLIENTS) { + res.writeHead(503, { "Content-Type": "text/plain", "Retry-After": "5" }); + res.end("too many event subscribers"); + return; + } res.writeHead(200, { "Content-Type": "text/event-stream", "Cache-Control": "no-cache", @@ -307,6 +431,15 @@ export function createCanvasRuntime(config) { * @returns {Promise<{url:string,title:string,status?:string}>} */ async function openInstance({ instanceId, input, ctx }) { + // Validate the open input against the declared inputSchema (same contract the + // actions get). A bad open payload fails fast with invalid_input rather than + // silently resolving the wrong domain. + if (config.inputSchema) { + const errs = validate(config.inputSchema, input ?? {}, "open input"); + if (errs.length) { + throw new CanvasKitError("invalid_input", `Invalid open input: ${errs.join("; ")}`); + } + } const domainId = config.resolveDomainId ? config.resolveDomainId(input ?? {}, ctx ?? {}) || "default" : "default"; @@ -341,6 +474,7 @@ export function createCanvasRuntime(config) { } async function shutdown() { + stopSync(); await Promise.all([...instances.keys()].map(closeInstance)); } @@ -348,6 +482,8 @@ export function createCanvasRuntime(config) { return (await getDomain(domainId)).state; } + startSync(); // no-op unless config.syncState + syncIntervalMs are set + return { config, setHost, @@ -358,5 +494,6 @@ export function createCanvasRuntime(config) { invokeFromAgent, // agent-side, resolves domain from ctx getState, _instances: instances, + _syncDomain: syncDomain, // manual one-shot sync (tests) }; } diff --git a/extensions/code-tutor/canvas-kit/storage.mjs b/extensions/code-tutor/canvas-kit/storage.mjs index 355983c..3f1177c 100644 --- a/extensions/code-tutor/canvas-kit/storage.mjs +++ b/extensions/code-tutor/canvas-kit/storage.mjs @@ -2,18 +2,61 @@ // // Durable JSON state helpers. State is keyed by a *domain id* (a stable logical // identifier resolved from the open input), never by instanceId — per -// create-canvas/SKILL.md. Two scopes: +// create-canvas/SKILL.md. Three tiers, matching the Canvas SDK storage model: // userStore -> $COPILOT_HOME/extensions//artifacts/ (per user, cross-session) -// workspaceStore -> / (per session) +// sessionStore -> $COPILOT_HOME/session-state//extensions// (per session, scratch) +// workspaceStore -> / (rooted at the session workspace) +// +// Sandboxing: callers derive the from a domain id that MUST be sanitized +// to a bare filename (the reference `fileFor` strips everything but +// [A-Za-z0-9._-]); the store never joins caller input as a path, so a domain id +// can't escape its extension's artifacts directory. -import { readFile, writeFile, mkdir, rename } from "node:fs/promises"; +import { readFile, writeFile, mkdir, rename, unlink } from "node:fs/promises"; import { join, dirname } from "node:path"; import { homedir } from "node:os"; +import { randomBytes } from "node:crypto"; function copilotHome() { return process.env.COPILOT_HOME || join(homedir(), ".copilot"); } +// Per-file write queue. Concurrent saves to the SAME durable file (an agent +// action and a UI action racing) must not overlap: on Windows two renames to the +// same destination collide with EPERM even with unique temp names. Chaining each +// save onto the previous one for that path serializes them (last enqueued wins), +// which is both correct (no interleaved writes) and portable. +const writeQueues = new Map(); // absolute file path -> tail Promise + +async function atomicWrite(file, data) { + await mkdir(dirname(file), { recursive: true }); + // Write to a temp sibling then atomically rename into place, so a crash or + // interruption mid-write can never truncate the existing durable file. + // rename(2) is atomic on the same filesystem. The temp name mixes pid + time + + // RANDOM bytes so two writers can never pick the same temp path. + const tmp = `${file}.${process.pid}.${Date.now()}.${randomBytes(6).toString("hex")}.tmp`; + await writeFile(tmp, JSON.stringify(data, null, 2), "utf8"); + try { + await rename(tmp, file); + } catch (err) { + // Windows can transiently EPERM/EACCES a rename when the destination is + // briefly held (AV scanner, indexer). Retry once after a short beat; always + // clean up our temp so a failed save leaves no orphaned *.tmp behind. + if (err?.code === "EPERM" || err?.code === "EACCES") { + await new Promise((r) => setTimeout(r, 25)); + try { + await rename(tmp, file); + } catch (err2) { + await unlink(tmp).catch(() => {}); + throw err2; + } + } else { + await unlink(tmp).catch(() => {}); + throw err; + } + } +} + function makeStore(file) { return { file, @@ -32,13 +75,15 @@ function makeStore(file) { } }, async save(data) { - await mkdir(dirname(file), { recursive: true }); - // Write to a temp sibling then atomically rename into place, so a crash or - // interruption mid-write can never truncate the existing durable file. - // rename(2) is atomic on the same filesystem. - const tmp = `${file}.${process.pid}.${Date.now()}.tmp`; - await writeFile(tmp, JSON.stringify(data, null, 2), "utf8"); - await rename(tmp, file); + // Serialize behind any in-flight save for this exact path (see writeQueues). + const prev = writeQueues.get(file) ?? Promise.resolve(); + const run = prev.catch(() => {}).then(() => atomicWrite(file, data)); + writeQueues.set(file, run); + try { + await run; + } finally { + if (writeQueues.get(file) === run) writeQueues.delete(file); + } }, }; } @@ -48,6 +93,24 @@ export function userStore(extensionName, fileName) { return makeStore(join(copilotHome(), "extensions", extensionName, "artifacts", fileName)); } +/** + * Per-session scratch store for a named extension, rooted under the session's + * state directory. Use for state that should NOT outlive the session (drafts, + * one-off working data). Pass the session id (e.g. from the SDK ctx). + */ +export function sessionStore(sessionId, extensionName, fileName) { + // Reduce the session id to a single safe path segment. The charset filter keeps + // "." (so dotted ids survive), so we must ALSO collapse any ".." run — otherwise + // a session id of ".." would join to one level ABOVE session-state and escape the + // per-session root. (sessionId is normally a trusted SDK value; this keeps the + // sanitizer honest regardless.) + const safeSession = + String(sessionId) + .replace(/[^A-Za-z0-9._-]/g, "_") + .replace(/\.\.+/g, "_") || "default"; + return makeStore(join(copilotHome(), "session-state", safeSession, "extensions", extensionName, fileName)); +} + /** Per-session store rooted at the session workspace path. */ export function workspaceStore(workspacePath, fileName) { return makeStore(join(workspacePath, fileName)); diff --git a/extensions/code-tutor/canvas-kit/validate.mjs b/extensions/code-tutor/canvas-kit/validate.mjs new file mode 100644 index 0000000..d44925e --- /dev/null +++ b/extensions/code-tutor/canvas-kit/validate.mjs @@ -0,0 +1,146 @@ +// canvas-kit/validate.mjs +// +// A tiny, dependency-free JSON-Schema-*subset* validator. It exists so the +// runtime can ENFORCE the action `inputSchema` (and an optional `stateSchema`) +// that authors already declare — turning the iframe↔extension / agent↔extension +// contract from "declared but unchecked" into "validated at the boundary". No +// npm dependency (the kit ships vendored, no install step), and it only covers +// the JSON Schema features the kit's schemas actually use: +// +// type "object" | "array" | "string" | "number" | "integer" | +// "boolean" | "null" (or an array of those) +// properties per-key subschemas (objects) +// required array of required property names (objects) +// additionalProperties false | subschema (objects) +// items subschema for every element (arrays) +// enum allowed literal values (deep-equal) +// minLength/maxLength string length bounds +// minimum/maximum numeric bounds +// minItems/maxItems array length bounds +// +// It is intentionally forgiving: an absent/empty schema validates anything, and +// unknown keywords are ignored (so a richer schema never hard-fails here). The +// goal is to catch the shape mistakes that actually break canvases — wrong type, +// missing required field, unknown/typo'd property, out-of-enum value — not to be +// a complete JSON Schema implementation. + +function typeOf(value) { + if (value === null) return "null"; + if (Array.isArray(value)) return "array"; + if (Number.isInteger(value)) return "integer"; + return typeof value; // "string" | "number" | "boolean" | "object" | "undefined" +} + +// Does `value` satisfy a single JSON Schema `type` token? "number" accepts +// integers; "integer" requires a whole number. +function matchesType(value, t) { + const actual = typeOf(value); + if (t === "number") return actual === "number" || actual === "integer"; + if (t === "integer") return actual === "integer"; + return actual === t; +} + +function deepEqual(a, b) { + if (a === b) return true; + if (typeof a !== typeof b || a === null || b === null) return false; + if (Array.isArray(a) && Array.isArray(b)) { + return a.length === b.length && a.every((x, i) => deepEqual(x, b[i])); + } + if (typeof a === "object" && typeof b === "object") { + const ak = Object.keys(a), bk = Object.keys(b); + return ak.length === bk.length && ak.every((k) => deepEqual(a[k], b[k])); + } + return false; +} + +/** + * Validate `value` against a JSON-Schema-subset `schema`. + * @param {object|undefined} schema + * @param {any} value + * @param {string} [path] dotted path used in error messages (default "input") + * @returns {string[]} human-readable error messages; empty array = valid. + */ +export function validate(schema, value, path = "input") { + const errors = []; + walk(schema, value, path, errors); + return errors; +} + +function walk(schema, value, path, errors) { + if (!schema || typeof schema !== "object") return; // absent/degenerate schema = anything goes + + // type (single token or array of tokens) + if (schema.type !== undefined) { + const types = Array.isArray(schema.type) ? schema.type : [schema.type]; + if (!types.some((t) => matchesType(value, t))) { + errors.push(`${path}: expected ${types.join(" | ")}, got ${typeOf(value)}`); + return; // a wrong base type makes deeper checks meaningless + } + } + + // enum + if (Array.isArray(schema.enum) && !schema.enum.some((allowed) => deepEqual(allowed, value))) { + errors.push(`${path}: must be one of ${schema.enum.map((v) => JSON.stringify(v)).join(", ")}`); + } + + const kind = typeOf(value); + + if (kind === "string") { + if (typeof schema.minLength === "number" && value.length < schema.minLength) { + errors.push(`${path}: must be at least ${schema.minLength} characters`); + } + if (typeof schema.maxLength === "number" && value.length > schema.maxLength) { + errors.push(`${path}: must be at most ${schema.maxLength} characters`); + } + } + + if (kind === "number" || kind === "integer") { + if (typeof schema.minimum === "number" && value < schema.minimum) { + errors.push(`${path}: must be >= ${schema.minimum}`); + } + if (typeof schema.maximum === "number" && value > schema.maximum) { + errors.push(`${path}: must be <= ${schema.maximum}`); + } + } + + if (kind === "array") { + if (typeof schema.minItems === "number" && value.length < schema.minItems) { + errors.push(`${path}: must have at least ${schema.minItems} item(s)`); + } + if (typeof schema.maxItems === "number" && value.length > schema.maxItems) { + errors.push(`${path}: must have at most ${schema.maxItems} item(s)`); + } + if (schema.items) { + value.forEach((item, i) => walk(schema.items, item, `${path}[${i}]`, errors)); + } + } + + if (kind === "object") { + const props = schema.properties ?? {}; + if (Array.isArray(schema.required)) { + for (const key of schema.required) { + // Object.hasOwn (not `value[key] === undefined`): a required property + // named after a prototype member (e.g. "toString", "constructor") would + // otherwise read the inherited value and wrongly pass the check. + if (!Object.hasOwn(value, key)) errors.push(`${path}.${key}: required`); + } + } + for (const [key, sub] of Object.entries(props)) { + if (Object.hasOwn(value, key)) walk(sub, value[key], `${path}.${key}`, errors); + } + // Membership is tested with Object.hasOwn, NOT `key in props`: `in` walks the + // prototype chain, so an extra key named "toString"/"constructor"/"__proto__" + // etc. would satisfy `key in props` and escape additionalProperties. This + // validator is the enforcement boundary (input → 400, state → 500), so that + // would be a real contract hole. + if (schema.additionalProperties === false) { + for (const key of Object.keys(value)) { + if (!Object.hasOwn(props, key)) errors.push(`${path}.${key}: unexpected property`); + } + } else if (schema.additionalProperties && typeof schema.additionalProperties === "object") { + for (const key of Object.keys(value)) { + if (!Object.hasOwn(props, key)) walk(schema.additionalProperties, value[key], `${path}.${key}`, errors); + } + } + } +} diff --git a/extensions/code-tutor/canvas-kit/version.mjs b/extensions/code-tutor/canvas-kit/version.mjs index 93471a9..6bff1cc 100644 --- a/extensions/code-tutor/canvas-kit/version.mjs +++ b/extensions/code-tutor/canvas-kit/version.mjs @@ -10,4 +10,4 @@ // convention (the skill ships as files, not an npm version); a commit short-sha // works too. Re-exported from client.mjs so a canvas can read it at runtime. -export const KIT_VERSION = "2026-07-05.1"; +export const KIT_VERSION = "2026-07-07.6"; diff --git a/extensions/code-tutor/test/smoke.test.mjs b/extensions/code-tutor/test/smoke.test.mjs index 677bb14..2fd02f9 100644 --- a/extensions/code-tutor/test/smoke.test.mjs +++ b/extensions/code-tutor/test/smoke.test.mjs @@ -367,7 +367,7 @@ try { await test("clear_cache with an out-of-enum level is rejected (does NOT nuke the concept)", async () => { const { body } = await post(open.url, "clear_cache", { conceptKey: "linear-search", level: "bogus" }); assert.equal(body.ok, false); - assert.match(body.message, /level must be one of/); + assert.match(body.message, /level.*must be one of/); // the concept's cached levels survive the rejected call const after = await post(open.url, "lookup_explanation", { conceptKey: "linear-search" }); assert.ok(after.body.result.cachedLevels.length > 0, "concept must not be deleted by a bad level"); diff --git a/extensions/language-tutor/canvas-kit/.kit-version.json b/extensions/language-tutor/canvas-kit/.kit-version.json index bf4f36d..a021440 100644 --- a/extensions/language-tutor/canvas-kit/.kit-version.json +++ b/extensions/language-tutor/canvas-kit/.kit-version.json @@ -1,5 +1,5 @@ { - "version": "2026-07-05.1", - "syncedAt": "2026-07-06T05:14:31.945Z", + "version": "2026-07-07.6", + "syncedAt": "2026-07-08T01:54:39.189Z", "source": "create-canvas-app/kit" } diff --git a/extensions/language-tutor/canvas-kit/client.mjs b/extensions/language-tutor/canvas-kit/client.mjs index 75e6e65..6f4cd9c 100644 --- a/extensions/language-tutor/canvas-kit/client.mjs +++ b/extensions/language-tutor/canvas-kit/client.mjs @@ -85,25 +85,21 @@ export function pollWhileVisible(tick, seconds, { whenVisible = true, immediate } /** - * Mount a canvas view and keep it live. - * @param {object} opts - * @param {(model:{state:any, invoke:Function, connected:boolean})=>any} opts.view - * Returns an htm/Preact vnode. Re-invoked on every state push. - * @param {HTMLElement} [opts.mount] defaults to #app or - * @param {(state:any)=>void} [opts.onState] - * @param {PollOptions} [opts.poll] built-in fixed-interval visibility-gated auto-refresh. - * For an interval bound to live state, use `pollWhileVisible` in a useEffect instead. - * @returns {{invoke:Function, refresh:Function, stopPoll:Function, get state():any}} + * DOM-FREE transport for a canvas: owns the loopback wiring (GET /state, GET + * /events SSE, POST /action) and the derived `state`/`connected`, with no Preact + * and no DOM. `mountCanvas` composes this with a render loop; keeping it separate + * makes the reconnect/invoke glue unit-testable without a browser (see + * test/client.test.mjs). Both callbacks receive the latest `(state, connected)`. + * @param {object} [opts] + * @param {(state:any, connected:boolean)=>void} [opts.onState] fired on the initial /state and every SSE push + * @param {(connected:boolean)=>void} [opts.onConnected] fired when the SSE stream opens/errors + * @param {typeof EventSource} [opts.EventSourceImpl] override the SSE impl (tests); defaults to the global + * @returns {{invoke:Function, refresh:Function, get state():any, get connected():boolean}} */ -export function mountCanvas({ view, mount, onState, poll } = {}) { - const root = mount || document.getElementById("app") || document.body; - // Preact's render() diffs against — but does not clear — pre-existing DOM in - // the container, so a static no-JS placeholder (e.g.

Loading…

in the - // HTML shell) would linger as a sibling. Clear it once so Preact owns an empty - // root; the view's own loading branch covers the gap until first state. - root.replaceChildren(); +export function connectCanvas({ onState, onConnected, EventSourceImpl } = {}) { let state = null; let connected = false; + const ES = EventSourceImpl || (typeof EventSource !== "undefined" ? EventSource : null); async function invoke(actionName, input) { const res = await fetch("./action", { @@ -118,20 +114,16 @@ export function mountCanvas({ view, mount, onState, poll } = {}) { return data.result; } - function rerender() { - render(view({ state, invoke, connected }), root); - } - async function refresh() { try { state = await (await fetch("./state")).json(); - onState?.(state); - rerender(); + onState?.(state, connected); } catch { /* offline; SSE will recover */ } } function connect() { - const es = new EventSource("./events"); + if (!ES) return; // no EventSource (e.g. a non-browser host); refresh() still works + const es = new ES("./events"); es.onmessage = (e) => { let next; try { @@ -139,20 +131,69 @@ export function mountCanvas({ view, mount, onState, poll } = {}) { } catch { return; // ignore a malformed SSE frame; the next push recovers } - // Update + render OUTSIDE the try so a bug in onState/the view surfaces as - // a real error instead of being silently mislabeled a "malformed frame". + // Update OUTSIDE the try so a bug in onState surfaces as a real error + // instead of being silently mislabeled a "malformed frame". state = next; connected = true; - onState?.(state); - rerender(); + onState?.(state, connected); }; - es.onopen = () => { connected = true; rerender(); }; - es.onerror = () => { connected = false; rerender(); /* EventSource auto-reconnects */ }; + es.onopen = () => { connected = true; onConnected?.(connected); }; + es.onerror = () => { connected = false; onConnected?.(connected); /* EventSource auto-reconnects */ }; } refresh(); connect(); + return { + invoke, + refresh, + get state() { return state; }, + get connected() { return connected; }, + }; +} + +/** + * Mount a canvas view and keep it live. + * @param {object} opts + * @param {(model:{state:any, invoke:Function, connected:boolean})=>any} opts.view + * Returns an htm/Preact vnode. Re-invoked on every state push. + * @param {HTMLElement} [opts.mount] defaults to #app or + * @param {(state:any)=>void} [opts.onState] + * @param {PollOptions} [opts.poll] built-in fixed-interval visibility-gated auto-refresh. + * For an interval bound to live state, use `pollWhileVisible` in a useEffect instead. + * @returns {{invoke:Function, refresh:Function, stopPoll:Function, get state():any}} + */ +export function mountCanvas({ view, mount, onState, poll } = {}) { + const root = mount || document.getElementById("app") || document.body; + // Preact's render() diffs against — but does not clear — pre-existing DOM in + // the container, so a static no-JS placeholder (e.g.

Loading…

in the + // HTML shell) would linger as a sibling. Clear it once so Preact owns an empty + // root; the view's own loading branch covers the gap until first state. + root.replaceChildren(); + + let latestState = null; + let latestConnected = false; + let client; + + function rerender() { + render(view({ state: latestState, invoke: client.invoke, connected: latestConnected }), root); + } + + // The transport is DOM-free (connectCanvas); this wrapper only adds the Preact + // render on each state/connection change. + client = connectCanvas({ + onState: (state, connected) => { + latestState = state; + latestConnected = connected; + onState?.(state); + rerender(); + }, + onConnected: (connected) => { + latestConnected = connected; + rerender(); + }, + }); + // Built-in fixed-interval auto-refresh, delegating to the shared // visibility-gated primitive. Pass `poll: { action, seconds, immediate }`. // @@ -164,7 +205,7 @@ export function mountCanvas({ view, mount, onState, poll } = {}) { // @property {boolean} [immediate=false] fire one tick right after mount function startPoll({ action, seconds, input, whenVisible = true, immediate = false } = {}) { return pollWhileVisible( - () => (action ? invoke(action, input) : refresh()), + () => (action ? client.invoke(action, input) : client.refresh()), seconds, { whenVisible, immediate } ); @@ -174,10 +215,10 @@ export function mountCanvas({ view, mount, onState, poll } = {}) { if (poll) stopPoll = startPoll(poll); return { - invoke, - refresh, + invoke: client.invoke, + refresh: client.refresh, stopPoll: () => stopPoll(), - get state() { return state; }, + get state() { return latestState; }, }; } diff --git a/extensions/language-tutor/canvas-kit/github-store.mjs b/extensions/language-tutor/canvas-kit/github-store.mjs new file mode 100644 index 0000000..d89a136 --- /dev/null +++ b/extensions/language-tutor/canvas-kit/github-store.mjs @@ -0,0 +1,215 @@ +// canvas-kit/github-store.mjs +// +// A SHARED, multi-writer durable store backed by a file in a GitHub repository. +// Where userStore/sessionStore/workspaceStore (storage.mjs) persist to local disk +// — private to one machine — githubStore persists the same JSON to a file in a +// repo via the Contents API, so every collaborator who can push to that repo edits +// ONE shared document. GitHub is the backing store AND the access-control layer +// (invite collaborators to a private repo); there is no server to run. +// +// It exposes the same { load, save } shape the runtime's loadState/saveState +// expect, plus poll() for cheap change-detection so a canvas can pull other +// people's edits live (wire it to server.mjs's syncState + syncIntervalMs). +// +// Concurrency: every write carries the blob sha it read (optimistic lock). A +// concurrent commit makes the PUT 409; save() re-reads to refresh the sha and +// retries. The default policy is last-writer-wins for the whole document; pass a +// merge(remoteState, myState) to resolve conflicts field-by-field instead (e.g. +// union a board's concerns by id). Reads use an ETag If-None-Match so an unchanged +// poll is a cheap 304 with no body. +// +// Token: resolved once from opts.token (string | async () => string), then +// GH_TOKEN / GITHUB_TOKEN, then `gh auth token`. Needs the `repo` scope for a +// private repo. The token is only ever sent as an Authorization header — never +// logged, never written to the repo. + +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; + +const execFileAsync = promisify(execFile); + +const API = "https://api.github.com"; +const UA = "canvas-kit-github-store"; +// Hard deadline for every GitHub API call so a hung request (stalled TLS, dropped +// connection) can't freeze a user action or dead-lock the sync loop (mirrors the +// AbortSignal.timeout pattern in net.mjs safeFetch). +const DEFAULT_TIMEOUT_MS = 15000; +// Refuse an implausibly large state file rather than allocate it on every poll. A +// board's JSON is tens of KB; this is a safety ceiling, not a real size limit. +const MAX_RESPONSE_BYTES = 8 * 1024 * 1024; +// Bounded save retries on an optimistic-lock conflict (409) before failing loud. +const MAX_SAVE_RETRIES = 3; + +// Resolve a GitHub token lazily and memoize it. A 401 (expired/rotated) clears the +// cache via invalidate() so the next call re-resolves. +function makeTokenResolver(tokenOpt) { + let cached = null; + async function fromGhCli() { + try { + const { stdout } = await execFileAsync("gh", ["auth", "token"], { windowsHide: true }); + return String(stdout).trim() || null; + } catch { + return null; // gh missing or not logged in → fall through to the no-token error + } + } + return { + async get() { + if (cached) return cached; + let t = null; + if (typeof tokenOpt === "function") t = await tokenOpt(); + else if (typeof tokenOpt === "string" && tokenOpt) t = tokenOpt; + if (!t) t = process.env.GH_TOKEN || process.env.GITHUB_TOKEN || null; + if (!t) t = await fromGhCli(); + if (!t) throw new Error("githubStore: no GitHub token (set GH_TOKEN or run `gh auth login`)"); + cached = t; + return cached; + }, + invalidate() { cached = null; }, + }; +} + +function encodePath(path) { + // Keep the slashes as path separators in the Contents API URL; encode each + // segment so spaces / unicode in a filename can't break the request. + return String(path).split("/").filter(Boolean).map(encodeURIComponent).join("/"); +} + +/** + * A GitHub-repo-backed durable store: one JSON file, many writers. + * @param {object} opts + * @param {string} opts.owner repo owner (user or org) + * @param {string} opts.repo repo name + * @param {string} opts.path path to the JSON file in the repo (e.g. "state/board.json") + * @param {string} [opts.branch="main"] + * @param {string|(()=>Promise|string)} [opts.token] token or async resolver + * @param {(remote:any,mine:any)=>any} [opts.merge] conflict resolver (default: last-writer-wins) + * @param {(state:any)=>string} [opts.message] commit message from the state (default: a timestamp) + * @param {string} [opts.apiBase=API] + * @returns {{file:string, load:(fallback?:any)=>Promise, poll:()=>Promise<{changed:boolean,state?:any}>, save:(state:any)=>Promise}} + */ +export function githubStore(opts) { + const { owner, repo, path, branch = "main", token, merge, message, apiBase = API } = opts ?? {}; + if (!owner || !repo || !path) throw new Error("githubStore: owner, repo and path are required"); + // apiBase is a trusted, fixed host by design — GitHub's public API by default, or a + // GitHub Enterprise host the canvas author sets at construction (never runtime + // input). That's why we call fetch() directly rather than the kit's safeFetch SSRF + // guard, which would add a DNS-resolution check on every call for a host that can't + // vary per request. Still, require https for any custom apiBase as defense-in-depth. + if (apiBase !== API) { + let base; + try { base = new URL(apiBase); } catch { throw new Error("githubStore: apiBase must be a valid URL"); } + if (base.protocol !== "https:") throw new Error("githubStore: apiBase must be https"); + } + + const tok = makeTokenResolver(token); + const contentsUrl = `${apiBase}/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/contents/${encodePath(path)}`; + const label = `${owner}/${repo}:${path}`; + + let sha = null; // last-seen blob sha — sent on write as the optimistic lock + let etag = null; // last-seen ETag — sent on poll as If-None-Match + let lastText = null; // last-seen decoded content — guards against no-op broadcasts + + async function api(url, init = {}, allow = []) { + const t = await tok.get(); + const res = await fetch(url, { + ...init, + signal: AbortSignal.timeout(DEFAULT_TIMEOUT_MS), + headers: { + Authorization: `Bearer ${t}`, + Accept: "application/vnd.github+json", + "X-GitHub-Api-Version": "2022-11-28", + "User-Agent": UA, + ...(init.headers ?? {}), + }, + }); + // An expired token surfaces as 401 — drop the memoized token so a retry can + // re-resolve (e.g. after `gh auth refresh`), then surface the failure. + if (res.status === 401) { tok.invalidate(); } + // ok, or an expected status the caller handles (404 fresh-file, 304 unchanged, + // 409 write conflict), pass through; anything else is a hard error. + if (res.ok || allow.includes(res.status)) return res; + const body = await res.text().catch(() => ""); + throw new Error(`githubStore ${label}: ${init.method ?? "GET"} ${res.status} ${body.slice(0, 200)}`); + } + + function decode(json) { + // Contents API returns base64 (wrapped at 60 cols); Buffer handles the newlines. + return Buffer.from(json.content ?? "", "base64").toString("utf8"); + } + + async function readJson(res) { + // Bound the allocation: refuse an oversized response before reading it, so a + // huge state file can't be pulled into memory on every poll tick. + const len = Number(res.headers.get("content-length") || 0); + if (len > MAX_RESPONSE_BYTES) { + throw new Error(`githubStore ${label}: response too large (${len} bytes)`); + } + return res.json(); + } + + async function load(fallback = null) { + const res = await api(`${contentsUrl}?ref=${encodeURIComponent(branch)}`, {}, [404]); + if (res.status === 404) { sha = null; etag = null; lastText = null; return fallback; } + const json = await readJson(res); + sha = json.sha ?? null; + etag = res.headers.get("etag"); + const text = decode(json); + lastText = text; + return text.trim() ? JSON.parse(text) : fallback; + } + + async function poll() { + const headers = etag ? { "If-None-Match": etag } : {}; + const res = await api(`${contentsUrl}?ref=${encodeURIComponent(branch)}`, { headers }, [304, 404]); + if (res.status === 304) return { changed: false }; + if (res.status === 404) { + if (sha === null && lastText === null) return { changed: false }; + sha = null; etag = null; lastText = null; + return { changed: true, state: null }; + } + const json = await readJson(res); + const text = decode(json); + sha = json.sha ?? null; + etag = res.headers.get("etag"); + if (text === lastText) return { changed: false }; + lastText = text; + return { changed: true, state: text.trim() ? JSON.parse(text) : null }; + } + + async function save(state) { + let toWrite = state; + for (let attempt = 0; ; attempt++) { + const text = JSON.stringify(toWrite, null, 2); + const body = { + message: (message ? message(toWrite) : `Update ${path}`) || `Update ${path}`, + content: Buffer.from(text, "utf8").toString("base64"), + branch, + ...(sha ? { sha } : {}), + }; + const res = await api(contentsUrl, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }, [409, 404]); + if (res.ok) { + const json = await res.json(); + sha = json.content?.sha ?? null; + etag = null; // PUT's ETag isn't the content GET's — force a fresh poll baseline + lastText = text; // our own write must not read back as a change + return; + } + // 409 (or a 404 if the file/branch vanished): the sha we held is stale. + // Re-read to refresh sha, optionally merge the remote with our intended + // write, and retry. Bounded so a persistent conflict fails loudly. + if ((res.status === 409 || res.status === 404) && attempt < MAX_SAVE_RETRIES) { + const remote = await load(null); + toWrite = merge ? merge(remote, state) : state; // default: last-writer-wins + continue; + } + const errBody = await res.text().catch(() => ""); + throw new Error(`githubStore ${label}: save failed ${res.status} ${errBody.slice(0, 200)}`); + } + } + + return { file: label, load, poll, save }; +} diff --git a/extensions/language-tutor/canvas-kit/icons.mjs b/extensions/language-tutor/canvas-kit/icons.mjs index 9dd30ea..04eb2e9 100644 --- a/extensions/language-tutor/canvas-kit/icons.mjs +++ b/extensions/language-tutor/canvas-kit/icons.mjs @@ -23,7 +23,18 @@ const VIEWBOX = "0 0 24 24"; const STROKE_WIDTH = 2; function resolve(name) { - return LUCIDE[name] || LUCIDE[aliases[name]] || null; + // Own-property lookups only. Bracket access on a plain object reaches inherited + // Object.prototype members, so a name like "toString"/"constructor"/ + // "hasOwnProperty" would otherwise resolve to a function (making hasIcon() lie + // and Icon()/lucideSVG() throw in nodeToString). Object.hasOwn keeps resolution + // to the real, vendored icon set. + if (typeof name !== "string") return null; + if (Object.hasOwn(LUCIDE, name)) return LUCIDE[name]; + if (Object.hasOwn(aliases, name)) { + const target = aliases[name]; + if (Object.hasOwn(LUCIDE, target)) return LUCIDE[target]; + } + return null; } function kebab(k) { diff --git a/extensions/language-tutor/canvas-kit/net.mjs b/extensions/language-tutor/canvas-kit/net.mjs new file mode 100644 index 0000000..fc1618c --- /dev/null +++ b/extensions/language-tutor/canvas-kit/net.mjs @@ -0,0 +1,126 @@ +// canvas-kit/net.mjs +// +// Server-side network safety for canvases that fetch external data. A canvas +// action runs on the loopback runtime with the app's network reach, so a +// caller-influenced URL is an SSRF risk: it could target cloud metadata +// (169.254.169.254), loopback, or the private network. This module is the kit's +// sanctioned egress primitive — validate a URL, then fetch it with a hard +// timeout — so every data canvas guards the same way instead of re-inlining the +// check. SERVER-ONLY (uses node:dns / node:net): import it from the SDK-free +// canvas.mjs, never from the browser view. +// +// Defense-in-depth, not a guarantee: a determined attacker could DNS-rebind +// between the resolve check and the connect. For a canvas pointed at a source +// you choose this is adequate; treat any fetched content as untrusted and render +// it as TEXT (never innerHTML). + +import { lookup } from "node:dns/promises"; +import { isIP } from "node:net"; + +/** + * True for addresses a server-side fetch should never reach: loopback, + * link-local (incl. cloud metadata 169.254.169.254), and private/CGNAT ranges. + * @param {string} ip + * @returns {boolean} + */ +export function isBlockedAddress(ip) { + const lower = ip.toLowerCase(); + // Decode an IPv4-mapped/compatible IPv6 literal down to its embedded IPv4 so + // the IPv4 range checks apply. Covers BOTH the dotted tail (::ffff:127.0.0.1) + // and the HEX tail that `new URL()` normalizes literals to (::ffff:7f00:1) — + // without the decode, `[::ffff:127.0.0.1]` reaches loopback and + // `[::ffff:a9fe:a9fe]` reaches cloud metadata. Only matches when the high bits + // are all zero (leading "::"), so a normal public v6 (e.g. 2001:db8::7f00:1) is + // never misread as IPv4. + const addr = embeddedIPv4(lower) ?? ip; + if (isIP(addr) === 4) { + const [a, b] = addr.split(".").map(Number); + if (a === 0 || a === 127 || a === 10) return true; // this-host / loopback / private + if (a === 169 && b === 254) return true; // link-local (incl. cloud IMDS) + if (a === 172 && b >= 16 && b <= 31) return true; // private + if (a === 192 && b === 168) return true; // private + if (a === 100 && b >= 64 && b <= 127) return true; // CGNAT + return false; + } + return lower === "::1" || lower === "::" || lower.startsWith("fe80") || lower.startsWith("fc") || lower.startsWith("fd"); +} + +// Decode an IPv4-mapped/compatible IPv6 literal (all-zero high groups, i.e. a +// leading "::", optionally "::ffff:") to its dotted IPv4; else null. Accepts the +// dotted tail (127.0.0.1) and the two-hex-group tail (7f00:1) that URL +// normalization produces. Anchored on "::" so it can't misread a public v6. +function embeddedIPv4(addr) { + const m = addr.match(/^::(?:ffff:)?((?:\d{1,3}\.){3}\d{1,3}|[0-9a-f]{1,4}:[0-9a-f]{1,4})$/); + if (!m) return null; + const tail = m[1]; + if (tail.includes(".")) return isIP(tail) === 4 ? tail : null; + const [h1, h2] = tail.split(":"); + const hi = parseInt(h1, 16), lo = parseInt(h2, 16); + return `${hi >> 8}.${hi & 255}.${lo >> 8}.${lo & 255}`; +} + +/** + * Allow only http/https to a PUBLIC host. Rejects every address the hostname + * resolves to, so a public name can't be pointed at an internal IP. Throws on a + * blocked/invalid URL; resolves to void when the URL is safe to fetch. + * @param {string} url + */ +export async function assertPublicUrl(url) { + let u; + try { u = new URL(url); } catch { throw new Error("Invalid source URL"); } + if (u.protocol !== "http:" && u.protocol !== "https:") { + throw new Error("Blocked URL protocol: " + u.protocol); + } + let host = u.hostname; + if (host.startsWith("[") && host.endsWith("]")) host = host.slice(1, -1); // IPv6 brackets + if (host.toLowerCase() === "localhost") throw new Error("Blocked host: localhost"); + const addrs = isIP(host) ? [host] : (await lookup(host, { all: true })).map((r) => r.address); + if (!addrs.length) throw new Error("Could not resolve host: " + host); + for (const ip of addrs) { + if (isBlockedAddress(ip)) throw new Error("Blocked private/loopback address: " + ip); + } +} + +/** + * SSRF-guarded fetch with a mandatory timeout. Runs assertPublicUrl on the URL + * and on EVERY redirect hop, so a chosen public host can't 30x-redirect the + * request into loopback/metadata/private space. Returns the final Response as-is + * (does NOT throw on a non-2xx status — the caller checks res.ok), so it is a + * drop-in for a guarded fetch(). + * + * Redirects are followed MANUALLY (redirect:"manual") rather than by fetch's + * default redirect:"follow": the default would chase a 3xx Location without + * re-running the guard, which silently undoes every check in this module. Here + * each hop's target is re-validated before we connect to it. + * @param {string} url + * @param {object} [opts] + * @param {number} [opts.timeoutMs=12000] abort the WHOLE operation (all hops) after this many ms + * @param {number} [opts.maxRedirects=5] how many 3xx hops to follow before giving up + * @param {object} [opts.headers] request headers (merged as-is) + * @param {RequestInit} [opts.rest] any other fetch init (method, body, …) + * @returns {Promise} + */ +export async function safeFetch(url, { timeoutMs = 12000, maxRedirects = 5, headers, ...rest } = {}) { + // One timeout signal bounds the entire operation, redirects included, so a + // chain of slow hops can't multiply the deadline. + const signal = AbortSignal.timeout(timeoutMs); + let current = url; + for (let hop = 0; ; hop++) { + await assertPublicUrl(current); + const res = await fetch(current, { + ...rest, + headers: headers ?? {}, + redirect: "manual", + signal, + }); + if (res.status >= 300 && res.status < 400 && res.headers.has("location")) { + if (hop >= maxRedirects) throw new Error("Too many redirects"); + const next = new URL(res.headers.get("location"), current).href; + // Discard the redirect body so the connection can be freed before the next hop. + try { await res.body?.cancel(); } catch { /* no body / already consumed */ } + current = next; + continue; + } + return res; + } +} diff --git a/extensions/language-tutor/canvas-kit/server.mjs b/extensions/language-tutor/canvas-kit/server.mjs index bf3a07f..eb533c9 100644 --- a/extensions/language-tutor/canvas-kit/server.mjs +++ b/extensions/language-tutor/canvas-kit/server.mjs @@ -20,6 +20,7 @@ import { createServer } from "node:http"; import { readFile } from "node:fs/promises"; import { join, normalize, extname, sep } from "node:path"; import { fileURLToPath } from "node:url"; +import { validate } from "./validate.mjs"; const KIT_DIR = fileURLToPath(new URL(".", import.meta.url)); @@ -52,7 +53,10 @@ export class CanvasKitError extends Error { * @param {(ctx:object)=>any|Promise} [config.createInitialState] * @param {(domainId:string)=>any|Promise} [config.loadState] * @param {(domainId:string, state:any)=>void|Promise} [config.saveState] + * @param {(domainId:string)=>{changed:boolean,state?:any}|Promise<{changed:boolean,state?:any}>} [config.syncState] optional delta-poll of a SHARED durable source (e.g. githubStore.poll); when set with syncIntervalMs, remote changes are adopted + broadcast to viewers live + * @param {number} [config.syncIntervalMs] poll cadence for syncState; only polled while a domain has >=1 connected viewer * @param {Record} config.actions + * @param {object} [config.stateSchema] optional JSON-Schema-subset for the durable state; when set, a mutation that violates it is rolled back and fails (500) * @param {string} config.assetsDir absolute path to the canvas web/ folder * @param {(ctx:object,state:any)=>string} [config.statusLine] */ @@ -112,14 +116,110 @@ export function createCanvasRuntime(config) { } } + // ---- optional shared-state sync (repo-backed multiplayer) ---------------- + // When config.syncState + config.syncIntervalMs are set, poll the durable + // source on an interval and adopt+broadcast remote changes, so collaborators + // editing a SHARED store (e.g. githubStore) see each other's edits live. We + // only poll a domain while at least one SSE client is watching it — no viewers, + // no network — so API usage stays proportional to real use. syncState returns + // { changed:boolean, state? }; a cheap unchanged poll (ETag 304) is changed:false. + const syncing = new Set(); // domainIds with an in-flight poll (prevents overlap) + let syncTimer = null; + + function domainHasViewers(domainId) { + for (const inst of instances.values()) { + if (inst.domainId === domainId && inst.clients.size > 0) return true; + } + return false; + } + + async function syncDomain(domainId) { + if (syncing.has(domainId)) return; // last tick still in flight — skip + syncing.add(domainId); + try { + const out = await config.syncState(domainId); + // Adopt only a real, non-null remote state. A null (file deleted upstream) + // is ignored so a transient upstream gap can't blank a live board. + if (out?.changed && out.state != null) { + const d = domains.get(domainId); + if (d) { + // Adopted remote state must clear the SAME stateSchema gate the invoke() + // path enforces. A collaborator (or a hand-edit on github.com) can push a + // shape that violates the schema; adopting it unvalidated would broadcast a + // corrupt shape to every viewer AND poison the next invoke()'s rollback + // baseline (structuredClone would snapshot the already-invalid state). + // Reject (skip) an invalid remote instead — the next valid write reconciles. + if (config.stateSchema && validate(config.stateSchema, out.state, "sync-remote").length) { + return; + } + d.state = out.state; + broadcast(domainId); + } + } + } catch { + // A transient network/API error must not kill the timer; retry next tick. + } finally { + syncing.delete(domainId); + } + } + + function syncTick() { + for (const domainId of domains.keys()) { + if (domainHasViewers(domainId)) syncDomain(domainId); + } + } + + // Floor the poll cadence: a mistakenly tiny interval (e.g. 1ms) would hammer the + // durable source — for githubStore that means burning the GitHub API rate limit in + // seconds. 2s is well below any real collaboration-latency need. + const MIN_SYNC_INTERVAL_MS = 2000; + + function startSync() { + if (syncTimer || typeof config.syncState !== "function") return; + const requested = Number(config.syncIntervalMs) || 0; + if (requested <= 0) return; + const ms = Math.max(requested, MIN_SYNC_INTERVAL_MS); + syncTimer = setInterval(syncTick, ms); + syncTimer.unref?.(); // never keep the process alive just to poll + } + + function stopSync() { + if (syncTimer) { clearInterval(syncTimer); syncTimer = null; } + } + // Core action invoker — the single code path behind both agent and UI actions. async function invoke(actionName, input, ctx) { - const action = config.actions[actionName]; + // Own-property lookup: `config.actions[actionName]` via bracket access would + // otherwise reach inherited members (e.g. "constructor", "toString"). The + // handler-typeof check below already rejects those, but resolving by + // Object.hasOwn keeps the boundary explicit and consistent with validate.mjs. + const action = + typeof actionName === "string" && Object.hasOwn(config.actions, actionName) + ? config.actions[actionName] + : null; if (!action || typeof action.handler !== "function") { throw new CanvasKitError("unknown_action", `Unknown action: ${actionName}`); } + // Enforce the declared inputSchema at the boundary (agent OR ui). The schema + // is a contract authors already write; validating it here turns "declared but + // unchecked" into a typed boundary and stops a malformed/typo'd payload from + // reaching the handler. A shape violation is the CALLER's fault → invalid_input + // (HTTP 400). Business rules ("title can't be blank") still live in the handler + // and surface as a 500, so a schema-valid-but-empty string reaches the handler. + if (action.inputSchema) { + const errs = validate(action.inputSchema, input ?? {}, "input"); + if (errs.length) { + throw new CanvasKitError("invalid_input", `Invalid input for '${actionName}': ${errs.join("; ")}`); + } + } const domainId = ctx?.domainId ?? "default"; const d = await getDomain(domainId, ctx); + // Deep snapshot for stateSchema rollback: a handler may mutate state IN PLACE + // and return the same object, so a reference copy (prevState = d.state) would + // point at the same (now-corrupt) object and restore nothing. structuredClone + // gives a real pre-mutation copy. Durable state is JSON-shaped, so it clones + // cleanly. Only pay the clone when a stateSchema is actually configured. + const prevState = config.stateSchema ? structuredClone(d.state) : undefined; let mutated = false; const api = { get state() { return d.state; }, @@ -161,6 +261,17 @@ export function createCanvasRuntime(config) { }; const result = await action.handler(api); if (mutated) { + // Optional stateSchema guards the durable shape: if a handler produced an + // invalid state, roll back the in-memory mutation and fail LOUD (a 500 — + // this is a handler bug, not caller input) instead of persisting/broadcasting + // corrupt state. Absent stateSchema, anything goes (opt-in). + if (config.stateSchema) { + const errs = validate(config.stateSchema, d.state, "state"); + if (errs.length) { + d.state = prevState; // roll back so in-memory stays consistent + throw new Error(`Action '${actionName}' produced invalid state: ${errs.join("; ")}`); + } + } if (config.saveState) await config.saveState(domainId, d.state); broadcast(domainId); } @@ -202,6 +313,12 @@ export function createCanvasRuntime(config) { // local client can't force unbounded memory growth on the loopback runtime. const MAX_BODY_BYTES = 1 << 20; // 1 MiB + // Cap concurrent SSE subscribers PER INSTANCE. A canvas panel needs only one + // /events stream (a couple across reopens); a runaway reconnect loop or a + // hostile local process hitting the loopback port could otherwise accumulate + // unbounded response handles + keep-alive timers. 64 is far above any real use. + const MAX_SSE_CLIENTS = 64; + function readBody(req) { return new Promise((resolve, reject) => { const chunks = []; @@ -240,6 +357,13 @@ export function createCanvasRuntime(config) { // GET /events — Server-Sent Events stream of state if (req.method === "GET" && path === "/events") { + // Refuse once an instance is saturated, so subscribers can't grow without + // bound (each holds a response handle + a keep-alive interval). + if (inst && inst.clients.size >= MAX_SSE_CLIENTS) { + res.writeHead(503, { "Content-Type": "text/plain", "Retry-After": "5" }); + res.end("too many event subscribers"); + return; + } res.writeHead(200, { "Content-Type": "text/event-stream", "Cache-Control": "no-cache", @@ -307,6 +431,15 @@ export function createCanvasRuntime(config) { * @returns {Promise<{url:string,title:string,status?:string}>} */ async function openInstance({ instanceId, input, ctx }) { + // Validate the open input against the declared inputSchema (same contract the + // actions get). A bad open payload fails fast with invalid_input rather than + // silently resolving the wrong domain. + if (config.inputSchema) { + const errs = validate(config.inputSchema, input ?? {}, "open input"); + if (errs.length) { + throw new CanvasKitError("invalid_input", `Invalid open input: ${errs.join("; ")}`); + } + } const domainId = config.resolveDomainId ? config.resolveDomainId(input ?? {}, ctx ?? {}) || "default" : "default"; @@ -341,6 +474,7 @@ export function createCanvasRuntime(config) { } async function shutdown() { + stopSync(); await Promise.all([...instances.keys()].map(closeInstance)); } @@ -348,6 +482,8 @@ export function createCanvasRuntime(config) { return (await getDomain(domainId)).state; } + startSync(); // no-op unless config.syncState + syncIntervalMs are set + return { config, setHost, @@ -358,5 +494,6 @@ export function createCanvasRuntime(config) { invokeFromAgent, // agent-side, resolves domain from ctx getState, _instances: instances, + _syncDomain: syncDomain, // manual one-shot sync (tests) }; } diff --git a/extensions/language-tutor/canvas-kit/storage.mjs b/extensions/language-tutor/canvas-kit/storage.mjs index 355983c..3f1177c 100644 --- a/extensions/language-tutor/canvas-kit/storage.mjs +++ b/extensions/language-tutor/canvas-kit/storage.mjs @@ -2,18 +2,61 @@ // // Durable JSON state helpers. State is keyed by a *domain id* (a stable logical // identifier resolved from the open input), never by instanceId — per -// create-canvas/SKILL.md. Two scopes: +// create-canvas/SKILL.md. Three tiers, matching the Canvas SDK storage model: // userStore -> $COPILOT_HOME/extensions//artifacts/ (per user, cross-session) -// workspaceStore -> / (per session) +// sessionStore -> $COPILOT_HOME/session-state//extensions// (per session, scratch) +// workspaceStore -> / (rooted at the session workspace) +// +// Sandboxing: callers derive the from a domain id that MUST be sanitized +// to a bare filename (the reference `fileFor` strips everything but +// [A-Za-z0-9._-]); the store never joins caller input as a path, so a domain id +// can't escape its extension's artifacts directory. -import { readFile, writeFile, mkdir, rename } from "node:fs/promises"; +import { readFile, writeFile, mkdir, rename, unlink } from "node:fs/promises"; import { join, dirname } from "node:path"; import { homedir } from "node:os"; +import { randomBytes } from "node:crypto"; function copilotHome() { return process.env.COPILOT_HOME || join(homedir(), ".copilot"); } +// Per-file write queue. Concurrent saves to the SAME durable file (an agent +// action and a UI action racing) must not overlap: on Windows two renames to the +// same destination collide with EPERM even with unique temp names. Chaining each +// save onto the previous one for that path serializes them (last enqueued wins), +// which is both correct (no interleaved writes) and portable. +const writeQueues = new Map(); // absolute file path -> tail Promise + +async function atomicWrite(file, data) { + await mkdir(dirname(file), { recursive: true }); + // Write to a temp sibling then atomically rename into place, so a crash or + // interruption mid-write can never truncate the existing durable file. + // rename(2) is atomic on the same filesystem. The temp name mixes pid + time + + // RANDOM bytes so two writers can never pick the same temp path. + const tmp = `${file}.${process.pid}.${Date.now()}.${randomBytes(6).toString("hex")}.tmp`; + await writeFile(tmp, JSON.stringify(data, null, 2), "utf8"); + try { + await rename(tmp, file); + } catch (err) { + // Windows can transiently EPERM/EACCES a rename when the destination is + // briefly held (AV scanner, indexer). Retry once after a short beat; always + // clean up our temp so a failed save leaves no orphaned *.tmp behind. + if (err?.code === "EPERM" || err?.code === "EACCES") { + await new Promise((r) => setTimeout(r, 25)); + try { + await rename(tmp, file); + } catch (err2) { + await unlink(tmp).catch(() => {}); + throw err2; + } + } else { + await unlink(tmp).catch(() => {}); + throw err; + } + } +} + function makeStore(file) { return { file, @@ -32,13 +75,15 @@ function makeStore(file) { } }, async save(data) { - await mkdir(dirname(file), { recursive: true }); - // Write to a temp sibling then atomically rename into place, so a crash or - // interruption mid-write can never truncate the existing durable file. - // rename(2) is atomic on the same filesystem. - const tmp = `${file}.${process.pid}.${Date.now()}.tmp`; - await writeFile(tmp, JSON.stringify(data, null, 2), "utf8"); - await rename(tmp, file); + // Serialize behind any in-flight save for this exact path (see writeQueues). + const prev = writeQueues.get(file) ?? Promise.resolve(); + const run = prev.catch(() => {}).then(() => atomicWrite(file, data)); + writeQueues.set(file, run); + try { + await run; + } finally { + if (writeQueues.get(file) === run) writeQueues.delete(file); + } }, }; } @@ -48,6 +93,24 @@ export function userStore(extensionName, fileName) { return makeStore(join(copilotHome(), "extensions", extensionName, "artifacts", fileName)); } +/** + * Per-session scratch store for a named extension, rooted under the session's + * state directory. Use for state that should NOT outlive the session (drafts, + * one-off working data). Pass the session id (e.g. from the SDK ctx). + */ +export function sessionStore(sessionId, extensionName, fileName) { + // Reduce the session id to a single safe path segment. The charset filter keeps + // "." (so dotted ids survive), so we must ALSO collapse any ".." run — otherwise + // a session id of ".." would join to one level ABOVE session-state and escape the + // per-session root. (sessionId is normally a trusted SDK value; this keeps the + // sanitizer honest regardless.) + const safeSession = + String(sessionId) + .replace(/[^A-Za-z0-9._-]/g, "_") + .replace(/\.\.+/g, "_") || "default"; + return makeStore(join(copilotHome(), "session-state", safeSession, "extensions", extensionName, fileName)); +} + /** Per-session store rooted at the session workspace path. */ export function workspaceStore(workspacePath, fileName) { return makeStore(join(workspacePath, fileName)); diff --git a/extensions/language-tutor/canvas-kit/validate.mjs b/extensions/language-tutor/canvas-kit/validate.mjs new file mode 100644 index 0000000..d44925e --- /dev/null +++ b/extensions/language-tutor/canvas-kit/validate.mjs @@ -0,0 +1,146 @@ +// canvas-kit/validate.mjs +// +// A tiny, dependency-free JSON-Schema-*subset* validator. It exists so the +// runtime can ENFORCE the action `inputSchema` (and an optional `stateSchema`) +// that authors already declare — turning the iframe↔extension / agent↔extension +// contract from "declared but unchecked" into "validated at the boundary". No +// npm dependency (the kit ships vendored, no install step), and it only covers +// the JSON Schema features the kit's schemas actually use: +// +// type "object" | "array" | "string" | "number" | "integer" | +// "boolean" | "null" (or an array of those) +// properties per-key subschemas (objects) +// required array of required property names (objects) +// additionalProperties false | subschema (objects) +// items subschema for every element (arrays) +// enum allowed literal values (deep-equal) +// minLength/maxLength string length bounds +// minimum/maximum numeric bounds +// minItems/maxItems array length bounds +// +// It is intentionally forgiving: an absent/empty schema validates anything, and +// unknown keywords are ignored (so a richer schema never hard-fails here). The +// goal is to catch the shape mistakes that actually break canvases — wrong type, +// missing required field, unknown/typo'd property, out-of-enum value — not to be +// a complete JSON Schema implementation. + +function typeOf(value) { + if (value === null) return "null"; + if (Array.isArray(value)) return "array"; + if (Number.isInteger(value)) return "integer"; + return typeof value; // "string" | "number" | "boolean" | "object" | "undefined" +} + +// Does `value` satisfy a single JSON Schema `type` token? "number" accepts +// integers; "integer" requires a whole number. +function matchesType(value, t) { + const actual = typeOf(value); + if (t === "number") return actual === "number" || actual === "integer"; + if (t === "integer") return actual === "integer"; + return actual === t; +} + +function deepEqual(a, b) { + if (a === b) return true; + if (typeof a !== typeof b || a === null || b === null) return false; + if (Array.isArray(a) && Array.isArray(b)) { + return a.length === b.length && a.every((x, i) => deepEqual(x, b[i])); + } + if (typeof a === "object" && typeof b === "object") { + const ak = Object.keys(a), bk = Object.keys(b); + return ak.length === bk.length && ak.every((k) => deepEqual(a[k], b[k])); + } + return false; +} + +/** + * Validate `value` against a JSON-Schema-subset `schema`. + * @param {object|undefined} schema + * @param {any} value + * @param {string} [path] dotted path used in error messages (default "input") + * @returns {string[]} human-readable error messages; empty array = valid. + */ +export function validate(schema, value, path = "input") { + const errors = []; + walk(schema, value, path, errors); + return errors; +} + +function walk(schema, value, path, errors) { + if (!schema || typeof schema !== "object") return; // absent/degenerate schema = anything goes + + // type (single token or array of tokens) + if (schema.type !== undefined) { + const types = Array.isArray(schema.type) ? schema.type : [schema.type]; + if (!types.some((t) => matchesType(value, t))) { + errors.push(`${path}: expected ${types.join(" | ")}, got ${typeOf(value)}`); + return; // a wrong base type makes deeper checks meaningless + } + } + + // enum + if (Array.isArray(schema.enum) && !schema.enum.some((allowed) => deepEqual(allowed, value))) { + errors.push(`${path}: must be one of ${schema.enum.map((v) => JSON.stringify(v)).join(", ")}`); + } + + const kind = typeOf(value); + + if (kind === "string") { + if (typeof schema.minLength === "number" && value.length < schema.minLength) { + errors.push(`${path}: must be at least ${schema.minLength} characters`); + } + if (typeof schema.maxLength === "number" && value.length > schema.maxLength) { + errors.push(`${path}: must be at most ${schema.maxLength} characters`); + } + } + + if (kind === "number" || kind === "integer") { + if (typeof schema.minimum === "number" && value < schema.minimum) { + errors.push(`${path}: must be >= ${schema.minimum}`); + } + if (typeof schema.maximum === "number" && value > schema.maximum) { + errors.push(`${path}: must be <= ${schema.maximum}`); + } + } + + if (kind === "array") { + if (typeof schema.minItems === "number" && value.length < schema.minItems) { + errors.push(`${path}: must have at least ${schema.minItems} item(s)`); + } + if (typeof schema.maxItems === "number" && value.length > schema.maxItems) { + errors.push(`${path}: must have at most ${schema.maxItems} item(s)`); + } + if (schema.items) { + value.forEach((item, i) => walk(schema.items, item, `${path}[${i}]`, errors)); + } + } + + if (kind === "object") { + const props = schema.properties ?? {}; + if (Array.isArray(schema.required)) { + for (const key of schema.required) { + // Object.hasOwn (not `value[key] === undefined`): a required property + // named after a prototype member (e.g. "toString", "constructor") would + // otherwise read the inherited value and wrongly pass the check. + if (!Object.hasOwn(value, key)) errors.push(`${path}.${key}: required`); + } + } + for (const [key, sub] of Object.entries(props)) { + if (Object.hasOwn(value, key)) walk(sub, value[key], `${path}.${key}`, errors); + } + // Membership is tested with Object.hasOwn, NOT `key in props`: `in` walks the + // prototype chain, so an extra key named "toString"/"constructor"/"__proto__" + // etc. would satisfy `key in props` and escape additionalProperties. This + // validator is the enforcement boundary (input → 400, state → 500), so that + // would be a real contract hole. + if (schema.additionalProperties === false) { + for (const key of Object.keys(value)) { + if (!Object.hasOwn(props, key)) errors.push(`${path}.${key}: unexpected property`); + } + } else if (schema.additionalProperties && typeof schema.additionalProperties === "object") { + for (const key of Object.keys(value)) { + if (!Object.hasOwn(props, key)) walk(schema.additionalProperties, value[key], `${path}.${key}`, errors); + } + } + } +} diff --git a/extensions/language-tutor/canvas-kit/version.mjs b/extensions/language-tutor/canvas-kit/version.mjs index 93471a9..6bff1cc 100644 --- a/extensions/language-tutor/canvas-kit/version.mjs +++ b/extensions/language-tutor/canvas-kit/version.mjs @@ -10,4 +10,4 @@ // convention (the skill ships as files, not an npm version); a commit short-sha // works too. Re-exported from client.mjs so a canvas can read it at runtime. -export const KIT_VERSION = "2026-07-05.1"; +export const KIT_VERSION = "2026-07-07.6"; diff --git a/extensions/news-aggregator/canvas-kit/.kit-version.json b/extensions/news-aggregator/canvas-kit/.kit-version.json index 90bdd70..62593fb 100644 --- a/extensions/news-aggregator/canvas-kit/.kit-version.json +++ b/extensions/news-aggregator/canvas-kit/.kit-version.json @@ -1,5 +1,5 @@ { - "version": "2026-07-05.1", - "syncedAt": "2026-07-06T05:14:32.084Z", + "version": "2026-07-07.6", + "syncedAt": "2026-07-08T01:54:39.324Z", "source": "create-canvas-app/kit" } diff --git a/extensions/news-aggregator/canvas-kit/client.mjs b/extensions/news-aggregator/canvas-kit/client.mjs index 75e6e65..6f4cd9c 100644 --- a/extensions/news-aggregator/canvas-kit/client.mjs +++ b/extensions/news-aggregator/canvas-kit/client.mjs @@ -85,25 +85,21 @@ export function pollWhileVisible(tick, seconds, { whenVisible = true, immediate } /** - * Mount a canvas view and keep it live. - * @param {object} opts - * @param {(model:{state:any, invoke:Function, connected:boolean})=>any} opts.view - * Returns an htm/Preact vnode. Re-invoked on every state push. - * @param {HTMLElement} [opts.mount] defaults to #app or - * @param {(state:any)=>void} [opts.onState] - * @param {PollOptions} [opts.poll] built-in fixed-interval visibility-gated auto-refresh. - * For an interval bound to live state, use `pollWhileVisible` in a useEffect instead. - * @returns {{invoke:Function, refresh:Function, stopPoll:Function, get state():any}} + * DOM-FREE transport for a canvas: owns the loopback wiring (GET /state, GET + * /events SSE, POST /action) and the derived `state`/`connected`, with no Preact + * and no DOM. `mountCanvas` composes this with a render loop; keeping it separate + * makes the reconnect/invoke glue unit-testable without a browser (see + * test/client.test.mjs). Both callbacks receive the latest `(state, connected)`. + * @param {object} [opts] + * @param {(state:any, connected:boolean)=>void} [opts.onState] fired on the initial /state and every SSE push + * @param {(connected:boolean)=>void} [opts.onConnected] fired when the SSE stream opens/errors + * @param {typeof EventSource} [opts.EventSourceImpl] override the SSE impl (tests); defaults to the global + * @returns {{invoke:Function, refresh:Function, get state():any, get connected():boolean}} */ -export function mountCanvas({ view, mount, onState, poll } = {}) { - const root = mount || document.getElementById("app") || document.body; - // Preact's render() diffs against — but does not clear — pre-existing DOM in - // the container, so a static no-JS placeholder (e.g.

Loading…

in the - // HTML shell) would linger as a sibling. Clear it once so Preact owns an empty - // root; the view's own loading branch covers the gap until first state. - root.replaceChildren(); +export function connectCanvas({ onState, onConnected, EventSourceImpl } = {}) { let state = null; let connected = false; + const ES = EventSourceImpl || (typeof EventSource !== "undefined" ? EventSource : null); async function invoke(actionName, input) { const res = await fetch("./action", { @@ -118,20 +114,16 @@ export function mountCanvas({ view, mount, onState, poll } = {}) { return data.result; } - function rerender() { - render(view({ state, invoke, connected }), root); - } - async function refresh() { try { state = await (await fetch("./state")).json(); - onState?.(state); - rerender(); + onState?.(state, connected); } catch { /* offline; SSE will recover */ } } function connect() { - const es = new EventSource("./events"); + if (!ES) return; // no EventSource (e.g. a non-browser host); refresh() still works + const es = new ES("./events"); es.onmessage = (e) => { let next; try { @@ -139,20 +131,69 @@ export function mountCanvas({ view, mount, onState, poll } = {}) { } catch { return; // ignore a malformed SSE frame; the next push recovers } - // Update + render OUTSIDE the try so a bug in onState/the view surfaces as - // a real error instead of being silently mislabeled a "malformed frame". + // Update OUTSIDE the try so a bug in onState surfaces as a real error + // instead of being silently mislabeled a "malformed frame". state = next; connected = true; - onState?.(state); - rerender(); + onState?.(state, connected); }; - es.onopen = () => { connected = true; rerender(); }; - es.onerror = () => { connected = false; rerender(); /* EventSource auto-reconnects */ }; + es.onopen = () => { connected = true; onConnected?.(connected); }; + es.onerror = () => { connected = false; onConnected?.(connected); /* EventSource auto-reconnects */ }; } refresh(); connect(); + return { + invoke, + refresh, + get state() { return state; }, + get connected() { return connected; }, + }; +} + +/** + * Mount a canvas view and keep it live. + * @param {object} opts + * @param {(model:{state:any, invoke:Function, connected:boolean})=>any} opts.view + * Returns an htm/Preact vnode. Re-invoked on every state push. + * @param {HTMLElement} [opts.mount] defaults to #app or + * @param {(state:any)=>void} [opts.onState] + * @param {PollOptions} [opts.poll] built-in fixed-interval visibility-gated auto-refresh. + * For an interval bound to live state, use `pollWhileVisible` in a useEffect instead. + * @returns {{invoke:Function, refresh:Function, stopPoll:Function, get state():any}} + */ +export function mountCanvas({ view, mount, onState, poll } = {}) { + const root = mount || document.getElementById("app") || document.body; + // Preact's render() diffs against — but does not clear — pre-existing DOM in + // the container, so a static no-JS placeholder (e.g.

Loading…

in the + // HTML shell) would linger as a sibling. Clear it once so Preact owns an empty + // root; the view's own loading branch covers the gap until first state. + root.replaceChildren(); + + let latestState = null; + let latestConnected = false; + let client; + + function rerender() { + render(view({ state: latestState, invoke: client.invoke, connected: latestConnected }), root); + } + + // The transport is DOM-free (connectCanvas); this wrapper only adds the Preact + // render on each state/connection change. + client = connectCanvas({ + onState: (state, connected) => { + latestState = state; + latestConnected = connected; + onState?.(state); + rerender(); + }, + onConnected: (connected) => { + latestConnected = connected; + rerender(); + }, + }); + // Built-in fixed-interval auto-refresh, delegating to the shared // visibility-gated primitive. Pass `poll: { action, seconds, immediate }`. // @@ -164,7 +205,7 @@ export function mountCanvas({ view, mount, onState, poll } = {}) { // @property {boolean} [immediate=false] fire one tick right after mount function startPoll({ action, seconds, input, whenVisible = true, immediate = false } = {}) { return pollWhileVisible( - () => (action ? invoke(action, input) : refresh()), + () => (action ? client.invoke(action, input) : client.refresh()), seconds, { whenVisible, immediate } ); @@ -174,10 +215,10 @@ export function mountCanvas({ view, mount, onState, poll } = {}) { if (poll) stopPoll = startPoll(poll); return { - invoke, - refresh, + invoke: client.invoke, + refresh: client.refresh, stopPoll: () => stopPoll(), - get state() { return state; }, + get state() { return latestState; }, }; } diff --git a/extensions/news-aggregator/canvas-kit/github-store.mjs b/extensions/news-aggregator/canvas-kit/github-store.mjs new file mode 100644 index 0000000..d89a136 --- /dev/null +++ b/extensions/news-aggregator/canvas-kit/github-store.mjs @@ -0,0 +1,215 @@ +// canvas-kit/github-store.mjs +// +// A SHARED, multi-writer durable store backed by a file in a GitHub repository. +// Where userStore/sessionStore/workspaceStore (storage.mjs) persist to local disk +// — private to one machine — githubStore persists the same JSON to a file in a +// repo via the Contents API, so every collaborator who can push to that repo edits +// ONE shared document. GitHub is the backing store AND the access-control layer +// (invite collaborators to a private repo); there is no server to run. +// +// It exposes the same { load, save } shape the runtime's loadState/saveState +// expect, plus poll() for cheap change-detection so a canvas can pull other +// people's edits live (wire it to server.mjs's syncState + syncIntervalMs). +// +// Concurrency: every write carries the blob sha it read (optimistic lock). A +// concurrent commit makes the PUT 409; save() re-reads to refresh the sha and +// retries. The default policy is last-writer-wins for the whole document; pass a +// merge(remoteState, myState) to resolve conflicts field-by-field instead (e.g. +// union a board's concerns by id). Reads use an ETag If-None-Match so an unchanged +// poll is a cheap 304 with no body. +// +// Token: resolved once from opts.token (string | async () => string), then +// GH_TOKEN / GITHUB_TOKEN, then `gh auth token`. Needs the `repo` scope for a +// private repo. The token is only ever sent as an Authorization header — never +// logged, never written to the repo. + +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; + +const execFileAsync = promisify(execFile); + +const API = "https://api.github.com"; +const UA = "canvas-kit-github-store"; +// Hard deadline for every GitHub API call so a hung request (stalled TLS, dropped +// connection) can't freeze a user action or dead-lock the sync loop (mirrors the +// AbortSignal.timeout pattern in net.mjs safeFetch). +const DEFAULT_TIMEOUT_MS = 15000; +// Refuse an implausibly large state file rather than allocate it on every poll. A +// board's JSON is tens of KB; this is a safety ceiling, not a real size limit. +const MAX_RESPONSE_BYTES = 8 * 1024 * 1024; +// Bounded save retries on an optimistic-lock conflict (409) before failing loud. +const MAX_SAVE_RETRIES = 3; + +// Resolve a GitHub token lazily and memoize it. A 401 (expired/rotated) clears the +// cache via invalidate() so the next call re-resolves. +function makeTokenResolver(tokenOpt) { + let cached = null; + async function fromGhCli() { + try { + const { stdout } = await execFileAsync("gh", ["auth", "token"], { windowsHide: true }); + return String(stdout).trim() || null; + } catch { + return null; // gh missing or not logged in → fall through to the no-token error + } + } + return { + async get() { + if (cached) return cached; + let t = null; + if (typeof tokenOpt === "function") t = await tokenOpt(); + else if (typeof tokenOpt === "string" && tokenOpt) t = tokenOpt; + if (!t) t = process.env.GH_TOKEN || process.env.GITHUB_TOKEN || null; + if (!t) t = await fromGhCli(); + if (!t) throw new Error("githubStore: no GitHub token (set GH_TOKEN or run `gh auth login`)"); + cached = t; + return cached; + }, + invalidate() { cached = null; }, + }; +} + +function encodePath(path) { + // Keep the slashes as path separators in the Contents API URL; encode each + // segment so spaces / unicode in a filename can't break the request. + return String(path).split("/").filter(Boolean).map(encodeURIComponent).join("/"); +} + +/** + * A GitHub-repo-backed durable store: one JSON file, many writers. + * @param {object} opts + * @param {string} opts.owner repo owner (user or org) + * @param {string} opts.repo repo name + * @param {string} opts.path path to the JSON file in the repo (e.g. "state/board.json") + * @param {string} [opts.branch="main"] + * @param {string|(()=>Promise|string)} [opts.token] token or async resolver + * @param {(remote:any,mine:any)=>any} [opts.merge] conflict resolver (default: last-writer-wins) + * @param {(state:any)=>string} [opts.message] commit message from the state (default: a timestamp) + * @param {string} [opts.apiBase=API] + * @returns {{file:string, load:(fallback?:any)=>Promise, poll:()=>Promise<{changed:boolean,state?:any}>, save:(state:any)=>Promise}} + */ +export function githubStore(opts) { + const { owner, repo, path, branch = "main", token, merge, message, apiBase = API } = opts ?? {}; + if (!owner || !repo || !path) throw new Error("githubStore: owner, repo and path are required"); + // apiBase is a trusted, fixed host by design — GitHub's public API by default, or a + // GitHub Enterprise host the canvas author sets at construction (never runtime + // input). That's why we call fetch() directly rather than the kit's safeFetch SSRF + // guard, which would add a DNS-resolution check on every call for a host that can't + // vary per request. Still, require https for any custom apiBase as defense-in-depth. + if (apiBase !== API) { + let base; + try { base = new URL(apiBase); } catch { throw new Error("githubStore: apiBase must be a valid URL"); } + if (base.protocol !== "https:") throw new Error("githubStore: apiBase must be https"); + } + + const tok = makeTokenResolver(token); + const contentsUrl = `${apiBase}/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/contents/${encodePath(path)}`; + const label = `${owner}/${repo}:${path}`; + + let sha = null; // last-seen blob sha — sent on write as the optimistic lock + let etag = null; // last-seen ETag — sent on poll as If-None-Match + let lastText = null; // last-seen decoded content — guards against no-op broadcasts + + async function api(url, init = {}, allow = []) { + const t = await tok.get(); + const res = await fetch(url, { + ...init, + signal: AbortSignal.timeout(DEFAULT_TIMEOUT_MS), + headers: { + Authorization: `Bearer ${t}`, + Accept: "application/vnd.github+json", + "X-GitHub-Api-Version": "2022-11-28", + "User-Agent": UA, + ...(init.headers ?? {}), + }, + }); + // An expired token surfaces as 401 — drop the memoized token so a retry can + // re-resolve (e.g. after `gh auth refresh`), then surface the failure. + if (res.status === 401) { tok.invalidate(); } + // ok, or an expected status the caller handles (404 fresh-file, 304 unchanged, + // 409 write conflict), pass through; anything else is a hard error. + if (res.ok || allow.includes(res.status)) return res; + const body = await res.text().catch(() => ""); + throw new Error(`githubStore ${label}: ${init.method ?? "GET"} ${res.status} ${body.slice(0, 200)}`); + } + + function decode(json) { + // Contents API returns base64 (wrapped at 60 cols); Buffer handles the newlines. + return Buffer.from(json.content ?? "", "base64").toString("utf8"); + } + + async function readJson(res) { + // Bound the allocation: refuse an oversized response before reading it, so a + // huge state file can't be pulled into memory on every poll tick. + const len = Number(res.headers.get("content-length") || 0); + if (len > MAX_RESPONSE_BYTES) { + throw new Error(`githubStore ${label}: response too large (${len} bytes)`); + } + return res.json(); + } + + async function load(fallback = null) { + const res = await api(`${contentsUrl}?ref=${encodeURIComponent(branch)}`, {}, [404]); + if (res.status === 404) { sha = null; etag = null; lastText = null; return fallback; } + const json = await readJson(res); + sha = json.sha ?? null; + etag = res.headers.get("etag"); + const text = decode(json); + lastText = text; + return text.trim() ? JSON.parse(text) : fallback; + } + + async function poll() { + const headers = etag ? { "If-None-Match": etag } : {}; + const res = await api(`${contentsUrl}?ref=${encodeURIComponent(branch)}`, { headers }, [304, 404]); + if (res.status === 304) return { changed: false }; + if (res.status === 404) { + if (sha === null && lastText === null) return { changed: false }; + sha = null; etag = null; lastText = null; + return { changed: true, state: null }; + } + const json = await readJson(res); + const text = decode(json); + sha = json.sha ?? null; + etag = res.headers.get("etag"); + if (text === lastText) return { changed: false }; + lastText = text; + return { changed: true, state: text.trim() ? JSON.parse(text) : null }; + } + + async function save(state) { + let toWrite = state; + for (let attempt = 0; ; attempt++) { + const text = JSON.stringify(toWrite, null, 2); + const body = { + message: (message ? message(toWrite) : `Update ${path}`) || `Update ${path}`, + content: Buffer.from(text, "utf8").toString("base64"), + branch, + ...(sha ? { sha } : {}), + }; + const res = await api(contentsUrl, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }, [409, 404]); + if (res.ok) { + const json = await res.json(); + sha = json.content?.sha ?? null; + etag = null; // PUT's ETag isn't the content GET's — force a fresh poll baseline + lastText = text; // our own write must not read back as a change + return; + } + // 409 (or a 404 if the file/branch vanished): the sha we held is stale. + // Re-read to refresh sha, optionally merge the remote with our intended + // write, and retry. Bounded so a persistent conflict fails loudly. + if ((res.status === 409 || res.status === 404) && attempt < MAX_SAVE_RETRIES) { + const remote = await load(null); + toWrite = merge ? merge(remote, state) : state; // default: last-writer-wins + continue; + } + const errBody = await res.text().catch(() => ""); + throw new Error(`githubStore ${label}: save failed ${res.status} ${errBody.slice(0, 200)}`); + } + } + + return { file: label, load, poll, save }; +} diff --git a/extensions/news-aggregator/canvas-kit/icons.mjs b/extensions/news-aggregator/canvas-kit/icons.mjs index 9dd30ea..04eb2e9 100644 --- a/extensions/news-aggregator/canvas-kit/icons.mjs +++ b/extensions/news-aggregator/canvas-kit/icons.mjs @@ -23,7 +23,18 @@ const VIEWBOX = "0 0 24 24"; const STROKE_WIDTH = 2; function resolve(name) { - return LUCIDE[name] || LUCIDE[aliases[name]] || null; + // Own-property lookups only. Bracket access on a plain object reaches inherited + // Object.prototype members, so a name like "toString"/"constructor"/ + // "hasOwnProperty" would otherwise resolve to a function (making hasIcon() lie + // and Icon()/lucideSVG() throw in nodeToString). Object.hasOwn keeps resolution + // to the real, vendored icon set. + if (typeof name !== "string") return null; + if (Object.hasOwn(LUCIDE, name)) return LUCIDE[name]; + if (Object.hasOwn(aliases, name)) { + const target = aliases[name]; + if (Object.hasOwn(LUCIDE, target)) return LUCIDE[target]; + } + return null; } function kebab(k) { diff --git a/extensions/news-aggregator/canvas-kit/net.mjs b/extensions/news-aggregator/canvas-kit/net.mjs new file mode 100644 index 0000000..fc1618c --- /dev/null +++ b/extensions/news-aggregator/canvas-kit/net.mjs @@ -0,0 +1,126 @@ +// canvas-kit/net.mjs +// +// Server-side network safety for canvases that fetch external data. A canvas +// action runs on the loopback runtime with the app's network reach, so a +// caller-influenced URL is an SSRF risk: it could target cloud metadata +// (169.254.169.254), loopback, or the private network. This module is the kit's +// sanctioned egress primitive — validate a URL, then fetch it with a hard +// timeout — so every data canvas guards the same way instead of re-inlining the +// check. SERVER-ONLY (uses node:dns / node:net): import it from the SDK-free +// canvas.mjs, never from the browser view. +// +// Defense-in-depth, not a guarantee: a determined attacker could DNS-rebind +// between the resolve check and the connect. For a canvas pointed at a source +// you choose this is adequate; treat any fetched content as untrusted and render +// it as TEXT (never innerHTML). + +import { lookup } from "node:dns/promises"; +import { isIP } from "node:net"; + +/** + * True for addresses a server-side fetch should never reach: loopback, + * link-local (incl. cloud metadata 169.254.169.254), and private/CGNAT ranges. + * @param {string} ip + * @returns {boolean} + */ +export function isBlockedAddress(ip) { + const lower = ip.toLowerCase(); + // Decode an IPv4-mapped/compatible IPv6 literal down to its embedded IPv4 so + // the IPv4 range checks apply. Covers BOTH the dotted tail (::ffff:127.0.0.1) + // and the HEX tail that `new URL()` normalizes literals to (::ffff:7f00:1) — + // without the decode, `[::ffff:127.0.0.1]` reaches loopback and + // `[::ffff:a9fe:a9fe]` reaches cloud metadata. Only matches when the high bits + // are all zero (leading "::"), so a normal public v6 (e.g. 2001:db8::7f00:1) is + // never misread as IPv4. + const addr = embeddedIPv4(lower) ?? ip; + if (isIP(addr) === 4) { + const [a, b] = addr.split(".").map(Number); + if (a === 0 || a === 127 || a === 10) return true; // this-host / loopback / private + if (a === 169 && b === 254) return true; // link-local (incl. cloud IMDS) + if (a === 172 && b >= 16 && b <= 31) return true; // private + if (a === 192 && b === 168) return true; // private + if (a === 100 && b >= 64 && b <= 127) return true; // CGNAT + return false; + } + return lower === "::1" || lower === "::" || lower.startsWith("fe80") || lower.startsWith("fc") || lower.startsWith("fd"); +} + +// Decode an IPv4-mapped/compatible IPv6 literal (all-zero high groups, i.e. a +// leading "::", optionally "::ffff:") to its dotted IPv4; else null. Accepts the +// dotted tail (127.0.0.1) and the two-hex-group tail (7f00:1) that URL +// normalization produces. Anchored on "::" so it can't misread a public v6. +function embeddedIPv4(addr) { + const m = addr.match(/^::(?:ffff:)?((?:\d{1,3}\.){3}\d{1,3}|[0-9a-f]{1,4}:[0-9a-f]{1,4})$/); + if (!m) return null; + const tail = m[1]; + if (tail.includes(".")) return isIP(tail) === 4 ? tail : null; + const [h1, h2] = tail.split(":"); + const hi = parseInt(h1, 16), lo = parseInt(h2, 16); + return `${hi >> 8}.${hi & 255}.${lo >> 8}.${lo & 255}`; +} + +/** + * Allow only http/https to a PUBLIC host. Rejects every address the hostname + * resolves to, so a public name can't be pointed at an internal IP. Throws on a + * blocked/invalid URL; resolves to void when the URL is safe to fetch. + * @param {string} url + */ +export async function assertPublicUrl(url) { + let u; + try { u = new URL(url); } catch { throw new Error("Invalid source URL"); } + if (u.protocol !== "http:" && u.protocol !== "https:") { + throw new Error("Blocked URL protocol: " + u.protocol); + } + let host = u.hostname; + if (host.startsWith("[") && host.endsWith("]")) host = host.slice(1, -1); // IPv6 brackets + if (host.toLowerCase() === "localhost") throw new Error("Blocked host: localhost"); + const addrs = isIP(host) ? [host] : (await lookup(host, { all: true })).map((r) => r.address); + if (!addrs.length) throw new Error("Could not resolve host: " + host); + for (const ip of addrs) { + if (isBlockedAddress(ip)) throw new Error("Blocked private/loopback address: " + ip); + } +} + +/** + * SSRF-guarded fetch with a mandatory timeout. Runs assertPublicUrl on the URL + * and on EVERY redirect hop, so a chosen public host can't 30x-redirect the + * request into loopback/metadata/private space. Returns the final Response as-is + * (does NOT throw on a non-2xx status — the caller checks res.ok), so it is a + * drop-in for a guarded fetch(). + * + * Redirects are followed MANUALLY (redirect:"manual") rather than by fetch's + * default redirect:"follow": the default would chase a 3xx Location without + * re-running the guard, which silently undoes every check in this module. Here + * each hop's target is re-validated before we connect to it. + * @param {string} url + * @param {object} [opts] + * @param {number} [opts.timeoutMs=12000] abort the WHOLE operation (all hops) after this many ms + * @param {number} [opts.maxRedirects=5] how many 3xx hops to follow before giving up + * @param {object} [opts.headers] request headers (merged as-is) + * @param {RequestInit} [opts.rest] any other fetch init (method, body, …) + * @returns {Promise} + */ +export async function safeFetch(url, { timeoutMs = 12000, maxRedirects = 5, headers, ...rest } = {}) { + // One timeout signal bounds the entire operation, redirects included, so a + // chain of slow hops can't multiply the deadline. + const signal = AbortSignal.timeout(timeoutMs); + let current = url; + for (let hop = 0; ; hop++) { + await assertPublicUrl(current); + const res = await fetch(current, { + ...rest, + headers: headers ?? {}, + redirect: "manual", + signal, + }); + if (res.status >= 300 && res.status < 400 && res.headers.has("location")) { + if (hop >= maxRedirects) throw new Error("Too many redirects"); + const next = new URL(res.headers.get("location"), current).href; + // Discard the redirect body so the connection can be freed before the next hop. + try { await res.body?.cancel(); } catch { /* no body / already consumed */ } + current = next; + continue; + } + return res; + } +} diff --git a/extensions/news-aggregator/canvas-kit/server.mjs b/extensions/news-aggregator/canvas-kit/server.mjs index bf3a07f..eb533c9 100644 --- a/extensions/news-aggregator/canvas-kit/server.mjs +++ b/extensions/news-aggregator/canvas-kit/server.mjs @@ -20,6 +20,7 @@ import { createServer } from "node:http"; import { readFile } from "node:fs/promises"; import { join, normalize, extname, sep } from "node:path"; import { fileURLToPath } from "node:url"; +import { validate } from "./validate.mjs"; const KIT_DIR = fileURLToPath(new URL(".", import.meta.url)); @@ -52,7 +53,10 @@ export class CanvasKitError extends Error { * @param {(ctx:object)=>any|Promise} [config.createInitialState] * @param {(domainId:string)=>any|Promise} [config.loadState] * @param {(domainId:string, state:any)=>void|Promise} [config.saveState] + * @param {(domainId:string)=>{changed:boolean,state?:any}|Promise<{changed:boolean,state?:any}>} [config.syncState] optional delta-poll of a SHARED durable source (e.g. githubStore.poll); when set with syncIntervalMs, remote changes are adopted + broadcast to viewers live + * @param {number} [config.syncIntervalMs] poll cadence for syncState; only polled while a domain has >=1 connected viewer * @param {Record} config.actions + * @param {object} [config.stateSchema] optional JSON-Schema-subset for the durable state; when set, a mutation that violates it is rolled back and fails (500) * @param {string} config.assetsDir absolute path to the canvas web/ folder * @param {(ctx:object,state:any)=>string} [config.statusLine] */ @@ -112,14 +116,110 @@ export function createCanvasRuntime(config) { } } + // ---- optional shared-state sync (repo-backed multiplayer) ---------------- + // When config.syncState + config.syncIntervalMs are set, poll the durable + // source on an interval and adopt+broadcast remote changes, so collaborators + // editing a SHARED store (e.g. githubStore) see each other's edits live. We + // only poll a domain while at least one SSE client is watching it — no viewers, + // no network — so API usage stays proportional to real use. syncState returns + // { changed:boolean, state? }; a cheap unchanged poll (ETag 304) is changed:false. + const syncing = new Set(); // domainIds with an in-flight poll (prevents overlap) + let syncTimer = null; + + function domainHasViewers(domainId) { + for (const inst of instances.values()) { + if (inst.domainId === domainId && inst.clients.size > 0) return true; + } + return false; + } + + async function syncDomain(domainId) { + if (syncing.has(domainId)) return; // last tick still in flight — skip + syncing.add(domainId); + try { + const out = await config.syncState(domainId); + // Adopt only a real, non-null remote state. A null (file deleted upstream) + // is ignored so a transient upstream gap can't blank a live board. + if (out?.changed && out.state != null) { + const d = domains.get(domainId); + if (d) { + // Adopted remote state must clear the SAME stateSchema gate the invoke() + // path enforces. A collaborator (or a hand-edit on github.com) can push a + // shape that violates the schema; adopting it unvalidated would broadcast a + // corrupt shape to every viewer AND poison the next invoke()'s rollback + // baseline (structuredClone would snapshot the already-invalid state). + // Reject (skip) an invalid remote instead — the next valid write reconciles. + if (config.stateSchema && validate(config.stateSchema, out.state, "sync-remote").length) { + return; + } + d.state = out.state; + broadcast(domainId); + } + } + } catch { + // A transient network/API error must not kill the timer; retry next tick. + } finally { + syncing.delete(domainId); + } + } + + function syncTick() { + for (const domainId of domains.keys()) { + if (domainHasViewers(domainId)) syncDomain(domainId); + } + } + + // Floor the poll cadence: a mistakenly tiny interval (e.g. 1ms) would hammer the + // durable source — for githubStore that means burning the GitHub API rate limit in + // seconds. 2s is well below any real collaboration-latency need. + const MIN_SYNC_INTERVAL_MS = 2000; + + function startSync() { + if (syncTimer || typeof config.syncState !== "function") return; + const requested = Number(config.syncIntervalMs) || 0; + if (requested <= 0) return; + const ms = Math.max(requested, MIN_SYNC_INTERVAL_MS); + syncTimer = setInterval(syncTick, ms); + syncTimer.unref?.(); // never keep the process alive just to poll + } + + function stopSync() { + if (syncTimer) { clearInterval(syncTimer); syncTimer = null; } + } + // Core action invoker — the single code path behind both agent and UI actions. async function invoke(actionName, input, ctx) { - const action = config.actions[actionName]; + // Own-property lookup: `config.actions[actionName]` via bracket access would + // otherwise reach inherited members (e.g. "constructor", "toString"). The + // handler-typeof check below already rejects those, but resolving by + // Object.hasOwn keeps the boundary explicit and consistent with validate.mjs. + const action = + typeof actionName === "string" && Object.hasOwn(config.actions, actionName) + ? config.actions[actionName] + : null; if (!action || typeof action.handler !== "function") { throw new CanvasKitError("unknown_action", `Unknown action: ${actionName}`); } + // Enforce the declared inputSchema at the boundary (agent OR ui). The schema + // is a contract authors already write; validating it here turns "declared but + // unchecked" into a typed boundary and stops a malformed/typo'd payload from + // reaching the handler. A shape violation is the CALLER's fault → invalid_input + // (HTTP 400). Business rules ("title can't be blank") still live in the handler + // and surface as a 500, so a schema-valid-but-empty string reaches the handler. + if (action.inputSchema) { + const errs = validate(action.inputSchema, input ?? {}, "input"); + if (errs.length) { + throw new CanvasKitError("invalid_input", `Invalid input for '${actionName}': ${errs.join("; ")}`); + } + } const domainId = ctx?.domainId ?? "default"; const d = await getDomain(domainId, ctx); + // Deep snapshot for stateSchema rollback: a handler may mutate state IN PLACE + // and return the same object, so a reference copy (prevState = d.state) would + // point at the same (now-corrupt) object and restore nothing. structuredClone + // gives a real pre-mutation copy. Durable state is JSON-shaped, so it clones + // cleanly. Only pay the clone when a stateSchema is actually configured. + const prevState = config.stateSchema ? structuredClone(d.state) : undefined; let mutated = false; const api = { get state() { return d.state; }, @@ -161,6 +261,17 @@ export function createCanvasRuntime(config) { }; const result = await action.handler(api); if (mutated) { + // Optional stateSchema guards the durable shape: if a handler produced an + // invalid state, roll back the in-memory mutation and fail LOUD (a 500 — + // this is a handler bug, not caller input) instead of persisting/broadcasting + // corrupt state. Absent stateSchema, anything goes (opt-in). + if (config.stateSchema) { + const errs = validate(config.stateSchema, d.state, "state"); + if (errs.length) { + d.state = prevState; // roll back so in-memory stays consistent + throw new Error(`Action '${actionName}' produced invalid state: ${errs.join("; ")}`); + } + } if (config.saveState) await config.saveState(domainId, d.state); broadcast(domainId); } @@ -202,6 +313,12 @@ export function createCanvasRuntime(config) { // local client can't force unbounded memory growth on the loopback runtime. const MAX_BODY_BYTES = 1 << 20; // 1 MiB + // Cap concurrent SSE subscribers PER INSTANCE. A canvas panel needs only one + // /events stream (a couple across reopens); a runaway reconnect loop or a + // hostile local process hitting the loopback port could otherwise accumulate + // unbounded response handles + keep-alive timers. 64 is far above any real use. + const MAX_SSE_CLIENTS = 64; + function readBody(req) { return new Promise((resolve, reject) => { const chunks = []; @@ -240,6 +357,13 @@ export function createCanvasRuntime(config) { // GET /events — Server-Sent Events stream of state if (req.method === "GET" && path === "/events") { + // Refuse once an instance is saturated, so subscribers can't grow without + // bound (each holds a response handle + a keep-alive interval). + if (inst && inst.clients.size >= MAX_SSE_CLIENTS) { + res.writeHead(503, { "Content-Type": "text/plain", "Retry-After": "5" }); + res.end("too many event subscribers"); + return; + } res.writeHead(200, { "Content-Type": "text/event-stream", "Cache-Control": "no-cache", @@ -307,6 +431,15 @@ export function createCanvasRuntime(config) { * @returns {Promise<{url:string,title:string,status?:string}>} */ async function openInstance({ instanceId, input, ctx }) { + // Validate the open input against the declared inputSchema (same contract the + // actions get). A bad open payload fails fast with invalid_input rather than + // silently resolving the wrong domain. + if (config.inputSchema) { + const errs = validate(config.inputSchema, input ?? {}, "open input"); + if (errs.length) { + throw new CanvasKitError("invalid_input", `Invalid open input: ${errs.join("; ")}`); + } + } const domainId = config.resolveDomainId ? config.resolveDomainId(input ?? {}, ctx ?? {}) || "default" : "default"; @@ -341,6 +474,7 @@ export function createCanvasRuntime(config) { } async function shutdown() { + stopSync(); await Promise.all([...instances.keys()].map(closeInstance)); } @@ -348,6 +482,8 @@ export function createCanvasRuntime(config) { return (await getDomain(domainId)).state; } + startSync(); // no-op unless config.syncState + syncIntervalMs are set + return { config, setHost, @@ -358,5 +494,6 @@ export function createCanvasRuntime(config) { invokeFromAgent, // agent-side, resolves domain from ctx getState, _instances: instances, + _syncDomain: syncDomain, // manual one-shot sync (tests) }; } diff --git a/extensions/news-aggregator/canvas-kit/storage.mjs b/extensions/news-aggregator/canvas-kit/storage.mjs index 355983c..3f1177c 100644 --- a/extensions/news-aggregator/canvas-kit/storage.mjs +++ b/extensions/news-aggregator/canvas-kit/storage.mjs @@ -2,18 +2,61 @@ // // Durable JSON state helpers. State is keyed by a *domain id* (a stable logical // identifier resolved from the open input), never by instanceId — per -// create-canvas/SKILL.md. Two scopes: +// create-canvas/SKILL.md. Three tiers, matching the Canvas SDK storage model: // userStore -> $COPILOT_HOME/extensions//artifacts/ (per user, cross-session) -// workspaceStore -> / (per session) +// sessionStore -> $COPILOT_HOME/session-state//extensions// (per session, scratch) +// workspaceStore -> / (rooted at the session workspace) +// +// Sandboxing: callers derive the from a domain id that MUST be sanitized +// to a bare filename (the reference `fileFor` strips everything but +// [A-Za-z0-9._-]); the store never joins caller input as a path, so a domain id +// can't escape its extension's artifacts directory. -import { readFile, writeFile, mkdir, rename } from "node:fs/promises"; +import { readFile, writeFile, mkdir, rename, unlink } from "node:fs/promises"; import { join, dirname } from "node:path"; import { homedir } from "node:os"; +import { randomBytes } from "node:crypto"; function copilotHome() { return process.env.COPILOT_HOME || join(homedir(), ".copilot"); } +// Per-file write queue. Concurrent saves to the SAME durable file (an agent +// action and a UI action racing) must not overlap: on Windows two renames to the +// same destination collide with EPERM even with unique temp names. Chaining each +// save onto the previous one for that path serializes them (last enqueued wins), +// which is both correct (no interleaved writes) and portable. +const writeQueues = new Map(); // absolute file path -> tail Promise + +async function atomicWrite(file, data) { + await mkdir(dirname(file), { recursive: true }); + // Write to a temp sibling then atomically rename into place, so a crash or + // interruption mid-write can never truncate the existing durable file. + // rename(2) is atomic on the same filesystem. The temp name mixes pid + time + + // RANDOM bytes so two writers can never pick the same temp path. + const tmp = `${file}.${process.pid}.${Date.now()}.${randomBytes(6).toString("hex")}.tmp`; + await writeFile(tmp, JSON.stringify(data, null, 2), "utf8"); + try { + await rename(tmp, file); + } catch (err) { + // Windows can transiently EPERM/EACCES a rename when the destination is + // briefly held (AV scanner, indexer). Retry once after a short beat; always + // clean up our temp so a failed save leaves no orphaned *.tmp behind. + if (err?.code === "EPERM" || err?.code === "EACCES") { + await new Promise((r) => setTimeout(r, 25)); + try { + await rename(tmp, file); + } catch (err2) { + await unlink(tmp).catch(() => {}); + throw err2; + } + } else { + await unlink(tmp).catch(() => {}); + throw err; + } + } +} + function makeStore(file) { return { file, @@ -32,13 +75,15 @@ function makeStore(file) { } }, async save(data) { - await mkdir(dirname(file), { recursive: true }); - // Write to a temp sibling then atomically rename into place, so a crash or - // interruption mid-write can never truncate the existing durable file. - // rename(2) is atomic on the same filesystem. - const tmp = `${file}.${process.pid}.${Date.now()}.tmp`; - await writeFile(tmp, JSON.stringify(data, null, 2), "utf8"); - await rename(tmp, file); + // Serialize behind any in-flight save for this exact path (see writeQueues). + const prev = writeQueues.get(file) ?? Promise.resolve(); + const run = prev.catch(() => {}).then(() => atomicWrite(file, data)); + writeQueues.set(file, run); + try { + await run; + } finally { + if (writeQueues.get(file) === run) writeQueues.delete(file); + } }, }; } @@ -48,6 +93,24 @@ export function userStore(extensionName, fileName) { return makeStore(join(copilotHome(), "extensions", extensionName, "artifacts", fileName)); } +/** + * Per-session scratch store for a named extension, rooted under the session's + * state directory. Use for state that should NOT outlive the session (drafts, + * one-off working data). Pass the session id (e.g. from the SDK ctx). + */ +export function sessionStore(sessionId, extensionName, fileName) { + // Reduce the session id to a single safe path segment. The charset filter keeps + // "." (so dotted ids survive), so we must ALSO collapse any ".." run — otherwise + // a session id of ".." would join to one level ABOVE session-state and escape the + // per-session root. (sessionId is normally a trusted SDK value; this keeps the + // sanitizer honest regardless.) + const safeSession = + String(sessionId) + .replace(/[^A-Za-z0-9._-]/g, "_") + .replace(/\.\.+/g, "_") || "default"; + return makeStore(join(copilotHome(), "session-state", safeSession, "extensions", extensionName, fileName)); +} + /** Per-session store rooted at the session workspace path. */ export function workspaceStore(workspacePath, fileName) { return makeStore(join(workspacePath, fileName)); diff --git a/extensions/news-aggregator/canvas-kit/validate.mjs b/extensions/news-aggregator/canvas-kit/validate.mjs new file mode 100644 index 0000000..d44925e --- /dev/null +++ b/extensions/news-aggregator/canvas-kit/validate.mjs @@ -0,0 +1,146 @@ +// canvas-kit/validate.mjs +// +// A tiny, dependency-free JSON-Schema-*subset* validator. It exists so the +// runtime can ENFORCE the action `inputSchema` (and an optional `stateSchema`) +// that authors already declare — turning the iframe↔extension / agent↔extension +// contract from "declared but unchecked" into "validated at the boundary". No +// npm dependency (the kit ships vendored, no install step), and it only covers +// the JSON Schema features the kit's schemas actually use: +// +// type "object" | "array" | "string" | "number" | "integer" | +// "boolean" | "null" (or an array of those) +// properties per-key subschemas (objects) +// required array of required property names (objects) +// additionalProperties false | subschema (objects) +// items subschema for every element (arrays) +// enum allowed literal values (deep-equal) +// minLength/maxLength string length bounds +// minimum/maximum numeric bounds +// minItems/maxItems array length bounds +// +// It is intentionally forgiving: an absent/empty schema validates anything, and +// unknown keywords are ignored (so a richer schema never hard-fails here). The +// goal is to catch the shape mistakes that actually break canvases — wrong type, +// missing required field, unknown/typo'd property, out-of-enum value — not to be +// a complete JSON Schema implementation. + +function typeOf(value) { + if (value === null) return "null"; + if (Array.isArray(value)) return "array"; + if (Number.isInteger(value)) return "integer"; + return typeof value; // "string" | "number" | "boolean" | "object" | "undefined" +} + +// Does `value` satisfy a single JSON Schema `type` token? "number" accepts +// integers; "integer" requires a whole number. +function matchesType(value, t) { + const actual = typeOf(value); + if (t === "number") return actual === "number" || actual === "integer"; + if (t === "integer") return actual === "integer"; + return actual === t; +} + +function deepEqual(a, b) { + if (a === b) return true; + if (typeof a !== typeof b || a === null || b === null) return false; + if (Array.isArray(a) && Array.isArray(b)) { + return a.length === b.length && a.every((x, i) => deepEqual(x, b[i])); + } + if (typeof a === "object" && typeof b === "object") { + const ak = Object.keys(a), bk = Object.keys(b); + return ak.length === bk.length && ak.every((k) => deepEqual(a[k], b[k])); + } + return false; +} + +/** + * Validate `value` against a JSON-Schema-subset `schema`. + * @param {object|undefined} schema + * @param {any} value + * @param {string} [path] dotted path used in error messages (default "input") + * @returns {string[]} human-readable error messages; empty array = valid. + */ +export function validate(schema, value, path = "input") { + const errors = []; + walk(schema, value, path, errors); + return errors; +} + +function walk(schema, value, path, errors) { + if (!schema || typeof schema !== "object") return; // absent/degenerate schema = anything goes + + // type (single token or array of tokens) + if (schema.type !== undefined) { + const types = Array.isArray(schema.type) ? schema.type : [schema.type]; + if (!types.some((t) => matchesType(value, t))) { + errors.push(`${path}: expected ${types.join(" | ")}, got ${typeOf(value)}`); + return; // a wrong base type makes deeper checks meaningless + } + } + + // enum + if (Array.isArray(schema.enum) && !schema.enum.some((allowed) => deepEqual(allowed, value))) { + errors.push(`${path}: must be one of ${schema.enum.map((v) => JSON.stringify(v)).join(", ")}`); + } + + const kind = typeOf(value); + + if (kind === "string") { + if (typeof schema.minLength === "number" && value.length < schema.minLength) { + errors.push(`${path}: must be at least ${schema.minLength} characters`); + } + if (typeof schema.maxLength === "number" && value.length > schema.maxLength) { + errors.push(`${path}: must be at most ${schema.maxLength} characters`); + } + } + + if (kind === "number" || kind === "integer") { + if (typeof schema.minimum === "number" && value < schema.minimum) { + errors.push(`${path}: must be >= ${schema.minimum}`); + } + if (typeof schema.maximum === "number" && value > schema.maximum) { + errors.push(`${path}: must be <= ${schema.maximum}`); + } + } + + if (kind === "array") { + if (typeof schema.minItems === "number" && value.length < schema.minItems) { + errors.push(`${path}: must have at least ${schema.minItems} item(s)`); + } + if (typeof schema.maxItems === "number" && value.length > schema.maxItems) { + errors.push(`${path}: must have at most ${schema.maxItems} item(s)`); + } + if (schema.items) { + value.forEach((item, i) => walk(schema.items, item, `${path}[${i}]`, errors)); + } + } + + if (kind === "object") { + const props = schema.properties ?? {}; + if (Array.isArray(schema.required)) { + for (const key of schema.required) { + // Object.hasOwn (not `value[key] === undefined`): a required property + // named after a prototype member (e.g. "toString", "constructor") would + // otherwise read the inherited value and wrongly pass the check. + if (!Object.hasOwn(value, key)) errors.push(`${path}.${key}: required`); + } + } + for (const [key, sub] of Object.entries(props)) { + if (Object.hasOwn(value, key)) walk(sub, value[key], `${path}.${key}`, errors); + } + // Membership is tested with Object.hasOwn, NOT `key in props`: `in` walks the + // prototype chain, so an extra key named "toString"/"constructor"/"__proto__" + // etc. would satisfy `key in props` and escape additionalProperties. This + // validator is the enforcement boundary (input → 400, state → 500), so that + // would be a real contract hole. + if (schema.additionalProperties === false) { + for (const key of Object.keys(value)) { + if (!Object.hasOwn(props, key)) errors.push(`${path}.${key}: unexpected property`); + } + } else if (schema.additionalProperties && typeof schema.additionalProperties === "object") { + for (const key of Object.keys(value)) { + if (!Object.hasOwn(props, key)) walk(schema.additionalProperties, value[key], `${path}.${key}`, errors); + } + } + } +} diff --git a/extensions/news-aggregator/canvas-kit/version.mjs b/extensions/news-aggregator/canvas-kit/version.mjs index 93471a9..6bff1cc 100644 --- a/extensions/news-aggregator/canvas-kit/version.mjs +++ b/extensions/news-aggregator/canvas-kit/version.mjs @@ -10,4 +10,4 @@ // convention (the skill ships as files, not an npm version); a commit short-sha // works too. Re-exported from client.mjs so a canvas can read it at runtime. -export const KIT_VERSION = "2026-07-05.1"; +export const KIT_VERSION = "2026-07-07.6"; diff --git a/extensions/random-animal/canvas-kit/.kit-version.json b/extensions/random-animal/canvas-kit/.kit-version.json index fd4b6d3..418f0cb 100644 --- a/extensions/random-animal/canvas-kit/.kit-version.json +++ b/extensions/random-animal/canvas-kit/.kit-version.json @@ -1,5 +1,5 @@ { - "version": "2026-07-05.1", - "syncedAt": "2026-07-06T05:14:32.246Z", + "version": "2026-07-07.6", + "syncedAt": "2026-07-08T01:54:39.463Z", "source": "create-canvas-app/kit" } diff --git a/extensions/random-animal/canvas-kit/client.mjs b/extensions/random-animal/canvas-kit/client.mjs index 75e6e65..6f4cd9c 100644 --- a/extensions/random-animal/canvas-kit/client.mjs +++ b/extensions/random-animal/canvas-kit/client.mjs @@ -85,25 +85,21 @@ export function pollWhileVisible(tick, seconds, { whenVisible = true, immediate } /** - * Mount a canvas view and keep it live. - * @param {object} opts - * @param {(model:{state:any, invoke:Function, connected:boolean})=>any} opts.view - * Returns an htm/Preact vnode. Re-invoked on every state push. - * @param {HTMLElement} [opts.mount] defaults to #app or - * @param {(state:any)=>void} [opts.onState] - * @param {PollOptions} [opts.poll] built-in fixed-interval visibility-gated auto-refresh. - * For an interval bound to live state, use `pollWhileVisible` in a useEffect instead. - * @returns {{invoke:Function, refresh:Function, stopPoll:Function, get state():any}} + * DOM-FREE transport for a canvas: owns the loopback wiring (GET /state, GET + * /events SSE, POST /action) and the derived `state`/`connected`, with no Preact + * and no DOM. `mountCanvas` composes this with a render loop; keeping it separate + * makes the reconnect/invoke glue unit-testable without a browser (see + * test/client.test.mjs). Both callbacks receive the latest `(state, connected)`. + * @param {object} [opts] + * @param {(state:any, connected:boolean)=>void} [opts.onState] fired on the initial /state and every SSE push + * @param {(connected:boolean)=>void} [opts.onConnected] fired when the SSE stream opens/errors + * @param {typeof EventSource} [opts.EventSourceImpl] override the SSE impl (tests); defaults to the global + * @returns {{invoke:Function, refresh:Function, get state():any, get connected():boolean}} */ -export function mountCanvas({ view, mount, onState, poll } = {}) { - const root = mount || document.getElementById("app") || document.body; - // Preact's render() diffs against — but does not clear — pre-existing DOM in - // the container, so a static no-JS placeholder (e.g.

Loading…

in the - // HTML shell) would linger as a sibling. Clear it once so Preact owns an empty - // root; the view's own loading branch covers the gap until first state. - root.replaceChildren(); +export function connectCanvas({ onState, onConnected, EventSourceImpl } = {}) { let state = null; let connected = false; + const ES = EventSourceImpl || (typeof EventSource !== "undefined" ? EventSource : null); async function invoke(actionName, input) { const res = await fetch("./action", { @@ -118,20 +114,16 @@ export function mountCanvas({ view, mount, onState, poll } = {}) { return data.result; } - function rerender() { - render(view({ state, invoke, connected }), root); - } - async function refresh() { try { state = await (await fetch("./state")).json(); - onState?.(state); - rerender(); + onState?.(state, connected); } catch { /* offline; SSE will recover */ } } function connect() { - const es = new EventSource("./events"); + if (!ES) return; // no EventSource (e.g. a non-browser host); refresh() still works + const es = new ES("./events"); es.onmessage = (e) => { let next; try { @@ -139,20 +131,69 @@ export function mountCanvas({ view, mount, onState, poll } = {}) { } catch { return; // ignore a malformed SSE frame; the next push recovers } - // Update + render OUTSIDE the try so a bug in onState/the view surfaces as - // a real error instead of being silently mislabeled a "malformed frame". + // Update OUTSIDE the try so a bug in onState surfaces as a real error + // instead of being silently mislabeled a "malformed frame". state = next; connected = true; - onState?.(state); - rerender(); + onState?.(state, connected); }; - es.onopen = () => { connected = true; rerender(); }; - es.onerror = () => { connected = false; rerender(); /* EventSource auto-reconnects */ }; + es.onopen = () => { connected = true; onConnected?.(connected); }; + es.onerror = () => { connected = false; onConnected?.(connected); /* EventSource auto-reconnects */ }; } refresh(); connect(); + return { + invoke, + refresh, + get state() { return state; }, + get connected() { return connected; }, + }; +} + +/** + * Mount a canvas view and keep it live. + * @param {object} opts + * @param {(model:{state:any, invoke:Function, connected:boolean})=>any} opts.view + * Returns an htm/Preact vnode. Re-invoked on every state push. + * @param {HTMLElement} [opts.mount] defaults to #app or + * @param {(state:any)=>void} [opts.onState] + * @param {PollOptions} [opts.poll] built-in fixed-interval visibility-gated auto-refresh. + * For an interval bound to live state, use `pollWhileVisible` in a useEffect instead. + * @returns {{invoke:Function, refresh:Function, stopPoll:Function, get state():any}} + */ +export function mountCanvas({ view, mount, onState, poll } = {}) { + const root = mount || document.getElementById("app") || document.body; + // Preact's render() diffs against — but does not clear — pre-existing DOM in + // the container, so a static no-JS placeholder (e.g.

Loading…

in the + // HTML shell) would linger as a sibling. Clear it once so Preact owns an empty + // root; the view's own loading branch covers the gap until first state. + root.replaceChildren(); + + let latestState = null; + let latestConnected = false; + let client; + + function rerender() { + render(view({ state: latestState, invoke: client.invoke, connected: latestConnected }), root); + } + + // The transport is DOM-free (connectCanvas); this wrapper only adds the Preact + // render on each state/connection change. + client = connectCanvas({ + onState: (state, connected) => { + latestState = state; + latestConnected = connected; + onState?.(state); + rerender(); + }, + onConnected: (connected) => { + latestConnected = connected; + rerender(); + }, + }); + // Built-in fixed-interval auto-refresh, delegating to the shared // visibility-gated primitive. Pass `poll: { action, seconds, immediate }`. // @@ -164,7 +205,7 @@ export function mountCanvas({ view, mount, onState, poll } = {}) { // @property {boolean} [immediate=false] fire one tick right after mount function startPoll({ action, seconds, input, whenVisible = true, immediate = false } = {}) { return pollWhileVisible( - () => (action ? invoke(action, input) : refresh()), + () => (action ? client.invoke(action, input) : client.refresh()), seconds, { whenVisible, immediate } ); @@ -174,10 +215,10 @@ export function mountCanvas({ view, mount, onState, poll } = {}) { if (poll) stopPoll = startPoll(poll); return { - invoke, - refresh, + invoke: client.invoke, + refresh: client.refresh, stopPoll: () => stopPoll(), - get state() { return state; }, + get state() { return latestState; }, }; } diff --git a/extensions/random-animal/canvas-kit/github-store.mjs b/extensions/random-animal/canvas-kit/github-store.mjs new file mode 100644 index 0000000..d89a136 --- /dev/null +++ b/extensions/random-animal/canvas-kit/github-store.mjs @@ -0,0 +1,215 @@ +// canvas-kit/github-store.mjs +// +// A SHARED, multi-writer durable store backed by a file in a GitHub repository. +// Where userStore/sessionStore/workspaceStore (storage.mjs) persist to local disk +// — private to one machine — githubStore persists the same JSON to a file in a +// repo via the Contents API, so every collaborator who can push to that repo edits +// ONE shared document. GitHub is the backing store AND the access-control layer +// (invite collaborators to a private repo); there is no server to run. +// +// It exposes the same { load, save } shape the runtime's loadState/saveState +// expect, plus poll() for cheap change-detection so a canvas can pull other +// people's edits live (wire it to server.mjs's syncState + syncIntervalMs). +// +// Concurrency: every write carries the blob sha it read (optimistic lock). A +// concurrent commit makes the PUT 409; save() re-reads to refresh the sha and +// retries. The default policy is last-writer-wins for the whole document; pass a +// merge(remoteState, myState) to resolve conflicts field-by-field instead (e.g. +// union a board's concerns by id). Reads use an ETag If-None-Match so an unchanged +// poll is a cheap 304 with no body. +// +// Token: resolved once from opts.token (string | async () => string), then +// GH_TOKEN / GITHUB_TOKEN, then `gh auth token`. Needs the `repo` scope for a +// private repo. The token is only ever sent as an Authorization header — never +// logged, never written to the repo. + +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; + +const execFileAsync = promisify(execFile); + +const API = "https://api.github.com"; +const UA = "canvas-kit-github-store"; +// Hard deadline for every GitHub API call so a hung request (stalled TLS, dropped +// connection) can't freeze a user action or dead-lock the sync loop (mirrors the +// AbortSignal.timeout pattern in net.mjs safeFetch). +const DEFAULT_TIMEOUT_MS = 15000; +// Refuse an implausibly large state file rather than allocate it on every poll. A +// board's JSON is tens of KB; this is a safety ceiling, not a real size limit. +const MAX_RESPONSE_BYTES = 8 * 1024 * 1024; +// Bounded save retries on an optimistic-lock conflict (409) before failing loud. +const MAX_SAVE_RETRIES = 3; + +// Resolve a GitHub token lazily and memoize it. A 401 (expired/rotated) clears the +// cache via invalidate() so the next call re-resolves. +function makeTokenResolver(tokenOpt) { + let cached = null; + async function fromGhCli() { + try { + const { stdout } = await execFileAsync("gh", ["auth", "token"], { windowsHide: true }); + return String(stdout).trim() || null; + } catch { + return null; // gh missing or not logged in → fall through to the no-token error + } + } + return { + async get() { + if (cached) return cached; + let t = null; + if (typeof tokenOpt === "function") t = await tokenOpt(); + else if (typeof tokenOpt === "string" && tokenOpt) t = tokenOpt; + if (!t) t = process.env.GH_TOKEN || process.env.GITHUB_TOKEN || null; + if (!t) t = await fromGhCli(); + if (!t) throw new Error("githubStore: no GitHub token (set GH_TOKEN or run `gh auth login`)"); + cached = t; + return cached; + }, + invalidate() { cached = null; }, + }; +} + +function encodePath(path) { + // Keep the slashes as path separators in the Contents API URL; encode each + // segment so spaces / unicode in a filename can't break the request. + return String(path).split("/").filter(Boolean).map(encodeURIComponent).join("/"); +} + +/** + * A GitHub-repo-backed durable store: one JSON file, many writers. + * @param {object} opts + * @param {string} opts.owner repo owner (user or org) + * @param {string} opts.repo repo name + * @param {string} opts.path path to the JSON file in the repo (e.g. "state/board.json") + * @param {string} [opts.branch="main"] + * @param {string|(()=>Promise|string)} [opts.token] token or async resolver + * @param {(remote:any,mine:any)=>any} [opts.merge] conflict resolver (default: last-writer-wins) + * @param {(state:any)=>string} [opts.message] commit message from the state (default: a timestamp) + * @param {string} [opts.apiBase=API] + * @returns {{file:string, load:(fallback?:any)=>Promise, poll:()=>Promise<{changed:boolean,state?:any}>, save:(state:any)=>Promise}} + */ +export function githubStore(opts) { + const { owner, repo, path, branch = "main", token, merge, message, apiBase = API } = opts ?? {}; + if (!owner || !repo || !path) throw new Error("githubStore: owner, repo and path are required"); + // apiBase is a trusted, fixed host by design — GitHub's public API by default, or a + // GitHub Enterprise host the canvas author sets at construction (never runtime + // input). That's why we call fetch() directly rather than the kit's safeFetch SSRF + // guard, which would add a DNS-resolution check on every call for a host that can't + // vary per request. Still, require https for any custom apiBase as defense-in-depth. + if (apiBase !== API) { + let base; + try { base = new URL(apiBase); } catch { throw new Error("githubStore: apiBase must be a valid URL"); } + if (base.protocol !== "https:") throw new Error("githubStore: apiBase must be https"); + } + + const tok = makeTokenResolver(token); + const contentsUrl = `${apiBase}/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/contents/${encodePath(path)}`; + const label = `${owner}/${repo}:${path}`; + + let sha = null; // last-seen blob sha — sent on write as the optimistic lock + let etag = null; // last-seen ETag — sent on poll as If-None-Match + let lastText = null; // last-seen decoded content — guards against no-op broadcasts + + async function api(url, init = {}, allow = []) { + const t = await tok.get(); + const res = await fetch(url, { + ...init, + signal: AbortSignal.timeout(DEFAULT_TIMEOUT_MS), + headers: { + Authorization: `Bearer ${t}`, + Accept: "application/vnd.github+json", + "X-GitHub-Api-Version": "2022-11-28", + "User-Agent": UA, + ...(init.headers ?? {}), + }, + }); + // An expired token surfaces as 401 — drop the memoized token so a retry can + // re-resolve (e.g. after `gh auth refresh`), then surface the failure. + if (res.status === 401) { tok.invalidate(); } + // ok, or an expected status the caller handles (404 fresh-file, 304 unchanged, + // 409 write conflict), pass through; anything else is a hard error. + if (res.ok || allow.includes(res.status)) return res; + const body = await res.text().catch(() => ""); + throw new Error(`githubStore ${label}: ${init.method ?? "GET"} ${res.status} ${body.slice(0, 200)}`); + } + + function decode(json) { + // Contents API returns base64 (wrapped at 60 cols); Buffer handles the newlines. + return Buffer.from(json.content ?? "", "base64").toString("utf8"); + } + + async function readJson(res) { + // Bound the allocation: refuse an oversized response before reading it, so a + // huge state file can't be pulled into memory on every poll tick. + const len = Number(res.headers.get("content-length") || 0); + if (len > MAX_RESPONSE_BYTES) { + throw new Error(`githubStore ${label}: response too large (${len} bytes)`); + } + return res.json(); + } + + async function load(fallback = null) { + const res = await api(`${contentsUrl}?ref=${encodeURIComponent(branch)}`, {}, [404]); + if (res.status === 404) { sha = null; etag = null; lastText = null; return fallback; } + const json = await readJson(res); + sha = json.sha ?? null; + etag = res.headers.get("etag"); + const text = decode(json); + lastText = text; + return text.trim() ? JSON.parse(text) : fallback; + } + + async function poll() { + const headers = etag ? { "If-None-Match": etag } : {}; + const res = await api(`${contentsUrl}?ref=${encodeURIComponent(branch)}`, { headers }, [304, 404]); + if (res.status === 304) return { changed: false }; + if (res.status === 404) { + if (sha === null && lastText === null) return { changed: false }; + sha = null; etag = null; lastText = null; + return { changed: true, state: null }; + } + const json = await readJson(res); + const text = decode(json); + sha = json.sha ?? null; + etag = res.headers.get("etag"); + if (text === lastText) return { changed: false }; + lastText = text; + return { changed: true, state: text.trim() ? JSON.parse(text) : null }; + } + + async function save(state) { + let toWrite = state; + for (let attempt = 0; ; attempt++) { + const text = JSON.stringify(toWrite, null, 2); + const body = { + message: (message ? message(toWrite) : `Update ${path}`) || `Update ${path}`, + content: Buffer.from(text, "utf8").toString("base64"), + branch, + ...(sha ? { sha } : {}), + }; + const res = await api(contentsUrl, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }, [409, 404]); + if (res.ok) { + const json = await res.json(); + sha = json.content?.sha ?? null; + etag = null; // PUT's ETag isn't the content GET's — force a fresh poll baseline + lastText = text; // our own write must not read back as a change + return; + } + // 409 (or a 404 if the file/branch vanished): the sha we held is stale. + // Re-read to refresh sha, optionally merge the remote with our intended + // write, and retry. Bounded so a persistent conflict fails loudly. + if ((res.status === 409 || res.status === 404) && attempt < MAX_SAVE_RETRIES) { + const remote = await load(null); + toWrite = merge ? merge(remote, state) : state; // default: last-writer-wins + continue; + } + const errBody = await res.text().catch(() => ""); + throw new Error(`githubStore ${label}: save failed ${res.status} ${errBody.slice(0, 200)}`); + } + } + + return { file: label, load, poll, save }; +} diff --git a/extensions/random-animal/canvas-kit/icons.mjs b/extensions/random-animal/canvas-kit/icons.mjs index 9dd30ea..04eb2e9 100644 --- a/extensions/random-animal/canvas-kit/icons.mjs +++ b/extensions/random-animal/canvas-kit/icons.mjs @@ -23,7 +23,18 @@ const VIEWBOX = "0 0 24 24"; const STROKE_WIDTH = 2; function resolve(name) { - return LUCIDE[name] || LUCIDE[aliases[name]] || null; + // Own-property lookups only. Bracket access on a plain object reaches inherited + // Object.prototype members, so a name like "toString"/"constructor"/ + // "hasOwnProperty" would otherwise resolve to a function (making hasIcon() lie + // and Icon()/lucideSVG() throw in nodeToString). Object.hasOwn keeps resolution + // to the real, vendored icon set. + if (typeof name !== "string") return null; + if (Object.hasOwn(LUCIDE, name)) return LUCIDE[name]; + if (Object.hasOwn(aliases, name)) { + const target = aliases[name]; + if (Object.hasOwn(LUCIDE, target)) return LUCIDE[target]; + } + return null; } function kebab(k) { diff --git a/extensions/random-animal/canvas-kit/net.mjs b/extensions/random-animal/canvas-kit/net.mjs new file mode 100644 index 0000000..fc1618c --- /dev/null +++ b/extensions/random-animal/canvas-kit/net.mjs @@ -0,0 +1,126 @@ +// canvas-kit/net.mjs +// +// Server-side network safety for canvases that fetch external data. A canvas +// action runs on the loopback runtime with the app's network reach, so a +// caller-influenced URL is an SSRF risk: it could target cloud metadata +// (169.254.169.254), loopback, or the private network. This module is the kit's +// sanctioned egress primitive — validate a URL, then fetch it with a hard +// timeout — so every data canvas guards the same way instead of re-inlining the +// check. SERVER-ONLY (uses node:dns / node:net): import it from the SDK-free +// canvas.mjs, never from the browser view. +// +// Defense-in-depth, not a guarantee: a determined attacker could DNS-rebind +// between the resolve check and the connect. For a canvas pointed at a source +// you choose this is adequate; treat any fetched content as untrusted and render +// it as TEXT (never innerHTML). + +import { lookup } from "node:dns/promises"; +import { isIP } from "node:net"; + +/** + * True for addresses a server-side fetch should never reach: loopback, + * link-local (incl. cloud metadata 169.254.169.254), and private/CGNAT ranges. + * @param {string} ip + * @returns {boolean} + */ +export function isBlockedAddress(ip) { + const lower = ip.toLowerCase(); + // Decode an IPv4-mapped/compatible IPv6 literal down to its embedded IPv4 so + // the IPv4 range checks apply. Covers BOTH the dotted tail (::ffff:127.0.0.1) + // and the HEX tail that `new URL()` normalizes literals to (::ffff:7f00:1) — + // without the decode, `[::ffff:127.0.0.1]` reaches loopback and + // `[::ffff:a9fe:a9fe]` reaches cloud metadata. Only matches when the high bits + // are all zero (leading "::"), so a normal public v6 (e.g. 2001:db8::7f00:1) is + // never misread as IPv4. + const addr = embeddedIPv4(lower) ?? ip; + if (isIP(addr) === 4) { + const [a, b] = addr.split(".").map(Number); + if (a === 0 || a === 127 || a === 10) return true; // this-host / loopback / private + if (a === 169 && b === 254) return true; // link-local (incl. cloud IMDS) + if (a === 172 && b >= 16 && b <= 31) return true; // private + if (a === 192 && b === 168) return true; // private + if (a === 100 && b >= 64 && b <= 127) return true; // CGNAT + return false; + } + return lower === "::1" || lower === "::" || lower.startsWith("fe80") || lower.startsWith("fc") || lower.startsWith("fd"); +} + +// Decode an IPv4-mapped/compatible IPv6 literal (all-zero high groups, i.e. a +// leading "::", optionally "::ffff:") to its dotted IPv4; else null. Accepts the +// dotted tail (127.0.0.1) and the two-hex-group tail (7f00:1) that URL +// normalization produces. Anchored on "::" so it can't misread a public v6. +function embeddedIPv4(addr) { + const m = addr.match(/^::(?:ffff:)?((?:\d{1,3}\.){3}\d{1,3}|[0-9a-f]{1,4}:[0-9a-f]{1,4})$/); + if (!m) return null; + const tail = m[1]; + if (tail.includes(".")) return isIP(tail) === 4 ? tail : null; + const [h1, h2] = tail.split(":"); + const hi = parseInt(h1, 16), lo = parseInt(h2, 16); + return `${hi >> 8}.${hi & 255}.${lo >> 8}.${lo & 255}`; +} + +/** + * Allow only http/https to a PUBLIC host. Rejects every address the hostname + * resolves to, so a public name can't be pointed at an internal IP. Throws on a + * blocked/invalid URL; resolves to void when the URL is safe to fetch. + * @param {string} url + */ +export async function assertPublicUrl(url) { + let u; + try { u = new URL(url); } catch { throw new Error("Invalid source URL"); } + if (u.protocol !== "http:" && u.protocol !== "https:") { + throw new Error("Blocked URL protocol: " + u.protocol); + } + let host = u.hostname; + if (host.startsWith("[") && host.endsWith("]")) host = host.slice(1, -1); // IPv6 brackets + if (host.toLowerCase() === "localhost") throw new Error("Blocked host: localhost"); + const addrs = isIP(host) ? [host] : (await lookup(host, { all: true })).map((r) => r.address); + if (!addrs.length) throw new Error("Could not resolve host: " + host); + for (const ip of addrs) { + if (isBlockedAddress(ip)) throw new Error("Blocked private/loopback address: " + ip); + } +} + +/** + * SSRF-guarded fetch with a mandatory timeout. Runs assertPublicUrl on the URL + * and on EVERY redirect hop, so a chosen public host can't 30x-redirect the + * request into loopback/metadata/private space. Returns the final Response as-is + * (does NOT throw on a non-2xx status — the caller checks res.ok), so it is a + * drop-in for a guarded fetch(). + * + * Redirects are followed MANUALLY (redirect:"manual") rather than by fetch's + * default redirect:"follow": the default would chase a 3xx Location without + * re-running the guard, which silently undoes every check in this module. Here + * each hop's target is re-validated before we connect to it. + * @param {string} url + * @param {object} [opts] + * @param {number} [opts.timeoutMs=12000] abort the WHOLE operation (all hops) after this many ms + * @param {number} [opts.maxRedirects=5] how many 3xx hops to follow before giving up + * @param {object} [opts.headers] request headers (merged as-is) + * @param {RequestInit} [opts.rest] any other fetch init (method, body, …) + * @returns {Promise} + */ +export async function safeFetch(url, { timeoutMs = 12000, maxRedirects = 5, headers, ...rest } = {}) { + // One timeout signal bounds the entire operation, redirects included, so a + // chain of slow hops can't multiply the deadline. + const signal = AbortSignal.timeout(timeoutMs); + let current = url; + for (let hop = 0; ; hop++) { + await assertPublicUrl(current); + const res = await fetch(current, { + ...rest, + headers: headers ?? {}, + redirect: "manual", + signal, + }); + if (res.status >= 300 && res.status < 400 && res.headers.has("location")) { + if (hop >= maxRedirects) throw new Error("Too many redirects"); + const next = new URL(res.headers.get("location"), current).href; + // Discard the redirect body so the connection can be freed before the next hop. + try { await res.body?.cancel(); } catch { /* no body / already consumed */ } + current = next; + continue; + } + return res; + } +} diff --git a/extensions/random-animal/canvas-kit/server.mjs b/extensions/random-animal/canvas-kit/server.mjs index bf3a07f..eb533c9 100644 --- a/extensions/random-animal/canvas-kit/server.mjs +++ b/extensions/random-animal/canvas-kit/server.mjs @@ -20,6 +20,7 @@ import { createServer } from "node:http"; import { readFile } from "node:fs/promises"; import { join, normalize, extname, sep } from "node:path"; import { fileURLToPath } from "node:url"; +import { validate } from "./validate.mjs"; const KIT_DIR = fileURLToPath(new URL(".", import.meta.url)); @@ -52,7 +53,10 @@ export class CanvasKitError extends Error { * @param {(ctx:object)=>any|Promise} [config.createInitialState] * @param {(domainId:string)=>any|Promise} [config.loadState] * @param {(domainId:string, state:any)=>void|Promise} [config.saveState] + * @param {(domainId:string)=>{changed:boolean,state?:any}|Promise<{changed:boolean,state?:any}>} [config.syncState] optional delta-poll of a SHARED durable source (e.g. githubStore.poll); when set with syncIntervalMs, remote changes are adopted + broadcast to viewers live + * @param {number} [config.syncIntervalMs] poll cadence for syncState; only polled while a domain has >=1 connected viewer * @param {Record} config.actions + * @param {object} [config.stateSchema] optional JSON-Schema-subset for the durable state; when set, a mutation that violates it is rolled back and fails (500) * @param {string} config.assetsDir absolute path to the canvas web/ folder * @param {(ctx:object,state:any)=>string} [config.statusLine] */ @@ -112,14 +116,110 @@ export function createCanvasRuntime(config) { } } + // ---- optional shared-state sync (repo-backed multiplayer) ---------------- + // When config.syncState + config.syncIntervalMs are set, poll the durable + // source on an interval and adopt+broadcast remote changes, so collaborators + // editing a SHARED store (e.g. githubStore) see each other's edits live. We + // only poll a domain while at least one SSE client is watching it — no viewers, + // no network — so API usage stays proportional to real use. syncState returns + // { changed:boolean, state? }; a cheap unchanged poll (ETag 304) is changed:false. + const syncing = new Set(); // domainIds with an in-flight poll (prevents overlap) + let syncTimer = null; + + function domainHasViewers(domainId) { + for (const inst of instances.values()) { + if (inst.domainId === domainId && inst.clients.size > 0) return true; + } + return false; + } + + async function syncDomain(domainId) { + if (syncing.has(domainId)) return; // last tick still in flight — skip + syncing.add(domainId); + try { + const out = await config.syncState(domainId); + // Adopt only a real, non-null remote state. A null (file deleted upstream) + // is ignored so a transient upstream gap can't blank a live board. + if (out?.changed && out.state != null) { + const d = domains.get(domainId); + if (d) { + // Adopted remote state must clear the SAME stateSchema gate the invoke() + // path enforces. A collaborator (or a hand-edit on github.com) can push a + // shape that violates the schema; adopting it unvalidated would broadcast a + // corrupt shape to every viewer AND poison the next invoke()'s rollback + // baseline (structuredClone would snapshot the already-invalid state). + // Reject (skip) an invalid remote instead — the next valid write reconciles. + if (config.stateSchema && validate(config.stateSchema, out.state, "sync-remote").length) { + return; + } + d.state = out.state; + broadcast(domainId); + } + } + } catch { + // A transient network/API error must not kill the timer; retry next tick. + } finally { + syncing.delete(domainId); + } + } + + function syncTick() { + for (const domainId of domains.keys()) { + if (domainHasViewers(domainId)) syncDomain(domainId); + } + } + + // Floor the poll cadence: a mistakenly tiny interval (e.g. 1ms) would hammer the + // durable source — for githubStore that means burning the GitHub API rate limit in + // seconds. 2s is well below any real collaboration-latency need. + const MIN_SYNC_INTERVAL_MS = 2000; + + function startSync() { + if (syncTimer || typeof config.syncState !== "function") return; + const requested = Number(config.syncIntervalMs) || 0; + if (requested <= 0) return; + const ms = Math.max(requested, MIN_SYNC_INTERVAL_MS); + syncTimer = setInterval(syncTick, ms); + syncTimer.unref?.(); // never keep the process alive just to poll + } + + function stopSync() { + if (syncTimer) { clearInterval(syncTimer); syncTimer = null; } + } + // Core action invoker — the single code path behind both agent and UI actions. async function invoke(actionName, input, ctx) { - const action = config.actions[actionName]; + // Own-property lookup: `config.actions[actionName]` via bracket access would + // otherwise reach inherited members (e.g. "constructor", "toString"). The + // handler-typeof check below already rejects those, but resolving by + // Object.hasOwn keeps the boundary explicit and consistent with validate.mjs. + const action = + typeof actionName === "string" && Object.hasOwn(config.actions, actionName) + ? config.actions[actionName] + : null; if (!action || typeof action.handler !== "function") { throw new CanvasKitError("unknown_action", `Unknown action: ${actionName}`); } + // Enforce the declared inputSchema at the boundary (agent OR ui). The schema + // is a contract authors already write; validating it here turns "declared but + // unchecked" into a typed boundary and stops a malformed/typo'd payload from + // reaching the handler. A shape violation is the CALLER's fault → invalid_input + // (HTTP 400). Business rules ("title can't be blank") still live in the handler + // and surface as a 500, so a schema-valid-but-empty string reaches the handler. + if (action.inputSchema) { + const errs = validate(action.inputSchema, input ?? {}, "input"); + if (errs.length) { + throw new CanvasKitError("invalid_input", `Invalid input for '${actionName}': ${errs.join("; ")}`); + } + } const domainId = ctx?.domainId ?? "default"; const d = await getDomain(domainId, ctx); + // Deep snapshot for stateSchema rollback: a handler may mutate state IN PLACE + // and return the same object, so a reference copy (prevState = d.state) would + // point at the same (now-corrupt) object and restore nothing. structuredClone + // gives a real pre-mutation copy. Durable state is JSON-shaped, so it clones + // cleanly. Only pay the clone when a stateSchema is actually configured. + const prevState = config.stateSchema ? structuredClone(d.state) : undefined; let mutated = false; const api = { get state() { return d.state; }, @@ -161,6 +261,17 @@ export function createCanvasRuntime(config) { }; const result = await action.handler(api); if (mutated) { + // Optional stateSchema guards the durable shape: if a handler produced an + // invalid state, roll back the in-memory mutation and fail LOUD (a 500 — + // this is a handler bug, not caller input) instead of persisting/broadcasting + // corrupt state. Absent stateSchema, anything goes (opt-in). + if (config.stateSchema) { + const errs = validate(config.stateSchema, d.state, "state"); + if (errs.length) { + d.state = prevState; // roll back so in-memory stays consistent + throw new Error(`Action '${actionName}' produced invalid state: ${errs.join("; ")}`); + } + } if (config.saveState) await config.saveState(domainId, d.state); broadcast(domainId); } @@ -202,6 +313,12 @@ export function createCanvasRuntime(config) { // local client can't force unbounded memory growth on the loopback runtime. const MAX_BODY_BYTES = 1 << 20; // 1 MiB + // Cap concurrent SSE subscribers PER INSTANCE. A canvas panel needs only one + // /events stream (a couple across reopens); a runaway reconnect loop or a + // hostile local process hitting the loopback port could otherwise accumulate + // unbounded response handles + keep-alive timers. 64 is far above any real use. + const MAX_SSE_CLIENTS = 64; + function readBody(req) { return new Promise((resolve, reject) => { const chunks = []; @@ -240,6 +357,13 @@ export function createCanvasRuntime(config) { // GET /events — Server-Sent Events stream of state if (req.method === "GET" && path === "/events") { + // Refuse once an instance is saturated, so subscribers can't grow without + // bound (each holds a response handle + a keep-alive interval). + if (inst && inst.clients.size >= MAX_SSE_CLIENTS) { + res.writeHead(503, { "Content-Type": "text/plain", "Retry-After": "5" }); + res.end("too many event subscribers"); + return; + } res.writeHead(200, { "Content-Type": "text/event-stream", "Cache-Control": "no-cache", @@ -307,6 +431,15 @@ export function createCanvasRuntime(config) { * @returns {Promise<{url:string,title:string,status?:string}>} */ async function openInstance({ instanceId, input, ctx }) { + // Validate the open input against the declared inputSchema (same contract the + // actions get). A bad open payload fails fast with invalid_input rather than + // silently resolving the wrong domain. + if (config.inputSchema) { + const errs = validate(config.inputSchema, input ?? {}, "open input"); + if (errs.length) { + throw new CanvasKitError("invalid_input", `Invalid open input: ${errs.join("; ")}`); + } + } const domainId = config.resolveDomainId ? config.resolveDomainId(input ?? {}, ctx ?? {}) || "default" : "default"; @@ -341,6 +474,7 @@ export function createCanvasRuntime(config) { } async function shutdown() { + stopSync(); await Promise.all([...instances.keys()].map(closeInstance)); } @@ -348,6 +482,8 @@ export function createCanvasRuntime(config) { return (await getDomain(domainId)).state; } + startSync(); // no-op unless config.syncState + syncIntervalMs are set + return { config, setHost, @@ -358,5 +494,6 @@ export function createCanvasRuntime(config) { invokeFromAgent, // agent-side, resolves domain from ctx getState, _instances: instances, + _syncDomain: syncDomain, // manual one-shot sync (tests) }; } diff --git a/extensions/random-animal/canvas-kit/storage.mjs b/extensions/random-animal/canvas-kit/storage.mjs index 355983c..3f1177c 100644 --- a/extensions/random-animal/canvas-kit/storage.mjs +++ b/extensions/random-animal/canvas-kit/storage.mjs @@ -2,18 +2,61 @@ // // Durable JSON state helpers. State is keyed by a *domain id* (a stable logical // identifier resolved from the open input), never by instanceId — per -// create-canvas/SKILL.md. Two scopes: +// create-canvas/SKILL.md. Three tiers, matching the Canvas SDK storage model: // userStore -> $COPILOT_HOME/extensions//artifacts/ (per user, cross-session) -// workspaceStore -> / (per session) +// sessionStore -> $COPILOT_HOME/session-state//extensions// (per session, scratch) +// workspaceStore -> / (rooted at the session workspace) +// +// Sandboxing: callers derive the from a domain id that MUST be sanitized +// to a bare filename (the reference `fileFor` strips everything but +// [A-Za-z0-9._-]); the store never joins caller input as a path, so a domain id +// can't escape its extension's artifacts directory. -import { readFile, writeFile, mkdir, rename } from "node:fs/promises"; +import { readFile, writeFile, mkdir, rename, unlink } from "node:fs/promises"; import { join, dirname } from "node:path"; import { homedir } from "node:os"; +import { randomBytes } from "node:crypto"; function copilotHome() { return process.env.COPILOT_HOME || join(homedir(), ".copilot"); } +// Per-file write queue. Concurrent saves to the SAME durable file (an agent +// action and a UI action racing) must not overlap: on Windows two renames to the +// same destination collide with EPERM even with unique temp names. Chaining each +// save onto the previous one for that path serializes them (last enqueued wins), +// which is both correct (no interleaved writes) and portable. +const writeQueues = new Map(); // absolute file path -> tail Promise + +async function atomicWrite(file, data) { + await mkdir(dirname(file), { recursive: true }); + // Write to a temp sibling then atomically rename into place, so a crash or + // interruption mid-write can never truncate the existing durable file. + // rename(2) is atomic on the same filesystem. The temp name mixes pid + time + + // RANDOM bytes so two writers can never pick the same temp path. + const tmp = `${file}.${process.pid}.${Date.now()}.${randomBytes(6).toString("hex")}.tmp`; + await writeFile(tmp, JSON.stringify(data, null, 2), "utf8"); + try { + await rename(tmp, file); + } catch (err) { + // Windows can transiently EPERM/EACCES a rename when the destination is + // briefly held (AV scanner, indexer). Retry once after a short beat; always + // clean up our temp so a failed save leaves no orphaned *.tmp behind. + if (err?.code === "EPERM" || err?.code === "EACCES") { + await new Promise((r) => setTimeout(r, 25)); + try { + await rename(tmp, file); + } catch (err2) { + await unlink(tmp).catch(() => {}); + throw err2; + } + } else { + await unlink(tmp).catch(() => {}); + throw err; + } + } +} + function makeStore(file) { return { file, @@ -32,13 +75,15 @@ function makeStore(file) { } }, async save(data) { - await mkdir(dirname(file), { recursive: true }); - // Write to a temp sibling then atomically rename into place, so a crash or - // interruption mid-write can never truncate the existing durable file. - // rename(2) is atomic on the same filesystem. - const tmp = `${file}.${process.pid}.${Date.now()}.tmp`; - await writeFile(tmp, JSON.stringify(data, null, 2), "utf8"); - await rename(tmp, file); + // Serialize behind any in-flight save for this exact path (see writeQueues). + const prev = writeQueues.get(file) ?? Promise.resolve(); + const run = prev.catch(() => {}).then(() => atomicWrite(file, data)); + writeQueues.set(file, run); + try { + await run; + } finally { + if (writeQueues.get(file) === run) writeQueues.delete(file); + } }, }; } @@ -48,6 +93,24 @@ export function userStore(extensionName, fileName) { return makeStore(join(copilotHome(), "extensions", extensionName, "artifacts", fileName)); } +/** + * Per-session scratch store for a named extension, rooted under the session's + * state directory. Use for state that should NOT outlive the session (drafts, + * one-off working data). Pass the session id (e.g. from the SDK ctx). + */ +export function sessionStore(sessionId, extensionName, fileName) { + // Reduce the session id to a single safe path segment. The charset filter keeps + // "." (so dotted ids survive), so we must ALSO collapse any ".." run — otherwise + // a session id of ".." would join to one level ABOVE session-state and escape the + // per-session root. (sessionId is normally a trusted SDK value; this keeps the + // sanitizer honest regardless.) + const safeSession = + String(sessionId) + .replace(/[^A-Za-z0-9._-]/g, "_") + .replace(/\.\.+/g, "_") || "default"; + return makeStore(join(copilotHome(), "session-state", safeSession, "extensions", extensionName, fileName)); +} + /** Per-session store rooted at the session workspace path. */ export function workspaceStore(workspacePath, fileName) { return makeStore(join(workspacePath, fileName)); diff --git a/extensions/random-animal/canvas-kit/validate.mjs b/extensions/random-animal/canvas-kit/validate.mjs new file mode 100644 index 0000000..d44925e --- /dev/null +++ b/extensions/random-animal/canvas-kit/validate.mjs @@ -0,0 +1,146 @@ +// canvas-kit/validate.mjs +// +// A tiny, dependency-free JSON-Schema-*subset* validator. It exists so the +// runtime can ENFORCE the action `inputSchema` (and an optional `stateSchema`) +// that authors already declare — turning the iframe↔extension / agent↔extension +// contract from "declared but unchecked" into "validated at the boundary". No +// npm dependency (the kit ships vendored, no install step), and it only covers +// the JSON Schema features the kit's schemas actually use: +// +// type "object" | "array" | "string" | "number" | "integer" | +// "boolean" | "null" (or an array of those) +// properties per-key subschemas (objects) +// required array of required property names (objects) +// additionalProperties false | subschema (objects) +// items subschema for every element (arrays) +// enum allowed literal values (deep-equal) +// minLength/maxLength string length bounds +// minimum/maximum numeric bounds +// minItems/maxItems array length bounds +// +// It is intentionally forgiving: an absent/empty schema validates anything, and +// unknown keywords are ignored (so a richer schema never hard-fails here). The +// goal is to catch the shape mistakes that actually break canvases — wrong type, +// missing required field, unknown/typo'd property, out-of-enum value — not to be +// a complete JSON Schema implementation. + +function typeOf(value) { + if (value === null) return "null"; + if (Array.isArray(value)) return "array"; + if (Number.isInteger(value)) return "integer"; + return typeof value; // "string" | "number" | "boolean" | "object" | "undefined" +} + +// Does `value` satisfy a single JSON Schema `type` token? "number" accepts +// integers; "integer" requires a whole number. +function matchesType(value, t) { + const actual = typeOf(value); + if (t === "number") return actual === "number" || actual === "integer"; + if (t === "integer") return actual === "integer"; + return actual === t; +} + +function deepEqual(a, b) { + if (a === b) return true; + if (typeof a !== typeof b || a === null || b === null) return false; + if (Array.isArray(a) && Array.isArray(b)) { + return a.length === b.length && a.every((x, i) => deepEqual(x, b[i])); + } + if (typeof a === "object" && typeof b === "object") { + const ak = Object.keys(a), bk = Object.keys(b); + return ak.length === bk.length && ak.every((k) => deepEqual(a[k], b[k])); + } + return false; +} + +/** + * Validate `value` against a JSON-Schema-subset `schema`. + * @param {object|undefined} schema + * @param {any} value + * @param {string} [path] dotted path used in error messages (default "input") + * @returns {string[]} human-readable error messages; empty array = valid. + */ +export function validate(schema, value, path = "input") { + const errors = []; + walk(schema, value, path, errors); + return errors; +} + +function walk(schema, value, path, errors) { + if (!schema || typeof schema !== "object") return; // absent/degenerate schema = anything goes + + // type (single token or array of tokens) + if (schema.type !== undefined) { + const types = Array.isArray(schema.type) ? schema.type : [schema.type]; + if (!types.some((t) => matchesType(value, t))) { + errors.push(`${path}: expected ${types.join(" | ")}, got ${typeOf(value)}`); + return; // a wrong base type makes deeper checks meaningless + } + } + + // enum + if (Array.isArray(schema.enum) && !schema.enum.some((allowed) => deepEqual(allowed, value))) { + errors.push(`${path}: must be one of ${schema.enum.map((v) => JSON.stringify(v)).join(", ")}`); + } + + const kind = typeOf(value); + + if (kind === "string") { + if (typeof schema.minLength === "number" && value.length < schema.minLength) { + errors.push(`${path}: must be at least ${schema.minLength} characters`); + } + if (typeof schema.maxLength === "number" && value.length > schema.maxLength) { + errors.push(`${path}: must be at most ${schema.maxLength} characters`); + } + } + + if (kind === "number" || kind === "integer") { + if (typeof schema.minimum === "number" && value < schema.minimum) { + errors.push(`${path}: must be >= ${schema.minimum}`); + } + if (typeof schema.maximum === "number" && value > schema.maximum) { + errors.push(`${path}: must be <= ${schema.maximum}`); + } + } + + if (kind === "array") { + if (typeof schema.minItems === "number" && value.length < schema.minItems) { + errors.push(`${path}: must have at least ${schema.minItems} item(s)`); + } + if (typeof schema.maxItems === "number" && value.length > schema.maxItems) { + errors.push(`${path}: must have at most ${schema.maxItems} item(s)`); + } + if (schema.items) { + value.forEach((item, i) => walk(schema.items, item, `${path}[${i}]`, errors)); + } + } + + if (kind === "object") { + const props = schema.properties ?? {}; + if (Array.isArray(schema.required)) { + for (const key of schema.required) { + // Object.hasOwn (not `value[key] === undefined`): a required property + // named after a prototype member (e.g. "toString", "constructor") would + // otherwise read the inherited value and wrongly pass the check. + if (!Object.hasOwn(value, key)) errors.push(`${path}.${key}: required`); + } + } + for (const [key, sub] of Object.entries(props)) { + if (Object.hasOwn(value, key)) walk(sub, value[key], `${path}.${key}`, errors); + } + // Membership is tested with Object.hasOwn, NOT `key in props`: `in` walks the + // prototype chain, so an extra key named "toString"/"constructor"/"__proto__" + // etc. would satisfy `key in props` and escape additionalProperties. This + // validator is the enforcement boundary (input → 400, state → 500), so that + // would be a real contract hole. + if (schema.additionalProperties === false) { + for (const key of Object.keys(value)) { + if (!Object.hasOwn(props, key)) errors.push(`${path}.${key}: unexpected property`); + } + } else if (schema.additionalProperties && typeof schema.additionalProperties === "object") { + for (const key of Object.keys(value)) { + if (!Object.hasOwn(props, key)) walk(schema.additionalProperties, value[key], `${path}.${key}`, errors); + } + } + } +} diff --git a/extensions/random-animal/canvas-kit/version.mjs b/extensions/random-animal/canvas-kit/version.mjs index 93471a9..6bff1cc 100644 --- a/extensions/random-animal/canvas-kit/version.mjs +++ b/extensions/random-animal/canvas-kit/version.mjs @@ -10,4 +10,4 @@ // convention (the skill ships as files, not an npm version); a commit short-sha // works too. Re-exported from client.mjs so a canvas can read it at runtime. -export const KIT_VERSION = "2026-07-05.1"; +export const KIT_VERSION = "2026-07-07.6"; diff --git a/extensions/stock-ticker/canvas-kit/.kit-version.json b/extensions/stock-ticker/canvas-kit/.kit-version.json index 80c9034..fe19040 100644 --- a/extensions/stock-ticker/canvas-kit/.kit-version.json +++ b/extensions/stock-ticker/canvas-kit/.kit-version.json @@ -1,5 +1,5 @@ { - "version": "2026-07-05.1", - "syncedAt": "2026-07-06T05:14:32.390Z", + "version": "2026-07-07.6", + "syncedAt": "2026-07-08T01:54:39.602Z", "source": "create-canvas-app/kit" } diff --git a/extensions/stock-ticker/canvas-kit/client.mjs b/extensions/stock-ticker/canvas-kit/client.mjs index 75e6e65..6f4cd9c 100644 --- a/extensions/stock-ticker/canvas-kit/client.mjs +++ b/extensions/stock-ticker/canvas-kit/client.mjs @@ -85,25 +85,21 @@ export function pollWhileVisible(tick, seconds, { whenVisible = true, immediate } /** - * Mount a canvas view and keep it live. - * @param {object} opts - * @param {(model:{state:any, invoke:Function, connected:boolean})=>any} opts.view - * Returns an htm/Preact vnode. Re-invoked on every state push. - * @param {HTMLElement} [opts.mount] defaults to #app or - * @param {(state:any)=>void} [opts.onState] - * @param {PollOptions} [opts.poll] built-in fixed-interval visibility-gated auto-refresh. - * For an interval bound to live state, use `pollWhileVisible` in a useEffect instead. - * @returns {{invoke:Function, refresh:Function, stopPoll:Function, get state():any}} + * DOM-FREE transport for a canvas: owns the loopback wiring (GET /state, GET + * /events SSE, POST /action) and the derived `state`/`connected`, with no Preact + * and no DOM. `mountCanvas` composes this with a render loop; keeping it separate + * makes the reconnect/invoke glue unit-testable without a browser (see + * test/client.test.mjs). Both callbacks receive the latest `(state, connected)`. + * @param {object} [opts] + * @param {(state:any, connected:boolean)=>void} [opts.onState] fired on the initial /state and every SSE push + * @param {(connected:boolean)=>void} [opts.onConnected] fired when the SSE stream opens/errors + * @param {typeof EventSource} [opts.EventSourceImpl] override the SSE impl (tests); defaults to the global + * @returns {{invoke:Function, refresh:Function, get state():any, get connected():boolean}} */ -export function mountCanvas({ view, mount, onState, poll } = {}) { - const root = mount || document.getElementById("app") || document.body; - // Preact's render() diffs against — but does not clear — pre-existing DOM in - // the container, so a static no-JS placeholder (e.g.

Loading…

in the - // HTML shell) would linger as a sibling. Clear it once so Preact owns an empty - // root; the view's own loading branch covers the gap until first state. - root.replaceChildren(); +export function connectCanvas({ onState, onConnected, EventSourceImpl } = {}) { let state = null; let connected = false; + const ES = EventSourceImpl || (typeof EventSource !== "undefined" ? EventSource : null); async function invoke(actionName, input) { const res = await fetch("./action", { @@ -118,20 +114,16 @@ export function mountCanvas({ view, mount, onState, poll } = {}) { return data.result; } - function rerender() { - render(view({ state, invoke, connected }), root); - } - async function refresh() { try { state = await (await fetch("./state")).json(); - onState?.(state); - rerender(); + onState?.(state, connected); } catch { /* offline; SSE will recover */ } } function connect() { - const es = new EventSource("./events"); + if (!ES) return; // no EventSource (e.g. a non-browser host); refresh() still works + const es = new ES("./events"); es.onmessage = (e) => { let next; try { @@ -139,20 +131,69 @@ export function mountCanvas({ view, mount, onState, poll } = {}) { } catch { return; // ignore a malformed SSE frame; the next push recovers } - // Update + render OUTSIDE the try so a bug in onState/the view surfaces as - // a real error instead of being silently mislabeled a "malformed frame". + // Update OUTSIDE the try so a bug in onState surfaces as a real error + // instead of being silently mislabeled a "malformed frame". state = next; connected = true; - onState?.(state); - rerender(); + onState?.(state, connected); }; - es.onopen = () => { connected = true; rerender(); }; - es.onerror = () => { connected = false; rerender(); /* EventSource auto-reconnects */ }; + es.onopen = () => { connected = true; onConnected?.(connected); }; + es.onerror = () => { connected = false; onConnected?.(connected); /* EventSource auto-reconnects */ }; } refresh(); connect(); + return { + invoke, + refresh, + get state() { return state; }, + get connected() { return connected; }, + }; +} + +/** + * Mount a canvas view and keep it live. + * @param {object} opts + * @param {(model:{state:any, invoke:Function, connected:boolean})=>any} opts.view + * Returns an htm/Preact vnode. Re-invoked on every state push. + * @param {HTMLElement} [opts.mount] defaults to #app or + * @param {(state:any)=>void} [opts.onState] + * @param {PollOptions} [opts.poll] built-in fixed-interval visibility-gated auto-refresh. + * For an interval bound to live state, use `pollWhileVisible` in a useEffect instead. + * @returns {{invoke:Function, refresh:Function, stopPoll:Function, get state():any}} + */ +export function mountCanvas({ view, mount, onState, poll } = {}) { + const root = mount || document.getElementById("app") || document.body; + // Preact's render() diffs against — but does not clear — pre-existing DOM in + // the container, so a static no-JS placeholder (e.g.

Loading…

in the + // HTML shell) would linger as a sibling. Clear it once so Preact owns an empty + // root; the view's own loading branch covers the gap until first state. + root.replaceChildren(); + + let latestState = null; + let latestConnected = false; + let client; + + function rerender() { + render(view({ state: latestState, invoke: client.invoke, connected: latestConnected }), root); + } + + // The transport is DOM-free (connectCanvas); this wrapper only adds the Preact + // render on each state/connection change. + client = connectCanvas({ + onState: (state, connected) => { + latestState = state; + latestConnected = connected; + onState?.(state); + rerender(); + }, + onConnected: (connected) => { + latestConnected = connected; + rerender(); + }, + }); + // Built-in fixed-interval auto-refresh, delegating to the shared // visibility-gated primitive. Pass `poll: { action, seconds, immediate }`. // @@ -164,7 +205,7 @@ export function mountCanvas({ view, mount, onState, poll } = {}) { // @property {boolean} [immediate=false] fire one tick right after mount function startPoll({ action, seconds, input, whenVisible = true, immediate = false } = {}) { return pollWhileVisible( - () => (action ? invoke(action, input) : refresh()), + () => (action ? client.invoke(action, input) : client.refresh()), seconds, { whenVisible, immediate } ); @@ -174,10 +215,10 @@ export function mountCanvas({ view, mount, onState, poll } = {}) { if (poll) stopPoll = startPoll(poll); return { - invoke, - refresh, + invoke: client.invoke, + refresh: client.refresh, stopPoll: () => stopPoll(), - get state() { return state; }, + get state() { return latestState; }, }; } diff --git a/extensions/stock-ticker/canvas-kit/github-store.mjs b/extensions/stock-ticker/canvas-kit/github-store.mjs new file mode 100644 index 0000000..d89a136 --- /dev/null +++ b/extensions/stock-ticker/canvas-kit/github-store.mjs @@ -0,0 +1,215 @@ +// canvas-kit/github-store.mjs +// +// A SHARED, multi-writer durable store backed by a file in a GitHub repository. +// Where userStore/sessionStore/workspaceStore (storage.mjs) persist to local disk +// — private to one machine — githubStore persists the same JSON to a file in a +// repo via the Contents API, so every collaborator who can push to that repo edits +// ONE shared document. GitHub is the backing store AND the access-control layer +// (invite collaborators to a private repo); there is no server to run. +// +// It exposes the same { load, save } shape the runtime's loadState/saveState +// expect, plus poll() for cheap change-detection so a canvas can pull other +// people's edits live (wire it to server.mjs's syncState + syncIntervalMs). +// +// Concurrency: every write carries the blob sha it read (optimistic lock). A +// concurrent commit makes the PUT 409; save() re-reads to refresh the sha and +// retries. The default policy is last-writer-wins for the whole document; pass a +// merge(remoteState, myState) to resolve conflicts field-by-field instead (e.g. +// union a board's concerns by id). Reads use an ETag If-None-Match so an unchanged +// poll is a cheap 304 with no body. +// +// Token: resolved once from opts.token (string | async () => string), then +// GH_TOKEN / GITHUB_TOKEN, then `gh auth token`. Needs the `repo` scope for a +// private repo. The token is only ever sent as an Authorization header — never +// logged, never written to the repo. + +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; + +const execFileAsync = promisify(execFile); + +const API = "https://api.github.com"; +const UA = "canvas-kit-github-store"; +// Hard deadline for every GitHub API call so a hung request (stalled TLS, dropped +// connection) can't freeze a user action or dead-lock the sync loop (mirrors the +// AbortSignal.timeout pattern in net.mjs safeFetch). +const DEFAULT_TIMEOUT_MS = 15000; +// Refuse an implausibly large state file rather than allocate it on every poll. A +// board's JSON is tens of KB; this is a safety ceiling, not a real size limit. +const MAX_RESPONSE_BYTES = 8 * 1024 * 1024; +// Bounded save retries on an optimistic-lock conflict (409) before failing loud. +const MAX_SAVE_RETRIES = 3; + +// Resolve a GitHub token lazily and memoize it. A 401 (expired/rotated) clears the +// cache via invalidate() so the next call re-resolves. +function makeTokenResolver(tokenOpt) { + let cached = null; + async function fromGhCli() { + try { + const { stdout } = await execFileAsync("gh", ["auth", "token"], { windowsHide: true }); + return String(stdout).trim() || null; + } catch { + return null; // gh missing or not logged in → fall through to the no-token error + } + } + return { + async get() { + if (cached) return cached; + let t = null; + if (typeof tokenOpt === "function") t = await tokenOpt(); + else if (typeof tokenOpt === "string" && tokenOpt) t = tokenOpt; + if (!t) t = process.env.GH_TOKEN || process.env.GITHUB_TOKEN || null; + if (!t) t = await fromGhCli(); + if (!t) throw new Error("githubStore: no GitHub token (set GH_TOKEN or run `gh auth login`)"); + cached = t; + return cached; + }, + invalidate() { cached = null; }, + }; +} + +function encodePath(path) { + // Keep the slashes as path separators in the Contents API URL; encode each + // segment so spaces / unicode in a filename can't break the request. + return String(path).split("/").filter(Boolean).map(encodeURIComponent).join("/"); +} + +/** + * A GitHub-repo-backed durable store: one JSON file, many writers. + * @param {object} opts + * @param {string} opts.owner repo owner (user or org) + * @param {string} opts.repo repo name + * @param {string} opts.path path to the JSON file in the repo (e.g. "state/board.json") + * @param {string} [opts.branch="main"] + * @param {string|(()=>Promise|string)} [opts.token] token or async resolver + * @param {(remote:any,mine:any)=>any} [opts.merge] conflict resolver (default: last-writer-wins) + * @param {(state:any)=>string} [opts.message] commit message from the state (default: a timestamp) + * @param {string} [opts.apiBase=API] + * @returns {{file:string, load:(fallback?:any)=>Promise, poll:()=>Promise<{changed:boolean,state?:any}>, save:(state:any)=>Promise}} + */ +export function githubStore(opts) { + const { owner, repo, path, branch = "main", token, merge, message, apiBase = API } = opts ?? {}; + if (!owner || !repo || !path) throw new Error("githubStore: owner, repo and path are required"); + // apiBase is a trusted, fixed host by design — GitHub's public API by default, or a + // GitHub Enterprise host the canvas author sets at construction (never runtime + // input). That's why we call fetch() directly rather than the kit's safeFetch SSRF + // guard, which would add a DNS-resolution check on every call for a host that can't + // vary per request. Still, require https for any custom apiBase as defense-in-depth. + if (apiBase !== API) { + let base; + try { base = new URL(apiBase); } catch { throw new Error("githubStore: apiBase must be a valid URL"); } + if (base.protocol !== "https:") throw new Error("githubStore: apiBase must be https"); + } + + const tok = makeTokenResolver(token); + const contentsUrl = `${apiBase}/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/contents/${encodePath(path)}`; + const label = `${owner}/${repo}:${path}`; + + let sha = null; // last-seen blob sha — sent on write as the optimistic lock + let etag = null; // last-seen ETag — sent on poll as If-None-Match + let lastText = null; // last-seen decoded content — guards against no-op broadcasts + + async function api(url, init = {}, allow = []) { + const t = await tok.get(); + const res = await fetch(url, { + ...init, + signal: AbortSignal.timeout(DEFAULT_TIMEOUT_MS), + headers: { + Authorization: `Bearer ${t}`, + Accept: "application/vnd.github+json", + "X-GitHub-Api-Version": "2022-11-28", + "User-Agent": UA, + ...(init.headers ?? {}), + }, + }); + // An expired token surfaces as 401 — drop the memoized token so a retry can + // re-resolve (e.g. after `gh auth refresh`), then surface the failure. + if (res.status === 401) { tok.invalidate(); } + // ok, or an expected status the caller handles (404 fresh-file, 304 unchanged, + // 409 write conflict), pass through; anything else is a hard error. + if (res.ok || allow.includes(res.status)) return res; + const body = await res.text().catch(() => ""); + throw new Error(`githubStore ${label}: ${init.method ?? "GET"} ${res.status} ${body.slice(0, 200)}`); + } + + function decode(json) { + // Contents API returns base64 (wrapped at 60 cols); Buffer handles the newlines. + return Buffer.from(json.content ?? "", "base64").toString("utf8"); + } + + async function readJson(res) { + // Bound the allocation: refuse an oversized response before reading it, so a + // huge state file can't be pulled into memory on every poll tick. + const len = Number(res.headers.get("content-length") || 0); + if (len > MAX_RESPONSE_BYTES) { + throw new Error(`githubStore ${label}: response too large (${len} bytes)`); + } + return res.json(); + } + + async function load(fallback = null) { + const res = await api(`${contentsUrl}?ref=${encodeURIComponent(branch)}`, {}, [404]); + if (res.status === 404) { sha = null; etag = null; lastText = null; return fallback; } + const json = await readJson(res); + sha = json.sha ?? null; + etag = res.headers.get("etag"); + const text = decode(json); + lastText = text; + return text.trim() ? JSON.parse(text) : fallback; + } + + async function poll() { + const headers = etag ? { "If-None-Match": etag } : {}; + const res = await api(`${contentsUrl}?ref=${encodeURIComponent(branch)}`, { headers }, [304, 404]); + if (res.status === 304) return { changed: false }; + if (res.status === 404) { + if (sha === null && lastText === null) return { changed: false }; + sha = null; etag = null; lastText = null; + return { changed: true, state: null }; + } + const json = await readJson(res); + const text = decode(json); + sha = json.sha ?? null; + etag = res.headers.get("etag"); + if (text === lastText) return { changed: false }; + lastText = text; + return { changed: true, state: text.trim() ? JSON.parse(text) : null }; + } + + async function save(state) { + let toWrite = state; + for (let attempt = 0; ; attempt++) { + const text = JSON.stringify(toWrite, null, 2); + const body = { + message: (message ? message(toWrite) : `Update ${path}`) || `Update ${path}`, + content: Buffer.from(text, "utf8").toString("base64"), + branch, + ...(sha ? { sha } : {}), + }; + const res = await api(contentsUrl, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }, [409, 404]); + if (res.ok) { + const json = await res.json(); + sha = json.content?.sha ?? null; + etag = null; // PUT's ETag isn't the content GET's — force a fresh poll baseline + lastText = text; // our own write must not read back as a change + return; + } + // 409 (or a 404 if the file/branch vanished): the sha we held is stale. + // Re-read to refresh sha, optionally merge the remote with our intended + // write, and retry. Bounded so a persistent conflict fails loudly. + if ((res.status === 409 || res.status === 404) && attempt < MAX_SAVE_RETRIES) { + const remote = await load(null); + toWrite = merge ? merge(remote, state) : state; // default: last-writer-wins + continue; + } + const errBody = await res.text().catch(() => ""); + throw new Error(`githubStore ${label}: save failed ${res.status} ${errBody.slice(0, 200)}`); + } + } + + return { file: label, load, poll, save }; +} diff --git a/extensions/stock-ticker/canvas-kit/icons.mjs b/extensions/stock-ticker/canvas-kit/icons.mjs index 9dd30ea..04eb2e9 100644 --- a/extensions/stock-ticker/canvas-kit/icons.mjs +++ b/extensions/stock-ticker/canvas-kit/icons.mjs @@ -23,7 +23,18 @@ const VIEWBOX = "0 0 24 24"; const STROKE_WIDTH = 2; function resolve(name) { - return LUCIDE[name] || LUCIDE[aliases[name]] || null; + // Own-property lookups only. Bracket access on a plain object reaches inherited + // Object.prototype members, so a name like "toString"/"constructor"/ + // "hasOwnProperty" would otherwise resolve to a function (making hasIcon() lie + // and Icon()/lucideSVG() throw in nodeToString). Object.hasOwn keeps resolution + // to the real, vendored icon set. + if (typeof name !== "string") return null; + if (Object.hasOwn(LUCIDE, name)) return LUCIDE[name]; + if (Object.hasOwn(aliases, name)) { + const target = aliases[name]; + if (Object.hasOwn(LUCIDE, target)) return LUCIDE[target]; + } + return null; } function kebab(k) { diff --git a/extensions/stock-ticker/canvas-kit/net.mjs b/extensions/stock-ticker/canvas-kit/net.mjs new file mode 100644 index 0000000..fc1618c --- /dev/null +++ b/extensions/stock-ticker/canvas-kit/net.mjs @@ -0,0 +1,126 @@ +// canvas-kit/net.mjs +// +// Server-side network safety for canvases that fetch external data. A canvas +// action runs on the loopback runtime with the app's network reach, so a +// caller-influenced URL is an SSRF risk: it could target cloud metadata +// (169.254.169.254), loopback, or the private network. This module is the kit's +// sanctioned egress primitive — validate a URL, then fetch it with a hard +// timeout — so every data canvas guards the same way instead of re-inlining the +// check. SERVER-ONLY (uses node:dns / node:net): import it from the SDK-free +// canvas.mjs, never from the browser view. +// +// Defense-in-depth, not a guarantee: a determined attacker could DNS-rebind +// between the resolve check and the connect. For a canvas pointed at a source +// you choose this is adequate; treat any fetched content as untrusted and render +// it as TEXT (never innerHTML). + +import { lookup } from "node:dns/promises"; +import { isIP } from "node:net"; + +/** + * True for addresses a server-side fetch should never reach: loopback, + * link-local (incl. cloud metadata 169.254.169.254), and private/CGNAT ranges. + * @param {string} ip + * @returns {boolean} + */ +export function isBlockedAddress(ip) { + const lower = ip.toLowerCase(); + // Decode an IPv4-mapped/compatible IPv6 literal down to its embedded IPv4 so + // the IPv4 range checks apply. Covers BOTH the dotted tail (::ffff:127.0.0.1) + // and the HEX tail that `new URL()` normalizes literals to (::ffff:7f00:1) — + // without the decode, `[::ffff:127.0.0.1]` reaches loopback and + // `[::ffff:a9fe:a9fe]` reaches cloud metadata. Only matches when the high bits + // are all zero (leading "::"), so a normal public v6 (e.g. 2001:db8::7f00:1) is + // never misread as IPv4. + const addr = embeddedIPv4(lower) ?? ip; + if (isIP(addr) === 4) { + const [a, b] = addr.split(".").map(Number); + if (a === 0 || a === 127 || a === 10) return true; // this-host / loopback / private + if (a === 169 && b === 254) return true; // link-local (incl. cloud IMDS) + if (a === 172 && b >= 16 && b <= 31) return true; // private + if (a === 192 && b === 168) return true; // private + if (a === 100 && b >= 64 && b <= 127) return true; // CGNAT + return false; + } + return lower === "::1" || lower === "::" || lower.startsWith("fe80") || lower.startsWith("fc") || lower.startsWith("fd"); +} + +// Decode an IPv4-mapped/compatible IPv6 literal (all-zero high groups, i.e. a +// leading "::", optionally "::ffff:") to its dotted IPv4; else null. Accepts the +// dotted tail (127.0.0.1) and the two-hex-group tail (7f00:1) that URL +// normalization produces. Anchored on "::" so it can't misread a public v6. +function embeddedIPv4(addr) { + const m = addr.match(/^::(?:ffff:)?((?:\d{1,3}\.){3}\d{1,3}|[0-9a-f]{1,4}:[0-9a-f]{1,4})$/); + if (!m) return null; + const tail = m[1]; + if (tail.includes(".")) return isIP(tail) === 4 ? tail : null; + const [h1, h2] = tail.split(":"); + const hi = parseInt(h1, 16), lo = parseInt(h2, 16); + return `${hi >> 8}.${hi & 255}.${lo >> 8}.${lo & 255}`; +} + +/** + * Allow only http/https to a PUBLIC host. Rejects every address the hostname + * resolves to, so a public name can't be pointed at an internal IP. Throws on a + * blocked/invalid URL; resolves to void when the URL is safe to fetch. + * @param {string} url + */ +export async function assertPublicUrl(url) { + let u; + try { u = new URL(url); } catch { throw new Error("Invalid source URL"); } + if (u.protocol !== "http:" && u.protocol !== "https:") { + throw new Error("Blocked URL protocol: " + u.protocol); + } + let host = u.hostname; + if (host.startsWith("[") && host.endsWith("]")) host = host.slice(1, -1); // IPv6 brackets + if (host.toLowerCase() === "localhost") throw new Error("Blocked host: localhost"); + const addrs = isIP(host) ? [host] : (await lookup(host, { all: true })).map((r) => r.address); + if (!addrs.length) throw new Error("Could not resolve host: " + host); + for (const ip of addrs) { + if (isBlockedAddress(ip)) throw new Error("Blocked private/loopback address: " + ip); + } +} + +/** + * SSRF-guarded fetch with a mandatory timeout. Runs assertPublicUrl on the URL + * and on EVERY redirect hop, so a chosen public host can't 30x-redirect the + * request into loopback/metadata/private space. Returns the final Response as-is + * (does NOT throw on a non-2xx status — the caller checks res.ok), so it is a + * drop-in for a guarded fetch(). + * + * Redirects are followed MANUALLY (redirect:"manual") rather than by fetch's + * default redirect:"follow": the default would chase a 3xx Location without + * re-running the guard, which silently undoes every check in this module. Here + * each hop's target is re-validated before we connect to it. + * @param {string} url + * @param {object} [opts] + * @param {number} [opts.timeoutMs=12000] abort the WHOLE operation (all hops) after this many ms + * @param {number} [opts.maxRedirects=5] how many 3xx hops to follow before giving up + * @param {object} [opts.headers] request headers (merged as-is) + * @param {RequestInit} [opts.rest] any other fetch init (method, body, …) + * @returns {Promise} + */ +export async function safeFetch(url, { timeoutMs = 12000, maxRedirects = 5, headers, ...rest } = {}) { + // One timeout signal bounds the entire operation, redirects included, so a + // chain of slow hops can't multiply the deadline. + const signal = AbortSignal.timeout(timeoutMs); + let current = url; + for (let hop = 0; ; hop++) { + await assertPublicUrl(current); + const res = await fetch(current, { + ...rest, + headers: headers ?? {}, + redirect: "manual", + signal, + }); + if (res.status >= 300 && res.status < 400 && res.headers.has("location")) { + if (hop >= maxRedirects) throw new Error("Too many redirects"); + const next = new URL(res.headers.get("location"), current).href; + // Discard the redirect body so the connection can be freed before the next hop. + try { await res.body?.cancel(); } catch { /* no body / already consumed */ } + current = next; + continue; + } + return res; + } +} diff --git a/extensions/stock-ticker/canvas-kit/server.mjs b/extensions/stock-ticker/canvas-kit/server.mjs index bf3a07f..eb533c9 100644 --- a/extensions/stock-ticker/canvas-kit/server.mjs +++ b/extensions/stock-ticker/canvas-kit/server.mjs @@ -20,6 +20,7 @@ import { createServer } from "node:http"; import { readFile } from "node:fs/promises"; import { join, normalize, extname, sep } from "node:path"; import { fileURLToPath } from "node:url"; +import { validate } from "./validate.mjs"; const KIT_DIR = fileURLToPath(new URL(".", import.meta.url)); @@ -52,7 +53,10 @@ export class CanvasKitError extends Error { * @param {(ctx:object)=>any|Promise} [config.createInitialState] * @param {(domainId:string)=>any|Promise} [config.loadState] * @param {(domainId:string, state:any)=>void|Promise} [config.saveState] + * @param {(domainId:string)=>{changed:boolean,state?:any}|Promise<{changed:boolean,state?:any}>} [config.syncState] optional delta-poll of a SHARED durable source (e.g. githubStore.poll); when set with syncIntervalMs, remote changes are adopted + broadcast to viewers live + * @param {number} [config.syncIntervalMs] poll cadence for syncState; only polled while a domain has >=1 connected viewer * @param {Record} config.actions + * @param {object} [config.stateSchema] optional JSON-Schema-subset for the durable state; when set, a mutation that violates it is rolled back and fails (500) * @param {string} config.assetsDir absolute path to the canvas web/ folder * @param {(ctx:object,state:any)=>string} [config.statusLine] */ @@ -112,14 +116,110 @@ export function createCanvasRuntime(config) { } } + // ---- optional shared-state sync (repo-backed multiplayer) ---------------- + // When config.syncState + config.syncIntervalMs are set, poll the durable + // source on an interval and adopt+broadcast remote changes, so collaborators + // editing a SHARED store (e.g. githubStore) see each other's edits live. We + // only poll a domain while at least one SSE client is watching it — no viewers, + // no network — so API usage stays proportional to real use. syncState returns + // { changed:boolean, state? }; a cheap unchanged poll (ETag 304) is changed:false. + const syncing = new Set(); // domainIds with an in-flight poll (prevents overlap) + let syncTimer = null; + + function domainHasViewers(domainId) { + for (const inst of instances.values()) { + if (inst.domainId === domainId && inst.clients.size > 0) return true; + } + return false; + } + + async function syncDomain(domainId) { + if (syncing.has(domainId)) return; // last tick still in flight — skip + syncing.add(domainId); + try { + const out = await config.syncState(domainId); + // Adopt only a real, non-null remote state. A null (file deleted upstream) + // is ignored so a transient upstream gap can't blank a live board. + if (out?.changed && out.state != null) { + const d = domains.get(domainId); + if (d) { + // Adopted remote state must clear the SAME stateSchema gate the invoke() + // path enforces. A collaborator (or a hand-edit on github.com) can push a + // shape that violates the schema; adopting it unvalidated would broadcast a + // corrupt shape to every viewer AND poison the next invoke()'s rollback + // baseline (structuredClone would snapshot the already-invalid state). + // Reject (skip) an invalid remote instead — the next valid write reconciles. + if (config.stateSchema && validate(config.stateSchema, out.state, "sync-remote").length) { + return; + } + d.state = out.state; + broadcast(domainId); + } + } + } catch { + // A transient network/API error must not kill the timer; retry next tick. + } finally { + syncing.delete(domainId); + } + } + + function syncTick() { + for (const domainId of domains.keys()) { + if (domainHasViewers(domainId)) syncDomain(domainId); + } + } + + // Floor the poll cadence: a mistakenly tiny interval (e.g. 1ms) would hammer the + // durable source — for githubStore that means burning the GitHub API rate limit in + // seconds. 2s is well below any real collaboration-latency need. + const MIN_SYNC_INTERVAL_MS = 2000; + + function startSync() { + if (syncTimer || typeof config.syncState !== "function") return; + const requested = Number(config.syncIntervalMs) || 0; + if (requested <= 0) return; + const ms = Math.max(requested, MIN_SYNC_INTERVAL_MS); + syncTimer = setInterval(syncTick, ms); + syncTimer.unref?.(); // never keep the process alive just to poll + } + + function stopSync() { + if (syncTimer) { clearInterval(syncTimer); syncTimer = null; } + } + // Core action invoker — the single code path behind both agent and UI actions. async function invoke(actionName, input, ctx) { - const action = config.actions[actionName]; + // Own-property lookup: `config.actions[actionName]` via bracket access would + // otherwise reach inherited members (e.g. "constructor", "toString"). The + // handler-typeof check below already rejects those, but resolving by + // Object.hasOwn keeps the boundary explicit and consistent with validate.mjs. + const action = + typeof actionName === "string" && Object.hasOwn(config.actions, actionName) + ? config.actions[actionName] + : null; if (!action || typeof action.handler !== "function") { throw new CanvasKitError("unknown_action", `Unknown action: ${actionName}`); } + // Enforce the declared inputSchema at the boundary (agent OR ui). The schema + // is a contract authors already write; validating it here turns "declared but + // unchecked" into a typed boundary and stops a malformed/typo'd payload from + // reaching the handler. A shape violation is the CALLER's fault → invalid_input + // (HTTP 400). Business rules ("title can't be blank") still live in the handler + // and surface as a 500, so a schema-valid-but-empty string reaches the handler. + if (action.inputSchema) { + const errs = validate(action.inputSchema, input ?? {}, "input"); + if (errs.length) { + throw new CanvasKitError("invalid_input", `Invalid input for '${actionName}': ${errs.join("; ")}`); + } + } const domainId = ctx?.domainId ?? "default"; const d = await getDomain(domainId, ctx); + // Deep snapshot for stateSchema rollback: a handler may mutate state IN PLACE + // and return the same object, so a reference copy (prevState = d.state) would + // point at the same (now-corrupt) object and restore nothing. structuredClone + // gives a real pre-mutation copy. Durable state is JSON-shaped, so it clones + // cleanly. Only pay the clone when a stateSchema is actually configured. + const prevState = config.stateSchema ? structuredClone(d.state) : undefined; let mutated = false; const api = { get state() { return d.state; }, @@ -161,6 +261,17 @@ export function createCanvasRuntime(config) { }; const result = await action.handler(api); if (mutated) { + // Optional stateSchema guards the durable shape: if a handler produced an + // invalid state, roll back the in-memory mutation and fail LOUD (a 500 — + // this is a handler bug, not caller input) instead of persisting/broadcasting + // corrupt state. Absent stateSchema, anything goes (opt-in). + if (config.stateSchema) { + const errs = validate(config.stateSchema, d.state, "state"); + if (errs.length) { + d.state = prevState; // roll back so in-memory stays consistent + throw new Error(`Action '${actionName}' produced invalid state: ${errs.join("; ")}`); + } + } if (config.saveState) await config.saveState(domainId, d.state); broadcast(domainId); } @@ -202,6 +313,12 @@ export function createCanvasRuntime(config) { // local client can't force unbounded memory growth on the loopback runtime. const MAX_BODY_BYTES = 1 << 20; // 1 MiB + // Cap concurrent SSE subscribers PER INSTANCE. A canvas panel needs only one + // /events stream (a couple across reopens); a runaway reconnect loop or a + // hostile local process hitting the loopback port could otherwise accumulate + // unbounded response handles + keep-alive timers. 64 is far above any real use. + const MAX_SSE_CLIENTS = 64; + function readBody(req) { return new Promise((resolve, reject) => { const chunks = []; @@ -240,6 +357,13 @@ export function createCanvasRuntime(config) { // GET /events — Server-Sent Events stream of state if (req.method === "GET" && path === "/events") { + // Refuse once an instance is saturated, so subscribers can't grow without + // bound (each holds a response handle + a keep-alive interval). + if (inst && inst.clients.size >= MAX_SSE_CLIENTS) { + res.writeHead(503, { "Content-Type": "text/plain", "Retry-After": "5" }); + res.end("too many event subscribers"); + return; + } res.writeHead(200, { "Content-Type": "text/event-stream", "Cache-Control": "no-cache", @@ -307,6 +431,15 @@ export function createCanvasRuntime(config) { * @returns {Promise<{url:string,title:string,status?:string}>} */ async function openInstance({ instanceId, input, ctx }) { + // Validate the open input against the declared inputSchema (same contract the + // actions get). A bad open payload fails fast with invalid_input rather than + // silently resolving the wrong domain. + if (config.inputSchema) { + const errs = validate(config.inputSchema, input ?? {}, "open input"); + if (errs.length) { + throw new CanvasKitError("invalid_input", `Invalid open input: ${errs.join("; ")}`); + } + } const domainId = config.resolveDomainId ? config.resolveDomainId(input ?? {}, ctx ?? {}) || "default" : "default"; @@ -341,6 +474,7 @@ export function createCanvasRuntime(config) { } async function shutdown() { + stopSync(); await Promise.all([...instances.keys()].map(closeInstance)); } @@ -348,6 +482,8 @@ export function createCanvasRuntime(config) { return (await getDomain(domainId)).state; } + startSync(); // no-op unless config.syncState + syncIntervalMs are set + return { config, setHost, @@ -358,5 +494,6 @@ export function createCanvasRuntime(config) { invokeFromAgent, // agent-side, resolves domain from ctx getState, _instances: instances, + _syncDomain: syncDomain, // manual one-shot sync (tests) }; } diff --git a/extensions/stock-ticker/canvas-kit/storage.mjs b/extensions/stock-ticker/canvas-kit/storage.mjs index 355983c..3f1177c 100644 --- a/extensions/stock-ticker/canvas-kit/storage.mjs +++ b/extensions/stock-ticker/canvas-kit/storage.mjs @@ -2,18 +2,61 @@ // // Durable JSON state helpers. State is keyed by a *domain id* (a stable logical // identifier resolved from the open input), never by instanceId — per -// create-canvas/SKILL.md. Two scopes: +// create-canvas/SKILL.md. Three tiers, matching the Canvas SDK storage model: // userStore -> $COPILOT_HOME/extensions//artifacts/ (per user, cross-session) -// workspaceStore -> / (per session) +// sessionStore -> $COPILOT_HOME/session-state//extensions// (per session, scratch) +// workspaceStore -> / (rooted at the session workspace) +// +// Sandboxing: callers derive the from a domain id that MUST be sanitized +// to a bare filename (the reference `fileFor` strips everything but +// [A-Za-z0-9._-]); the store never joins caller input as a path, so a domain id +// can't escape its extension's artifacts directory. -import { readFile, writeFile, mkdir, rename } from "node:fs/promises"; +import { readFile, writeFile, mkdir, rename, unlink } from "node:fs/promises"; import { join, dirname } from "node:path"; import { homedir } from "node:os"; +import { randomBytes } from "node:crypto"; function copilotHome() { return process.env.COPILOT_HOME || join(homedir(), ".copilot"); } +// Per-file write queue. Concurrent saves to the SAME durable file (an agent +// action and a UI action racing) must not overlap: on Windows two renames to the +// same destination collide with EPERM even with unique temp names. Chaining each +// save onto the previous one for that path serializes them (last enqueued wins), +// which is both correct (no interleaved writes) and portable. +const writeQueues = new Map(); // absolute file path -> tail Promise + +async function atomicWrite(file, data) { + await mkdir(dirname(file), { recursive: true }); + // Write to a temp sibling then atomically rename into place, so a crash or + // interruption mid-write can never truncate the existing durable file. + // rename(2) is atomic on the same filesystem. The temp name mixes pid + time + + // RANDOM bytes so two writers can never pick the same temp path. + const tmp = `${file}.${process.pid}.${Date.now()}.${randomBytes(6).toString("hex")}.tmp`; + await writeFile(tmp, JSON.stringify(data, null, 2), "utf8"); + try { + await rename(tmp, file); + } catch (err) { + // Windows can transiently EPERM/EACCES a rename when the destination is + // briefly held (AV scanner, indexer). Retry once after a short beat; always + // clean up our temp so a failed save leaves no orphaned *.tmp behind. + if (err?.code === "EPERM" || err?.code === "EACCES") { + await new Promise((r) => setTimeout(r, 25)); + try { + await rename(tmp, file); + } catch (err2) { + await unlink(tmp).catch(() => {}); + throw err2; + } + } else { + await unlink(tmp).catch(() => {}); + throw err; + } + } +} + function makeStore(file) { return { file, @@ -32,13 +75,15 @@ function makeStore(file) { } }, async save(data) { - await mkdir(dirname(file), { recursive: true }); - // Write to a temp sibling then atomically rename into place, so a crash or - // interruption mid-write can never truncate the existing durable file. - // rename(2) is atomic on the same filesystem. - const tmp = `${file}.${process.pid}.${Date.now()}.tmp`; - await writeFile(tmp, JSON.stringify(data, null, 2), "utf8"); - await rename(tmp, file); + // Serialize behind any in-flight save for this exact path (see writeQueues). + const prev = writeQueues.get(file) ?? Promise.resolve(); + const run = prev.catch(() => {}).then(() => atomicWrite(file, data)); + writeQueues.set(file, run); + try { + await run; + } finally { + if (writeQueues.get(file) === run) writeQueues.delete(file); + } }, }; } @@ -48,6 +93,24 @@ export function userStore(extensionName, fileName) { return makeStore(join(copilotHome(), "extensions", extensionName, "artifacts", fileName)); } +/** + * Per-session scratch store for a named extension, rooted under the session's + * state directory. Use for state that should NOT outlive the session (drafts, + * one-off working data). Pass the session id (e.g. from the SDK ctx). + */ +export function sessionStore(sessionId, extensionName, fileName) { + // Reduce the session id to a single safe path segment. The charset filter keeps + // "." (so dotted ids survive), so we must ALSO collapse any ".." run — otherwise + // a session id of ".." would join to one level ABOVE session-state and escape the + // per-session root. (sessionId is normally a trusted SDK value; this keeps the + // sanitizer honest regardless.) + const safeSession = + String(sessionId) + .replace(/[^A-Za-z0-9._-]/g, "_") + .replace(/\.\.+/g, "_") || "default"; + return makeStore(join(copilotHome(), "session-state", safeSession, "extensions", extensionName, fileName)); +} + /** Per-session store rooted at the session workspace path. */ export function workspaceStore(workspacePath, fileName) { return makeStore(join(workspacePath, fileName)); diff --git a/extensions/stock-ticker/canvas-kit/validate.mjs b/extensions/stock-ticker/canvas-kit/validate.mjs new file mode 100644 index 0000000..d44925e --- /dev/null +++ b/extensions/stock-ticker/canvas-kit/validate.mjs @@ -0,0 +1,146 @@ +// canvas-kit/validate.mjs +// +// A tiny, dependency-free JSON-Schema-*subset* validator. It exists so the +// runtime can ENFORCE the action `inputSchema` (and an optional `stateSchema`) +// that authors already declare — turning the iframe↔extension / agent↔extension +// contract from "declared but unchecked" into "validated at the boundary". No +// npm dependency (the kit ships vendored, no install step), and it only covers +// the JSON Schema features the kit's schemas actually use: +// +// type "object" | "array" | "string" | "number" | "integer" | +// "boolean" | "null" (or an array of those) +// properties per-key subschemas (objects) +// required array of required property names (objects) +// additionalProperties false | subschema (objects) +// items subschema for every element (arrays) +// enum allowed literal values (deep-equal) +// minLength/maxLength string length bounds +// minimum/maximum numeric bounds +// minItems/maxItems array length bounds +// +// It is intentionally forgiving: an absent/empty schema validates anything, and +// unknown keywords are ignored (so a richer schema never hard-fails here). The +// goal is to catch the shape mistakes that actually break canvases — wrong type, +// missing required field, unknown/typo'd property, out-of-enum value — not to be +// a complete JSON Schema implementation. + +function typeOf(value) { + if (value === null) return "null"; + if (Array.isArray(value)) return "array"; + if (Number.isInteger(value)) return "integer"; + return typeof value; // "string" | "number" | "boolean" | "object" | "undefined" +} + +// Does `value` satisfy a single JSON Schema `type` token? "number" accepts +// integers; "integer" requires a whole number. +function matchesType(value, t) { + const actual = typeOf(value); + if (t === "number") return actual === "number" || actual === "integer"; + if (t === "integer") return actual === "integer"; + return actual === t; +} + +function deepEqual(a, b) { + if (a === b) return true; + if (typeof a !== typeof b || a === null || b === null) return false; + if (Array.isArray(a) && Array.isArray(b)) { + return a.length === b.length && a.every((x, i) => deepEqual(x, b[i])); + } + if (typeof a === "object" && typeof b === "object") { + const ak = Object.keys(a), bk = Object.keys(b); + return ak.length === bk.length && ak.every((k) => deepEqual(a[k], b[k])); + } + return false; +} + +/** + * Validate `value` against a JSON-Schema-subset `schema`. + * @param {object|undefined} schema + * @param {any} value + * @param {string} [path] dotted path used in error messages (default "input") + * @returns {string[]} human-readable error messages; empty array = valid. + */ +export function validate(schema, value, path = "input") { + const errors = []; + walk(schema, value, path, errors); + return errors; +} + +function walk(schema, value, path, errors) { + if (!schema || typeof schema !== "object") return; // absent/degenerate schema = anything goes + + // type (single token or array of tokens) + if (schema.type !== undefined) { + const types = Array.isArray(schema.type) ? schema.type : [schema.type]; + if (!types.some((t) => matchesType(value, t))) { + errors.push(`${path}: expected ${types.join(" | ")}, got ${typeOf(value)}`); + return; // a wrong base type makes deeper checks meaningless + } + } + + // enum + if (Array.isArray(schema.enum) && !schema.enum.some((allowed) => deepEqual(allowed, value))) { + errors.push(`${path}: must be one of ${schema.enum.map((v) => JSON.stringify(v)).join(", ")}`); + } + + const kind = typeOf(value); + + if (kind === "string") { + if (typeof schema.minLength === "number" && value.length < schema.minLength) { + errors.push(`${path}: must be at least ${schema.minLength} characters`); + } + if (typeof schema.maxLength === "number" && value.length > schema.maxLength) { + errors.push(`${path}: must be at most ${schema.maxLength} characters`); + } + } + + if (kind === "number" || kind === "integer") { + if (typeof schema.minimum === "number" && value < schema.minimum) { + errors.push(`${path}: must be >= ${schema.minimum}`); + } + if (typeof schema.maximum === "number" && value > schema.maximum) { + errors.push(`${path}: must be <= ${schema.maximum}`); + } + } + + if (kind === "array") { + if (typeof schema.minItems === "number" && value.length < schema.minItems) { + errors.push(`${path}: must have at least ${schema.minItems} item(s)`); + } + if (typeof schema.maxItems === "number" && value.length > schema.maxItems) { + errors.push(`${path}: must have at most ${schema.maxItems} item(s)`); + } + if (schema.items) { + value.forEach((item, i) => walk(schema.items, item, `${path}[${i}]`, errors)); + } + } + + if (kind === "object") { + const props = schema.properties ?? {}; + if (Array.isArray(schema.required)) { + for (const key of schema.required) { + // Object.hasOwn (not `value[key] === undefined`): a required property + // named after a prototype member (e.g. "toString", "constructor") would + // otherwise read the inherited value and wrongly pass the check. + if (!Object.hasOwn(value, key)) errors.push(`${path}.${key}: required`); + } + } + for (const [key, sub] of Object.entries(props)) { + if (Object.hasOwn(value, key)) walk(sub, value[key], `${path}.${key}`, errors); + } + // Membership is tested with Object.hasOwn, NOT `key in props`: `in` walks the + // prototype chain, so an extra key named "toString"/"constructor"/"__proto__" + // etc. would satisfy `key in props` and escape additionalProperties. This + // validator is the enforcement boundary (input → 400, state → 500), so that + // would be a real contract hole. + if (schema.additionalProperties === false) { + for (const key of Object.keys(value)) { + if (!Object.hasOwn(props, key)) errors.push(`${path}.${key}: unexpected property`); + } + } else if (schema.additionalProperties && typeof schema.additionalProperties === "object") { + for (const key of Object.keys(value)) { + if (!Object.hasOwn(props, key)) walk(schema.additionalProperties, value[key], `${path}.${key}`, errors); + } + } + } +} diff --git a/extensions/stock-ticker/canvas-kit/version.mjs b/extensions/stock-ticker/canvas-kit/version.mjs index 93471a9..6bff1cc 100644 --- a/extensions/stock-ticker/canvas-kit/version.mjs +++ b/extensions/stock-ticker/canvas-kit/version.mjs @@ -10,4 +10,4 @@ // convention (the skill ships as files, not an npm version); a commit short-sha // works too. Re-exported from client.mjs so a canvas can read it at runtime. -export const KIT_VERSION = "2026-07-05.1"; +export const KIT_VERSION = "2026-07-07.6"; diff --git a/extensions/wiki-discover/canvas-kit/.kit-version.json b/extensions/wiki-discover/canvas-kit/.kit-version.json index 4685d99..f1b1363 100644 --- a/extensions/wiki-discover/canvas-kit/.kit-version.json +++ b/extensions/wiki-discover/canvas-kit/.kit-version.json @@ -1,5 +1,5 @@ { - "version": "2026-07-05.1", - "syncedAt": "2026-07-06T05:14:32.552Z", + "version": "2026-07-07.6", + "syncedAt": "2026-07-08T01:54:39.751Z", "source": "create-canvas-app/kit" } diff --git a/extensions/wiki-discover/canvas-kit/client.mjs b/extensions/wiki-discover/canvas-kit/client.mjs index 75e6e65..6f4cd9c 100644 --- a/extensions/wiki-discover/canvas-kit/client.mjs +++ b/extensions/wiki-discover/canvas-kit/client.mjs @@ -85,25 +85,21 @@ export function pollWhileVisible(tick, seconds, { whenVisible = true, immediate } /** - * Mount a canvas view and keep it live. - * @param {object} opts - * @param {(model:{state:any, invoke:Function, connected:boolean})=>any} opts.view - * Returns an htm/Preact vnode. Re-invoked on every state push. - * @param {HTMLElement} [opts.mount] defaults to #app or - * @param {(state:any)=>void} [opts.onState] - * @param {PollOptions} [opts.poll] built-in fixed-interval visibility-gated auto-refresh. - * For an interval bound to live state, use `pollWhileVisible` in a useEffect instead. - * @returns {{invoke:Function, refresh:Function, stopPoll:Function, get state():any}} + * DOM-FREE transport for a canvas: owns the loopback wiring (GET /state, GET + * /events SSE, POST /action) and the derived `state`/`connected`, with no Preact + * and no DOM. `mountCanvas` composes this with a render loop; keeping it separate + * makes the reconnect/invoke glue unit-testable without a browser (see + * test/client.test.mjs). Both callbacks receive the latest `(state, connected)`. + * @param {object} [opts] + * @param {(state:any, connected:boolean)=>void} [opts.onState] fired on the initial /state and every SSE push + * @param {(connected:boolean)=>void} [opts.onConnected] fired when the SSE stream opens/errors + * @param {typeof EventSource} [opts.EventSourceImpl] override the SSE impl (tests); defaults to the global + * @returns {{invoke:Function, refresh:Function, get state():any, get connected():boolean}} */ -export function mountCanvas({ view, mount, onState, poll } = {}) { - const root = mount || document.getElementById("app") || document.body; - // Preact's render() diffs against — but does not clear — pre-existing DOM in - // the container, so a static no-JS placeholder (e.g.

Loading…

in the - // HTML shell) would linger as a sibling. Clear it once so Preact owns an empty - // root; the view's own loading branch covers the gap until first state. - root.replaceChildren(); +export function connectCanvas({ onState, onConnected, EventSourceImpl } = {}) { let state = null; let connected = false; + const ES = EventSourceImpl || (typeof EventSource !== "undefined" ? EventSource : null); async function invoke(actionName, input) { const res = await fetch("./action", { @@ -118,20 +114,16 @@ export function mountCanvas({ view, mount, onState, poll } = {}) { return data.result; } - function rerender() { - render(view({ state, invoke, connected }), root); - } - async function refresh() { try { state = await (await fetch("./state")).json(); - onState?.(state); - rerender(); + onState?.(state, connected); } catch { /* offline; SSE will recover */ } } function connect() { - const es = new EventSource("./events"); + if (!ES) return; // no EventSource (e.g. a non-browser host); refresh() still works + const es = new ES("./events"); es.onmessage = (e) => { let next; try { @@ -139,20 +131,69 @@ export function mountCanvas({ view, mount, onState, poll } = {}) { } catch { return; // ignore a malformed SSE frame; the next push recovers } - // Update + render OUTSIDE the try so a bug in onState/the view surfaces as - // a real error instead of being silently mislabeled a "malformed frame". + // Update OUTSIDE the try so a bug in onState surfaces as a real error + // instead of being silently mislabeled a "malformed frame". state = next; connected = true; - onState?.(state); - rerender(); + onState?.(state, connected); }; - es.onopen = () => { connected = true; rerender(); }; - es.onerror = () => { connected = false; rerender(); /* EventSource auto-reconnects */ }; + es.onopen = () => { connected = true; onConnected?.(connected); }; + es.onerror = () => { connected = false; onConnected?.(connected); /* EventSource auto-reconnects */ }; } refresh(); connect(); + return { + invoke, + refresh, + get state() { return state; }, + get connected() { return connected; }, + }; +} + +/** + * Mount a canvas view and keep it live. + * @param {object} opts + * @param {(model:{state:any, invoke:Function, connected:boolean})=>any} opts.view + * Returns an htm/Preact vnode. Re-invoked on every state push. + * @param {HTMLElement} [opts.mount] defaults to #app or + * @param {(state:any)=>void} [opts.onState] + * @param {PollOptions} [opts.poll] built-in fixed-interval visibility-gated auto-refresh. + * For an interval bound to live state, use `pollWhileVisible` in a useEffect instead. + * @returns {{invoke:Function, refresh:Function, stopPoll:Function, get state():any}} + */ +export function mountCanvas({ view, mount, onState, poll } = {}) { + const root = mount || document.getElementById("app") || document.body; + // Preact's render() diffs against — but does not clear — pre-existing DOM in + // the container, so a static no-JS placeholder (e.g.

Loading…

in the + // HTML shell) would linger as a sibling. Clear it once so Preact owns an empty + // root; the view's own loading branch covers the gap until first state. + root.replaceChildren(); + + let latestState = null; + let latestConnected = false; + let client; + + function rerender() { + render(view({ state: latestState, invoke: client.invoke, connected: latestConnected }), root); + } + + // The transport is DOM-free (connectCanvas); this wrapper only adds the Preact + // render on each state/connection change. + client = connectCanvas({ + onState: (state, connected) => { + latestState = state; + latestConnected = connected; + onState?.(state); + rerender(); + }, + onConnected: (connected) => { + latestConnected = connected; + rerender(); + }, + }); + // Built-in fixed-interval auto-refresh, delegating to the shared // visibility-gated primitive. Pass `poll: { action, seconds, immediate }`. // @@ -164,7 +205,7 @@ export function mountCanvas({ view, mount, onState, poll } = {}) { // @property {boolean} [immediate=false] fire one tick right after mount function startPoll({ action, seconds, input, whenVisible = true, immediate = false } = {}) { return pollWhileVisible( - () => (action ? invoke(action, input) : refresh()), + () => (action ? client.invoke(action, input) : client.refresh()), seconds, { whenVisible, immediate } ); @@ -174,10 +215,10 @@ export function mountCanvas({ view, mount, onState, poll } = {}) { if (poll) stopPoll = startPoll(poll); return { - invoke, - refresh, + invoke: client.invoke, + refresh: client.refresh, stopPoll: () => stopPoll(), - get state() { return state; }, + get state() { return latestState; }, }; } diff --git a/extensions/wiki-discover/canvas-kit/github-store.mjs b/extensions/wiki-discover/canvas-kit/github-store.mjs new file mode 100644 index 0000000..d89a136 --- /dev/null +++ b/extensions/wiki-discover/canvas-kit/github-store.mjs @@ -0,0 +1,215 @@ +// canvas-kit/github-store.mjs +// +// A SHARED, multi-writer durable store backed by a file in a GitHub repository. +// Where userStore/sessionStore/workspaceStore (storage.mjs) persist to local disk +// — private to one machine — githubStore persists the same JSON to a file in a +// repo via the Contents API, so every collaborator who can push to that repo edits +// ONE shared document. GitHub is the backing store AND the access-control layer +// (invite collaborators to a private repo); there is no server to run. +// +// It exposes the same { load, save } shape the runtime's loadState/saveState +// expect, plus poll() for cheap change-detection so a canvas can pull other +// people's edits live (wire it to server.mjs's syncState + syncIntervalMs). +// +// Concurrency: every write carries the blob sha it read (optimistic lock). A +// concurrent commit makes the PUT 409; save() re-reads to refresh the sha and +// retries. The default policy is last-writer-wins for the whole document; pass a +// merge(remoteState, myState) to resolve conflicts field-by-field instead (e.g. +// union a board's concerns by id). Reads use an ETag If-None-Match so an unchanged +// poll is a cheap 304 with no body. +// +// Token: resolved once from opts.token (string | async () => string), then +// GH_TOKEN / GITHUB_TOKEN, then `gh auth token`. Needs the `repo` scope for a +// private repo. The token is only ever sent as an Authorization header — never +// logged, never written to the repo. + +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; + +const execFileAsync = promisify(execFile); + +const API = "https://api.github.com"; +const UA = "canvas-kit-github-store"; +// Hard deadline for every GitHub API call so a hung request (stalled TLS, dropped +// connection) can't freeze a user action or dead-lock the sync loop (mirrors the +// AbortSignal.timeout pattern in net.mjs safeFetch). +const DEFAULT_TIMEOUT_MS = 15000; +// Refuse an implausibly large state file rather than allocate it on every poll. A +// board's JSON is tens of KB; this is a safety ceiling, not a real size limit. +const MAX_RESPONSE_BYTES = 8 * 1024 * 1024; +// Bounded save retries on an optimistic-lock conflict (409) before failing loud. +const MAX_SAVE_RETRIES = 3; + +// Resolve a GitHub token lazily and memoize it. A 401 (expired/rotated) clears the +// cache via invalidate() so the next call re-resolves. +function makeTokenResolver(tokenOpt) { + let cached = null; + async function fromGhCli() { + try { + const { stdout } = await execFileAsync("gh", ["auth", "token"], { windowsHide: true }); + return String(stdout).trim() || null; + } catch { + return null; // gh missing or not logged in → fall through to the no-token error + } + } + return { + async get() { + if (cached) return cached; + let t = null; + if (typeof tokenOpt === "function") t = await tokenOpt(); + else if (typeof tokenOpt === "string" && tokenOpt) t = tokenOpt; + if (!t) t = process.env.GH_TOKEN || process.env.GITHUB_TOKEN || null; + if (!t) t = await fromGhCli(); + if (!t) throw new Error("githubStore: no GitHub token (set GH_TOKEN or run `gh auth login`)"); + cached = t; + return cached; + }, + invalidate() { cached = null; }, + }; +} + +function encodePath(path) { + // Keep the slashes as path separators in the Contents API URL; encode each + // segment so spaces / unicode in a filename can't break the request. + return String(path).split("/").filter(Boolean).map(encodeURIComponent).join("/"); +} + +/** + * A GitHub-repo-backed durable store: one JSON file, many writers. + * @param {object} opts + * @param {string} opts.owner repo owner (user or org) + * @param {string} opts.repo repo name + * @param {string} opts.path path to the JSON file in the repo (e.g. "state/board.json") + * @param {string} [opts.branch="main"] + * @param {string|(()=>Promise|string)} [opts.token] token or async resolver + * @param {(remote:any,mine:any)=>any} [opts.merge] conflict resolver (default: last-writer-wins) + * @param {(state:any)=>string} [opts.message] commit message from the state (default: a timestamp) + * @param {string} [opts.apiBase=API] + * @returns {{file:string, load:(fallback?:any)=>Promise, poll:()=>Promise<{changed:boolean,state?:any}>, save:(state:any)=>Promise}} + */ +export function githubStore(opts) { + const { owner, repo, path, branch = "main", token, merge, message, apiBase = API } = opts ?? {}; + if (!owner || !repo || !path) throw new Error("githubStore: owner, repo and path are required"); + // apiBase is a trusted, fixed host by design — GitHub's public API by default, or a + // GitHub Enterprise host the canvas author sets at construction (never runtime + // input). That's why we call fetch() directly rather than the kit's safeFetch SSRF + // guard, which would add a DNS-resolution check on every call for a host that can't + // vary per request. Still, require https for any custom apiBase as defense-in-depth. + if (apiBase !== API) { + let base; + try { base = new URL(apiBase); } catch { throw new Error("githubStore: apiBase must be a valid URL"); } + if (base.protocol !== "https:") throw new Error("githubStore: apiBase must be https"); + } + + const tok = makeTokenResolver(token); + const contentsUrl = `${apiBase}/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/contents/${encodePath(path)}`; + const label = `${owner}/${repo}:${path}`; + + let sha = null; // last-seen blob sha — sent on write as the optimistic lock + let etag = null; // last-seen ETag — sent on poll as If-None-Match + let lastText = null; // last-seen decoded content — guards against no-op broadcasts + + async function api(url, init = {}, allow = []) { + const t = await tok.get(); + const res = await fetch(url, { + ...init, + signal: AbortSignal.timeout(DEFAULT_TIMEOUT_MS), + headers: { + Authorization: `Bearer ${t}`, + Accept: "application/vnd.github+json", + "X-GitHub-Api-Version": "2022-11-28", + "User-Agent": UA, + ...(init.headers ?? {}), + }, + }); + // An expired token surfaces as 401 — drop the memoized token so a retry can + // re-resolve (e.g. after `gh auth refresh`), then surface the failure. + if (res.status === 401) { tok.invalidate(); } + // ok, or an expected status the caller handles (404 fresh-file, 304 unchanged, + // 409 write conflict), pass through; anything else is a hard error. + if (res.ok || allow.includes(res.status)) return res; + const body = await res.text().catch(() => ""); + throw new Error(`githubStore ${label}: ${init.method ?? "GET"} ${res.status} ${body.slice(0, 200)}`); + } + + function decode(json) { + // Contents API returns base64 (wrapped at 60 cols); Buffer handles the newlines. + return Buffer.from(json.content ?? "", "base64").toString("utf8"); + } + + async function readJson(res) { + // Bound the allocation: refuse an oversized response before reading it, so a + // huge state file can't be pulled into memory on every poll tick. + const len = Number(res.headers.get("content-length") || 0); + if (len > MAX_RESPONSE_BYTES) { + throw new Error(`githubStore ${label}: response too large (${len} bytes)`); + } + return res.json(); + } + + async function load(fallback = null) { + const res = await api(`${contentsUrl}?ref=${encodeURIComponent(branch)}`, {}, [404]); + if (res.status === 404) { sha = null; etag = null; lastText = null; return fallback; } + const json = await readJson(res); + sha = json.sha ?? null; + etag = res.headers.get("etag"); + const text = decode(json); + lastText = text; + return text.trim() ? JSON.parse(text) : fallback; + } + + async function poll() { + const headers = etag ? { "If-None-Match": etag } : {}; + const res = await api(`${contentsUrl}?ref=${encodeURIComponent(branch)}`, { headers }, [304, 404]); + if (res.status === 304) return { changed: false }; + if (res.status === 404) { + if (sha === null && lastText === null) return { changed: false }; + sha = null; etag = null; lastText = null; + return { changed: true, state: null }; + } + const json = await readJson(res); + const text = decode(json); + sha = json.sha ?? null; + etag = res.headers.get("etag"); + if (text === lastText) return { changed: false }; + lastText = text; + return { changed: true, state: text.trim() ? JSON.parse(text) : null }; + } + + async function save(state) { + let toWrite = state; + for (let attempt = 0; ; attempt++) { + const text = JSON.stringify(toWrite, null, 2); + const body = { + message: (message ? message(toWrite) : `Update ${path}`) || `Update ${path}`, + content: Buffer.from(text, "utf8").toString("base64"), + branch, + ...(sha ? { sha } : {}), + }; + const res = await api(contentsUrl, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }, [409, 404]); + if (res.ok) { + const json = await res.json(); + sha = json.content?.sha ?? null; + etag = null; // PUT's ETag isn't the content GET's — force a fresh poll baseline + lastText = text; // our own write must not read back as a change + return; + } + // 409 (or a 404 if the file/branch vanished): the sha we held is stale. + // Re-read to refresh sha, optionally merge the remote with our intended + // write, and retry. Bounded so a persistent conflict fails loudly. + if ((res.status === 409 || res.status === 404) && attempt < MAX_SAVE_RETRIES) { + const remote = await load(null); + toWrite = merge ? merge(remote, state) : state; // default: last-writer-wins + continue; + } + const errBody = await res.text().catch(() => ""); + throw new Error(`githubStore ${label}: save failed ${res.status} ${errBody.slice(0, 200)}`); + } + } + + return { file: label, load, poll, save }; +} diff --git a/extensions/wiki-discover/canvas-kit/icons.mjs b/extensions/wiki-discover/canvas-kit/icons.mjs index 9dd30ea..04eb2e9 100644 --- a/extensions/wiki-discover/canvas-kit/icons.mjs +++ b/extensions/wiki-discover/canvas-kit/icons.mjs @@ -23,7 +23,18 @@ const VIEWBOX = "0 0 24 24"; const STROKE_WIDTH = 2; function resolve(name) { - return LUCIDE[name] || LUCIDE[aliases[name]] || null; + // Own-property lookups only. Bracket access on a plain object reaches inherited + // Object.prototype members, so a name like "toString"/"constructor"/ + // "hasOwnProperty" would otherwise resolve to a function (making hasIcon() lie + // and Icon()/lucideSVG() throw in nodeToString). Object.hasOwn keeps resolution + // to the real, vendored icon set. + if (typeof name !== "string") return null; + if (Object.hasOwn(LUCIDE, name)) return LUCIDE[name]; + if (Object.hasOwn(aliases, name)) { + const target = aliases[name]; + if (Object.hasOwn(LUCIDE, target)) return LUCIDE[target]; + } + return null; } function kebab(k) { diff --git a/extensions/wiki-discover/canvas-kit/net.mjs b/extensions/wiki-discover/canvas-kit/net.mjs new file mode 100644 index 0000000..fc1618c --- /dev/null +++ b/extensions/wiki-discover/canvas-kit/net.mjs @@ -0,0 +1,126 @@ +// canvas-kit/net.mjs +// +// Server-side network safety for canvases that fetch external data. A canvas +// action runs on the loopback runtime with the app's network reach, so a +// caller-influenced URL is an SSRF risk: it could target cloud metadata +// (169.254.169.254), loopback, or the private network. This module is the kit's +// sanctioned egress primitive — validate a URL, then fetch it with a hard +// timeout — so every data canvas guards the same way instead of re-inlining the +// check. SERVER-ONLY (uses node:dns / node:net): import it from the SDK-free +// canvas.mjs, never from the browser view. +// +// Defense-in-depth, not a guarantee: a determined attacker could DNS-rebind +// between the resolve check and the connect. For a canvas pointed at a source +// you choose this is adequate; treat any fetched content as untrusted and render +// it as TEXT (never innerHTML). + +import { lookup } from "node:dns/promises"; +import { isIP } from "node:net"; + +/** + * True for addresses a server-side fetch should never reach: loopback, + * link-local (incl. cloud metadata 169.254.169.254), and private/CGNAT ranges. + * @param {string} ip + * @returns {boolean} + */ +export function isBlockedAddress(ip) { + const lower = ip.toLowerCase(); + // Decode an IPv4-mapped/compatible IPv6 literal down to its embedded IPv4 so + // the IPv4 range checks apply. Covers BOTH the dotted tail (::ffff:127.0.0.1) + // and the HEX tail that `new URL()` normalizes literals to (::ffff:7f00:1) — + // without the decode, `[::ffff:127.0.0.1]` reaches loopback and + // `[::ffff:a9fe:a9fe]` reaches cloud metadata. Only matches when the high bits + // are all zero (leading "::"), so a normal public v6 (e.g. 2001:db8::7f00:1) is + // never misread as IPv4. + const addr = embeddedIPv4(lower) ?? ip; + if (isIP(addr) === 4) { + const [a, b] = addr.split(".").map(Number); + if (a === 0 || a === 127 || a === 10) return true; // this-host / loopback / private + if (a === 169 && b === 254) return true; // link-local (incl. cloud IMDS) + if (a === 172 && b >= 16 && b <= 31) return true; // private + if (a === 192 && b === 168) return true; // private + if (a === 100 && b >= 64 && b <= 127) return true; // CGNAT + return false; + } + return lower === "::1" || lower === "::" || lower.startsWith("fe80") || lower.startsWith("fc") || lower.startsWith("fd"); +} + +// Decode an IPv4-mapped/compatible IPv6 literal (all-zero high groups, i.e. a +// leading "::", optionally "::ffff:") to its dotted IPv4; else null. Accepts the +// dotted tail (127.0.0.1) and the two-hex-group tail (7f00:1) that URL +// normalization produces. Anchored on "::" so it can't misread a public v6. +function embeddedIPv4(addr) { + const m = addr.match(/^::(?:ffff:)?((?:\d{1,3}\.){3}\d{1,3}|[0-9a-f]{1,4}:[0-9a-f]{1,4})$/); + if (!m) return null; + const tail = m[1]; + if (tail.includes(".")) return isIP(tail) === 4 ? tail : null; + const [h1, h2] = tail.split(":"); + const hi = parseInt(h1, 16), lo = parseInt(h2, 16); + return `${hi >> 8}.${hi & 255}.${lo >> 8}.${lo & 255}`; +} + +/** + * Allow only http/https to a PUBLIC host. Rejects every address the hostname + * resolves to, so a public name can't be pointed at an internal IP. Throws on a + * blocked/invalid URL; resolves to void when the URL is safe to fetch. + * @param {string} url + */ +export async function assertPublicUrl(url) { + let u; + try { u = new URL(url); } catch { throw new Error("Invalid source URL"); } + if (u.protocol !== "http:" && u.protocol !== "https:") { + throw new Error("Blocked URL protocol: " + u.protocol); + } + let host = u.hostname; + if (host.startsWith("[") && host.endsWith("]")) host = host.slice(1, -1); // IPv6 brackets + if (host.toLowerCase() === "localhost") throw new Error("Blocked host: localhost"); + const addrs = isIP(host) ? [host] : (await lookup(host, { all: true })).map((r) => r.address); + if (!addrs.length) throw new Error("Could not resolve host: " + host); + for (const ip of addrs) { + if (isBlockedAddress(ip)) throw new Error("Blocked private/loopback address: " + ip); + } +} + +/** + * SSRF-guarded fetch with a mandatory timeout. Runs assertPublicUrl on the URL + * and on EVERY redirect hop, so a chosen public host can't 30x-redirect the + * request into loopback/metadata/private space. Returns the final Response as-is + * (does NOT throw on a non-2xx status — the caller checks res.ok), so it is a + * drop-in for a guarded fetch(). + * + * Redirects are followed MANUALLY (redirect:"manual") rather than by fetch's + * default redirect:"follow": the default would chase a 3xx Location without + * re-running the guard, which silently undoes every check in this module. Here + * each hop's target is re-validated before we connect to it. + * @param {string} url + * @param {object} [opts] + * @param {number} [opts.timeoutMs=12000] abort the WHOLE operation (all hops) after this many ms + * @param {number} [opts.maxRedirects=5] how many 3xx hops to follow before giving up + * @param {object} [opts.headers] request headers (merged as-is) + * @param {RequestInit} [opts.rest] any other fetch init (method, body, …) + * @returns {Promise} + */ +export async function safeFetch(url, { timeoutMs = 12000, maxRedirects = 5, headers, ...rest } = {}) { + // One timeout signal bounds the entire operation, redirects included, so a + // chain of slow hops can't multiply the deadline. + const signal = AbortSignal.timeout(timeoutMs); + let current = url; + for (let hop = 0; ; hop++) { + await assertPublicUrl(current); + const res = await fetch(current, { + ...rest, + headers: headers ?? {}, + redirect: "manual", + signal, + }); + if (res.status >= 300 && res.status < 400 && res.headers.has("location")) { + if (hop >= maxRedirects) throw new Error("Too many redirects"); + const next = new URL(res.headers.get("location"), current).href; + // Discard the redirect body so the connection can be freed before the next hop. + try { await res.body?.cancel(); } catch { /* no body / already consumed */ } + current = next; + continue; + } + return res; + } +} diff --git a/extensions/wiki-discover/canvas-kit/server.mjs b/extensions/wiki-discover/canvas-kit/server.mjs index bf3a07f..eb533c9 100644 --- a/extensions/wiki-discover/canvas-kit/server.mjs +++ b/extensions/wiki-discover/canvas-kit/server.mjs @@ -20,6 +20,7 @@ import { createServer } from "node:http"; import { readFile } from "node:fs/promises"; import { join, normalize, extname, sep } from "node:path"; import { fileURLToPath } from "node:url"; +import { validate } from "./validate.mjs"; const KIT_DIR = fileURLToPath(new URL(".", import.meta.url)); @@ -52,7 +53,10 @@ export class CanvasKitError extends Error { * @param {(ctx:object)=>any|Promise} [config.createInitialState] * @param {(domainId:string)=>any|Promise} [config.loadState] * @param {(domainId:string, state:any)=>void|Promise} [config.saveState] + * @param {(domainId:string)=>{changed:boolean,state?:any}|Promise<{changed:boolean,state?:any}>} [config.syncState] optional delta-poll of a SHARED durable source (e.g. githubStore.poll); when set with syncIntervalMs, remote changes are adopted + broadcast to viewers live + * @param {number} [config.syncIntervalMs] poll cadence for syncState; only polled while a domain has >=1 connected viewer * @param {Record} config.actions + * @param {object} [config.stateSchema] optional JSON-Schema-subset for the durable state; when set, a mutation that violates it is rolled back and fails (500) * @param {string} config.assetsDir absolute path to the canvas web/ folder * @param {(ctx:object,state:any)=>string} [config.statusLine] */ @@ -112,14 +116,110 @@ export function createCanvasRuntime(config) { } } + // ---- optional shared-state sync (repo-backed multiplayer) ---------------- + // When config.syncState + config.syncIntervalMs are set, poll the durable + // source on an interval and adopt+broadcast remote changes, so collaborators + // editing a SHARED store (e.g. githubStore) see each other's edits live. We + // only poll a domain while at least one SSE client is watching it — no viewers, + // no network — so API usage stays proportional to real use. syncState returns + // { changed:boolean, state? }; a cheap unchanged poll (ETag 304) is changed:false. + const syncing = new Set(); // domainIds with an in-flight poll (prevents overlap) + let syncTimer = null; + + function domainHasViewers(domainId) { + for (const inst of instances.values()) { + if (inst.domainId === domainId && inst.clients.size > 0) return true; + } + return false; + } + + async function syncDomain(domainId) { + if (syncing.has(domainId)) return; // last tick still in flight — skip + syncing.add(domainId); + try { + const out = await config.syncState(domainId); + // Adopt only a real, non-null remote state. A null (file deleted upstream) + // is ignored so a transient upstream gap can't blank a live board. + if (out?.changed && out.state != null) { + const d = domains.get(domainId); + if (d) { + // Adopted remote state must clear the SAME stateSchema gate the invoke() + // path enforces. A collaborator (or a hand-edit on github.com) can push a + // shape that violates the schema; adopting it unvalidated would broadcast a + // corrupt shape to every viewer AND poison the next invoke()'s rollback + // baseline (structuredClone would snapshot the already-invalid state). + // Reject (skip) an invalid remote instead — the next valid write reconciles. + if (config.stateSchema && validate(config.stateSchema, out.state, "sync-remote").length) { + return; + } + d.state = out.state; + broadcast(domainId); + } + } + } catch { + // A transient network/API error must not kill the timer; retry next tick. + } finally { + syncing.delete(domainId); + } + } + + function syncTick() { + for (const domainId of domains.keys()) { + if (domainHasViewers(domainId)) syncDomain(domainId); + } + } + + // Floor the poll cadence: a mistakenly tiny interval (e.g. 1ms) would hammer the + // durable source — for githubStore that means burning the GitHub API rate limit in + // seconds. 2s is well below any real collaboration-latency need. + const MIN_SYNC_INTERVAL_MS = 2000; + + function startSync() { + if (syncTimer || typeof config.syncState !== "function") return; + const requested = Number(config.syncIntervalMs) || 0; + if (requested <= 0) return; + const ms = Math.max(requested, MIN_SYNC_INTERVAL_MS); + syncTimer = setInterval(syncTick, ms); + syncTimer.unref?.(); // never keep the process alive just to poll + } + + function stopSync() { + if (syncTimer) { clearInterval(syncTimer); syncTimer = null; } + } + // Core action invoker — the single code path behind both agent and UI actions. async function invoke(actionName, input, ctx) { - const action = config.actions[actionName]; + // Own-property lookup: `config.actions[actionName]` via bracket access would + // otherwise reach inherited members (e.g. "constructor", "toString"). The + // handler-typeof check below already rejects those, but resolving by + // Object.hasOwn keeps the boundary explicit and consistent with validate.mjs. + const action = + typeof actionName === "string" && Object.hasOwn(config.actions, actionName) + ? config.actions[actionName] + : null; if (!action || typeof action.handler !== "function") { throw new CanvasKitError("unknown_action", `Unknown action: ${actionName}`); } + // Enforce the declared inputSchema at the boundary (agent OR ui). The schema + // is a contract authors already write; validating it here turns "declared but + // unchecked" into a typed boundary and stops a malformed/typo'd payload from + // reaching the handler. A shape violation is the CALLER's fault → invalid_input + // (HTTP 400). Business rules ("title can't be blank") still live in the handler + // and surface as a 500, so a schema-valid-but-empty string reaches the handler. + if (action.inputSchema) { + const errs = validate(action.inputSchema, input ?? {}, "input"); + if (errs.length) { + throw new CanvasKitError("invalid_input", `Invalid input for '${actionName}': ${errs.join("; ")}`); + } + } const domainId = ctx?.domainId ?? "default"; const d = await getDomain(domainId, ctx); + // Deep snapshot for stateSchema rollback: a handler may mutate state IN PLACE + // and return the same object, so a reference copy (prevState = d.state) would + // point at the same (now-corrupt) object and restore nothing. structuredClone + // gives a real pre-mutation copy. Durable state is JSON-shaped, so it clones + // cleanly. Only pay the clone when a stateSchema is actually configured. + const prevState = config.stateSchema ? structuredClone(d.state) : undefined; let mutated = false; const api = { get state() { return d.state; }, @@ -161,6 +261,17 @@ export function createCanvasRuntime(config) { }; const result = await action.handler(api); if (mutated) { + // Optional stateSchema guards the durable shape: if a handler produced an + // invalid state, roll back the in-memory mutation and fail LOUD (a 500 — + // this is a handler bug, not caller input) instead of persisting/broadcasting + // corrupt state. Absent stateSchema, anything goes (opt-in). + if (config.stateSchema) { + const errs = validate(config.stateSchema, d.state, "state"); + if (errs.length) { + d.state = prevState; // roll back so in-memory stays consistent + throw new Error(`Action '${actionName}' produced invalid state: ${errs.join("; ")}`); + } + } if (config.saveState) await config.saveState(domainId, d.state); broadcast(domainId); } @@ -202,6 +313,12 @@ export function createCanvasRuntime(config) { // local client can't force unbounded memory growth on the loopback runtime. const MAX_BODY_BYTES = 1 << 20; // 1 MiB + // Cap concurrent SSE subscribers PER INSTANCE. A canvas panel needs only one + // /events stream (a couple across reopens); a runaway reconnect loop or a + // hostile local process hitting the loopback port could otherwise accumulate + // unbounded response handles + keep-alive timers. 64 is far above any real use. + const MAX_SSE_CLIENTS = 64; + function readBody(req) { return new Promise((resolve, reject) => { const chunks = []; @@ -240,6 +357,13 @@ export function createCanvasRuntime(config) { // GET /events — Server-Sent Events stream of state if (req.method === "GET" && path === "/events") { + // Refuse once an instance is saturated, so subscribers can't grow without + // bound (each holds a response handle + a keep-alive interval). + if (inst && inst.clients.size >= MAX_SSE_CLIENTS) { + res.writeHead(503, { "Content-Type": "text/plain", "Retry-After": "5" }); + res.end("too many event subscribers"); + return; + } res.writeHead(200, { "Content-Type": "text/event-stream", "Cache-Control": "no-cache", @@ -307,6 +431,15 @@ export function createCanvasRuntime(config) { * @returns {Promise<{url:string,title:string,status?:string}>} */ async function openInstance({ instanceId, input, ctx }) { + // Validate the open input against the declared inputSchema (same contract the + // actions get). A bad open payload fails fast with invalid_input rather than + // silently resolving the wrong domain. + if (config.inputSchema) { + const errs = validate(config.inputSchema, input ?? {}, "open input"); + if (errs.length) { + throw new CanvasKitError("invalid_input", `Invalid open input: ${errs.join("; ")}`); + } + } const domainId = config.resolveDomainId ? config.resolveDomainId(input ?? {}, ctx ?? {}) || "default" : "default"; @@ -341,6 +474,7 @@ export function createCanvasRuntime(config) { } async function shutdown() { + stopSync(); await Promise.all([...instances.keys()].map(closeInstance)); } @@ -348,6 +482,8 @@ export function createCanvasRuntime(config) { return (await getDomain(domainId)).state; } + startSync(); // no-op unless config.syncState + syncIntervalMs are set + return { config, setHost, @@ -358,5 +494,6 @@ export function createCanvasRuntime(config) { invokeFromAgent, // agent-side, resolves domain from ctx getState, _instances: instances, + _syncDomain: syncDomain, // manual one-shot sync (tests) }; } diff --git a/extensions/wiki-discover/canvas-kit/storage.mjs b/extensions/wiki-discover/canvas-kit/storage.mjs index 355983c..3f1177c 100644 --- a/extensions/wiki-discover/canvas-kit/storage.mjs +++ b/extensions/wiki-discover/canvas-kit/storage.mjs @@ -2,18 +2,61 @@ // // Durable JSON state helpers. State is keyed by a *domain id* (a stable logical // identifier resolved from the open input), never by instanceId — per -// create-canvas/SKILL.md. Two scopes: +// create-canvas/SKILL.md. Three tiers, matching the Canvas SDK storage model: // userStore -> $COPILOT_HOME/extensions//artifacts/ (per user, cross-session) -// workspaceStore -> / (per session) +// sessionStore -> $COPILOT_HOME/session-state//extensions// (per session, scratch) +// workspaceStore -> / (rooted at the session workspace) +// +// Sandboxing: callers derive the from a domain id that MUST be sanitized +// to a bare filename (the reference `fileFor` strips everything but +// [A-Za-z0-9._-]); the store never joins caller input as a path, so a domain id +// can't escape its extension's artifacts directory. -import { readFile, writeFile, mkdir, rename } from "node:fs/promises"; +import { readFile, writeFile, mkdir, rename, unlink } from "node:fs/promises"; import { join, dirname } from "node:path"; import { homedir } from "node:os"; +import { randomBytes } from "node:crypto"; function copilotHome() { return process.env.COPILOT_HOME || join(homedir(), ".copilot"); } +// Per-file write queue. Concurrent saves to the SAME durable file (an agent +// action and a UI action racing) must not overlap: on Windows two renames to the +// same destination collide with EPERM even with unique temp names. Chaining each +// save onto the previous one for that path serializes them (last enqueued wins), +// which is both correct (no interleaved writes) and portable. +const writeQueues = new Map(); // absolute file path -> tail Promise + +async function atomicWrite(file, data) { + await mkdir(dirname(file), { recursive: true }); + // Write to a temp sibling then atomically rename into place, so a crash or + // interruption mid-write can never truncate the existing durable file. + // rename(2) is atomic on the same filesystem. The temp name mixes pid + time + + // RANDOM bytes so two writers can never pick the same temp path. + const tmp = `${file}.${process.pid}.${Date.now()}.${randomBytes(6).toString("hex")}.tmp`; + await writeFile(tmp, JSON.stringify(data, null, 2), "utf8"); + try { + await rename(tmp, file); + } catch (err) { + // Windows can transiently EPERM/EACCES a rename when the destination is + // briefly held (AV scanner, indexer). Retry once after a short beat; always + // clean up our temp so a failed save leaves no orphaned *.tmp behind. + if (err?.code === "EPERM" || err?.code === "EACCES") { + await new Promise((r) => setTimeout(r, 25)); + try { + await rename(tmp, file); + } catch (err2) { + await unlink(tmp).catch(() => {}); + throw err2; + } + } else { + await unlink(tmp).catch(() => {}); + throw err; + } + } +} + function makeStore(file) { return { file, @@ -32,13 +75,15 @@ function makeStore(file) { } }, async save(data) { - await mkdir(dirname(file), { recursive: true }); - // Write to a temp sibling then atomically rename into place, so a crash or - // interruption mid-write can never truncate the existing durable file. - // rename(2) is atomic on the same filesystem. - const tmp = `${file}.${process.pid}.${Date.now()}.tmp`; - await writeFile(tmp, JSON.stringify(data, null, 2), "utf8"); - await rename(tmp, file); + // Serialize behind any in-flight save for this exact path (see writeQueues). + const prev = writeQueues.get(file) ?? Promise.resolve(); + const run = prev.catch(() => {}).then(() => atomicWrite(file, data)); + writeQueues.set(file, run); + try { + await run; + } finally { + if (writeQueues.get(file) === run) writeQueues.delete(file); + } }, }; } @@ -48,6 +93,24 @@ export function userStore(extensionName, fileName) { return makeStore(join(copilotHome(), "extensions", extensionName, "artifacts", fileName)); } +/** + * Per-session scratch store for a named extension, rooted under the session's + * state directory. Use for state that should NOT outlive the session (drafts, + * one-off working data). Pass the session id (e.g. from the SDK ctx). + */ +export function sessionStore(sessionId, extensionName, fileName) { + // Reduce the session id to a single safe path segment. The charset filter keeps + // "." (so dotted ids survive), so we must ALSO collapse any ".." run — otherwise + // a session id of ".." would join to one level ABOVE session-state and escape the + // per-session root. (sessionId is normally a trusted SDK value; this keeps the + // sanitizer honest regardless.) + const safeSession = + String(sessionId) + .replace(/[^A-Za-z0-9._-]/g, "_") + .replace(/\.\.+/g, "_") || "default"; + return makeStore(join(copilotHome(), "session-state", safeSession, "extensions", extensionName, fileName)); +} + /** Per-session store rooted at the session workspace path. */ export function workspaceStore(workspacePath, fileName) { return makeStore(join(workspacePath, fileName)); diff --git a/extensions/wiki-discover/canvas-kit/validate.mjs b/extensions/wiki-discover/canvas-kit/validate.mjs new file mode 100644 index 0000000..d44925e --- /dev/null +++ b/extensions/wiki-discover/canvas-kit/validate.mjs @@ -0,0 +1,146 @@ +// canvas-kit/validate.mjs +// +// A tiny, dependency-free JSON-Schema-*subset* validator. It exists so the +// runtime can ENFORCE the action `inputSchema` (and an optional `stateSchema`) +// that authors already declare — turning the iframe↔extension / agent↔extension +// contract from "declared but unchecked" into "validated at the boundary". No +// npm dependency (the kit ships vendored, no install step), and it only covers +// the JSON Schema features the kit's schemas actually use: +// +// type "object" | "array" | "string" | "number" | "integer" | +// "boolean" | "null" (or an array of those) +// properties per-key subschemas (objects) +// required array of required property names (objects) +// additionalProperties false | subschema (objects) +// items subschema for every element (arrays) +// enum allowed literal values (deep-equal) +// minLength/maxLength string length bounds +// minimum/maximum numeric bounds +// minItems/maxItems array length bounds +// +// It is intentionally forgiving: an absent/empty schema validates anything, and +// unknown keywords are ignored (so a richer schema never hard-fails here). The +// goal is to catch the shape mistakes that actually break canvases — wrong type, +// missing required field, unknown/typo'd property, out-of-enum value — not to be +// a complete JSON Schema implementation. + +function typeOf(value) { + if (value === null) return "null"; + if (Array.isArray(value)) return "array"; + if (Number.isInteger(value)) return "integer"; + return typeof value; // "string" | "number" | "boolean" | "object" | "undefined" +} + +// Does `value` satisfy a single JSON Schema `type` token? "number" accepts +// integers; "integer" requires a whole number. +function matchesType(value, t) { + const actual = typeOf(value); + if (t === "number") return actual === "number" || actual === "integer"; + if (t === "integer") return actual === "integer"; + return actual === t; +} + +function deepEqual(a, b) { + if (a === b) return true; + if (typeof a !== typeof b || a === null || b === null) return false; + if (Array.isArray(a) && Array.isArray(b)) { + return a.length === b.length && a.every((x, i) => deepEqual(x, b[i])); + } + if (typeof a === "object" && typeof b === "object") { + const ak = Object.keys(a), bk = Object.keys(b); + return ak.length === bk.length && ak.every((k) => deepEqual(a[k], b[k])); + } + return false; +} + +/** + * Validate `value` against a JSON-Schema-subset `schema`. + * @param {object|undefined} schema + * @param {any} value + * @param {string} [path] dotted path used in error messages (default "input") + * @returns {string[]} human-readable error messages; empty array = valid. + */ +export function validate(schema, value, path = "input") { + const errors = []; + walk(schema, value, path, errors); + return errors; +} + +function walk(schema, value, path, errors) { + if (!schema || typeof schema !== "object") return; // absent/degenerate schema = anything goes + + // type (single token or array of tokens) + if (schema.type !== undefined) { + const types = Array.isArray(schema.type) ? schema.type : [schema.type]; + if (!types.some((t) => matchesType(value, t))) { + errors.push(`${path}: expected ${types.join(" | ")}, got ${typeOf(value)}`); + return; // a wrong base type makes deeper checks meaningless + } + } + + // enum + if (Array.isArray(schema.enum) && !schema.enum.some((allowed) => deepEqual(allowed, value))) { + errors.push(`${path}: must be one of ${schema.enum.map((v) => JSON.stringify(v)).join(", ")}`); + } + + const kind = typeOf(value); + + if (kind === "string") { + if (typeof schema.minLength === "number" && value.length < schema.minLength) { + errors.push(`${path}: must be at least ${schema.minLength} characters`); + } + if (typeof schema.maxLength === "number" && value.length > schema.maxLength) { + errors.push(`${path}: must be at most ${schema.maxLength} characters`); + } + } + + if (kind === "number" || kind === "integer") { + if (typeof schema.minimum === "number" && value < schema.minimum) { + errors.push(`${path}: must be >= ${schema.minimum}`); + } + if (typeof schema.maximum === "number" && value > schema.maximum) { + errors.push(`${path}: must be <= ${schema.maximum}`); + } + } + + if (kind === "array") { + if (typeof schema.minItems === "number" && value.length < schema.minItems) { + errors.push(`${path}: must have at least ${schema.minItems} item(s)`); + } + if (typeof schema.maxItems === "number" && value.length > schema.maxItems) { + errors.push(`${path}: must have at most ${schema.maxItems} item(s)`); + } + if (schema.items) { + value.forEach((item, i) => walk(schema.items, item, `${path}[${i}]`, errors)); + } + } + + if (kind === "object") { + const props = schema.properties ?? {}; + if (Array.isArray(schema.required)) { + for (const key of schema.required) { + // Object.hasOwn (not `value[key] === undefined`): a required property + // named after a prototype member (e.g. "toString", "constructor") would + // otherwise read the inherited value and wrongly pass the check. + if (!Object.hasOwn(value, key)) errors.push(`${path}.${key}: required`); + } + } + for (const [key, sub] of Object.entries(props)) { + if (Object.hasOwn(value, key)) walk(sub, value[key], `${path}.${key}`, errors); + } + // Membership is tested with Object.hasOwn, NOT `key in props`: `in` walks the + // prototype chain, so an extra key named "toString"/"constructor"/"__proto__" + // etc. would satisfy `key in props` and escape additionalProperties. This + // validator is the enforcement boundary (input → 400, state → 500), so that + // would be a real contract hole. + if (schema.additionalProperties === false) { + for (const key of Object.keys(value)) { + if (!Object.hasOwn(props, key)) errors.push(`${path}.${key}: unexpected property`); + } + } else if (schema.additionalProperties && typeof schema.additionalProperties === "object") { + for (const key of Object.keys(value)) { + if (!Object.hasOwn(props, key)) walk(schema.additionalProperties, value[key], `${path}.${key}`, errors); + } + } + } +} diff --git a/extensions/wiki-discover/canvas-kit/version.mjs b/extensions/wiki-discover/canvas-kit/version.mjs index 93471a9..6bff1cc 100644 --- a/extensions/wiki-discover/canvas-kit/version.mjs +++ b/extensions/wiki-discover/canvas-kit/version.mjs @@ -10,4 +10,4 @@ // convention (the skill ships as files, not an npm version); a commit short-sha // works too. Re-exported from client.mjs so a canvas can read it at runtime. -export const KIT_VERSION = "2026-07-05.1"; +export const KIT_VERSION = "2026-07-07.6"; diff --git a/extensions/wiki-discover/test/smoke.test.mjs b/extensions/wiki-discover/test/smoke.test.mjs index f04fac7..9076f27 100644 --- a/extensions/wiki-discover/test/smoke.test.mjs +++ b/extensions/wiki-discover/test/smoke.test.mjs @@ -243,7 +243,7 @@ try { assert.equal((await st("flow")).queue.length, 1, "queue must not be cleared on a no-op removal"); }); await test("rating the current article advances + re-ranks + marks it seen (M1)", async () => { - await inv("rate", { value: "down", article: undefined }, "flow"); // rate current (Galaxy) + await inv("rate", { value: "down" }, "flow"); // rate current (Galaxy) const s = await st("flow"); assert.equal(s.current.title, "Cooking", "advanced to the next ranked card"); assert.ok(s.seenIds.includes("1"), "the rated article id is now seen");