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

An admin reads the last 20 refused first asks on /forges/refused #11630

Merged opened this

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

Merged

scout/forge:scout/11586-an-admin-reads-the-last-20-refused-first → main 8 commits fork 3 threads · 3 unresolved

  • Merged. scout/11586-an-admin-reads-the-last-20-refused-first was merged into main as 37b94f5.

Review

Sign in to review this change.
10 files changed +205 −0
docs/design/audit.md +1
@@ -93,6 +93,7 @@ Machine routes are not pages and have no entry: `/audit.csv`,
9393 ### Forges (the family)
9494
9595 - `/forges` — `pages/forges.ts` · the forges this one sent, as a client list · `/family` · colour 1.
96 - `/forges/refused` — `pages/forges-refused.ts` · the last 20 first asks the forge refused, with its words; an admin's only · `/system` · none seen; reuses the list and muted text.
9697 - `/forges/new` — `pages/new-forge.ts` · send a new forge · none · colour 1.
9798 - `/family` — `pages/family.ts` · parent, this forge and children as a tree · `/forges` (the same children) · colour 1.
9899 - `/claim` — `pages/claim.ts` · claim a forge that was sent to me · none · colour 1.
docs/features/forges-refused.md added +8
@@ -0,0 +1,8 @@
1 # Refused first asks (#11586)
2
3 `/forges/refused` lists the last 20 first asks a stranger made and the forge refused, newest first, with the refusal's words and when it happened. Only a site admin sees it; anyone else gets the same not-found page as any unknown path.
4
5 - **Read:** one `listRefusals` call (`core/first_ask_refusals.ts`); nothing is written by the page.
6 - **Escaped:** the ask is a stranger's text and is escaped on output. The rows hold no email or claim code, so none is shown.
7 - **Empty:** says "No first ask has been refused."
8 - **Door:** a link on `/system` and a terminal palette entry ("Refused first asks", `src/tui/screens/refused.ts`), both gated by `mayReadRefusals` (an admin who is a person), as the page is; `/forges` gets its own link in #11587.
outdated

'No terminal screen yet' breaks both doors: add src/tui/ screen + ENTRIES line, or a No door: <why> commit line the gate accepts.

