archy/neode-ui/src/api/rpc-client.ts
archipelago 67454974b2
All checks were successful
Demo images / Build & push demo images (push) Successful in 3m28s
feat(ui): shared stale-while-revalidate layer — useCachedResource + resources store + rpc-client abort/dedup/retry controls
Part B1+B2 of docs/FIPS-UPTIME-AND-UI-STATE-PLAN.md. Foundation for pages
that render instantly from cache on revisit and revalidate in the
background, instead of unmount-refetch-spinner on every navigation.

- stores/resources.ts: keyed {data, loadState, fetchedAt, error} entries
  with sticky-ready (never regress ready→loading), keep-last-value on
  error, per-key in-flight dedup, sessionStorage snapshot hydrate,
  debounced invalidate() fan-out, optimistic-update-with-rollback
- composables/useCachedResource.ts: SWR hook over the store — synchronous
  hydrate, TTL-gated background revalidate, revalidate-on-focus,
  abort-on-unmount fetcher signal
- rpc-client: AbortSignal support (aborts pending retries too), opt-in
  in-flight dedup keyed method+params, per-call maxRetries override
- 10 tests covering the SWR semantics

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-27 20:54:51 -04:00

1130 lines
33 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// RPC Client for connecting to Archipelago backend
export interface RPCOptions {
method: string
params?: Record<string, unknown>
timeout?: number
/** Abort the call (and any pending retries) from the outside — pass a
* component-scoped controller's signal so fan-outs stop on unmount. */
signal?: AbortSignal
/** Per-call retry budget (default 3). Use 1 for calls whose caller has its
* own timeout/fallback UX — retry×3 on a slow peer is how one unreachable
* node turns a 30s timeout into a 90s spinner. */
maxRetries?: number
/** Collapse concurrent identical calls (same method + params) into one
* request. Opt-in: only safe for reads. */
dedup?: boolean
}
export interface RPCResponse<T> {
result?: T
error?: {
code: number
message: string
data?: unknown
}
}
// Multi-version support (docs/bitcoin-multi-version-design.md). Mirrors the
// `package.versions` / `package.set-config` RPC payloads.
export interface CatalogVersionInfo {
version: string
default: boolean
deprecated: boolean
eol: string | null
}
export interface PackageVersionsResponse {
id: string
supportsVersions: boolean
default: string | null
installedVersion: string | null
pinnedVersion: string | null
autoUpdate: boolean
versions: CatalogVersionInfo[]
}
export interface SetPackageConfigResponse {
status: 'ok' | 'confirm_required'
id: string
// present on status === 'ok'
pinnedVersion?: string | null
autoUpdate?: boolean
versionChanged?: boolean
recreating?: boolean
// present on status === 'confirm_required'
kind?: string
currentVersion?: string
targetVersion?: string
warning?: string
}
/// 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
/** In-flight dedup map for `dedup: true` calls, keyed method+params. */
private inflight = new Map<string, Promise<unknown>>()
constructor(baseUrl: string = '/rpc/v1') {
this.baseUrl = baseUrl
}
async call<T>(options: RPCOptions): Promise<T> {
if (options.dedup) {
const key = `${options.method}:${JSON.stringify(options.params ?? {})}`
const existing = this.inflight.get(key)
if (existing) return existing as Promise<T>
const p = this.callInner<T>(options).finally(() => this.inflight.delete(key))
this.inflight.set(key, p)
return p
}
return this.callInner<T>(options)
}
private async callInner<T>(options: RPCOptions): Promise<T> {
const { method, params = {}, timeout = 15000, signal: external } = options
const maxRetries = Math.max(1, options.maxRetries ?? 3)
for (let attempt = 0; attempt < maxRetries; attempt++) {
if (external?.aborted) throw new Error('Aborted')
const controller = new AbortController()
const timeoutId = setTimeout(() => controller.abort(), timeout)
const onExternalAbort = () => controller.abort()
external?.addEventListener('abort', onExternalAbort, { once: true })
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)
external?.removeEventListener('abort', onExternalAbort)
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')
// Already on the login page: redirecting = a full reload of the
// page we're on. Any background poll that 401s would refresh
// /login in a loop forever (seen with the global mesh poll).
const isLogin = window.location.pathname.startsWith('/login')
console.warn(`[RPC] 401 on ${method} | path=${window.location.pathname} | onboarding=${isOnboarding} | redirecting=${RPCClient._sessionExpiredRedirecting}`)
if (!isOnboarding && !isLogin && !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)
external?.removeEventListener('abort', onExternalAbort)
if (error instanceof Error) {
if (error.name === 'AbortError') {
// Caller-initiated abort is final — never retried.
if (external?.aborted) throw new Error('Aborted')
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; ssh_updated?: boolean; ssh_error?: string | null }> {
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 },
})
}
/** Pay a Lightning invoice and resolve it to a REAL terminal state.
*
* The backend waits up to 120s on LND's synchronous pay endpoint; if the
* payment is still routing after that it answers `status: "pending"` with
* the payment hash instead of an error. This helper then polls
* lnd.paymentstatus until LND itself reports succeeded/failed, so callers
* never show "failed" for a payment that is merely slow — the bug where a
* settling payment was declared failed and then appeared in history a
* minute later. Returns `pending` only if the payment is STILL in flight
* after the polling window (rare; caller should say "still settling",
* not "failed"). */
async payLightningInvoice(params: {
payment_request: string
amount_sats?: number
}): Promise<{
status: 'succeeded' | 'failed' | 'pending'
payment_hash: string
amount_sats: number
failure_reason?: string
}> {
const res = await this.call<{
status?: string
payment_hash?: string
amount_sats?: number
}>({
method: 'lnd.payinvoice',
params,
// Above the backend's 120s wait so the backend always answers first.
timeout: 130000,
})
const hash = res.payment_hash || ''
const amount = res.amount_sats || 0
// Older backends have no status field — a plain response was a success.
if (res.status !== 'pending') {
return { status: 'succeeded', payment_hash: hash, amount_sats: amount }
}
if (!hash) return { status: 'pending', payment_hash: '', amount_sats: amount }
// Poll to a terminal state: every 3s for up to 2 minutes.
for (let i = 0; i < 40; i++) {
await new Promise((r) => setTimeout(r, 3000))
try {
const st = await this.call<{
status: string
failure_reason?: string
amount_sats?: number
}>({
method: 'lnd.paymentstatus',
params: { payment_hash: hash },
timeout: 15000,
})
if (st.status === 'succeeded') {
return { status: 'succeeded', payment_hash: hash, amount_sats: st.amount_sats || amount }
}
if (st.status === 'failed') {
return {
status: 'failed',
payment_hash: hash,
amount_sats: amount,
failure_reason: st.failure_reason || 'Payment failed',
}
}
} catch {
// Transient poll error — keep trying; only LND decides failure.
}
}
return { status: 'pending', payment_hash: hash, amount_sats: amount }
}
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 },
// Backend health-dial timeout is 12s — 15s here covers RPC overhead.
timeout: 15000,
})
}
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: 600000,
})
}
async uninstallPackage(
id: string,
options: { preserveData?: boolean } = {},
): 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, ...(options.preserveData !== undefined ? { preserve_data: options.preserveData } : {}) },
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,
})
}
// Multi-version support (docs/bitcoin-multi-version-design.md): list the
// versions a runner may install / switch to for an app, plus the current pin
// / auto-update preference and the version actually running.
async getPackageVersions(id: string): Promise<PackageVersionsResponse> {
return this.call({
method: 'package.versions',
params: { id },
timeout: 15000,
})
}
// Persist a version pin (or un-pin to track latest) and/or the auto-update
// toggle. A DOWNGRADE returns { status: 'confirm_required', warning } unless
// confirm:true is passed — the UI warns first, then re-calls with confirm.
async setPackageConfig(
id: string,
opts: { version?: string; autoUpdate?: boolean; confirm?: boolean },
): Promise<SetPackageConfigResponse> {
return this.call({
method: 'package.set-config',
params: { id, ...opts },
timeout: 600000,
})
}
async checkPackageUpdates(): Promise<{
status: string
refreshed: boolean
catalog_apps?: number
error?: string
}> {
// Refreshes the remote app catalog now (decoupled from the binary OTA).
// Per-app `available-update` badges repopulate on the next package scan
// and arrive via the usual WebSocket push.
return this.call({
method: 'package.check-updates',
timeout: 25000,
})
}
async getMarketplace(url: string): Promise<Record<string, unknown>> {
return this.call({
method: 'marketplace.get',
params: { url },
})
}
// Federation
async federationInvite(
trustLevel: 'trusted' | 'observer' = 'trusted'
): Promise<{ code: string; did: string; onion: string; trust_level: string }> {
return this.call({
method: 'federation.invite',
params: { trust_level: trustLevel },
})
}
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
lat?: number | null
lon?: number | null
}
}>
}> {
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()