Public: anyone can read this; the crew can change it.
An admin reads the last 20 refused first asks on /forges/refused #11589
Check · Review · Land · Live — Its check is red.
scout/forge:scout/11586-an-admin-reads-the-last-20-refused-first → main 2 commits fork
4 files
changed +103
−1
scripts/hollow-allow.ts +3 −1
| @@ -18,4 +18,6 @@ export type Allowed = { | ||
| 18 | 18 | issue: number; |
| 19 | 19 | }; |
| 20 | 20 | |
| 21 | export const ALLOWED: readonly Allowed[] = []; | |
| 21 | export const ALLOWED: readonly Allowed[] = [ | |
| 22 | { orphan: "GET /forges/refused", reason: "the page exists; /forges links to it in the next pull", issue: 11587 }, | |
| 23 | ]; |
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/web/pages/forges-refused.ts added +40
| @@ -0,0 +1,40 @@ | ||
| 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 { listRefusals } from "../../core/first_ask_refusals.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. | |
| 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 | return page({ | |
| 22 | ctx, | |
| 23 | title: "Refused first asks", | |
| 24 | body: html`<div class="narrow-wide" data-refused> | |
| 25 | ${pageHead("Refused first asks", { sub: "The last 20 first asks the forge turned away, newest first." })} | |
| 26 | ${rows.length === 0 | |
| 27 | ? html`<p class="muted">No first ask has been refused.</p>` | |
| 28 | : html`<ul class="list setting-list"> | |
| 29 | ${rows.map( | |
| 30 | (r) => html`<li data-refusal> | |
| 31 | <p>${time(new Date(r.at).toISOString())} · <span class="muted">${r.actorId ?? "no one signed in"}</span></p> | |
| 32 | <p>${r.ask}</p> | |
| 33 | <p class="muted small">${r.words}</p> | |
| 34 | </li>`, | |
| 35 | )} | |
| 36 | </ul>`} | |
| 37 | <p class="mt"><a href="/forges">Forges you sent</a></p> | |
| 38 | </div>`, | |
| 39 | }); | |
| 40 | } |
test/forges-refused.test.ts added +58
| @@ -0,0 +1,58 @@ | ||
| 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 | }); |