README.md

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 DELETE detaches 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_DIDS var) 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-separated did:key:z...) in wrangler vars or a .dev.vars file; create the R2 bucket named firkin (or edit wrangler.jsonc).
  • CID_POLICY controls which CID shapes uploads may use (comma-separated presets): dasl (default — CIDv1, raw + dag-cbor/DRISL codecs, base32-lower) and unixfs (adds dag-pb + CIDv0 Qm… + 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-mirror via wnfs-common) default to blake3 CIDs — clients must use SHA-256 CIDs (put_block_keyed) to pass the policy; see firkin 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.