@lunora/cli 1.0.0-alpha.32 → 1.0.0-alpha.321

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 (122) hide show
  1. package/LICENSE.md +33 -0
  2. package/README.md +1 -1
  3. package/dist/bin.mjs +2 -10
  4. package/dist/index.d.mts +960 -370
  5. package/dist/index.d.ts +960 -370
  6. package/dist/index.mjs +1 -19
  7. package/dist/packem_chunks/dispatch.mjs +1 -0
  8. package/dist/packem_chunks/handler.mjs +1 -160
  9. package/dist/packem_chunks/handler10.mjs +1 -16
  10. package/dist/packem_chunks/handler11.mjs +2 -22
  11. package/dist/packem_chunks/handler12.mjs +1 -192
  12. package/dist/packem_chunks/handler13.mjs +1 -131
  13. package/dist/packem_chunks/handler14.mjs +1 -65
  14. package/dist/packem_chunks/handler15.mjs +1 -58
  15. package/dist/packem_chunks/handler16.mjs +3 -80
  16. package/dist/packem_chunks/handler17.mjs +1 -43
  17. package/dist/packem_chunks/handler18.mjs +1 -105
  18. package/dist/packem_chunks/handler19.mjs +7 -170
  19. package/dist/packem_chunks/handler2.mjs +1 -114
  20. package/dist/packem_chunks/handler20.mjs +1 -94
  21. package/dist/packem_chunks/handler21.mjs +3 -94
  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 -315
  34. package/dist/packem_chunks/planDevCommand.mjs +7 -541
  35. package/dist/packem_chunks/runCodegenCommand.mjs +4 -52
  36. package/dist/packem_chunks/runDeployCommand.mjs +7 -594
  37. package/dist/packem_chunks/runInitCommand.mjs +162 -1430
  38. package/dist/packem_chunks/runResetCommand.mjs +1 -41
  39. package/dist/packem_chunks/runRpcCommand.mjs +1 -68
  40. package/dist/packem_shared/COMMANDS-DdaAWPtr.mjs +1 -0
  41. package/dist/packem_shared/DEFAULT_IMPORT_BATCH_SIZE-BgMPHEoe.mjs +1 -0
  42. package/dist/packem_shared/EXIT_CODE-08cwt3MK.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/binding-manifest-file-CQ1eQYy1.mjs +2 -0
  46. package/dist/packem_shared/buildRegistryIndex-Dc8D8AR6.mjs +1 -0
  47. package/dist/packem_shared/catalog-CahzmDLV.mjs +1 -0
  48. package/dist/packem_shared/cli-DOpChqUe.mjs +2 -0
  49. package/dist/packem_shared/codegen-error-AmH54ofi.mjs +3 -0
  50. package/dist/packem_shared/command-Mmxzxyr5.mjs +1 -0
  51. package/dist/packem_shared/commands-CNqednoX.mjs +13 -0
  52. package/dist/packem_shared/createLogger-Cl18I8AX.mjs +2 -0
  53. package/dist/packem_shared/createRecordingSpawner-sS7LEN7x.mjs +1 -0
  54. package/dist/packem_shared/deploy-target-DCWwiuAe.mjs +1 -0
  55. package/dist/packem_shared/diffSnapshots-WWwx-KvZ.mjs +5 -0
  56. package/dist/packem_shared/docker-DpVxvYpL.mjs +1 -0
  57. package/dist/packem_shared/import-D7qdpSmJ.mjs +12 -0
  58. package/dist/packem_shared/insertSchemaExtension-DuiV6cba.mjs +8 -0
  59. package/dist/packem_shared/lint-ignore-report-DKZagpqk.mjs +2 -0
  60. package/dist/packem_shared/open-url-EnKy--w-.mjs +1 -0
  61. package/dist/packem_shared/parseManifest-CwPTKdtS.mjs +1 -0
  62. package/dist/packem_shared/path-containment-CgxYZggb.mjs +1 -0
  63. package/dist/packem_shared/platform-diagnostics-Cn2g6Jh-.mjs +4 -0
  64. package/dist/packem_shared/prompt-cancelled-BvsNxg_Q.mjs +1 -0
  65. package/dist/packem_shared/render-lunora-error--4tmM6mt.mjs +3 -0
  66. package/dist/packem_shared/resolve-DUCSc7jQ.mjs +5 -0
  67. package/dist/packem_shared/resolve-target-C_ZloTvd.mjs +1 -0
  68. package/dist/packem_shared/runAddCommand-Cf2q2y27.mjs +1 -0
  69. package/dist/packem_shared/runExportCommand-DxsJHYZt.mjs +5 -0
  70. package/dist/packem_shared/runMigrateGenerateCommand-CDHswpMU.mjs +11 -0
  71. package/dist/packem_shared/schema-drift-gate-BDCkQ1S6.mjs +1 -0
  72. package/dist/packem_shared/schemaIrToSnapshot-BjK-0IRo.mjs +1 -0
  73. package/dist/packem_shared/shared-D-zCOmgY.mjs +1 -0
  74. package/dist/packem_shared/storage-J4xhM4FU.mjs +1 -0
  75. package/dist/packem_shared/tui-prompts-B3YwUhGw.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-C9lJnd5N.mjs +1 -0
  79. package/package.json +39 -19
  80. package/skills/README.md +35 -17
  81. package/skills/lunora/SKILL.md +123 -9
  82. package/skills/lunora-create-package/SKILL.md +4 -3
  83. package/skills/lunora-deploy/SKILL.md +42 -11
  84. package/skills/lunora-functions/SKILL.md +120 -15
  85. package/skills/lunora-migration-helper/SKILL.md +74 -21
  86. package/skills/lunora-performance-audit/SKILL.md +78 -9
  87. package/skills/lunora-quickstart/SKILL.md +93 -27
  88. package/skills/lunora-realtime/SKILL.md +73 -39
  89. package/skills/lunora-setup-auth/SKILL.md +33 -6
  90. package/skills/lunora-setup-hyperdrive/SKILL.md +33 -13
  91. package/skills/lunora-setup-hyperdrive-global/SKILL.md +5 -0
  92. package/skills/lunora-setup-mail/SKILL.md +34 -28
  93. package/skills/lunora-setup-scheduler/SKILL.md +18 -14
  94. package/skills/lunora-setup-storage/SKILL.md +187 -27
  95. package/dist/packem_chunks/runMigrateGenerateCommand.mjs +0 -397
  96. package/dist/packem_shared/COMMANDS-B0ftFD_3.mjs +0 -948
  97. package/dist/packem_shared/DEFAULT_IMPORT_BATCH_SIZE-Ck-2bU08.mjs +0 -244
  98. package/dist/packem_shared/admin-url-4UzT-CI4.mjs +0 -19
  99. package/dist/packem_shared/api-spec-CtA6ilu4.mjs +0 -13
  100. package/dist/packem_shared/buildRegistryIndex-BcYe607_.mjs +0 -38
  101. package/dist/packem_shared/codegen-error-DJG-ghs_.mjs +0 -31
  102. package/dist/packem_shared/command-D3lB_4Az.mjs +0 -19
  103. package/dist/packem_shared/commands-B-gR09Z_.mjs +0 -845
  104. package/dist/packem_shared/createLogger-B40gPzQo.mjs +0 -78
  105. package/dist/packem_shared/createRecordingSpawner-DxI3mebw.mjs +0 -43
  106. package/dist/packem_shared/detect-package-manager-DYp7n3mJ.mjs +0 -61
  107. package/dist/packem_shared/diffSnapshots-BeDvvNiF.mjs +0 -161
  108. package/dist/packem_shared/docker-hMQ97KSQ.mjs +0 -21
  109. package/dist/packem_shared/insertSchemaExtension-DAqbfr9Z.mjs +0 -64
  110. package/dist/packem_shared/open-url-Dfq6fAyT.mjs +0 -41
  111. package/dist/packem_shared/output-format-wUvAN6AL.mjs +0 -17
  112. package/dist/packem_shared/parseArgs-YXFuKdEk.mjs +0 -56
  113. package/dist/packem_shared/parseManifest--vZf2FY1.mjs +0 -94
  114. package/dist/packem_shared/prompt-cancelled-APzX1Im-.mjs +0 -9
  115. package/dist/packem_shared/resolve-target-qbsJ_5sF.mjs +0 -16
  116. package/dist/packem_shared/runAddCommand-bnY6-HKb.mjs +0 -4
  117. package/dist/packem_shared/schema-drift-gate-BtBt0as0.mjs +0 -79
  118. package/dist/packem_shared/schemaIrToSnapshot-DdsljJT-.mjs +0 -43
  119. package/dist/packem_shared/storage-BIsph-Vk.mjs +0 -84
  120. package/dist/packem_shared/tui-prompts-BjEN8XgP.mjs +0 -658
  121. package/dist/packem_shared/wrangler-name-cy4yhm9j.mjs +0 -12
  122. package/dist/packem_shared/wrangler-secrets-P2_ZUR-k.mjs +0 -47
