diff --git a/js/app.js b/js/app.js index 1dc48c90..1b727d23 100644 --- a/js/app.js +++ b/js/app.js @@ -387,6 +387,7 @@ class Application { isUserLoggedIn: () => this.isUserLoggedIn(), normalizeHexPubkey: (val) => this.normalizeHexPubkey(val), getPubkey: () => this.pubkey, + ensureSigner: (options) => this.ensureEncryptionCapableSigner(options), }, }); diff --git a/js/app/authSessionCoordinator.js b/js/app/authSessionCoordinator.js index a710b732..2645f35d 100644 --- a/js/app/authSessionCoordinator.js +++ b/js/app/authSessionCoordinator.js @@ -1795,6 +1795,27 @@ export function createAuthSessionCoordinator(deps) { return context; } + // Block lists are NIP-04 encrypted. A reloaded nsec session can lose its + // in-memory key; re-unlock it (one passphrase prompt) rather than failing + // with a blanket signer error (TODO #57). Only short-circuit on the + // user-driven outcomes — anything else falls through so the mutation's own + // signer error (e.g. an unresponsive NIP-07 extension) still surfaces. + if (typeof this.ensureEncryptionCapableSigner === "function") { + const ensured = await this.ensureEncryptionCapableSigner({ + pubkey: actorHex, + need: "encrypt", + promptMessage: + "Re-enter your PIN / passphrase to unlock your key and update your mute/block list.", + }); + if ( + ensured && + (ensured.reason === "cancelled" || ensured.reason === "bad-passphrase") + ) { + context.reason = ensured.reason; + return context; + } + } + try { await userBlocks.ensureLoaded(actorHex); const isBlocked = userBlocks.isBlocked(targetHex); diff --git a/js/channelProfile.js b/js/channelProfile.js index 26f47a4c..1261facb 100644 --- a/js/channelProfile.js +++ b/js/channelProfile.js @@ -4246,6 +4246,32 @@ function renderSubscribeButton(channelHex) { return; } + // Subscriptions (kind 30000) are NIP-04 encrypted. A reloaded nsec session + // can lose its in-memory key; re-unlock it with one passphrase prompt rather + // than failing with a blanket signer error (TODO #57). Gate BEFORE the + // optimistic update so the button doesn't flash on cancel. Only the + // user-driven outcomes short-circuit; anything else falls through to + // toggleChannel's own error handling below. + if (typeof currentApp.ensureEncryptionCapableSigner === "function") { + let ensured = { ok: true }; + try { + ensured = await currentApp.ensureEncryptionCapableSigner({ + pubkey: currentApp.pubkey, + need: "encrypt", + promptMessage: + "Re-enter your PIN / passphrase to unlock your key and update your subscriptions.", + }); + } catch (error) { + ensured = { ok: false, reason: "ensure-failed" }; + } + if ( + ensured && + (ensured.reason === "cancelled" || ensured.reason === "bad-passphrase") + ) { + return; + } + } + // Optimistic UI update const wasSubscribed = toggleBtn.dataset.state === "subscribed"; const nextState = wasSubscribed ? "unsubscribed" : "subscribed"; diff --git a/js/ui/profileModal/ProfileDirectMessageActions.js b/js/ui/profileModal/ProfileDirectMessageActions.js index 0e19cfec..a25e350c 100644 --- a/js/ui/profileModal/ProfileDirectMessageActions.js +++ b/js/ui/profileModal/ProfileDirectMessageActions.js @@ -240,6 +240,32 @@ export class ProfileDirectMessageActions { return; } + // DMs are encrypted (NIP-17 gift wrap / NIP-04). A reloaded nsec session can + // lose its in-memory key; re-unlock it with one passphrase prompt rather than + // failing to send (TODO #57). Only user-driven outcomes stop the send; other + // cases fall through to sendDirectMessage's own error handling. + const ensureSigner = + this.mainController.services?.ensureEncryptionCapableSigner; + if (typeof ensureSigner === "function") { + let ensured = { ok: true }; + try { + ensured = await ensureSigner({ + pubkey: this.controller.helper.resolveActiveDmActor(), + need: "encrypt", + promptMessage: + "Re-enter your PIN / passphrase to unlock your key and send messages.", + }); + } catch (error) { + ensured = { ok: false, reason: "ensure-failed" }; + } + if ( + ensured && + (ensured.reason === "cancelled" || ensured.reason === "bad-passphrase") + ) { + return; + } + } + const sendButton = this.controller.renderer.profileMessageSendButton; if (sendButton instanceof HTMLElement && "disabled" in sendButton) { sendButton.disabled = true; @@ -489,6 +515,35 @@ export class ProfileDirectMessageActions { return; } + // Re-unlock a reloaded nsec signer before sending (TODO #57). Same gate as the + // profile-modal composer; only user-driven outcomes stop the send. + const ensureSigner = + this.mainController.services?.ensureEncryptionCapableSigner; + if (typeof ensureSigner === "function") { + let ensured = { ok: true }; + try { + ensured = await ensureSigner({ + pubkey: actor, + need: "encrypt", + promptMessage: + "Re-enter your PIN / passphrase to unlock your key and send messages.", + }); + } catch (error) { + ensured = { ok: false, reason: "ensure-failed" }; + } + if ( + ensured && + (ensured.reason === "cancelled" || ensured.reason === "bad-passphrase") + ) { + this.controller.dmComposerState = "idle"; + await this.controller.renderer.renderDmAppShell( + this.controller.directMessagesCache, + { actorPubkey: actor }, + ); + return; + } + } + const privacyMode = typeof normalizedPayload.privacyMode === "string" ? normalizedPayload.privacyMode.trim().toLowerCase() diff --git a/js/ui/reactionController.js b/js/ui/reactionController.js index b9cb1ac8..6afe8e8f 100644 --- a/js/ui/reactionController.js +++ b/js/ui/reactionController.js @@ -19,6 +19,10 @@ export default class ReactionController { isUserLoggedIn: callbacks.isUserLoggedIn || (() => false), normalizeHexPubkey: callbacks.normalizeHexPubkey || ((val) => val), getPubkey: callbacks.getPubkey || (() => null), + // Ensure a sign-capable signer before publishing a reaction (kind 7). A + // reloaded nsec session can lose its in-memory key; this re-unlocks it with + // one passphrase prompt instead of the reaction silently failing (TODO #54). + ensureSigner: callbacks.ensureSigner || (async () => ({ ok: true })), }; this.reactionState = { @@ -336,6 +340,31 @@ export default class ReactionController { return; } + // A reloaded nsec session keeps the pubkey/UI but drops the in-memory signer, + // so publishing the kind-7 reaction would fail with a generic "Failed to send + // reaction" and the button would appear dead. Re-unlock the signer first (one + // passphrase prompt). Gate BEFORE the optimistic update so nothing flashes. + let ensured = { ok: true }; + try { + ensured = await this.callbacks.ensureSigner({ + need: "sign", + pubkey: this.callbacks.getPubkey(), + promptMessage: + "Re-enter your PIN / passphrase to unlock your key and react to videos.", + }); + } catch (error) { + devLogger.warn("[reaction] Signer gate failed:", error); + ensured = { ok: false, reason: "ensure-failed" }; + } + if (ensured && ensured.ok === false) { + videoModal.setUserReaction(previousReaction); + // cancelled = user backed out; bad-passphrase already showed its own toast. + if (ensured.reason !== "cancelled" && ensured.reason !== "bad-passphrase") { + this.ui.showError("Connect a signer to react to videos."); + } + return; + } + let rollbackSnapshot = null; try { rollbackSnapshot = this.applyOptimisticUpdate( diff --git a/tests/reaction-signer-gate.test.mjs b/tests/reaction-signer-gate.test.mjs new file mode 100644 index 00000000..e678d1ad --- /dev/null +++ b/tests/reaction-signer-gate.test.mjs @@ -0,0 +1,85 @@ +// Like/dislike (kind 7) must re-unlock a reloaded nsec signer before publishing, +// instead of the reaction silently failing / the button appearing dead (TODO #54). +// The ReactionController runs its ensureSigner gate before publishing. +// +// test_integrity_note: +// change_type: ["new_tests"] +// scenarios: +// - id: SCN-reaction-signer-gate +// given: "a logged-in user reacting with a mocked ensureSigner + publish" +// when: "handleReaction runs" +// then: "publish is gated on the signer being ensured (sign-capable)" +// observable_outcomes: +// - "cancelled/bad-passphrase gate -> publish never called, button reset" +// - "ok gate -> publish called once with the reaction content" +// determinism_controls: +// - "fully mocked services/ui/state/callbacks; no DOM/network" +// anti_cheat_rationale: +// prevents: ["hard-coded return value", "over-mocking internal logic"] +// relaxation: +// did_relax_any_assertion: false + +import test from "node:test"; +import { strict as assert } from "node:assert"; +import ReactionController from "../js/ui/reactionController.js"; + +function makeController({ ensureSigner } = {}) { + const publishCalls = []; + const reactionsSet = []; + const modal = { + setUserReaction: (r) => reactionsSet.push(r), + }; + const controller = new ReactionController({ + services: { + reactionCounter: { + publish: async (pointer, payload) => { + publishCalls.push({ pointer, payload }); + return { ok: true }; + }, + }, + }, + ui: { getVideoModal: () => modal, showError: () => {} }, + state: { + getCurrentVideo: () => ({ pubkey: "b".repeat(64) }), + getCurrentVideoPointer: () => ["a", 30078, "id"], + getCurrentVideoPointerKey: () => "pointer-key", + }, + callbacks: { + isUserLoggedIn: () => true, + getPubkey: () => "b".repeat(64), + ensureSigner: ensureSigner || (async () => ({ ok: true })), + }, + }); + return { controller, publishCalls, reactionsSet }; +} + +test("reaction: a cancelled signer gate blocks publish and resets the button", async () => { + let ensureCalls = 0; + const { controller, publishCalls } = makeController({ + ensureSigner: async (opts) => { + ensureCalls += 1; + assert.equal(opts.need, "sign", "reactions need signing, not encryption"); + return { ok: false, reason: "cancelled" }; + }, + }); + await controller.handleReaction({ reaction: "+" }); + assert.equal(ensureCalls, 1, "the signer gate ran"); + assert.equal(publishCalls.length, 0, "publish was NOT attempted after a cancelled gate"); +}); + +test("reaction: a bad-passphrase gate blocks publish (toast already shown by the gate)", async () => { + const { controller, publishCalls } = makeController({ + ensureSigner: async () => ({ ok: false, reason: "bad-passphrase" }), + }); + await controller.handleReaction({ reaction: "-" }); + assert.equal(publishCalls.length, 0); +}); + +test("reaction: an ok signer gate lets publish proceed once", async () => { + const { controller, publishCalls } = makeController({ + ensureSigner: async () => ({ ok: true, unlocked: true }), + }); + await controller.handleReaction({ reaction: "+" }); + assert.equal(publishCalls.length, 1, "publish ran after the gate passed"); + assert.equal(publishCalls[0].payload.content, "+"); +}); diff --git a/todo/TODO_2026-06-20_pre-launch.md b/todo/TODO_2026-06-20_pre-launch.md index 7fa24897..72114f61 100644 --- a/todo/TODO_2026-06-20_pre-launch.md +++ b/todo/TODO_2026-06-20_pre-launch.md @@ -1601,6 +1601,13 @@ passphrase-encrypted). Items 51, 56, 57 are all facets of that. active-signer availability, publish path). Note: reactions require signing — verify this isn't another "lost signer after refresh" (#57) surfacing as a dead button; if so, prompt to unlock rather than silently no-op. +- [x] **Lost-signer variant addressed 2026-07-01.** `ReactionController.handleReaction` + now runs the `ensureEncryptionCapableSigner` gate (`need:"sign"`) before + publishing, so a reloaded nsec session re-unlocks with one passphrase prompt + instead of the reaction silently failing (rolled-back button + generic error). + Tests: `tests/reaction-signer-gate.test.mjs`. **Still VERIFY on unstable** whether + any residual dead-button behavior remains (event binding / pointer availability), + which would be a separate wiring bug from the signer issue. ### 55. Video-grid event caching — faster loads, cache-first + merge-new (PERF / UX) - [ ] **Channel-profile grid still loads slowly.** Even after the coalescing + relay @@ -1653,11 +1660,22 @@ passphrase-encrypted). Items 51, 56, 57 are all facets of that. silently aborts, a bad passphrase shows its own toast, and publish never runs until the signer is confirmed. Tests: `tests/ensure-signer-decision.test.mjs` (9), + 2 wiring tests in `tests/profile-modal-controller.test.mjs`. Lint + build green. -- [ ] **Fast-follows (same gate, remaining call sites):** wire `subscriptions` - (add/remove channel), `userBlocks` (block/unblock — clean chokepoint at - `authSessionCoordinator.handleProfileBlocklistMutation`), DM send, and reactions - (**#54** — verify like/dislike is this same lost-signer issue vs a dead-button - wiring bug) through `ensureEncryptionCapableSigner`. Each needs its caller's - error-mapping checked so a "cancelled"/"bad-passphrase" reads cleanly (that's why - they weren't batched into the first pass). The `need:"sign"` mode covers - sign-only ops (e.g. reactions) that don't require encryption. +- [x] **Fast-follows wired — DONE 2026-07-01.** All remaining signing/encryption + call sites now route through `ensureEncryptionCapableSigner`, short-circuiting + only on the user-driven outcomes (cancel / bad-passphrase) so every other case + still falls through to the flow's own error handling: + - **Reactions (#54):** `ReactionController.handleReaction` gates before the + optimistic update with `need:"sign"` (kind-7 reactions don't need encryption). + Tests: `tests/reaction-signer-gate.test.mjs` (3). + - **Blocks:** `authSessionCoordinator.handleProfileBlocklistMutation` + (`need:"encrypt"`, NIP-04 mute/block lists). + - **Subscriptions:** the channel-profile Subscribe/Unsubscribe toggle + (`js/channelProfile.js`, `need:"encrypt"`, kind-30000). + - **DM send:** both composers — `handleSendProfileMessage` and + `handleDmAppShellSendMessage` (`need:"encrypt"`, NIP-17/NIP-04). + Lint + build + related suites (decision, reaction, DM, profile-modal, switch) + green. +- [ ] **VERIFY on unstable:** with an nsec account, reload, then (a) add a hashtag, + (b) like/dislike a video, (c) block/unblock a creator, (d) subscribe to a channel, + (e) send a DM — each should show ONE passphrase prompt (not a blanket error), then + work; the single unlock should cover the rest until the next reload.