@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.
- package/README.md +149 -36
- 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
|
|
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,
|
|
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
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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