Commit Graph
6352 Commits
Author SHA1 Message Date
thePR0M3TH3ANandClaude Opus 4.8 e27c2fae39 refactor(hashtags): extract pure helpers from hashtagPreferencesService (2/10)
File-size decomposition (no behavior change). Move the pure, state-free helpers
isTransientDecryptError, computeRetryDelay (+ their transient-error tables and
MAX_DECRYPT_RETRY_DELAY_MS) and normalizeHexPubkey out into a new
js/services/hashtagPreferencesHelpers.js and import them back. HEX64_REGEX is now
imported by the helper module instead of the service.

hashtagPreferencesService.js: 1541 -> 1480 lines (back under its 1484 limit).
Baseline left at 1434 (file fits its original allowance again; not bumped up).

Build + full unit suite green.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-19 11:09:34 -04:00
thePR0M3TH3ANandClaude Opus 4.8 76ba750708 refactor(playback): extract playback helpers to shrink playbackService (1/10)
File-size decomposition (no behavior change). Move the self-contained
SimpleEventEmitter and extractWebSeedsFromMagnet (+ its WEBSEED_PARAM_KEYS and
the now-sole safeDecodeURIComponent consumer) out of playbackService.js into a
new js/services/playbackHelpers.js and import them back.

playbackService.js: 1350 -> 1274 lines (now under its original 1289 baseline).
Re-recorded the smaller baseline in check-file-size.mjs to lock the reduction.

Build + full unit suite green.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-19 11:04:16 -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 c5a91cb4d2 [docs] mark slice B part 2 (bulk incremental decrypt) complete
Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-17 18:23:27 -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 5db18c9bd0 [docs] record slice B findings: DMs already optimal, watch-history routed
Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-17 12:55:41 -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 d143883e9c docs(todo): record NIP-46 decrypt slice A done + slice B (bulk DM/history) open
Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-17 12:14:21 -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 c2228f3f4f docs(todo): R2 system follow-up backlog
Captures remaining R2 work after the upload audit + object-cleanup increments:
credential-security review, bucket/custom-domain provisioning audit, CDN purge
integration, and follow-ups to the cleanup work (generic-S3 cleanup, all-versions
delete cleanup, edit thumbnail cleanup). Plus the Vercel production-branch parking
lot item.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-16 22:21:32 -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 96302a59f3 chore: remove dev Agent Dashboard sidebar link + view constant
Follow-up to c79120ba (which only captured the file deletion): removes the
sidebar nav link and the unused DEV_DASHBOARD view constant. No dangling refs.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-16 20:36:27 -04:00
thePR0M3TH3ANandClaude Opus 4.8 43c843dbae build(vercel): no-cache headers for HTML + service worker
Adds Cache-Control: no-cache,must-revalidate for the entry HTML (/ , *.html)
and sw.min.js so Vercel-served domains (and any CDN/browser in front) always
revalidate the shell and pick up new deploys, mirroring netlify.toml. Hashed
JS/CSS keep far-future caching via their content-hashed filenames.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-16 20:35:58 -04:00
thePR0M3TH3ANandClaude Opus 4.8 c79120ba04 chore: remove dead dev 'Agent Dashboard' view (embedded external TORCH)
Drops views/dev/agent-dashboard.html (iframed the external TORCH dashboard,
now defunct), its sidebar nav link, and the unused DEV_DASHBOARD view constant.
No dangling references; build green.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-16 20:35:58 -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 c1ae810776 chore: remove TORCH subsystem (no longer used)
Removes the TORCH task-locking/scheduler-memory subsystem in full:
- delete torch/ (4900+ files), torch-config.json, and tracked .scheduler-memory/
- drop torch:* npm scripts and the torch-lock dependency; regenerate
  package-lock.json (only torch-lock removed)
- strip the dead "TORCH Memory Integration" / "Task Claiming Protocol (TORCH)"
  sections from AGENTS.md and CLAUDE.md (agent coordination is now PR-based) and
  the torch/TORCH.md doc-table row
- drop the stale `torch` ignore entries from check-hex / check-inline-styles /
  check-design-tokens

Build + touched lints pass. Note: views/dev/agent-dashboard.html still embeds
the external TORCH dashboard URL — left in place (separate dev-tools view).

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-16 20:11:26 -04:00
thePR0M3TH3ANandClaude Opus 4.8 1d7808809e build(vercel): pin npm install command (avoid pnpm auto-detect)
Both package-lock.json and pnpm-lock.yaml are present, so Vercel auto-selected
pnpm, whose install failed on the new bitvid-unstable/bitvid-beta projects (the
existing bitvid project only worked via a dashboard install-command override).
Pinning installCommand makes every project/branch build with npm regardless of
dashboard settings.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-16 20:01:00 -04:00
thePR0M3TH3ANandClaude Opus 4.8 134f60dfd3 chore: gitignore .vercel CLI link directory
Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-16 19:50:55 -04:00
thePR0M3TH3ANandClaude Opus 4.8 4c5465eb35 build: auto-stamp service worker cache version with the build hash
The SW cache key (bitvid-sw-YYYY-MM-DD) and the informational meta in
index.html/embed.html were hand-bumped, so client caches only invalidated when
someone remembered to change the date — stale app shells (and stale footer
versions) persisted across deploys.

