The object model

Two identifiers, and only one of them is yours

This is what letting users upload rests on, what a share points at, and what the sweep counts. Every field is in the reference.

Who chooses itWhere it lives
pathYour client — invoices/2026/q3.pdfD1. Indexed, listable, searchable
keyThe server — obj/<uuid>R2. Opaque, and never sent to a client

No client-controlled text is ever interpolated into an R2 key. That one rule removes three classes of bug at once:

  • A ../ cannot escape a prefix, because there is no prefix to escape — the path never reaches R2.
  • Two clients cannot collide on a name, because they are not choosing the name that matters.
  • Nothing is injected into a key, because nothing goes into a key but a UUID.

It also sidesteps R2’s one-write-per-second-per-key limit by construction. Every object gets its own key, so no two writes ever contend for one — including a re-upload of the same logical path.

And the key format stays an internal detail. It can change without breaking a single one of your paths.

The id is a UUID on purpose

The object id appears in every route path, which makes it externally visible — and a sequential integer there would let anybody count how many files exist and probe for their neighbors. A UUID answers nothing.

The key is a UUIDv4 from the platform CSPRNG for the same reason plus one more: a leaked key is not a directory listing.

Three states, and the middle one is the interesting one

StatusMeans
pendingThe row and its quota are reserved. No bytes yet
storedR2 confirmed the bytes
failedAbandoned

An upload reserves before it writes, which is what makes a quota a quota rather than a suggestion. A pending row past its TTL is what the orphan sweep reclaims — the deployed Worker that provisioning stands up alongside the bucket.

Fields that are nullable for a reason

size is declared at init and confirmed at completion. Nullable, because a caller may genuinely not know it up front — but a null size reserves no quota, which is why the handler requires it whenever a quota is configured.

contentType is declared at init and overwritten at completion with what R2 actually stored. A presigned PUT cannot sign a content type, so only the completed row is authoritative. An active type is still neutralized on serve — the download response sets the headers that stop a browser executing it, whatever the row says.

checksum is null until a completion supplies one. Lowercase hex SHA-256 when it is known.

ownerId is null for system-owned objects. It comes from the auth seam, so an object created by a scheduled job legitimately has no owner.

uploadId is non-null only while a multipart upload is in flight.

Visibility is authorization, not a bucket setting

private is the owner alone. public is anyone holding the id.

The bucket itself stays private in both cases. Nothing is ever served directly out of R2 — the read goes through your Worker, which is what lets public mean this particular object rather than this whole bucket.

This is why GET /storage/:id carries no auth guard while HEAD on the same path does: a public object has to be readable without a session, so the authorization decision moves into the handler where it can see which object was asked for. A metadata probe is not a download, and does not get the same treatment.

One bucket per project, per environment

acme-staging-storage, acme-prod-storage.

A shared bucket buys nothing and risks everything. One bucket per account would mean staging writes objects into the bucket prod reads, and a staging teardown deletes prod’s files. Buckets are free — you pay for bytes and operations — so there is no saving to weigh against that.

The project segment is what makes provisioning safe. R2’s namespace is flat and account-wide, so an unprefixed pithy-storage-prod would be found by a second Pithy project in the same account and silently adopted: two apps writing into one bucket, and either teardown deleting both. The name is the only partition R2 offers, so the name carries the owner.

The store is mechanism; the key policy is not

ObjectStore takes an explicit key and moves bytes. It knows nothing about how the key was chosen.

Storage derives obj/<uuid>. Media passes media/<type>/<id>. Neither package knows the other’s scheme, which is exactly what lets media import the seam without inheriting storage’s opaque-key decision.

R2’s hard key limit is 1,024 bytes — measured in bytes, not characters, because a key may be UTF-8. A derived key is 40 bytes, so that check only ever matters for a key passed straight through.

ESC