Phase 1 of the on-boarding plan, and the shared layer for unified grid
loading. A video that exists as both a bitvid kind-30078 note and a NIP-71
mirror/original previously could appear twice (e.g. independent dual-posts).
js/utils/videoDeduper.js: collapseCrossEcosystem() groups events by a
per-author identity (fileSha256 / originalFileSha256 / infoHash, plus an
`eid` namespace that links an on-boarded note's `importedFrom` to the
original foreign event) via union-find, and keeps the bitvid-native version
(else newest). dedupeVideos() = root-dedup THEN cross-ecosystem collapse.
Routed through the shared chokepoint app.dedupeVideosByRoot (playback
coordinator), so the feed stage, channel profile, and search all collapse
duplicates identically — "show one, prefer bitvid" everywhere. Channel
fallback path updated too.
Limitation (documented): hash-less independent dual-posts can't be matched
until the bitvid side carries on-board provenance (Phase 2).
Tests + mutation-verified (prefer-bitvid, per-author identity, infohash,
provenance link, root+cross composition). 1262/1263 unit (known flake).
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Imports not produced by bitvid's storage system (reference imports / external
URLs) must be explicitly flagged externally-managed so storage tooling never
treats them as bitvid-owned: external badge (never "missing from bucket"),
excluded from orphan reconciliation, delete "file left behind" warning
suppressed, liveness unverifiable. Adds import modes (full re-host vs
reference) + Phase 2b to the plan and TODO 17c. Notes the existing
isUrlUnderBase heuristic is the baseline but a durable flag is needed.
Planning only; no code changes.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Adds docs/nip71-onboarding-plan.md and pre-launch TODO 17c for the agreed
two-way NIP-71 interop:
- Phase 1 (first): cross-ecosystem dedup — prefer the bitvid (30078) version,
show exactly one in every grid; identity = author + import-link/hash/infohash.
- Phase 2: creator-initiated self-import (model A) — re-host to the creator's
storage (CDN) + WebTorrent, publish a signed 30078 with an imported-from
provenance tag. No forging others' notes; no instance auto-rehost.
Planning only; no code changes.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
The channel-profile grid fetched only kind-30078, so a creator's NIP-71
videos (incl. their Nostube cross-posts) appeared in the main feed via
ingest but were absent from their profile wall.
New js/channelProfileVideos.js:
- buildChannelVideoFilters(pubkey): native (kind 30078, #t=video) + NIP-71
(kinds 21/22/34235/34236) filters for the author.
- convertChannelEvent(entry): routes NIP-71 kinds through
buildVideoFromNip71Event (which rejects bitvid's own outbound mirrors so
they don't duplicate the canonical note); kind-30078 uses the standard
converter.
channelProfile.js now uses these (net fewer lines, stays under size cap).
Existing buildRenderableChannelVideos dedupe-by-root + canAccess handle
precedence and whitelist/blacklist gating, so foreign videos inherit the
same gating as native ones.
Closes pre-launch TODO 17b. Tests + mutation-verified (NIP-71 routing,
mirror skip, dual-filter build). 1253/1254 unit (known UploadModal flake).
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Cold-start fired one kind-30000 REQ per community-blacklist curator
(loadCommunityBlacklistEntries did Promise.all over references, each a
fetchLatestListEvent) — ~28-32 parallel REQs that dominated the login
relay-REQ spike and starved the feed.
A Nostr filter ORs `authors` and `#d`, so all curators collapse into ONE
batched REQ. New js/adminListBatch.js (pure, mutation-tested):
buildBatchedReferenceFilter + selectNewestEventsForReferences (newest per
author:d-tag, discards author×d-tag cross-products) +
fetchBatchedReferenceEvents (routes through the subscription manager's
list — health-gated/timeout-bounded, not raw pool). Helpers extracted to
their own module so adminListStore stays under its size cap.
Best-effort: on batch failure we return nothing and the cached admin
state carries over (readCachedAdminState).
Addresses pre-launch TODO #9 (cold-login REQ storm). Tests + mutation-
verified (newest selection, cross-product discard). 1249/1250 unit
(known UploadModal flake).
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Strict relays (primal) reject a whole REQ with "uneven size input to
from_hex" when any ids/authors/#e/#p/#q entry isn't 64-char hex — silently
dropping that subscription's results. One malformed value anywhere in a
filter poisoned the request.
Sanitize at the universal pool choke point (normalizeFilterList, used by
both the shimmed pool.sub and pool.list): lowercase/trim and drop non-
64-hex entries from the hex-only fields; if a once-populated hex field ends
up empty, drop that filter rather than silently broadening the query.
Non-hex fields (#a/#d/#t/kinds/…) are untouched. Dev-logs what it strips
(with the filter's kinds) so the malformed source can be traced.
Also records pre-launch TODO items: NIP-71 inbound ingest is now live
(Phase 2, incl. the resolve-posted-at history-fetch fix), and a new
launch-blocker — channel-profile walls fetch only kind-30078 so a
creator's NIP-71 videos don't show on their profile.
Tests: filter-hex-sanitize (strip/keep/lowercase/drop-empty/leave-non-hex);
mutation-verified. 1244/1245 unit (known UploadModal flake).
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Deep dive into the "nothing loads / Fetching recent videos… hangs" symptom
found the real blocker: it was NOT the moderation/WoT stage (that uses
cached reads and passes unknown authors through clean). It was the feed's
resolve-posted-at stage.
resolveVideoPostedAt() short-circuits when a video has nip71.publishedAt,
otherwise it does a per-video `nostrClient.hydrateVideoHistory()` network
fetch (looking up kind-30078 history). Foreign NIP-71 videos have no such
history, and the ingest adapter wasn't setting nip71.publishedAt — so a
burst of ~32 ingested videos triggered ~32 blocking history fetches that
stalled the render and flooded relays.
Fix: the ingest adapter now surfaces nip71.publishedAt from the NIP-71
published_at tag (falling back to created_at). The feed short-circuits and
never fetches history for ingested videos. Re-enables FEATURE_NIP71_INGEST
by default now that the hang is addressed.
Tests: adapter published_at extraction + created_at fallback;
mutation-verified. 1237/1238 unit (known UploadModal flake).
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Ingest's data path is verified working (subscribe -> fetch -> adapt ->
inject -> survives the whitelist filter), but surfacing a burst of
ingested authors pushes bitvid's moderation/WoT stage to hydrate trust +
report data (kind 30000 / 1984) for many cold authors at once, storming
relays and stalling the feed render ("nothing loads"). The native feed
avoids this only because it trickles authors in gradually.
Flip FEATURE_NIP71_INGEST default to false to restore a stable feed for
launch. All ingest code stays in place behind the admin toggle; re-enable
once moderation/WoT hydration is bounded/non-blocking for an author
influx, or whitelisted authors are treated as pre-trusted (skipping the
WoT fetch). Tracked as the next NIP-71 increment.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Live logs proved ingest works (subscribed to 17 whitelisted authors,
received 61 events, injected 39) — but injecting foreign authors during
cold-start triggers the moderation/WoT stage to hydrate their social graph
(kind 30000), which competes with the initial feed load and stalls it
("Fetching recent videos…" hanging, needs a refresh).
- startWhenFeedReady(): defer the first ingest subscription until the
native feed's first videos:updated (with a 12s fallback for empty
feeds), keeping ingest off the cold-start critical path. Wired in
applicationBootstrap (kept thin so the size cap holds).
- emitRefresh now logs how many ingested videos survive the feed filter
("N ingested of M in filtered feed") so we can tell whether foreign
videos are filtered out vs. injected-but-not-rendered.
Tests: defer-until-feed-ready + fallback-timer; mutation-verified.
1236/1237 unit (known UploadModal flake).
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Two stabilization fixes after live testing showed ingest was invisible
and could amplify feed load:
1. Diagnostics were using devLogger.log, which is gated by IS_DEV_MODE
(false in all current builds) — so the [nip71Ingest] subscribe/flush
lines never printed and the feature was impossible to observe. Switched
the subscribe + per-flush lines to userLogger.info so we can confirm in
the console whether it subscribed (author count) and whether events are
arriving/injecting. (Temporary while stabilizing.)
2. Coalesce ingest-driven "videos:updated" signals behind an 8s throttle.
A burst of incoming events previously emitted a refresh per flush; each
refresh re-runs the feed and re-subscribes moderation/social context for
every video, which can contribute to a REQ storm. Injected videos are
already in the active store, so throttling only delays the visual
refresh, not the data.
Tests: throttle coalescing test (one immediate refresh per window, both
videos still stored); mutation-verified. 1234/1235 unit (known flake).
Co-Authored-By: Claude Opus 4.8 <[email protected]>
The admin whitelist hydrates asynchronously (remote fetch, then a second
hex-pubkey rebuild once NostrTools loads) and does not always emit a
change we can hook — e.g. loaded-from-cache-unchanged, or the async hex
rebuild that emits nothing. So at startup getWhitelistPubkeys() was empty,
resolveFilters() returned null, no subscription opened, and the
onWhitelistChange restart never fired: ingest stayed silent even when a
whitelisted author had foreign NIP-71 videos on the relays.
Fix: bounded retry (every 2s, ~60s) that re-attempts opening the
subscription until the author scope hydrates, in addition to the
onWhitelistChange listener. Author-scoping is kept deliberately — a broad
kinds:[21,22,...] subscription would be a REQ firehose that violates
bitvid's relay fan-out invariants.
Also adds dev-mode logging (subscribe author/kind count; per-flush events
in / injected) so ingest is observable in the console.
Tests: new race test (empty whitelist at start -> authors hydrate without
an emit -> retry subscribes); mutation-verified.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Pulls NIP-71 video events published by other Nostr apps (kinds
21/22/34235/34236), converts them via the ingest adapter, and injects
them into nostrClient.activeMap/allEvents so they flow through the
existing feed pipeline. Gated by FEATURE_NIP71_INGEST.
Mirrors bitvid's whitelist model: since the whitelist is "authors
allowed to publish/render here," ingest scopes its relay subscription to
whitelisted pubkeys while whitelist mode is on (bounded, no firehose) and
re-subscribes when the whitelist changes. When a deployer disables
whitelist mode, ingest opens to all authors (capped by a limit). Foreign
content is still gated by the same render-time filter
(shouldIncludeVideo -> accessControl.canAccess): whitelist, blacklist,
NSFW (content-warning -> isNsfw), private-skip, plus trust/mute/blur from
the feed engine. Native bitvid videos always win a shared root; among
ingested videos newest wins. Refreshes via the existing videos:updated
reactive path.
- accessControl.getWhitelistPubkeys(): hex whitelist for subscription scope.
- Wired in applicationBootstrap (start on init, stop on teardown).
- nostrService untouched (at its size cap); logic is a standalone service.
Mutation-verified: native-wins, whitelist scoping, feature gate.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
buildVideoFromNip71Event() converts a NIP-71 video event published by
another Nostr app (kinds 21/22/34235/34236) into a bitvid video object
shaped like convertEventToVideo's output, so ingested videos flow through
the existing feed pipeline (moderation, trust, NSFW gating, rendering).
Reads the imeta variant + standard NIP-71 tags (not bitvid's JSON
content): url/magnet, dim->width/height, image->thumbnail, x/ox hashes,
i/magnet->infoHash, title, summary->description, t->hashtags, duration.
Safety:
- content-warning -> isNsfw so ALLOW_NSFW_CONTENT + blur thresholds apply.
- skips bitvid's own outbound mirrors (["client","bitvid"]) to avoid
double-listing content already present as canonical kind-30078.
- never marks foreign content private (no encryption in NIP-71).
- stamps source:"nip71-ingest"/foreign:true provenance markers.
Pure + dependency-free; mutation-verified (mirror-skip, nsfw mapping).
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Foundation for inbound NIP-71 ingest. Adds a single admin switch in the
instance config (config/instance-config.js, default true), re-exported
through config.js and surfaced as a runtime-overridable flag in
constants.js — same pattern as FEATURE_NIP71_MIRROR / ALLOW_NSFW_CONTENT.
A deployer flips one constant to disable all inbound ingest if it ever
causes problems (spam, low-quality content, performance). The ingest
adapter + service (gated on this flag) land in follow-up commits; its
behavioral test will assert the service is inert when the flag is off.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Adds an opt-in account preference (off by default, per pubkey) that
auto-mirrors newly published eligible public videos to NIP-71
(kinds 34235/34236) so they surface on other Nostr video clients —
no per-video opt-in needed.
- nip71MirrorFlags: isAutoShareEnabled/setAutoShareEnabled (persisted
per pubkey) + pure resolvePublishSync({featureOn,autoShare,eligible}).
- nip71MirrorSync: syncNip71MirrorAfterPublish + a videos:published
subscription in initNip71MirrorSync; converts the canonical 30078
event, checks eligibility (public + hosted URL + ALLOW_NSFW gate),
publishes the mirror, and records the per-video flag so later
edits/deletes stay in lockstep. All best-effort.
- My Videos pane: an "Auto-share new public videos to other Nostr apps"
checkbox wired to setAutoShareEnabled, gated by FEATURE_NIP71_MIRROR.
Tests (mutation-verified): auto-share persistence, resolvePublishSync
(eligible+on=>publish; off/ineligible=>none), and videos:published
subscription wiring.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Auto-captures a video's width/height/duration at file-select and persists them on
the 30078 — the durable foundation for a future "shorts" vertical feed (filter on
height > width) and for the NIP-71 mirror's short-kind selection.
- js/utils/videoProbe.js: probeVideoMetadata(src) loads metadata into a hidden
<video> (element injectable for tests); resolves {width,height,duration} or null.
- UploadModal: probes the selected file (objectURL) in the background and threads
width/height/duration into the publish payload.
- videoPayloadBuilder: persists width/height/duration in content (publish) and
preserves them across edits; normalizers exported.
- convertEventToVideo: exposes video.width/height/duration → the mirror builder
already uses them (portrait ⇒ 34236 short + imeta dim).
tests: probe resolution (incl portrait + error/timeout → null); content persist +
parse; portrait⇒34236 / landscape⇒34235 mirror selection; edit preserves dims.
Mutation-verified earlier on the mirror short-selection.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Closes the real hashtag gap. Verified the upload modal's existing NIP-71 hashtag
editor (Nip71FormManager → nip71.hashtags → buildNip71MetadataTags) ALREADY emits
`t` tags on the 30078, which bitvid's feed scoring already reads — so publish+feed
hashtags work today. The gap was the 34235 mirror reading only `video.hashtags`
(empty for form-authored videos).
buildNip71MirrorEvent now resolves hashtags from whichever source is present —
video.hashtags, video.nip71.hashtags, or the raw `t` tags on video.tags — deduped,
lowercased, with bitvid's internal "video" topic tag dropped. So a shared video
stays categorized on Nostube/Amethyst regardless of how its tags were authored.
(The earlier content.hashtags builder path from a1bf7b15 stays as a harmless
secondary input; the form/t-tag path is the one users actually exercise.)
tests/nip71-mirror.test.mjs: hashtags sourced from nip71.hashtags; from raw t tags
with the 'video' topic dropped + deduped. Mutation-verified.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Polish toward NIP-71 parity + better native discovery. Hashtags now flow through
the video data layer (the input UI in the upload/edit modals is the remaining
step):
- videoPayloadBuilder: sanitizeVideoHashtags (normalize/dedupe, drop reserved
"video"); persists hashtags in 30078 content AND emits ["t", tag] so bitvid's
own feed scoring (exploreScoring/kidsScoring already read t tags) and relay
queries pick them up.
- convertEventToVideo: exposes video.hashtags from content → the NIP-71 mirror
builder already carries them as t tags, so a shared video is categorized on
other apps too.
tests/video-hashtags.test.mjs: sanitize rules, content+t-tag emission, round-trip
to video.hashtags, empty case. Mutation-verified (t-tag emission load-bearing).
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Closes the dual-event drift the user flagged: an opted-in 34235 mirror now stays
in lockstep with the canonical 30078 automatically — no per-video babysitting.
- js/services/nip71MirrorSync.js: syncNip71MirrorAfterEdit (re-publish if still
eligible; unshare + clear flag if it became ineligible, e.g. flipped private),
syncNip71MirrorAfterDelete (remove + clear flag). Plus initNip71MirrorSync()
which subscribes to nostrService's EXISTING videos:edited/videos:deleted events
— so the size-capped nostrService is untouched.
- js/services/nip71MirrorFlags.js: pure resolveEditSync/resolveDeleteSync decisions.
- applicationBootstrap: one-line initNip71MirrorSync(app.nostrService) wiring.
- All best-effort: a mirror failure never breaks the core edit/delete.
tests: resolver decisions (lockstep/unshare/none) + delete clears the flag +
event wiring is idempotent. Mutation-verified (eligibility branch load-bearing).
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Decided to keep the dual-event model (kind 30078 canonical + additive 34235/36
mirror) rather than collapse to a single canonical 34235 note. Captures the
rationale: private videos can't be public NIP-71 (no encryption), and kind 30078
is the addressing root (watch-history/comments/reactions/views/playlists/naddr/
moderation + hardcoded video filters) so switching it is a large forward-only
migration not worth the interop win right now. Accepted trade-off: keep the mirror
in sync on edit/delete (Phase 1 polish). Phase 0/1 builder/parser remain reusable
if single-canonical is ever revisited.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Makes the NIP-71 mirror user-facing via the My Videos tab — the natural
manage-my-videos surface — without touching the single-writer publish path.
- js/constants.js: new FEATURE_NIP71_MIRROR flag (default on). Independent of
FEATURE_PUBLISH_NIP71, which is already wired to the LEGACY 21/22 auto-publish in
videoPublisher.js — flipping that would activate the wrong (non-opt-in) model, so
the opt-in mirror gets its own flag and that legacy path stays dormant.
- js/services/nip71MirrorFlags.js: per-video opt-in persistence
(pubkey+videoRootId, off by default) + pure resolveMirrorToggle decision.
- MyVideosController: per-row "Share to apps" / "Shared ✓" toggle (gated by the
flag), wired to nip71MirrorService.publish/remove. Ineligible videos show a
disabled "Share off" with the reason (private / NSFW-not-allowed-here / needs
hosted URL) — the ALLOW_NSFW_CONTENT moderation gate surfaces here too.
tests/nip71-mirror-flags.test.mjs: persistence scoping + toggle decision
(enabled→remove, eligible→publish, ineligible→blocked). Mutation-verified.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Adds remove(video, options) to the mirror service for delete / toggle-off:
- NIP-09 kind-5 delete referencing BOTH addressable kinds
(34235:pub:root + 34236:pub:root, with k tags) so it's robust to a
since-changed orientation.
- empty-replace tombstone (same d-tag, title, NO imeta) so clients that cache
addressable events but ignore NIP-09 still see the entry cleared; uses the
short kind for portrait videos.
Published to the write/outbox relay set; unavailable without a signer.
tests/nip71-mirror-service.test.mjs: remove publishes delete (both kinds) +
tombstone; portrait => 34236 tombstone; unavailable without signer.
Mutation-verified (tombstone emission + both-kind a-tags load-bearing).
Note: full unit suite green apart from the documented order-dependent flakes
(uploadModal-reset; a transient login-modal clipboard global-state flake) — both
pass in isolation and are unrelated to this change.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Publish service for the opt-in NIP-71 mirror (kind 34235/36) to the user's
write/outbox relays, built on the Phase 0 builder. Flag still off; not yet wired
into the publish path (next increment).
- js/services/nip71MirrorService.js: createNip71MirrorService (DI) → isAvailable,
canMirror(video), publish(video, options). Signs with the active signer and
publishes to the write set (getDeletePublishRelays). Resolves the moderation
audit item: the site ALLOW_NSFW_CONTENT gate — an instance that won't surface
NSFW must not publish it outward (enforced in both publish() and canMirror(),
defense-in-depth). Private/url rules come from the builder.
tests/nip71-mirror-service.test.mjs: publishes a public video; NSFW blocked when
ALLOW_NSFW_CONTENT=false (and allowed when true); private/url-less refused;
unavailable without signer. Mutation-verified (NSFW gate load-bearing).
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Audit (verified in code) of how moderation, web-of-trust, mute/block lists, and the
site ALLOW_NSFW_CONTENT config interact with NIP-71 interop:
- Controls key on author pubkey + event id, so they apply to NIP-71 videos
automatically IF ingested videos share the feed pipeline (blacklist-filter,
moderation/WoT, suppression, kids, nsfw).
- Phase 1 (outbound): gate the mirror on ALLOW_NSFW_CONTENT (don't mirror NSFW from
an instance that forbids it); builder stays pure + always sets content-warning.
- Phase 2 (inbound) gap: mergeNip71MetadataIntoVideo does NOT map content-warning →
isNsfw, so foreign NSFW would bypass the site filter — must add that mapping; plus
route ingest through the full pipeline and trust-gate unknown authors.
- Kids feed already safe (allowlist on isForKids).
No code changes yet (both items land in their phases); captured as checklist items.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Improvements from a second pass over the plan + code:
- Add "Discovery & relays" section: the mirror must publish to the user's NIP-65
OUTBOX write relays (publish kind 10002 first if missing) or outbox clients
(Amethyst) can't find it — discovery is a relay problem, not just format. bitvid
already has publishRelayList/outbox; reuse it.
- Architecture: bitvid never double-renders its OWN mirror (merged onto 30078 by
videoRootId via nip71Cache) — dedup applies from Phase 1, not just Phase 2.
- 3rd hard rule: url must be publicly reachable (gate on verifyPublicAccess).
- Capture: wire existing upload `duration` into imeta; add a loadedmetadata probe
for `dim` + 34235-vs-34236 selection (pulled into v1; trimmed from backlog).
- Mapping: split `.content` (full) vs `alt` (short a11y text); note published_at is
read from the original 30078 so it's stable across edits.
- Surface the mirror toggle in the My Videos tab (existing management surface).
- Delete nuance: NIP-09 (a/k tags) AND empty-replace the addressable event so
NIP-09-ignoring clients still see it cleared.
- Phase 0: add an interop golden-shape test to guard against silent drift.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
- Document that the explore/kids feed ALREADY consumes NIP-71 hashtags (both raw
["t",…] tags and video.nip71.hashtags) — feed algo needs zero changes; only
capture is missing (no per-video hashtags field on 30078 today).
- Add a "Converting existing videos" section: opting in is an edit that maps every
available 30078 field into its NIP-71 home (incl. hashtags → t) and writes the
34235/36 mirror with d=videoRootId; non-destructive, lockstep edits.
- Pull hashtag capture + field conversion explicitly into Phase 1.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Adds docs/nip71-migration-plan.md — the agreed spec to build from:
- Dual-event, opt-in (off by default): keep canonical kind 30078; add addressable
34235/34236 mirror (d=videoRootId, edits in lockstep).
- Research correction: 34235/36 are NOT deprecated — current NIP-71 designates the
addressable pair for content that needs post-publication updates (exactly bitvid's
edit-in-place model).
- WebTorrent rides STANDARD NIP-94 imeta fields (magnet, i) rather than a hack;
private videos never mirrored; HTTPS url required to mirror.
- Phases: 0 retarget builders → 1 outbound publish/edit/delete → 1.5 NIP-89 "Open in
bitvid" handler + NIP-51 kind 30005 portable playlists → 2 inbound ingest.
Also refreshes docs/nips/71.md (was stale: only 21/22; now the current 233-line
spec with 34235/36 addressable kinds, waveform, origin, multi-audio). Updates
TODO #17 to point at the plan.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
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]>
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]>
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]>
"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]>
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]>
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]>
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]>
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]>
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]>
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]>
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]>
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]>