2026-09-09 18:15:21 -04:00
|
|
|
/** Consent-gated NIP-07 bridge between the dashboard and an iframe app. */
|
2026-08-12 10:55:50 +00:00
|
|
|
|
2026-09-09 18:15:21 -04:00
|
|
|
import { ref } from 'vue'
|
2026-08-12 10:55:50 +00:00
|
|
|
import { rpcClient } from '@/api/rpc-client'
|
|
|
|
|
import type { SelectedIdentity } from './useAppIdentity'
|
2026-09-09 18:15:21 -04:00
|
|
|
import {
|
|
|
|
|
consentKey,
|
|
|
|
|
hasRememberedConsent,
|
|
|
|
|
rememberConsent,
|
|
|
|
|
} from './nostrConsent'
|
|
|
|
|
|
|
|
|
|
interface BridgeOptions {
|
|
|
|
|
appId: () => string
|
|
|
|
|
appName: () => string
|
|
|
|
|
appUrl: () => string
|
|
|
|
|
frameWindow: () => Window | null
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export interface BridgeConsentRequest {
|
|
|
|
|
appName: string
|
|
|
|
|
method: string
|
|
|
|
|
identityLabel: string
|
|
|
|
|
eventKind?: number
|
|
|
|
|
content?: string
|
|
|
|
|
resolve: (remember: boolean) => void
|
|
|
|
|
reject: () => void
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const CONSENT_METHODS = new Set([
|
|
|
|
|
'getPublicKey', 'signEvent',
|
|
|
|
|
'nip04.encrypt', 'nip04.decrypt',
|
|
|
|
|
'nip44.encrypt', 'nip44.decrypt',
|
|
|
|
|
])
|
|
|
|
|
|
|
|
|
|
function senderMatches(expectedUrl: string, senderOrigin: string): boolean {
|
|
|
|
|
try {
|
|
|
|
|
const expected = new URL(expectedUrl, window.location.origin)
|
|
|
|
|
const sender = new URL(senderOrigin)
|
|
|
|
|
return expected.hostname === sender.hostname && expected.port === sender.port
|
|
|
|
|
} catch {
|
|
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-08-12 10:55:50 +00:00
|
|
|
|
|
|
|
|
export function useNostrBridge(
|
|
|
|
|
getStoredIdentity: () => SelectedIdentity | null,
|
2026-09-09 18:15:21 -04:00
|
|
|
options: BridgeOptions,
|
2026-08-12 10:55:50 +00:00
|
|
|
) {
|
2026-09-09 18:15:21 -04:00
|
|
|
const consentRequest = ref<BridgeConsentRequest | null>(null)
|
|
|
|
|
const showConsent = ref(false)
|
|
|
|
|
const consentPhase = ref<'review' | 'signing' | 'success' | 'error'>('review')
|
|
|
|
|
const consentError = ref('')
|
|
|
|
|
let consentApprovedAt = 0
|
|
|
|
|
let consentGeneration = 0
|
|
|
|
|
let approvedGeneration = 0
|
2026-10-06 10:41:58 -04:00
|
|
|
let sessionGeneration = 0
|
|
|
|
|
let disposed = false
|
|
|
|
|
let draining = false
|
|
|
|
|
let presentationComplete: Promise<void> = Promise.resolve()
|
|
|
|
|
let finishErrorPresentation: (() => void) | undefined
|
|
|
|
|
type RequestScope = { appId: string; identityId: string | null; session: number }
|
|
|
|
|
const requests: Array<{ event: MessageEvent; resolve: () => void; scope: RequestScope }> = []
|
2026-09-09 18:15:21 -04:00
|
|
|
|
|
|
|
|
function requestConsent(
|
|
|
|
|
method: string,
|
|
|
|
|
identityLabel: string,
|
|
|
|
|
eventKind?: number,
|
|
|
|
|
content?: string,
|
|
|
|
|
): Promise<boolean> {
|
|
|
|
|
return new Promise((resolve, reject) => {
|
|
|
|
|
consentGeneration += 1
|
|
|
|
|
consentRequest.value = {
|
|
|
|
|
appName: options.appName(), method, identityLabel, eventKind, content,
|
2026-10-06 10:41:58 -04:00
|
|
|
resolve, reject: () => reject(new Error('Signing request denied.')),
|
2026-09-09 18:15:21 -04:00
|
|
|
}
|
|
|
|
|
consentPhase.value = 'review'
|
|
|
|
|
consentError.value = ''
|
|
|
|
|
showConsent.value = true
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function approveConsent(remember: boolean) {
|
2026-10-06 10:41:58 -04:00
|
|
|
if (consentPhase.value !== 'review' || !consentRequest.value) return
|
2026-09-09 18:15:21 -04:00
|
|
|
consentRequest.value?.resolve(remember)
|
|
|
|
|
consentApprovedAt = Date.now()
|
|
|
|
|
approvedGeneration = consentGeneration
|
|
|
|
|
consentPhase.value = 'signing'
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function denyConsent() {
|
|
|
|
|
consentGeneration += 1
|
|
|
|
|
consentRequest.value?.reject()
|
2026-10-06 10:41:58 -04:00
|
|
|
finishErrorPresentation?.()
|
|
|
|
|
finishErrorPresentation = undefined
|
2026-09-09 18:15:21 -04:00
|
|
|
consentRequest.value = null
|
|
|
|
|
showConsent.value = false
|
|
|
|
|
consentPhase.value = 'review'
|
|
|
|
|
consentError.value = ''
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function finishConsentSuccess() {
|
|
|
|
|
const generation = approvedGeneration
|
|
|
|
|
const remaining = Math.max(0, 350 - (Date.now() - consentApprovedAt))
|
|
|
|
|
if (remaining) await new Promise(resolve => setTimeout(resolve, remaining))
|
|
|
|
|
if (generation !== consentGeneration || !showConsent.value) return
|
|
|
|
|
consentPhase.value = 'success'
|
|
|
|
|
await new Promise(resolve => setTimeout(resolve, 325))
|
|
|
|
|
if (generation !== consentGeneration) return
|
|
|
|
|
consentRequest.value = null
|
|
|
|
|
showConsent.value = false
|
|
|
|
|
consentPhase.value = 'review'
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function finishConsentError(error: unknown) {
|
|
|
|
|
consentError.value = error instanceof Error ? error.message : 'The node could not complete this request.'
|
|
|
|
|
consentPhase.value = 'error'
|
2026-10-06 10:41:58 -04:00
|
|
|
presentationComplete = new Promise(resolve => { finishErrorPresentation = resolve })
|
2026-09-09 18:15:21 -04:00
|
|
|
}
|
|
|
|
|
|
2026-10-06 10:41:58 -04:00
|
|
|
async function processNostrRequest(event: MessageEvent, scope: RequestScope) {
|
2026-09-09 18:15:21 -04:00
|
|
|
if (!event.data || event.data.type !== 'nostr-request') return
|
2026-08-12 10:55:50 +00:00
|
|
|
const { id, method, params } = event.data
|
|
|
|
|
const source = event.source as Window | null
|
2026-09-09 18:15:21 -04:00
|
|
|
if (
|
|
|
|
|
!source ||
|
|
|
|
|
source !== options.frameWindow() ||
|
|
|
|
|
!senderMatches(options.appUrl(), event.origin)
|
|
|
|
|
) return
|
|
|
|
|
|
2026-10-06 10:41:58 -04:00
|
|
|
if (disposed) {
|
|
|
|
|
source.postMessage({ type: 'nostr-response', id, error: 'App session closed.' }, event.origin)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
if (scope.appId !== options.appId() || scope.identityId !== (getStoredIdentity()?.id || null)
|
|
|
|
|
|| scope.session !== sessionGeneration) {
|
|
|
|
|
source.postMessage({ type: 'nostr-response', id, error: 'App or identity changed. Please retry.' }, event.origin)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
const requestedSession = sessionGeneration
|
|
|
|
|
const requestedApp = options.appId()
|
2026-08-12 10:55:50 +00:00
|
|
|
const storedIdentity = getStoredIdentity()
|
|
|
|
|
const identityId = storedIdentity?.id || null
|
2026-09-09 18:15:21 -04:00
|
|
|
const identityScope = identityId || 'node-default'
|
|
|
|
|
const identityLabel = storedIdentity?.name || 'Node default identity'
|
|
|
|
|
const origin = event.origin
|
|
|
|
|
let prompted = false
|
2026-10-06 10:41:58 -04:00
|
|
|
let promptGeneration: number | undefined
|
|
|
|
|
const stillCurrent = () => !disposed && requestedSession === sessionGeneration && requestedApp === options.appId()
|
|
|
|
|
&& source === options.frameWindow() && senderMatches(options.appUrl(), origin)
|
|
|
|
|
&& (getStoredIdentity()?.id || null) === identityId
|
|
|
|
|
&& (promptGeneration === undefined || promptGeneration === consentGeneration)
|
2026-08-12 10:55:50 +00:00
|
|
|
|
|
|
|
|
try {
|
2026-09-09 18:15:21 -04:00
|
|
|
if (CONSENT_METHODS.has(method)) {
|
|
|
|
|
const key = consentKey(origin, options.appId(), identityScope, method)
|
|
|
|
|
if (!hasRememberedConsent(key)) {
|
|
|
|
|
prompted = true
|
2026-10-06 10:41:58 -04:00
|
|
|
const consent = requestConsent(
|
2026-09-09 18:15:21 -04:00
|
|
|
method,
|
|
|
|
|
identityLabel,
|
|
|
|
|
method === 'signEvent' ? params?.event?.kind : undefined,
|
|
|
|
|
method === 'signEvent' ? params?.event?.content : undefined,
|
|
|
|
|
)
|
2026-10-06 10:41:58 -04:00
|
|
|
promptGeneration = consentGeneration
|
|
|
|
|
const remember = await consent
|
|
|
|
|
if (!stillCurrent()) throw new Error('App or identity changed. Please retry.')
|
2026-09-09 18:15:21 -04:00
|
|
|
if (remember) rememberConsent(key)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-06 10:41:58 -04:00
|
|
|
if (!stillCurrent()) throw new Error('App or identity changed. Please retry.')
|
2026-08-12 10:55:50 +00:00
|
|
|
let result: unknown
|
|
|
|
|
if (method === 'getPublicKey') {
|
|
|
|
|
if (storedIdentity?.nostr_pubkey) {
|
|
|
|
|
result = storedIdentity.nostr_pubkey
|
|
|
|
|
} else if (identityId) {
|
|
|
|
|
const res = await rpcClient.call<{ nostr_pubkey: string }>({ method: 'identity.get', params: { id: identityId } })
|
|
|
|
|
result = res.nostr_pubkey
|
|
|
|
|
} else {
|
|
|
|
|
const res = await rpcClient.call<{ nostr_pubkey: string }>({ method: 'node.nostr-pubkey' })
|
|
|
|
|
result = res.nostr_pubkey
|
|
|
|
|
}
|
|
|
|
|
} else if (method === 'signEvent') {
|
2026-09-09 18:15:21 -04:00
|
|
|
result = identityId
|
|
|
|
|
? await rpcClient.call<unknown>({ method: 'identity.nostr-sign', params: { id: identityId, event: params.event } })
|
|
|
|
|
: await rpcClient.call<unknown>({ method: 'node.nostr-sign', params: { event: params.event } })
|
|
|
|
|
} else if (method === 'getRelays') {
|
|
|
|
|
result = {}
|
|
|
|
|
} else if (method === 'nip04.encrypt') {
|
|
|
|
|
result = (await rpcClient.call<{ ciphertext: string }>({ method: 'identity.nostr-encrypt-nip04', params: { id: identityId || undefined, pubkey: params.pubkey, plaintext: params.plaintext } })).ciphertext
|
|
|
|
|
} else if (method === 'nip04.decrypt') {
|
|
|
|
|
result = (await rpcClient.call<{ plaintext: string }>({ method: 'identity.nostr-decrypt-nip04', params: { id: identityId || undefined, pubkey: params.pubkey, ciphertext: params.ciphertext } })).plaintext
|
|
|
|
|
} else if (method === 'nip44.encrypt') {
|
|
|
|
|
result = (await rpcClient.call<{ ciphertext: string }>({ method: 'identity.nostr-encrypt-nip44', params: { id: identityId || undefined, pubkey: params.pubkey, plaintext: params.plaintext } })).ciphertext
|
|
|
|
|
} else if (method === 'nip44.decrypt') {
|
|
|
|
|
result = (await rpcClient.call<{ plaintext: string }>({ method: 'identity.nostr-decrypt-nip44', params: { id: identityId || undefined, pubkey: params.pubkey, ciphertext: params.ciphertext } })).plaintext
|
|
|
|
|
} else {
|
|
|
|
|
throw new Error(`Unsupported NIP-07 method: ${method}`)
|
|
|
|
|
}
|
2026-10-06 10:41:58 -04:00
|
|
|
if (!stillCurrent()) throw new Error('App or identity changed. Please retry.')
|
2026-09-09 18:15:21 -04:00
|
|
|
source.postMessage({ type: 'nostr-response', id, result }, origin)
|
2026-10-06 10:41:58 -04:00
|
|
|
if (prompted) presentationComplete = finishConsentSuccess()
|
2026-08-12 10:55:50 +00:00
|
|
|
} catch (err) {
|
2026-09-09 18:15:21 -04:00
|
|
|
source.postMessage({
|
|
|
|
|
type: 'nostr-response', id,
|
|
|
|
|
error: err instanceof Error ? err.message : 'Unknown error',
|
|
|
|
|
}, origin)
|
|
|
|
|
if (prompted && showConsent.value) finishConsentError(err)
|
2026-08-12 10:55:50 +00:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-06 10:41:58 -04:00
|
|
|
async function drainRequests() {
|
|
|
|
|
if (draining) return
|
|
|
|
|
draining = true
|
|
|
|
|
try {
|
|
|
|
|
while (requests.length) {
|
|
|
|
|
const next = requests.shift()!
|
|
|
|
|
try { await processNostrRequest(next.event, next.scope) } catch {
|
|
|
|
|
// A removed/navigated frame can reject postMessage; drain the remaining requests.
|
|
|
|
|
} finally { next.resolve() }
|
|
|
|
|
// Preserve the approved success animation and never replace a prompt.
|
|
|
|
|
await presentationComplete
|
|
|
|
|
}
|
|
|
|
|
} finally { draining = false }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function handleNostrRequest(event: MessageEvent): Promise<void> {
|
|
|
|
|
if (!event.data || event.data.type !== 'nostr-request'
|
|
|
|
|
|| !event.source || event.source !== options.frameWindow()
|
|
|
|
|
|| !senderMatches(options.appUrl(), event.origin)) return Promise.resolve()
|
|
|
|
|
if (disposed || requests.length >= 16) {
|
|
|
|
|
(event.source as Window).postMessage({ type: 'nostr-response', id: event.data.id,
|
|
|
|
|
error: disposed ? 'App session closed.' : 'Too many signing requests. Please retry.' }, event.origin)
|
|
|
|
|
return Promise.resolve()
|
|
|
|
|
}
|
|
|
|
|
return new Promise(resolve => {
|
|
|
|
|
requests.push({ event, resolve, scope: { appId: options.appId(),
|
|
|
|
|
identityId: getStoredIdentity()?.id || null, session: sessionGeneration } })
|
|
|
|
|
void drainRequests()
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function cancelPending() {
|
|
|
|
|
sessionGeneration += 1
|
|
|
|
|
denyConsent()
|
|
|
|
|
for (const next of requests.splice(0)) {
|
|
|
|
|
try {
|
|
|
|
|
(next.event.source as Window)?.postMessage({ type: 'nostr-response', id: next.event.data.id,
|
|
|
|
|
error: 'App session closed.' }, next.event.origin)
|
|
|
|
|
} catch { /* frame already removed */ }
|
|
|
|
|
next.resolve()
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function dispose() {
|
|
|
|
|
disposed = true
|
|
|
|
|
cancelPending()
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-09 18:15:21 -04:00
|
|
|
return {
|
|
|
|
|
handleNostrRequest,
|
2026-10-06 10:41:58 -04:00
|
|
|
cancelPending,
|
|
|
|
|
dispose,
|
2026-09-09 18:15:21 -04:00
|
|
|
showConsent,
|
|
|
|
|
consentRequest,
|
|
|
|
|
consentPhase,
|
|
|
|
|
consentError,
|
|
|
|
|
approveConsent,
|
|
|
|
|
denyConsent,
|
|
|
|
|
}
|
2026-08-12 10:55:50 +00:00
|
|
|
}
|