@tokono.ma/firkin 0.2.0 → 0.4.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 +86 -15
- package/dist/bao.d.ts +25 -0
- package/dist/bao.js +225 -0
- package/dist/client.d.ts +240 -28
- package/dist/client.js +356 -65
- package/dist/index.d.ts +3 -0
- package/dist/index.js +3 -0
- package/dist/plc.d.ts +87 -0
- package/dist/plc.js +97 -0
- package/dist/rasl.d.ts +30 -0
- package/dist/rasl.js +107 -0
- package/dist/ucan.d.ts +7 -2
- package/dist/ucan.js +4 -3
- package/dist/webauthn.d.ts +56 -9
- package/dist/webauthn.js +83 -22
- package/package.json +5 -4
package/dist/client.js
CHANGED
|
@@ -4,15 +4,102 @@ import { CID } from "multiformats";
|
|
|
4
4
|
import { sha256 } from "@noble/hashes/sha2";
|
|
5
5
|
import { cidForBytes, CODEC_DAG_CBOR, CODEC_DAG_PB, CODEC_RAW } from "./cid.js";
|
|
6
6
|
import { BloomFilter } from "./bloom.js";
|
|
7
|
+
import { BAO_CHUNK_SIZE, decodeBaoRange } from "./bao.js";
|
|
8
|
+
/** Wire version prefix for every API path (`/health` and `/.well-known/*`
|
|
9
|
+
* live outside it). */
|
|
10
|
+
export const API_PREFIX = "/v1";
|
|
7
11
|
import { CMD, mintInvocation } from "./ucan.js";
|
|
12
|
+
import { assertionHeader, packageAssertion, } from "./webauthn.js";
|
|
8
13
|
/** Signer-backed source: mints a fresh direct invocation per request. */
|
|
9
14
|
export function signerToken(signer, expireIn = 600) {
|
|
10
15
|
return async (command, options) => mintInvocation(signer, { command, expireIn: options?.expireIn ?? expireIn });
|
|
11
16
|
}
|
|
17
|
+
/**
|
|
18
|
+
* Source for an **attested session**: every invocation carries
|
|
19
|
+
* `meta["firkin/account"]`, the hint the server uses to resolve the
|
|
20
|
+
* session → account binding. The session key signs directly (no proofs); the
|
|
21
|
+
* binding, not the token, carries the account's authority.
|
|
22
|
+
*
|
|
23
|
+
* Obtain the binding with a passkey: `attestationChallenge(...)` →
|
|
24
|
+
* `credentials.get` → `Firkin.attest(...)`.
|
|
25
|
+
*/
|
|
26
|
+
export function sessionTokenSource(signer, opts) {
|
|
27
|
+
const expireIn = opts.expireIn ?? 600;
|
|
28
|
+
return async (command, options) => mintInvocation(signer, {
|
|
29
|
+
command,
|
|
30
|
+
expireIn: options?.expireIn ?? expireIn,
|
|
31
|
+
meta: { "firkin/account": opts.account },
|
|
32
|
+
});
|
|
33
|
+
}
|
|
12
34
|
/** Fixed-token source: only appropriate for public/read-only usage. */
|
|
13
35
|
export function staticToken(token) {
|
|
14
36
|
return async () => token;
|
|
15
37
|
}
|
|
38
|
+
/**
|
|
39
|
+
* Canonicalize an instance origin: scheme + host + optional port, lowercased,
|
|
40
|
+
* with no trailing slash, path, query, fragment or userinfo.
|
|
41
|
+
*
|
|
42
|
+
* This matters twice over. Firkin compares the `firkin` audience field of its
|
|
43
|
+
* own records to `ORIGIN` exactly, and the ATProto indexers that can discover
|
|
44
|
+
* appeals — Constellation backlinks and Spacedust links — match URLs
|
|
45
|
+
* byte-for-byte, so `https://Firkin.example/` and `https://firkin.example`
|
|
46
|
+
* would be two unrelated values to them.
|
|
47
|
+
*
|
|
48
|
+
* Throws on a string that is not an absolute URL (e.g. a bare host).
|
|
49
|
+
*/
|
|
50
|
+
export function normalizeOrigin(origin) {
|
|
51
|
+
const url = new URL(origin.trim());
|
|
52
|
+
return `${url.protocol}//${url.host}`;
|
|
53
|
+
}
|
|
54
|
+
/** NSID of the access appeal an operator's firehose consumer reads.
|
|
55
|
+
* See `lexicons/ma.tokono.firkin.appeal.json`. */
|
|
56
|
+
export const APPEAL_COLLECTION = "ma.tokono.firkin.appeal";
|
|
57
|
+
/**
|
|
58
|
+
* Build an access appeal to write into the requesting account's repo.
|
|
59
|
+
*
|
|
60
|
+
* Firkin's HTTP API is admin-gated; this record is how an account asks an
|
|
61
|
+
* operator to register it. An operator-side consumer tails the ATProto
|
|
62
|
+
* firehose, filters this collection, and applies its own policy. Nothing here
|
|
63
|
+
* grants service — the commit signature only proves who appealed.
|
|
64
|
+
*
|
|
65
|
+
* `firkin` is normalized with `normalizeOrigin`, because an operator-side
|
|
66
|
+
* consumer (and any indexer in front of it) matches the value against its own
|
|
67
|
+
* origin exactly.
|
|
68
|
+
*
|
|
69
|
+
* Use `appealRkey(firkin)` as the record key.
|
|
70
|
+
*/
|
|
71
|
+
export function appealRequest(opts) {
|
|
72
|
+
const now = opts.now ?? Math.floor(Date.now() / 1000);
|
|
73
|
+
return {
|
|
74
|
+
$type: "ma.tokono.firkin.appeal",
|
|
75
|
+
firkin: normalizeOrigin(opts.firkin),
|
|
76
|
+
createdAt: new Date(now * 1000).toISOString(),
|
|
77
|
+
...(opts.expireIn === undefined
|
|
78
|
+
? {}
|
|
79
|
+
: { expiresAt: new Date((now + opts.expireIn) * 1000).toISOString() }),
|
|
80
|
+
...(opts.nonce === undefined ? {} : { nonce: opts.nonce }),
|
|
81
|
+
...(opts.note === undefined ? {} : { note: opts.note }),
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
/** The conventional record key for an appeal: the instance host
|
|
85
|
+
* (e.g. `firkin.example`), which is idempotent and a valid rkey. */
|
|
86
|
+
export function appealRkey(firkin) {
|
|
87
|
+
return new URL(firkin.trim()).host;
|
|
88
|
+
}
|
|
89
|
+
/** The record a did:plc account writes into its repo to attest a session
|
|
90
|
+
* (MST key `ma.tokono.firkin.session/<session did>`). Write it once with your
|
|
91
|
+
* ATProto agent, then submit the commit + MST path via `Firkin.attestPlc`. */
|
|
92
|
+
export function plcSessionRecord(opts) {
|
|
93
|
+
const nbf = opts.now ?? Math.floor(Date.now() / 1000);
|
|
94
|
+
return {
|
|
95
|
+
$type: "ma.tokono.firkin.session",
|
|
96
|
+
firkin: normalizeOrigin(opts.firkin),
|
|
97
|
+
session: opts.session,
|
|
98
|
+
commands: opts.commands,
|
|
99
|
+
nbf,
|
|
100
|
+
exp: nbf + (opts.expireIn ?? 30 * 24 * 60 * 60),
|
|
101
|
+
};
|
|
102
|
+
}
|
|
16
103
|
/** Simple in-memory cache (per Firkin instance). */
|
|
17
104
|
export class MemoryBlockCache {
|
|
18
105
|
map = new Map();
|
|
@@ -42,7 +129,10 @@ export class Firkin {
|
|
|
42
129
|
this.blockCache = blockCache;
|
|
43
130
|
}
|
|
44
131
|
// --- blobs ---------------------------------------------------------------
|
|
45
|
-
/** Upload bytes; returns the CID and whether it was already stored.
|
|
132
|
+
/** Upload bytes; returns the CID and whether it was already stored.
|
|
133
|
+
* `tags` is client-side sugar: after the bytes are stored (or deduped), the
|
|
134
|
+
* CID is added to each group with `tagAdd`. Tagging is a separate call, so
|
|
135
|
+
* if it fails the blob is already stored and `tagAdd` can be retried. */
|
|
46
136
|
async upload(data, opts = {}) {
|
|
47
137
|
let body;
|
|
48
138
|
let cid;
|
|
@@ -54,7 +144,7 @@ export class Firkin {
|
|
|
54
144
|
cid = cidForBytes(data, opts);
|
|
55
145
|
body = new Blob([data]);
|
|
56
146
|
}
|
|
57
|
-
const res = await this.fetchImpl(`${this.base}/blob/${cid}`, {
|
|
147
|
+
const res = await this.fetchImpl(`${this.base}${API_PREFIX}/blob/${cid}`, {
|
|
58
148
|
method: "PUT",
|
|
59
149
|
headers: {
|
|
60
150
|
Authorization: `Bearer ${toBase64Url(await this.token(CMD.blobUpload))}`,
|
|
@@ -65,24 +155,81 @@ export class Firkin {
|
|
|
65
155
|
if (!res.ok)
|
|
66
156
|
throw await ApiError.of(res);
|
|
67
157
|
const json = (await res.json());
|
|
158
|
+
await this.applyTags(cid, opts.tags);
|
|
68
159
|
return { cid, deduplicated: json.status === "already_stored" };
|
|
69
160
|
}
|
|
70
|
-
/** Fetch a whole blob.
|
|
71
|
-
*
|
|
72
|
-
*
|
|
73
|
-
*
|
|
161
|
+
/** Fetch a whole blob. Reads are authenticated: the server requires the
|
|
162
|
+
* `/firkin/blob/read` capability (an operator can opt into anonymous
|
|
163
|
+
* reads with `PUBLIC_READS=true`, in which case the header is ignored).
|
|
164
|
+
* Read tokens are reusable — reads are not replay-tracked. */
|
|
74
165
|
async get(cid) {
|
|
75
|
-
const res = await this.fetchImpl(`${this.base}/blob/${cid}`, {
|
|
166
|
+
const res = await this.fetchImpl(`${this.base}${API_PREFIX}/blob/${cid}`, {
|
|
76
167
|
headers: { Authorization: `Bearer ${toBase64Url(await this.token(CMD.blobRead))}` },
|
|
77
168
|
});
|
|
78
169
|
if (!res.ok)
|
|
79
170
|
throw await ApiError.of(res);
|
|
80
171
|
return new Uint8Array(await res.arrayBuffer());
|
|
81
172
|
}
|
|
173
|
+
/**
|
|
174
|
+
* Read a byte range of a **BDASL (BLAKE3)** blob with verified streaming:
|
|
175
|
+
* the server sends a bao proof and every chunk is checked against the CID
|
|
176
|
+
* before it is returned — the server is not trusted for integrity. Needs a
|
|
177
|
+
* BLAKE3 CID (`cidForBytes(data, { hash: "blake3" })`).
|
|
178
|
+
*/
|
|
179
|
+
async verifiedRange(cid, range = {}) {
|
|
180
|
+
const headers = {
|
|
181
|
+
Authorization: `Bearer ${toBase64Url(await this.token(CMD.blobRead))}`,
|
|
182
|
+
};
|
|
183
|
+
if (range.start !== undefined ||
|
|
184
|
+
range.end !== undefined ||
|
|
185
|
+
range.suffix !== undefined) {
|
|
186
|
+
headers.Range = rangeHeader(range);
|
|
187
|
+
}
|
|
188
|
+
const res = await this.fetchImpl(`${this.base}${API_PREFIX}/blob/${cid}?format=bao`, { headers });
|
|
189
|
+
if (!res.ok)
|
|
190
|
+
throw await ApiError.of(res);
|
|
191
|
+
const total = Number(res.headers.get("X-Firkin-Data-Size") ?? "0");
|
|
192
|
+
const contentRange = res.headers.get("Content-Range") ?? "";
|
|
193
|
+
const parsedCid = CID.parse(cid);
|
|
194
|
+
const encoded = new Uint8Array(await res.arrayBuffer());
|
|
195
|
+
if (total === 0) {
|
|
196
|
+
decodeBaoRange({
|
|
197
|
+
encoded,
|
|
198
|
+
root: parsedCid.multihash.digest,
|
|
199
|
+
size: 0,
|
|
200
|
+
chunkStart: 0,
|
|
201
|
+
chunkEnd: 1,
|
|
202
|
+
});
|
|
203
|
+
return { data: new Uint8Array(0), start: 0, end: 0, total: 0 };
|
|
204
|
+
}
|
|
205
|
+
const last = total - 1;
|
|
206
|
+
const wantStart = range.suffix !== undefined
|
|
207
|
+
? Math.max(0, total - range.suffix)
|
|
208
|
+
: Math.min(range.start ?? 0, last);
|
|
209
|
+
const wantEnd = range.suffix !== undefined
|
|
210
|
+
? last
|
|
211
|
+
: Math.min(range.end ?? last, last);
|
|
212
|
+
const chunkStart = Math.floor(wantStart / BAO_CHUNK_SIZE);
|
|
213
|
+
const chunkEnd = Math.floor(wantEnd / BAO_CHUNK_SIZE) + 1;
|
|
214
|
+
const covered = /bytes (\d+)-(\d+)\/(\d+)/.exec(contentRange);
|
|
215
|
+
if (covered && Number(covered[1]) !== chunkStart * BAO_CHUNK_SIZE) {
|
|
216
|
+
throw new Error("verified streaming: the server covered an unexpected data range");
|
|
217
|
+
}
|
|
218
|
+
const data = decodeBaoRange({
|
|
219
|
+
encoded,
|
|
220
|
+
root: parsedCid.multihash.digest,
|
|
221
|
+
size: total,
|
|
222
|
+
chunkStart,
|
|
223
|
+
chunkEnd,
|
|
224
|
+
});
|
|
225
|
+
const from = wantStart - chunkStart * BAO_CHUNK_SIZE;
|
|
226
|
+
const to = wantEnd - chunkStart * BAO_CHUNK_SIZE + 1;
|
|
227
|
+
return { data: data.slice(from, to), start: wantStart, end: wantEnd, total };
|
|
228
|
+
}
|
|
82
229
|
/** Fetch a byte range (206 responses; server-trusted verification).
|
|
83
230
|
* Authenticated like [`get`](#get). */
|
|
84
231
|
async getRange(cid, range = {}) {
|
|
85
|
-
const res = await this.fetchImpl(`${this.base}/blob/${cid}`, {
|
|
232
|
+
const res = await this.fetchImpl(`${this.base}${API_PREFIX}/blob/${cid}`, {
|
|
86
233
|
headers: {
|
|
87
234
|
Authorization: `Bearer ${toBase64Url(await this.token(CMD.blobRead))}`,
|
|
88
235
|
Range: rangeHeader(range),
|
|
@@ -109,7 +256,7 @@ export class Firkin {
|
|
|
109
256
|
* caller's own chain is not stored yet. */
|
|
110
257
|
async publishDelegation(minted, opts = {}) {
|
|
111
258
|
const token = opts.token ?? (await this.token(CMD.blobUpload));
|
|
112
|
-
const res = await this.fetchImpl(`${this.base}/delegation/${minted.cid}`, {
|
|
259
|
+
const res = await this.fetchImpl(`${this.base}${API_PREFIX}/tokens/delegation/${minted.cid}`, {
|
|
113
260
|
method: "PUT",
|
|
114
261
|
headers: { Authorization: `Bearer ${toBase64Url(token)}` },
|
|
115
262
|
body: new Blob([minted.proof]),
|
|
@@ -129,85 +276,99 @@ export class Firkin {
|
|
|
129
276
|
const token = await this.token(`${CMD.blobRead}/${cid}`, {
|
|
130
277
|
expireIn: opts.expireIn ?? 60,
|
|
131
278
|
});
|
|
132
|
-
return `${this.base}/blob/${cid}?token=${toBase64Url(token)}`;
|
|
279
|
+
return `${this.base}${API_PREFIX}/blob/${cid}?token=${toBase64Url(token)}`;
|
|
133
280
|
}
|
|
134
|
-
/** Attach an already-stored CID to the signer's account.
|
|
135
|
-
|
|
281
|
+
/** Attach an already-stored CID to the signer's account. `tags` is the
|
|
282
|
+
* same sugar as `upload`: the CID is added to each group afterwards. */
|
|
283
|
+
async claim(cid, opts = {}) {
|
|
136
284
|
const token = await this.token(CMD.blobClaim);
|
|
137
|
-
const res = await this.fetchImpl(`${this.base}/blob/${cid}/claim`, {
|
|
285
|
+
const res = await this.fetchImpl(`${this.base}${API_PREFIX}/blob/${cid}/claim`, {
|
|
138
286
|
method: "POST",
|
|
139
287
|
headers: { Authorization: `Bearer ${toBase64Url(token)}` },
|
|
140
288
|
});
|
|
141
289
|
if (!res.ok)
|
|
142
290
|
throw await ApiError.of(res);
|
|
291
|
+
await this.applyTags(cid, opts.tags);
|
|
143
292
|
}
|
|
144
293
|
/** Detach the signer's claim; bytes die with the last claimant. */
|
|
145
294
|
async delete(cid) {
|
|
146
|
-
const res = await this.fetchImpl(`${this.base}/blob/${cid}`, {
|
|
295
|
+
const res = await this.fetchImpl(`${this.base}${API_PREFIX}/blob/${cid}`, {
|
|
147
296
|
method: "DELETE",
|
|
148
297
|
headers: { Authorization: `Bearer ${toBase64Url(await this.token(CMD.blobDelete))}` },
|
|
149
298
|
});
|
|
150
299
|
if (!res.ok)
|
|
151
300
|
throw await ApiError.of(res);
|
|
152
301
|
}
|
|
153
|
-
/** Revoke
|
|
154
|
-
*
|
|
155
|
-
*
|
|
156
|
-
*
|
|
157
|
-
*
|
|
158
|
-
*
|
|
159
|
-
async revoke(opts) {
|
|
160
|
-
const
|
|
302
|
+
/** Revoke delegations/sessions: specific delegation or attestation CIDs,
|
|
303
|
+
* or every grant minted before now (`all: true`). Seed/did:key accounts
|
|
304
|
+
* authorize this with a direct invocation by the account key. An attested
|
|
305
|
+
* session must additionally pass a fresh passkey assertion (`stepUp`,
|
|
306
|
+
* built with `stepUpChallenge`) — a stolen session key must not be able to
|
|
307
|
+
* revoke. */
|
|
308
|
+
async revoke(opts, stepUp) {
|
|
309
|
+
const headers = {
|
|
310
|
+
Authorization: `Bearer ${toBase64Url(await this.token(CMD.accountRevoke))}`,
|
|
311
|
+
"Content-Type": "application/json",
|
|
312
|
+
};
|
|
313
|
+
if (stepUp)
|
|
314
|
+
headers["X-Firkin-Assertion"] = assertionHeader(stepUp);
|
|
315
|
+
const res = await this.fetchImpl(`${this.base}${API_PREFIX}/account/revoke`, {
|
|
161
316
|
method: "POST",
|
|
162
|
-
headers
|
|
163
|
-
Authorization: `Bearer ${toBase64Url(await this.token(CMD.accountRevoke))}`,
|
|
164
|
-
"Content-Type": "application/json",
|
|
165
|
-
},
|
|
317
|
+
headers,
|
|
166
318
|
body: JSON.stringify({ cids: opts.cids ?? [], all: opts.all ?? false }),
|
|
167
319
|
});
|
|
168
320
|
if (!res.ok)
|
|
169
321
|
throw await ApiError.of(res);
|
|
170
322
|
}
|
|
171
|
-
/**
|
|
172
|
-
*
|
|
173
|
-
*
|
|
174
|
-
*
|
|
175
|
-
*
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
323
|
+
/** Attest a session for a `did:plc` account. `material` is what the client
|
|
324
|
+
* fetched from the account's repo and the PLC directory (base64url DAG-CBOR):
|
|
325
|
+
*
|
|
326
|
+
* - `ops`: the PLC operation log, genesis first;
|
|
327
|
+
* - `commit`: the signed repo commit containing the record;
|
|
328
|
+
* - `record`: the `plcSessionRecord(...)` value;
|
|
329
|
+
* - `proof`: the MST nodes proving the record is in that commit.
|
|
330
|
+
*
|
|
331
|
+
* The server verifies everything offline (no PLC/PDS call). */
|
|
332
|
+
static async attestPlc(base, material, fetchImpl = globalThis.fetch) {
|
|
333
|
+
const res = await fetchImpl(`${base}${API_PREFIX}/tokens/attestation`, {
|
|
179
334
|
method: "POST",
|
|
180
|
-
headers: {
|
|
181
|
-
|
|
182
|
-
"Content-Type": "application/json",
|
|
183
|
-
},
|
|
184
|
-
body: JSON.stringify({ did, ...(credential ? { credential } : {}) }),
|
|
335
|
+
headers: { "Content-Type": "application/json" },
|
|
336
|
+
body: JSON.stringify({ plc: material }),
|
|
185
337
|
});
|
|
186
338
|
if (!res.ok)
|
|
187
339
|
throw await ApiError.of(res);
|
|
340
|
+
return (await res.json());
|
|
188
341
|
}
|
|
189
|
-
/**
|
|
190
|
-
|
|
191
|
-
|
|
342
|
+
/** Enrol a passkey session. The assertion's challenge must be
|
|
343
|
+
* `attestationChallenge(...)`; the server verifies the ceremony, stores the
|
|
344
|
+
* session → account binding, and returns its attestation CID. After this,
|
|
345
|
+
* requests are signed by the session key (see `sessionTokenSource`).
|
|
346
|
+
* Static: the endpoint is unauthenticated by design — the assertion IS the
|
|
347
|
+
* authentication. */
|
|
348
|
+
static async attest(base, assertion, fetchImpl = globalThis.fetch) {
|
|
349
|
+
const res = await fetchImpl(`${base}${API_PREFIX}/tokens/attestation`, {
|
|
192
350
|
method: "POST",
|
|
193
|
-
headers: {
|
|
194
|
-
|
|
195
|
-
"Content-Type": "application/json",
|
|
196
|
-
},
|
|
197
|
-
body: JSON.stringify(body),
|
|
351
|
+
headers: { "Content-Type": "application/json" },
|
|
352
|
+
body: JSON.stringify({ assertion: packageAssertion(assertion) }),
|
|
198
353
|
});
|
|
199
354
|
if (!res.ok)
|
|
200
355
|
throw await ApiError.of(res);
|
|
356
|
+
return (await res.json());
|
|
201
357
|
}
|
|
202
|
-
/** (
|
|
203
|
-
|
|
204
|
-
|
|
358
|
+
/** (Admin) register a DID as an account, optionally binding a WebAuthn
|
|
359
|
+
* credential (`id` = base64url rawId, `spki` = base64url of the browser's
|
|
360
|
+
* getPublicKey() DER). The binding is what lets a fresh browser re-attach
|
|
361
|
+
* the passkey later via `Firkin.whoami` — passkey-native accounts have no
|
|
362
|
+
* other record of the credential↔account link. */
|
|
363
|
+
async register(did, credential) {
|
|
364
|
+
const token = await this.token(CMD.accountRegister);
|
|
365
|
+
const res = await this.fetchImpl(`${this.base}${API_PREFIX}/accounts`, {
|
|
205
366
|
method: "POST",
|
|
206
367
|
headers: {
|
|
207
|
-
Authorization: `Bearer ${toBase64Url(
|
|
368
|
+
Authorization: `Bearer ${toBase64Url(token)}`,
|
|
208
369
|
"Content-Type": "application/json",
|
|
209
370
|
},
|
|
210
|
-
body: JSON.stringify({ credential }),
|
|
371
|
+
body: JSON.stringify({ did, ...(credential ? { credential } : {}) }),
|
|
211
372
|
});
|
|
212
373
|
if (!res.ok)
|
|
213
374
|
throw await ApiError.of(res);
|
|
@@ -220,7 +381,7 @@ export class Firkin {
|
|
|
220
381
|
* the endpoint is unauthenticated by design: the verified signature IS
|
|
221
382
|
* the authentication. */
|
|
222
383
|
static async whoami(base, assertion, fetchImpl = globalThis.fetch) {
|
|
223
|
-
const res = await fetchImpl(`${base}/account/whoami`, {
|
|
384
|
+
const res = await fetchImpl(`${base}${API_PREFIX}/account/whoami`, {
|
|
224
385
|
method: "POST",
|
|
225
386
|
headers: { "Content-Type": "application/json" },
|
|
226
387
|
body: JSON.stringify({
|
|
@@ -237,7 +398,7 @@ export class Firkin {
|
|
|
237
398
|
}
|
|
238
399
|
/** (Admin) list registered accounts. */
|
|
239
400
|
async listAccounts() {
|
|
240
|
-
const res = await this.fetchImpl(`${this.base}/accounts`, {
|
|
401
|
+
const res = await this.fetchImpl(`${this.base}${API_PREFIX}/accounts`, {
|
|
241
402
|
headers: {
|
|
242
403
|
Authorization: `Bearer ${toBase64Url(await this.token(CMD.accountList))}`,
|
|
243
404
|
},
|
|
@@ -253,7 +414,7 @@ export class Firkin {
|
|
|
253
414
|
// a resolved CID may be dangling (not yet uploaded or already GC'd).
|
|
254
415
|
/** Set `name` → `cid`. Overwrites silently (last-writer-wins). */
|
|
255
416
|
async registrySet(name, cid) {
|
|
256
|
-
const res = await this.fetchImpl(`${this.base}/registry/${encodeURIComponent(name)}`, {
|
|
417
|
+
const res = await this.fetchImpl(`${this.base}${API_PREFIX}/registry/${encodeURIComponent(name)}`, {
|
|
257
418
|
method: "PUT",
|
|
258
419
|
headers: {
|
|
259
420
|
Authorization: `Bearer ${toBase64Url(await this.token(CMD.registryWrite))}`,
|
|
@@ -266,7 +427,7 @@ export class Firkin {
|
|
|
266
427
|
}
|
|
267
428
|
/** Resolve `name`; returns null when unset. */
|
|
268
429
|
async registryGet(name) {
|
|
269
|
-
const res = await this.fetchImpl(`${this.base}/registry/${encodeURIComponent(name)}`, {
|
|
430
|
+
const res = await this.fetchImpl(`${this.base}${API_PREFIX}/registry/${encodeURIComponent(name)}`, {
|
|
270
431
|
headers: { Authorization: `Bearer ${toBase64Url(await this.token(CMD.registryRead))}` },
|
|
271
432
|
});
|
|
272
433
|
if (res.status === 404)
|
|
@@ -278,7 +439,7 @@ export class Firkin {
|
|
|
278
439
|
}
|
|
279
440
|
/** Unset `name` (idempotent). */
|
|
280
441
|
async registryDelete(name) {
|
|
281
|
-
const res = await this.fetchImpl(`${this.base}/registry/${encodeURIComponent(name)}`, {
|
|
442
|
+
const res = await this.fetchImpl(`${this.base}${API_PREFIX}/registry/${encodeURIComponent(name)}`, {
|
|
282
443
|
method: "DELETE",
|
|
283
444
|
headers: { Authorization: `Bearer ${toBase64Url(await this.token(CMD.registryWrite))}` },
|
|
284
445
|
});
|
|
@@ -287,7 +448,7 @@ export class Firkin {
|
|
|
287
448
|
}
|
|
288
449
|
/** The account's whole registry: name → CID (sorted). */
|
|
289
450
|
async registryList() {
|
|
290
|
-
const res = await this.fetchImpl(`${this.base}/registry`, {
|
|
451
|
+
const res = await this.fetchImpl(`${this.base}${API_PREFIX}/registry`, {
|
|
291
452
|
headers: { Authorization: `Bearer ${toBase64Url(await this.token(CMD.registryRead))}` },
|
|
292
453
|
});
|
|
293
454
|
if (!res.ok)
|
|
@@ -295,7 +456,136 @@ export class Firkin {
|
|
|
295
456
|
const json = (await res.json());
|
|
296
457
|
return json.names;
|
|
297
458
|
}
|
|
459
|
+
// --- blob tags --------------------------------------------------------------
|
|
460
|
+
/** Tag blobs: add CIDs to a per-account label group (idempotent). */
|
|
461
|
+
async tagAdd(name, cids) {
|
|
462
|
+
const body = Array.isArray(cids) ? { cids } : { cid: cids };
|
|
463
|
+
const res = await this.fetchImpl(`${this.base}${API_PREFIX}/tag/${encodeURIComponent(name)}`, {
|
|
464
|
+
method: "PUT",
|
|
465
|
+
headers: {
|
|
466
|
+
Authorization: `Bearer ${toBase64Url(await this.token(CMD.tagWrite))}`,
|
|
467
|
+
"Content-Type": "application/json",
|
|
468
|
+
},
|
|
469
|
+
body: JSON.stringify(body),
|
|
470
|
+
});
|
|
471
|
+
if (!res.ok)
|
|
472
|
+
throw await ApiError.of(res);
|
|
473
|
+
return (await res.json());
|
|
474
|
+
}
|
|
475
|
+
/** One page of a tag group (groups are paged; `next` walks them). */
|
|
476
|
+
async tagGet(name, opts = {}) {
|
|
477
|
+
const cursor = opts.cursor ?? 0;
|
|
478
|
+
const res = await this.fetchImpl(`${this.base}${API_PREFIX}/tag/${encodeURIComponent(name)}?cursor=${cursor}`, {
|
|
479
|
+
headers: {
|
|
480
|
+
Authorization: `Bearer ${toBase64Url(await this.token(CMD.tagRead))}`,
|
|
481
|
+
},
|
|
482
|
+
});
|
|
483
|
+
if (!res.ok)
|
|
484
|
+
throw await ApiError.of(res);
|
|
485
|
+
return (await res.json());
|
|
486
|
+
}
|
|
487
|
+
/** Every CID in a tag group (walks the pages). */
|
|
488
|
+
async tagAll(name) {
|
|
489
|
+
const all = [];
|
|
490
|
+
let cursor = 0;
|
|
491
|
+
while (cursor !== null) {
|
|
492
|
+
const page = await this.tagGet(name, { cursor });
|
|
493
|
+
all.push(...page.cids);
|
|
494
|
+
cursor = page.next;
|
|
495
|
+
}
|
|
496
|
+
return all;
|
|
497
|
+
}
|
|
498
|
+
/** The tag groups a CID belongs to (reverse lookup). Needs the full
|
|
499
|
+
* `/firkin/tag/read` command: a per-group proof cannot widen into it. */
|
|
500
|
+
async blobTags(cid) {
|
|
501
|
+
const res = await this.fetchImpl(`${this.base}${API_PREFIX}/blob/${cid}/tags`, {
|
|
502
|
+
headers: {
|
|
503
|
+
Authorization: `Bearer ${toBase64Url(await this.token(CMD.tagRead))}`,
|
|
504
|
+
},
|
|
505
|
+
});
|
|
506
|
+
if (!res.ok)
|
|
507
|
+
throw await ApiError.of(res);
|
|
508
|
+
const json = (await res.json());
|
|
509
|
+
return json.tags;
|
|
510
|
+
}
|
|
511
|
+
/** Add a CID to each group in `tags` (sugar for `upload`/`claim`). */
|
|
512
|
+
async applyTags(cid, tags) {
|
|
513
|
+
if (tags === undefined)
|
|
514
|
+
return;
|
|
515
|
+
for (const name of Array.isArray(tags) ? tags : [tags]) {
|
|
516
|
+
await this.tagAdd(name, cid);
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
/** Every tag group with its CID count. */
|
|
520
|
+
async tagList() {
|
|
521
|
+
const res = await this.fetchImpl(`${this.base}${API_PREFIX}/tag`, {
|
|
522
|
+
headers: {
|
|
523
|
+
Authorization: `Bearer ${toBase64Url(await this.token(CMD.tagRead))}`,
|
|
524
|
+
},
|
|
525
|
+
});
|
|
526
|
+
if (!res.ok)
|
|
527
|
+
throw await ApiError.of(res);
|
|
528
|
+
const json = (await res.json());
|
|
529
|
+
return json.tags;
|
|
530
|
+
}
|
|
531
|
+
/** Untag one blob from a group. */
|
|
532
|
+
async tagRemove(name, cid) {
|
|
533
|
+
const res = await this.fetchImpl(`${this.base}${API_PREFIX}/tag/${encodeURIComponent(name)}/${cid}`, {
|
|
534
|
+
method: "DELETE",
|
|
535
|
+
headers: {
|
|
536
|
+
Authorization: `Bearer ${toBase64Url(await this.token(CMD.tagWrite))}`,
|
|
537
|
+
},
|
|
538
|
+
});
|
|
539
|
+
if (!res.ok)
|
|
540
|
+
throw await ApiError.of(res);
|
|
541
|
+
return (await res.json());
|
|
542
|
+
}
|
|
543
|
+
/** Drop a tag group; the blobs stay stored. */
|
|
544
|
+
async tagDelete(name) {
|
|
545
|
+
const res = await this.fetchImpl(`${this.base}${API_PREFIX}/tag/${encodeURIComponent(name)}`, {
|
|
546
|
+
method: "DELETE",
|
|
547
|
+
headers: {
|
|
548
|
+
Authorization: `Bearer ${toBase64Url(await this.token(CMD.tagWrite))}`,
|
|
549
|
+
},
|
|
550
|
+
});
|
|
551
|
+
if (!res.ok)
|
|
552
|
+
throw await ApiError.of(res);
|
|
553
|
+
return (await res.json());
|
|
554
|
+
}
|
|
555
|
+
/** Drop a tag group **and detach every blob in it** (bytes die where this
|
|
556
|
+
* account is the last claimant). Needs `/firkin/tag/delete`. */
|
|
557
|
+
async tagDeleteBlobs(name) {
|
|
558
|
+
const res = await this.fetchImpl(`${this.base}${API_PREFIX}/tag/${encodeURIComponent(name)}/blobs`, {
|
|
559
|
+
method: "DELETE",
|
|
560
|
+
headers: {
|
|
561
|
+
Authorization: `Bearer ${toBase64Url(await this.token(CMD.tagDelete))}`,
|
|
562
|
+
},
|
|
563
|
+
});
|
|
564
|
+
if (!res.ok)
|
|
565
|
+
throw await ApiError.of(res);
|
|
566
|
+
return (await res.json());
|
|
567
|
+
}
|
|
298
568
|
// --- CAR Mirror batch sync ---------------------------------------------------
|
|
569
|
+
/**
|
|
570
|
+
* Ingest a rootless CARv1 — "upload a `.car` file". Every block is verified
|
|
571
|
+
* against its own CID, policy-checked and claimed by the account; there is
|
|
572
|
+
* no DAG traversal and no root requirement (unlike [`dagPush`](#dagpush),
|
|
573
|
+
* which syncs one DAG over multiple rounds). Partial success is normal:
|
|
574
|
+
* `failed` lists the rejected blocks (digest mismatch, policy, size).
|
|
575
|
+
*/
|
|
576
|
+
async ingestCar(car) {
|
|
577
|
+
const res = await this.fetchImpl(`${this.base}${API_PREFIX}/car`, {
|
|
578
|
+
method: "POST",
|
|
579
|
+
headers: {
|
|
580
|
+
Authorization: `Bearer ${toBase64Url(await this.token(CMD.blobUpload))}`,
|
|
581
|
+
"Content-Type": "application/vnd.ipld.car",
|
|
582
|
+
},
|
|
583
|
+
body: new Blob([car]),
|
|
584
|
+
});
|
|
585
|
+
if (!res.ok)
|
|
586
|
+
throw await ApiError.of(res);
|
|
587
|
+
return (await res.json());
|
|
588
|
+
}
|
|
299
589
|
/**
|
|
300
590
|
* Push a flat file as an ordered list of chunks: each chunk is stored as
|
|
301
591
|
* a raw block, and a dag-cbor list of their CIDs becomes the root — the
|
|
@@ -392,7 +682,7 @@ export class Firkin {
|
|
|
392
682
|
throw new Error("push round has no blocks");
|
|
393
683
|
const first = send[0];
|
|
394
684
|
const car = carFile(first === rootCid.toString() ? rootCid : CID.parse(first), send.map((c) => [CID.parse(c), all.get(c)]));
|
|
395
|
-
const res = await this.fetchImpl(`${this.base}/dag/push/${rootCid}`, {
|
|
685
|
+
const res = await this.fetchImpl(`${this.base}${API_PREFIX}/dag/push/${rootCid}`, {
|
|
396
686
|
method: "POST",
|
|
397
687
|
headers: {
|
|
398
688
|
Authorization: `Bearer ${toBase64Url(await this.token(CMD.blobUpload))}`,
|
|
@@ -447,7 +737,7 @@ export class Firkin {
|
|
|
447
737
|
};
|
|
448
738
|
if (body)
|
|
449
739
|
headers["Content-Type"] = "application/vnd.ipld.dag-cbor";
|
|
450
|
-
const res = await this.fetchImpl(`${this.base}/dag/pull/${root}`, {
|
|
740
|
+
const res = await this.fetchImpl(`${this.base}${API_PREFIX}/dag/pull/${root}`, {
|
|
451
741
|
method: "POST",
|
|
452
742
|
headers,
|
|
453
743
|
...(body ? { body: new Blob([body]) } : {}),
|
|
@@ -522,10 +812,11 @@ function rangeHeader(range) {
|
|
|
522
812
|
}
|
|
523
813
|
// --- CARv1 framing -----------------------------------------------------------
|
|
524
814
|
export function carFile(root, entries) {
|
|
525
|
-
// Header roots = the DAG root
|
|
526
|
-
//
|
|
527
|
-
//
|
|
528
|
-
|
|
815
|
+
// Header roots = the DAG root (`null` for a rootless bag — the DASL CAR
|
|
816
|
+
// format allows an empty roots array); entries = exactly the blocks being
|
|
817
|
+
// sent this round. Never repeat a block the server already has: the
|
|
818
|
+
// receiver interrupts the round at the first Have block.
|
|
819
|
+
const header = dagcbor.encode({ roots: root ? [root] : [], version: 1 });
|
|
529
820
|
const parts = [uvarintFrame(header.length), header];
|
|
530
821
|
for (const [cid, block] of entries) {
|
|
531
822
|
const entry = new Uint8Array(cid.bytes.length + block.length);
|
|
@@ -535,7 +826,7 @@ export function carFile(root, entries) {
|
|
|
535
826
|
}
|
|
536
827
|
return concat(parts);
|
|
537
828
|
}
|
|
538
|
-
function parseCarBlocks(car) {
|
|
829
|
+
export function parseCarBlocks(car) {
|
|
539
830
|
let pos = 0;
|
|
540
831
|
const readUvarint = () => {
|
|
541
832
|
let result = 0;
|
package/dist/index.d.ts
CHANGED
package/dist/index.js
CHANGED