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:
parent
aca1d66f71
commit
cbf971b6b2
@ -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.
|
- [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.
|
- [ ] **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.
|
||||||
|
|
||||||
|
|||||||
@ -161,11 +161,23 @@
|
|||||||
</div>
|
</div>
|
||||||
</Transition>
|
</Transition>
|
||||||
</Teleport>
|
</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>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, watch, onMounted, onBeforeUnmount } from 'vue'
|
import { ref, watch, onMounted, onBeforeUnmount } from 'vue'
|
||||||
import { useAppLauncherStore } from '@/stores/appLauncher'
|
import { useAppLauncherStore } from '@/stores/appLauncher'
|
||||||
|
import NostrSignConsent from '@/components/NostrSignConsent.vue'
|
||||||
import { rpcClient } from '@/api/rpc-client'
|
import { rpcClient } from '@/api/rpc-client'
|
||||||
|
|
||||||
interface PaymentRequest {
|
interface PaymentRequest {
|
||||||
|
|||||||
152
neode-ui/src/components/NostrSignConsent.vue
Normal file
152
neode-ui/src/components/NostrSignConsent.vue
Normal 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>
|
||||||
@ -102,15 +102,42 @@ function toEmbeddableUrl(url: string): string {
|
|||||||
return url
|
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', () => {
|
export const useAppLauncherStore = defineStore('appLauncher', () => {
|
||||||
const isOpen = ref(false)
|
const isOpen = ref(false)
|
||||||
const url = ref('')
|
const url = ref('')
|
||||||
const title = ref('')
|
const title = ref('')
|
||||||
|
const consentRequest = ref<NostrConsentRequest | null>(null)
|
||||||
|
const showConsent = ref(false)
|
||||||
let previousActiveElement: HTMLElement | null = null
|
let previousActiveElement: HTMLElement | null = null
|
||||||
|
|
||||||
function open(payload: { url: string; title: string; openInNewTab?: boolean }) {
|
function open(payload: { url: string; title: string; openInNewTab?: boolean }) {
|
||||||
if (payload.openInNewTab || mustOpenInNewTab(payload.url)) {
|
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')
|
window.open(payload.url, '_blank', 'noopener,noreferrer')
|
||||||
return
|
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
|
// NIP-07 postMessage handler — responds to nostr-request from iframe apps
|
||||||
async function handleNostrRequest(event: MessageEvent) {
|
async function handleNostrRequest(event: MessageEvent) {
|
||||||
if (!event.data || event.data.type !== 'nostr-request') return
|
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
|
const source = event.source as Window | null
|
||||||
if (!source) return
|
if (!source) return
|
||||||
|
|
||||||
|
const origin = url.value || 'unknown'
|
||||||
|
|
||||||
try {
|
try {
|
||||||
let result: unknown
|
let result: unknown
|
||||||
|
|
||||||
if (method === 'getPublicKey') {
|
if (method === 'getPublicKey') {
|
||||||
const res = await rpcClient.call<{ nostr_pubkey: string }>({ method: 'node.nostr-pubkey' })
|
const res = await rpcClient.call<{ nostr_pubkey: string }>({ method: 'node.nostr-pubkey' })
|
||||||
result = res.nostr_pubkey
|
result = res.nostr_pubkey
|
||||||
} else if (method === 'signEvent') {
|
} 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 } })
|
const res = await rpcClient.call<unknown>({ method: 'identity.nostr-sign', params: { event: params.event } })
|
||||||
result = res
|
result = res
|
||||||
} else if (method === 'getRelays') {
|
} else if (method === 'getRelays') {
|
||||||
@ -176,5 +242,9 @@ export const useAppLauncherStore = defineStore('appLauncher', () => {
|
|||||||
title,
|
title,
|
||||||
open,
|
open,
|
||||||
close,
|
close,
|
||||||
|
showConsent,
|
||||||
|
consentRequest,
|
||||||
|
approveConsent,
|
||||||
|
denyConsent,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user