feat(storage): Blossom Phase 1a — upload engine [LOCAL] (#30)

The Blossom upload orchestration, isolated + unit-tested. Only routes when a
connection's provider is "blossom", so the S3/R2 path is completely unaffected
(that branch can't trigger until the Phase-1b config UI can create a Blossom
connection).

- blossomService now owns the orchestration (WebTorrent-free, so it's Node-
  testable): uploadFile (multiServerUpload + BUD-11 auth) and uploadVideo (blob →
  injected torrent generator → upload .torrent as a 2nd blob → magnet with ws=the
  Blossom video url + xs=the Blossom .torrent url → blossom storagePointer). Same
  result shape as the S3 path, so publish is untouched. Exports isBlossomProvider
  + resolveBlossomServers.
- mediaUploader: thin Blossom branch at the top of uploadThumbnail/uploadVideo
  (delegates to blossomService, injecting generateTorrentMetadata so the service
  never imports WebTorrent); new getSigner dep. UploadModal + EditModal pass
  getSigner: () => getActiveSigner().

Tests: tests/blossom-service.test.mjs (7 — guards, mirror, torrent/magnet
orchestration, storagePointer). Lint + build clean. Flag still off.

NEXT (Phase 1b): the keyless multi-server Storage-pane config UI + connection
model (servers in plaintext connection meta — no secret) + resolveActiveConnection
Blossom branch, so a server can be configured and uploads run end-to-end.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
This commit is contained in:
thePR0M3TH3ANandClaude Opus 4.8 committed 2026-07-09 15:56:05 -04:00
1 parent 02e60f1842
commit 0eafd38ec4
5 files changed
+287 -77

No files matched your search

+103 -40
View File
@@ -1,33 +1,42 @@
// Blossom (nostr-native blob storage) upload service — Phase 0 skeleton.
// Blossom (nostr-native blob storage) upload service.
//
// Implements the same `uploadFile({…}) → { url, key, … }` contract as
// s3UploadService / r2Service (see js/ui/components/mediaUploader.js), so a later
// `isBlossomProvider` branch can route to it with no change downstream. Auth is a
// signed kind-24242 nostr event (BUD-11) built from bitvid's existing signer — no
// 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).
//
// Phase 0: the vendored SDK loader, availability gate, and the core upload path
// exist but nothing is wired into mediaUploader yet, and the flag is off by
// default. Phase 1 completes torrent parity (magnet/infoHash), the result-shape
// mapping, and the mediaUploader routing + Storage-pane config. See
// docs/blossom-plan.md.
// See docs/blossom-plan.md / TODO #30.
import { FEATURE_BLOSSOM_STORAGE } from "../constants.js";
import { userLogger } from "../utils/logger.js";
import { buildStoragePointerValue } from "../utils/storagePointer.js";
// 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";
function normalizeServerList(servers) {
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 Array.isArray(servers) ? servers : []) {
for (const entry of raw) {
const url = typeof entry === "string" ? entry.trim() : "";
if (!url || seen.has(url)) {
continue;
if (url && !seen.has(url)) {
seen.add(url);
out.push(url);
}
seen.add(url);
out.push(url);
}
return out;
}
@@ -38,13 +47,11 @@ export class BlossomService {
this._sdkPromise = null;
}
// On/off gate — off ⇒ the SDK is never imported and callers should not route
// uploads here.
// On/off gate — off ⇒ the SDK is never imported.
isAvailable() {
return FEATURE_BLOSSOM_STORAGE === true;
}
// Lazy-load the vendored SDK once.
async loadSdk() {
if (this._sdkPromise) {
return this._sdkPromise;
@@ -57,25 +64,14 @@ export class BlossomService {
}
/**
* Upload a file to one or more Blossom servers, mirroring for resilience.
*
* @param {object} params
* @param {File|Blob} params.file - the blob to upload.
* @param {string[]} params.servers - Blossom server base URLs (first = primary).
* @param {(draft: object) => Promise<object>} params.signer - bitvid's active
* signer (BUD-11 kind-24242 auth). Same shape the app already uses.
* @param {string} [params.type] - the auth `t` verb (default "upload").
* @param {(pct:number)=>void} [params.onProgress]
* @returns {Promise<{ url: string, key: string, servers: string[], descriptors: object[] }>}
*
* NOTE (Phase 1): torrent parity (magnet/infoHash), storagePointer, and the full
* mediaUploader result shape are added when this is wired into the upload flow.
* 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 = normalizeServerList(servers);
const serverList = resolveBlossomServers({ servers });
if (serverList.length === 0) {
throw new Error("Blossom upload requires at least one server URL.");
}
@@ -95,13 +91,10 @@ export class BlossomService {
// One auth per action, scoped to the blob's sha256; the SDK reuses it across
// the mirror set so a multi-server upload isn't N signer prompts.
const results = await multiServerUpload(serverList, file, {
onAuth: (server, sha256) =>
createUploadAuth(signer, sha256, { type }),
onProgress:
typeof onProgress === "function" ? onProgress : undefined,
onAuth: (server, sha256) => createUploadAuth(signer, sha256, { type }),
onProgress: typeof onProgress === "function" ? onProgress : undefined,
});
// results: Map<serverUrl, BlobDescriptor{ url, sha256, size, type, … }>
const descriptors = Array.from(results?.values?.() || []);
const primary =
descriptors.find((d) => typeof d?.url === "string" && d.url) || null;
@@ -116,6 +109,76 @@ export class BlossomService {
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]
*/
async uploadVideo({ file, servers, signer, generateTorrent, onProgress } = {}) {
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 torrentUploaded = await this.uploadFile({
file: torrent.torrentFile,
servers: serverList,
signer,
type: "upload",
});
const name = typeof file?.name === "string" ? file.name : "video";
result.infoHash = torrent.infoHash;
result.magnet = `magnet:?xt=urn:btih:${torrent.infoHash}&dn=${encodeURIComponent(
name,
)}&ws=${encodeURIComponent(videoPublicUrl)}&xs=${encodeURIComponent(
torrentUploaded.url,
)}`;
result.torrentUrl = torrentUploaded.url;
result.torrentFile = torrent.torrentFile;
result.hasValidInfoHash = true;
}
return result;
}
}
const blossomService = new BlossomService();
+2
View File
@@ -9,6 +9,7 @@ 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 { devLogger } from "../../utils/logger.js";
export class EditModal {
@@ -51,6 +52,7 @@ export class EditModal {
this.getCurrentPubkey ? this.getCurrentPubkey() : null,
safeEncodeNpub:
typeof safeEncodeNpub === "function" ? safeEncodeNpub : (p) => p,
getSigner: () => getActiveSigner(),
});
this.getMode =
typeof getMode === "function"
+1
View File
@@ -96,6 +96,7 @@ export class UploadModal {
getCurrentPubkey: () =>
this.getCurrentPubkey ? this.getCurrentPubkey() : null,
safeEncodeNpub: (pubkey) => this.safeEncodeNpub(pubkey),
getSigner: () => getActiveSigner(),
});
this.eventTarget =
eventTarget instanceof EventTarget ? eventTarget : new EventTarget();
+78 -1
View File
@@ -24,6 +24,10 @@ import {
buildStoragePointerValue,
buildStoragePrefixFromKey,
} from "../../utils/storagePointer.js";
import blossomServiceDefault, {
isBlossomProvider,
resolveBlossomServers,
} from "../../services/blossomService.js";
const INFO_HASH_PATTERN = /^[a-f0-9]{40}$/;
@@ -46,6 +50,8 @@ export class MediaUploader {
storageService,
getCurrentPubkey,
safeEncodeNpub,
blossomService,
getSigner,
} = {}) {
this.r2Service = r2Service || null;
this.s3Service = s3Service || null;
@@ -54,6 +60,10 @@ 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;
}
serviceFor(provider) {
@@ -120,6 +130,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 +227,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 +329,67 @@ 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 };
}
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),
});
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 +477,4 @@ export class MediaUploader {
}
}
export { isR2Provider, normalizeInfoHash, isValidInfoHash };
export { isR2Provider, isBlossomProvider, normalizeInfoHash, isValidInfoHash };
+103 -36
View File
@@ -1,55 +1,122 @@
// Phase 0 skeleton tests for blossomService: the availability gate and the
// input guards run without loading the vendored SDK (they throw first). Upload
// round-trips against a real Blossom server are exercised in Phase 1. See
// docs/blossom-plan.md / TODO #30.
// 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 } from "../js/services/blossomService.js";
import blossomService, {
BlossomService,
BLOSSOM_PROVIDER,
isBlossomProvider,
resolveBlossomServers,
} from "../js/services/blossomService.js";
test("isAvailable reflects the FEATURE_BLOSSOM_STORAGE flag (off by default)", () => {
const SHA = "a".repeat(64);
const signer = async (draft) => ({ ...draft, id: "id", sig: "sig", pubkey: "pk" });
// A BlossomService whose SDK is mocked: multiServerUpload returns a descriptor
// map keyed by server, echoing the file so we can assert which blob was sent.
function mockService() {
const uploads = [];
const svc = new BlossomService();
svc.loadSdk = async () => ({
createUploadAuth: async (s, sha256, opts) => ({ kind: 24242, sha256, opts }),
multiServerUpload: async (servers, file, opts) => {
uploads.push({ servers: [...servers], file });
// 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(servers[0], sha);
const map = new Map();
for (const server of servers) {
map.set(server, { url: `${server}/${sha}`, sha256: sha, size: 1, type: "x" });
}
return map;
},
});
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 flag (off by default)", () => {
assert.equal(blossomService.isAvailable(), false);
});
test("uploadFile rejects a missing file before touching the SDK", async () => {
const svc = new BlossomService();
await assert.rejects(
() => svc.uploadFile({ servers: ["https://blossom.example"], signer: async () => ({}) }),
/requires a file/,
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 rejects an empty server list", async () => {
const svc = new BlossomService();
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: async () => ({}) }),
() => svc.uploadFile({ file: new Blob(["x"]), servers: [], signer }),
/at least one server/,
);
});
test("uploadFile rejects a non-function signer", async () => {
const svc = new BlossomService();
await assert.rejects(
() =>
svc.uploadFile({
file: new Blob(["x"]),
servers: ["https://blossom.example"],
signer: null,
}),
() => svc.uploadFile({ file: new Blob(["x"]), servers: ["https://s"], signer: null }),
/requires a signer/,
);
});
test("uploadFile de-dupes and trims the server list before requiring one", async () => {
const svc = new BlossomService();
// All-blank/dup servers collapse to empty → the "at least one server" guard.
await assert.rejects(
() =>
svc.uploadFile({
file: new Blob(["x"]),
servers: [" ", "", " "],
signer: async () => ({}),
}),
/at least one server/,
);
test("uploadFile returns the primary descriptor url + sha256 key, mirrored to all servers", 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[0].servers, ["https://a", "https://b"], "mirrored to both");
});
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)}`);
});