@lunora/storage wraps a Cloudflare R2 binding with a typed API (upload,
download, delete, list, getMetadata, multipart) and two URL schemes: a
worker-signed URL the browser uses to upload/download through your Worker (so
your app gates the request), and a native S3 presigned URL that hits R2
directly.
Wiring
Declare the bucket on your app. The builder calls createStorage for you and
exposes the result as ctx.storage in every handler. The same declaration
backs the studio file browser.
// lunora/app.ts — defineApp is emitted by codegen into _generated/app
import { defineApp } from "@/lunora/_generated/app";
export default defineApp<Env>()
.shard((env) => env.SHARD)
.storage({
bucket: (env) => env.FILES,
// Extra named buckets, reached via ctx.storage.bucket("avatars").
buckets: { avatars: (env) => env.AVATARS },
publicBaseUrl: (env) => env.PUBLIC_STORAGE_BASE_URL,
signingSecret: (env) => env.STORAGE_SECRET,
})
.build();buckets binds the bucket; it does not name it to the type system. .storage() is a runtime object, so codegen never reads it: the
StorageBucketName union that types ctx.storage.bucket(name) is built from your schema — every v.storage("avatars") column — plus any
defineStorageRule({ bucket: "avatars" }), and always contains "default". Declare the bucket in one of those two places as well, or
ctx.storage.bucket("avatars") is a compile error even though the binding resolves at runtime:
// lunora/schema.ts — this is what puts "avatars" in StorageBucketName
export const schema = defineSchema({ profiles: defineTable({ image: v.storage("avatars") }) });ctx.storage is a narrower projection of the API below: query and mutation
handlers are typed with a read-only surface (download, head, getMetadata,
getSignedUrl, getUrl; a mutation also gets deleteAfterCommit), and only
action handlers are typed with the full read/write surface (store, delete,
generateUploadUrl, getPresignedUrl). Multipart (createMultipartUpload /
resumeMultipartUpload), upload, and list are on no ctx.storage
surface — call them on a createStorage instance (below). download(key)
resolves to the R2 object (its metadata plus a body stream), not to a bare
ReadableStream.
The read-only / action-only split is a type-level one. The object behind ctx.storage is the same in every context, so a mutation that casts past its
type — or plain JavaScript — can still reach delete. TypeScript is the guardrail; it is not a sandbox. The runtime allowlist is storageRules(...), which
rebuilds ctx.storage as exactly the gated surface and drops every ungated sibling (upload, multipart, getPresignedUrl, list) so nothing can route
around a rule. Reach for it whenever the split has to hold against more than a compiler.
Deleting an object with the row that owns it
A mutation runs inside the shard's storage transaction, which can roll back. An
R2 delete cannot — so delete stays action-only, and a mutation that removes the
row pointing at an object queues the object instead:
export const remove = mutation.input({ id: v.id("attachments") }).mutation(async ({ args, ctx }) => {
const attachment = await ctx.db.attachments.get(args.id);
if (!attachment) {
return;
}
await ctx.db.attachments.delete(args.id);
ctx.storage.deleteAfterCommit(attachment.key);
});The queue is flushed once the transaction commits, and never if it rolled back —
so the row and the bytes cannot disagree. deleteAfterCommit returns void
rather than a promise: nothing has been attempted yet when it returns, and the
object is still there on the next line.
The flush runs after the response wherever the host can defer it (Cloudflare can), so the caller does not wait on R2. The trade is that an object can briefly outlive its row, and that a failed delete leaks the object rather than failing a mutation that already committed — each failure is logged with its key. Nothing reads an object without its row, so the window itself is invisible.
ctx.storage.bucket("avatars").deleteAfterCommit(key) queues against that bucket.
An action needs none of this for its own deletes — it is not transactional, so it
calls delete directly — but a mutation it reaches through ctx.runMutation
still queues, and those deletes are flushed when the action returns.
An action that throws drops its queued deletes. The submutation's rows are already committed (ctx.runMutation does not put the action in a
transaction), so the row is gone and the object stays — and because the flush never ran, nothing is logged either. It is still the safe direction, an
orphaned object rather than a dangling row, but it is not the "the row and the bytes cannot disagree" guarantee a top-level mutation gives. If a delete has
to be observable, do the delete-and-row work in one top-level mutation rather than composing it from an action.
Build the full Storage outside a handler (or to use the object-level reads,
ranged downloads, and multipart shown below) with
createStorage({ bucket, bucketName, publicBaseUrl?, signingSecret?, s3? }).
bucketName is required and has no default — it is bound into every signed
URL's HMAC, so a bucket signing under the wrong name mints URLs that verify
against somebody else's bucket. The examples that follow use such an instance,
named storage.
Direct upload from an action
Minting an upload URL is a write capability, so it lives on an action. A
query or mutation only gets the read-only projection, without
generateUploadUrl, store, upload, or delete.
import { action, v } from "@/lunora/_generated/server";
export const uploadAvatar = action.input({ key: v.string(), contentType: v.string() }).action(async ({ ctx, args: { key, contentType } }) => {
const scopedKey = `avatars/${ctx.auth.userId ?? "anonymous"}/${key}`;
// PUT URL with the Content-Type pinned into the HMAC — the client must
// upload with exactly this Content-Type or verification fails.
const url = await ctx.storage.generateUploadUrl(scopedKey, { contentType, expiresInSeconds: 60 });
return { key: scopedKey, url };
});Client-supplied keys can address peer data. Always namespace keys with a per-tenant prefix (scopeKey(userId, key) or a manual `users/${userId}/${key}` ). Lunora rejects .., NUL bytes, and leading / on every key automatically, but that check does not enforce tenancy.
Reading
On a createStorage instance, download returns an R2 object whose body is a
ReadableStream (or null when the object is absent). Read it with
arrayBuffer() / text(), or stream body straight into a Response.
const object = await storage.download("avatars/abc.png");
if (object) {
const bytes = await object.arrayBuffer();
// or: return new Response(object.body, { headers: { "content-type": object.httpMetadata?.contentType ?? "" } });
}Pass { range } to stream only a byte window. R2 resolves the range
server-side, so the unwanted bytes never reach the Worker:
const head = await storage.download(key, { range: { offset: 0, length: 1024 } });getMetadata(key) reads size/content-type/sha256/upload time without fetching
the body (an R2 HEAD), returning null when the object is absent.
list(prefix?, { cursor, limit, delimiter }) paginates: echo cursor back for
the next page while truncated is true. The cursor is opaque: treat it as a
string, don't parse it.
With a delimiter, the keys that share a segment are rolled up into
delimitedPrefixes (the "folders") and are NOT in objects — a folder browser
must render both, or a listing of photos/ full of photos/2026/*.png looks
like an empty directory.
let cursor: string | undefined;
do {
const page = await storage.list("avatars/", { cursor, limit: 100 });
// ...use page.objects
cursor = page.truncated ? page.cursor : undefined;
} while (cursor);Signed URLs
A worker-signed URL resolves back through your Worker, so the request still
passes your auth/policy/rate-limit gates before the body is served. baseUrl
(and .storage()'s publicBaseUrl) must be a bare origin: getSignedUrl
rejects a URL that carries a path, since the signature binds host + bucket + key
and verification reconstructs the key from the full URL pathname. The Worker
route handling the signed download/upload (mounted at that origin, e.g.
GET /:key) calls verifySignedUrl to check the signature and expiry:
import { verifySignedUrl } from "@lunora/storage";
const result = await verifySignedUrl(request.url, env.STORAGE_SECRET);
if (!result.valid) {
// Do NOT echo result.reason to the client — "expired" vs "bad_signature"
// is a signing oracle. It is for server logs only.
return new Response("Forbidden", { status: 403 });
}
// result.key / result.method / result.contentType / result.bucketName are now trusted.Every bucket of one .storage({ bucket, buckets }) declaration shares the same
publicBaseUrl and signingSecret, so the bucket name is bound into the HMAC
(and mirrored on the URL as &bucket=) — a URL minted for avatars does not
verify against invoices. Resolve the R2 binding your route serves from
result.bucketName, never from a caller-supplied parameter. On a PUT, compare
the request's Content-Type against result.contentType and answer 415 when
they differ: the pin is only worth what the serving route enforces.
getSignedUrl(key, { method, expiresInSeconds, contentType }) mints the URL.
expiresInSeconds must be positive and at most 7 days. For a PUT URL,
contentType is baked into the signature so the upload is only valid with that
exact Content-Type; it is ignored for GET. generateUploadUrl is the
Convex-compatible alias for a PUT signed URL.
Presigned URLs (direct to R2)
getPresignedUrl(key, { method, expiresInSeconds }) mints a native S3 presigned
URL (SigV4) that hits R2 directly, bypassing the Worker. Use it for large
transfers where you don't need per-request app gating. It needs R2 S3 API
credentials, passed as s3 to createStorage:
const storage = createStorage({
bucket: env.FILES,
bucketName: "files",
s3: {
accountId: env.R2_ACCOUNT_ID,
accessKeyId: env.R2_ACCESS_KEY_ID,
secretAccessKey: env.R2_SECRET_ACCESS_KEY,
bucket: "files",
},
});
const url = await storage.getPresignedUrl("exports/report.csv", { method: "GET", expiresInSeconds: 900 });s3 has to reach the createStorage call that built the instance you are
calling — a Storage without it throws INTERNAL: 's3' credentials are required on every getPresignedUrl. Build the instance yourself (as above)
when the app's .storage() declaration does not carry s3.
Large objects (multipart)
For very large objects use R2's native multipart upload: createMultipartUpload
returns a handle whose uploadPart / complete / abort you drive yourself.
Persist uploadId to resume across requests with resumeMultipartUpload.
const multipart = await storage.createMultipartUpload("videos/clip.mp4", { contentType: "video/mp4" });
const part = await multipart.uploadPart(1, chunk);
await multipart.complete([part]);End-user uploads (RLS-gated)
The admin studio upload path (storageUpload → /_lunora/admin/storage) is gated
by an adminToken, which is right for the file browser and wrong for end users. For
browser-driven uploads with live progress, pause/resume, large-file resumable
and per-part retry, @lunora/storage/upload mounts @visulima/storage's
resumable upload handlers (TUS / chunked-REST / multipart) behind an app-supplied
RLS gate. Drive it from the client with
@lunora/react/upload
(or @lunora/vue / @lunora/solid / @lunora/svelte, or the framework-agnostic
@lunora/client/upload). Lunora does not hand-roll the uploader.
createUploadHandler returns a fetch(request) you mount on the route your
client uploads to. The authorize callback is the RLS decision: it runs before
every request (create, chunk PATCH, resume HEAD, delete) and denies
fail-closed: returning false or throwing yields a 403, never a 500.
import { createR2UploadStorage, createUploadHandler } from "@lunora/storage/upload";
// Back the handler with R2 via the dependency-light `aws-light` provider
// (aws4fetch, no AWS SDK). Needs `nodejs_compat` in wrangler.jsonc.
const uploads = createUploadHandler({
storage: createR2UploadStorage({
accountId: env.CF_ACCOUNT_ID,
bucket: "user-uploads",
accessKeyId: env.R2_ACCESS_KEY_ID,
secretAccessKey: env.R2_SECRET_ACCESS_KEY,
path: "/upload",
}),
// RLS: resolve the caller from the request and allow only their own prefix.
authorize: async ({ request }) => {
const user = await resolveUser(request); // your session/JWT check
return user !== null;
},
});
export default {
async fetch(request: Request): Promise<Response> {
const url = new URL(request.url);
if (url.pathname.startsWith("/upload")) {
return uploads.fetch(request);
}
return handleRest(request);
},
};protocol defaults to "tus" (the resumable, pause/resume-capable path); pass
"chunked-rest" or "multipart" to mount a different one. Use a memory provider
(@visulima/storage/provider/memory) in tests to exercise the whole flow
(progress, pause/resume, resume-after-drop) without a live bucket.