@lunora/cli
Version:
The Lunora CLI: init, dev, deploy, codegen, migrate, seed, doctor, insights, logs, registry, and the rest of the project commands
316 lines (257 loc) • 15.3 kB
Markdown
---
name: lunora-setup-storage
description: Adds R2-backed file storage to a Lunora app. Use for uploads/downloads via `lunora registry add storage`, signed PUT/GET URLs, the `UPLOADS` R2 bucket binding, `STORAGE_SIGNING_SECRET`, per-tenant key scoping, and verifying downloads in the Worker.
---
# Lunora Setup Storage
Wire R2-backed file storage into a Lunora app using the `storage` registry item,
which is built on `@lunora/storage` (an R2 adapter plus HMAC signed-URL helpers)
and exposes idiomatic Lunora functions for browser uploads, gated downloads,
delete, and list — with no bucket credential in the client.
A worker-signed URL points at **your Worker**, not at R2:
`${STORAGE_PUBLIC_BASE_URL}/<key>?exp&method&bucket&sig`. The `/storage/*` route
you add in step 4 is what verifies the signature and moves the bytes, for both
the upload and the download. (The no-Worker-in-the-path variant is
`@lunora/storage`'s S3 presigned URL, `getPresignedUrl` — it needs S3 credentials
on the bucket and enforces none of your rules.)
## When to Use
- Uploading user files (avatars, attachments) into R2 under your own gate.
- Serving private/gated downloads via short-lived signed URLs.
- Listing or deleting a caller's stored objects.
## When Not to Use
- The project has no Lunora backend yet — use `lunora-quickstart` first.
- Storage is already installed and you just want to upload — call
`client.action("storage/generateUploadUrl", …)` and `PUT` to the returned URL.
## Workflow
1. Add the `storage` item.
2. Configure the `UPLOADS` R2 bucket binding and the signing secret.
3. Regenerate types with `lunora codegen`.
4. Add the `/storage/*` route to the Worker — it verifies signatures and serves
both the signed `PUT` and the signed `GET`.
5. Upload/download from the client.
## Step 1: Add the item
```bash
lunora registry add storage
```
This:
1. Adds `@lunora/storage` and `@lunora/server` to `package.json` (run
`pnpm install` afterwards).
2. Adds an R2 bucket binding to `wrangler.jsonc` (`r2_buckets`, binding
**`UPLOADS`**, `bucket_name: "replace-me-uploads"` — rename it to a real
bucket). It **merges** into any existing `r2_buckets`.
3. Scaffolds `STORAGE_SIGNING_SECRET` (a secret) and `STORAGE_PUBLIC_BASE_URL`
into `.dev.vars`.
4. Copies `lunora/storage/index.ts` (the `generateUploadUrl` /
`getDownloadUrl` / `deleteObject` / `listObjects` functions) into your
project — it is **yours** to edit.
## Step 2: Configure the binding + secrets
| Name | Where | Notes |
| ------------------------- | ------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------- |
| `UPLOADS` | `wrangler.jsonc` → `r2_buckets[]` | The R2 bucket binding. Point `bucket_name` at a real bucket. |
| `STORAGE_SIGNING_SECRET` | secret (`.dev.vars` / `secret put`) | HMAC secret for signed URLs. Min 32 chars, enforced — a shorter one throws on the first call. Never share across tenants. |
| `STORAGE_PUBLIC_BASE_URL` | var (`.dev.vars` / `wrangler.jsonc`) | **Bare origin** running the `/storage/*` route (scaffolded `http://localhost:8787`). A base carrying a path is rejected by the signer. |
`STORAGE_PUBLIC_BASE_URL` must be `https://` anywhere but local dev. A signed URL
_is_ a bearer credential and the object bytes stream through it, so a plaintext
origin hands both to anyone on the path. Only `http://localhost` /
`http://127.0.0.1` belong in `.dev.vars`.
Generate a real signing secret with `openssl rand -base64 32` and write it with
`wrangler secret put STORAGE_SIGNING_SECRET` for production.
## Step 3: Regenerate types
```bash
lunora codegen
```
The functions surface in the generated `api` as `api.storage.generateUploadUrl`,
`api.storage.getDownloadUrl`, `api.storage.deleteObject`, and
`api.storage.listObjects`.
## Step 4: Add the `/storage/*` route to the Worker
**Required, not optional.** Without it a minted URL hits the Lunora catch-all and
every upload and download 404s — and it is the only thing checking the signature,
so skipping the check lets anyone read any key.
`@lunora/server`'s `serveStorageObject(ctx, key, request, authorize)` handles
the download half (`Range`/206, `ETag`, `nosniff`, and
`content-disposition: attachment` for anything outside a small inline-safe set —
raster images plus `audio/mpeg`, `audio/ogg`, `audio/wav`, `video/mp4`,
`video/webm`, with `image/svg+xml` deliberately excluded). It verifies nothing on
its own — its required `authorize` gate is where `verifySignedUrl` goes — and it
does not handle the upload. Reach for it from an `httpAction`, where `ctx.storage`
is in scope, whenever you want `Range` seeking or conditional requests.
The route below is the standalone version — a plain worker `fetch` with only the
R2 binding to hand, so it serves whole objects and skips `Range`/`ETag`. Both
verbs, by hand:
```ts
import { isSafeHeaderValue } from "@lunora/server";
import { verifySignedUrl } from "@lunora/storage";
/** Cap what a single signed PUT may store. */
const MAX_UPLOAD_BYTES = 25 * 1024 * 1024;
/**
* Origins allowed to upload cross-origin. Leave it empty when
* `STORAGE_PUBLIC_BASE_URL` is your app's own origin — then `cors` is inert and
* no browser ever preflights these routes.
*/
const ALLOWED_ORIGINS = new Set(["https://app.example.com"]);
/**
* Types safe to render in the browser. Everything else downloads — an uploader
* who pinned `text/html` or `image/svg+xml` must never get a same-origin script.
* (`serveStorageObject` applies this same list.)
*/
const INLINE_SAFE = new Set([
"audio/mpeg",
"audio/ogg",
"audio/wav",
"image/apng",
"image/avif",
"image/gif",
"image/jpeg",
"image/png",
"image/webp",
"video/mp4",
"video/webm",
]);
export default {
async fetch(request: Request, env: Env): Promise<Response> {
const url = new URL(request.url);
if (url.pathname.startsWith("/storage/")) {
const origin = request.headers.get("origin");
// `vary` rides on EVERY response, allowed origin or not: a shared
// cache keyed on the URL alone would otherwise replay one origin's
// `access-control-allow-origin` to another.
const cors = {
vary: "origin",
...(origin !== null && ALLOWED_ORIGINS.has(origin)
? { "access-control-allow-headers": "content-type", "access-control-allow-methods": "GET, PUT", "access-control-allow-origin": origin }
: {}),
};
// Before the verb check and the signature check: a preflight carries
// neither the signed method nor any credentials, so answering it
// later would 405 every cross-origin upload.
if (request.method === "OPTIONS") {
return new Response(null, { headers: cors, status: 204 });
}
// The method is signed, so a GET URL cannot be replayed as a PUT —
// check the verb anyway rather than relying on that alone.
if (request.method !== (url.searchParams.get("method") ?? "GET")) {
return new Response("method not allowed", { status: 405 });
}
const result = await verifySignedUrl(url, env.STORAGE_SIGNING_SECRET);
if (!result.valid || result.key === undefined) {
// Expose only `valid` — a precise reason is a signing oracle.
return new Response("forbidden", { status: 403 });
}
if (request.method === "PUT") {
// Store the content type the SIGNATURE pins, never the request's
// own header: the allowlist ran when the URL was minted, so
// trusting the header lets a caller mint for `image/png` and PUT
// `text/html` — stored XSS on this origin.
if (result.contentType === undefined) {
return new Response("upload URL carries no content type", { status: 400 });
}
// A declared length is the contract: R2 takes `request.body` as a
// stream, so there is nothing to measure before the write, and
// treating an ABSENT header as oversized would 413 every valid
// streamed upload. Demand it (411) and enforce it (413).
const declared = request.headers.get("content-length");
if (declared === null) {
return new Response("content-length required", { status: 411 });
}
const length = Number(declared);
if (!Number.isFinite(length) || length > MAX_UPLOAD_BYTES) {
return new Response("upload too large", { status: 413 });
}
await env.UPLOADS.put(result.key, request.body, { httpMetadata: { contentType: result.contentType } });
// The preflight's answer does not carry over: without CORS
// headers HERE too the browser passes preflight and then rejects
// the actual response.
return new Response(null, { headers: cors, status: 204 });
}
const object = await env.UPLOADS.get(result.key);
if (!object) {
return new Response("not found", { status: 404 });
}
// The stored content type came off an uploader-signed URL, so it is
// attacker-influenced: a CR/LF/NUL in it either throws inside
// `Headers` (an unhandled 500) or, on a permissive runtime, splits
// the response. Reject the value rather than reflect it — this is
// exactly what `isSafeHeaderValue` does inside `serveStorageObject`.
const rawContentType = object.httpMetadata?.contentType;
const contentType = rawContentType !== undefined && isSafeHeaderValue(rawContentType) ? rawContentType : "application/octet-stream";
return new Response(object.body, {
headers: {
...cors,
// The URL expires; a cached copy would not. Without this a
// browser or CDN can keep serving private bytes past `exp`,
// with `verifySignedUrl` never consulted again.
"cache-control": "private, no-store",
...(INLINE_SAFE.has(contentType.split(";")[0]?.trim().toLowerCase() ?? "") ? {} : { "content-disposition": "attachment" }),
"content-type": contentType,
"x-content-type-options": "nosniff",
},
});
}
// ... your Lunora handler
return new Response("not found", { status: 404 });
},
};
```
**Why the CORS lines are there.** If `STORAGE_PUBLIC_BASE_URL` is not your app's
own origin, the browser `PUT` below is preflighted (`PUT` is not a simple method,
and `content-type: image/png` is not a safelisted value). Answering `OPTIONS` is
only half of it: the browser also reads
`access-control-allow-origin` off the **real** response, so the 204 and the
download response carry `...cors` too — a route that answers only the preflight
passes it and then fails the request it was preflighting.
Keep `STORAGE_PUBLIC_BASE_URL` same-origin if you would rather not maintain an
allowlist; then `ALLOWED_ORIGINS` can be empty and `cors` never adds a header
beyond `vary: origin`.
`verifySignedUrl` checks expiry, then the HMAC. On a host-rewrite / CDN topology
pass `{ expectedHost }` (the `STORAGE_PUBLIC_BASE_URL` host) so the signature
canonicalizes against the host it was minted for.
## Step 5: Upload / download from the client
```ts
// 1. ask the server for a signed PUT URL
const { key, url } = await client.action("storage/generateUploadUrl", {
key: "avatar.png",
contentType: file.type,
});
// 2. upload it — the URL points at your Worker's `/storage/*` route, which
// verifies the signature and writes to R2. The content type is pinned into
// the signature (and carried on the URL as `&ct=`); that signed value is what
// gets stored, so the request's own `content-type` header is not read and
// cannot override it.
await fetch(url, { method: "PUT", headers: { "content-type": file.type }, body: file });
// 3. later, get a signed GET URL to display it
const { url: downloadUrl } = await client.action("storage/getDownloadUrl", { key: "avatar.png" });
```
Every key is scoped per-tenant with `scopeKey(requireOwner(ctx.auth.userId),
key)` — `requireOwner` returns `storage/<userId>` — so a client-supplied key can
never address another user's data, and the `storage/` prefix is what lands the
minted URL on the `/storage/*` route. The functions return the **scoped** key
(`storage/<userId>/avatar.png`) alongside the URL; persist that, and pass the
bare key back in — the component re-scopes it.
## Common Pitfalls
1. **Skipping `verifySignedUrl` on the download route.** Without it, anyone can
read any key. Always verify before streaming.
2. **Placeholder bucket name.** `lunora init` and `lunora add storage` prompt for
the bucket name (or take `--bucket <name>`), but the low-level
`lunora registry add storage` writes the placeholder
`bucket_name: "replace-me-uploads"` — rename it to a real R2 bucket. (R2 names
are lowercase alphanumeric + hyphens, 3–63 chars; wrangler rejects anything
else on `dev`/`deploy`.)
3. **Short / shared signing secret.** ≥32 chars is enforced (the item throws on
the first call below it). Cross-_bucket_ replay is not a risk here — the
bucket name is part of the HMAC canonical and rides on the URL as `&bucket=`,
so a URL minted for one bucket never verifies against another under the same
secret. Cross-_tenant_ reuse is the real hazard: one secret shared between two
apps lets either mint URLs the other's route will honour, so keep a distinct
secret per deployment.
4. **Base URL with a path.** `STORAGE_PUBLIC_BASE_URL` must be a bare origin. The
key is verified from the whole URL pathname, so a subpath base would make
every minted URL fail verification — `buildSignedUrl` rejects it up front.
5. **Routing the body through a Lunora function.** Uploads and downloads go
through the thin `/storage/*` route, which streams to and from R2 — don't
read the file into a `query`/`mutation`/`action` argument or return value.
## Checklist
- [ ] `lunora registry add storage` run, `pnpm install` done.
- [ ] `UPLOADS` bucket bound to a real bucket; `STORAGE_SIGNING_SECRET` (≥32
chars) and `STORAGE_PUBLIC_BASE_URL` (a bare origin) set.
- [ ] `lunora codegen` run so `api.storage.*` is generated.
- [ ] `/storage/*` route added, verifying signed URLs on both `PUT` and `GET`.
- [ ] Verified a client upload → signed download round-trip.