From 3e1d35945af2102724c542539a7af901b51e4c8b Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 10 Sep 2026 06:22:23 +0000 Subject: [PATCH 1/5] Enforce ops-state.schema.json in the ops state generator and tests The schema was published next to ops-state.json but nothing checked the document against it. The generator could write any shape and a hand edit would never be caught. Add a dependency free validator covering the schema subset in use, make the generator refuse to write a non conforming state, validate the committed ops-state.json in vitest, and expose ops:state and ops:validate npm scripts so the generator is a first class command. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01656XvdqNAVojSPQBtLZAAt --- package.json | 2 + src/__tests__/opsState.test.js | 42 +++++++++++++ tools/ops/build-ops-state.mjs | 9 +++ tools/ops/validate-ops-state.mjs | 105 +++++++++++++++++++++++++++++++ 4 files changed, 158 insertions(+) create mode 100644 src/__tests__/opsState.test.js create mode 100644 tools/ops/validate-ops-state.mjs diff --git a/package.json b/package.json index c2bd376..325bec0 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,8 @@ "budget": "node scripts/check-bundle-size.mjs", "smoke:visual": "node scripts/run-visual-smoke.mjs", "check:deploy": "node scripts/check-deploy-health.mjs", + "ops:state": "node tools/ops/build-ops-state.mjs", + "ops:validate": "node tools/ops/validate-ops-state.mjs", "ci": "node scripts/verify-runtime.mjs && npm run lint && npm run test && npm run build && npm run budget" }, "dependencies": { diff --git a/src/__tests__/opsState.test.js b/src/__tests__/opsState.test.js new file mode 100644 index 0000000..f427643 --- /dev/null +++ b/src/__tests__/opsState.test.js @@ -0,0 +1,42 @@ +import { describe, expect, it } from "vitest"; +import { readFileSync } from "node:fs"; +import { + DEFAULT_SCHEMA_PATH, + DEFAULT_STATE_PATH, + loadSchema, + validateOpsState, + validateOpsStateFile, +} from "../../tools/ops/validate-ops-state.mjs"; + +describe("ops-state contract", () => { + it("committed ops-state.json conforms to ops-state.schema.json", () => { + expect(validateOpsStateFile(DEFAULT_STATE_PATH, DEFAULT_SCHEMA_PATH)).toEqual([]); + }); + + it("committed ops-state.json reports the generator that owns it", () => { + const state = JSON.parse(readFileSync(DEFAULT_STATE_PATH, "utf8")); + expect(state.metadata.generator).toBe("tools/ops/build-ops-state.mjs"); + expect(state.metadata.schemaVersion).toBe(2); + }); + + it("rejects documents that break the contract", () => { + const schema = loadSchema(); + const valid = JSON.parse(readFileSync(DEFAULT_STATE_PATH, "utf8")); + + const badStatus = { ...valid, status: "green" }; + expect(validateOpsState(badStatus, schema)).toContainEqual(expect.stringContaining("$.status")); + + const missingMetadata = { ...valid }; + delete missingMetadata.metadata; + expect(validateOpsState(missingMetadata, schema)).toContainEqual(expect.stringContaining('required property "metadata"')); + + const extraField = { ...valid, notes: "hand edited" }; + expect(validateOpsState(extraField, schema)).toContainEqual(expect.stringContaining('unexpected property "notes"')); + + const badKpi = { ...valid, kpis: [{ label: "", value: 1, status: "ok" }] }; + expect(validateOpsState(badKpi, schema)).toContainEqual(expect.stringContaining("$.kpis[0].label")); + + const badDate = { ...valid, updatedAt: "yesterday" }; + expect(validateOpsState(badDate, schema)).toContainEqual(expect.stringContaining("date-time")); + }); +}); diff --git a/tools/ops/build-ops-state.mjs b/tools/ops/build-ops-state.mjs index f27b0e5..d0f714e 100644 --- a/tools/ops/build-ops-state.mjs +++ b/tools/ops/build-ops-state.mjs @@ -8,6 +8,7 @@ import { readFileSync, writeFileSync, existsSync, statSync, readdirSync } from " import { join, dirname, resolve, relative } from "node:path"; import { fileURLToPath } from "node:url"; import { execFileSync } from "node:child_process"; +import { loadSchema, validateOpsState } from "./validate-ops-state.mjs"; const PROJECT = { id: "pixel-forge", name: "PixelForge" }; const SCHEMA_VERSION = 2; @@ -236,6 +237,14 @@ const state = { }, }; +// ---- validate against the published contract before writing ---- +const schemaErrors = validateOpsState(state, loadSchema(join(ROOT, "ops-state.schema.json"))); +if (schemaErrors.length) { + console.error("[ops] refusing to write ops-state.json: output violates ops-state.schema.json"); + for (const error of schemaErrors) console.error(` - ${error}`); + process.exit(3); +} + // ---- write ---- try { writeFileSync(OUT, JSON.stringify(state, null, 2) + "\n", "utf8"); diff --git a/tools/ops/validate-ops-state.mjs b/tools/ops/validate-ops-state.mjs new file mode 100644 index 0000000..45c36ab --- /dev/null +++ b/tools/ops/validate-ops-state.mjs @@ -0,0 +1,105 @@ +#!/usr/bin/env node +// PixelForge · Ops state validator +// Checks an ops-state document against ops-state.schema.json using only +// built-in modules (the ops tooling contract forbids third-party packages). +// Supports the subset of JSON Schema draft-07 the schema actually uses: +// type (single or union), required, properties, additionalProperties, enum, +// minLength, minimum, items, and format: date-time. +// +// Usage: node tools/ops/validate-ops-state.mjs [statePath] [schemaPath] + +import { readFileSync } from "node:fs"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const ROOT = resolve(__dirname, "..", ".."); + +export const DEFAULT_STATE_PATH = join(ROOT, "ops-state.json"); +export const DEFAULT_SCHEMA_PATH = join(ROOT, "ops-state.schema.json"); + +function typeOf(value) { + if (value === null) return "null"; + if (Array.isArray(value)) return "array"; + if (typeof value === "number") return Number.isInteger(value) ? "integer" : "number"; + return typeof value; +} + +function matchesType(value, expected) { + const actual = typeOf(value); + if (expected === "number") return actual === "number" || actual === "integer"; + return actual === expected; +} + +function validateNode(value, schema, path, errors) { + if (schema.type) { + const allowed = Array.isArray(schema.type) ? schema.type : [schema.type]; + if (!allowed.some(type => matchesType(value, type))) { + errors.push(`${path}: expected type ${allowed.join("|")}, got ${typeOf(value)}`); + return; + } + } + + if (schema.enum && !schema.enum.includes(value)) { + errors.push(`${path}: value ${JSON.stringify(value)} is not one of ${JSON.stringify(schema.enum)}`); + } + + if (typeof value === "string") { + if (schema.minLength != null && value.length < schema.minLength) { + errors.push(`${path}: string shorter than minLength ${schema.minLength}`); + } + if (schema.format === "date-time" && Number.isNaN(Date.parse(value))) { + errors.push(`${path}: ${JSON.stringify(value)} is not a valid date-time`); + } + } + + if (typeof value === "number" && schema.minimum != null && value < schema.minimum) { + errors.push(`${path}: ${value} is below minimum ${schema.minimum}`); + } + + if (Array.isArray(value) && schema.items) { + value.forEach((item, index) => validateNode(item, schema.items, `${path}[${index}]`, errors)); + } + + if (value && typeof value === "object" && !Array.isArray(value)) { + const properties = schema.properties || {}; + for (const key of schema.required || []) { + if (!(key in value)) errors.push(`${path}: missing required property "${key}"`); + } + for (const [key, child] of Object.entries(value)) { + if (key in properties) { + validateNode(child, properties[key], `${path}.${key}`, errors); + } else if (schema.additionalProperties === false) { + errors.push(`${path}: unexpected property "${key}"`); + } + } + } +} + +export function validateOpsState(state, schema) { + const errors = []; + validateNode(state, schema, "$", errors); + return errors; +} + +export function loadSchema(schemaPath = DEFAULT_SCHEMA_PATH) { + return JSON.parse(readFileSync(schemaPath, "utf8")); +} + +export function validateOpsStateFile(statePath = DEFAULT_STATE_PATH, schemaPath = DEFAULT_SCHEMA_PATH) { + const state = JSON.parse(readFileSync(statePath, "utf8")); + return validateOpsState(state, loadSchema(schemaPath)); +} + +const invokedDirectly = process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url); +if (invokedDirectly) { + const statePath = process.argv[2] ? resolve(process.argv[2]) : DEFAULT_STATE_PATH; + const schemaPath = process.argv[3] ? resolve(process.argv[3]) : DEFAULT_SCHEMA_PATH; + const errors = validateOpsStateFile(statePath, schemaPath); + if (errors.length) { + console.error(`[ops] ${statePath} violates ${schemaPath}:`); + for (const error of errors) console.error(` - ${error}`); + process.exit(1); + } + console.log(`[ops] ${statePath} conforms to ${schemaPath}`); +} From 5b656b0606f889ae5249b12693fee5061b40b5c6 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 10 Sep 2026 06:25:14 +0000 Subject: [PATCH 2/5] Make the launcher to editor handoff real through a launch intent contract The launcher wrote PixelForge.launchDraft.v1 to sessionStorage on Create In Editor but nothing ever read it, so every preset, quick create card, and template opened the default 1200 by 800 document. Introduce src/launchIntent.js as the single contract: the launcher writes it, the editor consumes it once on mount and opens the requested size. Template cards now seed the draft with their dimensions instead of ignoring them. Backgrounds the document model cannot fill yet are reported to the user instead of being silently replaced. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01656XvdqNAVojSPQBtLZAAt --- src/PixelForge.jsx | 15 ++++- src/__tests__/launchIntent.test.jsx | 101 ++++++++++++++++++++++++++++ src/launchIntent.js | 68 +++++++++++++++++++ src/pages/LauncherPage.jsx | 13 ++-- 4 files changed, 189 insertions(+), 8 deletions(-) create mode 100644 src/__tests__/launchIntent.test.jsx create mode 100644 src/launchIntent.js diff --git a/src/PixelForge.jsx b/src/PixelForge.jsx index e434bdb..f4130e9 100644 --- a/src/PixelForge.jsx +++ b/src/PixelForge.jsx @@ -42,6 +42,7 @@ import { cloneShape, mergePrefs, getToolRequirement, isToolCompatibleWithLayer, normalizePanelTab, } from "./utils.js"; import { renderEditor } from "./render.js"; +import { consumeLaunchIntent } from "./launchIntent.js"; import { commitFloat } from "./marquee.js"; import { cropToRect, trimTransparent, rotateCanvas, flipCanvas } from "./canvasOps.js"; import { hitShape } from "./shapes.js"; @@ -715,8 +716,18 @@ export default function PixelForge() { /* ─── Init ─── */ useEffect(() => { - resetDocument(); - }, [resetDocument]); + const intent = consumeLaunchIntent(); + if (!intent) { + resetDocument(); + return; + } + resetDocument(intent.width, intent.height, intent.background || DEFAULT_BG); + if (!intent.backgroundSupported) { + requestAnimationFrame(() => { + flash(`Background "${intent.requestedBackground}" is not supported yet. Opened with a white background.`, "info", 3200); + }); + } + }, [flash, resetDocument]); /* ─── Render ─── */ const renderFrame = useEffectEvent(() => { diff --git a/src/__tests__/launchIntent.test.jsx b/src/__tests__/launchIntent.test.jsx new file mode 100644 index 0000000..8077ddd --- /dev/null +++ b/src/__tests__/launchIntent.test.jsx @@ -0,0 +1,101 @@ +import { act, cleanup, render, screen, waitFor } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import PixelForge from "../PixelForge.jsx"; +import { renderEditor } from "../render.js"; +import { + LAUNCH_INTENT_KEY, + consumeLaunchIntent, + normalizeLaunchIntent, + writeLaunchIntent, +} from "../launchIntent.js"; +import { DEFAULT_BG, DEFAULT_H, DEFAULT_W } from "../constants.js"; + +vi.mock("../render.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + renderEditor: vi.fn(), + }; +}); + +class ResizeObserverMock { + observe() {} + disconnect() {} +} + +function setViewportSize(width = 1000, height = 800) { + Object.defineProperty(HTMLElement.prototype, "clientWidth", { configurable: true, value: width }); + Object.defineProperty(HTMLElement.prototype, "clientHeight", { configurable: true, value: height }); +} + +function installAnimationFrame() { + if (globalThis.requestAnimationFrame) return; + globalThis.requestAnimationFrame = callback => window.setTimeout(() => callback(performance.now()), 0); + globalThis.cancelAnimationFrame = id => window.clearTimeout(id); + window.requestAnimationFrame = globalThis.requestAnimationFrame; + window.cancelAnimationFrame = globalThis.cancelAnimationFrame; +} + +describe("launchIntent contract", () => { + beforeEach(() => { + window.sessionStorage.clear(); + }); + + it("round-trips a launcher draft and consumes it exactly once", () => { + expect(writeLaunchIntent({ preset: "hd", width: 1920, height: 1080, background: "white" })).toBe(true); + expect(window.sessionStorage.getItem(LAUNCH_INTENT_KEY)).not.toBeNull(); + const intent = consumeLaunchIntent(); + expect(intent).toMatchObject({ preset: "hd", width: 1920, height: 1080, background: DEFAULT_BG, backgroundSupported: true }); + expect(window.sessionStorage.getItem(LAUNCH_INTENT_KEY)).toBeNull(); + expect(consumeLaunchIntent()).toBeNull(); + }); + + it("clamps dimensions to the document model and reports unsupported backgrounds", () => { + const intent = normalizeLaunchIntent({ width: "20000", height: "12", background: "dark" }); + expect(intent.width).toBe(8192); + expect(intent.height).toBe(64); + expect(intent.background).toBeNull(); + expect(intent.requestedBackground).toBe("dark"); + expect(intent.backgroundSupported).toBe(false); + }); + + it("falls back to defaults for garbage input", () => { + expect(normalizeLaunchIntent(null)).toBeNull(); + expect(normalizeLaunchIntent({ width: "abc", height: -4 })).toMatchObject({ width: DEFAULT_W, height: DEFAULT_H, backgroundSupported: true }); + window.sessionStorage.setItem(LAUNCH_INTENT_KEY, "{not json"); + expect(consumeLaunchIntent()).toBeNull(); + expect(window.sessionStorage.getItem(LAUNCH_INTENT_KEY)).toBeNull(); + }); +}); + +describe("editor honors launch intent", () => { + beforeEach(() => { + window.sessionStorage.clear(); + setViewportSize(); + installAnimationFrame(); + globalThis.ResizeObserver = ResizeObserverMock; + window.ResizeObserver = ResizeObserverMock; + renderEditor.mockClear(); + }); + + afterEach(() => { + cleanup(); + window.sessionStorage.clear(); + }); + + it("opens the document size chosen in the launcher", async () => { + writeLaunchIntent({ preset: "story", width: 1080, height: 1920, background: "white" }); + await act(async () => { + render(); + }); + await waitFor(() => expect(screen.getAllByText("1080 × 1920").length).toBeGreaterThan(0)); + expect(window.sessionStorage.getItem(LAUNCH_INTENT_KEY)).toBeNull(); + }); + + it("opens the default document when no intent is present", async () => { + await act(async () => { + render(); + }); + await waitFor(() => expect(screen.getAllByText(`${DEFAULT_W} × ${DEFAULT_H}`).length).toBeGreaterThan(0)); + }); +}); diff --git a/src/launchIntent.js b/src/launchIntent.js new file mode 100644 index 0000000..326c5d6 --- /dev/null +++ b/src/launchIntent.js @@ -0,0 +1,68 @@ +import { DEFAULT_W, DEFAULT_H, DEFAULT_BG } from "./constants.js"; +import { clamp } from "./utils.js"; + +// Launch intent is the handoff contract between the launcher pages (home, +// templates) and the editor. The launcher writes it, the editor consumes it +// exactly once on mount and opens a document that honors it. sessionStorage +// keeps the intent scoped to the tab that created it. +export const LAUNCH_INTENT_KEY = "PixelForge.launchDraft.v1"; +export const MIN_LAUNCH_DIM = 64; +export const MAX_LAUNCH_DIM = 8192; + +// Backgrounds the document model can honor today. createDefaultDocument fills +// the background layer with one solid color, so only solid fills map. Any +// other launcher choice is reported back to the caller as unsupported instead +// of being silently replaced. +export const LAUNCH_BACKGROUNDS = { + white: DEFAULT_BG, +}; + +function toLaunchDim(value, fallback) { + const n = Math.round(Number(value)); + if (!Number.isFinite(n) || n <= 0) return fallback; + return clamp(n, MIN_LAUNCH_DIM, MAX_LAUNCH_DIM); +} + +export function normalizeLaunchIntent(raw) { + if (!raw || typeof raw !== "object") return null; + const requestedBackground = typeof raw.background === "string" ? raw.background.trim().toLowerCase() : ""; + const background = LAUNCH_BACKGROUNDS[requestedBackground] || null; + return { + preset: typeof raw.preset === "string" ? raw.preset : null, + width: toLaunchDim(raw.width, DEFAULT_W), + height: toLaunchDim(raw.height, DEFAULT_H), + background, + requestedBackground: requestedBackground || null, + backgroundSupported: !requestedBackground || !!background, + }; +} + +export function writeLaunchIntent(draft) { + try { + window.sessionStorage.setItem(LAUNCH_INTENT_KEY, JSON.stringify(draft)); + return true; + } catch { + // Storage may be disabled; the editor still opens with defaults. + return false; + } +} + +function takeStoredIntent() { + try { + const raw = window.sessionStorage.getItem(LAUNCH_INTENT_KEY); + if (raw != null) window.sessionStorage.removeItem(LAUNCH_INTENT_KEY); + return raw; + } catch { + return null; + } +} + +export function consumeLaunchIntent() { + const raw = takeStoredIntent(); + if (raw == null) return null; + try { + return normalizeLaunchIntent(JSON.parse(raw)); + } catch { + return null; + } +} diff --git a/src/pages/LauncherPage.jsx b/src/pages/LauncherPage.jsx index c11d969..5139c54 100644 --- a/src/pages/LauncherPage.jsx +++ b/src/pages/LauncherPage.jsx @@ -4,6 +4,7 @@ import { Palette, Play, Plus, Search, Upload, } from "lucide-react"; import { ROUTES, routeHref } from "../routes.js"; +import { writeLaunchIntent } from "../launchIntent.js"; const QUICK = [ { id: "sq", name: "Square", size: "2048 x 2048", ratio: "1:1" }, @@ -106,11 +107,7 @@ export default function LauncherPage({ navigate, initialView = "home" }) { const [draft, setDraft] = useState({ preset: "hd", width: 1920, height: 1080, background: "white" }); const isTemplates = initialView === "templates"; const createProject = () => { - try { - window.sessionStorage.setItem("PixelForge.launchDraft.v1", JSON.stringify(draft)); - } catch { - // Non-critical; the editor remains available even if storage is disabled. - } + writeLaunchIntent(draft); navigate(ROUTES.editor); }; @@ -218,7 +215,11 @@ export default function LauncherPage({ navigate, initialView = "home" }) {
{TEMPLATES.map(template => ( -
- {RECENT_FILES.map(file => ( + {recentFiles.length ? recentFiles.map(file => ( - ))} + )) : ( +

No recent files yet. PixelForge does not track opened projects; use Open Editor and load a .pforge file from the File menu.

+ )}
From 13e4a63a2cdfcaa457437910a304bb845cd74a25 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 10 Sep 2026 06:28:15 +0000 Subject: [PATCH 4/5] Verify the deployed commit in the post deploy health gate The health job fetched the live page and its assets but had no way to tell whether it was looking at the build that was just deployed or a stale one still being served, so a failed or partial upload could pass on the strength of the previous release. Vite now stamps index.html with the commit via a pixelforge-build meta tag, the deploy workflow passes github.sha into both the build and the health check, and the check fails when the served stamp is missing or differs. Local runs without an expected stamp still work and report the stamp as informational. The script is importable so its parsing and enforcement are covered by vitest against a local http server. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01656XvdqNAVojSPQBtLZAAt --- .github/workflows/deploy-pages.yml | 4 ++ README.md | 2 +- scripts/check-deploy-health.mjs | 79 +++++++++++++++++++----- src/__tests__/deployHealth.test.js | 99 ++++++++++++++++++++++++++++++ vite.config.js | 29 +++++++++ 5 files changed, 196 insertions(+), 17 deletions(-) create mode 100644 src/__tests__/deployHealth.test.js diff --git a/.github/workflows/deploy-pages.yml b/.github/workflows/deploy-pages.yml index 76707a8..4cea05e 100644 --- a/.github/workflows/deploy-pages.yml +++ b/.github/workflows/deploy-pages.yml @@ -40,6 +40,8 @@ jobs: - name: Build site run: npm run build + env: + PIXELFORGE_BUILD_SHA: ${{ github.sha }} - name: Upload Pages artifact uses: actions/upload-pages-artifact@v5 @@ -80,3 +82,5 @@ jobs: - name: Post-deploy health check run: node scripts/check-deploy-health.mjs "${{ needs.deploy.outputs.page_url }}" + env: + PIXELFORGE_EXPECTED_BUILD: ${{ github.sha }} diff --git a/README.md b/README.md index cfb8016..e42b686 100644 --- a/README.md +++ b/README.md @@ -49,7 +49,7 @@ Browser visual smoke (builds, serves the preview, and drives headless Chrome acr npm run smoke:visual ``` -CI on `main` runs lint, tests, build, the bundle budget, and the browser visual smoke. Deployment to GitHub Pages is handled by `.github/workflows/deploy-pages.yml` after CI succeeds on `main`, with a manual dispatch fallback; a post-deploy health check then verifies the published page and its hashed script/stylesheet assets (`npm run check:deploy` runs the same check locally). +CI on `main` runs lint, tests, build, the bundle budget, and the browser visual smoke. Deployment to GitHub Pages is handled by `.github/workflows/deploy-pages.yml` after CI succeeds on `main`, with a manual dispatch fallback; a post-deploy health check then verifies the published page carries the build stamp for the deployed commit (``, injected at build time) and that its hashed script/stylesheet assets load (`npm run check:deploy` runs the same check locally; pass `--expect-build ` to enforce the stamp). ## Security notes diff --git a/scripts/check-deploy-health.mjs b/scripts/check-deploy-health.mjs index 65af170..9d66c03 100644 --- a/scripts/check-deploy-health.mjs +++ b/scripts/check-deploy-health.mjs @@ -2,23 +2,40 @@ // // Fetches the deployed page, confirms the app shell markup is present, then // fetches the hashed module script and stylesheet the page references so a -// broken asset upload cannot pass. Retries to ride out Pages propagation. +// broken asset upload cannot pass. When an expected build stamp is supplied +// (PIXELFORGE_EXPECTED_BUILD or --expect-build), the page must carry that +// stamp in its tag, so a stale or partial +// deployment cannot pass just because some earlier build is still served. +// Retries to ride out Pages propagation. // -// Usage: node scripts/check-deploy-health.mjs [baseUrl] +// Usage: node scripts/check-deploy-health.mjs [baseUrl] [--expect-build ] // baseUrl defaults to PIXELFORGE_DEPLOY_URL or the live GitHub Pages origin. import process from "node:process"; +import { fileURLToPath } from "node:url"; +import { resolve } from "node:path"; -const baseUrl = normalizeBase( - process.argv[2] || process.env.PIXELFORGE_DEPLOY_URL || "https://davehomeassist.github.io/PixelForge/", -); -const maxAttempts = Number(process.env.PIXELFORGE_HEALTH_ATTEMPTS || 10); -const retryDelayMs = Number(process.env.PIXELFORGE_HEALTH_RETRY_MS || 6000); +export const BUILD_META_NAME = "pixelforge-build"; -function normalizeBase(url) { +export function normalizeBase(url) { return url.endsWith("/") ? url : `${url}/`; } +export function parseArgs(argv) { + const positional = []; + let expectedBuild = null; + for (let index = 0; index < argv.length; index += 1) { + const arg = argv[index]; + if (arg === "--expect-build") { + expectedBuild = argv[index + 1] || null; + index += 1; + } else { + positional.push(arg); + } + } + return { baseUrl: positional[0] || null, expectedBuild }; +} + async function fetchOk(url, accept) { const response = await fetch(url, { redirect: "follow", headers: { accept } }); if (!response.ok) { @@ -31,7 +48,7 @@ async function fetchOk(url, accept) { return { body, contentType: response.headers.get("content-type") || "" }; } -function extractSameOriginAssets(html) { +export function extractSameOriginAssets(html, baseUrl) { const assets = []; const patterns = [ { kind: "script", regex: /]*type="module"[^>]*src="([^"]+)"/g }, @@ -48,7 +65,13 @@ function extractSameOriginAssets(html) { return assets; } -async function checkOnce() { +export function extractBuildStamp(html) { + const match = html.match(/ asset.kind === "script"); const hasStylesheet = assets.some(asset => asset.kind === "stylesheet"); if (!hasScript || !hasStylesheet) { @@ -81,10 +117,18 @@ async function checkOnce() { } async function main() { + const args = parseArgs(process.argv.slice(2)); + const baseUrl = normalizeBase( + args.baseUrl || process.env.PIXELFORGE_DEPLOY_URL || "https://davehomeassist.github.io/PixelForge/", + ); + const expectedBuild = (args.expectedBuild || process.env.PIXELFORGE_EXPECTED_BUILD || "").trim() || null; + const maxAttempts = Number(process.env.PIXELFORGE_HEALTH_ATTEMPTS || 10); + const retryDelayMs = Number(process.env.PIXELFORGE_HEALTH_RETRY_MS || 6000); + let lastError; for (let attempt = 1; attempt <= maxAttempts; attempt += 1) { try { - await checkOnce(); + await checkOnce({ baseUrl, expectedBuild }); console.log(`[health] Deployment healthy at ${baseUrl}`); return; } catch (error) { @@ -98,7 +142,10 @@ async function main() { throw lastError; } -main().catch(error => { - console.error("[health] Failed:", error.message); - process.exitCode = 1; -}); +const invokedDirectly = process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url); +if (invokedDirectly) { + main().catch(error => { + console.error("[health] Failed:", error.message); + process.exitCode = 1; + }); +} diff --git a/src/__tests__/deployHealth.test.js b/src/__tests__/deployHealth.test.js new file mode 100644 index 0000000..8762d68 --- /dev/null +++ b/src/__tests__/deployHealth.test.js @@ -0,0 +1,99 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { createServer } from "node:http"; +import { + checkOnce, + extractBuildStamp, + extractSameOriginAssets, + parseArgs, +} from "../../scripts/check-deploy-health.mjs"; + +const PAGE = (stamp) => ` + +PixelForge + + + +
`; + +function serve(routes) { + const server = createServer((req, res) => { + const route = routes[req.url]; + if (!route) { + res.statusCode = 404; + res.end("missing"); + return; + } + res.setHeader("content-type", route.type); + res.end(route.body); + }); + return new Promise(resolve => { + server.listen(0, "127.0.0.1", () => { + const { port } = server.address(); + resolve({ server, baseUrl: `http://127.0.0.1:${port}/PixelForge/` }); + }); + }); +} + +describe("check-deploy-health parsing", () => { + it("parses positional base url and expected build flag", () => { + expect(parseArgs(["https://example.test/PixelForge/", "--expect-build", "abc123"])).toEqual({ + baseUrl: "https://example.test/PixelForge/", + expectedBuild: "abc123", + }); + expect(parseArgs([])).toEqual({ baseUrl: null, expectedBuild: null }); + }); + + it("extracts the build stamp and only same-origin assets", () => { + const html = PAGE("deadbeef"); + expect(extractBuildStamp(html)).toBe("deadbeef"); + expect(extractBuildStamp("")).toBeNull(); + const assets = extractSameOriginAssets(html, "https://example.test/PixelForge/"); + expect(assets.map(asset => asset.kind)).toEqual(["script", "stylesheet"]); + expect(assets.every(asset => asset.url.startsWith("https://example.test/"))).toBe(true); + }); +}); + +describe("check-deploy-health against a served page", () => { + let active = null; + afterEach(async () => { + if (active) await new Promise(resolve => active.close(resolve)); + active = null; + }); + + async function serveBuild(stamp) { + const served = await serve({ + "/PixelForge/": { type: "text/html; charset=utf-8", body: PAGE(stamp) }, + "/PixelForge/assets/index-abc.js": { type: "text/javascript", body: "console.log(1)" }, + "/PixelForge/assets/index-abc.css": { type: "text/css", body: "body{}" }, + }); + active = served.server; + return served.baseUrl; + } + + it("passes when the served build matches the expected stamp", async () => { + const baseUrl = await serveBuild("abc123"); + await expect(checkOnce({ baseUrl, expectedBuild: "abc123" })).resolves.toBeUndefined(); + }); + + it("fails when the served build is stale", async () => { + const baseUrl = await serveBuild("older"); + await expect(checkOnce({ baseUrl, expectedBuild: "abc123" })).rejects.toThrow(/is build older, expected abc123/); + }); + + it("fails when the page carries no stamp but one is expected", async () => { + const served = await serve({ + "/PixelForge/": { type: "text/html", body: PAGE("x").replace(/]*>/, "") }, + }); + active = served.server; + await expect(checkOnce({ baseUrl: served.baseUrl, expectedBuild: "abc123" })).rejects.toThrow(/carries no pixelforge-build stamp/); + }); + + it("fails when a referenced asset is missing", async () => { + const served = await serve({ + "/PixelForge/": { type: "text/html", body: PAGE("abc123") }, + "/PixelForge/assets/index-abc.css": { type: "text/css", body: "body{}" }, + }); + active = served.server; + await expect(checkOnce({ baseUrl: served.baseUrl, expectedBuild: null })).rejects.toThrow(/index-abc\.js returned 404/); + }); +}); diff --git a/vite.config.js b/vite.config.js index 8d1f74c..c17ce2f 100644 --- a/vite.config.js +++ b/vite.config.js @@ -1,5 +1,34 @@ +import { execFileSync } from "node:child_process"; import { defineConfig } from "vite"; +// Build stamp written into index.html so the post-deploy health check can +// prove the published page is this build and not a stale earlier deploy. +// CI passes the commit through PIXELFORGE_BUILD_SHA; local builds fall back +// to the checked out HEAD and finally to "unknown". +function resolveBuildStamp() { + const fromEnv = (process.env.PIXELFORGE_BUILD_SHA || process.env.GITHUB_SHA || "").trim(); + if (fromEnv) return fromEnv; + try { + return execFileSync("git", ["rev-parse", "HEAD"], { stdio: ["ignore", "pipe", "ignore"] }).toString().trim() || "unknown"; + } catch { + return "unknown"; + } +} + +function buildStampPlugin() { + const stamp = resolveBuildStamp(); + return { + name: "pixelforge-build-stamp", + transformIndexHtml(html) { + return html.replace( + "", + `<meta name="pixelforge-build" content="${stamp.replace(/"/g, "")}">\n <title>`, + ); + }, + }; +} + export default defineConfig({ base: process.env.VITE_BASE ?? "/PixelForge/", + plugins: [buildStampPlugin()], }); From c094712c9ea9a686aac25d36fe8ca5a15c98aa50 Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Thu, 10 Sep 2026 06:28:31 +0000 Subject: [PATCH 5/5] Align the user guide with session scoped keys and the Replicate proxy The guide still said keys live in local storage and never leave the browser, and its setup steps omitted the CORS proxy that the H-1 remediation made mandatory, so a reader following it could never complete a generation. Describe session storage, add the proxy step, and state where each key actually travels. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01656XvdqNAVojSPQBtLZAAt --- docs/USER_GUIDE.md | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/docs/USER_GUIDE.md b/docs/USER_GUIDE.md index a6a5fe7..c46893d 100644 --- a/docs/USER_GUIDE.md +++ b/docs/USER_GUIDE.md @@ -201,13 +201,14 @@ PixelForge can generate images from a text prompt directly into a new layer. You 2. Click **Settings** (opens the AI Settings modal). 3. Paste your **Anthropic API key** (used to refine the prompt). 4. Paste your **Replicate API key** (used to render the image). -5. Click **Save**. Keys are stored in this browser's local storage only. +5. Paste the URL of a **CORS proxy** you run yourself that forwards requests to `api.replicate.com`. Replicate's API does not send CORS headers, so the browser cannot call it directly; without a proxy every generation fails at the request step. +6. Click **Save**. Keys are stored in this tab's session storage only and are cleared when the tab closes. Get keys from: - **Anthropic:** https://console.anthropic.com/ → API Keys - **Replicate:** https://replicate.com/account/api-tokens -Your keys never leave your browser, never enter `.pforge` save files, and never enter autosaved drafts. +Your keys never enter `.pforge` save files or autosaved drafts. The Anthropic key goes only to `api.anthropic.com`. The Replicate key transits the CORS proxy you configured, so only use a proxy you control. ### Generating @@ -343,7 +344,10 @@ Check that the active layer is **visible** (eye icon not crossed out) and has ** The Brush works only on raster layers. Add one via **+ Raster** in the Layers section, or let PixelForge auto-switch by clicking the highlighted layer. **"AI Generate says 'Set your API keys'."** -Open **✨ Generate → Settings** and paste both keys. Keys are stored in your browser's local storage. +Open **✨ Generate → Settings** and paste both keys. Keys are stored in this tab's session storage, so a new tab or a restarted browser needs them again. + +**"AI generation says it could not reach Replicate."** +Replicate blocks direct browser calls. Set the CORS proxy URL in AI Settings to a proxy you run that forwards to `api.replicate.com`. **"AI generation failed."** - Check your key validity on the provider dashboard