From 534631e1f6d54019cf6c79ad2a132a937fadec28 Mon Sep 17 00:00:00 2001 From: JSONbored <49853598+JSONbored@users.noreply.github.com> Date: Tue, 28 Jul 2026 23:51:37 -0700 Subject: [PATCH] feat(contract): give the request schemas, the control plane, and the self-host endpoints one contract (#9750) The three requirements of #9531 that did not land with it. REQUEST SCHEMAS. All 52 declarations move out of src/api/routes.ts into @loopover/contract, which leaves that file with zero z.object literals (asserted, not trusted). An MCP tool wrapping one of these routes now references the same object rather than an independently-authored copy of the shape -- the drift that made FleetRegisterInstallationInput omit the `registered` field its route accepts, leaving the opt-out unreachable over MCP until #9518 found it. Every schema is the declaration that was there, verbatim, so a payload the API accepted is still accepted and one it rejected is still rejected with the same status; the full suite passing unchanged is the evidence. Their bounds could not travel with them -- the contract is a zod-only leaf that cannot import the Worker or the engine -- so limits.ts restates nine of them and a meta-test pins each against its original, extending the posture PREFLIGHT_LIMITS already established there. The settings write schema is the one exception to a plain move: its commandAuthorization default is a twenty-key policy the engine owns, so the contract exports the shape as a factory and the src layer applies the engine's own value once, rather than the leaf restating a policy that large. CONTROL PLANE. It had no machine-readable contract of any kind -- no zod, no spec -- and its only description was prose plus the hardcoded fetches in the miner's admin client. It now has zod schemas, a committed openapi.json generated from the same route table the Worker registers from, and a drift check in test:ci. Because control-plane/ is not a workspace member and installs from its own lockfile, it reads a GENERATED mirror of the contract module rather than importing the package; the mirror is byte-identical by construction and its own check proves it. The admin client stops casting. TenantRecord was `Record`, so a field the control plane renamed reached a printed table as undefined with nothing reporting it -- and listTenants degraded a malformed response to `[]`, which for an admin listing is the most misleading possible answer. Both now parse against the published schema and fail loud, matching the posture that module's own header already claimed. Which surfaced a live defect: `loopover-miner tenant list` printed `(unknown)` for EVERY tenant name. The renderer read `record.name`, but the control plane has never returned a top-level name -- its projection is `{ tenant: { name }, ... }`. The bug survived because the fixtures were hand-written in the flat shape, so they agreed with the renderer and neither agreed with the server. Typing the record from the schema is what surfaced it: the fixtures stopped compiling. SELF-HOST INFRA. /ready, /metrics, /setup, and /setup/callback are answered by src/server.ts's own fetch handler, ahead of the Hono app, so the route-spec ratchet cannot see them and they have been served and undocumented since self-host shipped. They are specced under a `Self-host infra` tag, and the ratchet grants them its one exemption -- an exemption that cannot be widened by hand, because the set is read back out of src/server.ts and narrowed by what createApp() actually serves. That rule is also why /health and /v1/github/webhook need no entry: the app serves and documents both, the entrypoint merely reaches them first. --- apps/loopover-ui/public/openapi.json | 140 ++ control-plane/openapi.json | 1143 +++++++++++++++++ control-plane/package-lock.json | 12 +- control-plane/package.json | 5 +- .../src/generated/control-plane-contract.ts | 243 ++++ control-plane/src/http-app.ts | 82 +- package.json | 6 +- packages/loopover-contract/package.json | 8 + .../loopover-contract/src/api-requests.ts | 770 +++++++++++ .../loopover-contract/src/control-plane.ts | 236 ++++ packages/loopover-contract/src/index.ts | 13 + packages/loopover-contract/src/limits.ts | 42 + packages/loopover-miner/lib/tenant-cli.ts | 14 +- packages/loopover-miner/lib/tenant-client.ts | 35 +- scripts/gen-control-plane-contract.ts | 61 + scripts/gen-control-plane-openapi.ts | 120 ++ src/api/routes.ts | 792 +----------- src/mcp/server.ts | 9 + src/openapi/route-inventory.ts | 8 +- src/openapi/selfhost-infra-route-specs.ts | 122 ++ src/openapi/spec.ts | 4 + test/unit/contract-api-requests.test.ts | 206 +++ test/unit/control-plane-contract.test.ts | 150 +++ test/unit/docs-examples-schema.test.ts | 2 +- test/unit/miner-tenant.test.ts | 67 +- test/unit/selfhost-infra-route-specs.test.ts | 119 ++ 26 files changed, 3618 insertions(+), 791 deletions(-) create mode 100644 control-plane/openapi.json create mode 100644 control-plane/src/generated/control-plane-contract.ts create mode 100644 packages/loopover-contract/src/api-requests.ts create mode 100644 packages/loopover-contract/src/control-plane.ts create mode 100644 scripts/gen-control-plane-contract.ts create mode 100644 scripts/gen-control-plane-openapi.ts create mode 100644 src/openapi/selfhost-infra-route-specs.ts create mode 100644 test/unit/contract-api-requests.test.ts create mode 100644 test/unit/control-plane-contract.test.ts create mode 100644 test/unit/selfhost-infra-route-specs.test.ts diff --git a/apps/loopover-ui/public/openapi.json b/apps/loopover-ui/public/openapi.json index bb41b1537c..aaea48eef6 100644 --- a/apps/loopover-ui/public/openapi.json +++ b/apps/loopover-ui/public/openapi.json @@ -26891,6 +26891,146 @@ } } } + }, + "/ready": { + "get": { + "operationId": "getSelfhostReadiness", + "tags": [ + "Self-host infra" + ], + "summary": "Readiness probe for this self-hosted instance", + "description": "Runs the instance's readiness probes against its own database. Answers 503 when any probe fails, so a container orchestrator can gate traffic on the status code alone.", + "security": [], + "responses": { + "200": { + "description": "Every readiness probe passed", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "ok": { + "type": "boolean" + } + }, + "required": [ + "ok" + ], + "additionalProperties": { + "nullable": true + }, + "description": "Readiness result. `ok: false` is answered with a 503 so an orchestrator can act on the status line alone." + } + } + } + }, + "503": { + "description": "At least one readiness probe failed", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "ok": { + "type": "boolean" + } + }, + "required": [ + "ok" + ], + "additionalProperties": { + "nullable": true + }, + "description": "Readiness result. `ok: false` is answered with a 503 so an orchestrator can act on the status line alone." + } + } + } + } + } + } + }, + "/metrics": { + "get": { + "operationId": "getSelfhostMetrics", + "tags": [ + "Self-host infra" + ], + "summary": "Prometheus metrics for this self-hosted instance", + "description": "Prometheus text exposition (`text/plain; version=0.0.4`), not JSON — this is the scrape endpoint, and it is the one operation here whose response is deliberately not a schema.", + "security": [], + "responses": { + "200": { + "description": "Metrics in Prometheus text exposition format" + } + } + } + }, + "/setup": { + "get": { + "operationId": "getSelfhostSetupWizard", + "tags": [ + "Self-host infra" + ], + "summary": "First-run GitHub App setup wizard", + "description": "Available only while no GitHub App is configured — a live install cannot be rebound. Returns the token-entry form until SELFHOST_SETUP_TOKEN is presented, via the `x-setup-token` header or an `authorization` bearer; never a query parameter, which would leak the secret to access logs and browser history. In brokered mode (ORB_ENROLLMENT_SECRET set) it short-circuits to a brokered-mode page instead.", + "security": [], + "responses": { + "200": { + "description": "The token-entry form, or the setup page once authenticated" + }, + "400": { + "description": "SELFHOST_SETUP_TOKEN or PUBLIC_API_ORIGIN is not configured" + }, + "403": { + "description": "A setup token was supplied and did not match" + } + } + }, + "post": { + "operationId": "postSelfhostSetupWizard", + "tags": [ + "Self-host infra" + ], + "summary": "Submit the setup token from the wizard's form", + "description": "The browser half of the same wizard: the token travels in the POST body rather than a header, for the same reason it never travels in the URL.", + "security": [], + "responses": { + "200": { + "description": "The setup page, once the submitted token matched" + }, + "400": { + "description": "SELFHOST_SETUP_TOKEN or PUBLIC_API_ORIGIN is not configured" + }, + "403": { + "description": "The submitted token did not match" + } + } + } + }, + "/setup/callback": { + "get": { + "operationId": "getSelfhostSetupCallback", + "tags": [ + "Self-host infra" + ], + "summary": "GitHub App creation callback for the setup wizard", + "description": "Where GitHub returns after the operator creates the App. The one-time code is exchanged for the App's private key and webhook secret, so the redirect origin comes from PUBLIC_API_ORIGIN rather than the request's Host header — a spoofed Host would otherwise send the callback, and the credentials, somewhere else.", + "security": [], + "responses": { + "200": { + "description": "The App was created and its credentials were persisted" + }, + "400": { + "description": "No `code` parameter, or the wizard is not configured" + }, + "403": { + "description": "The `state` parameter did not match the one issued" + }, + "500": { + "description": "GitHub rejected the code exchange" + } + } + } } }, "servers": [ diff --git a/control-plane/openapi.json b/control-plane/openapi.json new file mode 100644 index 0000000000..f07323662a --- /dev/null +++ b/control-plane/openapi.json @@ -0,0 +1,1143 @@ +{ + "openapi": "3.0.3", + "info": { + "title": "LoopOver control plane", + "version": "0.1.0", + "description": "Tenant provisioning for the hosted ORB + AMS fleet. Admin-only, apart from the GitHub webhook ingress." + }, + "components": { + "securitySchemes": { + "ControlPlaneAdminBearer": { + "type": "http", + "scheme": "bearer", + "description": "The control plane's own admin token. Distinct from any tenant's per-instance secrets and from a LoopOver API token." + }, + "GitHubWebhookSignature": { + "type": "apiKey", + "in": "header", + "name": "x-hub-signature-256", + "description": "GitHub's HMAC-SHA256 over the raw request body, verified against the hosted fleet's own App webhook secret." + } + }, + "schemas": {}, + "parameters": {} + }, + "paths": { + "/health": { + "get": { + "operationId": "getControlPlaneHealth", + "tags": [ + "Control plane" + ], + "summary": "Liveness probe", + "security": [], + "responses": { + "200": { + "description": "The service is up", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "status": { + "type": "string", + "enum": [ + "ok" + ] + }, + "service": { + "type": "string", + "enum": [ + "control-plane" + ] + } + }, + "required": [ + "status", + "service" + ] + } + } + } + } + } + } + }, + "/v1/tenants": { + "post": { + "operationId": "createTenant", + "tags": [ + "Control plane" + ], + "summary": "Provision a tenant", + "description": "NOT idempotent, by design: an existing tenant of the same name AND product in a non-recreatable state is a 409, not a no-op. A previously torn-down or failed tenant may be recreated — a failed provision must not become a permanent block on retrying setup — and the recreated record does not inherit the old one's createdAt.", + "security": [ + { + "ControlPlaneAdminBearer": [] + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "name": { + "type": "string", + "minLength": 1 + }, + "product": { + "type": "string", + "minLength": 1 + }, + "schedule": { + "type": "object", + "properties": { + "command": { + "type": "string", + "enum": [ + "discover", + "manage-poll", + "attempt" + ] + }, + "args": { + "type": "array", + "items": { + "type": "string" + } + }, + "intervalMs": { + "type": "number", + "minimum": 0, + "exclusiveMinimum": true + } + }, + "required": [ + "command", + "intervalMs" + ] + }, + "orbInstallationId": { + "type": "integer", + "minimum": 0, + "exclusiveMinimum": true + } + }, + "required": [ + "name", + "product" + ] + } + } + } + }, + "responses": { + "201": { + "description": "Tenant provisioned. Returns the record in its settled state.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "tenant": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "pinnedVersion": { + "type": "string", + "nullable": true + } + }, + "required": [ + "name" + ] + }, + "product": { + "type": "string" + }, + "state": { + "type": "string", + "enum": [ + "provisioning", + "active", + "suspended", + "failed", + "torn down" + ] + }, + "amsSchedule": { + "type": "object", + "properties": { + "command": { + "type": "string", + "enum": [ + "discover", + "manage-poll", + "attempt" + ] + }, + "args": { + "type": "array", + "items": { + "type": "string" + } + }, + "intervalMs": { + "type": "number", + "minimum": 0, + "exclusiveMinimum": true + }, + "nextDueAt": { + "type": "string" + }, + "lastRunAt": { + "type": "string" + }, + "lastExitCode": { + "type": "integer" + } + }, + "required": [ + "command", + "args", + "intervalMs", + "nextDueAt" + ] + }, + "orbInstallationId": { + "type": "integer", + "minimum": 0, + "exclusiveMinimum": true + }, + "secretRef": { + "type": "string" + } + }, + "required": [ + "tenant", + "product", + "state" + ], + "additionalProperties": { + "nullable": true + } + } + } + } + }, + "400": { + "description": "Unparseable body, or a field that failed validation", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + }, + "401": { + "description": "Missing or wrong admin bearer", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + }, + "409": { + "description": "A tenant of this name and product already exists, or the installation ID is already claimed", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + }, + "503": { + "description": "No admin token is configured on this deployment, so the surface fails closed", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + } + } + }, + "get": { + "operationId": "listTenants", + "tags": [ + "Control plane" + ], + "summary": "List every tenant with its lifecycle state", + "security": [ + { + "ControlPlaneAdminBearer": [] + } + ], + "responses": { + "200": { + "description": "Every tenant, with timestamps", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "tenants": { + "type": "array", + "items": { + "type": "object", + "properties": { + "tenant": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "pinnedVersion": { + "type": "string", + "nullable": true + } + }, + "required": [ + "name" + ] + }, + "product": { + "type": "string" + }, + "state": { + "type": "string", + "enum": [ + "provisioning", + "active", + "suspended", + "failed", + "torn down" + ] + }, + "amsSchedule": { + "type": "object", + "properties": { + "command": { + "type": "string", + "enum": [ + "discover", + "manage-poll", + "attempt" + ] + }, + "args": { + "type": "array", + "items": { + "type": "string" + } + }, + "intervalMs": { + "type": "number", + "minimum": 0, + "exclusiveMinimum": true + }, + "nextDueAt": { + "type": "string" + }, + "lastRunAt": { + "type": "string" + }, + "lastExitCode": { + "type": "integer" + } + }, + "required": [ + "command", + "args", + "intervalMs", + "nextDueAt" + ] + }, + "orbInstallationId": { + "type": "integer", + "minimum": 0, + "exclusiveMinimum": true + }, + "secretRef": { + "type": "string" + }, + "createdAt": { + "type": "string" + }, + "updatedAt": { + "type": "string" + } + }, + "required": [ + "tenant", + "product", + "state", + "createdAt", + "updatedAt" + ], + "additionalProperties": { + "nullable": true + } + } + } + }, + "required": [ + "tenants" + ] + } + } + } + }, + "401": { + "description": "Missing or wrong admin bearer", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + }, + "503": { + "description": "No admin token is configured on this deployment, so the surface fails closed", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + } + } + } + }, + "/v1/tenants/rollout": { + "post": { + "operationId": "rolloutTenants", + "tags": [ + "Control plane" + ], + "summary": "Roll a pinned image version out across tenants", + "description": "DISABLED, not unimplemented: the previous version silently no-opped on all four of its promised effects, so it answers 501 rather than accepting a request that changes nothing a caller can observe.", + "security": [ + { + "ControlPlaneAdminBearer": [] + } + ], + "responses": { + "401": { + "description": "Missing or wrong admin bearer", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + }, + "501": { + "description": "Tenant rollout is not implemented", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + }, + "503": { + "description": "No admin token is configured on this deployment, so the surface fails closed", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + } + } + } + }, + "/v1/tenants/{name}/orb-installation": { + "patch": { + "operationId": "relinkOrbInstallation", + "tags": [ + "Control plane" + ], + "summary": "Re-link an existing ORB tenant's GitHub App installation", + "description": "The manual recovery path for a tenant whose routing pointer was wiped by a since-fixed registry bug. Refuses to take an installation another currently-claiming tenant still holds, but never 409s a tenant against itself.", + "security": [ + { + "ControlPlaneAdminBearer": [] + } + ], + "parameters": [ + { + "schema": { + "type": "string" + }, + "required": true, + "name": "name", + "in": "path" + }, + { + "schema": { + "type": "string", + "minLength": 1 + }, + "required": true, + "name": "product", + "in": "query" + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "orbInstallationId": { + "type": "integer", + "minimum": 0, + "exclusiveMinimum": true + } + }, + "required": [ + "orbInstallationId" + ] + } + } + } + }, + "responses": { + "200": { + "description": "The installation is now linked to this tenant", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "tenant": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "pinnedVersion": { + "type": "string", + "nullable": true + } + }, + "required": [ + "name" + ] + }, + "product": { + "type": "string" + }, + "state": { + "type": "string", + "enum": [ + "provisioning", + "active", + "suspended", + "failed", + "torn down" + ] + }, + "amsSchedule": { + "type": "object", + "properties": { + "command": { + "type": "string", + "enum": [ + "discover", + "manage-poll", + "attempt" + ] + }, + "args": { + "type": "array", + "items": { + "type": "string" + } + }, + "intervalMs": { + "type": "number", + "minimum": 0, + "exclusiveMinimum": true + }, + "nextDueAt": { + "type": "string" + }, + "lastRunAt": { + "type": "string" + }, + "lastExitCode": { + "type": "integer" + } + }, + "required": [ + "command", + "args", + "intervalMs", + "nextDueAt" + ] + }, + "orbInstallationId": { + "type": "integer", + "minimum": 0, + "exclusiveMinimum": true + }, + "secretRef": { + "type": "string" + } + }, + "required": [ + "tenant", + "product", + "state" + ], + "additionalProperties": { + "nullable": true + } + } + } + } + }, + "400": { + "description": "Missing product query parameter, a non-orb product, or an invalid installation ID", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + }, + "401": { + "description": "Missing or wrong admin bearer", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + }, + "404": { + "description": "No such tenant for that name and product", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + }, + "409": { + "description": "Another live tenant already claims that installation", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + }, + "503": { + "description": "No admin token is configured on this deployment, so the surface fails closed", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + } + } + } + }, + "/v1/tenants/{name}": { + "delete": { + "operationId": "destroyTenant", + "tags": [ + "Control plane" + ], + "summary": "Tear a tenant down", + "description": "Refuses while the record is observably `provisioning`: there is nothing durable yet to revoke, and tearing down mid-provision races the in-flight create. Retry once it settles.", + "security": [ + { + "ControlPlaneAdminBearer": [] + } + ], + "parameters": [ + { + "schema": { + "type": "string" + }, + "required": true, + "name": "name", + "in": "path" + }, + { + "schema": { + "type": "string", + "minLength": 1 + }, + "required": true, + "name": "product", + "in": "query" + } + ], + "responses": { + "200": { + "description": "Tenant torn down", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "tenant": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "pinnedVersion": { + "type": "string", + "nullable": true + } + }, + "required": [ + "name" + ] + }, + "product": { + "type": "string" + }, + "state": { + "type": "string", + "enum": [ + "provisioning", + "active", + "suspended", + "failed", + "torn down" + ] + }, + "amsSchedule": { + "type": "object", + "properties": { + "command": { + "type": "string", + "enum": [ + "discover", + "manage-poll", + "attempt" + ] + }, + "args": { + "type": "array", + "items": { + "type": "string" + } + }, + "intervalMs": { + "type": "number", + "minimum": 0, + "exclusiveMinimum": true + }, + "nextDueAt": { + "type": "string" + }, + "lastRunAt": { + "type": "string" + }, + "lastExitCode": { + "type": "integer" + } + }, + "required": [ + "command", + "args", + "intervalMs", + "nextDueAt" + ] + }, + "orbInstallationId": { + "type": "integer", + "minimum": 0, + "exclusiveMinimum": true + }, + "secretRef": { + "type": "string" + } + }, + "required": [ + "tenant", + "product", + "state" + ], + "additionalProperties": { + "nullable": true + } + } + } + } + }, + "400": { + "description": "Missing product query parameter", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + }, + "401": { + "description": "Missing or wrong admin bearer", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + }, + "404": { + "description": "No such tenant for that name and product", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + }, + "409": { + "description": "The tenant is still provisioning", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + }, + "503": { + "description": "No admin token is configured on this deployment, so the surface fails closed", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + } + } + } + }, + "/v1/orb/webhook": { + "post": { + "operationId": "routeOrbWebhook", + "tags": [ + "Control plane" + ], + "summary": "Route a GitHub webhook to the hosted ORB tenant it belongs to", + "description": "Deliberately outside the admin-bearer middleware: GitHub authenticates a webhook with its own HMAC signature over the raw body, not this service's admin token.", + "security": [ + { + "GitHubWebhookSignature": [] + } + ], + "responses": { + "200": { + "description": "Delivered to the tenant's container" + }, + "401": { + "description": "The signature did not verify", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + }, + "502": { + "description": "The tenant's container could not be reached", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + }, + "503": { + "description": "No webhook binding is configured on this deployment", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + } + } + } + } + } +} diff --git a/control-plane/package-lock.json b/control-plane/package-lock.json index 575e41fe8b..421eb25683 100644 --- a/control-plane/package-lock.json +++ b/control-plane/package-lock.json @@ -9,7 +9,8 @@ "version": "0.1.0", "dependencies": { "@cloudflare/containers": "^0.3.7", - "hono": "^4.12.31" + "hono": "^4.12.31", + "zod": "^4.4.3" }, "devDependencies": { "@cloudflare/workers-types": "^5.20260724.1", @@ -2666,6 +2667,15 @@ "@poppinss/exception": "^1.2.2", "error-stack-parser-es": "^1.0.5" } + }, + "node_modules/zod": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", + "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } } } } diff --git a/control-plane/package.json b/control-plane/package.json index 8a1a72f0dc..b7f58bf8a6 100644 --- a/control-plane/package.json +++ b/control-plane/package.json @@ -3,7 +3,7 @@ "version": "0.1.0", "private": true, "type": "module", - "description": "LoopOver control-plane — product-agnostic tenant provisioning/deprovisioning orchestration behind an injectable driver interface (#7524, part of the #7173 ORB+AMS hosting control-plane). Fake in-memory driver only; real Cloudflare/Postgres drivers are out of scope pending the Postgres-provider decision.", + "description": "LoopOver control-plane \u2014 product-agnostic tenant provisioning/deprovisioning orchestration behind an injectable driver interface (#7524, part of the #7173 ORB+AMS hosting control-plane). Fake in-memory driver only; real Cloudflare/Postgres drivers are out of scope pending the Postgres-provider decision.", "engines": { "node": ">=20" }, @@ -21,7 +21,8 @@ }, "dependencies": { "@cloudflare/containers": "^0.3.7", - "hono": "^4.12.31" + "hono": "^4.12.31", + "zod": "^4.4.3" }, "devDependencies": { "@cloudflare/workers-types": "^5.20260724.1", diff --git a/control-plane/src/generated/control-plane-contract.ts b/control-plane/src/generated/control-plane-contract.ts new file mode 100644 index 0000000000..9d0b026c27 --- /dev/null +++ b/control-plane/src/generated/control-plane-contract.ts @@ -0,0 +1,243 @@ +// GENERATED by scripts/gen-control-plane-contract.ts from packages/loopover-contract/src/control-plane.ts (#9750). +// Do not edit by hand; edit the contract module and run `npm run control-plane:contract`. +// +// control-plane/ is not an npm workspace member, so it cannot import @loopover/contract directly. This +// mirror is what lets the Worker validate against the SAME schemas the published spec is generated from +// and the miner's admin client parses with. + +// The hosted control plane's tenant-provisioning API, as schemas (#9750). +// +// Three consumers, one contract. The control-plane Worker validates requests against these; the generator +// emits `control-plane/openapi.json` from them under a `--check` drift guard; and the miner's admin client +// (`packages/loopover-miner/lib/tenant-client.ts`) parses responses with them instead of casting. +// +// Before this the surface had NO machine-readable contract at all -- no zod, no spec -- and its only +// description was prose plus the hardcoded fetches in that client, whose `TenantRecord` was literally +// `Record`. A caller could not learn from any artifact what a tenant even has on it. +// +// The control-plane package cannot be imported by loopover-miner (separate workspaces, no dependency edge) +// and vice versa, which is exactly why this lives in the zod-only leaf both already depend on. +import { z } from "zod"; + +/** Where a tenant is in its life. The control plane owns this vocabulary; nothing invents its own. */ +export const TENANT_LIFECYCLE_STATES = ["provisioning", "active", "suspended", "failed", "torn down"] as const; +export type TenantLifecycleState = (typeof TENANT_LIFECYCLE_STATES)[number]; + +/** + * The commands a hosted AMS tenant's scheduler may wake and run. + * + * Mirrors `HOSTED_CYCLE_COMMANDS` in packages/loopover-miner/lib/hosted-entry.ts. That list and this one + * used to be two literals in two packages with comments pointing at each other and nothing checking; now + * the miner's own list is asserted against this one, so the pointing is enforced rather than hoped for. + */ +export const HOSTED_CYCLE_COMMANDS = ["discover", "manage-poll", "attempt"] as const; +export type HostedCycleCommand = (typeof HOSTED_CYCLE_COMMANDS)[number]; + +/** + * An AMS tenant's cron-wake cadence. + * + * `intervalMs` has no configured maximum on purpose: an operator choosing an absurdly long interval is + * their call to make, not something this validation second-guesses. + */ +export const AmsCycleScheduleSchema = z.object({ + command: z.enum(HOSTED_CYCLE_COMMANDS), + args: z.array(z.string()), + intervalMs: z.number().positive(), + nextDueAt: z.string(), + lastRunAt: z.string().optional(), + /** The hosted entry point's exit code from the most recent run; absent until the first one, or on timeout. */ + lastExitCode: z.number().int().optional(), +}); +export type AmsCycleSchedule = z.infer; + +/** The schedule as a CREATE request supplies it: `nextDueAt` is minted server-side, not accepted. */ +export const AmsCycleScheduleRequestSchema = z.object({ + command: z.enum(HOSTED_CYCLE_COMMANDS), + args: z.array(z.string()).optional(), + intervalMs: z.number().positive(), +}); +export type AmsCycleScheduleRequest = z.infer; + +/** A GitHub App installation ID: always a positive integer, since that is GitHub's own ID space. */ +export const OrbInstallationIdSchema = z.number().int().positive(); + +export const TenantIdentitySchema = z.object({ + name: z.string(), + /** Absent or null = unpinned; the tenant follows its release channel's default. */ + pinnedVersion: z.string().nullable().optional(), +}); + +/** + * A tenant record as the API RETURNS it. + * + * Deliberately the safe projection the routes already emit: identity, product, state, and the two + * product-specific fields, plus an OPAQUE `secretRef`. No database connection details, no injected secret, + * no credential of any kind has ever been in this shape and none may be added to it. + * + * `looseObject`, not strict: an MCP output schema is a floor rather than a fence, and a client validating + * against today's shape must not break when the control plane starts returning one more field. + */ +export const TenantRecordSchema = z.looseObject({ + tenant: TenantIdentitySchema, + product: z.string(), + state: z.enum(TENANT_LIFECYCLE_STATES), + amsSchedule: AmsCycleScheduleSchema.optional(), + orbInstallationId: OrbInstallationIdSchema.optional(), + secretRef: z.string().optional(), +}); +export type TenantRecord = z.infer; + +/** The list route additionally carries the timestamps, which the single-record projection omits. */ +export const TenantListEntrySchema = TenantRecordSchema.extend({ + createdAt: z.string(), + updatedAt: z.string(), +}); +export type TenantListEntry = z.infer; + +export const TenantListResponseSchema = z.object({ tenants: z.array(TenantListEntrySchema) }); +export type TenantListResponse = z.infer; + +export const CreateTenantRequestSchema = z.object({ + name: z.string().trim().min(1), + product: z.string().trim().min(1), + schedule: AmsCycleScheduleRequestSchema.optional(), + orbInstallationId: OrbInstallationIdSchema.optional(), +}); +export type CreateTenantRequest = z.infer; + +export const RelinkOrbInstallationRequestSchema = z.object({ orbInstallationId: OrbInstallationIdSchema }); +export type RelinkOrbInstallationRequest = z.infer; + +/** `product` is required on the by-name routes so the registry resolves the same `${product}:${name}` key. */ +export const TenantProductQuerySchema = z.object({ product: z.string().trim().min(1) }); + +/** + * The error envelope every failing route answers with. + * + * `message` is present on the ones that can say something useful and absent on the ones that cannot, which + * is why it is optional rather than required-and-sometimes-empty. + */ +export const ControlPlaneErrorSchema = z.object({ error: z.string(), message: z.string().optional() }); +export type ControlPlaneError = z.infer; + +export const HealthResponseSchema = z.object({ status: z.literal("ok"), service: z.literal("control-plane") }); + +/** Auth posture per route. The tenant admin surface takes an admin bearer; the webhook has its own HMAC. */ +export type ControlPlaneAuth = "admin-bearer" | "webhook-signature" | "public"; + +export type ControlPlaneRoute = { + method: "get" | "post" | "patch" | "delete"; + /** Hono-style, so the app and the document are generated from the same string. */ + path: string; + operationId: string; + summary: string; + description?: string; + auth: ControlPlaneAuth; + request?: { body?: z.ZodTypeAny; query?: z.ZodObject }; + responses: Record; +}; + +const ADMIN_ERRORS = { + 401: { description: "Missing or wrong admin bearer", schema: ControlPlaneErrorSchema }, + 503: { description: "No admin token is configured on this deployment, so the surface fails closed", schema: ControlPlaneErrorSchema }, +}; + +/** + * Every route the control plane serves. + * + * One table, read by the Worker's own registration and by the spec generator, so the document cannot + * describe a route the app does not serve or miss one it does. + */ +export const CONTROL_PLANE_ROUTES: readonly ControlPlaneRoute[] = [ + { + method: "get", + path: "/health", + operationId: "getControlPlaneHealth", + summary: "Liveness probe", + auth: "public", + responses: { 200: { description: "The service is up", schema: HealthResponseSchema } }, + }, + { + method: "post", + path: "/v1/tenants", + operationId: "createTenant", + summary: "Provision a tenant", + description: + "NOT idempotent, by design: an existing tenant of the same name AND product in a non-recreatable state is a 409, not a no-op. A previously torn-down or failed tenant may be recreated — a failed provision must not become a permanent block on retrying setup — and the recreated record does not inherit the old one's createdAt.", + auth: "admin-bearer", + request: { body: CreateTenantRequestSchema }, + responses: { + 201: { description: "Tenant provisioned. Returns the record in its settled state.", schema: TenantRecordSchema }, + 400: { description: "Unparseable body, or a field that failed validation", schema: ControlPlaneErrorSchema }, + 409: { description: "A tenant of this name and product already exists, or the installation ID is already claimed", schema: ControlPlaneErrorSchema }, + ...ADMIN_ERRORS, + }, + }, + { + method: "get", + path: "/v1/tenants", + operationId: "listTenants", + summary: "List every tenant with its lifecycle state", + auth: "admin-bearer", + responses: { 200: { description: "Every tenant, with timestamps", schema: TenantListResponseSchema }, ...ADMIN_ERRORS }, + }, + { + method: "post", + path: "/v1/tenants/rollout", + operationId: "rolloutTenants", + summary: "Roll a pinned image version out across tenants", + description: + "DISABLED, not unimplemented: the previous version silently no-opped on all four of its promised effects, so it answers 501 rather than accepting a request that changes nothing a caller can observe.", + auth: "admin-bearer", + responses: { 501: { description: "Tenant rollout is not implemented", schema: ControlPlaneErrorSchema }, ...ADMIN_ERRORS }, + }, + { + method: "patch", + path: "/v1/tenants/:name/orb-installation", + operationId: "relinkOrbInstallation", + summary: "Re-link an existing ORB tenant's GitHub App installation", + description: + "The manual recovery path for a tenant whose routing pointer was wiped by a since-fixed registry bug. Refuses to take an installation another currently-claiming tenant still holds, but never 409s a tenant against itself.", + auth: "admin-bearer", + request: { body: RelinkOrbInstallationRequestSchema, query: TenantProductQuerySchema }, + responses: { + 200: { description: "The installation is now linked to this tenant", schema: TenantRecordSchema }, + 400: { description: "Missing product query parameter, a non-orb product, or an invalid installation ID", schema: ControlPlaneErrorSchema }, + 404: { description: "No such tenant for that name and product", schema: ControlPlaneErrorSchema }, + 409: { description: "Another live tenant already claims that installation", schema: ControlPlaneErrorSchema }, + ...ADMIN_ERRORS, + }, + }, + { + method: "delete", + path: "/v1/tenants/:name", + operationId: "destroyTenant", + summary: "Tear a tenant down", + description: + "Refuses while the record is observably `provisioning`: there is nothing durable yet to revoke, and tearing down mid-provision races the in-flight create. Retry once it settles.", + auth: "admin-bearer", + request: { query: TenantProductQuerySchema }, + responses: { + 200: { description: "Tenant torn down", schema: TenantRecordSchema }, + 400: { description: "Missing product query parameter", schema: ControlPlaneErrorSchema }, + 404: { description: "No such tenant for that name and product", schema: ControlPlaneErrorSchema }, + 409: { description: "The tenant is still provisioning", schema: ControlPlaneErrorSchema }, + ...ADMIN_ERRORS, + }, + }, + { + method: "post", + path: "/v1/orb/webhook", + operationId: "routeOrbWebhook", + summary: "Route a GitHub webhook to the hosted ORB tenant it belongs to", + description: + "Deliberately outside the admin-bearer middleware: GitHub authenticates a webhook with its own HMAC signature over the raw body, not this service's admin token.", + auth: "webhook-signature", + responses: { + 200: { description: "Delivered to the tenant's container" }, + 401: { description: "The signature did not verify", schema: ControlPlaneErrorSchema }, + 502: { description: "The tenant's container could not be reached", schema: ControlPlaneErrorSchema }, + 503: { description: "No webhook binding is configured on this deployment", schema: ControlPlaneErrorSchema }, + }, + }, +]; diff --git a/control-plane/src/http-app.ts b/control-plane/src/http-app.ts index 285127a81b..5d97974748 100644 --- a/control-plane/src/http-app.ts +++ b/control-plane/src/http-app.ts @@ -27,6 +27,14 @@ // secret driver's `revokeSecrets` knows which broker enrollment to revoke on teardown -- without it, a torn- // down tenant's stored credential would stay valid in the broker forever. import { Hono } from "hono"; +import { + AmsCycleScheduleRequestSchema, + CreateTenantRequestSchema, + HOSTED_CYCLE_COMMANDS as CONTRACT_HOSTED_CYCLE_COMMANDS, + OrbInstallationIdSchema, + RelinkOrbInstallationRequestSchema, + TenantProductQuerySchema, +} from "./generated/control-plane-contract.js"; import { normalizeSharedSecret, verifyBearer } from "./auth.js"; import { routeOrbWebhook, type RouterNamespaceLike } from "./orb-webhook-router.js"; import { @@ -71,44 +79,46 @@ function safeRecord(record: Pick; - if (typeof command !== "string" || !(HOSTED_CYCLE_COMMANDS as readonly string[]).includes(command)) { - return `schedule.command must be one of: ${HOSTED_CYCLE_COMMANDS.join(", ")}`; - } - if (args !== undefined && (!Array.isArray(args) || !args.every((value): value is string => typeof value === "string"))) { - return "schedule.args must be an array of strings"; - } - if (typeof intervalMs !== "number" || !Number.isFinite(intervalMs) || intervalMs <= 0) { + const parsed = AmsCycleScheduleRequestSchema.safeParse(value); + if (!parsed.success) { + const path = parsed.error.issues[0]?.path[0]; + if (path === "command") return `schedule.command must be one of: ${HOSTED_CYCLE_COMMANDS.join(", ")}`; + if (path === "args") return "schedule.args must be an array of strings"; return "schedule.intervalMs must be a positive number of milliseconds"; } - return { command, args: Array.isArray(args) ? args : [], intervalMs, nextDueAt: new Date().toISOString() }; + return { command: parsed.data.command, args: parsed.data.args ?? [], intervalMs: parsed.data.intervalMs, nextDueAt: new Date().toISOString() }; } /** Validated body of `POST /v1/tenants`'s optional `orbInstallationId` field (#7181): a GitHub App * installation ID, always a positive integer (GitHub's own ID space). `undefined` input (the field omitted) * is valid -- an ORB tenant with no installation linked yet simply never receives a routed webhook, which is - * a legitimate state during onboarding, not an error. */ + * a legitimate state during onboarding, not an error. + * + * #9750: the shape check is now OrbInstallationIdSchema, so the route and the published document agree by + * construction. The message is unchanged and still returned as a string, because callers (and the CLI that + * surfaces it) read that exact wording. */ function parseOrbInstallationId(value: unknown): number | string | undefined { if (value === undefined) return undefined; - if (typeof value !== "number" || !Number.isInteger(value) || value <= 0) { - return "orbInstallationId must be a positive integer"; - } - return value; + const parsed = OrbInstallationIdSchema.safeParse(value); + return parsed.success ? parsed.data : "orbInstallationId must be a positive integer"; } export function createTenantHttpApp(deps: TenantHttpAppDeps): Hono { @@ -134,9 +144,16 @@ export function createTenantHttpApp(deps: TenantHttpAppDeps): Hono { app.post("/v1/tenants", async (c) => { const body: unknown = await c.req.json().catch(() => null); if (body === null || typeof body !== "object") return c.json({ error: "invalid_json" }, 400); - const { name, product, schedule: scheduleInput, orbInstallationId: orbInstallationIdInput } = body as Record; - if (typeof name !== "string" || !name.trim()) return c.json({ error: "invalid_request", message: "name is required" }, 400); - if (typeof product !== "string" || !product.trim()) return c.json({ error: "invalid_request", message: "product is required" }, 400); + const { name: nameInput, product: productInput, schedule: scheduleInput, orbInstallationId: orbInstallationIdInput } = body as Record; + // #9750: the same two required-and-non-blank checks, now expressed by the schema the document publishes. + // Reported field by field rather than as a zod issue list, because these messages are what an operator + // reads out of the CLI and a shape change here would be a behavior change for them. + const identity = CreateTenantRequestSchema.pick({ name: true, product: true }).safeParse({ name: nameInput, product: productInput }); + if (!identity.success) { + const field = identity.error.issues[0]?.path[0] === "product" ? "product" : "name"; + return c.json({ error: "invalid_request", message: `${field} is required` }, 400); + } + const { name, product } = identity.data; const schedule = parseScheduleRequest(scheduleInput); if (typeof schedule === "string") return c.json({ error: "invalid_request", message: schedule }, 400); if (schedule && product !== "ams") return c.json({ error: "invalid_request", message: 'schedule is only valid for product "ams"' }, 400); @@ -229,10 +246,11 @@ export function createTenantHttpApp(deps: TenantHttpAppDeps): Hono { app.patch("/v1/tenants/:name/orb-installation", async (c) => { const name = c.req.param("name"); // Product is required so the registry can resolve the same `${product}:${name}` key used at create (#8024). - const product = c.req.query("product"); - if (typeof product !== "string" || !product.trim()) { + const productQuery = TenantProductQuerySchema.safeParse({ product: c.req.query("product") }); + if (!productQuery.success) { return c.json({ error: "invalid_request", message: "product query parameter is required" }, 400); } + const product = productQuery.data.product; if (product !== "orb") { return c.json({ error: "invalid_request", message: 'orbInstallationId is only valid for product "orb"' }, 400); } @@ -246,8 +264,11 @@ export function createTenantHttpApp(deps: TenantHttpAppDeps): Hono { if (orbInstallationIdInput === undefined) { return c.json({ error: "invalid_request", message: "orbInstallationId is required" }, 400); } - const orbInstallationId = parseOrbInstallationId(orbInstallationIdInput); - if (typeof orbInstallationId === "string") return c.json({ error: "invalid_request", message: orbInstallationId }, 400); + // RelinkOrbInstallationRequestSchema is the published shape; the field-level message stays the one the + // create route returns for the same value, so a caller sees one wording for one mistake. + const relink = RelinkOrbInstallationRequestSchema.safeParse({ orbInstallationId: orbInstallationIdInput }); + if (!relink.success) return c.json({ error: "invalid_request", message: "orbInstallationId must be a positive integer" }, 400); + const orbInstallationId = relink.data.orbInstallationId; // Refuse to steal an installation ID a DIFFERENT, currently-claiming tenant still legitimately holds — // same conflict posture as the create route's own check, just excluding this tenant's own current record @@ -268,10 +289,11 @@ export function createTenantHttpApp(deps: TenantHttpAppDeps): Hono { app.delete("/v1/tenants/:name", async (c) => { const name = c.req.param("name"); // Product is required so the registry can resolve the same `${product}:${name}` key used at create (#8024). - const product = c.req.query("product"); - if (typeof product !== "string" || !product.trim()) { + const productQuery = TenantProductQuerySchema.safeParse({ product: c.req.query("product") }); + if (!productQuery.success) { return c.json({ error: "invalid_request", message: "product query parameter is required" }, 400); } + const product = productQuery.data.product; const existing = await deps.registry.get(name, product); if (!existing) return c.json({ error: "tenant_not_found" }, 404); diff --git a/package.json b/package.json index 7eaafd297c..ae70787bfd 100644 --- a/package.json +++ b/package.json @@ -71,6 +71,10 @@ "ui:test": "npm run ui:kit:build && npm --workspace @loopover/ui run test && npm --workspace @loopover/ui-miner run test", "ui:openapi": "tsx scripts/write-ui-openapi.ts", "ui:openapi:check": "tsx scripts/write-ui-openapi.ts --check", + "control-plane:contract": "tsx scripts/gen-control-plane-contract.ts", + "control-plane:contract:check": "tsx scripts/gen-control-plane-contract.ts --check", + "control-plane:openapi": "tsx scripts/gen-control-plane-openapi.ts", + "control-plane:openapi:check": "tsx scripts/gen-control-plane-openapi.ts --check", "cloudflare:schema": "tsx scripts/write-cloudflare-schema.ts", "ui:version-audit": "node --experimental-strip-types scripts/check-ui-mcp-version-copy.ts", "ui:version-audit:sync": "node --experimental-strip-types scripts/check-ui-mcp-version-copy.ts --write", @@ -132,7 +136,7 @@ "test:smoke:browser:install": "playwright install chromium", "test:smoke:browser": "node --experimental-strip-types scripts/smoke-ui-browser.ts", "pretest:ci": "npm run check-node-version", - "test:ci": "git diff --check && npm run actionlint && npm run lint:composite-actions && npm run db:migrations:check && npm run db:migrations:immutable:check && npm run db:schema-drift:check && npm run selfhost:env-reference:check && npm run miner:env-reference:check && npm run selfhost:validate-observability && npm run cf-typegen:check && npm run build --workspace @loopover/engine && npm run build --workspace @loopover/discovery-index && npm run build:mcp && npm run build:miner && npm run build --workspace @loopover/ui-kit && npm run typecheck && npm run test:coverage && npm run test:engine-parity && npm run test:live-gate-parity && npm run test:driver-parity && npm run validate:mcp && npm run test --workspace @loopover/engine && npm run test:workers && npm run test:mcp-pack && npm run test:miner-pack && npm run test:engine-pack && npm run test:ui-kit-pack && npm run test:miner-deployment-docs-audit && npm run rees:test && npm run ui:openapi:check && npm run ui:version-audit && npm run docs:drift-check && npm run coverage-boltons:check && npm run import-specifiers:check && npm run ui-derived-types:check && npm run server-manifest:check && npm run dead-source-files:check && npm run regate-sort-key:check && npm run command-redelivery-guards:check && npm run dispatch-gate-reasons:check && npm run validate:no-hand-written-js && npm run replay-runner-manifest:check && npm run coco-dev-versions:check && npm run branding-drift:check && npm run manifest:drift-check && npm run engine-parity:drift-check && npm run engines-nvmrc:check && npm run release-manifest:sync:check && npm run release-linked-versions:check && npm run command-reference:check && npm run mcp:tool-reference:check && npm run mcp:client-config:check && npm run contract:api-schemas:check && npm run ui:lint && npm run ui:typecheck && npm run ui:test && npm run ui:build", + "test:ci": "git diff --check && npm run actionlint && npm run lint:composite-actions && npm run db:migrations:check && npm run db:migrations:immutable:check && npm run db:schema-drift:check && npm run selfhost:env-reference:check && npm run miner:env-reference:check && npm run selfhost:validate-observability && npm run cf-typegen:check && npm run build --workspace @loopover/engine && npm run build --workspace @loopover/discovery-index && npm run build:mcp && npm run build:miner && npm run build --workspace @loopover/ui-kit && npm run typecheck && npm run test:coverage && npm run test:engine-parity && npm run test:live-gate-parity && npm run test:driver-parity && npm run validate:mcp && npm run test --workspace @loopover/engine && npm run test:workers && npm run test:mcp-pack && npm run test:miner-pack && npm run test:engine-pack && npm run test:ui-kit-pack && npm run test:miner-deployment-docs-audit && npm run rees:test && npm run ui:openapi:check && npm run control-plane:contract:check && npm run control-plane:openapi:check && npm run ui:version-audit && npm run docs:drift-check && npm run coverage-boltons:check && npm run import-specifiers:check && npm run ui-derived-types:check && npm run server-manifest:check && npm run dead-source-files:check && npm run regate-sort-key:check && npm run command-redelivery-guards:check && npm run dispatch-gate-reasons:check && npm run validate:no-hand-written-js && npm run replay-runner-manifest:check && npm run coco-dev-versions:check && npm run branding-drift:check && npm run manifest:drift-check && npm run engine-parity:drift-check && npm run engines-nvmrc:check && npm run release-manifest:sync:check && npm run release-linked-versions:check && npm run command-reference:check && npm run mcp:tool-reference:check && npm run mcp:client-config:check && npm run contract:api-schemas:check && npm run ui:lint && npm run ui:typecheck && npm run ui:test && npm run ui:build", "test:release": "npm run test:ci && npm run changelog:check", "test:release:mcp": "npm run test:ci", "test:watch": "vitest", diff --git a/packages/loopover-contract/package.json b/packages/loopover-contract/package.json index 89e8766ca0..39b616ef8a 100644 --- a/packages/loopover-contract/package.json +++ b/packages/loopover-contract/package.json @@ -67,6 +67,14 @@ "./client-config": { "types": "./dist/client-config.d.ts", "default": "./dist/client-config.js" + }, + "./control-plane": { + "types": "./dist/control-plane.d.ts", + "default": "./dist/control-plane.js" + }, + "./api-requests": { + "types": "./dist/api-requests.d.ts", + "default": "./dist/api-requests.js" } }, "files": [ diff --git a/packages/loopover-contract/src/api-requests.ts b/packages/loopover-contract/src/api-requests.ts new file mode 100644 index 0000000000..7799f26932 --- /dev/null +++ b/packages/loopover-contract/src/api-requests.ts @@ -0,0 +1,770 @@ +// Every request schema the Worker API validates against (#9750). +// +// These were 52 declarations inline in src/api/routes.ts, which meant an MCP tool wrapping the same route +// validated against an independently-authored copy of the same shape. That cost is not theoretical: the +// #9518 migration found FleetRegisterInstallationInput never declaring the `registered` field its route +// accepts, making the opt-out unreachable over MCP. One exported object per payload is what stops that +// recurring -- a tool and its route now accept and reject the same thing by construction. +// +// MOVED, NOT REWRITTEN. Each schema is the declaration that was in routes.ts, verbatim, so a payload the +// API accepted before is accepted now and one it rejected is rejected the same way with the same status. +// The bounds they referenced could not travel with them -- this is a zod-only leaf that cannot import the +// Worker or the engine -- so limits.ts restates them and a meta-test pins each against its original, the +// posture PREFLIGHT_LIMITS already established there. +import { z } from "zod"; +import { + MAX_FOCUS_MANIFEST_BYTES, + MAX_LOCAL_SCORER_WARNING_CHARS, + MAX_LOCAL_SCORER_WARNING_COUNT, + MAX_NOTIFICATION_DELIVERY_ID_LENGTH, + MAX_NOTIFICATION_MARK_READ_IDS, + PREFLIGHT_LIMITS, + PUBLIC_SURFACE_SKIP_REASONS, + SCENARIO_MAX_BRANCH_REF_CHARS, + SCENARIO_MAX_LINKED_ISSUE_NUMBERS, + SCENARIO_MAX_REPO_FULL_NAME_CHARS, +} from "./limits.js"; + +/** + * Whether a value's JSON encoding fits a byte budget. + * + * Moved here with the one schema that refines on it (`focusManifestInputSchema`). Byte length rather than + * character count because the limit it enforces is a storage limit, and a manifest full of multi-byte + * characters is larger than its `.length` suggests. + */ +export function isJsonByteLengthWithinLimit(value: unknown, maxBytes: number): boolean { + try { + return new TextEncoder().encode(JSON.stringify(value)).byteLength <= maxBytes; + } catch { + // A cyclic structure cannot be serialized at all, which is not "within the limit". + return false; + } +} + +export const MAX_LOCAL_BRANCH_REF_CHARS = 256; +export const MAX_LOCAL_BRANCH_TEXT_CHARS = 4000; + +// #6745: body of POST /v1/contributors/:login/notifications/read. Mirrors markNotificationsReadShape +// (src/mcp/server.ts) minus `login` (which is the path param): `ids` is optional (absent = mark all delivered). +export const markNotificationsReadBodySchema = z.object({ + ids: z.array(z.string().min(1).max(MAX_NOTIFICATION_DELIVERY_ID_LENGTH)).max(MAX_NOTIFICATION_MARK_READ_IDS).optional(), +}); + +// #7657: AMS miner posts DetectedNotificationEvent-shaped AMS kinds; recipient is forced to the path login. +export const amsNotificationsBodySchema = z.object({ + events: z + .array( + z.object({ + eventType: z.enum(["ams_attempt_started", "ams_attempt_failed", "ams_governor_paused", "ams_pr_outcome"]), + repoFullName: z.string().min(1).max(200), + pullNumber: z.number().int().min(0), + dedupKey: z.string().min(1).max(500), + deeplink: z.string().min(1).max(2000), + actorLogin: z.string().min(1).max(100), + detectedAt: z.string().min(1).max(64), + }), + ) + .min(1) + .max(20), +}); + +// #6746: body of POST/DELETE /v1/contributors/:login/watches. Mirrors watchIssuesShape (src/mcp/server.ts) minus +// `login` (path param) and `action` (the HTTP verb). `labels` is POST-only (a DELETE ignores it). +export const watchSubscriptionBodySchema = z.object({ + repoFullName: z.string().min(3).max(200), + labels: z.array(z.string().min(1).max(100)).max(50).optional(), +}); + +export const preflightSchema = z.object({ + repoFullName: z.string().min(3).max(PREFLIGHT_LIMITS.repoFullNameChars), + contributorLogin: z.string().min(1).max(PREFLIGHT_LIMITS.contributorLoginChars).optional(), + title: z.string().min(1).max(PREFLIGHT_LIMITS.titleChars), + body: z.string().max(PREFLIGHT_LIMITS.bodyChars).optional(), + labels: z.array(z.string().max(PREFLIGHT_LIMITS.labelChars)).max(PREFLIGHT_LIMITS.labels).optional(), + changedFiles: z.array(z.string().max(PREFLIGHT_LIMITS.changedFileChars)).max(PREFLIGHT_LIMITS.changedFiles).optional(), + linkedIssues: z.array(z.number().int().positive()).max(PREFLIGHT_LIMITS.linkedIssues).optional(), + tests: z.array(z.string().max(PREFLIGHT_LIMITS.testChars)).max(PREFLIGHT_LIMITS.tests).optional(), + authorAssociation: z.string().max(PREFLIGHT_LIMITS.authorAssociationChars).optional(), +}); + +export const localDiffPreflightSchema = preflightSchema.extend({ + changedLineCount: z.number().int().min(0).optional(), + testFiles: z.array(z.string().max(PREFLIGHT_LIMITS.changedFileChars)).max(PREFLIGHT_LIMITS.changedFiles).optional(), + commitMessage: z.string().max(PREFLIGHT_LIMITS.bodyChars).optional(), +}); + +export const validateLinkedIssueSchema = z.object({ + issueNumber: z.number().int().positive(), + plannedChange: z + .object({ + title: z.string().min(1).max(PREFLIGHT_LIMITS.titleChars).optional(), + changedFiles: z.array(z.string().max(PREFLIGHT_LIMITS.changedFileChars)).max(PREFLIGHT_LIMITS.changedFiles).optional(), + contributorLogin: z.string().min(1).max(PREFLIGHT_LIMITS.contributorLoginChars).optional(), + }) + .optional(), +}); + +export const checkBeforeStartSchema = z.object({ + issueNumber: z.number().int().positive().optional(), + title: z.string().min(1).max(PREFLIGHT_LIMITS.titleChars).optional(), + plannedPaths: z.array(z.string().max(PREFLIGHT_LIMITS.changedFileChars)).max(PREFLIGHT_LIMITS.changedFiles).optional(), +}); + +export const lintPrTextSchema = z.object({ + commitMessages: z.array(z.string().max(PREFLIGHT_LIMITS.bodyChars)).max(50).optional(), + prBody: z.string().max(PREFLIGHT_LIMITS.bodyChars).optional(), + linkedIssue: z.number().int().positive().optional(), +}); + +export const validateFocusManifestSchema = z.object({ + content: z.string().max(256 * 1024), + source: z.enum(["repo_file", "api_record", "none"]).optional(), +}); + +// Pure local-metadata slop self-checks (no repo data, no secrets) — mirror the loopover_check_slop_risk / +// loopover_check_issue_slop MCP tools so the npm package can offer the same agent-native self-check. +// #6754: mirrors the loopover_evaluate_escalation MCP tool's input shape exactly (src/mcp/server.ts) so the +// REST surface can never accept something the tool would reject, or vice versa. +export const evaluateEscalationSchema = z.object({ + runStatus: z.enum(["running", "converged", "abandoned", "error"]), + healthStatus: z.enum(["healthy", "degraded", "critical"]).optional(), + customerFlagged: z.boolean().optional(), + killRequested: z.boolean().optional(), +}); + +// #7742: customer-facing APR transfer request. Completion is resolved SERVER-SIDE via loadAprIdeaCompletion — +// never accepted from the body (that was the #8000 Superagent P1). `.strict()` rejects any attempt to smuggle +// `ideaComplete` (or other unknown keys). Plan/payment fields are deliberately absent. +export const requestAprTransferSchema = z + .object({ + installationId: z.number().int().positive(), + repoFullName: z.string().min(1).max(200), + newOwner: z.string().min(1).max(100), + ideaId: z.string().min(1).max(200).optional(), + }) + .strict(); + +// #6744: mirrors `ProposeActionInput` in @loopover/contract VERBATIM, minus owner/repo (they are path params), so +// POST /v1/repos/:owner/:repo/agent/pending-actions can never stage an action the loopover_propose_action MCP +// tool would reject, or vice versa. actionClass stays the 7-value propose set (a subset of AgentActionClass). +export const proposePendingActionSchema = z.object({ + pullNumber: z.number().int().positive(), + actionClass: z.enum(["review", "request_changes", "approve", "merge", "close", "label", "review_state_label"]), + reason: z.string().max(500).optional(), + label: z.string().min(1).max(100).optional(), + reviewBody: z.string().max(60000).optional(), + mergeMethod: z.enum(["merge", "squash", "rebase"]).optional(), + closeComment: z.string().max(60000).optional(), +}); + +// #6755: mirrors `IntakeIdeaInput` in @loopover/contract VERBATIM. Fields are deliberately LOOSE here for the same +// reason they are on the tool: the engine's validateIdeaSubmission owns the real bounds/format checks and returns +// the actionable error list, so an empty/malformed submission must reach the handler rather than be rejected +// upstream by the schema. +export const intakeIdeaSchema = z.object({ + id: z.string().optional(), + title: z.string().optional(), + body: z.string().optional(), + targetRepo: z.string().optional(), + constraints: z.array(z.string()).max(50).optional(), + acceptanceHints: z.array(z.string()).max(50).optional(), + priority: z.string().optional(), + decomposition: z + .array(z.object({ key: z.string(), title: z.string(), body: z.string(), dependsOn: z.array(z.string()).max(50).optional() })) + .max(50) + .optional(), +}); + +// #6752: mirrors `BuildResultsPayloadInput` in @loopover/contract VERBATIM (same bounds, same optionality) so the +// REST surface can never accept an input the MCP tool would reject, or vice versa. +export const resultsPayloadSchema = z.object({ + repoFullName: z.string().min(1), + prNumber: z.number().int().nullable().optional(), + title: z.string(), + changedFiles: z + .array(z.object({ path: z.string(), additions: z.number().int().optional(), deletions: z.number().int().optional() })) + .max(5000) + .optional(), + status: z.enum(["open", "merged", "closed"]).optional(), +}); + +// #6753: mirrors `BuildProgressSnapshotInput` in @loopover/contract VERBATIM (same bounds, same optionality) so the +// REST surface can never accept an input the MCP tool would reject, or vice versa. +export const progressSnapshotSchema = z.object({ + iteration: z.number().int(), + maxIterations: z.number().int().nullable().optional(), + phase: z.enum(["queued", "claiming", "coding", "reviewing", "submitting", "done"]), + status: z.enum(["running", "converged", "abandoned", "error"]), + recentActivity: z + .array(z.object({ step: z.string(), detail: z.string().optional(), at: z.string().optional() })) + .max(1000) + .optional(), +}); + +// #6749: mirrors checkTestEvidenceShape in src/mcp/server.ts VERBATIM (same bounds, same optionality) so the +// REST surface can never accept an input the MCP tool would reject, or vice versa. +export const testEvidenceSchema = z.object({ + changedPaths: z.array(z.string().min(1).max(400)).max(2000), + testFiles: z.array(z.string().min(1).max(400)).max(2000).optional(), + tests: z.array(z.string().max(400)).max(2000).optional(), +}); + +// #6750: mirrors suggestBoundaryTestsShape in src/mcp/server.ts VERBATIM (same bounds, same .strict() +// objects, same optionality) so the REST surface can never accept an input the MCP tool would reject. +export const boundaryTestsSchema = z.object({ + changedFiles: z.array(z.object({ path: z.string().min(1).max(400) }).strict()).max(500), + boundaryTouches: z + .array( + z + .object({ + path: z.string().min(1).max(400), + kind: z.enum(["array_index_bounds", "null_or_undefined_branch", "empty_collection_check"]), + }) + .strict(), + ) + .max(20) + .optional(), + tests: z.array(z.string().max(400)).max(2000).optional(), + testFiles: z.array(z.string().max(400)).max(2000).optional(), +}); + +export const slopRiskSchema = z.object({ + changedFiles: z + .array(z.object({ path: z.string().min(1).max(400), additions: z.number().int().min(0).optional(), deletions: z.number().int().min(0).optional() })) + .max(2000) + .optional(), + description: z.string().max(20000).optional(), + tests: z.array(z.string().max(400)).max(2000).optional(), + testFiles: z.array(z.string().max(400)).max(2000).optional(), + commitMessages: z.array(z.string().max(2000)).max(200).optional(), + hasLinkedIssue: z.boolean().optional(), + issueDiscoveryLane: z.boolean().optional(), +}); + +// #6748: mirrors checkImprovementPotentialShape in src/mcp/server.ts VERBATIM (same bounds, same optionality) +// so the REST surface can never accept an input the MCP tool would reject, or vice versa. +export const improvementPotentialSchema = z.object({ + changedFiles: z + .array(z.object({ path: z.string().min(1).max(400), additions: z.number().int().min(0).optional(), deletions: z.number().int().min(0).optional() })) + .max(2000) + .optional(), + tests: z.array(z.string().max(400)).max(2000).optional(), + testFiles: z.array(z.string().max(400)).max(2000).optional(), + patchCoverageDeltaPercent: z.number().optional(), + complexityDeltas: z + .array( + z.object({ + file: z.string().min(1).max(400), + line: z.number().int().min(1), + name: z.string().min(1).max(400), + before: z.number().int().min(0), + after: z.number().int().min(0), + delta: z.number().int(), + }), + ) + .max(2000) + .optional(), + duplicationDeltas: z + .array( + z.object({ + file: z.string().min(1).max(400), + line: z.number().int().min(1), + duplicateOfLine: z.number().int().min(1), + lines: z.number().int().min(1), + }), + ) + .max(2000) + .optional(), +}); +export const issueSlopSchema = z.object({ + title: z.string().max(500).optional(), + body: z.string().max(40000).optional(), +}); + +export const selfhostDeadLetterQueueQuerySchema = z + .object({ + limit: z.coerce.number().int().optional(), + offset: z.coerce.number().int().optional(), + }) + .strict(); + +export const skippedPrAuditQuerySchema = z + .object({ + limit: z.coerce.number().int().optional(), + offset: z.coerce.number().int().optional(), + repoFullName: z.string().trim().min(3).max(200).optional(), + reason: z.enum(PUBLIC_SURFACE_SKIP_REASONS).optional(), + since: z.string().trim().min(1).max(64).optional(), + }) + .strict(); + +export const localBranchChangedFileSchema = z + .object({ + path: z.string().min(1).max(MAX_LOCAL_BRANCH_REF_CHARS), + previousPath: z.string().min(1).max(MAX_LOCAL_BRANCH_REF_CHARS).optional(), + additions: z.number().int().min(0).optional(), + deletions: z.number().int().min(0).optional(), + status: z.enum(["added", "modified", "deleted", "renamed", "copied", "unknown"]).optional(), + binary: z.boolean().optional(), + }) + .strict(); + +export const localBranchValidationSchema = z + .object({ + command: z.string().min(1).max(MAX_LOCAL_BRANCH_REF_CHARS), + status: z.enum(["passed", "failed", "not_run", "skipped", "focused", "unknown"]), + summary: z.string().max(MAX_LOCAL_BRANCH_TEXT_CHARS).optional(), + durationMs: z.number().int().min(0).optional(), + exitCode: z.number().int().min(0).optional(), + }) + .strict(); + +export const localBranchScorerSchema = z + .object({ + mode: z.enum(["metadata_only", "external_command", "gittensor_root"]), + activeModel: z.string().max(MAX_LOCAL_BRANCH_REF_CHARS).optional(), + sourceTokenScore: z.number().min(0).optional(), + totalTokenScore: z.number().min(0).optional(), + sourceLines: z.number().min(0).optional(), + testTokenScore: z.number().min(0).optional(), + nonCodeTokenScore: z.number().min(0).optional(), + nonCodeLines: z.number().min(0).optional(), + warnings: z.array(z.string().max(MAX_LOCAL_SCORER_WARNING_CHARS)).max(MAX_LOCAL_SCORER_WARNING_COUNT).optional(), + }) + .strict(); + +export const linkedIssueContextSchema = z + .object({ + status: z.enum(["raw", "plausible", "validated", "invalid", "unavailable"]).optional(), + source: z.enum(["user_supplied", "official_mirror", "github_cache", "issue_quality", "missing"]).optional(), + issueNumbers: z.array(z.number().int().positive()).max(50).optional(), + solvedByPullRequests: z.array(z.number().int().positive()).max(50).optional(), + reason: z.string().max(MAX_LOCAL_BRANCH_TEXT_CHARS).optional(), + warnings: z.array(z.string().max(MAX_LOCAL_BRANCH_TEXT_CHARS)).max(20).optional(), + }) + .strict(); + +export const branchEligibilitySchema = z + .object({ + status: z.enum(["eligible", "ineligible", "unknown"]), + source: z.enum(["github_metadata", "local_metadata", "registry", "user_supplied"]).optional(), + reason: z.string().max(MAX_LOCAL_BRANCH_TEXT_CHARS).optional(), + checkedAt: z.string().max(MAX_LOCAL_BRANCH_REF_CHARS).optional(), + stale: z.boolean().optional(), + }) + .strict() + .transform((value) => ({ ...value, status: value.status === "eligible" ? ("unknown" as const) : value.status, source: "user_supplied" as const })); + +export const focusManifestInputSchema = z + .record(z.string(), z.unknown()) + .refine((manifest) => isJsonByteLengthWithinLimit(manifest, MAX_FOCUS_MANIFEST_BYTES), { + message: `focusManifest must serialize to ${MAX_FOCUS_MANIFEST_BYTES} bytes or fewer`, + }); + +export const localBranchAnalysisSchema = z + .object({ + login: z.string().min(1).max(MAX_LOCAL_BRANCH_REF_CHARS), + repoFullName: z.string().min(3).max(SCENARIO_MAX_REPO_FULL_NAME_CHARS), + baseRef: z.string().min(1).max(SCENARIO_MAX_BRANCH_REF_CHARS).optional(), + headRef: z.string().min(1).max(SCENARIO_MAX_BRANCH_REF_CHARS).optional(), + branchName: z.string().min(1).max(SCENARIO_MAX_BRANCH_REF_CHARS).optional(), + baseSha: z.string().min(1).max(MAX_LOCAL_BRANCH_REF_CHARS).optional(), + headSha: z.string().min(1).max(MAX_LOCAL_BRANCH_REF_CHARS).optional(), + mergeBaseSha: z.string().min(1).max(MAX_LOCAL_BRANCH_REF_CHARS).optional(), + remoteTrackingSha: z.string().min(1).max(MAX_LOCAL_BRANCH_REF_CHARS).optional(), + commitMessages: z.array(z.string().max(MAX_LOCAL_BRANCH_TEXT_CHARS)).max(30).optional(), + changedFiles: z.array(localBranchChangedFileSchema).max(500).optional(), + validation: z.array(localBranchValidationSchema).max(50).optional(), + linkedIssues: z.array(z.number().int().positive()).max(SCENARIO_MAX_LINKED_ISSUE_NUMBERS).optional(), + labels: z.array(z.string().min(1).max(MAX_LOCAL_BRANCH_REF_CHARS)).max(50).optional(), + title: z.string().min(1).max(MAX_LOCAL_BRANCH_REF_CHARS).optional(), + body: z.string().max(MAX_LOCAL_BRANCH_TEXT_CHARS).optional(), + localScorer: localBranchScorerSchema.optional(), + pendingMergedPrCount: z.number().int().min(0).optional(), + pendingClosedPrCount: z.number().int().min(0).optional(), + approvedPrCount: z.number().int().min(0).optional(), + expectedOpenPrCountAfterMerge: z.number().int().min(0).optional(), + projectedCredibility: z.number().min(0).max(1).optional(), + scenarioNotes: z.array(z.string().max(MAX_LOCAL_BRANCH_TEXT_CHARS)).max(20).optional(), + pendingCommitCount: z.number().int().min(0).optional(), + ciStatusHints: z.array(z.string().max(MAX_LOCAL_BRANCH_TEXT_CHARS)).max(20).optional(), + focusManifest: focusManifestInputSchema.optional(), + branchEligibility: branchEligibilitySchema.optional(), + }) + .strict(); + +export const scorePreviewSchema = z.object({ + repoFullName: z.string().min(3), + targetType: z.enum(["planned_pr", "pull_request", "local_diff", "variant"]).default("planned_pr"), + targetKey: z.string().optional(), + contributorLogin: z.string().min(1).optional(), + labels: z.array(z.string()).optional(), + linkedIssueMode: z.enum(["none", "standard", "maintainer"]).default("none"), + linkedIssueContext: linkedIssueContextSchema.optional(), + sourceTokenScore: z.number().min(0).optional(), + totalTokenScore: z.number().min(0).optional(), + sourceLines: z.number().min(0).optional(), + testTokenScore: z.number().min(0).optional(), + nonCodeTokenScore: z.number().min(0).optional(), + nonCodeLines: z.number().min(0).optional(), + existingContributorTokenScore: z.number().min(0).optional(), + prAgeHours: z.number().min(0).optional(), + openPrCount: z.number().int().min(0).optional(), + mergedPullRequests: z.number().int().min(0).optional(), + validSolvedIssues: z.number().int().min(0).optional(), + issueCredibility: z.number().min(0).max(1).optional(), + credibility: z.number().min(0).max(1).optional(), + changesRequestedCount: z.number().int().min(0).optional(), + duplicateRiskCount: z.number().int().min(0).optional(), + fixedBaseScore: z.number().min(0).optional(), + metadataOnly: z.boolean().default(false), + pendingMergedPrCount: z.number().int().min(0).optional(), + pendingClosedPrCount: z.number().int().min(0).optional(), + approvedPrCount: z.number().int().min(0).optional(), + expectedOpenPrCountAfterMerge: z.number().int().min(0).optional(), + projectedCredibility: z.number().min(0).max(1).optional(), + scenarioNotes: z.array(z.string()).max(20).optional(), + branchEligibility: branchEligibilitySchema.optional(), +}); + +export const agentSurfaceSchema = z.enum(["api", "mcp", "github_comment"]).default("api"); + +export const agentRunSchema = z + .object({ + objective: z.string().min(1).max(500), + actorLogin: z.string().min(1), + surface: agentSurfaceSchema.optional(), + target: z + .object({ + repoFullName: z.string().min(3).optional(), + pullNumber: z.number().int().positive().optional(), + issueNumber: z.number().int().positive().optional(), + }) + .strict() + .optional(), + }) + .strict(); + +export const agentPlanSchema = z + .object({ + login: z.string().min(1), + objective: z.string().min(1).max(500).optional(), + repoFullName: z.string().min(3).optional(), + surface: agentSurfaceSchema.optional(), + }) + .strict(); + +export const agentExplainBlockersSchema = z.union([localBranchAnalysisSchema, agentPlanSchema]); + +// reviewCheckMode/linkedIssueGateMode/duplicatePrGateMode/qualityGateMode/qualityGateMinScore/ +// aiReviewMode/aiReviewByok/aiReviewProvider/aiReviewModel/aiReviewAllAuthors removed from this write +// schema (Batch C, loopover#6444) -- config-as-code only via .loopover.yml's gate.* block now; +// upsertRepositorySettings no longer has a DB column to write any of them into. +export const COMMAND_AUTHORIZATION_ROLES = ["maintainer", "collaborator", "pr_author", "confirmed_miner"] as const; + +/** The command-authorization block, without a default. */ +export const CommandAuthorizationPolicySchema = z.object({ + default: z.array(z.enum(COMMAND_AUTHORIZATION_ROLES)).max(4).optional(), + commands: z.record(z.string().trim().min(1).max(64), z.array(z.enum(COMMAND_AUTHORIZATION_ROLES)).max(4)).optional(), +}); +export type CommandAuthorizationPolicy = z.infer; + +/** + * The repository settings WRITE schema, as a factory over its one non-portable default. + * + * A factory rather than a plain schema because `commandAuthorization`'s default is a twenty-key policy + * object owned by @loopover/engine, and this is a zod-only leaf that cannot import it. Restating the policy + * here would be the exact duplication this move exists to remove -- so the shape lives here once and the + * src layer supplies the engine's own value once, at the single place the schema is constructed. + */ +export function buildRepositorySettingsSchema(defaultCommandAuthorization: CommandAuthorizationPolicy) { + return z.object({ + gatePack: z.enum(["gittensor", "oss-anti-slop"]).default("gittensor"), + aiReviewLowConfidenceDisposition: z.enum(["one_shot", "hold_for_review", "advisory_only"]).default("hold_for_review"), + closeOwnerAuthors: z.boolean().default(false), + autoLabelEnabled: z.boolean().default(true), + // #6443: gittensorLabel/blacklistLabel/createMissingLabel/contributorBlacklist removed -- no longer + // DB-backed, config-as-code only via .loopover.yml's settings: block now. + requireLinkedIssue: z.boolean().default(false), + commandAuthorization: CommandAuthorizationPolicySchema.default(defaultCommandAuthorization), + }); +} + +// #130 maintainer self-serve settings editor. A PATCH-style subset: every field optional so the maintainer +// dashboard can save just the group it changed. Excludes the secret-bearing aiReview* group (set via the +// dedicated /ai-review + /ai-key routes) and the operator-only scoring internal (backfillEnabled). The +// handler loads current settings and merges, since upsertRepositorySettings defaults any absent field +// rather than preserving it. +// reviewCheckMode/linkedIssueGateMode/duplicatePrGateMode/qualityGateMode/qualityGateMinScore/ +// selfAuthoredLinkedIssueGateMode removed from this write schema (Batch C, loopover#6444) -- +// config-as-code only via .loopover.yml's gate.* block now. +export const maintainerSettingsSchema = z + .object({ + gatePack: z.enum(["gittensor", "oss-anti-slop"]), + mergeReadinessGateMode: z.enum(["off", "advisory", "block"]), + manifestPolicyGateMode: z.enum(["off", "advisory", "block"]), + linkedIssueSatisfactionGateMode: z.enum(["off", "advisory", "block"]), + contentLaneDeliverableGateMode: z.enum(["off", "advisory", "block"]), + backtestRegressionGateMode: z.enum(["off", "advisory", "block"]), + // #6443: mergeTrainMode/gittensorLabel/blacklistLabel/createMissingLabel removed -- no longer DB-backed, + // config-as-code only via .loopover.yml's settings: block now. + // #6446: firstTimeContributorGrace removed -- a dead, never-wired RESERVED/INERT field (#2266); deleted + // rather than wired in, since the gate's one-shot design deliberately never softens a blocker for a + // newcomer. + slopGateMode: z.enum(["off", "advisory", "block"]), + slopGateMinScore: z.number().int().min(0).max(100).nullable(), + slopAiAdvisory: z.boolean(), + autoLabelEnabled: z.boolean(), + closeOwnerAuthors: z.boolean(), + requireLinkedIssue: z.boolean(), + agentPaused: z.boolean(), + agentDryRun: z.boolean(), + requireFreshRebaseWindowMinutes: z.number().int().positive().nullable(), + staleBaseAheadByThreshold: z.number().int().positive().nullable(), + commandAuthorization: z.object({ + default: z.array(z.enum(["maintainer", "collaborator", "pr_author", "confirmed_miner"])).max(4).optional(), + commands: z.record(z.string().trim().min(1).max(64), z.array(z.enum(["maintainer", "collaborator", "pr_author", "confirmed_miner"])).max(4)).optional(), + }), + // Agent-layer config (#773/#774). The DB layer normalizes autonomy (deny-by-default), so a loose + // record here is safe — invalid entries are dropped on persist. + // #6445: autoMaintain removed -- no longer DB-backed, config-as-code only via .loopover.yml's + // settings: block now. + autonomy: z.record(z.string().trim().min(1).max(32), z.enum(["observe", "auto_with_approval", "auto"])), + }) + .partial(); + +// #7676 installation-scoped bulk pause/dry-run: the same two per-repo flags maintainerSettingsSchema already +// validates, picked out on their own so a tenant with many repos under one installation can flip both at once +// instead of one PUT /v1/repos/:owner/:repo/settings call per repo. Deliberately just these two fields -- not +// a general bulk settings merge -- and deliberately separate from the global operator kill-switch +// (getGlobalAgentFrozenState), which stays its own singleton untouched by this. +export const installationBulkAgentSettingsSchema = maintainerSettingsSchema.pick({ agentPaused: true, agentDryRun: true }).strict(); + +// downgradeQualityGateMode (the settings-write-path "block" -> "advisory" downgrade for +// qualityGateMode/#2267) was removed here: qualityGateMode is config-as-code only now (Batch C, +// loopover#6444), so no write path sets it anymore. resolveEffectiveSettings's own downgrade logic +// (src/signals/focus-manifest.ts) still applies the same rule on the read/resolver path. + +// Maintainer BYOK provider key. Write-only: the key is encrypted at rest and never returned. A loose +// prefix check catches the common provider/key mismatch (e.g. pasting an OpenAI key under Anthropic) +// without coupling to exact provider key formats: Anthropic keys start with `sk-ant-`; OpenAI keys +// start with `sk-` but never `sk-ant-`. +export const repositoryAiKeySchema = z + .object({ + provider: z.enum(["anthropic", "openai"]), + key: z.string().trim().min(20).max(400), + model: z.string().trim().min(1).max(120).nullable().optional(), + }) + .refine((value) => (value.provider === "anthropic" ? value.key.startsWith("sk-ant-") : value.key.startsWith("sk-") && !value.key.startsWith("sk-ant-")), { + message: "API key does not match the selected provider (Anthropic keys start with sk-ant-, OpenAI keys start with sk-).", + path: ["key"], + }); + +// Instance subscription-CLI credential (#9543). Write-only: encrypted at rest, never returned. The +// single-line / no-comment / no-surrounding-whitespace rule is the SAME one the host-side rotation path +// enforces, for the same reason -- src/selfhost/load-file-secrets.ts only .trim()s, so a label line above +// the value silently becomes part of the credential and every AI call fails auth while the container stays +// healthy. Validating it here too means the DB path cannot store a shape the file path would reject. +export const rotatableProviderSchema = z.enum(["claude-code", "codex"]); +export const providerCredentialSchema = z.object({ + credential: z + .string() + .min(1) + .max(4096) + .refine((value) => !/[\r\n]/.test(value), "must be a single line -- a comment or label line would become part of the credential") + .refine((value) => value.trim() === value, "must not have leading or trailing whitespace") + .refine((value) => !value.startsWith("#"), "must not start with '#' -- that is a comment, not a credential"), +}); + +// Linear personal API key (#3186) -- no provider-prefix assertion (unlike the AI-key schema above): Linear's +// key format is not a stable enough public contract to hard-validate against, so only a length bound applies. +export const repositoryLinearKeySchema = z.object({ + key: z.string().trim().min(20).max(400), +}); + +// Maintainer-settable AI-review config. mode/byok/provider/model/allAuthors are config-as-code only now +// (Batch C, loopover#6444) -- set via a repo's own .loopover.yml gate.aiReview.* block, not this route -- +// so they are intentionally NOT accepted here anymore (a caller submitting the old shape gets a clean +// validation error naming the current route, not a silently-ignored write). The secret key is set +// separately via the ai-key route; never here. +export const repositoryAiReviewSchema = z + .object({ + closeOwnerAuthors: z.boolean().optional(), + // Disposition for a sub-aiReviewCloseConfidence-floor ai_consensus_defect/ai_review_split finding (#4603). + // Optional -- upsertRepositorySettings applies its own "hold_for_review" default when omitted. + lowConfidenceDisposition: z.enum(["one_shot", "hold_for_review", "advisory_only"]).optional(), + }) + // .strict() so a caller still sending the pre-Batch-C shape (mode/byok/provider/model/allAuthors) gets + // an immediate "unrecognized key" validation error naming exactly which fields moved, instead of those + // keys being silently dropped and the request appearing to partially succeed. + .strict(); + +export const contributorIssueDraftGenerateSchema = z.object({ + dryRun: z.boolean().optional().default(true), + create: z.boolean().optional().default(false), + limit: z.number().int().min(1).max(20).optional().default(5), +}); + +// #7764: REST mirror of the loopover_plan_repo_issues MCP tool (src/mcp/server.ts's planRepoIssuesShape). +// Unlike the contributor-issue-draft schema above, `goal` is a REQUIRED maintainer-supplied free-form string +// and `limit` is capped lower (10, not 20): every draft here costs real LLM spend, unlike that tool's zero-cost +// static signals. dryRun/create keep the same create-safety contract (create alone is rejected below). +export const issuePlanDraftGenerateSchema = z.object({ + goal: z.string().trim().min(1).max(2000), + dryRun: z.boolean().optional().default(true), + create: z.boolean().optional().default(false), + limit: z.number().int().min(1).max(10).optional().default(5), +}); + +export const settingsPreviewSchema = z.object({ + sample: z + .object({ + authorLogin: z.string().trim().min(1).max(100).optional(), + authorType: z.enum(["User", "Bot"]).optional(), + authorAssociation: z.enum(["OWNER", "MEMBER", "COLLABORATOR", "CONTRIBUTOR", "FIRST_TIMER", "FIRST_TIME_CONTRIBUTOR", "MANNEQUIN", "NONE"]).optional(), + minerStatus: z.enum(["confirmed", "not_found", "unavailable"]).optional(), + title: z.string().max(300).optional(), + body: z.string().max(10000).nullable().optional(), + labels: z.array(z.string().max(100)).max(50).optional(), + linkedIssues: z.array(z.number().int().positive()).max(50).optional(), + commandName: z.string().trim().min(1).max(64).optional(), + commenterLogin: z.string().trim().min(1).max(100).optional(), + commenterAssociation: z.enum(["OWNER", "MEMBER", "COLLABORATOR", "CONTRIBUTOR", "FIRST_TIMER", "FIRST_TIME_CONTRIBUTOR", "MANNEQUIN", "NONE"]).optional(), + }) + .optional(), +}); + +export const chatQaRequestSchema = z + .object({ + question: z.string().trim().min(1).max(500), + }) + .strict(); + +export const commandPreviewSchema = z + .object({ + command: z.string().min(1).max(80), + repoFullName: z.string().min(3).max(MAX_LOCAL_BRANCH_REF_CHARS).optional(), + pullNumber: z.number().int().positive().optional(), + login: z.string().min(1).max(MAX_LOCAL_BRANCH_REF_CHARS).optional(), + sample: z + .object({ + authorLogin: z.string().trim().min(1).max(100).optional(), + authorType: z.enum(["User", "Bot"]).optional(), + authorAssociation: z.enum(["OWNER", "MEMBER", "COLLABORATOR", "CONTRIBUTOR", "FIRST_TIMER", "FIRST_TIME_CONTRIBUTOR", "MANNEQUIN", "NONE"]).optional(), + commenterLogin: z.string().trim().min(1).max(100).optional(), + commenterAssociation: z.enum(["OWNER", "MEMBER", "COLLABORATOR", "CONTRIBUTOR", "FIRST_TIMER", "FIRST_TIME_CONTRIBUTOR", "MANNEQUIN", "NONE"]).optional(), + minerStatus: z.enum(["confirmed", "not_found", "unavailable"]).optional(), + title: z.string().max(300).optional(), + body: z.string().max(10000).nullable().optional(), + labels: z.array(z.string().max(100)).max(50).optional(), + linkedIssues: z.array(z.number().int().positive()).max(50).optional(), + permissions: z.record(z.string(), z.string()).optional(), + missingPermissions: z.array(z.string().max(100)).max(50).optional(), + }) + .strict() + .optional(), + }) + .strict(); + +export const commandFeedbackSchema = z + .object({ + answerId: z.string().min(8).max(120).regex(/^[A-Za-z0-9_.:-]+$/), + vote: z.enum(["useful", "not_useful"]), + }) + .strict(); + +export const killSwitchUpdateSchema = z + .object({ + frozen: z.boolean(), + }) + .strict(); + +// Config-push write path (#7522, piece 1 of #4902's design): an operator-addressed Orb-operational notice +// (enrollment lifecycle, capability announcement, deprecation notice) -- explicit installationIds target list +// only, no percentage/canary selector (no rollout-percentage primitive exists in this codebase to build one on +// top of; out of scope here). pushId doubles as the idempotency key (see enqueueConfigPushRelay's deliveryId +// derivation), so it's constrained to the same safe-identifier shape as commandFeedbackSchema's answerId above. +export const configPushSchema = z + .object({ + installationIds: z.array(z.number().int().positive()).min(1).max(500), + pushId: z.string().min(1).max(120).regex(/^[A-Za-z0-9_.:-]+$/), + message: z.string().min(1).max(500), + capability: z.string().min(1).max(120).optional(), + deprecatesAt: z.string().datetime().optional(), + }) + .strict(); + +export const digestSubscriptionSchema = z + .object({ + email: z.string().email().max(320), + }) + .strict(); + +export const postMergeIncidentSeveritySchema = z.enum(["low", "medium", "high", "critical"]); + +export const postMergeIncidentReportSchema = z + .object({ + description: z.string().min(1).max(4000), + severity: postMergeIncidentSeveritySchema, + mergedSha: z + .string() + .regex(/^[0-9a-f]{7,40}$/i) + .optional(), + }) + .strict(); + +export const operatorPostMergeIncidentReportSchema = z + .object({ + repoFullName: z.string().min(3).max(200), + pullNumber: z.number().int().positive(), + description: z.string().min(1).max(4000), + severity: postMergeIncidentSeveritySchema, + mergedSha: z + .string() + .regex(/^[0-9a-f]{7,40}$/i) + .optional(), + }) + .strict(); + +/** + * The queue-intelligence analysis payload (#9750). + * + * Declared INSIDE its handler until now, rebuilt on every request and invisible to any tool wanting to + * submit the same shape. The role and status literals are the ones src/queue-intelligence.ts exports as + * types; expressed here as the enums that validate them, so the schema and the analyzer cannot disagree + * about what a role is. + */ +export const QUEUE_INTELLIGENCE_LIMITS = { + bodyBytes: 1024 * 1024, + pullRequests: 250, + authorChars: 100, + titleChars: 300, + bodyChars: 4000, + duplicateCandidates: 25, +} as const; + +export const QUEUE_INTELLIGENCE_AUTHOR_ROLES = ["first-time", "contributor", "maintainer"] as const; +export const QUEUE_INTELLIGENCE_CHECKS_STATUSES = ["passing", "failing", "pending"] as const; + +export const QueueIntelligencePullRequestSchema = z.object({ + number: z.number().int().positive(), + author: z.string().max(QUEUE_INTELLIGENCE_LIMITS.authorChars), + authorRole: z.enum(QUEUE_INTELLIGENCE_AUTHOR_ROLES), + isConfirmedMiner: z.boolean(), + linkedIssue: z.object({ qualityScore: z.number().min(0).max(1) }).nullable(), + checksStatus: z.enum(QUEUE_INTELLIGENCE_CHECKS_STATUSES), + isStale: z.boolean(), + additions: z.number().int().nonnegative(), + deletions: z.number().int().nonnegative(), + title: z.string().max(QUEUE_INTELLIGENCE_LIMITS.titleChars), + body: z.string().max(QUEUE_INTELLIGENCE_LIMITS.bodyChars), + duplicateCandidates: z.array(z.number().int().positive()).max(QUEUE_INTELLIGENCE_LIMITS.duplicateCandidates), + createdAt: z.string().datetime(), + lastUpdatedAt: z.string().datetime(), +}); + +export const QueueIntelligenceRepoContextSchema = z.object({ + totalOpenPRs: z.number().int().nonnegative(), + avgReviewTimeDays: z.number().nonnegative(), + maintainerWorkload: z.number().min(0).max(1), +}); diff --git a/packages/loopover-contract/src/control-plane.ts b/packages/loopover-contract/src/control-plane.ts new file mode 100644 index 0000000000..19753d7a9b --- /dev/null +++ b/packages/loopover-contract/src/control-plane.ts @@ -0,0 +1,236 @@ +// The hosted control plane's tenant-provisioning API, as schemas (#9750). +// +// Three consumers, one contract. The control-plane Worker validates requests against these; the generator +// emits `control-plane/openapi.json` from them under a `--check` drift guard; and the miner's admin client +// (`packages/loopover-miner/lib/tenant-client.ts`) parses responses with them instead of casting. +// +// Before this the surface had NO machine-readable contract at all -- no zod, no spec -- and its only +// description was prose plus the hardcoded fetches in that client, whose `TenantRecord` was literally +// `Record`. A caller could not learn from any artifact what a tenant even has on it. +// +// The control-plane package cannot be imported by loopover-miner (separate workspaces, no dependency edge) +// and vice versa, which is exactly why this lives in the zod-only leaf both already depend on. +import { z } from "zod"; + +/** Where a tenant is in its life. The control plane owns this vocabulary; nothing invents its own. */ +export const TENANT_LIFECYCLE_STATES = ["provisioning", "active", "suspended", "failed", "torn down"] as const; +export type TenantLifecycleState = (typeof TENANT_LIFECYCLE_STATES)[number]; + +/** + * The commands a hosted AMS tenant's scheduler may wake and run. + * + * Mirrors `HOSTED_CYCLE_COMMANDS` in packages/loopover-miner/lib/hosted-entry.ts. That list and this one + * used to be two literals in two packages with comments pointing at each other and nothing checking; now + * the miner's own list is asserted against this one, so the pointing is enforced rather than hoped for. + */ +export const HOSTED_CYCLE_COMMANDS = ["discover", "manage-poll", "attempt"] as const; +export type HostedCycleCommand = (typeof HOSTED_CYCLE_COMMANDS)[number]; + +/** + * An AMS tenant's cron-wake cadence. + * + * `intervalMs` has no configured maximum on purpose: an operator choosing an absurdly long interval is + * their call to make, not something this validation second-guesses. + */ +export const AmsCycleScheduleSchema = z.object({ + command: z.enum(HOSTED_CYCLE_COMMANDS), + args: z.array(z.string()), + intervalMs: z.number().positive(), + nextDueAt: z.string(), + lastRunAt: z.string().optional(), + /** The hosted entry point's exit code from the most recent run; absent until the first one, or on timeout. */ + lastExitCode: z.number().int().optional(), +}); +export type AmsCycleSchedule = z.infer; + +/** The schedule as a CREATE request supplies it: `nextDueAt` is minted server-side, not accepted. */ +export const AmsCycleScheduleRequestSchema = z.object({ + command: z.enum(HOSTED_CYCLE_COMMANDS), + args: z.array(z.string()).optional(), + intervalMs: z.number().positive(), +}); +export type AmsCycleScheduleRequest = z.infer; + +/** A GitHub App installation ID: always a positive integer, since that is GitHub's own ID space. */ +export const OrbInstallationIdSchema = z.number().int().positive(); + +export const TenantIdentitySchema = z.object({ + name: z.string(), + /** Absent or null = unpinned; the tenant follows its release channel's default. */ + pinnedVersion: z.string().nullable().optional(), +}); + +/** + * A tenant record as the API RETURNS it. + * + * Deliberately the safe projection the routes already emit: identity, product, state, and the two + * product-specific fields, plus an OPAQUE `secretRef`. No database connection details, no injected secret, + * no credential of any kind has ever been in this shape and none may be added to it. + * + * `looseObject`, not strict: an MCP output schema is a floor rather than a fence, and a client validating + * against today's shape must not break when the control plane starts returning one more field. + */ +export const TenantRecordSchema = z.looseObject({ + tenant: TenantIdentitySchema, + product: z.string(), + state: z.enum(TENANT_LIFECYCLE_STATES), + amsSchedule: AmsCycleScheduleSchema.optional(), + orbInstallationId: OrbInstallationIdSchema.optional(), + secretRef: z.string().optional(), +}); +export type TenantRecord = z.infer; + +/** The list route additionally carries the timestamps, which the single-record projection omits. */ +export const TenantListEntrySchema = TenantRecordSchema.extend({ + createdAt: z.string(), + updatedAt: z.string(), +}); +export type TenantListEntry = z.infer; + +export const TenantListResponseSchema = z.object({ tenants: z.array(TenantListEntrySchema) }); +export type TenantListResponse = z.infer; + +export const CreateTenantRequestSchema = z.object({ + name: z.string().trim().min(1), + product: z.string().trim().min(1), + schedule: AmsCycleScheduleRequestSchema.optional(), + orbInstallationId: OrbInstallationIdSchema.optional(), +}); +export type CreateTenantRequest = z.infer; + +export const RelinkOrbInstallationRequestSchema = z.object({ orbInstallationId: OrbInstallationIdSchema }); +export type RelinkOrbInstallationRequest = z.infer; + +/** `product` is required on the by-name routes so the registry resolves the same `${product}:${name}` key. */ +export const TenantProductQuerySchema = z.object({ product: z.string().trim().min(1) }); + +/** + * The error envelope every failing route answers with. + * + * `message` is present on the ones that can say something useful and absent on the ones that cannot, which + * is why it is optional rather than required-and-sometimes-empty. + */ +export const ControlPlaneErrorSchema = z.object({ error: z.string(), message: z.string().optional() }); +export type ControlPlaneError = z.infer; + +export const HealthResponseSchema = z.object({ status: z.literal("ok"), service: z.literal("control-plane") }); + +/** Auth posture per route. The tenant admin surface takes an admin bearer; the webhook has its own HMAC. */ +export type ControlPlaneAuth = "admin-bearer" | "webhook-signature" | "public"; + +export type ControlPlaneRoute = { + method: "get" | "post" | "patch" | "delete"; + /** Hono-style, so the app and the document are generated from the same string. */ + path: string; + operationId: string; + summary: string; + description?: string; + auth: ControlPlaneAuth; + request?: { body?: z.ZodTypeAny; query?: z.ZodObject }; + responses: Record; +}; + +const ADMIN_ERRORS = { + 401: { description: "Missing or wrong admin bearer", schema: ControlPlaneErrorSchema }, + 503: { description: "No admin token is configured on this deployment, so the surface fails closed", schema: ControlPlaneErrorSchema }, +}; + +/** + * Every route the control plane serves. + * + * One table, read by the Worker's own registration and by the spec generator, so the document cannot + * describe a route the app does not serve or miss one it does. + */ +export const CONTROL_PLANE_ROUTES: readonly ControlPlaneRoute[] = [ + { + method: "get", + path: "/health", + operationId: "getControlPlaneHealth", + summary: "Liveness probe", + auth: "public", + responses: { 200: { description: "The service is up", schema: HealthResponseSchema } }, + }, + { + method: "post", + path: "/v1/tenants", + operationId: "createTenant", + summary: "Provision a tenant", + description: + "NOT idempotent, by design: an existing tenant of the same name AND product in a non-recreatable state is a 409, not a no-op. A previously torn-down or failed tenant may be recreated — a failed provision must not become a permanent block on retrying setup — and the recreated record does not inherit the old one's createdAt.", + auth: "admin-bearer", + request: { body: CreateTenantRequestSchema }, + responses: { + 201: { description: "Tenant provisioned. Returns the record in its settled state.", schema: TenantRecordSchema }, + 400: { description: "Unparseable body, or a field that failed validation", schema: ControlPlaneErrorSchema }, + 409: { description: "A tenant of this name and product already exists, or the installation ID is already claimed", schema: ControlPlaneErrorSchema }, + ...ADMIN_ERRORS, + }, + }, + { + method: "get", + path: "/v1/tenants", + operationId: "listTenants", + summary: "List every tenant with its lifecycle state", + auth: "admin-bearer", + responses: { 200: { description: "Every tenant, with timestamps", schema: TenantListResponseSchema }, ...ADMIN_ERRORS }, + }, + { + method: "post", + path: "/v1/tenants/rollout", + operationId: "rolloutTenants", + summary: "Roll a pinned image version out across tenants", + description: + "DISABLED, not unimplemented: the previous version silently no-opped on all four of its promised effects, so it answers 501 rather than accepting a request that changes nothing a caller can observe.", + auth: "admin-bearer", + responses: { 501: { description: "Tenant rollout is not implemented", schema: ControlPlaneErrorSchema }, ...ADMIN_ERRORS }, + }, + { + method: "patch", + path: "/v1/tenants/:name/orb-installation", + operationId: "relinkOrbInstallation", + summary: "Re-link an existing ORB tenant's GitHub App installation", + description: + "The manual recovery path for a tenant whose routing pointer was wiped by a since-fixed registry bug. Refuses to take an installation another currently-claiming tenant still holds, but never 409s a tenant against itself.", + auth: "admin-bearer", + request: { body: RelinkOrbInstallationRequestSchema, query: TenantProductQuerySchema }, + responses: { + 200: { description: "The installation is now linked to this tenant", schema: TenantRecordSchema }, + 400: { description: "Missing product query parameter, a non-orb product, or an invalid installation ID", schema: ControlPlaneErrorSchema }, + 404: { description: "No such tenant for that name and product", schema: ControlPlaneErrorSchema }, + 409: { description: "Another live tenant already claims that installation", schema: ControlPlaneErrorSchema }, + ...ADMIN_ERRORS, + }, + }, + { + method: "delete", + path: "/v1/tenants/:name", + operationId: "destroyTenant", + summary: "Tear a tenant down", + description: + "Refuses while the record is observably `provisioning`: there is nothing durable yet to revoke, and tearing down mid-provision races the in-flight create. Retry once it settles.", + auth: "admin-bearer", + request: { query: TenantProductQuerySchema }, + responses: { + 200: { description: "Tenant torn down", schema: TenantRecordSchema }, + 400: { description: "Missing product query parameter", schema: ControlPlaneErrorSchema }, + 404: { description: "No such tenant for that name and product", schema: ControlPlaneErrorSchema }, + 409: { description: "The tenant is still provisioning", schema: ControlPlaneErrorSchema }, + ...ADMIN_ERRORS, + }, + }, + { + method: "post", + path: "/v1/orb/webhook", + operationId: "routeOrbWebhook", + summary: "Route a GitHub webhook to the hosted ORB tenant it belongs to", + description: + "Deliberately outside the admin-bearer middleware: GitHub authenticates a webhook with its own HMAC signature over the raw body, not this service's admin token.", + auth: "webhook-signature", + responses: { + 200: { description: "Delivered to the tenant's container" }, + 401: { description: "The signature did not verify", schema: ControlPlaneErrorSchema }, + 502: { description: "The tenant's container could not be reached", schema: ControlPlaneErrorSchema }, + 503: { description: "No webhook binding is configured on this deployment", schema: ControlPlaneErrorSchema }, + }, + }, +]; diff --git a/packages/loopover-contract/src/index.ts b/packages/loopover-contract/src/index.ts index cca5877775..266cf97587 100644 --- a/packages/loopover-contract/src/index.ts +++ b/packages/loopover-contract/src/index.ts @@ -25,6 +25,19 @@ export { export * from "./enums.js"; export * from "./telemetry.js"; export { PREFLIGHT_LIMITS, PREDICT_GATE_MAX_CHANGED_PATHS, PREDICT_GATE_MAX_CHANGED_PATH_CHARS, WRITE_TOOL_LIMITS, SCENARIO_LIMITS } from "./limits.js"; +// #9750: the bounds the Worker's request schemas apply, restated here and pinned against their originals +// by test/unit/contract-api-requests.test.ts. +export { + MAX_FOCUS_MANIFEST_BYTES, + MAX_LOCAL_SCORER_WARNING_CHARS, + MAX_LOCAL_SCORER_WARNING_COUNT, + MAX_NOTIFICATION_DELIVERY_ID_LENGTH, + MAX_NOTIFICATION_MARK_READ_IDS, + PUBLIC_SURFACE_SKIP_REASONS, + SCENARIO_MAX_BRANCH_REF_CHARS, + SCENARIO_MAX_LINKED_ISSUE_NUMBERS, + SCENARIO_MAX_REPO_FULL_NAME_CHARS, +} from "./limits.js"; export { ownerRepoInput, ownerRepoPullInput, freshnessFields, toolErrorFields } from "./shared.js"; export { TOOL_CONTRACTS, listToolDefinitions, getToolContract } from "./tools/index.js"; export { diff --git a/packages/loopover-contract/src/limits.ts b/packages/loopover-contract/src/limits.ts index e2ca976e8d..2549466e79 100644 --- a/packages/loopover-contract/src/limits.ts +++ b/packages/loopover-contract/src/limits.ts @@ -67,3 +67,45 @@ export const SCENARIO_LIMITS = { repoFullNameChars: 200, branchRefChars: 200, } as const; + +// --------------------------------------------------------------------------------------------------- +// Bounds the Worker's REQUEST schemas apply (#9750). +// +// Same posture as PREFLIGHT_LIMITS above and for the same reason: api-requests.ts holds the schemas the +// API validates against, and this package cannot import the Worker or the engine. Each constant below is +// canonical in src/ or packages/loopover-engine/src/ and restated here, pinned against its original by +// test/unit/contract-api-requests.test.ts. A bound that drifts on one side only fails in the worst +// direction -- a schema that rejects input the server would have accepted, or accepts input it then +// truncates. +// --------------------------------------------------------------------------------------------------- + +/** src/db/repositories.ts */ +export const MAX_NOTIFICATION_MARK_READ_IDS = 100; +/** src/db/repositories.ts */ +export const MAX_NOTIFICATION_DELIVERY_ID_LENGTH = 128; + +/** src/signals/focus-manifest.ts */ +export const MAX_FOCUS_MANIFEST_BYTES = 128 * 1024; + +/** src/signals/local-scorer-diagnostics.ts */ +export const MAX_LOCAL_SCORER_WARNING_COUNT = 20; +/** src/signals/local-scorer-diagnostics.ts */ +export const MAX_LOCAL_SCORER_WARNING_CHARS = 1000; + +/** src/scenarios/input-model.ts */ +export const SCENARIO_MAX_REPO_FULL_NAME_CHARS = 200; +/** src/scenarios/input-model.ts */ +export const SCENARIO_MAX_BRANCH_REF_CHARS = 200; +/** src/scenarios/input-model.ts */ +export const SCENARIO_MAX_LINKED_ISSUE_NUMBERS = 50; + +/** src/signals/settings-preview.ts -- the reasons the public surface reports for skipping a PR. */ +export const PUBLIC_SURFACE_SKIP_REASONS = [ + "surface_off", + "missing_author", + "bot_author", + "ignored_author", + "maintainer_author", + "miner_detection_unavailable", + "not_official_gittensor_miner", +] as const; diff --git a/packages/loopover-miner/lib/tenant-cli.ts b/packages/loopover-miner/lib/tenant-cli.ts index b657b2e88d..6f35d0f632 100644 --- a/packages/loopover-miner/lib/tenant-cli.ts +++ b/packages/loopover-miner/lib/tenant-cli.ts @@ -84,8 +84,20 @@ export function parseTenantListArgs(args: string[]): ParsedTenantListArgs { return { json }; } +/** + * One tenant as a line of text. + * + * #9750: the name comes from `record.tenant.name`, not `record.name`. The control plane has never returned a + * top-level `name` -- its safe projection is `{ tenant: { name }, product, state, ... }` -- so this printed + * `(unknown)` for EVERY tenant against a real control plane. The bug survived because the test fixtures were + * hand-written in the flat shape, so they agreed with the renderer and neither agreed with the server. + * Typing TenantRecord from the published schema is what surfaced it: the fixtures stopped compiling. + * + * The defensive fallbacks stay. The schema is a loose object and a record can legitimately arrive from an + * older control plane, so a missing field must still print rather than throw mid-listing. + */ function renderTenantRecord(record: TenantRecord): string { - const name = typeof record.name === "string" ? record.name : "(unknown)"; + const name = typeof record.tenant?.name === "string" ? record.tenant.name : "(unknown)"; const product = typeof record.product === "string" ? record.product : "(unknown)"; const state = typeof record.state === "string" ? record.state : "(unknown)"; return `${name} product=${product} state=${state}`; diff --git a/packages/loopover-miner/lib/tenant-client.ts b/packages/loopover-miner/lib/tenant-client.ts index 3cbb01e0e7..26b5712634 100644 --- a/packages/loopover-miner/lib/tenant-client.ts +++ b/packages/loopover-miner/lib/tenant-client.ts @@ -9,6 +9,10 @@ * exactly as the API reports them -- no AMS-specific state vocabulary is invented here. A single bounded request * per call (no retry): a create is not idempotent, so it must not be silently re-sent. */ +import { TenantListResponseSchema, TenantRecordSchema, type TenantRecord } from "@loopover/contract/control-plane"; + +export type { TenantRecord }; + export const CONTROL_PLANE_FLAG = "LOOPOVER_MINER_CONTROL_PLANE"; export const CONTROL_PLANE_URL_FLAG = "LOOPOVER_MINER_CONTROL_PLANE_URL"; export const CONTROL_PLANE_ADMIN_TOKEN_FLAG = "LOOPOVER_MINER_CONTROL_PLANE_ADMIN_TOKEN"; @@ -25,10 +29,6 @@ export type CreateTenantOptions = TenantClientOptions & { product?: string; }; -/** A tenant record as reported by the control plane. Lifecycle `state` is passed through verbatim (the API owns - * the vocabulary, e.g. `provisioning` / `active` / `suspended` / `torn down`); other fields vary by product. */ -export type TenantRecord = Record; - const TRUTHY_ENV_VALUE = /^(1|true|yes|on)$/i; const DEFAULT_REQUEST_TIMEOUT_MS = 10_000; @@ -109,6 +109,24 @@ async function controlPlaneRequest( return payload; } +/** + * Parse a control-plane response against the published schema (#9750). + * + * The client used to CAST every response to `Record` and hand it straight to the CLI, so a + * field the control plane renamed reached a printed table as `undefined` and nothing anywhere reported it. + * Parsing is the fix, and it keeps this module's fail-loud posture: a create or destroy is a deliberate + * admin action, so a response that does not match the contract is an error with a readable reason, not a + * shrug. `TenantRecordSchema` is a loose object, so a control plane that starts returning an extra field + * still parses -- only a MISSING or wrong-typed one fails. + */ +function parseResponse(schema: { safeParse: (value: unknown) => { success: true; data: T } | { success: false; error: { message: string } } }, payload: unknown, method: string, path: string): T { + const parsed = schema.safeParse(payload); + if (!parsed.success) { + throw new Error(`control plane returned a response that does not match the published contract for ${method} ${path}: ${parsed.error.message}`); + } + return parsed.data; +} + /** * Create a hosted tenant instance. Returns the created tenant record exactly as the control plane reports it * (including its lifecycle `state`). `options.product` defaults to `"ams"`. @@ -118,7 +136,7 @@ async function controlPlaneRequest( */ export async function createTenant(name: string, options: CreateTenantOptions = {}): Promise { const product = typeof options.product === "string" && options.product.trim() ? options.product.trim() : "ams"; - return controlPlaneRequest("POST", "/v1/tenants", { name, product }, options); + return parseResponse(TenantRecordSchema, await controlPlaneRequest("POST", "/v1/tenants", { name, product }, options), "POST", "/v1/tenants"); } /** @@ -129,7 +147,9 @@ export async function createTenant(name: string, options: CreateTenantOptions = */ export async function listTenants(options: TenantClientOptions = {}): Promise { const payload = await controlPlaneRequest("GET", "/v1/tenants", undefined, options); - return Array.isArray(payload.tenants) ? payload.tenants : []; + // Was `Array.isArray(payload.tenants) ? payload.tenants : []` -- a missing or malformed `tenants` silently + // became "you have no tenants", which is the most misleading possible answer for an admin listing. + return parseResponse(TenantListResponseSchema, payload, "GET", "/v1/tenants").tenants; } /** @@ -140,5 +160,6 @@ export async function listTenants(options: TenantClientOptions = {}): Promise, fetchImpl?: typeof fetch, requestTimeoutMs?: number }} [options] */ export async function destroyTenant(name: string, options: TenantClientOptions = {}): Promise { - return controlPlaneRequest("DELETE", `/v1/tenants/${encodeURIComponent(name)}`, undefined, options); + const path = `/v1/tenants/${encodeURIComponent(name)}`; + return parseResponse(TenantRecordSchema, await controlPlaneRequest("DELETE", path, undefined, options), "DELETE", path); } diff --git a/scripts/gen-control-plane-contract.ts b/scripts/gen-control-plane-contract.ts new file mode 100644 index 0000000000..54531b48cc --- /dev/null +++ b/scripts/gen-control-plane-contract.ts @@ -0,0 +1,61 @@ +#!/usr/bin/env node +// Mirrors packages/loopover-contract/src/control-plane.ts into control-plane/src/generated/ (#9750). +// +// Why a generated mirror rather than a dependency: `control-plane/` is NOT an npm workspace member. It +// carries its own package-lock.json and CI installs it separately (`npm run control-plane:install`), so a +// `file:` edge onto @loopover/contract would require that package to be BUILT before that install runs -- +// an ordering constraint across two independent installs, for one zod-only module. +// +// The mirror is byte-identical apart from a header, and `--check` runs in test:ci, so the copy cannot +// diverge from the source. Same posture as gen-contract-api-schemas.ts, which mirrors in the other +// direction for the same class of reason: a package that cannot import a source still needs its schemas. +// +// The contract module is the canonical one. Editing the generated file is the thing this check exists to +// catch -- and it will, on the very next CI run. +import { mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { pathToFileURL } from "node:url"; + +const SOURCE = "packages/loopover-contract/src/control-plane.ts"; +const TARGET = "control-plane/src/generated/control-plane-contract.ts"; + +const HEADER = `// GENERATED by scripts/gen-control-plane-contract.ts from ${SOURCE} (#9750). +// Do not edit by hand; edit the contract module and run \`npm run control-plane:contract\`. +// +// control-plane/ is not an npm workspace member, so it cannot import @loopover/contract directly. This +// mirror is what lets the Worker validate against the SAME schemas the published spec is generated from +// and the miner's admin client parses with. + +`; + +export function render(source: string): string { + return HEADER + source; +} + +function main(argv: readonly string[]): void { + const check = argv.includes("--check"); + const root = process.cwd(); + const next = render(readFileSync(join(root, SOURCE), "utf8")); + const path = join(root, TARGET); + + let current = ""; + try { + current = readFileSync(path, "utf8"); + } catch { + // A missing mirror is generated fresh; under --check it is drift like any other. + } + + if (next === current) { + process.stdout.write(`gen-control-plane-contract: ${TARGET} is up to date.\n`); + return; + } + if (check) { + process.stderr.write(`gen-control-plane-contract: ${TARGET} is stale -- run \`npm run control-plane:contract\`.\n`); + process.exit(1); + } + mkdirSync(dirname(path), { recursive: true }); + writeFileSync(path, next); + process.stdout.write(`gen-control-plane-contract: wrote ${TARGET}.\n`); +} + +if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) main(process.argv.slice(2)); diff --git a/scripts/gen-control-plane-openapi.ts b/scripts/gen-control-plane-openapi.ts new file mode 100644 index 0000000000..66e8c4b92e --- /dev/null +++ b/scripts/gen-control-plane-openapi.ts @@ -0,0 +1,120 @@ +#!/usr/bin/env node +// Generates control-plane/openapi.json from the CONTROL_PLANE_ROUTES table (#9750). +// +// The hosted control plane was the one surface in this repo with no machine-readable contract at all: no +// zod, no spec, its only description prose plus the hardcoded fetches in +// packages/loopover-miner/lib/tenant-client.ts. Now the same table the Worker registers its routes from +// emits the document, so neither can describe a route the other does not serve. +// +// `--check` regenerates in memory and diffs, exiting 1 on drift; test:ci runs it, exactly like +// ui:openapi:check does for the main API. +import { writeFileSync, readFileSync } from "node:fs"; +import { join } from "node:path"; +import { pathToFileURL } from "node:url"; +import { OpenApiGeneratorV3, OpenAPIRegistry, extendZodWithOpenApi } from "@asteasolutions/zod-to-openapi"; +import { z } from "zod"; +import { CONTROL_PLANE_ROUTES, type ControlPlaneAuth } from "@loopover/contract/control-plane"; + +extendZodWithOpenApi(z); + +const TARGET = "control-plane/openapi.json"; + +/** + * Each auth level's security stanza. + * + * `[]`, not undefined, for the webhook and health routes -- an empty array is OpenAPI's explicit "needs no + * LoopOver credential", where an absent one means "not stated". The webhook genuinely needs no credential + * of this service's: GitHub authenticates it with an HMAC over the raw body, which is a scheme rather than + * a bearer, and #9707 is the cautionary case for publishing a 401 with nothing that could produce it. + */ +function securityFor(auth: ControlPlaneAuth): Array> { + if (auth === "admin-bearer") return [{ ControlPlaneAdminBearer: [] }]; + if (auth === "webhook-signature") return [{ GitHubWebhookSignature: [] }]; + return []; +} + +/** `:name` -> `{name}`, so the document templates what Hono routes. */ +function toSpecPath(path: string): string { + return path.replace(/:([A-Za-z0-9_]+)/g, "{$1}"); +} + +/** Every `:param` as an `in: path` parameter. Derived from the path, never declared 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()]))) }; +} + +export function buildControlPlaneSpec(): Record { + const registry = new OpenAPIRegistry(); + registry.registerComponent("securitySchemes", "ControlPlaneAdminBearer", { + type: "http", + scheme: "bearer", + description: "The control plane's own admin token. Distinct from any tenant's per-instance secrets and from a LoopOver API token.", + }); + registry.registerComponent("securitySchemes", "GitHubWebhookSignature", { + type: "apiKey", + in: "header", + name: "x-hub-signature-256", + description: "GitHub's HMAC-SHA256 over the raw request body, verified against the hosted fleet's own App webhook secret.", + }); + + for (const route of CONTROL_PLANE_ROUTES) { + const responses: Record = {}; + for (const [status, response] of Object.entries(route.responses)) { + responses[Number(status)] = response.schema + ? { description: response.description, content: { "application/json": { schema: response.schema } } } + : { description: response.description }; + } + registry.registerPath({ + method: route.method, + path: toSpecPath(route.path), + operationId: route.operationId, + tags: ["Control plane"], + summary: route.summary, + ...(route.description ? { description: route.description } : {}), + security: securityFor(route.auth), + request: { + ...(pathParameters(route.path) ?? {}), + ...(route.request?.body ? { body: { content: { "application/json": { schema: route.request.body } } } } : {}), + ...(route.request?.query ? { query: route.request.query } : {}), + }, + responses: responses as never, + }); + } + + return new OpenApiGeneratorV3(registry.definitions).generateDocument({ + openapi: "3.0.3", + info: { + title: "LoopOver control plane", + version: "0.1.0", + description: "Tenant provisioning for the hosted ORB + AMS fleet. Admin-only, apart from the GitHub webhook ingress.", + }, + }) as unknown as Record; +} + +function main(argv: readonly string[]): void { + const check = argv.includes("--check"); + const path = join(process.cwd(), TARGET); + const next = `${JSON.stringify(buildControlPlaneSpec(), null, 2)}\n`; + + let current = ""; + try { + current = readFileSync(path, "utf8"); + } catch { + // Missing is generated fresh, and is drift under --check. + } + + if (next === current) { + process.stdout.write(`gen-control-plane-openapi: ${TARGET} is up to date (${CONTROL_PLANE_ROUTES.length} routes).\n`); + return; + } + if (check) { + process.stderr.write(`gen-control-plane-openapi: ${TARGET} is stale -- run \`npm run control-plane:openapi\`.\n`); + process.exit(1); + } + writeFileSync(path, next); + process.stdout.write(`gen-control-plane-openapi: wrote ${TARGET} (${CONTROL_PLANE_ROUTES.length} routes).\n`); +} + +if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) main(process.argv.slice(2)); diff --git a/src/api/routes.ts b/src/api/routes.ts index 54a1be3db7..f84438209e 100644 --- a/src/api/routes.ts +++ b/src/api/routes.ts @@ -4,7 +4,7 @@ import { handleAppError, nonErrorBoundary } from "./error-handler"; import { createWorkerPostHogErrorMiddleware } from "./worker-posthog"; import { z } from "zod"; import { parsePositiveInt } from "../utils/json"; -import { analyzePRQueue, type AuthorRole, type ChecksStatus } from "../queue-intelligence"; +import { analyzePRQueue } from "../queue-intelligence"; import { completeGitHubWebOAuth, createSessionFromGitHubToken, getLiveSessionGitHubToken, pollGitHubDeviceFlow, startGitHubDeviceFlow, startGitHubWebOAuth } from "../auth/github-oauth"; import { enforceRateLimit, enforceShotRenderGlobalCeiling, routeClassForPath } from "../auth/rate-limit"; import { handleShot } from "../review/visual/shot"; @@ -34,7 +34,6 @@ import { import { normalizeGittBountySnapshot } from "../bounties/ingest"; import { DEFAULT_COMMAND_AUTHORIZATION_POLICY, normalizeCommandAuthorizationPolicy } from "../settings/command-authorization"; import { isDuplicateWinnerEnabledGlobally, resolveDuplicateWinnerEnabled } from "../settings/duplicate-winner-mode"; -import { SCENARIO_MAX_BRANCH_REF_CHARS, SCENARIO_MAX_LINKED_ISSUE_NUMBERS, SCENARIO_MAX_REPO_FULL_NAME_CHARS } from "../scenarios/input-model"; import { countOpenIssues, countOpenPullRequests, @@ -57,8 +56,6 @@ import { listAuditEventsForTarget, listNotificationDeliveriesForRecipient, markNotificationDeliveriesRead, - MAX_NOTIFICATION_DELIVERY_ID_LENGTH, - MAX_NOTIFICATION_MARK_READ_IDS, listPendingAgentActions, recordAuditEvent, recordPostMergeIncidentReport, @@ -177,7 +174,7 @@ import { } from "../orb/relay"; import { computeFleetAnalytics } from "../orb/analytics"; import { handleMcpRequest } from "../mcp/server"; -import { simulateOpenPrPressureShape } from "../mcp/server"; +import { simulateOpenPrPressureSchema } from "../mcp/server"; import { simulateOpenPrPressure, type OpenPrPressureInput } from "../services/open-pr-pressure-scenarios"; import { DISCOVERY_PATHS, discoveryDocumentsFor, respondWithDocument, toolsForDeployment } from "../mcp/discovery-routes"; import { isSelfHostedReviewRuntime } from "../selfhost/review-runtime"; @@ -256,7 +253,6 @@ import { buildAmsMinerCohortComparison } from "../review/ams-miner-cohort"; import { loadCachedBurdenForecastResponse } from "../services/burden-forecast"; import { buildUnavailableQueueTrendReport } from "../services/queue-trends"; import { loadOrComputeRepoOutcomePatternsResponse } from "../services/repo-outcome-patterns"; -import { PREFLIGHT_LIMITS } from "../signals/preflight-limits"; import { buildBountyAdvisory, buildCollisionReport, @@ -326,14 +322,13 @@ import { buildMaintainerSlopDuplicateTrend, SLOP_DUPLICATE_TREND_SNAPSHOT_LIMIT import { buildFederatedBenchmark } from "../orb/federated-benchmark"; import { resolveLoopOverSelfRepoFullName } from "../config/loopover-repo-focus-manifest"; import { buildGateOutcomeBreakdown, GATE_OUTCOME_BREAKDOWN_WINDOW_DAYS } from "../services/gate-outcome-breakdown"; -import { MAX_LOCAL_SCORER_WARNING_CHARS, MAX_LOCAL_SCORER_WARNING_COUNT } from "../signals/local-scorer-diagnostics"; -import { compileFocusManifestPolicy, MAX_FOCUS_MANIFEST_BYTES, resolveEffectiveSettings } from "../signals/focus-manifest"; +import { compileFocusManifestPolicy, resolveEffectiveSettings } from "../signals/focus-manifest"; import { resolveRepositorySettings } from "../settings/repository-settings"; import { loadPublicRepoFocusManifest, loadRepoFocusManifest, upsertRepoFocusManifest } from "../signals/focus-manifest-loader"; import { buildRepoOnboardingPackPreviewForRepo } from "../services/repo-onboarding-pack"; import { generateContributorIssueDrafts } from "../services/contributor-issue-draft"; import { generateIssuePlanDrafts } from "../services/issue-plan-draft"; -import { buildRepoSettingsPreview, PUBLIC_SURFACE_SKIP_REASONS, skippedPrAuditRemediation } from "../signals/settings-preview"; +import { buildRepoSettingsPreview, skippedPrAuditRemediation } from "../signals/settings-preview"; import { buildGittensorConfigRecommendation, buildRegistrationReadiness, @@ -430,20 +425,6 @@ async function recordRouteProductUsage( } const LOCAL_BRANCH_ANALYSIS_MAX_BODY_BYTES = 1024 * 1024; -const QUEUE_INTELLIGENCE_MAX_BODY_BYTES = 1024 * 1024; -const QUEUE_INTELLIGENCE_MAX_PULL_REQUESTS = 250; -const QUEUE_INTELLIGENCE_MAX_AUTHOR_LENGTH = 100; -const QUEUE_INTELLIGENCE_MAX_TITLE_LENGTH = 300; -const QUEUE_INTELLIGENCE_MAX_BODY_LENGTH = 4000; -const QUEUE_INTELLIGENCE_MAX_DUPLICATE_CANDIDATES = 25; - -function isJsonByteLengthWithinLimit(value: unknown, maxBytes: number): boolean { - try { - return new TextEncoder().encode(JSON.stringify(value)).byteLength <= maxBytes; - } catch { - return false; - } -} async function readRequestBodyWithLimit(request: Request, maxBytes: number): Promise { const stream = request.body; @@ -469,676 +450,63 @@ async function readRequestBodyWithLimit(request: Request, maxBytes: number): Pro return chunks.join(""); } -const MAX_LOCAL_BRANCH_REF_CHARS = 256; -const MAX_LOCAL_BRANCH_TEXT_CHARS = 4000; - -// #6745: body of POST /v1/contributors/:login/notifications/read. Mirrors markNotificationsReadShape -// (src/mcp/server.ts) minus `login` (which is the path param): `ids` is optional (absent = mark all delivered). -const markNotificationsReadBodySchema = z.object({ - ids: z.array(z.string().min(1).max(MAX_NOTIFICATION_DELIVERY_ID_LENGTH)).max(MAX_NOTIFICATION_MARK_READ_IDS).optional(), -}); - -// #7657: AMS miner posts DetectedNotificationEvent-shaped AMS kinds; recipient is forced to the path login. -const amsNotificationsBodySchema = z.object({ - events: z - .array( - z.object({ - eventType: z.enum(["ams_attempt_started", "ams_attempt_failed", "ams_governor_paused", "ams_pr_outcome"]), - repoFullName: z.string().min(1).max(200), - pullNumber: z.number().int().min(0), - dedupKey: z.string().min(1).max(500), - deeplink: z.string().min(1).max(2000), - actorLogin: z.string().min(1).max(100), - detectedAt: z.string().min(1).max(64), - }), - ) - .min(1) - .max(20), -}); - -// #6746: body of POST/DELETE /v1/contributors/:login/watches. Mirrors watchIssuesShape (src/mcp/server.ts) minus -// `login` (path param) and `action` (the HTTP verb). `labels` is POST-only (a DELETE ignores it). -const watchSubscriptionBodySchema = z.object({ - repoFullName: z.string().min(3).max(200), - labels: z.array(z.string().min(1).max(100)).max(50).optional(), -}); - -const preflightSchema = z.object({ - repoFullName: z.string().min(3).max(PREFLIGHT_LIMITS.repoFullNameChars), - contributorLogin: z.string().min(1).max(PREFLIGHT_LIMITS.contributorLoginChars).optional(), - title: z.string().min(1).max(PREFLIGHT_LIMITS.titleChars), - body: z.string().max(PREFLIGHT_LIMITS.bodyChars).optional(), - labels: z.array(z.string().max(PREFLIGHT_LIMITS.labelChars)).max(PREFLIGHT_LIMITS.labels).optional(), - changedFiles: z.array(z.string().max(PREFLIGHT_LIMITS.changedFileChars)).max(PREFLIGHT_LIMITS.changedFiles).optional(), - linkedIssues: z.array(z.number().int().positive()).max(PREFLIGHT_LIMITS.linkedIssues).optional(), - tests: z.array(z.string().max(PREFLIGHT_LIMITS.testChars)).max(PREFLIGHT_LIMITS.tests).optional(), - authorAssociation: z.string().max(PREFLIGHT_LIMITS.authorAssociationChars).optional(), -}); - -const localDiffPreflightSchema = preflightSchema.extend({ - changedLineCount: z.number().int().min(0).optional(), - testFiles: z.array(z.string().max(PREFLIGHT_LIMITS.changedFileChars)).max(PREFLIGHT_LIMITS.changedFiles).optional(), - commitMessage: z.string().max(PREFLIGHT_LIMITS.bodyChars).optional(), -}); - -const validateLinkedIssueSchema = z.object({ - issueNumber: z.number().int().positive(), - plannedChange: z - .object({ - title: z.string().min(1).max(PREFLIGHT_LIMITS.titleChars).optional(), - changedFiles: z.array(z.string().max(PREFLIGHT_LIMITS.changedFileChars)).max(PREFLIGHT_LIMITS.changedFiles).optional(), - contributorLogin: z.string().min(1).max(PREFLIGHT_LIMITS.contributorLoginChars).optional(), - }) - .optional(), -}); - -const checkBeforeStartSchema = z.object({ - issueNumber: z.number().int().positive().optional(), - title: z.string().min(1).max(PREFLIGHT_LIMITS.titleChars).optional(), - plannedPaths: z.array(z.string().max(PREFLIGHT_LIMITS.changedFileChars)).max(PREFLIGHT_LIMITS.changedFiles).optional(), -}); - -const lintPrTextSchema = z.object({ - commitMessages: z.array(z.string().max(PREFLIGHT_LIMITS.bodyChars)).max(50).optional(), - prBody: z.string().max(PREFLIGHT_LIMITS.bodyChars).optional(), - linkedIssue: z.number().int().positive().optional(), -}); - -const validateFocusManifestSchema = z.object({ - content: z.string().max(256 * 1024), - source: z.enum(["repo_file", "api_record", "none"]).optional(), -}); - -// Pure local-metadata slop self-checks (no repo data, no secrets) — mirror the loopover_check_slop_risk / -// loopover_check_issue_slop MCP tools so the npm package can offer the same agent-native self-check. -// #6754: mirrors the loopover_evaluate_escalation MCP tool's input shape exactly (src/mcp/server.ts) so the -// REST surface can never accept something the tool would reject, or vice versa. -const evaluateEscalationSchema = z.object({ - runStatus: z.enum(["running", "converged", "abandoned", "error"]), - healthStatus: z.enum(["healthy", "degraded", "critical"]).optional(), - customerFlagged: z.boolean().optional(), - killRequested: z.boolean().optional(), -}); - -// #7742: customer-facing APR transfer request. Completion is resolved SERVER-SIDE via loadAprIdeaCompletion — -// never accepted from the body (that was the #8000 Superagent P1). `.strict()` rejects any attempt to smuggle -// `ideaComplete` (or other unknown keys). Plan/payment fields are deliberately absent. -const requestAprTransferSchema = z - .object({ - installationId: z.number().int().positive(), - repoFullName: z.string().min(1).max(200), - newOwner: z.string().min(1).max(100), - ideaId: z.string().min(1).max(200).optional(), - }) - .strict(); - -// #6744: mirrors `ProposeActionInput` in @loopover/contract VERBATIM, minus owner/repo (they are path params), so -// POST /v1/repos/:owner/:repo/agent/pending-actions can never stage an action the loopover_propose_action MCP -// tool would reject, or vice versa. actionClass stays the 7-value propose set (a subset of AgentActionClass). -const proposePendingActionSchema = z.object({ - pullNumber: z.number().int().positive(), - actionClass: z.enum(["review", "request_changes", "approve", "merge", "close", "label", "review_state_label"]), - reason: z.string().max(500).optional(), - label: z.string().min(1).max(100).optional(), - reviewBody: z.string().max(60000).optional(), - mergeMethod: z.enum(["merge", "squash", "rebase"]).optional(), - closeComment: z.string().max(60000).optional(), -}); - -// #6755: mirrors `IntakeIdeaInput` in @loopover/contract VERBATIM. Fields are deliberately LOOSE here for the same -// reason they are on the tool: the engine's validateIdeaSubmission owns the real bounds/format checks and returns -// the actionable error list, so an empty/malformed submission must reach the handler rather than be rejected -// upstream by the schema. -const intakeIdeaSchema = z.object({ - id: z.string().optional(), - title: z.string().optional(), - body: z.string().optional(), - targetRepo: z.string().optional(), - constraints: z.array(z.string()).max(50).optional(), - acceptanceHints: z.array(z.string()).max(50).optional(), - priority: z.string().optional(), - decomposition: z - .array(z.object({ key: z.string(), title: z.string(), body: z.string(), dependsOn: z.array(z.string()).max(50).optional() })) - .max(50) - .optional(), -}); - -// #6752: mirrors `BuildResultsPayloadInput` in @loopover/contract VERBATIM (same bounds, same optionality) so the -// REST surface can never accept an input the MCP tool would reject, or vice versa. -const resultsPayloadSchema = z.object({ - repoFullName: z.string().min(1), - prNumber: z.number().int().nullable().optional(), - title: z.string(), - changedFiles: z - .array(z.object({ path: z.string(), additions: z.number().int().optional(), deletions: z.number().int().optional() })) - .max(5000) - .optional(), - status: z.enum(["open", "merged", "closed"]).optional(), -}); - -// #6753: mirrors `BuildProgressSnapshotInput` in @loopover/contract VERBATIM (same bounds, same optionality) so the -// REST surface can never accept an input the MCP tool would reject, or vice versa. -const progressSnapshotSchema = z.object({ - iteration: z.number().int(), - maxIterations: z.number().int().nullable().optional(), - phase: z.enum(["queued", "claiming", "coding", "reviewing", "submitting", "done"]), - status: z.enum(["running", "converged", "abandoned", "error"]), - recentActivity: z - .array(z.object({ step: z.string(), detail: z.string().optional(), at: z.string().optional() })) - .max(1000) - .optional(), -}); - -// #6749: mirrors checkTestEvidenceShape in src/mcp/server.ts VERBATIM (same bounds, same optionality) so the -// REST surface can never accept an input the MCP tool would reject, or vice versa. -const testEvidenceSchema = z.object({ - changedPaths: z.array(z.string().min(1).max(400)).max(2000), - testFiles: z.array(z.string().min(1).max(400)).max(2000).optional(), - tests: z.array(z.string().max(400)).max(2000).optional(), -}); - -// #6750: mirrors suggestBoundaryTestsShape in src/mcp/server.ts VERBATIM (same bounds, same .strict() -// objects, same optionality) so the REST surface can never accept an input the MCP tool would reject. -const boundaryTestsSchema = z.object({ - changedFiles: z.array(z.object({ path: z.string().min(1).max(400) }).strict()).max(500), - boundaryTouches: z - .array( - z - .object({ - path: z.string().min(1).max(400), - kind: z.enum(["array_index_bounds", "null_or_undefined_branch", "empty_collection_check"]), - }) - .strict(), - ) - .max(20) - .optional(), - tests: z.array(z.string().max(400)).max(2000).optional(), - testFiles: z.array(z.string().max(400)).max(2000).optional(), -}); - -const slopRiskSchema = z.object({ - changedFiles: z - .array(z.object({ path: z.string().min(1).max(400), additions: z.number().int().min(0).optional(), deletions: z.number().int().min(0).optional() })) - .max(2000) - .optional(), - description: z.string().max(20000).optional(), - tests: z.array(z.string().max(400)).max(2000).optional(), - testFiles: z.array(z.string().max(400)).max(2000).optional(), - commitMessages: z.array(z.string().max(2000)).max(200).optional(), - hasLinkedIssue: z.boolean().optional(), - issueDiscoveryLane: z.boolean().optional(), -}); - -// #6748: mirrors checkImprovementPotentialShape in src/mcp/server.ts VERBATIM (same bounds, same optionality) -// so the REST surface can never accept an input the MCP tool would reject, or vice versa. -const improvementPotentialSchema = z.object({ - changedFiles: z - .array(z.object({ path: z.string().min(1).max(400), additions: z.number().int().min(0).optional(), deletions: z.number().int().min(0).optional() })) - .max(2000) - .optional(), - tests: z.array(z.string().max(400)).max(2000).optional(), - testFiles: z.array(z.string().max(400)).max(2000).optional(), - patchCoverageDeltaPercent: z.number().optional(), - complexityDeltas: z - .array( - z.object({ - file: z.string().min(1).max(400), - line: z.number().int().min(1), - name: z.string().min(1).max(400), - before: z.number().int().min(0), - after: z.number().int().min(0), - delta: z.number().int(), - }), - ) - .max(2000) - .optional(), - duplicationDeltas: z - .array( - z.object({ - file: z.string().min(1).max(400), - line: z.number().int().min(1), - duplicateOfLine: z.number().int().min(1), - lines: z.number().int().min(1), - }), - ) - .max(2000) - .optional(), -}); -const issueSlopSchema = z.object({ - title: z.string().max(500).optional(), - body: z.string().max(40000).optional(), -}); - -const selfhostDeadLetterQueueQuerySchema = z - .object({ - limit: z.coerce.number().int().optional(), - offset: z.coerce.number().int().optional(), - }) - .strict(); - -const skippedPrAuditQuerySchema = z - .object({ - limit: z.coerce.number().int().optional(), - offset: z.coerce.number().int().optional(), - repoFullName: z.string().trim().min(3).max(200).optional(), - reason: z.enum(PUBLIC_SURFACE_SKIP_REASONS).optional(), - since: z.string().trim().min(1).max(64).optional(), - }) - .strict(); - -const localBranchChangedFileSchema = z - .object({ - path: z.string().min(1).max(MAX_LOCAL_BRANCH_REF_CHARS), - previousPath: z.string().min(1).max(MAX_LOCAL_BRANCH_REF_CHARS).optional(), - additions: z.number().int().min(0).optional(), - deletions: z.number().int().min(0).optional(), - status: z.enum(["added", "modified", "deleted", "renamed", "copied", "unknown"]).optional(), - binary: z.boolean().optional(), - }) - .strict(); - -const localBranchValidationSchema = z - .object({ - command: z.string().min(1).max(MAX_LOCAL_BRANCH_REF_CHARS), - status: z.enum(["passed", "failed", "not_run", "skipped", "focused", "unknown"]), - summary: z.string().max(MAX_LOCAL_BRANCH_TEXT_CHARS).optional(), - durationMs: z.number().int().min(0).optional(), - exitCode: z.number().int().min(0).optional(), - }) - .strict(); - -const localBranchScorerSchema = z - .object({ - mode: z.enum(["metadata_only", "external_command", "gittensor_root"]), - activeModel: z.string().max(MAX_LOCAL_BRANCH_REF_CHARS).optional(), - sourceTokenScore: z.number().min(0).optional(), - totalTokenScore: z.number().min(0).optional(), - sourceLines: z.number().min(0).optional(), - testTokenScore: z.number().min(0).optional(), - nonCodeTokenScore: z.number().min(0).optional(), - nonCodeLines: z.number().min(0).optional(), - warnings: z.array(z.string().max(MAX_LOCAL_SCORER_WARNING_CHARS)).max(MAX_LOCAL_SCORER_WARNING_COUNT).optional(), - }) - .strict(); - -const linkedIssueContextSchema = z - .object({ - status: z.enum(["raw", "plausible", "validated", "invalid", "unavailable"]).optional(), - source: z.enum(["user_supplied", "official_mirror", "github_cache", "issue_quality", "missing"]).optional(), - issueNumbers: z.array(z.number().int().positive()).max(50).optional(), - solvedByPullRequests: z.array(z.number().int().positive()).max(50).optional(), - reason: z.string().max(MAX_LOCAL_BRANCH_TEXT_CHARS).optional(), - warnings: z.array(z.string().max(MAX_LOCAL_BRANCH_TEXT_CHARS)).max(20).optional(), - }) - .strict(); - -const branchEligibilitySchema = z - .object({ - status: z.enum(["eligible", "ineligible", "unknown"]), - source: z.enum(["github_metadata", "local_metadata", "registry", "user_supplied"]).optional(), - reason: z.string().max(MAX_LOCAL_BRANCH_TEXT_CHARS).optional(), - checkedAt: z.string().max(MAX_LOCAL_BRANCH_REF_CHARS).optional(), - stale: z.boolean().optional(), - }) - .strict() - .transform((value) => ({ ...value, status: value.status === "eligible" ? ("unknown" as const) : value.status, source: "user_supplied" as const })); - -const focusManifestInputSchema = z - .record(z.string(), z.unknown()) - .refine((manifest) => isJsonByteLengthWithinLimit(manifest, MAX_FOCUS_MANIFEST_BYTES), { - message: `focusManifest must serialize to ${MAX_FOCUS_MANIFEST_BYTES} bytes or fewer`, - }); - -export const localBranchAnalysisSchema = z - .object({ - login: z.string().min(1).max(MAX_LOCAL_BRANCH_REF_CHARS), - repoFullName: z.string().min(3).max(SCENARIO_MAX_REPO_FULL_NAME_CHARS), - baseRef: z.string().min(1).max(SCENARIO_MAX_BRANCH_REF_CHARS).optional(), - headRef: z.string().min(1).max(SCENARIO_MAX_BRANCH_REF_CHARS).optional(), - branchName: z.string().min(1).max(SCENARIO_MAX_BRANCH_REF_CHARS).optional(), - baseSha: z.string().min(1).max(MAX_LOCAL_BRANCH_REF_CHARS).optional(), - headSha: z.string().min(1).max(MAX_LOCAL_BRANCH_REF_CHARS).optional(), - mergeBaseSha: z.string().min(1).max(MAX_LOCAL_BRANCH_REF_CHARS).optional(), - remoteTrackingSha: z.string().min(1).max(MAX_LOCAL_BRANCH_REF_CHARS).optional(), - commitMessages: z.array(z.string().max(MAX_LOCAL_BRANCH_TEXT_CHARS)).max(30).optional(), - changedFiles: z.array(localBranchChangedFileSchema).max(500).optional(), - validation: z.array(localBranchValidationSchema).max(50).optional(), - linkedIssues: z.array(z.number().int().positive()).max(SCENARIO_MAX_LINKED_ISSUE_NUMBERS).optional(), - labels: z.array(z.string().min(1).max(MAX_LOCAL_BRANCH_REF_CHARS)).max(50).optional(), - title: z.string().min(1).max(MAX_LOCAL_BRANCH_REF_CHARS).optional(), - body: z.string().max(MAX_LOCAL_BRANCH_TEXT_CHARS).optional(), - localScorer: localBranchScorerSchema.optional(), - pendingMergedPrCount: z.number().int().min(0).optional(), - pendingClosedPrCount: z.number().int().min(0).optional(), - approvedPrCount: z.number().int().min(0).optional(), - expectedOpenPrCountAfterMerge: z.number().int().min(0).optional(), - projectedCredibility: z.number().min(0).max(1).optional(), - scenarioNotes: z.array(z.string().max(MAX_LOCAL_BRANCH_TEXT_CHARS)).max(20).optional(), - pendingCommitCount: z.number().int().min(0).optional(), - ciStatusHints: z.array(z.string().max(MAX_LOCAL_BRANCH_TEXT_CHARS)).max(20).optional(), - focusManifest: focusManifestInputSchema.optional(), - branchEligibility: branchEligibilitySchema.optional(), - }) - .strict(); - -const scorePreviewSchema = z.object({ - repoFullName: z.string().min(3), - targetType: z.enum(["planned_pr", "pull_request", "local_diff", "variant"]).default("planned_pr"), - targetKey: z.string().optional(), - contributorLogin: z.string().min(1).optional(), - labels: z.array(z.string()).optional(), - linkedIssueMode: z.enum(["none", "standard", "maintainer"]).default("none"), - linkedIssueContext: linkedIssueContextSchema.optional(), - sourceTokenScore: z.number().min(0).optional(), - totalTokenScore: z.number().min(0).optional(), - sourceLines: z.number().min(0).optional(), - testTokenScore: z.number().min(0).optional(), - nonCodeTokenScore: z.number().min(0).optional(), - nonCodeLines: z.number().min(0).optional(), - existingContributorTokenScore: z.number().min(0).optional(), - prAgeHours: z.number().min(0).optional(), - openPrCount: z.number().int().min(0).optional(), - mergedPullRequests: z.number().int().min(0).optional(), - validSolvedIssues: z.number().int().min(0).optional(), - issueCredibility: z.number().min(0).max(1).optional(), - credibility: z.number().min(0).max(1).optional(), - changesRequestedCount: z.number().int().min(0).optional(), - duplicateRiskCount: z.number().int().min(0).optional(), - fixedBaseScore: z.number().min(0).optional(), - metadataOnly: z.boolean().default(false), - pendingMergedPrCount: z.number().int().min(0).optional(), - pendingClosedPrCount: z.number().int().min(0).optional(), - approvedPrCount: z.number().int().min(0).optional(), - expectedOpenPrCountAfterMerge: z.number().int().min(0).optional(), - projectedCredibility: z.number().min(0).max(1).optional(), - scenarioNotes: z.array(z.string()).max(20).optional(), - branchEligibility: branchEligibilitySchema.optional(), -}); - -const agentSurfaceSchema = z.enum(["api", "mcp", "github_comment"]).default("api"); - -const agentRunSchema = z - .object({ - objective: z.string().min(1).max(500), - actorLogin: z.string().min(1), - surface: agentSurfaceSchema.optional(), - target: z - .object({ - repoFullName: z.string().min(3).optional(), - pullNumber: z.number().int().positive().optional(), - issueNumber: z.number().int().positive().optional(), - }) - .strict() - .optional(), - }) - .strict(); - -const agentPlanSchema = z - .object({ - login: z.string().min(1), - objective: z.string().min(1).max(500).optional(), - repoFullName: z.string().min(3).optional(), - surface: agentSurfaceSchema.optional(), - }) - .strict(); - -const agentExplainBlockersSchema = z.union([localBranchAnalysisSchema, agentPlanSchema]); - -// reviewCheckMode/linkedIssueGateMode/duplicatePrGateMode/qualityGateMode/qualityGateMinScore/ -// aiReviewMode/aiReviewByok/aiReviewProvider/aiReviewModel/aiReviewAllAuthors removed from this write -// schema (Batch C, loopover#6444) -- config-as-code only via .loopover.yml's gate.* block now; -// upsertRepositorySettings no longer has a DB column to write any of them into. -const repositorySettingsSchema = z.object({ - gatePack: z.enum(["gittensor", "oss-anti-slop"]).default("gittensor"), - aiReviewLowConfidenceDisposition: z.enum(["one_shot", "hold_for_review", "advisory_only"]).default("hold_for_review"), - closeOwnerAuthors: z.boolean().default(false), - autoLabelEnabled: z.boolean().default(true), - // #6443: gittensorLabel/blacklistLabel/createMissingLabel/contributorBlacklist removed -- no longer - // DB-backed, config-as-code only via .loopover.yml's settings: block now. - requireLinkedIssue: z.boolean().default(false), - commandAuthorization: z - .object({ - default: z.array(z.enum(["maintainer", "collaborator", "pr_author", "confirmed_miner"])).max(4).optional(), - commands: z.record(z.string().trim().min(1).max(64), z.array(z.enum(["maintainer", "collaborator", "pr_author", "confirmed_miner"])).max(4)).optional(), - }) - .default(DEFAULT_COMMAND_AUTHORIZATION_POLICY), -}); - -// #130 maintainer self-serve settings editor. A PATCH-style subset: every field optional so the maintainer -// dashboard can save just the group it changed. Excludes the secret-bearing aiReview* group (set via the -// dedicated /ai-review + /ai-key routes) and the operator-only scoring internal (backfillEnabled). The -// handler loads current settings and merges, since upsertRepositorySettings defaults any absent field -// rather than preserving it. -// reviewCheckMode/linkedIssueGateMode/duplicatePrGateMode/qualityGateMode/qualityGateMinScore/ -// selfAuthoredLinkedIssueGateMode removed from this write schema (Batch C, loopover#6444) -- -// config-as-code only via .loopover.yml's gate.* block now. -const maintainerSettingsSchema = z - .object({ - gatePack: z.enum(["gittensor", "oss-anti-slop"]), - mergeReadinessGateMode: z.enum(["off", "advisory", "block"]), - manifestPolicyGateMode: z.enum(["off", "advisory", "block"]), - linkedIssueSatisfactionGateMode: z.enum(["off", "advisory", "block"]), - contentLaneDeliverableGateMode: z.enum(["off", "advisory", "block"]), - backtestRegressionGateMode: z.enum(["off", "advisory", "block"]), - // #6443: mergeTrainMode/gittensorLabel/blacklistLabel/createMissingLabel removed -- no longer DB-backed, - // config-as-code only via .loopover.yml's settings: block now. - // #6446: firstTimeContributorGrace removed -- a dead, never-wired RESERVED/INERT field (#2266); deleted - // rather than wired in, since the gate's one-shot design deliberately never softens a blocker for a - // newcomer. - slopGateMode: z.enum(["off", "advisory", "block"]), - slopGateMinScore: z.number().int().min(0).max(100).nullable(), - slopAiAdvisory: z.boolean(), - autoLabelEnabled: z.boolean(), - closeOwnerAuthors: z.boolean(), - requireLinkedIssue: z.boolean(), - agentPaused: z.boolean(), - agentDryRun: z.boolean(), - requireFreshRebaseWindowMinutes: z.number().int().positive().nullable(), - staleBaseAheadByThreshold: z.number().int().positive().nullable(), - commandAuthorization: z.object({ - default: z.array(z.enum(["maintainer", "collaborator", "pr_author", "confirmed_miner"])).max(4).optional(), - commands: z.record(z.string().trim().min(1).max(64), z.array(z.enum(["maintainer", "collaborator", "pr_author", "confirmed_miner"])).max(4)).optional(), - }), - // Agent-layer config (#773/#774). The DB layer normalizes autonomy (deny-by-default), so a loose - // record here is safe — invalid entries are dropped on persist. - // #6445: autoMaintain removed -- no longer DB-backed, config-as-code only via .loopover.yml's - // settings: block now. - autonomy: z.record(z.string().trim().min(1).max(32), z.enum(["observe", "auto_with_approval", "auto"])), - }) - .partial(); - -// #7676 installation-scoped bulk pause/dry-run: the same two per-repo flags maintainerSettingsSchema already -// validates, picked out on their own so a tenant with many repos under one installation can flip both at once -// instead of one PUT /v1/repos/:owner/:repo/settings call per repo. Deliberately just these two fields -- not -// a general bulk settings merge -- and deliberately separate from the global operator kill-switch -// (getGlobalAgentFrozenState), which stays its own singleton untouched by this. -const installationBulkAgentSettingsSchema = maintainerSettingsSchema.pick({ agentPaused: true, agentDryRun: true }).strict(); - -// downgradeQualityGateMode (the settings-write-path "block" -> "advisory" downgrade for -// qualityGateMode/#2267) was removed here: qualityGateMode is config-as-code only now (Batch C, -// loopover#6444), so no write path sets it anymore. resolveEffectiveSettings's own downgrade logic -// (src/signals/focus-manifest.ts) still applies the same rule on the read/resolver path. - -// Maintainer BYOK provider key. Write-only: the key is encrypted at rest and never returned. A loose -// prefix check catches the common provider/key mismatch (e.g. pasting an OpenAI key under Anthropic) -// without coupling to exact provider key formats: Anthropic keys start with `sk-ant-`; OpenAI keys -// start with `sk-` but never `sk-ant-`. -const repositoryAiKeySchema = z - .object({ - provider: z.enum(["anthropic", "openai"]), - key: z.string().trim().min(20).max(400), - model: z.string().trim().min(1).max(120).nullable().optional(), - }) - .refine((value) => (value.provider === "anthropic" ? value.key.startsWith("sk-ant-") : value.key.startsWith("sk-") && !value.key.startsWith("sk-ant-")), { - message: "API key does not match the selected provider (Anthropic keys start with sk-ant-, OpenAI keys start with sk-).", - path: ["key"], - }); - -// Instance subscription-CLI credential (#9543). Write-only: encrypted at rest, never returned. The -// single-line / no-comment / no-surrounding-whitespace rule is the SAME one the host-side rotation path -// enforces, for the same reason -- src/selfhost/load-file-secrets.ts only .trim()s, so a label line above -// the value silently becomes part of the credential and every AI call fails auth while the container stays -// healthy. Validating it here too means the DB path cannot store a shape the file path would reject. -const rotatableProviderSchema = z.enum(["claude-code", "codex"]); -const providerCredentialSchema = z.object({ - credential: z - .string() - .min(1) - .max(4096) - .refine((value) => !/[\r\n]/.test(value), "must be a single line -- a comment or label line would become part of the credential") - .refine((value) => value.trim() === value, "must not have leading or trailing whitespace") - .refine((value) => !value.startsWith("#"), "must not start with '#' -- that is a comment, not a credential"), -}); - -// Linear personal API key (#3186) -- no provider-prefix assertion (unlike the AI-key schema above): Linear's -// key format is not a stable enough public contract to hard-validate against, so only a length bound applies. -const repositoryLinearKeySchema = z.object({ - key: z.string().trim().min(20).max(400), -}); - -// Maintainer-settable AI-review config. mode/byok/provider/model/allAuthors are config-as-code only now -// (Batch C, loopover#6444) -- set via a repo's own .loopover.yml gate.aiReview.* block, not this route -- -// so they are intentionally NOT accepted here anymore (a caller submitting the old shape gets a clean -// validation error naming the current route, not a silently-ignored write). The secret key is set -// separately via the ai-key route; never here. -const repositoryAiReviewSchema = z - .object({ - closeOwnerAuthors: z.boolean().optional(), - // Disposition for a sub-aiReviewCloseConfidence-floor ai_consensus_defect/ai_review_split finding (#4603). - // Optional -- upsertRepositorySettings applies its own "hold_for_review" default when omitted. - lowConfidenceDisposition: z.enum(["one_shot", "hold_for_review", "advisory_only"]).optional(), - }) - // .strict() so a caller still sending the pre-Batch-C shape (mode/byok/provider/model/allAuthors) gets - // an immediate "unrecognized key" validation error naming exactly which fields moved, instead of those - // keys being silently dropped and the request appearing to partially succeed. - .strict(); - -const contributorIssueDraftGenerateSchema = z.object({ - dryRun: z.boolean().optional().default(true), - create: z.boolean().optional().default(false), - limit: z.number().int().min(1).max(20).optional().default(5), -}); - -// #7764: REST mirror of the loopover_plan_repo_issues MCP tool (src/mcp/server.ts's planRepoIssuesShape). -// Unlike the contributor-issue-draft schema above, `goal` is a REQUIRED maintainer-supplied free-form string -// and `limit` is capped lower (10, not 20): every draft here costs real LLM spend, unlike that tool's zero-cost -// static signals. dryRun/create keep the same create-safety contract (create alone is rejected below). -const issuePlanDraftGenerateSchema = z.object({ - goal: z.string().trim().min(1).max(2000), - dryRun: z.boolean().optional().default(true), - create: z.boolean().optional().default(false), - limit: z.number().int().min(1).max(10).optional().default(5), -}); - -const settingsPreviewSchema = z.object({ - sample: z - .object({ - authorLogin: z.string().trim().min(1).max(100).optional(), - authorType: z.enum(["User", "Bot"]).optional(), - authorAssociation: z.enum(["OWNER", "MEMBER", "COLLABORATOR", "CONTRIBUTOR", "FIRST_TIMER", "FIRST_TIME_CONTRIBUTOR", "MANNEQUIN", "NONE"]).optional(), - minerStatus: z.enum(["confirmed", "not_found", "unavailable"]).optional(), - title: z.string().max(300).optional(), - body: z.string().max(10000).nullable().optional(), - labels: z.array(z.string().max(100)).max(50).optional(), - linkedIssues: z.array(z.number().int().positive()).max(50).optional(), - commandName: z.string().trim().min(1).max(64).optional(), - commenterLogin: z.string().trim().min(1).max(100).optional(), - commenterAssociation: z.enum(["OWNER", "MEMBER", "COLLABORATOR", "CONTRIBUTOR", "FIRST_TIMER", "FIRST_TIME_CONTRIBUTOR", "MANNEQUIN", "NONE"]).optional(), - }) - .optional(), -}); - -const chatQaRequestSchema = z - .object({ - question: z.string().trim().min(1).max(500), - }) - .strict(); - -const commandPreviewSchema = z - .object({ - command: z.string().min(1).max(80), - repoFullName: z.string().min(3).max(MAX_LOCAL_BRANCH_REF_CHARS).optional(), - pullNumber: z.number().int().positive().optional(), - login: z.string().min(1).max(MAX_LOCAL_BRANCH_REF_CHARS).optional(), - sample: z - .object({ - authorLogin: z.string().trim().min(1).max(100).optional(), - authorType: z.enum(["User", "Bot"]).optional(), - authorAssociation: z.enum(["OWNER", "MEMBER", "COLLABORATOR", "CONTRIBUTOR", "FIRST_TIMER", "FIRST_TIME_CONTRIBUTOR", "MANNEQUIN", "NONE"]).optional(), - commenterLogin: z.string().trim().min(1).max(100).optional(), - commenterAssociation: z.enum(["OWNER", "MEMBER", "COLLABORATOR", "CONTRIBUTOR", "FIRST_TIMER", "FIRST_TIME_CONTRIBUTOR", "MANNEQUIN", "NONE"]).optional(), - minerStatus: z.enum(["confirmed", "not_found", "unavailable"]).optional(), - title: z.string().max(300).optional(), - body: z.string().max(10000).nullable().optional(), - labels: z.array(z.string().max(100)).max(50).optional(), - linkedIssues: z.array(z.number().int().positive()).max(50).optional(), - permissions: z.record(z.string(), z.string()).optional(), - missingPermissions: z.array(z.string().max(100)).max(50).optional(), - }) - .strict() - .optional(), - }) - .strict(); - -const commandFeedbackSchema = z - .object({ - answerId: z.string().min(8).max(120).regex(/^[A-Za-z0-9_.:-]+$/), - vote: z.enum(["useful", "not_useful"]), - }) - .strict(); - -const killSwitchUpdateSchema = z - .object({ - frozen: z.boolean(), - }) - .strict(); - -// Config-push write path (#7522, piece 1 of #4902's design): an operator-addressed Orb-operational notice -// (enrollment lifecycle, capability announcement, deprecation notice) -- explicit installationIds target list -// only, no percentage/canary selector (no rollout-percentage primitive exists in this codebase to build one on -// top of; out of scope here). pushId doubles as the idempotency key (see enqueueConfigPushRelay's deliveryId -// derivation), so it's constrained to the same safe-identifier shape as commandFeedbackSchema's answerId above. -const configPushSchema = z - .object({ - installationIds: z.array(z.number().int().positive()).min(1).max(500), - pushId: z.string().min(1).max(120).regex(/^[A-Za-z0-9_.:-]+$/), - message: z.string().min(1).max(500), - capability: z.string().min(1).max(120).optional(), - deprecatesAt: z.string().datetime().optional(), - }) - .strict(); - -const digestSubscriptionSchema = z - .object({ - email: z.string().email().max(320), - }) - .strict(); - -const postMergeIncidentSeveritySchema = z.enum(["low", "medium", "high", "critical"]); - -const postMergeIncidentReportSchema = z - .object({ - description: z.string().min(1).max(4000), - severity: postMergeIncidentSeveritySchema, - mergedSha: z - .string() - .regex(/^[0-9a-f]{7,40}$/i) - .optional(), - }) - .strict(); - -const operatorPostMergeIncidentReportSchema = z - .object({ - repoFullName: z.string().min(3).max(200), - pullNumber: z.number().int().positive(), - description: z.string().min(1).max(4000), - severity: postMergeIncidentSeveritySchema, - mergedSha: z - .string() - .regex(/^[0-9a-f]{7,40}$/i) - .optional(), - }) - .strict(); +// #9750: every request schema this file used to declare inline now lives in @loopover/contract, so an MCP +// tool wrapping one of these routes references the SAME object rather than a copy of the shape. The one +// exception is the settings write schema, whose `commandAuthorization` default is a twenty-key policy owned +// by the engine -- the contract exports the shape as a factory and the engine's value is applied here, once. +import { + markNotificationsReadBodySchema, + amsNotificationsBodySchema, + watchSubscriptionBodySchema, + preflightSchema, + localDiffPreflightSchema, + validateLinkedIssueSchema, + checkBeforeStartSchema, + lintPrTextSchema, + validateFocusManifestSchema, + evaluateEscalationSchema, + requestAprTransferSchema, + proposePendingActionSchema, + intakeIdeaSchema, + resultsPayloadSchema, + progressSnapshotSchema, + testEvidenceSchema, + boundaryTestsSchema, + slopRiskSchema, + improvementPotentialSchema, + issueSlopSchema, + selfhostDeadLetterQueueQuerySchema, + skippedPrAuditQuerySchema, + localBranchAnalysisSchema, + scorePreviewSchema, + agentRunSchema, + agentPlanSchema, + agentExplainBlockersSchema, + maintainerSettingsSchema, + installationBulkAgentSettingsSchema, + repositoryAiKeySchema, + rotatableProviderSchema, + providerCredentialSchema, + repositoryLinearKeySchema, + repositoryAiReviewSchema, + contributorIssueDraftGenerateSchema, + issuePlanDraftGenerateSchema, + settingsPreviewSchema, + chatQaRequestSchema, + commandPreviewSchema, + commandFeedbackSchema, + killSwitchUpdateSchema, + configPushSchema, + digestSubscriptionSchema, + postMergeIncidentReportSchema, + operatorPostMergeIncidentReportSchema, + buildRepositorySettingsSchema, + QUEUE_INTELLIGENCE_LIMITS, + QueueIntelligencePullRequestSchema, + QueueIntelligenceRepoContextSchema, +} from "@loopover/contract/api-requests"; + +const repositorySettingsSchema = buildRepositorySettingsSchema(DEFAULT_COMMAND_AUTHORIZATION_POLICY); function contributorOpenIssueCount(issues: Array<{ repoFullName: string; state: string }>, repoFullName: string): number { const targetRepo = repoFullName.toLowerCase(); @@ -4014,7 +3382,7 @@ export function createApp() { // delegates to the same pure simulateOpenPrPressure. No logic of its own. app.post("/v1/lint/open-pr-pressure", async (c) => { const body = await c.req.json().catch(() => null); - const parsed = z.object(simulateOpenPrPressureShape).safeParse(body); + const parsed = simulateOpenPrPressureSchema.safeParse(body); if (!parsed.success) return c.json({ error: "invalid_open_pr_pressure_request", issues: parsed.error.issues }, 400); return c.json(simulateOpenPrPressure(parsed.data as unknown as OpenPrPressureInput)); }); @@ -5313,13 +4681,13 @@ export function createApp() { app.post("/v1/internal/queue-intelligence", async (c) => { const contentLength = parsePositiveInt(c.req.header("content-length")); - if (contentLength !== null && contentLength > QUEUE_INTELLIGENCE_MAX_BODY_BYTES) { - return c.json({ error: "payload_too_large", maxBytes: QUEUE_INTELLIGENCE_MAX_BODY_BYTES }, 413); + if (contentLength !== null && contentLength > QUEUE_INTELLIGENCE_LIMITS.bodyBytes) { + return c.json({ error: "payload_too_large", maxBytes: QUEUE_INTELLIGENCE_LIMITS.bodyBytes }, 413); } - const rawBody = await readRequestBodyWithLimit(c.req.raw, QUEUE_INTELLIGENCE_MAX_BODY_BYTES); + const rawBody = await readRequestBodyWithLimit(c.req.raw, QUEUE_INTELLIGENCE_LIMITS.bodyBytes); if (rawBody === null) { - return c.json({ error: "payload_too_large", maxBytes: QUEUE_INTELLIGENCE_MAX_BODY_BYTES }, 413); + return c.json({ error: "payload_too_large", maxBytes: QUEUE_INTELLIGENCE_LIMITS.bodyBytes }, 413); } let body: unknown; @@ -5332,32 +4700,10 @@ export function createApp() { return c.json({ error: "invalid_request", detail: "pullRequests array required" }, 400); } const queueBody = body as { pullRequests: unknown[]; repoContext?: unknown }; - const prSchema = z.object({ - number: z.number().int().positive(), - author: z.string().max(QUEUE_INTELLIGENCE_MAX_AUTHOR_LENGTH), - authorRole: z.enum(["first-time", "contributor", "maintainer"] as [AuthorRole, ...AuthorRole[]]), - isConfirmedMiner: z.boolean(), - linkedIssue: z.object({ qualityScore: z.number().min(0).max(1) }).nullable(), - checksStatus: z.enum(["passing", "failing", "pending"] as [ChecksStatus, ...ChecksStatus[]]), - isStale: z.boolean(), - additions: z.number().int().nonnegative(), - deletions: z.number().int().nonnegative(), - title: z.string().max(QUEUE_INTELLIGENCE_MAX_TITLE_LENGTH), - body: z.string().max(QUEUE_INTELLIGENCE_MAX_BODY_LENGTH), - duplicateCandidates: z.array(z.number().int().positive()).max(QUEUE_INTELLIGENCE_MAX_DUPLICATE_CANDIDATES), - createdAt: z.string().datetime(), - lastUpdatedAt: z.string().datetime(), - }); - const repoContextSchema = z.object({ - totalOpenPRs: z.number().int().nonnegative(), - avgReviewTimeDays: z.number().nonnegative(), - maintainerWorkload: z.number().min(0).max(1), - }); - const prsResult = z.array(prSchema).max(QUEUE_INTELLIGENCE_MAX_PULL_REQUESTS).safeParse(queueBody.pullRequests); + const prsResult = z.array(QueueIntelligencePullRequestSchema).max(QUEUE_INTELLIGENCE_LIMITS.pullRequests).safeParse(queueBody.pullRequests); if (!prsResult.success) return c.json({ error: "invalid_request", issues: prsResult.error.issues }, 400); - const repoContext = repoContextSchema.safeParse(queueBody.repoContext).success - ? repoContextSchema.parse(queueBody.repoContext) - : { totalOpenPRs: 0, avgReviewTimeDays: 0, maintainerWorkload: 0 }; + const parsedRepoContext = QueueIntelligenceRepoContextSchema.safeParse(queueBody.repoContext); + const repoContext = parsedRepoContext.success ? parsedRepoContext.data : { totalOpenPRs: 0, avgReviewTimeDays: 0, maintainerWorkload: 0 }; const result = await analyzePRQueue(prsResult.data, repoContext); const recommendations: Record = {}; for (const [num, rec] of result.recommendations) recommendations[num] = rec; diff --git a/src/mcp/server.ts b/src/mcp/server.ts index 82ce2d30e8..bccab23f63 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -1071,6 +1071,15 @@ export const simulateOpenPrPressureShape = { contributorOpenPrCount: simulateOpenPrPressureCountSchema.optional(), }; +/** + * The same shape as a schema, built ONCE (#9750). + * + * `POST /v1/lint/open-pr-pressure` used to call `z.object(simulateOpenPrPressureShape)` inside its handler, + * so an identical schema was constructed on every request. Exported next to the shape it wraps, which also + * leaves routes.ts with no request-schema literal of its own. + */ +export const simulateOpenPrPressureSchema = z.object(simulateOpenPrPressureShape); + export async function handleMcpRequest(c: AppContext): Promise { if (c.req.method === "OPTIONS") return new Response(null, { status: 204 }); const identity = await authenticateMcpRequest(c); diff --git a/src/openapi/route-inventory.ts b/src/openapi/route-inventory.ts index 34dbb0d75f..3f5d150534 100644 --- a/src/openapi/route-inventory.ts +++ b/src/openapi/route-inventory.ts @@ -11,6 +11,7 @@ // This module is the comparison. It is deliberately pure (no fs, no env) so both the ratchet test // and any future tooling can use it, and so it stays cheap enough to run on every CI job. import type { Hono } from "hono"; +import { SELFHOST_INFRA_ROUTE_KEYS } from "./selfhost-infra-route-specs"; /** A route as either side describes it, normalized to one comparable string. */ export type RouteKey = string; @@ -74,8 +75,13 @@ export type RouteSpecDiff = { export function diffRoutesAgainstSpec(liveKeys: readonly RouteKey[], specKeys: readonly RouteKey[]): RouteSpecDiff { const live = new Set(liveKeys); const spec = new Set(specKeys); + // #9750: the self-host entrypoint answers a handful of paths in its OWN fetch handler, before the request + // reaches this app, so they are legitimately specced without appearing in createApp()'s route table. This + // is the ONLY exemption, and it cannot be widened by hand: SELFHOST_INFRA_ROUTE_KEYS is asserted against + // the paths src/server.ts really intercepts, read out of its source. + const servedElsewhere = new Set(SELFHOST_INFRA_ROUTE_KEYS); return { missingFromSpec: liveKeys.filter((key) => !spec.has(key)), - missingFromApp: specKeys.filter((key) => !live.has(key)), + missingFromApp: specKeys.filter((key) => !live.has(key) && !servedElsewhere.has(key)), }; } diff --git a/src/openapi/selfhost-infra-route-specs.ts b/src/openapi/selfhost-infra-route-specs.ts new file mode 100644 index 0000000000..aa64781796 --- /dev/null +++ b/src/openapi/selfhost-infra-route-specs.ts @@ -0,0 +1,122 @@ +// Spec entries for the endpoints the SELF-HOST Node entrypoint serves itself (#9750). +// +// `src/server.ts` answers these in its own `fetch` handler, before the request ever reaches the Hono app. +// That makes them invisible to the route↔spec ratchet, which walks `createApp()`'s route table -- so they +// have been served, and undocumented, since self-host shipped. An operator wiring a healthcheck or a +// Prometheus scrape had prose only. +// +// ANTI-ROT: `SELFHOST_INFRA_PATHS` is asserted against the paths `src/server.ts` actually intercepts, read +// out of its source (test/unit/selfhost-infra-route-specs.test.ts). A hand-kept list of paths served +// somewhere else is precisely the shape of rot this repo keeps finding -- it cannot fail on its own, so +// something has to fail for it. +import type { OpenAPIRegistry } from "@asteasolutions/zod-to-openapi"; +import { z } from "zod"; +import { registerRouteSpec, type RouteMethod } from "./define-route"; + +/** + * Every path `src/server.ts` names in a `path === "..."` comparison. + * + * Two of them need no operation here, and for the SAME reason rather than by exception: `/health` and + * `/v1/github/webhook` are both served by the Hono app, which already documents them. The entrypoint only + * gets to them first -- answering `/health` itself, and checking the webhook path to dedup a delivery + * before letting the request through. So the set that needs speccing is this list minus whatever + * `createApp()` serves, which the parity test computes from the live app rather than restating. + */ +export const SELFHOST_INFRA_PATHS = ["/health", "/metrics", "/ready", "/setup", "/setup/callback", "/v1/github/webhook"] as const; + +/** The subset the Hono app does NOT serve, so nothing else can document them. */ +export const SELFHOST_INFRA_SPEC_PATHS = ["/ready", "/metrics", "/setup", "/setup/callback"] as const; + +const ReadinessSchema = z + .looseObject({ ok: z.boolean() }) + .describe("Readiness result. `ok: false` is answered with a 503 so an orchestrator can act on the status line alone."); + +type InfraSpec = { + method: RouteMethod; + path: string; + operationId: string; + summary: string; + description: string; + responses: Record; +}; + +const SPECS: InfraSpec[] = [ + { + method: "get", + path: "/ready", + operationId: "getSelfhostReadiness", + summary: "Readiness probe for this self-hosted instance", + description: "Runs the instance's readiness probes against its own database. Answers 503 when any probe fails, so a container orchestrator can gate traffic on the status code alone.", + responses: { + 200: { description: "Every readiness probe passed", schema: ReadinessSchema }, + 503: { description: "At least one readiness probe failed", schema: ReadinessSchema }, + }, + }, + { + method: "get", + path: "/metrics", + operationId: "getSelfhostMetrics", + summary: "Prometheus metrics for this self-hosted instance", + description: "Prometheus text exposition (`text/plain; version=0.0.4`), not JSON — this is the scrape endpoint, and it is the one operation here whose response is deliberately not a schema.", + responses: { 200: { description: "Metrics in Prometheus text exposition format" } }, + }, + { + method: "get", + path: "/setup", + operationId: "getSelfhostSetupWizard", + summary: "First-run GitHub App setup wizard", + description: + "Available only while no GitHub App is configured — a live install cannot be rebound. Returns the token-entry form until SELFHOST_SETUP_TOKEN is presented, via the `x-setup-token` header or an `authorization` bearer; never a query parameter, which would leak the secret to access logs and browser history. In brokered mode (ORB_ENROLLMENT_SECRET set) it short-circuits to a brokered-mode page instead.", + responses: { + 200: { description: "The token-entry form, or the setup page once authenticated" }, + 400: { description: "SELFHOST_SETUP_TOKEN or PUBLIC_API_ORIGIN is not configured" }, + 403: { description: "A setup token was supplied and did not match" }, + }, + }, + { + method: "post", + path: "/setup", + operationId: "postSelfhostSetupWizard", + summary: "Submit the setup token from the wizard's form", + description: "The browser half of the same wizard: the token travels in the POST body rather than a header, for the same reason it never travels in the URL.", + responses: { + 200: { description: "The setup page, once the submitted token matched" }, + 400: { description: "SELFHOST_SETUP_TOKEN or PUBLIC_API_ORIGIN is not configured" }, + 403: { description: "The submitted token did not match" }, + }, + }, + { + method: "get", + path: "/setup/callback", + operationId: "getSelfhostSetupCallback", + summary: "GitHub App creation callback for the setup wizard", + description: + "Where GitHub returns after the operator creates the App. The one-time code is exchanged for the App's private key and webhook secret, so the redirect origin comes from PUBLIC_API_ORIGIN rather than the request's Host header — a spoofed Host would otherwise send the callback, and the credentials, somewhere else.", + responses: { + 200: { description: "The App was created and its credentials were persisted" }, + 400: { description: "No `code` parameter, or the wizard is not configured" }, + 403: { description: "The `state` parameter did not match the one issued" }, + 500: { description: "GitHub rejected the code exchange" }, + }, + }, +]; + +export function registerSelfhostInfraRouteSpecs(registry: OpenAPIRegistry): void { + for (const spec of SPECS) { + registerRouteSpec(registry, { + method: spec.method, + path: spec.path, + operationId: spec.operationId, + tags: ["Self-host infra"], + summary: spec.summary, + description: spec.description, + // No LoopOver credential reaches these: they are answered before the app's auth ever runs. The setup + // wizard has its own one-off token, which is not an API credential and has no scheme here. + auth: "public", + responses: spec.responses, + }); + } +} + +/** The (method, path) pairs this module contributes, for the ratchet's "no operation without a route" side. */ +export const SELFHOST_INFRA_ROUTE_KEYS: readonly string[] = SPECS.map((spec) => `${spec.method.toUpperCase()} ${spec.path}`); diff --git a/src/openapi/spec.ts b/src/openapi/spec.ts index 2641c0d111..8e36ce1442 100644 --- a/src/openapi/spec.ts +++ b/src/openapi/spec.ts @@ -2,6 +2,7 @@ import { OpenApiGeneratorV3, OpenAPIRegistry } from "@asteasolutions/zod-to-open import { requiresApiToken } from "../auth/route-auth"; import { registerDiscoveryRouteSpecs } from "./discovery-route-specs"; import { registerOrbAndControlRouteSpecs } from "./orb-and-control-route-specs"; +import { registerSelfhostInfraRouteSpecs } from "./selfhost-infra-route-specs"; import { registerInternalAndPublicRouteSpecs } from "./internal-and-public-route-specs"; import { z } from "zod"; import { @@ -180,6 +181,9 @@ export const SPEC_REGISTRARS: ReadonlyArray<(registry: OpenAPIRegistry) => void> // #9526: served by this app and computed at request time from the contract registry. registerDiscoveryRouteSpecs, registerInternalAndPublicRouteSpecs, + // #9750: served by src/server.ts ahead of this app, so the ratchet cannot see them; specced here so a + // self-host operator can read their own instance's endpoints out of the published document. + registerSelfhostInfraRouteSpecs, ]; export function buildOpenApiSpec() { diff --git a/test/unit/contract-api-requests.test.ts b/test/unit/contract-api-requests.test.ts new file mode 100644 index 0000000000..4359206826 --- /dev/null +++ b/test/unit/contract-api-requests.test.ts @@ -0,0 +1,206 @@ +import { readFileSync } from "node:fs"; +import { describe, expect, it } from "vitest"; +import { + QUEUE_INTELLIGENCE_AUTHOR_ROLES, + QUEUE_INTELLIGENCE_CHECKS_STATUSES, + QueueIntelligencePullRequestSchema, + QueueIntelligenceRepoContextSchema, + buildRepositorySettingsSchema, + checkBeforeStartSchema, + intakeIdeaSchema, + isJsonByteLengthWithinLimit, + killSwitchUpdateSchema, + markNotificationsReadBodySchema, + preflightSchema, + settingsPreviewSchema, + skippedPrAuditQuerySchema, +} from "@loopover/contract/api-requests"; +import { + MAX_FOCUS_MANIFEST_BYTES, + MAX_LOCAL_SCORER_WARNING_CHARS, + MAX_LOCAL_SCORER_WARNING_COUNT, + MAX_NOTIFICATION_DELIVERY_ID_LENGTH, + MAX_NOTIFICATION_MARK_READ_IDS, + PREFLIGHT_LIMITS, + PUBLIC_SURFACE_SKIP_REASONS, + SCENARIO_MAX_BRANCH_REF_CHARS, + SCENARIO_MAX_LINKED_ISSUE_NUMBERS, + SCENARIO_MAX_REPO_FULL_NAME_CHARS, +} from "@loopover/contract"; +import { MAX_NOTIFICATION_DELIVERY_ID_LENGTH as SRC_DELIVERY_ID, MAX_NOTIFICATION_MARK_READ_IDS as SRC_MARK_READ } from "../../src/db/repositories"; +import { MAX_FOCUS_MANIFEST_BYTES as SRC_MANIFEST_BYTES } from "../../src/signals/focus-manifest"; +import { MAX_LOCAL_SCORER_WARNING_CHARS as SRC_WARNING_CHARS, MAX_LOCAL_SCORER_WARNING_COUNT as SRC_WARNING_COUNT } from "../../src/signals/local-scorer-diagnostics"; +import { + SCENARIO_MAX_BRANCH_REF_CHARS as SRC_BRANCH_REF, + SCENARIO_MAX_LINKED_ISSUE_NUMBERS as SRC_LINKED_ISSUES, + SCENARIO_MAX_REPO_FULL_NAME_CHARS as SRC_REPO_NAME, +} from "../../src/scenarios/input-model"; +import { PUBLIC_SURFACE_SKIP_REASONS as SRC_SKIP_REASONS } from "../../src/signals/settings-preview"; +import { PREFLIGHT_LIMITS as SRC_PREFLIGHT_LIMITS } from "../../src/signals/preflight-limits"; +import { DEFAULT_COMMAND_AUTHORIZATION_POLICY } from "../../src/settings/command-authorization"; +import type { AuthorRole, ChecksStatus } from "../../src/queue-intelligence"; + +// #9750: the request schemas moved out of src/api/routes.ts into @loopover/contract, so a tool wrapping one +// of these routes references the same object instead of a copy of the shape. +// +// The bounds could not travel with them -- the contract is a zod-only leaf that cannot import the Worker or +// the engine -- so limits.ts restates them. A restatement is only safe while something fails when the two +// disagree, and a bound that drifts fails in the worst direction: a schema that rejects input the server +// would have accepted, or accepts input the server then truncates. That is what the first block pins. + +describe("every restated bound still equals its original (#9750)", () => { + it.each([ + ["MAX_NOTIFICATION_MARK_READ_IDS", MAX_NOTIFICATION_MARK_READ_IDS, SRC_MARK_READ], + ["MAX_NOTIFICATION_DELIVERY_ID_LENGTH", MAX_NOTIFICATION_DELIVERY_ID_LENGTH, SRC_DELIVERY_ID], + ["MAX_FOCUS_MANIFEST_BYTES", MAX_FOCUS_MANIFEST_BYTES, SRC_MANIFEST_BYTES], + ["MAX_LOCAL_SCORER_WARNING_COUNT", MAX_LOCAL_SCORER_WARNING_COUNT, SRC_WARNING_COUNT], + ["MAX_LOCAL_SCORER_WARNING_CHARS", MAX_LOCAL_SCORER_WARNING_CHARS, SRC_WARNING_CHARS], + ["SCENARIO_MAX_REPO_FULL_NAME_CHARS", SCENARIO_MAX_REPO_FULL_NAME_CHARS, SRC_REPO_NAME], + ["SCENARIO_MAX_BRANCH_REF_CHARS", SCENARIO_MAX_BRANCH_REF_CHARS, SRC_BRANCH_REF], + ["SCENARIO_MAX_LINKED_ISSUE_NUMBERS", SCENARIO_MAX_LINKED_ISSUE_NUMBERS, SRC_LINKED_ISSUES], + ])("%s", (_name, contractValue, sourceValue) => { + expect(contractValue).toBe(sourceValue); + }); + + it("PUBLIC_SURFACE_SKIP_REASONS matches src/signals/settings-preview.ts exactly, order included", () => { + expect([...PUBLIC_SURFACE_SKIP_REASONS]).toEqual([...SRC_SKIP_REASONS]); + }); + + it("PREFLIGHT_LIMITS still matches the engine's", () => { + expect(PREFLIGHT_LIMITS).toEqual(SRC_PREFLIGHT_LIMITS); + }); + + it("the queue-intelligence enums cover exactly the union the analyzer declares", () => { + // Typed rather than string-compared: a role added to the analyzer's union without being added here + // stops compiling, and one added here that the analyzer does not know does too. + const roles: readonly AuthorRole[] = QUEUE_INTELLIGENCE_AUTHOR_ROLES; + const statuses: readonly ChecksStatus[] = QUEUE_INTELLIGENCE_CHECKS_STATUSES; + expect(roles).toHaveLength(3); + expect(statuses).toHaveLength(3); + }); +}); + +describe("routes.ts no longer declares a request schema of its own (#9750)", () => { + it("has no z.object literal left", () => { + // The deliverable, asserted rather than trusted: a new inline schema here is a new copy of a shape a + // tool may already model, which is the whole class of drift this move removes. + expect(readFileSync("src/api/routes.ts", "utf8")).not.toContain("z.object("); + }); +}); + +describe("the moved schemas accept and reject what they always did (#9750)", () => { + it("bounds the notification id list at the storage limits, both sides", () => { + expect(markNotificationsReadBodySchema.safeParse({}).success, "absent ids means mark all delivered").toBe(true); + expect(markNotificationsReadBodySchema.safeParse({ ids: ["a"] }).success).toBe(true); + expect(markNotificationsReadBodySchema.safeParse({ ids: Array.from({ length: MAX_NOTIFICATION_MARK_READ_IDS + 1 }, () => "a") }).success).toBe(false); + expect(markNotificationsReadBodySchema.safeParse({ ids: ["x".repeat(MAX_NOTIFICATION_DELIVERY_ID_LENGTH + 1)] }).success).toBe(false); + }); + + it("holds preflight to the shared PREFLIGHT_LIMITS", () => { + const base = { repoFullName: "acme/widgets", title: "t", body: "b" }; + expect(preflightSchema.safeParse(base).success).toBe(true); + expect(preflightSchema.safeParse({ ...base, repoFullName: "a".repeat(PREFLIGHT_LIMITS.repoFullNameChars + 1) }).success).toBe(false); + expect(preflightSchema.safeParse({ ...base, labels: Array.from({ length: PREFLIGHT_LIMITS.labels + 1 }, () => "l") }).success).toBe(false); + }); + + it("keeps intake-idea deliberately LOOSE so the engine owns the real validation", () => { + // Every field optional on purpose: validateIdeaSubmission returns the actionable error list, so an + // empty submission must REACH the handler rather than be rejected by the schema. + expect(intakeIdeaSchema.safeParse({}).success).toBe(true); + expect(intakeIdeaSchema.safeParse({ constraints: Array.from({ length: 51 }, () => "c") }).success).toBe(false); + }); + + it("leaves check-before-start entirely optional — the repository is the path param, not the body", () => { + expect(checkBeforeStartSchema.safeParse({}).success).toBe(true); + expect(checkBeforeStartSchema.safeParse({ issueNumber: 12, title: "t" }).success).toBe(true); + // Still bounded on the fields it does accept. + expect(checkBeforeStartSchema.safeParse({ issueNumber: 0 }).success, "issue numbers start at 1").toBe(false); + expect(checkBeforeStartSchema.safeParse({ title: "t".repeat(PREFLIGHT_LIMITS.titleChars + 1) }).success).toBe(false); + }); + + it("validates the queue-intelligence payload at its own bounds", () => { + const pr = { + number: 1, + author: "acme", + authorRole: "contributor", + isConfirmedMiner: false, + linkedIssue: null, + checksStatus: "passing", + isStale: false, + additions: 1, + deletions: 0, + title: "t", + body: "b", + duplicateCandidates: [], + createdAt: "2026-01-01T00:00:00.000Z", + lastUpdatedAt: "2026-01-01T00:00:00.000Z", + }; + expect(QueueIntelligencePullRequestSchema.safeParse(pr).success).toBe(true); + expect(QueueIntelligencePullRequestSchema.safeParse({ ...pr, authorRole: "overlord" }).success).toBe(false); + expect(QueueIntelligencePullRequestSchema.safeParse({ ...pr, number: 0 }).success, "PR numbers start at 1").toBe(false); + expect(QueueIntelligencePullRequestSchema.safeParse({ ...pr, createdAt: "yesterday" }).success).toBe(false); + + expect(QueueIntelligenceRepoContextSchema.safeParse({ totalOpenPRs: 3, avgReviewTimeDays: 1.5, maintainerWorkload: 0.5 }).success).toBe(true); + expect(QueueIntelligenceRepoContextSchema.safeParse({ totalOpenPRs: 3, avgReviewTimeDays: 1.5, maintainerWorkload: 1.5 }).success).toBe(false); + }); + + it("is STRICT about the skipped-PR audit query, and about the reasons it will answer for", () => { + expect(skippedPrAuditQuerySchema.safeParse({ limit: "25" }).success, "query values arrive as strings").toBe(true); + // `.strict()`: an unknown filter would otherwise be silently ignored and quietly return the unfiltered + // page, which reads to a caller as "there is nothing matching". + expect(skippedPrAuditQuerySchema.safeParse({ notAFilter: "1" }).success).toBe(false); + expect(skippedPrAuditQuerySchema.safeParse({ reason: PUBLIC_SURFACE_SKIP_REASONS[0] }).success).toBe(true); + expect(skippedPrAuditQuerySchema.safeParse({ reason: "because_i_said_so" }).success).toBe(false); + }); + + it("accepts a settings preview with no sample, and bounds the sample it is given", () => { + expect(settingsPreviewSchema.safeParse({}).success, "no sample means preview the defaults").toBe(true); + expect(settingsPreviewSchema.safeParse({ sample: { authorType: "User" } }).success).toBe(true); + expect(settingsPreviewSchema.safeParse({ sample: { authorType: "Alien" } }).success).toBe(false); + expect(settingsPreviewSchema.safeParse({ sample: { body: null } }).success, "a PR with an empty body is real").toBe(true); + }); + + it("still requires the kill switch to say what it is doing", () => { + expect(killSwitchUpdateSchema.safeParse({ frozen: true }).success).toBe(true); + expect(killSwitchUpdateSchema.safeParse({}).success).toBe(false); + }); +}); + +describe("the settings write schema keeps the engine's default (#9750)", () => { + const schema = buildRepositorySettingsSchema(DEFAULT_COMMAND_AUTHORIZATION_POLICY); + + it("applies the ENGINE's policy when the block is omitted, not a restated copy", () => { + // The reason this schema is a factory: its default is a twenty-key policy the engine owns, and + // restating it in a leaf package is exactly the duplication this move removes. + const parsed = schema.parse({}); + expect(parsed.commandAuthorization).toEqual(DEFAULT_COMMAND_AUTHORIZATION_POLICY); + }); + + it("keeps every other default the route relied on", () => { + expect(schema.parse({})).toMatchObject({ + gatePack: "gittensor", + aiReviewLowConfidenceDisposition: "hold_for_review", + closeOwnerAuthors: false, + autoLabelEnabled: true, + requireLinkedIssue: false, + }); + }); + + it("rejects a role outside the four the policy recognises", () => { + expect(schema.safeParse({ commandAuthorization: { default: ["maintainer"] } }).success).toBe(true); + expect(schema.safeParse({ commandAuthorization: { default: ["overlord"] } }).success).toBe(false); + }); +}); + +describe("the JSON byte-budget helper (#9750)", () => { + it("measures BYTES, not characters — a multi-byte manifest is bigger than its length", () => { + expect(isJsonByteLengthWithinLimit({ a: "€€€" }, 100)).toBe(true); + expect(isJsonByteLengthWithinLimit({ a: "€" }, 5)).toBe(false); + }); + + it("treats a value that cannot be serialized at all as over budget, not under", () => { + const cyclic: Record = {}; + cyclic.self = cyclic; + expect(isJsonByteLengthWithinLimit(cyclic, 1_000_000)).toBe(false); + }); +}); diff --git a/test/unit/control-plane-contract.test.ts b/test/unit/control-plane-contract.test.ts new file mode 100644 index 0000000000..eda31de7b8 --- /dev/null +++ b/test/unit/control-plane-contract.test.ts @@ -0,0 +1,150 @@ +import { readFileSync } from "node:fs"; +import { describe, expect, it } from "vitest"; +import { + AmsCycleScheduleRequestSchema, + CONTROL_PLANE_ROUTES, + CreateTenantRequestSchema, + HOSTED_CYCLE_COMMANDS, + OrbInstallationIdSchema, + TENANT_LIFECYCLE_STATES, + TenantListResponseSchema, + TenantRecordSchema, +} from "@loopover/contract/control-plane"; +import { HOSTED_CYCLE_COMMANDS as MINER_HOSTED_CYCLE_COMMANDS } from "../../packages/loopover-miner/lib/hosted-entry"; +import { buildControlPlaneSpec } from "../../scripts/gen-control-plane-openapi"; +import { render } from "../../scripts/gen-control-plane-contract"; + +// #9750: the control plane was the one surface here with NO machine-readable contract -- no zod, no spec, +// its only description prose plus the hardcoded fetches in the miner's admin client. These pin the three +// properties that make the new contract worth having: the Worker validates against it, the document is +// generated from it, and the mirror the Worker imports cannot drift from it. + +describe("the route table describes the whole surface (#9750)", () => { + it("covers every route control-plane/src/http-app.ts registers", () => { + // Read out of the app rather than restated: a route added there without an entry here would otherwise + // be served and undocumented, which is the state this issue exists to end. + const source = readFileSync("control-plane/src/http-app.ts", "utf8"); + const registered = [...source.matchAll(/\bapp\.(get|post|patch|delete)\("([^"]+)"/g)].map((match) => `${match[1]!} ${match[2]!}`).sort(); + const tabled = CONTROL_PLANE_ROUTES.map((route) => `${route.method} ${route.path}`).sort(); + expect(tabled).toEqual(registered); + }); + + it("gives every route a stable operationId and at least one response", () => { + const ids = CONTROL_PLANE_ROUTES.map((route) => route.operationId); + expect(new Set(ids).size).toBe(ids.length); + for (const route of CONTROL_PLANE_ROUTES) expect(Object.keys(route.responses).length).toBeGreaterThan(0); + }); + + it("puts the admin bearer on the tenant surface and NOT on the webhook", () => { + // The webhook is deliberately outside the admin middleware: GitHub authenticates it with an HMAC over + // the raw body, so demanding the admin token there would describe a gate that does not exist. + const byPath = new Map(CONTROL_PLANE_ROUTES.map((route) => [route.path, route.auth])); + expect(byPath.get("/v1/tenants")).toBe("admin-bearer"); + expect(byPath.get("/v1/tenants/:name")).toBe("admin-bearer"); + expect(byPath.get("/v1/orb/webhook")).toBe("webhook-signature"); + expect(byPath.get("/health")).toBe("public"); + }); + + it("declares the 503 that every admin route answers when no admin token is configured", () => { + // Fails closed, and says so: an unconfigured deployment is a 503, not a 401 the caller would retry. + for (const route of CONTROL_PLANE_ROUTES.filter((entry) => entry.auth === "admin-bearer")) { + expect(Object.keys(route.responses), `${route.operationId}`).toEqual(expect.arrayContaining(["401", "503"])); + } + }); +}); + +describe("the generated document (#9750)", () => { + const spec = buildControlPlaneSpec() as { + paths: Record }>>; + components?: { securitySchemes?: Record }; + }; + + it("matches the committed control-plane/openapi.json", () => { + expect(`${JSON.stringify(spec, null, 2)}\n`).toBe(readFileSync("control-plane/openapi.json", "utf8")); + }); + + it("templates the path parameter rather than publishing a literal :name", () => { + expect(spec.paths["/v1/tenants/{name}"]).toBeDefined(); + expect(spec.paths["/v1/tenants/:name"]).toBeUndefined(); + expect(spec.paths["/v1/tenants/{name}"]!.delete!.responses).toBeDefined(); + }); + + it("never leaves a 401-declaring operation without a scheme", () => { + // The #9707 lesson, applied to a surface being specced for the first time. + for (const [path, item] of Object.entries(spec.paths)) { + for (const [method, operation] of Object.entries(item)) { + if (operation?.responses?.["401"]) expect(operation.security, `${method.toUpperCase()} ${path}`).not.toBeUndefined(); + } + } + }); + + it("names the header GitHub actually signs with", () => { + expect(spec.components?.securitySchemes?.GitHubWebhookSignature?.name).toBe("x-hub-signature-256"); + expect(spec.components?.securitySchemes?.ControlPlaneAdminBearer?.scheme).toBe("bearer"); + }); +}); + +describe("the mirror the Worker imports cannot drift (#9750)", () => { + it("is byte-identical to the contract module plus its generated header", () => { + // control-plane/ is not a workspace member and installs separately, so it reads a generated copy rather + // than importing the package. The copy is only safe because this fails the moment they differ. + const source = readFileSync("packages/loopover-contract/src/control-plane.ts", "utf8"); + expect(readFileSync("control-plane/src/generated/control-plane-contract.ts", "utf8")).toBe(render(source)); + }); + + it("marks the copy as generated so nobody edits it by hand", () => { + const mirror = readFileSync("control-plane/src/generated/control-plane-contract.ts", "utf8"); + expect(mirror.startsWith("// GENERATED by")).toBe(true); + expect(mirror).toContain("npm run control-plane:contract"); + }); +}); + +describe("the schemas accept what the routes accept and reject what they reject (#9750)", () => { + it("requires a non-blank name and product on create", () => { + expect(CreateTenantRequestSchema.safeParse({ name: "acme", product: "ams" }).success).toBe(true); + expect(CreateTenantRequestSchema.safeParse({ name: " ", product: "ams" }).success).toBe(false); + expect(CreateTenantRequestSchema.safeParse({ name: "acme" }).success).toBe(false); + }); + + it("takes an installation ID only as a positive integer, matching GitHub's own ID space", () => { + expect(OrbInstallationIdSchema.safeParse(12345).success).toBe(true); + for (const bad of [0, -1, 1.5, "12345", null]) expect(OrbInstallationIdSchema.safeParse(bad).success, String(bad)).toBe(false); + }); + + it("accepts a schedule with args omitted, and rejects a command outside the hosted set", () => { + expect(AmsCycleScheduleRequestSchema.safeParse({ command: "attempt", intervalMs: 1000 }).success).toBe(true); + expect(AmsCycleScheduleRequestSchema.safeParse({ command: "rm-rf", intervalMs: 1000 }).success).toBe(false); + expect(AmsCycleScheduleRequestSchema.safeParse({ command: "attempt", intervalMs: 0 }).success).toBe(false); + }); + + it("keeps the hosted command list identical to the miner's own dispatcher", () => { + // Both used to be plain literals in two packages, with comments pointing at each other and nothing + // checking. Compared against the DISPATCHER itself -- the object whose keys decide what a hosted + // container will actually run -- rather than against a copy of the list. + expect([...HOSTED_CYCLE_COMMANDS].sort()).toEqual(Object.keys(MINER_HOSTED_CYCLE_COMMANDS).sort()); + }); + + it("parses a tenant record as the routes project it, and rejects one missing its identity", () => { + const record = { tenant: { name: "acme" }, product: "ams", state: "active" as const }; + expect(TenantRecordSchema.safeParse(record).success).toBe(true); + expect(TenantRecordSchema.safeParse({ product: "ams", state: "active" }).success).toBe(false); + expect(TenantRecordSchema.safeParse({ ...record, state: "exploded" }).success).toBe(false); + }); + + it("stays open to a field it has never seen — an output schema is a floor, not a fence", () => { + const parsed = TenantRecordSchema.safeParse({ tenant: { name: "acme" }, product: "ams", state: "active", futureField: 1 }); + expect(parsed.success && (parsed.data as Record).futureField).toBe(1); + }); + + it("requires the timestamps on a list entry that the single-record projection omits", () => { + const entry = { tenant: { name: "acme" }, product: "ams", state: "active" as const }; + expect(TenantListResponseSchema.safeParse({ tenants: [entry] }).success).toBe(false); + expect(TenantListResponseSchema.safeParse({ tenants: [{ ...entry, createdAt: "x", updatedAt: "y" }] }).success).toBe(true); + }); + + it("covers every lifecycle state the control plane can report", () => { + for (const state of TENANT_LIFECYCLE_STATES) { + expect(TenantRecordSchema.safeParse({ tenant: { name: "a" }, product: "ams", state }).success, state).toBe(true); + } + }); +}); diff --git a/test/unit/docs-examples-schema.test.ts b/test/unit/docs-examples-schema.test.ts index 41584fd318..dce01c39b2 100644 --- a/test/unit/docs-examples-schema.test.ts +++ b/test/unit/docs-examples-schema.test.ts @@ -1,6 +1,6 @@ import { readFileSync } from "node:fs"; import { describe, expect, it } from "vitest"; -import { localBranchAnalysisSchema } from "../../src/api/routes"; +import { localBranchAnalysisSchema } from "@loopover/contract/api-requests"; // Drift guard (#3045): docs.branch-analysis.tsx and docs.scoreability.tsx each embed a hand-typed // JSON example that is supposed to mirror a real backing schema/type. Both pages previously drifted diff --git a/test/unit/miner-tenant.test.ts b/test/unit/miner-tenant.test.ts index 942f3fd5dd..15d4e0dcfe 100644 --- a/test/unit/miner-tenant.test.ts +++ b/test/unit/miner-tenant.test.ts @@ -1,4 +1,5 @@ import { afterEach, describe, expect, it, vi } from "vitest"; +import type { TenantRecord } from "@loopover/contract/control-plane"; import { CONTROL_PLANE_ADMIN_TOKEN_FLAG, @@ -92,17 +93,17 @@ describe("createTenant (#7275)", () => { expect(init.method).toBe("POST"); expect((init.headers as Record).authorization).toBe("Bearer admin-secret"); expect(JSON.parse(String(init.body))).toEqual({ name: "acme", product: "ams" }); - return Response.json({ name: "acme", product: "ams", state: "provisioning" }); + return Response.json({ tenant: { name: "acme" }, product: "ams", state: "provisioning" }); }); const record = await createTenant("acme", { env: ENABLED_ENV, fetchImpl }); - expect(record).toEqual({ name: "acme", product: "ams", state: "provisioning" }); + expect(record).toEqual({ tenant: { name: "acme" }, product: "ams", state: "provisioning" }); }); it("passes an explicit product through, and trims a URL's trailing slashes", async () => { const fetchImpl = vi.fn(async (url: string, init: RequestInit) => { expect(url).toBe("https://control.example.internal/v1/tenants"); expect(JSON.parse(String(init.body))).toEqual({ name: "acme", product: "widgets" }); - return Response.json({ name: "acme", product: "widgets", state: "provisioning" }); + return Response.json({ tenant: { name: "acme" }, product: "widgets", state: "provisioning" }); }); await createTenant("acme", { env: { ...ENABLED_ENV, [CONTROL_PLANE_URL_FLAG]: "https://control.example.internal///" }, @@ -115,7 +116,7 @@ describe("createTenant (#7275)", () => { it("falls back to the default product when product is blank/whitespace", async () => { const fetchImpl = vi.fn(async (_url: string, init: RequestInit) => { expect(JSON.parse(String(init.body))).toEqual({ name: "acme", product: "ams" }); - return Response.json({ name: "acme", product: "ams", state: "provisioning" }); + return Response.json({ tenant: { name: "acme" }, product: "ams", state: "provisioning" }); }); await createTenant("acme", { env: ENABLED_ENV, fetchImpl, product: " " }); expect(fetchImpl).toHaveBeenCalledTimes(1); @@ -159,25 +160,41 @@ describe("listTenants + destroyTenant (#7275)", () => { expect(url).toBe("https://control.example.internal/v1/tenants"); expect(init.method).toBe("GET"); expect(init.body).toBeUndefined(); - return Response.json({ tenants: [{ name: "acme", product: "ams", state: "active" }] }); + return Response.json({ tenants: [{ tenant: { name: "acme" }, product: "ams", state: "active", createdAt: "2026-01-01T00:00:00.000Z", updatedAt: "2026-01-01T00:00:00.000Z" }] }); }); const records = await listTenants({ env: ENABLED_ENV, fetchImpl }); - expect(records).toEqual([{ name: "acme", product: "ams", state: "active" }]); + expect(records).toEqual([{ tenant: { name: "acme" }, product: "ams", state: "active", createdAt: "2026-01-01T00:00:00.000Z", updatedAt: "2026-01-01T00:00:00.000Z" }]); }); - it("degrades a missing/non-array `tenants` field to an empty list", async () => { + it("FAILS LOUD on a response with no `tenants` field rather than reporting an empty fleet (#9750)", async () => { + // Was: degrade to []. An admin listing that answers "you have no tenants" because the control plane + // renamed a field is the most misleading possible answer -- and this client's whole documented posture + // is that every call fails loud, precisely because these are deliberate admin actions. const fetchImpl = async () => Response.json({ note: "no tenants field" }); - expect(await listTenants({ env: ENABLED_ENV, fetchImpl })).toEqual([]); + await expect(listTenants({ env: ENABLED_ENV, fetchImpl })).rejects.toThrow(/does not match the published contract/); + }); + + it("rejects a tenant record missing a required field, naming the route (#9750)", async () => { + const fetchImpl = async () => Response.json({ tenants: [{ product: "ams", state: "active", createdAt: "x", updatedAt: "y" }] }); + await expect(listTenants({ env: ENABLED_ENV, fetchImpl })).rejects.toThrow(/GET \/v1\/tenants/); + }); + + it("ACCEPTS a record carrying a field this client has never heard of", async () => { + // The schema is a loose object on purpose: a control plane that starts returning one more field must not + // break a client validating against today's shape. + const entry = { tenant: { name: "acme" }, product: "ams", state: "active", createdAt: "x", updatedAt: "y", somethingNew: 42 }; + const fetchImpl = async () => Response.json({ tenants: [entry] }); + expect(await listTenants({ env: ENABLED_ENV, fetchImpl })).toEqual([entry]); }); it("DELETEs a URL-encoded name and returns the final record", async () => { const fetchImpl = vi.fn(async (url: string, init: RequestInit) => { expect(url).toBe("https://control.example.internal/v1/tenants/acme%2Fedge"); expect(init.method).toBe("DELETE"); - return Response.json({ name: "acme/edge", state: "torn down" }); + return Response.json({ tenant: { name: "acme/edge" }, product: "ams", state: "torn down" }); }); const record = await destroyTenant("acme/edge", { env: ENABLED_ENV, fetchImpl }); - expect(record).toEqual({ name: "acme/edge", state: "torn down" }); + expect(record).toEqual({ tenant: { name: "acme/edge" }, product: "ams", state: "torn down" }); }); }); @@ -235,7 +252,7 @@ describe("tenant argv parsing (#7275)", () => { describe("runTenantCreate (#7275)", () => { it("prints json and forwards name/product/env/fetchImpl to the injected client", async () => { captureConsole(); - const createSpy = vi.fn(async () => ({ name: "acme", product: "widgets", state: "provisioning" })); + const createSpy = vi.fn(async () => ({ tenant: { name: "acme" }, product: "widgets", state: "provisioning" }) as TenantRecord); const fetchImpl = vi.fn(); const code = await runTenantCreate(["acme", "--product", "widgets", "--json"], { createTenant: createSpy, @@ -244,12 +261,12 @@ describe("runTenantCreate (#7275)", () => { }); expect(code).toBe(0); expect(createSpy).toHaveBeenCalledWith("acme", { env: ENABLED_ENV, fetchImpl, product: "widgets" }); - expect(JSON.parse(logs.join(""))).toEqual({ name: "acme", product: "widgets", state: "provisioning" }); + expect(JSON.parse(logs.join(""))).toEqual({ tenant: { name: "acme" }, product: "widgets", state: "provisioning" }); }); it("prints a human summary in text mode, omitting product when not supplied", async () => { captureConsole(); - const createSpy = vi.fn(async (_name: string, _options?: Record) => ({ name: "acme", product: "ams", state: "provisioning" })); + const createSpy = vi.fn(async (_name: string, _options?: Record) => ({ tenant: { name: "acme" }, product: "ams", state: "provisioning" }) as TenantRecord); const code = await runTenantCreate(["acme"], { createTenant: createSpy }); expect(code).toBe(0); expect(createSpy.mock.calls[0]![1]).not.toHaveProperty("product"); @@ -285,18 +302,18 @@ describe("runTenantCreate (#7275)", () => { describe("runTenantList (#7275)", () => { it("prints json of the records", async () => { captureConsole(); - const listSpy = vi.fn(async () => [{ name: "acme", product: "ams", state: "active" }]); + const listSpy = vi.fn(async () => [{ tenant: { name: "acme" }, product: "ams", state: "active" }] as TenantRecord[]); const code = await runTenantList(["--json"], { listTenants: listSpy }); expect(code).toBe(0); - expect(JSON.parse(logs.join(""))).toEqual([{ name: "acme", product: "ams", state: "active" }]); + expect(JSON.parse(logs.join(""))).toEqual([{ tenant: { name: "acme" }, product: "ams", state: "active" }]); }); it("prints one line per tenant in text mode", async () => { captureConsole(); const listSpy = vi.fn(async () => [ - { name: "acme", product: "ams", state: "active" }, - { name: "beta", product: "widgets", state: "suspended" }, - ]); + { tenant: { name: "acme" }, product: "ams", state: "active" }, + { tenant: { name: "beta" }, product: "widgets", state: "suspended" }, + ] as TenantRecord[]); const code = await runTenantList([], { listTenants: listSpy }); expect(code).toBe(0); expect(logs.join("")).toBe("acme product=ams state=active\nbeta product=widgets state=suspended"); @@ -309,7 +326,9 @@ describe("runTenantList (#7275)", () => { expect(logs.join("")).toBe("no tenants"); logs = []; - await runTenantList([], { listTenants: vi.fn(async () => [{} as Record]) }); + // A record missing every field still renders rather than throwing mid-listing -- the schema is a loose + // object and an older control plane is a real possibility. + await runTenantList([], { listTenants: vi.fn(async () => [{} as unknown as TenantRecord]) }); expect(logs.join("")).toBe("(unknown) product=(unknown) state=(unknown)"); }); @@ -337,17 +356,17 @@ describe("runTenantList (#7275)", () => { describe("runTenantDestroy (#7275)", () => { it("prints json and forwards the name to the injected client", async () => { captureConsole(); - const destroySpy = vi.fn(async () => ({ name: "acme", state: "torn down" })); + const destroySpy = vi.fn(async () => ({ tenant: { name: "acme" }, product: "ams", state: "torn down" }) as TenantRecord); const code = await runTenantDestroy(["acme", "--json"], { destroyTenant: destroySpy }); expect(code).toBe(0); expect(destroySpy).toHaveBeenCalledWith("acme", { env: undefined, fetchImpl: undefined }); - expect(JSON.parse(logs.join(""))).toEqual({ name: "acme", state: "torn down" }); + expect(JSON.parse(logs.join(""))).toEqual({ tenant: { name: "acme" }, product: "ams", state: "torn down" }); }); it("prints a human summary in text mode", async () => { captureConsole(); const code = await runTenantDestroy(["acme"], { - destroyTenant: vi.fn(async () => ({ name: "acme", product: "ams", state: "torn down" })), + destroyTenant: vi.fn(async () => ({ tenant: { name: "acme" }, product: "ams", state: "torn down" }) as TenantRecord), }); expect(code).toBe(0); expect(logs.join("")).toBe("destroyed acme product=ams state=torn down"); @@ -377,9 +396,9 @@ describe("runTenantDestroy (#7275)", () => { describe("runTenantCli dispatch (#7275)", () => { it("routes each subcommand to its handler", async () => { captureConsole(); - expect(await runTenantCli("create", ["acme"], { createTenant: vi.fn(async () => ({ name: "acme", product: "ams", state: "provisioning" })) })).toBe(0); + expect(await runTenantCli("create", ["acme"], { createTenant: vi.fn(async () => ({ tenant: { name: "acme" }, product: "ams", state: "provisioning" }) as TenantRecord) })).toBe(0); expect(await runTenantCli("list", [], { listTenants: vi.fn(async () => []) })).toBe(0); - expect(await runTenantCli("destroy", ["acme"], { destroyTenant: vi.fn(async () => ({ name: "acme", product: "ams", state: "torn down" })) })).toBe(0); + expect(await runTenantCli("destroy", ["acme"], { destroyTenant: vi.fn(async () => ({ tenant: { name: "acme" }, product: "ams", state: "torn down" }) as TenantRecord) })).toBe(0); }); it("reports usage for an unknown or missing subcommand", async () => { diff --git a/test/unit/selfhost-infra-route-specs.test.ts b/test/unit/selfhost-infra-route-specs.test.ts new file mode 100644 index 0000000000..c64506b6e6 --- /dev/null +++ b/test/unit/selfhost-infra-route-specs.test.ts @@ -0,0 +1,119 @@ +import { readFileSync } from "node:fs"; +import { describe, expect, it } from "vitest"; +import { buildOpenApiSpec } from "../../src/openapi/spec"; +import { createApp } from "../../src/api/routes"; +import { listLiveRouteKeys } from "../../src/openapi/route-inventory"; +import { SELFHOST_INFRA_PATHS, SELFHOST_INFRA_ROUTE_KEYS, SELFHOST_INFRA_SPEC_PATHS } from "../../src/openapi/selfhost-infra-route-specs"; + +// #9750: the self-host entrypoint answers these paths in its own fetch handler, ahead of the Hono app, so +// the route↔spec ratchet cannot see them -- and they were therefore served and undocumented since self-host +// shipped. +// +// Which means SELFHOST_INFRA_PATHS is a hand-written list of paths served in ANOTHER file, and a +// hand-written list that nothing checks is the exact failure mode this repo keeps finding (metagraphed's +// version-sync workflow watched a renamed path and passed for months while doing nothing). So the list is +// read back out of src/server.ts. It is also the only exemption the ratchet grants, which makes this test +// the thing standing between that exemption and becoming a place to hide an unspecced route. + +const SERVER_SOURCE = readFileSync("src/server.ts", "utf8"); + +/** + * Every path literal the entrypoint compares the request path against. + * + * Anchored on `path === "..."` specifically -- the shape every interception in that handler is written in -- + * rather than on any string that looks path-like, so a URL mentioned in a comment or built for an outbound + * request is never mistaken for a route this process serves. + */ +export function interceptedPaths(source: string): string[] { + return [...new Set([...source.matchAll(/\bpath === "([^"]+)"/g)].map((match) => match[1]!))].sort(); +} + +describe("the specced self-host infra set matches what src/server.ts intercepts (#9750)", () => { + it("finds the interceptions in the real entrypoint", () => { + // Guards the extractor itself: if the handler is ever rewritten into a form this regex cannot see, the + // parity assertion below would pass by comparing two empty-ish sets. + expect(interceptedPaths(SERVER_SOURCE).length).toBeGreaterThanOrEqual(SELFHOST_INFRA_PATHS.length); + }); + + it("declares exactly the paths the entrypoint names", () => { + expect(interceptedPaths(SERVER_SOURCE)).toEqual([...SELFHOST_INFRA_PATHS].sort()); + }); + + it("needs an operation for exactly those the Hono app does NOT serve — computed, not listed", () => { + // The rule, rather than an exemption list: `/health` and `/v1/github/webhook` drop out because + // createApp() serves and documents them, the entrypoint merely gets there first (answering one itself, + // checking the other's path to dedup a delivery before letting the request through). Deriving this from + // the live app means a route moving into or out of createApp cannot leave this set stale. + const served = new Set(listLiveRouteKeys(createApp() as never).map((key) => key.slice(key.indexOf(" ") + 1))); + const needsSpec = interceptedPaths(SERVER_SOURCE).filter((path) => !served.has(path)); + expect(needsSpec.sort()).toEqual([...SELFHOST_INFRA_SPEC_PATHS].sort()); + }); + + it("ignores a path-shaped string that is not an interception", () => { + expect(interceptedPaths('const target = "/v1/not-a-route"; if (path === "/real") {}')).toEqual(["/real"]); + }); + + it("de-duplicates a path the handler tests more than once", () => { + // `/setup` and `/setup/callback` are each compared in three separate branches (brokered mode, the + // wizard, the callback), which must not read as three routes. + expect(interceptedPaths('if (path === "/setup") {} if (path === "/setup") {}')).toEqual(["/setup"]); + }); +}); + +describe("every intercepted path is documented (#9750)", () => { + const paths = buildOpenApiSpec().paths as Record } | undefined>>; + + it("publishes an operation for each one the Hono app does not already serve", () => { + expect(SELFHOST_INFRA_SPEC_PATHS).not.toContain("/health"); + expect(SELFHOST_INFRA_SPEC_PATHS).not.toContain("/v1/github/webhook"); + for (const path of SELFHOST_INFRA_SPEC_PATHS) { + expect(paths[path], `${path} is intercepted by src/server.ts but has no operation`).toBeDefined(); + } + expect(paths["/health"]?.get, "/health stays the app's own operation").toBeDefined(); + }); + + it("tags them so they are readable as one surface rather than scattered through the API", () => { + for (const path of SELFHOST_INFRA_SPEC_PATHS) { + for (const operation of Object.values(paths[path]!)) { + if (operation) expect(operation.tags).toEqual(["Self-host infra"]); + } + } + }); + + it("declares `[]` security — these answer before any auth the app would run", () => { + for (const path of SELFHOST_INFRA_SPEC_PATHS) { + for (const operation of Object.values(paths[path]!)) { + if (operation) expect((operation as { security?: unknown }).security).toEqual([]); + } + } + }); + + it("gives /ready both of the statuses an orchestrator gates on", () => { + // The whole point of the endpoint: `ok: false` is a 503, not a 200 with a false in the body, so a + // readiness gate can act on the status line alone. + expect(Object.keys(paths["/ready"]!.get!.responses!).sort()).toEqual(["200", "503"]); + }); + + it("does not claim a JSON schema for the Prometheus scrape endpoint", () => { + // /metrics answers text/plain exposition. Publishing a JSON body for it would be a lie a generated + // client would act on. + const metrics = paths["/metrics"]!.get!.responses!["200"] as { content?: unknown }; + expect(metrics.content).toBeUndefined(); + }); + + it("documents both the header and the form half of the setup wizard", () => { + expect(paths["/setup"]!.get?.operationId).toBe("getSelfhostSetupWizard"); + expect(paths["/setup"]!.post?.operationId).toBe("postSelfhostSetupWizard"); + }); + + it("keeps the ratchet exemption exactly the set it publishes", () => { + // The exemption list and the operations must be the same thing, or the exemption becomes a place to + // park a route that is served by nothing at all. + const published = SELFHOST_INFRA_SPEC_PATHS.flatMap((path) => + Object.keys(paths[path]!) + .filter((method) => ["get", "post", "put", "patch", "delete"].includes(method)) + .map((method) => `${method.toUpperCase()} ${path}`), + ).sort(); + expect([...SELFHOST_INFRA_ROUTE_KEYS].sort()).toEqual(published); + }); +});