src/core/first_ask_refusals.ts +6
@@ -7,11 +7,17 @@
77
88 import type { Database } from "bun:sqlite";
99
10 /** How many refusals a reader sees. */
11 export const REFUSED_SHOWN = 20;
1012 export const ASK_CHARS = 200;
1113 export const PER_ACTOR_HOUR = 20;
1214 export const KEEP_MS = 30 * 24 * 3_600_000;
1315 const HOUR_MS = 3_600_000;
1416
17 /** Who may read the refusals: a forge admin who is a person. The page, its link on /system and the terminal all ask this. */
18 export const mayReadRefusals = (user: { isAdmin: boolean; kind?: string } | null | undefined): boolean =>
19 user?.isAdmin === true && user.kind !== "agent";
20
1521 export interface Refusal {
1622 readonly id: number;
1723 readonly at: number;
src/http/server.ts +2
@@ -280,6 +280,7 @@ import { firstAdminOptions, firstAdminPage, firstAdminSubmit } from "../web/page
280280 import { familyPage } from "../web/pages/family.ts";
281281 import { forgeLinkRevoke, forgeLinkSubmit, forgeLinkUndo, forgeRemoveConfirm, forgeRemoveSubmit, forgeStopSubmit,
282282 forgeOwnCodeSubmit, forgesPage } from "../web/pages/forges.ts";
283 import { forgesRefusedPage } from "../web/pages/forges-refused.ts";
283284 import { forgeItSubmit } from "../web/pages/forge-it.ts";
284285 import { funnelSubmit } from "./funnel.ts";
285286 import { pricingDone, pricingOrder, pricingPage, resendPage, resendSubmit } from "../web/pages/pricing.ts";
@@ -856,6 +857,7 @@ export function buildRouter(): Router<Handler> {
856857 router.add("POST", "/first-admin/options", firstAdminOptions);
857858 // "+ New forge": the spawn door as a page, any signed-in person (spawn/door.ts).
858859 router.add("GET", "/forges", forgesPage);
860 router.add("GET", "/forges/refused", forgesRefusedPage);
859861 router.add("GET", "/family", familyPage);
860862 router.add("GET", "/forges/name/:name", forgeName);
861863 router.add("POST", "/forges/:name/claim-link", forgeLinkSubmit);
src/tui/screens/entries.ts +2
@@ -77,6 +77,7 @@ import { entry as people } from "./people.ts";
7777 import { entry as pinned } from "./pinned.ts";
7878 import { entry as profile } from "./profile.ts";
7979 import { entry as quotas } from "./quotas.ts";
80 import { entry as refused } from "./refused.ts";
8081 import { entry as requests } from "./requests.ts";
8182 import { entry as resilience } from "./resilience.ts";
8283 import { entry as scorecard } from "./scorecard.ts";
@@ -187,6 +188,7 @@ export const ENTRIES: readonly PaletteEntry[] = [
187188 front,
188189 owner,
189190 forges,
191 refused,
190192 forgeReady,
191193 childCredit,
192194 originAhead,
src/tui/screens/refused.ts added +54
@@ -0,0 +1,54 @@
1 /**
2 * Refused first asks in the terminal (#11586): the last `REFUSED_SHOWN` first
3 * asks the forge turned away, newest first, as `/forges/refused` lists them.
4 * Gate is the page's (`mayReadRefusals`). One `listRefusals` call; read-only.
5 */
6
7 import type { Database } from "bun:sqlite";
8 import { REFUSED_SHOWN, listRefusals, mayReadRefusals } from "../../core/first_ask_refusals.ts";
9 import type { Ctx, Screen } from "../app/app.ts";
10 import { binding } from "../app/keys.ts";
11 import type { Session } from "../session.ts";
12 import { style } from "../term/style.ts";
13 import { Scroll, drawLines, hitScroll } from "./common.ts";
14 import type { PaletteEntry } from "./entries.ts";
15
16 /** One line per refusal; empty for anyone the gate refuses. Control characters in a stranger's ask become spaces. */
17 export function refusedLines(session: Session, db: Database = session.app.db, now = Date.now()): string[] {
18 if (!mayReadRefusals(session.user)) return [];
19 return listRefusals(db, REFUSED_SHOWN, now).map((r) =>
20 `${new Date(r.at).toISOString().slice(0, 16).replace("T", " ")} ${r.actorId ?? "no one"} ${r.ask} — ${r.words}`.replace(/[\u0000-\u001f\u007f-\u009f]/g, " "),
21 );
22 }
23
24 export function refusedScreen(session: Session, ctx: Ctx): Screen {
25 const scroll = new Scroll();
26 return {
27 title: "Refused first asks",
28 keys: [binding(["j", "k"], "scroll"), binding(["esc", "q"], "back")],
29 render(area, s) {
30 const c = ctx.theme.c;
31 const lines = refusedLines(session);
32 const shown = lines.length === 0 ? ["No first ask has been refused."] : lines;
33 drawLines(s, area, c, scroll, shown.map((text) => [{ text, style: style(c.fg, c.bg) }]));
34 hitScroll(ctx, area, scroll, shown.length);
35 },
36 key(e) {
37 if (scroll.key(e, refusedLines(session).length)) return true;
38 if (e.key === "esc" || e.key === "q") {
39 ctx.pop();
40 return true;
41 }
42 return false;
43 },
44 };
45 }
46
47 export const entry: PaletteEntry = {
48 id: "refused-asks",
49 title: "Refused first asks",
50 hint: `the last ${REFUSED_SHOWN} first asks turned away`,
51 after: ["system"],
52 offered: (session) => mayReadRefusals(session.user),
53 run: (session, ctx) => ctx.push(refusedScreen(session, ctx)),
54 };
src/web/pages/forges-refused.ts added +45
@@ -0,0 +1,45 @@
1 /**
2 * `/forges/refused`: the last 20 first asks a stranger made and the forge
3 * refused, with the refusal's words — an admin's only, the same answer
4 * `/forges` gives anyone else. One `listRefusals` call; the ask is text,
5 * escaped; no email or claim code is held by the rows, so none is shown.
6 */
7
8 import type { Ctx } from "../../http/context.ts";
9 import { notePhase, takeSessionTime } from "../../core/route_timing.ts";
10 import { REFUSED_SHOWN, listRefusals, mayReadRefusals } from "../../core/first_ask_refusals.ts";
11 import { notFoundPage, pageHead } from "../chrome.ts";
12 import { html } from "../html.ts";
13 import { page } from "../layout.ts";
14 import { time } from "../ui.ts";
15
16 export { REFUSED_SHOWN };
17
18 // GET /forges/refused — an admin's only.
19 export function forgesRefusedPage(ctx: Ctx): Response {
20 notePhase(ctx.request, "session", takeSessionTime(ctx.request));
21 if (!mayReadRefusals(ctx.auth.user)) return notFoundPage(ctx);
22 const rendering = performance.now();
23 const rows = listRefusals(ctx.app.db, REFUSED_SHOWN, Date.now());
24 const res = page({
25 ctx,
26 title: "Refused first asks",
27 body: html`<div class="narrow-wide" data-refused>
28 ${pageHead("Refused first asks", { sub: `The last ${REFUSED_SHOWN} first asks the forge turned away, newest first.` })}
29 ${rows.length === 0

'20' repeats REFUSED_SHOWN; build the sentence from the constant so they cannot drift (principle 1: say it once).

30 ? html`<p class="muted">No first ask has been refused.</p>`
31 : html`<ul class="list setting-list">
32 ${rows.map(
33 (r) => html`<li data-refusal>
34 <p>${time(new Date(r.at).toISOString())} · <span class="muted">${r.actorId ?? "no one signed in"}</span></p>
35 <p>${r.ask}</p>
36 <p class="muted small">${r.words}</p>
37 </li>`,
38 )}
39 </ul>`}
40 <p class="mt"><a href="/forges">Forges you sent</a></p>
41 </div>`,
42 });
43 notePhase(ctx.request, "render", performance.now() - rendering);
44 return res;
45 }
src/web/pages/system.ts +2
@@ -21,6 +21,7 @@
2121 * line each for memory, CPU and disk; a parent lists each child's use
2222 * against its size (#10853, `spawn/usage.ts`).
2323 */
24 import { mayReadRefusals } from "../../core/first_ask_refusals.ts";
2425 import { canSeeMemory, memoryHours } from "../../boards/memory.ts";
2526 import { telemetryReader } from "../../actions/telemetry.ts";
2627 import type { Ctx } from "../../http/context.ts";
@@ -385,6 +386,7 @@ export async function systemPage(ctx: Ctx): Promise<Response> {
385386 <span class="spacer"></span><a href="/system?pause">pause</a>
386387 </div>
387388 ${help}
outdated

Gated by owner (canSeeMemory) while /forges/refused is gated by isAdmin. Make the two the same condition and add a test for this link. Principle 5: this is a bare link in a small paragraph; fine for accent, but it floats between the help and the dup region without a row of its own.

389 ${mayReadRefusals(ctx.auth.user) ? html`<p class="small"><a href="/forges/refused">Refused first asks</a></p>` : null}
388390 <div id="sys-dup">${at("sys-dup")}</div>
389391 <section class="settings-block"><h2>Machine</h2>
390392 <div id="summary" class="row-meta">${at("summary")}</div>
test/forges-refused.test.ts added +84
@@ -0,0 +1,84 @@
1 /** GET /forges/refused (#11586): an admin reads the last 20 refused first asks. */
2
3 import { describe, expect, test } from "bun:test";
4 import { mkdtempSync } from "node:fs";
5 import { tmpdir } from "node:os";
6 import { join } from "node:path";
7 import { createApp } from "../src/app.ts";
8 import { recordRefusal } from "../src/core/first_ask_refusals.ts";
9 import { forgesPage } from "../src/web/pages/forges.ts";
10 import { forgesRefusedPage } from "../src/web/pages/forges-refused.ts";
11
12 const fresh = () => createApp({ dataDir: mkdtempSync(join(tmpdir(), "forge-refused-")), url: "https://x.test", port: 0 });
13 const ctxOf = (app: ReturnType<typeof fresh>, user: unknown) => {
14 const request = new Request("https://x.test/forges/refused");
15 return { app, request, auth: { user }, params: {}, url: new URL(request.url) } as never;
16 };
17
18 describe("GET /forges/refused", () => {
19 test("an admin sees the last 20, newest first, with the reason", async () => {
20 const app = fresh();
21 for (let i = 0; i < 25; i++) recordRefusal(app.db, { actorId: `u${i}`, ask: `ask number ${i}`, words: "Not today." }, Date.now() - (25 - i) * 1000);
22 const res = forgesRefusedPage(ctxOf(app, { id: "a", isAdmin: true }));
23 expect(res.status).toBe(200);
24 const body = await res.text();
25 expect(body.match(/data-refusal/g)).toHaveLength(20);
26 expect(body).toContain("ask number 24");
27 expect(body).not.toContain("ask number 4<");
28 expect(body.indexOf("ask number 24")).toBeLessThan(body.indexOf("ask number 23"));
29 expect(body).toContain("Not today.");
30 });
31 test("an empty list is one quiet line", async () => {
32 const body = await forgesRefusedPage(ctxOf(fresh(), { id: "a", isAdmin: true })).text();
33 expect(body).toContain("No first ask has been refused.");
34 expect(body).not.toContain("data-refusal");
35 });
36 test("a non-admin gets exactly what /forges gives", async () => {
37 const app = fresh();
38 for (const user of [undefined, { id: "b", isAdmin: false }]) {
39 const mine = forgesRefusedPage(ctxOf(app, user));
40 const theirs = await forgesPage(ctxOf(app, user));
41 expect(mine.status).toBe(theirs.status);
42 expect(await mine.text()).toBe(await theirs.text());
43 }
44 });
45 test("markup in an ask renders as text", async () => {
46 const app = fresh();
47 recordRefusal(app.db, { actorId: null, ask: "<script>alert(1)</script>", words: "no" }, Date.now());
48 const body = await forgesRefusedPage(ctxOf(app, { id: "a", isAdmin: true })).text();
49 expect(body).not.toContain("<script>alert(1)");
50 expect(body).toContain("&lt;script&gt;alert(1)&lt;/script&gt;");
51 });
52 test("no email or claim code appears", async () => {
53 const app = fresh();
54 recordRefusal(app.db, { actorId: "u1", ask: "build me a site", words: "no" }, Date.now());
55 const body = await forgesRefusedPage(ctxOf(app, { id: "a", isAdmin: true, email: "[email protected]" })).text();
56 expect(body).not.toMatch(/@x\.test|claim/i);
57 });
58 });
59
60 describe("doors to /forges/refused", () => {
61 const link = async (user: unknown) => {
62 const { systemPage } = await import("../src/web/pages/system.ts");
63 const app = fresh();
64 const res = await systemPage(ctxOf(app, user));
65 return (await res.text()).includes('href="/forges/refused"');
66 };
67 test("/system links it for an admin who is a person, not for others", async () => {
68 expect(await link({ id: "a", isAdmin: true, kind: "user" })).toBe(true);
69 expect(await link({ id: "b", isAdmin: false, kind: "user" })).toBe(false);
70 expect(await link({ id: "c", isAdmin: true, kind: "agent" })).toBe(false);
71 expect(await link(undefined)).toBe(false);
72 });
73 test("the terminal lists the same rows for an admin and none for others", async () => {
74 const { refusedLines } = await import("../src/tui/screens/refused.ts");
75 const app = fresh();
76 recordRefusal(app.db, { actorId: "u1", ask: "build\x1b[31m me", words: "Not today." }, Date.now());
77 const as = (user: unknown) => refusedLines({ app, user } as never);
78 const lines = as({ id: "a", isAdmin: true, kind: "user" });
79 expect(lines).toHaveLength(1);
80 expect(lines[0]).toContain("Not today.");
81 expect(lines[0]).not.toContain("\x1b");
82 expect(as({ id: "b", isAdmin: false })).toEqual([]);
83 });
84 });
test/palette-roster.test.ts +1
@@ -113,6 +113,7 @@ test("a site-admin person is offered the whole roster", () => {
113113 ["system", "System", "the machine now: CPU, memory, disks, processes"],
114114 ["pulls-to-land", "Pulls to land", "approved pulls, stale crew pulls, unproposed work"],
115115 ["valves", "Valves", "the week's budget as a valve per repository; turn one and the crew obeys"],
116 ["refused-asks", "Refused first asks", "the last 20 first asks turned away"],
116117 ["disks", "Disks", "machines' disk readings: 30-day write rate and projected rated-TBW date"],
117118 ["telemetry", "Telemetry", "which agents may read flow and system; enter flips the grant"],
118119 ["stalls", "Main-thread stalls", "the last hour's stall ms by route"],

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs