Skip to content
DocspackagesDocumentation

@lunora/storage

R2-backed object storage with worker-signed and S3 presigned URLs.

PackagesStorage

@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.