diff --git a/apps/web/src/app-shell.tsx b/apps/web/src/app-shell.tsx index 4649c5f..e4c1648 100644 --- a/apps/web/src/app-shell.tsx +++ b/apps/web/src/app-shell.tsx @@ -7,6 +7,7 @@ import { createEventStreamClient, type EventStreamClient } from './events/event- import { FleetPage, type FleetDataSource, type FleetSnapshot } from './fleet/fleet-page.js'; import { createFleetApiDataSource } from './fleet/fleet-api-data-source.js'; import { InstanceEditor, type InstanceDataSource } from './instances/instance-crud.js'; +import { OverviewSystemPage, type OverviewDataSource } from './instances/overview-system.js'; import { InstanceDetail, INSTANCE_MODULE_LABELS, @@ -61,6 +62,7 @@ export interface AppShellProps { instanceDataSource?: InstanceDataSource; capabilities?: InstanceCapabilityMap; capabilityDataSource?: CapabilityDataSource; + overviewDataSource?: OverviewDataSource; eventStreamClient?: EventStreamClient; } @@ -128,7 +130,9 @@ function Page({ instanceDataSource, capabilities, capabilityDataSource, + overviewDataSource, fleetRefreshSignal, + detailRefreshSignal, }: { route: ResolvedRoute; fleetDataSource: FleetDataSource | undefined; @@ -137,7 +141,9 @@ function Page({ instanceDataSource: InstanceDataSource | undefined; capabilities: InstanceCapabilityMap | undefined; capabilityDataSource: CapabilityDataSource | undefined; + overviewDataSource: OverviewDataSource | undefined; fleetRefreshSignal: number; + detailRefreshSignal: number; }): ReactNode { if (route.kind === 'fleet') return ( @@ -179,6 +185,17 @@ function Page({ {...(instance ? { instance } : {})} {...(capabilities ? { capabilities } : {})} {...(capabilityDataSource ? { capabilityDataSource } : {})} + {...(module === 'overview' && instance + ? { + moduleContent: ( + + ), + } + : {})} /> ); } @@ -209,6 +226,7 @@ export function AppShell({ instanceDataSource, capabilities, capabilityDataSource, + overviewDataSource, eventStreamClient, }: AppShellProps) { const defaultEventStreamClient = useMemo(() => createEventStreamClient(), []); @@ -261,7 +279,9 @@ export function AppShell({ instanceDataSource={instanceDataSource} capabilities={capabilities} capabilityDataSource={capabilityDataSource} + overviewDataSource={overviewDataSource} fleetRefreshSignal={refresh.fleet} + detailRefreshSignal={refresh.detail} /> diff --git a/apps/web/src/index.ts b/apps/web/src/index.ts index 9ea447b..347bfc4 100644 --- a/apps/web/src/index.ts +++ b/apps/web/src/index.ts @@ -53,4 +53,24 @@ export { type InvalidationBatch, } from './events/event-invalidation-controller.js'; +export { + OverviewSystemPage, + type OverviewDataSource, + type OverviewFieldValue, + type OverviewSection, + type OverviewSnapshot, + type OverviewSystemPageProps, +} from './instances/overview-system.js'; +export { + createOperationClient, + OperationClientError, + type OperationCatalogEntry, + type OperationCatalogPage, + type OperationCatalogQuery, + type OperationClient, + type OperationJob, + type PrepareOperationInput, + type PreparedOperation, +} from './operations/operation-client.js'; + export const webWorkspaceReady = true; diff --git a/apps/web/src/instances/instance-detail.tsx b/apps/web/src/instances/instance-detail.tsx index fd30153..543335e 100644 --- a/apps/web/src/instances/instance-detail.tsx +++ b/apps/web/src/instances/instance-detail.tsx @@ -1,3 +1,4 @@ +import type { ReactNode } from 'react'; import { useEffect, useRef, useState } from 'react'; import type { InstanceContext, InstanceModule } from '../app-shell.js'; @@ -22,6 +23,7 @@ export interface InstanceDetailProps { readonly instance?: InstanceContext; readonly capabilities?: InstanceCapabilityMap; readonly capabilityDataSource?: CapabilityDataSource; + readonly moduleContent?: ReactNode; } export const INSTANCE_MODULE_LABELS: Readonly> = { @@ -48,6 +50,10 @@ function capabilityFor(map: InstanceCapabilityMap, module: InstanceModule): Inst return map[module] ?? { state: 'unknown' }; } +function canRender(capability: InstanceCapability): boolean { + return capability.state === 'supported' || capability.state === 'degraded'; +} + function explanation(capability: InstanceCapability): string | null { if (capability.state === 'supported') return null; return capability.explanation?.trim() || DEFAULT_EXPLANATIONS[capability.state]; @@ -63,6 +69,7 @@ export function InstanceDetail({ instance, capabilities, capabilityDataSource, + moduleContent, }: InstanceDetailProps) { const ownsRoute = instance?.id === instanceId; const [loadedCapabilities, setLoadedCapabilities] = useState(); @@ -90,8 +97,9 @@ export function InstanceDetail({ } }, (error: unknown) => { + void error; if (!controller.signal.aborted && requestRef.current === request) { - setLoadError(error instanceof Error ? error.message : 'Capability discovery failed.'); + setLoadError('Capability discovery failed.'); setLoading(false); } }, @@ -166,12 +174,20 @@ export function InstanceDetail({

