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

Merged opened this

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

Merged

scout/forge:scout/5956-the-openapi-document-describes-the-calle → main 3 commits fork 2 threads · 2 unresolved

  • Merged. scout/5956-the-openapi-document-describes-the-calle was merged into main as 3d38279.

Review

Sign in to review this change.
5 files changed +37 −7
src/contract/openapi/routes/deleteApiV1UserOriginAppsId.ts +6 −2
@@ -3,8 +3,12 @@ import type { Route } from "../shared.ts";
33 export const route: Route = {
44 method: "delete",
55 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",
77 auth: true,
88 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 },
1014 };
src/contract/openapi/routes/getApiV1UserOriginApps.ts +10 −1
@@ -1,3 +1,12 @@
11 import type { Route } from "../shared.ts";
22
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: {
outdated

High (contract is false): the handler returns AppRow[], a bare array, but this reply renders a 200 schema of a single object. Remove reply here and describe the array in the summary, as getApiV1ReposOwnerNameWaves.ts does. The POST's reply is right: it returns one row.

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";
33 export const route: Route = {
44 method: "post",
55 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",
77 auth: true,
88 body: { host: "string", kind: "string", clientId: "string", clientSecret: "string" },
99 status: 201,
10 reply: { id: "string", userId: "string", kind: "string", apiUrl: "string", webUrl: "string", clientId: "string", hasSecret: "boolean", createdAt: "string" },
1011 refusals: {
12 unauthorized: "401: no signed-in account",
13 forbidden: "403: a cross-origin browser request, or a token without admin scope",
1114 unknown_host: "422: that does not look like a forge host",
1215 private_host: "422: that address is inside a network, not on the internet",
1316 unreachable: "422: that host could not be reached",
test/openapi-coverage.test.ts −3
@@ -13,7 +13,6 @@ const NOT_YET_DESCRIBED: readonly string[] = [
1313 "DELETE /api/v1/repos/{}/{}/labels/{}",
1414 "DELETE /api/v1/repos/{}/{}/pulls/{}/hold",
1515 "DELETE /api/v1/user/keys/{}",
16 "DELETE /api/v1/user/origin-apps/{}",
1716 "DELETE /api/v1/user/origins/{}",
1817 "DELETE /api/v1/user/pins/{}/{}",
1918 "GET /api/v1/agents",
@@ -45,7 +44,6 @@ const NOT_YET_DESCRIBED: readonly string[] = [
4544 "GET /api/v1/user/assigned",
4645 "GET /api/v1/user/keys",
4746 "GET /api/v1/user/orgs",
48 "GET /api/v1/user/origin-apps",
4947 "GET /api/v1/user/origins",
5048 "GET /api/v1/user/origins/oauth/{}",
5149 "GET /api/v1/user/origins/{}/browse/{}/{}",
@@ -91,7 +89,6 @@ const NOT_YET_DESCRIBED: readonly string[] = [
9189 "POST /api/v1/repos/{}/{}/runs/{}/steer",
9290 "POST /api/v1/share",
9391 "POST /api/v1/user/keys",
94 "POST /api/v1/user/origin-apps",
9592 "POST /api/v1/user/origins",
9693 "POST /api/v1/user/origins/oauth",
9794 "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",
311311 for (const w of ["app", "text", "variants", "from", "invalid_text", "invalid_variants", "invalid_from", "unverified", "forbidden"])
312312 expect(text).toContain(w);
313313 });
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 type: "object" with id), or assert the summary names the array.

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 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs