Files
bitvid/js/nostr/ensureSignerDecision.js
T
thePR0M3TH3ANandClaude Opus 4.8 1b79ab9d09 [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]>
2026-07-01 19:11:19 -04:00

57 lines
1.9 KiB
JavaScript

// Pure decision for `app.ensureEncryptionCapableSigner`. Given the active signer's
// current capabilities and whether a locked (passphrase-encrypted) nsec key exists
// for the active account, decide whether to proceed, fail, or prompt the user to
// re-unlock their key. Kept side-effect-free so the branching is unit-testable
// without the browser app shell (TODO #51/#54/#56/#57).
//
// Returns one of:
// { action: "ok" } — signer already capable
// { action: "prompt" } — recoverable: prompt + unlock nsec
// { action: "fail", reason: <string> } — not recoverable here
const defaultNormalize = (value) =>
typeof value === "string" ? value.trim().toLowerCase() : "";
export function isSignerCapable(capabilities, need = "encrypt") {
const caps =
capabilities && typeof capabilities === "object" ? capabilities : {};
if (!caps.sign) {
return false;
}
return need === "sign" ? true : Boolean(caps.nip44) || Boolean(caps.nip04);
}
export function decideSignerEnsure({
capabilities,
need = "encrypt",
normalizedPubkey,
storedKeyMeta,
normalizePubkey = defaultNormalize,
} = {}) {
if (isSignerCapable(capabilities, need)) {
return { action: "ok" };
}
if (!normalizedPubkey) {
return { action: "fail", reason: "no-signer" };
}
// Only an nsec-after-reload situation is recoverable here: a saved, locked key
// for this exact account we can decrypt with the passphrase. Anything else
// (e.g. an unresponsive NIP-07 extension) is the caller's own error to surface.
if (!storedKeyMeta || storedKeyMeta.hasEncryptedKey !== true) {
return { action: "fail", reason: "no-stored-key" };
}
if (
storedKeyMeta.pubkey &&
normalizePubkey(storedKeyMeta.pubkey) !== normalizedPubkey
) {
return { action: "fail", reason: "pubkey-mismatch" };
}
return { action: "prompt" };
}
export default decideSignerEnsure;