Backend (Convex)
Storage (R2)
Upload sessions, signed URLs, and size gates.
Media storage is Cloudflare R2 via @convex-dev/r2, wrapped by convex/storage/*.
Upload flow
generateUploadUrl(public mutation) — auth required.categoryis required and triggers staff-only gating for managed categories, MIME validation, and size checks; a signed PUT is minted and anr2UploadSessionsownership row is written in the same transaction.- All arguments are optional on purpose: the backend deploys before the dashboard, and a required arg would break every running dashboard build for the deploy window. Undeclared calls get legacy behavior plus an ownership row.
- The client PUTs bytes, then calls
syncMetadata; the component HEADs the object and invokesonSyncMetadata. - The truth gate is
onSyncMetadata— a scheduled action with no auth context (ctx.authis always null). It compares the real HEAD content-type/size against the category cap and, on violation, deletes the object (unlessR2_ENFORCE_UPLOAD_LIMITS=false), clears profile references, and deletes the mint record. Any new logic here must authorize via the mint record, neverctx.auth.
Limits
| Category | Max size |
|---|---|
| avatar / avatarCatalog | 5 MB |
| review / campaign / productGroup | 10 MB |
| carousel image | 1 MB |
| carousel gif | 10 MB |
| carousel video | 250 MB |
| general | 50 MB (*/* until no undeclared uploads remain) |
| export | 100 MB |
Signed URLs and deletion
getFileUrl clamps expiresIn to 60–3600 s (default 900); the plural getFileUrls applies only the 900 s default with no clamp. deleteFile lets staff delete any managed asset; regular users only keys their profile currently references. Avatar updates authorize against the mint record.
Purge cron (daily 05:00 UTC) drops mint rows older than 7 days — after the first upload the profile row itself is the ownership proof. Mobile uploads nothing directly (avatars come from the managed avatarCatalog by slug); the dashboard is the primary uploader.