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,
}
})