Public: anyone can read this; the crew can change it.
The OpenAPI document describes an app's routes: members, domains, env, redeploy and stop #4854
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/4844-the-openapi-document-describes-an-app-s → main 2 commits fork 1 thread · 1 unresolved
2 files
changed +85
−1
src/contract/openapi.ts +62 −1
| @@ -8,7 +8,7 @@ | ||
| 8 | 8 | */ |
| 9 | 9 | |
| 10 | 10 | interface Route { |
| 11 | readonly method: "get" | "post" | "patch" | "delete"; | |
| 11 | readonly method: "get" | "post" | "put" | "patch" | "delete"; | |
| 12 | 12 | readonly path: string; |
| 13 | 13 | readonly summary: string; |
| 14 | 14 | /** Whether it needs a token; reads of public things do not. */ |
| @@ -122,6 +122,67 @@ export const ROUTES: readonly Route[] = [ | ||
| 122 | 122 | }, |
| 123 | 123 | }, |
| 124 | 124 | { method: "get", path: `${REPO}/app/production/open`, summary: "Open the running app", auth: false }, |
| 125 | { method: "get", path: `${REPO}/app`, summary: "Read the repository's app: its settings, environments and state", auth: false }, | |
| 126 | { | |
| 127 | method: "post", | |
| 128 | path: `${REPO}/app`, | |
| 129 | summary: "Turn the repository into an app (admin scope); `access` is public or private, `previewData` is copy or empty; answers 201 with the app", | |
| 130 | auth: true, | |
| 131 | body: { slug: "string", access: "string", previewData: "string" }, | |
| 132 | status: 201, | |
| 133 | refusals: { app_limit: "the owner is at the app limit" }, | |
| 134 | }, | |
| 135 | { | |
| 136 | method: "patch", | |
| 137 | path: `${REPO}/app`, | |
| 138 | summary: "Change the app's settings (admin scope); `access` is public or private, `previewData` is copy or empty", | |
| 139 | auth: true, | |
| 140 | body: { slug: "string", access: "string", previewData: "string" }, | |
| 141 | }, | |
| 142 | { method: "delete", path: `${REPO}/app`, summary: "Turn the app off (admin scope); answers {}", auth: true }, | |
| 143 | { method: "get", path: `${REPO}/app/members`, summary: "Read the people a private app names; answers the members", auth: true }, | |
| 144 | { | |
| 145 | method: "put", | |
| 146 | path: `${REPO}/app/members`, | |
| 147 | summary: "Set the people a private app names (admin scope, a person's, never an agent's); a plan unless dryRun is false", | |
| 148 | auth: true, | |
| 149 | body: { members: "array", dryRun: "boolean" }, | |
| 150 | refusals: { invalid: "`members` is not a list of usernames" }, | |
| 151 | }, | |
| 152 | { method: "get", path: `${REPO}/app/domains`, summary: "List the app's own domains (admin scope); answers { items, next_cursor }", auth: true }, | |
| 153 | { | |
| 154 | method: "post", | |
| 155 | path: `${REPO}/app/domains`, | |
| 156 | summary: "Add a domain of your own to the app (admin scope); answers 201 with the domain", | |
| 157 | auth: true, | |
| 158 | body: { host: "string" }, | |
| 159 | status: 201, | |
| 160 | }, | |
| 161 | { method: "delete", path: `${REPO}/app/domains/{host}`, summary: "Remove a domain from the app (admin scope); answers {}", auth: true }, | |
| 162 | { method: "post", path: `${REPO}/app/domains/{host}/check`, summary: "Check that a domain points at the forge (admin scope); answers the domain", auth: true }, | |
| 163 | { method: "get", path: `${REPO}/app/env`, summary: "List the names of the app's environment variables (admin scope); a value is written and never read back; answers { items, next_cursor }", auth: true }, | |
| 164 | { | |
| 165 | method: "put", | |
| 166 | path: `${REPO}/app/env/{var}`, | |
| 167 | summary: "Set an environment variable (admin scope); the value is written and never read back; scope is all (default), production or preview", | |
| 168 | auth: true, | |
| 169 | body: { value: "string", scope: "string" }, | |
| 170 | }, | |
| 171 | { | |
| 172 | method: "delete", | |
| 173 | path: `${REPO}/app/env/{var}`, | |
| 174 | summary: "Unset an environment variable (admin scope); scope is all (default), production or preview; answers {}", | |
| 175 | auth: true, | |
| 176 | query: { scope: "string" }, | |
| 177 | }, | |
| 178 | { | |
| 179 | method: "post", | |
| 180 | path: `${REPO}/app/{env}/redeploy`, | |
| 181 | summary: "Redeploy an environment (rw scope); answers 202 with { run }", | |
| 182 | auth: true, | |
| 183 | status: 202, | |
| 184 | }, | |
| 185 | { method: "post", path: `${REPO}/app/{env}/stop`, summary: "Stop an environment (rw scope); answers {}", auth: true }, | |
| 125 | 186 | { method: "get", path: `${REPO}/map`, summary: "Read the plan: issues, their dependency edges and pillars", auth: false }, |
| 126 | 187 | { |
| 127 | 188 | method: "post", |
test/openapi.test.ts +23
| @@ -115,3 +115,26 @@ test("making and listing apps is described with every field the route reads", () | ||
| 115 | 115 | for (const code of ["unverified", "app_limit", "no_such_agent", "name_taken"]) |
| 116 | 116 | expect(post.responses.default.description).toContain(`\`${code}\``); |
| 117 | 117 | }); |
| 118 | ||
| 119 | test("an app's routes are described, and env values are never read back", () => { | |
| 120 | const doc = openapi("https://f.test", "1") as any; | |
| 121 | const A = "/api/v1/repos/{owner}/{name}/app"; | |
| 122 | const want: Record<string, string[]> = { | |
| 123 | [A]: ["get", "post", "patch", "delete"], | |
| 124 | [`${A}/members`]: ["get", "put"], | |
| 125 | [`${A}/domains`]: ["get", "post"], | |
| 126 | [`${A}/domains/{host}`]: ["delete"], | |
| 127 | [`${A}/domains/{host}/check`]: ["post"], | |
| 128 | [`${A}/env`]: ["get"], | |
| 129 | [`${A}/env/{var}`]: ["put", "delete"], | |
| 130 | [`${A}/{env}/redeploy`]: ["post"], | |
| 131 | [`${A}/{env}/stop`]: ["post"], | |
| 132 | }; | |
| 133 | for (const [path, methods] of Object.entries(want)) | |
| 134 | for (const m of methods) expect(doc.paths[path]?.[m], `${m} ${path}`).toBeDefined(); | |
| 135 | for (const m of ["post", "patch"]) | |
| 136 | expect(Object.keys(doc.paths[A][m].requestBody.content["application/json"].schema.properties)).toEqual(["slug", "access", "previewData"]); | |
| 137 | expect(doc.paths[`${A}/env/{var}`].put.summary).toContain("never read back"); | |
| 138 | expect(doc.paths[`${A}/env`].get.summary).toContain("never read back"); | |
| 139 | expect(doc.paths[`${A}/{env}/redeploy`].post.responses["202"]).toBeDefined(); | |
| 140 | }); |
new above
offline
Wrong fields: appPatch reads slug, access (public|private) and previewData (copy|empty), not name/description/visibility. Same on the PATCH row below.