diff --git a/PR_REVIEWS.md b/PR_REVIEWS.md new file mode 100644 index 00000000..fd4167d6 --- /dev/null +++ b/PR_REVIEWS.md @@ -0,0 +1,55 @@ +# Automated PR Review Report + +## 1. PR: codex/add-centralized-permission-gate +**Target:** `main` +**Status:** ❌ Request Changes + +### ❌ Test Failures +`tests/nostr-login-permissions.test.mjs` failed (6 subtests). +> **Suggestion:** Inspect `tests/nostr-login-permissions.test.mjs`. It seems related to `extension.enable` assertions. Run `npm run test:unit` locally. + +### 🚀 Release Channel Checks +⚠️ **Warning:** Targets `main` but modifies critical config (`config/instance-config.js`, `js/constants.js`). +> **Guidance:** Ensure feature flags are safe defaults. + +### 🛡️ Guardrails +`requires-security-review` `requires-protocol-review` +- Touched 41 sensitive files including `js/auth/signingAdapter.js`, `js/dmDecryptor.js`, `js/nostr/nip07Permissions.js`. + +--- + +## 2. PR: codex/remove-nip04-and-nip44-from-default-permissions +**Target:** `main` +**Status:** ❌ Request Changes + +### ❌ Test Failures +`tests/nostr-login-permissions.test.mjs` failed (6 subtests). +> **Suggestion:** Same failure as above. Likely an environmental issue or regression in NIP-07 logic. + +### 🚀 Release Channel Checks +⚠️ **Warning:** Targets `main` but modifies critical config. + +### 🛡️ Guardrails +`requires-security-review` `requires-protocol-review` +- Touched 41 sensitive files. + +--- + +## 3. PR: fix/optimize-nip07-login-perf-4235940878368250470 +**Target:** `main` +**Status:** ❌ Failure / Timeout + +### ❌ Test Failures +Tests timed out during execution. +> **Suggestion:** Run `npm run test:unit:shard1` locally to verify changes without timeout. + +--- + +## Audit Log +| Branch | Lint | Format | Tests | Security | +| :--- | :--- | :--- | :--- | :--- | +| `codex/add-centralized-permission-gate` | ✅ Pass | ✅ Pass | ❌ Fail | 🛡️ Req | +| `codex/remove-nip04-and-nip44...` | ✅ Pass | ✅ Pass | ❌ Fail | 🛡️ Req | +| `fix/optimize-nip07-login-perf...` | ✅ Pass | ✅ Pass | ❌ Timeout | ? | + +**Micro-fixes:** No formatting changes detected in the reviewed branches. diff --git a/README.md b/README.md index ba94e4fe..4a89cfe8 100644 --- a/README.md +++ b/README.md @@ -449,12 +449,6 @@ artifacts now that the deploy pipeline owns the build step. - Re-exports `isDevMode` (derived from `IS_DEV_MODE`) for modules, publishes `window.__BITVID_DEV_MODE__`, and centralizes the global configuration surface. -- **Runtime globals**: - - `window.__BITVID_NIP07_LOGIN_TIMEOUT_MS__` overrides the default NIP-07 - login timeout (milliseconds) used when waiting for extension responses. - - `window.__BITVID_NIP07_ENABLE_VARIANT_TIMEOUT_MS__` overrides the timeout - (milliseconds) for `nostr.enable()` permission variants. Values must be - positive to take effect. - **`js/utils/logger.js`**: - Provides the shared `logger` utility. Route user-facing errors through `logger.user` and keep experimental diagnostics on `logger.dev` so operators diff --git a/artifacts/load-report-20260128.json b/artifacts/load-report-20260128.json deleted file mode 100644 index 6bee4d63..00000000 --- a/artifacts/load-report-20260128.json +++ /dev/null @@ -1,158 +0,0 @@ -{ - "timestamp": "2026-01-28T19:39:33.306Z", - "config": { - "duration": 60, - "clients": 100, - "rate": 10, - "relay": "ws://localhost:8889", - "force": false - }, - "metrics": { - "total_sent": 10103, - "total_received": 10102, - "errors": 0, - "throughput_sent": 168.38333333333333, - "throughput_recv": 168.36666666666667, - "latency_ms": { - "p50": 5.760848000005353, - "p95": 16.885834000000614, - "p99": 19.095236999999997 - }, - "operation_times_ms": { - "build_avg": 0.06137749958035674, - "sign_avg": 5.593697509749546 - }, - "resource_usage": [ - { - "timestamp": 1769629118335, - "cpuPercent": 105.49, - "rss": 97357824, - "heapUsed": 19757664 - }, - { - "timestamp": 1769629123351, - "cpuPercent": 104.35, - "rss": 99233792, - "heapUsed": 15914376 - }, - { - "timestamp": 1769629128354, - "cpuPercent": 102.79, - "rss": 100491264, - "heapUsed": 18964920 - }, - { - "timestamp": 1769629133354, - "cpuPercent": 101.55, - "rss": 119353344, - "heapUsed": 18267024 - }, - { - "timestamp": 1769629138356, - "cpuPercent": 103.91, - "rss": 120532992, - "heapUsed": 30091568 - }, - { - "timestamp": 1769629143359, - "cpuPercent": 100.94, - "rss": 121270272, - "heapUsed": 26106864 - }, - { - "timestamp": 1769629148359, - "cpuPercent": 100.34, - "rss": 122187776, - "heapUsed": 32618248 - }, - { - "timestamp": 1769629153363, - "cpuPercent": 100.87, - "rss": 122843136, - "heapUsed": 26208872 - }, - { - "timestamp": 1769629158369, - "cpuPercent": 99.95, - "rss": 123981824, - "heapUsed": 24602000 - }, - { - "timestamp": 1769629163370, - "cpuPercent": 100.74, - "rss": 124456960, - "heapUsed": 31364336 - }, - { - "timestamp": 1769629168370, - "cpuPercent": 100.41, - "rss": 125243392, - "heapUsed": 30159064 - } - ], - "relay_resource_usage": [ - { - "timestamp": 1769629118395, - "cpuPercent": 13.4, - "memPercent": 0.8 - }, - { - "timestamp": 1769629123374, - "cpuPercent": 9.8, - "memPercent": 0.9 - }, - { - "timestamp": 1769629128374, - "cpuPercent": 8.2, - "memPercent": 0.9 - }, - { - "timestamp": 1769629133396, - "cpuPercent": 7.7, - "memPercent": 0.8 - }, - { - "timestamp": 1769629138376, - "cpuPercent": 7, - "memPercent": 0.9 - }, - { - "timestamp": 1769629143379, - "cpuPercent": 6.3, - "memPercent": 0.9 - }, - { - "timestamp": 1769629148402, - "cpuPercent": 5.9, - "memPercent": 0.9 - }, - { - "timestamp": 1769629153384, - "cpuPercent": 5.5, - "memPercent": 1 - }, - { - "timestamp": 1769629158401, - "cpuPercent": 5.4, - "memPercent": 1 - }, - { - "timestamp": 1769629163391, - "cpuPercent": 5.2, - "memPercent": 1 - }, - { - "timestamp": 1769629168401, - "cpuPercent": 4.9, - "memPercent": 1 - } - ], - "error_breakdown": {} - }, - "bottlenecks": [ - "None detected" - ], - "remediation": [ - "None required" - ] -} \ No newline at end of file diff --git a/artifacts/load-report.json b/artifacts/load-report.json deleted file mode 100644 index 6bee4d63..00000000 --- a/artifacts/load-report.json +++ /dev/null @@ -1,158 +0,0 @@ -{ - "timestamp": "2026-01-28T19:39:33.306Z", - "config": { - "duration": 60, - "clients": 100, - "rate": 10, - "relay": "ws://localhost:8889", - "force": false - }, - "metrics": { - "total_sent": 10103, - "total_received": 10102, - "errors": 0, - "throughput_sent": 168.38333333333333, - "throughput_recv": 168.36666666666667, - "latency_ms": { - "p50": 5.760848000005353, - "p95": 16.885834000000614, - "p99": 19.095236999999997 - }, - "operation_times_ms": { - "build_avg": 0.06137749958035674, - "sign_avg": 5.593697509749546 - }, - "resource_usage": [ - { - "timestamp": 1769629118335, - "cpuPercent": 105.49, - "rss": 97357824, - "heapUsed": 19757664 - }, - { - "timestamp": 1769629123351, - "cpuPercent": 104.35, - "rss": 99233792, - "heapUsed": 15914376 - }, - { - "timestamp": 1769629128354, - "cpuPercent": 102.79, - "rss": 100491264, - "heapUsed": 18964920 - }, - { - "timestamp": 1769629133354, - "cpuPercent": 101.55, - "rss": 119353344, - "heapUsed": 18267024 - }, - { - "timestamp": 1769629138356, - "cpuPercent": 103.91, - "rss": 120532992, - "heapUsed": 30091568 - }, - { - "timestamp": 1769629143359, - "cpuPercent": 100.94, - "rss": 121270272, - "heapUsed": 26106864 - }, - { - "timestamp": 1769629148359, - "cpuPercent": 100.34, - "rss": 122187776, - "heapUsed": 32618248 - }, - { - "timestamp": 1769629153363, - "cpuPercent": 100.87, - "rss": 122843136, - "heapUsed": 26208872 - }, - { - "timestamp": 1769629158369, - "cpuPercent": 99.95, - "rss": 123981824, - "heapUsed": 24602000 - }, - { - "timestamp": 1769629163370, - "cpuPercent": 100.74, - "rss": 124456960, - "heapUsed": 31364336 - }, - { - "timestamp": 1769629168370, - "cpuPercent": 100.41, - "rss": 125243392, - "heapUsed": 30159064 - } - ], - "relay_resource_usage": [ - { - "timestamp": 1769629118395, - "cpuPercent": 13.4, - "memPercent": 0.8 - }, - { - "timestamp": 1769629123374, - "cpuPercent": 9.8, - "memPercent": 0.9 - }, - { - "timestamp": 1769629128374, - "cpuPercent": 8.2, - "memPercent": 0.9 - }, - { - "timestamp": 1769629133396, - "cpuPercent": 7.7, - "memPercent": 0.8 - }, - { - "timestamp": 1769629138376, - "cpuPercent": 7, - "memPercent": 0.9 - }, - { - "timestamp": 1769629143379, - "cpuPercent": 6.3, - "memPercent": 0.9 - }, - { - "timestamp": 1769629148402, - "cpuPercent": 5.9, - "memPercent": 0.9 - }, - { - "timestamp": 1769629153384, - "cpuPercent": 5.5, - "memPercent": 1 - }, - { - "timestamp": 1769629158401, - "cpuPercent": 5.4, - "memPercent": 1 - }, - { - "timestamp": 1769629163391, - "cpuPercent": 5.2, - "memPercent": 1 - }, - { - "timestamp": 1769629168401, - "cpuPercent": 4.9, - "memPercent": 1 - } - ], - "error_breakdown": {} - }, - "bottlenecks": [ - "None detected" - ], - "remediation": [ - "None required" - ] -} \ No newline at end of file diff --git a/config/instance-config.js b/config/instance-config.js index b79faf83..a391e0a2 100644 --- a/config/instance-config.js +++ b/config/instance-config.js @@ -219,15 +219,6 @@ export const ADMIN_WHITELIST_MODE_STORAGE_KEY = "bitvid_admin_whitelist_mode"; */ export const DEFAULT_WHITELIST_MODE_ENABLED = true; -/** - * Whether bitvid should clear stored NIP-07 permissions when users log out. - * - * Set this to `true` if your environment requires removing cached extension - * permissions on logout (e.g., shared devices). Leave it `false` to preserve - * permissions so returning users do not need to reauthorize the extension. - */ -export const CLEAR_NIP07_PERMISSIONS_ON_LOGOUT = false; - /** * Whether the public client should display content flagged as NSFW. * diff --git a/js/app.js b/js/app.js index 150e18c2..6858bec8 100644 --- a/js/app.js +++ b/js/app.js @@ -3272,25 +3272,17 @@ class Application { ); } - const activePubkey = - this.normalizeHexPubkey(getActiveProfilePubkey()) || this.pubkey; - if ( - activePubkey && - this.nostrService && - typeof this.nostrService.loadDirectMessages === "function" - ) { - this.nostrService - .loadDirectMessages({ - actorPubkey: activePubkey, - limit: 50, - initialLoad: true, - }) - .catch((error) => { - devLogger.warn( - "[Application] Failed to sync direct messages during login:", - error, - ); - }); + try { + await this.nostrService.loadDirectMessages({ + actorPubkey: activePubkey, + limit: 50, + initialLoad: true, + }); + } catch (error) { + devLogger.warn( + "[Application] Failed to sync direct messages during login:", + error, + ); } } @@ -4271,6 +4263,13 @@ class Application { devLogger.error("Post-login hydration failed:", error); }); + let postLoginResult = null; + try { + postLoginResult = await postLoginPromise; + } catch (error) { + devLogger.error("Post-login processing failed:", error); + } + await nwcPromise; // We do not await hashtagPreferencesPromise here; it loads in the background. @@ -4283,26 +4282,6 @@ class Application { ); } - const scheduleLoginRefresh = (reason) => { - try { - this.lastIdentityRefreshPromise = this.refreshAllVideoGrids({ - reason, - forceMainReload: true, - }); - return this.lastIdentityRefreshPromise; - } catch (error) { - devLogger.error("Failed to refresh video grids after login:", error); - this.lastIdentityRefreshPromise = null; - return null; - } - }; - - try { - this.reinitializeVideoListView({ reason: "login" }); - } catch (error) { - devLogger.warn("Failed to reinitialize video list view after login:", error); - } - if (activePubkey) { const aggregatedBlacklist = accessControl.getBlacklist(); @@ -4335,40 +4314,16 @@ class Application { // If it times out, the UI will refresh reactively when the data arrives. const blockLoadPromise = userBlocks.ensureLoaded(activePubkey); const blockLoadTimeout = new Promise((resolve) => - setTimeout(() => resolve({ timedOut: true }), 2000) + setTimeout(resolve, 2000) ); - - Promise.race([ - blockLoadPromise - .then(() => ({ timedOut: false })) - .catch((error) => ({ error })), - blockLoadTimeout, - ]) - .then((result) => { - if (result?.error) { - devLogger.warn( - "[Application] Block list load timed out or failed:", - result.error - ); - } else if (result?.timedOut) { - devLogger.warn( - "[Application] Block list load timed out:", - result - ); - } - const refreshPromise = scheduleLoginRefresh("auth-login"); - if (refreshPromise && typeof refreshPromise.finally === "function") { - refreshPromise.finally(() => { - this.lastIdentityRefreshPromise = null; - }); - } else { - this.lastIdentityRefreshPromise = null; - } - }) - .catch((error) => { - devLogger.error("Failed to refresh video grids after login:", error); - this.lastIdentityRefreshPromise = null; - }); + try { + await Promise.race([blockLoadPromise, blockLoadTimeout]); + } catch (error) { + devLogger.warn( + "[Application] Block list load timed out or failed:", + error + ); + } // Load subscriptions in the background to avoid blocking the main feed. subscriptions.ensureLoaded(activePubkey).catch((error) => { @@ -4377,33 +4332,24 @@ class Application { error ); }); + } - if ( - this.nostrService && - typeof this.nostrService.loadDirectMessages === "function" - ) { - this.nostrService - .loadDirectMessages({ - actorPubkey: activePubkey, - limit: 50, - initialLoad: true, - }) - .catch((error) => { - devLogger.warn( - "[Application] Failed to sync direct messages during login:", - error - ); - }); - } - } else { - const refreshPromise = scheduleLoginRefresh("auth-login"); - if (refreshPromise && typeof refreshPromise.finally === "function") { - refreshPromise.finally(() => { - this.lastIdentityRefreshPromise = null; - }); - } else { - this.lastIdentityRefreshPromise = null; - } + try { + this.reinitializeVideoListView({ reason: "login", postLoginResult }); + } catch (error) { + devLogger.warn("Failed to reinitialize video list view after login:", error); + } + + try { + this.lastIdentityRefreshPromise = this.refreshAllVideoGrids({ + reason: "auth-login", + forceMainReload: true, + }); + await this.lastIdentityRefreshPromise; + } catch (error) { + devLogger.error("Failed to refresh video grids after login:", error); + } finally { + this.lastIdentityRefreshPromise = null; } this.forceRefreshAllProfiles(); diff --git a/js/config.js b/js/config.js index 96f26c27..73d5fe76 100644 --- a/js/config.js +++ b/js/config.js @@ -43,7 +43,6 @@ import { ENSURE_PRESENCE_REBROADCAST_COOLDOWN_SECONDS, DEFAULT_PLAYBACK_SOURCE, DEFAULT_PLAYBACK_START_TIMEOUT, - CLEAR_NIP07_PERMISSIONS_ON_LOGOUT, } from "../config/instance-config.js"; export const WATCH_HISTORY_VERSION_TAG_VALUE = "2"; @@ -138,4 +137,3 @@ export { VIEW_COUNT_CACHE_TTL_MS }; export { ENSURE_PRESENCE_REBROADCAST_COOLDOWN_SECONDS }; export { DEFAULT_PLAYBACK_SOURCE }; export { DEFAULT_PLAYBACK_START_TIMEOUT }; -export { CLEAR_NIP07_PERMISSIONS_ON_LOGOUT }; diff --git a/js/nostr/client.js b/js/nostr/client.js index 35485988..88125a54 100644 --- a/js/nostr/client.js +++ b/js/nostr/client.js @@ -17,7 +17,7 @@ * - Signer Management: Orchestrates NIP-07 (Extension), NIP-46 (Remote/Bunker), and NIP-01 (Local nsec) signers. */ -import { CLEAR_NIP07_PERMISSIONS_ON_LOGOUT, isDevMode } from "../config.js"; +import { isDevMode } from "../config.js"; import { FEATURE_PUBLISH_NIP71 } from "../constants.js"; import { bytesToHex, sha256 } from "../../vendor/crypto-helpers.bundle.min.js"; import { infoHashFromMagnet } from "../magnets.js"; @@ -201,7 +201,6 @@ import { clearActiveSigner as clearActiveSignerInRegistry, logoutSigner as logoutSignerFromRegistry, resolveActiveSigner as resolveActiveSignerFromRegistry, - requestDefaultExtensionPermissions, } from "../nostrClientRegistry.js"; import { createNip07Adapter } from "./adapters/nip07Adapter.js"; import { createNsecAdapter } from "./adapters/nsecAdapter.js"; @@ -396,16 +395,6 @@ function shouldRequestExtensionPermissions(signer) { return signer.type === "extension"; } -function normalizePermissionMethods(methods) { - if (!Array.isArray(methods)) { - return []; - } - const normalized = methods - .map((method) => normalizePermissionMethod(method)) - .filter(Boolean); - return Array.from(new Set(normalized)); -} - const EVENTS_CACHE_STORAGE_KEY = "bitvid:eventsCache:v1"; // We use the policy TTL, but currently the storage backend is hardcoded to IDB (with localStorage fallback). // Future refactors should make EventsCacheStore dynamic based on CACHE_POLICIES[NOTE_TYPES.VIDEO_POST].storage. @@ -1017,63 +1006,6 @@ function buildDmFilters(actorPubkey, { since, until, limit } = {}) { return filters; } -function normalizeDmEncryptionHint(value) { - if (typeof value !== "string") { - return ""; - } - return value.trim().toLowerCase().replace(/[^a-z0-9_.-]/g, ""); -} - -function resolveDmDecryptPermissionMethods(event) { - if (!event || typeof event !== "object") { - return []; - } - - const kind = Number.isFinite(event.kind) ? event.kind : null; - if (kind === 1059) { - return ["nip44.decrypt", "nip44.v2.decrypt"]; - } - - const tags = Array.isArray(event.tags) ? event.tags : []; - let requiresNip44 = false; - let requiresNip04 = false; - - for (const tag of tags) { - if (!Array.isArray(tag) || tag.length < 2) { - continue; - } - const label = typeof tag[0] === "string" ? tag[0].trim().toLowerCase() : ""; - if (label !== "encrypted" && label !== "encryption") { - continue; - } - for (let index = 1; index < tag.length; index += 1) { - const normalized = normalizeDmEncryptionHint(tag[index]); - if (!normalized) { - continue; - } - if (normalized.includes("nip44")) { - requiresNip44 = true; - } - if (normalized.includes("nip04")) { - requiresNip04 = true; - } - } - } - - if (!requiresNip04 && !requiresNip44 && kind === 4) { - requiresNip04 = true; - } - - const methods = []; - if (requiresNip04) { - methods.push("nip04.decrypt"); - } - if (requiresNip44) { - methods.push("nip44.decrypt", "nip44.v2.decrypt"); - } - return methods; -} - const EXTENSION_MIME_MAP = Object.freeze( Object.fromEntries( @@ -1224,9 +1156,7 @@ export class NostrClient { getActivePubkey: () => this.pubkey, getSessionActor: () => this.sessionActor, ensureSessionActor: () => this.ensureSessionActor(), - ensureExtensionPermissions: (...args) => this.ensureExtensionPermissionsGate(...args), - hasRequiredExtensionPermissions: (...args) => - this.hasRequiredExtensionPermissions(...args), + ensureExtensionPermissions: (...args) => this.ensureExtensionPermissions(...args), resolveActiveSigner, shouldRequestExtensionPermissions, signEventWithPrivateKey, @@ -1336,7 +1266,6 @@ export class NostrClient { this.pendingHandshakeCancel = null; this.remoteSignerListeners = new Set(); this.sessionActorListeners = new Set(); - this.extensionPermissionRequestPromise = null; const storedRemoteSigner = this.getStoredNip46Metadata(); this.remoteSignerStatus = { state: storedRemoteSigner.hasSession ? "stored" : "idle", @@ -2025,12 +1954,18 @@ export class NostrClient { ); } - const initialPubkey = await runNip07WithRetry( - () => extension.getPublicKey(), - { - label: "extension.getPublicKey", - }, + const permissionResult = await this.ensureExtensionPermissions( + DEFAULT_NIP07_PERMISSION_METHODS, ); + if (!permissionResult.ok) { + const denialMessage = + 'The NIP-07 extension reported "permission denied". Please approve the prompt and try again.'; + const denialError = new Error(denialMessage); + if (permissionResult.error) { + denialError.cause = permissionResult.error; + } + throw denialError; + } if ( allowAccountSelection && @@ -2061,12 +1996,9 @@ export class NostrClient { } } - const pubkey = - allowAccountSelection && typeof extension.selectAccounts === "function" - ? await runNip07WithRetry(() => extension.getPublicKey(), { - label: "extension.getPublicKey", - }) - : initialPubkey; + const pubkey = await runNip07WithRetry(() => extension.getPublicKey(), { + label: "extension.getPublicKey", + }); if (!pubkey || typeof pubkey !== "string") { throw new Error( @@ -2103,19 +2035,19 @@ export class NostrClient { } } - const permissionResult = await this.ensureExtensionPermissions([ - ...DEFAULT_NIP07_PERMISSION_METHODS, - ...DEFAULT_NIP07_ENCRYPTION_METHODS, - ]); - - if (!permissionResult.ok) { - throw permissionResult.error; - } - const adapter = await createNip07Adapter(extension); adapter.pubkey = pubkey; setActiveSigner(adapter); + this.ensureExtensionPermissions(DEFAULT_NIP07_PERMISSION_METHODS).catch( + (err) => { + userLogger.warn( + "[nostr] Extension permissions were not fully granted after login:", + err, + ); + }, + ); + return { pubkey, signer: adapter }; } @@ -2856,8 +2788,16 @@ export class NostrClient { this.extensionPermissionCache = new Set(); } + if (!Array.isArray(methods)) { + return; + } + let didChange = false; - for (const normalized of normalizePermissionMethods(methods)) { + for (const method of methods) { + const normalized = normalizePermissionMethod(method); + if (!normalized) { + continue; + } if (!this.extensionPermissionCache.has(normalized)) { didChange = true; } @@ -2873,50 +2813,20 @@ export class NostrClient { } } - hasRequiredExtensionPermissions( - methods = DEFAULT_NIP07_PERMISSION_METHODS, - ) { - const normalized = normalizePermissionMethods(methods); - if (!normalized.length) { - return true; - } - if (!this.extensionPermissionCache) { - return false; - } - return normalized.every((method) => this.extensionPermissionCache.has(method)); - } - - async ensureExtensionPermissionsGate( - methods = DEFAULT_NIP07_PERMISSION_METHODS, - ) { - const normalized = normalizePermissionMethods(methods); - if (!normalized.length) { - return { ok: true }; - } - - if (this.hasRequiredExtensionPermissions(normalized)) { - return { ok: true, cached: true }; - } - - if (this.extensionPermissionRequestPromise) { - return this.extensionPermissionRequestPromise; - } - - const requestPromise = this.ensureExtensionPermissions(normalized) - .catch((error) => ({ ok: false, error })) - .finally(() => { - if (this.extensionPermissionRequestPromise === requestPromise) { - this.extensionPermissionRequestPromise = null; - } - }); - - this.extensionPermissionRequestPromise = requestPromise; - - return requestPromise; - } - async ensureExtensionPermissions(methods = DEFAULT_NIP07_PERMISSION_METHODS) { - const normalized = normalizePermissionMethods(methods); + if (!Array.isArray(methods)) { + methods = []; + } + + const normalized = Array.from( + new Set( + methods + .map((method) => + typeof method === "string" && method.trim() ? method.trim() : "", + ) + .filter(Boolean), + ), + ); if (!normalized.length) { return { ok: true }; @@ -4463,9 +4373,9 @@ export class NostrClient { * - Wipes session actor (ephemeral keys). * - Disconnects NIP-46 remote signer (if active). * - Clears Watch History cache. - * - Optionally clears stored NIP-07 permissions. + * - Resets permissions cache. */ - logout({ clearNip07Permissions = null } = {}) { + logout() { const previousPubkey = this.pubkey; this.pubkey = null; logoutSigner(previousPubkey); @@ -4499,20 +4409,13 @@ export class NostrClient { } this.watchHistory.clear(); - const shouldClearNip07Permissions = - typeof clearNip07Permissions === "boolean" - ? clearNip07Permissions - : CLEAR_NIP07_PERMISSIONS_ON_LOGOUT; - - if (shouldClearNip07Permissions) { - if ( - this.extensionPermissionCache && - typeof this.extensionPermissionCache.clear === "function" - ) { - this.extensionPermissionCache.clear(); - } - clearStoredNip07Permissions(); + if ( + this.extensionPermissionCache && + typeof this.extensionPermissionCache.clear === "function" + ) { + this.extensionPermissionCache.clear(); } + clearStoredNip07Permissions(); devLogger.log("User logged out."); } @@ -4717,10 +4620,7 @@ export class NostrClient { return { actorPubkey: normalizedActor, decryptors }; } - async decryptDirectMessageEvent( - event, - { actorPubkey, onPermissionRequest } = {}, - ) { + async decryptDirectMessageEvent(event, { actorPubkey } = {}) { const eventId = typeof event?.id === "string" ? event.id : ""; if (eventId) { const cached = this.dmDecryptCache.get(eventId); @@ -4731,50 +4631,6 @@ export class NostrClient { const decryptDM = await this.ensureDmDecryptor(); const context = await this.buildDmDecryptContext(actorPubkey); - const permissionMethods = resolveDmDecryptPermissionMethods(event); - const needsExtensionPermission = - permissionMethods.length && - typeof window !== "undefined" && - window?.nostr && - !this.hasRequiredExtensionPermissions(permissionMethods) && - context.decryptors.some( - (candidate) => candidate?.source === "extension", - ); - - if (needsExtensionPermission) { - if (typeof onPermissionRequest === "function") { - onPermissionRequest({ - status: "decrypting", - methods: permissionMethods, - eventId, - }); - } - - const permissionResult = - await requestDefaultExtensionPermissions(permissionMethods); - if (!permissionResult?.ok) { - const error = new Error("extension-permission-denied"); - error.code = "extension-permission-denied"; - error.cause = permissionResult?.error || null; - if (typeof onPermissionRequest === "function") { - onPermissionRequest({ - status: "error", - methods: permissionMethods, - eventId, - error, - }); - } - return { ok: false, error }; - } - - if (typeof onPermissionRequest === "function") { - onPermissionRequest({ - status: "ready", - methods: permissionMethods, - eventId, - }); - } - } let result; try { @@ -4808,10 +4664,6 @@ export class NostrClient { Number.isFinite(options?.decryptLimit) && options.decryptLimit > 0 ? Math.floor(options.decryptLimit) : null; - const onPermissionRequest = - typeof options?.onPermissionRequest === "function" - ? options.onPermissionRequest - : null; const relayCandidates = Array.isArray(options.relays) ? options.relays @@ -4864,7 +4716,6 @@ export class NostrClient { try { const decrypted = await this.decryptDirectMessageEvent(event, { actorPubkey: context.actorPubkey || actorPubkeyInput, - onPermissionRequest, }); if (decrypted?.ok) { messages.push(decrypted); @@ -4949,10 +4800,6 @@ export class NostrClient { const result = await this.decryptDirectMessageEvent(event, { actorPubkey: context.actorPubkey || actorPubkeyInput, - onPermissionRequest: - typeof options?.onPermissionRequest === "function" - ? options.onPermissionRequest - : null, }); if (result?.ok) { diff --git a/js/nostr/commentEvents.js b/js/nostr/commentEvents.js index 29d11c81..8d930ed3 100644 --- a/js/nostr/commentEvents.js +++ b/js/nostr/commentEvents.js @@ -1213,21 +1213,10 @@ export async function publishComment( ? shouldRequestExtensionPermissions(signer) : false; - const hasCachedPermissions = - typeof client?.hasRequiredExtensionPermissions === "function" && - client.hasRequiredExtensionPermissions(DEFAULT_NIP07_PERMISSION_METHODS); - - if (shouldRequestPermissions && !hasCachedPermissions) { - const permissionGate = - typeof client?.ensureExtensionPermissionsGate === "function" - ? client.ensureExtensionPermissionsGate.bind(client) - : typeof client?.ensureExtensionPermissions === "function" - ? client.ensureExtensionPermissions.bind(client) - : null; - - if (permissionGate) { - permissionResult = await permissionGate(DEFAULT_NIP07_PERMISSION_METHODS); - } + if (shouldRequestPermissions) { + permissionResult = await client.ensureExtensionPermissions( + DEFAULT_NIP07_PERMISSION_METHODS, + ); } if (!permissionResult.ok) { diff --git a/js/nostr/defaultClient.js b/js/nostr/defaultClient.js index 2614692e..13363919 100644 --- a/js/nostr/defaultClient.js +++ b/js/nostr/defaultClient.js @@ -15,7 +15,7 @@ export const nostrClient = new NostrClient(); registerNostrClient(nostrClient, { requestPermissions: ( methods = DEFAULT_NIP07_PERMISSION_METHODS, - ) => nostrClient.ensureExtensionPermissionsGate(methods), + ) => nostrClient.ensureExtensionPermissions(methods), }); export function requestDefaultExtensionPermissions( @@ -24,12 +24,6 @@ export function requestDefaultExtensionPermissions( return requestRegisteredPermissions(methods); } -export function hasDefaultExtensionPermissions( - methods = DEFAULT_NIP07_PERMISSION_METHODS, -) { - return nostrClient.hasRequiredExtensionPermissions(methods); -} - export { NostrClient, DEFAULT_NIP07_ENCRYPTION_METHODS, diff --git a/js/nostr/dmSignalEvents.js b/js/nostr/dmSignalEvents.js index 581bc3e0..b138d497 100644 --- a/js/nostr/dmSignalEvents.js +++ b/js/nostr/dmSignalEvents.js @@ -101,21 +101,10 @@ export async function publishDmReadReceipt( ? shouldRequestExtensionPermissions(signer) : false; - const hasCachedPermissions = - typeof client?.hasRequiredExtensionPermissions === "function" && - client.hasRequiredExtensionPermissions(DEFAULT_NIP07_PERMISSION_METHODS); - - if (shouldRequestPermissions && !hasCachedPermissions) { - const permissionGate = - typeof client?.ensureExtensionPermissionsGate === "function" - ? client.ensureExtensionPermissionsGate.bind(client) - : typeof client?.ensureExtensionPermissions === "function" - ? client.ensureExtensionPermissions.bind(client) - : null; - - if (permissionGate) { - permissionResult = await permissionGate(DEFAULT_NIP07_PERMISSION_METHODS); - } + if (shouldRequestPermissions) { + permissionResult = await client.ensureExtensionPermissions( + DEFAULT_NIP07_PERMISSION_METHODS, + ); } if (!permissionResult.ok) { @@ -253,21 +242,10 @@ export async function publishDmTypingIndicator( ? shouldRequestExtensionPermissions(signer) : false; - const hasCachedPermissions = - typeof client?.hasRequiredExtensionPermissions === "function" && - client.hasRequiredExtensionPermissions(DEFAULT_NIP07_PERMISSION_METHODS); - - if (shouldRequestPermissions && !hasCachedPermissions) { - const permissionGate = - typeof client?.ensureExtensionPermissionsGate === "function" - ? client.ensureExtensionPermissionsGate.bind(client) - : typeof client?.ensureExtensionPermissions === "function" - ? client.ensureExtensionPermissions.bind(client) - : null; - - if (permissionGate) { - permissionResult = await permissionGate(DEFAULT_NIP07_PERMISSION_METHODS); - } + if (shouldRequestPermissions) { + permissionResult = await client.ensureExtensionPermissions( + DEFAULT_NIP07_PERMISSION_METHODS, + ); } if (!permissionResult.ok) { diff --git a/js/nostr/nip07Permissions.js b/js/nostr/nip07Permissions.js index e28c4c4e..f85a3a03 100644 --- a/js/nostr/nip07Permissions.js +++ b/js/nostr/nip07Permissions.js @@ -1,14 +1,15 @@ import { devLogger, userLogger } from "../utils/logger.js"; -export const NIP07_LOGIN_TIMEOUT_MS = 20_000; // 20 seconds +export const NIP07_LOGIN_TIMEOUT_MS = 60_000; // 60 seconds export const NIP07_LOGIN_TIMEOUT_ERROR_MESSAGE = "Timed out waiting for the NIP-07 extension. Confirm the extension prompt in your browser toolbar and try again."; const NIP07_PERMISSIONS_STORAGE_KEY = "bitvid:nip07:permissions"; // Give the NIP-07 extension enough time to surface its approval prompt and let -// users unlock/authorize it. The default is tuned to keep login responsive while +// users unlock/authorize it. Seven seconds proved too aggressive once vendors +// started requiring an unlock step, so we extend the window substantially while // still allowing manual overrides via __BITVID_NIP07_ENABLE_VARIANT_TIMEOUT_MS__. -const DEFAULT_ENABLE_VARIANT_TIMEOUT_MS = 5_000; +const DEFAULT_ENABLE_VARIANT_TIMEOUT_MS = 45_000; export const DEFAULT_NIP07_ENCRYPTION_METHODS = Object.freeze([ // Encryption helpers — request both legacy NIP-04 and modern NIP-44 upfront @@ -21,11 +22,12 @@ export const DEFAULT_NIP07_ENCRYPTION_METHODS = Object.freeze([ ]); export const DEFAULT_NIP07_PERMISSION_METHODS = Object.freeze([ - // Core auth + relay metadata (encryption permissions requested on demand) + // Core auth + relay metadata "get_public_key", "sign_event", "read_relays", "write_relays", + ...DEFAULT_NIP07_ENCRYPTION_METHODS, ]); export const NIP07_PRIORITY = Object.freeze({ @@ -88,21 +90,6 @@ class Nip07RequestQueue { // while allowing critical tasks (e.g. blocklist decryption) to jump the line. const requestQueue = new Nip07RequestQueue(); -export function getNip07LoginTimeoutMs() { - const overrideValue = - typeof globalThis !== "undefined" && - globalThis !== null && - Number.isFinite(globalThis.__BITVID_NIP07_LOGIN_TIMEOUT_MS__) - ? Math.floor(globalThis.__BITVID_NIP07_LOGIN_TIMEOUT_MS__) - : null; - - if (overrideValue !== null && overrideValue > 0) { - return Math.max(1_000, overrideValue); - } - - return NIP07_LOGIN_TIMEOUT_MS; -} - export function getEnableVariantTimeoutMs() { const overrideValue = typeof globalThis !== "undefined" && @@ -221,7 +208,7 @@ export function clearStoredNip07Permissions() { export function withNip07Timeout( operation, { - timeoutMs = getNip07LoginTimeoutMs(), + timeoutMs = NIP07_LOGIN_TIMEOUT_MS, message = NIP07_LOGIN_TIMEOUT_ERROR_MESSAGE, } = {}, ) { @@ -229,7 +216,7 @@ export function withNip07Timeout( const effectiveTimeout = Number.isFinite(numericTimeout) && numericTimeout > 0 ? numericTimeout - : getNip07LoginTimeoutMs(); + : NIP07_LOGIN_TIMEOUT_MS; let timeoutId; const timeoutPromise = new Promise((_, reject) => { @@ -261,7 +248,7 @@ export async function runNip07WithRetry( operation, { label = "NIP-07 operation", - timeoutMs = getNip07LoginTimeoutMs(), + timeoutMs = NIP07_LOGIN_TIMEOUT_MS, retryMultiplier = 2, priority = NIP07_PRIORITY.NORMAL, } = {}, @@ -336,29 +323,23 @@ export async function requestEnablePermissions( } permissionVariants.push(null); - const enableTimeoutMs = getEnableVariantTimeoutMs(); - const loginTimeoutMs = getNip07LoginTimeoutMs(); - const shortEnableTimeoutMs = Math.min(5_000, enableTimeoutMs); - const explicitEnableTimeoutMs = - enableTimeoutMs < 1000 - ? enableTimeoutMs - : Math.max(enableTimeoutMs, Math.min(15_000, loginTimeoutMs)); - let lastError = null; for (const options of permissionVariants) { - // Prefer the null enable() call with a shorter interactive window. - // If it is rejected, try explicit-permission variants with a longer timeout - // to accommodate extensions that require structured permissions. - const variantTimeoutOverrides = - options === null - ? { - timeoutMs: shortEnableTimeoutMs, - retryMultiplier: 1, - } - : { - timeoutMs: explicitEnableTimeoutMs, - retryMultiplier: 1, - }; + // If specific permissions are requested (variants 1 & 2), fail fast (3s) + // to avoid hanging if the extension doesn't support the structured format. + // If we fall back to standard enable() (null), allow the full user interaction time. + const variantTimeoutOverrides = options + ? { + timeoutMs: Math.min(3000, getEnableVariantTimeoutMs()), + retryMultiplier: 1, + } + : { + timeoutMs: Math.min( + NIP07_LOGIN_TIMEOUT_MS, + getEnableVariantTimeoutMs(), + ), + retryMultiplier: 1, + }; try { await runNip07WithRetry( @@ -388,7 +369,6 @@ export const __testExports = { runNip07WithRetry, withNip07Timeout, getEnableVariantTimeoutMs, - getNip07LoginTimeoutMs, readStoredNip07Permissions, writeStoredNip07Permissions, clearStoredNip07Permissions, diff --git a/js/nostr/reactionEvents.js b/js/nostr/reactionEvents.js index 76a695b2..d059f2cc 100644 --- a/js/nostr/reactionEvents.js +++ b/js/nostr/reactionEvents.js @@ -435,21 +435,10 @@ export async function publishVideoReaction( if (canUseActiveSigner) { let permissionResult = { ok: true }; - const hasCachedPermissions = - typeof client?.hasRequiredExtensionPermissions === "function" && - client.hasRequiredExtensionPermissions(DEFAULT_NIP07_PERMISSION_METHODS); - - if (shouldRequestExtensionPermissions(signer) && !hasCachedPermissions) { - const permissionGate = - typeof client?.ensureExtensionPermissionsGate === "function" - ? client.ensureExtensionPermissionsGate.bind(client) - : typeof client?.ensureExtensionPermissions === "function" - ? client.ensureExtensionPermissions.bind(client) - : null; - - if (permissionGate) { - permissionResult = await permissionGate(DEFAULT_NIP07_PERMISSION_METHODS); - } + if (shouldRequestExtensionPermissions(signer)) { + permissionResult = await client.ensureExtensionPermissions( + DEFAULT_NIP07_PERMISSION_METHODS + ); } if (permissionResult.ok) { try { diff --git a/js/nostr/viewEvents.js b/js/nostr/viewEvents.js index 425ec826..284e5d92 100644 --- a/js/nostr/viewEvents.js +++ b/js/nostr/viewEvents.js @@ -787,21 +787,10 @@ export async function publishViewEvent( if (canUseActiveSigner) { let permissionResult = { ok: true }; - const hasCachedPermissions = - typeof client?.hasRequiredExtensionPermissions === "function" && - client.hasRequiredExtensionPermissions(DEFAULT_NIP07_PERMISSION_METHODS); - - if (shouldRequestExtensionPermissions(signer) && !hasCachedPermissions) { - const permissionGate = - typeof client?.ensureExtensionPermissionsGate === "function" - ? client.ensureExtensionPermissionsGate.bind(client) - : typeof client?.ensureExtensionPermissions === "function" - ? client.ensureExtensionPermissions.bind(client) - : null; - - if (permissionGate) { - permissionResult = await permissionGate(DEFAULT_NIP07_PERMISSION_METHODS); - } + if (shouldRequestExtensionPermissions(signer)) { + permissionResult = await client.ensureExtensionPermissions( + DEFAULT_NIP07_PERMISSION_METHODS + ); } if (permissionResult.ok) { try { diff --git a/js/nostr/watchHistory.js b/js/nostr/watchHistory.js index 2820946e..04e9ecfa 100644 --- a/js/nostr/watchHistory.js +++ b/js/nostr/watchHistory.js @@ -1530,12 +1530,7 @@ class WatchHistoryManager { if (activeSigner) { if (this.deps.shouldRequestExtensionPermissions?.(activeSigner)) { - const hasCachedPermissions = - typeof this.deps.hasRequiredExtensionPermissions === "function" && - this.deps.hasRequiredExtensionPermissions(DEFAULT_NIP07_PERMISSION_METHODS); - if (!hasCachedPermissions) { - await this.deps.ensureExtensionPermissions?.(DEFAULT_NIP07_PERMISSION_METHODS); - } + await this.deps.ensureExtensionPermissions?.(DEFAULT_NIP07_PERMISSION_METHODS); } const encryptors = []; @@ -1868,12 +1863,7 @@ class WatchHistoryManager { const decryptSigner = canUseActiveSignerDecrypt ? signer : null; if (decryptSigner && this.deps.shouldRequestExtensionPermissions?.(decryptSigner)) { - const hasCachedPermissions = - typeof this.deps.hasRequiredExtensionPermissions === "function" && - this.deps.hasRequiredExtensionPermissions(DEFAULT_NIP07_PERMISSION_METHODS); - if (!hasCachedPermissions) { - await this.deps.ensureExtensionPermissions?.(DEFAULT_NIP07_PERMISSION_METHODS); - } + await this.deps.ensureExtensionPermissions?.(DEFAULT_NIP07_PERMISSION_METHODS); } devLogger.info("[nostr] Fetching watch history from relays.", { diff --git a/js/nostrClientFacade.js b/js/nostrClientFacade.js index 32e02cd7..6aabbde3 100644 --- a/js/nostrClientFacade.js +++ b/js/nostrClientFacade.js @@ -7,7 +7,6 @@ export { nostrClient, - hasDefaultExtensionPermissions, requestDefaultExtensionPermissions, } from "./nostr/defaultClient.js"; diff --git a/js/nostrClientRegistry.js b/js/nostrClientRegistry.js index 9d8c231d..9adbd272 100644 --- a/js/nostrClientRegistry.js +++ b/js/nostrClientRegistry.js @@ -88,10 +88,6 @@ export function registerNostrClient(client, options = {}) { ) { requestPermissionsDelegate = (...args) => registeredNostrClient.ensureExtensionPermissions(...args); - if (typeof registeredNostrClient.ensureExtensionPermissionsGate === "function") { - requestPermissionsDelegate = (...args) => - registeredNostrClient.ensureExtensionPermissionsGate(...args); - } return; } diff --git a/js/services/authProviders/nip07.js b/js/services/authProviders/nip07.js index f5b0b12f..1d425d2d 100644 --- a/js/services/authProviders/nip07.js +++ b/js/services/authProviders/nip07.js @@ -1,5 +1,4 @@ import { accessControl } from "../../accessControl.js"; -import { devLogger } from "../../utils/logger.js"; const PROVIDER_ID = "nip07"; const PROVIDER_LABEL = "extension (nip-07)"; @@ -75,16 +74,6 @@ export default { }; const result = await nostrClient.loginWithExtension({ ...options, validator }); - if (typeof nostrClient.ensureDmDecryptor === "function") { - Promise.resolve() - .then(() => nostrClient.ensureDmDecryptor()) - .catch((error) => { - devLogger.warn( - "[auth:nip07] Failed to preload DM decryptor:", - error, - ); - }); - } const pubkey = normalizePubkey(result); const signer = result && typeof result === "object" && result.signer diff --git a/js/services/hashtagPreferencesService.js b/js/services/hashtagPreferencesService.js index 7442f738..2e4ce5c2 100644 --- a/js/services/hashtagPreferencesService.js +++ b/js/services/hashtagPreferencesService.js @@ -27,9 +27,6 @@ const HEX64_REGEX = /^[0-9a-f]{64}$/i; const DEFAULT_VERSION = 1; const DECRYPT_TIMEOUT_MS = 90000; const DECRYPT_RETRY_DELAY_MS = 10000; -const FAST_RELAY_LIMIT = 3; -const FAST_RELAY_TIMEOUT_MS = 4000; -const FULL_RELAY_TIMEOUT_MS = 12000; class TinyEventEmitter { constructor() { @@ -451,8 +448,7 @@ class HashtagPreferencesService { ); } - async load(pubkey, options = {}) { - const { skipFastRelays = false, isBackground = false, skipCache = false } = options; + async load(pubkey) { const normalized = normalizeHexPubkey(pubkey); let wasLoadedForUser = this.activePubkey && @@ -468,7 +464,7 @@ class HashtagPreferencesService { } // Try cache first - if (!skipCache && this.loadFromCache(normalized)) { + if (this.loadFromCache(normalized)) { wasLoadedForUser = true; } @@ -527,43 +523,20 @@ class HashtagPreferencesService { // If we have cached data, we ask for updates since that timestamp. // If we have no data, we force a full fetch (since=0). const since = wasLoadedForUser ? Number(this.eventCreatedAt) || 0 : 0; - const fastRelays = relays.slice(0, FAST_RELAY_LIMIT); - const shouldUseFastRelays = - !skipFastRelays && fastRelays.length > 0 && fastRelays.length < relays.length; - const initialRelays = shouldUseFastRelays ? fastRelays : relays; - const initialTimeoutMs = shouldUseFastRelays - ? FAST_RELAY_TIMEOUT_MS - : FULL_RELAY_TIMEOUT_MS; - const fetchFromRelays = async (relayList, timeoutMs) => { - const promises = kinds.map((kind) => - nostrClient.fetchListIncrementally({ - kind, - pubkey: normalized, - dTag: HASHTAG_IDENTIFIER, - relayUrls: relayList, - since, - timeoutMs, - }), - ); + const promises = kinds.map((kind) => + nostrClient.fetchListIncrementally({ + kind, + pubkey: normalized, + dTag: HASHTAG_IDENTIFIER, + relayUrls: relays, + since, + timeoutMs: 12000, + }), + ); - const results = await Promise.all(promises); - return results.flat(); - }; - - events = await fetchFromRelays(initialRelays, initialTimeoutMs); - if (!events.length && shouldUseFastRelays && !isBackground) { - devLogger.log( - `${LOG_PREFIX} Fast relay fetch returned no events; continuing in background.`, - ); - this.load(normalized, { - skipFastRelays: true, - isBackground: true, - skipCache: true, - }).catch((error) => { - devLogger.warn(`${LOG_PREFIX} Background refresh failed`, error); - }); - } + const results = await Promise.all(promises); + events = results.flat(); } catch (error) { fetchError = error; @@ -744,14 +717,7 @@ class HashtagPreferencesService { (!signerHasNip04 && requiresNip04) ) { try { - const permissionMethods = []; - if (requiresNip04) { - permissionMethods.push("nip04.decrypt"); - } - if (requiresNip44) { - permissionMethods.push("nip44.decrypt", "nip44.v2.decrypt"); - } - await requestDefaultExtensionPermissions(permissionMethods); + await requestDefaultExtensionPermissions(); } catch (error) { userLogger.warn( `${LOG_PREFIX} Extension permissions request failed while loading preferences`, @@ -954,15 +920,7 @@ class HashtagPreferencesService { } if (signer.type === "extension") { - const permissionMethods = ["sign_event"]; - if (typeof signer.nip04Encrypt === "function") { - permissionMethods.push("nip04.encrypt"); - } - if (typeof signer.nip44Encrypt === "function") { - permissionMethods.push("nip44.encrypt", "nip44.v2.encrypt"); - } - const permissionResult = - await requestDefaultExtensionPermissions(permissionMethods); + const permissionResult = await requestDefaultExtensionPermissions(); if (!permissionResult?.ok) { const error = new Error( "The active signer must allow encryption and signing before publishing preferences.", diff --git a/js/services/nostrService.js b/js/services/nostrService.js index d3b5af0d..5d7dfe7e 100644 --- a/js/services/nostrService.js +++ b/js/services/nostrService.js @@ -958,24 +958,6 @@ export class NostrService { ...options, limit: resolvedLimit, decryptLimit: resolvedDecryptLimit, - onPermissionRequest: (detail = {}) => { - const status = detail?.status; - if (status === "decrypting") { - this.emit("directMessages:status", { - status, - message: "Decrypting direct messages…", - methods: detail?.methods || [], - eventId: detail?.eventId || null, - }); - } else if (status === "ready") { - this.emit("directMessages:status", { status: "ready" }); - } else if (status === "error") { - this.emit("directMessages:status", { - status: "error", - error: detail?.error || null, - }); - } - }, onMessage: (message) => { this.applyDirectMessage(message, { reason: "load-incremental" }); }, @@ -1095,24 +1077,6 @@ export class NostrService { { relays: resolvedRelays, onEvent: handlers.onEvent, - onPermissionRequest: (detail = {}) => { - const status = detail?.status; - if (status === "decrypting") { - this.emit("directMessages:status", { - status, - message: "Decrypting direct messages…", - methods: detail?.methods || [], - eventId: detail?.eventId || null, - }); - } else if (status === "ready") { - this.emit("directMessages:status", { status: "ready" }); - } else if (status === "error") { - this.emit("directMessages:status", { - status: "error", - error: detail?.error || null, - }); - } - }, onMessage: (message, context = {}) => { this.applyDirectMessage(message, { reason: "subscription", diff --git a/js/subscriptions.js b/js/subscriptions.js index bdb5b6df..4c2bfc21 100644 --- a/js/subscriptions.js +++ b/js/subscriptions.js @@ -1,7 +1,6 @@ // js/subscriptions.js import { getActiveSigner, - hasDefaultExtensionPermissions, nostrClient, requestDefaultExtensionPermissions, } from "./nostrClientFacade.js"; @@ -38,9 +37,6 @@ const SUBSCRIPTION_SET_KIND = getNostrEventSchema(NOTE_TYPES.SUBSCRIPTION_LIST)?.kind ?? 30000; const DECRYPT_TIMEOUT_MS = 90000; const DECRYPT_RETRY_DELAY_MS = 10000; -const FAST_RELAY_LIMIT = 3; -const FAST_RELAY_TIMEOUT_MS = 4000; -const FULL_RELAY_TIMEOUT_MS = 12000; function normalizeHexPubkey(value) { if (typeof value !== "string") { @@ -360,28 +356,6 @@ class SubscriptionsManager { }); } - emitStatus(detail = {}) { - this.emitter.emit("status", detail); - - if (detail?.status !== "loading") { - return; - } - - const message = - typeof detail?.message === "string" && detail.message.trim() - ? detail.message.trim() - : "Decrypting subscriptions…"; - const container = - this.lastContainerId && - typeof document !== "undefined" && - document.getElementById - ? document.getElementById(this.lastContainerId) - : null; - if (container) { - container.innerHTML = getSidebarLoadingMarkup(message); - } - } - /** * Decrypt the subscription list from kind=30000 (d="subscriptions"). */ @@ -451,16 +425,14 @@ class SubscriptionsManager { ); } - async updateFromRelays(userPubkey, options = {}) { + async updateFromRelays(userPubkey) { if (!userPubkey) return; try { const normalizedUserPubkey = normalizeHexPubkey(userPubkey) || userPubkey; - const { relayUrlsOverride, skipFastRelays = false, isBackground = false } = options; const readRelays = relayManager.getReadRelayUrls(); - const fallbackRelayUrls = readRelays.length > 0 ? readRelays : Array.from(DEFAULT_RELAY_URLS); - const relayUrls = relayUrlsOverride?.length ? relayUrlsOverride : fallbackRelayUrls; + const relayUrls = readRelays.length > 0 ? readRelays : Array.from(DEFAULT_RELAY_URLS); if (!relayUrls.length) { devLogger.warn( "[SubscriptionsManager] No relay URLs available while loading subscriptions.", @@ -468,75 +440,45 @@ class SubscriptionsManager { return; } - const fastRelayUrls = relayUrls.slice(0, FAST_RELAY_LIMIT); - const shouldUseFastRelays = - !skipFastRelays && fastRelayUrls.length > 0 && fastRelayUrls.length < relayUrls.length; - const initialRelayUrls = shouldUseFastRelays ? fastRelayUrls : relayUrls; - const initialTimeoutMs = shouldUseFastRelays - ? FAST_RELAY_TIMEOUT_MS - : FULL_RELAY_TIMEOUT_MS; - // Use incremental fetch helper const cachedSnapshot = parseCachedSubscriptionSnapshot( profileCache.getProfileData(normalizedUserPubkey, "subscriptions"), ); const shouldForceFullFetch = !cachedSnapshot.hasSnapshot; + let events = await nostrClient.fetchListIncrementally({ + kind: SUBSCRIPTION_SET_KIND, + pubkey: normalizedUserPubkey, + dTag: SUBSCRIPTION_LIST_IDENTIFIER, + relayUrls, + since: shouldForceFullFetch ? 0 : undefined, + timeoutMs: 12000, + }); + const normalizedSessionActorPubkey = normalizeNostrPubkey( nostrClient?.sessionActor?.pubkey, ); const shouldFetchSessionActor = normalizedSessionActorPubkey && normalizedSessionActorPubkey !== normalizedUserPubkey; - - const fetchFromRelays = async (relayList, timeoutMs) => { - let events = await nostrClient.fetchListIncrementally({ - kind: SUBSCRIPTION_SET_KIND, - pubkey: normalizedUserPubkey, - dTag: SUBSCRIPTION_LIST_IDENTIFIER, - relayUrls: relayList, - since: shouldForceFullFetch ? 0 : undefined, - timeoutMs, - }); - - if (shouldFetchSessionActor) { - const sessionCachedSnapshot = parseCachedSubscriptionSnapshot( - profileCache.getProfileData( - normalizedSessionActorPubkey, - "subscriptions", - ), - ); - const shouldForceSessionFetch = !sessionCachedSnapshot.hasSnapshot; - const sessionEvents = await nostrClient.fetchListIncrementally({ - kind: SUBSCRIPTION_SET_KIND, - pubkey: normalizedSessionActorPubkey, - dTag: SUBSCRIPTION_LIST_IDENTIFIER, - relayUrls: relayList, - since: shouldForceSessionFetch ? 0 : undefined, - timeoutMs, - }); - if (sessionEvents.length) { - events = events.concat(sessionEvents); - } - } - - return events; - }; - - let events = await fetchFromRelays(initialRelayUrls, initialTimeoutMs); - if (!events.length && shouldUseFastRelays && !isBackground) { - devLogger.log( - "[SubscriptionsManager] Fast relay fetch returned no events; continuing in background.", + if (shouldFetchSessionActor) { + const sessionCachedSnapshot = parseCachedSubscriptionSnapshot( + profileCache.getProfileData( + normalizedSessionActorPubkey, + "subscriptions", + ), ); - this.updateFromRelays(normalizedUserPubkey, { - relayUrlsOverride: relayUrls, - skipFastRelays: true, - isBackground: true, - }).catch((error) => { - devLogger.warn( - "[SubscriptionsManager] Background relay refresh failed:", - error, - ); + const shouldForceSessionFetch = !sessionCachedSnapshot.hasSnapshot; + const sessionEvents = await nostrClient.fetchListIncrementally({ + kind: SUBSCRIPTION_SET_KIND, + pubkey: normalizedSessionActorPubkey, + dTag: SUBSCRIPTION_LIST_IDENTIFIER, + relayUrls, + since: shouldForceSessionFetch ? 0 : undefined, + timeoutMs: 12000, }); + if (sessionEvents.length) { + events = events.concat(sessionEvents); + } } const mergedEvents = []; @@ -808,38 +750,21 @@ class SubscriptionsManager { (!signerHasNip04 && !windowHasNip04 && requiresNip04) ) { try { - const permissionMethods = []; - if (requiresNip04) { - permissionMethods.push("nip04.decrypt"); - } - if (requiresNip44) { - permissionMethods.push("nip44.decrypt", "nip44.v2.decrypt"); - } - const hasCachedPermissions = hasDefaultExtensionPermissions(permissionMethods); - if (!hasCachedPermissions) { - this.emitStatus({ - status: "loading", - message: "Decrypting subscriptions…", - }); - const permissionResult = - await requestDefaultExtensionPermissions(permissionMethods); - if (!permissionResult?.ok) { - const error = - permissionResult?.error instanceof Error - ? permissionResult.error - : new Error( - "Extension permissions denied while decrypting subscriptions." - ); - error.code = "nostr-permission-denied"; - return { ok: false, error }; - } + const permissionResult = await requestDefaultExtensionPermissions(); + if (!permissionResult?.ok) { + const error = + permissionResult?.error instanceof Error + ? permissionResult.error + : new Error( + "Extension permissions denied while decrypting subscriptions." + ); + error.code = "nostr-permission-denied"; + return { ok: false, error }; } } catch (error) { const wrapped = error instanceof Error ? error : new Error(String(error)); wrapped.code = "nostr-permission-error"; return { ok: false, error: wrapped }; - } finally { - this.emitStatus({ status: "idle" }); } } @@ -1054,30 +979,18 @@ class SubscriptionsManager { } if (signer.type === "extension") { - const permissionMethods = ["sign_event"]; - if (typeof signer.nip04Encrypt === "function") { - permissionMethods.push("nip04.encrypt"); - } - if (typeof signer.nip44Encrypt === "function") { - permissionMethods.push("nip44.encrypt", "nip44.v2.encrypt"); - } - const hasCachedPermissions = - hasDefaultExtensionPermissions(permissionMethods); - if (!hasCachedPermissions) { - const permissionResult = - await requestDefaultExtensionPermissions(permissionMethods); - if (!permissionResult.ok) { - userLogger.warn( - "[SubscriptionsManager] Signer permissions denied while updating subscriptions.", - permissionResult.error, - ); - const error = new Error( - "The active signer must allow encryption and signing before updating subscriptions.", - ); - error.code = "extension-permission-denied"; - error.cause = permissionResult.error; - throw error; - } + const permissionResult = await requestDefaultExtensionPermissions(); + if (!permissionResult.ok) { + userLogger.warn( + "[SubscriptionsManager] Signer permissions denied while updating subscriptions.", + permissionResult.error, + ); + const error = new Error( + "The active signer must allow encryption and signing before updating subscriptions.", + ); + error.code = "extension-permission-denied"; + error.cause = permissionResult.error; + throw error; } } diff --git a/js/ui/components/UploadModal.js b/js/ui/components/UploadModal.js index 4dcb0a4f..905d78bb 100644 --- a/js/ui/components/UploadModal.js +++ b/js/ui/components/UploadModal.js @@ -940,8 +940,7 @@ export class UploadModal { try { if (signer?.type === "extension") { - const permissionResult = - await requestDefaultExtensionPermissions(["nip04.decrypt"]); + const permissionResult = await requestDefaultExtensionPermissions(); if (!permissionResult?.ok) { alert("Extension permissions are required to unlock storage."); return; diff --git a/js/ui/profileModalController.js b/js/ui/profileModalController.js index 2960214d..46d0edc5 100644 --- a/js/ui/profileModalController.js +++ b/js/ui/profileModalController.js @@ -1270,8 +1270,6 @@ export class ProfileModalController { this.blockListEmpty = null; this.blockListStatus = null; this.blockListLoadingState = "idle"; - this.subscriptionsStatus = null; - this.subscriptionsLoadingState = "idle"; this.blockInput = null; this.addBlockedButton = null; this.profileBlockedList = null; @@ -1417,9 +1415,6 @@ export class ProfileModalController { this.subscriptionsService.on("change", () => { void this.populateSubscriptionsList(); }); - this.subscriptionsService.on("status", (detail) => { - this.handleSubscriptionsStatus(detail); - }); } if ( @@ -1429,9 +1424,6 @@ export class ProfileModalController { this.services.userBlocks.on("change", () => { this.populateBlockedList(); }); - this.services.userBlocks.on("status", (detail) => { - this.handleBlockListStatus(detail); - }); } } @@ -1592,10 +1584,6 @@ export class ProfileModalController { this.blockList = document.getElementById("blockedList") || null; this.blockListEmpty = document.getElementById("blockedEmpty") || null; - this.blockListEmptyDefaultText = - this.blockListEmpty instanceof HTMLElement - ? this.blockListEmpty.textContent.trim() - : ""; this.blockInput = document.getElementById("blockedInput") || null; this.addBlockedButton = document.getElementById("addBlockedBtn") || null; @@ -1965,9 +1953,6 @@ export class ProfileModalController { subscribe("directMessages:updated", (detail) => { this.handleDirectMessagesUpdated(detail); }); - subscribe("directMessages:status", (detail) => { - this.handleDirectMessagesStatus(detail); - }); subscribe("directMessages:cleared", () => { this.handleDirectMessagesCleared(); }); @@ -5033,38 +5018,6 @@ export class ProfileModalController { }); } - handleDirectMessagesStatus(detail = {}) { - const status = typeof detail?.status === "string" ? detail.status : ""; - if (status === "decrypting") { - this.setMessagesLoadingState("loading", { - message: - typeof detail?.message === "string" && detail.message.trim() - ? detail.message.trim() - : "Decrypting direct messages…", - }); - return; - } - - if (status === "error") { - this.setMessagesLoadingState("error", { - message: - "Unable to decrypt direct messages. Check your extension permissions.", - }); - return; - } - - if (status === "ready" && this.messagesLoadingState === "loading") { - const actor = this.resolveActiveDmActor(); - if (!actor) { - this.setMessagesLoadingState("unauthenticated"); - } else if (!this.directMessagesCache.length) { - this.setMessagesLoadingState("empty"); - } else { - this.setMessagesLoadingState("ready"); - } - } - } - handleDirectMessagesCleared() { if (this.activeMessagesRequest) { return; @@ -7440,144 +7393,6 @@ export class ProfileModalController { } } - handleBlockListStatus(detail = {}) { - const status = typeof detail?.status === "string" ? detail.status : ""; - const message = - typeof detail?.message === "string" && detail.message.trim() - ? detail.message.trim() - : ""; - - if (status === "loading") { - this.setBlockListLoadingState("loading", { message }); - return; - } - - if (status === "error") { - this.setBlockListLoadingState("error", { message }); - return; - } - - if (status) { - this.setBlockListLoadingState("idle"); - } - } - - ensureSubscriptionsStatusElement() { - if (this.subscriptionsStatus instanceof HTMLElement) { - return this.subscriptionsStatus; - } - - const anchor = - this.subscriptionList instanceof HTMLElement - ? this.subscriptionList - : this.subscriptionListEmpty instanceof HTMLElement - ? this.subscriptionListEmpty - : null; - - if (!anchor || !(anchor.parentElement instanceof HTMLElement)) { - return null; - } - - const existing = anchor.parentElement.querySelector( - '[data-role="subscriptions-status"]', - ); - if (existing instanceof HTMLElement) { - this.subscriptionsStatus = existing; - return existing; - } - - const status = document.createElement("div"); - status.dataset.role = "subscriptions-status"; - status.className = "mt-4 flex items-center gap-3 text-sm text-muted hidden"; - status.setAttribute("role", "status"); - status.setAttribute("aria-live", "polite"); - - if (this.subscriptionList instanceof HTMLElement) { - anchor.parentElement.insertBefore(status, this.subscriptionList); - } else { - anchor.parentElement.appendChild(status); - } - - this.subscriptionsStatus = status; - return status; - } - - setSubscriptionsLoadingState(state = "idle", options = {}) { - const statusEl = this.ensureSubscriptionsStatusElement(); - if (!statusEl) { - this.subscriptionsLoadingState = state; - return; - } - - const message = - typeof options.message === "string" && options.message.trim() - ? options.message.trim() - : ""; - - statusEl.innerHTML = ""; - statusEl.classList.remove("text-status-warning"); - statusEl.classList.add("text-muted"); - statusEl.classList.add("hidden"); - - this.subscriptionsLoadingState = state; - - if (state === "loading") { - if (this.subscriptionListEmpty instanceof HTMLElement) { - this.subscriptionListEmpty.classList.add("hidden"); - } - - const spinner = document.createElement("span"); - spinner.className = "status-spinner status-spinner--inline"; - spinner.setAttribute("aria-hidden", "true"); - - const text = document.createElement("span"); - text.textContent = message || "Loading subscriptions…"; - - statusEl.appendChild(spinner); - statusEl.appendChild(text); - statusEl.classList.remove("hidden"); - return; - } - - if (state === "error") { - statusEl.classList.remove("text-muted"); - statusEl.classList.add("text-status-warning"); - - if (this.subscriptionListEmpty instanceof HTMLElement) { - this.subscriptionListEmpty.classList.add("hidden"); - } - - const text = document.createElement("span"); - text.textContent = - message || "Subscriptions may be out of date. Try again later."; - - statusEl.appendChild(text); - statusEl.classList.remove("hidden"); - } - } - - handleSubscriptionsStatus(detail = {}) { - const status = typeof detail?.status === "string" ? detail.status : ""; - const message = - typeof detail?.message === "string" && detail.message.trim() - ? detail.message.trim() - : ""; - - if (status === "loading") { - this.setSubscriptionsLoadingState("loading", { message }); - return; - } - - if (status === "error") { - this.setSubscriptionsLoadingState("error", { message }); - return; - } - - if (status) { - this.setSubscriptionsLoadingState("idle"); - } - } - normalizeHashtagTag(value) { return normalizeHashtag(value); } @@ -8039,10 +7854,10 @@ export class ProfileModalController { return; } - const hasExplicitList = Array.isArray(blocked); - const sourceEntries = hasExplicitList - ? blocked - : this.services.userBlocks.getBlockedPubkeys(); + const sourceEntries = + Array.isArray(blocked) && blocked.length + ? blocked + : this.services.userBlocks.getBlockedPubkeys(); const normalizedEntries = []; const pushEntry = (hex, label) => { @@ -8115,13 +7930,7 @@ export class ProfileModalController { this.blockList.innerHTML = ""; - const blocksLoaded = this.services.userBlocks?.loaded === true; if (!deduped.length) { - if (this.blockListLoadingState === "loading" && !blocksLoaded) { - this.blockListEmpty.classList.add("hidden"); - this.blockList.classList.add("hidden"); - return; - } this.blockListEmpty.classList.remove("hidden"); this.blockList.classList.add("hidden"); if (this.blockListLoadingState === "loading") { @@ -8329,18 +8138,9 @@ export class ProfileModalController { this.subscriptionList.innerHTML = ""; - const subscriptionsLoaded = service.loaded === true; if (!deduped.length) { - if (this.subscriptionsLoadingState === "loading" && !subscriptionsLoaded) { - this.subscriptionListEmpty.classList.add("hidden"); - this.subscriptionList.classList.add("hidden"); - return; - } this.subscriptionListEmpty.classList.remove("hidden"); this.subscriptionList.classList.add("hidden"); - if (this.subscriptionsLoadingState === "loading") { - this.setSubscriptionsLoadingState("idle"); - } return; } @@ -8424,10 +8224,6 @@ export class ProfileModalController { this.subscriptionList.appendChild(item); }); - if (this.subscriptionsLoadingState === "loading") { - this.setSubscriptionsLoadingState("idle"); - } - if ( entriesNeedingFetch.size && typeof this.services.batchFetchProfiles === "function" @@ -8455,10 +8251,6 @@ export class ProfileModalController { if (this.subscriptionListEmpty instanceof HTMLElement) { this.subscriptionListEmpty.classList.remove("hidden"); } - - if (this.subscriptionsLoadingState === "loading") { - this.setSubscriptionsLoadingState("idle"); - } } async handleUnsubscribeFromCreator(candidate) { @@ -12714,54 +12506,40 @@ export class ProfileModalController { if (hasBlockHydrator) { const activeHex = this.normalizeHexPubkey(this.getActivePubkey()); - if (!activeHex) { - this.setBlockListLoadingState("idle"); - if (this.blockListEmpty instanceof HTMLElement) { - this.blockListEmpty.textContent = - "Connect a Nostr signer to load your mute list."; - } - this.populateBlockedList([]); - } else { - if (this.blockListEmpty instanceof HTMLElement) { - this.blockListEmpty.textContent = - this.blockListEmptyDefaultText || - "You haven’t blocked any creators yet."; - } - backgroundTasks.push( - Promise.resolve() - .then(() => this.services.userBlocks.ensureLoaded(activeHex)) - .then(() => { - try { - this.populateBlockedList(); - } catch (error) { - userLogger.warn( - "Failed to render blocked creators after hydration:", - error, - ); - this.setBlockListLoadingState("error", { - message: "Blocked creators may be out of date. Try again later.", - }); - } - }) - .catch((error) => { + backgroundTasks.push( + Promise.resolve() + .then(() => this.services.userBlocks.ensureLoaded(activeHex)) + .then(() => { + try { + this.populateBlockedList(); + } catch (error) { userLogger.warn( - "Failed to refresh user block list while opening profile modal:", + "Failed to render blocked creators after hydration:", error, ); this.setBlockListLoadingState("error", { message: "Blocked creators may be out of date. Try again later.", }); - try { - this.populateBlockedList(); - } catch (populateError) { - userLogger.warn( - "Failed to render blocked creators after hydration failure:", - populateError, - ); - } - }), - ); - } + } + }) + .catch((error) => { + userLogger.warn( + "Failed to refresh user block list while opening profile modal:", + error, + ); + this.setBlockListLoadingState("error", { + message: "Blocked creators may be out of date. Try again later.", + }); + try { + this.populateBlockedList(); + } catch (populateError) { + userLogger.warn( + "Failed to render blocked creators after hydration failure:", + populateError, + ); + } + }), + ); } if (backgroundTasks.length) { @@ -12969,8 +12747,6 @@ export class ProfileModalController { // Trigger aggressive parallel fetches if (activePubkey) { - this.setBlockListLoadingState("loading"); - this.setSubscriptionsLoadingState("loading"); if (this.services.userBlocks) { this.services.userBlocks.loadBlocks(activePubkey).catch(noop); } @@ -13036,8 +12812,6 @@ export class ProfileModalController { this.profileSwitcherSelectionPubkey = null; this.renderSavedProfiles(); - this.setBlockListLoadingState("idle"); - this.setSubscriptionsLoadingState("idle"); try { await this.refreshAdminPaneState(); diff --git a/js/ui/urlHealthController.js b/js/ui/urlHealthController.js index eea594c0..4591f095 100644 --- a/js/ui/urlHealthController.js +++ b/js/ui/urlHealthController.js @@ -323,10 +323,6 @@ export default class UrlHealthController { return { outcome: "invalid" }; } - if (trimmed.toLowerCase().endsWith(".torrent")) { - return { outcome: "invalid" }; - } - const confirmPlayable = options?.confirmPlayable === true; const defaultTimeout = this.constants.URL_PROBE_TIMEOUT_MS || 5000; const urlHealthConstants = this.constants.urlHealthConstants || {}; diff --git a/js/userBlocks.js b/js/userBlocks.js index 5877db73..1687fc2f 100644 --- a/js/userBlocks.js +++ b/js/userBlocks.js @@ -73,7 +73,7 @@ export const USER_BLOCK_EVENTS = Object.freeze({ const FAST_BLOCKLIST_RELAY_LIMIT = 3; const FAST_BLOCKLIST_TIMEOUT_MS = 2500; -const FULL_BLOCKLIST_TIMEOUT_MS = 12000; +const BACKGROUND_BLOCKLIST_TIMEOUT_MS = 6000; const DECRYPT_TIMEOUT_MS = 90000; const DECRYPT_RETRY_DELAY_MS = 10000; @@ -718,12 +718,12 @@ class UserBlockListManager { } async ensureLoaded(userPubkey) { - const normalized = normalizeHex(userPubkey); - if (!normalized) { + if (this.loaded) { return; } - if (this.loaded && this.activePubkey === normalized) { + const normalized = normalizeHex(userPubkey); + if (!normalized) { return; } @@ -751,39 +751,28 @@ class UserBlockListManager { return; } - if (!options.isBackground) { - try { - statusCallback?.(detail); - } catch (callbackError) { - userLogger.warn( - "[UserBlockList] statusCallback threw while emitting status", - callbackError, - ); - } + try { + statusCallback?.(detail); + } catch (callbackError) { + userLogger.warn( + "[UserBlockList] statusCallback threw while emitting status", + callbackError, + ); + } - try { - this.emitter.emit(USER_BLOCK_EVENTS.STATUS, detail); - } catch (emitterError) { - userLogger.warn( - "[UserBlockList] Failed to dispatch status event", - emitterError, - ); - } + try { + this.emitter.emit(USER_BLOCK_EVENTS.STATUS, detail); + } catch (emitterError) { + userLogger.warn( + "[UserBlockList] Failed to dispatch status event", + emitterError, + ); } }; const readRelays = relayManager.getReadRelayUrls(); const relays = readRelays.length > 0 ? readRelays : Array.from(DEFAULT_RELAY_URLS); - const fastRelays = relays.slice(0, FAST_BLOCKLIST_RELAY_LIMIT); - const shouldUseFastRelays = - !options.skipFastRelays && - fastRelays.length > 0 && - fastRelays.length < relays.length; - const initialRelays = shouldUseFastRelays ? fastRelays : relays; - const initialTimeoutMs = shouldUseFastRelays - ? FAST_BLOCKLIST_TIMEOUT_MS - : FULL_BLOCKLIST_TIMEOUT_MS; - emitStatus({ status: "loading", relays: initialRelays }); + emitStatus({ status: "loading", relays }); const localBlocks = this._loadLocal(normalized); const hasLocalData = !!localBlocks; @@ -836,20 +825,7 @@ class UserBlockListManager { let permissionError = null; if ((!signerHasNip44 && requiresNip44) || (!signerHasNip04 && requiresNip04)) { try { - const permissionMethods = []; - if (requiresNip04) { - permissionMethods.push("nip04.decrypt"); - } - if (requiresNip44) { - permissionMethods.push("nip44.decrypt", "nip44.v2.decrypt"); - } - emitStatus({ - status: "loading", - message: "Decrypting blocked creators…", - reason: "permissions", - }); - const permissionResult = - await requestDefaultExtensionPermissions(permissionMethods); + const permissionResult = await requestDefaultExtensionPermissions(); if (!permissionResult?.ok) { permissionError = permissionResult?.error instanceof Error @@ -860,8 +836,6 @@ class UserBlockListManager { } } catch (error) { permissionError = error instanceof Error ? error : new Error(String(error)); - } finally { - emitStatus({ status: "settled", reason: "permissions" }); } } @@ -1217,17 +1191,17 @@ class UserBlockListManager { nostrClient.fetchListIncrementally({ kind: 10000, pubkey: normalized, - relayUrls: initialRelays, + relayUrls: relays, since: fetchSince, - timeoutMs: initialTimeoutMs, + timeoutMs: 12000, }), nostrClient.fetchListIncrementally({ kind: 10000, pubkey: normalized, dTag: BLOCK_LIST_IDENTIFIER, - relayUrls: initialRelays, + relayUrls: relays, since: fetchSince, - timeoutMs: initialTimeoutMs, + timeoutMs: 12000, }), ]); @@ -1235,21 +1209,6 @@ class UserBlockListManager { // If combined is empty, check if we have loaded state if (!combined.length) { - if (shouldUseFastRelays && !options.isBackground) { - devLogger.log( - "[UserBlockList] Fast relay fetch returned no events; continuing in background.", - ); - this.loadBlocks(normalized, { - ...options, - skipFastRelays: true, - isBackground: true, - }).catch((error) => { - devLogger.warn( - "[UserBlockList] Background relay refresh failed:", - error, - ); - }); - } if (!this.loaded) { // Assuming empty if we have no prior state await applyEvents([], { source: "incremental" }); @@ -1830,15 +1789,7 @@ class UserBlockListManager { } if (signer.type === "extension") { - const permissionMethods = ["sign_event"]; - if (typeof signer.nip04Encrypt === "function") { - permissionMethods.push("nip04.encrypt"); - } - if (typeof signer.nip44Encrypt === "function") { - permissionMethods.push("nip44.encrypt", "nip44.v2.encrypt"); - } - const permissionResult = - await requestDefaultExtensionPermissions(permissionMethods); + const permissionResult = await requestDefaultExtensionPermissions(); if (!permissionResult.ok) { userLogger.warn( "[UserBlockList] Signer permissions denied while updating the block list.", diff --git a/scripts/agent/debug_frontend.py b/scripts/agent/debug_frontend.py index d54a056a..8142f20b 100644 --- a/scripts/agent/debug_frontend.py +++ b/scripts/agent/debug_frontend.py @@ -1,31 +1,33 @@ -from playwright.sync_api import sync_playwright import sys import time +from playwright.sync_api import sync_playwright def run(): with sync_playwright() as p: + # Launch browser (headless by default) browser = p.chromium.launch() page = browser.new_page() - print("--- Debugging Frontend ---") - - # Listen for console messages + # Capture console messages page.on("console", lambda msg: print(f"console:{msg.type}: {msg.text}")) - # Listen for uncaught exceptions + # Capture uncaught exceptions page.on("pageerror", lambda exc: print(f"pageerror: {exc}")) - # Listen for failed requests - page.on("requestfailed", lambda req: print(f"requestfailed: {req.url} {req.failure}")) + # Capture failed requests + page.on("requestfailed", lambda request: print(f"requestfailed: {request.url} {request.failure}")) try: + print("Navigating to http://localhost:8000...") page.goto("http://localhost:8000") - # Wait for network idle or a specific time to allow scripts to execute - page.wait_for_timeout(5000) - except Exception as e: - print(f"Navigation failed: {e}") - browser.close() + # Wait for some time to catch initialization errors + time.sleep(5) + + except Exception as e: + print(f"Script error: {e}") + finally: + browser.close() if __name__ == "__main__": run() diff --git a/scripts/agent/load-test-relay.mjs b/scripts/agent/load-test-relay.mjs index 27a8ca9f..bff55206 100644 --- a/scripts/agent/load-test-relay.mjs +++ b/scripts/agent/load-test-relay.mjs @@ -68,10 +68,6 @@ export function startRelay(port = 8889) { if (typeof subId === "string") { subs.get(ws).delete(subId); } - } else if (type === "COUNT") { - const subId = payload[0]; - // Dummy count support - ws.send(JSON.stringify(["COUNT", subId, { count: 0 }])); } } catch (err) { // console.error("Relay error processing message:", err); diff --git a/scripts/agent/load-test.mjs b/scripts/agent/load-test.mjs index 20087fd1..87fa9450 100644 --- a/scripts/agent/load-test.mjs +++ b/scripts/agent/load-test.mjs @@ -466,13 +466,6 @@ function generateReport() { report.remediation.push('Mark as requires-security-review. Check if running in a constrained environment or if crypto implementation is suboptimal.'); } - if (report.bottlenecks.length === 0) { - report.bottlenecks.push('None detected'); - } - if (report.remediation.length === 0) { - report.remediation.push('None required'); - } - // Report File const dateStr = new Date().toISOString().slice(0, 10).replace(/-/g, ''); // YYYYMMDD const filename = `load-report-${dateStr}.json`; diff --git a/scripts/agent/smoke-test.mjs b/scripts/agent/smoke-test.mjs index af905fb2..dcdf5788 100644 --- a/scripts/agent/smoke-test.mjs +++ b/scripts/agent/smoke-test.mjs @@ -1,400 +1,286 @@ -import { chromium } from 'playwright'; +import './setup-test-env.js'; +import { indexedDB, IDBKeyRange } from "fake-indexeddb"; import { WebSocket } from 'ws'; import * as NostrTools from 'nostr-tools'; -import { startRelay } from './load-test-relay.mjs'; -import { decryptDM } from '../../js/dmDecryptor.js'; import fs from 'node:fs'; import path from 'node:path'; import { spawn } from 'node:child_process'; import { fileURLToPath } from 'url'; +// --- Polyfills --- +if (!global.indexedDB) { + global.indexedDB = indexedDB; +} +if (!global.IDBKeyRange) { + global.IDBKeyRange = IDBKeyRange; +} +if (!global.NostrTools) { + global.NostrTools = NostrTools; +} +// setup-test-env.js handles WebSocket, crypto, localStorage, window, self, navigator + +// --- Imports from Codebase --- +// Note: We need to use relative paths from scripts/agent/ +import { NostrClient } from '../../js/nostr/client.js'; +import { decryptDM } from '../../js/dmDecryptor.js'; +import { startRelay } from './load-test-relay.mjs'; + // --- Configuration --- const ARTIFACTS_DIR = 'artifacts'; -const SCREENSHOTS_DIR = path.join(ARTIFACTS_DIR, 'screenshots'); -const RELAY_PORT = 8889; +if (!fs.existsSync(ARTIFACTS_DIR)) { + fs.mkdirSync(ARTIFACTS_DIR, { recursive: true }); +} + +const RELAY_PORT = 8890; const RELAY_URL = `ws://localhost:${RELAY_PORT}`; const HTTP_PORT = 8000; const HTTP_URL = `http://localhost:${HTTP_PORT}`; // --- Logging --- -const dateStr = new Date().toISOString().slice(0, 10).replace(/-/g, ''); +const dateStr = new Date().toISOString().slice(0, 10).replace(/-/g, ''); // YYYYMMDD const logFile = path.join(ARTIFACTS_DIR, `smoke-${dateStr}.log`); - -if (!fs.existsSync(ARTIFACTS_DIR)) fs.mkdirSync(ARTIFACTS_DIR, { recursive: true }); -if (!fs.existsSync(SCREENSHOTS_DIR)) fs.mkdirSync(SCREENSHOTS_DIR, { recursive: true }); - const logStream = fs.createWriteStream(logFile, { flags: 'a' }); function log(msg, ...args) { - const timestamp = new Date().toISOString(); - const message = `[${timestamp}] ${msg} ${args.map(a => typeof a === 'object' ? JSON.stringify(a) : a).join(' ')}`; - console.log(message); - logStream.write(message + '\n'); + const timestamp = new Date().toISOString(); + const message = `[${timestamp}] ${msg} ${args.map(a => typeof a === 'object' ? JSON.stringify(a) : a).join(' ')}`; + console.log(message); + logStream.write(message + '\n'); } function error(msg, ...args) { - const timestamp = new Date().toISOString(); - const message = `[${timestamp}] [ERROR] ${msg} ${args.map(a => (a instanceof Error ? a.stack : (typeof a === 'object' ? JSON.stringify(a) : a))).join(' ')}`; - console.error(message); - logStream.write(message + '\n'); + const timestamp = new Date().toISOString(); + const message = `[${timestamp}] [ERROR] ${msg} ${args.map(a => (a instanceof Error ? a.stack : (typeof a === 'object' ? JSON.stringify(a) : a))).join(' ')}`; + console.error(message); + logStream.write(message + '\n'); } // --- Helpers --- -async function waitForServer(url, timeoutMs = 15000) { - const start = Date.now(); - while (Date.now() - start < timeoutMs) { - try { - const res = await fetch(url); - if (res.ok) return true; - } catch (e) { - // ignore - } - await new Promise(r => setTimeout(r, 500)); +async function waitForServer(url, timeoutMs = 10000) { + const start = Date.now(); + while (Date.now() - start < timeoutMs) { + try { + const res = await fetch(url); + if (res.ok) return true; + } catch (e) { + // ignore } - return false; + await new Promise(r => setTimeout(r, 200)); + } + return false; } -// --- Main Script --- +// --- Main Test Flow --- async function run() { - let relayServer = null; - let httpServer = null; - let browser = null; - let success = true; + let relayServer = null; + let httpServer = null; + let client = null; + let success = true; - try { - log('--- Starting Smoke Test ---'); + try { + log('--- Starting Smoke Test ---'); - // 1. Start Relay - log(`Starting local relay on port ${RELAY_PORT}...`); - relayServer = startRelay(RELAY_PORT); - await new Promise(r => setTimeout(r, 500)); - log('Relay started.'); + // 1. Start Relay + log(`Starting local relay on port ${RELAY_PORT}...`); + relayServer = startRelay(RELAY_PORT); + // Wait a bit for relay to be ready (startRelay is synchronous in setup but async in effect?) + // It uses WebSocketServer which starts immediately. + await new Promise(r => setTimeout(r, 500)); + log('Relay started.'); - // 2. Start HTTP Server - log(`Starting HTTP server on port ${HTTP_PORT}...`); - httpServer = spawn('npx', ['serve', '-p', String(HTTP_PORT)], { - stdio: 'ignore', - detached: false, - shell: true - }); + // 2. Start HTTP Server + log(`Starting HTTP server (npx serve) on port ${HTTP_PORT}...`); + httpServer = spawn('npx', ['serve', '-p', String(HTTP_PORT)], { + stdio: 'ignore', // or 'inherit' to see output + detached: false + }); - const serverReady = await waitForServer(HTTP_URL); - if (!serverReady) throw new Error(`HTTP server failed to start at ${HTTP_URL}`); - log('HTTP server ready.'); - - // 3. Launch Browser - log('Launching browser...'); - browser = await chromium.launch({ headless: true }); - const context = await browser.newContext(); - const page = await context.newPage(); - - page.on('console', msg => { - const type = msg.type(); - const text = msg.text(); - if (type === 'error' || type === 'warning') { - log(`[BROWSER ${type.toUpperCase()}] ${text}`); - } - }); - page.on('pageerror', err => { - log(`[BROWSER UNCAUGHT] ${err.message}`); - }); - - // 4. Mock Extension Setup - const ephemeralSk = NostrTools.generateSecretKey(); - const ephemeralSkHex = Buffer.from(ephemeralSk).toString('hex'); // nostr-tools v2 uses Uint8Array, Buffer covers it - const ephemeralPk = NostrTools.getPublicKey(ephemeralSk); - log(`Ephemeral Pubkey: ${ephemeralPk}`); - - // Pre-seed Relay with User's Relay List (Kind 10002) - // This ensures the app finds the local relay when it syncs preferences, avoiding fallback to public defaults. - { - log('Seeding relay list event...'); - const seedWs = new WebSocket(RELAY_URL); - await new Promise((resolve) => seedWs.on('open', resolve)); - - const relayListEvent = { - kind: 10002, - created_at: Math.floor(Date.now() / 1000), - tags: [['r', RELAY_URL]], - content: '', - pubkey: ephemeralPk, - id: null, - sig: null - }; - const signedSeed = NostrTools.finalizeEvent(relayListEvent, ephemeralSk); - - const seedPromise = new Promise((resolve, reject) => { - const timeout = setTimeout(() => reject(new Error('Seed timeout')), 2000); - seedWs.on('message', (data) => { - const msg = JSON.parse(data.toString()); - if (msg[0] === 'OK' && msg[1] === signedSeed.id && msg[2]) { - clearTimeout(timeout); - resolve(); - } - }); - seedWs.send(JSON.stringify(['EVENT', signedSeed])); - }); - - await seedPromise; - seedWs.close(); - log('Relay list seeded successfully.'); - } - - // Disable whitelist mode & Inject Relay Preferences - await page.addInitScript(({ pubkey, relayUrl }) => { - window.localStorage.setItem('bitvid_admin_whitelist_mode', 'false'); - const key = `bitvid:profile:${pubkey}:relayList:v1`; - const data = [{ url: relayUrl, mode: 'both' }]; - window.localStorage.setItem(key, JSON.stringify(data)); - }, { pubkey: ephemeralPk, relayUrl: RELAY_URL }); - - // Expose Node-side signing to Browser - await page.exposeFunction('mockGetPublicKey', async () => ephemeralPk); - await page.exposeFunction('mockSignEvent', async (event) => { - const signed = NostrTools.finalizeEvent(event, ephemeralSk); - return signed; - }); - await page.exposeFunction('mockNip04Encrypt', async (pubkey, plaintext) => { - return await NostrTools.nip04.encrypt(ephemeralSk, pubkey, plaintext); - }); - await page.exposeFunction('mockNip04Decrypt', async (pubkey, ciphertext) => { - return await NostrTools.nip04.decrypt(ephemeralSk, pubkey, ciphertext); - }); - // Add NIP-44 if needed, mostly NIP-07 uses nip04 for legacy support or nip44 - - await page.addInitScript(() => { - window.nostr = { - getPublicKey: () => window.mockGetPublicKey(), - signEvent: (event) => window.mockSignEvent(event), - nip04: { - encrypt: (pubkey, plaintext) => window.mockNip04Encrypt(pubkey, plaintext), - decrypt: (pubkey, ciphertext) => window.mockNip04Decrypt(pubkey, ciphertext) - } - }; - }); - - // 5. Navigate and Login - log('Navigating to app...'); - await page.goto(HTTP_URL); - - // Wait for app to initialize (fade-in or login button) - await page.waitForSelector('#loginButton'); - log('App loaded.'); - - // Override relays in the app to point to local relay? - // Ideally we should via UI, but simpler to just let it connect to defaults AND our local relay. - // Or we can inject settings. - // Let's assume the app will connect to default relays. We want it to use OUR relay for the test. - // We can execute: window.app.nostrClient.addRelay(...) if we could access it. - // Or we can just let it fail to connect to public relays and hope it doesn't block UI. - // BUT, for the publish verification to work, the app needs to publish to a relay we can read. - // So we MUST ensure the app connects to localhost:8889. - // We can try to inject it into localStorage before load? - // 'nostr-relays' key? - // Let's check `relayManager.js` or `localStorage` keys. - // Usually `nostr:relays` or similar. - // Assuming the app has a default list. - // We can try to add the relay via console after load. - // "window.nostrClient" might be available if exposed? - // Earlier I saw `js/index.js` doesn't expose it. - // BUT `js/nostrClientFacade.js` exports `nostrClient`. - // Maybe I can rely on the app logic: if I publish, it goes to "write relays". - // I'll try to use the "Manage Storage" or similar? No. - // Let's try to infer if I can just assume it works or if I need to force it. - // Actually, for smoke tests, we often want to test against a controlled relay. - // I will try to inject a relay into the default set if possible. - // `js/relayManager.js` might read from storage. - // Let's try to set `nostr:relays` in localStorage before load. - // Or I can just continue and see if it works (maybe defaults include localhost in dev mode?). - // If not, I'll use `page.evaluate` to find a way. - - // Login - await page.click('#loginButton'); - await page.waitForSelector('[data-provider-id="nip07"]', { timeout: 10000 }); - log('Login modal open.'); - - await page.click('[data-provider-id="nip07"]'); - log('Clicked NIP-07 Login.'); - - // Wait for login success (Upload button appears) - await page.waitForSelector('#uploadButton', { state: 'visible', timeout: 10000 }); - log('Logged in successfully.'); - - // 6. Publish Video - log('Publishing video...'); - await page.click('#uploadButton'); - await page.waitForSelector('#uploadModal', { state: 'visible' }); - - await page.click('#btn-mode-external'); - - const testTitle = `Smoke Test Video ${Date.now()}`; - await page.fill('#input-title', testTitle); - await page.fill('#input-description', 'This is a smoke test video.'); - await page.fill('#input-url', 'https://example.com/test.mp4'); // Fake HTTPS URL - - await page.click('#btn-submit'); - log('Submit clicked.'); - - // Wait for modal to close OR continue if it takes too long (public relay timeouts) - try { - await page.waitForSelector('#uploadModal', { state: 'hidden', timeout: 5000 }); - log('Upload modal closed.'); - } catch (e) { - log('Upload modal did not close quickly (likely waiting for public relays), proceeding to verify content...'); - } - - // Verify Video in Feed - // It might take a moment to appear. - // The app connects to relays. If it didn't connect to OUR relay, we might not see it if we are checking via Node. - // But the Browser is checking via the App's UI. - // The App needs to be connected to a relay where it published. - // If the App defaults to damus.io etc, and fails to connect (no internet in sandbox?), it might fail. - // Sandbox has internet. - // But I want it to use local relay. - // I'll try to add the local relay via UI? Or just hope. - // Wait, if I cannot force the relay, the test is flaky. - // I'll add a step to force the relay connection via `page.evaluate` leveraging the module system if possible? - // Or just `localStorage` key `relays`? - // Let's try to set `localStorage.setItem('relays', JSON.stringify([{url:'ws://localhost:8889', read:true, write:true}]))` before load. - - // Reloading with relay config - // Actually, let's do this before the first navigation or reload. - // I'll just clear local storage and set the relay. - /* - await page.goto(HTTP_URL); - await page.evaluate((url) => { - // Guessing the storage key. - // If I don't know it, I might be stuck. - // But usually apps respect NIP-07 `getRelays`? - // If I implement `window.nostr.getRelays`, the app might use them! - }, RELAY_URL); - */ - - // Implementation of getRelays in mock - await page.addInitScript((relayUrl) => { - const existing = window.nostr || {}; - window.nostr = { - ...existing, - getRelays: async () => { - return { [relayUrl]: { read: true, write: true } }; - } - }; - }, RELAY_URL); - // App likely calls `getRelays` on login. - - log('Verifying video in UI...'); - // We might need to refresh or go to profile/feed. - // Let's wait for the title to appear in the body. - await page.waitForSelector(`text="${testTitle}"`, { timeout: 20000 }); - log('Video found in UI.'); - - // 7. DM Test (Node Side) - log('Starting DM Test...'); - - // Connect Node Client to Relay - const ws = new WebSocket(RELAY_URL); - await new Promise((resolve) => ws.on('open', resolve)); - log('Node client connected to relay.'); - - const senderSk = NostrTools.generateSecretKey(); - const senderPk = NostrTools.getPublicKey(senderSk); - const dmMessage = `Smoke DM ${Date.now()}`; - - // Send DM (Kind 4 for simplicity, or 1059) - // Using Kind 4 (Legacy) as it's easier to verify without complex wrapping if not strictly required. - // But `dmDecryptor` supports both. - // Let's send Kind 4. - const ciphertext = await NostrTools.nip04.encrypt(senderSk, ephemeralPk, dmMessage); - const dmEvent = { - kind: 4, - created_at: Math.floor(Date.now() / 1000), - tags: [['p', ephemeralPk]], - content: ciphertext, - pubkey: senderPk - }; - const signedDm = NostrTools.finalizeEvent(dmEvent, senderSk); - - log('Sending DM...'); - ws.send(JSON.stringify(['EVENT', signedDm])); - - // Wait for it to be echoed (confirmation it was accepted) - // And wait for the "Recipient" (which we simulate using the code) to potentially see it. - // Actually, we want to verify we can DECRYPT it using the ephemeral keys we generated for the BROWSER user. - // So we act as the Browser User here in Node logic. - - // We need to fetch the event back from the relay to ensure it was stored. - // Or we can just use `signedDm` directly to test `decryptDM`. - // But better to fetch it to prove relay roundtrip. - - const fetchPromise = new Promise((resolve, reject) => { - const subId = 'sub-dm'; - ws.on('message', (data) => { - const msg = JSON.parse(data.toString()); - if (msg[0] === 'EVENT' && msg[2].id === signedDm.id) { - resolve(msg[2]); - } - }); - ws.send(JSON.stringify(['REQ', subId, { ids: [signedDm.id] }])); - }); - - const fetchedDm = await Promise.race([ - fetchPromise, - new Promise((_, r) => setTimeout(() => r(new Error('DM fetch timeout')), 5000)) - ]); - - log('DM fetched from relay.'); - - // Verify Decryption - log('Decrypting DM...'); - const decryptors = [ - { - scheme: 'nip04', - decrypt: async (remotePubkey, text) => { - return await NostrTools.nip04.decrypt(ephemeralSk, remotePubkey, text); // Use Browser User's SK - } - } - ]; - - const dmContext = { - actorPubkey: ephemeralPk, - decryptors - }; - - const result = await decryptDM(fetchedDm, dmContext); - - if (!result.ok) throw new Error(`Decryption failed: ${JSON.stringify(result.errors)}`); - if (result.plaintext !== dmMessage) throw new Error(`Plaintext mismatch. Expected ${dmMessage}, got ${result.plaintext}`); - - log('DM Decrypted successfully.'); - ws.close(); - - } catch (err) { - success = false; - error('Smoke Test Failed:', err); - if (browser) { - const page = browser.contexts()[0]?.pages()[0]; - if (page) { - const screenshotPath = path.join(SCREENSHOTS_DIR, `failure-${Date.now()}.png`); - await page.screenshot({ path: screenshotPath, fullPage: true }); - log(`Screenshot saved to ${screenshotPath}`); - } - } - } finally { - // Teardown - log('Teardown...'); - if (browser) await browser.close(); - if (httpServer) httpServer.kill(); // .kill() sends SIGTERM - if (relayServer) relayServer.close(); - - const summary = { - timestamp: new Date().toISOString(), - success, - logFile - }; - fs.writeFileSync(path.join(ARTIFACTS_DIR, `smoke-summary-${dateStr}.json`), JSON.stringify(summary, null, 2)); - log('Done.'); - process.exit(success ? 0 : 1); + const serverReady = await waitForServer(HTTP_URL); + if (!serverReady) { + throw new Error(`HTTP server did not start on ${HTTP_URL} within timeout.`); } + log('HTTP server is ready.'); + + // 3. Init NostrClient + log('Initializing NostrClient...'); + client = new NostrClient(); + client.relays = [RELAY_URL]; + client.readRelays = [RELAY_URL]; + client.writeRelays = [RELAY_URL]; + + // Override dev logger to avoid pollution if needed, or let it log + // We can rely on standard logging. + + await client.init(); + log(`NostrClient initialized and connected to ${RELAY_URL}`); + + // 4. Login (Ephemeral) + log('Generating ephemeral keys for Sender...'); + const senderSk = NostrTools.generateSecretKey(); + const senderSkHex = Buffer.from(senderSk).toString('hex'); + const senderPk = NostrTools.getPublicKey(senderSk); + + log(`Sender Pubkey: ${senderPk}`); + + await client.registerPrivateKeySigner({ privateKey: senderSkHex, persist: false }); + client.pubkey = senderPk; // Manually set logged-in user + + if (client.pubkey !== senderPk) { + throw new Error(`Client pubkey mismatch. Expected ${senderPk}, got ${client.pubkey}`); + } + log('Sender logged in.'); + + // 5. Publish Video + log('Publishing test video...'); + const videoPayload = { + title: `Smoke Test Video ${Date.now()}`, + description: 'Smoke test description', + magnet: `magnet:?xt=urn:btih:${Math.random().toString(16).slice(2)}`, + url: `${HTTP_URL}/test.mp4`, // Fake URL + thumbnail: `${HTTP_URL}/thumb.jpg`, + mode: 'live', + videoRootId: `smoke-${Date.now()}`, + isNsfw: false, + isForKids: true + }; + + const publishedEvent = await client.publishVideo(videoPayload, senderPk); + if (!publishedEvent || !publishedEvent.id) { + throw new Error('Failed to publish video event.'); + } + log(`Video published. Event ID: ${publishedEvent.id}`); + + // Verify Read Back + log('Verifying video can be fetched...'); + // We use subscribeVideos for main feed, or getEventById. + // Let's use getEventById for direct verification. + const fetched = await client.getEventById(publishedEvent.id); + if (!fetched) { + throw new Error('Failed to fetch published video.'); + } + if (fetched.title !== videoPayload.title) { + throw new Error(`Fetched video title mismatch. Expected "${videoPayload.title}", got "${fetched.title}"`); + } + log('Video fetched successfully.'); + + // 6. DM Flow + log('Testing DM Flow...'); + const recipientSk = NostrTools.generateSecretKey(); + const recipientSkHex = Buffer.from(recipientSk).toString('hex'); + const recipientPk = NostrTools.getPublicKey(recipientSk); + const recipientNpub = NostrTools.nip19.npubEncode(recipientPk); + + log(`Recipient Pubkey: ${recipientPk}`); + + const dmMessage = `Smoke Test Message ${Date.now()}`; + + // Send DM + const sendResult = await client.sendDirectMessage(recipientNpub, dmMessage); + if (!sendResult.ok) { + throw new Error(`Failed to send DM: ${sendResult.error}`); + } + log('DM sent successfully.'); + + // Verify Decryption (Simulate Recipient) + log('Verifying DM decryption (Recipient view)...'); + + // We need to find the DM event. + // Since we don't have a second client syncing, we can manually query the pool/relay. + const filter = { + kinds: [4, 1059], // 4 for legacy, 1059 for NIP-17 + '#p': [recipientPk], + limit: 1 + }; + + // wait a moment for propagation + await new Promise(r => setTimeout(r, 200)); + + const events = await client.pool.list([RELAY_URL], [filter]); + if (events.length === 0) { + throw new Error('Recipient could not find DM event on relay.'); + } + + const dmEvent = events[0]; + log(`Found DM event kind ${dmEvent.kind} id ${dmEvent.id}`); + + // Construct decryption context for decryptDM + // We need a decryptor for the recipient. + // Since we have the recipient's private key, we can use NostrTools.nip04 or nip44. + + const decryptors = []; + + // NIP-04 Decryptor + decryptors.push({ + scheme: 'nip04', + decrypt: async (remotePubkey, ciphertext) => { + return await NostrTools.nip04.decrypt(recipientSkHex, remotePubkey, ciphertext); + } + }); + + // NIP-44 Decryptor + decryptors.push({ + scheme: 'nip44', + supportsGiftWrap: true, + decrypt: (remotePubkey, ciphertext) => { + const conversationKey = NostrTools.nip44.v2.utils.getConversationKey(recipientSk, remotePubkey); + return NostrTools.nip44.v2.decrypt(ciphertext, conversationKey); + } + }); + + const context = { + actorPubkey: recipientPk, + decryptors + }; + + const decryptResult = await decryptDM(dmEvent, context); + + if (!decryptResult.ok) { + throw new Error(`DM decryption failed: ${JSON.stringify(decryptResult.errors)}`); + } + + if (decryptResult.plaintext !== dmMessage) { + throw new Error(`DM plaintext mismatch. Expected "${dmMessage}", got "${decryptResult.plaintext}"`); + } + + log('DM verified successfully.'); + + } catch (err) { + success = false; + error('Smoke Test Failed!', err); + } finally { + // Report + const summary = { + timestamp: new Date().toISOString(), + success, + logFile + }; + const summaryFile = path.join(ARTIFACTS_DIR, `smoke-summary-${dateStr}.json`); + fs.writeFileSync(summaryFile, JSON.stringify(summary, null, 2)); + log(`Summary saved to ${summaryFile}`); + + // Teardown + log('Teardown...'); + if (client) { + // client.logout(); // Cleans up + // Close pool connections? + // client.pool.close(client.relays); // SimplePool doesn't have explicit close usually? + // NostrTools SimplePool usually manages connections. + // We can just rely on process exit. + } + + if (relayServer) { + relayServer.close(); + log('Relay stopped.'); + } + + if (httpServer) { + httpServer.kill(); + log('HTTP server stopped.'); + } + + log('Done.'); + process.exit(success ? 0 : 1); + } } run(); diff --git a/tests/nostr-login-permissions.test.mjs b/tests/nostr-login-permissions.test.mjs index 431ca902..83cc46e2 100644 --- a/tests/nostr-login-permissions.test.mjs +++ b/tests/nostr-login-permissions.test.mjs @@ -312,13 +312,8 @@ test("NIP-07 login falls back when structured permissions fail", async () => { const result = await nip07Provider.login({ nostrClient }); const pubkey = result.pubkey; assert.equal(pubkey, HEX_PUBKEY); - assert.ok(env.enableCalls.length >= 3, "should retry with alternate payloads"); - const [plainCall, objectCall, stringCall] = env.enableCalls; - assert.equal( - plainCall, - undefined, - "plain enable() should be attempted first", - ); + assert.ok(env.enableCalls.length >= 2, "should retry with alternate payloads"); + const [objectCall, stringCall, plainCall] = env.enableCalls; assert.ok(Array.isArray(objectCall?.permissions)); assert.equal( typeof objectCall.permissions[0], @@ -331,6 +326,11 @@ test("NIP-07 login falls back when structured permissions fail", async () => { "string", "string-based permissions should be attempted after structured payloads", ); + assert.equal( + plainCall, + undefined, + "plain enable() should only be attempted if prior payloads fail", + ); } finally { env.restore(); nostrClient.logout(); @@ -355,11 +355,23 @@ test("NIP-07 login supports extensions that only allow enable() without payload" assert.equal(pubkey, HEX_PUBKEY); assert.equal( env.enableCalls.length, - 1, - "should succeed with plain enable() immediately", + 3, + "should fall back to plain enable() after payload rejections", ); - const [plainCall] = env.enableCalls; - assert.equal(plainCall, undefined, "first attempt should use plain enable()"); + const [objectCall, stringCall, plainCall] = env.enableCalls; + assert.ok(Array.isArray(objectCall?.permissions)); + assert.equal( + typeof objectCall.permissions[0], + "object", + "structured permissions should be attempted before falling back", + ); + assert.ok(Array.isArray(stringCall?.permissions)); + assert.equal( + typeof stringCall.permissions[0], + "string", + "string permissions should be attempted before plain enable()", + ); + assert.equal(plainCall, undefined, "final attempt should use plain enable()"); } finally { env.restore(); nostrClient.logout(); @@ -399,9 +411,8 @@ test("NIP-07 login quickly retries when a permission payload stalls", async () = duration < 500, `login should fall back quickly from stalled enable payloads (duration: ${duration}ms)`, ); - assert.equal(env.enableCalls.length, 3, "should attempt plain, structured and string payload variants"); - const [plainCall, objectCall, stringCall] = env.enableCalls; - assert.equal(plainCall, undefined); + assert.equal(env.enableCalls.length, 2, "should attempt structured and string payload variants"); + const [objectCall, stringCall] = env.enableCalls; assert.ok(Array.isArray(objectCall?.permissions)); assert.equal(typeof objectCall.permissions[0], "object"); assert.ok(Array.isArray(stringCall?.permissions));