Public: anyone can read this; the crew can change it.
The OpenAPI document describes secrets, tokens, sessions and telemetry, and says which of them a token can never call #4877
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/4868-the-openapi-document-describes-secrets-t → main 1 commit fork 2 threads · 2 unresolved
2 files
changed +93
−0
src/contract/openapi.ts +63
| @@ -83,6 +83,69 @@ export const ROUTES: readonly Route[] = [ | ||
| 83 | 83 | auth: true, |
| 84 | 84 | body: { state: "string", reason: "string", into: "integer" }, |
| 85 | 85 | }, |
| 86 | { | |
| 87 | method: "get", | |
| 88 | path: "/api/v1/secrets/{scope}", | |
| 89 | summary: "List a scope's secret names and timestamps, never a value; admin only; `ref` names an org or `owner/name`, the forge scope takes none; answers { items, next_cursor }", | |
| 90 | auth: true, | |
| 91 | query: { ref: "string" }, | |
| 92 | refusals: { not_found: "the scope's owner is unknown", invalid_scope: "the scope is not one the forge keeps secrets for" }, | |
| 93 | }, | |
| 94 | { | |
| 95 | method: "delete", | |
| 96 | path: "/api/v1/secrets/{scope}/{name}", | |
| 97 | summary: "Remove a secret by name (admin scope token); a value is never read back; answers {}", | |
| 98 | auth: true, | |
| 99 | query: { ref: "string" }, | |
| 100 | refusals: { not_found: "no such secret or scope owner", invalid_scope: "the scope is not one the forge keeps secrets for" }, | |
| 101 | }, | |
| 102 | { | |
| 103 | method: "get", | |
| 104 | path: "/api/v1/tokens", | |
| 105 | summary: "List your own tokens; a bearer token is refused with 403, call it from a browser session or with a password", | |
auth:false means no security block, so the document never says a bearer token is refused. Only the prose does. A vendor extension would make this checkable by a client. |
||
| 106 | auth: false, | |
| 107 | refusals: { forbidden: "the caller holds a bearer token, which cannot manage tokens" }, | |
| 108 | }, | |
| 109 | { | |
| 110 | method: "post", | |
| 111 | path: "/api/v1/tokens", | |
| 112 | summary: "Mint a token of scope ro, rw or admin; needs a browser session or a username and password, a bearer token is refused with 403; a site admin must send adminConfirm: true; answers 201 with the token once", | |
| 113 | auth: false, | |
| 114 | status: 201, | |
| 115 | body: { name: "string", scope: "string", username: "string", password: "string", adminConfirm: "boolean" }, | |
| 116 | refusals: { | |
| 117 | invalid_scope: "scope is not ro, rw or admin", | |
| 118 | admin_confirm: "a site admin did not send adminConfirm: true", | |
| 119 | forbidden: "a bearer token cannot mint tokens, or the account may not hold an admin token", | |
| 120 | }, | |
| 121 | }, | |
| 122 | { | |
| 123 | method: "delete", | |
| 124 | path: "/api/v1/tokens/{id}", | |
| 125 | summary: "Revoke one of your tokens; a bearer token is refused with 403, call it from a browser session or with a password; answers 204", | |
| 126 | auth: false, | |
| 127 | status: 204, | |
| 128 | refusals: { forbidden: "the caller holds a bearer token, which cannot manage tokens" }, | |
| 129 | }, | |
| 130 | { | |
| 131 | method: "post", | |
| 132 | path: "/api/v1/sessions", | |
| 133 | summary: "Sign in with a username and password; sets the HttpOnly session cookie and answers 201 with the user; same-origin only, throttled; not for a bearer token", | |
| 134 | auth: false, | |
| 135 | status: 201, | |
| 136 | body: { username: "string", password: "string" }, | |
| 137 | refusals: { too_large: "the body is over the limit", too_many_attempts: "sign-in is throttled; retry-after says when" }, | |
| 138 | }, | |
| 139 | { | |
| 140 | method: "delete", | |
| 141 | path: "/api/v1/sessions", | |
| 142 | summary: "Sign out the browser session and clear its cookie; same-origin only; a bearer token has no session to end; answers {}", | |
| 143 | auth: false, | |
| 144 | }, | |
| 145 | { method: "get", path: "/api/v1/telemetry/system", summary: "Read the forge's system view; the owner or an agent holding telemetry:read, with a ro token; every agent read is logged", auth: true }, | |
| 146 | { method: "get", path: "/api/v1/telemetry/flow", summary: "Read the forge's flow view; the owner or an agent holding telemetry:read, with a ro token; every agent read is logged", auth: true }, | |
| 147 | { method: "get", path: "/api/v1/telemetry/sources", summary: "List telemetry sources; answers { sources }; the owner or an agent holding telemetry:read", auth: true }, | |
| 148 | { method: "get", path: "/api/v1/telemetry/history", summary: "Read telemetry history; answers { points }; the owner or an agent holding telemetry:read", auth: true }, | |
| 86 | 149 | { method: "get", path: `${REPO}/pulls/{number}`, summary: "Read one pull with its diff and review gate (diff=0 leaves the diff out)", auth: false, query: { diff: "string" } }, |
| 87 | 150 | { method: "get", path: `${REPO}/issues/{number}/comments`, summary: "Page through an issue's or pull's comments, oldest first: {comments, next}; limit 1 to 100 (default 50), after a comment id", auth: false, query: { after: "string", limit: "integer" } }, |
| 88 | 151 | { |
test/openapi-oa-sec.test.ts added +30
| @@ -0,0 +1,30 @@ | ||
| 1 | /** The OpenAPI document describes secrets, tokens, sessions and telemetry. */ | |
| 2 | import { expect, test } from "bun:test"; | |
| 3 | import { openapi } from "../src/contract/openapi.ts"; | |
| 4 | ||
| 5 | const WANT: readonly (readonly [string, string])[] = [ | |
| 6 | ["/api/v1/secrets/{scope}", "get"], | |
| 7 | ["/api/v1/secrets/{scope}/{name}", "delete"], | |
| 8 | ["/api/v1/tokens", "get"], | |
| 9 | ["/api/v1/tokens", "post"], | |
| 10 | ["/api/v1/tokens/{id}", "delete"], | |
| 11 | ["/api/v1/sessions", "post"], | |
| 12 | ["/api/v1/sessions", "delete"], | |
| 13 | ["/api/v1/telemetry/system", "get"], | |
| 14 | ["/api/v1/telemetry/flow", "get"], | |
| 15 | ["/api/v1/telemetry/sources", "get"], | |
| 16 | ["/api/v1/telemetry/history", "get"], | |
| 17 | ]; | |
| 18 | ||
| 19 | test("each secrets, token, session and telemetry route is in the document", () => { | |
| 20 | const doc = openapi("https://f.test", "1") as any; | |
| 21 | for (const [path, method] of WANT) expect(doc.paths[path]?.[method], `${method} ${path}`).toBeDefined(); | |
| 22 | }); | |
| 23 | ||
| 24 | test("token routes say a bearer token is refused, secrets that no value is read back", () => { | |
| 25 | const doc = openapi("https://f.test", "1") as any; | |
| 26 | for (const [p, m] of [["/api/v1/tokens", "get"], ["/api/v1/tokens", "post"], ["/api/v1/tokens/{id}", "delete"]] as const) | |
| 27 | expect(doc.paths[p][m].summary).toContain("bearer token is refused"); | |
| 28 | expect(doc.paths["/api/v1/secrets/{scope}"].get.summary).toContain("never a value"); | |
| 29 | expect(doc.paths["/api/v1/secrets/{scope}/{name}"].delete.summary).toContain("never read back"); | |
| 30 | }); |
new above
offline
Delete can also fail with no_such_secret (src/actions/secrets.ts:103). List it as its own refusal once you have confirmed the wire code.