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
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/7446-the-api-v1-repos-owner-name-app-routes-m → main 1 commit fork
2 files
changed +312
−314
src/http/api.ts +5 −314
| @@ -215,27 +215,7 @@ import { appLimitWords, appsOwnedBy } from "../actions/app_limit.ts"; | ||
| 215 | 215 | import { getQuota } from "../settings/quotas.ts"; |
| 216 | 216 | import { isSafeRef, isSafeTreePath } from "../git/guard.ts"; |
| 217 | 217 | 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"; | |
| 239 | 219 | import { listDomains } from "../core/domains.ts"; |
| 240 | 220 | import { |
| 241 | 221 | migrateMessage, |
| @@ -319,7 +299,6 @@ import { blobForRender, commitLog, listRefs, listTree } from "../git/read.ts"; | ||
| 319 | 299 | import { repoDir } from "../git/repos.ts"; |
| 320 | 300 | import { carriedPulls, pullPath } from "../story/wave_pulls.ts"; |
| 321 | 301 | import { pullRefs, readWave, readWaves } from "../story/waves.ts"; |
| 322 | import type { Result } from "../kernel/result.ts"; | |
| 323 | 302 | import { inviteLink, list as listInviteLinks, mint as mintInviteLink, revoke as revokeInviteLink } from "../actions/invites.ts"; |
| 324 | 303 | import { originForRepo, type OriginRepoRow } from "../core/origins.ts"; |
| 325 | 304 | import { subscribe as subscribeJob, type MirrorJob } from "../origins/jobs.ts"; |
| @@ -360,11 +339,6 @@ import { | ||
| 360 | 339 | unauthorized, |
| 361 | 340 | tooManyAttempts, |
| 362 | 341 | } from "./respond.ts"; |
| 363 | import { | |
| 364 | readAppMembers, | |
| 365 | setAppMembersThrough, | |
| 366 | type AppMembersError, | |
| 367 | } from "../actions/app_members.ts"; | |
| 368 | 342 | import { |
| 369 | 343 | explorePage, |
| 370 | 344 | type ExploreRow, |
| @@ -382,6 +356,7 @@ import { boardRoutes } from "./api/boards.ts"; | ||
| 382 | 356 | import { followingRoutes } from "./api/following.ts"; |
| 383 | 357 | import { labelsRoutes } from "./api/labels.ts"; |
| 384 | 358 | import { userRoutes } from "./api/user.ts"; |
| 359 | import { appRoutes } from "./api/app.ts"; | |
| 385 | 360 | import { appNoteRoutes } from "./api/app_notes.ts"; |
| 386 | 361 | import { reportRoutes } from "./api/reports.ts"; |
| 387 | 362 | import { rollbackRoutes } from "./api/rollback.ts"; |
| @@ -1000,7 +975,7 @@ function repoQuery(ctx: Ctx) { | ||
| 1000 | 975 | * An app as the API returns it, with what is running when a runtime is up |
| 1001 | 976 | * and the domains of its own that are verified — the ones the forge serves. |
| 1002 | 977 | */ |
| 1003 | function appJson( | |
| 978 | export function appJson( | |
| 1004 | 979 | ctx: Ctx, |
| 1005 | 980 | record: AppRecord, |
| 1006 | 981 | repo: Repo, |
| @@ -1020,18 +995,6 @@ function appJson( | ||
| 1020 | 995 | }; |
| 1021 | 996 | } |
| 1022 | 997 | |
| 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 | ||
| 1035 | 998 | export function agentJson(agent: Agent) { |
| 1036 | 999 | return { |
| 1037 | 1000 | username: agent.user.username, |
| @@ -1061,7 +1024,7 @@ function positiveInt(raw: string | undefined): number | undefined { | ||
| 1061 | 1024 | } |
| 1062 | 1025 | |
| 1063 | 1026 | /** 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 { | |
| 1065 | 1028 | const owner = getUserByName(ctx.app.db, ownerName); |
| 1066 | 1029 | const limit = getQuota(ctx.app.db, "apps"); |
| 1067 | 1030 | return json( |
| @@ -1129,21 +1092,6 @@ export function loadPull(ctx: Ctx): Found<{ pull: Pull }> { | ||
| 1129 | 1092 | |
| 1130 | 1093 | // ---- routes ---- |
| 1131 | 1094 | |
| 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 | ||
| 1147 | 1095 | /** Boards (`boards/query.ts`): a site admin who is a person, never an agent. */ |
| 1148 | 1096 | export function boardsAdmin(ctx: Ctx): Response | undefined { |
| 1149 | 1097 | if (!ctx.auth.user) return unauthorized(); |
| @@ -4242,160 +4190,7 @@ export function apiRoutes(router: Router<Handler>): void { | ||
| 4242 | 4190 | ); |
| 4243 | 4191 | }); |
| 4244 | 4192 | |
| 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); | |
| 4399 | 4194 | |
| 4400 | 4195 | egressGrantRoutes(router); |
| 4401 | 4196 | |
| @@ -4403,110 +4198,6 @@ export function apiRoutes(router: Router<Handler>): void { | ||
| 4403 | 4198 | |
| 4404 | 4199 | secretRoutes(router); |
| 4405 | 4200 | |
| 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 | ||
| 4510 | 4201 | // What the crew's runs here spent over a window (`core/usage.ts`): |
| 4511 | 4202 | // tokens, cost, wall time and outcome, by subscription, agent and issue. |
| 4512 | 4203 | // 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 | } |
new above
offline