Skip to content

Latest commit

 

History

History
549 lines (471 loc) · 30.3 KB

File metadata and controls

549 lines (471 loc) · 30.3 KB

mkit repo worker

The mkit anonymous-multiplayer repo server: a Rust Cloudflare Worker (workers-rs) that speaks ConnectRPC over the mkit.repo.v1.RepoService contract in proto/mkit/repo/v1/repo.proto.

A single shared mkit repository anyone may push to anonymously. Every write is signed with an Ed25519 key (the key is the whole identity — no accounts, no allow-list). Immutable mkit objects are content-addressed in R2; refs are the only mutable state and advance via compare-and-swap inside a Durable Object.

Architecture

  worker::Request ─┐
                   │  #[event(fetch)]  (src/worker_impl.rs)
                   ▼
       http::Request<Full<Bytes>>
                   │  ConnectRpcService::new(Router).with_interceptor(AuthInterceptor)
                   │  driven via tower::ServiceExt::oneshot
                   ▼
        RepoService impl (src/worker_impl/service.rs)
           ├── PutObject / GetObject ───────────────▶ R2 bucket  (binding STORAGE)
           └── GetRef / UpdateRef / ListRefs ───────▶ RefStore Durable Object (binding REFSTORE)
                   │                                     • one instance per `room`
                   ▼                                     • SQLite refs(path, value)
       http::Response<ConnectRpcBody>                    • serial CAS  (src/worker_impl/refstore.rs)
                   │  collect body → Bytes
                   ▼
              worker::Response

Everything that touches the JS/WASM boundary goes through the worker crate's typed wrappers (Request, Response, Env, Bucket, State/SqlStorage, WebSocket). You never call worker-sys directly; the DO and the fetch entry point are generated by #[durable_object] / #[event(fetch)].

The worker::Requesthttp adapter

ConnectRPC's server is ConnectRpcService<Router>, which implements tower::Service<http::Request<B>> where B: http_body::Body<Data = Bytes> and returns http::Response<ConnectRpcBody> with Error = Infallible. The fetch handler bridges the two worlds at exactly one boundary (src/worker_impl.rs):

  1. Request in. Read the raw body once (req.bytes().await) — the auth interceptor needs the exact wire bytes — and wrap them in http_body_util::Full<Bytes> (the simplest Body<Data = Bytes, Error = Infallible>). Map the method and copy every header across (so X-Public-Key … and the Connect Content-Type reach the codec and the interceptor).
  2. Dispatch. ConnectRpcService::new(router).with_interceptor(AuthInterceptor), driven by tower::ServiceExt::oneshot(http_req).await. The router needs no tokio runtime: on wasm32 its internal spawn is wasm_bindgen_futures::spawn_local.
  3. Response out. Collect ConnectRpcBody to Bytes (http_resp.into_body().collect().await.to_bytes()), rebuild a worker::Response::from_bytes(...).with_status(status), and copy the response headers back.

Send on wasm. ConnectRpcService requires the handler future to be Send, but worker R2/DO handles wrap JS values and are !Send. Workers is single-threaded, so each block that awaits a worker handle is wrapped in worker::send::SendFuture (an unsafe impl Send shim that is sound under a single thread). worker::Env is itself unsafe impl Send + Sync, so it lives in the service struct and is cheap to clone per request.

The write envelope (DEMO MODE — open write, no allow-list)

Auth rides in request metadata (headers), not in the proto message, so one guard (src/worker_impl/auth.rs, a ConnectRPC unary Interceptor) covers every write uniformly. The canonical signed string is built byte-for-byte by client and server (src/envelope.rs):

canonical = [ "mkit-write:v1",
              procedure,        // e.g. "/mkit.repo.v1.RepoService/UpdateRef"
              bodyDigest,       // lowercase-hex BLAKE3 of the RAW request body bytes
              createdAt,        // decimal epoch-ms
              idempotencyKey ]  // the Idempotency-Key value, or "" if absent
            .join("\n")

