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..2a4249a5 100644 --- a/components/profile-modal.html +++ b/components/profile-modal.html @@ -211,6 +211,28 @@ My Videos + Admin (Moderation) + + + + +npub...
@@ -140,6 +142,13 @@ >Website + +