import type { YinianAuthSession, YinianConfigSnapshot, YinianHotel, YinianImageCaptcha, YinianNotificationChannel, YinianSkillEntitlement, YinianLoginWithPasswordInput, YinianLoginWithSmsInput, YinianLocalSkill, YinianServerStatus, YinianSessionState, YinianSkillSyncResult, YinianUser, } from '../../shared/yinian'; import { randomUUID } from 'node:crypto'; import type { YinianControlPlane } from './control-plane'; import { getYinianStorage, type YinianStorage } from './storage'; interface HttpYinianControlPlaneOptions { apiBaseUrl: string; storage?: YinianStorage; } type JsonObject = Record; const DEFAULT_ACCESS_TOKEN_TTL_MS = 30 * 60 * 1000; const SKILL_CATEGORIES: YinianSkillEntitlement['category'][] = [ 'ota-monitoring', 'reporting', 'guest-comm', 'ops-automation', ]; const SKILL_TRIGGERS: Array = [ 'manual', 'scheduled', 'webhook', 'reply', ]; const DEFAULT_OAUTH_CLIENT_ID = 'customPC'; const DEFAULT_OAUTH_CLIENT_SECRET = 'customPC'; const DEFAULT_OAUTH_SCOPE = 'server'; const DEFAULT_CONFIG_SYNC_PATH = '/config/sync'; const DEFAULT_SKILLS_MANIFEST_PATH = '/skills/manifest'; export class HttpYinianControlPlane implements YinianControlPlane { private readonly apiBaseUrl: string; private readonly storage: YinianStorage; private accessToken: string | null = null; private refreshToken: string | null = null; private currentHotelId: string | null = null; private localSkills: YinianLocalSkill[] = []; constructor(options: HttpYinianControlPlaneOptions) { this.apiBaseUrl = options.apiBaseUrl.replace(/\/+$/, ''); this.storage = options.storage ?? getYinianStorage(); } async getServerStatus(): Promise { const checkedAt = Date.now(); try { const health = await this.request('/health', { auth: false }); return { mode: 'http', apiBaseUrl: this.apiBaseUrl, reachable: true, checkedAt, serverTime: readNumber(health, 'serverTime', 'server_time'), version: readString(health, 'version', 'appVersion', 'app_version'), message: readString(health, 'message', 'status') ?? '服务端连接正常', }; } catch (error) { return { mode: 'http', apiBaseUrl: this.apiBaseUrl, reachable: false, checkedAt, message: error instanceof Error ? error.message : String(error), }; } } async createImageCaptcha(randomStr = createRandomString()): Promise { const path = `/auth/code/image?randomStr=${encodeURIComponent(randomStr)}`; const requestUrl = `${this.apiBaseUrl}${path}`; const response = await fetch(requestUrl, { method: 'GET', headers: { 'X-YINIAN-App-Version': '0.1.0', }, }).catch((error: unknown) => { throw new Error(describeNetworkError(error, requestUrl)); }); if (!response.ok) { const data = await response.json().catch(() => ({})) as JsonObject; const message = readErrorMessage(data) ?? `YINIAN API request failed: ${response.status}`; throw new Error(message); } const contentType = response.headers.get('content-type') ?? ''; if (contentType.includes('application/json')) { const data = await response.json().catch(() => ({})) as JsonObject; return normalizeImageCaptcha(data, randomStr); } const imageBytes = Buffer.from(await response.arrayBuffer()); return { randomStr, imageBase64: imageBytes.toString('base64'), mimeType: contentType.split(';')[0] || 'image/png', raw: { contentType, size: imageBytes.byteLength, }, }; } async restoreSession(): Promise { const persisted = await this.storage.getSession(); if (!persisted || persisted.mode !== 'http') { return { authenticated: false }; } this.refreshToken = persisted.refreshToken ?? null; this.currentHotelId = persisted.currentHotelId; if (this.refreshToken) { try { return await this.refreshSession(); } catch { await this.storage.clearSession(); this.refreshToken = null; this.currentHotelId = null; return { authenticated: false }; } } // Access tokens intentionally remain memory-only. Until refresh-token // exchange is implemented, restore returns the persisted tenant snapshot so // the shell can recover UI context without exposing provider/model keys. return { authenticated: true, user: persisted.user, hotels: persisted.hotels, currentHotelId: persisted.currentHotelId, accessTokenExpiresAt: persisted.accessTokenExpiresAt, }; } async getSessionState(): Promise { if (!this.accessToken) return this.restoreSession(); const persisted = await this.storage.getSession(); if (persisted?.mode === 'http') { return { authenticated: true, user: persisted.user, hotels: persisted.hotels, currentHotelId: persisted.currentHotelId, accessTokenExpiresAt: persisted.accessTokenExpiresAt, }; } return { authenticated: false }; } async loginWithSms(input: YinianLoginWithSmsInput): Promise { void input; throw new Error('当前组织空间端仅支持账号密码登录'); } async loginWithPassword(input: YinianLoginWithPasswordInput): Promise { const response = await this.requestOAuthToken({ grant_type: 'password', username: input.account, account: input.account, password: input.password, code: input.captchaCode, captchaCode: input.captchaCode, captcha_code: input.captchaCode, randomStr: input.randomStr, random_str: input.randomStr, }); return this.applyLoginResponse(response, input.account); } async logout(): Promise { const hotelId = this.currentHotelId; this.accessToken = null; this.refreshToken = null; this.currentHotelId = null; if (hotelId) { await this.storage.clearConfig(hotelId); await this.storage.clearSkillRegistry(hotelId); } await this.storage.clearSession(); this.localSkills = []; return { authenticated: false }; } async switchHotel(hotelId: string): Promise { const session = await this.getSessionState(); if (!session.authenticated) throw new Error('请先登录'); const hotel = session.hotels.find((item) => item.id === hotelId); if (!hotel) throw new Error('工作空间不存在或未开通'); this.currentHotelId = hotelId; const nextSession = { ...session, currentHotelId: hotelId }; await this.persistSession(nextSession); const registry = await this.storage.getSkillRegistry(hotelId); this.localSkills = registry && 'skills' in registry ? registry.skills : []; return nextSession; } async getConfigSnapshot(): Promise { const session = await this.getSessionState(); if (!session.authenticated) throw new Error('请先登录'); const hotelId = session.currentHotelId; const configPath = buildScopedEndpoint( process.env.YINIAN_CONFIG_SYNC_PATH?.trim() || DEFAULT_CONFIG_SYNC_PATH, hotelId, ); let response: JsonObject; try { response = await this.request(configPath); } catch (error) { if (isMissingServerResourceError(error)) { const snapshot = createDefaultConfigSnapshot(session); await this.storage.setConfig(snapshot); return snapshot; } throw error; } const hotel = normalizeHotel(response.hotel) ?? session.hotels.find((item) => item.id === hotelId) ?? session.hotels[0]; const snapshot = { serverTime: readNumber(response, 'serverTime', 'server_time') ?? Date.now(), user: session.user, hotel, hotels: session.hotels, entitlements: normalizeEntitlements(readManifestItems(response, 'entitlements')), notificationChannels: normalizeNotificationChannels(readArray(response, 'notificationChannels', 'notification_channels')), featureFlags: normalizeBooleanRecord(readObject(response, 'featureFlags', 'feature_flags')), uiPolicy: normalizeUiPolicy(readObject(response, 'uiPolicy', 'ui_policy')), }; await this.storage.setConfig(snapshot); return snapshot; } async syncSkills(): Promise { const session = await this.getSessionState(); if (!session.authenticated) throw new Error('请先登录'); const manifestPath = buildScopedEndpoint( process.env.YINIAN_SKILLS_MANIFEST_PATH?.trim() || DEFAULT_SKILLS_MANIFEST_PATH, session.currentHotelId, ); const now = Date.now(); let response: JsonObject; try { response = await this.request(manifestPath); } catch (error) { if (isMissingServerResourceError(error)) { this.localSkills = []; await this.storage.setSkillRegistry({ hotelId: session.currentHotelId, updatedAt: now, skills: [], }); return { hotelId: session.currentHotelId, syncedAt: now, skills: [], }; } throw error; } const skills = readManifestItems(response).filter(isObject).map((item) => normalizeLocalSkill(item, now)); const previousRegistry = await this.storage.getSkillRegistry(session.currentHotelId); const previousSkills = previousRegistry && 'skills' in previousRegistry ? previousRegistry.skills : []; this.localSkills = skills.map((skill) => { const previous = previousSkills.find((item) => item.skillId === skill.skillId); return { ...skill, installedAt: previous?.installedAt ?? skill.installedAt, status: !skill.enabled ? 'disabled' : previous?.version === skill.version ? 'skipped' : previous ? 'updated' : skill.status, }; }); await this.storage.setSkillRegistry({ hotelId: session.currentHotelId, updatedAt: now, skills: this.localSkills, }); return { hotelId: session.currentHotelId, syncedAt: now, skills: this.localSkills, }; } async listLocalSkills(): Promise { const session = await this.getSessionState(); if (session.authenticated) { const registry = await this.storage.getSkillRegistry(session.currentHotelId); if (registry && 'skills' in registry) { this.localSkills = registry.skills; } } return this.localSkills; } async getSkillRegistry(hotelId?: string) { return this.storage.getSkillRegistry(hotelId); } private async applyLoginResponse(response: JsonObject, accountHint?: string): Promise { this.accessToken = readString(response, 'accessToken', 'access_token'); this.refreshToken = readString(response, 'refreshToken', 'refresh_token'); if (!this.accessToken) throw new Error('服务端未返回 access token'); const session = this.normalizeSessionFromPayload(response, accountHint) ?? await this.getSessionState(); if (!session.authenticated) throw new Error('登录成功但未获取到工作空间信息'); this.currentHotelId = session.currentHotelId; await this.persistSession(session); return session; } private async refreshSession(): Promise { if (!this.refreshToken) return { authenticated: false }; const response = await this.requestOAuthToken({ grant_type: 'refresh_token', refreshToken: this.refreshToken, refresh_token: this.refreshToken, }); return this.applyLoginResponse(response); } private async persistSession(session: YinianAuthSession): Promise { await this.storage.setSession({ mode: 'http', user: session.user, hotels: session.hotels, currentHotelId: session.currentHotelId, accessTokenExpiresAt: session.accessTokenExpiresAt, refreshToken: this.refreshToken ?? undefined, updatedAt: Date.now(), }); } private async requestOAuthToken(fields: Record): Promise { const clientId = process.env.YINIAN_AUTH_CLIENT_ID?.trim() || DEFAULT_OAUTH_CLIENT_ID; const headers = createOAuthHeaders(clientId); const clientBodyFields = headers.Authorization ? {} : { clientId, client_id: clientId }; return this.request('/auth/oauth2/token', { method: 'POST', auth: false, form: { scope: process.env.YINIAN_AUTH_SCOPE?.trim() || DEFAULT_OAUTH_SCOPE, ...clientBodyFields, ...fields, }, headers, }); } private normalizeSessionFromPayload(payload: JsonObject, accountHint?: string): YinianAuthSession | null { const tokenPayload = this.accessToken ? parseJwtPayload(this.accessToken) : undefined; const sessionPayload = { ...(tokenPayload ?? {}), ...payload, ...(readObject(payload, 'session') ?? {}), }; const userValue = readObject(sessionPayload, 'user', 'user_info', 'userInfo') ?? extractUserPayload(sessionPayload, accountHint); const user = normalizeUser(userValue); const hotelsValue = readArray(sessionPayload, 'hotels', 'workspaces', 'businesses', 'tenants'); const hotels = normalizeHotels(hotelsValue); if (hotels.length === 0) { hotels.push(createDefaultWorkspace(sessionPayload, userValue, user)); } const currentHotelId = readString(sessionPayload, 'currentHotelId', 'current_hotel_id', 'currentWorkspaceId', 'current_workspace_id', 'workspaceId', 'workspace_id') ?? this.currentHotelId ?? hotels[0]?.id; if (!currentHotelId) return null; return { authenticated: true, user, hotels, currentHotelId, accessTokenExpiresAt: readAccessTokenExpiresAt(sessionPayload), }; } private async request( path: string, options: { method?: string; body?: JsonObject; form?: Record; auth?: boolean; headers?: Record } = {}, ): Promise { const headers: Record = { 'X-YINIAN-App-Version': '0.1.0', ...(options.headers ?? {}), }; const body = options.form ? createFormBody(options.form) : options.body ? JSON.stringify(options.body) : undefined; if (options.body && !headers['Content-Type']) { headers['Content-Type'] = 'application/json'; } if (options.form && !headers['Content-Type']) { headers['Content-Type'] = 'application/x-www-form-urlencoded'; } if (options.auth !== false && this.accessToken) { headers.Authorization = `Bearer ${this.accessToken}`; } const requestUrl = `${this.apiBaseUrl}${path}`; const response = await fetch(requestUrl, { method: options.method ?? 'GET', headers, body, }).catch((error: unknown) => { throw new Error(describeNetworkError(error, requestUrl)); }); const data = await response.json().catch(() => ({})) as JsonObject; if (!response.ok) { const message = readErrorMessage(data) ?? `YINIAN API request failed: ${response.status}`; throw new Error(message); } const payload = unwrapApiPayload(data); return payload as T; } } function createDevicePayload(): JsonObject { return { device_id: 'dev_local_electron', platform: process.platform, app_version: '0.1.0', machine_name: '智念助手', }; } function describeNetworkError(error: unknown, requestUrl: string): string { const hostname = safeHostname(requestUrl); const code = readNestedErrorString(error, 'code'); const syscall = readNestedErrorString(error, 'syscall'); const detail = code ? `(${code}${syscall ? `/${syscall}` : ''})` : ''; if (code === 'ENOTFOUND') { return `无法连接服务端:域名 ${hostname} 解析失败${detail}。请检查服务端地址、网络或 VPN。`; } if (code === 'ECONNREFUSED') { return `无法连接服务端:${hostname} 拒绝连接${detail}。请确认服务端已启动并允许当前网络访问。`; } if (code === 'ETIMEDOUT' || code === 'UND_ERR_CONNECT_TIMEOUT') { return `无法连接服务端:连接 ${hostname} 超时${detail}。请检查网络、代理或 VPN。`; } if (code === 'CERT_HAS_EXPIRED' || code === 'UNABLE_TO_VERIFY_LEAF_SIGNATURE' || code === 'DEPTH_ZERO_SELF_SIGNED_CERT') { return `无法连接服务端:${hostname} 的 HTTPS 证书校验失败${detail}。请检查证书配置。`; } return `无法连接服务端:请求 ${hostname} 失败${detail}。请检查网络、代理、VPN 或服务端地址。`; } function safeHostname(requestUrl: string): string { try { return new URL(requestUrl).hostname; } catch { return requestUrl; } } function readNestedErrorString(error: unknown, key: string): string | undefined { const visited = new Set(); let current: unknown = error; while (isObject(current) && !visited.has(current)) { visited.add(current); const value = current[key]; if (typeof value === 'string' && value.trim()) return value; current = current.cause; } return undefined; } function createOAuthHeaders(clientId: string): Record { const explicitBasic = process.env.YINIAN_AUTH_BASIC?.trim(); if (explicitBasic) { return { Authorization: explicitBasic.startsWith('Basic ') ? explicitBasic : `Basic ${explicitBasic}` }; } const clientSecret = process.env.YINIAN_AUTH_CLIENT_SECRET?.trim() || DEFAULT_OAUTH_CLIENT_SECRET; if (!clientSecret) return {}; return { Authorization: `Basic ${Buffer.from(`${clientId}:${clientSecret}`).toString('base64')}`, }; } function createFormBody(fields: Record): string { const params = new URLSearchParams(); for (const [key, value] of Object.entries(fields)) { if (value == null || value === '') continue; if (Array.isArray(value)) { value.forEach((item) => params.append(key, item)); } else { params.append(key, value); } } return params.toString(); } function createRandomString(): string { return randomUUID(); } function parseJwtPayload(token: string): JsonObject | undefined { const [, rawPayload] = token.split('.'); if (!rawPayload) return undefined; try { const normalized = rawPayload.replace(/-/g, '+').replace(/_/g, '/'); const padded = normalized.padEnd(Math.ceil(normalized.length / 4) * 4, '='); const parsed = JSON.parse(Buffer.from(padded, 'base64').toString('utf8')) as unknown; return isObject(parsed) ? parsed : undefined; } catch { return undefined; } } function buildScopedEndpoint(endpoint: string, workspaceId: string): string { const [rawPath, rawQuery = ''] = endpoint.split('?'); const path = rawPath .replaceAll('{workspaceId}', encodeURIComponent(workspaceId)) .replaceAll('{workspace_id}', encodeURIComponent(workspaceId)) .replaceAll('{hotelId}', encodeURIComponent(workspaceId)) .replaceAll('{hotel_id}', encodeURIComponent(workspaceId)) .replaceAll('{tenantId}', encodeURIComponent(workspaceId)) .replaceAll('{tenant_id}', encodeURIComponent(workspaceId)); const params = new URLSearchParams(rawQuery); const hasExplicitScope = endpoint.includes('{workspaceId}') || endpoint.includes('{workspace_id}') || endpoint.includes('{hotelId}') || endpoint.includes('{hotel_id}') || endpoint.includes('{tenantId}') || endpoint.includes('{tenant_id}') || params.has('workspaceId') || params.has('workspace_id') || params.has('hotelId') || params.has('hotel_id') || params.has('tenantId') || params.has('tenant_id'); if (!hasExplicitScope) { params.set('hotel_id', workspaceId); } const query = params.toString(); return query ? `${path}?${query}` : path; } function normalizeImageCaptcha(value: JsonObject, fallbackRandomStr: string): YinianImageCaptcha { const payload = unwrapApiPayload(value); const image = readString(payload, 'image', 'img', 'captcha', 'captchaImage', 'captcha_image'); const imageBase64 = readString(payload, 'imageBase64', 'image_base64', 'base64'); return { randomStr: readString(payload, 'randomStr', 'random_str', 'key') ?? fallbackRandomStr, image, imageBase64, mimeType: readString(payload, 'mimeType', 'mime_type') ?? (imageBase64 ? 'image/png' : undefined), raw: payload, }; } function normalizeUser(value: unknown): YinianUser { const object = isObject(value) ? value : {}; return { id: readString(object, 'id', 'userId', 'user_id', 'userIdStr', 'user_id_str', 'sub', 'user_name') ?? 'user_unknown', name: readString(object, 'name', 'username', 'userName', 'user_name', 'nickname', 'nickName', 'realName', 'real_name', 'account') ?? '未命名用户', phone: readString(object, 'phone', 'mobile', 'phoneNumber', 'phone_number'), email: readString(object, 'email'), avatar: readString(object, 'avatar'), }; } function extractUserPayload(sessionPayload: JsonObject, accountHint?: string): JsonObject { const account = accountHint?.trim() || readString(sessionPayload, 'username', 'userName', 'user_name', 'account', 'phone', 'mobile') || '当前用户'; return { id: readString(sessionPayload, 'userId', 'user_id', 'id', 'sub', 'user_name') ?? account, name: readString(sessionPayload, 'name', 'username', 'userName', 'user_name', 'nickname', 'nickName', 'realName', 'real_name') ?? account, phone: readString(sessionPayload, 'phone', 'mobile', 'phoneNumber', 'phone_number'), email: readString(sessionPayload, 'email'), avatar: readString(sessionPayload, 'avatar'), tenantId: readString(sessionPayload, 'tenantId', 'tenant_id', 'deptId', 'dept_id', 'orgId', 'org_id'), }; } function createDefaultWorkspace(sessionPayload: JsonObject, userPayload: JsonObject, user: YinianUser): YinianHotel { const tenantId = readString(userPayload, 'tenantId', 'tenant_id') ?? readString(sessionPayload, 'tenantId', 'tenant_id') ?? readString(sessionPayload, 'deptId', 'dept_id', 'orgId', 'org_id') ?? 'default'; return { id: `service_${tenantId}`, name: readString(sessionPayload, 'tenantName', 'tenant_name', 'workspaceName', 'workspace_name', 'deptName', 'dept_name', 'orgName', 'org_name') ?? `${user.name}的组织空间`, }; } function createDefaultConfigSnapshot(session: YinianAuthSession): YinianConfigSnapshot { return { serverTime: Date.now(), user: session.user, hotel: session.hotels.find((item) => item.id === session.currentHotelId) ?? session.hotels[0], hotels: session.hotels, entitlements: [], notificationChannels: [], featureFlags: {}, uiPolicy: { defaultPage: 'today', showAdvancedSettings: false, }, }; } function isMissingServerResourceError(error: unknown): boolean { return error instanceof Error && /No static resource|404|not found/i.test(error.message); } function normalizeHotels(value: unknown): YinianHotel[] { if (!Array.isArray(value)) return []; return value.map(normalizeHotel).filter((hotel): hotel is YinianHotel => Boolean(hotel)); } function normalizeHotel(value: unknown): YinianHotel | null { if (!isObject(value)) return null; const hotel: YinianHotel = { id: readString(value, 'id') ?? readString(value, 'hotelId', 'hotel_id', 'workspaceId', 'workspace_id', 'tenantId', 'tenant_id') ?? 'hotel_unknown', name: readString(value, 'name') ?? '未命名工作空间', brand: readString(value, 'brand'), }; return hotel; } function normalizeLocalSkill(value: JsonObject, now: number): YinianLocalSkill { const skillId = readString( value, 'skillId', 'skill_id', 'appId', 'app_id', 'applicationId', 'application_id', 'code', 'skillCode', 'skill_code', 'id', ) ?? 'unknown-skill'; const version = readString(value, 'version', 'appVersion', 'app_version', 'currentVersion', 'current_version') ?? '0.0.0'; const status = readString(value, 'status', 'state'); const enabled = value.enabled !== false && value.kill_switch !== true && status !== 'disabled' && status !== 'disable' && status !== 'off'; return { skillId, name: readString(value, 'name', 'appName', 'app_name', 'applicationName', 'application_name', 'title') ?? skillId, version, enabled, installedAt: now, lastSyncedAt: now, status: enabled ? 'installed' : 'disabled', source: 'nianxx', bundleSha256: readString(value, 'bundleSha256', 'bundle_sha256', 'sha256', 'checksum', 'digest', 'bundleHash', 'bundle_hash'), }; } function normalizeEntitlements(value: unknown[] | undefined): YinianSkillEntitlement[] { if (!value) return []; return value.filter(isObject).map((item) => { const skillId = readString(item, 'skillId', 'skill_id', 'appId', 'app_id', 'applicationId', 'application_id', 'code', 'id') ?? 'unknown-skill'; const status = readString(item, 'status', 'state'); return { skillId, name: readString(item, 'name', 'appName', 'app_name', 'applicationName', 'application_name', 'title') ?? skillId, version: readString(item, 'version', 'appVersion', 'app_version', 'currentVersion', 'current_version') ?? '0.0.0', enabled: item.enabled !== false && status !== 'disabled' && status !== 'disable' && status !== 'off', category: normalizeSkillCategory(readString(item, 'category')), triggers: normalizeSkillTriggers(readArray(item, 'triggers')), lastRunAt: readNumber(item, 'lastRunAt', 'last_run_at'), }; }); } function normalizeNotificationChannels(value: unknown[] | undefined): YinianNotificationChannel[] { if (!value) return []; return value.filter(isObject).map((item) => ({ id: readString(item, 'id') ?? 'channel_unknown', kind: readString(item, 'kind') ?? 'unknown', label: readString(item, 'label') ?? '未命名通知通道', recipient: readString(item, 'recipient') ?? '', enabled: item.enabled !== false, source: readString(item, 'source') === 'kernel' ? 'kernel' : 'nianxx', })); } function normalizeUiPolicy(value: JsonObject | undefined): YinianConfigSnapshot['uiPolicy'] { const defaultPage = readString(value ?? {}, 'defaultPage', 'default_page'); return { defaultPage: defaultPage === 'chat' ? 'chat' : 'today', showAdvancedSettings: readBoolean(value ?? {}, 'showAdvancedSettings', 'show_advanced_settings') ?? false, }; } function normalizeSkillCategory(value: string | undefined): YinianSkillEntitlement['category'] { return SKILL_CATEGORIES.includes(value as YinianSkillEntitlement['category']) ? value as YinianSkillEntitlement['category'] : 'ops-automation'; } function normalizeSkillTriggers(value: unknown[] | undefined): YinianSkillEntitlement['triggers'] { if (!value) return []; return value.filter((item): item is YinianSkillEntitlement['triggers'][number] => ( typeof item === 'string' && SKILL_TRIGGERS.includes(item as YinianSkillEntitlement['triggers'][number]) )); } function readAccessTokenExpiresAt(object: JsonObject): number { return readNumber(object, 'accessTokenExpiresAt', 'access_token_expires_at', 'expiresAt', 'expires_at') ?? readExpiresIn(object) ?? (Date.now() + DEFAULT_ACCESS_TOKEN_TTL_MS); } function readExpiresIn(object: JsonObject): number | undefined { const expiresIn = readNumber(object, 'expiresIn', 'expires_in'); if (!expiresIn) return undefined; return Date.now() + expiresIn * 1000; } function isObject(value: unknown): value is JsonObject { return typeof value === 'object' && value !== null && !Array.isArray(value); } function readString(object: JsonObject, ...keys: string[]): string | undefined { for (const key of keys) { const value = object[key]; if (typeof value === 'string') return value; } return undefined; } function readNumber(object: JsonObject, ...keys: string[]): number | undefined { for (const key of keys) { const value = object[key]; if (typeof value === 'number') return value; if (typeof value === 'string' && value.trim() && Number.isFinite(Number(value))) return Number(value); } return undefined; } function readBoolean(object: JsonObject, ...keys: string[]): boolean | undefined { for (const key of keys) { const value = object[key]; if (typeof value === 'boolean') return value; } return undefined; } function readObject(object: JsonObject, ...keys: string[]): JsonObject | undefined { for (const key of keys) { const value = object[key]; if (isObject(value)) return value; } return undefined; } function unwrapApiPayload(data: JsonObject): JsonObject { const dataObject = readObject(data, 'data'); if (dataObject) { return { ...data, ...dataObject, }; } return data; } function readManifestItems(object: JsonObject, preferredKey?: string): unknown[] { if (preferredKey) { const preferred = readArray(object, preferredKey); if (preferred) return preferred; } const direct = readArray(object, 'skills', 'apps', 'applications', 'records', 'list', 'items', 'rows'); if (direct) return direct; const data = object.data; if (Array.isArray(data)) return data; if (isObject(data)) { return readManifestItems(data, preferredKey); } return []; } function readErrorMessage(data: JsonObject): string | undefined { const errorObject = readObject(data, 'error'); return readString(errorObject ?? {}, 'message', 'msg', 'detail') ?? readString(data, 'message', 'msg', 'error_description') ?? (typeof data.error === 'string' ? data.error : undefined); } function readArray(object: JsonObject, ...keys: string[]): unknown[] | undefined { for (const key of keys) { const value = object[key]; if (Array.isArray(value)) return value; } return undefined; } function normalizeBooleanRecord(value: JsonObject | undefined): Record { if (!value) return {}; return Object.fromEntries( Object.entries(value).filter((entry): entry is [string, boolean] => typeof entry[1] === 'boolean'), ); }