@lunora/cli 1.0.0-alpha.22 → 1.0.0-alpha.221

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 (116) hide show
  1. package/LICENSE.md +33 -0
  2. package/README.md +1 -1
  3. package/__assets__/package-og.svg +1 -1
  4. package/dist/bin.mjs +2 -10
  5. package/dist/index.d.mts +786 -341
  6. package/dist/index.d.ts +786 -341
  7. package/dist/index.mjs +1 -19
  8. package/dist/packem_chunks/handler.mjs +1 -150
  9. package/dist/packem_chunks/handler10.mjs +4 -22
  10. package/dist/packem_chunks/handler11.mjs +2 -192
  11. package/dist/packem_chunks/handler12.mjs +1 -131
  12. package/dist/packem_chunks/handler13.mjs +1 -65
  13. package/dist/packem_chunks/handler14.mjs +1 -58
  14. package/dist/packem_chunks/handler15.mjs +1 -79
  15. package/dist/packem_chunks/handler16.mjs +3 -43
  16. package/dist/packem_chunks/handler17.mjs +1 -105
  17. package/dist/packem_chunks/handler18.mjs +1 -170
  18. package/dist/packem_chunks/handler19.mjs +7 -89
  19. package/dist/packem_chunks/handler2.mjs +2 -114
  20. package/dist/packem_chunks/handler20.mjs +1 -94
  21. package/dist/packem_chunks/handler21.mjs +3 -311
  22. package/dist/packem_chunks/handler22.mjs +3 -0
  23. package/dist/packem_chunks/handler23.mjs +1 -0
  24. package/dist/packem_chunks/handler24.mjs +2 -0
  25. package/dist/packem_chunks/handler25.mjs +99 -0
  26. package/dist/packem_chunks/handler26.mjs +10 -0
  27. package/dist/packem_chunks/handler3.mjs +1 -204
  28. package/dist/packem_chunks/handler4.mjs +1 -33
  29. package/dist/packem_chunks/handler5.mjs +1 -49
  30. package/dist/packem_chunks/handler6.mjs +1 -91
  31. package/dist/packem_chunks/handler7.mjs +3 -42
  32. package/dist/packem_chunks/handler8.mjs +1 -174
  33. package/dist/packem_chunks/handler9.mjs +1 -16
  34. package/dist/packem_chunks/planDevCommand.mjs +7 -509
  35. package/dist/packem_chunks/runCodegenCommand.mjs +4 -52
  36. package/dist/packem_chunks/runDeployCommand.mjs +1 -504
  37. package/dist/packem_chunks/runInitCommand.mjs +149 -1388
  38. package/dist/packem_chunks/runMigrateGenerateCommand.mjs +9 -395
  39. package/dist/packem_chunks/runResetCommand.mjs +1 -41
  40. package/dist/packem_chunks/runRpcCommand.mjs +1 -68
  41. package/dist/packem_shared/COMMANDS-BbQ3-hLn.mjs +1 -0
  42. package/dist/packem_shared/DEFAULT_IMPORT_BATCH_SIZE-BPQYMEmV.mjs +1 -0
  43. package/dist/packem_shared/admin-token-VdUnvnKW.mjs +1 -0
  44. package/dist/packem_shared/admin-url-BhF5ufg1.mjs +1 -0
  45. package/dist/packem_shared/advisory-gate-Cl8IEFIj.mjs +1 -0
  46. package/dist/packem_shared/binding-manifest-file-CQ1eQYy1.mjs +2 -0
  47. package/dist/packem_shared/buildRegistryIndex-DVmy8fHE.mjs +1 -0
  48. package/dist/packem_shared/catalog-Cbzph90w.mjs +1 -0
  49. package/dist/packem_shared/cli-DOBnG22n.mjs +3 -0
  50. package/dist/packem_shared/codegen-error-AmH54ofi.mjs +3 -0
  51. package/dist/packem_shared/command-DwC11Cwe.mjs +1 -0
  52. package/dist/packem_shared/commands-CLLQuHqU.mjs +15 -0
  53. package/dist/packem_shared/createLogger-hGafwf-3.mjs +2 -0
  54. package/dist/packem_shared/createRecordingSpawner-DJlNacZ6.mjs +1 -0
  55. package/dist/packem_shared/deploy-target-B0Xcb2jV.mjs +1 -0
  56. package/dist/packem_shared/diffSnapshots-DtC5Cs4Z.mjs +5 -0
  57. package/dist/packem_shared/docker-DpVxvYpL.mjs +1 -0
  58. package/dist/packem_shared/import-DuHtoh4_.mjs +12 -0
  59. package/dist/packem_shared/insertSchemaExtension-DY4pA8wE.mjs +8 -0
  60. package/dist/packem_shared/lint-ignore-report-DKZagpqk.mjs +2 -0
  61. package/dist/packem_shared/open-url-EnKy--w-.mjs +1 -0
  62. package/dist/packem_shared/output-format-DfogXx0p.mjs +2 -0
  63. package/dist/packem_shared/parseManifest-CwPTKdtS.mjs +1 -0
  64. package/dist/packem_shared/path-containment-CgxYZggb.mjs +1 -0
  65. package/dist/packem_shared/platform-diagnostics-D0YzAdXm.mjs +4 -0
  66. package/dist/packem_shared/prompt-cancelled-C8Sj7OqI.mjs +1 -0
  67. package/dist/packem_shared/resolve-BWFT7RvE.mjs +5 -0
  68. package/dist/packem_shared/resolve-target-BRvw8Pf7.mjs +1 -0
  69. package/dist/packem_shared/runAddCommand-BABRWlOb.mjs +1 -0
  70. package/dist/packem_shared/runExportCommand-Dq-SpjAe.mjs +5 -0
  71. package/dist/packem_shared/schema-drift-gate-BDCkQ1S6.mjs +1 -0
  72. package/dist/packem_shared/schemaIrToSnapshot-VpPguG3W.mjs +1 -0
  73. package/dist/packem_shared/shared-D-zCOmgY.mjs +1 -0
  74. package/dist/packem_shared/storage-DlUnv7uG.mjs +1 -0
  75. package/dist/packem_shared/tui-prompts-Dd0pDuNr.mjs +4 -0
  76. package/dist/packem_shared/vectorize-metadata-dXpl2_Ar.mjs +1 -0
  77. package/dist/packem_shared/wrangler-name-Dsk5K1f-.mjs +1 -0
  78. package/dist/packem_shared/wrangler-secrets-CCdt0mUl.mjs +1 -0
  79. package/package.json +39 -17
  80. package/skills/README.md +35 -17
  81. package/skills/lunora/SKILL.md +65 -9
  82. package/skills/lunora-deploy/SKILL.md +42 -11
  83. package/skills/lunora-functions/SKILL.md +78 -15
  84. package/skills/lunora-migration-helper/SKILL.md +74 -21
  85. package/skills/lunora-performance-audit/SKILL.md +42 -9
  86. package/skills/lunora-quickstart/SKILL.md +93 -27
  87. package/skills/lunora-realtime/SKILL.md +58 -30
  88. package/skills/lunora-setup-auth/SKILL.md +23 -6
  89. package/skills/lunora-setup-hyperdrive/SKILL.md +33 -13
  90. package/skills/lunora-setup-mail/SKILL.md +34 -28
  91. package/skills/lunora-setup-scheduler/SKILL.md +18 -13
  92. package/skills/lunora-setup-storage/SKILL.md +182 -25
  93. package/dist/packem_shared/COMMANDS-D3h9Iwvl.mjs +0 -944
  94. package/dist/packem_shared/DEFAULT_IMPORT_BATCH_SIZE-Ck-2bU08.mjs +0 -244
  95. package/dist/packem_shared/admin-url-4UzT-CI4.mjs +0 -19
  96. package/dist/packem_shared/api-spec-CtA6ilu4.mjs +0 -13
  97. package/dist/packem_shared/buildRegistryIndex-BcYe607_.mjs +0 -38
  98. package/dist/packem_shared/command-BC30oSBW.mjs +0 -14
  99. package/dist/packem_shared/commands-hl0mRqqg.mjs +0 -845
  100. package/dist/packem_shared/createLogger-B40gPzQo.mjs +0 -78
  101. package/dist/packem_shared/createRecordingSpawner-DxI3mebw.mjs +0 -43
  102. package/dist/packem_shared/detect-package-manager-DYp7n3mJ.mjs +0 -61
  103. package/dist/packem_shared/diffSnapshots-BeDvvNiF.mjs +0 -161
  104. package/dist/packem_shared/docker-hMQ97KSQ.mjs +0 -21
  105. package/dist/packem_shared/insertSchemaExtension-DAqbfr9Z.mjs +0 -64
  106. package/dist/packem_shared/open-url-Dfq6fAyT.mjs +0 -41
  107. package/dist/packem_shared/output-format-wUvAN6AL.mjs +0 -17
  108. package/dist/packem_shared/parseArgs-YXFuKdEk.mjs +0 -56
  109. package/dist/packem_shared/parseManifest--vZf2FY1.mjs +0 -94
  110. package/dist/packem_shared/resolve-target-qbsJ_5sF.mjs +0 -16
  111. package/dist/packem_shared/runAddCommand-vJdgiR5t.mjs +0 -4
  112. package/dist/packem_shared/schema-drift-gate-BtBt0as0.mjs +0 -79
  113. package/dist/packem_shared/schemaIrToSnapshot-DdsljJT-.mjs +0 -43
  114. package/dist/packem_shared/storage-B7hHSTZP.mjs +0 -84
  115. package/dist/packem_shared/tui-prompts-M6OWsuyw.mjs +0 -663
  116. package/dist/packem_shared/wrangler-name-cy4yhm9j.mjs +0 -12
@@ -92,24 +92,41 @@ that's a remote resource only `wrangler hyperdrive create` can mint. Importing
92
92
  lunora codegen
93
93
  ```
94
94
 
95
- When codegen sees `ctx.sql` used, it adds `sql: SqlClient` to **`ActionCtx`
96
- only** — never `QueryCtx`/`MutationCtx` — with a JSDoc restating the
97
- determinism/realtime caveat.
95
+ When codegen sees `ctx.sql` used, it adds `readonly sql: SqlClient` to
96
+ **`ActionCtx` only** — never `QueryCtx`/`MutationCtx` — with a JSDoc restating
97
+ the determinism/realtime caveat, and emits a `.hyperdrive()` method on the
98
+ generated app builder for you to supply the client (step 4).
98
99
 
99
- ## Step 4: Use `ctx.sql` from an action
100
+ ## Step 4: Wire the client once, on the app builder
100
101
 
101
- ```ts
102
+ `ctx.sql` is `readonly` — assigning to it inside a handler is a `TS2540`.
103
+ Codegen cannot build the client for you (turning a connection string into a
104
+ `SqlClient` needs the driver you chose), so it emits a config thunk you fill in
105
+ at the app level. It is called once per shard construction, not per request:
106
+
107
+ ```ts title="src/server/index.ts"
108
+ import type { HyperdriveLike } from "@lunora/hyperdrive";
102
109
  import { createHyperdrive, fromPostgresJs } from "@lunora/hyperdrive";
103
110
  import postgres from "postgres";
104
111
 
105
- import { action, v } from "@lunora/server";
112
+ import { defineApp } from "../lunora/_generated/app.js";
113
+
114
+ const app = defineApp<Env>()
115
+ .shard((env) => env.SHARD)
116
+ .hyperdrive((env) => fromPostgresJs(postgres(createHyperdrive(env.HYPERDRIVE as HyperdriveLike).connectionString)))
117
+ .build();
118
+
119
+ export const ShardDO = app.ShardDO;
120
+ ```
121
+
122
+ Then read it from any action:
106
123
 
