feat: prepare Zhinian desktop client for pilot release

This commit is contained in:
inman
2026-04-29 10:23:20 +08:00
parent f9361e686a
commit 47b83b79fc
149 changed files with 15341 additions and 3590 deletions

View File

@@ -0,0 +1,804 @@
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<string, unknown>;
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<YinianSkillEntitlement['triggers'][number]> = [
'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<YinianServerStatus> {
const checkedAt = Date.now();
try {
const health = await this.request<JsonObject>('/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<YinianImageCaptcha> {
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<YinianSessionState> {
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<YinianSessionState> {
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<YinianAuthSession> {
void input;
throw new Error('当前组织空间端仅支持账号密码登录');
}
async loginWithPassword(input: YinianLoginWithPasswordInput): Promise<YinianAuthSession> {
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);
}
async logout(): Promise<YinianSessionState> {
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<YinianAuthSession> {
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<YinianConfigSnapshot> {
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<JsonObject>(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<YinianSkillSyncResult> {
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<JsonObject>(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<YinianLocalSkill[]> {
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): Promise<YinianAuthSession> {
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) ?? await this.getSessionState();
if (!session.authenticated) throw new Error('登录成功但未获取到工作空间信息');
this.currentHotelId = session.currentHotelId;
await this.persistSession(session);
return session;
}
private async refreshSession(): Promise<YinianSessionState> {
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<void> {
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<string, string | undefined>): Promise<JsonObject> {
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<JsonObject>('/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): YinianAuthSession | null {
const sessionPayload = readObject(payload, 'session') ?? payload;
const userValue = readObject(sessionPayload, 'user', 'user_info', 'userInfo');
if (!userValue) return null;
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<T>(
path: string,
options: { method?: string; body?: JsonObject; form?: Record<string, string | string[] | undefined>; auth?: boolean; headers?: Record<string, string> } = {},
): Promise<T> {
const headers: Record<string, string> = {
'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<unknown>();
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<string, string> {
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, string | string[] | undefined>): 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 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') ?? 'user_unknown',
name: readString(object, 'name', 'username', 'nickname') ?? '未命名用户',
phone: readString(object, 'phone'),
email: readString(object, 'email'),
avatar: readString(object, 'avatar'),
};
}
function createDefaultWorkspace(sessionPayload: JsonObject, userPayload: JsonObject, user: YinianUser): YinianHotel {
const tenantId = readString(userPayload, 'tenantId', 'tenant_id')
?? readString(sessionPayload, 'tenantId', 'tenant_id')
?? 'default';
return {
id: `service_${tenantId}`,
name: readString(sessionPayload, 'tenantName', 'tenant_name', 'workspaceName', 'workspace_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<string, boolean> {
if (!value) return {};
return Object.fromEntries(
Object.entries(value).filter((entry): entry is [string, boolean] => typeof entry[1] === 'boolean'),
);
}