feat(federation): presence-signing overlay on discovery enable
Some checks failed
Demo images / Build & push demo images (push) Failing after 1m43s

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>
This commit is contained in:
archipelago 2026-07-22 02:55:06 -04:00
parent 72fcf96016
commit b288be314c
3 changed files with 285 additions and 4 deletions

View File

@ -228,7 +228,9 @@ function seedMockState() {
return {
analyticsEnabled: false,
nodeVisibility: 'discoverable',
nostrDiscovery: true,
// Discovery starts OFF, matching the production default — enabling it in
// the UI walks through the presence-signing overlay.
nostrDiscovery: false,
pendingPeerRequests: [
{
id: 'preq-demo-1',

View File

@ -212,6 +212,17 @@
@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>
@ -230,6 +241,7 @@ 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'
@ -327,7 +339,7 @@ const dwnSyncLabel = computed(() => {
case 'synced': return 'Synced'
case 'syncing': return 'Syncing...'
case 'error': return 'Error'
default: return dwnStatus.value.sync_status
default: return dwnStatus.value.sync_status || 'Unknown'
}
})
@ -348,6 +360,7 @@ 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 || '')
@ -378,11 +391,19 @@ async function loadDiscoveryState() {
}
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 = ''
const next = !discoveryEnabled.value
try {
const result = await rpcClient.nostrSetDiscovery(next)
const result = await rpcClient.nostrSetDiscovery(false)
discoveryEnabled.value = !!result.enabled
} catch (e: unknown) {
discoveryError.value = e instanceof Error ? e.message : 'Failed to toggle discoverability'
@ -391,6 +412,20 @@ async function toggleDiscovery() {
}
}
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()

View File

@ -0,0 +1,244 @@
<template>
<Teleport to="body">
<Transition name="presence-sign">
<div
v-if="show"
class="fixed inset-0 z-[3100] flex items-center justify-center p-4"
@click="$emit('cancel')"
>
<!-- Backdrop frosted blur, same treatment as the app signer overlay -->
<div class="absolute inset-0 bg-black/40 backdrop-blur-2xl"></div>
<div
ref="modalRef"
@click.stop
role="dialog"
aria-modal="true"
aria-label="Sign and publish node presence"
class="relative z-10 w-full max-w-lg"
>
<!-- Header: glass disc + radial viz ring, mirroring NostrIdentityPicker -->
<div class="relative mb-6 flex flex-col items-center">
<div class="presence-hero">
<div class="presence-viz-ring">
<div
v-for="(_, i) in 48"
:key="i"
class="presence-viz-segment"
:style="{ '--seg-i': i, '--seg-deg': `${(i / 48) * 360}deg` }"
/>
</div>
<div class="presence-glass-border">
<div class="presence-glass-inner">
<svg class="w-9 h-9 text-white/90" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
<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>
</div>
<h2 class="mt-5 text-lg font-semibold text-white">Sign &amp; Publish Presence</h2>
<p class="mt-1 text-white/25 tracking-widest uppercase" style="font-size: 10px;">Nostr node discovery</p>
</div>
<div class="glass-card p-4 space-y-4">
<!-- Signer: single locked row, same look as the identity picker rows.
Node discovery always signs with the node's own key never a
personal identity so there is deliberately nothing to pick. -->
<div>
<div class="text-[10px] uppercase tracking-wide text-white/40 mb-1.5">Signer</div>
<div class="w-full p-3 rounded-lg bg-white/10 ring-1 ring-white/20">
<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">{{ (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">{{ 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">{{ npub ? truncateNpub(npub) : '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.5">
Presence events are always signed with this node's dedicated discovery key
your personal identities are never used, so there is nothing to choose.
</p>
</div>
<!-- Human-readable view of the exact content that gets signed -->
<div>
<div class="text-[10px] uppercase tracking-wide text-white/40 mb-1.5">What gets signed &amp; published</div>
<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">{{ did || '—' }}</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">{{ npub || '—' }}</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">{{ version || '—' }}</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. 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>
<!-- Actions -->
<div class="flex gap-3 mt-4">
<button @click="$emit('cancel')" class="glass-button flex-1 py-3 rounded-lg text-sm font-medium text-white/70">
Cancel
</button>
<button
@click="$emit('confirm')"
:disabled="busy"
class="flex-1 py-3 rounded-lg text-sm font-semibold transition-all duration-200 bg-white/10 text-white hover:bg-white/15 disabled:opacity-30 disabled:cursor-not-allowed"
>
{{ busy ? 'Publishing…' : 'Sign & Publish' }}
</button>
</div>
<p class="mt-3 text-center text-[10px] text-white/20 tracking-widest">
NIP-01 &middot; SECP256K1 &middot; SCHNORR &middot; Signed locally
</p>
</div>
</div>
</Transition>
</Teleport>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { useModalKeyboard } from '@/composables/useModalKeyboard'
const props = defineProps<{
show: boolean
serverName: string
npub: string
did: string
version: string
busy?: boolean
}>()
const emit = defineEmits<{
confirm: []
cancel: []
}>()
const modalRef = ref<HTMLElement | null>(null)
useModalKeyboard(modalRef, computed(() => props.show), () => emit('cancel'))
function truncateNpub(npub: string): string {
if (npub.length <= 20) return npub
return npub.slice(0, 12) + '...' + npub.slice(-6)
}
</script>
<style scoped>
/* Hero container — same dimensions/pattern as the NostrIdentityPicker overlay */
.presence-hero {
position: relative;
width: 148px;
height: 148px;
}
.presence-viz-ring {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
pointer-events: none;
}
.presence-viz-segment {
position: absolute;
left: 50%;
top: 50%;
width: 2.5px;
height: 14px;
margin-left: -1.25px;
margin-top: -7px;
background: linear-gradient(to bottom, rgba(250, 250, 250, 0.4), rgba(250, 250, 250, 0.06));
border-radius: 1.5px;
transform-origin: center center;
transform: rotate(var(--seg-deg)) translateY(-60px);
animation: presence-seg-pulse 14s ease-in-out infinite;
animation-delay: calc(var(--seg-i) * 0.02s);
}
@keyframes presence-seg-pulse {
0% { opacity: 0.15; transform: rotate(var(--seg-deg)) translateY(-60px) scaleY(0.4); }
7.1% { opacity: 0.7; transform: rotate(var(--seg-deg)) translateY(-60px) scaleY(1); }
14.3% { opacity: 0.15; transform: rotate(var(--seg-deg)) translateY(-60px) scaleY(0.4); }
21.4% { opacity: 0.7; transform: rotate(var(--seg-deg)) translateY(-60px) scaleY(1); }
28.6% { opacity: 0.15; transform: rotate(var(--seg-deg)) translateY(-60px) scaleY(0.4); }
35.7% { opacity: 0.7; transform: rotate(var(--seg-deg)) translateY(-60px) scaleY(1); }
42.9% { opacity: 0.15; transform: rotate(var(--seg-deg)) translateY(-60px) scaleY(0.4); }
50% { opacity: 0.7; transform: rotate(var(--seg-deg)) translateY(-60px) scaleY(1); }
57.1% { opacity: 0.15; transform: rotate(var(--seg-deg)) translateY(-60px) scaleY(0.4); }
64.3% { opacity: 0.7; transform: rotate(var(--seg-deg)) translateY(-60px) scaleY(1); }
71.4% { opacity: 0.15; transform: rotate(var(--seg-deg)) translateY(-60px) scaleY(0.4); }
78.6% { opacity: 1; transform: rotate(var(--seg-deg)) translateY(-60px) scaleY(1.5); }
85.7% { opacity: 1; transform: rotate(var(--seg-deg)) translateY(-60px) scaleY(1.5); }
92.9% { opacity: 0.15; transform: rotate(var(--seg-deg)) translateY(-60px) scaleY(0.4); }
100% { opacity: 0.15; transform: rotate(var(--seg-deg)) translateY(-60px) scaleY(0.4); }
}
.presence-glass-border {
position: absolute;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
width: 104px;
height: 104px;
border-radius: 9999px;
padding: 3px;
background: linear-gradient(135deg, rgba(255, 255, 255, 0.6) 0%, rgba(0, 0, 0, 0.8) 100%);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.5);
filter: drop-shadow(0 0 24px rgba(255, 255, 255, 0.08));
}
.presence-glass-inner {
width: 100%;
height: 100%;
border-radius: 9999px;
background: #000;
display: flex;
align-items: center;
justify-content: center;
}
/* Modal transitions — same curve as the identity picker */
.presence-sign-enter-active,
.presence-sign-leave-active {
transition: opacity 0.4s ease;
}
.presence-sign-enter-active > .relative {
transition: transform 0.5s cubic-bezier(0.22, 1, 0.36, 1), opacity 0.4s ease;
}
.presence-sign-leave-active > .relative {
transition: transform 0.25s ease, opacity 0.2s ease;
}
.presence-sign-enter-from { opacity: 0; }
.presence-sign-enter-from > .relative { transform: translateY(24px) scale(0.94); opacity: 0; }
.presence-sign-leave-to { opacity: 0; }
.presence-sign-leave-to > .relative { transform: translateY(10px) scale(0.98); opacity: 0; }
</style>