From e00a10c03980deaff723de7b4d1f6908b25c37fd Mon Sep 17 00:00:00 2001 From: Yilin Jing Date: Fri, 25 Sep 2026 10:07:09 -0400 Subject: [PATCH 01/15] Plan the project package: format, id, sp pack, board isolation Co-Authored-By: Claude Opus 5.5 --- docs/2026-09-25-project-package.md | 269 +++++++++++++++++++++++++++++ 1 file changed, 269 insertions(+) create mode 100644 docs/2026-09-25-project-package.md diff --git a/docs/2026-09-25-project-package.md b/docs/2026-09-25-project-package.md new file mode 100644 index 00000000..9c0f066e --- /dev/null +++ b/docs/2026-09-25-project-package.md @@ -0,0 +1,269 @@ +# A project is the unit people share + +2026-09-25. A plan, not yet built. Reviewed by two adversarial passes (correctness and security +against the code; scope against the rules in CLAUDE.md). What they changed is at the end. + +Whatever someone shares, on the community page or anywhere else, is packed and uploaded as a +whole project. Nothing smaller is shared: a canvas that should travel alone is a project with +one canvas. This plan says what a package holds, how it is checked, and what has to be safe +before anyone opens a project someone else made. + +## Where the format stands + +A project is a folder under the projects directory (`2026-09-23-projects-folder-only.md`). +Everything a canvas shows is saved in the folder, and every reference inside it is relative +(`2026-09-24-canvas-content-on-disk.md`). That was designed with sharing in mind, and on the +seven projects on this machine it holds: + +- 295 `canvas.json` assets point at `./files/…`. One points at a sibling canvas's `files/`, + `../sandwich-video/files/…` from `kasra-design`. None is absolute, none is a data URI, and + none points at a file that is missing. +- `canvas.json` carries tldraw's `schemaVersion` and is migrated on load (`canvasContent.ts`). +- `project.json` stores the cover as a path, and falls back to the default if the path is gone. + +**It is sound as a working folder but not as a package.** Five gaps: + +1. **Nothing defines what a project is made of.** The server knows what it reads; the folder + holds whatever an agent left there. + + | Project | Size | What makes it big | + |---|---|---| + | Launch Video Studios | 5.5 GB | `sandwich-video/files/`, 2.1 GB of video | + | Super Prototyping Site | 485 MB | | + + About 730 MB across projects is `scratch/`. Project roots also hold `tools/`, `web/`, + `mockups/`, `.claude/`, `refs/`, `.DS_Store`, and a loose HTML presentation. +2. **A board is not safe to open outside the canvas.** Boards are arbitrary HTML with scripts. + On the canvas they are safe: `CanvasFileShapeUtil.tsx` renders them `srcDoc` with + `sandbox=""`, or `sandbox="allow-scripts"` without `allow-same-origin` for the inspector. + But `/board//.html` (`sp.ts:354`) serves the same HTML on the app's own origin + with no CSP. Two ways reach it: + - `BoardsSheet.tsx:133` frames every board unsandboxed. + - Any board link opened in a tab of its own. + + A script there sends `Sec-Fetch-Site: same-origin`. `sameOrigin()` (`sp.ts:48`), which + guards every `/__sp` route, accepts it. So the script can POST to `/__sp/agent/run` and have + the agent run shell commands on the machine, or write any project's files. This is true + today for any board an agent writes, not only for shared ones. +3. **Nothing names the format or the project.** `project.json` holds an optional `cover`, and + on some projects a `name` the agent wrote. + - It has no version, so a newer layout cannot be told apart from a broken one. + - A project has no id. Its identity is its folder name, which is also its address + (`/p//`). +4. **Broken or unsafe contents are tolerated.** + - `readJson` (`boards.ts`) warns and carries on when a JSON file will not parse. + - `boardIndex` follows symlinks on purpose (`layout.md`), and they could point anywhere. + - A name containing `#` or `?` is dropped from the canvas, with only a console warning. + + Tolerance is right for your own folder. At an upload it lets a broken or hostile project + through. +5. **Third-party material sits beside first-party work.** `ref-*.html`, `assets/refs/` and a + root `refs/` (screen recordings from `/__sp/projects/ref`) are captures of other people's + products. The repo's `.gitignore` keeps them out of Git; a zip of the folder would ship + them. + +## What others do + +Four surveys looked at design tools, code sandboxes, plugin registries and the pi coding agent. +What carries over: + +| From | Practice | Gap | +|---|---|---| +| Penpot `.penpot` | A rarely-changing *format* version on the package; each document keeps its own *data* version, migrated on import. | 3 | +| Obsidian, Blender, HACS | Identity is an `id` in the manifest, checked for collisions by a bot, never the folder or repo name. | 3 | +| Obsidian releases, npm `files` | An allowlist of what ships. Ignore files are the fallback, and are where leaks come from. | 1, 5 | +| CodePen, Observable, Claude Artifacts | Runnable user HTML only runs on an origin that is not the app's. Replit's 2019 XSS came from `allow-scripts` together with `allow-same-origin` on user content. | 2 | +| Blender, Bolt, CodePen | A stated size cap, enforced at upload. | 1 | +| excalidraw-libraries | Submit by PR: one folder per submission plus an index entry. CI checks, a person reviews. | — | +| pi packages | The folder layout is the manifest. A `pi` key in `package.json` is needed only to depart from the conventions. The gallery is unreviewed, and says so: "review third-party package source before installing it." | 1, 3 | + +Considered and not taken: + +- **A file inventory in the manifest (Penpot).** Our folder conventions already say what a + project holds, as pi's do, and `boardIndex` reads them. An inventory would be a second copy + of that, and could disagree with the folder. +- **"Scan, don't sandbox" (Obsidian, VS Code, pi).** Plugins need their powers, so a sandbox + would break them. Boards need none: they are self-contained by the rule in `layout.md`, so + isolating them costs almost nothing. For the same reason, PR review in Phase 3 is curation, + never the security control. Phase 0 is. +- **Content-addressed `files/` (Excalidraw).** Deduplication pays only once the same bytes are + shared twice, and renaming would touch every `canvas.json`. +- **Git LFS for large files.** It spends the community repo's bandwidth quota; the cap does the + job for now. + +## The plan + +### Phase 0: a board never runs on the app's origin + +This stands alone and ships first. It protects local users today. + +- **The server.** In the `/board` route, an `.html` or `.svg` response gets the header the + `/file` route already sends (`sp.ts:470`): + `Content-Security-Policy: sandbox allow-scripts allow-forms allow-popups allow-modals allow-downloads`. + + With no `allow-same-origin`, the document gets an opaque origin wherever it is loaded, + framed or top-level. Its requests then carry `Sec-Fetch-Site: cross-site`, which + `sameOrigin()` already refuses. Rasters and video under the same route cannot run script and + need nothing. +- **The hosted build.** `vite.config.ts` writes boards into `dist/board/` as static files, and + Cloudflare Pages runs no server. A `canvas/public/_headers` rule gives `/board/*` the same + header. It matters there too: the demo is proxied under `superproto.dev/demo/`, so the + landing page and the committed `ref-*` captures share its origin. +- **Links.** `CanvasLinkShapeUtil.tsx:273` passes `layout.json`'s `links[].url` to + `window.open` in the app's own frame. It opens only `http:` and `https:`. +- **Test** (`sp.test.ts`): a board and an SVG carry the CSP. A POST to `/__sp/agent/run` with + `Sec-Fetch-Site: cross-site` is refused; that is already true, and the test pins it. +- **Check by hand before merging:** + - Export to Figma, which reads `sheet.html`. + - `/__sp/shoot` and `refkit shoot` against a board URL. + - A board with Google Fonts. + - The inspector. + - A board that uses `localStorage`: it throws in an opaque origin, as it already does on the + canvas. + + What stops working is what this closes. It gets fixed the way the inspector already works, + with `postMessage`, not by loosening the sandbox. + +### Phase 1: `project.json` gets `format` and `id` + +```json +{ "format": 1, "id": "0b6d3c1e-…", "name": "Launch Video Studios", "cover": { "path": "…" } } +``` + +- **`format`: the version of the folder layout and of this app's own files** (`layout.json`, + `comments.json`, `project.json`). `canvas.json` keeps tldraw's schema version, which is + Penpot's second tier. + - A project with no `format` is `1`. That is not a fallback: every folder that exists today + is a format-1 folder, and this is the day the number starts. + - A project with a `format` above the app's is refused when opened, local or not, with a + message to update the app. An older app that opened it would half-understand it, and its + next save could lose what it did not understand. +- **`id`: a UUID, minted when New project makes the project** (`POST /__sp/projects` in + `projects.ts`). + - A project made earlier gets one from its first `sp pack`. Nothing is written on a GET, the + rule from `2026-09-22-agent-workspace.md`. + - The id is the project's identity for sharing only. The folder name stays the local + address, and routing and the store's keys do not change. +- `author`, `license` and a fork's source are not added until the community repo takes its + first real submission. That is the case that shows where they belong. +- `layout.md`'s section on `project.json` lists the two fields. That copy ships with the app, + and it is the one the agent reads. + +### Phase 2: `sp pack` + +`sp pack --check` validates and writes nothing. `sp pack -o ` also +copies what passes into ``, as a folder: a PR adds a folder, so a zip has no reader yet. +- It lives in `tools/sp_canvas.py` beside the other subcommands, because CI and the agent both + already run `sp`. +- The rules it applies partly repeat `boardIndex` and `cover.ts`, in a second language. That + drift is accepted until import in the app needs the same rules in TypeScript, which is the + second case that would justify sharing them. + +**What goes in.** Everything else is left out and listed in the report, so the author sees +what was dropped. + +``` +project.json required +PRD.md +canvases// + NN-*.html boards; not ref-*.html + layout.json + icon.png + canvas.json + files/ only files a canvas.json record points at (see check 4) + assets/** not assets/refs/** + assets-dark/** + gen.py README.md probes.json crops.json assets.json +``` + +Always out: `scratch/`, `ref-*`, `assets/refs/`, the root `refs/`, dot files and dot folders, +anything else at the root, and `comments.json` (open question 2). + +**Checks.** Each check fails the pack; none warns and carries on: + +1. `project.json` parses and is an object, with a known `format` and a UUID `id`. +2. Every JSON file in the list parses. +3. No symlinks. No path escapes the project after resolution. +4. **Every reference resolves to a file in the package.** That means `canvas.json` asset `src`s, + `layout.json` file entries, and the `project.json` cover. + - References are collected across every canvas before anything is copied. + - A file is shipped where it lives: `sandwich-video/files/logo-servicenow.png` ships under + `sandwich-video/` because `kasra-design` points at it, even if nothing in `sandwich-video` + does. +5. `links[].url` in `layout.json` is `http:` or `https:`. +6. Names contain no `#` or `?`, do not start with a dot, and are NFC. macOS stores them + decomposed, and other systems do not re-normalise. +7. Size: each file at most 50 MB, and the whole at most 200 MB. That sits between Blender + (100–200 MB) and CodePen (15 MB media). Video is what hits it, and a project whose value is + gigabytes of video shares a link, not a package. + +Minting a missing `id` is the only write `sp pack` makes to the project. + +### Phase 3: community submission by PR + +The shape comes from excalidraw-libraries. Details wait for the first hand-submitted project. + +- **A community repo** holds `projects//`, the output of `sp pack -o`, and an + `index.json` with one entry per project. +- **CI runs `sp pack --check`** on each changed project and checks that each `id` is either + new or already at that same path. That is continuity of the path, not proof of who the + author is: there are no accounts. A person reviews what passes, for quality. The review is + curation and promises nothing about safety; Phase 0 does that. +- **The site** (`super-prototyping-landing`) builds `/community` from `index.json`. It shows + covers as images and never frames a board. + +## Later + +**Import** gets its own document when the app gets an import button. It has to: +- run every Phase 2 check on the way in, and refuse the whole package on any failure; +- stop extracting once the bytes actually written pass the cap, whatever the archive declares; +- refuse any entry whose path resolves outside the new folder; +- name the new folder from `name`, de-collided as New project does; +- ask the person, when the `id` is already in use, whether to replace that project or keep + both; +- tell the agent that the project, and its `gen.py`, came from someone else. + +A zip (`sp pack -o x.zip`) arrives with it, when something first hands out a download. + +Not planned: accounts, signing, hosting projects on superproto.dev, live collaboration, and +packing a single canvas. + +## Open questions + +1. **Does `gen.py` ship?** The recommendation is yes. + - For: it is the canvas's source of truth, and a remix without it can only be hand-edited. + - Against: it is code. The app never runs it, and import tells the agent whose it is. +2. **Does `comments.json` ship?** The recommendation is no: review threads can hold names and + private remarks. +3. **Should boards get `connect-src 'none'` too**, so a board cannot send what is typed into it + anywhere? Boards inline their images, so fonts are the one thing to check. +4. **The community repo's upkeep:** + - how big it may grow before it needs another home; + - who owns its CI (Phase 3 renders covers with headless Chrome); + - what licence a submission is under, and who says so; + - how something is taken down. + +## What the review changed + +- **Cross-canvas files.** A file one canvas points at inside another's `files/` would have + been dropped, or would have failed the pack. The real case is `kasra-design` pointing at + `sandwich-video`. References are now collected project-wide, and each file ships where it + lives. +- **Phase 0 now covers the hosted build.** It serves boards as static files and never runs the + server route, so it gets a `_headers` rule. It also covers SVG and `layout.json` links, which + run in app code and not in an iframe. +- **Import leaves this plan,** and the requirements the review found for it are recorded + above: the cap counts bytes actually extracted, so a crafted archive cannot slip past it, and + no entry may land outside the new folder. +- **Cut until a real case asks for them:** + - `author`, `license` and `forkedFrom`; + - the deterministic zip; + - the zip itself; + - `/` in the community repo's path, which was the third copy of one fact; + - the "imported" marker's exact shape. +- **The caps are decided,** not both a check and an open question. +- **Two things are said plainly:** the Python rules may drift from `boardIndex`, and the id + check in CI proves path continuity, not authorship. +- **Kept against the reviews:** refusing a newer `format` on every open, including local + ones. An older app that opened it would half-understand it and could lose data on its next + save. From f928f71f9dd2f001898525559f017d3668ee739b Mon Sep 17 00:00:00 2001 From: Yilin Jing Date: Fri, 25 Sep 2026 10:21:33 -0400 Subject: [PATCH 02/15] Settle the plan's open questions but licence and takedown Co-Authored-By: Claude Opus 5.5 --- docs/2026-09-25-project-package.md | 34 ++++++++++++++++++------------ 1 file changed, 21 insertions(+), 13 deletions(-) diff --git a/docs/2026-09-25-project-package.md b/docs/2026-09-25-project-package.md index 9c0f066e..02e3c04a 100644 --- a/docs/2026-09-25-project-package.md +++ b/docs/2026-09-25-project-package.md @@ -177,7 +177,7 @@ canvases// ``` Always out: `scratch/`, `ref-*`, `assets/refs/`, the root `refs/`, dot files and dot folders, -anything else at the root, and `comments.json` (open question 2). +anything else at the root, and `comments.json`. **Checks.** Each check fails the pack; none warns and carries on: @@ -228,20 +228,28 @@ A zip (`sp pack -o x.zip`) arrives with it, when something first hands out a dow Not planned: accounts, signing, hosting projects on superproto.dev, live collaboration, and packing a single canvas. +## Decided + +- **`gen.py` ships.** It is the canvas's source of truth, and a remix without it can only be + hand-edited. It is code, but the app never runs it. Import tells the agent whose code it is, + so the agent asks before running it. +- **`comments.json` does not ship.** Review threads can hold names and private remarks, and a + package shares the work, not its review. +- **Boards may load anything from the network, fonts above all.** A board may inline its fonts + or load them from any CDN, and the network is not closed with `connect-src` or `font-src`. + - All a board could send out is what someone types into the board itself. + - The opaque origin keeps the app, its files and the agent out of reach, and that is the + boundary. + - A font file inside the package counts against the caps like anything else. +- **The community repo stays small, so it is an ordinary GitHub repo.** Its CI is a GitHub + Actions workflow in that repo, owned by this project's maintainers. It runs `sp pack --check`, + renders covers with `refkit shoot`, and fails a PR over the caps. Its size is looked at again + if the repo passes 1 GB. + ## Open questions -1. **Does `gen.py` ship?** The recommendation is yes. - - For: it is the canvas's source of truth, and a remix without it can only be hand-edited. - - Against: it is code. The app never runs it, and import tells the agent whose it is. -2. **Does `comments.json` ship?** The recommendation is no: review threads can hold names and - private remarks. -3. **Should boards get `connect-src 'none'` too**, so a board cannot send what is typed into it - anywhere? Boards inline their images, so fonts are the one thing to check. -4. **The community repo's upkeep:** - - how big it may grow before it needs another home; - - who owns its CI (Phase 3 renders covers with headless Chrome); - - what licence a submission is under, and who says so; - - how something is taken down. +1. **Licence and takedown.** What licence a submission is under, and how something is taken + down, are the product's to decide. ## What the review changed From 87fe85988816b1c0bf888630d2ecfcbfa8c3f24f Mon Sep 17 00:00:00 2001 From: Yilin Jing Date: Fri, 25 Sep 2026 10:27:14 -0400 Subject: [PATCH 03/15] Leave measurement evidence out of the package Co-Authored-By: Claude Opus 5.5 --- docs/2026-09-25-project-package.md | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/docs/2026-09-25-project-package.md b/docs/2026-09-25-project-package.md index 02e3c04a..032d3141 100644 --- a/docs/2026-09-25-project-package.md +++ b/docs/2026-09-25-project-package.md @@ -173,11 +173,21 @@ canvases// files/ only files a canvas.json record points at (see check 4) assets/** not assets/refs/** assets-dark/** - gen.py README.md probes.json crops.json assets.json + gen.py README.md assets.json ``` -Always out: `scratch/`, `ref-*`, `assets/refs/`, the root `refs/`, dot files and dot folders, -anything else at the root, and `comments.json`. +Only `project.json` is required. Everything else in the list ships if it is there. A project +can be a clone of an app, an interface someone designed, or a phone mockup, and a folder with +nothing but boards is a whole project. + +Always out: +- `scratch/`, `ref-*`, `assets/refs/` and the root `refs/`; +- dot files and dot folders; +- anything else at the root; +- `comments.json`; +- `probes.json` and `crops.json`. They are the clone skill's measurement evidence, which + supports a claim of fidelity to someone else's app. They are not part of the work, and most + projects have none. **Checks.** Each check fails the pack; none warns and carries on: From bbb601ca7a64f8755361bc2c9506cd2fff522945 Mon Sep 17 00:00:00 2001 From: Yilin Jing Date: Fri, 25 Sep 2026 10:28:30 -0400 Subject: [PATCH 04/15] Define the package by place, not type, so new content ships unchanged Co-Authored-By: Claude Opus 5.5 --- docs/2026-09-25-project-package.md | 76 ++++++++++++++++-------------- 1 file changed, 41 insertions(+), 35 deletions(-) diff --git a/docs/2026-09-25-project-package.md b/docs/2026-09-25-project-package.md index 032d3141..8593a12b 100644 --- a/docs/2026-09-25-project-package.md +++ b/docs/2026-09-25-project-package.md @@ -138,6 +138,14 @@ This stands alone and ships first. It protects local users today. - A project with a `format` above the app's is refused when opened, local or not, with a message to update the app. An older app that opened it would half-understand it, and its next save could lose what it did not understand. + - **`format` goes up rarely, only when an older app would misread the project.** Adding + something is not that: + - The app ignores files it does not know, and never deletes them. + - It ignores JSON keys it does not know, and writes them back unchanged. A write goes + through `withLayoutKey`, or reads the file and sets one key, as `project.json`'s cover + does, so this already holds. + - A new kind of content, a new key or a new file is therefore still format 1. Only a + change to what an existing thing means raises it. - **`id`: a UUID, minted when New project makes the project** (`POST /__sp/projects` in `projects.ts`). - A project made earlier gets one from its first `sp pack`. Nothing is written on a GET, the @@ -159,53 +167,52 @@ copies what passes into ``, as a folder: a PR adds a folder, so a zip has n drift is accepted until import in the app needs the same rules in TypeScript, which is the second case that would justify sharing them. -**What goes in.** Everything else is left out and listed in the report, so the author sees -what was dropped. +**What goes in.** A project's content is open-ended: boards today, and later videos, notes, +decks, or kinds of content nobody has made yet. So the package is defined by where things +sit, not by what they are: -``` -project.json required -PRD.md -canvases// - NN-*.html boards; not ref-*.html - layout.json - icon.png - canvas.json - files/ only files a canvas.json record points at (see check 4) - assets/** not assets/refs/** - assets-dark/** - gen.py README.md assets.json -``` +- **At the root**, only `project.json` (required), the Markdown documents (`*.md`), and + `canvases/`. Everything else there is left out: that is where agents leave tools, web + builds and loose drafts. +- **Inside `canvases//`, everything ships,** whatever its type, except the list below. + A new kind of content goes into a canvas folder and ships with no change to `sp pack` or to + `format`. `files/` ships whole, not only what a record points at. That keeps content that + something other than `canvas.json` refers to, now or later, and it also keeps a file one + canvas uses from another's folder. -Only `project.json` is required. Everything else in the list ships if it is there. A project -can be a clone of an app, an interface someone designed, or a phone mockup, and a folder with -nothing but boards is a whole project. +A folder with nothing but boards is a whole project, and so is one with nothing but a video. -Always out: -- `scratch/`, `ref-*`, `assets/refs/` and the root `refs/`; +Left out anywhere, and listed in the report so the author sees it: +- `scratch/`, `ref-*`, `assets/refs/` and the root `refs/`: work in progress, and captures of + other people's products; - dot files and dot folders; -- anything else at the root; - `comments.json`; -- `probes.json` and `crops.json`. They are the clone skill's measurement evidence, which - supports a claim of fidelity to someone else's app. They are not part of the work, and most +- `probes.json` and `crops.json`. These are the clone skill's measurement evidence. They + support a claim of fidelity to someone else's app, are not part of the work, and most projects have none. +This is a list of exclusions, where the surveys favoured a list of inclusions. The trade is +deliberate. An inclusion list leaks nothing, but it drops every new kind of content until +someone edits it. The exclusions here are few and known, and the checks below are what keep a +package safe, not the file types. + **Checks.** Each check fails the pack; none warns and carries on: 1. `project.json` parses and is an object, with a known `format` and a UUID `id`. -2. Every JSON file in the list parses. +2. Every JSON file this app defines (`project.json`, `layout.json`, `canvas.json`) parses. + Other files are content, and are not read. 3. No symlinks. No path escapes the project after resolution. -4. **Every reference resolves to a file in the package.** That means `canvas.json` asset `src`s, - `layout.json` file entries, and the `project.json` cover. - - References are collected across every canvas before anything is copied. - - A file is shipped where it lives: `sandwich-video/files/logo-servicenow.png` ships under - `sandwich-video/` because `kasra-design` points at it, even if nothing in `sandwich-video` - does. +4. **Every reference the app makes resolves to a file in the package:** `canvas.json` asset + `src`s, `layout.json` file entries, and the `project.json` cover. A new kind of reference + gets its check when the app starts making it. 5. `links[].url` in `layout.json` is `http:` or `https:`. 6. Names contain no `#` or `?`, do not start with a dot, and are NFC. macOS stores them decomposed, and other systems do not re-normalise. -7. Size: each file at most 50 MB, and the whole at most 200 MB. That sits between Blender - (100–200 MB) and CodePen (15 MB media). Video is what hits it, and a project whose value is - gigabytes of video shares a link, not a package. +7. **Size: each file at most 50 MB, the whole at most 200 MB.** + - GitHub refuses files over 100 MB and warns from 50 MB, so 50 MB per file is the most a + PR can carry. + - That is a few minutes of 1080p video. A longer video goes on the canvas as a link to + where it is hosted, which the canvas already supports. Minting a missing `id` is the only write `sp pack` makes to the project. @@ -265,8 +272,7 @@ packing a single canvas. - **Cross-canvas files.** A file one canvas points at inside another's `files/` would have been dropped, or would have failed the pack. The real case is `kasra-design` pointing at - `sandwich-video`. References are now collected project-wide, and each file ships where it - lives. + `sandwich-video`. `files/` now ships whole, which also settles this case. - **Phase 0 now covers the hosted build.** It serves boards as static files and never runs the server route, so it gets a `_headers` rule. It also covers SVG and `layout.json` links, which run in app code and not in an iframe. From 4e6202b6bdced58469992c8f7d2ebdc894aea7f4 Mon Sep 17 00:00:00 2001 From: Yilin Jing Date: Fri, 25 Sep 2026 10:36:24 -0400 Subject: [PATCH 05/15] Give the package a thumbnail, as .fig and .sketch have Co-Authored-By: Claude Opus 5.5 --- docs/2026-09-25-project-package.md | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/docs/2026-09-25-project-package.md b/docs/2026-09-25-project-package.md index 8593a12b..5ba9aba4 100644 --- a/docs/2026-09-25-project-package.md +++ b/docs/2026-09-25-project-package.md @@ -161,6 +161,12 @@ This stands alone and ships first. It protects local users today. `sp pack --check` validates and writes nothing. `sp pack -o ` also copies what passes into ``, as a folder: a PR adds a folder, so a zip has no reader yet. +- **`-o` also writes `thumbnail.png` at the package's root.** It is the project's cover, drawn + with `refkit shoot` at 1600×1000. + - It is Figma's `thumbnail.png` and Sketch's `previews/preview.png`: a package can be + browsed without the app, and the community page reads its card straight from it. + - It exists only in the package. In the project the cover stays a path, as + `2026-09-23-project-covers.md` decided, because a stored picture goes stale. - It lives in `tools/sp_canvas.py` beside the other subcommands, because CI and the agent both already run `sp`. - The rules it applies partly repeat `boardIndex` and `cover.ts`, in a second language. That @@ -260,7 +266,8 @@ packing a single canvas. - A font file inside the package counts against the caps like anything else. - **The community repo stays small, so it is an ordinary GitHub repo.** Its CI is a GitHub Actions workflow in that repo, owned by this project's maintainers. It runs `sp pack --check`, - renders covers with `refkit shoot`, and fails a PR over the caps. Its size is looked at again + checks that `thumbnail.png` is the cover `sp pack` would draw now, and fails a PR over the + caps. Its size is looked at again if the repo passes 1 GB. ## Open questions From 925670223e1b9a33d7ce4674e739c7972ce1f0e5 Mon Sep 17 00:00:00 2001 From: Yilin Jing Date: Fri, 25 Sep 2026 11:45:31 -0400 Subject: [PATCH 06/15] Sandbox boards, refuse rebound hosts, give a new project an id A board opened at its own address now runs in an origin of its own, on the app's server and on Pages, so a project from someone else cannot drive the canvas's write endpoints. The server answers only to localhost or an IP address, which closes DNS rebinding. A link shape opens only a web address. A new project gets project.json with format 1 and a UUID, and one a newer app made is refused rather than misread. Co-Authored-By: Claude Opus 5.5 --- canvas/public/_headers | 3 ++ canvas/server/projects.test.ts | 23 +++++++++++++-- canvas/server/projects.ts | 47 +++++++++++++++++++++++++++++- canvas/server/sp.test.ts | 23 +++++++++++++-- canvas/server/sp.ts | 15 +++++++--- canvas/src/BoardsSheet.tsx | 38 ++++++++++++++---------- canvas/src/CanvasLinkShapeUtil.tsx | 8 +++-- 7 files changed, 129 insertions(+), 28 deletions(-) create mode 100644 canvas/public/_headers diff --git a/canvas/public/_headers b/canvas/public/_headers new file mode 100644 index 00000000..055f90ee --- /dev/null +++ b/canvas/public/_headers @@ -0,0 +1,3 @@ +# Cloudflare Pages: a board runs in an origin of its own, as server/sp.ts serves it (SANDBOX). +/board/* + Content-Security-Policy: sandbox allow-scripts allow-forms allow-popups allow-modals allow-downloads diff --git a/canvas/server/projects.test.ts b/canvas/server/projects.test.ts index e4534d28..953cc54f 100644 --- a/canvas/server/projects.test.ts +++ b/canvas/server/projects.test.ts @@ -65,6 +65,11 @@ it("serves every project at its own address and makes new ones", async () => { req.end(body && JSON.stringify(body)); }); try { + // Asked for by a name DNS could point here, a page from anywhere would be same-origin. + const at = (host: string) => ask("/", undefined, { host }); + expect((await at("rebound.example")).status).toBe(403); + expect((await at(`[::1]:${port}`)).status).toBe(302); + expect((await at("app.localhost")).status).toBe(302); // Nothing opened yet: the root is home, and the examples' window. expect(await ask("/")).toMatchObject({ status: 302, @@ -166,15 +171,29 @@ it("serves every project at its own address and makes new ones", async () => { expect(JSON.parse(made.text)).toEqual({ name: "beta", url: "/p/beta/" }); expect(fs.existsSync(path.join(tmp, "projects/beta/canvases"))).toBe(true); expect(fs.existsSync(path.join(tmp, "projects/beta/.claude"))).toBe(false); + const beta = JSON.parse( + fs.readFileSync(path.join(tmp, "projects/beta/project.json"), "utf8"), + ); + expect(beta.format).toBe(1); + expect(beta.id).toMatch(/^[0-9a-f-]{36}$/); + // One a newer app made is not opened, since this one could misread it and write it back. + write("projects/delta/canvases/one/01-a.html", "delta"); + write("projects/delta/project.json", JSON.stringify({ format: 2 })); + expect((await ask("/p/delta/")).status).toBe(409); + fs.rmSync(path.join(tmp, "projects/delta"), { recursive: true }); expect((await ask("/p/beta/__sp/index.json")).status).toBe(200); expect((await ask("/__sp/projects", { name: "beta" })).status).toBe(409); // No name: the first free "Untitled", which its agent names in project.json. for (const url of ["/p/Untitled/", "/p/Untitled%202/"]) - expect(JSON.parse((await ask("/__sp/projects", { name: " " })).text).url).toBe(url); + expect( + JSON.parse((await ask("/__sp/projects", { name: " " })).text).url, + ).toBe(url); write("projects/Untitled/project.json", JSON.stringify({ name: "Gamma" })); const titled = JSON.parse((await ask("/__sp/projects.json")).text); expect(titled.find((p: any) => p.name === "Untitled").title).toBe("Gamma"); - expect(JSON.parse((await ask("/p/Untitled/__sp/index.json")).text).title).toBe("Gamma"); + expect( + JSON.parse((await ask("/p/Untitled/__sp/index.json")).text).title, + ).toBe("Gamma"); expect((await ask("/__sp/projects", { name: "a/b" })).status).toBe(400); // A reference for a clone: into the project's `refs`, once, and only under a plain name. diff --git a/canvas/server/projects.ts b/canvas/server/projects.ts index 5a3cf60b..28c051e2 100644 --- a/canvas/server/projects.ts +++ b/canvas/server/projects.ts @@ -11,11 +11,12 @@ */ import fs from "node:fs"; import type { IncomingMessage, ServerResponse } from "node:http"; +import net from "node:net"; import os from "node:os"; import path from "node:path"; import { pipeline } from "node:stream/promises"; import { createAgentServer } from "./agent.ts"; -import { CANVASES } from "./boards.ts"; +import { CANVASES, readJson } from "./boards.ts"; import { createSpServer, reveal, sameOrigin, trash } from "./sp.ts"; /** @@ -49,6 +50,29 @@ function moveOldBoards(dir: string) { fs.rmSync(path.dirname(old), { recursive: true }); } +/** + * The newest `project.json` format this app reads. It goes up only when an app that reads this one + * would misread a project of the next; a file or key it does not know is ignored and kept, so + * adding one needs no new format. A project with none is format 1. + */ +const PROJECT_FORMAT = 1; + +/** Whether a Host header names this machine by an address or by localhost, which no DNS can move. */ +export function loopbackHost(host: string | undefined) { + if (!host) return false; + let hostname: string; + try { + hostname = new URL(`http://${host}`).hostname; + } catch { + return false; + } + return ( + net.isIP(hostname.replace(/^\[(.*)\]$/, "$1")) !== 0 || + hostname === "localhost" || + hostname.endsWith(".localhost") + ); +} + export function createProjectsServer(options: { /** Where every project is listed from, and where `POST /__sp/projects` makes one. */ projectsDir: string; @@ -111,6 +135,14 @@ export function createProjectsServer(options: { res: ServerResponse, next: () => void, ) => { + // Asked for by an address, not a name that could be anyone's. A site can point its own name at + // 127.0.0.1 once its page is open (DNS rebinding), and then it is same-origin with this server + // and every guard here waves it through. The browser still sends the name it asked for as + // Host, so a name other than localhost is refused. + if (!loopbackHost(req.headers.host)) { + res.statusCode = 403; + return res.end("This server answers only to localhost or an IP address."); + } const url = req.url ?? "/"; const [pathname, query = ""] = url.split(/\?(.*)/s); // The bare root is the home page, and the root with a query is the window on an example @@ -229,6 +261,11 @@ export function createProjectsServer(options: { // here and the examples, is the tree's and shown beside the project's own, so there is // nothing to copy in. fs.mkdirSync(path.join(dir, CANVASES), { recursive: true }); + // Its id is what a package of it is known by, whatever the folder is renamed to. + fs.writeFileSync( + path.join(dir, "project.json"), + `${JSON.stringify({ format: PROJECT_FORMAT, id: crypto.randomUUID() }, null, 2)}\n`, + ); } catch (e) { // A new name does not fix an unwritable Documents, so say what failed. return send( @@ -255,6 +292,14 @@ export function createProjectsServer(options: { res.statusCode = 404; return res.end("no such project"); } + // Made by a newer app, which may keep it in a way this one would misread, and then write back. + const format = (readJson(path.join(dir, "project.json")) as { format?: unknown })?.format; + if (typeof format === "number" && format > PROJECT_FORMAT) { + res.statusCode = 409; + return res.end( + `“${decodeURIComponent(name)}” was made by a newer Super Prototyping. Update the app to open it.`, + ); + } // The url stays stripped for `next`, which is the static app or Vite serving the page. req.url = rest; spFor(dir).handle(req, res, next); diff --git a/canvas/server/sp.test.ts b/canvas/server/sp.test.ts index 2f1b92bc..783ab5b6 100644 --- a/canvas/server/sp.test.ts +++ b/canvas/server/sp.test.ts @@ -46,6 +46,9 @@ it("shows the examples read-only beside the project's canvases", async () => { ]); expect((await ask("/board/an-example/01-a.html")).text).toBe("example"); expect((await ask("/board/shadowed/01-a.html")).text).toBe("mine"); + // Opened at its own address, a board runs in an origin of its own, not the canvas's. + const board = await ask("/board/shadowed/01-a.html"); + expect(board.csp).toMatch(/^sandbox allow-scripts /); const ground = { ground: "#000000" }; expect( @@ -342,6 +345,7 @@ it("serves the project's own files by their absolute path", async () => { ).toEqual({ status: 200, text: "glow", + csp: expect.stringMatching(/^sandbox allow-scripts /), }); expect((await ask(at(path.join(tmp, "secret.html")))).status).toBe(404); // A junction is the link Windows makes without admin rights, and a symlink elsewhere. @@ -406,7 +410,10 @@ it("hands a canvas command to the open canvas page and its answer back", async ( sheet.close(); // A shell cancelled while its command waited: the command is not run for nobody. - const cancelled = ask("/__sp/canvas", { slug: "home", command: { op: "delete" } }); + const cancelled = ask("/__sp/canvas", { + slug: "home", + command: { op: "delete" }, + }); await waiting(); cancelled.drop(); // Sent before the page opens, which is a reload: it goes to the page once it does. @@ -459,13 +466,23 @@ async function serve(options: Parameters[0]) { // `drop` is the caller going away before the answer, as a cancelled shell does. const ask = (url: string, body?: object) => { let req!: http.ClientRequest; - const answered = new Promise<{ status: number; text: string }>((done) => { + const answered = new Promise<{ + status: number; + text: string; + csp?: string; + }>((done) => { req = http.request( { port, path: url, method: body ? "POST" : "GET" }, (res) => { let text = ""; res.on("data", (chunk) => (text += chunk)); - res.on("end", () => done({ status: res.statusCode!, text })); + res.on("end", () => + done({ + status: res.statusCode!, + text, + csp: res.headers["content-security-policy"], + }), + ); }, ); req.on("error", () => {}); diff --git a/canvas/server/sp.ts b/canvas/server/sp.ts index d8bab39e..9f59bc3e 100644 --- a/canvas/server/sp.ts +++ b/canvas/server/sp.ts @@ -50,6 +50,15 @@ export function sameOrigin(req: IncomingMessage) { return site === undefined || site === "same-origin" || site === "none"; } +/** + * What a board, or any page a project holds, runs under when it is opened at its own address: an + * origin of its own, so its script cannot reach the canvas's storage or its write endpoints, whose + * guard sees it as cross-site. A board is a project's, and a project can be someone else's. + * `public/_headers` gives the hosted build's `/board/*` the same. + */ +const SANDBOX = + "sandbox allow-scripts allow-forms allow-popups allow-modals allow-downloads"; + /** * A canvas's folder: the project's own, else the example of that name. The project's own is what * the scan in boards.ts calls a canvas, a folder with a board or a layout.json in it, so a folder @@ -390,6 +399,7 @@ export function createSpServer(options: { return send(404, "not a board"); } res.setHeader("Content-Type", type); + res.setHeader("Content-Security-Policy", SANDBOX); res.setHeader("Cache-Control", "no-store"); res.setHeader("Accept-Ranges", "bytes"); // A byte range, which is how a video seeks: a pasted one can be a gigabyte, and without it @@ -467,10 +477,7 @@ export function createSpServer(options: { return res.end("not a file of this project"); } res.setHeader("Content-Type", type); - res.setHeader( - "Content-Security-Policy", - "sandbox allow-scripts allow-forms allow-popups allow-modals allow-downloads", - ); + res.setHeader("Content-Security-Policy", SANDBOX); res.setHeader("Cache-Control", "no-store"); fs.createReadStream(file).pipe(res); }); diff --git a/canvas/src/BoardsSheet.tsx b/canvas/src/BoardsSheet.tsx index c790330b..39dcb7e0 100644 --- a/canvas/src/BoardsSheet.tsx +++ b/canvas/src/BoardsSheet.tsx @@ -6,7 +6,8 @@ import { brandPageUrl, canvasPageUrl } from "./canvasUrl"; /** The extension that reads a page, and the Figma plugin that the extension can hand off to. */ const H2D_EXTENSION = "https://chromewebstore.google.com/detail/htmltodesign/ldnheaepmnmbjjjahokphckbpgciiaed"; -const H2D_PLUGIN = "https://www.figma.com/community/plugin/1159123024924461424/html-to-design"; +const H2D_PLUGIN = + "https://www.figma.com/community/plugin/1159123024924461424/html-to-design"; /** * One canvas page's boards, each at its own size, in one scrolling document. The page behind @@ -14,8 +15,8 @@ const H2D_PLUGIN = "https://www.figma.com/community/plugin/1159123024924461424/h * * Each board is an iframe pointed at that board's own address rather than inlined, because a * board is a whole document — its own doctype, its own reset, its own fonts — and forty of them - * flattened into one would be forty stylesheets fighting. The frames are same-origin, so a board - * behaves here exactly as it does in a tab of its own. + * flattened into one would be forty stylesheets fighting. The server sandboxes each in an origin + * of its own (sp.ts, SANDBOX), so a board behaves here exactly as it does in a tab of its own. */ export function BoardsSheet({ slug }: { slug: string }) { const rows = sheetRows(slug); @@ -47,10 +48,11 @@ export function BoardsSheet({ slug }: { slug: string }) {
  1. - Install the browser extension. The extension rather than the Figma plugin - alone, because the plugin fetches a public address from Figma's servers and a - canvas on localhost is not one — the extension reads the page from inside the - browser that already has it open. + Install the browser extension. The extension rather than + the Figma plugin alone, because the plugin fetches a public + address from Figma's servers and a canvas on localhost is not one + — the extension reads the page from inside the browser that + already has it open. {/* The one thing on this page that has to be done before anything else works, so it is a button and not the third link in a paragraph. */} Add html.to.design to your browser - Free · Chrome, Edge, Brave, Arc and other Chromium browsers + + Free · Chrome, Edge, Brave, Arc and other Chromium browsers + ↗ @@ -83,16 +87,18 @@ export function BoardsSheet({ slug }: { slug: string }) {
  2. - Capture this page. Click the extension's icon while this tab is in front, - leave the viewport on Browser, and press Capture Current Page. It - reads every board below at the size it ships at, rather than the zoomed-out - thumbnail the canvas shows. + Capture this page. Click the extension's icon while this + tab is in front, leave the viewport on Browser, and press{" "} + Capture Current Page. It reads every board below at the + size it ships at, rather than the zoomed-out thumbnail the canvas + shows.
  3. - Paste it into Figma. Pick Copy to clipboard and press ⌘V in a Figma - file; that route needs no plugin at all. The plugin is for the other two routes — - sending the capture straight over, or opening a saved .h2d file. - Either way the boards arrive as editable layers, not as images. + Paste it into Figma. Pick Copy to clipboard and + press ⌘V in a Figma file; that route needs no plugin at all. The + plugin is for the other two routes — sending the capture straight + over, or opening a saved .h2d file. Either way the + boards arrive as editable layers, not as images. shape.type === CANVAS_LINK_SHAPE_TYPE && shape.isLocked, + filter: (shape) => + shape.type === CANVAS_LINK_SHAPE_TYPE && shape.isLocked, }) as CanvasLinkShape | undefined; let pressed: CanvasLinkShape | undefined; @@ -270,7 +271,10 @@ export class CanvasLinkShapeUtil extends BaseBoxShapeUtil { override onClick(shape: CanvasLinkShape) { if (shape.props.url) { - window.open(shape.props.url, "_blank", "noopener,noreferrer"); + // A web address only: a layout.json can come from someone else's project, and a + // `javascript:` one would run in the canvas. + if (/^https?:/i.test(shape.props.url)) + window.open(shape.props.url, "_blank", "noopener,noreferrer"); return; } const page = this.editor From dc57b67817483c7e56445cf96dde95a830a2f21f Mon Sep 17 00:00:00 2001 From: Yilin Jing Date: Fri, 25 Sep 2026 11:50:53 -0400 Subject: [PATCH 07/15] Add sp pack: check a project as a package and copy it out The package is the project less what is left out by place: scratch, captures, dotfiles, comments and measurement evidence. Every reference the app makes has to resolve inside it, no symlinks, web links only, 50 MB a file and 200 MB in all. -o also draws the cover as thumbnail.png, and mints the project's id if it has none, the only write to the project. layout.md documents the folder and format 1, and the sp-canvas skill tells agents to keep the root clear. Co-Authored-By: Claude Opus 5.5 --- skills/sp-canvas/SKILL.md | 7 + skills/sp-canvas/references/layout.md | 73 +++++-- tools/sp_canvas.py | 267 +++++++++++++++++++++++++- tools/test_sp_canvas.py | 71 +++++++ 4 files changed, 401 insertions(+), 17 deletions(-) diff --git a/skills/sp-canvas/SKILL.md b/skills/sp-canvas/SKILL.md index bd7c4e96..6da9d867 100644 --- a/skills/sp-canvas/SKILL.md +++ b/skills/sp-canvas/SKILL.md @@ -117,6 +117,13 @@ one shape. Switch with the page menu at the top-left; do not build a separate switcher. `references/layout.md` has the `layout.json` schema, the caption rules, and the 478 × 980 / sandbox constraints every artboard lives under. +**Write nothing at the project's root** but its documents (`*.md`) and +`project.json`: work goes in a canvas folder, and what a run makes in that +folder's `scratch/`. A project is what gets shared, and `sp pack` packages +the root's `canvases/` and documents and nothing else there. Keep +`project.json`'s `id` and `format` as they are. The layout is +`references/layout.md`, under "The project folder". + **After editing `layout.json`, right-click the canvas and choose Force refresh.** Shape creation is idempotent. It fills in what is missing but never moves a shape that already exists, so inserting or reordering a row entry diff --git a/skills/sp-canvas/references/layout.md b/skills/sp-canvas/references/layout.md index 5b2a5c4d..7fe765f4 100644 --- a/skills/sp-canvas/references/layout.md +++ b/skills/sp-canvas/references/layout.md @@ -89,22 +89,6 @@ out top to bottom: it the canvas is dark grey (`#2b2b2b`). The canvas's right-click menu and the swatch at the end of its strip write it, so there is no reason to edit it by hand. On a light ground the row titles and captions turn dark. - -A project's own cover, when someone chose one on the canvas, is in -`project.json` at the project's root, beside `canvases/`, and nowhere else: - -```json -{ "cover": { "path": "/.html", "box": [x, y, w, h] } } -``` - -`path` is a board, or an image in a folder's image rows -(`/assets/brand/`). `box` is the part to keep in view, in the -file's px, and is left out for the whole board. Without the file, or with a -path that has since gone, the cover is the first canvas's `cover` board, -whole, the first canvas being first by `order` then slug. A card fills its -frame with it from the top; an element chosen as cover is centred instead. The canvas's -right-click menu writes the file and the home card's Reset cover deletes it, -so there is no reason to edit it by hand. - `files` entries are file names **without** `.html`, either bare (the humanized file name becomes the caption) or `{ "file", "label" }`. - `numbered: true` prefixes each caption with its 1-based position. Never @@ -133,6 +117,63 @@ After editing `layout.json`, right-click the canvas and choose **Force refresh**. Shape creation is idempotent (it never moves a shape that already exists), so reordering a row needs that refresh to take effect. +## The project folder and project.json + +A project is the unit that is shared: `sp pack` makes a package of one +whole project, never a single canvas. Its root holds only these: + +``` +/ + project.json what the project is + *.md its documents, PRD.md first + canvases// one folder per canvas, anything inside + refs/ what a clone started from, never packaged +``` + +Nothing else goes at the root. Put what a run makes in `/scratch/` and +third-party captures in `/assets/refs/` or `refs/`. None of the three +goes into a package, +and nor do dotfiles, `ref-*` boards, `comments.json`, `probes.json` or +`crops.json`. Everything else under `canvases/` does, video and notes included, +so a new kind of content needs no change to the package. + +`project.json`: + +```json +{ + "format": 1, + "id": "3f0c8a0e-7a51-4d0b-9a57-2f7f1a1d5c9e", + "name": "Kasra", + "cover": { "path": "/.html", "box": [x, y, w, h] } +} +``` + +- `format` is the layout of the whole folder. Without one it is 1. It goes up + only when an app that reads 1 would misread the folder, and an app refuses + to open a format newer than it knows. A new file or key needs no new format: + one the app does not know is ignored and kept. +- `id` is a UUID, made with the project, or by the first `sp pack` of an + older one. It stays when the folder is renamed, and is what the community + knows the project by. Never change it or copy it into another project. +- `name` is the title shown for the project, which the agent sets; the + folder name when there is none. + +A project's own cover, when someone chose one on the canvas, is `cover` +there and nowhere else: + +```json +{ "cover": { "path": "/.html", "box": [x, y, w, h] } } +``` + +`path` is a board, or an image in a folder's image rows +(`/assets/brand/`). `box` is the part to keep in view, in the +file's px, and is left out for the whole board. Without the file, or with a +path that has since gone, the cover is the first canvas's `cover` board, +whole, the first canvas being first by `order` then slug. A card fills its +frame with it from the top; an element chosen as cover is centred instead. The canvas's +right-click menu writes the file and the home card's Reset cover deletes it, +so there is no reason to edit it by hand. + ## Constraints on every artboard Boards render inside `