Public: anyone can read this; the crew can change it.
The OpenAPI document describes the caller's origin apps (list, add, remove) in their own route file #6029
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/5956-the-openapi-document-describes-the-calle → main 3 commits fork 2 threads · 2 unresolved
5 files
changed +37
−7
src/contract/openapi/routes/deleteApiV1UserOriginAppsId.ts +6 −2
| @@ -3,8 +3,12 @@ import type { Route } from "../shared.ts"; | ||
| 3 | 3 | export const route: Route = { |
| 4 | 4 | method: "delete", |
| 5 | 5 | path: "/api/v1/user/origin-apps/{id}", |
| 6 | summary: "Remove a registered OAuth application of yours; answers 204", | |
| 6 | summary: "Remove a registered OAuth application of yours, with its stored client secret; answers 204", | |
| 7 | 7 | auth: true, |
| 8 | 8 | status: 204, |
| 9 | refusals: { not_found: "404: no such application of yours" }, | |
| 9 | refusals: { | |
| 10 | unauthorized: "401: no signed-in account", | |
| 11 | forbidden: "403: a cross-origin browser request, or a token without admin scope", | |
| 12 | not_found: "404: no such application of yours", | |
| 13 | }, | |
| 10 | 14 | }; |
src/contract/openapi/routes/getApiV1UserOriginApps.ts +10 −1
| @@ -1,3 +1,12 @@ | ||
| 1 | 1 | import type { Route } from "../shared.ts"; |
| 2 | 2 | |
| 3 | export const route: Route = { method: "get", path: "/api/v1/user/origin-apps", summary: "List your registered OAuth applications, one per origin host", auth: true }; | |
| 3 | export const route: Route = { | |
| 4 | method: "get", | |
| 5 | path: "/api/v1/user/origin-apps", | |
| 6 | summary: "List your registered OAuth applications, one per origin host, as a bare array [{ id, userId, kind, apiUrl, webUrl, clientId, hasSecret, createdAt }]; the client secret is never read back, only hasSecret says one is held", | |
| 7 | auth: true, | |
| 8 | refusals: { | |
| 9 | unauthorized: "401: no signed-in account", | |
| 10 | forbidden: "403: a cross-origin browser request, or a token without admin scope", | |
| 11 | }, | |
| 12 | }; |
src/contract/openapi/routes/postApiV1UserOriginApps.ts +4 −1
| @@ -3,11 +3,14 @@ import type { Route } from "../shared.ts"; | ||
| 3 | 3 | export const route: Route = { |
| 4 | 4 | method: "post", |
| 5 | 5 | path: "/api/v1/user/origin-apps", |
| 6 | summary: "Register an OAuth application for an origin host (a forgejo or gitea needs its own; github may fall back to the forge's); answers 201 with the application", | |
| 6 | summary: "Register an OAuth application for an origin host (a forgejo or gitea needs its own; github may fall back to the forge's); answers 201 with the application. The client secret is written here and never read back", | |
| 7 | 7 | auth: true, |
| 8 | 8 | body: { host: "string", kind: "string", clientId: "string", clientSecret: "string" }, |
| 9 | 9 | status: 201, |
| 10 | reply: { id: "string", userId: "string", kind: "string", apiUrl: "string", webUrl: "string", clientId: "string", hasSecret: "boolean", createdAt: "string" }, | |
| 10 | 11 | refusals: { |
| 12 | unauthorized: "401: no signed-in account", | |
| 13 | forbidden: "403: a cross-origin browser request, or a token without admin scope", | |
| 11 | 14 | unknown_host: "422: that does not look like a forge host", |
| 12 | 15 | private_host: "422: that address is inside a network, not on the internet", |
| 13 | 16 | unreachable: "422: that host could not be reached", |
test/openapi-coverage.test.ts −3
| @@ -13,7 +13,6 @@ const NOT_YET_DESCRIBED: readonly string[] = [ | ||
| 13 | 13 | "DELETE /api/v1/repos/{}/{}/labels/{}", |
| 14 | 14 | "DELETE /api/v1/repos/{}/{}/pulls/{}/hold", |
| 15 | 15 | "DELETE /api/v1/user/keys/{}", |
| 16 | "DELETE /api/v1/user/origin-apps/{}", | |
| 17 | 16 | "DELETE /api/v1/user/origins/{}", |
| 18 | 17 | "DELETE /api/v1/user/pins/{}/{}", |
| 19 | 18 | "GET /api/v1/agents", |
| @@ -45,7 +44,6 @@ const NOT_YET_DESCRIBED: readonly string[] = [ | ||
| 45 | 44 | "GET /api/v1/user/assigned", |
| 46 | 45 | "GET /api/v1/user/keys", |
| 47 | 46 | "GET /api/v1/user/orgs", |
| 48 | "GET /api/v1/user/origin-apps", | |
| 49 | 47 | "GET /api/v1/user/origins", |
| 50 | 48 | "GET /api/v1/user/origins/oauth/{}", |
| 51 | 49 | "GET /api/v1/user/origins/{}/browse/{}/{}", |
| @@ -91,7 +89,6 @@ const NOT_YET_DESCRIBED: readonly string[] = [ | ||
| 91 | 89 | "POST /api/v1/repos/{}/{}/runs/{}/steer", |
| 92 | 90 | "POST /api/v1/share", |
| 93 | 91 | "POST /api/v1/user/keys", |
| 94 | "POST /api/v1/user/origin-apps", | |
| 95 | 92 | "POST /api/v1/user/origins", |
| 96 | 93 | "POST /api/v1/user/origins/oauth", |
| 97 | 94 | "POST /api/v1/user/origins/{}/sync", |
test/openapi.test.ts +17
| @@ -311,3 +311,20 @@ test("the tell row names its body, its answer and the refusals of its handler", | ||
| 311 | 311 | for (const w of ["app", "text", "variants", "from", "invalid_text", "invalid_variants", "invalid_from", "unverified", "forbidden"]) |
| 312 | 312 | expect(text).toContain(w); |
| 313 | 313 | }); |
| 314 | ||
| 315 | test("the origin applications are described, and a client secret is written, never read back", () => { | |
| 316 | const doc = openapi("https://f.test", "1") as any; | |
| 317 | const list = doc.paths["/api/v1/user/origin-apps"]; | |
| 318 | const add = list.post; | |
| 319 | expect(add.requestBody).toBeDefined(); | |
| 320 | expect(Object.keys(add.responses)).toContain("201"); | |
| 321 | expect(JSON.stringify(add.responses.default)).toContain("private_host"); | |
| 322 | expect(JSON.stringify(doc.paths["/api/v1/user/origin-apps/{id}"].delete.responses)).toContain("not_found"); | |
| 323 | const reply = JSON.stringify(list.get.responses["200"]); | |
This passes whether the list is described as an object or an array, so it would not catch the shape bug above. Assert the GET 200 schema's shape (not |
||
| 324 | expect(reply).not.toContain("clientSecret"); | |
| 325 | // A bare array, so the 200 must not claim to be one application object. | |
| 326 | expect(reply).not.toContain('"id"'); | |
| 327 | expect(list.get.summary).toContain("bare array [{ id,"); | |
| 328 | expect(list.get.summary).toContain("hasSecret"); | |
| 329 | expect(JSON.stringify(add.responses["201"])).not.toContain("clientSecret"); | |
| 330 | }); |
new above
offline
High (contract is false): the handler returns
AppRow[], a bare array, but thisreplyrenders a 200 schema of a single object. Removereplyhere and describe the array in the summary, asgetApiV1ReposOwnerNameWaves.tsdoes. The POST'sreplyis right: it returns one row.