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

The /api/v1/repos/:owner/:name/app routes move out of src/http/api.ts into src/http/api/app.ts, unchanged #7480

Merged opened this

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

Merged

mason/forge:mason/7446-the-api-v1-repos-owner-name-app-routes-m → main 1 commit fork

  • Merged. mason/7446-the-api-v1-repos-owner-name-app-routes-m was merged into main as a8caacc.

Review

Sign in to review this change.
2 files changed +312 −314
src/http/api.ts +5 −314
@@ -215,27 +215,7 @@ import { appLimitWords, appsOwnedBy } from "../actions/app_limit.ts";
215215 import { getQuota } from "../settings/quotas.ts";
216216 import { isSafeRef, isSafeTreePath } from "../git/guard.ts";
217217 import { compareBranches, listBranchRows } from "../git/branches.ts";
218 import {
219 appView,
220 appVisitors,
221 deleteAppEnvVar,
222 disableApp,
223 enableApp,
224 listAppEnv,
225 newApp,
226 openEnvUrl,
227 redeployEnv,
228 setAppEnvVar,
229 stopEnv,
230 updateAppSettings,
231 type AppSettingsInput,
232 } from "../actions/apps.ts";
233 import {
234 addAppDomain,
235 checkAppDomain,
236 listAppDomains,
237 removeAppDomain,
238 } from "../actions/domains.ts";
218 import { newApp } from "../actions/apps.ts";
239219 import { listDomains } from "../core/domains.ts";
240220 import {
241221 migrateMessage,
@@ -319,7 +299,6 @@ import { blobForRender, commitLog, listRefs, listTree } from "../git/read.ts";
319299 import { repoDir } from "../git/repos.ts";
320300 import { carriedPulls, pullPath } from "../story/wave_pulls.ts";
321301 import { pullRefs, readWave, readWaves } from "../story/waves.ts";
322 import type { Result } from "../kernel/result.ts";
323302 import { inviteLink, list as listInviteLinks, mint as mintInviteLink, revoke as revokeInviteLink } from "../actions/invites.ts";
324303 import { originForRepo, type OriginRepoRow } from "../core/origins.ts";
325304 import { subscribe as subscribeJob, type MirrorJob } from "../origins/jobs.ts";
@@ -360,11 +339,6 @@ import {
360339 unauthorized,
361340 tooManyAttempts,
362341 } from "./respond.ts";
363 import {
364 readAppMembers,
365 setAppMembersThrough,
366 type AppMembersError,
367 } from "../actions/app_members.ts";
368342 import {
369343 explorePage,
370344 type ExploreRow,
@@ -382,6 +356,7 @@ import { boardRoutes } from "./api/boards.ts";
382356 import { followingRoutes } from "./api/following.ts";
383357 import { labelsRoutes } from "./api/labels.ts";
384358 import { userRoutes } from "./api/user.ts";
359 import { appRoutes } from "./api/app.ts";
385360 import { appNoteRoutes } from "./api/app_notes.ts";
386361 import { reportRoutes } from "./api/reports.ts";
387362 import { rollbackRoutes } from "./api/rollback.ts";
@@ -1000,7 +975,7 @@ function repoQuery(ctx: Ctx) {
1000975 * An app as the API returns it, with what is running when a runtime is up
1001976 * and the domains of its own that are verified — the ones the forge serves.
1002977 */
1003 function appJson(
978 export function appJson(
1004979 ctx: Ctx,
1005980 record: AppRecord,
1006981 repo: Repo,
@@ -1020,18 +995,6 @@ function appJson(
1020995 };
1021996 }
1022997
1023 function appPatch(input: Record<string, unknown>): AppSettingsInput {
1024 return {
1025 ...(typeof input.slug === "string" ? { slug: input.slug } : {}),
1026 ...(input.access === "public" || input.access === "private"
1027 ? { access: input.access }
1028 : {}),
1029 ...(input.previewData === "copy" || input.previewData === "empty"
1030 ? { previewData: input.previewData }
1031 : {}),
1032 };
1033 }
1034
1035998 export function agentJson(agent: Agent) {
1036999 return {
10371000 username: agent.user.username,
@@ -1061,7 +1024,7 @@ function positiveInt(raw: string | undefined): number | undefined {
10611024 }
10621025
10631026 /** 403 naming the limit and the count, for an account at its app limit. */
1064 function appLimitResponse(ctx: Ctx, ownerName: string): Response {
1027 export function appLimitResponse(ctx: Ctx, ownerName: string): Response {
10651028 const owner = getUserByName(ctx.app.db, ownerName);
10661029 const limit = getQuota(ctx.app.db, "apps");
10671030 return json(
@@ -1129,21 +1092,6 @@ export function loadPull(ctx: Ctx): Found<{ pull: Pull }> {
11291092
11301093 // ---- routes ----
11311094
1132 /** An app-members answer: the list, or the refusal a stranger cannot read anything into. */
1133 function membersAnswer<T>(result: Result<T, AppMembersError>): Response {
1134 if (result.ok) return json(result.value);
1135 switch (result.error.kind) {
1136 case "invalid":
1137 return apiError("invalid", 422, result.error.message);
1138 case "forbidden":
1139 return apiError("forbidden", 403, SETTING_WORDS.appMembersForbidden);
1140 case "unauthorized":
1141 return unauthorized();
1142 default:
1143 return notFound();
1144 }
1145 }
1146
11471095 /** Boards (`boards/query.ts`): a site admin who is a person, never an agent. */
11481096 export function boardsAdmin(ctx: Ctx): Response | undefined {
11491097 if (!ctx.auth.user) return unauthorized();
@@ -4242,160 +4190,7 @@ export function apiRoutes(router: Router<Handler>): void {
42424190 );
42434191 });
42444192
4245 router.add("GET", "/api/v1/repos/:owner/:name/app", (ctx) => {
4246 notePhase(ctx.request, "session", takeSessionTime(ctx.request));
4247 const repo = loadRepo(ctx);
4248 if (!repo) return notFound();
4249 const read = timePhase(ctx.request, "app", () => {
4250 const view = appView(ctx.app, ctx.auth.user, repo);
4251 if (!view.ok) return null;
4252 return { view: view.value, vitals: appVisitors(ctx.app, ctx.auth.user, repo) };
4253 });
4254 if (!read) return notFound();
4255 return timePhase(ctx.request, "render", () =>
4256 json({
4257 ...appJson(ctx, read.view.app, repo, appsRuntime(ctx.app)),
4258 ...(read.vitals === undefined ? {} : { vitals: read.vitals }),
4259 }),
4260 );
4261 });
4262
4263 router.add("POST", "/api/v1/repos/:owner/:name/app", async (ctx) => {
4264 const denied = requireScope(ctx, "admin");
4265 if (denied) return denied;
4266 const repo = loadRepo(ctx);
4267 if (!repo) return notFound();
4268 const input = await body(ctx.request);
4269 const enabled = enableApp(ctx.app, ctx.auth.user, repo, appPatch(input));
4270 if (!enabled.ok) {
4271 if (enabled.error === "app_limit")
4272 return appLimitResponse(ctx, repo.ownerName);
4273 return respondAccess(enabled.error, 422);
4274 }
4275 return json(
4276 appJson(ctx, enabled.value, repo, appsRuntime(ctx.app)),
4277 201,
4278 );
4279 });
4280
4281 router.add("PATCH", "/api/v1/repos/:owner/:name/app", async (ctx) => {
4282 const denied = requireScope(ctx, "admin");
4283 if (denied) return denied;
4284 const repo = loadRepo(ctx);
4285 if (!repo) return notFound();
4286 const input = await body(ctx.request);
4287 const updated = updateAppSettings(
4288 ctx.app,
4289 ctx.auth.user,
4290 repo,
4291 appPatch(input),
4292 );
4293 if (!updated.ok) return respondAccess(updated.error, 422);
4294 return json(appJson(ctx, updated.value, repo, appsRuntime(ctx.app)));
4295 });
4296
4297 router.add("DELETE", "/api/v1/repos/:owner/:name/app", (ctx) => {
4298 const denied = requireScope(ctx, "admin");
4299 if (denied) return denied;
4300 const repo = loadRepo(ctx);
4301 if (!repo) return notFound();
4302 const disabled = disableApp(ctx.app, ctx.auth.user, repo);
4303 if (!disabled.ok) return respondAccess(disabled.error, 422);
4304 return json({}, 200);
4305 });
4306
4307 // The people a private app names (#752): they may enter it and its
4308 // previews, nothing else. Read and changed by people who can write the
4309 // repository, never an agent; a change is planned unless dryRun is false.
4310 router.add("GET", "/api/v1/repos/:owner/:name/app/members", (ctx) => {
4311 notePhase(ctx.request, "session", takeSessionTime(ctx.request));
4312 const denied = requireScope(ctx, "ro");
4313 if (denied) return denied;
4314 const repo = loadRepo(ctx);
4315 if (!repo) return notFound();
4316 const read = timePhase(ctx.request, "members", () => readAppMembers(ctx.app, ctx.auth.user, repo));
4317 return timePhase(ctx.request, "render", () => membersAnswer(read));
4318 });
4319
4320 router.add("PUT", "/api/v1/repos/:owner/:name/app/members", async (ctx) => {
4321 const input = await body(ctx.request);
4322 const denied = requireScope(ctx, "admin");
4323 if (denied) return denied;
4324 const repo = loadRepo(ctx);
4325 if (!repo) return notFound();
4326 const members = input.members;
4327 if (!Array.isArray(members) || !members.every((m) => typeof m === "string"))
4328 return apiError("invalid", 422, "send {members: [username, …], dryRun}");
4329 return membersAnswer(
4330 setAppMembersThrough(ctx.app, ctx.auth.user, repo, {
4331 members,
4332 dryRun: input.dryRun !== false,
4333 }),
4334 );
4335 });
4336
4337 // The app's own domains (docs/APPS.md, "Your own domain"). Admin scope
4338 // and admin over the repository, like the environment below; `:host` is
4339 // one path segment, dots included.
4340 router.add("GET", "/api/v1/repos/:owner/:name/app/domains", (ctx) => {
4341 notePhase(ctx.request, "session", takeSessionTime(ctx.request));
4342 const denied = requireScope(ctx, "admin");
4343 if (denied) return denied;
4344 const repo = loadRepo(ctx);
4345 if (!repo) return notFound();
4346 const listed = timePhase(ctx.request, "domains", () => listAppDomains(ctx.app, ctx.auth.user, repo));
4347 if (!listed.ok) return respondAccess(listed.error, 422);
4348 return timePhase(ctx.request, "render", () => json({ items: listed.value, next_cursor: null }));
4349 });
4350
4351 router.add("POST", "/api/v1/repos/:owner/:name/app/domains", async (ctx) => {
4352 const denied = requireScope(ctx, "admin");
4353 if (denied) return denied;
4354 const repo = loadRepo(ctx);
4355 if (!repo) return notFound();
4356 const input = await body(ctx.request);
4357 const added = addAppDomain(ctx.app, ctx.auth.user, repo, str(input.host));
4358 if (!added.ok) return respondAccess(added.error, 422);
4359 return json(added.value, 201);
4360 });
4361
4362 router.add(
4363 "POST",
4364 "/api/v1/repos/:owner/:name/app/domains/:host/check",
4365 async (ctx) => {
4366 const denied = requireScope(ctx, "admin");
4367 if (denied) return denied;
4368 const repo = loadRepo(ctx);
4369 if (!repo) return notFound();
4370 const checked = await checkAppDomain(
4371 ctx.app,
4372 ctx.auth.user,
4373 repo,
4374 ctx.params.host ?? "",
4375 );
4376 if (!checked.ok) return respondAccess(checked.error, 422);
4377 return json(checked.value);
4378 },
4379 );
4380
4381 router.add(
4382 "DELETE",
4383 "/api/v1/repos/:owner/:name/app/domains/:host",
4384 (ctx) => {
4385 const denied = requireScope(ctx, "admin");
4386 if (denied) return denied;
4387 const repo = loadRepo(ctx);
4388 if (!repo) return notFound();
4389 const removed = removeAppDomain(
4390 ctx.app,
4391 ctx.auth.user,
4392 repo,
4393 ctx.params.host ?? "",
4394 );
4395 if (!removed.ok) return respondAccess(removed.error, 422);
4396 return json({}, 200);
4397 },
4398 );
4193 appRoutes(router);
43994194
44004195 egressGrantRoutes(router);
44014196
@@ -4403,110 +4198,6 @@ export function apiRoutes(router: Router<Handler>): void {
44034198
44044199 secretRoutes(router);
44054200
4406 // The app's environment: names in, names out, and never a value back.
4407 // `:var`, not `:name` — `:name` is the repository's.
4408 router.add("GET", "/api/v1/repos/:owner/:name/app/env", (ctx) => {
4409 notePhase(ctx.request, "session", takeSessionTime(ctx.request));
4410 const denied = requireScope(ctx, "admin");
4411 if (denied) return denied;
4412 const repo = loadRepo(ctx);
4413 if (!repo) return notFound();
4414 const listed = timePhase(ctx.request, "env", () => listAppEnv(ctx.app, ctx.auth.user, repo));
4415 if (!listed.ok) return notFound();
4416 return timePhase(ctx.request, "render", () => json({ items: listed.value, next_cursor: null }));
4417 });
4418
4419 router.add("PUT", "/api/v1/repos/:owner/:name/app/env/:var", async (ctx) => {
4420 const denied = requireScope(ctx, "admin");
4421 if (denied) return denied;
4422 const repo = loadRepo(ctx);
4423 if (!repo) return notFound();
4424 const input = await body(ctx.request);
4425 // `scope`: all (the default), production, or preview. The same name may
4426 // be set once in each; the narrower one wins where both apply.
4427 const set = await setAppEnvVar(
4428 ctx.app,
4429 ctx.auth.user,
4430 repo,
4431 ctx.params.var ?? "",
4432 str(input.value),
4433 str(input.scope) || "all",
4434 );
4435 if (!set.ok) return respondAccess(set.error, 422);
4436 return json(set.value);
4437 });
4438
4439 router.add(
4440 "DELETE",
4441 "/api/v1/repos/:owner/:name/app/env/:var",
4442 async (ctx) => {
4443 const denied = requireScope(ctx, "admin");
4444 if (denied) return denied;
4445 const repo = loadRepo(ctx);
4446 if (!repo) return notFound();
4447 const removed = await deleteAppEnvVar(
4448 ctx.app,
4449 ctx.auth.user,
4450 repo,
4451 ctx.params.var ?? "",
4452 ctx.url.searchParams.get("scope") ?? "all",
4453 );
4454 if (!removed.ok) return respondAccess(removed.error, 422);
4455 return json({}, 200);
4456 },
4457 );
4458
4459 router.add(
4460 "POST",
4461 "/api/v1/repos/:owner/:name/app/:env/redeploy",
4462 async (ctx) => {
4463 const denied = requireScope(ctx, "rw");
4464 if (denied) return denied;
4465 const repo = loadRepo(ctx);
4466 if (!repo) return notFound();
4467 const queued = await redeployEnv(
4468 ctx.app,
4469 ctx.auth.user,
4470 repo,
4471 ctx.params.env ?? "",
4472 );
4473 if (!queued.ok) return respondAccess(queued.error, 422);
4474 return json({ run: queued.value ?? null }, 202);
4475 },
4476 );
4477
4478 router.add(
4479 "POST",
4480 "/api/v1/repos/:owner/:name/app/:env/stop",
4481 async (ctx) => {
4482 const denied = requireScope(ctx, "rw");
4483 if (denied) return denied;
4484 const repo = loadRepo(ctx);
4485 if (!repo) return notFound();
4486 const stopped = await stopEnv(
4487 ctx.app,
4488 ctx.auth.user,
4489 repo,
4490 ctx.params.env ?? "",
4491 );
4492 if (!stopped.ok) return respondAccess(stopped.error, 422);
4493 return json({}, 200);
4494 },
4495 );
4496
4497 // The URL to open an environment at. For a private app it carries a
4498 // grant, so the answer is for the caller alone and is not cached.
4499 router.add("GET", "/api/v1/repos/:owner/:name/app/:env/open", (ctx) => {
4500 notePhase(ctx.request, "session", takeSessionTime(ctx.request));
4501 const repo = timePhase(ctx.request, "repo", () => loadRepo(ctx));
4502 if (!repo) return notFound();
4503 const link = timePhase(ctx.request, "open", () =>
4504 openEnvUrl(ctx.app, ctx.auth.user, repo, ctx.params.env ?? ""),
4505 );
4506 if (!link.ok) return respondAccess(link.error, 422);
4507 return json({ url: link.value });
4508 });
4509
45104201 // What the crew's runs here spent over a window (`core/usage.ts`):
45114202 // tokens, cost, wall time and outcome, by subscription, agent and issue.
45124203 // Spend is the owner's own fact, so a reader who cannot write gets 403.
src/http/api/app.ts added +307
@@ -0,0 +1,307 @@
1 /**
2 * A repository's app over the API: `…/app` itself (read, enable, change,
3 * disable), the people a private app names, its own domains, its
4 * environment variables, and redeploying, stopping and opening one
5 * environment. The rules live in `actions/apps.ts`, `actions/app_members.ts`
6 * and `actions/domains.ts`.
7 */
8
9 import { readAppMembers, setAppMembersThrough, type AppMembersError } from "../../actions/app_members.ts";
10 import { appView, appVisitors, deleteAppEnvVar, disableApp, enableApp, listAppEnv, openEnvUrl, redeployEnv, setAppEnvVar, stopEnv, updateAppSettings, type AppSettingsInput } from "../../actions/apps.ts";
11 import { addAppDomain, checkAppDomain, listAppDomains, removeAppDomain } from "../../actions/domains.ts";
12 import { SETTING_WORDS } from "../../actions/setting_words.ts";
13 import { appsRuntime } from "../../apps/runtime.ts";
14 import { notePhase, takeSessionTime, timePhase } from "../../core/route_timing.ts";
15 import type { Result } from "../../kernel/result.ts";
16 import { appJson, appLimitResponse, loadRepo } from "../api.ts";
17 import type { Handler } from "../context.ts";
18 import { apiError, body, json, notFound, requireScope, respondAccess, str, unauthorized } from "../respond.ts";
19 import type { Router } from "../router.ts";
20
21 function appPatch(input: Record<string, unknown>): AppSettingsInput {
22 return {
23 ...(typeof input.slug === "string" ? { slug: input.slug } : {}),
24 ...(input.access === "public" || input.access === "private"
25 ? { access: input.access }
26 : {}),
27 ...(input.previewData === "copy" || input.previewData === "empty"
28 ? { previewData: input.previewData }
29 : {}),
30 };
31 }
32
33 /** An app-members answer: the list, or the refusal a stranger cannot read anything into. */
34 function membersAnswer<T>(result: Result<T, AppMembersError>): Response {
35 if (result.ok) return json(result.value);
36 switch (result.error.kind) {
37 case "invalid":
38 return apiError("invalid", 422, result.error.message);
39 case "forbidden":
40 return apiError("forbidden", 403, SETTING_WORDS.appMembersForbidden);
41 case "unauthorized":
42 return unauthorized();
43 default:
44 return notFound();
45 }
46 }
47
48 export function appRoutes(router: Router<Handler>): void {
49 router.add("GET", "/api/v1/repos/:owner/:name/app", (ctx) => {
50 notePhase(ctx.request, "session", takeSessionTime(ctx.request));
51 const repo = loadRepo(ctx);
52 if (!repo) return notFound();
53 const read = timePhase(ctx.request, "app", () => {
54 const view = appView(ctx.app, ctx.auth.user, repo);
55 if (!view.ok) return null;
56 return { view: view.value, vitals: appVisitors(ctx.app, ctx.auth.user, repo) };
57 });
58 if (!read) return notFound();
59 return timePhase(ctx.request, "render", () =>
60 json({
61 ...appJson(ctx, read.view.app, repo, appsRuntime(ctx.app)),
62 ...(read.vitals === undefined ? {} : { vitals: read.vitals }),
63 }),
64 );
65 });
66
67 router.add("POST", "/api/v1/repos/:owner/:name/app", async (ctx) => {
68 const denied = requireScope(ctx, "admin");
69 if (denied) return denied;
70 const repo = loadRepo(ctx);
71 if (!repo) return notFound();
72 const input = await body(ctx.request);
73 const enabled = enableApp(ctx.app, ctx.auth.user, repo, appPatch(input));
74 if (!enabled.ok) {
75 if (enabled.error === "app_limit")
76 return appLimitResponse(ctx, repo.ownerName);
77 return respondAccess(enabled.error, 422);
78 }
79 return json(
80 appJson(ctx, enabled.value, repo, appsRuntime(ctx.app)),
81 201,
82 );
83 });
84
85 router.add("PATCH", "/api/v1/repos/:owner/:name/app", async (ctx) => {
86 const denied = requireScope(ctx, "admin");
87 if (denied) return denied;
88 const repo = loadRepo(ctx);
89 if (!repo) return notFound();
90 const input = await body(ctx.request);
91 const updated = updateAppSettings(
92 ctx.app,
93 ctx.auth.user,
94 repo,
95 appPatch(input),
96 );
97 if (!updated.ok) return respondAccess(updated.error, 422);
98 return json(appJson(ctx, updated.value, repo, appsRuntime(ctx.app)));
99 });
100
101 router.add("DELETE", "/api/v1/repos/:owner/:name/app", (ctx) => {
102 const denied = requireScope(ctx, "admin");
103 if (denied) return denied;
104 const repo = loadRepo(ctx);
105 if (!repo) return notFound();
106 const disabled = disableApp(ctx.app, ctx.auth.user, repo);
107 if (!disabled.ok) return respondAccess(disabled.error, 422);
108 return json({}, 200);
109 });
110
111 // The people a private app names (#752): they may enter it and its
112 // previews, nothing else. Read and changed by people who can write the
113 // repository, never an agent; a change is planned unless dryRun is false.
114 router.add("GET", "/api/v1/repos/:owner/:name/app/members", (ctx) => {
115 notePhase(ctx.request, "session", takeSessionTime(ctx.request));
116 const denied = requireScope(ctx, "ro");
117 if (denied) return denied;
118 const repo = loadRepo(ctx);
119 if (!repo) return notFound();
120 const read = timePhase(ctx.request, "members", () => readAppMembers(ctx.app, ctx.auth.user, repo));
121 return timePhase(ctx.request, "render", () => membersAnswer(read));
122 });
123
124 router.add("PUT", "/api/v1/repos/:owner/:name/app/members", async (ctx) => {
125 const input = await body(ctx.request);
126 const denied = requireScope(ctx, "admin");
127 if (denied) return denied;
128 const repo = loadRepo(ctx);
129 if (!repo) return notFound();
130 const members = input.members;
131 if (!Array.isArray(members) || !members.every((m) => typeof m === "string"))
132 return apiError("invalid", 422, "send {members: [username, …], dryRun}");
133 return membersAnswer(
134 setAppMembersThrough(ctx.app, ctx.auth.user, repo, {
135 members,
136 dryRun: input.dryRun !== false,
137 }),
138 );
139 });
140
141 // The app's own domains (docs/APPS.md, "Your own domain"). Admin scope
142 // and admin over the repository, like the environment below; `:host` is
143 // one path segment, dots included.
144 router.add("GET", "/api/v1/repos/:owner/:name/app/domains", (ctx) => {
145 notePhase(ctx.request, "session", takeSessionTime(ctx.request));
146 const denied = requireScope(ctx, "admin");
147 if (denied) return denied;
148 const repo = loadRepo(ctx);
149 if (!repo) return notFound();
150 const listed = timePhase(ctx.request, "domains", () => listAppDomains(ctx.app, ctx.auth.user, repo));
151 if (!listed.ok) return respondAccess(listed.error, 422);
152 return timePhase(ctx.request, "render", () => json({ items: listed.value, next_cursor: null }));
153 });
154
155 router.add("POST", "/api/v1/repos/:owner/:name/app/domains", async (ctx) => {
156 const denied = requireScope(ctx, "admin");
157 if (denied) return denied;
158 const repo = loadRepo(ctx);
159 if (!repo) return notFound();
160 const input = await body(ctx.request);
161 const added = addAppDomain(ctx.app, ctx.auth.user, repo, str(input.host));
162 if (!added.ok) return respondAccess(added.error, 422);
163 return json(added.value, 201);
164 });
165
166 router.add(
167 "POST",
168 "/api/v1/repos/:owner/:name/app/domains/:host/check",
169 async (ctx) => {
170 const denied = requireScope(ctx, "admin");
171 if (denied) return denied;
172 const repo = loadRepo(ctx);
173 if (!repo) return notFound();
174 const checked = await checkAppDomain(
175 ctx.app,
176 ctx.auth.user,
177 repo,
178 ctx.params.host ?? "",
179 );
180 if (!checked.ok) return respondAccess(checked.error, 422);
181 return json(checked.value);
182 },
183 );
184
185 router.add(
186 "DELETE",
187 "/api/v1/repos/:owner/:name/app/domains/:host",
188 (ctx) => {
189 const denied = requireScope(ctx, "admin");
190 if (denied) return denied;
191 const repo = loadRepo(ctx);
192 if (!repo) return notFound();
193 const removed = removeAppDomain(
194 ctx.app,
195 ctx.auth.user,
196 repo,
197 ctx.params.host ?? "",
198 );
199 if (!removed.ok) return respondAccess(removed.error, 422);
200 return json({}, 200);
201 },
202 );
203
204 // The app's environment: names in, names out, and never a value back.
205 // `:var`, not `:name` — `:name` is the repository's.
206 router.add("GET", "/api/v1/repos/:owner/:name/app/env", (ctx) => {
207 notePhase(ctx.request, "session", takeSessionTime(ctx.request));
208 const denied = requireScope(ctx, "admin");
209 if (denied) return denied;
210 const repo = loadRepo(ctx);
211 if (!repo) return notFound();
212 const listed = timePhase(ctx.request, "env", () => listAppEnv(ctx.app, ctx.auth.user, repo));
213 if (!listed.ok) return notFound();
214 return timePhase(ctx.request, "render", () => json({ items: listed.value, next_cursor: null }));
215 });
216
217 router.add("PUT", "/api/v1/repos/:owner/:name/app/env/:var", async (ctx) => {
218 const denied = requireScope(ctx, "admin");
219 if (denied) return denied;
220 const repo = loadRepo(ctx);
221 if (!repo) return notFound();
222 const input = await body(ctx.request);
223 // `scope`: all (the default), production, or preview. The same name may
224 // be set once in each; the narrower one wins where both apply.
225 const set = await setAppEnvVar(
226 ctx.app,
227 ctx.auth.user,
228 repo,
229 ctx.params.var ?? "",
230 str(input.value),
231 str(input.scope) || "all",
232 );
233 if (!set.ok) return respondAccess(set.error, 422);
234 return json(set.value);
235 });
236
237 router.add(
238 "DELETE",
239 "/api/v1/repos/:owner/:name/app/env/:var",
240 async (ctx) => {
241 const denied = requireScope(ctx, "admin");
242 if (denied) return denied;
243 const repo = loadRepo(ctx);
244 if (!repo) return notFound();
245 const removed = await deleteAppEnvVar(
246 ctx.app,
247 ctx.auth.user,
248 repo,
249 ctx.params.var ?? "",
250 ctx.url.searchParams.get("scope") ?? "all",
251 );
252 if (!removed.ok) return respondAccess(removed.error, 422);
253 return json({}, 200);
254 },
255 );
256
257 router.add(
258 "POST",
259 "/api/v1/repos/:owner/:name/app/:env/redeploy",
260 async (ctx) => {
261 const denied = requireScope(ctx, "rw");
262 if (denied) return denied;
263 const repo = loadRepo(ctx);
264 if (!repo) return notFound();
265 const queued = await redeployEnv(
266 ctx.app,
267 ctx.auth.user,
268 repo,
269 ctx.params.env ?? "",
270 );
271 if (!queued.ok) return respondAccess(queued.error, 422);
272 return json({ run: queued.value ?? null }, 202);
273 },
274 );
275
276 router.add(
277 "POST",
278 "/api/v1/repos/:owner/:name/app/:env/stop",
279 async (ctx) => {
280 const denied = requireScope(ctx, "rw");
281 if (denied) return denied;
282 const repo = loadRepo(ctx);
283 if (!repo) return notFound();
284 const stopped = await stopEnv(
285 ctx.app,
286 ctx.auth.user,
287 repo,
288 ctx.params.env ?? "",
289 );
290 if (!stopped.ok) return respondAccess(stopped.error, 422);
291 return json({}, 200);
292 },
293 );
294
295 // The URL to open an environment at. For a private app it carries a
296 // grant, so the answer is for the caller alone and is not cached.
297 router.add("GET", "/api/v1/repos/:owner/:name/app/:env/open", (ctx) => {
298 notePhase(ctx.request, "session", takeSessionTime(ctx.request));
299 const repo = timePhase(ctx.request, "repo", () => loadRepo(ctx));
300 if (!repo) return notFound();
301 const link = timePhase(ctx.request, "open", () =>
302 openEnvUrl(ctx.app, ctx.auth.user, repo, ctx.params.env ?? ""),
303 );
304 if (!link.ok) return respondAccess(link.error, 422);
305 return json({ url: link.value });
306 });
307 }

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs