archy/neode-ui/src/api/rpc-client.ts
archipelago 0733ac4034 fix(ui): shorten install/uninstall/update timeouts for async RPCs
With the backend flipped to async-spawn, install/uninstall/update return
immediately with a { status, package_id } envelope. Client timeouts of
45m/11m were a leftover from synchronous handlers and masked real RPC
failures.

Drop all install/uninstall/update RPC timeouts to 15s. Progress and
terminal state still arrive through the live state stream — the RPC
only needs to confirm the spawn was accepted.

Return-type annotations updated in rpc-client.ts and stores/server.ts.
Five direct rpcClient.call sites across Marketplace.vue, Discover.vue,
and MarketplaceAppDetails.vue updated with the shorter timeout.
2026-04-23 06:58:02 -04:00

944 lines
26 KiB
TypeScript

// RPC Client for connecting to Archipelago backend
export interface RPCOptions {
method: string
params?: Record<string, unknown>
timeout?: number
}
export interface RPCResponse<T> {
result?: T
error?: {
code: number
message: string
data?: unknown
}
}
/// Mirrors `crate::federation::pending::PendingPeerRequest` on the backend.
export type PendingState = 'pending' | 'sent' | 'approved' | 'rejected' | 'expired'
export interface PendingPeerRequest {
id: string
from_nostr_pubkey: string
from_nostr_npub: string
from_did: string
from_name: string | null
message: string | null
received_at: string
state: PendingState
outbound: boolean
}
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<T>(options: RPCOptions): Promise<T> {
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<string, string> = {
'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') {
// Clear stale auth immediately — stops App.vue watcher from
// firing more requests and prevents the router from
// optimistically navigating to /dashboard
try { localStorage.removeItem('neode-auth') } catch { /* noop */ }
const isOnboarding = window.location.pathname.startsWith('/onboarding')
console.warn(`[RPC] 401 on ${method} | path=${window.location.pathname} | onboarding=${isOnboarding} | redirecting=${RPCClient._sessionExpiredRedirecting}`)
if (!isOnboarding && !RPCClient._sessionExpiredRedirecting) {
RPCClient._sessionExpiredRedirecting = true
console.warn(`[RPC] Session expired — redirecting to /login in 300ms`)
setTimeout(() => {
window.location.href = '/login'
}, 300)
}
throw new Error('Session expired')
}
// 403: read body to distinguish CSRF (retryable) from RBAC (permanent)
if (response.status === 403) {
let reason = ''
try {
const body: RPCResponse<unknown> = await response.json()
reason = body.error?.message || ''
} catch { /* body parse failed */ }
const isCsrf = !reason || reason.toLowerCase().includes('csrf')
if (isCsrf && attempt < maxRetries - 1) {
// CSRF mismatch — cookie may have been updated by a concurrent
// Set-Cookie response not yet visible to JS. Retry after delay.
await new Promise((r) => setTimeout(r, 500))
continue
}
throw new Error(reason || `HTTP 403: Forbidden`)
}
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<T> = 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<void> {
return this.call({
method: 'auth.logout',
params: {},
})
}
async completeOnboarding(): Promise<boolean> {
return this.call({
method: 'auth.onboardingComplete',
params: {},
})
}
async isOnboardingComplete(): Promise<boolean> {
return this.call({
method: 'auth.isOnboardingComplete',
params: {},
})
}
async resetOnboarding(): Promise<boolean> {
return this.call({
method: 'auth.resetOnboarding',
params: {},
})
}
// ─── Seed Management ───────────────────────────────────────────────
async generateSeed(): Promise<{ words: string[] }> {
return this.call({ method: 'seed.generate' })
}
async verifySeed(words: string[], indices: number[]): Promise<{
verified: boolean
did: string
nostr_npub: string
}> {
return this.call({ method: 'seed.verify', params: { words, indices } })
}
async restoreSeed(words: string[]): Promise<{
did: string
nostr_npub: string
restored: boolean
}> {
return this.call({ method: 'seed.restore', params: { words } })
}
async saveSeedEncrypted(passphrase: string): Promise<{ saved: boolean }> {
return this.call({ method: 'seed.save-encrypted', params: { passphrase } })
}
async seedStatus(): Promise<{
has_seed: boolean
is_legacy: boolean
identity_count: number
next_index: number
}> {
return this.call({ method: 'seed.status' })
}
// ─── Node Identity ───────────────────────────────────────────────
async getNodeDid(): Promise<{ did: string; pubkey: string; nostr_pubkey?: string; nostr_npub?: 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<Record<string, unknown>> {
return this.call({
method: 'identity.resolve-did',
params: did ? { did } : {},
})
}
async createPresentation(params: {
holderId: string
credentialIds: string[]
}): Promise<Record<string, unknown>> {
return this.call({
method: 'identity.create-presentation',
params: { holder_id: params.holderId, credential_ids: params.credentialIds },
})
}
async verifyPresentation(presentation: Record<string, unknown>): 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; from_name?: string; message: string; timestamp: string; direction?: 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<string> {
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<Record<string, unknown>> {
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<void> {
return this.call({
method: 'server.restart',
params: {},
})
}
async shutdownServer(): Promise<void> {
return this.call({
method: 'server.shutdown',
params: {},
})
}
async installPackage(
id: string,
marketplaceUrl: string,
version: string,
): Promise<{ status: string; package_id: string }> {
// Backend is async — returns { status: 'installing' } in <1s after
// flipping state and spawning the pull/install pipeline. Progress is
// streamed via WebSocket (install_progress field on the package entry).
return this.call({
method: 'package.install',
params: { id, 'marketplace-url': marketplaceUrl, version },
timeout: 15000,
})
}
async uninstallPackage(id: string): Promise<{ status: string; package_id: string }> {
// Backend is async — returns { status: 'removing' } immediately after
// flipping state. Graceful stop (up to 600s for bitcoin) and data wipe
// (up to minutes for large chainstate) run in a background task.
// Progress shown via uninstall_stage field on the package entry.
return this.call({
method: 'package.uninstall',
params: { id },
timeout: 15000,
})
}
async startPackage(id: string): Promise<void> {
return this.call({
method: 'package.start',
params: { id },
timeout: 15000,
})
}
async stopPackage(id: string): Promise<void> {
return this.call({
method: 'package.stop',
params: { id },
timeout: 15000,
})
}
async restartPackage(id: string): Promise<void> {
return this.call({
method: 'package.restart',
params: { id },
timeout: 15000,
})
}
async updatePackage(id: string): Promise<{ status: string; package_id: string }> {
// Backend is async — returns { status: 'updating' } immediately after
// flipping state. Pull / stop / recreate / verify runs in background,
// with rollback-on-failure.
return this.call({
method: 'package.update',
params: { id },
timeout: 15000,
})
}
async getMarketplace(url: string): Promise<Record<string, unknown>> {
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 meshContactsList(): Promise<{
contacts: Array<{ pubkey: string; alias?: string | null; notes?: string | null; pinned?: boolean; blocked?: boolean }>
}> {
return this.call({ method: 'mesh.contacts-list', params: {} })
}
async meshContactsSave(
pubkey: string,
alias?: string | null,
): Promise<{ saved: boolean; pubkey: string; alias: string | null }> {
const params: Record<string, unknown> = { pubkey }
if (alias !== undefined) params.alias = alias
return this.call({ method: 'mesh.contacts-save', params })
}
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
node_name?: 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
nostr_npub?: string
}
}>
}> {
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 },
})
}
// Nostr peer-discovery — see `core/archipelago/src/nostr_handshake.rs`.
// None of these methods ever exchange the local onion address on a public
// relay. `handshake.discover` returns presence-only events (DID + npub);
// `handshake.connect` ships a NIP-44-encrypted PeerRequest with no onion;
// `handshake.poll` queues inbound requests into the federation pending
// inbox for manual approval (it does NOT auto-accept).
async nostrDiscoveryStatus(): Promise<{ enabled: boolean }> {
return this.call({ method: 'nostr.discovery-status', params: {} })
}
async nostrSetDiscovery(enabled: boolean): Promise<{ enabled: boolean }> {
return this.call({
method: 'nostr.set-discovery',
params: { enabled },
timeout: 30000,
})
}
async handshakeDiscover(): Promise<{
nodes: Array<{
nostr_pubkey: string
nostr_npub: string
did: string
version: string
}>
}> {
return this.call({ method: 'handshake.discover', params: {}, timeout: 30000 })
}
async handshakeConnect(
recipient: string,
message?: string,
name?: string,
): Promise<{ ok: boolean; sent_to: string; id: string }> {
return this.call({
method: 'handshake.connect',
params: {
recipient_nostr_pubkey: recipient,
...(message ? { message } : {}),
...(name ? { name } : {}),
},
timeout: 30000,
})
}
async handshakePoll(): Promise<{
polled: number
new_requests: PendingPeerRequest[]
applied_invites: string[]
rejected_outbound: string[]
skipped: string[]
}> {
return this.call({ method: 'handshake.poll', params: {}, timeout: 30000 })
}
async federationListPendingRequests(): Promise<{
requests: PendingPeerRequest[]
}> {
return this.call({ method: 'federation.list-pending-requests', params: {} })
}
async federationApproveRequest(id: string): Promise<{ approved: boolean; id: string }> {
return this.call({
method: 'federation.approve-request',
params: { id },
timeout: 30000,
})
}
async federationRejectRequest(
id: string,
reason?: string,
notify = false,
): Promise<{ rejected: boolean; id: string }> {
return this.call({
method: 'federation.reject-request',
params: {
id,
...(reason ? { reason } : {}),
notify,
},
timeout: 30000,
})
}
/// Withdraw an outbound peer request we sent. Default notify=true so
/// the recipient's inbound row disappears from their UI (the main
/// UX reason to cancel — avoid leaving a stale handshake dangling).
async federationCancelRequest(
id: string,
reason?: string,
notify = true,
): Promise<{ cancelled: boolean; id: string; notified: boolean }> {
return this.call({
method: 'federation.cancel-request',
params: {
id,
...(reason ? { reason } : {}),
notify,
},
timeout: 30000,
})
}
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
wg_ip?: string | null
node_npub?: string | null
relay_url?: string | null
relay_onion?: string | null
relay_direct?: string | null
}> {
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()