Public: anyone can read this; the crew can change it.

Organization routes leave api.ts for src/http/api/orgs.ts #6944

Merged opened this

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

Merged

mason/forge:mason/6327-organization-routes-leave-api-ts-for-src → main 1 commit fork 1 thread · 1 unresolved

  • Merged. mason/6327-organization-routes-leave-api-ts-for-src was merged into main as e96ad56.

Review

Sign in to review this change.
2 files changed +193 −166
src/http/api.ts +4 −166
@@ -108,13 +108,6 @@ import {
108108 setIssueSealed,
109109 setIssueStateThrough,
110110 } from "../actions/issues.ts";
111 import {
112 addTeamMember,
113 createOrg,
114 createTeam,
115 deleteTeam,
116 removeTeamMember,
117 } from "../actions/orgs.ts";
118111 import {
119112 connectOrigin,
120113 deleteOriginApp,
@@ -214,13 +207,7 @@ import {
214207 } from "../core/issues.ts";
215208 import { listKeys, publicKey } from "../core/keys.ts";
216209 import {
217 getOrg,
218210 listOrgsForUser,
219 listTeamMembers,
220 listTeams,
221 listTeamsVisibleTo,
222 type Permission,
223 type Team,
224211 soleOwnedOrgs,
225212 lastOwnerSays,
226213 } from "../core/orgs.ts";
@@ -452,6 +439,7 @@ import { COPIES_PER_DAY, makeItYours } from "../actions/make_it_yours.ts";
452439 import { passportRoutes } from "./passport_edge.ts";
453440 import { egressGrantRoutes } from "./api/egress_grants.ts";
454441 import { labelsRoutes } from "./api/labels.ts";
442 import { orgRoutes } from "./api/orgs.ts";
455443 import { secretRoutes } from "./api/secrets.ts";
456444 import type { Router } from "./router.ts";
457445 import { clientAddress, clientBucket } from "./peer.ts";
@@ -1050,7 +1038,7 @@ function offsetRefused(error: "invalid_limit" | "invalid_cursor"): Response {
10501038 * `?limit=&cursor=` for a list paged by a name: limit 1..200 (default 50),
10511039 * the cursor the last name of the page before. A bad one is a 422.
10521040 */
1053 function keyedWindow(ctx: Ctx): { limit: number; cursor: string | null } | Response {
1041 export function keyedWindow(ctx: Ctx): { limit: number; cursor: string | null } | Response {
10541042 const q = ctx.url.searchParams;
10551043 const rawLimit = q.get("limit");
10561044 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
10631051 }
10641052
10651053 /** The page of `rows` (sorted by name) after the cursor: `{ items, next_cursor }`. */
1066 function keyedPage<T>(
1054 export function keyedPage<T>(
10671055 rows: T[],
10681056 keyOf: (row: T) => string,
10691057 window: { limit: number; cursor: string | null },
@@ -1233,29 +1221,6 @@ function loadPull(ctx: Ctx): Found<{ pull: Pull }> {
12331221 return { repo, pull };
12341222 }
12351223
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
12591224 // ---- routes ----
12601225
12611226 /** 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 {
22222187
22232188 // ── organizations & teams ────────────────────────────────────────────
22242189
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);
23532191
23542192 // ── search ───────────────────────────────────────────────────────────
23552193 //
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 );}

Nit: the closing } of orgRoutes is stuck to );. Put it on its own line, like the other src/http/api/*.ts modules.

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs