feat(blossom): Phase 2 management — blob delete-on-orphan + kind-10063 server list (#30)

- 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]>
This commit is contained in:
thePR0M3TH3ANandClaude Opus 4.8 committed 2026-07-12 10:32:22 -04:00
1 parent 51072e4871
commit e81e65ff40
9 files changed
+515

No files matched your search

+10
View File
@@ -1027,6 +1027,16 @@
<textarea id="storageBlossomServers" rows="3" class="input" placeholder="https://blossom.example.com
https://another.blossom.example"></textarea>
</label>
<!-- BUD-03 kind-10063 discovery: import the list you've published, or publish the list above. -->
<div class="flex flex-wrap gap-2">
<button id="storageBlossomImportBtn" type="button" class="btn-ghost focus-ring text-xs">
Import from my server list
</button>
<button id="storageBlossomPublishBtn" type="button" class="btn-ghost focus-ring text-xs">
Publish my server list
</button>
</div>
<p id="storageBlossomServerListStatus" class="text-xs text-muted" role="status"></p>
</div>
<!-- S3-family fields (R2 / B2 / Custom S3). Hidden for Blossom. -->
+38
View File
@@ -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', <url>]`
* tag per server, in preference order, de-duped.
*
* @param {Object} params
* @param {string} params.pubkey
* @param {number} params.created_at
* @param {string[]} params.servers - Ordered server URLs.
* @returns {Object} Unsigned event.
*/
export function buildBlossomServerListEvent(params) {
const { pubkey, created_at, servers } = params || {};
const seen = new Set();
const tags = [];
if (Array.isArray(servers)) {
for (const entry of servers) {
const url = typeof entry === "string" ? entry.trim() : "";
if (url && !seen.has(url)) {
seen.add(url);
tags.push(["server", url]);
}
}
}
tags.push(["client", "bitvid"]);
return {
kind: BLOSSOM_SERVER_LIST_KIND,
pubkey,
created_at,
tags,
content: "",
};
}
/**
* Builds a Zap Request event (Kind 9734).
* Used to request an invoice from a Lightning Service Provider (LSP).
+63
View File
@@ -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://<host>/<sha256>.mp4` → `<sha256>`). Returns "" when
// the URL doesn't point at a Blossom-style content-addressed blob.
export function blobSha256FromUrl(url) {
const raw = typeof url === "string" ? url.trim() : "";
if (!raw) return "";
try {
const path = new URL(raw).pathname;
const last = path.split("/").pop() || "";
const hex = last.split(".")[0].toLowerCase();
return /^[0-9a-f]{64}$/.test(hex) ? hex : "";
} catch {
return "";
}
}
// Read the server URLs from a user's kind-10063 Blossom server-list event (BUD-03):
// one `["server", <url>]` tag per server, in preference order. De-duped + trimmed.
export function serversFromServerListEvent(event) {
const tags = Array.isArray(event?.tags) ? event.tags : [];
const urls = tags
.filter((t) => Array.isArray(t) && t[0] === "server" && typeof t[1] === "string")
.map((t) => t[1]);
return resolveBlossomServers({ servers: urls });
}
export class BlossomService {
constructor(deps = {}) {
this.deps = deps;
@@ -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/<pubkey>`). Some
* servers require a signed list auth; provide the signer to cover those.
+24
View File
@@ -1,5 +1,9 @@
import { devLogger } from "../utils/logger.js";
import r2Service from "../services/r2Service.js";
import blossomService, {
blobSha256FromUrl,
} from "../services/blossomService.js";
import { getActiveSigner } from "../nostrClientRegistry.js";
import { readRelayPublishSummary } from "../nostrPublish.js";
export default class EditModalController {
@@ -125,6 +129,26 @@ export default class EditModalController {
`[edit] Removed ${cleanup.deleted.length} superseded storage object(s).`
);
}
// Blossom orphans are content-addressed blobs, not R2/S3 keys — delete
// them via BUD-12 (best-effort, from each blob URL's own origin).
const blossomUrls = orphans
.map((o) => o.url || o.thumbnail || "")
.filter((u) => blobSha256FromUrl(u));
if (blossomUrls.length) {
const activeSigner = getActiveSigner();
if (activeSigner && typeof activeSigner.signEvent === "function") {
const signer = (draft) => activeSigner.signEvent(draft);
for (const url of blossomUrls) {
const res = await blossomService.deleteByUrl({ url, signer });
if (res.deleted?.length) {
devLogger.log(
`[edit] Deleted superseded Blossom blob ${res.sha256}.`
);
}
}
}
}
}
} catch (cleanupErr) {
devLogger.warn(
@@ -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.
+79
View File
@@ -0,0 +1,79 @@
// BUD-03 (kind 10063) Blossom server-list import/publish — the relay read/write
// logic, extracted from ProfileStorageController so it stays thin and this is
// unit-testable. Each function returns a { kind, message, ... } result the UI can
// render directly (never throws). See docs/blossom-plan.md (Decision 3).
import { serversFromServerListEvent } from "../../services/blossomService.js";
import {
buildBlossomServerListEvent,
BLOSSOM_SERVER_LIST_KIND,
} from "../../nostrEventSchemas.js";
function readRelaysOf(client) {
if (Array.isArray(client?.readRelays) && client.readRelays.length) {
return client.readRelays;
}
return Array.isArray(client?.relays) ? client.relays : [];
}
/**
* Fetch the newest kind-10063 server list published by `pubkey` and return its
* servers. Does NOT persist — the caller drops them into the form for Save.
*
* @returns {Promise<{kind:"success"|"error", message:string, servers?:string[], error?:unknown}>}
*/
export async function importBlossomServerList({ client, pubkey } = {}) {
if (!client || typeof client.getSubscriptionManager !== "function" || !pubkey) {
return { kind: "error", message: "Log in to import your server list." };
}
try {
const events = await client.getSubscriptionManager().list({
filters: [{ kinds: [BLOSSOM_SERVER_LIST_KIND], authors: [pubkey] }],
relays: readRelaysOf(client),
});
const newest = (Array.isArray(events) ? events : [])
.filter((e) => e && Array.isArray(e.tags))
.sort((a, b) => (b.created_at || 0) - (a.created_at || 0))[0];
const servers = newest ? serversFromServerListEvent(newest) : [];
if (!servers.length) {
return {
kind: "error",
message: "No published server list found for your account.",
};
}
return {
kind: "success",
message: `Imported ${servers.length} server(s). Click Save to use them.`,
servers,
};
} catch (error) {
return { kind: "error", message: "Import failed. Please try again.", error };
}
}
/**
* Build + sign + publish the given servers as the user's kind-10063 list.
*
* @returns {Promise<{kind:"success"|"error", message:string, error?:unknown}>}
*/
export async function publishBlossomServerList({ client, pubkey, servers } = {}) {
if (!client || typeof client.signAndPublishEvent !== "function" || !pubkey) {
return { kind: "error", message: "Log in to publish your server list." };
}
if (!Array.isArray(servers) || servers.length === 0) {
return { kind: "error", message: "Add at least one server first." };
}
try {
const event = buildBlossomServerListEvent({
pubkey,
created_at: Math.floor(Date.now() / 1000),
servers,
});
await client.signAndPublishEvent(event);
return {
kind: "success",
message: `Published ${servers.length} server(s) to your Nostr relays.`,
};
} catch (error) {
return { kind: "error", message: "Publish failed. Please try again.", error };
}
}
+111
View File
@@ -0,0 +1,111 @@
// BUD-03 kind-10063 server-list import/publish helpers (extracted from the storage
// pane). Every path returns a { kind, message, ... } result — never throws — so the
// UI can render it directly. See docs/blossom-plan.md (Decision 3).
import test from "node:test";
import assert from "node:assert/strict";
import {
importBlossomServerList,
publishBlossomServerList,
} from "../js/ui/profileModal/blossomServerList.js";
const PK = "a".repeat(64);
const listEvent = (servers, created_at = 1000) => ({
kind: 10063,
created_at,
tags: servers.map((s) => ["server", s]),
});
test("importBlossomServerList returns the NEWEST list's servers, author-scoped", async () => {
const client = {
readRelays: ["wss://r"],
getSubscriptionManager: () => ({
list: async ({ filters, relays }) => {
assert.deepEqual(filters[0].kinds, [10063]);
assert.deepEqual(filters[0].authors, [PK]);
assert.deepEqual(relays, ["wss://r"]);
return [listEvent(["https://old"], 100), listEvent(["https://a", "https://b"], 999)];
},
}),
};
const res = await importBlossomServerList({ client, pubkey: PK });
assert.equal(res.kind, "success");
assert.deepEqual(res.servers, ["https://a", "https://b"]);
});
test("importBlossomServerList: nothing published → error result (no throw)", async () => {
const client = { getSubscriptionManager: () => ({ list: async () => [] }) };
const res = await importBlossomServerList({ client, pubkey: PK });
assert.equal(res.kind, "error");
assert.match(res.message, /No published server list/);
});
test("importBlossomServerList: not logged in → error", async () => {
const res = await importBlossomServerList({ client: null, pubkey: "" });
assert.equal(res.kind, "error");
assert.match(res.message, /Log in/);
});
test("importBlossomServerList: relay error is caught, returned as an error result", async () => {
const client = {
getSubscriptionManager: () => ({
list: async () => {
throw new Error("relay down");
},
}),
};
const res = await importBlossomServerList({ client, pubkey: PK });
assert.equal(res.kind, "error");
assert.match(res.message, /Import failed/);
assert.equal(res.error.message, "relay down");
});
test("publishBlossomServerList signs + publishes a kind-10063 event with server tags", async () => {
let published = null;
const client = {
signAndPublishEvent: async (ev) => {
published = ev;
return { id: "x" };
},
};
const res = await publishBlossomServerList({
client,
pubkey: PK,
servers: ["https://a", "https://b"],
});
assert.equal(res.kind, "success");
assert.equal(published.kind, 10063);
assert.equal(published.pubkey, PK);
assert.deepEqual(
published.tags.filter((t) => t[0] === "server"),
[["server", "https://a"], ["server", "https://b"]],
);
});
test("publishBlossomServerList: no servers → error (nothing published)", async () => {
let called = false;
const client = {
signAndPublishEvent: async () => {
called = true;
},
};
const res = await publishBlossomServerList({ client, pubkey: PK, servers: [] });
assert.equal(res.kind, "error");
assert.match(res.message, /at least one server/);
assert.equal(called, false);
});
test("publishBlossomServerList: publish failure → error result (no throw)", async () => {
const client = {
signAndPublishEvent: async () => {
throw new Error("nope");
},
};
const res = await publishBlossomServerList({
client,
pubkey: PK,
servers: ["https://a"],
});
assert.equal(res.kind, "error");
assert.match(res.message, /Publish failed/);
});
+105
View File
@@ -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 () => ({
+19
View File
@@ -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, "");
});