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

`/:owner/:name/display` shows a repository's display on main, read-only #9540

Merged opened this

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

Merged

scout/forge:scout/9531-owner-name-display-shows-a-repository-s → main 3 commits fork 1 thread · 1 unresolved

  • Merged. scout/9531-owner-name-display-shows-a-repository-s was merged into main as f8fbcf9.

Review

Sign in to review this change.
10 files changed +379 −1
docs/design/areas/display.md added +3
@@ -0,0 +1,3 @@
1 # Display
2
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.
docs/features/display.md added +10
@@ -0,0 +1,10 @@
1 # Display
2
3 One repository's display: the builds its `display.json` (on the default branch) shows. Read only.
4
5 - **Page**: `/<owner>/<repo>/display`. Server-rendered. The title and layout, then one row per build: name, version, and, when you may read the build's repository, links to its screenshots and the repository. A build whose repository you cannot read shows its name and version and "Private", nothing more. A `display.json` that does not parse shows its error.
6 - **404**: a repository with no `display.json`, one that does not exist, and one you may not read all answer the same 404.
7 - **Reaching it**: each row of `/displays` links to it.
8 - **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".
9
10 Not done: ticking builds, editing the title, covers, the player and the Arcade host. See `docs/features/displays.md` for the list.
src/display/launch.ts added +13
@@ -0,0 +1,13 @@
1 /**
2 * Where a display item opens, as far as main reads it: a pin's screenshots
3 * on its own repository. Jay Moran's launch.ts (`origin/incoming/jay-main`)
4 * also signs grants for captured pages on the display origin and names the
5 * covers and downloads; those wait for the Arcade host and for `pin_demos.ts`.
6 */
7
8 import type { Repo } from "../core/repos.ts";
9 import type { BuildPin } from "../git/builds.ts";
10
11 /** A pin's screenshots, on its repository. */
12 export const repoShotsUrl = (repo: Pick<Repo, "ownerName" | "name">, pin: Pick<BuildPin, "label">): string =>
13 `/${repo.ownerName}/${repo.name}/builds/${encodeURIComponent(pin.label)}/shots`;
src/http/server.ts +3
@@ -35,6 +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";
3839 import { displaysPage } from "../web/pages/displays.ts";
3940 import { boardLive, boardShow, boardsList } from "../web/pages/boards-view.ts";
4041 import { actingVia } from "../core/acting_via.ts";
@@ -838,6 +839,8 @@ export function buildRouter(): Router<Handler> {
838839 router.add("GET", "/new/issue", newIssueAnywhere);
839840 router.add("GET", "/repos", myReposPage);
840841 router.add("GET", "/displays", displaysPage);
842 // A repository's display, read only (`display.ts`): 404 without a display.json or without read access.
843 router.add("GET", "/:owner/:name/display", displayPage);
841844 router.add("GET", "/now/:stage", nowPage);
842845 router.add("GET", "/audit", auditPage);
843846 router.add("GET", "/releases", releasesPage);
src/tui/screens/display.ts added +86
@@ -0,0 +1,86 @@
1 /**
2 * A repository's display (`/<owner>/<repo>/display` on the web), read only:
3 * its title and layout, then its builds as `displayPage` shows them — a build
4 * whose repository the viewer reads names it, any other says "private" and
5 * nothing more. Read through the same `displayOf` and `displayItems`, and
6 * gated here as well as on the command, since the screen is exported.
7 */
8
9 import { canRead, type Repo } from "../../core/repos.ts";
10 import { displayItems, type DisplayItem } from "../../display/items.ts";
11 import { INVALID_TITLE, displayOf } from "../../display/known.ts";
12 import type { Ctx, Screen } from "../app/app.ts";
13 import { binding } from "../app/keys.ts";
14 import type { Command } from "../app/palette.ts";
15 import type { Session } from "../session.ts";
16 import { style } from "../term/style.ts";
17 import { truncate } from "../term/width.ts";
18 import { ListState } from "./common.ts";
19
20 export const NO_DISPLAY_LINE = "No display here";
21 export const NO_BUILDS_LINE = "This display shows no builds yet.";
22
23 /** One build as a line: name and version, then its repository, or "private". */
24 export const itemLine = (it: DisplayItem): string =>
25 `${it.demo.name} ${it.demo.version} ${it.open ? `${it.repo.ownerName}/${it.repo.name}` : "private"}`;
26
27 export function displayScreen(f: Session, ctx: Ctx, repo: Repo): Screen {
28 let head: string | undefined;
29 let lines: string[] = [];
30 let missing = false;
31 const state = new ListState();
32 return {
33 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();
44 },
45 render(area, s) {
46 const c = ctx.theme.c;
47 const right = area.x + area.w;
48 const line = (y: number, text: string, fg = c.fg, x = 1) => s.text(area.x + x, area.y + y, truncate(text, Math.max(1, area.w - x)), style(fg, c.bg), right);
49 if (missing) return void line(0, NO_DISPLAY_LINE);
50 if (head === undefined) return void line(0, "Reading…");
51 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);
55 for (let i = 0; i < rows; i++) {
56 const l = lines[from + i];
57 if (l === undefined) break;
58 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);
61 }
62 },
63 key(e) {
64 const n = lines.length;
65 if (e.key === "j" || e.key === "down") state.move(1, n);
66 else if (e.key === "k" || e.key === "up") state.move(-1, n);
67 else if (e.key === "esc" || e.key === "q") ctx.pop();
68 else return false;
69 ctx.invalidate();
70 return true;
71 },
72 };
73 }
74
75 /** The palette's door on a repository screen, for whoever may read it. */
76 export function displayCommands(f: Session, ctx: Ctx, repo: Repo | undefined): Command[] {
77 if (repo === undefined || !canRead(f.db, repo, f.user)) return [];
78 return [
79 {
80 id: "display",
81 title: `${repo.ownerName}/${repo.name} — Display`,
82 hint: "the builds its display.json shows",
83 run: () => ctx.push(displayScreen(f, ctx, repo)),
84 },
85 ];
86 }
src/tui/screens/workspace.ts +2
@@ -134,6 +134,7 @@ import { deleteRepoCommands } from "./deleterepo.ts";
134134 import { grantsCommands } from "./grants.ts";
135135 import { visionCommands } from "./vision.ts";
136136 import { buildsCommands } from "./builds.ts";
137 import { displayCommands } from "./display.ts";
137138 import { iterationsCommands } from "./iterations.ts";
138139 import { labelsCommands } from "./labels.ts";
139140 import { appEnvCommands } from "./appenv.ts";
@@ -1455,6 +1456,7 @@ export function workspaceScreen(
14551456 ...labelsCommands(f, ctx, repo),
14561457 ...buildsCommands(f, ctx, repo),
14571458 ...iterationsCommands(f, ctx, repo),
1459 ...displayCommands(f, ctx, repo),
14581460 ...appEnvCommands(f, ctx, repo),
14591461 ...autonomyCommands(f, ctx, repo),
14601462 ...trafficCommands(f, ctx, repo),
src/web/pages/display.ts added +66
@@ -0,0 +1,66 @@
1 /**
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.
8 */
9
10 import { notePhase, takeSessionTime, timePhase, timePhaseAsync } from "../../core/route_timing.ts";
11 import { displayItems } from "../../display/items.ts";
12 import { INVALID_TITLE, displayOf } from "../../display/known.ts";
13 import { repoShotsUrl } from "../../display/launch.ts";
14 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
20 const LAYOUT_WORD = { shelf: "Shelf", grid: "Grid", graph: "Graph" } as const;
21
22 // GET /:owner/:name/display — 404 for a repository with no display.json or one the viewer cannot read.

The page has no terminal counterpart. Add a TUI screen for one repository's display (same gating) and a feature doc docs/features/display.md.

23 export async function displayPage(ctx: Ctx): Promise<Response> {
24 notePhase(ctx.request, "session", takeSessionTime(ctx.request));
25 const repo = loadRepo(ctx);
26 if (!repo) return timePhase(ctx.request, "render", () => notFoundPage(ctx));
27 const config = await timePhaseAsync(ctx.request, "display", () => displayOf(ctx.app, repo));
28 if (config === undefined) return timePhase(ctx.request, "render", () => notFoundPage(ctx));
29 const items = config.ok ? await timePhaseAsync(ctx.request, "items", () => displayItems(ctx.app, repo, config.value, ctx.auth.user)) : [];
30 const title = config.ok ? config.value.title : INVALID_TITLE;
31 const body = html`${repoHeader(ctx, repo, "code", { compact: true })}
32 <div class="page-head mt">
33 <h2>${title}</h2>
34 <a href="/displays">All displays</a>
35 </div>
36 ${!config.ok
37 ? html`<p role="alert">${config.error}</p>`
38 : items.length === 0
39 ? empty({ icon: "pin", title: "No builds", note: "This display shows no builds yet." })
40 : html`<p>${LAYOUT_WORD[config.value.layout]} · ${String(items.length)} ${items.length === 1 ? "build" : "builds"}</p>
41 <div class="table-scroll" role="region" tabindex="0" aria-label="Builds">
42 <table class="table" data-dense data-by="display-builds">
43 <thead>
44 <tr><th scope="col">Build</th><th scope="col">Version</th><th scope="col">Repository</th></tr>
45 </thead>
46 <tbody>
47 ${items.map((it) =>
48 it.open
49 ? html`<tr data-build="${it.key}">
50 <td><a href="${repoShotsUrl(it.repo, it.pin)}">${it.demo.name}</a></td>
51 <td class="mono">${it.demo.version}</td>
52 <td><a class="mono" href="${repoBase(it.repo)}">${it.repo.ownerName}/${it.repo.name}</a></td>
53 </tr>`
54 : html`<tr data-build="private">
55 <td>${it.demo.name}</td>
56 <td class="mono">${it.demo.version}</td>
57 <td>Private</td>
58 </tr>`,
59 )}
60 </tbody>
61 </table>
62 </div>`}`;
63 return timePhase(ctx.request, "render", () =>
64 page({ ctx, title: `Display · ${repo.name}`, crumbs: crumbsFor(repo), palette: repoPalette(ctx, repo), body }),
65 );
66 }
src/web/pages/displays.ts +1 −1
@@ -43,7 +43,7 @@ export async function displaysPage(ctx: Ctx): Promise<Response> {
4343 const base = repoBase(d.repo);
4444 const name = `${d.repo.ownerName}/${d.repo.name}`;
4545 return html`<tr data-display="${name}">
46 <td><a href="${base}">${d.config.ok ? d.config.value.title : INVALID_TITLE}</a></td>
46 <td><a href="${base}/display">${d.config.ok ? d.config.value.title : INVALID_TITLE}</a></td>
4747 <td>${d.config.ok ? LAYOUT_WORD[d.config.value.layout] : "None"}</td>
4848 <td><a class="mono" href="${base}">${name}</a></td>
4949 <td data-demos>${d.demos === undefined ? "None" : String(d.demos)}</td>
test/display.test.ts added +98
@@ -0,0 +1,98 @@
1 /** `/:owner/:name/display`: a repository's display, read only. */
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 { createApp, type App } from "../src/app.ts";
7 import { commitToBranch, createRepo } from "../src/actions/repos.ts";
8 import { getRepo } from "../src/core/repos.ts";
9 import { createSession } from "../src/core/sessions.ts";
10 import { createUser, type User } from "../src/core/users.ts";
11 import { pinBuild } from "../src/git/builds.ts";
12 import { repoDir } from "../src/git/repos.ts";
13 import { serve, type Servers } from "../src/http/server.ts";
14 import { proveAll } from "./helpers/verified.ts";
15
16 describe("the display page", () => {
17 let dataDir: string;
18 let app: App;
19 let servers: Servers;
20 let alice: User;
21 let bob: User;
22
23 const get = (path: string, u?: User) =>
24 fetch(`${servers.url}${path}`, {
25 headers: u === undefined ? {} : { cookie: `forge_session=${createSession(app.db, u.id).token}` },
26 redirect: "manual",
27 });
28 const commit = async (name: string, path: string, content: string) => {
29 const made = await commitToBranch(app, alice, getRepo(app.db, "alice", name)!, { message: path, files: [{ path, content }] });
30 if (!made.ok) throw new Error(JSON.stringify(made.error));
31 return made.value.commitOid;
32 };
33
34 beforeAll(async () => {
35 dataDir = await mkdtemp(join(tmpdir(), "forge-display-"));
36 app = createApp({ dataDir, port: 0 });
37 servers = await serve(app);
38 for (const name of ["alice", "bob"]) {
39 const made = await createUser(app.db, { origin: "seed", trusted: true, username: name, email: `${name}@forge.local`, password: "a-good-passphrase", adminIfFirst: true });
40 if (!made.ok) throw new Error(made.error);
41 if (name === "alice") alice = made.value;
42 else bob = made.value;
43 }
44 proveAll(app);
45 for (const [name, visibility] of [["karts", "public"], ["vault", "private"], ["plain", "public"]] as const) {
46 const made = await createRepo(app, alice, { name, visibility });
47 if (!made.ok) throw new Error(String(made.error));
48 }
49 const oid = await commit("karts", "a.txt", "a");
50 const pinned = await pinBuild(repoDir(app.reposRoot, "alice", "karts"), {
51 label: "fiesta",
52 oid,
53 note: "",
54 source: "commit",
55 tagger: { name: "alice", email: "[email protected]" },
56 at: 1_800_000_000,
57 demo: { name: "krazy-karts", version: "1.0", page: "/", showcase: true, launch: "shots" },
58 });
59 if (!pinned.ok) throw new Error(pinned.error);
60 await commit("karts", "display.json", JSON.stringify({ title: "Kart night", layout: "grid", sources: ["alice/karts"], builds: ["alice/karts:fiesta"] }));
61 await commit("vault", "display.json", JSON.stringify({ title: "Vault night", layout: "shelf", sources: ["alice/vault"] }));
62 }, 60_000);
63
64 afterAll(async () => {
65 servers.stop();
66 app.close();
67 await rm(dataDir, { recursive: true, force: true });
68 });
69
70 test("shows the title and the pinned build, server-rendered, with the security headers", async () => {
71 const res = await get("/alice/karts/display", bob);
72 expect(res.status).toBe(200);
73 expect(res.headers.get("content-security-policy")).toBeTruthy();
74 expect(res.headers.get("x-content-type-options")).toBe("nosniff");
75 expect(res.headers.get("referrer-policy")).toBeTruthy();
76 const page = await res.text();
77 expect(page).toContain("Kart night");
78 expect(page).toContain("krazy-karts");
79 expect(page).toContain('href="/alice/karts/builds/fiesta/shots"');
80 });
81
82 test("a repository without display.json is a 404", async () => {
83 expect((await get("/alice/plain/display", alice)).status).toBe(404);
84 expect((await get("/alice/nope/display", alice)).status).toBe(404);
85 });
86
87 test("an unreadable repository is a 404, a readable one is not", async () => {
88 expect((await get("/alice/vault/display", bob)).status).toBe(404);
89 expect((await get("/alice/vault/display")).status).toBe(404);
90 expect((await get("/alice/vault/display", alice)).status).toBe(200);
91 });
92
93 test("/displays links each entry to its display page", async () => {
94 const list = await (await get("/displays", alice)).text();
95 expect(list).toContain('href="/alice/karts/display"');
96 expect(list).toContain('href="/alice/vault/display"');
97 });
98 });
test/tui-display.test.ts added +97
@@ -0,0 +1,97 @@
1 /** The terminal's Display screen: title, layout and builds, the open/private split, 404-alike for the unreadable. */
2 import { afterAll, beforeAll, 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 { commitToBranch, createRepo } from "../src/actions/repos.ts";
7 import { createApp, type App } from "../src/app.ts";
8 import { getRepo, type Repo } from "../src/core/repos.ts";
9 import { createUser, type User } from "../src/core/users.ts";
10 import { pinBuild } from "../src/git/builds.ts";
11 import { repoDir } from "../src/git/repos.ts";
12 import { createForgeApp } from "../src/tui/forge.ts";
13 import { displayCommands, displayScreen, NO_DISPLAY_LINE } from "../src/tui/screens/display.ts";
14 import { headlessTerminal } from "../src/tui/term/terminal.ts";
15 import { quietLogs } from "./helpers/globals.ts";
16 import { settleApp } from "./helpers/tui.ts";
17
18 quietLogs();
19
20 let dir: string;
21 let app: App;
22 let alice: User;
23 let bob: User;
24 const guest = { sshUsername: "git", remoteAddress: "127.0.0.1", fingerprint: "SHA256:test" };
25 const repo = (n: string): Repo => getRepo(app.db, "alice", n)!;
26
27 beforeAll(async () => {
28 dir = await mkdtemp(join(tmpdir(), "forge-tui-display-"));
29 app = createApp({ dataDir: dir, port: 0 });
30 const mk = async (username: string) => {
31 const made = await createUser(app.db, { origin: "seed", trusted: true, username, email: `${username}@f.dev`, password: "a-good-passphrase", adminIfFirst: true });
32 if (!made.ok) throw new Error(made.error);
33 return made.value;
34 };
35 alice = await mk("alice");
36 bob = await mk("bob");
37 for (const [name, visibility] of [["karts", "public"], ["vault", "private"], ["plain", "public"]] as const) {
38 const made = await createRepo(app, alice, { name, visibility });
39 if (!made.ok) throw new Error(String(made.error));
40 }
41 const commit = async (name: string, path: string, content: string) => {
42 const c = await commitToBranch(app, alice, repo(name), { message: path, files: [{ path, content }] });
43 if (!c.ok) throw new Error(JSON.stringify(c.error));
44 return c.value.commitOid;
45 };
46 const pin = async (name: string, label: string, demo: string) => {
47 const p = await pinBuild(repoDir(app.reposRoot, "alice", name), {
48 label, oid: await commit(name, `${label}.txt`, label), note: "", source: "commit",
49 tagger: { name: "alice", email: "[email protected]" }, at: 1_800_000_000,
50 demo: { name: demo, version: "1.0", page: "/", showcase: true, launch: "shots" },
51 });
52 if (!p.ok) throw new Error(p.error);
53 };
54 await pin("karts", "fiesta", "krazy-karts");
55 await pin("vault", "hush", "secret-game");
56 await commit("karts", "display.json", JSON.stringify({ title: "Kart night", layout: "grid", sources: ["alice/karts", "alice/vault"] }));
57 await commit("vault", "display.json", JSON.stringify({ title: "Vault night", layout: "shelf", sources: ["alice/vault"] }));
58 }, 60_000);
59
60 afterAll(async () => {
61 app.close();
62 await rm(dir, { recursive: true, force: true });
63 });
64
65 async function drive(r: Repo, user: User | undefined): Promise<string> {
66 const terminal = headlessTerminal(100, 30);
67 const { app: tui, session } = createForgeApp(app, user ? { ...guest, user } : guest, { terminal, exitHooks: false });
68 tui.start();
69 tui.ctx.push(displayScreen(session, tui.ctx, r));
70 await settleApp(tui);
71 const text = tui.screen.toText();
72 tui.stop?.();
73 return text;
74 }
75
76 test("a reader sees the title, layout and builds; a private build's repository is named only to its readers", async () => {
77 const mine = await drive(repo("karts"), alice);
78 for (const w of ["Kart night", "grid", "krazy-karts 1.0", "alice/karts", "secret-game 1.0", "alice/vault"]) expect(mine).toContain(w);
79 const other = await drive(repo("karts"), bob);
80 expect(other).toContain("krazy-karts 1.0");
81 expect(other).toContain("secret-game 1.0 private");
82 expect(other).not.toContain("alice/vault");
83 });
84
85 test("no display.json, or an unreadable repository, reads as no display", async () => {
86 expect(await drive(repo("plain"), alice)).toContain(NO_DISPLAY_LINE);
87 const text = await drive(repo("vault"), undefined);
88 expect(text).toContain(NO_DISPLAY_LINE);
89 expect(text).not.toContain("Vault night");
90 });
91
92 test("the command is offered for a readable repository only", () => {
93 const terminal = headlessTerminal(100, 30);
94 const { app: tui, session } = createForgeApp(app, guest, { terminal, exitHooks: false });
95 expect(displayCommands(session, tui.ctx, repo("vault"))).toEqual([]);
96 expect(displayCommands(session, tui.ctx, repo("karts")).map((c) => c.id)).toEqual(["display"]);
97 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs