feat(storage): Blossom Phase 1b — keyless multi-server config UI [LOCAL, flag ON] (#30)

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]>
This commit is contained in:
thePR0M3TH3ANandClaude Opus 4.8 committed 2026-07-09 16:31:34 -04:00
1 parent 0eafd38ec4
commit c5e8c7f9f1
5 files changed
+194 -4

No files matched your search

+18
View File
@@ -1012,9 +1012,25 @@
<option value="cloudflare_r2" selected>Cloudflare R2</option>
<option value="backblaze_b2">Backblaze B2</option>
<option value="generic_s3">Custom S3</option>
<option id="storageProviderBlossomOption" value="blossom" class="hidden">Blossom (Nostr-native)</option>
</select>
</label>
<!-- Blossom (Nostr-native) — keyless, multi-server. Shown when the
Blossom provider is selected; auth is your Nostr signer. -->
<div id="storageBlossomSection" class="hidden space-y-3">
<div class="card border border-info/30 bg-info/10 p-3 text-xs text-info">
Blossom stores files on nostr-native blob servers. No access keys — uploads are authorized by your Nostr signer. Add one or more servers (uploads mirror to all for resilience). The first server is the primary.
</div>
<label class="block text-sm text-muted-strong" for="storageBlossomServers">
<span class="mb-2 block font-medium">Blossom servers (one URL per line)</span>
<textarea id="storageBlossomServers" rows="3" class="input" placeholder="https://blossom.example.com
https://another.blossom.example"></textarea>
</label>
</div>
<!-- S3-family fields (R2 / B2 / Custom S3). Hidden for Blossom. -->
<div id="storageS3Fields" class="space-y-4">
<!-- R2 Helper -->
<div id="storageR2Helper" class="card border border-info/30 bg-info/10 p-3 text-xs text-info">
Enter your Cloudflare R2 credentials. Your Account ID (from dashboard) is used to connect. For Public Access URL, use your custom domain or R2.dev subdomain. Ensure the token has "Object Read & Write" permissions.
@@ -1068,6 +1084,8 @@
The public URL where your files will be accessible (CDN).
</span>
</label>
</div>
<!-- /storageS3Fields -->
<label class="flex items-center gap-2 text-sm text-muted-strong">
<input id="storageDefault" type="checkbox" class="checkbox" />
+4 -1
View File
@@ -286,8 +286,11 @@ export const FEATURE_BITCOIN_CONNECT = true;
* Defaults to `false` ("off = no trace"): with it off the Storage pane shows only
* R2 / S3 / B2 as today and the vendored Blossom SDK is never imported. Flip to
* `true` to expose the Blossom provider.
*
* Currently `true` for LOCAL testing of Phase 1b — reset to `false` (shipping
* default) before pushing/promoting until the upload flow is verified.
*/
export const FEATURE_BLOSSOM_STORAGE = false;
export const FEATURE_BLOSSOM_STORAGE = true;
/**
* Card liveness visibility policy — what a non-owner video card does while its
+16
View File
@@ -25,6 +25,7 @@ import {
buildStoragePrefixFromKey,
} from "../../utils/storagePointer.js";
import blossomServiceDefault, {
BLOSSOM_PROVIDER,
isBlossomProvider,
resolveBlossomServers,
} from "../../services/blossomService.js";
@@ -109,6 +110,21 @@ export class MediaUploader {
base.configured = true;
base.provider = targetConn.provider;
// Blossom has no encrypted secret — its server list lives in the plaintext
// connection meta, so credentials are available without unlocking storage
// (uploads authorize with the Nostr signer, not a stored key).
if (isBlossomProvider(targetConn.provider)) {
base.unlocked = true;
base.credentials = {
provider: BLOSSOM_PROVIDER,
servers: Array.isArray(targetConn.meta?.servers)
? targetConn.meta.servers
: [],
meta: targetConn.meta || {},
};
return base;
}
if (base.unlocked) {
const details = await this.storageService.getConnection(
pubkey,
+153 -1
View File
@@ -7,6 +7,8 @@ import {
derivePublicBaseUrl,
} from "../../services/s3Service.js";
import { getActiveSigner } from "../../nostr/client.js";
import { FEATURE_BLOSSOM_STORAGE } from "../../constants.js";
import { BLOSSOM_PROVIDER } from "../../services/blossomService.js";
import { DEFAULT_NIP07_ENCRYPTION_METHODS } from "../../nostr/nip07Permissions.js";
import { storageSyncService } from "../../services/storageSyncService.js";
import { StorageCorsHelp } from "./storageCorsHelp.js";
@@ -97,6 +99,41 @@ export class ProfileStorageController {
this.storageSyncToggle = document.getElementById("storageSyncToggle") || null;
this.storageSyncRestoreBtn = document.getElementById("storageSyncRestoreBtn") || null;
this.storageSyncStatus = document.getElementById("storageSyncStatus") || null;
// Blossom (nostr-native) — keyless multi-server storage.
this.storageS3Fields = document.getElementById("storageS3Fields") || null;
this.storageBlossomSection =
document.getElementById("storageBlossomSection") || null;
this.storageBlossomServersInput =
document.getElementById("storageBlossomServers") || null;
this.storageProviderBlossomOption =
document.getElementById("storageProviderBlossomOption") || null;
// Reveal the Blossom provider option only when the deployment enables it.
if (
FEATURE_BLOSSOM_STORAGE === true &&
this.storageProviderBlossomOption instanceof HTMLElement
) {
this.storageProviderBlossomOption.classList.remove("hidden");
}
}
parseBlossomServers() {
const raw = this.storageBlossomServersInput?.value || "";
const out = [];
const seen = new Set();
for (const line of raw.split(/[\n,]/)) {
const url = line.trim().replace(/\/+$/, "");
if (!url || seen.has(url)) {
continue;
}
// Only accept absolute http(s) server URLs.
if (!/^https?:\/\//i.test(url)) {
continue;
}
seen.add(url);
out.push(url);
}
return out;
}
registerEventListeners() {
@@ -336,7 +373,35 @@ export class ProfileStorageController {
this.mainController.getActivePubkey(),
);
const storageService = this.mainController.services?.storageService;
if (!pubkey || !storageService || !storageService.isUnlocked?.(pubkey)) {
if (!pubkey || !storageService) {
return;
}
const selectedProvider = this.storageProviderInput?.value || "cloudflare_r2";
// Blossom servers live in plaintext connection meta — load them without an
// unlock (there's no encrypted secret to decrypt).
if (selectedProvider === BLOSSOM_PROVIDER) {
try {
const connections = await storageService.listConnections(pubkey);
const match = findProviderConnection(connections, BLOSSOM_PROVIDER);
const servers = Array.isArray(match?.meta?.servers)
? match.meta.servers
: [];
if (this.storageBlossomServersInput instanceof HTMLElement) {
this.storageBlossomServersInput.value = servers.join("\n");
}
if (this.storageDefaultInput instanceof HTMLElement) {
this.storageDefaultInput.checked =
match?.meta?.defaultForUploads === true;
}
} catch (error) {
devLogger.warn("[ProfileModal] Failed to load Blossom servers:", error);
}
return;
}
if (!storageService.isUnlocked?.(pubkey)) {
return;
}
const provider = this.storageProviderInput?.value || "cloudflare_r2";
@@ -364,6 +429,21 @@ export class ProfileStorageController {
updateStorageFormVisibility() {
const provider = this.storageProviderInput?.value || "cloudflare_r2";
// Blossom has no bucket/keys/CORS — swap the whole S3 field group for the
// keyless multi-server section.
const isBlossom = provider === BLOSSOM_PROVIDER;
if (this.storageS3Fields instanceof HTMLElement) {
this.storageS3Fields.classList.toggle("hidden", isBlossom);
}
if (this.storageBlossomSection instanceof HTMLElement) {
this.storageBlossomSection.classList.toggle("hidden", !isBlossom);
}
if (isBlossom) {
this.corsHelp.setVisible(false);
return;
}
const isR2 = provider === "cloudflare_r2";
// B2 derives its endpoint from the region, so its raw Endpoint field is hidden.
const isB2 = provider === PROVIDERS.B2;
@@ -710,6 +790,44 @@ export class ProfileStorageController {
if (!storageService) return;
const provider = this.storageProviderInput?.value || "cloudflare_r2";
// Blossom: keyless. Persist the server list in the (plaintext) connection meta;
// there is no secret to encrypt and no bucket/endpoint to validate.
if (provider === BLOSSOM_PROVIDER) {
const servers = this.parseBlossomServers();
if (servers.length === 0) {
this.setStorageFormStatus(
"Add at least one Blossom server URL (https://…).",
"error",
);
return;
}
const isDefaultBlossom = this.storageDefaultInput?.checked || false;
this.setStorageFormStatus("Saving...", "info");
try {
await saveProviderConnection(storageService, pubkey, {
provider: BLOSSOM_PROVIDER,
payload: { provider: BLOSSOM_PROVIDER },
meta: {
provider: BLOSSOM_PROVIDER,
servers,
defaultForUploads: isDefaultBlossom,
label: `Blossom (${servers.length} server${servers.length === 1 ? "" : "s"})`,
},
isDefault: isDefaultBlossom,
});
this.setStorageFormStatus("Blossom servers saved.", "success");
this.mainController.showSuccess("Blossom storage saved.");
} catch (error) {
devLogger.error("Failed to save Blossom connection:", error);
this.setStorageFormStatus(
"Failed to save Blossom servers. Unlock storage first if prompted.",
"error",
);
}
return;
}
let endpointOrAccount = this.storageEndpointInput?.value?.trim() || "";
const region = this.storageRegionInput?.value?.trim() || "auto";
const accessKeyId = this.storageAccessKeyInput?.value?.trim() || "";
@@ -860,6 +978,40 @@ export class ProfileStorageController {
}
const provider = this.storageProviderInput?.value || "cloudflare_r2";
// Blossom: no credentials to test. Best-effort reachability ping of each
// server (CORS may block it — a failure here doesn't mean upload will fail,
// so it's informational). The real verification is an upload.
if (provider === BLOSSOM_PROVIDER) {
const servers = this.parseBlossomServers();
if (servers.length === 0) {
this.setStorageFormStatus(
"Add at least one Blossom server URL first.",
"error",
);
return;
}
this.setStorageFormStatus("Checking servers…", "info");
let reachable = 0;
await Promise.all(
servers.map(async (server) => {
try {
await fetch(server, { method: "HEAD", mode: "cors" });
reachable += 1;
} catch {
// CORS/offline — inconclusive, not necessarily a failure.
}
}),
);
this.setStorageFormStatus(
reachable > 0
? `Reached ${reachable}/${servers.length} server(s). Upload to fully verify.`
: `Couldn't reach the server(s) from the browser (CORS may block a HEAD). Save and try an upload to verify.`,
reachable > 0 ? "success" : "info",
);
return;
}
let endpointOrAccount = this.storageEndpointInput?.value?.trim() || "";
const region = this.storageRegionInput?.value?.trim() || "auto";
const accessKeyId = this.storageAccessKeyInput?.value?.trim() || "";
+3 -2
View File
@@ -11,6 +11,7 @@ import blossomService, {
isBlossomProvider,
resolveBlossomServers,
} from "../js/services/blossomService.js";
import { FEATURE_BLOSSOM_STORAGE } from "../js/constants.js";
const SHA = "a".repeat(64);
const signer = async (draft) => ({ ...draft, id: "id", sig: "sig", pubkey: "pk" });
@@ -43,8 +44,8 @@ test("isBlossomProvider / BLOSSOM_PROVIDER only match 'blossom'", () => {
assert.equal(isBlossomProvider("cloudflare_r2"), false);
});
test("isAvailable reflects the flag (off by default)", () => {
assert.equal(blossomService.isAvailable(), false);
test("isAvailable reflects the FEATURE_BLOSSOM_STORAGE flag", () => {
assert.equal(blossomService.isAvailable(), FEATURE_BLOSSOM_STORAGE === true);
});
test("resolveBlossomServers de-dupes, trims, and reads meta.servers", () => {