@tokono.ma/firkin 0.0.0-stage → 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 +157 -2
- package/dist/bip39.d.ts +4 -0
- package/dist/bip39.js +18 -0
- package/dist/bloom.d.ts +17 -0
- package/dist/bloom.js +82 -0
- package/dist/cid.d.ts +17 -0
- package/dist/cid.js +69 -0
- package/dist/client.d.ts +187 -0
- package/dist/client.js +718 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.js +8 -0
- package/dist/keys.d.ts +84 -0
- package/dist/keys.js +234 -0
- package/dist/ucan.d.ts +93 -0
- package/dist/ucan.js +205 -0
- package/dist/webauthn.d.ts +20 -0
- package/dist/webauthn.js +29 -0
- package/package.json +54 -4
package/README.md
CHANGED
|
@@ -1,3 +1,158 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @tokono.ma/firkin
|
|
2
2
|
|
|
3
|
-
|
|
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`.
|
|
6
|
+
|
|
7
|
+
```sh
|
|
8
|
+
npm install @tokono.ma/firkin
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
Pure ESM, zero Node-only APIs: it runs in browsers through any bundler and
|
|
12
|
+
in Node 22+ directly. Unreleased commits:
|
|
13
|
+
`npm install git+https://tangled.org/tokono.ma/firkin`.
|
|
14
|
+
|
|
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/dist/bip39.d.ts
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
/** Encode a 32-byte ed25519 seed as a 24-word BIP39 phrase. */
|
|
2
|
+
export declare function seedToMnemonic(seed: Uint8Array): string;
|
|
3
|
+
/** Decode a 24-word BIP39 phrase back into the 32-byte ed25519 seed. */
|
|
4
|
+
export declare function mnemonicToSeedBytes(mnemonic: string): Uint8Array;
|
package/dist/bip39.js
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
// BIP39 seed-phrase backup: a 24-word phrase encodes 256 bits of entropy
|
|
2
|
+
// that IS the ed25519 seed (bijective: entropy ↔ mnemonic — no PBKDF2
|
|
3
|
+
// stretching, so the phrase round-trips to the exact account key).
|
|
4
|
+
import { entropyToMnemonic, mnemonicToEntropy } from "@scure/bip39";
|
|
5
|
+
import { wordlist } from "@scure/bip39/wordlists/english";
|
|
6
|
+
/** Encode a 32-byte ed25519 seed as a 24-word BIP39 phrase. */
|
|
7
|
+
export function seedToMnemonic(seed) {
|
|
8
|
+
if (seed.length !== 32)
|
|
9
|
+
throw new Error("backup seed must be 32 bytes");
|
|
10
|
+
return entropyToMnemonic(seed, wordlist);
|
|
11
|
+
}
|
|
12
|
+
/** Decode a 24-word BIP39 phrase back into the 32-byte ed25519 seed. */
|
|
13
|
+
export function mnemonicToSeedBytes(mnemonic) {
|
|
14
|
+
const seed = mnemonicToEntropy(mnemonic.trim().toLowerCase(), wordlist);
|
|
15
|
+
if (seed.length !== 32)
|
|
16
|
+
throw new Error("mnemonic does not encode a 32-byte seed");
|
|
17
|
+
return seed;
|
|
18
|
+
}
|
package/dist/bloom.d.ts
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export declare class BloomFilter {
|
|
2
|
+
readonly bytes: Uint8Array;
|
|
3
|
+
readonly hashCount: number;
|
|
4
|
+
private constructor();
|
|
5
|
+
/** From a push response's `bb` (bytes) + `bk` (hash count). */
|
|
6
|
+
static fromWire(bytes: Uint8Array, hashCount: number): BloomFilter;
|
|
7
|
+
/** Fresh filter sized for `n` items at false-positive probability `fpp`
|
|
8
|
+
* (spec §3.4.2: k = m/n·ln2, m = -n·ln ε / ln²2). */
|
|
9
|
+
static build(n: number, fpp?: number): Promise<BloomFilter>;
|
|
10
|
+
get bitSize(): number;
|
|
11
|
+
/** Probabilistic membership: true = definitely or probably present. */
|
|
12
|
+
contains(item: Uint8Array): Promise<boolean>;
|
|
13
|
+
insert(item: Uint8Array): Promise<void>;
|
|
14
|
+
/** deterministic-bloom's HashIndexIterator: k accepted indexes, seeds
|
|
15
|
+
* counting up from 0 across hash rounds and rejection samples alike. */
|
|
16
|
+
private indices;
|
|
17
|
+
}
|
package/dist/bloom.js
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
// TS port of the Bloom filter used by car-mirror
|
|
2
|
+
// (`deterministic-bloom`, runtime_size), so the client can BOTH interpret
|
|
3
|
+
// the server's push-response filters AND build pull-request filters the
|
|
4
|
+
// server will interpret identically:
|
|
5
|
+
//
|
|
6
|
+
// - items are hashed with XXH3-64, seed = a monotonic counter starting at 0
|
|
7
|
+
// that is shared across the k hash rounds AND rejection-sampling retries
|
|
8
|
+
// (the counter resets per insert/contains call)
|
|
9
|
+
// - index = hash % 2^ceil(log2(bit_size)); non-power-of-two sizes rejection-
|
|
10
|
+
// sample (retry with the next seed when the value lands past the size)
|
|
11
|
+
// - bits are LSB0 within the byte array
|
|
12
|
+
// - an item is "contained" when all k of its bits are set (false positives
|
|
13
|
+
// possible, never false negatives)
|
|
14
|
+
//
|
|
15
|
+
// Items fed by the server are CID binary bytes (`cid.to_bytes()`), i.e. the
|
|
16
|
+
// multiformats `CID.bytes`.
|
|
17
|
+
//
|
|
18
|
+
// NB: deterministic-bloom casts the u64 hash `as usize` — 32 bits on the
|
|
19
|
+
// wasm32 worker. Both the read and write side mimic that truncation here
|
|
20
|
+
// (revisit if the store is ever built natively, e.g. celld on x86_64, where
|
|
21
|
+
// usize is 64 bits).
|
|
22
|
+
import { xxhash3 } from "hash-wasm";
|
|
23
|
+
export class BloomFilter {
|
|
24
|
+
bytes;
|
|
25
|
+
hashCount;
|
|
26
|
+
constructor(bytes, hashCount) {
|
|
27
|
+
this.bytes = bytes;
|
|
28
|
+
this.hashCount = hashCount;
|
|
29
|
+
}
|
|
30
|
+
/** From a push response's `bb` (bytes) + `bk` (hash count). */
|
|
31
|
+
static fromWire(bytes, hashCount) {
|
|
32
|
+
return new BloomFilter(bytes, hashCount);
|
|
33
|
+
}
|
|
34
|
+
/** Fresh filter sized for `n` items at false-positive probability `fpp`
|
|
35
|
+
* (spec §3.4.2: k = m/n·ln2, m = -n·ln ε / ln²2). */
|
|
36
|
+
static async build(n, fpp = Math.min(0.1, 1 / (10 * n))) {
|
|
37
|
+
if (n <= 0)
|
|
38
|
+
throw new Error("bloom needs at least one item");
|
|
39
|
+
const ln2 = Math.LN2;
|
|
40
|
+
const bits = Math.ceil((-n * Math.log(fpp)) / (ln2 * ln2));
|
|
41
|
+
const hashCount = Math.max(1, Math.round((bits / n) * ln2));
|
|
42
|
+
return new BloomFilter(new Uint8Array(Math.ceil(bits / 8)), hashCount);
|
|
43
|
+
}
|
|
44
|
+
get bitSize() {
|
|
45
|
+
return this.bytes.length * 8;
|
|
46
|
+
}
|
|
47
|
+
/** Probabilistic membership: true = definitely or probably present. */
|
|
48
|
+
async contains(item) {
|
|
49
|
+
for await (const index of this.indices(item)) {
|
|
50
|
+
const byte = this.bytes[index >> 3] ?? 0;
|
|
51
|
+
if ((byte & (1 << (index & 7))) === 0)
|
|
52
|
+
return false;
|
|
53
|
+
}
|
|
54
|
+
return true;
|
|
55
|
+
}
|
|
56
|
+
async insert(item) {
|
|
57
|
+
for await (const index of this.indices(item)) {
|
|
58
|
+
this.bytes[index >> 3] = (this.bytes[index >> 3] ?? 0) | (1 << (index & 7));
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
/** deterministic-bloom's HashIndexIterator: k accepted indexes, seeds
|
|
62
|
+
* counting up from 0 across hash rounds and rejection samples alike. */
|
|
63
|
+
async *indices(item) {
|
|
64
|
+
const size = this.bitSize;
|
|
65
|
+
if (size === 0 || this.hashCount === 0)
|
|
66
|
+
return;
|
|
67
|
+
const po2 = Math.pow(2, Math.ceil(Math.log2(size)));
|
|
68
|
+
let seed = 0;
|
|
69
|
+
for (let accepted = 0; accepted < this.hashCount; seed++) {
|
|
70
|
+
if (seed > 10_000_000)
|
|
71
|
+
throw new Error("bloom rejection sampling exhausted");
|
|
72
|
+
const hex = await xxhash3(item, seed, 0);
|
|
73
|
+
// NB: the `as usize` truncation described above.
|
|
74
|
+
const hash = BigInt("0x" + hex) & 0xffffffffn;
|
|
75
|
+
const value = Number(hash % BigInt(po2));
|
|
76
|
+
if (value < size) {
|
|
77
|
+
accepted++;
|
|
78
|
+
yield value;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
package/dist/cid.d.ts
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { bytesToHex } from "./keys.ts";
|
|
2
|
+
export declare const CODEC_RAW = 85;
|
|
3
|
+
export declare const CODEC_DAG_PB = 112;
|
|
4
|
+
export declare const CODEC_DAG_CBOR = 113;
|
|
5
|
+
export declare const HASH_SHA256 = 18;
|
|
6
|
+
export declare const HASH_BLAKE3 = 30;
|
|
7
|
+
export type HashName = "sha256" | "blake3";
|
|
8
|
+
/** The DASL/BDASL CID of the given bytes (canonical base32-lower text). */
|
|
9
|
+
export declare function cidForBytes(data: Uint8Array, opts?: {
|
|
10
|
+
codec?: number;
|
|
11
|
+
hash?: HashName;
|
|
12
|
+
}): string;
|
|
13
|
+
/** CID text from a raw digest, codec and multihash code. */
|
|
14
|
+
export declare function cidFromDigest(digest: Uint8Array, codec?: number, hashCode?: number): string;
|
|
15
|
+
/** Convenience: hex digest + codec → CID (useful with external hashers). */
|
|
16
|
+
export declare function cidFromDigestHex(hex: string, codec?: number): string;
|
|
17
|
+
export { bytesToHex };
|
package/dist/cid.js
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
// DASL/BDASL CID computation (CIDv1, base32-lower canonical form).
|
|
2
|
+
//
|
|
3
|
+
// Mirrors firkin-core's codec: version 0x01 + codec uvarint + multihash
|
|
4
|
+
// (SHA-256 0x12 or BLAKE3 0x1e, 32-byte digest).
|
|
5
|
+
import { sha256 } from "@noble/hashes/sha2";
|
|
6
|
+
import { blake3 } from "@noble/hashes/blake3";
|
|
7
|
+
import { bytesToHex } from "./keys.js";
|
|
8
|
+
export const CODEC_RAW = 0x55;
|
|
9
|
+
export const CODEC_DAG_PB = 0x70;
|
|
10
|
+
export const CODEC_DAG_CBOR = 0x71;
|
|
11
|
+
export const HASH_SHA256 = 0x12;
|
|
12
|
+
export const HASH_BLAKE3 = 0x1e;
|
|
13
|
+
const BASE32_ALPHABET = "abcdefghijklmnopqrstuvwxyz234567";
|
|
14
|
+
function base32LowerNoPad(bytes) {
|
|
15
|
+
let bits = 0;
|
|
16
|
+
let value = 0;
|
|
17
|
+
let out = "";
|
|
18
|
+
for (const b of bytes) {
|
|
19
|
+
value = (value << 8) | b;
|
|
20
|
+
bits += 8;
|
|
21
|
+
while (bits >= 5) {
|
|
22
|
+
out += BASE32_ALPHABET[(value >>> (bits - 5)) & 31];
|
|
23
|
+
bits -= 5;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
if (bits > 0) {
|
|
27
|
+
out += BASE32_ALPHABET[(value << (5 - bits)) & 31];
|
|
28
|
+
}
|
|
29
|
+
return out;
|
|
30
|
+
}
|
|
31
|
+
function pushUvarint(out, value, at) {
|
|
32
|
+
let v = value;
|
|
33
|
+
let i = at;
|
|
34
|
+
while (v >= 0x80) {
|
|
35
|
+
out[i++] = (v & 0x7f) | 0x80;
|
|
36
|
+
v >>>= 7;
|
|
37
|
+
}
|
|
38
|
+
out[i++] = v;
|
|
39
|
+
return i;
|
|
40
|
+
}
|
|
41
|
+
/** The DASL/BDASL CID of the given bytes (canonical base32-lower text). */
|
|
42
|
+
export function cidForBytes(data, opts = {}) {
|
|
43
|
+
const codec = opts.codec ?? CODEC_RAW;
|
|
44
|
+
const hash = opts.hash ?? "sha256";
|
|
45
|
+
const hashCode = hash === "blake3" ? HASH_BLAKE3 : HASH_SHA256;
|
|
46
|
+
const digest = hash === "blake3" ? blake3(data) : sha256(data);
|
|
47
|
+
return cidFromDigest(digest, codec, hashCode);
|
|
48
|
+
}
|
|
49
|
+
/** CID text from a raw digest, codec and multihash code. */
|
|
50
|
+
export function cidFromDigest(digest, codec = CODEC_RAW, hashCode = HASH_SHA256) {
|
|
51
|
+
if (digest.length !== 32)
|
|
52
|
+
throw new Error("expected a 32-byte digest");
|
|
53
|
+
const wire = new Uint8Array(4 + 32);
|
|
54
|
+
wire[0] = 0x01;
|
|
55
|
+
let i = pushUvarint(wire, codec, 1);
|
|
56
|
+
i = pushUvarint(wire, hashCode, i);
|
|
57
|
+
i = pushUvarint(wire, 32, i);
|
|
58
|
+
wire.set(digest, i);
|
|
59
|
+
return "b" + base32LowerNoPad(wire);
|
|
60
|
+
}
|
|
61
|
+
/** Convenience: hex digest + codec → CID (useful with external hashers). */
|
|
62
|
+
export function cidFromDigestHex(hex, codec = CODEC_RAW) {
|
|
63
|
+
const digest = new Uint8Array(32);
|
|
64
|
+
for (let i = 0; i < 32; i++) {
|
|
65
|
+
digest[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);
|
|
66
|
+
}
|
|
67
|
+
return cidFromDigest(digest, codec);
|
|
68
|
+
}
|
|
69
|
+
export { bytesToHex };
|
package/dist/client.d.ts
ADDED
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
import { CID } from "multiformats";
|
|
2
|
+
import type { Signer } from "./keys.ts";
|
|
3
|
+
/** Supplies a fresh bearer token for a command. Mutating commands are
|
|
4
|
+
* single-use server-side — implementations MUST mint a new token per call
|
|
5
|
+
* (the default signer source does). */
|
|
6
|
+
export type TokenSource = (command: string) => Promise<Uint8Array>;
|
|
7
|
+
/** Signer-backed source: mints a fresh direct invocation per request. */
|
|
8
|
+
export declare function signerToken(signer: Signer, expireIn?: number): TokenSource;
|
|
9
|
+
/** Fixed-token source: only appropriate for public/read-only usage. */
|
|
10
|
+
export declare function staticToken(token: Uint8Array): TokenSource;
|
|
11
|
+
export interface UploadResult {
|
|
12
|
+
cid: string;
|
|
13
|
+
/** True when the blob was already stored (dedup; claim joined). */
|
|
14
|
+
deduplicated: boolean;
|
|
15
|
+
}
|
|
16
|
+
export interface ByteRange {
|
|
17
|
+
/** Inclusive start (default 0). */
|
|
18
|
+
start?: number;
|
|
19
|
+
/** Inclusive end (default last byte). */
|
|
20
|
+
end?: number;
|
|
21
|
+
/** Last N bytes (alternative to start/end). */
|
|
22
|
+
suffix?: number;
|
|
23
|
+
}
|
|
24
|
+
export interface RangeResult {
|
|
25
|
+
data: Uint8Array;
|
|
26
|
+
/** Inclusive range within the full blob. */
|
|
27
|
+
start: number;
|
|
28
|
+
end: number;
|
|
29
|
+
total: number;
|
|
30
|
+
}
|
|
31
|
+
/** Local block store used for pull-side Bloom narrowing (spec §3.2.1): the
|
|
32
|
+
* cache's CIDs go into the pull request's filter, so the server skips
|
|
33
|
+
* blocks this client already has. */
|
|
34
|
+
export interface BlockCache {
|
|
35
|
+
get(cid: string): Promise<Uint8Array | undefined> | Uint8Array | undefined;
|
|
36
|
+
set(cid: string, bytes: Uint8Array): Promise<void> | void;
|
|
37
|
+
keys(): Promise<string[]> | string[];
|
|
38
|
+
}
|
|
39
|
+
/** Simple in-memory cache (per Firkin instance). */
|
|
40
|
+
export declare class MemoryBlockCache implements BlockCache {
|
|
41
|
+
private map;
|
|
42
|
+
get(cid: string): Uint8Array | undefined;
|
|
43
|
+
set(cid: string, bytes: Uint8Array): void;
|
|
44
|
+
keys(): string[];
|
|
45
|
+
}
|
|
46
|
+
export declare class Firkin {
|
|
47
|
+
private readonly base;
|
|
48
|
+
private readonly token;
|
|
49
|
+
private readonly fetchImpl;
|
|
50
|
+
private readonly blockCache;
|
|
51
|
+
constructor(base: string, token: TokenSource, fetchImpl?: typeof fetch, blockCache?: BlockCache);
|
|
52
|
+
/** Upload bytes; returns the CID and whether it was already stored. */
|
|
53
|
+
upload(data: Uint8Array | Blob, opts?: {
|
|
54
|
+
codec?: number;
|
|
55
|
+
hash?: "sha256" | "blake3";
|
|
56
|
+
}): Promise<UploadResult>;
|
|
57
|
+
/** Fetch a whole blob. Authenticated reads: the server's reads are public
|
|
58
|
+
* by default, but a `PUBLIC_READS=false` deployment requires the
|
|
59
|
+
* `/firkin/blob/read` capability — read tokens are reusable (reads are
|
|
60
|
+
* not replay-tracked), so sending one is always correct and cheap. */
|
|
61
|
+
get(cid: string): Promise<Uint8Array>;
|
|
62
|
+
/** Fetch a byte range (206 responses; server-trusted verification).
|
|
63
|
+
* Authenticated like [`get`](#get). */
|
|
64
|
+
getRange(cid: string, range?: ByteRange): Promise<RangeResult>;
|
|
65
|
+
/** Attach an already-stored CID to the signer's account. */
|
|
66
|
+
claim(cid: string): Promise<void>;
|
|
67
|
+
/** Detach the signer's claim; bytes die with the last claimant. */
|
|
68
|
+
delete(cid: string): Promise<void>;
|
|
69
|
+
/** Revoke delegated sessions: specific delegation CIDs, or every
|
|
70
|
+
* delegation minted before now (`all: true`). Must be authorized by the
|
|
71
|
+
* account key itself — a direct invocation (seed accounts) or a fresh
|
|
72
|
+
* passkey assertion delegating `/firkin/account/revoke` to the session key
|
|
73
|
+
* (passkey-native accounts; the server caps its lifetime at 10 minutes).
|
|
74
|
+
* A plain delegated session cannot revoke. */
|
|
75
|
+
revoke(opts: {
|
|
76
|
+
cids?: string[];
|
|
77
|
+
all?: boolean;
|
|
78
|
+
}): Promise<void>;
|
|
79
|
+
/** (Admin) register a DID as an account, optionally binding a WebAuthn
|
|
80
|
+
* credential (`id` = base64url rawId, `spki` = base64url of the browser's
|
|
81
|
+
* getPublicKey() DER). The binding is what lets a fresh browser re-attach
|
|
82
|
+
* the passkey later via `Firkin.whoami` — passkey-native accounts have no
|
|
83
|
+
* other record of the credential↔account link. */
|
|
84
|
+
register(did: string, credential?: {
|
|
85
|
+
id: string;
|
|
86
|
+
spki: string;
|
|
87
|
+
}): Promise<void>;
|
|
88
|
+
/** (Account key) attest a session delegation with the bound passkey. */
|
|
89
|
+
attest(body: {
|
|
90
|
+
credential_id: string;
|
|
91
|
+
auth_data: string;
|
|
92
|
+
client_data: string;
|
|
93
|
+
signature: string;
|
|
94
|
+
delegation_cid: string;
|
|
95
|
+
}): Promise<void>;
|
|
96
|
+
/** (Account key) bind a WebAuthn credential to this account. */
|
|
97
|
+
bindCredential(credential: {
|
|
98
|
+
id: string;
|
|
99
|
+
spki: string;
|
|
100
|
+
}): Promise<void>;
|
|
101
|
+
/** Resolve a passkey assertion to its account DID — the login primitive
|
|
102
|
+
* for passkey-native accounts on a fresh browser. The client performs a
|
|
103
|
+
* discoverable-credentials WebAuthn get (no `allowCredentials`); the
|
|
104
|
+
* server looks the credential up (bound at registration), verifies the
|
|
105
|
+
* ceremony + ES256 signature, and answers with the DID. Static because
|
|
106
|
+
* the endpoint is unauthenticated by design: the verified signature IS
|
|
107
|
+
* the authentication. */
|
|
108
|
+
static whoami(base: string, assertion: {
|
|
109
|
+
id: string;
|
|
110
|
+
authData: Uint8Array;
|
|
111
|
+
clientDataJSON: string;
|
|
112
|
+
signature: Uint8Array;
|
|
113
|
+
}, fetchImpl?: typeof fetch): Promise<string>;
|
|
114
|
+
/** (Admin) list registered accounts. */
|
|
115
|
+
listAccounts(): Promise<{
|
|
116
|
+
did: string;
|
|
117
|
+
registered_at: number;
|
|
118
|
+
}[]>;
|
|
119
|
+
/** Set `name` → `cid`. Overwrites silently (last-writer-wins). */
|
|
120
|
+
registrySet(name: string, cid: string): Promise<void>;
|
|
121
|
+
/** Resolve `name`; returns null when unset. */
|
|
122
|
+
registryGet(name: string): Promise<string | null>;
|
|
123
|
+
/** Unset `name` (idempotent). */
|
|
124
|
+
registryDelete(name: string): Promise<void>;
|
|
125
|
+
/** The account's whole registry: name → CID (sorted). */
|
|
126
|
+
registryList(): Promise<Record<string, string>>;
|
|
127
|
+
/**
|
|
128
|
+
* Push a flat file as an ordered list of chunks: each chunk is stored as
|
|
129
|
+
* a raw block, and a dag-cbor list of their CIDs becomes the root — the
|
|
130
|
+
* shape [`pullFile`](#pullfile) reassembles. The manifest is built here
|
|
131
|
+
* and pushed through [`dagPush`](#dagpush), so there is one
|
|
132
|
+
* push implementation. For an existing DAG (real CIDs and real root),
|
|
133
|
+
* use `dagPush` directly.
|
|
134
|
+
*/
|
|
135
|
+
pushFile(chunks: Uint8Array[], opts?: {
|
|
136
|
+
roundBudgetBytes?: number;
|
|
137
|
+
}): Promise<string>;
|
|
138
|
+
/**
|
|
139
|
+
* Push an existing DAG as-is: `blocks` maps each block's REAL CID to its
|
|
140
|
+
* bytes, and `root` is the DAG's real root (one of the blocks). Same
|
|
141
|
+
* multi-round / Bloom-narrowed protocol as [`pushFile`](#pushfile), but
|
|
142
|
+
* nothing is re-hashed or wrapped in a manifest — the server stores the
|
|
143
|
+
* blocks under the given CIDs, so `dagPull(root)` works for other clients
|
|
144
|
+
* and a name-registry entry pointing at `root` resolves to the real DAG
|
|
145
|
+
* (e.g. a WNFS root tree: dag-pb root + dag-cbor/dag-pb descendants).
|
|
146
|
+
*
|
|
147
|
+
* Links are followed per the block's own codec (dag-cbor and dag-pb);
|
|
148
|
+
* blocks of other codecs are terminals.
|
|
149
|
+
*/
|
|
150
|
+
dagPush(root: string | CID, blocks: Iterable<[string | CID, Uint8Array]>, opts?: {
|
|
151
|
+
roundBudgetBytes?: number;
|
|
152
|
+
}): Promise<string>;
|
|
153
|
+
/**
|
|
154
|
+
* The shared CAR Mirror push loop for a graph already in memory: BFS from
|
|
155
|
+
* the round's subgraph roots, Bloom-skip blocks the server has (never the
|
|
156
|
+
* subgraph roots themselves), budget-chunked; repeat while the server
|
|
157
|
+
* reports pending roots.
|
|
158
|
+
*/
|
|
159
|
+
private pushGraph;
|
|
160
|
+
/** Pull a whole DAG knowing only its root; returns its blocks by CID.
|
|
161
|
+
*
|
|
162
|
+
* Spec §3.2: with a populated block cache, the pull request carries a
|
|
163
|
+
* Bloom filter over the cached CIDs, so the server skips blocks this
|
|
164
|
+
* client already has; the loop re-requests until the local graph below
|
|
165
|
+
* the root is complete (Bloom false positives can strand a block for one
|
|
166
|
+
* round — it's uncached, so the next filter omits it and the server
|
|
167
|
+
* sends it). With an empty cache this is a cold pull, exactly as before.
|
|
168
|
+
*/
|
|
169
|
+
dagPull(root: string): Promise<Map<string, Uint8Array>>;
|
|
170
|
+
/**
|
|
171
|
+
* Pull + reassemble a file pushed via [`pushFile`](#pushfile): the root is
|
|
172
|
+
* a dag-cbor list of raw chunk CIDs in order — concatenates them back.
|
|
173
|
+
*/
|
|
174
|
+
pullFile(root: string): Promise<Uint8Array>;
|
|
175
|
+
}
|
|
176
|
+
export declare function carFile(root: CID, entries: [CID, Uint8Array][]): Uint8Array;
|
|
177
|
+
export declare function multihashSha256(data: Uint8Array): MultihashDigest;
|
|
178
|
+
type MultihashDigest = {
|
|
179
|
+
code: number;
|
|
180
|
+
size: number;
|
|
181
|
+
digest: Uint8Array;
|
|
182
|
+
bytes: Uint8Array;
|
|
183
|
+
};
|
|
184
|
+
/** The CID links of a block, per its codec: dag-cbor (0x71) and dag-pb
|
|
185
|
+
* (0x70). Other codecs have no parseable links (raw blocks are terminals). */
|
|
186
|
+
export declare function linksOf(code: number, bytes: Uint8Array): CID[];
|
|
187
|
+
export {};
|