From c48083dfcc3390dceb481a8b64bd309518689cd3 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Tue, 30 Jun 2026 17:00:57 -0400 Subject: [PATCH] [admin] fix whitelist/blacklist edits with nsec & NIP-46; loosen file-size limits MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Admin list edits worked with a NIP-07 extension but silently failed with nsec and NIP-46 (Amber). persistNostrState signed with window.nostr.signEvent directly (and required requestDefaultExtensionPermissions), so non-extension logins threw "nostr-extension-missing". Fix: sign with the ACTIVE signer (getActiveSigner() from the registry — extension/nsec/ NIP-46 all expose signEvent), and only run the extension-permission prompt when the signer is an extension (type extension/nip07). Regression test in tests/admin-list-store.test.mjs (nsec signer used while window.nostr.signEvent throws). Also (per maintainer request): raise the file-size lint margins to stop churning on an arbitrary cap — THRESHOLD 1000→1200 (new files), GROWTH_MARGIN 50→200 (grandfathered). TODOs added: #42 follow-up audit of every signing path (must use the active signer, not window.nostr); #43 logged-in profile card at the top of the profile modal (half-on/half- off); #44 in-modal storage selector + unlock for the upload modal. Build + lint clean; admin-list suite green. Co-Authored-By: Claude Opus 4.8 --- js/adminListStore.js | 36 +++++++++++++++--------------- scripts/check-file-size.mjs | 4 ++-- tests/admin-list-store.test.mjs | 28 +++++++++++++++++++++++ todo/TODO_2026-06-20_pre-launch.md | 35 +++++++++++++++++++++++++++++ 4 files changed, 83 insertions(+), 20 deletions(-) diff --git a/js/adminListStore.js b/js/adminListStore.js index 3059d9cd..6b739297 100644 --- a/js/adminListStore.js +++ b/js/adminListStore.js @@ -12,6 +12,7 @@ import { import { SHORT_TIMEOUT_MS } from "./constants.js"; import { getRegisteredNostrClient, + getActiveSigner, requestDefaultExtensionPermissions, } from "./nostrClientRegistry.js"; import { devLogger, userLogger } from "./utils/logger.js"; @@ -574,8 +575,7 @@ async function fetchLatestListEvent(filter, contextLabel = "admin-list") { pubkey, dTag, relayUrls: relays, - // Force a full fetch: admin lists are replaceable (see - // tests/nostr/relayBatchFetcherReplaceable.test.mjs). + // Full fetch: admin lists are replaceable (relayBatchFetcherReplaceable.test). since: 0, }), timeoutPromise, @@ -682,8 +682,7 @@ async function loadCommunityBlacklistEntries() { return []; } - // Batch all curator blacklists into ONE REQ (was the cold-start kind-30000 storm). - // Best-effort: on failure the cached admin state carries over. + // Batch all curator blacklists into ONE REQ (best-effort; cache carries over on fail). try { const matched = await fetchBatchedReferenceEvents({ references, @@ -874,20 +873,21 @@ async function persistNostrState(actorNpub, updates = {}) { } ensureNostrReady(); - const permissionResult = await requestDefaultExtensionPermissions(); - if (!permissionResult.ok) { - throw createError( - "extension-permission-denied", - "The NIP-07 extension must allow signing before updating admin lists.", - permissionResult.error, - ); + // Sign with the ACTIVE signer (extension / nsec / NIP-46), not window.nostr. + const signer = getActiveSigner(); + if (!signer || typeof signer.signEvent !== "function") { + throw createError("nostr-signer-missing", "No active signer to update admin lists. Please log in."); } - const extension = window?.nostr; - if (!extension || typeof extension.signEvent !== "function") { - throw createError( - "nostr-extension-missing", - "A Nostr extension with signEvent support is required." - ); + // Only a NIP-07 extension needs an explicit permission prompt before signing. + if (signer.type === "extension" || signer.type === "nip07") { + const permissionResult = await requestDefaultExtensionPermissions(); + if (!permissionResult.ok) { + throw createError( + "extension-permission-denied", + "The NIP-07 extension must allow signing before updating admin lists.", + permissionResult.error, + ); + } } const actorHex = decodeNpubToHex(actorNpub); @@ -950,7 +950,7 @@ async function persistNostrState(actorNpub, updates = {}) { let signedEvent; try { - signedEvent = await extension.signEvent(event); + signedEvent = await signer.signEvent(event); } catch (error) { throw createError("signature-failed", "Failed to sign admin list event.", error); } diff --git a/scripts/check-file-size.mjs b/scripts/check-file-size.mjs index 3b3fcb81..113a6a69 100644 --- a/scripts/check-file-size.mjs +++ b/scripts/check-file-size.mjs @@ -19,8 +19,8 @@ import { join, relative } from "node:path"; const ROOT = new URL("..", import.meta.url).pathname.replace(/\/$/, ""); const SOURCE_DIR = join(ROOT, "js"); -const THRESHOLD = 1000; // lines — new files must stay under this -const GROWTH_MARGIN = 50; // lines — allowlisted files may grow by this much for minor fixes +const THRESHOLD = 1200; // lines — new files must stay under this (soft ceiling) +const GROWTH_MARGIN = 200; // lines — allowlisted files may grow by this much for fixes // Grandfathered files: path relative to repo root → line count when recorded. // These files are known to be oversized and are decomposition targets. diff --git a/tests/admin-list-store.test.mjs b/tests/admin-list-store.test.mjs index 0c062f2c..6d321c87 100644 --- a/tests/admin-list-store.test.mjs +++ b/tests/admin-list-store.test.mjs @@ -667,4 +667,32 @@ assert.ok( "should warn when blacklist updates fail on a relay", ); +// Regression: admin list edits must sign with the ACTIVE signer (nsec / NIP-46), not +// window.nostr — persistNostrState previously hardcoded the NIP-07 extension, so nsec +// and NIP-46 logins couldn't edit the whitelist. +let nsecSignerCalls = 0; +globalThis.window.nostr.signEvent = async () => { + throw new Error("window.nostr must NOT be used to sign admin lists"); +}; +setActiveSigner({ + pubkey: superHex, + type: "nsec", + signEvent: async (event) => { + nsecSignerCalls += 1; + return { ...event, id: "nsec-signed" }; + }, +}); +nostrClient.writeRelays = [relays[1]]; +setPublishBehaviors([{ url: relays[1], success: true }]); + +await assert.doesNotReject( + () => + persistAdminState(ADMIN_SUPER_NPUB, { whitelist: ["npub1nsecwhitelistmember"] }), + "admin list edit must succeed with an nsec signer and no usable window.nostr", +); +assert.ok( + nsecSignerCalls > 0, + "should sign admin list updates with the active (nsec) signer, not window.nostr", +); + console.log("admin-list-store tests passed"); diff --git a/todo/TODO_2026-06-20_pre-launch.md b/todo/TODO_2026-06-20_pre-launch.md index a20b49a5..51fc43c9 100644 --- a/todo/TODO_2026-06-20_pre-launch.md +++ b/todo/TODO_2026-06-20_pre-launch.md @@ -523,6 +523,41 @@ toward freshness and looked identical. Gave each a structural identity: Build + lint clean; affected suites green. NOTE: `npm run build`/`lint` do NOT parse JS — verify migrations by importing the module / running its test. +### 42. Admin whitelist edit fails with nsec / NIP-46 (only NIP-07 worked) — FIXED +- [x] **DONE 2026-06-30.** Editing the admin whitelist/blacklist worked with a NIP-07 + extension but silently failed with nsec and NIP-46 (Amber). Root cause: + `adminListStore.persistNostrState` signed with `window.nostr.signEvent` directly + (and required `requestDefaultExtensionPermissions`), so non-extension logins threw + "nostr-extension-missing". Fix: sign with the **active signer** + (`getActiveSigner()` from the registry — works for extension/nsec/NIP-46, all expose + `signEvent`), and only run the extension-permission prompt when the signer is an + extension (`type === "extension"/"nip07"`). Regression test added to + `tests/admin-list-store.test.mjs` (nsec signer used while `window.nostr.signEvent` + throws). Build + lint clean. +- [ ] **AUDIT (follow-up):** sweep EVERY place that signs an event and confirm it uses the + active-signer path (`getActiveSigner().signEvent` / the client's sign helper), NOT + `window.nostr` directly. `window.nostr`-only signing breaks nsec + NIP-46. Grep for + `window.nostr`, `extension.signEvent`, and direct `signEvent` calls across publish + paths (lists, reactions, reposts, comments, profile metadata, NWC, watch history, + deletes, NIP-71 mirror, view events) and fix any that bypass the active signer. + +### 43. Logged-in profile card at the top of the profile modal (UX) +- [ ] Show the **logged-in user's profile photo + name** at the **top-center** of the + profile modal, styled as a "profile card" that sits **half-on / half-off** the + modal edge (overlapping the top border) for a polished look. Goal: users can see + WHICH profile they're acting as (e.g. uploading as) at a glance. Pull avatar/name + from the profile cache for the active pubkey; update on profile switch. + +### 44. In-modal storage selector for uploads (UX) +- [ ] Make storage unlock + selection easier from the **video upload modal**: let the + user **pick any configured storage connection** (R2 / B2 / Custom S3) and unlock it + **without leaving the upload modal** — today they must close it, open Profile → + Storage, change the default/selected connection, then reopen upload. Add a provider + picker (driven by `storageService.listConnections`) + inline unlock in the upload + modal's storage section; selecting one sets it as the active upload target for this + upload (and optionally the default). Reuse `loadFromStorage` / the per-provider + connection model. + ### 8. Orphan storage garbage-collection tool - [x] **Largely delivered by the My Videos tab** (`9d3a0df0`): lists bucket objects no live note references and offers per-file delete (under the user's prefix only).