feat(web): add overview system module foundation
This commit is contained in:
@@ -0,0 +1,216 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { OperationClientError, createOperationClient } from './operation-client.js';
|
||||
|
||||
const response = (body: unknown, init: ResponseInit = {}) =>
|
||||
new Response(JSON.stringify(body), {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'application/json', ...init.headers },
|
||||
...init,
|
||||
});
|
||||
|
||||
const entry = {
|
||||
operationId: 'safeOp',
|
||||
title: 'Safe operation',
|
||||
risk: 'R1',
|
||||
capability: 'command',
|
||||
batchable: false,
|
||||
parameterSchemaId: 'safe.params.v1',
|
||||
};
|
||||
const page = { items: [entry], page: { page: 1, pageSize: 25, total: 1 } };
|
||||
const preparation = {
|
||||
id: 'prep-1',
|
||||
status: 'prepared',
|
||||
operationId: 'safeOp',
|
||||
risk: 'R1',
|
||||
expiresAt: '2030-01-01T00:00:00.000Z',
|
||||
confirmationToken: 'opaque-secret',
|
||||
confirmationPrompt: 'Confirm safe operation',
|
||||
targetCount: 1,
|
||||
};
|
||||
const job = {
|
||||
id: 'job-1',
|
||||
operationId: 'safeOp',
|
||||
status: 'succeeded',
|
||||
rootJobId: 'job-1',
|
||||
items: [{ id: 'item-1', targetId: 'instance-1', state: 'succeeded' }],
|
||||
attempts: [
|
||||
{
|
||||
id: 'attempt-1',
|
||||
state: 'succeeded',
|
||||
startedAt: '2029-01-01T00:00:00.000Z',
|
||||
finishedAt: '2029-01-01T00:00:01.000Z',
|
||||
},
|
||||
],
|
||||
createdAt: '2029-01-01T00:00:00.000Z',
|
||||
};
|
||||
|
||||
describe('safe operation client', () => {
|
||||
it('loads a strictly validated catalog using same-origin credentials', async () => {
|
||||
const fetcher = vi.fn(async () => response(page));
|
||||
const client = createOperationClient(fetcher as typeof fetch);
|
||||
|
||||
await expect(client.list({ risk: 'R1', search: 'safe value' })).resolves.toEqual(page);
|
||||
expect(fetcher).toHaveBeenCalledWith('/api/v1/operations?risk=R1&search=safe+value', {
|
||||
method: 'GET',
|
||||
credentials: 'same-origin',
|
||||
headers: { accept: 'application/json' },
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ ...page, leaked: true },
|
||||
{ ...page, items: [{ ...entry, method: 'POST' }] },
|
||||
{ ...page, items: [{ ...entry, risk: 'R9' }] },
|
||||
{ ...page, page: { ...page.page, total: -1 } },
|
||||
])('rejects malformed or expanded catalog envelopes', async (body) => {
|
||||
const client = createOperationClient(vi.fn(async () => response(body)) as typeof fetch);
|
||||
await expect(client.list()).rejects.toThrow('Operation catalog response is invalid.');
|
||||
});
|
||||
|
||||
it('only prepares an operation from the fetched catalog and retains its token privately', async () => {
|
||||
const fetcher = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(response(page))
|
||||
.mockResolvedValueOnce(response(preparation));
|
||||
const client = createOperationClient(fetcher as typeof fetch);
|
||||
await client.list();
|
||||
|
||||
const result = await client.prepare({
|
||||
operationId: 'safeOp',
|
||||
targets: [{ instanceId: 'instance-1', revision: 2 }],
|
||||
parameters: { parameterSchemaId: 'safe.params.v1', fields: [] },
|
||||
});
|
||||
expect(result).toEqual({
|
||||
id: 'prep-1',
|
||||
status: 'prepared',
|
||||
operationId: 'safeOp',
|
||||
risk: 'R1',
|
||||
expiresAt: preparation.expiresAt,
|
||||
confirmationPrompt: 'Confirm safe operation',
|
||||
targetCount: 1,
|
||||
});
|
||||
expect(JSON.stringify(result)).not.toContain('opaque-secret');
|
||||
expect(fetcher.mock.calls[1]).toEqual([
|
||||
'/api/v1/operations/prepare',
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
credentials: 'same-origin',
|
||||
body: JSON.stringify({
|
||||
operationId: 'safeOp',
|
||||
targets: [{ instanceId: 'instance-1', revision: 2 }],
|
||||
parameters: { parameterSchemaId: 'safe.params.v1', fields: [] },
|
||||
}),
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it('refuses uncatalogued operations and mismatched schemas without a request', async () => {
|
||||
const fetcher = vi.fn(async () => response(page));
|
||||
const client = createOperationClient(fetcher as typeof fetch);
|
||||
await client.list();
|
||||
|
||||
await expect(
|
||||
client.prepare({
|
||||
operationId: 'hiddenOp',
|
||||
targets: [{ instanceId: 'i' }],
|
||||
parameters: { parameterSchemaId: 'hidden.v1', fields: [] },
|
||||
}),
|
||||
).rejects.toThrow('Operation is not present in the loaded catalog.');
|
||||
await expect(
|
||||
client.prepare({
|
||||
operationId: 'safeOp',
|
||||
targets: [{ instanceId: 'i' }],
|
||||
parameters: { parameterSchemaId: 'wrong', fields: [] },
|
||||
}),
|
||||
).rejects.toThrow('Operation parameter schema does not match the catalog.');
|
||||
expect(fetcher).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('executes with the in-memory token exactly once and validates public Job fields', async () => {
|
||||
const fetcher = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(response(page))
|
||||
.mockResolvedValueOnce(response(preparation))
|
||||
.mockResolvedValueOnce(response(job, { status: 202 }));
|
||||
const client = createOperationClient(fetcher as typeof fetch);
|
||||
await client.list();
|
||||
await client.prepare({
|
||||
operationId: 'safeOp',
|
||||
targets: [{ instanceId: 'instance-1' }],
|
||||
parameters: { parameterSchemaId: 'safe.params.v1', fields: [] },
|
||||
});
|
||||
|
||||
await expect(client.execute('prep-1')).resolves.toEqual(job);
|
||||
expect(JSON.parse(fetcher.mock.calls[2][1].body)).toEqual({
|
||||
preparationId: 'prep-1',
|
||||
confirmationToken: 'opaque-secret',
|
||||
});
|
||||
await expect(client.execute('prep-1')).rejects.toThrow(
|
||||
'No in-memory confirmation is available for this preparation.',
|
||||
);
|
||||
expect(fetcher).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it('rejects expanded preparation and Job payloads', async () => {
|
||||
const badPreparationClient = createOperationClient(
|
||||
vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(response(page))
|
||||
.mockResolvedValueOnce(
|
||||
response({ ...preparation, transportPath: '/private' }),
|
||||
) as typeof fetch,
|
||||
);
|
||||
await badPreparationClient.list();
|
||||
await expect(
|
||||
badPreparationClient.prepare({
|
||||
operationId: 'safeOp',
|
||||
targets: [{ instanceId: 'i' }],
|
||||
parameters: { parameterSchemaId: 'safe.params.v1', fields: [] },
|
||||
}),
|
||||
).rejects.toThrow('Operation preparation response is invalid.');
|
||||
|
||||
const badJobClient = createOperationClient(
|
||||
vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(response(page))
|
||||
.mockResolvedValueOnce(response(preparation))
|
||||
.mockResolvedValueOnce(
|
||||
response({ ...job, upstreamResponse: 'secret' }, { status: 202 }),
|
||||
) as typeof fetch,
|
||||
);
|
||||
await badJobClient.list();
|
||||
await badJobClient.prepare({
|
||||
operationId: 'safeOp',
|
||||
targets: [{ instanceId: 'i' }],
|
||||
parameters: { parameterSchemaId: 'safe.params.v1', fields: [] },
|
||||
});
|
||||
await expect(badJobClient.execute('prep-1')).rejects.toThrow(
|
||||
'Operation execution response is invalid.',
|
||||
);
|
||||
});
|
||||
|
||||
it('redacts Problem Details detail, validation messages, and unknown response fields', async () => {
|
||||
const problem = {
|
||||
type: 'https://example.invalid/problem',
|
||||
title: 'Bad Request',
|
||||
status: 400,
|
||||
detail: 'token=server-secret',
|
||||
code: 'VALIDATION_FAILED',
|
||||
requestId: 'request-1',
|
||||
validation: [{ field: 'parameters', code: 'INVALID', message: 'secret field value' }],
|
||||
debug: 'private stack',
|
||||
};
|
||||
const client = createOperationClient(
|
||||
vi.fn(async () =>
|
||||
response(problem, { status: 400, headers: { 'content-type': 'application/problem+json' } }),
|
||||
) as typeof fetch,
|
||||
);
|
||||
|
||||
const error = await client.list().catch((caught: unknown) => caught);
|
||||
expect(error).toBeInstanceOf(OperationClientError);
|
||||
expect(error).toMatchObject({ status: 400, code: 'VALIDATION_FAILED', requestId: 'request-1' });
|
||||
expect(JSON.stringify(error)).not.toContain('server-secret');
|
||||
expect(JSON.stringify(error)).not.toContain('secret field value');
|
||||
expect(JSON.stringify(error)).not.toContain('private stack');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,440 @@
|
||||
import type {
|
||||
OperationCatalogEntry,
|
||||
OperationPage,
|
||||
OperationPageQuery,
|
||||
PrepareOperationRequest,
|
||||
RiskLevel,
|
||||
} from '@multi-simadmin/contracts';
|
||||
|
||||
type OperationCatalogPage = OperationPage;
|
||||
type OperationCatalogQuery = OperationPageQuery;
|
||||
type PrepareOperationInput = PrepareOperationRequest;
|
||||
export type OperationCapability = OperationCatalogEntry['capability'];
|
||||
export type {
|
||||
OperationCatalogEntry,
|
||||
OperationPage as OperationCatalogPage,
|
||||
OperationPageQuery as OperationCatalogQuery,
|
||||
PrepareOperationRequest as PrepareOperationInput,
|
||||
RiskLevel,
|
||||
};
|
||||
|
||||
/** The confirmation token is deliberately absent from this public view. */
|
||||
export interface PreparedOperation {
|
||||
readonly id: string;
|
||||
readonly status: 'prepared';
|
||||
readonly operationId: string;
|
||||
readonly risk: RiskLevel;
|
||||
readonly expiresAt: string;
|
||||
readonly confirmationPrompt: string;
|
||||
readonly targetCount: number;
|
||||
}
|
||||
|
||||
export type JobStatus =
|
||||
| 'queued'
|
||||
| 'running'
|
||||
| 'cancelling'
|
||||
| 'succeeded'
|
||||
| 'partially-succeeded'
|
||||
| 'failed'
|
||||
| 'cancelled'
|
||||
| 'unknown-result';
|
||||
export type JobItemState = 'succeeded' | 'failed' | 'skipped' | 'cancelled' | 'unknown-result';
|
||||
export type AttemptState = 'running' | 'succeeded' | 'failed' | 'cancelled' | 'unknown-result';
|
||||
export interface OperationJob {
|
||||
readonly id: string;
|
||||
readonly operationId: string;
|
||||
readonly status: JobStatus;
|
||||
readonly retryOfJobId?: string;
|
||||
readonly rootJobId: string;
|
||||
readonly items: readonly Readonly<{
|
||||
id: string;
|
||||
targetId: string;
|
||||
state: JobItemState;
|
||||
sourceJobItemId?: string;
|
||||
error?: RedactedProblem;
|
||||
}>[];
|
||||
readonly attempts: readonly Readonly<{
|
||||
id: string;
|
||||
state: AttemptState;
|
||||
startedAt: string;
|
||||
finishedAt?: string;
|
||||
}>[];
|
||||
readonly createdAt: string;
|
||||
}
|
||||
|
||||
export interface RedactedProblem {
|
||||
readonly status: number;
|
||||
readonly code: string;
|
||||
readonly requestId: string;
|
||||
}
|
||||
|
||||
export class OperationClientError extends Error implements RedactedProblem {
|
||||
override readonly name = 'OperationClientError';
|
||||
constructor(
|
||||
readonly status: number,
|
||||
readonly code: string,
|
||||
readonly requestId: string,
|
||||
) {
|
||||
super(`Operation request failed (${status}, ${code}).`);
|
||||
}
|
||||
}
|
||||
|
||||
export interface OperationClient {
|
||||
list(query?: OperationCatalogQuery): Promise<OperationCatalogPage>;
|
||||
prepare(input: PrepareOperationInput): Promise<PreparedOperation>;
|
||||
execute(preparationId: string): Promise<OperationJob>;
|
||||
}
|
||||
|
||||
const risks = new Set(['R0', 'R1', 'R2', 'R3']);
|
||||
const capabilities = new Set(['query', 'command', 'job']);
|
||||
const preparationStatuses = new Set(['prepared', 'consumed', 'expired', 'invalidated']);
|
||||
const jobStatuses = new Set([
|
||||
'queued',
|
||||
'running',
|
||||
'cancelling',
|
||||
'succeeded',
|
||||
'partially-succeeded',
|
||||
'failed',
|
||||
'cancelled',
|
||||
'unknown-result',
|
||||
]);
|
||||
const itemStates = new Set(['succeeded', 'failed', 'skipped', 'cancelled', 'unknown-result']);
|
||||
const attemptStates = new Set(['running', 'succeeded', 'failed', 'cancelled', 'unknown-result']);
|
||||
|
||||
const record = (value: unknown): Record<string, unknown> | undefined =>
|
||||
typeof value === 'object' && value !== null && !Array.isArray(value)
|
||||
? (value as Record<string, unknown>)
|
||||
: undefined;
|
||||
const exactKeys = (
|
||||
value: Record<string, unknown>,
|
||||
required: readonly string[],
|
||||
optional: readonly string[] = [],
|
||||
) => {
|
||||
const keys = Object.keys(value);
|
||||
return (
|
||||
required.every((key) => key in value) &&
|
||||
keys.every((key) => required.includes(key) || optional.includes(key))
|
||||
);
|
||||
};
|
||||
const string = (value: unknown): value is string => typeof value === 'string' && value.length > 0;
|
||||
const integer = (value: unknown): value is number =>
|
||||
Number.isSafeInteger(value) && (value as number) >= 0;
|
||||
const timestamp = (value: unknown): value is string =>
|
||||
string(value) &&
|
||||
/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})$/u.test(value) &&
|
||||
Number.isFinite(Date.parse(value));
|
||||
|
||||
function safePrepareInput(value: PrepareOperationInput): PrepareOperationInput | undefined {
|
||||
const input = record(value);
|
||||
const parameters = record(input?.parameters);
|
||||
if (
|
||||
!input ||
|
||||
!exactKeys(input, ['operationId', 'targets', 'parameters']) ||
|
||||
!string(input.operationId) ||
|
||||
!Array.isArray(input.targets) ||
|
||||
input.targets.length < 1 ||
|
||||
!parameters ||
|
||||
!exactKeys(parameters, ['parameterSchemaId', 'fields']) ||
|
||||
!string(parameters.parameterSchemaId) ||
|
||||
!Array.isArray(parameters.fields)
|
||||
)
|
||||
return undefined;
|
||||
const targets = input.targets.map((value) => {
|
||||
const target = record(value);
|
||||
if (
|
||||
!target ||
|
||||
!exactKeys(target, ['instanceId'], ['revision']) ||
|
||||
!string(target.instanceId) ||
|
||||
(target.revision !== undefined && (!integer(target.revision) || target.revision < 1))
|
||||
)
|
||||
return undefined;
|
||||
return target;
|
||||
});
|
||||
const fields = parameters.fields.map((value) => {
|
||||
const field = record(value);
|
||||
if (
|
||||
!field ||
|
||||
!exactKeys(field, ['fieldId', 'kind', 'value']) ||
|
||||
!string(field.fieldId) ||
|
||||
!string(field.kind)
|
||||
)
|
||||
return undefined;
|
||||
const valid =
|
||||
(field.kind === 'string' && typeof field.value === 'string') ||
|
||||
(field.kind === 'number' &&
|
||||
typeof field.value === 'number' &&
|
||||
Number.isFinite(field.value)) ||
|
||||
(field.kind === 'boolean' && typeof field.value === 'boolean') ||
|
||||
(field.kind === 'string-list' &&
|
||||
Array.isArray(field.value) &&
|
||||
field.value.every((item) => typeof item === 'string')) ||
|
||||
(field.kind === 'number-list' &&
|
||||
Array.isArray(field.value) &&
|
||||
field.value.every((item) => typeof item === 'number' && Number.isFinite(item))) ||
|
||||
(field.kind === 'null' && field.value === null);
|
||||
return valid ? field : undefined;
|
||||
});
|
||||
if (targets.some((target) => !target) || fields.some((field) => !field)) return undefined;
|
||||
return {
|
||||
operationId: input.operationId,
|
||||
targets: targets as unknown as PrepareOperationInput['targets'],
|
||||
parameters: {
|
||||
parameterSchemaId: parameters.parameterSchemaId,
|
||||
fields: fields as unknown as PrepareOperationInput['parameters']['fields'],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function parseProblem(value: unknown): RedactedProblem | undefined {
|
||||
const body = record(value);
|
||||
if (!body || !integer(body.status) || !string(body.code) || !string(body.requestId))
|
||||
return undefined;
|
||||
return { status: body.status, code: body.code, requestId: body.requestId };
|
||||
}
|
||||
|
||||
function parseCatalog(value: unknown): OperationCatalogPage | undefined {
|
||||
const body = record(value);
|
||||
if (!body || !exactKeys(body, ['items', 'page']) || !Array.isArray(body.items)) return undefined;
|
||||
const metadata = record(body.page);
|
||||
if (
|
||||
!metadata ||
|
||||
!exactKeys(metadata, ['page', 'pageSize', 'total']) ||
|
||||
!integer(metadata.page) ||
|
||||
metadata.page < 1 ||
|
||||
!integer(metadata.pageSize) ||
|
||||
metadata.pageSize < 1 ||
|
||||
!integer(metadata.total)
|
||||
)
|
||||
return undefined;
|
||||
const items: OperationCatalogEntry[] = [];
|
||||
for (const itemValue of body.items) {
|
||||
const item = record(itemValue);
|
||||
if (
|
||||
!item ||
|
||||
!exactKeys(item, [
|
||||
'operationId',
|
||||
'title',
|
||||
'risk',
|
||||
'capability',
|
||||
'batchable',
|
||||
'parameterSchemaId',
|
||||
]) ||
|
||||
!string(item.operationId) ||
|
||||
!string(item.title) ||
|
||||
!risks.has(item.risk as string) ||
|
||||
!capabilities.has(item.capability as string) ||
|
||||
typeof item.batchable !== 'boolean' ||
|
||||
!string(item.parameterSchemaId)
|
||||
)
|
||||
return undefined;
|
||||
items.push(item as unknown as OperationCatalogEntry);
|
||||
}
|
||||
return { items, page: metadata as unknown as OperationCatalogPage['page'] };
|
||||
}
|
||||
|
||||
interface PrivatePreparation extends PreparedOperation {
|
||||
readonly confirmationToken: string;
|
||||
}
|
||||
function parsePreparation(value: unknown): PrivatePreparation | undefined {
|
||||
const body = record(value);
|
||||
if (
|
||||
!body ||
|
||||
!exactKeys(body, [
|
||||
'id',
|
||||
'status',
|
||||
'operationId',
|
||||
'risk',
|
||||
'expiresAt',
|
||||
'confirmationToken',
|
||||
'confirmationPrompt',
|
||||
'targetCount',
|
||||
]) ||
|
||||
!string(body.id) ||
|
||||
!preparationStatuses.has(body.status as string) ||
|
||||
body.status !== 'prepared' ||
|
||||
!string(body.operationId) ||
|
||||
!risks.has(body.risk as string) ||
|
||||
!timestamp(body.expiresAt) ||
|
||||
!string(body.confirmationToken) ||
|
||||
!string(body.confirmationPrompt) ||
|
||||
!integer(body.targetCount)
|
||||
)
|
||||
return undefined;
|
||||
return body as unknown as PrivatePreparation;
|
||||
}
|
||||
|
||||
function parseNestedProblem(value: unknown): RedactedProblem | undefined {
|
||||
const body = record(value);
|
||||
if (
|
||||
!body ||
|
||||
!exactKeys(body, ['type', 'title', 'status', 'detail', 'code', 'requestId'], ['validation'])
|
||||
)
|
||||
return undefined;
|
||||
return parseProblem(body);
|
||||
}
|
||||
|
||||
function parseJob(value: unknown): OperationJob | undefined {
|
||||
const body = record(value);
|
||||
if (
|
||||
!body ||
|
||||
!exactKeys(
|
||||
body,
|
||||
['id', 'operationId', 'status', 'rootJobId', 'items', 'attempts', 'createdAt'],
|
||||
['retryOfJobId'],
|
||||
) ||
|
||||
!string(body.id) ||
|
||||
!string(body.operationId) ||
|
||||
!jobStatuses.has(body.status as string) ||
|
||||
!string(body.rootJobId) ||
|
||||
(body.retryOfJobId !== undefined && !string(body.retryOfJobId)) ||
|
||||
!Array.isArray(body.items) ||
|
||||
!Array.isArray(body.attempts) ||
|
||||
!timestamp(body.createdAt)
|
||||
)
|
||||
return undefined;
|
||||
const items = body.items.map((value) => {
|
||||
const item = record(value);
|
||||
if (
|
||||
!item ||
|
||||
!exactKeys(item, ['id', 'targetId', 'state'], ['sourceJobItemId', 'error']) ||
|
||||
!string(item.id) ||
|
||||
!string(item.targetId) ||
|
||||
!itemStates.has(item.state as string) ||
|
||||
(item.sourceJobItemId !== undefined && !string(item.sourceJobItemId))
|
||||
)
|
||||
return undefined;
|
||||
const error = item.error === undefined ? undefined : parseNestedProblem(item.error);
|
||||
if (item.error !== undefined && !error) return undefined;
|
||||
return { ...item, ...(error ? { error } : {}) };
|
||||
});
|
||||
const attempts = body.attempts.map((value) => {
|
||||
const attempt = record(value);
|
||||
if (
|
||||
!attempt ||
|
||||
!exactKeys(attempt, ['id', 'state', 'startedAt'], ['finishedAt']) ||
|
||||
!string(attempt.id) ||
|
||||
!attemptStates.has(attempt.state as string) ||
|
||||
!timestamp(attempt.startedAt) ||
|
||||
(attempt.finishedAt !== undefined && !timestamp(attempt.finishedAt))
|
||||
)
|
||||
return undefined;
|
||||
return attempt;
|
||||
});
|
||||
if (items.some((item) => !item) || attempts.some((attempt) => !attempt)) return undefined;
|
||||
return { ...body, items, attempts } as unknown as OperationJob;
|
||||
}
|
||||
|
||||
async function jsonResponse(response: Response): Promise<unknown> {
|
||||
let body: unknown;
|
||||
try {
|
||||
body = await response.json();
|
||||
} catch {
|
||||
body = undefined;
|
||||
}
|
||||
if (!response.ok) {
|
||||
const problem = parseProblem(body);
|
||||
throw problem && problem.status === response.status
|
||||
? new OperationClientError(problem.status, problem.code, problem.requestId)
|
||||
: new OperationClientError(response.status, 'REQUEST_FAILED', 'unavailable');
|
||||
}
|
||||
return body;
|
||||
}
|
||||
|
||||
const post = (body: unknown): RequestInit => ({
|
||||
method: 'POST',
|
||||
credentials: 'same-origin',
|
||||
headers: { accept: 'application/json', 'content-type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
function queryString(query: OperationCatalogQuery): string {
|
||||
const params = new URLSearchParams();
|
||||
for (const key of [
|
||||
'page',
|
||||
'pageSize',
|
||||
'sort',
|
||||
'direction',
|
||||
'risk',
|
||||
'capability',
|
||||
'batchable',
|
||||
'search',
|
||||
] as const) {
|
||||
const value = query[key];
|
||||
if (value !== undefined) params.set(key, String(value));
|
||||
}
|
||||
const encoded = params.toString();
|
||||
return encoded ? `?${encoded}` : '';
|
||||
}
|
||||
|
||||
export function createOperationClient(fetcher: typeof fetch = fetch): OperationClient {
|
||||
const catalog = new Map<string, OperationCatalogEntry>();
|
||||
const confirmations = new Map<string, Readonly<{ token: string; operationId: string }>>();
|
||||
return {
|
||||
async list(query = {}) {
|
||||
const body = await jsonResponse(
|
||||
await fetcher(`/api/v1/operations${queryString(query)}`, {
|
||||
method: 'GET',
|
||||
credentials: 'same-origin',
|
||||
headers: { accept: 'application/json' },
|
||||
}),
|
||||
);
|
||||
const parsed = parseCatalog(body);
|
||||
if (!parsed) throw new Error('Operation catalog response is invalid.');
|
||||
catalog.clear();
|
||||
for (const item of parsed.items) catalog.set(item.operationId, item);
|
||||
return parsed;
|
||||
},
|
||||
async prepare(input) {
|
||||
const safeInput = safePrepareInput(input);
|
||||
if (!safeInput) throw new Error('Operation preparation request is invalid.');
|
||||
const allowed = catalog.get(safeInput.operationId);
|
||||
if (!allowed) throw new Error('Operation is not present in the loaded catalog.');
|
||||
if (allowed.parameterSchemaId !== safeInput.parameters.parameterSchemaId)
|
||||
throw new Error('Operation parameter schema does not match the catalog.');
|
||||
const parsed = parsePreparation(
|
||||
await jsonResponse(await fetcher('/api/v1/operations/prepare', post(safeInput))),
|
||||
);
|
||||
if (
|
||||
!parsed ||
|
||||
parsed.operationId !== safeInput.operationId ||
|
||||
parsed.risk !== allowed.risk ||
|
||||
parsed.targetCount !== safeInput.targets.length
|
||||
)
|
||||
throw new Error('Operation preparation response is invalid.');
|
||||
confirmations.set(parsed.id, {
|
||||
token: parsed.confirmationToken,
|
||||
operationId: parsed.operationId,
|
||||
});
|
||||
const publicPreparation: PreparedOperation = {
|
||||
id: parsed.id,
|
||||
status: parsed.status,
|
||||
operationId: parsed.operationId,
|
||||
risk: parsed.risk,
|
||||
expiresAt: parsed.expiresAt,
|
||||
confirmationPrompt: parsed.confirmationPrompt,
|
||||
targetCount: parsed.targetCount,
|
||||
};
|
||||
return publicPreparation;
|
||||
},
|
||||
async execute(preparationId) {
|
||||
const confirmation = confirmations.get(preparationId);
|
||||
if (!confirmation)
|
||||
throw new Error('No in-memory confirmation is available for this preparation.');
|
||||
confirmations.delete(preparationId);
|
||||
const parsed = parseJob(
|
||||
await jsonResponse(
|
||||
await fetcher(
|
||||
'/api/v1/operations/execute',
|
||||
post({
|
||||
preparationId,
|
||||
confirmationToken: confirmation.token,
|
||||
}),
|
||||
),
|
||||
),
|
||||
);
|
||||
if (!parsed || parsed.operationId !== confirmation.operationId)
|
||||
throw new Error('Operation execution response is invalid.');
|
||||
return parsed;
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user