merge: promote unstable → beta — Blossom companion-event P2P, blob management (BUD-03/BUD-12), storage sync + selector fixes (#30)

This commit is contained in:
thePR0M3TH3AN committed 2026-07-12 22:07:02 -04:00
commit 5313675896
44 files changed
+5109 -30

No files matched your search

+64 -1
View File
@@ -860,8 +860,42 @@
<p class="text-sm text-muted">
Connect a Nostr Wallet Connect (NWC) compatible wallet to zap creators directly from bitvid.
</p>
<div id="profileWalletBitcoinConnectRow" class="hidden space-y-2">
<button
id="profileWalletBitcoinConnect"
type="button"
class="btn focus-ring inline-flex items-center gap-2"
>
<svg
class="h-4 w-4"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
<path d="M13 2 4 14h6l-1 8 9-12h-6l1-8Z"></path>
</svg>
Connect wallet
</button>
<p class="text-xs text-muted">
One-tap connect via Bitcoin Connect (NWC) — or paste a connection manually below.
</p>
</div>
<div class="space-y-4">
<label class="block text-sm text-muted-strong" for="profileWalletUri">
<details id="profileWalletAdvanced" class="wallet-advanced">
<summary
id="profileWalletAdvancedSummary"
class="wallet-advanced__summary cursor-pointer text-xs font-medium text-muted-strong hover:text-text"
>
Advanced — paste a connection manually
</summary>
<label
class="mt-3 block text-sm text-muted-strong"
for="profileWalletUri"
>
<span class="mb-2 block font-medium">Wallet Connect URI</span>
<input
id="profileWalletUri"
@@ -870,6 +904,7 @@
class="input"
/>
</label>
</details>
<label class="block text-sm text-muted-strong" for="profileWalletDefaultZap">
<span class="mb-2 block font-medium">Default zap amount (sats)</span>
<input
@@ -977,9 +1012,35 @@
<option value="cloudflare_r2" selected>Cloudflare R2</option>
<option value="backblaze_b2">Backblaze B2</option>
<option value="generic_s3">Custom S3</option>
<option id="storageProviderBlossomOption" value="blossom" class="hidden">Blossom (Nostr-native)</option>
</select>
</label>
<!-- Blossom (Nostr-native) — keyless, multi-server. Shown when the
Blossom provider is selected; auth is your Nostr signer. -->
<div id="storageBlossomSection" class="hidden space-y-3">
<div class="card border border-info/30 bg-info/10 p-3 text-xs text-info">
Blossom stores files on nostr-native blob servers. No access keys — uploads are authorized by your Nostr signer. Add one or more servers (uploads mirror to all for resilience). The first server is the primary.
</div>
<label class="block text-sm text-muted-strong" for="storageBlossomServers">
<span class="mb-2 block font-medium">Blossom servers (one URL per line)</span>
<textarea id="storageBlossomServers" rows="3" class="input" placeholder="https://blossom.example.com
https://another.blossom.example"></textarea>
</label>
<!-- BUD-03 kind-10063 discovery: import the list you've published, or publish the list above. -->
<div class="flex flex-wrap gap-2">
<button id="storageBlossomImportBtn" type="button" class="btn-ghost focus-ring text-xs">
Import from my server list
</button>
<button id="storageBlossomPublishBtn" type="button" class="btn-ghost focus-ring text-xs">
Publish my server list
</button>
</div>
<p id="storageBlossomServerListStatus" class="text-xs text-muted" role="status"></p>
</div>
<!-- S3-family fields (R2 / B2 / Custom S3). Hidden for Blossom. -->
<div id="storageS3Fields" class="space-y-4">
<!-- R2 Helper -->
<div id="storageR2Helper" class="card border border-info/30 bg-info/10 p-3 text-xs text-info">
Enter your Cloudflare R2 credentials. Your Account ID (from dashboard) is used to connect. For Public Access URL, use your custom domain or R2.dev subdomain. Ensure the token has "Object Read & Write" permissions.
@@ -1033,6 +1094,8 @@
The public URL where your files will be accessible (CDN).
</span>
</label>
</div>
<!-- /storageS3Fields -->
<label class="flex items-center gap-2 text-sm text-muted-strong">
<input id="storageDefault" type="checkbox" class="checkbox" />
+45
View File
@@ -264,6 +264,51 @@ export const FEATURE_NIP71_INGEST = true;
*/
export const FEATURE_AUDIO_INGEST = false;
/**
* Whether this deployment shows the "Connect wallet" button that uses Alby's
* Bitcoin Connect for a smooth NWC connect flow (docs/bitcoin-connect-plan.md,
* TODO #61). When on, the Wallet Connect pane offers Bitcoin Connect alongside
* the manual URI field (which stays as the backup). It only lazy-loads the
* vendored Bitcoin Connect bundle when the user actually clicks Connect.
*
* SHIPPING DEFAULT should be `false` ("off = no trace"). Currently `true` for
* local testing only — reset to `false` before pushing/shipping until verified.
*/
export const FEATURE_BITCOIN_CONNECT = true;
/**
* Whether this deployment offers Blossom (nostr-native blob storage) as a
* first-class upload provider alongside Cloudflare R2 / generic S3 / Backblaze B2
* (docs/blossom-plan.md, TODO #30). Blossom uploads authorize with the user's
* existing nostr signer (kind-24242 auth events) instead of S3 access keys, and
* mirror to multiple servers for resilience.
*
* Defaults to `false` ("off = no trace"): with it off the Storage pane shows only
* R2 / S3 / B2 as today and the vendored Blossom SDK is never imported. Flip to
* `true` to expose the Blossom provider.
*
* Currently `true` for LOCAL testing of Phase 1b — reset to `false` (shipping
* default) before pushing/promoting until the upload flow is verified.
*/
export const FEATURE_BLOSSOM_STORAGE = true;
/**
* FEATURE_BLOSSOM_TORRENT_METADATA — restore WebTorrent/P2P for Blossom-hosted
* videos without hosting a `.torrent` file. Blossom servers reject a `.torrent`
* (415), so a Blossom video's magnet ships webseed-only (no `xs=`) and can't
* bootstrap WebTorrent. With this on, bitvid publishes the torrent piece-map as a
* separate, infohash-keyed Nostr event (kind 30078, `d=bitvid:torrent:<infohash>`)
* and fetches it lazily only when the torrent path actually runs.
* See docs/blossom-torrent-metadata-plan.md.
*
* Defaults to `false` ("off = no trace"): off ⇒ no companion event is published
* and the playback lookup is skipped (Blossom stays URL-only, exactly as today).
*
* Currently `true` for LOCAL testing of the companion-event flow — reset to
* `false` (shipping default) before pushing/promoting.
*/
export const FEATURE_BLOSSOM_TORRENT_METADATA = true;
/**
* Card liveness visibility policy — what a non-owner video card does while its
* CDN/WebTorrent liveness probes are still running:
+24 -1
View File
@@ -580,6 +580,25 @@
display: none;
}
/* "Advanced — paste a connection manually" disclosure in the Wallet pane
(Bitcoin Connect). A subtle rotating caret replaces the default marker. */
.wallet-advanced__summary {
list-style: none;
}
.wallet-advanced__summary::-webkit-details-marker {
display: none;
}
.wallet-advanced__summary::before {
content: "▸ ";
color: var(--text-subtle);
}
.wallet-advanced[open] > .wallet-advanced__summary::before {
content: "▾ ";
}
.popularity-modal__backdrop {
z-index: var(--z-overlay-popover);
padding: var(--space-md);
@@ -5846,7 +5865,11 @@
.dm-contact-row--active:hover,
.dm-contact-row--active:focus-visible {
background: color-mix(in srgb, var(--color-accent) 16%, transparent);
border-color: color-mix(in srgb, var(--color-accent) 60%, var(--border-strong));
border-color: color-mix(
in srgb,
var(--color-accent) 60%,
var(--border-strong)
);
box-shadow: inset 3px 0 0 0 var(--color-accent);
}
+167
View File
@@ -0,0 +1,167 @@
# Bitcoin Connect — NWC wallet connect UX — Dev Plan
Status: **DECISIONS NEEDED (D1–D6)** — no code yet. Adds Alby's
[Bitcoin Connect](https://bitcoin-connect.com/) as a smooth "connect your wallet"
front-end for NWC, feeding bitvid's **existing** NWC pipeline. Manual URI entry
stays as the backup/advanced path. Feature-flagged (off = no trace).
## Executive summary
bitvid already has a full NWC stack: manual URI entry (`#walletUri` in the Wallet
Connect pane → `ProfileWalletController`), validation
(`nwcSettingsService.validateWalletUri` — requires `nostr+walletconnect://`),
per-npub storage (`nwcSettings.js`, IndexedDB + localStorage), opt-in encrypted
cross-login sync, and the zap-split/pay_invoice payment pipeline
(`js/payments/*`). The friction is purely the *connect* step — pasting a URI.
Bitcoin Connect solves exactly that: a polished modal that walks the user through
connecting a wallet (CoinOS, Alby Hub, mutiny, etc.) and, for the NWC connector,
**exposes the raw NWC pairing URI**. So we bolt it on as an alternate connect
button that extracts the URI and hands it to bitvid's existing validate→store
pipeline. **No change to how bitvid pays or stores** — one new way to obtain the
same string. Manual entry stays for power users / unsupported wallets.
## How Bitcoin Connect works (verified against the v3 README)
- Package `@getalby/bitcoin-connect` (ESM; Lit web components + functions; bundles
`@getalby/js-sdk` internally — no separate peer dep). Also loadable from
`https://esm.sh/@getalby/bitcoin-connect@<pin>` (our CSP already allows esm.sh).
- `init({ appName, filters: ["nwc"], persistConnection, providerConfig: { nwc: {
authorizationUrlOptions: { requestMethods } } } })` — `filters:["nwc"]` restricts
the modal to NWC; `requestMethods` requests least-privilege scopes.
- `launchModal()` / `await requestProvider()` opens the connect flow.
- `onConnected((provider) => …)` → a WebLN provider. **NWC extraction:**
```js
import { onConnected } from "@getalby/bitcoin-connect";
import { WebLNProviders } from "@getalby/sdk"; // or the re-export
onConnected((provider) => {
if (provider instanceof WebLNProviders.NostrWebLNProvider) {
const uri = provider.client.nostrWalletConnectUrl; // ← the NWC secret
}
});
```
- `disconnect()` clears its connection; `getConnectorConfig()` returns the saved
connector config. Persistence to localStorage is automatic when
`persistConnection: true`.
- ⚠️ README says the lib is **Alpha** — pin an exact version.
## Decisions needed
> **DECISION 1 — Delivery. 🔵 NEEDED.**
> - **A — Vendor + lazy `import()`:** esbuild-bundle a pinned `@getalby/bitcoin-connect`
> into `vendor/bitcoin-connect.bundle.min.js` (same pattern as floating-ui in
> `scripts/build-beacon.mjs`), dynamically imported only when the user opens the
> Wallet pane / clicks Connect. Offline-safe, supply-chain-pinned, no main-bundle bloat.
> - **B — Lazy-load from esm.sh** (CSP already allows it) at a pinned version.
> Zero vendoring work, but a runtime CDN dependency + supply-chain trust.
> _Recommendation: **A** — bitvid vendors its other heavy deps; Bitcoin Connect is
> a Lit bundle (~hundreds of KB) so lazy-import is important either way._
> **DECISION 2 — Persistence ownership. 🔵 NEEDED.**
> - **A — `persistConnection: false`:** Bitcoin Connect is purely the connect UI;
> bitvid extracts the URI and is the ONLY store (its existing encrypted-sync /
> per-npub model). No duplicate plaintext copy in `bc:*` localStorage.
> - **B — let it persist** and rely on `disconnect()` on bitvid logout.
> _Recommendation: **A** — one owner of the spending secret; call `disconnect()`
> right after extracting to be safe._
> **DECISION 3 — Connector scope. 🔵 NEEDED.**
> - **A — NWC only** (`filters:["nwc"]`). Yields a URI that flows straight into the
> existing pipeline; zap-split/pay_invoice unchanged.
> - **B — also allow WebLN / Alby-extension / Alby Hub** connectors. Smoother for
> Alby users, but those give a WebLN *provider*, not an NWC URI — bitvid's raw-NWC
> payment pipeline can't consume them without a second WebLN payment path.
> _Recommendation: **A for v1** (matches the pipeline); a WebLN path is a separate
> later effort if there's demand._
> **DECISION 4 — Requested scopes. 🔵 NEEDED.**
> `requestMethods` to request from the wallet. bitvid needs **`pay_invoice`** (zaps);
> `get_balance` (balance display), `make_invoice` / `lookup_invoice` (receipts/tally).
> _Recommendation: `["pay_invoice","get_balance","make_invoice","lookup_invoice"]` —
> least privilege for what bitvid actually calls. (The wallet ultimately decides.)_
> **DECISION 5 — UI placement vs manual mode. 🔵 NEEDED.**
> _Recommendation: In the Wallet Connect pane, make **"Connect wallet"** (Bitcoin
> Connect) the primary CTA, and move the raw-URI field under a collapsible
> **"Advanced / paste a connection manually"** — manual stays a first-class backup,
> just de-emphasized. On success, populate the same status/disconnect UI._
> **DECISION 6 — Feature flag. 🔵 NEEDED.**
> _Recommendation: `FEATURE_BITCOIN_CONNECT` in `config/instance-config.js` (default
> off = no trace), wired exactly like `FEATURE_AUDIO_INGEST`. With it off, only the
> manual URI field shows — zero new code path or bundle load._
## Config flag (off = no trace)
- `FEATURE_BITCOIN_CONNECT` (default **false**) — gates the "Connect wallet" button
+ the lazy Bitcoin Connect import. Off ⇒ manual URI entry only (today's behavior).
## Integration points (small, contained)
- `components/profile-modal.html` — Wallet Connect pane: add the "Connect wallet"
button; wrap the existing `#walletUri` field as "Advanced/manual."
- `js/ui/profileModal/ProfileWalletController.js` — a `handleBitcoinConnect()` that
lazy-imports the lib, `init()`s once (NWC-only, least-privilege, no-persist),
`launchModal()`, and on `onConnected` extracts `nostrWalletConnectUrl` →
runs the SAME `validateWalletUri` + save path as manual "Save wallet" → then
`disconnect()`. Reuses all existing status/disconnect/sync UI.
- `config/instance-config.js` / `js/config.js` / `js/constants.js` — the flag.
- `scripts/` + `vendor/` — the esbuild vendor bundle (D1-A).
## Architecture
Bitcoin Connect is a **connect adapter**, not a payment layer:
```
[Connect wallet] → BC modal (NWC) → onConnected(provider)
→ provider.client.nostrWalletConnectUrl
→ validateWalletUri() (existing, scheme check)
→ nwcSettingsService save (existing: per-npub store + optional encrypted sync)
→ BC disconnect() (bitvid now owns the secret)
[zaps] → js/payments/nwcClient pay_invoice + zap-split (UNCHANGED)
```
The manual field writes to the exact same validate→store path, so the two entry
points converge immediately and everything downstream is untouched.
## Security (NWC URI = bearer SPENDING secret)
- **Never log** `nostrWalletConnectUrl` (or the provider) — extract → validate →
store via the existing (never-logged) pipeline; treat like the manual field.
- **`persistConnection: false` + `disconnect()`** after extract so the secret isn't
duplicated in Bitcoin Connect's own plaintext `bc:*` localStorage (D2).
- **Pin the version + vendor it** (Alpha lib) — no unpinned CDN `@latest`.
- **Least-privilege `requestMethods`** (D4) — request only what bitvid calls.
- Re-run bitvid's `validateWalletUri` on the extracted string; don't trust the lib
blindly. Keep the explicit "a wallet-connect URI can SPEND" warning on the
encrypted-sync toggle.
- Payment invariants unchanged: `PLATFORM_FEE_PERCENT=30` split, preimage
verification, and the "a pay_invoice timeout must NEVER auto-resend" rule all live
in `js/payments/*` and are not touched.
## Phases (flag-gated from day one)
- **Phase 0 — Flag + vendor bundle.** Add `FEATURE_BITCOIN_CONNECT` (off) and the
esbuild vendor step. No UX.
- **Phase 1 — Connect button (headline).** Wallet pane "Connect wallet" → BC NWC
modal → extract → existing save path → disconnect. Manual field → "Advanced."
- **Phase 2 — Polish.** Balance display (BC `showBalance` / get_balance), reconnect
affordance, and a "test connection" ping. Optional.
- **Phase 3 (only if wanted) — WebLN path** (D3-B): accept Alby-extension/Hub
connectors via a WebLN payment adapter alongside raw NWC. Separate effort.
## Risks / watch-items
- **Alpha library / breaking changes** — pin + vendor; wrap behind our own thin
adapter so an API change is a one-file fix.
- **Bundle weight** — lazy-import only on Wallet-pane open / button click.
- **Wallet grants fewer scopes than requested** — if `pay_invoice` isn't granted,
zaps fail; surface a clear error and fall back to manual.
- **Not all wallets support NWC** — manual mode + non-NWC connectors (D3-B) cover the gap.
- **esm.sh path (D1-B)** would add a runtime third-party origin to the trust surface.
## Sources
- getAlby/bitcoin-connect README (v3) — install, `init` filters/providerConfig,
`onConnected`, `NostrWebLNProvider.client.nostrWalletConnectUrl`, `disconnect`.
- bitcoin-connect.com. Related bitvid: `js/services/nwcSettingsService.js`,
`js/nwcSettings.js`, `js/ui/profileModal/ProfileWalletController.js`,
`js/payments/*`, `docs/audio-integration-plan.md` (flag-wiring pattern).
+224
View File
@@ -0,0 +1,224 @@
# Blossom storage — first-class alongside R2 / S3 — Dev Plan
Status: **DECISIONS LOCKED (D1–D10, per recommendations, 2026-07-09)** — ready to build. Adds Blossom (nostr-native
blob storage) as a first-class upload provider next to Cloudflare R2 / generic S3
/ Backblaze B2, using the maintained `blossom-client-sdk`. Feature-flagged
(off = no trace). Tracks TODO #30. Research: docs sources at the bottom; spec is
`github.com/hzrd149/blossom` (BUDs). Sibling of the audio / Bitcoin Connect plans.
## Executive summary
bitvid's uploads already flow through one seam: `mediaUploader` selects a service
by provider and calls `service.uploadFile({ … }) → { url, key, … }` (the `url` is
what lands in the kind-30078 video event). Both `r2Service` and `s3UploadService`
implement that `uploadFile` contract; routing is a one-liner
(`isR2Provider(provider) ? r2Service : s3Service`, `mediaUploader.js:38`).
**Blossom = a new `blossomService` implementing the same `uploadFile` contract**,
plus an `isBlossomProvider` branch. Blossom's upload is *simpler* than S3 (no
SigV4, no multipart, no bucket creation, no client-side CORS provisioning) and its
auth is a signed nostr event (kind 24242) — so it reuses **bitvid's existing
signer** with zero new crypto. The one real cost is the Storage-pane config UI,
which today hard-assumes S3 credential fields and needs a parallel **keyless,
multi-server** form.
The payoff: for anyone who picks Blossom, the whole "store an upload secret in the
browser + encrypted-sync it" surface **disappears** (there is no secret — only
server URLs + the user's signer), and files gain **resilience via mirroring** to
multiple servers, which is Blossom's actual advantage over a single S3 bucket.
## Landscape — the Blossom spec today (researched July 2026)
The spec has modularized since older write-ups; the auth and management endpoints
moved into their own BUDs:
| BUD | Covers | Tier |
| --- | --- | --- |
| **BUD-01** | server requirements + blob retrieval (`GET`/`HEAD /<sha256>`) | core |
| **BUD-02** | `PUT /upload` (returns a blob descriptor) | core |
| **BUD-11** | **Nostr Authorization** — kind-24242 auth event | core |
| **BUD-12** | `GET /list/<pubkey>` + `DELETE /<sha256>` (moved out of 02) | opt |
| BUD-03 | server discovery via **kind-10063** user server list | recommended |
| BUD-04 | cross-server **mirroring** (`PUT /mirror`) | recommended |
| BUD-05 | `/media` endpoint (metadata strip / re-encode) | optional |
| BUD-06 | upload **negotiation** (preflight before sending bytes) | recommended |
| BUD-07 / 09 / 10 | payment-gated / reporting / URI scheme | optional |
**Core minimum a client should implement today: BUD-01 + BUD-02 + BUD-11.** Add
BUD-03 (discovery) + BUD-06 (preflight) + BUD-04 (mirror) for a good client.
- **Client SDK:** `blossom-client-sdk` v5 (hzrd149, the spec author). ESM; only
required dep is `@noble/hashes` — which **bitvid already vendors** (crypto-
helpers). Optional peer deps (cashu, hls.js) are skipped. Small bundle.
- **Auth (BUD-11):** a kind-24242 event with `t` (`get`/`upload`/`list`/`delete`/
`media`), a **required NIP-40 `expiration`**, optional `server` (scope to a
domain) and `x` (scope to a sha256), sent as
`Authorization: Nostr <base64url event>`. The SDK's `createUploadAuth(signer,
sha256, { type })` builds it; the `signer` it wants is exactly bitvid's shape
(`async (eventTemplate) → signed event`).
- **Blob descriptor** (`PUT /upload` response): `{ url, sha256, size, type,
uploaded, … }` — and MAY include `magnet` / `infohash` / `ipfs`, a direct tie-in
to bitvid's torrent story. `sha256` maps onto bitvid's existing `fileSha256`/`x`.
## Decisions needed
> **DECISION 1 — Client library. ✅ LOCKED (= recommendation below).**
> - **A — Vendor `blossom-client-sdk` v5** (esbuild bundle like bitcoin-connect /
> floating-ui; its `@noble/hashes` dep is already vendored). Tracks the spec's
> ongoing BUD refactors; gives upload/mirror/discovery/negotiation for free.
> - **B — Hand-roll** the ~3 HTTP calls + the kind-24242 auth event.
> _Recommendation: **A** — the spec is actively modularizing (auth→BUD-11,
> list/delete→BUD-12); the maintained SDK absorbs that churn, and it's small._
> **DECISION 2 — Single server vs multi-server + mirror. ✅ LOCKED (= recommendation below).**
> - **A — Multi-server list** (`multiServerUpload`): upload to N servers (HEAD
> preflight, `/mirror` to register on ones that already have the blob).
> - **B — Single server** URL only.
> _Recommendation: **A** — multiple live copies (resilience/censorship-resistance)
> is the entire point of Blossom over one S3 bucket. Config becomes a server list._
> **DECISION 3 — kind-10063 server discovery/publish (BUD-03). ✅ LOCKED (= recommendation below).**
> Read the user's published server list to pre-fill their servers, and publish/
> update it so other clients can find bitvid-hosted media.
> _Recommendation: **read in v1** (`getServersFromServerListEvent`), **publish in
> v1.5** (a "publish my server list" action). Spec says clients SHOULD do both._
> **DECISION 4 — `/upload` vs `/media` (BUD-05). ✅ LOCKED (= recommendation below).**
> `/media` strips metadata + may re-encode; `/upload` stores exact bytes.
> _Recommendation: **`/upload` for videos** (keeps the exact file so the torrent
> infohash/seed stays valid), optional **`/media` for thumbnails/avatars** later
> (privacy/EXIF strip)._
> **DECISION 5 — Signer wiring. ✅ LOCKED (= recommendation below).**
> _Recommendation: reuse bitvid's **active signer adapter** as the SDK `signer`
> (NIP-07 / NIP-46 / nsec all already produce `async (tpl) → signed event`). No new
> crypto. Respect the signer circuit-breaker; reuse one auth across a mirror set
> (the SDK supports this) so a multi-server upload isn't N prompts._
> **DECISION 6 — Torrent parity. ✅ LOCKED (= recommendation below).**
> S3 uploads also build a magnet + seed via WebTorrent. Keep that for Blossom?
> _Recommendation: **Yes** — keep parity (Blossom URL as the hosted source +
> magnet as the P2P fallback). If the server's blob descriptor already returns
> `magnet`/`infohash`, prefer/verify it; else compute as today. sha256 = the
> shared content id._
> **DECISION 7 — Management (list/delete/orphan-GC). ✅ LOCKED (= recommendation below).**
> BUD-12 `GET /list/<pubkey>` + `DELETE /<sha256>` for the storage-management UI
> (My Videos orphan tools #8/#13).
> _Recommendation: **v2** — v1 ships upload; wire list/delete + orphan parity after._
> **DECISION 8 — Storage-pane config type. ✅ LOCKED (= recommendation below).**
> _Recommendation: add a **"Blossom"** option to `#storageProvider` that swaps the
> S3 fields (endpoint/region/access-key/secret/bucket + CORS helper) for a **keyless
> multi-server list** (add/remove server URLs, mark default, "test", optional
> "import from my kind-10063 list" / "publish my list"). New connection-payload
> variant `{ servers: [...] }` in the per-provider slot model._
> **DECISION 9 — Feature flag. ✅ LOCKED (= recommendation below).**
> _Recommendation: `FEATURE_BLOSSOM_STORAGE` in `config/instance-config.js`
> (default off = no trace), wired like `FEATURE_AUDIO_INGEST` /
> `FEATURE_BITCOIN_CONNECT`. Off ⇒ no Blossom option, SDK never imported._
> **DECISION 10 — Privacy posture. ✅ LOCKED (= recommendation below).**
> Blossom blobs are **public + content-addressed** (anyone with the URL/sha256 can
> fetch), same as a public S3 bucket. There are no private blobs in the base spec.
> _Recommendation: treat Blossom as public hosting (bitvid videos are already
> public); use `/media` to strip EXIF on images; do NOT route anything sensitive
> (e.g. private DMs) through public Blossom without BUD-07-style gating — keep DM
> attachments on the existing path until a private story exists._
## Config flag (off = no trace)
- `FEATURE_BLOSSOM_STORAGE` (default **false**) — gates the "Blossom" storage
provider option, the blossomService, and the lazy SDK import. Off ⇒ Storage pane
shows only R2 / S3 / B2 exactly as today.
## The `uploadFile` contract (the seam Blossom implements)
`mediaUploader` calls, for both thumbnail and video (4 call sites):
```
service.uploadFile({ file, key?, contentType?, onProgress, credentials, … })
→ { url, key, storagePointer?, infoHash?, magnet?, torrentUrl?, … }
```
`blossomService.uploadFile` maps to:
1. `sha256 = @noble/hashes` of the file (SDK helper) → the content id + `key`.
2. `createUploadAuth(signer, sha256, { type })` → kind-24242 auth (scoped `x`,
short `expiration`).
3. `multiServerUpload(servers, file, { onAuth })` (BUD-06 preflight + BUD-04
mirror) → blob descriptors; pick the primary `url` (default server), keep the
mirror URLs as fallbacks (map into the video's imeta `sources`, #17/#20).
4. Return `{ url, key: sha256, storagePointer, magnet?, infoHash? }` so the rest of
the publish pipeline is untouched.
## Architecture
- **`js/services/blossomService.js`** — implements `uploadFile` (+ later
`listObjects`/`deleteObject` for BUD-12). Wraps the vendored SDK; takes the app's
active signer. No credentials object — just the server list + signer.
- **`mediaUploader.js`** — add `isBlossomProvider`; route to `blossomService`.
- **Storage pane** (`components/profile-modal.html`, `ProfileStorageController`,
`storageConnections`) — Blossom provider type: keyless multi-server form +
payload variant `{ servers }`; hide S3 fields + CORS helper for Blossom.
- **Discovery** — `getServersFromServerListEvent()` + `USER_BLOSSOM_SERVER_LIST_KIND`
(kind 10063) to read; a publish action to write (BUD-03).
- **Flag** — `config/instance-config.js` → `js/config.js` → `js/constants.js`.
- **Vendor** — `scripts/build-blossom-sdk.mjs` → `vendor/blossom-client-sdk.bundle.min.js`
(lazy-imported only when a Blossom upload runs).
## Security
- **No stored secret for Blossom** — only public server URLs + the user's signer.
This *removes* the encrypted-cred-sync surface (#15) for Blossom users; nothing
to leak, sync, or lock.
- **Auth is short-lived + scoped** — kind-24242 events carry a required NIP-40
`expiration` and MUST be scoped (`t`, `x` to the blob's sha256, optional `server`
to the domain). Build fresh per action; never a long-lived token.
- **Signer discipline** — uploads sign per action; reuse one auth across a mirror
set (SDK) so a multi-server upload isn't N signer prompts, and respect the NIP-07
circuit-breaker / decrypt-budget invariants. Never log the signed auth event's
contents beyond what devLogger already gates.
- **Integrity** — verify the returned `sha256` matches the uploaded bytes; on
download, clients SHOULD verify the hash (BUD-01).
- **Public data only** — Blossom blobs are world-readable; keep private content off
it (D10).
## Phases (each flag-gated from day one)
- **Phase 0 — Flag + vendor SDK + service skeleton.** `FEATURE_BLOSSOM_STORAGE`,
`scripts/build-blossom-sdk.mjs`, a `blossomService` stub. No UX.
- **Phase 1 — Upload (headline).** `blossomService.uploadFile` via
`multiServerUpload` + signer auth + BUD-06 preflight; `isBlossomProvider` routing;
Storage-pane keyless multi-server form + "test"; torrent parity (D6). Video +
thumbnail upload land in the same publish pipeline.
- **Phase 1.5 — Discovery.** Read kind-10063 to pre-fill servers; a "publish my
server list" action (BUD-03).
- **Phase 2 — Management.** BUD-12 `list`/`delete`, orphan-GC parity (#8/#13),
mirror controls (BUD-04), optional `/media` for thumbnails (BUD-05).
- **Phase 3 — Extras.** DM attachments (only with a private story), BUD-09
reporting, richer multi-server health/status.
## Risks / watch-items
- **Server availability/churn** → mitigated by multi-server + mirror (D2); surface
per-server status.
- **Spec still evolving** (BUD-11/12 refactor is recent) → the SDK tracks it; pin
the exact version (like bitcoin-connect) and wrap it behind our thin service.
- **Large videos = one `PUT`** (no S3 multipart); some servers cap size → BUD-06
preflight + fall over to another server; surface clear size errors.
- **Server CORS** is the operator's job, not the client's — a misconfigured server
blocks browser upload; detect + suggest known-good servers.
- **Signer prompts** on multi-server → reuse one auth across the mirror set.
- **Bundle** — lazy-import the SDK only when a Blossom upload actually runs.
## Sources
- Spec + BUDs: `github.com/hzrd149/blossom` (BUD-01/02/03/04/05/06/11/12).
- `github.com/hzrd149/blossom-client-sdk` (v5): `createUploadAuth`, `uploadBlob`,
`mirrorBlob`, `multiServerUpload`, `multiServerMediaUpload`, `listBlobs`,
`getServersFromServerListEvent`, `USER_BLOSSOM_SERVER_LIST_KIND`,
`encodeAuthorizationHeader`.
- NIP-B7 (Blossom media in notes), NIP-40 (expiration), NIP-98 (HTTP auth lineage).
- bitvid: `js/ui/components/mediaUploader.js` (uploadFile seam),
`js/services/{r2Service,s3UploadService}.js`, `js/ui/profileModal/{ProfileStorageController,storageConnections}.js`,
`components/profile-modal.html` (Storage pane), `docs/bitcoin-connect-plan.md`
(flag + vendor pattern), TODO #30.
+277
View File
@@ -0,0 +1,277 @@
# Blossom videos — WebTorrent metadata via a companion Nostr event — Dev Plan
Status: **DECISIONS LOCKED (D1–D6, per recommendations, 2026-07-10)** — ready to
build. Restores P2P/WebTorrent playback for Blossom-hosted videos without hosting a
`.torrent` file, by publishing the torrent piece-map as a separate, infohash-keyed
Nostr event that bitvid fetches lazily only when the torrent path actually runs.
Depends on: `docs/blossom-plan.md` (Blossom storage) and the existing WebTorrent
playback path (`js/webtorrent.js`, `js/services/playbackService.js`).
## Executive summary
Blossom servers accept media types only and reject a `.torrent`
(`application/x-bittorrent` → `415 "File type not allowed"`, verified live). A magnet
therefore ships with a webseed (`ws=` = the Blossom URL) but **no `xs=`** (no hosted
`.torrent`). WebTorrent cannot start from a webseed alone — it needs the torrent
**metadata** (the `info` dict / piece map) from either the hosted `.torrent` or a live
peer. So Blossom videos currently publish **URL-only** (they play via direct HTML5,
which works — verified: `video/mp4`, CORS `*`, Range `206` through blossom.band's
`307`→`video.nostr.build`).
This plan restores P2P by carrying the piece-map **on Nostr**: at upload bitvid already
generates the `.torrent` (that's how it gets the infohash), so it publishes that
`.torrent` as a **companion event addressed by infohash**. bitvid fetches it **only when
a viewer starts the torrent path**, verifies it, then `client.add(torrentBuffer, {
urlList:[videoUrl] })` — WebTorrent now has the map and streams the bytes from the
Blossom webseed over HTTP Range.
Why a companion event rather than inlining the blob in the video event: the constraint
that bites bitvid is the **feed**, not a single note. bitvid pulls hundreds of video
events per feed load and >90% are watched via direct URL and never touch the torrent
path. Inlining taxes every feed event for a feature most playbacks never use. A separate,
lazily-fetched event keeps the feed lean, removes any practical size cap (the blob is off
the feed), and is nostr-idiomatic: the metadata is **content-addressed by its own hash**.
## Background — the graceful ladder
For a video with a valid infohash, bitvid picks the best available P2P story:
1. **`.torrent` hosted (`xs=`)** — R2/S3, or a self-hosted/paid Blossom server that
accepts `.torrent`. Full magnet, playable in any torrent client. *(unchanged today)*
2. **Companion event** — Blossom URL-only case: `ws=` magnet + an infohash-keyed
metadata event. Playable **in bitvid** (which knows to do the lookup). *(this plan)*
3. **URL-only** — flag off, over cap, or publish failed. Direct HTML5 playback.
*(current Blossom behavior; also the fallback for 1 and 2)*
The webseed (`ws=`) URL is also just the plain video URL, so **direct playback always
works** regardless of the torrent story — P2P is strictly additive.
## Applicability across media types
The trigger keys on **(a valid infohash + a hosted webseed URL)**, not on the "video"
label — so it is inherited by any media that flows through bitvid's upload + torrent seam
(`blossomService.uploadVideo`, i.e. "upload a media blob → generate a `.torrent`"), and
skipped by anything that doesn't.
| Type | Inherits it? | Why |
|---|---|---|
| **Regular video** | ✅ yes | The seam itself. |
| **Shorts** | ✅ yes, free | A Short is a regular video with `format:"short"` on the same upload+torrent path (`docs/shorts-plan.md` keeps "bitvid's WebTorrent advantage"). Uploaded to Blossom ⇒ same companion. |
| **Audio — publish** (`FEATURE_AUDIO_PUBLISH`, later) | ✅ yes, if it reuses the seam | WebTorrent is file-type-agnostic — an audio blob torrents/webseeds exactly like video. As long as audio *upload* routes through the shared upload+torrent path, no extra work. |
| **Audio — ingest** (`FEATURE_AUDIO_INGEST`) | ❌ no | Surfaces *others'* externally-hosted audio; bitvid uploads nothing, so there is no infohash/webseed to describe. |
| **Live streams** (`FEATURE_LIVE_INGEST`) | ❌ no | An HLS `.m3u8` stream isn't a static file — no fixed byte layout, no deterministic infohash, nothing on Blossom to seed. A **saved VOD later re-uploaded as a normal video** would qualify as a regular video, not as "the live stream." |
Design implication: keep the torrent + companion orchestration at the **generic
media-blob seam** (it already is — `uploadVideo` is "upload blob + optional torrent",
just video-flavored in name), so new *upload-based* types inherit it for free and
*ingest/stream* types are correctly excluded. No per-type wiring, no per-type schema — the
companion event is infohash-keyed and media-type-agnostic (`d = bitvid:torrent:<infohash>`).
## Why not NIP-35 (kinds 2003/2004)?
Nostr already has a torrent NIP — **NIP-35**: `kind 2003` (torrent) + `kind 2004`
(torrent comment). It carries a torrent's **`x` (infohash)**, `title`, `file` list,
`tracker` list, external-DB `i` references, and category `t` tags. It is deliberately
**not** used here, because it solves a *different* problem:
- NIP-35 is a torrent **announcement / index** format (a Nostr-native tracker listing).
It records metadata *about* a torrent plus the infohash — but **not the piece hashes /
the bencoded `info` dict / the `.torrent` bytes**. A client that has only a NIP-35 event
still has to obtain the actual torrent metadata from **peers / DHT / trackers**.
- Our problem is exactly that a fresh Blossom video has **no seeders and no hosted
`.torrent`**, so there is nowhere to fetch the piece map from. The companion event exists
to **transport the piece map itself** (the full `.torrent`, base64) so WebTorrent can
reconstruct the torrent and stream from the webseed with **zero peers**. NIP-35 omits the
one field that makes that possible.
So NIP-35 can't replace the companion event for *playback*. The two are complementary: this
plan handles playback bootstrap; a `kind 2003` could be layered on later purely for
**cross-client discovery** (see Phase 3) without changing this mechanism. Caveat for that
future work: bitvid's webseed-first, no-seeder model doesn't map cleanly onto NIP-35's
"here's a torrent to download" expectation, and emitting a 2003 per video risks noise —
so treat it as opt-in reach, not a default.
## Decisions
> **DECISION 1 — Event kind + addressing. ✅ LOCKED.**
> Reuse **NIP-78 `kind:30078`** (bitvid already uses it for `bitvid:storage-connections`)
> as a parameterized-replaceable event with `d = "bitvid:torrent:<infohash>"` (infohash
> lowercase hex). bitvid derives the key from the magnet's `btih` and queries
> `authors:[videoAuthor], kinds:[30078], #d:["bitvid:torrent:<infohash>"]`.
> _Rationale: no new kind to define; matches bitvid's existing 30078 namespacing; keyed
> by infohash ⇒ self-verifying and content-addressed. A **dedicated kind** for
> cross-client discoverability is a future option (D6/Phase 3), not MVP._
> **DECISION 2 — Payload. ✅ LOCKED.**
> `content` is a small JSON envelope `{"v":1,"infohash":"<hex>","torrent":"<base64>"}`
> where `torrent` is the **full `.torrent` file bytes, base64** (preserves the infohash
> exactly; `client.add` accepts the raw buffer). Tags: `["d","bitvid:torrent:<infohash>"]`,
> `["x","<infohash>"]` (NIP-35/94 convention), `["client","bitvid"]`, and a back-reference
> `["e","<videoEventId>"]` for debug/discovery (not used for lookup).
> _Rationale: raw `.torrent` = zero reconstruction risk to the infohash; the `v` envelope
> gives forward-compat; the `x` tag makes it conventionally queryable._
> **DECISION 3 — Publish trigger. ✅ LOCKED.**
> Publish the companion **only** when: the video has a valid infohash **and** the
> `.torrent` could **not** be hosted (`torrentUrl === ""`, i.e. the Blossom URL-only case)
> **and** `FEATURE_BLOSSOM_TORRENT_METADATA` is on **and** the base64 payload ≤ the cap
> (D4). R2/S3 and `.torrent`-accepting Blossom servers keep using `xs=` and publish no
> companion.
> _Rationale: the companion exists to cover exactly the gap Blossom creates; never
> duplicate what `xs=` already provides._
> **DECISION 4 — Size cap (safety, not the feed gate). ✅ LOCKED.**
> Gate on the **actual base64 payload size**, not a file-size proxy (metadata scales
> sub-linearly with file size as WebTorrent grows the piece length; the encoded size is
> known exactly at upload). Default cap **65536 bytes (64 KB)** base64 → URL-only above
> it. A companion event ~this size is off the feed, so the cap is a pathological-case
> guard, not a feature limiter (metadata plateaus ~20–40 KB even for multi-GB videos).
> _Rationale: exact, robust, and generous enough to cover realistic videos._
> **DECISION 5 — Playback: lazy fetch + verify + trusted webseed. ✅ LOCKED.**
> On the torrent path, when the magnet has `btih` but no usable `xs=`: query the companion
> by infohash (author-scoped in MVP), base64-decode, **verify the reconstructed torrent's
> infohash === the magnet's `btih`**, then `client.add(buffer, { urlList:[videoUrl],
> announce: WSS_TRACKERS })`. The **webseed URL comes from the trusted video event's `url`
> field** — bitvid **ignores any url-list baked into the `.torrent`**. Cache by infohash.
> Any miss/mismatch/timeout ⇒ URL-only fallback (already the current behavior).
> _Rationale: content from other users is public + untrusted; the infohash check makes
> tampering un-actionable, and sourcing the webseed from the video event prevents a
> malicious companion from redirecting the byte source._
> **DECISION 6 — Config flag + scope. ✅ LOCKED.**
> `FEATURE_BLOSSOM_TORRENT_METADATA` in `config/instance-config.js` (default **false**),
> wired like `FEATURE_BLOSSOM_STORAGE` / `FEATURE_BITCOIN_CONNECT`. Off ⇒ no companion is
> published and the playback lookup is skipped (Blossom stays URL-only). MVP is
> **author-scoped** lookup (same pubkey as the video). Crowd-sourced metadata (accept a
> valid companion from **any** author, since it's self-verifying) and a dedicated,
> cross-client kind are **Phase 3**.
## Config flag (off = no trace)
- `FEATURE_BLOSSOM_TORRENT_METADATA` (default **false**) — gates both the publish side
(no companion event) and the playback lookup (skip the query). Off ⇒ Blossom behaves
exactly as it does today (URL-only). Constant `BLOSSOM_TORRENT_METADATA_MAX_BASE64 =
65536` lives in `js/constants.js`.
## Event shape
```jsonc
// kind 30078 (NIP-78), parameterized-replaceable, signed by the video author
{
"kind": 30078,
"tags": [
["d", "bitvid:torrent:<infohash-lowercase-hex>"],
["x", "<infohash-lowercase-hex>"],
["client", "bitvid"],
["e", "<video-event-id>"] // back-reference, debug/discovery only
],
"content": "{\"v\":1,\"infohash\":\"<hex>\",\"torrent\":\"<base64 .torrent bytes>\"}"
}
```
Builder lives in `js/nostrEventSchemas.js` (source of truth) as
`buildTorrentMetadataEvent({ pubkey, created_at, infoHash, torrentFileBytes })`, alongside
the existing builders.
## Publish flow (Phase 1)
In `blossomService.uploadVideo`, inside the existing `if (torrent?.hasValidInfoHash …)`
block, extend the "`.torrent` not hosted" branch:
1. Compute `b64 = base64(torrent.torrentFile bytes)`.
2. If `FEATURE_BLOSSOM_TORRENT_METADATA` and `b64.length ≤ MAX`: build the companion event
(schema above), sign via the active signer, publish to the user's write relays (reuse
the existing publish helper), and set the result to the **companion tier**:
`magnet = magnet:?xt=urn:btih:<infoHash>&dn=<name>&ws=<videoUrl>` (no `xs=`),
`hasValidInfoHash = true`, `infoHash = <hex>`, `torrentUrl = ""`.
3. Else (flag off / over cap / publish failed): stay **URL-only** as today
(`magnet = ""`, `hasValidInfoHash = false`). The local `.torrent` is still retained on
`result.torrentFile`.
Orchestration note: `blossomService` stays WebTorrent-free; the companion publish uses an
injected `publishEvent`/signer callback (same injection pattern as `generateTorrent`), so
the service remains unit-testable in Node.
## Playback flow (Phase 2)
Add a `torrentMetadataService` (fetch/verify/cache) consumed by the WebTorrent add path
(`js/webtorrent.js`) or `playbackService`:
1. Torrent path selected; magnet has `btih:<infohash>` and no usable `xs=`.
2. Flag on → `getTorrentMetadata({ infoHash, author })`: relay query
`kinds:[30078], authors:[author], #d:["bitvid:torrent:<infohash>"]`, newest wins.
Cache the decoded buffer by infohash (memory; optional IndexedDB later).
3. Decode envelope → base64-decode → `.torrent` buffer → parse → **assert infohash ===
btih** (reject + log on mismatch).
4. `client.add(buffer, { urlList: [videoUrl /* from the video event */], announce:
WSS_TRACKERS })`. WebTorrent has the map; the webseed serves bytes over Range.
5. Any failure ⇒ URL-only (unchanged fallback). Never block first paint: URL-first plays
immediately while this runs in the background.
## Security
- **Untrusted content:** verify reconstructed infohash === `btih` before use; on mismatch,
discard and fall back. The infohash is the query key *and* the integrity check.
- **Webseed source:** always the video event's own `url`; never the `.torrent`'s embedded
url-list — prevents byte-source redirection by a malicious companion.
- **Size guard:** refuse to decode a `content` whose base64 exceeds the cap.
- **Author scope (MVP):** query only the video's author, so no cross-author injection.
Crowd-sourcing (Phase 3) stays safe *because* it's self-verifying, but is opt-in later.
- **No new signing surface:** reuse the active signer adapter; respect its circuit-breaker.
## Phases (flag-gated from day one)
- **Phase 0 — Flag + schema.** `FEATURE_BLOSSOM_TORRENT_METADATA` wired through
instance-config → config → constants; `BLOSSOM_TORRENT_METADATA_MAX_BASE64`;
`buildTorrentMetadataEvent` in `nostrEventSchemas.js` + docs in
`docs/nostr-event-schemas.md`. No behavior change.
- **Phase 1 — Publish.** Companion publish in the Blossom URL-only branch (D3/D4); promote
Blossom to the **companion tier** magnet when published. Unit tests: builder round-trip,
cap gate, tier selection.
- **Phase 2 — Playback.** `torrentMetadataService` (fetch/verify/cache) + wire into the
WebTorrent add path with trusted-webseed sourcing and URL-only fallback. Unit tests:
infohash-mismatch rejection, webseed sourcing, cache hit. Live check: webseed follows the
Blossom `307` redirect.
- **Phase 3 — Reach (later).** Crowd-sourced lookup (any author), a dedicated cross-client
kind, republish-on-mirror, IndexedDB metadata cache, and an **optional NIP-35 `kind 2003`
companion for cross-client discovery** (complementary to — not a replacement for — the
piece-map event; see "Why not NIP-35" above).
## Test scenarios (scenario-first; see Dark Factory rules)
- **Builder round-trip:** a `.torrent` buffer → `buildTorrentMetadataEvent` → event with
`d=bitvid:torrent:<infohash>`, `x` tag, and `content.torrent` base64 that decodes back to
the exact input bytes.
- **Cap gate:** payload > `MAX` ⇒ no companion, `hasValidInfoHash=false` (URL-only);
≤ `MAX` ⇒ companion built, `hasValidInfoHash=true`, `ws=`-only magnet, no `xs=`.
- **Verify — reject tampering:** a fetched companion whose reconstructed infohash ≠ `btih`
⇒ rejected, URL-only fallback taken.
- **Webseed sourcing:** even when the `.torrent` embeds url-list `X`, the add path uses the
video event's `url` as the sole webseed.
- **Round-trip discovery:** publish (mocked relay) → fetch by infohash → reconstruct →
infohash matches → `client.add` invoked with the trusted `urlList`.
## Risks / watch-items
- **Companion availability** — if it isn't on the queried relays, P2P is skipped and the
video still plays URL-only. Publish to the user's write relays; consider republish on
re-open (Phase 3).
- **Extra signer prompt at upload** — one more sign (the companion) after the video event.
Reuse the live session; acceptable. Surface it in the upload progress copy.
- **Standalone-client incompatibility** — a `ws=`-only magnet is playable **in bitvid**
(which does the lookup), not in a generic torrent client. Guaranteed generic P2P needs a
hosted `.torrent` (`xs=`, tier 1). Accepted tradeoff.
- **Webseed `307` redirect** — WebTorrent's HTTP webseed must follow blossom.band →
video.nostr.build; browser `fetch` does, but verify live in Phase 2.
- **Very large / pathological piece counts** — metadata may exceed the 64 KB cap ⇒
URL-only fallback (correct, not a failure). Optional future lever: raise the generated
piece length to shrink metadata (harmless with a webseed serving pieces over Range).
## Rollback
Flip `FEATURE_BLOSSOM_TORRENT_METADATA = false`: no companion is published and the
playback lookup is skipped — Blossom instantly reverts to URL-only. Already-published
companion events are inert (bitvid won't query them); they're standard replaceable 30078
events the author can overwrite/delete via normal Nostr means.
+1
View File
@@ -201,6 +201,7 @@ Video posts should treat `videoRootId` as the stable series identifier that rema
| Submission (`NOTE_TYPES.SUBMISSION`) | `SUBMISSION_KIND` (`30083`, addressable, `d = submission id`) | `['t','bitvid-submission']`, `['d', <id>]`, `['k', 'application'\|'appeal'\|'bug'\|'feature'\|'feedback']`, `['applicant', <npub>]`, `['p', <admin hex>]`; free-text body in `content`. | Structured, PUBLIC application/appeal/bug/feature/feedback (#23) — replaces the old kind-4 encrypted DM forms so an admin "Submissions" tab can list + approve/deny. Published from an EPHEMERAL key (no login), so `applicant` is the CLAIMED npub. Built/parsed by `js/submissions/submissionService.js`. |
| HTTP Auth (`NOTE_TYPES.HTTP_AUTH`) | `27235` | `['u', <url>]`, `['method', <http method>]`, `['payload', <payload hash>]` | Optional plaintext content (NIP-98 recommends empty, but implementations may vary) |
| Report (`NOTE_TYPES.REPORT`) | `1984` | `['e', <event id>, <report type>]`, `['p', <pubkey>, <report type>]`, and optional `['t', <report type>]` | Plaintext report reason |
| Torrent metadata (`buildTorrentMetadataEvent`) | `30078` (addressable, `d = bitvid:torrent:<infohash>`) | `['d', 'bitvid:torrent:<infohash>']`, `['x', <infohash>]`, `['client', 'bitvid']`, optional `['e', <video event id>]` | JSON envelope `{ v:1, infohash:<hex>, torrent:<base64 .torrent bytes> }`. The WebTorrent piece-map for a Blossom-hosted video (whose magnet is webseed-only because Blossom rejects `.torrent`). Fetched lazily on the torrent path and **verified: reconstructed infohash must equal the magnet's `btih`**; the webseed URL comes from the video event, never the embedded url-list. Gated by `FEATURE_BLOSSOM_TORRENT_METADATA`. See `docs/blossom-torrent-metadata-plan.md`. |
### Share notes (kind `1`)
+7
View File
@@ -25,7 +25,9 @@ import {
DEFAULT_TRUSTED_MUTE_HIDE_THRESHOLD,
DEFAULT_TRUSTED_SPAM_HIDE_THRESHOLD,
FEED_TYPES,
FEATURE_BLOSSOM_TORRENT_METADATA,
} from "./constants.js";
import torrentMetadataService from "./services/torrentMetadataService.js";
import { updateVideoCardSourceVisibility } from "./utils/cardSourceVisibility.js";
import { collectVideoTags } from "./utils/videoTags.js";
import { normalizeHashtag } from "./utils/hashtagNormalization.js";
@@ -642,6 +644,11 @@ class Application {
userLogger,
nostrClient,
torrentClient,
// Torrent piece-map lookup for Blossom videos — passed only when the flag
// is on, so its presence in the coordinator IS the gate.
torrentMetadataService: FEATURE_BLOSSOM_TORRENT_METADATA
? torrentMetadataService
: null,
emit,
accessControl,
isValidMagnetUri,
+50
View File
@@ -41,6 +41,7 @@ export function createPlaybackCoordinator(deps) {
userLogger,
nostrClient,
torrentClient,
torrentMetadataService,
emit,
accessControl,
isValidMagnetUri,
@@ -382,6 +383,52 @@ export function createPlaybackCoordinator(deps) {
? hostedUrlWebSeed.trim()
: "";
// Blossom videos ship a webseed-only magnet (no xs=). When the piece-map
// lookup is wired (flag on) and the magnet lacks xs=, fetch the verified
// .torrent from the video's infohash-keyed companion event so WebTorrent has
// metadata to bootstrap from. Best-effort + verified upstream: null ⇒ the
// bare magnet is used (may still work via peers, else the URL fallback runs).
let companionTorrentBytes = null;
if (
torrentMetadataService &&
typeof extractBtihFromMagnet === "function" &&
typeof magnet === "string" &&
!/[?&]xs=/.test(magnet)
) {
const infoHash = extractBtihFromMagnet(magnet);
const author =
this.currentVideo && typeof this.currentVideo.pubkey === "string"
? this.currentVideo.pubkey
: "";
if (infoHash && author) {
try {
const relays =
Array.isArray(nostrClient?.readRelays) &&
nostrClient.readRelays.length
? nostrClient.readRelays
: nostrClient?.relays || [];
const resolved = await torrentMetadataService.fetch({
infoHash,
author,
// Route relay reads through the subscription manager (one-shot,
// deduped, timeout-bounded) — never pool.list directly.
queryEvents: (filter) =>
nostrClient
.getSubscriptionManager()
.list({ filters: [filter], relays }),
});
if (resolved?.torrentBytes) {
companionTorrentBytes = resolved.torrentBytes;
}
} catch (error) {
devLogger?.warn?.(
"[playback] companion torrent-metadata lookup failed:",
error
);
}
}
}
const attemptStream = async (candidate) => {
const trimmedCandidate =
typeof candidate === "string" ? candidate.trim() : "";
@@ -429,6 +476,9 @@ export function createPlaybackCoordinator(deps) {
{
urlList: sanitizedUrlList,
hostedUrlWebSeed: sanitizedHostedWebSeed,
// Verified piece-map from the companion event (Blossom videos); when
// present, streamVideo adds this buffer instead of the bare magnet.
torrentFileBytes: companionTorrentBytes,
hooks: {
// Surface a mid-stream stall instead of silently freezing on a
// buffering frame: tell the user whether the swarm is just slow
+6
View File
@@ -26,6 +26,9 @@ import {
ALLOW_NSFW_CONTENT,
FEATURE_NIP71_INGEST,
FEATURE_AUDIO_INGEST,
FEATURE_BITCOIN_CONNECT,
FEATURE_BLOSSOM_STORAGE,
FEATURE_BLOSSOM_TORRENT_METADATA,
CARD_LIVENESS_POLICY,
LIVENESS_PROBE_PREFETCH_MARGIN,
DEFAULT_TRUST_SEED_NPUBS,
@@ -123,6 +126,9 @@ export const ADMIN_COMMUNITY_BLACKLIST_PREFIX = "community:blacklist";
export { ALLOW_NSFW_CONTENT };
export { FEATURE_NIP71_INGEST };
export { FEATURE_AUDIO_INGEST };
export { FEATURE_BITCOIN_CONNECT };
export { FEATURE_BLOSSOM_STORAGE };
export { FEATURE_BLOSSOM_TORRENT_METADATA };
export { CARD_LIVENESS_POLICY };
export { LIVENESS_PROBE_PREFETCH_MARGIN };
export { DEFAULT_TRUST_SEED_NPUBS };
+38
View File
@@ -8,6 +8,9 @@ import {
DEFAULT_PLAYBACK_START_TIMEOUT,
FEATURE_NIP71_INGEST as CONFIG_FEATURE_NIP71_INGEST,
FEATURE_AUDIO_INGEST as CONFIG_FEATURE_AUDIO_INGEST,
FEATURE_BITCOIN_CONNECT as CONFIG_FEATURE_BITCOIN_CONNECT,
FEATURE_BLOSSOM_STORAGE as CONFIG_FEATURE_BLOSSOM_STORAGE,
FEATURE_BLOSSOM_TORRENT_METADATA as CONFIG_FEATURE_BLOSSOM_TORRENT_METADATA,
CARD_LIVENESS_POLICY as CONFIG_CARD_LIVENESS_POLICY,
LIVENESS_PROBE_PREFETCH_MARGIN as CONFIG_LIVENESS_PROBE_PREFETCH_MARGIN,
IS_DEV_MODE,
@@ -199,6 +202,17 @@ const DEFAULT_FLAGS = Object.freeze({
// Default OFF ("off = no trace") — only an explicit `true` in the instance
// config lights it up.
FEATURE_AUDIO_INGEST: CONFIG_FEATURE_AUDIO_INGEST === true,
// Bitcoin Connect NWC "Connect wallet" flow (docs/bitcoin-connect-plan.md, #61).
// Default OFF; only an explicit `true` in the instance config enables it.
FEATURE_BITCOIN_CONNECT: CONFIG_FEATURE_BITCOIN_CONNECT === true,
// Blossom (nostr-native blob storage) provider (docs/blossom-plan.md, #30).
// Default OFF; only an explicit `true` in the instance config enables it.
FEATURE_BLOSSOM_STORAGE: CONFIG_FEATURE_BLOSSOM_STORAGE === true,
// Blossom videos: publish/lookup the WebTorrent piece-map as a companion Nostr
// event so P2P works without a hosted .torrent (docs/blossom-torrent-metadata-plan.md).
// Default OFF; only an explicit `true` in the instance config enables it.
FEATURE_BLOSSOM_TORRENT_METADATA:
CONFIG_FEATURE_BLOSSOM_TORRENT_METADATA === true,
FEATURE_HASHTAG_PREFERENCES: false,
FEATURE_TRUST_SEEDS: true, // Rollback: disable to drop baseline trust seeds without code changes.
FEATURE_TRUSTED_HIDE_CONTROLS: true,
@@ -226,6 +240,10 @@ const runtimeFlags = (() => {
FEATURE_NIP71_MIRROR: DEFAULT_FLAGS.FEATURE_NIP71_MIRROR,
FEATURE_NIP71_INGEST: DEFAULT_FLAGS.FEATURE_NIP71_INGEST,
FEATURE_AUDIO_INGEST: DEFAULT_FLAGS.FEATURE_AUDIO_INGEST,
FEATURE_BITCOIN_CONNECT: DEFAULT_FLAGS.FEATURE_BITCOIN_CONNECT,
FEATURE_BLOSSOM_STORAGE: DEFAULT_FLAGS.FEATURE_BLOSSOM_STORAGE,
FEATURE_BLOSSOM_TORRENT_METADATA:
DEFAULT_FLAGS.FEATURE_BLOSSOM_TORRENT_METADATA,
FEATURE_HASHTAG_PREFERENCES: DEFAULT_FLAGS.FEATURE_HASHTAG_PREFERENCES,
FEATURE_TRUST_SEEDS: DEFAULT_FLAGS.FEATURE_TRUST_SEEDS,
FEATURE_TRUSTED_HIDE_CONTROLS: DEFAULT_FLAGS.FEATURE_TRUSTED_HIDE_CONTROLS,
@@ -276,6 +294,26 @@ export let FEATURE_AUDIO_INGEST = coerceBoolean(
DEFAULT_FLAGS.FEATURE_AUDIO_INGEST
);
export let FEATURE_BITCOIN_CONNECT = coerceBoolean(
runtimeFlags.FEATURE_BITCOIN_CONNECT,
DEFAULT_FLAGS.FEATURE_BITCOIN_CONNECT
);
export let FEATURE_BLOSSOM_STORAGE = coerceBoolean(
runtimeFlags.FEATURE_BLOSSOM_STORAGE,
DEFAULT_FLAGS.FEATURE_BLOSSOM_STORAGE
);
export let FEATURE_BLOSSOM_TORRENT_METADATA = coerceBoolean(
runtimeFlags.FEATURE_BLOSSOM_TORRENT_METADATA,
DEFAULT_FLAGS.FEATURE_BLOSSOM_TORRENT_METADATA
);
// Max base64 size of an embedded .torrent piece-map before bitvid falls back to
// URL-only (the companion event is off the feed, so this is a pathological-case
// guard, not a feature limiter). See docs/blossom-torrent-metadata-plan.md (D4).
export const BLOSSOM_TORRENT_METADATA_MAX_BASE64 = 65536;
export let FEATURE_HASHTAG_PREFERENCES = coerceBoolean(
runtimeFlags.FEATURE_HASHTAG_PREFERENCES,
DEFAULT_FLAGS.FEATURE_HASHTAG_PREFERENCES
+120
View File
@@ -2493,6 +2493,126 @@ export function buildViewEvent(params) {
return event;
}
// --- WebTorrent piece-map companion event (NIP-78 app data) ------------------
// Restores P2P for Blossom-hosted videos (whose magnet is webseed-only because
// Blossom rejects a .torrent) by publishing the torrent metadata as a separate,
// infohash-keyed event that bitvid fetches lazily on the torrent path.
// See docs/blossom-torrent-metadata-plan.md.
export const TORRENT_METADATA_KIND = 30078;
export const TORRENT_METADATA_DTAG_PREFIX = "bitvid:torrent:";
export const TORRENT_METADATA_ENVELOPE_VERSION = 1;
// Normalize an infohash to the lowercase-hex form used in the addressing key.
// BitTorrent v1 (WebTorrent) infohashes are 40 hex chars.
export function normalizeInfoHash(infoHash) {
const hex = typeof infoHash === "string" ? infoHash.trim().toLowerCase() : "";
return /^[0-9a-f]{40}$/.test(hex) ? hex : "";
}
// The parameterized-replaceable `d`-tag value that addresses a video's piece-map.
export function torrentMetadataDTag(infoHash) {
const hex = normalizeInfoHash(infoHash);
return hex ? `${TORRENT_METADATA_DTAG_PREFIX}${hex}` : "";
}
/**
* Builds the WebTorrent piece-map companion event (kind 30078). The `content` is a
* versioned JSON envelope carrying the full `.torrent` file bytes (base64); the
* `info` dict inside is what determines the infohash, so storing the raw bytes
* keeps the reconstructed infohash exact. Addressed by `d = bitvid:torrent:<hex>`
* so bitvid can look it up from a magnet's `btih`.
*
* @param {Object} params
* @param {string} params.pubkey - Author (the video's author) hex pubkey.
* @param {number} params.created_at - Unix seconds.
* @param {string} params.infoHash - BitTorrent v1 infohash (40 hex chars).
* @param {string} params.torrentBase64 - base64 of the full `.torrent` file bytes.
* @param {string} [params.videoEventId] - The video event id (back-reference only).
* @returns {Object} Unsigned event.
*/
export function buildTorrentMetadataEvent(params) {
const {
pubkey,
created_at,
infoHash,
torrentBase64,
videoEventId = "",
} = params || {};
const hex = normalizeInfoHash(infoHash);
if (!hex) {
throw new Error(
"buildTorrentMetadataEvent requires a valid 40-hex-char infoHash."
);
}
if (typeof torrentBase64 !== "string" || !torrentBase64) {
throw new Error(
"buildTorrentMetadataEvent requires a non-empty base64 torrent payload."
);
}
const tags = [
["d", `${TORRENT_METADATA_DTAG_PREFIX}${hex}`],
["x", hex],
["client", "bitvid"],
];
if (typeof videoEventId === "string" && videoEventId.trim()) {
tags.push(["e", videoEventId.trim()]);
}
const content = JSON.stringify({
v: TORRENT_METADATA_ENVELOPE_VERSION,
infohash: hex,
torrent: torrentBase64,
});
return {
kind: TORRENT_METADATA_KIND,
pubkey,
created_at,
tags,
content: ensureValidUtf8Content(content),
};
}
// --- Blossom server list (BUD-03, kind 10063) --------------------------------
// A user's preferred Blossom media servers, in order. Lets other clients discover
// where a user hosts blobs, and lets bitvid pre-fill its storage pane from it.
export const BLOSSOM_SERVER_LIST_KIND = 10063;
/**
* Builds a Blossom server-list event (BUD-03, kind 10063): one `['server', <url>]`
* tag per server, in preference order, de-duped.
*
* @param {Object} params
* @param {string} params.pubkey
* @param {number} params.created_at
* @param {string[]} params.servers - Ordered server URLs.
* @returns {Object} Unsigned event.
*/
export function buildBlossomServerListEvent(params) {
const { pubkey, created_at, servers } = params || {};
const seen = new Set();
const tags = [];
if (Array.isArray(servers)) {
for (const entry of servers) {
const url = typeof entry === "string" ? entry.trim() : "";
if (url && !seen.has(url)) {
seen.add(url);
tags.push(["server", url]);
}
}
}
tags.push(["client", "bitvid"]);
return {
kind: BLOSSOM_SERVER_LIST_KIND,
pubkey,
created_at,
tags,
content: "",
};
}
/**
* Builds a Zap Request event (Kind 9734).
* Used to request an invoice from a Lightning Service Provider (LSP).
+498
View File
@@ -0,0 +1,498 @@
// Blossom (nostr-native blob storage) upload service.
//
// Owns the Blossom upload orchestration so mediaUploader stays thin and this
// logic is unit-testable without pulling WebTorrent. Implements the same result
// shape as the S3/R2 path (see js/ui/components/mediaUploader.js) so a
// `provider === "blossom"` connection flows through publish unchanged. Auth is a
// signed kind-24242 nostr event (BUD-11) from bitvid's existing signer — no
// access keys. Uploads mirror to multiple servers for resilience (BUD-04).
// See docs/blossom-plan.md / TODO #30.
import {
FEATURE_BLOSSOM_STORAGE,
BLOSSOM_TORRENT_METADATA_MAX_BASE64,
} from "../constants.js";
import { buildStoragePointerValue } from "../utils/storagePointer.js";
import { devLogger } from "../utils/logger.js";
// base64 of a File/Blob's bytes (browser + Node 20+). Used to embed the
// .torrent piece-map in the companion event. See docs/blossom-torrent-metadata-plan.md.
async function fileToBase64(file) {
const buf = await file.arrayBuffer();
const bytes = new Uint8Array(buf);
if (typeof Buffer !== "undefined") {
return Buffer.from(bytes).toString("base64");
}
let binary = "";
for (let i = 0; i < bytes.length; i += 1) {
binary += String.fromCharCode(bytes[i]);
}
return btoa(binary);
}
// Vendored, pinned blossom-client-sdk bundle (scripts/build-blossom-sdk.mjs).
// Lazy-imported so its (small) weight never touches the main bundle.
const BLOSSOM_SDK_BUNDLE_URL = "../../vendor/blossom-client-sdk.bundle.min.js";
export const BLOSSOM_PROVIDER = "blossom";
export function isBlossomProvider(provider) {
return provider === BLOSSOM_PROVIDER;
}
// Extract a clean, de-duped server-URL list from a connection's credentials
// (servers live in the plaintext connection meta — they are public, not secret).
export function resolveBlossomServers(credentials) {
const source = credentials || {};
const raw = Array.isArray(source.servers)
? source.servers
: Array.isArray(source.meta?.servers)
? source.meta.servers
: [];
const out = [];
const seen = new Set();
for (const entry of raw) {
const url = typeof entry === "string" ? entry.trim() : "";
if (url && !seen.has(url)) {
seen.add(url);
out.push(url);
}
}
return out;
}
// Common media extensions → MIME type. Browsers leave File.type empty for some
// containers (.m4v/.mkv) and for files dragged in without an OS MIME mapping.
// The Blossom SDK uploads `body: file` with no explicit Content-Type, so an empty
// File.type means the PUT declares no type — and a type-allowlisting server (e.g.
// blossom.band) then answers 415. We re-declare the type from the filename.
const MIME_BY_EXTENSION = {
mp4: "video/mp4",
m4v: "video/mp4",
mov: "video/quicktime",
webm: "video/webm",
mkv: "video/x-matroska",
ogv: "video/ogg",
mp3: "audio/mpeg",
m4a: "audio/mp4",
m4b: "audio/mp4",
aac: "audio/aac",
wav: "audio/wav",
flac: "audio/flac",
opus: "audio/opus",
oga: "audio/ogg",
ogg: "audio/ogg",
weba: "audio/webm",
jpg: "image/jpeg",
jpeg: "image/jpeg",
png: "image/png",
webp: "image/webp",
gif: "image/gif",
avif: "image/avif",
torrent: "application/x-bittorrent",
};
function inferMimeFromName(name) {
const ext =
typeof name === "string" ? name.split(".").pop()?.toLowerCase() : "";
return (ext && MIME_BY_EXTENSION[ext]) || "";
}
// Types we should never trust for a media upload — a recognized extension wins
// over these. Browsers report "" for some containers, and apps/WebTorrent often
// stamp "application/octet-stream" on media files; strict Blossom servers reject
// both with 415/400 even though they accept the real type (e.g. video/mp4).
const GENERIC_TYPES = new Set(["", "application/octet-stream", "binary/octet-stream"]);
// Guarantee the blob carries a correct Content-Type. When the filename has a
// recognized media extension, trust it over an empty or generic browser type and
// re-wrap the blob with the inferred type (bytes are unchanged, so the sha256 is
// too). A specific, non-generic browser type is left as-is.
export function ensureTypedFile(file) {
if (!file || typeof File === "undefined") {
return file;
}
const name = typeof file.name === "string" ? file.name : "upload";
const inferred = inferMimeFromName(name);
const current = typeof file.type === "string" ? file.type : "";
// Nothing to infer, or the browser already declares the exact inferred type.
if (!inferred || current === inferred) {
return file;
}
// Keep a specific, non-generic browser type; only override empty/generic ones.
if (current && !GENERIC_TYPES.has(current)) {
return file;
}
try {
return new File([file], name, { type: inferred });
} catch {
return file;
}
}
// Extract the blob id (sha256, 64 hex) from a Blossom URL — the last path segment
// minus any extension (`https://<host>/<sha256>.mp4` → `<sha256>`). Returns "" when
// the URL doesn't point at a Blossom-style content-addressed blob.
export function blobSha256FromUrl(url) {
const raw = typeof url === "string" ? url.trim() : "";
if (!raw) return "";
try {
const path = new URL(raw).pathname;
const last = path.split("/").pop() || "";
const hex = last.split(".")[0].toLowerCase();
return /^[0-9a-f]{64}$/.test(hex) ? hex : "";
} catch {
return "";
}
}
// Read the server URLs from a user's kind-10063 Blossom server-list event (BUD-03):
// one `["server", <url>]` tag per server, in preference order. De-duped + trimmed.
export function serversFromServerListEvent(event) {
const tags = Array.isArray(event?.tags) ? event.tags : [];
const urls = tags
.filter((t) => Array.isArray(t) && t[0] === "server" && typeof t[1] === "string")
.map((t) => t[1]);
return resolveBlossomServers({ servers: urls });
}
export class BlossomService {
constructor(deps = {}) {
this.deps = deps;
this._sdkPromise = null;
}
// On/off gate — off ⇒ the SDK is never imported.
isAvailable() {
return FEATURE_BLOSSOM_STORAGE === true;
}
async loadSdk() {
if (this._sdkPromise) {
return this._sdkPromise;
}
this._sdkPromise = import(BLOSSOM_SDK_BUNDLE_URL).catch((error) => {
this._sdkPromise = null;
throw error;
});
return this._sdkPromise;
}
/**
* Upload a single blob to one or more Blossom servers (mirrored). Returns the
* primary blob descriptor's `url` (`<server>/<sha256>`) and `key` (sha256).
*/
async uploadFile({ file, servers, signer, type = "upload", onProgress } = {}) {
if (!file) {
throw new Error("Blossom upload requires a file.");
}
const serverList = resolveBlossomServers({ servers });
if (serverList.length === 0) {
throw new Error("Blossom upload requires at least one server URL.");
}
if (typeof signer !== "function") {
throw new Error("Blossom upload requires a signer (a signed-event function).");
}
const sdk = await this.loadSdk();
const { uploadBlob, createUploadAuth } = sdk;
if (
typeof uploadBlob !== "function" ||
typeof createUploadAuth !== "function"
) {
throw new Error("Blossom SDK is missing expected exports.");
}
// Ensure the blob declares a Content-Type — an empty File.type otherwise
// uploads as a typeless PUT and strict servers reject it with 415.
const typedFile = ensureTypedFile(file);
// Surface exactly what we're about to send so a 415 is never a guess:
// the effective Content-Type (from typedFile.type), original type, name, size.
devLogger.log(
`[Blossom] uploading "${typeof typedFile?.name === "string" ? typedFile.name : "(no name)"}" — ` +
`sending Content-Type="${typedFile?.type || "(empty)"}" ` +
`(original File.type="${file?.type || "(empty)"}", ${typedFile?.size ?? "?"} bytes) ` +
`to ${serverList.length} server(s): ${serverList.join(", ")}`,
);
// Upload DIRECTLY to each server (BUD-02 `PUT /upload`), in parallel, rather
// than the SDK's upload-one-then-`/mirror` flow — the server-to-server mirror
// step fails from a browser (CORS) on many servers. A fresh signed upload auth
// (BUD-11, scoped to the blob's sha256) is created per request.
const onAuth = (server, sha256) =>
createUploadAuth(signer, sha256, { type });
const settled = await Promise.allSettled(
serverList.map((server) =>
uploadBlob(server, typedFile, {
onAuth,
onProgress: typeof onProgress === "function" ? onProgress : undefined,
}),
),
);
const descriptors = [];
const failures = [];
settled.forEach((r, i) => {
if (r.status === "fulfilled" && r.value && typeof r.value.url === "string") {
descriptors.push(r.value);
} else {
const reason = r.reason;
// The SDK's HTTPError carries `.status`; surface it so 415 (media type)
// vs 413 (too large) vs 402 (payment) is unambiguous in the message.
const status =
reason && typeof reason.status === "number"
? `HTTP ${reason.status}: `
: "";
const message = reason?.message || reason || "no blob URL returned";
failures.push(`${serverList[i]}: ${status}${message}`);
}
});
const primary = descriptors.find((d) => typeof d?.url === "string" && d.url);
if (!primary) {
throw new Error(
`Blossom upload failed on all server(s) — ${failures.join("; ")}. ` +
`Common causes: the file exceeds the server's size limit (blossom.band ` +
`caps free uploads at 20 MiB), the server rejects the media type, blocks ` +
`browser uploads (CORS), or requires payment/allowlisting.`,
);
}
return {
url: primary.url,
key: primary.sha256 || "",
servers: serverList,
descriptors,
};
}
/**
* Full video upload: the blob, then (if `generateTorrent` yields one) the
* `.torrent` as a second blob, wired into a magnet (ws = the Blossom video URL,
* xs = the Blossom .torrent URL). Returns the same shape mediaUploader's S3 path
* returns so publish is untouched. `generateTorrent` is injected by the caller
* so this module never imports WebTorrent (keeps it unit-testable).
*
* @param {object} params
* @param {File|Blob} params.file
* @param {string[]} params.servers
* @param {(draft:object)=>Promise<object>} params.signer
* @param {(args:{file:File,videoPublicUrl:string})=>Promise<{hasValidInfoHash:boolean,infoHash?:string,torrentFile?:File}>} [params.generateTorrent]
* @param {(fraction:number)=>void} [params.onProgress]
* @param {(args:{infoHash:string,torrentBase64:string})=>Promise<void>} [params.publishTorrentMetadata]
* Publishes the torrent piece-map as a companion Nostr event (Tier 2). When
* provided and `FEATURE_BLOSSOM_TORRENT_METADATA` is on and the `.torrent`
* couldn't be hosted (no `xs=`), a successful publish yields a `ws=`-only magnet
* bitvid can bootstrap. Any failure / over-cap ⇒ URL-only.
*/
async uploadVideo({
file,
servers,
signer,
generateTorrent,
onProgress,
publishTorrentMetadata,
} = {}) {
const serverList = resolveBlossomServers({ servers });
if (serverList.length === 0) {
throw new Error("No Blossom servers configured for this connection.");
}
const uploaded = await this.uploadFile({
file,
servers: serverList,
signer,
type: "upload",
onProgress,
});
const videoPublicUrl = uploaded.url; // <server>/<sha256>
const result = {
url: videoPublicUrl,
key: uploaded.key,
storagePointer: buildStoragePointerValue({
provider: BLOSSOM_PROVIDER,
prefix: serverList[0], // where the blob lives, for later delete/list (BUD-12)
}),
infoHash: "",
magnet: "",
torrentUrl: "",
torrentFile: null,
hasValidInfoHash: false,
};
let torrent = null;
if (typeof generateTorrent === "function") {
torrent = await generateTorrent({ file, videoPublicUrl });
}
if (torrent?.hasValidInfoHash && torrent.torrentFile) {
const name = typeof file?.name === "string" ? file.name : "video";
// Retain the generated .torrent locally so a future/other storage target
// could host it — but don't advertise a magnet yet (see below).
result.torrentFile = torrent.torrentFile;
// Try to host the .torrent alongside the video so the magnet carries xs=
// (the metadata source WebTorrent needs to start). Best-effort: most Blossom
// servers accept media types only and reject a .torrent (415). Only when the
// .torrent is actually hosted do we publish a magnet — a webseed-only magnet
// (no xs=) can't bootstrap WebTorrent and would be a dead share link, so we
// publish URL-only instead. Servers that DO accept .torrent (self-hosted or
// paid) automatically yield a full, P2P-playable magnet with no other change.
let torrentUrl = "";
try {
const torrentUploaded = await this.uploadFile({
file: torrent.torrentFile,
servers: serverList,
signer,
type: "upload",
});
torrentUrl = torrentUploaded.url;
} catch (error) {
devLogger.warn(
`[Blossom] Video uploaded. The .torrent sidecar wasn't hosted ` +
`(Blossom servers accept media only); publishing URL-only — a ` +
`webseed-only magnet can't bootstrap WebTorrent.`,
);
}
const wsMagnet = () =>
`magnet:?xt=urn:btih:${torrent.infoHash}&dn=${encodeURIComponent(name)}` +
`&ws=${encodeURIComponent(videoPublicUrl)}`;
if (torrentUrl) {
// Tier 1 — the .torrent is hosted (xs=): a full magnet, playable in any
// torrent client.
result.infoHash = torrent.infoHash;
result.torrentUrl = torrentUrl;
result.magnet = `${wsMagnet()}&xs=${encodeURIComponent(torrentUrl)}`;
result.hasValidInfoHash = true;
} else if (typeof publishTorrentMetadata === "function") {
// Tier 2 — publish the piece-map as a companion Nostr event (D3/D4) so
// bitvid can bootstrap WebTorrent from the infohash without a hosted
// .torrent. The publisher is only wired when FEATURE_BLOSSOM_TORRENT_METADATA
// is on (that flag gate lives at the mediaUploader seam), so its presence
// IS the gate here. Gate on the exact encoded size (off the feed, but a
// guard); over cap or a failed publish falls through to URL-only.
try {
const torrentBase64 = await fileToBase64(torrent.torrentFile);
if (torrentBase64.length > BLOSSOM_TORRENT_METADATA_MAX_BASE64) {
devLogger.warn(
`[Blossom] Video uploaded. Torrent piece-map is ${torrentBase64.length} ` +
`base64 bytes (> ${BLOSSOM_TORRENT_METADATA_MAX_BASE64}); publishing ` +
`URL-only.`,
);
} else {
await publishTorrentMetadata({
infoHash: torrent.infoHash,
torrentBase64,
});
result.infoHash = torrent.infoHash;
result.magnet = wsMagnet(); // ws= only; metadata is on Nostr
result.hasValidInfoHash = true;
}
} catch (error) {
devLogger.warn(
`[Blossom] Video uploaded, but publishing the torrent metadata ` +
`companion failed; publishing URL-only. Reason: ${
error?.message || error
}`,
);
}
}
// else: URL-only (flag off / no publisher wired) — video already succeeded.
}
return result;
}
/**
* Delete a blob from one Blossom server (BUD-12 `DELETE /<sha256>`), authorized
* by a signed kind-24242 delete auth (BUD-11). Returns true on success.
*/
async deleteFile({ server, sha256, signer } = {}) {
const url = typeof server === "string" ? server.trim() : "";
const hash = typeof sha256 === "string" ? sha256.trim() : "";
if (!url || !hash) {
throw new Error("Blossom delete requires a server and a sha256.");
}
if (typeof signer !== "function") {
throw new Error("Blossom delete requires a signer.");
}
const sdk = await this.loadSdk();
const { deleteBlob, createDeleteAuth } = sdk;
if (
typeof deleteBlob !== "function" ||
typeof createDeleteAuth !== "function"
) {
throw new Error("Blossom SDK is missing delete exports.");
}
return deleteBlob(url, hash, {
onAuth: () => createDeleteAuth(signer, hash),
});
}
/**
* Delete the blob behind a Blossom URL from every configured server
* (best-effort). Used by orphan cleanup when a Blossom-hosted video/thumbnail is
* superseded or deleted. A non-Blossom URL (no 64-hex blob id) is a no-op.
*
* @returns {Promise<{sha256:string, deleted:string[], failed:{server:string,error:string}[]}>}
*/
async deleteByUrl({ url, servers, signer } = {}) {
const sha256 = blobSha256FromUrl(url);
if (!sha256) {
return { sha256: "", deleted: [], failed: [] };
}
let serverList = resolveBlossomServers({ servers });
if (serverList.length === 0) {
// No explicit servers — delete from the blob URL's own origin (the server
// that served it). Callers with the full mirror set should pass `servers`.
try {
serverList = [new URL(url).origin];
} catch {
serverList = [];
}
}
const deleted = [];
const failed = [];
await Promise.allSettled(
serverList.map(async (server) => {
try {
await this.deleteFile({ server, sha256, signer });
deleted.push(server);
} catch (error) {
failed.push({ server, error: error?.message || String(error) });
}
}),
);
return { sha256, deleted, failed };
}
/**
* List a user's blobs on a Blossom server (BUD-12 `GET /list/<pubkey>`). Some
* servers require a signed list auth; provide the signer to cover those.
* Returns the array of blob descriptors.
*/
async listFiles({ server, pubkey, signer } = {}) {
const url = typeof server === "string" ? server.trim() : "";
const pk = typeof pubkey === "string" ? pubkey.trim() : "";
if (!url || !pk) {
throw new Error("Blossom list requires a server and a pubkey.");
}
const sdk = await this.loadSdk();
const { listBlobs, createListAuth } = sdk;
if (typeof listBlobs !== "function") {
throw new Error("Blossom SDK is missing list exports.");
}
const opts =
typeof signer === "function" && typeof createListAuth === "function"
? { onAuth: () => createListAuth(signer) }
: {};
return listBlobs(url, pk, opts);
}
}
const blossomService = new BlossomService();
export default blossomService;
+13 -2
View File
@@ -1288,8 +1288,19 @@ class PlaybackSession extends SimpleEventEmitter {
}
}
// Fallback to URL
if (httpsUrl && forcedSource !== "torrent") {
// Fallback to URL. Even when P2P was explicitly forced, a hosted URL is a
// better outcome than a hard "No playable source found" if the torrent
// DEFINITIVELY failed (e.g. a transient companion-lookup failure or a dead
// swarm). The torrent-first attempt above already ran — and, when there was
// no URL plan B, waited indefinitely — so we only reach here on real
// failure, not on a slow-but-working swarm.
// Rollback: restore `&& forcedSource !== "torrent"` to make forced P2P hard-fail.
if (httpsUrl) {
if (forcedSource === "torrent") {
this.service.log(
"[playVideoWithFallback] Forced torrent failed; falling back to the hosted URL."
);
}
const urlResult = await attemptHostedPlayback();
if (urlResult && urlResult.source === "url") {
return urlResult;
+77 -1
View File
@@ -194,6 +194,31 @@ export class StorageService {
this.dbPromise = null;
// Cache decrypted master keys in memory: Map<pubkey, CryptoKey>
this.masterKeys = new Map();
// Observers notified when a pubkey's connection set changes (e.g. a Nostr
// sync import), so open UIs (the upload modal) can refresh live.
this._changeListeners = new Set();
}
/**
* Subscribe to connection-set changes. The listener receives `{ pubkey }`.
* Returns an unsubscribe function.
*/
onConnectionsChanged(listener) {
if (typeof listener !== "function") {
return () => {};
}
this._changeListeners.add(listener);
return () => this._changeListeners.delete(listener);
}
_emitConnectionsChanged(pubkey) {
for (const listener of this._changeListeners) {
try {
listener({ pubkey });
} catch (error) {
// A misbehaving observer must never break a storage write.
}
}
}
/**
@@ -591,6 +616,41 @@ export class StorageService {
userLogger.log(`[StorageService] Saved connection ${connectionId}`);
}
/**
* Save a KEYLESS connection (e.g. Blossom): all config lives in the plaintext
* meta and there is no secret to encrypt, so this does NOT require unlock().
* Used for providers whose auth is the user's Nostr signer, not a stored key.
*/
async saveKeylessConnection(pubkey, connectionId, meta = {}) {
const account = (await this._getAccount(pubkey)) || {
pubkey,
connections: {},
};
if (!account.connections) {
account.connections = {};
}
if (meta.defaultForUploads) {
for (const key in account.connections) {
const conn = account.connections[key];
if (conn.meta) {
conn.meta.defaultForUploads = false;
}
}
}
account.connections[connectionId] = {
id: connectionId,
provider: meta.provider || connectionId,
meta: {
...meta,
lastSaved: Date.now(),
provider: meta.provider || connectionId,
},
encrypted: null,
};
await this._saveAccount(account);
userLogger.log(`[StorageService] Saved keyless connection ${connectionId}`);
}
/**
* Retrieves and decrypts a connection.
* Requires unlock() to be called first.
@@ -675,13 +735,29 @@ export class StorageService {
if (!pubkey) {
throw new Error("Pubkey required to import storage account record.");
}
if (!record || typeof record !== "object" || !record.encryptedMasterKey) {
if (!record || typeof record !== "object") {
throw new Error("Invalid storage account record.");
}
// A Blossom-only account is keyless (no encrypted payloads), so it legitimately
// has no encryptedMasterKey. Require the envelope ONLY when the record actually
// carries an encrypted connection that would need it to unlock — otherwise a
// Blossom-only account can't sync across devices.
const connections =
record.connections && typeof record.connections === "object"
? record.connections
: {};
const hasEncryptedConnection = Object.values(connections).some(
(conn) => conn && conn.encrypted != null,
);
if (hasEncryptedConnection && !record.encryptedMasterKey) {
throw new Error("Invalid storage account record.");
}
const toSave = { ...record, pubkey };
await this._saveAccount(toSave);
// Drop any in-memory master key so the next unlock uses the imported envelope.
this.lock(pubkey);
// Let open UIs (e.g. the upload modal) reflect the newly-synced connections.
this._emitConnectionsChanged(pubkey);
return toSave;
}
+164
View File
@@ -0,0 +1,164 @@
// Torrent piece-map lookup for Blossom videos (Phase 2 of the WebTorrent-metadata
// companion event). Given a magnet's infohash + the video's author, fetches the
// infohash-keyed kind-30078 companion event, decodes the embedded `.torrent`, and
// VERIFIES that the reconstructed infohash equals the requested one before handing
// the bytes to WebTorrent. Untrusted content: a mismatch (tamper / wrong event) is
// rejected → the caller falls back to URL-only. See docs/blossom-torrent-metadata-plan.md.
import {
TORRENT_METADATA_KIND,
torrentMetadataDTag,
normalizeInfoHash,
} from "../nostrEventSchemas.js";
// base64 → Uint8Array (browser + Node).
export function base64ToBytes(b64) {
if (typeof Buffer !== "undefined") {
return new Uint8Array(Buffer.from(b64, "base64"));
}
const binary = atob(b64);
const out = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i += 1) out[i] = binary.charCodeAt(i);
return out;
}
function bytesToHex(bytes) {
let hex = "";
for (let i = 0; i < bytes.length; i += 1) {
hex += bytes[i].toString(16).padStart(2, "0");
}
return hex;
}
// Advance past one bencoded value beginning at `i`; returns the index after it.
// Handles integers (`i…e`), byte strings (`<len>:…`), lists (`l…e`) and dicts
// (`d…e`). Throws on anything malformed (callers treat that as "unverifiable").
function skipValue(b, i) {
if (i >= b.length) throw new Error("bencode: truncated");
const c = b[i];
if (c === 0x69 /* i */) {
i += 1;
while (i < b.length && b[i] !== 0x65 /* e */) i += 1;
if (i >= b.length) throw new Error("bencode: unterminated int");
return i + 1;
}
if (c === 0x6c /* l */ || c === 0x64 /* d */) {
i += 1;
while (i < b.length && b[i] !== 0x65 /* e */) i = skipValue(b, i);
if (i >= b.length) throw new Error("bencode: unterminated container");
return i + 1;
}
// byte string: <len>:<bytes>
let len = 0;
let sawDigit = false;
while (i < b.length && b[i] >= 0x30 && b[i] <= 0x39) {
len = len * 10 + (b[i] - 0x30);
sawDigit = true;
i += 1;
}
if (!sawDigit || b[i] !== 0x3a /* : */) throw new Error("bencode: bad string");
i += 1;
if (i + len > b.length) throw new Error("bencode: string overruns buffer");
return i + len;
}
// Return the raw bytes of the top-level `info` value in a `.torrent`, or null.
function extractInfoDictBytes(b) {
if (!(b instanceof Uint8Array) || b[0] !== 0x64 /* d */) return null;
let i = 1;
const decoder = new TextDecoder();
while (i < b.length && b[i] !== 0x65 /* e */) {
// key (byte string)
let len = 0;
let sawDigit = false;
while (i < b.length && b[i] >= 0x30 && b[i] <= 0x39) {
len = len * 10 + (b[i] - 0x30);
sawDigit = true;
i += 1;
}
if (!sawDigit || b[i] !== 0x3a) return null;
i += 1;
if (i + len > b.length) return null;
const key = decoder.decode(b.subarray(i, i + len));
i += len;
const valueStart = i;
i = skipValue(b, i);
if (key === "info") return b.subarray(valueStart, i);
}
return null;
}
// The BitTorrent v1 infohash (SHA-1 of the bencoded `info` dict) of a `.torrent`
// buffer, lowercase hex, or "" if it can't be parsed/hashed.
export async function infoHashFromTorrent(bytes) {
try {
const info = extractInfoDictBytes(bytes);
if (!info) return "";
const digest = await crypto.subtle.digest("SHA-1", info);
return bytesToHex(new Uint8Array(digest));
} catch {
return "";
}
}
export class TorrentMetadataService {
constructor() {
// Cache the verified piece-map by infohash so repeated plays don't re-query.
this.cache = new Map();
}
/**
* Fetch + verify a video's torrent piece-map from its companion event.
*
* @param {object} params
* @param {string} params.infoHash - The magnet's `btih` (40 hex).
* @param {string} [params.author] - The video's author hex pubkey (scopes the query).
* @param {(filter:object)=>Promise<Array<object>>} params.queryEvents - Relay query.
* @returns {Promise<{infoHash:string,torrentBytes:Uint8Array}|null>} null ⇒ URL-only.
*/
async fetch({ infoHash, author, queryEvents } = {}) {
const hex = normalizeInfoHash(infoHash);
if (!hex || typeof queryEvents !== "function") return null;
if (this.cache.has(hex)) return this.cache.get(hex);
const filter = {
kinds: [TORRENT_METADATA_KIND],
"#d": [torrentMetadataDTag(hex)],
};
if (author) filter.authors = [author];
let events;
try {
events = await queryEvents(filter);
} catch {
return null;
}
if (!Array.isArray(events) || events.length === 0) return null;
// Newest event wins.
const newest = events
.filter((e) => e && typeof e.content === "string")
.sort((a, b) => (b.created_at || 0) - (a.created_at || 0))[0];
if (!newest) return null;
let bytes;
try {
const envelope = JSON.parse(newest.content);
if (!envelope || typeof envelope.torrent !== "string") return null;
bytes = base64ToBytes(envelope.torrent);
} catch {
return null;
}
// SECURITY: only trust the piece-map if it reconstructs to the requested
// infohash. A tampered/wrong event fails this and is discarded.
const actual = await infoHashFromTorrent(bytes);
if (actual !== hex) return null;
const result = { infoHash: hex, torrentBytes: bytes };
this.cache.set(hex, result);
return result;
}
}
const torrentMetadataService = new TorrentMetadataService();
export default torrentMetadataService;
+4
View File
@@ -9,6 +9,8 @@ import { createModalAccessibility } from "./modalAccessibility.js";
import { Nip71FormManager } from "./nip71FormManager.js";
import { MediaUploader } from "./mediaUploader.js";
import { initEditModalUpload } from "./editModalUpload.js";
import { getActiveSigner } from "../../nostrClientRegistry.js";
import { nostrClient } from "../../nostrClientFacade.js";
import { devLogger } from "../../utils/logger.js";
export class EditModal {
@@ -51,6 +53,8 @@ export class EditModal {
this.getCurrentPubkey ? this.getCurrentPubkey() : null,
safeEncodeNpub:
typeof safeEncodeNpub === "function" ? safeEncodeNpub : (p) => p,
getSigner: () => getActiveSigner(),
signAndPublishEvent: (event) => nostrClient.signAndPublishEvent(event),
});
this.getMode =
typeof getMode === "function"
+71 -7
View File
@@ -16,12 +16,17 @@ import { sanitizeBucketName } from "../../storage/r2-mgmt.js";
import { buildR2Key, buildPublicUrl } from "../../r2.js";
import { buildS3ObjectUrl } from "../../services/s3Service.js";
import { PROVIDERS } from "../../services/storageService.js";
import {
BLOSSOM_PROVIDER,
isBlossomProvider,
} from "../../services/blossomService.js";
import {
buildStoragePointerValue,
buildStoragePrefixFromKey,
deriveStoragePointerFromUrl,
} from "../../utils/storagePointer.js";
import {
nostrClient,
getActiveSigner,
requestDefaultExtensionPermissions,
} from "../../nostrClientFacade.js";
@@ -96,6 +101,8 @@ export class UploadModal {
getCurrentPubkey: () =>
this.getCurrentPubkey ? this.getCurrentPubkey() : null,
safeEncodeNpub: (pubkey) => this.safeEncodeNpub(pubkey),
getSigner: () => getActiveSigner(),
signAndPublishEvent: (event) => nostrClient.signAndPublishEvent(event),
});
this.eventTarget =
eventTarget instanceof EventTarget ? eventTarget : new EventTarget();
@@ -118,9 +125,24 @@ export class UploadModal {
this.activeProvider = null;
this.isStorageUnlocked = false;
this.storageConfigured = false;
// Blossom connections have no encrypted secret, so they never need a PIN
// unlock — the upload authorizes with the Nostr signer instead.
this.activeProviderIsBlossom = false;
// #44: per-modal upload-destination override (null = account default).
this.selectedConnectionId = null;
// Live-refresh the storage destination if connections change under an open
// modal — e.g. credentials synced from Nostr — so the user doesn't have to
// reopen. Scoped to the active user + open state.
if (this.storageService?.onConnectionsChanged) {
this.storageService.onConnectionsChanged(({ pubkey } = {}) => {
if (!this.isVisible) return;
const current = this.getCurrentPubkey ? this.getCurrentPubkey() : null;
if (pubkey && current && pubkey !== current) return;
void this.refreshStorageFromSync(current);
});
}
// Upload State
this.videoUploadState = {
status: 'idle', // idle, uploading, complete, error
@@ -555,9 +577,10 @@ export class UploadModal {
e.target.value = ""; // Clear selection
return;
}
if (!this.isStorageUnlocked) {
if (!this.isStorageUnlocked && !this.activeProviderIsBlossom) {
// #56: unlock inline (silent restore or one passphrase prompt) and
// continue with this same file pick instead of erroring.
// continue with this same file pick instead of erroring. Blossom has no
// secret to unlock — it authorizes with the Nostr signer at upload time.
const unlocked = await this.ensureStorageUnlockedForUpload();
if (!unlocked) {
e.target.value = "";
@@ -663,8 +686,9 @@ export class UploadModal {
this.resetThumbnailPicker();
return;
}
if (!this.isStorageUnlocked) {
if (!this.isStorageUnlocked && !this.activeProviderIsBlossom) {
// #56: prompt to unlock inline instead of silently dropping the pick.
// Blossom needs no unlock (signer-authorized at upload time).
const unlocked = await this.ensureStorageUnlockedForUpload();
if (!unlocked) {
this.resetThumbnailPicker();
@@ -912,6 +936,26 @@ export class UploadModal {
});
}
// Re-derive the storage destination after connections change under an open
// modal (e.g. a Nostr sync import). Mirrors the open-time flow: a sync import
// may re-lock storage with a new master-key envelope, so re-attempt auto-unlock
// (Blossom stays keyless), recompute the unlocked state, then reload + repaint.
async refreshStorageFromSync(pubkey) {
if (!this.storageService || !this.isVisible) return;
const key =
pubkey || (this.getCurrentPubkey ? this.getCurrentPubkey() : null);
try {
await this.maybeAutoUnlockStorage(key);
this.isStorageUnlocked = key
? this.storageService.isUnlocked(key)
: false;
await this.loadFromStorage();
this.updateLockUi();
} catch (error) {
devLogger.warn?.("[UploadModal] storage-sync refresh failed:", error);
}
}
async loadFromStorage() {
if (!this.storageService) return;
const pubkey = this.getCurrentPubkey ? this.getCurrentPubkey() : null;
@@ -933,12 +977,20 @@ export class UploadModal {
if (targetConn) {
this.storageConfigured = true;
this.activeProvider = targetConn.provider;
this.activeProviderIsBlossom = isBlossomProvider(targetConn.provider);
// UI Updates
this.toggleStorageView("summary");
const providerName = this.getProviderLabel(targetConn.provider || targetConn.meta?.provider);
const bucketName = targetConn.meta?.bucket || "Unknown Bucket";
const urlStyle = this.describeUrlStyle(targetConn.provider, targetConn.meta?.forcePathStyle);
const blossomServers = Array.isArray(targetConn.meta?.servers)
? targetConn.meta.servers
: [];
const bucketName = this.activeProviderIsBlossom
? `${blossomServers.length} server${blossomServers.length === 1 ? "" : "s"}`
: targetConn.meta?.bucket || "Unknown Bucket";
const urlStyle = this.activeProviderIsBlossom
? "Nostr-native (content-addressed)"
: this.describeUrlStyle(targetConn.provider, targetConn.meta?.forcePathStyle);
if (this.statusText.summaryProvider) {
this.statusText.summaryProvider.textContent = providerName;
@@ -954,7 +1006,14 @@ export class UploadModal {
}
if (this.statusText.summaryCopy) this.statusText.summaryCopy.textContent = this.getSummaryCopy(targetConn.provider);
if (this.isStorageUnlocked) {
if (this.activeProviderIsBlossom) {
// No secret to decrypt — servers live in the plaintext meta.
this.activeCredentials = {
provider: BLOSSOM_PROVIDER,
servers: blossomServers,
meta: targetConn.meta || {},
};
} else if (this.isStorageUnlocked) {
const details = await this.storageService.getConnection(pubkey, targetConn.id);
if (details) {
this.activeCredentials = details;
@@ -969,6 +1028,7 @@ export class UploadModal {
this.storageConfigured = false;
this.activeCredentials = null;
this.activeProvider = null;
this.activeProviderIsBlossom = false;
this.toggleStorageView("empty");
}
this.updateLockUi();
@@ -1063,7 +1123,8 @@ export class UploadModal {
}
updateLockUi() {
const locked = !this.isStorageUnlocked;
// Blossom has no secret to unlock — never show it as "locked".
const locked = !this.isStorageUnlocked && !this.activeProviderIsBlossom;
if (this.statusText?.storageLock) {
this.statusText.storageLock.textContent = locked ? "Locked 🔒" : "Unlocked 🔓";
this.statusText.storageLock.className = locked ? "text-xs text-critical" : "text-xs text-success";
@@ -1079,6 +1140,9 @@ export class UploadModal {
}
getProviderLabel(provider) {
if (isBlossomProvider(provider)) {
return "Blossom";
}
if (provider === PROVIDERS.R2 || provider === "cloudflare_r2") {
return "Cloudflare R2";
}
+127 -1
View File
@@ -24,6 +24,13 @@ import {
buildStoragePointerValue,
buildStoragePrefixFromKey,
} from "../../utils/storagePointer.js";
import blossomServiceDefault, {
BLOSSOM_PROVIDER,
isBlossomProvider,
resolveBlossomServers,
} from "../../services/blossomService.js";
import { buildTorrentMetadataEvent } from "../../nostrEventSchemas.js";
import { FEATURE_BLOSSOM_TORRENT_METADATA } from "../../constants.js";
const INFO_HASH_PATTERN = /^[a-f0-9]{40}$/;
@@ -46,6 +53,9 @@ export class MediaUploader {
storageService,
getCurrentPubkey,
safeEncodeNpub,
blossomService,
getSigner,
signAndPublishEvent,
} = {}) {
this.r2Service = r2Service || null;
this.s3Service = s3Service || null;
@@ -54,6 +64,15 @@ export class MediaUploader {
typeof getCurrentPubkey === "function" ? getCurrentPubkey : null;
this.safeEncodeNpub =
typeof safeEncodeNpub === "function" ? safeEncodeNpub : () => "";
// Blossom (nostr-native) upload path. Only exercised when a connection's
// provider is "blossom", so S3/R2 uploads are completely unaffected.
this.blossomService = blossomService || blossomServiceDefault;
this.getSigner = typeof getSigner === "function" ? getSigner : null;
// Sign + publish an unsigned Nostr event to the user's relays. Used only to
// publish the WebTorrent piece-map companion event for Blossom videos when
// FEATURE_BLOSSOM_TORRENT_METADATA is on. See docs/blossom-torrent-metadata-plan.md.
this.signAndPublishEvent =
typeof signAndPublishEvent === "function" ? signAndPublishEvent : null;
}
serviceFor(provider) {
@@ -99,6 +118,21 @@ export class MediaUploader {
base.configured = true;
base.provider = targetConn.provider;
// Blossom has no encrypted secret — its server list lives in the plaintext
// connection meta, so credentials are available without unlocking storage
// (uploads authorize with the Nostr signer, not a stored key).
if (isBlossomProvider(targetConn.provider)) {
base.unlocked = true;
base.credentials = {
provider: BLOSSOM_PROVIDER,
servers: Array.isArray(targetConn.meta?.servers)
? targetConn.meta.servers
: [],
meta: targetConn.meta || {},
};
return base;
}
if (base.unlocked) {
const details = await this.storageService.getConnection(
pubkey,
@@ -120,6 +154,9 @@ export class MediaUploader {
// routing when the modal's provider state is stale.
const effectiveProvider =
credentials?.provider || credentials?.meta?.provider || provider;
if (isBlossomProvider(effectiveProvider)) {
return this.uploadThumbnailToBlossom(file, { credentials, onProgress });
}
const service = this.serviceFor(effectiveProvider);
const { settings, bucketEntry } = await service.prepareUpload(npub, {
@@ -214,6 +251,9 @@ export class MediaUploader {
// R2 connection (accountId, no endpoint) through the S3 path — or vice-versa.
const effectiveProvider =
credentials?.provider || credentials?.meta?.provider || provider;
if (isBlossomProvider(effectiveProvider)) {
return this.uploadVideoToBlossom(file, { credentials, onProgress });
}
const service = this.serviceFor(effectiveProvider);
const { settings, bucketEntry } = await service.prepareUpload(npub, {
@@ -313,6 +353,92 @@ export class MediaUploader {
return result;
}
// --- Blossom (nostr-native blob storage) path --------------------------------
// Thin wiring only: resolve servers + signer, then delegate the orchestration
// to blossomService (which owns upload + torrent + magnet + storagePointer and
// stays WebTorrent-free / unit-testable). The torrent generator is injected so
// blossomService never imports WebTorrent. See docs/blossom-plan.md.
// Adapt bitvid's active signer (object with signEvent) to the SDK's signer
// shape: an async (eventTemplate) → signed event.
async resolveBlossomSigner() {
const signer = this.getSigner ? await this.getSigner() : null;
if (!signer || typeof signer.signEvent !== "function") {
throw new Error("A Nostr signer is required to upload to Blossom.");
}
return (draft) => signer.signEvent(draft);
}
async uploadThumbnailToBlossom(file, { credentials, onProgress } = {}) {
const servers = resolveBlossomServers(credentials);
if (servers.length === 0) {
throw new Error("No Blossom servers configured for this connection.");
}
const signer = await this.resolveBlossomSigner();
const uploaded = await this.blossomService.uploadFile({
file,
servers,
signer,
type: "upload",
onProgress: (fraction) => {
if (typeof onProgress === "function") onProgress(fraction);
},
});
return { url: uploaded.url, key: uploaded.key };
}
// Build the flag-gated companion-metadata publisher passed to blossomService
// (Tier 2). Returns undefined unless FEATURE_BLOSSOM_TORRENT_METADATA is on, a
// publish path is wired, and the active pubkey is known — in which case
// blossomService only calls it when the .torrent couldn't be hosted.
resolveTorrentMetadataPublisher() {
if (!FEATURE_BLOSSOM_TORRENT_METADATA || !this.signAndPublishEvent) {
return undefined;
}
const pubkey =
typeof this.getCurrentPubkey === "function" ? this.getCurrentPubkey() : "";
if (!pubkey || typeof pubkey !== "string") {
return undefined;
}
return async ({ infoHash, torrentBase64 }) => {
const event = buildTorrentMetadataEvent({
pubkey,
created_at: Math.floor(Date.now() / 1000),
infoHash,
torrentBase64,
});
await this.signAndPublishEvent(event);
};
}
async uploadVideoToBlossom(file, { credentials, onProgress } = {}) {
const emit = (fraction, label) => {
if (typeof onProgress === "function") onProgress({ fraction, label });
};
const servers = resolveBlossomServers(credentials);
if (servers.length === 0) {
throw new Error("No Blossom servers configured for this connection.");
}
const signer = await this.resolveBlossomSigner();
emit(0, "Uploading video to Blossom...");
const result = await this.blossomService.uploadVideo({
file,
servers,
signer,
generateTorrent: (args) => this.generateTorrentMetadata(args),
onProgress: (fraction) => emit(fraction, null),
publishTorrentMetadata: this.resolveTorrentMetadataPublisher(),
});
emit(
1,
result.hasValidInfoHash
? "Ready to publish!"
: "Upload complete (No torrent fallback).",
);
return result;
}
/**
* Derive a WebTorrent webseed for an EXTERNALLY-hosted video URL so an external
* link keeps the P2P benefit. Best-effort: fetches the remote file (CORS-
@@ -400,4 +526,4 @@ export class MediaUploader {
}
}
export { isR2Provider, normalizeInfoHash, isValidInfoHash };
export { isR2Provider, isBlossomProvider, normalizeInfoHash, isValidInfoHash };
+24
View File
@@ -1,5 +1,9 @@
import { devLogger } from "../utils/logger.js";
import r2Service from "../services/r2Service.js";
import blossomService, {
blobSha256FromUrl,
} from "../services/blossomService.js";
import { getActiveSigner } from "../nostrClientRegistry.js";
import { readRelayPublishSummary } from "../nostrPublish.js";
export default class EditModalController {
@@ -125,6 +129,26 @@ export default class EditModalController {
`[edit] Removed ${cleanup.deleted.length} superseded storage object(s).`
);
}
// Blossom orphans are content-addressed blobs, not R2/S3 keys — delete
// them via BUD-12 (best-effort, from each blob URL's own origin).
const blossomUrls = orphans
.map((o) => o.url || o.thumbnail || "")
.filter((u) => blobSha256FromUrl(u));
if (blossomUrls.length) {
const activeSigner = getActiveSigner();
if (activeSigner && typeof activeSigner.signEvent === "function") {
const signer = (draft) => activeSigner.signEvent(draft);
for (const url of blossomUrls) {
const res = await blossomService.deleteByUrl({ url, signer });
if (res.deleted?.length) {
devLogger.log(
`[edit] Deleted superseded Blossom blob ${res.sha256}.`
);
}
}
}
}
}
} catch (cleanupErr) {
devLogger.warn(
+235 -1
View File
@@ -7,6 +7,12 @@ import {
derivePublicBaseUrl,
} from "../../services/s3Service.js";
import { getActiveSigner } from "../../nostr/client.js";
import { FEATURE_BLOSSOM_STORAGE } from "../../constants.js";
import { BLOSSOM_PROVIDER } from "../../services/blossomService.js";
import {
importBlossomServerList,
publishBlossomServerList,
} from "./blossomServerList.js";
import { DEFAULT_NIP07_ENCRYPTION_METHODS } from "../../nostr/nip07Permissions.js";
import { storageSyncService } from "../../services/storageSyncService.js";
import { StorageCorsHelp } from "./storageCorsHelp.js";
@@ -97,6 +103,47 @@ export class ProfileStorageController {
this.storageSyncToggle = document.getElementById("storageSyncToggle") || null;
this.storageSyncRestoreBtn = document.getElementById("storageSyncRestoreBtn") || null;
this.storageSyncStatus = document.getElementById("storageSyncStatus") || null;
// Blossom (nostr-native) — keyless multi-server storage.
this.storageS3Fields = document.getElementById("storageS3Fields") || null;
this.storageBlossomSection =
document.getElementById("storageBlossomSection") || null;
this.storageBlossomServersInput =
document.getElementById("storageBlossomServers") || null;
this.storageBlossomImportBtn =
document.getElementById("storageBlossomImportBtn") || null;
this.storageBlossomPublishBtn =
document.getElementById("storageBlossomPublishBtn") || null;
this.storageBlossomServerListStatus =
document.getElementById("storageBlossomServerListStatus") || null;
this.storageProviderBlossomOption =
document.getElementById("storageProviderBlossomOption") || null;
// Reveal the Blossom provider option only when the deployment enables it.
if (
FEATURE_BLOSSOM_STORAGE === true &&
this.storageProviderBlossomOption instanceof HTMLElement
) {
this.storageProviderBlossomOption.classList.remove("hidden");
}
}
parseBlossomServers() {
const raw = this.storageBlossomServersInput?.value || "";
const out = [];
const seen = new Set();
for (const line of raw.split(/[\n,]/)) {
const url = line.trim().replace(/\/+$/, "");
if (!url || seen.has(url)) {
continue;
}
// Only accept absolute http(s) server URLs.
if (!/^https?:\/\//i.test(url)) {
continue;
}
seen.add(url);
out.push(url);
}
return out;
}
registerEventListeners() {
@@ -124,6 +171,18 @@ export class ProfileStorageController {
});
}
if (this.storageBlossomImportBtn instanceof HTMLElement) {
this.storageBlossomImportBtn.addEventListener("click", () => {
void this.handleImportBlossomServerList();
});
}
if (this.storageBlossomPublishBtn instanceof HTMLElement) {
this.storageBlossomPublishBtn.addEventListener("click", () => {
void this.handlePublishBlossomServerList();
});
}
if (this.storageProviderInput instanceof HTMLElement) {
this.storageProviderInput.addEventListener("change", () => {
void this.handleProviderChange();
@@ -336,7 +395,35 @@ export class ProfileStorageController {
this.mainController.getActivePubkey(),
);
const storageService = this.mainController.services?.storageService;
if (!pubkey || !storageService || !storageService.isUnlocked?.(pubkey)) {
if (!pubkey || !storageService) {
return;
}
const selectedProvider = this.storageProviderInput?.value || "cloudflare_r2";
// Blossom servers live in plaintext connection meta — load them without an
// unlock (there's no encrypted secret to decrypt).
if (selectedProvider === BLOSSOM_PROVIDER) {
try {
const connections = await storageService.listConnections(pubkey);
const match = findProviderConnection(connections, BLOSSOM_PROVIDER);
const servers = Array.isArray(match?.meta?.servers)
? match.meta.servers
: [];
if (this.storageBlossomServersInput instanceof HTMLElement) {
this.storageBlossomServersInput.value = servers.join("\n");
}
if (this.storageDefaultInput instanceof HTMLElement) {
this.storageDefaultInput.checked =
match?.meta?.defaultForUploads === true;
}
} catch (error) {
devLogger.warn("[ProfileModal] Failed to load Blossom servers:", error);
}
return;
}
if (!storageService.isUnlocked?.(pubkey)) {
return;
}
const provider = this.storageProviderInput?.value || "cloudflare_r2";
@@ -364,6 +451,21 @@ export class ProfileStorageController {
updateStorageFormVisibility() {
const provider = this.storageProviderInput?.value || "cloudflare_r2";
// Blossom has no bucket/keys/CORS — swap the whole S3 field group for the
// keyless multi-server section.
const isBlossom = provider === BLOSSOM_PROVIDER;
if (this.storageS3Fields instanceof HTMLElement) {
this.storageS3Fields.classList.toggle("hidden", isBlossom);
}
if (this.storageBlossomSection instanceof HTMLElement) {
this.storageBlossomSection.classList.toggle("hidden", !isBlossom);
}
if (isBlossom) {
this.corsHelp.setVisible(false);
return;
}
const isR2 = provider === "cloudflare_r2";
// B2 derives its endpoint from the region, so its raw Endpoint field is hidden.
const isB2 = provider === PROVIDERS.B2;
@@ -480,6 +582,50 @@ export class ProfileStorageController {
this.storageUnlockFailure = null;
}
setBlossomServerListStatus(message, kind = "info") {
if (!(this.storageBlossomServerListStatus instanceof HTMLElement)) return;
this.storageBlossomServerListStatus.textContent = message || "";
const tone =
kind === "error"
? "text-status-danger"
: kind === "success"
? "text-status-success"
: "text-muted";
this.storageBlossomServerListStatus.className = `text-xs ${tone}`;
}
blossomServerListArgs() {
return {
client: this.mainController.services?.nostrClient,
pubkey: this.mainController.normalizeHexPubkey(
this.mainController.getActivePubkey(),
),
};
}
async handleImportBlossomServerList() {
this.setBlossomServerListStatus("Fetching your server list…");
const res = await importBlossomServerList(this.blossomServerListArgs());
if (
res.servers?.length &&
this.storageBlossomServersInput instanceof HTMLElement
) {
this.storageBlossomServersInput.value = res.servers.join("\n");
}
if (res.error) devLogger.warn("[ProfileModal] Blossom import:", res.error);
this.setBlossomServerListStatus(res.message, res.kind);
}
async handlePublishBlossomServerList() {
this.setBlossomServerListStatus("Publishing your server list…");
const res = await publishBlossomServerList({
...this.blossomServerListArgs(),
servers: this.parseBlossomServers(),
});
if (res.error) devLogger.warn("[ProfileModal] Blossom publish:", res.error);
this.setBlossomServerListStatus(res.message, res.kind);
}
// After a page reload, a persisted nsec session restores the logged-in pubkey + UI
// but NOT the in-memory signer (the key is passphrase-encrypted). Storage unlock then
// has no usable signer, and the generic "No active signer" error is misleading.
@@ -710,6 +856,60 @@ export class ProfileStorageController {
if (!storageService) return;
const provider = this.storageProviderInput?.value || "cloudflare_r2";
// Blossom: keyless. Persist the server list in the (plaintext) connection meta;
// there is no secret to encrypt and no bucket/endpoint to validate.
if (provider === BLOSSOM_PROVIDER) {
const servers = this.parseBlossomServers();
if (servers.length === 0) {
this.setStorageFormStatus(
"Add at least one Blossom server URL (https://…).",
"error",
);
return;
}
const isDefaultBlossom = this.storageDefaultInput?.checked || false;
this.setStorageFormStatus("Saving...", "info");
try {
// Keyless: no secret to encrypt, so no storage unlock needed.
await storageService.saveKeylessConnection(pubkey, BLOSSOM_PROVIDER, {
provider: BLOSSOM_PROVIDER,
servers,
defaultForUploads: isDefaultBlossom,
label: `Blossom (${servers.length} server${servers.length === 1 ? "" : "s"})`,
});
this.setStorageFormStatus("Blossom servers saved.", "success");
this.mainController.showSuccess("Blossom storage saved.");
// Keep the encrypted synced copy current if the user opted in — the
// account record (which carries the Blossom servers in plaintext meta)
// is re-encrypted to self and published, so it roams like the S3 path.
if (storageSyncService.isEnabled(pubkey)) {
try {
const syncResult = await storageSyncService.push(pubkey, {
confirmOverwrite: () => confirmSyncOverwrite(STORAGE_SYNC_LABEL),
});
if (syncResult?.ok) {
this.setSyncStatus("Synced copy updated.", "success");
} else if (syncResult?.conflict) {
this.setSyncStatus(
"Synced copy on your account is newer — not overwritten.",
);
}
} catch (syncError) {
devLogger.warn(
"[ProfileModal] Blossom re-sync after save failed:",
syncError,
);
}
}
} catch (error) {
devLogger.error("Failed to save Blossom connection:", error);
this.setStorageFormStatus("Failed to save Blossom servers.", "error");
}
return;
}
let endpointOrAccount = this.storageEndpointInput?.value?.trim() || "";
const region = this.storageRegionInput?.value?.trim() || "auto";
const accessKeyId = this.storageAccessKeyInput?.value?.trim() || "";
@@ -860,6 +1060,40 @@ export class ProfileStorageController {
}
const provider = this.storageProviderInput?.value || "cloudflare_r2";
// Blossom: no credentials to test. Best-effort reachability ping of each
// server (CORS may block it — a failure here doesn't mean upload will fail,
// so it's informational). The real verification is an upload.
if (provider === BLOSSOM_PROVIDER) {
const servers = this.parseBlossomServers();
if (servers.length === 0) {
this.setStorageFormStatus(
"Add at least one Blossom server URL first.",
"error",
);
return;
}
this.setStorageFormStatus("Checking servers…", "info");
let reachable = 0;
await Promise.all(
servers.map(async (server) => {
try {
await fetch(server, { method: "HEAD", mode: "cors" });
reachable += 1;
} catch {
// CORS/offline — inconclusive, not necessarily a failure.
}
}),
);
this.setStorageFormStatus(
reachable > 0
? `Reached ${reachable}/${servers.length} server(s). Upload to fully verify.`
: `Couldn't reach the server(s) from the browser (CORS may block a HEAD). Save and try an upload to verify.`,
reachable > 0 ? "success" : "info",
);
return;
}
let endpointOrAccount = this.storageEndpointInput?.value?.trim() || "";
const region = this.storageRegionInput?.value?.trim() || "auto";
const accessKeyId = this.storageAccessKeyInput?.value?.trim() || "";
@@ -1,6 +1,13 @@
import { devLogger } from "../../utils/logger.js";
import { FEATURE_BITCOIN_CONNECT } from "../../constants.js";
import { showConfirm } from "../confirmDialog.js";
import { NWC_URI_SCHEME } from "../profileModalContract.js";
// Vendored, pinned Bitcoin Connect bundle (scripts/build-bitcoin-connect.mjs).
// Lazy-imported only when the user clicks "Connect wallet", so its ~700KB never
// weighs on the main bundle. See docs/bitcoin-connect-plan.md.
const BITCOIN_CONNECT_BUNDLE_URL =
"../../../vendor/bitcoin-connect.bundle.min.js";
import { createWalletSyncService } from "../../services/walletSyncService.js";
import { runWalletZappabilityCheck } from "./walletZappabilityCheck.js";
import {
@@ -32,6 +39,13 @@ export class ProfileWalletController {
this.walletSyncRestoreBtn = null;
this.walletSyncStatus = null;
this._walletSyncService = null;
this.walletBitcoinConnectRow = null;
this.walletBitcoinConnectButton = null;
this.walletAdvanced = null;
this.walletAdvancedSummary = null;
this._bitcoinConnectModulePromise = null;
this._bitcoinConnectInitialized = false;
}
cacheDomReferences() {
@@ -46,9 +60,40 @@ export class ProfileWalletController {
this.walletSyncToggle = document.getElementById("walletSyncToggle") || null;
this.walletSyncRestoreBtn = document.getElementById("walletSyncRestoreBtn") || null;
this.walletSyncStatus = document.getElementById("walletSyncStatus") || null;
this.walletBitcoinConnectRow =
document.getElementById("profileWalletBitcoinConnectRow") || null;
this.walletBitcoinConnectButton =
document.getElementById("profileWalletBitcoinConnect") || null;
this.walletAdvanced =
document.getElementById("profileWalletAdvanced") || null;
this.walletAdvancedSummary =
document.getElementById("profileWalletAdvancedSummary") || null;
// Backwards compatibility alias if needed by tests/external code
this.mainController.profileWalletStatusText = this.walletStatusText;
this.applyBitcoinConnectVisibility();
}
// Bitcoin Connect on ⇒ show the "Connect wallet" button and tuck the manual URI
// field under an "Advanced" disclosure (Connect is the primary path, manual the
// backup). Off ⇒ hide the button, and force the disclosure open with its summary
// hidden so the URI field just shows plainly (unchanged legacy behavior). Off
// also means the vendored bundle is never imported.
applyBitcoinConnectVisibility() {
const enabled = FEATURE_BITCOIN_CONNECT === true;
if (this.walletBitcoinConnectRow instanceof HTMLElement) {
this.walletBitcoinConnectRow.classList.toggle("hidden", !enabled);
}
if (this.walletAdvanced instanceof HTMLElement) {
// When disabled, keep the field visible (open) and drop the toggle chrome.
this.walletAdvanced.open = !enabled;
}
if (this.walletAdvancedSummary instanceof HTMLElement) {
this.walletAdvancedSummary.classList.toggle("hidden", !enabled);
}
}
registerEventListeners() {
@@ -90,6 +135,15 @@ export class ProfileWalletController {
});
}
if (
FEATURE_BITCOIN_CONNECT === true &&
this.walletBitcoinConnectButton instanceof HTMLElement
) {
this.walletBitcoinConnectButton.addEventListener("click", () => {
void this.handleBitcoinConnect();
});
}
if (this.walletSyncToggle instanceof HTMLElement) {
this.walletSyncToggle.addEventListener("change", () => {
void this.handleToggleSync();
@@ -521,6 +575,104 @@ export class ProfileWalletController {
return { valid: true, sanitized: value };
}
// Lazy-load + one-time init of the vendored Bitcoin Connect bundle. Restricts
// the modal to NWC and requests only the methods bitvid actually calls. Does
// NOT let Bitcoin Connect persist the connection — bitvid owns the secret.
async ensureBitcoinConnectModule() {
if (this._bitcoinConnectModulePromise) {
return this._bitcoinConnectModulePromise;
}
this._bitcoinConnectModulePromise = (async () => {
const bc = await import(BITCOIN_CONNECT_BUNDLE_URL);
if (!this._bitcoinConnectInitialized) {
bc.init({
appName: "bitvid",
filters: ["nwc"],
persistConnection: false,
providerConfig: {
nwc: {
authorizationUrlOptions: {
requestMethods: [
"pay_invoice",
"get_balance",
"make_invoice",
"lookup_invoice",
],
},
},
},
});
// One persistent handler: extract the NWC URI, hand it to the SAME
// validate→save path as the manual field, then disconnect so bitvid is
// the only holder of the spending secret. NEVER log the URI.
bc.onConnected(async (provider) => {
const uri =
typeof provider?.client?.nostrWalletConnectUrl === "string"
? provider.client.nostrWalletConnectUrl
: "";
try {
if (uri && this.walletUriInput instanceof HTMLElement) {
this.setSecretInputValue(this.walletUriInput, uri);
await this.handleWalletSave();
} else if (!uri) {
this.updateWalletStatus(
"That wallet didn't expose an NWC connection. Paste one manually below.",
"error",
);
}
} catch (error) {
devLogger.error("[wallet] Bitcoin Connect save failed", error);
} finally {
try {
bc.disconnect();
} catch (disconnectError) {
devLogger.warn(
"[wallet] Bitcoin Connect disconnect failed",
disconnectError,
);
}
try {
bc.closeModal?.();
} catch (closeError) {
// non-fatal
}
}
});
this._bitcoinConnectInitialized = true;
}
return bc;
})().catch((error) => {
// Reset so a later click can retry a failed load.
this._bitcoinConnectModulePromise = null;
throw error;
});
return this._bitcoinConnectModulePromise;
}
async handleBitcoinConnect() {
if (FEATURE_BITCOIN_CONNECT !== true || this.isWalletBusy()) {
return;
}
let bc;
try {
bc = await this.ensureBitcoinConnectModule();
} catch (error) {
devLogger.error("[wallet] Failed to load Bitcoin Connect", error);
this.updateWalletStatus(
"Couldn't load the wallet connector. Try again, or paste a connection manually.",
"error",
);
this.mainController.showError?.("Couldn't load the wallet connector.");
return;
}
try {
bc.launchModal();
} catch (error) {
devLogger.error("[wallet] Bitcoin Connect launch failed", error);
this.updateWalletStatus("Couldn't open the wallet connector.", "error");
}
}
async handleWalletSave() {
const { uri, defaultZap, error } = this.getWalletFormValues();
const context = {
+79
View File
@@ -0,0 +1,79 @@
// BUD-03 (kind 10063) Blossom server-list import/publish — the relay read/write
// logic, extracted from ProfileStorageController so it stays thin and this is
// unit-testable. Each function returns a { kind, message, ... } result the UI can
// render directly (never throws). See docs/blossom-plan.md (Decision 3).
import { serversFromServerListEvent } from "../../services/blossomService.js";
import {
buildBlossomServerListEvent,
BLOSSOM_SERVER_LIST_KIND,
} from "../../nostrEventSchemas.js";
function readRelaysOf(client) {
if (Array.isArray(client?.readRelays) && client.readRelays.length) {
return client.readRelays;
}
return Array.isArray(client?.relays) ? client.relays : [];
}
/**
* Fetch the newest kind-10063 server list published by `pubkey` and return its
* servers. Does NOT persist — the caller drops them into the form for Save.
*
* @returns {Promise<{kind:"success"|"error", message:string, servers?:string[], error?:unknown}>}
*/
export async function importBlossomServerList({ client, pubkey } = {}) {
if (!client || typeof client.getSubscriptionManager !== "function" || !pubkey) {
return { kind: "error", message: "Log in to import your server list." };
}
try {
const events = await client.getSubscriptionManager().list({
filters: [{ kinds: [BLOSSOM_SERVER_LIST_KIND], authors: [pubkey] }],
relays: readRelaysOf(client),
});
const newest = (Array.isArray(events) ? events : [])
.filter((e) => e && Array.isArray(e.tags))
.sort((a, b) => (b.created_at || 0) - (a.created_at || 0))[0];
const servers = newest ? serversFromServerListEvent(newest) : [];
if (!servers.length) {
return {
kind: "error",
message: "No published server list found for your account.",
};
}
return {
kind: "success",
message: `Imported ${servers.length} server(s). Click Save to use them.`,
servers,
};
} catch (error) {
return { kind: "error", message: "Import failed. Please try again.", error };
}
}
/**
* Build + sign + publish the given servers as the user's kind-10063 list.
*
* @returns {Promise<{kind:"success"|"error", message:string, error?:unknown}>}
*/
export async function publishBlossomServerList({ client, pubkey, servers } = {}) {
if (!client || typeof client.signAndPublishEvent !== "function" || !pubkey) {
return { kind: "error", message: "Log in to publish your server list." };
}
if (!Array.isArray(servers) || servers.length === 0) {
return { kind: "error", message: "Add at least one server first." };
}
try {
const event = buildBlossomServerListEvent({
pubkey,
created_at: Math.floor(Date.now() / 1000),
servers,
});
await client.signAndPublishEvent(event);
return {
kind: "success",
message: `Published ${servers.length} server(s) to your Nostr relays.`,
};
} catch (error) {
return { kind: "error", message: "Publish failed. Please try again.", error };
}
}
+6 -1
View File
@@ -86,7 +86,12 @@ export function fillStorageForm(c, conn) {
} = conn.meta || {};
if (c.storageProviderInput) {
c.storageProviderInput.value = provider || "cloudflare_r2";
// The provider lives at the top level for legacy connections but only in
// `meta` for the decrypted/keyless shape returned by getConnection — mirror
// connectionProvider()'s fallback so the selector reflects the loaded (default)
// connection instead of silently reverting to Cloudflare.
c.storageProviderInput.value =
provider || conn.meta?.provider || "cloudflare_r2";
}
const resolvedEndpoint =
endpoint || accountId || payloadAccountId || payloadEndpoint || "";
+23 -3
View File
@@ -941,7 +941,17 @@ export class TorrentClient {
// Cap peer connections for the streaming torrent too. 30 is plenty to
// saturate playback bandwidth while avoiding the WebRTC-handshake storm
// that can peg the main thread (WebTorrent's default is 55).
const chromeOptions = { strategy: "sequential", maxConns: 30 };
// Announce to the WSS trackers explicitly. The magnet path already carries
// trackers (playbackUtils augments it), but a .torrent-buffer add
// (torrentFileBytes, from a Blossom video's companion event) has an EMPTY
// embedded announce list — without this it would reach no tracker, discover
// no WebRTC peers, and stream from the webseed only (looks like "1 peer").
// WebTorrent merges this with any trackers already on the source, deduped.
const chromeOptions = {
strategy: "sequential",
maxConns: 30,
announce: [...WSS_TRACKERS],
};
/**
* CRITICAL: Passing `urlList` ensures the client can stream from the webseed.
* Without this, videos with 0 P2P peers will fail to play even if a valid
@@ -951,12 +961,22 @@ export class TorrentClient {
chromeOptions.urlList = candidateUrls;
}
// When the caller supplies verified .torrent bytes (the piece-map from a
// Blossom video's companion event — already infohash-checked upstream), add
// that buffer directly so WebTorrent has metadata without a peer or xs=.
// The trusted webseed still rides along via chromeOptions.urlList.
const torrentSource =
opts?.torrentFileBytes instanceof Uint8Array &&
opts.torrentFileBytes.length
? opts.torrentFileBytes
: magnetURI;
return new Promise((resolve, reject) => {
// 3) Add the torrent to the client and handle accordingly.
if (isFirefoxBrowser) {
this.log("Starting torrent download (Firefox path)");
this.client.add(
magnetURI,
torrentSource,
{ ...chromeOptions, maxWebConns: 4 },
(torrent) => {
this.log("Torrent added (Firefox path):", torrent.name);
@@ -972,7 +992,7 @@ export class TorrentClient {
);
} else {
this.log("Starting torrent download (Chrome path)");
this.client.add(magnetURI, chromeOptions, (torrent) => {
this.client.add(torrentSource, chromeOptions, (torrent) => {
this.log("Torrent added (Chrome path):", torrent.name);
this.handleTorrentStream(
torrent,
+151 -3
View File
@@ -20,7 +20,9 @@
"ws": "^8.19.0"
},
"devDependencies": {
"@getalby/bitcoin-connect": "3.12.3",
"@playwright/test": "^1.58.2",
"blossom-client-sdk": "5.0.0",
"cssnano": "^7.1.2",
"fake-indexeddb": "^6.2.5",
"jsdom": "^28.1.0",
@@ -832,6 +834,52 @@
"integrity": "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==",
"license": "MIT"
},
"node_modules/@getalby/bitcoin-connect": {
"version": "3.12.3",
"resolved": "https://registry.npmjs.org/@getalby/bitcoin-connect/-/bitcoin-connect-3.12.3.tgz",
"integrity": "sha512-2d+tNHBCZBoImKJgZwTUOyK9ypXaTg/hwvtQ8EHbgSKKkuycOrks7SzlUc0iJHthD3JGFVMWZEgOgw4AQVVE4w==",
"dev": true,
"license": "MIT",
"dependencies": {
"@getalby/lightning-tools": "^8.1.1",
"@getalby/sdk": "^8.0.3",
"@lightninglabs/lnc-web": "^0.3.6-alpha",
"qrcode-generator": "1.4.4",
"zustand": "^5.0.13"
}
},
"node_modules/@getalby/lightning-tools": {
"version": "8.2.0",
"resolved": "https://registry.npmjs.org/@getalby/lightning-tools/-/lightning-tools-8.2.0.tgz",
"integrity": "sha512-eL+cnHyzeUARKVzNRuFBRBppAU5RBJOLjhFVzSWt8hDibRzL5+e4hq8I79+RGHfrWWMUDv382Jx8ewOazrBj7w==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=14"
},
"funding": {
"type": "lightning",
"url": "lightning:[email protected]"
}
},
"node_modules/@getalby/sdk": {
"version": "8.0.3",
"resolved": "https://registry.npmjs.org/@getalby/sdk/-/sdk-8.0.3.tgz",
"integrity": "sha512-vPEogAWwLHbL55COeXrRN7yRBXMDGDxX1M2A+o3Lqw60FFng6fa9FK9sw8ptdILMD1dQZq8FzPXuabQ7seoDBA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@getalby/lightning-tools": "^8.1.1",
"nostr-tools": "^2.23.3"
},
"engines": {
"node": ">=22"
},
"funding": {
"type": "lightning",
"url": "lightning:[email protected]"
}
},
"node_modules/@jridgewell/gen-mapping": {
"version": "0.3.13",
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
@@ -891,6 +939,24 @@
"dev": true,
"license": "MIT"
},
"node_modules/@lightninglabs/lnc-core": {
"version": "0.3.6-alpha",
"resolved": "https://registry.npmjs.org/@lightninglabs/lnc-core/-/lnc-core-0.3.6-alpha.tgz",
"integrity": "sha512-ObzY5wi+PS0GQduFHYDjGJpYZxKD27vtMf3fUVne2gGNMej1mvvJAOwATgef8YV/IZodAzy73Z/F2zfaQ+iE4Q==",
"dev": true,
"license": "MIT"
},
"node_modules/@lightninglabs/lnc-web": {
"version": "0.3.6-alpha",
"resolved": "https://registry.npmjs.org/@lightninglabs/lnc-web/-/lnc-web-0.3.6-alpha.tgz",
"integrity": "sha512-oiL1mKlbEjb1lcmNAHA0Gn+mlx5oQOJ8YVONJR/vgo4QLmL/48EyA12QbZ71mP9li45hGOoGqpYMRwBYtIeFog==",
"dev": true,
"license": "MIT",
"dependencies": {
"@lightninglabs/lnc-core": "^0.3.6-alpha",
"crypto-js": "4.2.0"
}
},
"node_modules/@noble/ciphers": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-2.1.1.tgz",
@@ -1797,6 +1863,44 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/blossom-client-sdk": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/blossom-client-sdk/-/blossom-client-sdk-5.0.0.tgz",
"integrity": "sha512-xz7w6eBQv6uiNK9ogh5AymnzCAohiU029XySpKxJyG7RsDHkoru+xoZhY55tiIOyWFX/5s8pip4hVh5hO1PN/Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"@noble/hashes": "^1.8.0"
},
"engines": {
"node": ">=18"
},
"peerDependencies": {
"@cashu/cashu-ts": "^2.4.3",
"hls.js": "^1.6.16"
},
"peerDependenciesMeta": {
"@cashu/cashu-ts": {
"optional": true
},
"hls.js": {
"optional": true
}
}
},
"node_modules/blossom-client-sdk/node_modules/@noble/hashes": {
"version": "1.8.0",
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz",
"integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==",
"dev": true,
"license": "MIT",
"engines": {
"node": "^14.21.3 || >=16"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/boolbase": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz",
@@ -2321,6 +2425,13 @@
"node": ">= 8"
}
},
"node_modules/crypto-js": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/crypto-js/-/crypto-js-4.2.0.tgz",
"integrity": "sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q==",
"dev": true,
"license": "MIT"
},
"node_modules/css-declaration-sorter": {
"version": "7.3.1",
"resolved": "https://registry.npmjs.org/css-declaration-sorter/-/css-declaration-sorter-7.3.1.tgz",
@@ -3789,9 +3900,9 @@
}
},
"node_modules/nostr-tools": {
"version": "2.23.1",
"resolved": "https://registry.npmjs.org/nostr-tools/-/nostr-tools-2.23.1.tgz",
"integrity": "sha512-Q5SJ1omrseBFXtLwqDhufpFLA6vX3rS/IuBCc974qaYX6YKGwEPxa/ZsyxruUOr+b+5EpWL2hFmCB5AueYrfBw==",
"version": "2.23.9",
"resolved": "https://registry.npmjs.org/nostr-tools/-/nostr-tools-2.23.9.tgz",
"integrity": "sha512-PBN3TpGh+EszlTZWpQdvcqEMqhcKcY1vTw0Xkkccg+TyP7y/icu9/iXJw72aFZ/ETm35ehU0hneGTXLaTSkImw==",
"license": "Unlicense",
"dependencies": {
"@noble/ciphers": "2.1.1",
@@ -5046,6 +5157,13 @@
"node": ">=20"
}
},
"node_modules/qrcode-generator": {
"version": "1.4.4",
"resolved": "https://registry.npmjs.org/qrcode-generator/-/qrcode-generator-1.4.4.tgz",
"integrity": "sha512-HM7yY8O2ilqhmULxGMpcHSF1EhJJ9yBj8gvDEuZ6M+KGJ0YY2hKpnXvRD+hZPLrDVck3ExIGhmPtSdcjC+guuw==",
"dev": true,
"license": "MIT"
},
"node_modules/queue-microtask": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
@@ -6738,6 +6856,36 @@
"engines": {
"node": ">=8"
}
},
"node_modules/zustand": {
"version": "5.0.14",
"resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.14.tgz",
"integrity": "sha512-/8tAspM5LMPr28b3fwLYrtdj77ECpfZviaP75CMTnwO8ISyaE4GDIG/9rDDYq/cH9D2Xw2A2RXglLInmVBQB/g==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12.20.0"
},
"peerDependencies": {
"@types/react": ">=18.0.0",
"immer": ">=9.0.6",
"react": ">=18.0.0",
"use-sync-external-store": ">=1.2.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"immer": {
"optional": true
},
"react": {
"optional": true
},
"use-sync-external-store": {
"optional": true
}
}
}
}
}
+4
View File
@@ -14,6 +14,8 @@
"build:css": "postcss css/tailwind.source.css -o css/tailwind.generated.css",
"build:beacon:bundle": "node scripts/build-beacon.mjs",
"build:beacon": "npm run build:beacon:bundle && npm run lint:inline-styles",
"build:bitcoin-connect": "node scripts/build-bitcoin-connect.mjs",
"build:blossom-sdk": "node scripts/build-blossom-sdk.mjs",
"ci:beacon": "npm run build:beacon",
"format": "prettier --cache --write css/tailwind.source.css css/tokens.css css/docs.css \"docs/**/*.html\" tailwind.config.cjs README.md CONTRIBUTING.md",
"audit": "node scripts/daily-design-system-audit.mjs",
@@ -57,7 +59,9 @@
"lint:pool-access": "node scripts/check-direct-pool-access.mjs"
},
"devDependencies": {
"@getalby/bitcoin-connect": "3.12.3",
"@playwright/test": "^1.58.2",
"blossom-client-sdk": "5.0.0",
"cssnano": "^7.1.2",
"fake-indexeddb": "^6.2.5",
"jsdom": "^28.1.0",
+51
View File
@@ -0,0 +1,51 @@
// Bundles Alby's Bitcoin Connect into a single self-contained ESM file that
// bitvid lazy-imports behind FEATURE_BITCOIN_CONNECT (docs/bitcoin-connect-plan.md).
// Mirrors the floating-ui vendor step in build-beacon.mjs. Pinned via the exact
// devDependency version in package.json.
import { build } from "esbuild";
import { mkdir, copyFile } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const entryPoint = path.join(repoRoot, "scripts/vendor/bitcoin-connect-entry.mjs");
const vendorDir = path.join(repoRoot, "vendor");
const outFile = path.join(vendorDir, "bitcoin-connect.bundle.min.js");
const licenseSrc = path.join(
repoRoot,
"node_modules/@getalby/bitcoin-connect/LICENSE",
);
const licenseOut = path.join(vendorDir, "bitcoin-connect.LICENSE");
await mkdir(vendorDir, { recursive: true });
await build({
bundle: true,
entryPoints: [entryPoint],
outfile: outFile,
format: "esm",
splitting: false,
platform: "browser",
target: ["es2020"],
minify: true,
sourcemap: false,
define: {
"process.env.NODE_ENV": '"production"',
global: "globalThis",
},
// @getalby/lightning-tools lazily `import('crypto')` for L402 macaroons — a
// path bitvid never calls. Alias it to a browser shim so the bundle resolves.
alias: {
crypto: path.join(repoRoot, "scripts/vendor/crypto-shim.mjs"),
"node:crypto": path.join(repoRoot, "scripts/vendor/crypto-shim.mjs"),
},
logLevel: "info",
});
try {
await copyFile(licenseSrc, licenseOut);
} catch (error) {
console.warn("[build-bitcoin-connect] could not copy LICENSE:", error.message);
}
console.log("[build-bitcoin-connect] wrote", path.relative(repoRoot, outFile));
+51
View File
@@ -0,0 +1,51 @@
// Bundles blossom-client-sdk into a single self-contained ESM file that bitvid
// lazy-imports behind FEATURE_BLOSSOM_STORAGE (docs/blossom-plan.md, TODO #30).
// Mirrors the bitcoin-connect / floating-ui vendor steps. Pinned via the exact
// devDependency version in package.json.
import { build } from "esbuild";
import { mkdir, copyFile } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const entryPoint = path.join(repoRoot, "scripts/vendor/blossom-sdk-entry.mjs");
const vendorDir = path.join(repoRoot, "vendor");
const outFile = path.join(vendorDir, "blossom-client-sdk.bundle.min.js");
const licenseSrc = path.join(
repoRoot,
"node_modules/blossom-client-sdk/LICENSE.txt",
);
const licenseOut = path.join(vendorDir, "blossom-client-sdk.LICENSE");
await mkdir(vendorDir, { recursive: true });
await build({
bundle: true,
entryPoints: [entryPoint],
outfile: outFile,
format: "esm",
splitting: false,
platform: "browser",
target: ["es2020"],
minify: true,
sourcemap: false,
define: {
"process.env.NODE_ENV": '"production"',
global: "globalThis",
},
// @cashu/cashu-ts is an OPTIONAL peer dep the SDK only lazy-imports on the
// BUD-07 payment branch (HTTP 402). bitvid uses free Blossom servers, so alias
// it to a throwing stub instead of pulling the dep.
alias: {
"@cashu/cashu-ts": path.join(repoRoot, "scripts/vendor/cashu-stub.mjs"),
},
logLevel: "info",
});
try {
await copyFile(licenseSrc, licenseOut);
} catch (error) {
console.warn("[build-blossom-sdk] could not copy LICENSE:", error.message);
}
console.log("[build-blossom-sdk] wrote", path.relative(repoRoot, outFile));
+1 -1
View File
@@ -57,7 +57,7 @@ const GRANDFATHERED = {
"js/app/playbackCoordinator.js": 1376,
"js/feedEngine/stages.js": 1370,
"js/ui/views/VideoListView.js": 1359,
"js/ui/components/UploadModal.js": 1319,
"js/ui/components/UploadModal.js": 1400,
"js/ui/components/SimilarContentCard.js": 1303,
"js/services/playbackService.js": 1274,
"js/services/authService.js": 1335,
+17
View File
@@ -0,0 +1,17 @@
// Vendor entry for Alby's Bitcoin Connect (getalby/bitcoin-connect), bundled by
// scripts/build-bitcoin-connect.mjs into vendor/bitcoin-connect.bundle.min.js so
// bitvid can lazy-import it behind FEATURE_BITCOIN_CONNECT without a runtime CDN.
//
// Importing the package self-registers its web components (bc-modal, etc.), so a
// single import both loads the API and boots the UI. We re-export only the small
// surface ProfileWalletController needs. See docs/bitcoin-connect-plan.md.
export {
init,
launchModal,
closeModal,
onConnected,
onDisconnected,
disconnect,
getConnectorConfig,
isConnected,
} from "@getalby/bitcoin-connect";
+26
View File
@@ -0,0 +1,26 @@
// Vendor entry for blossom-client-sdk (hzrd149), bundled by
// scripts/build-blossom-sdk.mjs into vendor/blossom-client-sdk.bundle.min.js so
// bitvid can lazy-import it behind FEATURE_BLOSSOM_STORAGE without a runtime CDN.
// Re-exports only the surface bitvid needs. See docs/blossom-plan.md.
//
// The action helpers live under the `./actions/*` subpaths (the root entry
// namespaces them as `Actions`); auth + discovery are top-level modules.
export {
createUploadAuth,
createMirrorAuth,
createListAuth,
createDeleteAuth,
encodeAuthorizationHeader,
} from "blossom-client-sdk/auth";
export {
getServersFromServerListEvent,
USER_BLOSSOM_SERVER_LIST_KIND,
} from "blossom-client-sdk/nostr";
export {
multiServerUpload,
multiServerMediaUpload,
} from "blossom-client-sdk/actions/multi-server";
export { uploadBlob } from "blossom-client-sdk/actions/upload";
export { mirrorBlob } from "blossom-client-sdk/actions/mirror";
export { listBlobs, iterateBlobs } from "blossom-client-sdk/actions/list";
export { deleteBlob } from "blossom-client-sdk/actions/delete";
+16
View File
@@ -0,0 +1,16 @@
// Stub for the optional @cashu/cashu-ts peer dep, aliased in when bundling
// blossom-client-sdk (scripts/build-blossom-sdk.mjs). The SDK only reaches for it
// on the BUD-07 cashu-payment branch (a lazy `await import()` triggered by an HTTP
// 402). bitvid targets free Blossom servers, so this never runs; the stubs throw
// loudly if a payment-gated server is ever hit rather than silently misbehaving.
function unsupported() {
throw new Error(
"Cashu payments (BUD-07) are not supported in the bitvid Blossom bundle",
);
}
export const getEncodedToken = unsupported;
export const decodePaymentRequest = unsupported;
export const getDecodedToken = unsupported;
export default {};
+26
View File
@@ -0,0 +1,26 @@
// Minimal browser shim for node's `crypto`, aliased in when bundling Bitcoin
// Connect (scripts/build-bitcoin-connect.mjs). The only place its deps reach for
// `crypto` is @getalby/lightning-tools' L402 macaroon helpers via a lazy
// `await import('crypto')` — a path bitvid's NWC connect/pay flow never calls.
// timingSafeEqual is implemented properly; the L402-only createHmac is stubbed so
// it fails loudly rather than silently misbehaving if ever reached.
export function timingSafeEqual(a, b) {
const ua = a instanceof Uint8Array ? a : new Uint8Array(a);
const ub = b instanceof Uint8Array ? b : new Uint8Array(b);
if (ua.length !== ub.length) {
return false;
}
let diff = 0;
for (let i = 0; i < ua.length; i += 1) {
diff |= ua[i] ^ ub[i];
}
return diff === 0;
}
export function createHmac() {
throw new Error(
"createHmac (L402 macaroons) is not available in the bitvid browser bundle",
);
}
export default { timingSafeEqual, createHmac };
+111
View File
@@ -0,0 +1,111 @@
// BUD-03 kind-10063 server-list import/publish helpers (extracted from the storage
// pane). Every path returns a { kind, message, ... } result — never throws — so the
// UI can render it directly. See docs/blossom-plan.md (Decision 3).
import test from "node:test";
import assert from "node:assert/strict";
import {
importBlossomServerList,
publishBlossomServerList,
} from "../js/ui/profileModal/blossomServerList.js";
const PK = "a".repeat(64);
const listEvent = (servers, created_at = 1000) => ({
kind: 10063,
created_at,
tags: servers.map((s) => ["server", s]),
});
test("importBlossomServerList returns the NEWEST list's servers, author-scoped", async () => {
const client = {
readRelays: ["wss://r"],
getSubscriptionManager: () => ({
list: async ({ filters, relays }) => {
assert.deepEqual(filters[0].kinds, [10063]);
assert.deepEqual(filters[0].authors, [PK]);
assert.deepEqual(relays, ["wss://r"]);
return [listEvent(["https://old"], 100), listEvent(["https://a", "https://b"], 999)];
},
}),
};
const res = await importBlossomServerList({ client, pubkey: PK });
assert.equal(res.kind, "success");
assert.deepEqual(res.servers, ["https://a", "https://b"]);
});
test("importBlossomServerList: nothing published → error result (no throw)", async () => {
const client = { getSubscriptionManager: () => ({ list: async () => [] }) };
const res = await importBlossomServerList({ client, pubkey: PK });
assert.equal(res.kind, "error");
assert.match(res.message, /No published server list/);
});
test("importBlossomServerList: not logged in → error", async () => {
const res = await importBlossomServerList({ client: null, pubkey: "" });
assert.equal(res.kind, "error");
assert.match(res.message, /Log in/);
});
test("importBlossomServerList: relay error is caught, returned as an error result", async () => {
const client = {
getSubscriptionManager: () => ({
list: async () => {
throw new Error("relay down");
},
}),
};
const res = await importBlossomServerList({ client, pubkey: PK });
assert.equal(res.kind, "error");
assert.match(res.message, /Import failed/);
assert.equal(res.error.message, "relay down");
});
test("publishBlossomServerList signs + publishes a kind-10063 event with server tags", async () => {
let published = null;
const client = {
signAndPublishEvent: async (ev) => {
published = ev;
return { id: "x" };
},
};
const res = await publishBlossomServerList({
client,
pubkey: PK,
servers: ["https://a", "https://b"],
});
assert.equal(res.kind, "success");
assert.equal(published.kind, 10063);
assert.equal(published.pubkey, PK);
assert.deepEqual(
published.tags.filter((t) => t[0] === "server"),
[["server", "https://a"], ["server", "https://b"]],
);
});
test("publishBlossomServerList: no servers → error (nothing published)", async () => {
let called = false;
const client = {
signAndPublishEvent: async () => {
called = true;
},
};
const res = await publishBlossomServerList({ client, pubkey: PK, servers: [] });
assert.equal(res.kind, "error");
assert.match(res.message, /at least one server/);
assert.equal(called, false);
});
test("publishBlossomServerList: publish failure → error result (no throw)", async () => {
const client = {
signAndPublishEvent: async () => {
throw new Error("nope");
},
};
const res = await publishBlossomServerList({
client,
pubkey: PK,
servers: ["https://a"],
});
assert.equal(res.kind, "error");
assert.match(res.message, /Publish failed/);
});
+495
View File
@@ -0,0 +1,495 @@
// blossomService tests (Node-safe — no WebTorrent). Covers the availability gate,
// server-list resolution, input guards, and the uploadVideo orchestration (blob +
// injected torrent → magnet + storagePointer). The vendored SDK is mocked via
// loadSdk; real-server round-trips are exercised in-browser. See docs/blossom-plan.md.
import test from "node:test";
import assert from "node:assert/strict";
import blossomService, {
BlossomService,
BLOSSOM_PROVIDER,
isBlossomProvider,
resolveBlossomServers,
blobSha256FromUrl,
serversFromServerListEvent,
} from "../js/services/blossomService.js";
import { FEATURE_BLOSSOM_STORAGE } from "../js/constants.js";
const SHA = "a".repeat(64);
const signer = async (draft) => ({ ...draft, id: "id", sig: "sig", pubkey: "pk" });
// A BlossomService whose SDK is mocked: uploadBlob uploads directly to each
// server (BUD-02 /upload) and returns a descriptor, echoing the blob so we can
// assert which servers were hit. `failServers` simulates per-server failures.
function mockService({ failServers = [] } = {}) {
const uploads = [];
const svc = new BlossomService();
svc.loadSdk = async () => ({
createUploadAuth: async (s, sha256, opts) => ({ kind: 24242, sha256, opts }),
uploadBlob: async (server, file, opts) => {
// Give each blob a distinct sha256 so video vs .torrent URLs differ.
const sha = file?.name?.includes(".torrent") ? "b".repeat(64) : SHA;
if (typeof opts?.onAuth === "function") {
await opts.onAuth(server, sha, "upload", file);
}
uploads.push({ server, file });
if (failServers.includes(server)) {
throw new Error("CORS blocked");
}
return { url: `${server}/${sha}`, sha256: sha, size: 1, type: "x" };
},
});
return { svc, uploads };
}
test("isBlossomProvider / BLOSSOM_PROVIDER only match 'blossom'", () => {
assert.equal(BLOSSOM_PROVIDER, "blossom");
assert.equal(isBlossomProvider("blossom"), true);
assert.equal(isBlossomProvider("cloudflare_r2"), false);
});
test("isAvailable reflects the FEATURE_BLOSSOM_STORAGE flag", () => {
assert.equal(blossomService.isAvailable(), FEATURE_BLOSSOM_STORAGE === true);
});
test("resolveBlossomServers de-dupes, trims, and reads meta.servers", () => {
assert.deepEqual(
resolveBlossomServers({ servers: [" https://a ", "https://a", "https://b", "", " "] }),
["https://a", "https://b"],
);
assert.deepEqual(resolveBlossomServers({ meta: { servers: ["https://c"] } }), ["https://c"]);
assert.deepEqual(resolveBlossomServers({}), []);
});
test("uploadFile guards: file, servers, signer required", async () => {
const { svc } = mockService();
await assert.rejects(() => svc.uploadFile({ servers: ["https://s"], signer }), /requires a file/);
await assert.rejects(
() => svc.uploadFile({ file: new Blob(["x"]), servers: [], signer }),
/at least one server/,
);
await assert.rejects(
() => svc.uploadFile({ file: new Blob(["x"]), servers: ["https://s"], signer: null }),
/requires a signer/,
);
});
test("uploadFile uploads directly to every server and returns the primary url", async () => {
const { svc, uploads } = mockService();
const out = await svc.uploadFile({
file: new File([new Uint8Array([1])], "v.mp4"),
servers: ["https://a", "https://b"],
signer,
});
assert.equal(out.url, `https://a/${SHA}`);
assert.equal(out.key, SHA);
assert.deepEqual(
uploads.map((u) => u.server).sort(),
["https://a", "https://b"],
"uploaded to both servers directly (no /mirror)",
);
});
test("uploadFile survives partial failure — one server down, another succeeds", async () => {
const { svc } = mockService({ failServers: ["https://a"] });
const out = await svc.uploadFile({
file: new File([new Uint8Array([1])], "v.mp4"),
servers: ["https://a", "https://b"],
signer,
});
assert.equal(out.url, `https://b/${SHA}`, "falls back to the working server");
});
test("uploadFile throws a descriptive error when ALL servers fail", async () => {
const { svc } = mockService({ failServers: ["https://a", "https://b"] });
await assert.rejects(
() =>
svc.uploadFile({
file: new File([new Uint8Array([1])], "v.mp4"),
servers: ["https://a", "https://b"],
signer,
}),
/failed on all server\(s\).*CORS blocked/s,
);
});
test("uploadFile declares a Content-Type when the File has none (avoids 415)", async () => {
// Browsers leave File.type empty for containers like .m4v; the SDK then PUTs a
// typeless body and strict servers answer 415. uploadFile must re-type the blob.
const { svc, uploads } = mockService();
const typeless = new File([new Uint8Array([1])], "clip.m4v");
assert.equal(typeless.type, "", "precondition: the File has no MIME type");
await svc.uploadFile({ file: typeless, servers: ["https://a"], signer });
assert.equal(
uploads[0].file.type,
"video/mp4",
"the uploaded blob was re-typed from the .m4v extension",
);
});
test("uploadFile overrides a generic application/octet-stream type from the extension", async () => {
// Files produced by other apps or fetched via WebTorrent often carry a generic
// "application/octet-stream" type; strict servers reject that with 415/400 even
// though they accept the real type. A recognized extension must win.
const { svc, uploads } = mockService();
const generic = new File([new Uint8Array([1])], "Fire Pit Render.mp4", {
type: "application/octet-stream",
});
await svc.uploadFile({ file: generic, servers: ["https://a"], signer });
assert.equal(
uploads[0].file.type,
"video/mp4",
"generic octet-stream is replaced by video/mp4 from the .mp4 extension",
);
});
test("uploadFile keeps a specific, correct browser type untouched", async () => {
const { svc, uploads } = mockService();
const good = new File([new Uint8Array([1])], "clip.mkv", { type: "video/webm" });
await svc.uploadFile({ file: good, servers: ["https://a"], signer });
// A specific (non-generic) browser type is trusted even if it differs from the
// extension's inferred type — we don't second-guess a real declared type.
assert.equal(uploads[0].file.type, "video/webm");
});
test("uploadFile surfaces the server HTTP status in the all-fail error", async () => {
// Simulate a Blossom server rejecting the media type (415). The SDK's HTTPError
// exposes `.status`; the thrown message must include it, not just a vague reason.
const svc = new BlossomService();
svc.loadSdk = async () => ({
createUploadAuth: async () => ({ kind: 24242 }),
uploadBlob: async () => {
const err = new Error("Something went wrong");
err.status = 415;
throw err;
},
});
await assert.rejects(
() =>
svc.uploadFile({
file: new File([new Uint8Array([1])], "v.mp4"),
servers: ["https://a"],
signer,
}),
/HTTP 415: Something went wrong/,
);
});
test("uploadVideo without a torrent returns a blossom storagePointer and no magnet", async () => {
const { svc } = mockService();
const result = await svc.uploadVideo({
file: new File([new Uint8Array([1, 2])], "clip.mp4"),
servers: ["https://a"],
signer,
generateTorrent: async () => ({ hasValidInfoHash: false, torrentFile: null }),
});
assert.equal(result.url, `https://a/${SHA}`);
assert.equal(result.key, SHA);
assert.match(result.storagePointer, /blossom/);
assert.equal(result.magnet, "");
assert.equal(result.hasValidInfoHash, false);
});
test("uploadVideo with a torrent uploads the .torrent as a blob and builds a magnet (ws + xs)", async () => {
const { svc, uploads } = mockService();
const infoHash = "0".repeat(40);
const result = await svc.uploadVideo({
file: new File([new Uint8Array([1, 2])], "clip.mp4"),
servers: ["https://a"],
signer,
generateTorrent: async ({ videoPublicUrl }) => {
assert.equal(videoPublicUrl, `https://a/${SHA}`, "ws is the blossom video url");
return {
hasValidInfoHash: true,
infoHash,
torrentFile: new File([new Uint8Array([9])], "clip.torrent"),
};
},
});
assert.equal(uploads.length, 2, "video blob + .torrent blob");
assert.equal(result.hasValidInfoHash, true);
assert.equal(result.infoHash, infoHash);
assert.match(result.magnet, new RegExp(`btih:${infoHash}`));
assert.match(result.magnet, /ws=https%3A%2F%2Fa%2F/, "ws = blossom video url");
assert.match(result.magnet, /xs=https%3A%2F%2Fa%2F/, "xs = blossom .torrent url");
assert.equal(result.torrentUrl, `https://a/${"b".repeat(64)}`);
});
test("uploadVideo publishes URL-only when the .torrent sidecar is rejected (415)", async () => {
// Spec correction: a webseed-only magnet (ws= but no xs=) can't bootstrap
// WebTorrent — there's no metadata source — so shipping one produces a dead
// share link. When a Blossom server rejects the .torrent (415), uploadVideo must
// succeed with the video URL but publish NO magnet (URL-only), while retaining
// the generated .torrent locally for other storage targets.
const svc = new BlossomService();
svc.loadSdk = async () => ({
createUploadAuth: async () => ({ kind: 24242 }),
uploadBlob: async (server, file) => {
if (file?.name?.includes(".torrent")) {
const err = new Error("File type not allowed, unsupported");
err.status = 415;
throw err;
}
return { url: `${server}/${SHA}`, sha256: SHA, size: 1, type: "video/mp4" };
},
});
const infoHash = "0".repeat(40);
const torrentFile = new File([new Uint8Array([9])], "clip.torrent");
const result = await svc.uploadVideo({
file: new File([new Uint8Array([1, 2])], "clip.mp4"),
servers: ["https://a"],
signer,
generateTorrent: async () => ({ hasValidInfoHash: true, infoHash, torrentFile }),
});
assert.equal(result.url, `https://a/${SHA}`, "video url is returned");
assert.equal(result.magnet, "", "no magnet is published when the .torrent isn't hosted");
assert.equal(result.torrentUrl, "", "no hosted .torrent url after rejection");
assert.equal(result.hasValidInfoHash, false, "no usable magnet is advertised");
assert.equal(result.torrentFile, torrentFile, "the generated .torrent is retained locally");
});
test("uploadVideo Tier 2: publishes a companion metadata event → ws=-only magnet", async () => {
// .torrent rejected by the server (415), but a publisher is wired (flag on at the
// mediaUploader seam): bitvid publishes the piece-map companion event and ships a
// ws=-only magnet it can bootstrap from Nostr. No xs= (nothing hosted).
const svc = new BlossomService();
svc.loadSdk = async () => ({
createUploadAuth: async () => ({ kind: 24242 }),
uploadBlob: async (server, file) => {
if (file?.name?.includes(".torrent")) {
const err = new Error("File type not allowed");
err.status = 415;
throw err;
}
return { url: `${server}/${SHA}`, sha256: SHA, size: 1, type: "video/mp4" };
},
});
const infoHash = "0".repeat(40);
const published = [];
const result = await svc.uploadVideo({
file: new File([new Uint8Array([1, 2])], "clip.mp4"),
servers: ["https://a"],
signer,
generateTorrent: async () => ({
hasValidInfoHash: true,
infoHash,
torrentFile: new File([new Uint8Array([9, 9, 9])], "clip.torrent"),
}),
publishTorrentMetadata: async (args) => published.push(args),
});
assert.equal(published.length, 1, "the companion was published exactly once");
assert.equal(published[0].infoHash, infoHash);
assert.equal(
Buffer.from(published[0].torrentBase64, "base64").length,
3,
"the exact .torrent bytes were base64-encoded",
);
assert.equal(result.hasValidInfoHash, true);
assert.match(result.magnet, new RegExp(`btih:${infoHash}`));
assert.match(result.magnet, /ws=https%3A%2F%2Fa%2F/, "webseed points at the video");
assert.ok(!/xs=/.test(result.magnet), "no xs= — the .torrent isn't hosted");
});
test("uploadVideo Tier 2: over-cap piece-map falls back to URL-only", async () => {
const svc = new BlossomService();
svc.loadSdk = async () => ({
createUploadAuth: async () => ({ kind: 24242 }),
uploadBlob: async (server, file) => {
if (file?.name?.includes(".torrent")) {
const err = new Error("File type not allowed");
err.status = 415;
throw err;
}
return { url: `${server}/${SHA}`, sha256: SHA, size: 1, type: "video/mp4" };
},
});
const published = [];
// A .torrent whose base64 exceeds the 64 KiB cap.
const big = new File([new Uint8Array(60 * 1024)], "big.torrent");
const result = await svc.uploadVideo({
file: new File([new Uint8Array([1])], "clip.mp4"),
servers: ["https://a"],
signer,
generateTorrent: async () => ({
hasValidInfoHash: true,
infoHash: "0".repeat(40),
torrentFile: big,
}),
publishTorrentMetadata: async (args) => published.push(args),
});
assert.equal(published.length, 0, "over-cap ⇒ nothing published");
assert.equal(result.magnet, "", "URL-only");
assert.equal(result.hasValidInfoHash, false);
});
test("uploadVideo Tier 2: a failed companion publish falls back to URL-only", async () => {
const svc = new BlossomService();
svc.loadSdk = async () => ({
createUploadAuth: async () => ({ kind: 24242 }),
uploadBlob: async (server, file) => {
if (file?.name?.includes(".torrent")) {
const err = new Error("File type not allowed");
err.status = 415;
throw err;
}
return { url: `${server}/${SHA}`, sha256: SHA, size: 1, type: "video/mp4" };
},
});
const result = await svc.uploadVideo({
file: new File([new Uint8Array([1])], "clip.mp4"),
servers: ["https://a"],
signer,
generateTorrent: async () => ({
hasValidInfoHash: true,
infoHash: "0".repeat(40),
torrentFile: new File([new Uint8Array([9])], "clip.torrent"),
}),
publishTorrentMetadata: async () => {
throw new Error("relay unreachable");
},
});
assert.equal(result.magnet, "", "publish failed ⇒ URL-only, video still succeeds");
assert.equal(result.hasValidInfoHash, false);
assert.equal(result.url, `https://a/${SHA}`);
});
// --- BUD-12 management primitives (Phase 2) ---
test("deleteFile guards + calls deleteBlob with a delete auth", async () => {
const svc = new BlossomService();
const calls = [];
svc.loadSdk = async () => ({
createDeleteAuth: async (s, hash) => ({ kind: 24242, t: "delete", hash }),
deleteBlob: async (server, hash, opts) => {
if (typeof opts?.onAuth === "function") await opts.onAuth();
calls.push({ server, hash });
return true;
},
});
await assert.rejects(() => svc.deleteFile({ sha256: SHA, signer }), /server and a sha256/);
await assert.rejects(() => svc.deleteFile({ server: "https://a", signer }), /server and a sha256/);
await assert.rejects(
() => svc.deleteFile({ server: "https://a", sha256: SHA, signer: null }),
/requires a signer/,
);
const ok = await svc.deleteFile({ server: "https://a", sha256: SHA, signer });
assert.equal(ok, true);
assert.deepEqual(calls[0], { server: "https://a", hash: SHA });
});
test("blobSha256FromUrl extracts the 64-hex blob id (or '' for non-blobs)", () => {
const sha = "a".repeat(64);
assert.equal(blobSha256FromUrl(`https://npub1x.blossom.band/${sha}.mp4`), sha);
assert.equal(blobSha256FromUrl(`https://blossom.band/${sha}`), sha);
assert.equal(blobSha256FromUrl(`https://cdn.example/u/2026/clip.mp4`), "", "not a blob path");
assert.equal(blobSha256FromUrl("not a url"), "");
assert.equal(blobSha256FromUrl(""), "");
});
test("deleteByUrl maps a Blossom URL to its blob and deletes it on every server", async () => {
const svc = new BlossomService();
const calls = [];
svc.loadSdk = async () => ({
createDeleteAuth: async () => ({ kind: 24242, t: "delete" }),
deleteBlob: async (server, hash) => {
calls.push({ server, hash });
return true;
},
});
const sha = "b".repeat(64);
const out = await svc.deleteByUrl({
url: `https://blossom.band/${sha}.mp4`,
servers: ["https://a", "https://b"],
signer,
});
assert.equal(out.sha256, sha);
assert.deepEqual(out.deleted.sort(), ["https://a", "https://b"]);
assert.deepEqual(calls.map((c) => c.hash), [sha, sha]);
});
test("deleteByUrl falls back to the URL's own origin when no servers are given", async () => {
const svc = new BlossomService();
const calls = [];
svc.loadSdk = async () => ({
createDeleteAuth: async () => ({}),
deleteBlob: async (server, hash) => {
calls.push({ server, hash });
return true;
},
});
const sha = "d".repeat(64);
const out = await svc.deleteByUrl({
url: `https://npub1x.blossom.band/${sha}.mp4`,
signer,
});
assert.equal(out.sha256, sha);
assert.deepEqual(out.deleted, ["https://npub1x.blossom.band"], "deleted from the URL origin");
assert.equal(calls[0].server, "https://npub1x.blossom.band");
});
test("deleteByUrl is a no-op for a non-Blossom URL", async () => {
const svc = new BlossomService();
let called = false;
svc.loadSdk = async () => ({
createDeleteAuth: async () => ({}),
deleteBlob: async () => {
called = true;
},
});
const out = await svc.deleteByUrl({
url: "https://cdn.example/video.mp4",
servers: ["https://a"],
signer,
});
assert.equal(out.sha256, "");
assert.equal(called, false, "no delete attempted for a non-blob URL");
});
test("deleteByUrl reports per-server failures without throwing", async () => {
const svc = new BlossomService();
svc.loadSdk = async () => ({
createDeleteAuth: async () => ({}),
deleteBlob: async (server) => {
if (server === "https://a") throw new Error("403 forbidden");
return true;
},
});
const sha = "c".repeat(64);
const out = await svc.deleteByUrl({
url: `https://x/${sha}`,
servers: ["https://a", "https://b"],
signer,
});
assert.deepEqual(out.deleted, ["https://b"]);
assert.equal(out.failed.length, 1);
assert.match(out.failed[0].error, /403/);
});
test("serversFromServerListEvent reads + de-dupes the BUD-03 server tags", () => {
const event = {
kind: 10063,
tags: [
["server", " https://a "],
["server", "https://a"],
["server", "https://b"],
["client", "x"],
["server", ""],
],
};
assert.deepEqual(serversFromServerListEvent(event), ["https://a", "https://b"]);
assert.deepEqual(serversFromServerListEvent(null), []);
});
test("listFiles returns the server's blob descriptors for a pubkey", async () => {
const svc = new BlossomService();
svc.loadSdk = async () => ({
createListAuth: async () => ({ kind: 24242, t: "list" }),
listBlobs: async (server, pubkey) => [
{ url: `${server}/${SHA}`, sha256: SHA, size: 1, type: "video/mp4" },
],
});
await assert.rejects(() => svc.listFiles({ pubkey: "pk" }), /server and a pubkey/);
const blobs = await svc.listFiles({ server: "https://a", pubkey: "f".repeat(64), signer });
assert.equal(blobs.length, 1);
assert.equal(blobs[0].sha256, SHA);
});
+98
View File
@@ -18,10 +18,40 @@ import {
computeDefaultForUploads,
legacyDuplicateIds,
saveProviderConnection,
fillStorageForm,
} from "../js/ui/profileModal/storageConnections.js";
// --- Pure decision logic ---
describe("fillStorageForm provider selector", () => {
// getConnection returns { ...payload, meta } — the provider is only in `meta` for
// the decrypted/keyless shape (payload has no top-level provider). The selector
// must reflect it, not silently revert to Cloudflare.
test("selects the connection's provider from meta when there's no top-level provider", () => {
const c = { storageProviderInput: { value: "cloudflare_r2" }, updateStorageFormVisibility: () => {}, handlePublicUrlInput: () => {} };
fillStorageForm(c, { meta: { provider: "backblaze_b2" } });
assert.equal(c.storageProviderInput.value, "backblaze_b2");
});
test("selects a keyless Blossom default (no payload/provider, provider only in meta)", () => {
const c = { storageProviderInput: { value: "cloudflare_r2" }, updateStorageFormVisibility: () => {}, handlePublicUrlInput: () => {} };
fillStorageForm(c, { meta: { provider: "blossom", defaultForUploads: true } });
assert.equal(c.storageProviderInput.value, "blossom");
});
test("prefers a top-level provider when present", () => {
const c = { storageProviderInput: { value: "" }, updateStorageFormVisibility: () => {}, handlePublicUrlInput: () => {} };
fillStorageForm(c, { provider: "generic_s3", meta: { provider: "cloudflare_r2" } });
assert.equal(c.storageProviderInput.value, "generic_s3");
});
test("falls back to cloudflare_r2 only when no provider is known", () => {
const c = { storageProviderInput: { value: "blossom" }, updateStorageFormVisibility: () => {}, handlePublicUrlInput: () => {} };
fillStorageForm(c, { meta: {} });
assert.equal(c.storageProviderInput.value, "cloudflare_r2");
});
});
describe("per-provider connection helpers", () => {
test("computeDefaultForUploads: explicit choice, first-ever, and 'was already default'", () => {
// Explicit checkbox always wins.
@@ -182,4 +212,72 @@ describe("saving multiple providers does not clash", () => {
"the synced record contains every provider's connection",
);
});
test("importing a synced record notifies connection-change observers (open UIs refresh)", async () => {
await saveProviderConnection(storageService, pubkey, {
provider: "cloudflare_r2",
payload: r2Payload,
meta: { provider: "cloudflare_r2" },
isDefault: true,
});
const record = await storageService.exportAccountRecord(pubkey);
let fired = null;
const off = storageService.onConnectionsChanged((e) => {
fired = e;
});
await storageService.importAccountRecord(pubkey, record);
off();
assert.deepEqual(
fired,
{ pubkey },
"a Nostr sync import fires a connection-change event so the open upload modal can refresh",
);
// Unsubscribe actually detaches — a second import must not re-fire this listener.
fired = null;
await storageService.importAccountRecord(pubkey, record);
assert.equal(fired, null, "off() detached the observer");
});
test("a Blossom-only (keyless) account syncs without an encryptedMasterKey", async () => {
// Blossom connections are keyless (encrypted: null), so a Blossom-only account
// legitimately has no encryptedMasterKey. Importing it must still work.
const record = {
pubkey,
connections: {
blossom: {
id: "blossom",
provider: "blossom",
meta: { provider: "blossom", servers: ["https://blossom.band"] },
encrypted: null,
},
},
};
await storageService.importAccountRecord(pubkey, record); // must not throw
const conns = await storageService.listConnections(pubkey);
assert.deepEqual(conns.map((c) => c.id), ["blossom"]);
assert.deepEqual(conns[0].meta.servers, ["https://blossom.band"]);
});
test("an encrypted (R2/S3) connection still requires the master-key envelope", async () => {
// A record carrying an encrypted payload can't be unlocked without the
// envelope — importing it without one must still be rejected.
const record = {
pubkey,
connections: {
default: {
id: "default",
provider: "cloudflare_r2",
meta: { provider: "cloudflare_r2" },
encrypted: { cipher: "deadbeef", iv: "00" },
},
},
};
await assert.rejects(
() => storageService.importAccountRecord(pubkey, record),
/Invalid storage account record/,
);
});
});
+98
View File
@@ -0,0 +1,98 @@
// buildTorrentMetadataEvent + infohash-addressing helpers (Phase 0 of the Blossom
// WebTorrent-metadata companion event). Covers the event shape, the base64
// round-trip, the addressing key, and input guards. See
// docs/blossom-torrent-metadata-plan.md.
import test from "node:test";
import assert from "node:assert/strict";
import {
buildTorrentMetadataEvent,
normalizeInfoHash,
torrentMetadataDTag,
TORRENT_METADATA_KIND,
buildBlossomServerListEvent,
BLOSSOM_SERVER_LIST_KIND,
} from "../js/nostrEventSchemas.js";
const INFOHASH = "0123456789abcdef0123456789abcdef01234567"; // 40 hex
const TORRENT_B64 = Buffer.from("d4:infod6:lengthi1eee").toString("base64");
const PK = "a".repeat(64);
test("normalizeInfoHash lowercases valid 40-hex and rejects the rest", () => {
assert.equal(normalizeInfoHash(INFOHASH.toUpperCase()), INFOHASH);
assert.equal(normalizeInfoHash(` ${INFOHASH} `), INFOHASH);
assert.equal(normalizeInfoHash("not-hex"), "");
assert.equal(normalizeInfoHash("abc"), "", "too short");
assert.equal(normalizeInfoHash("a".repeat(64)), "", "sha256-length is not a v1 infohash");
assert.equal(normalizeInfoHash(null), "");
});
test("torrentMetadataDTag builds the addressing key, empty on bad input", () => {
assert.equal(torrentMetadataDTag(INFOHASH), `bitvid:torrent:${INFOHASH}`);
assert.equal(torrentMetadataDTag("nope"), "");
});
test("buildTorrentMetadataEvent produces a kind-30078 event keyed by infohash", () => {
const event = buildTorrentMetadataEvent({
pubkey: PK,
created_at: 1700000000,
infoHash: INFOHASH.toUpperCase(), // caller may pass any case
torrentBase64: TORRENT_B64,
videoEventId: "vid-event-id",
});
assert.equal(event.kind, TORRENT_METADATA_KIND);
assert.equal(event.pubkey, PK);
const dTag = event.tags.find((t) => t[0] === "d");
assert.deepEqual(dTag, ["d", `bitvid:torrent:${INFOHASH}`], "addressed by lowercase infohash");
assert.deepEqual(event.tags.find((t) => t[0] === "x"), ["x", INFOHASH]);
assert.deepEqual(event.tags.find((t) => t[0] === "client"), ["client", "bitvid"]);
assert.deepEqual(event.tags.find((t) => t[0] === "e"), ["e", "vid-event-id"]);
});
test("buildTorrentMetadataEvent content round-trips the exact .torrent bytes", () => {
const event = buildTorrentMetadataEvent({
pubkey: PK,
created_at: 1700000000,
infoHash: INFOHASH,
torrentBase64: TORRENT_B64,
});
const envelope = JSON.parse(event.content);
assert.equal(envelope.v, 1);
assert.equal(envelope.infohash, INFOHASH);
assert.equal(envelope.torrent, TORRENT_B64);
// The recovered bytes are byte-identical to what was embedded.
assert.equal(
Buffer.from(envelope.torrent, "base64").toString(),
"d4:infod6:lengthi1eee",
);
// No back-reference tag when videoEventId is omitted.
assert.equal(event.tags.some((t) => t[0] === "e"), false);
});
test("buildTorrentMetadataEvent rejects an invalid infohash or empty payload", () => {
assert.throws(
() => buildTorrentMetadataEvent({ pubkey: PK, created_at: 1, infoHash: "bad", torrentBase64: TORRENT_B64 }),
/valid 40-hex-char infoHash/,
);
assert.throws(
() => buildTorrentMetadataEvent({ pubkey: PK, created_at: 1, infoHash: INFOHASH, torrentBase64: "" }),
/non-empty base64 torrent payload/,
);
});
test("buildBlossomServerListEvent builds a de-duped kind-10063 server list (BUD-03)", () => {
const ev = buildBlossomServerListEvent({
pubkey: PK,
created_at: 1700000000,
servers: [" https://a ", "https://a", "https://b", ""],
});
assert.equal(ev.kind, BLOSSOM_SERVER_LIST_KIND);
assert.equal(ev.kind, 10063);
assert.deepEqual(
ev.tags.filter((t) => t[0] === "server"),
[["server", "https://a"], ["server", "https://b"]],
"one server tag per unique url, in order",
);
assert.deepEqual(ev.tags.find((t) => t[0] === "client"), ["client", "bitvid"]);
assert.equal(ev.content, "");
});
+128
View File
@@ -0,0 +1,128 @@
// torrentMetadataService: fetch → decode → VERIFY infohash → cache, plus the
// bencode/SHA-1 infohash computation. Builds a real .torrent in-test so the verify
// is exercised end-to-end (not circularly). See docs/blossom-torrent-metadata-plan.md.
import test from "node:test";
import assert from "node:assert/strict";
import {
TorrentMetadataService,
infoHashFromTorrent,
base64ToBytes,
} from "../js/services/torrentMetadataService.js";
import { torrentMetadataDTag } from "../js/nostrEventSchemas.js";
// --- tiny bencode encoder (test-only) ---
function bstr(s) {
const b = Buffer.from(s);
return Buffer.concat([Buffer.from(`${b.length}:`), b]);
}
function bbytes(buf) {
return Buffer.concat([Buffer.from(`${buf.length}:`), buf]);
}
function bint(n) {
return Buffer.from(`i${n}e`);
}
// A minimal, valid info dict + a full .torrent wrapping it.
const pieces = Buffer.alloc(20, 7); // one 20-byte SHA-1 placeholder
const infoDict = Buffer.concat([
Buffer.from("d"),
bstr("length"), bint(3),
bstr("name"), bstr("clip.mp4"),
bstr("piece length"), bint(16384),
bstr("pieces"), bbytes(pieces),
Buffer.from("e"),
]);
const torrent = Buffer.concat([
Buffer.from("d"),
bstr("announce"), bstr("wss://tracker.example"),
bstr("info"), infoDict,
Buffer.from("e"),
]);
async function sha1Hex(buf) {
const d = await crypto.subtle.digest("SHA-1", buf);
return Buffer.from(new Uint8Array(d)).toString("hex");
}
function companionEvent(infoHash, torrentBuf, created_at = 1000) {
return {
kind: 30078,
created_at,
tags: [["d", torrentMetadataDTag(infoHash)]],
content: JSON.stringify({ v: 1, infohash: infoHash, torrent: torrentBuf.toString("base64") }),
};
}
test("infoHashFromTorrent isolates the info dict and SHA-1s exactly those bytes", async () => {
const expected = await sha1Hex(infoDict); // hash of ONLY the info dict
const actual = await infoHashFromTorrent(new Uint8Array(torrent));
assert.equal(actual, expected, "infohash = SHA-1 of the bencoded info dict");
assert.equal(actual.length, 40);
});
test("infoHashFromTorrent returns '' for non-torrent / malformed bytes", async () => {
assert.equal(await infoHashFromTorrent(new Uint8Array([1, 2, 3])), "");
assert.equal(await infoHashFromTorrent(new Uint8Array(Buffer.from("d4:spam"))), "");
});
test("fetch returns the verified piece-map for a matching companion event", async () => {
const infoHash = await infoHashFromTorrent(new Uint8Array(torrent));
const svc = new TorrentMetadataService();
let queries = 0;
const out = await svc.fetch({
infoHash,
author: "a".repeat(64),
queryEvents: async (filter) => {
queries += 1;
assert.deepEqual(filter["#d"], [torrentMetadataDTag(infoHash)], "queried by infohash key");
assert.deepEqual(filter.authors, ["a".repeat(64)], "author-scoped");
return [companionEvent(infoHash, torrent)];
},
});
assert.ok(out, "returns a result");
assert.equal(out.infoHash, infoHash);
assert.equal(Buffer.from(out.torrentBytes).equals(torrent), true, "exact .torrent bytes");
assert.equal(queries, 1);
});
test("fetch REJECTS a companion whose bytes don't match the requested infohash", async () => {
// Ask for a different infohash than the .torrent actually reconstructs to.
const wrongHash = "1".repeat(40);
const svc = new TorrentMetadataService();
const out = await svc.fetch({
infoHash: wrongHash,
queryEvents: async () => [companionEvent(wrongHash, torrent)], // torrent hashes to something else
});
assert.equal(out, null, "infohash mismatch ⇒ rejected (tamper-resistant)");
});
test("fetch returns null when not found or malformed", async () => {
const infoHash = await infoHashFromTorrent(new Uint8Array(torrent));
const svc = new TorrentMetadataService();
assert.equal(await svc.fetch({ infoHash, queryEvents: async () => [] }), null, "not found");
assert.equal(
await svc.fetch({ infoHash, queryEvents: async () => [{ content: "not json" }] }),
null,
"malformed content",
);
assert.equal(await svc.fetch({ infoHash: "nope", queryEvents: async () => [] }), null, "bad infohash");
});
test("fetch caches the verified result (no second relay query)", async () => {
const infoHash = await infoHashFromTorrent(new Uint8Array(torrent));
const svc = new TorrentMetadataService();
let queries = 0;
const q = async () => {
queries += 1;
return [companionEvent(infoHash, torrent)];
};
await svc.fetch({ infoHash, queryEvents: q });
await svc.fetch({ infoHash, queryEvents: q });
assert.equal(queries, 1, "second fetch served from cache");
});
test("base64ToBytes round-trips", () => {
const bytes = base64ToBytes(Buffer.from([1, 2, 3, 250]).toString("base64"));
assert.deepEqual([...bytes], [1, 2, 3, 250]);
});
+43 -8
View File
@@ -1010,14 +1010,31 @@ to rescue a creator who *does* get WoT-hidden, so if it happens, ship this promp
so either is feasible. Leaning soft-only so a whitelist can't force-show illegal.
- [ ] When a real example appears: flip the placeholder with the scope above + tests.
### 30. Blossom storage support (bring to par with R2 / S3)
- [ ] Add **Blossom** (BUD-01/02 blob storage over Nostr) as a storage provider
alongside Cloudflare R2 and generic S3. Bring it to functional parity where
possible: upload, thumbnail, `.torrent`, public URL resolution, and
delete/edit cleanup. Slot it into the existing storage-provider abstraction
(`r2Service.js` / `storageService.js`) and the Storage settings pane; auth is
a signed Nostr event per Blossom rather than S3 keys. Research the BUD spec
coverage needed for bitvid's upload/delete flows.
### 30. Blossom storage support (bring to par with R2 / S3) — full plan in docs/blossom-plan.md
- [ ] Add **Blossom** (nostr-native blob storage) as a first-class upload provider
alongside Cloudflare R2 / generic S3 / B2. Slots into the existing
`service.uploadFile({…}) → {url,key}` seam (`mediaUploader.js:38`) as a new
`blossomService` + `isBlossomProvider` branch — Blossom's upload is *simpler*
than S3 (no SigV4/multipart/bucket/CORS), and auth is a signed nostr event
(kind 24242, BUD-11) that reuses bitvid's existing signer (no new crypto).
- [ ] **Best practice (researched 2026-07-09):** use the maintained
**`blossom-client-sdk` v5** (hzrd149) — ESM, only dep `@noble/hashes` which
bitvid already vendors. Spec has modularized: BUD-01 retrieval, BUD-02
`/upload`, **BUD-11 auth (kind 24242, NIP-40 expiration, `t`/`x`/`server`
tags, `Authorization: Nostr <base64url>`)**, BUD-12 `/list`+`DELETE`, BUD-03
kind-10063 discovery, BUD-04 mirror, BUD-05 `/media`, BUD-06 preflight. Core
min = 01+02+11; add 03/06/04.
- [ ] **Design shifts (decisions D1–D10 in the plan):** multi-server list + mirror
(Blossom's whole point vs one bucket), kind-10063 read/publish, `/upload` for
video (keep bytes for torrent) + optional `/media` for thumbnails, keep the
magnet/torrent parity, and a **keyless** multi-server config form (no
access-key/secret/bucket) — which also *removes* the encrypted-cred-sync
surface for Blossom users (there's no secret, only server URLs + the signer).
- [ ] Flag-gated `FEATURE_BLOSSOM_STORAGE` (off = no trace), vendored SDK,
phased: P0 flag+vendor, P1 upload+config UI, P1.5 discovery, P2
list/delete+orphan-GC+mirror, P3 extras. Lift = medium (upload core is SDK
glue; the config UI is the main cost). Public blobs only (D10 — no private
content on base Blossom).
### 34. NIP-71 mirror: videos show "not mirrored" + duplicate on re-mirror (BUG) — FIXED 2026-06-29 (only live Amber VERIFY remains)
Reported 2026-06-25. Relates to #17 (NIP-71 interop) / the bitvid→NIP-71 mirror.
@@ -2085,3 +2102,21 @@ example: event `95053d75…` "Nostr Compass Podcast #20" (pubkey `b7ed68b0…`).
media grid — plan the category resolver + per-tab loaders together so video /
audio / live / shorts don't each grow a bespoke fetch, and so the channel-profile
tabs and sidebar tabs share one source of truth.
### 61. Bitcoin Connect — smooth NWC wallet connect UX — full plan in docs/bitcoin-connect-plan.md
Requested 2026-07-09. Add Alby's Bitcoin Connect (getalby/bitcoin-connect,
bitcoin-connect.com) as a polished "connect your wallet" modal for NWC, feeding
bitvid's EXISTING NWC pipeline; keep manual URI entry as the backup/advanced path.
- [ ] Verified feasible: BC's NWC connector exposes the raw pairing URI via
`provider.client.nostrWalletConnectUrl` (on `onConnected`), which flows straight
into `nwcSettingsService.validateWalletUri` → the existing per-npub store. No
change to how bitvid pays or stores — one new way to obtain the same string.
- [ ] Decisions D1–D6 in the plan (recommendations: vendor + lazy import;
`persistConnection:false` + `disconnect()` after extract; NWC-only connector;
least-privilege `requestMethods` incl. `pay_invoice`; Connect = primary CTA with
manual under "Advanced"; `FEATURE_BITCOIN_CONNECT` flag, off = no trace).
- [ ] Security: NWC URI is a bearer SPENDING secret — never log it; pin+vendor the
(Alpha) lib; re-validate the extracted string; payment invariants
(PLATFORM_FEE_PERCENT=30, preimage verify, no pay_invoice auto-resend) untouched.
- [ ] Phased + flag-gated: Phase 0 flag+vendor, Phase 1 connect button, Phase 2
polish (balance/reconnect), Phase 3 optional WebLN path for Alby-extension/Hub.
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) Alby contributors <[email protected]> (https://getalby.com)
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
File diff suppressed because it is too large. Load diff
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2024 hzrd149
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+6
View File
@@ -0,0 +1,6 @@
var _t=Object.defineProperty;var ue=(n,e,t)=>e in n?_t(n,e,{enumerable:!0,configurable:!0,writable:!0,value:t}):n[e]=t;var $=(n,e)=>()=>(n&&(e=n(n=0)),e);var St=(n,e)=>{for(var t in e)_t(n,t,{get:e[t],enumerable:!0})};var Q=(n,e,t)=>ue(n,typeof e!="symbol"?e+"":e,t);function he(n){return n instanceof Uint8Array||ArrayBuffer.isView(n)&&n.constructor.name==="Uint8Array"}function Y(n,...e){if(!he(n))throw new Error("Uint8Array expected");if(e.length>0&&!e.includes(n.length))throw new Error("Uint8Array expected of length "+e+", got length="+n.length)}function Bt(n,e=!0){if(n.destroyed)throw new Error("Hash instance has been destroyed");if(e&&n.finished)throw new Error("Hash#digest() has already been called")}function Mt(n,e){Y(n);let t=e.outputLen;if(n.length<t)throw new Error("digestInto() expects output buffer of length at least "+t)}function V(...n){for(let e=0;e<n.length;e++)n[e].fill(0)}function st(n){return new DataView(n.buffer,n.byteOffset,n.byteLength)}function R(n,e){return n<<32-e|n>>>e}function Ft(n){if(Y(n),fe)return n.toHex();let e="";for(let t=0;t<n.length;t++)e+=le[n[t]];return e}function de(n){if(typeof n!="string")throw new Error("string expected");return new Uint8Array(new TextEncoder().encode(n))}function Ut(n){return typeof n=="string"&&(n=de(n)),Y(n),n}function N(n){let e=r=>n().update(Ut(r)).digest(),t=n();return e.outputLen=t.outputLen,e.blockLen=t.blockLen,e.create=()=>n(),e}var fe,le,it,ct=$(()=>{fe=typeof Uint8Array.from([]).toHex=="function"&&typeof Uint8Array.fromHex=="function",le=Array.from({length:256},(n,e)=>e.toString(16).padStart(2,"0"));it=class{}});function xe(n,e,t,r){if(typeof n.setBigUint64=="function")return n.setBigUint64(e,t,r);let o=BigInt(32),i=BigInt(4294967295),a=Number(t>>o&i),s=Number(t&i),u=r?4:0,h=r?0:4;n.setUint32(e+u,a,r),n.setUint32(e+h,s,r)}function kt(n,e,t){return n&e^~n&t}function Dt(n,e,t){return n&e^n&t^e&t}var Z,_,M,E,S,jt=$(()=>{ct();Z=class extends it{constructor(e,t,r,o){super(),this.finished=!1,this.length=0,this.pos=0,this.destroyed=!1,this.blockLen=e,this.outputLen=t,this.padOffset=r,this.isLE=o,this.buffer=new Uint8Array(e),this.view=st(this.buffer)}update(e){Bt(this),e=Ut(e),Y(e);let{view:t,buffer:r,blockLen:o}=this,i=e.length;for(let a=0;a<i;){let s=Math.min(o-this.pos,i-a);if(s===o){let u=st(e);for(;o<=i-a;a+=o)this.process(u,a);continue}r.set(e.subarray(a,a+s),this.pos),this.pos+=s,a+=s,this.pos===o&&(this.process(t,0),this.pos=0)}return this.length+=e.length,this.roundClean(),this}digestInto(e){Bt(this),Mt(e,this),this.finished=!0;let{buffer:t,view:r,blockLen:o,isLE:i}=this,{pos:a}=this;t[a++]=128,V(this.buffer.subarray(a)),this.padOffset>o-a&&(this.process(r,0),a=0);for(let c=a;c<o;c++)t[c]=0;xe(r,o-8,BigInt(this.length*8),i),this.process(r,0);let s=st(e),u=this.outputLen;if(u%4)throw new Error("_sha2: outputLen should be aligned to 32bit");let h=u/4,d=this.get();if(h>d.length)throw new Error("_sha2: outputLen bigger than state");for(let c=0;c<h;c++)s.setUint32(4*c,d[c],i)}digest(){let{buffer:e,outputLen:t}=this;this.digestInto(e);let r=e.slice(0,t);return this.destroy(),r}_cloneInto(e){e||(e=new this.constructor),e.set(...this.get());let{blockLen:t,buffer:r,length:o,finished:i,destroyed:a,pos:s}=this;return e.destroyed=a,e.finished=i,e.length=o,e.pos=s,o%t&&e.buffer.set(r),e}clone(){return this._cloneInto()}},_=Uint32Array.from([1779033703,3144134277,1013904242,2773480762,1359893119,2600822924,528734635,1541459225]),M=Uint32Array.from([3238371032,914150663,812702999,4144912697,4290775857,1750603025,1694076839,3204075428]),E=Uint32Array.from([3418070365,3238371032,1654270250,914150663,2438529370,812702999,355462360,4144912697,1731405415,4290775857,2394180231,1750603025,3675008525,1694076839,1203062813,3204075428]),S=Uint32Array.from([1779033703,4089235720,3144134277,2227873595,1013904242,4271175723,2773480762,1595750129,1359893119,2917565137,2600822924,725511199,528734635,4215389547,1541459225,327033209])});function me(n,e=!1){return e?{h:Number(n&ut),l:Number(n>>Vt&ut)}:{h:Number(n>>Vt&ut)|0,l:Number(n&ut)|0}}function Nt(n,e=!1){let t=n.length,r=new Uint32Array(t),o=new Uint32Array(t);for(let i=0;i<t;i++){let{h:a,l:s}=me(n[i],e);[r[i],o[i]]=[a,s]}return[r,o]}function I(n,e,t,r){let o=(e>>>0)+(r>>>0);return{h:n+t+(o/2**32|0)|0,l:o|0}}var ut,Vt,Ht,Lt,G,W,tt,et,Gt,Wt,Ot,qt,Xt,Kt,$t=$(()=>{ut=BigInt(4294967295),Vt=BigInt(32);Ht=(n,e,t)=>n>>>t,Lt=(n,e,t)=>n<<32-t|e>>>t,G=(n,e,t)=>n>>>t|e<<32-t,W=(n,e,t)=>n<<32-t|e>>>t,tt=(n,e,t)=>n<<64-t|e>>>t-32,et=(n,e,t)=>n>>>t-32|e<<64-t;Gt=(n,e,t)=>(n>>>0)+(e>>>0)+(t>>>0),Wt=(n,e,t,r)=>e+t+r+(n/2**32|0)|0,Ot=(n,e,t,r)=>(n>>>0)+(e>>>0)+(t>>>0)+(r>>>0),qt=(n,e,t,r,o)=>e+t+r+o+(n/2**32|0)|0,Xt=(n,e,t,r,o)=>(n>>>0)+(e>>>0)+(t>>>0)+(r>>>0)+(o>>>0),Kt=(n,e,t,r,o,i)=>e+t+r+o+i+(n/2**32|0)|0});var Qt={};St(Qt,{SHA224:()=>ht,SHA256:()=>nt,SHA384:()=>ft,SHA512:()=>O,SHA512_224:()=>lt,SHA512_256:()=>dt,sha224:()=>Ae,sha256:()=>ye,sha384:()=>Se,sha512:()=>Ee,sha512_224:()=>Ue,sha512_256:()=>Be});var ge,k,nt,ht,Jt,wLine truncated
/*! Bundled license information:
@noble/hashes/esm/utils.js:
(*! noble-hashes - MIT License (c) 2022 Paul Miller (paulmillr.com) *)
*/