From a86970d33be2ec2e1eab2b17cedb34ad8e136d73 Mon Sep 17 00:00:00 2001 From: chick Date: Thu, 16 Jul 2026 10:58:05 +0800 Subject: [PATCH] feat(api): define v1 control-plane contract --- openapi/multi-simadmin.v1.json | 2163 +++++++++++++++++ package.json | 6 +- packages/contracts/package.json | 1 + .../contracts/src/api-v1.contract.test.ts | 223 ++ packages/contracts/src/audit.ts | 52 + packages/contracts/src/errors.ts | 18 + packages/contracts/src/index.ts | 5 + packages/contracts/src/instances.ts | 81 + packages/contracts/src/jobs.ts | 79 + .../contracts/src/openapi-validator.test.ts | 137 ++ packages/contracts/src/openapi-validator.ts | 399 +++ packages/contracts/src/operations.ts | 66 + packages/contracts/tsconfig.json | 3 +- test/phase-one-workspace.test.js | 5 + 14 files changed, 3234 insertions(+), 4 deletions(-) create mode 100644 openapi/multi-simadmin.v1.json create mode 100644 packages/contracts/src/api-v1.contract.test.ts create mode 100644 packages/contracts/src/audit.ts create mode 100644 packages/contracts/src/errors.ts create mode 100644 packages/contracts/src/instances.ts create mode 100644 packages/contracts/src/jobs.ts create mode 100644 packages/contracts/src/openapi-validator.test.ts create mode 100644 packages/contracts/src/openapi-validator.ts create mode 100644 packages/contracts/src/operations.ts diff --git a/openapi/multi-simadmin.v1.json b/openapi/multi-simadmin.v1.json new file mode 100644 index 0000000..d85aa30 --- /dev/null +++ b/openapi/multi-simadmin.v1.json @@ -0,0 +1,2163 @@ +{ + "openapi": "3.1.0", + "info": { + "title": "Multi SimAdmin Control Plane API", + "version": "1.0.0", + "description": "Versioned control-plane contract. It exposes registered structured operations, never an arbitrary upstream method/path proxy." + }, + "servers": [ + { + "url": "/", + "description": "Same-origin control plane; paths already include /api/v1." + } + ], + "paths": { + "/api/v1/instances": { + "get": { + "operationId": "listInstances", + "parameters": [ + { + "$ref": "#/components/parameters/Page" + }, + { + "$ref": "#/components/parameters/PageSize" + }, + { + "$ref": "#/components/parameters/SortDirection" + }, + { + "name": "sort", + "in": "query", + "schema": { + "type": "string", + "enum": ["name", "status", "freshness", "updatedAt"] + }, + "description": "Stable ordering; instance id ascending is the final tie-breaker." + }, + { + "name": "search", + "in": "query", + "schema": { + "type": "string", + "maxLength": 200 + } + }, + { + "name": "capabilityStatus", + "in": "query", + "schema": { + "$ref": "#/components/schemas/CapabilityStatus" + } + }, + { + "name": "freshness", + "in": "query", + "schema": { + "$ref": "#/components/schemas/SnapshotFreshness" + } + }, + { + "name": "tag", + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "credentialConfigured", + "in": "query", + "schema": { + "type": "boolean" + } + } + ], + "responses": { + "200": { + "description": "Success", + "headers": { + "X-Request-Id": { + "description": "Correlation identifier for this response.", + "schema": { + "type": "string" + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InstancePage" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "default": { + "$ref": "#/components/responses/Problem" + } + }, + "summary": "List registered instances" + }, + "post": { + "operationId": "createInstance", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InstanceInput" + } + } + } + }, + "responses": { + "201": { + "description": "Success", + "headers": { + "X-Request-Id": { + "description": "Correlation identifier for this response.", + "schema": { + "type": "string" + } + }, + "ETag": { + "description": "Opaque entity tag containing the current resource revision.", + "schema": { + "type": "string" + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Instance" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "default": { + "$ref": "#/components/responses/Problem" + } + }, + "summary": "Create a registered instance" + } + }, + "/api/v1/instances/{instanceId}": { + "get": { + "operationId": "getInstance", + "parameters": [ + { + "name": "instanceId", + "in": "path", + "required": true, + "schema": { + "type": "string", + "minLength": 1 + } + } + ], + "responses": { + "200": { + "description": "Success", + "headers": { + "X-Request-Id": { + "description": "Correlation identifier for this response.", + "schema": { + "type": "string" + } + }, + "ETag": { + "description": "Opaque entity tag containing the current resource revision.", + "schema": { + "type": "string" + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Instance" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "default": { + "$ref": "#/components/responses/Problem" + } + }, + "summary": "Get a registered instance" + }, + "patch": { + "operationId": "updateInstance", + "parameters": [ + { + "name": "instanceId", + "in": "path", + "required": true, + "schema": { + "type": "string", + "minLength": 1 + } + }, + { + "$ref": "#/components/parameters/IfMatch" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InstancePatch" + } + } + } + }, + "responses": { + "200": { + "description": "Success", + "headers": { + "X-Request-Id": { + "description": "Correlation identifier for this response.", + "schema": { + "type": "string" + } + }, + "ETag": { + "description": "Opaque entity tag containing the current resource revision.", + "schema": { + "type": "string" + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Instance" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "412": { + "$ref": "#/components/responses/PreconditionFailed" + }, + "default": { + "$ref": "#/components/responses/Problem" + } + }, + "x-precondition": "A matching If-Match revision is required.", + "summary": "Update a registered instance" + }, + "delete": { + "operationId": "deleteInstance", + "parameters": [ + { + "name": "instanceId", + "in": "path", + "required": true, + "schema": { + "type": "string", + "minLength": 1 + } + }, + { + "$ref": "#/components/parameters/IfMatch" + }, + { + "$ref": "#/components/parameters/PreparationId" + }, + { + "$ref": "#/components/parameters/ConfirmationToken" + } + ], + "responses": { + "202": { + "description": "Success", + "headers": { + "X-Request-Id": { + "description": "Correlation identifier for this response.", + "schema": { + "type": "string" + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Job" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "412": { + "$ref": "#/components/responses/PreconditionFailed" + }, + "default": { + "$ref": "#/components/responses/Problem" + } + }, + "x-precondition": "A matching If-Match revision is required.", + "description": "R3 deletion cannot execute directly: it requires the one-time token and Preparation id returned by operations/prepare, bound to this instance, revision, and delete operation.", + "summary": "Delete a registered instance through confirmed execution" + } + }, + "/api/v1/instances/{instanceId}/test-connection": { + "post": { + "operationId": "testInstanceConnection", + "parameters": [ + { + "name": "instanceId", + "in": "path", + "required": true, + "schema": { + "type": "string", + "minLength": 1 + } + } + ], + "responses": { + "200": { + "description": "Success", + "headers": { + "X-Request-Id": { + "description": "Correlation identifier for this response.", + "schema": { + "type": "string" + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SessionMetadata" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "default": { + "$ref": "#/components/responses/Problem" + } + }, + "summary": "Test an instance connection" + } + }, + "/api/v1/instances/{instanceId}/login": { + "post": { + "operationId": "loginInstance", + "parameters": [ + { + "name": "instanceId", + "in": "path", + "required": true, + "schema": { + "type": "string", + "minLength": 1 + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LoginInput" + } + } + } + }, + "responses": { + "200": { + "description": "Success", + "headers": { + "X-Request-Id": { + "description": "Correlation identifier for this response.", + "schema": { + "type": "string" + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SessionMetadata" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "default": { + "$ref": "#/components/responses/Problem" + } + }, + "summary": "Create an instance authentication session" + } + }, + "/api/v1/instances/{instanceId}/logout": { + "post": { + "operationId": "logoutInstance", + "parameters": [ + { + "name": "instanceId", + "in": "path", + "required": true, + "schema": { + "type": "string", + "minLength": 1 + } + } + ], + "responses": { + "200": { + "description": "Success", + "headers": { + "X-Request-Id": { + "description": "Correlation identifier for this response.", + "schema": { + "type": "string" + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SessionMetadata" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "default": { + "$ref": "#/components/responses/Problem" + } + }, + "summary": "Clear an instance authentication session" + } + }, + "/api/v1/operations": { + "get": { + "operationId": "listOperations", + "parameters": [ + { + "$ref": "#/components/parameters/Page" + }, + { + "$ref": "#/components/parameters/PageSize" + }, + { + "$ref": "#/components/parameters/SortDirection" + }, + { + "name": "sort", + "in": "query", + "schema": { + "type": "string", + "enum": ["operationId", "risk", "capability"] + }, + "description": "Stable ordering; operationId ascending is the final tie-breaker." + }, + { + "name": "search", + "in": "query", + "schema": { + "type": "string", + "maxLength": 200 + } + }, + { + "name": "risk", + "in": "query", + "schema": { + "$ref": "#/components/schemas/RiskLevel" + } + }, + { + "name": "capability", + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "batchable", + "in": "query", + "schema": { + "type": "boolean" + } + } + ], + "responses": { + "200": { + "description": "Success", + "headers": { + "X-Request-Id": { + "description": "Correlation identifier for this response.", + "schema": { + "type": "string" + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OperationPage" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "default": { + "$ref": "#/components/responses/Problem" + } + }, + "summary": "List registered operations" + } + }, + "/api/v1/operations/prepare": { + "post": { + "operationId": "prepareOperation", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PrepareOperationRequest" + } + } + } + }, + "responses": { + "200": { + "description": "Success", + "headers": { + "X-Request-Id": { + "description": "Correlation identifier for this response.", + "schema": { + "type": "string" + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Preparation" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "default": { + "$ref": "#/components/responses/Problem" + } + }, + "summary": "Prepare and validate an operation" + } + }, + "/api/v1/operations/execute": { + "post": { + "operationId": "executePreparedOperation", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ExecuteOperationRequest" + } + } + } + }, + "responses": { + "202": { + "description": "Success", + "headers": { + "X-Request-Id": { + "description": "Correlation identifier for this response.", + "schema": { + "type": "string" + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Job" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "default": { + "$ref": "#/components/responses/Problem" + } + }, + "summary": "Execute a prepared operation" + } + }, + "/api/v1/jobs": { + "get": { + "operationId": "listJobs", + "parameters": [ + { + "$ref": "#/components/parameters/Page" + }, + { + "$ref": "#/components/parameters/PageSize" + }, + { + "$ref": "#/components/parameters/SortDirection" + }, + { + "name": "sort", + "in": "query", + "schema": { + "type": "string", + "enum": ["createdAt", "status", "operationId"] + }, + "description": "Stable ordering; job id ascending is the final tie-breaker." + }, + { + "name": "status", + "in": "query", + "schema": { + "$ref": "#/components/schemas/JobStatus" + } + }, + { + "name": "operationId", + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "rootJobId", + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "instanceId", + "in": "query", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Success", + "headers": { + "X-Request-Id": { + "description": "Correlation identifier for this response.", + "schema": { + "type": "string" + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/JobPage" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "default": { + "$ref": "#/components/responses/Problem" + } + }, + "summary": "List jobs" + } + }, + "/api/v1/jobs/{jobId}": { + "get": { + "operationId": "getJob", + "parameters": [ + { + "name": "jobId", + "in": "path", + "required": true, + "schema": { + "type": "string", + "minLength": 1 + } + } + ], + "responses": { + "200": { + "description": "Success", + "headers": { + "X-Request-Id": { + "description": "Correlation identifier for this response.", + "schema": { + "type": "string" + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Job" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "default": { + "$ref": "#/components/responses/Problem" + } + }, + "summary": "Get a job" + } + }, + "/api/v1/jobs/{jobId}/cancel": { + "post": { + "operationId": "cancelJob", + "parameters": [ + { + "name": "jobId", + "in": "path", + "required": true, + "schema": { + "type": "string", + "minLength": 1 + } + } + ], + "responses": { + "202": { + "description": "Success", + "headers": { + "X-Request-Id": { + "description": "Correlation identifier for this response.", + "schema": { + "type": "string" + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Job" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "default": { + "$ref": "#/components/responses/Problem" + } + }, + "summary": "Request job cancellation" + } + }, + "/api/v1/jobs/{jobId}/retry": { + "post": { + "operationId": "retryJob", + "parameters": [ + { + "name": "jobId", + "in": "path", + "required": true, + "schema": { + "type": "string", + "minLength": 1 + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RetryJobRequest" + } + } + } + }, + "responses": { + "202": { + "description": "Success", + "headers": { + "X-Request-Id": { + "description": "Correlation identifier for this response.", + "schema": { + "type": "string" + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Job" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "default": { + "$ref": "#/components/responses/Problem" + } + }, + "summary": "Retry selected failed job items in a new job" + } + }, + "/api/v1/audit": { + "get": { + "operationId": "listAuditEvents", + "parameters": [ + { + "$ref": "#/components/parameters/Page" + }, + { + "$ref": "#/components/parameters/PageSize" + }, + { + "$ref": "#/components/parameters/SortDirection" + }, + { + "name": "sort", + "in": "query", + "schema": { + "type": "string", + "enum": ["occurredAt", "action", "outcome"] + }, + "description": "Stable ordering; audit event id ascending is the final tie-breaker." + }, + { + "name": "actorId", + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "instanceId", + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "jobId", + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "operationId", + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "outcome", + "in": "query", + "schema": { + "$ref": "#/components/schemas/AuditOutcome" + } + }, + { + "name": "occurredFrom", + "in": "query", + "schema": { + "type": "string", + "format": "date-time" + } + }, + { + "name": "occurredTo", + "in": "query", + "schema": { + "type": "string", + "format": "date-time" + } + }, + { + "name": "requestId", + "in": "query", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Success", + "headers": { + "X-Request-Id": { + "description": "Correlation identifier for this response.", + "schema": { + "type": "string" + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AuditPage" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "default": { + "$ref": "#/components/responses/Problem" + } + }, + "summary": "List audit events" + } + }, + "/api/v1/audit/{eventId}": { + "get": { + "operationId": "getAuditEvent", + "parameters": [ + { + "name": "eventId", + "in": "path", + "required": true, + "schema": { + "type": "string", + "minLength": 1 + } + } + ], + "responses": { + "200": { + "description": "Success", + "headers": { + "X-Request-Id": { + "description": "Correlation identifier for this response.", + "schema": { + "type": "string" + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AuditEvent" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "default": { + "$ref": "#/components/responses/Problem" + } + }, + "summary": "Get an audit event" + } + }, + "/api/v1/events": { + "get": { + "operationId": "streamEvents", + "responses": { + "200": { + "description": "Server-sent control-plane events", + "headers": { + "X-Request-Id": { + "description": "Correlation identifier for this response.", + "schema": { + "type": "string" + } + } + }, + "content": { + "text/event-stream": { + "schema": { + "$ref": "#/components/schemas/EventEnvelope" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "default": { + "$ref": "#/components/responses/Problem" + } + }, + "parameters": [ + { + "$ref": "#/components/parameters/LastEventId" + } + ], + "summary": "Stream control-plane events" + } + } + }, + "components": { + "schemas": { + "Revision": { + "type": "integer", + "minimum": 1, + "description": "Monotonically increasing positive resource revision." + }, + "CapabilityStatus": { + "type": "string", + "enum": ["supported", "unsupported", "auth-required", "degraded", "unknown"] + }, + "SnapshotFreshness": { + "type": "string", + "enum": ["fresh", "stale", "expired", "unknown"] + }, + "RiskLevel": { + "type": "string", + "enum": ["R0", "R1", "R2", "R3"] + }, + "ValidationIssue": { + "type": "object", + "additionalProperties": false, + "required": ["field", "code", "message"], + "properties": { + "field": { + "type": "string" + }, + "code": { + "type": "string" + }, + "message": { + "type": "string", + "description": "Redacted validation message; never contains submitted secret values." + } + } + }, + "ProblemDetails": { + "type": "object", + "additionalProperties": false, + "required": ["type", "title", "status", "detail", "code", "requestId"], + "properties": { + "type": { + "type": "string", + "format": "uri-reference" + }, + "title": { + "type": "string" + }, + "status": { + "type": "integer", + "minimum": 400, + "maximum": 599 + }, + "detail": { + "type": "string" + }, + "code": { + "type": "string" + }, + "requestId": { + "type": "string" + }, + "validation": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ValidationIssue" + } + } + } + }, + "PageMeta": { + "type": "object", + "required": ["page", "pageSize", "total"], + "properties": { + "page": { + "type": "integer", + "minimum": 1 + }, + "pageSize": { + "type": "integer", + "minimum": 1, + "maximum": 100 + }, + "total": { + "type": "integer", + "minimum": 0 + } + }, + "additionalProperties": false + }, + "PasswordUpdate": { + "description": "Explicit password-reference update. Password is accepted only by the set variant and is never returned.", + "oneOf": [ + { + "type": "object", + "additionalProperties": false, + "required": ["action"], + "properties": { + "action": { + "const": "preserve" + } + } + }, + { + "type": "object", + "additionalProperties": false, + "required": ["action", "password"], + "properties": { + "action": { + "const": "set" + }, + "password": { + "type": "string", + "minLength": 1, + "writeOnly": true + } + } + }, + { + "type": "object", + "additionalProperties": false, + "required": ["action"], + "properties": { + "action": { + "const": "clear" + } + } + } + ], + "discriminator": { + "propertyName": "action" + } + }, + "InstanceInput": { + "type": "object", + "additionalProperties": false, + "required": ["name", "origin"], + "properties": { + "name": { + "type": "string", + "minLength": 1 + }, + "origin": { + "type": "string", + "format": "uri" + }, + "tags": { + "type": "array", + "items": { + "type": "string" + } + }, + "password": { + "$ref": "#/components/schemas/PasswordUpdate" + } + } + }, + "InstancePatch": { + "type": "object", + "additionalProperties": false, + "minProperties": 1, + "properties": { + "name": { + "type": "string", + "minLength": 1 + }, + "origin": { + "type": "string", + "format": "uri" + }, + "tags": { + "type": "array", + "items": { + "type": "string" + } + }, + "password": { + "$ref": "#/components/schemas/PasswordUpdate" + } + } + }, + "Instance": { + "type": "object", + "additionalProperties": false, + "required": [ + "id", + "name", + "origin", + "tags", + "revision", + "capabilityStatus", + "freshness", + "credentialConfigured" + ], + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "origin": { + "type": "string", + "format": "uri" + }, + "tags": { + "type": "array", + "items": { + "type": "string" + } + }, + "revision": { + "$ref": "#/components/schemas/Revision" + }, + "capabilityStatus": { + "$ref": "#/components/schemas/CapabilityStatus" + }, + "freshness": { + "$ref": "#/components/schemas/SnapshotFreshness" + }, + "credentialConfigured": { + "type": "boolean" + } + } + }, + "SessionMetadata": { + "type": "object", + "additionalProperties": false, + "required": ["instanceId", "authenticated", "checkedAt"], + "properties": { + "instanceId": { + "type": "string" + }, + "authenticated": { + "type": "boolean" + }, + "checkedAt": { + "type": "string", + "format": "date-time" + } + } + }, + "LoginInput": { + "type": "object", + "additionalProperties": false, + "properties": { + "password": { + "type": "string", + "minLength": 1, + "writeOnly": true + } + }, + "description": "Omit password to resolve a saved reference server-side; supplied password is one-shot input only." + }, + "OperationCatalogEntry": { + "type": "object", + "additionalProperties": false, + "required": [ + "operationId", + "title", + "risk", + "capability", + "batchable", + "parameterSchemaId" + ], + "properties": { + "operationId": { + "type": "string" + }, + "title": { + "type": "string" + }, + "risk": { + "$ref": "#/components/schemas/RiskLevel" + }, + "capability": { + "type": "string" + }, + "batchable": { + "type": "boolean" + }, + "parameterSchemaId": { + "type": "string", + "minLength": 1 + } + }, + "description": "Safe metadata projection from OperationRegistry; no upstream path or arbitrary method is exposed." + }, + "OperationTarget": { + "type": "object", + "additionalProperties": false, + "required": ["instanceId"], + "properties": { + "instanceId": { + "type": "string" + }, + "revision": { + "$ref": "#/components/schemas/Revision" + } + } + }, + "PrepareOperationRequest": { + "type": "object", + "additionalProperties": false, + "required": ["operationId", "targets", "parameters"], + "properties": { + "operationId": { + "type": "string" + }, + "targets": { + "type": "array", + "minItems": 1, + "items": { + "$ref": "#/components/schemas/OperationTarget" + } + }, + "parameters": { + "$ref": "#/components/schemas/RegisteredParameters" + } + } + }, + "Preparation": { + "type": "object", + "additionalProperties": false, + "required": [ + "id", + "status", + "operationId", + "risk", + "expiresAt", + "confirmationToken", + "confirmationPrompt", + "targetCount" + ], + "properties": { + "id": { + "type": "string" + }, + "operationId": { + "type": "string" + }, + "risk": { + "$ref": "#/components/schemas/RiskLevel" + }, + "expiresAt": { + "type": "string", + "format": "date-time" + }, + "confirmationPrompt": { + "type": "string" + }, + "targetCount": { + "type": "integer", + "minimum": 1 + }, + "status": { + "$ref": "#/components/schemas/PreparationStatus" + }, + "confirmationToken": { + "type": "string", + "minLength": 20, + "description": "Opaque one-time confirmation credential. This is the sole intentional token-valued success response field; never log, audit, persist in browser storage, or return again." + } + }, + "description": "Preflight-only record; it does not create a Job. The response delivers a short-lived one-time confirmationToken consumed with this preparation by execute." + }, + "ExecuteOperationRequest": { + "type": "object", + "additionalProperties": false, + "required": ["preparationId", "confirmationToken"], + "properties": { + "preparationId": { + "type": "string" + }, + "confirmationToken": { + "type": "string", + "writeOnly": true + } + }, + "description": "Execution consumes a preparation-bound confirmation. Dangerous operations cannot bypass prepare." + }, + "JobStatus": { + "type": "string", + "enum": [ + "queued", + "running", + "cancelling", + "succeeded", + "partially-succeeded", + "failed", + "cancelled", + "unknown-result" + ], + "description": "Transitions: queued -> running|cancelled; running -> cancelling|succeeded|partially-succeeded|failed|unknown-result; cancelling -> cancelled|failed|unknown-result. Terminal states are immutable." + }, + "JobItemTerminalState": { + "type": "string", + "enum": ["succeeded", "failed", "skipped", "cancelled", "unknown-result"] + }, + "JobItem": { + "type": "object", + "description": "Each item independently reaches one terminal state and may contain its own redacted error.", + "required": ["id", "targetId", "state"], + "properties": { + "id": { + "type": "string" + }, + "targetId": { + "type": "string" + }, + "state": { + "$ref": "#/components/schemas/JobItemTerminalState" + }, + "sourceJobItemId": { + "type": "string" + }, + "error": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "additionalProperties": false + }, + "Attempt": { + "type": "object", + "description": "An Attempt terminal result is immutable. User retry creates a new Job and independent Attempt; bounded transport retries, when allowed, remain internal to this Attempt.", + "required": ["id", "state", "startedAt"], + "properties": { + "id": { + "type": "string" + }, + "state": { + "$ref": "#/components/schemas/AttemptStatus" + }, + "startedAt": { + "type": "string", + "format": "date-time" + }, + "finishedAt": { + "type": "string", + "format": "date-time" + } + }, + "additionalProperties": false + }, + "Job": { + "type": "object", + "description": "Terminal Job state, result, items, Attempts and event history are immutable. User retry always creates a new Job with retryOfJobId and rootJobId; successful items are never replayed.", + "required": ["id", "operationId", "status", "rootJobId", "items", "attempts", "createdAt"], + "properties": { + "id": { + "type": "string" + }, + "operationId": { + "type": "string" + }, + "status": { + "$ref": "#/components/schemas/JobStatus" + }, + "retryOfJobId": { + "type": "string" + }, + "rootJobId": { + "type": "string" + }, + "items": { + "type": "array", + "items": { + "$ref": "#/components/schemas/JobItem" + } + }, + "attempts": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Attempt" + } + }, + "createdAt": { + "type": "string", + "format": "date-time" + } + }, + "additionalProperties": false + }, + "RetryJobRequest": { + "type": "object", + "additionalProperties": false, + "required": ["itemIds", "preparationId", "confirmationToken"], + "properties": { + "itemIds": { + "type": "array", + "minItems": 1, + "items": { + "type": "string" + } + }, + "preparationId": { + "type": "string" + }, + "confirmationToken": { + "type": "string", + "writeOnly": true + } + }, + "description": "Select only retryable failed items. Creates a new Job; never mutates the source or replays successful items." + }, + "AuditEvent": { + "type": "object", + "additionalProperties": false, + "required": ["id", "occurredAt", "actorId", "action", "outcome", "requestId"], + "properties": { + "id": { + "type": "string" + }, + "occurredAt": { + "type": "string", + "format": "date-time" + }, + "actorId": { + "type": "string" + }, + "action": { + "type": "string" + }, + "outcome": { + "$ref": "#/components/schemas/AuditOutcome" + }, + "requestId": { + "type": "string" + }, + "instanceId": { + "type": "string" + }, + "jobId": { + "type": "string" + }, + "parameterSummary": { + "type": "array", + "items": { + "$ref": "#/components/schemas/RedactedParameterSummaryItem" + }, + "description": "Metadata-only, display-safe summary; never raw request values." + }, + "itemId": { + "type": "string" + }, + "attemptId": { + "type": "string" + }, + "preparationId": { + "type": "string" + } + } + }, + "PreparationStatus": { + "type": "string", + "enum": ["prepared", "consumed", "expired", "invalidated"] + }, + "AttemptStatus": { + "type": "string", + "enum": ["running", "succeeded", "failed", "cancelled", "unknown-result"], + "description": "Attempt-specific state; it is independent from JobStatus." + }, + "RegisteredParameters": { + "type": "object", + "additionalProperties": false, + "required": ["parameterSchemaId", "fields"], + "properties": { + "parameterSchemaId": { + "type": "string", + "minLength": 1, + "description": "Immutable schema id from OperationRegistry." + }, + "fields": { + "type": "array", + "uniqueItems": true, + "items": { + "$ref": "#/components/schemas/RegisteredParameterValue" + }, + "description": "Structured fields only; duplicate or unknown fieldId values are rejected." + } + } + }, + "InstancePage": { + "type": "object", + "additionalProperties": false, + "required": ["items", "page"], + "properties": { + "items": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Instance" + } + }, + "page": { + "$ref": "#/components/schemas/PageMeta" + } + } + }, + "OperationPage": { + "type": "object", + "additionalProperties": false, + "required": ["items", "page"], + "properties": { + "items": { + "type": "array", + "items": { + "$ref": "#/components/schemas/OperationCatalogEntry" + } + }, + "page": { + "$ref": "#/components/schemas/PageMeta" + } + } + }, + "JobPage": { + "type": "object", + "additionalProperties": false, + "required": ["items", "page"], + "properties": { + "items": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Job" + } + }, + "page": { + "$ref": "#/components/schemas/PageMeta" + } + } + }, + "AuditPage": { + "type": "object", + "additionalProperties": false, + "required": ["items", "page"], + "properties": { + "items": { + "type": "array", + "items": { + "$ref": "#/components/schemas/AuditEvent" + } + }, + "page": { + "$ref": "#/components/schemas/PageMeta" + } + } + }, + "AuditOutcome": { + "type": "string", + "enum": ["succeeded", "failed", "partially-succeeded", "denied"] + }, + "EventEnvelope": { + "description": "JSON encoded in each SSE data field. SSE id equals envelope id. Associations allow direct instance/job/item/attempt/audit correlation.", + "oneOf": [ + { + "type": "object", + "additionalProperties": false, + "required": ["kind", "id", "occurredAt", "requestId", "instanceId"], + "properties": { + "kind": { + "const": "instance" + }, + "id": { + "type": "string" + }, + "occurredAt": { + "type": "string", + "format": "date-time" + }, + "requestId": { + "type": "string" + }, + "instanceId": { + "type": "string" + } + } + }, + { + "type": "object", + "additionalProperties": false, + "required": ["kind", "id", "occurredAt", "requestId", "jobId"], + "properties": { + "kind": { + "const": "job" + }, + "id": { + "type": "string" + }, + "occurredAt": { + "type": "string", + "format": "date-time" + }, + "requestId": { + "type": "string" + }, + "jobId": { + "type": "string" + } + } + }, + { + "type": "object", + "additionalProperties": false, + "required": ["kind", "id", "occurredAt", "requestId", "jobId", "itemId"], + "properties": { + "kind": { + "const": "item" + }, + "id": { + "type": "string" + }, + "occurredAt": { + "type": "string", + "format": "date-time" + }, + "requestId": { + "type": "string" + }, + "jobId": { + "type": "string" + }, + "itemId": { + "type": "string" + } + } + }, + { + "type": "object", + "additionalProperties": false, + "required": ["kind", "id", "occurredAt", "requestId", "jobId", "attemptId"], + "properties": { + "kind": { + "const": "attempt" + }, + "id": { + "type": "string" + }, + "occurredAt": { + "type": "string", + "format": "date-time" + }, + "requestId": { + "type": "string" + }, + "jobId": { + "type": "string" + }, + "attemptId": { + "type": "string" + } + } + }, + { + "type": "object", + "additionalProperties": false, + "required": ["kind", "id", "occurredAt", "requestId", "auditEventId"], + "properties": { + "kind": { + "const": "audit" + }, + "id": { + "type": "string" + }, + "occurredAt": { + "type": "string", + "format": "date-time" + }, + "requestId": { + "type": "string" + }, + "auditEventId": { + "type": "string" + } + } + } + ], + "discriminator": { + "propertyName": "kind" + } + }, + "RegisteredParameterValue": { + "description": "One registry-addressed scalar/list field. The registry rejects unknown fieldId values and kind mismatches. Transport fields such as method, path, host, and port are never registered.", + "oneOf": [ + { + "type": "object", + "additionalProperties": false, + "required": ["fieldId", "kind", "value"], + "properties": { + "fieldId": { + "type": "string", + "minLength": 1, + "pattern": "^[A-Za-z][A-Za-z0-9_.-]*$" + }, + "kind": { + "const": "string" + }, + "value": { + "type": "string" + } + } + }, + { + "type": "object", + "additionalProperties": false, + "required": ["fieldId", "kind", "value"], + "properties": { + "fieldId": { + "type": "string", + "minLength": 1, + "pattern": "^[A-Za-z][A-Za-z0-9_.-]*$" + }, + "kind": { + "const": "number" + }, + "value": { + "type": "number" + } + } + }, + { + "type": "object", + "additionalProperties": false, + "required": ["fieldId", "kind", "value"], + "properties": { + "fieldId": { + "type": "string", + "minLength": 1, + "pattern": "^[A-Za-z][A-Za-z0-9_.-]*$" + }, + "kind": { + "const": "boolean" + }, + "value": { + "type": "boolean" + } + } + }, + { + "type": "object", + "additionalProperties": false, + "required": ["fieldId", "kind", "value"], + "properties": { + "fieldId": { + "type": "string", + "minLength": 1, + "pattern": "^[A-Za-z][A-Za-z0-9_.-]*$" + }, + "kind": { + "const": "string-list" + }, + "value": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, + { + "type": "object", + "additionalProperties": false, + "required": ["fieldId", "kind", "value"], + "properties": { + "fieldId": { + "type": "string", + "minLength": 1, + "pattern": "^[A-Za-z][A-Za-z0-9_.-]*$" + }, + "kind": { + "const": "number-list" + }, + "value": { + "type": "array", + "items": { + "type": "number" + } + } + } + }, + { + "type": "object", + "additionalProperties": false, + "required": ["fieldId", "kind", "value"], + "properties": { + "fieldId": { + "type": "string", + "minLength": 1, + "pattern": "^[A-Za-z][A-Za-z0-9_.-]*$" + }, + "kind": { + "const": "null" + }, + "value": { + "type": "null" + } + } + } + ], + "discriminator": { + "propertyName": "kind" + } + }, + "RedactedParameterSummaryItem": { + "type": "object", + "additionalProperties": false, + "required": ["fieldId", "displayValue", "redacted"], + "properties": { + "fieldId": { + "type": "string" + }, + "displayValue": { + "type": "string", + "description": "Safe display text only; secrets use the canonical redaction marker." + }, + "redacted": { + "type": "boolean" + } + } + } + }, + "parameters": { + "Page": { + "name": "page", + "in": "query", + "schema": { + "type": "integer", + "minimum": 1, + "default": 1 + } + }, + "PageSize": { + "name": "pageSize", + "in": "query", + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 100, + "default": 25 + } + }, + "SortDirection": { + "name": "direction", + "in": "query", + "schema": { + "type": "string", + "enum": ["asc", "desc"], + "default": "asc" + } + }, + "IfMatch": { + "name": "If-Match", + "in": "header", + "required": true, + "description": "ETag of the resource revision. Mismatch returns 412.", + "schema": { + "type": "string" + } + }, + "PreparationId": { + "name": "X-Preparation-Id", + "in": "header", + "required": true, + "description": "Preparation bound to this exact R3 request.", + "schema": { + "type": "string", + "minLength": 1 + } + }, + "ConfirmationToken": { + "name": "X-Confirmation-Token", + "in": "header", + "required": true, + "description": "Opaque, short-lived, one-time confirmation returned by prepare and atomically consumed at execution entry. Sensitive header: never log, audit, trace, or persist.", + "schema": { + "type": "string", + "minLength": 20 + }, + "x-log-redaction": true + }, + "LastEventId": { + "name": "Last-Event-ID", + "in": "header", + "required": false, + "description": "Resume strictly after this SSE event id. A missing/expired retention position produces a reset event or 409 rather than silently skipping events.", + "schema": { + "type": "string", + "minLength": 1 + } + } + }, + "headers": {}, + "responses": { + "BadRequest": { + "description": "Invalid request", + "headers": { + "X-Request-Id": { + "description": "Correlation identifier for this response.", + "schema": { + "type": "string" + } + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "NotFound": { + "description": "Resource not found", + "headers": { + "X-Request-Id": { + "description": "Correlation identifier for this response.", + "schema": { + "type": "string" + } + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "PreconditionFailed": { + "description": "If-Match revision precondition failed", + "headers": { + "X-Request-Id": { + "description": "Correlation identifier for this response.", + "schema": { + "type": "string" + } + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "Problem": { + "description": "Standard control-plane error", + "headers": { + "X-Request-Id": { + "description": "Correlation identifier for this response.", + "schema": { + "type": "string" + } + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + }, + "securitySchemes": { + "ControlPlaneSession": { + "type": "apiKey", + "in": "cookie", + "name": "multi_simadmin_session", + "description": "HttpOnly, SameSite=Strict control-plane session cookie. The API is same-origin and loopback-bound." + } + } + }, + "security": [ + { + "ControlPlaneSession": [] + } + ] +} diff --git a/package.json b/package.json index 16b465a..a2d0845 100644 --- a/package.json +++ b/package.json @@ -8,12 +8,12 @@ "scripts": { "start": "node server/index.js", "dev": "node --watch server/index.js", - "test": "node --test test/*.test.js packages/operation-registry/test/*.test.ts packages/test-fixtures/test/*.test.ts", + "test": "node --test test/*.test.js packages/operation-registry/test/*.test.ts packages/test-fixtures/test/*.test.ts && vitest run", "test:legacy": "node --test test/*.test.js", "test:unit": "vitest run", - "test:contract": "node --test packages/operation-registry/test/*.test.ts", + "test:contract": "node --test packages/operation-registry/test/*.test.ts && vitest run packages/contracts/src/api-v1.contract.test.ts packages/contracts/src/openapi-validator.test.ts", "lint": "eslint apps packages/contracts test/phase-one-workspace.test.js eslint.config.js vitest.config.ts", - "format:check": "prettier --check apps packages/contracts test/phase-one-workspace.test.js eslint.config.js vitest.config.ts tsconfig.base.json pnpm-workspace.yaml package.json", + "format:check": "prettier --check apps packages/contracts openapi test/phase-one-workspace.test.js eslint.config.js vitest.config.ts tsconfig.base.json pnpm-workspace.yaml package.json", "typecheck": "corepack pnpm --recursive --if-present run typecheck" }, "dependencies": { diff --git a/packages/contracts/package.json b/packages/contracts/package.json index 22b2a5e..e85387d 100644 --- a/packages/contracts/package.json +++ b/packages/contracts/package.json @@ -5,6 +5,7 @@ "type": "module", "exports": "./src/index.ts", "scripts": { + "test": "vitest run", "typecheck": "tsc -p tsconfig.json" } } diff --git a/packages/contracts/src/api-v1.contract.test.ts b/packages/contracts/src/api-v1.contract.test.ts new file mode 100644 index 0000000..ddc96d4 --- /dev/null +++ b/packages/contracts/src/api-v1.contract.test.ts @@ -0,0 +1,223 @@ +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { describe, expect, it } from 'vitest'; +import { + API_V1_PREFIX, + CAPABILITY_STATUSES, + FRESHNESS_STATUSES, + JOB_ITEM_TERMINAL_STATES, + JOB_STATUSES, + MAX_PAGE_SIZE, + RISK_LEVELS, +} from './index.js'; + +type Schema = { + $ref?: string; + properties: Record; + items?: Schema; + enum: string[]; + required: string[]; + oneOf: Schema[]; + description?: string; + [key: string]: unknown; +}; +type Operation = { + operationId?: string; + responses?: Record< + string, + { headers?: Record; content?: Record } + >; + parameters?: Array<{ $ref?: string }>; + requestBody?: { content?: Record }; +}; +type Document = { + openapi: string; + paths: Record>; + components: { + schemas: Record; + responses: Record; + parameters: Record; + }; +}; +const document = JSON.parse( + readFileSync( + fileURLToPath(new URL('../../../openapi/multi-simadmin.v1.json', import.meta.url)), + 'utf8', + ), +) as Document; +const methods = new Set(['get', 'post', 'put', 'patch', 'delete']); +const operations = Object.entries(document.paths).flatMap(([path, item]) => + Object.entries(item) + .filter(([method]) => methods.has(method)) + .map(([method, operation]) => ({ path, method, operation })), +); + +function refs(value: unknown): string[] { + if (Array.isArray(value)) return value.flatMap(refs); + if (!value || typeof value !== 'object') return []; + return Object.entries(value).flatMap(([key, child]) => + key === '$ref' ? [String(child)] : refs(child), + ); +} +function responseSchema(operation: Operation, status: string): Schema { + const response = operation.responses?.[status]; + expect(response).toBeDefined(); + return (response?.content?.['application/json']?.schema ?? {}) as Schema; +} + +describe('control-plane API v1 OpenAPI contract', () => { + it('is OpenAPI 3.1 with only /api/v1 paths, unique operationIds and no generic proxy', () => { + expect(document.openapi).toMatch(/^3\.1\./); + expect(operations.length).toBeGreaterThanOrEqual(18); + expect(operations.every(({ path }) => path.startsWith(API_V1_PREFIX))).toBe(true); + const ids = operations.map(({ operation }) => operation.operationId); + expect(ids.every(Boolean)).toBe(true); + expect(new Set(ids).size).toBe(ids.length); + expect( + Object.keys(document.paths).some((path) => /proxy|workbench|\{method\}|\{path\}/i.test(path)), + ).toBe(false); + }); + + it('has the IA resource/action boundary', () => { + const required = [ + 'GET /api/v1/instances', + 'POST /api/v1/instances', + 'GET /api/v1/instances/{instanceId}', + 'PATCH /api/v1/instances/{instanceId}', + 'DELETE /api/v1/instances/{instanceId}', + 'POST /api/v1/instances/{instanceId}/test-connection', + 'POST /api/v1/instances/{instanceId}/login', + 'POST /api/v1/instances/{instanceId}/logout', + 'GET /api/v1/operations', + 'POST /api/v1/operations/prepare', + 'POST /api/v1/operations/execute', + 'GET /api/v1/jobs', + 'GET /api/v1/jobs/{jobId}', + 'POST /api/v1/jobs/{jobId}/cancel', + 'POST /api/v1/jobs/{jobId}/retry', + 'GET /api/v1/audit', + 'GET /api/v1/audit/{eventId}', + 'GET /api/v1/events', + ]; + const actual = new Set(operations.map(({ method, path }) => `${method.toUpperCase()} ${path}`)); + expect(required.every((endpoint) => actual.has(endpoint))).toBe(true); + expect( + document.paths['/api/v1/events']?.get?.responses?.['200']?.content?.['text/event-stream'], + ).toBeDefined(); + expect(document.paths['/api/v1/operations/execute']?.post?.responses?.['202']).toBeDefined(); + }); + + it('resolves every local component ref', () => { + for (const ref of refs(document)) { + expect(ref.startsWith('#/components/')).toBe(true); + const segments = ref.slice(2).split('/'); + let cursor: unknown = document; + for (const segment of segments) cursor = (cursor as Record)?.[segment]; + expect(cursor, `unresolved ${ref}`).toBeDefined(); + } + }); + + it('gives every operation success plus reusable Problem Details errors and request IDs', () => { + const problem = document.components.schemas.ProblemDetails!; + expect(problem.required).toEqual( + expect.arrayContaining(['type', 'title', 'status', 'detail', 'code', 'requestId']), + ); + expect(document.components.responses.PreconditionFailed).toBeDefined(); + for (const { operation } of operations) { + const statuses = Object.keys(operation.responses ?? {}); + expect(statuses.some((status) => /^2\d\d$/.test(status))).toBe(true); + expect(statuses.some((status) => /^4\d\d$|^5\d\d$|default/.test(status))).toBe(true); + for (const [status, response] of Object.entries(operation.responses ?? {})) { + if (/^2\d\d$/.test(status)) expect(response.headers?.['X-Request-Id']).toBeDefined(); + if (/^4\d\d$|^5\d\d$|default/.test(status)) { + if ('$ref' in response) + expect(String(response.$ref)).toMatch(/^#\/components\/responses\//); + else expect(response.content?.['application/problem+json']).toBeDefined(); + } + } + } + }); + + it('keeps TS and OpenAPI enums/pagination/revisions aligned', () => { + expect(document.components.schemas.CapabilityStatus!.enum).toEqual(CAPABILITY_STATUSES); + expect(document.components.schemas.SnapshotFreshness!.enum).toEqual(FRESHNESS_STATUSES); + expect(document.components.schemas.JobStatus!.enum).toEqual(JOB_STATUSES); + expect(document.components.schemas.JobItemTerminalState!.enum).toEqual( + JOB_ITEM_TERMINAL_STATES, + ); + expect(document.components.schemas.RiskLevel!.enum).toEqual(RISK_LEVELS); + expect(document.components.parameters.PageSize!.schema).toMatchObject({ + maximum: MAX_PAGE_SIZE, + minimum: 1, + }); + expect(document.components.schemas.Revision).toMatchObject({ type: 'integer', minimum: 1 }); + for (const path of ['/api/v1/instances/{instanceId}']) { + for (const method of ['patch', 'delete']) + expect(document.paths[path]?.[method]?.parameters).toContainEqual({ + $ref: '#/components/parameters/IfMatch', + }); + } + }); + + it('models ETags, confirmation, partial jobs and immutable retry lineage', () => { + for (const [path, method, status] of [ + ['/api/v1/instances', 'post', '201'], + ['/api/v1/instances/{instanceId}', 'get', '200'], + ['/api/v1/instances/{instanceId}', 'patch', '200'], + ] as const) { + expect(document.paths[path]?.[method]?.responses?.[status]?.headers?.ETag).toBeDefined(); + } + const execute = document.components.schemas.ExecuteOperationRequest!; + expect(execute.required).toEqual( + expect.arrayContaining(['preparationId', 'confirmationToken']), + ); + const job = document.components.schemas.Job!; + expect(String(job.description)).toMatch(/terminal.*immutable|immutable.*terminal/i); + expect(job.properties).toHaveProperty('retryOfJobId'); + expect(job.properties).toHaveProperty('rootJobId'); + expect(job.properties).not.toHaveProperty('parentJobId'); + expect(document.components.schemas.JobItem!.properties).toHaveProperty('error'); + expect(String(document.components.schemas.JobItem!.description)).toMatch(/independent/i); + expect(document.components.schemas.JobStatus!.enum).toContain('partially-succeeded'); + expect(document.components.schemas.JobStatus!.enum).toContain('unknown-result'); + expect(document.components.schemas.AttemptStatus!.enum).not.toContain('queued'); + expect(String(document.components.schemas.Attempt!.description)).toMatch( + /terminal.*immutable|immutable.*terminal/i, + ); + }); + + it('keeps password input-only and explicitly allows only the prepare confirmation credential in success responses', () => { + const update = document.components.schemas.PasswordUpdate!; + expect(update.oneOf).toHaveLength(3); + expect(JSON.stringify(update)).toContain('preserve'); + expect(JSON.stringify(update)).toContain('set'); + expect(JSON.stringify(update)).toContain('clear'); + expect(JSON.stringify(update)).toContain('writeOnly'); + for (const { operation } of operations) { + for (const status of Object.keys(operation.responses ?? {}).filter((value) => + /^2\d\d$/.test(value), + )) { + const serialized = JSON.stringify(responseSchema(operation, status)); + if (operation.operationId === 'prepareOperation') { + expect(serialized).toContain('Preparation'); + } else { + expect(serialized).not.toMatch(/password|token|cookie|secret/i); + } + } + } + }); + + it('binds destructive deletion to prepare confirmation and defines resumable typed SSE', () => { + const deletion = document.paths['/api/v1/instances/{instanceId}']?.delete; + expect(deletion?.parameters).toEqual( + expect.arrayContaining([ + { $ref: '#/components/parameters/PreparationId' }, + { $ref: '#/components/parameters/ConfirmationToken' }, + ]), + ); + expect(document.paths['/api/v1/events']?.get?.parameters).toContainEqual({ + $ref: '#/components/parameters/LastEventId', + }); + expect(document.components.schemas.EventEnvelope!.oneOf).toHaveLength(5); + }); +}); diff --git a/packages/contracts/src/audit.ts b/packages/contracts/src/audit.ts new file mode 100644 index 0000000..6d767bb --- /dev/null +++ b/packages/contracts/src/audit.ts @@ -0,0 +1,52 @@ +import type { PageEnvelope, PageQuery } from './instances.js'; + +export const AUDIT_OUTCOMES = ['succeeded', 'failed', 'partially-succeeded', 'denied'] as const; +export type AuditOutcome = (typeof AUDIT_OUTCOMES)[number]; + +export interface RedactedParameterSummaryItem { + readonly fieldId: string; + readonly displayValue: string; + readonly redacted: boolean; +} + +export interface AuditEvent { + readonly id: string; + readonly occurredAt: string; + readonly actorId: string; + readonly action: string; + readonly outcome: AuditOutcome; + readonly requestId: string; + readonly instanceId?: string; + readonly jobId?: string; + readonly itemId?: string; + readonly attemptId?: string; + readonly preparationId?: string; + readonly parameterSummary?: readonly RedactedParameterSummaryItem[]; +} +export interface AuditFilters { + readonly actorId?: string; + readonly instanceId?: string; + readonly jobId?: string; + readonly operationId?: string; + readonly outcome?: AuditOutcome; + readonly occurredFrom?: string; + readonly occurredTo?: string; + readonly requestId?: string; +} +export type AuditPageQuery = PageQuery<'occurredAt' | 'action' | 'outcome'> & AuditFilters; +export type AuditPage = PageEnvelope; + +export const EVENT_KINDS = ['instance', 'job', 'item', 'attempt', 'audit'] as const; +export type EventKind = (typeof EVENT_KINDS)[number]; +interface EventBase { + readonly kind: Kind; + readonly id: string; + readonly occurredAt: string; + readonly requestId: string; +} +export type EventEnvelope = + | (EventBase<'instance'> & { readonly instanceId: string }) + | (EventBase<'job'> & { readonly jobId: string }) + | (EventBase<'item'> & { readonly jobId: string; readonly itemId: string }) + | (EventBase<'attempt'> & { readonly jobId: string; readonly attemptId: string }) + | (EventBase<'audit'> & { readonly auditEventId: string }); diff --git a/packages/contracts/src/errors.ts b/packages/contracts/src/errors.ts new file mode 100644 index 0000000..10ed4b4 --- /dev/null +++ b/packages/contracts/src/errors.ts @@ -0,0 +1,18 @@ +export const PROBLEM_CONTENT_TYPE = 'application/problem+json' as const; + +export interface ValidationIssue { + readonly field: string; + readonly code: string; + readonly message: string; +} + +/** RFC 9457-compatible, redacted control-plane error envelope. */ +export interface ProblemDetails { + readonly type: string; + readonly title: string; + readonly status: number; + readonly detail: string; + readonly code: string; + readonly requestId: string; + readonly validation?: readonly ValidationIssue[]; +} diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index df77fe8..0c6a0b1 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -1 +1,6 @@ +export * from './audit.js'; +export * from './errors.js'; +export * from './instances.js'; +export * from './jobs.js'; +export * from './operations.js'; export const contractsWorkspaceReady = true; diff --git a/packages/contracts/src/instances.ts b/packages/contracts/src/instances.ts new file mode 100644 index 0000000..845d346 --- /dev/null +++ b/packages/contracts/src/instances.ts @@ -0,0 +1,81 @@ +export const API_V1_PREFIX = '/api/v1' as const; +export const MAX_PAGE_SIZE = 100 as const; +export const SORT_DIRECTIONS = ['asc', 'desc'] as const; +export const CAPABILITY_STATUSES = [ + 'supported', + 'unsupported', + 'auth-required', + 'degraded', + 'unknown', +] as const; +export const FRESHNESS_STATUSES = ['fresh', 'stale', 'expired', 'unknown'] as const; + +export type SortDirection = (typeof SORT_DIRECTIONS)[number]; +export type CapabilityStatus = (typeof CAPABILITY_STATUSES)[number]; +export type SnapshotFreshness = (typeof FRESHNESS_STATUSES)[number]; +export type Revision = number; + +/** Page ordering is stable: the resource id is always the final ascending tie-breaker. */ +export interface PageQuery { + readonly page?: number; + readonly pageSize?: number; + readonly sort?: SortField; + readonly direction?: SortDirection; +} +export interface PageMeta { + readonly page: number; + readonly pageSize: number; + readonly total: number; +} +export interface PageEnvelope { + readonly items: readonly T[]; + readonly page: PageMeta; +} + +export type PasswordUpdate = + | { readonly action: 'preserve' } + | { readonly action: 'set'; readonly password: string } + | { readonly action: 'clear' }; + +export interface InstanceInput { + readonly name: string; + readonly origin: string; + readonly tags?: readonly string[]; + readonly password?: PasswordUpdate; +} +export interface InstancePatch { + readonly name?: string; + readonly origin?: string; + readonly tags?: readonly string[]; + readonly password?: PasswordUpdate; +} +export interface LoginInput { + /** One-shot input; omit to resolve the saved credential reference server-side. */ + readonly password?: string; +} +export interface Instance { + readonly id: string; + readonly name: string; + readonly origin: string; + readonly tags: readonly string[]; + readonly revision: Revision; + readonly capabilityStatus: CapabilityStatus; + readonly freshness: SnapshotFreshness; + readonly credentialConfigured: boolean; +} +export interface InstanceFilters { + readonly search?: string; + readonly capabilityStatus?: CapabilityStatus; + readonly freshness?: SnapshotFreshness; + readonly tag?: string; + readonly credentialConfigured?: boolean; +} +export type InstancePageQuery = PageQuery<'name' | 'status' | 'freshness' | 'updatedAt'> & + InstanceFilters; +export type InstancePage = PageEnvelope; + +export interface SessionMetadata { + readonly instanceId: string; + readonly authenticated: boolean; + readonly checkedAt: string; +} diff --git a/packages/contracts/src/jobs.ts b/packages/contracts/src/jobs.ts new file mode 100644 index 0000000..7624d61 --- /dev/null +++ b/packages/contracts/src/jobs.ts @@ -0,0 +1,79 @@ +import type { ProblemDetails } from './errors.js'; +import type { PageEnvelope, PageQuery } from './instances.js'; + +export const JOB_STATUSES = [ + 'queued', + 'running', + 'cancelling', + 'succeeded', + 'partially-succeeded', + 'failed', + 'cancelled', + 'unknown-result', +] as const; +export const JOB_TERMINAL_STATUSES = [ + 'succeeded', + 'partially-succeeded', + 'failed', + 'cancelled', + 'unknown-result', +] as const; +export const ATTEMPT_STATUSES = [ + 'running', + 'succeeded', + 'failed', + 'cancelled', + 'unknown-result', +] as const; +export const JOB_ITEM_TERMINAL_STATES = [ + 'succeeded', + 'failed', + 'skipped', + 'cancelled', + 'unknown-result', +] as const; +export type JobStatus = (typeof JOB_STATUSES)[number]; +export type JobTerminalStatus = (typeof JOB_TERMINAL_STATUSES)[number]; +export type AttemptStatus = (typeof ATTEMPT_STATUSES)[number]; +export type JobItemTerminalState = (typeof JOB_ITEM_TERMINAL_STATES)[number]; + +/** Each item reaches an independent terminal state and may carry its own redacted error. */ +export interface JobItem { + readonly id: string; + readonly targetId: string; + readonly state: JobItemTerminalState; + readonly sourceJobItemId?: string; + readonly error?: ProblemDetails; +} +/** Attempt results are immutable. User retry creates a new Job and independent Attempt. */ +export interface Attempt { + readonly id: string; + readonly state: AttemptStatus; + readonly startedAt: string; + readonly finishedAt?: string; +} +/** Terminal Jobs, JobItems, Attempts, and events are immutable; retry creates a new lineage Job. */ +export interface Job { + readonly id: string; + readonly operationId: string; + readonly status: JobStatus; + readonly retryOfJobId?: string; + readonly rootJobId: string; + readonly items: readonly JobItem[]; + readonly attempts: readonly Attempt[]; + readonly createdAt: string; +} +export interface JobFilters { + readonly status?: JobStatus; + readonly operationId?: string; + readonly rootJobId?: string; + readonly instanceId?: string; +} +export type JobPageQuery = PageQuery<'createdAt' | 'status' | 'operationId'> & JobFilters; +export type JobPage = PageEnvelope; + +export interface RetryJobRequest { + readonly itemIds: readonly string[]; + readonly preparationId: string; + readonly confirmationToken: string; +} diff --git a/packages/contracts/src/openapi-validator.test.ts b/packages/contracts/src/openapi-validator.test.ts new file mode 100644 index 0000000..8805f2c --- /dev/null +++ b/packages/contracts/src/openapi-validator.test.ts @@ -0,0 +1,137 @@ +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { describe, expect, it } from 'vitest'; +import { validateControlPlaneOpenApi } from './openapi-validator.js'; + +// Mutation tests intentionally need dynamic, deeply writable JSON fixture access. +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type JsonObject = Record; + +const source = JSON.parse( + readFileSync( + fileURLToPath(new URL('../../../openapi/multi-simadmin.v1.json', import.meta.url)), + 'utf8', + ), +) as JsonObject; + +const clone = (): JsonObject => structuredClone(source) as JsonObject; +const rejects = (mutate: (document: JsonObject) => void, message: RegExp): void => { + const document = clone(); + mutate(document); + expect(() => validateControlPlaneOpenApi(document)).toThrow(message); +}; + +describe('independent deep OpenAPI validator', () => { + it('accepts the checked-in contract', () => { + expect(() => validateControlPlaneOpenApi(source)).not.toThrow(); + }); + + it('rejects a secret added through a deeply referenced success schema', () => { + rejects((document) => { + document.components.schemas.Instance.properties.profile = { + type: 'object', + properties: { password: { type: 'string' } }, + }; + }, /password|sensitive/i); + }); + + it('rejects an error response missing its request id', () => { + rejects((document) => { + delete document.components.responses.Problem.headers['X-Request-Id']; + }, /X-Request-Id/); + }); + + it('rejects an error response missing problem+json', () => { + rejects((document) => { + delete document.components.responses.BadRequest.content['application/problem+json']; + }, /application\/problem\+json/); + }); + + it('rejects a non-required path parameter', () => { + rejects((document) => { + const parameter = document.paths['/api/v1/jobs/{jobId}'].get.parameters.find( + (value: JsonObject) => value.name === 'jobId', + ); + parameter.required = false; + }, /jobId.*required|path parameter/i); + }); + + it('rejects removal of the create request body', () => { + rejects((document) => { + delete document.paths['/api/v1/instances'].post.requestBody; + }, /requestBody.*POST \/api\/v1\/instances|request-body baseline/i); + }); + + it('rejects PageMeta required and maximum drift', () => { + rejects((document) => { + document.components.schemas.PageMeta.required = ['page', 'pageSize']; + document.components.schemas.PageMeta.properties.pageSize.maximum = 999; + }, /PageMeta/); + }); + + it('rejects sort direction enum drift', () => { + rejects((document) => { + document.components.parameters.SortDirection.schema.enum = ['up', 'down']; + }, /SortDirection/); + }); + + it('rejects removal of the nested Job item ref', () => { + rejects((document) => { + delete document.components.schemas.Job.properties.items.items.$ref; + }, /Job\.items|JobItem/); + }); + + it('rejects illegal Path Item operation-like fields', () => { + rejects((document) => { + document.paths['/api/v1/jobs'].fetch = { responses: {} }; + }, /illegal Path Item field.*fetch/i); + }); + + it('rejects illegal fields inside an Operation Object', () => { + rejects((document) => { + document.paths['/api/v1/instances/{instanceId}'].patch.precondition = true; + }, /illegal Operation field.*precondition/i); + }); + + it('rejects arbitrary operation parameter objects', () => { + rejects((document) => { + document.components.schemas.RegisteredParameters.properties = { + parameterSchemaId: { type: 'string' }, + values: { type: 'object', additionalProperties: true }, + }; + }, /arbitrary values object|RegisteredParameters/i); + }); + + it('requires the destructive confirmation header to be log-redacted', () => { + rejects((document) => { + delete document.components.parameters.ConfirmationToken['x-log-redaction']; + }, /ConfirmationToken.*redacted header/i); + }); + + it('rejects removal or drift of control-plane authentication', () => { + rejects((document) => { + delete document.security; + }, /root security.*ControlPlaneSession/i); + rejects((document) => { + document.components.securitySchemes.ControlPlaneSession.in = 'query'; + }, /fixed control-plane cookie scheme/i); + }); + + it('rejects a server/path double API version prefix', () => { + rejects((document) => { + document.servers = [{ url: '/api/v1' }]; + }, /duplicates.*api\/v1|double.*prefix/i); + }); + + it('resolves refs recursively and reports unresolved refs even behind cycles', () => { + rejects((document) => { + document.components.schemas.CycleA = { $ref: '#/components/schemas/CycleB' }; + document.components.schemas.CycleB = { + allOf: [ + { $ref: '#/components/schemas/CycleA' }, + { $ref: '#/components/schemas/DoesNotExist' }, + ], + }; + }, /unresolved.*DoesNotExist/i); + }); +}); diff --git a/packages/contracts/src/openapi-validator.ts b/packages/contracts/src/openapi-validator.ts new file mode 100644 index 0000000..72e567c --- /dev/null +++ b/packages/contracts/src/openapi-validator.ts @@ -0,0 +1,399 @@ +type JsonObject = Record; + +const HTTP_METHODS = new Set(['get', 'put', 'post', 'delete', 'options', 'head', 'patch', 'trace']); +const PATH_ITEM_FIELDS = new Set([ + '$ref', + 'summary', + 'description', + 'get', + 'put', + 'post', + 'delete', + 'options', + 'head', + 'patch', + 'trace', + 'servers', + 'parameters', +]); +const OPERATION_FIELDS = new Set([ + 'tags', + 'summary', + 'description', + 'externalDocs', + 'operationId', + 'parameters', + 'requestBody', + 'responses', + 'callbacks', + 'deprecated', + 'security', + 'servers', +]); + +/** Deliberately independent literals: these must never be generated from the document under test. */ +const ENDPOINTS = new Set([ + 'GET /api/v1/instances', + 'POST /api/v1/instances', + 'GET /api/v1/instances/{instanceId}', + 'PATCH /api/v1/instances/{instanceId}', + 'DELETE /api/v1/instances/{instanceId}', + 'POST /api/v1/instances/{instanceId}/test-connection', + 'POST /api/v1/instances/{instanceId}/login', + 'POST /api/v1/instances/{instanceId}/logout', + 'GET /api/v1/operations', + 'POST /api/v1/operations/prepare', + 'POST /api/v1/operations/execute', + 'GET /api/v1/jobs', + 'GET /api/v1/jobs/{jobId}', + 'POST /api/v1/jobs/{jobId}/cancel', + 'POST /api/v1/jobs/{jobId}/retry', + 'GET /api/v1/audit', + 'GET /api/v1/audit/{eventId}', + 'GET /api/v1/events', +]); +const REQUEST_BODY_ENDPOINTS = new Set([ + 'POST /api/v1/instances', + 'PATCH /api/v1/instances/{instanceId}', + 'POST /api/v1/instances/{instanceId}/login', + 'POST /api/v1/operations/prepare', + 'POST /api/v1/operations/execute', + 'POST /api/v1/jobs/{jobId}/retry', +]); +const ENUMS: ReadonlyArray = [ + [ + 'components.schemas.CapabilityStatus', + ['supported', 'unsupported', 'auth-required', 'degraded', 'unknown'], + ], + ['components.schemas.SnapshotFreshness', ['fresh', 'stale', 'expired', 'unknown']], + [ + 'components.schemas.JobStatus', + [ + 'queued', + 'running', + 'cancelling', + 'succeeded', + 'partially-succeeded', + 'failed', + 'cancelled', + 'unknown-result', + ], + ], + [ + 'components.schemas.AttemptStatus', + ['running', 'succeeded', 'failed', 'cancelled', 'unknown-result'], + ], + [ + 'components.schemas.JobItemTerminalState', + ['succeeded', 'failed', 'skipped', 'cancelled', 'unknown-result'], + ], + ['components.schemas.RiskLevel', ['R0', 'R1', 'R2', 'R3']], + ['components.schemas.AuditOutcome', ['succeeded', 'failed', 'partially-succeeded', 'denied']], + ['components.parameters.SortDirection.schema', ['asc', 'desc']], +]; + +const object = (value: unknown, label: string): JsonObject => { + if (!value || typeof value !== 'object' || Array.isArray(value)) + fail(`${label} must be an object`); + return value as JsonObject; +}; +const array = (value: unknown): unknown[] => (Array.isArray(value) ? value : []); +const fail = (message: string): never => { + throw new Error(`OpenAPI validation failed: ${message}`); +}; +const pointerPart = (value: string): string => value.replaceAll('~1', '/').replaceAll('~0', '~'); + +class LocalRefs { + readonly document: JsonObject; + + constructor(document: JsonObject) { + this.document = document; + } + + get(refValue: unknown): unknown { + if (typeof refValue !== 'string' || !refValue.startsWith('#/')) + fail(`only local refs are allowed: ${String(refValue)}`); + const ref = refValue as string; + let cursor: unknown = this.document; + for (const part of ref.slice(2).split('/').map(pointerPart)) { + if (!cursor || typeof cursor !== 'object' || !(part in cursor)) + fail(`unresolved local ref ${ref}`); + cursor = (cursor as JsonObject)[part]; + } + return cursor; + } + + resolve(value: unknown, chain = new Set()): unknown { + let cursor = value; + while (cursor && typeof cursor === 'object' && !Array.isArray(cursor) && '$ref' in cursor) { + const refValue = (cursor as JsonObject).$ref; + if (typeof refValue !== 'string') fail('$ref must be a string'); + const ref = refValue as string; + if (chain.has(ref)) return cursor; + chain.add(ref); + cursor = this.get(ref); + } + return cursor; + } + + validateAll(value: unknown, active = new Set()): void { + if (!value || typeof value !== 'object' || active.has(value)) return; + active.add(value); + if (!Array.isArray(value) && '$ref' in value) this.get((value as JsonObject).$ref); + for (const child of Array.isArray(value) ? value : Object.values(value as JsonObject)) { + this.validateAll(child, active); + } + active.delete(value); + } +} + +const at = (document: JsonObject, dottedPath: string): unknown => + dottedPath + .split('.') + .reduce((cursor, part) => object(cursor, dottedPath)[part], document); +const sameStrings = (actual: unknown, expected: readonly string[]): boolean => + Array.isArray(actual) && + actual.length === expected.length && + actual.every((value, index) => value === expected[index]); + +function assertHeader(response: JsonObject, label: string): void { + const headers = object(response.headers, `${label} headers`); + const header = headers['X-Request-Id'] ?? headers['x-request-id']; + if (!header) fail(`${label} must declare X-Request-Id`); + const schema = object(header, `${label} X-Request-Id`).schema; + if (object(schema, `${label} X-Request-Id schema`).type !== 'string') { + fail(`${label} X-Request-Id must have a string schema`); + } +} + +function scanSuccessSchema( + schema: unknown, + refs: LocalRefs, + label: string, + seenRefs = new Set(), +): void { + if (!schema || typeof schema !== 'object') fail(`${label} has no valid schema`); + if (Array.isArray(schema)) { + schema.forEach((child) => scanSuccessSchema(child, refs, label, new Set(seenRefs))); + return; + } + const node = schema as JsonObject; + if ('$ref' in node) { + const ref = String(node.$ref); + refs.get(ref); + if (seenRefs.has(ref)) return; + seenRefs.add(ref); + scanSuccessSchema(refs.get(ref), refs, `${label} -> ${ref}`, seenRefs); + return; + } + const properties = node.properties; + if (properties && typeof properties === 'object' && !Array.isArray(properties)) { + for (const [name, child] of Object.entries(properties as JsonObject)) { + const normalized = name.replaceAll(/[-_]/g, '').toLowerCase(); + const confirmationAllowed = + normalized === 'confirmationtoken' && label.includes('Preparation'); + if ( + /password|cookie|secret|accesstoken|refreshtoken|token/.test(normalized) && + !confirmationAllowed + ) { + fail(`${label} exposes sensitive response property ${name}`); + } + scanSuccessSchema(child, refs, `${label}.${name}`, new Set(seenRefs)); + } + } + for (const keyword of ['items', 'additionalProperties', 'allOf', 'anyOf', 'oneOf', 'not']) { + if (node[keyword] && typeof node[keyword] === 'object') { + scanSuccessSchema(node[keyword], refs, `${label}.${keyword}`, new Set(seenRefs)); + } + } +} + +function assertProblem(responseValue: unknown, refs: LocalRefs, label: string): void { + const response = object(refs.resolve(responseValue), label); + assertHeader(response, label); + const content = object(response.content, `${label} content`); + const media = content['application/problem+json']; + if (!media) fail(`${label} must declare application/problem+json`); + const schema = object(object(media, `${label} problem media`).schema, `${label} problem schema`); + const resolved = refs.resolve(schema); + const expected = at(refs.document, 'components.schemas.ProblemDetails'); + if (resolved !== expected) + fail(`${label} application/problem+json must resolve to ProblemDetails`); +} + +function validateModelBaselines(document: JsonObject): void { + for (const [path, expected] of ENUMS) { + const schema = object(at(document, path), path); + if (!sameStrings(schema.enum, expected)) fail(`${path.split('.').at(-2) ?? path} enum drift`); + } + const pageMeta = object(at(document, 'components.schemas.PageMeta'), 'PageMeta'); + if (!sameStrings(pageMeta.required, ['page', 'pageSize', 'total'])) + fail('PageMeta required drift'); + const pageProperties = object(pageMeta.properties, 'PageMeta.properties'); + if (object(pageProperties.pageSize, 'PageMeta.pageSize').maximum !== 100) { + fail('PageMeta.pageSize maximum must be 100'); + } + const jobProperties = object(at(document, 'components.schemas.Job.properties'), 'Job.properties'); + for (const [field, target] of [ + ['items', '#/components/schemas/JobItem'], + ['attempts', '#/components/schemas/Attempt'], + ] as const) { + const items = object(object(jobProperties[field], `Job.${field}`).items, `Job.${field}.items`); + if (items.$ref !== target) + fail(`Job.${field}.items must reference ${target.split('/').at(-1)}`); + } + const registered = object( + at(document, 'components.schemas.RegisteredParameters'), + 'RegisteredParameters', + ); + const registeredProperties = object(registered.properties, 'RegisteredParameters.properties'); + if ('values' in registeredProperties || registered.additionalProperties !== false) { + fail('RegisteredParameters must not accept an arbitrary values object'); + } + const fields = object(registeredProperties.fields, 'RegisteredParameters.fields'); + if ( + object(fields.items, 'RegisteredParameters.fields.items').$ref !== + '#/components/schemas/RegisteredParameterValue' + ) { + fail('RegisteredParameters.fields must reference RegisteredParameterValue'); + } + const confirmation = object( + at(document, 'components.parameters.ConfirmationToken'), + 'ConfirmationToken', + ); + if ( + confirmation.in !== 'header' || + confirmation.name !== 'X-Confirmation-Token' || + confirmation.required !== true || + confirmation['x-log-redaction'] !== true + ) { + fail('ConfirmationToken must be a required redacted header'); + } +} + +export function validateControlPlaneOpenApi(input: unknown): void { + const document = object(input, 'document'); + const refs = new LocalRefs(document); + refs.validateAll(document); + if (typeof document.openapi !== 'string' || !document.openapi.startsWith('3.1.')) { + fail('document must use OpenAPI 3.1'); + } + const rootSecurity = array(document.security); + if ( + rootSecurity.length !== 1 || + !Array.isArray(object(rootSecurity[0], 'root security').ControlPlaneSession) + ) { + fail('root security must require ControlPlaneSession'); + } + const sessionScheme = object( + at(document, 'components.securitySchemes.ControlPlaneSession'), + 'ControlPlaneSession', + ); + if ( + sessionScheme.type !== 'apiKey' || + sessionScheme.in !== 'cookie' || + sessionScheme.name !== 'multi_simadmin_session' + ) { + fail('ControlPlaneSession must be the fixed control-plane cookie scheme'); + } + const paths = object(document.paths, 'paths'); + const actualEndpoints = new Set(); + const operations: Array<{ + endpoint: string; + path: string; + operation: JsonObject; + pathItem: JsonObject; + }> = []; + + for (const [path, rawPathItem] of Object.entries(paths)) { + const pathItem = object(rawPathItem, `path ${path}`); + for (const field of Object.keys(pathItem)) { + if (!PATH_ITEM_FIELDS.has(field)) fail(`illegal Path Item field ${field} at ${path}`); + } + for (const [method, rawOperation] of Object.entries(pathItem)) { + if (!HTTP_METHODS.has(method)) continue; + const endpoint = `${method.toUpperCase()} ${path}`; + const operation = object(rawOperation, endpoint); + for (const field of Object.keys(operation)) { + if (!OPERATION_FIELDS.has(field) && !field.startsWith('x-')) { + fail(`illegal Operation field ${field} at ${endpoint}`); + } + } + actualEndpoints.add(endpoint); + operations.push({ endpoint, path, operation, pathItem }); + } + } + if ( + actualEndpoints.size !== ENDPOINTS.size || + [...actualEndpoints].some((item) => !ENDPOINTS.has(item)) + ) { + fail(`endpoint set must exactly match the approved ${ENDPOINTS.size} endpoints`); + } + + for (const serverValue of array(document.servers)) { + const urlValue = object(serverValue, 'server').url; + if (typeof urlValue !== 'string') fail('server url must be a string'); + const url = urlValue as string; + const pathname = url.replace(/^https?:\/\/[^/]+/, ''); + if ( + /\/api\/v1\/?$/.test(pathname) && + Object.keys(paths).some((path) => path.startsWith('/api/v1/')) + ) { + fail('server URL duplicates the /api/v1 path prefix'); + } + } + + for (const { endpoint, path, operation, pathItem } of operations) { + const placeholders = [...path.matchAll(/\{([^}]+)\}/g)].map((match) => match[1] as string); + const parameters = [...array(pathItem.parameters), ...array(operation.parameters)].map( + (parameter) => object(refs.resolve(parameter), `${endpoint} parameter`), + ); + for (const name of placeholders) { + if ( + !parameters.some( + (parameter) => + parameter.name === name && parameter.in === 'path' && parameter.required === true, + ) + ) { + fail(`${endpoint} path parameter ${name} must be in:path and required true`); + } + } + + const mustHaveBody = REQUEST_BODY_ENDPOINTS.has(endpoint); + if (Boolean(operation.requestBody) !== mustHaveBody) + fail(`request-body baseline mismatch for ${endpoint}`); + if (mustHaveBody) { + const body = object(refs.resolve(operation.requestBody), `${endpoint} requestBody`); + if (body.required !== true) fail(`${endpoint} requestBody must be required`); + const media = object(body.content, `${endpoint} requestBody content`)['application/json']; + if (!media || !object(media, `${endpoint} JSON body`).schema) { + fail(`${endpoint} requestBody must declare application/json with schema`); + } + } + + const responses = object(operation.responses, `${endpoint} responses`); + const statuses = Object.keys(responses); + if (!statuses.some((status) => /^2\d\d$/.test(status))) + fail(`${endpoint} has no success response`); + if (!statuses.some((status) => /^(?:4\d\d|5\d\d|default)$/.test(status))) + fail(`${endpoint} has no error response`); + for (const [status, responseValue] of Object.entries(responses)) { + if (/^2\d\d$/.test(status)) { + const response = object(refs.resolve(responseValue), `${endpoint} ${status}`); + assertHeader(response, `${endpoint} ${status}`); + if (status !== '204') { + const content = object(response.content, `${endpoint} ${status} content`); + if (Object.keys(content).length === 0) + fail(`${endpoint} ${status} must declare response content`); + for (const [mediaType, mediaValue] of Object.entries(content)) { + const schema = object(mediaValue, `${endpoint} ${status} ${mediaType}`).schema; + scanSuccessSchema(schema, refs, `${endpoint} ${status} ${mediaType}`); + } + } + } else if (/^(?:4\d\d|5\d\d|default)$/.test(status)) { + assertProblem(responseValue, refs, `${endpoint} ${status}`); + } + } + } + validateModelBaselines(document); +} diff --git a/packages/contracts/src/operations.ts b/packages/contracts/src/operations.ts new file mode 100644 index 0000000..d9425a3 --- /dev/null +++ b/packages/contracts/src/operations.ts @@ -0,0 +1,66 @@ +import type { PageEnvelope, PageQuery } from './instances.js'; + +export const RISK_LEVELS = ['R0', 'R1', 'R2', 'R3'] as const; +export const PREPARATION_STATUSES = ['prepared', 'consumed', 'expired', 'invalidated'] as const; +export type RiskLevel = (typeof RISK_LEVELS)[number]; +export type PreparationStatus = (typeof PREPARATION_STATUSES)[number]; +/** Safe registry metadata; it never exposes an arbitrary HTTP method, path, host, or port. */ +export interface OperationCatalogEntry { + readonly operationId: string; + readonly title: string; + readonly risk: RiskLevel; + readonly capability: string; + readonly batchable: boolean; + readonly parameterSchemaId: string; +} +export interface ParameterSchemaRef { + readonly parameterSchemaId: string; +} +export interface OperationTarget { + readonly instanceId: string; + readonly revision?: number; +} +export type RegisteredParameterValue = + | { readonly fieldId: string; readonly kind: 'string'; readonly value: string } + | { readonly fieldId: string; readonly kind: 'number'; readonly value: number } + | { readonly fieldId: string; readonly kind: 'boolean'; readonly value: boolean } + | { readonly fieldId: string; readonly kind: 'string-list'; readonly value: readonly string[] } + | { readonly fieldId: string; readonly kind: 'number-list'; readonly value: readonly number[] } + | { readonly fieldId: string; readonly kind: 'null'; readonly value: null }; + +/** + * Structured fields validated against the immutable registry schema. Unknown field IDs and kind + * mismatches are rejected; callers cannot submit an arbitrary JSON object or transport target. + */ +export interface RegisteredParameters extends ParameterSchemaRef { + readonly fields: readonly RegisteredParameterValue[]; +} +export interface PrepareOperationRequest { + readonly operationId: string; + readonly targets: readonly OperationTarget[]; + readonly parameters: RegisteredParameters; +} +export interface Preparation { + readonly id: string; + readonly status: PreparationStatus; + readonly operationId: string; + readonly risk: RiskLevel; + readonly expiresAt: string; + /** Opaque, short-lived, one-time value returned only by prepare and consumed by execute. */ + readonly confirmationToken: string; + readonly confirmationPrompt: string; + readonly targetCount: number; +} +export interface ExecuteOperationRequest { + readonly preparationId: string; + readonly confirmationToken: string; +} +export interface OperationFilters { + readonly risk?: RiskLevel; + readonly capability?: string; + readonly batchable?: boolean; + readonly search?: string; +} +export type OperationPageQuery = PageQuery<'operationId' | 'risk' | 'capability'> & + OperationFilters; +export type OperationPage = PageEnvelope; diff --git a/packages/contracts/tsconfig.json b/packages/contracts/tsconfig.json index 4491d1b..426fe9c 100644 --- a/packages/contracts/tsconfig.json +++ b/packages/contracts/tsconfig.json @@ -3,5 +3,6 @@ "compilerOptions": { "rootDir": "src" }, - "include": ["src/**/*.ts"] + "include": ["src/**/*.ts"], + "exclude": ["src/**/*.test.ts"] } diff --git a/test/phase-one-workspace.test.js b/test/phase-one-workspace.test.js index b517260..a8082e0 100644 --- a/test/phase-one-workspace.test.js +++ b/test/phase-one-workspace.test.js @@ -29,6 +29,11 @@ test('Phase 1 workspace pins pnpm and preserves the legacy service gates', async /^node --test$/, 'root test must not auto-discover Vitest-only files', ); + assert.match( + packageJson.scripts['test:contract'], + /openapi-validator\.test\.ts/, + 'contract gate must include independent OpenAPI mutation tests', + ); assert.match(packageJson.scripts.lint, /apps/); assert.match(packageJson.scripts.lint, /packages\/contracts/); assert.match(packageJson.scripts.lint, /eslint\.config\.js/);