From f49080558b2bd194620cbf88cd44cda338fb4b55 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Thu, 23 Jul 2026 21:31:57 -0400 Subject: [PATCH 1/5] docs: add BitUnlock permission layer plan --- docs/BITUNLOCK_PERMISSION_LAYER_PLAN.md | 522 ++++++++++++++++++++++++ 1 file changed, 522 insertions(+) create mode 100644 docs/BITUNLOCK_PERMISSION_LAYER_PLAN.md diff --git a/docs/BITUNLOCK_PERMISSION_LAYER_PLAN.md b/docs/BITUNLOCK_PERMISSION_LAYER_PLAN.md new file mode 100644 index 00000000..076043a3 --- /dev/null +++ b/docs/BITUNLOCK_PERMISSION_LAYER_PLAN.md @@ -0,0 +1,522 @@ +# BitVid BitUnlock permission-layer plan + +Status: proposed application architecture and implementation plan, 2026-07-23. + +BitVid should evolve from a public Nostr video client into a creator-controlled media platform that +supports public media, purchases, rentals, creator memberships, platform subscriptions, bundles, and +pay-per-view access. BitUnlock is the reusable payment-verification and permission layer beneath those +features. + +The architectural rule is: + +> **BitVid owns media behavior. BitUnlock owns payment-backed permission.** + +Related projects and documents: + +- BitUnlock `docs/STREAMING_SUBSCRIPTION_ACCESS_PROFILE.md` — generic entitlement and streaming-access profile. +- BitUnlock `docs/GATED_ACCESS_PLATFORM_PLAN.md` — renewable/revocable entitlements and relying-party verification. +- BitUnlock `docs/ACCESS_GRANTS_PLAN_V2.md` — bring-your-own-storage capability patterns. +- BitUnlock `docs/SECURITY_AUDIT_AND_RESILIENCE_PLAN.md` — security, migration, backup, and recovery gates. +- BitVid [`nostr-event-schemas.md`](./nostr-event-schemas.md) — current NIP-71 event behavior. +- BitVid [`playback-fallback.md`](./playback-fallback.md) — current URL/WebTorrent playback behavior. + +## 1. Product direction + +BitVid remains a Nostr-native, creator-controlled video application, but its access model expands from +"public or hidden" into a general policy system. + +BitVid should support: + +- public videos; +- login-only videos; +- permanent individual purchases; +- time-limited rentals; +- creator memberships; +- platform-wide subscription catalogs; +- bundles, seasons, courses, and collection passes; +- pay-per-view premieres and livestreams; +- patron/supporter tiers; +- free previews and delayed public release; +- future prepaid or metered viewing where economically appropriate. + +A single video may accept several routes. For example, a viewer may qualify through an individual +purchase, an active rental, a creator membership, or a platform subscription. + +## 2. Responsibility boundary + +### BitVid owns + +- NIP-71 publishing and compatibility; +- channels, catalogs, playlists, seasons, bundles, and collections; +- search, discovery, feeds, recommendations, and moderation; +- profiles, watch history, favorites, and parental controls; +- access-policy authoring and resource-to-product mapping; +- checkout presentation inside the BitVid UI; +- playback-session creation and renewal; +- HLS/DASH manifests, MP4 range behavior, WebTorrent policy, storage, CDN, and origin integration; +- devices, concurrent streams, household rules, and application sessions; +- creator dashboards, pricing presentation, support, refunds, and catalog management. + +### BitUnlock owns + +- product registration and policy discovery; +- Lightning/NWC payment-plan creation; +- authoritative settlement verification; +- partial-payment recovery, retries, and idempotency; +- fulfillment and buyer-bound signed entitlement issuance; +- perpetual and term access semantics; +- renewal, expiration, revocation, and buyer recovery; +- generic relying-party verification; +- optional storage-capability issuance for static sellers; +- SDK, schemas, vectors, sandbox, and normalized errors. + +### BitRoad's role + +BitRoad may list and sell the same products, but BitRoad is not a runtime dependency for playback. +Purchases made through BitRoad and purchases initiated inside BitVid should produce compatible +BitUnlock entitlements that any compatible BitVid client can verify. + +## 3. Access modes + +### 3.1 Public + +The video is playable by any NIP-71 client. No BitUnlock product is required. + +### 3.2 Login-only + +The video remains free, but BitVid requires a proven Nostr identity before playback. This can support +private communities or account features without payment. + +### 3.3 Permanent purchase + +The viewer buys a video or catalog item once and receives a perpetual BitUnlock entitlement. BitVid +may issue short-lived playback sessions each time, but the entitlement itself does not expire. + +### 3.4 Rental + +The viewer buys a term entitlement. The first MVP should use purchase-relative expiry because that is +already supported by BitUnlock. A future first-play-relative rental requires a separate activation +record and must not be inferred from payment alone. + +### 3.5 Creator membership + +A creator defines one or more membership products. BitVid maps each membership tier to a changing set +of videos and benefits. + +Example tiers: + +- Supporter — early access and badge; +- Member — premium catalog; +- Producer — premium catalog, downloads, and private livestreams. + +BitUnlock verifies the tier entitlement; BitVid owns the current benefit mapping. + +### 3.6 Platform subscription + +BitVid defines platform-wide plans such as Basic, Premium, Family, Sports, or Annual. The active plan +entitlement grants access to application-defined catalog tiers. + +BitUnlock does not need to know which videos are in each tier. BitVid resolves the catalog policy. + +### 3.7 Bundle or catalog pass + +One entitlement may unlock a season, trilogy, course, conference archive, creator bundle, or topical +collection. + +### 3.8 Pay-per-view and livestream admission + +A term entitlement may authorize a premiere, live event, conference, or limited screening, with an +optional replay window. + +### 3.9 Delayed public release + +A video may initially require an entitlement and become public at a specified time. BitVid evaluates +the release timestamp before invoking paid-access checks. + +### 3.10 Metered viewing + +Pay-per-minute or pay-per-stream is deferred until BitUnlock supports an appropriate prepaid rail and +batched fee accounting. BitVid should not create a Lightning invoice or minimum service fee for every +small playback action. + +## 4. Resource access policy + +BitVid should represent access as an application-owned OR-of-routes policy. + +Conceptual shape: + +```json +{ + "version": "bitvid-access-policy-v1", + "mode": "any", + "routes": [ + { + "kind": "public-after", + "at": 1798761600 + }, + { + "kind": "bitunlock-entitlement", + "servicePubkey": "", + "productCoordinate": "" + }, + { + "kind": "bitunlock-entitlement", + "servicePubkey": "", + "productCoordinate": "", + "requireCurrentStatus": true + }, + { + "kind": "bitunlock-entitlement", + "servicePubkey": "", + "productCoordinate": "", + "requireCurrentStatus": true + } + ] +} +``` + +The exact wire format must be reviewed before implementation. The policy must support: + +- multiple acceptable entitlement products; +- public or login-only fallback; +- local release times; +- perpetual versus term expectations; +- mandatory online revocation checks where needed; +- future AND conditions for add-ons or region/catalog constraints; +- explicit service-pubkey pinning and versioning. + +## 5. NIP-71 compatibility + +BitVid must preserve ordinary NIP-71 interoperability. + +Recommended approach: + +1. Keep the standard NIP-71 video event valid and useful to other clients. +2. Add an optional reference to a separate replaceable BitVid access-policy event, or a minimal + extension tag pointing to that policy. +3. Do not publish permanent private storage URLs, decryption keys, playback tokens, buyer identities, + or subscription state in the public video event. +4. Clients that do not understand the access extension may show metadata and a locked state rather + than receiving a false public playback URL. +5. Public videos continue to carry normal public playback sources. + +A separate access-policy event is preferable to embedding a large mutable catalog rule directly in +every video event. It allows membership/catalog mappings to evolve without republishing media +metadata. + +## 6. Viewer flow + +### 6.1 Discovery + +BitVid loads the NIP-71 event and associated access policy. It can render the card, title, creator, and +preview metadata without revealing protected media capabilities. + +### 6.2 Authentication + +The viewer signs in through BitLogin, NIP-07, or another supported Nostr signer. BitVid treats the +proven pubkey as the account identity. + +### 6.3 Entitlement recovery + +BitVid loads buyer-held entitlements from: + +- local encrypted storage; +- the buyer's relay/vault recovery path; +- BitUnlock's authenticated buyer-recovery endpoint. + +The client should cache receipts but re-evaluate expiry and required revocation status before granting +new playback. + +### 6.4 Policy evaluation + +BitVid checks local routes first, then verifies candidate BitUnlock receipts against: + +- pinned BitUnlock service pubkey; +- authenticated buyer pubkey; +- expected product coordinate; +- supported schema and policy versions; +- entitlement term and expiry; +- current revocation status where required. + +### 6.5 Checkout + +When no route succeeds, BitVid presents applicable purchase choices inside the video UI: + +- buy permanently; +- rent; +- join creator membership; +- subscribe to BitVid plan; +- buy event pass. + +The BitUnlock SDK manages quote creation, wallet interaction, partial-payment recovery, bounded +polling, unlock, and entitlement validation. + +### 6.6 Playback authorization + +After a route succeeds, a trusted BitVid backend or media-origin component issues a short-lived +resource-scoped playback capability. The video bytes flow from the storage/CDN/origin to the viewer, +not through BitUnlock. + +## 7. Playback architecture + +### 7.1 MVP private-origin path + +Use a small BitVid authorization service or edge function: + +1. Receive authenticated viewer proof and buyer-presented entitlement. +2. Verify the entitlement with the BitUnlock SDK. +3. Evaluate the BitVid resource policy. +4. Create a short-lived playback session. +5. Return a signed manifest URL, signed cookie, or presigned MP4/object URL. +6. Refresh the session during long playback without requiring a new payment. + +### 7.2 Capability properties + +The playback capability should bind: + +- buyer or delegated device identity; +- video/resource ID; +- accepted policy route and entitlement ID; +- origin/audience; +- issue and expiry time; +- session revision or nonce; +- plan quality/download permissions where applicable. + +The capability should normally last minutes, even when the subscription lasts a month. + +### 7.3 HLS/DASH + +The design must account for: + +- manifest authorization; +- segment requests after the original URL expires; +- session renewal during long playback; +- seeking and bitrate switching; +- CDN caching without making protected manifests public; +- signed cookies versus per-segment query tokens; +- CORS and CSP among BitVid, the authorization service, and the media origin. + +### 7.4 MP4 + +For MP4 playback, preserve byte-range support and ensure session renewal does not break seeking. A +short-lived signed cookie or origin session may work better than a one-time URL with an expiry shorter +than the video. + +### 7.5 WebTorrent + +Public WebTorrent distribution is naturally incompatible with revocable private subscription access +once plaintext media is shared to peers. BitVid should: + +- keep WebTorrent for public media; +- allow encrypted torrent payloads for permanent purchases where the buyer receives a decryption key; +- not claim that private subscription streams remain revocable after distributing a reusable + decryption key or plaintext torrent. + +## 8. Subscription MVP + +The first subscription release should intentionally avoid automatic charging and household complexity. + +MVP capabilities: + +- one creator monthly membership product; +- one BitVid platform monthly plan; +- manual Lightning renewal; +- current plan and expiry display; +- renewal checkout against the same product; +- immutable new entitlement for each period; +- expiration and seller revocation; +- catalog-tier mapping; +- recovery on a new device; +- one active viewer session policy; +- short-lived playback session against a private test origin. + +The MVP is complete when a buyer can subscribe, recover the entitlement elsewhere, play an included +video, fail after expiry/revocation, renew, and play again without mutating the old receipt. + +## 9. Automatic renewal + +Automatic renewal is a later phase requiring an explicit, constrained buyer NWC authorization. + +Requirements include: + +- merchant and product binding; +- maximum amount; +- minimum billing interval; +- period and cumulative spending limits; +- cancellation and revocation; +- duplicate-charge and replay protection; +- notifications and failed-renewal behavior; +- no seller ability to broaden permission silently. + +BitVid must not market subscriptions as automatically recurring until this separate authorization flow +has been implemented and reviewed. + +## 10. Devices, profiles, and household access + +The root Nostr key should not be copied to every television. + +Later phases should introduce: + +- buyer-authorized device keys or application sessions; +- device list and remote sign-out; +- delegated scope limited to BitVid; +- maximum devices per plan; +- concurrent stream limits; +- profile records and parental controls; +- household-member authorization without sharing the root key; +- revocation and recovery. + +Profiles and household rules remain BitVid application state. BitUnlock only verifies the underlying +subscription entitlement unless a future generic delegated-capability feature is intentionally added. + +## 11. Creator and operator experience + +Creators should be able to: + +- register BitUnlock-backed purchase, rental, membership, and event products; +- connect those products to videos, playlists, or catalog groups; +- define accepted access routes; +- preview the locked viewer experience; +- revoke a term entitlement where policy permits; +- see aggregate commerce state without receiving buyer private keys or sensitive credentials; +- use BitRoad as an optional storefront while keeping BitVid-native checkout available. + +Operators should be able to: + +- configure the accepted BitUnlock service identity; +- configure a private media origin/CDN; +- control playback-session TTLs and concurrent-session policy; +- suspend new paid access safely during incidents; +- audit authorization failures without logging receipts or playback tokens. + +## 12. Privacy and security + +- Buyer receipts should remain private and buyer-presented. +- BitVid must not expose a public API allowing arbitrary enumeration of buyer purchases. +- Playback tokens, signed URLs, entitlements, NWC URIs, and storage credentials must not enter logs. +- Client-side verification may improve UX but cannot be the sole control for a private media origin. +- Copied receipts must fail for a different authenticated pubkey. +- Valid receipts for unaccepted products must fail. +- Expired and revoked subscriptions must not mint new sessions. +- Cross-video and cross-channel capability substitution must fail. +- BitUnlock's service identity and accepted schema versions must be pinned. +- BitUnlock must never proxy video bytes. +- Private storage/CDN credentials must remain application-side or narrowly scoped and encrypted. + +## 13. Implementation phases + +### Phase 0 — Architecture and protocol decisions + +- [ ] Freeze the BitVid responsibility boundary with BitUnlock and BitRoad. +- [ ] Define `bitvid-access-policy-v1` and negative vectors. +- [ ] Decide the NIP-71 extension/reference format without breaking ordinary clients. +- [ ] Define resource IDs, catalog groups, product coordinates, and policy versioning. +- [ ] Define public, login-only, permanent, rental, membership, subscription, bundle, event, and + delayed-public routes. +- [ ] Threat-model buyer receipt privacy, copied receipts, cross-resource substitution, and playback + capability leakage. + +### Phase 1 — BitUnlock SDK and identity integration + +- [ ] Pin BitUnlock service policy and service pubkey. +- [ ] Add BitLogin/Nostr authenticated buyer context where not already available. +- [ ] Integrate entitlement recovery. +- [ ] Integrate `verifyEntitlement()` and revocation/status checks. +- [ ] Add typed access-decision results and normalized user-facing errors. +- [ ] Add unit tests for wrong buyer, wrong product, bad signature, expiry, and revocation. + +### Phase 2 — Locked media UX and checkout + +- [ ] Render locked cards and video pages without exposing protected playback URLs. +- [ ] Show accepted purchase/rental/subscription choices. +- [ ] Integrate BitUnlock checkout, partial-payment recovery, and completed-purchase recovery. +- [ ] Display current entitlement route, expiry, and renewal state. +- [ ] Accept entitlements produced by BitRoad purchases. +- [ ] Add sandbox fixtures for each access mode. + +### Phase 3 — Playback authorization service + +- [ ] Create a minimal trusted BitVid authorization service/edge function. +- [ ] Verify buyer authentication and presented entitlements server-side. +- [ ] Evaluate `bitvid-access-policy-v1`. +- [ ] Issue short-lived resource-scoped playback sessions. +- [ ] Integrate a private test origin with signed URL/cookie support. +- [ ] Add renewal during long playback and safe error recovery. +- [ ] Test cross-video, replay, expiry, and token-leak scenarios. + +### Phase 4 — Subscription MVP + +- [ ] Register one creator membership and one platform subscription product. +- [ ] Map subscription products to catalog groups. +- [ ] Add manual renewal UX. +- [ ] Demonstrate expiry, revocation, renewal, and new-device recovery. +- [ ] Add a subscription-management view. +- [ ] Enforce an initial concurrent-session policy. +- [ ] Complete a real mainnet end-to-end subscription and playback test after sandbox proof. + +### Phase 5 — Purchases, rentals, bundles, and events + +- [ ] Permanent individual video purchase. +- [ ] Purchase-relative rental. +- [ ] Bundle/season/course pass. +- [ ] Pay-per-view and replay window. +- [ ] Delayed public release. +- [ ] Creator tier benefits beyond playback. + +### Phase 6 — Production streaming + +- [ ] Choose HLS/DASH/CDN architecture. +- [ ] Implement manifest and segment authorization. +- [ ] Preserve MP4 range and seek behavior. +- [ ] Define CORS, CSP, cache, and origin rules. +- [ ] Add playback-session observability without leaking viewing details. +- [ ] Load-test authorization and renewal paths. +- [ ] Add incident suspension and recovery procedures. + +### Phase 7 — Devices and households + +- [ ] Delegated device keys or application sessions. +- [ ] Device list, revocation, and remote sign-out. +- [ ] Concurrent-stream limits by plan. +- [ ] Profiles and parental controls. +- [ ] Household-member authorization. +- [ ] Recovery without copying the root Nostr private key. + +### Phase 8 — Advanced billing + +- [ ] Design and audit restricted automatic NWC renewal. +- [ ] Trials, promotions, upgrades, downgrades, and plan transitions. +- [ ] Cashu/prepaid or batched metered viewing after BitUnlock fee accrual is ready. +- [ ] Creator revenue and platform-fee reporting without making BitVid custodial. + +## 14. Acceptance criteria + +The first complete reference implementation must demonstrate: + +1. A standard public NIP-71 video still works in ordinary clients. +2. A protected video reveals no permanent private-media URL publicly. +3. A buyer purchases a monthly BitUnlock subscription inside BitVid. +4. An independently versioned BitVid authorization component verifies the entitlement. +5. The component issues a five-minute capability for the correct video. +6. The media origin serves bytes directly without BitUnlock proxying them. +7. A copied entitlement fails for another pubkey. +8. A valid but unrelated entitlement fails. +9. Expiry and revocation prevent new playback sessions. +10. Manual renewal creates a new entitlement period and restores access. +11. The buyer recovers access on a new device without repaying. +12. An individual permanent purchase is accepted as an alternate route to the same video. +13. A BitRoad-originated purchase works without BitRoad being present during playback. +14. Logs and analytics contain no entitlement plaintext, NWC secrets, storage credentials, or + playback bearer tokens. + +## 15. Explicit non-goals for the first release + +- Automatic recurring charges. +- Full Netflix-style household detection. +- Strong DRM guarantees against screen capture or authorized-user copying. +- Private plaintext WebTorrent subscription streaming. +- Pay-per-minute billing. +- Geographic licensing enforcement. +- Recommendation-system redesign. +- Building BitUnlock-specific payment or entitlement logic directly into BitVid instead of using the + public SDK and generic contracts. From 4eaa78cf5978f0a5c9d0ebf0cdf5ea1bb13a1616 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Thu, 23 Jul 2026 21:32:49 -0400 Subject: [PATCH 2/5] docs: add BitUnlock permission layer TODO --- ...O_2026-07-23_bitunlock-permission-layer.md | 207 ++++++++++++++++++ 1 file changed, 207 insertions(+) create mode 100644 todo/TODO_2026-07-23_bitunlock-permission-layer.md diff --git a/todo/TODO_2026-07-23_bitunlock-permission-layer.md b/todo/TODO_2026-07-23_bitunlock-permission-layer.md new file mode 100644 index 00000000..b3d294bb --- /dev/null +++ b/todo/TODO_2026-07-23_bitunlock-permission-layer.md @@ -0,0 +1,207 @@ +# BitVid BitUnlock permission-layer TODO + +Date: 2026-07-23 + +Authoritative design: [`../docs/BITUNLOCK_PERMISSION_LAYER_PLAN.md`](../docs/BITUNLOCK_PERMISSION_LAYER_PLAN.md) + +BitUnlock generic profile: `PR0M3TH3AN/bitunlock/docs/STREAMING_SUBSCRIPTION_ACCESS_PROFILE.md` + +## Goal + +Evolve BitVid into a creator-controlled Nostr media platform supporting public videos, purchases, +rentals, memberships, platform subscriptions, bundles, and events while using BitUnlock as the single +generic payment-verification and entitlement layer. + +BitVid owns catalog and playback behavior. BitUnlock owns payment-backed permission. BitRoad remains an +optional storefront and is never required during playback. + +## Execution order + +Complete phases in order unless a documented dependency explicitly permits parallel work. Do not start +automatic recurring billing, households, or metered viewing before the single-user manual-renewal +subscription path is proven end to end. + +## Phase 0 — Freeze the boundary and policy format + +- [ ] Approve the responsibility split among BitVid, BitUnlock, BitRoad, and the media origin/CDN. +- [ ] Define `bitvid-access-policy-v1` as an OR-of-routes policy. +- [ ] Define routes for public, login-only, permanent purchase, purchase-relative rental, creator + membership, platform subscription, bundle/catalog pass, event admission, and delayed public + release. +- [ ] Decide whether NIP-71 events reference a separate replaceable access-policy event or carry a + minimal policy-coordinate tag. +- [ ] Preserve ordinary public NIP-71 interoperability. +- [ ] Define resource IDs, catalog groups, product coordinates, policy versions, and migration rules. +- [ ] Add schemas and negative vectors for malformed policies and cross-resource substitution. +- [ ] Threat-model receipt privacy, copied receipts, wrong buyers, wrong products, expired/revoked + terms, capability leakage, and public metadata disclosure. + +### Exit criterion + +A public video remains playable in an ordinary NIP-71 client, while a protected test video can publish +metadata and a locked state without exposing a permanent private playback URL. + +## Phase 1 — Identity, entitlement recovery, and verification + +- [ ] Pin the production and sandbox BitUnlock service identities. +- [ ] Reuse BitVid's signer facade for buyer authentication; add BitLogin as an adapter/integration + path without creating a second identity stack. +- [ ] Add a buyer entitlement store with local encrypted cache and recovery adapters. +- [ ] Integrate BitUnlock buyer recovery. +- [ ] Integrate `verifyEntitlement()` for buyer, service, product, signature, schema, and policy checks. +- [ ] Integrate expiry evaluation and online revocation/status checks where the route requires them. +- [ ] Return typed access decisions rather than booleans. +- [ ] Add unit tests for copied receipt, wrong buyer, wrong product, unsupported version, bad signature, + expiry, revocation, and unavailable status service. +- [ ] Ensure no public endpoint can enumerate a buyer's purchases. + +### Exit criterion + +A separately generated sandbox entitlement can be recovered and correctly accepted or rejected by +BitVid without custom payment or cryptographic logic outside the BitUnlock SDK. + +## Phase 2 — Locked media UX and checkout + +- [ ] Render locked video cards and modal/page states. +- [ ] Display the accepted routes for a protected video: buy, rent, join creator, subscribe, or buy + event pass. +- [ ] Integrate BitUnlock quote creation and wallet presentation. +- [ ] Resume partially paid orders without repaying settled legs. +- [ ] Unlock and validate the resulting fulfillment/entitlement. +- [ ] Recover completed purchases after refresh or on a new device. +- [ ] Display which route currently grants access and when it expires. +- [ ] Add manual renewal entry points. +- [ ] Accept compatible entitlements created by purchases initiated in BitRoad. +- [ ] Add fake-sats fixtures for every supported route. + +### Exit criterion + +A viewer can complete, interrupt, resume, and recover a sandbox purchase or subscription entirely from +the BitVid interface. + +## Phase 3 — Trusted playback authorization + +- [ ] Choose the minimal BitVid authorization-service/edge-function architecture. +- [ ] Require server-side buyer authentication for private-origin access. +- [ ] Verify buyer-presented BitUnlock entitlements in the trusted component. +- [ ] Evaluate `bitvid-access-policy-v1` server-side. +- [ ] Issue a short-lived, resource-scoped playback capability. +- [ ] Bind capabilities to buyer or delegated device, resource, route/entitlement, audience, expiry, + and session revision. +- [ ] Integrate one private test origin using a signed URL, signed cookie, or opaque origin session. +- [ ] Keep all video bytes out of BitUnlock. +- [ ] Exclude entitlements, signed URLs, playback tokens, storage credentials, and NWC data from logs. +- [ ] Test replay, cross-video substitution, wrong audience, token expiry, and session revocation. + +### Exit criterion + +An independently versioned BitVid authorization component verifies a subscription entitlement and +issues a five-minute capability for exactly one protected video, with bytes served directly by the +private origin. + +## Phase 4 — Manual-renewal subscription MVP + +- [ ] Register one creator monthly membership product in BitUnlock. +- [ ] Register one BitVid platform monthly subscription product. +- [ ] Map those products to separate catalog groups. +- [ ] Add current-plan and expiry UI. +- [ ] Add manual Lightning renewal against the same product. +- [ ] Confirm each renewal creates a new immutable entitlement period. +- [ ] Demonstrate seller revocation and natural expiry. +- [ ] Recover the active entitlement on another device/client. +- [ ] Enforce an initial one-active-playback-session policy. +- [ ] Complete the full flow in fake-sats sandbox. +- [ ] Complete one controlled real-mainnet subscription and playback test after sandbox proof. + +### Exit criterion + +A buyer subscribes, plays an included video, loses access after expiry/revocation, renews, regains +access, and recovers that access on another client without mutating the old receipt or repaying an +already completed period. + +## Phase 5 — Purchases, rentals, bundles, and events + +- [ ] Permanent individual-video purchase accepted as an alternate route to a subscription video. +- [ ] Purchase-relative rental. +- [ ] Season/course/collection pass. +- [ ] Pay-per-view event with optional replay window. +- [ ] Delayed public release. +- [ ] Creator patron tiers with benefits beyond playback. +- [ ] Clear UX when several routes are available or already owned. +- [ ] Refund/revocation policy documentation appropriate to each route. + +## Phase 6 — Production streaming + +- [ ] Select HLS/DASH packaging and CDN/origin architecture. +- [ ] Define manifest and segment authorization. +- [ ] Choose signed cookies, session headers, or query tokens based on CDN behavior. +- [ ] Renew playback authorization during long videos without another payment prompt. +- [ ] Preserve adaptive bitrate switching and seeking after session renewal. +- [ ] Preserve MP4 byte-range behavior. +- [ ] Define CORS and CSP for BitVid, authorization service, and media origins. +- [ ] Define cache behavior that does not make protected manifests public. +- [ ] Keep WebTorrent for public content; document encrypted permanent-purchase use and reject claims + of revocable private plaintext torrent streaming. +- [ ] Load-test authorization, renewal, and failure recovery. +- [ ] Add operational suspension and incident recovery. + +## Phase 7 — Devices, profiles, and households + +- [ ] Design buyer-authorized delegated device keys or application sessions. +- [ ] Add device naming, listing, expiry, and remote sign-out. +- [ ] Limit delegation to the BitVid audience and subscription scope. +- [ ] Add maximum-device and concurrent-stream policies by plan. +- [ ] Add profiles and parental controls as BitVid application state. +- [ ] Add household-member authorization without sharing the root Nostr key. +- [ ] Add recovery when a television/device key is lost. +- [ ] Threat-model stolen device sessions and delegation replay. + +## Phase 8 — Advanced billing + +- [ ] Write a separate automatic-renewal design using explicitly restricted buyer NWC authorization. +- [ ] Require merchant/product binding, amount cap, billing interval, cumulative limit, cancellation, + notification, and duplicate-charge protection. +- [ ] Complete independent security review before enabling automatic charges. +- [ ] Add trials, promotions, upgrades, downgrades, and plan transitions. +- [ ] Add Cashu/prepaid or batched metered viewing only after BitUnlock's batched fee accounting exists. +- [ ] Define non-custodial creator/platform revenue reporting. + +## Security and privacy gates + +- [ ] Client-side verification is never the sole control for a private origin. +- [ ] A copied entitlement fails for a different authenticated pubkey. +- [ ] A valid entitlement for an unaccepted product fails. +- [ ] Expired and revoked terms cannot create new playback sessions. +- [ ] Access policy, service identity, and schema versions are pinned. +- [ ] Protected NIP-71 metadata exposes no permanent private object URL or key. +- [ ] Buyer receipt recovery remains private and buyer-scoped. +- [ ] Logs contain no NWC credentials, storage credentials, entitlement plaintext, or bearer tokens. +- [ ] Cross-channel, cross-video, cross-product, and cross-tenant substitutions fail closed. +- [ ] BitUnlock is never used as a media proxy. + +## Reference acceptance suite + +- [ ] Public video works without BitUnlock. +- [ ] Login-only route works without payment. +- [ ] Permanent purchase route works. +- [ ] Rental route expires correctly. +- [ ] Creator membership route works. +- [ ] Platform subscription route works. +- [ ] Bundle/event routes work. +- [ ] Several routes can authorize the same video. +- [ ] BitRoad-originated entitlement works inside BitVid. +- [ ] Interrupted checkout recovers without duplicate payment. +- [ ] New-device entitlement recovery works. +- [ ] Wrong buyer/product/resource and copied receipts fail. +- [ ] Revocation and expiry stop new sessions. +- [ ] Playback capability is short-lived and resource-scoped. +- [ ] Origin serves bytes directly. + +## Deferred non-goals + +- Automatic charging in the first subscription release. +- Full household detection in the first subscription release. +- Strong DRM claims against screen capture or authorized-user copying. +- Private plaintext WebTorrent subscription delivery. +- Pay-per-minute Lightning invoices. +- Geographic media-rights enforcement. From 126a613527de36942aa9ff3d16be8722c20d0a98 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Thu, 23 Jul 2026 21:32:59 -0400 Subject: [PATCH 3/5] docs: link BitUnlock permission layer plan --- docs/README.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/docs/README.md b/docs/README.md index d854ff06..eccd934b 100644 --- a/docs/README.md +++ b/docs/README.md @@ -4,6 +4,7 @@ Welcome to the bitvid documentation. This directory contains detailed architectu ## Core Systems +- **[BitUnlock Permission Layer](./BITUNLOCK_PERMISSION_LAYER_PLAN.md)**: Architecture and phased plan for purchases, rentals, creator memberships, platform subscriptions, bundles, events, and short-lived playback authorization using BitUnlock as BitVid's permission layer. - **[Application Coordinators](./app-coordinators.md)**: Overview of the coordinator pattern used to manage app-wide state and services. - **[Data-Flow Refactor — Dev Plan & Spec](./architecture-refactor.md)**: The incremental plan to put all subsystems on a shared, governed relay/data foundation (SubscriptionManager, stores, unidirectional flow). - **[Known Bugs (deferred)](./KNOWN_BUGS.md)**: Confirmed defects/limitations found during the refactor, queued to fix later. @@ -36,4 +37,8 @@ Welcome to the bitvid documentation. This directory contains detailed architectu - **[Deployment Notes](./deployment-notes.md)**: Notes and checklists for deploying bitvid. - **[Manual QA Checklist](./qa.md)**: The standard manual QA script for releases. +## Active plans + +- **[BitUnlock Permission-Layer TODO](../todo/TODO_2026-07-23_bitunlock-permission-layer.md)**: Ordered implementation checklist for the BitUnlock-backed media permission and subscription work. + For more details on contributing, please see [CONTRIBUTING.md](../CONTRIBUTING.md). From f373bb9ce77d7e9b5896eb55838364e5469f3878 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Sun, 26 Jul 2026 08:58:58 -0400 Subject: [PATCH 4/5] fix: keep autoplay fallback from saving mute --- js/app/authSessionCoordinator.js | 13 +++++++++++- js/app/playbackCoordinator.js | 2 +- js/services/playbackService.js | 16 ++++++++++++++- js/utils/autoplayPreference.js | 27 +++++++++++++++++++++++++ js/webtorrent.js | 3 ++- tests/app/login-defers-dms.test.mjs | 27 +++++++++++++++++++++++++ tests/services/playbackService.test.mjs | 18 +++++++++++------ 7 files changed, 96 insertions(+), 10 deletions(-) create mode 100644 js/utils/autoplayPreference.js diff --git a/js/app/authSessionCoordinator.js b/js/app/authSessionCoordinator.js index 94986672..1c50994d 100644 --- a/js/app/authSessionCoordinator.js +++ b/js/app/authSessionCoordinator.js @@ -232,7 +232,18 @@ export function createAuthSessionCoordinator(deps) { } this.applyAuthenticatedUiState(); - this.commentController?.refreshAuthState?.(); + const activeModalVideo = + typeof this.videoModal?.getCurrentVideo === "function" + ? this.videoModal.getCurrentVideo() + : this.commentController?.currentVideo || null; + if (this.commentController && activeModalVideo) { + // Login can reconfigure relay-backed services. Reload the active thread + // instead of only enabling its composer so readable comments survive + // the transition from guest to authenticated state. + this.commentController.load(activeModalVideo); + } else { + this.commentController?.refreshAuthState?.(); + } this.updateShareNostrAuthState({ reason: "auth-login" }); if (typeof this.refreshUnreadDmIndicator === "function") { void this.refreshUnreadDmIndicator({ reason: "auth-login" }); diff --git a/js/app/playbackCoordinator.js b/js/app/playbackCoordinator.js index 6a4e2afe..f1ccf4ed 100644 --- a/js/app/playbackCoordinator.js +++ b/js/app/playbackCoordinator.js @@ -237,7 +237,7 @@ export function createPlaybackCoordinator(deps) { this.log("Autoplay failed:", err); if (!this.modalVideo.muted) { this.log("Falling back to muted autoplay."); - this.modalVideo.muted = true; + this.playbackService.muteForAutoplayFallback(this.modalVideo); this.modalVideo.play().catch((err2) => { this.log("Muted autoplay also failed:", err2); }); diff --git a/js/services/playbackService.js b/js/services/playbackService.js index e6b74bc6..f9adf2de 100644 --- a/js/services/playbackService.js +++ b/js/services/playbackService.js @@ -1,6 +1,10 @@ import { userLogger } from "../utils/logger.js"; import { PLAYBACK_START_TIMEOUT } from "../constants.js"; import { getCurrentVideo, setCurrentVideo } from "../state/appState.js"; +import { + consumeAutoplayFallbackMute, + muteForAutoplayFallback, +} from "../utils/autoplayPreference.js"; import { SimpleEventEmitter, extractWebSeedsFromMagnet, @@ -330,11 +334,17 @@ export class PlaybackService { return; } const storedUnmuted = localStorage.getItem("unmutedAutoplay"); - const userWantsUnmuted = storedUnmuted === "true"; + // Sound is the default for an explicit watch action. Browsers may still + // reject unmuted autoplay, in which case the coordinator retries muted. + // Preserve a viewer's explicit mute choice across subsequent videos. + const userWantsUnmuted = storedUnmuted !== "false"; videoElement.muted = !userWantsUnmuted; if (!videoElement.dataset.autoplayBound) { videoElement.addEventListener("volumechange", () => { + if (consumeAutoplayFallbackMute(videoElement)) { + return; + } localStorage.setItem( "unmutedAutoplay", (!videoElement.muted).toString() @@ -344,6 +354,10 @@ export class PlaybackService { } } + muteForAutoplayFallback(videoElement) { + return muteForAutoplayFallback(videoElement); + } + /** * Registers a set of event listeners ("watchdogs") on the video element to detect * when playback has stalled or failed. diff --git a/js/utils/autoplayPreference.js b/js/utils/autoplayPreference.js new file mode 100644 index 00000000..f5cce19f --- /dev/null +++ b/js/utils/autoplayPreference.js @@ -0,0 +1,27 @@ +const FALLBACK_MUTE_DATASET_KEY = "bitvidAutoplayFallbackMute"; + +/** + * Mute playback after an unmuted autoplay rejection without recording the + * browser-policy fallback as a viewer preference. + */ +export function muteForAutoplayFallback(videoElement) { + if (!videoElement || videoElement.muted) { + return false; + } + + if (videoElement.dataset) { + videoElement.dataset[FALLBACK_MUTE_DATASET_KEY] = "true"; + } + videoElement.muted = true; + return true; +} + +/** Consume the marker attached to the next fallback-generated volume change. */ +export function consumeAutoplayFallbackMute(videoElement) { + if (videoElement?.dataset?.[FALLBACK_MUTE_DATASET_KEY] !== "true") { + return false; + } + + delete videoElement.dataset[FALLBACK_MUTE_DATASET_KEY]; + return true; +} diff --git a/js/webtorrent.js b/js/webtorrent.js index 5264177c..6c5ee499 100644 --- a/js/webtorrent.js +++ b/js/webtorrent.js @@ -30,6 +30,7 @@ import { waitForActiveController as waitForActiveControllerHelper, } from "./webtorrentHelpers.js"; import { devLogger, userLogger } from "./utils/logger.js"; +import { muteForAutoplayFallback } from "./utils/autoplayPreference.js"; import { emit } from "./embedDiagnostics.js"; const DEFAULT_PROBE_TRACKERS = Object.freeze([...WSS_TRACKERS]); @@ -561,7 +562,7 @@ export class TorrentClient { return; } this.log(`Retrying with muted autoplay (${context} path).`); - videoElement.muted = true; + muteForAutoplayFallback(videoElement); videoElement.play().catch((err2) => { this.log(`Muted autoplay also failed (${context} path):`, err2); }); diff --git a/tests/app/login-defers-dms.test.mjs b/tests/app/login-defers-dms.test.mjs index 6f37003d..68620d15 100644 --- a/tests/app/login-defers-dms.test.mjs +++ b/tests/app/login-defers-dms.test.mjs @@ -151,3 +151,30 @@ test("login does not eagerly load DM history; it is deferred", async () => { "the lightweight unread DM indicator should still run so the badge stays accurate", ); }); + +test("login reloads the open video comment thread", async () => { + globalThis.window.location = { search: "", href: "http://localhost/" }; + + const coord = makeCoordinator({ + loadDirectMessages: async () => ({ ok: true }), + refreshUnreadDmIndicator: async () => {}, + }); + const activeVideo = { id: "b".repeat(64), title: "Open video" }; + const loadedVideos = []; + coord.videoModal = { getCurrentVideo: () => activeVideo }; + coord.commentController = { + load: (video) => loadedVideos.push(video), + refreshAuthState: () => { + throw new Error("an open thread should be reloaded, not only refreshed"); + }, + }; + + await coord.handleAuthLogin({ + pubkey: ACTIVE_PUBKEY, + identityChanged: true, + postLogin: { profile: { name: "tester" } }, + postLoginPromise: Promise.resolve({ profile: { name: "tester" } }), + }); + + assert.deepEqual(loadedVideos, [activeVideo]); +}); diff --git a/tests/services/playbackService.test.mjs b/tests/services/playbackService.test.mjs index 115ce348..a6dcf57d 100644 --- a/tests/services/playbackService.test.mjs +++ b/tests/services/playbackService.test.mjs @@ -44,21 +44,27 @@ describe("PlaybackService", () => { const service = new PlaybackService(); const video = document.createElement("video"); - // Default: muted (unless localStorage says unmuted) + // Default: sound on unless the viewer explicitly chose mute. localStorage.removeItem("unmutedAutoplay"); service.prepareVideoElement(video); - assert.equal(video.muted, true); + assert.equal(video.muted, false); assert.equal(video.dataset.autoplayBound, "true"); - // Simulate volume change - video.muted = false; + // Simulate an explicit mute choice. + video.muted = true; video.dispatchEvent(new window.Event("volumechange")); - assert.equal(localStorage.getItem("unmutedAutoplay"), "true"); + assert.equal(localStorage.getItem("unmutedAutoplay"), "false"); // Test respecting stored value const video2 = document.createElement("video"); service.prepareVideoElement(video2); - assert.equal(video2.muted, false); + assert.equal(video2.muted, true); + + // A browser-policy fallback is temporary and must not become a saved mute. + localStorage.setItem("unmutedAutoplay", "true"); + video2.muted = false; + service.muteForAutoplayFallback(video2); + assert.equal(localStorage.getItem("unmutedAutoplay"), "true"); }); test("registerUrlPlaybackWatchdogs triggers onFallback on error", () => { From ce14004725bad67b6b534e6a2d94852c1300e231 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Sun, 26 Jul 2026 09:23:05 -0400 Subject: [PATCH 5/5] Preserve autoplay preference on fallback; reload open comment thread after login ### Motivation - Prevent a browser-policy muted-autoplay retry from overwriting a viewer's explicit mute/unmute preference and ensure fallback mutes are transient. - Ensure that when a user logs in while a video comment modal is open, the open thread is reloaded (not just refreshed) so comments continue to display correctly after relay-backed services are reconfigured. ### Description - Add `js/utils/autoplayPreference.js` with `muteForAutoplayFallback` and `consumeAutoplayFallbackMute` helpers to mark and consume transient fallback mutes. - Update `js/services/playbackService.js` to treat sound as default unless the viewer explicitly chose mute, persist explicit choices via `localStorage`, ignore fallback-generated volume changes using `consumeAutoplayFallbackMute`, and expose `muteForAutoplayFallback` delegating to the new util. - Replace direct `videoElement.muted = true` calls with `playbackService.muteForAutoplayFallback` in `js/app/playbackCoordinator.js` and `js/webtorrent.js` so fallback mutes are marked as transient. - Change `js/app/authSessionCoordinator.js` to reload the currently open video comment thread with `commentController.load(activeModalVideo)` after login if a modal video is active, instead of only calling `refreshAuthState`. - Update tests in `tests/services/playbackService.test.mjs` and add a test in `tests/app/login-defers-dms.test.mjs` to cover the login behavior that reloads an open comment thread. ### Testing - Ran the `PlaybackService` unit tests (`tests/services/playbackService.test.mjs`) which assert `prepareVideoElement` behavior, explicit mute persistence, and the transient nature of fallback mute, and they passed. - Ran the auth login unit tests including `tests/app/login-defers-dms.test.mjs` which verifies DM deferral and reload of an open comment thread, and they passed. --- js/app/authSessionCoordinator.js | 13 +++- js/app/playbackCoordinator.js | 2 +- js/feedEngine/index.js | 1 + js/feedEngine/mostZappedFeed.js | 25 +++++-- js/feedEngine/sources.js | 83 +++++++++++++++++++++++ js/services/playbackService.js | 16 ++++- js/ui/bitloginModalIntegration.js | 33 ++++++--- js/utils/autoplayPreference.js | 27 ++++++++ js/webtorrent.js | 3 +- tests/app/login-defers-dms.test.mjs | 27 ++++++++ tests/bitlogin-modal-integration.test.mjs | 24 ++++++- tests/most-zapped-source.test.mjs | 73 ++++++++++++++++++++ tests/services/playbackService.test.mjs | 18 +++-- todo/TODO_2026-06-20_pre-launch.md | 25 ++++--- 14 files changed, 337 insertions(+), 33 deletions(-) create mode 100644 js/utils/autoplayPreference.js create mode 100644 tests/most-zapped-source.test.mjs diff --git a/js/app/authSessionCoordinator.js b/js/app/authSessionCoordinator.js index 94986672..1c50994d 100644 --- a/js/app/authSessionCoordinator.js +++ b/js/app/authSessionCoordinator.js @@ -232,7 +232,18 @@ export function createAuthSessionCoordinator(deps) { } this.applyAuthenticatedUiState(); - this.commentController?.refreshAuthState?.(); + const activeModalVideo = + typeof this.videoModal?.getCurrentVideo === "function" + ? this.videoModal.getCurrentVideo() + : this.commentController?.currentVideo || null; + if (this.commentController && activeModalVideo) { + // Login can reconfigure relay-backed services. Reload the active thread + // instead of only enabling its composer so readable comments survive + // the transition from guest to authenticated state. + this.commentController.load(activeModalVideo); + } else { + this.commentController?.refreshAuthState?.(); + } this.updateShareNostrAuthState({ reason: "auth-login" }); if (typeof this.refreshUnreadDmIndicator === "function") { void this.refreshUnreadDmIndicator({ reason: "auth-login" }); diff --git a/js/app/playbackCoordinator.js b/js/app/playbackCoordinator.js index 6a4e2afe..f1ccf4ed 100644 --- a/js/app/playbackCoordinator.js +++ b/js/app/playbackCoordinator.js @@ -237,7 +237,7 @@ export function createPlaybackCoordinator(deps) { this.log("Autoplay failed:", err); if (!this.modalVideo.muted) { this.log("Falling back to muted autoplay."); - this.modalVideo.muted = true; + this.playbackService.muteForAutoplayFallback(this.modalVideo); this.modalVideo.play().catch((err2) => { this.log("Muted autoplay also failed:", err2); }); diff --git a/js/feedEngine/index.js b/js/feedEngine/index.js index ec4b91fc..4ee83b51 100644 --- a/js/feedEngine/index.js +++ b/js/feedEngine/index.js @@ -21,6 +21,7 @@ export { } from "./sorters.js"; export { createActiveNostrSource, + createWhitelistedAuthorsNostrSource, createSubscriptionAuthorsSource, createWatchHistoryPointerSource, } from "./sources.js"; diff --git a/js/feedEngine/mostZappedFeed.js b/js/feedEngine/mostZappedFeed.js index 7fcde6ec..ae9169a8 100644 --- a/js/feedEngine/mostZappedFeed.js +++ b/js/feedEngine/mostZappedFeed.js @@ -1,8 +1,9 @@ // js/feedEngine/mostZappedFeed.js // -// The "Most Zapped" tab (#47): the recently-added (active) source ranked by -// ZAP TOTAL (sats from kind-9735 receipts). Mirrors trendingFeed.js — lives -// outside feedCoordinator.js (size cap) and is wired in via thin delegations. +// The "Most Zapped" tab (#47): the catalog of approved creators ranked by +// ZAP TOTAL (sats from kind-9735 receipts). It is intentionally scoped to the +// creator whitelist for discovery, but still runs the ordinary moderation +// stages — whitelisting does not waive Web-of-Trust handling. // The metric getter uses requestVideoZapTotal, which returns the cached total // AND schedules a batched receipt fetch for unknown pointers; the Most Zapped // view re-runs the feed on zapTotals' change signal so the order settles as @@ -11,7 +12,7 @@ import { FEED_TYPES } from "../constants.js"; import { requestVideoZapTotal } from "../zapTotals.js"; import { - createActiveNostrSource, + createWhitelistedAuthorsNostrSource, createBlacklistFilterStage, createDedupeByRootStage, createModerationStage, @@ -43,6 +44,19 @@ function makeGetZapTotal(app) { }; } +function getWhitelistedAuthors(app) { + const accessControl = app?.nostrService?.accessControl; + if (typeof accessControl?.getWhitelistPubkeys !== "function") { + return []; + } + try { + const authors = accessControl.getWhitelistPubkeys(); + return Array.isArray(authors) ? authors : []; + } catch (error) { + return []; + } +} + export function buildMostZappedFeedRuntime(app) { const blacklist = app.blacklistedEventIds instanceof Set @@ -59,6 +73,7 @@ export function buildMostZappedFeedRuntime(app) { ? { ...moderationThresholds } : undefined, getZapTotal: makeGetZapTotal(app), + whitelistedAuthors: getWhitelistedAuthors(app), }; } @@ -94,7 +109,7 @@ export function registerMostZappedFeed(app) { try { return app.feedEngine.registerFeed(FEED_TYPES.MOST_ZAPPED, { - source: createActiveNostrSource({ service: app.nostrService }), + source: createWhitelistedAuthorsNostrSource({ service: app.nostrService }), stages: [ createBlacklistFilterStage({ shouldIncludeVideo: (video, options) => diff --git a/js/feedEngine/sources.js b/js/feedEngine/sources.js index bf7bf2a3..2b086741 100644 --- a/js/feedEngine/sources.js +++ b/js/feedEngine/sources.js @@ -62,6 +62,89 @@ export function createActiveNostrSource({ service } = {}) { }; } +// Bounded discovery source for curated surfaces such as Most Zapped. It never +// falls back to an unscoped relay request: the caller must provide approved +// creator pubkeys in runtime.whitelistedAuthors. The successful remote result +// is retained briefly so an asynchronous ranking update does not turn into a +// second full relay scan for the same author set. +export function createWhitelistedAuthorsNostrSource({ + service, + refreshTtlMs = 2 * 60 * 1000, + now = () => Date.now(), +} = {}) { + const resolvedService = resolveService(service, nostrService); + let cachedAuthorsKey = ""; + let cachedVideos = []; + let fetchedAt = 0; + let inFlight = null; + + const normalizeAuthors = (input) => + Array.from( + new Set( + toArray(input) + .map(normalizeAuthor) + .filter(Boolean), + ), + ).sort(); + + return async function whitelistedAuthorsNostrSource(context = {}) { + const authors = normalizeAuthors(context?.runtime?.whitelistedAuthors); + const authorsKey = authors.join(","); + if (!authors.length) { + cachedAuthorsKey = ""; + cachedVideos = []; + fetchedAt = 0; + return []; + } + + const cacheIsCurrent = + authorsKey === cachedAuthorsKey && + now() - fetchedAt < Math.max(0, Number(refreshTtlMs) || 0); + + if (!cacheIsCurrent && !inFlight) { + const fetchByAuthors = resolvedService?.fetchVideosByAuthors; + inFlight = Promise.resolve( + typeof fetchByAuthors === "function" + ? fetchByAuthors.call(resolvedService, authors) + : [], + ) + .then((videos) => { + const allowed = new Set(authors); + cachedAuthorsKey = authorsKey; + cachedVideos = (Array.isArray(videos) ? videos : []).filter( + (video) => allowed.has(normalizeAuthor(video?.pubkey)), + ); + fetchedAt = now(); + return cachedVideos; + }) + .catch((error) => { + context?.log?.( + "[whitelisted-authors-source] Failed to fetch approved creators", + error, + ); + return authorsKey === cachedAuthorsKey ? cachedVideos : []; + }) + .finally(() => { + inFlight = null; + }); + } + + const videos = cacheIsCurrent ? cachedVideos : await inFlight; + // The whitelist may change while a prior author-scoped request is still in + // flight. Filter again at return time so that stale results can never + // briefly render after an administrator removes a creator. + const allowed = new Set(authors); + return (Array.isArray(videos) ? videos : []) + .filter((video) => allowed.has(normalizeAuthor(video?.pubkey))) + .map((video) => ({ + video, + metadata: { + source: "nostr:whitelisted-authors", + }, + })); + }; +} + export function createSubscriptionAuthorsSource({ service } = {}) { const resolvedService = resolveService(service, nostrService); diff --git a/js/services/playbackService.js b/js/services/playbackService.js index e6b74bc6..f9adf2de 100644 --- a/js/services/playbackService.js +++ b/js/services/playbackService.js @@ -1,6 +1,10 @@ import { userLogger } from "../utils/logger.js"; import { PLAYBACK_START_TIMEOUT } from "../constants.js"; import { getCurrentVideo, setCurrentVideo } from "../state/appState.js"; +import { + consumeAutoplayFallbackMute, + muteForAutoplayFallback, +} from "../utils/autoplayPreference.js"; import { SimpleEventEmitter, extractWebSeedsFromMagnet, @@ -330,11 +334,17 @@ export class PlaybackService { return; } const storedUnmuted = localStorage.getItem("unmutedAutoplay"); - const userWantsUnmuted = storedUnmuted === "true"; + // Sound is the default for an explicit watch action. Browsers may still + // reject unmuted autoplay, in which case the coordinator retries muted. + // Preserve a viewer's explicit mute choice across subsequent videos. + const userWantsUnmuted = storedUnmuted !== "false"; videoElement.muted = !userWantsUnmuted; if (!videoElement.dataset.autoplayBound) { videoElement.addEventListener("volumechange", () => { + if (consumeAutoplayFallbackMute(videoElement)) { + return; + } localStorage.setItem( "unmutedAutoplay", (!videoElement.muted).toString() @@ -344,6 +354,10 @@ export class PlaybackService { } } + muteForAutoplayFallback(videoElement) { + return muteForAutoplayFallback(videoElement); + } + /** * Registers a set of event listeners ("watchdogs") on the video element to detect * when playback has stalled or failed. diff --git a/js/ui/bitloginModalIntegration.js b/js/ui/bitloginModalIntegration.js index eec967eb..a909ca1d 100644 --- a/js/ui/bitloginModalIntegration.js +++ b/js/ui/bitloginModalIntegration.js @@ -46,6 +46,14 @@ function closeLoginModal() { } } +function isHtmlElement(value, documentRef = document) { + const HTMLElementConstructor = + documentRef?.defaultView?.HTMLElement || globalThis.HTMLElement; + return typeof HTMLElementConstructor === "function" + ? value instanceof HTMLElementConstructor + : Boolean(value && value.nodeType === 1); +} + // Cheap insurance against wireBitloginLogin() ever running twice for the same // element (e.g. a future bootstrap change that calls it more than once): the // same instance should only ever get one "bitlogin-login" @@ -82,12 +90,12 @@ function attachWidget(app, widget) { // Unstable-branch experiment (AGENTS.md §1, config/instance-config.js) -- // leave the permanent, statically-declared markup in place but never load // the vendored bundle or show the (otherwise-empty, unupgraded) element. - if (mount instanceof HTMLElement) { + if (isHtmlElement(mount)) { mount.hidden = true; } return; } - if (mount instanceof HTMLElement) { + if (isHtmlElement(mount)) { mount.hidden = false; } @@ -162,22 +170,29 @@ function attachWidget(app, widget) { * @param {{ authService: { requestLogin: Function } }} app */ export function wireBitloginLogin(app) { - if (!app?.authService) { + if (!app?.authService || typeof document === "undefined") { return; } - const existing = document.getElementById("bitloginWidget"); - if (existing instanceof HTMLElement) { + const documentRef = document; + const existing = documentRef.getElementById("bitloginWidget"); + if (isHtmlElement(existing, documentRef)) { attachWidget(app, existing); return; } - const observer = new MutationObserver(() => { - const widget = document.getElementById("bitloginWidget"); - if (widget instanceof HTMLElement) { + const Observer = + documentRef.defaultView?.MutationObserver || globalThis.MutationObserver; + if (typeof Observer !== "function" || !documentRef.body) { + return; + } + + const observer = new Observer(() => { + const widget = documentRef.getElementById("bitloginWidget"); + if (isHtmlElement(widget, documentRef)) { observer.disconnect(); attachWidget(app, widget); } }); - observer.observe(document.body, { childList: true, subtree: true }); + observer.observe(documentRef.body, { childList: true, subtree: true }); } diff --git a/js/utils/autoplayPreference.js b/js/utils/autoplayPreference.js new file mode 100644 index 00000000..f5cce19f --- /dev/null +++ b/js/utils/autoplayPreference.js @@ -0,0 +1,27 @@ +const FALLBACK_MUTE_DATASET_KEY = "bitvidAutoplayFallbackMute"; + +/** + * Mute playback after an unmuted autoplay rejection without recording the + * browser-policy fallback as a viewer preference. + */ +export function muteForAutoplayFallback(videoElement) { + if (!videoElement || videoElement.muted) { + return false; + } + + if (videoElement.dataset) { + videoElement.dataset[FALLBACK_MUTE_DATASET_KEY] = "true"; + } + videoElement.muted = true; + return true; +} + +/** Consume the marker attached to the next fallback-generated volume change. */ +export function consumeAutoplayFallbackMute(videoElement) { + if (videoElement?.dataset?.[FALLBACK_MUTE_DATASET_KEY] !== "true") { + return false; + } + + delete videoElement.dataset[FALLBACK_MUTE_DATASET_KEY]; + return true; +} diff --git a/js/webtorrent.js b/js/webtorrent.js index 5264177c..6c5ee499 100644 --- a/js/webtorrent.js +++ b/js/webtorrent.js @@ -30,6 +30,7 @@ import { waitForActiveController as waitForActiveControllerHelper, } from "./webtorrentHelpers.js"; import { devLogger, userLogger } from "./utils/logger.js"; +import { muteForAutoplayFallback } from "./utils/autoplayPreference.js"; import { emit } from "./embedDiagnostics.js"; const DEFAULT_PROBE_TRACKERS = Object.freeze([...WSS_TRACKERS]); @@ -561,7 +562,7 @@ export class TorrentClient { return; } this.log(`Retrying with muted autoplay (${context} path).`); - videoElement.muted = true; + muteForAutoplayFallback(videoElement); videoElement.play().catch((err2) => { this.log(`Muted autoplay also failed (${context} path):`, err2); }); diff --git a/tests/app/login-defers-dms.test.mjs b/tests/app/login-defers-dms.test.mjs index 6f37003d..68620d15 100644 --- a/tests/app/login-defers-dms.test.mjs +++ b/tests/app/login-defers-dms.test.mjs @@ -151,3 +151,30 @@ test("login does not eagerly load DM history; it is deferred", async () => { "the lightweight unread DM indicator should still run so the badge stays accurate", ); }); + +test("login reloads the open video comment thread", async () => { + globalThis.window.location = { search: "", href: "http://localhost/" }; + + const coord = makeCoordinator({ + loadDirectMessages: async () => ({ ok: true }), + refreshUnreadDmIndicator: async () => {}, + }); + const activeVideo = { id: "b".repeat(64), title: "Open video" }; + const loadedVideos = []; + coord.videoModal = { getCurrentVideo: () => activeVideo }; + coord.commentController = { + load: (video) => loadedVideos.push(video), + refreshAuthState: () => { + throw new Error("an open thread should be reloaded, not only refreshed"); + }, + }; + + await coord.handleAuthLogin({ + pubkey: ACTIVE_PUBKEY, + identityChanged: true, + postLogin: { profile: { name: "tester" } }, + postLoginPromise: Promise.resolve({ profile: { name: "tester" } }), + }); + + assert.deepEqual(loadedVideos, [activeVideo]); +}); diff --git a/tests/bitlogin-modal-integration.test.mjs b/tests/bitlogin-modal-integration.test.mjs index 3e930937..32f5b2a2 100644 --- a/tests/bitlogin-modal-integration.test.mjs +++ b/tests/bitlogin-modal-integration.test.mjs @@ -1,7 +1,10 @@ import test from "node:test"; import assert from "node:assert/strict"; -import { resolveVerifiedBitloginPubkey } from "../js/ui/bitloginModalIntegration.js"; +import { + resolveVerifiedBitloginPubkey, + wireBitloginLogin, +} from "../js/ui/bitloginModalIntegration.js"; const PUBKEY = "a".repeat(64); @@ -19,3 +22,22 @@ test("BitLogin rejects malformed claims and widgets without a worker-backed publ assert.equal(await resolveVerifiedBitloginPubkey({}, PUBKEY), ""); assert.equal(await resolveVerifiedBitloginPubkey({ getPublicKey: async () => PUBKEY }, "npub1nope"), ""); }); + +test("BitLogin bootstrap safely defers when MutationObserver is unavailable", () => { + const originalDocument = globalThis.document; + const originalObserver = globalThis.MutationObserver; + globalThis.document = { + body: {}, + defaultView: {}, + getElementById: () => null, + }; + try { + delete globalThis.MutationObserver; + assert.doesNotThrow(() => wireBitloginLogin({ authService: {} })); + } finally { + if (originalDocument === undefined) delete globalThis.document; + else globalThis.document = originalDocument; + if (originalObserver === undefined) delete globalThis.MutationObserver; + else globalThis.MutationObserver = originalObserver; + } +}); diff --git a/tests/most-zapped-source.test.mjs b/tests/most-zapped-source.test.mjs new file mode 100644 index 00000000..d071083e --- /dev/null +++ b/tests/most-zapped-source.test.mjs @@ -0,0 +1,73 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { createWhitelistedAuthorsNostrSource } from "../js/feedEngine/sources.js"; + +test("Most Zapped discovery fetches only whitelisted creators and caches the catalog", async () => { + const calls = []; + const approved = "a".repeat(64); + const unapproved = "b".repeat(64); + let now = 1_000; + const source = createWhitelistedAuthorsNostrSource({ + now: () => now, + service: { + async fetchVideosByAuthors(authors) { + calls.push(authors); + return [ + { id: "approved", pubkey: approved, created_at: 20 }, + { id: "unapproved", pubkey: unapproved, created_at: 30 }, + ]; + }, + }, + }); + + const runtime = { whitelistedAuthors: [approved.toUpperCase()] }; + const first = await source({ runtime }); + const rerank = await source({ runtime }); + + assert.deepEqual(calls, [[approved]], "relay lookup is author-scoped once"); + assert.deepEqual(first.map((entry) => entry.video.id), ["approved"]); + assert.deepEqual(rerank.map((entry) => entry.video.id), ["approved"]); + + now += 2 * 60 * 1000; + await source({ runtime }); + assert.equal(calls.length, 2, "catalog refreshes after its bounded TTL"); +}); + +test("Most Zapped discovery is empty without approved creators and never queries globally", async () => { + let calls = 0; + const source = createWhitelistedAuthorsNostrSource({ + service: { + async fetchVideosByAuthors() { + calls += 1; + return [{ id: "must-not-leak", pubkey: "c".repeat(64) }]; + }, + }, + }); + + const result = await source({ runtime: { whitelistedAuthors: [] } }); + assert.deepEqual(result, []); + assert.equal(calls, 0); +}); + +test("Most Zapped never leaks a removed creator while an earlier lookup is in flight", async () => { + const removed = "d".repeat(64); + const remaining = "e".repeat(64); + let resolveFetch; + const source = createWhitelistedAuthorsNostrSource({ + service: { + fetchVideosByAuthors() { + return new Promise((resolve) => { + resolveFetch = resolve; + }); + }, + }, + }); + + const first = source({ runtime: { whitelistedAuthors: [removed] } }); + const afterRemoval = source({ runtime: { whitelistedAuthors: [remaining] } }); + resolveFetch([{ id: "removed-video", pubkey: removed }]); + + assert.deepEqual((await first).map((entry) => entry.video.id), ["removed-video"]); + assert.deepEqual(await afterRemoval, []); +}); diff --git a/tests/services/playbackService.test.mjs b/tests/services/playbackService.test.mjs index 115ce348..a6dcf57d 100644 --- a/tests/services/playbackService.test.mjs +++ b/tests/services/playbackService.test.mjs @@ -44,21 +44,27 @@ describe("PlaybackService", () => { const service = new PlaybackService(); const video = document.createElement("video"); - // Default: muted (unless localStorage says unmuted) + // Default: sound on unless the viewer explicitly chose mute. localStorage.removeItem("unmutedAutoplay"); service.prepareVideoElement(video); - assert.equal(video.muted, true); + assert.equal(video.muted, false); assert.equal(video.dataset.autoplayBound, "true"); - // Simulate volume change - video.muted = false; + // Simulate an explicit mute choice. + video.muted = true; video.dispatchEvent(new window.Event("volumechange")); - assert.equal(localStorage.getItem("unmutedAutoplay"), "true"); + assert.equal(localStorage.getItem("unmutedAutoplay"), "false"); // Test respecting stored value const video2 = document.createElement("video"); service.prepareVideoElement(video2); - assert.equal(video2.muted, false); + assert.equal(video2.muted, true); + + // A browser-policy fallback is temporary and must not become a saved mute. + localStorage.setItem("unmutedAutoplay", "true"); + video2.muted = false; + service.muteForAutoplayFallback(video2); + assert.equal(localStorage.getItem("unmutedAutoplay"), "true"); }); test("registerUrlPlaybackWatchdogs triggers onFallback on error", () => { diff --git a/todo/TODO_2026-06-20_pre-launch.md b/todo/TODO_2026-06-20_pre-launch.md index 6bc13a77..d0154955 100644 --- a/todo/TODO_2026-06-20_pre-launch.md +++ b/todo/TODO_2026-06-20_pre-launch.md @@ -615,7 +615,7 @@ toward freshness and looked identical. Gave each a structural identity: handled). ### 47. "Most Zapped" sidebar tab (trending-by-zaps) -- [ ] **NEXT (planned, not started): bitvid-native zap tally.** Full spec + +- [x] **DONE 2026-07-26: bitvid-native zap tally.** Full spec + ordered task checklist in `docs/zap-tally-plan.md`. Problem: NIP-57 zap receipts (9735) are published by the RECIPIENT's LNURL server, and custodial wallets (Strike) don't — so paid zaps leave no on-relay record @@ -624,12 +624,13 @@ toward freshness and looked identical. Gave each a structural identity: the preimage→payment_hash + description_hash→zapRequest binding, reusing the existing bolt11 decode in `zapReceiptValidator.js`), counted alongside real 9735s (deduped by payment_hash, 9735-preferred). Behind - `FEATURE_ZAP_TALLY` (default off). Plan also covers a + `FEATURE_ZAP_TALLY` (default on). Plan also covers a SECOND orange zaps-over-time line on the per-video Popularity chart with a legend (red=Views/orange=Zaps) and visible date labels (docs §5.9). See the - plan for the file-by-file changes. Current state (commit `cbc62306`): 9735 counting + optimistic - bump + durable local ledger (`bitvid:sentZaps:v1`) already shipped; the - tally is the missing global/cross-user source. + plan for the file-by-file changes. Current state: kind-9735 counting, + optimistic bump + durable local ledger (`bitvid:sentZaps:v1`), and the + verified tally source are shipped; receipts and tallies are deduped by + payment hash. - [x] **DONE 2026-07-03.** Full Trending-pattern clone ranked by SATS: - `js/zapTotals.js` — per-pointer kind-9735 aggregation. The feed's getZapTotal reads the cached total AND schedules a BATCHED one-shot @@ -647,18 +648,26 @@ toward freshness and looked identical. Gave each a structural identity: link, viewManager registry, feedCoordinator case/loader, `FEED_TYPES.MOST_ZAPPED`, `FEATURE_MOST_ZAPPED_FEED` flag (default on; off hides the tab like Trending's flag). + - **Discovery hardened 2026-07-26:** fetches the bounded catalog for + creator-whitelist pubkeys only (never an unscoped relay query), then + ranks those videos by zaps. The normal blacklist and Web-of-Trust + stages still run; whitelist membership is not a moderation override. + The catalog is briefly cached so zap-total re-ranks do not repeatedly + scan relays, and an in-flight result is re-filtered after whitelist + changes so removed creators cannot briefly appear. Tests: `tests/zap-totals.test.mjs` (5). Lint + build green. - [x] **Follow-up DONE 2026-07-03 — per-card zap badges.** Video cards now show the zap total in bitcoin ORANGE (new `--color-zap` token, both themes), RIGHT-ALIGNED in the engagement row (views/comments stay left; CSS - `margin-inline-start: auto` on `.video-card__zaps`). Hidden until a - nonzero total is known (no "0 sats" noise). Binder + `margin-inline-start: auto` on `.video-card__zaps`). The badge is visible + from bind (showing `0 sats` until receipts arrive). Binder (`js/ui/views/videoCardZapTotals.js`, VideoListView file-size budget) fills `[data-zap-total]` from the shared zapTotals cache — bind() also schedules the batched receipt fetch, and one change listener updates every bound card as batches land. Test: `tests/video-card-zap-totals.test.mjs` (drives the REAL store). -- [ ] **VERIFY on unstable:** open Most Zapped → zapped videos rise as receipts +- [ ] **VERIFY on unstable:** open Most Zapped → only whitelisted creators are + discovered; zapped videos rise as receipts load (unzapped feed reads as Recent at first) and their cards show the orange right-aligned sats badge; unzapped cards show no badge; zap a video → within ~2min of tab re-entry its rank + badge reflect the new