Private attachment storage
Respondent attachments must never share the public branding bucket. Presigned PUT URLs reveal their object key, and Cloudflare R2 public access is bucket-wide; putting attachments in the branding bucket would bypass the API's signed GET authorization.
Required topology
R2_*remains the public branding bucket and may useR2_PUBLIC_URL.R2_PRIVATE_*is a separate bucket with a restricted credential used only for respondent attachment PUT/HEAD/GET/DELETE operations.FILE_UPLOAD_MAX_BYTESis the global per-object ceiling (at most 100 MiB), while each form field may declare a smallermaxSize.FILE_UPLOAD_RESERVATION_TTL_SECONDScontrols abandoned pending capability expiry and must remain between 60 and 3600 seconds.- The private bucket must have its managed
r2.devdomain disabled and no enabled custom domains. Cloudflare documents these as independent public access paths: https://developers.cloudflare.com/r2/buckets/public-buckets/. - Browser CORS must allow signed
GETdownloads andPUTuploads from explicit production public-form origins (never*). Uploads explicitly require bothContent-TypeandIf-None-Match; Cloudflare requires every browser-sent header to appear inAllowedHeaders: https://developers.cloudflare.com/r2/buckets/cors/. - Upload URLs sign
If-None-Match: *, so a replay cannot replace an object after preflight validation. Cloudflare lists conditionalPutObjectandIf-None-Matchas supported by the R2 S3 API: https://developers.cloudflare.com/r2/api/s3/api/.
Example private-bucket CORS policy:
[
{
"AllowedOrigins": ["https://forms.example.com"],
"AllowedMethods": ["GET", "PUT"],
"AllowedHeaders": ["Content-Type", "If-None-Match"],
"ExposeHeaders": ["ETag"],
"MaxAgeSeconds": 3600
}
]Release verification
The static bun run release:preflight gate requires a distinct private bucket, the public-access-disabled attestation, and the exact browser headers. Before enabling attachment uploads, run the provider-backed gate with a short-lived Cloudflare token carrying R2 read/write configuration access:
CLOUDFLARE_ACCOUNT_ID=... \
CLOUDFLARE_R2_VERIFY_TOKEN=... \
bun run storage:verify-privateThe command queries Cloudflare's managed-domain, custom-domain, and CORS APIs, uploads a random private probe, proves unsigned GET cannot retrieve it through the S3 endpoint or any known bucket domain, and deletes the probe. It fails on cleanup failure. Cloudflare's authoritative endpoints are documented under the R2 bucket domain API and bucket CORS API.
Record the successful command output in release evidence. Never store the Cloudflare verification token in the API runtime environment.
Retention
Expired pending uploads are reclaimed in a bounded batch before new quota reservations, and the hourly storage:sweep command owns retries. The sweeper routes branding/ keys to the public bucket and attachments/ keys to the private bucket. Provider deletion must succeed before the database inventory row is acknowledged and removed.