build-dist now stamps the dist artifacts with `bitvid-sw-<buildDate>-<hash8>`
(same content hash shown in the footer), so every content change yields a new
cache name and the SW's activate step drops the old cache automatically. Only
the dist output is rewritten — the committed (minified) sw.min.js source is left
as-is, so the sw-compat guard and tests that reference the source value are
unaffected. Build verified: all three dist files share one stamped version,
dist/sw.min.js still parses, verify:dist:deploy-artifact passes.

Also confirmed `npm run build` is reproducible (same inputs => same hash), so
deploys are deterministic.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-16 19:42:47 -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 0d6af82b30 docs(agents): capture NIP-07 reliability + upload-path lessons
- AGENTS.md §4a: Cloudflare/S3 upload-path gotchas from the 2026-06-16 audit
  (r2Service/s3UploadService duplication, buildR2Key collision = data loss,
  whole-file hash before upload, optimistic publish success, silent partial
  failures / empty-file guard).
- AGENTS.md §17 (new, appended so existing section numbers/cross-refs stay
  valid): NIP-07 Signer Reliability & Encrypted-List Loading — raw-probe
  diagnostic + the resilience invariants (relay cap, circuit breaker, no
  transient-error swallowing, decrypt budget, lazy modal population) and the
  deterministic fake-signer + mock-relay test harness.
- CLAUDE.md: matching condensed "NIP-07 Signer Reliability (hard-won)"
  subsection under Nostr Integration, cross-linking AGENTS.md §17 and
  KNOWN_BUGS #0.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-16 16:36:35 -04:00
thePR0M3TH3ANandClaude Opus 4.8 8022942464 docs: record nip-07 list-decrypt + post-login freeze resolution
- README "Login with Nostr": recommend a well-maintained NIP-07 signer
  (nos2x/Alby) and add a troubleshooting note for the locked/asleep-signer
  case (unlock/reload + refresh), since a flaky extension was the root cause
  of the encrypted-list load failures.
- KNOWN_BUGS #0: mark RESOLVED (2026-06-16) — confirmed in real env after the
  user switched to nos2x; DMs/watch-history/lists decrypt in ms with no
  timeouts. Documented the DM decrypt-resilience + receiver fixes and the
  post-login profile-panel freeze fix (defer panel rendering to selectPane).
  Recorded the remaining low-priority follow-ups (liveness-ranked relays,
  opt-in queue concurrency, optional DM double-decrypt routing).
- Retitle the section "## Open" → "## Tracked" so resolved entries (#0/#1/#2)
  aren't mislabeled as open; status is now per-entry.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-16 16:23:17 -04:00
thePR0M3TH3ANandClaude Opus 4.8 a1a8f6cda6 perf(profile): defer profile-panel rendering until the modal opens
Post-login the site froze for ~10-15s and the profile panel wouldn't open.
Cause: profileModalController.handleAuthLogin eagerly rendered EVERY profile
panel at login (twice) — friends list + per-contact avatar fetches,
subscriptions, blocks, relays, wallet, hashtags, storage — into a CLOSED
modal. With a responsive signer (nos2x) making decryption instant, that all
fired in one main-thread burst right after login. The block- and
subscription-"change" listeners and the moderation "contacts" listener also
re-rendered their panels unconditionally as that data loaded, compounding it.

selectPane() already lazily populates each pane when it's shown, so the eager
pass was redundant. Changes:
- handleAuthLogin: only run the full panel population when the modal is
  already open (e.g. account switch with it visible); otherwise rely on
  open()/selectPane. Kept the cheap DM-identity setup eager.
- Gate the userBlocks "change", subscriptions "change", and moderation
  "contacts" re-renders on isProfileModalOpen() so they don't paint into a
  closed modal during the post-login data burst.
- Add a "friends" case to selectPane() (the only pane without a lazy-populate
  trigger) so it still populates on open now that login doesn't.
- Add isProfileModalOpen() + populateOpenProfilePanels() helpers.

All panes remain covered (account is static; admin refreshes independently).
Full build + unit suite green. KNOWN_BUGS #0 (post-login freeze).

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-16 16:06:26 -04:00
thePR0M3TH3ANandClaude Opus 4.8 4f7ef08935 fix(feed): render the grid after the disclaimer is dismissed (logged-out)
KNOWN_BUGS #1: logged out, the video grid stayed empty after dismissing the
disclaimer until a manual page refresh.

Reproduced deterministically with scripts/perf/disclaimer-grid.mjs (fresh
context, real relays) once it actually waits for and dismisses the
disclaimer: post-dismiss card timeline was 0,0,0,… and only a reload
yielded 132 cards.

Root cause: bootstrapInterface (js/index.js) opens the disclaimer — which
makes the background inert via staticModalAccessibility — and THEN awaits
handleHashChange(), which loads the feed view and renders into that
inert/hidden background, so the initial grid mount/render is dropped.
Dismissing the disclaimer never re-triggered a render.

Fix: disclaimer.js hide() now dispatches a `bitvid:disclaimer-dismissed`
event; index.js listens once and calls application.loadVideos(true) so the
feed re-renders into the now-interactive background. Only fires when a shown
disclaimer is dismissed, so returning visitors (who never see it) get no
redundant reload.

Harness after fix: post-dismiss timeline 132,132,… immediately. Full build
+ unit suite green.

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