Skip to content

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 use R2_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_BYTES is the global per-object ceiling (at most 100 MiB), while each form field may declare a smaller maxSize.
  • FILE_UPLOAD_RESERVATION_TTL_SECONDS controls abandoned pending capability expiry and must remain between 60 and 3600 seconds.
  • The private bucket must have its managed r2.dev domain 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 GET downloads and PUT uploads from explicit production public-form origins (never *). Uploads explicitly require both Content-Type and If-None-Match; Cloudflare requires every browser-sent header to appear in AllowedHeaders: 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 conditional PutObject and If-None-Match as supported by the R2 S3 API: https://developers.cloudflare.com/r2/api/s3/api/.

Example private-bucket CORS policy:

json
[
  {
    "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:

bash
CLOUDFLARE_ACCOUNT_ID=... \
CLOUDFLARE_R2_VERIFY_TOKEN=... \
bun run storage:verify-private

The 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.

KinoForms documentation