@@ -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
@@ -120,7 +125,6 @@ import { createWorkpool } from "@lunora/scheduler";
120
125
 
121
126
  const pool = createWorkpool({
122
127
  namespace: env.SCHEDULER,
123
- originUrl: "https://my-app.example.com",
124
128
  name: "imports",
125
129
  maxConcurrency: 3,
126
130
  });
@@ -7,12 +7,21 @@ 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
+ `<base>/<key>?exp&method&bucket&sig`, where the base is the origin the request
15
+ reached your Worker on (`ctx.origin`) unless `STORAGE_PUBLIC_BASE_URL` overrides
16
+ it. The `/storage/*` route
17
+ you add in step 4 is what verifies the signature and moves the bytes, for both
18
+ the upload and the download. (The no-Worker-in-the-path variant is
19
+ `@lunora/storage`'s S3 presigned URL, `getPresignedUrl` — it needs S3 credentials
20
+ on the bucket and enforces none of your rules.)
12
21
 
13
22
  ## When to Use
14
23
 
15
- - Uploading user files (avatars, attachments) straight to R2.
24
+ - Uploading user files (avatars, attachments) into R2 under your own gate.
16
25
  - Serving private/gated downloads via short-lived signed URLs.
17
26
  - Listing or deleting a caller's stored objects.
