@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();ctx.storage is a narrower projection of the API below: query and mutation
handlers get a read-only surface (download, getMetadata, getSignedUrl,
getUrl, list), and only action handlers get the full read/write surface
(store, delete, generateUploadUrl, multipart, presigned). On ctx.storage,
download(key) resolves to the ReadableStream directly.
Build the full Storage outside a handler (or to use the object-level reads,
ranged downloads, and multipart shown below) with
createStorage({ bucket, publicBaseUrl?, signingSecret?, s3? }). 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.
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 only host + 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 are now trusted.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,
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 });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.