diff --git a/loop/plan.md b/loop/plan.md index cfba341d..5974099e 100644 --- a/loop/plan.md +++ b/loop/plan.md @@ -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 '' '';` 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. diff --git a/neode-ui/src/components/AppLauncherOverlay.vue b/neode-ui/src/components/AppLauncherOverlay.vue index ca857fad..82f4ab0f 100644 --- a/neode-ui/src/components/AppLauncherOverlay.vue +++ b/neode-ui/src/components/AppLauncherOverlay.vue @@ -161,11 +161,23 @@ + + + + + diff --git a/neode-ui/src/stores/appLauncher.ts b/neode-ui/src/stores/appLauncher.ts index c3604bb9..e5275f40 100644 --- a/neode-ui/src/stores/appLauncher.ts +++ b/neode-ui/src/stores/appLauncher.ts @@ -102,15 +102,42 @@ function toEmbeddableUrl(url: string): string { return url } +const APPROVED_ORIGINS_KEY = 'neode_nostr_approved_origins' + +function getApprovedOrigins(): Set { + 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(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 { + 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({ 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, } })