Promote unstable → beta

Resolved-set durability (local cache + mirror + loss-proof republish + whitelist fallback); nav unread-dot position fix.
This commit is contained in:
thePR0M3TH3AN committed 2026-07-07 22:13:35 -04:00
commit 564f36f41f
4 files changed
+194 -20

No files matched your search

+118 -17
View File
@@ -20,11 +20,57 @@ import { devLogger } from "../utils/logger.js";
export const RESOLVED_LIST_KIND = 30000;
export const SUBMISSIONS_RESOLVED_DTAG = "bitvid:admin:submissions-resolved";
const LIST_LIMIT = 500;
const LOCAL_RESOLVED_KEY = "bitvid:admin:submissions-resolved-ids";
function normHex(value) {
return typeof value === "string" ? value.trim().toLowerCase() : "";
}
function normNpub(value) {
return typeof value === "string" ? value.trim() : "";
}
// A device-local, ever-growing set of resolved submission ids. It backs up the
// relay resolved-set two ways: (1) it hides handled items even if a cold-load
// relay read momentarily misses the list, and (2) it makes the read-modify-write
// in markSubmissionResolved loss-proof — a missed read can't republish a shrunk
// list. Cleared with site data (that's fine; the mirrored relay list restores).
function getLocalResolvedIds() {
try {
if (typeof localStorage === "undefined") {
return new Set();
}
const raw = localStorage.getItem(LOCAL_RESOLVED_KEY);
const parsed = raw ? JSON.parse(raw) : [];
return new Set(
Array.isArray(parsed) ? parsed.filter((id) => typeof id === "string" && id) : [],
);
} catch (error) {
return new Set();
}
}
function addLocalResolvedIds(ids) {
try {
if (typeof localStorage === "undefined") {
return;
}
const set = getLocalResolvedIds();
let changed = false;
for (const id of ids || []) {
if (id && !set.has(id)) {
set.add(id);
changed = true;
}
}
if (changed) {
localStorage.setItem(LOCAL_RESOLVED_KEY, JSON.stringify([...set]));
}
} catch (error) {
// best effort — local backup must never break the flow
}
}
function getManagerAndRelays(client) {
const manager =
typeof client?.getSubscriptionManager === "function"
@@ -34,12 +80,12 @@ function getManagerAndRelays(client) {
return { manager, relays };
}
// Keep pending submissions alive. They're authored by throwaway ephemeral keys
// and public relays prune events from pubkeys with no social graph, so the queue
// quietly evaporates over time. A signed event can be re-published by anyone, so
// on each fetch we re-broadcast the still-pending events back to our relays to
// refresh them. Best-effort and fire-and-forget — never blocks or fails a fetch.
function mirrorPendingSubmissions(rawEvents, client) {
// Re-broadcast signed events to our relays so aggressive relay pruning doesn't
// quietly drop them. Used for both pending submissions (ephemeral-authored, no
// social graph → pruned fast) and the admin resolved-set (so the "handled" list
// stays alive). A signed event can be re-published by anyone. Best-effort and
// fire-and-forget — never blocks or fails a fetch.
function mirrorEventsToRelays(rawEvents, client) {
try {
const pool = client?.pool;
const relays = Array.isArray(client?.relays) ? client.relays : [];
@@ -68,13 +114,18 @@ function newestByCreatedAt(events) {
/**
* Fetch the PENDING submissions addressed to an admin: parsed, deduped by
* applicant (newest wins), and excluding any already in an editor's resolved-set.
* @param {{ adminHex: string, editorHexes?: string[], client?: any }} opts
* applicant (newest wins), and excluding anything already handled — via the
* relay resolved-set, the device-local resolved cache, OR (for applications) an
* applicant already on the whitelist.
* @param {{
* adminHex: string, editorHexes?: string[], whitelistNpubs?: string[], client?: any,
* }} opts
* @returns {Promise<Array>} pending submissions, newest-first
*/
export async function fetchPendingSubmissions({
adminHex,
editorHexes = [],
whitelistNpubs = [],
client = nostrClient,
} = {}) {
const admin = normHex(adminHex);
@@ -97,15 +148,44 @@ export async function fetchPendingSubmissions({
return [];
}
const resolvedIds = await fetchResolvedIds({
// Handled = union of (relay resolved-sets) + (device-local resolved cache).
const resolvedEvents = await fetchResolvedSets({
editorHexes: [admin, ...editorHexes],
manager,
relays,
});
const relayResolvedIds = collectResolvedIds(resolvedEvents);
// Persist what the relays know into the local cache (so a later prune or a
// cold read can't resurface them), then hide by the union of both.
addLocalResolvedIds([...relayResolvedIds]);
const resolvedIds = new Set([...relayResolvedIds, ...getLocalResolvedIds()]);
// Keep the resolved-set alive on relays too — it prunes just like submissions.
mirrorEventsToRelays(resolvedEvents, client);
// Belt-and-suspenders: an application whose applicant is already whitelisted
// is durably handled even if the resolved-set were lost entirely.
const whitelisted = new Set(
(Array.isArray(whitelistNpubs) ? whitelistNpubs : [])
.map(normNpub)
.filter(Boolean),
);
const isHandled = (submission) => {
if (resolvedIds.has(submission.eventId)) {
return true;
}
if (
submission.type === "application" &&
submission.applicant &&
whitelisted.has(normNpub(submission.applicant))
) {
return true;
}
return false;
};
const parsed = (Array.isArray(subEvents) ? subEvents : [])
.map(parseSubmissionEvent)
.filter((s) => s && s.eventId && !resolvedIds.has(s.eventId))
.filter((s) => s && s.eventId && !isHandled(s))
.sort((a, b) => (b.createdAt || 0) - (a.createdAt || 0));
// Dedupe by claimed applicant (newest already first); fall back to id.
@@ -123,7 +203,7 @@ export async function fetchPendingSubmissions({
const rawPending = (Array.isArray(subEvents) ? subEvents : []).filter(
(event) => event && pendingIds.has(event.id),
);
mirrorPendingSubmissions(rawPending, client);
mirrorEventsToRelays(rawPending, client);
return pending;
}
@@ -162,15 +242,15 @@ export async function resolveEventAuthorHex({ eventId, client = nostrClient } =
}
}
async function fetchResolvedIds({ editorHexes, manager, relays }) {
// Fetch every editor's resolved-set (raw events, so the caller can also mirror
// them to keep them alive).
async function fetchResolvedSets({ editorHexes, manager, relays }) {
const authors = [...new Set(editorHexes.map(normHex).filter(Boolean))];
const ids = new Set();
if (!authors.length) {
return ids;
return [];
}
let events = [];
try {
events = await manager.list({
const events = await manager.list({
relays,
filters: [
{
@@ -181,10 +261,16 @@ async function fetchResolvedIds({ editorHexes, manager, relays }) {
},
],
});
return Array.isArray(events) ? events : [];
} catch (error) {
devLogger.warn("[submissions] Failed to list resolved-sets:", error);
return ids;
return [];
}
}
// Union the `e`-tag ids across a set of resolved-set events.
function collectResolvedIds(events) {
const ids = new Set();
for (const event of Array.isArray(events) ? events : []) {
for (const tag of event?.tags || []) {
if (Array.isArray(tag) && tag[0] === "e" && tag[1]) {
@@ -215,6 +301,10 @@ export async function markSubmissionResolved({
return null;
}
// Record locally FIRST so this id can never be lost, even if the read below
// misses and the republish would otherwise shrink the list.
addLocalResolvedIds([eventId]);
const { manager, relays } = getManagerAndRelays(client);
let current = null;
if (manager && typeof manager.list === "function" && relays.length) {
@@ -238,15 +328,26 @@ export async function markSubmissionResolved({
const tags = [["d", SUBMISSIONS_RESOLVED_DTAG]];
const seen = new Set();
// 1) Preserve existing entries (with their status/applicant audit tags).
for (const tag of current?.tags || []) {
if (Array.isArray(tag) && tag[0] === "e" && tag[1] && !seen.has(tag[1])) {
seen.add(tag[1]);
tags.push(["e", tag[1], tag[2] || "", tag[3] || ""]);
}
}
// 2) The submission being resolved right now, with its audit metadata.
if (!seen.has(eventId)) {
seen.add(eventId);
tags.push(["e", eventId, status, submission.applicant || ""]);
}
// 3) Loss-proofing: fold in every id this device knows was resolved, so a
// missed read can never republish a list smaller than what we've handled.
for (const id of getLocalResolvedIds()) {
if (id && !seen.has(id)) {
seen.add(id);
tags.push(["e", id]);
}
}
tags.push(["client", "bitvid"]);
const event = {
+6 -1
View File
@@ -973,7 +973,12 @@ export default class ApplicationBootstrap {
const editorHexes = Array.from(accessControl.getEditors?.() || [])
.map((npub) => app.safeDecodeNpub(npub))
.filter(Boolean);
const list = await fetchPendingSubmissions({ adminHex, editorHexes });
const whitelistNpubs = Array.from(accessControl.getWhitelist?.() || []);
const list = await fetchPendingSubmissions({
adminHex,
editorHexes,
whitelistNpubs,
});
pending = Array.isArray(list) && list.length > 0;
}
}
+2 -1
View File
@@ -52,11 +52,12 @@ export async function populateSubmissions(controller) {
const editorHexes = Array.from(accessControl.getEditors?.() || [])
.map((npub) => safeDecodeNpub(npub))
.filter(Boolean);
const whitelistNpubs = Array.from(accessControl.getWhitelist?.() || []);
setLoading(controller, true);
let pending = [];
try {
pending = await fetchPendingSubmissions({ adminHex, editorHexes });
pending = await fetchPendingSubmissions({ adminHex, editorHexes, whitelistNpubs });
} catch (error) {
devLogger.warn("[profileModal] Failed to load submissions:", error);
}
+68 -1
View File
@@ -1,6 +1,19 @@
import { test } from "node:test";
import { test, beforeEach } from "node:test";
import assert from "node:assert/strict";
// The facade keeps a device-local resolved-id cache in localStorage; node has
// none, so provide a minimal in-memory shim and reset it before each test.
global.localStorage = (() => {
const store = new Map();
return {
getItem: (k) => (store.has(k) ? store.get(k) : null),
setItem: (k, v) => store.set(k, String(v)),
removeItem: (k) => store.delete(k),
clear: () => store.clear(),
};
})();
beforeEach(() => global.localStorage.clear());
import {
fetchPendingSubmissions,
markSubmissionResolved,
@@ -136,6 +149,60 @@ test("markSubmissionResolved appends to the acting moderator's resolved-set", as
assert.equal(newTag[3], "npub-bob", "records applicant");
});
test("fetchPendingSubmissions hides an application whose applicant is already whitelisted", async () => {
const events = [
submissionEvent({ id: "wl1", applicant: "npub-whitelisted", created_at: 100 }),
submissionEvent({ id: "wl2", applicant: "npub-other", created_at: 100 }),
];
const pending = await fetchPendingSubmissions({
adminHex: ADMIN,
whitelistNpubs: ["npub-whitelisted"],
client: mockClient({ events }),
});
assert.deepEqual(
pending.map((s) => s.applicant),
["npub-other"],
"whitelisted applicant's application is treated as handled",
);
});
test("fetchPendingSubmissions hides a locally-resolved submission even with an empty relay resolved-set", async () => {
const events = [submissionEvent({ id: "loc1", applicant: "npub-a", created_at: 100 })];
const client = mockClient({ events });
// No resolved-set on relays; resolving seeds only the device-local cache.
await markSubmissionResolved({
submission: { eventId: "evt-loc1" },
actingHex: MOD,
client,
});
const pending = await fetchPendingSubmissions({ adminHex: ADMIN, client });
assert.equal(pending.length, 0, "local cache keeps it hidden after a cold read");
});
test("markSubmissionResolved never republishes a shrunk list (loss-proof via local cache)", async () => {
let published = null;
// Client has NO resolved-set events, so every read-modify-write read misses.
const client = mockClient({ events: [], onPublish: (e) => (published = e) });
await markSubmissionResolved({
submission: { eventId: "evtA", applicant: "npub-a" },
status: "approved",
actingHex: MOD,
client,
});
await markSubmissionResolved({
submission: { eventId: "evtB", applicant: "npub-b" },
status: "denied",
actingHex: MOD,
client,
});
const eIds = published.tags.filter((t) => t[0] === "e").map((t) => t[1]).sort();
assert.deepEqual(
eIds,
["evtA", "evtB"],
"the earlier id is carried forward from the local cache despite the missed read",
);
});
test("resolveEventAuthorHex returns the blocked event's author (lowercased)", async () => {
const blockedEventId = "f".repeat(64);
const authorHex = "C".repeat(64); // stored upper-case to prove normalization