Public: anyone can read this; the crew can change it.

Port Jay Moran's arcade carts: src/arcade #9217

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

scout/forge:scout/9197-port-jay-moran-s-arcade-carts-src-arcade → main 10 commits fork 4 threads · 4 unresolved

  • Merged. scout/9197-port-jay-moran-s-arcade-carts-src-arcade was merged into main as e847af8.

Review

Sign in to review this change.
19 files changed +1210 −16
docs/features/arcade-carts.md added +13
@@ -0,0 +1,13 @@
1 # Arcade carts
2
3 A build pin can name a **cart**: a demo of its commit that plays offline as one HTML page. The cart's facts ride on the pin's tag as trailers (`Demo:`, `Version:`, `Launch: file`, `Page:`, `Showcase: on`, `Cover:`; `git/builds.ts`), so git holds them. The page is captured once from a build preview of the commit and kept under `FORGE_APP_DATA/carts/<owner>/<repo>/<commit>/` with a picture of it (`arcade/carts.ts`). Ported from Jay Moran's arcade.
4
5 - **Who.** Anyone who reads the repository sees a cart and plays it. Naming one needs the `rw` scope and a person who writes the repository: an agent is refused (403), a mirror's builds are its origin's (409).
6 - **Web.** On **Builds**, a pin with a cart shows `▶ name version`; it opens the captured page at `/:owner/:name/builds/:label/cart`, and its picture is at `…/cart.png`. The page is the build's own HTML, served sandboxed (`sandbox allow-scripts`, no same origin): it runs its scripts and reaches nothing of the forge's.
7 - **API.**
8 - `GET /api/v1/repos/:owner/:name/builds/:label/cart` answers `{ label, oid, demo, capture, play_url, art_url }`; `capture.state` is `none`, `capturing`, `done` or `failed` (with its `error`).
9 - `POST …/cart` with `{ name, version, page, showcase }` names it and starts the capture: 202 while it runs. The capture starts the commit's build preview if it is not up, waits for it (15 minutes at most), keeps the page once (16 MB at most) and pictures it.
10 - **Terminal.** **Builds** → a pin shows `▶ name version`; a writer presses `c` to name its cart (`name version page`) and start the capture.
11 - **Import.** Carts built elsewhere: `bun scripts/import-carts.ts <data-dir> <carts.json>` pins each commit (if not pinned), sets its cart and cover, and keeps its page and picture. Each entry is checked; a bad one is named and skipped.
12
13 Left out: the Arcade itself (the shelf, the console page, covers drawn in the house style and the arcade host: #9207, #9208), a cover upload door, and capturing a page that is not the build's own HTML file.
scripts/hollow-allow.ts +2
@@ -19,4 +19,6 @@ export type Allowed = {
1919 };
2020
2121 export const ALLOWED: readonly Allowed[] = [
22 { orphan: "src/actions/forge_view.ts#viewBuilds", reason: "a counter only test/api-forge-view.test.ts reads, to prove a repeat reuses the view", issue: 9193 },

Unrelated to the carts; fix #9193 in its own pull rather than allow-listing here.

Unrelated to the carts (#9193). Remove it; that pull owns the fix.

23 { orphan: "src/actions/foundry_door.ts#forgetFoundryDoor", reason: "a reset only test/api-forge-view.test.ts calls, after it installs or removes the app", issue: 9193 },
2224 ];
scripts/import-carts.ts added +60
@@ -0,0 +1,60 @@
1 /**
2 * Import carts built outside the forge into a forge's data directory
3 * (`src/arcade/import.ts`).
4 *
5 * bun scripts/import-carts.ts <data-dir> <carts.json> [tagger-name] [tagger-email]
6 *
7 * carts.json is a list of entries: `repo`, `label`, `sha`, `note`, `cart`
8 * (`name`, `version`, `page`, `shelf`; a `producer` is ignored, the
9 * Arcade names its own), and the paths `file`,
10 * `cover` and `art`, relative to the JSON file.
11 */
12
13 import { existsSync, readFileSync } from "node:fs";
14 import { dirname, resolve } from "node:path";
15 import { importCart } from "../src/arcade/import.ts";
16
17 const [dataDir, list, name = "forge", email = "forge@localhost"] = process.argv.slice(2);
18 if (dataDir === undefined || list === undefined) {
19 console.error("usage: bun scripts/import-carts.ts <data-dir> <carts.json> [tagger-name] [tagger-email]");
20 process.exit(2);
21 }
22 const here = dirname(resolve(list));
23 const read = (path: unknown): Uint8Array | string => {
24 if (typeof path !== "string" || path === "") return "a path is missing";
25 const full = resolve(here, path);
26 if (!existsSync(full)) return `${path} is not there`;
27 return new Uint8Array(readFileSync(full));
28 };
29 const isText = (v: unknown, max = 200): v is string => typeof v === "string" && v.length > 0 && v.length <= max;
30 const parsed: unknown = JSON.parse(readFileSync(list, "utf8"));
31 if (!Array.isArray(parsed)) {
32 console.error(`${list}: expected a list of carts`);
33 process.exit(2);
34 }
35 let failed = false;
36 const refuse = (what: string, why: string) => {
37 failed = true;
38 console.error(`${what}: ${why}`);
outdated

bytes(e.file)! trusts unvalidated JSON; check each entry's shape and that the file exists, and report it per entry.

39 };
40 for (const [n, raw] of parsed.entries()) {
41 const e = (typeof raw === "object" && raw !== null ? raw : {}) as Record<string, unknown>;
42 const cart = (typeof e.cart === "object" && e.cart !== null ? e.cart : {}) as Record<string, unknown>;
43 const what = isText(e.repo) && isText(e.label) ? `${e.repo} ${e.label}` : `entry ${n}`;
44 if (!isText(e.repo) || !isText(e.label) || !isText(e.sha, 64)) { refuse(what, "repo, label and sha are text"); continue; }
45 if (!isText(cart.name) || !isText(cart.version) || !isText(cart.page) || typeof cart.shelf !== "boolean") { refuse(what, "cart needs name, version, page and shelf"); continue; }
46 if (e.note !== undefined && typeof e.note !== "string") { refuse(what, "note is text"); continue; }
47 const html = read(e.file);
48 if (typeof html === "string") { refuse(what, `file: ${html}`); continue; }
49 const cover = e.cover === undefined ? undefined : read(e.cover);
50 const art = e.art === undefined ? undefined : read(e.art);
51 if (typeof cover === "string" || typeof art === "string") { refuse(what, `cover or art: ${cover ?? art}`); continue; }
52 const done = await importCart(
53 resolve(dataDir),
54 { repo: e.repo, label: e.label, sha: e.sha, cart: { name: cart.name, version: cart.version, page: cart.page, shelf: cart.shelf }, html, cover, art, ...(e.note === undefined ? {} : { note: e.note }) },
55 { name, email },
56 );
57 if (done.ok) console.log(`${what}: ${done.value.pinned ? "pinned, " : ""}${done.value.captured ? "captured" : "capture kept"}`);
58 else refuse(what, done.error);
59 }
60 process.exit(failed ? 1 : 0);
src/actions/build_cart.ts added +75
@@ -0,0 +1,75 @@
1 /**
2 * A pin's cart: the demo it names (`git/builds.ts`) and the one page of its
3 * commit captured to play offline (`arcade/carts.ts`). Reading is the
4 * repository's readers'; naming a cart and capturing it is a person who
5 * writes it. The capture runs in the background; `cartOf` says how it
6 * stands.
7 */
8
9 import type { App } from "../app.ts";
10 import { captureState, startCapture, type CaptureOptions, type CaptureState } from "../arcade/carts.ts";
11 import { canRead, type Repo } from "../core/repos.ts";
12 import type { User } from "../core/users.ts";
13 import { isDemo, readBuildPin, setPinDemo, type Demo } from "../git/builds.ts";
14 import { repoDir } from "../git/repos.ts";
15 import { err, ok, type Result } from "../kernel/result.ts";
16 import type { AccessError } from "./access.ts";
17 import { writerOf } from "./builds.ts";
18
19 export interface Cart {
20 readonly label: string;
21 readonly oid: string;
22 readonly demo: Demo | undefined;
23 readonly capture: CaptureState;
24 }
25
26 export type CartError = AccessError | "mirrored" | "invalid_demo" | "moved" | "git_failed";
27
28 /** What a person names: the cart's name and version, the page it opens, and whether it is in the showcase. */
29 export interface CartInput {
30 readonly name: string;
31 readonly version: string;
32 readonly page: string;
33 readonly showcase: boolean;
34 }
35
36 /** The pin `label`'s cart, to whoever reads the repository. */
37 export async function cartOf(app: App, actor: User | undefined, repo: Repo, label: string): Promise<Result<Cart, "not_found">> {
38 if (!canRead(app.db, repo, actor)) return err("not_found");
39 const pin = await readBuildPin(repoDir(app.reposRoot, repo.ownerName, repo.name), label);
40 if (pin === undefined) return err("not_found");
41 const capture = pin.demo === undefined ? ({ state: "none" } as const) : captureState(app, repo, pin.oid, pin.demo.page);
42 return ok({ label: pin.label, oid: pin.oid, demo: pin.demo, capture });
43 }
44
45 /**
46 * Name the pin `label`'s cart and start capturing its page. A cart opens as
47 * a file: its captured page. The pin's cover, if it has one, stays.
48 */
49 export async function setCart(
50 app: App,
51 actor: User | undefined,
52 repo: Repo,
53 label: string,
54 input: CartInput,
55 options: CaptureOptions = {},
56 ): Promise<Result<Cart, CartError>> {
57 const writer = writerOf(app, actor, repo);
58 if (!writer.ok) return writer;
59 const dir = repoDir(app.reposRoot, repo.ownerName, repo.name);
60 const pin = await readBuildPin(dir, label);
61 if (pin === undefined) return err("not_found");
62 const demo: Demo = {
63 name: input.name.trim(),
64 version: input.version.trim(),
65 launch: "file",
66 page: input.page.trim() || "/",
67 showcase: input.showcase,
68 ...(pin.demo?.cover === undefined ? {} : { cover: pin.demo.cover }),
69 };
70 if (!isDemo(demo)) return err("invalid_demo");
71 const set = await setPinDemo(dir, label, demo);
72 if (!set.ok) return err(set.error);
73 if (captureState(app, repo, pin.oid, demo.page).state === "none") void startCapture(app, writer.value, repo, pin.oid, demo.page, options);
74 return ok({ label, oid: pin.oid, demo, capture: captureState(app, repo, pin.oid, demo.page) });
75 }
src/arcade/carts.ts added +283
@@ -0,0 +1,283 @@
1 /**
2 * Cart captures: the one page a pin releases, saved once from its running
3 * build. A capture is the cart itself — what the Arcade plays and what
4 * Download hands over — so it never has to wait for a preview again.
5 *
6 * Captures live in the data directory, at
7 * `carts/<owner>/<repo>/<sha>/<page>.html`, with a picture of the page
8 * beside it as `<page>.png` for the cover. A file there is never written
9 * twice: the commit is fixed, so its page is too.
10 *
11 * Capturing is the slow part: the build's preview is started if it is not
12 * running, waited for, and asked for the page. It runs in the background;
13 * what a panel shows meanwhile is held in memory, and a forge coming back
14 * simply shows the capture as missing, with Capture again beside it.
15 */
16
17 import { existsSync, linkSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
18 import { dirname, join } from "node:path";
19 import type { App } from "../app.ts";
20 import { previewBuild, type PreviewBuildError } from "../actions/build_preview.ts";
21 import { buildEnv } from "../apps/data.ts";
22 import { appsRuntime } from "../apps/runtime.ts";
23 import { getAppByRepo } from "../core/apps.ts";
24 import type { Repo } from "../core/repos.ts";
25 import type { User } from "../core/users.ts";
26 import { isDemoPage } from "../git/builds.ts";
27 import { isSafeOid } from "../git/guard.ts";
28 import { takeLook, type Capture } from "../looks/capture.ts";
29 import { createLog } from "../kernel/log.ts";
30 import { err, ok, type Result } from "../kernel/result.ts";
31
32 const log = createLog("carts");
33
34 export const cartsRoot = (dataDir: string): string => join(dataDir, "carts");
35
36 /** A page's file name: `/` is `index`, `/spicy` is `spicy`. */
37 export const pageFile = (page: string): string =>
38 page === "/" ? "index" : page.slice(1).replaceAll("/", "-");
39
40 /** Where a commit's page is kept, without its extension. */
41 export function capturePath(dataDir: string, repo: Pick<Repo, "ownerName" | "name">, oid: string, page: string): string {
42 return join(cartsRoot(dataDir), repo.ownerName, repo.name, oid, pageFile(page));
43 }
44
45 const valid = (oid: string, page: string): boolean => isSafeOid(oid) && /^[0-9a-f]{40,64}$/.test(oid) && isDemoPage(page);
46
47 /** The captured page, or undefined. */
48 export function readCapture(dataDir: string, repo: Pick<Repo, "ownerName" | "name">, oid: string, page: string): Uint8Array | undefined {
49 if (!valid(oid, page)) return undefined;
50 const path = `${capturePath(dataDir, repo, oid, page)}.html`;
51 return existsSync(path) ? new Uint8Array(readFileSync(path)) : undefined;
52 }
53
54 /** The picture of the captured page, or undefined. */
55 export function readArt(dataDir: string, repo: Pick<Repo, "ownerName" | "name">, oid: string, page: string): Uint8Array | undefined {
56 if (!valid(oid, page)) return undefined;
57 const path = `${capturePath(dataDir, repo, oid, page)}.png`;
58 return existsSync(path) ? new Uint8Array(readFileSync(path)) : undefined;
59 }
60
61 /**
62 * Write `bytes` at `path` only if nothing is there: written to one side,
63 * then linked in, which fails rather than replace a file. False when a
64 * file was already there.
65 */
66 export function writeOnce(path: string, bytes: Uint8Array): boolean {
67 if (existsSync(path)) return false;
68 mkdirSync(dirname(path), { recursive: true });
69 const staged = `${path}.${process.pid}.${Date.now()}.part`;
70 writeFileSync(staged, bytes);
71 try {
72 linkSync(staged, path);
73 return true;
74 } catch {
75 return false;
76 } finally {
77 rmSync(staged, { force: true });
78 }
79 }
80
81 /** Store a capture made elsewhere — the forge CLI's import. False if one is there. */
82 export function importCapture(
83 dataDir: string,
84 repo: Pick<Repo, "ownerName" | "name">,
85 oid: string,
86 page: string,
87 html: Uint8Array,
88 art?: Uint8Array,
89 ): Result<boolean, "invalid"> {
90 if (!valid(oid, page)) return err("invalid");
91 const base = capturePath(dataDir, repo, oid, page);
92 const wrote = writeOnce(`${base}.html`, html);
93 if (art !== undefined) writeOnce(`${base}.png`, art);
94 return ok(wrote);
95 }
96
97 export type CaptureError =
98 | PreviewBuildError
99 | "not_an_app"
100 | "invalid"
101 | "build_failed"
102 | "timed_out"
103 | "page_missing";
104
105 export type CaptureState =
106 | { readonly state: "done" }
107 | { readonly state: "capturing" }
108 | { readonly state: "failed"; readonly error: CaptureError; readonly detail?: string }
109 | { readonly state: "none" };
110
111 /** What a capture under way, or one that failed, looks like this boot. */
112 const jobs = new WeakMap<App, Map<string, CaptureState>>();
113 const jobKey = (repo: Repo, oid: string, page: string): string => `${repo.id}:${oid}:${page}`;
114 const jobsOf = (app: App): Map<string, CaptureState> => {
115 let map = jobs.get(app);
116 if (map === undefined) jobs.set(app, (map = new Map()));
117 return map;
118 };
119
120 /** Whether a page is captured, being captured, or failed. */
121 export function captureState(app: App, repo: Repo, oid: string, page: string): CaptureState {
122 if (!valid(oid, page)) return { state: "none" };
123 if (existsSync(`${capturePath(app.config.dataDir, repo, oid, page)}.html`)) return { state: "done" };
124 return jobsOf(app).get(jobKey(repo, oid, page)) ?? { state: "none" };
125 }
126
127 export interface CaptureOptions {
128 /** How long the build may take to come up. */
129 readonly waitMs?: number;
130 /** What takes the picture; a test passes a stand-in. */
131 readonly picture?: Capture;
132 /** The commit's build preview; by default the app's own (`previewOf`). */
133 readonly build?: Build;
134 /** How often the build is looked at while it comes up. */
135 readonly pollMs?: number;
136 }
137
138 /** A commit's build preview, as a capture sees it: how it stands, and how to start it. */
139 export interface Build {
140 readonly up: () => { readonly state: string; readonly port?: number | undefined } | undefined;
141 readonly start: () => Promise<Result<unknown, CaptureError>>;
142 }
143
144 /** The build preview of `oid` in `repo`'s app, or undefined when it is no app. */
145 function previewOf(app: App, actor: User | undefined, repo: Repo, oid: string): Build | undefined {
146 const record = getAppByRepo(app.db, repo.id);
147 const runtime = appsRuntime(app);
148 if (record === undefined || runtime === undefined) return undefined;
149 const env = buildEnv(oid);
150 return {
151 up: () => runtime.status(record).find((e) => e.env === env && e.commitOid === oid),
152 start: () => previewBuild(app, actor, repo, oid),
153 };
154 }
155
156 /** How long a build gets to come up before the capture gives up. */
157 const WAIT_MS = 15 * 60_000;
158 /** The picture's window: a game's wide screen, which the cover crops to its middle square. */
159 const ART_WINDOW = { width: 960, height: 624 } as const;
160
161 /**
162 * Capture one page of `oid`: start its build preview as `actor` if it is
163 * not running, wait for it, save the page once and picture it. Resolves
164 * when done; `startCapture` is the same, in the background.
165 */
166 export async function captureCart(
167 app: App,
168 actor: User | undefined,
169 repo: Repo,
170 oid: string,
171 page: string,
172 options: CaptureOptions = {},
173 ): Promise<Result<void, CaptureError>> {
174 if (!valid(oid, page)) return err("invalid");
175 const base = capturePath(app.config.dataDir, repo, oid, page);
176 if (existsSync(`${base}.html`)) return ok(undefined);
177 const build = options.build ?? previewOf(app, actor, repo, oid);
178 if (build === undefined) return err("not_an_app");
179 if (build.up()?.state !== "running") {
180 const started = await build.start();
181 if (!started.ok) return started;
182 }
183 const deadline = Date.now() + (options.waitMs ?? WAIT_MS);
184 let port: number | undefined;
185 for (;;) {
186 const now = build.up();
187 if (now?.state === "running" && now.port !== undefined) {
188 port = now.port;
189 break;
190 }
191 if (now?.state === "failed") return err("build_failed");
192 if (Date.now() > deadline) return err("timed_out");
193 await Bun.sleep(options.pollMs ?? 500);
194 }
195 return keepPage(base, `http://127.0.0.1:${port}${page}`, options.picture ?? takeLook, `${repo.ownerName}/${repo.name}`, oid, page);
196 }
197
198 /** The most bytes of a build's page a capture keeps. */
199 export const PAGE_MAX = 16 * 1024 * 1024;
200
201 /** A response body read up to `max` bytes; undefined if it is longer. */
202 async function readCapped(answer: Response, max: number): Promise<Uint8Array | undefined> {
203 const declared = Number(answer.headers.get("content-length") ?? 0);
204 if (declared > max || answer.body === null) return declared > max ? undefined : new Uint8Array();
205 const chunks: Uint8Array[] = [];
206 let total = 0;
207 for await (const chunk of answer.body as unknown as AsyncIterable<Uint8Array>) {
208 total += chunk.length;
209 if (total > max) {
210 await answer.body.cancel().catch(() => {});
211 return undefined;
212 }
213 chunks.push(chunk);
214 }
215 return Buffer.concat(chunks);
216 }
217
218 /**
219 * Fetch `url` once, keep it at `base.html` only if nothing is there, then
220 * picture it for the cover's art. A page over `max` bytes is not kept.
221 */
222 export async function keepPage(
223 base: string,
224 url: string,
225 picture: Capture,
226 who = "",
227 oid = "",
228 page = "",
229 max = PAGE_MAX,
230 ): Promise<Result<void, "page_missing">> {
231 let bytes: Uint8Array | undefined;
232 try {
233 const answer = await fetch(url, { redirect: "manual" });
234 if (!answer.ok) return err("page_missing");
235 bytes = await readCapped(answer, max);
236 } catch {
237 return err("page_missing");
238 }
239 if (bytes === undefined) return err("page_missing");
240 writeOnce(`${base}.html`, bytes);
241 // The picture is the cover's art; a cart without one still plays.
242 const staged = `${base}.${process.pid}.shot.png`;
243 const shot = await picture({ url, out: staged, ...ART_WINDOW, timeoutMs: 30_000 });
244 if (shot.ok && existsSync(staged)) writeOnce(`${base}.png`, new Uint8Array(readFileSync(staged)));
245 else if (!shot.ok) log.warn("cart picture not taken", { repo: who, oid, page, error: shot.error });
246 rmSync(staged, { force: true });
247 return ok(undefined);
248 }
249
250 /** A failed build's first error line, from its environment, so a person reads why. */
251 function failedDetail(app: App, repo: Repo, oid: string, error: CaptureError): { detail?: string } {
252 if (error !== "build_failed") return {};
253 const record = getAppByRepo(app.db, repo.id);
254 const env = record === undefined ? undefined : appsRuntime(app)?.status(record).find((e) => e.env === buildEnv(oid));
255 const first = env?.error?.split("\n")[0]?.trim();
256 return first === undefined || first === "" ? {} : { detail: first };
257 }
258
259 /** Capture in the background; the panel reads `captureState` meanwhile. */
260 export function startCapture(
261 app: App,
262 actor: User | undefined,
263 repo: Repo,
264 oid: string,
265 page: string,
266 options: CaptureOptions = {},
267 ): Promise<Result<void, CaptureError>> {
268 const map = jobsOf(app);
269 const key = jobKey(repo, oid, page);
270 map.set(key, { state: "capturing" });
271 return captureCart(app, actor, repo, oid, page, options).then(
272 (done) => {
273 if (done.ok) map.delete(key);
274 else map.set(key, { state: "failed", error: done.error, ...failedDetail(app, repo, oid, done.error) });
275 return done;
276 },
277 (error: unknown) => {
278 log.error("cart capture failed", { oid, page, error: String(error) });
279 map.set(key, { state: "failed", error: "page_missing" });
280 return err("page_missing" as const);
281 },
282 );
283 }
src/arcade/import.ts added +86
@@ -0,0 +1,86 @@
1 /**
2 * Bringing carts in that were built outside the forge: a game whose
3 * offline file comes from its own build step, not from a page its app
4 * serves (Krazy Karts' `build:file`), or a shelf that lived elsewhere
5 * before the Arcade. An operator runs it on the forge's own data
6 * directory (`scripts/import-carts.ts`); each entry pins its commit if no
7 * pin has that label yet, writes the cart facts and cover onto the pin,
8 * and stores the file as that commit's capture of the page.
9 *
10 * A capture is still written once: a page already captured is kept.
11 */
12
13 import { join } from "node:path";
14 import { pinBuild, readBuildPin, setPinDemo, storeCover, type Demo } from "../git/builds.ts";
15 import { repoDir } from "../git/repos.ts";
16 import { err, ok, type Result } from "../kernel/result.ts";
17 import { importCapture } from "./carts.ts";
18
19 export interface CartImport {
20 /** `owner/name`. */
21 readonly repo: string;
22 readonly label: string;
23 /** The commit, full id, the cart was built from. */
24 readonly sha: string;
25 readonly note?: string;
26 /** The cart's facts; `shelf` puts it in the showcase. A `producer` is the Arcade's own, not the pin's. */
27 readonly cart: Pick<Demo, "name" | "version" | "page"> & { readonly shelf: boolean };
28 /** The cart file's bytes. */
29 readonly html: Uint8Array;
30 /** An uploaded cover, already titled. */
31 readonly cover?: Uint8Array | undefined;
32 /** A picture of the page, for a cover drawn in the house style. */
33 readonly art?: Uint8Array | undefined;
34 }
35
36 export interface ImportResult {
37 readonly label: string;
38 readonly pinned: boolean;
39 readonly captured: boolean;
40 }
41
42 /** Import one cart into the forge at `dataDir`, as `tagger`. */
43 export async function importCart(
44 dataDir: string,
45 entry: CartImport,
46 tagger: { readonly name: string; readonly email: string },
47 ): Promise<Result<ImportResult, string>> {
48 const [owner = "", name = ""] = entry.repo.split("/");
49 if (!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(owner) || !/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(name))
50 return err(`${entry.repo}: not owner/name`);
51 const dir = repoDir(join(dataDir, "repos"), owner, name);
52 let pin = await readBuildPin(dir, entry.label);
53 let pinned = false;
54 if (pin === undefined) {
55 const made = await pinBuild(dir, {
56 label: entry.label,
57 oid: entry.sha,
58 note: entry.note ?? "",
59 source: "import",
60 tagger,
61 });
62 if (!made.ok) return err(`${entry.repo} ${entry.label}: ${made.error}`);
63 pin = made.value;
64 pinned = true;
65 } else if (pin.oid !== entry.sha) return err(`${entry.repo} ${entry.label}: pinned at ${pin.oid}, not ${entry.sha}`);
66 let cover = pin.demo?.cover;
67 if (entry.cover !== undefined) {
68 const stored = await storeCover(dir, entry.label, entry.cover);
69 if (!stored.ok) return err(`${entry.repo} ${entry.label}: cover ${stored.error}`);
70 cover = stored.value;
71 }
72 // A cart is a captured page: it opens as a file.
73 const demo: Demo = {
74 name: entry.cart.name,
75 version: entry.cart.version,
76 launch: "file",
77 page: entry.cart.page,
78 showcase: entry.cart.shelf,
79 ...(cover === undefined ? {} : { cover }),
80 };
81 const set = await setPinDemo(dir, entry.label, demo);
82 if (!set.ok) return err(`${entry.repo} ${entry.label}: ${set.error}`);
83 const captured = importCapture(dataDir, { ownerName: owner, name }, entry.sha, entry.cart.page, entry.html, entry.art);
84 if (!captured.ok) return err(`${entry.repo} ${entry.label}: page ${entry.cart.page} is no capture path`);
85 return ok({ label: entry.label, pinned, captured: captured.value });
86 }
src/contract/openapi/routes/getApiV1ReposOwnerNameBuildsLabelCart.ts added +8
@@ -0,0 +1,8 @@
1 import { REPO, type Route } from "../shared.ts";
2
3 export const route: Route = {
4 method: "get",
5 path: `${REPO}/builds/{label}/cart`,
6 summary: "A pin's cart: { label, oid, demo, capture, play_url, art_url }; capture.state is none, capturing, done or failed, and play_url is set once the page is captured",
7 auth: false,
8 };
src/contract/openapi/routes/index.ts +2
@@ -100,6 +100,7 @@ export { route as getApiV1ReposOwnerNameAppNotes } from "./getApiV1ReposOwnerNam
100100 export { route as getApiV1ReposOwnerNameAppProductionOpen } from "./getApiV1ReposOwnerNameAppProductionOpen.ts";
101101 export { route as getApiV1ReposOwnerNameBranches } from "./getApiV1ReposOwnerNameBranches.ts";
102102 export { route as getApiV1ReposOwnerNameBuilds } from "./getApiV1ReposOwnerNameBuilds.ts";
103 export { route as getApiV1ReposOwnerNameBuildsLabelCart } from "./getApiV1ReposOwnerNameBuildsLabelCart.ts";
103104 export { route as getApiV1ReposOwnerNameBuildsLabelShots } from "./getApiV1ReposOwnerNameBuildsLabelShots.ts";
104105 export { route as getApiV1ReposOwnerNameBuildsLabelShotsLooks } from "./getApiV1ReposOwnerNameBuildsLabelShotsLooks.ts";
105106 export { route as getApiV1ReposOwnerNameCommits } from "./getApiV1ReposOwnerNameCommits.ts";
@@ -257,6 +258,7 @@ export { route as postApiV1ReposOwnerNameAppNotesIdHide } from "./postApiV1Repos
257258 export { route as postApiV1ReposOwnerNameAppNotesIdUnban } from "./postApiV1ReposOwnerNameAppNotesIdUnban.ts";
258259 export { route as postApiV1ReposOwnerNameBranches } from "./postApiV1ReposOwnerNameBranches.ts";
259260 export { route as postApiV1ReposOwnerNameBuilds } from "./postApiV1ReposOwnerNameBuilds.ts";
261 export { route as postApiV1ReposOwnerNameBuildsLabelCart } from "./postApiV1ReposOwnerNameBuildsLabelCart.ts";
260262 export { route as postApiV1ReposOwnerNameBuildsLabelShots } from "./postApiV1ReposOwnerNameBuildsLabelShots.ts";
261263 export { route as postApiV1ReposOwnerNameBuildsLabelShotsFile } from "./postApiV1ReposOwnerNameBuildsLabelShotsFile.ts";
262264 export { route as postApiV1ReposOwnerNameBuildsLabelShotsLooks } from "./postApiV1ReposOwnerNameBuildsLabelShotsLooks.ts";
src/contract/openapi/routes/postApiV1ReposOwnerNameBuildsLabelCart.ts added +16
@@ -0,0 +1,16 @@
1 import { REPO, type Route } from "../shared.ts";
2
3 export const route: Route = {
4 method: "post",
5 path: `${REPO}/builds/{label}/cart`,
6 summary: "Name a pin's cart (rw scope, a person who writes): { name, version, page, showcase }, and start capturing its page from a build preview of its commit; answers 202 with the cart while the capture runs, 200 once it is kept",
7 auth: true,
8 status: 202,
9 refusals: {
10 forbidden: "403: not a person who writes the repository (an agent is refused)",
11 not_found: "404: no such repository or pin, or the caller cannot read it",
12 mirrored: "409: the repository mirrors an origin",
13 moved: "409: the pin changed while it was written",
14 invalid_demo: "422: a name over 60 or version over 40 characters, more than one line, or a page that is not a plain path",
15 },
16 };
src/git/builds.ts +209 −13
@@ -33,12 +33,60 @@ export interface BuildPin {
3333 readonly pinnedBy: string;
3434 /** ISO time it was pinned. */
3535 readonly pinnedAt: string;
36 /** The demo this pin is, when it names one. */
37 readonly demo?: Demo | undefined;
3638 /** Its notes, ranked, when it has any (`withNotes`); `note` is then the first one's text. */
3739 readonly notes?: readonly BuildNote[] | undefined;
3840 /** The tag message's note, when `note` holds the first of its notes instead. */
3941 readonly tagNote?: string | undefined;
4042 }
4143
44 /**
45 * How a demo opens: `file` plays or downloads its captured HTML page, `env`
46 * opens a running build preview of its commit, `shots` shows screenshots.
47 */
48 export const LAUNCHES = ["file", "env", "shots"] as const;
49 export type Launch = (typeof LAUNCHES)[number];
50
51 export const isLaunch = (value: unknown): value is Launch =>
52 typeof value === "string" && (LAUNCHES as readonly string[]).includes(value);
53
54 /** A pin's demo: what a display shows and opens. */
55 export interface Demo {
56 /** The demo it belongs to: pins sharing a name are one demo's builds. */
57 readonly name: string;
58 readonly version: string;
59 /** How it opens; unset follows the repository's `app.launch` (`launchOf`). */
60 readonly launch?: Launch | undefined;
61 /** The page it opens, `/` or `/spicy`. */
62 readonly page: string;
63 /** On display pages: chosen by a person, never by default. */
64 readonly showcase: boolean;
65 /** An uploaded cover: a blob kept by `refs/covers/<label>`. */
66 readonly cover?: string | undefined;
67 }
68
69 export const COVERS_PREFIX = "refs/covers/";
70
71 /** A demo fact: one plain line, at most `max` characters. */
72 const isFact = (s: string, max: number): boolean =>
73 s.length > 0 && s.length <= max && !/[\x00-\x1f\x7f<>]/.test(s) && s.trim() === s;
74
75 /** A page a demo may name: a path of plain segments. */
76 export const isDemoPage = (page: string): boolean =>
77 page.length <= 200 && /^\/(?:[A-Za-z0-9._-]+(?:\/[A-Za-z0-9._-]+)*)?$/.test(page) && !page.includes("..");
78
79 /** Whether a demo's facts are all well formed. */
80 export function isDemo(demo: Demo): boolean {
81 return (
82 isFact(demo.name, 60) &&
83 isFact(demo.version, 40) &&
84 (demo.launch === undefined || isLaunch(demo.launch)) &&
85 isDemoPage(demo.page) &&
86 (demo.cover === undefined || isSafeOid(demo.cover))
87 );
88 }
89
4290 /** A label is short and plain: letters, digits, `.`, `_` and `-`. */
4391 export function isPinLabel(label: string): boolean {
4492 return (
@@ -62,6 +110,7 @@ export function isPinParent(parent: string): boolean {
62110
63111 export type PinBuildError =
64112 | "invalid_label"
113 | "invalid_demo"
65114 | "invalid_parent"
66115 | "not_a_commit"
67116 | "exists"
@@ -76,6 +125,7 @@ export interface PinInput {
76125 readonly tagger: { readonly name: string; readonly email: string };
77126 /** Seconds since the epoch; tests pass one to order pins. */
78127 readonly at?: number;
128 readonly demo?: Demo | undefined;
79129 }
80130
81131 /** One line, no control bytes: what a trailer or tagger field may hold. */
@@ -95,9 +145,11 @@ export async function pinBuild(
95145 if (kind.code !== 0 || kind.stdout.trim() !== "commit")
96146 return err("not_a_commit");
97147
148 if (input.demo !== undefined && !isDemo(input.demo)) return err("invalid_demo");
149
98150 const at = input.at ?? Math.floor(Date.now() / 1000);
99151 const note = input.note.replace(/\r/g, "").trim();
100 const message = pinMessage(note === "" ? input.label : note, input.parent, oneLine(input.source) || "commit");
152 const message = pinMessage(note === "" ? input.label : note, input.parent, oneLine(input.source) || "commit", input.demo);
101153 const tag =
102154 `object ${input.oid}\ntype commit\ntag ${input.label}\n` +
103155 `tagger ${oneLine(input.tagger.name) || "forge"} <${oneLine(input.tagger.email)}> ${at} +0000\n\n` +
@@ -118,31 +170,158 @@ export async function pinBuild(
118170 source: oneLine(input.source) || "commit",
119171 pinnedBy: oneLine(input.tagger.name) || "forge",
120172 pinnedAt: new Date(at * 1000).toISOString(),
173 demo: input.demo,
121174 });
122175 }
123176
124 /** A pin's message: its note, then its trailers. */
125 function pinMessage(note: string, parent: string | undefined, source: string): string {
126 const trailers = [...(parent === undefined ? [] : [`Parent: ${parent}`]), `Source: ${source}`];
177 /** A pin's message: its note, then its trailers. Always the new trailers. */
178 function pinMessage(note: string, parent: string | undefined, source: string, demo: Demo | undefined): string {
179 const trailers = [
180 ...(parent === undefined ? [] : [`Parent: ${parent}`]),
181 `Source: ${source}`,
182 ...(demo === undefined
183 ? []
184 : [
185 `Demo: ${demo.name}`,
186 `Version: ${demo.version}`,
187 ...(demo.launch === undefined ? [] : [`Launch: ${demo.launch}`]),
188 `Page: ${demo.page}`,
189 ...(demo.showcase ? ["Showcase: on"] : []),
190 ...(demo.cover === undefined ? [] : [`Cover: ${demo.cover}`]),
191 ]),
192 ];
127193 return `${note}\n\n${trailers.join("\n")}\n`;
128194 }
129195
130 const TRAILER = /^(Parent|Source): (.*)$/;
196 /** Every trailer a pin may carry. */
197 const TRAILER = /^(Parent|Source|Demo|Cart|Version|Launch|Producer|Page|Showcase|Shelf|Cover): (.*)$/;
198
199 /** What a pin's message carries, as `parsePinMessage` reads it. */
200 export interface PinMessage {
201 readonly note: string;
202 readonly parent: string | undefined;
203 readonly source: string;
204 readonly demo: Demo | undefined;
205 /** Whether it carries any old trailer: `Cart:`, `Shelf:` or `Producer:`. */
206 readonly legacy: boolean;
207 /** The old `Producer:`, when it carries one. */
208 readonly producer: string | undefined;
209 }
131210
132211 /** The trailers block at the end of a pin's message, and the note above it. */
133 function parsePinMessage(text: string): { note: string; parent: string | undefined; source: string } {
212 export function parsePinMessage(text: string): PinMessage {
134213 const lines = text.replace(/\n+$/, "").split("\n");
135214 let parent: string | undefined;
136215 let source = "commit";
216 const facts = new Map<string, string>();
137217 let end = lines.length;
138218 while (end > 0) {
139 const m = TRAILER.exec(lines[end - 1]!);
219 const line = lines[end - 1]!;
220 const m = TRAILER.exec(line);
140221 if (m === null) break;
141222 if (m[1] === "Parent" && isPinParent(m[2]!)) parent = m[2]!;
142223 else if (m[1] === "Source") source = m[2]!;
224 else if (!facts.has(m[1]!)) facts.set(m[1]!, m[2]!);
143225 end--;
144226 }
145 return { note: lines.slice(0, end).join("\n").trim(), parent, source };
227 // A new trailer wins over its old name when a tag carries both. An old
228 // `Cart:` was always a captured page: with no `Launch:`, it is `file`.
229 const name = facts.get("Demo") ?? facts.get("Cart");
230 const cover = facts.get("Cover");
231 const launch = facts.get("Launch") ?? (facts.has("Demo") || !facts.has("Cart") ? undefined : "file");
232 const demo: Demo | undefined =
233 name === undefined
234 ? undefined
235 : {
236 name,
237 version: facts.get("Version") ?? "",
238 ...(launch === undefined ? {} : { launch: launch as Launch }),
239 page: facts.get("Page") ?? "/",
240 showcase: (facts.get("Showcase") ?? facts.get("Shelf")) === "on",
241 ...(cover === undefined ? {} : { cover }),
242 };
243 return {
244 note: lines.slice(0, end).join("\n").trim(),
245 parent,
246 source,
247 demo: demo !== undefined && isDemo(demo) ? demo : undefined,
248 legacy: facts.has("Cart") || facts.has("Shelf") || facts.has("Producer"),
249 producer: facts.get("Producer"),
250 };
251 }
252
253 export type SetDemoError = "invalid_demo" | "not_found" | "moved" | "git_failed";
254
255 /** A pin's tag as `rewritePin` reads it: the tag object, its commit, its tagger line and its message. */
256 interface PinTag {
257 readonly tag: string;
258 readonly object: string;
259 readonly tagger: { readonly name: string; readonly email: string; readonly at: string; readonly zone: string };
260 readonly message: PinMessage;
261 }
262
263 /**
264 * Write a new tag object for the pin `label`'s commit, keeping its tagger,
265 * date and message, with the demo `change` makes of what it holds. The ref
266 * moves only from the tag read here, so two edits never cross. Notes are
267 * never written here: they live on `refs/forge/notes`.
268 */
269 async function rewritePin(
270 dir: string,
271 label: string,
272 change: (old: PinTag) => Demo | undefined,
273 ): Promise<Result<BuildPin, "not_found" | "moved" | "git_failed">> {
274 if (!isPinLabel(label)) return err("not_found");
275 const ref = `${BUILDS_PREFIX}${label}`;
276 const old = await runGitCapture(["rev-parse", "--verify", "--quiet", ref], { cwd: dir });
277 const oldTag = old.stdout.trim();
278 if (old.code !== 0 || !isSafeOid(oldTag)) return err("not_found");
279 const body = await runGit(["cat-file", "tag", oldTag], { cwd: dir });
280 if (!body.ok) return err("not_found");
281 const split = body.value.indexOf("\n\n");
282 const head = split === -1 ? body.value : body.value.slice(0, split);
283 const tagger = /^tagger (.*) <([^>]*)> (\d+) ([+-]\d{4})$/m.exec(head);
284 const object = /^object ([0-9a-f]+)$/m.exec(head)?.[1];
285 if (tagger === null || object === undefined) return err("not_found");
286 const message = parsePinMessage(split === -1 ? "" : body.value.slice(split + 2));
287 const demo = change({ tag: oldTag, object, tagger: { name: tagger[1]!, email: tagger[2]!, at: tagger[3]!, zone: tagger[4]! }, message });
288 const tag =
289 `object ${object}\ntype commit\ntag ${label}\n` +
290 `tagger ${tagger[1]} <${tagger[2]}> ${tagger[3]} ${tagger[4]}\n\n` +
291 pinMessage(message.note === "" ? label : message.note, message.parent, message.source, demo);
292 const made = await runGit(["mktag"], { cwd: dir, stdin: tag });
293 if (!made.ok) return err("git_failed");
294 const moved = await runGitCapture(["update-ref", ref, made.value.trim(), oldTag], { cwd: dir });
295 if (moved.code !== 0) return err("moved");
296 return ok({
297 label,
298 oid: object,
299 note: message.note,
300 parent: message.parent,
301 source: message.source,
302 pinnedBy: tagger[1]!,
303 pinnedAt: new Date(Number(tagger[3]) * 1000).toISOString(),
304 demo,
305 });
306 }
307
308 /**
309 * Give the pin `label` these demo facts, or none: a new tag object for the
310 * same commit, with the same tagger, date, note, parent and source, and
311 * only the new trailers.
312 */
313 export async function setPinDemo(
314 dir: string,
315 label: string,
316 demo: Demo | undefined,
317 ): Promise<Result<BuildPin, SetDemoError>> {
318 if (demo !== undefined && !isDemo(demo)) {
319 if (!isPinLabel(label)) return err("not_found");
320 return err("invalid_demo");
321 }
322 const done = await rewritePin(dir, label, () => demo);
323 if (!done.ok) return done;
324 return ok(withNotes(done.value, (await readBuildNotes(dir, [label])).get(label)));
146325 }
147326
148327 /** The most bytes of one note's text. */
@@ -410,8 +589,9 @@ export function coverType(bytes: Uint8Array): "image/png" | "image/jpeg" | "imag
410589 export type DeletePinError = "not_found" | "moved" | "git_failed";
411590
412591 /**
413 * Delete the pin `label`: `refs/builds/<label>`, only from the tag read
414 * here, so a pin changed meanwhile is not deleted. The commit stays.
592 * Delete the pin `label`: `refs/builds/<label>`, with its cover and its
593 * screenshots, in one ref transaction. The pin's ref goes only from the tag
594 * read here, so a pin changed meanwhile is not deleted. The commit stays.
415595 */
416596 export async function deletePin(dir: string, label: string): Promise<Result<{ readonly oid: string; readonly tag: string }, DeletePinError>> {
417597 if (!isPinLabel(label)) return err("not_found");
@@ -425,9 +605,11 @@ export async function deletePin(dir: string, label: string): Promise<Result<{ re
425605 if (tag === undefined) return err("not_found");
426606 const commit = await runGitCapture(["rev-parse", "--verify", "--quiet", `${tag}^{commit}`], { cwd: dir });
427607 const lines = [`delete ${ref} ${tag}`];
428 // The pin's curated screenshots (`git/shots.ts`) go with it, so a label pinned again starts bare.
429 const shots = await read(`refs/shots/${label}`);
430 if (shots !== undefined) lines.push(`delete refs/shots/${label} ${shots}`);
608 // The pin's cover and curated screenshots (`git/shots.ts`) go with it, so a label pinned again starts bare.
609 for (const other of [`${COVERS_PREFIX}${label}`, `refs/shots/${label}`]) {
610 const at = await read(other);
611 if (at !== undefined) lines.push(`delete ${other} ${at}`);
612 }
431613 const done = await runGitCapture(["update-ref", "--stdin"], { cwd: dir, stdin: `start\n${lines.join("\n")}\nprepare\ncommit\n` });
432614 if (done.code !== 0) return err(/cannot lock|is at|expected/i.test(done.stderr) ? "moved" : "git_failed");
433615 return ok({ oid: commit.stdout.trim(), tag });
@@ -439,6 +621,19 @@ async function readTagPin(dir: string, label: string): Promise<BuildPin | undefi
439621 return (await listTagPins(dir, label))[0];
440622 }
441623
624 /** The largest cover a person may upload. */
625 export const COVER_MAX = 2 * 1024 * 1024;
626
627 /** Store a cover picture as a blob kept by `refs/covers/<label>`; its id. */
628 export async function storeCover(dir: string, label: string, bytes: Uint8Array): Promise<Result<string, "invalid_cover" | "git_failed">> {
629 if (!isPinLabel(label) || bytes.length > COVER_MAX || coverType(bytes) === undefined) return err("invalid_cover");
630 const blob = await runGit(["hash-object", "-w", "--stdin"], { cwd: dir, stdin: bytes });
631 if (!blob.ok) return err("git_failed");
632 const oid = blob.value.trim();
633 const kept = await runGitCapture(["update-ref", `${COVERS_PREFIX}${label}`, oid], { cwd: dir });
634 return kept.code === 0 ? ok(oid) : err("git_failed");
635 }
636
442637 /**
443638 * Every pin, newest first, read in one `for-each-ref`; or the one `label`.
444639 * A pin's note is its notes file on `refs/forge/notes` when it has one,
@@ -478,6 +673,7 @@ async function listTagPins(dir: string, label?: string): Promise<BuildPin[]> {
478673 source: message.source,
479674 pinnedBy: fields[i + 3]!,
480675 pinnedAt: new Date(fields[i + 4]!).toISOString(),
676 demo: message.demo,
481677 });
482678 }
483679 return pins;
src/http/api.ts +2
@@ -355,6 +355,7 @@ import { userRoutes } from "./api/user.ts";
355355 import { appRoutes } from "./api/app.ts";
356356 import { buildRoutes } from "./api/builds.ts";
357357 import { buildShotRoutes } from "./api/build_shots.ts";
358 import { buildCartRoutes } from "./api/build_cart.ts";
358359 import { iterationRoutes } from "./api/iterations.ts";
359360 import { appNoteRoutes } from "./api/app_notes.ts";
360361 import { reportRoutes } from "./api/reports.ts";
@@ -3637,6 +3638,7 @@ export function apiRoutes(router: Router<Handler>): void {
36373638 appRoutes(router);
36383639 buildRoutes(router);
36393640 buildShotRoutes(router);
3641 buildCartRoutes(router);
36403642 iterationRoutes(router);
36413643
36423644 egressGrantRoutes(router);
src/http/api/build_cart.ts added +57
@@ -0,0 +1,57 @@
1 /**
2 * A pin's cart over the API (`actions/build_cart.ts`): what it is and how
3 * its capture stands, to the repository's readers; naming it and starting
4 * its capture needs the `rw` scope and a person who writes.
5 */
6
7 import { cartOf, setCart, type Cart } from "../../actions/build_cart.ts";
8 import type { Repo } from "../../core/repos.ts";
9 import { notePhase, takeSessionTime } from "../../core/route_timing.ts";
10 import { loadRepo } from "../api.ts";
11 import type { Handler } from "../context.ts";
12 import { body, json, notFound, requireScope, respondAccess } from "../respond.ts";
13 import type { Router } from "../router.ts";
14
15 const STATUS: Record<string, number> = { mirrored: 409, moved: 409, invalid_demo: 422, git_failed: 500 };
16
17 /** A cart as JSON: where it plays once captured. */
18 export const cartJson = (repo: Repo, cart: Cart) => {
19 const at = `/${repo.ownerName}/${repo.name}/builds/${encodeURIComponent(cart.label)}/cart`;
20 return {
21 label: cart.label,
22 oid: cart.oid,
23 demo: cart.demo ?? null,
24 capture: cart.capture,
25 play_url: cart.capture.state === "done" ? at : null,
26 art_url: cart.capture.state === "done" ? `${at}.png` : null,
27 };
28 };
29
30 const text = (v: unknown, max: number): string => (typeof v === "string" ? v.slice(0, max) : "");
31
32 export function buildCartRoutes(router: Router<Handler>): void {
33 router.add("GET", "/api/v1/repos/:owner/:name/builds/:label/cart", async (ctx) => {
34 notePhase(ctx.request, "session", takeSessionTime(ctx.request));
35 const repo = loadRepo(ctx);
36 if (!repo) return notFound();
37 const cart = await cartOf(ctx.app, ctx.auth.user, repo, ctx.params.label ?? "");
38 return cart.ok ? json(cartJson(repo, cart.value)) : notFound();
39 });
40
41 // Name the cart ({ name, version, page, showcase }) and start capturing its page: 202 while it runs.
42 router.add("POST", "/api/v1/repos/:owner/:name/builds/:label/cart", async (ctx) => {
43 const denied = requireScope(ctx, "rw");
44 if (denied) return denied;
45 const repo = loadRepo(ctx);
46 if (!repo) return notFound();
47 const b = await body(ctx.request);
48 const done = await setCart(ctx.app, ctx.auth.user, repo, ctx.params.label ?? "", {
49 name: text(b.name, 200),
50 version: text(b.version, 200),
51 page: text(b.page, 400),
52 showcase: b.showcase === true,
53 });
54 if (!done.ok) return respondAccess(done.error, STATUS[done.error] ?? 422);
55 return json(cartJson(repo, done.value), done.value.capture.state === "done" ? 200 : 202);
56 });
57 }
src/http/server.ts +4
@@ -186,6 +186,7 @@ import {
186186 import { iterationsPage, noteSubmit } from "../web/pages/iterations.ts";
187187 import { buildsPage, deletePinSubmit, newBuildPage, pinBuildSubmit, previewBuildSubmit } from "../web/pages/builds.ts";
188188 import { shotImage } from "../web/pages/build_shots.ts";
189 import { cartArt, cartPage } from "../web/pages/build_cart.ts";
189190 import { wavePage, wavesAtom, wavesPage } from "../web/pages/waves.ts";
190191 import {
191192 contributeSubmit,
@@ -1263,6 +1264,9 @@ export function buildRouter(): Router<Handler> {
12631264 router.add("GET", "/:owner/:name/builds/new", newBuildPage);
12641265 // A pin's screenshot, read: a tree kept by refs/shots/<label> (`git/shots.ts`).
12651266 router.add("GET", "/:owner/:name/builds/:label/shots/:file", shotImage);
1267 // A pin's cart: its captured page, played sandboxed, and its picture (`arcade/carts.ts`).
1268 router.add("GET", "/:owner/:name/builds/:label/cart", cartPage);
1269 router.add("GET", "/:owner/:name/builds/:label/cart.png", cartArt);
12661270 router.add("GET", "/:owner/:name/iterations", iterationsPage);
12671271 router.add("POST", "/:owner/:name/builds", pinBuildSubmit);
12681272 router.add("POST", "/:owner/:name/builds/preview", previewBuildSubmit);
src/tui/screens/builds.ts +25 −3
@@ -8,6 +8,7 @@
88 * which check again.
99 */
1010
11 import { setCart } from "../../actions/build_cart.ts";
1112 import { deletePin, notePin, pinBuild } from "../../actions/builds.ts";
1213 import { canRead, canWrite, type Repo } from "../../core/repos.ts";
1314 import { listBuildPins, type BuildPin } from "../../git/builds.ts";
@@ -38,6 +39,8 @@ const WORDS: Record<string, string> = {
3839 moved: "That pin changed meanwhile; try again.",
3940 mirrored: "A mirror's builds are its origin's.",
4041 notes_lost: "The notes ref lost a note; nothing was written.",
42 invalid_demo: "A cart is a name (60), a version (40) and a plain page path.",
43 git_failed: "Git refused the change.",
4144 };
4245 const say = (error: string): string => WORDS[error] ?? "Only a person who writes this repository can do that.";
4346
@@ -94,6 +97,24 @@ export function buildsScreen(f: Session, ctx: Ctx, repo: Repo): Screen {
9497 }),
9598 );
9699 };
100 const nameCart = (picked: BuildPin): void => {
101 ctx.push(
102 promptScreen(ctx, screen, {
103 title: `Cart for ${picked.label}`,
104 label: "name, version, then the page it opens",
105 value: picked.demo === undefined ? `${repo.name} ${picked.label} /` : `${picked.demo.name} ${picked.demo.version} ${picked.demo.page}`,
106 onSubmit: (entry) => {
107 const [name = "", version = "", page = "/"] = entry.trim().split(/\s+/);
108 if (name === "" || version === "") return "Give a name and a version.";
109 return ctx.track(
110 setCart(f.app, f.user, repo, picked.label, { name, version, page, showcase: picked.demo?.showcase ?? false }).then((r) =>
111 done(r.ok ? (r.value.capture.state === "done" ? `Cart ${name} is captured.` : `Capturing ${name} from a preview of ${picked.label}.`) : say(r.error)),
112 ),
113 );
114 },
115 }),
116 );
117 };
97118 const ask = (picked: BuildPin): void => {
98119 ctx.push(
99120 confirmScreen(ctx, screen, {
@@ -115,7 +136,7 @@ export function buildsScreen(f: Session, ctx: Ctx, repo: Repo): Screen {
115136 return [
116137 binding(["j", "k"], "move"),
117138 binding(["s"], "screenshots"),
118 ...(mayPin(f, repo) ? [binding(["p"], "pin"), binding(["n"], "note"), binding(["d"], "delete")] : []),
139 ...(mayPin(f, repo) ? [binding(["p"], "pin"), binding(["n"], "note"), binding(["c"], "cart"), binding(["d"], "delete")] : []),
119140 binding(["esc", "q"], "back"),
120141 ];
121142 },
@@ -142,7 +163,7 @@ export function buildsScreen(f: Session, ctx: Ctx, repo: Repo): Screen {
142163 const picked = from + i === state.index;
143164 s.text(area.x, area.y + i, picked ? ">" : " ", style(c.accent, c.bg), right);
144165 const first = p.note.split("\n")[0] ?? "";
145 line(i, `${p.label} ${metaOf(p.oid.slice(0, 7), p.pinnedBy, ago(p.pinnedAt), first === p.label ? "" : first)}`, picked ? c.fg : c.muted);
166 line(i, `${p.label} ${metaOf(p.oid.slice(0, 7), p.pinnedBy, ago(p.pinnedAt), first === p.label ? "" : first)}${p.demo === undefined ? "" : ` ▶ ${p.demo.name} ${p.demo.version}`}`, picked ? c.fg : c.muted);
146167 }
147168 },
148169 key(e) {
@@ -155,6 +176,7 @@ export function buildsScreen(f: Session, ctx: Ctx, repo: Repo): Screen {
155176 else if (e.key === "s" && !hidden && picked !== undefined) ctx.push(shotsScreen(f, ctx, repo, picked));
156177 else if (e.key === "p" && write) void ctx.track(pin());
157178 else if (e.key === "n" && write && picked !== undefined) addNote(picked);
179 else if (e.key === "c" && write && picked !== undefined) nameCart(picked);
158180 else if (e.key === "d" && write && picked !== undefined) ask(picked);
159181 else return false;
160182 ctx.invalidate();
@@ -171,7 +193,7 @@ export function buildsCommands(f: Session, ctx: Ctx, repo: Repo | undefined): Co
171193 {
172194 id: "builds",
173195 title: `${repo.ownerName}/${repo.name} — Builds`,
174 hint: "its pinned builds; `s` shows a pin's screenshots; a writer pins, notes and deletes",
196 hint: "its pinned builds; `s` shows a pin's screenshots; a writer pins, notes, names a cart (`c`) and deletes",
175197 run: () => ctx.push(buildsScreen(f, ctx, repo)),
176198 },
177199 ];
src/web/pages/build_cart.ts added +45
@@ -0,0 +1,45 @@
1 /**
2 * A pin's cart, played: its captured page and its picture
3 * (`arcade/carts.ts`), to whoever reads the repository. The page is a
4 * build's own HTML, so it is served sandboxed — an opaque origin that may
5 * run its scripts and reach nothing of the forge's: no cookie, no storage,
6 * no request back with the viewer's session.
7 */
8
9 import { cartOf } from "../../actions/build_cart.ts";
10 import { readArt, readCapture } from "../../arcade/carts.ts";
11 import { notePhase, takeSessionTime } from "../../core/route_timing.ts";
12 import type { Ctx } from "../../http/context.ts";
13 import { loadRepo, notFoundPage } from "../chrome.ts";
14
15 /** What a played cart may do: run its own scripts, styles and pictures inline, nothing else, nowhere else. */
16 export const CART_POLICY =
17 "sandbox allow-scripts allow-pointer-lock; default-src 'none'; script-src 'unsafe-inline' blob:; style-src 'unsafe-inline'; img-src data: blob:; media-src data: blob:; font-src data:; worker-src blob:; form-action 'none'; frame-ancestors 'self'";
18
19 async function cartBytes(ctx: Ctx, art: boolean): Promise<Response> {
20 notePhase(ctx.request, "session", takeSessionTime(ctx.request));
21 const repo = loadRepo(ctx);
22 if (!repo) return notFoundPage(ctx);
23 const cart = await cartOf(ctx.app, ctx.auth.user, repo, ctx.params.label ?? "");
24 if (!cart.ok || cart.value.demo === undefined) return notFoundPage(ctx);
25 const { oid, demo } = cart.value;
26 const bytes = (art ? readArt : readCapture)(ctx.app.config.dataDir, repo, oid, demo.page);
27 if (bytes === undefined) return notFoundPage(ctx);
28 // A capture is written once per commit and page: the bytes never change.
29 const etag = `"${oid.slice(0, 16)}${art ? "-art" : ""}"`;
30 const headers = {
31 "content-type": art ? "image/png" : "text/html; charset=utf-8",
32 "x-content-type-options": "nosniff",
33 "content-security-policy": art ? "default-src 'none'; sandbox" : CART_POLICY,
34 "referrer-policy": "no-referrer",
35 etag,
36 "cache-control": "private, no-cache",
37 };
38 if (ctx.request.headers.get("if-none-match") === etag) return new Response(null, { status: 304, headers });
39 return new Response(bytes, { headers });
40 }
41
42 // GET /:owner/:name/builds/:label/cart — the captured page, played sandboxed.
43 export const cartPage = (ctx: Ctx): Promise<Response> => cartBytes(ctx, false);
44 // GET /:owner/:name/builds/:label/cart.png — its picture, the cover's art.
45 export const cartArt = (ctx: Ctx): Promise<Response> => cartBytes(ctx, true);
src/web/pages/builds.ts +6
@@ -107,6 +107,12 @@ export function buildsCard(
107107 <span class="muted small build-meta"
108108 >${n.pin.source} · ${n.pin.pinnedBy} · ${time(n.pin.pinnedAt)}</span
109109 >
110 ${when(
111 n.from === undefined && n.pin.demo?.launch === "file",
112 () => html`<a class="build-cart small" href="${base}/builds/${encodeURIComponent(n.pin.label)}/cart" title="Play the captured page of ${n.pin.demo?.name ?? ""}"
113 >▶ ${n.pin.demo?.name ?? ""} ${n.pin.demo?.version ?? ""}</a
114 >`,
115 )}
110116 ${when(
111117 canPreview && n.from === undefined,
112118 () => html`<form method="post" action="${base}/builds/preview" class="build-preview">
test/arcade-carts.test.ts added +199
@@ -0,0 +1,199 @@
1 /**
2 * Arcade carts: a cart built outside the forge is imported as a pin that
3 * carries its demo, with its page and picture kept once, and its cover
4 * kept by the repository.
5 */
6 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
7 import { existsSync } from "node:fs";
8 import { mkdtemp, rm } from "node:fs/promises";
9 import { tmpdir } from "node:os";
10 import { join } from "node:path";
11 import { commitToBranch, createRepo } from "../src/actions/repos.ts";
12 import { createApp, type App } from "../src/app.ts";
13 import { captureCart, captureState, capturePath, importCapture, keepPage, readArt, readCapture, startCapture } from "../src/arcade/carts.ts";
14 import { importCart } from "../src/arcade/import.ts";
15 import { createUser } from "../src/core/users.ts";
16 import { deletePin, listBuildPins, parsePinMessage, readBuildPin, setPinDemo } from "../src/git/builds.ts";
17 import { repoDir } from "../src/git/repos.ts";
18
19 const PNG = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 5]);
20 const HTML = new TextEncoder().encode("<!doctype html><title>kart</title>");
21 const tagger = { name: "Jay Moran", email: "[email protected]" };
22
23 describe("arcade carts", () => {
24 let dataDir: string;
25 let app: App;
26 let sha = "";
27 let dir = "";
28
29 beforeAll(async () => {
30 dataDir = await mkdtemp(join(tmpdir(), "forge-carts-"));
31 app = createApp({ dataDir, port: 0 });
32 const user = await createUser(app.db, { origin: "seed", trusted: true, username: "alice", email: "[email protected]", password: "a-good-passphrase", adminIfFirst: true });
33 if (!user.ok) throw new Error(user.error);
34 const alice = user.value;
35 const made = await createRepo(app, alice, { name: "karts", visibility: "public" });
36 if (!made.ok) throw new Error(String(made.error));
37 const c = await commitToBranch(app, alice, made.value, {
38 branch: "main", message: "one", files: [{ path: "a.txt", content: "a\n" }],
39 });
40 if (!c.ok) throw new Error(JSON.stringify(c.error));
41 sha = c.value.commitOid;
42 dir = repoDir(app.reposRoot, "alice", "karts");
43 });
44
45 afterAll(async () => {
46 app.close();
47 await rm(dataDir, { recursive: true, force: true });
48 });
49
50 const entry = (over: object = {}) => ({
51 repo: "alice/karts", label: "iter-1", sha, note: "first",
52 cart: { name: "fiesta-kart", version: "1.2", page: "/", shelf: true },
53 html: HTML, cover: PNG, art: PNG, ...over,
54 });
55
56 test("an import pins the commit with its demo and keeps the page, picture and cover", async () => {
57 const done = await importCart(dataDir, entry(), tagger);
58 expect(done.ok && done.value).toEqual({ label: "iter-1", pinned: true, captured: true });
59 const pin = await readBuildPin(dir, "iter-1");
60 expect(pin?.oid).toBe(sha);
61 expect(pin?.pinnedBy).toBe("Jay Moran");
62 expect(pin?.demo).toMatchObject({ name: "fiesta-kart", version: "1.2", launch: "file", page: "/", showcase: true });
63 expect(pin?.demo?.cover).toMatch(/^[0-9a-f]{40,64}$/);
64 expect(readCapture(dataDir, { ownerName: "alice", name: "karts" }, sha, "/")).toEqual(HTML);
65 expect(readArt(dataDir, { ownerName: "alice", name: "karts" }, sha, "/")).toEqual(PNG);
66 });
67
68 test("importing again changes nothing: the page is written once", async () => {
69 const again = await importCart(dataDir, entry({ html: new TextEncoder().encode("other") }), tagger);
70 expect(again.ok && again.value).toEqual({ label: "iter-1", pinned: false, captured: false });
71 expect(readCapture(dataDir, { ownerName: "alice", name: "karts" }, sha, "/")).toEqual(HTML);
72 expect((await listBuildPins(dir)).length).toBe(1);
73 });
74
75 test("an import refuses what is not a repo, a commit, a cover or a page", async () => {
76 expect((await importCart(dataDir, entry({ repo: "../x/y" }), tagger)).ok).toBe(false);
77 expect((await importCart(dataDir, entry({ label: "iter-2", sha: "0".repeat(40) }), tagger)).ok).toBe(false);
78 expect((await importCart(dataDir, entry({ label: "iter-3", cover: new Uint8Array([1, 2, 3]) }), tagger)).ok).toBe(false);
79 expect((await importCart(dataDir, entry({ label: "iter-1", sha: "1".repeat(40) }), tagger)).ok).toBe(false);
80 expect((await importCart(dataDir, entry({ label: "iter-4", cart: { name: "x", version: "1", page: "/../x", shelf: false } }), tagger)).ok).toBe(false);
81 });
82
83 test("a capture path cannot leave the carts directory", () => {
84 const repo = { ownerName: "alice", name: "karts" };
85 expect(importCapture(dataDir, repo, sha, "/../../x", HTML).ok).toBe(false);
86 expect(importCapture(dataDir, repo, "../../etc", "/", HTML).ok).toBe(false);
87 expect(readCapture(dataDir, repo, "../../etc", "/")).toBeUndefined();
88 expect(existsSync(capturePath(dataDir, repo, sha, "/spicy"))).toBe(false);
89 });
90
91 test("a demo survives a rewrite of its pin, and goes when set to none; deleting drops the cover", async () => {
92 const pin = (await readBuildPin(dir, "iter-1"))!;
93 const hid = await setPinDemo(dir, "iter-1", { ...pin.demo!, showcase: false });
94 expect(hid.ok && hid.value.demo?.showcase).toBe(false);
95 const back = (await readBuildPin(dir, "iter-1"))!;
96 expect(back).toMatchObject({ oid: sha, note: "first", pinnedBy: "Jay Moran", source: "import" });
97 expect(back.demo?.showcase).toBe(false);
98 expect((await setPinDemo(dir, "iter-1", { ...back.demo!, name: "bad\nname" })).ok).toBe(false);
99 expect((await setPinDemo(dir, "nope", undefined)).ok).toBe(false);
100 expect((await setPinDemo(dir, "iter-1", undefined)).ok).toBe(true);
101 expect((await readBuildPin(dir, "iter-1"))?.demo).toBeUndefined();
102 expect((await deletePin(dir, "iter-1")).ok).toBe(true);
103 });
104
105 test("old Cart: and Shelf: trailers still read as a demo", () => {
106 const m = parsePinMessage("n\n\nSource: commit\nCart: old\nVersion: 3\nPage: /\nShelf: on\n");
107 expect(m.demo).toMatchObject({ name: "old", launch: "file", showcase: true });
108 });
109 });
110
111 describe("arcade capture", () => {
112 const { mkdtempSync, readFileSync: rf } = require("node:fs") as typeof import("node:fs");
113 const stub = Bun.serve({
114 port: 0,
115 fetch: (r) => {
116 const path = new URL(r.url).pathname;
117 if (path === "/big") return new Response(new Uint8Array(2000));
118 if (path === "/gone") return new Response("no", { status: 404 });
119 return new Response("<p>game</p>");
120 },
121 });
122 const url = (p: string) => `http://127.0.0.1:${stub.port}${p}`;
123 const picture = (calls: string[]) => async (input: { url: string; out: string }) => {
124 calls.push(input.url);
125 await Bun.write(input.out, PNG);
126 return { ok: true, value: {} } as never;
127 };
128
129 test("a page is kept once, with its picture taken by the stand-in", async () => {
130 const base = join(mkdtempSync(join(tmpdir(), "forge-keep-")), "index");
131 const calls: string[] = [];
132 expect((await keepPage(base, url("/"), picture(calls))).ok).toBe(true);
133 expect(rf(`${base}.html`, "utf8")).toBe("<p>game</p>");
134 expect(new Uint8Array(rf(`${base}.png`))).toEqual(PNG);
135 expect(calls).toEqual([url("/")]);
136 expect(existsSync(`${base}.${process.pid}.shot.png`)).toBe(false);
137 });
138
139 test("a missing page, a page over the cap, or a dead server keeps nothing", async () => {
140 const dir = mkdtempSync(join(tmpdir(), "forge-keep-"));
141 const none = picture([]);
142 expect((await keepPage(join(dir, "a"), url("/gone"), none)).ok).toBe(false);
143 expect((await keepPage(join(dir, "b"), url("/big"), none, "", "", "", 1000)).ok).toBe(false);
144 expect((await keepPage(join(dir, "c"), "http://127.0.0.1:1/", none)).ok).toBe(false);
145 expect(existsSync(join(dir, "a.html")) || existsSync(join(dir, "b.html")) || existsSync(join(dir, "c.html"))).toBe(false);
146 });
147
148 test("a failed picture still keeps the page", async () => {
149 const base = join(mkdtempSync(join(tmpdir(), "forge-keep-")), "index");
150 const failed = (async () => ({ ok: false, error: "no chromium" })) as never;
151 expect((await keepPage(base, url("/"), failed)).ok).toBe(true);
152 expect(existsSync(`${base}.html`)).toBe(true);
153 expect(existsSync(`${base}.png`)).toBe(false);
154 });
155
156 const fakeApp = () => createApp({ dataDir: mkdtempSync(join(tmpdir(), "forge-cap-")), port: 0 });
157 const repo = { id: 1, ownerName: "alice", name: "karts" } as never;
158 const OID = "a".repeat(40);
159
160 test("a capture starts the build when it is not running, waits for it, keeps the page and its picture", async () => {
161 const app = fakeApp();
162 let state = "stopped";
163 let starts = 0;
164 const build = {
165 up: () => ({ state, port: stub.port }),
166 start: async () => {
167 starts++;
168 setTimeout(() => (state = "running"), 30);
169 return { ok: true, value: undefined } as const;
170 },
171 };
172 const calls: string[] = [];
173 const run = startCapture(app, undefined, repo, OID, "/", { build, picture: picture(calls), pollMs: 5 });
174 expect(captureState(app, repo, OID, "/")).toEqual({ state: "capturing" });
175 expect((await run).ok).toBe(true);
176 expect(starts).toBe(1);
177 expect(calls).toEqual([url("/")]);
178 expect(captureState(app, repo, OID, "/")).toEqual({ state: "done" });
179 expect(readCapture(app.config.dataDir, repo, OID, "/")).toEqual(new TextEncoder().encode("<p>game</p>"));
180 // Captured once: a second capture neither starts a build nor fetches.
181 expect((await captureCart(app, undefined, repo, OID, "/", { build, picture: picture(calls) })).ok).toBe(true);
182 expect(starts).toBe(1);
183 app.close();
184 });
185
186 test("a build that fails, or never comes up, is a failed capture the state remembers", async () => {
187 const app = fakeApp();
188 const failed = { up: () => ({ state: "failed" }), start: async () => ({ ok: true, value: undefined }) as const };
189 expect(await startCapture(app, undefined, repo, OID, "/", { build: failed, pollMs: 5 })).toEqual({ ok: false, error: "build_failed" });
190 expect(captureState(app, repo, OID, "/")).toMatchObject({ state: "failed", error: "build_failed" });
191 const slow = { up: () => ({ state: "starting" }), start: async () => ({ ok: true, value: undefined }) as const };
192 expect(await captureCart(app, undefined, repo, OID, "/spicy", { build: slow, waitMs: 20, pollMs: 5 })).toEqual({ ok: false, error: "timed_out" });
193 const gone = { up: () => ({ state: "running", port: stub.port }), start: async () => ({ ok: true, value: undefined }) as const };
194 expect(await captureCart(app, undefined, repo, OID, "/gone", { build: gone, picture: picture([]) })).toEqual({ ok: false, error: "page_missing" });
195 // A repository that is no app has no build to capture from.
196 expect(await captureCart(app, undefined, repo, OID, "/")).toEqual({ ok: false, error: "not_an_app" });
197 app.close();
198 });
199 });
test/build-cart.test.ts added +114
@@ -0,0 +1,114 @@
1 /**
2 * A pin's cart through its doors: the API names it and starts its capture,
3 * the builds page links a captured cart, and the cart plays sandboxed to the
4 * repository's readers only.
5 */
6 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
7 import { mkdtemp, rm } from "node:fs/promises";
8 import { tmpdir } from "node:os";
9 import { join } from "node:path";
10 import { pinBuild } from "../src/actions/builds.ts";
11 import { commitToBranch, createRepo } from "../src/actions/repos.ts";
12 import { createApp, type App } from "../src/app.ts";
13 import { importCapture } from "../src/arcade/carts.ts";
14 import { createToken } from "../src/core/tokens.ts";
15 import { createUser, getUserByName } from "../src/core/users.ts";
16 import { serve, type Servers } from "../src/http/server.ts";
17
18 const PNG = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 5]);
19
20 describe("a pin's cart", () => {
21 let dataDir: string;
22 let app: App;
23 let servers: Servers;
24 let base: string;
25 const tokens: Record<string, string> = {};
26 let oid = "";
27
28 const api = (path: string, who?: string, init: RequestInit = {}) =>
29 fetch(`${base}/api/v1/repos/alice/${path}`, {
30 ...init,
31 headers: { ...(who ? { authorization: `Bearer ${tokens[who]}` } : {}), "content-type": "application/json" },
32 });
33
34 beforeAll(async () => {
35 dataDir = await mkdtemp(join(tmpdir(), "forge-cart-"));
36 app = createApp({ dataDir, port: 0 });
37 servers = await serve(app);
38 base = servers.url;
39 for (const name of ["alice", "bob"]) {
40 const made = await createUser(app.db, { origin: "seed", trusted: true, username: name, email: `${name}@forge.local`, password: "a-good-passphrase", adminIfFirst: true });
41 if (!made.ok) throw new Error(made.error);
42 tokens[name] = createToken(app.db, getUserByName(app.db, name)!.id, "t", "rw").token;
43 }
44 const alice = getUserByName(app.db, "alice")!;
45 for (const [name, visibility] of [["karts", "public"], ["secret", "private"]] as const) {
46 const made = await createRepo(app, alice, { name, visibility });
47 if (!made.ok) throw new Error(String(made.error));
48 const c = await commitToBranch(app, alice, made.value, { message: "one", files: [{ path: "a.txt", content: "a\n" }] });
49 if (!c.ok) throw new Error(JSON.stringify(c.error));
50 oid = c.value.commitOid;
51 const pinned = await pinBuild(app, alice, made.value, { label: "v1", oid });
52 if (!pinned.ok) throw new Error(pinned.error);
53 }
54 });
55
56 afterAll(async () => {
57 servers.stop();
58 app.close();
59 await rm(dataDir, { recursive: true, force: true });
60 });
61
62 test("a pin with no cart says so", async () => {
63 const res = await api("karts/builds/v1/cart");
64 expect(res.status).toBe(200);
65 expect(await res.json()).toMatchObject({ label: "v1", demo: null, capture: { state: "none" }, play_url: null });
66 expect((await api("karts/builds/nope/cart")).status).toBe(404);
67 });
68
69 test("a writer names the cart and its capture starts; a reader, a stranger and a bad name are refused", async () => {
70 const send = (who: string | undefined, b: object) => api("karts/builds/v1/cart", who, { method: "POST", body: JSON.stringify(b) });
71 const cart = { name: "fiesta-kart", version: "1", page: "/", showcase: true };
72 expect((await send(undefined, cart)).status).toBe(401);
73 expect((await send("bob", cart)).status).toBe(403);
74 expect((await send("alice", { ...cart, name: "two\nlines" })).status).toBe(422);
75 expect((await send("alice", { ...cart, page: "/../x" })).status).toBe(422);
76 const res = await send("alice", cart);
77 expect(res.status).toBe(202);
78 expect(await res.json()).toMatchObject({ demo: { name: "fiesta-kart", version: "1", launch: "file", page: "/", showcase: true } });
79 // karts is no app, so there is no build to capture from: the state says why.
80 await Bun.sleep(50);
81 expect(await (await api("karts/builds/v1/cart")).json()).toMatchObject({ capture: { state: "failed", error: "not_an_app" } });
82 });
83
84 test("a captured cart plays sandboxed, with its picture, and the builds page links it", async () => {
85 importCapture(dataDir, { ownerName: "alice", name: "karts" }, oid, "/", new TextEncoder().encode("<p>kart</p>"), PNG);
86 expect(await (await api("karts/builds/v1/cart")).json()).toMatchObject({
87 capture: { state: "done" },
88 play_url: "/alice/karts/builds/v1/cart",
89 art_url: "/alice/karts/builds/v1/cart.png",
90 });
91 const played = await fetch(`${base}/alice/karts/builds/v1/cart`);
92 expect(played.status).toBe(200);
93 expect(await played.text()).toBe("<p>kart</p>");
94 const policy = played.headers.get("content-security-policy") ?? "";
95 expect(policy).toStartWith("sandbox allow-scripts");
96 expect(policy).not.toContain("allow-same-origin");
97 expect(played.headers.get("x-content-type-options")).toBe("nosniff");
98 const art = await fetch(`${base}/alice/karts/builds/v1/cart.png`);
99 expect(art.headers.get("content-type")).toBe("image/png");
100 expect(new Uint8Array(await art.arrayBuffer())).toEqual(PNG);
101 const page = await (await fetch(`${base}/alice/karts/builds`)).text();
102 expect(page).toContain('href="/alice/karts/builds/v1/cart"');
103 expect(page).toContain("fiesta-kart");
104 });
105
106 test("a private repository's cart is not found by a stranger", async () => {
107 const send = await api("secret/builds/v1/cart", "alice", { method: "POST", body: JSON.stringify({ name: "hush", version: "1", page: "/", showcase: false }) });
108 expect(send.status).toBe(202);
109 importCapture(dataDir, { ownerName: "alice", name: "secret" }, oid, "/", new TextEncoder().encode("<p>hush</p>"));
110 expect((await api("secret/builds/v1/cart")).status).toBe(404);
111 expect((await fetch(`${base}/alice/secret/builds/v1/cart`)).status).toBe(404);
112 expect((await api("secret/builds/v1/cart", "alice")).status).toBe(200);
113 });
114 });
test/security-floor-routes.test.ts +4
@@ -50,11 +50,14 @@ const HOOK = "needs a webhook, which the seed does not register";
5050
5151 /** GET patterns the seed cannot fill → why. Requested with a placeholder all the same. */
5252 const SHOT = "needs a pin with a picture kept by refs/shots; the seed pins nothing";
53 const CART = "the seed pins no build with a cart (test/build-cart.test.ts covers its headers)";
5354 const UNSEEDED: Record<string, string> = {
5455 "/:owner/-/teams/:team": ORG,
5556 "/talk/:id": TALK,
5657 "/bring/:id/:job": "needs a bring job of the signed-in person; the seed brings nothing",
5758 "/:owner/:name/bets/:id": BET,
59 "/:owner/:name/builds/:label/cart": CART,
60 "/:owner/:name/builds/:label/cart.png": CART,
5861 "/:owner/:name/builds/:label/shots/:file": SHOT,
5962 "/:owner/:name/looks/:env/:commit/:kind.png": LOOK,
6063 "/:owner/:name/milestones/:number/limb/:op": LIMB,
@@ -68,6 +71,7 @@ const UNSEEDED: Record<string, string> = {
6871 "/api/v1/orgs/:org": ORG,
6972 "/api/v1/orgs/:org/teams": ORG,
7073 "/api/v1/orgs/:org/teams/:team/members": ORG,
74 "/api/v1/repos/:owner/:name/builds/:label/cart": CART,
7175 "/api/v1/repos/:owner/:name/builds/:label/shots": SHOT,
7276 "/api/v1/repos/:owner/:name/builds/:label/shots/looks": SHOT,
7377 "/api/v1/repos/:owner/:name/gates/:id/log": GATE,

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs