mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-11-04 11:52:25 +00:00
[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:
1 parent
1b79ab9d09
commit
1622e85d10
7 files changed
+243
-8
No files matched your search
@@ -387,6 +387,7 @@ class Application {
|
||||
isUserLoggedIn: () => this.isUserLoggedIn(),
|
||||
normalizeHexPubkey: (val) => this.normalizeHexPubkey(val),
|
||||
getPubkey: () => this.pubkey,
|
||||
ensureSigner: (options) => this.ensureEncryptionCapableSigner(options),
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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, "+");
|
||||
});
|
||||
@@ -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.
|
||||
Reference in new issue
Block a user