diff --git a/apps/loopover-ui/public/openapi.json b/apps/loopover-ui/public/openapi.json index 47177df11c..1e1f1e91b8 100644 --- a/apps/loopover-ui/public/openapi.json +++ b/apps/loopover-ui/public/openapi.json @@ -16573,6 +16573,17 @@ "in": "cookie", "name": "loopover_session", "description": "HttpOnly browser session cookie set by GitHub web OAuth." + }, + "OrbBearer": { + "type": "http", + "scheme": "bearer", + "description": "ORB-issued instance token, minted by POST /v1/orb/token and presented by a self-hosted ORB instance on the relay endpoints. Not a LoopOver API token." + }, + "OrbWebhookSignature": { + "type": "apiKey", + "in": "header", + "name": "x-loopover-signature", + "description": "HMAC signature over the raw request body, verified against the instance's shared secret. The webhook carries no bearer token." } } }, @@ -16591,7 +16602,11 @@ } } } - } + }, + "operationId": "getHealth", + "tags": [ + "Meta" + ] } }, "/v1/mcp/compatibility": { @@ -16608,7 +16623,11 @@ } } } - } + }, + "operationId": "getMcpCompatibility", + "tags": [ + "MCP" + ] } }, "/v1/public/stats": { @@ -16631,7 +16650,11 @@ "503": { "description": "Public stats are temporarily unavailable" } - } + }, + "operationId": "listPublicStats", + "tags": [ + "Public" + ] } }, "/v1/public/github/repos/{owner}/{repo}/stats": { @@ -16672,7 +16695,11 @@ "503": { "description": "GitHub repository stats are unavailable" } - } + }, + "operationId": "listPublicGithubReposByOwnerByRepoStats", + "tags": [ + "Public" + ] } }, "/v1/public/repos/{owner}/{repo}/quality": { @@ -16713,7 +16740,11 @@ "503": { "description": "Public quality metrics are temporarily unavailable" } - } + }, + "operationId": "getPublicReposByOwnerByRepoQuality", + "tags": [ + "Public" + ] } }, "/v1/registry/snapshot": { @@ -16738,6 +16769,10 @@ { "LoopOverSessionCookie": [] } + ], + "operationId": "getRegistrySnapshot", + "tags": [ + "Registry" ] } }, @@ -16763,6 +16798,10 @@ { "LoopOverSessionCookie": [] } + ], + "operationId": "listRegistryChanges", + "tags": [ + "Registry" ] } }, @@ -16788,6 +16827,10 @@ { "LoopOverSessionCookie": [] } + ], + "operationId": "getScoringModel", + "tags": [ + "Scoring" ] } }, @@ -16813,6 +16856,10 @@ { "LoopOverSessionCookie": [] } + ], + "operationId": "getFindingTaxonomy", + "tags": [ + "API" ] } }, @@ -16838,6 +16885,10 @@ { "LoopOverSessionCookie": [] } + ], + "operationId": "listEnrichmentAnalyzers", + "tags": [ + "API" ] } }, @@ -16863,6 +16914,10 @@ { "LoopOverSessionCookie": [] } + ], + "operationId": "listUpstreamStatus", + "tags": [ + "Registry" ] } }, @@ -16891,6 +16946,10 @@ { "LoopOverSessionCookie": [] } + ], + "operationId": "getUpstreamRuleset", + "tags": [ + "Registry" ] } }, @@ -16935,6 +16994,10 @@ { "LoopOverSessionCookie": [] } + ], + "operationId": "getUpstreamDrift", + "tags": [ + "Registry" ] } }, @@ -16963,6 +17026,10 @@ { "LoopOverSessionCookie": [] } + ], + "operationId": "postScoringPreview", + "tags": [ + "Scoring" ] } }, @@ -16991,6 +17058,10 @@ { "LoopOverSessionCookie": [] } + ], + "operationId": "postScoringEligibilityPlan", + "tags": [ + "Scoring" ] } }, @@ -17019,6 +17090,10 @@ { "LoopOverSessionCookie": [] } + ], + "operationId": "postScoringExplainBreakdown", + "tags": [ + "Scoring" ] } }, @@ -17044,6 +17119,10 @@ { "LoopOverSessionCookie": [] } + ], + "operationId": "listSyncStatus", + "tags": [ + "API" ] } }, @@ -17069,6 +17148,10 @@ { "LoopOverSessionCookie": [] } + ], + "operationId": "listReadiness", + "tags": [ + "API" ] } }, @@ -17115,6 +17198,10 @@ { "LoopOverSessionCookie": [] } + ], + "operationId": "listInstallations", + "tags": [ + "API" ] } }, @@ -17153,6 +17240,10 @@ { "LoopOverSessionCookie": [] } + ], + "operationId": "getInstallationsByIdHealth", + "tags": [ + "API" ] } }, @@ -17191,6 +17282,10 @@ { "LoopOverSessionCookie": [] } + ], + "operationId": "getInstallationsByIdRepair", + "tags": [ + "API" ] } }, @@ -17229,6 +17324,10 @@ { "LoopOverSessionCookie": [] } + ], + "operationId": "postInstallationsByIdRepairRefresh", + "tags": [ + "API" ] } }, @@ -17369,6 +17468,10 @@ { "LoopOverSessionCookie": [] } + ], + "operationId": "getAppNotificationModel", + "tags": [ + "Control panel" ] } }, @@ -17397,6 +17500,10 @@ { "LoopOverSessionCookie": [] } + ], + "operationId": "listRepos", + "tags": [ + "API" ] } }, @@ -17443,6 +17550,10 @@ { "LoopOverSessionCookie": [] } + ], + "operationId": "getReposByOwnerByRepo", + "tags": [ + "Repositories" ] } }, @@ -17486,6 +17597,10 @@ { "LoopOverSessionCookie": [] } + ], + "operationId": "getReposByOwnerByRepoIntelligence", + "tags": [ + "Repositories" ] } }, @@ -17532,6 +17647,10 @@ { "LoopOverSessionCookie": [] } + ], + "operationId": "getReposByOwnerByRepoIssueQuality", + "tags": [ + "Repositories" ] } }, @@ -17581,6 +17700,10 @@ { "LoopOverSessionCookie": [] } + ], + "operationId": "getReposByOwnerByRepoGateConfigEffective", + "tags": [ + "Repositories" ] } }, @@ -17618,6 +17741,10 @@ { "LoopOverSessionCookie": [] } + ], + "operationId": "postLoopEvaluateEscalation", + "tags": [ + "Loop" ] } }, @@ -17655,6 +17782,10 @@ { "LoopOverSessionCookie": [] } + ], + "operationId": "postLoopResultsPayload", + "tags": [ + "Loop" ] } }, @@ -17692,6 +17823,10 @@ { "LoopOverSessionCookie": [] } + ], + "operationId": "postLoopProgressSnapshot", + "tags": [ + "Loop" ] } }, @@ -17729,6 +17864,10 @@ { "LoopOverSessionCookie": [] } + ], + "operationId": "postLoopIntakeIdea", + "tags": [ + "Loop" ] } }, @@ -17766,6 +17905,10 @@ { "LoopOverSessionCookie": [] } + ], + "operationId": "postLoopPlanIdeaClaims", + "tags": [ + "Loop" ] } }, @@ -17815,6 +17958,10 @@ { "LoopOverSessionCookie": [] } + ], + "operationId": "listReposByOwnerByRepoLiveGateThresholds", + "tags": [ + "Repositories" ] } }, @@ -17864,6 +18011,10 @@ { "LoopOverSessionCookie": [] } + ], + "operationId": "getReposByOwnerByRepoMaintainerNoise", + "tags": [ + "Repositories" ] } }, @@ -17913,6 +18064,10 @@ { "LoopOverSessionCookie": [] } + ], + "operationId": "getReposByOwnerByRepoAmsMinerCohort", + "tags": [ + "Repositories" ] } }, @@ -17972,6 +18127,10 @@ { "LoopOverSessionCookie": [] } + ], + "operationId": "getReposByOwnerByRepoGatePrecision", + "tags": [ + "Repositories" ] } }, @@ -18031,6 +18190,10 @@ { "LoopOverSessionCookie": [] } + ], + "operationId": "getReposByOwnerByRepoOutcomeCalibration", + "tags": [ + "Repositories" ] } }, @@ -18080,6 +18243,10 @@ { "LoopOverSessionCookie": [] } + ], + "operationId": "getReposByOwnerByRepoActivationPreview", + "tags": [ + "Repositories" ] } }, @@ -18126,6 +18293,10 @@ { "LoopOverSessionCookie": [] } + ], + "operationId": "listReposByOwnerByRepoOutcomePatterns", + "tags": [ + "Repositories" ] } }, @@ -18169,6 +18340,10 @@ { "LoopOverSessionCookie": [] } + ], + "operationId": "listReposByOwnerByRepoRegistrationReadiness", + "tags": [ + "Repositories" ] } }, @@ -18212,6 +18387,10 @@ { "LoopOverSessionCookie": [] } + ], + "operationId": "getReposByOwnerByRepoGittensorConfigRecommendation", + "tags": [ + "Repositories" ] } }, @@ -18261,6 +18440,10 @@ { "LoopOverSessionCookie": [] } + ], + "operationId": "getReposByOwnerByRepoFocusManifest", + "tags": [ + "Repositories" ] }, "put": { @@ -18311,6 +18494,10 @@ { "LoopOverSessionCookie": [] } + ], + "operationId": "putReposByOwnerByRepoFocusManifest", + "tags": [ + "Repositories" ] } }, @@ -18360,6 +18547,10 @@ { "LoopOverSessionCookie": [] } + ], + "operationId": "postReposByOwnerByRepoFocusManifestRefresh", + "tags": [ + "Repositories" ] } }, @@ -18507,6 +18698,10 @@ { "LoopOverSessionCookie": [] } + ], + "operationId": "getReposByOwnerByRepoAgentAuditFeed", + "tags": [ + "Repositories" ] } }, @@ -18632,6 +18827,10 @@ { "LoopOverSessionCookie": [] } + ], + "operationId": "postReposByOwnerByRepoPullsByNumberIncidentReports", + "tags": [ + "Repositories" ] } }, @@ -18743,6 +18942,10 @@ { "LoopOverSessionCookie": [] } + ], + "operationId": "postAppIncidentReports", + "tags": [ + "Control panel" ] } }, @@ -18774,6 +18977,10 @@ { "LoopOverSessionCookie": [] } + ], + "operationId": "getAppSelfDogfoodRegistrationPack", + "tags": [ + "Control panel" ] } }, @@ -18823,6 +19030,10 @@ { "LoopOverSessionCookie": [] } + ], + "operationId": "getReposByOwnerByRepoSelfDogfoodRegistrationPack", + "tags": [ + "Repositories" ] } }, @@ -18875,6 +19086,10 @@ { "LoopOverSessionCookie": [] } + ], + "operationId": "getReposByOwnerByRepoOnboardingPackPreview", + "tags": [ + "Repositories" ] } }, @@ -18927,6 +19142,10 @@ { "LoopOverSessionCookie": [] } + ], + "operationId": "postReposByOwnerByRepoContributorIssueDraftsGenerate", + "tags": [ + "Repositories" ] } }, @@ -18979,6 +19198,10 @@ { "LoopOverSessionCookie": [] } + ], + "operationId": "postReposByOwnerByRepoIssuePlanDraftsGenerate", + "tags": [ + "Repositories" ] } }, @@ -19022,6 +19245,57 @@ { "LoopOverSessionCookie": [] } + ], + "operationId": "listReposByOwnerByRepoSettings", + "tags": [ + "Repositories" + ] + }, + "put": { + "operationId": "updateRepoSettings", + "tags": [ + "Repositories" + ], + "summary": "Replace a repo's LoopOver settings", + "security": [ + { + "LoopOverBearer": [] + }, + { + "LoopOverSessionCookie": [] + } + ], + "responses": { + "200": { + "description": "Settings updated" + }, + "400": { + "description": "Malformed settings" + }, + "401": { + "description": "Not signed in" + }, + "403": { + "description": "Insufficient control-panel role" + } + }, + "parameters": [ + { + "schema": { + "type": "string" + }, + "required": true, + "name": "owner", + "in": "path" + }, + { + "schema": { + "type": "string" + }, + "required": true, + "name": "repo", + "in": "path" + } ] } }, @@ -19065,6 +19339,10 @@ { "LoopOverSessionCookie": [] } + ], + "operationId": "getReposByOwnerByRepoAutomationState", + "tags": [ + "Repositories" ] } }, @@ -19108,6 +19386,10 @@ { "LoopOverSessionCookie": [] } + ], + "operationId": "postReposByOwnerByRepoRepoDocsRefresh", + "tags": [ + "Repositories" ] } }, @@ -19154,6 +19436,10 @@ { "LoopOverSessionCookie": [] } + ], + "operationId": "postReposByOwnerByRepoSettingsPreview", + "tags": [ + "Repositories" ] } }, @@ -19205,6 +19491,10 @@ { "LoopOverSessionCookie": [] } + ], + "operationId": "getReposByOwnerByRepoPullsByNumberMaintainerPacket", + "tags": [ + "Repositories" ] } }, @@ -19256,6 +19546,10 @@ { "LoopOverSessionCookie": [] } + ], + "operationId": "getReposByOwnerByRepoPullsByNumberReviewability", + "tags": [ + "Repositories" ] } }, @@ -19327,6 +19621,10 @@ { "LoopOverSessionCookie": [] } + ], + "operationId": "listReposByOwnerByRepoPullsByNumberAiReviewFindings", + "tags": [ + "Repositories" ] } }, @@ -19362,6 +19660,10 @@ { "LoopOverSessionCookie": [] } + ], + "operationId": "getContributorsByLoginProfile", + "tags": [ + "Contributors" ] } }, @@ -19407,6 +19709,10 @@ { "LoopOverSessionCookie": [] } + ], + "operationId": "getContributorsByLoginDecisionPack", + "tags": [ + "Contributors" ] } }, @@ -19442,6 +19748,10 @@ { "LoopOverSessionCookie": [] } + ], + "operationId": "getContributorsByLoginOpenPrMonitor", + "tags": [ + "Contributors" ] } }, @@ -19488,6 +19798,10 @@ { "LoopOverSessionCookie": [] } + ], + "operationId": "listContributorsByLoginPrOutcomes", + "tags": [ + "Contributors" ] } }, @@ -19523,6 +19837,10 @@ { "LoopOverSessionCookie": [] } + ], + "operationId": "listContributorsByLoginNotifications", + "tags": [ + "Contributors" ] } }, @@ -19578,6 +19896,10 @@ { "LoopOverSessionCookie": [] } + ], + "operationId": "postContributorsByLoginNotificationsRead", + "tags": [ + "Contributors" ] } }, @@ -19639,6 +19961,10 @@ { "LoopOverSessionCookie": [] } + ], + "operationId": "getContributorsByLoginReposByOwnerByRepoDecision", + "tags": [ + "Contributors" ] } }, @@ -19667,6 +19993,10 @@ { "LoopOverSessionCookie": [] } + ], + "operationId": "postPreflightPr", + "tags": [ + "Preflight" ] } }, @@ -19698,6 +20028,10 @@ { "LoopOverSessionCookie": [] } + ], + "operationId": "postPreflightReviewRisk", + "tags": [ + "Preflight" ] } }, @@ -19726,6 +20060,10 @@ { "LoopOverSessionCookie": [] } + ], + "operationId": "postPreflightLocalDiff", + "tags": [ + "Preflight" ] } }, @@ -19757,6 +20095,10 @@ { "LoopOverSessionCookie": [] } + ], + "operationId": "postLocalBranchAnalysis", + "tags": [ + "Local" ] } }, @@ -19788,6 +20130,10 @@ { "LoopOverSessionCookie": [] } + ], + "operationId": "postAgentRuns", + "tags": [ + "Agent automation" ] }, "get": { @@ -19851,6 +20197,10 @@ { "LoopOverSessionCookie": [] } + ], + "operationId": "listAgentRuns", + "tags": [ + "Agent automation" ] } }, @@ -19889,6 +20239,10 @@ { "LoopOverSessionCookie": [] } + ], + "operationId": "getAgentRunsById", + "tags": [ + "Agent automation" ] } }, @@ -19930,6 +20284,10 @@ { "LoopOverSessionCookie": [] } + ], + "operationId": "postAgentPlanNextWork", + "tags": [ + "Agent automation" ] } }, @@ -19971,6 +20329,10 @@ { "LoopOverSessionCookie": [] } + ], + "operationId": "postAgentPreflightBranch", + "tags": [ + "Agent automation" ] } }, @@ -20012,6 +20374,10 @@ { "LoopOverSessionCookie": [] } + ], + "operationId": "postAgentPreparePrPacket", + "tags": [ + "Agent automation" ] } }, @@ -20053,6 +20419,10 @@ { "LoopOverSessionCookie": [] } + ], + "operationId": "postAgentExplainBlockers", + "tags": [ + "Agent automation" ] } }, @@ -20081,6 +20451,10 @@ { "LoopOverSessionCookie": [] } + ], + "operationId": "listBounties", + "tags": [ + "Bounties" ] } }, @@ -20119,6 +20493,10 @@ { "LoopOverSessionCookie": [] } + ], + "operationId": "getBountiesByIdAdvisory", + "tags": [ + "Bounties" ] } }, @@ -20157,6 +20535,10 @@ { "LoopOverSessionCookie": [] } + ], + "operationId": "getBountiesByIdLifecycle", + "tags": [ + "Bounties" ] } }, @@ -20170,7 +20552,11 @@ "401": { "description": "Invalid webhook signature" } - } + }, + "operationId": "postGithubWebhook", + "tags": [ + "Webhooks" + ] } }, "/v1/public/decision-ledger/verify": { @@ -20184,13 +20570,9 @@ "description": "First break found: sequence_gap | predecessor_mismatch | row_hash_mismatch | missing_record | content_mismatch | short_tail (a record newer than the verified tip has no chain entry — the truncated-tail signature) | unchained_record (an INTERIOR record has no chain entry — the failed-append signature). Records younger than the 5-minute append grace window are not reported: the record insert and its chain append are two writes moments apart, and a verify landing between them is not evidence of tampering." } }, - "security": [ - { - "LoopOverBearer": [] - }, - { - "LoopOverSessionCookie": [] - } + "operationId": "getPublicDecisionLedgerVerify", + "tags": [ + "Public" ] } }, @@ -20218,13 +20600,9 @@ "description": "No ledger row at that seq (never appended -- distinct from a row with empty fields)" } }, - "security": [ - { - "LoopOverBearer": [] - }, - { - "LoopOverSessionCookie": [] - } + "operationId": "getPublicDecisionLedgerRowBySeq", + "tags": [ + "Public" ] } }, @@ -20236,13 +20614,9 @@ "description": "{ keys: [{ keyId, publicKeySpki, notBefore, notAfter }], currentKeyId } — retired keys are retained so anchors signed under them stay verifiable; currentKeyId is null when unconfigured or the rotation state is ambiguous" } }, - "security": [ - { - "LoopOverBearer": [] - }, - { - "LoopOverSessionCookie": [] - } + "operationId": "getPublicDecisionLedgerAnchorKey", + "tags": [ + "Public" ] } }, @@ -20285,13 +20659,9 @@ "description": "{ anchors: [{ id, seq, rowHash, keyId, backend, backendRef, status, error, createdAt }], nextBefore } — a failed attempt is returned identically to a successful one, never filtered out or reshaped" } }, - "security": [ - { - "LoopOverBearer": [] - }, - { - "LoopOverSessionCookie": [] - } + "operationId": "listPublicDecisionLedgerAnchors", + "tags": [ + "Public" ] } }, @@ -20335,13 +20705,9 @@ "description": "No decision record persisted yet for this PR" } }, - "security": [ - { - "LoopOverBearer": [] - }, - { - "LoopOverSessionCookie": [] - } + "operationId": "getPublicDecisionRecordsByOwnerByRepoByPull", + "tags": [ + "Public" ] } }, @@ -20374,13 +20740,9 @@ "description": "Public stats disabled (same flag as /v1/public/stats)" } }, - "security": [ - { - "LoopOverBearer": [] - }, - { - "LoopOverSessionCookie": [] - } + "operationId": "listPublicEvalScores", + "tags": [ + "Public" ] } }, @@ -20395,13 +20757,9 @@ "description": "Malformed JSON or invalid payload shape" } }, - "security": [ - { - "LoopOverBearer": [] - }, - { - "LoopOverSessionCookie": [] - } + "operationId": "postOrbIngest", + "tags": [ + "ORB" ] } }, @@ -20415,7 +20773,11 @@ "503": { "description": "GitHub OAuth app secret is not configured" } - } + }, + "operationId": "getAuthGithubStart", + "tags": [ + "Auth" + ] } }, "/v1/auth/github/callback": { @@ -20425,7 +20787,11 @@ "302": { "description": "Completes GitHub web OAuth and redirects to the app" } - } + }, + "operationId": "getAuthGithubCallback", + "tags": [ + "Auth" + ] } }, "/v1/auth/github/device/start": { @@ -20447,7 +20813,11 @@ "429": { "description": "Rate limited" } - } + }, + "operationId": "postAuthGithubDeviceStart", + "tags": [ + "Auth" + ] } }, "/v1/auth/github/device/poll": { @@ -20469,7 +20839,11 @@ "429": { "description": "Rate limited" } - } + }, + "operationId": "postAuthGithubDevicePoll", + "tags": [ + "Auth" + ] } }, "/v1/auth/github/session": { @@ -20491,7 +20865,11 @@ "429": { "description": "Rate limited" } - } + }, + "operationId": "postAuthGithubSession", + "tags": [ + "Auth" + ] } }, "/v1/auth/logout": { @@ -20513,7 +20891,11 @@ "429": { "description": "Rate limited" } - } + }, + "operationId": "postAuthLogout", + "tags": [ + "Auth" + ] } }, "/v1/auth/session": { @@ -20523,7 +20905,11 @@ "200": { "description": "Current auth session, or signed_out when no app session is present" } - } + }, + "operationId": "getAuthSession", + "tags": [ + "Auth" + ] } }, "/v1/auth/github/token": { @@ -20558,10 +20944,11 @@ "description": "Rate limited" } }, + "operationId": "postAuthGithubToken", + "tags": [ + "Auth" + ], "security": [ - { - "LoopOverBearer": [] - }, { "LoopOverSessionCookie": [] } @@ -20599,6 +20986,10 @@ { "LoopOverSessionCookie": [] } + ], + "operationId": "getAppOverview", + "tags": [ + "Control panel" ] } }, @@ -20630,6 +21021,10 @@ { "LoopOverSessionCookie": [] } + ], + "operationId": "getAppRoles", + "tags": [ + "Control panel" ] } }, @@ -20661,6 +21056,10 @@ { "LoopOverSessionCookie": [] } + ], + "operationId": "getAppMinerDashboard", + "tags": [ + "Control panel" ] } }, @@ -20692,6 +21091,10 @@ { "LoopOverSessionCookie": [] } + ], + "operationId": "getAppMaintainerDashboard", + "tags": [ + "Control panel" ] } }, @@ -20723,6 +21126,10 @@ { "LoopOverSessionCookie": [] } + ], + "operationId": "getAppOperatorDashboard", + "tags": [ + "Control panel" ] } }, @@ -20754,6 +21161,10 @@ { "LoopOverSessionCookie": [] } + ], + "operationId": "getAppCommands", + "tags": [ + "Control panel" ] } }, @@ -20785,6 +21196,10 @@ { "LoopOverSessionCookie": [] } + ], + "operationId": "getAppCommandsUsefulness", + "tags": [ + "Control panel" ] } }, @@ -20816,6 +21231,10 @@ { "LoopOverSessionCookie": [] } + ], + "operationId": "getAppDigest", + "tags": [ + "Control panel" ] } }, @@ -20847,6 +21266,10 @@ { "LoopOverSessionCookie": [] } + ], + "operationId": "getAppAnalyticsDailyRollups", + "tags": [ + "Control panel" ] } }, @@ -20878,6 +21301,10 @@ { "LoopOverSessionCookie": [] } + ], + "operationId": "getAppAnalyticsMcpCompatibility", + "tags": [ + "Control panel" ] } }, @@ -20933,6 +21360,10 @@ { "LoopOverSessionCookie": [] } + ], + "operationId": "postAppSelfhostQueueDeadByIdReplay", + "tags": [ + "Control panel" ] } }, @@ -20988,6 +21419,10 @@ { "LoopOverSessionCookie": [] } + ], + "operationId": "deleteAppSelfhostQueueDeadById", + "tags": [ + "Control panel" ] } }, @@ -21025,6 +21460,10 @@ { "LoopOverSessionCookie": [] } + ], + "operationId": "deleteAppSelfhostQueueDead", + "tags": [ + "Control panel" ] }, "get": { @@ -21085,6 +21524,10 @@ { "LoopOverSessionCookie": [] } + ], + "operationId": "getAppSelfhostQueueDead", + "tags": [ + "Control panel" ] } }, @@ -21165,6 +21608,10 @@ { "LoopOverSessionCookie": [] } + ], + "operationId": "getAppAnalyticsWeeklyValueReport", + "tags": [ + "Control panel" ] } }, @@ -21260,6 +21707,10 @@ { "LoopOverSessionCookie": [] } + ], + "operationId": "getAppSkippedPrAudit", + "tags": [ + "Control panel" ] } }, @@ -21297,6 +21748,10 @@ { "LoopOverSessionCookie": [] } + ], + "operationId": "postAppCommandsPreview", + "tags": [ + "Control panel" ] } }, @@ -21344,6 +21799,10 @@ { "LoopOverSessionCookie": [] } + ], + "operationId": "postAppCommandsFeedback", + "tags": [ + "Control panel" ] } }, @@ -21391,15 +21850,19 @@ { "LoopOverSessionCookie": [] } + ], + "operationId": "postAppDigestSubscriptions", + "tags": [ + "Control panel" ] } }, "/v1/internal/jobs/refresh-registry": { "post": { - "summary": "Queue a registry refresh job", + "summary": "Queue a job to refresh the Gittensor registry snapshot", "responses": { "202": { - "description": "Registry refresh queued" + "description": "Job queued" }, "401": { "description": "Invalid internal token" @@ -21408,19 +21871,21 @@ "security": [ { "LoopOverBearer": [] - }, - { - "LoopOverSessionCookie": [] } + ], + "operationId": "queueRefreshRegistryJob", + "tags": [ + "Internal", + "Jobs" ] } }, "/v1/internal/jobs/backfill-registered-repos": { "post": { - "summary": "Queue a registered-repository backfill job", + "summary": "Queue a job to backfill registered repository records", "responses": { "202": { - "description": "Registered repo backfill queued" + "description": "Job queued" }, "401": { "description": "Invalid internal token" @@ -21429,22 +21894,21 @@ "security": [ { "LoopOverBearer": [] - }, - { - "LoopOverSessionCookie": [] } + ], + "operationId": "queueBackfillRegisteredReposJob", + "tags": [ + "Internal", + "Jobs" ] } }, "/v1/internal/jobs/backfill-repo-segment": { "post": { - "summary": "Queue a repository segment backfill job", + "summary": "Queue a job to backfill repository segment assignments", "responses": { "202": { - "description": "Repository segment backfill queued" - }, - "400": { - "description": "Invalid segment request" + "description": "Job queued" }, "401": { "description": "Invalid internal token" @@ -21453,22 +21917,21 @@ "security": [ { "LoopOverBearer": [] - }, - { - "LoopOverSessionCookie": [] } + ], + "operationId": "queueBackfillRepoSegmentJob", + "tags": [ + "Internal", + "Jobs" ] } }, "/v1/internal/jobs/backfill-pr-details": { "post": { - "summary": "Queue an open pull request detail backfill job", + "summary": "Queue a job to backfill pull-request detail rows", "responses": { "202": { - "description": "Open PR detail backfill queued" - }, - "400": { - "description": "Invalid PR detail backfill request" + "description": "Job queued" }, "401": { "description": "Invalid internal token" @@ -21477,22 +21940,21 @@ "security": [ { "LoopOverBearer": [] - }, - { - "LoopOverSessionCookie": [] } + ], + "operationId": "queueBackfillPrDetailsJob", + "tags": [ + "Internal", + "Jobs" ] } }, "/v1/internal/jobs/generate-review-recap": { "post": { - "summary": "Queue a maintainer review recap digest job", + "summary": "Queue a job to generate the maintainer review recap", "responses": { "202": { - "description": "Maintainer review recap digest queued (#1963)" - }, - "400": { - "description": "Missing repoFullName" + "description": "Job queued" }, "401": { "description": "Invalid internal token" @@ -21501,19 +21963,21 @@ "security": [ { "LoopOverBearer": [] - }, - { - "LoopOverSessionCookie": [] } + ], + "operationId": "queueGenerateReviewRecapJob", + "tags": [ + "Internal", + "Jobs" ] } }, "/v1/internal/jobs/refresh-scoring-model": { "post": { - "summary": "Queue a scoring model refresh job", + "summary": "Queue a job to refresh the active scoring model", "responses": { "202": { - "description": "Internal job queued" + "description": "Job queued" }, "401": { "description": "Invalid internal token" @@ -21522,19 +21986,21 @@ "security": [ { "LoopOverBearer": [] - }, - { - "LoopOverSessionCookie": [] } + ], + "operationId": "queueRefreshScoringModelJob", + "tags": [ + "Internal", + "Jobs" ] } }, "/v1/internal/jobs/refresh-upstream-drift": { "post": { - "summary": "Queue an upstream drift refresh job", + "summary": "Queue a job to recompute upstream ruleset drift", "responses": { "202": { - "description": "Internal job queued" + "description": "Job queued" }, "401": { "description": "Invalid internal token" @@ -21543,19 +22009,21 @@ "security": [ { "LoopOverBearer": [] - }, - { - "LoopOverSessionCookie": [] } + ], + "operationId": "queueRefreshUpstreamDriftJob", + "tags": [ + "Internal", + "Jobs" ] } }, "/v1/internal/jobs/file-upstream-drift-issues": { "post": { - "summary": "Queue a job that files upstream drift issues", + "summary": "Queue a job to file issues for open upstream drift", "responses": { "202": { - "description": "Internal job queued" + "description": "Job queued" }, "401": { "description": "Invalid internal token" @@ -21564,10 +22032,12 @@ "security": [ { "LoopOverBearer": [] - }, - { - "LoopOverSessionCookie": [] } + ], + "operationId": "queueFileUpstreamDriftIssuesJob", + "tags": [ + "Internal", + "Jobs" ] } }, @@ -21582,22 +22052,18 @@ "description": "Invalid internal token" } }, - "security": [ - { - "LoopOverBearer": [] - }, - { - "LoopOverSessionCookie": [] - } + "operationId": "postInternalJobsBuildContributorEvidence", + "tags": [ + "Internal" ] } }, "/v1/internal/jobs/build-contributor-decision-packs": { "post": { - "summary": "Queue a contributor decision pack build job", + "summary": "Queue a job to rebuild contributor decision packs", "responses": { "202": { - "description": "Internal job queued" + "description": "Job queued" }, "401": { "description": "Invalid internal token" @@ -21606,10 +22072,12 @@ "security": [ { "LoopOverBearer": [] - }, - { - "LoopOverSessionCookie": [] } + ], + "operationId": "queueBuildContributorDecisionPacksJob", + "tags": [ + "Internal", + "Jobs" ] } }, @@ -21624,22 +22092,18 @@ "description": "Invalid internal token" } }, - "security": [ - { - "LoopOverBearer": [] - }, - { - "LoopOverSessionCookie": [] - } + "operationId": "postInternalJobsBuildBurdenForecasts", + "tags": [ + "Internal" ] } }, "/v1/internal/jobs/generate-signal-snapshots": { "post": { - "summary": "Queue a signal snapshot generation job", + "summary": "Queue a job to generate signal snapshots", "responses": { "202": { - "description": "Internal job queued" + "description": "Job queued" }, "401": { "description": "Invalid internal token" @@ -21648,19 +22112,21 @@ "security": [ { "LoopOverBearer": [] - }, - { - "LoopOverSessionCookie": [] } + ], + "operationId": "queueGenerateSignalSnapshotsJob", + "tags": [ + "Internal", + "Jobs" ] } }, "/v1/internal/jobs/generate-weekly-value-report": { "post": { - "summary": "Queue a weekly value report job", + "summary": "Queue a job to generate the weekly value report", "responses": { "202": { - "description": "Internal job queued" + "description": "Job queued" }, "401": { "description": "Invalid internal token" @@ -21669,10 +22135,12 @@ "security": [ { "LoopOverBearer": [] - }, - { - "LoopOverSessionCookie": [] } + ], + "operationId": "queueGenerateWeeklyValueReportJob", + "tags": [ + "Internal", + "Jobs" ] } }, @@ -21687,13 +22155,9 @@ "description": "Invalid internal token" } }, - "security": [ - { - "LoopOverBearer": [] - }, - { - "LoopOverSessionCookie": [] - } + "operationId": "postInternalJobsRepairDataFidelity", + "tags": [ + "Internal" ] } }, @@ -21708,13 +22172,9 @@ "description": "Invalid internal token" } }, - "security": [ - { - "LoopOverBearer": [] - }, - { - "LoopOverSessionCookie": [] - } + "operationId": "postInternalBountiesImport", + "tags": [ + "Internal" ] } }, @@ -21761,6 +22221,10 @@ { "LoopOverSessionCookie": [] } + ], + "operationId": "listReposByOwnerByRepoAgentPendingActions", + "tags": [ + "Repositories" ] }, "post": { @@ -21820,6 +22284,10 @@ { "LoopOverSessionCookie": [] } + ], + "operationId": "postReposByOwnerByRepoAgentPendingActions", + "tags": [ + "Repositories" ] } }, @@ -21895,6 +22363,10 @@ { "LoopOverSessionCookie": [] } + ], + "operationId": "postReposByOwnerByRepoAgentPendingActionsByIdByDecision", + "tags": [ + "Repositories" ] } }, @@ -21932,6 +22404,10 @@ { "LoopOverSessionCookie": [] } + ], + "operationId": "postLintPrText", + "tags": [ + "Advisory checks" ] } }, @@ -21969,6 +22445,10 @@ { "LoopOverSessionCookie": [] } + ], + "operationId": "postLintSlopRisk", + "tags": [ + "Advisory checks" ] } }, @@ -22006,6 +22486,10 @@ { "LoopOverSessionCookie": [] } + ], + "operationId": "postLintImprovementPotential", + "tags": [ + "Advisory checks" ] } }, @@ -22043,6 +22527,10 @@ { "LoopOverSessionCookie": [] } + ], + "operationId": "postLintOpenPrPressure", + "tags": [ + "Advisory checks" ] } }, @@ -22080,6 +22568,10 @@ { "LoopOverSessionCookie": [] } + ], + "operationId": "postLintBoundaryTests", + "tags": [ + "Advisory checks" ] } }, @@ -22117,6 +22609,10 @@ { "LoopOverSessionCookie": [] } + ], + "operationId": "postLintTestEvidence", + "tags": [ + "Advisory checks" ] } }, @@ -22154,6 +22650,10 @@ { "LoopOverSessionCookie": [] } + ], + "operationId": "postLintIssueSlop", + "tags": [ + "Advisory checks" ] } }, @@ -22191,6 +22691,10 @@ { "LoopOverSessionCookie": [] } + ], + "operationId": "postValidateFocusManifest", + "tags": [ + "Advisory checks" ] } }, @@ -22247,6 +22751,10 @@ { "LoopOverSessionCookie": [] } + ], + "operationId": "getReposByOwnerByRepoSelftuneOverridesAudit", + "tags": [ + "Repositories" ] } }, @@ -22317,6 +22825,10 @@ { "LoopOverSessionCookie": [] } + ], + "operationId": "deleteReposByOwnerByRepoSelftuneOverrides", + "tags": [ + "Repositories" ] } }, @@ -22378,6 +22890,10 @@ { "LoopOverSessionCookie": [] } + ], + "operationId": "postReposByOwnerByRepoValidateLinkedIssue", + "tags": [ + "Repositories" ] } }, @@ -22439,6 +22955,10 @@ { "LoopOverSessionCookie": [] } + ], + "operationId": "postReposByOwnerByRepoCheckBeforeStart", + "tags": [ + "Repositories" ] } }, @@ -22540,6 +23060,10 @@ { "LoopOverSessionCookie": [] } + ], + "operationId": "postOpportunitiesFind", + "tags": [ + "Discovery" ] } }, @@ -22619,6 +23143,10 @@ { "LoopOverSessionCookie": [] } + ], + "operationId": "postIssueRagRetrieve", + "tags": [ + "Discovery" ] } }, @@ -22660,6 +23188,10 @@ { "LoopOverSessionCookie": [] } + ], + "operationId": "listContributorsByLoginWatches", + "tags": [ + "Contributors" ] }, "post": { @@ -22711,6 +23243,10 @@ { "LoopOverSessionCookie": [] } + ], + "operationId": "postContributorsByLoginWatches", + "tags": [ + "Contributors" ] }, "delete": { @@ -22762,6 +23298,10 @@ { "LoopOverSessionCookie": [] } + ], + "operationId": "deleteContributorsByLoginWatches", + "tags": [ + "Contributors" ] } }, @@ -22846,13 +23386,9 @@ "description": "Invalid internal token" } }, - "security": [ - { - "LoopOverBearer": [] - }, - { - "LoopOverSessionCookie": [] - } + "operationId": "getInternalProviderCredentialsByProvider", + "tags": [ + "Internal" ] }, "post": { @@ -22912,13 +23448,9 @@ "description": "TOKEN_ENCRYPTION_SECRET is not configured, so the credential cannot be stored encrypted" } }, - "security": [ - { - "LoopOverBearer": [] - }, - { - "LoopOverSessionCookie": [] - } + "operationId": "postInternalProviderCredentialsByProvider", + "tags": [ + "Internal" ] }, "delete": { @@ -22966,14 +23498,2579 @@ "description": "Invalid internal token" } }, + "operationId": "deleteInternalProviderCredentialsByProvider", + "tags": [ + "Internal" + ] + } + }, + "/v1/orb/token": { + "post": { + "operationId": "mintOrbInstanceToken", + "tags": [ + "ORB" + ], + "summary": "Mint an ORB instance token", + "security": [ + { + "OrbBearer": [] + } + ], + "responses": { + "200": { + "description": "Instance token issued" + }, + "400": { + "description": "Malformed enrollment request" + }, + "401": { + "description": "Missing or invalid ORB instance token" + } + } + } + }, + "/v1/orb/relay": { + "post": { + "operationId": "relayOrbEvent", + "tags": [ + "ORB" + ], + "summary": "Relay one ORB event to the hosted control plane", + "security": [ + { + "OrbBearer": [] + } + ], + "responses": { + "202": { + "description": "Event accepted for processing" + }, + "400": { + "description": "Malformed relay payload" + }, + "401": { + "description": "Missing or invalid ORB instance token" + } + } + } + }, + "/v1/orb/relay/register": { + "post": { + "operationId": "registerOrbRelayInstance", + "tags": [ + "ORB" + ], + "summary": "Register a self-hosted ORB instance with the relay", + "security": [ + { + "OrbBearer": [] + } + ], + "responses": { + "200": { + "description": "Instance registered" + }, + "400": { + "description": "Malformed registration" + }, + "401": { + "description": "Missing or invalid ORB instance token" + } + } + } + }, + "/v1/orb/relay/pull": { + "post": { + "operationId": "pullOrbRelayWork", + "tags": [ + "ORB" + ], + "summary": "Pull queued relay work for an ORB instance", + "security": [ + { + "OrbBearer": [] + } + ], + "responses": { + "200": { + "description": "Queued work, possibly empty" + }, + "401": { + "description": "Missing or invalid ORB instance token" + } + } + } + }, + "/v1/orb/webhook": { + "post": { + "operationId": "receiveOrbWebhook", + "tags": [ + "ORB" + ], + "summary": "Receive a signed ORB webhook", + "security": [ + { + "OrbWebhookSignature": [] + } + ], + "responses": { + "202": { + "description": "Webhook accepted" + }, + "400": { + "description": "Malformed webhook body" + }, + "401": { + "description": "Signature verification failed" + } + } + } + }, + "/v1/orb/oauth/callback": { + "get": { + "operationId": "completeOrbOauth", + "tags": [ + "ORB" + ], + "summary": "Complete the ORB GitHub OAuth flow", + "responses": { + "302": { + "description": "Redirect back to the ORB instance" + }, + "400": { + "description": "Missing or invalid OAuth code" + } + }, + "security": [] + } + }, + "/v1/internal/orb/instances": { + "get": { + "operationId": "listOrbInstances", + "tags": [ + "ORB", + "Internal" + ], + "summary": "List registered ORB instances", + "security": [ + { + "LoopOverBearer": [] + } + ], + "responses": { + "200": { + "description": "Registered instances" + }, + "401": { + "description": "Invalid internal token" + } + } + } + }, + "/v1/internal/orb/instances/register": { + "post": { + "operationId": "registerOrbInstanceInternal", + "tags": [ + "ORB", + "Internal" + ], + "summary": "Register an ORB instance from the control plane", "security": [ { "LoopOverBearer": [] + } + ], + "responses": { + "200": { + "description": "Instance registered" + }, + "400": { + "description": "Malformed registration" }, + "401": { + "description": "Invalid internal token" + } + } + } + }, + "/v1/internal/orb/installations": { + "get": { + "operationId": "listOrbInstallations", + "tags": [ + "ORB", + "Internal" + ], + "summary": "List GitHub App installations known to the ORB control plane", + "security": [ { - "LoopOverSessionCookie": [] + "LoopOverBearer": [] } - ] + ], + "responses": { + "200": { + "description": "Installations" + }, + "401": { + "description": "Invalid internal token" + } + } + } + }, + "/v1/internal/orb/installations/register": { + "post": { + "operationId": "registerOrbInstallation", + "tags": [ + "ORB", + "Internal" + ], + "summary": "Register a GitHub App installation with the ORB control plane", + "security": [ + { + "LoopOverBearer": [] + } + ], + "responses": { + "200": { + "description": "Installation registered" + }, + "400": { + "description": "Malformed registration" + }, + "401": { + "description": "Invalid internal token" + } + } + } + }, + "/v1/internal/orb/installations/backfill": { + "post": { + "operationId": "backfillOrbInstallations", + "tags": [ + "ORB", + "Internal" + ], + "summary": "Backfill ORB installation records from GitHub", + "security": [ + { + "LoopOverBearer": [] + } + ], + "responses": { + "202": { + "description": "Backfill queued" + }, + "401": { + "description": "Invalid internal token" + } + } + } + }, + "/v1/internal/orb/enrollments": { + "post": { + "operationId": "createOrbEnrollment", + "tags": [ + "ORB", + "Internal" + ], + "summary": "Create an ORB instance enrollment", + "security": [ + { + "LoopOverBearer": [] + } + ], + "responses": { + "200": { + "description": "Enrollment created" + }, + "400": { + "description": "Malformed enrollment" + }, + "401": { + "description": "Invalid internal token" + } + } + } + }, + "/v1/internal/orb/enrollments/{enrollId}/revoke": { + "post": { + "operationId": "revokeOrbEnrollment", + "tags": [ + "ORB", + "Internal" + ], + "summary": "Revoke an ORB instance enrollment", + "security": [ + { + "LoopOverBearer": [] + } + ], + "responses": { + "200": { + "description": "Enrollment revoked" + }, + "401": { + "description": "Invalid internal token" + }, + "404": { + "description": "No such enrollment" + } + }, + "parameters": [ + { + "schema": { + "type": "string" + }, + "required": true, + "name": "enrollId", + "in": "path" + } + ] + } + }, + "/v1/app/installations": { + "get": { + "operationId": "listAppInstallations", + "tags": [ + "Control panel" + ], + "summary": "List the GitHub App installations the caller can administer", + "security": [ + { + "LoopOverBearer": [] + }, + { + "LoopOverSessionCookie": [] + } + ], + "responses": { + "200": { + "description": "Installations" + }, + "401": { + "description": "Not signed in" + }, + "403": { + "description": "Insufficient control-panel role" + } + } + } + }, + "/v1/app/installations/{id}/health": { + "get": { + "operationId": "getAppInstallationHealth", + "tags": [ + "Control panel" + ], + "summary": "Return one installation's health summary", + "security": [ + { + "LoopOverBearer": [] + }, + { + "LoopOverSessionCookie": [] + } + ], + "responses": { + "200": { + "description": "Installation health" + }, + "401": { + "description": "Not signed in" + }, + "403": { + "description": "Insufficient control-panel role" + }, + "404": { + "description": "No such installation" + } + }, + "parameters": [ + { + "schema": { + "type": "string" + }, + "required": true, + "name": "id", + "in": "path" + } + ] + } + }, + "/v1/app/installations/{id}/repair": { + "get": { + "operationId": "getAppInstallationRepair", + "tags": [ + "Control panel" + ], + "summary": "Return the repair plan for an unhealthy installation", + "security": [ + { + "LoopOverBearer": [] + }, + { + "LoopOverSessionCookie": [] + } + ], + "responses": { + "200": { + "description": "Repair plan" + }, + "401": { + "description": "Not signed in" + }, + "403": { + "description": "Insufficient control-panel role" + }, + "404": { + "description": "No such installation" + } + }, + "parameters": [ + { + "schema": { + "type": "string" + }, + "required": true, + "name": "id", + "in": "path" + } + ] + } + }, + "/v1/app/installations/{id}/repair/refresh": { + "post": { + "operationId": "refreshAppInstallationRepair", + "tags": [ + "Control panel" + ], + "summary": "Recompute an installation's repair plan", + "security": [ + { + "LoopOverBearer": [] + }, + { + "LoopOverSessionCookie": [] + } + ], + "responses": { + "200": { + "description": "Repair plan recomputed" + }, + "401": { + "description": "Not signed in" + }, + "403": { + "description": "Insufficient control-panel role" + }, + "404": { + "description": "No such installation" + } + }, + "parameters": [ + { + "schema": { + "type": "string" + }, + "required": true, + "name": "id", + "in": "path" + } + ] + } + }, + "/v1/app/installations/{id}/agent/bulk-settings": { + "put": { + "operationId": "bulkUpdateInstallationAgentSettings", + "tags": [ + "Control panel", + "Agent automation" + ], + "summary": "Apply agent settings across every repo in an installation", + "security": [ + { + "LoopOverBearer": [] + }, + { + "LoopOverSessionCookie": [] + } + ], + "responses": { + "200": { + "description": "Settings applied" + }, + "400": { + "description": "Malformed settings" + }, + "401": { + "description": "Not signed in" + }, + "403": { + "description": "Insufficient control-panel role" + }, + "404": { + "description": "No such installation" + } + }, + "parameters": [ + { + "schema": { + "type": "string" + }, + "required": true, + "name": "id", + "in": "path" + } + ] + } + }, + "/v1/app/kill-switch": { + "get": { + "operationId": "getFleetKillSwitch", + "tags": [ + "Control panel", + "Operations" + ], + "summary": "Read the fleet-wide agent kill switch", + "security": [ + { + "LoopOverBearer": [] + }, + { + "LoopOverSessionCookie": [] + } + ], + "responses": { + "200": { + "description": "Kill-switch state" + }, + "401": { + "description": "Not signed in" + }, + "403": { + "description": "Insufficient control-panel role" + } + } + }, + "post": { + "operationId": "setFleetKillSwitch", + "tags": [ + "Control panel", + "Operations" + ], + "summary": "Set the fleet-wide agent kill switch", + "security": [ + { + "LoopOverBearer": [] + }, + { + "LoopOverSessionCookie": [] + } + ], + "responses": { + "200": { + "description": "Kill switch updated" + }, + "400": { + "description": "Malformed request" + }, + "401": { + "description": "Not signed in" + }, + "403": { + "description": "Insufficient control-panel role" + } + } + } + }, + "/v1/app/fleet/config-push": { + "post": { + "operationId": "pushFleetConfig", + "tags": [ + "Control panel", + "Operations" + ], + "summary": "Push a configuration change across the fleet", + "security": [ + { + "LoopOverBearer": [] + }, + { + "LoopOverSessionCookie": [] + } + ], + "responses": { + "202": { + "description": "Config push queued" + }, + "400": { + "description": "Malformed push" + }, + "401": { + "description": "Not signed in" + }, + "403": { + "description": "Insufficient control-panel role" + } + } + } + }, + "/v1/app/miner-dashboard/refresh": { + "post": { + "operationId": "refreshMinerDashboard", + "tags": [ + "Control panel" + ], + "summary": "Recompute the miner dashboard aggregates", + "security": [ + { + "LoopOverBearer": [] + }, + { + "LoopOverSessionCookie": [] + } + ], + "responses": { + "202": { + "description": "Refresh queued" + }, + "401": { + "description": "Not signed in" + }, + "403": { + "description": "Insufficient control-panel role" + } + } + } + }, + "/v1/repos/{owner}/{repo}/ai-key": { + "get": { + "operationId": "getAiKey", + "tags": [ + "Repositories", + "Bring your own key" + ], + "summary": "Report whether a AI provider key is configured for this repo (never the key itself)", + "security": [ + { + "LoopOverBearer": [] + }, + { + "LoopOverSessionCookie": [] + } + ], + "responses": { + "200": { + "description": "Key presence and metadata" + }, + "401": { + "description": "Not signed in" + }, + "403": { + "description": "Insufficient control-panel role" + }, + "404": { + "description": "Repo not registered" + } + }, + "parameters": [ + { + "schema": { + "type": "string" + }, + "required": true, + "name": "owner", + "in": "path" + }, + { + "schema": { + "type": "string" + }, + "required": true, + "name": "repo", + "in": "path" + } + ] + }, + "post": { + "operationId": "setAiKey", + "tags": [ + "Repositories", + "Bring your own key" + ], + "summary": "Store a AI provider key for this repo", + "security": [ + { + "LoopOverBearer": [] + }, + { + "LoopOverSessionCookie": [] + } + ], + "responses": { + "200": { + "description": "Key stored" + }, + "400": { + "description": "Malformed key" + }, + "401": { + "description": "Not signed in" + }, + "403": { + "description": "Insufficient control-panel role" + } + }, + "parameters": [ + { + "schema": { + "type": "string" + }, + "required": true, + "name": "owner", + "in": "path" + }, + { + "schema": { + "type": "string" + }, + "required": true, + "name": "repo", + "in": "path" + } + ] + }, + "delete": { + "operationId": "deleteAiKey", + "tags": [ + "Repositories", + "Bring your own key" + ], + "summary": "Remove this repo's AI provider key", + "security": [ + { + "LoopOverBearer": [] + }, + { + "LoopOverSessionCookie": [] + } + ], + "responses": { + "200": { + "description": "Key removed" + }, + "401": { + "description": "Not signed in" + }, + "403": { + "description": "Insufficient control-panel role" + } + }, + "parameters": [ + { + "schema": { + "type": "string" + }, + "required": true, + "name": "owner", + "in": "path" + }, + { + "schema": { + "type": "string" + }, + "required": true, + "name": "repo", + "in": "path" + } + ] + } + }, + "/v1/internal/repos/{owner}/{repo}/ai-key": { + "get": { + "operationId": "getAiKeyInternal", + "tags": [ + "Bring your own key", + "Internal" + ], + "summary": "Read this repo's AI provider key state from the control plane", + "security": [ + { + "LoopOverBearer": [] + } + ], + "responses": { + "200": { + "description": "Key presence and metadata" + }, + "401": { + "description": "Invalid internal token" + } + }, + "parameters": [ + { + "schema": { + "type": "string" + }, + "required": true, + "name": "owner", + "in": "path" + }, + { + "schema": { + "type": "string" + }, + "required": true, + "name": "repo", + "in": "path" + } + ] + }, + "post": { + "operationId": "setAiKeyInternal", + "tags": [ + "Bring your own key", + "Internal" + ], + "summary": "Store this repo's AI provider key from the control plane", + "security": [ + { + "LoopOverBearer": [] + } + ], + "responses": { + "200": { + "description": "Key stored" + }, + "400": { + "description": "Malformed key" + }, + "401": { + "description": "Invalid internal token" + } + }, + "parameters": [ + { + "schema": { + "type": "string" + }, + "required": true, + "name": "owner", + "in": "path" + }, + { + "schema": { + "type": "string" + }, + "required": true, + "name": "repo", + "in": "path" + } + ] + }, + "delete": { + "operationId": "deleteAiKeyInternal", + "tags": [ + "Bring your own key", + "Internal" + ], + "summary": "Remove this repo's AI provider key from the control plane", + "security": [ + { + "LoopOverBearer": [] + } + ], + "responses": { + "200": { + "description": "Key removed" + }, + "401": { + "description": "Invalid internal token" + } + }, + "parameters": [ + { + "schema": { + "type": "string" + }, + "required": true, + "name": "owner", + "in": "path" + }, + { + "schema": { + "type": "string" + }, + "required": true, + "name": "repo", + "in": "path" + } + ] + } + }, + "/v1/repos/{owner}/{repo}/linear-key": { + "get": { + "operationId": "getLinearKey", + "tags": [ + "Repositories", + "Bring your own key" + ], + "summary": "Report whether a Linear key is configured for this repo (never the key itself)", + "security": [ + { + "LoopOverBearer": [] + }, + { + "LoopOverSessionCookie": [] + } + ], + "responses": { + "200": { + "description": "Key presence and metadata" + }, + "401": { + "description": "Not signed in" + }, + "403": { + "description": "Insufficient control-panel role" + }, + "404": { + "description": "Repo not registered" + } + }, + "parameters": [ + { + "schema": { + "type": "string" + }, + "required": true, + "name": "owner", + "in": "path" + }, + { + "schema": { + "type": "string" + }, + "required": true, + "name": "repo", + "in": "path" + } + ] + }, + "post": { + "operationId": "setLinearKey", + "tags": [ + "Repositories", + "Bring your own key" + ], + "summary": "Store a Linear key for this repo", + "security": [ + { + "LoopOverBearer": [] + }, + { + "LoopOverSessionCookie": [] + } + ], + "responses": { + "200": { + "description": "Key stored" + }, + "400": { + "description": "Malformed key" + }, + "401": { + "description": "Not signed in" + }, + "403": { + "description": "Insufficient control-panel role" + } + }, + "parameters": [ + { + "schema": { + "type": "string" + }, + "required": true, + "name": "owner", + "in": "path" + }, + { + "schema": { + "type": "string" + }, + "required": true, + "name": "repo", + "in": "path" + } + ] + }, + "delete": { + "operationId": "deleteLinearKey", + "tags": [ + "Repositories", + "Bring your own key" + ], + "summary": "Remove this repo's Linear key", + "security": [ + { + "LoopOverBearer": [] + }, + { + "LoopOverSessionCookie": [] + } + ], + "responses": { + "200": { + "description": "Key removed" + }, + "401": { + "description": "Not signed in" + }, + "403": { + "description": "Insufficient control-panel role" + } + }, + "parameters": [ + { + "schema": { + "type": "string" + }, + "required": true, + "name": "owner", + "in": "path" + }, + { + "schema": { + "type": "string" + }, + "required": true, + "name": "repo", + "in": "path" + } + ] + } + }, + "/v1/internal/repos/{owner}/{repo}/linear-key": { + "get": { + "operationId": "getLinearKeyInternal", + "tags": [ + "Bring your own key", + "Internal" + ], + "summary": "Read this repo's Linear key state from the control plane", + "security": [ + { + "LoopOverBearer": [] + } + ], + "responses": { + "200": { + "description": "Key presence and metadata" + }, + "401": { + "description": "Invalid internal token" + } + }, + "parameters": [ + { + "schema": { + "type": "string" + }, + "required": true, + "name": "owner", + "in": "path" + }, + { + "schema": { + "type": "string" + }, + "required": true, + "name": "repo", + "in": "path" + } + ] + }, + "post": { + "operationId": "setLinearKeyInternal", + "tags": [ + "Bring your own key", + "Internal" + ], + "summary": "Store this repo's Linear key from the control plane", + "security": [ + { + "LoopOverBearer": [] + } + ], + "responses": { + "200": { + "description": "Key stored" + }, + "400": { + "description": "Malformed key" + }, + "401": { + "description": "Invalid internal token" + } + }, + "parameters": [ + { + "schema": { + "type": "string" + }, + "required": true, + "name": "owner", + "in": "path" + }, + { + "schema": { + "type": "string" + }, + "required": true, + "name": "repo", + "in": "path" + } + ] + }, + "delete": { + "operationId": "deleteLinearKeyInternal", + "tags": [ + "Bring your own key", + "Internal" + ], + "summary": "Remove this repo's Linear key from the control plane", + "security": [ + { + "LoopOverBearer": [] + } + ], + "responses": { + "200": { + "description": "Key removed" + }, + "401": { + "description": "Invalid internal token" + } + }, + "parameters": [ + { + "schema": { + "type": "string" + }, + "required": true, + "name": "owner", + "in": "path" + }, + { + "schema": { + "type": "string" + }, + "required": true, + "name": "repo", + "in": "path" + } + ] + } + }, + "/v1/repos/{owner}/{repo}/ai-review": { + "put": { + "operationId": "updateRepoAiReviewMode", + "tags": [ + "Repositories" + ], + "summary": "Set a repo's AI-review mode", + "security": [ + { + "LoopOverBearer": [] + }, + { + "LoopOverSessionCookie": [] + } + ], + "responses": { + "200": { + "description": "AI-review mode updated" + }, + "400": { + "description": "Malformed mode" + }, + "401": { + "description": "Not signed in" + }, + "403": { + "description": "Insufficient control-panel role" + } + }, + "parameters": [ + { + "schema": { + "type": "string" + }, + "required": true, + "name": "owner", + "in": "path" + }, + { + "schema": { + "type": "string" + }, + "required": true, + "name": "repo", + "in": "path" + } + ] + } + }, + "/v1/internal/jobs/refresh-registry/run": { + "post": { + "operationId": "runRefreshRegistryJob", + "tags": [ + "Internal", + "Jobs" + ], + "summary": "Run the job to refresh the Gittensor registry snapshot inline, bypassing the queue", + "security": [ + { + "LoopOverBearer": [] + } + ], + "responses": { + "200": { + "description": "Job ran inline and returned its result" + }, + "401": { + "description": "Invalid internal token" + } + } + } + }, + "/v1/internal/jobs/refresh-scoring-model/run": { + "post": { + "operationId": "runRefreshScoringModelJob", + "tags": [ + "Internal", + "Jobs" + ], + "summary": "Run the job to refresh the active scoring model inline, bypassing the queue", + "security": [ + { + "LoopOverBearer": [] + } + ], + "responses": { + "200": { + "description": "Job ran inline and returned its result" + }, + "401": { + "description": "Invalid internal token" + } + } + } + }, + "/v1/internal/jobs/refresh-upstream-drift/run": { + "post": { + "operationId": "runRefreshUpstreamDriftJob", + "tags": [ + "Internal", + "Jobs" + ], + "summary": "Run the job to recompute upstream ruleset drift inline, bypassing the queue", + "security": [ + { + "LoopOverBearer": [] + } + ], + "responses": { + "200": { + "description": "Job ran inline and returned its result" + }, + "401": { + "description": "Invalid internal token" + } + } + } + }, + "/v1/internal/jobs/file-upstream-drift-issues/run": { + "post": { + "operationId": "runFileUpstreamDriftIssuesJob", + "tags": [ + "Internal", + "Jobs" + ], + "summary": "Run the job to file issues for open upstream drift inline, bypassing the queue", + "security": [ + { + "LoopOverBearer": [] + } + ], + "responses": { + "200": { + "description": "Job ran inline and returned its result" + }, + "401": { + "description": "Invalid internal token" + } + } + } + }, + "/v1/internal/jobs/build-contributor-decision-packs/run": { + "post": { + "operationId": "runBuildContributorDecisionPacksJob", + "tags": [ + "Internal", + "Jobs" + ], + "summary": "Run the job to rebuild contributor decision packs inline, bypassing the queue", + "security": [ + { + "LoopOverBearer": [] + } + ], + "responses": { + "200": { + "description": "Job ran inline and returned its result" + }, + "401": { + "description": "Invalid internal token" + } + } + } + }, + "/v1/internal/jobs/refresh-contributor-activity": { + "post": { + "operationId": "queueRefreshContributorActivityJob", + "tags": [ + "Internal", + "Jobs" + ], + "summary": "Queue a job to refresh cached contributor activity", + "security": [ + { + "LoopOverBearer": [] + } + ], + "responses": { + "202": { + "description": "Job queued" + }, + "401": { + "description": "Invalid internal token" + } + } + } + }, + "/v1/internal/jobs/refresh-contributor-activity/run": { + "post": { + "operationId": "runRefreshContributorActivityJob", + "tags": [ + "Internal", + "Jobs" + ], + "summary": "Run the job to refresh cached contributor activity inline, bypassing the queue", + "security": [ + { + "LoopOverBearer": [] + } + ], + "responses": { + "200": { + "description": "Job ran inline and returned its result" + }, + "401": { + "description": "Invalid internal token" + } + } + } + }, + "/v1/internal/jobs/generate-signal-snapshots/run": { + "post": { + "operationId": "runGenerateSignalSnapshotsJob", + "tags": [ + "Internal", + "Jobs" + ], + "summary": "Run the job to generate signal snapshots inline, bypassing the queue", + "security": [ + { + "LoopOverBearer": [] + } + ], + "responses": { + "200": { + "description": "Job ran inline and returned its result" + }, + "401": { + "description": "Invalid internal token" + } + } + } + }, + "/v1/internal/jobs/generate-weekly-value-report/run": { + "post": { + "operationId": "runGenerateWeeklyValueReportJob", + "tags": [ + "Internal", + "Jobs" + ], + "summary": "Run the job to generate the weekly value report inline, bypassing the queue", + "security": [ + { + "LoopOverBearer": [] + } + ], + "responses": { + "200": { + "description": "Job ran inline and returned its result" + }, + "401": { + "description": "Invalid internal token" + } + } + } + }, + "/v1/internal/jobs/generate-review-recap/run": { + "post": { + "operationId": "runGenerateReviewRecapJob", + "tags": [ + "Internal", + "Jobs" + ], + "summary": "Run the job to generate the maintainer review recap inline, bypassing the queue", + "security": [ + { + "LoopOverBearer": [] + } + ], + "responses": { + "200": { + "description": "Job ran inline and returned its result" + }, + "401": { + "description": "Invalid internal token" + } + } + } + }, + "/v1/internal/jobs/backfill-registered-repos/run": { + "post": { + "operationId": "runBackfillRegisteredReposJob", + "tags": [ + "Internal", + "Jobs" + ], + "summary": "Run the job to backfill registered repository records inline, bypassing the queue", + "security": [ + { + "LoopOverBearer": [] + } + ], + "responses": { + "200": { + "description": "Job ran inline and returned its result" + }, + "401": { + "description": "Invalid internal token" + } + } + } + }, + "/v1/internal/jobs/backfill-repo-segment/run": { + "post": { + "operationId": "runBackfillRepoSegmentJob", + "tags": [ + "Internal", + "Jobs" + ], + "summary": "Run the job to backfill repository segment assignments inline, bypassing the queue", + "security": [ + { + "LoopOverBearer": [] + } + ], + "responses": { + "200": { + "description": "Job ran inline and returned its result" + }, + "401": { + "description": "Invalid internal token" + } + } + } + }, + "/v1/internal/jobs/backfill-pr-details/run": { + "post": { + "operationId": "runBackfillPrDetailsJob", + "tags": [ + "Internal", + "Jobs" + ], + "summary": "Run the job to backfill pull-request detail rows inline, bypassing the queue", + "security": [ + { + "LoopOverBearer": [] + } + ], + "responses": { + "200": { + "description": "Job ran inline and returned its result" + }, + "401": { + "description": "Invalid internal token" + } + } + } + }, + "/v1/internal/jobs/backfill-contributor-gate-history/run": { + "post": { + "operationId": "runBackfillContributorGateHistoryJob", + "tags": [ + "Internal", + "Jobs" + ], + "summary": "Run the job to backfill contributor gate history", + "security": [ + { + "LoopOverBearer": [] + } + ], + "responses": { + "200": { + "description": "Job ran inline and returned its result" + }, + "401": { + "description": "Invalid internal token" + } + } + } + }, + "/v1/internal/jobs/refresh-installation-health/run": { + "post": { + "operationId": "runRefreshInstallationHealthJob", + "tags": [ + "Internal", + "Jobs" + ], + "summary": "Run the job to refresh GitHub App installation health", + "security": [ + { + "LoopOverBearer": [] + } + ], + "responses": { + "200": { + "description": "Job ran inline and returned its result" + }, + "401": { + "description": "Invalid internal token" + } + } + } + }, + "/v1/internal/jobs/rollup-product-usage": { + "post": { + "operationId": "queueRollupProductUsageJob", + "tags": [ + "Internal", + "Jobs" + ], + "summary": "Queue a job to roll up product usage counters", + "security": [ + { + "LoopOverBearer": [] + } + ], + "responses": { + "202": { + "description": "Job queued" + }, + "401": { + "description": "Invalid internal token" + } + } + } + }, + "/v1/internal/jobs/rollup-product-usage/run": { + "post": { + "operationId": "runRollupProductUsageJob", + "tags": [ + "Internal", + "Jobs" + ], + "summary": "Run the job to roll up product usage counters inline, bypassing the queue", + "security": [ + { + "LoopOverBearer": [] + } + ], + "responses": { + "200": { + "description": "Job ran inline and returned its result" + }, + "401": { + "description": "Invalid internal token" + } + } + } + }, + "/v1/internal/jobs/rag-index": { + "post": { + "operationId": "RunRagIndex", + "tags": [ + "Internal", + "Jobs" + ], + "summary": "Run the job to index repository content for retrieval", + "security": [ + { + "LoopOverBearer": [] + } + ], + "responses": { + "200": { + "description": "Job ran inline and returned its result" + }, + "400": { + "description": "Malformed job request" + }, + "401": { + "description": "Invalid internal token" + } + } + } + }, + "/v1/internal/jobs/regate-pr": { + "post": { + "operationId": "RegatePullRequest", + "tags": [ + "Internal", + "Jobs" + ], + "summary": "Run the job to re-run the gate for one pull request", + "security": [ + { + "LoopOverBearer": [] + } + ], + "responses": { + "200": { + "description": "Job ran inline and returned its result" + }, + "400": { + "description": "Malformed job request" + }, + "401": { + "description": "Invalid internal token" + } + } + } + }, + "/v1/internal/status": { + "get": { + "operationId": "getInternalStatus", + "tags": [ + "Internal" + ], + "summary": "Return the control plane's own status", + "security": [ + { + "LoopOverBearer": [] + } + ], + "responses": { + "200": { + "description": "Measurement payload" + }, + "401": { + "description": "Invalid internal token" + } + } + } + }, + "/v1/internal/decision": { + "get": { + "operationId": "getInternalDecision", + "tags": [ + "Internal" + ], + "summary": "Return one recorded gate decision", + "security": [ + { + "LoopOverBearer": [] + } + ], + "responses": { + "200": { + "description": "Measurement payload" + }, + "401": { + "description": "Invalid internal token" + } + } + } + }, + "/v1/internal/parity": { + "get": { + "operationId": "getInternalParity", + "tags": [ + "Internal" + ], + "summary": "Return native-vs-live gate parity measurements", + "security": [ + { + "LoopOverBearer": [] + } + ], + "responses": { + "200": { + "description": "Measurement payload" + }, + "401": { + "description": "Invalid internal token" + } + } + } + }, + "/v1/internal/predicted-agreement": { + "get": { + "operationId": "getInternalPredictedAgreement", + "tags": [ + "Internal" + ], + "summary": "Return predicted-gate agreement measurements", + "security": [ + { + "LoopOverBearer": [] + } + ], + "responses": { + "200": { + "description": "Measurement payload" + }, + "401": { + "description": "Invalid internal token" + } + } + } + }, + "/v1/internal/calibration": { + "get": { + "operationId": "getInternalCalibration", + "tags": [ + "Internal" + ], + "summary": "Return gate calibration measurements", + "security": [ + { + "LoopOverBearer": [] + } + ], + "responses": { + "200": { + "description": "Measurement payload" + }, + "401": { + "description": "Invalid internal token" + } + } + } + }, + "/v1/internal/calibration-trend": { + "get": { + "operationId": "getInternalCalibrationTrend", + "tags": [ + "Internal" + ], + "summary": "Return the calibration trend over time", + "security": [ + { + "LoopOverBearer": [] + } + ], + "responses": { + "200": { + "description": "Measurement payload" + }, + "401": { + "description": "Invalid internal token" + } + } + } + }, + "/v1/internal/calibration/knobs": { + "get": { + "operationId": "getInternalCalibrationKnobs", + "tags": [ + "Internal" + ], + "summary": "Return the tunable calibration knobs and their live values", + "security": [ + { + "LoopOverBearer": [] + } + ], + "responses": { + "200": { + "description": "Measurement payload" + }, + "401": { + "description": "Invalid internal token" + } + } + } + }, + "/v1/internal/calibration/satisfaction-floor": { + "get": { + "operationId": "getInternalSatisfactionFloor", + "tags": [ + "Internal" + ], + "summary": "Return the current satisfaction floor", + "security": [ + { + "LoopOverBearer": [] + } + ], + "responses": { + "200": { + "description": "Measurement payload" + }, + "401": { + "description": "Invalid internal token" + } + } + } + }, + "/v1/internal/audit-labels": { + "get": { + "operationId": "listInternalAuditLabels", + "tags": [ + "Internal" + ], + "summary": "List adjudication labels for the audit corpus", + "security": [ + { + "LoopOverBearer": [] + } + ], + "responses": { + "200": { + "description": "Measurement payload" + }, + "401": { + "description": "Invalid internal token" + } + } + } + }, + "/v1/internal/fairness/contributors": { + "get": { + "operationId": "listInternalFairnessContributors", + "tags": [ + "Internal" + ], + "summary": "List contributor fairness measurements", + "security": [ + { + "LoopOverBearer": [] + } + ], + "responses": { + "200": { + "description": "Measurement payload" + }, + "401": { + "description": "Invalid internal token" + } + } + } + }, + "/v1/internal/fleet/analytics": { + "get": { + "operationId": "getInternalFleetAnalytics", + "tags": [ + "Internal" + ], + "summary": "Return fleet-wide analytics", + "security": [ + { + "LoopOverBearer": [] + } + ], + "responses": { + "200": { + "description": "Measurement payload" + }, + "401": { + "description": "Invalid internal token" + } + } + } + }, + "/v1/internal/ops/stats": { + "get": { + "operationId": "getInternalOpsStats", + "tags": [ + "Internal" + ], + "summary": "Return operational statistics", + "security": [ + { + "LoopOverBearer": [] + } + ], + "responses": { + "200": { + "description": "Measurement payload" + }, + "401": { + "description": "Invalid internal token" + } + } + } + }, + "/v1/internal/retention/preview": { + "get": { + "operationId": "getInternalRetentionPreview", + "tags": [ + "Internal" + ], + "summary": "Preview what a retention sweep would delete", + "security": [ + { + "LoopOverBearer": [] + } + ], + "responses": { + "200": { + "description": "Measurement payload" + }, + "401": { + "description": "Invalid internal token" + } + } + } + }, + "/v1/internal/fairness/contributors/{login}": { + "get": { + "operationId": "getInternalFairnessContributor", + "tags": [ + "Internal" + ], + "summary": "Return one contributor's fairness measurements", + "security": [ + { + "LoopOverBearer": [] + } + ], + "parameters": [ + { + "schema": { + "type": "string" + }, + "required": true, + "name": "login", + "in": "path" + } + ], + "responses": { + "200": { + "description": "Fairness measurements" + }, + "401": { + "description": "Invalid internal token" + }, + "404": { + "description": "No such contributor" + } + } + } + }, + "/v1/internal/audit-labels/adjudicate": { + "post": { + "operationId": "adjudicateAuditLabel", + "tags": [ + "Internal" + ], + "summary": "Record an adjudication for one audit-corpus item", + "security": [ + { + "LoopOverBearer": [] + } + ], + "responses": { + "200": { + "description": "Adjudication recorded" + }, + "400": { + "description": "Malformed adjudication" + }, + "401": { + "description": "Invalid internal token" + } + } + } + }, + "/v1/internal/calibration/loosen-satisfaction-floor": { + "post": { + "operationId": "loosenSatisfactionFloor", + "tags": [ + "Internal" + ], + "summary": "Loosen the satisfaction floor by one calibrated step", + "security": [ + { + "LoopOverBearer": [] + } + ], + "responses": { + "200": { + "description": "Floor updated" + }, + "400": { + "description": "Malformed request" + }, + "401": { + "description": "Invalid internal token" + } + } + } + }, + "/v1/internal/queue-intelligence": { + "post": { + "operationId": "recordQueueIntelligence", + "tags": [ + "Internal" + ], + "summary": "Record a queue-intelligence observation", + "security": [ + { + "LoopOverBearer": [] + } + ], + "responses": { + "202": { + "description": "Observation recorded" + }, + "400": { + "description": "Malformed observation" + }, + "401": { + "description": "Invalid internal token" + } + } + } + }, + "/v1/internal/repos/{owner}/{repo}/contribution-policy": { + "get": { + "operationId": "getRepoContributionPolicyInternal", + "tags": [ + "Internal", + "Repositories" + ], + "summary": "Read a repo's contribution policy from the control plane", + "security": [ + { + "LoopOverBearer": [] + } + ], + "parameters": [ + { + "schema": { + "type": "string" + }, + "required": true, + "name": "owner", + "in": "path" + }, + { + "schema": { + "type": "string" + }, + "required": true, + "name": "repo", + "in": "path" + } + ], + "responses": { + "200": { + "description": "Contribution policy" + }, + "401": { + "description": "Invalid internal token" + } + } + }, + "post": { + "operationId": "setRepoContributionPolicyInternal", + "tags": [ + "Internal", + "Repositories" + ], + "summary": "Write a repo's contribution policy from the control plane", + "security": [ + { + "LoopOverBearer": [] + } + ], + "parameters": [ + { + "schema": { + "type": "string" + }, + "required": true, + "name": "owner", + "in": "path" + }, + { + "schema": { + "type": "string" + }, + "required": true, + "name": "repo", + "in": "path" + } + ], + "responses": { + "200": { + "description": "Policy written" + }, + "400": { + "description": "Malformed policy" + }, + "401": { + "description": "Invalid internal token" + } + } + } + }, + "/v1/internal/repos/{owner}/{repo}/settings": { + "post": { + "operationId": "setRepoSettingsInternal", + "tags": [ + "Internal", + "Repositories" + ], + "summary": "Write a repo's settings from the control plane", + "security": [ + { + "LoopOverBearer": [] + } + ], + "parameters": [ + { + "schema": { + "type": "string" + }, + "required": true, + "name": "owner", + "in": "path" + }, + { + "schema": { + "type": "string" + }, + "required": true, + "name": "repo", + "in": "path" + } + ], + "responses": { + "200": { + "description": "Settings written" + }, + "400": { + "description": "Malformed settings" + }, + "401": { + "description": "Invalid internal token" + } + } + } + }, + "/openapi.json": { + "get": { + "operationId": "getOpenApiDocument", + "tags": [ + "Meta" + ], + "summary": "Return this OpenAPI document", + "responses": { + "200": { + "description": "The OpenAPI 3 document" + } + }, + "security": [] + } + }, + "/loopover/shot": { + "get": { + "operationId": "getLoopoverShot", + "tags": [ + "Meta" + ], + "summary": "Return the LoopOver social preview image", + "responses": { + "200": { + "description": "Preview image" + } + }, + "security": [] + } + }, + "/v1/public/subnet-interface": { + "get": { + "operationId": "getPublicSubnetInterface", + "tags": [ + "Public" + ], + "summary": "Return the public subnet interface description", + "responses": { + "200": { + "description": "Subnet interface" + } + }, + "security": [] + } + }, + "/v1/public/repos/{owner}/{repo}/badge.json": { + "get": { + "operationId": "getRepoBadgeJson", + "tags": [ + "Public" + ], + "summary": "Return a shields.io-compatible badge payload for a repo", + "parameters": [ + { + "schema": { + "type": "string" + }, + "required": true, + "name": "owner", + "in": "path" + }, + { + "schema": { + "type": "string" + }, + "required": true, + "name": "repo", + "in": "path" + } + ], + "responses": { + "200": { + "description": "Badge payload" + } + }, + "security": [] + } + }, + "/v1/public/repos/{owner}/{repo}/badge.svg": { + "get": { + "operationId": "getRepoBadgeSvg", + "tags": [ + "Public" + ], + "summary": "Return a rendered SVG badge for a repo", + "parameters": [ + { + "schema": { + "type": "string" + }, + "required": true, + "name": "owner", + "in": "path" + }, + { + "schema": { + "type": "string" + }, + "required": true, + "name": "repo", + "in": "path" + } + ], + "responses": { + "200": { + "description": "SVG badge" + } + }, + "security": [] + } + }, + "/v1/mcp/finding-taxonomy": { + "get": { + "operationId": "getMcpFindingTaxonomy", + "tags": [ + "MCP" + ], + "summary": "Return the review finding taxonomy the MCP surfaces use", + "responses": { + "200": { + "description": "Finding taxonomy" + } + }, + "security": [] + } + }, + "/v1/mcp/enrichment-analyzers": { + "get": { + "operationId": "getMcpEnrichmentAnalyzers", + "tags": [ + "MCP" + ], + "summary": "Return the review-enrichment analyzers the MCP surfaces expose", + "responses": { + "200": { + "description": "Analyzer descriptions" + } + }, + "security": [] + } + }, + "/v1/drafts": { + "post": { + "operationId": "createReviewDraft", + "tags": [ + "Drafts" + ], + "summary": "Submit a review draft", + "responses": { + "200": { + "description": "Draft accepted" + }, + "400": { + "description": "Malformed draft" + }, + "404": { + "description": "Draft submission is disabled" + } + }, + "security": [] + } + }, + "/v1/drafts/{id}": { + "get": { + "operationId": "getReviewDraft", + "tags": [ + "Drafts" + ], + "summary": "Read one submitted review draft", + "parameters": [ + { + "schema": { + "type": "string" + }, + "required": true, + "name": "id", + "in": "path" + } + ], + "responses": { + "200": { + "description": "Draft" + }, + "404": { + "description": "No such draft, or draft submission is disabled" + } + }, + "security": [] + } + }, + "/v1/drafts/auth/callback": { + "get": { + "operationId": "completeDraftOauth", + "tags": [ + "Drafts" + ], + "summary": "Complete the draft-submission GitHub OAuth flow", + "responses": { + "302": { + "description": "Redirect back to the draft" + }, + "400": { + "description": "Missing or invalid OAuth code" + } + }, + "security": [] + } + }, + "/v1/ams/ingest": { + "post": { + "operationId": "ingestAmsTelemetry", + "tags": [ + "AMS" + ], + "summary": "Ingest an AMS miner telemetry batch", + "security": [ + { + "OrbBearer": [] + } + ], + "responses": { + "202": { + "description": "Batch accepted" + }, + "400": { + "description": "Malformed batch" + }, + "401": { + "description": "Missing or invalid ingest credential" + } + } + } + }, + "/v1/contributors/{login}/ams-notifications": { + "post": { + "operationId": "sendAmsNotification", + "tags": [ + "Contributors", + "AMS" + ], + "summary": "Deliver an AMS notification to a contributor", + "security": [ + { + "LoopOverBearer": [] + }, + { + "LoopOverSessionCookie": [] + } + ], + "parameters": [ + { + "schema": { + "type": "string" + }, + "required": true, + "name": "login", + "in": "path" + } + ], + "responses": { + "202": { + "description": "Notification queued" + }, + "400": { + "description": "Malformed notification" + }, + "401": { + "description": "Missing or invalid token" + } + } + } + }, + "/v1/local/remediation-plan": { + "post": { + "operationId": "buildLocalRemediationPlan", + "tags": [ + "Local" + ], + "summary": "Build a remediation plan from local branch metadata", + "security": [ + { + "LoopOverBearer": [] + }, + { + "LoopOverSessionCookie": [] + } + ], + "responses": { + "200": { + "description": "Remediation plan" + }, + "400": { + "description": "Malformed branch metadata" + }, + "401": { + "description": "Missing or invalid token" + } + } + } + }, + "/v1/loop/request-apr-transfer": { + "post": { + "operationId": "requestAprTransfer", + "tags": [ + "Loop" + ], + "summary": "Request an APR transfer for a rented loop", + "security": [ + { + "LoopOverBearer": [] + }, + { + "LoopOverSessionCookie": [] + } + ], + "responses": { + "200": { + "description": "Transfer requested" + }, + "400": { + "description": "Malformed request" + }, + "401": { + "description": "Missing or invalid token" + } + } + } + }, + "/v1/repos/{owner}/{repo}/pulls/{number}/chat-qa": { + "post": { + "operationId": "askPullRequestChatQuestion", + "tags": [ + "Repositories", + "Review" + ], + "summary": "Ask a question about one pull request's review", + "security": [ + { + "LoopOverBearer": [] + }, + { + "LoopOverSessionCookie": [] + } + ], + "parameters": [ + { + "schema": { + "type": "string" + }, + "required": true, + "name": "owner", + "in": "path" + }, + { + "schema": { + "type": "string" + }, + "required": true, + "name": "repo", + "in": "path" + }, + { + "schema": { + "type": "string" + }, + "required": true, + "name": "number", + "in": "path" + } + ], + "responses": { + "200": { + "description": "Answer" + }, + "400": { + "description": "Malformed question" + }, + "401": { + "description": "Not signed in" + }, + "404": { + "description": "No such pull request" + } + } } } }, diff --git a/src/api/routes.ts b/src/api/routes.ts index bf3bc3858d..e76869a5f1 100644 --- a/src/api/routes.ts +++ b/src/api/routes.ts @@ -1,4 +1,5 @@ import { Hono, type Context } from "hono"; +import { requiresApiToken } from "../auth/route-auth"; import { createWorkerPostHogErrorMiddleware } from "./worker-posthog"; import { z } from "zod"; import { parsePositiveInt } from "../utils/json"; @@ -6870,54 +6871,6 @@ async function isAuthorizedIngest(configuredToken: string | undefined, presented return timingSafeEqual(presentedToken, configuredToken); } -function requiresApiToken(path: string): boolean { - if (path === "/health") return false; - if (path === "/v1/mcp/compatibility") return false; - if (path === "/v1/mcp/finding-taxonomy") return false; - if (path === "/v1/mcp/enrichment-analyzers") return false; - if (/^\/v1\/public\/github\/repos\/[^/]+\/[^/]+\/stats$/.test(path)) return false; - if (/^\/v1\/public\/repos\/[^/]+\/[^/]+\/badge\.(svg|json)$/.test(path)) return false; - if (/^\/v1\/public\/repos\/[^/]+\/[^/]+\/quality$/.test(path)) return false; - if (path === "/v1/public/subnet-interface") return false; - if (path === "/v1/public/stats") return false; - // #9120: this route's own doc comment always claimed "safe unauthenticated" but was missing from this exact - // list, so it 401'd in prod — verified live: subnet-interface/stats/quality answered 200, this one 401'd. - if (path === "/v1/public/decision-ledger/verify") return false; - // #9266: the eval-scores transport is unauthenticated by the same design as every /v1/public/* sibling - // above -- committed to a corpus checksum, independently re-derivable, nothing gated behind a token. - if (path === "/v1/public/eval-scores") return false; - // #9269: the single-row read, added in the SAME PR as its route so the two can never drift the way #9120's - // sibling did. Regex (not a literal) because of the :seq path parameter. - if (/^\/v1\/public\/decision-ledger\/row\/[^/]+$/.test(path)) return false; - // #9270: the published anchor-signing public keys, added in the SAME PR as its route so the two cannot - // drift the way #9120's sibling did. - if (path === "/v1/public/decision-ledger/anchor-key") return false; - // #9271: the public anchor-attempt listing, added in the SAME PR as its route. - if (path === "/v1/public/decision-ledger/anchors") return false; - // #9123: the new public decision-record read route — same unauthenticated posture as its ledger-verify - // sibling immediately above, added in the SAME PR so the two can never drift apart the way #9120 did. The - // pull segment matches any non-slash text (not just digits): an invalid pull number is the ROUTE's 400 to - // return, not the auth gate's 401 — mirrors every other dynamic-segment exemption below (owner/repo use the - // same unvalidated [^/]+). - if (/^\/v1\/public\/decision-records\/[^/]+\/[^/]+\/[^/]+$/.test(path)) return false; - if (path === "/openapi.json") return false; - if (path === "/mcp") return false; - // Public OAuth draft-submission flow (LOOPOVER_REVIEW_DRAFT): the submission entry points are unauthenticated - // by design. The handlers themselves 404 when the flag is off, so this exemption is inert flag-OFF. - if (path === "/v1/drafts" || path.startsWith("/v1/drafts/")) return false; - if (path.startsWith("/v1/auth/")) return false; - if (path === "/v1/github/webhook") return false; - if (path === "/v1/orb/webhook") return false; - if (path === "/v1/orb/relay") return false; - if (path === "/v1/orb/oauth/callback") return false; - if (path === "/v1/orb/token") return false; - if (path === "/v1/orb/relay/register") return false; - if (path === "/v1/orb/relay/pull") return false; - if (path === "/v1/orb/ingest") return false; - if (path === "/v1/ams/ingest") return false; - if (path.startsWith("/v1/internal/")) return false; - return path.startsWith("/v1/"); -} // Unauthenticated, cookie-free, aggregate-only public GET endpoints (health check, homepage stats counter, // per-repo public stats badge) -- open to any origin via a separate, credential-free CORS branch above. diff --git a/src/auth/route-auth.ts b/src/auth/route-auth.ts new file mode 100644 index 0000000000..538675885e --- /dev/null +++ b/src/auth/route-auth.ts @@ -0,0 +1,74 @@ +// Which routes require a credential, as ONE model (#9531). +// +// Extracted from src/api/routes.ts so the OpenAPI builder can read the same predicate the app gates +// on. It cannot import routes.ts: that module reaches `cloudflare:` imports, and the spec is +// generated by a plain Node script (scripts/write-ui-openapi.ts) whose loader cannot resolve them. +// +// Before this, src/openapi/spec.ts carried `isProtectedPath` -- a second, path-prefix approximation +// that treated every `/v1/*` route as protected bar a short literal list. It had already drifted: +// the whole `/v1/public/decision-ledger/*` family answers 200 to an anonymous caller and was +// published as needing a bearer. A pure module both sides import is what makes that class of drift +// impossible rather than merely fixed. +// +// PURE by construction: string predicates only, no env, no I/O, no Workers types. Keep it that way +// -- the moment this file needs a runtime binding, the spec builder stops working again. + +/** + * The ONE model of which routes are credential-gated (#9531). + * + * Exported so src/openapi/spec.ts derives its security stanzas from this rather than from a second, + * path-prefix approximation of it. `isProtectedPath` was that approximation, and it had already + * drifted: it treated every `/v1/*` path as protected except a short literal list, which disagreed + * with this function on the whole `/v1/public/decision-ledger/*` family among others -- so the + * published document advertised a bearer requirement on routes that answer 200 to an anonymous + * caller. Deriving from the enforcer means the document cannot say something the middleware does + * not do. + */ +export function requiresApiToken(path: string): boolean { + if (path === "/health") return false; + if (path === "/v1/mcp/compatibility") return false; + if (path === "/v1/mcp/finding-taxonomy") return false; + if (path === "/v1/mcp/enrichment-analyzers") return false; + if (/^\/v1\/public\/github\/repos\/[^/]+\/[^/]+\/stats$/.test(path)) return false; + if (/^\/v1\/public\/repos\/[^/]+\/[^/]+\/badge\.(svg|json)$/.test(path)) return false; + if (/^\/v1\/public\/repos\/[^/]+\/[^/]+\/quality$/.test(path)) return false; + if (path === "/v1/public/subnet-interface") return false; + if (path === "/v1/public/stats") return false; + // #9120: this route's own doc comment always claimed "safe unauthenticated" but was missing from this exact + // list, so it 401'd in prod — verified live: subnet-interface/stats/quality answered 200, this one 401'd. + if (path === "/v1/public/decision-ledger/verify") return false; + // #9266: the eval-scores transport is unauthenticated by the same design as every /v1/public/* sibling + // above -- committed to a corpus checksum, independently re-derivable, nothing gated behind a token. + if (path === "/v1/public/eval-scores") return false; + // #9269: the single-row read, added in the SAME PR as its route so the two can never drift the way #9120's + // sibling did. Regex (not a literal) because of the :seq path parameter. + if (/^\/v1\/public\/decision-ledger\/row\/[^/]+$/.test(path)) return false; + // #9270: the published anchor-signing public keys, added in the SAME PR as its route so the two cannot + // drift the way #9120's sibling did. + if (path === "/v1/public/decision-ledger/anchor-key") return false; + // #9271: the public anchor-attempt listing, added in the SAME PR as its route. + if (path === "/v1/public/decision-ledger/anchors") return false; + // #9123: the new public decision-record read route — same unauthenticated posture as its ledger-verify + // sibling immediately above, added in the SAME PR so the two can never drift apart the way #9120 did. The + // pull segment matches any non-slash text (not just digits): an invalid pull number is the ROUTE's 400 to + // return, not the auth gate's 401 — mirrors every other dynamic-segment exemption below (owner/repo use the + // same unvalidated [^/]+). + if (/^\/v1\/public\/decision-records\/[^/]+\/[^/]+\/[^/]+$/.test(path)) return false; + if (path === "/openapi.json") return false; + if (path === "/mcp") return false; + // Public OAuth draft-submission flow (LOOPOVER_REVIEW_DRAFT): the submission entry points are unauthenticated + // by design. The handlers themselves 404 when the flag is off, so this exemption is inert flag-OFF. + if (path === "/v1/drafts" || path.startsWith("/v1/drafts/")) return false; + if (path.startsWith("/v1/auth/")) return false; + if (path === "/v1/github/webhook") return false; + if (path === "/v1/orb/webhook") return false; + if (path === "/v1/orb/relay") return false; + if (path === "/v1/orb/oauth/callback") return false; + if (path === "/v1/orb/token") return false; + if (path === "/v1/orb/relay/register") return false; + if (path === "/v1/orb/relay/pull") return false; + if (path === "/v1/orb/ingest") return false; + if (path === "/v1/ams/ingest") return false; + if (path.startsWith("/v1/internal/")) return false; + return path.startsWith("/v1/"); +} diff --git a/src/openapi/define-route.ts b/src/openapi/define-route.ts index 06464eab08..8c8e0b350e 100644 --- a/src/openapi/define-route.ts +++ b/src/openapi/define-route.ts @@ -21,7 +21,7 @@ import { z } from "zod"; * declaration the runtime gate enforces -- replacing `isProtectedPath()`, a second, path-prefix * model of the same policy that had already drifted out of agreement with it. */ -export type RouteAuth = "public" | "token" | "session" | "internal"; +export type RouteAuth = "public" | "token" | "session" | "internal" | "orb" | "webhook"; export type RouteMethod = "get" | "post" | "put" | "patch" | "delete"; @@ -46,16 +46,44 @@ export type DefineRouteOptions; }; +/** + * Every `:param` in the path, as an OpenAPI `in: path` parameter schema. + * + * Emitted automatically rather than declared per route: a templated segment with no matching + * parameter is a schema-validation warning (Cloudflare 30046) and leaves a generated client with a + * URL it cannot fill, and there is no case where a path parameter is optional -- so the correct + * declaration is fully derivable from the path itself and nothing is gained by asking for it twice. + */ +function pathParameters(path: string): { params: z.ZodObject } | undefined { + const names = [...path.matchAll(/:([A-Za-z0-9_]+)/g)].map((match) => match[1]!); + if (names.length === 0) return undefined; + return { params: z.object(Object.fromEntries(names.map((name) => [name, z.string()]))) }; +} + /** Hono writes `:param`; OpenAPI writes `{param}`. */ function toSpecPath(path: string): string { return path.replace(/:([A-Za-z0-9_]+)/g, "{$1}"); } -/** `public` routes carry no security stanza; everything else accepts either credential the API - * actually supports. Internal routes are bearer-only -- there is no cookie path to them. */ +/** + * The security stanza a declared auth level emits. + * + * `public` carries none. `internal` is bearer-only -- there is no cookie path to it. `orb` and + * `webhook` (#9531) exist because the ORB ingress genuinely does not authenticate the way the rest + * of the API does, and collapsing them into `public` would publish a document that says these + * routes need no credential at all. They need a DIFFERENT one: an ORB-issued bearer for the relay + * and token endpoints, an HMAC signature header for the webhook. `requiresApiToken()` exempts both + * from the LoopOver bearer check, which is what made them look public to the old path-prefix model. + */ function securityFor(auth: RouteAuth): RouteConfig["security"] { - if (auth === "public") return undefined; + // `[]`, not undefined: an empty security array is OpenAPI's explicit "this operation needs no + // credential", where an ABSENT one means "not stated". The distinction is load-bearing here -- + // applySecurityMetadata fills in the legacy `registerPath` calls that never declared anything, and + // it can only tell those apart from a deliberately public route if public says so out loud. + if (auth === "public") return []; if (auth === "internal") return [{ LoopOverBearer: [] }]; + if (auth === "orb") return [{ OrbBearer: [] }]; + if (auth === "webhook") return [{ OrbWebhookSignature: [] }]; return [{ LoopOverBearer: [] }, { LoopOverSessionCookie: [] }]; } @@ -138,6 +166,11 @@ export function registerRouteSpec(registry: OpenAPIRegistry, options: RouteSpecO registry.registerPath({ method: options.method, path: toSpecPath(options.path), + request: { + ...(pathParameters(options.path) ?? {}), + ...(options.request?.body ? { body: { content: { "application/json": { schema: options.request.body } } } } : {}), + ...(options.request?.query ? { query: options.request.query } : {}), + }, operationId: options.operationId, tags: options.tags, summary: options.summary, diff --git a/src/openapi/internal-and-public-route-specs.ts b/src/openapi/internal-and-public-route-specs.ts new file mode 100644 index 0000000000..6c5a60ade4 --- /dev/null +++ b/src/openapi/internal-and-public-route-specs.ts @@ -0,0 +1,357 @@ +// Spec entries for the remaining internal, public, and miscellaneous routes (#9531, batch 2). +// +// Completes the ratchet: with these the published document describes every route `createApp()` +// serves, and src/openapi/unspecced-routes-baseline.json is deleted. +// +// The `/run` suffix pattern is the biggest family here and worth naming: nineteen internal job +// routes exist in two forms -- a bare POST that ENQUEUES the job onto the durable queue, and a +// `/run` sibling that executes it inline and returns the result. Both are real, both are called +// (the queue path in production, the inline path by the ops runbook when a queue is wedged), and +// the two answer with different status codes. Registering them from one table keeps that pairing +// visible instead of leaving nineteen near-identical stanzas to drift apart by hand. +import type { OpenAPIRegistry } from "@asteasolutions/zod-to-openapi"; +import { registerRouteSpec, type RouteAuth, type RouteMethod } from "./define-route"; + +type SpecEntry = { + method: RouteMethod; + path: string; + operationId: string; + tags: [string, ...string[]]; + summary: string; + auth: RouteAuth; + responses: Record; +}; + +const INTERNAL_AUTH = { 401: { description: "Invalid internal token" } }; +const QUEUED = { 202: { description: "Job queued" }, ...INTERNAL_AUTH }; +const RAN = { 200: { description: "Job ran inline and returned its result" }, ...INTERNAL_AUTH }; + +/** `[path segment, operationId stem, human summary]` for the jobs that have BOTH forms. */ +const JOB_PAIRS: ReadonlyArray = [ + ["refresh-registry", "RefreshRegistry", "refresh the Gittensor registry snapshot"], + ["refresh-scoring-model", "RefreshScoringModel", "refresh the active scoring model"], + ["refresh-upstream-drift", "RefreshUpstreamDrift", "recompute upstream ruleset drift"], + ["file-upstream-drift-issues", "FileUpstreamDriftIssues", "file issues for open upstream drift"], + ["build-contributor-decision-packs", "BuildContributorDecisionPacks", "rebuild contributor decision packs"], + ["refresh-contributor-activity", "RefreshContributorActivity", "refresh cached contributor activity"], + ["generate-signal-snapshots", "GenerateSignalSnapshots", "generate signal snapshots"], + ["generate-weekly-value-report", "GenerateWeeklyValueReport", "generate the weekly value report"], + ["generate-review-recap", "GenerateReviewRecap", "generate the maintainer review recap"], + ["backfill-registered-repos", "BackfillRegisteredRepos", "backfill registered repository records"], + ["backfill-repo-segment", "BackfillRepoSegment", "backfill repository segment assignments"], + ["backfill-pr-details", "BackfillPrDetails", "backfill pull-request detail rows"], + ["rollup-product-usage", "RollupProductUsage", "roll up product usage counters"], +]; + +/** Jobs that exist only in the bare (enqueue-or-run) form. */ +const SINGLE_JOBS: ReadonlyArray = [ + ["rag-index", "RunRagIndex", "index repository content for retrieval"], + ["regate-pr", "RegatePullRequest", "re-run the gate for one pull request"], +]; + +/** + * Jobs that exist ONLY in the `/run` form, with no enqueue sibling. + * + * Not an oversight in the routes -- both are operator-triggered repairs with no scheduled trigger, + * so there is nothing to enqueue them from. Listed separately because assuming the pair was + * symmetric published two operations no route served, which is precisely what the ratchet's + * "never publishes an operation for a route the app does not serve" direction caught. + */ +const RUN_ONLY_JOBS: ReadonlyArray = [ + ["backfill-contributor-gate-history", "BackfillContributorGateHistory", "backfill contributor gate history"], + ["refresh-installation-health", "RefreshInstallationHealth", "refresh GitHub App installation health"], +]; + +function jobRoutes(): SpecEntry[] { + const entries: SpecEntry[] = []; + for (const [segment, stem, summary] of JOB_PAIRS) { + entries.push({ + method: "post", + path: `/v1/internal/jobs/${segment}`, + operationId: `queue${stem}Job`, + tags: ["Internal", "Jobs"], + summary: `Queue a job to ${summary}`, + auth: "internal", + responses: QUEUED, + }); + entries.push({ + method: "post", + path: `/v1/internal/jobs/${segment}/run`, + operationId: `run${stem}Job`, + tags: ["Internal", "Jobs"], + summary: `Run the job to ${summary} inline, bypassing the queue`, + auth: "internal", + responses: RAN, + }); + } + for (const [segment, stem, summary] of RUN_ONLY_JOBS) { + entries.push({ + method: "post", + path: `/v1/internal/jobs/${segment}/run`, + operationId: `run${stem}Job`, + tags: ["Internal", "Jobs"], + summary: `Run the job to ${summary}`, + auth: "internal", + responses: RAN, + }); + } + for (const [segment, operationId, summary] of SINGLE_JOBS) { + entries.push({ + method: "post", + path: `/v1/internal/jobs/${segment}`, + operationId, + tags: ["Internal", "Jobs"], + summary: `Run the job to ${summary}`, + auth: "internal", + responses: { ...RAN, 400: { description: "Malformed job request" } }, + }); + } + return entries; +} + +const INTERNAL_READS: SpecEntry[] = [ + ["/v1/internal/status", "getInternalStatus", "Return the control plane's own status"], + ["/v1/internal/decision", "getInternalDecision", "Return one recorded gate decision"], + ["/v1/internal/parity", "getInternalParity", "Return native-vs-live gate parity measurements"], + ["/v1/internal/predicted-agreement", "getInternalPredictedAgreement", "Return predicted-gate agreement measurements"], + ["/v1/internal/calibration", "getInternalCalibration", "Return gate calibration measurements"], + ["/v1/internal/calibration-trend", "getInternalCalibrationTrend", "Return the calibration trend over time"], + ["/v1/internal/calibration/knobs", "getInternalCalibrationKnobs", "Return the tunable calibration knobs and their live values"], + ["/v1/internal/calibration/satisfaction-floor", "getInternalSatisfactionFloor", "Return the current satisfaction floor"], + ["/v1/internal/audit-labels", "listInternalAuditLabels", "List adjudication labels for the audit corpus"], + ["/v1/internal/fairness/contributors", "listInternalFairnessContributors", "List contributor fairness measurements"], + ["/v1/internal/fleet/analytics", "getInternalFleetAnalytics", "Return fleet-wide analytics"], + ["/v1/internal/ops/stats", "getInternalOpsStats", "Return operational statistics"], + ["/v1/internal/retention/preview", "getInternalRetentionPreview", "Preview what a retention sweep would delete"], +].map(([path, operationId, summary]) => ({ + method: "get" as const, + path: path!, + operationId: operationId!, + tags: ["Internal"] as [string, ...string[]], + summary: summary!, + auth: "internal" as const, + responses: { 200: { description: "Measurement payload" }, ...INTERNAL_AUTH }, +})); + +const INTERNAL_OTHER: SpecEntry[] = [ + { + method: "get", + path: "/v1/internal/fairness/contributors/:login", + operationId: "getInternalFairnessContributor", + tags: ["Internal"], + summary: "Return one contributor's fairness measurements", + auth: "internal", + responses: { 200: { description: "Fairness measurements" }, 404: { description: "No such contributor" }, ...INTERNAL_AUTH }, + }, + { + method: "post", + path: "/v1/internal/audit-labels/adjudicate", + operationId: "adjudicateAuditLabel", + tags: ["Internal"], + summary: "Record an adjudication for one audit-corpus item", + auth: "internal", + responses: { 200: { description: "Adjudication recorded" }, 400: { description: "Malformed adjudication" }, ...INTERNAL_AUTH }, + }, + { + method: "post", + path: "/v1/internal/calibration/loosen-satisfaction-floor", + operationId: "loosenSatisfactionFloor", + tags: ["Internal"], + summary: "Loosen the satisfaction floor by one calibrated step", + auth: "internal", + responses: { 200: { description: "Floor updated" }, 400: { description: "Malformed request" }, ...INTERNAL_AUTH }, + }, + { + method: "post", + path: "/v1/internal/queue-intelligence", + operationId: "recordQueueIntelligence", + tags: ["Internal"], + summary: "Record a queue-intelligence observation", + auth: "internal", + responses: { 202: { description: "Observation recorded" }, 400: { description: "Malformed observation" }, ...INTERNAL_AUTH }, + }, + { + method: "get", + path: "/v1/internal/repos/:owner/:repo/contribution-policy", + operationId: "getRepoContributionPolicyInternal", + tags: ["Internal", "Repositories"], + summary: "Read a repo's contribution policy from the control plane", + auth: "internal", + responses: { 200: { description: "Contribution policy" }, ...INTERNAL_AUTH }, + }, + { + method: "post", + path: "/v1/internal/repos/:owner/:repo/contribution-policy", + operationId: "setRepoContributionPolicyInternal", + tags: ["Internal", "Repositories"], + summary: "Write a repo's contribution policy from the control plane", + auth: "internal", + responses: { 200: { description: "Policy written" }, 400: { description: "Malformed policy" }, ...INTERNAL_AUTH }, + }, + { + method: "post", + path: "/v1/internal/repos/:owner/:repo/settings", + operationId: "setRepoSettingsInternal", + tags: ["Internal", "Repositories"], + summary: "Write a repo's settings from the control plane", + auth: "internal", + responses: { 200: { description: "Settings written" }, 400: { description: "Malformed settings" }, ...INTERNAL_AUTH }, + }, +]; + +const PUBLIC_ROUTES: SpecEntry[] = [ + { + method: "get", + path: "/openapi.json", + operationId: "getOpenApiDocument", + tags: ["Meta"], + summary: "Return this OpenAPI document", + auth: "public", + responses: { 200: { description: "The OpenAPI 3 document" } }, + }, + { + method: "get", + path: "/loopover/shot", + operationId: "getLoopoverShot", + tags: ["Meta"], + summary: "Return the LoopOver social preview image", + auth: "public", + responses: { 200: { description: "Preview image" } }, + }, + { + method: "get", + path: "/v1/public/subnet-interface", + operationId: "getPublicSubnetInterface", + tags: ["Public"], + summary: "Return the public subnet interface description", + auth: "public", + responses: { 200: { description: "Subnet interface" } }, + }, + { + method: "get", + path: "/v1/public/repos/:owner/:repo/badge.json", + operationId: "getRepoBadgeJson", + tags: ["Public"], + summary: "Return a shields.io-compatible badge payload for a repo", + auth: "public", + responses: { 200: { description: "Badge payload" } }, + }, + { + method: "get", + path: "/v1/public/repos/:owner/:repo/badge.svg", + operationId: "getRepoBadgeSvg", + tags: ["Public"], + summary: "Return a rendered SVG badge for a repo", + auth: "public", + responses: { 200: { description: "SVG badge" } }, + }, + { + method: "get", + path: "/v1/mcp/finding-taxonomy", + operationId: "getMcpFindingTaxonomy", + tags: ["MCP"], + summary: "Return the review finding taxonomy the MCP surfaces use", + auth: "public", + responses: { 200: { description: "Finding taxonomy" } }, + }, + { + method: "get", + path: "/v1/mcp/enrichment-analyzers", + operationId: "getMcpEnrichmentAnalyzers", + tags: ["MCP"], + summary: "Return the review-enrichment analyzers the MCP surfaces expose", + auth: "public", + responses: { 200: { description: "Analyzer descriptions" } }, + }, +]; + +const MISC_ROUTES: SpecEntry[] = [ + { + method: "post", + path: "/v1/drafts", + operationId: "createReviewDraft", + tags: ["Drafts"], + // Unauthenticated by design (the OAuth draft-submission flow); the handlers 404 when the + // LOOPOVER_REVIEW_DRAFT flag is off, so the exemption is inert flag-OFF. + auth: "public", + summary: "Submit a review draft", + responses: { 200: { description: "Draft accepted" }, 400: { description: "Malformed draft" }, 404: { description: "Draft submission is disabled" } }, + }, + { + method: "get", + path: "/v1/drafts/:id", + operationId: "getReviewDraft", + tags: ["Drafts"], + auth: "public", + summary: "Read one submitted review draft", + responses: { 200: { description: "Draft" }, 404: { description: "No such draft, or draft submission is disabled" } }, + }, + { + method: "get", + path: "/v1/drafts/auth/callback", + operationId: "completeDraftOauth", + tags: ["Drafts"], + auth: "public", + summary: "Complete the draft-submission GitHub OAuth flow", + responses: { 302: { description: "Redirect back to the draft" }, 400: { description: "Missing or invalid OAuth code" } }, + }, + { + method: "post", + path: "/v1/ams/ingest", + operationId: "ingestAmsTelemetry", + tags: ["AMS"], + // Its own shared-secret header, like the ORB ingress -- not a LoopOver bearer. + auth: "orb", + summary: "Ingest an AMS miner telemetry batch", + responses: { 202: { description: "Batch accepted" }, 400: { description: "Malformed batch" }, 401: { description: "Missing or invalid ingest credential" } }, + }, + { + method: "post", + path: "/v1/contributors/:login/ams-notifications", + operationId: "sendAmsNotification", + tags: ["Contributors", "AMS"], + summary: "Deliver an AMS notification to a contributor", + auth: "token", + responses: { 202: { description: "Notification queued" }, 400: { description: "Malformed notification" }, 401: { description: "Missing or invalid token" } }, + }, + { + method: "post", + path: "/v1/local/remediation-plan", + operationId: "buildLocalRemediationPlan", + tags: ["Local"], + summary: "Build a remediation plan from local branch metadata", + auth: "token", + responses: { 200: { description: "Remediation plan" }, 400: { description: "Malformed branch metadata" }, 401: { description: "Missing or invalid token" } }, + }, + { + method: "post", + path: "/v1/loop/request-apr-transfer", + operationId: "requestAprTransfer", + tags: ["Loop"], + summary: "Request an APR transfer for a rented loop", + auth: "token", + responses: { 200: { description: "Transfer requested" }, 400: { description: "Malformed request" }, 401: { description: "Missing or invalid token" } }, + }, + { + method: "post", + path: "/v1/repos/:owner/:repo/pulls/:number/chat-qa", + operationId: "askPullRequestChatQuestion", + tags: ["Repositories", "Review"], + summary: "Ask a question about one pull request's review", + auth: "session", + responses: { 200: { description: "Answer" }, 400: { description: "Malformed question" }, 401: { description: "Not signed in" }, 404: { description: "No such pull request" } }, + }, +]; + +export function registerInternalAndPublicRouteSpecs(registry: OpenAPIRegistry): void { + for (const entry of INTERNAL_AND_PUBLIC_ROUTE_SPECS) registerRouteSpec(registry, entry); +} + +/** Exported for the auth-parity meta-test. */ +export const INTERNAL_AND_PUBLIC_ROUTE_SPECS: readonly SpecEntry[] = [ + ...jobRoutes(), + ...INTERNAL_READS, + ...INTERNAL_OTHER, + ...PUBLIC_ROUTES, + ...MISC_ROUTES, +]; diff --git a/src/openapi/orb-and-control-route-specs.ts b/src/openapi/orb-and-control-route-specs.ts new file mode 100644 index 0000000000..f0a8bcd7e9 --- /dev/null +++ b/src/openapi/orb-and-control-route-specs.ts @@ -0,0 +1,336 @@ +// Spec entries for the ORB ingress, the control-panel app surface, and the per-repo key/settings +// routes (#9531, batch 1 of the ratchet). +// +// These 24 routes served real traffic while the published document said nothing about them -- the +// whole ORB management surface among them, which is the precondition for #9522's management tools. +// +// SPEC-ONLY, deliberately. `registerRouteSpec` contributes a correct operation without moving the +// handler through `defineRoute`'s validating wrapper. Moving handlers is the other half of #9531 and +// happens per family; separating the two means the document stops lying about what the app serves +// now, rather than after every handler has been rewritten. Each entry still carries the things the +// old hand-registered operations never did: a stable operationId, real tags, and an auth level that +// derives its security stanza rather than having one bolted on by path prefix. +import type { OpenAPIRegistry } from "@asteasolutions/zod-to-openapi"; +import { registerRouteSpec, type RouteAuth, type RouteMethod } from "./define-route"; + +type SpecEntry = { + method: RouteMethod; + path: string; + operationId: string; + tags: [string, ...string[]]; + summary: string; + auth: RouteAuth; + responses: Record; +}; + +/** Shared by every ORB-authenticated endpoint. */ +const ORB_AUTH_RESPONSES = { 401: { description: "Missing or invalid ORB instance token" } }; +const INTERNAL_AUTH_RESPONSES = { 401: { description: "Invalid internal token" } }; +const SESSION_AUTH_RESPONSES = { 401: { description: "Not signed in" }, 403: { description: "Insufficient control-panel role" } }; + +const ORB_ROUTES: SpecEntry[] = [ + { + method: "post", + path: "/v1/orb/token", + operationId: "mintOrbInstanceToken", + tags: ["ORB"], + summary: "Mint an ORB instance token", + // The minting endpoint is reached WITH the enrollment secret, not with a token it has yet to + // issue -- so `orb`, not `public`: a credential is required, just not a LoopOver bearer. + auth: "orb", + responses: { 200: { description: "Instance token issued" }, 400: { description: "Malformed enrollment request" }, ...ORB_AUTH_RESPONSES }, + }, + { + method: "post", + path: "/v1/orb/relay", + operationId: "relayOrbEvent", + tags: ["ORB"], + summary: "Relay one ORB event to the hosted control plane", + auth: "orb", + responses: { 202: { description: "Event accepted for processing" }, 400: { description: "Malformed relay payload" }, ...ORB_AUTH_RESPONSES }, + }, + { + method: "post", + path: "/v1/orb/relay/register", + operationId: "registerOrbRelayInstance", + tags: ["ORB"], + summary: "Register a self-hosted ORB instance with the relay", + auth: "orb", + responses: { 200: { description: "Instance registered" }, 400: { description: "Malformed registration" }, ...ORB_AUTH_RESPONSES }, + }, + { + method: "post", + path: "/v1/orb/relay/pull", + operationId: "pullOrbRelayWork", + tags: ["ORB"], + summary: "Pull queued relay work for an ORB instance", + auth: "orb", + responses: { 200: { description: "Queued work, possibly empty" }, ...ORB_AUTH_RESPONSES }, + }, + { + method: "post", + path: "/v1/orb/webhook", + operationId: "receiveOrbWebhook", + tags: ["ORB"], + // Signature-verified, not bearer-authenticated -- the one route whose credential is a header + // over the raw body. + auth: "webhook", + summary: "Receive a signed ORB webhook", + responses: { 202: { description: "Webhook accepted" }, 400: { description: "Malformed webhook body" }, 401: { description: "Signature verification failed" } }, + }, + { + method: "get", + path: "/v1/orb/oauth/callback", + operationId: "completeOrbOauth", + tags: ["ORB"], + // Genuinely public: the browser arrives here from GitHub with a one-time code and no credential + // of its own; the code IS the proof. + auth: "public", + summary: "Complete the ORB GitHub OAuth flow", + responses: { 302: { description: "Redirect back to the ORB instance" }, 400: { description: "Missing or invalid OAuth code" } }, + }, +]; + +const INTERNAL_ORB_ROUTES: SpecEntry[] = [ + { + method: "get", + path: "/v1/internal/orb/instances", + operationId: "listOrbInstances", + tags: ["ORB", "Internal"], + summary: "List registered ORB instances", + auth: "internal", + responses: { 200: { description: "Registered instances" }, ...INTERNAL_AUTH_RESPONSES }, + }, + { + method: "post", + path: "/v1/internal/orb/instances/register", + operationId: "registerOrbInstanceInternal", + tags: ["ORB", "Internal"], + summary: "Register an ORB instance from the control plane", + auth: "internal", + responses: { 200: { description: "Instance registered" }, 400: { description: "Malformed registration" }, ...INTERNAL_AUTH_RESPONSES }, + }, + { + method: "get", + path: "/v1/internal/orb/installations", + operationId: "listOrbInstallations", + tags: ["ORB", "Internal"], + summary: "List GitHub App installations known to the ORB control plane", + auth: "internal", + responses: { 200: { description: "Installations" }, ...INTERNAL_AUTH_RESPONSES }, + }, + { + method: "post", + path: "/v1/internal/orb/installations/register", + operationId: "registerOrbInstallation", + tags: ["ORB", "Internal"], + summary: "Register a GitHub App installation with the ORB control plane", + auth: "internal", + responses: { 200: { description: "Installation registered" }, 400: { description: "Malformed registration" }, ...INTERNAL_AUTH_RESPONSES }, + }, + { + method: "post", + path: "/v1/internal/orb/installations/backfill", + operationId: "backfillOrbInstallations", + tags: ["ORB", "Internal"], + summary: "Backfill ORB installation records from GitHub", + auth: "internal", + responses: { 202: { description: "Backfill queued" }, ...INTERNAL_AUTH_RESPONSES }, + }, + { + method: "post", + path: "/v1/internal/orb/enrollments", + operationId: "createOrbEnrollment", + tags: ["ORB", "Internal"], + summary: "Create an ORB instance enrollment", + auth: "internal", + responses: { 200: { description: "Enrollment created" }, 400: { description: "Malformed enrollment" }, ...INTERNAL_AUTH_RESPONSES }, + }, + { + method: "post", + path: "/v1/internal/orb/enrollments/:enrollId/revoke", + operationId: "revokeOrbEnrollment", + tags: ["ORB", "Internal"], + summary: "Revoke an ORB instance enrollment", + auth: "internal", + responses: { 200: { description: "Enrollment revoked" }, 404: { description: "No such enrollment" }, ...INTERNAL_AUTH_RESPONSES }, + }, +]; + +const APP_ROUTES: SpecEntry[] = [ + { + method: "get", + path: "/v1/app/installations", + operationId: "listAppInstallations", + tags: ["Control panel"], + summary: "List the GitHub App installations the caller can administer", + auth: "session", + responses: { 200: { description: "Installations" }, ...SESSION_AUTH_RESPONSES }, + }, + { + method: "get", + path: "/v1/app/installations/:id/health", + operationId: "getAppInstallationHealth", + tags: ["Control panel"], + summary: "Return one installation's health summary", + auth: "session", + responses: { 200: { description: "Installation health" }, 404: { description: "No such installation" }, ...SESSION_AUTH_RESPONSES }, + }, + { + method: "get", + path: "/v1/app/installations/:id/repair", + operationId: "getAppInstallationRepair", + tags: ["Control panel"], + summary: "Return the repair plan for an unhealthy installation", + auth: "session", + responses: { 200: { description: "Repair plan" }, 404: { description: "No such installation" }, ...SESSION_AUTH_RESPONSES }, + }, + { + method: "post", + path: "/v1/app/installations/:id/repair/refresh", + operationId: "refreshAppInstallationRepair", + tags: ["Control panel"], + summary: "Recompute an installation's repair plan", + auth: "session", + responses: { 200: { description: "Repair plan recomputed" }, 404: { description: "No such installation" }, ...SESSION_AUTH_RESPONSES }, + }, + { + method: "put", + path: "/v1/app/installations/:id/agent/bulk-settings", + operationId: "bulkUpdateInstallationAgentSettings", + tags: ["Control panel", "Agent automation"], + summary: "Apply agent settings across every repo in an installation", + auth: "session", + responses: { 200: { description: "Settings applied" }, 400: { description: "Malformed settings" }, 404: { description: "No such installation" }, ...SESSION_AUTH_RESPONSES }, + }, + { + method: "get", + path: "/v1/app/kill-switch", + operationId: "getFleetKillSwitch", + tags: ["Control panel", "Operations"], + summary: "Read the fleet-wide agent kill switch", + auth: "session", + responses: { 200: { description: "Kill-switch state" }, ...SESSION_AUTH_RESPONSES }, + }, + { + method: "post", + path: "/v1/app/kill-switch", + operationId: "setFleetKillSwitch", + tags: ["Control panel", "Operations"], + summary: "Set the fleet-wide agent kill switch", + auth: "session", + responses: { 200: { description: "Kill switch updated" }, 400: { description: "Malformed request" }, ...SESSION_AUTH_RESPONSES }, + }, + { + method: "post", + path: "/v1/app/fleet/config-push", + operationId: "pushFleetConfig", + tags: ["Control panel", "Operations"], + summary: "Push a configuration change across the fleet", + auth: "session", + responses: { 202: { description: "Config push queued" }, 400: { description: "Malformed push" }, ...SESSION_AUTH_RESPONSES }, + }, + { + method: "post", + path: "/v1/app/miner-dashboard/refresh", + operationId: "refreshMinerDashboard", + tags: ["Control panel"], + summary: "Recompute the miner dashboard aggregates", + auth: "session", + responses: { 202: { description: "Refresh queued" }, ...SESSION_AUTH_RESPONSES }, + }, +]; + +/** The per-repo BYO-key and settings routes. Each key route exists twice -- once maintainer-facing + * under /v1/repos, once internal -- and the pair is specced together so they cannot drift. */ +const REPO_ROUTES: SpecEntry[] = [ + ...(["ai-key", "linear-key"] as const).flatMap((kind): SpecEntry[] => { + const label = kind === "ai-key" ? "AI provider" : "Linear"; + const Kind = kind === "ai-key" ? "Ai" : "Linear"; + return [ + { + method: "get", + path: `/v1/repos/:owner/:repo/${kind}`, + operationId: `get${Kind}Key`, + 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 }, + }, + { + method: "post", + path: `/v1/repos/:owner/:repo/${kind}`, + operationId: `set${Kind}Key`, + 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 }, + }, + { + method: "delete", + path: `/v1/repos/:owner/:repo/${kind}`, + operationId: `delete${Kind}Key`, + tags: ["Repositories", "Bring your own key"], + summary: `Remove this repo's ${label} key`, + auth: "session", + responses: { 200: { description: "Key removed" }, ...SESSION_AUTH_RESPONSES }, + }, + { + method: "get", + path: `/v1/internal/repos/:owner/:repo/${kind}`, + operationId: `get${Kind}KeyInternal`, + tags: ["Bring your own key", "Internal"], + summary: `Read this repo's ${label} key state from the control plane`, + auth: "internal", + responses: { 200: { description: "Key presence and metadata" }, ...INTERNAL_AUTH_RESPONSES }, + }, + { + method: "post", + path: `/v1/internal/repos/:owner/:repo/${kind}`, + operationId: `set${Kind}KeyInternal`, + 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 }, + }, + { + method: "delete", + path: `/v1/internal/repos/:owner/:repo/${kind}`, + operationId: `delete${Kind}KeyInternal`, + tags: ["Bring your own key", "Internal"], + summary: `Remove this repo's ${label} key from the control plane`, + auth: "internal", + responses: { 200: { description: "Key removed" }, ...INTERNAL_AUTH_RESPONSES }, + }, + ]; + }), + { + method: "put", + path: "/v1/repos/:owner/:repo/settings", + operationId: "updateRepoSettings", + tags: ["Repositories"], + summary: "Replace a repo's LoopOver settings", + auth: "session", + responses: { 200: { description: "Settings updated" }, 400: { description: "Malformed settings" }, ...SESSION_AUTH_RESPONSES }, + }, + { + method: "put", + path: "/v1/repos/:owner/:repo/ai-review", + operationId: "updateRepoAiReviewMode", + tags: ["Repositories"], + summary: "Set a repo's AI-review mode", + auth: "session", + responses: { 200: { description: "AI-review mode updated" }, 400: { description: "Malformed mode" }, ...SESSION_AUTH_RESPONSES }, + }, +]; + +/** Register every entry in this batch. Called from buildOpenApiSpec. */ +export function registerOrbAndControlRouteSpecs(registry: OpenAPIRegistry): void { + for (const entry of [...ORB_ROUTES, ...INTERNAL_ORB_ROUTES, ...APP_ROUTES, ...REPO_ROUTES]) { + registerRouteSpec(registry, entry); + } +} + +/** Exported for the meta-test that asserts every entry's declared auth matches the middleware that + * actually gates it. */ +export const ORB_AND_CONTROL_ROUTE_SPECS: readonly SpecEntry[] = [...ORB_ROUTES, ...INTERNAL_ORB_ROUTES, ...APP_ROUTES, ...REPO_ROUTES]; diff --git a/src/openapi/spec.ts b/src/openapi/spec.ts index 8e80a61bd2..25e4798440 100644 --- a/src/openapi/spec.ts +++ b/src/openapi/spec.ts @@ -1,4 +1,7 @@ import { OpenApiGeneratorV3, OpenAPIRegistry } from "@asteasolutions/zod-to-openapi"; +import { requiresApiToken } from "../auth/route-auth"; +import { registerOrbAndControlRouteSpecs } from "./orb-and-control-route-specs"; +import { registerInternalAndPublicRouteSpecs } from "./internal-and-public-route-specs"; import { z } from "zod"; import { AdvisorySchema, @@ -138,6 +141,29 @@ import { WorkboardItemSchema, } from "./schemas"; +/** + * The operationId and tag for a route registered from a loop (#9531). + * + * The literal `registerPath` calls above carry both inline; the handful registered from a table + * cannot, so they derive them here from the same rules. Derived rather than hand-listed because + * these tables exist precisely so that adding a sibling route is a one-line change -- requiring a + * second hand-written entry per route would give that up. + */ +function loopOperationMeta(method: string, path: string, tag: string): { operationId: string; tags: [string, ...string[]] } { + const stem = path + .split("/") + .filter((segment) => segment && segment !== "v1") + .map((segment) => + segment + .split(/[-.]/) + .filter(Boolean) + .map((word) => `${word[0]!.toUpperCase()}${word.slice(1)}`) + .join(""), + ) + .join(""); + return { operationId: `${method}${stem}`, tags: [tag] }; +} + export function buildOpenApiSpec() { const registry = new OpenAPIRegistry(); registry.register("Health", HealthSchema); @@ -271,6 +297,8 @@ export function buildOpenApiSpec() { registry.registerPath({ method: "get", path: "/health", + operationId: "getHealth", + tags: ["Meta"], summary: "Service liveness probe", responses: { 200: { description: "Service health", content: { "application/json": { schema: HealthSchema } } }, @@ -279,6 +307,8 @@ export function buildOpenApiSpec() { registry.registerPath({ method: "get", path: "/v1/mcp/compatibility", + operationId: "getMcpCompatibility", + tags: ["MCP"], summary: "Public-safe API and MCP client compatibility metadata", responses: { 200: { description: "Public-safe API and MCP compatibility metadata", content: { "application/json": { schema: McpCompatibilitySchema } } }, @@ -287,6 +317,8 @@ export function buildOpenApiSpec() { registry.registerPath({ method: "get", path: "/v1/public/stats", + operationId: "listPublicStats", + tags: ["Public"], summary: "Public homepage aggregate stats", responses: { 200: { description: "Public-safe homepage stats: lifetime PRs handled/merged/closed, gate + slop blocks, and reversal-grounded accuracy. Aggregate counts only.", content: { "application/json": { schema: PublicStatsSchema } } }, @@ -297,6 +329,8 @@ export function buildOpenApiSpec() { registry.registerPath({ method: "get", path: "/v1/public/github/repos/{owner}/{repo}/stats", + operationId: "listPublicGithubReposByOwnerByRepoStats", + tags: ["Public"], summary: "Public GitHub stars and forks for an allowlisted repository", request: { params: z.object({ owner: z.string(), repo: z.string() }) }, responses: { @@ -308,6 +342,8 @@ export function buildOpenApiSpec() { registry.registerPath({ method: "get", path: "/v1/public/repos/{owner}/{repo}/quality", + operationId: "getPublicReposByOwnerByRepoQuality", + tags: ["Public"], summary: "Public repository quality summary for an opted-in repository", request: { params: z.object({ owner: z.string(), repo: z.string() }) }, responses: { @@ -323,6 +359,8 @@ export function buildOpenApiSpec() { registry.registerPath({ method: "get", path: "/v1/registry/snapshot", + operationId: "getRegistrySnapshot", + tags: ["Registry"], summary: "Latest Gittensor registry snapshot", responses: { 200: { description: "Latest Gittensor registry snapshot", content: { "application/json": { schema: RegistrySnapshotSchema } } }, @@ -331,6 +369,8 @@ export function buildOpenApiSpec() { registry.registerPath({ method: "get", path: "/v1/registry/changes", + operationId: "listRegistryChanges", + tags: ["Registry"], summary: "Diff between the two latest registry snapshots", responses: { 200: { description: "Diff between latest registry snapshots", content: { "application/json": { schema: RegistryChangeReportSchema } } }, @@ -339,6 +379,8 @@ export function buildOpenApiSpec() { registry.registerPath({ method: "get", path: "/v1/scoring/model", + operationId: "getScoringModel", + tags: ["Scoring"], summary: "Latest scoring model snapshot", responses: { 200: { description: "Latest private scoring model snapshot", content: { "application/json": { schema: ScoringModelSnapshotSchema } } }, @@ -347,6 +389,8 @@ export function buildOpenApiSpec() { registry.registerPath({ method: "get", path: "/v1/finding-taxonomy", + operationId: "getFindingTaxonomy", + tags: ["API"], summary: "Canonical AI-review finding taxonomy", responses: { 200: { description: "Finding categories and the severity ladder", content: { "application/json": { schema: FindingTaxonomyDocumentSchema } } }, @@ -355,6 +399,8 @@ export function buildOpenApiSpec() { registry.registerPath({ method: "get", path: "/v1/enrichment-analyzers", + operationId: "listEnrichmentAnalyzers", + tags: ["API"], summary: "REES enrichment analyzer taxonomy", responses: { 200: { description: "Default profile and the registered enrichment analyzers", content: { "application/json": { schema: EnrichmentAnalyzersTaxonomyDocumentSchema } } }, @@ -363,6 +409,8 @@ export function buildOpenApiSpec() { registry.registerPath({ method: "get", path: "/v1/upstream/status", + operationId: "listUpstreamStatus", + tags: ["Registry"], summary: "Upstream Gittensor source and ruleset drift status", responses: { 200: { description: "Upstream Gittensor source/ruleset drift status", content: { "application/json": { schema: UpstreamStatusSchema } } }, @@ -371,6 +419,8 @@ export function buildOpenApiSpec() { registry.registerPath({ method: "get", path: "/v1/upstream/ruleset", + operationId: "getUpstreamRuleset", + tags: ["Registry"], summary: "Latest normalized upstream Gittensor ruleset snapshot", responses: { 200: { description: "Latest normalized upstream Gittensor ruleset snapshot", content: { "application/json": { schema: UpstreamRulesetSnapshotSchema } } }, @@ -380,6 +430,8 @@ export function buildOpenApiSpec() { registry.registerPath({ method: "get", path: "/v1/upstream/drift", + operationId: "getUpstreamDrift", + tags: ["Registry"], summary: "Open and historical upstream drift reports", responses: { 200: { @@ -399,6 +451,8 @@ export function buildOpenApiSpec() { registry.registerPath({ method: "post", path: "/v1/scoring/preview", + operationId: "postScoringPreview", + tags: ["Scoring"], summary: "Generate a scoring preview artifact for a candidate contribution", responses: { 200: { description: "Private scoring preview artifact", content: { "application/json": { schema: ScorePreviewSchema } } }, @@ -408,6 +462,8 @@ export function buildOpenApiSpec() { registry.registerPath({ method: "post", path: "/v1/scoring/eligibility-plan", + operationId: "postScoringEligibilityPlan", + tags: ["Scoring"], summary: "Derive a contributor eligibility plan from a scoring preview — REST mirror of loopover_get_eligibility_plan (#9301)", responses: { 200: { @@ -421,6 +477,8 @@ export function buildOpenApiSpec() { registry.registerPath({ method: "post", path: "/v1/scoring/explain-breakdown", + operationId: "postScoringExplainBreakdown", + tags: ["Scoring"], summary: "Explain a score breakdown from a scoring preview — REST mirror of loopover_explain_score_breakdown (#9301)", responses: { 200: { @@ -434,6 +492,8 @@ export function buildOpenApiSpec() { registry.registerPath({ method: "get", path: "/v1/sync/status", + operationId: "listSyncStatus", + tags: ["API"], summary: "Repository and installation sync status", responses: { 200: { description: "Repository and installation sync status", content: { "application/json": { schema: SyncStatusSchema } } }, @@ -442,6 +502,8 @@ export function buildOpenApiSpec() { registry.registerPath({ method: "get", path: "/v1/readiness", + operationId: "listReadiness", + tags: ["API"], summary: "Operational readiness summary for the hosted API", responses: { 200: { description: "Operational readiness summary for hosted API, signal fidelity, and public-review preparation", content: { "application/json": { schema: ReadinessSchema } } }, @@ -450,6 +512,8 @@ export function buildOpenApiSpec() { registry.registerPath({ method: "get", path: "/v1/installations", + operationId: "listInstallations", + tags: ["API"], summary: "List GitHub App installations and their health", responses: { 200: { @@ -468,6 +532,8 @@ export function buildOpenApiSpec() { registry.registerPath({ method: "get", path: "/v1/installations/{id}/health", + operationId: "getInstallationsByIdHealth", + tags: ["API"], summary: "GitHub App installation health detail", request: { params: z.object({ id: z.string() }) }, responses: { @@ -478,6 +544,8 @@ export function buildOpenApiSpec() { registry.registerPath({ method: "get", path: "/v1/installations/{id}/repair", + operationId: "getInstallationsByIdRepair", + tags: ["API"], summary: "GitHub App installation repair diagnostics", request: { params: z.object({ id: z.string() }) }, responses: { @@ -488,6 +556,8 @@ export function buildOpenApiSpec() { registry.registerPath({ method: "post", path: "/v1/installations/{id}/repair/refresh", + operationId: "postInstallationsByIdRepairRefresh", + tags: ["API"], summary: "Recompute GitHub App installation repair diagnostics", request: { params: z.object({ id: z.string() }) }, responses: { @@ -498,6 +568,8 @@ export function buildOpenApiSpec() { registry.registerPath({ method: "get", path: "/v1/app/notification-model", + operationId: "getAppNotificationModel", + tags: ["Control panel"], summary: "Opt-in notification model and PWA-readiness metadata", responses: { 200: { @@ -538,6 +610,8 @@ export function buildOpenApiSpec() { registry.registerPath({ method: "get", path: "/v1/repos", + operationId: "listRepos", + tags: ["API"], summary: "List known repositories", responses: { 200: { description: "Known repositories", content: { "application/json": { schema: RepositorySchema.array() } } }, @@ -546,6 +620,8 @@ export function buildOpenApiSpec() { registry.registerPath({ method: "get", path: "/v1/repos/{owner}/{repo}", + operationId: "getReposByOwnerByRepo", + tags: ["Repositories"], summary: "Repository detail", request: { params: z.object({ owner: z.string(), repo: z.string() }) }, responses: { @@ -556,6 +632,8 @@ export function buildOpenApiSpec() { registry.registerPath({ method: "get", path: "/v1/repos/{owner}/{repo}/intelligence", + operationId: "getReposByOwnerByRepoIntelligence", + tags: ["Repositories"], summary: "Canonical repository intelligence bundle", request: { params: z.object({ owner: z.string(), repo: z.string() }) }, responses: { @@ -565,6 +643,8 @@ export function buildOpenApiSpec() { registry.registerPath({ method: "get", path: "/v1/repos/{owner}/{repo}/issue-quality", + operationId: "getReposByOwnerByRepoIssueQuality", + tags: ["Repositories"], summary: "Repository issue quality report", request: { params: z.object({ owner: z.string(), repo: z.string() }) }, responses: { @@ -575,6 +655,8 @@ export function buildOpenApiSpec() { registry.registerPath({ method: "get", path: "/v1/repos/{owner}/{repo}/gate-config/effective", + operationId: "getReposByOwnerByRepoGateConfigEffective", + tags: ["Repositories"], summary: "Current effective self-tuned gate config for a repo (#6247)", request: { params: z.object({ owner: z.string(), repo: z.string() }) }, responses: { @@ -589,6 +671,8 @@ export function buildOpenApiSpec() { registry.registerPath({ method: "get", path: "/v1/repos/{owner}/{repo}/selftune/overrides/audit", + operationId: "getReposByOwnerByRepoSelftuneOverridesAudit", + tags: ["Repositories"], summary: "Self-tune gate override audit trail for a repo (#9303)", request: { params: z.object({ owner: z.string(), repo: z.string() }), @@ -611,6 +695,8 @@ export function buildOpenApiSpec() { registry.registerPath({ method: "delete", path: "/v1/repos/{owner}/{repo}/selftune/overrides", + operationId: "deleteReposByOwnerByRepoSelftuneOverrides", + tags: ["Repositories"], summary: "Clear the live self-tune gate override for a repo (#9303)", request: { params: z.object({ owner: z.string(), repo: z.string() }), @@ -635,6 +721,8 @@ export function buildOpenApiSpec() { registry.registerPath({ method: "post", path: "/v1/repos/{owner}/{repo}/validate-linked-issue", + operationId: "postReposByOwnerByRepoValidateLinkedIssue", + tags: ["Repositories"], summary: "Validate a linked issue for a planned change — REST mirror of loopover_validate_linked_issue (#9304)", request: { params: z.object({ owner: z.string(), repo: z.string() }), @@ -655,6 +743,8 @@ export function buildOpenApiSpec() { registry.registerPath({ method: "post", path: "/v1/repos/{owner}/{repo}/check-before-start", + operationId: "postReposByOwnerByRepoCheckBeforeStart", + tags: ["Repositories"], summary: "Pre-work claim/duplicate check before starting an issue — REST mirror of loopover_check_before_start (#9304)", request: { params: z.object({ owner: z.string(), repo: z.string() }), @@ -675,6 +765,8 @@ export function buildOpenApiSpec() { registry.registerPath({ method: "post", path: "/v1/loop/evaluate-escalation", + operationId: "postLoopEvaluateEscalation", + tags: ["Loop"], summary: "Evaluate whether a loop outcome should escalate — REST mirror of loopover_evaluate_escalation (#9309)", request: { body: { @@ -693,6 +785,8 @@ export function buildOpenApiSpec() { registry.registerPath({ method: "post", path: "/v1/loop/results-payload", + operationId: "postLoopResultsPayload", + tags: ["Loop"], summary: "Compose a loop results-delivery payload — REST mirror of loopover_build_results_payload (#9309)", request: { body: { @@ -711,6 +805,8 @@ export function buildOpenApiSpec() { registry.registerPath({ method: "post", path: "/v1/loop/progress-snapshot", + operationId: "postLoopProgressSnapshot", + tags: ["Loop"], summary: "Compose a running-loop progress snapshot — REST mirror of loopover_build_progress_snapshot (#9309)", request: { body: { @@ -729,6 +825,8 @@ export function buildOpenApiSpec() { registry.registerPath({ method: "post", path: "/v1/loop/intake-idea", + operationId: "postLoopIntakeIdea", + tags: ["Loop"], summary: "Validate an idea submission and assemble its task-graph — REST mirror of loopover_intake_idea (#9309)", request: { body: { @@ -747,6 +845,8 @@ export function buildOpenApiSpec() { registry.registerPath({ method: "post", path: "/v1/loop/plan-idea-claims", + operationId: "postLoopPlanIdeaClaims", + tags: ["Loop"], summary: "Disposition an idea's task-graph into a claim plan — REST mirror of loopover_plan_idea_claims (#9309)", request: { body: { @@ -765,6 +865,8 @@ export function buildOpenApiSpec() { registry.registerPath({ method: "post", path: "/v1/lint/pr-text", + operationId: "postLintPrText", + tags: ["Advisory checks"], summary: "Lint a PR's commit messages + body — REST mirror of loopover_lint_pr_text (#9308)", request: { body: { content: { "application/json": { schema: LintPrTextRequestSchema } } } }, responses: { @@ -778,6 +880,8 @@ export function buildOpenApiSpec() { registry.registerPath({ method: "post", path: "/v1/lint/slop-risk", + operationId: "postLintSlopRisk", + tags: ["Advisory checks"], summary: "Assess a changeset's slop risk — REST mirror of loopover_check_slop_risk (#9308)", request: { body: { content: { "application/json": { schema: CheckSlopRiskRequestSchema } } } }, responses: { @@ -791,6 +895,8 @@ export function buildOpenApiSpec() { registry.registerPath({ method: "post", path: "/v1/lint/improvement-potential", + operationId: "postLintImprovementPotential", + tags: ["Advisory checks"], summary: "Score a changeset's structural improvement potential — REST mirror of loopover_check_improvement_potential (#9308)", request: { body: { content: { "application/json": { schema: CheckImprovementPotentialRequestSchema } } } }, responses: { @@ -804,6 +910,8 @@ export function buildOpenApiSpec() { registry.registerPath({ method: "post", path: "/v1/lint/open-pr-pressure", + operationId: "postLintOpenPrPressure", + tags: ["Advisory checks"], summary: "Simulate open-PR queue pressure for a repo — REST mirror of loopover_simulate_open_pr_pressure (#9308)", request: { body: { content: { "application/json": { schema: SimulateOpenPrPressureRequestSchema } } } }, responses: { @@ -817,6 +925,8 @@ export function buildOpenApiSpec() { registry.registerPath({ method: "post", path: "/v1/lint/boundary-tests", + operationId: "postLintBoundaryTests", + tags: ["Advisory checks"], summary: "Suggest boundary tests for a changeset — REST mirror of loopover_suggest_boundary_tests (#9308)", request: { body: { content: { "application/json": { schema: SuggestBoundaryTestsRequestSchema } } } }, responses: { @@ -830,6 +940,8 @@ export function buildOpenApiSpec() { registry.registerPath({ method: "post", path: "/v1/lint/test-evidence", + operationId: "postLintTestEvidence", + tags: ["Advisory checks"], summary: "Classify a changeset's test evidence — REST mirror of loopover_check_test_evidence (#9308)", request: { body: { content: { "application/json": { schema: CheckTestEvidenceRequestSchema } } } }, responses: { @@ -843,6 +955,8 @@ export function buildOpenApiSpec() { registry.registerPath({ method: "post", path: "/v1/lint/issue-slop", + operationId: "postLintIssueSlop", + tags: ["Advisory checks"], summary: "Assess an issue's slop risk from its title/body — REST mirror of loopover_check_issue_slop (#9308)", request: { body: { content: { "application/json": { schema: CheckIssueSlopRequestSchema } } } }, responses: { @@ -856,6 +970,8 @@ export function buildOpenApiSpec() { registry.registerPath({ method: "post", path: "/v1/validate/focus-manifest", + operationId: "postValidateFocusManifest", + tags: ["Advisory checks"], summary: "Validate a .loopover focus-manifest config — REST mirror of loopover_validate_config (#9308)", request: { body: { content: { "application/json": { schema: ValidateFocusManifestRequestSchema } } } }, responses: { @@ -869,6 +985,8 @@ export function buildOpenApiSpec() { registry.registerPath({ method: "get", path: "/v1/repos/{owner}/{repo}/live-gate-thresholds", + operationId: "listReposByOwnerByRepoLiveGateThresholds", + tags: ["Repositories"], summary: "Live self-tuned gate thresholds for AMS probe (#6486)", request: { params: z.object({ owner: z.string(), repo: z.string() }) }, responses: { @@ -883,6 +1001,8 @@ export function buildOpenApiSpec() { registry.registerPath({ method: "get", path: "/v1/repos/{owner}/{repo}/maintainer-noise", + operationId: "getReposByOwnerByRepoMaintainerNoise", + tags: ["Repositories"], summary: "Maintainer queue-noise triage report for a repository (#9302)", request: { params: z.object({ owner: z.string(), repo: z.string() }) }, responses: { @@ -898,6 +1018,8 @@ export function buildOpenApiSpec() { registry.registerPath({ method: "get", path: "/v1/repos/{owner}/{repo}/ams-miner-cohort", + operationId: "getReposByOwnerByRepoAmsMinerCohort", + tags: ["Repositories"], summary: "AMS-vs-human contributor-mix cohort comparison for a repository (#9302)", request: { params: z.object({ owner: z.string(), repo: z.string() }) }, responses: { @@ -913,6 +1035,8 @@ export function buildOpenApiSpec() { registry.registerPath({ method: "get", path: "/v1/repos/{owner}/{repo}/gate-precision", + operationId: "getReposByOwnerByRepoGatePrecision", + tags: ["Repositories"], summary: "Per-gate-type false-positive precision measurement for a repository (#9302)", request: { params: z.object({ owner: z.string(), repo: z.string() }), @@ -931,6 +1055,8 @@ export function buildOpenApiSpec() { registry.registerPath({ method: "get", path: "/v1/repos/{owner}/{repo}/outcome-calibration", + operationId: "getReposByOwnerByRepoOutcomeCalibration", + tags: ["Repositories"], summary: "Slop-band and recommendation outcome calibration for a repository (#9302)", request: { params: z.object({ owner: z.string(), repo: z.string() }), @@ -949,6 +1075,8 @@ export function buildOpenApiSpec() { registry.registerPath({ method: "get", path: "/v1/repos/{owner}/{repo}/activation-preview", + operationId: "getReposByOwnerByRepoActivationPreview", + tags: ["Repositories"], summary: "Deterministic maintainer activation preview for a repository (#9302)", request: { params: z.object({ owner: z.string(), repo: z.string() }) }, responses: { @@ -964,6 +1092,8 @@ export function buildOpenApiSpec() { registry.registerPath({ method: "get", path: "/v1/repos/{owner}/{repo}/outcome-patterns", + operationId: "listReposByOwnerByRepoOutcomePatterns", + tags: ["Repositories"], summary: "Accepted and rejected pull request outcome patterns for a repository", request: { params: z.object({ owner: z.string(), repo: z.string() }) }, responses: { @@ -974,6 +1104,8 @@ export function buildOpenApiSpec() { registry.registerPath({ method: "get", path: "/v1/repos/{owner}/{repo}/registration-readiness", + operationId: "listReposByOwnerByRepoRegistrationReadiness", + tags: ["Repositories"], summary: "Gittensor registration readiness signal for repository owners", request: { params: z.object({ owner: z.string(), repo: z.string() }) }, responses: { @@ -983,6 +1115,8 @@ export function buildOpenApiSpec() { registry.registerPath({ method: "get", path: "/v1/repos/{owner}/{repo}/gittensor-config-recommendation", + operationId: "getReposByOwnerByRepoGittensorConfigRecommendation", + tags: ["Repositories"], summary: "Recommended Gittensor configuration for a repository", request: { params: z.object({ owner: z.string(), repo: z.string() }) }, responses: { @@ -992,6 +1126,8 @@ export function buildOpenApiSpec() { registry.registerPath({ method: "get", path: "/v1/repos/{owner}/{repo}/focus-manifest", + operationId: "getReposByOwnerByRepoFocusManifest", + tags: ["Repositories"], summary: "Repository focus manifest and compiled policy", request: { params: z.object({ owner: z.string(), repo: z.string() }) }, responses: { @@ -1002,6 +1138,8 @@ export function buildOpenApiSpec() { registry.registerPath({ method: "post", path: "/v1/repos/{owner}/{repo}/focus-manifest/refresh", + operationId: "postReposByOwnerByRepoFocusManifestRefresh", + tags: ["Repositories"], summary: "Refresh the persisted focus manifest from the repository file", request: { params: z.object({ owner: z.string(), repo: z.string() }) }, responses: { @@ -1012,6 +1150,8 @@ export function buildOpenApiSpec() { registry.registerPath({ method: "put", path: "/v1/repos/{owner}/{repo}/focus-manifest", + operationId: "putReposByOwnerByRepoFocusManifest", + tags: ["Repositories"], summary: "Persist an API-backed focus manifest for a repository", request: { params: z.object({ owner: z.string(), repo: z.string() }) }, responses: { @@ -1023,6 +1163,8 @@ export function buildOpenApiSpec() { registry.registerPath({ method: "get", path: "/v1/repos/{owner}/{repo}/agent/pending-actions", + operationId: "listReposByOwnerByRepoAgentPendingActions", + tags: ["Repositories"], summary: "Maintainer-scoped agent approval queue of pending staged actions", request: { params: z.object({ owner: z.string(), repo: z.string() }) }, responses: { @@ -1036,6 +1178,8 @@ export function buildOpenApiSpec() { registry.registerPath({ method: "post", path: "/v1/repos/{owner}/{repo}/agent/pending-actions", + operationId: "postReposByOwnerByRepoAgentPendingActions", + tags: ["Repositories"], summary: "Stage an agent action into the approval queue for maintainer review", request: { params: z.object({ owner: z.string(), repo: z.string() }), @@ -1054,6 +1198,8 @@ export function buildOpenApiSpec() { registry.registerPath({ method: "post", path: "/v1/repos/{owner}/{repo}/agent/pending-actions/{id}/{decision}", + operationId: "postReposByOwnerByRepoAgentPendingActionsByIdByDecision", + tags: ["Repositories"], summary: "Accept (execute) or reject a staged agent action in the approval queue", request: { params: z.object({ owner: z.string(), repo: z.string(), id: z.string(), decision: z.enum(["accept", "reject"]) }) }, responses: { @@ -1070,6 +1216,8 @@ export function buildOpenApiSpec() { registry.registerPath({ method: "get", path: "/v1/repos/{owner}/{repo}/agent/audit-feed", + operationId: "getReposByOwnerByRepoAgentAuditFeed", + tags: ["Repositories"], summary: "Maintainer-scoped agent audit feed of executed actions and approval decisions", request: { params: z.object({ owner: z.string(), repo: z.string() }) }, responses: { @@ -1117,6 +1265,8 @@ export function buildOpenApiSpec() { registry.registerPath({ method: "post", path: "/v1/repos/{owner}/{repo}/pulls/{number}/incident-reports", + operationId: "postReposByOwnerByRepoPullsByNumberIncidentReports", + tags: ["Repositories"], summary: "Record a post-merge incident report for a pull request", request: { params: z.object({ owner: z.string(), repo: z.string(), number: z.string() }), @@ -1147,6 +1297,8 @@ export function buildOpenApiSpec() { registry.registerPath({ method: "post", path: "/v1/app/incident-reports", + operationId: "postAppIncidentReports", + tags: ["Control panel"], summary: "Record a post-merge incident report from the operator side", request: { body: { @@ -1178,6 +1330,8 @@ export function buildOpenApiSpec() { registry.registerPath({ method: "get", path: "/v1/app/self-dogfood/registration-pack", + operationId: "getAppSelfDogfoodRegistrationPack", + tags: ["Control panel"], summary: "Self-dogfood registration pack for the LoopOver repository", responses: { 200: { description: "Private self-dogfood registration pack for the LoopOver repo", content: { "application/json": { schema: z.record(z.string(), z.unknown()) } } }, @@ -1187,6 +1341,8 @@ export function buildOpenApiSpec() { registry.registerPath({ method: "get", path: "/v1/repos/{owner}/{repo}/self-dogfood-registration-pack", + operationId: "getReposByOwnerByRepoSelfDogfoodRegistrationPack", + tags: ["Repositories"], summary: "Self-dogfood registration pack when the repository matches the configured target", request: { params: z.object({ owner: z.string(), repo: z.string() }) }, responses: { @@ -1197,6 +1353,8 @@ export function buildOpenApiSpec() { registry.registerPath({ method: "get", path: "/v1/repos/{owner}/{repo}/onboarding-pack/preview", + operationId: "getReposByOwnerByRepoOnboardingPackPreview", + tags: ["Repositories"], summary: "Preview the onboarding pack for an accepted repository", request: { params: z.object({ owner: z.string(), repo: z.string() }) }, responses: { @@ -1208,6 +1366,8 @@ export function buildOpenApiSpec() { registry.registerPath({ method: "post", path: "/v1/repos/{owner}/{repo}/contributor-issue-drafts/generate", + operationId: "postReposByOwnerByRepoContributorIssueDraftsGenerate", + tags: ["Repositories"], summary: "Generate maintainer-reviewed contributor issue drafts", request: { params: z.object({ owner: z.string(), repo: z.string() }) }, responses: { @@ -1219,6 +1379,8 @@ export function buildOpenApiSpec() { registry.registerPath({ method: "post", path: "/v1/repos/{owner}/{repo}/issue-plan-drafts/generate", + operationId: "postReposByOwnerByRepoIssuePlanDraftsGenerate", + tags: ["Repositories"], summary: "AI-plan repo issue drafts from a maintainer goal", request: { params: z.object({ owner: z.string(), repo: z.string() }) }, responses: { @@ -1230,6 +1392,8 @@ export function buildOpenApiSpec() { registry.registerPath({ method: "get", path: "/v1/repos/{owner}/{repo}/settings", + operationId: "listReposByOwnerByRepoSettings", + tags: ["Repositories"], summary: "Repository automation settings", request: { params: z.object({ owner: z.string(), repo: z.string() }) }, responses: { @@ -1239,6 +1403,8 @@ export function buildOpenApiSpec() { registry.registerPath({ method: "get", path: "/v1/repos/{owner}/{repo}/automation-state", + operationId: "getReposByOwnerByRepoAutomationState", + tags: ["Repositories"], summary: "Derived agent automation state for a repository", request: { params: z.object({ owner: z.string(), repo: z.string() }) }, responses: { @@ -1252,6 +1418,8 @@ export function buildOpenApiSpec() { registry.registerPath({ method: "post", path: "/v1/repos/{owner}/{repo}/repo-docs/refresh", + operationId: "postReposByOwnerByRepoRepoDocsRefresh", + tags: ["Repositories"], summary: "Open (or find the already-open) AGENTS.md/CLAUDE.md generation pull request", request: { params: z.object({ owner: z.string(), repo: z.string() }) }, responses: { @@ -1261,6 +1429,8 @@ export function buildOpenApiSpec() { registry.registerPath({ method: "post", path: "/v1/repos/{owner}/{repo}/settings-preview", + operationId: "postReposByOwnerByRepoSettingsPreview", + tags: ["Repositories"], summary: "Dry-run the public surface decision for a sample pull request", request: { params: z.object({ owner: z.string(), repo: z.string() }) }, responses: { @@ -1271,6 +1441,8 @@ export function buildOpenApiSpec() { registry.registerPath({ method: "get", path: "/v1/repos/{owner}/{repo}/pulls/{number}/maintainer-packet", + operationId: "getReposByOwnerByRepoPullsByNumberMaintainerPacket", + tags: ["Repositories"], summary: "Maintainer review packet for a pull request", request: { params: z.object({ owner: z.string(), repo: z.string(), number: z.string() }) }, responses: { @@ -1280,6 +1452,8 @@ export function buildOpenApiSpec() { registry.registerPath({ method: "get", path: "/v1/repos/{owner}/{repo}/pulls/{number}/reviewability", + operationId: "getReposByOwnerByRepoPullsByNumberReviewability", + tags: ["Repositories"], summary: "Pull request reviewability score and maintainer action", request: { params: z.object({ owner: z.string(), repo: z.string(), number: z.string() }) }, responses: { @@ -1289,6 +1463,8 @@ export function buildOpenApiSpec() { registry.registerPath({ method: "get", path: "/v1/repos/{owner}/{repo}/pulls/{number}/ai-review-findings", + operationId: "listReposByOwnerByRepoPullsByNumberAiReviewFindings", + tags: ["Repositories"], summary: "A PR author's own structured, published AI-review findings", request: { params: z.object({ owner: z.string(), repo: z.string(), number: z.string() }), @@ -1309,6 +1485,8 @@ export function buildOpenApiSpec() { registry.registerPath({ method: "get", path: "/v1/contributors/{login}/profile", + operationId: "getContributorsByLoginProfile", + tags: ["Contributors"], summary: "Contributor evidence profile", request: { params: z.object({ login: z.string() }) }, responses: { @@ -1318,6 +1496,8 @@ export function buildOpenApiSpec() { registry.registerPath({ method: "get", path: "/v1/contributors/{login}/decision-pack", + operationId: "getContributorsByLoginDecisionPack", + tags: ["Contributors"], summary: "Canonical contributor decision pack", request: { params: z.object({ login: z.string() }) }, responses: { @@ -1331,6 +1511,8 @@ export function buildOpenApiSpec() { registry.registerPath({ method: "get", path: "/v1/contributors/{login}/open-pr-monitor", + operationId: "getContributorsByLoginOpenPrMonitor", + tags: ["Contributors"], summary: "Contributor open-PR monitor with classifications and next-step packets", request: { params: z.object({ login: z.string() }) }, responses: { @@ -1343,6 +1525,8 @@ export function buildOpenApiSpec() { registry.registerPath({ method: "get", path: "/v1/contributors/{login}/pr-outcomes", + operationId: "listContributorsByLoginPrOutcomes", + tags: ["Contributors"], summary: "Contributor post-merge PR outcome history", request: { params: z.object({ login: z.string() }), @@ -1358,6 +1542,8 @@ export function buildOpenApiSpec() { registry.registerPath({ method: "get", path: "/v1/contributors/{login}/notifications", + operationId: "listContributorsByLoginNotifications", + tags: ["Contributors"], summary: "Contributor badge notification feed", request: { params: z.object({ login: z.string() }) }, responses: { @@ -1370,6 +1556,8 @@ export function buildOpenApiSpec() { registry.registerPath({ method: "post", path: "/v1/contributors/{login}/notifications/read", + operationId: "postContributorsByLoginNotificationsRead", + tags: ["Contributors"], summary: "Mark contributor notifications read", request: { params: z.object({ login: z.string() }), @@ -1392,6 +1580,8 @@ export function buildOpenApiSpec() { registry.registerPath({ method: "get", path: "/v1/contributors/{login}/watches", + operationId: "listContributorsByLoginWatches", + tags: ["Contributors"], summary: "List contributor issue-watch subscriptions — REST mirror of loopover_watch_issues action=list (#9306)", request: { params: z.object({ login: z.string() }) }, responses: { @@ -1406,6 +1596,8 @@ export function buildOpenApiSpec() { registry.registerPath({ method: "post", path: "/v1/contributors/{login}/watches", + operationId: "postContributorsByLoginWatches", + tags: ["Contributors"], summary: "Subscribe to a repo's new issues — REST mirror of loopover_watch_issues action=watch (#9306)", request: { params: z.object({ login: z.string() }), @@ -1426,6 +1618,8 @@ export function buildOpenApiSpec() { registry.registerPath({ method: "delete", path: "/v1/contributors/{login}/watches", + operationId: "deleteContributorsByLoginWatches", + tags: ["Contributors"], summary: "Unsubscribe from a repo's issue watches — REST mirror of loopover_watch_issues action=unwatch (#9306)", request: { params: z.object({ login: z.string() }), @@ -1446,6 +1640,8 @@ export function buildOpenApiSpec() { registry.registerPath({ method: "get", path: "/v1/contributors/{login}/repos/{owner}/{repo}/decision", + operationId: "getContributorsByLoginReposByOwnerByRepoDecision", + tags: ["Contributors"], summary: "Repository-specific contributor decision", request: { params: z.object({ login: z.string(), owner: z.string(), repo: z.string() }) }, responses: { @@ -1456,6 +1652,8 @@ export function buildOpenApiSpec() { registry.registerPath({ method: "post", path: "/v1/preflight/pr", + operationId: "postPreflightPr", + tags: ["Preflight"], summary: "Run submission preflight for a pull request", responses: { 200: { description: "Submission preflight result", content: { "application/json": { schema: PreflightResultSchema } } }, @@ -1465,6 +1663,8 @@ export function buildOpenApiSpec() { registry.registerPath({ method: "post", path: "/v1/preflight/review-risk", + operationId: "postPreflightReviewRisk", + tags: ["Preflight"], summary: "Explain review risk for a planned pull request", responses: { 200: { description: "Review-risk explanation with preflight, role context, and recommendation", content: { "application/json": { schema: ReviewRiskExplanationSchema } } }, @@ -1475,6 +1675,8 @@ export function buildOpenApiSpec() { registry.registerPath({ method: "post", path: "/v1/preflight/local-diff", + operationId: "postPreflightLocalDiff", + tags: ["Preflight"], summary: "Run preflight against a local diff", responses: { 200: { description: "Local diff preflight result", content: { "application/json": { schema: LocalDiffPreflightResultSchema } } }, @@ -1484,6 +1686,8 @@ export function buildOpenApiSpec() { registry.registerPath({ method: "post", path: "/v1/local/branch-analysis", + operationId: "postLocalBranchAnalysis", + tags: ["Local"], summary: "Analyze a local branch for MCP clients", responses: { 200: { description: "Private local branch analysis for MCP clients", content: { "application/json": { schema: LocalBranchAnalysisSchema } } }, @@ -1494,6 +1698,8 @@ export function buildOpenApiSpec() { registry.registerPath({ method: "post", path: "/v1/agent/runs", + operationId: "postAgentRuns", + tags: ["Agent automation"], summary: "Queue an agent run", responses: { 202: { description: "Copilot-only agent run queued", content: { "application/json": { schema: AgentRunBundleSchema } } }, @@ -1504,6 +1710,8 @@ export function buildOpenApiSpec() { registry.registerPath({ method: "get", path: "/v1/agent/runs", + operationId: "listAgentRuns", + tags: ["Agent automation"], summary: "List persisted agent runs for an actor", request: { query: z.object({ @@ -1536,6 +1744,8 @@ export function buildOpenApiSpec() { registry.registerPath({ method: "get", path: "/v1/agent/runs/{id}", + operationId: "getAgentRunsById", + tags: ["Agent automation"], summary: "Persisted agent run bundle", request: { params: z.object({ id: z.string() }) }, responses: { @@ -1546,6 +1756,8 @@ export function buildOpenApiSpec() { registry.registerPath({ method: "post", path: "/v1/opportunities/find", + operationId: "postOpportunitiesFind", + tags: ["Discovery"], summary: "Find cross-repo contribution opportunities (#9310)", request: { body: { @@ -1565,6 +1777,8 @@ export function buildOpenApiSpec() { registry.registerPath({ method: "post", path: "/v1/issue-rag/retrieve", + operationId: "postIssueRagRetrieve", + tags: ["Discovery"], summary: "Retrieve issue-centric RAG context for the miner analyze phase (#9310)", request: { body: { @@ -1590,6 +1804,7 @@ export function buildOpenApiSpec() { registry.registerPath({ method: "post", path, + ...loopOperationMeta("post", path, "Agent automation"), summary, responses: { 200: { description: "Agent run completed with deterministic ranked actions", content: { "application/json": { schema: AgentRunBundleSchema } } }, @@ -1602,6 +1817,8 @@ export function buildOpenApiSpec() { registry.registerPath({ method: "get", path: "/v1/bounties", + operationId: "listBounties", + tags: ["Bounties"], summary: "List known bounty records", responses: { 200: { description: "Known bounty records", content: { "application/json": { schema: BountySchema.array() } } }, @@ -1610,6 +1827,8 @@ export function buildOpenApiSpec() { registry.registerPath({ method: "get", path: "/v1/bounties/{id}/advisory", + operationId: "getBountiesByIdAdvisory", + tags: ["Bounties"], summary: "Bounty lifecycle advisory", request: { params: z.object({ id: z.string() }) }, responses: { @@ -1620,6 +1839,8 @@ export function buildOpenApiSpec() { registry.registerPath({ method: "get", path: "/v1/bounties/{id}/lifecycle", + operationId: "getBountiesByIdLifecycle", + tags: ["Bounties"], summary: "Bounty lifecycle transition history", request: { params: z.object({ id: z.string() }) }, responses: { @@ -1630,6 +1851,8 @@ export function buildOpenApiSpec() { registry.registerPath({ method: "post", path: "/v1/github/webhook", + operationId: "postGithubWebhook", + tags: ["Webhooks"], summary: "Receive a GitHub webhook delivery", responses: { 202: { description: "Webhook queued" }, @@ -1639,6 +1862,8 @@ export function buildOpenApiSpec() { registry.registerPath({ method: "get", path: "/v1/public/decision-ledger/verify", + operationId: "getPublicDecisionLedgerVerify", + tags: ["Public"], summary: "Verify a window of the hash-chained decision ledger (resumable via afterSeq)", responses: { 200: { description: "Window verified clean; nextAfterSeq is the resume cursor (null at the tip). Every response also carries tipSeq/tipHash/totalCount for third-party checkpointing, and prunedRecords — the count of rows whose record preimage was legitimately pruned by the published retention window (chain checks still hold for them; only the content re-check is impossible, and the committed digest stays published)." }, @@ -1648,6 +1873,8 @@ export function buildOpenApiSpec() { registry.registerPath({ method: "get", path: "/v1/public/decision-ledger/row/{seq}", + operationId: "getPublicDecisionLedgerRowBySeq", + tags: ["Public"], summary: "Fetch one decision-ledger row by seq, so an external anchor can be bound back to the live chain", request: { params: z.object({ seq: z.string() }) }, responses: { @@ -1659,6 +1886,8 @@ export function buildOpenApiSpec() { registry.registerPath({ method: "get", path: "/v1/public/decision-ledger/anchor-key", + operationId: "getPublicDecisionLedgerAnchorKey", + tags: ["Public"], summary: "Published anchor-signing public keys with their full rotation history, for verifying an externally-published ledger anchor", responses: { 200: { description: "{ keys: [{ keyId, publicKeySpki, notBefore, notAfter }], currentKeyId } — retired keys are retained so anchors signed under them stay verifiable; currentKeyId is null when unconfigured or the rotation state is ambiguous" }, @@ -1667,6 +1896,8 @@ export function buildOpenApiSpec() { registry.registerPath({ method: "get", path: "/v1/public/decision-ledger/anchors", + operationId: "listPublicDecisionLedgerAnchors", + tags: ["Public"], summary: "Every external anchoring attempt, success and failure, paginated newest-first — anchoring's own health as a public fact", request: { query: z.object({ backend: z.enum(["rekor", "git", "ots"]).optional(), before: z.string().optional(), limit: z.string().optional() }) }, responses: { @@ -1676,6 +1907,8 @@ export function buildOpenApiSpec() { registry.registerPath({ method: "get", path: "/v1/public/decision-records/{owner}/{repo}/{pull}", + operationId: "getPublicDecisionRecordsByOwnerByRepoByPull", + tags: ["Public"], summary: "Fetch the latest published decision record for a PR, verbatim, plus its content digest", request: { params: z.object({ owner: z.string(), repo: z.string(), pull: z.string() }) }, responses: { @@ -1687,6 +1920,8 @@ export function buildOpenApiSpec() { registry.registerPath({ method: "get", path: "/v1/public/eval-scores", + operationId: "listPublicEvalScores", + tags: ["Public"], summary: "Fetch EvalScoreRecords (#9215) -- the objective-eval-provider transport, digest-committed and independently re-derivable", request: { query: z.object({ subject: z.string().optional(), since: z.string().optional() }) }, responses: { @@ -1697,6 +1932,8 @@ export function buildOpenApiSpec() { registry.registerPath({ method: "post", path: "/v1/orb/ingest", + operationId: "postOrbIngest", + tags: ["ORB"], summary: "Ingest a batch of Orb events", responses: { 200: { description: "Batch accepted; returns { accepted: number }" }, @@ -1706,6 +1943,8 @@ export function buildOpenApiSpec() { registry.registerPath({ method: "get", path: "/v1/auth/github/start", + operationId: "getAuthGithubStart", + tags: ["Auth"], summary: "Start GitHub web OAuth", responses: { 302: { description: "Redirects to GitHub web OAuth" }, @@ -1715,6 +1954,8 @@ export function buildOpenApiSpec() { registry.registerPath({ method: "get", path: "/v1/auth/github/callback", + operationId: "getAuthGithubCallback", + tags: ["Auth"], summary: "Complete GitHub web OAuth and redirect to the app", responses: { 302: { description: "Completes GitHub web OAuth and redirects to the app" }, @@ -1729,6 +1970,7 @@ export function buildOpenApiSpec() { registry.registerPath({ method: "post", path, + ...loopOperationMeta("post", path, "Auth"), summary, responses: { 200: { description: "Auth request completed" }, @@ -1742,6 +1984,8 @@ export function buildOpenApiSpec() { registry.registerPath({ method: "get", path: "/v1/auth/session", + operationId: "getAuthSession", + tags: ["Auth"], summary: "Current authentication session", responses: { 200: { description: "Current auth session, or signed_out when no app session is present" }, @@ -1750,6 +1994,14 @@ export function buildOpenApiSpec() { registry.registerPath({ method: "post", path: "/v1/auth/github/token", + operationId: "postAuthGithubToken", + tags: ["Auth"], + // #9531: SESSION ONLY, declared rather than inferred. Both former models got this wrong in + // different directions -- isProtectedPath published the generic bearer+cookie pair (a bearer + // alone gets a 403 from this handler: it checks `identity.kind !== "session"`), and the real + // token gate exempts all of `/v1/auth/*`, which would publish it as needing nothing. It is + // gated, in the handler, on a browser session specifically. + security: [{ LoopOverSessionCookie: [] }], summary: "Fetch the current session's live GitHub token (for AMS git operations)", responses: { 200: { description: "The session's GitHub token", content: { "application/json": { schema: z.object({ token: z.string() }) } } }, @@ -1761,6 +2013,8 @@ export function buildOpenApiSpec() { registry.registerPath({ method: "get", path: "/v1/app/overview", + operationId: "getAppOverview", + tags: ["Control panel"], summary: "Live app overview assembled from backend data", responses: { 200: { description: "Live app overview assembled from backend data", content: { "application/json": { schema: z.record(z.string(), z.unknown()) } } }, @@ -1782,6 +2036,7 @@ export function buildOpenApiSpec() { registry.registerPath({ method: "get", path, + ...loopOperationMeta("get", path, "Control panel"), summary, responses: { 200: { description: "Live app API response", content: { "application/json": { schema: z.record(z.string(), z.unknown()) } } }, @@ -1792,6 +2047,8 @@ export function buildOpenApiSpec() { registry.registerPath({ method: "post", path: "/v1/app/selfhost/queue/dead/{id}/replay", + operationId: "postAppSelfhostQueueDeadByIdReplay", + tags: ["Control panel"], summary: "Replay a dead-letter queue job", request: { params: z.object({ @@ -1810,6 +2067,8 @@ export function buildOpenApiSpec() { registry.registerPath({ method: "delete", path: "/v1/app/selfhost/queue/dead/{id}", + operationId: "deleteAppSelfhostQueueDeadById", + tags: ["Control panel"], summary: "Delete a dead-letter queue job", request: { params: z.object({ @@ -1828,6 +2087,8 @@ export function buildOpenApiSpec() { registry.registerPath({ method: "delete", path: "/v1/app/selfhost/queue/dead", + operationId: "deleteAppSelfhostQueueDead", + tags: ["Control panel"], summary: "Purge all dead-letter queue jobs", responses: { 200: { description: "Dead-letter jobs purged", content: { "application/json": { schema: z.record(z.string(), z.unknown()) } } }, @@ -1839,6 +2100,8 @@ export function buildOpenApiSpec() { registry.registerPath({ method: "get", path: "/v1/app/selfhost/queue/dead", + operationId: "getAppSelfhostQueueDead", + tags: ["Control panel"], summary: "List dead-letter queue jobs", request: { query: z.object({ @@ -1863,6 +2126,8 @@ export function buildOpenApiSpec() { registry.registerPath({ method: "get", path: "/v1/app/analytics/weekly-value-report", + operationId: "getAppAnalyticsWeeklyValueReport", + tags: ["Control panel"], summary: "Weekly value report", request: { query: z.object({ @@ -1903,6 +2168,8 @@ export function buildOpenApiSpec() { registry.registerPath({ method: "get", path: "/v1/app/skipped-pr-audit", + operationId: "getAppSkippedPrAudit", + tags: ["Control panel"], summary: "Audit of pull requests the review agent skipped", request: { query: z.object({ @@ -1938,6 +2205,8 @@ export function buildOpenApiSpec() { registry.registerPath({ method: "post", path: "/v1/app/commands/preview", + operationId: "postAppCommandsPreview", + tags: ["Control panel"], summary: "Dry-run a sanitized @loopover command response", responses: { 200: { description: "Maintainer dry-run preview of a sanitized @loopover command response (no GitHub mutation)", content: { "application/json": { schema: CommandPreviewResponseSchema } } }, @@ -1954,6 +2223,7 @@ export function buildOpenApiSpec() { registry.registerPath({ method: "post", path, + ...loopOperationMeta("post", path, "Control panel"), summary, responses: { 200: { description: "Live app mutation or preview response", content: { "application/json": { schema: z.record(z.string(), z.unknown()) } } }, @@ -1968,6 +2238,8 @@ export function buildOpenApiSpec() { registry.registerPath({ method: "get", path: "/v1/internal/provider-credentials/{provider}", + operationId: "getInternalProviderCredentialsByProvider", + tags: ["Internal"], summary: "Read the secret-free status of a stored instance subscription credential", request: { params: z.object({ provider: z.enum(["claude-code", "codex"]) }) }, responses: { @@ -1989,6 +2261,8 @@ export function buildOpenApiSpec() { registry.registerPath({ method: "post", path: "/v1/internal/provider-credentials/{provider}", + operationId: "postInternalProviderCredentialsByProvider", + tags: ["Internal"], summary: "Store or replace an instance subscription credential, encrypted at rest", request: { params: z.object({ provider: z.enum(["claude-code", "codex"]) }), @@ -2004,6 +2278,8 @@ export function buildOpenApiSpec() { registry.registerPath({ method: "delete", path: "/v1/internal/provider-credentials/{provider}", + operationId: "deleteInternalProviderCredentialsByProvider", + tags: ["Internal"], summary: "Clear a stored instance subscription credential, falling back to the secret file or boot env", request: { params: z.object({ provider: z.enum(["claude-code", "codex"]) }) }, responses: { @@ -2015,6 +2291,8 @@ export function buildOpenApiSpec() { registry.registerPath({ method: "post", path: "/v1/internal/jobs/refresh-registry", + operationId: "postInternalJobsRefreshRegistry", + tags: ["Internal"], summary: "Queue a registry refresh job", responses: { 202: { description: "Registry refresh queued" }, @@ -2024,6 +2302,8 @@ export function buildOpenApiSpec() { registry.registerPath({ method: "post", path: "/v1/internal/jobs/backfill-registered-repos", + operationId: "postInternalJobsBackfillRegisteredRepos", + tags: ["Internal"], summary: "Queue a registered-repository backfill job", responses: { 202: { description: "Registered repo backfill queued" }, @@ -2033,6 +2313,8 @@ export function buildOpenApiSpec() { registry.registerPath({ method: "post", path: "/v1/internal/jobs/backfill-repo-segment", + operationId: "postInternalJobsBackfillRepoSegment", + tags: ["Internal"], summary: "Queue a repository segment backfill job", responses: { 202: { description: "Repository segment backfill queued" }, @@ -2043,6 +2325,8 @@ export function buildOpenApiSpec() { registry.registerPath({ method: "post", path: "/v1/internal/jobs/backfill-pr-details", + operationId: "postInternalJobsBackfillPrDetails", + tags: ["Internal"], summary: "Queue an open pull request detail backfill job", responses: { 202: { description: "Open PR detail backfill queued" }, @@ -2053,6 +2337,8 @@ export function buildOpenApiSpec() { registry.registerPath({ method: "post", path: "/v1/internal/jobs/generate-review-recap", + operationId: "postInternalJobsGenerateReviewRecap", + tags: ["Internal"], summary: "Queue a maintainer review recap digest job", responses: { 202: { description: "Maintainer review recap digest queued (#1963)" }, @@ -2074,6 +2360,7 @@ export function buildOpenApiSpec() { registry.registerPath({ method: "post", path, + ...loopOperationMeta("post", path, "Internal"), summary, responses: { 202: { description: "Internal job queued" }, @@ -2084,6 +2371,8 @@ export function buildOpenApiSpec() { registry.registerPath({ method: "post", path: "/v1/internal/bounties/import", + operationId: "postInternalBountiesImport", + tags: ["Internal"], summary: "Import a bounty snapshot", responses: { 200: { description: "Bounty snapshot imported" }, @@ -2091,6 +2380,13 @@ export function buildOpenApiSpec() { }, }); + // #9531: the ORB ingress, the control-panel app surface, and the per-repo key/settings routes. + // Registered from their own module rather than inline here because each entry declares an auth + // level that DERIVES its security stanza, instead of having one bolted on afterwards by + // applySecurityMetadata's path-prefix guesswork. + registerOrbAndControlRouteSpecs(registry); + registerInternalAndPublicRouteSpecs(registry); + const generator = new OpenApiGeneratorV3(registry.definitions); const document = generator.generateDocument({ openapi: "3.0.3", @@ -2108,6 +2404,16 @@ type GeneratedOperation = NonNullable security?: Array>; }; +/** + * Attach the security schemes, and the per-operation stanza derived from the REAL gate (#9531). + * + * This used to consult `isProtectedPath`, a second path-prefix model of the same policy that had + * already drifted out of agreement with the middleware -- it called every `/v1/*` route protected + * bar a short literal list, so the document advertised a bearer requirement on the entire + * `/v1/public/decision-ledger/*` family, all of which answer 200 unauthenticated. Now it asks + * `requiresApiToken`, the function the app itself gates on, so the document cannot claim something + * the runtime does not enforce. + */ function applySecurityMetadata(document: GeneratedOpenApiDocument): GeneratedOpenApiDocument { document.components = { ...(document.components ?? {}), @@ -2124,21 +2430,42 @@ function applySecurityMetadata(document: GeneratedOpenApiDocument): GeneratedOpe name: "loopover_session", description: "HttpOnly browser session cookie set by GitHub web OAuth.", }, + // #9531: the ORB ingress does not authenticate the way the rest of the API does, and the old + // path-prefix security model published it as needing no credential at all. It needs a + // different one. + OrbBearer: { + type: "http", + scheme: "bearer", + description: "ORB-issued instance token, minted by POST /v1/orb/token and presented by a self-hosted ORB instance on the relay endpoints. Not a LoopOver API token.", + }, + OrbWebhookSignature: { + type: "apiKey", + in: "header", + name: "x-loopover-signature", + description: "HMAC signature over the raw request body, verified against the instance's shared secret. The webhook carries no bearer token.", + }, }, }; for (const [path, pathItem] of Object.entries(document.paths)) { - if (!pathItem || !isProtectedPath(path)) continue; + if (!pathItem) continue; + // The document writes `{param}`; the gate matches the concrete path a caller sends. Substituting + // a placeholder segment keeps the two comparable -- every exemption in requiresApiToken that + // covers a dynamic route is a regex over non-slash segments, which any placeholder satisfies. + const gated = requiresApiToken(path.replace(/\{[^}]+\}/g, "_")); for (const method of ["get", "post", "put", "patch", "delete"] as const) { const operation = pathItem[method] as GeneratedOperation | undefined; - if (operation) operation.security = [{ LoopOverBearer: [] }, { LoopOverSessionCookie: [] }]; + if (!operation) continue; + // NEVER overwrite a declared stanza. Routes registered through defineRoute already carry the + // scheme their own `auth` implies -- OrbBearer for the AMS ingress, a signature header for the + // webhook, bearer-only for `/v1/internal/*`, `[]` for public -- and clobbering that with the + // generic pair is precisely the bug this function used to have: every auth level in the + // published document compiled to the same requirement, so it advertised a LoopOver bearer on + // the ORB ingress route whose own comment says it takes a shared-secret header instead. + if (operation.security !== undefined) continue; + // Only the legacy registerPath calls reach here -- they declare no auth, so the gate is the + // only thing that knows. + if (gated) operation.security = [{ LoopOverBearer: [] }, { LoopOverSessionCookie: [] }]; } } return document; } - -function isProtectedPath(path: string): boolean { - if (path === "/health" || path === "/openapi.json" || path === "/mcp" || path === "/v1/mcp/compatibility" || path === "/v1/public/stats" || path === "/v1/public/github/repos/{owner}/{repo}/stats" || path === "/v1/public/repos/{owner}/{repo}/quality") return false; - if (path.startsWith("/v1/auth/")) return path === "/v1/auth/github/token"; - if (path === "/v1/github/webhook") return false; - return path.startsWith("/v1/"); -} diff --git a/src/openapi/unspecced-routes-baseline.json b/src/openapi/unspecced-routes-baseline.json deleted file mode 100644 index a5eef45230..0000000000 --- a/src/openapi/unspecced-routes-baseline.json +++ /dev/null @@ -1,92 +0,0 @@ -[ - "DELETE /v1/internal/repos/{owner}/{repo}/ai-key", - "DELETE /v1/internal/repos/{owner}/{repo}/linear-key", - "DELETE /v1/repos/{owner}/{repo}/ai-key", - "DELETE /v1/repos/{owner}/{repo}/linear-key", - "GET /loopover/shot", - "GET /openapi.json", - "GET /v1/app/installations", - "GET /v1/app/installations/{id}/health", - "GET /v1/app/installations/{id}/repair", - "GET /v1/app/kill-switch", - "GET /v1/drafts/auth/callback", - "GET /v1/drafts/{id}", - "GET /v1/internal/audit-labels", - "GET /v1/internal/calibration", - "GET /v1/internal/calibration-trend", - "GET /v1/internal/calibration/knobs", - "GET /v1/internal/calibration/satisfaction-floor", - "GET /v1/internal/decision", - "GET /v1/internal/fairness/contributors", - "GET /v1/internal/fairness/contributors/{login}", - "GET /v1/internal/fleet/analytics", - "GET /v1/internal/ops/stats", - "GET /v1/internal/orb/installations", - "GET /v1/internal/orb/instances", - "GET /v1/internal/parity", - "GET /v1/internal/predicted-agreement", - "GET /v1/internal/repos/{owner}/{repo}/ai-key", - "GET /v1/internal/repos/{owner}/{repo}/contribution-policy", - "GET /v1/internal/repos/{owner}/{repo}/linear-key", - "GET /v1/internal/retention/preview", - "GET /v1/internal/status", - "GET /v1/mcp/enrichment-analyzers", - "GET /v1/mcp/finding-taxonomy", - "GET /v1/orb/oauth/callback", - "GET /v1/public/repos/{owner}/{repo}/badge.json", - "GET /v1/public/repos/{owner}/{repo}/badge.svg", - "GET /v1/public/subnet-interface", - "GET /v1/repos/{owner}/{repo}/ai-key", - "GET /v1/repos/{owner}/{repo}/linear-key", - "POST /v1/ams/ingest", - "POST /v1/app/fleet/config-push", - "POST /v1/app/installations/{id}/repair/refresh", - "POST /v1/app/kill-switch", - "POST /v1/app/miner-dashboard/refresh", - "POST /v1/contributors/{login}/ams-notifications", - "POST /v1/drafts", - "POST /v1/internal/audit-labels/adjudicate", - "POST /v1/internal/calibration/loosen-satisfaction-floor", - "POST /v1/internal/jobs/backfill-contributor-gate-history/run", - "POST /v1/internal/jobs/backfill-pr-details/run", - "POST /v1/internal/jobs/backfill-registered-repos/run", - "POST /v1/internal/jobs/backfill-repo-segment/run", - "POST /v1/internal/jobs/build-contributor-decision-packs/run", - "POST /v1/internal/jobs/file-upstream-drift-issues/run", - "POST /v1/internal/jobs/generate-review-recap/run", - "POST /v1/internal/jobs/generate-signal-snapshots/run", - "POST /v1/internal/jobs/generate-weekly-value-report/run", - "POST /v1/internal/jobs/rag-index", - "POST /v1/internal/jobs/refresh-contributor-activity", - "POST /v1/internal/jobs/refresh-contributor-activity/run", - "POST /v1/internal/jobs/refresh-installation-health/run", - "POST /v1/internal/jobs/refresh-registry/run", - "POST /v1/internal/jobs/refresh-scoring-model/run", - "POST /v1/internal/jobs/refresh-upstream-drift/run", - "POST /v1/internal/jobs/regate-pr", - "POST /v1/internal/jobs/rollup-product-usage", - "POST /v1/internal/jobs/rollup-product-usage/run", - "POST /v1/internal/orb/enrollments", - "POST /v1/internal/orb/enrollments/{enrollId}/revoke", - "POST /v1/internal/orb/installations/backfill", - "POST /v1/internal/orb/installations/register", - "POST /v1/internal/orb/instances/register", - "POST /v1/internal/queue-intelligence", - "POST /v1/internal/repos/{owner}/{repo}/ai-key", - "POST /v1/internal/repos/{owner}/{repo}/contribution-policy", - "POST /v1/internal/repos/{owner}/{repo}/linear-key", - "POST /v1/internal/repos/{owner}/{repo}/settings", - "POST /v1/local/remediation-plan", - "POST /v1/loop/request-apr-transfer", - "POST /v1/orb/relay", - "POST /v1/orb/relay/pull", - "POST /v1/orb/relay/register", - "POST /v1/orb/token", - "POST /v1/orb/webhook", - "POST /v1/repos/{owner}/{repo}/ai-key", - "POST /v1/repos/{owner}/{repo}/linear-key", - "POST /v1/repos/{owner}/{repo}/pulls/{number}/chat-qa", - "PUT /v1/app/installations/{id}/agent/bulk-settings", - "PUT /v1/repos/{owner}/{repo}/ai-review", - "PUT /v1/repos/{owner}/{repo}/settings" -] diff --git a/test/unit/define-route.test.ts b/test/unit/define-route.test.ts index db8ccdb182..018a0d1974 100644 --- a/test/unit/define-route.test.ts +++ b/test/unit/define-route.test.ts @@ -149,7 +149,7 @@ describe("defineRoute", () => { expect((await app.request("/v1/thing?limit=nope")).status).toBe(400); }); - it("omits a security stanza for public routes and requires credentials otherwise", () => { + it("declares an empty security stanza for public routes and requires credentials otherwise", () => { const { app, registry } = build(); for (const [auth, path, id] of [ ["public", "/v1/open", "openOp"], @@ -160,7 +160,11 @@ describe("defineRoute", () => { defineRoute(app, registry, { method: "get", path, operationId: id, tags: ["t"], summary: "s", auth, responses: { 200: { description: "ok" } } }, (c) => c.json({})); } const paths = generate(registry).paths ?? {}; - expect(paths["/v1/open"]?.get?.security).toBeUndefined(); + // `[]`, not undefined (#9531): an empty array is OpenAPI's explicit "no credential required", + // where an absent one means "not stated". applySecurityMetadata fills in the legacy registerPath + // calls that never declared anything, and it can only leave a deliberately public route alone if + // that route says so out loud. + expect(paths["/v1/open"]?.get?.security).toEqual([]); // Internal routes are bearer-only: no browser session ever reaches them. expect(paths["/v1/internal/thing"]?.get?.security).toEqual([{ LoopOverBearer: [] }]); for (const path of ["/v1/tokened", "/v1/sessioned"]) { diff --git a/test/unit/openapi-security-parity.test.ts b/test/unit/openapi-security-parity.test.ts new file mode 100644 index 0000000000..75b16ff376 --- /dev/null +++ b/test/unit/openapi-security-parity.test.ts @@ -0,0 +1,83 @@ +// The published security stanzas must describe what the app actually enforces (#9531). +// +// This replaces `isProtectedPath`, which was a SECOND model of the same policy living in the spec +// builder -- a path-prefix approximation that treated every `/v1/*` route as protected bar a short +// literal list. It had already drifted from the real gate: the whole `/v1/public/decision-ledger/*` +// family answers 200 to an anonymous caller and was published as requiring a bearer. +// +// There is now one model (src/auth/route-auth.ts) that both the middleware and the document read. +// These tests guard the two ways that can still go wrong: a route whose declared stanza contradicts +// the gate, and the gate quietly changing which routes are open. +import { describe, expect, it } from "vitest"; +import { buildOpenApiSpec } from "../../src/openapi/spec"; +import { requiresApiToken } from "../../src/auth/route-auth"; + +type Operation = { security?: Array> }; + +function operations(): Array<{ path: string; method: string; operation: Operation }> { + const spec = buildOpenApiSpec(); + const rows: Array<{ path: string; method: string; operation: Operation }> = []; + for (const [path, item] of Object.entries(spec.paths)) { + for (const method of ["get", "post", "put", "patch", "delete"] as const) { + const operation = (item as Record)[method] as Operation | undefined; + if (operation) rows.push({ path, method, operation }); + } + } + return rows; +} + +/** The document writes `{param}`; the gate matches a concrete path. */ +const concrete = (path: string): string => path.replace(/\{[^}]+\}/g, "_"); + +describe("OpenAPI security parity with the real gate (#9531)", () => { + // `requiresApiToken` is ONE gate, not the only one, and reading it as "needs no credential at all" + // is the mistake this test originally made -- twice. Four route families are exempt from it + // precisely because they carry their own credential: + // + // - `/v1/internal/*`, gated by its own INTERNAL_JOB_TOKEN middleware (`app.use("/v1/internal/*")`); + // - the ORB ingress, presenting an ORB-issued bearer; + // - the ORB webhook, verified by a body signature; + // - `POST /v1/auth/github/token`, gated IN THE HANDLER on a browser session (it 403s a + // bearer-only caller), which is why it declares LoopOverSessionCookie alone. + // + // Naming the actual scheme is more accurate than silence in every one of those cases, so what + // this hunts is narrower: a route with no gate of any kind still advertising a credential. + const SELF_GATED_SCHEMES = ["OrbBearer", "OrbWebhookSignature", "LoopOverSessionCookie"]; + const selfGated = (path: string, operation: Operation): boolean => + path.startsWith("/v1/internal/") || + // A session-cookie-ONLY stanza means handler-gated; the generic bearer+cookie PAIR is what the + // legacy fill-in emits, and that is the thing worth catching on an ungated route. + (operation.security?.length === 1 && SELF_GATED_SCHEMES.some((scheme) => scheme in operation.security![0]!)); + + it("never advertises a credential requirement on a route no gate protects", () => { + const contradictions = operations() + .filter(({ path, operation }) => !requiresApiToken(concrete(path)) && !selfGated(path, operation) && (operation.security?.length ?? 0) > 0) + .map(({ path, method }) => `${method.toUpperCase()} ${path}`); + expect(contradictions).toEqual([]); + }); + + it("never publishes a gated route as needing no credential", () => { + const silent = operations() + .filter(({ path, operation }) => requiresApiToken(concrete(path)) && operation.security !== undefined && operation.security.length === 0) + .map(({ path, method }) => `${method.toUpperCase()} ${path}`); + expect(silent).toEqual([]); + }); + + it("distinguishes the auth levels rather than collapsing them to one stanza", () => { + // The defect this replaced: every operation emitted the same LoopOverBearer+SessionCookie pair, + // so the document could not tell a caller which routes need nothing, which need an internal + // token, and which need the ORB's own credential. + const shapes = new Set(operations().map(({ operation }) => JSON.stringify(operation.security ?? null))); + expect(shapes.size).toBeGreaterThan(3); + expect(shapes).toContain(JSON.stringify([])); + expect(shapes).toContain(JSON.stringify([{ OrbBearer: [] }])); + expect(shapes).toContain(JSON.stringify([{ LoopOverBearer: [] }])); + }); + + it("declares every security scheme it references", () => { + const spec = buildOpenApiSpec(); + const declared = new Set(Object.keys(spec.components?.securitySchemes ?? {})); + const referenced = new Set(operations().flatMap(({ operation }) => (operation.security ?? []).flatMap((entry) => Object.keys(entry)))); + expect([...referenced].filter((name) => !declared.has(name))).toEqual([]); + }); +}); diff --git a/test/unit/openapi.test.ts b/test/unit/openapi.test.ts index e3632cb783..8820d747a1 100644 --- a/test/unit/openapi.test.ts +++ b/test/unit/openapi.test.ts @@ -122,9 +122,11 @@ describe("OpenAPI contract", () => { "/v1/repos/{owner}/{repo}/maintainer-lane", "/v1/repos/{owner}/{repo}/pulls/{number}/review-intelligence", "/v1/repos/{owner}/{repo}/pulls/{number}/scoring-preview", - "/v1/internal/jobs/generate-signal-snapshots/run", "/v1/auth/extension/session", "/v1/extension/pull-context", + // #9531 removed "/v1/internal/jobs/generate-signal-snapshots/run" from this list: the route + // is LIVE (src/api/routes.ts registers it), so asserting the document must not describe it + // was asserting the document must stay wrong. It is specced now, like every other route. ]) { expect(spec.paths[removedPath]).toBeUndefined(); } @@ -185,7 +187,10 @@ describe("OpenAPI contract", () => { expect(spec.paths["/v1/app/overview"]?.get?.security).toEqual([{ LoopOverBearer: [] }, { LoopOverSessionCookie: [] }]); expect(spec.paths["/v1/auth/session"]?.get?.security).toBeUndefined(); expect(spec.paths["/v1/auth/logout"]?.post?.security).toBeUndefined(); - expect(spec.paths["/v1/auth/github/token"]?.post?.security).toEqual([{ LoopOverBearer: [] }, { LoopOverSessionCookie: [] }]); + // #9531: session-only, not the generic pair. This handler returns 403 to a bearer-only caller + // (`identity.kind !== "session"`), so advertising LoopOverBearer as sufficient was a published + // lie -- one neither of the two former security models got right. + expect(spec.paths["/v1/auth/github/token"]?.post?.security).toEqual([{ LoopOverSessionCookie: [] }]); }); // #9303: selftune-override routes were live but undocumented; assert both paths, their HTTP methods, and the @@ -333,8 +338,13 @@ describe("OpenAPI contract", () => { expect(propKeys(response)).toEqual(Object.keys(outputShape).sort()); } - // request-apr-transfer is explicitly out of scope for this batch and must stay undocumented here. - expect(spec.paths["/v1/loop/request-apr-transfer"]).toBeUndefined(); + // request-apr-transfer was out of scope for #9309's batch. #9531 documents every live route, so + // it is described now -- but deliberately WITHOUT the tool-parity request/response schemas the + // family above carries, because it has no MCP tool to be at parity with. Asserting that + // distinction is what this line is for now that "absent entirely" is no longer true of anything. + const aprTransfer = spec.paths["/v1/loop/request-apr-transfer"]?.post; + expect(aprTransfer?.operationId).toBe("requestAprTransfer"); + expect(aprTransfer?.requestBody).toBeUndefined(); }); // #9308: the eight /v1/lint/* + /v1/validate/focus-manifest advisory-check routes are each backed by an MCP diff --git a/test/unit/route-spec-ratchet.test.ts b/test/unit/route-spec-ratchet.test.ts index f80302b0ef..ddca09389e 100644 --- a/test/unit/route-spec-ratchet.test.ts +++ b/test/unit/route-spec-ratchet.test.ts @@ -7,10 +7,10 @@ // 90 live routes had no spec entry, including every ORB management surface (/v1/orb/*, // /v1/internal/orb/*, fleet config-push, kill-switch, the DLQ admin quartet). // -// The baseline is a RATCHET, not an allowlist: it may only shrink. A route removed from it can -// never silently come back, and a NEW unspecced route fails immediately rather than joining a -// growing pile. #9531 drives it to zero, at which point the file is deleted and this test keeps -// enforcing the invariant with an empty set. +// The baseline WAS a ratchet that could only shrink. #9531 drove it to zero and deleted the file; +// what remains is the invariant it was protecting, now enforced absolutely: every live route has an +// operation, and every operation has a live route. There is no longer anywhere to record an +// exception, which is the point -- a new unspecced route fails immediately. import { describe, expect, it } from "vitest"; import { createApp } from "../../src/api/routes"; import { buildOpenApiSpec } from "../../src/openapi/spec"; @@ -21,9 +21,6 @@ import { listSpecRouteKeys, normalizeRoutePath, } from "../../src/openapi/route-inventory"; -import baseline from "../../src/openapi/unspecced-routes-baseline.json" with { type: "json" }; - -const KNOWN_UNSPECCED = new Set(baseline as string[]); describe("route inventory helpers", () => { it("rewrites Hono path params into OpenAPI form", () => { @@ -62,41 +59,29 @@ describe("route inventory helpers", () => { describe("route↔spec ratchet", () => { const diff = diffRoutesAgainstSpec(listLiveRouteKeys(createApp() as never), listSpecRouteKeys(buildOpenApiSpec() as never)); - it("describes every live route in the OpenAPI document, except the shrinking known-gap baseline", () => { - const newlyUnspecced = diff.missingFromSpec.filter((key) => !KNOWN_UNSPECCED.has(key)); - expect( - newlyUnspecced, - `These routes exist in createApp() but have no OpenAPI operation. Register them through the spec ` + - `(see src/openapi/spec.ts) rather than adding them to src/openapi/unspecced-routes-baseline.json -- ` + - `that file may only shrink (#9531).`, - ).toEqual([]); - }); - - it("keeps the baseline honest: an entry that is now specced must be removed from it", () => { - // Without this, the baseline would quietly retain entries for routes someone specced along the - // way, and the "may only shrink" promise would be unverifiable -- the file would stop - // describing the real remaining work. - const stale = [...KNOWN_UNSPECCED].filter((key) => !diff.missingFromSpec.includes(key)); + it("describes every live route in the OpenAPI document", () => { expect( - stale, - `These routes ARE now described in the OpenAPI document but are still listed in ` + - `src/openapi/unspecced-routes-baseline.json. Delete them from that file.`, + diff.missingFromSpec, + `These routes exist in createApp() but have no OpenAPI operation. Register them through the ` + + `seam (src/openapi/define-route.ts) -- there is no baseline to add them to (#9531).`, ).toEqual([]); }); it("never publishes an operation for a route the app does not serve", () => { // Strictly worse than a missing entry: a generated client compiles a call that 404s at runtime. - // No baseline for this one -- it is zero today and must stay zero. + // This direction never had a baseline and caught two invented job routes during #9531 -- an + // assumption that every internal job had both an enqueue and a `/run` form, which two of them + // do not. expect( diff.missingFromApp, "These operations are in the OpenAPI document but no live route serves them.", ).toEqual([]); }); - it("has a baseline that only contains real, currently-unspecced routes", () => { - expect(KNOWN_UNSPECCED.size).toBeGreaterThan(0); - for (const key of KNOWN_UNSPECCED) { - expect(key, `baseline entry is not a METHOD /path key`).toMatch(/^(GET|POST|PUT|PATCH|DELETE) \//); - } + it("describes a real, non-trivial surface -- so neither direction can pass by describing nothing", () => { + // Both assertions above are satisfied by two empty lists. This is what makes them mean + // something. + expect(listLiveRouteKeys(createApp() as never).length).toBeGreaterThan(200); + expect(listSpecRouteKeys(buildOpenApiSpec() as never).length).toBeGreaterThan(200); }); });