feat(api): define v1 control-plane contract

This commit is contained in:
chick
2026-07-16 10:58:05 +08:00
parent aa3c3f9803
commit a86970d33b
14 changed files with 3234 additions and 4 deletions
File diff suppressed because it is too large Load Diff
+3 -3
View File
@@ -8,12 +8,12 @@
"scripts": { "scripts": {
"start": "node server/index.js", "start": "node server/index.js",
"dev": "node --watch 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:legacy": "node --test test/*.test.js",
"test:unit": "vitest run", "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", "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" "typecheck": "corepack pnpm --recursive --if-present run typecheck"
}, },
"dependencies": { "dependencies": {
+1
View File
@@ -5,6 +5,7 @@
"type": "module", "type": "module",
"exports": "./src/index.ts", "exports": "./src/index.ts",
"scripts": { "scripts": {
"test": "vitest run",
"typecheck": "tsc -p tsconfig.json" "typecheck": "tsc -p tsconfig.json"
} }
} }
@@ -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<string, Schema>;
items?: Schema;
enum: string[];
required: string[];
oneOf: Schema[];
description?: string;
[key: string]: unknown;
};
type Operation = {
operationId?: string;
responses?: Record<
string,
{ headers?: Record<string, unknown>; content?: Record<string, { schema?: Schema }> }
>;
parameters?: Array<{ $ref?: string }>;
requestBody?: { content?: Record<string, { schema?: Schema }> };
};
type Document = {
openapi: string;
paths: Record<string, Record<string, Operation>>;
components: {
schemas: Record<string, Schema>;
responses: Record<string, unknown>;
parameters: Record<string, Schema>;
};
};
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<string, unknown>)?.[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);
});
});
+52
View File
@@ -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<AuditEvent>;
export const EVENT_KINDS = ['instance', 'job', 'item', 'attempt', 'audit'] as const;
export type EventKind = (typeof EVENT_KINDS)[number];
interface EventBase<Kind extends EventKind> {
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 });
+18
View File
@@ -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[];
}
+5
View File
@@ -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; export const contractsWorkspaceReady = true;
+81
View File
@@ -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<SortField extends string> {
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<T> {
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<Instance>;
export interface SessionMetadata {
readonly instanceId: string;
readonly authenticated: boolean;
readonly checkedAt: string;
}
+79
View File
@@ -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<Job>;
export interface RetryJobRequest {
readonly itemIds: readonly string[];
readonly preparationId: string;
readonly confirmationToken: string;
}
@@ -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<string, any>;
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);
});
});
+399
View File
@@ -0,0 +1,399 @@
type JsonObject = Record<string, unknown>;
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<readonly [string, readonly string[]]> = [
[
'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<string>()): 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<unknown>()): 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<unknown>((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<string>(),
): 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<string>();
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);
}
+66
View File
@@ -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<OperationCatalogEntry>;
+2 -1
View File
@@ -3,5 +3,6 @@
"compilerOptions": { "compilerOptions": {
"rootDir": "src" "rootDir": "src"
}, },
"include": ["src/**/*.ts"] "include": ["src/**/*.ts"],
"exclude": ["src/**/*.test.ts"]
} }
+5
View File
@@ -29,6 +29,11 @@ test('Phase 1 workspace pins pnpm and preserves the legacy service gates', async
/^node --test$/, /^node --test$/,
'root test must not auto-discover Vitest-only files', '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, /apps/);
assert.match(packageJson.scripts.lint, /packages\/contracts/); assert.match(packageJson.scripts.lint, /packages\/contracts/);
assert.match(packageJson.scripts.lint, /eslint\.config\.js/); assert.match(packageJson.scripts.lint, /eslint\.config\.js/);