File uploads
S3-compatible storage with presigned uploads and per-plan limits.
File uploads store user content in an S3-compatible bucket. The Next server never proxies bytes: it issues short-lived presigned URLs, the browser PUTs straight to the bucket, and a download route redirects to a presigned GET. Every file is org-scoped, capped by a per-purpose size/type policy, and metered against an optional per-plan storage quota.
Optional feature
Files is optional (core: false, requires: ["orgs"]). Files are org-owned;
the storage quota and the deletion audit are soft seams that light up only when
flags and audit-log are present.
What it does
- Presigned upload flow — the
filesrouter (createFilesRouter) onorgProcedureexposescreateUpload(validate, quota-check, write apendingrow, return{ fileId, uploadUrl }),confirmUpload(pending→ready),delete(soft-delete →status = "deleted"), andlist. The client PUTs viaputObject(lib/put-object.ts,XMLHttpRequestfor progress). - Storage primitives —
server/storage.tsis the only importer of the AWS SDK:presignUpload/presignDownload/deleteObject, both TTLs 300s,forcePathStyle: truefor MinIO, withcontent-typebound into the SigV4 signature so the stored type can't be swapped. - Per-purpose constraints —
lib/config.tsdefinesFilePurpose(avatar|logo|attachment): avatars and logos are 2 MiB raster images (SVG excluded to avoid stored-XSS); attachments are 25 MiB (images + common docs). These caps are fixed product policy, not entitlements. - Download route —
GET /api/files/:idauthorizes via session membership in the file's org, then 302-redirects to a presigned GET. Missing / non-ready/ other-org files all collapse to 404 (no existence oracle); attachments get a sanitizedContent-Disposition: attachment. - Reaper —
reapFiles(db, deleteObj, asOf)reclaims orphanedpendingrows (older than 24h) and soft-deletedrows, object first then row. It runs from a durable Inngest cron and, when jobs is pruned, from an opportunistic on-write sweep insidecreateUpload. - UI components —
FileUpload,AvatarPicker,LogoPicker, andAttachmentsManager;/settings/attachmentsis the worked attachment example.
Models it adds
| Model | Purpose |
|---|---|
File | An org-scoped uploaded file: key (unique, {orgId}/{purpose}/{uuid}), contentType, size, purpose, and a status that walks pending → ready → deleted. uploadedById is a plain audit column, not a User relation, so deleting a user never cascades files away. |
There is no separate Attachment model — "attachment" is a purpose value on
File. Adds the marker-wrapped Organization.files relation.
Env it needs
S3_ENDPOINT= # S3-compatible endpoint (MinIO in dev)
S3_REGION=
S3_BUCKET=
S3_ACCESS_KEY_ID=
S3_SECRET_ACCESS_KEY=All are .optional() in env.ts (D-043), so a generated app builds with uploads
dormant; getStorage() throws loudly listing any missing var when a route
actually needs storage. Adds the minio compose service and the two @aws-sdk/*
dependencies.
Composition
requires: ["orgs"]— every procedure runs onorgProcedurescoped to the active org,File.organizationIdcascades fromOrganization, and object keys are org-partitioned.- Soft consumer seams (injected, files never imports them): flags supplies
the
storageBytesquota check (omitted ⇒ unlimited); audit-log recordsfile.deleted; jobs runs the reaper cron (the on-write sweep is the jobs-pruned fallback). - Seams files exposes —
AvatarPicker(consumed by the profile form behind@feature:filesmarkers, saving through coreuser.updateAvatar) andLogoPicker(consumed by org settings, saving through coreorg.updateLogo).User.imageandOrganization.logoare core fields; only the upload path prunes, falling back to a plain URL/initials display. - Dependents — none; no feature requires files.
What pruning removes
Leaving files out drops features/files, the /api/files/:id route, the
/settings/attachments page, the File model and the Organization.files
relation, and the createFilesRouter wiring — plus the files-reaper cron, the
five S3_* env vars, the minio compose service, and the two AWS SDK
dependencies. No feature requires files, so nothing else is forced out; the
avatar and logo pickers fall back to plain image/URL fields at their marked call
sites.