From 6dd40ce3305c1a082de6b3fa52bb091844bb89c2 Mon Sep 17 00:00:00 2001
From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com>
Date: Thu, 9 Jul 2026 14:19:17 -0400
Subject: [PATCH 01/18] docs: add Bitcoin Connect (NWC connect UX) integration
plan + TODO #61
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Requested: integrate getAlby/bitcoin-connect as a smooth NWC "connect wallet"
front-end, keeping manual URI entry as backup. Plan (docs/bitcoin-connect-plan.md)
grounds it in bitvid's existing NWC stack: BC's NWC connector exposes the raw
pairing URI (provider.client.nostrWalletConnectUrl) which feeds the existing
validateWalletUri → per-npub store → zap-split/pay_invoice pipeline unchanged.
BC is a connect adapter, not a payment layer. Covers delivery (vendor+lazy import
vs esm.sh; CSP already allows esm.sh), persistence ownership, NWC-only connector
scope, least-privilege requestMethods, UI placement, FEATURE_BITCOIN_CONNECT flag,
and the security constraints (bearer spending secret, Alpha lib pin/vendor).
Co-Authored-By: Claude Opus 4.8
---
docs/bitcoin-connect-plan.md | 167 +++++++++++++++++++++++++++++
todo/TODO_2026-06-20_pre-launch.md | 18 ++++
2 files changed, 185 insertions(+)
create mode 100644 docs/bitcoin-connect-plan.md
diff --git a/docs/bitcoin-connect-plan.md b/docs/bitcoin-connect-plan.md
new file mode 100644
index 00000000..9ff3ab64
--- /dev/null
+++ b/docs/bitcoin-connect-plan.md
@@ -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@` (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).
diff --git a/todo/TODO_2026-06-20_pre-launch.md b/todo/TODO_2026-06-20_pre-launch.md
index c7ee104d..576b3fe6 100644
--- a/todo/TODO_2026-06-20_pre-launch.md
+++ b/todo/TODO_2026-06-20_pre-launch.md
@@ -2085,3 +2085,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.
From 69e87c6e156db503a5693d339fa6fd4000cb06e1 Mon Sep 17 00:00:00 2001
From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com>
Date: Thu, 9 Jul 2026 14:49:37 -0400
Subject: [PATCH 02/18] =?UTF-8?q?feat(wallet):=20Bitcoin=20Connect=20NWC?=
=?UTF-8?q?=20"Connect=20wallet"=20flow=20[LOCAL=20=E2=80=94=20flag=20ON,?=
=?UTF-8?q?=20do=20not=20push=20as-is]?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Phase 0+1 of docs/bitcoin-connect-plan.md (#61). Adds Alby's Bitcoin Connect as a
one-tap NWC connect front-end that feeds bitvid's EXISTING wallet pipeline; manual
URI entry stays as the backup.
- Vendored: pinned @getalby/bitcoin-connect@3.12.3 bundled by
scripts/build-bitcoin-connect.mjs → vendor/bitcoin-connect.bundle.min.js (esbuild,
browser ESM). crypto-shim aliases the L402-only `import('crypto')` bitvid never
calls. `npm run build:bitcoin-connect` rebuilds it.
- Flag: FEATURE_BITCOIN_CONNECT wired like FEATURE_AUDIO_INGEST
(instance-config → config → constants).
- UI: a "Connect wallet" button in the Wallet Connect pane (hidden unless the flag
is on); the manual URI field stays below as the backup.
- Controller: ProfileWalletController.handleBitcoinConnect() lazy-imports the bundle
(only on click — the ~700KB never touches the main bundle), inits NWC-only with
least-privilege requestMethods and persistConnection:false, then on onConnected
extracts provider.client.nostrWalletConnectUrl → the SAME validate→save path as
the manual field → disconnect() so bitvid solely holds the spending secret. The
URI is never logged.
NOTE: config/instance-config.js FEATURE_BITCOIN_CONNECT is TRUE for local testing
only. Reset to false (shipping default) before pushing/deploying. Local commit;
not pushed, not deployed.
Co-Authored-By: Claude Opus 4.8
---
components/profile-modal.html | 24 +
config/instance-config.js | 12 +
js/config.js | 2 +
js/constants.js | 10 +
js/ui/profileModal/ProfileWalletController.js | 137 ++
package-lock.json | 115 +-
package.json | 2 +
scripts/build-bitcoin-connect.mjs | 51 +
scripts/vendor/bitcoin-connect-entry.mjs | 17 +
scripts/vendor/crypto-shim.mjs | 26 +
vendor/bitcoin-connect.LICENSE | 21 +
vendor/bitcoin-connect.bundle.min.js | 1249 +++++++++++++++++
12 files changed, 1663 insertions(+), 3 deletions(-)
create mode 100644 scripts/build-bitcoin-connect.mjs
create mode 100644 scripts/vendor/bitcoin-connect-entry.mjs
create mode 100644 scripts/vendor/crypto-shim.mjs
create mode 100644 vendor/bitcoin-connect.LICENSE
create mode 100644 vendor/bitcoin-connect.bundle.min.js
diff --git a/components/profile-modal.html b/components/profile-modal.html
index 00375790..e0932ba2 100644
--- a/components/profile-modal.html
+++ b/components/profile-modal.html
@@ -860,6 +860,30 @@
Connect a Nostr Wallet Connect (NWC) compatible wallet to zap creators directly from bitvid.
+
+
+
+ One-tap connect via Bitcoin Connect (NWC) — or paste a connection manually below.
+
+
`),de,wn(`w-7 h-7 ${de}`),de,this.renderQR(),this._copyAuthString,Pe,qe,this._hasCopiedAuthString?ha:fa,this._hasCopiedAuthString?"Copied!":"Copy")}renderQR(){if(!this._authString)return null;let e=this._authString;return setTimeout(()=>{var t;let r=(t=this.shadowRoot)==null?void 0:t.getElementById("qr");if(!r)return void console.error("qr canvas not found");let n=r.getContext("2d");if(!n)return void console.error("could not get context for qr canvas");let i=(0,To.default)(0,"L");i.addData(e),i.make();let a=i.getModuleCount();r.width=4*a,r.height=4*a,i.renderTo2dContext(n,4)},100),X(r0||(r0=o0`
+
+
+ `))}_copyAuthString(){this._authString&&(navigator.clipboard.writeText(this._authString),this._hasCopiedAuthString=!0,setTimeout(()=>{this._hasCopiedAuthString=!1},2e3))}async initAlbyGo(){try{var e;let t=(e=ne.getState().bitcoinConnectConfig.providerConfig)==null||(e=e.nwc)==null?void 0:e.authorizationUrlOptions,r=t?.expiresAt,n=t?.requestMethods;n||(n=["get_info","get_balance","get_budget","pay_invoice","list_transactions","lookup_invoice","make_invoice"]);let i=new Yn({name:this._appName,icon:this._appIcon,relayUrls:["wss://relay.getalby.com/v1"],requestMethods:n,notificationTypes:t?.notificationTypes,maxAmount:t?.maxAmount,budgetRenewal:t?.budgetRenewal,expiresAt:r?Math.floor(r.getTime()/1e3):void 0,isolated:t?.isolated,metadata:t?.metadata,returnTo:t?.returnTo});this._authString=i.getConnectionUri("alby"),window.location.href=this._authString;let{unsub:a}=await i.subscribe({onSuccess:async function(f){f.close(),ne.getState().connect({nwcUrl:f.nostrWalletConnectUrl,connectorName:"Alby Hub",connectorType:"nwc.albyhub"})}});this._unsub=a}catch(t){console.error(t),alert(""+t)}}};ee([ge()],co.prototype,"_authString",void 0),ee([ge()],co.prototype,"_hasCopiedAuthString",void 0),co=ee([pe("bc-alby-go")],co);var i0,s0,k5=e=>e,lo=class extends Ce()(ye){constructor(){super(...arguments),this._lnbitsAdminKey="",this._lnbitsUrl=""}render(){return X(i0||(i0=k5`
+
+
+
+
+ In LNbits, choose the wallet you want to connect, open it, click on
+ API docs and copy the Admin Key. Paste it below:
+
+
+
+ LNbits Admin Key
+
+
+
+ LNbits URL
+
+
+
+
+ Connect
+
+
+
+
`),qs,mr,de,this._lnbitsAdminKey,this._lnbitsAdminKeyChanged,bn,It,de,this._lnbitsUrl,this._lnbitsUrlChanged,bn,It,this.onConnect)}_lnbitsAdminKeyChanged(e){this._lnbitsAdminKey=e.target.value}_lnbitsUrlChanged(e){this._lnbitsUrl=e.target.value}async onConnect(){if(!this._lnbitsAdminKey)return void ne.getState().setError("Please enter your admin key");if(!this._lnbitsUrl)return void ne.getState().setError("Please enter your LNbits instance URL");let e=this._lnbitsUrl;e.endsWith("/")&&(e=e.substring(0,e.length-1)),await ne.getState().connect({lnbitsAdminKey:this._lnbitsAdminKey,lnbitsInstanceUrl:e,connectorName:qs,connectorType:"lnbits"})}};ee([ge()],lo.prototype,"_lnbitsAdminKey",void 0),ee([ge()],lo.prototype,"_lnbitsUrl",void 0),lo=ee([pe("bc-lnbits")],lo);var zh=Ee(s0||(s0=(e=>e)`
+
+`)),a0,L5=Ee(a0||(a0=(e=>e)``)),c0,U5=Ee(c0||(c0=(e=>e)``)),l0,u0,h0,f0,d0,p0,y0,g0,b0,w0,m0,v0,E0,_0,C0,A0,S0,T0,O0,je=e=>e,Wt=class extends Ce()(ye){constructor(){super(...arguments),this._hasCopiedInvoice=!1,this._isPaying=!1,this._showQR=!1,this.paymentMethods="all"}updated(e){super.updated(e),e.has("paid")&&this.paid&&setTimeout(()=>{No()},3e3)}renderHeading(e){return X(l0||(l0=je`
+
+ ${0}
+ `),this.paymentMethods==="all"||this.paymentMethods==="external"?X(b0||(b0=je`
+
+ ${0} Open in a Bitcoin Wallet
+
+ `),this.invoice,L5):null,e,t,r?X(w0||(w0=je`
`),zs,de,de,de,de,this._nwcUrl,this.nwcUrlChanged,bn,It,this.onConnect)}nwcUrlChanged(e){this._nwcUrl=e.target.value}async onConnect(){this._nwcUrl?await ne.getState().connect({nwcUrl:this._nwcUrl,connectorName:zs,connectorType:"nwc.lnbits"}):ne.getState().setError("Please enter a URL")}};ee([ge()],ys.prototype,"_nwcUrl",void 0),ys=ee([pe("bc-lnbits-nwc")],ys);var H5=Ee(D0||(D0=(e=>e)`
+`)),Js="Cashu.me",B0=class extends st{constructor(){super("nwc.cashume",Js,"#7f38ca",H5)}async _onClick(){ne.getState().pushRoute("/cashu-me")}};B0=ee([pe("bc-cashu-me-connector")],B0);var F0,$5=e=>e,gs=class extends Ce()(ye){constructor(){super(...arguments),this._nwcUrl=""}render(){return X(F0||(F0=$5`
+
+
+
+
+ 1. Open
+ Cashu.me
+
+ , click on the hamburger menu on
+ the top left, then click
+ Settings. Scroll down to
+ Nostr Wallet Connect
+ and enable NWC, and then press the copy button.
+
+
+ 2. Keep the Cashu.me browser tab open while using Bitcoin Connect.
+
`),this._error):null)}_onClickConnectWallet(){this._showConnect=!0}},Es.styles=[...mh.styles,Oo(Ch||(Ch=fo`
+ :host {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 100%;
+ }
+ `))],Es);ee([Ue({type:Boolean})],un.prototype,"closable",void 0),ee([Ue({type:String})],un.prototype,"invoice",void 0),ee([Ue({type:String,attribute:"payment-methods"})],un.prototype,"paymentMethods",void 0),ee([Ue({type:Boolean})],un.prototype,"paid",void 0),ee([ge()],un.prototype,"_showConnect",void 0),un=ee([pe("bc-payment")],un);export{No as closeModal,i5 as disconnect,s5 as getConnectorConfig,r5 as init,n5 as isConnected,Kh as launchModal,X3 as onConnected,e5 as onDisconnected};
+/*! Bundled license information:
+
+@lightninglabs/lnc-web/dist/index.js:
+ (*! For license information please see index.js.LICENSE.txt *)
+
+@getalby/sdk/dist/esm/index.js:
+ (*! noble-hashes - MIT License (c) 2022 Paul Miller (paulmillr.com) *)
+ (*! noble-curves - MIT License (c) 2022 Paul Miller (paulmillr.com) *)
+ (*! scure-base - MIT License (c) 2022 Paul Miller (paulmillr.com) *)
+ (*! noble-ciphers - MIT License (c) 2023 Paul Miller (paulmillr.com) *)
+
+@getalby/sdk/dist/esm/index.js:
+ (*! noble-hashes - MIT License (c) 2022 Paul Miller (paulmillr.com) *)
+
+@getalby/sdk/dist/esm/index.js:
+ (*! scure-base - MIT License (c) 2022 Paul Miller (paulmillr.com) *)
+
+@getalby/lightning-tools/dist/esm/index.js:
+ (*! noble-hashes - MIT License (c) 2022 Paul Miller (paulmillr.com) *)
+ (*! scure-base - MIT License (c) 2022 Paul Miller (paulmillr.com) *)
+*/
From 1add0bebec91480713f60897d7bb9425f5037369 Mon Sep 17 00:00:00 2001
From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com>
Date: Thu, 9 Jul 2026 14:56:00 -0400
Subject: [PATCH 03/18] feat(wallet): tuck manual URI under an "Advanced"
disclosure (Bitcoin Connect D5)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
When FEATURE_BITCOIN_CONNECT is on, "Connect wallet" is the primary path and the
raw Wallet Connect URI field collapses under an "Advanced — paste a connection
manually" disclosure (backup, de-emphasized). When the flag is off, the
controller forces the disclosure open and hides its summary, so the URI field
shows plainly exactly as before (no behavior change). Default-zap field stays
always visible. Adds a small token-based rotating-caret style for the summary.
Still LOCAL (flag ON for testing); not pushed, not deployed.
Co-Authored-By: Claude Opus 4.8
---
components/profile-modal.html | 29 +++++++++++------
css/tailwind.source.css | 19 ++++++++++++
js/ui/profileModal/ProfileWalletController.js | 31 ++++++++++++++-----
3 files changed, 62 insertions(+), 17 deletions(-)
diff --git a/components/profile-modal.html b/components/profile-modal.html
index e0932ba2..4a430a12 100644
--- a/components/profile-modal.html
+++ b/components/profile-modal.html
@@ -885,15 +885,26 @@
+ 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.
+
+
+
+
+
+
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.
@@ -1068,6 +1084,8 @@
The public URL where your files will be accessible (CDN).
+
+
+
+
+
+
+
+
diff --git a/js/nostrEventSchemas.js b/js/nostrEventSchemas.js
index 68013ffe..19e45b38 100644
--- a/js/nostrEventSchemas.js
+++ b/js/nostrEventSchemas.js
@@ -2575,6 +2575,44 @@ export function buildTorrentMetadataEvent(params) {
};
}
+// --- 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', ]`
+ * 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).
diff --git a/js/services/blossomService.js b/js/services/blossomService.js
index aef5dcb9..e47d35db 100644
--- a/js/services/blossomService.js
+++ b/js/services/blossomService.js
@@ -129,6 +129,32 @@ export function ensureTypedFile(file) {
}
}
+// Extract the blob id (sha256, 64 hex) from a Blossom URL — the last path segment
+// minus any extension (`https:///.mp4` → ``). 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", ]` 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;
@@ -406,6 +432,43 @@ export class BlossomService {
});
}
+ /**
+ * 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/`). Some
* servers require a signed list auth; provide the signer to cover those.
diff --git a/js/ui/editModalController.js b/js/ui/editModalController.js
index 2a74c06c..9ec5011e 100644
--- a/js/ui/editModalController.js
+++ b/js/ui/editModalController.js
@@ -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(
diff --git a/js/ui/profileModal/ProfileStorageController.js b/js/ui/profileModal/ProfileStorageController.js
index 07cf38d3..bec962ab 100644
--- a/js/ui/profileModal/ProfileStorageController.js
+++ b/js/ui/profileModal/ProfileStorageController.js
@@ -9,6 +9,10 @@ import {
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";
@@ -106,6 +110,12 @@ export class ProfileStorageController {
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.
@@ -161,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();
@@ -560,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.
diff --git a/js/ui/profileModal/blossomServerList.js b/js/ui/profileModal/blossomServerList.js
new file mode 100644
index 00000000..dc2e1a8f
--- /dev/null
+++ b/js/ui/profileModal/blossomServerList.js
@@ -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 };
+ }
+}
diff --git a/tests/blossom-server-list.test.mjs b/tests/blossom-server-list.test.mjs
new file mode 100644
index 00000000..fed76b6a
--- /dev/null
+++ b/tests/blossom-server-list.test.mjs
@@ -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/);
+});
diff --git a/tests/blossom-service.test.mjs b/tests/blossom-service.test.mjs
index dc83ebf6..aa9ac359 100644
--- a/tests/blossom-service.test.mjs
+++ b/tests/blossom-service.test.mjs
@@ -10,6 +10,8 @@ import blossomService, {
BLOSSOM_PROVIDER,
isBlossomProvider,
resolveBlossomServers,
+ blobSha256FromUrl,
+ serversFromServerListEvent,
} from "../js/services/blossomService.js";
import { FEATURE_BLOSSOM_STORAGE } from "../js/constants.js";
@@ -375,6 +377,109 @@ test("deleteFile guards + calls deleteBlob with a delete auth", async () => {
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 () => ({
diff --git a/tests/torrent-metadata-schema.test.mjs b/tests/torrent-metadata-schema.test.mjs
index c22c83c6..307fd519 100644
--- a/tests/torrent-metadata-schema.test.mjs
+++ b/tests/torrent-metadata-schema.test.mjs
@@ -10,6 +10,8 @@ import {
normalizeInfoHash,
torrentMetadataDTag,
TORRENT_METADATA_KIND,
+ buildBlossomServerListEvent,
+ BLOSSOM_SERVER_LIST_KIND,
} from "../js/nostrEventSchemas.js";
const INFOHASH = "0123456789abcdef0123456789abcdef01234567"; // 40 hex
@@ -77,3 +79,20 @@ test("buildTorrentMetadataEvent rejects an invalid infohash or empty payload", (
/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, "");
+});