diff --git a/src/lib/nostr/signing.ts b/src/lib/nostr/signing.ts deleted file mode 100644 index dda7315..0000000 --- a/src/lib/nostr/signing.ts +++ /dev/null @@ -1,108 +0,0 @@ -import type { EventTemplate, Event as NostrEvent } from 'nostr-tools' -import { getApiUrl, getAuthToken } from '@/lib/config/lnbits' - -/** - * Uniform bucket-B signing helper. Bucket-B kinds (1, 3, 4, 5, 7, 1111, - * 1621, 1622, 10003, 30023, 31922, 31923, 31925) sign server-side via - * the `NostrSigner` ABC — LocalSigner does it in-process, RemoteBunker - * routes via NIP-46 to nsecbunkerd. The webapp posts an unsigned event - * template and receives the signed event back. - * - * Wire shape: POST /api/v1/auth/sign-event - * - Cookie auth (cookie_access_token from login) OR Bearer auth - * (Authorization: Bearer ). The webapp uses Bearer. - * - CSRF: GET /auth/csrf-token issues an XSRF-TOKEN cookie + body - * token; we echo the token in X-CSRF-Token. We lazy-fetch once - * per page load and refresh on 403. - * - Body: {kind, created_at, tags, content} - * - Returns: fully-signed event {kind, created_at, tags, content, - * pubkey, id, sig}. - * - * Refs: - * - `~/dev/coordination/webapp-design-questions.md` Q3.3 (uniform - * helper); aiolabs/lnbits PR #29 (the endpoint); commit - * `9a300c1` (the prvkey-removal that this unblocks). - */ - -let cachedCsrfToken: string | null = null - -async function fetchCsrfToken(): Promise { - const response = await fetch(getApiUrl('/auth/csrf-token'), { - method: 'GET', - credentials: 'include', - headers: getAuthHeaders(), - }) - if (!response.ok) { - throw new Error( - `Failed to obtain CSRF token: ${response.status} ${response.statusText}`, - ) - } - const data = await response.json() - if (typeof data?.csrf_token !== 'string') { - throw new Error('CSRF token response missing csrf_token field') - } - return data.csrf_token -} - -async function getCsrfToken(forceRefresh = false): Promise { - if (!forceRefresh && cachedCsrfToken) return cachedCsrfToken - cachedCsrfToken = await fetchCsrfToken() - return cachedCsrfToken -} - -function getAuthHeaders(): Record { - const token = getAuthToken() - return token ? { Authorization: `Bearer ${token}` } : {} -} - -async function signOnce( - template: EventTemplate, - csrfToken: string, -): Promise { - return fetch(getApiUrl('/auth/sign-event'), { - method: 'POST', - credentials: 'include', - headers: { - 'Content-Type': 'application/json', - 'X-CSRF-Token': csrfToken, - ...getAuthHeaders(), - }, - body: JSON.stringify({ - kind: template.kind, - created_at: template.created_at, - tags: template.tags, - content: template.content, - }), - }) -} - -export async function signEventViaLnbits( - template: EventTemplate, -): Promise { - let csrfToken = await getCsrfToken() - let response = await signOnce(template, csrfToken) - - // On CSRF rejection, refresh the token once and retry. The cached - // token outlives the cookie when the browser is restarted across - // an expired XSRF-TOKEN cookie; one retry covers that race. - if (response.status === 403) { - const body = await response.clone().text() - if (body.includes('CSRF')) { - csrfToken = await getCsrfToken(true) - response = await signOnce(template, csrfToken) - } - } - - if (!response.ok) { - let detail = `${response.status} ${response.statusText}` - try { - const parsed = await response.json() - if (typeof parsed?.detail === 'string') detail = parsed.detail - } catch { - // body wasn't JSON - } - throw new Error(`signEventViaLnbits: ${detail}`) - } - - return (await response.json()) as NostrEvent -} diff --git a/src/modules/activities/composables/useBookmarks.ts b/src/modules/activities/composables/useBookmarks.ts index 6106953..b427a28 100644 --- a/src/modules/activities/composables/useBookmarks.ts +++ b/src/modules/activities/composables/useBookmarks.ts @@ -1,8 +1,7 @@ import { ref, computed, onMounted, onUnmounted } from 'vue' -import type { EventTemplate, Event as NostrEvent } from 'nostr-tools' +import { finalizeEvent, type EventTemplate, type Event as NostrEvent } from 'nostr-tools' import { tryInjectService, SERVICE_TOKENS } from '@/core/di-container' import { useAuth } from '@/composables/useAuthService' -import { signEventViaLnbits } from '@/lib/nostr/signing' /** * NIP-51 Bookmarks (kind 10003) for saving favorite activities. @@ -90,7 +89,7 @@ export function useBookmarks() { * Toggle bookmark for an activity. Publishes updated NIP-51 bookmark list. */ async function toggleBookmark(activityKind: number, pubkey: string, dTag: string) { - if (!isAuthenticated.value || !currentUser.value?.pubkey) return + if (!isAuthenticated.value || !currentUser.value?.prvkey) return const coord = `${activityKind}:${pubkey}:${dTag}` const newCoords = new Set(state.value.bookmarkedCoords) @@ -111,13 +110,8 @@ export function useBookmarks() { tags, } - let signedEvent: NostrEvent - try { - signedEvent = await signEventViaLnbits(template) - } catch (err) { - console.error('[useBookmarks] signEventViaLnbits failed:', err) - return - } + const signingKey = hexToUint8Array(currentUser.value.prvkey) + const signedEvent = finalizeEvent(template, signingKey) const relayHub = tryInjectService(SERVICE_TOKENS.RELAY_HUB) if (!relayHub) return @@ -153,3 +147,10 @@ export function useBookmarks() { } } +function hexToUint8Array(hex: string): Uint8Array { + const bytes = new Uint8Array(hex.length / 2) + for (let i = 0; i < hex.length; i += 2) { + bytes[i / 2] = parseInt(hex.substr(i, 2), 16) + } + return bytes +} diff --git a/src/modules/activities/composables/useRSVP.ts b/src/modules/activities/composables/useRSVP.ts index e3d14ab..337a7ec 100644 --- a/src/modules/activities/composables/useRSVP.ts +++ b/src/modules/activities/composables/useRSVP.ts @@ -1,8 +1,7 @@ import { ref, onMounted, onUnmounted } from 'vue' -import type { EventTemplate, Event as NostrEvent } from 'nostr-tools' +import { finalizeEvent, type EventTemplate, type Event as NostrEvent } from 'nostr-tools' import { tryInjectService, SERVICE_TOKENS } from '@/core/di-container' import { useAuth } from '@/composables/useAuthService' -import { signEventViaLnbits } from '@/lib/nostr/signing' import { NIP52_KINDS, type RSVPStatus } from '../types/nip52' /** @@ -151,7 +150,7 @@ export function useRSVP() { activityDTag: string, status: RSVPStatus ): Promise { - if (!isAuthenticated.value || !currentUser.value?.pubkey) return null + if (!isAuthenticated.value || !currentUser.value?.prvkey) return null const coord = `${activityKind}:${activityPubkey}:${activityDTag}` @@ -185,13 +184,8 @@ export function useRSVP() { ], } - let signedEvent: NostrEvent - try { - signedEvent = await signEventViaLnbits(template) - } catch (err) { - console.error('[useRSVP] signEventViaLnbits failed:', err) - return null - } + const signingKey = hexToUint8Array(currentUser.value.prvkey) + const signedEvent = finalizeEvent(template, signingKey) const relayHub = tryInjectService(SERVICE_TOKENS.RELAY_HUB) if (!relayHub) return null @@ -246,3 +240,10 @@ export function useRSVP() { } } +function hexToUint8Array(hex: string): Uint8Array { + const bytes = new Uint8Array(hex.length / 2) + for (let i = 0; i < hex.length; i += 2) { + bytes[i / 2] = parseInt(hex.substr(i, 2), 16) + } + return bytes +} diff --git a/src/modules/base/services/NostrTransportService.ts b/src/modules/base/services/NostrTransportService.ts index 32b7a4d..8ce4974 100644 --- a/src/modules/base/services/NostrTransportService.ts +++ b/src/modules/base/services/NostrTransportService.ts @@ -1,32 +1,58 @@ +import { finalizeEvent, nip44, type EventTemplate, type Event } from 'nostr-tools' import { BaseService } from '@/core/base/BaseService' import { LNBITS_CONFIG } from '@/lib/config/lnbits' +import type { AuthService } from '@/modules/base/auth/auth-service' +import type { RelayHub } from '@/modules/base/nostr/relay-hub' /** * Client for LNbits's nostr-transport (kind-21000 RPC over relays, * landed upstream Sun May 24 2026, commit f235966c). * - * **Disabled in phase 1/2 per design-questions Q4.2.** The kind-21000 - * NIP-44 RPC transport requires the caller to hold a raw user prvkey - * for the NIP-44 v2 conversation key derivation AND for signing the - * outer envelope. Post-aiolabs/lnbits#9 the webapp no longer has - * prvkey at all, and `/auth/sign-event` doesn't list 21000 in - * `ALLOWED_KINDS`. Reviving this transport is phase-3+ work (Q4.3: - * lnbits-side change so the transport accepts an inner-payload - * identity claim rather than the outer pubkey). + * The webapp wraps its RPC payload in an NIP-44 v2 encrypted JSON + * envelope, signs a kind-21000 event with the current user's Nostr + * key, publishes via RelayHub, and listens for a signed response + * from the LNbits server pubkey addressed back to us. Shards + * (per Lightning.Pub's `{part,index,totalShards,shardsId}` format) + * are reassembled before parsing. * - * Every prior caller has been migrated: - * - ticket scanner → events extension HTTP (PR #87) - * - bookmarks / RSVP / NIP-09 deletion → `signEventViaLnbits` - * - * The service + DI registration stay as scaffolding so phase-3+ - * revival is a single-file diff. + * This is the preferred call site for any backend op that has a + * registered handler — the activities ticket scanner is the first + * webapp consumer; wallet ops + event CRUD are obvious next adopters. */ +const TRANSPORT_KIND = 21000 + interface RpcCallOptions { + /** Wallet id to send as part of the RPC body — required for + * AUTH_WALLET endpoints. Defaults to currentUser.wallets[0].id. */ walletId?: string + /** Per-call timeout in ms before throwing. Default 15s. */ timeoutMs?: number } +interface ShardWrapper { + part: string + index: number + totalShards: number + shardsId: string +} + +interface RpcRequestEnvelope { + rpc_name: string + request_id: string + wallet_id?: string + body?: Record + query?: Record +} + +interface RpcResponseEnvelope { + status: 'OK' | 'ERROR' + request_id: string + subscription_id?: string | null + data?: T | null + error?: string | null +} + export class NostrRpcError extends Error { constructor(public readonly message: string) { super(message) @@ -41,28 +67,179 @@ export class NostrTransportService extends BaseService { dependencies: ['AuthService', 'RelayHub'], } + /** Server pubkey (hex). Logged by the LNbits server at startup + * and configured via VITE_LNBITS_NOSTR_TRANSPORT_PUBKEY. */ + private get serverPubkey(): string { + return LNBITS_CONFIG.NOSTR_TRANSPORT_PUBKEY + } + protected async onInitialize(): Promise { - if (!LNBITS_CONFIG.NOSTR_TRANSPORT_PUBKEY) { + if (!this.serverPubkey) { this.debug( 'No VITE_LNBITS_NOSTR_TRANSPORT_PUBKEY configured — RPC calls will fail', ) } else { - this.debug( - `Initialized with server pubkey ${LNBITS_CONFIG.NOSTR_TRANSPORT_PUBKEY.slice(0, 16)}… ` + - '(phase 1/2: call() is disabled per Q4.2)', - ) + this.debug(`Initialized with server pubkey ${this.serverPubkey.slice(0, 16)}…`) } } + /** + * Invoke an LNbits nostr-transport RPC and await the response. + * + * Throws `NostrRpcError` on backend ERROR responses (e.g. "Ticket + * not paid for"), a generic Error on signing / encryption / timeout + * failures. + */ async call( - _rpcName: string, - _body: Record, - _options: RpcCallOptions = {}, + rpcName: string, + body: Record, + options: RpcCallOptions = {}, ): Promise { - throw new Error( - 'NostrTransportService.call is disabled in phase 1/2; deferred ' + - 'to phase 3+ per design-questions Q4.2/Q4.3. Route through ' + - 'extension HTTP endpoints (Bucket A) or signEventViaLnbits (Bucket B).', + const auth = this.authService as AuthService | undefined + const user = auth?.currentUser.value + if (!user || !user.prvkey || !user.pubkey) { + throw new Error('Sign-in with a Nostr key required to call RPC') + } + if (!this.serverPubkey) { + throw new Error( + 'Missing VITE_LNBITS_NOSTR_TRANSPORT_PUBKEY — cannot reach LNbits', + ) + } + const walletId = options.walletId ?? user.wallets?.[0]?.id + const timeoutMs = options.timeoutMs ?? 15_000 + const requestId = crypto.randomUUID() + + const privkeyBytes = hexToBytes(user.prvkey) + const conversationKey = nip44.v2.utils.getConversationKey( + privkeyBytes, + this.serverPubkey, ) + + const envelope: RpcRequestEnvelope = { + rpc_name: rpcName, + request_id: requestId, + ...(walletId ? { wallet_id: walletId } : {}), + body, + } + const ciphertext = nip44.v2.encrypt(JSON.stringify(envelope), conversationKey) + + const template: EventTemplate = { + kind: TRANSPORT_KIND, + created_at: Math.floor(Date.now() / 1000), + tags: [['p', this.serverPubkey]], + content: ciphertext, + } + const signedEvent = finalizeEvent(template, privkeyBytes) + + // Subscribe BEFORE publishing so we can't miss a fast reply. + const replyPromise = this.waitForReply( + requestId, + user.pubkey, + conversationKey, + timeoutMs, + ) + + const hub = this.relayHub as RelayHub + const publishResult = await hub.publishEvent(signedEvent) + if (publishResult.success === 0) { + throw new Error('Failed to publish RPC event to any relay') + } + + return replyPromise + } + + private async waitForReply( + requestId: string, + clientPubkey: string, + conversationKey: Uint8Array, + timeoutMs: number, + ): Promise { + const hub = this.relayHub as RelayHub + const shards = new Map>() + const shardsExpected = new Map() + let unsubscribe: (() => void) | null = null + + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + unsubscribe?.() + reject(new Error(`RPC ${requestId} timed out after ${timeoutMs}ms`)) + }, timeoutMs) + + unsubscribe = hub.subscribe({ + id: `nostr-rpc-${requestId}`, + filters: [ + { + kinds: [TRANSPORT_KIND], + '#p': [clientPubkey], + authors: [this.serverPubkey], + // Don't bound by `since` — the server may publish before + // the relay has accepted our subscription create. The + // request_id match below is the actual filter. + }, + ], + onEvent: (event: Event) => { + let plaintext: string + try { + plaintext = nip44.v2.decrypt(event.content, conversationKey) + } catch (e) { + this.debug(`Skipping undecryptable event ${event.id.slice(0, 16)}: ${e}`) + return + } + + // Reassemble shards (Lightning.Pub wrapper format) before + // attempting to parse as an RPC response. Plaintexts that + // don't carry the wrapper are full single-event responses. + let fullText: string | null = plaintext + try { + const maybeShard = JSON.parse(plaintext) as Partial + if ( + typeof maybeShard.shardsId === 'string' && + typeof maybeShard.index === 'number' && + typeof maybeShard.totalShards === 'number' && + typeof maybeShard.part === 'string' + ) { + const buf = shards.get(maybeShard.shardsId) ?? new Map() + buf.set(maybeShard.index, maybeShard.part) + shards.set(maybeShard.shardsId, buf) + shardsExpected.set(maybeShard.shardsId, maybeShard.totalShards) + if (buf.size < maybeShard.totalShards) { + return // wait for more shards + } + const sorted = Array.from({ length: maybeShard.totalShards }, (_, i) => + buf.get(i) ?? '', + ) + fullText = sorted.join('') + } + } catch { + // not JSON → fall through with plaintext as-is + } + + let response: RpcResponseEnvelope + try { + response = JSON.parse(fullText) as RpcResponseEnvelope + } catch (e) { + this.debug(`Skipping unparseable RPC payload: ${e}`) + return + } + if (response.request_id !== requestId) return // not for us + + clearTimeout(timer) + unsubscribe?.() + if (response.status === 'OK') { + resolve(response.data as T) + } else { + reject(new NostrRpcError(response.error ?? 'Unknown RPC error')) + } + }, + }) + }) } } + +function hexToBytes(hex: string): Uint8Array { + const bytes = new Uint8Array(hex.length / 2) + for (let i = 0; i < hex.length; i += 2) { + bytes[i / 2] = parseInt(hex.slice(i, i + 2), 16) + } + return bytes +} diff --git a/src/modules/market/composables/useMarket.ts b/src/modules/market/composables/useMarket.ts index 090e8ef..62c66da 100644 --- a/src/modules/market/composables/useMarket.ts +++ b/src/modules/market/composables/useMarket.ts @@ -2,6 +2,8 @@ import { ref, computed, onMounted, onUnmounted, readonly } from 'vue' import { useMarketStore } from '../stores/market' import { injectService, SERVICE_TOKENS } from '@/core/di-container' import { config } from '@/lib/config' +import type { NostrmarketService } from '../services/nostrmarketService' +import { nip59 } from 'nostr-tools' import { useAsyncOperation } from '@/core/composables/useAsyncOperation' import { auth } from '@/composables/useAuthService' @@ -42,6 +44,7 @@ export function useMarket() { const marketStore = useMarketStore() const relayHub = injectService(SERVICE_TOKENS.RELAY_HUB) as any const authService = injectService(SERVICE_TOKENS.AUTH_SERVICE) as any + const nostrmarketService = injectService(SERVICE_TOKENS.NOSTRMARKET_SERVICE) as NostrmarketService if (!relayHub) { throw new Error('RelayHub not available. Make sure base module is installed.') @@ -430,24 +433,56 @@ export function useMarket() { return null } + // Convert hex string to Uint8Array (browser-compatible) + const hexToUint8Array = (hex: string): Uint8Array => { + const bytes = new Uint8Array(hex.length / 2) + for (let i = 0; i < hex.length; i += 2) { + bytes[i / 2] = parseInt(hex.slice(i, i + 2), 16) + } + return bytes + } + // Handle incoming order gift wraps (kind 1059) — payment requests, status updates. // - // **Disabled in phase 1/2 per design-questions Q4.2 / Bucket C.** NIP-59 - // gift-wrap unwrap requires a raw user prvkey for NIP-44 v2 decryption; - // post-aiolabs/lnbits#9 the webapp doesn't hold one and lnbits doesn't - // yet expose a server-routed nip44_decrypt endpoint (would route through - // the signer ABC's existing nip44_decrypt method — phase-3+ work). - // - // Until then incoming order DMs are not processed by the webapp. - // Buyers will see order status changes via the nostrmarket extension's - // own server-side handling rather than relying on this client-side - // unwrap. Flag locally so we notice if the path becomes hot. + // The outer event's pubkey is an ephemeral key (NIP-59); the real merchant + // pubkey is on the unwrapped rumor. Content is JSON with a `type` field + // (1 = payment request, 2 = order status update). const handleOrderDM = async (event: any) => { - console.warn( - '[useMarket] Skipping order gift wrap (kind 1059) unwrap — phase 1/2 ' + - 'has no prvkey access for NIP-44 decryption. Event id:', - event?.id, - ) + try { + console.log('🎁 Received order gift wrap:', event.id, '(kind', event.kind + ')') + + const userPrivkey = + authService.user.value?.prvkey ?? auth.currentUser.value?.prvkey + + if (!userPrivkey) { + console.warn('Cannot unwrap gift wrap: no user private key available') + return + } + + const prvkeyBytes = hexToUint8Array(userPrivkey) + const rumor = nip59.unwrapEvent(event, prvkeyBytes) + console.log('🔓 Unwrapped rumor from merchant:', rumor.pubkey.slice(0, 10) + '...') + + const messageData = JSON.parse(rumor.content) + console.log('📨 Parsed message data:', messageData) + + switch (messageData.type) { + case 1: // Payment request + console.log('💰 Processing payment request for order:', messageData.id) + await nostrmarketService.handlePaymentRequest(messageData) + console.log('✅ Payment request processed successfully') + break + case 2: // Order status update + console.log('📦 Processing order status update for order:', messageData.id) + await nostrmarketService.handleOrderStatusUpdate(messageData) + console.log('✅ Order status update processed successfully') + break + default: + console.log('❓ Unknown message type:', messageData.type) + } + } catch (error) { + console.error('Failed to handle order gift wrap:', error) + } } // Handle incoming market events diff --git a/src/modules/nostr-feed/components/NostrFeed.vue b/src/modules/nostr-feed/components/NostrFeed.vue index ce7350d..ab75da0 100644 --- a/src/modules/nostr-feed/components/NostrFeed.vue +++ b/src/modules/nostr-feed/components/NostrFeed.vue @@ -22,7 +22,7 @@ import type { ScheduledEvent } from '@/modules/tasks/services/TaskService' import { injectService, SERVICE_TOKENS } from '@/core/di-container' import type { AuthService } from '@/modules/base/auth/auth-service' import type { RelayHub } from '@/modules/base/nostr/relay-hub' -import { signEventViaLnbits } from '@/lib/nostr/signing' +import { finalizeEvent } from 'nostr-tools' import { useToast } from '@/core/composables/useToast' interface Emits { @@ -366,6 +366,15 @@ function onToggleLimited(postId: string) { limitedReplyPosts.value = newLimited } +// Helper function to convert hex string to Uint8Array +const hexToUint8Array = (hex: string): Uint8Array => { + const bytes = new Uint8Array(hex.length / 2) + for (let i = 0; i < hex.length; i += 2) { + bytes[i / 2] = parseInt(hex.substr(i, 2), 16) + } + return bytes +} + // Handle delete post button click - show confirmation dialog function onDeletePost(note: FeedPost) { if (!authService?.isAuthenticated.value || !authService?.user.value) { @@ -396,8 +405,9 @@ async function confirmDeletePost() { return } - if (!authService?.user.value?.pubkey) { - toast.error("Not signed in") + const userPrivkey = authService?.user.value?.prvkey + if (!userPrivkey) { + toast.error("User private key not available") // pragma: allowlist secret showDeleteDialog.value = false postToDelete.value = null return @@ -415,8 +425,9 @@ async function confirmDeletePost() { created_at: Math.floor(Date.now() / 1000) } - // Sign the deletion event server-side via lnbits. - const signedEvent = await signEventViaLnbits(deletionEvent) + // Sign the deletion event + const privkeyBytes = hexToUint8Array(userPrivkey) + const signedEvent = finalizeEvent(deletionEvent, privkeyBytes) // Publish the deletion request const result = await relayHub.publishEvent(signedEvent)