From 98dd51509dabbbbfba4d318daadce4019e5fa350 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Wed, 8 Oct 2025 22:27:11 -0400 Subject: [PATCH] Add session fallback signing to nostr publish flow --- js/nostr.js | 82 +++++++++++++++++++++++++++++++- tests/view-counter.test.mjs | 94 +++++++++++++++++++++++++++++++++++++ 2 files changed, 174 insertions(+), 2 deletions(-) diff --git a/js/nostr.js b/js/nostr.js index 4e380719..f142bda2 100644 --- a/js/nostr.js +++ b/js/nostr.js @@ -5293,7 +5293,85 @@ export class NostrClient { rejectionLogLevel = "error", } = {} ) { - const signedEvent = await window.nostr.signEvent(event); + const extension = window?.nostr; + const extensionSigner = + extension && typeof extension.signEvent === "function" + ? extension.signEvent.bind(extension) + : null; + + const normalizedEventPubkey = + event && typeof event.pubkey === "string" + ? event.pubkey.toLowerCase() + : ""; + const normalizedLogged = + typeof this.pubkey === "string" ? this.pubkey.toLowerCase() : ""; + const usingSessionActor = + normalizedEventPubkey && + normalizedLogged && + normalizedEventPubkey !== normalizedLogged; + + let eventToSign = event; + let signedEvent = null; + let signerPubkey = null; + + if ( + extensionSigner && + normalizedEventPubkey && + normalizedEventPubkey === normalizedLogged + ) { + signedEvent = await extensionSigner(event); + } else { + try { + const currentSessionPubkey = + typeof this.sessionActor?.pubkey === "string" + ? this.sessionActor.pubkey.toLowerCase() + : ""; + + if ( + usingSessionActor && + normalizedEventPubkey && + normalizedEventPubkey !== currentSessionPubkey + ) { + await this.ensureSessionActor(true); + } else { + await this.ensureSessionActor(); + } + + const sessionActor = this.sessionActor; + if ( + !sessionActor || + typeof sessionActor.pubkey !== "string" || + !sessionActor.pubkey || + typeof sessionActor.privateKey !== "string" || + !sessionActor.privateKey + ) { + throw new Error("session-actor-unavailable"); + } + + const normalizedSessionPubkey = sessionActor.pubkey.toLowerCase(); + if ( + !normalizedEventPubkey || + normalizedEventPubkey !== normalizedSessionPubkey || + event.pubkey !== sessionActor.pubkey + ) { + eventToSign = { ...event, pubkey: sessionActor.pubkey }; + } + + signedEvent = signEventWithPrivateKey( + eventToSign, + sessionActor.privateKey + ); + signerPubkey = sessionActor.pubkey; + } catch (error) { + console.warn("[nostr] Failed to sign event with session key:", error); + throw error; + } + } + + if (!signerPubkey && signedEvent && typeof signedEvent.pubkey === "string") { + signerPubkey = signedEvent.pubkey; + } + if (isDevMode) { console.log(`Signed ${devLogLabel} event:`, signedEvent); } @@ -5343,7 +5421,7 @@ export class NostrClient { }); } - return { signedEvent, summary: publishSummary }; + return { signedEvent, summary: publishSummary, signerPubkey }; } async publishVideo(videoPayload, pubkey) { diff --git a/tests/view-counter.test.mjs b/tests/view-counter.test.mjs index 0b0d7d18..788895fe 100644 --- a/tests/view-counter.test.mjs +++ b/tests/view-counter.test.mjs @@ -722,6 +722,99 @@ async function testRecordVideoViewEmitsJsonPayload() { ); } +async function testSignAndPublishFallbackUsesSessionActor() { + const originalExtension = window.nostr; + const originalPool = nostrClient.pool; + const originalRelays = nostrClient.relays; + const originalPubkey = nostrClient.pubkey; + const originalSessionActor = nostrClient.sessionActor; + const originalEnsureSessionActor = nostrClient.ensureSessionActor; + const ensureCalls = []; + const publishCalls = []; + + try { + delete window.nostr; + + nostrClient.pool = { + publish(urls, event) { + publishCalls.push({ urls, event }); + return { + on(eventName, handler) { + if (eventName === "ok") { + handler(); + } + return true; + }, + }; + }, + }; + nostrClient.relays = ["wss://relay.fallback"]; + nostrClient.pubkey = "logged-user"; + nostrClient.sessionActor = { + pubkey: "session-actor", + privateKey: "session-private", + }; + nostrClient.ensureSessionActor = async (forceRenew = false) => { + ensureCalls.push(forceRenew === true); + return nostrClient.sessionActor.pubkey; + }; + + const event = { + kind: 1, + pubkey: "session-actor", + created_at: 123, + tags: [], + content: "hello fallback", + }; + + const result = await nostrClient.signAndPublishEvent(event, { + context: "fallback", + logName: "fallback", + }); + + assert.ok(result, "expected a publish result when falling back to the session actor"); + assert.equal( + result.signedEvent.pubkey, + "session-actor", + "fallback signing should use the session actor pubkey", + ); + assert.equal( + result.signerPubkey, + "session-actor", + "signer pubkey should reflect the session actor when falling back", + ); + assert.ok( + ensureCalls.length > 0, + "fallback signing should attempt to ensure the session actor", + ); + assert.ok( + Array.isArray(result.summary?.accepted) && result.summary.accepted.length > 0, + "fallback publish should report accepted relays", + ); + assert.equal( + publishCalls.length, + result.summary.accepted.length, + "each accepted relay should correspond to a publish attempt", + ); + assert.equal( + result.summary.accepted[0]?.url, + "wss://relay.fallback", + "publish summary should surface the accepting relay", + ); + } finally { + if (typeof originalExtension === "undefined") { + delete window.nostr; + } else { + window.nostr = originalExtension; + } + nostrClient.pool = originalPool; + nostrClient.relays = originalRelays; + nostrClient.pubkey = originalPubkey; + nostrClient.sessionActor = originalSessionActor; + nostrClient.ensureSessionActor = originalEnsureSessionActor; + } +} + // Cached totals older than VIEW_COUNT_CACHE_TTL_MS should be discarded and rehydrated from relays. async function testHydrationRefreshesAfterCacheTtl() { harness.reset(); @@ -947,6 +1040,7 @@ await testLocalIngestNotifiesImmediately(); await testUnsubscribeStopsCallbacks(); await testRelayCountAggregationUsesBestEstimate(); await testRecordVideoViewEmitsJsonPayload(); +await testSignAndPublishFallbackUsesSessionActor(); await testHydrateHistoryPrefersRootEvent(); await testModalPostedTimestampStaysInSync();