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
co-authored by Claude Opus 4.6
parent efdea936fa
commit d9f833878c
4 changed files with 236 additions and 2 deletions
+71 -1
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,
}
})