- Rust 72%
- TypeScript 18%
- Shell 10%
Resource Bearer #
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. See docs/DESIGN.md.
Browser & Node.js client (TypeScript SDK) #
The SDK lives in sdk/ and is not published to npm — install it
straight from the repo (the repo root is the npm package; npm runs a
prepare script that builds sdk/dist at install time):
npm install git+https://tangled.org/tokono.ma/resource-bearer
# pinned to a commit for reproducible installs:
npm install git+https://tangled.org/tokono.ma/resource-bearer#<commit>
# or from any clone:
git clone https://tangled.org/tokono.ma/resource-bearer
cd resource-bearer && npm install
cd .. && npm install ./resource-bearer
(Pure ESM with no Node-only APIs — runs in browsers via any bundler and in Node 22+ directly.)
Quickstart #
import { Signer, ResourceBearer, signerToken } from "@tokono.ma/resource-bearer";
// 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 ResourceBearer("https://bearer.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: public by default; verify trustlessly by re-hashing.
const bytes = await rb.get(cid);
// Partial reads:
const { data: slice } = await rb.getRange(cid, { start: 0, end: 99 });
// Lifecycle: claims attach to your account; delete detaches yours.
await rb.claim(otherCid);
await rb.delete(cid);
// Batch sync a whole DAG of blocks (CAR Mirror):
const root = await rb.dagPush([block1, block2, block3]);
const blockCount = await rb.dagPull(root);
Delegating to a bot or another device #
import { mintDelegation } from "@tokono.ma/resource-bearer";
// You (the account) grant a narrow capability + expiry:
const proof = await mintDelegation(signer, {
subject: signer.did(),
audience: botDid,
command: "/rb/blob",
expireIn: 3600,
});
// The bot mints single-use invocations carrying your proof:
const token = await mintDelegatedInvocation(botSigner, {
command: "/rb/blob/upload",
proofs: [proof],
});
See docs/CLIENT.md for the full guide (CLI equivalent,
error codes, replay semantics).
Layout #
crates/core— platform-independent logic: DASL CID codec (per dasl.ing: SHA-256, raw codec, base32-lower), streaming upload verifier, UCAN capability scheme. Compiles on any target; unit-tested.crates/worker— the Cloudflare Worker (workers-rs):durable/account.rs—AccountDo(one per DID): registration flag + derived CID index.durable/blob.rs—BlobDo(one per CID): source of truth for the claimant set and upload status; alarm-driven GC.durable/registry.rs—RegistryDo: global account index for admin listing.auth.rs— UCAN 1.0 invocation + delegation verification (ed25519did:key, DAG-CBOR envelopes viaucan-wg/rs-ucan, pinned to a revision).
crates/client—rb-client: reference CLI (keygen, tokens, delegations, CID computation, DAG push/pull).sdk/—@tokono.ma/resource-bearer, the browser-first TypeScript SDK: WebCrypto-compatible ed25519 identities, UCAN 1.0 token minting (byte-identical to the Rust implementation — golden-tested in CI), DASL CID computation, and aResourceBearerclient (upload/get/range/claim/ delete/dagPush/dagPull). Zero Node APIs; runs in browsers and Node 22+. Install:npm install git+https://tangled.org/tokono.ma/resource-bearer— seedocs/CLIENT.mdfor full usage.scripts/build-worker.sh— buildsbuild/worker/shim.mjsfor wrangler using plainwasm-bindgen(no worker-build/esbuild needed).tests/integration.sh— end-to-end suite against realwrangler dev.
API #
| Method | Path | Auth (UCAN command) | Behavior |
|---|---|---|---|
| GET | /healthz |
none | liveness |
| PUT | /blob/<cid> |
/rb/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 |
| GET/HEAD | /blob/<cid> |
public (or /rb/blob/read if PUBLIC_READS=false) |
fetch from R2; supports single-range Range requests (206/416, Accept-Ranges: bytes) |
| DELETE | /blob/<cid> |
/rb/blob/delete (single-use) |
detach the caller's claim; R2 object deleted when the last claim leaves |
| POST | /blob/<cid>/claim |
/rb/blob/claim (single-use) |
attach an already-stored CID to your account (no bytes) |
| GET | /blob/:cid/claims |
/rb/blob/read |
ops/debug view of the claimant set (always authenticated) |
| POST | /dag/push/:root |
/rb/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) |
| POST/GET | /dag/pull/:root |
public (or /rb/blob/read) |
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 | /accounts (admin) |
/rb/account/register (single-use) |
body {"did": "did:key:z..."} |
| GET | /accounts (admin) |
/rb/account/list |
list registered DIDs |
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 |
104857600 (100 MiB) |
max bytes per upload and per CAR Mirror block (protocol caps at 4 MB/frame) |
PUBLIC_READS |
true |
anonymous GET/HEAD + dag-pull; when false, reads require a registered DID too |
Replay protection #
Tokens for mutating commands (upload, delete, claim, account register) are
single-use: the sha2-256 of the exact token bytes is recorded in the caller's
AccountDo until the token's own exp (swept by alarm). A second use of the
same token gets 403 replay detected — mint a fresh token per request
(rb-client 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:
[ invocation_envelope, delegation_envelope, ... ] # proofs root-first
- 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, each delegation envelope forms the chain subject → … → invocation issuer, with command narrowing enforced segment-wise.
Verification: every envelope's ed25519 signature (against its issuer's DID
key), invocation exp, per-proof exp/nbf, proof-CID binding (the
transmitted 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 (nonce tracking) is not implemented yet.
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.
Notes:
- Set
ADMIN_DIDS(comma-separateddid:key:z...) in wrangler vars or a.dev.varsfile; create the R2 bucket namedresource-bearer(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; seerb-client 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).
Tests #
cargo test -p rb-core # unit tests (CID codec, verifier)
./tests/integration.sh # end-to-end against wrangler dev
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.