From 9310929f0def553f6dcfe2cecbb030fd7253a453 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Fri, 2 Jan 2026 12:18:57 -0500 Subject: [PATCH] Ensure trusted seeds hydrate before app creation --- js/bootstrap.js | 108 +++++++++++++++++++++++--- js/index.js | 22 +++--- tests/moderation/trust-seeds.test.mjs | 39 +++++++++- 3 files changed, 147 insertions(+), 22 deletions(-) diff --git a/js/bootstrap.js b/js/bootstrap.js index b535f438..9f1095eb 100644 --- a/js/bootstrap.js +++ b/js/bootstrap.js @@ -12,6 +12,9 @@ import nostrService from "./services/nostrService.js"; import r2Service from "./services/r2Service.js"; import { loadView } from "./viewManager.js"; import hashtagPreferences from "./services/hashtagPreferencesService.js"; +import { devLogger, userLogger } from "./utils/logger.js"; + +const TRUST_SEED_READY_TIMEOUT_MS = 3500; function normalizeNpub(value) { return typeof value === "string" ? value.trim() : ""; @@ -44,6 +47,53 @@ function buildTrustedSeeds({ superAdmin, editors, fallbackSeeds }) { return seeds; } +function delay(ms = 0) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +async function waitForRelaysReady({ timeoutMs = TRUST_SEED_READY_TIMEOUT_MS } = {}) { + const start = Date.now(); + while (Date.now() - start < timeoutMs) { + if ( + nostrService?.nostrClient?.relays && + Array.isArray(nostrService.nostrClient.relays) && + nostrService.nostrClient.relays.length > 0 + ) { + return true; + } + await delay(125); + } + return false; +} + +async function waitForAccessControl({ timeoutMs = TRUST_SEED_READY_TIMEOUT_MS } = {}) { + if (!accessControl || typeof accessControl.ensureReady !== "function") { + return { ok: true, timedOut: false }; + } + + let timedOut = false; + const timeoutPromise = new Promise((_, reject) => { + setTimeout(() => { + timedOut = true; + reject(new Error("accessControl ensureReady timed out")); + }, timeoutMs); + }); + + try { + await Promise.race([accessControl.ensureReady(), timeoutPromise]); + return { ok: true, timedOut: false }; + } catch (error) { + if (timedOut) { + devLogger.warn( + "[bootstrap] access control hydration timed out; falling back to default trust seeds.", + error, + ); + return { ok: true, timedOut: true }; + } + throw error; + } +} + async function bootstrapTrustedSeeds() { if ( !( @@ -67,20 +117,49 @@ async function bootstrapTrustedSeeds() { fallbackSeeds: DEFAULT_TRUST_SEED_NPUBS, }); moderationService.setTrustedSeeds(seeds); - } catch { - // Swallow bootstrap errors so login flow can continue; flag enables quick rollback. + if ( + moderationService && + typeof moderationService.recomputeAllSummaries === "function" + ) { + moderationService.recomputeAllSummaries(); + } + } catch (error) { + userLogger.warn("[bootstrap] Failed to apply trusted seeds", error); } }; - try { - if (accessControl && typeof accessControl.ensureReady === "function") { - await accessControl.ensureReady(); + let attempts = 0; + const hydrate = async () => { + attempts += 1; + try { + const result = await waitForAccessControl(); + applySeeds(); + return result; + } catch (error) { + userLogger.warn( + "[bootstrap] Failed to hydrate admin lists for trusted seeds", + error, + ); + return { ok: false, timedOut: false, error }; + } + }; + + let hydrateResult = await hydrate(); + + if (!hydrateResult?.ok && attempts < 2) { + const relaysReady = await waitForRelaysReady(); + if (relaysReady) { + hydrateResult = await hydrate(); + } else { + devLogger.warn( + "[bootstrap] Skipping trusted seed retry because relays were not ready in time.", + ); } - } catch { - // Swallow bootstrap errors so login flow can continue; flag enables quick rollback. } - applySeeds(); + if (!hydrateResult?.ok) { + applySeeds(); + } const applyOnChange = () => { applySeeds(); @@ -95,7 +174,7 @@ async function bootstrapTrustedSeeds() { } } -bootstrapTrustedSeeds(); +export const trustedSeedsReadyPromise = bootstrapTrustedSeeds(); function mergeServices(overrides = {}) { const merged = { nostrService, r2Service, hashtagPreferences }; @@ -115,7 +194,16 @@ function mergeServices(overrides = {}) { return merged; } -export function createApplication({ services, loadView: loadViewOverride } = {}) { +export async function createApplication({ services, loadView: loadViewOverride } = {}) { + try { + await trustedSeedsReadyPromise; + } catch (error) { + userLogger.warn( + "[bootstrap] Failed to await trusted seed hydration before creating application", + error, + ); + } + return new Application({ services: mergeServices(services), loadView: typeof loadViewOverride === "function" ? loadViewOverride : loadView, diff --git a/js/index.js b/js/index.js index e2189682..553d3e60 100644 --- a/js/index.js +++ b/js/index.js @@ -3,7 +3,6 @@ import { validateInstanceConfig } from "../config/validate-config.js"; import { ASSET_VERSION } from "../config/asset-version.js"; import "./bufferPolyfill.js"; -import Application from "./app.js"; import { setApplication, setApplicationReady } from "./applicationContext.js"; import nostrService from "./services/nostrService.js"; import r2Service from "./services/r2Service.js"; @@ -37,6 +36,7 @@ import { accessControl } from "./accessControl.js"; import { nostrClient } from "./nostrClientFacade.js"; import { userBlocks } from "./userBlocks.js"; import { relayManager } from "./relayManager.js"; +import createApplication from "./bootstrap.js"; validateInstanceConfig(); @@ -354,17 +354,17 @@ function startApplication() { return applicationReadyPromise; } - application = new Application({ - services: { - nostrService, - r2Service, - }, - loadView, - }); - - setApplication(application); - const startupPromise = (async () => { + application = await createApplication({ + services: { + nostrService, + r2Service, + }, + loadView, + }); + + setApplication(application); + await application.init(); if (typeof application.start === "function") { await application.start(); diff --git a/tests/moderation/trust-seeds.test.mjs b/tests/moderation/trust-seeds.test.mjs index 06901be9..ec81cf20 100644 --- a/tests/moderation/trust-seeds.test.mjs +++ b/tests/moderation/trust-seeds.test.mjs @@ -87,6 +87,37 @@ test("trusted seeds contribute to trusted report counts", (t) => { assert.deepEqual(reporters.map((entry) => entry.pubkey), [seedHex]); }); +test("trusted seed updates recompute guest report summaries", (t) => { + withMockedNostrTools(t); + + const eventId = "a".repeat(64); + const reporterHex = "b".repeat(64); + const reporterNpub = `npub${reporterHex}`; + + const { service } = createModerationServiceHarness(t); + + const report = createReportEvent({ + id: "c".repeat(64), + reporter: reporterHex, + eventId, + createdAt: 1_700_000_999, + type: "spam", + }); + + service.ingestReportEvent(report); + + const preSeedSummary = service.getTrustedReportSummary(eventId); + assert.equal(preSeedSummary.totalTrusted, 0); + assert.equal(preSeedSummary.types.spam?.trusted ?? 0, 0); + + service.setTrustedSeeds([reporterNpub]); + + const postSeedSummary = service.getTrustedReportSummary(eventId); + assert.equal(postSeedSummary.totalTrusted, 1); + assert.equal(postSeedSummary.types.spam.trusted, 1); + assert.equal(service.trustedReportCount(eventId, "spam"), 1); +}); + test("moderator seeds populate trusted contacts", (t) => { withMockedNostrTools(t); @@ -150,6 +181,7 @@ test("bootstrap seeds track editor roster and ignore whitelist-only changes", as }; const setTrustedSeedsCalls = []; + const recomputeCalls = []; const moderationServiceMock = { setTrustedSeeds: (seeds) => { const snapshot = []; @@ -160,12 +192,16 @@ test("bootstrap seeds track editor roster and ignore whitelist-only changes", as } setTrustedSeedsCalls.push(snapshot); }, + recomputeAllSummaries: () => { + recomputeCalls.push(true); + }, }; globalThis.__bootstrapAccessControlMock = accessControlMock; globalThis.__bootstrapModerationServiceMock = moderationServiceMock; - await import("../../js/bootstrap.js"); + const bootstrapModule = await import("../../js/bootstrap.js"); + await bootstrapModule.trustedSeedsReadyPromise; assert.equal(ensureReadyCalls, 1); assert.ok( @@ -174,6 +210,7 @@ test("bootstrap seeds track editor roster and ignore whitelist-only changes", as ); assert.equal(setTrustedSeedsCalls.length, 1); + assert.equal(recomputeCalls.length, 1, "summaries should recompute after trusted seeds apply"); const [initialSeeds] = setTrustedSeedsCalls; assert.ok( initialSeeds.includes(ADMIN_SUPER_NPUB),