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

Merged opened this

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

Merged

mason/forge:mason/6332-a-repository-s-report-routes-leave-api-t → main 4 commits fork

  • Merged. mason/6332-a-repository-s-report-routes-leave-api-t was merged into main as 90b88d3.

Review

Sign in to review this change.
3 files changed +173 −130
src/http/api.ts +5 −127
@@ -15,7 +15,6 @@ import { showProvenance } from "../actions/provenance.ts";
1515 import {
1616 APP_ALREADY_YOURS,
1717 ORIGIN_UNREACHABLE,
18 REPORT_ALREADY_DECIDED,
1918 } from "../actions/origin_app_words.ts";
2019 import { getProvenance, provenanceFacts } from "../core/provenance.ts";
2120 import { goldenPath } from "../apps/golden_path.ts";
@@ -418,24 +417,17 @@ import { forgeRoutes } from "./api/forges.ts";
418417 import { agentRoutes } from "./api/agents.ts";
419418 import { boardRoutes } from "./api/boards.ts";
420419 import { labelsRoutes } from "./api/labels.ts";
420 import { reportRoutes } from "./api/reports.ts";
421421 import { orgRoutes } from "./api/orgs.ts";
422422 import { secretRoutes } from "./api/secrets.ts";
423423 import { upstreamRoutes } from "./api/upstream.ts";
424424 import type { Router } from "./router.ts";
425 import { clientAddress, clientBucket } from "./peer.ts";
425 import { clientAddress } from "./peer.ts";
426426 import { manifestFor } from "../actions/here.ts";
427427 import { egressSnapshot } from "../apps/egress.ts";
428428 import { egressMetrics } from "../apps/egress_metrics.ts";
429429 import { forgeMetrics } from "../metrics/forge.ts";
430430 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";
439431 import { ADMIN_WORDS } from "../actions/admin_words.ts";
440432 import { SETTING_WORDS } from "../actions/setting_words.ts";
441433 import { SPEND_WORDS } from "../actions/spend_words.ts";
@@ -487,9 +479,6 @@ function reportInstead(ctx: Ctx, repo: Repo): Response {
487479 );
488480 }
489481
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
493482 // ---- shapes ----
494483
495484 /** A fleet refusal: the access codes keep their fixed shapes. */
@@ -503,7 +492,7 @@ function respondFleet(error: FleetError): Response {
503492 );
504493 }
505494
506 function withLabels<T extends Issue>(ctx: Ctx, issue: T) {
495 export function withLabels<T extends Issue>(ctx: Ctx, issue: T) {
507496 return {
508497 ...readerMarked(ctx, issue.repoId, [issue])[0]!,
509498 labels: labelsFor(ctx.app.db, issue.id),
@@ -762,7 +751,7 @@ function sourceFailure(error: SourceError): Response {
762751 * said no" from "the origin said no" — and the status says which side is
763752 * worth retrying against.
764753 */
765 function originAware(error: string, status = 422): Response {
754 export function originAware(error: string, status = 422): Response {
766755 if (error === "invalid_reason")
767756 return apiError(
768757 "invalid_reason",
@@ -2902,118 +2891,7 @@ export function apiRoutes(router: Router<Handler>): void {
29022891 return json(withLabels(ctx, created.value), 201);
29032892 });
29042893
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);
30172895
30182896 router.add("GET", "/api/v1/repos/:owner/:name/issues/:number", (ctx) => {
30192897 // 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 @@
44 * and the MCP import the sentences; a copy typed back into either fails here.
55 */
66 import { describe, expect, test } from "bun:test";
7 import { readFileSync } from "node:fs";
7 import { readdirSync, readFileSync } from "node:fs";
88 import { join } from "node:path";
99 import {
1010 APP_ALREADY_YOURS,
@@ -14,8 +14,23 @@ import {
1414 import { importsFrom, mcpSource } from "./helpers/mcp.ts";
1515
1616 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 };
1934
2035 const SENTENCES = {
2136 ORIGIN_UNREACHABLE: "the origin could not be reached",

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs