export type IdentityStatus = 'confirmed' | 'pending'; export type IdentityReason = 'hardware_swapped' | 'imei_claimed'; export type IdentityField = 'imei' | 'manufacturer' | 'model' | 'revision'; export interface IdentityConflict { readonly reason: IdentityReason; readonly instanceIds: readonly string[]; readonly instanceNames: readonly string[]; } export interface IdentityChange { readonly field: IdentityField; readonly from: string; readonly to: string; } export interface DeviceIdentity { readonly instanceId: string; readonly instanceName: string; readonly status: IdentityStatus; readonly reasons: readonly IdentityReason[]; readonly conflicts: readonly IdentityConflict[]; readonly imei: string; readonly manufacturer: string; readonly model: string; readonly revision: string; readonly agent: string; readonly origin: string; readonly fingerprint: string; readonly confirmedFingerprint: string; readonly changes: readonly IdentityChange[]; readonly observedAt: string; readonly confirmedAt: string; } export interface IdentityOverview { readonly items: readonly DeviceIdentity[]; readonly tracked: number; readonly pending: number; } export interface IdentityDataSource { list(signal?: AbortSignal): Promise; confirm(instanceId: string): Promise; /** One device read; `observed` is false when the node answered without any identity field. */ refresh(instanceId: string): Promise<{ observed: boolean; identity?: DeviceIdentity }>; } const REASONS: ReadonlySet = new Set(['hardware_swapped', 'imei_claimed']); const FIELDS: ReadonlySet = new Set(['imei', 'manufacturer', 'model', 'revision']); const text = (value: unknown): string => (typeof value === 'string' ? value : ''); const boundedText = (value: unknown, maximum: number): string => { const raw = text(value); return raw.length > maximum ? raw.slice(0, maximum) : raw; }; function reasons(value: unknown): readonly IdentityReason[] { if (!Array.isArray(value)) return []; return value.filter( (item): item is IdentityReason => typeof item === 'string' && REASONS.has(item), ); } function conflicts(value: unknown): readonly IdentityConflict[] { if (!Array.isArray(value)) return []; const parsed: IdentityConflict[] = []; for (const entry of value) { if (!entry || typeof entry !== 'object' || Array.isArray(entry)) continue; const item = entry as Record; const reason = text(item.reason); if (!REASONS.has(reason)) continue; parsed.push({ reason: reason as IdentityReason, instanceIds: Array.isArray(item.instanceIds) ? item.instanceIds.map(text).filter(Boolean) : [], instanceNames: Array.isArray(item.instanceNames) ? item.instanceNames.map(text).filter(Boolean) : [], }); } return parsed; } function changes(value: unknown): readonly IdentityChange[] { if (!Array.isArray(value)) return []; const parsed: IdentityChange[] = []; for (const entry of value) { if (!entry || typeof entry !== 'object' || Array.isArray(entry)) continue; const item = entry as Record; const field = text(item.field); if (!FIELDS.has(field)) continue; parsed.push({ field: field as IdentityField, from: boundedText(item.from, 64), to: boundedText(item.to, 64), }); } return parsed; } function parseIdentity(value: unknown): DeviceIdentity | undefined { if (!value || typeof value !== 'object' || Array.isArray(value)) return undefined; const item = value as Record; const instanceId = boundedText(item.instanceId, 256); if (!instanceId) return undefined; return { instanceId, instanceName: boundedText(item.instanceName, 200), status: item.status === 'pending' ? 'pending' : 'confirmed', reasons: reasons(item.reasons), conflicts: conflicts(item.conflicts), imei: boundedText(item.imei, 64), manufacturer: boundedText(item.manufacturer, 128), model: boundedText(item.model, 128), revision: boundedText(item.revision, 128), agent: boundedText(item.agent, 128), origin: boundedText(item.origin, 512), fingerprint: boundedText(item.fingerprint, 128), confirmedFingerprint: boundedText(item.confirmedFingerprint, 128), changes: changes(item.changes), observedAt: boundedText(item.observedAt, 64), confirmedAt: boundedText(item.confirmedAt, 64), }; } async function json(response: Response): Promise { if (!response.ok) throw new Error(`Identity request failed (${response.status})`); return (await response.json()) as T; } const HEADERS = { accept: 'application/json' } as const; function postJson(): RequestInit { return { method: 'POST', credentials: 'same-origin', headers: { ...HEADERS, 'content-type': 'application/json' }, body: '{}', }; } const path = (instanceId: string, action: string): string => `/api/v1/fleet/identities/${encodeURIComponent(instanceId)}/${action}`; export function createIdentityApiDataSource(fetcher: typeof fetch = fetch): IdentityDataSource { return { async list(signal) { const body = await json( await fetcher('/api/v1/fleet/identities', { method: 'GET', credentials: 'same-origin', headers: HEADERS, ...(signal ? { signal } : {}), }), ); const source = (body ?? {}) as Record; const items = (Array.isArray(source.items) ? source.items : []) .map(parseIdentity) .filter((item): item is DeviceIdentity => item !== undefined); const summary = (source.summary ?? {}) as Record; const count = (value: unknown): number => typeof value === 'number' && Number.isFinite(value) && value >= 0 ? Math.floor(value) : 0; return { items, tracked: count(summary.tracked), pending: count(summary.pending) }; }, async confirm(instanceId) { const body = await json(await fetcher(path(instanceId, 'confirm'), postJson())); const identity = parseIdentity((body as Record).identity); if (!identity) throw new Error('Identity response is invalid.'); return identity; }, async refresh(instanceId) { const body = await json(await fetcher(path(instanceId, 'refresh'), postJson())); const source = (body ?? {}) as Record; const identity = parseIdentity(source.identity); return { observed: source.observed === true, ...(identity ? { identity } : {}), }; }, }; }