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

On the home, an admin selects apps and archives them in one tap, and the answer offers Undo #2524

Merged opened this

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

Merged

scout/forge:scout/2512-on-the-home-an-admin-selects-apps-and-ar → main 5 commits fork 8 threads · 8 unresolved

  • Merged. scout/2512-on-the-home-an-admin-selects-apps-and-ar was merged into main as 4c15a1c.

Review

Sign in to review this change.
10 files changed +397 −4
README.md +8
@@ -390,6 +390,14 @@ reader and the explorer's default view; `/explore` shows it with `archived:yes`
390390 (the **Show archived** toggle). It stays in `/apps`, search, the palette and
391391 the repository API (`GET /api/v1/repos`).
392392
393 A site admin archives many at once from the home. In the **Your apps** card,
394 **Select** shows a checkbox per app (they take no room until then); tick some
395 and press **Archive**. This is the fleet's `archive` operation over those
396 repositories (`POST /home/archive`, same-origin, a site admin's), so it has the
397 fleet's receipt: the page that answers lists what was archived and an **Undo**
398 button for the person who ran it (kept for a day), after which the apps are back
399 on the home. The terminal's dashboard archives and undoes the same way (below); either door refreshes an open web home (the `fleet.operated` event).
400
393401 ## Health
394402
395403 `GET /api/v1/health` (and the MCP `health` tool) answer a site admin with
src/core/reserved.ts +1
@@ -72,6 +72,7 @@ export const SYSTEM_NAMES: readonly string[] = [
7272 "flow",
7373 "front",
7474 "go",
outdated

Nit: the list is alphabetical, so home belongs after go.

75 "home",
7576 "icons",
7677 "live",
7778 "login",
src/http/server.ts +4
@@ -248,6 +248,7 @@ import {
248248 asksMute,
249249 asksPage,
250250 } from "../web/studio/asks_page.ts";
251 import { homeArchive, homeArchived, homeArchiveUndo } from "../web/studio/home_archive.ts";
251252 import { liveFeedFragment } from "../web/studio/feed.ts";
252253 import { serviceWorker } from "../web/sw.ts";
253254 import { explorePage } from "../web/studio/explore.ts";
@@ -648,6 +649,9 @@ export function buildRouter(): Router<Handler> {
648649 // Apps and people ranked by real numbers, narrowed in one line (#607).
649650 router.add("GET", "/explore", explorePage);
650651 router.add("POST", "/front", frontUpdate);
652 router.add("POST", "/home/archive", homeArchive);
653 router.add("GET", "/home/archived/:op", homeArchived);
654 router.add("POST", "/home/archived/:op/undo", homeArchiveUndo);
651655 router.add("GET", "/signin", signinPage);
652656 router.add("POST", "/signin", signinSubmit);
653657 router.add("GET", "/signin/parent", parentStart);
src/web/client.ts +4
@@ -1930,6 +1930,10 @@ export const liveRegionsJs = /* js */ `
19301930 // post is in flight) is never pulled out from under the click; the next
19311931 // matching event tries the swap again.
19321932 if (mine.querySelector('[data-busy]')) return;
1933 // Likewise a region with a ticked box (apps picked for Archive, or the
1934 // Select toggle itself): a tap on a phone does not focus a checkbox, so
1935 // focus cannot be what protects the picks.
1936 if (mine.querySelector('input[type=checkbox]:checked')) return;
19331937 var active = document.activeElement;
19341938 if (active && mine.contains(active) && (active.tagName === 'INPUT' || active.tagName === 'TEXTAREA')) return;
19351939 if (same(mine.innerHTML, theirs.innerHTML)) return;
src/web/studio/feed_style.ts +3
@@ -27,6 +27,9 @@ export const feedCss = /* css */ `
2727 /* Your apps: a narrow column on a desk, a tab away on a phone. */
2828 .home-tabs { display: none; margin-bottom: 12px; }
2929 .home-app { gap: 8px; }
30 .home-pick { display: none; }
31 #apps-select:checked ~ * .home-pick { display: inline-flex; }
32 #apps-select:focus-visible ~ * .home-apps-verbs label { outline: 2px solid var(--accent, currentColor); }
3033 .home-apps-verbs { flex-wrap: wrap; padding: 10px 14px; }
3134 @media (max-width: 899px) {
3235 .home-tabs { display: inline-flex; }
src/web/studio/home.ts +31 −4
@@ -60,7 +60,7 @@ import { passkeyJs } from "../passkey-client.ts";
6060 const ON_YOU_LIVE =
6161 "issue.assigned,pull.assigned,issue.closed,pull.closed,issue.reopened,pull.reopened,pull.merged,fleet.operated";
6262 /** The events that can change how an app on the rail is. */
63 const APPS_LIVE = "app.serving,app.stopped,run.finished";
63 const APPS_LIVE = "app.serving,app.stopped,run.finished,fleet.operated";

Adding fleet.operated here is right, but this region also re-renders on run.finished, and it now holds form state (the Select toggle and the ticks). client.ts's swap only holds off while an INPUT has focus, and iOS Safari doesn't focus checkboxes on tap. So the ticks vanish the next time a run finishes. Have swap skip a region with a :checked checkbox, and add a test for it.

6464 /** How many of your apps the rail names before "All apps". */
6565 const RAIL_APPS = 8;
6666
@@ -83,13 +83,23 @@ function yourApps(ctx: Ctx, user: User): Html {
8383 ? undefined
8484 : runtime.status(record).find((env) => env.env === "production")?.state;
8585 return html`<li class="row home-app">
86 ${when(
87 user.isAdmin,
88 () =>
89 html`<input
90 class="home-pick"
91 type="checkbox"
92 name="repo"
93 value="${repo.id}"
94 aria-label="Select ${repo.name}"
95 />`,
96 )}
8697 <a class="truncate" href="${repoBase(repo)}">${repo.ownerName}/${repo.name}</a>
8798 <span class="spacer"></span>
8899 ${state === undefined ? null : envStateBadge(state)}
89100 </li>`;
90101 });
91 return card(
92 html`${rows.length === 0
102 const list = html`${rows.length === 0
93103 ? html`<p class="muted small">No apps yet.</p>`
94104 : html`<ul class="list">
95105 ${rows}
@@ -108,7 +118,24 @@ function yourApps(ctx: Ctx, user: User): Html {
108118 ${when(user.isAdmin, () =>
109119 btn("Front page", { href: "/front", size: "sm", icon: "globe" }),
110120 )}
111 </div>`,
121 ${when(
122 user.isAdmin && rows.length > 0,
123 () => html`<label class="btn btn-sm" for="apps-select">Select</label>
124 <span class="home-pick">${btn("Archive", { size: "sm" })}</span>`,
125 )}
126 </div>`;
127 return card(
128 // An admin's list is a form: ticks cost no space until Select shows them.
129 user.isAdmin && rows.length > 0
130 ? html`<form method="post" action="/home/archive">
131 <input

This form is inside #live-apps, so a live swap replaces the form, and the ticks with it. See the comment on APPS_LIVE.

132 class="sr-only"
133 type="checkbox"
134 id="apps-select"
135 aria-label="Select apps"
136 />${list}
137 </form>`
138 : list,
112139 {
113140 head: html`${icon("rocket")}
114141 <h2>Your apps</h2>`,
src/web/studio/home_archive.ts added +108
@@ -0,0 +1,108 @@
1 /**
2 * Archiving apps from the home: an admin ticks apps in the rail and archives
3 * them in one step. It is the fleet's own `archive` operation over the ticked
4 * repositories (`actions/fleet.ts`) — nothing new is written here — and the
5 * page that answers lists what was archived with the operation's undo.
6 */
7
8 import { applyFleetIds, getFleetOp, undoFleetOp } from "../../actions/fleet.ts";
9 import type { Ctx } from "../../http/context.ts";
10 import { signInFirst } from "../chrome.ts";
11 import { readForm } from "../form.ts";
12 import { html } from "../html.ts";
13 import { page, redirect } from "../layout.ts";
14 import { btn } from "../ui.ts";
15 import { MAX_ROWS } from "../../fleet/selection.ts";
16
17 /** The ticked repository ids, once each, as long as they are plausible ids. */
18 function picked(form: Awaited<ReturnType<typeof readForm>>): string[] {
19 if (form instanceof Response) return [];
20 const ids = form
21 .getAll("repo")
22 .filter((v): v is string => typeof v === "string" && /^[\w-]{1,64}$/.test(v));
23 return [...new Set(ids)];
24 }
25
26 // POST /home/archive — archive the ticked apps, a site admin's.
27 export async function homeArchive(ctx: Ctx): Promise<Response> {

This action exists only on the web. The terminal dashboard needs the same select, archive and undo for an admin, through the same applyFleetIds call (see the review body).

28 if (!ctx.auth.user) return signInFirst(ctx);
29 const form = await readForm(ctx, "admin");
30 if (form instanceof Response) return form;
31 if (!ctx.auth.user.isAdmin) return new Response("forbidden", { status: 403 });
32 const ids = picked(form);
33 if (ids.length === 0 || ids.length > MAX_ROWS)

Low: ticking more than MAX_ROWS apps gets the flash "Tick the apps to archive first.", which is wrong for that case. Split the two cases, or say "Archive at most N at once".

34 return redirect("/", {
35 url: ctx.url,
36 flash: { tone: "danger", message: "Tick the apps to archive first." },
37 });
38 const op = await applyFleetIds(
39 ctx.app,
40 ctx.auth.user,
41 "repo",
42 ids,
43 `home: ${ids.length} selected`,
44 { verb: "archive", value: true },
45 );
46 if (!op.ok)
47 return redirect("/", {
48 url: ctx.url,
49 flash: { tone: "danger", message: op.error.message },
50 });
51 return redirect(`/home/archived/${op.value.id}`);
52 }
53
54 // GET /home/archived/:op — what was archived, and the undo.
55 export function homeArchived(ctx: Ctx): Response {
56 if (!ctx.auth.user) return signInFirst(ctx);
57 const found = getFleetOp(ctx.app, ctx.auth.user, ctx.params.op ?? "");
58 if (!found.ok || found.value.verb !== "archive")
59 return new Response("not found", { status: 404 });
60 const op = found.value;
61 const wasUndo = op.undoOf !== null;
62 const rows = op.rows.filter((r) => r.outcome === "ok");
63 const left = op.rows.filter((r) => r.outcome !== "ok");
64 // Only the person who ran it may undo it (`undoFleetOp`), whoever may read it.
65 const canUndo =
outdated

Minor: getFleetOp lets any site admin read this receipt, but undoFleetOp refuses a personal op to anyone except the person who ran it. So another admin sees an Undo button here, and pressing it gives a bare 404. Show the button only when op.actorId === ctx.auth.user.id.

66 op.reversible && op.undoneBy === null && !wasUndo && op.actorId === ctx.auth.user.id;

Low: canUndo does not check the day window. After 24 hours the receipt still shows Undo and "Undo is kept for a day", and the click comes back as an expired flash. Leave the button out when Date.now() - Date.parse(op.createdAt) is past the window, using the same rule as undoFleetOp.

67 const body = html`<h1>${wasUndo ? "Brought back" : "Archived"}</h1>
68 <p id="home-archived" role="status">
69 ${rows.length} app${rows.length === 1 ? "" : "s"}
70 ${wasUndo ? "back on the home" : "archived"}${left.length === 0
71 ? ""
72 : `, ${left.length} left as they were`}.
73 </p>
74 <ul class="list">
75 ${rows.map((r) => html`<li class="row">${r.label}</li>`)}
76 </ul>
77 ${left.length === 0
78 ? null
79 : html`<ul class="list muted">
80 ${left.map((r) => html`<li class="row">${r.label}: ${r.reason}</li>`)}
81 </ul>`}
82 <div class="bar mt">
83 ${canUndo
84 ? html`<form method="post" action="/home/archived/${op.id}/undo">
85 ${btn("Undo", { variant: "primary" })}
86 </form>`
87 : null}
88 ${btn("Home", { href: "/" })}
89 </div>
90 ${canUndo ? html`<p class="small muted">Undo is kept for a day.</p>` : null}`;
91 return page({ ctx, title: wasUndo ? "Brought back" : "Archived", body });
92 }
93
94 // POST /home/archived/:op/undo — the operation's own undo.
95 export async function homeArchiveUndo(ctx: Ctx): Promise<Response> {
96 if (!ctx.auth.user) return signInFirst(ctx);
97 const form = await readForm(ctx, "admin");
98 if (form instanceof Response) return form;
99 const undone = await undoFleetOp(ctx.app, ctx.auth.user, ctx.params.op ?? "");
100 if (!undone.ok && undone.error.code === "not_found")
101 return new Response("not found", { status: 404 });
102 if (!undone.ok)
103 return redirect("/", {
104 url: ctx.url,
105 flash: { tone: "danger", message: undone.error.message },
106 });
107 return redirect(`/home/archived/${undone.value.id}`);
108 }
test/home-archive.test.ts added +149
@@ -0,0 +1,149 @@
1 /**
2 * Archiving from the home: an admin ticks apps and archives them in one
3 * step; the answer lists them and offers the fleet's undo.
4 */
5 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
6 import { mkdtemp, rm } from "node:fs/promises";
7 import { tmpdir } from "node:os";
8 import { join } from "node:path";
9 import { createApp, type App } from "../src/app.ts";
10 import { createRepo } from "../src/actions/repos.ts";
11 import { createApp as insertApp } from "../src/core/apps.ts";
12 import type { Repo } from "../src/core/repos.ts";
13 import { createSession } from "../src/core/sessions.ts";
14 import { createUser, type User } from "../src/core/users.ts";
15 import { serve, type Servers } from "../src/http/server.ts";
16 import { promote } from "./helpers/members.ts";
17
18 describe("home archive", () => {
19 let dataDir: string;
20 let app: App;
21 let servers: Servers;
22 let root: User;
23 let rootCookie: string;
24 let bobCookie: string;
25 let repos: Repo[];
26
27 const get = (path: string, cookie: string) =>
28 fetch(`${servers.url}${path}`, {
29 headers: { cookie: `${cookie}; forge_mode=studio` },
30 redirect: "manual",
31 });
32 const post = (path: string, cookie: string, body: string, origin = servers.url) =>
33 fetch(`${servers.url}${path}`, {
34 method: "POST",
35 headers: {
36 cookie: `${cookie}; forge_mode=studio`,
37 origin,
38 "content-type": "application/x-www-form-urlencoded",
39 },
40 body,
41 redirect: "manual",
42 });
43
44 beforeAll(async () => {
45 dataDir = await mkdtemp(join(tmpdir(), "forge-home-archive-"));
46 app = createApp({ dataDir, port: 0 });
47 servers = await serve(app);
48 const mk = async (username: string, first = false) => {
49 const r = await createUser(app.db, {
50 origin: "seed",
51 trusted: true,
52 username,
53 email: `${username}@forge.local`,
54 password: "a-good-passphrase",
55 adminIfFirst: first,
56 });
57 if (!r.ok) throw new Error(r.error);
58 promote(app.db, r.value);
59 return r.value;
60 };
61 root = await mk("keeper", true);
62 const bob = await mk("bob");
63 rootCookie = `forge_session=${createSession(app.db, root.id).token}`;
64 bobCookie = `forge_session=${createSession(app.db, bob.id).token}`;
65 repos = [];
66 for (const name of ["alpha", "beta", "gamma"]) {
67 const made = await createRepo(app, root, { name, visibility: "public" });
68 if (!made.ok) throw new Error(String(made.error));
69 insertApp(app.db, { repoId: made.value.id, slug: name, access: "public" });
70 repos.push(made.value);
71 }
72 const mine = await createRepo(app, bob, { name: "mine", visibility: "public" });
73 if (!mine.ok) throw new Error(String(mine.error));
74 }, 30_000);
75
76 afterAll(async () => {
77 servers.stop();
78 app.close();
79 await rm(dataDir, { recursive: true, force: true });
80 });
81
82 const body = (...ids: string[]) => ids.map((id) => `repo=${id}`).join("&");
83 const home = async (cookie: string) => (await get("/live/apps", cookie)).text();
84 const full = async (cookie: string) => (await get("/", cookie)).text();
85
86 test("an admin archives two, the answer lists them, Undo brings them back", async () => {
87 const before = await full(rootCookie);
88 expect(before).toContain('name="repo"');
89 expect(before).toContain("keeper/alpha");
90
91 const res = await post("/home/archive", rootCookie, body(repos[0]!.id, repos[1]!.id));
92 expect(res.status).toBe(303);
93 const receipt = res.headers.get("location")!;
94 expect(receipt).toStartWith("/home/archived/");
95
96 const after = await home(rootCookie);
97 expect(after).not.toContain("keeper/alpha");
98 expect(after).not.toContain("keeper/beta");
99 expect(after).toContain("keeper/gamma");
100
101 const answer = await (await get(receipt, rootCookie)).text();
102 expect(answer).toContain("keeper/alpha");
103 expect(answer).toContain("keeper/beta");
104 expect(answer.replace(/\s+/g, " ")).toContain("2 apps archived");
105 expect(answer).toContain(`action="${receipt}/undo"`);
106
107 const undone = await post(`${receipt}/undo`, rootCookie, "");
108 expect(undone.status).toBe(303);
109 const back = await home(rootCookie);
110 expect(back).toContain("keeper/alpha");
111 expect(back).toContain("keeper/beta");
112
113 const twice = await post(`${receipt}/undo`, rootCookie, "");
114 expect(twice.status).toBe(303);
115 expect(twice.headers.get("location")).toBe("/");
116 });
117
118 test("a cross-origin post is refused and archives nothing", async () => {
119 const res = await post(
120 "/home/archive",
121 rootCookie,
122 body(repos[2]!.id),
123 "https://evil.example",
124 );
125 expect(res.status).toBe(403);
126 expect(await home(rootCookie)).toContain("keeper/gamma");
127 });
128
129 test("a non-admin sees no checkboxes and cannot post", async () => {
130 const page = await full(bobCookie);
131 expect(page).not.toContain('name="repo"');
132 expect(page).not.toContain("apps-select");
133 const res = await post("/home/archive", bobCookie, body(repos[2]!.id));
134 expect(res.status).toBe(403);
135 expect(await home(rootCookie)).toContain("keeper/gamma");
136 });
137
138 test("another admin reads the receipt but is not offered Undo", async () => {
139 app.db.run("UPDATE user SET is_admin = 1 WHERE username = 'bob'");
140 const res = await post("/home/archive", rootCookie, body(repos[2]!.id));
141 const receipt = res.headers.get("location")!;
142 const mine = await (await get(receipt, rootCookie)).text();
143 const theirs = await (await get(receipt, bobCookie)).text();
144 expect(mine).toContain("/undo");
145 expect(theirs).toContain("keeper/gamma");
146 expect(theirs).not.toContain("/undo");
147 app.db.run("UPDATE user SET is_admin = 0 WHERE username = 'bob'");
148 });
149 });
test/live-pages.test.ts +9
@@ -169,6 +169,15 @@ describe("live browsing pages", () => {
169169 const { liveRegionsJs } = await import("../src/web/client.ts");
170170 expect(liveRegionsJs).toContain("data-busy");
171171 });
172
173 test("a live region never swaps out from under ticked boxes", async () => {
174 const { liveRegionsJs } = await import("../src/web/client.ts");
175 expect(liveRegionsJs).toContain("input[type=checkbox]:checked");
176 // The guard sits in `swap`, before anything is replaced.
177 const swap = liveRegionsJs.slice(liveRegionsJs.indexOf("function swap("));
178 expect(swap.indexOf("checkbox]:checked")).toBeGreaterThan(-1);
179 expect(swap.indexOf("checkbox]:checked")).toBeLessThan(swap.indexOf("innerHTML = "));
180 });
172181 });
173182
174183 describe("the beat of a region with data-live-every", () => {
test/tui-archive.test.ts +80
@@ -11,6 +11,9 @@ import { setAssignee } from "../src/core/issues.ts";
1111 import { createApp, type App as Forge } from "../src/app.ts";
1212 import { getRepo, type Repo } from "../src/core/repos.ts";
1313 import type { User } from "../src/core/users.ts";
14 import { createSession } from "../src/core/sessions.ts";
15 import { serve, type Servers } from "../src/http/server.ts";
16 import { createApp as insertApp } from "../src/core/apps.ts";
1417 import { createForgeApp } from "../src/tui/forge.ts";
1518 import type { TuiIdentity } from "../src/tui/session.ts";
1619 import { headlessTerminal } from "../src/tui/term/terminal.ts";
@@ -144,3 +147,80 @@ describe("archive in the terminal", () => {
144147 }
145148 });
146149 });
150
151 describe("a terminal archive and the web home", () => {
152 let dataDir: string;
153 let forge: Forge;
154 let admin: User;
155 let servers: Servers;
156
157 function boot(user: User) {
158 const terminal = headlessTerminal(120, 40);
159 const { app } = createForgeApp(
160 forge,
161 { sshUsername: user.username, remoteAddress: "127.0.0.1", fingerprint: "SHA256:test", user },
162 { terminal, exitHooks: false },
163 );
164 app.start();
165 const press = async (...keys: string[]): Promise<void> => {
166 for (const key of keys)
167 app.dispatch({ type: "key", key, ctrl: false, alt: false, shift: false });
168 await settleApp(app);
169 };
170 return { app, press, text: () => app.screen.toText() };
171 }
172 const apps = async (cookie: string) =>
173 (
174 await fetch(`${servers.url}/live/apps`, {
175 headers: { cookie: `${cookie}; forge_mode=studio` },
176 })
177 ).text();
178
179 beforeAll(async () => {
180 dataDir = await mkdtemp(join(tmpdir(), "forge-tui-bulk-archive-"));
181 forge = createApp({ dataDir, port: 0 });
182 const a = await seedAdmin(forge, {
183 username: "keeper",
184 email: "[email protected]",
185 password: "a-long-passphrase",
186 });
187 if (!a.ok) throw new Error("signup failed");
188 admin = a.value;
189 for (const name of ["alpha", "beta"]) {
190 const made = await createRepo(forge, admin, { name, visibility: "public" });
191 if (!made.ok) throw new Error("repo failed");
192 insertApp(forge.db, { repoId: made.value.id, slug: name, access: "public" });
193 }
194 servers = await serve(forge);
195 });
196
197 afterAll(async () => {
198 servers.stop();

Call forge.close() here too: the other suites close the app before removing its data dir.

199 forge.close();
200 await rm(dataDir, { recursive: true, force: true });
201 });
202
203 test("the web home drops what the terminal archives, and has it back on undo", async () => {
204 const cookie = `forge_session=${createSession(forge.db, admin.id).token}`;
205 expect(await apps(cookie)).toContain("keeper/alpha");
206 const h = boot(admin);
207 try {
208 await settleUntil(h.app, "keeper/alpha");
209 await h.press("space", "j", "space");
210 await h.press("a");
211 await h.press("enter");
212 await settleUntil(h.app, "archived 2");
213 expect(getRepo(forge.db, "keeper", "alpha")?.archivedAt).not.toBeNull();
214 expect(getRepo(forge.db, "keeper", "beta")?.archivedAt).not.toBeNull();
215 const gone = await apps(cookie);
216 expect(gone).not.toContain("keeper/alpha");
217 expect(gone).not.toContain("keeper/beta");
218 await h.press("u");
219 await settleUntil(h.app, "undone");
220 expect(getRepo(forge.db, "keeper", "alpha")?.archivedAt).toBeNull();
221 expect(await apps(cookie)).toContain("keeper/alpha");
222 } finally {
223 h.app.stop();
224 }
225 });
226 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs