feat(api): define v1 control-plane contract
This commit is contained in:
@@ -5,6 +5,7 @@
|
||||
"type": "module",
|
||||
"exports": "./src/index.ts",
|
||||
"scripts": {
|
||||
"test": "vitest run",
|
||||
"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;
|
||||
|
||||
@@ -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": {
|
||||
"rootDir": "src"
|
||||
},
|
||||
"include": ["src/**/*.ts"]
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": ["src/**/*.test.ts"]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user