Public: anyone can read this; the crew can change it.
A repository's report routes leave api.ts for src/http/api/reports.ts #7014
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/6332-a-repository-s-report-routes-leave-api-t → main 4 commits fork
3 files
changed +173
−130
src/http/api.ts +5 −127
| @@ -15,7 +15,6 @@ import { showProvenance } from "../actions/provenance.ts"; | ||
| 15 | 15 | import { |
| 16 | 16 | APP_ALREADY_YOURS, |
| 17 | 17 | ORIGIN_UNREACHABLE, |
| 18 | REPORT_ALREADY_DECIDED, | |
| 19 | 18 | } from "../actions/origin_app_words.ts"; |
| 20 | 19 | import { getProvenance, provenanceFacts } from "../core/provenance.ts"; |
| 21 | 20 | import { goldenPath } from "../apps/golden_path.ts"; |
| @@ -418,24 +417,17 @@ import { forgeRoutes } from "./api/forges.ts"; | ||
| 418 | 417 | import { agentRoutes } from "./api/agents.ts"; |
| 419 | 418 | import { boardRoutes } from "./api/boards.ts"; |
| 420 | 419 | import { labelsRoutes } from "./api/labels.ts"; |
| 420 | import { reportRoutes } from "./api/reports.ts"; | |
| 421 | 421 | import { orgRoutes } from "./api/orgs.ts"; |
| 422 | 422 | import { secretRoutes } from "./api/secrets.ts"; |
| 423 | 423 | import { upstreamRoutes } from "./api/upstream.ts"; |
| 424 | 424 | import type { Router } from "./router.ts"; |
| 425 | import { clientAddress, clientBucket } from "./peer.ts"; | |
| 425 | import { clientAddress } from "./peer.ts"; | |
| 426 | 426 | import { manifestFor } from "../actions/here.ts"; |
| 427 | 427 | import { egressSnapshot } from "../apps/egress.ts"; |
| 428 | 428 | import { egressMetrics } from "../apps/egress_metrics.ts"; |
| 429 | 429 | import { forgeMetrics } from "../metrics/forge.ts"; |
| 430 | 430 | import { appSources } from "../metrics/app_sources.ts"; |
| 431 | import { | |
| 432 | acceptReport, | |
| 433 | dismissReport, | |
| 434 | fileReport, | |
| 435 | waitingReports, | |
| 436 | } from "../actions/reports.ts"; | |
| 437 | import { BODY_MAX, REQUEST_MAX, TITLE_MAX } from "../core/reports.ts"; | |
| 438 | import { REPORT_RECEIVED } from "../actions/report_words.ts"; | |
| 439 | 431 | import { ADMIN_WORDS } from "../actions/admin_words.ts"; |
| 440 | 432 | import { SETTING_WORDS } from "../actions/setting_words.ts"; |
| 441 | 433 | import { SPEND_WORDS } from "../actions/spend_words.ts"; |
| @@ -487,9 +479,6 @@ function reportInstead(ctx: Ctx, repo: Repo): Response { | ||
| 487 | 479 | ); |
| 488 | 480 | } |
| 489 | 481 | |
| 490 | /** What a refused report is told: the shape that would have been taken. */ | |
| 491 | 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`; | |
| 492 | ||
| 493 | 482 | // ---- shapes ---- |
| 494 | 483 | |
| 495 | 484 | /** A fleet refusal: the access codes keep their fixed shapes. */ |
| @@ -503,7 +492,7 @@ function respondFleet(error: FleetError): Response { | ||
| 503 | 492 | ); |
| 504 | 493 | } |
| 505 | 494 | |
| 506 | function withLabels<T extends Issue>(ctx: Ctx, issue: T) { | |
| 495 | export function withLabels<T extends Issue>(ctx: Ctx, issue: T) { | |
| 507 | 496 | return { |
| 508 | 497 | ...readerMarked(ctx, issue.repoId, [issue])[0]!, |
| 509 | 498 | labels: labelsFor(ctx.app.db, issue.id), |
| @@ -762,7 +751,7 @@ function sourceFailure(error: SourceError): Response { | ||
| 762 | 751 | * said no" from "the origin said no" — and the status says which side is |
| 763 | 752 | * worth retrying against. |
| 764 | 753 | */ |
| 765 | function originAware(error: string, status = 422): Response { | |
| 754 | export function originAware(error: string, status = 422): Response { | |
| 766 | 755 | if (error === "invalid_reason") |
| 767 | 756 | return apiError( |
| 768 | 757 | "invalid_reason", |
| @@ -2902,118 +2891,7 @@ export function apiRoutes(router: Router<Handler>): void { | ||
| 2902 | 2891 | return json(withLabels(ctx, created.value), 201); |
| 2903 | 2892 | }); |
| 2904 | 2893 | |
| 2905 | // ── reports from outside — no account, a person decides ────────────── | |
| 2906 | ||
| 2907 | router.add("POST", "/api/v1/repos/:owner/:name/reports", async (ctx) => { | |
| 2908 | const repo = loadRepo(ctx); | |
| 2909 | if (!repo) return notFound(); | |
| 2910 | const input = await boundedBody(ctx.request, REQUEST_MAX); | |
| 2911 | if (input === "too_large") | |
| 2912 | return json({ error: "too_large", message: REPORT_HINT }, 413); | |
| 2913 | const filed = fileReport( | |
| 2914 | ctx.app, | |
| 2915 | repo, | |
| 2916 | { title: str(input.title), body: str(input.body), page: str(input.page) }, | |
| 2917 | clientBucket(ctx.request), | |
| 2918 | ); | |
| 2919 | if (!filed.ok) { | |
| 2920 | const refusal = filed.error; | |
| 2921 | if (refusal.error === "not_found") return notFound(); | |
| 2922 | if (refusal.error === "rate_limited") | |
| 2923 | return json( | |
| 2924 | { error: "rate_limited", message: "too many reports; wait and send again" }, | |
| 2925 | 429, | |
| 2926 | { "retry-after": String(refusal.retryAfter) }, | |
| 2927 | ); | |
| 2928 | if (refusal.error === "queue_full") | |
| 2929 | return apiError( | |
| 2930 | "queue_full", | |
| 2931 | 429, | |
| 2932 | "this repository has many reports waiting; try again later", | |
| 2933 | ); | |
| 2934 | return apiError(refusal.error, 422, REPORT_HINT); | |
| 2935 | } | |
| 2936 | return json( | |
| 2937 | { | |
| 2938 | id: filed.value.id, | |
| 2939 | state: filed.value.state, | |
| 2940 | message: REPORT_RECEIVED, | |
| 2941 | }, | |
| 2942 | 202, | |
| 2943 | ); | |
| 2944 | }); | |
| 2945 | ||
| 2946 | // Reading the words is a writer's act, so a read-only token — the one an | |
| 2947 | // app process holds for its owner — cannot fetch them. | |
| 2948 | router.add("GET", "/api/v1/repos/:owner/:name/reports", (ctx) => { | |
| 2949 | notePhase(ctx.request, "session", takeSessionTime(ctx.request)); | |
| 2950 | const denied = requireScope(ctx, "rw"); | |
| 2951 | if (denied) return denied; | |
| 2952 | const repo = loadRepo(ctx); | |
| 2953 | if (!repo) return notFound(); | |
| 2954 | const window = keyedWindow(ctx); | |
| 2955 | if (window instanceof Response) return window; | |
| 2956 | const waiting = timePhase(ctx.request, "reports", () => | |
| 2957 | waitingReports(ctx.app, ctx.auth.user, repo), | |
| 2958 | ); | |
| 2959 | if (!waiting.ok) return respondAccess(waiting.error, 403); | |
| 2960 | return timePhase(ctx.request, "render", () => | |
| 2961 | json( | |
| 2962 | keyedPage( | |
| 2963 | waiting.value.map((r) => ({ ...r, from: "outside" })), | |
| 2964 | (r) => r.id, | |
| 2965 | window, | |
| 2966 | ), | |
| 2967 | ), | |
| 2968 | ); | |
| 2969 | }); | |
| 2970 | ||
| 2971 | router.add( | |
| 2972 | "POST", | |
| 2973 | "/api/v1/repos/:owner/:name/reports/:id/accept", | |
| 2974 | async (ctx) => { | |
| 2975 | const denied = requireScope(ctx, "rw"); | |
| 2976 | if (denied) return denied; | |
| 2977 | const repo = loadRepo(ctx); | |
| 2978 | if (!repo) return notFound(); | |
| 2979 | // `{ title }` is the accepting person's wording; the marker stays. | |
| 2980 | const { title } = await body(ctx.request); | |
| 2981 | const accepted = await acceptReport( | |
| 2982 | ctx.app, | |
| 2983 | ctx.auth.user, | |
| 2984 | repo, | |
| 2985 | ctx.params.id ?? "", | |
| 2986 | typeof title === "string" ? title : undefined, | |
| 2987 | ); | |
| 2988 | if (!accepted.ok) | |
| 2989 | return accepted.error === "decided" | |
| 2990 | ? apiError("decided", 409, REPORT_ALREADY_DECIDED) | |
| 2991 | : originAware(accepted.error); | |
| 2992 | return json(withLabels(ctx, accepted.value), 201); | |
| 2993 | }, | |
| 2994 | ); | |
| 2995 | ||
| 2996 | router.add( | |
| 2997 | "POST", | |
| 2998 | "/api/v1/repos/:owner/:name/reports/:id/dismiss", | |
| 2999 | (ctx) => { | |
| 3000 | const denied = requireScope(ctx, "rw"); | |
| 3001 | if (denied) return denied; | |
| 3002 | const repo = loadRepo(ctx); | |
| 3003 | if (!repo) return notFound(); | |
| 3004 | const dismissed = dismissReport( | |
| 3005 | ctx.app, | |
| 3006 | ctx.auth.user, | |
| 3007 | repo, | |
| 3008 | ctx.params.id ?? "", | |
| 3009 | ); | |
| 3010 | if (!dismissed.ok) | |
| 3011 | return dismissed.error === "decided" | |
| 3012 | ? apiError("decided", 409, REPORT_ALREADY_DECIDED) | |
| 3013 | : respondAccess(dismissed.error, 403); | |
| 3014 | return json({ id: ctx.params.id, state: "dismissed" }); | |
| 3015 | }, | |
| 3016 | ); | |
| 2894 | reportRoutes(router); | |
| 3017 | 2895 | |
| 3018 | 2896 | router.add("GET", "/api/v1/repos/:owner/:name/issues/:number", (ctx) => { |
| 3019 | 2897 | // Numbers are shared with pulls: a pull's number answers as the same |
src/http/api/reports.ts added +150
| @@ -0,0 +1,150 @@ | ||
| 1 | /** | |
| 2 | * Reports from outside over the API: `POST` and `GET` | |
| 3 | * `/api/v1/repos/:owner/:name/reports`, and `POST` `…/reports/:id/accept` | |
| 4 | * and `…/reports/:id/dismiss`. Filing needs no account and is rate-limited | |
| 5 | * per client; a person decides. Moved out of `api.ts` (#6332); the rules | |
| 6 | * live in `actions/reports.ts`. | |
| 7 | */ | |
| 8 | ||
| 9 | import { REPORT_ALREADY_DECIDED } from "../../actions/origin_app_words.ts"; | |
| 10 | import { REPORT_RECEIVED } from "../../actions/report_words.ts"; | |
| 11 | import { | |
| 12 | acceptReport, | |
| 13 | dismissReport, | |
| 14 | fileReport, | |
| 15 | waitingReports, | |
| 16 | } from "../../actions/reports.ts"; | |
| 17 | import { BODY_MAX, REQUEST_MAX, TITLE_MAX } from "../../core/reports.ts"; | |
| 18 | import { notePhase, takeSessionTime, timePhase } from "../../core/route_timing.ts"; | |
| 19 | import { keyedPage, keyedWindow, loadRepo, originAware, withLabels } from "../api.ts"; | |
| 20 | import type { Handler } from "../context.ts"; | |
| 21 | import { clientBucket } from "../peer.ts"; | |
| 22 | import { | |
| 23 | apiError, | |
| 24 | body, | |
| 25 | boundedBody, | |
| 26 | json, | |
| 27 | notFound, | |
| 28 | requireScope, | |
| 29 | respondAccess, | |
| 30 | str, | |
| 31 | } from "../respond.ts"; | |
| 32 | import type { Router } from "../router.ts"; | |
| 33 | ||
| 34 | /** 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`; | |
| 36 | ||
| 37 | export function reportRoutes(router: Router<Handler>): void { | |
| 38 | // ── reports from outside — no account, a person decides ────────────── | |
| 39 | ||
| 40 | router.add("POST", "/api/v1/repos/:owner/:name/reports", async (ctx) => { | |
| 41 | const repo = loadRepo(ctx); | |
| 42 | if (!repo) return notFound(); | |
| 43 | const input = await boundedBody(ctx.request, REQUEST_MAX); | |
| 44 | if (input === "too_large") | |
| 45 | return json({ error: "too_large", message: REPORT_HINT }, 413); | |
| 46 | const filed = fileReport( | |
| 47 | ctx.app, | |
| 48 | repo, | |
| 49 | { title: str(input.title), body: str(input.body), page: str(input.page) }, | |
| 50 | clientBucket(ctx.request), | |
| 51 | ); | |
| 52 | if (!filed.ok) { | |
| 53 | const refusal = filed.error; | |
| 54 | if (refusal.error === "not_found") return notFound(); | |
| 55 | if (refusal.error === "rate_limited") | |
| 56 | return json( | |
| 57 | { error: "rate_limited", message: "too many reports; wait and send again" }, | |
| 58 | 429, | |
| 59 | { "retry-after": String(refusal.retryAfter) }, | |
| 60 | ); | |
| 61 | if (refusal.error === "queue_full") | |
| 62 | return apiError( | |
| 63 | "queue_full", | |
| 64 | 429, | |
| 65 | "this repository has many reports waiting; try again later", | |
| 66 | ); | |
| 67 | return apiError(refusal.error, 422, REPORT_HINT); | |
| 68 | } | |
| 69 | return json( | |
| 70 | { | |
| 71 | id: filed.value.id, | |
| 72 | state: filed.value.state, | |
| 73 | message: REPORT_RECEIVED, | |
| 74 | }, | |
| 75 | 202, | |
| 76 | ); | |
| 77 | }); | |
| 78 | ||
| 79 | // Reading the words is a writer's act, so a read-only token — the one an | |
| 80 | // app process holds for its owner — cannot fetch them. | |
| 81 | router.add("GET", "/api/v1/repos/:owner/:name/reports", (ctx) => { | |
| 82 | notePhase(ctx.request, "session", takeSessionTime(ctx.request)); | |
| 83 | const denied = requireScope(ctx, "rw"); | |
| 84 | if (denied) return denied; | |
| 85 | const repo = loadRepo(ctx); | |
| 86 | if (!repo) return notFound(); | |
| 87 | const window = keyedWindow(ctx); | |
| 88 | if (window instanceof Response) return window; | |
| 89 | const waiting = timePhase(ctx.request, "reports", () => | |
| 90 | waitingReports(ctx.app, ctx.auth.user, repo), | |
| 91 | ); | |
| 92 | if (!waiting.ok) return respondAccess(waiting.error, 403); | |
| 93 | return timePhase(ctx.request, "render", () => | |
| 94 | json( | |
| 95 | keyedPage( | |
| 96 | waiting.value.map((r) => ({ ...r, from: "outside" })), | |
| 97 | (r) => r.id, | |
| 98 | window, | |
| 99 | ), | |
| 100 | ), | |
| 101 | ); | |
| 102 | }); | |
| 103 | ||
| 104 | router.add( | |
| 105 | "POST", | |
| 106 | "/api/v1/repos/:owner/:name/reports/:id/accept", | |
| 107 | async (ctx) => { | |
| 108 | const denied = requireScope(ctx, "rw"); | |
| 109 | if (denied) return denied; | |
| 110 | const repo = loadRepo(ctx); | |
| 111 | if (!repo) return notFound(); | |
| 112 | // `{ title }` is the accepting person's wording; the marker stays. | |
| 113 | const { title } = await body(ctx.request); | |
| 114 | const accepted = await acceptReport( | |
| 115 | ctx.app, | |
| 116 | ctx.auth.user, | |
| 117 | repo, | |
| 118 | ctx.params.id ?? "", | |
| 119 | typeof title === "string" ? title : undefined, | |
| 120 | ); | |
| 121 | if (!accepted.ok) | |
| 122 | return accepted.error === "decided" | |
| 123 | ? apiError("decided", 409, REPORT_ALREADY_DECIDED) | |
| 124 | : originAware(accepted.error); | |
| 125 | return json(withLabels(ctx, accepted.value), 201); | |
| 126 | }, | |
| 127 | ); | |
| 128 | ||
| 129 | router.add( | |
| 130 | "POST", | |
| 131 | "/api/v1/repos/:owner/:name/reports/:id/dismiss", | |
| 132 | (ctx) => { | |
| 133 | const denied = requireScope(ctx, "rw"); | |
| 134 | if (denied) return denied; | |
| 135 | const repo = loadRepo(ctx); | |
| 136 | if (!repo) return notFound(); | |
| 137 | const dismissed = dismissReport( | |
| 138 | ctx.app, | |
| 139 | ctx.auth.user, | |
| 140 | repo, | |
| 141 | ctx.params.id ?? "", | |
| 142 | ); | |
| 143 | if (!dismissed.ok) | |
| 144 | return dismissed.error === "decided" | |
| 145 | ? apiError("decided", 409, REPORT_ALREADY_DECIDED) | |
| 146 | : respondAccess(dismissed.error, 403); | |
| 147 | return json({ id: ctx.params.id, state: "dismissed" }); | |
| 148 | }, | |
| 149 | ); | |
| 150 | } |
test/origin-app-words.test.ts +18 −3
| @@ -4,7 +4,7 @@ | ||
| 4 | 4 | * and the MCP import the sentences; a copy typed back into either fails here. |
| 5 | 5 | */ |
| 6 | 6 | import { describe, expect, test } from "bun:test"; |
| 7 | import { readFileSync } from "node:fs"; | |
| 7 | import { readdirSync, readFileSync } from "node:fs"; | |
| 8 | 8 | import { join } from "node:path"; |
| 9 | 9 | import { |
| 10 | 10 | APP_ALREADY_YOURS, |
| @@ -14,8 +14,23 @@ import { | ||
| 14 | 14 | import { importsFrom, mcpSource } from "./helpers/mcp.ts"; |
| 15 | 15 | |
| 16 | 16 | const root = join(import.meta.dir, ".."); |
| 17 | const read = (path: string) => | |
| 18 | path === "src/http/mcp.ts" ? mcpSource(root) : readFileSync(join(root, path), "utf8"); | |
| 17 | // The API door is api.ts and the route families moved out of it (#6332). | |
| 18 | const apiFiles = (): string[] => [ | |
| 19 | "src/http/api.ts", | |
| 20 | ...readdirSync(join(root, "src/http/api")) | |
| 21 | .filter((f) => f.endsWith(".ts")) | |
| 22 | .sort() | |
| 23 | .map((f) => `src/http/api/${f}`), | |
| 24 | ]; | |
| 25 | const apiSource = (): string => | |
| 26 | apiFiles() | |
| 27 | .map((f) => readFileSync(join(root, f), "utf8")) | |
| 28 | .join("\n"); | |
| 29 | const read = (path: string) => { | |
| 30 | if (path === "src/http/mcp.ts") return mcpSource(root); | |
| 31 | if (path === "src/http/api.ts") return apiSource(); | |
| 32 | return readFileSync(join(root, path), "utf8"); | |
| 33 | }; | |
| 19 | 34 | |
| 20 | 35 | const SENTENCES = { |
| 21 | 36 | ORIGIN_UNREACHABLE: "the origin could not be reached", |
new above
offline