@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.
@@ -0,0 +1,197 @@
1
+ /**
2
+ * @fileoverview Aleph Cloud storage backend
3
+ *
4
+ * The keyless one. `POST https://ipfs.aleph.cloud/api/v0/add` takes a file with
5
+ * **no API key at any point** and answers `access-control-allow-origin: *`, so
6
+ * a browser posts to it directly — no proxy, no bearer token, nothing on the
7
+ * device that would be an account takeover if it leaked. For an application
8
+ * that has to store something from wherever it happens to be running, that is
9
+ * a different category from every other driver here.
10
+ *
11
+ * Measured against the live host on 2026-09-09, because a driver written from
12
+ * a description is a guess:
13
+ *
14
+ * - a 4 KB `application/vnd.ipld.car` upload came back **byte-identical**, so
15
+ * there is none of the "binary files case by case" hedging other services
16
+ * attach to CARs;
17
+ * - an empty blob is accepted, and answers with the well-known empty-file CID;
18
+ * - retrieval through Aleph's own gateway is exact.
19
+ *
20
+ * ## What it cannot do, and why the shape follows
21
+ *
22
+ * **Only `add` exists.** `dag/import`, `block/put`, `dag/put`, `pin/add` and
23
+ * `cat` all 404 on that host. So there is no CAR import and no way to write a
24
+ * dag-cbor block under its own CID — which since 0.5.2 costs nothing, because
25
+ * the CAR goes up as one opaque file anyway and the inner CIDs come back when
26
+ * we unpack it ourselves.
27
+ *
28
+ * **The id is Aleph's, not ours.** `add` wraps the file in UnixFS and returns
29
+ * *its* CIDv0 for the wrapper. That is not the CID of our bytes, and the handle
30
+ * says so by carrying an `id` and no `cid`. This is exactly the distinction the
31
+ * contract exists for: a caller that treated the two as interchangeable would
32
+ * eventually restore something that was never backed up.
33
+ *
34
+ * **`add` is ingest, not persistence.** Retention comes from a wallet-signed
35
+ * STORE message with the `ipfs` engine, posted to `api2.aleph.im`. That needs a
36
+ * wallet and an SDK, so it is **injected rather than imported**: pass `pin` and
37
+ * the driver declares `pinByCid`; leave it out and the driver is honest about
38
+ * being ingest-only. `relay-button`'s `@le-space/browser` already speaks that
39
+ * API, which is the implementation this hook is shaped for.
40
+ *
41
+ * @author @NiKrause
42
+ * @requires ./types.js - the backend contract
43
+ */
44
+
45
+ /* global FormData */
46
+
47
+ import { defineBackend, handleId, BackendError } from "./types.js";
48
+ import { fetchFromGateways } from "../gateway-fetch.js";
49
+
50
+ /** Aleph's own first: it serves what Aleph ingested without waiting for propagation. */
51
+ export const ALEPH_GATEWAYS = Object.freeze([
52
+ "https://ipfs.aleph.cloud/ipfs",
53
+ "https://dweb.link/ipfs",
54
+ "https://ipfs.io/ipfs",
55
+ ]);
56
+
57
+ const ALEPH_INGEST = "https://ipfs.aleph.cloud/api/v0/add";
58
+ const DEFAULT_TIMEOUT_MS = 120_000;
59
+
60
+ /**
61
+ * Create an Aleph backend.
62
+ *
63
+ * @param {object} [options]
64
+ * @param {string} [options.ingestUrl] - defaults to Aleph's public IPFS host
65
+ * @param {string[]} [options.gateways] - retrieval, tried in order
66
+ * @param {number} [options.timeout] - per request, in ms
67
+ * @param {(cid: string, meta?: object) => Promise<any>} [options.pin] - make it
68
+ * stick. Aleph retains what a wallet-signed STORE message names, so this is
69
+ * the caller's wallet, not ours. Supplying it is what declares `pinByCid`;
70
+ * without it the driver stores and says plainly that it does not retain.
71
+ * @param {typeof fetch} [options.fetch] - for tests, or a browser with its own
72
+ * @returns {import("./types.js").StorageBackend}
73
+ */
74
+ export function createAlephBackend(options = {}) {
75
+ const ingestUrl = options.ingestUrl || ALEPH_INGEST;
76
+ const gateways = options.gateways || ALEPH_GATEWAYS;
77
+ const timeout = options.timeout ?? DEFAULT_TIMEOUT_MS;
78
+ const pin = options.pin;
79
+ const doFetch = options.fetch || globalThis.fetch;
80
+
81
+ if (typeof doFetch !== "function") {
82
+ throw new BackendError(
83
+ "INVALID_BACKEND",
84
+ "The aleph backend needs fetch — pass one in `options.fetch` on a runtime without it",
85
+ );
86
+ }
87
+
88
+ const backend = {
89
+ name: "aleph",
90
+
91
+ capabilities: {
92
+ // Follows from `pin`, rather than being claimed: the contract checks that
93
+ // the flag and the method agree, and this is where they come from.
94
+ pinByCid: Boolean(pin),
95
+ carImport: false,
96
+ // A CAR goes up as one opaque file and comes back byte for byte —
97
+ // measured, not assumed, and the conformance suite re-checks it on every
98
+ // run by verifying each block against its own CID.
99
+ preservesInnerCids: true,
100
+ // No key exists to leak.
101
+ browserSafeAuth: true,
102
+ delegation: false,
103
+ // The IPFS host offers no listing, and the messages API would need the
104
+ // wallet address this driver deliberately does not hold.
105
+ listing: false,
106
+ // Unpinning is a FORGET message, so it belongs with `pin` rather than
107
+ // here. Claiming deletion the driver cannot perform would be worse than
108
+ // saying no.
109
+ deletion: false,
110
+ minBlobSize: 0,
111
+ },
112
+
113
+ async putBlob(bytes, meta = {}) {
114
+ const name = meta.name || "blob";
115
+ const form = new FormData();
116
+ form.append(
117
+ "file",
118
+ new Blob([bytes], { type: meta.contentType || "application/octet-stream" }),
119
+ name,
120
+ );
121
+
122
+ let response;
123
+ try {
124
+ response = await doFetch(ingestUrl, {
125
+ method: "POST",
126
+ body: form,
127
+ signal: AbortSignal.timeout ? AbortSignal.timeout(timeout) : undefined,
128
+ });
129
+ } catch (error) {
130
+ throw new BackendError("UNSUPPORTED", `Aleph ingest is unreachable: ${error.message}`);
131
+ }
132
+
133
+ if (!response.ok) {
134
+ throw new BackendError(
135
+ "UNSUPPORTED",
136
+ `Aleph ingest answered ${response.status} ${response.statusText}`,
137
+ );
138
+ }
139
+
140
+ // `add` answers one JSON object per line. A name with a path in it — and
141
+ // backupDatabase gives every file one — makes the host wrap the file in
142
+ // directories and add a line for each (measured 2026-09-17). The id we
143
+ // keep is the file's own, never a wrapper's.
144
+ let entries;
145
+ try {
146
+ entries = (await response.text())
147
+ .split("\n")
148
+ .filter((line) => line.trim())
149
+ .map((line) => JSON.parse(line));
150
+ } catch {
151
+ throw new BackendError("UNSUPPORTED", "Aleph ingest answered something that is not JSON");
152
+ }
153
+ const body = entries.find((entry) => entry?.Name === name) ?? entries[0];
154
+ if (!body?.Hash) {
155
+ throw new BackendError("UNSUPPORTED", "Aleph ingest returned no Hash");
156
+ }
157
+
158
+ return {
159
+ // Aleph's CIDv0 for the UnixFS wrapper it made. Deliberately not
160
+ // reported as `cid`: it names their encoding of our bytes, not ours.
161
+ id: body.Hash,
162
+ backend: "aleph",
163
+ size: bytes.length,
164
+ ...(meta.name ? { name: meta.name } : {}),
165
+ // Said out loud in the handle, because "stored" and "kept" are not the
166
+ // same thing here and a caller should not have to read this file.
167
+ retained: Boolean(pin),
168
+ };
169
+ },
170
+
171
+ async getBlob(handle) {
172
+ const id = handleId(handle);
173
+ try {
174
+ return await fetchFromGateways(id, { gateways, timeout });
175
+ } catch (error) {
176
+ throw new BackendError("NOT_FOUND", `No blob for ${id}: ${error.message}`);
177
+ }
178
+ },
179
+ };
180
+
181
+ if (pin) {
182
+ /**
183
+ * Ask Aleph to keep something already on IPFS.
184
+ *
185
+ * Class 1 for the pin, class 2 for the bytes — the split the evaluation
186
+ * predicted, and the reason `pinCid` here does not upload anything.
187
+ */
188
+ backend.pinCid = async (cid, meta = {}) => {
189
+ await pin(cid, meta);
190
+ return { id: cid, cid, backend: "aleph", retained: true, ...(meta.name ? { name: meta.name } : {}) };
191
+ };
192
+ }
193
+
194
+ return defineBackend(backend);
195
+ }
196
+
197
+ export default createAlephBackend;
@@ -0,0 +1,325 @@
1
+ /**
2
+ * @fileoverview Lighthouse storage backend for OrbitDB Storage Bridge
3
+ *
4
+ * Pay once, stored in perpetuity: a one-time payment funds an endowment that renews the
5
+ * Filecoin deals. That is a different bargain from every other backend here, and it fits a
6
+ * different job -- a small archive you want to stop thinking about, not a rolling backup
7
+ * whose old versions you would happily expire, because here you pay for those forever too.
8
+ *
9
+ * No SDK dependency. The endpoints and shapes below are the ones `@lighthouse-web3/sdk`
10
+ * 0.4.7 uses: `POST {node}/api/v0/add?wrap-with-directory=false&cid-version=1` with one
11
+ * multipart `file`, `POST {node}/api/v0/dag/import` for a CAR, `GET
12
+ * {api}/api/user/files_uploaded` for the listing and `DELETE {api}/api/user/delete_file`.
13
+ * Their Node client insists on a path on disk; posting the multipart body ourselves takes
14
+ * the bytes we already hold, and works unchanged in a browser.
15
+ *
16
+ * What the Pinata driver learned against a live account applies here too, so it is built
17
+ * in rather than rediscovered: a backup's CAR goes up as a plain file unless CAR import is
18
+ * asked for, because `backupDatabase` restores by fetching the CAR back; a file name
19
+ * carries no path, because a Kubo-shaped `add` turns a path into folders; and a refusal
20
+ * says what the service said.
21
+ *
22
+ * @author @NiKrause
23
+ * @requires ./types.js - the backend contract
24
+ * @see {@link ../../docs/STORAGE-BACKENDS.md} for the cost model and the trade-offs
25
+ */
26
+
27
+ /* global FormData, URLSearchParams */
28
+
29
+ import { defineBackend, handleId, BackendError } from "./types.js";
30
+
31
+ const DEFAULT_NODE = "https://upload.lighthouse.storage";
32
+ const DEFAULT_API = "https://api.lighthouse.storage";
33
+ const DEFAULT_GATEWAY = "https://gateway.lighthouse.storage";
34
+
35
+ const CAR_MIME = "application/vnd.ipld.car";
36
+
37
+ /** How often a gateway 429 is waited out before it counts as a failure. */
38
+ const GATEWAY_RETRIES = 4;
39
+ /** The longest single wait, whatever Retry-After asks for. */
40
+ const MAX_RETRY_WAIT_MS = 30_000;
41
+
42
+ /**
43
+ * Lighthouse's words for an account whose plan does not include the call. Measured on
44
+ * 2026-09-17: an upload with a valid key on an expired trial answered 403 "Trial expired
45
+ * … Please upgrade to a paid plan", while the listing still answered 200.
46
+ */
47
+ const PLAN_REFUSAL = /trial (period )?(has )?expired|upgrade to a paid plan/i;
48
+
49
+ /**
50
+ * What a refusal means to a caller: a plan without the feature is "cannot", a key the
51
+ * service rejects is a misconfigured backend, only a 404 is "not there", and anything
52
+ * else is "cannot".
53
+ */
54
+ const codeFor = (status, detail = "") => {
55
+ if (status === 404) return "NOT_FOUND";
56
+ if (PLAN_REFUSAL.test(detail)) return "UNSUPPORTED";
57
+ if (status === 401 || status === 403) return "INVALID_BACKEND";
58
+ return "UNSUPPORTED";
59
+ };
60
+
61
+ /** The first 240 characters of what the service said, on one line. */
62
+ const reasonOf = async (response) =>
63
+ (await response.text().catch(() => "")).replace(/\s+/g, " ").trim().slice(0, 240);
64
+
65
+ /** Retry-After when the gateway sends one, otherwise doubling from a second. */
66
+ const retryWait = (response, attempt) => {
67
+ const header = response.headers.get("retry-after");
68
+ const seconds = header === null ? NaN : Number(header);
69
+ const ms = Number.isFinite(seconds) && seconds >= 0 ? seconds * 1000 : 1000 * 2 ** attempt;
70
+ return Math.min(ms, MAX_RETRY_WAIT_MS);
71
+ };
72
+
73
+ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
74
+
75
+ /** A base URL as given, or over https when given as a bare domain. */
76
+ const baseUrl = (value) => {
77
+ const trimmed = String(value).trim().replace(/\/+$/, "");
78
+ return /^[a-z][a-z0-9+.-]*:\/\//i.test(trimmed) ? trimmed : `https://${trimmed}`;
79
+ };
80
+
81
+ /**
82
+ * An `add` or `dag/import` answer: one JSON object, a JSON array, or one object per
83
+ * line — Kubo answers the last way whenever more than one entry comes back.
84
+ */
85
+ const parseEntries = (text) => {
86
+ try {
87
+ const parsed = JSON.parse(text);
88
+ return Array.isArray(parsed) ? parsed : [parsed];
89
+ } catch {
90
+ return text
91
+ .split("\n")
92
+ .map((line) => line.trim())
93
+ .filter(Boolean)
94
+ .map((line) => {
95
+ try {
96
+ return JSON.parse(line);
97
+ } catch {
98
+ return null;
99
+ }
100
+ })
101
+ .filter(Boolean);
102
+ }
103
+ };
104
+
105
+ /**
106
+ * Create a Lighthouse backend.
107
+ *
108
+ * @param {object} options
109
+ * @param {string} [options.apiKey] - Lighthouse API key; falls back to LIGHTHOUSE_API_KEY
110
+ * @param {"shared"|"user"} [options.keyOwnership="shared"] - who the key belongs to.
111
+ * Lighthouse lets each person mint their own key by signing with their own wallet
112
+ * (`POST /api/auth/create_api_key`), and only then is nothing secret being shipped to a
113
+ * browser -- so this is what decides `browserSafeAuth`. The key is unscoped and
114
+ * long-lived either way: an XSS is a full account takeover, user-minted or not.
115
+ * @param {boolean} [options.carImport=false] - send CARs to `dag/import`, so Lighthouse
116
+ * unpacks them and the blocks inside keep their CIDs. The handle then names the CAR's
117
+ * root, which a gateway serves as that block, not as the CAR — so `backupDatabase`,
118
+ * which restores by fetching the CAR back, needs this off. Without it a CAR goes up as
119
+ * one plain file and comes back byte for byte.
120
+ * @param {string} [options.gateway] - retrieval gateway; a bare domain is read over https
121
+ * @param {number} [options.gatewayRetries=4] - how often a gateway 429 is waited out
122
+ * @param {string} [options.node] - upload node
123
+ * @param {string} [options.api] - account API
124
+ * @param {"walrus"} [options.storageType] - Lighthouse can put the bytes on Walrus/Sui instead
125
+ * @returns {import("./types.js").StorageBackend}
126
+ */
127
+ export function createLighthouseBackend(options = {}) {
128
+ const apiKey =
129
+ options.apiKey ||
130
+ (typeof process !== "undefined"
131
+ ? process.env?.LIGHTHOUSE_API_KEY
132
+ : undefined);
133
+
134
+ if (!apiKey) {
135
+ throw new BackendError(
136
+ "INVALID_BACKEND",
137
+ "createLighthouseBackend needs an apiKey (or LIGHTHOUSE_API_KEY)",
138
+ );
139
+ }
140
+
141
+ const node = baseUrl(options.node || DEFAULT_NODE);
142
+ const api = baseUrl(options.api || DEFAULT_API);
143
+ const gateway = baseUrl(options.gateway || DEFAULT_GATEWAY);
144
+ const storageType = options.storageType;
145
+ const carImport = options.carImport === true;
146
+ const gatewayRetries = options.gatewayRetries ?? GATEWAY_RETRIES;
147
+
148
+ const authHeaders = (extra = {}) => ({
149
+ Authorization: `Bearer ${apiKey}`,
150
+ ...(storageType ? { "X-Storage-Type": storageType } : {}),
151
+ ...extra,
152
+ });
153
+
154
+ /** Call the account API; a refusal carries the service's reason and a code. */
155
+ const call = async (url, init = {}) => {
156
+ const response = await fetch(url, init);
157
+ if (!response.ok) {
158
+ const path = url.replace(api, "");
159
+ const reason = await reasonOf(response);
160
+ throw new BackendError(
161
+ codeFor(response.status, reason),
162
+ `Lighthouse ${init.method || "GET"} ${path} failed: HTTP ${response.status} ${reason}`.trim() +
163
+ (PLAN_REFUSAL.test(reason) ? " (the account's plan does not include this)" : ""),
164
+ );
165
+ }
166
+ return response;
167
+ };
168
+
169
+ /** One page of the account listing, normalised to backend entries. */
170
+ const listFiles = async (listOptions = {}) => {
171
+ const query = new URLSearchParams({
172
+ lastKey: listOptions.cursor ?? "null",
173
+ fileType: listOptions.fileType || "all",
174
+ });
175
+ const response = await call(`${api}/api/user/files_uploaded?${query}`, {
176
+ headers: authHeaders({ "Content-Type": "application/json" }),
177
+ });
178
+ const body = await response.json();
179
+ const files = body?.data?.fileList || body?.fileList || [];
180
+ return files.map((file) => ({
181
+ id: file.cid,
182
+ cid: file.cid,
183
+ backend: "lighthouse",
184
+ size: Number(file.fileSizeInBytes) || undefined,
185
+ // Deletion addresses Lighthouse's own file id, not the CID.
186
+ fileId: file.id,
187
+ insertedAt: file.createdAt,
188
+ raw: file,
189
+ }));
190
+ };
191
+
192
+ return defineBackend({
193
+ name: "lighthouse",
194
+
195
+ capabilities: {
196
+ // No pin-by-CID endpoint: the bytes have to travel.
197
+ pinByCid: false,
198
+ // dag/import unpacks a CAR and keeps the CIDs inside — opt-in, see carImport above.
199
+ carImport,
200
+ // A plain /api/v0/add is hashed by Lighthouse with its own chunker and always comes
201
+ // back as UnixFS or raw -- never dag-cbor -- so an OrbitDB block cannot be stored
202
+ // under its own CID here. Backups go up as a CAR.
203
+ preservesInnerCids: false,
204
+ browserSafeAuth: options.keyOwnership === "user",
205
+ delegation: false,
206
+ listing: true,
207
+ deletion: true,
208
+ minBlobSize: 0,
209
+ },
210
+
211
+ /** @type {import("./types.js").StorageBackend["putBlob"]} */
212
+ putBlob: async (bytes, meta = {}) => {
213
+ const name = meta.name || "blob";
214
+ const type = meta.type || "application/octet-stream";
215
+ const isCar = carImport && (meta.car ?? type === CAR_MIME);
216
+
217
+ // No path in the file's own name: backupDatabase names files `<space>/backup-…`,
218
+ // and a Kubo-shaped add turns a path into folders and answers for those too. The
219
+ // SDK sends the base name for the same reason. dag/import wants a .car name.
220
+ const baseName = name.split("/").filter(Boolean).pop() || "blob";
221
+ const fileName = isCar && !/\.car$/i.test(baseName) ? `${baseName}.car` : baseName;
222
+
223
+ const form = new FormData();
224
+ form.append("file", new File([bytes], fileName, { type }));
225
+
226
+ const url = isCar
227
+ ? `${node}/api/v0/dag/import`
228
+ : `${node}/api/v0/add?wrap-with-directory=false&cid-version=1`;
229
+
230
+ const response = await fetch(url, {
231
+ method: "POST",
232
+ headers: authHeaders(),
233
+ body: form,
234
+ });
235
+ if (!response.ok) {
236
+ const reason = await reasonOf(response);
237
+ throw new BackendError(
238
+ codeFor(response.status, reason),
239
+ `Lighthouse upload failed: HTTP ${response.status} ${reason}`.trim() +
240
+ (PLAN_REFUSAL.test(reason) ? " (the account's plan does not include uploads)" : ""),
241
+ );
242
+ }
243
+
244
+ const entries = parseEntries(await response.text());
245
+ const data = entries.find((entry) => entry?.Name === fileName) ?? entries[0] ?? {};
246
+ const cid = data.Hash || data.cid || data.Root?.Cid?.["/"];
247
+ if (!cid) {
248
+ throw new BackendError(
249
+ "UNSUPPORTED",
250
+ `Lighthouse accepted the upload but returned no CID: ${JSON.stringify(data).slice(0, 200)}`,
251
+ );
252
+ }
253
+
254
+ return {
255
+ id: cid,
256
+ // Only an imported CAR comes back under a CID that is ours — its root. A plain
257
+ // upload is hashed by Lighthouse, so its CID names their encoding of our bytes.
258
+ ...(isCar ? { cid } : {}),
259
+ backend: "lighthouse",
260
+ size: Number(data.Size ?? bytes.length),
261
+ raw: data,
262
+ };
263
+ },
264
+
265
+ /** @type {import("./types.js").StorageBackend["getBlob"]} */
266
+ getBlob: async (handle) => {
267
+ const cid = handleId(handle);
268
+ let response;
269
+ // A 429 says "not now", not "not there": wait as told, a few times.
270
+ for (let attempt = 0; ; attempt++) {
271
+ response = await fetch(`${gateway}/ipfs/${cid}`);
272
+ if (response.status !== 429 || attempt >= gatewayRetries) break;
273
+ await response.body?.cancel();
274
+ await sleep(retryWait(response, attempt));
275
+ }
276
+ if (!response.ok) {
277
+ const reason = await reasonOf(response);
278
+ throw new BackendError(
279
+ "NOT_FOUND",
280
+ `Lighthouse gateway did not serve ${cid}: HTTP ${response.status}` +
281
+ (reason ? ` "${reason}"` : "") +
282
+ (response.status === 402
283
+ ? " (402 is what this gateway answers for content it does not hold)"
284
+ : "") +
285
+ (response.status === 429 ? ` (still rate limited after ${gatewayRetries} retries)` : ""),
286
+ );
287
+ }
288
+ return new Uint8Array(await response.arrayBuffer());
289
+ },
290
+
291
+ /** @type {import("./types.js").StorageBackend["list"]} */
292
+ list: listFiles,
293
+
294
+ /** @type {import("./types.js").StorageBackend["remove"]} */
295
+ remove: async (handle) => {
296
+ let fileId = typeof handle === "object" ? handle?.fileId : undefined;
297
+
298
+ if (!fileId) {
299
+ // putBlob does not learn the file id, so a delete usually has to look it up.
300
+ // Match on the CID and refuse rather than guess: this account is paid for once
301
+ // and its files are meant to be permanent.
302
+ const cid = handleId(handle);
303
+ const entries = await listFiles();
304
+ const match = entries.find((entry) => entry.cid === cid);
305
+ if (!match?.fileId) {
306
+ throw new BackendError(
307
+ "NOT_FOUND",
308
+ `No Lighthouse file found for ${cid} in the first page of the listing; pass the listed entry to delete it`,
309
+ );
310
+ }
311
+ fileId = match.fileId;
312
+ }
313
+
314
+ await call(
315
+ `${api}/api/user/delete_file?id=${encodeURIComponent(fileId)}`,
316
+ {
317
+ method: "DELETE",
318
+ headers: authHeaders({ "Content-Type": "application/json" }),
319
+ },
320
+ );
321
+ },
322
+ });
323
+ }
324
+
325
+ export default createLighthouseBackend;
@@ -0,0 +1,140 @@
1
+ /**
2
+ * @fileoverview In-process storage backend for OrbitDB Storage Bridge
3
+ *
4
+ * The reference driver: everything the contract asks for, nothing a network can add.
5
+ * It exists so the conformance suite has a baseline that cannot fail for reasons of
6
+ * its own, and so demos and tests can run a full backup/restore cycle with no service,
7
+ * no credentials and no wallet.
8
+ *
9
+ * Ids are real CIDs — raw codec, sha-256 — so a caller that treats the id as content
10
+ * addressed is not learning a habit that only works here.
11
+ *
12
+ * @author @NiKrause
13
+ * @requires ./types.js - the backend contract
14
+ */
15
+
16
+ import { CID } from "multiformats/cid";
17
+ import { sha256 } from "multiformats/hashes/sha2";
18
+ import * as raw from "multiformats/codecs/raw";
19
+ import { defineBackend, handleId, BackendError } from "./types.js";
20
+
21
+ /**
22
+ * Create an in-process backend.
23
+ *
24
+ * @param {object} [options]
25
+ * @param {Map<string, Uint8Array>} [options.store] - bring your own map to inspect it from a test
26
+ * @param {(cid: string) => Promise<Uint8Array|null>} [options.resolve] - when given, the
27
+ * backend can pin by CID: `pinCid()` calls this to fetch the bytes, the way a real
28
+ * pinning service fetches from the IPFS network. Declaring `pinByCid` follows from it.
29
+ * @returns {import("./types.js").StorageBackend}
30
+ */
31
+ export function createMemoryBackend(options = {}) {
32
+ const store = options.store || new Map();
33
+ const resolve = options.resolve;
34
+
35
+ const put = async (bytes, meta = {}) => {
36
+ const digest = await sha256.digest(bytes);
37
+ const cid = CID.create(1, raw.code, digest).toString();
38
+ // Copied, not referenced. A real backend serialises the bytes onto a wire,
39
+ // so a caller can reuse its buffer afterwards without touching what was
40
+ // stored; holding the caller's array here would make this driver the one
41
+ // place that behaves differently, and it is the driver every other one is
42
+ // measured against.
43
+ store.set(cid, bytes.slice());
44
+ return {
45
+ id: cid,
46
+ cid,
47
+ backend: "memory",
48
+ size: bytes.length,
49
+ ...(meta.name ? { name: meta.name } : {}),
50
+ };
51
+ };
52
+
53
+ const backend = {
54
+ name: "memory",
55
+
56
+ capabilities: {
57
+ pinByCid: Boolean(resolve),
58
+ carImport: false,
59
+ // the bytes are handed back exactly as they arrived, keyed by their own
60
+ // hash — and by copy, so "exactly as they arrived" survives a caller that
61
+ // reuses its buffer
62
+ preservesInnerCids: true,
63
+ browserSafeAuth: true,
64
+ delegation: false,
65
+ listing: true,
66
+ deletion: true,
67
+ minBlobSize: 0,
68
+ },
69
+
70
+ putBlob: put,
71
+
72
+ getBlob: async (handle) => {
73
+ const id = handleId(handle);
74
+ const bytes = store.get(id);
75
+ if (!bytes) {
76
+ throw new BackendError("NOT_FOUND", `No blob for ${id}`);
77
+ }
78
+ // Likewise on the way out: what the caller does to this must not reach
79
+ // back into the store.
80
+ return bytes.slice();
81
+ },
82
+
83
+ list: async () =>
84
+ Array.from(store.entries()).map(([cid, bytes]) => ({
85
+ id: cid,
86
+ cid,
87
+ backend: "memory",
88
+ size: bytes.length,
89
+ })),
90
+
91
+ remove: async (handle) => {
92
+ store.delete(handleId(handle));
93
+ },
94
+ };
95
+
96
+ if (resolve) {
97
+ backend.pinCid = async (cid, meta = {}) => {
98
+ const key = String(cid);
99
+ const bytes = await resolve(key);
100
+ if (!bytes) {
101
+ throw new BackendError("NOT_FOUND", `Cannot resolve ${cid} to pin it`);
102
+ }
103
+
104
+ // Pinning stores the bytes under the CID it was handed. Re-deriving one here
105
+ // would silently re-code the block -- a dag-cbor entry pinned as raw keeps its
106
+ // digest and loses its codec, which reads as success and restores as garbage.
107
+ const parsed = CID.parse(key);
108
+ if (parsed.multihash.code !== sha256.code) {
109
+ throw new BackendError(
110
+ "UNSUPPORTED",
111
+ `${cid} is not sha-256 addressed; this backend cannot verify it`,
112
+ );
113
+ }
114
+ const rehashed = CID.create(
115
+ parsed.version,
116
+ parsed.code,
117
+ await sha256.digest(bytes),
118
+ );
119
+ if (rehashed.toString() !== key) {
120
+ throw new BackendError(
121
+ "NOT_FOUND",
122
+ `Resolved bytes for ${cid} do not hash to it`,
123
+ );
124
+ }
125
+
126
+ store.set(key, bytes);
127
+ return {
128
+ id: key,
129
+ cid: key,
130
+ backend: "memory",
131
+ size: bytes.length,
132
+ ...(meta.name ? { name: meta.name } : {}),
133
+ };
134
+ };
135
+ }
136
+
137
+ return defineBackend(backend);
138
+ }
139
+
140
+ export default createMemoryBackend;