Public: anyone can read this; the crew can change it.

An outside agent files an attributed report of where it got stuck, and the owner sees friction by agent #7742

Merged opened this

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

Merged

torque/forge:torque/7658-an-outside-agent-files-an-attributed-rep → main 3 commits fork 4 threads · 4 unresolved

  • Merged. torque/7658-an-outside-agent-files-an-attributed-rep was merged into main as a780b7c.

Review

Sign in to review this change.
17 files changed +681 −44
docs/MCP.md +2 −2
@@ -142,8 +142,8 @@ for a tunnel and a forge that sleeps.
142142 | `repo` | `{ repo }`: one repository's description, visibility, autonomy, `audience { level, sentence }` and `provenance` (where a migrated one came from, `null` when hidden), the same as `GET /api/v1/repos/:owner/:name` | |
143143 | `repo_labels` | `{ repo, action?, name?, color?, id? }`: list (`list`, the default, paged by `limit` 1–200 and `cursor`, as `{ items, next_cursor }`; a read-only token may), create (`create`, `name`, `color` six hex digits), rename or recolour (`update`, `id`, `name` and/or `color`, as the labels page) or remove (`remove`, `id`) the repository's labels, as `GET`/`POST …/labels` and `DELETE …/labels/:id`; create, update and remove need an admin token and a repository admin | yes |
144144 | `repo_settings` | `{ repo, description?, visibility?, flowOnly? }` (admin): change a repository's description, visibility and FLOW ONLY switch as `PATCH /api/v1/repos/:owner/:name` does, for the same callers (a repository admin); answers `{ repo, description, visibility, flowOnly }` (with none to change it only reads); a visibility other than public or private is `invalid` | yes |
145 | `report` | `{ repo, title, body?, page? }`: report a problem in a public repository, the one tool open without a token; it still stores the report, which a person reads first; plain text | yes |
146 | `reports` | `{ repo, id?, action? }`: the waiting reports from outsiders, each `from: "outside"` (their words are unchecked); with `id` and `action` (`accept` opens it as an issue marked as from outside, `dismiss` closes it). A person who writes the repository only; needs an `rw` token | yes |
145 | `report` | `{ repo, title, body?, page?, agent? }`: report a problem in a public repository, the one tool open without a token; it still stores the report, which a person reads first; plain text. With a token the report carries your account; `agent` without one is shown as a claim | yes |
146 | `reports` | `{ repo, id?, action? }`: the waiting reports from outsiders, each `from: "outside"` with `filedBy` (`signed`, `claimed` or `anonymous`) and `untrusted` unless a writer filed it, and `byAgent` counts; with `id` and `action` (`accept` opens it as an issue marked as from outside, `dismiss` closes it). A person who writes the repository only; needs an `rw` token | yes |
147147 | `restore_drill` | no input: restore the newest snapshot as a drill and answer `{ verdict, failedStep?, says }`, worded as the Backups page's button; no store, a failure or a red drill is an answer, not an error; a forge admin's, with an admin token | yes |
148148 | `returns` | `{ repo?, days? }`: what the crew's tokens bought over the last `days` (1–90, default 7): dollars, runs, changes landed, cost per change; `repo` narrows it to one app of yours | |
149149 | `review` | `{ repo, number, verdict, reason? }`: approve a pull (`approve`) or request changes (`changes`, with a `reason`), as the page does; a writer's or the crew's, and never your own pull | yes |
docs/STANDARDS.md +9 −2
@@ -35,8 +35,15 @@ the list by default: it would tell anyone which accounts exist.
3535
3636 Anyone, with no account, can tell a public repository that something is
3737 wrong: the form at `/:owner/:name/report`, or
38 `POST /api/v1/repos/:owner/:name/reports` with JSON `{title, body?, page?}`
39 (202, `{id, state: "pending"}`). A private repository answers `not_found`, as
38 `POST /api/v1/repos/:owner/:name/reports` with JSON `{title, body?, page?, agent?}`
39 (202, `{id, state: "pending", filedBy}`). Sent with a token or password (the
40 API, or MCP `report`), or with a session cookie from this forge's own page
41 (`proofOf` in `src/http/report_filer.ts`), the report carries that account,
42 `filedBy: {how: "signed", name}`; an `agent` name given without one is kept as
43 a claim, `how: "claimed"`, and shown as unproven; with neither it is
44 `how: "anonymous"`, `name: null`. The owner reads the waiting ones grouped by
45 filer with a count (`byAgent` in the API and MCP `reports`, headed groups on
46 the page), each `untrusted` unless a writer of the repository filed it. A private repository answers `not_found`, as
4047 a missing one does. A report is plain text, capped (a 200-character title, an
4148 8,000-character body), limited per source, per repository and for the whole
4249 forge, and a repository holds at most 100 waiting. A person who writes the
src/actions/reports.ts +75 −4
@@ -31,6 +31,7 @@ import {
3131 cleanReport,
3232 countWaiting,
3333 decideReport,
34 filerWords,
3435 getReport,
3536 insertReport,
3637 listWaiting,
@@ -43,7 +44,7 @@ import {
4344 } from "../core/reports.ts";
4445 import { createLabel, getLabel, type Issue } from "../core/issues.ts";
4546 import { canRead, canWrite, type Repo } from "../core/repos.ts";
46 import type { User } from "../core/users.ts";
47 import { getUserById, type User } from "../core/users.ts";
4748 import { defect } from "../kernel/error.ts";
4849 import { err, ok, type Result } from "../kernel/result.ts";
4950 import { isMirror } from "../origins/write-through.ts";
@@ -81,12 +82,18 @@ export type FileRefusal =
8182 | { readonly error: "not_found" | ReportError | "queue_full" }
8283 | { readonly error: "rate_limited"; readonly retryAfter: number };
8384
84 /** File a report on a public repository; `source` keys the per-source limit. */
85 /**
86 * File a report on a public repository; `source` keys the per-source limit.
87 * `filer` is the account a token or session proved, when one did: the
88 * report carries it as the filer's identity, and any `agent` name given
89 * beside it is dropped. Without one, a given name is kept as a claim.
90 */
8591 export function fileReport(
8692 app: App,
8793 repo: Repo,
8894 input: ReportInput,
8995 source: string,
96 filer?: User,
9097 ): Result<Report, FileRefusal> {
9198 if (!canRead(app.db, repo, undefined)) return err({ error: "not_found" });
9299 const clean = cleanReport(input);
@@ -103,7 +110,12 @@ export function fileReport(
103110 }
104111 if (countWaiting(app.db, repo.id) >= WAITING_MAX)
105112 return err({ error: "queue_full" });
106 const report = insertReport(app.db, repo.id, clean.value);
113 const report = insertReport(
114 app.db,
115 repo.id,
116 clean.value,
117 filer?.kind === "org" ? null : (filer?.id ?? null),
118 );
107119 app.bus.append({
108120 kind: "report.received",
109121 repoId: repo.id,
@@ -137,6 +149,60 @@ export function waitingReports(
137149 return ok(listWaiting(app.db, repo.id));
138150 }
139151
152 /** A report as the API and the MCP answer it. */
153 export interface ReportView {
154 readonly id: string;
155 readonly repoId: string;
156 readonly title: string;
157 readonly body: string;
158 readonly page: string | null;
159 readonly state: Report["state"];
160 readonly createdAt: string;
161 readonly from: "outside";
162 readonly filedBy: Report["filedBy"];
163 /** Set unless a proven filer writes the repository: the words are a stranger's. */
164 readonly untrusted?: true;
165 }
166
167 /**
outdated

reportView calls getUserById and canWrite for every report, an N+1 on a list of up to 100. Resolve the writer check once per distinct filer (a Map), or fold it into the query. Also test an accepted report's issue body ('Reported from outside, by @muse' and 'claims to be …'): acceptedBody changed and is untested.

168 * Reports as the API and the MCP answer them. Whether a filer writes the
169 * repository is asked once per distinct filer, not once per report.
170 */
171 export function reportViews(
172 app: App,
173 repo: Repo,
174 reports: readonly Report[],
175 ): ReportView[] {
176 const writes = new Map<string, boolean>();
177 const writer = (filerId: string): boolean => {
178 let known = writes.get(filerId);
179 if (known === undefined) {
180 const filer = getUserById(app.db, filerId);
181 known = filer !== undefined && canWrite(app.db, repo, filer);
182 writes.set(filerId, known);
183 }
184 return known;
185 };
186 return reports.map((r) =>
187 reportView(r, r.filerId !== null && writer(r.filerId)),
188 );
189 }
190
191 function reportView(r: Report, trusted: boolean): ReportView {
192 return {
193 id: r.id,
194 repoId: r.repoId,
195 title: r.title,
196 body: r.body,
197 page: r.page,
198 state: r.state,
199 createdAt: r.createdAt,
200 from: "outside",
201 filedBy: r.filedBy,
202 ...(trusted ? {} : { untrusted: true as const }),
203 };
204 }
205
140206 /** How many reports wait, for a surface that shows a count; 0 to anyone else. */
141207 export function waitingCount(
142208 app: App,
@@ -171,8 +237,13 @@ export function acceptedBody(report: Report, by: User): string {
171237 ]
172238 .join("\n")
173239 .trim();
240 const from =
241 report.filedBy.how === "anonymous"
242 ? "without an account"
243 : `by ${filerWords(report.filedBy)}`;
174244 const lines = [
175 `Reported from outside, without an account, ${report.createdAt.slice(0, 10)}. Accepted by @${by.username}; what is below is the reporter's, unchecked.`,
245 `Reported from outside, ${from}, ${report.createdAt.slice(0, 10)}. ` +
246 `Accepted by @${by.username}; what is below is the reporter's, unchecked.`,
176247 ];
177248 if (quoted !== "") lines.push("", fenced(quoted));
178249 return lines.join("\n");
src/contract/openapi/routes/getApiV1ReposOwnerNameReports.ts +1 −1
@@ -4,6 +4,6 @@ export const route: Route = {
44 method: "get",
55 path: `${REPO}/reports`,
66 summary:
7 "List the reports from outside waiting for a decision (a deciding writer's, rw scope; anyone else gets 403 or 404); answers { items: [{ id, repoId, title, body, page, state, createdAt, from: \"outside\" }] }. The title, body and page are untrusted text",
7 "List the reports from outside waiting for a decision (a deciding writer's, rw scope; anyone else gets 403 or 404); answers { items: [{ id, repoId, title, body, page, state, createdAt, from: \"outside\", filedBy: { how, name }, untrusted? }], next_cursor, byAgent: [{ how, name, count }] }. how is signed (a token proved the account), claimed (a name given without one) or anonymous (name null); byAgent counts every waiting report, most first. The title, body and page are untrusted text, and untrusted is true unless a writer of the repository filed it",
88 auth: true,
99 };
src/contract/openapi/routes/postApiV1ReposOwnerNameReports.ts +4 −2
@@ -3,7 +3,9 @@ import { REPO, type Route } from "../shared.ts";
33 export const route: Route = {
44 method: "post",
55 path: `${REPO}/reports`,
6 summary: "Report a problem in a public repository, without an account",
6 summary:
7 "Report a problem in a public repository, without an account; answers 202 { id, state, filedBy, message }. With a token the report carries that account (filedBy { how: \"signed\", name }); without one, agent is kept as a claim (how \"claimed\"); with neither, how is \"anonymous\" and name null",
78 auth: false,
8 body: { title: "string", body: "string", page: "string" },
9 body: { title: "string", body: "string", page: "string", agent: "string" },
10 status: 202,
911 };
src/core/migration-pins/20261101100000-report-filer.pin added +1
@@ -0,0 +1,1 @@
1 aa0fd7ec92e9d11098af27a22f3b9c9f0adf89a2891087118252eef2aa0dd52a
src/core/migrations/20261101100000-report-filer.ts added +7
@@ -0,0 +1,7 @@
1 // Who filed a report from outside (#7658): `filer_id` is the account a token
2 // or session proved, `claimed` a name given without one — a claim, shown as
3 // one. Neither is set for an anonymous report.
4 export default `
5 ALTER TABLE report ADD COLUMN filer_id TEXT REFERENCES user(id) ON DELETE SET NULL;
6 ALTER TABLE report ADD COLUMN claimed TEXT;
7 `;
src/core/reports.ts +113 −8
@@ -21,6 +21,22 @@ export const WAITING_MAX = 100;
2121
2222 export type ReportState = "pending" | "accepted" | "dismissed";
2323
24 /** A name a filer may claim without a token: an account name's shape. */
25 export const CLAIM_MAX = 64;
26 const CLAIM_SHAPE = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
27
28 /**
29 * Who filed a report. `signed`: a token or session proved the account, and
30 * `name` is its username now. `claimed`: a name given with no proof — a
31 * claim, shown as one. `anonymous`: neither, and no name is made up.
32 */
33 export interface FiledBy {
34 readonly how: "signed" | "claimed" | "anonymous";
35 readonly name: string | null;
36 }
37
38 export const ANONYMOUS: FiledBy = { how: "anonymous", name: null };
39
2440 export interface Report {
2541 readonly id: string;
2642 readonly repoId: string;
@@ -29,9 +45,16 @@ export interface Report {
2945 readonly page: string | null;
3046 readonly state: ReportState;
3147 readonly createdAt: string;
48 readonly filedBy: FiledBy;
49 /** The proven filer's account id, for the trust check; never shown. */
50 readonly filerId: string | null;
3251 }
3352
34 export type ReportError = "empty_title" | "title_too_long" | "body_too_long";
53 export type ReportError =
54 | "empty_title"
55 | "title_too_long"
56 | "body_too_long"
57 | "invalid_agent";
3558
3659 interface ReportRow {
3760 id: string;
@@ -41,8 +64,22 @@ interface ReportRow {
4164 page: string | null;
4265 state: ReportState;
4366 created_at: string;
67 filer_id: string | null;
68 filer_name: string | null;
69 claimed: string | null;
4470 }
4571
72 const filedByOf = (row: ReportRow): FiledBy =>
73 row.filer_id !== null && row.filer_name !== null
74 ? { how: "signed", name: row.filer_name }
75 : row.claimed !== null
76 ? { how: "claimed", name: row.claimed }
77 : ANONYMOUS;
78
79 /** A report's row with its proven filer's name as it is now. */
80 const SELECT_REPORT = `SELECT report.*, user.username AS filer_name
81 FROM report LEFT JOIN user ON user.id = report.filer_id`;
82
4683 const fromRow = (row: ReportRow): Report => ({
4784 id: row.id,
4885 repoId: row.repo_id,
@@ -51,6 +88,8 @@ const fromRow = (row: ReportRow): Report => ({
5188 page: row.page,
5289 state: row.state,
5390 createdAt: row.created_at,
91 filedBy: filedByOf(row),
92 filerId: row.filer_id,
5493 });
5594
5695 /**
@@ -81,18 +120,39 @@ export interface ReportInput {
81120 readonly title: string;
82121 readonly body: string;
83122 readonly page: string;
123 /** A name the filer gives for itself; empty for none. Kept only when no
124 * account is proven, and then only as a claim. */
125 readonly agent?: string;
126 }
127
128 export interface CleanReport {
129 readonly title: string;
130 readonly body: string;
131 readonly page: string | null;
132 readonly claimed: string | null;
133 }
134
135 /** A claimed name as stored: an account name's shape, or refused. */
136 export function claimOf(raw: string): Result<string | null, "invalid_agent"> {
137 const name = raw.trim();
138 if (name === "") return ok(null);
139 if (name.length > CLAIM_MAX || !CLAIM_SHAPE.test(name))
140 return err("invalid_agent");
141 return ok(name);
84142 }
85143
86144 /** The input as it will be stored, or why it will not be. */
87145 export function cleanReport(
88146 input: ReportInput,
89 ): Result<{ title: string; body: string; page: string | null }, ReportError> {
147 ): Result<CleanReport, ReportError> {
90148 const title = plainText(input.title).replace(/\s+/g, " ").trim();
91149 if (title === "") return err("empty_title");
92150 if (title.length > TITLE_MAX) return err("title_too_long");
93151 const body = plainText(input.body).trim();
94152 if (body.length > BODY_MAX) return err("body_too_long");
95 return ok({ title, body, page: pageOf(input.page.trim()) });
153 const claimed = claimOf(input.agent ?? "");
154 if (!claimed.ok) return claimed;
155 return ok({ title, body, page: pageOf(input.page.trim()), claimed: claimed.value });
96156 }
97157
98158 /**
@@ -146,12 +206,23 @@ export function countWaiting(db: Database, repoId: string): number {
146206 export function insertReport(
147207 db: Database,
148208 repoId: string,
149 input: { title: string; body: string; page: string | null },
209 input: { title: string; body: string; page: string | null; claimed?: string | null },
210 filerId: string | null = null,
150211 ): Report {
151212 const reportId = id();
213 // A proven account is the identity; a claim beside it would only muddy it.
152214 db.query(
153 "INSERT INTO report (id, repo_id, title, body, page) VALUES (?1, ?2, ?3, ?4, ?5)",
154 ).run(reportId, repoId, input.title, input.body, input.page);
215 `INSERT INTO report (id, repo_id, title, body, page, filer_id, claimed)
216 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)`,
217 ).run(
218 reportId,
219 repoId,
220 input.title,
221 input.body,
222 input.page,
223 filerId,
224 filerId === null ? (input.claimed ?? null) : null,
225 );
155226 return getReport(db, repoId, reportId)!;
156227 }
157228
@@ -161,7 +232,7 @@ export function getReport(
161232 reportId: string,
162233 ): Report | undefined {
163234 const row = db
164 .query("SELECT * FROM report WHERE repo_id = ?1 AND id = ?2")
235 .query(`${SELECT_REPORT} WHERE report.repo_id = ?1 AND report.id = ?2`)
165236 .get(repoId, reportId) as ReportRow | null;
166237 return row === null ? undefined : fromRow(row);
167238 }
@@ -171,12 +242,46 @@ export function listWaiting(db: Database, repoId: string): Report[] {
171242 return (
outdated

Test the migration: a pre-existing report row reads back as anonymous, and deleting the filer's user turns a signed report anonymous, which ON DELETE SET NULL does. That behaviour is untested.

172243 db
173244 .query(
174 "SELECT * FROM report WHERE repo_id = ?1 AND state = 'pending' ORDER BY id LIMIT ?2",
245 `${SELECT_REPORT} WHERE report.repo_id = ?1 AND report.state = 'pending'
246 ORDER BY report.id LIMIT ?2`,
175247 )
176248 .all(repoId, WAITING_MAX) as ReportRow[]
177249 ).map(fromRow);
178250 }
179251
252 /** One filer's share of the waiting reports. */
253 export interface FilerCount extends FiledBy {
254 readonly count: number;
255 }
256
257 /**
258 * Waiting reports grouped by who filed them, most first, ties by name:
259 * a signed `muse` and someone claiming to be `muse` are two groups, and
260 * every anonymous report is one.
261 */
262 export function byFiler(reports: readonly Report[]): FilerCount[] {
263 const groups = new Map<string, FilerCount>();
264 for (const r of reports) {
265 const key = `${r.filedBy.how}:${r.filedBy.name ?? ""}`;
266 const seen = groups.get(key);
267 groups.set(key, { ...r.filedBy, count: (seen?.count ?? 0) + 1 });
268 }
269 const rank = { signed: 0, claimed: 1, anonymous: 2 } as const;
270 return [...groups.values()].sort(
271 (a, b) =>
272 b.count - a.count ||
273 rank[a.how] - rank[b.how] ||
274 (a.name ?? "").localeCompare(b.name ?? ""),
275 );
276 }
277
278 /** How a filer reads in a sentence: `@muse`, `claims to be muse`, `anonymous`. */
279 export function filerWords(by: FiledBy): string {
280 if (by.how === "signed") return `@${by.name}`;
281 if (by.how === "claimed") return `claims to be ${by.name}, unproven`;
282 return "anonymous";
283 }
284
180285 /**
181286 * Decide a waiting report, once. `false` when it was already decided — two
182287 * people pressing accept at once open one issue, not two.
src/http/api/reports.ts +22 −7
@@ -12,13 +12,15 @@ import {
1212 acceptReport,
1313 dismissReport,
1414 fileReport,
15 reportViews,
1516 waitingReports,
1617 } from "../../actions/reports.ts";
17 import { BODY_MAX, REQUEST_MAX, TITLE_MAX } from "../../core/reports.ts";
18 import { BODY_MAX, REQUEST_MAX, TITLE_MAX, byFiler } from "../../core/reports.ts";
1819 import { notePhase, takeSessionTime, timePhase } from "../../core/route_timing.ts";
1920 import { keyedPage, keyedWindow, loadRepo, originAware, withLabels } from "../api.ts";
2021 import type { Handler } from "../context.ts";
2122 import { clientBucket } from "../peer.ts";
23 import { proofOf } from "../report_filer.ts";
2224 import {
2325 apiError,
2426 body,
@@ -32,7 +34,11 @@ import {
3234 import type { Router } from "../router.ts";
3335
3436 /** What a refused report is told: the shape that would have been taken. */
35 const REPORT_HINT = `send JSON {title, body?, page?}: plain text, a title of 1-${TITLE_MAX} characters, a body of at most ${BODY_MAX}, page an http(s) address`;
37 const REPORT_HINT =
38 "send JSON {title, body?, page?, agent?}: plain text, " +
39 `a title of 1-${TITLE_MAX} characters, a body of at most ${BODY_MAX}, ` +
40 "page an http(s) address, agent a name of letters, digits, . _ - " +
41 "(a claim unless a token proves it)";
3642
3743 export function reportRoutes(router: Router<Handler>): void {
3844 // ── reports from outside — no account, a person decides ──────────────
@@ -46,8 +52,14 @@ export function reportRoutes(router: Router<Handler>): void {
4652 const filed = fileReport(
4753 ctx.app,
4854 repo,
49 { title: str(input.title), body: str(input.body), page: str(input.page) },
55 {
56 title: str(input.title),
57 body: str(input.body),
outdated

HIGH: ctx.auth.user includes a cookie session, and this route is auth: false. A cross-site fetch or form from another origin carries the victim's cookie and files a report under their name (CSRF on a mutation), which a writer then reads as signed. MCP already restricts proof to token or password. Do the same here: attribute only when ctx.auth.via is token or password, or enforce the same-origin check for the cookie case. Put the rule in one shared helper (e.g. proofOf(ctx)) that the API, MCP and web all use. Add a test for a cookie-only API POST from a foreign Origin: it must be anonymous or claimed.

58 page: str(input.page),
59 agent: str(input.agent),
60 },
5061 clientBucket(ctx.request),
62 proofOf(ctx),
5163 );
5264 if (!filed.ok) {
5365 const refusal = filed.error;
@@ -70,6 +82,7 @@ export function reportRoutes(router: Router<Handler>): void {
7082 {
7183 id: filed.value.id,
7284 state: filed.value.state,
85 filedBy: filed.value.filedBy,
7386 message: REPORT_RECEIVED,
7487 },
7588 202,
@@ -91,13 +104,15 @@ export function reportRoutes(router: Router<Handler>): void {
91104 );
92105 if (!waiting.ok) return respondAccess(waiting.error, 403);
93106 return timePhase(ctx.request, "render", () =>
94 json(
95 keyedPage(
96 waiting.value.map((r) => ({ ...r, from: "outside" })),
107 json({
108 ...keyedPage(
109 reportViews(ctx.app, repo, waiting.value),
97110 (r) => r.id,
98111 window,
99112 ),
100 ),
113 // Every waiting report's filer, not only this page's.
114 byAgent: byFiler(waiting.value),
115 }),
101116 );
102117 });
103118
src/http/mcp.ts +12 −4
@@ -27,6 +27,7 @@ import { resourceMetadataUrl } from "./oauth.ts";
2727 import { body, boundedBody, json } from "./respond.ts";
2828 import type { Router } from "./router.ts";
2929 import { clientBucket } from "./peer.ts";
30 import { proofOf } from "./report_filer.ts";
3031 import { fileReport } from "../actions/reports.ts";
3132 import { REPORT_RECEIVED } from "../actions/report_words.ts";
3233 import { BODY_MAX, REQUEST_MAX, TITLE_MAX } from "../core/reports.ts";
@@ -207,7 +208,7 @@ function rpcOne(
207208 export const REPORT_TOOL = {
208209 name: "report",
209210 /** Its row in docs/MCP.md (`bun scripts/mcp-tools.ts`); never sent to a client. */
210 doc: "`{ repo, title, body?, page? }`: report a problem in a public repository, the one tool open without a token; it still stores the report, which a person reads first; plain text",
211 doc: "`{ repo, title, body?, page?, agent? }`: report a problem in a public repository, the one tool open without a token; it still stores the report, which a person reads first; plain text. With a token the report carries your account; `agent` without one is shown as a claim",
211212 writes: true,
212213 description:
213214 "Report a problem in a public repository, no account needed. Plain text; a person reads it first.",
@@ -221,6 +222,11 @@ export const REPORT_TOOL = {
221222 type: "string",
222223 description: "Address where seen.",
223224 },
225 agent: {
226 type: "string",
227 maxLength: 64,
228 description: "Your name; a claim without a token.",
229 },
224230 },
225231 required: ["repo", "title"],
226232 },
@@ -266,8 +272,10 @@ function reportCall(ctx: Ctx, message: unknown): unknown | undefined {
266272 title: typeof args["title"] === "string" ? args["title"] : "",
267273 body: typeof args["body"] === "string" ? args["body"] : "",
268274 page: typeof args["page"] === "string" ? args["page"] : "",
275 agent: typeof args["agent"] === "string" ? args["agent"] : "",
269276 },
270277 clientBucket(ctx.request),
278 proofOf(ctx),
271279 );
272280 if (!filed.ok) {
273281 const e = filed.error;
@@ -276,7 +284,7 @@ function reportCall(ctx: Ctx, message: unknown): unknown | undefined {
276284 ? "not_found: no such public repository (owner/name)"
277285 : e.error === "rate_limited"
278286 ? `rate_limited: try again in ${e.retryAfter} seconds`
279 : `${e.error}: plain text, a title of 1-${TITLE_MAX} characters, a body of at most ${BODY_MAX}`,
287 : `${e.error}: plain text, a title of 1-${TITLE_MAX} characters, a body of at most ${BODY_MAX}, agent a name of letters, digits, . _ -`,
280288 true,
281289 );
282290 }
@@ -336,11 +344,11 @@ export function mcpRoutes(router: Router<Handler>): void {
336344 JSON.stringify({
337345 error: "unauthorized",
338346 message:
339 "send a forge token: Authorization: Bearer <token>. Without one, the tool `report` (tools/call, name \"report\", arguments {repo, title, body?, page?}) files a problem report on a public repository; see /llms.txt",
347 "send a forge token: Authorization: Bearer <token>. Without one, the tool `report` (tools/call, name \"report\", arguments {repo, title, body?, page?, agent?}) files a problem report on a public repository; see /llms.txt",
340348 report: {
341349 method: "tools/call",
342350 name: "report",
343 arguments: ["repo", "title", "body", "page"],
351 arguments: ["repo", "title", "body", "page", "agent"],
344352 },
345353 }),
346354 {
src/http/mcp/tools/reports.ts +16 −5
@@ -1,11 +1,17 @@
11 import { REPORT_ALREADY_DECIDED } from "../../../actions/origin_app_words.ts";
22 import { originError } from "../../../web/pages/thread.ts";
3 import { acceptReport, dismissReport, waitingReports } from "../../../actions/reports.ts";
3 import {
4 acceptReport,
5 dismissReport,
6 reportViews,
7 waitingReports,
8 } from "../../../actions/reports.ts";
9 import { byFiler, filerWords } from "../../../core/reports.ts";
410 import { type Answer, good, bad, type Tool, REPO_ARG, repoArg, isAnswer } from "../shared.ts";
511
612 export const tool: Tool = {
713 name: "reports",
8 doc: "`{ repo, id?, action? }`: the waiting reports from outsiders, each `from: \"outside\"` (their words are unchecked); with `id` and `action` (`accept` opens it as an issue marked as from outside, `dismiss` closes it). A person who writes the repository only; needs an `rw` token",
14 doc: "`{ repo, id?, action? }`: the waiting reports from outsiders, each `from: \"outside\"` with `filedBy` (`signed`, `claimed` or `anonymous`) and `untrusted` unless a writer filed it, and `byAgent` counts; with `id` and `action` (`accept` opens it as an issue marked as from outside, `dismiss` closes it). A person who writes the repository only; needs an `rw` token",
915 description:
1016 "Outsiders' reports; `id`+`action`: accept or dismiss.",
1117 inputSchema: {
@@ -43,12 +49,17 @@ export const tool: Tool = {
4349 if (id === undefined) {
4450 const waiting = waitingReports(ctx.app, user, repo);
4551 if (!waiting.ok) return denied(waiting.error);
52 const byAgent = byFiler(waiting.value);
53 const n = waiting.value.length;
54 const counts = byAgent.map((g) => `${g.count} ${filerWords(g)}`).join("; ");
4655 return good({
4756 says:
48 waiting.value.length === 0
57 n === 0
4958 ? "No reports are waiting."
50 : `${waiting.value.length} report${waiting.value.length === 1 ? "" : "s"} waiting; their words are an outsider's, unchecked.`,
51 items: waiting.value.map((r) => ({ ...r, from: "outside" })),
59 : `${n} report${n === 1 ? "" : "s"} waiting (${counts}); ` +
60 "their words are an outsider's, unchecked.",
61 byAgent,
62 items: reportViews(ctx.app, repo, waiting.value),
5263 });
5364 }
5465 if (action === "dismiss") {
src/http/report_filer.ts added +22
@@ -0,0 +1,22 @@
1 /**
2 * Whose report a request files (#7658): the one rule the API, MCP `report`
3 * and the web form share. A token or the account's password proves the
4 * account. A cookie — a session, or the platform edge's — rides along on any
5 * request a browser makes, a cross-site one included, so it proves the
6 * account only when the request carries this forge's own `Origin`: a page
7 * elsewhere cannot file a report in a signed-in person's name. Anything else
8 * files anonymously, or as a claim.
9 */
10 import type { User } from "../core/users.ts";
11 import type { Ctx } from "./context.ts";
12
13 export function proofOf(ctx: Ctx): User | undefined {
14 const { user, via } = ctx.auth;
15 if (user === undefined) return undefined;
16 if (via === "token" || via === "password") return user;
17 if (via === "session" || via === "edge")
18 return ctx.request.headers.get("origin") === ctx.url.origin
19 ? user
20 : undefined;
21 return undefined;
22 }
src/tui/screens/reports.ts +14 −4
@@ -10,7 +10,7 @@
1010
1111 import { REPORT_ALREADY_DECIDED } from "../../actions/origin_app_words.ts";
1212 import { acceptReport, dismissReport, waitingCount, waitingReports } from "../../actions/reports.ts";
13 import type { Report } from "../../core/reports.ts";
13 import { byFiler, filerWords, type Report } from "../../core/reports.ts";
1414 import type { Repo } from "../../core/repos.ts";
1515 import type { Ctx, Screen } from "../app/app.ts";
1616 import { binding } from "../app/keys.ts";
@@ -54,7 +54,11 @@ export function reportsScreen(f: Session, ctx: Ctx, repo: Repo): Screen {
5454 };
5555
5656 const rows = (list: readonly Report[]): ListRow[] =>
57 list.map((r) => ({ title: r.title, secondary: r.page ?? "", meta: ago(r.createdAt) }));
57 list.map((r) => ({ title: r.title, secondary: filerWords(r.filedBy), meta: ago(r.createdAt) }));
58
59 /** The waiting reports' filers with a count each, most first: "2 @muse · 1 anonymous". */
60 const counts = (list: readonly Report[]): string =>
61 byFiler(list).map((g) => `${g.count} ${filerWords(g)}`).join(" · ");
5862
5963 const screen: Screen = {
6064 title: `Reports: ${repo.ownerName}/${repo.name}`,
@@ -63,7 +67,12 @@ export function reportsScreen(f: Session, ctx: Ctx, repo: Repo): Screen {
6367 const c = ctx.theme.c;
6468 const list = load();
6569 const [head, body] = split(area, "v", [len(1), fill(1)]);
66 const status = list.length === 0 ? "Nothing waits." : note === "" ? "Sent from outside, unchecked. a accepts as an issue, d dismisses." : "";
70 const status =
71 list.length === 0
72 ? "Nothing waits."
73 : note === ""
74 ? `From ${counts(list)}; unchecked. a accepts as an issue, d dismisses.`
75 : "";
6776 const text = [note, status].filter((t) => t !== "").join(" ");
6877 s.text(head!.x, head!.y, truncate(text, head!.w), style(c.mutedFg, c.bg), head!.x + head!.w);
6978 const { main, aside } = beside(body!, ctx.twoPane, { main: pct(45), aside: fill(1) });
@@ -73,7 +82,8 @@ export function reportsScreen(f: Session, ctx: Ctx, repo: Repo): Screen {
7382 const x = aside.x + 1;
7483 const w = Math.max(0, aside.w - 2);
7584 const lines = [
76 `Sent ${ago(picked.createdAt)}${picked.page === null ? "" : ` from ${picked.page}`}`,
85 `Sent ${ago(picked.createdAt)} by ${filerWords(picked.filedBy)}`,
86 ...(picked.page === null ? [] : [`From ${picked.page}`]),
7787 "",
7888 ...wrap(picked.body === "" ? "(no words)" : picked.body, w),
7989 ];
src/web/pages/reports.ts +40 −5
@@ -16,7 +16,10 @@ import {
1616 OUTSIDE_TITLE,
1717 REQUEST_MAX,
1818 TITLE_MAX,
19 byFiler,
20 filerWords,
1921 outsideWords,
22 type FiledBy,
2023 type Report,
2124 } from "../../core/reports.ts";
2225 import { canRead, type Repo } from "../../core/repos.ts";
@@ -28,6 +31,7 @@ import {
2831 timePhase,
2932 } from "../../core/route_timing.ts";
3033 import { clientBucket } from "../../http/peer.ts";
34 import { proofOf } from "../../http/report_filer.ts";
3135 import {
3236 crumbsFor,
3337 loadRepo,
@@ -84,6 +88,12 @@ function reportForm(
8488 something is wrong. You need no account. A person reads it first;
8589 nothing acts on it before they do.
8690 </p>
91 ${ctx.auth.user
92 ? html`<p class="hint">
93 Sent as @${ctx.auth.user.username}: the people who keep it see
94 who sent it.
95 </p>`
96 : html`<p class="hint">Sent anonymously: you are not signed in.</p>`}
8797 ${when(values.page !== "", () => html`<p class="small mono">About this page: ${values.page}</p>`)}
8898 <form class="mt" method="post" action="${base}/report">
8999 ${formField({
@@ -188,7 +198,13 @@ export async function reportSubmit(ctx: Ctx): Promise<Response> {
188198 // person is told, and nothing is kept.
189199 if (field(form, "website") !== "")
outdated

The form passes the session user. Confirm this POST runs the same-origin check before it attributes the report, and add a test for a signed-in form POST: the report is signed, and the form shows 'Sent as @name'. The 'Sent as' and 'Sent anonymously' hints are untested.

190200 return flashTo(ctx, sent, { message: "Report sent." });
191 const filed = fileReport(ctx.app, repo, values, clientBucket(ctx.request));
201 const filed = fileReport(
202 ctx.app,
203 repo,
204 values,
205 clientBucket(ctx.request),
206 proofOf(ctx),
207 );
192208 if (filed.ok) return flashTo(ctx, sent, { message: "Report sent." });
193209 const refusal = filed.error;
194210 if (refusal.error === "not_found") return notFoundPage(ctx);
@@ -242,7 +258,7 @@ function reportCard(base: string, report: Report): Html {
242258 </form>`,
243259 {
244260 tone: "attn",
245 head: html`From outside, not checked · ${time(report.createdAt)}`,
261 head: html`From outside, ${filerWords(report.filedBy)}, not checked · ${time(report.createdAt)}`,
246262 foot: html`${btn("Accept as an issue", {
247263 variant: "primary",
248264 icon: "issue",
@@ -255,6 +271,16 @@ function reportCard(base: string, report: Report): Html {
255271 );
256272 }
257273
274 const sameFiler = (a: FiledBy, b: FiledBy): boolean =>
275 a.how === b.how && a.name === b.name;
276
277 /** A group's heading: the proven name, the claim as a claim, or anonymous. */
278 function groupTitle(by: FiledBy): string {
279 if (by.how === "signed") return `@${by.name}`;
280 if (by.how === "claimed") return `“${by.name}”, claimed without a token`;
281 return "Anonymous";
282 }
283
258284 // GET /:owner/:name/reports
259285 export function reportsPage(ctx: Ctx): Response {
260286 notePhase(ctx.request, "session", takeSessionTime(ctx.request));
@@ -282,8 +308,10 @@ export function reportsPage(ctx: Ctx): Response {
282308 })}
283309 <h1 class="mt">Reports from outside</h1>
284310 <p class="hint">
285 Sent without an account. No agent sees one until you accept it; then it
286 is your issue, with the words quoted.
311 Sent from outside, grouped by who sent them: an account a token or
312 session proved, a name claimed without one, or anonymous. No agent
313 sees one until you accept it; then it is your issue, with the words
314 quoted.
287315 </p>
288316 <div class="mt">
289317 ${waiting.value.length === 0
@@ -292,7 +320,14 @@ export function reportsPage(ctx: Ctx): Response {
292320 title: "Nothing waiting",
293321 note: `Anyone can report a problem at ${base}/report.`,
294322 })
295 : waiting.value.map((r) => reportCard(base, r))}
323 : byFiler(waiting.value).map(
324 (g) => html`<section class="mt">
325 <h2>${groupTitle(g)} · ${g.count}</h2>
326 ${waiting.value
327 .filter((r) => sameFiler(r.filedBy, g))
328 .map((r) => reportCard(base, r))}
329 </section>`,
330 )}
296331 </div>`,
297332 });
298333 notePhase(ctx.request, "render", performance.now() - rendering);
test/reports-attributed.test.ts added +326
@@ -0,0 +1,326 @@
1 /**
2 * A report carries who filed it (#7658): an account a token proved, a name
3 * claimed without one — shown as a claim — or nobody, said as anonymous.
4 * The owner reads the waiting reports grouped by filer with a count, and a
5 * stranger's words stay `untrusted`.
6 */
7 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
8 import { mkdtemp, rm } from "node:fs/promises";
9 import { tmpdir } from "node:os";
10 import { join } from "node:path";
11 import { createAgent } from "../src/actions/crew.ts";
12 import { acceptReport } from "../src/actions/reports.ts";
13 import { createRepo } from "../src/actions/repos.ts";
14 import { createApp, type App } from "../src/app.ts";
15 import { getIssue } from "../src/core/issues.ts";
16 import {
17 byFiler,
18 claimOf,
19 filerWords,
20 getReport,
21 insertReport,
22 listWaiting,
23 type Report,
24 } from "../src/core/reports.ts";
25 import type { Repo } from "../src/core/repos.ts";
26 import { createSession } from "../src/core/sessions.ts";
27 import { createToken } from "../src/core/tokens.ts";
28 import { createUser, type User } from "../src/core/users.ts";
29 import type { Ctx } from "../src/http/context.ts";
30 import { proofOf } from "../src/http/report_filer.ts";
31 import { serve, type Servers } from "../src/http/server.ts";
32
33 describe("attributed reports", () => {
34 let dataDir: string;
35 let app: App;
36 let servers: Servers;
37 let aliceToken: string;
38 let museToken: string;
39 let source = 0;
40
41 beforeAll(async () => {
42 dataDir = await mkdtemp(join(tmpdir(), "forge-reports-attributed-"));
43 app = createApp({ dataDir, port: 0 });
44 servers = await serve(app);
45 const a = await createUser(app.db, { origin: "seed", trusted: true,
46 username: "alice", email: "[email protected]", password: "a-good-passphrase", adminIfFirst: true });
47 if (!a.ok) throw new Error(a.error);
48 const made = await createRepo(app, a.value, { name: "game", visibility: "public" });
49 if (!made.ok) throw new Error(String(made.error));
50 // muse is an outside agent: on no crew, so it writes nothing here.
51 const muse = await createAgent(app, a.value, { username: "muse" });
52 if (!muse.ok) throw new Error(String(muse.error));
53 aliceToken = createToken(app.db, a.value.id, "t", "rw").token;
54 museToken = createToken(app.db, muse.value.user.id, "t", "rw").token;
55 }, 30_000);
56
57 afterAll(async () => {
58 servers.stop();
59 app.close();
60 await rm(dataDir, { recursive: true, force: true });
61 });
62
63 const file = (payload: Record<string, unknown>, token?: string) =>
64 fetch(`${servers.url}/api/v1/repos/alice/game/reports`, {
65 method: "POST",
66 headers: {
67 "content-type": "application/json",
68 "x-forwarded-for": `198.51.100.${++source}`,
69 ...(token ? { authorization: `Bearer ${token}` } : {}),
70 },
71 body: JSON.stringify(payload),
72 });
73
74 const mcp = async (name: string, args: unknown, token?: string) => {
75 const res = await fetch(`${servers.url}/mcp`, {
76 method: "POST",
77 headers: {
78 "content-type": "application/json",
79 accept: "application/json, text/event-stream",
80 "x-forwarded-for": `198.51.100.${++source}`,
81 ...(token ? { authorization: `Bearer ${token}` } : {}),
82 },
83 body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call",
84 params: { name, arguments: args } }),
85 });
86 const body: any = await res.json();
87 return JSON.parse(body.result.content[0].text);
88 };
89
90 test("a report filed with a token carries its account; a claim beside it is dropped", async () => {
91 const res = await file({ title: "Stuck on the login page", agent: "someone-else" }, museToken);
92 expect(res.status).toBe(202);
93 expect(((await res.json()) as any).filedBy).toEqual({ how: "signed", name: "muse" });
94 const viaMcp = await mcp("report", { repo: "alice/game", title: "Cannot find the API" }, museToken);
95 expect(viaMcp.state).toBe("pending");
96 });
97
98 test("a name given without a token is a claim; none at all is anonymous", async () => {
99 const claimed = await file({ title: "Search hangs", agent: "grokbot" });
100 expect(((await claimed.json()) as any).filedBy).toEqual({ how: "claimed", name: "grokbot" });
101 const anon = await file({ title: "Typo on home" });
102 expect(((await anon.json()) as any).filedBy).toEqual({ how: "anonymous", name: null });
103 // A claim cannot dress up as an account (no @) or carry markup.
104 expect((await file({ title: "x", agent: "@muse" })).status).toBe(422);
105 expect((await file({ title: "x", agent: "<b>muse</b>" })).status).toBe(422);
106 });
107
108 test("the API lists each report's filer, untrusted, with counts by agent", async () => {
109 const res = await fetch(`${servers.url}/api/v1/repos/alice/game/reports`, {
110 headers: { authorization: `Bearer ${aliceToken}` },
111 });
112 const listed: any = await res.json();
113 const signed = listed.items.filter((r: any) => r.filedBy.how === "signed");
114 expect(signed.map((r: any) => r.filedBy.name)).toEqual(["muse", "muse"]);
115 const anon = listed.items.find((r: any) => r.title === "Typo on home");
116 expect(anon.filedBy).toEqual({ how: "anonymous", name: null });
117 expect(listed.items.every((r: any) => r.untrusted === true)).toBe(true);
118 expect(listed.items.some((r: any) => "filerId" in r)).toBe(false);
119 expect(listed.byAgent).toEqual([
120 { how: "signed", name: "muse", count: 2 },
121 { how: "claimed", name: "grokbot", count: 1 },
122 { how: "anonymous", name: null, count: 1 },
123 ]);
124 });
125
126 test("the MCP `reports` tool groups by agent and says so", async () => {
127 const listed = await mcp("reports", { repo: "alice/game" }, aliceToken);
128 expect(listed.byAgent[0]).toEqual({ how: "signed", name: "muse", count: 2 });
129 expect(listed.says).toContain("2 @muse");
130 expect(listed.says).toContain("1 claims to be grokbot, unproven");
131 expect(listed.says).toContain("1 anonymous");
132 expect(listed.items.every((r: any) => r.untrusted === true)).toBe(true);
133 });
134
135 test("the reports page groups by filer with a count and marks a claim", async () => {
136 const page = await (
137 await fetch(`${servers.url}/alice/game/reports`, {
138 headers: { authorization: `Bearer ${aliceToken}` },
139 })
140 ).text();
141 expect(page).toContain("<h2>@muse · 2</h2>");
142 expect(page).toContain("“grokbot”, claimed without a token · 1");
143 expect(page).toContain("<h2>Anonymous · 1</h2>");
144 expect(page).toContain("From outside, anonymous, not checked");
145 // The anonymous report claims no one.
146 const anonCard = page.slice(page.indexOf("<h2>Anonymous"));
147 expect(anonCard).not.toContain("@muse");
148 });
149
150 test("a writer's own report is not marked untrusted", async () => {
151 expect((await file({ title: "Note to self" }, aliceToken)).status).toBe(202);
152 const listed: any = await (
153 await fetch(`${servers.url}/api/v1/repos/alice/game/reports`, {
154 headers: { authorization: `Bearer ${aliceToken}` },
155 })
156 ).json();
157 const own = listed.items.find((r: any) => r.title === "Note to self");
158 expect(own.filedBy).toEqual({ how: "signed", name: "alice" });
159 expect(own.untrusted).toBeUndefined();
160 });
161 });
162
163 describe("proof of the filer", () => {
164 let dataDir: string;
165 let app: App;
166 let servers: Servers;
167 let alice: User;
168 let game: Repo;
169 let cookie: string;
170 let source = 0;
171
172 beforeAll(async () => {
173 dataDir = await mkdtemp(join(tmpdir(), "forge-reports-proof-"));
174 app = createApp({ dataDir, port: 0 });
175 servers = await serve(app);
176 const a = await createUser(app.db, { origin: "seed", trusted: true,
177 username: "alice", email: "[email protected]", password: "a-good-passphrase", adminIfFirst: true });
178 if (!a.ok) throw new Error(a.error);
179 alice = a.value;
180 const made = await createRepo(app, alice, { name: "game", visibility: "public" });
181 if (!made.ok) throw new Error(String(made.error));
182 game = made.value;
183 cookie = `forge_session=${createSession(app.db, alice.id).token}`;
184 }, 30_000);
185
186 afterAll(async () => {
187 servers.stop();
188 app.close();
189 await rm(dataDir, { recursive: true, force: true });
190 });
191
192 const post = (path: string, type: string, payload: string, headers: Record<string, string>) =>
193 fetch(`${servers.url}${path}`, {
194 method: "POST",
195 redirect: "manual",
196 headers: { "content-type": type, "x-forwarded-for": `203.0.113.${++source}`, ...headers },
197 body: payload,
198 });
199 const api = (payload: object, headers: Record<string, string>) =>
200 post("/api/v1/repos/alice/game/reports", "application/json", JSON.stringify(payload), headers);
201 const filedBy = async (res: Response) => {
202 expect(res.status).toBe(202);
203 return ((await res.json()) as any).filedBy;
204 };
205
206 test("a cookie on a cross-site API POST files nothing in anyone's name", async () => {
207 // The floor refuses it before the route runs; nothing is stored.
208 const refused = await api({ title: "Cross-site" }, { cookie, origin: "https://evil.example" });
209 expect(refused.status).toBe(403);
210 expect(listWaiting(app.db, game.id).some((r) => r.title === "Cross-site")).toBe(false);
211 // A cookie without this forge's Origin proves no one.
212 expect(await filedBy(await api({ title: "x" }, { cookie }))).toEqual({ how: "anonymous", name: null });
213 expect(await filedBy(await api({ title: "x", agent: "muse" }, { cookie }))).toEqual({
214 how: "claimed",
215 name: "muse",
216 });
217 });
218
219 test("proofOf: a token or password always; a cookie only with this forge's Origin", () => {
220 const ctx = (via: string, origin?: string) =>
221 ({
222 auth: { user: alice, via, scope: undefined },
223 url: new URL("https://forge.test/x"),
224 request: new Request("https://forge.test/x", {
225 method: "POST",
226 headers: origin === undefined ? {} : { origin },
227 }),
228 }) as unknown as Ctx;
229 expect(proofOf(ctx("token"))).toBe(alice);
230 expect(proofOf(ctx("password", "https://evil.example"))).toBe(alice);
231 expect(proofOf(ctx("session", "https://forge.test"))).toBe(alice);
232 expect(proofOf(ctx("edge", "https://forge.test"))).toBe(alice);
233 expect(proofOf(ctx("session", "https://evil.example"))).toBeUndefined();
234 expect(proofOf(ctx("session"))).toBeUndefined();
235 expect(proofOf(ctx("session", "null"))).toBeUndefined();
236 expect(proofOf(ctx("none", "https://forge.test"))).toBeUndefined();
237 });
238
239 test("a cookie from this forge's own page proves the account", async () => {
240 expect(await filedBy(await api({ title: "x" }, { cookie, origin: servers.url }))).toEqual({
241 how: "signed",
242 name: "alice",
243 });
244 });
245
246 test("the form says whose a report will be, and a signed-in POST from it is signed", async () => {
247 const signedIn = await (await fetch(`${servers.url}/alice/game/report`, { headers: { cookie } })).text();
248 expect(signedIn).toContain("Sent as @alice");
249 const stranger = await (await fetch(`${servers.url}/alice/game/report`)).text();
250 expect(stranger).toContain("Sent anonymously");
251 expect(stranger).not.toContain("Sent as @");
252
253 const form = "title=From+the+form&body=&page=";
254 const sent = await post("/alice/game/report", "application/x-www-form-urlencoded", form, {
255 cookie,
256 origin: servers.url,
257 });
258 expect(sent.status).toBe(303);
259 const waiting: any = await (
260 await fetch(`${servers.url}/api/v1/repos/alice/game/reports`, { headers: { cookie } })
261 ).json();
262 const mine = waiting.items.find((r: any) => r.title === "From the form");
263 expect(mine.filedBy).toEqual({ how: "signed", name: "alice" });
264 // The form refuses a foreign page before anything is filed.
265 const refused = await post("/alice/game/report", "application/x-www-form-urlencoded", form, {
266 cookie,
267 origin: "https://evil.example",
268 });
269 expect(refused.status).toBe(403);
270 });
271
272 test("an accepted report's issue names its filer, a claim as a claim", async () => {
273 const signed = insertReport(app.db, game.id, { title: "Signed one", body: "b", page: null }, alice.id);
274 const claimed = insertReport(app.db, game.id, { title: "Claimed one", body: "b", page: null, claimed: "muse" });
275 const anon = insertReport(app.db, game.id, { title: "Anon one", body: "b", page: null });
276 const body = async (id: string) => {
277 const done = await acceptReport(app, alice, game, id);
278 if (!done.ok) throw new Error(String(done.error));
279 return getIssue(app.db, game.id, done.value.number)!.body;
280 };
281 expect(await body(signed.id)).toStartWith("Reported from outside, by @alice, ");
282 expect(await body(claimed.id)).toStartWith("Reported from outside, by claims to be muse, unproven, ");
283 expect(await body(anon.id)).toStartWith("Reported from outside, without an account, ");
284 });
285
286 test("a row from before the migration reads anonymous; deleting the filer makes it anonymous", async () => {
287 app.db
288 .query("INSERT INTO report (id, repo_id, title, body, page) VALUES ('old-row', ?1, 'Old', '', NULL)")
289 .run(game.id);
290 expect(getReport(app.db, game.id, "old-row")!.filedBy).toEqual({ how: "anonymous", name: null });
291
292 const d = await createUser(app.db, { origin: "seed", trusted: true,
293 username: "dora", email: "[email protected]", password: "a-good-passphrase" });
294 if (!d.ok) throw new Error(d.error);
295 const r = insertReport(app.db, game.id, { title: "Dora's", body: "", page: null }, d.value.id);
296 expect(r.filedBy).toEqual({ how: "signed", name: "dora" });
297 app.db.query("DELETE FROM user WHERE id = ?1").run(d.value.id);
298 const after = getReport(app.db, game.id, r.id)!;
299 expect(after.filerId).toBeNull();
300 expect(after.filedBy).toEqual({ how: "anonymous", name: null });
301 });
302 });
303
304 describe("byFiler", () => {
305 const r = (how: "signed" | "claimed" | "anonymous", name: string | null): Report => ({
306 id: "r", repoId: "p", title: "t", body: "", page: null, state: "pending",
307 createdAt: "2026-11-01T00:00:00.000Z", filedBy: { how, name }, filerId: null,
308 });
309
310 test("a signed name and the same name claimed are two groups, most first", () => {
311 expect(
312 byFiler([r("claimed", "muse"), r("anonymous", null), r("signed", "muse"), r("anonymous", null)]),
313 ).toEqual([
314 { how: "anonymous", name: null, count: 2 },
315 { how: "signed", name: "muse", count: 1 },
316 { how: "claimed", name: "muse", count: 1 },
317 ]);
318 });
319
320 test("a claim is a name's shape or nothing", () => {
321 expect(claimOf(" ")).toEqual({ ok: true, value: null } as any);
322 expect(claimOf("grok.bot_2").ok).toBe(true);
323 expect(claimOf("a".repeat(65)).ok).toBe(false);
324 expect(filerWords({ how: "anonymous", name: null })).toBe("anonymous");
325 });
326 });↵
test/reports.test.ts +2
@@ -529,6 +529,8 @@ describe("reports from outside", () => {
529529 page: "https://x.example/#[click](https://evil.example/e)",
530530 state: "pending",
531531 createdAt: "2026-10-05T00:00:00.000Z",
532 filedBy: { how: "anonymous", name: null },
533 filerId: null,
532534 },
533535 alice,
534536 );
test/tui-reports.test.ts +15
@@ -174,3 +174,18 @@ describe("no row for an agent or a guest", () => {
174174 expect(reportsCommands({ app, user: undefined } as Session, {} as Ctx, repo)).toEqual([]);
175175 });
176176 });
177
178 describe("who filed it, in the terminal (#7658)", () => {
179 test("each row names its filer and the head counts them, most first", async () => {
180 for (const r of waiting()) dismissReport(app, alice, repo, r.id);
181 insertReport(app.db, repo.id, { title: "Stuck at sign-in", body: "", page: null }, bob.id);
182 insertReport(app.db, repo.id, { title: "Stuck at the map", body: "", page: null }, bob.id);
183 insertReport(app.db, repo.id, { title: "Search hangs", body: "", page: null, claimed: "grokbot" });
184 insertReport(app.db, repo.id, { title: "Typo", body: "", page: null });
185 const h = await open(alice);
186 const text = await h.run(":", "Reports (", "\r");
187 expect(text).toContain("From 2 @bob · 1 claims to be grokbot, unproven · 1 anonymous; unchecked.");
188 expect(text).toContain("Sent just now by @bob");
189 h.stop();
190 });
191 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs