From ebd8cef8cd5a9616a3a38f1379516502c724c05f Mon Sep 17 00:00:00 2001 From: Padreug Date: Wed, 3 Jun 2026 18:48:42 +0200 Subject: [PATCH] feat(base): phase-2 bucket-B migration via signEventViaLnbits MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- src/lib/nostr/signing.ts | 108 ++++++++ .../activities/composables/useBookmarks.ts | 21 +- src/modules/activities/composables/useRSVP.ts | 21 +- .../base/services/NostrTransportService.ts | 231 ++---------------- src/modules/market/composables/useMarket.ts | 65 ++--- .../nostr-feed/components/NostrFeed.vue | 21 +- 6 files changed, 175 insertions(+), 292 deletions(-) create mode 100644 src/lib/nostr/signing.ts diff --git a/src/lib/nostr/signing.ts b/src/lib/nostr/signing.ts new file mode 100644 index 0000000..dda7315 --- /dev/null +++ b/src/lib/nostr/signing.ts @@ -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 ). 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 b427a28..6106953 100644 --- a/src/modules/activities/composables/useBookmarks.ts +++ b/src/modules/activities/composables/useBookmarks.ts @@ -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(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 -} diff --git a/src/modules/activities/composables/useRSVP.ts b/src/modules/activities/composables/useRSVP.ts index 337a7ec..e3d14ab 100644 --- a/src/modules/activities/composables/useRSVP.ts +++ b/src/modules/activities/composables/useRSVP.ts @@ -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 { - 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(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 -} diff --git a/src/modules/base/services/NostrTransportService.ts b/src/modules/base/services/NostrTransportService.ts index 8ce4974..32b7a4d 100644 --- a/src/modules/base/services/NostrTransportService.ts +++ b/src/modules/base/services/NostrTransportService.ts @@ -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 - 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) @@ -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 { - 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)}…`) - } - } - - /** - * 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 = {}, - ): Promise { - 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', + this.debug( + `Initialized with server pubkey ${LNBITS_CONFIG.NOSTR_TRANSPORT_PUBKEY.slice(0, 16)}… ` + + '(phase 1/2: call() is disabled per Q4.2)', ) } - 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, + async call( + _rpcName: string, + _body: Record, + _options: RpcCallOptions = {}, ): 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')) - } - }, - }) - }) + 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).', + ) } } - -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 62c66da..090e8ef 100644 --- a/src/modules/market/composables/useMarket.ts +++ b/src/modules/market/composables/useMarket.ts @@ -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 diff --git a/src/modules/nostr-feed/components/NostrFeed.vue b/src/modules/nostr-feed/components/NostrFeed.vue index ab75da0..ce7350d 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 { 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)