mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-27 07:52:24 +00:00
[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:
1 parent
633e946325
commit
1b79ab9d09
7 files changed
+442
No files matched your search
@@ -97,6 +97,12 @@ import {
|
||||
getActiveSigner,
|
||||
onActiveSignerChanged,
|
||||
} from "./nostrClientRegistry.js";
|
||||
import { resolveSignerCapabilities } from "./nostr/signerCapabilities.js";
|
||||
import {
|
||||
decideSignerEnsure,
|
||||
isSignerCapable,
|
||||
} from "./nostr/ensureSignerDecision.js";
|
||||
import { showPasswordPrompt } from "./ui/promptDialog.js";
|
||||
import { queueSignEvent } from "./nostr/signRequestQueue.js";
|
||||
import {
|
||||
DEFAULT_NIP07_CORE_METHODS,
|
||||
@@ -2074,6 +2080,100 @@ class Application {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure the ACTIVE signer can encrypt/sign. If it can't because a persisted
|
||||
* nsec session restored the pubkey + UI but not the in-memory signer after a
|
||||
* page reload (the private key is only passphrase-encrypted — see TODO #36),
|
||||
* prompt once for the passphrase and re-unlock the signer app-wide via
|
||||
* `unlockStoredSessionActor`. One unlock re-enables every signing/encryption
|
||||
* flow (hashtags, subscriptions, blocks, DMs, reactions, storage) until the
|
||||
* next reload — instead of each guard dead-ending with a blanket
|
||||
* "connect a signer that supports encryption" error (TODO #51/#54/#56/#57).
|
||||
*
|
||||
* @param {object} [opts]
|
||||
* @param {string} [opts.pubkey] - Account to unlock (defaults to the active one).
|
||||
* @param {"encrypt"|"sign"} [opts.need] - Capability required. "encrypt" needs
|
||||
* nip44/nip04; "sign" needs signEvent only.
|
||||
* @param {string} [opts.promptMessage] - Override the passphrase-prompt copy.
|
||||
* @returns {Promise<{ok: boolean, reason?: string, unlocked?: boolean, error?: Error}>}
|
||||
*/
|
||||
async ensureEncryptionCapableSigner({
|
||||
pubkey,
|
||||
need = "encrypt",
|
||||
promptMessage,
|
||||
} = {}) {
|
||||
const normalized =
|
||||
this.normalizeHexPubkey(pubkey) ||
|
||||
this.normalizeHexPubkey(this.pubkey) ||
|
||||
"";
|
||||
|
||||
let meta = null;
|
||||
try {
|
||||
meta =
|
||||
normalized &&
|
||||
typeof nostrClient?.getStoredSessionActorMetadata === "function"
|
||||
? nostrClient.getStoredSessionActorMetadata(normalized)
|
||||
: null;
|
||||
} catch (error) {
|
||||
devLogger.warn(
|
||||
"[Application] Failed to read stored session metadata while ensuring signer:",
|
||||
error,
|
||||
);
|
||||
}
|
||||
|
||||
const decision = decideSignerEnsure({
|
||||
capabilities: resolveSignerCapabilities(getActiveSigner()),
|
||||
need,
|
||||
normalizedPubkey: normalized,
|
||||
storedKeyMeta: meta,
|
||||
normalizePubkey: (value) => this.normalizeHexPubkey(value) || "",
|
||||
});
|
||||
|
||||
if (decision.action === "ok") {
|
||||
return { ok: true };
|
||||
}
|
||||
if (decision.action === "fail") {
|
||||
return { ok: false, reason: decision.reason };
|
||||
}
|
||||
|
||||
const message =
|
||||
typeof promptMessage === "string" && promptMessage.trim()
|
||||
? promptMessage.trim()
|
||||
: "Re-enter your PIN / passphrase to unlock your saved key for this action.";
|
||||
const passphrase = await showPasswordPrompt(message, {
|
||||
title: "Unlock your key",
|
||||
confirmLabel: "Unlock",
|
||||
});
|
||||
if (passphrase == null || passphrase === "") {
|
||||
return { ok: false, reason: "cancelled" };
|
||||
}
|
||||
|
||||
try {
|
||||
await nostrClient.unlockStoredSessionActor(passphrase, {
|
||||
pubkey: normalized,
|
||||
});
|
||||
} catch (error) {
|
||||
if (error?.code === "decrypt-failed") {
|
||||
this.showError?.(
|
||||
"Incorrect PIN / passphrase. Try that action again to re-enter it.",
|
||||
);
|
||||
return { ok: false, reason: "bad-passphrase", error };
|
||||
}
|
||||
devLogger.warn(
|
||||
"[Application] Failed to unlock stored nsec session for a signing action:",
|
||||
error,
|
||||
);
|
||||
return { ok: false, reason: "unlock-failed", error };
|
||||
}
|
||||
|
||||
// The signer is now registered app-wide; confirm the capability actually
|
||||
// came back before telling the caller to proceed.
|
||||
if (!isSignerCapable(resolveSignerCapabilities(getActiveSigner()), need)) {
|
||||
return { ok: false, reason: "still-incapable" };
|
||||
}
|
||||
return { ok: true, unlocked: true };
|
||||
}
|
||||
|
||||
resetPermissionPromptState() {
|
||||
this.permissionPromptShownForSession = false;
|
||||
this.permissionPromptVisible = false;
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
// 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;
|
||||
@@ -572,6 +572,11 @@ export default class ApplicationBootstrap {
|
||||
},
|
||||
describeLoginError: (error, fallbackMessage) =>
|
||||
app.describeLoginError(error, fallbackMessage),
|
||||
// Ensure the active signer can encrypt/sign before a list mutation; if a
|
||||
// reloaded nsec session lost its in-memory key, this prompts once to
|
||||
// re-unlock it instead of dead-ending with a blanket error (TODO #57).
|
||||
ensureEncryptionCapableSigner: (options = {}) =>
|
||||
app.ensureEncryptionCapableSigner(options),
|
||||
hashtagPreferences: app.hashtagPreferences,
|
||||
getHashtagPreferences: () => app.getHashtagPreferences(),
|
||||
describeHashtagPreferencesError: (error, fallbackMessage) =>
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
// The account-switch / reload signer gate (app.ensureEncryptionCapableSigner)
|
||||
// decides whether a signing/encryption action can proceed, must fail, or should
|
||||
// prompt the user to re-unlock a persisted nsec key. decideSignerEnsure is the
|
||||
// pure branching behind it (TODO #51/#54/#56/#57).
|
||||
//
|
||||
// test_integrity_note:
|
||||
// change_type: ["new_tests"]
|
||||
// scenarios:
|
||||
// - id: SCN-signer-ensure-decision
|
||||
// given: "current signer capabilities + whether a locked nsec key exists"
|
||||
// when: "decideSignerEnsure runs"
|
||||
// then: "ok when capable, prompt when recoverable, fail with a precise reason otherwise"
|
||||
// observable_outcomes:
|
||||
// - "capable encrypt signer -> {action:ok}"
|
||||
// - "no signer + no pubkey -> fail:no-signer"
|
||||
// - "incapable + no stored key -> fail:no-stored-key"
|
||||
// - "incapable + stored key for a DIFFERENT pubkey -> fail:pubkey-mismatch"
|
||||
// - "incapable + matching locked key -> prompt"
|
||||
// - "need:sign only requires signEvent (nip44/nip04 not required)"
|
||||
// determinism_controls:
|
||||
// - "pure function; explicit inputs; injected normalizePubkey"
|
||||
// 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 {
|
||||
decideSignerEnsure,
|
||||
isSignerCapable,
|
||||
} from "../js/nostr/ensureSignerDecision.js";
|
||||
|
||||
const PUB = "a".repeat(64);
|
||||
const OTHER = "b".repeat(64);
|
||||
const norm = (v) => (typeof v === "string" ? v.trim().toLowerCase() : "");
|
||||
|
||||
test("capable encrypt-signer proceeds", () => {
|
||||
const d = decideSignerEnsure({
|
||||
capabilities: { sign: true, nip44: true, nip04: false },
|
||||
need: "encrypt",
|
||||
normalizedPubkey: PUB,
|
||||
storedKeyMeta: { hasEncryptedKey: true, pubkey: PUB },
|
||||
normalizePubkey: norm,
|
||||
});
|
||||
assert.deepEqual(d, { action: "ok" });
|
||||
});
|
||||
|
||||
test("nip04-only signer is still encrypt-capable", () => {
|
||||
assert.equal(isSignerCapable({ sign: true, nip04: true }, "encrypt"), true);
|
||||
});
|
||||
|
||||
test("a sign-only signer is NOT encrypt-capable but IS sign-capable", () => {
|
||||
const caps = { sign: true, nip44: false, nip04: false };
|
||||
assert.equal(isSignerCapable(caps, "encrypt"), false);
|
||||
assert.equal(isSignerCapable(caps, "sign"), true);
|
||||
// need:sign proceeds even without any encryption method
|
||||
assert.deepEqual(
|
||||
decideSignerEnsure({
|
||||
capabilities: caps,
|
||||
need: "sign",
|
||||
normalizedPubkey: PUB,
|
||||
storedKeyMeta: { hasEncryptedKey: true, pubkey: PUB },
|
||||
normalizePubkey: norm,
|
||||
}),
|
||||
{ action: "ok" },
|
||||
);
|
||||
});
|
||||
|
||||
test("no signer and no pubkey -> fail:no-signer", () => {
|
||||
const d = decideSignerEnsure({
|
||||
capabilities: { sign: false },
|
||||
need: "encrypt",
|
||||
normalizedPubkey: "",
|
||||
storedKeyMeta: null,
|
||||
normalizePubkey: norm,
|
||||
});
|
||||
assert.deepEqual(d, { action: "fail", reason: "no-signer" });
|
||||
});
|
||||
|
||||
test("incapable but no stored nsec key -> fail:no-stored-key (caller surfaces its own error)", () => {
|
||||
const d = decideSignerEnsure({
|
||||
capabilities: { sign: false },
|
||||
need: "encrypt",
|
||||
normalizedPubkey: PUB,
|
||||
storedKeyMeta: null,
|
||||
normalizePubkey: norm,
|
||||
});
|
||||
assert.deepEqual(d, { action: "fail", reason: "no-stored-key" });
|
||||
});
|
||||
|
||||
test("stored key belongs to a DIFFERENT account -> fail:pubkey-mismatch (never unlock the wrong key)", () => {
|
||||
const d = decideSignerEnsure({
|
||||
capabilities: { sign: false },
|
||||
need: "encrypt",
|
||||
normalizedPubkey: PUB,
|
||||
storedKeyMeta: { hasEncryptedKey: true, pubkey: OTHER },
|
||||
normalizePubkey: norm,
|
||||
});
|
||||
assert.deepEqual(d, { action: "fail", reason: "pubkey-mismatch" });
|
||||
});
|
||||
|
||||
test("incapable + matching locked nsec key -> prompt to re-unlock", () => {
|
||||
const d = decideSignerEnsure({
|
||||
capabilities: { sign: true, nip44: false, nip04: false },
|
||||
need: "encrypt",
|
||||
normalizedPubkey: PUB,
|
||||
storedKeyMeta: { hasEncryptedKey: true, pubkey: PUB },
|
||||
normalizePubkey: norm,
|
||||
});
|
||||
assert.deepEqual(d, { action: "prompt" });
|
||||
});
|
||||
|
||||
test("stored key with no pubkey field still prompts (metadata may omit it)", () => {
|
||||
const d = decideSignerEnsure({
|
||||
capabilities: { sign: false },
|
||||
need: "encrypt",
|
||||
normalizedPubkey: PUB,
|
||||
storedKeyMeta: { hasEncryptedKey: true },
|
||||
normalizePubkey: norm,
|
||||
});
|
||||
assert.deepEqual(d, { action: "prompt" });
|
||||
});
|
||||
|
||||
test("hasEncryptedKey must be strictly true (a falsey/omitted flag never prompts)", () => {
|
||||
const d = decideSignerEnsure({
|
||||
capabilities: { sign: false },
|
||||
need: "encrypt",
|
||||
normalizedPubkey: PUB,
|
||||
storedKeyMeta: { hasEncryptedKey: "yes", pubkey: PUB },
|
||||
normalizePubkey: norm,
|
||||
});
|
||||
assert.deepEqual(d, { action: "fail", reason: "no-stored-key" });
|
||||
});
|
||||
@@ -1475,3 +1475,90 @@ test('refreshOpenPanesForActiveIdentity re-runs the active pane only when the mo
|
||||
'open modal: re-runs the active pane so it reloads for the new identity',
|
||||
);
|
||||
});
|
||||
|
||||
// Hashtag mutation routes through the signer gate (TODO #57): a reloaded nsec
|
||||
// session that lost its in-memory key must trigger a re-unlock prompt, not a
|
||||
// blanket "connect a signer that supports encryption" error — and publish must
|
||||
// NOT run until the signer is confirmed.
|
||||
//
|
||||
// test_integrity_note:
|
||||
// change_type: ["new_tests"]
|
||||
// scenarios:
|
||||
// - id: SCN-hashtag-signer-gate
|
||||
// given: "a profile modal with a mocked ensureEncryptionCapableSigner + publish"
|
||||
// when: "persistHashtagPreferences runs"
|
||||
// then: "publish is gated on the signer being ensured first"
|
||||
// observable_outcomes:
|
||||
// - "gate returns not-ok -> publish never called, throws missing-signer"
|
||||
// - "gate returns ok -> publish runs once"
|
||||
// determinism_controls:
|
||||
// - "mocked service + gate; JSDOM controller; no network"
|
||||
// anti_cheat_rationale:
|
||||
// prevents: ["over-mocking internal logic", "hard-coded return value"]
|
||||
// relaxation:
|
||||
// did_relax_any_assertion: false
|
||||
|
||||
test('hashtag persist: a not-ok signer gate blocks publish (no blanket dead-end)', async (t) => {
|
||||
const publishCalls = [];
|
||||
let ensureCalls = 0;
|
||||
const controller = createController({
|
||||
services: {
|
||||
ensureEncryptionCapableSigner: async () => {
|
||||
ensureCalls += 1;
|
||||
return { ok: false, reason: 'cancelled' };
|
||||
},
|
||||
hashtagPreferences: {
|
||||
publish: async (payload) => {
|
||||
publishCalls.push(payload);
|
||||
return { ok: true };
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
await controller.load();
|
||||
t.after(() => {
|
||||
try {
|
||||
controller.hide({ silent: true });
|
||||
} catch {}
|
||||
resetRuntimeFlags();
|
||||
});
|
||||
controller.setActivePubkey('a'.repeat(64));
|
||||
|
||||
await assert.rejects(
|
||||
() =>
|
||||
controller.hashtagController.persistHashtagPreferences({
|
||||
pubkey: 'a'.repeat(64),
|
||||
}),
|
||||
(err) => err?.code === 'hashtag-preferences-missing-signer',
|
||||
);
|
||||
assert.equal(ensureCalls, 1, 'the signer gate ran before publishing');
|
||||
assert.equal(publishCalls.length, 0, 'publish was NOT attempted after the gate failed');
|
||||
});
|
||||
|
||||
test('hashtag persist: an ok signer gate lets publish proceed', async (t) => {
|
||||
const publishCalls = [];
|
||||
const controller = createController({
|
||||
services: {
|
||||
ensureEncryptionCapableSigner: async () => ({ ok: true, unlocked: true }),
|
||||
hashtagPreferences: {
|
||||
publish: async (payload) => {
|
||||
publishCalls.push(payload);
|
||||
return { ok: true };
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
await controller.load();
|
||||
t.after(() => {
|
||||
try {
|
||||
controller.hide({ silent: true });
|
||||
} catch {}
|
||||
resetRuntimeFlags();
|
||||
});
|
||||
controller.setActivePubkey('a'.repeat(64));
|
||||
|
||||
await controller.hashtagController.persistHashtagPreferences({
|
||||
pubkey: 'a'.repeat(64),
|
||||
});
|
||||
assert.equal(publishCalls.length, 1, 'publish ran once after the gate passed');
|
||||
});
|
||||
@@ -1640,3 +1640,24 @@ passphrase-encrypted). Items 51, 56, 57 are all facets of that.
|
||||
dead-end error. Cross-ref **#48** (fluent switching) and the account-switch refresh
|
||||
work (commit `87af650b`). Audit every "signer that supports encryption" guard and
|
||||
route it to the unlock prompt when a saved nsec key exists for the active pubkey.
|
||||
- [x] **Shared gate built + hashtags wired — DONE 2026-07-01.** New reusable
|
||||
`app.ensureEncryptionCapableSigner({ pubkey, need, promptMessage })`
|
||||
(`js/app.js`, pure branching in `js/nostr/ensureSignerDecision.js`): 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
|
||||
flow until the next reload). Never unlocks a different account's key
|
||||
(pubkey-mismatch guard). Exposed to the profile modal as
|
||||
`services.ensureEncryptionCapableSigner`. **Hashtag add/edit** now routes through
|
||||
it (`ProfileHashtagController.persistHashtagPreferences`): a cancelled prompt
|
||||
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.
|
||||
Reference in new issue
Block a user