Public: anyone can read this; the crew can change it.
A repository's egress-grant routes leave api.ts for src/http/api/egress_grants.ts #6877
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/6304-a-repository-s-egress-grant-routes-leave → main 1 commit fork 1 thread · 1 unresolved
4 files
changed +72
−59
src/http/api.ts +3 −55
| @@ -303,11 +303,6 @@ import { | ||
| 303 | 303 | listAppDomains, |
| 304 | 304 | removeAppDomain, |
| 305 | 305 | } from "../actions/domains.ts"; |
| 306 | import { | |
| 307 | egressView, | |
| 308 | grantAppEgress, | |
| 309 | revokeAppEgress, | |
| 310 | } from "../actions/egress_grants.ts"; | |
| 311 | 306 | import { |
| 312 | 307 | addPromptSource, |
| 313 | 308 | listPromptSources, |
| @@ -457,6 +452,7 @@ import { | ||
| 457 | 452 | import { frontInput, frontJson, setFront } from "../actions/front.ts"; |
| 458 | 453 | import { COPIES_PER_DAY, makeItYours } from "../actions/make_it_yours.ts"; |
| 459 | 454 | import { passportRoutes } from "./passport_edge.ts"; |
| 455 | import { egressGrantRoutes } from "./api/egress_grants.ts"; | |
| 460 | 456 | import type { Router } from "./router.ts"; |
| 461 | 457 | import { clientAddress, clientBucket } from "./peer.ts"; |
| 462 | 458 | import { manifestFor } from "../actions/here.ts"; |
| @@ -1188,7 +1184,7 @@ function appLimitResponse(ctx: Ctx, ownerName: string): Response { | ||
| 1188 | 1184 | // ---- resolution ---- |
| 1189 | 1185 | |
| 1190 | 1186 | /** The repo a route names, if the viewer may see it. */ |
| 1191 | function loadRepo(ctx: Ctx): Repo | undefined { | |
| 1187 | export function loadRepo(ctx: Ctx): Repo | undefined { | |
| 1192 | 1188 | const repo = getRepo(ctx.app.db, ctx.params.owner!, ctx.params.name!); |
| 1193 | 1189 | return repo && canRead(ctx.app.db, repo, ctx.auth.user) ? repo : undefined; |
| 1194 | 1190 | } |
| @@ -5875,55 +5871,7 @@ export function apiRoutes(router: Router<Handler>): void { | ||
| 5875 | 5871 | }, |
| 5876 | 5872 | ); |
| 5877 | 5873 | |
| 5878 | // A site admin's grant of one private destination forge.json asks for | |
| 5879 | // (#3433). Reading takes admin over the repository; changing takes a site | |
| 5880 | // admin. The destination is the exact `host:port` forge.json names. | |
| 5881 | router.add("GET", "/api/v1/repos/:owner/:name/egress-grants", async (ctx) => { | |
| 5882 | notePhase(ctx.request, "session", takeSessionTime(ctx.request)); | |
| 5883 | const denied = requireScope(ctx, "admin"); | |
| 5884 | if (denied) return denied; | |
| 5885 | const repo = loadRepo(ctx); | |
| 5886 | if (!repo) return notFound(); | |
| 5887 | const view = await timePhaseAsync(ctx.request, "grants", () => | |
| 5888 | egressView(ctx.app, ctx.auth.user, repo), | |
| 5889 | ); | |
| 5890 | if (!view.ok) return respondAccess(view.error, 422); | |
| 5891 | return timePhase(ctx.request, "render", () => | |
| 5892 | json({ items: view.value.rows, changes: view.value.changes, next_cursor: null }), | |
| 5893 | ); | |
| 5894 | }); | |
| 5895 | ||
| 5896 | router.add("POST", "/api/v1/repos/:owner/:name/egress-grants", async (ctx) => { | |
| 5897 | const denied = requireScope(ctx, "admin"); | |
| 5898 | if (denied) return denied; | |
| 5899 | const repo = loadRepo(ctx); | |
| 5900 | if (!repo) return notFound(); | |
| 5901 | const input = await body(ctx.request); | |
| 5902 | const granted = await grantAppEgress( | |
| 5903 | ctx.app, | |
| 5904 | ctx.auth.user, | |
| 5905 | repo, | |
| 5906 | str(input.destination), | |
| 5907 | ); | |
| 5908 | if (!granted.ok) return respondAccess(granted.error, 422); | |
| 5909 | return json(granted.value, 201); | |
| 5910 | }); | |
| 5911 | ||
| 5912 | router.add("DELETE", "/api/v1/repos/:owner/:name/egress-grants", async (ctx) => { | |
| 5913 | const denied = requireScope(ctx, "admin"); | |
| 5914 | if (denied) return denied; | |
| 5915 | const repo = loadRepo(ctx); | |
| 5916 | if (!repo) return notFound(); | |
| 5917 | const input = await body(ctx.request); | |
| 5918 | const revoked = revokeAppEgress( | |
| 5919 | ctx.app, | |
| 5920 | ctx.auth.user, | |
| 5921 | repo, | |
| 5922 | str(input.destination), | |
| 5923 | ); | |
| 5924 | if (!revoked.ok) return respondAccess(revoked.error, 422); | |
| 5925 | return json({}, 200); | |
| 5926 | }); | |
| 5874 | egressGrantRoutes(router); | |
| 5927 | 5875 | |
| 5928 | 5876 | // Where the crew's next tell comes from when nobody is typing |
| 5929 | 5877 | // (docs/APPS.md, "Prompts from Instagram"). Admin scope and admin over |
src/http/api/egress_grants.ts added +64
| @@ -0,0 +1,64 @@ | ||
| 1 | /** | |
| 2 | * A repository's egress grants over the API: `GET`, `POST` and `DELETE` | |
| 3 | * `/api/v1/repos/:owner/:name/egress-grants`. Moved out of `api.ts` (#6304); | |
| 4 | * the rules live in `actions/egress_grants.ts`. | |
| 5 | */ | |
| 6 | ||
| 7 | import { egressView, grantAppEgress, revokeAppEgress } from "../../actions/egress_grants.ts"; | |
| 8 | import { notePhase, takeSessionTime, timePhase, timePhaseAsync } from "../../core/route_timing.ts"; | |
| 9 | import { loadRepo } from "../api.ts"; | |
| 10 | import type { Handler } from "../context.ts"; | |
| 11 | import { body, json, notFound, requireScope, respondAccess, str } from "../respond.ts"; | |
| 12 | import type { Router } from "../router.ts"; | |
| 13 | ||
| 14 | export function egressGrantRoutes(router: Router<Handler>): void { | |
| 15 | // A site admin's grant of one private destination forge.json asks for | |
| 16 | // (#3433). Reading takes admin over the repository; changing takes a site | |
| 17 | // admin. The destination is the exact `host:port` forge.json names. | |
| 18 | router.add("GET", "/api/v1/repos/:owner/:name/egress-grants", async (ctx) => { | |
| 19 | notePhase(ctx.request, "session", takeSessionTime(ctx.request)); | |
| 20 | const denied = requireScope(ctx, "admin"); | |
| 21 | if (denied) return denied; | |
| 22 | const repo = loadRepo(ctx); | |
| 23 | if (!repo) return notFound(); | |
| 24 | const view = await timePhaseAsync(ctx.request, "grants", () => | |
| 25 | egressView(ctx.app, ctx.auth.user, repo), | |
| 26 | ); | |
| 27 | if (!view.ok) return respondAccess(view.error, 422); | |
| 28 | return timePhase(ctx.request, "render", () => | |
| 29 | json({ items: view.value.rows, changes: view.value.changes, next_cursor: null }), | |
| 30 | ); | |
| 31 | }); | |
| 32 | ||
| 33 | router.add("POST", "/api/v1/repos/:owner/:name/egress-grants", async (ctx) => { | |
| 34 | const denied = requireScope(ctx, "admin"); | |
| 35 | if (denied) return denied; | |
| 36 | const repo = loadRepo(ctx); | |
| 37 | if (!repo) return notFound(); | |
| 38 | const input = await body(ctx.request); | |
| 39 | const granted = await grantAppEgress( | |
| 40 | ctx.app, | |
| 41 | ctx.auth.user, | |
| 42 | repo, | |
| 43 | str(input.destination), | |
| 44 | ); | |
| 45 | if (!granted.ok) return respondAccess(granted.error, 422); | |
| 46 | return json(granted.value, 201); | |
| 47 | }); | |
| 48 | ||
| 49 | router.add("DELETE", "/api/v1/repos/:owner/:name/egress-grants", async (ctx) => { | |
| 50 | const denied = requireScope(ctx, "admin"); | |
| 51 | if (denied) return denied; | |
| 52 | const repo = loadRepo(ctx); | |
| 53 | if (!repo) return notFound(); | |
| 54 | const input = await body(ctx.request); | |
| 55 | const revoked = revokeAppEgress( | |
| 56 | ctx.app, | |
| 57 | ctx.auth.user, | |
| 58 | repo, | |
| 59 | str(input.destination), | |
| 60 | ); | |
| 61 | if (!revoked.ok) return respondAccess(revoked.error, 422); | |
| 62 | return json({}, 200); | |
| 63 | }); | |
| 64 | } |
test/openapi-coverage.test.ts +2 −2
| @@ -13,9 +13,9 @@ const NOT_YET_DESCRIBED: readonly string[] = [ | ||
| 13 | 13 | |
| 14 | 14 | const norm = (path: string) => path.replace(/[:*]\w+/g, "{}").replace(/\{\w+\}/g, "{}"); |
| 15 | 15 | |
| 16 | /** Every file under src/http that registers routes. */ | |
| 16 | /** Every file under src/http (and its route modules in src/http/api/) that registers routes. */ | |
| 17 | 17 | const files = () => |
| 18 | readdirSync(new URL("../src/http/", import.meta.url)) | |
| 18 | [...readdirSync(new URL("../src/http/", import.meta.url)), ...readdirSync(new URL("../src/http/api/", import.meta.url)).map((f) => `api/${f}`)] | |
| 19 | 19 | .filter((f) => f.endsWith(".ts")) |
| 20 | 20 | .map((f) => ({ f, src: readFileSync(new URL(`../src/http/${f}`, import.meta.url), "utf8") })) |
| 21 | 21 | .filter(({ src }) => src.includes("router.add(")); |
test/openapi.test.ts +3 −2
| @@ -1,6 +1,6 @@ | ||
| 1 | 1 | /** The OpenAPI document names every route the manifest names. */ |
| 2 | 2 | import { expect, test } from "bun:test"; |
| 3 | import { readFileSync } from "node:fs"; | |
| 3 | import { readdirSync, readFileSync } from "node:fs"; | |
| 4 | 4 | import { mkdtemp, rm } from "node:fs/promises"; |
| 5 | 5 | import { tmpdir } from "node:os"; |
| 6 | 6 | import { join } from "node:path"; |
| @@ -30,7 +30,8 @@ test("every http route the manifest names is described", () => { | ||
| 30 | 30 | }); |
| 31 | 31 | |
| 32 | 32 | test("every described route is registered in the router", () => { |
| 33 | const src = ["src/http/server.ts", "src/http/api.ts", "src/http/mcp.ts", "src/http/passport_edge.ts", "src/http/feed.ts", "src/http/checks_recheck.ts"] | |
| 33 | const src = ["src/http/server.ts", "src/http/api.ts", "src/http/mcp.ts", "src/http/passport_edge.ts", "src/http/feed.ts", "src/http/checks_recheck.ts", | |
| 34 | ...readdirSync("src/http/api").map((f) => `src/http/api/${f}`)] | |
| 34 | 35 | .map((f) => readFileSync(f, "utf8")) |
| 35 | 36 | .join("\n") |
| 36 | 37 | // A route registered in a loop over issues and pulls is two routes. |
new above
offline
Exporting
loadRepomakes api.ts and api/egress_grants.ts import each other. That is fine today because it is only called inside handlers. Once a few more families move out, it would be cleaner to moveloadRepointo a smallhttp/repo_access.tsthat both import. That can be a later pull, not this one.