[admin] fix whitelist/blacklist edits with nsec & NIP-46; loosen file-size limits

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 <[email protected]>
This commit is contained in:
thePR0M3TH3ANandClaude Opus 4.8 committed 2026-06-30 17:00:57 -04:00
1 parent ad83c9f8db
commit c48083dfcc
4 files changed
+83 -20

No files matched your search

+18 -18
View File
@@ -12,6 +12,7 @@ import {
import { SHORT_TIMEOUT_MS } from "./constants.js"; import { SHORT_TIMEOUT_MS } from "./constants.js";
import { import {
getRegisteredNostrClient, getRegisteredNostrClient,
getActiveSigner,
requestDefaultExtensionPermissions, requestDefaultExtensionPermissions,
} from "./nostrClientRegistry.js"; } from "./nostrClientRegistry.js";
import { devLogger, userLogger } from "./utils/logger.js"; import { devLogger, userLogger } from "./utils/logger.js";
@@ -574,8 +575,7 @@ async function fetchLatestListEvent(filter, contextLabel = "admin-list") {
pubkey, pubkey,
dTag, dTag,
relayUrls: relays, relayUrls: relays,
// Force a full fetch: admin lists are replaceable (see // Full fetch: admin lists are replaceable (relayBatchFetcherReplaceable.test).
// tests/nostr/relayBatchFetcherReplaceable.test.mjs).
since: 0, since: 0,
}), }),
timeoutPromise, timeoutPromise,
@@ -682,8 +682,7 @@ async function loadCommunityBlacklistEntries() {
return []; return [];
} }
// Batch all curator blacklists into ONE REQ (was the cold-start kind-30000 storm). // Batch all curator blacklists into ONE REQ (best-effort; cache carries over on fail).
// Best-effort: on failure the cached admin state carries over.
try { try {
const matched = await fetchBatchedReferenceEvents({ const matched = await fetchBatchedReferenceEvents({
references, references,
@@ -874,20 +873,21 @@ async function persistNostrState(actorNpub, updates = {}) {
} }
ensureNostrReady(); ensureNostrReady();
const permissionResult = await requestDefaultExtensionPermissions(); // Sign with the ACTIVE signer (extension / nsec / NIP-46), not window.nostr.
if (!permissionResult.ok) { const signer = getActiveSigner();
throw createError( if (!signer || typeof signer.signEvent !== "function") {
"extension-permission-denied", throw createError("nostr-signer-missing", "No active signer to update admin lists. Please log in.");
"The NIP-07 extension must allow signing before updating admin lists.",
permissionResult.error,
);
} }
const extension = window?.nostr; // Only a NIP-07 extension needs an explicit permission prompt before signing.
if (!extension || typeof extension.signEvent !== "function") { if (signer.type === "extension" || signer.type === "nip07") {
throw createError( const permissionResult = await requestDefaultExtensionPermissions();
"nostr-extension-missing", if (!permissionResult.ok) {
"A Nostr extension with signEvent support is required." throw createError(
); "extension-permission-denied",
"The NIP-07 extension must allow signing before updating admin lists.",
permissionResult.error,
);
}
} }
const actorHex = decodeNpubToHex(actorNpub); const actorHex = decodeNpubToHex(actorNpub);
@@ -950,7 +950,7 @@ async function persistNostrState(actorNpub, updates = {}) {
let signedEvent; let signedEvent;
try { try {
signedEvent = await extension.signEvent(event); signedEvent = await signer.signEvent(event);
} catch (error) { } catch (error) {
throw createError("signature-failed", "Failed to sign admin list event.", error); throw createError("signature-failed", "Failed to sign admin list event.", error);
} }
+2 -2
View File
@@ -19,8 +19,8 @@ import { join, relative } from "node:path";
const ROOT = new URL("..", import.meta.url).pathname.replace(/\/$/, ""); const ROOT = new URL("..", import.meta.url).pathname.replace(/\/$/, "");
const SOURCE_DIR = join(ROOT, "js"); const SOURCE_DIR = join(ROOT, "js");
const THRESHOLD = 1000; // lines — new files must stay under this const THRESHOLD = 1200; // lines — new files must stay under this (soft ceiling)
const GROWTH_MARGIN = 50; // lines — allowlisted files may grow by this much for minor fixes 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. // Grandfathered files: path relative to repo root → line count when recorded.
// These files are known to be oversized and are decomposition targets. // These files are known to be oversized and are decomposition targets.
+28
View File
@@ -667,4 +667,32 @@ assert.ok(
"should warn when blacklist updates fail on a relay", "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"); console.log("admin-list-store tests passed");
+35
View File
@@ -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 Build + lint clean; affected suites green. NOTE: `npm run build`/`lint` do NOT
parse JS — verify migrations by importing the module / running its test. 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 ### 8. Orphan storage garbage-collection tool
- [x] **Largely delivered by the My Videos tab** (`9d3a0df0`): lists bucket objects no - [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). live note references and offers per-file delete (under the user's prefix only).