- Rust 60%
- TypeScript 27%
- Shell 8%
- JavaScript 4%
- HTML 1%
- 1 more language <1%
Firkin #
A Rust web API for content-addressed blob storage on Cloudflare Workers: Durable Objects hold the metadata (accounts, per-CID claimant sets), R2 holds the bytes.
Protocols #
What the server speaks, and where each spec lives:
| Protocol | Role here |
|---|---|
| CID (multiformats) | content identifiers: CIDv1, base32-lower canonical form |
| DASL | the default CID shape: raw sled codec, SHA-256, canonical encoding; uploads are verified against it |
| BDASL | BLAKE3 CIDs for progressive/streaming verification (CID_POLICY preset bdasl) |
IPLD dag-cbor / dag-pb |
block encodings; CAR Mirror walks links out of both (UnixFS/WNFS-shaped DAGs work) |
| UCAN 1.0-rc.1 — invocation + delegation | authorization on every mutating request; proofs travel by reference via the delegation store |
| Varsig | signature headers in UCAN envelopes (ed25519 + ES256/P-256) |
| WebAuthn / passkeys | passkey-native accounts: the ES256 credential's key is the account DID; assertions double as delegation proofs |
| CAR Mirror v0.1.0 | DAG batch sync (/dag/push, /dag/pull) with Bloom-narrowed rounds; pinned to wnfs-wg/rs-car-mirror |
| RASL | anonymous, self-certifying retrieval at /.well-known/rasl/<cid> (opt-in) |
HTTP Range / CORS |
ranged blob reads (206/416) for streaming, and cross-origin API access |
Browser & Node.js client (TypeScript SDK) #
The SDK lives in sdk/ and is published to npm as
@tokono.ma/firkin.
Installing from git also works (for unreleased commits): the repo root is
an npm package too and serves the committed sdk/dist, so no build step
is needed. The firkin reference CLI is unrelated to npm — it's a Rust
binary, installed with cargo install --path crates/client (see
docs/CLIENT.md).
npm install @tokono.ma/firkin
# unreleased commits / local checkouts:
npm install git+https://tangled.org/tokono.ma/firkin
npm install git+https://tangled.org/tokono.ma/firkin#<commit> # pinned
(Pure ESM with no Node-only APIs — runs in browsers via any bundler and in Node 22+ directly.)
Quickstart #
import { Signer, Firkin, signerToken } from "@tokono.ma/firkin";
// Your identity: the seed is the account. Persist it (IndexedDB, localStorage,
// wallet-managed) — without it you lose control of your claims.
const signer = await Signer.fromSeedHex(seedHex);
// or: await Signer.generate()
const rb = new Firkin("https://firkin.tokono.ma", signerToken(signer));
// Store: the CID is computed for you; the server verifies + dedups.
const { cid, deduplicated } = await rb.upload(new Blob([data]));
// Read: the client sends a read token (reads are authenticated by default).
const bytes = await rb.get(cid);
// Partial reads:
const { data: slice } = await rb.getRange(cid, { start: 0, end: 99 });
// Media elements can't send headers: a short-lived, CID-scoped read URL.
const url = await rb.readUrl(cid, { expireIn: 60 }); // <audio src={url}>
// Lifecycle: claims attach to your account; delete detaches yours.
await rb.claim(otherCid);
await rb.delete(cid);
// Batch sync a file as ordered chunks (CAR Mirror):
const root = await rb.pushFile([block1, block2, block3]);
const bytes = await rb.pullFile(root); // only the root needed
// …or push an existing DAG as-is (real CIDs, real root — e.g. WNFS):
await rb.dagPush(wnfsRoot, new Map([[cid1, bytes1], [cid2, bytes2]]));
const graph = await rb.dagPull(wnfsRoot); // Map<cid, bytes>
// Name registry: mutable pointers to CIDs (private, always authenticated).
await rb.registrySet("avatar", cid);
const avatar = await rb.registryGet("avatar"); // null when unset
Delegating to a bot or another device #
import { Signer, delegate, invocation } from "@tokono.ma/firkin";
// The bot generates its own key and tells you only its DID —
// the private key never leaves the bot.
const botSigner = await Signer.generate();
const botDid = botSigner.did();
// You (the account) grant a narrow capability + expiry:
const proof = await delegate(signer, {
subject: signer.did(),
audience: botDid,
command: "/firkin/blob",
expireIn: 3600,
});
// Hand `proof` to the bot; it mints single-use invocations carrying it:
const token = await invocation(botSigner, {
command: "/firkin/blob/upload",
proofs: [proof],
});
See docs/CLIENT.md for the full guide (CLI equivalent,
error codes, replay semantics).
API #
Paths sorted alphabetically. Auth is a UCAN invocation unless noted; "single-use" commands are replay-protected.
| Method | Path | Auth (UCAN command) | Behavior |
|---|---|---|---|
| GET/HEAD | /.well-known/rasl/<cid> |
none — RASL is anonymous by definition; requires RASL=true |
RASL retrieval: raw bytes with application/octet-stream, nosniff, immutable caching, range requests; 404 unless enabled |
| POST | /account/recover |
/firkin/account/recover |
body {"new_did": "did:key:z..."}: migrates every claim (and the name registry) to the new DID, registers + indexes it, disables the old identity |
| POST | /account/revoke |
/firkin/account/revoke (account key only) |
body {"cids": ["<proof cid>"]} kills those delegations, or {"all": true} sets a cutoff that rejects every delegation minted before now. Authorized by a direct invocation (seed accounts) or a fresh passkey assertion delegating /firkin/account/revoke to the session key (passkey-native; TTL capped at 10 min) — a delegated session can never revoke |
| POST | /account/whoami |
none (the assertion IS the auth) | body: a WebAuthn assertion {"id", "authData", "clientData", "signature"} (base64url) from a discoverable-credentials touch; verifies it against the bound public key → {"did": "…"} — passkey re-attachment on a fresh browser |
| GET | /accounts (admin) |
/firkin/account/list |
list registered DIDs |
| POST | /accounts (admin) |
/firkin/account/register (single-use) |
body {"did": "did:key:z..."}; optionally {"credential": {"id": "<b64url rawId>", "spki": "<b64url SPKI-DER>"}} — binds the passkey (SPKI must match the DID) so a fresh browser can re-attach it via /account/whoami |
| DELETE | /blob/<cid> |
/firkin/blob/delete (single-use) |
detach the caller's claim; R2 object deleted when the last claim leaves |
| GET/HEAD | /blob/<cid> |
/firkin/blob/read (anonymous only if PUBLIC_READS=true; also accepted as ?token= on GET/HEAD for media URLs) |
fetch from R2; supports single-range Range requests (206/416, Accept-Ranges: bytes). Narrowed read proofs (/firkin/blob/read/<cid>) only cover that CID |
| PUT | /blob/<cid> |
/firkin/blob/upload (single-use) |
stream-verify digest against the CID (SHA-256 or BLAKE3 per the CID's own multihash), store in R2; requires Content-Length; enforces MAX_BLOB_SIZE; dedup: if already stored, just joins the claimant set |
| POST | /blob/<cid>/claim |
/firkin/blob/claim (single-use) |
attach an already-stored CID to your account (no bytes) |
| GET | /blob/<cid>/claims |
/firkin/blob/read |
ops/debug view of the claimant set (always authenticated) |
| POST | /blobs/exists |
/firkin/blob/read (anonymous only if PUBLIC_READS=true) |
body {"cids": [...]} (max 10 000) → {"have": [...], "missing": [...]}; exists-preflight before a push/upload |
| POST | /dag/pull/:root |
/firkin/blob/read (anonymous only if PUBLIC_READS=true) |
CAR Mirror pull: optional dag-cbor PullRequest (Bloom + roots) body; the server walks the stored DAG and streams a CARv1 of the blocks — clients that only know the root get the whole graph |
| POST | /dag/push/:root |
/firkin/blob/upload (single-use) |
CAR Mirror push: client streams a CAR of DAG blocks; each block is verified (per its own multihash) + policy-checked + size-capped + claimed by the subject; response is a dag-cbor PushResponse (200 finished / 202 more rounds) |
| PUT | /delegation/<cid> |
/firkin/blob/upload (single-use) |
body: a delegation envelope or assertion proof; verifies it, checks the CID, stores it in the subject's delegation store (evicted on expiry, capped). Invocations then reference it by CID in prf — the SDK's default (no inline proofs) |
| GET | /health |
none | liveness |
| GET | /registry |
/firkin/registry/read (always) |
the account's registry: {"names": {name: cid, …}} |
| DELETE | /registry/<name> |
/firkin/registry/write (single-use) |
unset a name (idempotent) |
| GET | /registry/<name> |
/firkin/registry/read (always) |
resolve one name → {name, cid} or 404 |
| PUT | /registry/<name> |
/firkin/registry/write (single-use) |
set name → CID; body {"cid": "…"}; values must be CIDs, names are pointers (no claim implied); last-writer-wins; capped by MAX_NAMES |
Admin console #
The worker serves an admin interface at /admin (same origin as the API —
no CORS, no separate hosting). Sources in admin/; dist/ is committed and
served via wrangler assets — rebuild with npm install && npm run build in
admin/.
Passkey-native accounts: the passkey's ES256 credential key IS the
account (a P-256 did:key:zDna…). A touch signs a delegation to a session
key (the assertion's challenge = the bare delegation payload; the server
verifies it against the credential's public key). A 24-word BIP39 phrase can
be registered as a recovery identity (delegated /firkin/account/recover,
indefinite, via a touch) — write down the phrase AND the proof.
Session revocation works for passkey-native accounts too: a fresh touch
delegates /firkin/account/revoke to the session key (the console's
"Revoke ALL sessions" button, or client.revoke({ all: true }) with a
per-revoke assertion). The assertion delegation is capped at 10 minutes
server-side, so a stolen session key — which can never produce a fresh
touch — cannot revoke.
Re-attaching a passkey on a fresh browser (lost IndexedDB, new
device): register the credential at enroll time (client.register(did, { id, spki })) — the server then answers
Firkin.whoami(base, assertion) for a discoverable-credentials touch
(no allowCredentials): one touch, and the browser knows its account DID
again. Both the admin console and the demo have a Use existing
passkey button for this.
Configuration (wrangler vars / .dev.vars) #
| Var | Default | Meaning |
|---|---|---|
ADMIN_DIDS |
(required) | comma-separated admin did:key:z... |
CID_POLICY |
dasl |
accepted CID shapes, comma-separated presets: dasl (v1, raw + dag-cbor/DRISL, SHA-256), unixfs (adds dag-pb + CIDv0 Qm… + base58btc), bdasl (adds BLAKE3 multihash → progressive verified streaming). Gates writes; reads accept any well-formed CID |
MAX_BLOB_SIZE |
1073741824 (1 GiB) |
max bytes per upload and per CAR Mirror block (protocol caps at 4 MB/frame). On Cloudflare the Workers request-body limit is the effective ceiling; 1 GiB is reachable self-hosted |
PUBLIC_READS |
false |
when false (default), GET/HEAD + dag-pull + /blobs/exists require a /firkin/blob/read token (the TS SDK sends them automatically, and ?token= read URLs work). Set true to allow anonymous reads (e.g. a public mirror). Name-registry reads are always authenticated regardless of this var |
MAX_NAMES |
1000 |
max names per account's registry |
RASL |
false |
serve /.well-known/rasl/<cid> for anonymous, content-addressed retrieval (RASL hint host). Explicit opt-in: reads stay authenticated |
RP_ID |
localhost |
WebAuthn relying-party ID (passkey attestation checks) |
ORIGIN |
http://localhost:8787 |
expected WebAuthn assertion origin |
Replay protection #
Tokens for mutating commands (upload, delete, claim, registry write, account
register) are single-use: the invocation envelope's CID is recorded
in the caller's AccountDo until the token's own exp (swept by alarm) —
per the UCAN spec, every token hashes to a unique CIDv1. A
second use of the same token gets 403 replay detected — mint a fresh token
per request (firkin does automatically). Read commands are idempotent and
exempt.
Auth wire format #
Authorization: Bearer <token> where <token> is base64url (unpadded) of
the DAG-CBOR encoding of a container list. Proofs travel by reference (the
SDK's default): the container holds only the invocation, and its prf names
delegations in the subject's delegation store (PUT /delegation/<cid>);
the server resolves them (UCAN spec: an unresolvable CID invalidates the
chain). Inline proofs ([invocation, delegation…]) are still accepted — that
is the bootstrap path that publishes a delegation before it is stored, and
what the firkin CLI sends.
[ invocation_envelope ] # proofs referenced by CID
[ invocation_envelope, delegation_envelope ] # legacy / bootstrap: inline
- the invocation envelope is the UCAN 1.0
[sig, {h, inv}]tuple; - with no proofs, the invocation must be signed directly by the account DID;
- with proofs, the chain runs subject → … → invocation issuer, with command narrowing enforced segment-wise.
Verification: every signature (against its issuer's DID key), invocation
exp, per-proof exp/nbf, proof-CID binding (the resolved proofs must be
exactly those referenced in invocation.proofs), and the issuer chain
(syntatic_checks).
Semantics to keep in mind:
- A delegated request acts on behalf of the subject's account. When bob
uploads via a delegation from alice, the claim on the CID attaches to
alice's account; a delegated
DELETEdetaches alice's claim. - Delegation can never elevate rights past the subject's: admin endpoints require the subject to be an admin DID.
- Admin DIDs (from the
ADMIN_DIDSvar) are implicitly registered accounts. - Replay protection keys on the invocation's CID (see above); reads are exempt.
Build & deploy #
rustup target add wasm32-unknown-unknown
cargo install wasm-bindgen-cli # must match the wasm-bindgen crate version
npm install # wrangler
./scripts/build-worker.sh # outputs ./build/worker/shim.mjs
npx wrangler deploy
npx wrangler dev works against the generated shim for local testing.
Self-hosting (e.g. a Synology NAS with Docker): no conversion needed —
deploy/docker/ runs the same worker build under workerd via
wrangler dev, with Durable Objects + simulated R2 on a volume. See
docs/SELFHOSTING.md.
Notes:
- Set
ADMIN_DIDS(comma-separateddid:key:z...) in wrangler vars or a.dev.varsfile; create the R2 bucket namedfirkin(or editwrangler.jsonc). CID_POLICYcontrols which CID shapes uploads may use (comma-separated presets):dasl(default — CIDv1, raw + dag-cbor/DRISL codecs, base32-lower) andunixfs(adds dag-pb + CIDv0Qm…+ base58btc). E.g.CID_POLICY="dasl,unixfs". Policy gates writes only — reads accept any well-formed CID. All accepted CIDs are SHA-256-multihashed so uploads stay verifiable.- Chunking/Merkle-tree layout is a client concern: any DAG of DASL-CID
blobs (UnixFS-style or otherwise) can be stored as individual blobs with a
manifest root; the server treats every CID as opaque bytes. For DAG-aware
batch sync, the CAR Mirror endpoints (
/dag/push,/dag/pull) DO parse links out of dag-cbor/dag-pb blocks server-side, so clients that don't know the graph can pull it from a root alone. Client deps (car-mirrorviawnfs-common) default to blake3 CIDs — clients must use SHA-256 CIDs (put_block_keyed) to pass the policy; seefirkin dag-push/dag-pull. - The config is intentionally
wrangler.jsonc(not TOML): celld's deployer accepts only the JSON forms, keeping the celld migration path open. - CAR Mirror rounds run against a durable session cache: memoized DAG
links live in a
SessionDo(one per caller+root, single-row state, TTL alarm sweep), loaded once per round and flushed once after — two DO calls per round, never per block. Correctness doesn't depend on it (stateless rounds are spec-legal); it saves re-parsing on multi-round syncs. - CAR Mirror is pinned to
wnfs-wg/rs-car-mirror@2a968ea(spec v0.1.0, WIP upstream).
Cost-consistency notes (design rules baked in) #
- The Worker streams uploads to R2 while hashing; Durable Object calls are short state transitions only (never held open across R2 transfers) — this keeps DO duration inside the cheap included allotment.
- Claim-before-upload + TTL alarm sweeps mean a crashed upload self-heals: the worst case is temporary garbage, never permanently inconsistent state.
- R2 deletes are free, so alarm-driven GC is unmetered.
Contributing #
See CONTRIBUTING.md for the repository layout, test suites, and build prerequisites.