diff --git a/js/feedEngine/index.js b/js/feedEngine/index.js index ec4b91fc..4ee83b51 100644 --- a/js/feedEngine/index.js +++ b/js/feedEngine/index.js @@ -21,6 +21,7 @@ export { } from "./sorters.js"; export { createActiveNostrSource, + createWhitelistedAuthorsNostrSource, createSubscriptionAuthorsSource, createWatchHistoryPointerSource, } from "./sources.js"; diff --git a/js/feedEngine/mostZappedFeed.js b/js/feedEngine/mostZappedFeed.js index 7fcde6ec..ae9169a8 100644 --- a/js/feedEngine/mostZappedFeed.js +++ b/js/feedEngine/mostZappedFeed.js @@ -1,8 +1,9 @@ // js/feedEngine/mostZappedFeed.js // -// The "Most Zapped" tab (#47): the recently-added (active) source ranked by -// ZAP TOTAL (sats from kind-9735 receipts). Mirrors trendingFeed.js — lives -// outside feedCoordinator.js (size cap) and is wired in via thin delegations. +// The "Most Zapped" tab (#47): the catalog of approved creators ranked by +// ZAP TOTAL (sats from kind-9735 receipts). It is intentionally scoped to the +// creator whitelist for discovery, but still runs the ordinary moderation +// stages — whitelisting does not waive Web-of-Trust handling. // The metric getter uses requestVideoZapTotal, which returns the cached total // AND schedules a batched receipt fetch for unknown pointers; the Most Zapped // view re-runs the feed on zapTotals' change signal so the order settles as @@ -11,7 +12,7 @@ import { FEED_TYPES } from "../constants.js"; import { requestVideoZapTotal } from "../zapTotals.js"; import { - createActiveNostrSource, + createWhitelistedAuthorsNostrSource, createBlacklistFilterStage, createDedupeByRootStage, createModerationStage, @@ -43,6 +44,19 @@ function makeGetZapTotal(app) { }; } +function getWhitelistedAuthors(app) { + const accessControl = app?.nostrService?.accessControl; + if (typeof accessControl?.getWhitelistPubkeys !== "function") { + return []; + } + try { + const authors = accessControl.getWhitelistPubkeys(); + return Array.isArray(authors) ? authors : []; + } catch (error) { + return []; + } +} + export function buildMostZappedFeedRuntime(app) { const blacklist = app.blacklistedEventIds instanceof Set @@ -59,6 +73,7 @@ export function buildMostZappedFeedRuntime(app) { ? { ...moderationThresholds } : undefined, getZapTotal: makeGetZapTotal(app), + whitelistedAuthors: getWhitelistedAuthors(app), }; } @@ -94,7 +109,7 @@ export function registerMostZappedFeed(app) { try { return app.feedEngine.registerFeed(FEED_TYPES.MOST_ZAPPED, { - source: createActiveNostrSource({ service: app.nostrService }), + source: createWhitelistedAuthorsNostrSource({ service: app.nostrService }), stages: [ createBlacklistFilterStage({ shouldIncludeVideo: (video, options) => diff --git a/js/feedEngine/sources.js b/js/feedEngine/sources.js index bf7bf2a3..2b086741 100644 --- a/js/feedEngine/sources.js +++ b/js/feedEngine/sources.js @@ -62,6 +62,89 @@ export function createActiveNostrSource({ service } = {}) { }; } +// Bounded discovery source for curated surfaces such as Most Zapped. It never +// falls back to an unscoped relay request: the caller must provide approved +// creator pubkeys in runtime.whitelistedAuthors. The successful remote result +// is retained briefly so an asynchronous ranking update does not turn into a +// second full relay scan for the same author set. +export function createWhitelistedAuthorsNostrSource({ + service, + refreshTtlMs = 2 * 60 * 1000, + now = () => Date.now(), +} = {}) { + const resolvedService = resolveService(service, nostrService); + let cachedAuthorsKey = ""; + let cachedVideos = []; + let fetchedAt = 0; + let inFlight = null; + + const normalizeAuthors = (input) => + Array.from( + new Set( + toArray(input) + .map(normalizeAuthor) + .filter(Boolean), + ), + ).sort(); + + return async function whitelistedAuthorsNostrSource(context = {}) { + const authors = normalizeAuthors(context?.runtime?.whitelistedAuthors); + const authorsKey = authors.join(","); + if (!authors.length) { + cachedAuthorsKey = ""; + cachedVideos = []; + fetchedAt = 0; + return []; + } + + const cacheIsCurrent = + authorsKey === cachedAuthorsKey && + now() - fetchedAt < Math.max(0, Number(refreshTtlMs) || 0); + + if (!cacheIsCurrent && !inFlight) { + const fetchByAuthors = resolvedService?.fetchVideosByAuthors; + inFlight = Promise.resolve( + typeof fetchByAuthors === "function" + ? fetchByAuthors.call(resolvedService, authors) + : [], + ) + .then((videos) => { + const allowed = new Set(authors); + cachedAuthorsKey = authorsKey; + cachedVideos = (Array.isArray(videos) ? videos : []).filter( + (video) => allowed.has(normalizeAuthor(video?.pubkey)), + ); + fetchedAt = now(); + return cachedVideos; + }) + .catch((error) => { + context?.log?.( + "[whitelisted-authors-source] Failed to fetch approved creators", + error, + ); + return authorsKey === cachedAuthorsKey ? cachedVideos : []; + }) + .finally(() => { + inFlight = null; + }); + } + + const videos = cacheIsCurrent ? cachedVideos : await inFlight; + // The whitelist may change while a prior author-scoped request is still in + // flight. Filter again at return time so that stale results can never + // briefly render after an administrator removes a creator. + const allowed = new Set(authors); + return (Array.isArray(videos) ? videos : []) + .filter((video) => allowed.has(normalizeAuthor(video?.pubkey))) + .map((video) => ({ + video, + metadata: { + source: "nostr:whitelisted-authors", + }, + })); + }; +} + export function createSubscriptionAuthorsSource({ service } = {}) { const resolvedService = resolveService(service, nostrService); diff --git a/js/ui/bitloginModalIntegration.js b/js/ui/bitloginModalIntegration.js index eec967eb..a909ca1d 100644 --- a/js/ui/bitloginModalIntegration.js +++ b/js/ui/bitloginModalIntegration.js @@ -46,6 +46,14 @@ function closeLoginModal() { } } +function isHtmlElement(value, documentRef = document) { + const HTMLElementConstructor = + documentRef?.defaultView?.HTMLElement || globalThis.HTMLElement; + return typeof HTMLElementConstructor === "function" + ? value instanceof HTMLElementConstructor + : Boolean(value && value.nodeType === 1); +} + // Cheap insurance against wireBitloginLogin() ever running twice for the same // element (e.g. a future bootstrap change that calls it more than once): the // same instance should only ever get one "bitlogin-login" @@ -82,12 +90,12 @@ function attachWidget(app, widget) { // Unstable-branch experiment (AGENTS.md §1, config/instance-config.js) -- // leave the permanent, statically-declared markup in place but never load // the vendored bundle or show the (otherwise-empty, unupgraded) element. - if (mount instanceof HTMLElement) { + if (isHtmlElement(mount)) { mount.hidden = true; } return; } - if (mount instanceof HTMLElement) { + if (isHtmlElement(mount)) { mount.hidden = false; } @@ -162,22 +170,29 @@ function attachWidget(app, widget) { * @param {{ authService: { requestLogin: Function } }} app */ export function wireBitloginLogin(app) { - if (!app?.authService) { + if (!app?.authService || typeof document === "undefined") { return; } - const existing = document.getElementById("bitloginWidget"); - if (existing instanceof HTMLElement) { + const documentRef = document; + const existing = documentRef.getElementById("bitloginWidget"); + if (isHtmlElement(existing, documentRef)) { attachWidget(app, existing); return; } - const observer = new MutationObserver(() => { - const widget = document.getElementById("bitloginWidget"); - if (widget instanceof HTMLElement) { + const Observer = + documentRef.defaultView?.MutationObserver || globalThis.MutationObserver; + if (typeof Observer !== "function" || !documentRef.body) { + return; + } + + const observer = new Observer(() => { + const widget = documentRef.getElementById("bitloginWidget"); + if (isHtmlElement(widget, documentRef)) { observer.disconnect(); attachWidget(app, widget); } }); - observer.observe(document.body, { childList: true, subtree: true }); + observer.observe(documentRef.body, { childList: true, subtree: true }); } diff --git a/tests/bitlogin-modal-integration.test.mjs b/tests/bitlogin-modal-integration.test.mjs index 3e930937..32f5b2a2 100644 --- a/tests/bitlogin-modal-integration.test.mjs +++ b/tests/bitlogin-modal-integration.test.mjs @@ -1,7 +1,10 @@ import test from "node:test"; import assert from "node:assert/strict"; -import { resolveVerifiedBitloginPubkey } from "../js/ui/bitloginModalIntegration.js"; +import { + resolveVerifiedBitloginPubkey, + wireBitloginLogin, +} from "../js/ui/bitloginModalIntegration.js"; const PUBKEY = "a".repeat(64); @@ -19,3 +22,22 @@ test("BitLogin rejects malformed claims and widgets without a worker-backed publ assert.equal(await resolveVerifiedBitloginPubkey({}, PUBKEY), ""); assert.equal(await resolveVerifiedBitloginPubkey({ getPublicKey: async () => PUBKEY }, "npub1nope"), ""); }); + +test("BitLogin bootstrap safely defers when MutationObserver is unavailable", () => { + const originalDocument = globalThis.document; + const originalObserver = globalThis.MutationObserver; + globalThis.document = { + body: {}, + defaultView: {}, + getElementById: () => null, + }; + try { + delete globalThis.MutationObserver; + assert.doesNotThrow(() => wireBitloginLogin({ authService: {} })); + } finally { + if (originalDocument === undefined) delete globalThis.document; + else globalThis.document = originalDocument; + if (originalObserver === undefined) delete globalThis.MutationObserver; + else globalThis.MutationObserver = originalObserver; + } +}); diff --git a/tests/most-zapped-source.test.mjs b/tests/most-zapped-source.test.mjs new file mode 100644 index 00000000..d071083e --- /dev/null +++ b/tests/most-zapped-source.test.mjs @@ -0,0 +1,73 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { createWhitelistedAuthorsNostrSource } from "../js/feedEngine/sources.js"; + +test("Most Zapped discovery fetches only whitelisted creators and caches the catalog", async () => { + const calls = []; + const approved = "a".repeat(64); + const unapproved = "b".repeat(64); + let now = 1_000; + const source = createWhitelistedAuthorsNostrSource({ + now: () => now, + service: { + async fetchVideosByAuthors(authors) { + calls.push(authors); + return [ + { id: "approved", pubkey: approved, created_at: 20 }, + { id: "unapproved", pubkey: unapproved, created_at: 30 }, + ]; + }, + }, + }); + + const runtime = { whitelistedAuthors: [approved.toUpperCase()] }; + const first = await source({ runtime }); + const rerank = await source({ runtime }); + + assert.deepEqual(calls, [[approved]], "relay lookup is author-scoped once"); + assert.deepEqual(first.map((entry) => entry.video.id), ["approved"]); + assert.deepEqual(rerank.map((entry) => entry.video.id), ["approved"]); + + now += 2 * 60 * 1000; + await source({ runtime }); + assert.equal(calls.length, 2, "catalog refreshes after its bounded TTL"); +}); + +test("Most Zapped discovery is empty without approved creators and never queries globally", async () => { + let calls = 0; + const source = createWhitelistedAuthorsNostrSource({ + service: { + async fetchVideosByAuthors() { + calls += 1; + return [{ id: "must-not-leak", pubkey: "c".repeat(64) }]; + }, + }, + }); + + const result = await source({ runtime: { whitelistedAuthors: [] } }); + assert.deepEqual(result, []); + assert.equal(calls, 0); +}); + +test("Most Zapped never leaks a removed creator while an earlier lookup is in flight", async () => { + const removed = "d".repeat(64); + const remaining = "e".repeat(64); + let resolveFetch; + const source = createWhitelistedAuthorsNostrSource({ + service: { + fetchVideosByAuthors() { + return new Promise((resolve) => { + resolveFetch = resolve; + }); + }, + }, + }); + + const first = source({ runtime: { whitelistedAuthors: [removed] } }); + const afterRemoval = source({ runtime: { whitelistedAuthors: [remaining] } }); + resolveFetch([{ id: "removed-video", pubkey: removed }]); + + assert.deepEqual((await first).map((entry) => entry.video.id), ["removed-video"]); + assert.deepEqual(await afterRemoval, []); +}); diff --git a/todo/TODO_2026-06-20_pre-launch.md b/todo/TODO_2026-06-20_pre-launch.md index 25e4a6ae..d0154955 100644 --- a/todo/TODO_2026-06-20_pre-launch.md +++ b/todo/TODO_2026-06-20_pre-launch.md @@ -648,6 +648,13 @@ toward freshness and looked identical. Gave each a structural identity: link, viewManager registry, feedCoordinator case/loader, `FEED_TYPES.MOST_ZAPPED`, `FEATURE_MOST_ZAPPED_FEED` flag (default on; off hides the tab like Trending's flag). + - **Discovery hardened 2026-07-26:** fetches the bounded catalog for + creator-whitelist pubkeys only (never an unscoped relay query), then + ranks those videos by zaps. The normal blacklist and Web-of-Trust + stages still run; whitelist membership is not a moderation override. + The catalog is briefly cached so zap-total re-ranks do not repeatedly + scan relays, and an in-flight result is re-filtered after whitelist + changes so removed creators cannot briefly appear. Tests: `tests/zap-totals.test.mjs` (5). Lint + build green. - [x] **Follow-up DONE 2026-07-03 — per-card zap badges.** Video cards now show the zap total in bitcoin ORANGE (new `--color-zap` token, both themes), @@ -659,7 +666,8 @@ toward freshness and looked identical. Gave each a structural identity: schedules the batched receipt fetch, and one change listener updates every bound card as batches land. Test: `tests/video-card-zap-totals.test.mjs` (drives the REAL store). -- [ ] **VERIFY on unstable:** open Most Zapped → zapped videos rise as receipts +- [ ] **VERIFY on unstable:** open Most Zapped → only whitelisted creators are + discovered; zapped videos rise as receipts load (unzapped feed reads as Recent at first) and their cards show the orange right-aligned sats badge; unzapped cards show no badge; zap a video → within ~2min of tab re-entry its rank + badge reflect the new