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

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

scout/forge:scout/11436-an-outsider-s-report-form-on-a-public-ap → main 6 commits fork 2 threads · 2 unresolved

  • Merged. scout/11436-an-outsider-s-report-form-on-a-public-ap was merged into main as 0e0d666.

Review

Sign in to review this change.
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`;
133133 | `land_past_gate` | — | Land one pull past a broken gate, with a recorded reason; an owner's only | 321 |
134134 | `lift` | — | Lift an issue out from behind the Later gate | 214 |
135135 | `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 |
137137 | `maintenance` | `status`, `prepare`, `resume`, `pause-duty`, `resume-duty` | Prepare the forge for a power-off, check the drain, or end maintenance (admin) | 308 |
138138 | `make_it_yours` | — | Fork an app you can see: code and history, following the original | 337 |
139139 | `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)";
1212 /** The answer to a report that was filed and now waits for a person. */
1313 export const REPORT_RECEIVED =
1414 "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";
4848 import { defect } from "../kernel/error.ts";
4949 import { err, ok, type Result } from "../kernel/result.ts";
5050 import { isMirror } from "../origins/write-through.ts";
51 import { limitValue } from "../settings/limits.ts";
5152 import { openIssueThrough, setIssueLabels } from "./issues.ts";
5253
53 const PER_SOURCE = 3;
5454 const PER_REPO = 20;
5555 const PER_FORGE = 60;
5656
@@ -62,6 +62,7 @@ interface Limits {
6262
6363 const limiter = (perMinute: number) =>
6464 new RateLimiter({ perMinute, adminPerMinute: perMinute, enabled: true });
65 const perSourceOf = (app: App) => limitValue(app.db, "report.per_source");
6566
6667 /** One set per forge, so a test's forge never throttles another's. */
6768 const limits = new WeakMap<App, Limits>();
@@ -69,7 +70,7 @@ function limitsOf(app: App): Limits {
6970 let found = limits.get(app);
7071 if (found === undefined) {
7172 found = {
outdated

Magic 5 duplicating the setting's default, overwritten by setLimits below. Build from limitValue; no second copy of the number.

72 source: limiter(PER_SOURCE),
73 source: limiter(perSourceOf(app)),
7374 repo: limiter(PER_REPO),
7475 forge: limiter(PER_FORGE),
7576 };
@@ -99,6 +100,9 @@ export function fileReport(
99100 const clean = cleanReport(input);
100101 if (!clean.ok) return err({ error: clean.error });
101102 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 });
102106 for (const verdict of [
103107 () => forge.take("forge", "ro"),
104108 () => perRepo.take(repo.id, "ro"),
src/core/reports.ts +12
@@ -317,3 +317,15 @@ export function undecideReport(db: Database, reportId: string): void {
317317 WHERE id = ?1 AND issue_number IS NULL`,
318318 ).run(reportId);
319319 }
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 =
4949 | "guest.fork_storage_mb"
5050 | "guest.cooldown_minutes"
5151 | "api.anonymous_per_source"
52 | "report.per_source"
5253 | "model_review.auto"
5354 | "model_review.min_landed"
5455 | "model_review.slack_pct"
@@ -282,6 +283,14 @@ export const LIMITS: Record<LimitKey, LimitDef> = {
282283 max: 100_000,
283284 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.",
284285 },
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 },
285294 "model_review.auto": {
286295 label: "Weekly model review applies a cheaper model on its own",
287296 unit: "0 asks first, 1 applies",
src/web/pages/reports.ts +28 −7
@@ -11,6 +11,11 @@ import {
1111 fileReport,
1212 waitingReports,
1313 } from "../../actions/reports.ts";
14 import {
15 REPORT_WHERE_REPLY,
16 REPORT_WHO_READS,
17 reportTooFast,
18 } from "../../actions/report_words.ts";
1419 import {
1520 BODY_MAX,
1621 OUTSIDE_TITLE,
@@ -19,6 +24,7 @@ import {
1924 byFiler,
2025 filerWords,
2126 outsideWords,
27 reportStanding,
2228 type FiledBy,
2329 type Report,
2430 } from "../../core/reports.ts";
@@ -84,10 +90,13 @@ function reportForm(
8490 <h1 class="mt">Report a problem</h1>
8591 <div class="narrow-wide mt">
8692 <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.
9095 </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>
91100 ${ctx.auth.user
92101 ? html`<p class="hint">
93102 Sent as @${ctx.auth.user.username}: the people who keep it see
@@ -146,7 +155,10 @@ export function reportPage(ctx: Ctx): Response {
146155 }
147156
148157 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);
150162 return page({
151163 ctx,
152164 title: `Report sent · ${repo.ownerName}/${repo.name}`,
@@ -157,9 +169,15 @@ function reportBody(ctx: Ctx, repo: Repo): Response {
157169 })}
158170 <section class="settings-block">
159171 <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>`}
161178 </section>`,
162179 });
180 }
163181 return reportForm(ctx, repo, {
164182 title: "",
165183 body: "",
@@ -204,7 +222,10 @@ export async function reportSubmit(ctx: Ctx): Promise<Response> {
204222 clientBucket(ctx.request),
205223 proofOf(ctx),
206224 );
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 });
208229 const refusal = filed.error;
209230 if (refusal.error === "not_found") return notFoundPage(ctx);
210231 if (refusal.error === "rate_limited") {
@@ -213,7 +234,7 @@ export async function reportSubmit(ctx: Ctx): Promise<Response> {
213234 repo,
214235 values,
215236 {
216 message: `Too many reports just now. Try again in ${refusal.retryAfter} seconds.`,
237 message: reportTooFast(refusal.retryAfter),
217238 },
218239 429,
219240 );
test/muse-journey.test.ts +3 −3
@@ -206,7 +206,7 @@ describe("the Muse test: a stranger agent needs no teaching", () => {
206206 const before = pending();
207207 const one = { "x-forwarded-for": "203.0.113.77" };
208208 const statuses: number[] = [];
209 for (let i = 0; i < 5; i++) {
209 for (let i = 0; i < 6; i++) {
210210 const res = await fetch(`${servers.url}/api/v1/repos/alice/notes/reports`, {
211211 method: "POST",
212212 headers: { "content-type": "application/json", ...one },
@@ -214,7 +214,7 @@ describe("the Muse test: a stranger agent needs no teaching", () => {
214214 });
215215 statuses.push(res.status);
216216 }
217 expect(statuses).toEqual([202, 202, 202, 429, 429]);
217 expect(statuses).toEqual([202, 202, 202, 202, 202, 429]);
218218 // The MCP tool is the same door and the same source, so the same limit.
219219 const limited = (await (
220220 await rpc(
@@ -242,7 +242,7 @@ describe("the Muse test: a stranger agent needs no teaching", () => {
242242 params: { name: "report", arguments: { repo: "alice/notes", title: "t", body: "x".repeat(70_000) } },
243243 });
244244 expect(hugeMcp.status).toBe(413);
245 expect(pending()).toBe(before + 3);
245 expect(pending()).toBe(before + 5);
246246 });
247247
248248 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";
99 import { tmpdir } from "node:os";
1010 import { join } from "node:path";
1111 import { addToCrew, createAgent } from "../src/actions/crew.ts";
12 import { REPORT_WHERE_REPLY, REPORT_WHO_READS } from "../src/actions/report_words.ts";
1213 import { acceptReport, acceptedBody } from "../src/actions/reports.ts";
1314 import { renderMarkdown } from "../src/web/markdown.ts";
1415 import { createRepo } from "../src/actions/repos.ts";
1516 import { createApp, type App } from "../src/app.ts";
17 import { setLimit } from "../src/settings/limits.ts";
1618 import { getIssue } from "../src/core/issues.ts";
1719 import { OUTSIDE_TITLE, outsideTitle } from "../src/core/reports.ts";
1820 import { taskPrompt } from "../src/crew/brief.ts";
@@ -229,7 +231,7 @@ describe("reports from outside", () => {
229231 const before = count("report");
230232 const source = { "x-forwarded-for": "203.0.113.9" };
231233 const statuses: number[] = [];
232 for (let i = 0; i < 5; i++) {
234 for (let i = 0; i < 7; i++) {
233235 const res = await report("alice/game", { title: `t${i}` }, source);
234236 statuses.push(res.status);
235237 if (res.status === 429) {
@@ -237,9 +239,9 @@ describe("reports from outside", () => {
237239 expect(((await res.json()) as any).error).toBe("rate_limited");
238240 }
239241 }
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);
243245 });
244246
245247 test("the web form's honeypot looks accepted and stores nothing", async () => {
@@ -281,6 +283,51 @@ describe("reports from outside", () => {
281283 expect(hidden.status).toBe(404);
282284 });
283285
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
284331 test("the owner sees reports marked from outside, escaped, and only the owner", async () => {
285332 const anon = await fetch(`${base}/alice/game/reports`, {
286333 redirect: "manual",

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs