Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,8 @@ Rules inside a canvas folder:

- `gen.py` is the only source of truth. The `NN-*.html` boards are its
output. Edit the generator and re-run, never the HTML.
- Commit `layout.json`, `icon.png` and `assets/`. `gen.py` inlines the
- Commit `layout.json`, `icon.png`, `thumbnail.png` (drawn by `sp thumbnail`)
and `assets/`. `gen.py` inlines the
images in `assets/` as `data:` URIs.
- Commit `PRD.md`, the product the folder prototypes, to the `sp-define-product`
skill's template. Its Screens table lists the folder's boards.
Expand Down
18 changes: 18 additions & 0 deletions canvas/community.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" href="/favicon.ico" sizes="16x16 32x32 48x48" />
<link rel="icon" type="image/png" href="/favicon-32.png" sizes="32x32" />
<title>Community — Super Prototyping</title>
<meta
name="description"
content="Community-made app clones, device mockups and editable boards, made with Super Prototyping."
/>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/communitySite.tsx"></script>
</body>
</html>
3 changes: 3 additions & 0 deletions canvas/public/_headers
Original file line number Diff line number Diff line change
@@ -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
1 change: 1 addition & 0 deletions canvas/server/boards.ts
Original file line number Diff line number Diff line change
Expand Up @@ -296,6 +296,7 @@ export function boardIndex(
),
layout: readJson(path.join(folder, "layout.json")),
icon: fs.existsSync(path.join(folder, "icon.png")),
thumbnail: fs.existsSync(path.join(folder, "thumbnail.png")),
brand: brandImages(folder),
thumbs: [] as string[],
assets: assetIndex(folder),
Expand Down
23 changes: 21 additions & 2 deletions canvas/server/projects.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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.
Expand Down
47 changes: 46 additions & 1 deletion canvas/server/projects.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

/**
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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`,
);
Comment on lines +265 to +268

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve the generated ID when naming an untitled project

When the user leaves the project name blank, AppShell.tsx immediately instructs the agent to write {"name": "…"} as project.json. This newly created file already contains format and id, so following that instruction replaces the generated identity; sp pack --check then rejects the project for lacking an ID, while sp pack -o silently assigns a different one. Update the naming instruction or flow to merge name into the existing object.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 038d8ca: the prompt now tells the agent to add name to project.json, keeping the keys already there.

} catch (e) {
// A new name does not fix an unwritable Documents, so say what failed.
return send(
Expand All @@ -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);
Expand Down
23 changes: 20 additions & 3 deletions canvas/server/sp.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -459,13 +466,23 @@ async function serve(options: Parameters<typeof createSpServer>[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 | 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", () => {});
Expand Down
19 changes: 13 additions & 6 deletions canvas/server/sp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -349,7 +358,7 @@ export function createSpServer(options: {
// A board folder's files, at the addresses the index hands the page: `<slug>/<file>.html`
// is a board as a web page, which the canvas's two "open as a web page" buttons point at;
// `<slug>/icon.png` and `<slug>/assets/brand/**` are the images a page places as shapes
// of their own. The build emits the same paths as files; here they are read off the
// of their own, and `<slug>/thumbnail.png` the one the community page shows for it. The build emits the same paths as files; here they are read off the
// boards directory per request, so a reload shows the current version.
route("/board", (req, res) => {
const send = (code: number, message: string) => {
Expand All @@ -370,7 +379,7 @@ export function createSpServer(options: {
const type =
parts.length === 2 && parts[1].endsWith(".html")
? "text/html; charset=utf-8"
: parts.length === 2 && parts[1] === "icon.png"
: parts.length === 2 && ["icon.png", "thumbnail.png"].includes(parts[1])
? "image/png"
: parts.length >= 4 && parts[1] === "assets" && parts[2] === "brand"
? IMAGE_MIME[path.extname(parts[parts.length - 1]).toLowerCase()]
Expand All @@ -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
Expand Down Expand Up @@ -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);
});
Expand Down
Loading
Loading