feat(base): phase-2 bucket-B migration via signEventViaLnbits #88
6 changed files with 174 additions and 291 deletions
feat(base): phase-2 bucket-B migration via signEventViaLnbits
Closes the build-fail interval opened by PR #84 (User.prvkey field removal). Adds the uniform signEventViaLnbits() helper per design-questions Q3.3 and migrates the 5 compile-failing sites the prvkey removal exposed. New helper at src/lib/nostr/signing.ts: - POST /api/v1/auth/sign-event with Bearer auth + credentials:include - Lazy CSRF token fetch + cache; one-shot refresh on 403-with-CSRF - Returns the fully-signed event for caller to publish Site migrations: - activities/composables/useBookmarks.ts (kind 10003) — drop finalizeEvent - activities/composables/useRSVP.ts (kind 31925) — drop finalizeEvent - nostr-feed/components/NostrFeed.vue (kind 5 deletion) — drop finalizeEvent - base/services/NostrTransportService.ts (kind 21000 RPC bootstrap) — call() now throws "deferred to phase 3+ per Q4.2/Q4.3"; scaffolding retained for the eventual transport revival - market/composables/useMarket.ts (NIP-59 gift-wrap unwrap) — disabled with a console.warn; no server-routed nip44_decrypt endpoint exists yet (Bucket C territory) Known regression intentionally accepted: incoming order-DM gift-wrap processing in the marketplace is non-functional until phase-3 adds NIP-44 decrypt over HTTP/bunker. Per design-questions §"Open questions deferred", marketplace order receipt routes through nostrmarket server-side anyway; this client-side path was a redundant fast-path. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
commit
ebd8cef8cd
108
src/lib/nostr/signing.ts
Normal file
108
src/lib/nostr/signing.ts
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
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 <token>). 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<string> {
|
||||
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<string> {
|
||||
if (!forceRefresh && cachedCsrfToken) return cachedCsrfToken
|
||||
cachedCsrfToken = await fetchCsrfToken()
|
||||
return cachedCsrfToken
|
||||
}
|
||||
|
||||
function getAuthHeaders(): Record<string, string> {
|
||||
const token = getAuthToken()
|
||||
return token ? { Authorization: `Bearer ${token}` } : {}
|
||||
}
|
||||
|
||||
async function signOnce(
|
||||
template: EventTemplate,
|
||||
csrfToken: string,
|
||||
): Promise<Response> {
|
||||
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<NostrEvent> {
|
||||
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
|
||||
}
|
||||
|
|
@ -1,7 +1,8 @@
|
|||
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||
import { finalizeEvent, type EventTemplate, type Event as NostrEvent } from 'nostr-tools'
|
||||
import type { EventTemplate, 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.
|
||||
|
|
@ -89,7 +90,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?.prvkey) return
|
||||
if (!isAuthenticated.value || !currentUser.value?.pubkey) return
|
||||
|
||||
const coord = `${activityKind}:${pubkey}:${dTag}`
|
||||
const newCoords = new Set(state.value.bookmarkedCoords)
|
||||
|
|
@ -110,8 +111,13 @@ export function useBookmarks() {
|
|||
tags,
|
||||
}
|
||||
|
||||
const signingKey = hexToUint8Array(currentUser.value.prvkey)
|
||||
const signedEvent = finalizeEvent(template, signingKey)
|
||||
let signedEvent: NostrEvent
|
||||
try {
|
||||
signedEvent = await signEventViaLnbits(template)
|
||||
} catch (err) {
|
||||
console.error('[useBookmarks] signEventViaLnbits failed:', err)
|
||||
return
|
||||
}
|
||||
|
||||
const relayHub = tryInjectService<any>(SERVICE_TOKENS.RELAY_HUB)
|
||||
if (!relayHub) return
|
||||
|
|
@ -147,10 +153,3 @@ 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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
import { ref, onMounted, onUnmounted } from 'vue'
|
||||
import { finalizeEvent, type EventTemplate, type Event as NostrEvent } from 'nostr-tools'
|
||||
import type { EventTemplate, 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'
|
||||
|
||||
/**
|
||||
|
|
@ -150,7 +151,7 @@ export function useRSVP() {
|
|||
activityDTag: string,
|
||||
status: RSVPStatus
|
||||
): Promise<RSVPStatus | null> {
|
||||
if (!isAuthenticated.value || !currentUser.value?.prvkey) return null
|
||||
if (!isAuthenticated.value || !currentUser.value?.pubkey) return null
|
||||
|
||||
const coord = `${activityKind}:${activityPubkey}:${activityDTag}`
|
||||
|
||||
|
|
@ -184,8 +185,13 @@ export function useRSVP() {
|
|||
],
|
||||
}
|
||||
|
||||
const signingKey = hexToUint8Array(currentUser.value.prvkey)
|
||||
const signedEvent = finalizeEvent(template, signingKey)
|
||||
let signedEvent: NostrEvent
|
||||
try {
|
||||
signedEvent = await signEventViaLnbits(template)
|
||||
} catch (err) {
|
||||
console.error('[useRSVP] signEventViaLnbits failed:', err)
|
||||
return null
|
||||
}
|
||||
|
||||
const relayHub = tryInjectService<any>(SERVICE_TOKENS.RELAY_HUB)
|
||||
if (!relayHub) return null
|
||||
|
|
@ -240,10 +246,3 @@ 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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,58 +1,32 @@
|
|||
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).
|
||||
*
|
||||
* 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.
|
||||
* **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).
|
||||
*
|
||||
* 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.
|
||||
* 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.
|
||||
*/
|
||||
|
||||
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<string, unknown>
|
||||
query?: Record<string, unknown>
|
||||
}
|
||||
|
||||
interface RpcResponseEnvelope<T = unknown> {
|
||||
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)
|
||||
|
|
@ -67,179 +41,28 @@ 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<void> {
|
||||
if (!this.serverPubkey) {
|
||||
if (!LNBITS_CONFIG.NOSTR_TRANSPORT_PUBKEY) {
|
||||
this.debug(
|
||||
'No VITE_LNBITS_NOSTR_TRANSPORT_PUBKEY configured — RPC calls will fail',
|
||||
)
|
||||
} else {
|
||||
this.debug(`Initialized with server pubkey ${this.serverPubkey.slice(0, 16)}…`)
|
||||
this.debug(
|
||||
`Initialized with server pubkey ${LNBITS_CONFIG.NOSTR_TRANSPORT_PUBKEY.slice(0, 16)}… ` +
|
||||
'(phase 1/2: call() is disabled per Q4.2)',
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<T = unknown>(
|
||||
rpcName: string,
|
||||
body: Record<string, unknown>,
|
||||
options: RpcCallOptions = {},
|
||||
_rpcName: string,
|
||||
_body: Record<string, unknown>,
|
||||
_options: RpcCallOptions = {},
|
||||
): Promise<T> {
|
||||
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',
|
||||
'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 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<T>(
|
||||
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<T>(
|
||||
requestId: string,
|
||||
clientPubkey: string,
|
||||
conversationKey: Uint8Array,
|
||||
timeoutMs: number,
|
||||
): Promise<T> {
|
||||
const hub = this.relayHub as RelayHub
|
||||
const shards = new Map<string, Map<number, string>>()
|
||||
const shardsExpected = new Map<string, number>()
|
||||
let unsubscribe: (() => void) | null = null
|
||||
|
||||
return new Promise<T>((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<ShardWrapper>
|
||||
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<number, string>()
|
||||
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<T>
|
||||
try {
|
||||
response = JSON.parse(fullText) as RpcResponseEnvelope<T>
|
||||
} 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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,8 +2,6 @@ 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'
|
||||
|
||||
|
|
@ -44,7 +42,6 @@ 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.')
|
||||
|
|
@ -433,56 +430,24 @@ 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.
|
||||
//
|
||||
// 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).
|
||||
// **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.
|
||||
const handleOrderDM = async (event: any) => {
|
||||
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)
|
||||
}
|
||||
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,
|
||||
)
|
||||
}
|
||||
|
||||
// Handle incoming market events
|
||||
|
|
|
|||
|
|
@ -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 { finalizeEvent } from 'nostr-tools'
|
||||
import { signEventViaLnbits } from '@/lib/nostr/signing'
|
||||
import { useToast } from '@/core/composables/useToast'
|
||||
|
||||
interface Emits {
|
||||
|
|
@ -366,15 +366,6 @@ 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) {
|
||||
|
|
@ -405,9 +396,8 @@ async function confirmDeletePost() {
|
|||
return
|
||||
}
|
||||
|
||||
const userPrivkey = authService?.user.value?.prvkey
|
||||
if (!userPrivkey) {
|
||||
toast.error("User private key not available") // pragma: allowlist secret
|
||||
if (!authService?.user.value?.pubkey) {
|
||||
toast.error("Not signed in")
|
||||
showDeleteDialog.value = false
|
||||
postToDelete.value = null
|
||||
return
|
||||
|
|
@ -425,9 +415,8 @@ async function confirmDeletePost() {
|
|||
created_at: Math.floor(Date.now() / 1000)
|
||||
}
|
||||
|
||||
// Sign the deletion event
|
||||
const privkeyBytes = hexToUint8Array(userPrivkey)
|
||||
const signedEvent = finalizeEvent(deletionEvent, privkeyBytes)
|
||||
// Sign the deletion event server-side via lnbits.
|
||||
const signedEvent = await signEventViaLnbits(deletionEvent)
|
||||
|
||||
// Publish the deletion request
|
||||
const result = await relayHub.publishEvent(signedEvent)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue