@lunora/cli 0.0.0 → 1.0.0-alpha.10

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.
Files changed (75) hide show
  1. package/LICENSE.md +105 -0
  2. package/README.md +109 -9
  3. package/__assets__/package-og.svg +14 -0
  4. package/dist/bin.mjs +11 -0
  5. package/dist/index.d.mts +956 -0
  6. package/dist/index.d.ts +956 -0
  7. package/dist/index.mjs +19 -0
  8. package/dist/packem_chunks/handler.mjs +150 -0
  9. package/dist/packem_chunks/handler10.mjs +22 -0
  10. package/dist/packem_chunks/handler11.mjs +192 -0
  11. package/dist/packem_chunks/handler12.mjs +131 -0
  12. package/dist/packem_chunks/handler13.mjs +65 -0
  13. package/dist/packem_chunks/handler14.mjs +58 -0
  14. package/dist/packem_chunks/handler15.mjs +79 -0
  15. package/dist/packem_chunks/handler16.mjs +43 -0
  16. package/dist/packem_chunks/handler17.mjs +105 -0
  17. package/dist/packem_chunks/handler18.mjs +170 -0
  18. package/dist/packem_chunks/handler19.mjs +89 -0
  19. package/dist/packem_chunks/handler2.mjs +114 -0
  20. package/dist/packem_chunks/handler20.mjs +94 -0
  21. package/dist/packem_chunks/handler21.mjs +311 -0
  22. package/dist/packem_chunks/handler3.mjs +204 -0
  23. package/dist/packem_chunks/handler4.mjs +33 -0
  24. package/dist/packem_chunks/handler5.mjs +49 -0
  25. package/dist/packem_chunks/handler6.mjs +91 -0
  26. package/dist/packem_chunks/handler7.mjs +42 -0
  27. package/dist/packem_chunks/handler8.mjs +174 -0
  28. package/dist/packem_chunks/handler9.mjs +16 -0
  29. package/dist/packem_chunks/planDevCommand.mjs +500 -0
  30. package/dist/packem_chunks/runCodegenCommand.mjs +52 -0
  31. package/dist/packem_chunks/runDeployCommand.mjs +504 -0
  32. package/dist/packem_chunks/runInitCommand.mjs +1498 -0
  33. package/dist/packem_chunks/runMigrateGenerateCommand.mjs +397 -0
  34. package/dist/packem_chunks/runResetCommand.mjs +41 -0
  35. package/dist/packem_chunks/runRpcCommand.mjs +68 -0
  36. package/dist/packem_shared/COMMANDS-D3h9Iwvl.mjs +944 -0
  37. package/dist/packem_shared/DEFAULT_IMPORT_BATCH_SIZE-Ck-2bU08.mjs +244 -0
  38. package/dist/packem_shared/admin-url-4UzT-CI4.mjs +19 -0
  39. package/dist/packem_shared/api-spec-CtA6ilu4.mjs +13 -0
  40. package/dist/packem_shared/buildRegistryIndex-BcYe607_.mjs +38 -0
  41. package/dist/packem_shared/command-BC30oSBW.mjs +14 -0
  42. package/dist/packem_shared/commands-DPKWlqqX.mjs +812 -0
  43. package/dist/packem_shared/createLogger-B40gPzQo.mjs +78 -0
  44. package/dist/packem_shared/createRecordingSpawner-DxI3mebw.mjs +43 -0
  45. package/dist/packem_shared/detect-package-manager-DYp7n3mJ.mjs +61 -0
  46. package/dist/packem_shared/diffSnapshots-BeDvvNiF.mjs +161 -0
  47. package/dist/packem_shared/docker-hMQ97KSQ.mjs +21 -0
  48. package/dist/packem_shared/insertSchemaExtension-BuzF6-t2.mjs +59 -0
  49. package/dist/packem_shared/open-url-Dfq6fAyT.mjs +41 -0
  50. package/dist/packem_shared/output-format-wUvAN6AL.mjs +17 -0
  51. package/dist/packem_shared/parseArgs-YXFuKdEk.mjs +56 -0
  52. package/dist/packem_shared/parseManifest--vZf2FY1.mjs +94 -0
  53. package/dist/packem_shared/resolve-target-qbsJ_5sF.mjs +16 -0
  54. package/dist/packem_shared/runAddCommand-CTRA_JlL.mjs +4 -0
  55. package/dist/packem_shared/schema-drift-gate-BtBt0as0.mjs +79 -0
  56. package/dist/packem_shared/schemaIrToSnapshot-DdsljJT-.mjs +43 -0
  57. package/dist/packem_shared/storage-2RJBhUC4.mjs +84 -0
  58. package/dist/packem_shared/tui-prompts-DEiPCKV-.mjs +661 -0
  59. package/dist/packem_shared/wrangler-name-cy4yhm9j.mjs +12 -0
  60. package/package.json +62 -18
  61. package/skills/README.md +29 -0
  62. package/skills/lunora/SKILL.md +83 -0
  63. package/skills/lunora-create-package/SKILL.md +129 -0
  64. package/skills/lunora-deploy/SKILL.md +150 -0
  65. package/skills/lunora-functions/SKILL.md +182 -0
  66. package/skills/lunora-migration-helper/SKILL.md +194 -0
  67. package/skills/lunora-performance-audit/SKILL.md +143 -0
  68. package/skills/lunora-quickstart/SKILL.md +240 -0
  69. package/skills/lunora-realtime/SKILL.md +177 -0
  70. package/skills/lunora-setup-auth/SKILL.md +170 -0
  71. package/skills/lunora-setup-hyperdrive/SKILL.md +154 -0
  72. package/skills/lunora-setup-hyperdrive-global/SKILL.md +171 -0
  73. package/skills/lunora-setup-mail/SKILL.md +151 -0
  74. package/skills/lunora-setup-scheduler/SKILL.md +157 -0
  75. package/skills/lunora-setup-storage/SKILL.md +158 -0
