-
Notifications
You must be signed in to change notification settings - Fork 0
Storage And Uploads
Call sites name a disk, never a driver. Package @ultimat3/storage (tier 1) — the full
reference is
packages/storage/README.md,
and the upload design is
docs/architecture/17-uploads.md.
import { defineStorage, disk, localDriver, s3Driver, scopedKey } from '@ultimat3/storage';
defineStorage({
disks: {
uploads: localDriver({ root: '.storage/uploads' }),
media: s3Driver({ bucket: 'media', endpoint: process.env.S3_ENDPOINT, forcePathStyle: true }),
},
default: 'uploads',
});
await disk('media').put(scopedKey(orgId, 'avatars', 'a.png'), bytes, { contentType: 'image/png' });Swapping local for s3 changes no call site, and x dev needs no MinIO.
| Driver | Backing | For |
|---|---|---|
localDriver |
Bun.file / Bun.write under one root |
dev, tests, a single node |
s3Driver |
Bun.s3 |
MinIO, R2, AWS — the difference is endpoint + forcePathStyle. Credentials are env var names, never literals |
assertSafeKey() runs on every key: .., absolute keys, backslashes, control bytes, encoded
separators, empty segments and over-long keys are X_STORAGE_PATH_UNSAFE. scopedKey(org, …) is
org/<org>/…; a surface guards a client key with isWithinOrg(key, ctx.actor.orgId) and
isTenantScoped(key) (X_STORAGE_ORG_MISMATCH).
User uploads never go through put() (which buffers, and stops at maxPutBytes, 10 MB by default).
They go straight to the disk, in three calls — and the client never names the key:
| Hop | Call | What it guarantees |
|---|---|---|
| 1. server, in an action | grantUpload({ disk, orgId: ctx.actor.orgId, request, policy }) |
a signed grant for a key the server chose, under the actor's org |
| 2. browser | uploadFile({ file, grant, onProgress }) |
a PUT with real progress; storage/upload-client.ts is the one XHR seam in the browser |
3. server, at PUT /_storage/:disk/*key
|
mounted by the framework in x dev and runRole, around acceptSignedUpload. Answers 201 { key }
|
refuses a bad or expired signature, a key outside the actor's org, more bytes or another type than was granted, or magic bytes that contradict the type |
The mounted PUT (As of 2026-09-24, #523) requires a signed-in actor, like the GET. It
needs no permission: the grant is the authorization, and the action that minted it ran its own
policy. The route checks the signature and the expiry, and it checks that the key is inside the
actor's org. It validates the upload against what the grant signed (the content type and
maxBytes), so a PDF granted under uploadPolicy({ allowedContentTypes: ['application/pdf'] })
is accepted, and it does not use uploadPolicy()'s image-only default. The signed maxBytes caps
how much of the body is read. bodyLimitBytes does not, and neither does an unverified query value.
The signature covers the constraints (method, key, expiry, maxBytes, content type), so editing
?x-max= invalidates it. Uploads are sniffed: validateUpload() reads magic bytes and refuses a
.png that is really HTML (X_STORAGE_TYPE_REJECTED).
The local disk signs with STORAGE_SIGNING_SECRET. The shipped development secret is accepted
only in development or test; a process that names no environment is treated as production
and refused at construction (X_ENV_MISSING).
As of 2026-09-24 (#524): the app's own. Both routes read the process's one registry per
request. That registry is the last defineStorage() call, which is the app's when an app module
declares its disks. The boot's env-selected disk (object on S3_ENDPOINT, else local) is
served only when nothing declared one. A defineStorage at module scope in any file under
apps/*/ is enough:
// apps/web/shared/storage.ts
export const storage = defineStorage({
disks: { uploads: localDriver({ root: '.storage/uploads' }), evidence: s3Driver({ bucket }) },
default: 'uploads',
});storage:read |
the GET requires it. When an app declares its own disks, x verify's policy step reports X_PERMISSION_UNKNOWN if the app's permission set lacks storage:read. Before this, the first signed URL returned a 500
|
| the boot's own disk | still built. In production with no S3_ENDPOINT, that is a local disk and still needs STORAGE_SIGNING_SECRET. To skip it, export runtime = { storage } from apps/<app>/runtime.ts. runRole reads that file, and it replaces the env-selected disk entirely |
definedStorage() |
the registry or undefined, without a throw. It is the question the routes ask |
An upload lands under org/<org>/pending/… before its row exists, and is promoted once it has one:
| Call | Does |
|---|---|
promoteAttachment({ disk, key, orgId, target }) |
copy then delete, onto org/<org>/<entity>/<id>/<field>/…. Only a pending key: another row's attached key is X_STORAGE_NOT_PENDING
|
grantUpload({ …, quarantine: true }) → releaseQuarantine({ disk, key, orgId })
|
the place for your scanner: a quarantined key cannot be promoted (X_STORAGE_QUARANTINED) until your scan job releases it. The scanner is your app's (axiom 8) |
sweepOrphans({ disk, orgId, olderThanMs }) |
deletes stale pending/ keys of one org and answers { deleted, failed } — a refused delete is reported, never counted as done |
transformImage() and blurPlaceholder() run on core's pipeline (Bun.Image, no sharp) and
encode png, jpeg and webp; avif is key and srcset math only. variantKey(),
srcsetDescriptors() and fitDimensions() are pure, which is how SEO builds srcset
without decoding a byte.
Every X_STORAGE_* code is in Error codes. Encryption at rest, lifecycle rules and
storage classes are bucket configuration, not the framework's (axiom 7).
Ultimate — v22.1.0 As of 2026-09. Stable API, semver from here. MIT licensed. What npm serves is npm view @ultimat3/core version, never this line.
This footer is the only page that stamps a version. It renders under every wiki page, so one release bumps one line; a stamp on a second page is 46 hand-copies of one fact, and every one of them goes stale on the next tag.
Repository · Issues · Changelog · llms.txt
Edits to these pages are synced from wiki/ in the repository — change the file there, not the wiki, or the next sync overwrites it.
Start
Tutorials
- 1 · First app
- 2 · First feature
- 3 · Auth and admin
- 4 · Jobs and realtime
- 5 · Deploy free
- 6 · Growing up
Primitives
- The eight primitives
- Building your own base
- Actions
- Entities and migrations
- Policies and authz
- Queries and live queries
- Client data
- Jobs and workflows
- Scheduled tasks
- Routes and render modes
Capabilities
- Realtime
- Caching and invalidation
- Batching and preloading
- N+1 detection
- PWA and offline
- MCP and AI
- Agents
- Admin dashboard
- Scraping
- Auth
- Notify
- Storage and uploads
- Feature flags
- SEO
Cross-cutting
- I18n
- Theming
- UI components
- Interface rules
- Timezones and dates
- Money
- Resource management
- Migrations and backfills
- Testing
Reference