feat(api): define v1 control-plane contract
This commit is contained in:
File diff suppressed because it is too large
Load Diff
+3
-3
@@ -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": {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 });
|
||||||
@@ -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[];
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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>;
|
||||||
@@ -3,5 +3,6 @@
|
|||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"rootDir": "src"
|
"rootDir": "src"
|
||||||
},
|
},
|
||||||
"include": ["src/**/*.ts"]
|
"include": ["src/**/*.ts"],
|
||||||
|
"exclude": ["src/**/*.test.ts"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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/);
|
||||||
|
|||||||
Reference in New Issue
Block a user