18
27
 
@@ -27,7 +36,8 @@ downloads, delete, and list — so the bytes never proxy through your Worker.
27
36
  1. Add the `storage` item.
28
37
  2. Configure the `UPLOADS` R2 bucket binding and the signing secret.
29
38
  3. Regenerate types with `lunora codegen`.
30
- 4. Verify signed downloads in the Worker's `GET /storage/:key` route.
39
+ 4. Add the `/storage/*` route to the Worker — it verifies signatures and serves
40
+ both the signed `PUT` and the signed `GET`.
31
41
  5. Upload/download from the client.
32
42
 
33
43
  ## Step 1: Add the item
@@ -43,19 +53,24 @@ This:
43
53
  2. Adds an R2 bucket binding to `wrangler.jsonc` (`r2_buckets`, binding
44
54
  **`UPLOADS`**, `bucket_name: "replace-me-uploads"` — rename it to a real
45
55
  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`.
56
+ 3. Scaffolds `STORAGE_SIGNING_SECRET` (a secret) and an empty, optional
57
+ `STORAGE_PUBLIC_BASE_URL` into `.dev.vars`.
48
58
  4. Copies `lunora/storage/index.ts` (the `generateUploadUrl` /
49
59
  `getDownloadUrl` / `deleteObject` / `listObjects` functions) into your
50
60
  project — it is **yours** to edit.
51
61
 
52
62
  ## Step 2: Configure the binding + secrets
53
63
 
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`. |
64
+ | Name | Where | Notes |
65
+ | ------------------------- | -------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
66
+ | `UPLOADS` | `wrangler.jsonc` → `r2_buckets[]` | The R2 bucket binding. Point `bucket_name` at a real bucket. |
67
+ | `STORAGE_SIGNING_SECRET` | secret (`.dev.vars` / `secret put`) | HMAC secret for signed URLs. Min 32 chars, enforced — a shorter one throws on the first call. Never share across tenants. |
68
+ | `STORAGE_PUBLIC_BASE_URL` | optional var (`.dev.vars` / Worker variable) | Leave it **empty** and URLs are signed against the origin the request reached your Worker on (`ctx.origin`), right in dev, previews and production alike. Set a **bare origin** only when another host (a CDN) serves `/storage/*`, or to sign where no request is behind the call: a query, a scheduled job, a workflow step have no `ctx.origin`, so signing there without it throws. A base carrying a path is rejected by the signer. |
69
+
70
+ A configured `STORAGE_PUBLIC_BASE_URL` must be `https://` anywhere but local dev. A signed URL
71
+ _is_ a bearer credential and the object bytes stream through it, so a plaintext
72
+ origin hands both to anyone on the path. Only `http://localhost` /
73
+ `http://127.0.0.1` belong in `.dev.vars`.
59
74
 
60
75
  Generate a real signing secret with `openssl rand -base64 32` and write it with
61
76
  `wrangler secret put STORAGE_SIGNING_SECRET` for production.
@@ -70,20 +85,87 @@ The functions surface in the generated `api` as `api.storage.generateUploadUrl`,
70
85
  `api.storage.getDownloadUrl`, `api.storage.deleteObject`, and
71
86
  `api.storage.listObjects`.
72
87
 
73
- ## Step 4: Verify downloads in the Worker
88
+ ## Step 4: Add the `/storage/*` route to the Worker
89
+
90
+ **Required, not optional.** Without it a minted URL hits the Lunora catch-all and
91
+ every upload and download 404s — and it is the only thing checking the signature,
92
+ so skipping the check lets anyone read any key.
74
93
 
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):
94
+ `@lunora/server`'s `serveStorageObject(ctx, key, request, authorize)` handles
95
+ the download half (`Range`/206, `ETag`, `nosniff`, and
96
+ `content-disposition: attachment` for anything outside a small inline-safe set —
97
+ raster images plus `audio/mpeg`, `audio/ogg`, `audio/wav`, `video/mp4`,
98
+ `video/webm`, with `image/svg+xml` deliberately excluded). It verifies nothing on
99
+ its own — its required `authorize` gate is where `verifySignedUrl` goes — and it
100
+ does not handle the upload. Reach for it from an `httpAction`, where `ctx.storage`
101
+ is in scope, whenever you want `Range` seeking or conditional requests.
102
+
103
+ The route below is the standalone version — a plain worker `fetch` with only the
104
+ R2 binding to hand, so it serves whole objects and skips `Range`/`ETag`. Both
105
+ verbs, by hand:
78
106
 
