Guidance for agentic coding tools working in this repository. The README is the user-facing documentation; this file is about how the code is built and what must not break.
Cumulus is an ATProto blob proxy on Cloudflare Workers using Workers
Cache (cache.enabled, GA July 2026) as its only caching layer. All eight
phases of the original spec are implemented and deployed. The spec and the
per-phase plans with measured results live in .claude/docs/ — consult
.claude/docs/SPEC.md for design rationale and .claude/docs/plans/ for
what was verified on the platform and what was found along the way.
Workers Cache, the Images binding, the cf CLI and the experimental
cloudflare.config.ts format are all newer than most training data. Read
the current docs rather than writing from memory:
- https://developers.cloudflare.com/workers/cache/ (+
configuration/,cache-keys/,purge/,limitations/) - https://developers.cloudflare.com/images/transform-images/bindings/
This project does not use caches.default / the CacheStorage API
anywhere. caches.default.put(...) is the old colo-local API; its presence
is a bug.
- Every response sets explicit
Cache-Control— errors, redirects, health checks, admin. Heuristic caching silently caches bare responses. Use theCACHE_CONTROLconstants insrc/response.ts. - Never return
206. The platform slices ranges from stored 200s; the 200 must carryAccept-Ranges: bytesor slicing does not happen. - Verify before serving. Blob bytes are buffered and SHA-256-checked against the CID before any byte reaches the client or cache.
- No query-string routes. Path-only; param order fragments cache keys.
- Tag everything cacheable — including 403s and 404s — with
did:{did}/cid:{cid}(lowercased) plusv:{versionId}; scoped responses addrec:{did}/{collection}/{rkey}; 413/415 addcfg:{hash}. Untagged deny responses cannot be purged. - Purges fan out per entrypoint via the
purgeTags/purgeEverythingRPC methods onIdentity,PolicyandRecord. A purge fromdefaultdoes not touch another entrypoint's cache. Everypurge()call counts against the (Free-tier) purge rate limit separately. - Content-Type comes from magic-byte sniffing (
src/sniff.ts), never from the PDS.image/svg+xmlstays off the allowlist. - No Durable Objects, Workflows or Queues. Drains are cron + KV cursor; overlapping drains are idempotent. Outbound websockets live only for the duration of a drain.
- Derived responses (
/metadata,/img/) obtain the original viactx.exports.default.fetch()loopback, never by fetching the PDS themselves. Presets are the fixed Bluesky four; no free-form parameters. - Scoped-mode admission is a forward
getRecordmembership check through theRecordentrypoint; never build a reverse index of blob references. Open and scoped routes never coexist in one deployment. - Settings are text bindings in
cloudflare.config.ts— that file is the user-facing configuration.loadConfig(env)insrc/config.tsparses them, withCONFIG_DEFAULTSas the fallback for environments that omit a binding (tests). Adding a setting means touching both plus the README table. Nothing readsprocess.envat build time.
src/index.ts router for the default entrypoint; exports Identity, Policy, Record; scheduled()
src/entrypoints/ Identity (DID → PDS / labeler endpoint), Policy (verdicts), Record (getRecord + blob refs)
src/blob.ts PDS fetch, buffering with the size cap, sha256
src/cid.ts base32 + CIDv1/raw/sha2-256 decode; no multiformats dependency
src/sniff.ts dimensions.ts magic bytes and header-only dimension parsing for the five formats
src/path.ts scoped.ts img.ts path parsing and canonicalisation; aliases 301 to one canonical URL
src/response.ts header contract, tag helpers, purge helpers (ctx.cache may be absent locally)
src/admin.ts Basic-auth admin routes
src/labels.ts drain.ts labeler config, queryLabels client, subscribeLabels drain (DAG-CBOR via src/cbor.ts)
src/jetstream.ts socket.ts Jetstream drain; shared outbound-websocket reader
src/store.ts the only KV schema: cursors, drain status, record-level deny set
src/config.ts CONFIG_DEFAULTS and loadConfig
cloudflare.config.ts settings (text bindings), exports (per-entrypoint cache), Images, KV, cron
test/ vitest inside workerd; test/integration/ runs on Node against the deployed Worker
pnpm dev— local dev server (real PLC/PDS, local cache semantics)pnpm test— workerd suite;pnpm test:deployed— HTTP suite against production (readsADMIN_PASSWORDfrom.env; it purges, so run it sparingly — the purge rate limit bites after a few runs)pnpm check/pnpm fix— oxfmt + oxlint + typespnpm run deploy—cf deploy(plainpnpm deployis a pnpm builtin).pnpm admin:password— generate the admin secret, set it, save it to.env.
- Tests import
env/exportsfromcloudflare:workers;exports.default.fetch()drives the router andctx.exportsloopbacks work in workerd. @cloudflare/vitest-plugin1.0 has nofetchMock; stub outbound calls withvi.spyOn(globalThis, "fetch")andstubFetch()fromtest/helpers.ts(routes by hostname, throws on anything unstubbed).- Pass
redirect: "manual"when asserting 301s — the loopback follows redirects by default. ctx.cacheis absent in miniflare; purge helpers return asuccess: falseresult there, so purge tests assert shape and fan-out, and real purges are covered bytest/integration/.- Websocket servers in tests:
new WebSocketPair(),server.accept(), send, returnnew Response(null, { status: 101, webSocket: client }). Binary frames arrive asBlob. - The Images binding is remote-only; tests install a fake on
env.IMAGES. vi.spyOncannot replace ES-module exports across workerd module boundaries; inject behaviour (e.g. the drain'spurgeoption) instead.
- Cache key = entrypoint + path + query; GET/HEAD share an entry; Range is
sliced from the stored 200 only with
Accept-Ranges: bytes. cloudflare-cdn-cache-controlis honoured and stripped;cache-tagis stripped;no-storeshows asBYPASS.crossVersionCache: truekeeps entries across deploys; purgev:{id}after a header-changing deploy. Entries also survive aMODEswitch — purge all afterwards.- Native SHA-256 bills ≈1.2 ms CPU per MB; hence the 3 MB Free-plan default.
bindings.kv()without an id auto-provisions on deploy; declared secrets must exist beforecf deployaccepts the upload.- Workers cannot
fetch("wss://…");src/socket.tsrewrites to https and sends the upgrade header. anim: trueis only valid for webp output on the Images binding.
cloudflare.config.tsuses the experimental config format viadefineWorkerfrom@cloudflare/vite-plugin/experimental-config(not@cloudflare/config— different unique symbols break type inference).Envis inferred from it; there is no generated vars list to regenerate.- The entrypoint import uses
with { type: "cf-worker" }; keep that form. - The compatibility date appears in
cloudflare.config.tsandvite.config.ts; change both.nodejs_compatis implied by dates ≥ 2026-08-04; do not add it back. vitemust stay aliased to@voidzero-dev/vite-plus-coreand thevitestpin must match vite-plus (pnpm exec vp toolchain).- Formatting uses tabs;
.claude/docs/**is excluded from formatting.
- Label signature verification (labels are trusted over transport).
- A streaming mode for blobs larger than the isolate can buffer.
- Multi-region PDS failover.