fix(zaps): scope discovery to approved creators

This commit is contained in:
thePR0M3TH3AN committed 2026-07-25 23:24:49 -04:00
1 parent 7739a934f9
commit 2cda5025d4
7 files changed
+233 -16

No files matched your search

+1
View File
@@ -21,6 +21,7 @@ export {
} from "./sorters.js";
export {
createActiveNostrSource,
createWhitelistedAuthorsNostrSource,
createSubscriptionAuthorsSource,
createWatchHistoryPointerSource,
} from "./sources.js";
+20 -5
View File
@@ -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) =>
+83
View File
@@ -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);
+24 -9
View File
@@ -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 <bitlogin-auth> 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 });
}
+23 -1
View File
@@ -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;
}
});
+73
View File
@@ -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, []);
});
+9 -1
View File
@@ -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