Add Storage

pithy add storage --with-prerequisites

--with-prerequisites composes secrets, which storage requires — the R2 credentials are read through the registry, so storage will not compose without it.

Add auth too, if it is not already there. Every owner-scoped route reads the authenticated user, so without it those routes deny.

What lands in your repo

apps/<worker>/pithy.config.ts gains the registration. Every option has a working default, so the block is usable as written:

storage({
  basePath: "/storage",
  quota: { bytesPerOwner: 5 * 1024 * 1024 * 1024 },
}),

The one worth setting early is the quota. Without a considered value, the first person to discover your upload endpoint decides how much R2 you buy.

apps/<worker>/wrangler.jsonc gains two bindings, in every environment stanza:

BindingTypeWhat it is
DBd1Where the object and share rows live — the same app database
STORAGE_BUCKETr2The bucket the bytes go in

The r2_buckets entry is written with the bucket name provision will create. The bucket itself does not exist yet, and that is fine: nothing reads it until something uploads.

STORAGE_SWEEP — the daily reconciliation Workflow — is not written here. It is optional, and wrangler requires both a name and a class_name on every workflows entry with the deployed name being per project and environment. An entry short of either field does not degrade; wrangler refuses to load the config at all. It arrives with provision.

What runs

add runs that Worker’s dev migrations, creating two tables:

pithy_storage_objects · pithy_storage_shares

Those two carry the only foreign key in the kit — shares reference objects — because that one relationship is internal to the capability and cannot cross a boundary.

The five config options

OptionDefaultWhat it decides
basePath/storageWhere the object routes mount
sharePath/sWhere share fetches mount. Kept short, because the whole URL gets pasted into a chat window
multipartThresholdBytes100 MiBAbove this, an upload is split into resumable parts. It cannot exceed R2’s single-PUT ceiling
partSizeBytes64 MiBBytes per part. R2 allows at most 10,000 parts, so this fixes the largest object you can store
defaultVisibilityprivateVisibility for an upload that does not name one. Private, so a forgotten field can never publish a file
pendingTtlSeconds86400How long an unfinished upload holds its quota reservation before the sweep reclaims it

Then provision

pithy storage provision --r2-access-key-id "$R2_KEY" --r2-secret-access-key "$R2_SECRET"

That creates the per-environment R2 buckets, writes the storage-r2-credentials secret, deploys the sweep worker, and writes the STORAGE_SWEEP binding into every environment stanza.

The R2 key pair is supplied, not minted. Cloudflare exposes no API for creating one, so you make it under R2 → Manage API tokens and hand it over — on the flags, or as R2_CREDENTIALS in the account config. Passing one half without the other is refused.

Each environment’s deploy needs the app DB id from that environment’s stanza and the environment’s secrets database, so pithy secrets provision has to have run first. Both are refused rather than deployed around.

pithy storage is the whole command surface, including teardown and what --storage deletes.

Check it worked

pithy doctor reports storage under the Worker’s health. Before provision, the STORAGE_BUCKET binding reads as declared with no resource behind it — which is the correct state, not a fault, until you provision.

ESC