Object storage
Loomup stores object metadata in each project's SQLite database and object bytes in either Cloudflare R2 or a local filesystem. Hosted Loomup uses R2 with an isolated projects/<project-id>/ key prefix. Access is enforced with the same rule expression engine as table CRUD (auth.uid(), owner_id = auth.uid(), etc.).
Storage is off by default. Enable it and declare buckets in loomup.toml.
Config
[storage]
enabled = true
backend = "local"
root = "./data/storage" # resolved relative to the config file directory
max_upload_bytes = 52428800 # 50 MiB per object
max_total_bytes = 104857600 # 100 MiB total free-tier; set 0 for unlimited
# optional MIME allow-list (empty = any):
# allowed_mime_prefixes = ["image/", "application/pdf"]
[storage.buckets.avatars]
public = false
[storage.buckets.avatars.rules]
read = "owner_id = auth.uid()"
create = "auth.uid() != null"
update = "owner_id = auth.uid()"
delete = "owner_id = auth.uid()"
[storage.buckets.public]
public = true
[storage.buckets.public.rules]
read = "true"
create = "auth.uid() != null"
update = "auth.uid() != null"
delete = "auth.uid() != null"
For Cloudflare R2:
[storage]
enabled = true
backend = "r2"
[storage.r2]
endpoint = "https://<account-id>.r2.cloudflarestorage.com"
bucket = "loomup-production"
prefix = "projects/<project-id>"
access_key_id_env = "LOOMUP_R2_ACCESS_KEY_ID"
secret_access_key_env = "LOOMUP_R2_SECRET_ACCESS_KEY"
Hosted production can set LOOMUP_R2_ACCOUNT_ID, LOOMUP_R2_BUCKET,
LOOMUP_R2_ACCESS_KEY_ID, and LOOMUP_R2_SECRET_ACCESS_KEY. The first two
select R2 for every hosted project; credential values remain environment-only.
Bucket names
1–63 characters: lowercase letters, digits, hyphens; must start and end with alphanumeric.
Rules
Rules are evaluated against object metadata JSON:
| Field | Meaning |
|---|---|
id | Object UUID |
bucket | Bucket name |
path | Object key within the bucket |
name | Basename of path |
owner_id | Uploader’s user id (nullable) |
content_type | MIME type |
size | Size in bytes |
created_at / updated_at | Unix timestamps |
Same grammar as access rules. Admin users bypass rules when admin.rule_bypass = true (default).
Read access can be inherited from a related record without changing the object's
owner. For example, generated attachment rules can compare lookup(...) values
on both sides of = to verify the attachment and parent project share a workspace.
Direct downloads, HEAD requests, and signed-URL creation evaluate the same bucket
read rule. Existing signed-grant expiry and revocation behavior is unchanged.
Default deny: unconfigured buckets are not usable (404). Unconfigured rule fields default to "false".
HTTP API
Base path: /storage/v1. When storage.enabled = false, endpoints return 503 with code storage_disabled.
| Method | Path | Description |
|---|---|---|
GET | /storage/v1/buckets | List configured buckets |
GET | /storage/v1/{bucket}?prefix=&limit=&offset= | List object metadata (rule-filtered) |
POST / PUT | /storage/v1/{bucket}/object/{*path} | Upload raw body |
GET | /storage/v1/{bucket}/object/{*path} | Download bytes |
HEAD | /storage/v1/{bucket}/object/{*path} | Metadata headers only |
DELETE | /storage/v1/{bucket}/object/{*path} | Delete object |
POST | /storage/v1/{bucket}/sign/{*path} | Create a short-lived signed download URL |
GET | /storage/v1/{bucket}/signed/{*path}?token=… | Download with a signed URL |
Auth: Authorization: Bearer <access_token> (or cookie mode when enabled). Trusted backends may use a project:backend key; narrowly scoped keys use storage:<bucket>:read or storage:<bucket>:write. Optional for public-read buckets.
Upload
curl -X POST "http://127.0.0.1:3000/storage/v1/avatars/object/user-1/profile.png" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: image/png" \
-H "x-loomup-upsert: false" \
--data-binary @profile.png
- Body is the raw file (not multipart in v1 preferred path).
Content-Typeshould be the object MIME type.x-loomup-upsert: trueoverwrites an existing object (requiresupdaterule); default create-only returns 409 on conflict.- 201 Created on first insert; 200 OK on upsert overwrite.
- Owner is set at create and preserved on upsert (not reassigned to the overwriting user).
- Concurrent creates of the same key: one wins, the other gets 409.
- Success:
{ "data": { "id", "bucket", "path", "name", "owner_id", "content_type", "size", "etag", "created_at", "updated_at" } }.
Download
curl -OJ -H "Authorization: Bearer $TOKEN" \
"http://127.0.0.1:3000/storage/v1/avatars/object/user-1/profile.png"
Returns raw bytes with Content-Type, Content-Length, ETag, Content-Disposition, and Cache-Control. Rule deny and missing objects both return 404 (no existence leak).
Conditional GET: send If-None-Match: "<etag>" to receive 304 Not Modified when unchanged.
Signed download URLs
An authenticated caller with read access can create a URL that grants download access without forwarding its session token:
curl -X POST \
"http://127.0.0.1:3000/storage/v1/avatars/sign/user-1/profile.png" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
--data '{"expires_in":900}'
The response contains { "data": { "url", "expires_at" } }. expires_in
defaults to 900 seconds and must be between 1 and 86,400 seconds. The URL is
bound to the exact bucket and path, expires automatically, and cannot be used
to upload, replace, or delete an object. The TypeScript client exposes this as
client.storage.from(bucket).createSignedUrl(path, expiresIn).
Hosted CDN delivery
Worker authorization and usage calls use the dedicated CDN service credential. They do not require mobile app attestation; public mutations retain their normal attestation requirements.
Hosted operators can route uploaded-file downloads through cdn.tryloomup.com,
a Cloudflare Worker backed directly by the private R2 bucket. The Worker has its
own repository at base/cdn/; its source, dependencies, and deployment workflow
do not live in this server repository.
With delivery enabled for a project, object GET requests return a non-cacheable 307 redirect. Existing public URLs and SDK downloads continue to work by following it. HEAD on the original server route remains metadata-only. Uploads, lists, deletes, and website/Studio assets retain their existing routes.
Signing requests may include "delivery":"cdn" to receive an absolute HTTPS
CDN URL. Without that preference the response remains a relative server URL
which redirects, preserving older clients. If CDN delivery is disabled, the
server returns its ordinary relative signed URL. SDKs must accept both shapes.
New CDN grants bind project, bucket, path, object ID, issuer, and expiry. Before every GET/HEAD, including cache hits and 304 responses, the server rechecks the issuer's current access, user status/role or service-key scopes/revocation, and object existence. Losing access revokes a link before expiry. Delete/recreate does not revive old links; overwriting the same object returns its current bytes. Existing version-1 links retain their original semantics until expiry.
The server returns only permission/metadata decisions; R2-to-Worker-to-client
transfers bypass DigitalOcean. Anonymous requests still need an allowing read
rule: public=true is not authorization. R2 objects remain private.
The Worker streams bytes and supports single ranges. It caches complete objects
up to 8 MiB internally for one hour using the current R2 revision. Larger files
stream without cloning. Grant responses use private, no-store; anonymous CDN
responses use no-cache. Authorization is never cached. Authorization outages
fail closed even when bytes are cached.
The Worker-only POST routes /storage/internal/cdn/authorize and
/storage/internal/cdn/usage require the separate operator service credential;
hosted calls use the /p/<project-id> prefix. They are documented in OpenAPI.
Usage events are deduplicated and committed with local totals and the platform
export outbox. Response lengths, including ranges, count once; HEAD and 304 count
zero bytes. Accounting measures response size rather than confirmed client receipt.
See Deployment for configuration and rollback.
Object path validation
- Use
/separators only (no\). - No absolute paths, trailing
/, empty segments,., or... - Max length 1024 characters; max 64 path segments.
- List
prefixtreats%and_as literals (not SQL LIKE wildcards).
Content types
- Stored without parameters (
image/png; charset=…→image/png). - Must look like
type/subtype(alphanumeric plus limited punctuation). - Optional
storage.allowed_mime_prefixesis matched case-insensitively against the base type.
Config validation
Bucket names and access-rule syntax are validated when config is loaded (even if storage.enabled = false). Invalid rules fail startup/parse_config with a clear storage.buckets.<name>.rules.<op> message.
Ops
- Bootstrap probes that
storage.rootis writable and creates.tmp/for atomic uploads. - Stale
*.partstaging files older than 1 hour under.tmp/are removed on startup. /readyfails when storage is enabled but the root directory is missing.- Admin metrics include
storage_uploads/storage_downloads/storage_deletes/storage_errors.
Local on-disk layout
{project}/
loomup.toml
data/
app.sqlite
storage/ # storage.root
avatars/
user-1/
profile.png
.tmp/ # atomic upload staging
Metadata lives in system tables _storage_buckets and _storage_objects (never exposed under /api).
Backups
Backing up SQLite alone is not enough. For local storage, also copy storage.root. For R2, retain/version the corresponding projects/<project-id>/ prefix. Orphan objects may remain after a failed delete and can be garbage-collected against _storage_objects metadata.
Security notes
- Local storage roots are created with restrictive permissions on Unix (
0700). - R2 credentials are loaded only from their configured environment variables.
- Path traversal is rejected.
max_upload_bytescaps completed object size (default 50 MiB, maximum configuration 1 GiB). Raw requests are additionally capped at 64 MiB; larger files must use resumable uploads.max_total_bytescaps the sum of all object sizes across buckets (default 100 MiB free-tier). Set0for unlimited. Exceeding returns 507 with codestorage_quota_exceeded(message reports the projected total after the rejected upload). When both caps are set,max_upload_bytesmust be<= max_total_bytes.- Prefer TLS at a reverse proxy for production uploads/downloads.
Client SDKs
| SDK | API |
|---|---|
TypeScript @loomup/client | client.storage.from(bucket).upload/download/list/remove |
| Swift | client.storage.from("avatars").upload(path:data:…) |
| Kotlin | client.storage.from("avatars").upload(…) |
| Flutter / Dart | client.storage.from('avatars').upload(…) |
Server-side framework packages
These return a full LoomupClient (cookie session + Bearer) with client.storage, plus FormData helpers:
| Package | Helpers |
|---|---|
@loomup/next | uploadFromFormData, storageDownloadResponse |
@loomup/nuxt / @loomup/nuxt/server | same |
@loomup/astro/server | same (ServerLoomupClient extends the core client) |
Browser-only packages (@loomup/react, Vue, RN) still use client.storage via the shared TypeScript client; they do not need separate server helpers.
See sdk.md, nextjs.md, sdk-nuxt.md, sdk-astro.md.
Not in v1
- Multipart/form-data upload helper
- Image transforms
- Realtime events on object changes
- Admin UI storage browser
See also
Resumable uploads (up to 1 GiB)
Configure the target project's storage.max_upload_bytes = 1073741824 and a
max_total_bytes quota large enough for its existing objects plus the new file.
The default 50 MiB object limit and 100 MiB quota remain unchanged. Deploy backend
support before upgrading clients. Keep storage.root on persistent disk even
when using R2: in-progress files are staged under .uploads there.
POST /storage/v1/<bucket>/uploadswith JSON{ "path": "movie.mp4", "size": 1073741824, "content_type": "video/mp4" }.- Read
data.id,offset, andchunk_size; send sequentialPUT /storage/v1/<bucket>/uploads/<id>?offset=<offset>requests containing at most 8 MiB of raw bytes. The response reports the next committed offset. POST /storage/v1/<bucket>/uploads/<id>/completeafter every byte has arrived. This returns the ordinary storage object envelope. Local storage uses a staged file and R2 uses multipart transfer, without buffering the whole object.- On cancellation,
DELETE /storage/v1/<bucket>/uploads/<id>. This removes temporary bytes, not a completed object.
All operations require the same owner and a current storage-write principal.
Missing, invalid, or expired authentication returns 401, including when a token
expires while a chunk is in transit. Refresh the access token and retry the same
chunk and offset; rejected authentication does not advance the committed offset.
A valid principal denied by the bucket policy still receives 403.
Bucket write rules are checked at start, append, and completion. Identical chunk
retries are idempotent; wrong offsets or conflicting bytes return 409. Completion
receipts support retries after a lost response. A completed receipt additionally
requires current read access. Recovery before a receipt was saved verifies the physical
object checksum, rather than trusting its database claim. Repairing missing or
corrupted bytes additionally requires current bucket update permission. GET on a session returns its committed offset for
resuming after a restart. The caller must retain the original file and session ID.
Sessions expire after 24 hours. Starting another upload lazily removes expired sessions and files, interrupted creations without metadata, and staging copies left after a completed receipt. Locked sessions are never removed. Each owner may have ten incomplete sessions; all sessions reserve at most 16 GiB per project, reduced to the project's storage quota when smaller. Maintain free staging disk capacity for active uploads and local atomic copies. Sessions are durable on the project's storage volume; replicas must share that volume or route a session to the same host.
New JavaScript and Swift SDK uploads automatically use this protocol above 8 MiB.
JavaScript exposes createUpload, uploadStatus, uploadChunk, resumeUpload,
completeUpload, and abortUpload for explicit resume. Swift also accepts
upload(path:fileURL:contentType:upsert:) to read large files in bounded chunks.
Direct and signed downloads stream bytes; CDN-enabled downloads retain existing
signed redirects. Small raw uploads and object metadata formats remain compatible.
Workspace quotas
A SQL-free $quotas declaration adds transaction-level record and storage limits,
including service-key writes, direct SDK uploads, and resumable sessions:
$quotas:
workspace:
scopes: workspaces
memberships: workspace_memberships
records: projects
entitlements: workspace_billing
bucket: attachments
prefixes: [entries, issues, issue-uploads, comment-uploads, api-uploads]
references: [attachments, issue_attachments, comment_attachments]
default_records: 3
default_bytes: 1073741824
The record table has workspace_id and nullable deleted_at. The memberships
table has workspace_id, user_id, and role. Entitlements have a unique
workspace_id, nullable max_records (null means unlimited), max_bytes, and
valid_until in Unix milliseconds. All entitlement mutations must be denied to
ordinary users in the access profile; only a trusted billing service writes them.
Absent or expired entitlement rows use the defaults. Restoring or moving a
project counts as creation in the destination workspace; other edits and reads
continue when a workspace is above its allowance.
Paths use <declared-prefix>/<workspace-id>/.... Membership is checked independently
of bucket rules. Reference tables have workspace_id, r2_key, and byte_size;
new references must match a ready physical object in that workspace with the exact
size. Quota-managed objects are immutable: use a new path to replace content.
Known relationship fields (project_id, entry_id, issue_id, comment_id) and
soft-delete/expiry markers determine whether an object remains in active use.
Schema application backfills physical object metadata, including retained files.
For legacy paths outside the current workspace path format, it uses the declared
reference tables' r2_key, workspace_id, and byte_size records. All matching
records must agree on one existing workspace and match the physical object's
size. Missing, conflicting, or size-mismatched ownership rejects the migration.
Previously recorded quota ownership remains a claim when a policy is reapplied,
including after attachment references are removed. Files and URLs are preserved;
new uploads still require the current path format, and avatar paths cannot be
reclassified as workspace attachments. Preexisting resumable sessions are adopted before
usage is reported or new uploads are accepted. Reservations are atomic, count
against available capacity, and release after completion, abort, or successful
expired-session cleanup. The shared project storage cap and per-file cap still
apply. A workspace must fit both its allowance and the shared project cap.
avatars/<user-id>/... is a bounded personal exception: 20 MiB total per user,
5 MiB per image, at most 4096 × 4096 pixels, and decoded PNG/JPEG/WebP/GIF bytes
matching their MIME type. These objects cannot become workspace attachment
references. Arbitrary data or another user's avatar path is rejected.
Backend-only endpoints (authenticate the workspace owner in the application):
GET /storage/v1/{bucket}/quota/workspace/{workspace}: authoritative stored and reserved bytes, byte allowance, active project count and project allowance.GET .../objects?offset=0: pages of 50 physical objects with cleanup eligibility.DELETE .../objects/{id}: permanent cleanup after owner confirmation.
Cleanup refuses active references and recent orphan uploads. A deleting marker prevents new attachment references. Physical deletion happens before removing accounting metadata; a failure leaves the bytes charged and can be retried.
Stable quota errors include project_limit_exceeded (409),
storage_quota_exceeded (507), invalid_workspace_path, invalid_avatar,
invalid_storage_reference, immutable_quota_object, and storage_object_in_use.