@tokono.ma/firkin 0.1.0 → 0.1.1

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.
Files changed (2) hide show
  1. package/README.md +149 -36
  2. package/package.json +1 -1
package/README.md CHANGED
@@ -1,45 +1,158 @@
1
1
  # @tokono.ma/firkin
2
2
 
3
- Browser-first TypeScript SDK for the Firkin API.
3
+ Browser-first TypeScript SDK for the [Firkin](https://tangled.org/tokono.ma/firkin)
4
+ API: content-addressed blobs (DASL CIDs), UCAN 1.0 authorization, and
5
+ CAR Mirror batch sync — over plain `fetch`.
4
6
 
5
7
  ```sh
6
8
  npm install @tokono.ma/firkin
7
9
  ```
8
10
 
9
- Pure ESM, no Node-only APIs — runs in browsers via any bundler and in
10
- Node 22+ directly. Unreleased commits:
11
+ Pure ESM, zero Node-only APIs: it runs in browsers through any bundler and
12
+ in Node 22+ directly. Unreleased commits:
11
13
  `npm install git+https://tangled.org/tokono.ma/firkin`.
12
14
 
13
- - `keys.ts` — ed25519 identities (`@noble/ed25519`); the seed is the account;
14
- DID derivation (`did:key:z…`).
15
- - `cid.ts` — DASL/BDASL CID computation (SHA-256 or BLAKE3, canonical
16
- base32-lower).
17
- - `ucan.ts` — UCAN 1.0 invocation + delegation **minting** as DAG-CBOR
18
- container tokens (`[invocation, ...proofs]`), byte-identical to the
19
- server's Rust implementation (rs-ucan).
20
- - `client.ts` — `Firkin`: upload/get/getRange/claim/delete/register/
21
- pushFile/dagPush/dagPull over plain fetch, CARv1 framing included.
22
-
23
- ## Minting is a shim, on purpose
24
-
25
- `ucan.ts` implements only the wire assembly (~150 lines); all crypto and
26
- encoding is library code (`@noble/ed25519`, `@ipld/dag-cbor`). Correctness is
27
- pinned by golden tests (`test/ucan.test.ts`): fixture tokens from the Rust
28
- reference client decode + re-encode byte-identically, and live tokens are
29
- accepted by the real server (run inside the integration suite).
30
-
31
- ### The iso-ucan question
32
-
33
- [`iso-ucan`](https://github.com/hugomrdias/iso-repo/tree/main/packages/iso-ucan)
34
- is the richer TS library (zod-typed capabilities, proof storage, replay +
35
- revocation, an RPC layer) implementing the same UCAN 1.0 spec family — but it
36
- mints **final 1.0.0** wire format: it renames `arg` → `args`, omits
37
- `aud`/`cause`/`iat`/`meta` when implicit, and tags `ucan/inv@1.0.0`. The
38
- server (pinned rs-ucan, rc.1) rejects those today. When rs-ucan lands final
39
- 1.0.0 support, swap `ucan.ts`'s three mint functions for iso-ucan behind the
40
- SDK's existing `TokenSource` interface — no other layer changes.
41
-
42
- Also worth adopting from iso-ucan later: its replay key (CID of the signed
43
- *payload*, not the whole envelope) matters if ECDSA DIDs are ever supported —
44
- ed25519 signatures are deterministic, so our token-hash key is equivalent
45
- today.
15
+ ## Quickstart
16
+
17
+ ```ts
18
+ import { Signer, Firkin, signerToken } from "@tokono.ma/firkin";
19
+
20
+ const signer = await Signer.generate(); // or Signer.fromSeedHex(hex)
21
+ console.log(signer.did()); // did:key:z… — an admin must register it
22
+
23
+ const rb = new Firkin("https://firkin.example.com", signerToken(signer));
24
+
25
+ const { cid, deduplicated } = await rb.upload(new Blob(["hello"]));
26
+ const bytes = await rb.get(cid); // same bytes = same CID, stored once
27
+ ```
28
+
29
+ Every mutating call carries a single-use UCAN invocation minted by the
30
+ `TokenSource`. `signerToken(signer)` is the simple case (the account key
31
+ signs directly); delegates mint with [`invocation()`](#delegation--sessions).
32
+
33
+ ## Mental model
34
+
35
+ - **Content addressing.** The CID is computed from the bytes *before*
36
+ upload (`cidForBytes`), and the server verifies the digest — never
37
+ trusts it. Identical bytes anywhere dedupe to one stored object, shared
38
+ by every claimant.
39
+ - **Identity is a key pair.** Your seed is your account; its `did:key` is
40
+ registered by an admin. Passkey-native accounts use a P-256 credential
41
+ instead (see [Passkeys](#passkeys)).
42
+ - **Authorization is per request.** Mutating commands are single-use
43
+ (replay-protected); read tokens are reusable. That is why the
44
+ `TokenSource` is a function, not a token.
45
+
46
+ ## API
47
+
48
+ ```ts
49
+ // blobs — reads are authenticated automatically (for PUBLIC_READS=false)
50
+ rb.upload(data, { codec?, hash?: "sha256" | "blake3" }) → { cid, deduplicated }
51
+ rb.get(cid) → Uint8Array
52
+ rb.getRange(cid, { start?, end?, suffix? }) → { data, start, end, total }
53
+
54
+ // claims (multi-account sharing of one stored object)
55
+ rb.claim(cid) // attach an already-stored CID to your account
56
+ rb.delete(cid) // detach; bytes die with the last claimant
57
+
58
+ // name registry — private name → CID pointers (names imply no claim)
59
+ rb.registrySet(name, cid)
60
+ rb.registryGet(name) // null when unset
61
+ rb.registryList() // Record<name, cid>
62
+ rb.registryDelete(name)
63
+
64
+ // CAR Mirror (batch sync)
65
+ rb.pushFile(chunks) // ordered chunks → dag-cbor list root
66
+ rb.pullFile(root) // inverse of pushFile
67
+ rb.dagPush(root, blocks) // an existing DAG, as-is: [cid, bytes][] | Map
68
+ rb.dagPull(root) // Map<cid, bytes> — only the root is needed
69
+
70
+ // account
71
+ rb.revoke({ cids? , all? }) // kill delegations (account key only)
72
+ rb.register(did, credential?) // admin: register a DID (+ passkey binding)
73
+ rb.listAccounts() // admin
74
+ Firkin.whoami(base, assertion) // passkey re-attachment → account DID
75
+ ```
76
+
77
+ `dagPull` follows dag-cbor **and dag-pb** links server-side, so a WNFS-shaped
78
+ graph (dag-pb root tree, dag-cbor forest) comes back whole from its root.
79
+ `dagPush` keeps every block under its real CID — nothing is re-hashed —
80
+ which is what makes it composable with other IPLD tooling and with
81
+ registry entries that point at the real root. `pushFile` is sugar over
82
+ `dagPush` for plain chunk lists.
83
+
84
+ Errors throw an `Error` with `status` (the HTTP code) and the server's
85
+ message — branch on `status` (401 unauthorized, 403 forbidden/revoked,
86
+ 404 not found, 413 too large, 422 digest mismatch).
87
+
88
+ ## Delegation & sessions
89
+
90
+ Delegate a narrow capability to a short-lived key, then mint invocations
91
+ with that key:
92
+
93
+ ```ts
94
+ import { delegationPayloadBytes, invocation, delegate } from "@tokono.ma/firkin";
95
+
96
+ const { proof, cid } = await delegate(account, {
97
+ subject: account.did(), // rights holder (the account)
98
+ audience: session.did(), // recipient
99
+ command: "/firkin/blob/upload", // narrow prefixes only ever narrow
100
+ expireIn: 8 * 3600, // omit for an indefinite delegation
101
+ });
102
+
103
+ const rb = new Firkin(base, async (command) =>
104
+ invocation(session, { command, proofs: [proof], expireIn: 600 }),
105
+ );
106
+
107
+ // Revocation must be authorized by the account key — not the session:
108
+ const accountClient = new Firkin(base, signerToken(account));
109
+ await accountClient.revoke({ cids: [cid] }); // or { all: true }
110
+ ```
111
+
112
+ The server enforces that the chain cannot widen rights and that revocations
113
+ are signed by the account key (`revoke` from a plain delegated session is
114
+ refused — a stolen session must not be able to revoke).
115
+
116
+ ## Passkeys
117
+
118
+ `WebCryptoSigner` / `WebCryptoP256Signer` hold **non-extractable** keys
119
+ (structured-cloneable into IndexedDB: scripts can sign, never read).
120
+ Passkey-native accounts — the credential key *is* the account — sign
121
+ delegations with a WebAuthn assertion:
122
+
123
+ ```ts
124
+ import { assertionToProof, delegationPayloadBytes } from "@tokono.ma/firkin";
125
+
126
+ const payload = delegationPayloadBytes({
127
+ issuerDid: passkeyDid, subject: passkeyDid, audience: session.did(),
128
+ command: "/firkin/blob", expireIn: 8 * 3600,
129
+ });
130
+ const assertion = await navigator.credentials.get({
131
+ publicKey: { challenge: base64url(payload), rpId, userVerification: "required" },
132
+ });
133
+ const proof = assertionToProof(payload, {
134
+ authData: /* bytes */, clientDataJSON: /* exact JSON text */, signature: /* bytes */,
135
+ });
136
+ ```
137
+
138
+ `Firkin.whoami` resolves a discoverable-credential assertion back to its
139
+ account DID on a fresh browser. Working example:
140
+ [`examples/passkey/`](https://tangled.org/tokono.ma/firkin/tree/main/examples/passkey).
141
+
142
+ ## Design notes
143
+
144
+ **Minting is a shim, on purpose.** `ucan.ts` only assembles the wire format;
145
+ the crypto and encoding are library code (`@noble/ed25519`,
146
+ `@ipld/dag-cbor`, `multiformats`, `hash-wasm`). Correctness is pinned by
147
+ golden tests: tokens minted here are accepted by the Rust server and decode
148
+ byte-identically to the Rust reference client's fixtures.
149
+
150
+ **[iso-ucan](https://github.com/hugomrdias/iso-repo/tree/main/packages/iso-ucan)**
151
+ is the fuller TS UCAN library, but it mints final UCAN 1.0.0 (the server is
152
+ pinned to rc.1 today). When the server moves to 1.0.0 final, the three
153
+ mint functions can be swapped for iso-ucan behind the existing
154
+ `TokenSource` interface — no other layer changes.
155
+
156
+ ## License
157
+
158
+ Apache-2.0 OR MIT.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tokono.ma/firkin",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "Browser-first TypeScript client SDK for the Firkin API (UCAN 1.0, DASL CIDs, CAR Mirror sync)",
5
5
  "license": "Apache-2.0 OR MIT",
6
6
  "repository": {