diff --git a/TEST_INTEGRITY.md b/TEST_INTEGRITY.md index 16d311e7..eda16051 100644 --- a/TEST_INTEGRITY.md +++ b/TEST_INTEGRITY.md @@ -458,3 +458,133 @@ test_integrity_note: did_relax_any_assertion: false if_true_explain_spec_basis: "" ``` + +## 2026-07-05 — todo-11b final: un-quarantine nwc-client (+ user-blocks / nostr-publish-rejection hangs) + +```yaml +test_integrity_note: + change_type: ["spec_correction", "refactor_tests", "flake_fix"] + scenarios: + - id: SCN-nwc-unsupported-encryption-scheme + given: "An NWC wallet whose info event (kind 13194) advertises ONLY an encryption scheme bitvid does not implement (nip44_v3)" + when: "ensureEncryptionForContext(context) negotiates a scheme" + then: "It rejects with message /Wallet advertises unsupported encryption schemes: nip44_v3\\./ AND error.code === 'UNSUPPORTED_ENCRYPTION'" + - id: SCN-nwc-mock-toolkit-selection + given: "Each nwc-client section installs a per-section window.NostrTools mock to drive encryption-scheme selection" + when: "The nostr-tools bootstrap has installed its frozen canonical toolkit on the global scope" + then: "Removing scope.__BITVID_CANONICAL_NOSTR_TOOLS__ lets readToolkitFromScope fall through to the section mock, so the wiring under test runs against the mock (not real @noble against fake keys)" + observable_outcomes: + - "ensureEncryptionForContext rejection message + error.code (UNSUPPORTED_ENCRYPTION) — asserted exactly" + - "nip44_v2 section still resolves via the mock's nip44 (assert.match on the mock ciphertext shape)" + determinism_controls: + - "per-section in-memory window.NostrTools mocks; canonical-toolkit removal is deterministic (one-time, nothing re-installs it); no network" + anti_cheat_rationale: + prevents: + - "over-mocking internal logic" + - "hard-coded return value" + note: | + nwc-client: TWO issues. (1) MOCK SHADOW (not a spec change): the bootstrap installs a + frozen canonical toolkit that readToolkitFromScope() prefers over window.NostrTools, so + the per-section mocks never took effect and real @noble ran against the fake test keys + ("bad point: is not on curve"). Deleting the scope canonical lets the resolver use each + section's mock — the tests exercise the NWC client's encryption-SELECTION wiring, not + @noble crypto (which is real elsewhere; the parseNwcUri hex-vs-bytes production bug is + guarded by tests/nwc-parse-uri.test.mjs). (2) SPEC CORRECTION: one section asserted a + rejection when the toolkit "lacks nip44" by omitting nip44 from the mock. That precondition + is architecturally unreachable — the bootstrap's mergeWithCanonical ALWAYS backfills the + canonical nip44 (even an explicit `nip44: null` is overwritten by `!merged.nip44`), so + nip44_v2 is never an unsupported scheme. Replaced with a wallet advertising a scheme bitvid + genuinely cannot provide (nip44_v3), exercising the SAME UNSUPPORTED_ENCRYPTION rejection + via a reachable scenario, and strengthened the check to also assert error.code — strictly + stronger than the old message-only match. + + user-blocks + nostr-publish-rejection: FLAKE_FIX (no assertion changed). Both are + bare-assert files that PASS all assertions then hang on lingering handles after completion + (user-blocks: a never-resolving decrypt's background retry timer that manager.reset() doesn't + cancel; nostr-publish-rejection: js/app.js + subscriptions + userBlocks module-load + timers/connection managers). Applied the repo's established post-completion + `setTimeout(() => process.exit(0), 50)` exit after cleanup. No behavior or expectation was + altered — the process simply exits once the assertions have all run. + relaxation: + did_relax_any_assertion: false + if_true_explain_spec_basis: "" +``` + +## 2026-07-05 — todo-11: uploadModal-reset zombie-callback test made deterministic (correct mock boundary) + +```yaml +test_integrity_note: + change_type: ["flake_fix", "refactor_tests"] + scenarios: + - id: SCN-upload-zombie-callback-guard + given: "An upload is in progress (videoUploadState.status === 'uploading') via a controllable pending mediaUploader.uploadVideo" + when: "resetUploads() runs mid-upload (bumping videoUploadId), then the in-flight upload later resolves (a 'zombie' completion)" + then: "The stale completion is ignored — status stays 'idle' and results.videoUrl stays '' — because handleVideoSelection guards on videoUploadId !== currentUploadId" + observable_outcomes: + - "videoUploadState.status: 'uploading' after start, 'idle' after reset, still 'idle' after the zombie resolve" + - "results.videoUrl.value === '' after the zombie resolve" + - "test exits 0 with no async-after-test activity (was leaking webtorrent/Blob errors)" + determinism_controls: + - "mediaUploader.uploadVideo replaced with a manually-resolved promise; captureVideoMetadata stubbed; jsdom DOM; no webtorrent, no network, no real Blob ops" + anti_cheat_rationale: + prevents: + - "over-mocking internal logic" + - "retry/sleep-based flake masking" + note: | + FLAKE_FIX + REFACTOR_TESTS, no assertion weakened. The subtest PASSED its asserts but + leaked async activity after the test ended, which node:test flags as a failure. Root + cause: it mocked modal.generateTorrentMetadata / resolveUploadIdentifier, but + handleVideoSelection drives the upload through this.mediaUploader.uploadVideo — a + DIFFERENT object with its OWN helpers — so the mediaUploader still ran real + createTorrentMetadata()/hashing on a synthetic {name:'video.mp4'} file ("invalid input + type" async), and captureVideoMetadata() ran URL.createObjectURL(file) ("must be an + instance of Blob"). Fixed by mocking at the correct DI boundary + (modal.mediaUploader.uploadVideo returns the controllable pending promise) and stubbing + captureVideoMetadata. The unit under test is the modal's videoUploadId zombie-guard, so + mocking the uploader boundary is correct isolation, not over-mocking (real upload/torrent + paths are covered by the mediaUploader / s3-upload suites). + mutation_verification: + - "Broke the guard (`if (this.videoUploadId !== currentUploadId) return` → `if (false)`) → the test FAILS ('State should remain idle after zombie completion', # fail 1). Reverted." + relaxation: + did_relax_any_assertion: false + if_true_explain_spec_basis: "" +``` + +## 2026-07-05 — todo-11b: un-quarantine dm-block-filter (hermetic subscription; no force-exit) + +```yaml +test_integrity_note: + change_type: ["flake_fix"] + scenarios: + - id: SCN-dm-block-filter-hermetic + given: "The 'integration with loaded user block list' test loads standard + legacy block lists and filters incoming DMs" + when: "manager.loadBlocks() runs against stubbed fetchListIncrementally, with the live block-list subscription stubbed out" + then: "Both blocked contacts (standard + legacy) are filtered (dmMessages === [ALLOWED]) AND the process opens NO real relay sockets, so node:test exits naturally with the correct exit code" + observable_outcomes: + - "manager.isBlocked(legacy) && manager.isBlocked(standard) === true; dmMessages length 1 (ALLOWED only)" + - "process.getActiveResourcesInfo() shows no TCPSocketWrap after loadBlocks; node exits 0 on pass, 1 on a forced failure (verified — failures NOT masked)" + determinism_controls: + - "stubbed nostrClient.fetchListIncrementally; truthy stub nostrClient.pool; no-op nostrClient.getSubscriptionManager; window.nostr.nip04.decrypt stub. All restored in finally. No network." + anti_cheat_rationale: + prevents: + - "retry/sleep-based flake masking" + - "over-mocking internal logic" + note: | + FLAKE_FIX, no assertion changed. The file PASSED all subtests then hung: loadBlocks() + opens a LIVE block-list subscription through nostrClient's real SimplePool aimed at the + test's fake relay URLs, leaking TCP sockets + reconnect timers that outlive the run so + node:test never exits (surfaced only once the runner was given a per-file timeout). + Diagnosed with process.getActiveResourcesInfo() — 4 TCPSocketWrap + reconnect Timeouts; + NOT the decrypt-retry timer (verified absent). Nulling the pool did not help because + loadBlocks calls ensurePool() (userBlocks.js:967) to re-create+connect a real pool when + pool is falsy. Fix keeps the test hermetic: a TRUTHY stub pool (so ensurePool is skipped) + + a no-op getSubscriptionManager (so the subscription opens nothing). The block-merge + logic under test still runs fully through the stubbed fetchListIncrementally. A force + `process.exit` in an after() hook was explicitly REJECTED after verifying it masks real + failures (process.exitCode is not set when the hook's timer fires — a deliberately broken + assertion still exited 0 under that approach). Under this fix, the forced-failure control + correctly exits 1. + relaxation: + did_relax_any_assertion: false + if_true_explain_spec_basis: "" +``` diff --git a/components/iframe_forms/iframe-application-form.html b/components/iframe_forms/iframe-application-form.html index 7c5a3f63..e44a132e 100644 --- a/components/iframe_forms/iframe-application-form.html +++ b/components/iframe_forms/iframe-application-form.html @@ -279,11 +279,12 @@ // Set the recipient's NPUB (your personal NPUB) here. const recipientNpub = - "npub13yarr7j6vjqjjkahd63dmr27curypehx45ucue286ac7sft27y0srnpmpe"; + "npub15jnttpymeytm80hatjqcvhhqhzrhx6gxp8pq0wn93rhnu8s9h9dsha32lx"; const RELAYS = [ "wss://relay.snort.social", "wss://relay.damus.io", "wss://relay.primal.net", + "wss://nos.lol", ]; const pool = new SimplePool(); @@ -437,23 +438,27 @@ For further questions, contact us through bitvid’s Nostr support channels. const ephemeralPubHex = getPublicKey(ephemeralPriv); log("Ephemeral pubkey: " + ephemeralPubHex.slice(0, 16) + "..."); - // Encrypt the application content. - log("Encrypting application content (nip04)..."); - const ciphertext = await nip04.encrypt( - ephemeralPriv, - targetPubHex, - applicationContent - ); - log("[DEBUG] Ciphertext: " + ciphertext); - log("Encryption done."); - - // Build the Nostr event template. + // Build a structured bitvid submission (kind 30083, PUBLIC — no + // encryption). Published from the ephemeral key; the admin's + // Submissions tab lists these and can approve → add to whitelist. const now = Math.floor(Date.now() / 1000); + const submissionId = + "sub-" + + now.toString(36) + + "-" + + Math.random().toString(36).slice(2, 10); const eventTemplate = { - kind: 4, + kind: 30083, created_at: now, - tags: [["p", targetPubHex]], - content: ciphertext, + tags: [ + ["d", submissionId], + ["k", "application"], + ["applicant", applicantNpub], + ["p", targetPubHex], + ["t", "bitvid-submission"], + ["client", "bitvid"], + ], + content: applicationContent, }; log( "[DEBUG] Event template before finalizing: " + @@ -473,7 +478,7 @@ For further questions, contact us through bitvid’s Nostr support channels. for (const url of RELAYS) { log("Connecting to " + url + " for subscription..."); const relay = await Relay.connect(url); - relay.subscribe([{ authors: [ephemeralPubHex], kinds: [4] }], { + relay.subscribe([{ authors: [ephemeralPubHex], kinds: [30083] }], { onEvent(foundEvent) { if (foundEvent.id === event.id) { log( diff --git a/components/iframe_forms/iframe-bug-fix-form.html b/components/iframe_forms/iframe-bug-fix-form.html index 13704d9a..3040a75d 100644 --- a/components/iframe_forms/iframe-bug-fix-form.html +++ b/components/iframe_forms/iframe-bug-fix-form.html @@ -261,11 +261,12 @@ // Set the recipient's NPUB (your personal NPUB) const recipientNpub = - "npub13yarr7j6vjqjjkahd63dmr27curypehx45ucue286ac7sft27y0srnpmpe"; + "npub15jnttpymeytm80hatjqcvhhqhzrhx6gxp8pq0wn93rhnu8s9h9dsha32lx"; const RELAYS = [ "wss://relay.snort.social", "wss://relay.damus.io", "wss://relay.primal.net", + "wss://nos.lol", ]; const pool = new SimplePool(); @@ -417,23 +418,31 @@ For urgent issues, contact us through bitvid’s Nostr support channels. const ephemeralPubHex = getPublicKey(ephemeralPriv); log("Ephemeral pubkey: " + ephemeralPubHex.slice(0, 16) + "..."); - // Encrypt the bug report content. - log("Encrypting bug report content (nip04)..."); - const ciphertext = await nip04.encrypt( - ephemeralPriv, - targetPubHex, - bugReportContent - ); - log("[DEBUG] Ciphertext: " + ciphertext); - log("Encryption done."); - - // Build the event template. + // Build a structured bitvid submission (kind 30083, PUBLIC — no + // encryption). Published from the ephemeral key. const now = Math.floor(Date.now() / 1000); + const submissionId = + "sub-" + + now.toString(36) + + "-" + + Math.random().toString(36).slice(2, 10); + const tags = [ + ["d", submissionId], + ["k", "bug"], + ]; + if (userNpub) { + tags.push(["applicant", userNpub]); + } + tags.push( + ["p", targetPubHex], + ["t", "bitvid-submission"], + ["client", "bitvid"] + ); const eventTemplate = { - kind: 4, + kind: 30083, created_at: now, - tags: [["p", targetPubHex]], - content: ciphertext, + tags, + content: bugReportContent, }; log( "[DEBUG] Event template before finalizing: " + @@ -453,7 +462,7 @@ For urgent issues, contact us through bitvid’s Nostr support channels. for (const url of RELAYS) { log("Connecting to " + url + " for subscription..."); const relay = await Relay.connect(url); - relay.subscribe([{ authors: [ephemeralPubHex], kinds: [4] }], { + relay.subscribe([{ authors: [ephemeralPubHex], kinds: [30083] }], { onEvent(foundEvent) { if (foundEvent.id === event.id) { log( diff --git a/components/iframe_forms/iframe-content-appeals-form.html b/components/iframe_forms/iframe-content-appeals-form.html index 56c2a228..12dafd62 100644 --- a/components/iframe_forms/iframe-content-appeals-form.html +++ b/components/iframe_forms/iframe-content-appeals-form.html @@ -45,6 +45,18 @@ placeholder="Enter the magnet link" /> + + +

+ Required — open the blocked video's ⋯ menu, choose “Copy link”, and + paste it here so a moderator can locate and unblock the exact video. +

+ @@ -193,15 +205,46 @@ // Set the recipient's NPUB (your personal NPUB) here. const recipientNpub = - "npub13yarr7j6vjqjjkahd63dmr27curypehx45ucue286ac7sft27y0srnpmpe"; + "npub15jnttpymeytm80hatjqcvhhqhzrhx6gxp8pq0wn93rhnu8s9h9dsha32lx"; const RELAYS = [ "wss://relay.snort.social", "wss://relay.damus.io", "wss://relay.primal.net", + "wss://nos.lol", ]; const pool = new SimplePool(); + // Validate + normalize the blocked-video reference to a 64-char hex + // event id. Accepts a bitvid share link (which embeds an nevent), a raw + // nevent1…/note1… pointer, or a bare hex id. Returns "" when nothing + // valid is found so the caller can block submission. + const HEX_EVENT_ID = /^[0-9a-f]{64}$/i; + function extractEventHex(input) { + const trimmed = String(input || "").trim(); + if (!trimmed) { + return ""; + } + if (HEX_EVENT_ID.test(trimmed)) { + return trimmed.toLowerCase(); + } + // Pull an nevent/note token out of a URL or free text. + const match = trimmed.match(/(nevent1[0-9a-z]+|note1[0-9a-z]+)/i); + const token = match ? match[1] : trimmed; + try { + const decoded = nip19.decode(token); + if (decoded.type === "nevent" && HEX_EVENT_ID.test(decoded.data?.id || "")) { + return decoded.data.id.toLowerCase(); + } + if (decoded.type === "note" && HEX_EVENT_ID.test(decoded.data || "")) { + return decoded.data.toLowerCase(); + } + } catch (err) { + // not a decodable pointer + } + return ""; + } + document .getElementById("dm-form") .addEventListener("submit", async (ev) => { @@ -220,6 +263,17 @@ const magnetLink = document .getElementById("magnetLink") .value.trim(); + const eventPointer = document + .getElementById("eventPointer") + .value.trim(); + const targetEventHex = extractEventHex(eventPointer); + if (!targetEventHex) { + log( + "Please paste a valid video share link, nevent1…, note1…, or 64-char event id so a moderator can locate the blocked video.", + "error" + ); + return; + } const submissionDate = document .getElementById("submissionDate") .value.trim(); @@ -261,6 +315,7 @@ **2. Content Details** - **Title of the Video:** ${videoTitle} - **Magnet Link:** ${magnetLink} +- **Blocked Event ID:** ${targetEventHex} - **Date of Content Submission:** ${submissionDate} **3. Reason for Appeal** @@ -315,23 +370,27 @@ For further questions, reach out through bitvid’s Nostr support channels. const ephemeralPubHex = getPublicKey(ephemeralPriv); log("Ephemeral pubkey: " + ephemeralPubHex.slice(0, 16) + "..."); - // Encrypt the appeal content. - log("Encrypting appeal content (nip04)..."); - const ciphertext = await nip04.encrypt( - ephemeralPriv, - targetPubHex, - appealContent - ); - log("[DEBUG] Ciphertext: " + ciphertext); - log("Encryption done."); - - // Build the event template. + // Build a structured bitvid submission (kind 30083, PUBLIC — no + // encryption). Published from the ephemeral key. + log("Building appeal submission (kind 30083, public)..."); const now = Math.floor(Date.now() / 1000); + const submissionId = + "sub-" + + now.toString(36) + + "-" + + Math.random().toString(36).slice(2, 10); const eventTemplate = { - kind: 4, + kind: 30083, created_at: now, - tags: [["p", targetPubHex]], - content: ciphertext, + tags: [ + ["d", submissionId], + ["k", "appeal"], + ["p", targetPubHex], + ["e", targetEventHex], + ["t", "bitvid-submission"], + ["client", "bitvid"], + ], + content: appealContent, }; log( "[DEBUG] Event template before finalizing: " + @@ -351,7 +410,7 @@ For further questions, reach out through bitvid’s Nostr support channels. for (const url of RELAYS) { log("Connecting to " + url + " for subscription..."); const relay = await Relay.connect(url); - relay.subscribe([{ authors: [ephemeralPubHex], kinds: [4] }], { + relay.subscribe([{ authors: [ephemeralPubHex], kinds: [30083] }], { onEvent(foundEvent) { if (foundEvent.id === event.id) { log( diff --git a/components/iframe_forms/iframe-feedback-form.html b/components/iframe_forms/iframe-feedback-form.html index 5e568548..37933bbe 100644 --- a/components/iframe_forms/iframe-feedback-form.html +++ b/components/iframe_forms/iframe-feedback-form.html @@ -197,11 +197,12 @@ // Set the recipient's NPUB (your personal NPUB) const recipientNpub = - "npub13yarr7j6vjqjjkahd63dmr27curypehx45ucue286ac7sft27y0srnpmpe"; + "npub15jnttpymeytm80hatjqcvhhqhzrhx6gxp8pq0wn93rhnu8s9h9dsha32lx"; const RELAYS = [ "wss://relay.snort.social", "wss://relay.damus.io", "wss://relay.primal.net", + "wss://nos.lol", ]; const pool = new SimplePool(); @@ -318,21 +319,31 @@ For additional discussions, reach out via bitvid’s Nostr support channels. // Encrypt the feedback content. log("Encrypting feedback content (nip04)..."); - const ciphertext = await nip04.encrypt( - ephemeralPriv, - targetPubHex, - feedbackContent - ); - log("[DEBUG] Ciphertext: " + ciphertext); - log("Encryption done."); - - // Build the event template. + // Build a structured bitvid submission (kind 30083, PUBLIC — no + // encryption). Published from the ephemeral key. const now = Math.floor(Date.now() / 1000); + const submissionId = + "sub-" + + now.toString(36) + + "-" + + Math.random().toString(36).slice(2, 10); + const tags = [ + ["d", submissionId], + ["k", "feedback"], + ]; + if (userNpub) { + tags.push(["applicant", userNpub]); + } + tags.push( + ["p", targetPubHex], + ["t", "bitvid-submission"], + ["client", "bitvid"] + ); const eventTemplate = { - kind: 4, + kind: 30083, created_at: now, - tags: [["p", targetPubHex]], - content: ciphertext, + tags, + content: feedbackContent, }; log( "[DEBUG] Event template before finalizing: " + @@ -352,7 +363,7 @@ For additional discussions, reach out via bitvid’s Nostr support channels. for (const url of RELAYS) { log("Connecting to " + url + " for subscription..."); const relay = await Relay.connect(url); - relay.subscribe([{ authors: [ephemeralPubHex], kinds: [4] }], { + relay.subscribe([{ authors: [ephemeralPubHex], kinds: [30083] }], { onEvent(foundEvent) { if (foundEvent.id === event.id) { log( diff --git a/components/iframe_forms/iframe-request-form.html b/components/iframe_forms/iframe-request-form.html index 8c76a281..4470ab20 100644 --- a/components/iframe_forms/iframe-request-form.html +++ b/components/iframe_forms/iframe-request-form.html @@ -221,11 +221,12 @@ // Set the recipient's NPUB (your personal NPUB) const recipientNpub = - "npub13yarr7j6vjqjjkahd63dmr27curypehx45ucue286ac7sft27y0srnpmpe"; + "npub15jnttpymeytm80hatjqcvhhqhzrhx6gxp8pq0wn93rhnu8s9h9dsha32lx"; const RELAYS = [ "wss://relay.snort.social", "wss://relay.damus.io", "wss://relay.primal.net", + "wss://nos.lol", ]; const pool = new SimplePool(); @@ -360,21 +361,31 @@ For further discussions, reach out through bitvid’s Nostr support channels. // Encrypt the feature request content log("Encrypting feature request content (nip04)..."); - const ciphertext = await nip04.encrypt( - ephemeralPriv, - targetPubHex, - featureRequestContent - ); - log("[DEBUG] Ciphertext: " + ciphertext); - log("Encryption done."); - - // Build the event template + // Build a structured bitvid submission (kind 30083, PUBLIC — no + // encryption). Published from the ephemeral key. const now = Math.floor(Date.now() / 1000); + const submissionId = + "sub-" + + now.toString(36) + + "-" + + Math.random().toString(36).slice(2, 10); + const tags = [ + ["d", submissionId], + ["k", "feature"], + ]; + if (userNpub) { + tags.push(["applicant", userNpub]); + } + tags.push( + ["p", targetPubHex], + ["t", "bitvid-submission"], + ["client", "bitvid"] + ); const eventTemplate = { - kind: 4, + kind: 30083, created_at: now, - tags: [["p", targetPubHex]], - content: ciphertext, + tags, + content: featureRequestContent, }; log( "[DEBUG] Event template before finalizing: " + @@ -394,7 +405,7 @@ For further discussions, reach out through bitvid’s Nostr support channels. for (const url of RELAYS) { log("Connecting to " + url + " for subscription..."); const relay = await Relay.connect(url); - relay.subscribe([{ authors: [ephemeralPubHex], kinds: [4] }], { + relay.subscribe([{ authors: [ephemeralPubHex], kinds: [30083] }], { onEvent(foundEvent) { if (foundEvent.id === event.id) { log( diff --git a/components/profile-modal.html b/components/profile-modal.html index a4ee0f30..d66bbd31 100644 --- a/components/profile-modal.html +++ b/components/profile-modal.html @@ -211,6 +211,28 @@ My Videos + +

+ One-tap connect via Bitcoin Connect (NWC) — or paste a connection manually below. +

+
- +
+ + Advanced — paste a connection manually + + +
+ + + + +
Enter your Cloudflare R2 credentials. Your Account ID (from dashboard) is used to connect. For Public Access URL, use your custom domain or R2.dev subdomain. Ensure the token has "Object Read & Write" permissions. @@ -1006,6 +1094,8 @@ The public URL where your files will be accessible (CDN). +
+
+ +
+
+
+

+ Trusted creators +

+

+ Turn off web-of-trust warnings for a creator across all + their videos. They stay listed here until you reset them. +

+
+
+ + +
+

+
+ No trusted creators yet. +
+ +
@@ -1794,6 +1995,82 @@ > Moderators + +
+ +