@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,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;
|