Public: anyone can read this; the crew can change it.
Organization routes leave api.ts for src/http/api/orgs.ts #6944
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/6327-organization-routes-leave-api-ts-for-src → main 1 commit fork 1 thread · 1 unresolved
2 files
changed +193
−166
src/http/api.ts +4 −166
| @@ -108,13 +108,6 @@ import { | ||
| 108 | 108 | setIssueSealed, |
| 109 | 109 | setIssueStateThrough, |
| 110 | 110 | } from "../actions/issues.ts"; |
| 111 | import { | |
| 112 | addTeamMember, | |
| 113 | createOrg, | |
| 114 | createTeam, | |
| 115 | deleteTeam, | |
| 116 | removeTeamMember, | |
| 117 | } from "../actions/orgs.ts"; | |
| 118 | 111 | import { |
| 119 | 112 | connectOrigin, |
| 120 | 113 | deleteOriginApp, |
| @@ -214,13 +207,7 @@ import { | ||
| 214 | 207 | } from "../core/issues.ts"; |
| 215 | 208 | import { listKeys, publicKey } from "../core/keys.ts"; |
| 216 | 209 | import { |
| 217 | getOrg, | |
| 218 | 210 | listOrgsForUser, |
| 219 | listTeamMembers, | |
| 220 | listTeams, | |
| 221 | listTeamsVisibleTo, | |
| 222 | type Permission, | |
| 223 | type Team, | |
| 224 | 211 | soleOwnedOrgs, |
| 225 | 212 | lastOwnerSays, |
| 226 | 213 | } from "../core/orgs.ts"; |
| @@ -452,6 +439,7 @@ import { COPIES_PER_DAY, makeItYours } from "../actions/make_it_yours.ts"; | ||
| 452 | 439 | import { passportRoutes } from "./passport_edge.ts"; |
| 453 | 440 | import { egressGrantRoutes } from "./api/egress_grants.ts"; |
| 454 | 441 | import { labelsRoutes } from "./api/labels.ts"; |
| 442 | import { orgRoutes } from "./api/orgs.ts"; | |
| 455 | 443 | import { secretRoutes } from "./api/secrets.ts"; |
| 456 | 444 | import type { Router } from "./router.ts"; |
| 457 | 445 | import { clientAddress, clientBucket } from "./peer.ts"; |
| @@ -1050,7 +1038,7 @@ function offsetRefused(error: "invalid_limit" | "invalid_cursor"): Response { | ||
| 1050 | 1038 | * `?limit=&cursor=` for a list paged by a name: limit 1..200 (default 50), |
| 1051 | 1039 | * the cursor the last name of the page before. A bad one is a 422. |
| 1052 | 1040 | */ |
| 1053 | function keyedWindow(ctx: Ctx): { limit: number; cursor: string | null } | Response { | |
| 1041 | export function keyedWindow(ctx: Ctx): { limit: number; cursor: string | null } | Response { | |
| 1054 | 1042 | const q = ctx.url.searchParams; |
| 1055 | 1043 | const rawLimit = q.get("limit"); |
| 1056 | 1044 | const limit = rawLimit === null ? LIST_LIMIT : /^\d{1,4}$/.test(rawLimit) ? Number(rawLimit) : 0; |
| @@ -1063,7 +1051,7 @@ function keyedWindow(ctx: Ctx): { limit: number; cursor: string | null } | Respo | ||
| 1063 | 1051 | } |
| 1064 | 1052 | |
| 1065 | 1053 | /** The page of `rows` (sorted by name) after the cursor: `{ items, next_cursor }`. */ |
| 1066 | function keyedPage<T>( | |
| 1054 | export function keyedPage<T>( | |
| 1067 | 1055 | rows: T[], |
| 1068 | 1056 | keyOf: (row: T) => string, |
| 1069 | 1057 | window: { limit: number; cursor: string | null }, |
| @@ -1233,29 +1221,6 @@ function loadPull(ctx: Ctx): Found<{ pull: Pull }> { | ||
| 1233 | 1221 | return { repo, pull }; |
| 1234 | 1222 | } |
| 1235 | 1223 | |
| 1236 | /** An org by name plus the team a route names (by name, case-insensitive). */ | |
| 1237 | function loadTeam(ctx: Ctx): { org: User; team: Team } | { error: Response } { | |
| 1238 | const org = getOrg(ctx.app.db, ctx.params.org!); | |
| 1239 | if (!org) return { error: notFound() }; | |
| 1240 | const wanted = (ctx.params.team ?? "").toLowerCase(); | |
| 1241 | const team = listTeams(ctx.app.db, org.id).find( | |
| 1242 | (t) => t.name.toLowerCase() === wanted, | |
| 1243 | ); | |
| 1244 | return team ? { org, team } : { error: notFound() }; | |
| 1245 | } | |
| 1246 | ||
| 1247 | /** Whether the viewer may see this team at all (admin, or a member of it). */ | |
| 1248 | function teamVisible(ctx: Ctx, org: User, team: Team): boolean { | |
| 1249 | return listTeamsVisibleTo(ctx.app.db, org.id, ctx.auth.user).some( | |
| 1250 | (t) => t.id === team.id, | |
| 1251 | ); | |
| 1252 | } | |
| 1253 | ||
| 1254 | const permissionOf = (raw: unknown): Permission => { | |
| 1255 | const p = str(raw); | |
| 1256 | return p === "write" || p === "admin" ? p : "read"; | |
| 1257 | }; | |
| 1258 | ||
| 1259 | 1224 | // ---- routes ---- |
| 1260 | 1225 | |
| 1261 | 1226 | /** An app-members answer: the list, or the refusal a stranger cannot read anything into. */ |
| @@ -2222,134 +2187,7 @@ export function apiRoutes(router: Router<Handler>): void { | ||
| 2222 | 2187 | |
| 2223 | 2188 | // ── organizations & teams ──────────────────────────────────────────── |
| 2224 | 2189 | |
| 2225 | router.add("POST", "/api/v1/orgs", async (ctx) => { | |
| 2226 | const denied = requireScope(ctx, "rw"); | |
| 2227 | if (denied) return denied; | |
| 2228 | const input = await body(ctx.request); | |
| 2229 | const created = createOrg(ctx.app, ctx.auth.user, str(input.name)); | |
| 2230 | if (!created.ok) return respondAccess(created.error, 422); | |
| 2231 | return json( | |
| 2232 | { id: created.value.id, username: created.value.username, kind: "org" }, | |
| 2233 | 201, | |
| 2234 | ); | |
| 2235 | }); | |
| 2236 | ||
| 2237 | router.add("GET", "/api/v1/orgs/:org", (ctx) => { | |
| 2238 | notePhase(ctx.request, "session", takeSessionTime(ctx.request)); | |
| 2239 | const org = getOrg(ctx.app.db, ctx.params.org!); | |
| 2240 | if (!org) return notFound(); | |
| 2241 | const teams = timePhase(ctx.request, "org", () => | |
| 2242 | listTeamsVisibleTo(ctx.app.db, org.id, ctx.auth.user), | |
| 2243 | ); | |
| 2244 | return timePhase(ctx.request, "render", () => | |
| 2245 | json({ id: org.id, ...publicProfile(org), teams }), | |
| 2246 | ); | |
| 2247 | }); | |
| 2248 | ||
| 2249 | router.add("GET", "/api/v1/orgs/:org/teams", (ctx) => { | |
| 2250 | notePhase(ctx.request, "session", takeSessionTime(ctx.request)); | |
| 2251 | const org = getOrg(ctx.app.db, ctx.params.org!); | |
| 2252 | if (!org) return notFound(); | |
| 2253 | const window = keyedWindow(ctx); | |
| 2254 | if (window instanceof Response) return window; | |
| 2255 | const teams = timePhase(ctx.request, "teams", () => | |
| 2256 | listTeamsVisibleTo(ctx.app.db, org.id, ctx.auth.user), | |
| 2257 | ); | |
| 2258 | return timePhase(ctx.request, "render", () => | |
| 2259 | json(keyedPage(teams, (t) => t.name, window)), | |
| 2260 | ); | |
| 2261 | }); | |
| 2262 | ||
| 2263 | router.add("POST", "/api/v1/orgs/:org/teams", async (ctx) => { | |
| 2264 | const denied = requireScope(ctx, "admin"); | |
| 2265 | if (denied) return denied; | |
| 2266 | const org = getOrg(ctx.app.db, ctx.params.org!); | |
| 2267 | if (!org) return notFound(); | |
| 2268 | const input = await body(ctx.request); | |
| 2269 | const team = createTeam( | |
| 2270 | ctx.app, | |
| 2271 | ctx.auth.user, | |
| 2272 | org, | |
| 2273 | str(input.name), | |
| 2274 | permissionOf(input.permission), | |
| 2275 | ); | |
| 2276 | if (!team.ok) return respondAccess(team.error, 422); | |
| 2277 | return json(team.value, 201); | |
| 2278 | }); | |
| 2279 | ||
| 2280 | router.add("DELETE", "/api/v1/orgs/:org/teams/:team", (ctx) => { | |
| 2281 | const denied = requireScope(ctx, "admin"); | |
| 2282 | if (denied) return denied; | |
| 2283 | const found = loadTeam(ctx); | |
| 2284 | if ("error" in found) return found.error; | |
| 2285 | const removed = deleteTeam(ctx.app, ctx.auth.user, found.org, found.team); | |
| 2286 | if (!removed.ok) return respondAccess(removed.error, 409); | |
| 2287 | return new Response(null, { status: 204 }); | |
| 2288 | }); | |
| 2289 | ||
| 2290 | router.add("GET", "/api/v1/orgs/:org/teams/:team/members", (ctx) => { | |
| 2291 | notePhase(ctx.request, "session", takeSessionTime(ctx.request)); | |
| 2292 | const found = loadTeam(ctx); | |
| 2293 | if ("error" in found) return found.error; | |
| 2294 | if (!teamVisible(ctx, found.org, found.team)) return notFound(); | |
| 2295 | const window = keyedWindow(ctx); | |
| 2296 | if (window instanceof Response) return window; | |
| 2297 | const members = timePhase(ctx.request, "members", () => | |
| 2298 | listTeamMembers(ctx.app.db, found.team.id).map(publicProfile), | |
| 2299 | ); | |
| 2300 | return timePhase(ctx.request, "render", () => | |
| 2301 | json(keyedPage(members, (u) => u.username, window)), | |
| 2302 | ); | |
| 2303 | }); | |
| 2304 | ||
| 2305 | router.add("POST", "/api/v1/orgs/:org/teams/:team/members", async (ctx) => { | |
| 2306 | const denied = requireScope(ctx, "admin"); | |
| 2307 | if (denied) return denied; | |
| 2308 | const found = loadTeam(ctx); | |
| 2309 | if ("error" in found) return found.error; | |
| 2310 | const input = await body(ctx.request); | |
| 2311 | const added = addTeamMember( | |
| 2312 | ctx.app, | |
| 2313 | ctx.auth.user, | |
| 2314 | found.org, | |
| 2315 | found.team, | |
| 2316 | str(input.user), | |
| 2317 | ); | |
| 2318 | if (!added.ok) return respondAccess(added.error, 422); | |
| 2319 | return json(added.value, added.value.added ? 201 : 200); | |
| 2320 | }); | |
| 2321 | ||
| 2322 | router.add( | |
| 2323 | "DELETE", | |
| 2324 | "/api/v1/orgs/:org/teams/:team/members/:username", | |
| 2325 | (ctx) => { | |
| 2326 | const denied = requireScope(ctx, "admin"); | |
| 2327 | if (denied) return denied; | |
| 2328 | const found = loadTeam(ctx); | |
| 2329 | if ("error" in found) return found.error; | |
| 2330 | const username = ctx.params.username!; | |
| 2331 | const isMember = listTeamMembers(ctx.app.db, found.team.id).some( | |
| 2332 | (m) => m.username.toLowerCase() === username.toLowerCase(), | |
| 2333 | ); | |
| 2334 | const removed = removeTeamMember( | |
| 2335 | ctx.app, | |
| 2336 | ctx.auth.user, | |
| 2337 | found.org, | |
| 2338 | found.team, | |
| 2339 | username, | |
| 2340 | ); | |
| 2341 | if (!removed.ok) | |
| 2342 | return removed.error === "last_owner" | |
| 2343 | ? apiError( | |
| 2344 | "last_owner", | |
| 2345 | 409, | |
| 2346 | "an organization keeps at least one person who owns it; make someone else an owner first", | |
| 2347 | ) | |
| 2348 | : respondAccess(removed.error, 422); | |
| 2349 | if (!isMember) return notFound(); | |
| 2350 | return new Response(null, { status: 204 }); | |
| 2351 | }, | |
| 2352 | ); | |
| 2190 | orgRoutes(router); | |
| 2353 | 2191 | |
| 2354 | 2192 | // ── search ─────────────────────────────────────────────────────────── |
| 2355 | 2193 | // |
src/http/api/orgs.ts added +189
| @@ -0,0 +1,189 @@ | ||
| 1 | /** | |
| 2 | * Organizations and their teams over the API: `POST /api/v1/orgs`, | |
| 3 | * `GET /api/v1/orgs/:org`, and the `…/teams` and `…/teams/:team/members` | |
| 4 | * routes. Moved out of `api.ts` (#6327); the rules live in `actions/orgs.ts`. | |
| 5 | */ | |
| 6 | ||
| 7 | import { | |
| 8 | addTeamMember, | |
| 9 | createOrg, | |
| 10 | createTeam, | |
| 11 | deleteTeam, | |
| 12 | removeTeamMember, | |
| 13 | } from "../../actions/orgs.ts"; | |
| 14 | import { | |
| 15 | getOrg, | |
| 16 | listTeamMembers, | |
| 17 | listTeams, | |
| 18 | listTeamsVisibleTo, | |
| 19 | type Permission, | |
| 20 | type Team, | |
| 21 | } from "../../core/orgs.ts"; | |
| 22 | import { notePhase, takeSessionTime, timePhase } from "../../core/route_timing.ts"; | |
| 23 | import type { User } from "../../core/users.ts"; | |
| 24 | import { keyedPage, keyedWindow } from "../api.ts"; | |
| 25 | import type { Ctx, Handler } from "../context.ts"; | |
| 26 | import { | |
| 27 | apiError, | |
| 28 | body, | |
| 29 | json, | |
| 30 | notFound, | |
| 31 | publicProfile, | |
| 32 | requireScope, | |
| 33 | respondAccess, | |
| 34 | str, | |
| 35 | } from "../respond.ts"; | |
| 36 | import type { Router } from "../router.ts"; | |
| 37 | ||
| 38 | /** An org by name plus the team a route names (by name, case-insensitive). */ | |
| 39 | function loadTeam(ctx: Ctx): { org: User; team: Team } | { error: Response } { | |
| 40 | const org = getOrg(ctx.app.db, ctx.params.org!); | |
| 41 | if (!org) return { error: notFound() }; | |
| 42 | const wanted = (ctx.params.team ?? "").toLowerCase(); | |
| 43 | const team = listTeams(ctx.app.db, org.id).find( | |
| 44 | (t) => t.name.toLowerCase() === wanted, | |
| 45 | ); | |
| 46 | return team ? { org, team } : { error: notFound() }; | |
| 47 | } | |
| 48 | ||
| 49 | /** Whether the viewer may see this team at all (admin, or a member of it). */ | |
| 50 | function teamVisible(ctx: Ctx, org: User, team: Team): boolean { | |
| 51 | return listTeamsVisibleTo(ctx.app.db, org.id, ctx.auth.user).some( | |
| 52 | (t) => t.id === team.id, | |
| 53 | ); | |
| 54 | } | |
| 55 | ||
| 56 | const permissionOf = (raw: unknown): Permission => { | |
| 57 | const p = str(raw); | |
| 58 | return p === "write" || p === "admin" ? p : "read"; | |
| 59 | }; | |
| 60 | ||
| 61 | export function orgRoutes(router: Router<Handler>): void { | |
| 62 | router.add("POST", "/api/v1/orgs", async (ctx) => { | |
| 63 | const denied = requireScope(ctx, "rw"); | |
| 64 | if (denied) return denied; | |
| 65 | const input = await body(ctx.request); | |
| 66 | const created = createOrg(ctx.app, ctx.auth.user, str(input.name)); | |
| 67 | if (!created.ok) return respondAccess(created.error, 422); | |
| 68 | return json( | |
| 69 | { id: created.value.id, username: created.value.username, kind: "org" }, | |
| 70 | 201, | |
| 71 | ); | |
| 72 | }); | |
| 73 | ||
| 74 | router.add("GET", "/api/v1/orgs/:org", (ctx) => { | |
| 75 | notePhase(ctx.request, "session", takeSessionTime(ctx.request)); | |
| 76 | const org = getOrg(ctx.app.db, ctx.params.org!); | |
| 77 | if (!org) return notFound(); | |
| 78 | const teams = timePhase(ctx.request, "org", () => | |
| 79 | listTeamsVisibleTo(ctx.app.db, org.id, ctx.auth.user), | |
| 80 | ); | |
| 81 | return timePhase(ctx.request, "render", () => | |
| 82 | json({ id: org.id, ...publicProfile(org), teams }), | |
| 83 | ); | |
| 84 | }); | |
| 85 | ||
| 86 | router.add("GET", "/api/v1/orgs/:org/teams", (ctx) => { | |
| 87 | notePhase(ctx.request, "session", takeSessionTime(ctx.request)); | |
| 88 | const org = getOrg(ctx.app.db, ctx.params.org!); | |
| 89 | if (!org) return notFound(); | |
| 90 | const window = keyedWindow(ctx); | |
| 91 | if (window instanceof Response) return window; | |
| 92 | const teams = timePhase(ctx.request, "teams", () => | |
| 93 | listTeamsVisibleTo(ctx.app.db, org.id, ctx.auth.user), | |
| 94 | ); | |
| 95 | return timePhase(ctx.request, "render", () => | |
| 96 | json(keyedPage(teams, (t) => t.name, window)), | |
| 97 | ); | |
| 98 | }); | |
| 99 | ||
| 100 | router.add("POST", "/api/v1/orgs/:org/teams", async (ctx) => { | |
| 101 | const denied = requireScope(ctx, "admin"); | |
| 102 | if (denied) return denied; | |
| 103 | const org = getOrg(ctx.app.db, ctx.params.org!); | |
| 104 | if (!org) return notFound(); | |
| 105 | const input = await body(ctx.request); | |
| 106 | const team = createTeam( | |
| 107 | ctx.app, | |
| 108 | ctx.auth.user, | |
| 109 | org, | |
| 110 | str(input.name), | |
| 111 | permissionOf(input.permission), | |
| 112 | ); | |
| 113 | if (!team.ok) return respondAccess(team.error, 422); | |
| 114 | return json(team.value, 201); | |
| 115 | }); | |
| 116 | ||
| 117 | router.add("DELETE", "/api/v1/orgs/:org/teams/:team", (ctx) => { | |
| 118 | const denied = requireScope(ctx, "admin"); | |
| 119 | if (denied) return denied; | |
| 120 | const found = loadTeam(ctx); | |
| 121 | if ("error" in found) return found.error; | |
| 122 | const removed = deleteTeam(ctx.app, ctx.auth.user, found.org, found.team); | |
| 123 | if (!removed.ok) return respondAccess(removed.error, 409); | |
| 124 | return new Response(null, { status: 204 }); | |
| 125 | }); | |
| 126 | ||
| 127 | router.add("GET", "/api/v1/orgs/:org/teams/:team/members", (ctx) => { | |
| 128 | notePhase(ctx.request, "session", takeSessionTime(ctx.request)); | |
| 129 | const found = loadTeam(ctx); | |
| 130 | if ("error" in found) return found.error; | |
| 131 | if (!teamVisible(ctx, found.org, found.team)) return notFound(); | |
| 132 | const window = keyedWindow(ctx); | |
| 133 | if (window instanceof Response) return window; | |
| 134 | const members = timePhase(ctx.request, "members", () => | |
| 135 | listTeamMembers(ctx.app.db, found.team.id).map(publicProfile), | |
| 136 | ); | |
| 137 | return timePhase(ctx.request, "render", () => | |
| 138 | json(keyedPage(members, (u) => u.username, window)), | |
| 139 | ); | |
| 140 | }); | |
| 141 | ||
| 142 | router.add("POST", "/api/v1/orgs/:org/teams/:team/members", async (ctx) => { | |
| 143 | const denied = requireScope(ctx, "admin"); | |
| 144 | if (denied) return denied; | |
| 145 | const found = loadTeam(ctx); | |
| 146 | if ("error" in found) return found.error; | |
| 147 | const input = await body(ctx.request); | |
| 148 | const added = addTeamMember( | |
| 149 | ctx.app, | |
| 150 | ctx.auth.user, | |
| 151 | found.org, | |
| 152 | found.team, | |
| 153 | str(input.user), | |
| 154 | ); | |
| 155 | if (!added.ok) return respondAccess(added.error, 422); | |
| 156 | return json(added.value, added.value.added ? 201 : 200); | |
| 157 | }); | |
| 158 | ||
| 159 | router.add( | |
| 160 | "DELETE", | |
| 161 | "/api/v1/orgs/:org/teams/:team/members/:username", | |
| 162 | (ctx) => { | |
| 163 | const denied = requireScope(ctx, "admin"); | |
| 164 | if (denied) return denied; | |
| 165 | const found = loadTeam(ctx); | |
| 166 | if ("error" in found) return found.error; | |
| 167 | const username = ctx.params.username!; | |
| 168 | const isMember = listTeamMembers(ctx.app.db, found.team.id).some( | |
| 169 | (m) => m.username.toLowerCase() === username.toLowerCase(), | |
| 170 | ); | |
| 171 | const removed = removeTeamMember( | |
| 172 | ctx.app, | |
| 173 | ctx.auth.user, | |
| 174 | found.org, | |
| 175 | found.team, | |
| 176 | username, | |
| 177 | ); | |
| 178 | if (!removed.ok) | |
| 179 | return removed.error === "last_owner" | |
| 180 | ? apiError( | |
| 181 | "last_owner", | |
| 182 | 409, | |
| 183 | "an organization keeps at least one person who owns it; make someone else an owner first", | |
| 184 | ) | |
| 185 | : respondAccess(removed.error, 422); | |
| 186 | if (!isMember) return notFound(); | |
| 187 | return new Response(null, { status: 204 }); | |
| 188 | }, | |
| 189 | );} | |
new above
offline
Nit: the closing
}oforgRoutesis stuck to);. Put it on its own line, like the othersrc/http/api/*.tsmodules.