@le-space/orbitdb-storage-bridge 0.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +290 -0
- package/dist/components/StorachaAuth.svelte +801 -0
- package/dist/components/StorachaIntegration.svelte +965 -0
- package/dist/components/WebAuthnDIDProvider.js +562 -0
- package/dist/components/storacha-backup.js +325 -0
- package/dist/components/theme.js +79 -0
- package/lib/backends/aleph-pin.js +161 -0
- package/lib/backends/aleph.js +197 -0
- package/lib/backends/lighthouse.js +325 -0
- package/lib/backends/memory.js +140 -0
- package/lib/backends/pinata.js +330 -0
- package/lib/backends/resolve.js +72 -0
- package/lib/backends/storacha.js +178 -0
- package/lib/backends/types.js +187 -0
- package/lib/backup-car.js +1286 -0
- package/lib/backup-helpers.js +87 -0
- package/lib/backup-metadata.js +40 -0
- package/lib/backup.js +249 -0
- package/lib/block-bytes.js +39 -0
- package/lib/car-storage.js +342 -0
- package/lib/courier-sync.js +886 -0
- package/lib/dehydrate.js +146 -0
- package/lib/extract-blocks.js +258 -0
- package/lib/gateway-fetch.js +116 -0
- package/lib/ipns-helpers.js +324 -0
- package/lib/logger.js +128 -0
- package/lib/memory-courier.js +105 -0
- package/lib/orbitdb-storacha-bridge.js +2789 -0
- package/lib/pointer-ipns.js +234 -0
- package/lib/restore-cid.js +302 -0
- package/lib/ucan-bridge.js +921 -0
- package/lib/utils.js +316 -0
- package/package.json +172 -0
|
@@ -0,0 +1,330 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Pinata storage backend for OrbitDB Storage Bridge
|
|
3
|
+
*
|
|
4
|
+
* Pinata is the only evaluated backend that can both take a CAR and pin a CID that is
|
|
5
|
+
* already on IPFS, and those are the two shapes this library wants. Pinning by CID is the
|
|
6
|
+
* better one: we already run Helia, so `pinCid()` moves no bytes through a vendor API and
|
|
7
|
+
* the hashes cannot drift, because nobody but us ever computed them.
|
|
8
|
+
*
|
|
9
|
+
* Both are paid-plan features, so both are opt-in (`pinByCid`, `carImport`). Measured on
|
|
10
|
+
* the free plan on 2026-09-17: uploads, listing and deletion work, a CAR goes up as a plain
|
|
11
|
+
* file and comes back byte for byte, and pin by CID answers 403 "not supported by the
|
|
12
|
+
* current plan type".
|
|
13
|
+
*
|
|
14
|
+
* No SDK dependency -- the v3 API is three documented endpoints and `fetch`, which keeps
|
|
15
|
+
* the package lean and works unchanged in the browser.
|
|
16
|
+
*
|
|
17
|
+
* Two modes, and the capability set says which one you got:
|
|
18
|
+
*
|
|
19
|
+
* - **with a JWT** — full backend: upload, list, delete, and pin by CID on a paid plan.
|
|
20
|
+
* The JWT is a bearer secret, so `browserSafeAuth` is false; this belongs on a relay or
|
|
21
|
+
* in Node.
|
|
22
|
+
* - **with `getUploadUrl`** — a presigned upload URL minted elsewhere. Nothing secret
|
|
23
|
+
* reaches the browser, so `browserSafeAuth` is true, but the operations that need the
|
|
24
|
+
* account key are gone and the driver declares them gone rather than failing later.
|
|
25
|
+
*
|
|
26
|
+
* @author @NiKrause
|
|
27
|
+
* @requires ./types.js - the backend contract
|
|
28
|
+
* @see {@link https://docs.pinata.cloud/files/uploading-files} for the CAR upload rules
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
/* global FormData, URLSearchParams */
|
|
32
|
+
|
|
33
|
+
import { defineBackend, handleId, BackendError } from "./types.js";
|
|
34
|
+
|
|
35
|
+
const DEFAULT_UPLOAD_URL = "https://uploads.pinata.cloud/v3/files";
|
|
36
|
+
const DEFAULT_API_URL = "https://api.pinata.cloud/v3";
|
|
37
|
+
const DEFAULT_GATEWAY = "https://gateway.pinata.cloud";
|
|
38
|
+
|
|
39
|
+
/** Pinata treats a CAR upload as a CAR only when told to. */
|
|
40
|
+
const CAR_MIME = "application/vnd.ipld.car";
|
|
41
|
+
|
|
42
|
+
/** How often a gateway 429 is waited out before it counts as a failure. */
|
|
43
|
+
const GATEWAY_RETRIES = 4;
|
|
44
|
+
/** The longest single wait, whatever Retry-After asks for. */
|
|
45
|
+
const MAX_RETRY_WAIT_MS = 30_000;
|
|
46
|
+
|
|
47
|
+
/** Pinata's words for a feature the account's plan does not include. */
|
|
48
|
+
const PLAN_REFUSAL = /not supported by the current plan/i;
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* What a refusal means to a caller: a plan without the feature is "cannot", a key Pinata
|
|
52
|
+
* rejects or has not scoped for the call is a misconfigured backend, and only a 404 is
|
|
53
|
+
* "not there".
|
|
54
|
+
*/
|
|
55
|
+
const codeFor = (status, detail) => {
|
|
56
|
+
if (status === 404) return "NOT_FOUND";
|
|
57
|
+
if (status === 401 || (status === 403 && !PLAN_REFUSAL.test(detail))) {
|
|
58
|
+
return "INVALID_BACKEND";
|
|
59
|
+
}
|
|
60
|
+
return "UNSUPPORTED";
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
/** Retry-After when the gateway sends one, otherwise doubling from a second. */
|
|
64
|
+
const retryWait = (response, attempt) => {
|
|
65
|
+
const header = response.headers.get("retry-after");
|
|
66
|
+
const seconds = header === null ? NaN : Number(header);
|
|
67
|
+
const ms = Number.isFinite(seconds) && seconds >= 0 ? seconds * 1000 : 1000 * 2 ** attempt;
|
|
68
|
+
return Math.min(ms, MAX_RETRY_WAIT_MS);
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* A gateway as a URL. Pinata's dashboard lists gateways as bare domains
|
|
75
|
+
* (`<name>.mypinata.cloud`), and one pasted as shown would otherwise reach
|
|
76
|
+
* `fetch` without a scheme and fail every read with "Invalid URL".
|
|
77
|
+
*/
|
|
78
|
+
const gatewayUrl = (value) => {
|
|
79
|
+
const trimmed = value.trim().replace(/\/+$/, "");
|
|
80
|
+
return /^[a-z][a-z0-9+.-]*:\/\//i.test(trimmed) ? trimmed : `https://${trimmed}`;
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Create a Pinata backend.
|
|
85
|
+
*
|
|
86
|
+
* @param {object} options
|
|
87
|
+
* @param {string} [options.jwt] - Pinata JWT; falls back to PINATA_JWT
|
|
88
|
+
* @param {() => Promise<string>} [options.getUploadUrl] - mint a presigned upload URL
|
|
89
|
+
* instead of holding a JWT. Uploads only; see the note above.
|
|
90
|
+
* @param {string} [options.gateway] - retrieval gateway: `https://<name>.mypinata.cloud`, or
|
|
91
|
+
* the bare domain as Pinata's dashboard shows it. The account's dedicated gateway is
|
|
92
|
+
* strongly preferred: the shared one rate limits with 429s, which are waited out, but slowly
|
|
93
|
+
* @param {number} [options.gatewayRetries=4] - how often a gateway 429 is waited out
|
|
94
|
+
* @param {"public"|"private"} [options.network="public"] - CAR uploads are public only
|
|
95
|
+
* @param {boolean} [options.pinByCid=false] - offer `pinCid()`. Paid plans only; the free
|
|
96
|
+
* plan refuses it with 403, so it is declared only when asked for.
|
|
97
|
+
* @param {boolean} [options.carImport=false] - send CARs with `car=true`, so Pinata unpacks
|
|
98
|
+
* and indexes the blocks. Paid plans only. Without it a CAR goes up as one opaque file,
|
|
99
|
+
* exactly as on Aleph, and comes back byte for byte for us to unpack.
|
|
100
|
+
* @param {string} [options.uploadUrl]
|
|
101
|
+
* @param {string} [options.apiUrl]
|
|
102
|
+
* @returns {import("./types.js").StorageBackend}
|
|
103
|
+
*/
|
|
104
|
+
export function createPinataBackend(options = {}) {
|
|
105
|
+
const jwt =
|
|
106
|
+
options.jwt ||
|
|
107
|
+
(typeof process !== "undefined" ? process.env?.PINATA_JWT : undefined);
|
|
108
|
+
const getUploadUrl = options.getUploadUrl;
|
|
109
|
+
|
|
110
|
+
if (!jwt && !getUploadUrl) {
|
|
111
|
+
throw new BackendError(
|
|
112
|
+
"INVALID_BACKEND",
|
|
113
|
+
"createPinataBackend needs either a jwt or a getUploadUrl function",
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const network = options.network || "public";
|
|
118
|
+
const uploadUrl = options.uploadUrl || DEFAULT_UPLOAD_URL;
|
|
119
|
+
const apiUrl = (options.apiUrl || DEFAULT_API_URL).replace(/\/+$/, "");
|
|
120
|
+
const gateway = gatewayUrl(options.gateway || DEFAULT_GATEWAY);
|
|
121
|
+
const keyed = Boolean(jwt);
|
|
122
|
+
const carImport = options.carImport === true;
|
|
123
|
+
const pinByCid = keyed && options.pinByCid === true;
|
|
124
|
+
const gatewayRetries = options.gatewayRetries ?? GATEWAY_RETRIES;
|
|
125
|
+
|
|
126
|
+
/** Call the account API. Only available in JWT mode. */
|
|
127
|
+
const api = async (path, init = {}) => {
|
|
128
|
+
const response = await fetch(`${apiUrl}${path}`, {
|
|
129
|
+
...init,
|
|
130
|
+
headers: {
|
|
131
|
+
Authorization: `Bearer ${jwt}`,
|
|
132
|
+
...(init.headers || {}),
|
|
133
|
+
},
|
|
134
|
+
});
|
|
135
|
+
if (!response.ok) {
|
|
136
|
+
const detail = await response.text().catch(() => "");
|
|
137
|
+
throw new BackendError(
|
|
138
|
+
codeFor(response.status, detail),
|
|
139
|
+
`Pinata ${init.method || "GET"} ${path} failed: HTTP ${response.status} ${detail}`.trim() +
|
|
140
|
+
(PLAN_REFUSAL.test(detail) ? " (a paid-plan feature)" : ""),
|
|
141
|
+
);
|
|
142
|
+
}
|
|
143
|
+
return response.status === 204 ? null : response.json();
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
const backend = {
|
|
147
|
+
name: "pinata",
|
|
148
|
+
|
|
149
|
+
capabilities: {
|
|
150
|
+
// Paid plans only — the free plan answers 403 — so declared only when asked for.
|
|
151
|
+
pinByCid,
|
|
152
|
+
// A CAR uploaded with car=true is unpacked and its blocks indexed — paid plans only,
|
|
153
|
+
// so it is opt-in. The free plan stores the CAR as a file, which is all a backup needs.
|
|
154
|
+
carImport,
|
|
155
|
+
// A plain file push is hashed by Pinata with its own chunker, so per-block upload is
|
|
156
|
+
// not safe here -- and at 60 requests a minute on the free tier it would be unwise
|
|
157
|
+
// even if it were. Declaring false routes backups through a CAR, which is correct.
|
|
158
|
+
preservesInnerCids: false,
|
|
159
|
+
browserSafeAuth: !keyed,
|
|
160
|
+
delegation: true,
|
|
161
|
+
listing: keyed,
|
|
162
|
+
deletion: keyed,
|
|
163
|
+
minBlobSize: 0,
|
|
164
|
+
},
|
|
165
|
+
|
|
166
|
+
/** @type {import("./types.js").StorageBackend["putBlob"]} */
|
|
167
|
+
putBlob: async (bytes, meta = {}) => {
|
|
168
|
+
const name = meta.name || "blob";
|
|
169
|
+
const type = meta.type || "application/octet-stream";
|
|
170
|
+
const isCar = carImport && (meta.car ?? type === CAR_MIME);
|
|
171
|
+
|
|
172
|
+
const form = new FormData();
|
|
173
|
+
// The file's own name must not carry a path. backupDatabase names files
|
|
174
|
+
// `<space>/backup-…`, and Pinata turns a path into a folder and answers with
|
|
175
|
+
// the folder's CID, which a gateway serves as an HTML listing. The full name
|
|
176
|
+
// still goes up as the upload's `name`.
|
|
177
|
+
const fileName = name.split("/").filter(Boolean).pop() || "blob";
|
|
178
|
+
form.append("file", new File([bytes], fileName, { type }));
|
|
179
|
+
form.append("network", network);
|
|
180
|
+
form.append("name", name);
|
|
181
|
+
if (isCar) {
|
|
182
|
+
form.append("car", "true");
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const target = getUploadUrl ? await getUploadUrl() : uploadUrl;
|
|
186
|
+
const response = await fetch(target, {
|
|
187
|
+
method: "POST",
|
|
188
|
+
headers:
|
|
189
|
+
keyed && !getUploadUrl ? { Authorization: `Bearer ${jwt}` } : {},
|
|
190
|
+
body: form,
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
if (!response.ok) {
|
|
194
|
+
const detail = await response.text().catch(() => "");
|
|
195
|
+
throw new BackendError(
|
|
196
|
+
codeFor(response.status, detail),
|
|
197
|
+
`Pinata upload failed: HTTP ${response.status} ${detail}`.trim() +
|
|
198
|
+
(isCar
|
|
199
|
+
? " (CAR import needs a paid plan, the public network, and a single root CID)"
|
|
200
|
+
: ""),
|
|
201
|
+
);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const body = await response.json();
|
|
205
|
+
const data = body?.data || body;
|
|
206
|
+
return {
|
|
207
|
+
id: data.cid,
|
|
208
|
+
// Only an imported CAR comes back under a CID that is ours — its root. A file
|
|
209
|
+
// upload is hashed by Pinata's own chunker, so like Aleph's it names their
|
|
210
|
+
// encoding of our bytes, and the handle does not pretend otherwise.
|
|
211
|
+
...(isCar ? { cid: data.cid } : {}),
|
|
212
|
+
backend: "pinata",
|
|
213
|
+
size: data.size ?? bytes.length,
|
|
214
|
+
// Deletion addresses Pinata's own file id, not the CID.
|
|
215
|
+
fileId: data.id,
|
|
216
|
+
raw: data,
|
|
217
|
+
};
|
|
218
|
+
},
|
|
219
|
+
|
|
220
|
+
/** @type {import("./types.js").StorageBackend["getBlob"]} */
|
|
221
|
+
getBlob: async (handle) => {
|
|
222
|
+
const cid = handleId(handle);
|
|
223
|
+
let response;
|
|
224
|
+
// A 429 says "not now", not "not there": wait as told, a few times.
|
|
225
|
+
for (let attempt = 0; ; attempt++) {
|
|
226
|
+
response = await fetch(`${gateway}/ipfs/${cid}`);
|
|
227
|
+
if (response.status !== 429 || attempt >= gatewayRetries) break;
|
|
228
|
+
await response.body?.cancel();
|
|
229
|
+
await sleep(retryWait(response, attempt));
|
|
230
|
+
}
|
|
231
|
+
if (!response.ok) {
|
|
232
|
+
// A gateway says why it refused ("… not pinned to their Pinata account.
|
|
233
|
+
// - ERR_ID:00006"); a status code alone does not.
|
|
234
|
+
const reason = (await response.text().catch(() => ""))
|
|
235
|
+
.replace(/\s+/g, " ")
|
|
236
|
+
.trim()
|
|
237
|
+
.slice(0, 240);
|
|
238
|
+
throw new BackendError(
|
|
239
|
+
"NOT_FOUND",
|
|
240
|
+
`Pinata gateway did not serve ${cid}: HTTP ${response.status}` +
|
|
241
|
+
(reason ? ` "${reason}"` : "") +
|
|
242
|
+
(response.status === 404
|
|
243
|
+
? " (a CAR upload is processed asynchronously; it may not be indexed yet)"
|
|
244
|
+
: "") +
|
|
245
|
+
(response.status === 429
|
|
246
|
+
? ` (still rate limited after ${gatewayRetries} retries` +
|
|
247
|
+
(gateway === DEFAULT_GATEWAY
|
|
248
|
+
? "; the shared gateway is for testing, pass the account's dedicated gateway as `gateway`)"
|
|
249
|
+
: ")")
|
|
250
|
+
: ""),
|
|
251
|
+
);
|
|
252
|
+
}
|
|
253
|
+
return new Uint8Array(await response.arrayBuffer());
|
|
254
|
+
},
|
|
255
|
+
};
|
|
256
|
+
|
|
257
|
+
if (pinByCid) {
|
|
258
|
+
backend.pinCid = async (cid, meta = {}) => {
|
|
259
|
+
const body = await api("/files/public/pin_by_cid", {
|
|
260
|
+
method: "POST",
|
|
261
|
+
headers: { "Content-Type": "application/json" },
|
|
262
|
+
body: JSON.stringify({
|
|
263
|
+
cid: String(cid),
|
|
264
|
+
...(meta.name ? { name: meta.name } : {}),
|
|
265
|
+
}),
|
|
266
|
+
});
|
|
267
|
+
const data = body?.data || body || {};
|
|
268
|
+
return {
|
|
269
|
+
id: String(cid),
|
|
270
|
+
cid: String(cid),
|
|
271
|
+
backend: "pinata",
|
|
272
|
+
// The id of the pin *request* in Pinata's queue. The file it becomes gets an id
|
|
273
|
+
// of its own once retrieved, so this is deliberately not `fileId`: remove() then
|
|
274
|
+
// resolves the file by CID instead of deleting by an id that names something else.
|
|
275
|
+
requestId: data.id,
|
|
276
|
+
// "prechecking" or "retrieving": Pinata has accepted the job, not finished it.
|
|
277
|
+
status: data.status,
|
|
278
|
+
raw: data,
|
|
279
|
+
};
|
|
280
|
+
};
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
if (keyed) {
|
|
284
|
+
backend.list = async (listOptions = {}) => {
|
|
285
|
+
const query = new URLSearchParams();
|
|
286
|
+
if (listOptions.limit) query.set("limit", String(listOptions.limit));
|
|
287
|
+
if (listOptions.cursor) query.set("pageToken", listOptions.cursor);
|
|
288
|
+
if (listOptions.cid) query.set("cid", String(listOptions.cid));
|
|
289
|
+
|
|
290
|
+
const body = await api(
|
|
291
|
+
`/files/${network}${query.size ? `?${query}` : ""}`,
|
|
292
|
+
);
|
|
293
|
+
const files = body?.data?.files || [];
|
|
294
|
+
return files.map((file) => ({
|
|
295
|
+
id: file.cid,
|
|
296
|
+
cid: file.cid,
|
|
297
|
+
backend: "pinata",
|
|
298
|
+
size: file.size,
|
|
299
|
+
fileId: file.id,
|
|
300
|
+
insertedAt: file.created_at,
|
|
301
|
+
raw: file,
|
|
302
|
+
}));
|
|
303
|
+
};
|
|
304
|
+
|
|
305
|
+
backend.remove = async (handle) => {
|
|
306
|
+
let fileId = typeof handle === "object" ? handle?.fileId : undefined;
|
|
307
|
+
|
|
308
|
+
if (!fileId) {
|
|
309
|
+
// Only a CID was given. Resolve it, and refuse unless the record we found is
|
|
310
|
+
// actually the one asked for -- deleting the wrong file is not recoverable.
|
|
311
|
+
const cid = handleId(handle);
|
|
312
|
+
const matches = await backend.list({ cid, limit: 2 });
|
|
313
|
+
const match = matches.find((entry) => entry.cid === cid);
|
|
314
|
+
if (!match?.fileId) {
|
|
315
|
+
throw new BackendError(
|
|
316
|
+
"NOT_FOUND",
|
|
317
|
+
`No Pinata file found for ${cid}; pass the handle from putBlob to delete it`,
|
|
318
|
+
);
|
|
319
|
+
}
|
|
320
|
+
fileId = match.fileId;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
await api(`/files/${network}/${fileId}`, { method: "DELETE" });
|
|
324
|
+
};
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
return defineBackend(backend);
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
export default createPinataBackend;
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One decision about where a backup goes: a ready backend is used as-is, and
|
|
3
|
+
* credentials or a UCAN client build a Storacha one.
|
|
4
|
+
*
|
|
5
|
+
* Its own module, and the Storacha backend is loaded only when it is actually
|
|
6
|
+
* built, because that module pulls `@storacha/client` — about 88 kB gzipped in
|
|
7
|
+
* a browser bundle. A page that backs up to Aleph should not carry a client
|
|
8
|
+
* for a service it never calls.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { logger } from "../logger.js";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Resolve the storage backend for a call.
|
|
15
|
+
*
|
|
16
|
+
* Six copies of this decision used to sit inline in this file and in backup-car.js,
|
|
17
|
+
* which is how a vendor ends up welded to a library. Now there is one: pass a ready
|
|
18
|
+
* backend and it is used as-is, pass a UCAN client or credentials and a Storacha
|
|
19
|
+
* backend is built around them.
|
|
20
|
+
*
|
|
21
|
+
* @param {Object} [config] - call options
|
|
22
|
+
* @param {Object} [config.backend] - any driver implementing the backend contract
|
|
23
|
+
* @param {Object} [config.ucanClient] - a Storacha client authorised over UCAN
|
|
24
|
+
* @param {string} [config.spaceDID]
|
|
25
|
+
* @param {string} [config.storachaKey] - falls back to STORACHA_KEY
|
|
26
|
+
* @param {string} [config.storachaProof] - falls back to STORACHA_PROOF
|
|
27
|
+
* @param {Object} [config.serviceConf]
|
|
28
|
+
* @param {string|URL} [config.receiptsEndpoint]
|
|
29
|
+
* @param {string[]} [config.gateways] - retrieval gateways for the Storacha backend
|
|
30
|
+
* @returns {Promise<import("./backends/types.js").StorageBackend>}
|
|
31
|
+
* @see {@link ./backends/types.js} for the contract
|
|
32
|
+
*/
|
|
33
|
+
export async function resolveBackend(config = {}) {
|
|
34
|
+
if (config.backend) {
|
|
35
|
+
return config.backend;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const gateways = config.gateways;
|
|
39
|
+
|
|
40
|
+
if (config.ucanClient) {
|
|
41
|
+
logger.info("🔐 Using UCAN authentication...");
|
|
42
|
+
const { createStorachaBackend } = await import("./storacha.js");
|
|
43
|
+
return createStorachaBackend({
|
|
44
|
+
client: config.ucanClient,
|
|
45
|
+
spaceDID: config.spaceDID,
|
|
46
|
+
...(gateways ? { gateways } : {}),
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const storachaKey =
|
|
51
|
+
config.storachaKey ||
|
|
52
|
+
(typeof process !== "undefined" ? process.env?.STORACHA_KEY : undefined);
|
|
53
|
+
const storachaProof =
|
|
54
|
+
config.storachaProof ||
|
|
55
|
+
(typeof process !== "undefined" ? process.env?.STORACHA_PROOF : undefined);
|
|
56
|
+
|
|
57
|
+
if (!storachaKey || !storachaProof) {
|
|
58
|
+
throw new Error(
|
|
59
|
+
"Storacha authentication required: pass storachaKey + storachaProof OR ucanClient in options",
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
logger.info("🔑 Using credential authentication...");
|
|
64
|
+
const { createStorachaBackend } = await import("./storacha.js");
|
|
65
|
+
return createStorachaBackend({
|
|
66
|
+
storachaKey,
|
|
67
|
+
storachaProof,
|
|
68
|
+
serviceConf: config.serviceConf,
|
|
69
|
+
receiptsEndpoint: config.receiptsEndpoint,
|
|
70
|
+
...(gateways ? { gateways } : {}),
|
|
71
|
+
});
|
|
72
|
+
}
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Storacha storage backend for OrbitDB Storage Bridge
|
|
3
|
+
*
|
|
4
|
+
* The original backend, behind the contract the others will implement. It is kept for
|
|
5
|
+
* three reasons even though the public service is gone: the in-memory upload-api in
|
|
6
|
+
* `test/helpers/in-memory-storacha.js` still speaks this protocol, anyone holding
|
|
7
|
+
* credentials against a self-hosted w3up deployment can still use it, and it is the
|
|
8
|
+
* reference for what a UCAN-delegated backend looked like when one existed.
|
|
9
|
+
*
|
|
10
|
+
* Retrieval no longer defaults to Storacha's own gateways: `storacha.link` and `w3s.link`
|
|
11
|
+
* answer 301 to `dweb.link` as of 2026-09-05, so the driver goes there directly and lets
|
|
12
|
+
* a caller pass its own list — which is what the in-memory service does.
|
|
13
|
+
*
|
|
14
|
+
* @author @NiKrause
|
|
15
|
+
* @requires ./types.js - the backend contract
|
|
16
|
+
* @see {@link ../../docs/STORAGE-BACKENDS.md} for what replaced it
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import * as Client from "@storacha/client";
|
|
20
|
+
import { StoreMemory } from "@storacha/client/stores/memory";
|
|
21
|
+
import { Signer } from "@storacha/client/principal/ed25519";
|
|
22
|
+
import * as Proof from "@storacha/client/proof";
|
|
23
|
+
import { CID } from "multiformats/cid";
|
|
24
|
+
import { defineBackend, handleId, BackendError } from "./types.js";
|
|
25
|
+
|
|
26
|
+
/** Gateways tried in order by `getBlob()`, unless the caller supplies its own. */
|
|
27
|
+
export const DEFAULT_GATEWAYS = Object.freeze([
|
|
28
|
+
"https://dweb.link",
|
|
29
|
+
"https://ipfs.io",
|
|
30
|
+
]);
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Build a Storacha client from key and proof, or adopt one that already exists.
|
|
34
|
+
*
|
|
35
|
+
* @param {object} options
|
|
36
|
+
* @param {string} [options.storachaKey]
|
|
37
|
+
* @param {string} [options.storachaProof]
|
|
38
|
+
* @param {object} [options.client] - a pre-built client, e.g. one authorised over UCAN
|
|
39
|
+
* @param {string} [options.spaceDID] - space to select on a pre-built client
|
|
40
|
+
* @param {object} [options.serviceConf]
|
|
41
|
+
* @param {string|URL} [options.receiptsEndpoint]
|
|
42
|
+
* @returns {Promise<object>}
|
|
43
|
+
*/
|
|
44
|
+
async function resolveClient(options) {
|
|
45
|
+
if (options.client) {
|
|
46
|
+
if (options.spaceDID) {
|
|
47
|
+
await options.client.setCurrentSpace(options.spaceDID);
|
|
48
|
+
}
|
|
49
|
+
return options.client;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if (!options.storachaKey || !options.storachaProof) {
|
|
53
|
+
throw new BackendError(
|
|
54
|
+
"INVALID_BACKEND",
|
|
55
|
+
"createStorachaBackend needs either a client or storachaKey + storachaProof",
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const clientOptions = {
|
|
60
|
+
principal: Signer.parse(options.storachaKey),
|
|
61
|
+
store: new StoreMemory(),
|
|
62
|
+
};
|
|
63
|
+
if (options.serviceConf) {
|
|
64
|
+
clientOptions.serviceConf = options.serviceConf;
|
|
65
|
+
}
|
|
66
|
+
if (options.receiptsEndpoint) {
|
|
67
|
+
clientOptions.receiptsEndpoint = options.receiptsEndpoint;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const client = await Client.create(clientOptions);
|
|
71
|
+
const space = await client.addSpace(await Proof.parse(options.storachaProof));
|
|
72
|
+
await client.setCurrentSpace(space.did());
|
|
73
|
+
return client;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Create a Storacha backend.
|
|
78
|
+
*
|
|
79
|
+
* @param {object} options - see {@link resolveClient}, plus:
|
|
80
|
+
* @param {string[]} [options.gateways] - retrieval gateways, tried in order
|
|
81
|
+
* @returns {Promise<import("./types.js").StorageBackend>}
|
|
82
|
+
*/
|
|
83
|
+
export async function createStorachaBackend(options = {}) {
|
|
84
|
+
const client = await resolveClient(options);
|
|
85
|
+
const gateways = (options.gateways || DEFAULT_GATEWAYS).map((gateway) =>
|
|
86
|
+
String(gateway).replace(/\/+$/, ""),
|
|
87
|
+
);
|
|
88
|
+
|
|
89
|
+
return defineBackend({
|
|
90
|
+
name: "storacha",
|
|
91
|
+
|
|
92
|
+
capabilities: {
|
|
93
|
+
pinByCid: false,
|
|
94
|
+
carImport: false,
|
|
95
|
+
// the client hashes locally and the service stores exactly those bytes —
|
|
96
|
+
// the property this library was built on
|
|
97
|
+
preservesInnerCids: true,
|
|
98
|
+
browserSafeAuth: true,
|
|
99
|
+
delegation: true,
|
|
100
|
+
listing: true,
|
|
101
|
+
deletion: true,
|
|
102
|
+
minBlobSize: 0,
|
|
103
|
+
},
|
|
104
|
+
|
|
105
|
+
/** @type {import("./types.js").StorageBackend["putBlob"]} */
|
|
106
|
+
putBlob: async (bytes, meta = {}) => {
|
|
107
|
+
const name = meta.name || "blob";
|
|
108
|
+
const file = new File([bytes], name, {
|
|
109
|
+
type: meta.type || "application/octet-stream",
|
|
110
|
+
});
|
|
111
|
+
const cid = (await client.uploadFile(file)).toString();
|
|
112
|
+
return {
|
|
113
|
+
id: cid,
|
|
114
|
+
cid,
|
|
115
|
+
backend: "storacha",
|
|
116
|
+
size: bytes.length,
|
|
117
|
+
...(meta.name ? { name } : {}),
|
|
118
|
+
};
|
|
119
|
+
},
|
|
120
|
+
|
|
121
|
+
/** @type {import("./types.js").StorageBackend["getBlob"]} */
|
|
122
|
+
getBlob: async (handle) => {
|
|
123
|
+
const id = handleId(handle);
|
|
124
|
+
const failures = [];
|
|
125
|
+
|
|
126
|
+
for (const gateway of gateways) {
|
|
127
|
+
try {
|
|
128
|
+
const response = await fetch(`${gateway}/ipfs/${id}`);
|
|
129
|
+
if (!response.ok) {
|
|
130
|
+
failures.push(`${gateway}: HTTP ${response.status}`);
|
|
131
|
+
continue;
|
|
132
|
+
}
|
|
133
|
+
return new Uint8Array(await response.arrayBuffer());
|
|
134
|
+
} catch (error) {
|
|
135
|
+
failures.push(`${gateway}: ${error?.message ?? error}`);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
throw new BackendError(
|
|
140
|
+
"NOT_FOUND",
|
|
141
|
+
`No gateway served ${id} (${failures.join("; ")})`,
|
|
142
|
+
);
|
|
143
|
+
},
|
|
144
|
+
|
|
145
|
+
/** @type {import("./types.js").StorageBackend["list"]} */
|
|
146
|
+
list: async (listOptions = {}) => {
|
|
147
|
+
const query = {};
|
|
148
|
+
if (listOptions.size) query.size = listOptions.size;
|
|
149
|
+
if (listOptions.cursor) query.cursor = listOptions.cursor;
|
|
150
|
+
|
|
151
|
+
const result = await client.capability.upload.list(query);
|
|
152
|
+
return result.results.map((upload) => ({
|
|
153
|
+
id: upload.root.toString(),
|
|
154
|
+
cid: upload.root.toString(),
|
|
155
|
+
backend: "storacha",
|
|
156
|
+
size:
|
|
157
|
+
upload.shards?.reduce(
|
|
158
|
+
(total, shard) => total + (shard.size || 0),
|
|
159
|
+
0,
|
|
160
|
+
) || undefined,
|
|
161
|
+
insertedAt: upload.insertedAt,
|
|
162
|
+
updatedAt: upload.updatedAt,
|
|
163
|
+
// Escape hatch: the vendor record, for callers that have not generalised yet.
|
|
164
|
+
raw: upload,
|
|
165
|
+
}));
|
|
166
|
+
},
|
|
167
|
+
|
|
168
|
+
/** @type {import("./types.js").StorageBackend["remove"]} */
|
|
169
|
+
remove: async (handle) => {
|
|
170
|
+
await client.capability.upload.remove(CID.parse(handleId(handle)));
|
|
171
|
+
},
|
|
172
|
+
|
|
173
|
+
/** The underlying client, for the space and UCAN handling that does not generalise. */
|
|
174
|
+
client,
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
export default createStorachaBackend;
|