@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/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. Authenticated reads: the server's reads are public
71
- * by default, but a `PUBLIC_READS=false` deployment requires the
72
- * `/firkin/blob/read` capability — read tokens are reusable (reads are
73
- * not replay-tracked), so sending one is always correct and cheap. */
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
- async claim(cid) {
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 delegated sessions: specific delegation CIDs, or every
154
- * delegation minted before now (`all: true`). Must be authorized by the
155
- * account key itself — a direct invocation (seed accounts) or a fresh
156
- * passkey assertion delegating `/firkin/account/revoke` to the session key
157
- * (passkey-native accounts; the server caps its lifetime at 10 minutes).
158
- * A plain delegated session cannot revoke. */
159
- async revoke(opts) {
160
- const res = await this.fetchImpl(`${this.base}/account/revoke`, {
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
- /** (Admin) register a DID as an account, optionally binding a WebAuthn
172
- * credential (`id` = base64url rawId, `spki` = base64url of the browser's
173
- * getPublicKey() DER). The binding is what lets a fresh browser re-attach
174
- * the passkey later via `Firkin.whoami` — passkey-native accounts have no
175
- * other record of the credential↔account link. */
176
- async register(did, credential) {
177
- const token = await this.token(CMD.accountRegister);
178
- const res = await this.fetchImpl(`${this.base}/accounts`, {
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
- Authorization: `Bearer ${toBase64Url(token)}`,
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
- /** (Account key) attest a session delegation with the bound passkey. */
190
- async attest(body) {
191
- const res = await this.fetchImpl(`${this.base}/account/attest`, {
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
- Authorization: `Bearer ${toBase64Url(await this.token(CMD.accountAttest))}`,
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
- /** (Account key) bind a WebAuthn credential to this account. */
203
- async bindCredential(credential) {
204
- const res = await this.fetchImpl(`${this.base}/account/bind`, {
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(await this.token(CMD.accountBind))}`,
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; entries = exactly the blocks being sent
526
- // this round. Never repeat a block the server already has: the receiver
527
- // interrupts the round at the first Have block.
528
- const header = dagcbor.encode({ roots: [root], version: 1 });
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
@@ -4,5 +4,8 @@ export * from "./ucan.ts";
4
4
  export * from "./client.ts";
5
5
  export * from "./bloom.ts";
6
6
  export * from "./webauthn.ts";
7
+ export * from "./plc.ts";
8
+ export * from "./rasl.ts";
9
+ export * from "./bao.ts";
7
10
  export * from "./bip39.ts";
8
11
  export * from "./bloom.ts";
package/dist/index.js CHANGED
@@ -4,5 +4,8 @@ export * from "./ucan.js";
4
4
  export * from "./client.js";
5
5
  export * from "./bloom.js";
6
6
  export * from "./webauthn.js";
7
+ export * from "./plc.js";
8
+ export * from "./rasl.js";
9
+ export * from "./bao.js";
7
10
  export * from "./bip39.js";
8
11
  export * from "./bloom.js";