signing_digest = BLAKE3(utf8(canonical))            // 32 bytes
valid          = ed25519_verify_strict(pubkey, signing_digest, signature)

Headers: X-Public-Key (64-hex), X-Signature (128-hex), X-Digest (64-hex, the client-claimed BLAKE3 of the raw body), X-Created-At (epoch-ms), Idempotency-Key (optional).

The server recomputes BLAKE3(raw body) and checks it equals X-Digest (400 body digest mismatch otherwise), enforces a ±5 min freshness window (401 stale or future signature), and strict-verifies the Ed25519 signature (401 invalid signature). This is a plain envelope digest — NOT an mkit commit signature — so the SPEC-SIGNING commit/remix/tag domain prefixes do not apply. Verification uses ed25519_dalek::VerifyingKey::verify_strict (RFC 8032/ZIP-215-off), the same strict line mkit-core::sign holds.

Open-write: any valid Ed25519 key may write any ref; a valid signature proves request integrity and same-author, never authority. The verified writer pubkey is attributed onto each RefEvent.

Writes (PutObject, UpdateRef) require the envelope. Reads (GetObject, GetRef, ListRefs, WatchRefs) are unauthenticated.

Security model/known limitations

This is a DEMO server; the envelope proves request integrity and same-author, not authority. Be aware of:

  • Replay within the freshness window. A captured signed write is replayable for as long as it stays fresh (the ±5 min X-Created-At window), but the Idempotency-Key IS deduplicated server-side for the writes where a replay would otherwise be observable: PostMessage and React dedupe on (author, idem), and UpdateRef dedupes on (author, name, idem) — a replayed request returns the ORIGINAL result instead of re-applying (see idem_keys / react_idem / update_idem in refstore.rs). This closes the REF_EXPECTATION_ANY ANY-clobber: a replayed ANY-update, resubmitted inside the freshness window, returns its first result rather than re-running the CAS against whatever the ref holds now. MISSING/MATCH updates were already self-limiting (the precondition fails on replay) and remain so. For content-addressed PutObject a replay is inert regardless (same object_idduplicate=true), so it has no dedupe table. Each ledger is pruned on the same freshness-window schedule so it can't grow unbounded.
  • Open write. Any valid Ed25519 key may write any ref in any room; there is no allow-list. The signature is integrity and attribution, never authorization.
  • Input bounds. room is validated ^[A-Za-z0-9._-]{1,64}$; ref name/ prefix follow SPEC-REFS §3; request bodies (and the PutObject bytes payload) are capped at 8 MiB. Invalid inputs are rejected with Connect invalid_argument before any storage I/O.
  • Per-key write quota. A valid Ed25519 signature proves a distinct key, not a throttled one, and a fresh key is free to mint — so PutObject/ UpdateRef are additionally metered per (author, room): at most WRITE_QUOTA_MAX_OPS writes and WRITE_QUOTA_MAX_BYTES of PutObject bytes per author per room in a rolling WRITE_QUOTA_WINDOW_MS window (see src/write_quota.rs). AuthInterceptor (src/worker_impl/auth.rs) checks-and-consumes the budget against the room's RefStore DO (POST /quota, src/worker_impl/refstore.rs) BEFORE the handler runs, so the counter lives in the DO's serial per-room state rather than a Worker-global that would race across isolates. Over-quota writes are rejected with Connect resource_exhausted. A DO-unreachable quota check fails OPEN (logged, write proceeds) rather than turning a transient infra hiccup into an outage for every writer. This is application-layer defense; pair with a Cloudflare Rate Limiting rule keyed on X-Public-Key at the edge for defense in depth (not configured here).

Endpoints

ConnectRPC unary, POST /mkit.repo.v1.RepoService/<Method>:

