// RPC Client for connecting to Archipelago backend export interface RPCOptions { method: string params?: Record timeout?: number } export interface RPCResponse { result?: T error?: { code: number message: string data?: unknown } } function getCsrfToken(): string | null { const match = document.cookie.match(/(?:^|;\s*)csrf_token=([^;]+)/) if (match) return match[1]! // Fallback: check for a meta tag (useful when cookies are blocked or not yet set) return document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') ?? null } class RPCClient { private static _sessionExpiredRedirecting = false private baseUrl: string constructor(baseUrl: string = '/rpc/v1') { this.baseUrl = baseUrl } async call(options: RPCOptions): Promise { const { method, params = {}, timeout = 15000 } = options const maxRetries = 3 for (let attempt = 0; attempt < maxRetries; attempt++) { const controller = new AbortController() const timeoutId = setTimeout(() => controller.abort(), timeout) try { const headers: Record = { 'Content-Type': 'application/json', } const csrfToken = getCsrfToken() if (csrfToken) { headers['X-CSRF-Token'] = csrfToken } const response = await fetch(this.baseUrl, { method: 'POST', credentials: 'include', // Important for session cookies headers, body: JSON.stringify({ method, params }), signal: controller.signal, }) clearTimeout(timeoutId) if (!response.ok) { // Session expired — debounced redirect to login // Use a single shared timeout to prevent redirect storms when // multiple parallel requests all get 401 at once if (response.status === 401 && method !== 'auth.login') { if (!RPCClient._sessionExpiredRedirecting) { RPCClient._sessionExpiredRedirecting = true setTimeout(() => { window.location.href = '/login' }, 300) } throw new Error('Session expired') } // CSRF 403: retry once after short delay (cookie may have been // updated by a concurrent Set-Cookie response not yet visible to JS) if (response.status === 403 && attempt < maxRetries - 1) { await new Promise((r) => setTimeout(r, 300)) continue } const err = new Error(`HTTP ${response.status}: ${response.statusText}`) const isRetryable = response.status === 502 || response.status === 503 if (isRetryable && attempt < maxRetries - 1) { const delay = 600 * (attempt + 1) await new Promise((r) => setTimeout(r, Math.floor(delay * (0.5 + Math.random() * 0.5)))) continue } throw err } const data: RPCResponse = await response.json() if (data.error) { throw new Error(data.error.message || 'RPC Error') } return data.result as T } catch (error) { clearTimeout(timeoutId) if (error instanceof Error) { if (error.name === 'AbortError') { const timeoutErr = new Error('Request timeout') if (attempt < maxRetries - 1) { const delay = 600 * (attempt + 1) await new Promise((r) => setTimeout(r, Math.floor(delay * (0.5 + Math.random() * 0.5)))) continue } throw timeoutErr } const msg = error.message const isRetryable = /502|503|Bad Gateway|fetch|network/i.test(msg) if (isRetryable && attempt < maxRetries - 1) { const delay = 600 * (attempt + 1) await new Promise((r) => setTimeout(r, Math.floor(delay * (0.5 + Math.random() * 0.5)))) continue } throw error } throw new Error('Unknown error occurred') } } throw new Error('Request failed after retries') } // Convenience methods async login(password: string): Promise<{ requires_totp?: boolean } | null> { return this.call({ method: 'auth.login', params: { password, }, }) } async loginTotp(code: string): Promise<{ success: boolean }> { return this.call({ method: 'auth.login.totp', params: { code } }) } async loginBackup(code: string): Promise<{ success: boolean }> { return this.call({ method: 'auth.login.backup', params: { code } }) } async totpSetupBegin(password: string): Promise<{ qr_svg: string secret_base32: string pending_token: string }> { return this.call({ method: 'auth.totp.setup.begin', params: { password } }) } async totpSetupConfirm(params: { code: string password: string pendingToken: string }): Promise<{ enabled: boolean; backup_codes: string[] }> { return this.call({ method: 'auth.totp.setup.confirm', params }) } async totpDisable(password: string, code: string): Promise<{ disabled: boolean }> { return this.call({ method: 'auth.totp.disable', params: { password, code } }) } async totpStatus(): Promise<{ enabled: boolean }> { return this.call({ method: 'auth.totp.status', params: {} }) } async changePassword(params: { currentPassword: string newPassword: string alsoChangeSsh?: boolean }): Promise<{ success: boolean }> { return this.call({ method: 'auth.changePassword', params: { currentPassword: params.currentPassword, newPassword: params.newPassword, alsoChangeSsh: params.alsoChangeSsh ?? true, }, }) } async logout(): Promise { return this.call({ method: 'auth.logout', params: {}, }) } async completeOnboarding(): Promise { return this.call({ method: 'auth.onboardingComplete', params: {}, }) } async isOnboardingComplete(): Promise { return this.call({ method: 'auth.isOnboardingComplete', params: {}, }) } async resetOnboarding(): Promise { return this.call({ method: 'auth.resetOnboarding', params: {}, }) } async getNodeDid(): Promise<{ did: string; pubkey: string }> { return this.call({ method: 'node.did', params: {}, }) } async signChallenge(challenge: string): Promise<{ signature: string }> { return this.call({ method: 'node.signChallenge', params: { challenge }, }) } async createBackup(passphrase: string): Promise<{ version: number did: string pubkey: string kid: string encrypted: boolean blob: string timestamp: string }> { return this.call({ method: 'node.createBackup', params: { passphrase }, }) } async resolveDid(did?: string): Promise> { return this.call({ method: 'identity.resolve-did', params: did ? { did } : {}, }) } async createPresentation(params: { holderId: string credentialIds: string[] }): Promise> { return this.call({ method: 'identity.create-presentation', params: { holder_id: params.holderId, credential_ids: params.credentialIds }, }) } async verifyPresentation(presentation: Record): Promise<{ valid: boolean holder_valid: boolean credentials: Array<{ id: string; valid: boolean; revoked: boolean }> }> { return this.call({ method: 'identity.verify-presentation', params: { presentation }, }) } async createPsbt(params: { outputs: Array<{ address: string; amount_sats: number }> feeRateSatPerVbyte?: number }): Promise<{ psbt_base64: string change_output_index: number total_amount_sats: number fee_rate_sat_per_vbyte: number }> { return this.call({ method: 'lnd.create-psbt', params: { outputs: params.outputs, fee_rate_sat_per_vbyte: params.feeRateSatPerVbyte ?? 10, }, }) } async finalizePsbt(signedPsbtBase64: string): Promise<{ raw_final_tx: string broadcast: boolean }> { return this.call({ method: 'lnd.finalize-psbt', params: { signed_psbt_base64: signedPsbtBase64 }, }) } async publishNostrIdentity(): Promise<{ event_id: string; success: number; failed: number }> { return this.call({ method: 'node.nostr-publish', params: {}, }) } async getNostrPubkey(): Promise<{ nostr_pubkey: string; nostr_npub?: string }> { return this.call({ method: 'node.nostr-pubkey', params: {}, }) } async listPeers(): Promise<{ peers: Array<{ onion: string; pubkey: string; name?: string }> }> { return this.call({ method: 'node-list-peers', params: {}, }) } async addPeer(params: { onion: string; pubkey: string; name?: string }): Promise<{ peers: unknown[] }> { return this.call({ method: 'node-add-peer', params, }) } async removePeer(pubkey: string): Promise<{ peers: unknown[] }> { return this.call({ method: 'node-remove-peer', params: { pubkey }, }) } async sendMessageToPeer(onion: string, message: string): Promise<{ ok: boolean; sent_to: string }> { return this.call({ method: 'node-send-message', params: { onion, message }, timeout: 90000, }) } async checkPeerReachable(onion: string): Promise<{ onion: string; reachable: boolean }> { return this.call({ method: 'node-check-peer', params: { onion }, timeout: 35000, }) } async getReceivedMessages(): Promise<{ messages: Array<{ from_pubkey: string; message: string; timestamp: string }> }> { return this.call({ method: 'node-messages-received', params: {}, }) } async discoverNodes(): Promise<{ nodes: Array<{ did: string; onion: string; pubkey: string; node_address: string }> }> { return this.call({ method: 'node-nostr-discover', params: {}, timeout: 20000, }) } async getTorAddress(): Promise<{ tor_address: string | null }> { return this.call({ method: 'node.tor-address', params: {}, }) } async torListServices(): Promise<{ services: Array<{ name: string; local_port: number; onion_address: string | null; enabled: boolean }> }> { return this.call({ method: 'tor.list-services' }) } async torRotateService(name: string): Promise<{ rotated: boolean; name: string; old_onion: string | null; new_onion: string | null; transition_hours: number }> { return this.call({ method: 'tor.rotate-service', params: { name } }) } async torToggleApp(appId: string, enabled: boolean): Promise<{ app_id: string; enabled: boolean; changed: boolean; onion_address: string | null }> { return this.call({ method: 'tor.toggle-app', params: { app_id: appId, enabled } }) } async torCleanupRotated(): Promise<{ cleaned: string[]; count: number }> { return this.call({ method: 'tor.cleanup-rotated' }) } async verifyNostrRevoked(): Promise<{ revoked: boolean nostr_pubkey: string latest_content?: string error?: string }> { return this.call({ method: 'node-nostr-verify-revoked', params: {}, timeout: 25000, }) } async echo(message: string): Promise { return this.call({ method: 'server.echo', params: { message }, }) } async getSystemTime(): Promise<{ now: string; uptime: number }> { return this.call({ method: 'server.time', params: {}, }) } async getMetrics(): Promise> { return this.call({ method: 'server.metrics', params: {}, }) } async updateServer(marketplaceUrl: string): Promise<'updating' | 'no-updates'> { return this.call({ method: 'server.update', params: { 'marketplace-url': marketplaceUrl }, }) } async detectUsbDevices(): Promise<{ devices: Array<{ type: string vendor_id: string product_id: string manufacturer: string product: string }> }> { return this.call({ method: 'system.detect-usb-devices', params: {}, }) } async restartServer(): Promise { return this.call({ method: 'server.restart', params: {}, }) } async shutdownServer(): Promise { return this.call({ method: 'server.shutdown', params: {}, }) } async installPackage(id: string, marketplaceUrl: string, version: string): Promise { return this.call({ method: 'package.install', params: { id, 'marketplace-url': marketplaceUrl, version }, }) } async uninstallPackage(id: string): Promise { return this.call({ method: 'package.uninstall', params: { id }, }) } async startPackage(id: string): Promise { return this.call({ method: 'package.start', params: { id }, }) } async stopPackage(id: string): Promise { return this.call({ method: 'package.stop', params: { id }, }) } async restartPackage(id: string): Promise { return this.call({ method: 'package.restart', params: { id }, }) } async getMarketplace(url: string): Promise> { return this.call({ method: 'marketplace.get', params: { url }, }) } // Federation async federationInvite(): Promise<{ code: string; did: string; onion: string }> { return this.call({ method: 'federation.invite', params: {}, }) } async federationJoin(code: string): Promise<{ joined: boolean node: { did: string; onion: string; pubkey: string; trust_level: string } }> { return this.call({ method: 'federation.join', params: { code }, }) } async federationListNodes(): Promise<{ nodes: Array<{ did: string pubkey: string onion: string trust_level: string added_at: string name?: string last_seen?: string last_state?: { timestamp: string apps: Array<{ id: string; status: string; version?: string }> cpu_usage_percent?: number mem_used_bytes?: number mem_total_bytes?: number disk_used_bytes?: number disk_total_bytes?: number uptime_secs?: number tor_active?: boolean } }> }> { return this.call({ method: 'federation.list-nodes', params: {}, }) } async federationRemoveNode(did: string): Promise<{ removed: boolean; nodes_remaining: number }> { return this.call({ method: 'federation.remove-node', params: { did }, }) } async federationSetTrust( did: string, trustLevel: 'trusted' | 'observer' | 'untrusted', ): Promise<{ updated: boolean; did: string; trust_level: string }> { return this.call({ method: 'federation.set-trust', params: { did, trust_level: trustLevel }, }) } async federationSyncState(): Promise<{ synced: number failed: number results: Array<{ did: string; status: string; apps?: number; error?: string }> }> { return this.call({ method: 'federation.sync-state', params: {}, timeout: 120000, }) } async federationDeployApp(params: { did: string appId: string version?: string marketplaceUrl?: string }): Promise<{ deployed: boolean; app_id: string; peer_did: string; peer_onion: string }> { return this.call({ method: 'federation.deploy-app', params: { did: params.did, app_id: params.appId, version: params.version ?? 'latest', marketplace_url: params.marketplaceUrl ?? '', }, timeout: 180000, }) } // VPN async vpnStatus(): Promise<{ connected: boolean provider?: string interface?: string ip_address?: string hostname?: string peers_connected: number bytes_in: number bytes_out: number configured: boolean configured_provider: string }> { return this.call({ method: 'vpn.status', params: {}, }) } async vpnConfigure(params: { provider: 'tailscale' | 'wireguard' auth_key?: string address?: string dns?: string peer?: { public_key: string endpoint: string allowed_ips?: string persistent_keepalive?: number } }): Promise<{ configured: boolean; provider: string; public_key?: string; address?: string }> { return this.call({ method: 'vpn.configure', params, timeout: 60000, }) } async vpnDisconnect(): Promise<{ disconnected: boolean }> { return this.call({ method: 'vpn.disconnect', params: {}, }) } // Marketplace async marketplaceDiscover(): Promise<{ apps: Array<{ manifest: { app_id: string name: string version: string description: { short: string; long: string } | string author: { name: string; did: string; nostr_pubkey: string } container: { image: string; ports: Array<{ container: number; host: number }>; } category: string icon_url: string repo_url: string license: string } trust_score: number trust_tier: string relay_count: number first_seen: string nostr_pubkey: string }> relay_count: number }> { return this.call({ method: 'marketplace.discover', params: {}, timeout: 30000, }) } // DNS async dnsStatus(): Promise<{ provider: string servers: string[] doh_enabled: boolean doh_url: string | null resolv_conf_servers: string[] }> { return this.call({ method: 'network.dns-status', params: {}, }) } async configureDns(params: { provider: 'system' | 'cloudflare' | 'google' | 'quad9' | 'mullvad' | 'custom' servers?: string[] }): Promise<{ ok: boolean provider: string servers: string[] doh_enabled: boolean doh_url: string | null }> { return this.call({ method: 'network.configure-dns', params, }) } // Disk management async diskStatus(): Promise<{ used_bytes: number total_bytes: number free_bytes: number used_percent: number level: 'ok' | 'warning' | 'critical' }> { return this.call({ method: 'system.disk-status' }) } async diskCleanup(): Promise<{ freed_bytes: number freed_human: string actions: string[] }> { return this.call({ method: 'system.disk-cleanup', timeout: 60000, }) } } export const rpcClient = new RPCClient()