import { ATTEMPT_STATUSES, JOB_ITEM_TERMINAL_STATES, JOB_STATUSES, MAX_PAGE_SIZE, SORT_DIRECTIONS, type Attempt, type Job, type JobItem, type JobPage, type JobPageQuery, type ProblemDetails, type ValidationIssue, } from '@multi-simadmin/contracts'; import type { JobsDataSource } from './jobs-page.js'; const MAX_STRING_LENGTH = 256; const QUERY_KEYS = [ 'page', 'pageSize', 'sort', 'direction', 'status', 'operationId', 'rootJobId', 'instanceId', ] as const; const ITEM_KEYS = ['id', 'targetId', 'state', 'sourceJobItemId', 'error'] as const; const ATTEMPT_KEYS = ['id', 'state', 'startedAt', 'finishedAt'] as const; const PROBLEM_KEYS = [ 'type', 'title', 'status', 'detail', 'code', 'requestId', 'validation', ] as const; function record(value: unknown): Record | null { return value !== null && typeof value === 'object' && !Array.isArray(value) ? (value as Record) : null; } function hasExactKeys( value: Record, required: readonly string[], optional: readonly string[] = [], ): boolean { const keys = Object.keys(value); const allowed = new Set([...required, ...optional]); return ( required.every((key) => Object.hasOwn(value, key)) && keys.every((key) => allowed.has(key)) ); } function boundedString(value: unknown): value is string { return typeof value === 'string' && value.length > 0 && value.length <= MAX_STRING_LENGTH; } function canonicalTimestamp(value: unknown): value is string { if (!boundedString(value)) return false; const time = Date.parse(value); return Number.isFinite(time) && new Date(time).toISOString() === value; } function safePositiveInteger(value: unknown, maximum = Number.MAX_SAFE_INTEGER): value is number { return Number.isSafeInteger(value) && (value as number) >= 1 && (value as number) <= maximum; } function parseValidationIssue(value: unknown): ValidationIssue | null { const source = record(value); if ( !source || !hasExactKeys(source, ['field', 'code', 'message']) || !boundedString(source.field) || !boundedString(source.code) || !boundedString(source.message) ) return null; return { field: source.field, code: source.code, message: source.message }; } function parseProblem(value: unknown): ProblemDetails | null { const source = record(value); if ( !source || !hasExactKeys(source, PROBLEM_KEYS.slice(0, 6), ['validation']) || !boundedString(source.type) || !boundedString(source.title) || !Number.isSafeInteger(source.status) || (source.status as number) < 400 || (source.status as number) > 599 || !boundedString(source.detail) || !boundedString(source.code) || !boundedString(source.requestId) ) return null; let validation: ValidationIssue[] | undefined; if (source.validation !== undefined) { if (!Array.isArray(source.validation) || source.validation.length > MAX_PAGE_SIZE) return null; validation = []; for (const candidate of source.validation) { const issue = parseValidationIssue(candidate); if (!issue) return null; validation.push(issue); } } return { type: source.type, title: source.title, status: source.status as number, detail: source.detail, code: source.code, requestId: source.requestId, ...(validation === undefined ? {} : { validation }), }; } function parseItem(value: unknown): JobItem | null { const source = record(value); if ( !source || !hasExactKeys(source, ITEM_KEYS.slice(0, 3), ['sourceJobItemId', 'error']) || !boundedString(source.id) || !boundedString(source.targetId) || typeof source.state !== 'string' || !(JOB_ITEM_TERMINAL_STATES as readonly string[]).includes(source.state) ) return null; const sourceJobItemId = source.sourceJobItemId; if (sourceJobItemId !== undefined && !boundedString(sourceJobItemId)) return null; const error = source.error === undefined ? undefined : parseProblem(source.error); if (source.error !== undefined && !error) return null; const result: JobItem = { id: source.id, targetId: source.targetId, state: source.state as JobItem['state'], ...(sourceJobItemId === undefined ? {} : { sourceJobItemId }), }; return error ? { ...result, error } : result; } function parseAttempt(value: unknown): Attempt | null { const source = record(value); if ( !source || !hasExactKeys(source, ATTEMPT_KEYS.slice(0, 3), ['finishedAt']) || !boundedString(source.id) || typeof source.state !== 'string' || !(ATTEMPT_STATUSES as readonly string[]).includes(source.state) || !canonicalTimestamp(source.startedAt) || (source.finishedAt !== undefined && !canonicalTimestamp(source.finishedAt)) ) return null; return { id: source.id, state: source.state as Attempt['state'], startedAt: source.startedAt, ...(source.finishedAt === undefined ? {} : { finishedAt: source.finishedAt as string }), }; } function parseJob(value: unknown): Job | null { const source = record(value); if ( !source || !hasExactKeys( source, ['id', 'operationId', 'status', 'rootJobId', 'items', 'attempts', 'createdAt'], ['retryOfJobId'], ) || !boundedString(source.id) || !boundedString(source.operationId) || typeof source.status !== 'string' || !(JOB_STATUSES as readonly string[]).includes(source.status) || !boundedString(source.rootJobId) || !Array.isArray(source.items) || source.items.length > MAX_PAGE_SIZE || !Array.isArray(source.attempts) || source.attempts.length > MAX_PAGE_SIZE || !canonicalTimestamp(source.createdAt) || (source.retryOfJobId !== undefined && !boundedString(source.retryOfJobId)) ) return null; const items: JobItem[] = []; for (const candidate of source.items) { const item = parseItem(candidate); if (!item) return null; items.push(item); } const attempts: Attempt[] = []; for (const candidate of source.attempts) { const attempt = parseAttempt(candidate); if (!attempt) return null; attempts.push(attempt); } return { id: source.id, operationId: source.operationId, status: source.status as Job['status'], ...(source.retryOfJobId === undefined ? {} : { retryOfJobId: source.retryOfJobId as string }), rootJobId: source.rootJobId, items, attempts, createdAt: source.createdAt, }; } function parseJobPage(value: unknown): JobPage { const source = record(value); if (!source || !hasExactKeys(source, ['items', 'page']) || !Array.isArray(source.items)) throw new Error('Jobs response is invalid.'); const page = record(source.page); if ( !page || !hasExactKeys(page, ['page', 'pageSize', 'total']) || !safePositiveInteger(page.page) || !safePositiveInteger(page.pageSize, MAX_PAGE_SIZE) || !Number.isSafeInteger(page.total) || (page.total as number) < 0 || source.items.length > MAX_PAGE_SIZE || source.items.length > (page.pageSize as number) || source.items.length > (page.total as number) ) throw new Error('Jobs response is invalid.'); const items: Job[] = []; for (const candidate of source.items) { const item = parseJob(candidate); if (!item) throw new Error('Jobs response is invalid.'); items.push(item); } return { items, page: { page: page.page as number, pageSize: page.pageSize as number, total: page.total as number, }, }; } function queryString(query: JobPageQuery): string { if (Object.keys(query).some((key) => !(QUERY_KEYS as readonly string[]).includes(key))) throw new Error('Jobs query is invalid.'); const params = new URLSearchParams(); for (const key of QUERY_KEYS) { const value = query[key]; if (value === undefined) continue; const valid = (key === 'page' && safePositiveInteger(value)) || (key === 'pageSize' && safePositiveInteger(value, MAX_PAGE_SIZE)) || (key === 'sort' && ['createdAt', 'status', 'operationId'].includes(value as string)) || (key === 'direction' && (SORT_DIRECTIONS as readonly unknown[]).includes(value)) || (key === 'status' && (JOB_STATUSES as readonly unknown[]).includes(value)) || (['operationId', 'rootJobId', 'instanceId'].includes(key) && boundedString(value)); if (!valid) throw new Error('Jobs query is invalid.'); params.set(key, String(value)); } const serialized = params.toString(); return serialized ? `?${serialized}` : ''; } export class JobsApiError extends Error { readonly status: number; readonly code: string; readonly requestId: string; constructor(status: number, code: string, requestId: string) { super(`Jobs request failed (${status}; code=${code}; requestId=${requestId}).`); this.name = 'JobsApiError'; this.status = status; this.code = code; this.requestId = requestId; } } async function responseError(response: Response): Promise { let code = 'UNKNOWN'; let requestId = 'unavailable'; try { const problem = parseProblem(await response.json()); if (problem && problem.status === response.status) { code = problem.code; requestId = problem.requestId; } } catch { // Never read or expose raw response text or Problem detail/title. } return new JobsApiError(response.status, code, requestId); } export function createJobsApiDataSource(fetcher: typeof fetch = fetch): JobsDataSource { return { async load(query, signal): Promise { const response = await fetcher(`/api/v1/jobs${queryString(query)}`, { method: 'GET', credentials: 'same-origin', headers: { accept: 'application/json' }, signal, }); if (!response.ok) throw await responseError(response); let body: unknown; try { body = await response.json(); } catch { throw new Error('Jobs response is invalid.'); } return parseJobPage(body); }, }; }