@lunora/cli 1.0.0-alpha.361 → 1.0.0-alpha.363

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,162 +1,193 @@
1
1
  ---
2
2
  name: lunora-setup-scheduler
3
- description: Schedules deferred and recurring work in a Lunora app. Use for `ctx.scheduler.runAfter` / `runAt` (delayed function dispatch), cron jobs via `lunora registry add crons` (the `cronJobs()` builder), the `SchedulerDO` / `SCHEDULER` binding, retries, and the workpool for bounded concurrency.
3
+ description: Schedules deferred and recurring work in a Lunora app. Covers `ctx.scheduler.runAfter` / `runAt` (delayed dispatch of a function or workflow), cron jobs in `lunora/crons.ts` via `lunora registry add crons` and the `cronJobs()` builder, the `SchedulerDO` / `SCHEDULER` binding and `LUNORA_ORIGIN_URL`, retries and dead-lettering, and `createWorkpool` for bounded concurrency. Use when the user wants to "run this later", "send a reminder in 3 days", "run every night / every 5 minutes", add a cron, cancel a scheduled job, or sees `ORIGIN_NOT_CONFIGURED` or a missing `SchedulerDO` export.
4
4
  ---
5
5
 
6
6
  # Lunora Setup Scheduler
7
7
 
8
- Schedule work in Lunora two ways, both backed by `@lunora/scheduler` (re-exported
9
- from `@lunora/server`):
8
+ Lunora schedules work in two ways, both implemented in `@lunora/scheduler`:
10
9
 
11
- - **Deferred dispatch** — `ctx.scheduler.runAfter` / `runAt` from any function,
12
- to run a function later. Built into the runtime; **no registry install**.
13
- - **Recurring jobs (crons)** — declare them in `lunora/crons.ts` with
14
- `cronJobs()`. Add the starter with `lunora registry add crons`.
10
+ - **Deferred dispatch**: `ctx.scheduler.runAfter` / `runAt` run a function (or
11
+ start a workflow) later. Jobs are stored in the `SchedulerDO` Durable Object
12
+ (binding `SCHEDULER`), which owns the alarm, the retries and the dead-letter list.
13
+ - **Recurring jobs (crons)**: declared in `lunora/crons.ts` with `cronJobs()`.
14
+ Codegen compiles them into Cloudflare Cron Triggers, and the Worker's
15
+ `scheduled()` handler dispatches them directly. They don't go through the
16
+ `SchedulerDO`.
15
17
 
16
- Both run jobs through the `SchedulerDO` Durable Object (binding `SCHEDULER`),
17
- which owns the alarm and durable storage.
18
+ ## When Not to Use
18
19
 
19
- ## When to Use
20
+ - The project has no Lunora backend yet: use `lunora-quickstart` first.
21
+ - You only need to react to data changes: use a reactive `query` /
22
+ subscription (`lunora-realtime`).
23
+ - Multi-step orchestration with sleeps and waits belongs in a durable workflow.
24
+ A scheduler or cron can start one, but don't build step logic on top of jobs.
20
25
 
21
- - Running a function after a delay or at a specific time
22
- (`runAfter` / `runAt`).
23
- - Declaring recurring jobs (sweep, digest, report) on a cron schedule.
24
- - Bounding concurrency for many enqueued jobs (a workpool).
26
+ ## Deferred dispatch: `runAfter` / `runAt`
25
27
 
26
- ## When Not to Use
28
+ ### Wire it once
27
29
 
28
- - The project has no Lunora backend yet — use `lunora-quickstart` first.
29
- - You just need to react to live data changes — use a reactive `query` /
30
- subscription (`lunora-realtime`), not a scheduled job.
30
+ ```bash
31
+ pnpm add @lunora/scheduler
32
+ lunora codegen
33
+ ```
31
34
 
32
- ## Deferred dispatch — `runAfter` / `runAt`
35
+ Codegen needs `@lunora/scheduler` installed as soon as `ctx.scheduler` is used,
36
+ because the generated `_generated/app.ts` imports `createScheduler`. Once the
37
+ dependency is present, codegen emits `_generated/scheduler.ts` (re-exporting
38
+ `SchedulerDO`) and a `.scheduler()` method on the app builder.
33
39
 
34
- Available on `ctx.scheduler` in a **mutation or an action** — never a `query`,
35
- which is deterministic and re-runs. Target functions are passed by reference
36
- from the generated `api` / `internal` objects (a `"file:fn"` path string also
37
- works):
40
+ - **Vite-first apps** (generated worker entry): the plugin forwards
41
+ `SchedulerDO` and calls `.scheduler()` for you.
42
+ - **Hand-written worker entry** (`src/server.ts` with `defineApp`): wire it yourself.
43
+ `wrangler.jsonc`'s `SCHEDULER` binding is reconciled from this export:
38
44
 
