This repository has no description

README.md

Resource Bearer #

CI

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 (ed25519 did:key, DAG-CBOR envelopes via ucan-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 a ResourceBearer client (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 — see docs/CLIENT.md for full usage.
  • scripts/build-worker.sh — builds build/worker/shim.mjs for wrangler using plain wasm-bindgen (no worker-build/esbuild needed).
  • tests/integration.sh — end-to-end suite against real wrangler 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 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 (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-separated did:key:z...) in wrangler vars or a .dev.vars file; create the R2 bucket named resource-bearer (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 rb-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.