79
107
  ```ts
108
+ import { isSafeHeaderValue } from "@lunora/server";
80
109
  import { verifySignedUrl } from "@lunora/storage";
81
110
 
111
+ /** Cap what a single signed PUT may store. */
112
+ const MAX_UPLOAD_BYTES = 25 * 1024 * 1024;
113
+
114
+ /**
115
+ * Origins allowed to upload cross-origin. Leave it empty when
116
+ * `STORAGE_PUBLIC_BASE_URL` is your app's own origin — then `cors` is inert and
117
+ * no browser ever preflights these routes.
118
+ */
119
+ const ALLOWED_ORIGINS = new Set(["https://app.example.com"]);
120
+
121
+ /**
122
+ * Types safe to render in the browser. Everything else downloads — an uploader
123
+ * who pinned `text/html` or `image/svg+xml` must never get a same-origin script.
124
+ * (`serveStorageObject` applies this same list.)
125
+ */
126
+ const INLINE_SAFE = new Set([
127
+ "audio/mpeg",
128
+ "audio/ogg",
129
+ "audio/wav",
130
+ "image/apng",
131
+ "image/avif",
132
+ "image/gif",
133
+ "image/jpeg",
134
+ "image/png",
135
+ "image/webp",
136
+ "video/mp4",
137
+ "video/webm",
138
+ ]);
139
+
82
140
  export default {
83
141
  async fetch(request: Request, env: Env): Promise<Response> {
84
142
  const url = new URL(request.url);
85
143
 
86
144
  if (url.pathname.startsWith("/storage/")) {
145
+ const origin = request.headers.get("origin");
146
+ // `vary` rides on EVERY response, allowed origin or not: a shared
147
+ // cache keyed on the URL alone would otherwise replay one origin's
148
+ // `access-control-allow-origin` to another.
149
+ const cors = {
150
+ vary: "origin",
151
+ ...(origin !== null && ALLOWED_ORIGINS.has(origin)
152
+ ? { "access-control-allow-headers": "content-type", "access-control-allow-methods": "GET, PUT", "access-control-allow-origin": origin }
153
+ : {}),
154
+ };
155
+
156
+ // Before the verb check and the signature check: a preflight carries
157
+ // neither the signed method nor any credentials, so answering it
158
+ // later would 405 every cross-origin upload.
159
+ if (request.method === "OPTIONS") {
160
+ return new Response(null, { headers: cors, status: 204 });
161
+ }
162
+
163
+ // The method is signed, so a GET URL cannot be replayed as a PUT —
164
+ // check the verb anyway rather than relying on that alone.
165
+ if (request.method !== (url.searchParams.get("method") ?? "GET")) {
166
+ return new Response("method not allowed", { status: 405 });
167
+ }
168
+
87
169
  const result = await verifySignedUrl(url, env.STORAGE_SIGNING_SECRET);
88
170
 
89
171
  if (!result.valid || result.key === undefined) {
@@ -91,14 +173,64 @@ export default {
91
173
  return new Response("forbidden", { status: 403 });
92
174
  }
93
175
 
176
+ if (request.method === "PUT") {
177
+ // Store the content type the SIGNATURE pins, never the request's
178
+ // own header: the allowlist ran when the URL was minted, so
179
+ // trusting the header lets a caller mint for `image/png` and PUT
180
+ // `text/html` — stored XSS on this origin.
181
+ if (result.contentType === undefined) {
182
+ return new Response("upload URL carries no content type", { status: 400 });
183
+ }
184
+
185
+ // A declared length is the contract: R2 takes `request.body` as a
186
+ // stream, so there is nothing to measure before the write, and
187
+ // treating an ABSENT header as oversized would 413 every valid
188
+ // streamed upload. Demand it (411) and enforce it (413).
189
+ const declared = request.headers.get("content-length");
190
+
191
+ if (declared === null) {
192
+ return new Response("content-length required", { status: 411 });
193
+ }
194
+
195
+ const length = Number(declared);
196
+
197
+ if (!Number.isFinite(length) || length > MAX_UPLOAD_BYTES) {
198
+ return new Response("upload too large", { status: 413 });
199
+ }
200
+
201
+ await env.UPLOADS.put(result.key, request.body, { httpMetadata: { contentType: result.contentType } });
202
+
203
+ // The preflight's answer does not carry over: without CORS
204
+ // headers HERE too the browser passes preflight and then rejects
205
+ // the actual response.
206
+ return new Response(null, { headers: cors, status: 204 });
207
+ }
208
+
94
209
  const object = await env.UPLOADS.get(result.key);
95
210
 
96
211
  if (!object) {
97
212
  return new Response("not found", { status: 404 });
98
213
  }
99
214
 
215
+ // The stored content type came off an uploader-signed URL, so it is
216
+ // attacker-influenced: a CR/LF/NUL in it either throws inside
217
+ // `Headers` (an unhandled 500) or, on a permissive runtime, splits
218
+ // the response. Reject the value rather than reflect it — this is
219
+ // exactly what `isSafeHeaderValue` does inside `serveStorageObject`.
220
+ const rawContentType = object.httpMetadata?.contentType;
221
+ const contentType = rawContentType !== undefined && isSafeHeaderValue(rawContentType) ? rawContentType : "application/octet-stream";
222
+
100
223
  return new Response(object.body, {
101
- headers: { "content-type": object.httpMetadata?.contentType ?? "application/octet-stream" },
224
+ headers: {
225
+ ...cors,
226
+ // The URL expires; a cached copy would not. Without this a
227
+ // browser or CDN can keep serving private bytes past `exp`,
228
+ // with `verifySignedUrl` never consulted again.
229
+ "cache-control": "private, no-store",
230
+ ...(INLINE_SAFE.has(contentType.split(";")[0]?.trim().toLowerCase() ?? "") ? {} : { "content-disposition": "attachment" }),
231
+ "content-type": contentType,
232
+ "x-content-type-options": "nosniff",
233
+ },
102
234
  });
103
235
  }
104
236
 
@@ -108,6 +240,18 @@ export default {
108
240
  };
109
241
  ```
