merge: reuse accepted AI provider setup in publishing worktree

This commit is contained in:
archipelago
2026-10-08 12:24:43 -04:00
70 changed files with 3180 additions and 499 deletions
@@ -0,0 +1,28 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { archyBridge } from '@/services/archyBridge'
const originalParent = window.parent
const origin = 'https://node.example'
afterEach(() => { archyBridge.destroy(); Object.defineProperty(window, 'parent', { value: originalParent, configurable: true }); vi.restoreAllMocks() })
describe('trusted provider setup bridge', () => {
it('accepts configuration only from the embedding parent, rejects siblings and other origins', () => {
const parent = { postMessage: vi.fn() }
Object.defineProperty(window, 'parent', { value: parent, configurable: true })
archyBridge.init(origin)
const listener = vi.fn(); const unsubscribe = archyBridge.onProviderConfigured(listener)
const send = (source: unknown, from: string, provider = 'openai') => window.dispatchEvent(new MessageEvent('message', { source: source as Window, origin: from, data: { type: 'ai:provider-configured', provider, model: 'test-model' } }))
send({}, origin); send(parent, 'https://evil.example'); send(parent, origin, 'arbitrary')
expect(listener).not.toHaveBeenCalled()
send(parent, origin)
expect(listener).toHaveBeenCalledExactlyOnceWith({ provider: 'openai', model: 'test-model' })
archyBridge.requestAISetup()
expect(parent.postMessage).toHaveBeenLastCalledWith({ type: 'ai:setup-request' }, origin)
unsubscribe()
})
it('replays the selection when the composer mounts after the handshake', () => {
const parent = { postMessage: vi.fn() }; Object.defineProperty(window, 'parent', { value: parent, configurable: true })
archyBridge.init(origin)
window.dispatchEvent(new MessageEvent('message', { source: parent as unknown as Window, origin, data: { type: 'ai:provider-configured', provider: 'local' } }))
const listener = vi.fn(); const unsubscribe = archyBridge.onProviderConfigured(listener)
expect(listener).toHaveBeenCalledExactlyOnceWith({ provider: 'local', model: '' }); unsubscribe()
})
})
@@ -249,6 +249,24 @@ describe('useAI', () => {
expect(chatStore.isStreaming).toBe(false)
})
it.each([502, 503, 429, 401])('distinguishes HTTP %s from a missing credential', async (status) => {
globalThis.fetch = vi.fn().mockResolvedValue({ ok: false, status, text: async () => 'Provider request failed' })
const store = useChatStore(); store.webSearchEnabled = false
const ai = useAI(); ai.needsApiKey.value = false
await ai.sendMessage('test')
expect(ai.needsApiKey.value).toBe(status === 401)
expect(store.isStreaming).toBe(false)
})
it('offers funding for a Routstr payment-required response without mislabeling it a key error', async () => {
globalThis.fetch = vi.fn().mockResolvedValue({ ok: false, status: 402, text: async () => JSON.stringify({ error: { message: 'Your spending allowance is exhausted' } }) })
const store = useChatStore(); store.webSearchEnabled = false
const ai = useAI(); ai.setProvider('routstr'); ai.needsApiKey.value = false; ai.needsFunding.value = false
await ai.sendMessage('test')
expect(ai.needsFunding.value).toBe(true); expect(ai.needsApiKey.value).toBe(false)
expect(store.messages.find(m => m.role === 'assistant')?.content).toContain('spending allowance')
})
it('handles connection errors gracefully', async () => {
globalThis.fetch = vi.fn().mockRejectedValue(new Error('Network failure'))
@@ -123,6 +123,7 @@
:style="modelPickerDropdownStyle"
@click.stop
>
<button v-if="archyBridge.isInArchy()" class="w-full text-left rounded-lg px-3 py-2 text-sm text-white/90 hover:bg-white/10" @click="showModelPicker = false; archyBridge.requestAISetup()">AI connection</button>
<div v-for="provider in availableProviders" :key="provider.id">
<p class="text-xs font-semibold uppercase tracking-wider mb-1.5 px-1 text-white/40">
{{ provider.name }}
@@ -247,6 +248,7 @@ import { useAI } from '@/composables/useAI'
import { useContentPanel } from '@/composables/useContentPanel'
import { downloadConversation, type ExportFormat } from '@/utils/conversation-export'
import { parseImportFile } from '@/utils/conversation-import'
import { archyBridge } from '@/services/archyBridge'
import { useComparisonMode } from '@/composables/useComparisonMode'
defineProps<{
@@ -332,8 +334,7 @@ const modelDisplayName = computed(() => {
})
function selectModel(providerId: string, modelId: string) {
setProvider(providerId as 'routstr' | 'claude' | 'openrouter' | 'mock')
setModel(modelId)
if (setProvider(providerId as Parameters<typeof setProvider>[0])) setModel(modelId)
showModelPicker.value = false
}
@@ -186,8 +186,9 @@ defineEmits<{
}>()
const chatStore = useChatStore()
const { sendMessage, stopGeneration, editAndResend, regenerateLastResponse, activeModel, needsApiKey } = useAI()
const { sendMessage, stopGeneration, editAndResend, regenerateLastResponse, activeModel, needsApiKey, needsFunding } = useAI()
const { updatePanelFromText, panelOpen, panelFilms, panelTitle, activeTab, availableTabs, setActiveTab, enterDesignSystemMode } = useContentPanel()
import { archyBridge } from '@/services/archyBridge'
import { useCodeContext } from '@/composables/useCodeContext'
import { useVisualViewport } from '@/composables/useVisualViewport'
const codeContext = useCodeContext()
@@ -206,11 +207,19 @@ const showSettings = ref(false)
// without fixing anything).
watch(needsApiKey, (needs) => {
if (needs) {
showSettings.value = true
if (archyBridge.isInArchy()) archyBridge.requestAISetup()
else showSettings.value = true
needsApiKey.value = false
}
})
watch(needsFunding, needed => {
if (!needed) return
if (archyBridge.isInArchy()) archyBridge.requestAISetup('funding')
else showSettings.value = true
needsFunding.value = false
})
// Scroll position memory per conversation
const scrollPositions = new Map<string, number>()
@@ -1,5 +1,9 @@
<template>
<div class="space-y-4">
<div v-if="embedded" class="space-y-3">
<p class="text-sm">AI connections and private keys are managed by this node.</p>
<button class="rounded-lg px-3 py-2 bg-white/10 text-sm" @click="archyBridge.requestAISetup()">Manage AI connection</button>
</div>
<div v-else class="space-y-4">
<h3 class="text-sm font-bold" :class="isDark ? 'text-white/90' : 'text-gray-900'">
API Keys
</h3>
@@ -93,10 +97,12 @@
</template>
<script setup lang="ts">
import { archyBridge } from '@/services/archyBridge'
import { ref, onMounted } from 'vue'
import { useTheme } from '@/composables/useTheme'
import { storeApiKey, getApiKey, deleteApiKey, listProviders, maskApiKey } from '@/utils/key-vault'
const embedded = archyBridge.isInArchy()
const { isDark } = useTheme()
interface ProviderInfo {
@@ -156,5 +162,5 @@ async function removeKey(provider: string) {
await loadProviders()
}
onMounted(loadProviders)
onMounted(() => { if (!embedded) void loadProviders() })
</script>
+31 -28
View File
@@ -13,7 +13,7 @@ import { useCodeContext } from '@/composables/useCodeContext'
import { apiFetch } from '@/utils/api-fetch'
import { useSettingsStore } from '@/stores/settings'
type Provider = 'routstr' | 'claude' | 'openrouter' | 'mock'
type Provider = 'routstr' | 'claude' | 'openrouter' | 'mock' | 'openai' | 'auto' | 'local'
// API paths are relative to the base URL so they work both in dev (/) and Archy (/aiui/)
const BASE = import.meta.env.BASE_URL || '/'
@@ -120,34 +120,15 @@ Prioritize Podcasting 2.0–friendly platforms: Fountain.fm, Podcast Index, Cast
Always include these tags so the UI can render rich cards. Write a brief reason why each is worth checking out.
${librarySection}`
const activeProvider = ref<Provider>('claude')
const activeProvider = ref<Provider>(archyBridge.isInArchy() ? 'auto' : 'claude')
const activeModel = ref('claude-haiku-4.5')
// One-shot signal a send/regenerate/edit failure looked like a missing or
// invalid API key (or an unreachable proxy) rather than a transient/server
// error — consumed by ChatWindow.vue to auto-open Settings so the user isn't
// left in a dead end with no obvious next step. Deliberately narrow (401/403,
// explicit "api key"/"unauthorized" text, or a connection-level failure to
// reach the proxy at all) so a rate-limited or momentarily-flaky provider
// response does NOT send the user to Settings for a problem Settings can't
// fix. Reset to false by the consumer immediately after acting on it, so it
// behaves as a pulse rather than sticky state (each new failure can re-fire).
// Credentials require setup; network failures and provider outages require retry.
const needsApiKey = ref(false)
const needsFunding = ref(false)
function looksLikeMissingApiKey(err: string): boolean {
const lower = err.toLowerCase()
return (
/\b(401|403)\b/.test(err) ||
lower.includes('api key') ||
lower.includes('x-api-key') ||
lower.includes('unauthorized') ||
lower.includes('authentication_error') ||
lower.includes('failed to fetch') ||
lower.includes('econnrefused') ||
lower.includes(' 502') ||
lower.includes(' 503')
)
return /\b(401|403)\b|api[ _-]?key|unauthorized|authentication_error|credential/i.test(err)
}
// ─── Routstr model catalog (fetched from the node's session-gated proxy) ───
@@ -161,7 +142,7 @@ async function refreshRoutstrModels() {
routstrModelsFetched = true
try {
const res = await apiFetch(ROUTSTR_MODELS_PATH)
if (!res.ok) return
if (!res.ok) { routstrModelsFetched = false; return }
const data = await res.json()
if (Array.isArray(data?.data)) {
routstrModels.value = data.data
@@ -170,13 +151,21 @@ async function refreshRoutstrModels() {
id: m.id as string,
name: (m.name as string) || (m.id as string),
}))
}
if (activeProvider.value === 'routstr' && activeModel.value === 'routstr-unavailable' && routstrModels.value[0]) activeModel.value = routstrModels.value[0].id
} else { routstrModelsFetched = false }
} catch {
routstrModelsFetched = false // allow a retry on the next send/open
}
}
const availableProviders = computed(() => {
if (archyBridge.isInArchy()) return [
{ id: 'local' as Provider, name: 'Local AI', models: [{ id: 'node', name: 'Node configuration' }] },
{ id: 'auto' as Provider, name: 'Node AI', models: [{ id: 'node', name: 'Node configuration' }] },
{ id: 'claude' as Provider, name: 'Claude API', models: [{ id: 'node', name: 'Node configuration' }] },
{ id: 'openai' as Provider, name: 'OpenAI API', models: [{ id: activeProvider.value === 'openai' ? activeModel.value : 'node', name: activeProvider.value === 'openai' ? activeModel.value : 'Configure model' }] },
{ id: 'routstr' as Provider, name: 'Routstr (sats)', models: routstrModels.value.length ? routstrModels.value : [{ id: 'routstr-unavailable', name: 'Models unavailable — retry' }] },
]
const providers: { id: Provider; name: string; models: { id: string; name: string }[] }[] = [
{
id: 'routstr',
@@ -187,7 +176,7 @@ const availableProviders = computed(() => {
},
{
id: 'claude',
name: 'Claude (Max)',
name: 'Claude API',
models: [
{ id: 'claude-haiku-4.5', name: 'Claude 4.5 Haiku' },
{ id: 'claude-sonnet-4', name: 'Claude Sonnet 4' },
@@ -207,24 +196,36 @@ const availableProviders = computed(() => {
})
providers.push({
id: 'mock',
name: 'Local (no API)',
name: 'Demo echo',
models: [{ id: 'echo', name: 'Echo (mirror input)' }],
})
return providers
})
function setProvider(provider: Provider) {
if (archyBridge.isInArchy()) {
if (provider === 'routstr' && activeProvider.value === 'routstr') return true
archyBridge.requestAISetup(); return false
}
activeProvider.value = provider
const p = availableProviders.value.find((pp) => pp.id === provider)
if (p && p.models.length > 0) {
activeModel.value = p.models[0].id
}
return true
}
function setModel(model: string) {
if (archyBridge.isInArchy() && activeProvider.value !== 'routstr') { archyBridge.requestAISetup(); return }
activeModel.value = model
}
archyBridge.onProviderConfigured(({ provider, model }) => {
activeProvider.value = provider
activeModel.value = model || (provider === 'routstr' ? routstrModels.value[0]?.id || 'routstr-unavailable' : 'node')
if (provider === 'routstr') void refreshRoutstrModels()
})
interface ChatMessage {
role: 'user' | 'assistant'
content: string
@@ -448,6 +449,7 @@ async function streamRoutstr(
})
const bodyText = await res.text().catch(() => '')
if (res.status === 402) needsFunding.value = true
if (!res.ok) {
// The node's refusals carry a plain-language error.message (budget not
// set, budget spent, wallet can't fund) — surface it verbatim.
@@ -974,5 +976,6 @@ export function useAI() {
setProvider,
setModel,
needsApiKey,
needsFunding,
}
}
+22 -1
View File
@@ -55,6 +55,10 @@ interface ThemeInfo {
type PermissionsCallback = (categories: AIContextCategory[]) => void
type ThemeCallback = (theme: ThemeInfo) => void
export interface AIProviderSelection { provider: 'auto' | 'local' | 'claude' | 'openai' | 'routstr'; model: string }
const providerCallbacks = new Set<(selection: AIProviderSelection) => void>()
let currentProvider: AIProviderSelection | null = null
let requestId = 0
const pendingRequests = new Map<string, {
resolve: (value: unknown) => void
@@ -80,12 +84,19 @@ function postToParent(msg: unknown) {
function handleMessage(event: MessageEvent) {
// Always validate origin — reject if not configured or mismatched
if (!allowedOrigin || event.origin !== allowedOrigin) return
if (!allowedOrigin || event.origin !== allowedOrigin || event.source !== window.parent) return
const msg = event.data
if (!msg || typeof msg.type !== 'string') return
switch (msg.type) {
case 'ai:provider-configured': {
if (!['auto', 'local', 'claude', 'openai', 'routstr'].includes(msg.provider)) break
const selection = { provider: msg.provider as AIProviderSelection['provider'], model: typeof msg.model === 'string' ? msg.model : '' }
currentProvider = selection
for (const callback of providerCallbacks) callback(selection)
break
}
case 'context:response': {
const pending = pendingRequests.get(msg.id)
if (pending) {
@@ -223,11 +234,21 @@ export const archyBridge = {
}
},
requestAISetup(reason?: 'funding') { postToParent({ type: 'ai:setup-request', ...(reason ? { reason } : {}) }) },
onProviderConfigured(callback: (selection: AIProviderSelection) => void) {
providerCallbacks.add(callback)
if (currentProvider) callback(currentProvider)
return () => { providerCallbacks.delete(callback) }
},
/** Clean up listeners */
destroy() {
window.removeEventListener('message', handleMessage)
pendingRequests.clear()
initialized = false
currentProvider = null
allowedOrigin = null
},
/** Check if running inside Archy iframe */
+8 -7
View File
@@ -352,20 +352,19 @@ impl RpcHandler {
(Some(u), Some(t)) if t > 0 => {
serde_json::json!((u as f64 / t as f64 * 100.0).round())
}
_ => serde_json::json!(0),
_ => serde_json::Value::Null,
}
};
let apps = state.map(|s| s.apps.as_slice()).unwrap_or(&[]);
let reported_at = state
.map(|s| s.timestamp.clone())
.or_else(|| n.last_seen.clone())
.unwrap_or_else(|| n.added_at.clone());
.or_else(|| n.last_seen.clone());
let mut report = serde_json::json!({
"node_id": n.did,
"node_name": state.and_then(|s| s.node_name.clone()).or_else(|| n.name.clone()),
"uptime_secs": state.and_then(|s| s.uptime_secs).unwrap_or(0),
"cpu_pct": state.and_then(|s| s.cpu_usage_percent).map(|v| v.round()).unwrap_or(0.0),
"uptime_secs": state.and_then(|s| s.uptime_secs),
"cpu_pct": state.and_then(|s| s.cpu_usage_percent).filter(|v| v.is_finite() && (0.0..=100.0).contains(v)).map(|v| v.round()),
"mem_pct": pct(state.and_then(|s| s.mem_used_bytes), state.and_then(|s| s.mem_total_bytes)),
"disk_pct": pct(state.and_then(|s| s.disk_used_bytes), state.and_then(|s| s.disk_total_bytes)),
"container_count": apps.len(),
@@ -561,7 +560,7 @@ fn annotate_fleet_report(report: &mut serde_json::Value) {
let is_online = reported
.map(|dt| {
let age = chrono::Utc::now().signed_duration_since(dt);
age.num_minutes() < 30
age.num_seconds() >= -60 && age.num_seconds() < 1800
})
.unwrap_or(false);
@@ -569,7 +568,9 @@ fn annotate_fleet_report(report: &mut serde_json::Value) {
.map(|dt| {
let age = chrono::Utc::now().signed_duration_since(dt);
let mins = age.num_minutes();
if mins < 1 {
if age.num_seconds() < -60 {
"unknown (clock ahead)".to_string()
} else if mins < 1 {
"just now".to_string()
} else if mins < 60 {
format!("{}m ago", mins)
@@ -602,14 +602,39 @@ impl RpcHandler {
None
};
// Reuse the minute collector instead of running expensive probes for
// every peer. An absent/stalled collector is unknown, never zero load.
let now = chrono::Utc::now().timestamp();
let latest = self
.metrics_store
.latest()
.await
.filter(|sample| (0..=180).contains(&now.saturating_sub(sample.timestamp)));
let metrics = latest.as_ref().map(|sample| &sample.system);
let uptime = tokio::fs::read_to_string("/proc/uptime")
.await
.ok()
.and_then(|s| s.split_whitespace().next()?.parse::<f64>().ok())
.filter(|v| v.is_finite() && *v >= 0.0)
.map(|v| v as u64);
let state = federation::build_local_state(
apps,
0.0,
0,
0,
0,
0,
0,
metrics
.map(|m| m.cpu_percent)
.filter(|v| v.is_finite() && (0.0..=100.0).contains(v)),
metrics
.filter(|m| m.mem_total_bytes > 0)
.map(|m| m.mem_used_bytes),
metrics
.filter(|m| m.mem_total_bytes > 0)
.map(|m| m.mem_total_bytes),
metrics
.filter(|m| m.disk_total_bytes > 0)
.map(|m| m.disk_used_bytes),
metrics
.filter(|m| m.disk_total_bytes > 0)
.map(|m| m.disk_total_bytes),
uptime,
tor_active,
server_name,
nostr_npub,
@@ -1254,76 +1279,120 @@ impl RpcHandler {
);
}
let reply = self.prepare_peer_approval_reply(&req).await?;
// Persist the operator decision before transport. A relay outage must
// not require another approval or lose the already-authorized reply.
pending::decide(&self.config.data_dir, id, pending::PendingState::Approved).await?;
let delivered = self.deliver_peer_approval_reply(&reply).await?;
Ok(serde_json::json!({ "approved": true, "id": id, "delivery_pending": !delivered }))
}
async fn prepare_peer_approval_reply(
&self,
req: &pending::PendingPeerRequest,
) -> Result<federation::handshake_delivery::ApprovalReply> {
use federation::handshake_delivery::{self, ApprovalReply};
if let Some(reply) = handshake_delivery::find(&self.config.data_dir, &req.id).await? {
anyhow::ensure!(
reply.recipient == req.from_nostr_pubkey && reply.expected_did == req.from_did,
"Approval recipient changed"
);
return Ok(reply);
}
let (data, _) = self.state_manager.get_snapshot().await;
let local_did = identity::did_key_from_pubkey_hex(&data.server_info.pubkey)?;
let local_onion = data
.server_info
.tor_address
.clone()
.as_deref()
.ok_or_else(|| anyhow::anyhow!("Tor address not available"))?;
let local_pubkey = data.server_info.pubkey.clone();
// Generate a one-shot federation invite. The code embeds OUR onion
// and OUR pubkey, but it leaves this box only inside the NIP-44
// ciphertext below.
let identity_dir = self.config.data_dir.join("identity");
let local_fips_npub = identity::fips_npub(&identity_dir).await.unwrap_or(None);
// Discovery/connection-request approvals admit the requester as
// Observer — the invite itself now carries that level, so both
// sides converge on Observer without post-hoc demotion.
let local_fips_npub = identity::fips_npub(&self.config.data_dir.join("identity"))
.await
.unwrap_or(None);
let invite_code = federation::create_invite(
&self.config.data_dir,
&local_did,
&local_onion,
&local_pubkey,
local_onion,
&data.server_info.pubkey,
local_fips_npub.as_deref(),
TrustLevel::Observer,
)
.await?;
handshake_delivery::stage(
&self.config.data_dir,
ApprovalReply {
request_id: req.id.clone(),
recipient: req.from_nostr_pubkey.clone(),
expected_did: req.from_did.clone(),
invite_code,
attempts: 0,
next_attempt: 0,
},
)
.await
}
// Pre-add the requester to OUR federation list as Observer so that
// when their `federation.peer-joined` callback arrives over Tor we
// already trust their pubkey enough to accept the join. Their DID
// and pubkey come from the request — we'll cross-check the pubkey
// against the eventual peer-joined signature in the existing
// verification path (handlers.rs line ~365).
if !req.from_did.is_empty() {
// We don't know the requester's onion or ed25519 pubkey yet —
// they'll send those in the federation.peer-joined callback
// after they apply our invite. Until then we can't add a real
// FederatedNode entry. We just store the pending row as
// Approved so the UI shows progress, and trust the existing
// peer-joined handler to admit them as Observer when they call.
//
// Caveat: peer-joined currently hardcodes TrustLevel::Trusted.
// We override that below by demoting on success.
debug!(
requester_did = %req.from_did,
"Approval pending — waiting for federation.peer-joined callback over Tor"
);
}
// Encrypt + send the invite over NIP-44 to the requester.
let identity_dir = self.config.data_dir.join("identity");
nostr_handshake::send_peer_invite(
&identity_dir,
&req.from_nostr_pubkey,
&invite_code,
&self.config.nostr_relays,
async fn deliver_peer_approval_reply(
&self,
reply: &federation::handshake_delivery::ApprovalReply,
) -> Result<bool> {
let Some(claimed) = federation::handshake_delivery::claim(
&self.config.data_dir,
&reply.request_id,
chrono::Utc::now().timestamp(),
)
.await?
else {
return Ok(false);
};
let result = nostr_handshake::send_peer_invite(
&self.config.data_dir.join("identity"),
&claimed.recipient,
&claimed.invite_code,
&self.handshake_relays().await,
self.config.nostr_tor_proxy.as_deref(),
)
.await?;
.await;
if result.is_err() {
warn!(request_id = %reply.request_id, "Peer approval delivery deferred; durable retry scheduled");
}
Ok(result.is_ok())
}
pending::set_state(&self.config.data_dir, id, pending::PendingState::Approved).await?;
info!(
id = %id,
from = %req.from_nostr_pubkey,
"Approved peer request and shipped invite over NIP-44"
);
Ok(serde_json::json!({
"approved": true,
"id": id,
}))
/// Recover relay loss and legacy approvals without changing trust or
/// resurrecting a node that the operator explicitly removed.
pub(in crate::api::rpc) async fn retry_peer_approval_replies(&self) -> Result<()> {
let requests = pending::load_pending(&self.config.data_dir).await?;
let nodes = federation::load_nodes(&self.config.data_dir).await?;
let removed = federation::load_removed_dids(&self.config.data_dir).await?;
let cutoff = chrono::Utc::now() - chrono::Duration::days(30);
let mut sent = 0;
for req in requests {
if req.outbound || req.state != pending::PendingState::Approved {
federation::handshake_delivery::remove(&self.config.data_dir, &req.id).await?;
continue;
}
let expired = chrono::DateTime::parse_from_rfc3339(&req.received_at)
.map(|time| time < cutoff)
.unwrap_or(true);
if expired
|| removed.contains(&req.from_did)
|| nodes.iter().any(|node| node.did == req.from_did)
{
federation::handshake_delivery::remove(&self.config.data_dir, &req.id).await?;
continue;
}
if req.from_did.is_empty() || sent >= 4 {
continue;
}
let reply = self.prepare_peer_approval_reply(&req).await?;
if reply.next_attempt > chrono::Utc::now().timestamp() {
continue;
}
self.deliver_peer_approval_reply(&reply).await?;
sent += 1;
}
Ok(())
}
/// federation.reject-request — drop a pending request and, if requested,
@@ -1353,19 +1422,19 @@ impl RpcHandler {
);
}
pending::decide(&self.config.data_dir, id, pending::PendingState::Rejected).await?;
if notify {
let identity_dir = self.config.data_dir.join("identity");
let _ = nostr_handshake::send_peer_reject(
&identity_dir,
&req.from_nostr_pubkey,
reason,
&self.config.nostr_relays,
&self.handshake_relays().await,
self.config.nostr_tor_proxy.as_deref(),
)
.await;
}
pending::set_state(&self.config.data_dir, id, pending::PendingState::Rejected).await?;
info!(id = %id, from = %req.from_nostr_pubkey, "Rejected peer request");
Ok(serde_json::json!({ "rejected": true, "id": id }))
}
@@ -1410,7 +1479,7 @@ impl RpcHandler {
&identity_dir,
&req.from_nostr_pubkey,
reason,
&self.config.nostr_relays,
&self.handshake_relays().await,
self.config.nostr_tor_proxy.as_deref(),
)
.await
@@ -0,0 +1,276 @@
//! Exercise encrypted replies through a relay configured in the UI only.
use crate::federation::pending::{self, PendingState};
use futures_util::{SinkExt, StreamExt};
use nostr_sdk::prelude::{nip44, Event, Keys};
use std::sync::Arc;
use std::time::Duration;
#[tokio::test]
async fn managed_relay_receives_approval_rejection_and_cancellation() {
for (operation, accepted) in [
("approve", true),
("reject", true),
("cancel", true),
("approve", false),
("retry", true),
] {
let tmp = tempfile::tempdir().unwrap();
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let relay_url = format!("ws://{}", listener.local_addr().unwrap());
let relay = tokio::spawn(async move {
let (socket, _) = listener.accept().await.unwrap();
let mut ws = tokio_tungstenite::accept_async(socket).await.unwrap();
while let Some(Ok(message)) = ws.next().await {
if !message.is_text() {
continue;
}
let value: serde_json::Value =
serde_json::from_str(message.to_text().unwrap()).unwrap();
if value[0] != "EVENT" {
continue;
}
let event: Event = serde_json::from_value(value[1].clone()).unwrap();
event.verify().unwrap();
ws.send(tokio_tungstenite::tungstenite::Message::Text(
serde_json::json!([
"OK",
event.id.to_hex(),
accepted,
"blocked: fixture rejection"
])
.to_string(),
))
.await
.unwrap();
return event;
}
panic!("relay closed without a signed event");
});
let mut config = crate::config::Config::default();
config.data_dir = tmp.path().to_path_buf();
config.nostr_relays.clear();
config.nostr_tor_proxy = None;
crate::nostr_relays::save_relays(
tmp.path(),
&crate::nostr_relays::RelayStore {
relays: vec![crate::nostr_relays::RelayConfig {
url: relay_url,
enabled: true,
added_at: chrono::Utc::now().to_rfc3339(),
}],
},
)
.await
.unwrap();
let sender = Keys::parse(&"11".repeat(32)).unwrap();
let recipient = Keys::parse(&"22".repeat(32)).unwrap();
let identity_dir = tmp.path().join("identity");
tokio::fs::create_dir_all(&identity_dir).await.unwrap();
tokio::fs::write(identity_dir.join("nostr_secret"), "11".repeat(32))
.await
.unwrap();
tokio::fs::write(identity_dir.join("node_key"), [0x33; 32])
.await
.unwrap();
let state = Arc::new(crate::state::StateManager::new());
state
.mutate_data(|data| {
data.server_info.pubkey = "33".repeat(32);
data.server_info.tor_address = Some(format!("{}.onion", "a".repeat(56)));
})
.await;
let handler = crate::api::rpc::RpcHandler::new(
config,
state,
Arc::new(crate::monitoring::MetricsStore::new()),
crate::session::SessionStore::new_for_tests(tmp.path().join("sessions.json")),
None,
None,
)
.await
.unwrap();
let row = if operation == "cancel" {
pending::insert_outbound(
tmp.path(),
recipient.public_key().to_hex(),
String::new(),
crate::identity::did_key_from_pubkey_hex(&"44".repeat(32)).unwrap(),
None,
None,
)
.await
.unwrap()
} else {
pending::insert_inbound(
tmp.path(),
recipient.public_key().to_hex(),
String::new(),
crate::identity::did_key_from_pubkey_hex(&"44".repeat(32)).unwrap(),
None,
None,
)
.await
.unwrap()
.unwrap()
};
if operation == "retry" {
pending::decide(tmp.path(), &row.id, PendingState::Approved)
.await
.unwrap();
}
let params = Some(serde_json::json!({"id": row.id, "notify": true}));
let action = async {
match operation {
"approve" => handler.handle_federation_approve_request(params).await,
"reject" => handler.handle_federation_reject_request(params).await,
"retry" => handler
.retry_peer_approval_replies()
.await
.map(|_| serde_json::json!({"ok": true})),
_ => handler.handle_federation_cancel_request(params).await,
}
};
let outcome = tokio::time::timeout(Duration::from_secs(20), action)
.await
.unwrap();
assert_eq!(outcome.is_ok(), accepted || operation == "approve");
let event = tokio::time::timeout(Duration::from_secs(5), relay)
.await
.unwrap()
.unwrap();
assert_eq!(event.pubkey, sender.public_key());
let plaintext =
nip44::decrypt(recipient.secret_key(), &event.pubkey, &event.content).unwrap();
let message: serde_json::Value = serde_json::from_str(&plaintext).unwrap();
let expected = match operation {
"approve" | "retry" => "peer-invite",
"reject" => "peer-reject",
_ => "peer-cancel",
};
assert_eq!(message["type"], expected);
let saved = pending::find_by_id(tmp.path(), &row.id).await.unwrap();
if !accepted {
assert_eq!(
saved.unwrap().state,
if operation == "approve" {
PendingState::Approved
} else {
PendingState::Pending
}
);
if operation == "approve" {
assert_eq!(outcome.unwrap()["delivery_pending"], true);
let durable = crate::federation::handshake_delivery::find(tmp.path(), &row.id)
.await
.unwrap()
.unwrap();
assert_eq!(durable.recipient, recipient.public_key().to_hex());
assert_eq!(durable.attempts, 1);
}
assert!(crate::federation::load_nodes(tmp.path())
.await
.unwrap()
.is_empty());
continue;
}
match operation {
"approve" | "retry" => {
assert_eq!(saved.unwrap().state, PendingState::Approved);
let first = crate::federation::handshake_delivery::find(tmp.path(), &row.id)
.await
.unwrap()
.unwrap();
assert_eq!(first.attempts, 1);
// Concurrent/background polling honors the persisted backoff.
handler.retry_peer_approval_replies().await.unwrap();
let second = crate::federation::handshake_delivery::find(tmp.path(), &row.id)
.await
.unwrap()
.unwrap();
assert_eq!(second.attempts, 1);
assert_eq!(first.invite_code, second.invite_code);
let invite =
crate::federation::parse_invite(message["invite_code"].as_str().unwrap())
.unwrap();
assert_eq!(invite.trust_level, crate::federation::TrustLevel::Observer);
assert!(!event.content.contains(".onion"));
}
"reject" => assert_eq!(saved.unwrap().state, PendingState::Rejected),
_ => assert!(saved.is_none()),
}
}
}
#[tokio::test]
async fn federation_metrics_are_collected_values_or_unknown_never_placeholders() {
for age in [None, Some(0), Some(181), Some(-120)] {
let tmp = tempfile::tempdir().unwrap();
let mut config = crate::config::Config::default();
config.data_dir = tmp.path().to_path_buf();
let metrics = Arc::new(crate::monitoring::MetricsStore::new());
if let Some(age) = age {
metrics
.push(
serde_json::from_value(serde_json::json!({
"timestamp": chrono::Utc::now().timestamp() - age,
"system": {"cpu_percent": 37.5, "mem_used_bytes": 200,
"mem_total_bytes": 800, "disk_used_bytes": 600,
"disk_total_bytes": 1000, "net_rx_bytes": 0, "net_tx_bytes": 0,
"load_avg_1": 0.0, "load_avg_5": 0.0, "load_avg_15": 0.0},
"containers": [], "rpc_latency_ms": 0.0, "ws_connections": 0
}))
.unwrap(),
)
.await;
}
let handler = crate::api::rpc::RpcHandler::new(
config,
Arc::new(crate::state::StateManager::new()),
metrics,
crate::session::SessionStore::new_for_tests(tmp.path().join("sessions.json")),
None,
None,
)
.await
.unwrap();
let snapshot = handler.handle_federation_get_state().await.unwrap();
if age == Some(0) {
assert_eq!(snapshot["cpu_usage_percent"], 37.5);
assert_eq!(snapshot["mem_used_bytes"], 200);
assert_eq!(snapshot["disk_total_bytes"], 1000);
} else {
for field in [
"cpu_usage_percent",
"mem_used_bytes",
"mem_total_bytes",
"disk_used_bytes",
"disk_total_bytes",
] {
assert!(
snapshot.get(field).is_none_or(|v| v.is_null()),
"{age:?}: {field}"
);
}
}
let peer = serde_json::from_value(serde_json::json!({
"did": "did:key:test", "pubkey": "11".repeat(32), "onion": "test.onion",
"trust_level": "trusted", "added_at": chrono::Utc::now().to_rfc3339(),
"last_state": snapshot
}))
.unwrap();
crate::federation::save_nodes(tmp.path(), &[peer])
.await
.unwrap();
let fleet = handler.handle_telemetry_fleet_status().await.unwrap();
let report = &fleet["nodes"][0];
if age == Some(0) {
assert_eq!(report["cpu_pct"], 38.0);
assert_eq!(report["mem_pct"], 25.0);
assert_eq!(report["disk_pct"], 60.0);
} else {
for field in ["cpu_pct", "mem_pct", "disk_pct"] {
assert!(report[field].is_null(), "{age:?}: {field}");
}
}
}
}
@@ -1,4 +1,6 @@
mod handlers;
#[cfg(test)]
mod handshake_tests;
use anyhow::Result;
+16 -1
View File
@@ -276,6 +276,11 @@ impl RpcHandler {
}
Err(e) => tracing::debug!("background handshake poll failed: {e:#}"),
}
if load_discovery_state(&self.config.data_dir).await.enabled {
if let Err(error) = self.retry_peer_approval_replies().await {
tracing::warn!("Peer approval retry could not complete: {error:#}");
}
}
}
pub(super) async fn handle_handshake_poll(&self) -> Result<serde_json::Value> {
@@ -356,6 +361,16 @@ impl RpcHandler {
);
continue;
};
let scoped_invite = match crate::federation::restrict_discovery_invite(
invite_code,
&row.from_did,
) {
Ok(code) => code,
Err(_) => {
tracing::warn!("Rejected peer invite with mismatched identity");
continue;
}
};
let row_id = row.id.clone();
let (data, _) = self.state_manager.get_snapshot().await;
let local_did =
@@ -373,7 +388,7 @@ impl RpcHandler {
let local_name = data.server_info.name.clone();
match crate::federation::accept_invite(
&self.config.data_dir,
invite_code,
&scoped_invite,
&local_did,
&local_onion,
&local_pubkey,
+54 -35
View File
@@ -1025,10 +1025,46 @@ impl RpcHandler {
.ok_or_else(|| anyhow::anyhow!("Missing key"))?;
match key {
"claude_api_key_set" => {
let key_file = self.config.data_dir.join("secrets/claude-api-key");
let has_key = tokio::fs::metadata(&key_file).await.is_ok();
Ok(serde_json::json!({ "value": has_key }))
"claude_api_key_set" | "openai_api_key_set" => {
let provider = if key == "claude_api_key_set" {
"claude"
} else {
"openai"
};
Ok(
serde_json::json!({ "value": crate::settings::model_provider::has_key(&self.config.data_dir, provider).await }),
)
}
"ai_provider" => {
let settings =
crate::settings::model_provider::ModelProvider::load(&self.config.data_dir)
.await?;
Ok(serde_json::json!({ "value": settings }))
}
"ai_provider_status" => {
let settings =
crate::settings::model_provider::ModelProvider::load(&self.config.data_dir)
.await?;
let local = tokio::time::timeout(std::time::Duration::from_secs(4), async {
let (detected, _) = crate::api::rpc::mesh::assistant::detect_ollama().await;
detected
&& crate::assistant::backends::ollama::model_supports_tools(
crate::assistant::backends::ollama::OLLAMA_BASE_URL,
crate::assistant::backends::ollama::OLLAMA_DEFAULT_MODEL,
)
.await
});
let (claude, openai, local) = tokio::join!(
crate::settings::model_provider::has_key(&self.config.data_dir, "claude"),
crate::settings::model_provider::has_key(&self.config.data_dir, "openai"),
local,
);
let budget = crate::assistant::AssistantBudget::load(&self.config.data_dir).await;
Ok(serde_json::json!({ "value": {
"schema": 1, "settings": settings, "claude_configured": claude,
"openai_configured": openai, "local_ready": local.ok(),
"routstr_remaining_sats": budget.remaining_sats(),
}}))
}
_ => Ok(serde_json::json!({ "value": null })),
}
@@ -1210,38 +1246,21 @@ impl RpcHandler {
let value = params.get("value").and_then(|v| v.as_str()).unwrap_or("");
match key {
"claude_api_key" => {
let secrets_dir = self.config.data_dir.join("secrets");
tokio::fs::create_dir_all(&secrets_dir)
.await
.context("Failed to create secrets dir")?;
let key_file = secrets_dir.join("claude-api-key");
if value.is_empty() {
// Remove key
tokio::fs::remove_file(&key_file).await.ok();
info!("Claude API key removed");
"claude_api_key" | "openai_api_key" => {
let provider = if key == "claude_api_key" {
"claude"
} else {
// Save key
tokio::fs::write(&key_file, value)
.await
.context("Failed to write API key")?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&key_file, std::fs::Permissions::from_mode(0o600))
.ok();
}
info!("Claude API key saved");
}
// `secrets/claude-api-key` (above) is deliberately the ONLY
// Claude key ledger on this node (13-02-PLAN.md). A second
// copy used to be written alongside it for a standalone,
// unauthenticated sidecar process on port 3142 — that
// sidecar and its key copy are retired; the session-gated
// Rust daemon reads this one file directly.
"openai"
};
crate::settings::model_provider::save_key(&self.config.data_dir, provider, value)
.await?;
info!(provider, "AI provider credential updated");
Ok(serde_json::json!({ "saved": true }))
}
"ai_provider" => {
let settings: crate::settings::model_provider::ModelProvider =
serde_json::from_str(value).context("Invalid AI provider settings")?;
settings.save(&self.config.data_dir).await?;
Ok(serde_json::json!({ "saved": true }))
}
_ => anyhow::bail!("Unknown setting: {}", key),
@@ -11,6 +11,7 @@ use crate::api::rpc::RpcHandler;
pub mod claude;
pub mod ollama;
pub mod openai;
pub mod routstr;
#[cfg(test)]
pub mod scripted;
@@ -39,6 +40,8 @@ pub trait Backend: Send + Sync {
pub enum BackendId {
Ollama,
Claude,
Openai,
Unavailable,
/// 13-13: the third D-04 leg. Not currently returned as the "primary"
/// id by `select_backend` (mirroring the existing convention that the
/// returned id names the primary attempt, not necessarily which leg of
@@ -52,11 +55,23 @@ impl std::fmt::Display for BackendId {
match self {
BackendId::Ollama => write!(f, "ollama"),
BackendId::Claude => write!(f, "claude"),
BackendId::Openai => write!(f, "openai"),
BackendId::Unavailable => write!(f, "unavailable"),
BackendId::Routstr => write!(f, "routstr"),
}
}
}
struct InvalidProviderSettings;
#[async_trait]
impl Backend for InvalidProviderSettings {
async fn send(&self, _: &str, _: &[ToolDef], _: &[ChatMessage]) -> Result<BackendTurn> {
anyhow::bail!(
"AI connection settings could not be loaded. Review them before sending a message."
)
}
}
/// D-04's per-call fallback: try `primary`'s `send()`, and on a transport
/// error fall through to `secondary` for that SAME call rather than
/// failing the whole turn — a local model that answers earlier turns and
@@ -115,6 +130,54 @@ fn ollama_is_selectable(detected: bool, tool_capable: bool) -> bool {
/// tools-free degrade.
pub async fn select_backend(handler: &RpcHandler) -> (Box<dyn Backend>, BackendId) {
let data_dir = handler.data_dir();
// An explicit provider is a privacy and billing choice. Never silently
// fall through to another provider if its credentials or network fail.
match crate::settings::model_provider::ModelProvider::load(data_dir).await {
Ok(settings) => match settings.provider {
crate::settings::model_provider::Provider::Openai => {
return (
Box::new(openai::OpenaiBackend::new(
data_dir.to_path_buf(),
settings.openai_model,
)),
BackendId::Openai,
)
}
crate::settings::model_provider::Provider::Claude => {
return (
Box::new(claude::ClaudeBackend::new(data_dir.to_path_buf())),
BackendId::Claude,
)
}
crate::settings::model_provider::Provider::Local => {
return (
Box::new(ollama::OllamaBackend::new(
ollama::OLLAMA_BASE_URL.to_string(),
ollama::OLLAMA_DEFAULT_MODEL.to_string(),
)),
BackendId::Ollama,
)
}
crate::settings::model_provider::Provider::Routstr => {
let budget = crate::assistant::AssistantBudget::load(data_dir).await;
let mints = crate::wallet::ecash::load_accepted_mints(data_dir)
.await
.map(|m| m.mints)
.unwrap_or_default();
return (
Box::new(routstr::RoutstrBackend::new(
data_dir.to_path_buf(),
budget.payment_policy(),
mints,
handler.nostr_tor_proxy(),
)),
BackendId::Routstr,
);
}
crate::settings::model_provider::Provider::Auto => {}
},
Err(_) => return (Box::new(InvalidProviderSettings), BackendId::Unavailable),
}
let (detected, _models) = crate::api::rpc::mesh::assistant::detect_ollama().await;
let model = ollama::OLLAMA_DEFAULT_MODEL;
let tool_capable = if detected {
@@ -0,0 +1,279 @@
//! Explicit OpenAI API selection using the shared tool loop and egress policy.
//! Keys stay node-side; no redirects, automatic retries, or provider fallback.
use super::{Backend, BackendTurn};
use crate::assistant::{
egress::{self, EgressVerdict},
tools::{ChatMessage, ToolCall, ToolDef},
};
use anyhow::{Context, Result};
use async_trait::async_trait;
use serde_json::{json, Value};
use std::{path::PathBuf, time::Duration};
const URL: &str = "https://api.openai.com/v1/chat/completions";
const RESPONSE_LIMIT: usize = 2 * 1024 * 1024;
pub struct OpenaiBackend {
data_dir: PathBuf,
model: String,
}
impl OpenaiBackend {
pub fn new(data_dir: PathBuf, model: String) -> Self {
Self { data_dir, model }
}
async fn send_at(
&self,
url: &str,
system: &str,
tools: &[ToolDef],
history: &[ChatMessage],
) -> Result<BackendTurn> {
let key = tokio::fs::read_to_string(self.data_dir.join("secrets/openai-api-key"))
.await
.map_err(|_| {
anyhow::anyhow!("OpenAI API key is not configured. Open AI connection settings.")
})?;
anyhow::ensure!(!key.trim().is_empty(), "OpenAI API key is not configured");
anyhow::ensure!(
!self.model.is_empty(),
"Choose an OpenAI model in AI connection settings"
);
let mut messages = vec![json!({"role": "system", "content": system})];
messages.extend(history.iter().flat_map(super::routstr::message_to_wire));
let mut body = json!({"model": self.model, "messages": messages, "stream": false,
"store": false, "max_completion_tokens": 2048, "n": 1});
if !tools.is_empty() {
body["tools"] = json!(tools.iter().map(|tool| json!({"type": "function", "function": {
"name": tool.name, "description": tool.description, "parameters": tool.parameters,
}})).collect::<Vec<_>>());
body["parallel_tool_calls"] = json!(false);
}
let context = egress::EgressContext::from_turn(
history,
&tools.iter().map(|tool| tool.name).collect::<Vec<_>>(),
&self.data_dir.join("secrets"),
)
.await;
match egress::screen_outbound(&body.to_string(), &context) {
EgressVerdict::Allow => {}
EgressVerdict::Truncate(value) => {
body = serde_json::from_str(&value)
.context("Could not apply outbound privacy filter")?;
}
EgressVerdict::BlockFallBackLocal => {
crate::assistant::global_counters().note_blocked_egress();
anyhow::bail!("This message contains private key or recovery material and was not sent to OpenAI");
}
}
let client = reqwest::Client::builder()
.timeout(Duration::from_secs(180))
.connect_timeout(Duration::from_secs(15))
.redirect(reqwest::redirect::Policy::none())
.build()?;
let mut response = client.post(url).bearer_auth(key.trim()).json(&body).send().await
.map_err(|_| anyhow::anyhow!("OpenAI is temporarily unreachable. Your request was not retried automatically."))?;
if !response.status().is_success() {
anyhow::bail!("{}", error_message(response.status().as_u16()));
}
let mut bytes = Vec::new();
while let Some(chunk) = response
.chunk()
.await
.context("OpenAI response interrupted")?
{
anyhow::ensure!(
bytes.len().saturating_add(chunk.len()) <= RESPONSE_LIMIT,
"OpenAI response exceeded the size limit"
);
bytes.extend_from_slice(&chunk);
}
parse_response(
&serde_json::from_slice(&bytes).context("OpenAI returned an invalid response")?,
)
}
}
#[async_trait]
impl Backend for OpenaiBackend {
async fn send(
&self,
system: &str,
tools: &[ToolDef],
history: &[ChatMessage],
) -> Result<BackendTurn> {
self.send_at(URL, system, tools, history).await
}
}
fn error_message(status: u16) -> &'static str {
match status {
401 | 403 => "OpenAI rejected the API key or project access. Check AI connection settings.",
404 => "This OpenAI model is unavailable for your account. Choose another model in AI connection settings.",
429 => "OpenAI usage or rate limit reached. Check your API billing and retry later.",
500..=599 => "OpenAI is temporarily unavailable. Retry later.",
_ => "OpenAI rejected the request. Check the selected model and retry.",
}
}
fn parse_response(value: &Value) -> Result<BackendTurn> {
let choice = value["choices"]
.as_array()
.and_then(|items| items.first())
.context("OpenAI returned no answer")?;
anyhow::ensure!(
choice["finish_reason"] != "length",
"OpenAI reached the response limit. Try a shorter request."
);
let message = &choice["message"];
if let Some(calls) = message["tool_calls"]
.as_array()
.filter(|calls| !calls.is_empty())
{
let mut parsed = Vec::new();
for call in calls {
anyhow::ensure!(
call["type"] == "function",
"Unsupported OpenAI tool response"
);
let id = call["id"]
.as_str()
.filter(|id| !id.is_empty())
.context("Missing OpenAI tool call ID")?;
let name = call["function"]["name"]
.as_str()
.filter(|name| !name.is_empty())
.context("Missing OpenAI tool name")?;
let arguments: Value = serde_json::from_str(
call["function"]["arguments"]
.as_str()
.context("Invalid OpenAI tool arguments")?,
)
.context("Invalid OpenAI tool arguments")?;
anyhow::ensure!(
arguments.is_object()
&& !parsed.iter().any(|previous: &ToolCall| previous.id == id),
"Invalid OpenAI tool call"
);
parsed.push(ToolCall {
id: id.into(),
name: name.into(),
arguments,
});
}
return Ok(BackendTurn::ToolCalls(parsed));
}
let text = message["content"]
.as_str()
.or_else(|| message["refusal"].as_str())
.filter(|text| !text.trim().is_empty())
.context("OpenAI returned no text; check model compatibility")?;
Ok(BackendTurn::Text(text.into()))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::assistant::tools::{Role, ToolResult};
#[test]
fn parses_text_and_rejects_incomplete_or_malformed_tool_calls() {
assert!(
matches!(parse_response(&json!({"choices":[{"message":{"content":"hello"}}]})).unwrap(), BackendTurn::Text(text) if text == "hello")
);
let valid = json!({"choices":[{"message":{"tool_calls":[{"id":"call_1","type":"function","function":{"name":"status","arguments":"{\"count\":1}"}}]}}]});
assert!(
matches!(parse_response(&valid).unwrap(), BackendTurn::ToolCalls(calls) if calls[0].arguments["count"] == 1)
);
for args in ["{", "null", "[]"] {
let mut invalid = valid.clone();
invalid["choices"][0]["message"]["tool_calls"][0]["function"]["arguments"] =
json!(args);
assert!(parse_response(&invalid).is_err());
}
assert!(parse_response(
&json!({"choices":[{"finish_reason":"length","message":{"content":"partial"}}]})
)
.is_err());
assert!(parse_response(&json!({"choices":[]})).is_err());
}
#[test]
fn errors_distinguish_credentials_limits_and_outages_without_raw_provider_data() {
assert!(error_message(401).contains("API key"));
assert!(error_message(429).contains("limit"));
assert!(!error_message(503).contains("key"));
let wire = super::super::routstr::message_to_wire(&ChatMessage {
role: Role::Tool,
text: None,
tool_calls: vec![],
tool_results: vec![ToolResult {
call_id: "call_1".into(),
content: "result".into(),
is_error: false,
}],
});
assert_eq!(wire[0]["tool_call_id"], "call_1");
}
#[tokio::test]
async fn real_http_adapter_sends_private_key_only_in_header_and_never_follows_redirect() {
use hyper::{
service::{make_service_fn, service_fn},
Body, Response, Server,
};
use std::sync::{Arc, Mutex};
let captured = Arc::new(Mutex::new(Vec::new()));
let capture = captured.clone();
let server = Server::bind(&([127, 0, 0, 1], 0).into()).serve(make_service_fn(move |_| {
let capture = capture.clone();
async move {
Ok::<_, hyper::Error>(service_fn(move |request: hyper::Request<Body>| {
let capture = capture.clone();
async move {
let (parts, body) = request.into_parts();
let body = hyper::body::to_bytes(body).await?;
capture.lock().unwrap().push((
parts.headers,
serde_json::from_slice::<Value>(&body).unwrap(),
));
Ok::<_, hyper::Error>(
Response::builder()
.status(302)
.header("Location", "/leak")
.body(Body::empty())
.unwrap(),
)
}
}))
}
}));
let url = format!("http://{}/v1/chat/completions", server.local_addr());
let task = tokio::spawn(server);
let dir = tempfile::tempdir().unwrap();
crate::settings::model_provider::save_key(dir.path(), "openai", "fixture-private-key")
.await
.unwrap();
let backend = OpenaiBackend::new(dir.path().into(), "test-model".into());
let history = [ChatMessage {
role: Role::User,
text: Some("Hello".into()),
tool_calls: vec![],
tool_results: vec![],
}];
assert!(backend
.send_at(&url, "Be helpful", &[], &history)
.await
.is_err());
let requests = captured.lock().unwrap();
assert_eq!(requests.len(), 1);
assert_eq!(requests[0].0["authorization"], "Bearer fixture-private-key");
assert_eq!(requests[0].1["store"], false);
assert_eq!(requests[0].1["max_completion_tokens"], 2048);
assert!(!requests[0].1.to_string().contains("fixture-private-key"));
drop(requests);
let private = [ChatMessage {
role: Role::User,
text: Some("fixture-private-key".into()),
tool_calls: vec![],
tool_results: vec![],
}];
assert!(backend
.send_at(&url, "Be helpful", &[], &private)
.await
.is_err());
assert_eq!(captured.lock().unwrap().len(), 1);
task.abort();
}
}
@@ -295,7 +295,7 @@ fn parse_openai_tool_calls(raw_calls: &[Value]) -> Vec<ToolCall> {
/// (the wire-format inverse of `parse_openai_tool_calls`), and tool-result
/// turns carry `tool_call_id` so each call's id is echoed back exactly —
/// the OpenAI-shape contract this adapter's edge is responsible for.
fn message_to_wire(msg: &ChatMessage) -> Vec<Value> {
pub(super) fn message_to_wire(msg: &ChatMessage) -> Vec<Value> {
match msg.role {
Role::System => vec![],
Role::User => vec![json!({
@@ -0,0 +1,199 @@
//! Durable, node-encrypted approval replies. Relay acknowledgement is not peer
//! acceptance: keep retrying the same invite until reciprocal membership exists.
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use std::path::Path;
use tokio::{fs, io::AsyncWriteExt};
const FILE: &str = "federation/handshake-delivery.enc";
const DOMAIN: &[u8] = b"archipelago-handshake-delivery-v1";
static LOCK: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(());
#[derive(Clone, Serialize, Deserialize)]
pub(crate) struct ApprovalReply {
pub request_id: String,
pub recipient: String,
pub expected_did: String,
pub invite_code: String,
pub attempts: u32,
pub next_attempt: i64,
}
async fn load(data_dir: &Path) -> Result<Vec<ApprovalReply>> {
let bytes = match fs::read(data_dir.join(FILE)).await {
Ok(bytes) => bytes,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
Err(e) => return Err(e.into()),
};
let key = crate::storage_crypto::derive_key(data_dir, DOMAIN).await?;
let plaintext = crate::storage_crypto::open(&bytes, &key)?;
serde_json::from_slice(&plaintext)
.context("Invalid handshake delivery store; preserved for recovery")
}
async fn save(data_dir: &Path, entries: &[ApprovalReply]) -> Result<()> {
let key = crate::storage_crypto::derive_key(data_dir, DOMAIN).await?;
let bytes = crate::storage_crypto::seal(&serde_json::to_vec(entries)?, &key)?;
let path = data_dir.join(FILE);
let parent = path.parent().context("Delivery parent missing")?;
fs::create_dir_all(parent).await?;
let temporary = parent.join(format!(".delivery-{}.tmp", uuid::Uuid::new_v4()));
let result = async {
let mut file = fs::OpenOptions::new()
.create_new(true)
.write(true)
.mode(0o600)
.open(&temporary)
.await?;
file.write_all(&bytes).await?;
file.sync_all().await?;
drop(file);
fs::rename(&temporary, &path).await?;
fs::File::open(parent).await?.sync_all().await?;
Ok::<_, anyhow::Error>(())
}
.await;
if result.is_err() {
let _ = fs::remove_file(temporary).await;
}
result
}
pub(crate) async fn find(data_dir: &Path, request_id: &str) -> Result<Option<ApprovalReply>> {
let _guard = LOCK.lock().await;
Ok(load(data_dir)
.await?
.into_iter()
.find(|entry| entry.request_id == request_id))
}
pub(crate) async fn stage(data_dir: &Path, reply: ApprovalReply) -> Result<ApprovalReply> {
let _guard = LOCK.lock().await;
let mut entries = load(data_dir).await?;
if let Some(existing) = entries
.iter()
.find(|entry| entry.request_id == reply.request_id)
{
anyhow::ensure!(
existing.recipient == reply.recipient && existing.expected_did == reply.expected_did,
"Approval recipient changed; refusing delivery"
);
return Ok(existing.clone());
}
anyhow::ensure!(entries.len() < 1024, "Handshake delivery queue is full");
entries.push(reply.clone());
save(data_dir, &entries).await?;
Ok(reply)
}
/// Claim before sending, including failed sends. Concurrent polls cannot create
/// retry storms; a crash after this write delays but never loses the reply.
pub(crate) async fn claim(
data_dir: &Path,
request_id: &str,
now: i64,
) -> Result<Option<ApprovalReply>> {
let _guard = LOCK.lock().await;
let mut entries = load(data_dir).await?;
let Some(entry) = entries
.iter_mut()
.find(|entry| entry.request_id == request_id)
else {
return Ok(None);
};
if entry.next_attempt > now {
return Ok(None);
}
entry.attempts = entry.attempts.saturating_add(1);
let delay = 30_i64
.saturating_mul(1_i64 << entry.attempts.min(7))
.min(3600);
entry.next_attempt = now.saturating_add(delay);
let claimed = entry.clone();
save(data_dir, &entries).await?;
Ok(Some(claimed))
}
pub(crate) async fn remove(data_dir: &Path, request_id: &str) -> Result<()> {
let _guard = LOCK.lock().await;
let mut entries = load(data_dir).await?;
let before = entries.len();
entries.retain(|entry| entry.request_id != request_id);
if entries.len() != before {
save(data_dir, &entries).await?;
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
async fn fixture() -> tempfile::TempDir {
let dir = tempfile::tempdir().unwrap();
fs::create_dir_all(dir.path().join("identity"))
.await
.unwrap();
fs::write(dir.path().join("identity/node_key"), [7; 32])
.await
.unwrap();
dir
}
fn reply() -> ApprovalReply {
ApprovalReply {
request_id: "request-1".into(),
recipient: "recipient".into(),
expected_did: "did:key:peer".into(),
invite_code: "secret-invite".into(),
attempts: 0,
next_attempt: 0,
}
}
#[tokio::test]
async fn encrypted_reply_survives_reload_and_retry_claim_is_exclusive() {
let dir = fixture().await;
stage(dir.path(), reply()).await.unwrap();
let raw = fs::read(dir.path().join(FILE)).await.unwrap();
assert!(!raw.windows(13).any(|bytes| bytes == b"secret-invite"));
assert_eq!(
find(dir.path(), "request-1")
.await
.unwrap()
.unwrap()
.invite_code,
"secret-invite"
);
let (a, b) = tokio::join!(
claim(dir.path(), "request-1", 100),
claim(dir.path(), "request-1", 100)
);
assert_eq!(
usize::from(a.unwrap().is_some()) + usize::from(b.unwrap().is_some()),
1
);
assert!(claim(dir.path(), "request-1", 159).await.unwrap().is_none());
assert_eq!(
claim(dir.path(), "request-1", 160)
.await
.unwrap()
.unwrap()
.attempts,
2
);
remove(dir.path(), "request-1").await.unwrap();
assert!(find(dir.path(), "request-1").await.unwrap().is_none());
}
#[tokio::test]
async fn corrupt_store_is_not_overwritten_and_recipient_cannot_change() {
let dir = fixture().await;
stage(dir.path(), reply()).await.unwrap();
let mut other = reply();
other.recipient = "different-recipient".into();
assert!(stage(dir.path(), other).await.is_err());
let path = dir.path().join(FILE);
let mut raw = fs::read(&path).await.unwrap();
raw[20] ^= 1;
fs::write(&path, &raw).await.unwrap();
assert!(stage(dir.path(), reply()).await.is_err());
assert_eq!(fs::read(path).await.unwrap(), raw);
}
}
@@ -134,6 +134,32 @@ pub fn parse_invite(code: &str) -> Result<ParsedInvite> {
})
}
/// Bind a Nostr-discovery reply to the node the operator requested, and cap
/// its grant before any local node entry or callback is written. Legacy invites
/// default to Trusted, which must never transiently authorize discovery peers.
pub(crate) fn restrict_discovery_invite(code: &str, expected_did: &str) -> Result<String> {
use base64::Engine;
let parsed = parse_invite(code)?;
anyhow::ensure!(
!expected_did.is_empty() && parsed.did == expected_did,
"Peer invite does not match the requested node"
);
anyhow::ensure!(
crate::identity::did_key_from_pubkey_hex(&parsed.pubkey)? == parsed.did,
"Peer invite DID does not match its identity key"
);
let bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD.decode(
code.strip_prefix("fed1:")
.context("Invalid invite prefix")?,
)?;
let mut payload: serde_json::Value = serde_json::from_slice(&bytes)?;
payload["trust"] = serde_json::json!("observer");
Ok(format!(
"fed1:{}",
base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(serde_json::to_vec(&payload)?)
))
}
/// Accept an invite: parse code, verify the remote node, add to federation.
pub async fn accept_invite(
data_dir: &Path,
@@ -621,3 +647,35 @@ mod tests {
assert_eq!(nodes.len(), 1, "re-accept should not duplicate");
}
}
#[cfg(test)]
mod discovery_invite_scope_tests {
use super::*;
use base64::Engine;
#[test]
fn discovery_reply_binds_identity_and_caps_legacy_trust_before_acceptance() {
let key = "33".repeat(32);
let did = crate::identity::did_key_from_pubkey_hex(&key).unwrap();
let payload =
serde_json::json!({"did":did,"pubkey":key,"onion":"test.onion","token":"test-token"});
let code = format!(
"fed1:{}",
base64::engine::general_purpose::URL_SAFE_NO_PAD
.encode(serde_json::to_vec(&payload).unwrap())
);
let restricted = restrict_discovery_invite(&code, &did).unwrap();
let parsed = parse_invite(&restricted).unwrap();
assert_eq!(parsed.trust_level, TrustLevel::Observer);
assert_eq!(parsed.token, "test-token");
assert!(restrict_discovery_invite(&code, "did:key:someone-else").is_err());
assert!(restrict_discovery_invite(&code, "").is_err());
let mut forged = payload;
forged["pubkey"] = serde_json::json!("44".repeat(32));
let forged = format!(
"fed1:{}",
base64::engine::general_purpose::URL_SAFE_NO_PAD
.encode(serde_json::to_vec(&forged).unwrap())
);
assert!(restrict_discovery_invite(&forged, &did).is_err());
}
}
+2
View File
@@ -6,12 +6,14 @@
mod invites;
pub mod pending;
pub(crate) mod handshake_delivery;
mod storage;
mod sync;
mod types;
// Re-export all public items so `crate::federation::*` continues to work.
pub use invites::{accept_invite, create_invite, parse_invite};
pub(crate) use invites::restrict_discovery_invite;
// Crate-internal: used by the periodic federation auto-sync to re-assert
// membership to peers that don't list us back (asymmetry self-heal).
pub(crate) use invites::notify_join;
+193 -6
View File
@@ -14,6 +14,9 @@ use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use std::path::Path;
use tokio::fs;
use tokio::io::AsyncWriteExt;
static PENDING_STORE_LOCK: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(());
const PENDING_FILE: &str = "federation/pending_requests.json";
const MAX_PENDING_PER_PUBKEY: usize = 5;
@@ -76,11 +79,12 @@ pub async fn load_pending(data_dir: &Path) -> Result<Vec<PendingPeerRequest>> {
let content = fs::read_to_string(&path)
.await
.context("Failed to read pending requests file")?;
let file: PendingRequestsFile = serde_json::from_str(&content).unwrap_or_default();
let file: PendingRequestsFile = serde_json::from_str(&content)
.context("Invalid pending requests file; preserving existing data")?;
Ok(file.requests)
}
pub async fn save_pending(data_dir: &Path, requests: &[PendingPeerRequest]) -> Result<()> {
async fn save_pending(data_dir: &Path, requests: &[PendingPeerRequest]) -> Result<()> {
let path = data_dir.join(PENDING_FILE);
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)
@@ -92,9 +96,27 @@ pub async fn save_pending(data_dir: &Path, requests: &[PendingPeerRequest]) -> R
};
let content =
serde_json::to_string_pretty(&file).context("Failed to serialize pending requests")?;
fs::write(&path, content)
.await
.context("Failed to write pending requests file")?;
let parent = path.parent().context("Pending requests parent missing")?;
let temporary = parent.join(format!(".pending-{}.tmp", uuid::Uuid::new_v4()));
let result = async {
let mut file = fs::OpenOptions::new()
.write(true)
.create_new(true)
.mode(0o600)
.open(&temporary)
.await?;
file.write_all(content.as_bytes()).await?;
file.sync_all().await?;
drop(file);
fs::rename(&temporary, &path).await?;
fs::File::open(parent).await?.sync_all().await?;
Ok::<_, anyhow::Error>(())
}
.await;
if result.is_err() {
let _ = fs::remove_file(&temporary).await;
}
result.context("Failed to atomically save pending requests")?;
Ok(())
}
@@ -102,7 +124,10 @@ pub async fn save_pending(data_dir: &Path, requests: &[PendingPeerRequest]) -> R
fn expire_stale(requests: &mut Vec<PendingPeerRequest>) {
let cutoff = chrono::Utc::now() - chrono::Duration::days(PENDING_EXPIRY_DAYS);
for r in requests.iter_mut() {
if !matches!(r.state, PendingState::Pending | PendingState::Sent) {
if !matches!(
r.state,
PendingState::Pending | PendingState::Sent | PendingState::Approved
) {
continue;
}
if let Ok(ts) = chrono::DateTime::parse_from_rfc3339(&r.received_at) {
@@ -131,6 +156,7 @@ pub async fn insert_inbound(
from_name: Option<String>,
message: Option<String>,
) -> Result<Option<PendingPeerRequest>> {
let _guard = PENDING_STORE_LOCK.lock().await;
let mut requests = load_pending(data_dir).await?;
expire_stale(&mut requests);
@@ -189,6 +215,7 @@ pub async fn insert_outbound(
to_name: Option<String>,
message: Option<String>,
) -> Result<PendingPeerRequest> {
let _guard = PENDING_STORE_LOCK.lock().await;
let mut requests = load_pending(data_dir).await?;
expire_stale(&mut requests);
requests.retain(|r| {
@@ -218,6 +245,7 @@ pub async fn find_by_id(data_dir: &Path, id: &str) -> Result<Option<PendingPeerR
}
pub async fn set_state(data_dir: &Path, id: &str, state: PendingState) -> Result<()> {
let _guard = PENDING_STORE_LOCK.lock().await;
let mut requests = load_pending(data_dir).await?;
if let Some(r) = requests.iter_mut().find(|r| r.id == id) {
r.state = state;
@@ -228,10 +256,32 @@ pub async fn set_state(data_dir: &Path, id: &str, state: PendingState) -> Result
Ok(())
}
/// Resolve a pending decision once; concurrent approval/rejection cannot
/// overwrite each other after a slow network request.
pub async fn decide(data_dir: &Path, id: &str, decision: PendingState) -> Result<()> {
anyhow::ensure!(
matches!(decision, PendingState::Approved | PendingState::Rejected),
"Invalid pending decision"
);
let _guard = PENDING_STORE_LOCK.lock().await;
let mut requests = load_pending(data_dir).await?;
let row = requests
.iter_mut()
.find(|row| row.id == id)
.context("Pending request not found")?;
anyhow::ensure!(
!row.outbound && row.state == PendingState::Pending,
"Request has already been decided"
);
row.state = decision;
save_pending(data_dir, &requests).await
}
/// Remove a pending request entirely. Used when the sender cancels an
/// outbound request they initiated and we want it gone (not just marked
/// Rejected/Cancelled — those states fill up the UI audit trail).
pub async fn delete(data_dir: &Path, id: &str) -> Result<()> {
let _guard = PENDING_STORE_LOCK.lock().await;
let mut requests = load_pending(data_dir).await?;
let before = requests.len();
requests.retain(|r| r.id != id);
@@ -372,3 +422,140 @@ mod tests {
assert_eq!(reloaded.state, PendingState::Approved);
}
}
#[cfg(test)]
mod persistence_regressions {
use super::*;
#[tokio::test]
async fn concurrent_requests_are_not_lost() {
let dir = tempfile::tempdir().unwrap();
let mut tasks = Vec::new();
for i in 0..24 {
let path = dir.path().to_path_buf();
tasks.push(tokio::spawn(async move {
insert_inbound(
&path,
format!("key-{i}"),
format!("npub-{i}"),
format!("did:key:{i}"),
None,
None,
)
.await
.unwrap()
}));
}
for task in tasks {
assert!(task.await.unwrap().is_some());
}
let rows = load_pending(dir.path()).await.unwrap();
assert_eq!(rows.len(), 24);
assert_eq!(
rows.iter()
.map(|r| &r.id)
.collect::<std::collections::HashSet<_>>()
.len(),
24
);
}
#[tokio::test]
async fn malformed_store_is_preserved_instead_of_replaced_with_one_request() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join(PENDING_FILE);
fs::create_dir_all(path.parent().unwrap()).await.unwrap();
let damaged = b"{incomplete existing requests";
fs::write(&path, damaged).await.unwrap();
assert!(insert_inbound(
dir.path(),
"key".into(),
"npub".into(),
"did:key:test".into(),
None,
None
)
.await
.is_err());
assert_eq!(fs::read(path).await.unwrap(), damaged);
}
}
#[cfg(test)]
mod decision_regressions {
use super::*;
#[tokio::test]
async fn only_one_concurrent_operator_decision_wins() {
let dir = tempfile::tempdir().unwrap();
let row = insert_inbound(
dir.path(),
"key".into(),
"npub".into(),
"did:key:peer".into(),
None,
None,
)
.await
.unwrap()
.unwrap();
let (approve, reject) = tokio::join!(
decide(dir.path(), &row.id, PendingState::Approved),
decide(dir.path(), &row.id, PendingState::Rejected)
);
assert_eq!(
usize::from(approve.is_ok()) + usize::from(reject.is_ok()),
1
);
let saved = find_by_id(dir.path(), &row.id).await.unwrap().unwrap();
assert_eq!(
saved.state,
if approve.is_ok() {
PendingState::Approved
} else {
PendingState::Rejected
}
);
}
#[tokio::test]
async fn an_expired_approval_does_not_block_a_new_request_forever() {
let dir = tempfile::tempdir().unwrap();
let mut row = insert_inbound(
dir.path(),
"key".into(),
"npub".into(),
"did:key:peer".into(),
None,
None,
)
.await
.unwrap()
.unwrap();
row.state = PendingState::Approved;
row.received_at = (chrono::Utc::now() - chrono::Duration::days(31)).to_rfc3339();
save_pending(dir.path(), &[row]).await.unwrap();
let renewed = insert_inbound(
dir.path(),
"key".into(),
"npub".into(),
"did:key:peer".into(),
None,
None,
)
.await
.unwrap();
assert!(renewed.is_some());
let rows = load_pending(dir.path()).await.unwrap();
assert_eq!(
rows.iter()
.filter(|r| r.state == PendingState::Expired)
.count(),
1
);
assert_eq!(
rows.iter()
.filter(|r| r.state == PendingState::Pending)
.count(),
1
);
}
}
+24 -24
View File
@@ -230,12 +230,12 @@ async fn merge_transitive_peers(
#[allow(clippy::too_many_arguments)]
pub fn build_local_state(
apps: Vec<AppStatus>,
cpu: f64,
mem_used: u64,
mem_total: u64,
disk_used: u64,
disk_total: u64,
uptime: u64,
cpu: Option<f64>,
mem_used: Option<u64>,
mem_total: Option<u64>,
disk_used: Option<u64>,
disk_total: Option<u64>,
uptime: Option<u64>,
tor_active: bool,
server_name: Option<String>,
nostr_npub: Option<String>,
@@ -261,12 +261,12 @@ pub fn build_local_state(
timestamp: chrono::Utc::now().to_rfc3339(),
node_name: server_name,
apps,
cpu_usage_percent: Some(cpu),
mem_used_bytes: Some(mem_used),
mem_total_bytes: Some(mem_total),
disk_used_bytes: Some(disk_used),
disk_total_bytes: Some(disk_total),
uptime_secs: Some(uptime),
cpu_usage_percent: cpu,
mem_used_bytes: mem_used,
mem_total_bytes: mem_total,
disk_used_bytes: disk_used,
disk_total_bytes: disk_total,
uptime_secs: uptime,
tor_active: Some(tor_active),
nostr_npub,
own_fips_npub,
@@ -355,12 +355,12 @@ mod tests {
status: "running".to_string(),
version: Some("0.18".to_string()),
}],
25.5,
2_000_000_000,
8_000_000_000,
100_000_000_000,
500_000_000_000,
3600,
Some(25.5),
Some(2_000_000_000),
Some(8_000_000_000),
Some(100_000_000_000),
Some(500_000_000_000),
Some(3600),
true,
Some("Test Node".to_string()),
None,
@@ -430,12 +430,12 @@ mod tests {
];
let state = build_local_state(
vec![],
0.0,
0,
0,
0,
0,
0,
None,
None,
None,
None,
None,
None,
true,
None,
None,
+55 -13
View File
@@ -13,11 +13,11 @@ pub async fn collect_snapshot() -> Result<MetricSnapshot> {
read_loadavg(),
);
let cpu = cpu.unwrap_or(0.0);
let (mem_used, mem_total) = mem.unwrap_or((0, 0));
let (disk_used, disk_total) = disk.unwrap_or((0, 0));
let (net_rx, net_tx) = net.unwrap_or((0, 0));
let (l1, l5, l15) = load.unwrap_or((0.0, 0.0, 0.0));
let cpu = cpu?;
let (mem_used, mem_total) = mem?;
let (disk_used, disk_total) = disk?;
let (net_rx, net_tx) = net?;
let (l1, l5, l15) = load?;
let system = SystemMetrics {
cpu_percent: cpu,
@@ -120,10 +120,9 @@ async fn read_disk_usage() -> Result<(u64, u64)> {
} else {
"/"
};
let output = tokio::process::Command::new("df")
.args(["--block-size=1", "--output=used,size", target])
.output()
.await
let mut command = tokio::process::Command::new("df");
command.args(["--block-size=1", "--output=used,size", target]);
let output = bounded_output(command, std::time::Duration::from_secs(3)).await
.context("Failed to run df")?;
if !output.status.success() {
@@ -215,12 +214,23 @@ async fn read_network_totals() -> Result<(u64, u64)> {
Ok((rx_total, tx_total))
}
/// A wedged runtime or filesystem must not freeze every monitoring snapshot.
/// Dropping a timed-out child kills it, so repeated polls cannot leak processes.
async fn bounded_output(
mut command: tokio::process::Command,
timeout: std::time::Duration,
) -> Result<std::process::Output> {
command.kill_on_drop(true);
tokio::time::timeout(timeout, command.output())
.await.context("Metrics subprocess timed out")?
.context("Metrics subprocess failed")
}
/// Get per-container resource stats via `podman stats --no-stream --format json`.
async fn read_container_stats() -> Result<Vec<ContainerMetrics>> {
let output = tokio::process::Command::new("podman")
.args(["stats", "--no-stream", "--format", "json"])
.output()
.await
let mut command = tokio::process::Command::new("podman");
command.args(["stats", "--no-stream", "--format", "json"]);
let output = bounded_output(command, std::time::Duration::from_secs(8)).await
.context("Failed to run podman stats")?;
if !output.status.success() {
@@ -391,3 +401,35 @@ mod tests {
assert_eq!(parse_bytes_field(&obj, "mem"), Some(268435456));
}
}
#[cfg(test)]
mod subprocess_deadline_tests {
use super::*;
#[tokio::test]
async fn a_stalled_metrics_command_is_bounded_and_killed() {
let dir = tempfile::tempdir().unwrap();
let pid_file = dir.path().join("pid");
let mut command = tokio::process::Command::new("sh");
command.arg("-c").arg("echo $$ > \"$1\"; exec sleep 30").arg("metrics-test").arg(&pid_file);
let start = std::time::Instant::now();
let error = bounded_output(command, std::time::Duration::from_millis(500)).await.unwrap_err();
assert!(error.to_string().contains("timed out"));
assert!(start.elapsed() < std::time::Duration::from_secs(3));
let pid = tokio::fs::read_to_string(pid_file).await.unwrap();
for _ in 0..40 {
if !std::path::Path::new(&format!("/proc/{}", pid.trim())).exists() { return; }
tokio::time::sleep(std::time::Duration::from_millis(25)).await;
}
panic!("Timed-out metrics subprocess was not reaped");
}
#[tokio::test]
async fn successful_metrics_output_is_preserved() {
let mut command = tokio::process::Command::new("printf");
command.arg("metrics-ok");
let output = bounded_output(command, std::time::Duration::from_secs(1)).await.unwrap();
assert!(output.status.success());
assert_eq!(output.stdout, b"metrics-ok");
}
}
+6 -6
View File
@@ -14,21 +14,21 @@ use std::path::PathBuf;
use std::sync::Arc;
use tracing::{debug, warn};
/// Spawn the background metrics collector (runs every 300 seconds / 5 minutes).
/// Spawn the background metrics collector at the store's one-minute resolution.
/// Evaluates alert rules on each snapshot and dispatches notifications.
/// Note: health_monitor.rs handles container state polling at 120s intervals.
/// This collector handles system-level metrics (CPU, disk, network) and only
/// calls podman stats every 5 minutes to avoid duplicate subprocess overhead.
/// Runtime commands have deadlines; unavailable container stats cannot hold
/// system readings indefinitely. Missed ticks are skipped, never replayed.
pub fn spawn_metrics_collector(
store: Arc<MetricsStore>,
state: Option<Arc<crate::state::StateManager>>,
data_dir: Option<PathBuf>,
) {
tokio::spawn(async move {
// Wait 60s for system to stabilize after boot
tokio::time::sleep(std::time::Duration::from_secs(60)).await;
// Start promptly without competing with the very first boot tasks.
tokio::time::sleep(std::time::Duration::from_secs(5)).await;
let mut interval = tokio::time::interval(std::time::Duration::from_secs(300));
let mut interval = tokio::time::interval(std::time::Duration::from_secs(60));
interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
loop {
+3 -3
View File
@@ -291,15 +291,15 @@ impl Server {
);
// Background handshake poll: fetch inbound nostr peer requests every
// 5 minutes instead of only when a user presses the Federation Poll
// 30 seconds instead of only when a user presses the Federation Poll
// button (requests used to sit on relays unseen — 2026-07-22). The
// handler's own discoverability gate makes this a no-op until the
// user opts in.
{
let rpc = api_handler.rpc_handler().clone();
tokio::spawn(async move {
let mut tick = tokio::time::interval(std::time::Duration::from_secs(300));
tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
let mut tick = tokio::time::interval(std::time::Duration::from_secs(30));
tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
loop {
tick.tick().await;
rpc.background_handshake_poll().await;
+2
View File
@@ -9,3 +9,5 @@ pub mod session_policy;
pub mod transport;
pub mod bitcoin_storage;
pub mod model_provider;
@@ -0,0 +1,178 @@
//! Owner-selected chat provider. API keys remain in the node's private secret
//! ledger and are never returned by settings or included in chat context.
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use std::path::Path;
use tokio::{fs, io::AsyncWriteExt};
#[derive(Clone, Copy, Debug, Default, Deserialize, Serialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum Provider {
#[default]
Auto,
Claude,
Openai,
Local,
Routstr,
}
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
#[serde(deny_unknown_fields)]
pub struct ModelProvider {
#[serde(default)]
pub provider: Provider,
#[serde(default)]
pub openai_model: String,
}
impl ModelProvider {
pub fn validate(&self) -> Result<()> {
anyhow::ensure!(
!self.openai_model.starts_with("sk-")
&& self.openai_model.len() <= 128
&& self
.openai_model
.bytes()
.all(|b| b.is_ascii_alphanumeric() || b"-_.:".contains(&b)),
"Invalid OpenAI model name"
);
anyhow::ensure!(
self.provider != Provider::Openai || !self.openai_model.is_empty(),
"Choose an OpenAI model before connecting"
);
Ok(())
}
pub async fn load(data_dir: &Path) -> Result<Self> {
match fs::read(data_dir.join("settings/model-provider.json")).await {
Ok(bytes) => {
let settings: Self = serde_json::from_slice(&bytes)
.context("Invalid AI provider settings; preserved for recovery")?;
settings.validate()?;
Ok(settings)
}
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(Self::default()),
Err(error) => Err(error.into()),
}
}
pub async fn save(&self, data_dir: &Path) -> Result<()> {
self.validate()?;
write_private(
&data_dir.join("settings/model-provider.json"),
&serde_json::to_vec(self)?,
)
.await
}
}
pub fn key_name(provider: &str) -> Result<&'static str> {
match provider {
"claude" => Ok("claude-api-key"),
"openai" => Ok("openai-api-key"),
_ => anyhow::bail!("Unsupported AI provider"),
}
}
pub async fn has_key(data_dir: &Path, provider: &str) -> bool {
let Ok(name) = key_name(provider) else {
return false;
};
fs::read_to_string(data_dir.join("secrets").join(name))
.await
.is_ok_and(|key| !key.trim().is_empty())
}
pub async fn save_key(data_dir: &Path, provider: &str, value: &str) -> Result<()> {
let path = data_dir.join("secrets").join(key_name(provider)?);
let value = value.trim();
anyhow::ensure!(
value.len() <= 4096 && value.bytes().all(|b| b.is_ascii_graphic()),
"Invalid API key format"
);
if value.is_empty() {
match fs::remove_file(path).await {
Ok(()) => Ok(()),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
Err(e) => Err(e.into()),
}
} else {
write_private(&path, value.as_bytes()).await
}
}
async fn write_private(path: &Path, bytes: &[u8]) -> Result<()> {
let parent = path.parent().context("Missing settings directory")?;
fs::create_dir_all(parent).await?;
let temporary = parent.join(format!(".provider-{}.tmp", uuid::Uuid::new_v4()));
let result = async {
let mut file = fs::OpenOptions::new()
.create_new(true)
.write(true)
.mode(0o600)
.open(&temporary)
.await?;
file.write_all(bytes).await?;
file.sync_all().await?;
drop(file);
fs::rename(&temporary, path).await?;
fs::File::open(parent).await?.sync_all().await?;
Ok::<_, anyhow::Error>(())
}
.await;
if result.is_err() {
let _ = fs::remove_file(temporary).await;
}
result
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn private_keys_replace_atomically_and_never_enter_public_settings() {
use std::os::unix::fs::PermissionsExt;
let dir = tempfile::tempdir().unwrap();
assert!(!has_key(dir.path(), "openai").await);
save_key(dir.path(), "openai", "test-key-one")
.await
.unwrap();
save_key(dir.path(), "openai", "test-key-two")
.await
.unwrap();
assert!(has_key(dir.path(), "openai").await);
let key_path = dir.path().join("secrets/openai-api-key");
assert_eq!(
fs::metadata(&key_path).await.unwrap().permissions().mode() & 0o777,
0o600
);
assert_eq!(fs::read_to_string(&key_path).await.unwrap(), "test-key-two");
let settings = ModelProvider {
provider: Provider::Openai,
openai_model: "test-model".into(),
};
settings.save(dir.path()).await.unwrap();
let body = serde_json::to_string(&ModelProvider::load(dir.path()).await.unwrap()).unwrap();
assert!(!body.contains("test-key"));
assert!(save_key(dir.path(), "../openai", "key").await.is_err());
assert!(save_key(dir.path(), "openai", "key\nInjected: bad")
.await
.is_err());
assert_eq!(fs::read_to_string(&key_path).await.unwrap(), "test-key-two");
save_key(dir.path(), "openai", "").await.unwrap();
assert!(!has_key(dir.path(), "openai").await);
}
#[tokio::test]
async fn invalid_settings_preserve_existing_configuration() {
let dir = tempfile::tempdir().unwrap();
ModelProvider::default().save(dir.path()).await.unwrap();
let invalid = ModelProvider {
provider: Provider::Openai,
openai_model: String::new(),
};
assert!(invalid.save(dir.path()).await.is_err());
assert_eq!(
ModelProvider::load(dir.path()).await.unwrap().provider,
Provider::Auto
);
let path = dir.path().join("settings/model-provider.json");
fs::write(&path, b"broken").await.unwrap();
assert!(ModelProvider::load(dir.path()).await.is_err());
assert_eq!(fs::read(&path).await.unwrap(), b"broken");
}
}
+43 -4
View File
@@ -75,11 +75,15 @@ pub fn open(data: &[u8], key: &[u8; 32]) -> Result<Vec<u8>> {
.map_err(|_| anyhow::anyhow!("decryption failed — key mismatch or corruption"))
}
/// Heuristic: does this look like legacy plaintext JSON (starts with `{`/`[`)?
/// Encrypted blobs start with a random nonce byte, so a `{`/`[` first byte is a
/// reliable migration signal.
/// Recognize a complete legacy JSON object/array, including leading whitespace.
/// A random nonce can start with `{` or `[`; checking only that byte misclassifies
/// valid ciphertext and can trigger an empty-store migration. Validate the entire
/// document without allocating a second copy of the store's object tree.
pub fn is_plaintext_json(raw: &[u8]) -> bool {
matches!(raw.first(), Some(b'{') | Some(b'['))
matches!(
raw.iter().copied().find(|byte| !byte.is_ascii_whitespace()),
Some(b'{') | Some(b'[')
) && serde_json::from_slice::<serde::de::IgnoredAny>(raw).is_ok()
}
#[cfg(test)]
@@ -110,9 +114,44 @@ mod tests {
fn detects_plaintext_vs_ciphertext() {
assert!(is_plaintext_json(b"{\"a\":1}"));
assert!(is_plaintext_json(b"[]"));
assert!(is_plaintext_json(b" \r\n\t{\"messages\": []}\n"));
for invalid in [
b"{".as_slice(),
b"[",
b"{}trailing",
b"[\xff]",
b"null",
b"\"text\"",
] {
assert!(!is_plaintext_json(invalid));
}
assert!(!is_plaintext_json(&seal(b"x", &[3u8; 32]).unwrap()));
}
#[test]
fn json_prefix_nonce_does_not_trigger_plaintext_migration() {
use chacha20poly1305::aead::{Aead, KeyInit};
let key = [3u8; 32];
let plaintext = br#"{"messages":[{"message":"preserve me"}]}"#;
let cipher = chacha20poly1305::ChaCha20Poly1305::new_from_slice(&key).unwrap();
// Deterministically reproduce both collisions instead of relying on
// OsRng to happen to pick one during a test run.
for prefix in [b'{', b'['] {
let mut nonce = [0xffu8; 12];
nonce[0] = prefix;
let encrypted = cipher
.encrypt(
chacha20poly1305::aead::generic_array::GenericArray::from_slice(&nonce),
plaintext.as_slice(),
)
.unwrap();
let mut envelope = nonce.to_vec();
envelope.extend(encrypted);
assert!(!is_plaintext_json(&envelope));
assert_eq!(open(&envelope, &key).unwrap(), plaintext);
}
}
/// KEY-05 regression: a blob written by the pre-migration `seal` must still
/// open after the migration.
///
+49
View File
@@ -0,0 +1,49 @@
# AIUI provider setup follow-up
Status: implementation in progress; not deployed or accepted.
The app's browser key vault did not configure the node's authoritative Claude
ledger. Embedded chat delegates to the node's tool loop, whose provider selection
also ignored the frontend's Claude/OpenRouter picker. The backend retired the
OpenRouter relay while that picker still offered it. Generic502/503 errors were
classified as missing keys and sent users back to settings.
Implementation scope: offer setup in trusted dashboard chrome before first use;
keep credentials out of the iframe's chat, prompts, history and browser storage;
use private atomic node credential writes; persist an explicit provider choice;
retain the existing tool permissions and outbound privacy screen. Explicit
provider selection must not silently send a failed request to a different cloud
provider. Routstr funding and allowance remain separate, deliberate actions.
OpenAI support uses its standard API key, not a presumed Codex subscription key.
For the existing node tool loop, the Chat Completions API retains the same
message/tool-result representation and existing egress checks. This is an
intentional integration choice, not a claim that it is the newer Responses API.
The HTTP adapter has a fixed HTTPS destination, no redirects or retries, a bounded
completion and response, store:false, and errors that do not echo upstream bodies.
The operator supplies the model ID; no inference is issued merely by saving a key.
Official documentation checked2026-10-06:
- https://developers.openai.com/api/reference/overview (server-side bearer credentials)
- https://developers.openai.com/api/reference/resources/chat/subresources/completions/methods/create
(max_completion_tokens, tool calls, store)
- https://developers.openai.com/api/docs/guides/streaming-responses
(Responses recommendation and distinction from Chat Completions)
Qualification so far: all 1,244 dashboard tests and 363 AIUI tests pass before
final toolbar placement/funding refinements; latest focused checks pass 15
dashboard and 24 AIUI tests. Both production bundles build. Chromium390/1440px
verifies first-use setup, private fixture key/model save, no key in localStorage,
retained unsent draft and no page errors. Visual review found an overlapping
mobile setup button; moved setup into the existing model menu. That final layout
still needs rebuilt-browser qualification. No fixture key reached a real provider.
Explicit local selection now stays local; Claude/OpenAI selection cannot silently
fall through. Routstr selection persists and retains the existing budget checks.
Payment-required responses request the funding view, while temporary502/503 and
rate limits do not claim credentials are missing. Reopening ecash funding reloads
the address, including repeated opens on the same tab.
Remaining: isolated backend results, final browser/funding/key-error checks,
actual provider compatibility and node deployment. No paid inference tests or new
wallet spending have been performed or authorized by this implementation work.
+63
View File
@@ -0,0 +1,63 @@
# Fleet metric repair — qualification in progress
## Confirmed cause
The federation.get-state handler passed literal zero values for CPU, RAM, disk
and uptime into build_local_state. Peers therefore received a valid signed RPC
response containing fabricated measurements. Fleet also turned absent fields
into zeroes and treated a newly added peer's registration time as a report.
Read-only live inspection on the dev node found fifteen federation reports with
zero resource values, alongside two nonzero collector reports. Yaya has no
Trusted fleet reports; peer (Observer) access is deliberately distinct.
## Candidate change
- Use the existing minute MetricsStore sample, avoiding expensive per-peer probes.
- Samples older than three minutes or ahead of the local clock are unavailable.
- Read uptime from the host uptime source; errors remain unavailable.
- Preserve optional measurements through federation and the Fleet response.
- Display unavailable measurements separately from valid zeroes; average only
valid measurements from reporting online nodes.
- Do not infer contact from when a peer was added. Invalid/future report dates
show unknown status; offline cards state when the last report arrived.
- Keep existing trust restrictions and signed FIPS-preferred federation transport.
This change does not claim FIPS media-stream acceptance or fix stalled peering.
## Qualification
All 1,684 backend tests pass (four ignored), including actual-handler fresh,
absent, stale and future sample coverage. All 11 Fleet helper/component tests
pass, including real-zero versus missing values, malformed/future dates,
ordering, averages and rendered unavailable/offline states. Frontend typecheck
passes. The first component assertion incorrectly expected spaces between
separate elements; it was corrected to inspect those elements. No deployment
or live metric repair acceptance yet.
Required: actual-handler fresh/absent/stale/future samples; full relevant suites;
mobile/desktop browser layout; upgraded sender and receiver comparing local
Monitoring to received Fleet values; restart/reconnect, offline ageing and real
transport evidence. Legacy senders still advertising zeros need the sender fix;
the receiver cannot reliably distinguish a legacy fabricated zero from idle CPU.
## Dev deployment and collector follow-up
Dev qualification deployment uses backend041f1fa2 (SHA256
11b62f697a71b762bf8638063d1a858a68eea6d7268e300f6320c99068efa78a)
and frontend3d0c67eb. Health and unchanged app-container identities/start times
pass. Live federation CPU/memory/disk values exactly match local Monitoring;
Web5 mobile390px/desktop1440px checks pass. Rollback retained on the node at
/var/lib/archipelago/support/followup-20261005-2120. Yaya remains on the prior
backend; no receiver/fleet-wide acceptance is claimed.
Live testing caught an unbounded podman stats subprocess delaying the first
snapshot, and a300-second collection interval conflicting with180-second Fleet
freshness. The next candidate starts after5seconds and collects once per minute,
with3-second df and8-second podman deadlines and kill-on-drop cleanup. Failed
system reads no longer become invented zero samples. Container-stat failure
leaves container readings unavailable while retaining valid system readings.
The actual subprocess timeout/reaping regression passes; all1,686 backend tests
pass (four ignored). This collector correction is not deployed yet.
Framework SSH and backend health work, but its stored dashboard session returns
401. Authenticated Framework Monitoring acceptance remains open. No authentication
boundary was bypassed to produce an apparent pass.
@@ -0,0 +1,73 @@
# IndeeHub distribution: current implementation design
Status: design for the post-1.9 follow-up, not implemented/accepted. Earlier
swarm plans describe historical experiments and must not be read as live proof.
## Standards checked on 2026-10-05
- Nostr [NIP-71](https://github.com/nostr-protocol/nips/blob/master/71.md)
defines video metadata, including addressable normal-video kind34235. Use a
stable producer/project identifier for revisions. This does not define paid
viewing rights. Hash/media metadata follows
[NIP-94](https://github.com/nostr-protocol/nips/blob/master/94.md).
- [NIP-98](https://github.com/nostr-protocol/nips/blob/master/98.md) authenticates
HTTP requests; it is not proof of payment or permission to another creator's
project. Keep the existing verified app session and project ownership checks.
- [Cashu NUT-18](https://github.com/cashubtc/nuts/blob/main/18.md) supplies payment
request negotiation. [NUT-04](https://github.com/cashubtc/nuts/blob/main/04.md)
covers mint quotes/issuance; method-specific current specifications and older
deployed mint responses must both be capability-tested. Keep quote identifiers
private to the receiving wallet. Payment settlement must be correlated to the
purchase, never inferred from a change in wallet balance.
- [NUT-19](https://github.com/cashubtc/nuts/blob/main/19.md) provides mint-side
cached responses where supported. It supplements a durable local payment
journal; it does not replace one or make an arbitrary retry safe.
- [LNURL-pay](https://github.com/lnurl/luds/blob/luds/06.md) supports an invoice
handoff. A Lightning address by itself is not a signed settlement receipt.
## Required implementation contract
1. Backstage publishes only the selected, owned project. Announcements contain
public metadata, producer identity, node identity, content hashes, current
price/window and accepted-method capabilities. Never publish Cloud paths,
mint quotes, tokens, paid media keys or private management addresses.
2. Each instance's Archipelago source verifies signatures, identity bindings and
monotonic event revisions. Persist discovery so a relay outage does not empty
an existing library. Keep other sources and the current default intact.
3. A purchase binds buyer identity, publisher, project revision, amount, currency,
selected payment method and an unpredictable idempotency identifier. Persist
the quote before requesting payment. Snapshot the offer so later edits cannot
silently change the purchased terms.
4. Reuse file-payment capability negotiation and proven settlement primitives.
Existing file Lightning invoices currently require LND; therefore adding
first-use Lightning-to-Cashu receiving is real work, not a display label. The
receiver must verify a correlated invoice/mint receipt and recover issuance
after a lost response before granting access. Its provisioned ecash address
must map to the actual receiving wallet. No new real payment is authorized by
this design; use isolated fixtures until a bounded payment is approved.
5. A paid purchase creates one durable entitlement. Default demo window proposal:
start at the first successfully authorized media response; persist start and
expiry atomically. Retries, seek and reconnect reuse it without another
payment. Check the entitlement for every media/range/key request. Clock
rollback must not extend an already-started window.
6. Browser/companion playback remains ordinary authenticated local HTTP. Actual
inter-node media bytes use FIPS, with a bound node identity and authenticated
purchase capability. No silent Tor/LAN/iroh fallback for required FIPS media.
Show a recoverable unavailable route without requesting another payment.
7. Range/segment serving streams bounded buffers with backpressure and cancel
propagation. Do not read an entire paid film into a Vec before serving it.
Verify length/hash/revision, reject malformed ranges and path escapes, and
keep cache access subject to the same entitlement. Delivered plaintext cannot
be made impossible to copy; expiry controls subsequent authorized delivery.
## Qualification that remains required
Publisher/receiver integration must cover altered metadata, forged identities,
wrong mint, rejected/late/duplicate payment, missing transaction response, restart,
clock change, expired window, revocation, missing FIPS route, seek and disconnect.
Measure real media-byte transport and memory use. Test the actual Backstage,
Archipelago listing, purchase and player on mobile/desktop and companion.
Use only the operator-designated Yaya Cloud video, preserve the source, and
publish the IndeeHub app image/catalog update at the end of qualification.
One working fixture is not acceptance of the complete distributed flow.
+82
View File
@@ -0,0 +1,82 @@
# IndeeHub distributed viewing: protocol review
Status: preliminary design, 2026-10-05. Not implemented or qualified. Reconcile
with actual IndeeHub source before selecting the final event/API contract.
## Updated constraints
The older `phase4-streaming-ecash-plan.md` mixes producer content sales with
bandwidth resale and an optional iroh swarm. Its implementation assertions are
historical. The operator now requires FIPS for inter-node media bytes, producer
payments, timed viewing, and an Archipelago catalog across instances. A successful
bandwidth payment alone must not unlock a producer's protected film.
The old document also describes a fail-open paid-serving path. Audit the current
implementation; never adopt fail-open behavior for paid media or key delivery.
Keep free software updates outside any paid-content gate.
## Primary specifications checked
- [NIP-71 video events](https://github.com/nostr-protocol/nips/blob/master/71.md):
defines ordinary and addressable video metadata, including variants. Evaluate
addressable normal-video events for stable film identity and metadata updates.
This is a draft optional specification, not a complete rental/access protocol.
- [NIP-94 file metadata](https://github.com/nostr-protocol/nips/blob/master/94.md):
describes file hashes, MIME types, sizes and locations. Reuse compatible fields
rather than inventing incompatible meanings for standard tags.
- [Blossom BUD-01](https://github.com/hzrd149/blossom/blob/master/buds/01.md):
specifies SHA256-addressed HTTP blob retrieval. Its public cross-origin server
conventions must not be copied onto dashboard/RPC authentication boundaries.
Hash addressing can complement a FIPS-backed gateway; Blossom alone does not
establish payment or timed viewing rights.
- [Cashu NUT-18](https://github.com/cashubtc/nuts/blob/main/18.md): receiver requests
can describe amount, unit, accepted mints and token delivery. Reconcile supported
mint preferences/methods with our deployed wallets, not just the latest schema.
- [NUT-04](https://github.com/cashubtc/nuts/blob/main/04.md) and
[NUT-23](https://github.com/cashubtc/nuts/blob/main/23.md): verify current mint
quote/payment accounting and BOLT11 behavior against supported mints. Keep quote
identifiers private. Successful invoice payment and successful token issuance
are distinct recovery steps; never repeat payment to recover an issuance reply.
These are source/specification findings. Compatibility with existing clients and
mints remains to be tested. Pin specification revisions when implementing so a
moving document cannot silently change the wire contract.
## Proposed separation of responsibilities
1. **Discovery:** publisher-authorized signed metadata; stable film/version ID,
public title/artwork/teaser and explicit supported paid-content extension.
Deduplicate, reconcile updates/deletions and recover missed events. Do not put
private viewing keys, receipts, quotes or wallet credentials on public relays.
2. **Producer offer and settlement:** reuse recipient-capability negotiation from
file purchases. Bind amount, recipient, content version and duration to one
durable purchase ID. Verify settlement at the seller before issuing access.
Support the existing Lightning-to-ecash-address flow without requiring LND.
3. **Viewing entitlement:** a versioned authenticated grant with content, buyer,
validity and replay rules. This is application-specific until interoperability
is demonstrated; do not present it as defined by the metadata/payment NIPs.
4. **Playback gateway:** browser/companion use normal authenticated media requests
to their node. The node obtains protected segments over FIPS and verifies
content integrity and entitlement. Seek/retry/resume reuse the purchase.
5. **Caching:** peers may cache authorized ciphertext. Key delivery and subsequent
segment access remain gated. Already delivered plaintext or keys cannot be
made uncopyable or retroactively revoked; do not promise DRM guarantees.
No silent media fallback to Tor/LAN/iroh satisfies the operator's FIPS requirement.
If FIPS is unavailable, retain paid ownership and explain retry/recovery rather
than charge again. Separate routing diagnostics from normal playback controls.
## Decisions and proofs required before publishing the test film
Identify the exact Yaya Cloud video, preserve its original and obtain the intended
price and viewing-window semantics. Define activation versus expiry, clock skew,
multiple devices, publisher outage, refund policy and content-version replacement.
Use isolated/regtest funds for automated tests; new real payments require a bounded
amount authorization. Publish no unrelated Cloud file.
Qualification must cover settlement with lost replies, duplicate payment callbacks,
wrong mint/recipient/content, denied keys, expired grants, FIPS outage and recovery,
range/HLS seek, mobile background/resume, source/peer restart and storage recovery.
Verify actual transport and producer balance changes rather than relying on UI
labels. Include fresh/upgrade tests and any required IndeeHub image/catalog update
at the end of the implementation.
+64
View File
@@ -0,0 +1,64 @@
# IndeeHub native signer follow-up
## Reproduced on Yaya
The installed app and dashboard have the same provider SHA256
`529fe82e7c16b51c62678427f565048c3dd93ca28320bd8494c17236ff57bb81`.
A clean mobile Chromium session opens the identity picker. After selecting the
existing profile and Authenticate, the picker disappears but the broker stays
full-screen (`aria-hidden=false`, 390 by 844). Its document has no visible text
or buttons, and the app still shows Sign In. The trace contains signer-ready and
signer-show but no signer-identity or signer-hide through 18 seconds.
The picker emits an entry from a Vue reactive array. NostrTabSigner passes that
proxy object directly to cross-frame postMessage after hiding the picker.
Structured cloning rejects reactive proxies, interrupting the handoff before
it schedules the broker hide. Reload uses the already-serialized identity from
localStorage, explaining why refresh can appear to repair the problem.
## Candidate fix
Send an explicit plain object containing only the selected public identity
fields. The private signer remains on the node; unrelated metadata is excluded.
Consent behavior and the existing green completion animation are unchanged.
A regression uses a real Vue reactive identity and structuredClone, verifies
that the proxy itself is rejected, the public handoff is cloneable, metadata is
excluded, and the overlay closes. Eleven focused signer/provider tests pass,
and frontend typecheck passes. A browser qualification using candidate dashboard
assets with the actual Yaya app/auth backend is in progress. No deployment or
actual companion acceptance is claimed yet.
## App source and further review
The canonical app is the Vite/Vue source in the separate IndeeHub repository,
not the obsolete Next.js Dockerfile under apps/indeedhub. Its existing local
nginx edit and untracked workflow documentation were preserved; new app work
uses a separate worktree.
Review found additional app-side concerns to test: production network failures
can flip the app into mock mode and fabricate subscribed users, and the header
can discard a valid backend Nostr session when the local signer account has not
been restored. Do not claim these repaired by the broker object-cloning fix.
## Live candidate qualification and restored-session prompting
2026-10-05: actual Yaya NIP-98 session exchange returns201 and authenticated
profile returns200 using candidate dashboard and app assets. The overlay hides;
a full refresh reuses the session and profile without another auth exchange.
Evidence: /tmp/archy-indeehub-full-candidate-2.log. This uses headless Chromium
390x844, real cookies/signatures/RPC/API, with only static candidate assets routed
locally. Initial asset-routing attempts hit Chromium private-network checks;
forwarding real API requests in the fixture resolved that test harness failure.
No backend authentication was mocked or bypassed.
Public-key lookups can inherit transient activation from the identity-picker
click/reload. The provider now avoids interpreting a restored-session hint or
already selected identity as a new account-switch request. Requests still pass
to the authenticated broker; the hint grants no signature permission. Explicit
selectIdentity remains available. Twelve provider/tab-signer regressions pass.
The combined frontend production check found strict TypeScript nullability
errors in Fleet test array indexing; the fixture now asserts both cards exist
and uses non-null indexing. No production Fleet behavior changed in that repair.
Actual deployment, companion lifecycle and the remaining recovery cases stay open.
+85
View File
@@ -0,0 +1,85 @@
# Node connection flow plan
Status: proposal for the post-1.9.0 work. Uses existing components, colors,
spacing, glass cards, typography and motion. No broad navigation redesign has
been deployed. Connection reliability must be qualified before this flow ships.
## Entry and return paths
- Web5 always exposes **Connect with Nodes**, including when mobile quick actions
are collapsed. Keep **Connected Nodes** beside the entry or directly below it.
- Cloud peer files links to the same connection flow and retains its return
location. Successful connection returns to that peer's files when appropriate.
- Fleet provides the same connection entry, with an explicit distinction between
connecting to another person's node and linking a node the operator owns.
- Open the route immediately with cached safe summaries or a loading state;
discovery and transport checks run after navigation. Do not await remote calls
before rendering the destination. Cancel obsolete work on navigation away.
## Connect with Nodes
Use one page with existing tabs: **Discover**, **Requests**, **Connected**.
On mobile keep tabs in one horizontally scrollable row. Preserve the selected
view, search and scroll position when opening a node and returning.
Discover shows the existing opt-in Nostr presence results and an explicit invite
entry. Search updates locally; refresh provides immediate progress, timeout and
retry feedback. Distinguish stale advertisements from recently contacted nodes.
A presence event is discovery information, not authorization or proof of reachability.
Each node has a single clear action: Request connection, View request, or Open
node according to its actual state. Explain what information the request shares.
Avoid duplicate requests on repeated taps or when responses arrive late.
## Requests and approval
Display incoming and sent Nostr requests in the same Requests view, with counts
and a readable node identity/name. Incoming requests offer Approve or Reject;
sent requests offer Cancel. Keep completed history available but secondary.
An approval progresses through distinct states:
1. Request sent / Awaiting approval.
2. Approved / Connecting — authenticated invitation accepted, join not confirmed.
3. Connected — persisted relationship and authenticated reciprocal confirmation.
4. Connection delayed — show bounded retry and a useful error; retain the approved
operation so restart, lost acknowledgement or transient outage can recover.
Do not label relay acceptance as peer connection. A retry must reuse the same
logical operation, prevent duplicate peers and retain the operator's trust choice.
Cancellation/rejection delivery failures must be visible rather than reported as
successfully notified. Define recovery for already-approved legacy requests.
Normal discovery connections grant Observer access. **Link your own nodes** must
be a separate explicit flow with existing ownership/authentication requirements;
being reachable over FIPS never grants Trusted access or remote management rights.
## Connected nodes and Fleet
Show actual connection state and last successful authenticated contact. Distinguish
**Offline**, **Connecting**, **Unknown** and **Metrics unavailable**. Last report
age alone does not establish when a node went offline. Future/skewed timestamps
must not make a node permanently online.
Default ordering: online, connecting, unknown, confirmed offline; stable ordering
within groups. Honor manually selected sorting/filtering and do not disrupt the
user's selection while metrics update. Offline rows show last contact; show an
"offline for" duration only when an observed transition supports it.
The existing network map uses matching status labels and accessible details;
color alone is insufficient. Node detail keeps Connect/Retry, Files and permitted
management actions together. Do not add duplicate connection mechanisms.
## Acceptance before deployment
- Two real nodes: request, approval, reciprocal connection and persisted lists.
- Retry after lost reply, duplicate/reordered events, restart on each side,
unavailable relay, FIPS outage and supported transport recovery.
- Invalid signatures, unsolicited invites, wrong identities, stale/cancelled
requests and blocked peers cannot gain access or elevate trust.
- Desktop and actual companion: first connection, revisit, back navigation,
search, tab switching, refresh, background/resume and interrupted network.
- Measure tap-to-feedback, first usable content, discovery completion and
approval-to-confirmed-connection before and after. Preserve unknown data.
- Operator UAT gives exact nodes, steps and expected states; no extra payment or
wallet/channel changes are needed for connection testing.
+64
View File
@@ -0,0 +1,64 @@
# Peer requests, delivery, and availability follow-up
Status: implementation under qualification. Not a claim of reciprocal live-node
acceptance or completion of the post-1.9 backlog.
## Confirmed failures
Yaya retained the dev node's approved inbound request while the dev node retained
its outbound Sent request, without reciprocal federation membership. Approval
previously had no durable delivery/retry record. Configured managed relays were
also omitted from reply publication; that separate repair is in 9a041bed.
The Connected Nodes card cached untimestamped reachability booleans, with cached
results overriding the shared store. A failing RPC was rendered as an offline
route. Nostr requests were absent from its Requests tab and badge.
## Changes
- Persist the approval decision and a node-key-encrypted reply before delivery.
Retry the same invite with bounded exponential backoff, at most four eligible
replies per background pass. Relay acknowledgement alone does not remove it;
reciprocal membership does. Removed peers and expired requests are excluded.
- Recover legacy Approved rows through the same supported delivery path. Do not
edit peer files by hand or elevate Observer relationships to Trusted.
- Serialize pending-store mutations and replace its private file atomically.
Malformed storage is preserved and fails explicitly. Conflicting decisions
cannot both win. Approved requests expire after 30 days to permit reconnect.
- Validate the invite's DID and key against the requested identity, normalize
discovery trust to Observer before acceptance and callback.
- Poll in the background every 30 seconds, skipping missed ticks.
- Show Nostr requests in Connected Nodes with a direct link to review them.
Preserve pending rows on failed refresh, and surface partial failures.
- Render independently arriving node lists, limit reachability probes to four,
ignore superseded replies and age timestamped reachability after 90 seconds.
An RPC failure is unknown; an explicit failed reachability check is unreachable.
Report last successful contact without inventing continuous offline duration.
- Place online nodes first, then unknown and unreachable, preserving order within
each group. Fleet describes stale reports as Not reporting. Map labels include
last contact and dashed links indicate no recent contact, not a live route.
## Evidence so far
- Original full backend candidate: 1,693 passed, 4 ignored, no failures, through
the isolated runner (`/tmp/archy-peering-full-backend.log`).
- Additional review added recovery-through-real-relay and retry-backoff checks;
all 50 focused federation tests pass, including actual encrypted relay delivery
for legacy Approved rows and suppression of duplicate attempts during backoff.
Final formatted source also passes all 1,693 backend tests (4 ignored).
- Connected Nodes: 9 focused tests pass, including independent rendering,
mixed failed/negative/successful probes, Nostr requests, cache age and clock skew.
- Fleet/request display: 13 focused tests pass.
- Type checking and production UI build pass. Final frontend suite: 1,238 tests
in 153 files pass (`/tmp/archy-peering-full-ui-final.log`).
- Yaya Chromium at 390 and 1440px passes candidate-asset browser checks with
deterministic peer RPC fixtures: availability text/order, approved Nostr requests,
connection navigation and no page errors (`/tmp/archy-peering-browser-candidate-7.log`).
Fixture checks are not evidence of actual reciprocal membership.
- Clean backend artifact at 10d31ae1: SHA256
`120bd0f51fbceafeceb5557117a442fffc432a7b4d5115da1a163e472f7160da`.
Actual-node deployment and reciprocal membership checks remain required.
The prior reply-rejection test expected a Pending row. This revision deliberately
supersedes that behavior: the decision remains Approved with delivery pending,
so a relay outage does not undo an operator decision or require reapproval.
@@ -0,0 +1,123 @@
# Post-1.9.0 reliability investigation
Status: implementation and qualification in progress. These changes are on
`work/post-190-reliability`, separate from the published 1.9.0-alpha artifacts.
Nothing here constitutes live acceptance or permission to change peer trust.
## Peering: approved request does not establish a relationship
Read-only inspection on 2026-10-05 reproduced the operator's report: the receiving
node retains an approved inbound request while the requesting node retains its
outbound request in Sent state; neither has the reciprocal federation entry.
Both have discoverability enabled. FIPS is running on both (different service
names); checking only `fips.service` would incorrectly report one as inactive.
Source discrepancy: request publication and polling use `handshake_relays()`,
which combines managed relays and configured defaults. Approval, rejection and
cancellation instead use only configuration defaults. Align all three reply
paths with the shared resolver. Add an actual-handler integration test with a
UI-configured local WebSocket relay, signed event verification, recipient-only
NIP-44 decryption, reply types, persisted request states and Observer-only approval.
The isolated test passes: all three reply types reach the managed-only relay,
and a rejected approval remains Pending with no peer added. The complete backend
suite is running; actual-node handshake recovery remains outstanding.
This discrepancy is not yet a proven complete explanation of the live failure.
Read-only relay queries are being checked. Other source risks requiring separate
qualification include best-effort peer-joined callbacks without durable retry,
five-minute background polling, latest-50-event fetching without a cursor, and
pending-file read/modify/write operations without serialization. Do not manually
mark requests completed or elevate trust to make the UI appear connected.
## Web5 navigation cleanup
The Wallet quick-action only toggles a local disconnected flag and refreshes LND
information. Remove it and its independent LND polling; wallet interfaces remain
elsewhere. Rename Find Nodes to Connect with Nodes, including English/Spanish
translations and existing navigation assertions. Four focused component tests
pass. Full frontend tests, type checking and mobile/desktop rendering checks are
still required; no deployment or operator acceptance is claimed.
## Fleet findings to investigate
`normalizeFleetNode` substitutes zero for absent metrics, and timestamp age alone
is interpreted as online/offline. Missing values must not be presented as real
zero measurements, and stale reporting must not be treated as a measured offline
transition. Trace telemetry collection and transport before changing presentation.
## V4V source and demo catalog located
The existing Gitea repository is `v4v/v4v`, branch `demo-portainer`, at
`3ae171d6b0c728665a860520fe393c0abb772798`. The earlier `lfg2025/v4v`
location does not resolve on that server. Read-only inspection of the actual
Portainer documentation confirms a sanitized demo catalog with 52 entries and
47 playable tracks: 21 bundled demo WAVs and 26 publisher-hosted entries. These
counts describe the documented seed, not a fresh playback acceptance result.
Its importer is designed to back up existing state, merge by ID and retain
accounts, payment records and edits. Qualify those behaviors before deployment.
The source explicitly keeps private catalog/media archives out of public source
publication. Preserve that boundary when packaging the Yaya-only demo: hiding a
card is not sufficient protection for private media or credentials. Inspect the
existing node's state and exact deployed revision before modifying its stack.
## Qualification results so far
Frontend type checking passes. Full suite: 1,221 passed, one unchanged paid-file
case hit its 20-second test timeout under concurrent build/upload load. That
file's six tests all passed when rerun alone. Retain the original failure; do not
rewrite it as an entirely green full-suite run. Four targeted navigation tests
passed separately. Mobile/desktop browser acceptance remains outstanding.
The first new Rust test compile exposed two fixture-only String/&str mismatches.
Corrected them and added rejected-relay coverage: failed delivery must leave the
request Pending and must not add a peer. Isolated compilation/execution now passes (one integration test covers four
scenarios). No live peering repair or deployment is claimed.
Public relay queries found no matching reply on the managed relays that completed
the query; some endpoints were unavailable. The default Damus relay requires
authentication for this filter, so its unauthenticated rejection is not evidence
of a missing event. The installed SDK already enables automatic authentication.
Do not infer that the node has the same rejection without authenticated evidence.
### Completed source-test runs
The second full frontend run, limited to two workers, passes all1,222 tests across
150 files. Type checking passes. The first full backend run exposed a real
pre-existing bug: encrypted chat/contact nonces starting with `{` or `[` were
misclassified as legacy JSON. This is not dismissed as a flaky test.
Replace prefix-only classification with full JSON object/array validation using
`IgnoredAny` to avoid building another object tree. Deterministic valid encrypted
fixtures cover both prefixes; existing pre-migration ciphertext compatibility
and tamper/wrong-key tests remain. Full isolated backend rerun passes1,683 tests,
zero failures, four existing ignored. Logs are retained in
`/tmp/archy-followup-backend-suite-2.log` and
`/tmp/archy-followup-ui-suite-2.log`.
Published1.9.0 artifacts remain unchanged, and their release page discloses this
newly discovered issue. Private snapshots were attempted on all four authorized
nodes before further restarts: only Shorty had an affected message store at the
expected paths; its bytes were verified after backup. No message contents or
wallet data were exported. Actual candidate build/deployment and store reload
acceptance still remain; do not equate source-test success with delivery.
## Production storage candidate qualification
The cfd9a596 production binary (SHA256
3d720c6ddb2622ddef956b5ef0d54ecef64617e4bd16d07327b55ac737d00fe7)
was exercised in the disposable installed-system VM. Independent Python
ChaCha20-Poly1305 fixtures used the guest's key locally, without exporting it.
Encrypted nonces starting with `{` and `[` loaded through the real message RPC,
retained their exact ciphertext, and survived a second service restart. Legacy
JSON with leading whitespace migrated to authenticated ciphertext and survived
another restart with all message fields intact.
Fixture corrections were required: the first root-owned 0600 file was unreadable
by the service; the next legacy assertion omitted optional fields that serde
normally emits as null. Both initial failures remain in the qualification logs.
The corrected run passed all three data cases. SSH disconnected during final
cleanup, so restoration was completed as a guest systemd job and independently
checked: original 560aa600 backend, active service, and original absent message
store restored. The VM was then shut down. This is not live-fleet deployment or
proof of every corrupt-store recovery path.
+12 -3
View File
@@ -1,8 +1,17 @@
# Work requested after 1.9.0-alpha
Status: queued by the operator on 2026-10-05. Complete the current release first;
these requests do not silently expand its artifact scope. No implementation or
acceptance is claimed by this backlog.
Status: implementation and qualification in progress. 1.9.0-alpha was published
separately; these follow-ups are not in its immutable artifacts. The list below
remains the complete acceptance scope, not a claim that every item is finished.
Current evidence is recorded in [Fleet metrics](fleet-metrics-followup.md),
[peering reliability](peering-reliability-followup.md), and the
[IndeeHub design review](indeehub-distribution-current-design.md). Monitoring and
the tested signer/dashboard candidate are deployed to dev and Yaya with rollback
backups; actual IndeeHub image publication is deferred until the end as requested.
Framework's authenticated Monitoring check awaits an operator dashboard login.
Streaming, storage-source integrations, AIUI setup, V4V packaging/player,
companion hardware checks and the full Fleet acceptance matrix remain open.
## 1. Distributed IndeeHub publishing and paid viewing
+10 -1
View File
@@ -211,7 +211,16 @@
selectedPublicKeyTimer = null;
return Promise.resolve(publicKey);
}
if (navigator.userActivation && navigator.userActivation.isActive) {
// The picker click itself leaves transient user activation active. A second
// public-key lookup in the same login must not open another picker.
var restoringSession = false;
try {
var sessionHint = sessionStorage.getItem('nostr_token');
restoringSession = !!sessionHint && sessionHint.indexOf('mock-') !== 0;
} catch (_) {}
// This is only a UI hint: the broker still verifies the node session and
// signing permissions. A restored app token never authorizes a signature.
if (!restoringSession && !selectedIdentity && navigator.userActivation && navigator.userActivation.isActive) {
return selectIdentity().then(function () {
return getPublicKey();
});
@@ -0,0 +1,135 @@
<template>
<BaseModal :show="show && !funding" title="Connect your AI" max-width="max-w-xl" @close="close">
<p class="text-sm text-white/60 mb-4">Choose how your assistant connects. API keys stay on this node and never enter your chat.</p>
<p v-if="loading" role="status" class="text-sm text-white/60 mb-3">Checking this node…</p>
<p v-if="error" role="alert" class="text-sm text-amber-200 mb-3">{{ error }}</p>
<div class="grid grid-cols-1 sm:grid-cols-3 gap-2 mb-4">
<button v-for="choice in choices" :key="choice.id" class="glass-button rounded-lg px-3 py-3 text-sm text-white" :aria-pressed="mode === choice.id" @click="choose(choice.id)">{{ choice.label }}</button>
</div>
<form v-if="mode === 'claude' || mode === 'openai'" class="space-y-3" @submit.prevent="save">
<p class="text-xs text-white/60">{{ mode === 'openai' ? 'Use an OpenAI API key with API billing. Choose a chat model your project can access.' : 'Use your Anthropic API key. Usage is billed to that API account.' }}</p>
<p v-if="configured" class="text-sm text-white/70">A key is already configured. Leave this field empty to keep it.</p>
<label class="block text-sm text-white/80" for="ai-connection-key">{{ mode === 'openai' ? 'OpenAI' : 'Claude' }} API key</label>
<input id="ai-connection-key" v-model="key" type="password" autocomplete="off" spellcheck="false" class="input-glass w-full" placeholder="Paste API key" :disabled="saving" />
<template v-if="mode === 'openai'">
<label class="block text-sm text-white/80" for="ai-connection-model">Model ID</label>
<input id="ai-connection-model" v-model="model" type="text" spellcheck="false" class="input-glass w-full" placeholder="Enter your OpenAI model ID" :disabled="saving" />
</template>
<button class="glass-button rounded-lg px-4 py-2 text-sm text-white w-full" :disabled="saving || loading || (!key.trim() && !configured) || (mode === 'openai' && !model.trim())">{{ saving ? 'Saving…' : 'Save and continue' }}</button>
</form>
<div v-else-if="mode === 'routstr'" class="space-y-3">
<p class="text-sm text-white/70">Pay for AI with ecash. Add funds to this node’s wallet and set the most it may spend.</p>
<p class="text-sm text-white/70">Ecash balance: {{ balance === null ? 'Unavailable' : balance.toLocaleString() + ' sats' }}</p>
<button class="glass-button rounded-lg px-4 py-2 text-sm text-white" @click="funding = true">Add ecash</button>
<RoutstrBudgetSection />
<button class="glass-button rounded-lg px-4 py-2 text-sm text-white w-full" @click="continueRoutstr">Use Routstr</button>
<p class="text-xs text-white/50">The selected model’s price and accepted mint still apply. No payment is sent by opening this setup.</p>
</div>
<button v-if="status?.local_ready" class="glass-button rounded-lg px-4 py-2 text-sm text-white mt-4 w-full" @click="useLocal">Use local AI</button>
<p class="text-xs text-white/40 mt-4">Your draft stays in place when you close this window.</p>
</BaseModal>
<ReceiveBitcoinModal :show="show && funding" initial-method="ecash" @close="finishFunding" @received="finishFunding" />
</template>
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { rpcClient } from '@/api/rpc-client'
import BaseModal from './BaseModal.vue'
import ReceiveBitcoinModal from './ReceiveBitcoinModal.vue'
import RoutstrBudgetSection from '@/views/settings/RoutstrBudgetSection.vue'
type Choice = 'claude' | 'openai' | 'routstr'
interface ProviderStatus {
schema: number
settings: { provider: 'auto' | 'local' | 'routstr' | 'claude' | 'openai'; openai_model: string }
claude_configured: boolean
openai_configured: boolean
local_ready: boolean | null
routstr_remaining_sats: number
}
const props = defineProps<{ show: boolean }>()
const emit = defineEmits<{ close: []; configured: [provider: Choice | 'auto' | 'local', model?: string] }>()
const choices: { id: Choice; label: string }[] = [{ id: 'claude', label: 'Claude API' }, { id: 'openai', label: 'OpenAI API' }, { id: 'routstr', label: 'Routstr · sats' }]
const mode = ref<Choice | null>(null)
const key = ref('')
const model = ref('')
const status = ref<ProviderStatus | null>(null)
const balance = ref<number | null>(null)
const error = ref('')
const loading = ref(false)
const saving = ref(false)
const funding = ref(false)
const configured = computed(() => mode.value === 'claude' ? status.value?.claude_configured : status.value?.openai_configured)
let refreshPromise: Promise<ProviderStatus | null> | null = null
async function refresh(): Promise<ProviderStatus | null> {
if (refreshPromise) return refreshPromise
loading.value = true
refreshPromise = (async () => {
try {
const response = await rpcClient.call<{ value: ProviderStatus | null }>({ method: 'system.settings.get', params: { key: 'ai_provider_status' }, timeout: 8000 })
if (response.value?.schema !== 1) throw new Error('AI connection setup needs the matching node update.')
status.value = response.value
if (!model.value) model.value = response.value.settings.openai_model || ''
return response.value
} catch {
error.value = 'Could not check AI connections. Try again when this node is reachable.'
return null
} finally { loading.value = false; refreshPromise = null }
})()
return refreshPromise
}
async function checkNeeded(): Promise<boolean> {
const state = await refresh()
if (!state) return false // An unavailable status endpoint is not proof of missing configuration.
if (state.settings.provider === 'openai') return !state.openai_configured
if (state.settings.provider === 'claude') return !state.claude_configured
if (state.settings.provider === 'local') return state.local_ready === false
if (state.settings.provider === 'routstr' || (state.local_ready === false && !state.claude_configured)) {
if (state.routstr_remaining_sats <= 0) return true
await refreshBalance()
return balance.value === 0
}
return false
}
async function refreshBalance() {
try { const result = await rpcClient.call<{ balance_sats: number }>({ method: 'wallet.ecash-balance', timeout: 8000 }); balance.value = Number.isFinite(result.balance_sats) ? result.balance_sats : null }
catch { balance.value = null }
}
function choose(choice: Choice) { key.value = ''; error.value = ''; mode.value = choice; if (choice === 'routstr') void refreshBalance() }
function close() { key.value = ''; funding.value = false; emit('close') }
async function save() {
if ((mode.value !== 'claude' && mode.value !== 'openai') || saving.value) return
const provider = mode.value
saving.value = true; error.value = ''
try {
if (key.value.trim()) {
await rpcClient.call({ method: 'system.settings.set', params: { key: `${provider}_api_key`, value: key.value.trim() } })
key.value = ''
if (status.value) status.value[provider === 'claude' ? 'claude_configured' : 'openai_configured'] = true
} else if (!configured.value) return
await rpcClient.call({ method: 'system.settings.set', params: { key: 'ai_provider', value: JSON.stringify({ provider, openai_model: model.value.trim() }) } })
emit('configured', provider, provider === 'openai' ? model.value.trim() : undefined); close()
} catch { error.value = 'Could not save the connection. Check the key and model, then retry.' }
finally { saving.value = false }
}
async function useLocal() {
error.value = ''
try {
await rpcClient.call({ method: 'system.settings.set', params: { key: 'ai_provider', value: JSON.stringify({ provider: 'local', openai_model: model.value }) } })
emit('configured', 'local'); close()
} catch { error.value = 'Could not select local AI. Try again.' }
}
async function continueRoutstr() {
const [state] = await Promise.all([refresh(), refreshBalance()])
if (!state || state.routstr_remaining_sats <= 0) { error.value = 'Set a spending allowance before using Routstr.'; return }
if (balance.value === null || balance.value <= 0) { error.value = 'Add ecash to this node before using Routstr.'; return }
try {
await rpcClient.call({ method: 'system.settings.set', params: { key: 'ai_provider', value: JSON.stringify({ provider: 'routstr', openai_model: model.value }) } })
emit('configured', 'routstr'); close()
} catch { error.value = 'Could not select Routstr. Try again.' }
}
async function finishFunding() { funding.value = false; await refreshBalance() }
watch(() => props.show, open => { if (open) { error.value = ''; void refresh() } else { key.value = ''; funding.value = false } })
async function syncSelection() { const state = await refresh(); if (state) emit('configured', state.settings.provider, state.settings.provider === 'openai' ? state.settings.openai_model : undefined) }
defineExpose({ checkNeeded, syncSelection, showRoutstr: () => choose('routstr') })
</script>
@@ -147,6 +147,7 @@ const lightning = useLightningRequired()
const props = defineProps<{
show: boolean
initialMethod?: 'lightning' | 'onchain' | 'ecash' | 'ark'
/** Optional info banner shown on the on-chain tab (e.g. Zeus channel limits) */
note?: string
/** Generate an on-chain address immediately when the modal opens */
@@ -168,7 +169,7 @@ watch(() => props.show, (open) => {
// Blank slate on every open: a leftover amount/memo/token or a previous
// invoice quietly carrying into a new receive flow is exactly the stale-
// state class the operator flagged on the send modal (2026-08-05).
receiveMethod.value = 'onchain'
receiveMethod.value = props.initialMethod ?? 'onchain'
invoiceAmount.value = 0
invoiceMemo.value = ''
invoiceResult.value = ''
@@ -342,8 +343,8 @@ async function pollLnClaims() {
onUnmounted(stopLnClaimPoll)
// Fetch the address the first time the operator opens the ecash tab.
watch(receiveMethod, (m) => {
if (m === 'ecash' && props.show) {
watch([receiveMethod, () => props.show], ([m, open]) => {
if (m === 'ecash' && open) {
lnWatchStartedAt.value = Math.floor(Date.now() / 1000)
void loadLnAddress()
}
@@ -0,0 +1,60 @@
import { mount, flushPromises } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import AIConnectionModal from '../AIConnectionModal.vue'
import { rpcClient } from '@/api/rpc-client'
vi.mock('@/api/rpc-client', () => ({ rpcClient: { call: vi.fn() } }))
vi.mock('../ReceiveBitcoinModal.vue', () => ({ default: { props: ['show', 'initialMethod'], template: '<div />' } }))
vi.mock('@/views/settings/RoutstrBudgetSection.vue', () => ({ default: { template: '<div />' } }))
const state = () => ({ schema: 1, settings: { provider: 'auto', openai_model: '' }, claude_configured: false, openai_configured: false, local_ready: false, routstr_remaining_sats: 0 })
function mountModal() { return mount(AIConnectionModal, { props: { show: false }, global: { stubs: { BaseModal: { props: ['show'], template: '<div v-if="show"><slot /></div>' } } } }) }
function button(w: ReturnType<typeof mountModal>, label: string) { return w.findAll('button').find(b => b.text() === label)! }
beforeEach(() => { vi.clearAllMocks(); vi.mocked(rpcClient.call).mockImplementation(async ({ method }) => method === 'system.settings.get' ? { value: state() } : {}) })
describe('AI connection setup', () => {
it('detects absent configuration without treating a failed status query as missing keys', async () => {
const w = mountModal()
expect(await (w.vm as any).checkNeeded()).toBe(true)
vi.mocked(rpcClient.call).mockRejectedValueOnce(new Error('offline'))
expect(await (w.vm as any).checkNeeded()).toBe(false)
expect(rpcClient.call).not.toHaveBeenCalledWith(expect.objectContaining({ method: 'system.settings.set' }))
w.unmount()
})
it('sends a key only to private settings, clears the field, and selects the persisted model', async () => {
const w = mountModal(); await w.setProps({ show: true }); await flushPromises()
await button(w, 'OpenAI API').trigger('click')
await w.get('#ai-connection-key').setValue('test-private-key')
await w.get('#ai-connection-model').setValue('test-chat-model')
await w.get('form').trigger('submit'); await flushPromises()
const writes = vi.mocked(rpcClient.call).mock.calls.map(([r]) => r).filter(r => r.method === 'system.settings.set')
expect(writes.map(r => r.params)).toEqual([{ key: 'openai_api_key', value: 'test-private-key' }, { key: 'ai_provider', value: JSON.stringify({ provider: 'openai', openai_model: 'test-chat-model' }) }])
expect((w.get('#ai-connection-key').element as HTMLInputElement).value).toBe('')
expect(w.emitted('configured')).toEqual([['openai', 'test-chat-model']])
expect(JSON.stringify(w.emitted())).not.toContain('test-private-key')
w.unmount()
})
it('clears unsaved keys when switching provider and closing', async () => {
const w = mountModal(); await w.setProps({ show: true }); await flushPromises()
await button(w, 'Claude API').trigger('click'); await w.get('#ai-connection-key').setValue('unsaved')
await button(w, 'OpenAI API').trigger('click')
expect((w.get('#ai-connection-key').element as HTMLInputElement).value).toBe('')
await w.get('#ai-connection-key').setValue('unsaved-again'); await w.setProps({ show: false }); await w.setProps({ show: true })
expect((w.get('#ai-connection-key').element as HTMLInputElement).value).toBe('')
expect(w.emitted('configured')).toBeUndefined(); w.unmount()
})
it('does not reuse an OpenAI model ID when restoring a Routstr connection', async () => {
const value = { ...state(), settings: { provider: 'routstr', openai_model: 'previous-openai-model' } }
vi.mocked(rpcClient.call).mockResolvedValue({ value })
const w = mountModal(); await (w.vm as any).syncSelection()
expect(w.emitted('configured')).toEqual([['routstr', undefined]])
w.unmount()
})
it('does not authorize Routstr spending from setup when allowance is zero', async () => {
const w = mountModal(); await w.setProps({ show: true }); await flushPromises()
await button(w, 'Routstr · sats').trigger('click'); await flushPromises()
await button(w, 'Use Routstr').trigger('click'); await flushPromises()
expect(w.text()).toContain('Set a spending allowance')
expect(w.emitted('configured')).toBeUndefined()
expect(vi.mocked(rpcClient.call).mock.calls.every(([r]) => !['assistant.budget-set', 'system.settings.set'].includes(r.method))).toBe(true)
w.unmount()
})
})
@@ -40,6 +40,17 @@ beforeEach(() => {
// unmounts the dialog — but the RPC-eager tab switch is exactly the kind of
// path a future change could regress, so it's worth pinning down.
describe('ReceiveBitcoinModal — ecash tab click', () => {
it('loads the ecash address on each open when funding starts on the ecash tab', async () => {
vi.mocked(rpcClient.call).mockResolvedValue({ address: 'funding@minibits.cash' } as never)
const wrapper = mount(ReceiveBitcoinModal, { props: { show: false, initialMethod: 'ecash' }, attachTo: document.body })
await wrapper.setProps({ show: true }); await flushPromises()
expect(document.body.textContent).toContain('funding@minibits.cash')
await wrapper.setProps({ show: false }); await wrapper.setProps({ show: true }); await flushPromises()
expect(document.body.textContent).toContain('funding@minibits.cash')
expect(vi.mocked(rpcClient.call).mock.calls.filter(([r]) => r.method === 'wallet.ecash-lnaddress')).toHaveLength(2)
wrapper.unmount()
})
it('offers authenticated setup for an unseeded wallet and retries the address after setup', async () => {
let active = false
vi.mocked(rpcClient.call).mockImplementation(async ({ method }) => {
@@ -12,6 +12,7 @@
<span class="node-map-legend-item"><span class="node-map-dot" :style="{ background: motionTokens.color.trusted }"></span>Trusted</span>
<span class="node-map-legend-item"><span class="node-map-dot" :style="{ background: motionTokens.color.observer }"></span>Observer</span>
<span class="node-map-legend-item"><span class="node-map-dot" :style="{ background: motionTokens.color.untrusted }"></span>Untrusted</span>
<span class="node-map-legend-item">Dashed: no recent contact</span>
<span v-if="requests?.length" class="node-map-legend-item"><span class="node-map-dot" :style="{ background: motionTokens.color.pending }"></span>Request</span>
</div>
<!-- 2D/3D projection toggle — tweens the camera between the flat radial
@@ -81,6 +82,7 @@ export interface MapNode {
label: string
trust_level: 'trusted' | 'observer' | 'untrusted'
online: boolean
statusLabel?: string
app_count: number
is_self: boolean
}
@@ -162,6 +164,7 @@ interface PeerVis {
dying: boolean
el: SVGGElement
label: SVGTextElement
statusText: SVGTextElement
linkEl: SVGPathElement | null
dots: GlobeDot[]
radiusPx: number
@@ -461,9 +464,15 @@ function createPeer(node: MapNode, slot: { angle: number; ringRadius: number },
fill: motionTokens.color.textSecondary, 'font-size': '11px', 'font-family': FONT,
})
label.textContent = node.label
const statusText = el('text', {
dy: String(r + 28), 'text-anchor': 'middle', fill: motionTokens.color.textSecondary,
'font-size': '9px', 'font-family': FONT,
})
statusText.textContent = node.statusLabel || (node.online ? 'Online' : 'Status unknown')
const title = el('title')
title.textContent = `${node.did}\nApps: ${node.app_count}\n${node.online ? 'Online' : 'Offline'}`
g.append(label, title)
title.textContent = `${node.did}\nApps: ${node.app_count}\n${statusText.textContent}`
g.setAttribute('aria-label', `${node.label}: ${statusText.textContent}`)
g.append(label, statusText, title)
g.addEventListener('click', () => emit('select', node.did))
nodesLayer!.appendChild(g)
@@ -483,6 +492,7 @@ function createPeer(node: MapNode, slot: { angle: number; ringRadius: number },
dying: false,
el: g,
label,
statusText,
linkEl,
dots,
radiusPx: r,
@@ -672,7 +682,9 @@ function applyGraph() {
existing.node = node
if (existing.label.textContent !== node.label) existing.label.textContent = node.label
const title = existing.el.querySelector('title')
if (title) title.textContent = `${node.did}\nApps: ${node.app_count}\n${node.online ? 'Online' : 'Offline'}`
existing.statusText.textContent = node.statusLabel || (node.online ? 'Online' : 'Status unknown')
existing.el.setAttribute('aria-label', `${node.label}: ${existing.statusText.textContent}`)
if (title) title.textContent = `${node.did}\nApps: ${node.app_count}\n${existing.statusText.textContent}`
stylePeerDom(existing)
}
@@ -1249,7 +1261,7 @@ function initialBuild() {
}
const graphSignature = computed(() => JSON.stringify({
nodes: props.nodes.map(n => [n.did, n.label, n.trust_level, n.online, n.app_count, n.is_self]),
nodes: props.nodes.map(n => [n.did, n.label, n.trust_level, n.online, n.statusLabel, n.app_count, n.is_self]),
links: props.links.map(l => [l.source, l.target]),
requests: (props.requests ?? []).map(r => [r.id, r.label]),
}))
+1 -1
View File
@@ -392,7 +392,7 @@
"manageDomains": "Manage Domains",
"relaysConnected": "{count} connected",
"peersKnown": "{count} peer(s) known",
"findNodes": "Find Nodes",
"findNodes": "Connect with Nodes",
"sendMessage": "Send Message",
"sendMessageTitle": "Send Message (over Tor)",
"to": "To",
+1 -1
View File
@@ -390,7 +390,7 @@
"manageDomains": "Administrar dominios",
"relaysConnected": "{count} conectados",
"peersKnown": "{count} par(es) conocido(s)",
"findNodes": "Buscar nodos",
"findNodes": "Conectar con nodos",
"sendMessage": "Enviar mensaje",
"sendMessageTitle": "Enviar mensaje (a trav\u00e9s de Tor)",
"to": "Para",
@@ -6,6 +6,7 @@ import { useWeb5BadgeStore } from '../web5Badge'
vi.mock('@/api/rpc-client', () => ({
rpcClient: {
call: vi.fn(),
federationListPendingRequests: vi.fn().mockResolvedValue({ requests: [] }),
},
}))
@@ -75,4 +76,25 @@ describe('useWeb5BadgeStore', () => {
await store.refresh()
expect(store.pendingRequestCount).toBe(2)
})
it('counts incoming Nostr requests while excluding outbound and completed requests', async () => {
vi.mocked(rpcClient.call).mockResolvedValueOnce({ requests: [{ id: 'legacy' }] })
const common = { from_did: 'did:key:peer', from_nostr_pubkey: 'key', from_nostr_npub: 'npub', from_name: null, message: null, received_at: new Date().toISOString() }
vi.mocked(rpcClient.federationListPendingRequests).mockResolvedValueOnce({ requests: [
{ ...common, id: 'incoming', state: 'pending', outbound: false },
{ ...common, id: 'outgoing', state: 'sent', outbound: true },
{ ...common, id: 'approved', state: 'approved', outbound: false },
] })
const store = useWeb5BadgeStore()
await store.refresh()
expect(store.pendingRequestCount).toBe(2)
})
it('preserves the previous badge when the Nostr source cannot be refreshed', async () => {
vi.mocked(rpcClient.call).mockResolvedValueOnce({ requests: [] })
vi.mocked(rpcClient.federationListPendingRequests).mockRejectedValueOnce(new Error('offline'))
const store = useWeb5BadgeStore()
store.pendingRequestCount = 3
await store.refresh()
expect(store.pendingRequestCount).toBe(3)
})
})
+12 -5
View File
@@ -6,11 +6,18 @@ export const useWeb5BadgeStore = defineStore('web5Badge', () => {
const pendingRequestCount = ref(0)
async function refresh() {
try {
const res = await rpcClient.call<{ requests: Array<{ id: string }> }>({ method: 'network.list-requests' })
pendingRequestCount.value = res.requests?.length ?? 0
} catch (e) {
if (import.meta.env.DEV) console.warn('Badge refresh failed — best-effort', e)
const results = await Promise.allSettled([
rpcClient.call<{ requests: Array<{ id: string }> }>({ method: 'network.list-requests' }),
rpcClient.federationListPendingRequests(),
])
// Do not clear an existing badge when a request source is unavailable.
if (results.every(result => result.status === 'fulfilled')) {
const legacy = results[0]
const nostr = results[1]
if (legacy?.status === 'fulfilled' && nostr?.status === 'fulfilled') {
pendingRequestCount.value = (legacy.value.requests?.length ?? 0) +
(nostr.value.requests?.filter(request => request.state === 'pending' && !request.outbound).length ?? 0)
}
}
}
+18 -1
View File
@@ -15,6 +15,8 @@
/>
</div>
<AIConnectionModal ref="connectionSetup" :show="showConnectionSetup" @close="showConnectionSetup = false" @configured="providerConfigured" />
<!-- Loading indicator while iframe loads. pointer-events:none on the
wrapper (see <style>) so this never blocks clicks reaching the
iframe underneath even while shown; the bounded timeout below
@@ -124,6 +126,7 @@ import { useRoute, useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { ContextBroker } from '@/services/contextBroker'
import ToolConfirmModal from '@/components/ToolConfirmModal.vue'
import AIConnectionModal from '@/components/AIConnectionModal.vue'
import { AI_PERMISSION_CATEGORIES } from '@/stores/aiPermissions'
import { IS_DEMO } from '@/composables/useDemoIntro'
@@ -133,6 +136,13 @@ const router = useRouter()
const route = useRoute()
const aiuiFrame = ref<HTMLIFrameElement | null>(null)
const aiuiConnected = ref(false)
const connectionSetup = ref<InstanceType<typeof AIConnectionModal> | null>(null)
const showConnectionSetup = ref(false)
function providerConfigured(provider: 'claude' | 'openai' | 'routstr' | 'auto' | 'local', model?: string) {
if (!aiuiFrame.value?.contentWindow || !aiuiUrl.value) return
aiuiFrame.value.contentWindow.postMessage({ type: 'ai:provider-configured', provider, model }, new URL(aiuiUrl.value, window.location.origin).origin)
}
onMounted(async () => { if (!IS_DEMO && await connectionSetup.value?.checkNeeded()) showConnectionSetup.value = true })
// Belt-and-suspenders backstop (2026-07-30 live-testing follow-up): the
// loading overlay must never be able to wedge the UI permanently regardless
// of AIUI/backend state — a broken handshake, a misconfigured origin, or the
@@ -285,15 +295,21 @@ function openAISettings() {
}
function onAiuiMessage(event: MessageEvent) {
if (!aiuiUrl.value) return
if (!aiuiUrl.value || event.source !== aiuiFrame.value?.contentWindow) return
// Validate origin — only accept messages from AIUI
try {
const expected = new URL(aiuiUrl.value, window.location.origin).origin
if (event.origin !== expected) return
} catch { return }
if (event.data?.type === 'ai:setup-request') {
showConnectionSetup.value = true
if (event.data.reason === 'funding') connectionSetup.value?.showRoutstr()
return
}
// Listen for ready messages from AIUI iframe
if (event.data?.type === 'ready') {
aiuiConnected.value = true
if (!IS_DEMO) void connectionSetup.value?.syncSelection()
if (loadTimeout) { clearTimeout(loadTimeout); loadTimeout = null }
// A ⌘K ask that arrived before the handshake is waiting — send it now.
flushAsk()
@@ -362,6 +378,7 @@ onBeforeUnmount(() => {
</script>
<style scoped>
/* Teleported to body, so this is positioned against the viewport, not the
chat panel. Sits above the iframe but below the confirm modal — a
blocking decision must always win over a passive offer. */
+7 -4
View File
@@ -182,6 +182,7 @@
<PendingRequestsPanel
:requests="pendingRequests"
:peer-dids="nodes.map(node => node.did)"
:polling="pollingHandshake"
:busy-id="pendingBusyId"
@poll="pollHandshake"
@@ -260,6 +261,7 @@
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { validContact, lastContactLabel } from './web5/nodeAvailability'
import { rpcClient } from '@/api/rpc-client'
import { useCachedResource } from '@/composables/useCachedResource'
import { useTransportStore } from '@/stores/transport'
@@ -349,6 +351,7 @@ const selfDid = ref<string>((() => {
try { return localStorage.getItem('neode_did') || '' } catch { return '' }
})())
const availabilityNow = ref(Date.now())
const mapNodes = computed(() => {
const result = []
if (selfDid.value) {
@@ -367,6 +370,7 @@ const mapNodes = computed(() => {
label: nodeName(node),
trust_level: node.trust_level as 'trusted' | 'observer' | 'untrusted',
online: isOnlineCheck(node),
statusLabel: isOnlineCheck(node) ? 'Online' : lastContactLabel(node.last_seen, availabilityNow.value),
app_count: node.last_state?.apps?.length ?? 0,
is_self: false,
})
@@ -572,10 +576,8 @@ async function cancelPending(id: string) {
}
function isOnlineCheck(node: FederatedNode): boolean {
if (!node.last_seen) return false
const lastSeen = new Date(node.last_seen).getTime()
const tenMinutesAgo = Date.now() - 10 * 60 * 1000
return lastSeen > tenMinutesAgo
const lastSeen = validContact(node.last_seen, availabilityNow.value)
return lastSeen !== undefined && availabilityNow.value - lastSeen < 10 * 60 * 1000
}
/** Explicit reload (mutations, retry): surfaces a load failure in the error
@@ -832,6 +834,7 @@ onMounted(async () => {
// Self DID not available
}
autoRefreshTimer = setInterval(() => {
availabilityNow.value = Date.now()
void nodesRes.refresh()
loadPendingRequests()
}, 5000)
+1
View File
@@ -82,6 +82,7 @@
:node-count="fleet.nodes.value.length"
:online-count="fleet.onlineCount.value"
:offline-count="fleet.offlineCount.value"
:unknown-count="fleet.unknownCount.value"
:fleet-health-pct="fleet.fleetHealthPct.value"
:healthy-count="fleet.healthyCount.value"
:avg-cpu="fleet.avgCpu.value"
+12 -7
View File
@@ -74,14 +74,19 @@ function hideSigner(delay = 0) {
}
function sendIdentity(identity: SelectedIdentity) {
// Picker entries are Vue reactive objects. postMessage cannot clone a Proxy;
// sending it directly closes the picker and then throws, stranding the app
// behind the otherwise empty signer frame. Copy only the public fields.
parentPost({ type: 'archipelago:signer-identity', identity: {
id: identity.id, name: identity.name, did: identity.did,
pubkey: identity.pubkey, nostr_pubkey: identity.nostr_pubkey,
// The picker emits a Vue reactive object. Browser postMessage cannot clone
// that proxy: sending it closes the picker, then throws before the broker
// can hide, leaving a blank full-screen surface. Send public scalar fields
// explicitly, also keeping any unrelated identity metadata out of the app.
const publicIdentity: SelectedIdentity = {
id: identity.id,
name: identity.name,
did: identity.did,
pubkey: identity.pubkey,
nostr_pubkey: identity.nostr_pubkey,
nostr_npub: identity.nostr_npub,
} })
}
parentPost({ type: 'archipelago:signer-identity', identity: publicIdentity })
}
const bridge = useNostrBridge(getStoredIdentity, {
@@ -11,6 +11,8 @@ import { KeepAlive, defineComponent, h, ref } from 'vue'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import Chat from '../Chat.vue'
vi.mock('@/components/AIConnectionModal.vue', () => ({ default: { template: '<div />', methods: { checkNeeded: async () => false, syncSelection: async () => {} } } }))
const routerBackMock = vi.fn()
const routerPushMock = vi.fn()
const routerReplaceMock = vi.fn()
@@ -99,6 +101,7 @@ describe('Chat / AIUI embed URL stability + D-14 defaults (02-07)', () => {
Object.defineProperty(frame, 'contentWindow', { configurable: true, value: { postMessage: post } })
window.dispatchEvent(new MessageEvent('message', {
source: (wrapper.find('iframe').element as HTMLIFrameElement).contentWindow,
origin: 'http://localhost:5173',
data: { type: 'ready' },
}))
@@ -125,6 +128,7 @@ describe('Chat / AIUI embed URL stability + D-14 defaults (02-07)', () => {
Object.defineProperty(frame, 'contentWindow', { configurable: true, value: { postMessage: post } })
window.dispatchEvent(new MessageEvent('message', {
source: (wrapper.find('iframe').element as HTMLIFrameElement).contentWindow,
origin: 'http://localhost:5173',
data: { type: 'ready' },
}))
@@ -165,6 +169,7 @@ describe('Chat / AIUI embed URL stability + D-14 defaults (02-07)', () => {
const { wrapper } = mountChatInKeepAlive()
window.dispatchEvent(new MessageEvent('message', {
source: (wrapper.find('iframe').element as HTMLIFrameElement).contentWindow,
data: { type: 'ready' },
origin: 'http://evil.example',
}))
@@ -177,10 +182,22 @@ describe('Chat / AIUI embed URL stability + D-14 defaults (02-07)', () => {
wrapper.unmount()
})
it('ignores ready and setup messages from a different same-origin frame', async () => {
const { wrapper } = mountChatInKeepAlive()
for (const type of ['ready', 'ai:setup-request']) window.dispatchEvent(new MessageEvent('message', {
source: window, origin: 'http://localhost:5173', data: { type },
}))
await flushPromises()
expect(wrapper.find('.chat-loading').exists()).toBe(true)
expect(wrapper.find('[title="chat.aiuiConnected"]').exists()).toBe(false)
wrapper.unmount()
})
it('aiuiConnected survives a deactivate/reactivate cycle once set by a same-origin ready message', async () => {
const { wrapper, show } = mountChatInKeepAlive()
window.dispatchEvent(new MessageEvent('message', {
source: (wrapper.find('iframe').element as HTMLIFrameElement).contentWindow,
data: { type: 'ready' },
origin: 'http://localhost:5173',
}))
@@ -1,6 +1,7 @@
import { reactive } from 'vue'
import { shallowMount } from '@vue/test-utils'
import { flushPromises, shallowMount } from '@vue/test-utils'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { reactive } from 'vue'
import NostrIdentityPicker from '@/components/NostrIdentityPicker.vue'
import NostrTabSigner from '@/views/NostrTabSigner.vue'
describe('NostrTabSigner visibility', () => {
@@ -9,7 +10,7 @@ describe('NostrTabSigner visibility', () => {
window.history.replaceState({}, '', '/nostr-signer')
})
afterEach(() => vi.restoreAllMocks())
afterEach(() => { vi.restoreAllMocks(); vi.useRealTimers() })
function parentMessage(data: Record<string, unknown>) {
const event = new MessageEvent('message', { data, origin: window.location.origin })
@@ -64,4 +65,33 @@ describe('NostrTabSigner visibility', () => {
expect(document.documentElement.classList.contains('nostr-signer-route')).toBe(false)
expect(document.body.classList.contains('nostr-signer-route')).toBe(false)
})
it('hands off a reactive picker identity as cloneable public fields and releases the overlay', async () => {
vi.useFakeTimers()
const sent: Array<Record<string, unknown>> = []
// Real cross-frame postMessage performs structured cloning. A normal spy
// would miss the browser-only DataCloneError that leaves a grey overlay.
vi.spyOn(window.parent, 'postMessage').mockImplementation((message) => {
sent.push(structuredClone(message))
})
const wrapper = shallowMount(NostrTabSigner)
parentMessage({type: 'archipelago:signer-init', appId: 'indeedhub', appName: 'IndeeHub'})
const selected = reactive({
id: 'identity-a', name: 'Alice', did: 'did:key:test', pubkey: 'public-node-key',
nostr_pubkey: 'a'.repeat(64), nostr_npub: 'npub-test', internal_metadata: 'must-stay-local',
})
expect(() => structuredClone(selected)).toThrow()
wrapper.findComponent(NostrIdentityPicker).vm.$emit('select', selected)
await flushPromises()
const identityMessage = sent.find(message => message.type === 'archipelago:signer-identity')
expect(identityMessage?.identity).toEqual({
id: 'identity-a', name: 'Alice', did: 'did:key:test', pubkey: 'public-node-key',
nostr_pubkey: 'a'.repeat(64), nostr_npub: 'npub-test',
})
await vi.advanceTimersByTimeAsync(400)
expect(sent).toContainEqual({type: 'archipelago:signer-hide'})
expect(wrapper.findComponent(NostrIdentityPicker).props('show')).toBe(false)
wrapper.unmount()
})
})
@@ -24,8 +24,17 @@ type ProviderWindow = Window & {
describe('nostr-provider identity selection', () => {
let providerWindow: ProviderWindow
let listeners: Array<[string, EventListenerOrEventListenerObject, boolean | AddEventListenerOptions | undefined]>
beforeEach(() => {
vi.useFakeTimers()
listeners = []
const add = window.addEventListener.bind(window)
vi.spyOn(window, 'addEventListener').mockImplementation((type, listener, options) => {
if (listener) listeners.push([type, listener as EventListenerOrEventListenerObject, options])
add(type, listener, options)
})
sessionStorage.clear()
providerWindow = window as ProviderWindow
delete providerWindow.__archipelagoNostr
delete providerWindow.nostr
@@ -36,6 +45,9 @@ describe('nostr-provider identity selection', () => {
})
afterEach(() => {
for (const [type, listener, options] of listeners) window.removeEventListener(type, listener, options)
vi.clearAllTimers()
vi.unstubAllGlobals()
vi.useRealTimers()
vi.restoreAllMocks()
Reflect.deleteProperty(document, 'readyState')
@@ -122,6 +134,46 @@ describe('nostr-provider identity selection', () => {
)
})
it('does not reopen after a selected identity when activation survives account restoration', async () => {
const { frame, postMessage, signerOrigin } = loadProvider(true)
const selected = new MessageEvent('message', {
data: { type: 'archipelago:signer-identity', identity: { nostr_pubkey: 'selected-key' } },
origin: signerOrigin,
})
Object.defineProperty(selected, 'source', { value: frame.contentWindow })
window.dispatchEvent(selected)
await expect(providerWindow.nostr!.getPublicKey()).resolves.toBe('selected-key')
postMessage.mockClear()
const next = providerWindow.nostr!.getPublicKey()
expect(postMessage).not.toHaveBeenCalledWith(
expect.objectContaining({ type: 'archipelago:signer-select-identity' }), expect.anything(),
)
const request = postMessage.mock.calls[0]![0] as { id: number }
const response = new MessageEvent('message', {
data: { type: 'nostr-response', id: request.id, result: 'selected-key' }, origin: signerOrigin,
})
Object.defineProperty(response, 'source', { value: frame.contentWindow })
window.dispatchEvent(response)
await expect(next).resolves.toBe('selected-key')
// Explicit account switching remains available after a successful login.
void providerWindow.archipelagoNostr!.selectIdentity()
expect(postMessage).toHaveBeenCalledWith(
expect.objectContaining({ type: 'archipelago:signer-select-identity', force: true }), signerOrigin,
)
})
it('does not mistake reload activation for a new login while restoring a session', () => {
sessionStorage.setItem('nostr_token', 'test-session-hint')
const { postMessage, signerOrigin } = loadProvider(true)
void providerWindow.nostr!.getPublicKey()
expect(postMessage).toHaveBeenCalledWith(
expect.objectContaining({ type: 'nostr-request', method: 'getPublicKey' }), signerOrigin,
)
expect(postMessage).not.toHaveBeenCalledWith(
expect.objectContaining({ type: 'archipelago:signer-select-identity' }), expect.anything(),
)
})
it('parks the hidden broker off-screen and reuses it for the next request', async () => {
const surface = {
expectPageTransition: vi.fn(),
@@ -1,12 +1,12 @@
<template>
<div v-if="visibleRequests.length > 0" class="glass-card p-6 mb-6">
<div id="connection-requests" v-if="visibleRequests.length > 0" class="glass-card p-6 mb-6">
<div class="flex items-center justify-between mb-4">
<div>
<h2 class="text-base font-semibold text-white">Pending Peer Requests</h2>
<h2 class="text-base font-semibold text-white">Nostr Connection Requests</h2>
<p class="text-xs text-white/60">
Inbound requests await your approval. Outbound requests show what you've sent.
Approved peers are added as <span class="text-orange-300">Observer</span> — promote
to Trusted manually if you want them to receive state syncs.
Connections become <span class="text-orange-300">Observer</span> peers.
Approval delivery retries automatically while the other node connects.
</p>
</div>
<button
@@ -80,6 +80,7 @@ const props = defineProps<{
requests: PendingPeerRequest[]
polling: boolean
busyId: string | null
peerDids?: string[]
}>()
defineEmits<{
@@ -96,6 +97,7 @@ const visibleRequests = computed(() => {
const cutoff = Date.now() - 24 * 60 * 60 * 1000
return props.requests.filter((r) => {
if (r.state === 'pending' || r.state === 'sent') return true
if (r.state === 'approved' && !props.peerDids?.includes(r.from_did)) return true
const ts = new Date(r.received_at).getTime()
return Number.isFinite(ts) && ts >= cutoff
})
@@ -104,14 +106,20 @@ const visibleRequests = computed(() => {
function badgeClass(r: PendingPeerRequest): string {
if (r.state === 'pending') return 'bg-yellow-500/20 text-yellow-300'
if (r.state === 'sent') return 'bg-blue-500/20 text-blue-300'
if (r.state === 'approved') return 'bg-green-500/20 text-green-300'
if (r.state === 'approved') return props.peerDids?.includes(r.from_did)
? 'bg-green-500/20 text-green-300' : 'bg-yellow-500/20 text-yellow-300'
if (r.state === 'rejected') return 'bg-red-500/20 text-red-300'
return 'bg-white/10 text-white/50'
}
function badgeLabel(r: PendingPeerRequest): string {
if (r.outbound && r.state === 'sent') return 'Sent'
if (r.outbound && r.state === 'approved') return 'They approved'
if (r.state === 'approved') {
if (props.peerDids?.includes(r.from_did)) return 'Peer added'
const age = Date.now() - new Date(r.received_at).getTime()
if (!Number.isFinite(age) || age > 30 * 24 * 60 * 60 * 1000) return 'Approval expired — reconnect'
return r.outbound ? 'They approved — connecting' : 'Approved — connecting'
}
if (r.outbound && r.state === 'rejected') return 'They rejected'
if (r.outbound) return r.state
if (r.state === 'pending') return 'Inbound'
@@ -126,7 +134,7 @@ function shortNpub(npub: string): string {
function relative(iso: string): string {
const ts = new Date(iso).getTime()
if (!Number.isFinite(ts)) return iso
const diff = Date.now() - ts
const diff = Math.max(0, Date.now() - ts)
const sec = Math.floor(diff / 1000)
if (sec < 60) return `${sec}s ago`
const min = Math.floor(sec / 60)
@@ -0,0 +1,25 @@
import { mount } from '@vue/test-utils'
import { describe, expect, it } from 'vitest'
import PendingRequestsPanel from '../PendingRequestsPanel.vue'
import type { PendingPeerRequest } from '@/api/rpc-client'
const request: PendingPeerRequest = {
id: 'request', from_did: 'did:key:peer', from_nostr_pubkey: 'key', from_nostr_npub: 'npub',
from_name: 'Other node', message: null, outbound: false, state: 'approved',
received_at: new Date(Date.now()-2*86400000).toISOString(),
}
describe('Nostr connection progress', () => {
it('keeps unresolved approvals visible and distinguishes local membership', async () => {
const wrapper=mount(PendingRequestsPanel,{props:{requests:[request],polling:false,busyId:null,peerDids:[]}})
expect(wrapper.text()).toContain('Approved — connecting')
expect(wrapper.text()).not.toContain('Peer added')
await wrapper.setProps({requests:[{...request,received_at:new Date().toISOString()}],peerDids:[request.from_did]})
expect(wrapper.text()).toContain('Peer added')
expect(wrapper.text()).not.toContain('Approved — connecting')
wrapper.unmount()
})
it('explains expired approval instead of claiming a connection is still progressing', () => {
const wrapper=mount(PendingRequestsPanel,{props:{requests:[{...request,received_at:new Date(Date.now()-31*86400000).toISOString()}],polling:false,busyId:null}})
expect(wrapper.text()).toContain('Approval expired — reconnect')
wrapper.unmount()
})
})
+12 -12
View File
@@ -17,7 +17,7 @@
<!-- Empty State -->
<div v-if="!nodes.length" class="text-white/40 text-sm py-8 text-center">
No nodes reporting. Ensure telemetry is enabled on beta nodes.
No fleet nodes yet. Connect a trusted node to see its status.
</div>
<div v-else class="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4">
@@ -32,7 +32,7 @@
<div class="flex items-center gap-2">
<span
class="fleet-status-dot"
:class="isOnline(node.reported_at) ? 'fleet-dot-online' : 'fleet-dot-offline'"
:class="fleetStatus(node.reported_at) === 'unknown' ? 'bg-white/30' : isOnline(node.reported_at) ? 'fleet-dot-online' : 'fleet-dot-offline'"
></span>
<span class="text-sm font-semibold text-white truncate">{{ fleetNodeDisplayName(node) }}</span>
</div>
@@ -49,10 +49,10 @@
<div
class="fleet-bar-fill"
:class="healthBarClass(node.cpu_pct)"
:style="{ width: Math.min(node.cpu_pct, 100) + '%' }"
:style="{ width: Math.min(node.cpu_pct ?? 0, 100) + '%' }"
></div>
</div>
<span class="text-xs text-white/60 w-10 text-right">{{ node.cpu_pct.toFixed(0) }}%</span>
<span class="text-xs text-white/60 w-10 text-right">{{ formatMetric(node.cpu_pct) }}</span>
</div>
<div class="fleet-metric-row">
<span class="text-xs text-white/50">RAM</span>
@@ -60,10 +60,10 @@
<div
class="fleet-bar-fill"
:class="healthBarClass(node.mem_pct)"
:style="{ width: Math.min(node.mem_pct, 100) + '%' }"
:style="{ width: Math.min(node.mem_pct ?? 0, 100) + '%' }"
></div>
</div>
<span class="text-xs text-white/60 w-10 text-right">{{ node.mem_pct.toFixed(0) }}%</span>
<span class="text-xs text-white/60 w-10 text-right">{{ formatMetric(node.mem_pct) }}</span>
</div>
<div class="fleet-metric-row">
<span class="text-xs text-white/50">Disk</span>
@@ -71,10 +71,10 @@
<div
class="fleet-bar-fill"
:class="healthBarClass(node.disk_pct)"
:style="{ width: Math.min(node.disk_pct, 100) + '%' }"
:style="{ width: Math.min(node.disk_pct ?? 0, 100) + '%' }"
></div>
</div>
<span class="text-xs text-white/60 w-10 text-right">{{ node.disk_pct.toFixed(0) }}%</span>
<span class="text-xs text-white/60 w-10 text-right">{{ formatMetric(node.disk_pct) }}</span>
</div>
</div>
@@ -82,9 +82,9 @@
<span>{{ node.running_count }}/{{ node.container_count }} containers</span>
<span>{{ node.federation_peers }} peers</span>
</div>
<div class="flex items-center justify-between text-xs text-white/40 mt-1">
<span>Up {{ formatUptime(node.uptime_secs) }}</span>
<span>{{ timeAgo(node.reported_at) }}</span>
<div class="flex flex-wrap gap-x-3 gap-y-1 items-center justify-between text-xs text-white/40 mt-1">
<span>{{ node.uptime_secs === null ? 'Uptime unavailable' : 'Up ' + formatUptime(node.uptime_secs) }}</span>
<span>{{ fleetStatus(node.reported_at) === 'unknown' ? 'Status unknown' : fleetStatus(node.reported_at) === 'stale' ? 'Not reporting · last seen ' + timeAgo(node.reported_at) : 'Seen ' + timeAgo(node.reported_at) }}</span>
</div>
</div>
</div>
@@ -94,7 +94,7 @@
<script setup lang="ts">
import {
type FleetNode, type SortOption, SORT_OPTIONS,
isOnline, healthBarClass, formatUptime, timeAgo, fleetNodeDisplayName, fleetNodeSubtitle,
isOnline, fleetStatus, formatMetric, healthBarClass, formatUptime, timeAgo, fleetNodeDisplayName, fleetNodeSubtitle,
} from './useFleetData'
defineProps<{
+14 -12
View File
@@ -5,43 +5,45 @@
<p class="text-2xl font-bold text-white">{{ nodeCount }}</p>
<p class="text-xs text-white/40">
<span class="fleet-dot-online"></span> {{ onlineCount }} online
<span class="ml-1 fleet-dot-offline"></span> {{ offlineCount }} offline
<span class="ml-1 fleet-dot-offline"></span> {{ offlineCount }} not reporting
<span v-if="unknownCount" class="ml-1">{{ unknownCount }} unknown</span>
</p>
</div>
<div data-controller-container tabindex="0" class="monitoring-stat-card">
<p class="text-xs text-white/50 uppercase tracking-wide">Fleet Health</p>
<p class="text-2xl font-bold text-white">{{ fleetHealthPct }}%</p>
<p class="text-xs text-white/40">{{ healthyCount }}/{{ nodeCount }} no alerts</p>
<p class="text-xs text-white/40">{{ healthyCount }}/{{ nodeCount }} reporting without alerts</p>
</div>
<div data-controller-container tabindex="0" class="monitoring-stat-card">
<p class="text-xs text-white/50 uppercase tracking-wide">Avg CPU</p>
<p class="text-2xl font-bold text-white" :class="healthTextClass(avgCpu)">{{ avgCpu.toFixed(1) }}%</p>
<p class="text-xs text-white/40">across fleet</p>
<p class="text-2xl font-bold text-white" :class="healthTextClass(avgCpu)">{{ formatMetric(avgCpu, 1) }}</p>
<p class="text-xs text-white/40">reporting online nodes</p>
</div>
<div data-controller-container tabindex="0" class="monitoring-stat-card">
<p class="text-xs text-white/50 uppercase tracking-wide">Avg RAM</p>
<p class="text-2xl font-bold text-white" :class="healthTextClass(avgMem)">{{ avgMem.toFixed(1) }}%</p>
<p class="text-xs text-white/40">across fleet</p>
<p class="text-2xl font-bold text-white" :class="healthTextClass(avgMem)">{{ formatMetric(avgMem, 1) }}</p>
<p class="text-xs text-white/40">reporting online nodes</p>
</div>
<div data-controller-container tabindex="0" class="monitoring-stat-card">
<p class="text-xs text-white/50 uppercase tracking-wide">Avg Disk</p>
<p class="text-2xl font-bold text-white" :class="healthTextClass(avgDisk)">{{ avgDisk.toFixed(1) }}%</p>
<p class="text-xs text-white/40">across fleet</p>
<p class="text-2xl font-bold text-white" :class="healthTextClass(avgDisk)">{{ formatMetric(avgDisk, 1) }}</p>
<p class="text-xs text-white/40">reporting online nodes</p>
</div>
</div>
</template>
<script setup lang="ts">
import { healthTextClass } from './useFleetData'
import { healthTextClass, formatMetric } from './useFleetData'
defineProps<{
nodeCount: number
onlineCount: number
offlineCount: number
unknownCount: number
fleetHealthPct: number
healthyCount: number
avgCpu: number
avgMem: number
avgDisk: number
avgCpu: number | null
avgMem: number | null
avgDisk: number | null
}>()
</script>
@@ -0,0 +1,39 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { mount } from '@vue/test-utils'
import FleetNodeGrid from '../FleetNodeGrid.vue'
import FleetOverviewCards from '../FleetOverviewCards.vue'
import { normalizeFleetNode } from '../useFleetData'
describe('fleet unavailable readings', () => {
afterEach(() => vi.useRealTimers())
it('shows an unavailable reading differently from a real zero and explains offline status', () => {
vi.useFakeTimers()
vi.setSystemTime(new Date('2026-06-10T12:00:00Z'))
const nodes = [normalizeFleetNode({node_id: 'offline', reported_at: '2026-06-08T12:00:00Z', cpu_pct: 0}), normalizeFleetNode({node_id: 'unknown'})]
const wrapper = mount(FleetNodeGrid, {
props: {nodes, sortedNodes: nodes, sortBy: 'status', selectedNodeId: null},
global: {mocks: {$ver: (v: string) => v}},
})
const cards = wrapper.findAll('.fleet-node-card')
expect(cards).toHaveLength(2)
expect(cards[0]!.text()).toContain('0%')
expect(cards[0]!.text()).toContain('—')
expect(cards[0]!.text()).toContain('Not reporting · last seen 2d ago')
expect(cards[1]!.text()).toContain('Status unknown')
expect(cards[1]!.text()).not.toContain('0%')
expect(cards[1]!.text()).toContain('Uptime unavailable')
expect(wrapper.text()).not.toContain('NaN')
wrapper.unmount()
})
it('renders empty averages without claiming zero load', () => {
const wrapper = mount(FleetOverviewCards, {props: {
nodeCount: 1, onlineCount: 0, offlineCount: 0, unknownCount: 1,
fleetHealthPct: 0, healthyCount: 0, avgCpu: null, avgMem: null, avgDisk: null,
}})
expect(wrapper.text()).toContain('1 unknown')
expect(wrapper.findAll('.monitoring-stat-card').slice(2).map(card => card.findAll('p').map(p => p.text()).join(' '))).toEqual([
'Avg CPU — reporting online nodes', 'Avg RAM — reporting online nodes', 'Avg Disk — reporting online nodes',
])
wrapper.unmount()
})
})
@@ -1,5 +1,9 @@
import { describe, expect, it, vi } from 'vitest'
import { afterEach, describe, expect, it, vi } from 'vitest'
import {
averageMetric,
fleetStatus,
formatMetric,
timeAgo,
fleetNodeDisplayName,
fleetNodeSubtitle,
isOnline,
@@ -31,6 +35,7 @@ function node(id: string, reportedAt: string): FleetNode {
}
describe('fleet data helpers', () => {
afterEach(() => vi.useRealTimers())
it('treats nodes reported within 30 minutes as online', () => {
vi.useFakeTimers()
vi.setSystemTime(new Date('2026-06-10T12:00:00Z'))
@@ -41,7 +46,7 @@ describe('fleet data helpers', () => {
vi.useRealTimers()
})
it('sorts status with online nodes first, then latest report', () => {
it('sorts status with online nodes first without shuffling within a status group', () => {
vi.useFakeTimers()
vi.setSystemTime(new Date('2026-06-10T12:00:00Z'))
const nodes = [
@@ -51,8 +56,8 @@ describe('fleet data helpers', () => {
]
expect(sortFleetNodes(nodes, 'status').map(n => n.node_id)).toEqual([
'online-new',
'online-old',
'online-new',
'offline',
])
@@ -79,9 +84,9 @@ describe('fleet data helpers', () => {
expect(normalized.node_name).toBeNull()
expect(normalized.hostname).toBeNull()
expect(normalized.server_url).toBeNull()
expect(normalized.cpu_pct).toBe(0)
expect(normalized.mem_pct).toBe(0)
expect(normalized.disk_pct).toBe(0)
expect(normalized.cpu_pct).toBeNull()
expect(normalized.mem_pct).toBeNull()
expect(normalized.disk_pct).toBeNull()
expect(normalized.containers).toEqual([])
expect(normalized.recent_alerts).toEqual([])
})
@@ -116,3 +121,47 @@ describe('fleet data helpers', () => {
expect(normalizeNodeHistoryResponse({})).toEqual([])
})
})
describe('fleet missing data and clock boundaries', () => {
afterEach(() => vi.useRealTimers())
it('does not turn malformed, missing or future timestamps into online status', () => {
vi.useFakeTimers()
vi.setSystemTime(new Date('2026-06-10T12:00:00Z'))
for (const value of ['', 'broken', '2027-01-01T00:00:00Z']) {
expect(fleetStatus(value)).toBe('unknown')
expect(isOnline(value)).toBe(false)
expect(timeAgo(value)).toBe('Unknown')
}
expect(fleetStatus('2026-06-10T11:30:00Z')).toBe('stale')
expect(fleetStatus('2026-06-10T12:00:30Z')).toBe('online')
expect(sortFleetNodes([node('unknown', ''), node('future', '2027-01-01T00:00:00Z'), node('old', '2026-06-10T10:00:00Z'), node('new', '2026-06-10T11:59:00Z')], 'status').map(n => n.node_id)).toEqual(['new', 'unknown', 'future', 'old'])
})
it('keeps valid zero separate from unavailable and invalid measurements', () => {
expect(normalizeFleetNode({cpu_pct: 0}).cpu_pct).toBe(0)
for (const value of [null, undefined, NaN, Infinity, -1, 101]) {
const result = normalizeFleetNode({cpu_pct: value, mem_pct: value, disk_pct: value})
expect(result.cpu_pct).toBeNull()
expect(result.mem_pct).toBeNull()
expect(result.disk_pct).toBeNull()
}
expect(formatMetric(null)).toBe('—')
expect(formatMetric(0)).toBe('0%')
expect(formatMetric(25.25, 1)).toBe('25.3%')
})
it('averages only real measurements from reporting online nodes', () => {
vi.useFakeTimers()
vi.setSystemTime(new Date('2026-06-10T12:00:00Z'))
const first = node('first', '2026-06-10T11:59:00Z')
first.cpu_pct = 0
const second = node('second', first.reported_at)
second.cpu_pct = 50
const missing = node('missing', first.reported_at)
missing.cpu_pct = null
const offline = node('offline', '2026-06-01T00:00:00Z')
offline.cpu_pct = 100
expect(averageMetric([first, second, missing, offline], 'cpu_pct')).toBe(25)
expect(averageMetric([missing, offline], 'cpu_pct')).toBeNull()
expect(averageMetric([], 'cpu_pct')).toBeNull()
})
})
+57 -36
View File
@@ -12,11 +12,11 @@ export interface FleetNode {
hostname?: string | null
server_url?: string | null
version: string
uptime_secs: number
uptime_secs: number | null
cpu_cores: number
cpu_pct: number
mem_pct: number
disk_pct: number
cpu_pct: number | null
mem_pct: number | null
disk_pct: number | null
container_count: number
running_count: number
federation_peers: number
@@ -43,7 +43,8 @@ export type SortOption = 'status' | 'last-seen' | 'name'
// --- Utility Functions ---
export function formatUptime(secs: number): string {
export function formatUptime(secs: number | null): string {
if (secs === null) return 'Unavailable'
if (secs < 60) return `${secs}s`
const days = Math.floor(secs / 86400)
const hours = Math.floor((secs % 86400) / 3600)
@@ -56,6 +57,7 @@ export function formatUptime(secs: number): string {
export function timeAgo(dateStr: string): string {
const now = Date.now()
const then = new Date(dateStr).getTime()
if (!Number.isFinite(then) || then > now + 60_000) return 'Unknown'
const diffMs = now - then
if (diffMs < 0) return 'just now'
const diffSecs = Math.floor(diffMs / 1000)
@@ -68,18 +70,34 @@ export function timeAgo(dateStr: string): string {
return `${diffDays}d ago`
}
export function isOnline(reportedAt: string): boolean {
const thirtyMinMs = 30 * 60 * 1000
return Date.now() - new Date(reportedAt).getTime() < thirtyMinMs
export function fleetStatus(reportedAt: string): 'online' | 'stale' | 'unknown' {
const age = Date.now() - new Date(reportedAt).getTime()
if (!Number.isFinite(age) || age < -60_000) return 'unknown'
return age < 30 * 60 * 1000 ? 'online' : 'stale'
}
export function healthBarClass(pct: number): string {
export function isOnline(reportedAt: string): boolean {
return fleetStatus(reportedAt) === 'online'
}
export function formatMetric(value: number | null, digits = 0): string {
return value === null ? '—' : `${value.toFixed(digits)}%`
}
export function averageMetric(nodes: FleetNode[], field: 'cpu_pct' | 'mem_pct' | 'disk_pct'): number | null {
const values = nodes.filter(n => isOnline(n.reported_at)).map(n => n[field]).filter((v): v is number => v !== null && Number.isFinite(v))
return values.length ? values.reduce((sum, value) => sum + value, 0) / values.length : null
}
export function healthBarClass(pct: number | null): string {
if (pct === null) return ''
if (pct >= 85) return 'monitoring-bar-danger'
if (pct >= 60) return 'monitoring-bar-warn'
return 'monitoring-bar-ok'
}
export function healthTextClass(pct: number): string {
export function healthTextClass(pct: number | null): string {
if (pct === null) return ''
if (pct >= 85) return 'fleet-text-danger'
if (pct >= 60) return 'fleet-text-warn'
return ''
@@ -132,19 +150,21 @@ export const SORT_OPTIONS: Array<{ label: string; value: SortOption }> = [
{ label: 'Name', value: 'name' },
]
function reportTime(node: FleetNode): number {
return fleetStatus(node.reported_at) === 'unknown' ? 0 : new Date(node.reported_at).getTime()
}
export function sortFleetNodes(nodes: FleetNode[], sortBy: SortOption): FleetNode[] {
const sorted = [...nodes]
switch (sortBy) {
case 'status':
sorted.sort((a, b) => {
const aOnline = isOnline(a.reported_at)
const bOnline = isOnline(b.reported_at)
if (aOnline !== bOnline) return aOnline ? -1 : 1
return new Date(b.reported_at).getTime() - new Date(a.reported_at).getTime()
const rank = { online: 0, unknown: 1, stale: 2 }
return rank[fleetStatus(a.reported_at)] - rank[fleetStatus(b.reported_at)]
})
break
case 'last-seen':
sorted.sort((a, b) => new Date(b.reported_at).getTime() - new Date(a.reported_at).getTime())
sorted.sort((a, b) => reportTime(b) - reportTime(a))
break
case 'name':
sorted.sort((a, b) => fleetNodeDisplayName(a).localeCompare(fleetNodeDisplayName(b)))
@@ -153,6 +173,15 @@ export function sortFleetNodes(nodes: FleetNode[], sortBy: SortOption): FleetNod
return sorted
}
function numberOrNull(value: unknown): number | null {
return typeof value === 'number' && Number.isFinite(value) && value >= 0 ? value : null
}
function percentageOrNull(value: unknown): number | null {
const number = numberOrNull(value)
return number !== null && number <= 100 ? number : null
}
function numberOrZero(value: unknown): number {
return typeof value === 'number' && Number.isFinite(value) ? value : 0
}
@@ -164,17 +193,17 @@ export function normalizeFleetNode(node: Partial<FleetNode>): FleetNode {
hostname: typeof node.hostname === 'string' ? node.hostname : null,
server_url: typeof node.server_url === 'string' ? node.server_url : null,
version: typeof node.version === 'string' ? node.version : 'unknown',
uptime_secs: numberOrZero(node.uptime_secs),
uptime_secs: numberOrNull(node.uptime_secs),
cpu_cores: numberOrZero(node.cpu_cores),
cpu_pct: numberOrZero(node.cpu_pct),
mem_pct: numberOrZero(node.mem_pct),
disk_pct: numberOrZero(node.disk_pct),
cpu_pct: percentageOrNull(node.cpu_pct),
mem_pct: percentageOrNull(node.mem_pct),
disk_pct: percentageOrNull(node.disk_pct),
container_count: numberOrZero(node.container_count),
running_count: numberOrZero(node.running_count),
federation_peers: numberOrZero(node.federation_peers),
recent_alerts: Array.isArray(node.recent_alerts) ? node.recent_alerts : [],
containers: Array.isArray(node.containers) ? node.containers : [],
reported_at: typeof node.reported_at === 'string' ? node.reported_at : new Date(0).toISOString(),
reported_at: typeof node.reported_at === 'string' ? node.reported_at : '',
}
}
@@ -195,7 +224,7 @@ type FleetCache = {
sortBy: SortOption
}
const FLEET_CACHE_KEY = 'archipelago.fleet.cache.v1'
const FLEET_CACHE_KEY = 'archipelago.fleet.cache.v2'
function readFleetCache(): Partial<FleetCache> {
if (typeof window === 'undefined') return {}
@@ -246,28 +275,20 @@ export function useFleetData() {
// --- Computed ---
const onlineCount = computed(() => nodes.value.filter(n => isOnline(n.reported_at)).length)
const offlineCount = computed(() => nodes.value.length - onlineCount.value)
const healthyCount = computed(() => nodes.value.filter(n => n.recent_alerts.length === 0).length)
const offlineCount = computed(() => nodes.value.filter(n => fleetStatus(n.reported_at) === 'stale').length)
const unknownCount = computed(() => nodes.value.filter(n => fleetStatus(n.reported_at) === 'unknown').length)
const healthyCount = computed(() => nodes.value.filter(n => isOnline(n.reported_at) && n.cpu_pct !== null && n.mem_pct !== null && n.disk_pct !== null && n.recent_alerts.length === 0).length)
const fleetHealthPct = computed(() => {
if (!nodes.value.length) return 0
return Math.round((healthyCount.value / nodes.value.length) * 100)
})
const avgCpu = computed(() => {
if (!nodes.value.length) return 0
return nodes.value.reduce((sum, n) => sum + n.cpu_pct, 0) / nodes.value.length
})
const avgCpu = computed(() => averageMetric(nodes.value, 'cpu_pct'))
const avgMem = computed(() => {
if (!nodes.value.length) return 0
return nodes.value.reduce((sum, n) => sum + n.mem_pct, 0) / nodes.value.length
})
const avgMem = computed(() => averageMetric(nodes.value, 'mem_pct'))
const avgDisk = computed(() => {
if (!nodes.value.length) return 0
return nodes.value.reduce((sum, n) => sum + n.disk_pct, 0) / nodes.value.length
})
const avgDisk = computed(() => averageMetric(nodes.value, 'disk_pct'))
const selectedNode = computed(() => {
if (!selectedNodeId.value) return null
@@ -525,7 +546,7 @@ export function useFleetData() {
loading, refreshing, errorMessage, nodes, fleetAlerts, alertsLoading,
selectedNodeId, selectedNode, nodeHistory, nodeHistoryLoading,
autoRefresh, lastRefreshed, sortBy, chartWidth,
onlineCount, offlineCount, healthyCount, fleetHealthPct,
onlineCount, offlineCount, unknownCount, healthyCount, fleetHealthPct,
avgCpu, avgMem, avgDisk, sortedNodes, allAppIds,
nodeHistoryLabels, nodeHistoryCpuDatasets, nodeHistoryMemDatasets, nodeHistoryDiskDatasets,
refreshAll, selectNode, toggleAutoRefresh, exportFleetData,
+2 -62
View File
@@ -12,8 +12,6 @@
:dhtDid="dhtDid"
:dhtDidCopied="dhtDidCopied"
:publishingDht="publishingDht"
:walletConnected="walletConnected"
:connectingWallet="connectingWallet"
:nostrRelayStats="nostrRelaysRef?.nostrRelayStats ?? null"
:connectedNodesCount="connectedNodesRef?.peers?.length ?? 0"
:detectedHwWallets="detectedHwWallets"
@@ -23,7 +21,6 @@
@copyDhtDid="copyDhtDid"
@refreshDhtDid="refreshDhtDid"
@publishDhtDid="publishDhtDid"
@connectWallet="connectWallet"
@manageRelays="nostrRelaysRef?.openRelaysModal()"
/>
@@ -90,7 +87,7 @@ let web5AnimationDone = false
</script>
<script setup lang="ts">
import { ref, computed, onActivated, onDeactivated, onMounted, onUnmounted } from 'vue'
import { ref, computed, onActivated, onMounted } from 'vue'
import { useI18n } from 'vue-i18n'
import { rpcClient } from '@/api/rpc-client'
import { useCachedResource } from '@/composables/useCachedResource'
@@ -288,41 +285,9 @@ async function copyDidDocument() {
setTimeout(() => { didDocCopied.value = false }, 2000)
}
// --- Wallet / LND Balances ---
// Cached: balances/transactions paint instantly on revisit and revalidate
// behind the cached value; errors keep the last-known data.
const lndInfoRes = useCachedResource<{
balance_sats: number
channel_balance_sats: number
synced_to_chain: boolean
}>({
key: 'web5.lnd-info',
fetcher: (signal) => rpcClient.call({ method: 'lnd.getinfo', signal, dedup: true, maxRetries: 1 }),
persist: false, // wallet balance — must never land in sessionStorage (T-02-01)
})
// connectWallet() can still "disconnect" the (hidden) wallet card UI-side.
const walletManuallyDisconnected = ref(false)
const walletConnected = computed(() =>
!walletManuallyDisconnected.value && lndInfoRes.data.value !== null && !lndInfoRes.error.value)
const connectingWallet = ref(false)
// Ecash/transaction/balance display lives in the hidden wallet card — when it
// returns, add cached resources for wallet.ecash-balance / lnd.gettransactions
// here rather than reviving the old eager loaders.
// Hardware wallets
const detectedHwWallets = ref<HwWalletDevice[]>([])
async function connectWallet() {
if (walletConnected.value) {
walletManuallyDisconnected.value = true
} else {
connectingWallet.value = true
walletManuallyDisconnected.value = false
await lndInfoRes.refresh()
connectingWallet.value = false
}
}
async function detectHardwareWallets() {
try {
const res = await rpcClient.detectUsbDevices()
@@ -332,10 +297,6 @@ async function detectHardwareWallets() {
}
}
// Auto-refresh wallet data every 30s while mounted (B5 will move this to
// WS-push invalidation; the store dedups overlapping refreshes).
let walletRefreshInterval: ReturnType<typeof setInterval> | null = null
// Every-entry: none of these child components use useCachedResource
// internally (confirmed by reading Web5ConnectedNodes.vue/Web5Identities.vue/
// Web5NodeVisibility.vue/Web5NostrRelays.vue — all are plain functions
@@ -353,20 +314,12 @@ function armWeb5Live() {
// credentialsRef.value?.loadCredentials() // hidden for now
// sharedContentRef.value?.loadContentItems() // hidden for now
// Wallet/profits resources fetch themselves on first use (and skip the
// Profits resources fetch themselves on first use (and skip the
// fetch entirely when the cached value is still fresh).
detectHardwareWallets()
// Shared content loaded by the component itself via expose
// The SharedContent component manages its own loadContentItems
// Only-while-visible: the 30s force-refresh interval bypasses
// useCachedResource's own TTL gate for lndInfoRes, so it must not keep
// running while Web5 is off screen.
if (walletRefreshInterval) clearInterval(walletRefreshInterval)
walletRefreshInterval = setInterval(() => {
void lndInfoRes.refresh()
}, 30000)
}
// Vue fires onActivated immediately after onMounted on a KeepAlive-wrapped
// component's first mount, which would otherwise double every load in
@@ -400,17 +353,4 @@ onMounted(() => {
armWeb5Live()
})
onDeactivated(() => {
if (walletRefreshInterval) {
clearInterval(walletRefreshInterval)
walletRefreshInterval = null
}
})
onUnmounted(() => {
if (walletRefreshInterval) {
clearInterval(walletRefreshInterval)
walletRefreshInterval = null
}
})
</script>
+114 -100
View File
@@ -25,7 +25,7 @@
</div>
<!-- Tabs: Trusted | Observers | Messages | Requests -->
<div class="flex gap-1 mb-4 border-b border-white/10">
<div class="flex gap-1 mb-4 border-b border-white/10 overflow-x-auto whitespace-nowrap shrink-0">
<button
@click="nodesContainerTab = 'trusted'"
class="px-4 py-2 text-sm font-medium rounded-t-lg transition-colors"
@@ -48,11 +48,12 @@
:class="nodesContainerTab === 'requests' ? 'bg-white/10 text-white' : 'text-white/60 hover:text-white/80 hover:bg-white/5'"
>
{{ t('web5.requests') }}
<span v-if="connectionRequests.length > 0" class="ml-1.5 text-xs text-orange-400">({{ connectionRequests.length }})</span>
<span v-if="connectionRequests.length > 0" class="w-2 h-2 rounded-full bg-orange-500 animate-pulse"></span>
<span v-if="requestCount > 0" class="ml-1.5 text-xs text-orange-400">({{ requestCount }})</span>
<span v-if="requestCount > 0" class="w-2 h-2 rounded-full bg-orange-500 animate-pulse"></span>
</button>
</div>
<p v-if="peersError && nodesContainerTab !== 'requests'" role="status" class="text-xs text-amber-200 mb-2">{{ peersError }}</p>
<!-- Trusted tab -->
<div v-show="nodesContainerTab === 'trusted'" class="space-y-2 min-h-0 overflow-y-auto max-h-72 xl:flex-1 xl:basis-0 xl:max-h-none">
<div v-if="loadingPeers && peers.length === 0" class="p-4 text-center text-white/60 text-sm">
@@ -65,16 +66,16 @@
{{ t('common.loading') }}
</div>
<div
v-for="p in peers"
v-for="p in sortedPeers"
:key="p.pubkey"
@click="router.push({ path: '/dashboard/server/federation', query: { node: p.did || p.pubkey || p.onion } })"
class="flex items-center justify-between p-3 bg-white/5 rounded-lg cursor-pointer hover:bg-white/10 transition-colors"
>
<div class="flex items-center gap-3 min-w-0">
<div class="w-2 h-2 rounded-full shrink-0" :class="peerReachable[p.onion] ? 'bg-green-400' : 'bg-amber-400'"></div>
<div class="w-2 h-2 rounded-full shrink-0" :class="statusClass(p)"></div>
<div class="min-w-0">
<p class="text-sm font-mono text-white/90 truncate">{{ p.name || p.onion || (p.pubkey || '').slice(0, 16) + '...' }}</p>
<p class="text-xs text-white/50 truncate">{{ p.onion }}</p>
<p class="text-xs text-white/50 truncate">{{ availabilityText(p) }} · {{ contactText(p) }}</p>
</div>
</div>
<button
@@ -98,16 +99,16 @@
{{ t('common.loading') }}
</div>
<div
v-for="p in observers"
v-for="p in sortedObservers"
:key="p.pubkey"
@click="router.push({ path: '/dashboard/server/federation', query: { node: p.did || p.pubkey || p.onion } })"
class="flex items-center justify-between p-3 bg-white/5 rounded-lg cursor-pointer hover:bg-white/10 transition-colors"
>
<div class="flex items-center gap-3 min-w-0">
<div class="w-2 h-2 rounded-full shrink-0" :class="peerReachable[p.onion] ? 'bg-green-400' : 'bg-amber-400'"></div>
<div class="w-2 h-2 rounded-full shrink-0" :class="statusClass(p)"></div>
<div class="min-w-0">
<p class="text-sm font-mono text-white/90 truncate">{{ p.name || p.onion || (p.pubkey || '').slice(0, 16) + '...' }}</p>
<p class="text-xs text-white/50 truncate">{{ p.onion }}</p>
<p class="text-xs text-white/50 truncate">{{ availabilityText(p) }} · {{ contactText(p) }}</p>
</div>
</div>
<span class="px-2 py-1 text-xs rounded bg-blue-500/20 text-blue-300 shrink-0">
@@ -118,10 +119,17 @@
<!-- Requests tab -->
<div v-show="nodesContainerTab === 'requests'" class="space-y-2 min-h-0 overflow-y-auto max-h-72 xl:flex-1 xl:basis-0 xl:max-h-none">
<div v-if="loadingRequests && connectionRequests.length === 0" class="p-4 text-center text-white/60 text-sm">
<p v-if="requestError" role="status" class="text-xs text-amber-200">{{ requestError }}</p>
<button v-for="request in nostrRequests" :key="'nostr:' + request.id"
class="w-full text-left p-3 bg-white/5 rounded-lg hover:bg-white/10 transition-colors"
@click="router.push({ path: '/dashboard/server/federation', hash: '#connection-requests' })">
<span class="block text-sm text-white truncate">{{ request.from_name || request.from_did || request.from_nostr_npub }}</span>
<span class="block text-xs text-white/60">Nostr · {{ request.state === 'approved' ? 'Approved — connecting' : request.outbound ? 'Sent — awaiting approval' : 'Incoming — review request' }}</span>
</button>
<div v-if="loadingRequests && requestCount === 0" class="p-4 text-center text-white/60 text-sm">
{{ t('common.loading') }}
</div>
<div v-else-if="connectionRequests.length === 0" class="p-4 text-center text-white/60 text-sm">
<div v-else-if="requestCount === 0" class="p-4 text-center text-white/60 text-sm">
{{ t('web5.noRequests') }}
</div>
<div v-else-if="loadingRequests" class="p-2 text-center text-white/45 text-xs">
@@ -231,13 +239,14 @@
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { rpcClient } from '@/api/rpc-client'
import { useMessageToast } from '@/composables/useMessageToast'
import { useWeb5BadgeStore } from '@/stores/web5Badge'
import { useAppStore } from '@/stores/app'
import type { PendingPeerRequest } from '@/api/rpc-client'
import { availabilityRank, lastContactLabel, nodeAvailability, validContact, type ReachabilitySample } from './nodeAvailability'
import { useModalKeyboard } from '@/composables/useModalKeyboard'
import { formatMessageTime } from './utils'
import type { Peer, ConnectionRequest } from './types'
@@ -246,13 +255,13 @@ const router = useRouter()
const { t } = useI18n()
const messageToast = useMessageToast()
const web5Badge = useWeb5BadgeStore()
const appStore = useAppStore()
const CONNECTED_NODES_CACHE_KEY = 'archipelago.web5.connected-nodes.v1'
const CONNECTED_NODES_CACHE_KEY = 'archipelago.web5.connected-nodes.v2'
type ConnectedNodesCache = {
peers: Peer[]
observers: Peer[]
peerReachable: Record<string, boolean>
peerReachable: Record<string, ReachabilitySample>
connectionRequests: ConnectionRequest[]
}
@@ -289,8 +298,27 @@ const cached = readConnectedNodesCache()
const peers = ref<Peer[]>(cached.peers ?? [])
const observers = ref<Peer[]>(cached.observers ?? [])
const loadingPeers = ref(false)
const peerReachableLocal = ref<Record<string, boolean>>(cached.peerReachable ?? {})
const peerReachable = computed(() => ({ ...appStore.peerHealth, ...peerReachableLocal.value }))
const peerReachableLocal = ref<Record<string, ReachabilitySample>>(cached.peerReachable ?? {})
const now = ref(Date.now())
let ageTimer: ReturnType<typeof setInterval> | undefined
let peerGeneration = 0
onMounted(() => { ageTimer = setInterval(() => { now.value = Date.now() }, 15_000) })
onUnmounted(() => { clearInterval(ageTimer); peerGeneration++ })
function availability(peer: Peer) { return nodeAvailability(peerReachableLocal.value[peer.onion], now.value) }
function availabilityText(peer: Peer) {
const status = availability(peer)
return status === 'online' ? 'Online' : status === 'unreachable' ? 'Unreachable' : 'Status unknown'
}
function contactText(peer: Peer) {
const local = validContact(peerReachableLocal.value[peer.onion]?.lastSeen, now.value) ?? 0
const recorded = validContact(peer.last_seen, now.value) ?? 0
return lastContactLabel(Math.max(local, recorded), now.value)
}
function statusClass(peer: Peer) {
return availability(peer) === 'online' ? 'bg-green-400' : availability(peer) === 'unreachable' ? 'bg-amber-400' : 'bg-white/30'
}
const sortedPeers = computed(() => [...peers.value].sort((a, b) => availabilityRank(availability(a)) - availabilityRank(availability(b))))
const sortedObservers = computed(() => [...observers.value].sort((a, b) => availabilityRank(availability(a)) - availabilityRank(availability(b))))
// Send message modal
const showSendMessageModal = ref(false)
@@ -309,6 +337,10 @@ const sendMessageSuccess = ref('')
// Connection requests
const connectionRequests = ref<ConnectionRequest[]>(cached.connectionRequests ?? [])
const nostrRequests = ref<PendingPeerRequest[]>([])
const requestCount = computed(() => connectionRequests.value.length + nostrRequests.value.length)
const requestError = ref('')
const peersError = ref('')
const loadingRequests = ref(false)
const processingRequestId = ref<string | null>(null)
@@ -329,13 +361,14 @@ function federationNodeToPeer(node: FederationNode): Peer {
onion: node.onion,
pubkey: node.pubkey,
did: node.did,
last_seen: node.last_seen,
name: node.name || `Federation: ${node.did?.slice(0, 16) || 'node'}`,
}
}
function switchToRequestsTab() {
nodesContainerTab.value = 'requests'
if (connectionRequests.value.length === 0 && !loadingRequests.value) {
if (!loadingRequests.value) {
loadConnectionRequests()
}
}
@@ -346,74 +379,58 @@ function refreshActiveTab() {
else void loadPeers()
}
function persistCache() {
writeConnectedNodesCache({ peers: peers.value, observers: observers.value, peerReachable: peerReachableLocal.value, connectionRequests: connectionRequests.value })
}
async function loadPeers() {
const hadPeers = peers.value.length > 0 || observers.value.length > 0
if (loadingPeers.value) return
const generation = ++peerGeneration
loadingPeers.value = true
try {
// Independent RPCs — fetched together (serialized they stacked two full
// mesh round-trips before anything rendered).
const [res, fedSettled] = await Promise.all([
rpcClient.listPeers(),
rpcClient.federationListNodes().catch(() => null),
])
const peerList = res.peers || []
const observerList: Peer[] = []
try {
const fedNodes = fedSettled?.nodes || []
for (const n of fedNodes) {
if (!n.onion || n.trust_level === 'untrusted') {
continue
}
if (n.trust_level === 'observer') {
if (!observerList.some(p => p.onion === n.onion || p.pubkey === n.pubkey)) {
observerList.push(federationNodeToPeer(n))
}
continue
}
if (!peerList.some(p => p.onion === n.onion || p.pubkey === n.pubkey)) {
peerList.push(federationNodeToPeer(n))
}
}
} catch {
// Federation may not be set up
peersError.value = ''
let direct: Peer[] | undefined
let federated: FederationNode[] | undefined
function applyLists() {
if (generation !== peerGeneration) return
const trusted = direct ? [...direct] : [...peers.value]
const observerList: Peer[] = federated ? [] : [...observers.value]
for (const node of federated ?? []) {
if (!node.onion || node.trust_level === 'untrusted') continue
const list = node.trust_level === 'observer' ? observerList : trusted
const peer = federationNodeToPeer(node)
const index = list.findIndex(p => p.did === node.did || p.onion === node.onion || (p.pubkey && p.pubkey === node.pubkey))
if (index < 0) list.push(peer)
else list[index] = { ...list[index], ...peer }
}
peers.value = peerList
peers.value = trusted
observers.value = observerList
// The list is ready — render it and re-enable Refresh NOW. The
// reachability dots fill in as probes resolve. Before 2026-07-22 the
// probes ran ONE AT A TIME with a 30s Tor timeout each, so Refresh sat
// disabled for N-offline-peers × 30s ("takes ages").
loadingPeers.value = false
void Promise.allSettled(
[...peers.value, ...observers.value].map(async (p) => {
try {
const check = await rpcClient.checkPeerReachable(p.onion)
peerReachableLocal.value[p.onion] = check.reachable
} catch {
peerReachableLocal.value[p.onion] = false
}
}),
).then(() => {
writeConnectedNodesCache({
peers: peers.value,
observers: observers.value,
peerReachable: peerReachableLocal.value,
connectionRequests: connectionRequests.value,
})
})
} catch (e) {
if (import.meta.env.DEV) console.error('Failed to load peers:', e)
if (!hadPeers) {
peers.value = []
observers.value = []
}
} finally {
loadingPeers.value = false
persistCache()
}
const results = await Promise.allSettled([
rpcClient.listPeers().then(result => { direct = result.peers || []; applyLists() }),
rpcClient.federationListNodes().then(result => { federated = result.nodes || []; applyLists() }),
])
if (generation !== peerGeneration) return
if (results.some(result => result.status === 'rejected')) peersError.value = 'Some nodes could not be refreshed. Showing available information.'
loadingPeers.value = false
// Bound concurrent probes, render each result immediately, and ignore replies
// from a superseded refresh. A failed RPC is unknown, not an offline node.
const pending = [...new Map([...peers.value, ...observers.value].map(peer => [peer.onion, peer])).values()]
async function probe() {
while (pending.length && generation === peerGeneration) {
const peer = pending.shift()!
let reachable: boolean | null = null
try { reachable = (await rpcClient.checkPeerReachable(peer.onion)).reachable } catch { /* unknown */ }
if (generation !== peerGeneration) return
now.value = Date.now()
peerReachableLocal.value[peer.onion] = {
reachable,
checkedAt: now.value,
lastSeen: reachable ? now.value : validContact(peerReachableLocal.value[peer.onion]?.lastSeen, now.value),
}
persistCache()
}
}
void Promise.all(Array.from({ length: Math.min(4, pending.length) }, probe))
}
async function sendMessage() {
@@ -437,23 +454,20 @@ async function sendMessage() {
}
async function loadConnectionRequests() {
const hadRequests = connectionRequests.value.length > 0
if (loadingRequests.value) return
loadingRequests.value = true
try {
const res = await rpcClient.call<{ requests: ConnectionRequest[] }>({ method: 'network.list-requests' })
connectionRequests.value = res.requests || []
web5Badge.pendingRequestCount = connectionRequests.value.length
writeConnectedNodesCache({
peers: peers.value,
observers: observers.value,
peerReachable: peerReachableLocal.value,
connectionRequests: connectionRequests.value,
})
} catch {
if (!hadRequests) connectionRequests.value = []
} finally {
loadingRequests.value = false
}
requestError.value = ''
const results = await Promise.allSettled([
rpcClient.call<{ requests: ConnectionRequest[] }>({ method: 'network.list-requests' }).then(res => { connectionRequests.value = res.requests || [] }),
rpcClient.federationListPendingRequests().then(res => {
nostrRequests.value = (res.requests || []).filter(request => request.state === 'pending' || request.state === 'sent' ||
(request.state === 'approved' && ![...peers.value, ...observers.value].some(peer => peer.did === request.from_did)))
}),
])
if (results.some(result => result.status === 'rejected')) requestError.value = 'Some requests could not be refreshed. Try again.'
web5Badge.pendingRequestCount = connectionRequests.value.length + nostrRequests.value.filter(request => request.state === 'pending' && !request.outbound).length
persistCache()
loadingRequests.value = false
}
async function acceptRequest(requestId: string) {
@@ -461,7 +475,7 @@ async function acceptRequest(requestId: string) {
try {
await rpcClient.call({ method: 'network.accept-request', params: { id: requestId } })
connectionRequests.value = connectionRequests.value.filter(r => r.id !== requestId)
web5Badge.pendingRequestCount = connectionRequests.value.length
web5Badge.pendingRequestCount = connectionRequests.value.length + nostrRequests.value.filter(request => request.state === 'pending' && !request.outbound).length
writeConnectedNodesCache({
peers: peers.value,
observers: observers.value,
@@ -482,7 +496,7 @@ async function rejectRequest(requestId: string) {
try {
await rpcClient.call({ method: 'network.reject-request', params: { id: requestId } })
connectionRequests.value = connectionRequests.value.filter(r => r.id !== requestId)
web5Badge.pendingRequestCount = connectionRequests.value.length
web5Badge.pendingRequestCount = connectionRequests.value.length + nostrRequests.value.filter(request => request.state === 'pending' && !request.outbound).length
writeConnectedNodesCache({
peers: peers.value,
observers: observers.value,
+2 -2
View File
@@ -15,7 +15,7 @@
</div>
<div class="web5-card-actions-top items-center gap-2">
<RouterLink to="/dashboard/server/federation" class="glass-button glass-button-sm px-3 rounded-lg text-sm font-medium inline-flex items-center gap-2 no-underline">
Find Nodes
Connect with Nodes
</RouterLink>
<RouterLink to="/dashboard/fleet" class="glass-button glass-button-sm px-3 rounded-lg text-sm font-medium inline-flex items-center gap-2 no-underline">
Fleet
@@ -74,7 +74,7 @@
<div class="web5-card-actions-bottom-grid mt-6 grid-cols-2 gap-3">
<RouterLink to="/dashboard/server/federation" class="mobile-card-action glass-button rounded-lg text-sm font-medium text-white/90 hover:text-white transition-colors no-underline">
Find Nodes
Connect with Nodes
</RouterLink>
<RouterLink to="/dashboard/fleet" class="mobile-card-action glass-button rounded-lg text-sm font-medium text-white/90 hover:text-white transition-colors no-underline">
Fleet
+1 -25
View File
@@ -1,7 +1,7 @@
<template>
<!-- Quick Actions Container -->
<div class="glass-card p-6 mb-6">
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 2xl:grid-cols-6 gap-4 stagger-grid">
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 2xl:grid-cols-5 gap-4 stagger-grid">
<!-- Networking Profits -->
<div data-controller-container tabindex="0" :class="{ 'card-stagger': showStagger }" class="flex flex-col gap-3 p-4 bg-white/5 rounded-lg min-w-0" style="--stagger-index: 0">
<div class="flex items-center gap-3 min-w-0">
@@ -113,27 +113,6 @@
</button>
</div>
<!-- Wallet Connection -->
<div data-controller-container tabindex="0" :class="{ 'card-stagger': showStagger }" class="flex flex-col gap-3 p-4 bg-white/5 rounded-lg min-w-0" style="--stagger-index: 2">
<div class="flex items-center gap-3 min-w-0">
<div class="relative shrink-0">
<div class="w-3 h-3 rounded-full" :class="walletConnected ? 'bg-green-400' : 'bg-red-400'"></div>
<div v-if="walletConnected" class="absolute inset-0 w-3 h-3 rounded-full bg-green-400 animate-ping opacity-75"></div>
</div>
<div class="min-w-0">
<p class="text-sm font-medium text-white">{{ t('web5.wallet') }}</p>
<p class="text-xs text-white/60">{{ walletConnected ? t('common.connected') : t('common.disconnected') }}</p>
</div>
</div>
<button
@click="$emit('connectWallet')"
class="w-full mt-auto px-3 py-1.5 glass-button glass-button-sm rounded text-xs font-medium text-white/90 hover:text-white transition-colors disabled:opacity-50"
:disabled="connectingWallet"
>
{{ connectingWallet ? t('common.connecting') : walletConnected ? t('common.disconnect') : t('common.connect') }}
</button>
</div>
<!-- Nostr Relay Status -->
<div data-controller-container tabindex="0" :class="{ 'card-stagger': showStagger }" class="flex flex-col gap-3 p-4 bg-white/5 rounded-lg min-w-0" style="--stagger-index: 3">
<div class="flex items-center gap-3 min-w-0">
@@ -225,8 +204,6 @@ defineProps<{
dhtDid: string | null
dhtDidCopied: boolean
publishingDht: boolean
walletConnected: boolean
connectingWallet: boolean
nostrRelayStats: NostrRelayStatsData | null
connectedNodesCount: number
detectedHwWallets: HwWalletDevice[]
@@ -239,7 +216,6 @@ defineEmits<{
copyDhtDid: []
refreshDhtDid: []
publishDhtDid: []
connectWallet: []
manageRelays: []
}>()
</script>
@@ -1,8 +1,10 @@
import { mount } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { mount, flushPromises, enableAutoUnmount } from '@vue/test-utils'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import Web5ConnectedNodes from '../Web5ConnectedNodes.vue'
import { rpcClient } from '@/api/rpc-client'
enableAutoUnmount(afterEach)
vi.mock('vue-router', () => ({
useRouter: () => ({ push: vi.fn() }),
}))
@@ -15,6 +17,7 @@ vi.mock('@/api/rpc-client', () => ({
rpcClient: {
listPeers: vi.fn(() => new Promise(() => {})),
federationListNodes: vi.fn().mockResolvedValue({ nodes: [] }),
federationListPendingRequests: vi.fn().mockResolvedValue({ requests: [] }),
checkPeerReachable: vi.fn().mockResolvedValue({ reachable: false }),
call: vi.fn(),
},
@@ -41,6 +44,7 @@ vi.mock('@/composables/useModalKeyboard', () => ({
describe('Web5ConnectedNodes', () => {
beforeEach(() => {
vi.clearAllMocks()
sessionStorage.clear()
})
it('shows a loading state for empty trusted nodes while peers are loading', async () => {
@@ -83,4 +87,43 @@ describe('Web5ConnectedNodes', () => {
expect(wrapper.text()).toContain('Please connect')
})
it('shows Nostr requests beside legacy requests without requiring discovery first', async () => {
vi.mocked(rpcClient.call).mockResolvedValue({ requests: [] })
vi.mocked(rpcClient.federationListPendingRequests).mockResolvedValueOnce({ requests: [{
id: 'nostr-1', from_did: 'did:key:yaya', from_name: 'Yaya', from_nostr_npub: 'npub1yaya',
from_nostr_pubkey: 'abc', message: null, received_at: new Date().toISOString(), state: 'approved', outbound: false,
}] })
const wrapper = mount(Web5ConnectedNodes)
await wrapper.vm.loadConnectionRequests()
expect(wrapper.text()).toContain('Yaya')
expect(wrapper.text()).toContain('Approved — connecting')
expect(wrapper.text()).not.toContain('web5.noRequests')
})
it('renders direct peers while federation is slow and distinguishes probe failure from unreachable', async () => {
vi.mocked(rpcClient.listPeers).mockResolvedValueOnce({ peers: [
{ onion: 'online.onion', pubkey: 'online', name: 'Available node' },
{ onion: 'failed.onion', pubkey: 'failed', name: 'Query failed' },
{ onion: 'offline.onion', pubkey: 'offline', name: 'No route' },
] })
let finish!: (value: { nodes: [] }) => void
vi.mocked(rpcClient.federationListNodes).mockReturnValueOnce(new Promise(resolve => { finish = resolve }))
vi.mocked(rpcClient.checkPeerReachable).mockImplementation(async onion => {
if (onion === 'failed.onion') throw new Error('unauthorized')
return { onion, reachable: onion === 'online.onion' }
})
const wrapper = mount(Web5ConnectedNodes)
const load = wrapper.vm.loadPeers()
await flushPromises()
expect(wrapper.text()).toContain('Available node')
expect(wrapper.text()).toContain('Status unknown')
finish({ nodes: [] })
await load
await flushPromises()
expect(wrapper.text()).toContain('Online · Last seen just now')
expect(wrapper.text()).toContain('Unreachable · No confirmed contact')
expect(wrapper.text()).toContain('Status unknown · No confirmed contact')
expect(wrapper.text()).not.toContain('Offline for')
})
})
@@ -1,5 +1,5 @@
import { mount } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { mount, enableAutoUnmount } from '@vue/test-utils'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import Web5ConnectedNodes from '../Web5ConnectedNodes.vue'
// Structural pin for UIFIX-02: the connected-nodes card's height must track
@@ -7,6 +7,8 @@ import Web5ConnectedNodes from '../Web5ConnectedNodes.vue'
// child), and the tab panes must scroll inside that height rather than
// growing to fit every row. See:
enableAutoUnmount(afterEach)
vi.mock('vue-router', () => ({
useRouter: () => ({ push: vi.fn() }),
}))
@@ -19,6 +21,7 @@ vi.mock('@/api/rpc-client', () => ({
rpcClient: {
listPeers: vi.fn(() => new Promise(() => {})),
federationListNodes: vi.fn().mockResolvedValue({ nodes: [] }),
federationListPendingRequests: vi.fn().mockResolvedValue({ requests: [] }),
checkPeerReachable: vi.fn().mockResolvedValue({ reachable: false }),
call: vi.fn(),
},
@@ -45,6 +48,7 @@ vi.mock('@/composables/useModalKeyboard', () => ({
describe('Web5ConnectedNodes scroll contract (UIFIX-02)', () => {
beforeEach(() => {
vi.clearAllMocks()
sessionStorage.clear()
})
it('gives all three tab panes the bounded, sibling-matched scroll contract', () => {
@@ -34,13 +34,13 @@ function mountFederation() {
}
describe('Web5Federation', () => {
it('surfaces Find Nodes and Fleet routes', () => {
it('surfaces Connect with Nodes and Fleet routes', () => {
const wrapper = mountFederation()
const links = wrapper.findAll('a').map(link => link.attributes('href'))
expect(links).toContain('/dashboard/server/federation')
expect(links).toContain('/dashboard/fleet')
expect(wrapper.text()).toContain('Find Nodes')
expect(wrapper.text()).toContain('Connect with Nodes')
expect(wrapper.text()).toContain('Fleet')
})
@@ -0,0 +1,24 @@
import { describe, expect, it } from 'vitest'
import { nodeAvailability, lastContactLabel, validContact, REACHABILITY_MAX_AGE, availabilityRank } from '../nodeAvailability'
describe('node availability observations', () => {
const now = Date.parse('2026-10-06T00:00:00Z')
it('ages cached observations and rejects malformed or future samples', () => {
expect(nodeAvailability({ reachable: true, checkedAt: now }, now)).toBe('online')
expect(nodeAvailability({ reachable: false, checkedAt: now }, now)).toBe('unreachable')
expect(nodeAvailability({ reachable: null, checkedAt: now }, now)).toBe('unknown')
for (const checkedAt of [NaN, now + 120000, now - REACHABILITY_MAX_AGE - 1]) {
expect(nodeAvailability({ reachable: true, checkedAt }, now)).toBe('unknown')
}
expect(nodeAvailability(undefined, now)).toBe('unknown')
})
it('reports last contact without inventing continuous offline duration', () => {
expect(lastContactLabel(now - 2 * 86400000, now)).toBe('Last seen 2d ago')
for (const value of [null, undefined, '', 'broken', 0, now + 120000]) {
expect(validContact(value, now)).toBeUndefined()
expect(lastContactLabel(value, now)).toBe('No confirmed contact')
}
expect(lastContactLabel(now + 30000, now)).toBe('Last seen just now')
expect(['unreachable', 'online', 'unknown'].sort((a, b) => availabilityRank(a as any) - availabilityRank(b as any))).toEqual(['online', 'unknown', 'unreachable'])
})
})
@@ -0,0 +1,25 @@
/** Reachability is an observation, not proof of a continuous outage. */
export interface ReachabilitySample { reachable: boolean | null; checkedAt: number; lastSeen?: number }
export type NodeAvailability = 'online' | 'unreachable' | 'unknown'
export const REACHABILITY_MAX_AGE = 90_000
export function validContact(value: unknown, now: number): number | undefined {
const timestamp = typeof value === 'number' ? value : typeof value === 'string' ? Date.parse(value) : NaN
return Number.isFinite(timestamp) && timestamp > 0 && timestamp <= now + 60_000 ? timestamp : undefined
}
export function nodeAvailability(sample: ReachabilitySample | undefined, now: number): NodeAvailability {
if (!sample || !Number.isFinite(sample.checkedAt) || now - sample.checkedAt > REACHABILITY_MAX_AGE || sample.checkedAt > now + 60_000) return 'unknown'
return sample.reachable === true ? 'online' : sample.reachable === false ? 'unreachable' : 'unknown'
}
export function lastContactLabel(value: unknown, now: number): string {
const timestamp = validContact(value, now)
if (!timestamp) return 'No confirmed contact'
const seconds = Math.max(0, Math.floor((now - timestamp) / 1000))
if (seconds < 60) return 'Last seen just now'
if (seconds < 3600) return `Last seen ${Math.floor(seconds / 60)}m ago`
if (seconds < 86400) return `Last seen ${Math.floor(seconds / 3600)}h ago`
return `Last seen ${Math.floor(seconds / 86400)}d ago`
}
export function availabilityRank(status: NodeAvailability): number {
return status === 'online' ? 0 : status === 'unknown' ? 1 : 2
}
+1 -1
View File
@@ -159,4 +159,4 @@ export interface DwnMessageEntry {
export type VisibilityLevel = 'hidden' | 'discoverable' | 'public'
export type Peer = { onion: string; pubkey: string; name?: string; did?: string }
export type Peer = { onion: string; pubkey: string; name?: string; did?: string; last_seen?: string }