export { normalizeBaseUrl, normalizeInstance } from './config/schema.js' export const DEFAULT_TIMEOUT_MS = Number(process.env.MULTI_SIMADMIN_TIMEOUT_MS || 6000) export function redactInstance(instance) { return { id: instance.id, name: instance.name, url: instance.url, description: instance.description, tags: instance.tags || [], auth: { mode: instance.auth?.mode || 'none', hasPassword: Boolean(instance.auth?.password), }, capabilities: instance.capabilities || [], } } function apiData(payload) { return payload?.data ?? payload } function pickDevice(data) { data = apiData(data) if (!data || typeof data !== 'object') return null return { imei: data.imei || data.IMEI || null, manufacturer: data.manufacturer || data.vendor || null, model: data.model || data.device_model || null, firmware: data.firmware || data.firmware_version || data.version || data.revision || null, revision: data.revision || null, powered: data.powered ?? data.power ?? null, online: data.online ?? data.is_online ?? null, } } function pickSim(data) { data = apiData(data) if (!data || typeof data !== 'object') return null return { iccid: data.iccid || data.ICCID || null, imsi: data.imsi || data.IMSI || null, phoneNumber: data.phone_number || data.phoneNumber || data.msisdn || (Array.isArray(data.phone_numbers) ? data.phone_numbers[0] : null) || null, phoneNumbers: data.phone_numbers || data.phoneNumbers || null, operator: data.operator || data.operator_name || null, signal: data.signal || data.signal_strength || null, present: data.present ?? data.inserted ?? null, smsCenter: data.sms_center || data.smsCenter || null, } } function pickNetwork(data) { data = apiData(data) if (!data || typeof data !== 'object') return null return { operator: data.operator || data.operator_name || data.provider || null, registration: data.registration || data.registration_state || data.registration_status || data.status || null, accessTechnology: data.access_technology || data.accessTechnology || data.rat || data.mode || data.technology_preference || null, signal: data.signal || data.signal_strength || data.rssi || null, mcc: data.mcc || null, mnc: data.mnc || null, } } function pickSms(data) { data = apiData(data) if (!data || typeof data !== 'object') return null return { total: data.total ?? data.total_count ?? data.count ?? null, unread: data.unread ?? data.unread_count ?? null, conversations: data.conversations ?? data.conversation_count ?? null, incoming: data.incoming ?? null, outgoing: data.outgoing ?? null, pushed: data.pushed ?? null, pushAttempted: data.push_attempted ?? data.pushAttempted ?? null, } } function pickDataStatus(data) { data = apiData(data) if (!data || typeof data !== 'object') return null return { enabled: data.enabled ?? data.data_enabled ?? null, connected: data.connected ?? data.is_connected ?? null, roaming: data.roaming ?? data.roaming_allowed ?? null, active: data.active ?? null, } } function pickOta(data) { data = apiData(data) if (!data || typeof data !== 'object') return null return { currentVersion: data.current_version || data.currentVersion || data.version || null, latestVersion: data.latest_version || data.latestVersion || null, updateAvailable: data.update_available ?? data.updateAvailable ?? null, pendingUpdate: data.pending_update ?? data.pendingUpdate ?? null, currentCommit: data.current_commit || data.currentCommit || null, } } function pickSystem(data) { data = apiData(data) if (!data || typeof data !== 'object') return null return { cpuLoad: data.cpu_load ?? data.cpuLoad ?? null, memory: data.memory || null, disk: data.disk || null, networkSpeed: data.network_speed || data.networkSpeed || null, info: data.system_info || data.systemInfo || null, temperature: data.temperature || null, uptime: data.uptime ?? null, } } export function summarizeInstanceSnapshot(raw) { return { device: pickDevice(raw.device), sim: pickSim(raw.sim), network: pickNetwork(raw.network), sms: pickSms(raw.smsStats), data: pickDataStatus(raw.data), ota: pickOta(raw.ota), system: pickSystem(raw.stats), stats: apiData(raw.stats) || null, calls: apiData(raw.calls) || null, } } function parseSetCookie(value) { if (!value) return [] if (Array.isArray(value)) return value return [String(value)] } class CookieJar { constructor() { this.map = new Map() } setFromHeaders(headers) { const raw = headers.getSetCookie ? headers.getSetCookie() : parseSetCookie(headers.get('set-cookie')) for (const line of raw) { const [pair] = String(line).split(';') const eq = pair.indexOf('=') if (eq <= 0) continue const name = pair.slice(0, eq).trim() const value = pair.slice(eq + 1).trim() if (value) this.map.set(name, value) else this.map.delete(name) } } header() { return [...this.map.entries()].map(([k, v]) => `${k}=${v}`).join('; ') } clear() { this.map.clear() } } export function createSimAdminClient(instance, { fetchImpl = fetch, timeoutMs = DEFAULT_TIMEOUT_MS } = {}) { instance = structuredClone(instance) const jar = new CookieJar() async function request(endpoint, options = {}) { const url = new URL(endpoint, instance.url) const controller = new AbortController() const timer = setTimeout(() => controller.abort(), options.timeoutMs || timeoutMs) const headers = new Headers(options.headers || {}) const cookie = jar.header() if (cookie && !headers.has('cookie')) headers.set('cookie', cookie) if (headers.has('cookie')) headers.cookie = headers.get('cookie') try { const response = await fetchImpl(url, { ...options, headers, signal: controller.signal, redirect: options.redirect || 'manual' }) jar.setFromHeaders(response.headers) return response } finally { clearTimeout(timer) } } async function fetchJson(endpoint, options = {}) { const startedAt = Date.now() const response = await request(endpoint, { method: 'GET', ...options, headers: { accept: 'application/json,text/plain,*/*', ...(options.headers || {}) } }) const text = await response.text() let data = null try { data = text ? JSON.parse(text) : null } catch {} return { ok: response.ok, status: response.status, latencyMs: Date.now() - startedAt, data, text: data ? undefined : text.slice(0, 1000), headers: response.headers } } let ephemeralSecret = '' async function ensureAuthenticated({ credential } = {}) { const status = await fetchJson('/api/auth/status') const auth = apiData(status.data) || {} const protectionEnabled = auth.settings?.password_protection_enabled ?? auth.settings?.passwordProtectionEnabled if (auth.authenticated || protectionEnabled === false || auth.configured === false) { return { configured: auth.configured ?? null, authenticated: Boolean(auth.authenticated || protectionEnabled === false), loginAttempted: false, statusCode: status.status } } const password = credential === undefined ? (ephemeralSecret || instance.auth?.password) : String(credential || '') if (!password) { if (status.status === 404 && instance.auth?.mode === 'none') return { configured: null, authenticated: null, loginAttempted: false, statusCode: status.status, reason: 'auth_status_unsupported' } return { configured: auth.configured ?? null, authenticated: false, loginAttempted: false, statusCode: status.status, reason: 'password_required' } } if (credential !== undefined) ephemeralSecret = '' const login = await request('/api/auth/login', { method: 'POST', headers: { 'content-type': 'application/json', accept: 'application/json' }, body: JSON.stringify({ password }), }) if (credential !== undefined && login.ok) ephemeralSecret = password return { configured: auth.configured ?? null, authenticated: login.ok, loginAttempted: true, statusCode: login.status, reason: login.ok ? null : 'login_failed' } } function clearEphemeralSecret() { ephemeralSecret = '' } return { instance, jar, request, fetchJson, ensureAuthenticated, clearEphemeralSecret } } export function buildClients(instances, options = {}) { return new Map(instances.map(instance => [instance.id, createSimAdminClient(instance, options)])) }