{INSTANCE_MODULE_LABELS[module]}

{loading ?

Loading capabilities…

: null} {loadError ?

Capabilities unavailable: {loadError}

: null} - {!loading && activeCapability.state === 'supported' ? ( -

- Inspect {INSTANCE_MODULE_LABELS[module].toLowerCase()} data and available operations. -

+ {!loading && canRender(activeCapability) ? ( + <> + {activeCapability.state === 'degraded' ? ( +

{explanation(activeCapability)}

+ ) : null} + {moduleContent ?? ( +

+ Inspect {INSTANCE_MODULE_LABELS[module].toLowerCase()} data and available + operations. +

+ )} + ) : null} - {!loading && activeCapability.state !== 'supported' ? ( + {!loading && !canRender(activeCapability) ? (

{explanation(activeCapability)}

) : null} diff --git a/apps/web/src/instances/overview-system.test.tsx b/apps/web/src/instances/overview-system.test.tsx new file mode 100644 index 0000000..f73d564 --- /dev/null +++ b/apps/web/src/instances/overview-system.test.tsx @@ -0,0 +1,203 @@ +// @vitest-environment jsdom +import { cleanup, render, screen, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { AppShell, type InstanceContext } from '../app-shell.js'; +import { + OverviewSystemPage, + type OverviewDataSource, + type OverviewSnapshot, +} from './overview-system.js'; +import type { InstanceCapabilityMap } from './instance-detail.js'; + +afterEach(cleanup); + +const owner: InstanceContext = { + id: 'alpha', + name: 'Alpha', + origin: 'https://alpha.example', + status: 'online', + authentication: 'authenticated', + freshness: 'fresh', +}; +const overviewCapability: InstanceCapabilityMap = { + overview: { state: 'supported' }, +}; +const snapshot: OverviewSnapshot = { + observedAt: '2026-07-17T10:00:00Z', + device: { Model: 'SIMBox 8', Uptime: '2 days' }, + sim: { Slots: 8, Active: 6 }, + network: { Operator: 'Example Mobile', Registration: 'registered' }, + stats: { 'Messages today': 42, Calls: 7 }, + cpu: { Usage: '18%', Temperature: '46 °C' }, + connectivity: { State: 'connected', Latency: '21 ms' }, +}; + +function deferredSource() { + let resolve!: (value: OverviewSnapshot) => void; + let reject!: (reason: unknown) => void; + const load = vi.fn( + (_instanceId, _signal) => + new Promise((done, fail) => { + void _instanceId; + void _signal; + resolve = done; + reject = fail; + }), + ); + return { + source: { load }, + load, + resolve: (value: OverviewSnapshot) => resolve(value), + reject: (reason: unknown) => reject(reason), + }; +} + +describe('Phase 6.1 Overview / System read slice', () => { + it('loads only through an injected source and renders six structured sections', async () => { + const pending = deferredSource(); + render( + , + ); + + expect(screen.getByRole('status', { name: 'Overview loading status' }).textContent).toContain( + 'Loading overview', + ); + expect(pending.load).toHaveBeenCalledWith('alpha', expect.any(AbortSignal)); + pending.resolve(snapshot); + + for (const heading of ['Device', 'SIM', 'Network', 'Statistics', 'CPU', 'Connectivity']) { + expect(await screen.findByRole('heading', { name: heading })).toBeTruthy(); + } + expect( + within(screen.getByRole('region', { name: 'Device' })).getByText('SIMBox 8'), + ).toBeTruthy(); + expect(screen.getByText(/Observed 2026-07-17T10:00:00Z/)).toBeTruthy(); + expect(screen.queryByRole('button', { name: /restart/i })).toBeNull(); + expect(screen.getByText(/Restart is unavailable.*R3/i)).toBeTruthy(); + }); + + it('is honest when no safe read source is injected and never attempts a production endpoint', () => { + render( + , + ); + expect(screen.getByRole('status', { name: 'Overview unavailable' }).textContent).toMatch( + /no safe overview read data source/i, + ); + expect(screen.queryByRole('button', { name: /restart/i })).toBeNull(); + }); + + it('shows authentication-required without loading or leaking prior owner data', async () => { + const load = vi.fn().mockResolvedValue(snapshot); + const { rerender } = render(); + expect(await screen.findByText('SIMBox 8')).toBeTruthy(); + + rerender( + , + ); + expect(screen.getByRole('alert').textContent).toMatch(/authentication is required/i); + expect(screen.queryByText('SIMBox 8')).toBeNull(); + expect(load).toHaveBeenCalledTimes(1); + }); + + it('marks injected data stale from owner context while retaining structured values', async () => { + render( + snapshot }} + />, + ); + expect((await screen.findByRole('status', { name: 'Overview freshness' })).textContent).toMatch( + /stale/i, + ); + expect(screen.getByText('SIMBox 8')).toBeTruthy(); + }); + + it('surfaces read errors and supports a real injected retry', async () => { + const user = userEvent.setup(); + const load = vi + .fn() + .mockRejectedValueOnce(new Error('collector unavailable')) + .mockResolvedValueOnce(snapshot); + render(); + + expect((await screen.findByRole('alert')).textContent).toContain( + 'Overview data could not be loaded.', + ); + await user.click(screen.getByRole('button', { name: 'Retry loading overview' })); + expect(await screen.findByText('SIMBox 8')).toBeTruthy(); + expect(load).toHaveBeenCalledTimes(2); + }); + + it('aborts old reads, fences late owners, and reloads on refreshSignal', async () => { + const alpha = deferredSource(); + const bravo = deferredSource(); + const load = vi.fn((instanceId, signal) => + instanceId === 'alpha' + ? alpha.source.load(instanceId, signal) + : bravo.source.load(instanceId, signal), + ); + const source = { load }; + const { rerender } = render( + , + ); + const alphaSignal = load.mock.calls[0]?.[1]; + + rerender( + , + ); + expect(alphaSignal?.aborted).toBe(true); + alpha.resolve({ ...snapshot, device: { Model: 'Alpha stale' } }); + bravo.resolve({ ...snapshot, device: { Model: 'Bravo current' } }); + expect(await screen.findByText('Bravo current')).toBeTruthy(); + expect(screen.queryByText('Alpha stale')).toBeNull(); + + rerender( + , + ); + expect(load).toHaveBeenCalledTimes(3); + }); + + it('keeps unsupported capability explicit and does not invoke the overview source', () => { + const load = vi.fn(); + render( + , + ); + expect(screen.getAllByText('No overview read support').length).toBeGreaterThan(0); + expect(load).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/web/src/instances/overview-system.tsx b/apps/web/src/instances/overview-system.tsx new file mode 100644 index 0000000..3d4e9b7 --- /dev/null +++ b/apps/web/src/instances/overview-system.tsx @@ -0,0 +1,185 @@ +import { useEffect, useRef, useState } from 'react'; + +import type { InstanceContext } from '../app-shell.js'; + +export type OverviewFieldValue = string | number | boolean | null; +export type OverviewSection = Readonly>; + +export interface OverviewSnapshot { + readonly observedAt?: string; + readonly device: OverviewSection; + readonly sim: OverviewSection; + readonly network: OverviewSection; + readonly stats: OverviewSection; + readonly cpu: OverviewSection; + readonly connectivity: OverviewSection; +} + +export interface OverviewDataSource { + /** Implementations are injected by the application owner; this component has no production endpoint. */ + load(instanceId: string, signal: AbortSignal): Promise; +} + +export interface OverviewSystemPageProps { + readonly instance: InstanceContext; + readonly dataSource?: OverviewDataSource; + /** Change this value when an external owner has requested a refresh. */ + readonly refreshSignal?: unknown; +} + +type ReadState = + | { kind: 'idle' } + | { kind: 'loading'; snapshot?: OverviewSnapshot } + | { kind: 'ready'; snapshot: OverviewSnapshot } + | { kind: 'error'; message: string; snapshot?: OverviewSnapshot }; + +const SECTIONS = [ + ['device', 'Device'], + ['sim', 'SIM'], + ['network', 'Network'], + ['stats', 'Statistics'], + ['cpu', 'CPU'], + ['connectivity', 'Connectivity'], +] as const; + +function StructuredSection({ label, values }: { label: string; values: OverviewSection }) { + const entries = Object.entries(values); + return ( +
+

{label}

+ {entries.length ? ( +
+ {entries.map(([key, value]) => ( +
+
{key}
+
{value == null ? 'Unavailable' : String(value)}
+
+ ))} +
+ ) : ( +

No {label.toLocaleLowerCase()} data was supplied.

+ )} +
+ ); +} + +export function OverviewSystemPage({ + instance, + dataSource, + refreshSignal, +}: OverviewSystemPageProps) { + const requestOwner = useRef(0); + const [retry, setRetry] = useState(0); + const [state, setState] = useState({ kind: 'idle' }); + + useEffect(() => { + const request = ++requestOwner.current; + const controller = new AbortController(); + + if (instance.authentication === 'auth-required') { + setState({ kind: 'idle' }); + return () => controller.abort(); + } + if (!dataSource) { + setState({ kind: 'idle' }); + return () => controller.abort(); + } + + setState((current) => ({ + kind: 'loading', + ...(current.kind === 'ready' || current.kind === 'error' + ? current.snapshot + ? { snapshot: current.snapshot } + : {} + : {}), + })); + void dataSource.load(instance.id, controller.signal).then( + (snapshot) => { + if (request === requestOwner.current && !controller.signal.aborted) + setState({ kind: 'ready', snapshot }); + }, + (reason: unknown) => { + void reason; + if (request === requestOwner.current && !controller.signal.aborted) + setState((current) => ({ + kind: 'error', + message: 'Overview data could not be loaded.', + ...(current.kind === 'loading' && current.snapshot + ? { snapshot: current.snapshot } + : {}), + })); + }, + ); + return () => controller.abort(); + }, [dataSource, instance.authentication, instance.id, refreshSignal, retry]); + + if (instance.authentication === 'auth-required') + return ( +
+ Authentication is required before overview data can be read for this instance. +
+ ); + + if (!dataSource) + return ( +
+ No safe overview read data source is available. This console will not invent or call an + uncontracted production endpoint. +
+ ); + + const retainedSnapshot = + state.kind === 'loading' || state.kind === 'error' ? state.snapshot : undefined; + + if ((state.kind === 'loading' || state.kind === 'idle') && !retainedSnapshot) + return ( +

+ Loading overview… +

+ ); + + if (state.kind === 'error' && !state.snapshot) + return ( +
+

Unable to load overview: {state.message}

+ +
+ ); + + const snapshot = state.kind === 'ready' ? state.snapshot : retainedSnapshot; + if (!snapshot) return null; + + return ( +
+ {state.kind === 'loading' ?

Refreshing overview…

: null} + {state.kind === 'error' ? ( +
+

Refresh failed; showing the last known overview.

+ +
+ ) : null} + {instance.freshness !== 'fresh' ? ( +

+ Overview data is {instance.freshness}; verify freshness before relying on these values. +

+ ) : null} + {snapshot.observedAt ?

Observed {snapshot.observedAt}

: null} +
+ {SECTIONS.map(([key, label]) => ( + + ))} +
+
+

System actions

+

+ Restart is unavailable because the backend R3 restart operation is not implemented. No + action is offered. +

+
+
+ ); +} diff --git a/apps/web/src/operations/operation-client.test.ts b/apps/web/src/operations/operation-client.test.ts new file mode 100644 index 0000000..3140c2e --- /dev/null +++ b/apps/web/src/operations/operation-client.test.ts @@ -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'); + }); +}); diff --git a/apps/web/src/operations/operation-client.ts b/apps/web/src/operations/operation-client.ts new file mode 100644 index 0000000..a84b63e --- /dev/null +++ b/apps/web/src/operations/operation-client.ts @@ -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; + prepare(input: PrepareOperationInput): Promise; + execute(preparationId: string): Promise; +} + +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 | undefined => + typeof value === 'object' && value !== null && !Array.isArray(value) + ? (value as Record) + : undefined; +const exactKeys = ( + value: Record, + 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 { + 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(); + const confirmations = new Map>(); + 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; + }, + }; +}