Public: anyone can read this; the crew can change it.
An outsider's report form on a public app says what happens next in two lines, and answers 429 after five a minute #11441
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/11436-an-outsider-s-report-form-on-a-public-ap → main 6 commits fork 2 threads · 2 unresolved
9 files
changed +128
−17
docs/MCP.md +1 −1
| @@ -133,7 +133,7 @@ bytes in `tools/list`. After changing a tool run `bun scripts/mcp-table.ts`; | ||
| 133 | 133 | | `land_past_gate` | — | Land one pull past a broken gate, with a recorded reason; an owner's only | 321 | |
| 134 | 134 | | `lift` | — | Lift an issue out from behind the Later gate | 214 | |
| 135 | 135 | | `limb` | `defer`, `resume`, `close`, `raise`, `lower`, `replan`, `takeback`, `undo` | Act on a limb by `action` | 532 | |
| 136 | | `limits` | — | Every limit the owner can hit | 1005 | | |
| 136 | | `limits` | — | Every limit the owner can hit | 1025 | | |
| 137 | 137 | | `maintenance` | `status`, `prepare`, `resume`, `pause-duty`, `resume-duty` | Prepare the forge for a power-off, check the drain, or end maintenance (admin) | 308 | |
| 138 | 138 | | `make_it_yours` | — | Fork an app you can see: code and history, following the original | 337 | |
| 139 | 139 | | `map` | — | An app's plan: ready or waiting, critical path, crew, parkedTwice; `done` adds closed; `first: N` ranks N first | 375 | |
docs/features/report-form.md added +8
| @@ -0,0 +1,8 @@ | ||
| 1 | # The report form for outsiders | |
| 2 | ||
| 3 | Anyone may tell a public repository something is wrong at `/<owner>/<name>/report`, with no account. | |
| 4 | ||
| 5 | - **Two lines.** Above the form: who reads the report (a person who maintains the repository, before anything acts on it) and what sending gives you (a link). Words: `src/actions/report_words.ts`. | |
| 6 | - **The sent link.** Sending redirects to `/<owner>/<name>/report?sent=<id>`. It shows "Not read yet" until a person decides, then either the issue it became or that it was closed without one. It never shows the report's text or any reply. | |
| 7 | - **The limit.** One source may send `report.per_source` reports a minute (default 5); the next gets 429 with `Retry-After`. Set it on the limits page or the `limits` tool; it is read on every report, so a change applies at once. Per-repository and per-forge caps stay in `src/actions/reports.ts`. | |
| 8 | - **Terminal.** None for outsiders: the terminal needs an account. Signed-in people report from a repository's `:` palette (`src/tui/screens/report.ts`) and read waiting reports on the reports page. |
src/actions/report_words.ts +10
| @@ -12,3 +12,13 @@ export const REPORT_LINK_TITLE = "Report a problem (no account needed)"; | ||
| 12 | 12 | /** The answer to a report that was filed and now waits for a person. */ |
| 13 | 13 | export const REPORT_RECEIVED = |
| 14 | 14 | "Received. A person who maintains this repository will read it; nothing acts on it before they do."; |
| 15 | ||
| 16 | /** The form's first line: who reads a report. */ | |
| 17 | export const REPORT_WHO_READS = "A person who maintains this repository reads it first; nothing acts on it before they do."; | |
| 18 | ||
| 19 | /** The form's second line: where the answer appears. */ | |
| 20 | export const REPORT_WHERE_REPLY = "Sending gives you a link that shows whether a person has read it and the issue it became."; | |
| 21 | ||
| 22 | /** The form's refusal when one address files too fast. */ | |
| 23 | export const reportTooFast = (seconds: number): string => | |
| 24 | `Too many reports from here just now. Try again in ${seconds} seconds.`; |
src/actions/reports.ts +6 −2
| @@ -48,9 +48,9 @@ import { getUserById, type User } from "../core/users.ts"; | ||
| 48 | 48 | import { defect } from "../kernel/error.ts"; |
| 49 | 49 | import { err, ok, type Result } from "../kernel/result.ts"; |
| 50 | 50 | import { isMirror } from "../origins/write-through.ts"; |
| 51 | import { limitValue } from "../settings/limits.ts"; | |
| 51 | 52 | import { openIssueThrough, setIssueLabels } from "./issues.ts"; |
| 52 | 53 | |
| 53 | const PER_SOURCE = 3; | |
| 54 | 54 | const PER_REPO = 20; |
| 55 | 55 | const PER_FORGE = 60; |
| 56 | 56 | |
| @@ -62,6 +62,7 @@ interface Limits { | ||
| 62 | 62 | |
| 63 | 63 | const limiter = (perMinute: number) => |
| 64 | 64 | new RateLimiter({ perMinute, adminPerMinute: perMinute, enabled: true }); |
| 65 | const perSourceOf = (app: App) => limitValue(app.db, "report.per_source"); | |
| 65 | 66 | |
| 66 | 67 | /** One set per forge, so a test's forge never throttles another's. */ |
| 67 | 68 | const limits = new WeakMap<App, Limits>(); |
| @@ -69,7 +70,7 @@ function limitsOf(app: App): Limits { | ||
| 69 | 70 | let found = limits.get(app); |
| 70 | 71 | if (found === undefined) { |
| 71 | 72 | found = { |
| 72 | source: limiter(PER_SOURCE), | |
| 73 | source: limiter(perSourceOf(app)), | |
| 73 | 74 | repo: limiter(PER_REPO), |
| 74 | 75 | forge: limiter(PER_FORGE), |
| 75 | 76 | }; |
| @@ -99,6 +100,9 @@ export function fileReport( | ||
| 99 | 100 | const clean = cleanReport(input); |
| 100 | 101 | if (!clean.ok) return err({ error: clean.error }); |
| 101 | 102 | const { forge, repo: perRepo, source: perSource } = limitsOf(app); |
| 103 | // The per-source number is a setting (`report.per_source`), read on every use. | |
| 104 | const perMinute = perSourceOf(app); | |
| 105 | perSource.setLimits({ perMinute, adminPerMinute: perMinute, enabled: true }); | |
| 102 | 106 | for (const verdict of [ |
| 103 | 107 | () => forge.take("forge", "ro"), |
| 104 | 108 | () => perRepo.take(repo.id, "ro"), |
src/core/reports.ts +12
| @@ -317,3 +317,15 @@ export function undecideReport(db: Database, reportId: string): void { | ||
| 317 | 317 | WHERE id = ?1 AND issue_number IS NULL`, |
| 318 | 318 | ).run(reportId); |
| 319 | 319 | } |
| 320 | ||
| 321 | /** Where a report stands, for the link its filer was given: state and issue, never words. */ | |
| 322 | export function reportStanding( | |
| 323 | db: Database, | |
| 324 | repoId: string, | |
| 325 | reportId: string, | |
| 326 | ): { state: ReportState; issue: number | null } | undefined { | |
| 327 | const row = db | |
| 328 | .query("SELECT state, issue_number AS issue FROM report WHERE repo_id = ?1 AND id = ?2") | |
| 329 | .get(repoId, reportId) as { state: ReportState; issue: number | null } | null; | |
| 330 | return row ?? undefined; | |
| 331 | } |
src/settings/limits.ts +9
| @@ -49,6 +49,7 @@ export type LimitKey = | ||
| 49 | 49 | | "guest.fork_storage_mb" |
| 50 | 50 | | "guest.cooldown_minutes" |
| 51 | 51 | | "api.anonymous_per_source" |
| 52 | | "report.per_source" | |
| 52 | 53 | | "model_review.auto" |
| 53 | 54 | | "model_review.min_landed" |
| 54 | 55 | | "model_review.slack_pct" |
| @@ -282,6 +283,14 @@ export const LIMITS: Record<LimitKey, LimitDef> = { | ||
| 282 | 283 | max: 100_000, |
| 283 | 284 | reason: "A token's reads are counted by the token; a stranger's have no token to count, so one source reading without one has a ceiling of its own.", |
| 284 | 285 | }, |
| 286 | "report.per_source": { | |
| 287 | label: "Reports from outside per source a minute", | |
| 288 | unit: "reports", | |
| 289 | default: 5, | |
| 290 | min: 1, | |
| 291 | max: 1_000, | |
| 292 | reason: "Anyone may file a report with no account, so one address cannot flood the people who read them; the sixth in a minute waits.", | |
| 293 | }, | |
| 285 | 294 | "model_review.auto": { |
| 286 | 295 | label: "Weekly model review applies a cheaper model on its own", |
| 287 | 296 | unit: "0 asks first, 1 applies", |
src/web/pages/reports.ts +28 −7
| @@ -11,6 +11,11 @@ import { | ||
| 11 | 11 | fileReport, |
| 12 | 12 | waitingReports, |
| 13 | 13 | } from "../../actions/reports.ts"; |
| 14 | import { | |
| 15 | REPORT_WHERE_REPLY, | |
| 16 | REPORT_WHO_READS, | |
| 17 | reportTooFast, | |
| 18 | } from "../../actions/report_words.ts"; | |
| 14 | 19 | import { |
| 15 | 20 | BODY_MAX, |
| 16 | 21 | OUTSIDE_TITLE, |
| @@ -19,6 +24,7 @@ import { | ||
| 19 | 24 | byFiler, |
| 20 | 25 | filerWords, |
| 21 | 26 | outsideWords, |
| 27 | reportStanding, | |
| 22 | 28 | type FiledBy, |
| 23 | 29 | type Report, |
| 24 | 30 | } from "../../core/reports.ts"; |
| @@ -84,10 +90,13 @@ function reportForm( | ||
| 84 | 90 | <h1 class="mt">Report a problem</h1> |
| 85 | 91 | <div class="narrow-wide mt"> |
| 86 | 92 | <p> |
| 87 | Tell the people who keep ${repo.ownerName}/${repo.name} that | |
| 88 | something is wrong. You need no account. A person reads it first; | |
| 89 | nothing acts on it before they do. | |
| 93 | Tell the maintainers of ${repo.ownerName}/${repo.name} that | |
| 94 | something is wrong. You need no account. | |
| 90 | 95 | </p> |
| 96 | <ul class="hint" id="report-next"> | |
Principle 1 and 8: the two lines are repeated on the sent page, and line two promises a reply that the link only partly delivers. Say it once, truthfully. |
||
| 97 | <li>${REPORT_WHO_READS}</li> | |
| 98 | <li>${REPORT_WHERE_REPLY}</li> | |
| 99 | </ul> | |
| 91 | 100 | ${ctx.auth.user |
| 92 | 101 | ? html`<p class="hint"> |
| 93 | 102 | Sent as @${ctx.auth.user.username}: the people who keep it see |
| @@ -146,7 +155,10 @@ export function reportPage(ctx: Ctx): Response { | ||
| 146 | 155 | } |
| 147 | 156 | |
| 148 | 157 | function reportBody(ctx: Ctx, repo: Repo): Response { |
| 149 | if (ctx.url.searchParams.get("sent") === "1") | |
| 158 | const sentId = ctx.url.searchParams.get("sent"); | |
| 159 | if (sentId !== null && sentId !== "") { | |
| 160 | const standing = reportStanding(ctx.app.db, repo.id, sentId.slice(0, 64)); | |
| 161 | const base = repoBase(repo); | |
| 150 | 162 | return page({ |
| 151 | 163 | ctx, |
| 152 | 164 | title: `Report sent · ${repo.ownerName}/${repo.name}`, |
| @@ -157,9 +169,15 @@ function reportBody(ctx: Ctx, repo: Repo): Response { | ||
| 157 | 169 | })} |
| 158 | 170 | <section class="settings-block"> |
| 159 | 171 | <h1>Report sent</h1> |
| 160 | <p>A person who keeps this repository will read it.</p> | |
| 172 | ${standing?.state === "accepted" && standing.issue !== null | |
| 173 | ? html`<p>A person read it and opened | |
| 174 | <a href="${base}/issues/${standing.issue}">an issue</a> from it.</p>` | |
| 175 | : standing?.state === "dismissed" | |
| 176 | ? html`<p>A person read it and closed it without an issue.</p>` | |
| 177 | : html`<p>Not read yet. Keep this link and look again.</p>`} | |
| 161 | 178 | </section>`, |
| 162 | 179 | }); |
| 180 | } | |
| 163 | 181 | return reportForm(ctx, repo, { |
| 164 | 182 | title: "", |
| 165 | 183 | body: "", |
| @@ -204,7 +222,10 @@ export async function reportSubmit(ctx: Ctx): Promise<Response> { | ||
| 204 | 222 | clientBucket(ctx.request), |
| 205 | 223 | proofOf(ctx), |
| 206 | 224 | ); |
| 207 | if (filed.ok) return flashTo(ctx, sent, { message: "Report sent." }); | |
| 225 | if (filed.ok) | |
| 226 | return flashTo(ctx, `${repoBase(repo)}/report?sent=${filed.value.id}`, { | |
| 227 | message: "Report sent.", | |
| 228 | }); | |
| 208 | 229 | const refusal = filed.error; |
| 209 | 230 | if (refusal.error === "not_found") return notFoundPage(ctx); |
| 210 | 231 | if (refusal.error === "rate_limited") { |
| @@ -213,7 +234,7 @@ export async function reportSubmit(ctx: Ctx): Promise<Response> { | ||
| 213 | 234 | repo, |
| 214 | 235 | values, |
| 215 | 236 | { |
| 216 | message: `Too many reports just now. Try again in ${refusal.retryAfter} seconds.`, | |
| 237 | message: reportTooFast(refusal.retryAfter), | |
| 217 | 238 | }, |
| 218 | 239 | 429, |
| 219 | 240 | ); |
test/muse-journey.test.ts +3 −3
| @@ -206,7 +206,7 @@ describe("the Muse test: a stranger agent needs no teaching", () => { | ||
| 206 | 206 | const before = pending(); |
| 207 | 207 | const one = { "x-forwarded-for": "203.0.113.77" }; |
| 208 | 208 | const statuses: number[] = []; |
| 209 | for (let i = 0; i < 5; i++) { | |
| 209 | for (let i = 0; i < 6; i++) { | |
| 210 | 210 | const res = await fetch(`${servers.url}/api/v1/repos/alice/notes/reports`, { |
| 211 | 211 | method: "POST", |
| 212 | 212 | headers: { "content-type": "application/json", ...one }, |
| @@ -214,7 +214,7 @@ describe("the Muse test: a stranger agent needs no teaching", () => { | ||
| 214 | 214 | }); |
| 215 | 215 | statuses.push(res.status); |
| 216 | 216 | } |
| 217 | expect(statuses).toEqual([202, 202, 202, 429, 429]); | |
| 217 | expect(statuses).toEqual([202, 202, 202, 202, 202, 429]); | |
| 218 | 218 | // The MCP tool is the same door and the same source, so the same limit. |
| 219 | 219 | const limited = (await ( |
| 220 | 220 | await rpc( |
| @@ -242,7 +242,7 @@ describe("the Muse test: a stranger agent needs no teaching", () => { | ||
| 242 | 242 | params: { name: "report", arguments: { repo: "alice/notes", title: "t", body: "x".repeat(70_000) } }, |
| 243 | 243 | }); |
| 244 | 244 | expect(hugeMcp.status).toBe(413); |
| 245 | expect(pending()).toBe(before + 3); | |
| 245 | expect(pending()).toBe(before + 5); | |
| 246 | 246 | }); |
| 247 | 247 | |
| 248 | 248 | test("a private app's door answers neither route and leaks no repository name", async () => { |
test/reports.test.ts +51 −4
| @@ -9,10 +9,12 @@ import { mkdtemp, rm } from "node:fs/promises"; | ||
| 9 | 9 | import { tmpdir } from "node:os"; |
| 10 | 10 | import { join } from "node:path"; |
| 11 | 11 | import { addToCrew, createAgent } from "../src/actions/crew.ts"; |
| 12 | import { REPORT_WHERE_REPLY, REPORT_WHO_READS } from "../src/actions/report_words.ts"; | |
| 12 | 13 | import { acceptReport, acceptedBody } from "../src/actions/reports.ts"; |
| 13 | 14 | import { renderMarkdown } from "../src/web/markdown.ts"; |
| 14 | 15 | import { createRepo } from "../src/actions/repos.ts"; |
| 15 | 16 | import { createApp, type App } from "../src/app.ts"; |
| 17 | import { setLimit } from "../src/settings/limits.ts"; | |
| 16 | 18 | import { getIssue } from "../src/core/issues.ts"; |
| 17 | 19 | import { OUTSIDE_TITLE, outsideTitle } from "../src/core/reports.ts"; |
| 18 | 20 | import { taskPrompt } from "../src/crew/brief.ts"; |
| @@ -229,7 +231,7 @@ describe("reports from outside", () => { | ||
| 229 | 231 | const before = count("report"); |
| 230 | 232 | const source = { "x-forwarded-for": "203.0.113.9" }; |
| 231 | 233 | const statuses: number[] = []; |
| 232 | for (let i = 0; i < 5; i++) { | |
| 234 | for (let i = 0; i < 7; i++) { | |
| 233 | 235 | const res = await report("alice/game", { title: `t${i}` }, source); |
| 234 | 236 | statuses.push(res.status); |
| 235 | 237 | if (res.status === 429) { |
| @@ -237,9 +239,9 @@ describe("reports from outside", () => { | ||
| 237 | 239 | expect(((await res.json()) as any).error).toBe("rate_limited"); |
| 238 | 240 | } |
| 239 | 241 | } |
| 240 | expect(statuses.slice(0, 3)).toEqual([202, 202, 202]); | |
| 241 | expect(statuses.slice(3)).toEqual([429, 429]); | |
| 242 | expect(count("report")).toBe(before + 3); | |
| 242 | expect(statuses.slice(0, 5)).toEqual([202, 202, 202, 202, 202]); | |
| 243 | expect(statuses.slice(5)).toEqual([429, 429]); | |
| 244 | expect(count("report")).toBe(before + 5); | |
| 243 | 245 | }); |
| 244 | 246 | |
| 245 | 247 | test("the web form's honeypot looks accepted and stores nothing", async () => { |
| @@ -281,6 +283,51 @@ describe("reports from outside", () => { | ||
| 281 | 283 | expect(hidden.status).toBe(404); |
| 282 | 284 | }); |
| 283 | 285 | |
| 286 | test("the form says who reads it and where the reply appears; the web form answers 429 after five a minute", async () => { | |
| 287 | // Its own repository, so the per-repository cap other tests spend is not drawn on. | |
| 288 | const side = await createRepo(app, alice, { name: "side", visibility: "public" }); | |
| 289 | if (!side.ok) throw new Error(String(side.error)); | |
| 290 | const page = await (await fetch(`${base}/alice/side/report`)).text(); | |
| 291 | expect(page).toContain(REPORT_WHO_READS); | |
| 292 | expect(page).toContain(REPORT_WHERE_REPLY); | |
| 293 | const source = { "x-forwarded-for": "198.51.100.77" }; | |
| 294 | const post = () => | |
| 295 | fetch(`${base}/alice/side/report`, { | |
| 296 | method: "POST", | |
| 297 | headers: { "content-type": "application/x-www-form-urlencoded", ...source }, | |
| 298 | body: "title=flood&body=x", | |
| 299 | redirect: "manual", | |
| 300 | }); | |
| 301 | let link = ""; | |
| 302 | for (let i = 0; i < 5; i++) { | |
| 303 | const ok = await post(); | |
| 304 | expect(ok.status).toBe(303); | |
| 305 | link = ok.headers.get("location") ?? ""; | |
| 306 | } | |
| 307 | // The returned link is this report's own, and says where it stands. | |
| 308 | expect(link).toMatch(/^\/alice\/side\/report\?sent=\w+/); | |
| 309 | const sentPage = await (await fetch(`${base}${link}`)).text(); | |
| 310 | expect(sentPage).toContain("Not read yet"); | |
| 311 | const sixth = await post(); | |
| 312 | expect(sixth.status).toBe(429); | |
| 313 | expect(Number(sixth.headers.get("retry-after"))).toBeGreaterThan(0); | |
| 314 | expect(await sixth.text()).toContain("Too many reports from here just now"); | |
| 315 | }); | |
| 316 | ||
| 317 | test("report.per_source is read on every use: set to 2, the third is 429", async () => { | |
| 318 | const side = await createRepo(app, alice, { name: "dial", visibility: "public" }); | |
| 319 | if (!side.ok) throw new Error(String(side.error)); | |
| 320 | expect(setLimit(app.db, "report.per_source", 2, alice.id).ok).toBe(true); | |
| 321 | try { | |
| 322 | const source = { "x-forwarded-for": "198.51.100.88" }; | |
| 323 | const statuses: number[] = []; | |
| 324 | for (let i = 0; i < 3; i++) statuses.push((await report("alice/dial", { title: `d${i}` }, source)).status); | |
| 325 | expect(statuses).toEqual([202, 202, 429]); | |
| 326 | } finally { | |
| 327 | setLimit(app.db, "report.per_source", null, alice.id); | |
| 328 | } | |
| 329 | }); | |
| 330 | ||
| 284 | 331 | test("the owner sees reports marked from outside, escaped, and only the owner", async () => { |
| 285 | 332 | const anon = await fetch(`${base}/alice/game/reports`, { |
| 286 | 333 | redirect: "manual", |
Magic 5 duplicating the setting's default, overwritten by setLimits below. Build from limitValue; no second copy of the number.