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 it | Where it lives | |
|---|---|---|
path | Your client — invoices/2026/q3.pdf | D1. Indexed, listable, searchable |
key | The 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
| Status | Means |
|---|---|
pending | The row and its quota are reserved. No bytes yet |
stored | R2 confirmed the bytes |
failed | Abandoned |
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.