110
242
 
243
+ **Why the CORS lines are there.** If `STORAGE_PUBLIC_BASE_URL` is not your app's
244
+ own origin, the browser `PUT` below is preflighted (`PUT` is not a simple method,
245
+ and `content-type: image/png` is not a safelisted value). Answering `OPTIONS` is
246
+ only half of it: the browser also reads
247
+ `access-control-allow-origin` off the **real** response, so the 204 and the
248
+ download response carry `...cors` too — a route that answers only the preflight
249
+ passes it and then fails the request it was preflighting.
250
+
251
+ Keep `STORAGE_PUBLIC_BASE_URL` same-origin if you would rather not maintain an
252
+ allowlist; then `ALLOWED_ORIGINS` can be empty and `cors` never adds a header
253
+ beyond `vary: origin`.
254
+
111
255
  `verifySignedUrl` checks expiry, then the HMAC. On a host-rewrite / CDN topology
112
256
  pass `{ expectedHost }` (the `STORAGE_PUBLIC_BASE_URL` host) so the signature
113
257
  canonicalizes against the host it was minted for.
@@ -121,17 +265,23 @@ const { key, url } = await client.action("storage/generateUploadUrl", {
121
265
  contentType: file.type,
122
266
  });
123
267
 
124
- // 2. upload straight to R2 (no Worker proxy)
268
+ // 2. upload it — the URL points at your Worker's `/storage/*` route, which
269
+ // verifies the signature and writes to R2. The content type is pinned into
270
+ // the signature (and carried on the URL as `&ct=`); that signed value is what
271
+ // gets stored, so the request's own `content-type` header is not read and
272
+ // cannot override it.
125
273
  await fetch(url, { method: "PUT", headers: { "content-type": file.type }, body: file });
