[auth] Route reactions, blocks, subscriptions & DM send through the signer gate (#54/#57)

Fast-follows to the ensureEncryptionCapableSigner gate: every remaining
signing/encryption flow now re-unlocks a reloaded nsec signer with one passphrase
prompt instead of dead-ending. Each call site short-circuits ONLY on the
user-driven outcomes (cancel / bad-passphrase); every other case falls through to
the flow's own existing error handling, so non-nsec signers (e.g. an unresponsive
NIP-07 extension) behave exactly as before.

- Reactions (#54): ReactionController.handleReaction gates before the optimistic
  update with need:"sign" (kind-7 reactions don't need encryption) — fixes the
  "like/dislike does nothing" that was a rolled-back publish on a lost signer.
- Blocks: handleProfileBlocklistMutation (need:"encrypt", NIP-04 mute/block lists).
- Subscriptions: the channel Subscribe/Unsubscribe toggle (need:"encrypt", kind 30000).
- DM send: both composers — handleSendProfileMessage and handleDmAppShellSendMessage
  (need:"encrypt", NIP-17/NIP-04).

Reactions get the gate via a new ensureSigner callback on ReactionController; the
modal flows use the services.ensureEncryptionCapableSigner already exposed.

Tests: tests/reaction-signer-gate.test.mjs (3 — cancelled/bad-passphrase block
publish, ok proceeds, asserts need:"sign"). Related suites (decision, DM, profile
modal, switch delegators) green. Lint + build green.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
This commit is contained in:
thePR0M3TH3ANandClaude Opus 4.8 committed 2026-07-01 19:23:04 -04:00
1 parent 1b79ab9d09
commit 1622e85d10
7 files changed
+243 -8

No files matched your search

@@ -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()