fix(access): canAccess cross-derives hex+npub so whitelisted authors aren't blocked (#32)

A video from a genuinely whitelisted author refused to play with "not from a
whitelisted author" (the source URL was confirmed healthy — HTTP 200, video/mp4,
range, CORS *). Root cause: accessControl keeps the whitelist in two sets —
whitelistPubkeys (hex) and whitelist (npub) — and canAccess consulted only one
based on the caller's input format without cross-deriving. playbackCoordinator
passes the author's npub, so it checked only the npub set; an entry present in
only the hex representation read as "not allowed."

- canAccess now derives BOTH hex and npub from the candidate and checks both sets
  (only fixes false-negatives; a non-whitelisted author is still denied).
- Dev-gated diagnostic at the playback rejection logs hex/npub, whitelistMode,
  set sizes, and inNpubSet/inHexSet to pinpoint any remaining denial.
- Tests: tests/access-control-canaccess.test.mjs.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
This commit is contained in:
thePR0M3TH3ANandClaude Opus 4.8 committed 2026-06-25 10:39:11 -04:00
1 parent f5bf3d115c
commit 8ab531b241
4 files changed
+129 -1

No files matched your search

+33 -1
View File
@@ -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;
+12
View File
@@ -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()) {
+61
View File
@@ -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);
});
+23
View File
@@ -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)` /