Both read the same
R2_* variables and the same R2_PATH_PREFIX.
createR2Storage
AppContext. Each router that needs storage builds its own from the env: clients, crm, content, exercises, uploads, onboarding, autofit-import, automation-api and the signing lane (signingStorageFromEnv).
Keys and URLs
Every method takes a key relative toR2_PATH_PREFIX. joinKey(prefix, key) adds the prefix, and publicUrlOf(publicBaseUrl, fullKey) builds the public URL. All write paths use the same two helpers, so a multipart upload and a plain put of the same key return the same URL.
Three ways to upload
1. JSON with base64 (small files)
The client sends the file as a base64 string inside a JSON body. The service decodes it and callsputObject. Used for images and for POST /v1/web/content/media.
Limits come from the body parser: 24mb for JSON in general, 48mb on the two AI analyze routes. Base64 inflates a file by about a third, so the practical file size is lower than the parser limit.
2. Presigned PUT
createUploadUrl returns a URL the browser can PUT to directly. POST /v1/web/content/media-url exposes it. The comment in r2.ts gives the reason: a large file sent as a data URL is held in several copies at once and walks the API into its memory ceiling. The 600 second lifetime is long enough for a slow phone and short enough that a leaked URL is not a standing write grant.
3. Relayed multipart (large video)
The browser cannot always reach R2 directly, because the shared bucket’s CORS policy is not under this project’s control. The multipart lane sends the file through the API in slices without either Node process holding the whole file.
A part is sent as
Content-Type: application/octet-stream. The route has its own express.raw parser with a 12mb limit, and MAX_MEDIA_PART_BYTES is 12 MiB. The service signature covers the raw bytes of the part.
The exercise library uploads its videos through these same routes with a different key prefix. There is one multipart relay in the codebase.
Size caps in content.service.ts: MAX_MEDIA_BYTES is 99,000,000 bytes, and a plan PDF is capped at 30 MiB (MAX_PLAN_PDF_BYTES).
Trainee uploads
POST /v1/trainee/uploads (modules/uploads/) is the trainee app’s upload route.
- The guard is
authenticateTrainee. There is norequireTraineeAppAccesson this router. - The body is the raw file. The router adds
express.raw({ type: () => true, limit: '15mb' }). x-file-typedefaults toapplication/octet-streamandx-file-nametoupload.- An empty body is
BAD_REQUEST,no file provided. - The file is stored under
media/<uuid>.<ext>and the response is{ fileUrl }.
jpg, png, webp, gif, mp4, mov), otherwise bin.
The global parsers run before the router’s own. A request sent as
application/octet-stream is parsed by the global express.raw with its 12mb limit, and one sent as application/json by the JSON parser. The router’s 15mb parser only applies to other content types, such as image/jpeg.HEIC conversion
iPhones save photos as HEIC, and only Safari displays HEIC. A trainee’s photo looked fine on their phone and arrived broken on the coach’s screen.storage/heic.ts converts on the way in.
isHeic(file)reads the file’s ownftypbox, not its name. The app names photos.jpgwhatever their real format. AVIF uses the same container and is left alone, because every browser displays it.heicToJpeg(file)decodes in a worker thread. The decoder is WebAssembly and synchronous. A 24 megapixel photo holds a thread for most of a second and about 100 MB of pixels. One conversion runs at a time, each in a fresh worker that is terminated afterwards, because WebAssembly memory never shrinks while its thread lives.- Output is JPEG at quality 80, with the longest edge at most 1600 px (
HEIC_JPEG_MAX_EDGE), the same size the trainee app shrinks photos to. - Files that declare more than 64 megapixels (
HEIC_MAX_PIXELS) are stored as uploaded. Decoding allocates for the declared size, so a small file can claim a huge image. - Conversion times out after 60 seconds.
storage/heic-repair.ts and scripts/convert-heic-media.ts repair photos stored before conversion existed. The JPEG is written beside the original under the same id with a different extension, so a reference is repointed by swapping the extension. Originals are never overwritten, because a CDN or a browser may still hold them under the old URL.
Signed documents
The public signing lane stores source PDFs, signature images and the final signed PDF throughsigningStorageFromEnv(ctx.env). Stored names are fixed length random hex, so deletePrefix(key) removes exactly that one object. Documents are returned to the signer through the lane’s own routes (GET /v1/public/sign/:token/document and /signed), not by handing out the public URL.
AutoFit import snapshots
The import worker writes the raw data it pulls to R2 so a later stage can resume. Those snapshots are sealed with AES-GCM before the write, because the bucket serves its keys publicly, and the whole snapshot prefix is deleted when the run reachesDONE. Mirrored media lives under its own prefix because client rows reference it and it must outlive the run.
@repo/storage
The avatar package is a thin S3 client used by the oRPC procedures that hand upload URLs to the web app (users.avatarUploadUrl, organizations.createLogoUploadUrl, createBrandLogoUploadUrl, createSelfieExampleUploadUrl).
bucketis a logical name. The only one isavatars, mapped toNEXT_PUBLIC_AVATARS_BUCKET_NAME, elseR2_BUCKET_NAME, else the literalavatars.- Keys are
avatars/<path>underR2_PATH_PREFIX(joinStorageKey). - Each setting falls back from
R2_*toS3_*:R2_ENDPOINTorS3_ENDPOINT, and so on. The client is created lazily and throws a named error when a variable is missing. - The S3 client uses
forcePathStyle: true.
Choosing a path for new work
Whatever the path, validate the size before reading the whole body where you can, set the content type explicitly, and generate the key on the server. Never build a key from a client supplied name beyond a checked extension.