Public: anyone can read this; the crew can change it.
The operator reads a forge's refused first asks on /forges/refused, linked by one line from /forges #11568
scout/forge:scout/11555-the-operator-reads-a-forge-s-refused-fir → main 6 commits fork
5 files
changed +119
−1
docs/features/first-ask.md added +6
| @@ -0,0 +1,6 @@ | ||
| 1 | # First ask: refusals | |
| 2 | ||
| 3 | When the forge refuses a first ask, it keeps one row: who asked, the first 200 characters, and the refusal's words. Never an email, token or claim code. | |
| 4 | ||
| 5 | - **Where they are read.** The operator (an admin) opens `/forges/refused`, linked by one line at the foot of `/forges` (with the count when there are any). It lists the last 20, newest first; others get the same answer `/forges` gives them. | |
| 6 | - **Caps.** At most 20 refusals are kept per person per hour (the next is dropped silently), and rows are kept 30 days. |
src/http/server.ts +2
| @@ -5,6 +5,7 @@ | ||
| 5 | 5 | * verdict. Auth is resolved once per request and handed to handlers as data. |
| 6 | 6 | */ |
| 7 | 7 | |
| 8 | import { forgesRefusedPage } from "../web/pages/forges-refused.ts"; | |
| 8 | 9 | import { apiRoot } from "./api_root.ts"; |
| 9 | 10 | import { isTokenScheme, tokenSecret } from "./token_header.ts"; |
| 10 | 11 | import { |
| @@ -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/web/pages/forges-refused.ts added +45
| @@ -0,0 +1,45 @@ | ||
| 1 | /** | |
| 2 | * `/forges/refused`: the last 20 first asks the forge refused (#11555), read | |
| 3 | * by the operator. One `listRefusals` call; who asked is a username, never an | |
| 4 | * email or claim code; the ask is escaped text. | |
| 5 | */ | |
| 6 | ||
| 7 | import type { Ctx } from "../../http/context.ts"; | |
| 8 | import { listRefusals } from "../../core/first_ask_refusals.ts"; | |
| 9 | import { getUsersByIds } from "../../core/users.ts"; | |
| 10 | import { notFoundPage, pageHead } from "../chrome.ts"; | |
| 11 | import { html } from "../html.ts"; | |
| 12 | import { page } from "../layout.ts"; | |
| 13 | import { time } from "../ui.ts"; | |
| 14 | ||
| 15 | export const REFUSED_SHOWN = 20; | |
| 16 | ||
| 17 | // GET /forges/refused — an admin's only, the same answer /forges gives others. | |
| 18 | export function forgesRefusedPage(ctx: Ctx): Response { | |
| 19 | if (!ctx.auth.user?.isAdmin) return notFoundPage(ctx); | |
| 20 | const rows = listRefusals(ctx.app.db, REFUSED_SHOWN, Date.now()); | |
| 21 | const people = getUsersByIds(ctx.app.db, rows.flatMap((r) => (r.actorId === null ? [] : [r.actorId]))); | |
| 22 | const response = page({ | |
| 23 | ctx, | |
| 24 | title: "Refused first asks", | |
| 25 | body: html`<div class="narrow-wide" data-forges-refused> | |
| 26 | ${pageHead("Refused first asks", { sub: "The last 20 first asks the forge refused, newest first." })} | |
| 27 | <section class="settings-block"> | |
| 28 | ${rows.length === 0 | |
| 29 | ? html`<p class="muted">No first ask has been refused.</p>` | |
| 30 | : html`<ul class="list setting-list"> | |
| 31 | ${rows.map( | |
| 32 | (r) => html`<li data-refusal="${r.id}"> | |
| 33 | <p>${time(new Date(r.at).toISOString())} · ${r.actorId === null ? "a visitor" : (people.get(r.actorId)?.username ?? "someone gone")}</p> | |
| 34 | <p>${r.ask}</p> | |
| 35 | <p class="muted">${r.words}</p> | |
| 36 | </li>`, | |
| 37 | )} | |
| 38 | </ul>`} | |
| 39 | </section> | |
| 40 | <p class="mt"><a href="/forges">Forges you sent</a></p> | |
| 41 | </div>`, | |
| 42 | }); | |
| 43 | response.headers.set("cache-control", "no-store"); | |
| 44 | return response; | |
| 45 | } |
src/web/pages/forges.ts +8 −1
| @@ -22,6 +22,7 @@ import { diskWords } from "../../spawn/disk_mount.ts"; | ||
| 22 | 22 | import { listChildren } from "../../actions/spawn.ts"; |
| 23 | 23 | import { ownCode, removeForge, stopForge, type Managed } from "../../spawn/manage.ts"; |
| 24 | 24 | import { readOwnCode } from "../../spawn/own_code.ts"; |
| 25 | import { listRefusals } from "../../core/first_ask_refusals.ts"; | |
| 25 | 26 | import { claimLinkBody } from "./new-forge.ts"; |
| 26 | 27 | |
| 27 | 28 | |
| @@ -141,6 +142,12 @@ export function sideBySide(rows: readonly ClientRow[]): Html { | ||
| 141 | 142 | </div>`; |
| 142 | 143 | } |
| 143 | 144 | |
| 145 | /** The one line to the refused first asks, with the count when there are any. */ | |
| 146 | function refusedLine(ctx: Ctx): string { | |
| 147 | const n = listRefusals(ctx.app.db, 20, Date.now()).length; | |
| 148 | return n === 0 ? "Refused first asks" : `Refused first asks (${n})`; | |
| 149 | } | |
| 150 | ||
| 144 | 151 | // GET /forges — an admin's only. |
| 145 | 152 | export async function forgesPage(ctx: Ctx): Promise<Response> { |
| 146 | 153 | const user = ctx.auth.user; |
| @@ -179,7 +186,7 @@ export async function forgesPage(ctx: Ctx): Promise<Response> { | ||
| 179 | 186 | </ul> |
| 180 | 187 | </section>`} |
| 181 | 188 | ${await ownCodeSection(ctx)} |
| 182 | <p class="mt"><a href="/fleet/graph">Fleet graph</a></p> | |
| 189 | <p class="mt"><a href="/fleet/graph">Fleet graph</a> · <a href="/forges/refused">${refusedLine(ctx)}</a></p> | |
| 183 | 190 | </div>`, |
| 184 | 191 | }); |
| 185 | 192 | notePhase(ctx.request, "render", performance.now() - rendering); |
test/forges-refused.test.ts added +58
| @@ -0,0 +1,58 @@ | ||
| 1 | /** /forges/refused (#11555): an admin reads the refusals; others are refused; asks render as text. */ | |
| 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 { createApp } from "../src/app.ts"; | |
| 7 | import { createToken } from "../src/core/tokens.ts"; | |
| 8 | import { createUser } from "../src/core/users.ts"; | |
| 9 | import { recordRefusal } from "../src/core/first_ask_refusals.ts"; | |
| 10 | import { serve } from "../src/http/server.ts"; | |
| 11 | ||
| 12 | let dir = ""; | |
| 13 | let app: ReturnType<typeof createApp>; | |
| 14 | let servers: Awaited<ReturnType<typeof serve>>; | |
| 15 | let admin = ""; | |
| 16 | let other = ""; | |
| 17 | const get = (path: string, token: string) => fetch(servers.url + path, { headers: { authorization: `Bearer ${token}` } }); | |
| 18 | ||
| 19 | beforeAll(async () => { | |
| 20 | dir = await mkdtemp(join(tmpdir(), "forge-refused-")); | |
| 21 | app = createApp({ dataDir: dir, port: 0 }); | |
| 22 | servers = await serve(app); | |
| 23 | const a = await createUser(app.db, { origin: "seed", trusted: true, username: "alice", email: "[email protected]", password: "a-good-passphrase", adminIfFirst: true }); | |
| 24 | const b = await createUser(app.db, { origin: "seed", trusted: true, username: "bob", email: "[email protected]", password: "a-good-passphrase" }); | |
| 25 | if (!a.ok || !b.ok) throw new Error("users"); | |
| 26 | admin = createToken(app.db, a.value.id, "t", "admin").token; | |
| 27 | other = createToken(app.db, b.value.id, "t", "admin").token; | |
| 28 | }); | |
| 29 | afterAll(async () => { | |
| 30 | servers.stop(); | |
| 31 | app.close(); | |
| 32 | await rm(dir, { recursive: true, force: true }); | |
| 33 | }); | |
| 34 | ||
| 35 | test("empty is one quiet line, and /forges links to the page", async () => { | |
| 36 | const res = await get("/forges/refused", admin); | |
| 37 | expect(res.status).toBe(200); | |
| 38 | expect(await res.text()).toContain("No first ask has been refused."); | |
| 39 | expect(await (await get("/forges", admin)).text()).toContain('<a href="/forges/refused">Refused first asks</a>'); | |
| 40 | }); | |
| 41 | ||
| 42 | test("an admin sees the rows, escaped, and /forges counts them", async () => { | |
| 43 | recordRefusal(app.db, { actorId: null, ask: "<script>alert(1)</script>", words: "Say something." }, Date.now()); | |
| 44 | const body = await (await get("/forges/refused", admin)).text(); | |
| 45 | expect(body).toContain("<script>alert(1)</script>"); | |
| 46 | expect(body).not.toContain("<script>alert(1)"); | |
| 47 | expect(body).toContain("Say something."); | |
| 48 | expect(body).not.toContain("@forge.local"); | |
| 49 | expect(await (await get("/forges", admin)).text()).toContain("Refused first asks (1)"); | |
| 50 | }); | |
| 51 | ||
| 52 | test("a non-admin gets the answer /forges gives", async () => { | |
| 53 | const a = await get("/forges/refused", other); | |
| 54 | const b = await get("/forges", other); | |
| 55 | expect(a.status).toBe(b.status); | |
| 56 | expect(a.status).toBe(404); | |
| 57 | expect(await a.text()).not.toContain("alert(1)"); | |
| 58 | }); |