Commit Graph
1144 Commits
Author SHA1 Message Date
thePR0M3TH3ANandClaude Opus 4.8 afb6200b40 fix(delete): publish tombstones to the write relays so deletes propagate
Root cause of "deleted/tombstoned videos still show in the UI": normal video
publishes fan out to the uncapped WRITE relay set, but both delete paths — the
soft-delete tombstone (revertVideo) and the NIP-09 deletion — published only to
this.relays, the CAPPED read set (<=8). A video published broadly was therefore
tombstoned on just a subset; every relay outside that subset kept serving the
original event, so the video resurrected on the next load (the tombstone's
created_at was already bumped strictly-newer, so replacement worked — it just
never reached the relays that mattered).

Add getDeletePublishRelays() (prefer write set, fall back to read set, then
bundled defaults) and use it for both delete publishes, mirroring publishVideo.

Also removed a stray `const a = 1` debug leftover at end of client.js.

Tests: getDeletePublishRelays prefers the write set / falls back to read; and a
call-site test that revertVideo publishes the tombstone to the full 12-relay
write set rather than the 2-relay capped read subset. Mutation-verified —
restoring `this.relays` at the call sites fails the propagation test.

Build + full lint green.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-20 12:38:51 -04:00
thePR0M3TH3ANandClaude Opus 4.8 6b63625d88 test(feed): update subscribeVideos spec for reserved live defaults
Spec correction following de515462 (feed always reserves live default
aggregators). The existing "uses the healthy relays when they are available"
case asserted deepEqual(subRelayArgs, healthy) — i.e. ONLY the healthy relays.
That expectation is now wrong: the feed deliberately also reserves known-good
defaults so a healthy-looking but dead user list can't hang the feed. Replaced
with a stricter behavioral assertion (healthy relays still present AND a default
reserved AND bounded to the read cap). Mutation-verified: reverting
buildFeedRelaySet to the old healthy-only logic fails the updated case.

test_integrity_note:
  change_type: ["spec_correction"]
  scenarios:
    - id: SCN-feed-reserve-live-defaults
      given: "the user has healthy-looking relays that may actually be dead"
      when: "the feed subscription is created via subscribeVideos"
      then: "the relay set includes the user's healthy relays AND at least one default aggregator, bounded to MAX_SUBSCRIBE_RELAYS"
  observable_outcomes:
    - "the relay array passed to pool.sub()"
  determinism_controls:
    - "mocked pool.sub captures the boundary relay list; getHealthyRelays stubbed"
  anti_cheat_rationale:
    prevents:
      - "over-mocking internal logic"
      - "snapshot rubber-stamping"
  relaxation:
    did_relax_any_assertion: false
    if_true_explain_spec_basis: "old deepEqual replaced with stricter superset+liveness+bound assertions"

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-20 12:25:27 -04:00
thePR0M3TH3ANandClaude Opus 4.8 f0eecec09d feat(delete): warn when a hosted file is left behind by locked storage
Deleting a video removes the Nostr note(s) but cleanup of the underlying R2/S3
object was silently skipped when storage was locked — leaving the file publicly
downloadable with no indication to the user. Now the delete flow surfaces a
clear heads-up when that happens, telling the user to unlock storage and delete
again (or remove the object from their bucket manually).

