From fdd76c4a501b1301a7afab2c3958e67f0248859c Mon Sep 17 00:00:00 2001 From: shin-core <153108882+shin-core@users.noreply.github.com> Date: Wed, 29 Jul 2026 20:22:14 +0900 Subject: [PATCH] fix(openapi): declare 503 on the BYOK key setters and drop the unreachable GET 404 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The four BYOK key-write routes answer 503 `encryption_unavailable` when TOKEN_ENCRYPTION_SECRET is unconfigured, but their spec entries never declared it — so a generated client had no schema for the one failure a maintainer actually hits. The two public key GETs declared a 404 they can never return: the handler answers 200 `{ configured: false }` for an unregistered repo, never a 404. In the per-`kind` flatMap that generates all four key routes (so ai-key and linear-key can't drift), add 503 to the public and internal setters and remove the 404 from the public getter. No handler behaviour changes. openapi.json regenerated. Closes #9709 --- apps/loopover-ui/public/openapi.json | 18 +++++++---- src/openapi/orb-and-control-route-specs.ts | 9 ++++-- test/unit/routes-ai-byok.test.ts | 35 ++++++++++++++++++++++ 3 files changed, 53 insertions(+), 9 deletions(-) diff --git a/apps/loopover-ui/public/openapi.json b/apps/loopover-ui/public/openapi.json index 8124cf79d6..38ebf1b6ef 100644 --- a/apps/loopover-ui/public/openapi.json +++ b/apps/loopover-ui/public/openapi.json @@ -24419,9 +24419,6 @@ }, "403": { "description": "Insufficient control-panel role" - }, - "404": { - "description": "Repo not registered" } }, "parameters": [ @@ -24470,6 +24467,9 @@ }, "403": { "description": "Insufficient control-panel role" + }, + "503": { + "description": "encryption_unavailable — TOKEN_ENCRYPTION_SECRET is not configured" } }, "parameters": [ @@ -24598,6 +24598,9 @@ }, "401": { "description": "Invalid internal token" + }, + "503": { + "description": "encryption_unavailable — TOKEN_ENCRYPTION_SECRET is not configured" } }, "parameters": [ @@ -24684,9 +24687,6 @@ }, "403": { "description": "Insufficient control-panel role" - }, - "404": { - "description": "Repo not registered" } }, "parameters": [ @@ -24735,6 +24735,9 @@ }, "403": { "description": "Insufficient control-panel role" + }, + "503": { + "description": "encryption_unavailable — TOKEN_ENCRYPTION_SECRET is not configured" } }, "parameters": [ @@ -24863,6 +24866,9 @@ }, "401": { "description": "Invalid internal token" + }, + "503": { + "description": "encryption_unavailable — TOKEN_ENCRYPTION_SECRET is not configured" } }, "parameters": [ diff --git a/src/openapi/orb-and-control-route-specs.ts b/src/openapi/orb-and-control-route-specs.ts index 2dac5570c7..04e0fa3c03 100644 --- a/src/openapi/orb-and-control-route-specs.ts +++ b/src/openapi/orb-and-control-route-specs.ts @@ -255,7 +255,8 @@ const REPO_ROUTES: SpecEntry[] = [ tags: ["Repositories", "Bring your own key"], summary: `Report whether a ${label} key is configured for this repo (never the key itself)`, auth: "session", - responses: { 200: { description: "Key presence and metadata" }, 404: { description: "Repo not registered" }, ...SESSION_AUTH_RESPONSES }, + // No 404 (#9709): the GET returns { configured: false } for an unregistered repo, never a 404. + responses: { 200: { description: "Key presence and metadata" }, ...SESSION_AUTH_RESPONSES }, }, { method: "post", @@ -264,7 +265,8 @@ const REPO_ROUTES: SpecEntry[] = [ tags: ["Repositories", "Bring your own key"], summary: `Store a ${label} key for this repo`, auth: "session", - responses: { 200: { description: "Key stored" }, 400: { description: "Malformed key" }, ...SESSION_AUTH_RESPONSES }, + // 503 (#9709): the handler answers encryption_unavailable when TOKEN_ENCRYPTION_SECRET is unconfigured. + responses: { 200: { description: "Key stored" }, 400: { description: "Malformed key" }, 503: { description: "encryption_unavailable — TOKEN_ENCRYPTION_SECRET is not configured" }, ...SESSION_AUTH_RESPONSES }, }, { method: "delete", @@ -291,7 +293,8 @@ const REPO_ROUTES: SpecEntry[] = [ tags: ["Bring your own key", "Internal"], summary: `Store this repo's ${label} key from the control plane`, auth: "internal", - responses: { 200: { description: "Key stored" }, 400: { description: "Malformed key" }, ...INTERNAL_AUTH_RESPONSES }, + // 503 (#9709): mirrors the public setter — encryption_unavailable when TOKEN_ENCRYPTION_SECRET is unset. + responses: { 200: { description: "Key stored" }, 400: { description: "Malformed key" }, 503: { description: "encryption_unavailable — TOKEN_ENCRYPTION_SECRET is not configured" }, ...INTERNAL_AUTH_RESPONSES }, }, { method: "delete", diff --git a/test/unit/routes-ai-byok.test.ts b/test/unit/routes-ai-byok.test.ts index a04f5ee26f..40bffdfab0 100644 --- a/test/unit/routes-ai-byok.test.ts +++ b/test/unit/routes-ai-byok.test.ts @@ -1,5 +1,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { createApp } from "../../src/api/routes"; +import { buildOpenApiSpec } from "../../src/openapi/spec"; import { createSessionForGitHubUser } from "../../src/auth/security"; import { getRepositorySettings, upsertInstallation, upsertPullRequestFromGitHub, upsertRepositorySettings, upsertRepositoryFromGitHub } from "../../src/db/repositories"; import { getRepositoryCollaboratorPermission } from "../../src/github/app"; @@ -225,6 +226,40 @@ describe("maintainer BYOK key route", () => { expect(res.status).toBe(503); expect(await res.json()).toMatchObject({ error: "encryption_unavailable" }); }); + + describe("the spec declares the statuses the key routes actually return (#9709)", () => { + it("POST /v1/repos/:owner/:repo/ai-key answers 503 without a secret AND declares 503 in the spec", async () => { + const app = createApp(); + const env = createTestEnv({}); + const res = await app.request(`/v1/repos/${REPO}/ai-key`, { method: "POST", headers: apiHeaders(env), body: JSON.stringify({ provider: "openai", key: "sk-openai-valid-key-123456" }) }, env); + expect(res.status).toBe(503); + expect(await res.json()).toMatchObject({ error: "encryption_unavailable" }); + const spec = buildOpenApiSpec(); + expect(Object.keys(spec.paths["/v1/repos/{owner}/{repo}/ai-key"]?.post?.responses ?? {})).toContain("503"); + }); + + it("POST /v1/internal/repos/:owner/:repo/linear-key answers 503 without a secret AND declares 503 in the spec", async () => { + const app = createApp(); + const env = createTestEnv({}); + const internalHeaders = { authorization: `Bearer ${env.INTERNAL_JOB_TOKEN}`, "content-type": "application/json" }; + const res = await app.request(`/v1/internal/repos/${REPO}/linear-key`, { method: "POST", headers: internalHeaders, body: JSON.stringify({ key: "lin_api_valid_linear_key_1234567890" }) }, env); + expect(res.status).toBe(503); + expect(await res.json()).toMatchObject({ error: "encryption_unavailable" }); + const spec = buildOpenApiSpec(); + expect(Object.keys(spec.paths["/v1/internal/repos/{owner}/{repo}/linear-key"]?.post?.responses ?? {})).toContain("503"); + }); + + it("GET /v1/repos/:owner/:repo/ai-key answers 200 { configured: false } for a repo with no key — proving the removed 404 was unreachable", async () => { + const app = createApp(); + const env = createTestEnv({ TOKEN_ENCRYPTION_SECRET: SECRET }); + const res = await app.request(`/v1/repos/${REPO}/ai-key`, { headers: apiHeaders(env) }, env); + expect(res.status).toBe(200); + expect(await res.json()).toMatchObject({ configured: false }); + // And the spec no longer claims a 404 for that GET. + const spec = buildOpenApiSpec(); + expect(Object.keys(spec.paths["/v1/repos/{owner}/{repo}/ai-key"]?.get?.responses ?? {})).not.toContain("404"); + }); + }); }); describe("maintainer route authz (session-scoped)", () => {