@le-space/orbitdb-storage-bridge 0.10.0 → 0.12.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/README.md CHANGED
@@ -5,8 +5,8 @@
5
5
 
6
6
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
7
7
  [![Node.js](https://img.shields.io/badge/Node.js-22+-green.svg)](https://nodejs.org/)
8
- [![CI/CD Pipeline](https://github.com/NiKrause/@le-space/orbitdb-storage-bridge/actions/workflows/ci.yml/badge.svg)](https://github.com/NiKrause/@le-space/orbitdb-storage-bridge/actions/workflows/ci.yml)
9
- [![ESLint](https://img.shields.io/badge/ESLint-passing-brightgreen.svg)](https://github.com/NiKrause/@le-space/orbitdb-storage-bridge/actions/workflows/ci.yml)
8
+ [![CI/CD Pipeline](https://github.com/NiKrause/orbitdb-storage-bridge/actions/workflows/ci.yml/badge.svg)](https://github.com/NiKrause/orbitdb-storage-bridge/actions/workflows/ci.yml)
9
+ [![ESLint](https://img.shields.io/badge/ESLint-passing-brightgreen.svg)](https://github.com/NiKrause/orbitdb-storage-bridge/actions/workflows/ci.yml)
10
10
  [![npm version](https://img.shields.io/npm/v/@le-space/orbitdb-storage-bridge.svg)](https://www.npmjs.com/package/@le-space/orbitdb-storage-bridge)
11
11
 
12
12
  > [!NOTE]
@@ -60,6 +60,18 @@
60
60
  - [License](#license)
61
61
 
62
62
 
63
+ ## Does this work from a browser? Measure it
64
+
65
+ `examples/browser/storage-probe/` is a static page that asks the services this package talks to,
66
+ from a real browser, with no server: do Aleph, Pinata and Lighthouse answer a page at all, does
67
+ their CORS survive a refusal as well as a success, and does anybody on IPFS hold a given CID at an
68
+ address a browser can dial. With your own key it does a real upload and reads it back. The key is
69
+ kept in that browser and sent only to the service it belongs to.
70
+
71
+ It is published from this repository's Pages, and it opens from a checkout just as well — there is
72
+ no build step, which is the point: what it measures is a browser talking to a service, with
73
+ nothing in between.
74
+
63
75
  ## Status: Storacha sunset (May 2026)
64
76
 
65
77
  Storacha switched off user writes in **May 2026** and has since decommissioned the service.
@@ -73,6 +85,17 @@ Verified on 2026-09-05:
73
85
  | the widget demo CID linked in the roadmap below | `504` on `w3s.link`, `dweb.link`, `ipfs.io` and `trustless-gateway.link` |
74
86
  | `@storacha/client` on npm | last release `2.1.4`, 2026-05-15, not marked deprecated |
75
87
 
88
+ The gateway those redirects pointed at is gone too. On **2026-09-21** Protocol Labs retired
89
+ `ipfs.io` and `dweb.link`: both answer `429` with an RFC 8594 `Sunset` header and a link to
90
+ [gatewaychanges.ipfs.io](https://gatewaychanges.ipfs.io/), so `storacha.link` and `w3s.link`
91
+ now redirect to a closed door. Retrieval defaults here are `ipfs.aleph.cloud` — the one free
92
+ path gateway measured still serving arbitrary CIDs on 2026-09-23 — with
93
+ `trustless-gateway.link` available for verifiable single-block requests
94
+ (`Accept: application/vnd.ipld.raw`). One host is not a fallback chain, which is why `peer-fetch.js` fetches from the providers that
95
+ hold the blocks instead — bitswap over libp2p, measured from a real page at 0.73 s to dial and
96
+ 0.26 s for the block, with no credential in the path. See
97
+ [docs/RECOVERY-ON-A-SECOND-DEVICE.md](docs/RECOVERY-ON-A-SECOND-DEVICE.md).
98
+
76
99
  The shutdown is traceable in the open:
77
100
  [`upload-service#708`](https://github.com/storacha/upload-service/pull/708) added a `writesDisabled`
78
101
  kill switch that makes the eight user-initiated write capabilities
@@ -157,7 +180,7 @@ The project includes **Svelte components** for browser-based demos and integrati
157
180
  ## Roadmap
158
181
 
159
182
  > Being re-based on a backend interface instead of a single vendor — the plan is
160
- > [issue 54](https://github.com/NiKrause/@le-space/orbitdb-storage-bridge/issues/54), not here. The WebAuthn/varsig items below survive
183
+ > [issue 54](https://github.com/NiKrause/orbitdb-storage-bridge/issues/54), not here. The WebAuthn/varsig items below survive
161
184
  > unchanged; the Storacha-named ones become backend-agnostic.
162
185
 
163
186
  - [ ] Live parallel persistence: hand an open database a backend-backed OrbitDB `ComposedStorage`, so every block is written to a backend **as it is created** — during sync and after each update — rather than only when a backup runs.
@@ -169,11 +192,11 @@ The project includes **Svelte components** for browser-based demos and integrati
169
192
  - [ ] v0.4.4 (Feb 2026): Latest-backup pointer (single CID) to avoid listing via the Storacha SDK and restore from the IPFS network for initial OrbitDB syncs.
170
193
  - [ ] After each backup, write a small pointer record (JSON) that stores the latest metadata CID, CAR CID, and last heads (block CID).
171
194
  - [ ] Store that pointer in a user-controlled place (local storage, QR/share link, WebAuthN largetBlog extension or file download).
172
- - [ ] v0.5.0 (Feb 2026): OrbitDB CustomStorage (StorachaStorage) ([issue 23](https://github.com/NiKrause/@le-space/orbitdb-storage-bridge/issues/23)).
195
+ - [ ] v0.5.0 (Feb 2026): OrbitDB CustomStorage (StorachaStorage) ([issue 23](https://github.com/NiKrause/orbitdb-storage-bridge/issues/23)).
173
196
  - [ ] v0.6.0 (Mar 2026): WebAuthN + varsig signing/verification (Ed25519 and P-256) for OrbitDB oplog. https://github.com/ChainAgnostic/varsig/blob/main/README.md
174
197
  - [ ] v0.6.1 (Mar 2026): WebAuthN + SimpleEncryption example that uses WebAuthN+PRF key material for encrypted backups and restore.
175
198
  - [ ] v0.7.0 (Apr 2026): WebAuthN + OrbitDB AccessController (store a UCAN instead of only a DID for admin/write access).
176
- - [ ] Alice (authenticated via UCAN or Storacha credentials) can delegate/revoke access for Bob with custom/default capabilities ([issue 16](https://github.com/NiKrause/@le-space/orbitdb-storage-bridge/issues/16)). See [WebAuthN Upload Wall](https://github.com/NiKrause/ucan-upload-wall/tree/browser-only/web) and the [live demo](https://bafybeibdcnp7pr26okzr6kbygcounsz3klyg3vydxwwovmz2ljyzfmprre.ipfs.w3s.link/).
199
+ - [ ] Alice (authenticated via UCAN or Storacha credentials) can delegate/revoke access for Bob with custom/default capabilities ([issue 16](https://github.com/NiKrause/orbitdb-storage-bridge/issues/16)). See [WebAuthN Upload Wall](https://github.com/NiKrause/ucan-upload-wall/tree/browser-only/web) and the [live demo](https://bafybeibdcnp7pr26okzr6kbygcounsz3klyg3vydxwwovmz2ljyzfmprre.ipfs.w3s.link/).
177
200
  - [ ] v0.7.1 (May 2026): Storacha Backup & Restore Svelte widget with WebAuthN-varsig UCAN signing/verification (Ed25519/P-256).
178
201
  - [ ] v0.7.2 (May 2026): Storacha Backup & Restore React widget with WebAuthN-varsig UCAN signing/verification (Ed25519/P-256).
179
202
  - [ ] v0.7.3 (May 2026): Storacha Backup & Restore React widget with WebAuthN-varsig UCAN delegation (Ed25519/P-256).
@@ -224,6 +247,24 @@ The scripts written against Storacha's space and UCAN model are in
224
247
  [`examples/storacha/`](examples/storacha/README.md). None of them runs end to
225
248
  end since the uploads stopped, and the README there says what replaced each.
226
249
 
250
+ ### In a browser: a database back on a device that has nothing
251
+
252
+ [**funkpost's recovery page**](https://nikrause.github.io/funkpost/recovery/) runs the whole
253
+ [recovery procedure](docs/RECOVERY-ON-A-SECOND-DEVICE.md) in a phone's browser, with a
254
+ security key and nothing else ([source](https://github.com/NiKrause/funkpost/tree/main/examples/recovery)):
255
+
256
+ 1. **the key gives the identity** — the DID, and a signing key derived from its PRF output,
257
+ the same on every device;
258
+ 2. **a list** is made and written to;
259
+ 3. **`dehydrate`** backs it up to Aleph as a CAR, and publishes an IPNS pointer under a name
260
+ the key derives;
261
+ 4. **on another device** — or the same one, wiped — the same key finds the pointer, **`hydrate`**
262
+ brings the list back, and the list takes new entries, because the writer is the same.
263
+
264
+ On 21 September 2026 it ran that way on two phones: a Galaxy Fold 5 backed up and was reset,
265
+ and a Galaxy A57 with the same key brought the list back and wrote to it. The page says at
266
+ every step which service it contacts; the technical details sit behind one button.
267
+
227
268
  ### Svelte Components
228
269
 
229
270
  For browser-based integration, this project includes Svelte components for authentication, backup/restore, P2P replication, and WebAuthn biometric authentication. See [**SVELTE-COMPONENTS.md**](SVELTE-COMPONENTS.md) for complete documentation of all available components and demonstrations.
@@ -47,12 +47,16 @@
47
47
  import { defineBackend, handleId, BackendError } from "./types.js";
48
48
  import { fetchFromGateways } from "../gateway-fetch.js";
49
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
- ]);
50
+ /**
51
+ * Aleph's own, and only Aleph's own.
52
+ *
53
+ * `dweb.link` and `ipfs.io` were the two fallbacks behind it until Protocol
54
+ * Labs retired them on 2026-09-21; they answer 429 with a `Sunset` header now.
55
+ * Nothing free was found to replace them that will serve an arbitrary CID, so
56
+ * this driver's retrieval depends on one host until the peer path in #112
57
+ * lands. A caller with its own gateway passes `gateways`.
58
+ */
59
+ export const ALEPH_GATEWAYS = Object.freeze(["https://ipfs.aleph.cloud/ipfs"]);
56
60
 
57
61
  const ALEPH_INGEST = "https://ipfs.aleph.cloud/api/v0/add";
58
62
  const DEFAULT_TIMEOUT_MS = 120_000;
@@ -0,0 +1,186 @@
1
+ /**
2
+ * @fileoverview One backend from a plain choice — a name and, where a service
3
+ * needs one, a key.
4
+ *
5
+ * `resolveBackend` understands a ready backend or Storacha credentials, which is
6
+ * what a Node caller has. A page has something smaller and less trusting: a
7
+ * reader ticked *Lighthouse* and pasted their own key, and nothing else is
8
+ * known. This turns that into a driver.
9
+ *
10
+ * ## Every vendor module is imported lazily, and that is the point
11
+ *
12
+ * A page that chose Aleph must not ship the Pinata and Lighthouse drivers, and
13
+ * a page that chose none of them must ship no vendor code at all. Each branch
14
+ * therefore `import()`s exactly what it builds — the same reason
15
+ * `resolveBackend` loads Storacha only when it builds one, where the module
16
+ * costs about 88 kB gzipped.
17
+ *
18
+ * ## Several choices are a mirror, not a loop in the caller
19
+ *
20
+ * `kind: ["aleph", "lighthouse"]` builds both and returns
21
+ * {@link ../backends/mirror.js createMirrorBackend} over them, so the caller's
22
+ * code is the same whether one service was ticked or three.
23
+ *
24
+ * ## A gateway belongs to the service it came from
25
+ *
26
+ * Since the public path gateways were retired (#111), the gateway a reader can
27
+ * actually use is usually their own account's — `<name>.mypinata.cloud`,
28
+ * `<name>.lighthouseweb3.xyz` — and those are not interchangeable: Pinata's
29
+ * answers 401 for a Lighthouse CID, Lighthouse's answers 402. So `gateway` is
30
+ * a string only while one service is chosen, and an object keyed by service
31
+ * once several are. Handing one string to three drivers is refused rather than
32
+ * half-working.
33
+ *
34
+ * ## What it refuses
35
+ *
36
+ * A key that is missing is refused here, with the name of the service in the
37
+ * message, rather than at the first upload — a page can then keep the button
38
+ * disabled and say why. Nothing in this module reads an environment variable on
39
+ * a page's behalf: in a browser there is no environment, and a driver that
40
+ * silently finds a key somewhere else is a driver nobody can reason about. The
41
+ * per-driver fallbacks to `process.env` stay where they are, for Node.
42
+ *
43
+ * @author @NiKrause
44
+ * @requires ./types.js - the contract every branch returns
45
+ */
46
+
47
+ import { BackendError } from "./types.js";
48
+
49
+ /**
50
+ * Aleph's driver tries a list of `…/ipfs` prefixes, while a reader types a
51
+ * host. Accept either, and a bare domain as https, like the other drivers do.
52
+ */
53
+ const alephGateway = (value) => {
54
+ const withScheme = /^https?:\/\//i.test(value) ? value : `https://${value}`;
55
+ const trimmed = withScheme.replace(/\/+$/, "");
56
+ return trimmed.endsWith("/ipfs") ? trimmed : `${trimmed}/ipfs`;
57
+ };
58
+
59
+ /** The names a caller may ask for. */
60
+ export const BACKEND_KINDS = Object.freeze([
61
+ "aleph",
62
+ "pinata",
63
+ "lighthouse",
64
+ "storacha",
65
+ "memory",
66
+ ]);
67
+
68
+ /**
69
+ * Build a backend from a choice.
70
+ *
71
+ * @param {object} choice
72
+ * @param {string|string[]} choice.kind - one name, or several for a mirror
73
+ * @param {string} [choice.jwt] - Pinata
74
+ * @param {() => Promise<string>} [choice.getUploadUrl] - Pinata, without a secret in the page
75
+ * @param {string} [choice.apiKey] - Lighthouse
76
+ * @param {"shared"|"user"} [choice.keyOwnership] - Lighthouse: whose key it is,
77
+ * which is what decides `browserSafeAuth`
78
+ * @param {string|Record<string, string>} [choice.gateway] - retrieval gateway:
79
+ * a string for a single kind, or `{ pinata: "…", lighthouse: "…" }` for several.
80
+ * A bare domain is read as https, as each driver already accepts
81
+ * @param {string[]} [choice.gateways] - retrieval gateways, tried in order; wins
82
+ * over `gateway` where a driver takes a list
83
+ * @param {object} [choice.options] - passed through to the driver, for anything
84
+ * this signature does not name
85
+ * @param {"one"|"all"} [choice.require] - for several kinds: how many must accept a write
86
+ * @returns {Promise<import("./types.js").StorageBackend>}
87
+ */
88
+ export async function createBackendFromChoice(choice = {}) {
89
+ const kinds = Array.isArray(choice.kind) ? choice.kind : [choice.kind];
90
+ const wanted = kinds.filter(Boolean);
91
+
92
+ if (wanted.length === 0) {
93
+ throw new BackendError(
94
+ "INVALID_BACKEND",
95
+ `Pick a backend: ${BACKEND_KINDS.join(", ")}`,
96
+ );
97
+ }
98
+ for (const kind of wanted) {
99
+ if (!BACKEND_KINDS.includes(kind)) {
100
+ throw new BackendError(
101
+ "INVALID_BACKEND",
102
+ `Unknown backend "${kind}" — pick one of ${BACKEND_KINDS.join(", ")}`,
103
+ );
104
+ }
105
+ }
106
+
107
+ if (wanted.length > 1 && typeof choice.gateway === "string") {
108
+ throw new BackendError(
109
+ "INVALID_BACKEND",
110
+ `A gateway belongs to one service — with several kinds pass an object: ` +
111
+ `{ gateway: { ${wanted.map((k) => `${k}: "…"`).join(", ")} } }`,
112
+ );
113
+ }
114
+
115
+ if (wanted.length > 1) {
116
+ const backends = [];
117
+ for (const kind of wanted) {
118
+ backends.push(await createBackendFromChoice({ ...choice, kind }));
119
+ }
120
+ const { createMirrorBackend } = await import("./mirror.js");
121
+ return createMirrorBackend(backends, { require: choice.require ?? "one" });
122
+ }
123
+
124
+ const [kind] = wanted;
125
+ const extra = choice.options ?? {};
126
+ const gateway =
127
+ typeof choice.gateway === "string" ? choice.gateway : choice.gateway?.[kind];
128
+ const gateways = choice.gateways ? { gateways: choice.gateways } : {};
129
+ // Pinata and Lighthouse take one gateway; Aleph takes the list it tries in
130
+ // order, so a single choice becomes a list of one.
131
+ const oneGateway = gateway ? { gateway } : {};
132
+
133
+ if (kind === "aleph") {
134
+ const { createAlephBackend } = await import("./aleph.js");
135
+ const fromOne =
136
+ gateway && !choice.gateways ? { gateways: [alephGateway(gateway)] } : {};
137
+ return createAlephBackend({ ...gateways, ...fromOne, ...extra });
138
+ }
139
+
140
+ if (kind === "pinata") {
141
+ if (
142
+ !choice.jwt &&
143
+ !choice.getUploadUrl &&
144
+ !extra.jwt &&
145
+ !extra.getUploadUrl
146
+ ) {
147
+ throw new BackendError(
148
+ "INVALID_BACKEND",
149
+ "Pinata needs a JWT, or a getUploadUrl function that mints a presigned URL",
150
+ );
151
+ }
152
+ const { createPinataBackend } = await import("./pinata.js");
153
+ return createPinataBackend({
154
+ ...(choice.jwt ? { jwt: choice.jwt } : {}),
155
+ ...(choice.getUploadUrl ? { getUploadUrl: choice.getUploadUrl } : {}),
156
+ ...oneGateway,
157
+ ...gateways,
158
+ ...extra,
159
+ });
160
+ }
161
+
162
+ if (kind === "lighthouse") {
163
+ if (!choice.apiKey && !extra.apiKey) {
164
+ throw new BackendError("INVALID_BACKEND", "Lighthouse needs an apiKey");
165
+ }
166
+ const { createLighthouseBackend } = await import("./lighthouse.js");
167
+ return createLighthouseBackend({
168
+ ...(choice.apiKey ? { apiKey: choice.apiKey } : {}),
169
+ // Whose key it is decides browserSafeAuth, and a page should say which it got.
170
+ keyOwnership: choice.keyOwnership ?? "shared",
171
+ ...oneGateway,
172
+ ...gateways,
173
+ ...extra,
174
+ });
175
+ }
176
+
177
+ if (kind === "memory") {
178
+ const { createMemoryBackend } = await import("./memory.js");
179
+ return createMemoryBackend(extra);
180
+ }
181
+
182
+ const { resolveBackend } = await import("./resolve.js");
183
+ return resolveBackend({ ...choice, ...extra, kind: undefined });
184
+ }
185
+
186
+ export default createBackendFromChoice;
@@ -0,0 +1,234 @@
1
+ /**
2
+ * @fileoverview One backup, several services: the mirror backend.
3
+ *
4
+ * A page that wants its backup on Aleph *and* on Lighthouse had to call both
5
+ * and reconcile two handles itself. This does that once, behind the same
6
+ * contract every other driver keeps, so `dehydrate`, `backupDatabaseCAR` and
7
+ * `restoreFromCID` need to know nothing about it.
8
+ *
9
+ * ## What a mirror promises, and what it refuses to
10
+ *
11
+ * **A partial write is reported, never swallowed.** By default a write
12
+ * succeeds when at least one service took it — a backup that fails because the
13
+ * third service was down would be worse than useless — but the handle then
14
+ * names who holds it and who refused, and the caller decides what to say. Pass
15
+ * `require: "all"` when a copy everywhere is the point of the exercise.
16
+ *
17
+ * **A read asks in order and stops at the first answer.** Not a race: a race
18
+ * spends every service's bandwidth on every read, and on a gateway that bills
19
+ * by request that is somebody's money. The order is the order the backends
20
+ * were given, so "the fast one first" is the caller's decision to make.
21
+ *
22
+ * **Capabilities are the honest composition, not the flattering one.**
23
+ * `browserSafeAuth` is true only when *every* service is safe to hand a page,
24
+ * because the weakest one decides what a page leaks. `preservesInnerCids` and
25
+ * `carImport` likewise. `minBlobSize` is the largest of them, because a blob
26
+ * has to clear the strictest door. `pinByCid` is the exception: it is true when
27
+ * *any* service can pin, and `pinCid()` then asks exactly those.
28
+ *
29
+ * **It does not list and it does not delete.** Both questions have no single
30
+ * honest answer across services — a listing would be a union with duplicates,
31
+ * and a deletion that half succeeds leaves a copy behind while reporting
32
+ * success. A caller that wants either can ask the service it means.
33
+ *
34
+ * @author @NiKrause
35
+ * @requires ./types.js - the contract this keeps
36
+ */
37
+
38
+ import {
39
+ defineBackend,
40
+ handleId,
41
+ BackendError,
42
+ DEFAULT_CAPABILITIES,
43
+ } from "./types.js";
44
+ import { logger } from "../logger.js";
45
+
46
+ /** The strictest door decides; an unstated minimum is no minimum. */
47
+ const largestMinimum = (backends) =>
48
+ backends.reduce(
49
+ (largest, backend) =>
50
+ Math.max(largest, backend.capabilities?.minBlobSize ?? 0),
51
+ 0,
52
+ );
53
+
54
+ const everyOne = (backends, flag) =>
55
+ backends.every((backend) => Boolean(backend.capabilities?.[flag]));
56
+ const anyOne = (backends, flag) =>
57
+ backends.some((backend) => Boolean(backend.capabilities?.[flag]));
58
+
59
+ /**
60
+ * Write one backup to several services and read it back from whichever answers.
61
+ *
62
+ * @param {import("./types.js").StorageBackend[]} backends - two or more drivers,
63
+ * in the order reads should try them
64
+ * @param {object} [options]
65
+ * @param {"one"|"all"} [options.require="one"] - how many services must accept a
66
+ * write for it to count as one
67
+ * @param {string} [options.name] - what the mirror calls itself; the default
68
+ * names its members, because a log line saying "mirror" says nothing
69
+ * @returns {import("./types.js").StorageBackend}
70
+ */
71
+ export function createMirrorBackend(backends = [], options = {}) {
72
+ const members = backends.filter(Boolean);
73
+ if (members.length < 2) {
74
+ throw new BackendError(
75
+ "INVALID_BACKEND",
76
+ "createMirrorBackend needs at least two backends; one backend is not a mirror",
77
+ );
78
+ }
79
+
80
+ const require_ = options.require ?? "one";
81
+ if (require_ !== "one" && require_ !== "all") {
82
+ throw new BackendError(
83
+ "INVALID_BACKEND",
84
+ `require must be "one" or "all", not ${require_}`,
85
+ );
86
+ }
87
+
88
+ const name =
89
+ options.name ||
90
+ `mirror(${members.map((backend) => backend.name).join("+")})`;
91
+ const pinners = members.filter(
92
+ (backend) => typeof backend.pinCid === "function",
93
+ );
94
+
95
+ /** Run one operation against every member, keeping which of them said what. */
96
+ const acrossAll = async (operation) => {
97
+ const settled = await Promise.allSettled(
98
+ members.map((backend) => operation(backend)),
99
+ );
100
+ const copies = [];
101
+ const failures = [];
102
+ settled.forEach((result, index) => {
103
+ const backend = members[index];
104
+ if (result.status === "fulfilled") {
105
+ copies.push({ backend: backend.name, ...result.value });
106
+ } else {
107
+ failures.push({
108
+ backend: backend.name,
109
+ code: result.reason?.code ?? "FAILED",
110
+ message: result.reason?.message ?? String(result.reason),
111
+ });
112
+ }
113
+ });
114
+ return { copies, failures };
115
+ };
116
+
117
+ /**
118
+ * One handle for the copies. The id is the CID when every service agreed on
119
+ * one — which is the normal case, since the bytes decide it — and the first
120
+ * success otherwise, so a handle is always usable somewhere.
121
+ */
122
+ const mergeHandles = ({ copies, failures }, what) => {
123
+ if (copies.length === 0) {
124
+ throw new BackendError(
125
+ failures[0]?.code === "TOO_SMALL" ? "TOO_SMALL" : "FAILED",
126
+ `${name}: no service accepted ${what} — ${failures
127
+ .map((failure) => `${failure.backend}: ${failure.message}`)
128
+ .join("; ")}`,
129
+ );
130
+ }
131
+ if (require_ === "all" && failures.length > 0) {
132
+ throw new BackendError(
133
+ "FAILED",
134
+ `${name}: ${failures.length} of ${members.length} services refused ${what} and require is "all" — ${failures
135
+ .map((failure) => `${failure.backend}: ${failure.message}`)
136
+ .join("; ")}`,
137
+ );
138
+ }
139
+ if (failures.length > 0) {
140
+ logger.warn(
141
+ `⚠️ ${name}: ${copies.length} of ${members.length} services hold ${what}; ` +
142
+ failures
143
+ .map((failure) => `${failure.backend} refused (${failure.code})`)
144
+ .join(", "),
145
+ );
146
+ }
147
+
148
+ const cids = new Set(copies.map((copy) => copy.cid).filter(Boolean));
149
+ const agreed = cids.size === 1 ? [...cids][0] : null;
150
+ return {
151
+ id: agreed ?? copies[0].id,
152
+ ...(agreed ? { cid: agreed } : {}),
153
+ backend: name,
154
+ ...(copies[0].size != null ? { size: copies[0].size } : {}),
155
+ ...(copies[0].name ? { name: copies[0].name } : {}),
156
+ copies,
157
+ ...(failures.length > 0 ? { failures } : {}),
158
+ };
159
+ };
160
+
161
+ /** A member's own id for this handle, since only the CID is shared. */
162
+ const idFor = (backend, handle) => {
163
+ const copy =
164
+ typeof handle === "object" && handle?.copies
165
+ ? handle.copies.find((entry) => entry.backend === backend.name)
166
+ : null;
167
+ return copy?.id ?? handleId(handle);
168
+ };
169
+
170
+ const mirror = {
171
+ name,
172
+ /** The members, in read order — a caller that wants one service can reach it. */
173
+ backends: members,
174
+ capabilities: {
175
+ ...DEFAULT_CAPABILITIES,
176
+ pinByCid: pinners.length > 0 && anyOne(members, "pinByCid"),
177
+ carImport: everyOne(members, "carImport"),
178
+ preservesInnerCids: everyOne(members, "preservesInnerCids"),
179
+ browserSafeAuth: everyOne(members, "browserSafeAuth"),
180
+ delegation: everyOne(members, "delegation"),
181
+ listing: false,
182
+ deletion: false,
183
+ minBlobSize: largestMinimum(members),
184
+ },
185
+
186
+ putBlob: async (bytes, meta = {}) =>
187
+ mergeHandles(
188
+ await acrossAll((backend) => backend.putBlob(bytes, meta)),
189
+ "the blob",
190
+ ),
191
+
192
+ getBlob: async (handle) => {
193
+ const reasons = [];
194
+ for (const backend of members) {
195
+ try {
196
+ return await backend.getBlob(idFor(backend, handle));
197
+ } catch (error) {
198
+ reasons.push(`${backend.name}: ${error?.message ?? error}`);
199
+ }
200
+ }
201
+ throw new BackendError(
202
+ "NOT_FOUND",
203
+ `${name}: no service returned ${handleId(handle)} — ${reasons.join("; ")}`,
204
+ );
205
+ },
206
+ };
207
+
208
+ if (mirror.capabilities.pinByCid) {
209
+ /** Only the services that can pin are asked; the others have nothing to say. */
210
+ mirror.pinCid = async (cid, meta = {}) => {
211
+ const settled = await Promise.allSettled(
212
+ pinners.map((backend) => backend.pinCid(cid, meta)),
213
+ );
214
+ const copies = [];
215
+ const failures = [];
216
+ settled.forEach((result, index) => {
217
+ const backend = pinners[index];
218
+ if (result.status === "fulfilled")
219
+ copies.push({ backend: backend.name, ...result.value });
220
+ else
221
+ failures.push({
222
+ backend: backend.name,
223
+ code: result.reason?.code ?? "FAILED",
224
+ message: result.reason?.message ?? String(result.reason),
225
+ });
226
+ });
227
+ return mergeHandles({ copies, failures }, `the pin for ${cid}`);
228
+ };
229
+ }
230
+
231
+ return defineBackend(mirror);
232
+ }
233
+
234
+ export default createMirrorBackend;
@@ -20,6 +20,9 @@ import { logger } from "../logger.js";
20
20
  *
21
21
  * @param {Object} [config] - call options
22
22
  * @param {Object} [config.backend] - any driver implementing the backend contract
23
+ * @param {string|string[]} [config.kind] - a backend by name, or several for a
24
+ * mirror, with the credentials each needs. What a page has, since a reader
25
+ * ticks a service and pastes a key rather than handing over a driver.
23
26
  * @param {Object} [config.ucanClient] - a Storacha client authorised over UCAN
24
27
  * @param {string} [config.spaceDID]
25
28
  * @param {string} [config.storachaKey] - falls back to STORACHA_KEY
@@ -35,6 +38,13 @@ export async function resolveBackend(config = {}) {
35
38
  return config.backend;
36
39
  }
37
40
 
41
+ // A name and a key: loaded here so that one decision stays one decision, and
42
+ // lazily so a caller that never names a vendor never bundles one.
43
+ if (config.kind) {
44
+ const { createBackendFromChoice } = await import("./choose.js");
45
+ return createBackendFromChoice(config);
46
+ }
47
+
38
48
  const gateways = config.gateways;
39
49
 
40
50
  if (config.ucanClient) {
@@ -7,9 +7,10 @@
7
7
  * credentials against a self-hosted w3up deployment can still use it, and it is the
8
8
  * reference for what a UCAN-delegated backend looked like when one existed.
9
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.
10
+ * Retrieval defaults to a gateway belonging to nobody in this story: `storacha.link`
11
+ * and `w3s.link` answer 301 to `dweb.link`, which Protocol Labs retired on 2026-09-21
12
+ * along with `ipfs.io`. A caller passes its own list — which is what the in-memory
13
+ * service does.
13
14
  *
14
15
  * @author @NiKrause
15
16
  * @requires ./types.js - the backend contract
@@ -23,11 +24,15 @@ import * as Proof from "@storacha/client/proof";
23
24
  import { CID } from "multiformats/cid";
24
25
  import { defineBackend, handleId, BackendError } from "./types.js";
25
26
 
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
- ]);
27
+ /**
28
+ * Gateways tried in order by `getBlob()`, unless the caller supplies its own.
29
+ *
30
+ * Storacha's own hosts redirected here, and here was retired on 2026-09-21
31
+ * together with `ipfs.io`. What is left is a gateway belonging to a different
32
+ * service, which will serve a Storacha CID only while somebody still provides
33
+ * those blocks to the network.
34
+ */
35
+ export const DEFAULT_GATEWAYS = Object.freeze(["https://ipfs.aleph.cloud"]);
31
36
 
32
37
  /**
33
38
  * Build a Storacha client from key and proof, or adopt one that already exists.
package/lib/backup-car.js CHANGED
@@ -33,13 +33,14 @@ const spaceHelpers = () => import("./orbitdb-storacha-bridge.js");
33
33
  import { unixfs } from "@helia/unixfs";
34
34
  import logger from "./logger.js";
35
35
  import { readBlockBytes } from "./block-bytes.js";
36
+ import { DEFAULT_GATEWAYS, MAX_BACKOFF_MS } from "./gateway-fetch.js";
36
37
 
37
38
  /**
38
39
  * Default configuration options
39
40
  */
40
41
  const DEFAULT_OPTIONS = {
41
42
  timeout: 30000,
42
- gateway: "https://w3s.link",
43
+ gateway: "https://ipfs.aleph.cloud", // w3s.link redirects to a gateway retired on 2026-09-21
43
44
  verbose: false,
44
45
  // Network download options
45
46
  useIPFSNetwork: true, // Enable network downloads by default
@@ -812,12 +813,11 @@ export async function restoreFromSpaceCAR(orbitdb, options = {}) {
812
813
 
813
814
  // Fallback to gateway if network failed or disabled
814
815
  if (!carBytes) {
815
- // Try multiple gateways in order
816
+ // The configured gateway, then whatever the package still trusts.
817
+ // `storacha.link`, `dweb.link` and `ipfs.io` were all retired on
818
+ // 2026-09-21 and stood here until then.
816
819
  const gateways = [
817
- `${config.gateway}/ipfs`,
818
- "https://storacha.link/ipfs",
819
- "https://dweb.link/ipfs",
820
- "https://ipfs.io/ipfs",
820
+ ...new Set([`${config.gateway}/ipfs`, ...DEFAULT_GATEWAYS]),
821
821
  ];
822
822
 
823
823
  let carResponse;
@@ -943,6 +943,13 @@ export async function restoreFromSpaceCAR(orbitdb, options = {}) {
943
943
  }
944
944
  }
945
945
 
946
+ if (waitTime > MAX_BACKOFF_MS) {
947
+ logger.warn(
948
+ ` ⚠️ ${gateway} wants ${Math.round(waitTime / 1000)}s — treating it as closed`,
949
+ );
950
+ break; // try the next gateway rather than wait out a retirement
951
+ }
952
+
946
953
  logger.warn(
947
954
  ` Waiting ${Math.round(waitTime / 1000)}s before retry (attempt ${attempts + 1}/${maxAttempts})...`,
948
955
  );
@@ -12,13 +12,39 @@
12
12
  * @module gateway-fetch
13
13
  */
14
14
 
15
- /** Tried in order. The first is Storacha's own, the rest are public. */
16
- export const DEFAULT_GATEWAYS = [
17
- "https://w3s.link/ipfs",
18
- "https://storacha.link/ipfs",
19
- "https://dweb.link/ipfs",
20
- "https://ipfs.io/ipfs",
21
- ];
15
+ /**
16
+ * Tried in order.
17
+ *
18
+ * This used to be four entries and is now one, because on 2026-09-21 Protocol
19
+ * Labs retired `ipfs.io` and `dweb.link` — they answer 429 with an RFC 8594
20
+ * `Sunset` header — and `w3s.link` and `storacha.link` redirect to `dweb.link`.
21
+ * All four were dead at once, which is what a list of gateways run by one
22
+ * organisation is worth.
23
+ *
24
+ * Measured 2026-09-23: `ipfs.aleph.cloud` answers in 0.2 s with CORS, and no
25
+ * other free path gateway found would serve an arbitrary CID —
26
+ * `gateway.pinata.cloud` answers 403 for content it does not hold,
27
+ * `4everland.io` redirects after 15 s, the rest do not answer at all.
28
+ *
29
+ * One entry is not a fallback chain, and pretending otherwise is how this
30
+ * rotted unnoticed. The replacement is not a longer list: it is fetching from
31
+ * the providers that hold the blocks, over libp2p — see issue #112.
32
+ */
33
+ export const DEFAULT_GATEWAYS = ["https://ipfs.aleph.cloud/ipfs"];
34
+
35
+ /**
36
+ * Gateways that serve **verifiable** responses only: a single block, or a CAR.
37
+ *
38
+ * `trustless-gateway.link` was still healthy when the path gateways were
39
+ * retired, and it answers with CORS — but it refuses an ordinary path request
40
+ * with a 406, so it is only usable with `accept` set, and only for a CID whose
41
+ * bytes are one block. A UnixFS file split into chunks needs its DAG walked,
42
+ * which this module deliberately cannot do.
43
+ */
44
+ export const TRUSTLESS_GATEWAYS = ["https://trustless-gateway.link/ipfs"];
45
+
46
+ /** `accept` for a single block from a trustless gateway. */
47
+ export const RAW_BLOCK = "application/vnd.ipld.raw";
22
48
 
23
49
  /**
24
50
  * Quiet by default. A restore reports itself through its return value and its
@@ -30,6 +56,15 @@ export const SILENT = { info() {}, warn() {}, debug() {} };
30
56
  const DEFAULT_TIMEOUT_MS = 60_000;
31
57
  const MAX_ATTEMPTS_PER_GATEWAY = 3;
32
58
 
59
+ /**
60
+ * Longest 429 wait worth honouring.
61
+ *
62
+ * A retired gateway asks for 900 s, and three attempts against four of them is
63
+ * most of an afternoon spent waiting for an answer that will not change. Past
64
+ * this, the gateway is not rate-limiting us, it is closed.
65
+ */
66
+ export const MAX_BACKOFF_MS = 30_000;
67
+
33
68
  /**
34
69
  * A gateway that cannot serve a CID usually says so in HTML, with a 200.
35
70
  *
@@ -73,9 +108,14 @@ const backoffFor = (response, attempt) => {
73
108
  * @param {string[]} [options.gateways]
74
109
  * @param {number} [options.timeout] per request, in ms
75
110
  * @param {AbortSignal} [options.signal]
111
+ * @param {string} [options.accept] sent as `Accept`; `RAW_BLOCK` for a single
112
+ * block from a trustless gateway
113
+ * @param {number} [options.maxBackoff] longest 429 wait to honour, in ms
114
+ * @param {(ms: number) => Promise<void>} [options.sleep] injectable for tests
115
+ * @param {typeof fetch} [options.fetchImpl] injectable for tests
76
116
  * @returns {Promise<Uint8Array>}
77
117
  */
78
- export async function fetchFromGateways(cid, { gateways = DEFAULT_GATEWAYS, timeout = DEFAULT_TIMEOUT_MS, signal = null, log = SILENT } = {}) {
118
+ export async function fetchFromGateways(cid, { gateways = DEFAULT_GATEWAYS, timeout = DEFAULT_TIMEOUT_MS, signal = null, log = SILENT, accept = null, maxBackoff = MAX_BACKOFF_MS, sleep = waitFor, fetchImpl = fetch } = {}) {
79
119
  let lastError = null;
80
120
 
81
121
  for (const gateway of gateways) {
@@ -83,12 +123,27 @@ export async function fetchFromGateways(cid, { gateways = DEFAULT_GATEWAYS, time
83
123
  for (let attempt = 0; attempt < MAX_ATTEMPTS_PER_GATEWAY; attempt++) {
84
124
  const timer = AbortSignal.timeout ? AbortSignal.timeout(timeout) : null;
85
125
  try {
86
- const response = await fetch(url, { signal: signal ?? timer ?? undefined });
126
+ const response = await fetchImpl(url, {
127
+ signal: signal ?? timer ?? undefined,
128
+ ...(accept ? { headers: { Accept: accept } } : {}),
129
+ });
130
+
131
+ // An RFC 8594 Sunset header means the gateway is going away or already
132
+ // has. Retrying is pointless, and so is coming back next time.
133
+ const sunset = response.headers?.get?.("Sunset");
134
+ if (sunset && !response.ok) {
135
+ log.warn(` ⚠️ ${gateway} is retired (Sunset: ${sunset})`);
136
+ break;
137
+ }
87
138
 
88
139
  if (response.status === 429 && attempt < MAX_ATTEMPTS_PER_GATEWAY - 1) {
89
140
  const wait = backoffFor(response, attempt);
141
+ if (wait > maxBackoff) {
142
+ log.warn(` ⚠️ ${gateway} wants ${Math.round(wait / 1000)}s — treating it as closed`);
143
+ break;
144
+ }
90
145
  log.warn(` ⚠️ ${gateway} rate-limited; waiting ${Math.round(wait / 1000)}s`);
91
- await waitFor(wait);
146
+ await sleep(wait);
92
147
  continue;
93
148
  }
94
149
  if (!response.ok) {
@@ -17,6 +17,7 @@ import { sha256 } from "multiformats/hashes/sha2";
17
17
  import { bases } from "multiformats/basics";
18
18
  import { EventEmitter } from "events";
19
19
  import { createHeliaOrbitDB, cleanupOrbitDBDirectories } from "./utils.js";
20
+ import { DEFAULT_GATEWAYS } from "./gateway-fetch.js";
20
21
  import {
21
22
  generateBackupPrefix,
22
23
  getBackupFilenames,
@@ -47,7 +48,7 @@ function logHeliaActivity(message) {
47
48
  const DEFAULT_OPTIONS = {
48
49
  // Core configuration
49
50
  timeout: 30000, // Timeout in milliseconds
50
- gateway: "https://dweb.link", // IPFS gateway URL (w3s.link only redirects here now)
51
+ gateway: "https://ipfs.aleph.cloud", // dweb.link was retired on 2026-09-21
51
52
  verbose: false, // Enable verbose debug logging
52
53
 
53
54
  // Network download options
@@ -823,14 +824,12 @@ export async function downloadBlock(cid, options = {}) {
823
824
  }
824
825
  }
825
826
 
826
- // Fallback to gateway download
827
- // storacha.link and w3s.link answer 301 to dweb.link as of 2026-09-05, so going
828
- // through them only buys a redirect. Ask the destination directly.
829
- const gateways = [
830
- `${config.gateway}/ipfs`,
831
- "https://dweb.link/ipfs",
832
- "https://ipfs.io/ipfs",
833
- ];
827
+ // Fallback to gateway download.
828
+ // `dweb.link` and `ipfs.io` stood here until Protocol Labs retired them on
829
+ // 2026-09-21; `storacha.link` and `w3s.link` had already become redirects to
830
+ // the first of those. What is left is one host, which is not a fallback
831
+ // chain — see issue #112 for the path that does not depend on one.
832
+ const gateways = [...new Set([`${config.gateway}/ipfs`, ...DEFAULT_GATEWAYS])];
834
833
 
835
834
  for (const gateway of gateways) {
836
835
  try {
@@ -0,0 +1,365 @@
1
+ /**
2
+ * @fileoverview Fetching bytes by CID from peers, when a gateway will not do.
3
+ *
4
+ * `gateway-fetch.js` is the HTTP half of this: ask a host, hope it answers.
5
+ * That stopped being a plan on 2026-09-21, when the public path gateways were
6
+ * retired (#111) and every fallback list in this package turned out to be one
7
+ * live entry and a dead tail. This is the other half — ask the peers that
8
+ * actually hold the blocks.
9
+ *
10
+ * ## Measured before it was written
11
+ *
12
+ * From a real page (Chrome, no build step, Helia loaded as ES modules), on
13
+ * 2026-09-23:
14
+ *
15
+ * ```
16
+ * Pinata dial 729 ms over wss bitswap 263 ms
17
+ * Lighthouse dial 581 ms over webrtc-direct bitswap 323 ms
18
+ * Aleph dial 450 ms over webrtc-direct bitswap 345 ms
19
+ * ```
20
+ *
21
+ * No credential in any of it, while both paid providers' HTTP gateways want
22
+ * that account's key and Lighthouse's shared gateway answers 402 for content
23
+ * it does not hold. Three services, two transports, about a second each.
24
+ *
25
+ * ## The peer list follows the backends, and nothing else
26
+ *
27
+ * There is no default provider here, and that is deliberate. Whoever stores
28
+ * with a service already shares their CIDs with it, so reading from it adds no
29
+ * new party — but a page that dials Pinata having never uploaded there would
30
+ * be telling Pinata what its reader is looking for, in exchange for nothing.
31
+ * The caller names the providers. {@link PINATA_BITSWAP} is a constant because
32
+ * Pinata publishes it in DNS; everyone else has to be looked up.
33
+ *
34
+ * ## What the caller has to bring
35
+ *
36
+ * A Helia with bitswap. This module has no libp2p of its own — the page that
37
+ * restores already has a node, and a second one would be a second identity on
38
+ * the network. Helia's browser defaults try to listen on `/webrtc` and
39
+ * `/p2p-circuit` and **throw on start** when no transport serves them, so a
40
+ * fetch-only node wants `addresses: { listen: [] }`.
41
+ *
42
+ * @module peer-fetch
43
+ */
44
+
45
+ /**
46
+ * Pinata's bitswap endpoint, published in DNS:
47
+ *
48
+ * ```
49
+ * $ dig +short TXT _dnsaddr.bitswap-v3.pinata.cloud
50
+ * "dnsaddr=/dns4/bitswap-v3.pinata.cloud/tcp/443/wss/p2p/Qmdv6yNikmUWUWXufLJLRNkv6Y9sY5cmgeX5RVWA4WNMz4"
51
+ * ```
52
+ *
53
+ * A DNS name on 443 with a CA certificate is dialable from a page, which is
54
+ * what makes it worth naming here rather than looking up per CID.
55
+ */
56
+ export const PINATA_BITSWAP =
57
+ "/dns4/bitswap-v3.pinata.cloud/tcp/443/wss/p2p/Qmdv6yNikmUWUWXufLJLRNkv6Y9sY5cmgeX5RVWA4WNMz4";
58
+
59
+ /**
60
+ * Aleph's own node, which took some finding.
61
+ *
62
+ * It publishes no `_dnsaddr` and answers `404` to `/api/v0/id`, so looking for
63
+ * it the way Pinata is found says it has no peer — which is what an earlier
64
+ * version of this file claimed. It does: ask a router for the providers of a
65
+ * CID Aleph holds, and the record is `46.255.204.211`, the address
66
+ * `ipfs.aleph.cloud` resolves to, with `webrtc-direct` and `webtransport`
67
+ * among its addresses. A page dialled it in 450 ms and had the block 345 ms
68
+ * later.
69
+ *
70
+ * Two caveats make this a starting point rather than a guarantee:
71
+ *
72
+ * - **The certhash rotates.** A `webrtc-direct` address carries the
73
+ * certificate's hash, and the node generates a new certificate when it
74
+ * restarts. When this address stops working, look the CID up again — the
75
+ * peer id is the stable part.
76
+ * - **Only one router will tell you.** Aleph announces over the DHT rather
77
+ * than IPNI, so `cid.contact` — the one router that answers a browser with
78
+ * CORS — does not know its CIDs, while `delegated-ipfs.dev` does and sends
79
+ * no CORS header for provider lookups. From a page, this constant is the way
80
+ * in; from Node, {@link providersFor} with `delegated-ipfs.dev` is better.
81
+ */
82
+ export const ALEPH_BITSWAP = Object.freeze([
83
+ "/ip4/46.255.204.211/udp/4001/webrtc-direct/certhash/uEiCbM9yMfnP02vviIL26n8bI0-vU0DGUHx20POBLKGjEmg/p2p/12D3KooWACE5dRw5V9WXuDTcngjE3ZaDSZ4qYJGfuhXZbENnL54y",
84
+ "/ip4/46.255.204.211/udp/4001/quic-v1/webtransport/certhash/uEiDMxOK9kZFH5SW6zcmNpXU4EGgBgvZYqqlJhw1cci50KA/certhash/uEiCeRpuyQWojWx2cP8guNiY3EDfIF25D2k8CMTZd1qtSfw/p2p/12D3KooWACE5dRw5V9WXuDTcngjE3ZaDSZ4qYJGfuhXZbENnL54y",
85
+ ]);
86
+
87
+ /** Aleph's peer id, which outlives the certificate hashes above. */
88
+ export const ALEPH_PEER_ID = "12D3KooWACE5dRw5V9WXuDTcngjE3ZaDSZ4qYJGfuhXZbENnL54y";
89
+
90
+ /**
91
+ * Routers that answer a page.
92
+ *
93
+ * `cid.contact` sends `access-control-allow-origin: *` for provider lookups;
94
+ * `delegated-ipfs.dev` did not, measured 2026-09-23 — it does for `/routing/v1/ipns`,
95
+ * which is why the pointer lookup can use it and this cannot.
96
+ *
97
+ * They also know different things, which matters more than the CORS header:
98
+ * `cid.contact` is an IPNI index and knows what is announced to it — Pinata's
99
+ * CIDs and Lighthouse's were there — while a CID that Aleph holds appeared
100
+ * only at `delegated-ipfs.dev`, because Aleph announces over the DHT. So from
101
+ * a page, a lookup finds the two paid services and not Aleph; that is what
102
+ * {@link ALEPH_BITSWAP} is for.
103
+ */
104
+ export const DEFAULT_ROUTERS = Object.freeze(["https://cid.contact"]);
105
+
106
+ /** Everything a Node caller can ask, where CORS does not apply. */
107
+ export const ALL_ROUTERS = Object.freeze([
108
+ "https://cid.contact",
109
+ "https://delegated-ipfs.dev",
110
+ ]);
111
+
112
+ /**
113
+ * Transports a browser can dial.
114
+ *
115
+ * `tcp` and plain `quic-v1` are not here: a page has no raw sockets. `tls/ws`
116
+ * needs a name rather than an IP, since the certificate has to match — an
117
+ * AutoTLS `libp2p.direct` address qualifies only while that name resolves,
118
+ * which for one provider it did not on the day this was written.
119
+ */
120
+ export const BROWSER_TRANSPORTS = Object.freeze([
121
+ "/wss",
122
+ "/tls/ws",
123
+ "/webtransport",
124
+ "/webrtc-direct",
125
+ ]);
126
+
127
+ const DEFAULT_TIMEOUT_MS = 30_000;
128
+
129
+ /** Quiet by default, like `gateway-fetch.js`. */
130
+ export const SILENT = { info() {}, warn() {}, debug() {} };
131
+
132
+ /** Does this multiaddr use a transport a page can open? */
133
+ export function isBrowserDialable(addr) {
134
+ if (typeof addr !== "string") return false;
135
+ if (!BROWSER_TRANSPORTS.some((transport) => addr.includes(transport))) return false;
136
+ // A certificate cannot be issued for a bare IP, so ws/wss on one is not
137
+ // dialable however well-formed it looks; webtransport and webrtc-direct
138
+ // carry a certhash instead and are fine.
139
+ const needsName = addr.includes("/wss") || addr.includes("/tls/ws");
140
+ return !needsName || addr.includes("/dns");
141
+ }
142
+
143
+ /**
144
+ * Who says they hold this CID, and at what address.
145
+ *
146
+ * @param {string} cid
147
+ * @param {Object} [options]
148
+ * @param {string[]} [options.routers] - delegated routing endpoints
149
+ * @param {boolean} [options.dialableOnly=true] - keep only what a page can open
150
+ * @param {AbortSignal} [options.signal]
151
+ * @param {typeof fetch} [options.fetchImpl]
152
+ * @returns {Promise<Array<{ id: string, addrs: string[] }>>}
153
+ */
154
+ export async function providersFor(cid, {
155
+ routers = DEFAULT_ROUTERS,
156
+ dialableOnly = true,
157
+ signal = null,
158
+ fetchImpl = fetch,
159
+ log = SILENT,
160
+ } = {}) {
161
+ const found = new Map();
162
+
163
+ for (const router of routers) {
164
+ try {
165
+ const response = await fetchImpl(`${router}/routing/v1/providers/${cid}`, {
166
+ headers: { Accept: "application/json" },
167
+ signal,
168
+ });
169
+ if (!response.ok) {
170
+ log.debug(` ⚠️ ${router} answered ${response.status}`);
171
+ continue;
172
+ }
173
+ const body = await response.text();
174
+ for (const record of parseProviderRecords(body)) {
175
+ const id = record.ID ?? record.id;
176
+ if (!id) continue;
177
+ const addrs = (record.Addrs ?? record.addrs ?? []).filter(
178
+ (addr) => !dialableOnly || isBrowserDialable(addr),
179
+ );
180
+ if (addrs.length === 0) continue;
181
+ const already = found.get(id);
182
+ if (already) {
183
+ for (const addr of addrs) if (!already.addrs.includes(addr)) already.addrs.push(addr);
184
+ } else {
185
+ found.set(id, { id, addrs: [...addrs] });
186
+ }
187
+ }
188
+ } catch (error) {
189
+ log.debug(` ⚠️ ${router} failed: ${error.message}`);
190
+ }
191
+ }
192
+
193
+ return [...found.values()];
194
+ }
195
+
196
+ /**
197
+ * A routing answer is JSON, or one JSON object per line when the router
198
+ * streams — both shapes are in the spec, and both are in the wild.
199
+ */
200
+ function parseProviderRecords(body) {
201
+ const records = [];
202
+ const push = (value) => {
203
+ if (!value) return;
204
+ if (Array.isArray(value.Providers)) records.push(...value.Providers);
205
+ else if (Array.isArray(value.providers)) records.push(...value.providers);
206
+ else records.push(value);
207
+ };
208
+ try {
209
+ push(JSON.parse(body));
210
+ return records;
211
+ } catch {
212
+ // not one document — try it as a stream of them
213
+ }
214
+ for (const line of body.split("\n")) {
215
+ const trimmed = line.trim();
216
+ if (!trimmed) continue;
217
+ try {
218
+ push(JSON.parse(trimmed));
219
+ } catch {
220
+ // a partial line at the end of a stream; nothing to do with it
221
+ }
222
+ }
223
+ return records;
224
+ }
225
+
226
+ /**
227
+ * Build a `fetchBytes(cid)` that goes over libp2p.
228
+ *
229
+ * Fits where `restoreFromCID`'s `fetchBytes` goes, so nothing downstream
230
+ * changes: the blocks are verified against their CIDs exactly as before, which
231
+ * is what makes a stranger's bytes as safe as a gateway's.
232
+ *
233
+ * @param {Object} options
234
+ * @param {Object} options.helia - a started Helia **with bitswap**
235
+ * @param {string[] | ((cid: string) => Promise<string[]>)} options.providers -
236
+ * multiaddrs to dial, or a function that finds them for a CID
237
+ * @param {number} [options.timeout]
238
+ * @param {(addr: string) => Promise<unknown>} [options.dial] - defaults to the node's
239
+ * @param {(cid: any, options?: Object) => AsyncIterable<Uint8Array>} [options.cat] -
240
+ * defaults to `@helia/unixfs`, which reads a chunked file as well as a single block
241
+ * @returns {(cid: string, options?: Object) => Promise<Uint8Array>}
242
+ */
243
+ export function createPeerFetch({
244
+ helia,
245
+ providers = [],
246
+ timeout = DEFAULT_TIMEOUT_MS,
247
+ dial = null,
248
+ cat = null,
249
+ log = SILENT,
250
+ } = {}) {
251
+ if (!helia && (!dial || !cat)) {
252
+ throw new Error("createPeerFetch needs a Helia node, or both dial and cat");
253
+ }
254
+
255
+ return async function fetchOverPeers(cid, options = {}) {
256
+ const signal = options.signal ?? AbortSignal.timeout(options.timeout ?? timeout);
257
+ const addrs =
258
+ typeof providers === "function" ? await providers(cid) : [...providers];
259
+
260
+ if (addrs.length === 0) {
261
+ throw new Error(`No provider to dial for ${cid}`);
262
+ }
263
+
264
+ const dialOne = dial ?? (async (addr) => {
265
+ const { multiaddr } = await import("@multiformats/multiaddr");
266
+ return helia.libp2p.dial(multiaddr(addr), { signal });
267
+ });
268
+
269
+ let dialed = 0;
270
+ for (const addr of addrs) {
271
+ try {
272
+ await dialOne(addr);
273
+ dialed += 1;
274
+ log.debug(` ✅ dialled ${addr}`);
275
+ } catch (error) {
276
+ log.debug(` ⚠️ could not dial ${addr}: ${error.message}`);
277
+ }
278
+ }
279
+ if (dialed === 0) {
280
+ throw new Error(`Could not dial any provider for ${cid} (tried ${addrs.length})`);
281
+ }
282
+
283
+ const read = cat ?? (await unixfsCat(helia));
284
+ const parts = [];
285
+ let total = 0;
286
+ for await (const chunk of read(cid, { signal })) {
287
+ parts.push(chunk);
288
+ total += chunk.length;
289
+ }
290
+ const bytes = new Uint8Array(total);
291
+ let at = 0;
292
+ for (const part of parts) {
293
+ bytes.set(part, at);
294
+ at += part.length;
295
+ }
296
+ log.info(` ✅ ${bytes.length} bytes over libp2p from ${dialed} peer(s)`);
297
+ return bytes;
298
+ };
299
+ }
300
+
301
+ /**
302
+ * `@helia/unixfs` rather than the blockstore: a backup large enough to be
303
+ * chunked is a DAG, and `blockstore.get` would return its root block and call
304
+ * that the file. Imported here so a caller that supplies `cat` never loads it.
305
+ */
306
+ async function unixfsCat(helia) {
307
+ const [{ unixfs }, { CID }] = await Promise.all([
308
+ import("@helia/unixfs"),
309
+ import("multiformats/cid"),
310
+ ]);
311
+ const fs = unixfs(helia);
312
+ return (cid, options) => fs.cat(typeof cid === "string" ? CID.parse(cid) : cid, options);
313
+ }
314
+
315
+ /**
316
+ * Try HTTP first, then peers — and say which one delivered.
317
+ *
318
+ * The order is not a preference for HTTP: a warm gateway answered in 0.23 s
319
+ * against 0.86–1.9 s over libp2p, and a phone on a rationed connection should
320
+ * not open a swarm for a file one request would have fetched. But the timeout
321
+ * is short on purpose, because the other measurement from the same day is a
322
+ * gateway taking 29 s for a block a peer served in under one.
323
+ *
324
+ * @param {Object} options
325
+ * @param {(cid: string, options?: Object) => Promise<Uint8Array>} options.viaGateway
326
+ * @param {(cid: string, options?: Object) => Promise<Uint8Array>} options.viaPeers
327
+ * @param {number} [options.gatewayTimeout=3000] - before the peer path starts
328
+ * @param {(path: "gateway"|"peers", info: Object) => void} [options.onPath] -
329
+ * told which path was taken and how long it took, so a page can show it
330
+ * @returns {(cid: string, options?: Object) => Promise<Uint8Array>}
331
+ */
332
+ export function createGatewayFirstFetch({
333
+ viaGateway,
334
+ viaPeers,
335
+ gatewayTimeout = 3000,
336
+ onPath = () => {},
337
+ now = () => Date.now(),
338
+ } = {}) {
339
+ return async function fetchBytes(cid, options = {}) {
340
+ const started = now();
341
+ try {
342
+ const bytes = await viaGateway(cid, { ...options, timeout: gatewayTimeout });
343
+ onPath("gateway", { cid, ms: now() - started, bytes: bytes.length });
344
+ return bytes;
345
+ } catch (error) {
346
+ const gatewayMs = now() - started;
347
+ const peersStarted = now();
348
+ try {
349
+ const bytes = await viaPeers(cid, options);
350
+ onPath("peers", {
351
+ cid,
352
+ ms: now() - peersStarted,
353
+ bytes: bytes.length,
354
+ after: { path: "gateway", ms: gatewayMs, error: error.message },
355
+ });
356
+ return bytes;
357
+ } catch (peerError) {
358
+ throw new Error(
359
+ `Could not fetch ${cid}: the gateway said "${error.message}" and the peers said "${peerError.message}"`,
360
+ { cause: peerError },
361
+ );
362
+ }
363
+ }
364
+ };
365
+ }
@@ -102,7 +102,7 @@ function findCorrectManifest(analysis) {
102
102
  */
103
103
  const DEFAULT_UCAN_OPTIONS = {
104
104
  timeout: 30000,
105
- gateway: "https://w3s.link",
105
+ gateway: "https://ipfs.aleph.cloud", // w3s.link redirects to a gateway retired on 2026-09-21
106
106
  batchSize: 10,
107
107
  maxConcurrency: 3,
108
108
  // UCAN-specific options
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@le-space/orbitdb-storage-bridge",
3
- "version": "0.10.0",
3
+ "version": "0.12.0",
4
4
  "description": "Back up, restore and replicate OrbitDB databases through pluggable storage backends, with hash and identity preservation",
5
5
  "main": "lib/orbitdb-storacha-bridge.js",
6
6
  "svelte": "dist/components/",
@@ -16,6 +16,7 @@
16
16
  "./dehydrate": "./lib/dehydrate.js",
17
17
  "./restore-cid": "./lib/restore-cid.js",
18
18
  "./gateway-fetch": "./lib/gateway-fetch.js",
19
+ "./peer-fetch": "./lib/peer-fetch.js",
19
20
  "./backends/types": "./lib/backends/types.js",
20
21
  "./backends/memory": "./lib/backends/memory.js",
21
22
  "./backends/storacha": "./lib/backends/storacha.js",
@@ -23,6 +24,9 @@
23
24
  "./backends/aleph-pin": "./lib/backends/aleph-pin.js",
24
25
  "./backends/pinata": "./lib/backends/pinata.js",
25
26
  "./backends/lighthouse": "./lib/backends/lighthouse.js",
27
+ "./backends/mirror": "./lib/backends/mirror.js",
28
+ "./backends/choose": "./lib/backends/choose.js",
29
+ "./backends/resolve": "./lib/backends/resolve.js",
26
30
  "./memory-courier": "./lib/memory-courier.js",
27
31
  "./StorachaIntegration.svelte": "./dist/components/StorachaIntegration.svelte",
28
32
  "./StorachaAuth.svelte": "./dist/components/StorachaAuth.svelte",
@@ -83,7 +87,6 @@
83
87
  "dependencies": {
84
88
  "@chainsafe/libp2p-noise": "^17.0.0",
85
89
  "@chainsafe/libp2p-yamux": "^8.0.1",
86
- "@helia/block-brokers": "^5.2.4",
87
90
  "@helia/routers": "^5.1.1",
88
91
  "@helia/unixfs": "^8.0.7",
89
92
  "@ipld/car": "^5.4.1",
@@ -105,7 +108,7 @@
105
108
  "cbor-web": "^10.0.11",
106
109
  "datastore-level": "^13.0.1",
107
110
  "dotenv": "^17.2.1",
108
- "helia": "^7.1.12",
111
+ "helia": "^7.1.15",
109
112
  "libp2p": "^3.3.6",
110
113
  "multiformats": "^14.0.5"
111
114
  },