107
- export const listLegacyOrders = action.input({ orgId: v.string() }).action(async ({ ctx, args: { orgId } }) => {
108
- const { connectionString } = createHyperdrive(ctx.env.HYPERDRIVE);
109
- ctx.sql = fromPostgresJs(postgres(connectionString));
124
+ ```ts
125
+ import { action, v } from "@/lunora/_generated/server";
110
126
 
111
- return ctx.sql.query<{ id: string; total: number }>("select id, total from orders where org = $1", [orgId]);
112
- });
127
+ export const listLegacyOrders = action
128
+ .input({ orgId: v.string() })
129
+ .action(async ({ ctx, args: { orgId } }) => ctx.sql.query<{ id: string; total: number }>("select id, total from orders where org = $1", [orgId]));
113
130
  ```
114
131
 
115
132
  The package never rewrites SQL — use your driver's native placeholders (`$1` for
@@ -122,10 +139,13 @@ Postgres change. To make external data reactive, write a projection into a
122
139
  `defineSchema` table from the same action — that write _is_ tracked:
123
140
 
124
141
  ```ts
142
+ import { api } from "@/lunora/_generated/api";
143
+
125
144
  const [row] = await ctx.sql.query<{ id: string; total: number }>("select id, total from orders where id = $1", [id]);
126
145
 
127
- // This write re-runs live queries reading `orders`:
128
- await ctx.runMutation("orders:upsert", { id: row.id, total: row.total });
146
+ // This write re-runs live queries reading `orders`. Pass the generated
147
+ // reference — `ctx.run*` takes a reference, not a "file:fn" string.
148
+ await ctx.runMutation(api.orders.upsert, { id: row.id, total: row.total });
129
149
  ```
130
150
 
131
151
  ## Common Pitfalls
@@ -7,8 +7,9 @@ description: Adds transactional email to a Lunora app. Use for sending mail (ver
7
7
 
8
8
  Wire transactional email into a Lunora app using the `mail` registry item, which
9
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
10
+ header-injection-safe address handling) and exposes a `sendEmail` `internalAction`
11
+ plus a fire-and-forget `queueEmail` `internalAction` server-only, because a
12
+ client-callable general-purpose mailer is an open relay. In dev, every send is captured into the
12
13
  Studio Mail tab instead of going out.
13
14
 
14
15
  ## When to Use
@@ -21,14 +22,14 @@ Studio Mail tab instead of going out.
21
22
 
22
23
  - The project has no Lunora backend yet — use `lunora-quickstart` first.
23
24
  - Mail is already installed and you just want to send — call
24
- `ctx.runAction(api.mail.sendEmail, …)` or `client.action("mail/sendEmail", …)`.
25
+ `ctx.runAction(internal.mail.sendEmail, …)` from a server handler.
25
26
 
26
27
  ## Workflow
27
28
 
28
29
  1. Add the `mail` item.
29
30
  2. Configure the `SEND_EMAIL` binding (or a provider) and `MAIL_FROM`.
30
31
  3. Regenerate types with `lunora codegen`.
31
- 4. Send mail from a function (or the client); render a React template if needed.
32
+ 4. Send mail from a server function; render a React template if needed.
32
33
 
33
34
  ## Step 1: Add the item
34
35
 
@@ -40,8 +41,8 @@ This:
40
41
 
41
42
  1. Adds `@lunora/mail` and `@lunora/server` to `package.json` (run
42
43
  `pnpm install` afterwards).
43
- 2. Copies `lunora/mail/index.ts` (the `sendEmail` / `queueEmail` actions) into
44
- your project — it is **yours** to edit.
44
+ 2. Copies `lunora/mail/index.ts` (the `sendEmail` / `queueEmail`
45
+ **`internalAction`s**) into your project — it is **yours** to edit.
45
46
  3. Adds a `send_email` binding (`SEND_EMAIL`, with a `destination_address`
46
47
  placeholder) to `wrangler.jsonc` and scaffolds `MAIL_FROM` (the default
47
48
  sender) into `.dev.vars`.
@@ -71,24 +72,27 @@ verification and forgot-password mail — is intercepted and surfaced in the
71
72
  lunora codegen
72
73
  ```
73
74
 
74
- The functions surface in the generated `api` as `api.mail.sendEmail` and
75
- `api.mail.queueEmail`.
75
+ The functions surface in the generated **`internal`** (server-only) namespace as
76
+ `internal.mail.sendEmail` and `internal.mail.queueEmail` — they are deliberately
77
+ **not** in the client-reachable `api`.
76
78
 
77
79
  ## Step 4: Send mail
78
80
 
79
81
  ### From another function
80
82
 
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
+ `sendEmail` is an **`internalAction`** (sending is non-transactional network
84
+ I/O). From a mutation, schedule it as a follow-up so the request is not blocked:
83
85
 
84
86
  ```ts
85
- import { mutation, v } from "@lunora/server";
87
+ import { internalMutation, v } from "#lunora/_generated/server.js";
86
88
 
87
- import { api } from "./_generated/api";
89
+ import { internal } from "./_generated/api";
88
90
 
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, {
91
+ export const inviteUser = internalMutation.input({ email: v.string() }).mutation(async ({ ctx, args: { email } }) => {
92
+ // ...authenticate the caller and persist the invite, then send the mail as a
93
+ // follow-up action. The recipient is decided server-side — never forward a
94
+ // client-chosen `to`/`from`/`html` straight through.
95
+ await ctx.scheduler.runAfter(0, internal.mail.sendEmail, {
92
96
  to: email,
93
97
  subject: "You're invited",
94
98
  html: "<p>Click the link to join.</p>",
@@ -96,15 +100,15 @@ export const inviteUser = mutation.input({ email: v.string() }).mutation(async (
96
100
  });
97
101
  ```
98
102
 
99
- ### From a client
103
+ ### Not from a client
100
104
 
101
- ```ts
102
- await client.action("mail/sendEmail", {
103
- to: "alice@example.com",
104
- subject: "Welcome",
105
- text: "Thanks for signing up!",
106
- });
107
- ```
105
+ There is no `client.action("mail/sendEmail", …)` path, and adding one is the
106
+ mistake this item exists to prevent: a general-purpose mailer that lets the
107
+ caller pick recipient, subject and body is an open relay for phishing through
108
+ your verified domain. If you need a client-callable send, write a
109
+ _purpose-specific_ public `action` that takes only safe business inputs (e.g.
110
+ `{ orderId }`), checks `ctx.auth`/RBAC, derives the recipient server-side,
111
+ rate-limits it (`@lunora/ratelimit`), and calls `internal.mail.sendEmail`.
108
112
 
109
113
  ### With a React email template
110
114
 
@@ -131,8 +135,10 @@ await createMailer({ apiKey: env.RESEND_API_KEY as string, from: env.MAIL_FROM a
131
135
  1. **Expecting prod email to "just work".** Dev captures into the Studio;
132
136
  production needs the `SEND_EMAIL` binding (a verified destination) or
133
137
  `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`.
138
+ 2. **Calling `sendEmail` from the client, or as a query/mutation.** It is an
139
+ `internalAction` — invoke it via `ctx.runAction` / `ctx.scheduler.runAfter`
140
+ from a server handler. A client `client.action("mail/sendEmail", …)` is not
141
+ reachable and answers `FUNCTION_NOT_FOUND`.
136
142
  3. **Using `queueEmail` without a Queue binding.** It requires a Cloudflare
137
143
  Queue producer binding; until you add one, `@lunora/mail` throws
138
144
  `` `queue` binding is required for mailer.queue() ``. The item does not add
@@ -145,7 +151,7 @@ await createMailer({ apiKey: env.RESEND_API_KEY as string, from: env.MAIL_FROM a
145
151
  - [ ] `lunora registry add mail` run, `pnpm install` done.
146
152
  - [ ] `SEND_EMAIL` binding configured (verified destination) or
147
153
  `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", …)`).
154
+ - [ ] `lunora codegen` run so `internal.mail.*` is generated.
155
+ - [ ] Mail sent from a server function (`ctx.scheduler.runAfter` / `ctx.runAction`
156
+ with `internal.mail.sendEmail`) never from the client.
151
157
  - [ ] Verified the send appears in the Studio Mail tab in dev.
@@ -31,31 +31,36 @@ which owns the alarm and durable storage.
31
31
 
32
32
  ## Deferred dispatch — `runAfter` / `runAt`
33
33
 
34
- Available on `ctx.scheduler` in any function. Target functions are passed by
35
- reference from the generated `api` / `internal` proxy:
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` proxy (a `"file:fn"` path string also
37
+ works):
36
38
 
37
39
  ```ts
38
- import { mutation, v } from "@lunora/server";
40
+ import { mutation, v } from "#lunora/_generated/server.js";
39
41
 
40
42
  import { internal } from "./_generated/api";
41
43
 
42
44
  export const startTrial = mutation.input({ userId: v.string() }).mutation(async ({ ctx, args: { userId } }) => {
43
45
  // run an internal action 14 days from now
44
- const { id } = await ctx.scheduler.runAfter(14 * 24 * 60 * 60 * 1000, internal.billing.endTrial, { userId });
46
+ const jobId = await ctx.scheduler.runAfter(14 * 24 * 60 * 60 * 1000, internal.billing.endTrial, { userId });
45
47
 
46
- return { jobId: id };
48
+ return { jobId };
47
49
  });
48
50
  ```
49
51
 
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)`;
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)`;
54
56
  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.
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.
59
64
  - The `SchedulerDO` binding (`SCHEDULER`) is **auto-inferred and reconciled**
60
65
  into `wrangler.jsonc` by `@lunora/config` once `@lunora/scheduler` is in use —
61
66
  run `lunora codegen` / `lunora doctor` to confirm. Scheduled jobs run with no
@@ -7,12 +7,19 @@ description: Adds R2-backed file storage to a Lunora app. Use for uploads/downlo
7
7
 
8
8
  Wire R2-backed file storage into a Lunora app using the `storage` registry item,
9
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.
10
+ and exposes idiomatic Lunora functions for browser uploads, gated downloads,
11
+ delete, and list — with no bucket credential in the client.
12
+
13
+ A worker-signed URL points at **your Worker**, not at R2:
14
+ `${STORAGE_PUBLIC_BASE_URL}/<key>?exp&method&bucket&sig`. The `/storage/*` route
15
+ you add in step 4 is what verifies the signature and moves the bytes, for both
16
+ the upload and the download. (The no-Worker-in-the-path variant is
17
+ `@lunora/storage`'s S3 presigned URL, `getPresignedUrl` — it needs S3 credentials
18
+ on the bucket and enforces none of your rules.)
12
19
 
13
20
  ## When to Use
14
21
 
15
- - Uploading user files (avatars, attachments) straight to R2.
22
+ - Uploading user files (avatars, attachments) into R2 under your own gate.
16
23
  - Serving private/gated downloads via short-lived signed URLs.
17
24
  - Listing or deleting a caller's stored objects.
18
25
 
@@ -27,7 +34,8 @@ downloads, delete, and list — so the bytes never proxy through your Worker.
27
34
  1. Add the `storage` item.
28
35
  2. Configure the `UPLOADS` R2 bucket binding and the signing secret.
29
36
  3. Regenerate types with `lunora codegen`.
30
- 4. Verify signed downloads in the Worker's `GET /storage/:key` route.
37
+ 4. Add the `/storage/*` route to the Worker it verifies signatures and serves
38
+ both the signed `PUT` and the signed `GET`.
31
39
  5. Upload/download from the client.
32
40
 
33
41
  ## Step 1: Add the item
@@ -51,11 +59,16 @@ This:
51
59
 
52
60
  ## Step 2: Configure the binding + secrets
53
61
 
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`. |
62
+ | Name | Where | Notes |
63
+ | ------------------------- | ------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------- |
64
+ | `UPLOADS` | `wrangler.jsonc` → `r2_buckets[]` | The R2 bucket binding. Point `bucket_name` at a real bucket. |
65
+ | `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. |
66
+ | `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. |
67
+
68
+ `STORAGE_PUBLIC_BASE_URL` must be `https://` anywhere but local dev. A signed URL
69
+ _is_ a bearer credential and the object bytes stream through it, so a plaintext
70
+ origin hands both to anyone on the path. Only `http://localhost` /
71
+ `http://127.0.0.1` belong in `.dev.vars`.
59
72
 
60
73
  Generate a real signing secret with `openssl rand -base64 32` and write it with
61
74
  `wrangler secret put STORAGE_SIGNING_SECRET` for production.
@@ -70,20 +83,87 @@ The functions surface in the generated `api` as `api.storage.generateUploadUrl`,
70
83
  `api.storage.getDownloadUrl`, `api.storage.deleteObject`, and
71
84
  `api.storage.listObjects`.
72
85
 
73
- ## Step 4: Verify downloads in the Worker
86
+ ## Step 4: Add the `/storage/*` route to the Worker
87
+
88
+ **Required, not optional.** Without it a minted URL hits the Lunora catch-all and
89
+ every upload and download 404s — and it is the only thing checking the signature,
90
+ so skipping the check lets anyone read any key.
91
+
92
+ `@lunora/server`'s `serveStorageObject(ctx, key, request, authorize)` handles
93
+ the download half (`Range`/206, `ETag`, `nosniff`, and
94
+ `content-disposition: attachment` for anything outside a small inline-safe set —
95
+ raster images plus `audio/mpeg`, `audio/ogg`, `audio/wav`, `video/mp4`,
96
+ `video/webm`, with `image/svg+xml` deliberately excluded). It verifies nothing on
97
+ its own — its required `authorize` gate is where `verifySignedUrl` goes — and it
98
+ does not handle the upload. Reach for it from an `httpAction`, where `ctx.storage`
99
+ is in scope, whenever you want `Range` seeking or conditional requests.
74
100
 
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):
101
+ The route below is the standalone version a plain worker `fetch` with only the
102
+ R2 binding to hand, so it serves whole objects and skips `Range`/`ETag`. Both
103
+ verbs, by hand:
78
104
 
79
105
  ```ts
106
+ import { isSafeHeaderValue } from "@lunora/server";
80
107
  import { verifySignedUrl } from "@lunora/storage";
81
108
 
109
+ /** Cap what a single signed PUT may store. */
110
+ const MAX_UPLOAD_BYTES = 25 * 1024 * 1024;
111
+
112
+ /**
113
+ * Origins allowed to upload cross-origin. Leave it empty when
114
+ * `STORAGE_PUBLIC_BASE_URL` is your app's own origin — then `cors` is inert and
115
+ * no browser ever preflights these routes.
116
+ */
117
+ const ALLOWED_ORIGINS = new Set(["https://app.example.com"]);
118
+
119
+ /**
120
+ * Types safe to render in the browser. Everything else downloads — an uploader
121
+ * who pinned `text/html` or `image/svg+xml` must never get a same-origin script.
122
+ * (`serveStorageObject` applies this same list.)
123
+ */
124
+ const INLINE_SAFE = new Set([
125
+ "audio/mpeg",
126
+ "audio/ogg",
127
+ "audio/wav",
128
+ "image/apng",
129
+ "image/avif",
130
+ "image/gif",
131
+ "image/jpeg",
132
+ "image/png",
133
+ "image/webp",
134
+ "video/mp4",
135
+ "video/webm",
136
+ ]);
137
+
82
138
  export default {
83
139
  async fetch(request: Request, env: Env): Promise<Response> {
84
140
  const url = new URL(request.url);
85
141
 
86
142
  if (url.pathname.startsWith("/storage/")) {
143
+ const origin = request.headers.get("origin");
144
+ // `vary` rides on EVERY response, allowed origin or not: a shared
145
+ // cache keyed on the URL alone would otherwise replay one origin's
146
+ // `access-control-allow-origin` to another.
147
+ const cors = {
148
+ vary: "origin",
149
+ ...(origin !== null && ALLOWED_ORIGINS.has(origin)
150
+ ? { "access-control-allow-headers": "content-type", "access-control-allow-methods": "GET, PUT", "access-control-allow-origin": origin }
151
+ : {}),
152
+ };
153
+
154
+ // Before the verb check and the signature check: a preflight carries
155
+ // neither the signed method nor any credentials, so answering it
156
+ // later would 405 every cross-origin upload.
157
+ if (request.method === "OPTIONS") {
158
+ return new Response(null, { headers: cors, status: 204 });
159
+ }
160
+
161
+ // The method is signed, so a GET URL cannot be replayed as a PUT —
162
+ // check the verb anyway rather than relying on that alone.
163
+ if (request.method !== (url.searchParams.get("method") ?? "GET")) {
164
+ return new Response("method not allowed", { status: 405 });
165
+ }
166
+
87
167
  const result = await verifySignedUrl(url, env.STORAGE_SIGNING_SECRET);
88
168
 
89
169
  if (!result.valid || result.key === undefined) {
@@ -91,14 +171,64 @@ export default {
91
171
  return new Response("forbidden", { status: 403 });
92
172
  }
93
173
 
174
+ if (request.method === "PUT") {
175
+ // Store the content type the SIGNATURE pins, never the request's
176
+ // own header: the allowlist ran when the URL was minted, so
177
+ // trusting the header lets a caller mint for `image/png` and PUT
178
+ // `text/html` — stored XSS on this origin.
179
+ if (result.contentType === undefined) {
180
+ return new Response("upload URL carries no content type", { status: 400 });
181
+ }
182
+
183
+ // A declared length is the contract: R2 takes `request.body` as a
184
+ // stream, so there is nothing to measure before the write, and
185
+ // treating an ABSENT header as oversized would 413 every valid
186
+ // streamed upload. Demand it (411) and enforce it (413).
187
+ const declared = request.headers.get("content-length");
188
+
189
+ if (declared === null) {
190
+ return new Response("content-length required", { status: 411 });
191
+ }
192
+
193
+ const length = Number(declared);
194
+
195
+ if (!Number.isFinite(length) || length > MAX_UPLOAD_BYTES) {
196
+ return new Response("upload too large", { status: 413 });
197
+ }
198
+
199
+ await env.UPLOADS.put(result.key, request.body, { httpMetadata: { contentType: result.contentType } });
200
+
201
+ // The preflight's answer does not carry over: without CORS
202
+ // headers HERE too the browser passes preflight and then rejects
203
+ // the actual response.
204
+ return new Response(null, { headers: cors, status: 204 });
205
+ }
206
+
94
207
  const object = await env.UPLOADS.get(result.key);
95
208
 
96
209
  if (!object) {
97
210
  return new Response("not found", { status: 404 });
98
211
  }
99
212
 
213
+ // The stored content type came off an uploader-signed URL, so it is
214
+ // attacker-influenced: a CR/LF/NUL in it either throws inside
215
+ // `Headers` (an unhandled 500) or, on a permissive runtime, splits
216
+ // the response. Reject the value rather than reflect it — this is
217
+ // exactly what `isSafeHeaderValue` does inside `serveStorageObject`.
218
+ const rawContentType = object.httpMetadata?.contentType;
219
+ const contentType = rawContentType !== undefined && isSafeHeaderValue(rawContentType) ? rawContentType : "application/octet-stream";
220
+
100
221
  return new Response(object.body, {
101
- headers: { "content-type": object.httpMetadata?.contentType ?? "application/octet-stream" },
222
+ headers: {
223
+ ...cors,
224
+ // The URL expires; a cached copy would not. Without this a
225
+ // browser or CDN can keep serving private bytes past `exp`,
226
+ // with `verifySignedUrl` never consulted again.
227
+ "cache-control": "private, no-store",
228
+ ...(INLINE_SAFE.has(contentType.split(";")[0]?.trim().toLowerCase() ?? "") ? {} : { "content-disposition": "attachment" }),
229
+ "content-type": contentType,
230
+ "x-content-type-options": "nosniff",
231
+ },
102
232
  });
103
233
  }
104
234
 
@@ -108,6 +238,18 @@ export default {
108
238
  };
109
239
  ```
110
240
 
241
+ **Why the CORS lines are there.** If `STORAGE_PUBLIC_BASE_URL` is not your app's
242
+ own origin, the browser `PUT` below is preflighted (`PUT` is not a simple method,
243
+ and `content-type: image/png` is not a safelisted value). Answering `OPTIONS` is
244
+ only half of it: the browser also reads
245
+ `access-control-allow-origin` off the **real** response, so the 204 and the
246
+ download response carry `...cors` too — a route that answers only the preflight
247
+ passes it and then fails the request it was preflighting.
248
+
249
+ Keep `STORAGE_PUBLIC_BASE_URL` same-origin if you would rather not maintain an
250
+ allowlist; then `ALLOWED_ORIGINS` can be empty and `cors` never adds a header
251
+ beyond `vary: origin`.
252
+
111
253
  `verifySignedUrl` checks expiry, then the HMAC. On a host-rewrite / CDN topology
112
254
  pass `{ expectedHost }` (the `STORAGE_PUBLIC_BASE_URL` host) so the signature
113
255
  canonicalizes against the host it was minted for.
@@ -121,17 +263,23 @@ const { key, url } = await client.action("storage/generateUploadUrl", {
121
263
  contentType: file.type,
122
264
  });
123
265
 
124
- // 2. upload straight to R2 (no Worker proxy)
266
+ // 2. upload it the URL points at your Worker's `/storage/*` route, which
267
+ // verifies the signature and writes to R2. The content type is pinned into
268
+ // the signature (and carried on the URL as `&ct=`); that signed value is what
269
+ // gets stored, so the request's own `content-type` header is not read and
270
+ // cannot override it.
125
271
  await fetch(url, { method: "PUT", headers: { "content-type": file.type }, body: file });
126
272
 
127
273
  // 3. later, get a signed GET URL to display it
128
274
  const { url: downloadUrl } = await client.action("storage/getDownloadUrl", { key: "avatar.png" });
129
275
  ```
130
276
 
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.
277
+ Every key is scoped per-tenant with `scopeKey(requireOwner(ctx.auth.userId),
278
+ key)` — `requireOwner` returns `storage/<userId>` — so a client-supplied key can
279
+ never address another user's data, and the `storage/` prefix is what lands the
280
+ minted URL on the `/storage/*` route. The functions return the **scoped** key
281
+ (`storage/<userId>/avatar.png`) alongside the URL; persist that, and pass the
282
+ bare key back in — the component re-scopes it.
135
283
 
136
284
  ## Common Pitfalls
137
285
 
@@ -143,16 +291,25 @@ persist that, and pass the bare key back in — the component re-scopes it.
143
291
  `bucket_name: "replace-me-uploads"` — rename it to a real R2 bucket. (R2 names
144
292
  are lowercase alphanumeric + hyphens, 3–63 chars; wrangler rejects anything
145
293
  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.
294
+ 3. **Short / shared signing secret.** ≥32 chars is enforced (the item throws on
295
+ the first call below it). Cross-_bucket_ replay is not a risk here — the
296
+ bucket name is part of the HMAC canonical and rides on the URL as `&bucket=`,
297
+ so a URL minted for one bucket never verifies against another under the same
298
+ secret. Cross-_tenant_ reuse is the real hazard: one secret shared between two
299
+ apps lets either mint URLs the other's route will honour, so keep a distinct
300
+ secret per deployment.
301
+ 4. **Base URL with a path.** `STORAGE_PUBLIC_BASE_URL` must be a bare origin. The
302
+ key is verified from the whole URL pathname, so a subpath base would make
303
+ every minted URL fail verification — `buildSignedUrl` rejects it up front.
304
+ 5. **Routing the body through a Lunora function.** Uploads and downloads go
305
+ through the thin `/storage/*` route, which streams to and from R2 — don't
306
+ read the file into a `query`/`mutation`/`action` argument or return value.
150
307
 
151
308
  ## Checklist
152
309
 
153
310
  - [ ] `lunora registry add storage` run, `pnpm install` done.
154
311
  - [ ] `UPLOADS` bucket bound to a real bucket; `STORAGE_SIGNING_SECRET` (≥32
155
- chars) and `STORAGE_PUBLIC_BASE_URL` set.
312
+ chars) and `STORAGE_PUBLIC_BASE_URL` (a bare origin) set.
156
313
  - [ ] `lunora codegen` run so `api.storage.*` is generated.
157
- - [ ] `GET /storage/:key` route verifies signed URLs before streaming.
314
+ - [ ] `/storage/*` route added, verifying signed URLs on both `PUT` and `GET`.
158
315
  - [ ] Verified a client upload → signed download round-trip.