39
45
  ```ts
40
- import { mutation, v } from "#lunora/_generated/server.js";
46
+ const app = defineApp<Env>()
47
+ .shard((env) => env.SHARD)
48
+ .scheduler({ namespace: (env) => env.SCHEDULER })
49
+ .build();
50
+
51
+ export const ShardDO = app.ShardDO;
52
+ export { SchedulerDO } from "../lunora/_generated/scheduler.js";
53
+ ```
41
54
 
42
- import { internal } from "./_generated/internal";
55
+ The `SchedulerDO` calls back into the Worker at `LUNORA_ORIGIN_URL`. It reads
56
+ that value from its own env and never from the request, so a caller can't point
57
+ dispatch at an arbitrary URL. Without it, every `runAfter`/`runAt` fails with
58
+ `ORIGIN_NOT_CONFIGURED`, and nothing provisions it for you:
43
59
 
44
- export const startTrial = mutation.input({ userId: v.string() }).mutation(async ({ ctx, args: { userId } }) => {
45
- // run an internal action 14 days from now
60
+ - dev: `LUNORA_ORIGIN_URL="http://localhost:5173"` in `.dev.vars` (your dev
61
+ server's URL)
62
+ - prod: `vars.LUNORA_ORIGIN_URL` in `wrangler.jsonc`, or
63
+ `wrangler secret put LUNORA_ORIGIN_URL` (per `--env`)
64
+
65
+ `lunora doctor` warns when the scheduler binding exists and the variable is unset.
66
+
67
+ ### Schedule from a mutation or action
68
+
69
+ ```ts
70
+ import { LunoraError } from "lunorash/server";
71
+
72
+ import { internal } from "#lunora/_generated/internal.js";
73
+ import { mutation } from "#lunora/_generated/server.js";
74
+
75
+ export const startTrial = mutation.mutation(async ({ ctx }) => {
76
+ // the caller's own id, never one the client passes in
77
+ const { userId } = ctx.auth;
78
+ if (!userId) {
79
+ throw new LunoraError("UNAUTHORIZED", "not signed in");
80
+ }
81
+ // run an internal action 14 days from now; the id is a plain string
46
82
  const jobId = await ctx.scheduler.runAfter(14 * 24 * 60 * 60 * 1000, internal.billing.endTrial, { userId });
47
83
 
48
84
  return { jobId };
49
85
  });
50
86
  ```
51
87
 
52
- - `runAfter(delayMs, fnRef, args?)` — run after a delay (`delayMs` must be a
53
- non-negative finite number). `runAt(timestampMs, fnRef, args?)` — run at an
54
- epoch-ms timestamp.
55
- - Both resolve the job id. Cancel with `ctx.scheduler.cancel(id)`;
56
- inspect with `ctx.scheduler.get(id)` / `ctx.scheduler.list()`.
57
- - Those three arguments are the whole `ctx.scheduler` surface. Per-job
58
- `RunOptions` — a `retry` policy (`{ maxAttempts, backoff, baseMs, maxMs }`), a
59
- `shardKey` routing hint, `pool` — live on `@lunora/scheduler`'s own
60
- `createScheduler(...)` client, which you construct yourself when you need them.
61
- Jobs scheduled through `ctx.scheduler` take the DO defaults: 5 retries,
62
- `backoff: "exponential"`, `baseMs: 30_000`. On retry exhaustion the job is
63
- dead-lettered, never silently dropped.
64
- - The `SchedulerDO` binding (`SCHEDULER`) is **auto-inferred and reconciled**
65
- into `wrangler.jsonc` by `@lunora/config` once `@lunora/scheduler` is in use —
66
- run `lunora codegen` / `lunora doctor` to confirm. Scheduled jobs run with no
67
- end-user identity, so target **internal** functions (`internal.*`).
68
-
69
- ## Recurring jobs — crons
88
+ - The full `ctx.scheduler` surface is `runAfter(delayMs, target, args?)`,
89
+ `runAt(timestampMs, target, args?)` (epoch ms), `cancel(id)`, `get(id)`, and `list()`.
90
+ It is not available in a `query`, because queries are deterministic and re-run.
91
+ - `target` is a generated `internal.*` / `api.*` mutation or action reference, a
92
+ `"file:fn"` string, or a `workflows.<name>` / `agents.<name>` reference.
93
+ A workflow or agent reference starts a fresh instance when the job fires, with
94
+ `args` as its params. Scheduled jobs run with no end-user identity, so target
95
+ `internal.*` functions.
96
+ - **Inside a mutation, scheduling is transactional.** The call is buffered and
97
+ dispatched only after the mutation commits. If the mutation rolls back (OCC
98
+ conflict, validation error), the job is dropped too. The returned id is final
99
+ immediately, so you can store it on the row you're writing and `cancel` it later.
100
+ - Retries use the DO defaults: 5 retries, exponential backoff from 30 s. A job
101
+ that exhausts them is dead-lettered (visible in the studio), not dropped. To set
102
+ a per-job `retry` policy (`{ maxAttempts, backoff: "exponential" | "linear",
103
+ baseMs, maxMs }`) or a `shardKey`, build your own client with
104
+ `createScheduler({ namespace })` from `@lunora/scheduler`. Its `runAfter`/`runAt`
105
+ take a fourth `RunOptions` argument and add `dead()` / `deadRetry(id)`.
106
+
107
+ ## Recurring jobs: crons
70
108
 
71
109
  ### Step 1: Add the starter
72
110
 
73
111
  ```bash
74
112
  lunora registry add crons
113
+ pnpm install
75
114
  ```
76
115
 
77
- This adds `@lunora/server` to `package.json` (run `pnpm install`) and copies
78
- `lunora/crons.ts` (a `cronJobs()` registry with one illustrative job) and
79
- `lunora/crons/jobs.ts` (the example `run` internal mutation it fires) into your
80
- project — both **yours** to edit. No extra DO binding is required.
116
+ This adds `@lunora/server` + `@lunora/scheduler` to `package.json` and copies two
117
+ files you own: `lunora/crons.ts` (a `cronJobs()` registry) and
118
+ `lunora/crons/jobs.ts` (an example `internalMutation`, `internal.crons.jobs.run`).
81
119
 
82
120
  ### Step 2: Declare jobs
83
121
 
84
122
  ```ts
85
123
  import { cronJobs } from "@lunora/server";
86
124
 
87
- import { internal } from "./_generated/internal";
125
+ import { internal } from "#lunora/_generated/internal.js";
88
126
 
89
127
  const crons = cronJobs();
90
128
 
91
129
  crons.interval("sweep presence", { minutes: 5 }, internal.presence.sweep, { roomId: "lobby" });
92
- crons.daily("digest", { hourUTC: 9, minuteUTC: 0 }, internal.email.digest, {});
93
- crons.weekly("report", { dayOfWeek: "monday", hourUTC: 8, minuteUTC: 0 }, internal.reports.weekly, {});
94
- crons.monthly("invoice", { day: 1, hourUTC: 0, minuteUTC: 0 }, internal.billing.invoice, {});
95
- crons.cron("custom", "0 */6 * * *", internal.foo.bar, {}); // raw cron escape hatch
130
+ crons.hourly("rollup", { minuteUTC: 17 }, internal.stats.rollup);
131
+ crons.daily("digest", { hourUTC: 9, minuteUTC: 0 }, internal.email.digest);
132
+ crons.weekly("report", { dayOfWeek: "monday", hourUTC: 8, minuteUTC: 0 }, internal.reports.weekly);
133
+ crons.monthly("invoice", { day: 1, hourUTC: 0, minuteUTC: 0 }, internal.billing.invoice);
134
+ crons.cron("custom", "0 */6 * * *", internal.foo.bar); // raw cron escape hatch
96
135
 
97
136
  export default crons;
98
137
  ```
99
138
 
100
- - `name` must be a non-empty **string literal**, unique across the project.
101
- - `fnRef` must be a static property access on the generated object
102
- (`internal.<…path>.<fn>` or `api.<…path>.<fn>`, one segment per folder and
103
- file, e.g. `internal.crons.jobs.run`) so codegen can discover it.
104
- Cron targets must be **internal** functions — a client can never invoke them.
105
- - All schedules are UTC and validated at definition time
106
- (`hourUTC: 25` throws immediately).
139
+ - Names must be non-empty string literals, unique across the project.
140
+ - Targets must be static property accesses (`internal.<…path>.<fn>`, one segment
141
+ per folder and file, e.g. `internal.crons.jobs.run`) or a `workflows.<name>` /
142
+ `agents.<name>` reference. Codegen resolves them from the AST, so a computed
143
+ reference fails codegen. `args` is optional and must be a literal.
144
+ - Schedules are UTC and validated when the job is defined. `interval` takes exactly one of
145
+ `{ minutes }` (must divide 60) or `{ hours }` (must divide 24). `{ seconds }`
146
+ is rejected because Cron Triggers have a one-minute floor. For sub-minute work,
147
+ use `runAfter`.
148
+ - Use `hourly` with an offset minute to spread jobs away from `:00`.
107
149
 
108
- ### Step 3: Regenerate types and the schedule
150
+ ### Step 3: Regenerate
109
151
 
110
152
  ```bash
111
153
  lunora codegen
112
154
  ```
113
155
 
114
- Codegen discovers each registration by AST, compiles the schedule to a cron
115
- expression, and emits `lunora/_generated/crons.ts` (the dispatcher the Worker's
116
- `scheduled()` handler consumes) plus the matching `triggers.crons` entry in
117
- `wrangler.jsonc`. You never hand-edit the wrangler schedule array.
156
+ Codegen emits `lunora/_generated/crons.ts` (`LUNORA_CRON_TRIGGERS` plus the
157
+ `LUNORA_CRONS` dispatcher map, which `defineApp` wires into `scheduled()`) and
158
+ syncs `triggers.crons` in `wrangler.jsonc`. Don't hand-edit that array.
159
+
160
+ **Verify:** the studio's scheduled-jobs view lists each cron and has a "Run now"
161
+ action, which calls the same dispatch path as a real tick.
118
162
 
119
- ## Bounded concurrency — workpool (optional)
163
+ ## Bounded concurrency: workpool (optional)
120
164
 
121
- For many enqueued jobs that must not all run at once, use a workpool — a named
122
- logical pool inside the same `SchedulerDO` (no extra binding):
165
+ A workpool is a named pool inside the same `SchedulerDO`. It needs no extra
166
+ binding. Build it in Worker code that has the raw `env`:
123
167
 
124
168
  ```ts
125
169
  import { createWorkpool } from "@lunora/scheduler";
126
170
 
127
- const pool = createWorkpool({
128
- namespace: env.SCHEDULER,
129
- name: "imports",
130
- maxConcurrency: 3,
131
- });
171
+ const pool = createWorkpool({ namespace: env.SCHEDULER, name: "imports", maxConcurrency: 3 });
132
172
 
133
- await pool.enqueue(internal.imports.processRow, { rowId });
173
+ await pool.enqueue(internal.imports.processRow, { rowId }, { retry: { maxAttempts: 3 } });
174
+ // pool.cancel(id), pool.status() → { inFlight, queued, maxConcurrency }
134
175
  ```
135
176
 
136
- The DO caps simultaneous dispatch at `maxConcurrency` and queues the rest
137
- durably. A Cloudflare-Queues-backed variant (`createQueueWorkpool`) leans on
138
- queue config for concurrency/retries instead — reach for the DO workpool when you
139
- need per-job cancel / status.
177
+ The DO dispatches at most `maxConcurrency` jobs at once and queues the rest
178
+ durably. One alarm drain keeps at most six dispatches in flight (the DO connection limit),
179
+ so a long-running job holds a slot for its whole duration. For individually long jobs
180
+ (LLM calls, exports), prefer `createQueueWorkpool`, which is backed by Cloudflare Queues.
181
+ You give up the hard cap, per-job cancel and per-job status in exchange.
140
182
 
141
183
  ## Common Pitfalls
142
184
 
143
- 1. **Targeting a non-internal function from a job/cron.** Scheduled dispatch has
144
- no end-user identity — target `internal.*` functions.
145
- 2. **Non-static `fnRef` or `name` in `cronJobs()`.** Codegen discovers them by
146
- AST; a dynamic reference or computed name can't be found.
147
- 3. **Forgetting `lunora codegen` after editing crons.** The `triggers.crons`
148
- array and the dispatcher map are codegen output — re-run it.
149
- 4. **Non-idempotent job handlers.** A missed tick may be retried and a slow tick
150
- can overlap the next — make handlers idempotent.
151
-
152
- ## Checklist
153
-
154
- - [ ] Deferred work uses `ctx.scheduler.runAfter` / `runAt` against
155
- `internal.*` functions.
156
- - [ ] `SCHEDULER` (SchedulerDO) binding present in `wrangler.jsonc`
157
- (`lunora doctor` clean) when using the scheduler.
158
- - [ ] Recurring jobs declared in `lunora/crons.ts` via `cronJobs()` (after
159
- `lunora registry add crons`).
160
- - [ ] `lunora codegen` run so `_generated/crons.ts` + `triggers.crons` are
161
- synced.
162
- - [ ] Job handlers are idempotent.
185
+ 1. **`ORIGIN_NOT_CONFIGURED`**: `LUNORA_ORIGIN_URL` is unset for the `SchedulerDO`
186
+ (see above).
187
+ 2. **`SchedulerDO` not exported** from a hand-written worker entry: wrangler
188
+ only binds classes the entry exports, so `SCHEDULER` is never provisioned.
189
+ 3. **Dynamic `name` or target in `cronJobs()`**: codegen cannot discover it.
190
+ 4. **Editing crons without re-running `lunora codegen`**: `triggers.crons` and the
191
+ dispatcher map are generated output and go stale.
192
+ 5. **Non-idempotent handlers**: a failed job is retried, and a slow cron tick
193
+ can overlap the next one.
@@ -1,108 +1,72 @@
1
1
  ---
2
2
  name: lunora-setup-storage
3
- 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.
3
+ description: Adds R2-backed file storage to a Lunora app with the `storage` registry item (`@lunora/storage`). Covers worker-signed PUT/GET URLs, the `UPLOADS` R2 bucket binding, `STORAGE_SIGNING_SECRET` and `STORAGE_PUBLIC_BASE_URL`, per-user key scoping, and the `/storage/*` Worker route that verifies signatures and moves the bytes. Use when the user wants file or image uploads, avatars, attachments or gated downloads, runs `lunora add storage` or `lunora registry add storage`, edits `lunora/storage/index.ts`, or sees signed storage URLs return 404/403 or uploads fail CORS.
4
4
  ---
5
5
 
6
6
  # Lunora Setup Storage
7
7
 
8
- Wire R2-backed file storage into a Lunora app using the `storage` registry item,
9
- which is built on `@lunora/storage` (an R2 adapter plus HMAC signed-URL helpers)
10
- and exposes idiomatic Lunora functions for browser uploads, gated downloads,
11
- delete, and list — with no bucket credential in the client.
8
+ The `storage` registry item wraps `@lunora/storage`, an R2 adapter with HMAC
9
+ signed-URL helpers. It exposes Lunora functions for browser uploads, gated
10
+ downloads, delete and list. The client never holds a bucket credential.
12
11
 
13
- A worker-signed URL points at **your Worker**, not at R2:
14
- `<base>/<key>?exp&method&bucket&sig`, where the base is the origin the request
15
- reached your Worker on (`ctx.origin`) unless `STORAGE_PUBLIC_BASE_URL` overrides
16
- it. The `/storage/*` route
17
- you add in step 4 is what verifies the signature and moves the bytes, for both
18
- the upload and the download. (The no-Worker-in-the-path variant is
19
- `@lunora/storage`'s S3 presigned URL, `getPresignedUrl` — it needs S3 credentials
20
- on the bucket and enforces none of your rules.)
12
+ A worker-signed URL points at your Worker, not at R2. Its shape is
13
+ `<base>/<key>?exp&method&bucket&sig`. The base is `ctx.origin` unless
14
+ `STORAGE_PUBLIC_BASE_URL` overrides it. The `/storage/*` route from Step 3
15
+ verifies the signature and moves the bytes, for both upload and download.
16
+ `@lunora/storage`'s `getPresignedUrl` takes the Worker out of the path, but it
17
+ needs S3 credentials and none of your rules apply to it.
21
18
 
22
- ## When to Use
23
-
24
- - Uploading user files (avatars, attachments) into R2 under your own gate.
25
- - Serving private/gated downloads via short-lived signed URLs.
26
- - Listing or deleting a caller's stored objects.
27
-
28
- ## When Not to Use
29
-
30
- - The project has no Lunora backend yet — use `lunora-quickstart` first.
31
- - Storage is already installed and you just want to upload — call
32
- `client.action("storage/generateUploadUrl", …)` and `PUT` to the returned URL.
33
-
34
- ## Workflow
35
-
36
- 1. Add the `storage` item.
37
- 2. Configure the `UPLOADS` R2 bucket binding and the signing secret.
38
- 3. Regenerate types with `lunora codegen`.
39
- 4. Add the `/storage/*` route to the Worker — it verifies signatures and serves
40
- both the signed `PUT` and the signed `GET`.
41
- 5. Upload/download from the client.
19
+ If the project has no Lunora backend yet, start with `lunora-quickstart`.
42
20
 
43
21
  ## Step 1: Add the item
44
22
 
45
23
  ```bash
46
- lunora registry add storage
24
+ lunora add storage # asks for the R2 bucket name
25
+ lunora add storage --bucket my-app-uploads
26
+ lunora registry add storage # low-level: writes bucket_name "replace-me-uploads"
47
27
  ```
48
28
 
49
- This:
50
-
51
- 1. Adds `@lunora/storage` and `@lunora/server` to `package.json` (run
52
- `pnpm install` afterwards).
53
- 2. Adds an R2 bucket binding to `wrangler.jsonc` (`r2_buckets`, binding
54
- **`UPLOADS`**, `bucket_name: "replace-me-uploads"` — rename it to a real
55
- bucket). It **merges** into any existing `r2_buckets`.
56
- 3. Scaffolds `STORAGE_SIGNING_SECRET` (a secret) and an empty, optional
57
- `STORAGE_PUBLIC_BASE_URL` into `.dev.vars`.
58
- 4. Copies `lunora/storage/index.ts` (the `generateUploadUrl` /
59
- `getDownloadUrl` / `deleteObject` / `listObjects` functions) into your
60
- project — it is **yours** to edit.
29
+ Then run `pnpm install` and `lunora codegen`. The item does the following:
61
30
 
62
- ## Step 2: Configure the binding + secrets
31
+ 1. Adds `@lunora/storage`, `@lunora/server`, `@lunora/errors` and `@lunora/ratelimit`.
32
+ 2. Merges an R2 binding named `UPLOADS` into `r2_buckets` in `wrangler.jsonc`.
33
+ 3. Writes `STORAGE_SIGNING_SECRET` (a secret) to `.dev.vars`, plus an empty, optional `STORAGE_PUBLIC_BASE_URL`.
34
+ 4. Copies `lunora/storage/index.ts` into the project. The project owns this file. Codegen exposes its functions as `api.storage.generateUploadUrl`, `getDownloadUrl` and `listObjects` (actions) and `deleteObject` (mutation). Every one requires a signed-in user, because `requireOwner` throws `UNAUTHORIZED` otherwise, so set up auth first (`lunora-setup-auth`). Each is rate-limited per user to 60 requests a minute through an in-memory, per-isolate limiter. For a durable limiter, run `lunora add ratelimit`.
63
35
 
64
- | Name | Where | Notes |
65
- | ------------------------- | -------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
66
- | `UPLOADS` | `wrangler.jsonc` → `r2_buckets[]` | The R2 bucket binding. Point `bucket_name` at a real bucket. |
67
- | `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. |
68
- | `STORAGE_PUBLIC_BASE_URL` | optional var (`.dev.vars` / Worker variable) | Leave it **empty** and URLs are signed against the origin the request reached your Worker on (`ctx.origin`), right in dev, previews and production alike. Set a **bare origin** only when another host (a CDN) serves `/storage/*`, or to sign where no request is behind the call: a query, a scheduled job, a workflow step have no `ctx.origin`, so signing there without it throws. A base carrying a path is rejected by the signer. |
36
+ ## Step 2: Configure the binding and secrets
69
37
 
70
- A configured `STORAGE_PUBLIC_BASE_URL` must be `https://` anywhere but local dev. A signed URL
71
- _is_ a bearer credential and the object bytes stream through it, so a plaintext
72
- origin hands both to anyone on the path. Only `http://localhost` /
73
- `http://127.0.0.1` belong in `.dev.vars`.
38
+ | Name | Where | Notes |
39
+ | ------------------------- | ------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------- |
40
+ | `UPLOADS` | `wrangler.jsonc` → `r2_buckets` | Set `bucket_name` to a real bucket: lowercase letters, digits and hyphens, 3–63 chars. Otherwise wrangler rejects it. |
41
+ | `STORAGE_SIGNING_SECRET` | secret | At least 32 chars; a shorter secret throws on the first call. Use `openssl rand -base64 32`, then `wrangler secret put` for production. |
42
+ | `STORAGE_PUBLIC_BASE_URL` | optional var | See below. |
74
43
 
75
- Generate a real signing secret with `openssl rand -base64 32` and write it with
76
- `wrangler secret put STORAGE_SIGNING_SECRET` for production.
44
+ Leave `STORAGE_PUBLIC_BASE_URL` empty for most apps. URLs are then signed
45
+ against `ctx.origin`, which is correct in dev, in previews and in production.
46
+ Set it only in two cases:
77
47
 
78
- ## Step 3: Regenerate types
79
-
80
- ```bash
81
- lunora codegen
82
- ```
48
+ - Another host, such as a CDN, serves `/storage/*`.
49
+ - You sign where no request is behind the call: a query, a scheduled job or a workflow step. None of these has a `ctx.origin`, so signing there throws.
83
50
 
84
- The functions surface in the generated `api` as `api.storage.generateUploadUrl`,
85
- `api.storage.getDownloadUrl`, `api.storage.deleteObject`, and
86
- `api.storage.listObjects`.
51
+ The value must be a bare origin. The key is verified from the whole URL
52
+ pathname, so the signer rejects a base that includes a path. Outside local dev
53
+ the value must use `https://`, because a signed URL is a bearer credential.
87
54
 
88
- ## Step 4: Add the `/storage/*` route to the Worker
55
+ ## Step 3: Add the `/storage/*` route to the Worker
89
56
 
90
- **Required, not optional.** Without it a minted URL hits the Lunora catch-all and
91
- every upload and download 404s — and it is the only thing checking the signature,
92
- so skipping the check lets anyone read any key.
57
+ The app needs this route. Without it, every minted URL falls through to the
58
+ Lunora catch-all and returns 404. The route is also the only place the signature
59
+ is checked, so a route that skips `verifySignedUrl` lets anyone read any key.
93
60
 
94
- `@lunora/server`'s `serveStorageObject(ctx, key, request, authorize)` handles
95
- the download half (`Range`/206, `ETag`, `nosniff`, and
96
- `content-disposition: attachment` for anything outside a small inline-safe set —
97
- raster images plus `audio/mpeg`, `audio/ogg`, `audio/wav`, `video/mp4`,
98
- `video/webm`, with `image/svg+xml` deliberately excluded). It verifies nothing on
99
- its own — its required `authorize` gate is where `verifySignedUrl` goes — and it
100
- does not handle the upload. Reach for it from an `httpAction`, where `ctx.storage`
101
- is in scope, whenever you want `Range` seeking or conditional requests.
61
+ `@lunora/server`'s `serveStorageObject(ctx, key, request, authorize)` covers the
62
+ download half. It handles `Range`/206, `ETag`, `nosniff` and the inline-safe
63
+ `content-disposition` list. It verifies nothing on its own, so call
64
+ `verifySignedUrl` inside its `authorize` gate. It also doesn't handle uploads.
65
+ Use it from an `httpAction`, where `ctx.storage` is in scope, when you need
66
+ `Range` seeking.
102
67
 
103
- The route below is the standalone version — a plain worker `fetch` with only the
104
- R2 binding to hand, so it serves whole objects and skips `Range`/`ETag`. Both
105
- verbs, by hand:
68
+ The standalone route below is a plain Worker `fetch` that only has the R2
69
+ binding. It handles both verbs and serves whole objects:
106
70
 
107
71
  ```ts
108
72
  import { isSafeHeaderValue } from "@lunora/server";
@@ -240,79 +204,49 @@ export default {
240
204
  };
241
205
  ```
242
206
 
243
- **Why the CORS lines are there.** If `STORAGE_PUBLIC_BASE_URL` is not your app's
244
- own origin, the browser `PUT` below is preflighted (`PUT` is not a simple method,
245
- and `content-type: image/png` is not a safelisted value). Answering `OPTIONS` is
246
- only half of it: the browser also reads
247
- `access-control-allow-origin` off the **real** response, so the 204 and the
248
- download response carry `...cors` too — a route that answers only the preflight
249
- passes it and then fails the request it was preflighting.
250
-
251
- Keep `STORAGE_PUBLIC_BASE_URL` same-origin if you would rather not maintain an
252
- allowlist; then `ALLOWED_ORIGINS` can be empty and `cors` never adds a header
253
- beyond `vary: origin`.
207
+ CORS: if `STORAGE_PUBLIC_BASE_URL` is a different origin from the app, the
208
+ browser preflights the `PUT`. The browser then reads
209
+ `access-control-allow-origin` from the real response as well, which is why the
210
+ 204 and the download response also spread `...cors`. Keep the base same-origin
211
+ and `ALLOWED_ORIGINS` can stay empty.
254
212
 
255
- `verifySignedUrl` checks expiry, then the HMAC. On a host-rewrite / CDN topology
256
- pass `{ expectedHost }` (the `STORAGE_PUBLIC_BASE_URL` host) so the signature
257
- canonicalizes against the host it was minted for.
213
+ On a CDN or host-rewrite setup, pass `{ expectedHost }` to `verifySignedUrl`,
214
+ set to the host of `STORAGE_PUBLIC_BASE_URL`, so the signature is checked
215
+ against the host the URL was minted for.
258
216
 
259
- ## Step 5: Upload / download from the client
217
+ ## Step 4: Upload and download from the client
260
218
 
261
219
  ```ts
262
- // 1. ask the server for a signed PUT URL
263
- const { key, url } = await client.action("storage/generateUploadUrl", {
264
- key: "avatar.png",
265
- contentType: file.type,
266
- });
267
-
268
- // 2. upload it — the URL points at your Worker's `/storage/*` route, which
269
- // verifies the signature and writes to R2. The content type is pinned into
270
- // the signature (and carried on the URL as `&ct=`); that signed value is what
271
- // gets stored, so the request's own `content-type` header is not read and
272
- // cannot override it.
220
+ import { api } from "../lunora/_generated/api"; // path relative to your client file
221
+
222
+ // 1. Mint a signed PUT URL. contentType must be in ALLOWED_UPLOAD_CONTENT_TYPES
223
+ // in lunora/storage/index.ts. The type is pinned into the signature, and the
224
+ // route stores that pinned value, not the request header.
225
+ const { key, url } = await client.action(api.storage.generateUploadUrl, { key: "avatar.png", contentType: file.type });
226
+
227
+ // 2. Upload straight to the Worker's /storage/* route.
273
228
  await fetch(url, { method: "PUT", headers: { "content-type": file.type }, body: file });
274
229
 
275
- // 3. later, get a signed GET URL to display it
276
- const { url: downloadUrl } = await client.action("storage/getDownloadUrl", { key: "avatar.png" });
230
+ // 3. Later, mint a signed GET URL to display it.
231
+ const { url: downloadUrl } = await client.action(api.storage.getDownloadUrl, { key: "avatar.png" });
277
232
  ```
278
233
 
279
- Every key is scoped per-tenant with `scopeKey(requireOwner(ctx.auth.userId),
280
- key)` — `requireOwner` returns `storage/<userId>` — so a client-supplied key can
281
- never address another user's data, and the `storage/` prefix is what lands the
282
- minted URL on the `/storage/*` route. The functions return the **scoped** key
283
- (`storage/<userId>/avatar.png`) alongside the URL; persist that, and pass the
284
- bare key back in — the component re-scopes it.
285
-
286
- ## Common Pitfalls
287
-
288
- 1. **Skipping `verifySignedUrl` on the download route.** Without it, anyone can
289
- read any key. Always verify before streaming.
290
- 2. **Placeholder bucket name.** `lunora init` and `lunora add storage` prompt for
291
- the bucket name (or take `--bucket <name>`), but the low-level
292
- `lunora registry add storage` writes the placeholder
293
- `bucket_name: "replace-me-uploads"` — rename it to a real R2 bucket. (R2 names
294
- are lowercase alphanumeric + hyphens, 3–63 chars; wrangler rejects anything
295
- else on `dev`/`deploy`.)
296
- 3. **Short / shared signing secret.** ≥32 chars is enforced (the item throws on
297
- the first call below it). Cross-_bucket_ replay is not a risk here — the
298
- bucket name is part of the HMAC canonical and rides on the URL as `&bucket=`,
299
- so a URL minted for one bucket never verifies against another under the same
300
- secret. Cross-_tenant_ reuse is the real hazard: one secret shared between two
301
- apps lets either mint URLs the other's route will honour, so keep a distinct
302
- secret per deployment.
303
- 4. **Base URL with a path.** `STORAGE_PUBLIC_BASE_URL` must be a bare origin. The
304
- key is verified from the whole URL pathname, so a subpath base would make
305
- every minted URL fail verification — `buildSignedUrl` rejects it up front.
306
- 5. **Routing the body through a Lunora function.** Uploads and downloads go
307
- through the thin `/storage/*` route, which streams to and from R2 — don't
308
- read the file into a `query`/`mutation`/`action` argument or return value.
309
-
310
- ## Checklist
311
-
312
- - [ ] `lunora registry add storage` run, `pnpm install` done.
313
- - [ ] `UPLOADS` bucket bound to a real bucket; `STORAGE_SIGNING_SECRET` (≥32
314
- chars) set; `STORAGE_PUBLIC_BASE_URL` left empty unless another host
315
- serves `/storage/*` or you sign URLs in a query or a scheduled job.
316
- - [ ] `lunora codegen` run so `api.storage.*` is generated.
317
- - [ ] `/storage/*` route added, verifying signed URLs on both `PUT` and `GET`.
318
- - [ ] Verified a client upload → signed download round-trip.
234
+ Every key is scoped as `storage/<userId>/<key>`, so a key from the client can't
235
+ reach another user's data. The `storage/` prefix is also what routes the minted
236
+ URL to `/storage/*`. The functions return the scoped key: persist it if you
237
+ like, but always pass the bare key back in, because the function scopes it
238
+ again. `listObjects` is an action, not a query, because R2 isn't reactive.
239
+ Refetch it after an upload or a delete.
240
+
241
+ ## Pitfalls
242
+
243
+ - **Signing secret reuse.** Signed URLs carry the bucket name inside the HMAC, so they can't be replayed across buckets. They can be replayed across deployments: two apps that share a secret will each honor URLs the other minted. Give each deployment its own secret.
244
+ - **File bodies through functions.** Bytes stream through the `/storage/*` route. Don't pass file contents as a `query`/`mutation`/`action` argument or return value.
245
+ - **Content types.** Widen the upload list through `ALLOWED_UPLOAD_CONTENT_TYPES`. Leave out `text/html` and `image/svg+xml`, since an uploaded one served from your origin is stored XSS.
246
+
247
+ ## Verify
248
+
249
+ 1. Run `lunora dev`, sign in, and upload a file through `generateUploadUrl` and `PUT`. The PUT should return 204.
250
+ 2. Open the URL from `getDownloadUrl`. It should return the bytes with `cache-control: private, no-store`.
251
+ 3. Change one character of `sig` in that URL. The route should return 403.
252
+ 4. Before deploying, check that `bucket_name` is a real bucket and that `STORAGE_SIGNING_SECRET` was set with `wrangler secret put`.