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
Check · Review · Land · Live done — Landed and live in production.
torque/forge:torque/7658-an-outside-agent-files-an-attributed-rep → main 3 commits fork 4 threads · 4 unresolved
docs/MCP.md +2 −2
| @@ -142,8 +142,8 @@ for a tunnel and a forge that sleeps. | ||
| 142 | 142 | | `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` | | |
| 143 | 143 | | `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 | |
| 144 | 144 | | `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 | | |
| 147 | 147 | | `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 | |
| 148 | 148 | | `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 | | |
| 149 | 149 | | `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. | ||
| 35 | 35 | |
| 36 | 36 | Anyone, with no account, can tell a public repository that something is |
| 37 | 37 | 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 | |
| 40 | 47 | a missing one does. A report is plain text, capped (a 200-character title, an |
| 41 | 48 | 8,000-character body), limited per source, per repository and for the whole |
| 42 | 49 | 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 { | ||
| 31 | 31 | cleanReport, |
| 32 | 32 | countWaiting, |
| 33 | 33 | decideReport, |
| 34 | filerWords, | |
| 34 | 35 | getReport, |
| 35 | 36 | insertReport, |
| 36 | 37 | listWaiting, |
| @@ -43,7 +44,7 @@ import { | ||
| 43 | 44 | } from "../core/reports.ts"; |
| 44 | 45 | import { createLabel, getLabel, type Issue } from "../core/issues.ts"; |
| 45 | 46 | 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"; | |
| 47 | 48 | import { defect } from "../kernel/error.ts"; |
| 48 | 49 | import { err, ok, type Result } from "../kernel/result.ts"; |
| 49 | 50 | import { isMirror } from "../origins/write-through.ts"; |
| @@ -81,12 +82,18 @@ export type FileRefusal = | ||
| 81 | 82 | | { readonly error: "not_found" | ReportError | "queue_full" } |
| 82 | 83 | | { readonly error: "rate_limited"; readonly retryAfter: number }; |
| 83 | 84 | |
| 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 | */ | |
| 85 | 91 | export function fileReport( |
| 86 | 92 | app: App, |
| 87 | 93 | repo: Repo, |
| 88 | 94 | input: ReportInput, |
| 89 | 95 | source: string, |
| 96 | filer?: User, | |
| 90 | 97 | ): Result<Report, FileRefusal> { |
| 91 | 98 | if (!canRead(app.db, repo, undefined)) return err({ error: "not_found" }); |
| 92 | 99 | const clean = cleanReport(input); |
| @@ -103,7 +110,12 @@ export function fileReport( | ||
| 103 | 110 | } |
| 104 | 111 | if (countWaiting(app.db, repo.id) >= WAITING_MAX) |
| 105 | 112 | 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 | ); | |
| 107 | 119 | app.bus.append({ |
| 108 | 120 | kind: "report.received", |
| 109 | 121 | repoId: repo.id, |
| @@ -137,6 +149,60 @@ export function waitingReports( | ||
| 137 | 149 | return ok(listWaiting(app.db, repo.id)); |
| 138 | 150 | } |
| 139 | 151 | |
| 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 | /** | |
| 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 | ||
| 140 | 206 | /** How many reports wait, for a surface that shows a count; 0 to anyone else. */ |
| 141 | 207 | export function waitingCount( |
| 142 | 208 | app: App, |
| @@ -171,8 +237,13 @@ export function acceptedBody(report: Report, by: User): string { | ||
| 171 | 237 | ] |
| 172 | 238 | .join("\n") |
| 173 | 239 | .trim(); |
| 240 | const from = | |
| 241 | report.filedBy.how === "anonymous" | |
| 242 | ? "without an account" | |
| 243 | : `by ${filerWords(report.filedBy)}`; | |
| 174 | 244 | 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.`, | |
| 176 | 247 | ]; |
| 177 | 248 | if (quoted !== "") lines.push("", fenced(quoted)); |
| 178 | 249 | return lines.join("\n"); |
src/contract/openapi/routes/getApiV1ReposOwnerNameReports.ts +1 −1
| @@ -4,6 +4,6 @@ export const route: Route = { | ||
| 4 | 4 | method: "get", |
| 5 | 5 | path: `${REPO}/reports`, |
| 6 | 6 | 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", | |
| 8 | 8 | auth: true, |
| 9 | 9 | }; |
src/contract/openapi/routes/postApiV1ReposOwnerNameReports.ts +4 −2
| @@ -3,7 +3,9 @@ import { REPO, type Route } from "../shared.ts"; | ||
| 3 | 3 | export const route: Route = { |
| 4 | 4 | method: "post", |
| 5 | 5 | 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", | |
| 7 | 8 | auth: false, |
| 8 | body: { title: "string", body: "string", page: "string" }, | |
| 9 | body: { title: "string", body: "string", page: "string", agent: "string" }, | |
| 10 | status: 202, | |
| 9 | 11 | }; |
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; | ||
| 21 | 21 | |
| 22 | 22 | export type ReportState = "pending" | "accepted" | "dismissed"; |
| 23 | 23 | |
| 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 | ||
| 24 | 40 | export interface Report { |
| 25 | 41 | readonly id: string; |
| 26 | 42 | readonly repoId: string; |
| @@ -29,9 +45,16 @@ export interface Report { | ||
| 29 | 45 | readonly page: string | null; |
| 30 | 46 | readonly state: ReportState; |
| 31 | 47 | 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; | |
| 32 | 51 | } |
| 33 | 52 | |
| 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"; | |
| 35 | 58 | |
| 36 | 59 | interface ReportRow { |
| 37 | 60 | id: string; |
| @@ -41,8 +64,22 @@ interface ReportRow { | ||
| 41 | 64 | page: string | null; |
| 42 | 65 | state: ReportState; |
| 43 | 66 | created_at: string; |
| 67 | filer_id: string | null; | |
| 68 | filer_name: string | null; | |
| 69 | claimed: string | null; | |
| 44 | 70 | } |
| 45 | 71 | |
| 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 | ||
| 46 | 83 | const fromRow = (row: ReportRow): Report => ({ |
| 47 | 84 | id: row.id, |
| 48 | 85 | repoId: row.repo_id, |
| @@ -51,6 +88,8 @@ const fromRow = (row: ReportRow): Report => ({ | ||
| 51 | 88 | page: row.page, |
| 52 | 89 | state: row.state, |
| 53 | 90 | createdAt: row.created_at, |
| 91 | filedBy: filedByOf(row), | |
| 92 | filerId: row.filer_id, | |
| 54 | 93 | }); |
| 55 | 94 | |
| 56 | 95 | /** |
| @@ -81,18 +120,39 @@ export interface ReportInput { | ||
| 81 | 120 | readonly title: string; |
| 82 | 121 | readonly body: string; |
| 83 | 122 | 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); | |
| 84 | 142 | } |
| 85 | 143 | |
| 86 | 144 | /** The input as it will be stored, or why it will not be. */ |
| 87 | 145 | export function cleanReport( |
| 88 | 146 | input: ReportInput, |
| 89 | ): Result<{ title: string; body: string; page: string | null }, ReportError> { | |
| 147 | ): Result<CleanReport, ReportError> { | |
| 90 | 148 | const title = plainText(input.title).replace(/\s+/g, " ").trim(); |
| 91 | 149 | if (title === "") return err("empty_title"); |
| 92 | 150 | if (title.length > TITLE_MAX) return err("title_too_long"); |
| 93 | 151 | const body = plainText(input.body).trim(); |
| 94 | 152 | 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 }); | |
| 96 | 156 | } |
| 97 | 157 | |
| 98 | 158 | /** |
| @@ -146,12 +206,23 @@ export function countWaiting(db: Database, repoId: string): number { | ||
| 146 | 206 | export function insertReport( |
| 147 | 207 | db: Database, |
| 148 | 208 | 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, | |
| 150 | 211 | ): Report { |
| 151 | 212 | const reportId = id(); |
| 213 | // A proven account is the identity; a claim beside it would only muddy it. | |
| 152 | 214 | 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 | ); | |
| 155 | 226 | return getReport(db, repoId, reportId)!; |
| 156 | 227 | } |
| 157 | 228 | |
| @@ -161,7 +232,7 @@ export function getReport( | ||
| 161 | 232 | reportId: string, |
| 162 | 233 | ): Report | undefined { |
| 163 | 234 | 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`) | |
| 165 | 236 | .get(repoId, reportId) as ReportRow | null; |
| 166 | 237 | return row === null ? undefined : fromRow(row); |
| 167 | 238 | } |
| @@ -171,12 +242,46 @@ export function listWaiting(db: Database, repoId: string): Report[] { | ||
| 171 | 242 | 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 |
||
| 172 | 243 | db |
| 173 | 244 | .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`, | |
| 175 | 247 | ) |
| 176 | 248 | .all(repoId, WAITING_MAX) as ReportRow[] |
| 177 | 249 | ).map(fromRow); |
| 178 | 250 | } |
| 179 | 251 | |
| 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 | ||
| 180 | 285 | /** |
| 181 | 286 | * Decide a waiting report, once. `false` when it was already decided — two |
| 182 | 287 | * people pressing accept at once open one issue, not two. |
src/http/api/reports.ts +22 −7
| @@ -12,13 +12,15 @@ import { | ||
| 12 | 12 | acceptReport, |
| 13 | 13 | dismissReport, |
| 14 | 14 | fileReport, |
| 15 | reportViews, | |
| 15 | 16 | waitingReports, |
| 16 | 17 | } 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"; | |
| 18 | 19 | import { notePhase, takeSessionTime, timePhase } from "../../core/route_timing.ts"; |
| 19 | 20 | import { keyedPage, keyedWindow, loadRepo, originAware, withLabels } from "../api.ts"; |
| 20 | 21 | import type { Handler } from "../context.ts"; |
| 21 | 22 | import { clientBucket } from "../peer.ts"; |
| 23 | import { proofOf } from "../report_filer.ts"; | |
| 22 | 24 | import { |
| 23 | 25 | apiError, |
| 24 | 26 | body, |
| @@ -32,7 +34,11 @@ import { | ||
| 32 | 34 | import type { Router } from "../router.ts"; |
| 33 | 35 | |
| 34 | 36 | /** 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)"; | |
| 36 | 42 | |
| 37 | 43 | export function reportRoutes(router: Router<Handler>): void { |
| 38 | 44 | // ── reports from outside — no account, a person decides ────────────── |
| @@ -46,8 +52,14 @@ export function reportRoutes(router: Router<Handler>): void { | ||
| 46 | 52 | const filed = fileReport( |
| 47 | 53 | ctx.app, |
| 48 | 54 | 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: |
||
| 58 | page: str(input.page), | |
| 59 | agent: str(input.agent), | |
| 60 | }, | |
| 50 | 61 | clientBucket(ctx.request), |
| 62 | proofOf(ctx), | |
| 51 | 63 | ); |
| 52 | 64 | if (!filed.ok) { |
| 53 | 65 | const refusal = filed.error; |
| @@ -70,6 +82,7 @@ export function reportRoutes(router: Router<Handler>): void { | ||
| 70 | 82 | { |
| 71 | 83 | id: filed.value.id, |
| 72 | 84 | state: filed.value.state, |
| 85 | filedBy: filed.value.filedBy, | |
| 73 | 86 | message: REPORT_RECEIVED, |
| 74 | 87 | }, |
| 75 | 88 | 202, |
| @@ -91,13 +104,15 @@ export function reportRoutes(router: Router<Handler>): void { | ||
| 91 | 104 | ); |
| 92 | 105 | if (!waiting.ok) return respondAccess(waiting.error, 403); |
| 93 | 106 | 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), | |
| 97 | 110 | (r) => r.id, |
| 98 | 111 | window, |
| 99 | 112 | ), |
| 100 | ), | |
| 113 | // Every waiting report's filer, not only this page's. | |
| 114 | byAgent: byFiler(waiting.value), | |
| 115 | }), | |
| 101 | 116 | ); |
| 102 | 117 | }); |
| 103 | 118 |
src/http/mcp.ts +12 −4
| @@ -27,6 +27,7 @@ import { resourceMetadataUrl } from "./oauth.ts"; | ||
| 27 | 27 | import { body, boundedBody, json } from "./respond.ts"; |
| 28 | 28 | import type { Router } from "./router.ts"; |
| 29 | 29 | import { clientBucket } from "./peer.ts"; |
| 30 | import { proofOf } from "./report_filer.ts"; | |
| 30 | 31 | import { fileReport } from "../actions/reports.ts"; |
| 31 | 32 | import { REPORT_RECEIVED } from "../actions/report_words.ts"; |
| 32 | 33 | import { BODY_MAX, REQUEST_MAX, TITLE_MAX } from "../core/reports.ts"; |
| @@ -207,7 +208,7 @@ function rpcOne( | ||
| 207 | 208 | export const REPORT_TOOL = { |
| 208 | 209 | name: "report", |
| 209 | 210 | /** 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", | |
| 211 | 212 | writes: true, |
| 212 | 213 | description: |
| 213 | 214 | "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 = { | ||
| 221 | 222 | type: "string", |
| 222 | 223 | description: "Address where seen.", |
| 223 | 224 | }, |
| 225 | agent: { | |
| 226 | type: "string", | |
| 227 | maxLength: 64, | |
| 228 | description: "Your name; a claim without a token.", | |
| 229 | }, | |
| 224 | 230 | }, |
| 225 | 231 | required: ["repo", "title"], |
| 226 | 232 | }, |
| @@ -266,8 +272,10 @@ function reportCall(ctx: Ctx, message: unknown): unknown | undefined { | ||
| 266 | 272 | title: typeof args["title"] === "string" ? args["title"] : "", |
| 267 | 273 | body: typeof args["body"] === "string" ? args["body"] : "", |
| 268 | 274 | page: typeof args["page"] === "string" ? args["page"] : "", |
| 275 | agent: typeof args["agent"] === "string" ? args["agent"] : "", | |
| 269 | 276 | }, |
| 270 | 277 | clientBucket(ctx.request), |
| 278 | proofOf(ctx), | |
| 271 | 279 | ); |
| 272 | 280 | if (!filed.ok) { |
| 273 | 281 | const e = filed.error; |
| @@ -276,7 +284,7 @@ function reportCall(ctx: Ctx, message: unknown): unknown | undefined { | ||
| 276 | 284 | ? "not_found: no such public repository (owner/name)" |
| 277 | 285 | : e.error === "rate_limited" |
| 278 | 286 | ? `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, . _ -`, | |
| 280 | 288 | true, |
| 281 | 289 | ); |
| 282 | 290 | } |
| @@ -336,11 +344,11 @@ export function mcpRoutes(router: Router<Handler>): void { | ||
| 336 | 344 | JSON.stringify({ |
| 337 | 345 | error: "unauthorized", |
| 338 | 346 | 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", | |
| 340 | 348 | report: { |
| 341 | 349 | method: "tools/call", |
| 342 | 350 | name: "report", |
| 343 | arguments: ["repo", "title", "body", "page"], | |
| 351 | arguments: ["repo", "title", "body", "page", "agent"], | |
| 344 | 352 | }, |
| 345 | 353 | }), |
| 346 | 354 | { |
src/http/mcp/tools/reports.ts +16 −5
| @@ -1,11 +1,17 @@ | ||
| 1 | 1 | import { REPORT_ALREADY_DECIDED } from "../../../actions/origin_app_words.ts"; |
| 2 | 2 | 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"; | |
| 4 | 10 | import { type Answer, good, bad, type Tool, REPO_ARG, repoArg, isAnswer } from "../shared.ts"; |
| 5 | 11 | |
| 6 | 12 | export const tool: Tool = { |
| 7 | 13 | 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", | |
| 9 | 15 | description: |
| 10 | 16 | "Outsiders' reports; `id`+`action`: accept or dismiss.", |
| 11 | 17 | inputSchema: { |
| @@ -43,12 +49,17 @@ export const tool: Tool = { | ||
| 43 | 49 | if (id === undefined) { |
| 44 | 50 | const waiting = waitingReports(ctx.app, user, repo); |
| 45 | 51 | 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("; "); | |
| 46 | 55 | return good({ |
| 47 | 56 | says: |
| 48 | waiting.value.length === 0 | |
| 57 | n === 0 | |
| 49 | 58 | ? "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), | |
| 52 | 63 | }); |
| 53 | 64 | } |
| 54 | 65 | 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 @@ | ||
| 10 | 10 | |
| 11 | 11 | import { REPORT_ALREADY_DECIDED } from "../../actions/origin_app_words.ts"; |
| 12 | 12 | 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"; | |
| 14 | 14 | import type { Repo } from "../../core/repos.ts"; |
| 15 | 15 | import type { Ctx, Screen } from "../app/app.ts"; |
| 16 | 16 | import { binding } from "../app/keys.ts"; |
| @@ -54,7 +54,11 @@ export function reportsScreen(f: Session, ctx: Ctx, repo: Repo): Screen { | ||
| 54 | 54 | }; |
| 55 | 55 | |
| 56 | 56 | 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(" · "); | |
| 58 | 62 | |
| 59 | 63 | const screen: Screen = { |
| 60 | 64 | title: `Reports: ${repo.ownerName}/${repo.name}`, |
| @@ -63,7 +67,12 @@ export function reportsScreen(f: Session, ctx: Ctx, repo: Repo): Screen { | ||
| 63 | 67 | const c = ctx.theme.c; |
| 64 | 68 | const list = load(); |
| 65 | 69 | 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 | : ""; | |
| 67 | 76 | const text = [note, status].filter((t) => t !== "").join(" "); |
| 68 | 77 | s.text(head!.x, head!.y, truncate(text, head!.w), style(c.mutedFg, c.bg), head!.x + head!.w); |
| 69 | 78 | 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 { | ||
| 73 | 82 | const x = aside.x + 1; |
| 74 | 83 | const w = Math.max(0, aside.w - 2); |
| 75 | 84 | 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}`]), | |
| 77 | 87 | "", |
| 78 | 88 | ...wrap(picked.body === "" ? "(no words)" : picked.body, w), |
| 79 | 89 | ]; |
src/web/pages/reports.ts +40 −5
| @@ -16,7 +16,10 @@ import { | ||
| 16 | 16 | OUTSIDE_TITLE, |
| 17 | 17 | REQUEST_MAX, |
| 18 | 18 | TITLE_MAX, |
| 19 | byFiler, | |
| 20 | filerWords, | |
| 19 | 21 | outsideWords, |
| 22 | type FiledBy, | |
| 20 | 23 | type Report, |
| 21 | 24 | } from "../../core/reports.ts"; |
| 22 | 25 | import { canRead, type Repo } from "../../core/repos.ts"; |
| @@ -28,6 +31,7 @@ import { | ||
| 28 | 31 | timePhase, |
| 29 | 32 | } from "../../core/route_timing.ts"; |
| 30 | 33 | import { clientBucket } from "../../http/peer.ts"; |
| 34 | import { proofOf } from "../../http/report_filer.ts"; | |
| 31 | 35 | import { |
| 32 | 36 | crumbsFor, |
| 33 | 37 | loadRepo, |
| @@ -84,6 +88,12 @@ function reportForm( | ||
| 84 | 88 | something is wrong. You need no account. A person reads it first; |
| 85 | 89 | nothing acts on it before they do. |
| 86 | 90 | </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>`} | |
| 87 | 97 | ${when(values.page !== "", () => html`<p class="small mono">About this page: ${values.page}</p>`)} |
| 88 | 98 | <form class="mt" method="post" action="${base}/report"> |
| 89 | 99 | ${formField({ |
| @@ -188,7 +198,13 @@ export async function reportSubmit(ctx: Ctx): Promise<Response> { | ||
| 188 | 198 | // person is told, and nothing is kept. |
| 189 | 199 | 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 |
||
| 190 | 200 | 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 | ); | |
| 192 | 208 | if (filed.ok) return flashTo(ctx, sent, { message: "Report sent." }); |
| 193 | 209 | const refusal = filed.error; |
| 194 | 210 | if (refusal.error === "not_found") return notFoundPage(ctx); |
| @@ -242,7 +258,7 @@ function reportCard(base: string, report: Report): Html { | ||
| 242 | 258 | </form>`, |
| 243 | 259 | { |
| 244 | 260 | 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)}`, | |
| 246 | 262 | foot: html`${btn("Accept as an issue", { |
| 247 | 263 | variant: "primary", |
| 248 | 264 | icon: "issue", |
| @@ -255,6 +271,16 @@ function reportCard(base: string, report: Report): Html { | ||
| 255 | 271 | ); |
| 256 | 272 | } |
| 257 | 273 | |
| 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 | ||
| 258 | 284 | // GET /:owner/:name/reports |
| 259 | 285 | export function reportsPage(ctx: Ctx): Response { |
| 260 | 286 | notePhase(ctx.request, "session", takeSessionTime(ctx.request)); |
| @@ -282,8 +308,10 @@ export function reportsPage(ctx: Ctx): Response { | ||
| 282 | 308 | })} |
| 283 | 309 | <h1 class="mt">Reports from outside</h1> |
| 284 | 310 | <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. | |
| 287 | 315 | </p> |
| 288 | 316 | <div class="mt"> |
| 289 | 317 | ${waiting.value.length === 0 |
| @@ -292,7 +320,14 @@ export function reportsPage(ctx: Ctx): Response { | ||
| 292 | 320 | title: "Nothing waiting", |
| 293 | 321 | note: `Anyone can report a problem at ${base}/report.`, |
| 294 | 322 | }) |
| 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 | )} | |
| 296 | 331 | </div>`, |
| 297 | 332 | }); |
| 298 | 333 | 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", () => { | ||
| 529 | 529 | page: "https://x.example/#[click](https://evil.example/e)", |
| 530 | 530 | state: "pending", |
| 531 | 531 | createdAt: "2026-10-05T00:00:00.000Z", |
| 532 | filedBy: { how: "anonymous", name: null }, | |
| 533 | filerId: null, | |
| 532 | 534 | }, |
| 533 | 535 | alice, |
| 534 | 536 | ); |
test/tui-reports.test.ts +15
| @@ -174,3 +174,18 @@ describe("no row for an agent or a guest", () => { | ||
| 174 | 174 | expect(reportsCommands({ app, user: undefined } as Session, {} as Ctx, repo)).toEqual([]); |
| 175 | 175 | }); |
| 176 | 176 | }); |
| 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 | }); |
reportViewcallsgetUserByIdandcanWritefor 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 …'):acceptedBodychanged and is untested.