archy/neode-ui/src/views/Federation.vue
archipelago b288be314c
Some checks failed
Demo images / Build & push demo images (push) Failing after 1m43s
feat(federation): presence-signing overlay on discovery enable
Enabling Nostr discoverability now opens a signer overlay (same visual
language as the app NIP-07 identity picker) before anything is published:
a locked signer row fixed to the node's discovery key — deliberately not
pickable, personal identities are never used — plus the exact signed
content (DID, npub, software version, kind 30078/NIP-33 format) and a
Sign & Publish confirm. Disabling stays immediate. Also seeds the mock
with discovery OFF to match the production default, and defaults
dwnSyncLabel to 'Unknown' when the backend omits sync_status.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-22 02:55:06 -04:00

709 lines
24 KiB
Vue

<template>
<div class="pb-6">
<FederationHeader
:self-did="selfDid"
:server-name="appStore.serverName"
@rotate="showRotateModal = true"
/>
<RotateDidModal
:visible="showRotateModal"
:rotating="rotatingDid"
:error="rotateError"
:success="rotateSuccess"
@close="showRotateModal = false; rotateError = ''; rotateSuccess = ''"
@rotate="rotateDid"
/>
<!-- View Tabs (same style as Home Dashboard/Setup tabs; full-width on mobile) -->
<div v-if="nodes.length > 0" role="tablist" class="mode-switcher mb-6 w-full md:w-auto">
<button
v-for="tab in viewTabs"
:key="tab.id"
class="mode-switcher-btn"
role="tab"
:aria-selected="activeView === tab.id"
:class="{ 'mode-switcher-btn-active': activeView === tab.id }"
@click="setView(tab.id)"
>
{{ tab.label }}
</button>
</div>
<!-- Network Map View -->
<div v-if="activeView === 'map' && nodes.length > 0" class="mb-6">
<NetworkMap :nodes="mapNodes" :links="mapLinks" />
</div>
<template v-if="activeView === 'list'">
<QuickActions
:generating-invite="generatingInvite"
:invite-type="inviteType"
:invite-code="inviteCode"
:syncing="syncing"
@generate-invite="handleGenerateInvite"
@show-join="showJoinModal = true"
@sync="syncAll"
@clear-invite="inviteCode = ''"
/>
<!-- Nostr discoverability strip: opt-in toggle + Discover button.
Renders inline so the Federation page is the single place a user
manages everything related to peering. The toggle directly mutates
the `nostr_discovery_enabled` config flag backend defaults to OFF
and nothing is published until the user explicitly turns it on. -->
<div class="glass-card p-4 mb-6">
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
<div class="min-w-0">
<div class="flex items-center gap-2">
<span class="text-sm font-medium text-white">Nostr discoverability</span>
<span
class="inline-block px-2 py-0.5 text-[10px] uppercase tracking-wide rounded"
:class="discoveryEnabled ? 'bg-green-500/20 text-green-300' : 'bg-white/10 text-white/50'"
>{{ discoveryEnabled ? 'On' : 'Off' }}</span>
</div>
<p class="text-xs text-white/60 mt-1">
When on, this node publishes a presence event (DID + npub only never an onion)
so other nodes can find you and request to peer. Inbound requests land in the
panel below for your approval. Off by default.
</p>
<button
class="text-xs text-white/50 hover:text-white/80 underline underline-offset-2 mt-1"
@click="showSigningInfo = !showSigningInfo"
>
{{ showSigningInfo ? 'Hide signing details' : 'Signing details' }}
</button>
</div>
<div class="flex items-center gap-2 shrink-0">
<button
class="px-3 py-1.5 glass-button glass-button-sm rounded text-xs text-white/90 hover:text-white disabled:opacity-50"
:disabled="discoveryToggling"
@click="toggleDiscovery"
>
{{ discoveryToggling ? '…' : (discoveryEnabled ? 'Disable' : 'Enable') }}
</button>
<button
class="px-3 py-1.5 glass-button glass-button-sm rounded text-xs text-white/90 hover:text-white disabled:opacity-50"
:disabled="!discoveryEnabled"
@click="showDiscoverModal = true"
>
Discover Nodes
</button>
</div>
</div>
<!-- Signing details: shows WHO signs the presence event (always the
node's own discovery key — deliberately not pickable, so a user's
personal Web5/Nostr identities can never be burned for node
discovery) and a human-readable view of WHAT gets signed. -->
<div v-if="showSigningInfo" class="mt-4 pt-4 border-t border-white/10 space-y-3">
<div>
<label class="block text-[10px] uppercase tracking-wide text-white/40 mb-1">Signer</label>
<!-- Same row style as the NostrIdentityPicker overlay, but locked:
node discovery always signs with the node's own key, never a
personal identity, so there is nothing to pick. -->
<div class="w-full sm:max-w-md p-3 rounded-lg bg-white/[0.03] ring-1 ring-white/10">
<div class="flex items-center gap-3">
<div class="w-9 h-9 rounded-lg flex items-center justify-center shrink-0 bg-white/10 text-white/80">
<span class="text-sm font-bold">{{ (appStore.serverName || 'N').charAt(0).toUpperCase() }}</span>
</div>
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2">
<span class="text-white font-semibold text-sm truncate">{{ appStore.serverName || 'This node' }}</span>
<span class="text-[10px] px-1.5 py-0.5 rounded bg-white/10 text-white/60">node identity</span>
</div>
<div class="mt-0.5">
<span class="text-white/35 text-xs font-mono truncate">{{ nodeNpub ? truncateNpub(nodeNpub) : 'loading' }}</span>
</div>
</div>
<svg class="w-4 h-4 text-white/30 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2" aria-label="Locked">
<path stroke-linecap="round" stroke-linejoin="round" d="M16.5 10.5V6.75a4.5 4.5 0 10-9 0v3.75m-.75 11.25h10.5a2.25 2.25 0 002.25-2.25v-6.75a2.25 2.25 0 00-2.25-2.25H6.75a2.25 2.25 0 00-2.25 2.25v6.75a2.25 2.25 0 002.25 2.25z" />
</svg>
</div>
</div>
<p class="text-[11px] text-white/40 mt-1">
Presence events are always signed with this node's dedicated discovery key.
Your personal identities are never used for node discovery, so unlike the app
sign-in overlay there is nothing to choose here.
</p>
</div>
<div>
<label class="block text-[10px] uppercase tracking-wide text-white/40 mb-1">What gets signed &amp; published</label>
<dl class="text-xs space-y-1.5">
<div class="flex flex-col sm:flex-row sm:gap-2">
<dt class="text-white/50 shrink-0 sm:w-28">Identity (DID)</dt>
<dd class="text-white/80 font-mono break-all">{{ selfDid || '—' }}</dd>
</div>
<div class="flex flex-col sm:flex-row sm:gap-2">
<dt class="text-white/50 shrink-0 sm:w-28">Signing key</dt>
<dd class="text-white/80 font-mono break-all">{{ nodeNpub || '—' }}</dd>
</div>
<div class="flex flex-col sm:flex-row sm:gap-2">
<dt class="text-white/50 shrink-0 sm:w-28">Software version</dt>
<dd class="text-white/80">{{ appVersion || '—' }}</dd>
</div>
<div class="flex flex-col sm:flex-row sm:gap-2">
<dt class="text-white/50 shrink-0 sm:w-28">Event format</dt>
<dd class="text-white/80">Nostr kind 30078, replaceable (NIP-33), tag <span class="font-mono">archipelago-node</span></dd>
</div>
</dl>
<p class="text-[11px] text-white/40 mt-2">
Every presence event carries a Schnorr signature (NIP-01) made with the key
above relays reject unsigned events, so anything you see via discovery was
cryptographically signed by its node. Your onion address is never part of this
event; it is only shared over encrypted DMs (NIP-44) after you approve a peer.
</p>
</div>
</div>
</div>
<div v-if="discoveryError" class="mb-4 text-xs text-red-400">{{ discoveryError }}</div>
<PendingRequestsPanel
:requests="pendingRequests"
:polling="pollingHandshake"
:busy-id="pendingBusyId"
@poll="pollHandshake"
@approve="approvePending"
@reject="rejectPending"
@cancel="cancelPending"
/>
<NodeList
:nodes="nodes"
:loading="loading"
:error="error"
:sync-results="syncResults"
:dwn-sync-dot-class="dwnSyncDotClass"
:cleaning-nodes="cleaningNodes"
@select-node="selectedNode = $event"
@clear-sync-results="syncResults = []"
@cleanup-dead="cleanupDeadNodes"
/>
</template>
<NodeDetailModal
:node="selectedNode"
:dwn-sync-dot-class="dwnSyncDotClass"
:dwn-sync-label="dwnSyncLabel"
:dwn-message-count="String(dwnStatus?.message_count ?? '--')"
:dwn-last-sync="dwnStatus?.last_sync ? timeAgo(dwnStatus.last_sync) : 'never'"
:dwn-syncing="dwnSyncing"
:deploying="deploying"
:deploy-result="deployResult"
@close="selectedNode = null"
@change-trust="changeTrust"
@remove-node="removeNode"
@deploy-app="deployApp"
@dwn-sync="triggerDwnSync"
/>
<JoinModal
:visible="showJoinModal"
:joining="joining"
:error="joinError"
:success="joinSuccess"
@close="showJoinModal = false"
@join="joinFederation"
/>
<DiscoverModal
:visible="showDiscoverModal"
:outbound-sent="pendingRequests"
@close="showDiscoverModal = false"
@sent="loadPendingRequests"
/>
<PresenceSignModal
:show="showPresenceSignModal"
:server-name="appStore.serverName"
:npub="nodeNpub"
:did="selfDid"
:version="appVersion"
:busy="discoveryToggling"
@confirm="confirmPresenceSign"
@cancel="showPresenceSignModal = false"
/>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { rpcClient } from '@/api/rpc-client'
import { useTransportStore } from '@/stores/transport'
import { useAppStore } from '@/stores/app'
import { useSyncStore } from '@/stores/sync'
import NetworkMap from '@/components/federation/NetworkMap.vue'
import FederationHeader from './federation/FederationHeader.vue'
import RotateDidModal from './federation/RotateDidModal.vue'
import QuickActions from './federation/QuickActions.vue'
import NodeList from './federation/NodeList.vue'
import NodeDetailModal from './federation/NodeDetailModal.vue'
import JoinModal from './federation/JoinModal.vue'
import PendingRequestsPanel from './federation/PendingRequestsPanel.vue'
import DiscoverModal from './federation/DiscoverModal.vue'
import PresenceSignModal from './federation/PresenceSignModal.vue'
import type { FederatedNode, DwnStatus, SyncResult } from './federation/types'
import type { PendingPeerRequest } from '@/api/rpc-client'
import { nodeName, timeAgo } from './federation/utils'
const transportStore = useTransportStore()
const appStore = useAppStore()
const syncStore = useSyncStore()
const nodes = ref<FederatedNode[]>([])
const loading = ref(true)
const error = ref('')
const selectedNode = ref<FederatedNode | null>(null)
const inviteType = ref<'trusted' | 'observer'>('trusted')
const inviteCode = ref('')
const generatingInvite = ref(false)
const showJoinModal = ref(false)
const joining = ref(false)
const joinError = ref('')
const joinSuccess = ref(false)
const syncing = ref(false)
const syncResults = ref<SyncResult[]>([])
const deploying = ref(false)
const deployResult = ref('')
const viewTabs = [
{ id: 'list', label: 'List View' },
{ id: 'map', label: 'Network Map' },
] as const
type ViewId = typeof viewTabs[number]['id']
const activeView = ref<ViewId>(
(localStorage.getItem('federation-view') as ViewId) || (nodes.value.length >= 3 ? 'map' : 'list')
)
function setView(id: ViewId) {
activeView.value = id
localStorage.setItem('federation-view', id)
}
const selfDid = ref('')
const mapNodes = computed(() => {
const result = []
if (selfDid.value) {
result.push({
did: selfDid.value,
label: appStore.serverName,
trust_level: 'trusted' as const,
online: true,
app_count: 0,
is_self: true,
})
}
for (const node of nodes.value) {
result.push({
did: node.did,
label: nodeName(node),
trust_level: node.trust_level as 'trusted' | 'observer' | 'untrusted',
online: isOnlineCheck(node),
app_count: node.last_state?.apps?.length ?? 0,
is_self: false,
})
}
return result
})
const mapLinks = computed(() => {
if (!selfDid.value) return []
return nodes.value.map(n => ({
source: selfDid.value,
target: n.did,
}))
})
const dwnStatus = ref<DwnStatus | null>(null)
const dwnSyncing = ref(false)
const dwnSyncDotClass = computed(() => {
if (!dwnStatus.value) return 'bg-white/30'
switch (dwnStatus.value.sync_status) {
case 'synced': return 'bg-green-400'
case 'syncing': return 'bg-yellow-400 animate-pulse'
case 'error': return 'bg-red-400'
default: return 'bg-white/30'
}
})
const dwnSyncLabel = computed(() => {
if (!dwnStatus.value) return 'Unknown'
switch (dwnStatus.value.sync_status) {
case 'synced': return 'Synced'
case 'syncing': return 'Syncing...'
case 'error': return 'Error'
default: return dwnStatus.value.sync_status || 'Unknown'
}
})
// DID rotation
const showRotateModal = ref(false)
const rotatingDid = ref(false)
const rotateError = ref('')
const rotateSuccess = ref('')
// Dead node cleanup
const cleaningNodes = ref(false)
// Nostr discoverability + pending peer requests
const discoveryEnabled = ref(false)
const discoveryToggling = ref(false)
const discoveryError = ref('')
const showDiscoverModal = ref(false)
// Signing-details disclosure: who signs the presence event (always the node's
// own discovery key) and a human-readable view of the signed content.
const showSigningInfo = ref(false)
const showPresenceSignModal = ref(false)
const nodeNpub = ref('')
const appVersion = computed(() => syncStore.serverInfo?.version || '')
function truncateNpub(npub: string): string {
if (npub.length <= 20) return npub
return npub.slice(0, 12) + '...' + npub.slice(-6)
}
async function loadNodeNpub() {
try {
const res = await rpcClient.call<{ nostr_npub?: string }>({ method: 'node.nostr-pubkey' })
if (res?.nostr_npub) nodeNpub.value = res.nostr_npub
} catch {
// Nostr key not provisioned yet — panel shows a placeholder
}
}
const pendingRequests = ref<PendingPeerRequest[]>([])
const pollingHandshake = ref(false)
const pendingBusyId = ref<string | null>(null)
async function loadDiscoveryState() {
try {
const result = await rpcClient.nostrDiscoveryStatus()
discoveryEnabled.value = !!result.enabled
} catch {
discoveryEnabled.value = false
}
}
async function toggleDiscovery() {
if (!discoveryEnabled.value) {
// Enabling publishes a signed presence event — surface the signer overlay
// first so the user sees exactly what gets signed and with which key.
// Nothing is published until they confirm.
if (!nodeNpub.value) loadNodeNpub()
showPresenceSignModal.value = true
return
}
// Disabling publishes nothing new — apply immediately.
discoveryToggling.value = true
discoveryError.value = ''
try {
const result = await rpcClient.nostrSetDiscovery(false)
discoveryEnabled.value = !!result.enabled
} catch (e: unknown) {
discoveryError.value = e instanceof Error ? e.message : 'Failed to toggle discoverability'
} finally {
discoveryToggling.value = false
}
}
async function confirmPresenceSign() {
discoveryToggling.value = true
discoveryError.value = ''
try {
const result = await rpcClient.nostrSetDiscovery(true)
discoveryEnabled.value = !!result.enabled
showPresenceSignModal.value = false
} catch (e: unknown) {
discoveryError.value = e instanceof Error ? e.message : 'Failed to enable discoverability'
} finally {
discoveryToggling.value = false
}
}
async function loadPendingRequests() {
try {
const result = await rpcClient.federationListPendingRequests()
pendingRequests.value = result.requests
} catch (e: unknown) {
discoveryError.value = e instanceof Error ? e.message : 'Failed to load pending requests'
}
}
async function pollHandshake() {
pollingHandshake.value = true
discoveryError.value = ''
try {
await rpcClient.handshakePoll()
await loadPendingRequests()
// If a poll applied a PeerInvite, the federation node list also changed.
await loadNodes()
} catch (e: unknown) {
discoveryError.value = e instanceof Error ? e.message : 'Poll failed'
} finally {
pollingHandshake.value = false
}
}
async function approvePending(id: string) {
pendingBusyId.value = id
discoveryError.value = ''
try {
await rpcClient.federationApproveRequest(id)
await loadPendingRequests()
await loadNodes()
} catch (e: unknown) {
discoveryError.value = e instanceof Error ? e.message : 'Approve failed'
} finally {
pendingBusyId.value = null
}
}
async function rejectPending(id: string) {
pendingBusyId.value = id
discoveryError.value = ''
try {
await rpcClient.federationRejectRequest(id)
await loadPendingRequests()
} catch (e: unknown) {
discoveryError.value = e instanceof Error ? e.message : 'Reject failed'
} finally {
pendingBusyId.value = null
}
}
async function cancelPending(id: string) {
pendingBusyId.value = id
discoveryError.value = ''
try {
// Default notify=true from the rpc-client — the peer's inbound row
// disappears from their UI so they don't have to wonder about a
// stale handshake.
await rpcClient.federationCancelRequest(id)
await loadPendingRequests()
} catch (e: unknown) {
discoveryError.value = e instanceof Error ? e.message : 'Cancel failed'
} finally {
pendingBusyId.value = null
}
}
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
}
async function loadNodes() {
return loadNodesWithOptions()
}
async function loadNodesWithOptions(options: { showLoader?: boolean; surfaceErrors?: boolean } = {}) {
const showLoader = options.showLoader ?? nodes.value.length === 0
const surfaceErrors = options.surfaceErrors ?? true
try {
if (showLoader) loading.value = true
const result = await rpcClient.federationListNodes()
nodes.value = result.nodes
error.value = ''
} catch (e) {
if (surfaceErrors) {
error.value = e instanceof Error ? e.message : 'Failed to load nodes'
}
} finally {
if (showLoader) loading.value = false
}
}
function handleGenerateInvite(type: 'trusted' | 'observer') {
inviteType.value = type
generateInvite()
}
async function generateInvite() {
try {
generatingInvite.value = true
error.value = ''
// The invite type is not cosmetic: it sets the trust level the invite
// grants both sides ("Invite a Peer" = observer, "Link Your Nodes" = trusted)
const result = await rpcClient.federationInvite(inviteType.value)
inviteCode.value = result.code
} catch (e) {
error.value = e instanceof Error ? e.message : 'Failed to generate invite'
} finally {
generatingInvite.value = false
}
}
async function joinFederation(code: string) {
try {
joining.value = true
joinError.value = ''
joinSuccess.value = false
await rpcClient.federationJoin(code)
joinSuccess.value = true
await loadNodes()
setTimeout(() => { showJoinModal.value = false; joinSuccess.value = false }, 1500)
} catch (e) {
joinError.value = e instanceof Error ? e.message : 'Failed to join'
} finally {
joining.value = false
}
}
async function syncAll() {
try {
syncing.value = true
error.value = ''
syncResults.value = []
const result = await rpcClient.call<{
synced: number; failed: number;
results: SyncResult[]
}>({ method: 'federation.sync-state', timeout: 180000 })
syncResults.value = result.results
await loadNodes()
} catch (e) {
error.value = e instanceof Error ? e.message : 'Sync failed — some peers may be unreachable over Tor'
} finally {
syncing.value = false
}
}
async function changeTrust(did: string, level: string) {
try {
await rpcClient.federationSetTrust(did, level as 'trusted' | 'observer' | 'untrusted')
await loadNodes()
if (selectedNode.value?.did === did) {
selectedNode.value = nodes.value.find(n => n.did === did) ?? null
}
} catch (e) {
error.value = e instanceof Error ? e.message : 'Failed to update trust level'
}
}
async function removeNode(did: string) {
try {
await rpcClient.federationRemoveNode(did)
selectedNode.value = null
await loadNodes()
} catch (e) {
error.value = e instanceof Error ? e.message : 'Failed to remove node'
}
}
async function deployApp(did: string, appId: string) {
try {
deploying.value = true
deployResult.value = ''
await rpcClient.federationDeployApp({ did, appId })
deployResult.value = `Successfully deployed ${appId} to remote node`
} catch (e) {
deployResult.value = `Error: ${e instanceof Error ? e.message : 'Deploy failed'}`
} finally {
deploying.value = false
}
}
async function loadDwnStatus() {
try {
const result = await rpcClient.call<DwnStatus>({ method: 'dwn.status' })
dwnStatus.value = result
} catch {
dwnStatus.value = null
}
}
async function triggerDwnSync() {
try {
dwnSyncing.value = true
await rpcClient.call({ method: 'dwn.sync', timeout: 120000 })
await loadDwnStatus()
} catch {
// Silently handle sync errors
} finally {
dwnSyncing.value = false
}
}
async function cleanupDeadNodes() {
cleaningNodes.value = true
try {
const deadNodes = nodes.value.filter(n => !isOnlineCheck(n) && (!n.last_seen || n.last_seen === 'never'))
for (const node of deadNodes) {
await rpcClient.federationRemoveNode(node.did)
}
await loadNodes()
} catch (e) {
error.value = e instanceof Error ? e.message : 'Cleanup failed'
} finally {
cleaningNodes.value = false
}
}
async function rotateDid(password: string) {
if (!password) return
rotatingDid.value = true
rotateError.value = ''
rotateSuccess.value = ''
try {
const result = await rpcClient.call<{
old_did: string; new_did: string; proof_signature: string; proof_message: string
}>({ method: 'node.rotate-did', params: { password } })
selfDid.value = result.new_did
try { localStorage.setItem('neode_did', result.new_did) } catch { /* noop */ }
rotateSuccess.value = `DID rotated. Notifying peers...`
const notify = await rpcClient.call<{ notified: number; failed: number }>({
method: 'federation.notify-did-change',
params: {
old_did: result.old_did,
new_did: result.new_did,
proof_signature: result.proof_signature,
proof_message: result.proof_message,
},
timeout: 120000,
})
rotateSuccess.value = `DID rotated successfully. ${notify.notified} peers notified${notify.failed > 0 ? `, ${notify.failed} failed` : ''}.`
} catch (err: unknown) {
rotateError.value = err instanceof Error ? err.message : 'Rotation failed'
} finally {
rotatingDid.value = false
}
}
let autoRefreshTimer: ReturnType<typeof setInterval> | null = null
onMounted(async () => {
loadNodesWithOptions({ showLoader: true })
loadDwnStatus()
loadDiscoveryState()
loadPendingRequests()
loadNodeNpub()
transportStore.fetchPeers()
try {
const result = await rpcClient.getNodeDid()
selfDid.value = result.did
} catch {
// Self DID not available
}
autoRefreshTimer = setInterval(() => {
loadNodesWithOptions({ showLoader: false, surfaceErrors: false })
loadPendingRequests()
}, 5000)
})
onUnmounted(() => {
if (autoRefreshTimer) {
clearInterval(autoRefreshTimer)
autoRefreshTimer = null
}
})
</script>