Commit Graph
6423 Commits
Author SHA1 Message Date
thePR0M3TH3ANandClaude Opus 4.8 2a979c2eca docs(deploy): record release-channel → Vercel project promotion + on-demand deploy
Captures the 3-project mapping (bitvid-unstable/beta/bitvid → unstable.bitvid.network
/ bitvid-beta.vercel.app / bitvid.network), the vercel.json knobs (npm install pin,
deploymentEnabled.unstable), the promotion git steps (merge unstable→beta taking
unstable's tree, ff main), per-project on-demand deploy commands, and the
version-marker verification + soak-skip caveat.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-21 22:23:32 -04:00
thePR0M3TH3ANandClaude Opus 4.8 0ec9db70e8 fix(zap): receipt validation works with nostr-tools 2.x SimplePool
After a successful split zap, validation errored: "Unable to initialize a relay
pool for receipt validation." The validator built a fresh `new SimplePool()` and
required `pool.list()`, but nostr-tools 2.x removed SimplePool.list() (the rest of
the app only has it because js/nostr/toolkit.js shims it). So validation always
failed even though the payment went through.

Fix: resolveReceiptListPool() applies the app's legacy .list shim
(shimLegacySimplePoolMethods) to the freshly created pool, and the gate now
accepts a listEvents override instead of hard-requiring pool.list.

tests/zap-receipt-pool.test.mjs: 2.x pool gets .list shimmed; createPool override
shimmed; listEvents override leaves the pool as-is; null when neither available.
Mutation-verified (removing the shim fails it).

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-21 22:11:46 -04:00
thePR0M3TH3ANandClaude Opus 4.8 bb33b12649 fix(auth): anonymous view-event session actor no longer reports you as logged out
After watching/opening a video, zapping showed "Log in and connect a wallet"
even though logged in. Recording a view creates an anonymous session actor with
source "session" (SignerManager.ensureSessionActor). isUserLoggedIn() treated any
declaredSource on a different-pubkey session actor as a managed alternate
identity and returned false — so the view-event telemetry actor logged the user
out app-wide (zap gate, etc.).

Fix: "session" is the anonymous telemetry source, not a login identity — only a
managed source (e.g. "nsec") for a different pubkey disqualifies. (Commit
7985a481 intended to allow anonymous actors but only covered the sourceless
shape; the real actor carries source "session".)

tests/app/is-user-logged-in.test.mjs: added the real view-event actor shape
(source "session", different pubkey) → logged in; the "nsec" rejection case still
holds. Mutation-verified.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-21 21:52:52 -04:00
thePR0M3TH3ANandClaude Opus 4.8 dec5cf8fe1 feat(sync): warn before overwriting a newer remote copy (conflict guard)
Saves push silently as before, but if the relay copy is NEWER than the one this
device last pushed (another device changed it), the user is now asked to confirm
before overwriting — preventing a silent multi-device clobber. No nag on normal
saves.

- js/services/settingsSyncFlags.js: track per-pubkey+kind last-pushed created_at
  (getSyncPushedAt/setSyncPushedAt).
- js/services/syncConflict.js: pushWithConflictCheck — peek remote created_at
  (list-only, no decrypt), compare to last push, call confirmOverwrite only on a
  real conflict, then push strictly-newer than remote so the replace wins.
- storage/walletSyncService push()/enable() route through it and accept a
  confirmOverwrite callback.
- ProfileStorage/WalletController supply confirmOverwrite (window.confirm) and
  surface a "kept the newer copy" status when the user declines.

tests/sync-conflict.test.mjs: no-remote→silent, newer→prompt(+decline blocks
push), confirm→strictly-newer push, older→no prompt. Mutation-verified
(ignoring the decline fails). Service-test twins gained exists().

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-21 21:21:54 -04:00
thePR0M3TH3ANandClaude Opus 4.8 c829a42a45 fix(wallet): testing the connection no longer wipes an unsaved typed URI
"Test connection" then "Save" removed the wallet. handleWalletTest re-rendered
the pane (refreshWalletPaneState) in its finally, which repopulates the form from
SAVED settings — empty for a URI you typed but hadn't saved — wiping the field.
The next Save then saw an empty field and took the "removed" branch.

Test must preserve what the user entered: refresh only the button state
(applyWalletControlState), not the whole form.

tests/wallet-test-preserves-input.test.mjs: a Test of an unsaved typed URI keeps
it in the field. Mutation-verified (restoring refreshWalletPaneState fails it).

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-21 21:07:00 -04:00
thePR0M3TH3ANandClaude Opus 4.8 c26ef89a93 fix(sync): empty wallet save must not wipe the synced copy on relays
Data-loss bug: on a fresh device the user opened the Wallet pane (field empty,
wallet not yet restored), clicked Save, and the "removed" branch called
walletSync.disable() — publishing a {cleared:true} note that destroyed the good
encrypted copy another device relied on. (Confirmed in logs: a kind-30078
"bitvid:nwc" {cleared:true} event signed right after login.)

An empty save happens routinely (saving before hydration/restore), so treating it
as a deliberate disconnect-that-wipes-the-remote is wrong. Removed the auto-clear
from the empty-save branch: the remote synced note is now changed ONLY via the
explicit sync toggle (enable/disable) or a successful save (push). An empty save
clears just the LOCAL wallet.

tests/wallet-sync-no-clobber.test.mjs: empty save does not call disable/push; a
real save still pushes. Mutation-verified (re-adding the disable fails the test).

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-21 20:52:20 -04:00
thePR0M3TH3ANandClaude Opus 4.8 234f57e009 docs(todo): mark encrypted settings sync (#15) complete
Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-21 18:09:50 -04:00
thePR0M3TH3ANandClaude Opus 4.8 4186b4c4ba feat(sync): offer-to-pull encrypted settings on login (one-time, non-blocking)
Completes todo #15. On login, if an encrypted copy of the user's storage and/or
wallet settings exists on their Nostr account — and this device hasn't already
opted in — offer to restore it.

- js/nostr/encryptedSync.js: exists(dTag) — cheap list-only existence peek (NO
  decrypt, so it doesn't spend the signer budget); decryption only on accept.
- storage/walletSyncService: hasRemote() wrappers over exists().
- js/services/settingsRestorePrompt.js: one-time per-pubkey offer. Only prompts
  for items with a remote copy not already enabled locally; if nothing remote
  exists it does NOT consume the one-time offer (a note published later can still
  prompt next login). On accept, pulls + applies.
- app.js handleLoginModalSuccess: fires maybeOfferSettingsRestore(pubkey) after
  login, deferred ~2s so relays connect; best-effort, never blocks login; toasts
  on restore.

tests/settings-restore-prompt.test.mjs: offer+restore-only-remote-items, decline,
prompt-once, skip-already-enabled, don't-consume-when-empty. Mutation-verified.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-21 18:08:43 -04:00
thePR0M3TH3ANandClaude Opus 4.8 d552e9017c feat(sync): NWC wallet sync (opt-in, spend-gated) + shared opt-in flags
Adds encrypted cross-login sync for the NWC wallet connection (todo #15),
reusing the encrypted-sync core under d-tag "bitvid:nwc".

- js/services/settingsSyncFlags.js: shared per-pubkey opt-in flag store
  (storage/wallet); storageSyncService refactored onto it (identical behavior,
  tests still green).
- js/services/walletSyncService.js: factory (NwcSettingsService is per-app, not a
  singleton). push() syncs the current nwcUri (+defaultZap); pull() applies it via
  updateActiveNwcSettings; disable() wipes the note.
- Wallet pane (components/profile-modal.html + ProfileWalletController): opt-in
  toggle + Restore button + a strong SPENDING warning, shown only when a signer
  can encrypt. Enabling requires an explicit window.confirm (the URI can spend).
  Re-pushes on save when enabled; and on wallet DISCONNECT while synced it clears
  the note so a removed spending URI doesn't linger (encrypted) on relays.

tests/wallet-sync-service.test.mjs: enable-pushes-uri, pull-restores-via-
updateActiveNwcSettings, disable-clears, no-wallet-doesn't-publish. Mutation-
verified (skip-apply + skip-clear both fail).

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-21 17:57:39 -04:00
thePR0M3TH3ANandClaude Opus 4.8 c651271af3 docs(todo): mark storage sync shipped; NWC + login-pull remain
Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-21 17:18:48 -04:00
thePR0M3TH3ANandClaude Opus 4.8 7376ff91e0 feat(sync): Storage pane UI for encrypted settings sync (opt-in toggle + restore)
Wires the storage-credential sync (6f7f4196) into the profile Storage pane.

UI (components/profile-modal.html): a "Cross-device sync" card shown only when
storage is unlocked — an opt-in toggle ("Sync these settings to my Nostr account
(encrypted)"), a "Restore from my Nostr account" button, the public-relay /
key-compromise warning copy, and a status line.

Controller (ProfileStorageController):
- toggle on -> storageSyncService.enable() (encrypt + publish), with rollback +
  message if nothing is stored yet; toggle off -> disable() (wipe the note)
- restore -> pull() + re-render the pane so the restored connection shows
- after a successful Save, re-push the synced copy when sync is enabled

Also routes the core's relay READ through the subscription manager (injected
listEvents) instead of pool.list, satisfying lint:pool-access for the new file;
the facade supplies it via nostrClient.getSubscriptionManager().list.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-21 17:17:35 -04:00
thePR0M3TH3ANandClaude Opus 4.8 6f7f419636 feat(sync): storage-credential sync data layer (push/pull/clear)
Wires the encrypted-sync core (0abb82b9) to the live client + active signer and
adds the storage-credential sync service. No UI yet — the Storage pane toggle is
the next increment.

- js/nostr/encryptedSyncFacade.js: default encryptedSync instance bound to
  nostrClient (write set via getDeletePublishRelays, read relays, pool) and the
  active signer's signEvent. Keeps the core pure/testable.
- js/services/storageService.js: exportAccountRecord / importAccountRecord — get
  the on-disk account record and restore it on another device. Import REPLACES
  the local record (same-pubkey envelope unlocks with the same signer); cross-
  device merge is a follow-up.
- js/services/storageSyncService.js: enable/disable/push/pull under d-tag
  "bitvid:storage-connections", with a per-pubkey opt-in flag (off by default).
  enable() pushes immediately; disable() wipes the published note.

tests/storage-sync-service.test.mjs: enable-pushes-exported-record, pull-restores
on a fresh device, disable-clears, empty-record-doesn't-publish. Mutation-verified
(skip-import and skip-clear both fail).

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-21 17:01:54 -04:00
thePR0M3TH3ANandClaude Opus 4.8 0abb82b933 feat(sync): encrypted cross-login settings sync core (NIP-78, opt-in)
Shared core for the opt-in encrypted sync of bitvid settings (storage creds, NWC
wallet) recorded in todo #15. Publishes settings as NIP-78 app-data (kind 30078)
REPLACEABLE events, NIP-44-encrypted to the user's own key (NIP-04 fallback when
the signer lacks NIP-44).

js/nostr/encryptedSync.js — createEncryptedSyncManager(deps):
  - isAvailable(): signer present + can encrypt to self
  - push(dTag, payload): encrypt-to-self -> publish to WRITE relays, created_at
    forced strictly-newer so a replace actually wins
  - pull(dTag): read newest-per-d-tag from read relays -> decrypt (a stale device
    cannot clobber newer settings)
  - clear(dTag): publish a cleared marker so the wipe propagates (empty-
    replaceable-publish lesson)

Dependency-injected (pure) so it unit-tests without a live signer/relay. No UI
wiring yet — no behavior change. Storage pane wiring is the next increment.

Security: encryption hides contents but not that this pubkey stores bitvid
settings (public relays); anyone with the user's key can decrypt; NWC URIs are
spending secrets (caller must gate behind a warning); never log decrypted data.

tests/encrypted-sync.test.mjs — round-trip, no-cleartext-leak, newest-wins,
clear, NIP-04 fallback. Mutation-verified (newest-selection + encryption-bypass
both fail the suite).

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-21 16:52:44 -04:00
thePR0M3TH3ANandClaude Opus 4.8 ebacf223c8 fix(watch-history): resolve freshly-watched videos from cache (default video kind in address lookup)
Watch-history cards rendered "Untitled video" / "Unknown" for newly watched
videos. nostrClient.allEvents stores CONVERTED video objects
(convertEventToVideo), which carry tags/pubkey but no `kind` field — only raw
relay events do. resolveEventAddress() did Number(event.kind) → NaN and bailed
with "", so the address (cache-by-"a"-pointer) scan matched nothing. Every video
pointer (all are "a" addresses) was forced to a relay round-trip, and when that
returned nothing the card showed Unknown — even though the just-watched video
was sitting right there in allEvents.

Fix: default to the video kind (30078) when a cached object has no `kind`, so the
local cache scan resolves freshly-watched "a" pointers without a relay round-trip.

Adds tests/watch-history-hydration-cache.test.mjs: relays return nothing, so a
pass can only come from the cache scan (mutation-verified against the NaN-reject
regression). Exports createWatchHistoryHydrationStage for boundary testing.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-21 14:10:21 -04:00
thePR0M3TH3ANandClaude Opus 4.8 48b6f10e0d fix(watch-history): record new watches under the logged-in identity, not the anonymous session actor
View events are always signed by the anonymous session actor (privacy for the
public view counter). publishView read the watched-actor from that view event's
pubkey, so a logged-in user's new watches were enqueued — and republished via
updateWatchHistoryList — under the SESSION actor. The read path queries
authors:[loggedIn] only, so those session-authored months were never
discovered: newly watched videos never appeared in the synced watch history
(e.g. a 2026-06 month that never showed up despite watching June videos).

Fix:
- publishView now resolves the watch-history actor to the logged-in pubkey
  first, falling back to the view event / session actor only when logged out.
- A watch is flagged session/local-only only when nobody is logged in
  (previously it was flagged whenever the view actor differed from the
  logged-in user, which was always true given anonymized views).

Adds tests/watch-history-records-under-login.test.mjs (mutation-verified: both
the actor-resolution and the session-flag fixes are independently load-bearing).

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-21 10:55:27 -04:00
thePR0M3TH3ANandClaude Opus 4.8 25c56fad48 docs(todo): record watch-history all-relays false-failure fix
Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-20 19:21:20 -04:00
thePR0M3TH3ANandClaude Opus 4.8 565a961823 fix(watch-history): a snapshot succeeds if ANY relay accepts (not all)
This is why logged-in removal "worked but threw a flag error", and likely why
new watches stopped appearing in history. publishMonthRecord reported success
only when EVERY write relay accepted (acceptedCount === relays.length). With a
20+ relay list many always reject (auth-required/rate-limited/not-in-write-set),
so every snapshot — removals AND new-watch tracking — was marked
"partial-relay-acceptance" -> ok:false -> "watch-history-snapshot-failed". The
data WAS published (major relays accepted), but the UI showed a failure and the
queue treated it as not-yet-synced.

A watch-history event is durable if any relay accepted it (reads take the newest
copy per d-tag), so success = acceptedCount > 0; only a zero-accept publish is a
retryable failure. Also dropped the redundant partialAcceptance alias to stay
within the file-size budget.

Tests + mutation-verified: 1-of-3 accept -> ok/non-retryable; 0-of-3 ->
failure/retryable. Reverting to the all-relays criterion fails the partial case.

Build + full lint green.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-20 19:13:32 -04:00
thePR0M3TH3ANandClaude Opus 4.8 0756dd36d8 fix(watch-history): allow logged-out (local) removal instead of "disabled"
The logged-out delete error: handleWatchHistoryRemoval threw
"watch-history-disabled" whenever the service wasn't synced (isEnabled() false) —
which is the NORMAL state for a logged-out user — so local deletes errored with
"Watch history sync is not available right now" and never touched the local
queue. It conflated "not synced to relays" with "disabled".

Now it only rejects when neither synced NOR local history is supported. For a
local-only actor it proceeds with the snapshot (replace), which rewrites the
local queue (replaceLocalQueue), and SKIPS the relay publish (gated on synced) so
a session-actor list is never pushed to relays. Success copy reflects local vs
synced.

Tests + mutation-verified: local-only removal isn't rejected, snapshots with
replace, and does NOT publish to relays; synced removal still publishes; truly
disabled (no sync, no local) is still rejected. Reverting the gate fails the
local cases.

Build + full lint green.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-20 19:00:51 -04:00
thePR0M3TH3ANandClaude Opus 4.8 12440d2f48 docs(todo): record profile-pane no-op fix + logged-out local delete fix
Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-20 18:53:23 -04:00
thePR0M3TH3ANandClaude Opus 4.8 2f95d4486e fix(watch-history): logged-out (local) delete now rewrites the local queue
The other half of the synced-vs-local design: for logged-out / session
(local-only) actors, watch history lives in a localStorage queue (loadLatest ->
collectQueueItems), but snapshot no-op'd for local-only actors — so a removal
never touched the queue and the item reappeared on reload (same symptom as the
logged-in bug, different layer).

Now a REPLACE snapshot for a local-only actor rewrites the local queue to exactly
the provided items (new replaceLocalQueue helper): removals prune it, an empty
replace clears it. Normal watch-tracking adds still flow through the queue via
recordVideoView, unchanged.

Tests + mutation-verified: local replace seeds then prunes the queue; empty
replace clears it. Reverting to the no-op fails both.

Build + full lint green (watchHistoryService under file-size budget).

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-20 18:45:42 -04:00
thePR0M3TH3ANandClaude Opus 4.8 4c6415c2c4 fix(watch-history): profile-pane delete was wired to a no-op (real fix)
THE root cause of "watch history delete does nothing / card vanishes then pops
back": the profile modal's history renderer overrode `remove` to route through
onHistoryReady -> app.handleProfileHistoryEvent(), which is a stub that just
`return null`. So deleting an item in the profile history pane published NOTHING
— the card was removed optimistically, then the refresh re-rendered the full
list and it reappeared. No publish, no logs, no relay change.

This explains why earlier fixes (replace semantics, newest-per-d-tag read,
empty-month publish) didn't help — they were all on the standalone history path /
the publish layer, but the profile pane never reached any of them. The override
was almost certainly a placeholder left over from the synced-vs-local history
refactor that was never implemented.

Fix: drop the broken `remove` override so the profile history pane falls through
to the renderer's working default (defaultRemoveHandler -> snapshot +
updateWatchHistoryList with replace:true) — the same path the standalone view
uses. Validated manually (UI wiring); regression test to backfill.

Build + full lint green.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-20 18:40:00 -04:00
thePR0M3TH3ANandClaude Opus 4.8 eeacf592dd chore(watch-history): instrument defaultRemoveHandler (the real delete path)
Diagnostics revealed the delete does NOT go through watchHistoryController
(app.watchHistoryController is undefined — it lives on modalCoordinator, not the
app root), so the renderer's `remove` handler always falls through to the
uninstrumented defaultRemoveHandler. That's why no [watch-history:remove] logs
appeared even though a refresh fired. Instrument defaultRemoveHandler to log the
published (reduced) item count + snapshot/list-update results, so we can confirm
whether the publish reduces correctly and isolate the read/cache layer.

Flag-gated; no behavior change.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-20 18:27:42 -04:00
thePR0M3TH3ANandClaude Opus 4.8 4a2ba2cc2f chore(watch-history): production-visible opt-in diagnostics for removal/read
Watch-history delete still misbehaves for the user; add targeted diagnostics to
pinpoint whether it's the publish, relay propagation, or the local cache/read.

- logWatchHistoryDebug now routes to console (not the prod-suppressed devLogger),
  so enabling the existing `bitvid:debug:watch-history` flag surfaces logs on the
  hosted site too.
- Instrument the removal path (watchHistoryController): the computed remaining
  list (count + values + source), snapshot result, list-update result/finalCount,
  and publish failures.
- Instrument the read path (historyView.loadHistory): what a (force) refresh
  actually returns (count + values) — directly tests the "is the cache returning
  the stale full list?" hypothesis.

Flag-gated and off by default; no behavior change (watch-history tests green).

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-20 18:16:10 -04:00
thePR0M3TH3ANandClaude Opus 4.8 5dd5451dc7 docs(todo): add live-stream (NIP-53) + short-form NIP-71 watch support
Two new content-type backlog items (research-first): watch-only Nostr live
streams from zap.stream/shosho.live (likely NIP-53 kind 30311 + 1311 chat), and
consuming external short-form/NIP-71 video notes (likely kind 22/21) from apps
like Amethyst. Flags that these are two distinct NIPs, not the same thing.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-20 18:11:47 -04:00
thePR0M3TH3ANandClaude Opus 4.8 882ba3303d docs(todo): add opt-in encrypted cross-login sync of storage creds + NWC
Captures the requested feature: optionally sync S3/R2 storage credentials and the
NWC zap wallet URI across logins via a self-encrypted NIP-78 (kind 30078) note,
unlocked by the signer. Includes the design path (reuse the existing envelope
encryption for storage creds; NIP-44-to-self for the NWC URI), the relay/
replaceable lessons from this session (write relays, strictly-newer created_at,
newest-per-d-tag on read, empty-publish to clear), opt-in UX, and the threat
model (public-relay blob, key-compromise trust root, NWC = spending capability,
signer decrypt budget).

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-20 17:58:15 -04:00
thePR0M3TH3ANandClaude Opus 4.8 df28c67496 docs(todo): mark watch-history empty-month clear fixed; all 3 gaps closed
Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-20 17:54:27 -04:00
thePR0M3TH3ANandClaude Opus 4.8 a3799f3fba fix(watch-history): publish empty month events so clears propagate
Third and final watch-history-delete gap: removing the LAST item of a month (or
clearing all) left the file's items live on relays. updateWatchHistoryList
already sets records[month]=[] to clear an emptied month, but publishMonthRecord
early-returned on an empty month (`if (!items.length) return {ok:true}`), so the
clearing event was never published and relays kept serving the old, full copy.

Now an empty month still publishes a newest, EMPTY replaceable event for that
d-tag, overwriting the old copy so the clear actually takes effect (and reads —
which take the newest per d-tag after fff2bef2 — see it as empty).

Tests + mutation-verified: an emptied month publishes a clearing event with the
right d-tag and no pointer tags, while a month that still has items publishes its
remaining pointers. Restoring the early-return fails the clearing scenario.

Build + full lint green (watchHistory.js under file-size budget).

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-20 17:47:21 -04:00
thePR0M3TH3ANandClaude Opus 4.8 421e50a90f docs(todo): record watch-history read-dedup fix + remaining empty-month clear
Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-20 17:39:30 -04:00
thePR0M3TH3ANandClaude Opus 4.8 fff2bef2d4 fix(watch-history): take newest version per month on read (delete sticks)
Second root cause of "watch history delete doesn't work / list isn't getting
overwritten": reads UNION items across every event the relays return, and a
publish only reports success when ALL relays accept. With a flaky relay list the
reduced (post-removal) event reaches some relays while others keep serving the
old, larger copy — and the reader deduped only by event id, so both the stale
and fresh versions of the same month (d-tag) were unioned, resurrecting removed
items.

Add dedupeNewestPerReplaceableAddress and apply it at both read union sites (the
chunk-events path and the pointer-events path): keep only the newest event per
d-tag (month) before unioning, so a lagging relay can't reintroduce removed
items. Distinct months are all preserved; events without a d-tag are kept by id.

Tests + mutation-verified: newest-per-month wins (incl. the fewer-items removal
case), distinct months preserved, newest-first ordering, no-d-tag kept by id.
Reverting to id-dedup fails the resurrection scenarios.

Build + full lint green (watchHistory.js back under file-size budget).

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-20 17:29:35 -04:00
thePR0M3TH3ANandClaude Opus 4.8 51a9b3e788 docs(todo): record My Videos follow-ups + deploy/CI infra items
Adds this session's deferred/discovered work: My Videos Restore action, info.json
upload sidecar (with privacy guard), orphan size/date metadata, confirm-modal
polish, external-URL liveness caveat; plus the on-demand deploy workflow decision
(beta?) and the env-sensitive visual-regression baseline that needs CI-side
regeneration. Marks the orphan-GC item as largely delivered by the My Videos tab,
and logs the completed My Videos / delete / watch-history / CI / Vercel work.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-20 17:21:21 -04:00
thePR0M3TH3ANandClaude Opus 4.8 ba1f492f37 chore(vercel): disable auto-deploys for the unstable branch
Pushing to unstable triggered a Vercel Preview build on every commit (and the
manual promote rebuilt again) — wasted build time on a fast-moving dev branch we
deploy on demand anyway. Set git.deploymentEnabled.unstable=false so pushes no
longer auto-build; deploys are now explicit via `vercel deploy --prod` when we
want unstable.bitvid.network updated. beta/main keep auto-deploying (intentional,
low-frequency promotions).

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-20 17:15:18 -04:00
thePR0M3TH3ANandClaude Opus 4.8 9d3a0df02a feat(my-videos): wire bucket reconciliation into the tab (Phase 2, step 3b)
After the note-side list renders, the My Videos tab now reconciles against the
bucket: lists the user's objects (unlock-gated), then marks any active note whose
hosted file is gone as a 🟠 "Missing file", and lists bucket objects no current
video references in an "Orphaned storage objects" section — including files left
behind by videos deleted on Nostr (the headline cleanup case). Each orphan has a
"Delete file" button (with a confirm) that calls deleteStorageKeys and refreshes.

Reconciliation runs after the list is shown so the UI stays responsive, and is a
no-op (orphan section hidden) when storage is locked/unconfigured. Rows carry a
data-video-id + a data-role pill so missing status can be applied in place.

Build + full lint green (DOM via createElement/textContent, no innerHTML).

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-20 15:00:43 -04:00
thePR0M3TH3ANandClaude Opus 4.8 41505c2e51 feat(my-videos): delete-by-key primitive for orphan cleanup (Phase 2, step 3a)
The bucket listing returns raw keys, but deleteVideoStorage only works from note
objects — so orphan cleanup needs a key-based delete. Adds
r2Service.deleteStorageKeys({ keys, npub|pubkey }) (unlock-gated; deletes exactly
the given keys, records per-key failures without aborting the batch). Extracts a
shared resolveStorageClient (unlock-gated connection+client resolution) now used
by both listVideoStorageObjects and deleteStorageKeys, removing the duplication.

Tests + mutation-verified: never deletes while locked, empty-list no-op, deletes
exactly the given keys, per-key failure isolation. The shared lock gate is
mutation-verified across both list and delete paths.

Build + full lint green (r2Service under its file-size budget).

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-20 14:58:29 -04:00
thePR0M3TH3ANandClaude Opus 4.8 513f75e4da feat(my-videos): bucket object listing for reconciliation (Phase 2, step 2)
Adds the bucket-enumeration primitive the My Videos tab needs to find orphan and
missing files:
- s3 layer: ListObjectsV2Command wired into the SDK wrapper + a paginated
  listObjects({ s3, bucket, prefix, maxKeys }) helper (follows continuation
  tokens, bounded by maxKeys).
- r2Service.listVideoStorageObjects({ npub|pubkey }) — unlock-gated, lists under
  the user's own u/<npub>/ prefix; returns { ok, reason, keys }. Body lives in
  r2ServiceHelpers (with a shared buildProviderClient) so r2Service stays under
  its file-size budget; ensureS3SdkLoaded is injectable via ctx for testing.

Tests + mutation-verified: never lists while locked (storage-locked, no call),
no-connection / missing-bucket gating, and unlocked listing is scoped to the
user's own u/<npub>/ prefix (empty-prefix mutation fails). Removing the lock gate
or widening the prefix each fail a scenario.

Build + full lint green (r2Service back under limit).

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-20 14:55:41 -04:00
thePR0M3TH3ANandClaude Opus 4.8 bbb8dd0fd8 feat(my-videos): storage reconciliation core (Phase 2, step 1)
Pure diff of the user's notes against their bucket contents: reconcileStorage
returns MISSING (an active hosted note whose primary video file is absent) and
ORPHAN keys (bucket objects no live note references — including a deleted video's
leftover file, the "deleted on Nostr but still in the bucket" case). Safety
guarantees: a file an active note still uses, plus its sibling .torrent and
thumbnail, is never reported as an orphan; a missing sibling does not mark a row
missing (only a missing primary video file does); external-URL videos can't be
"missing" but unrelated bucket files are still orphans.

Tests + mutation-verified: orphan = present minus referenced (not all present),
and the missing-primary check, are both independently guarded.

Build + full lint green. Next: r2Service.listVideoStorageObjects + wiring.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-20 14:48:54 -04:00
thePR0M3TH3ANandClaude Opus 4.8 f847bce0d5 feat(my-videos): thumbnail + Edit/Delete row actions (Phase 1, step 5)
Each My Videos row now shows the video's thumbnail (lazy, with a placeholder box
that survives a broken/missing image) and, for active videos, Edit and Delete
buttons that route to the app's existing handleEditVideo / handleFullDeleteVideo
flows (closing the full-screen profile modal first so those modals aren't hidden
behind it). Delete reuses the app flow that already cleans the live URL's storage.

Deleted videos intentionally have no per-row action yet: the tombstone scrubbed
their URL, so reclaiming their orphaned bucket file requires the Phase 2 bucket
listing/reconciliation (and Restore) — added there so it's done correctly.

Build + full lint green (DOM via createElement/textContent, no innerHTML).

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-20 14:43:14 -04:00
thePR0M3TH3ANandClaude Opus 4.8 ff04fcbd4b fix(my-videos): use lowercase pane key so the tab opens (not Account)
selectPane lowercases the requested name, but the My Videos pane/nav were
registered under the camelCase key "myVideos". Every other pane key is a single
lowercase word, so this latent mismatch only surfaced now: clicking My Videos
resolved "myvideos" against panes that only had "myVideos", failed the
availableKeys check, and fell back to Account. Register the pane/nav/selectPane
hook under "myvideos".

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-20 14:37:30 -04:00
thePR0M3TH3ANandClaude Opus 4.8 475880198b feat(my-videos): add the My Videos profile tab + listing (Phase 1, step 3)
Adds a "My Videos" tab to the profile modal that lists every video the logged-in
user has published — collapsed to one row per video in its current state
(including tombstoned ones) — each with a note-side health pill: red "No playable
source", grey "Deleted on Nostr", green "OK". Shows a summary line
(N videos · M need attention · K deleted) and refreshes from relays on open.

- New MyVideosController (lazy-populated via selectPane, like the other panes):
  fetchVideosByAuthors -> collapseUserVideos -> classifyVideoHealth -> render.
  Rows built with createElement/textContent (no innerHTML; user titles/URLs are
  not interpolated into markup). Status pills reuse the relay-pill token styles.
- Sidebar nav button + pane markup in components/profile-modal.html.
- Wired into profileModalController (cache refs, listeners, selectPane hook);
  the existing nav-button loop auto-binds the click.

This is display-only; later steps add the hosted-URL HEAD probe (dead-url), row
actions (edit/delete/revert/storage cleanup), and Phase 2 bucket reconciliation.

Build + full lint green (no new file-size/innerHTML/token violations).

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-20 14:32:20 -04:00
thePR0M3TH3ANandClaude Opus 4.8 363bf6cfba feat(my-videos): collapse user events to latest-per-video (Phase 1, step 2)
Second pure piece of the "My Videos" tab: collapseUserVideos reduces all of a
user's video events (every edit/revert/deletion the client holds) to one entry
per addressable video, keeping the latest version — which carries the current
state, including deleted:true when the latest is a tombstone (those are gone from
the active feed but still matter here for storage cleanup). Filters to the owner
only, drops invalid notes, sorts newest-first.

Tests + mutation-verified: owner filtering, dedup-to-latest, deleted-latest
preserved, re-publish-after-delete wins, invalid dropped, empty-pubkey returns
nothing (never leak all videos). Flipping latest->oldest fails 3 scenarios.

Build + full lint green.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-20 14:26:32 -04:00
thePR0M3TH3ANandClaude Opus 4.8 8cb03d23cc feat(my-videos): add pure video-health classifier (Phase 1, step 1)
First piece of the "My Videos" management tab: classifyVideoHealth decides the
maintenance status for one of the user's own videos from note-side data plus an
optional URL probe result. Pure/synchronous so it's deterministic and testable;
the controller will do the actual HEAD probe and pass the result in.

Statuses: no-source (no url AND no magnet) -> error; hosted-URL-unreachable ->
warning; deleted (tombstoned) -> info; otherwise ok. Key guardrail: only URLs
under the user's storage publicBaseUrl can be flagged dead — external/CORS-opaque
links are never cried wolf on. Deleted is checked before no-source since
soft-delete scrubs url/magnet.

Tests + mutation-verified: dropping the hosted guard (external false-alarm) and
removing deleted-precedence each fail a scenario.

Build + full lint green.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-20 14:24:51 -04:00
thePR0M3TH3ANandClaude Opus 4.8 73a06069ad ci: run visual regression in its own job (don't gate deploy on it)
The visual-regression step lived inside the build job, and deploy-cache-state
(the Cloudflare CDN cache-purge that makes new content visible) needs: build.
So an environment-sensitive pixel diff — CI's Chromium/font rendering differs
from the committed baseline (locally the baseline is byte-identical, so it's not
a content regression) — failed the build job and silently blocked every deploy.
The unstable.bitvid.network CDN was stuck on the 2026-06-17 build as a result.

Move the visual steps into a standalone visual-regression job so they still run
and report a failure, but no longer block the build job or the deploy. Real
lint/build/artifact failures still gate the deploy via build.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-20 13:16:11 -04:00
thePR0M3TH3ANandClaude Opus 4.8 9a48c59953 docs(todo): mark watch-history delete fix done; note write-relay confirmation
Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-20 12:58:20 -04:00
thePR0M3TH3ANandClaude Opus 4.8 5f19f53670 fix(watch-history): removal must replace the list, not merge it back
Root cause of "watch history delete does not work at all": the removal paths
call watchHistoryService.snapshot(remaining, …), but snapshot published via
updateWatchHistoryList WITHOUT replace:true. The default merges incoming items
with the existing cached list, so the just-removed item was merged straight back
and the deletion silently had no effect. Removing the LAST item was worse: an
empty list fell through to republishing the pending queue instead of clearing.

- snapshot now honors options.replace: forwards it to updateWatchHistoryList,
  treats an explicit empty replace as a clear (publishes empty instead of
  short-circuiting or falling back to the queue), and skips the cached-items
  merge when replacing. Adds (the default) keep merge semantics.
- Both removal call sites (watchHistoryController.handleWatchHistoryRemoval and
  historyView.defaultRemoveHandler) now pass replace:true.

Tests (snapshot at the updateWatchHistoryList boundary): removing an item
publishes the remaining list with replace:true; removing the last item publishes
an empty replace (not a queue republish / no-op); a normal watch-tracking
snapshot still merges (replace not forced). Mutation-verified — dropping the
replace forwarding fails 2 cases and reverting the empty-clear handling fails the
clear case.

Build + full lint green.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-20 12:52:15 -04:00
thePR0M3TH3ANandClaude Opus 4.8 f3dff8e5b9 docs(todo): mark delete relay-scope fix done; note remaining verify steps
Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-20 12:44:49 -04:00
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 d513fd8874 docs(todo): add consolidated pre-launch fixes & audits backlog
Captures the remaining suggested improvements plus the user's newly reported
pre-launch items: tombstoned-videos-still-showing delete audit, broken
watch-history delete, zaps + platform-fee-split audit, generic-S3 testing, view
counter accuracy, mobile/video-card layout, card-hide liveness audit, orphan GC
tool, login REQ-storm, webtorrent null.fill crash, flaky-test hardening, and the
unstable→beta promotion. Includes a "Done this session" section for context.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-20 12:32:27 -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