Public: anyone can read this; the crew can change it.
An admin reads the last 20 refused first asks on /forges/refused #11630
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/11586-an-admin-reads-the-last-20-refused-first → main 8 commits fork 3 threads · 3 unresolved
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`, | ||
| 93 | 93 | ### Forges (the family) |
| 94 | 94 | |
| 95 | 95 | - `/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. | |
| 96 | 97 | - `/forges/new` — `pages/new-forge.ts` · send a new forge · none · colour 1. |
| 97 | 98 | - `/family` — `pages/family.ts` · parent, this forge and children as a tree · `/forges` (the same children) · colour 1. |
| 98 | 99 | - `/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. | |
src/core/first_ask_refusals.ts +6
| @@ -7,11 +7,17 @@ | ||
| 7 | 7 | |
| 8 | 8 | import type { Database } from "bun:sqlite"; |
| 9 | 9 | |
| 10 | /** How many refusals a reader sees. */ | |
| 11 | export const REFUSED_SHOWN = 20; | |
| 10 | 12 | export const ASK_CHARS = 200; |
| 11 | 13 | export const PER_ACTOR_HOUR = 20; |
| 12 | 14 | export const KEEP_MS = 30 * 24 * 3_600_000; |
| 13 | 15 | const HOUR_MS = 3_600_000; |
| 14 | 16 | |
| 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 | ||
| 15 | 21 | export interface Refusal { |
| 16 | 22 | readonly id: number; |
| 17 | 23 | readonly at: number; |
src/http/server.ts +2
| @@ -280,6 +280,7 @@ import { firstAdminOptions, firstAdminPage, firstAdminSubmit } from "../web/page | ||
| 280 | 280 | import { familyPage } from "../web/pages/family.ts"; |
| 281 | 281 | import { forgeLinkRevoke, forgeLinkSubmit, forgeLinkUndo, forgeRemoveConfirm, forgeRemoveSubmit, forgeStopSubmit, |
| 282 | 282 | forgeOwnCodeSubmit, forgesPage } from "../web/pages/forges.ts"; |
| 283 | import { forgesRefusedPage } from "../web/pages/forges-refused.ts"; | |
| 283 | 284 | import { forgeItSubmit } from "../web/pages/forge-it.ts"; |
| 284 | 285 | import { funnelSubmit } from "./funnel.ts"; |
| 285 | 286 | import { pricingDone, pricingOrder, pricingPage, resendPage, resendSubmit } from "../web/pages/pricing.ts"; |
| @@ -856,6 +857,7 @@ export function buildRouter(): Router<Handler> { | ||
| 856 | 857 | router.add("POST", "/first-admin/options", firstAdminOptions); |
| 857 | 858 | // "+ New forge": the spawn door as a page, any signed-in person (spawn/door.ts). |
| 858 | 859 | router.add("GET", "/forges", forgesPage); |
| 860 | router.add("GET", "/forges/refused", forgesRefusedPage); | |
| 859 | 861 | router.add("GET", "/family", familyPage); |
| 860 | 862 | router.add("GET", "/forges/name/:name", forgeName); |
| 861 | 863 | 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"; | ||
| 77 | 77 | import { entry as pinned } from "./pinned.ts"; |
| 78 | 78 | import { entry as profile } from "./profile.ts"; |
| 79 | 79 | import { entry as quotas } from "./quotas.ts"; |
| 80 | import { entry as refused } from "./refused.ts"; | |
| 80 | 81 | import { entry as requests } from "./requests.ts"; |
| 81 | 82 | import { entry as resilience } from "./resilience.ts"; |
| 82 | 83 | import { entry as scorecard } from "./scorecard.ts"; |
| @@ -187,6 +188,7 @@ export const ENTRIES: readonly PaletteEntry[] = [ | ||
| 187 | 188 | front, |
| 188 | 189 | owner, |
| 189 | 190 | forges, |
| 191 | refused, | |
| 190 | 192 | forgeReady, |
| 191 | 193 | childCredit, |
| 192 | 194 | 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 @@ | ||
| 21 | 21 | * line each for memory, CPU and disk; a parent lists each child's use |
| 22 | 22 | * against its size (#10853, `spawn/usage.ts`). |
| 23 | 23 | */ |
| 24 | import { mayReadRefusals } from "../../core/first_ask_refusals.ts"; | |
| 24 | 25 | import { canSeeMemory, memoryHours } from "../../boards/memory.ts"; |
| 25 | 26 | import { telemetryReader } from "../../actions/telemetry.ts"; |
| 26 | 27 | import type { Ctx } from "../../http/context.ts"; |
| @@ -385,6 +386,7 @@ export async function systemPage(ctx: Ctx): Promise<Response> { | ||
| 385 | 386 | <span class="spacer"></span><a href="/system?pause">pause</a> |
| 386 | 387 | </div> |
| 387 | 388 | ${help} |
outdated
Gated by |
||
| 389 | ${mayReadRefusals(ctx.auth.user) ? html`<p class="small"><a href="/forges/refused">Refused first asks</a></p>` : null} | |
| 388 | 390 | <div id="sys-dup">${at("sys-dup")}</div> |
| 389 | 391 | <section class="settings-block"><h2>Machine</h2> |
| 390 | 392 | <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("<script>alert(1)</script>"); | |
| 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", () => { | ||
| 113 | 113 | ["system", "System", "the machine now: CPU, memory, disks, processes"], |
| 114 | 114 | ["pulls-to-land", "Pulls to land", "approved pulls, stale crew pulls, unproposed work"], |
| 115 | 115 | ["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"], | |
| 116 | 117 | ["disks", "Disks", "machines' disk readings: 30-day write rate and projected rated-TBW date"], |
| 117 | 118 | ["telemetry", "Telemetry", "which agents may read flow and system; enter flips the grant"], |
| 118 | 119 | ["stalls", "Main-thread stalls", "the last hour's stall ms by route"], |
'No terminal screen yet' breaks both doors: add src/tui/ screen + ENTRIES line, or a
No door: <why>commit line the gate accepts.