[auth] Re-unlock the signer on demand instead of blanket-erroring encryption ops (#57)

A persisted nsec session restores the pubkey + UI on reload but NOT the in-memory
signer (the private key is only passphrase-encrypted — TODO #36). So after a refresh,
adding/editing a hashtag on an nsec account dead-ended with "Connect a Nostr signer
that supports encryption before managing hashtag preferences" — even though the user
looks logged in. Same class blocks subscriptions/blocks/DMs/reactions.

New reusable gate app.ensureEncryptionCapableSigner({ pubkey, need, promptMessage }):
checks the active signer's capabilities; if it can't encrypt/sign AND a MATCHING
locked nsec key exists for the account, prompts once for the passphrase and re-unlocks
the signer app-wide via unlockStoredSessionActor. One unlock re-enables every signing
flow until the next reload. Never unlocks a different account's key (pubkey-mismatch
guard); "no-stored-key" leaves non-nsec cases (e.g. an unresponsive NIP-07 extension)
to the caller's own error. Branching lives in js/nostr/ensureSignerDecision.js (pure,
unit-testable without the app shell).

Wired hashtag add/edit through it (ProfileHashtagController.persistHashtagPreferences):
a cancelled prompt aborts silently, a bad passphrase shows its own toast, and publish
never runs until the signer is confirmed. Exposed to the modal as
services.ensureEncryptionCapableSigner.

This is the shared machinery for the whole reloaded-nsec cluster (#51/#54/#56/#57);
subscriptions/blocks/DM-send/reactions are mechanical fast-follows against the same
gate (tracked in TODO #57).

Tests: tests/ensure-signer-decision.test.mjs (9 — capability + recoverable/fail
branching, never-wrong-key), + 2 wiring tests (gate blocks publish until ok) in
tests/profile-modal-controller.test.mjs. 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:11:19 -04:00
1 parent 633e946325
commit 1b79ab9d09
7 files changed
+442

No files matched your search

@@ -336,6 +336,45 @@ export class ProfileHashtagController {
const payload = normalizedPubkey ? { pubkey: normalizedPubkey } : {};
// Managing hashtags needs an encryption-capable signer. If a reloaded nsec
// session lost its in-memory key, re-unlock it (one passphrase prompt) instead
// of dead-ending with "connect a signer that supports encryption" (TODO #57).
const ensureSigner =
this.mainController.services?.ensureEncryptionCapableSigner;
if (typeof ensureSigner === "function") {
let ensured = { ok: true };
try {
ensured = await ensureSigner({
pubkey: normalizedPubkey,
need: "encrypt",
promptMessage:
"Re-enter your PIN / passphrase to unlock your key and manage hashtag preferences.",
});
} catch (error) {
ensured = { ok: false, reason: "ensure-failed", error };
}
if (!ensured?.ok) {
// "bad-passphrase" already showed its own toast; "cancelled" is a silent
// user choice. Only surface the generic hint for other reasons.
if (ensured?.reason !== "cancelled" && ensured?.reason !== "bad-passphrase") {
const message = this.describeHashtagPreferencesError(
Object.assign(new Error("Signer unavailable."), {
code: "hashtag-preferences-missing-signer",
}),
);
if (message) {
this.mainController.showError(message);
this.setHashtagStatus(message, "warning");
}
} else {
this.setHashtagStatus("", "muted");
}
const error = new Error("Signer unavailable for hashtag preferences.");
error.code = "hashtag-preferences-missing-signer";
throw error;
}
}
const pendingMessage =
typeof progressMessage === "string" && progressMessage.trim()
? progressMessage.trim()