SsuperslateDocs
Build with the boilerplate

Add an upload type

Add a secure provider-neutral upload purpose with ownership, limits, and confirmation.

Add an upload type

When to use

Use this path when a product needs a new, explicitly owned object-storage purpose. The launch foundation currently supports one narrow flow: an authenticated user uploads their own public profile image, up to 5 MiB, through a 15-minute presigned PUT.

Do not generalize this into arbitrary files. Documents, executables, private downloads, shared workspace files, and user-generated public media have different authorization, malware, content validation, retention, and delivery requirements.

Files and boundaries

  • packages/contracts/src/upload.ts: type, request schemas, MIME types, size, extension, expiry.
  • apps/server/src/domains/upload/routes.ts: session, billing gate, rate limit, validation.
  • apps/server/src/domains/upload/service.ts: authorization, keys, claim, byte/metadata validation, and publication policy.
  • apps/server/src/domains/upload/repository.ts: owner-scoped persistence and atomic state changes.
  • apps/server/src/infra/storage/contract.ts: provider-neutral storage contract.
  • apps/server/src/infra/storage/: R2, S3, and GCS provider mechanics.
  • apps/server/src/config/env.ts: selected-provider configuration.
  • apps/web/src/components/ui/file-upload/use-file-upload.ts: signed PUT, progress, confirmation.
  • apps/server/migrations/: schema changes after the first commercial tag.

Contracts define wire input, the domain defines authorization and policy, and each storage adapter defines provider mechanics. Never authorize from an object-key prefix alone when a database owner record is available.

Procedure

  1. Write the purpose, owner, visibility, maximum size, accepted media types, retention, overwrite behavior, abuse controls, and post-upload validation before adding an enum.
  2. Add the type and its maximum size, MIME allowlist, and extension mapping to the shared contract. Keep claim input limited to purpose, owner/entity ID, MIME type, declared byte size, and optional display filename.
  3. Add target authorization to assert_upload_target_access. Resolve ownership from the session and database state, never from client claims.
  4. Add a unique public key shape to generate_file_path and its inverse to parse_file_path. Staged keys stay under pending/; public keys keep an immutable random component and never receive a presigned write URL.
  5. Persist the pending claim before returning it: owner, purpose, entity, private staging key, intended public key, declared size, signed MIME type, expiry, and pending status. Never return either key from the claim endpoint.
  6. Upload the raw file to the private staging bucket with PUT, exact signed Content-Type, and the browser-derived Content-Length. The included path uses direct presigned PUT, not browser multipart POST.
  7. Confirm with only the server-issued upload_id. Load an owner-scoped, unexpired pending row, read actual object metadata, compare size and MIME type, structurally validate the complete provider-version-pinned object, then publish that same validated version to the public bucket before atomically transitioning pending → completed.
  8. On mismatch, mark the claim failed and best-effort delete the staged object. Keep the mandatory one-day pending/ lifecycle rule on the private bucket; claim creation refuses to sign when the rule cannot be verified.
  9. Add web UI validation for feedback, but keep all security validation on the server.
  10. Configure bucket CORS for the exact web origin, PUT, and Content-Type.
  11. Add database-backed tests for owner, cross-owner, expiry, missing object, size mismatch, MIME header mismatch, MIME spoofing, version-bound publication, replay, concurrency, and cleanup.

Verification

Run:

vp run --filter @app/contracts test
bun test --cwd apps/server src/domains/upload/service.test.ts
RUN_DB_INTEGRATION_TESTS=1 DATABASE_URL=postgresql://... \
  bun test --cwd apps/server src/domains/upload/service.integration.test.ts
vp check
vp run --filter @app/server typecheck
vp run --filter @app/web typecheck

Against disposable private and public buckets for each supported provider, prove a correct upload, wrong Content-Type, wrong byte length, expired URL, over-limit declaration, spoofed content, provider-version race, replay, cross-owner confirmation, staging cleanup, and delete. Verify a public URL only for types deliberately classified as public.

Security constraints

  • Presigned URLs are bearer tokens and can be reused until expiry. Keep expiry short, keys unique, logs redacted, and HTTPS mandatory.
  • The presigner binds exact Content-Length and Content-Type; confirmation independently checks stored provider metadata. The web client sets only Content-Type because browsers own the forbidden Content-Length header and derive it from the File.
  • PNG chunks/CRCs, JPEG markers/scans, and WebP RIFF/chunks are structurally validated with bounded dimensions and no trailing payload before publication. This is not full pixel decompression, EXIF removal, moderation, or malware scanning. Add normalization or scanning before supporting documents or broadly public user content.
  • Publication uses an ETag-conditional S3/R2 copy or a generation-pinned GCS copy. Do not replace it with a path that can publish a different version than the one validated.
  • Keep owner predicates in SQL transitions so concurrent or cross-owner requests cannot consume another claim.
  • Partial selected-provider configuration is invalid. Disabled storage is valid locally and upload endpoints return a stable 503 without a provider call.

References: R2 presigned URLs, S3 presigned uploads, and GCS V4 signed URLs.

Failure modes

  • UNAVAILABLE: storage is disabled, selected-provider configuration is incomplete, or the staging lifecycle rule is missing/inaccessible.
  • Provider signature mismatch: request method, browser byte length, or Content-Type differs from the signed claim.
  • Browser CORS failure: the bucket origin, method, or allowed header is incomplete.
  • Object not found: the PUT failed or the wrong bucket/key was used; start a new claim.
  • Size or MIME mismatch: the object is failed and deleted; never trust browser confirmation fields.
  • Confirmation replay: the owner-scoped pending update affects zero rows and returns a stable error.
  • Copy precondition failure: the staged ETag or GCS generation changed after validation; start a new claim.
  • Orphan after failed immediate cleanup: inspect failed claims and the mandatory lifecycle rule; retry deletion without changing the claim to completed.

Rollback and diagnosis

Disable the new type at the contract and authorization boundary first so no new URLs are signed. Preserve existing rows and keys until their ownership and product references are known. Remove UI entry points, then delete objects through an owner-scoped maintenance operation.

Record upload ID, purpose, status, owner ID, key, declared/actual metadata, expiry, request ID, and provider error category. Never record credentials or a still-live presigned URL.

Before the first commercial tag, a baseline correction is allowed with a fresh migration up → down → up proof. After that tag, add a reversible timestamped migration; never edit the shipped baseline.

Acceptance criteria

  • The type has an explicit purpose, owner, visibility, limits, retention, and abuse policy.
  • Claim creation authorizes before signing and rejects invalid size/MIME input.
  • Staging and public keys are separate and unique; a reusable staged URL cannot overwrite a published object.
  • Confirmation accepts only upload_id and verifies owner, pending state, expiry, actual size, MIME metadata, and complete image structure before copying the validated provider version.
  • Cross-owner, expired, mismatched, spoofed, replayed, and concurrent confirmations are proven.
  • Rejected objects are failed and best-effort deleted; claim creation verifies automatic abandoned object expiration before signing.
  • Zero-key local development and all-or-none production configuration are proven.
  • The focused tests, vp check, server typecheck, and web typecheck pass.

Agent prohibitions

  • Do not accept file_path, completion status, or actual file size from the browser at confirmation.
  • Do not use multipart POST with the included presigned PUT flow.
  • Do not use a stable overwrite key for mutable public content.
  • Do not presign a public destination key or return a private staging key to the browser.
  • Do not authorize solely by parsing a client-supplied path.
  • Do not claim structural image validation is pixel decoding or malware scanning.
  • Do not add arbitrary document uploads without a processing and security contract.
  • Do not require storage credentials for basic local setup.

On this page