feat: add NIP-07 signing consent modal with remember-per-app support

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Dorian 2026-03-12 23:33:30 +00:00
parent aca1d66f71
commit cbf971b6b2
4 changed files with 236 additions and 2 deletions

View File

@ -484,7 +484,7 @@
- [x] **NIP07-01** — Configure nginx to inject nostr-provider.js into iframe apps. In `image-recipe/configs/nginx-archipelago.conf`, for every `/app/*` proxy location block, add `sub_filter '</head>' '<script src="/nostr-provider.js"></script></head>';` and `sub_filter_once on;`. Ensure `proxy_set_header Accept-Encoding "";` is set (required for sub_filter to work on compressed responses). Copy `neode-ui/public/nostr-provider.js` to `/opt/archipelago/web-ui/nostr-provider.js` in the deploy script. Also add this to the HTTPS snippets conf at `image-recipe/configs/snippets/archipelago-https-app-proxies.conf`. **Acceptance**: Open any iframe app (e.g., Mempool at `/app/mempool/`), open browser DevTools console, type `window.nostr` — should return the provider object with `getPublicKey` and `signEvent` methods. Deploy and verify.
- [ ] **NIP07-02** — Add signing consent modal. In `neode-ui/src/components/`, create `NostrSignConsent.vue` — a modal that shows when an iframe app requests a Nostr signature. Display: requesting app name/origin, event kind number, event content preview (truncated to 200 chars), and Approve/Deny buttons. In `neode-ui/src/stores/appLauncher.ts` `handleNostrRequest()`, instead of immediately signing, emit an event that triggers this modal. Only call the backend RPC after user approves. Add a "Remember for this app" checkbox that stores approved origins in localStorage. **Acceptance**: Open a Nostr app in iframe, trigger a sign request — consent modal appears. Approve → signature returned. Deny → error returned to iframe. Deploy and verify.
- [x] **NIP07-02** — Add signing consent modal. In `neode-ui/src/components/`, create `NostrSignConsent.vue` — a modal that shows when an iframe app requests a Nostr signature. Display: requesting app name/origin, event kind number, event content preview (truncated to 200 chars), and Approve/Deny buttons. In `neode-ui/src/stores/appLauncher.ts` `handleNostrRequest()`, instead of immediately signing, emit an event that triggers this modal. Only call the backend RPC after user approves. Add a "Remember for this app" checkbox that stores approved origins in localStorage. **Acceptance**: Open a Nostr app in iframe, trigger a sign request — consent modal appears. Approve → signature returned. Deny → error returned to iframe. Deploy and verify.
- [ ] **NIP07-03** — Test NIP-07 with a real Nostr web app. Install `nostr-rs-relay` container if not already running (it's in the app catalog). Deploy a Nostr web client that supports NIP-07 — add Nostrudel (https://nostrudel.ninja) as a web-only app entry in `Marketplace.vue` `getCuratedAppList()` (category: "Social", opens in iframe). Open Nostrudel, verify it detects `window.nostr`, can fetch the pubkey, and can sign events (post a note). **Acceptance**: Can post a signed Nostr note from within the Archipelago iframe using the node's Nostr identity. Verify the note appears on a public Nostr client.

View File

@ -161,11 +161,23 @@
</div>
</Transition>
</Teleport>
<!-- Nostr signing consent modal -->
<NostrSignConsent
:show="store.showConsent"
:app-name="store.consentRequest?.appName ?? ''"
:method="store.consentRequest?.method ?? ''"
:event-kind="store.consentRequest?.eventKind"
:content="store.consentRequest?.content"
@approve="store.approveConsent"
@deny="store.denyConsent"
/>
</template>
<script setup lang="ts">
import { ref, watch, onMounted, onBeforeUnmount } from 'vue'
import { useAppLauncherStore } from '@/stores/appLauncher'
import NostrSignConsent from '@/components/NostrSignConsent.vue'
import { rpcClient } from '@/api/rpc-client'
interface PaymentRequest {

View File

@ -0,0 +1,152 @@
<template>
<Teleport to="body">
<Transition name="modal">
<div
v-if="show"
class="fixed inset-0 z-[3000] flex items-center justify-center p-4"
@click="deny"
>
<div class="absolute inset-0 bg-black/60 backdrop-blur-sm"></div>
<div
ref="modalRef"
@click.stop
class="glass-card p-6 max-w-md w-full relative z-10"
>
<div class="flex items-start justify-between gap-4 mb-4">
<h3 class="text-xl font-semibold text-white">Nostr Signing Request</h3>
<button
@click="deny"
class="p-2 rounded-lg hover:bg-white/10 text-white/70 hover:text-white transition-colors"
aria-label="Close"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<div class="space-y-3 mb-6">
<div class="bg-black/20 rounded-xl border border-white/10 p-3">
<p class="text-white/50 text-xs uppercase tracking-wider mb-1">App</p>
<p class="text-white text-sm font-medium">{{ appName }}</p>
</div>
<div class="bg-black/20 rounded-xl border border-white/10 p-3">
<p class="text-white/50 text-xs uppercase tracking-wider mb-1">Method</p>
<p class="text-white text-sm font-medium">{{ method }}</p>
</div>
<div v-if="contentPreview" class="bg-black/20 rounded-xl border border-white/10 p-3">
<p class="text-white/50 text-xs uppercase tracking-wider mb-1">Content</p>
<p class="text-white/80 text-sm font-mono break-all">{{ contentPreview }}</p>
</div>
<div v-if="eventKind !== undefined" class="bg-black/20 rounded-xl border border-white/10 p-3">
<p class="text-white/50 text-xs uppercase tracking-wider mb-1">Event Kind</p>
<p class="text-white text-sm font-medium">{{ eventKind }} <span class="text-white/50">({{ eventKindLabel }})</span></p>
</div>
</div>
<label class="flex items-center gap-2 mb-4 cursor-pointer">
<input
v-model="rememberChoice"
type="checkbox"
class="w-4 h-4 rounded border-white/30 bg-white/10 text-orange-400 focus:ring-orange-400/50"
/>
<span class="text-white/70 text-sm">Remember for this app</span>
</label>
<div class="flex gap-3">
<button @click="deny" class="glass-button flex-1 py-2.5 rounded-lg text-sm font-medium">
Deny
</button>
<button @click="approve" class="glass-button flex-1 py-2.5 rounded-lg text-sm font-medium text-orange-400 border-orange-400/30">
Approve
</button>
</div>
</div>
</div>
</Transition>
</Teleport>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { useModalKeyboard } from '@/composables/useModalKeyboard'
const EVENT_KIND_LABELS: Record<number, string> = {
0: 'Metadata',
1: 'Short Text Note',
2: 'Recommend Relay',
3: 'Contacts',
4: 'Encrypted DM',
5: 'Event Deletion',
6: 'Repost',
7: 'Reaction',
9734: 'Zap Request',
9735: 'Zap Receipt',
10002: 'Relay List',
30023: 'Long-form Content',
}
const props = defineProps<{
show: boolean
appName: string
method: string
eventKind?: number
content?: string
}>()
const emit = defineEmits<{
approve: [remember: boolean]
deny: []
}>()
const modalRef = ref<HTMLElement | null>(null)
const rememberChoice = ref(false)
useModalKeyboard(modalRef, computed(() => props.show), () => emit('deny'))
const contentPreview = computed(() => {
if (!props.content) return ''
return props.content.length > 200 ? props.content.slice(0, 200) + '...' : props.content
})
const eventKindLabel = computed(() => {
if (props.eventKind === undefined) return ''
return EVENT_KIND_LABELS[props.eventKind] ?? 'Unknown'
})
function approve() {
emit('approve', rememberChoice.value)
}
function deny() {
emit('deny')
}
</script>
<style scoped>
.modal-enter-active,
.modal-leave-active {
transition: opacity 0.3s ease;
}
.modal-enter-from,
.modal-leave-to {
opacity: 0;
}
.modal-enter-active .glass-card,
.modal-leave-active .glass-card {
transition: transform 0.3s ease;
}
.modal-enter-from .glass-card {
transform: scale(0.95);
}
.modal-leave-to .glass-card {
transform: scale(0.95);
}
</style>

View File

@ -102,15 +102,42 @@ function toEmbeddableUrl(url: string): string {
return url
}
const APPROVED_ORIGINS_KEY = 'neode_nostr_approved_origins'
function getApprovedOrigins(): Set<string> {
try {
const stored = localStorage.getItem(APPROVED_ORIGINS_KEY)
return stored ? new Set(JSON.parse(stored) as string[]) : new Set()
} catch {
return new Set()
}
}
function saveApprovedOrigin(origin: string) {
const origins = getApprovedOrigins()
origins.add(origin)
localStorage.setItem(APPROVED_ORIGINS_KEY, JSON.stringify([...origins]))
}
export interface NostrConsentRequest {
appName: string
method: string
eventKind?: number
content?: string
resolve: (remember: boolean) => void
reject: () => void
}
export const useAppLauncherStore = defineStore('appLauncher', () => {
const isOpen = ref(false)
const url = ref('')
const title = ref('')
const consentRequest = ref<NostrConsentRequest | null>(null)
const showConsent = ref(false)
let previousActiveElement: HTMLElement | null = null
function open(payload: { url: string; title: string; openInNewTab?: boolean }) {
if (payload.openInNewTab || mustOpenInNewTab(payload.url)) {
// New tab: always use direct port URL so app assets load correctly
window.open(payload.url, '_blank', 'noopener,noreferrer')
return
}
@ -134,6 +161,29 @@ export const useAppLauncherStore = defineStore('appLauncher', () => {
}
}
function approveConsent(remember: boolean) {
if (consentRequest.value) {
consentRequest.value.resolve(remember)
consentRequest.value = null
}
showConsent.value = false
}
function denyConsent() {
if (consentRequest.value) {
consentRequest.value.reject()
consentRequest.value = null
}
showConsent.value = false
}
function requestConsent(appName: string, method: string, eventKind?: number, content?: string): Promise<boolean> {
return new Promise((resolve, reject) => {
consentRequest.value = { appName, method, eventKind, content, resolve, reject }
showConsent.value = true
})
}
// NIP-07 postMessage handler — responds to nostr-request from iframe apps
async function handleNostrRequest(event: MessageEvent) {
if (!event.data || event.data.type !== 'nostr-request') return
@ -141,12 +191,28 @@ export const useAppLauncherStore = defineStore('appLauncher', () => {
const source = event.source as Window | null
if (!source) return
const origin = url.value || 'unknown'
try {
let result: unknown
if (method === 'getPublicKey') {
const res = await rpcClient.call<{ nostr_pubkey: string }>({ method: 'node.nostr-pubkey' })
result = res.nostr_pubkey
} else if (method === 'signEvent') {
// Check if origin is pre-approved
const approved = getApprovedOrigins()
if (!approved.has(origin)) {
const eventKind = params?.event?.kind as number | undefined
const content = params?.event?.content as string | undefined
try {
const remember = await requestConsent(title.value || 'App', 'signEvent', eventKind, content)
if (remember) saveApprovedOrigin(origin)
} catch {
source.postMessage({ type: 'nostr-response', id, error: 'User denied signing request' }, '*')
return
}
}
const res = await rpcClient.call<unknown>({ method: 'identity.nostr-sign', params: { event: params.event } })
result = res
} else if (method === 'getRelays') {
@ -176,5 +242,9 @@ export const useAppLauncherStore = defineStore('appLauncher', () => {
title,
open,
close,
showConsent,
consentRequest,
approveConsent,
denyConsent,
}
})