To make the signal accurate, deleteVideoStorage now resolves the affected object
keys BEFORE the credentials check (publicBaseUrl is available even while
locked). So "storage-locked" is reported only when there are genuinely hosted
objects to remove; an external-URL video (nothing on the user's bucket) returns
"no-matching-objects" and never triggers a false warning.

Tests (deleteVideoStorage via injected credentials/deleteObject): locked +
hosted object -> storage-locked; locked + external URL -> no-matching-objects
(no false alarm); unlocked -> actually deletes the object. Mutation-verified —
restoring the old credentials-first ordering misreports the external-URL case.

Build + full lint green.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-20 12:21:49 -04:00
thePR0M3TH3ANandClaude Opus 4.8 3f4299a885 feat(publish): tell the user how many relays accepted their video
Publishing showed a flat "Video shared successfully!" even when most (or all)
relays rejected the event, so users couldn't tell a partial/failed publish from
a clean one. Now publishVideo captures the per-relay accept/reject summary and
stashes a compact {accepted,total} tally on the returned event via a
non-enumerable key (never serialized, never sent to relays). app.js reads it
back and reports the real outcome:
  - all relays accepted  -> "Video published to N relays!"
  - some rejected        -> warning "Video published to N of M relays …"
  - none accepted        -> error (defensive; this path normally throws first)
Falls back to the generic message when no tally is available.

Tests (describePublishOutcome + attach/readRelayPublishSummary): success/partial/
zero/singular/missing-tally messaging, and that the tally round-trips while
staying out of JSON serialization. Mutation-verified — flipping partial→success
and the summary key to enumerable both fail the suite.

Build + full lint green.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-20 12:19:09 -04:00
thePR0M3TH3ANandClaude Opus 4.8 de515462e6 fix(feed): always reserve live default relays in the feed set
The video feed's getDefaultRelays only fell back to bitvid's default relays
when the healthy set was COMPLETELY empty. A user whose own relays are all dead
but not yet probed (so they still pass the liveness filter) would have the feed
subscribe to that fully-broken list and hang forever on "Fetching…" — the
default aggregators were never injected.

Route the healthy set through capReadRelays (via new buildFeedRelaySet), which
always reserves a couple of reliable aggregator slots. Now the feed can reach
live relays regardless of how broken the user's personal list is, while still
prioritizing the user's own relays and bounding REQ fan-out to MAX_SUBSCRIBE_RELAYS.

Tests (buildFeedRelaySet): an all-dead user list still yields a default
aggregator while keeping the user's relays; empty input falls back to defaults;
an empty healthy set uses the configured fallback yet still reserves defaults; a
huge list stays bounded. Mutation-verified — reverting to the old
healthy-or-defaults logic fails 3 of the 4 scenarios.

Build + full lint green.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-20 12:14:03 -04:00
thePR0M3TH3ANandClaude Opus 4.8 3aef97f08a feat(edit): clean up superseded thumbnail on edit (orphan removal)
Previously editing a video deleted the old hosted video + its .torrent when the
URL changed, but intentionally left the old thumbnail orphaned in the bucket.
Now that the Edit modal can replace a thumbnail (and we have a clear "thumbnail
changed" signal), also delete the superseded thumbnail: build an orphan list with
the old video ({url}) and/or old thumbnail ({thumbnail}) and pass both to
deleteVideoStorage. Each guarded on old & new being non-empty and different, so we
never delete an object the new note still references. Best-effort; never blocks
the edit.

Tests (collectVideoStorageKeys): a {url} orphan yields the video key + sibling
.torrent; a {thumbnail} orphan yields the thumbnail key; both collect together;
objects under a different publicBaseUrl are never collected (bucket-switch safety).

Also documents the pre-existing uploadModal-reset.test.mjs flake in KNOWN_ISSUES
(reproduced on 1b11cb1b; not from this work).

Build + full lint green; full unit suite green except the documented pre-existing
flake.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-20 09:32:30 -04:00
thePR0M3TH3ANandClaude Opus 4.8 c8a63c57c6 feat(edit): thumbnail + video file upload in Edit modal (steps 2-3/3)
Brings the Edit modal to upload parity with the Upload modal: re-upload a
thumbnail or video file instead of only pasting URLs.

- Wire storage services (r2/s3/storageService) + pubkey/npub accessors into
  EditModal via ModalManager/initEditModal; EditModal constructs a shared
  MediaUploader.
- New editModalUpload.js holds all the upload glue (file-picker wiring, progress,
  storage-unlock checks, field write-back) to keep EditModal under its file-size
  budget. EditModal calls initEditModalUpload(this, context) in one line.
- edit-video-modal.html: "Replace video file…" (under the URL field) and
  "Replace thumbnail file…" (under the thumbnail field), each with a hidden file
  input + status line.
- On thumbnail upload: writes the new URL into the thumbnail field. On video
  upload: writes url + magnet (+ ws/xs hints). All written via setFieldUnlocked,
  which marks the field edited so EditModal.collect actually persists it (gated
  behind storage being configured + unlocked).

setFieldUnlocked is unit-tested (mutation-verified): without the unlock, the
re-upload would silently not save. Build + full lint + unit suite green.

NOTE: the upload/storage flow itself can't be unit-tested (browser + R2/S3);
pending manual verification of editing a video's thumbnail/file.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-20 09:09:07 -04:00
thePR0M3TH3ANandClaude Opus 4.8 95071fd16b feat(relays): clearer relay health UI + fix broken relay operations
Relay tab improvements so users can see which relays to remove:
- Per-relay status pill: Online (with ping) / Offline / Checking… / Blocked.
- Insecure remote ws:// relays (CSP-blocked) flagged immediately with a hint.
- Offline relays show a "consider removing" hint; their Remove button is
  emphasized.
- Summary banner when relays are down (incl. the all-dead case explaining why
  videos won't load), pointing at Restore defaults / healthy relays.

Also fixes a pre-existing decomposition bug (commit 7300977a): handleRelayOperation
called this.runRelayOperation, but that method lives on the main
ProfileModalController — so ALL relay ops (add/remove/mode/restore) threw
"this.runRelayOperation is not a function". Route via this.mainController.

Tests: relay-insecure-url (isInsecureRelayUrl), relay-operation-routing (routes to
mainController.runRelayOperation, guards no-pubkey). Both mutation-verified.
Build + lint + full unit suite green.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-20 08:27:41 -04:00
thePR0M3TH3ANandClaude Opus 4.8 6984fb2ef9 fix(publish): handle nostr-tools array-return; stop uncaught rejection flood
publishEventToRelay expected the old nostr-tools publish handle (.on/.then), but
nostr-tools 2.17 SimplePool.publish(relays, event) returns an ARRAY of per-relay
promises. The array has neither .on nor .then, so the code (a) fell through to
the "optimistic success" path (publishes were never truly confirmed) and (b)
never attached a .catch to the inner promise — so every relay that rejected
("blocked", "auth-required", "publish timed out", websocket error, …) leaked an
unhandled promise rejection. With a large/flaky relay list (e.g. a 35-relay
account hitting many paid/restricted relays) that floods the event loop and can
freeze the tab — observed when republishing the relay list and when publishing a
video.

Fix: detect the array return and Promise.allSettled it — any fulfilled = success,
otherwise a failure result; every rejection is consumed. Confirmed publishes now
resolve from real acks instead of the optimistic fallback.

New tests assert a rejecting array-promise becomes a failure result with NO
unhandled rejection leak, a fulfilling one succeeds, and an all-rejecting publish
still resolves. Mutation-verified; existing publish tests pass under the suite.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-20 08:27:21 -04:00
thePR0M3TH3ANandClaude Opus 4.8 0031d68b67 fix(signer): register NIP-07 adapter on refresh restore (storage unlock)
On a fresh login, loginWithExtension builds + registers the NIP-07 signer
adapter. On a page REFRESH that path never runs — the app restores the
logged-in pubkey/UI, but ensureActiveSignerForPubkey only RESOLVED from the
signer registry; it never CONSTRUCTED an adapter from window.nostr. So the
registry stayed empty and ensureActiveSignerForPubkey returned null forever.

Decrypt paths survived via their own per-call window.nostr fallbacks, but
features relying solely on the registry — profile Storage "Unlock" — failed
with "No active signer found." despite a healthy, instantly-responsive
extension. (Diagnosed via temporary instrumentation: getActiveSigner_truthy
false AND ensureResult_truthy false, while extension getPublicKey responded in
1ms.)

Fix: when a live extension's own pubkey matches the requested pubkey and no
signer is registered, build + register the adapter (mirroring
loginWithExtension), with a guard that never adopts a mismatched account.

New tests in ensure-active-signer-refresh.test.mjs (own file for process-level
isolation — the registry/permission state are module singletons): constructs +
registers a usable signer from a live extension on an empty registry; rejects a
mismatched account. Mutation-verified. Signer suite 26/26, full unit suite +
build green.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-20 07:39:05 -04:00
thePR0M3TH3ANandClaude Opus 4.8 c62114557c feat(webseed): manage multiple web seeds in the edit modal (slice 3/3)
Completes end-to-end multi-webseed support: the editor can now see, add, and
remove every web seed a video carries.

- magnetShared: promote normalizeWebSeedList to a shared export (videoNotePayload
  now imports it) and add setMagnetWebSeeds(), which replaces a magnet's COMPLETE
  ws= set with exactly the requested list. ensureWebSeeds is append-only, so
  without set-semantics a webseed a user deleted would linger in the magnet;
  setMagnetWebSeeds strips the existing ws= params first, making removal stick.
- edit-video-modal.html: the single "Web seed (ws=)" input becomes a "Web seeds
  (one per line)" textarea with backup-origin help text.
- EditModal: open() populates the field from extractAllWebSeeds() (every seed,
  not just the first). On save, when the field was manually edited the magnet's
  ws set is rebuilt from the field via setMagnetWebSeeds (true add/remove);
  otherwise existing seeds are preserved untouched. The content `ws` field still
  stores the primary (first) seed while the magnet carries the full set.

Mutation-verified (no-strip and no-add both fail the removal/add tests); build,
relevant lints (inline-styles, tokens, innerHTML), and full unit suite green.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-19 08:52:11 -04:00
thePR0M3TH3ANandClaude Opus 4.8 ce7ef9ef2d feat(webseed): upload form accepts multiple web seeds (slice 2/3)
Let uploaders declare a backup origin at creation time so the swarm can keep
streaming when the primary CDN is overloaded.

- upload-modal.html: the single "Web Seed (ws)" input becomes a "Web seeds
  (one per line)" textarea with help text explaining the backup-origin use
  case and the byte-identical-file requirement.
- UploadModal: in upload mode the R2/CDN url stays the primary webseed and any
  textarea entries follow it (passed as [primaryUrl, textarea]); external mode
  already flowed the field through. All parsing stays in the payload layer.
- videoNotePayload.normalizeWebSeedList now splits every entry (array OR
  string) on newlines/commas, so the upload-modal's [url, "a\nb"] shape and a
  plain multi-line string both expand + dedupe correctly.

Mutation-verified (array entries no longer split fails the upload-shape test);
build + full unit suite green. Lints relevant to the change (inline-styles,
tokens, innerHTML) pass.

Note: npm run lint also reports pre-existing file-size violations in unrelated
files (SignerManager, watchHistory, playbackService, webtorrent, etc.) that
grew in earlier commits — none are touched here. Flagging separately.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-19 08:36:13 -04:00
thePR0M3TH3ANandClaude Opus 4.8 cf98d06cca feat(webseed): carry multiple web seeds through publish (slice 1/3)
Foundation for declaring more than one web seed per video so the WebTorrent
swarm can pull pieces from an independent backup origin when the primary CDN
is overloaded — multiple `ws=` params are the standard BEP19 carrier and the
playback path already attaches every webseed it finds in the magnet.

- magnetShared: add extractAllWebSeeds(), which returns every `ws=` URL
  (deduped, in order) instead of only the first like extractMagnetHints —
  so a magnet with several webseeds can be round-tripped without dropping the
  extras.
- videoNotePayload: normalizeWebSeedList() accepts the ws field as an array OR
  a newline/comma-delimited string and feeds the full, deduped set into the
  magnet (plus the hosted CDN url, as before). A single ws string still works
  unchanged; the content `ws` field still reflects the primary seed.

Upload/Edit UI to author and manage the list lands in slices 2 and 3.

Mutation-verified (array-drop, split-drop, first-only, dedupe-loss all fail
the new tests); build + full unit suite green.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-19 08:21:18 -04:00
thePR0M3TH3ANandClaude Opus 4.8 512b6c3f6a feat(feed): warm watch history in background after login
The For You feed's watch-history suppression and tag-affinity previously
loaded only when the For You / History view was first opened
(feedCoordinator loadLatest({allowStale:true})), so on a cold login the
feed first rendered without suppression and then re-ran ~1-3s later on the
watch-history "fingerprint" event. Watch history was the one feed-driving
list not eagerly warmed in the background on login.

It was deliberately deferred because a cold load used to be ~150+
serialized nip-07 decrypts that starved the single-threaded signer during
the fragile post-login handshake. That cost has since been bounded
(format-routing + newest-first chunk cap + deferred backfill), so the
authSessionCoordinator.js comment's condition for re-enabling an eager
preload is now met.

Kick a fire-and-forget watch-history preload AFTER the blocks/subscriptions/
hashtags lists finish decrypting (never concurrently — same reasoning as the
DM deferral). loadLatest({allowStale:true}) returns immediately and runs the
chunk-capped refresh in the background, so the For You feed's suppression is
warm by the time the user reaches it instead of waiting on the tab.

Extracted as schedulePostLoginWatchHistoryPreload() so the contract is
unit-tested in isolation (correct pubkey + allowStale, pubkey guard,
fire-and-forget / non-blocking, swallows a failed refresh). Build + full
unit suite green; mutation-verified.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-19 08:04:54 -04:00
thePR0M3TH3ANandClaude Opus 4.8 1b11cb1bc1 [nostr] make video edit/delete reliably replace prior versions
Two replaceable-event (NIP-33/kind 30078) lifecycle fixes found auditing
edit/delete/tombstone:

1. created_at monotonicity. Edits and delete tombstones used bare
   Date.now(), with no guarantee of being newer than the version they replace.
   NIP-01 keeps the highest created_at (ties -> lowest id), so a device clock
   that's behind — or a second edit within the same second — left relays
   serving the OLD version: edits silently didn't apply, and (worse) deletions
   didn't actually remove the video for other clients (the local in-memory
   tombstone masked the failure locally). The edit and revert/delete builders
   now use max(now, baseEvent.created_at + 1). created_at is threaded through
   revertVideo and _softDeleteVersions so the bulk-delete path keeps it too.

2. Legacy tombstone key mismatch. The deletion builder synthesizes
   videoRootId = "LEGACY:<pubkey>:<dTag>" for legacy videos, so its tombstone
   keyed as "ROOT:LEGACY:..." while the original legacy event (no videoRootId)
   keyed as "<pubkey>:<dTag>" — the tombstone never matched the zombie it was
   meant to suppress. getActiveKey now treats a "LEGACY:"-prefixed root as
   non-distinct and falls through to the pubkey:dTag identity, so both match.

Verified sound and unchanged: d-tag + videoRootId preservation on edit,
ownership/version checks, dual delete (NIP-09 kind-5 + per-d-tag soft-delete),
and the tombstone guard wired across all ingestion paths.

Tests: edit/tombstone created_at is bumped above a future-dated base
(clock-skew) and uses wall-clock for a past base; a legacy deletion shares the
original event's key. Both fixes mutation-verified.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-18 20:32:50 -04:00
thePR0M3TH3ANandClaude Opus 4.8 006403b7b4 [playback] stop the WebTorrent webseed flood on multi-file torrents
The video's hosted CDN URL was added to WebTorrent's `urlList`, but a
standalone hosted-file URL is only a valid BEP19 webseed for a SINGLE-file
torrent. For a multi-file torrent WebTorrent treats it as a base directory and
appends "<torrent name>/<file path>", producing a doubled URL that 404s on
every request — the flood seen on e.g. archive.org videos.

The CDN URL is no longer placed in urlList. It's forwarded separately as
`hostedUrlWebSeed` and attached post-metadata (handleTorrentStream) via
torrent.addWebSeed ONLY when the torrent is a single file, where the URL maps
directly to the file. Multi-file torrents get no file-pointing webseed, so no
flood. Magnet-declared `ws=` webseeds are unaffected (passed via urlList as
before). With CDN-first, dual-source videos play the CDN directly anyway, so
this only affects forced-torrent / URL-less paths.

Tests: a single-file torrent receives the hosted URL via addWebSeed; a
multi-file torrent does not. The single-file guard is mutation-verified.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-18 16:05:54 -04:00
thePR0M3TH3ANandClaude Opus 4.8 ff5c641951 [auth] enforce access control on silent NIP-46 session restore
scheduleStoredRemoteSignerRestore reconnected a stored remote-signer session on
startup via useStoredRemoteSigner({ silent: true }) with NO validator, so a
pubkey that had since been blocked could silently reconnect — the same
access-control bypass class as the nsec fix. It now passes an access-control
validator (waits for the admin lists to load, then checks canAccess); since
useStoredRemoteSigner already clears the stored session when the validator
rejects, a blocked pubkey's session is forgotten on restore. The connect and
explicit "use stored" paths already enforced this; only the silent startup
restore was missing it.

Tests: the restore supplies a validator function; the validator allows
permitted pubkeys and rejects blocked ones. Mutation-verified (dropping the
validator fails the test).

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-18 15:53:56 -04:00
thePR0M3TH3ANandClaude Opus 4.8 4a38e27150 [auth] fix nsec login: enforce access control + working remember/unlock
Audit of the nsec (direct private key) login found the client's
registerPrivateKeySigner silently ignored the access-control validator AND
the persist/passphrase options, so:

- Access-control bypass (security): nsec logins skipped the blacklist/whitelist
  gate that NIP-07 and NIP-46 logins enforce. registerPrivateKeySigner now runs
  the validator BEFORE activating the signer and forgets a since-blocked stored
  key on denial.
- "Remember this key" never worked: it persisted nothing (the payload used a
  shape persistSessionActor rejects), and there was no way to detect or unlock a
  saved key — getStoredSessionActorMetadata and unlockStoredSessionActor were
  referenced by the UI/provider but defined nowhere. Implemented both on the
  client: persist now writes the canonical { privateKeyEncrypted, encryption }
  shape (PBKDF2 250k + AES-GCM, already in place), the login modal can detect a
  saved key, and unlock decrypts with the passphrase and re-activates via the
  same proven createNsecAdapter path. The nsec provider now passes the
  access-control validator to the unlock path too.
- Fixed misleading copy: the provider claimed BIP-39 seed support, but only
  hex/nsec are accepted.

Untouched-but-verified: decode/validate (hex64 + nsec1), logout clears the
in-memory key, no key logging.

Tests (tests/nsec-login.test.mjs): a denied validator rejects login and
activates no signer; persist -> unlock round-trips the same pubkey (wrong
passphrase fails); unlock re-checks access control and forgets a blocked key.
The validator-enforcement and persist-shape fixes are both mutation-verified.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-18 15:40:16 -04:00
thePR0M3TH3ANandClaude Opus 4.8 f7cea24068 [comments] honor NIP-09 deletions in comment threads
Deleted comments previously persisted (no kind-5 handling; the thread cache
only merged-in). The thread service now processes NIP-09 deletion requests:

- A kind-5 event removes a target comment only when it is signed by that
  comment's own author (you cannot delete someone else's comment). The author
  match is the security boundary.
- Deletions are remembered per target, so a comment that arrives after its
  author's deletion (out-of-order relays) is suppressed on insert.
- On load, after fetching the thread, deletions for the loaded comment ids are
  fetched (kind 5, #e: ids) and applied before the thread is rendered, so the
  cache can't resurrect a deleted comment. Removal re-renders the thread.
- Replies to a removed comment become unreachable from the root and are hidden
  with their deleted parent.

`fetchDeletionEvents` is injectable; the default queries the relay pool. Live
deletions mid-session aren't subscribed yet (a kind-5 can't be scoped by the
video upfront) — deletions are honored on each thread open.

Tests: author-signed deletion removes the comment; a non-author deletion is
ignored; an out-of-order deletion suppresses the later comment. Both the
author-match and the suppress-on-insert guard are mutation-verified.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-18 13:55:03 -04:00
thePR0M3TH3ANandClaude Opus 4.8 6f5da76d30 [comments] align count with displayed kinds + preserve multi-line content
- discussionCountService now counts getAllowedCommentKinds() (NIP-22 kind 1111
  + legacy kind 1) instead of 1111-only, so the discussion badge no longer
  undercounts threads that contain legacy comments. The count filters are
  derived from the same source the thread display uses, so they can't drift.
- Add white-space: pre-wrap to .comment-thread__body so comments with line
  breaks/paragraphs preserve them instead of collapsing into one run-on line
  (long lines still wrap via word-break: break-word).

Test: spec-corrected discussion-count-service to assert the count filters use
getAllowedCommentKinds() (reverting to 1111-only fails the deepEqual).

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-18 12:15:46 -04:00
thePR0M3TH3ANandClaude Opus 4.8 89747d7290 [playback] CDN-first for dual-source videos + reliable hosted playback
Two fixes for hosted (CDN) playback that users hit on a torrent-first instance:

1. Reliability: clear crossOrigin before direct hosted playback (in
   resetVideoElement and right before video.src = httpsUrl). A leftover
   crossOrigin="anonymous" from a prior WebTorrent attempt on the reused
   <video> forced a CORS request that CDNs / redirect targets (e.g. archive.org
   storage nodes) reject, failing with MEDIA_ERR_SRC_NOT_SUPPORTED ("source not
   supported or blocked"). Direct <video> playback never needs CORS, so any URL
   now plays.

2. CDN-first for dual-source: when a hosted URL exists, play it first for
   instant, reliable startup; WebTorrent is reserved for URL-less videos (and
   explicit forcedSource: "torrent"). Previously a torrent-first instance made
   dual-source videos wait on a P2P cold start before falling back to the CDN.
   The torrent-first branch is now only reached without a URL, so it waits
   indefinitely for peers (no plan B) rather than on a fixed timeout.

Tests: hosted playback clears crossOrigin and plays; a torrent-first instance
plays the URL first when one exists and never calls WebTorrent; URL-less videos
still use WebTorrent. Spec-corrected two playbackService_order tests that
encoded the old torrent-first-for-dual-source behavior. All mutation-verified.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-18 12:15:38 -04:00
thePR0M3TH3ANandClaude Opus 4.8 4e5bfaa6af [playback] harden WebTorrent streaming + add forced-source test deep links
WebTorrent (handleTorrentStream) hardening:
- Resolve only when playback is actually viable (loadeddata/canplay/playing),
  not the instant streamTo() is called. A dead/too-slow swarm is no longer
  reported as success, so the caller's fallback can run.
- Pre-playback torrent errors reject (caller falls back); post-playback errors
  go to an onPlaybackError hook instead of a dead reject().
- Mid-stream stall watchdog surfaces an onStall hook ("waiting for peers").
- Fix a <video> listener leak: stream listeners are tracked and torn down
  before the next stream and on cleanup() (the modal reuses one element).
- Stream the largest decodable file from a multi-file torrent and deselect the
  rest (wider ext set: mp4/m4v/webm/mkv/mov/ogv/ogg) so the swarm isn't spent
  downloading files we never play.
playbackCoordinator wires onStall/onPlaybackError to user-facing status.

Forced-source deep links (test affordance):
- The main app honors ?v=<nevent>&playback=torrent|url (mirrors the embed
  player) to force a playback path; a missing source shows a clear message.
- The video share menu gains "Copy WebTorrent URL" / "Copy CDN URL" items that
  copy the forced-source deep link (disabled with a reason when unavailable).

Tests: webtorrent-handlers (resolve-on-readiness, pre/post-ready errors, stall
fires/clears, largest-file select/deselect, no listener accumulation),
playback-forced-source (param normalization), videoMenuRenderers (copy items +
availability). All mutation-verified.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-18 12:15:27 -04:00
thePR0M3TH3ANandClaude Opus 4.8 577ea5af15 [security] add a strict Content-Security-Policy to the HTML entry points
Defense-in-depth against XSS for a client that holds storage credentials in
memory. The critical directive is a strict script-src: 'self' + the hash of the
inline theme-bootstrap + the nostr-tools CDN fallbacks, with no 'unsafe-inline'
or 'unsafe-eval' (verified no bundle uses eval/Function/wasm). This blocks
injected script/object elements, inline handlers, and base-tag hijack, so an
injected payload cannot run and read the in-memory storage master key, the
signer, or DM plaintext. connect/img/media stay broad because relays, R2/S3
endpoints, and video/thumbnail URLs are user/content defined; worker-src allows
blob: for the WebTorrent module worker. Applied to index.html and embed.html.

Test (tests/csp-policy.test.mjs) locks the inline-script hash to script-src and
asserts script-src stays strict (no unsafe-inline/eval) with object-src 'none'
and base-uri 'self'.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-18 12:15:14 -04:00
thePR0M3TH3ANandClaude Opus 4.8 70ce597a86 [watch-history] activate fast cold-load render + background chunk backfill
Slice B part 2 (final). On a cold watch-history load (nothing cached to
show yet) the service now decrypts only the newest chunks first
(chunkDecryptLimit = 3) so the UI renders recent history immediately
instead of freezing behind a deep, monthly-chunked history — the original
NIP-46 symptom. If older chunks were deferred, a single background pass
then backfills them (no chunk cap; already-decrypted chunks are served
from cache, so only the deferred older chunks cost signer RPCs) and emits
"fingerprint", which the history view already re-renders on. The serial
NIP-46 request queue paces the backfill, so there is no rate-limit flood.

Scoped to cold loads only: a warm refresh (items already on screen)
resolves the full history directly, avoiding a transient "history shrank
then grew" flicker. No view / feed-engine changes and no change to the
shared For You / explore paths beyond a fast first render then a fill-in.

Chosen over strict scroll-triggered lazy loading, which would have
required risky surgery on the history view's feed-engine + cursor
pagination and the shared loadLatest path pre-launch.

Tests: cold load passes a finite chunk limit on the first resolve, then an
unbounded background backfill runs and emits recent-only -> full; warm
refresh is unbounded. Both mutation-verified (removing the bound and
no-op'ing the backfill each fail).

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-17 18:23:10 -04:00
thePR0M3TH3ANandClaude Opus 4.8 362016ead6 [watch-history] thread chunkDecryptLimit through resolve + expose deferred count
Service-layer plumbing for bounded watch-history loading (slice B part 2).
manager.resolve() now forwards chunkDecryptLimit to fetch() and records the
resulting deferred-chunk count in a per-actor side-channel, so resolve() keeps
returning a bare items array (its sole caller depends on that) while the
"more older chunks available" signal is still retrievable. Exposed via
getWatchHistoryDeferredChunkCount() on the manager and the NostrClient facade.
clear() resets the side-channel.

Still inert by default (no caller passes a finite limit yet); this is the
substrate for activating fast-first-render. Tested: resolve under
chunkDecryptLimit:1 returns only the newest chunk's items and reports
deferredChunkCount=1, reset to 0 after clear().

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-17 14:49:40 -04:00
thePR0M3TH3ANandClaude Opus 4.8 11ff205fc0 [watch-history] add newest-first, budget-capped chunk decryption (foundation)
Foundation for bounded watch-history loading (slice B part 2). A deep
history references many monthly chunks; under a NIP-46 remote signer each
uncached chunk decrypt is a serialized published relay RPC, so decrypting
them all up front blocks the first render and risks the relay rate limit.

manager.fetch now:
- sorts chunk events newest-first by created_at, so the decrypt budget is
  spent on the most recent history;
- accepts a chunkDecryptLimit option that caps how many *uncached*,
  genuinely-encrypted chunks are decrypted per fetch. Cached/plaintext
  chunks are always decrypted (they cost no RPC) and never count against
  the limit. Default is Infinity -> no behavior change for existing
  callers;
- returns deferredChunkCount so a caller can tell more history is
  available on demand (re-fetching with a larger limit reuses the chunk
  cache, so only the newly requested older chunks cost RPCs).

Test: two uncached nip44 chunks (newest + older) with chunkDecryptLimit:1
asserts only the newest is decrypted (1 RPC), deferredChunkCount is 1, and
only the newest chunk's items appear. Mutation-verified against both
removing the cap and removing the newest-first sort.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-17 14:42:56 -04:00
thePR0M3TH3ANandClaude Opus 4.8 cd614876f5 [watch-history] strictly format-route decrypt to halve NIP-46 RPCs
Watch-history decryption previously ordered the matching cipher family
first but kept the other family as a *fallback*. The opposite family can
never decrypt a given ciphertext (NIP-04 always carries a literal "?iv="
marker; NIP-44 is plain base64), so that fallback was a guaranteed-wasted
decrypt attempt. Under a NIP-46 remote signer every decrypt is a
*published* relay RPC routed through the serial 250ms request queue, so a
transient failure on the correct family triggered a second wasted RPC
that burned the queue and pushed cold loads toward the relay rate limit
("noting too much") — the symptom users hit.

determineWatchHistoryDecryptionOrder now filters the attempt set to the
single family implied by the ciphertext shape (trusting the ciphertext
over a possibly-misleading "encrypted" tag), with a safety fallback to
the full ordered list when nothing matches so the only available
decryptor is never stranded. This mirrors the list-decrypt routing
already shipped for userBlocks / subscriptions / hashtags (slice A).

Test: a NIP-44 ciphertext mislabeled with an ["encrypted","nip04"] tag,
against a signer exposing both decryptors where nip04 returns decoy data.
Asserts nip04 is never attempted and the nip44 plaintext is used.
Mutation-verified: removing the filter fails the test.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-17 12:55:18 -04:00
thePR0M3TH3ANandClaude Opus 4.8 8ab8dde7a7 fix(nip46): route list decryption by ciphertext format (cut RPC fan-out)
Under a NIP-46 remote signer, every decrypt is a PUBLISHED relay RPC. The list
services (blocks/subscriptions/hashtags) probed all decryptor families
concurrently via Promise.any — so each list event published nip04 AND nip44 RPCs
at once. On login that flooded the relay ("rate-limited: you are noting too
much"), and the racing/queued RPCs blew past the 30s decrypt budget → the
"Decryption timed out" failures.

A NIP-04 payload carries the "?iv=" marker and can ONLY be decrypted by nip04;
otherwise it's nip44. The other family can never succeed on a given ciphertext,
so racing it is pure waste (and a wasted published RPC under NIP-46). Restrict
the decrypt probe to the family matching the ciphertext format (fall back to all
if the format is somehow unknown). Halves cold-load RPCs and stops the racing
publishes that tripped the rate limit; benefits all signer types.

Spec correction: the hashtag "retries decryptors when hinted scheme fails" test
stubbed an impossible scenario (the same ciphertext decrypting under nip04 after
nip44 failed). Rewritten to assert the real contract — the ciphertext format
routes decryption and overrides a misleading `encrypted` tag, invoking only the
matching decryptor.

test_integrity_note:
  change_type: ["spec_correction"]
  scenarios:
    - id: SCN-decrypt-format-routing
      given: "a nip04-format ciphertext (?iv=) tagged misleadingly as nip44"
      when: "a list service decrypts it"
      then: "only the nip04 decryptor is invoked; nip44 is never attempted"
  anti_cheat_rationale:
    prevents: ["over-mocking internal logic", "asserting impossible cross-family decryption"]
  relaxation:
    did_relax_any_assertion: false

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-17 12:13:58 -04:00
thePR0M3TH3ANandClaude Opus 4.8 ef66092727 fix(nip46): implement the nostrconnect:// (QR) connect-link login flow
The NIP-46 login dialog is purely the QR / connect-link flow ("scan with Amber/
NsecBunker"), but it failed with "A remote signer pubkey is required." A
nostrconnect:// link carries the CLIENT pubkey; the remote signer's pubkey is
only learned after it scans the QR and acknowledges — and the active connect
path (SignerManager) only supported bunker:// (pubkey already in the URI). The
"wait for the signer to acknowledge" logic existed only on the unused
Nip46Connector.

Port it into SignerManager as _waitForRemoteSignerHandshake: subscribe to
kind-24133 events addressed to the client pubkey, decrypt the signer's response,
match the handshake secret (or a generic ack), surface auth_url challenges, and
resolve with the signer's pubkey (with a timeout + a cancel hook wired into
disconnectRemoteSigner). connectRemoteSigner now performs this wait for
client-initiated (nostrconnect) connections before constructing the RPC client;
bunker:// is unchanged.

Decryption uses the existing attemptDecryptNip46HandshakePayload (injectable via
_decryptHandshakePayload for testing). Test: tests/nostr/nip46-wait-handshake.test.mjs
(secret match resolves with signer pubkey; wrong secret ignored -> timeout;
auth_url surfaced without resolving). Full suite green.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-17 07:01:39 -04:00
thePR0M3TH3ANandClaude Opus 4.8 3adf1be77d fix(nip46): enforce the access-control validator on remote-signer login
connectRemoteSigner and useStoredRemoteSigner accepted but never used the
`validator` the auth provider passes (accessControl.canAccess) -- so NIP-46
logins bypassed the block/allow gate that nsec/NIP-07 logins enforce. (The
validation lived only on the unused Nip46Connector.)

Both paths now run the validator against the remote signer's resolved user
pubkey BEFORE activating the signer (via _enforceRemoteSignerValidator): the
validator throws on a blocked/non-permitted pubkey, or returning false rejects
with code remote-signer-access-denied. A stored session rejected by the
validator is forgotten so a blocked pubkey can't keep retrying.

Residual (noted in TODO): the silent startup auto-restore
(scheduleStoredRemoteSignerRestore) still reconnects without a validator.

Test: tests/nostr/nip46-prepare-handshake.test.mjs (validator throws / false /
true / absent). Full suite green.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-17 06:44:47 -04:00
thePR0M3TH3ANandClaude Opus 4.8 92d8fabfe5 fix(nip46): implement prepareRemoteSignerHandshake (generate-connect-link login)
NIP-46 login via a generated connect link failed with
"nostrClient.prepareRemoteSignerHandshake is not a function". The auth provider
(authProviders/nip46.js) calls nostrClient.prepareRemoteSignerHandshake() to mint
an ephemeral client keypair + nostrconnect:// URI before connecting, but that
method existed only on the unused Nip46Connector class — neither SignerManager
nor NostrClient exposed it.

Add SignerManager.prepareRemoteSignerHandshake (generates the keypair via
nostr-tools, resolves relays, builds the nostrconnect:// URI with relay/secret/
perms/metadata params) and proxy it on NostrClient next to connectRemoteSigner.

Test: tests/nostr/nip46-prepare-handshake.test.mjs. Full suite green.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-17 06:35:11 -04:00
thePR0M3TH3ANandClaude Opus 4.8 93b3d6da22 test(s3): integration smoke for the upload -> publish -> cleanup round-trip
Drives the real s3UploadService.uploadVideo for a generic (vhost-style) S3
connection with only the S3 I/O mocked, then feeds the published note's URLs
into r2Service.deleteVideoStorage. Asserts the upload writes video + .thumb +
.torrent, the note is valid (URL + magnet with info-hash), and cleanup deletes
exactly those objects using a forcePathStyle-aware S3 client — i.e. the upload
key and the cleanup-derived key agree (the parity property).

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-17 06:23:06 -04:00
thePR0M3TH3ANandClaude Opus 4.8 fdebf05e1a feat(s3): bring generic S3 to parity with R2 (cleanup + CORS guidance)
Two gaps where generic S3 trailed Cloudflare R2:

1. Object cleanup on delete/edit didn't work for generic S3. deleteVideoStorage
   built its client via makeR2Client, which hard-forces path-style and ignores
   the connection's forcePathStyle -- so vhost-style S3 deletes were misaddressed
   and cleanup silently failed (S3 users' deleted/edited files orphaned). It is
   now provider-aware (mirrors uploadFile): R2 -> makeR2Client (path-style),
   generic S3 -> makeS3Client honoring forcePathStyle. makeS3Client is now
   injectable for testing.

2. CORS guidance on upload failure was R2-only. Extracted isLikelyCorsError +
   a generic-S3 guidance builder into js/utils/uploadErrorHints.js (re-exported
   from r2Service for back-compat); s3UploadService now appends actionable CORS
   guidance on an opaque "Failed to fetch" too.

Generic S3 is now at functional parity with R2 for upload -> publish -> cleanup.

Tests: r2-delete-video-storage (adds generic-S3 forcePathStyle routing),
s3-upload-cors-guidance (new). Full suite green.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-17 06:13:38 -04:00
thePR0M3TH3ANandClaude Opus 4.8 a7803c810b test(e2e): smoke the real upload -> publish -> feed -> delete pipeline
Existing upload/feed specs seed events straight into the relay, bypassing the
app's own publish path. This spec drives the actual UI: open the upload modal,
switch to External Link mode, fill title + URL, submit -> the app signs and
publishes to the mock relay -> the video appears in the feed; then the owner
deletes it via the gear menu and it leaves the feed.

Closes the launch-readiness coverage gap on the publish lifecycle (the S3/R2
file upload + cleanup are covered by the unit suites). Verified passing on
chromium (2/2).

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-17 05:56:38 -04:00
thePR0M3TH3ANandClaude Opus 4.8 7f5dcd7e50 fix(upload): attach CORS guidance to direct upload failures
A new user's top first-upload failure is a missing R2 CORS policy (browser
PUT/POST blocked) -- and object-scoped R2 tokens often cannot auto-set CORS.
The connection test already surfaces buildCorsGuidance on the opaque
"Failed to fetch", but a user who skips the test and uploads directly got a
bare "Upload failed: Failed to fetch". The upload error handler now detects the
browser CORS/network rejection (isLikelyCorsError -- Chrome "Failed to fetch",
Firefox "NetworkError", Safari "Load failed") and appends the same actionable
guidance. Updated the R2 follow-up TODO (credential audit done; CORS reviewed).

Test: tests/services/r2-cors-error-classifier.test.mjs. Full suite green.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-17 05:47:12 -04:00
thePR0M3TH3ANandClaude Opus 4.8 200e5cec80 test(storage): assert credentials never persist in plaintext at rest
Pre-launch credential-security regression: scans the ENTIRE serialized account
record (meta + encrypted blob) for the secret/access-key strings and asserts
they never appear in plaintext, that listConnections() doesn't expose them, that
they round-trip while unlocked, and that they're unreachable once locked. The
prior check only compared the cipher field to JSON.stringify(payload), which
wouldn't catch a secret leaking into plaintext meta.

Audit conclusion: credential storage is sound — envelope encryption (AES-GCM-256
payload, fresh per-encrypt IV; master key encrypted to-self via the Nostr signer,
NIP-44 preferred / NIP-04 fallback), in-memory master key cleared on logout, no
secret logging, legacy plaintext migrated then cleared.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-17 05:35:02 -04:00
thePR0M3TH3ANandClaude Opus 4.8 33c701aae8 feat(r2): clean up the superseded object when a video's URL is replaced on edit
Editing a video with a newly-uploaded file produced a new content-addressed
object and orphaned the old one. editModalController.handleSubmit now removes the
old object (best-effort) when the hosted URL was genuinely replaced — guarded so
it only fires when both old and new URLs are present and different (so clearing
the URL, which may still be referenced by a magnet web-seed, never deletes it),
and it deliberately leaves thumbnails alone (the new note may still point at
them). Reuses R2Service.deleteVideoStorage (owned-objects-only, never throws).

Documented the cleanup contract in AGENTS.md §4a.

Test: tests/ui/edit-modal-storage-cleanup.test.mjs (3). Full suite green.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-16 22:19:01 -04:00
thePR0M3TH3ANandClaude Opus 4.8 95b51b6e61 feat(r2): delete the underlying storage objects when a video is deleted
Deleting a video removed the Nostr note but left the actual file (plus its
thumbnail and .torrent) publicly downloadable at its R2/S3 URL forever — a
privacy gap and ongoing storage cost. Now the delete flow also removes the
backing objects, best-effort.

- collectVideoStorageKeys() (storagePointer.js): pure helper that derives the
  object keys (video + sibling .torrent + thumbnail) from a note, ONLY for URLs
  under the owner's bucket base — external/3rd-party URLs are never returned.
- R2Service.deleteVideoStorage(): resolves the owner's connection, and deletes
  those keys. Skips cleanly when storage is locked / no creds / no owned
  objects, and never throws (failures are recorded, not propagated) so storage
  cleanup can't block note deletion. R2Service class now exported for testing.
- app.handleDeleteModalConfirm: calls it after handleFullDeleteVideo succeeds.

Tests: tests/storage-key-collection.test.mjs (4),
tests/services/r2-delete-video-storage.test.mjs (4). Full suite green.

Note: superseded versions orphaned by past edits aren't retroactively cleaned;
edit-time cleanup (next) prevents new orphans going forward.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-16 22:09:57 -04:00
thePR0M3TH3ANandClaude Opus 4.8 ac1dcff184 fix(publish): flag + log unconfirmed (optimistic) relay publishes
publishEventToRelay optimistically reports success when a relay handle exposes
no ok/failed event and isn't thenable (legacy 'seen'-only relays). That made an
UNCONFIRMED publish indistinguishable from a real ack, so a note could show
'Published' with nothing actually stored. The result now carries optimistic:true
for that path (callers can distinguish it from a confirmed ack) and logs a
dev.warn. Behavior is otherwise unchanged (still treated as success for legacy
relays). Publish audit #3.

Test: tests/nostr-publish-optimistic.test.mjs.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-16 20:28:27 -04:00
thePR0M3TH3ANandClaude Opus 4.8 3d6ffe997b fix(upload): surface thumbnail/.torrent upload failures to the user
These optional-asset uploads are non-fatal, but failures were warn-only — the
user got a silently-missing thumbnail or torrent with no signal. Both upload
services now emit a 'warning' status at the point of failure and append a note
to the final 'Published' status (downgraded to warning variant) listing what
failed. Cloudflare-upload audit #5.

Test: tests/services/s3-upload-optional-asset-failures.test.mjs.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-16 20:19:27 -04:00
thePR0M3TH3ANandClaude Opus 4.8 04ae0ec4e6 fix(upload): fail fast on 0-byte files in multipartUpload
A 0-byte file produced a multipart upload with zero parts, which S3/R2 reject
with a cryptic CompleteMultipartUpload error. Guard file.size === 0 up front
with a clear message, before any S3 request is issued. Cloudflare-upload #4.

Test: tests/multipart-empty-file-guard.test.mjs.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-16 20:15:17 -04:00
thePR0M3TH3ANandClaude Opus 4.8 6b603cc21c fix(upload): surface an 'Analyzing' status during pre-upload file hashing
Computing the torrent info-hash (and the content-hash fallback) reads the entire
file before the upload starts and before any progress is emitted, so on
multi-GB videos there was a long silent pause that looked like a freeze. Both
upload services now emit an 'Analyzing video…' status when a full-file hash is
required (i.e. no valid info-hash was pre-supplied). Cloudflare-upload audit #2.

Test: tests/services/s3-upload-analyzing-status.test.mjs.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-16 20:14:21 -04:00
thePR0M3TH3ANandClaude Opus 4.8 066d98febb fix(upload): content-address storage keys to stop URL-first overwrites
buildR2Key fell back to a static "uploads" namespace (and "video" slug) when
no torrent info-hash was available, so two distinct URL-first uploads sharing a
filename produced the SAME R2/S3 key — the second silently overwrote the first
object while the first video note still pointed at that URL (data loss).
WebTorrent uploads were already safe because the info-hash namespaces the key.

Add computeStorageContentHash(file) in js/r2.js: when no info-hash is present it
derives a deterministic content-based namespace — full SHA-256 of the bytes for
files ≤512 MB (idempotent: re-uploading the same file reuses the key), and a
SHA-256 fingerprint over name/size/mtime + sampled head/tail bytes above that,
so we never buffer a multi-GB video in memory. Wired into BOTH upload paths
(r2Service + s3UploadService) via a new storageIdentifier kept separate from the
info-hash, so magnet generation is unchanged.

Test: tests/storage-key-collision.test.mjs (distinct same-named uploads get
distinct keys; same content is idempotent; large-file fingerprint path samples
edges without buffering and stays deterministic). Full unit suite green.

test_integrity_note:
  change_type: ["new_tests"]
  scenarios:
    - id: SCN-upload-key-no-collision
      given: "a URL-first upload with no torrent info-hash"
      when: "the storage key is derived (computeStorageContentHash -> buildR2Key)"
      then: "distinct same-named files get distinct keys, same content is idempotent, and no key uses the colliding 'uploads' namespace"
  observable_outcomes:
    - "returned key strings for real Blob/File content and a fake >512MB file"
  determinism_controls:
    - "fixed byte content; fake large file with stubbed slice() and fixed lastModified"
  anti_cheat_rationale:
    prevents:
      - "hard-coded return value"
      - "over-mocking internal logic"
  relaxation:
    did_relax_any_assertion: false

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-16 16:56:12 -04:00
thePR0M3TH3ANandClaude Opus 4.8 bf30233649 fix(dm): route Actions calls to the correct controller (open conversation, etc.)
The controller→Actions split left a batch of method calls in
ProfileDirectMessageActions pointing at `this` (the Actions instance)
instead of the object that actually defines them. Opening a conversation
threw "this.setFocusedDmConversation is not a function"; the same latent
bug also broke DM send-error messaging, read receipts, settings publish,
and the whole attachment flow (generate/render/reset/upload) — the user
just hadn't exercised them yet.

Route each to its real receiver:
- DM-controller methods → this.controller.*:
  handleDmSettingsPublish, describeDirectMessageSendError,
  maybePublishReadReceipt, setFocusedDmConversation
- main-controller methods → this.mainController.*:
  generateAttachmentId, renderAttachmentQueue, resetAttachmentQueue,
  uploadAttachmentQueue, clearProfileMessages

(Builds on the earlier handleDmConversationSelect delegation fix.) A static
sweep confirms every remaining `this.*` call in Actions resolves to an
Actions method or an explicit controller/mainController/helper receiver.

Test: tests/dm-conversation-select.test.mjs (cheat-resistant — drives the
real handleDmConversationSelect with stub receivers; would throw on the old
wrong-receiver code). DM unit + integration suites green; full build OK.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-16 15:07:14 -04:00
thePR0M3TH3ANandClaude Opus 4.8 7951fd0f09 fix(dm): can't open conversations + "helpers unavailable" on a working signer
After switching to a responsive signer (nos2x), two DM-specific bugs surfaced.

1. Clicking a conversation in the list threw
   "this.controller.handleDmConversationSelect is not a function"
   (ProfileDirectMessageRenderer onSelectConversation). The method exists on
   ProfileDirectMessageActions and the controller delegates ~15 sibling Actions
   methods — but the handleDmConversationSelect delegation was missing. Add it.

2. Loading a conversation's messages threw "DM decryption helpers are
   unavailable" even though the signer decrypts blocks/subscriptions/hashtags
   fine. buildDmDecryptContext gated decryptor registration on (a) the dynamic
   `capabilities` getter (which probes window.nostr and false-negatives on lazy
   injection / nos2x's module shape) and (b) the permission-probe result. The
   list services already learned to register by METHOD EXISTENCE and validate at
   call time; align the DM path to do the same so a present, working signer
   never yields zero decryptors. The permission probe still runs.

Tests: tests/dm-decrypt-context.test.mjs (cheat-resistant — a signer whose
capabilities getter reports false and whose permission probe returns ok:false
must still register nip04+nip44 decryptors). Full unit + dm:unit + dm:integration
suites green. KNOWN_BUGS #0.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-16 14:52:11 -04:00
thePR0M3TH3ANandClaude Opus 4.8 8f39b2c479 feat(signer): surface an actionable notice when the signer goes unresponsive
A raw window.nostr probe in the user's browser (getPublicKey → nip04.encrypt
→ nip04.decrypt, bypassing ALL of bitvid) HUNG forever, with the extension's
own console showing `ObjectMultiplex - orphaned data for "background-liveness"`
+ `malformed chunk` + listener-leak warnings. That is the NIP-07 extension's
content-script ↔ background-service-worker channel being dead (classic MV3
worker death) — an extension/environment failure, not a bitvid bug. No client
change can force an unresponsive signer to answer; the fix is to reload/unlock
the extension.

What bitvid CAN do is stop failing silently. Add js/utils/signerHealthNotice.js:
after a few consecutive list-decrypt timeouts while the signer reports itself
present, emit ONE rate-limited, actionable user notice ("your signer isn't
responding to decryption — unlock/reload the extension and refresh") instead of
the silent 15-minute retry loop. Reset on any decrypt success so a transient
blip doesn't warn. Wired into blocks, hashtags, and subscriptions.

Also documents the definitive root-cause + all resilience increments in
KNOWN_BUGS #0. Test: tests/signer-health-notice.test.mjs (threshold, rate-limit,
success-reset). Full unit suite green.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-16 14:18:46 -04:00
thePR0M3TH3ANandClaude Opus 4.8 10ea4f6a22 fix(subscriptions): guard the subscription-list refresh loop
The live subscription-list subscription (kind 30000) delivers our OWN list
event back through handleSubscriptionListEvent, which called updateFromRelays
on EVERY event. updateFromRelays re-fetches the list, the relay echoes the
same event through the live sub, and the handler fires again — a
self-sustaining loop that floods the cold-login burst with redundant
kind-30000 REQs (the kind 30000=19 storm in the real-env console,
KNOWN_BUGS #0). Same shape as the contact-list emit→fetch→emit loop fixed
earlier.

Add an emit-on-change guard: skip the refresh when the event is one we've
already acted on (by id — matters during the pre-decrypt login window where
subsEventCreatedAt isn't set yet) or is not newer than what we've applied.
A genuinely newer list event still refreshes exactly once.

Also extend the channel-sim harness to capture per-filter REQ signatures and
kind-30000 REQ timing, which is how this loop was isolated.

Test: tests/subscriptions-manager.test.mjs — echo of the same event id does
not re-refresh; an older event is ignored; a newer event refreshes once.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-16 09:53:52 -04:00
thePR0M3TH3ANandClaude Opus 4.8 ff1afa1175 fix(nostr): count 'message channel closed' toward the circuit breaker
Real-env console (2026-06-15) showed the breaker never opening even as
decrypts failed for minutes. Cause: the breaker treated every non-timeout
error as "channel alive" and RESET the timeout streak — but
"message channel closed before a response was received" is the exact
dead-channel signal, and it rejects as a (fast) non-timeout error. So each
such rejection reset the counter and the circuit could never open.

Classify channel-death messages (message channel closed / could not
establish connection / receiving end does not exist / extension context
invalidated / connection lost) as channel failures that count TOWARD
opening the circuit, instead of as responsive failures that reset it.
Genuine extension responses (user rejection, permission denied, etc.)
still reset the streak.

Test: extends nip07-channel-breaker with a synchronous channel-closed op
asserting consecutive channel-death errors open the circuit and then
fast-fail (no waits).

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-15 09:11:40 -04:00
thePR0M3TH3ANandClaude Opus 4.8 ab57f8d596 fix(nostr): circuit-break the dead nip-07 signer channel
Real-env console (2026-06-15) showed that once the extension's message
port drops under the post-login burst ("message channel closed"), EVERY
subsequent decrypt call hangs to its full ~15s timeout. Each list service
(blocks, hashtags, subscriptions, watch history) independently retries on
that timeout, so the app sits in a permanent loop of 15s-blocking
extension calls — pinning the CPU ("fans at max") and never recovering
until a manual page refresh.

Add a shared circuit breaker at the runNip07WithRetry chokepoint:
- After CIRCUIT_TIMEOUT_THRESHOLD (3) consecutive timeouts the circuit
  OPENS; further calls fail fast with code "nip07-channel-unresponsive"
  (no multi-second hang), so the retry loops become cheap and the CPU
  settles.
- While open, a single probe per CIRCUIT_PROBE_INTERVAL_MS is allowed
  through; one success CLOSES the circuit and normal decryption resumes —
  no page refresh required.
- Responsive failures (extension answered with an error) reset the
  timeout streak — only genuine channel hangs open the circuit.
- Interactive permission prompts bypass the breaker entirely: a user
  taking their time at the extension UI can neither trip it nor be
  blocked by it, and such a prompt can itself heal the channel.

Increment 2 of the "cut the login burst, then add a safety net" work for
KNOWN_BUGS #0. Test: tests/nostr/nip07-channel-breaker.test.mjs
(cheat-resistant — drives the real queue with hanging/responsive/success
ops; asserts open-after-threshold, fast-fail, probe-recovery, responsive
errors don't open, and bypass is never fast-failed). Full unit suite green.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-15 08:49:05 -04:00
thePR0M3TH3ANandClaude Opus 4.8 71cd04b215 perf(login): stop eagerly loading DM history at login
Real-env console (cold nip-07 login) showed list decryption failing en
masse: blocks/hashtags/subscriptions fetched fine (relay cap working —
every sub now fans out to exactly 8 relays) but decryption timed out for
minutes with "message channel closed", only recovering on a page refresh.

Root cause: the single-threaded nip-07 extension channel is overwhelmed
during the post-login handshake. One contributor is the eager DM load —
loadDirectMessages({ limit: 50, initialLoad: true }) ran at login and
fired a burst of decrypts that competed with the feed-driving lists
(blocks/subscriptions/hashtags) for the same channel. DMs are lower
priority (the user isn't viewing Messages at login) and already load
lazily when the Messages tab opens.

Defer the eager DM load; report dms state as "deferred". The lightweight
unread-indicator refresh still runs so the badge stays accurate. First
increment of the "cut the login burst" work (KNOWN_BUGS #0).

Test: tests/app/login-defers-dms.test.mjs (cheat-resistant — asserts
loadDirectMessages is never called at login, dms state is "deferred", and
the unread indicator still runs).

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-14 23:16:40 -04:00