Public: anyone can read this; the crew can change it.
The OpenAPI document describes the caller's origins: list, add, connect by OAuth, sync, read repositories, remove #5167
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/5151-the-openapi-document-describes-the-calle → main 4 commits fork 1 thread · 1 unresolved
10 files
changed +140
−7
src/contract/openapi/routes/deleteApiV1UserOriginsId.ts added +13
| @@ -0,0 +1,13 @@ | ||
| 1 | import type { Route } from "../shared.ts"; | |
| 2 | ||
| 3 | const U = "no valid credentials were sent"; | |
| 4 | const F = "the credential is not the account: a session from another origin, or a token without admin scope"; | |
| 5 | const NF = "no such origin among the caller's"; | |
| 6 | ||
| 7 | export const route: Route = { | |
| 8 | method: "delete", | |
| 9 | path: "/api/v1/user/origins/{id}", | |
| 10 | summary: "Disconnect an origin: forget its credential, and revoke an OAuth grant upstream when there is one; answers { revokedUpstream }; repositories already migrated stay; the account only", | |
| 11 | auth: true, | |
| 12 | refusals: { unauthorized: U, forbidden: F, not_found: NF }, | |
| 13 | }; |
src/contract/openapi/routes/getApiV1UserOrigins.ts added +12
| @@ -0,0 +1,12 @@ | ||
| 1 | import type { Route } from "../shared.ts"; | |
| 2 | ||
| 3 | const U = "no valid credentials were sent"; | |
| 4 | const F = "the credential is not the account: a session from another origin, or a token without admin scope"; | |
| 5 | ||
| 6 | export const route: Route = { | |
| 7 | method: "get", | |
| 8 | path: "/api/v1/user/origins", | |
| 9 | summary: "List the forges (GitHub, Forgejo, Gitea) the caller has connected, never with a token; the caller's account only: a session, the password or an admin-scope token, any other token is a 403", | |
| 10 | auth: true, | |
| 11 | refusals: { unauthorized: U, forbidden: F }, | |
| 12 | }; |
src/contract/openapi/routes/getApiV1UserOriginsIdRepos.ts added +19
| @@ -0,0 +1,19 @@ | ||
| 1 | import type { Route } from "../shared.ts"; | |
| 2 | ||
| 3 | const U = "no valid credentials were sent"; | |
| 4 | const F = "the credential is not the account: a session from another origin, or a token without admin scope"; | |
| 5 | const NF = "no such origin among the caller's"; | |
| 6 | ||
| 7 | export const route: Route = { | |
| 8 | method: "get", | |
| 9 | path: "/api/v1/user/origins/{id}/repos", | |
| 10 | summary: "List the repositories an origin holds, from the cache unless `refresh=1` asks the origin again (a failed refresh falls back to the cache); pass an item's fullName to POST /api/v1/migrate as { source: { originId, fullName } }; the account only", | |
| 11 | auth: true, | |
| 12 | query: { refresh: "string" }, | |
| 13 | refusals: { | |
| 14 | unauthorized: U, | |
| 15 | forbidden: F, | |
| 16 | not_found: NF, | |
| 17 | origin_unreachable: "502, the origin could not be reached and nothing is cached", | |
| 18 | }, | |
| 19 | }; |
src/contract/openapi/routes/getApiV1UserOriginsOauthId.ts added +12
| @@ -0,0 +1,12 @@ | ||
| 1 | import type { Route } from "../shared.ts"; | |
| 2 | ||
| 3 | const U = "no valid credentials were sent"; | |
| 4 | const F = "the credential is not the account: a session from another origin, or a token without admin scope"; | |
| 5 | ||
| 6 | export const route: Route = { | |
| 7 | method: "get", | |
| 8 | path: "/api/v1/user/origins/oauth/{id}", | |
| 9 | summary: "Poll an OAuth connect by its flowId until it is no longer pending (obey intervalSecs); answers the flow's status, and once connected the origin's id; the account only", | |
| 10 | auth: true, | |
| 11 | refusals: { unauthorized: U, forbidden: F, not_found: "no such flow among the caller's" }, | |
| 12 | }; |
src/contract/openapi/routes/index.ts +7
| @@ -24,6 +24,7 @@ export { route as deleteApiV1Sessions } from "./deleteApiV1Sessions.ts"; | ||
| 24 | 24 | export { route as deleteApiV1TokensId } from "./deleteApiV1TokensId.ts"; |
| 25 | 25 | export { route as deleteApiV1UserKeysId } from "./deleteApiV1UserKeysId.ts"; |
| 26 | 26 | export { route as deleteApiV1UserOriginAppsId } from "./deleteApiV1UserOriginAppsId.ts"; |
| 27 | export { route as deleteApiV1UserOriginsId } from "./deleteApiV1UserOriginsId.ts"; | |
| 27 | 28 | export { route as deleteApiV1UserPinsOwnerName } from "./deleteApiV1UserPinsOwnerName.ts"; |
| 28 | 29 | export { route as deleteApiV1UsersUsernameSessionsId } from "./deleteApiV1UsersUsernameSessionsId.ts"; |
| 29 | 30 | export { route as getApiV1AdminAccounts } from "./getApiV1AdminAccounts.ts"; |
| @@ -126,6 +127,7 @@ export { route as getApiV1UserAssigned } from "./getApiV1UserAssigned.ts"; | ||
| 126 | 127 | export { route as getApiV1UserKeys } from "./getApiV1UserKeys.ts"; |
| 127 | 128 | export { route as getApiV1UserOrgs } from "./getApiV1UserOrgs.ts"; |
| 128 | 129 | export { route as getApiV1UserOriginApps } from "./getApiV1UserOriginApps.ts"; |
| 130 | export { route as getApiV1UserOrigins } from "./getApiV1UserOrigins.ts"; | |
| 129 | 131 | export { route as getApiV1UserOriginsIdBrowseOwnerNameBlobRefPath } from "./getApiV1UserOriginsIdBrowseOwnerNameBlobRefPath.ts"; |
| 130 | 132 | export { route as getApiV1UserOriginsIdBrowseOwnerNameBranches } from "./getApiV1UserOriginsIdBrowseOwnerNameBranches.ts"; |
| 131 | 133 | export { route as getApiV1UserOriginsIdBrowseOwnerNameCommits } from "./getApiV1UserOriginsIdBrowseOwnerNameCommits.ts"; |
| @@ -141,6 +143,8 @@ export { route as getApiV1UserOriginsIdBrowseOwnerNameTreeRef } from "./getApiV1 | ||
| 141 | 143 | export { route as getApiV1UserOriginsIdBrowseOwnerNameTreeRefPath } from "./getApiV1UserOriginsIdBrowseOwnerNameTreeRefPath.ts"; |
| 142 | 144 | export { route as getApiV1UserOriginsIdMirrorJob } from "./getApiV1UserOriginsIdMirrorJob.ts"; |
| 143 | 145 | export { route as getApiV1UserOriginsIdMirrorJobStream } from "./getApiV1UserOriginsIdMirrorJobStream.ts"; |
| 146 | export { route as getApiV1UserOriginsIdRepos } from "./getApiV1UserOriginsIdRepos.ts"; | |
| 147 | export { route as getApiV1UserOriginsOauthId } from "./getApiV1UserOriginsOauthId.ts"; | |
| 144 | 148 | export { route as getApiV1UserPins } from "./getApiV1UserPins.ts"; |
| 145 | 149 | export { route as getApiV1UsersUsername } from "./getApiV1UsersUsername.ts"; |
| 146 | 150 | export { route as getApiV1UsersUsernameRepos } from "./getApiV1UsersUsernameRepos.ts"; |
| @@ -234,9 +238,12 @@ export { route as postApiV1Tokens } from "./postApiV1Tokens.ts"; | ||
| 234 | 238 | export { route as postApiV1UserEmailVerify } from "./postApiV1UserEmailVerify.ts"; |
| 235 | 239 | export { route as postApiV1UserKeys } from "./postApiV1UserKeys.ts"; |
| 236 | 240 | export { route as postApiV1UserOriginApps } from "./postApiV1UserOriginApps.ts"; |
| 241 | export { route as postApiV1UserOrigins } from "./postApiV1UserOrigins.ts"; | |
| 237 | 242 | export { route as postApiV1UserOriginsIdBrowseOwnerNameContribute } from "./postApiV1UserOriginsIdBrowseOwnerNameContribute.ts"; |
| 238 | 243 | export { route as postApiV1UserOriginsIdFork } from "./postApiV1UserOriginsIdFork.ts"; |
| 239 | 244 | export { route as postApiV1UserOriginsIdMirror } from "./postApiV1UserOriginsIdMirror.ts"; |
| 245 | export { route as postApiV1UserOriginsIdSync } from "./postApiV1UserOriginsIdSync.ts"; | |
| 246 | export { route as postApiV1UserOriginsOauth } from "./postApiV1UserOriginsOauth.ts"; | |
| 240 | 247 | export { route as postApiV1UserPassword } from "./postApiV1UserPassword.ts"; |
| 241 | 248 | export { route as postApiV1Users } from "./postApiV1Users.ts"; |
| 242 | 249 | export { route as postMcp } from "./postMcp.ts"; |
src/contract/openapi/routes/postApiV1UserOrigins.ts added +23
| @@ -0,0 +1,23 @@ | ||
| 1 | import type { Route } from "../shared.ts"; | |
| 2 | ||
| 3 | const U = "no valid credentials were sent"; | |
| 4 | const F = "the credential is not the account: a session from another origin, or a token without admin scope"; | |
| 5 | ||
| 6 | export const route: Route = { | |
| 7 | method: "post", | |
| 8 | path: "/api/v1/user/origins", | |
| 9 | summary: "Connect a forge with a personal access token; answers 201 with the origin and `reattached`, the frozen mirrors this brought back and the ones it could not, each with its reason; `host` is a name (github), a host or a URL and defaults to `kind`; the account only (session, password or admin-scope token); the token is stored sealed and never read back", | |
| 10 | auth: true, | |
| 11 | status: 201, | |
| 12 | body: { kind: "string", host: "string", token: "string" }, | |
| 13 | refusals: { | |
| 14 | unauthorized: U, | |
| 15 | forbidden: F, | |
| 16 | unknown_host: "422, that does not look like a forge host", | |
| 17 | private_host: "422, that address is inside a network, not on the internet", | |
| 18 | unreachable: "422, that host could not be reached", | |
| 19 | bad_token: "422, the token is empty or the origin rejected it", | |
| 20 | already_connected: "422, that account is already connected here", | |
| 21 | rate_limited: "429, too many connect attempts; wait a minute", | |
| 22 | }, | |
| 23 | }; |
src/contract/openapi/routes/postApiV1UserOriginsIdSync.ts added +13
| @@ -0,0 +1,13 @@ | ||
| 1 | import type { Route } from "../shared.ts"; | |
| 2 | ||
| 3 | const U = "no valid credentials were sent"; | |
| 4 | const F = "the credential is not the account: a session from another origin, or a token without admin scope"; | |
| 5 | const NF = "no such origin among the caller's"; | |
| 6 | ||
| 7 | export const route: Route = { | |
| 8 | method: "post", | |
| 9 | path: "/api/v1/user/origins/{id}/sync", | |
| 10 | summary: "Sync an origin's mirrors now; answers the sync report; the account only", | |
| 11 | auth: true, | |
| 12 | refusals: { unauthorized: U, forbidden: F, not_found: NF }, | |
| 13 | }; |
src/contract/openapi/routes/postApiV1UserOriginsOauth.ts added +23
| @@ -0,0 +1,23 @@ | ||
| 1 | import type { Route } from "../shared.ts"; | |
| 2 | ||
| 3 | const U = "no valid credentials were sent"; | |
| 4 | const F = "the credential is not the account: a session from another origin, or a token without admin scope"; | |
| 5 | ||
| 6 | export const route: Route = { | |
| 7 | method: "post", | |
| 8 | path: "/api/v1/user/origins/oauth", | |
| 9 | summary: "Start connecting a forge by OAuth without handing over a token; answers 201 with { flowId, flow, userCode, url, shortCode, intervalSecs, expiresAt }: flow `device` (GitHub: a person enters `userCode` at `url`) or `code` (Forgejo, Gitea: a person opens `url`); then poll GET /api/v1/user/origins/oauth/{id} with the flowId; the account only", | |
| 10 | auth: true, | |
| 11 | status: 201, | |
| 12 | body: { kind: "string", host: "string" }, | |
| 13 | refusals: { | |
| 14 | unauthorized: U, | |
| 15 | forbidden: F, | |
| 16 | unknown_host: "422, that does not look like a forge host", | |
| 17 | private_host: "422, that address is inside a network, not on the internet", | |
| 18 | unreachable: "422, that host could not be reached", | |
| 19 | no_app: "422, no OAuth application is registered for that host: register one at /api/v1/user/origin-apps, or connect with a token", | |
| 20 | provider_refused: "422, the forge refused to start the flow", | |
| 21 | rate_limited: "429, too many connect attempts; wait a minute", | |
| 22 | }, | |
| 23 | }; |
test/openapi-coverage.test.ts −7
| @@ -11,7 +11,6 @@ const NOT_YET_DESCRIBED: readonly string[] = [ | ||
| 11 | 11 | "DELETE /api/v1/orgs/{}/teams/{}/members/{}", |
| 12 | 12 | "DELETE /api/v1/repos/{}/{}/labels/{}", |
| 13 | 13 | "DELETE /api/v1/user/keys/{}", |
| 14 | "DELETE /api/v1/user/origins/{}", | |
| 15 | 14 | "DELETE /api/v1/user/pins/{}/{}", |
| 16 | 15 | "GET /api/v1/agents", |
| 17 | 16 | "GET /api/v1/asks", |
| @@ -33,15 +32,12 @@ const NOT_YET_DESCRIBED: readonly string[] = [ | ||
| 33 | 32 | "GET /api/v1/user/assigned", |
| 34 | 33 | "GET /api/v1/user/keys", |
| 35 | 34 | "GET /api/v1/user/orgs", |
| 36 | "GET /api/v1/user/origins", | |
| 37 | "GET /api/v1/user/origins/oauth/{}", | |
| 38 | 35 | "GET /api/v1/user/origins/{}/browse/{}/{}", |
| 39 | 36 | "GET /api/v1/user/origins/{}/browse/{}/{}/issues", |
| 40 | 37 | "GET /api/v1/user/origins/{}/browse/{}/{}/issues/{}", |
| 41 | 38 | "GET /api/v1/user/origins/{}/browse/{}/{}/pulls", |
| 42 | 39 | "GET /api/v1/user/origins/{}/browse/{}/{}/pulls/{}", |
| 43 | 40 | "GET /api/v1/user/origins/{}/mirror/{}/stream", |
| 44 | "GET /api/v1/user/origins/{}/repos", | |
| 45 | 41 | "GET /api/v1/user/pins", |
| 46 | 42 | "GET /api/v1/users/{}", |
| 47 | 43 | "GET /api/v1/users/{}/repos", |
| @@ -65,9 +61,6 @@ const NOT_YET_DESCRIBED: readonly string[] = [ | ||
| 65 | 61 | "POST /api/v1/repos/{}/{}/reports/{}/dismiss", |
| 66 | 62 | "POST /api/v1/repos/{}/{}/runs/{}/steer", |
| 67 | 63 | "POST /api/v1/user/keys", |
| 68 | "POST /api/v1/user/origins", | |
| 69 | "POST /api/v1/user/origins/oauth", | |
| 70 | "POST /api/v1/user/origins/{}/sync", | |
| 71 | 64 | "PUT /api/v1/front", |
| 72 | 65 | "PUT /api/v1/repos/{}/{}/crew/{}", |
| 73 | 66 | "PUT /api/v1/repos/{}/{}/pulls/{}/labels", |
test/openapi.test.ts +18
| @@ -345,6 +345,24 @@ test("mirroring and forking from an origin, its job and stream, and origin apps | ||
| 345 | 345 | expect(row.summary).toContain("mirror.done"); |
| 346 | 346 | }); |
| 347 | 347 | |
| 348 | test("the caller's origins are described, from listing to removal, with no token in the document", () => { | |
| 349 | const doc = openapi("https://f.test", "1") as any; | |
| 350 | const O = "/api/v1/user/origins"; | |
| 351 | const want: Record<string, string[]> = { | |
| 352 | [O]: ["get", "post"], | |
| 353 | [`${O}/oauth`]: ["post"], | |
| 354 | [`${O}/oauth/{id}`]: ["get"], | |
| 355 | [`${O}/{id}`]: ["delete"], | |
| 356 | [`${O}/{id}/sync`]: ["post"], | |
| 357 | [`${O}/{id}/repos`]: ["get"], | |
| 358 | }; | |
| 359 | for (const [path, methods] of Object.entries(want)) | |
| 360 | for (const m of methods) expect(doc.paths[path]?.[m], `${m} ${path}`).toBeDefined(); | |
| 361 | expect(doc.paths[O].post.responses["201"]).toBeDefined(); | |
| 362 | expect(doc.paths[`${O}/oauth`].post.responses["201"]).toBeDefined(); | |
| 363 | expect(JSON.stringify(doc.paths[O])).not.toMatch(/ghp_|github_pat_/); | |
| 364 | }); | |
| 365 | ||
| 348 | 366 | test("the admin's writes are described: a capacity setting, a limit and a team", () => { |
| 349 | 367 | const doc = openapi("https://f.test", "1") as any; |
| 350 | 368 | for (const path of ["capacity/{key}", "limits/{key}", "teams/{slug}"]) { |
new above
offline