RPC Auth Behavior
PutObject write Verifies BLAKE3(bytes) == object_id; stores idempotently at R2 key {room}/objects/{hex(object_id)} (re-PUT → duplicate=true).
GetObject read R2 get → {found, bytes}.
GetRef read DO read → {exists, object_id}.
UpdateRef write CAS (ANY/MISSING/MATCH) inside the DO's serial execution → {committed, conflict, current_id}.
ListRefs read DO list under an optional prefix → {refs}.
WatchRefs read Connect server-streaming, bridged from the DO's /watch WebSocket, streaming a RoomEvent (commit/chat/reaction/presence) per broadcast — see below.
PurgeRoom admin Deletes the room's R2 objects and chat bodies and its DO-resident refs/messages/reactions/commit-index rows. Irreversible. See "Retention and backup/restore" below.

WatchRefs / streaming (issue #705, building on the #697 spike)

WatchRefs is real Connect server-streaming, not a stub: the worker opens its own WebSocket to the room's RefStore DO /watch endpoint (the same endpoint the raw-WebSocket fallback below proxies to a browser), consumes it, and re-emits every broadcast — a ref advance, a chat post, a reaction toggle, or a presence roster change — as a RoomEvent on the ServiceStream<RoomEvent> the generated trait requires. RoomEvent is a oneof over RefEvent/ChatMessage/ReactionEvent/PresenceEvent (see proto/mkit/repo/v1/repo.proto), so a Connect client sees one schema- validated feed for the whole room, not just ref advances.

The lifetime wall, and the bridge past it. WebSocket::events() returns EventStream<'ws>, which borrows the socket — a struct holding both a WebSocket and its own EventStream<'_> is self-referential and can't be built in safe Rust, so naively trying to return that borrowed stream as the 'static + Send ServiceStream<RoomEvent> doesn't compile. The fix: never let the borrow escape a function. service.rs::bridge_watch_socket owns the WebSocket, calls .events() on it locally, and fully drains that borrowed stream inside its own async body — which runs under wasm_bindgen_futures::spawn_local rather than a Send-bounded spawn, so it never needs to be Send either. Each event it decodes is translated into a fully-owned RoomEvent (or ConnectError) and pushed onto a futures_channel::mpsc::unbounded sender; the receiver end — the "owned channel" — is what actually satisfies ServiceStream<RoomEvent>: it holds no borrow into the WebSocket or the spawned task, so it is Send (its item type is plain owned data) and 'static (no lifetime parameters at all), with no unsafe impl Send required anywhere in the bridge. See the doc comment on RepoServer::watch_refs in src/worker_impl/service.rs for the full walkthrough, and src/room_event.rs (host-testable, covered by cargo test --lib) for the RoomEvent build/encode/decode functions shared by the DO broadcast path and this bridge.

Delivery in local wrangler dev — VERIFIED (2026-07-11, issue #705); a real deployed-Worker trial — VERIFIED (2026-07-13, issue #803). The #697 spike that introduced this bridge reported a gap: a hand-rolled Connect-streaming test client against WatchRefs under local wrangler dev received zero bytes, not even headers, seconds after the bridge had already logged processing an event — and left it unresolved, guessing either a wrangler dev/miniflare buffering artifact or a bug in the generic HTTP response adapter (worker_impl.rs::serve_connect). Re-running that exact scenario against the SAME bridge code in this pass, with careful attention to test-harness artifacts (a naive curl -N ... & sleep; kill pattern can lose buffered-but-unflushed bytes on SIGTERM before they hit disk, which is what produced the "zero bytes" symptom on a subsequent repro attempt in this same session — polling for actual byte growth before tearing down the client fixed it):

  • 20+ manual trials against a fresh local wrangler dev instance (wrangler 4.110.0, worker 0.8.5, connectrpc 0.8.0) delivered every triggered event, every time — single-event, back-to-back multi-trial, and incremental multi-event-over-one-connection runs (two UpdateRefs on the same open WatchRefs stream, each arriving within tens of milliseconds of the triggering RPC, not buffered until the stream closes).
  • Repeated with Accept-Encoding: gzip set (matching what a real browser/ fetch() client negotiates) — no change; delivery is not gzip-buffered.
  • Repeated after modeling the full RoomEvent oneof (this issue's scope): all four kinds — commit, chat, reaction, presence — arrive correctly as the new proto union, not the old flat/ad hoc shape.
  • apps/repo-worker/tests/watch_refs_stream.rs is a host-side (no wasm32 target, no DO, no Worker runtime) cargo test that drives WatchRefs through the REAL connectrpc::Router/ConnectRpcService dispatch used in production and asserts all four RoomEvent kinds decode correctly off the wire — this is the automated regression test PR #738's spike didn't have.

Real deployed Cloudflare Worker — VERIFIED (2026-07-13, issue #803). wrangler dev --remote remains a dead end for this Worker independent of authorization: this wrangler version (4.110.0) has fully removed Durable Object support from --remote mode (wrangler dev --remote is no longer supported for Durable Objects), which every RPC except the unary PutObject/GetObject R2 path depends on (the RefStore DO). Instead, this was verified against mkit-repo-worker-staging (staging-api.mkit.sh, wrangler deploy --env staging — isolated storage, no production state touched): opened a real WatchRefs stream via buf curl against the live edge deployment, then drove two signed UpdateRef calls (via src/bin/sign.rs, a CAS-ANY write followed by a CAS-MATCH write against the first's currentId) against the same room. Both commit events arrived on the already-open stream — the first immediately after its RPC, the second back-to-back on the same connection without a reconnect — closing the last mile of confidence the local-wrangler dev-only verification above couldn't reach on its own.

Scope. The bridge is single-subscriber-per-request, not bidi: each WatchRefs call opens its own worker→DO WebSocket, which is fine for a demo's fan-out but is a DO connection per Connect subscriber, not shared.

Raw-WebSocket fallback (still wired, still the production path). Live room activity is also still reachable over a raw WebSocket at the worker route GET /watch/<room> — the RefStore DO accepts each subscriber as a hibernatable WebSocket and broadcasts the SAME RoomEvent proto3-JSON payload (see src/room_event.rs) to every subscriber on each successful UpdateRef/PostMessage/React/presence change, so the raw socket and the Connect stream share one wire schema (see mkit#705's Implementation Notes). apps/web's subscribeRoom still uses this raw-WebSocket route directly (not the generated Connect client — that broader TS client rollout across apps/web is tracked separately, out of scope for this issue) but its frame parser (parseActivityFrame in apps/web/src/lib/repo/backend.ts) now decodes the unified RoomEvent schema instead of the old ad hoc WatchFrame/PresenceJson JSON dialects. The unary path is fully functional independent of either streaming path.

Retention and backup/restore

Every mkit object PutObject stores is content-addressed and permanent — no RPC ever deletes one implicitly, and the DO's SQLite prunes are bounded serving caches, not deletion of the underlying data (chat/reaction rows are capped by MESSAGES_RETAINED/REACTIONS_RETAINED, but the objects themselves live in R2 forever; see refstore.rs). Combined with open, anonymous PutObject, storage for an abandoned or abusive room only grows. Two independent mechanisms bound that:

1. PurgeRoom — operator-triggered, immediate

PurgeRoom({ room }) deletes, in this order:

  1. Every R2 object under {room}/objects/ and {room}/messages/ (paginated list and batched delete_multiple, 1000 keys/call).
  2. Every row the room's RefStore DO owns: refs, messages (plus idem_keys), reactions (+ react_idem/react_rate), and the denormalized commits index.

It is irreversible — there is no soft-delete, undo, or grace period — and admin-gated: the request MUST carry a bearer X-Admin-Token header equal to the Worker's ADMIN_TOKEN secret, checked in constant time (worker_impl/auth.rs). Provision the secret once per environment:

# Generate and store a random token (never commit it). Re-running `secret put`
# rotates it — no code change needed on either side.
openssl rand -hex 32 | wrangler secret put ADMIN_TOKEN

An unset ADMIN_TOKEN fails every PurgeRoom call closed (unauthenticated), never open — there is no "demo mode" fallback for this RPC, unlike the open-write envelope above.

Example call (see src/bin/sign.rs for the write-envelope signer used by the other RPCs — PurgeRoom needs no signature, only the admin header):

curl -s -X POST https://api.mkit.sh/mkit.repo.v1.RepoService/PurgeRoom \
  -H 'Content-Type: application/json' \
  -H "X-Admin-Token: $ADMIN_TOKEN" \
  -d '{"room":"some-abandoned-room"}'

2. R2 lifecycle rule — automatic, age-based backstop

PurgeRoom is opt-in and per-room; an operator has to know a room exists and choose to purge it. As a backstop independent of that RPC, configure an R2 object lifecycle rule on the mkit-repo-objects bucket that expires objects a fixed age after upload, so a room nobody ever purges doesn't accumulate cost forever:

# Verify flags with `wrangler r2 bucket lifecycle add --help` first — the CLI
# surface has changed across wrangler versions and this was not exercised
# against a live account in this change. 180 days is a starting point, not a
# validated production value; tune it against actual demo-room lifetimes.
wrangler r2 bucket lifecycle add mkit-repo-objects \
  --id expire-180d --prefix "" --expire-days 180

This was not added as a Terraform resource alongside the WAF rules in infra/cloudflare/. That module authenticates with a zone-scoped token (Zone WAF Write) against mkit.sh's zone, and manages resources (cloudflare_ruleset) that already exist there; R2 buckets are account-scoped and, per this crate's own README, already provisioned out-of-band via wrangler r2 bucket create — Terraform doesn't manage the bucket itself today. Bolting an account-scoped, unverified-schema resource onto a zone-scoped module (with no CLOUDFLARE_ACCOUNT_ID token available to validate a terraform plan from this change) seemed like a worse default than documenting the equivalent wrangler command next to the tool that already owns the bucket's lifecycle. Revisit if/when infra/cloudflare/ grows account-scoped state.

Backup/restore

Assumption stated explicitly (no prior art in this repo for either half):

  • R2 objects. There is no bulk export/import RPC. For ad hoc backup of a single room, script the existing read RPCs (ListRefs, ListCommits, GetObject per hash, ListMessages, ListReactions) — everything needed to reconstruct a room already round-trips over the public read surface, no new machinery required. For whole-bucket backup, R2 exposes an S3-compatible API; use rclone or the AWS CLI against it (rclone sync r2:mkit-repo-objects ./backup/). Restore is the same in reverse (rclone sync ./backup/ r2:mkit-repo-objects) — object keys are content-addressed, so a restore can never corrupt an object, only reintroduce one a client will re-verify by hash on read.
  • DO SQLite state (refs/messages/reactions/commit-index). workers-rs 0.8's SqlStorage exposes no bulk export API, and this repo has no DO-snapshotting tooling. The primary safety net is Cloudflare's own Point-in-Time Recovery for SQLite-backed Durable Objects (automatic, ~30-day retention, dashboard-driven restore-to-bookmark) — this requires NO code or process in this repo, but IS an assumption about a platform feature this change does not verify end-to-end. A room's refs can also be reconstructed from R2 alone (walk ListCommits/GetObject and replay UpdateRef), which is a slower but code-free fallback if PITR is unavailable; chat/reaction history has no such fallback (it isn't derivable from the object graph) and is not durably backed up beyond PITR.

RefStore Durable Object

One instance per room (env.durable_object("REFSTORE").id_from_name(room)). Stores refs in SQLite — refs(path TEXT PRIMARY KEY, value TEXT), value = 64-hex of the 32-byte object id. The worker reaches it over an internal JSON HTTP protocol (POST /get | /update | /list | /quota, GET /watch). The CAS decision is the pure refs::evaluate_cas shared with the unit tests, evaluated inside the DO's single-threaded fetch, so concurrent UpdateRefs can't race. The same single-threaded serialization is why the per-author write-quota ledger (write_quota table, POST /quota) lives here too — see "Per-key write quota" above.

Build and run

# unit tests (host) — envelope verify + ref CAS conformance vectors
cargo test --lib

# compile to wasm32 (what worker-build runs under the hood)
cargo build --target wasm32-unknown-unknown

# full worker bundle (the wrangler [build] command): cdylib → wasm-opt → esbuild
worker-build --release

# local dev server (workerd + R2/DO emulation)
wrangler dev --local

wrangler.jsonc binds the R2 bucket STORAGE, the Durable Object REFSTORE (SQLite migration v1), and compatibility_date.

Conformance client/signer

src/bin/sign.rs is a dev-only host binary that emits the write-envelope headers as curl -H flags (and sign b3 <s> prints a BLAKE3 hex, handy for deriving a PutObject object_id):

BODY='{"room":"demo","name":"refs/heads/main","newId":"<b64>","expectation":"REF_EXPECTATION_MISSING"}'
HDRS=$(cargo run -q --bin sign -- /mkit.repo.v1.RepoService/UpdateRef "$BODY" my-idem-key)
eval curl -s -X POST http://localhost:8787/mkit.repo.v1.RepoService/UpdateRef \
  -H "'Content-Type: application/json'" $HDRS -d "'$BODY'"

Layout

  • src/envelope.rs, src/refs.rs, src/hashing.rs, src/chat.rs, src/write_quota.rs — pure, target-independent logic carrying the conformance contract (the #[cfg(test)] modules replay the TS vectors, or for write_quota exercise the fixed-window budget decision directly). Compiled on host and wasm; cargo test --lib runs them without a wasm32 target or a DO/SQLite harness.
  • src/worker_impl.rs and src/worker_impl/{auth,refstore,service}.rs — wasm32-only worker glue (the macros emit #[wasm_bindgen]).
  • proto/ — the canonical repo.proto, plus buf.gen.yaml (the reference TypeScript codegen recipe external RepoService clients run via buf generate against the buf.build/officialunofficial/mkit-repo BSR module, issue #719 — distinct from ../buf.gen.yaml, which drives apps/web's own internal vendored-codegen regen). repo.proto's buf module (name:, lint/breaking config) lives in the repo-root buf.yaml workspace, not a buf.yaml here. build.rs runs connectrpc-build over repo.proto into $OUT_DIR for this crate's own Rust server, included via connectrpc::include_generated!() — a separate path from the BSR-published recipe above.

Deploy (go live)

Until this is deployed, the multiplayer demo only runs against wrangler dev locally (miniflare R2 and DO) — it is not reachable by anyone else. To make it live for everyone:

  1. Provision storage (one-time, on the Cloudflare account that hosts mkit.sh): wrangler r2 bucket create mkit-repo-objects. The RefStore Durable Object and its v1 sqlite migration are created automatically on first deploy.
  2. Deploy the Worker: wrangler deploy (needs an authenticated wrangler/ CLOUDFLARE_API_TOKEN), or wire a Cloudflare Workers Builds project at apps/repo-worker so it deploys on merge — same mechanism as the web app.
  3. Pin the origin: the api.mkit.sh custom_domain route is declared in wrangler.jsonc and is the production backend origin. This host must match:
    • apps/web/src/security-policy.js → CSP connect-src (already allows https://api.mkit.sh and wss://api.mkit.sh), and
    • the web build's VITE_REPO_BACKEND_URL env (set it to https://api.mkit.sh in the Workers Builds env; when unset the demo falls back to the in-memory mock so it still runs offline).
  4. Ship the web: merge to main → Workers Builds deploys mkit.sh/multiplayer, which will now talk to the live Worker — real cross-device multiplayer, real passkey PRF in actual browsers.

Open write is intentional (anonymous demo); see the security note above for the replay-window/rate-limit caveats before exposing it publicly.

Run your own instance (self-hosting)

The steps above assume the maintainers' own Cloudflare account, the mkit-repo-objects R2 bucket, and the api.mkit.sh route (wrangler.jsonc:40/:49). To run a private instance on your own account instead:

  1. Provision your own bucket. Edit r2_buckets[0].bucket_name in wrangler.jsonc (currently mkit-repo-objects) to a globally-unique name of your own, then create it: wrangler r2 bucket create <your-bucket-name>. Reusing the literal mkit-repo-objects name will collide with the maintainers' bucket if you ever deploy to the same account.
  2. Point the route at your own domain, or drop it. Edit routes[0].pattern in wrangler.jsonc (currently api.mkit.sh) to a hostname on a Cloudflare zone you control, or delete the routes array entirely to deploy to the default <name>.<subdomain>.workers.dev origin instead — no custom domain required.
  3. Deploy: wrangler deploy (needs an authenticated wrangler/ CLOUDFLARE_API_TOKEN). The RefStore Durable Object and its v1 SQLite migration (wrangler.jsonc:65) are created automatically on first deploy, same as the maintainers' instance.
  4. Re-point the web app's CSP and backend URL, if you're also self-hosting apps/web (or writing your own client): add your Worker's origin, in both https:// and wss:// forms, to the connect-src directive in apps/web/src/security-policy.js (the wss:// form covers the /watch/<room> WebSocket — see "WatchRefs / streaming" above), and set the web build's VITE_REPO_BACKEND_URL to your Worker's https:// origin (unset, the web app falls back to an in-memory mock backend).

Cloudflare plan and cost notes

  • Durable Objects with the SQLite storage backend — what RefStore uses (wrangler.jsonc:65, new_sqlite_classes: ["RefStore"]) — run on both the Workers Free and Workers Paid plans; Cloudflare lifted the earlier Paid-only restriction for the SQLite storage class. Re-verify against Cloudflare's Durable Objects pricing page before budgeting — pricing and plan gating change over time, and a stale number here would be worse than none.
  • Free plan caps worth knowing before a real (non-toy) deployment: 100,000 Durable Object requests/day, 100,000 SQLite row writes/day, 5 GB total SQLite storage, and 13,000 GB-s of compute duration/day, all per account. Every UpdateRef costs one DO request plus at least one row write, so a busy shared room can exhaust the daily write cap.
  • R2 (the STORAGE binding, holding every PutObject's bytes) requires a payment method on file to activate at all, even if usage stays inside its free tier (10 GB storage, 1M Class A ops/month, 10M Class B ops/month, no egress charge — see R2 pricing). R2 is billed independently of the Workers plan tier.
  • If you outgrow the Free plan, Workers Paid is a $5/month minimum (metered beyond the included allotments — see Workers pricing), which also raises the Durable Object allotments to 1M requests, 400,000 GB-s duration, 25B row reads, and 50M row writes included per month.
  • Custom domains (routes[0].custom_domain: true) need the domain's zone active on your Cloudflare account (nameservers pointed at Cloudflare) but are themselves available on the Free plan — you do not need Paid just to attach a custom domain. Dropping the routes block and using the default workers.dev subdomain (step 2 above) avoids the zone requirement altogether.

Staging

wrangler.jsonc also declares an env.staging block: a fully isolated deployment (mkit-repo-worker-staging, its own mkit-repo-objects-staging R2 bucket, its own RefStore DO storage) fronted by staging-api.mkit.sh. It never shares state with production.

# validate the config without deploying (no resources touched)
wrangler deploy --env staging --dry-run

# deploy for real (needs an authenticated wrangler / CLOUDFLARE_API_TOKEN)
wrangler deploy --env staging

Use it to validate schema, auth-interceptor, or rate-limit changes against a real Cloudflare account before promoting them to api.mkit.sh. To exercise it end to end, point a generated Connect client (or src/bin/sign.rs and curl) at https://staging-api.mkit.sh and run a PutObject/GetObject round trip — the same generated client used against production works unmodified, just retargeted.