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.
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)].
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):
- Request in. Read the raw body once (
req.bytes().await) — the auth interceptor needs the exact wire bytes — and wrap them inhttp_body_util::Full<Bytes>(the simplestBody<Data = Bytes, Error = Infallible>). Map the method and copy every header across (soX-Public-Key… and the ConnectContent-Typereach the codec and the interceptor). - Dispatch.
ConnectRpcService::new(router).with_interceptor(AuthInterceptor), driven bytower::ServiceExt::oneshot(http_req).await. The router needs no tokio runtime: onwasm32its internal spawn iswasm_bindgen_futures::spawn_local. - Response out. Collect
ConnectRpcBodytoBytes(http_resp.into_body().collect().await.to_bytes()), rebuild aworker::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.
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.
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-Atwindow), but theIdempotency-KeyIS deduplicated server-side for the writes where a replay would otherwise be observable:PostMessageandReactdedupe on(author, idem), andUpdateRefdedupes on(author, name, idem)— a replayed request returns the ORIGINAL result instead of re-applying (seeidem_keys/react_idem/update_ideminrefstore.rs). This closes theREF_EXPECTATION_ANYANY-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/MATCHupdates were already self-limiting (the precondition fails on replay) and remain so. For content-addressedPutObjecta replay is inert regardless (sameobject_id→duplicate=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.
roomis validated^[A-Za-z0-9._-]{1,64}$; refname/prefixfollow SPEC-REFS §3; request bodies (and the PutObjectbytespayload) are capped at 8 MiB. Invalid inputs are rejected with Connectinvalid_argumentbefore 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/UpdateRefare additionally metered per(author, room): at mostWRITE_QUOTA_MAX_OPSwrites andWRITE_QUOTA_MAX_BYTESofPutObjectbytes per author per room in a rollingWRITE_QUOTA_WINDOW_MSwindow (seesrc/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 Connectresource_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 onX-Public-Keyat the edge for defense in depth (not configured here).
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 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 devinstance (wrangler 4.110.0,worker0.8.5,connectrpc0.8.0) delivered every triggered event, every time — single-event, back-to-back multi-trial, and incremental multi-event-over-one-connection runs (twoUpdateRefs on the same openWatchRefsstream, each arriving within tens of milliseconds of the triggering RPC, not buffered until the stream closes). - Repeated with
Accept-Encoding: gzipset (matching what a real browser/fetch()client negotiates) — no change; delivery is not gzip-buffered. - Repeated after modeling the full
RoomEventoneof (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.rsis a host-side (no wasm32 target, no DO, no Worker runtime)cargo testthat drivesWatchRefsthrough the REALconnectrpc::Router/ConnectRpcServicedispatch used in production and asserts all fourRoomEventkinds 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.
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:
PurgeRoom({ room }) deletes, in this order:
- Every R2 object under
{room}/objects/and{room}/messages/(paginatedlistand batcheddelete_multiple, 1000 keys/call). - Every row the room's RefStore DO owns:
refs,messages(plusidem_keys),reactions(+react_idem/react_rate), and the denormalizedcommitsindex.
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_TOKENAn 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"}'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 180This 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.
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,GetObjectper 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; usercloneor 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
SqlStorageexposes 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 (walkListCommits/GetObjectand replayUpdateRef), 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.
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.
# 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 --localwrangler.jsonc binds the R2 bucket STORAGE, the Durable Object REFSTORE
(SQLite migration v1), and compatibility_date.
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'"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 forwrite_quotaexercise the fixed-window budget decision directly). Compiled on host and wasm;cargo test --libruns them without a wasm32 target or a DO/SQLite harness.src/worker_impl.rsandsrc/worker_impl/{auth,refstore,service}.rs— wasm32-only worker glue (the macros emit#[wasm_bindgen]).proto/— the canonicalrepo.proto, plusbuf.gen.yaml(the reference TypeScript codegen recipe externalRepoServiceclients run viabuf generateagainst thebuf.build/officialunofficial/mkit-repoBSR 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-rootbuf.yamlworkspace, not abuf.yamlhere.build.rsrunsconnectrpc-buildoverrepo.protointo$OUT_DIRfor this crate's own Rust server, included viaconnectrpc::include_generated!()— a separate path from the BSR-published recipe above.
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:
- Provision storage (one-time, on the Cloudflare account that hosts mkit.sh):
wrangler r2 bucket create mkit-repo-objects. The RefStore Durable Object and itsv1sqlite migration are created automatically on first deploy. - Deploy the Worker:
wrangler deploy(needs an authenticated wrangler/CLOUDFLARE_API_TOKEN), or wire a Cloudflare Workers Builds project atapps/repo-workerso it deploys on merge — same mechanism as the web app. - Pin the origin: the
api.mkit.shcustom_domainroute is declared inwrangler.jsoncand is the production backend origin. This host must match:apps/web/src/security-policy.js→ CSPconnect-src(already allowshttps://api.mkit.shandwss://api.mkit.sh), and- the web build's
VITE_REPO_BACKEND_URLenv (set it tohttps://api.mkit.shin the Workers Builds env; when unset the demo falls back to the in-memory mock so it still runs offline).
- 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.
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:
- Provision your own bucket. Edit
r2_buckets[0].bucket_nameinwrangler.jsonc(currentlymkit-repo-objects) to a globally-unique name of your own, then create it:wrangler r2 bucket create <your-bucket-name>. Reusing the literalmkit-repo-objectsname will collide with the maintainers' bucket if you ever deploy to the same account. - Point the route at your own domain, or drop it. Edit
routes[0].patterninwrangler.jsonc(currentlyapi.mkit.sh) to a hostname on a Cloudflare zone you control, or delete theroutesarray entirely to deploy to the default<name>.<subdomain>.workers.devorigin instead — no custom domain required. - Deploy:
wrangler deploy(needs an authenticated wrangler/CLOUDFLARE_API_TOKEN). TheRefStoreDurable Object and itsv1SQLite migration (wrangler.jsonc:65) are created automatically on first deploy, same as the maintainers' instance. - 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 bothhttps://andwss://forms, to theconnect-srcdirective inapps/web/src/security-policy.js(thewss://form covers the/watch/<room>WebSocket — see "WatchRefs / streaming" above), and set the web build'sVITE_REPO_BACKEND_URLto your Worker'shttps://origin (unset, the web app falls back to an in-memory mock backend).
- Durable Objects with the SQLite storage backend — what
RefStoreuses (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
UpdateRefcosts one DO request plus at least one row write, so a busy shared room can exhaust the daily write cap. - R2 (the
STORAGEbinding, holding everyPutObject'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 theroutesblock and using the defaultworkers.devsubdomain (step 2 above) avoids the zone requirement altogether.
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 stagingUse 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.