Merge branch 'unstable' into security-fix-disable-dev-mode-6861215856535760775

This commit is contained in:
thePR0M3TH3AN authored and GitHub committed 2026-02-08 13:26:56 -05:00
commit f99f3f5ea8
20 files changed
+2659 -1717

No files matched your search

+63
View File
@@ -0,0 +1,63 @@
// benchmarks/kids_scoring_bench.js
import { createKidsScorerStage } from "../js/feedEngine/kidsScoring.js";
import { performance } from "perf_hooks";
async function runBenchmark() {
const itemCount = 200000;
const items = [];
// Create mock items with diverse data to exercise different paths
for (let i = 0; i < itemCount; i++) {
items.push({
video: {
id: `v${i}`,
pubkey: `pubkey${i % 100}`,
duration: 300 + (i % 600), // 5 to 15 minutes
tags: [
["t", "kids"],
["t", "learning"],
["t", i % 2 === 0 ? "preschool" : "science"],
],
viewCount: i * 10,
created_at: Date.now() / 1000 - (i % 10000),
moderation: { trustedCount: i % 5 },
},
metadata: {},
});
}
const context = {
runtime: {
ageGroup: "preschool",
trustedAuthors: ["pubkey1", "pubkey2"],
},
addWhy: () => {}, // Mock addWhy
};
const scorer = createKidsScorerStage();
console.log(`Starting kids scoring benchmark with ${itemCount} items...`);
// Warmup
await scorer([...items.slice(0, 1000)], context);
const iterations = 100;
let totalTime = 0;
for (let i = 0; i < iterations; i++) {
// Reset metadata for each run to be cleaner
items.forEach(item => item.metadata = {});
const start = performance.now();
await scorer(items, context);
const end = performance.now();
totalTime += (end - start);
if (i % 5 === 0) process.stdout.write(".");
}
const avgTime = totalTime / iterations;
console.log(`\nAverage time per run: ${avgTime.toFixed(4)}ms`);
}
runBenchmark().catch(console.error);
+2
View File
@@ -33,6 +33,8 @@ export const IS_DEV_MODE = false;
* a quieter dev mode without disabling other development helpers entirely.
* Use the runtime override documented in `js/config.js` if you need to toggle
* verbosity without editing this file.
*
* Security: This must be false in production.
*/
export const IS_VERBOSE_DEV_MODE = false;
+118 -415
View File
@@ -177,7 +177,14 @@ import { createPlaybackCoordinator } from "./app/playbackCoordinator.js";
import { createAuthSessionCoordinator } from "./app/authSessionCoordinator.js";
import { createModalCoordinator } from "./app/modalCoordinator.js";
import { createModerationCoordinator } from "./app/moderationCoordinator.js";
import { createRouterCoordinator } from "./app/routerCoordinator.js";
import { createUiCoordinator } from "./app/uiCoordinator.js";
import { HEX64_REGEX } from "./utils/hex.js";
import {
safeEncodeNpub,
safeDecodeNpub,
normalizeHexPubkey,
} from "./utils/nostrHelpers.js";
const recordVideoViewApi = (...args) => recordVideoView(nostrClient, ...args);
@@ -240,7 +247,7 @@ class Application {
}
constructor({ services = {}, ui = {}, helpers = {}, loadView: viewLoader } = {}) {
this.loadView = typeof viewLoader === "function" ? viewLoader : null;
this.loadView = viewLoader || null;
const bootstrapServices = {
...services,
@@ -640,6 +647,22 @@ class Application {
writable: true,
configurable: true,
});
Object.defineProperty(this, "_router", {
value: bindCoordinator(this, createRouterCoordinator({
devLogger,
})),
writable: true,
configurable: true,
});
Object.defineProperty(this, "_ui", {
value: bindCoordinator(this, createUiCoordinator({
devLogger,
})),
writable: true,
configurable: true,
});
}
get modalVideo() {
@@ -1208,92 +1231,72 @@ class Application {
await watchHistoryInitPromise;
}
async initializeServices() {
await this._initViewManager();
this._warmupNostrExtension();
this._initServiceWorker();
this._initAuthAndModeration();
}
initializeUIResources() {
return this._initModals();
}
async initializeNetwork() {
await this._initNostr();
}
async initializeDataAndSession() {
await this._initTrustedSeeds();
this._initViewCounter();
this._initAccessControl();
await this._initSessionActor();
this._initAccessControlListeners();
}
initializeUserInterface() {
this._initUI();
}
async performAutoLogin() {
await this._initAutoLogin();
}
async finalizeInitialization() {
this.setupEventListeners();
await this._initWatchHistory();
this.checkUrlParams();
}
async init() {
try {
await this._initViewManager();
this._warmupNostrExtension();
this._initServiceWorker();
this._initAuthAndModeration();
await this.initializeServices();
const modalBootstrapPromise = this._initModals();
const modalPromise = this.initializeUIResources();
await this.initializeNetwork();
await modalPromise;
await this._initNostr();
await this.initializeDataAndSession();
await modalBootstrapPromise;
await this._initTrustedSeeds();
this._initViewCounter();
this._initAccessControl();
await this._initSessionActor();
this._initAccessControlListeners();
this._initUI();
await this._initAutoLogin();
// 5. Setup general event listeners
this.setupEventListeners();
await this._initWatchHistory();
// 9. Check URL ?v= param
this.checkUrlParams();
this.initializeUserInterface();
await this.performAutoLogin();
await this.finalizeInitialization();
} catch (error) {
devLogger.error("Init failed:", error);
this.showError("Failed to connect to Nostr relay");
}
}
goToProfile(pubkey) {
if (typeof pubkey !== "string") {
this.showError("No creator info available.");
return;
}
let candidate = pubkey.trim();
if (!candidate) {
this.showError("No creator info available.");
return;
}
if (candidate.startsWith("nostr:")) {
candidate = candidate.slice("nostr:".length);
}
const normalizedHex = this.normalizeHexPubkey(candidate);
const npub = normalizedHex ? this.safeEncodeNpub(normalizedHex) : null;
if (!npub) {
devLogger.warn(
"[Application] Invalid pubkey for profile navigation:",
candidate,
);
this.showError("Invalid creator profile.");
return;
}
window.location.hash = `#view=channel-profile&npub=${npub}`;
goToProfile(...args) {
this._initCoordinators();
return this._router.goToProfile(...args);
}
openCreatorChannel() {
if (!this.currentVideo || !this.currentVideo.pubkey) {
this.showError("No creator info available.");
return;
}
try {
// Encode the hex pubkey to npub
const npub = window.NostrTools.nip19.npubEncode(this.currentVideo.pubkey);
// Close the video modal
this.hideModal();
// Switch to channel profile view
window.location.hash = `#view=channel-profile&npub=${npub}`;
} catch (err) {
devLogger.error("Failed to open creator channel:", err);
this.showError("Could not open channel.");
}
openCreatorChannel(...args) {
this._initCoordinators();
return this._router.openCreatorChannel(...args);
}
openWalletPane() {
@@ -1337,41 +1340,14 @@ class Application {
}
}
dispatchAuthLoadingState(detail = {}) {
if (typeof window === "undefined" || typeof window.dispatchEvent !== "function") {
return false;
}
try {
const payload = { ...(typeof detail === "object" && detail ? detail : {}) };
window.dispatchEvent(new CustomEvent("bitvid:auth-loading-state", { detail: payload }));
return true;
} catch (error) {
devLogger.warn("[Application] Failed to dispatch auth loading state:", error);
return false;
}
dispatchAuthLoadingState(...args) {
this._initCoordinators();
return this._ui.dispatchAuthLoadingState(...args);
}
updateAuthLoadingState(partial = {}) {
const baseState =
this.authLoadingState && typeof this.authLoadingState === "object"
? this.authLoadingState
: { profile: "idle", lists: "idle", dms: "idle" };
const nextState = {
...baseState,
...(partial && typeof partial === "object" ? partial : {}),
};
this.authLoadingState = nextState;
const root = typeof document !== "undefined" ? document.documentElement : null;
if (root) {
root.dataset.authProfileLoading = nextState.profile || "idle";
root.dataset.authListsLoading = nextState.lists || "idle";
root.dataset.authDmsLoading = nextState.dms || "idle";
}
this.dispatchAuthLoadingState(nextState);
return nextState;
updateAuthLoadingState(...args) {
this._initCoordinators();
return this._ui.updateAuthLoadingState(...args);
}
normalizeModalTrigger(...args) {
@@ -1663,23 +1639,9 @@ class Application {
this.closeAllMoreMenus();
}
handleProfileChannelLink(element) {
if (!(element instanceof HTMLElement)) {
return;
}
const targetNpub =
typeof element.dataset.targetNpub === "string"
? element.dataset.targetNpub
: "";
if (this.profileController) {
this.profileController.hide();
}
if (targetNpub) {
window.location.hash = `#view=channel-profile&npub=${encodeURIComponent(
targetNpub,
)}`;
}
handleProfileChannelLink(...args) {
this._initCoordinators();
return this._router.handleProfileChannelLink(...args);
}
async handleLoginModalSuccess(payload = {}) {
@@ -3260,36 +3222,9 @@ class Application {
return this._moderation.refreshVisibleModerationUi(...args);
}
updateActiveProfileUI(pubkey, profile = {}) {
if (this.profileController) {
this.profileController.handleProfileUpdated({
pubkey,
profile,
});
return;
}
const picture = profile.picture || "assets/svg/default-profile.svg";
if (this.profileAvatar) {
this.profileAvatar.src = picture;
}
const channelLink = document.getElementById("profileChannelLink");
if (channelLink instanceof HTMLElement) {
const targetNpub = this.safeEncodeNpub(pubkey);
if (targetNpub) {
channelLink.href = `#view=channel-profile&npub=${targetNpub}`;
channelLink.dataset.targetNpub = targetNpub;
channelLink.classList.remove("hidden");
} else {
channelLink.removeAttribute("href");
if (channelLink.dataset) {
delete channelLink.dataset.targetNpub;
}
channelLink.classList.add("hidden");
}
}
updateActiveProfileUI(...args) {
this._initCoordinators();
return this._ui.updateActiveProfileUI(...args);
}
async hydrateNwcSettingsForPubkey(pubkey) {
@@ -3370,226 +3305,49 @@ class Application {
return this.nwcSettingsService.clearStoredNwcSettings(pubkey, options);
}
applyAuthenticatedUiState() {
if (this.loginButton) {
this.loginButton.classList.add("hidden");
this.loginButton.setAttribute("hidden", "");
}
if (this.logoutButton) {
this.logoutButton.classList.remove("hidden");
}
if (this.userStatus) {
this.userStatus.classList.add("hidden");
}
if (this.uploadButton) {
this.uploadButton.classList.remove("hidden");
this.uploadButton.removeAttribute("hidden");
}
if (this.profileButton) {
this.profileButton.classList.remove("hidden");
this.profileButton.removeAttribute("hidden");
}
const subscriptionsLink = this.resolveSubscriptionsLink();
if (subscriptionsLink) {
subscriptionsLink.classList.remove("hidden");
}
const forYouLink = this.resolveForYouLink();
if (forYouLink) {
forYouLink.classList.remove("hidden");
}
const exploreLink = this.resolveExploreLink();
if (exploreLink) {
exploreLink.classList.remove("hidden");
}
applyAuthenticatedUiState(...args) {
this._initCoordinators();
return this._ui.applyAuthenticatedUiState(...args);
}
applyLoggedOutUiState() {
if (this.loginButton) {
this.loginButton.classList.remove("hidden");
this.loginButton.removeAttribute("hidden");
}
if (this.logoutButton) {
this.logoutButton.classList.add("hidden");
}
if (this.userStatus) {
this.userStatus.classList.add("hidden");
}
if (this.userPubKey) {
this.userPubKey.textContent = "";
}
if (this.uploadButton) {
this.uploadButton.classList.add("hidden");
this.uploadButton.setAttribute("hidden", "");
}
if (this.profileButton) {
this.profileButton.classList.add("hidden");
this.profileButton.setAttribute("hidden", "");
}
const subscriptionsLink = this.resolveSubscriptionsLink();
if (subscriptionsLink) {
subscriptionsLink.classList.add("hidden");
}
const forYouLink = this.resolveForYouLink();
if (forYouLink) {
forYouLink.classList.add("hidden");
}
const exploreLink = this.resolveExploreLink();
if (exploreLink) {
exploreLink.classList.add("hidden");
}
applyLoggedOutUiState(...args) {
this._initCoordinators();
return this._ui.applyLoggedOutUiState(...args);
}
syncAuthUiState() {
if (this.isUserLoggedIn()) {
this.applyAuthenticatedUiState();
} else {
this.applyLoggedOutUiState();
}
syncAuthUiState(...args) {
this._initCoordinators();
return this._ui.syncAuthUiState(...args);
}
refreshChromeElements() {
const doc = typeof document !== "undefined" ? document : null;
const findElement = (id) => {
if (!doc || typeof doc.getElementById !== "function") {
return null;
}
const element = doc.getElementById(id);
if (!element || typeof element.addEventListener !== "function") {
return null;
}
return element;
};
this.loginButton = findElement("loginButton");
this.logoutButton = findElement("logoutButton");
this.uploadButton = findElement("uploadButton");
this.profileButton = findElement("profileButton");
this.closeLoginModalBtn = findElement("closeLoginModal");
return {
loginButton: this.loginButton,
logoutButton: this.logoutButton,
uploadButton: this.uploadButton,
profileButton: this.profileButton,
closeLoginModalButton: this.closeLoginModalBtn,
};
refreshChromeElements(...args) {
this._initCoordinators();
return this._ui.refreshChromeElements(...args);
}
resolveSubscriptionsLink() {
if (
this.subscriptionsLink instanceof HTMLElement &&
this.subscriptionsLink.isConnected
) {
return this.subscriptionsLink;
}
const linkCandidate = document.getElementById("subscriptionsLink");
if (linkCandidate instanceof HTMLElement) {
this.subscriptionsLink = linkCandidate;
return this.subscriptionsLink;
}
this.subscriptionsLink = null;
return null;
resolveSubscriptionsLink(...args) {
this._initCoordinators();
return this._ui.resolveSubscriptionsLink(...args);
}
resolveForYouLink() {
if (this.forYouLink instanceof HTMLElement && this.forYouLink.isConnected) {
return this.forYouLink;
}
const linkCandidate = document.getElementById("forYouLink");
if (linkCandidate instanceof HTMLElement) {
this.forYouLink = linkCandidate;
return this.forYouLink;
}
this.forYouLink = null;
return null;
resolveForYouLink(...args) {
this._initCoordinators();
return this._ui.resolveForYouLink(...args);
}
resolveExploreLink() {
if (
this.exploreLink instanceof HTMLElement &&
this.exploreLink.isConnected
) {
return this.exploreLink;
}
const linkCandidate = document.getElementById("exploreLink");
if (linkCandidate instanceof HTMLElement) {
this.exploreLink = linkCandidate;
return this.exploreLink;
}
this.exploreLink = null;
return null;
resolveExploreLink(...args) {
this._initCoordinators();
return this._ui.resolveExploreLink(...args);
}
hydrateSidebarNavigation() {
const chromeElements = this.refreshChromeElements();
this.resolveSubscriptionsLink();
this.resolveForYouLink();
this.resolveExploreLink();
if (this.appChromeController) {
if (typeof this.appChromeController.setElements === "function") {
this.appChromeController.setElements(chromeElements);
} else {
if (this.appChromeController.elements) {
Object.assign(this.appChromeController.elements, chromeElements);
}
this.appChromeController.initialize();
}
}
this.syncAuthUiState();
hydrateSidebarNavigation(...args) {
this._initCoordinators();
return this._ui.hydrateSidebarNavigation(...args);
}
maybeShowExperimentalLoginWarning(provider) {
const normalizedProvider =
typeof provider === "string" ? provider.trim().toLowerCase() : "";
if (normalizedProvider !== "nsec" && normalizedProvider !== "nip46") {
return;
}
const now = Date.now();
if (
this.lastExperimentalWarningKey === normalizedProvider &&
typeof this.lastExperimentalWarningAt === "number" &&
now - this.lastExperimentalWarningAt < 2000
) {
return;
}
this.lastExperimentalWarningKey = normalizedProvider;
this.lastExperimentalWarningAt = now;
const providerLabel =
normalizedProvider === "nsec"
? "Direct nsec or seed"
: "NIP-46 remote signer";
this.showStatus(
`${providerLabel} logins are still in development and may not work well yet. We recommend using a NIP-07 browser extension for the most reliable experience.`,
{ showSpinner: false, autoHideMs: 5000 },
);
maybeShowExperimentalLoginWarning(...args) {
this._initCoordinators();
return this._ui.maybeShowExperimentalLoginWarning(...args);
}
async handlePermissionPromptRequest() {
@@ -4886,46 +4644,11 @@ class Application {
* Simple helper to safely encode an npub.
*/
safeEncodeNpub(pubkey) {
if (typeof pubkey !== "string") {
return null;
}
const trimmed = pubkey.trim();
if (!trimmed) {
return null;
}
if (trimmed.startsWith("npub1")) {
return trimmed;
}
try {
return window.NostrTools.nip19.npubEncode(trimmed);
} catch (err) {
return null;
}
return safeEncodeNpub(pubkey);
}
safeDecodeNpub(npub) {
if (typeof npub !== "string") {
return null;
}
const trimmed = npub.trim();
if (!trimmed) {
return null;
}
try {
const decoded = window.NostrTools.nip19.decode(trimmed);
if (decoded.type === "npub" && typeof decoded.data === "string") {
return decoded.data;
}
} catch (err) {
return null;
}
return null;
return safeDecodeNpub(npub);
}
selectPreferredCreatorName(candidates = []) {
@@ -5357,27 +5080,7 @@ class Application {
}
normalizeHexPubkey(pubkey) {
if (typeof pubkey !== "string") {
return null;
}
const trimmed = pubkey.trim();
if (!trimmed) {
return null;
}
if (HEX64_REGEX.test(trimmed)) {
return trimmed.toLowerCase();
}
if (trimmed.startsWith("npub1")) {
const decoded = this.safeDecodeNpub(trimmed);
if (decoded && HEX64_REGEX.test(decoded)) {
return decoded.toLowerCase();
}
}
return null;
return normalizeHexPubkey(pubkey);
}
/**
+88
View File
@@ -0,0 +1,88 @@
// js/app/routerCoordinator.js
/**
* Routing logic for the application.
*
* All module-level dependencies are injected from the Application
* composition root rather than imported at module scope.
*
* Methods use `this` which is bound to the Application instance.
*/
export function createRouterCoordinator(deps) {
const {
devLogger,
} = deps;
return {
goToProfile(pubkey) {
if (typeof pubkey !== "string") {
this.showError("No creator info available.");
return;
}
let candidate = pubkey.trim();
if (!candidate) {
this.showError("No creator info available.");
return;
}
if (candidate.startsWith("nostr:")) {
candidate = candidate.slice("nostr:".length);
}
const normalizedHex = this.normalizeHexPubkey(candidate);
const npub = normalizedHex ? this.safeEncodeNpub(normalizedHex) : null;
if (!npub) {
devLogger.warn(
"[Application] Invalid pubkey for profile navigation:",
candidate,
);
this.showError("Invalid creator profile.");
return;
}
window.location.hash = `#view=channel-profile&npub=${npub}`;
},
openCreatorChannel() {
if (!this.currentVideo || !this.currentVideo.pubkey) {
this.showError("No creator info available.");
return;
}
try {
// Encode the hex pubkey to npub
const npub = window.NostrTools.nip19.npubEncode(this.currentVideo.pubkey);
// Close the video modal
this.hideModal();
// Switch to channel profile view
window.location.hash = `#view=channel-profile&npub=${npub}`;
} catch (err) {
devLogger.error("Failed to open creator channel:", err);
this.showError("Could not open channel.");
}
},
handleProfileChannelLink(element) {
if (!(element instanceof HTMLElement)) {
return;
}
const targetNpub =
typeof element.dataset.targetNpub === "string"
? element.dataset.targetNpub
: "";
if (this.profileController) {
this.profileController.hide();
}
if (targetNpub) {
window.location.hash = `#view=channel-profile&npub=${encodeURIComponent(
targetNpub,
)}`;
}
}
};
}
+308
View File
@@ -0,0 +1,308 @@
// js/app/uiCoordinator.js
/**
* UI State and Chrome management.
*
* All module-level dependencies are injected from the Application
* composition root rather than imported at module scope.
*
* Methods use `this` which is bound to the Application instance.
*/
export function createUiCoordinator(deps) {
const {
devLogger,
} = deps;
return {
dispatchAuthLoadingState(detail = {}) {
if (typeof window === "undefined" || typeof window.dispatchEvent !== "function") {
return false;
}
try {
const payload = { ...(typeof detail === "object" && detail ? detail : {}) };
window.dispatchEvent(new CustomEvent("bitvid:auth-loading-state", { detail: payload }));
return true;
} catch (error) {
devLogger.warn("[Application] Failed to dispatch auth loading state:", error);
return false;
}
},
updateAuthLoadingState(partial = {}) {
const baseState =
this.authLoadingState && typeof this.authLoadingState === "object"
? this.authLoadingState
: { profile: "idle", lists: "idle", dms: "idle" };
const nextState = {
...baseState,
...(partial && typeof partial === "object" ? partial : {}),
};
this.authLoadingState = nextState;
const root = typeof document !== "undefined" ? document.documentElement : null;
if (root) {
root.dataset.authProfileLoading = nextState.profile || "idle";
root.dataset.authListsLoading = nextState.lists || "idle";
root.dataset.authDmsLoading = nextState.dms || "idle";
}
this.dispatchAuthLoadingState(nextState);
return nextState;
},
updateActiveProfileUI(pubkey, profile = {}) {
if (this.profileController) {
this.profileController.handleProfileUpdated({
pubkey,
profile,
});
return;
}
const picture = profile.picture || "assets/svg/default-profile.svg";
if (this.profileAvatar) {
this.profileAvatar.src = picture;
}
const channelLink = document.getElementById("profileChannelLink");
if (channelLink instanceof HTMLElement) {
const targetNpub = this.safeEncodeNpub(pubkey);
if (targetNpub) {
channelLink.href = `#view=channel-profile&npub=${targetNpub}`;
channelLink.dataset.targetNpub = targetNpub;
channelLink.classList.remove("hidden");
} else {
channelLink.removeAttribute("href");
if (channelLink.dataset) {
delete channelLink.dataset.targetNpub;
}
channelLink.classList.add("hidden");
}
}
},
applyAuthenticatedUiState() {
if (this.loginButton) {
this.loginButton.classList.add("hidden");
this.loginButton.setAttribute("hidden", "");
}
if (this.logoutButton) {
this.logoutButton.classList.remove("hidden");
}
if (this.userStatus) {
this.userStatus.classList.add("hidden");
}
if (this.uploadButton) {
this.uploadButton.classList.remove("hidden");
this.uploadButton.removeAttribute("hidden");
}
if (this.profileButton) {
this.profileButton.classList.remove("hidden");
this.profileButton.removeAttribute("hidden");
}
const subscriptionsLink = this.resolveSubscriptionsLink();
if (subscriptionsLink) {
subscriptionsLink.classList.remove("hidden");
}
const forYouLink = this.resolveForYouLink();
if (forYouLink) {
forYouLink.classList.remove("hidden");
}
const exploreLink = this.resolveExploreLink();
if (exploreLink) {
exploreLink.classList.remove("hidden");
}
},
applyLoggedOutUiState() {
if (this.loginButton) {
this.loginButton.classList.remove("hidden");
this.loginButton.removeAttribute("hidden");
}
if (this.logoutButton) {
this.logoutButton.classList.add("hidden");
}
if (this.userStatus) {
this.userStatus.classList.add("hidden");
}
if (this.userPubKey) {
this.userPubKey.textContent = "";
}
if (this.uploadButton) {
this.uploadButton.classList.add("hidden");
this.uploadButton.setAttribute("hidden", "");
}
if (this.profileButton) {
this.profileButton.classList.add("hidden");
this.profileButton.setAttribute("hidden", "");
}
const subscriptionsLink = this.resolveSubscriptionsLink();
if (subscriptionsLink) {
subscriptionsLink.classList.add("hidden");
}
const forYouLink = this.resolveForYouLink();
if (forYouLink) {
forYouLink.classList.add("hidden");
}
const exploreLink = this.resolveExploreLink();
if (exploreLink) {
exploreLink.classList.add("hidden");
}
},
syncAuthUiState() {
if (this.isUserLoggedIn()) {
this.applyAuthenticatedUiState();
} else {
this.applyLoggedOutUiState();
}
},
refreshChromeElements() {
const doc = typeof document !== "undefined" ? document : null;
const findElement = (id) => {
if (!doc || typeof doc.getElementById !== "function") {
return null;
}
const element = doc.getElementById(id);
if (!element || typeof element.addEventListener !== "function") {
return null;
}
return element;
};
this.loginButton = findElement("loginButton");
this.logoutButton = findElement("logoutButton");
this.uploadButton = findElement("uploadButton");
this.profileButton = findElement("profileButton");
this.closeLoginModalBtn = findElement("closeLoginModal");
return {
loginButton: this.loginButton,
logoutButton: this.logoutButton,
uploadButton: this.uploadButton,
profileButton: this.profileButton,
closeLoginModalButton: this.closeLoginModalBtn,
};
},
resolveSubscriptionsLink() {
if (
this.subscriptionsLink instanceof HTMLElement &&
this.subscriptionsLink.isConnected
) {
return this.subscriptionsLink;
}
const linkCandidate = document.getElementById("subscriptionsLink");
if (linkCandidate instanceof HTMLElement) {
this.subscriptionsLink = linkCandidate;
return this.subscriptionsLink;
}
this.subscriptionsLink = null;
return null;
},
resolveForYouLink() {
if (this.forYouLink instanceof HTMLElement && this.forYouLink.isConnected) {
return this.forYouLink;
}
const linkCandidate = document.getElementById("forYouLink");
if (linkCandidate instanceof HTMLElement) {
this.forYouLink = linkCandidate;
return this.forYouLink;
}
this.forYouLink = null;
return null;
},
resolveExploreLink() {
if (
this.exploreLink instanceof HTMLElement &&
this.exploreLink.isConnected
) {
return this.exploreLink;
}
const linkCandidate = document.getElementById("exploreLink");
if (linkCandidate instanceof HTMLElement) {
this.exploreLink = linkCandidate;
return this.exploreLink;
}
this.exploreLink = null;
return null;
},
hydrateSidebarNavigation() {
const chromeElements = this.refreshChromeElements();
this.resolveSubscriptionsLink();
this.resolveForYouLink();
this.resolveExploreLink();
if (this.appChromeController) {
if (typeof this.appChromeController.setElements === "function") {
this.appChromeController.setElements(chromeElements);
} else {
if (this.appChromeController.elements) {
Object.assign(this.appChromeController.elements, chromeElements);
}
this.appChromeController.initialize();
}
}
this.syncAuthUiState();
},
maybeShowExperimentalLoginWarning(provider) {
const normalizedProvider =
typeof provider === "string" ? provider.trim().toLowerCase() : "";
if (normalizedProvider !== "nsec" && normalizedProvider !== "nip46") {
return;
}
const now = Date.now();
if (
this.lastExperimentalWarningKey === normalizedProvider &&
typeof this.lastExperimentalWarningAt === "number" &&
now - this.lastExperimentalWarningAt < 2000
) {
return;
}
this.lastExperimentalWarningKey = normalizedProvider;
this.lastExperimentalWarningAt = now;
const providerLabel =
normalizedProvider === "nsec"
? "Direct nsec or seed"
: "NIP-46 remote signer";
this.showStatus(
`${providerLabel} logins are still in development and may not work well yet. We recommend using a NIP-07 browser extension for the most reliable experience.`,
{ showSpinner: false, autoHideMs: 5000 },
);
}
};
}
+20 -15
View File
@@ -391,27 +391,32 @@ export function createKidsScorerStage({
const videoId = typeof video.id === "string" ? video.id : null;
const pubkey = typeof video.pubkey === "string" ? video.pubkey : null;
const positiveComponents = [
{ key: "age-appropriateness", value: ageAppropriateness },
{ key: "educational-boost", value: educationalBoost },
{ key: "author-trust", value: authorTrust },
{ key: "popularity", value: popularityWithinKids },
{ key: "freshness", value: freshness },
];
let maxKey = "age-appropriateness";
let maxValue = ageAppropriateness;
let dominantPositive = positiveComponents[0];
for (const component of positiveComponents) {
if (component.value > dominantPositive.value) {
dominantPositive = component;
}
if (educationalBoost > maxValue) {
maxKey = "educational-boost";
maxValue = educationalBoost;
}
if (authorTrust > maxValue) {
maxKey = "author-trust";
maxValue = authorTrust;
}
if (popularityWithinKids > maxValue) {
maxKey = "popularity";
maxValue = popularityWithinKids;
}
if (freshness > maxValue) {
maxKey = "freshness";
maxValue = freshness;
}
if (dominantPositive.value > 0) {
if (maxValue > 0) {
context?.addWhy?.({
stage: stageName,
type: "score",
reason: dominantPositive.key,
value: dominantPositive.value,
reason: maxKey,
value: maxValue,
score: kidsScore,
videoId,
pubkey,
+41 -1163
View File
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+65
View File
@@ -5,10 +5,15 @@ import {
summarizePublishResults,
} from "../nostrPublish.js";
import {
buildVideoPostEvent,
buildRepostEvent,
buildVideoMirrorEvent,
sanitizeAdditionalTags,
} from "../nostrEventSchemas.js";
import {
getStoragePointerFromTags,
resolveStoragePointerValue,
} from "../utils/storagePointer.js";
import { normalizePointerInput } from "./watchHistory.js";
import { sanitizeRelayList } from "./nip46Client.js";
import {
@@ -1332,4 +1337,64 @@ export async function rebroadcastEvent({ client, eventId, options = {} }) {
}
}
export function buildRevertVideoPayload({
baseEvent,
originalEventId,
pubkey,
existingD,
stableDTag,
}) {
let oldContent = {};
try {
oldContent = JSON.parse(baseEvent.content || "{}");
} catch (err) {
devLogger.warn(
"[nostr] Failed to parse baseEvent.content while reverting:",
err,
);
oldContent = {};
}
const oldVersion = oldContent.version ?? 1;
const finalRootId =
oldContent.videoRootId ||
(existingD
? `LEGACY:${baseEvent.pubkey}:${existingD}`
: stableDTag || baseEvent.id || originalEventId || "");
const oldIsNsfw = oldContent.isNsfw === true;
const oldIsForKids = oldContent.isForKids === true && !oldIsNsfw;
const contentObject = {
videoRootId: finalRootId,
version: oldVersion,
deleted: true,
isPrivate: oldContent.isPrivate ?? false,
isNsfw: oldIsNsfw,
isForKids: oldIsForKids,
title: oldContent.title || "",
url: "",
magnet: "",
thumbnail: "",
description: "This version was reverted by the creator.",
mode: oldContent.mode || "live",
};
const storagePointer = resolveStoragePointerValue({
storagePointer: getStoragePointerFromTags(baseEvent.tags),
url: oldContent.url,
infoHash: oldContent.infoHash,
fallbackId: finalRootId,
});
const additionalTags = storagePointer ? [["s", storagePointer]] : [];
return buildVideoPostEvent({
pubkey,
created_at: Math.floor(Date.now() / 1000),
dTagValue: stableDTag,
content: contentObject,
additionalTags,
});
}
export { summarizePublishResults };
+90 -119
View File
@@ -29,6 +29,11 @@ import getDefaultAuthProvider, {
} from "./authProviders/index.js";
import { fetchProfileMetadata } from "./profileMetadataService.js";
import { HEX64_REGEX } from "../utils/hex.js";
import {
safeEncodeNpub,
safeDecodeNpub,
normalizeHexPubkey,
} from "../utils/nostrHelpers.js";
class SimpleEventEmitter {
constructor(logger = null) {
@@ -267,70 +272,15 @@ export default class AuthService {
}
safeEncodeNpub(pubkey) {
if (typeof pubkey !== "string") {
return null;
}
const trimmed = pubkey.trim();
if (!trimmed) {
return null;
}
if (trimmed.startsWith("npub1")) {
return trimmed;
}
try {
return window?.NostrTools?.nip19?.npubEncode(trimmed) || null;
} catch (error) {
return null;
}
return safeEncodeNpub(pubkey);
}
safeDecodeNpub(npub) {
if (typeof npub !== "string") {
return null;
}
const trimmed = npub.trim();
if (!trimmed) {
return null;
}
try {
const decoded = window?.NostrTools?.nip19?.decode(trimmed);
if (decoded?.type === "npub" && typeof decoded.data === "string") {
return decoded.data;
}
} catch (error) {
return null;
}
return null;
return safeDecodeNpub(npub);
}
normalizeHexPubkey(pubkey) {
if (typeof pubkey !== "string") {
return null;
}
const trimmed = pubkey.trim();
if (!trimmed) {
return null;
}
if (HEX64_REGEX.test(trimmed)) {
return trimmed.toLowerCase();
}
if (trimmed.startsWith("npub1")) {
const decoded = this.safeDecodeNpub(trimmed);
if (decoded && HEX64_REGEX.test(decoded)) {
return decoded.toLowerCase();
}
}
return null;
return normalizeHexPubkey(pubkey);
}
async requestLogin(rawOptions = {}) {
@@ -1030,8 +980,63 @@ export default class AuthService {
return { switched: true, detail };
}
async fetchProfileFromRelay(
relayUrl,
filter,
normalizedPubkey,
timeoutMs,
requireEvent,
) {
const fetchPromise = this.nostrClient.pool.list([relayUrl], filter);
let timeoutId;
const timeoutPromise = new Promise((_, reject) => {
timeoutId = setTimeout(() => {
const timeoutError = new Error(
`Timed out fetching profile from ${relayUrl} after ${timeoutMs}ms`,
);
timeoutError.code = "timeout";
timeoutError.relay = relayUrl;
timeoutError.timeoutMs = timeoutMs;
reject(timeoutError);
}, timeoutMs);
});
try {
const result = await Promise.race([fetchPromise, timeoutPromise]);
clearTimeout(timeoutId);
const events = Array.isArray(result)
? result.filter((event) => event && event.pubkey === normalizedPubkey)
: [];
if (requireEvent && !events.length) {
const emptyError = new Error(
`No profile events returned from ${relayUrl}`,
);
emptyError.code = "empty";
emptyError.relay = relayUrl;
throw emptyError;
}
return { relayUrl, events };
} catch (error) {
clearTimeout(timeoutId);
// Suppress unhandled rejection from fetchPromise if it's still running
fetchPromise.catch(() => {});
if (error.relay) {
throw error;
}
const wrapped = error instanceof Error ? error : new Error(String(error));
wrapped.relay = relayUrl;
throw wrapped;
}
}
async loadOwnProfile(pubkey) {
const normalized = this.normalizeHexPubkey(pubkey) ||
const normalized =
this.normalizeHexPubkey(pubkey) ||
(typeof pubkey === "string" ? pubkey.trim() : "");
if (!normalized) {
return FALLBACK_PROFILE;
@@ -1074,13 +1079,20 @@ export default class AuthService {
return {
name: data.display_name || data.name || FALLBACK_PROFILE.name,
picture: data.picture || FALLBACK_PROFILE.picture,
about: typeof data.about === "string" ? data.about : FALLBACK_PROFILE.about,
about:
typeof data.about === "string" ? data.about : FALLBACK_PROFILE.about,
website:
typeof data.website === "string" ? data.website : FALLBACK_PROFILE.website,
typeof data.website === "string"
? data.website
: FALLBACK_PROFILE.website,
banner:
typeof data.banner === "string" ? data.banner : FALLBACK_PROFILE.banner,
lud16: typeof data.lud16 === "string" ? data.lud16 : FALLBACK_PROFILE.lud16,
lud06: typeof data.lud06 === "string" ? data.lud06 : FALLBACK_PROFILE.lud06,
typeof data.banner === "string"
? data.banner
: FALLBACK_PROFILE.banner,
lud16:
typeof data.lud16 === "string" ? data.lud16 : FALLBACK_PROFILE.lud16,
lud06:
typeof data.lud06 === "string" ? data.lud06 : FALLBACK_PROFILE.lud06,
};
} catch (error) {
this.log("[AuthService] Failed to parse profile metadata", error);
@@ -1088,67 +1100,26 @@ export default class AuthService {
}
};
const fetchFromRelay = (relayUrl, timeoutMs, requireEvent) => {
return new Promise((resolve, reject) => {
let settled = false;
const timer = setTimeout(() => {
if (settled) {
return;
}
settled = true;
const timeoutError = new Error(
`Timed out fetching profile from ${relayUrl} after ${timeoutMs}ms`
);
timeoutError.code = "timeout";
timeoutError.relay = relayUrl;
timeoutError.timeoutMs = timeoutMs;
reject(timeoutError);
}, timeoutMs);
Promise.resolve()
.then(() => this.nostrClient.pool.list([relayUrl], filter))
.then((result) => {
if (settled) {
return;
}
settled = true;
clearTimeout(timer);
const events = Array.isArray(result)
? result.filter((event) => event && event.pubkey === normalized)
: [];
if (requireEvent && !events.length) {
const emptyError = new Error(
`No profile events returned from ${relayUrl}`
);
emptyError.code = "empty";
emptyError.relay = relayUrl;
reject(emptyError);
return;
}
resolve({ relayUrl, events });
})
.catch((error) => {
if (settled) {
return;
}
settled = true;
clearTimeout(timer);
const wrapped =
error instanceof Error ? error : new Error(String(error));
wrapped.relay = relayUrl;
reject(wrapped);
});
});
};
const fastRelays = relays.slice(0, FAST_PROFILE_RELAY_LIMIT);
const backgroundRelays = relays.slice(fastRelays.length);
const fastPromises = fastRelays.map((relayUrl) =>
fetchFromRelay(relayUrl, FAST_PROFILE_TIMEOUT_MS, true)
this.fetchProfileFromRelay(
relayUrl,
filter,
normalized,
FAST_PROFILE_TIMEOUT_MS,
true,
),
);
const backgroundPromises = backgroundRelays.map((relayUrl) =>
fetchFromRelay(relayUrl, BACKGROUND_PROFILE_TIMEOUT_MS, false)
this.fetchProfileFromRelay(
relayUrl,
filter,
normalized,
BACKGROUND_PROFILE_TIMEOUT_MS,
false,
),
);
const background = Promise.allSettled([
+102
View File
@@ -0,0 +1,102 @@
import { HEX64_REGEX } from "./hex.js";
/**
* Safely encode a hex pubkey to npub.
*
* @param {string} pubkey - The hex public key.
* @returns {string|null} The npub string or null if encoding failed.
*/
export function safeEncodeNpub(pubkey) {
if (typeof pubkey !== "string") {
return null;
}
const trimmed = pubkey.trim();
if (!trimmed) {
return null;
}
if (trimmed.startsWith("npub1")) {
return trimmed;
}
try {
if (
typeof window !== "undefined" &&
window.NostrTools &&
window.NostrTools.nip19 &&
typeof window.NostrTools.nip19.npubEncode === "function"
) {
return window.NostrTools.nip19.npubEncode(trimmed);
}
} catch (err) {
// ignore
}
return null;
}
/**
* Safely decode an npub to hex pubkey.
*
* @param {string} npub - The npub string.
* @returns {string|null} The hex public key or null if decoding failed.
*/
export function safeDecodeNpub(npub) {
if (typeof npub !== "string") {
return null;
}
const trimmed = npub.trim();
if (!trimmed) {
return null;
}
try {
if (
typeof window !== "undefined" &&
window.NostrTools &&
window.NostrTools.nip19 &&
typeof window.NostrTools.nip19.decode === "function"
) {
const decoded = window.NostrTools.nip19.decode(trimmed);
if (decoded.type === "npub" && typeof decoded.data === "string") {
return decoded.data;
}
}
} catch (err) {
// ignore
}
return null;
}
/**
* Normalize a public key to hex format, handling npub inputs.
*
* @param {string} pubkey - The public key (hex or npub).
* @returns {string|null} The normalized hex public key or null.
*/
export function normalizeHexPubkey(pubkey) {
if (typeof pubkey !== "string") {
return null;
}
const trimmed = pubkey.trim();
if (!trimmed) {
return null;
}
if (HEX64_REGEX.test(trimmed)) {
return trimmed.toLowerCase();
}
if (trimmed.startsWith("npub1")) {
const decoded = safeDecodeNpub(trimmed);
if (decoded && HEX64_REGEX.test(decoded)) {
return decoded.toLowerCase();
}
}
return null;
}
+2 -2
View File
@@ -33,7 +33,7 @@ export function sanitizeProfileMediaUrl(value) {
return "";
}
trimmed = trimmed.replace(/^['"]+|['"]+$/g, "");
trimmed = trimmed.replace(/^['"]+|['"]+$/g, "").trim();
if (!trimmed) {
return "";
}
@@ -50,7 +50,7 @@ export function sanitizeProfileMediaUrl(value) {
return "";
}
if (/^ipfs:\/\//i.test(trimmed)) {
if (/^ipfs(\/|:\/\/)/i.test(trimmed)) {
return normalizeIpfsGatewayUrl(trimmed);
}
+34
View File
@@ -121,3 +121,37 @@ export function resolveInfoJsonUrl({ storagePointer, url } = {}) {
return normalizeInfoJsonBase(prefix);
}
export function resolveStoragePointerValue({
storagePointer,
url,
infoHash,
fallbackId,
provider,
} = {}) {
const normalized = normalizeStoragePointer(storagePointer);
if (normalized) {
return normalized;
}
const derivedFromUrl = deriveStoragePointerFromUrl(url, provider || "url");
if (derivedFromUrl) {
return derivedFromUrl;
}
if (typeof infoHash === "string" && infoHash.trim()) {
return buildStoragePointerValue({
provider: "btih",
prefix: infoHash.trim().toLowerCase(),
});
}
if (typeof fallbackId === "string" && fallbackId.trim()) {
return buildStoragePointerValue({
provider: "nostr",
prefix: fallbackId.trim(),
});
}
return "";
}
+95
View File
@@ -305,3 +305,98 @@ await (async () => {
assert.ok(detail);
assert.equal(detail.pubkey, SAMPLE_PUBKEY);
})();
// === Tests for loadOwnProfile logic ===
await (async () => {
resetState();
const PUBKEY = SAMPLE_PUBKEY;
// Test Fast Relay Success
{
const mockNostrClient = {
relays: ["wss://fast1.com", "wss://fast2.com", "wss://slow.com"],
pool: {
list: async (relays) => {
const url = relays[0];
if (url === "wss://fast1.com") {
return [{
pubkey: PUBKEY,
created_at: 100,
content: JSON.stringify({ name: "Fast User" })
}];
}
return new Promise(() => {}); // Hang others
}
},
handleEvent: () => {}
};
const service = new AuthService({ nostrClient: mockNostrClient });
// Override setProfileCacheEntry to avoid side effects or errors from real implementation if not needed,
// but here we just let it run. However, the service needs nostrClient.pool
// We need to make sure setProfileCacheEntry doesn't fail.
// It imports from cache.js which uses profileCache.
// profileCache is imported from state/profileCache.js.
// The previous tests didn't seem to mock setProfileCacheEntry.
const profile = await service.loadOwnProfile(PUBKEY);
assert.equal(profile.name, "Fast User");
}
// Test All Relays Fail
{
const mockNostrClient = {
relays: ["wss://fail1.com", "wss://fail2.com"],
pool: {
list: async () => {
throw new Error("Failed");
}
},
handleEvent: () => {}
};
const service = new AuthService({ nostrClient: mockNostrClient });
const profile = await service.loadOwnProfile(PUBKEY);
assert.equal(profile.name, "Unknown");
}
// Test Timeout Fallback
{
let backgroundResolve;
const backgroundPromise = new Promise(r => backgroundResolve = r);
const mockNostrClient = {
relays: ["wss://fast-fail.com", "wss://fast-fail2.com", "wss://background-ok.com", "wss://background-ok-2.com"],
pool: {
list: async (relays) => {
const url = relays[0];
if (url.includes("fast")) {
// Simulate timeout or error
throw new Error("Fast fail");
}
if (url.includes("background")) {
await backgroundPromise;
return [{
pubkey: PUBKEY,
created_at: 200,
content: JSON.stringify({ name: "Background User" })
}];
}
return [];
}
},
handleEvent: () => {}
};
const service = new AuthService({ nostrClient: mockNostrClient });
const profile = await service.loadOwnProfile(PUBKEY);
assert.equal(profile.name, "Unknown");
// We resolve background promise to let background logic finish eventually (though test finishes here)
backgroundResolve();
}
})();
+73
View File
@@ -0,0 +1,73 @@
import test from "node:test";
import assert from "node:assert/strict";
import { createKidsScorerStage } from "../../js/feedEngine/kidsScoring.js";
test("kids scorer correctly identifies dominant positive component", async () => {
const stage = createKidsScorerStage({
weights: {
w_age: 0.2,
w_edu: 0.2,
w_author: 0.2,
w_pop: 0.2,
w_fresh: 0.2,
w_risk: 0
}
});
// Create an item where one component is clearly dominant
// Age appropriateness: 1.0 (perfect duration + tags)
// Others: 0
const items = [
{
video: {
id: "v1",
pubkey: "pk1",
duration: 300, // 5 min (perfect for toddler/preschool)
tags: [["t", "preschool"], ["t", "learning"]], // Matches preferred
created_at: Date.now() / 1000 - 10000000, // Old
viewCount: 0
},
metadata: {},
},
];
const why = [];
const context = {
runtime: {
ageGroup: "preschool",
trustedAuthors: []
},
addWhy: (detail) => why.push(detail)
};
await stage(items, context);
assert.equal(why.length, 1);
assert.equal(why[0].reason, "age-appropriateness");
assert.ok(why[0].value > 0);
});
test("kids scorer picks educational-boost when dominant", async () => {
const stage = createKidsScorerStage({
educationalTags: ["math"],
weights: { w_age: 0, w_edu: 1, w_author: 0, w_pop: 0, w_fresh: 0, w_risk: 0 }
});
const items = [{
video: {
id: "v2",
tags: [["t", "math"]],
duration: 300
},
metadata: {}
}];
const why = [];
await stage(items, {
runtime: { educationalTags: ["math"] },
addWhy: (d) => why.push(d)
});
assert.equal(why.length, 1);
assert.equal(why[0].reason, "educational-boost");
});
+47
View File
@@ -165,6 +165,53 @@ describe("NostrClient", () => {
});
});
describe("fetchVideos", () => {
it("should delegate to subscribeVideos and return result on eose", async () => {
let subOpts = null;
// Override subscribeVideos to verify delegation
client.subscribeVideos = (cb, opts) => {
subOpts = opts;
return {
on: (evt, handler) => {
if (evt === "eose") setTimeout(handler, 0);
},
unsub: () => {},
};
};
// Mock helpers
client.getActiveVideos = () => [{ id: "v1" }];
client.populateNip71MetadataForVideos = async () => {};
client.applyRootCreatedAt = () => {};
const result = await client.fetchVideos({ limit: 5 });
assert.equal(result[0].id, "v1");
assert.equal(subOpts.limit, 5);
// Ensure we force full fetch by default
assert.equal(subOpts.since, 0);
});
it("should respect since parameter if provided", async () => {
let subOpts = null;
client.subscribeVideos = (cb, opts) => {
subOpts = opts;
return {
on: (evt, handler) => {
if (evt === "eose") setTimeout(handler, 0);
},
unsub: () => {},
};
};
client.getActiveVideos = () => [];
client.populateNip71MetadataForVideos = async () => {};
client.applyRootCreatedAt = () => {};
await client.fetchVideos({ since: 12345 });
assert.equal(subOpts.since, 12345);
});
});
describe("publishVideo", () => {
it("should throw if not logged in", async () => {
await assert.rejects(
+164
View File
@@ -0,0 +1,164 @@
import { describe, it, beforeEach, mock } from "node:test";
import assert from "node:assert/strict";
import { Nip46Connector } from "../../js/nostr/nip46Connector.js";
const VALID_HEX_KEY = "0000000000000000000000000000000000000000000000000000000000000001";
const VALID_PUBKEY = "0000000000000000000000000000000000000000000000000000000000000002";
// Mock dependencies
const mockDeps = {
parseNip46ConnectionString: mock.fn(),
generateNip46Secret: mock.fn(),
readStoredNip46Session: mock.fn(),
writeStoredNip46Session: mock.fn(),
clearStoredNip46Session: mock.fn(),
normalizeNip46EncryptionAlgorithm: mock.fn(),
decryptNip46Session: mock.fn(),
attemptDecryptNip46HandshakePayload: mock.fn(),
sanitizeNip46Metadata: mock.fn((m) => m || {}),
resolveNip46Relays: mock.fn((r) => r || []),
normalizeNostrPubkey: mock.fn((k) => k),
encodeHexToNpub: mock.fn((k) => "npub" + k),
bytesToHex: mock.fn((b) => VALID_HEX_KEY),
signEventWithPrivateKey: mock.fn(),
Nip46RpcClient: class MockRpcClient {
constructor() {
this.destroy = mock.fn();
this.ensureSubscription = mock.fn(() => Promise.resolve());
this.connect = mock.fn(() => Promise.resolve());
this.getUserPubkey = mock.fn(() => Promise.resolve(VALID_PUBKEY));
this.metadata = {};
}
},
ensureNostrTools: mock.fn(async () => ({
generateSecretKey: () => new Uint8Array(32),
getPublicKey: () => VALID_PUBKEY,
})),
getCachedNostrTools: mock.fn(() => ({
generateSecretKey: () => new Uint8Array(32),
getPublicKey: () => VALID_PUBKEY,
})),
NIP46_RPC_KIND: 24133,
NIP46_HANDSHAKE_TIMEOUT_MS: 100,
NIP46_AUTH_CHALLENGE_MAX_ATTEMPTS: 3,
};
describe("Nip46Connector", () => {
let connector;
let mockNostrClient;
beforeEach(() => {
// Reset mocks
Object.values(mockDeps).forEach((m) => {
if (m.mock) m.mock.resetCalls();
});
mockNostrClient = {
pool: {
publish: mock.fn(() => Promise.resolve()),
sub: mock.fn(() => ({
on: mock.fn(),
unsub: mock.fn(),
})),
},
ensurePool: mock.fn(async () => mockNostrClient.pool),
connectToRelays: mock.fn(async () => []),
emitRemoteSignerChange: mock.fn(),
relays: ["wss://relay.example.com"],
};
connector = new Nip46Connector(mockNostrClient, mockDeps);
});
describe("createKeyPair", () => {
it("should generate a new key pair if none provided", async () => {
mockDeps.generateNip46Secret.mock.mockImplementationOnce(() => "mock-secret");
const result = await connector.createKeyPair();
assert.deepEqual(result, {
privateKey: VALID_HEX_KEY,
publicKey: VALID_PUBKEY,
});
});
it("should use existing keys if provided", async () => {
const result = await connector.createKeyPair(
VALID_HEX_KEY,
VALID_PUBKEY
);
assert.deepEqual(result, {
privateKey: VALID_HEX_KEY,
publicKey: VALID_PUBKEY,
});
});
});
describe("prepareHandshake", () => {
it("should parse connection string and return details", async () => {
mockDeps.generateNip46Secret.mock.mockImplementationOnce(() => "handshake-secret");
const result = await connector.prepareHandshake({
connectionString: "bunker://...",
});
assert.equal(result.clientPrivateKey, VALID_HEX_KEY);
assert.equal(result.secret, "handshake-secret");
assert.match(result.connectionString, /^nostrconnect:\/\//);
});
});
describe("connect", () => {
it("should connect directly if remote pubkey is known", async () => {
const options = {
connectionString: "bunker://remote-pub?relay=wss://relay.com",
clientPrivateKey: VALID_HEX_KEY,
};
mockDeps.parseNip46ConnectionString.mock.mockImplementationOnce(() => ({
relays: ["wss://relay.com"],
remotePubkey: VALID_PUBKEY,
type: "bunker",
}));
mockDeps.normalizeNip46EncryptionAlgorithm.mock.mockImplementationOnce(() => "nip44");
mockDeps.normalizeNostrPubkey.mock.mockImplementation((k) => k);
const result = await connector.connect(options);
assert.equal(result.pubkey, VALID_PUBKEY);
assert.ok(result.client instanceof mockDeps.Nip46RpcClient);
assert.equal(mockDeps.writeStoredNip46Session.mock.callCount(), 1);
});
});
describe("reconnectStored", () => {
it("should throw if no stored session", async () => {
mockDeps.readStoredNip46Session.mock.mockImplementationOnce(() => null);
await assert.rejects(connector.reconnectStored(), /No remote signer session is stored/);
});
it("should reconnect using stored session", async () => {
mockDeps.readStoredNip46Session.mock.mockImplementationOnce(() => ({
userPubkey: "user-pub",
remotePubkey: "remote-pub",
relays: [],
clientPrivateKey: "client-priv",
secret: "secret",
}));
mockDeps.decryptNip46Session.mock.mockImplementationOnce((s) => s);
const result = await connector.reconnectStored();
assert.equal(result.pubkey, VALID_PUBKEY);
assert.ok(result.client instanceof mockDeps.Nip46RpcClient);
});
});
describe("disconnect", () => {
it("should clear stored session and emit change", async () => {
await connector.disconnect({ keepStored: false });
assert.equal(mockDeps.clearStoredNip46Session.mock.callCount(), 1);
});
});
});
+63
View File
@@ -0,0 +1,63 @@
import { describe, it } from "node:test";
import assert from "node:assert/strict";
import {
normalizeHexString,
normalizeHexId,
normalizeHexPubkey,
HEX64_REGEX,
} from "../../js/utils/hex.js";
describe("hex utils", () => {
describe("normalizeHexString", () => {
it("should return empty string for non-string inputs", () => {
assert.equal(normalizeHexString(undefined), "");
assert.equal(normalizeHexString(null), "");
assert.equal(normalizeHexString(123), "");
assert.equal(normalizeHexString({}), "");
});
it("should return empty string for empty or whitespace-only strings", () => {
assert.equal(normalizeHexString(""), "");
assert.equal(normalizeHexString(" "), "");
});
it("should trim and lowercase valid hex strings", () => {
assert.equal(normalizeHexString("ABC"), "abc");
assert.equal(normalizeHexString(" DEF "), "def");
assert.equal(normalizeHexString("AbCdEf"), "abcdef");
});
});
describe("aliases", () => {
it("should export normalizeHexId as an alias", () => {
assert.equal(normalizeHexId, normalizeHexString);
});
it("should export normalizeHexPubkey as an alias", () => {
assert.equal(normalizeHexPubkey, normalizeHexString);
});
});
describe("HEX64_REGEX", () => {
it("should match valid 64-character hex strings", () => {
const validHex = "a".repeat(64);
assert.match(validHex, HEX64_REGEX);
const mixedCase = "A".repeat(32) + "b".repeat(32);
assert.match(mixedCase, HEX64_REGEX);
});
it("should not match strings with incorrect length", () => {
assert.doesNotMatch("a".repeat(63), HEX64_REGEX);
assert.doesNotMatch("a".repeat(65), HEX64_REGEX);
});
it("should not match strings with non-hex characters", () => {
const invalidChar = "a".repeat(63) + "g";
assert.doesNotMatch(invalidChar, HEX64_REGEX);
});
it("should not match empty strings", () => {
assert.doesNotMatch("", HEX64_REGEX);
});
});
});
+74
View File
@@ -0,0 +1,74 @@
import test from "node:test";
import assert from "node:assert/strict";
import { sanitizeProfileMediaUrl } from "../../js/utils/profileMedia.js";
test("sanitizeProfileMediaUrl handles non-string inputs", () => {
assert.equal(sanitizeProfileMediaUrl(null), "");
assert.equal(sanitizeProfileMediaUrl(undefined), "");
assert.equal(sanitizeProfileMediaUrl(123), "");
assert.equal(sanitizeProfileMediaUrl({}), "");
});
test("sanitizeProfileMediaUrl handles empty or whitespace-only strings", () => {
assert.equal(sanitizeProfileMediaUrl(""), "");
assert.equal(sanitizeProfileMediaUrl(" "), "");
assert.equal(sanitizeProfileMediaUrl("\t\n"), "");
});
test("sanitizeProfileMediaUrl trims whitespace and removes quotes", () => {
assert.equal(sanitizeProfileMediaUrl(" http://example.com "), "https://example.com");
assert.equal(sanitizeProfileMediaUrl("'http://example.com'"), "https://example.com");
assert.equal(sanitizeProfileMediaUrl('"http://example.com"'), "https://example.com");
assert.equal(sanitizeProfileMediaUrl(" ' http://example.com ' "), "https://example.com");
});
test("sanitizeProfileMediaUrl allows data:image/ URLs", () => {
const dataUrl = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=";
assert.equal(sanitizeProfileMediaUrl(dataUrl), dataUrl);
});
test("sanitizeProfileMediaUrl allows blob: URLs", () => {
const blobUrl = "blob:http://example.com/550e8400-e29b-41d4-a716-446655440000";
assert.equal(sanitizeProfileMediaUrl(blobUrl), blobUrl);
});
test("sanitizeProfileMediaUrl rejects specific placeholder images", () => {
assert.equal(sanitizeProfileMediaUrl("images/robot.png"), "");
});
test("sanitizeProfileMediaUrl normalizes IPFS URLs", () => {
assert.equal(sanitizeProfileMediaUrl("ipfs://CID"), "https://ipfs.io/ipfs/CID");
assert.equal(sanitizeProfileMediaUrl("ipfs/CID"), "https://ipfs.io/ipfs/CID");
});
test("sanitizeProfileMediaUrl handles protocol-relative URLs", () => {
assert.equal(sanitizeProfileMediaUrl("//example.com/image.jpg"), "https://example.com/image.jpg");
});
test("sanitizeProfileMediaUrl allows relative paths", () => {
assert.equal(sanitizeProfileMediaUrl("/assets/image.jpg"), "/assets/image.jpg");
assert.equal(sanitizeProfileMediaUrl("./image.jpg"), "./image.jpg");
assert.equal(sanitizeProfileMediaUrl("../image.jpg"), "../image.jpg");
assert.equal(sanitizeProfileMediaUrl("assets/image.jpg"), "assets/image.jpg");
});
test("sanitizeProfileMediaUrl adds protocol to domains and localhost", () => {
assert.equal(sanitizeProfileMediaUrl("localhost:8080/image.jpg"), "http://localhost:8080/image.jpg");
assert.equal(sanitizeProfileMediaUrl("example.com/image.jpg"), "https://example.com/image.jpg");
assert.equal(sanitizeProfileMediaUrl("sub.example.com/image.jpg"), "https://sub.example.com/image.jpg");
// IP address pattern in code: (?:\d{1,3}\.){3}\d{1,3}
assert.equal(sanitizeProfileMediaUrl("127.0.0.1:8080/image.jpg"), "http://127.0.0.1:8080/image.jpg");
});
test("sanitizeProfileMediaUrl coerces http to https except for localhost", () => {
assert.equal(sanitizeProfileMediaUrl("http://example.com/image.jpg"), "https://example.com/image.jpg");
assert.equal(sanitizeProfileMediaUrl("http://localhost:8080/image.jpg"), "http://localhost:8080/image.jpg");
assert.equal(sanitizeProfileMediaUrl("http://127.0.0.1:8080/image.jpg"), "http://127.0.0.1:8080/image.jpg");
});
test("sanitizeProfileMediaUrl rejects unsupported patterns", () => {
// The code logs a warning and returns "" for things that don't match localhost or domain patterns
// if they don't start with http/https
assert.equal(sanitizeProfileMediaUrl("javascript:alert(1)"), "");
assert.equal(sanitizeProfileMediaUrl("ftp://example.com/file"), ""); // Starts with ftp, not http/https/ipfs
});
+117 -3
View File
@@ -3,13 +3,22 @@ import { test } from "node:test";
import { validateInstanceConfig } from "../config/validate-config.js";
// Common valid overrides to use as a base
const VALID_OVERRIDES = {
ADMIN_SUPER_NPUB: "npub1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq",
PLATFORM_FEE_PERCENT: 5,
PLATFORM_LUD16_OVERRIDE: "[email protected]",
};
test("validateInstanceConfig succeeds with valid configuration", () => {
assert.doesNotThrow(() => validateInstanceConfig(VALID_OVERRIDES));
});
test(
"validateInstanceConfig throws when platform fee is positive without a platform override",
() => {
const overrides = {
ADMIN_SUPER_NPUB:
"npub1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq",
PLATFORM_FEE_PERCENT: 5,
...VALID_OVERRIDES,
PLATFORM_LUD16_OVERRIDE: " ",
};
@@ -19,3 +28,108 @@ test(
});
}
);
test("validateInstanceConfig throws when ADMIN_SUPER_NPUB is empty", () => {
const overrides = {
...VALID_OVERRIDES,
ADMIN_SUPER_NPUB: " ",
};
assert.throws(() => validateInstanceConfig(overrides), {
message: /ADMIN_SUPER_NPUB is required/,
});
});
test("validateInstanceConfig throws when ADMIN_SUPER_NPUB does not start with npub", () => {
const overrides = {
...VALID_OVERRIDES,
ADMIN_SUPER_NPUB: "invalid_npub",
};
assert.throws(() => validateInstanceConfig(overrides), {
message: /ADMIN_SUPER_NPUB should be a bech32 npub/,
});
});
test("validateInstanceConfig throws when PLATFORM_FEE_PERCENT is negative", () => {
const overrides = {
...VALID_OVERRIDES,
PLATFORM_FEE_PERCENT: -1,
};
assert.throws(() => validateInstanceConfig(overrides), {
message: /PLATFORM_FEE_PERCENT must be between 0 and 100 inclusive/,
});
});
test("validateInstanceConfig throws when PLATFORM_FEE_PERCENT is greater than 100", () => {
const overrides = {
...VALID_OVERRIDES,
PLATFORM_FEE_PERCENT: 101,
};
assert.throws(() => validateInstanceConfig(overrides), {
message: /PLATFORM_FEE_PERCENT must be between 0 and 100 inclusive/,
});
});
test("validateInstanceConfig throws when PLATFORM_FEE_PERCENT is not finite", () => {
const overrides = {
...VALID_OVERRIDES,
PLATFORM_FEE_PERCENT: Infinity,
};
assert.throws(() => validateInstanceConfig(overrides), {
message: /PLATFORM_FEE_PERCENT must be a finite number/,
});
});
test("validateInstanceConfig throws when THEME_ACCENT_OVERRIDES has invalid hex color", () => {
const overrides = {
...VALID_OVERRIDES,
THEME_ACCENT_OVERRIDES: {
light: {
accent: "invalid-hex",
},
},
};
assert.throws(() => validateInstanceConfig(overrides), {
message: /must be a #RRGGBB hex color/,
});
});
test("validateInstanceConfig throws when THEME_ACCENT_OVERRIDES structure is invalid", () => {
const overrides = {
...VALID_OVERRIDES,
THEME_ACCENT_OVERRIDES: {
light: "not-an-object",
},
};
assert.throws(() => validateInstanceConfig(overrides), {
message: /must be an object with accent overrides/,
});
});
test("validateInstanceConfig throws when optional URL is invalid", () => {
const overrides = {
...VALID_OVERRIDES,
BLOG_URL: "not-a-valid-url",
};
assert.throws(() => validateInstanceConfig(overrides), {
message: /BLOG_URL must be a valid http\(s\) URL/,
});
});
test("validateInstanceConfig throws when optional URL has invalid protocol", () => {
const overrides = {
...VALID_OVERRIDES,
BLOG_URL: "ftp://example.com",
};
assert.throws(() => validateInstanceConfig(overrides), {
message: /BLOG_URL must be a valid http\(s\) URL/,
});
});