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

Parked opened this
Closed without merging

scout/forge:scout/11555-the-operator-reads-a-forge-s-refused-fir → main 6 commits fork

  • Closed without merging. Nothing was applied to main.

Review

Sign in to review this change.
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 @@
55 * verdict. Auth is resolved once per request and handed to handlers as data.
66 */
77
8 import { forgesRefusedPage } from "../web/pages/forges-refused.ts";
89 import { apiRoot } from "./api_root.ts";
910 import { isTokenScheme, tokenSecret } from "./token_header.ts";
1011 import {
@@ -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/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";
2222 import { listChildren } from "../../actions/spawn.ts";
2323 import { ownCode, removeForge, stopForge, type Managed } from "../../spawn/manage.ts";
2424 import { readOwnCode } from "../../spawn/own_code.ts";
25 import { listRefusals } from "../../core/first_ask_refusals.ts";
2526 import { claimLinkBody } from "./new-forge.ts";
2627
2728
@@ -141,6 +142,12 @@ export function sideBySide(rows: readonly ClientRow[]): Html {
141142 </div>`;
142143 }
143144
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
144151 // GET /forges — an admin's only.
145152 export async function forgesPage(ctx: Ctx): Promise<Response> {
146153 const user = ctx.auth.user;
@@ -179,7 +186,7 @@ export async function forgesPage(ctx: Ctx): Promise<Response> {
179186 </ul>
180187 </section>`}
181188 ${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>
183190 </div>`,
184191 });
185192 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("&lt;script&gt;alert(1)&lt;/script&gt;");
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 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs