diff --git a/js/accessControl.js b/js/accessControl.js index 0d31e511..f03f254c 100644 --- a/js/accessControl.js +++ b/js/accessControl.js @@ -749,7 +749,39 @@ class AccessControl { } } - const normalized = normalizeNpub(npub); + let normalized = normalizeNpub(npub); + + // Cross-derive the missing representation. `whitelistPubkeys`/`blacklistPubkeys` + // are hex-keyed while `whitelist`/`blacklist` are npub-keyed; both are built + // from the same admin lists, but a format mismatch between the caller's input + // and how a given entry was stored must NOT read as "not allowed". Filling in + // both forms lets the checks below consult both sets. + const accessTools = + (typeof window !== "undefined" ? window?.NostrTools : null) || + (typeof globalThis !== "undefined" ? globalThis?.NostrTools : null) || + null; + if (normalized && !hex && typeof accessTools?.nip19?.decode === "function") { + try { + const decoded = accessTools.nip19.decode(normalized); + if (decoded?.type === "npub") { + hex = normalizeHexKey( + typeof decoded.data === "string" + ? decoded.data + : bytesToHex(decoded.data) + ); + } + } catch (error) { + // ignore — fall back to the npub-only checks + } + } + if (hex && !normalized && typeof accessTools?.nip19?.npubEncode === "function") { + try { + normalized = normalizeNpub(accessTools.nip19.npubEncode(hex)); + } catch (error) { + // ignore — fall back to the hex-only checks + } + } + if (!normalized) { if (!hex) { return false; diff --git a/js/app/playbackCoordinator.js b/js/app/playbackCoordinator.js index 37bae812..08a9c8f2 100644 --- a/js/app/playbackCoordinator.js +++ b/js/app/playbackCoordinator.js @@ -677,6 +677,18 @@ export function createPlaybackCoordinator(deps) { } const authorNpub = this.safeEncodeNpub(video.pubkey) || video.pubkey; if (!accessControl.canAccess(authorNpub)) { + // Dev diagnostic: pinpoint WHY access was denied (format mismatch vs the + // author genuinely not being on the list vs lists not loaded). + devLogger.warn("[playback] access denied — whitelist diagnostic", { + pubkey: video.pubkey, + authorNpub, + foreign: video.foreign === true || video.source === "nip71-ingest", + whitelistMode: accessControl.whitelistMode?.(), + whitelistNpubSize: accessControl.whitelist?.size, + whitelistHexSize: accessControl.whitelistPubkeys?.size, + inNpubSet: accessControl.whitelist?.has?.(authorNpub), + inHexSet: accessControl.whitelistPubkeys?.has?.(video.pubkey), + }); if (accessControl.isBlacklisted(authorNpub)) { this.showError("This content has been removed or is not allowed."); } else if (accessControl.whitelistMode()) { diff --git a/tests/access-control-canaccess.test.mjs b/tests/access-control-canaccess.test.mjs new file mode 100644 index 00000000..71d8c755 --- /dev/null +++ b/tests/access-control-canaccess.test.mjs @@ -0,0 +1,61 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import * as nostrTools from "nostr-tools"; + +import { accessControl } from "../js/accessControl.js"; + +// Bug: a genuinely whitelisted author was blocked at playback because canAccess +// checks the hex set when given hex and the npub set when given an npub, without +// cross-deriving. If an entry lands in only one representation (e.g. hex decode +// hadn't run when the list loaded), querying in the other format read as "not +// allowed". The fix cross-derives both forms and checks both sets. + +// canAccess resolves NostrTools from window/globalThis for the cross-derive. +const priorTools = globalThis.NostrTools; +globalThis.NostrTools = { nip19: nostrTools.nip19 }; + +const sk = nostrTools.generateSecretKey(); +const HEX = nostrTools.getPublicKey(sk); +const NPUB = nostrTools.nip19.npubEncode(HEX); + +const otherSk = nostrTools.generateSecretKey(); +const OTHER_HEX = nostrTools.getPublicKey(otherSk); +const OTHER_NPUB = nostrTools.nip19.npubEncode(OTHER_HEX); + +function seed({ whitelist = [], whitelistHex = [] } = {}) { + accessControl.whitelistEnabled = true; + accessControl.whitelist = new Set(whitelist); + accessControl.whitelistPubkeys = new Set(whitelistHex); + accessControl.blacklist = new Set(); + accessControl.blacklistPubkeys = new Set(); + accessControl.editors = new Set(); // ensure the author isn't treated as admin +} + +test.after(() => { + globalThis.NostrTools = priorTools; +}); + +test("whitelisted by HEX only is recognized when queried by npub (cross-derive)", () => { + seed({ whitelistHex: [HEX] }); // npub set deliberately empty + assert.equal(accessControl.canAccess(NPUB), true, "npub query finds the hex-set author"); + assert.equal(accessControl.canAccess(HEX), true, "hex query still works"); +}); + +test("whitelisted by NPUB only is recognized when queried by hex (cross-derive)", () => { + seed({ whitelist: [NPUB] }); // hex set deliberately empty + assert.equal(accessControl.canAccess(HEX), true, "hex query finds the npub-set author"); + assert.equal(accessControl.canAccess(NPUB), true, "npub query still works"); +}); + +test("a non-whitelisted author is still denied in both formats (no over-allow)", () => { + seed({ whitelist: [NPUB], whitelistHex: [HEX] }); + assert.equal(accessControl.canAccess(OTHER_NPUB), false); + assert.equal(accessControl.canAccess(OTHER_HEX), false); +}); + +test("with whitelist mode OFF, any author is allowed regardless of sets", () => { + seed({}); + accessControl.whitelistEnabled = false; + assert.equal(accessControl.canAccess(OTHER_NPUB), true); + assert.equal(accessControl.canAccess(OTHER_HEX), true); +}); diff --git a/todo/TODO_2026-06-20_pre-launch.md b/todo/TODO_2026-06-20_pre-launch.md index 174868b4..4c3c61d2 100644 --- a/todo/TODO_2026-06-20_pre-launch.md +++ b/todo/TODO_2026-06-20_pre-launch.md @@ -599,6 +599,29 @@ SILENT one: `handleEventDetailsAction` only opened the modal `if (… && payload - [ ] **VERIFY live**: click ⋯ → Event Details AND ⋯ → Popularity in the video modal AND on a grid/channel card; both should open the correct video. +### 32. Whitelisted author's video won't play — "not from a whitelisted author" (BUG) +Reported 2026-06-25: a video from a known-whitelisted author refused to play +("This content is not from a whitelisted author." at `playbackCoordinator +.playVideoByEventId`). The source URL was confirmed 100% healthy (HTTP 200, +video/mp4, range + CORS `*`) — the block is the whitelist gate, not the source. +- [x] **Root cause + fix: `canAccess` format asymmetry.** `accessControl` keeps the + whitelist in TWO sets — `whitelistPubkeys` (hex) and `whitelist` (npub) — and + `canAccess` checked only ONE based on the caller's input format (npub→npub set, + hex→hex set) without cross-deriving. `playbackCoordinator` passes the author's + **npub**, so it consulted only the npub set; if the entry was present in only + the hex set (e.g. the hex decode hadn't run / a normalization mismatch), a + genuinely whitelisted author read as "not allowed." Fixed `canAccess` to + cross-derive both forms and check BOTH sets (false-negatives only; a + non-whitelisted author is still denied). Tests: + `tests/access-control-canaccess.test.mjs`. +- [x] Added a dev-gated diagnostic at the playback rejection + (`playbackCoordinator`) logging hex/npub, whitelistMode, set sizes, and + `inNpubSet`/`inHexSet` — so if it still denies, we know instantly whether the + author is genuinely off the list (→ then it's the separate "ingested foreign + content shown in a whitelist-mode feed but unplayable" question) vs a load race. +- [ ] **VERIFY live**: replay the failing nevent. If it plays → fixed. If it still + denies, paste the new `[playback] access denied — whitelist diagnostic` line. + ### 28. Beacon torrent app stuck — spinner never resolves (BUG) — FIXED 2026-06-24 - [x] **Root cause:** the processing overlay (`torrent/app.js`) was gated on WebTorrent's metadata-ready callback (`client.add(magnet, opts, cb)` /