Picks up BitLogin's login-surface expansion (NIP-07 extension, NIP-46
remote signer with nostrconnect QR, passkey sign-in), three rounds of
adversarial-audit fixes, and the mandatory recovery-phrase ceremony for
passkey-created accounts. Vendored via scripts/build-bitlogin-widget.mjs,
which pins the source commit in vendor/bitlogin.integrity.json; the
shared-chunk hashes changed, so three old chunks are removed and three
new ones added.
CSP fix, required for sign-in to work at all: BitLogin's crypto worker
compiles an Argon2id WASM module, and index.html's script-src had no
'wasm-unsafe-eval', so WebAssembly.compile would be blocked and every
BitLogin password flow would fail -- silently, because the page still
loads. This exact omission broke sign-in on bitlogin.network for four
days. The keyword permits WASM compilation only; JS eval stays blocked,
and the CSP comment's stale "no bundle uses wasm" claim is corrected.
tests/csp-policy.test.mjs now pins the keyword for index.html so it
cannot be dropped again. Baseline verified unchanged: npm run test:unit
green, npm run lint clean, and the raw tests/*.test.mjs glob fails the
same 10 pre-existing files before and after this change.
test_integrity_note:
change_type: ["new_tests"]
scenarios:
- id: SCN-csp-allows-wasm-for-bitlogin
given: "index.html mounts <bitlogin-auth>, whose worker compiles an Argon2id WASM module"
when: "the page's Content-Security-Policy script-src is evaluated"
then: "'wasm-unsafe-eval' is present, so WebAssembly.compile is permitted while 'unsafe-eval' remains absent"
observable_outcomes:
- "script-src directive parsed from the index.html CSP meta tag contains 'wasm-unsafe-eval'"
- "existing assertions still forbid 'unsafe-inline' and 'unsafe-eval'"
determinism_controls:
- "pure static parse of a committed HTML file; no clock, network, or browser"
anti_cheat_rationale:
prevents:
- "silently relaxing script-src to make sign-in work"
- "dropping the keyword and shipping a login page that cannot run Argon2id"
relaxation:
did_relax_any_assertion: false
if_true_explain_spec_basis: ""
The 2026-06-25 audit blamed "logout logs out everyone" / broken profile
switching / unknown-user-after-refresh on the single shared nip46Client and
single-slot session storage. Re-auditing the current code shows every named
suspect has since been fixed piecemeal: per-account v2 session map with v1
last-connected default, keepStored logout, scoped no-arg clear (v1 slot only),
reuseStored+expectPubkey switch wired end-to-end into
useStoredRemoteSigner({pubkey}), and a validator-enforced boot restore.
None of that was pinned at the manager level, so any layer could regress
silently. tests/nip46-signer-lifecycle.test.mjs now exercises the real
SignerManager against the real signer registry and real (polyfilled) storage:
- logging out the active NIP-46 account destroys its live client exactly once
while BOTH accounts' stored sessions and the other account's registry signer
survive
- a failed targeted restore (passphrase-locked or missing session) fails typed
and deletes nothing
- disconnectRemoteSigner with and without keepStored never touches the
per-account map
Mutation-checked: reinjecting the historical bugs (logout without keepStored;
no-arg clear wiping the v2 map) fails 2/6; real code passes 6/6.
TODO #33 updated: remaining work is the on-phone Amber verification the item
already gates on — and if that still misbehaves, the failure is now known to
live ABOVE this contract (UI/state reconciliation on restore failure), which
narrows the search considerably.
Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01LHH3wPBsoruwCaJo8Rpf6a
CI has been failing on unstable for weeks, across four unit files and a set of
e2e specs. None of it was a product regression: every failure is a test that
encodes behavior a later commit deliberately changed. Each is corrected against
the spec that superseded it, not adjusted until it passed.
Unit:
- video-modal-moderation: the fixture set flat `blurThumbnail`/`trustedMuteCount`,
but normalizeVideoModerationContext derives shouldShow from
`original.blurThumbnail`/`trustedCount`, so the assertion ran against a
context whose early return had just deleted the blur it was checking. 4525c1c6
also made a lone trusted mute a non-blocking ranking signal (its sibling test
was renamed to say exactly that; this one was missed). Fixture now mirrors
what moderationDecorator emits.
- trustBootstrap: asserted a subscription to onWhitelistChange that 18e08bf2
deliberately removed -- buildTrustedSeeds derives seeds from the super admin,
editors and fallback seeds, so a whitelist change cannot alter them. The
removal is now pinned so it is not reinstated by reflex.
- moderation-service: 18e08bf2 removed `status.whitelisted ||` from
isTrustedReporter (being allowed to publish is not a statement that your
reports carry weight). A whitelisted reporter now counts toward the report
total but not toward trusted.
- playbackService_forcedSource: forced P2P now falls back to a hosted URL when
the torrent definitively fails -- playbackService documents the change and its
rollback. The old assertion did not merely fail, it HUNG: forced sources skip
the start timeout, so the now-reachable hosted attempt waited forever on a
<video> that never emits.
E2E:
- ui-modals: asserted exactly one `.bv-modal` per component page, but
profile-modal.html also bundles #storageCorsModal (d7ad1516). Requires at
least one and drives the primary, which is what the test's own injected
querySelector already used.
- coverage-utility-modules: used `author:abc123`, which 9afbc0cb correctly began
rejecting as undecodable. Uses a real pubkey for the success path and pins the
rejection separately.
- webseed-playback: asserted the FINAL src equals the CDN URL while also
tolerating "No playable source found" in its status check -- contradictory,
since a failed attempt calls resetVideoElement() and clears src. Headless CI
cannot decode the archive.org H.264 sample. Now records src attachments and
asserts the URL was attached, which is the behavior under test.
- nostr-runtime-coverage: asserted a legacy plaintext session-actor entry reads
back as null. Since 8692dac6 added per-account storage, readV1Entry PURGES the
plaintext entry and the reader falls back to the sole remaining encrypted
account -- so null no longer describes the security property. Asserts the
property directly: no plaintext key survives, in the entry or in raw storage.
Also drops the syncAuthUiState share-gate wiring added in d141c316. It was
redundant: Application.updateShareNostrAuthState() already fires on init, login,
logout, pubkey-change and signer-change and forwards to
videoModal.setShareNostrAuthState(). That call short-circuited on its own
`if (!this.videoModal?.setShareNostrAuthState) return;` guard purely because the
method did not exist -- adding it (d141c316) was the whole fix. The tests now
pin that contract instead of a second, competing code path.
All 416 unit files, all three CI shards, both DM suites and lint are green.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
buildShareUrlFromEventId encoded a BARE event id: no author, no relay hints. A
viewer whose relay set did not happen to carry that event could not resolve it,
so bitvid fell back to the home feed -- the "the shared link just opens
bitvid.network" report.
It is not a rare edge case. The reported video was present on nos.lol but
absent from relay.damus.io, relay.snort.social and relay.primal.net -- three of
bitvid's four bootstrap relays -- so most viewers arriving cold could not open
it at all.
The pointer now carries:
- the video's author, pulled from videosMap, which also lets clients look the
event up by author+kind if the id itself has aged out of a relay
- up to three relay hints from the client's write relays (falling back to its
general relay list), deduped and scheme-filtered
Both are best-effort: an unknown video or a malformed pubkey still produces the
old bare-id pointer rather than an invalid one, and no relays configured omits
the hint instead of encoding an empty array.
Also restores the path separator. The base URL has its trailing slash stripped,
so shared links read `https://bitvid.network?v=...`. That is legal and browsers
normalize it, but it looks like a typo in a note and some link parsers
mis-detect where the URL ends.
New tests fail 6/9 without the change.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
On mobile a bitvid share link opens with no user gesture, so the browser
refuses autoplay. bitvid handled that correctly -- it showed "Press play to
start the hosted video." and armed a no-stall watchdog to wait -- but the 3s
start timeout kept running underneath and fired anyway. For a video with both a
hosted URL and a magnet that meant:
Press play to start the hosted video.
Switching to WebTorrent...
The element was reset out from under the prompt the viewer was about to act on,
and the swarm (often seedless, the webseed being the very URL we just
abandoned) played nothing. Desktop usually hides this because an established
Media Engagement score lets autoplay through, so the same link plays there.
A blocked autoplay is not a stalled source. Suspend the start timeout while we
are waiting on a user gesture and let the attempt settle on its own -- it
resolves when they press play, or when a real media error or stall fires. The
guard is cleared on the `play` gesture so a genuine stall afterwards still
fails over normally.
Reproduced on an emulated phone against the reported video
(v.nostr.build/1eNLjw5el05Uc3KU.mp4, magnet with a webseed): before, statuses
end "Press play..." then "Switching to WebTorrent..." with the P2P path
entered; after, they end at "Press play..." and playback starts on tap. The new
tests fail 2/4 without the guard.
Note for future test authors: createSession() overwrites any
playbackStartTimeout passed in its options with the service's value, so a
per-session override is silently ignored -- set it on the PlaybackService
constructor or the test races the real 3s default and proves nothing.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
The share menu's "Share on Nostr" entry was never clickable, no matter who was
logged in -- it rendered as inert, dimmed text.
VideoModal initialized `shareNostrAuthState` to { isLoggedIn: false, hasSigner:
false } and nothing ever wrote to it. createVideoShareMenuPanel gates the item
on `isLoggedIn && hasSigner`, so it was always disabled, and the click
delegation in bindShareMenuActions() bails on `button.disabled` -- so the whole
share-to-Nostr flow behind it was unreachable.
The renderer and the publish path were both already correct; only the gate was
missing:
- VideoModal.setShareNostrAuthState() records the state and closes an already
open share menu when it changes, so a menu opened before login does not keep
showing the stale disabled item. It is a no-op when nothing changed, to avoid
slamming the menu shut on every sync.
- syncAuthUiState() computes the gate from isUserLoggedIn() plus a
signing-capable active signer, mirroring ShareNostrController.handleShare():
a signer without signEvent would only produce an error toast on click.
- syncAuthUiState() was only ever called at bootstrap, so logging in
mid-session left the gate closed. It now self-installs a bitvid:auth-changed
listener on first run (bound once, re-entrancy safe) rather than adding a
call site in js/app.js, which is at its file-size cap.
Verified in a browser that the rendered item is disabled when logged out or
signer-less and enabled + click-dispatching with both; the new tests fail 5/9
with the gate push removed.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
index.html carries one set of site-wide OG tags, so every /?v=<nevent> link
previewed as the same generic bitvid card -- never the video's title or
thumbnail. bitvid is a static SPA and crawlers do not run JS, so the client
cannot fix this.
api/og.js renders the tags on demand. Nothing is pre-generated and nothing is
stored: the video is a query param, not a path, so one stateless function
serves every video and dist/ is byte-identical with or without it. The only
caching is the CDN edge (s-maxage=300) plus a per-container memo of the admin
lists.
This is never load-bearing. Only crawler user-agents are rewritten to it
(vercel.json), humans get a 302 to the untouched SPA, and a static/nsite deploy
with no functions keeps working -- it just serves the site-wide card. Rollback
is deleting the `rewrites` block.
- Emits twitter:player / og:video:url pointing at embed.html?pointer=<nevent>,
so a shared link plays inline on Twitter/Discord/Telegram rather than showing
a still. frame-ancestors * already permits the embed.
- Fails closed. A crawler never authenticates, so anything emitted is public
forever in third-party caches: deleted, isPrivate, isNsfw/content-warning,
blacklisted authors, and -- while whitelist mode is on -- an unavailable or
empty whitelist all fall back to the generic card.
- Escapes every attribute. Titles come from arbitrary nostr events and this HTML
is served from bitvid's own origin; only http(s) URLs reach a meta tag.
- Parses BOTH event shapes: bitvid's kind-30078 v3 JSON and the NIP-71 tag shape
(kinds 21/22/34235/34236) that nostube/slidestr publish, where the title is a
`title` tag and the thumbnail an `imeta image` entry. Reading only the JSON
shape made every NIP-71 video fall back to the generic card.
- Relay budget is 4s: a cold pool's first read measured ~3s, and 1.5s silently
degraded every preview. Warm containers answer in <600ms.
Admin-list identity and the relay set are imported from js/config.js,
js/nostr/toolkit.js and js/nostrEventSchemas.js rather than duplicated -- a
hardcoded copy of the namespace gated every preview during development, and a
test now fails if any npub or wss:// literal reappears in the handler.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
"Share on Nostr" published a kind-1 note whose body ended with a bare thumbnail
URL. Many clients only inline such a URL when a NIP-92 `imeta` entry describes
it, so the viewer often saw a plain link instead of a preview image.
- buildShareEvent now emits an `imeta` entry for the thumbnail. The media type
is inferred from the extension and OMITTED when unknown -- a wrong `m` is
worse than a missing one, since clients use it to decide whether to render
inline at all, so extensionless Blossom URLs ship url + alt only.
- inferImageMimeTypeFromUrl lives in a SEPARATE map from EXTENSION_MIME_MAP:
that map gates video upload/publish paths, and folding image types into it
would let an image pass a video check.
- shareNostrController was passing only { id, pubkey } to buildShareEvent, so
the tag could never be built; it now forwards title and thumbnail.
When a video has a NIP-71 mirror, the note instead quotes it with
`nostr:naddr1...`, which nostr clients render as a native video quote card --
the closest thing to a rich preview that does not depend on any client fetching
OpenGraph tags (most never do).
- The mirror is addressable (kind 34235/34236, author = video pubkey,
d = videoRootId), so js/nostr/mirrorPointer.js derives the coordinate with no
relay lookup, and the pointer survives the author editing the video where a
plain `nevent` would rot on the next replace.
- The pointer is only embedded after nip71MirrorService.findMirror() confirms
the mirror exists. A pointer to a missing event renders as a broken/empty
quote box, which is worse than the plain-link fallback, so a disabled
service, a throwing lookup, a 2.5s timeout or a missing videoRootId all fall
back to the thumbnail note.
- resolveMirrorKind prefers the kinds actually observed on relays and only then
falls back to the same portrait rule the mirror builder uses, so a derived
pointer matches what would be published rather than guessing independently.
- While quoting, the raw thumbnail URL is dropped from the body and the imeta
tag is skipped: the quote card already shows the thumbnail, and emitting both
rendered the same image twice. An `a` tag links the note to the coordinate.
Known limitation: a client rendering the quote card still has to play the
video, so mirrors whose imeta url is an .m3u8 hit the same HLS wall -- title and
thumbnail render, playback depends on the client.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
bitvid attached every hosted source with `videoEl.src = url`, which only plays
HLS on Safari-family browsers. Firefox 146 fails with MEDIA_ERR_SRC_NOT_SUPPORTED
(canPlayType returns ""), so every .m3u8 note -- e.g. anything published by
nostube/slidestr -- was unplayable there and the URL-health badge showed
"offline". Chrome/Brave 149 happen to play HLS natively, which is why this
looked browser-specific.
Vendor hls.js (npm run build:hls) and route HLS sources through it:
- js/services/hlsPlayback.js owns detection, engine choice and the hls.js
lifecycle. Engine choice keys off MediaSource, NOT canPlayType: Chrome
returns "maybe" for every HLS type, so a native-first branch would hang.
Native is used only when MSE is genuinely unavailable.
- The bundle is the FULL build, not hls.js/light -- light drops the alternate
audio stream controller and real ladders demux audio into an EXT-X-MEDIA
group. It is lazy-imported only when a .m3u8 is encountered, so ordinary
MP4/WebTorrent playback never pays the ~500KB.
- modalCoordinator tears the instance down first; clearing `src` alone leaves
the transmuxing worker and segment fetches running after the modal closes.
- urlHealthController probes HLS by fetching the playlist for #EXTM3U. The old
bare-<video> probe could not load it and on Chrome emitted no events at all,
burning the full timeout on every HLS card.
- HLS_PLAYBACK_START_TIMEOUT widens the 3s start window, which fired
mid-startup: the bundle, master playlist, variant playlist, init segment and
first media segment are four-plus serial round trips.
Also fixes two bugs found while verifying this:
- A stale MediaError was reported as the current failure. Teardown clears `src`
and calls load(), leaving MEDIA_ERR_SRC_NOT_SUPPORTED ("Empty src attribute")
on the element; a later watchdog fallback rendered that as "Hosted playback
failed: source not supported or blocked." over a stream that was playing
fine. Only a MediaError produced by the current attempt is surfaced now.
Sources are also bound before the watchdogs are armed, so hls.js's own
attachMedia() src swap cannot read as a playback failure.
- `audio/x-mpegurl` is a legacy alias for an HLS playlist of any kind, not an
audio-only assertion. isAudioOnlyVideoObject misfiled HLS video ladders
published with it as podcasts and dropped them from every video feed.
Verified end-to-end in Chromium, Chrome 149, Brave 149 and Firefox 146 against
a live HLS stream: Firefox goes from "No playable source found." to playing.
Rollback: stop calling attachHlsSource() and the previous `src` assignment
returns; see docs/playback-fallback.md.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
### Motivation
- Prevent a browser-policy muted-autoplay retry from overwriting a viewer's explicit mute/unmute preference and ensure fallback mutes are transient.
- Ensure that when a user logs in while a video comment modal is open, the open thread is reloaded (not just refreshed) so comments continue to display correctly after relay-backed services are reconfigured.
### Description
- Add `js/utils/autoplayPreference.js` with `muteForAutoplayFallback` and `consumeAutoplayFallbackMute` helpers to mark and consume transient fallback mutes.
- Update `js/services/playbackService.js` to treat sound as default unless the viewer explicitly chose mute, persist explicit choices via `localStorage`, ignore fallback-generated volume changes using `consumeAutoplayFallbackMute`, and expose `muteForAutoplayFallback` delegating to the new util.
- Replace direct `videoElement.muted = true` calls with `playbackService.muteForAutoplayFallback` in `js/app/playbackCoordinator.js` and `js/webtorrent.js` so fallback mutes are marked as transient.
- Change `js/app/authSessionCoordinator.js` to reload the currently open video comment thread with `commentController.load(activeModalVideo)` after login if a modal video is active, instead of only calling `refreshAuthState`.
- Update tests in `tests/services/playbackService.test.mjs` and add a test in `tests/app/login-defers-dms.test.mjs` to cover the login behavior that reloads an open comment thread.
### Testing
- Ran the `PlaybackService` unit tests (`tests/services/playbackService.test.mjs`) which assert `prepareVideoElement` behavior, explicit mute persistence, and the transient nature of fallback mute, and they passed.
- Ran the auth login unit tests including `tests/app/login-defers-dms.test.mjs` which verifies DM deferral and reload of an open comment thread, and they passed.
(cherry picked from commit ce14004725)
bitlogin's widget now implements NIP-04 (nip04Encrypt/nip04Decrypt),
closing the one gap left relative to a real NIP-07 extension. bitvid's
own nip07Adapter/nsecAdapter/nip46Adapter all already wire this up and
report it via signerCapabilities.nip04 -- BitLogin's adapter was the
one exception, hardcoded to nip04: false because the widget genuinely
didn't support it before. js/nostr/client.js's DM encrypt/decrypt path
only offers NIP-04 to a caller when that capability flag is true, so a
BitLogin-signed-in user previously had no working legacy-DM path at
all. Re-vendored the widget bundle (bitlogin's CHANGELOG.md, 2026-07-21
entry, third one) and updated the one test that hardcoded the old
(now-incorrect) capabilities shape.
Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01DY9UMTuF43Ekc2U4E1eDwX
The login modal now shows <bitlogin-auth> -- a portable Nostr identity
behind a login name and password -- as the default sign-in path,
styled with bitvid's own brand tokens (accent-strong, surfaces, radii).
The existing NIP-07 extension, nsec, and NIP-46 remote-signer options
still work exactly as before, just collapsed under a new "Other login
options" fold instead of a flat "Available methods" grid; nip07 no
longer carries the stale "Recommended" eyebrow now that BitLogin has
that role.
BitLogin isn't a single button-click-then-await-a-promise flow like
the other providers -- it's a whole multi-screen sign-in/create/recover
UI -- so it gets a permanent, statically-declared widget in
components/login-modal.html (js/ui/bitloginModalIntegration.js relocates
it, never destroys and recreates it, since removal tears down its crypto
Web Worker) instead of a grid button. It still goes through the exact
same session-establishment path as every other signer: a new "bitlogin"
provider (js/services/authProviders/bitlogin.js) lets AuthService
resolve and activate it via the normal requestLogin -> handleAuthLogin
path, while providersForModal excludes it from the grid so it isn't
rendered twice.
Two things this needed beyond the wiring itself:
- js/app.js is one line from its lint/file-size ceiling, so the only
change there is renaming an import binding (providers -> providersForModal),
a net-zero line change. The actual widget/event wiring lives in a new,
unconstrained module instead.
- The login modal's HTML (including the permanent widget element) loads
asynchronously after bootstrap runs, so wireBitloginLogin() waits via
MutationObserver rather than assuming a specific call order.
Vendored via scripts/build-bitlogin-widget.mjs (mirrors the existing
Bitcoin Connect/Blossom vendor steps): BitLogin isn't published to npm,
so the widget build is copied verbatim from the sibling ../bitlogin
checkout into committed vendor/bitlogin/, then lazy-imported (like
Bitcoin Connect) so its ~350KB never weighs on visitors who never open
the login modal.
Gated behind FEATURE_BITLOGIN (config/instance-config.js), default on
for this unstable-branch experiment per AGENTS.md §1 -- reset to false
before this reaches main until verified end-to-end against production
relays.
Verified: npm run lint (clean), the full auth/login test suite (43/43,
including new coverage for the provider + adapter), and a manual
Chrome pass against the built dist/ -- widget renders themed correctly,
"Other login options" still shows the original four methods unchanged.
- deleteByUrl maps a Blossom URL back to its content-addressed blob (blobSha256FromUrl) and deletes it via BUD-12, defaulting to the URL's own origin. Wired into the edit-modal orphan cleanup so a superseded Blossom video/thumbnail is reclaimed (best-effort, never blocks the edit).
- BUD-03 kind-10063 server-list discovery: read (serversFromServerListEvent) + build (buildBlossomServerListEvent) + an extracted, testable import/publish helper (blossomServerList.js), plus 'Import from my server list' / 'Publish my server list' controls in the Storage pane's Blossom section.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
importAccountRecord hard-required encryptedMasterKey, but a Blossom-only account is keyless (no encrypted payloads) and legitimately has none — so its synced record failed to import. Require the envelope ONLY when the record carries an encrypted connection that actually needs it to unlock.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
storageService now emits onConnectionsChanged (from importAccountRecord, the sync-import path); the upload modal subscribes and, when open + active user, re-derives its storage destination in place (re-attempt auto-unlock, recompute unlocked state, reload, repaint) — so credentials synced from Nostr are reflected without reopening. Bumped UploadModal's file-size baseline for the growth.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Blossom servers reject a .torrent (415), so a Blossom video's magnet is webseed-only and can't bootstrap WebTorrent. This restores P2P without hosting a .torrent:
- Upload hardening: correct Content-Type (overrides empty/generic application/octet-stream), HTTP status surfaced in errors, and best-effort .torrent sidecar so a rejected sidecar no longer sinks the upload (URL-first fallback).
- Publish: when the .torrent can't be hosted, publish the piece-map as an infohash-keyed companion event (kind 30078, d=bitvid:torrent:<infohash>) and ship a ws= magnet. Gated by FEATURE_BLOSSOM_TORRENT_METADATA (default off = no trace); size-capped.
- Playback: torrentMetadataService lazily fetches the companion, VERIFIES the reconstructed infohash === the magnet's btih (untrusted content), sources the webseed from the video event, and hands the .torrent buffer to WebTorrent so it streams with zero peers. Relay reads go through the subscription manager.
Inherited by any upload-based media (shorts, audio-publish); excludes ingest/live. See docs/blossom-torrent-metadata-plan.md. Includes a Prettier reflow in css/tailwind.source.css.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
fillStorageForm read the provider only from conn.provider (top-level), but getConnection returns { ...payload, meta } with the provider in meta (and no top-level provider for keyless Blossom) — so the selector always fell back to cloudflare_r2. Mirror connectionProvider()'s conn.provider || conn.meta?.provider fallback so the pane reflects the loaded default connection.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Real-device failure: multiServerUpload uploads to one server then has the OTHERS
fetch it server-to-server via PUT /mirror — those /mirror requests fail from a
browser (net::ERR_FAILED / CORS), so the whole upload returned no blob URL
("Blossom upload did not return a usable blob URL").
Switch blossomService.uploadFile to upload DIRECTLY to each server (BUD-02
PUT /upload) in parallel via uploadBlob, resilient: first success wins, per-server
failures are collected, and if ALL fail the error names each server + the likely
cause (CORS / size / payment) and suggests blossom.band. A fresh signed upload
auth (BUD-11) is created per request.
Tests: blossom-service 11/11 (added partial-failure fallback + all-fail error).
Lint + build clean. Local only.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
The management primitives, self-contained + unit-tested. Not yet wired into the
S3-shaped orphan-GC / delete-flow (that reconcile system assumes bucket/key
semantics — its own browser-tested increment, same pattern as the upload).
- Vendor entry: export createDeleteAuth (+ rebuilt bundle, still ~27KB).
- blossomService.deleteFile({ server, sha256, signer }) — BUD-12 DELETE /<sha256>
with a signed kind-24242 delete auth (BUD-11).
- blossomService.listFiles({ server, pubkey, signer }) — BUD-12 GET /list/<pubkey>,
optional list auth.
Tests: blossom-service 9/9 (added delete/list guards + happy paths). Lint + build
clean.
REMAINING Phase 2: wire deleteFile into video-delete cleanup (resolve
server+sha256 from the blossom storagePointer), orphan-GC parity, and the
kind-10063 discovery UI (import/publish server list — needs relay fetch/publish
plumbing).
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Makes Blossom configurable + uploadable end-to-end (local testing).
- Storage pane: a "Blossom (Nostr-native)" provider option (revealed only when the
flag is on) that swaps the S3 field group (#storageS3Fields) for a keyless
#storageBlossomSection — a multi-server textarea (one URL per line). No
access-key/secret/bucket/CORS.
- ProfileStorageController: parseBlossomServers(); save branch persists the server
list in the PLAINTEXT connection meta ({servers}, no encrypted secret) via the
per-provider slot; provider-change loads servers from meta WITHOUT an unlock;
Test does a best-effort reachability ping; visibility branch hides S3 fields +
CORS helper for Blossom.
- mediaUploader.resolveActiveConnection: Blossom connections resolve from plaintext
meta with no unlock (uploads authorize via the Nostr signer, not a stored key),
so credentials = { provider, servers, meta } flow straight into the Phase-1a
engine.
- instance-config: FEATURE_BLOSSOM_STORAGE = true for LOCAL testing (reset to false
before pushing).
Tests: blossom-service 7/7 (isAvailable now flag-relative). Lint + build clean.
Blossom UI present in dist.
VERIFY locally: Storage → Provider → Blossom → add a server → Save → upload a
video → it plays from <server>/<sha256>. Watch the UploadModal's own storage
gating (may need a tweak for the no-secret/no-PIN Blossom case).
Co-Authored-By: Claude Opus 4.8 <[email protected]>