@@ -0,0 +1,151 @@
1
+ ---
2
+ name: lunora-setup-mail
3
+ description: Adds transactional email to a Lunora app. Use for sending mail (verification, password reset, invites, notifications) via `lunora registry add mail`, the `sendEmail` / `queueEmail` actions, the `SEND_EMAIL` Cloudflare Email Workers binding, Resend, React email templates, and the dev mail catcher.
4
+ ---
5
+
6
+ # Lunora Setup Mail
7
+
8
+ Wire transactional email into a Lunora app using the `mail` registry item, which
9
+ is built on `@lunora/mail` (a Cloudflare Email Workers transport with
10
+ header-injection-safe address handling) and exposes a `sendEmail` action plus a
11
+ fire-and-forget `queueEmail` action. In dev, every send is captured into the
12
+ Studio Mail tab instead of going out.
13
+
14
+ ## When to Use
15
+
16
+ - Sending app→user mail: invites, notifications, receipts.
17
+ - Delivering verification / password-reset mail from `@lunora/auth`.
18
+ - Using a React (`react-email`) template or a hosted provider (Resend).
19
+
20
+ ## When Not to Use
21
+
22
+ - The project has no Lunora backend yet — use `lunora-quickstart` first.
23
+ - Mail is already installed and you just want to send — call
24
+ `ctx.runAction(api.mail.sendEmail, …)` or `client.action("mail/sendEmail", …)`.
25
+
26
+ ## Workflow
27
+
28
+ 1. Add the `mail` item.
29
+ 2. Configure the `SEND_EMAIL` binding (or a provider) and `MAIL_FROM`.
30
+ 3. Regenerate types with `lunora codegen`.
31
+ 4. Send mail from a function (or the client); render a React template if needed.
32
+
33
+ ## Step 1: Add the item
34
+
35
+ ```bash
36
+ lunora registry add mail
37
+ ```
38
+
39
+ This:
40
+
41
+ 1. Adds `@lunora/mail` and `@lunora/server` to `package.json` (run
42
+ `pnpm install` afterwards).
43
+ 2. Copies `lunora/mail/index.ts` (the `sendEmail` / `queueEmail` actions) into
44
+ your project — it is **yours** to edit.
45
+ 3. Adds a `send_email` binding (`SEND_EMAIL`, with a `destination_address`
46
+ placeholder) to `wrangler.jsonc` and scaffolds `MAIL_FROM` (the default
47
+ sender) into `.dev.vars`.
48
+
49
+ ## Step 2: Configure delivery
50
+
51
+ | Name | Where | Notes |
52
+ | ---------------- | ------------------------------------ | --------------------------------------------------------------------------------- |
53
+ | `SEND_EMAIL` | `wrangler.jsonc` → `send_email[]` | Cloudflare Email Workers binding. Single-recipient; only verified destinations. |
54
+ | `MAIL_FROM` | var (`.dev.vars` / `wrangler.jsonc`) | Default sender address. |
55
+ | `RESEND_API_KEY` | secret (optional) | Use a hosted provider instead — pass `apiKey` to `createMailer` in `lunora/mail`. |
56
+
57
+ For production with Cloudflare Email Workers, set up
58
+ [Email Routing](https://developers.cloudflare.com/email-routing/): verify a
59
+ destination address and replace the `REPLACE_ME@example.com` placeholder. Prefer
60
+ Resend? Pass `apiKey` (or a custom `transport`) to `createMailer` in your copied
61
+ `lunora/mail/index.ts`.
62
+
63
+ In `lunora dev` (`WORKER_ENV=development`) the scaffold swaps in `@lunora/mail`'s
64
+ **capture transport** automatically: every send — including `@lunora/auth`'s
65
+ verification and forgot-password mail — is intercepted and surfaced in the
66
+ **Studio Mail tab**. Nothing leaves your machine and no provider setup is needed.
67
+
68
+ ## Step 3: Regenerate types
69
+
70
+ ```bash
71
+ lunora codegen
72
+ ```
73
+
74
+ The functions surface in the generated `api` as `api.mail.sendEmail` and
75
+ `api.mail.queueEmail`.
76
+
77
+ ## Step 4: Send mail
78
+
79
+ ### From another function
80
+
81
+ `sendEmail` is an **action** (sending is non-transactional network I/O). From a
82
+ mutation, schedule it as a follow-up so the request is not blocked:
83
+
84
+ ```ts
85
+ import { mutation, v } from "@lunora/server";
86
+
87
+ import { api } from "./_generated/api";
88
+
89
+ export const inviteUser = mutation.input({ email: v.string() }).mutation(async ({ ctx, args: { email } }) => {
90
+ // ...persist the invite, then send the mail as a follow-up action
91
+ await ctx.scheduler.runAfter(0, api.mail.sendEmail, {
92
+ to: email,
93
+ subject: "You're invited",
94
+ html: "<p>Click the link to join.</p>",
95
+ });
96
+ });
97
+ ```
98
+
99
+ ### From a client
100
+
101
+ ```ts
102
+ await client.action("mail/sendEmail", {
103
+ to: "alice@example.com",
104
+ subject: "Welcome",
105
+ text: "Thanks for signing up!",
106
+ });
107
+ ```
108
+
109
+ ### With a React email template
110
+
111
+ React elements are not JSON-serializable across the RPC boundary, so the
112
+ `sendEmail` args take `html` / `text`. To use a `react-email` template, render it
113
+ where you call the mailer — edit `lunora/mail/index.ts` to pass `react` straight
114
+ into `mailer.send`:
115
+
116
+ ```ts
117
+ import { createMailer } from "@lunora/mail";
118
+ import { env } from "cloudflare:workers";
119
+
120
+ import { WelcomeEmail } from "./emails/Welcome";
121
+
122
+ await createMailer({ apiKey: env.RESEND_API_KEY as string, from: env.MAIL_FROM as string }).send({
123
+ to: "alice@example.com",
124
+ subject: "Welcome",
125
+ react: <WelcomeEmail name="Alice" />,
126
+ });
127
+ ```
128
+
129
+ ## Common Pitfalls
130
+
131
+ 1. **Expecting prod email to "just work".** Dev captures into the Studio;
132
+ production needs the `SEND_EMAIL` binding (a verified destination) or
133
+ `RESEND_API_KEY`.
134
+ 2. **Calling `sendEmail` as a query/mutation.** It is an action — invoke it via
135
+ `ctx.runAction` / `ctx.scheduler.runAfter` / `client.action`, never `ctx.db`.
136
+ 3. **Using `queueEmail` without a Queue binding.** It requires a Cloudflare
137
+ Queue producer binding; until you add one, `@lunora/mail` throws
138
+ `` `queue` binding is required for mailer.queue() ``. The item does not add
139
+ the Queue for you — see the `mail` README's "Queueing" section.
140
+ 4. **Passing a React element through the action args.** Render it inside the
141
+ mailer (`mailer.send({ react })`), not across the RPC boundary.
142
+
143
+ ## Checklist
144
+
145
+ - [ ] `lunora registry add mail` run, `pnpm install` done.
146
+ - [ ] `SEND_EMAIL` binding configured (verified destination) or
147
+ `RESEND_API_KEY` set; `MAIL_FROM` set.
148
+ - [ ] `lunora codegen` run so `api.mail.*` is generated.
149
+ - [ ] Mail sent from a function (`ctx.scheduler.runAfter`/`ctx.runAction` with
150
+ `api.mail.sendEmail`) or the client (`client.action("mail/sendEmail", …)`).
151
+ - [ ] Verified the send appears in the Studio Mail tab in dev.
@@ -0,0 +1,157 @@
1
+ ---
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.
4
+ ---
5
+
6
+ # Lunora Setup Scheduler
7
+
8
+ Schedule work in Lunora two ways, both backed by `@lunora/scheduler` (re-exported
9
+ from `@lunora/server`):
10
+
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`.
15
+
16
+ Both run jobs through the `SchedulerDO` Durable Object (binding `SCHEDULER`),
17
+ which owns the alarm and durable storage.
18
+
19
+ ## When to Use
20
+
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).
25
+
26
+ ## When Not to Use
27
+
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.
31
+
32
+ ## Deferred dispatch — `runAfter` / `runAt`
33
+
34
+ Available on `ctx.scheduler` in any function. Target functions are passed by
35
+ reference from the generated `api` / `internal` proxy:
36
+
37
+ ```ts
38
+ import { mutation, v } from "@lunora/server";
39
+
40
+ import { internal } from "./_generated/api";
41
+
42
+ export const startTrial = mutation.input({ userId: v.string() }).mutation(async ({ ctx, args: { userId } }) => {
43
+ // run an internal action 14 days from now
44
+ const { id } = await ctx.scheduler.runAfter(14 * 24 * 60 * 60 * 1000, internal.billing.endTrial, { userId });
45
+
46
+ return { jobId: id };
47
+ });
48
+ ```
49
+
50
+ - `runAfter(delayMs, fnRef, args, options?)` — run after a delay (`delayMs` must
51
+ be a non-negative finite number). `runAt(date, fnRef, args, options?)` — run at
52
+ a `Date` or epoch-ms timestamp.
53
+ - Both return `{ id, scheduledFor }`. Cancel with `ctx.scheduler.cancel(id)`;
54
+ inspect with `ctx.scheduler.get(id)` / `ctx.scheduler.list()`.
55
+ - `options` accepts a `retry` policy (`{ maxAttempts, backoff, baseMs, maxMs }`;
56
+ DO defaults: `maxAttempts: 5`, `backoff: "exponential"`, `baseMs: 30_000`) and
57
+ a `shardKey` routing hint. On retry exhaustion the job is dead-lettered, never
58
+ silently dropped.
59
+ - The `SchedulerDO` binding (`SCHEDULER`) is **auto-inferred and reconciled**
60
+ into `wrangler.jsonc` by `@lunora/config` once `@lunora/scheduler` is in use —
61
+ run `lunora codegen` / `lunora doctor` to confirm. Scheduled jobs run with no
62
+ end-user identity, so target **internal** functions (`internal.*`).
63
+
64
+ ## Recurring jobs — crons
65
+
66
+ ### Step 1: Add the starter
67
+
68
+ ```bash
69
+ lunora registry add crons
70
+ ```
71
+
72
+ This adds `@lunora/server` to `package.json` (run `pnpm install`) and copies
73
+ `lunora/crons.ts` (a `cronJobs()` registry with one illustrative job) and
74
+ `lunora/crons/jobs.ts` (the example `run` internal mutation it fires) into your
75
+ project — both **yours** to edit. No extra DO binding is required.
76
+
77
+ ### Step 2: Declare jobs
78
+
79
+ ```ts
80
+ import { cronJobs } from "@lunora/server";
81
+
82
+ import { internal } from "./_generated/api";
83
+
84
+ const crons = cronJobs();
85
+
86
+ crons.interval("sweep presence", { minutes: 5 }, internal.presence.sweep, { roomId: "lobby" });
87
+ crons.daily("digest", { hourUTC: 9, minuteUTC: 0 }, internal.email.digest, {});
88
+ crons.weekly("report", { dayOfWeek: "monday", hourUTC: 8, minuteUTC: 0 }, internal.reports.weekly, {});
89
+ crons.monthly("invoice", { day: 1, hourUTC: 0, minuteUTC: 0 }, internal.billing.invoice, {});
90
+ crons.cron("custom", "0 */6 * * *", internal.foo.bar, {}); // raw cron escape hatch
91
+
92
+ export default crons;
93
+ ```
94
+
95
+ - `name` must be a non-empty **string literal**, unique across the project.
96
+ - `fnRef` must be a static two-segment access on the proxy
97
+ (`internal.<file>.<fn>` or `api.<file>.<fn>`) so codegen can discover it.
98
+ Cron targets must be **internal** functions — a client can never invoke them.
99
+ - All schedules are UTC and validated at definition time
100
+ (`hourUTC: 25` throws immediately).
101
+
102
+ ### Step 3: Regenerate types and the schedule
103
+
104
+ ```bash
105
+ lunora codegen
106
+ ```
107
+
108
+ Codegen discovers each registration by AST, compiles the schedule to a cron
109
+ expression, and emits `lunora/_generated/crons.ts` (the dispatcher the Worker's
110
+ `scheduled()` handler consumes) plus the matching `triggers.crons` entry in
111
+ `wrangler.jsonc`. You never hand-edit the wrangler schedule array.
112
+
113
+ ## Bounded concurrency — workpool (optional)
114
+
115
+ For many enqueued jobs that must not all run at once, use a workpool — a named
116
+ logical pool inside the same `SchedulerDO` (no extra binding):
117
+
118
+ ```ts
119
+ import { createWorkpool } from "@lunora/scheduler";
120
+
121
+ const pool = createWorkpool({
122
+ namespace: env.SCHEDULER,
123
+ originUrl: "https://my-app.example.com",
124
+ name: "imports",
125
+ maxConcurrency: 3,
126
+ });
127
+
128
+ await pool.enqueue(internal.imports.processRow, { rowId });
129
+ ```
130
+
131
+ The DO caps simultaneous dispatch at `maxConcurrency` and queues the rest
132
+ durably. A Cloudflare-Queues-backed variant (`createQueueWorkpool`) leans on
133
+ queue config for concurrency/retries instead — reach for the DO workpool when you
134
+ need per-job cancel / status.
135
+
136
+ ## Common Pitfalls
137
+
138
+ 1. **Targeting a non-internal function from a job/cron.** Scheduled dispatch has
139
+ no end-user identity — target `internal.*` functions.
140
+ 2. **Non-static `fnRef` or `name` in `cronJobs()`.** Codegen discovers them by
141
+ AST; a dynamic reference or computed name can't be found.
142
+ 3. **Forgetting `lunora codegen` after editing crons.** The `triggers.crons`
143
+ array and the dispatcher map are codegen output — re-run it.
144
+ 4. **Non-idempotent job handlers.** A missed tick may be retried and a slow tick
145
+ can overlap the next — make handlers idempotent.
146
+
147
+ ## Checklist
148
+
149
+ - [ ] Deferred work uses `ctx.scheduler.runAfter` / `runAt` against
150
+ `internal.*` functions.
151
+ - [ ] `SCHEDULER` (SchedulerDO) binding present in `wrangler.jsonc`
152
+ (`lunora doctor` clean) when using the scheduler.
153
+ - [ ] Recurring jobs declared in `lunora/crons.ts` via `cronJobs()` (after
154
+ `lunora registry add crons`).
155
+ - [ ] `lunora codegen` run so `_generated/crons.ts` + `triggers.crons` are
156
+ synced.
157
+ - [ ] Job handlers are idempotent.
@@ -0,0 +1,158 @@
1
+ ---
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.
4
+ ---
5
+
6
+ # Lunora Setup Storage
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 direct browser uploads, gated
11
+ downloads, delete, and list — so the bytes never proxy through your Worker.
12
+
13
+ ## When to Use
14
+
15
+ - Uploading user files (avatars, attachments) straight to R2.
16
+ - Serving private/gated downloads via short-lived signed URLs.
17
+ - Listing or deleting a caller's stored objects.
18
+
19
+ ## When Not to Use
20
+
21
+ - The project has no Lunora backend yet — use `lunora-quickstart` first.
22
+ - Storage is already installed and you just want to upload — call
23
+ `client.action("storage/generateUploadUrl", …)` and `PUT` to the returned URL.
24
+
25
+ ## Workflow
26
+
27
+ 1. Add the `storage` item.
28
+ 2. Configure the `UPLOADS` R2 bucket binding and the signing secret.
29
+ 3. Regenerate types with `lunora codegen`.
30
+ 4. Verify signed downloads in the Worker's `GET /storage/:key` route.
31
+ 5. Upload/download from the client.
32
+
33
+ ## Step 1: Add the item
34
+
35
+ ```bash
36
+ lunora registry add storage
37
+ ```
38
+
39
+ This:
40
+
41
+ 1. Adds `@lunora/storage` and `@lunora/server` to `package.json` (run
42
+ `pnpm install` afterwards).
43
+ 2. Adds an R2 bucket binding to `wrangler.jsonc` (`r2_buckets`, binding
44
+ **`UPLOADS`**, `bucket_name: "replace-me-uploads"` — rename it to a real
45
+ bucket). It **merges** into any existing `r2_buckets`.
46
+ 3. Scaffolds `STORAGE_SIGNING_SECRET` (a secret) and `STORAGE_PUBLIC_BASE_URL`
47
+ into `.dev.vars`.
48
+ 4. Copies `lunora/storage/index.ts` (the `generateUploadUrl` /
49
+ `getDownloadUrl` / `deleteObject` / `listObjects` functions) into your
50
+ project — it is **yours** to edit.
51
+
52
+ ## Step 2: Configure the binding + secrets
53
+
54
+ | Name | Where | Notes |
55
+ | ------------------------- | ------------------------------------ | ------------------------------------------------------------------------ |
56
+ | `UPLOADS` | `wrangler.jsonc` → `r2_buckets[]` | The R2 bucket binding. Point `bucket_name` at a real bucket. |
57
+ | `STORAGE_SIGNING_SECRET` | secret (`.dev.vars` / `secret put`) | HMAC secret for signed URLs. Min 32 chars; never share across buckets. |
58
+ | `STORAGE_PUBLIC_BASE_URL` | var (`.dev.vars` / `wrangler.jsonc`) | Public host/route that fronts the bucket and serves `GET /storage/:key`. |
59
+
60
+ Generate a real signing secret with `openssl rand -base64 32` and write it with
61
+ `wrangler secret put STORAGE_SIGNING_SECRET` for production.
62
+
63
+ ## Step 3: Regenerate types
64
+
65
+ ```bash
66
+ lunora codegen
67
+ ```
68
+
69
+ The functions surface in the generated `api` as `api.storage.generateUploadUrl`,
70
+ `api.storage.getDownloadUrl`, `api.storage.deleteObject`, and
71
+ `api.storage.listObjects`.
72
+
73
+ ## Step 4: Verify downloads in the Worker
74
+
75
+ Signed URLs are only as safe as the route that checks them. Gate
76
+ `GET /storage/:key` with `verifySignedUrl` before streaming the R2 body
77
+ (`@lunora/server` also ships `serveStorageObject` to do this):
78
+
79
+ ```ts
80
+ import { verifySignedUrl } from "@lunora/storage";
81
+
82
+ export default {
83
+ async fetch(request: Request, env: Env): Promise<Response> {
84
+ const url = new URL(request.url);
85
+
86
+ if (url.pathname.startsWith("/storage/")) {
87
+ const result = await verifySignedUrl(url, env.STORAGE_SIGNING_SECRET);
88
+
89
+ if (!result.valid || result.key === undefined) {
90
+ // Expose only `valid` — a precise reason is a signing oracle.
91
+ return new Response("forbidden", { status: 403 });
92
+ }
93
+
94
+ const object = await env.UPLOADS.get(result.key);
95
+
96
+ if (!object) {
97
+ return new Response("not found", { status: 404 });
98
+ }
99
+
100
+ return new Response(object.body, {
101
+ headers: { "content-type": object.httpMetadata?.contentType ?? "application/octet-stream" },
102
+ });
103
+ }
104
+
105
+ // ... your Lunora handler
106
+ return new Response("not found", { status: 404 });
107
+ },
108
+ };
109
+ ```
110
+
111
+ `verifySignedUrl` checks expiry, then the HMAC. On a host-rewrite / CDN topology
112
+ pass `{ expectedHost }` (the `STORAGE_PUBLIC_BASE_URL` host) so the signature
113
+ canonicalizes against the host it was minted for.
114
+
115
+ ## Step 5: Upload / download from the client
116
+
117
+ ```ts
118
+ // 1. ask the server for a signed PUT URL
119
+ const { key, url } = await client.action("storage/generateUploadUrl", {
120
+ key: "avatar.png",
121
+ contentType: file.type,
122
+ });
123
+
124
+ // 2. upload straight to R2 (no Worker proxy)
125
+ await fetch(url, { method: "PUT", headers: { "content-type": file.type }, body: file });
126
+
127
+ // 3. later, get a signed GET URL to display it
128
+ const { url: downloadUrl } = await client.action("storage/getDownloadUrl", { key: "avatar.png" });
129
+ ```
130
+
131
+ Every key is scoped per-tenant with `scopeKey(tenantPrefix(ctx.auth.userId),
132
+ key)`, so a client-supplied key can never address another user's data. The
133
+ functions return the **scoped** key (`<userId>/avatar.png`) alongside the URL;
134
+ persist that, and pass the bare key back in — the component re-scopes it.
135
+
136
+ ## Common Pitfalls
137
+
138
+ 1. **Skipping `verifySignedUrl` on the download route.** Without it, anyone can
139
+ read any key. Always verify before streaming.
140
+ 2. **Placeholder bucket name.** `lunora init` and `lunora add storage` prompt for
141
+ the bucket name (or take `--bucket <name>`), but the low-level
142
+ `lunora registry add storage` writes the placeholder
143
+ `bucket_name: "replace-me-uploads"` — rename it to a real R2 bucket. (R2 names
144
+ are lowercase alphanumeric + hyphens, 3–63 chars; wrangler rejects anything
145
+ else on `dev`/`deploy`.)
146
+ 3. **Short / shared signing secret.** Use ≥32 chars and a distinct secret per
147
+ bucket; reusing it lets one bucket's URLs sign for another.
148
+ 4. **Proxying bytes through the Worker.** The design uploads/downloads directly
149
+ to R2 via signed URLs — don't re-route the file body through a function.
150
+
151
+ ## Checklist
152
+
153
+ - [ ] `lunora registry add storage` run, `pnpm install` done.
154
+ - [ ] `UPLOADS` bucket bound to a real bucket; `STORAGE_SIGNING_SECRET` (≥32
155
+ chars) and `STORAGE_PUBLIC_BASE_URL` set.
156
+ - [ ] `lunora codegen` run so `api.storage.*` is generated.
157
+ - [ ] `GET /storage/:key` route verifies signed URLs before streaming.
158
+ - [ ] Verified a client upload → signed download round-trip.