@tokono.ma/firkin 0.3.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 +84 -14
- package/dist/bao.d.ts +25 -0
- package/dist/bao.js +225 -0
- package/dist/client.d.ts +210 -10
- package/dist/client.js +309 -16
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/dist/plc.d.ts +87 -0
- package/dist/plc.js +97 -0
- package/dist/ucan.d.ts +7 -0
- package/dist/ucan.js +4 -1
- package/dist/webauthn.d.ts +56 -9
- package/dist/webauthn.js +83 -22
- package/package.json +5 -4
package/dist/client.js
CHANGED
|
@@ -4,18 +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";
|
|
7
8
|
/** Wire version prefix for every API path (`/health` and `/.well-known/*`
|
|
8
9
|
* live outside it). */
|
|
9
10
|
export const API_PREFIX = "/v1";
|
|
10
11
|
import { CMD, mintInvocation } from "./ucan.js";
|
|
12
|
+
import { assertionHeader, packageAssertion, } from "./webauthn.js";
|
|
11
13
|
/** Signer-backed source: mints a fresh direct invocation per request. */
|
|
12
14
|
export function signerToken(signer, expireIn = 600) {
|
|
13
15
|
return async (command, options) => mintInvocation(signer, { command, expireIn: options?.expireIn ?? expireIn });
|
|
14
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
|
+
}
|
|
15
34
|
/** Fixed-token source: only appropriate for public/read-only usage. */
|
|
16
35
|
export function staticToken(token) {
|
|
17
36
|
return async () => token;
|
|
18
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
|
+
}
|
|
19
103
|
/** Simple in-memory cache (per Firkin instance). */
|
|
20
104
|
export class MemoryBlockCache {
|
|
21
105
|
map = new Map();
|
|
@@ -45,7 +129,10 @@ export class Firkin {
|
|
|
45
129
|
this.blockCache = blockCache;
|
|
46
130
|
}
|
|
47
131
|
// --- blobs ---------------------------------------------------------------
|
|
48
|
-
/** 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. */
|
|
49
136
|
async upload(data, opts = {}) {
|
|
50
137
|
let body;
|
|
51
138
|
let cid;
|
|
@@ -68,6 +155,7 @@ export class Firkin {
|
|
|
68
155
|
if (!res.ok)
|
|
69
156
|
throw await ApiError.of(res);
|
|
70
157
|
const json = (await res.json());
|
|
158
|
+
await this.applyTags(cid, opts.tags);
|
|
71
159
|
return { cid, deduplicated: json.status === "already_stored" };
|
|
72
160
|
}
|
|
73
161
|
/** Fetch a whole blob. Reads are authenticated: the server requires the
|
|
@@ -82,6 +170,62 @@ export class Firkin {
|
|
|
82
170
|
throw await ApiError.of(res);
|
|
83
171
|
return new Uint8Array(await res.arrayBuffer());
|
|
84
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
|
+
}
|
|
85
229
|
/** Fetch a byte range (206 responses; server-trusted verification).
|
|
86
230
|
* Authenticated like [`get`](#get). */
|
|
87
231
|
async getRange(cid, range = {}) {
|
|
@@ -112,7 +256,7 @@ export class Firkin {
|
|
|
112
256
|
* caller's own chain is not stored yet. */
|
|
113
257
|
async publishDelegation(minted, opts = {}) {
|
|
114
258
|
const token = opts.token ?? (await this.token(CMD.blobUpload));
|
|
115
|
-
const res = await this.fetchImpl(`${this.base}${API_PREFIX}/delegation/${minted.cid}`, {
|
|
259
|
+
const res = await this.fetchImpl(`${this.base}${API_PREFIX}/tokens/delegation/${minted.cid}`, {
|
|
116
260
|
method: "PUT",
|
|
117
261
|
headers: { Authorization: `Bearer ${toBase64Url(token)}` },
|
|
118
262
|
body: new Blob([minted.proof]),
|
|
@@ -134,8 +278,9 @@ export class Firkin {
|
|
|
134
278
|
});
|
|
135
279
|
return `${this.base}${API_PREFIX}/blob/${cid}?token=${toBase64Url(token)}`;
|
|
136
280
|
}
|
|
137
|
-
/** Attach an already-stored CID to the signer's account.
|
|
138
|
-
|
|
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 = {}) {
|
|
139
284
|
const token = await this.token(CMD.blobClaim);
|
|
140
285
|
const res = await this.fetchImpl(`${this.base}${API_PREFIX}/blob/${cid}/claim`, {
|
|
141
286
|
method: "POST",
|
|
@@ -143,6 +288,7 @@ export class Firkin {
|
|
|
143
288
|
});
|
|
144
289
|
if (!res.ok)
|
|
145
290
|
throw await ApiError.of(res);
|
|
291
|
+
await this.applyTags(cid, opts.tags);
|
|
146
292
|
}
|
|
147
293
|
/** Detach the signer's claim; bytes die with the last claimant. */
|
|
148
294
|
async delete(cid) {
|
|
@@ -153,24 +299,62 @@ export class Firkin {
|
|
|
153
299
|
if (!res.ok)
|
|
154
300
|
throw await ApiError.of(res);
|
|
155
301
|
}
|
|
156
|
-
/** Revoke
|
|
157
|
-
*
|
|
158
|
-
*
|
|
159
|
-
*
|
|
160
|
-
*
|
|
161
|
-
*
|
|
162
|
-
async revoke(opts) {
|
|
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);
|
|
163
315
|
const res = await this.fetchImpl(`${this.base}${API_PREFIX}/account/revoke`, {
|
|
164
316
|
method: "POST",
|
|
165
|
-
headers
|
|
166
|
-
Authorization: `Bearer ${toBase64Url(await this.token(CMD.accountRevoke))}`,
|
|
167
|
-
"Content-Type": "application/json",
|
|
168
|
-
},
|
|
317
|
+
headers,
|
|
169
318
|
body: JSON.stringify({ cids: opts.cids ?? [], all: opts.all ?? false }),
|
|
170
319
|
});
|
|
171
320
|
if (!res.ok)
|
|
172
321
|
throw await ApiError.of(res);
|
|
173
322
|
}
|
|
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`, {
|
|
334
|
+
method: "POST",
|
|
335
|
+
headers: { "Content-Type": "application/json" },
|
|
336
|
+
body: JSON.stringify({ plc: material }),
|
|
337
|
+
});
|
|
338
|
+
if (!res.ok)
|
|
339
|
+
throw await ApiError.of(res);
|
|
340
|
+
return (await res.json());
|
|
341
|
+
}
|
|
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`, {
|
|
350
|
+
method: "POST",
|
|
351
|
+
headers: { "Content-Type": "application/json" },
|
|
352
|
+
body: JSON.stringify({ assertion: packageAssertion(assertion) }),
|
|
353
|
+
});
|
|
354
|
+
if (!res.ok)
|
|
355
|
+
throw await ApiError.of(res);
|
|
356
|
+
return (await res.json());
|
|
357
|
+
}
|
|
174
358
|
/** (Admin) register a DID as an account, optionally binding a WebAuthn
|
|
175
359
|
* credential (`id` = base64url rawId, `spki` = base64url of the browser's
|
|
176
360
|
* getPublicKey() DER). The binding is what lets a fresh browser re-attach
|
|
@@ -272,6 +456,115 @@ export class Firkin {
|
|
|
272
456
|
const json = (await res.json());
|
|
273
457
|
return json.names;
|
|
274
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
|
+
}
|
|
275
568
|
// --- CAR Mirror batch sync ---------------------------------------------------
|
|
276
569
|
/**
|
|
277
570
|
* Ingest a rootless CARv1 — "upload a `.car` file". Every block is verified
|
|
@@ -533,7 +826,7 @@ export function carFile(root, entries) {
|
|
|
533
826
|
}
|
|
534
827
|
return concat(parts);
|
|
535
828
|
}
|
|
536
|
-
function parseCarBlocks(car) {
|
|
829
|
+
export function parseCarBlocks(car) {
|
|
537
830
|
let pos = 0;
|
|
538
831
|
const readUvarint = () => {
|
|
539
832
|
let result = 0;
|
package/dist/index.d.ts
CHANGED
package/dist/index.js
CHANGED
package/dist/plc.d.ts
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import type { PlcSessionRecord } from "./client.ts";
|
|
2
|
+
/** The record collection Firkin verifies (`ma.tokono.firkin.session`). */
|
|
3
|
+
export declare const PLC_SESSION_COLLECTION = "ma.tokono.firkin.session";
|
|
4
|
+
/** What `Firkin.attestPlc` takes. */
|
|
5
|
+
export interface PlcAttestationMaterial {
|
|
6
|
+
did: string;
|
|
7
|
+
/** PLC operations, genesis first (base64url DAG-CBOR). */
|
|
8
|
+
ops: string[];
|
|
9
|
+
/** The signed repo commit block. */
|
|
10
|
+
commit: string;
|
|
11
|
+
/** The session record. */
|
|
12
|
+
record: string;
|
|
13
|
+
/** MST node blocks proving the record's inclusion. */
|
|
14
|
+
proof: string[];
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* The subset of an ATProto agent this needs. `@atproto/api`'s `Agent` (or an
|
|
18
|
+
* OAuth session's agent) satisfies it structurally.
|
|
19
|
+
*/
|
|
20
|
+
export interface PlcAgentLike {
|
|
21
|
+
com: {
|
|
22
|
+
atproto: {
|
|
23
|
+
repo: {
|
|
24
|
+
putRecord(input: {
|
|
25
|
+
repo: string;
|
|
26
|
+
collection: string;
|
|
27
|
+
rkey: string;
|
|
28
|
+
record: unknown;
|
|
29
|
+
validate?: boolean;
|
|
30
|
+
}): Promise<{
|
|
31
|
+
data: {
|
|
32
|
+
commit?: {
|
|
33
|
+
cid?: string;
|
|
34
|
+
};
|
|
35
|
+
};
|
|
36
|
+
}>;
|
|
37
|
+
};
|
|
38
|
+
sync: {
|
|
39
|
+
getLatestCommit(input: {
|
|
40
|
+
did: string;
|
|
41
|
+
}): Promise<{
|
|
42
|
+
data: {
|
|
43
|
+
cid: string;
|
|
44
|
+
};
|
|
45
|
+
}>;
|
|
46
|
+
getBlocks(input: {
|
|
47
|
+
did: string;
|
|
48
|
+
cids: string[];
|
|
49
|
+
}): Promise<{
|
|
50
|
+
data: Uint8Array;
|
|
51
|
+
}>;
|
|
52
|
+
getRecord(input: {
|
|
53
|
+
did: string;
|
|
54
|
+
collection: string;
|
|
55
|
+
rkey: string;
|
|
56
|
+
}): Promise<{
|
|
57
|
+
data: {
|
|
58
|
+
cid: string;
|
|
59
|
+
value: unknown;
|
|
60
|
+
proof?: Uint8Array[];
|
|
61
|
+
};
|
|
62
|
+
}>;
|
|
63
|
+
};
|
|
64
|
+
};
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Write `record` into the account's repo and collect the attestation material.
|
|
69
|
+
*
|
|
70
|
+
* `record` is what `plcSessionRecord(...)` returns; its `session` field is used
|
|
71
|
+
* as the record key, which is why the service can find it deterministically.
|
|
72
|
+
*
|
|
73
|
+
* The commit and the MST proof must describe the *same* revision; a concurrent
|
|
74
|
+
* write between the two fetches is detected (the proof must contain the
|
|
75
|
+
* commit's `data` root) and retried.
|
|
76
|
+
*/
|
|
77
|
+
export declare function collectPlcAttestation(opts: {
|
|
78
|
+
agent: PlcAgentLike;
|
|
79
|
+
did: string;
|
|
80
|
+
record: PlcSessionRecord;
|
|
81
|
+
collection?: string;
|
|
82
|
+
/** Defaults to `https://plc.directory`. */
|
|
83
|
+
plcDirectory?: string;
|
|
84
|
+
fetchImpl?: typeof fetch;
|
|
85
|
+
/** Fetch retries when the repo advances mid-collection (default 5). */
|
|
86
|
+
attempts?: number;
|
|
87
|
+
}): Promise<PlcAttestationMaterial>;
|
package/dist/plc.js
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
// `did:plc` attestation material: write the session record into the account's
|
|
2
|
+
// repo with an ATProto agent, then collect everything Firkin verifies.
|
|
3
|
+
//
|
|
4
|
+
// Firkin never talks to a PDS or a PLC directory. The client gathers:
|
|
5
|
+
// - the PLC operation log (`plc.directory/<did>/log`),
|
|
6
|
+
// - the signed repo commit containing the record,
|
|
7
|
+
// - the record itself,
|
|
8
|
+
// - the MST nodes proving the record is in that commit.
|
|
9
|
+
// Everything is sent as base64url DAG-CBOR; `Firkin.attestPlc` posts it.
|
|
10
|
+
import * as dagcbor from "@ipld/dag-cbor";
|
|
11
|
+
import { cidForBytes, CODEC_DAG_CBOR } from "./cid.js";
|
|
12
|
+
import { parseCarBlocks } from "./client.js";
|
|
13
|
+
/** The record collection Firkin verifies (`ma.tokono.firkin.session`). */
|
|
14
|
+
export const PLC_SESSION_COLLECTION = "ma.tokono.firkin.session";
|
|
15
|
+
/**
|
|
16
|
+
* Write `record` into the account's repo and collect the attestation material.
|
|
17
|
+
*
|
|
18
|
+
* `record` is what `plcSessionRecord(...)` returns; its `session` field is used
|
|
19
|
+
* as the record key, which is why the service can find it deterministically.
|
|
20
|
+
*
|
|
21
|
+
* The commit and the MST proof must describe the *same* revision; a concurrent
|
|
22
|
+
* write between the two fetches is detected (the proof must contain the
|
|
23
|
+
* commit's `data` root) and retried.
|
|
24
|
+
*/
|
|
25
|
+
export async function collectPlcAttestation(opts) {
|
|
26
|
+
const collection = opts.collection ?? PLC_SESSION_COLLECTION;
|
|
27
|
+
// The PLC endpoint takes the DID verbatim (`/did:plc:…/log`); validate the
|
|
28
|
+
// syntax rather than escaping it into a path.
|
|
29
|
+
if (!/^did:plc:[a-z2-7]{24}$/.test(opts.did)) {
|
|
30
|
+
throw new Error(`not a did:plc: ${opts.did}`);
|
|
31
|
+
}
|
|
32
|
+
const session = opts.record.session;
|
|
33
|
+
if (typeof session !== "string" || session.length === 0) {
|
|
34
|
+
throw new Error("record.session (the did:key) is required");
|
|
35
|
+
}
|
|
36
|
+
const rkey = session;
|
|
37
|
+
await opts.agent.com.atproto.repo.putRecord({
|
|
38
|
+
repo: opts.did,
|
|
39
|
+
collection,
|
|
40
|
+
rkey,
|
|
41
|
+
record: opts.record,
|
|
42
|
+
});
|
|
43
|
+
const fetchImpl = opts.fetchImpl ?? globalThis.fetch;
|
|
44
|
+
const directory = opts.plcDirectory ?? "https://plc.directory";
|
|
45
|
+
const logRes = await fetchImpl(`${directory}/${opts.did}/log`);
|
|
46
|
+
if (!logRes.ok) {
|
|
47
|
+
throw new Error(`PLC log for ${opts.did}: ${logRes.status}`);
|
|
48
|
+
}
|
|
49
|
+
const log = (await logRes.json());
|
|
50
|
+
if (!Array.isArray(log) || log.length === 0) {
|
|
51
|
+
throw new Error(`PLC log for ${opts.did} is empty`);
|
|
52
|
+
}
|
|
53
|
+
const ops = log.map((op) => base64url(dagcbor.encode(op)));
|
|
54
|
+
for (let attempt = 0; attempt < (opts.attempts ?? 5); attempt++) {
|
|
55
|
+
// Everything below is public; the agent's auth is simply unused.
|
|
56
|
+
const latest = await opts.agent.com.atproto.sync.getLatestCommit({
|
|
57
|
+
did: opts.did,
|
|
58
|
+
});
|
|
59
|
+
const car = await opts.agent.com.atproto.sync.getBlocks({
|
|
60
|
+
did: opts.did,
|
|
61
|
+
cids: [latest.data.cid],
|
|
62
|
+
});
|
|
63
|
+
const commitBytes = parseCarBlocks(new Uint8Array(car.data)).get(latest.data.cid);
|
|
64
|
+
if (!commitBytes)
|
|
65
|
+
continue;
|
|
66
|
+
const commit = dagcbor.decode(commitBytes);
|
|
67
|
+
const rec = await opts.agent.com.atproto.sync.getRecord({
|
|
68
|
+
did: opts.did,
|
|
69
|
+
collection,
|
|
70
|
+
rkey,
|
|
71
|
+
});
|
|
72
|
+
const proof = rec.data.proof ?? [];
|
|
73
|
+
if (!commit.data || proof.length === 0)
|
|
74
|
+
continue;
|
|
75
|
+
// The proof must belong to this commit's tree. The node order is not
|
|
76
|
+
// specified, so look for the declared root anywhere in the set. (`proof`
|
|
77
|
+
// entries are raw DAG-CBOR blocks, hashed as-is.)
|
|
78
|
+
const root = commit.data.toString();
|
|
79
|
+
const roots = proof.map((node) => cidForBytes(node, { codec: CODEC_DAG_CBOR }));
|
|
80
|
+
if (!roots.includes(root))
|
|
81
|
+
continue;
|
|
82
|
+
return {
|
|
83
|
+
did: opts.did,
|
|
84
|
+
ops,
|
|
85
|
+
commit: base64url(commitBytes),
|
|
86
|
+
record: base64url(dagcbor.encode(rec.data.value)),
|
|
87
|
+
proof: proof.map((node) => base64url(node)),
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
throw new Error("the repo kept advancing while collecting the attestation; retry");
|
|
91
|
+
}
|
|
92
|
+
function base64url(bytes) {
|
|
93
|
+
let binary = "";
|
|
94
|
+
for (const byte of bytes)
|
|
95
|
+
binary += String.fromCharCode(byte);
|
|
96
|
+
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
97
|
+
}
|
package/dist/ucan.d.ts
CHANGED
|
@@ -23,6 +23,9 @@ export declare const CMD: {
|
|
|
23
23
|
readonly accountRecover: "/firkin/account/recover";
|
|
24
24
|
readonly registryRead: "/firkin/registry/read";
|
|
25
25
|
readonly registryWrite: "/firkin/registry/write";
|
|
26
|
+
readonly tagRead: "/firkin/tag/read";
|
|
27
|
+
readonly tagWrite: "/firkin/tag/write";
|
|
28
|
+
readonly tagDelete: "/firkin/tag/delete";
|
|
26
29
|
};
|
|
27
30
|
export type UcanCommand = (typeof CMD)[keyof typeof CMD] | (string & {});
|
|
28
31
|
export interface MintOptions {
|
|
@@ -30,6 +33,10 @@ export interface MintOptions {
|
|
|
30
33
|
command: UcanCommand;
|
|
31
34
|
/** Seconds until expiry (default 600). */
|
|
32
35
|
expireIn?: number;
|
|
36
|
+
/** Free-form signed metadata. Attested sessions set
|
|
37
|
+
* `{ "firkin/account": "<account did>" }` so the server can resolve the
|
|
38
|
+
* session binding (the value is a hint; the DO enforces it). */
|
|
39
|
+
meta?: Record<string, unknown>;
|
|
33
40
|
}
|
|
34
41
|
export interface DelegationOptions extends MintOptions {
|
|
35
42
|
/** The rights holder (account DID) — the chain root. */
|
package/dist/ucan.js
CHANGED
|
@@ -48,6 +48,9 @@ export const CMD = {
|
|
|
48
48
|
accountRecover: "/firkin/account/recover",
|
|
49
49
|
registryRead: "/firkin/registry/read",
|
|
50
50
|
registryWrite: "/firkin/registry/write",
|
|
51
|
+
tagRead: "/firkin/tag/read",
|
|
52
|
+
tagWrite: "/firkin/tag/write",
|
|
53
|
+
tagDelete: "/firkin/tag/delete",
|
|
51
54
|
};
|
|
52
55
|
function expFromNow(expireIn) {
|
|
53
56
|
return expireIn === undefined ? null : Math.floor(Date.now() / 1000) + expireIn;
|
|
@@ -72,7 +75,7 @@ function invocationPayload(signer, opts) {
|
|
|
72
75
|
cause: null,
|
|
73
76
|
iat: null,
|
|
74
77
|
exp: expFromNow(opts.expireIn),
|
|
75
|
-
meta: {},
|
|
78
|
+
meta: opts.meta ?? {},
|
|
76
79
|
nonce: nonce(),
|
|
77
80
|
};
|
|
78
81
|
}
|