126
274
 
127
275
  // 3. later, get a signed GET URL to display it
128
276
  const { url: downloadUrl } = await client.action("storage/getDownloadUrl", { key: "avatar.png" });
129
277
  ```
130
278
 
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.
279
+ Every key is scoped per-tenant with `scopeKey(requireOwner(ctx.auth.userId),
280
+ key)` — `requireOwner` returns `storage/<userId>` — so a client-supplied key can
281
+ never address another user's data, and the `storage/` prefix is what lands the
282
+ minted URL on the `/storage/*` route. The functions return the **scoped** key
283
+ (`storage/<userId>/avatar.png`) alongside the URL; persist that, and pass the
284
+ bare key back in — the component re-scopes it.
135
285
 
136
286
  ## Common Pitfalls
137
287
 
@@ -143,16 +293,26 @@ persist that, and pass the bare key back in — the component re-scopes it.
143
293
  `bucket_name: "replace-me-uploads"` — rename it to a real R2 bucket. (R2 names
144
294
  are lowercase alphanumeric + hyphens, 3–63 chars; wrangler rejects anything
145
295
  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.
296
+ 3. **Short / shared signing secret.** ≥32 chars is enforced (the item throws on
297
+ the first call below it). Cross-_bucket_ replay is not a risk here — the
298
+ bucket name is part of the HMAC canonical and rides on the URL as `&bucket=`,
299
+ so a URL minted for one bucket never verifies against another under the same
300
+ secret. Cross-_tenant_ reuse is the real hazard: one secret shared between two
301
+ apps lets either mint URLs the other's route will honour, so keep a distinct
302
+ secret per deployment.
303
+ 4. **Base URL with a path.** `STORAGE_PUBLIC_BASE_URL` must be a bare origin. The
304
+ key is verified from the whole URL pathname, so a subpath base would make
305
+ every minted URL fail verification — `buildSignedUrl` rejects it up front.
306
+ 5. **Routing the body through a Lunora function.** Uploads and downloads go
307
+ through the thin `/storage/*` route, which streams to and from R2 — don't
308
+ read the file into a `query`/`mutation`/`action` argument or return value.
150
309
 
151
310
  ## Checklist
152
311
 
153
312
  - [ ] `lunora registry add storage` run, `pnpm install` done.
154
313
  - [ ] `UPLOADS` bucket bound to a real bucket; `STORAGE_SIGNING_SECRET` (≥32
155
- chars) and `STORAGE_PUBLIC_BASE_URL` set.
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.
156
316
  - [ ] `lunora codegen` run so `api.storage.*` is generated.
157
- - [ ] `GET /storage/:key` route verifies signed URLs before streaming.
317
+ - [ ] `/storage/*` route added, verifying signed URLs on both `PUT` and `GET`.
158
318
  - [ ] Verified a client upload → signed download round-trip.