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

A display's builds are chosen through PUT …/display/builds, validated and same-origin checked #9555

Merged opened this

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

Merged

scout/forge:scout/9467-a-display-s-builds-are-chosen-through-pu → main 3 commits fork 1 thread · 1 unresolved

  • Merged. scout/9467-a-display-s-builds-are-chosen-through-pu was merged into main as a4daf6f.

Review

Sign in to review this change.
12 files changed +523 −35
docs/design/areas/display.md +3 −1
@@ -1,3 +1,5 @@
11 # Display
22
3 A repository's display, `/:owner/:name/display` (`src/web/pages/display.ts`), read only: the builds its `display.json` holds, as a dense table (`.table`, `.table-scroll`) under the repo header, as whoever looks may read them. It adds no component class. Covers, the player, ticking builds and editing the title wait for the submit handlers, `pin_demos.ts` and the Arcade host.
3 A repository's display, `/:owner/:name/display` (`src/web/pages/display.ts`): the builds its `display.json` holds, as a dense table (`.table`, `.table-scroll`) under the repo header, as whoever looks may read them. It adds no component class. Covers, the player, editing the title waits for `pin_demos.ts` and the Arcade host.
4
5 Writers also get a form of `.field` fieldset, `.check` rows, a `btn` and a `.hint` status line: no new component class.↵
docs/features/display.md +3 −1
@@ -7,4 +7,6 @@ One repository's display: the builds its `display.json` (on the default branch)
77 - **Reaching it**: each row of `/displays` links to it.
88 - **Terminal**: in a repository's screen, the palette command "<owner>/<repo> — Display" (`src/tui/screens/display.ts`) shows the same title, layout and builds with the same open/private split, from the same `displayOf` and `displayItems`. `j`/`k` move, `esc` goes back. A repository you may not read is not offered it, and the screen itself says "No display here".
99
10 Not done: ticking builds, editing the title, covers, the player and the Arcade host. See `docs/features/displays.md` for the list.
10 - **Choosing builds**: a person who writes the repository (never an agent) ticks builds and saves, on the page, in the terminal, or over the API. The page form posts to the same page (works without script; with script it sends the PUT); the terminal's Display screen shows `[x]`/`[ ]` rows for a writer, `space` ticks and `s` saves; the API is `PUT /api/v1/repos/:owner/:name/display/builds` with `{ "builds": ["owner/name:label", …], "tip"?: commit }` (rw scope, same-origin for a session). All three call `chooseBuilds` (`src/actions/displays.ts`), which commits `builds` into `display.json`. Each build must be a pin in a repository the writer reads (422 otherwise); a `tip` that is no longer the default branch's commit is a 409. Order is the order shown; reordering waits.
11
12 Not done: editing the title, covers, the player and the Arcade host. See `docs/features/displays.md` for the list.
src/actions/displays.ts added +99
@@ -0,0 +1,99 @@
1 /**
2 * A display's write half: choosing its builds. `display.json` on the default
3 * branch is the record, so a choice is a commit that sets its `builds` list
4 * (`commitToBranch` announces the push, and the display caches drop). The
5 * person must write the repository (never an agent), and every build named
6 * must be a pin in a repository they read.
7 */
8
9 import type { App } from "../app.ts";
10 import { readFileAt } from "../apps/checkout.ts";
11 import { canRead, getRepo, type Repo } from "../core/repos.ts";
12 import type { User } from "../core/users.ts";
13 import { DISPLAY_FILE, parseBuilds, parseDisplay } from "../display/config.ts";
14 import { sourceRepos } from "../display/items.ts";
15 import { writesDisplay } from "../display/selections.ts";
16 import { exactRefOid } from "../git/branches.ts";
17 import { listBuildPins } from "../git/builds.ts";
18 import { isSafeOid } from "../git/guard.ts";
19 import { repoDir } from "../git/repos.ts";
20 import { err, ok, type Result } from "../kernel/result.ts";
21 import { commitToBranch } from "./repos.ts";
22
23 export type ChooseError =
24 | { kind: "forbidden" }
25 | { kind: "no_display" }
26 | { kind: "invalid"; message: string }
27 | { kind: "failed"; message: string };
28
29 const splitKey = (key: string): [string, string, string] => {
30 const at = key.lastIndexOf(":");
31 const [owner = "", name = ""] = key.slice(0, at).split("/");
32 return [owner, name, key.slice(at + 1)];
33 };
34
35 export interface Choice {
36 readonly builds: readonly string[];
37 /** The default branch's commit the chooser saw: the save is refused (409) if it has moved. */
38 readonly tip?: string;
39 }
40
41 /** The body of a PUT, checked: `{ "builds": [owner/name:label, …], "tip"?: commit }`. */
42 export function parseChoice(input: unknown): Result<Choice, string> {
43 if (typeof input !== "object" || input === null || Array.isArray(input)) return err("the body must be a JSON object");
44 const keys = Object.keys(input);
45 if (keys.some((k) => k !== "builds" && k !== "tip")) return err("the body may hold only builds and tip");
46 const tip = (input as { tip?: unknown }).tip;
47 if (tip !== undefined && (typeof tip !== "string" || !isSafeOid(tip))) return err("tip must be a full commit id");
48 const builds = (input as { builds?: unknown }).builds;
49 if (builds === undefined) return err("builds is required");
50 const parsed = parseBuilds(builds);
51 return parsed.ok ? ok({ builds: parsed.value ?? [], ...(tip === undefined ? {} : { tip }) }) : err(parsed.error);
52 }
53
54 /** Set the display's `builds` to `builds`, in that order. */
55 export async function chooseBuilds(app: App, user: User | undefined, repo: Repo, builds: readonly string[], seenTip?: string): Promise<Result<readonly string[], ChooseError>> {
56 if (!writesDisplay(app, repo, user)) return err({ kind: "forbidden" });
57 const dir = repoDir(app.reposRoot, repo.ownerName, repo.name);
58 const tip = await exactRefOid(dir, `refs/heads/${repo.defaultBranch}`);
59 const text = tip === undefined ? undefined : await readFileAt(dir, tip, DISPLAY_FILE);
60 if (tip === undefined || text === undefined) return err({ kind: "no_display" });
61 const config = parseDisplay(text);
62 if (!config.ok) return err({ kind: "invalid", message: config.error });
63
64 const pinsOf = new Map<string, Set<string>>();
65 for (const key of builds) {
66 const [owner, name, label] = splitKey(key);
67 const found = getRepo(app.db, owner, name);
68 // A repository the person cannot read is not there for them.
69 if (found === undefined || !canRead(app.db, found, user)) return err({ kind: "invalid", message: `no build ${key}` });
70 let labels = pinsOf.get(found.id);
71 if (labels === undefined) {
72 labels = new Set((await listBuildPins(repoDir(app.reposRoot, found.ownerName, found.name))).map((p) => p.label));
73 pinsOf.set(found.id, labels);
74 }
75 if (!labels.has(label)) return err({ kind: "invalid", message: `no build ${key}` });
76 }
77
78 const file = { ...(JSON.parse(text) as Record<string, unknown>), builds: [...builds] };
79 const made = await commitToBranch(app, user, repo, {
80 message: "Choose the display's builds",
81 files: [{ path: DISPLAY_FILE, content: `${JSON.stringify(file, null, 2)}\n` }],
82 expectTip: seenTip ?? tip,
83 });
84 if (!made.ok) return err({ kind: "failed", message: "the display could not be saved; reload and try again" });
85 return ok([...builds]);
86 }
87
88 /** Every build the person could add: the pins of the display's sources they read, plus those already chosen. */
89 export async function choosableBuilds(app: App, sources: Parameters<typeof sourceRepos>[1], chosen: readonly string[], user: User | undefined): Promise<string[]> {
90 const out = [...chosen];
91 for (const r of sourceRepos(app, sources)) {
92 if (!canRead(app.db, r, user)) continue;
93 for (const pin of await listBuildPins(repoDir(app.reposRoot, r.ownerName, r.name))) {
94 const key = `${r.ownerName}/${r.name}:${pin.label}`;
95 if (!out.includes(key)) out.push(key);
96 }
97 }
98 return out;
99 }
src/contract/openapi/routes/index.ts +1
@@ -344,6 +344,7 @@ export { route as putApiV1Front } from "./putApiV1Front.ts";
344344 export { route as putApiV1ReposOwnerNameAppEnvVar } from "./putApiV1ReposOwnerNameAppEnvVar.ts";
345345 export { route as putApiV1ReposOwnerNameAppMembers } from "./putApiV1ReposOwnerNameAppMembers.ts";
346346 export { route as putApiV1ReposOwnerNameCrewUsername } from "./putApiV1ReposOwnerNameCrewUsername.ts";
347 export { route as putApiV1ReposOwnerNameDisplayBuilds } from "./putApiV1ReposOwnerNameDisplayBuilds.ts";
347348 export { route as putApiV1ReposOwnerNameIssuesNumberAssignee } from "./putApiV1ReposOwnerNameIssuesNumberAssignee.ts";
348349 export { route as putApiV1ReposOwnerNameIssuesNumberLabels } from "./putApiV1ReposOwnerNameIssuesNumberLabels.ts";
349350 export { route as putApiV1ReposOwnerNameIssuesNumberPlan } from "./putApiV1ReposOwnerNameIssuesNumberPlan.ts";
src/contract/openapi/routes/putApiV1ReposOwnerNameDisplayBuilds.ts added +15
@@ -0,0 +1,15 @@
1 import { REPO, type Route } from "../shared.ts";
2
3 export const route: Route = {
4 method: "put",
5 path: `${REPO}/display/builds`,
6 summary: "Choose the builds a repository's display shows, in order (rw scope, a person who writes it); commits display.json; answers the builds",
7 auth: true,
8 body: { builds: "array" },
9 refusals: {
10 bad_json: "the body is not JSON (400)",
11 invalid: "the body is malformed or names a build that is not there (422)",
12 no_display: "the repository has no display.json (404)",
13 too_large: "the body is too large (413)",
14 },
15 };
src/http/api.ts +2
@@ -342,6 +342,7 @@ import { keeperRoutes } from "./api/keeper.ts";
342342 import { agentRoutes } from "./api/agents.ts";
343343 import { subscriptionRoutes } from "./api/subscriptions.ts";
344344 import { boardRoutes } from "./api/boards.ts";
345 import { displayRoutes } from "./api/displays.ts";
345346 import { followingRoutes } from "./api/following.ts";
346347 import { labelsRoutes } from "./api/labels.ts";
347348 import { visionRoutes } from "./api/vision.ts";
@@ -1271,6 +1272,7 @@ export function apiRoutes(router: Router<Handler>): void {
12711272 });
12721273
12731274 boardRoutes(router);
1275 displayRoutes(router);
12741276
12751277 // A domain drill and its verdict (`kernel/domain_drill.ts`): an admin
12761278 // person's, kept whether green or red.
src/http/api/displays.ts added +43
@@ -0,0 +1,43 @@
1 /**
2 * A repository's display over the API: `PUT …/display/builds` chooses its
3 * builds. The rules live in `actions/displays.ts`; this parses and shapes.
4 */
5
6 import { chooseBuilds, parseChoice } from "../../actions/displays.ts";
7 import type { Handler } from "../context.ts";
8 import { loadRepo } from "../api.ts";
9 import { bodyCapFor, readBounded } from "../limits.ts";
10 import { apiError, forbidden, json, notFound, requireScope } from "../respond.ts";
11 import type { Router } from "../router.ts";
12
13 export function displayRoutes(router: Router<Handler>): void {
14 router.add("PUT", "/api/v1/repos/:owner/:name/display/builds", async (ctx) => {
15 // Sign-in and, for a browser's session, same-origin.
16 const denied = requireScope(ctx, "rw");
17 if (denied) return denied;
18 const repo = loadRepo(ctx);
19 if (!repo) return notFound();
20 const bytes = await readBounded(ctx.request, bodyCapFor(ctx.request));
21 if (bytes === "too_large") return apiError("too_large", 413, "that is too large to read here");
22 let input: unknown;
23 try {
24 input = JSON.parse(new TextDecoder().decode(bytes));
25 } catch {
26 return apiError("bad_json", 400, "the body must be JSON: { \"builds\": [\"owner/name:label\"] }");
27 }
28 const choice = parseChoice(input);
29 if (!choice.ok) return apiError("invalid", 422, choice.error);
30 const done = await chooseBuilds(ctx.app, ctx.auth.user, repo, choice.value.builds, choice.value.tip);
31 if (done.ok) return json({ builds: done.value });
32 switch (done.error.kind) {
33 case "forbidden":
34 return forbidden();
35 case "no_display":
36 return notFound();
37 case "invalid":
38 return apiError("invalid", 422, done.error.message);
39 default:
40 return apiError("failed", 409, done.error.message);
41 }
42 });
43 }
src/http/server.ts +2 −1
@@ -35,7 +35,7 @@ import { boardNew, boardPin, boardPost, pinPost } from "../web/pages/boards-edit
3535 import { boardsHoldPost } from "../web/pages/boards-holds.ts";
3636 import { nowPage } from "../web/pages/now.ts";
3737 import { myReposPage } from "../web/pages/my-repos.ts";
38 import { displayPage } from "../web/pages/display.ts";
38 import { displayChoose, displayPage } from "../web/pages/display.ts";
3939 import { displaysPage } from "../web/pages/displays.ts";
4040 import { boardLive, boardShow, boardsList } from "../web/pages/boards-view.ts";
4141 import { actingVia } from "../core/acting_via.ts";
@@ -846,6 +846,7 @@ export function buildRouter(): Router<Handler> {
846846 router.add("GET", "/displays", displaysPage);
847847 // A repository's display, read only (`display.ts`): 404 without a display.json or without read access.
848848 router.add("GET", "/:owner/:name/display", displayPage);
849 router.add("POST", "/:owner/:name/display", displayChoose);
849850 router.add("GET", "/now/:stage", nowPage);
850851 router.add("GET", "/audit", auditPage);
851852 router.add("GET", "/releases", releasesPage);
src/tui/screens/display.ts +61 −19
@@ -1,11 +1,18 @@
11 /**
2 * A repository's display (`/<owner>/<repo>/display` on the web), read only:
2 * A repository's display (`/<owner>/<repo>/display` on the web):
33 * its title and layout, then its builds as `displayPage` shows them — a build
44 * whose repository the viewer reads names it, any other says "private" and
55 * nothing more. Read through the same `displayOf` and `displayItems`, and
66 * gated here as well as on the command, since the screen is exported.
7 * Whoever writes the repository (`writesDisplay`) sees the builds they could
8 * choose instead, `space` ticks one and `s` saves through `chooseBuilds`, the
9 * same write the web form and the API make.
710 */
811
12 import { chooseBuilds, choosableBuilds } from "../../actions/displays.ts";
13 import { writesDisplay } from "../../display/selections.ts";
14 import { selectionKeys } from "../../display/items.ts";
15
916 import { canRead, type Repo } from "../../core/repos.ts";
1017 import { displayItems, type DisplayItem } from "../../display/items.ts";
1118 import { INVALID_TITLE, displayOf } from "../../display/known.ts";
@@ -28,20 +35,41 @@ export function displayScreen(f: Session, ctx: Ctx, repo: Repo): Screen {
2835 let head: string | undefined;
2936 let lines: string[] = [];
3037 let missing = false;
38 // A writer's view: every build they could choose, and which are ticked.
39 let choices: string[] | undefined;
40 const ticked = new Set<string>();
41 let note = "";
3142 const state = new ListState();
43 // What each build reads as, by key, so a choice shows its name.
44 const named = new Map<string, string>();
45 const read = async (): Promise<void> => {
46 const config = canRead(f.db, repo, f.user) ? await displayOf(f.app, repo) : undefined;
47 missing = config === undefined;
48 if (config !== undefined) {
49 head = config.ok ? `${config.value.title} · ${config.value.layout}` : INVALID_TITLE;
50 const items = config.ok ? await displayItems(f.app, repo, config.value, f.user) : [];
51 lines = config.ok ? items.map(itemLine) : [config.error];
52 named.clear();
53 for (const it of items) named.set(it.key, itemLine(it));
54 choices = undefined;
55 if (config.ok && writesDisplay(f.app, repo, f.user)) {
56 const chosen = await selectionKeys(f.app, config.value, f.user);
57 choices = await choosableBuilds(f.app, config.value, chosen, f.user);
58 ticked.clear();
59 for (const k of chosen) ticked.add(k);
60 }
61 }
62 state.clamp((choices ?? lines).length);
63 ctx.invalidate();
64 };
3265 return {
3366 title: "Display",
34 keys: [binding(["j", "k"], "move"), binding(["esc", "q"], "back")],
35 async load() {
36 const config = canRead(f.db, repo, f.user) ? await displayOf(f.app, repo) : undefined;
37 missing = config === undefined;
38 if (config !== undefined) {
39 head = config.ok ? `${config.value.title} · ${config.value.layout}` : INVALID_TITLE;
40 lines = config.ok ? (await displayItems(f.app, repo, config.value, f.user)).map(itemLine) : [config.error];
41 }
42 state.clamp(lines.length);
43 ctx.invalidate();
67 get keys() {
68 return choices === undefined
69 ? [binding(["j", "k"], "move"), binding(["esc", "q"], "back")]
70 : [binding(["j", "k"], "move"), binding(["space"], "tick"), binding(["s"], "save"), binding(["esc", "q"], "back")];
4471 },
72 load: read,
4573 render(area, s) {
4674 const c = ctx.theme.c;
4775 const right = area.x + area.w;
@@ -49,22 +77,36 @@ export function displayScreen(f: Session, ctx: Ctx, repo: Repo): Screen {
4977 if (missing) return void line(0, NO_DISPLAY_LINE);
5078 if (head === undefined) return void line(0, "Reading…");
5179 line(0, head);
52 if (lines.length === 0) return void line(1, NO_BUILDS_LINE, c.mutedFg);
53 const rows = Math.max(0, area.h - 1);
54 const from = state.window(rows, lines.length);
80 const shown = choices === undefined ? lines : choices.map((k) => `[${ticked.has(k) ? "x" : " "}] ${named.get(k) ?? k}`);
81 if (note !== "") line(1, note, c.mutedFg);
82 if (shown.length === 0) return void line(1, NO_BUILDS_LINE, c.mutedFg);
83 const top = note === "" ? 0 : 1;
84 const rows = Math.max(0, area.h - 1 - top);
85 const from = state.window(rows, shown.length);
5586 for (let i = 0; i < rows; i++) {
56 const l = lines[from + i];
87 const l = shown[from + i];
5788 if (l === undefined) break;
5889 const picked = from + i === state.index;
59 s.text(area.x, area.y + 1 + i, picked ? ">" : " ", style(c.accent, c.bg), right);
60 line(1 + i, l, picked ? c.fg : c.mutedFg);
90 s.text(area.x, area.y + 1 + top + i, picked ? ">" : " ", style(c.accent, c.bg), right);
91 line(1 + top + i, l, picked ? c.fg : c.mutedFg);
6192 }
6293 },
6394 key(e) {
64 const n = lines.length;
95 const n = (choices ?? lines).length;
6596 if (e.key === "j" || e.key === "down") state.move(1, n);
6697 else if (e.key === "k" || e.key === "up") state.move(-1, n);
67 else if (e.key === "esc" || e.key === "q") ctx.pop();
98 else if (choices !== undefined && e.key === " ") {
99 const k = choices[state.index];
100 if (k !== undefined) void (ticked.delete(k) || ticked.add(k));
101 } else if (choices !== undefined && e.key === "s") {
102 // Chosen builds keep their order; newly ticked ones follow.
103 const keys = choices.filter((k) => ticked.has(k));
104 void ctx.track(chooseBuilds(f.app, f.user, repo, keys).then(async (done) => {
105 note = done.ok ? "Saved." : done.error.kind === "invalid" || done.error.kind === "failed" ? done.error.message : "Not saved.";
106 if (done.ok) await read();
107 ctx.invalidate();
108 }));
109 } else if (e.key === "esc" || e.key === "q") ctx.pop();
68110 else return false;
69111 ctx.invalidate();
70112 return true;
src/web/pages/display.ts +68 −13
@@ -1,24 +1,41 @@
11 /**
2 * A repository's display page, `/<owner>/<repo>/display`, read only: the
3 * builds its `display.json` holds (`display/config.ts`) as whoever is
4 * looking may read them, after Jay Moran's page. A build whose repository
5 * the viewer cannot read shows its name and version alone. Ticking builds,
6 * editing the title, covers and the player wait for the submit handlers and
7 * the Arcade host. A bad `display.json` shows its error, never a guess.
2 * A repository's display page, `/<owner>/<repo>/display`: the builds its
3 * `display.json` holds (`display/config.ts`), as whoever is looking may read
4 * them; a build whose repository the viewer cannot read shows its name and
5 * version alone. A writer also gets a form to choose the builds, which works
6 * without script (POST here) and, with script, sends the API's PUT. Editing
7 * the title, covers and the player wait. A bad `display.json` shows its error.
88 */
99
1010 import { notePhase, takeSessionTime, timePhase, timePhaseAsync } from "../../core/route_timing.ts";
11 import { displayItems } from "../../display/items.ts";
11 import { chooseBuilds, choosableBuilds, parseChoice } from "../../actions/displays.ts";
12 import { displayItems, selectionKeys } from "../../display/items.ts";
13 import { writesDisplay } from "../../display/selections.ts";
14 import { exactRefOid } from "../../git/branches.ts";
15 import { repoDir } from "../../git/repos.ts";
1216 import { INVALID_TITLE, displayOf } from "../../display/known.ts";
1317 import { repoShotsUrl } from "../../display/launch.ts";
1418 import type { Ctx } from "../../http/context.ts";
15 import { crumbsFor, loadRepo, notFoundPage, repoBase, repoHeader, repoPalette } from "../chrome.ts";
16 import { html } from "../html.ts";
17 import { page } from "../layout.ts";
18 import { empty } from "../ui.ts";
19 import { crumbsFor, forbiddenPage, loadRepo, notFoundPage, repoBase, repoHeader, repoPalette } from "../chrome.ts";
20 import { html, raw } from "../html.ts";
21 import { flashTo, page } from "../layout.ts";
22 import { btn, empty } from "../ui.ts";
23 import { readForm } from "../form.ts";
1924
2025 const LAYOUT_WORD = { shelf: "Shelf", grid: "Grid", graph: "Graph" } as const;
2126
27 /** Saving the form is a PUT; without script the checkboxes still show what is chosen. */
28 const CHOOSE_JS = `document.querySelectorAll("form[data-choose]").forEach(function (f) {
29 f.addEventListener("submit", function (e) {
30 e.preventDefault();
31 var s = f.querySelector("[data-choose-status]");
32 var builds = Array.prototype.map.call(f.querySelectorAll("input[name=build]:checked"), function (i) { return i.value; });
33 fetch(f.getAttribute("data-choose"), { method: "PUT", headers: { "content-type": "application/json" }, body: JSON.stringify(f.elements.tip ? { builds: builds, tip: f.elements.tip.value } : { builds: builds }), credentials: "same-origin" })
34 .then(function (r) { return r.ok ? location.reload() : r.json().then(function (j) { s.textContent = j.message || "Not saved"; }); })
35 .catch(function () { s.textContent = "Not saved"; });
36 });
37 });`;
38
2239 // GET /:owner/:name/display — 404 for a repository with no display.json or one the viewer cannot read.
2340 export async function displayPage(ctx: Ctx): Promise<Response> {
2441 notePhase(ctx.request, "session", takeSessionTime(ctx.request));
@@ -27,6 +44,10 @@ export async function displayPage(ctx: Ctx): Promise<Response> {
2744 const config = await timePhaseAsync(ctx.request, "display", () => displayOf(ctx.app, repo));
2845 if (config === undefined) return timePhase(ctx.request, "render", () => notFoundPage(ctx));
2946 const items = config.ok ? await timePhaseAsync(ctx.request, "items", () => displayItems(ctx.app, repo, config.value, ctx.auth.user)) : [];
47 const writes = config.ok && writesDisplay(ctx.app, repo, ctx.auth.user);
48 const chosen = writes && config.ok ? await selectionKeys(ctx.app, config.value, ctx.auth.user) : [];
49 const choices = writes && config.ok ? await choosableBuilds(ctx.app, config.value, chosen, ctx.auth.user) : [];
50 const tip = writes ? await exactRefOid(repoDir(ctx.app.reposRoot, repo.ownerName, repo.name), `refs/heads/${repo.defaultBranch}`) : undefined;
3051 const title = config.ok ? config.value.title : INVALID_TITLE;
3152 const body = html`${repoHeader(ctx, repo, "code", { compact: true })}
3253 <div class="page-head mt">
@@ -59,8 +80,42 @@ export async function displayPage(ctx: Ctx): Promise<Response> {
5980 )}
6081 </tbody>
6182 </table>
62 </div>`}`;
83 </div>`}
84 ${writes
85 ? html`<form class="mt" method="post" action="${repoBase(repo)}/display" data-choose="/api/v1/repos/${repo.ownerName}/${repo.name}/display/builds">
86 <fieldset class="field">
87 <legend>Choose builds</legend>
88 ${choices.length === 0
89 ? html`<p class="hint">No builds to choose from yet.</p>`
90 : choices.map(
91 (key) => html`<label class="check"><input type="checkbox" name="build" value="${key}" ${chosen.includes(key) ? raw("checked") : null} />
outdated

No-JS submit goes to action="" as a POST nobody handles. Add a POST handler or hide the button without script.

92 <span class="mono">${key}</span></label>`,
93 )}
94 </fieldset>
95 ${tip === undefined ? null : html`<input type="hidden" name="tip" value="${tip}" />`}
96 ${btn("Save builds", { variant: "primary" })}
97 <p class="hint" role="status" data-choose-status></p>
98 </form>`
99 : null}`;
63100 return timePhase(ctx.request, "render", () =>
64 page({ ctx, title: `Display · ${repo.name}`, crumbs: crumbsFor(repo), palette: repoPalette(ctx, repo), body }),
101 page({ ctx, title: `Display · ${repo.name}`, ...(writes ? { scriptJs: CHOOSE_JS } : {}), crumbs: crumbsFor(repo), palette: repoPalette(ctx, repo), body }),
65102 );
66103 }
104
105 // POST /:owner/:name/display — the form without script: choose the ticked builds, then back to the page.
106 export async function displayChoose(ctx: Ctx): Promise<Response> {
107 const form = await readForm(ctx, "rw");
108 if (form instanceof Response) return form;
109 const repo = loadRepo(ctx);
110 if (!repo) return notFoundPage(ctx);
111 const back = `${repoBase(repo)}/display`;
112 const picked = form.getAll("build").filter((v): v is string => typeof v === "string");
113 const tip = form.get("tip");
114 const parsed = parseChoice({ builds: picked, ...(typeof tip === "string" && tip !== "" ? { tip } : {}) });
115 if (!parsed.ok) return flashTo(ctx, back, { tone: "danger", message: parsed.error });
116 const done = await chooseBuilds(ctx.app, ctx.auth.user, repo, parsed.value.builds, parsed.value.tip);
117 if (done.ok) return flashTo(ctx, back, { message: "Builds saved." });
118 if (done.error.kind === "no_display") return notFoundPage(ctx);
119 if (done.error.kind === "forbidden") return forbiddenPage(ctx, "Only a person who writes this repository can choose its builds.");
120 return flashTo(ctx, back, { tone: "danger", message: done.error.message });
121 }
test/displays.test.ts added +180
@@ -0,0 +1,180 @@
1 /** `PUT /api/v1/repos/:owner/:name/display/builds`: choosing a display's builds. */
2 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
3 import { mkdtemp, rm } from "node:fs/promises";
4 import { tmpdir } from "node:os";
5 import { join } from "node:path";
6 import { addToCrew, createAgent } from "../src/actions/crew.ts";
7 import { setRole } from "../src/actions/roles.ts";
8 import { createToken } from "../src/core/tokens.ts";
9 import { createApp, type App } from "../src/app.ts";
10 import { commitToBranch, createRepo } from "../src/actions/repos.ts";
11 import { getRepo } from "../src/core/repos.ts";
12 import { createSession } from "../src/core/sessions.ts";
13 import { createUser, type User } from "../src/core/users.ts";
14 import { pinBuild } from "../src/git/builds.ts";
15 import { repoDir } from "../src/git/repos.ts";
16 import { serve, type Servers } from "../src/http/server.ts";
17 import { proveAll } from "./helpers/verified.ts";
18
19 describe("choosing a display's builds", () => {
20 let dataDir: string;
21 let app: App;
22 let servers: Servers;
23 let alice: User;
24 let bob: User;
25 const url = "/api/v1/repos/alice/karts/display/builds";
26
27 const cookie = (u: User) => `forge_session=${createSession(app.db, u.id).token}`;
28 const get = (path: string, u: User) => fetch(`${servers.url}${path}`, { headers: { cookie: cookie(u) } });
29 const put = (body: string, u: User, origin?: string) =>
30 fetch(`${servers.url}${url}`, {
31 method: "PUT",
32 headers: { cookie: cookie(u), "content-type": "application/json", ...(origin === undefined ? {} : { origin }) },
33 body,
34 });
35
36 beforeAll(async () => {
37 dataDir = await mkdtemp(join(tmpdir(), "forge-displays-"));
38 app = createApp({ dataDir, port: 0 });
39 servers = await serve(app);
40 for (const name of ["alice", "bob"]) {
41 const made = await createUser(app.db, { origin: "seed", trusted: true, username: name, email: `${name}@forge.local`, password: "a-good-passphrase", adminIfFirst: true });
42 if (!made.ok) throw new Error(made.error);
43 if (name === "alice") alice = made.value;
44 else bob = made.value;
45 }
46 proveAll(app);
47 const made = await createRepo(app, alice, { name: "karts", visibility: "public" });
48 if (!made.ok) throw new Error(String(made.error));
49 const repo = getRepo(app.db, "alice", "karts")!;
50 const commit = async (path: string, content: string) => {
51 const c = await commitToBranch(app, alice, repo, { message: path, files: [{ path, content }] });
52 if (!c.ok) throw new Error(JSON.stringify(c.error));
53 return c.value.commitOid;
54 };
55 const oid = await commit("a.txt", "a");
56 for (const label of ["fiesta", "encore"]) {
57 const pinned = await pinBuild(repoDir(app.reposRoot, "alice", "karts"), {
58 label, oid, note: "", source: "commit", tagger: { name: "alice", email: "[email protected]" }, at: 1_800_000_000,
59 demo: { name: label === "fiesta" ? "krazy-karts" : "encore-karts", version: "1.0", page: "/", showcase: true, launch: "shots" },
60 });
61 if (!pinned.ok) throw new Error(pinned.error);
62 }
63 await commit("display.json", JSON.stringify({ title: "Kart night", layout: "grid", sources: ["alice/karts"], builds: ["alice/karts:fiesta"] }));
64 }, 60_000);
65
66 afterAll(async () => {
67 servers.stop();
68 app.close();
69 await rm(dataDir, { recursive: true, force: true });
70 });
71
72 test("a writer's form is on the page, a reader's is not", async () => {
73 expect(await (await get("/alice/karts/display", alice)).text()).toContain("data-choose=");
74 expect(await (await get("/alice/karts/display", bob)).text()).not.toContain("data-choose=");
75 });
76
77 test("a malformed body is a 422, bad JSON a 400, and nothing changes", async () => {
78 expect((await put('{"builds":"x"}', alice)).status).toBe(422);
79 expect((await put('{"builds":["nonsense"]}', alice)).status).toBe(422);
80 expect((await put('{"builds":["alice/karts:nope"]}', alice)).status).toBe(422);
81 expect((await put('{"builds":["alice/karts:fiesta","alice/karts:fiesta"]}', alice)).status).toBe(422);
82 expect((await put('{"builds":[],"title":"x"}', alice)).status).toBe(422);
83 expect((await put("not json", alice)).status).toBe(400);
84 const page = await (await get("/alice/karts/display", bob)).text();
85 expect(page).toContain("krazy-karts");
86 expect(page).not.toContain("encore-karts");
87 });
88
89 test("a cross-origin PUT and a reader's PUT are refused", async () => {
90 const body = '{"builds":["alice/karts:encore"]}';
91 expect((await put(body, alice, "https://evil.example")).status).toBe(403);
92 expect((await put(body, bob)).status).toBe(403);
93 expect((await fetch(`${servers.url}${url}`, { method: "PUT", body })).status).toBe(401);
94 expect(await (await get("/alice/karts/display", bob)).text()).not.toContain("encore-karts");
95 });
96
97 test("a good PUT changes what the display page shows, after a reload", async () => {
98 const res = await put('{"builds":["alice/karts:encore"]}', alice);
99 expect(res.status).toBe(200);
100 expect(await res.json()).toEqual({ builds: ["alice/karts:encore"] });
101 const page = await (await get("/alice/karts/display", bob)).text();
102 expect(page).toContain("encore-karts");
103 expect(page).not.toContain("krazy-karts");
104 // The form for the writer holds the choice.
105 expect(await (await get("/alice/karts/display", alice)).text()).toMatch(/value="alice\/karts:encore"\s+checked/);
106 });
107
108 test("an agent that writes the repository is refused, a stale tip is a 409, a good tip saves", async () => {
109 const bot = await createAgent(app, alice, { username: "botty" });
110 if (!bot.ok) throw new Error(String(bot.error));
111 const added = addToCrew(app, alice, getRepo(app.db, "alice", "karts")!, "botty");
112 if (!added.ok) throw new Error(String(added.error));
113 const token = createToken(app.db, bot.value.user.id, "t", "rw").token;
114 const agent = await fetch(`${servers.url}${url}`, {
115 method: "PUT",
116 headers: { authorization: `Bearer ${token}`, "content-type": "application/json" },
117 body: '{"builds":["alice/karts:fiesta"]}',
118 });
119 expect(agent.status).toBe(403);
120
121 const stale = await put(`{"builds":["alice/karts:fiesta"],"tip":"${"0".repeat(40)}"}`, alice);
122 expect(stale.status).toBe(409);
123 expect((await put('{"builds":[],"tip":"nope"}', alice)).status).toBe(422);
124 const tip = /name="tip" value="([0-9a-f]{40})"/.exec(await (await get("/alice/karts/display", alice)).text())?.[1];
125 expect(tip).toBeDefined();
126 expect((await put(`{"builds":["alice/karts:fiesta"],"tip":"${tip}"}`, alice)).status).toBe(200);
127 });
128
129 test("a build in a repository the caller cannot read is a 422", async () => {
130 const vault = await createRepo(app, alice, { name: "vault", visibility: "private" });
131 if (!vault.ok) throw new Error(String(vault.error));
132 const v = getRepo(app.db, "alice", "vault")!;
133 const c = await commitToBranch(app, alice, v, { message: "v", files: [{ path: "v.txt", content: "v" }] });
134 if (!c.ok) throw new Error(JSON.stringify(c.error));
135 const pinned = await pinBuild(repoDir(app.reposRoot, "alice", "vault"), {
136 label: "hush", oid: c.value.commitOid, note: "", source: "commit", tagger: { name: "alice", email: "[email protected]" }, at: 1_800_000_000,
137 });
138 if (!pinned.ok) throw new Error(pinned.error);
139 const made = await createUser(app.db, { origin: "seed", trusted: true, username: "carol", email: "[email protected]", password: "a-good-passphrase" });
140 if (!made.ok) throw new Error(made.error);
141 proveAll(app);
142 const member = setRole(app, alice, "carol", "member");
143 if (!member.ok) throw new Error(member.error);
144 const show = await createRepo(app, made.value, { name: "show", visibility: "public" });
145 if (!show.ok) throw new Error(String(show.error));
146 const d = await commitToBranch(app, made.value, getRepo(app.db, "carol", "show")!, { message: "d", files: [{ path: "display.json", content: JSON.stringify({ title: "Show", layout: "grid", sources: ["carol/show"] }) }] });
147 if (!d.ok) throw new Error(JSON.stringify(d.error));
148 const res = await fetch(`${servers.url}/api/v1/repos/carol/show/display/builds`, {
149 method: "PUT",
150 headers: { cookie: cookie(made.value), "content-type": "application/json" },
151 body: '{"builds":["alice/vault:hush"]}',
152 });
153 expect(res.status).toBe(422);
154 // The owner of the private repository may name it.
155 const own = await fetch(`${servers.url}${url}`, {
156 method: "PUT",
157 headers: { cookie: cookie(alice), "content-type": "application/json" },
158 body: '{"builds":["alice/vault:hush"]}',
159 });
160 expect(own.status).toBe(200);
161 });
162
163 test("the form works without script: a same-origin POST saves and redirects, a cross-origin one is refused", async () => {
164 const post = (u: User, origin?: string) =>
165 fetch(`${servers.url}/alice/karts/display`, {
166 method: "POST",
167 headers: { cookie: cookie(u), "content-type": "application/x-www-form-urlencoded", ...(origin === undefined ? {} : { origin }) },
168 body: "build=alice%2Fkarts%3Aencore",
169 redirect: "manual",
170 });
171 expect((await post(alice, "https://evil.example")).status).toBe(403);
172 expect((await post(bob)).status).toBe(403);
173 const res = await post(alice, servers.url);
174 expect(res.status).toBe(303);
175 expect(res.headers.get("location")).toBe("/alice/karts/display");
176 const page = await (await get("/alice/karts/display", bob)).text();
177 expect(page).toContain("encore-karts");
178 expect(page).not.toContain("krazy-karts");
179 });
180 });
test/tui-display.test.ts +46
@@ -7,6 +7,7 @@ import { commitToBranch, createRepo } from "../src/actions/repos.ts";
77 import { createApp, type App } from "../src/app.ts";
88 import { getRepo, type Repo } from "../src/core/repos.ts";
99 import { createUser, type User } from "../src/core/users.ts";
10 import { readDisplay } from "../src/display/config.ts";
1011 import { pinBuild } from "../src/git/builds.ts";
1112 import { repoDir } from "../src/git/repos.ts";
1213 import { createForgeApp } from "../src/tui/forge.ts";
@@ -95,3 +96,48 @@ test("the command is offered for a readable repository only", () => {
9596 expect(displayCommands(session, tui.ctx, repo("vault"))).toEqual([]);
9697 expect(displayCommands(session, tui.ctx, repo("karts")).map((c) => c.id)).toEqual(["display"]);
9798 });
99
100 test("a writer ticks a build with space and saves it with s; a reader cannot", async () => {
101 const made = await createRepo(app, alice, { name: "pick", visibility: "public" });
102 if (!made.ok) throw new Error(String(made.error));
103 const c = await commitToBranch(app, alice, repo("pick"), { message: "x", files: [{ path: "x.txt", content: "x" }] });
104 if (!c.ok) throw new Error(JSON.stringify(c.error));
105 for (const label of ["one", "two"]) {
106 const p = await pinBuild(repoDir(app.reposRoot, "alice", "pick"), {
107 label, oid: c.value.commitOid, note: "", source: "commit", tagger: { name: "alice", email: "[email protected]" }, at: 1_800_000_000,
108 demo: { name: `${label}-game`, version: "1.0", page: "/", showcase: true, launch: "shots" },
109 });
110 if (!p.ok) throw new Error(p.error);
111 }
112 const d = await commitToBranch(app, alice, repo("pick"), { message: "d", files: [{ path: "display.json", content: JSON.stringify({ title: "Pick", layout: "grid", sources: ["alice/pick"], builds: ["alice/pick:one"] }) }] });
113 if (!d.ok) throw new Error(JSON.stringify(d.error));
114
115 const open = async (user: User) => {
116 const terminal = headlessTerminal(100, 30);
117 const { app: tui, session } = createForgeApp(app, { ...guest, user }, { terminal, exitHooks: false });
118 tui.start();
119 const screen = displayScreen(session, tui.ctx, repo("pick"));
120 tui.ctx.push(screen);
121 await settleApp(tui);
122 return { tui, screen };
123 };
124 const press = (screen: ReturnType<typeof displayScreen>, key: string) => screen.key?.({ key } as never);
125
126 const reader = await open(bob);
127 expect(reader.tui.screen.toText()).not.toContain("[x]");
128 expect(press(reader.screen, " ")).toBe(false);
129 expect(press(reader.screen, "s")).toBe(false);
130 reader.tui.stop?.();
131
132 const writer = await open(alice);
133 expect(writer.tui.screen.toText()).toContain("[x] one-game");
134 expect(writer.tui.screen.toText()).toContain("[ ] alice/pick:two");
135 press(writer.screen, "j");
136 press(writer.screen, " ");
137 press(writer.screen, "s");
138 await settleApp(writer.tui);
139 expect(writer.tui.screen.toText()).toContain("Saved.");
140 const read = await readDisplay(app, repo("pick"));
141 expect(read?.ok && read.value.builds).toEqual(["alice/pick:one", "alice/pick:two"]);
142 writer.tui.stop?.();
143 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs