@lunora/cli 1.0.0-alpha.362 → 1.0.0-alpha.364
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.
- package/dist/packem_chunks/handler6.mjs +2 -2
- package/dist/packem_chunks/runDeployCommand.mjs +1 -1
- package/dist/packem_chunks/runDevCommand.mjs +2 -2
- package/package.json +11 -11
- package/skills/lunora/SKILL.md +140 -164
- package/skills/lunora-create-package/SKILL.md +97 -85
- package/skills/lunora-deploy/SKILL.md +175 -144
- package/skills/lunora-functions/SKILL.md +186 -85
- package/skills/lunora-migration-helper/SKILL.md +146 -174
- package/skills/lunora-performance-audit/SKILL.md +135 -162
- package/skills/lunora-quickstart/SKILL.md +128 -195
- package/skills/lunora-realtime/SKILL.md +137 -130
- package/skills/lunora-setup-auth/SKILL.md +157 -124
- package/skills/lunora-setup-hyperdrive/SKILL.md +101 -104
- package/skills/lunora-setup-hyperdrive-global/SKILL.md +92 -94
- package/skills/lunora-setup-mail/SKILL.md +83 -117
- package/skills/lunora-setup-scheduler/SKILL.md +130 -99
- package/skills/lunora-setup-storage/SKILL.md +83 -149
|
@@ -1,162 +1,193 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: lunora-setup-scheduler
|
|
3
|
-
description: Schedules deferred and recurring work in a Lunora app.
|
|
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
|
-
|
|
9
|
-
from `@lunora/server`):
|
|
8
|
+
Lunora schedules work in two ways, both implemented in `@lunora/scheduler`:
|
|
10
9
|
|
|
11
|
-
- **Deferred dispatch
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
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
|
-
|
|
17
|
-
which owns the alarm and durable storage.
|
|
18
|
+
## When Not to Use
|
|
18
19
|
|
|
19
|
-
|
|
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
|
-
|
|
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
|
-
|
|
28
|
+
### Wire it once
|
|
27
29
|
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
30
|
+
```bash
|
|
31
|
+
pnpm add @lunora/scheduler
|
|
32
|
+
lunora codegen
|
|
33
|
+
```
|
|
31
34
|
|
|
32
|
-
|
|
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
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
45
|
-
|
|
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,
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
`
|
|
59
|
-
`
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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`
|
|
78
|
-
`lunora/crons.ts` (a `cronJobs()` registry
|
|
79
|
-
`lunora/crons/jobs.ts` (
|
|
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 "
|
|
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.
|
|
93
|
-
crons.
|
|
94
|
-
crons.
|
|
95
|
-
crons.
|
|
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
|
-
-
|
|
101
|
-
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
-
|
|
106
|
-
(`
|
|
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
|
|
150
|
+
### Step 3: Regenerate
|
|
109
151
|
|
|
110
152
|
```bash
|
|
111
153
|
lunora codegen
|
|
112
154
|
```
|
|
113
155
|
|
|
114
|
-
Codegen
|
|
115
|
-
|
|
116
|
-
`
|
|
117
|
-
|
|
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
|
|
163
|
+
## Bounded concurrency: workpool (optional)
|
|
120
164
|
|
|
121
|
-
|
|
122
|
-
|
|
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
|
|
137
|
-
durably.
|
|
138
|
-
|
|
139
|
-
|
|
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.
|
|
144
|
-
|
|
145
|
-
2.
|
|
146
|
-
|
|
147
|
-
3. **
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
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
|
|
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
|
-
|
|
9
|
-
|
|
10
|
-
and
|
|
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
|
|
14
|
-
`<base>/<key>?exp&method&bucket&sig
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
`
|
|
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
|
-
|
|
76
|
-
`
|
|
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
|
-
|
|
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
|
|
85
|
-
|
|
86
|
-
`
|
|
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
|
|
55
|
+
## Step 3: Add the `/storage/*` route to the Worker
|
|
89
56
|
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
so
|
|
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)`
|
|
95
|
-
|
|
96
|
-
`content-disposition
|
|
97
|
-
|
|
98
|
-
`
|
|
99
|
-
|
|
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
|
|
104
|
-
|
|
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
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
`
|
|
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
|
-
|
|
256
|
-
|
|
257
|
-
|
|
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
|
|
217
|
+
## Step 4: Upload and download from the client
|
|
260
218
|
|
|
261
219
|
```ts
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
//
|
|
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.
|
|
276
|
-
const { url: downloadUrl } = await client.action(
|
|
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
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
##
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
3.
|
|
297
|
-
|
|
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`.
|