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); + }); +});