From 121f5cc34268ac0092f60ae3544cfbde0ac9aca2 Mon Sep 17 00:00:00 2001 From: Padreug Date: Sun, 3 May 2026 12:28:45 +0200 Subject: [PATCH 1/2] feat(market): migrate order DMs to NIP-17 (NIP-44 + NIP-59 gift wrap) The nostrmarket LNbits extension was refactored to NIP-17 messaging (refactor/nip17-messaging branch, PR #2). Customers must send orders as kind 1059 gift wraps so the merchant's _handle_gift_wrap() handler can process them; kind 4 NIP-04 events are now ignored by the backend. Changes: - nostrmarketService.publishOrder(): replace nip04.encrypt + finalizeEvent (kind 4) with nip59.wrapEvent producing kind 1059. The order JSON sits in an unsigned kind 14 rumor, sealed (kind 13) with the customer's key, wrapped (kind 1059) with an ephemeral key. - useMarket.handleOrderDM(): unwrap incoming kind 1059 via nip59.unwrapEvent instead of nip04.decrypt. Read sender pubkey from rumor.pubkey (the gift wrap's pubkey is ephemeral). - useMarket.registerMarketMessageHandler(): bypass chat-service and subscribe directly to {kinds: [1059], '#p': [userPubkey]}. The chat service still uses NIP-04 - when it migrates to NIP-17 it can take over routing again via setMarketMessageHandler. nostr-tools v2.10.4 (already a dep) provides the NIP-44/NIP-59 APIs. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/modules/market/composables/useMarket.ts | 91 ++++++++++--------- .../market/services/nostrmarketService.ts | 78 +++++----------- 2 files changed, 71 insertions(+), 98 deletions(-) diff --git a/src/modules/market/composables/useMarket.ts b/src/modules/market/composables/useMarket.ts index 66af02c..62c66da 100644 --- a/src/modules/market/composables/useMarket.ts +++ b/src/modules/market/composables/useMarket.ts @@ -3,7 +3,7 @@ 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 { nip04 } from 'nostr-tools' +import { nip59 } from 'nostr-tools' import { useAsyncOperation } from '@/core/composables/useAsyncOperation' import { auth } from '@/composables/useAuthService' @@ -54,19 +54,29 @@ export function useMarket() { throw new Error('AuthService not available. Make sure base module is installed.') } - // Register market DM handler with chat service (if available) + // Subscribe to incoming order gift wraps (NIP-17 / kind 1059) addressed to the user. + // + // The chat service still runs on NIP-04 (kind 4); when it migrates to NIP-17 it + // can take over routing of order DMs the way it does today via setMarketMessageHandler. + // Until then the market subscribes directly so order flows aren't dependent on chat. const registerMarketMessageHandler = () => { try { - // Try to get the chat service (it might not be available if chat module isn't loaded) - const chatService = (globalThis as any).chatService - if (chatService && chatService.setMarketMessageHandler) { - chatService.setMarketMessageHandler(handleOrderDM) - console.log('πŸ›’ Registered market message handler with chat service') - } else { - console.log('πŸ›’ Chat service not available, market will use its own DM subscription') + const userPubkey = authService.user.value?.pubkey || auth.currentUser.value?.pubkey + if (!userPubkey) { + console.log('πŸ›’ No user pubkey available; skipping order gift-wrap subscription') + return } + + const unsubscribe = relayHub.subscribe({ + id: `market-orders-${userPubkey.slice(0, 16)}`, + filters: [{ kinds: [1059], '#p': [userPubkey] }], + onEvent: (event: any) => handleOrderDM(event) + }) + console.log('🎁 Subscribed to order gift wraps (kind 1059)') + // unsubscribe is currently not retained; market lifecycle owns this + void unsubscribe } catch (error) { - console.log('πŸ›’ Could not register with chat service:', error) + console.warn('πŸ›’ Failed to subscribe to order gift wraps:', error) } } @@ -423,53 +433,46 @@ export function useMarket() { return null } - // Handle incoming order DMs (payment requests, status updates) + // 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. + // + // 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) => { try { - console.log('πŸ”” Received order-related DM:', event.id, 'from:', event.pubkey.slice(0, 8)) - - // Check both injected auth service AND global auth composable - const hasAuthService = authService.user.value?.prvkey - const hasGlobalAuth = auth.currentUser.value?.prvkey - - const userPrivkey = hasAuthService ? authService.user.value.prvkey : auth.currentUser.value?.prvkey - const userPubkey = hasAuthService ? authService.user.value.pubkey : auth.currentUser.value?.pubkey - - if (!userPrivkey || !userPubkey) { - console.warn('Cannot decrypt DM: no user private key available', { - hasAuthService: !!hasAuthService, - hasGlobalAuth: !!hasGlobalAuth, - authServicePrivkey: !!authService.user.value?.prvkey, - globalAuthPrivkey: !!auth.currentUser.value?.prvkey - }) + 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 } - - console.log('πŸ” Market DM decryption auth check:', { - hasAuthService: !!hasAuthService, - hasGlobalAuth: !!hasGlobalAuth, - usingAuthService: !!hasAuthService, - userPubkey: userPubkey.substring(0, 10) + '...' - }) - console.log('πŸ”“ Attempting to decrypt DM with private key available') + const prvkeyBytes = hexToUint8Array(userPrivkey) + const rumor = nip59.unwrapEvent(event, prvkeyBytes) + console.log('πŸ”“ Unwrapped rumor from merchant:', rumor.pubkey.slice(0, 10) + '...') - // Decrypt the DM content - const decryptedContent = await nip04.decrypt(userPrivkey, event.pubkey, event.content) - console.log('πŸ”“ Decrypted DM content:', decryptedContent) - - // Parse the decrypted content as JSON - const messageData = JSON.parse(decryptedContent) + const messageData = JSON.parse(rumor.content) console.log('πŸ“¨ Parsed message data:', messageData) - // Handle different types of messages 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 + 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') @@ -478,7 +481,7 @@ export function useMarket() { console.log('❓ Unknown message type:', messageData.type) } } catch (error) { - console.error('Failed to handle order DM:', error) + console.error('Failed to handle order gift wrap:', error) } } diff --git a/src/modules/market/services/nostrmarketService.ts b/src/modules/market/services/nostrmarketService.ts index 733879e..6598d38 100644 --- a/src/modules/market/services/nostrmarketService.ts +++ b/src/modules/market/services/nostrmarketService.ts @@ -1,4 +1,4 @@ -import { finalizeEvent, type EventTemplate, nip04 } from 'nostr-tools' +import { type EventTemplate, nip59 } from 'nostr-tools' import { BaseService } from '@/core/base/BaseService' import type { Order } from '@/modules/market/stores/market' @@ -159,12 +159,17 @@ export class NostrmarketService extends BaseService { // Stall and product publishing is now handled by LNbits API endpoints /** - * Publish an order event (kind 4 encrypted DM) to nostrmarket + * Publish an order as a NIP-59 gift-wrapped (kind 1059) event to nostrmarket. + * + * The order JSON is placed in an unsigned kind 14 rumor, sealed (kind 13) + * with the customer's key, and wrapped (kind 1059) with an ephemeral key. + * Only the merchant can decrypt the wrap; the public event reveals nothing + * about the sender. */ async publishOrder(order: Order, merchantPubkey: string): Promise { const { prvkey } = this.getAuth() - - // Convert order to nostrmarket format - exactly matching the specification + + // Convert order to nostrmarket format - matches NIP-15 customer order spec const orderData = { type: 0, // DirectMessageType.CUSTOMER_ORDER id: order.id, @@ -175,72 +180,37 @@ export class NostrmarketService extends BaseService { contact: { name: order.contactInfo?.message || order.contactInfo?.email || 'Unknown', email: order.contactInfo?.email || '' - // Remove phone field - not in nostrmarket specification }, - // Only include address if it's a physical good and address is provided ...(order.shippingZone?.requiresPhysicalShipping && order.contactInfo?.address ? { address: order.contactInfo.address } : {}), shipping_id: order.shippingZone?.id || 'online' } - // Encrypt the message using NIP-04 - console.log('πŸ” NIP-04 encryption debug:', { - prvkeyType: typeof prvkey, - prvkeyIsString: typeof prvkey === 'string', - prvkeyLength: prvkey.length, - prvkeySample: prvkey.substring(0, 10) + '...', - merchantPubkeyType: typeof merchantPubkey, - merchantPubkeyLength: merchantPubkey.length, - orderDataString: JSON.stringify(orderData).substring(0, 50) + '...' - }) - - let encryptedContent: string - try { - encryptedContent = await nip04.encrypt(prvkey, merchantPubkey, JSON.stringify(orderData)) - console.log('πŸ” NIP-04 encryption successful:', { - encryptedContentLength: encryptedContent.length, - encryptedContentSample: encryptedContent.substring(0, 50) + '...' - }) - } catch (error) { - console.error('πŸ” NIP-04 encryption failed:', error) - throw error - } - - const eventTemplate: EventTemplate = { - kind: 4, // Encrypted DM - tags: [['p', merchantPubkey]], // Recipient (merchant) - content: encryptedContent, // Use encrypted content + const rumorTemplate: Partial = { + kind: 14, + tags: [['p', merchantPubkey]], + content: JSON.stringify(orderData), created_at: Math.floor(Date.now() / 1000) } - console.log('πŸ”§ finalizeEvent debug:', { - prvkeyType: typeof prvkey, - prvkeyIsString: typeof prvkey === 'string', - prvkeyLength: prvkey.length, - prvkeySample: prvkey.substring(0, 10) + '...', - encodedPrvkeyType: typeof new TextEncoder().encode(prvkey), - encodedPrvkeyLength: new TextEncoder().encode(prvkey).length, - eventTemplate - }) - - // Convert hex string to Uint8Array properly const prvkeyBytes = this.hexToUint8Array(prvkey) - console.log('πŸ”§ prvkeyBytes debug:', { - prvkeyBytesType: typeof prvkeyBytes, - prvkeyBytesLength: prvkeyBytes.length, - prvkeyBytesIsUint8Array: prvkeyBytes instanceof Uint8Array + const giftWrap = nip59.wrapEvent(rumorTemplate, prvkeyBytes, merchantPubkey) + + console.log('🎁 Order gift-wrapped (NIP-17):', { + orderId: order.id, + giftWrapId: giftWrap.id, + kind: giftWrap.kind, + merchantPubkey: merchantPubkey.substring(0, 10) + '...' }) - const event = finalizeEvent(eventTemplate, prvkeyBytes) - const result = await this.relayHub.publishEvent(event) - + const result = await this.relayHub.publishEvent(giftWrap) + console.log('Order published to nostrmarket:', { orderId: order.id, - eventId: result, + eventId: giftWrap.id, merchantPubkey, - content: orderData, - encryptedContent: encryptedContent.substring(0, 50) + '...' + content: orderData }) return result.success.toString() -- 2.53.0 From a0187a660476a22009dccd1de36b8a01d6fcd044 Mon Sep 17 00:00:00 2001 From: Padreug Date: Sun, 3 May 2026 16:02:06 +0200 Subject: [PATCH 2/2] fix(vite): rewrite to .html when query has dots (JWT tokens) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The dev SPA-fallback plugin used `!req.url.includes('.')` to skip asset requests, which also matched JWT-shaped `?token=hdr.body.sig` query strings β€” so `localhost:5185/?token=...` fell through to the hub `index.html` instead of `market.html`, breaking the hubβ†’standalone auth-relay link. Strip the query before the extension check. Applied to all 7 standalone vite configs. --- vite.activities.config.ts | 7 +++++-- vite.castle.config.ts | 7 +++++-- vite.chat.config.ts | 5 ++++- vite.forum.config.ts | 5 ++++- vite.market.config.ts | 5 ++++- vite.tasks.config.ts | 5 ++++- vite.wallet.config.ts | 5 ++++- 7 files changed, 30 insertions(+), 9 deletions(-) diff --git a/vite.activities.config.ts b/vite.activities.config.ts index aea9915..b7627e4 100644 --- a/vite.activities.config.ts +++ b/vite.activities.config.ts @@ -15,13 +15,16 @@ function activitiesHtmlPlugin(): Plugin { name: 'activities-html-rewrite', configureServer(server) { server.middlewares.use((req, _res, next) => { - // Rewrite all non-asset requests to activities.html + // Rewrite all non-asset requests to activities.html. + // Strip query before checking for an extension β€” JWTs (e.g. ?token=...) + // contain dots and would otherwise get mistaken for an asset request. + const path = req.url ? req.url.split('?')[0] : '' if ( req.url && !req.url.startsWith('/@') && !req.url.startsWith('/src/') && !req.url.startsWith('/node_modules/') && - !req.url.includes('.') // skip files with extensions + !path.includes('.') ) { req.url = '/activities.html' } diff --git a/vite.castle.config.ts b/vite.castle.config.ts index d0916eb..6ec7e0d 100644 --- a/vite.castle.config.ts +++ b/vite.castle.config.ts @@ -15,13 +15,16 @@ function castleHtmlPlugin(): Plugin { name: 'castle-html-rewrite', configureServer(server) { server.middlewares.use((req, _res, next) => { - // Rewrite all non-asset requests to castle.html + // Rewrite all non-asset requests to castle.html. + // Strip query before checking for an extension β€” JWTs (e.g. ?token=...) + // contain dots and would otherwise get mistaken for an asset request. + const path = req.url ? req.url.split('?')[0] : '' if ( req.url && !req.url.startsWith('/@') && !req.url.startsWith('/src/') && !req.url.startsWith('/node_modules/') && - !req.url.includes('.') // skip files with extensions + !path.includes('.') ) { req.url = '/castle.html' } diff --git a/vite.chat.config.ts b/vite.chat.config.ts index 6965d08..c28535f 100644 --- a/vite.chat.config.ts +++ b/vite.chat.config.ts @@ -11,12 +11,15 @@ function chatHtmlPlugin(): Plugin { name: 'chat-html-rewrite', configureServer(server) { server.middlewares.use((req, _res, next) => { + // Strip query before checking for an extension β€” JWTs (e.g. ?token=...) + // contain dots and would otherwise get mistaken for an asset request. + const path = req.url ? req.url.split('?')[0] : '' if ( req.url && !req.url.startsWith('/@') && !req.url.startsWith('/src/') && !req.url.startsWith('/node_modules/') && - !req.url.includes('.') + !path.includes('.') ) { req.url = '/chat.html' } diff --git a/vite.forum.config.ts b/vite.forum.config.ts index 756d5c1..0fdebbe 100644 --- a/vite.forum.config.ts +++ b/vite.forum.config.ts @@ -11,12 +11,15 @@ function forumHtmlPlugin(): Plugin { name: 'forum-html-rewrite', configureServer(server) { server.middlewares.use((req, _res, next) => { + // Strip query before checking for an extension β€” JWTs (e.g. ?token=...) + // contain dots and would otherwise get mistaken for an asset request. + const path = req.url ? req.url.split('?')[0] : '' if ( req.url && !req.url.startsWith('/@') && !req.url.startsWith('/src/') && !req.url.startsWith('/node_modules/') && - !req.url.includes('.') + !path.includes('.') ) { req.url = '/forum.html' } diff --git a/vite.market.config.ts b/vite.market.config.ts index 255d8c0..bf38430 100644 --- a/vite.market.config.ts +++ b/vite.market.config.ts @@ -11,12 +11,15 @@ function marketHtmlPlugin(): Plugin { name: 'market-html-rewrite', configureServer(server) { server.middlewares.use((req, _res, next) => { + // Strip query before checking for an extension β€” JWTs (e.g. ?token=...) + // contain dots and would otherwise get mistaken for an asset request. + const path = req.url ? req.url.split('?')[0] : '' if ( req.url && !req.url.startsWith('/@') && !req.url.startsWith('/src/') && !req.url.startsWith('/node_modules/') && - !req.url.includes('.') + !path.includes('.') ) { req.url = '/market.html' } diff --git a/vite.tasks.config.ts b/vite.tasks.config.ts index 3cb15fd..1edc3e6 100644 --- a/vite.tasks.config.ts +++ b/vite.tasks.config.ts @@ -11,12 +11,15 @@ function tasksHtmlPlugin(): Plugin { name: 'tasks-html-rewrite', configureServer(server) { server.middlewares.use((req, _res, next) => { + // Strip query before checking for an extension β€” JWTs (e.g. ?token=...) + // contain dots and would otherwise get mistaken for an asset request. + const path = req.url ? req.url.split('?')[0] : '' if ( req.url && !req.url.startsWith('/@') && !req.url.startsWith('/src/') && !req.url.startsWith('/node_modules/') && - !req.url.includes('.') + !path.includes('.') ) { req.url = '/tasks.html' } diff --git a/vite.wallet.config.ts b/vite.wallet.config.ts index dfa0bd6..f991672 100644 --- a/vite.wallet.config.ts +++ b/vite.wallet.config.ts @@ -15,12 +15,15 @@ function walletHtmlPlugin(): Plugin { name: 'wallet-html-rewrite', configureServer(server) { server.middlewares.use((req, _res, next) => { + // Strip query before checking for an extension β€” JWTs (e.g. ?token=...) + // contain dots and would otherwise get mistaken for an asset request. + const path = req.url ? req.url.split('?')[0] : '' if ( req.url && !req.url.startsWith('/@') && !req.url.startsWith('/src/') && !req.url.startsWith('/node_modules/') && - !req.url.includes('.') + !path.includes('.') ) { req.url = '/wallet.html' } -- 2.53.0