diff --git a/components/profile-modal.html b/components/profile-modal.html
index 4a430a12..f9beec3e 100644
--- a/components/profile-modal.html
+++ b/components/profile-modal.html
@@ -1012,9 +1012,25 @@
Cloudflare R2
Backblaze B2
Custom S3
+ Blossom (Nostr-native)
+
+
+
+ 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.
+
+
+ Blossom servers (one URL per line)
+
+
+
+
+
+
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/config/instance-config.js b/config/instance-config.js
index 424cb9a6..79c4248a 100644
--- a/config/instance-config.js
+++ b/config/instance-config.js
@@ -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
diff --git a/js/ui/components/mediaUploader.js b/js/ui/components/mediaUploader.js
index 29a7d737..83984cd4 100644
--- a/js/ui/components/mediaUploader.js
+++ b/js/ui/components/mediaUploader.js
@@ -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,
diff --git a/js/ui/profileModal/ProfileStorageController.js b/js/ui/profileModal/ProfileStorageController.js
index b2798d1c..cf56279c 100644
--- a/js/ui/profileModal/ProfileStorageController.js
+++ b/js/ui/profileModal/ProfileStorageController.js
@@ -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() || "";
diff --git a/tests/blossom-service.test.mjs b/tests/blossom-service.test.mjs
index f90c4f5f..abf54d9b 100644
--- a/tests/blossom-service.test.mjs
+++ b/tests/blossom-service.test.mjs
@@ -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", () => {