@lunora/cli 1.0.0-alpha.37 → 1.0.0-alpha.370

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 (132) 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 +1007 -372
  5. package/dist/index.d.ts +1007 -372
  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 +2 -16
  10. package/dist/packem_chunks/handler11.mjs +1 -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 +3 -58
  15. package/dist/packem_chunks/handler16.mjs +1 -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 +1 -94
  22. package/dist/packem_chunks/handler22.mjs +3 -0
  23. package/dist/packem_chunks/handler23.mjs +3 -0
  24. package/dist/packem_chunks/handler24.mjs +1 -0
  25. package/dist/packem_chunks/handler25.mjs +2 -0
  26. package/dist/packem_chunks/handler26.mjs +1 -0
  27. package/dist/packem_chunks/handler27.mjs +99 -0
  28. package/dist/packem_chunks/handler28.mjs +10 -0
  29. package/dist/packem_chunks/handler3.mjs +1 -204
  30. package/dist/packem_chunks/handler4.mjs +1 -33
  31. package/dist/packem_chunks/handler5.mjs +1 -49
  32. package/dist/packem_chunks/handler6.mjs +3 -91
  33. package/dist/packem_chunks/handler7.mjs +1 -42
  34. package/dist/packem_chunks/handler8.mjs +1 -174
  35. package/dist/packem_chunks/handler9.mjs +1 -315
  36. package/dist/packem_chunks/runCodegenCommand.mjs +4 -52
  37. package/dist/packem_chunks/runDeployCommand.mjs +11 -594
  38. package/dist/packem_chunks/runDevCommand.mjs +2 -0
  39. package/dist/packem_chunks/runInitCommand.mjs +164 -1432
  40. package/dist/packem_chunks/runResetCommand.mjs +1 -41
  41. package/dist/packem_chunks/runRpcCommand.mjs +1 -68
  42. package/dist/packem_shared/COMMANDS-CWnIJmxM.mjs +1 -0
  43. package/dist/packem_shared/DEFAULT_IMPORT_BATCH_SIZE-CyIsI3S1.mjs +1 -0
  44. package/dist/packem_shared/EXIT_CODE-D_XCixv8.mjs +1 -0
  45. package/dist/packem_shared/admin-token-VdUnvnKW.mjs +1 -0
  46. package/dist/packem_shared/admin-url-DJB8P6Jh.mjs +1 -0
  47. package/dist/packem_shared/binding-manifest-file-CQ1eQYy1.mjs +2 -0
  48. package/dist/packem_shared/buildRegistryIndex-Dc8D8AR6.mjs +1 -0
  49. package/dist/packem_shared/catalog-CahzmDLV.mjs +1 -0
  50. package/dist/packem_shared/cli-6BRlq6nw.mjs +2 -0
  51. package/dist/packem_shared/cli-manifest-CRet0egA.mjs +1 -0
  52. package/dist/packem_shared/cloudflare-credentials-lC5JtPLe.mjs +1 -0
  53. package/dist/packem_shared/codegen-error-AmH54ofi.mjs +3 -0
  54. package/dist/packem_shared/command-CAXl2A1S.mjs +1 -0
  55. package/dist/packem_shared/commands-DgUDxkA8.mjs +13 -0
  56. package/dist/packem_shared/createLogger-Cl18I8AX.mjs +2 -0
  57. package/dist/packem_shared/createRecordingSpawner-Ch7zuQQE.mjs +1 -0
  58. package/dist/packem_shared/deploy-target-DCWwiuAe.mjs +1 -0
  59. package/dist/packem_shared/diffSnapshots-WWwx-KvZ.mjs +5 -0
  60. package/dist/packem_shared/docker-DpVxvYpL.mjs +1 -0
  61. package/dist/packem_shared/health-probe-Bw9JFfIy.mjs +1 -0
  62. package/dist/packem_shared/import-BRM1hs7j.mjs +12 -0
  63. package/dist/packem_shared/index-oHZBGBwB.mjs +1 -0
  64. package/dist/packem_shared/insertSchemaExtension-DuiV6cba.mjs +8 -0
  65. package/dist/packem_shared/lint-ignore-report-DKZagpqk.mjs +2 -0
  66. package/dist/packem_shared/open-url-EnKy--w-.mjs +1 -0
  67. package/dist/packem_shared/output-format-B4eQHi5y.mjs +2 -0
  68. package/dist/packem_shared/parseManifest-CwPTKdtS.mjs +1 -0
  69. package/dist/packem_shared/path-containment-CgxYZggb.mjs +1 -0
  70. package/dist/packem_shared/plan-DJeIwzFu.mjs +5 -0
  71. package/dist/packem_shared/planDevCommand-DkaC-fNF.mjs +1 -0
  72. package/dist/packem_shared/platform-diagnostics-Cn2g6Jh-.mjs +4 -0
  73. package/dist/packem_shared/prompt-cancelled-BvsNxg_Q.mjs +1 -0
  74. package/dist/packem_shared/render-lunora-error-C3OJY5wk.mjs +2 -0
  75. package/dist/packem_shared/resolve-BrsV4Qyc.mjs +5 -0
  76. package/dist/packem_shared/resolve-target-C_ZloTvd.mjs +1 -0
  77. package/dist/packem_shared/runAddCommand-DTvfp4h2.mjs +1 -0
  78. package/dist/packem_shared/runExportCommand-slAq5BCF.mjs +5 -0
  79. package/dist/packem_shared/runMigrateGenerateCommand-Ii_WMZEA.mjs +11 -0
  80. package/dist/packem_shared/schemaIrToSnapshot-BjK-0IRo.mjs +1 -0
  81. package/dist/packem_shared/secret-key-BCrqNJ68.mjs +1 -0
  82. package/dist/packem_shared/shared-D-zCOmgY.mjs +1 -0
  83. package/dist/packem_shared/storage-DWhTX8eS.mjs +1 -0
  84. package/dist/packem_shared/tui-prompts-B3YwUhGw.mjs +4 -0
  85. package/dist/packem_shared/variables-acxcvVQa.mjs +1 -0
  86. package/dist/packem_shared/wrangler-name-Dsk5K1f-.mjs +1 -0
  87. package/dist/packem_shared/wrangler-secrets-3chIVv3H.mjs +1 -0
  88. package/package.json +39 -19
  89. package/skills/README.md +35 -17
  90. package/skills/lunora/SKILL.md +154 -64
  91. package/skills/lunora-create-package/SKILL.md +97 -84
  92. package/skills/lunora-deploy/SKILL.md +175 -105
  93. package/skills/lunora-functions/SKILL.md +362 -56
  94. package/skills/lunora-migration-helper/SKILL.md +162 -137
  95. package/skills/lunora-performance-audit/SKILL.md +137 -95
  96. package/skills/lunora-quickstart/SKILL.md +137 -136
  97. package/skills/lunora-realtime/SKILL.md +140 -99
  98. package/skills/lunora-setup-auth/SKILL.md +162 -102
  99. package/skills/lunora-setup-hyperdrive/SKILL.md +114 -97
  100. package/skills/lunora-setup-hyperdrive-global/SKILL.md +93 -90
  101. package/skills/lunora-setup-mail/SKILL.md +85 -113
  102. package/skills/lunora-setup-scheduler/SKILL.md +132 -96
  103. package/skills/lunora-setup-storage/SKILL.md +189 -95
  104. package/dist/packem_chunks/planDevCommand.mjs +0 -541
  105. package/dist/packem_chunks/runMigrateGenerateCommand.mjs +0 -397
  106. package/dist/packem_shared/COMMANDS-B0ftFD_3.mjs +0 -948
  107. package/dist/packem_shared/DEFAULT_IMPORT_BATCH_SIZE-Ck-2bU08.mjs +0 -244
  108. package/dist/packem_shared/admin-url-4UzT-CI4.mjs +0 -19
  109. package/dist/packem_shared/api-spec-CtA6ilu4.mjs +0 -13
  110. package/dist/packem_shared/buildRegistryIndex-BcYe607_.mjs +0 -38
  111. package/dist/packem_shared/codegen-error-DJG-ghs_.mjs +0 -31
  112. package/dist/packem_shared/command-D3lB_4Az.mjs +0 -19
  113. package/dist/packem_shared/commands-B-gR09Z_.mjs +0 -845
  114. package/dist/packem_shared/createLogger-B40gPzQo.mjs +0 -78
  115. package/dist/packem_shared/createRecordingSpawner-DxI3mebw.mjs +0 -43
  116. package/dist/packem_shared/detect-package-manager-DYp7n3mJ.mjs +0 -61
  117. package/dist/packem_shared/diffSnapshots-BeDvvNiF.mjs +0 -161
  118. package/dist/packem_shared/docker-hMQ97KSQ.mjs +0 -21
  119. package/dist/packem_shared/insertSchemaExtension-DAqbfr9Z.mjs +0 -64
  120. package/dist/packem_shared/open-url-Dfq6fAyT.mjs +0 -41
  121. package/dist/packem_shared/output-format-wUvAN6AL.mjs +0 -17
  122. package/dist/packem_shared/parseArgs-YXFuKdEk.mjs +0 -56
  123. package/dist/packem_shared/parseManifest--vZf2FY1.mjs +0 -94
  124. package/dist/packem_shared/prompt-cancelled-APzX1Im-.mjs +0 -9
  125. package/dist/packem_shared/resolve-target-qbsJ_5sF.mjs +0 -16
  126. package/dist/packem_shared/runAddCommand-bnY6-HKb.mjs +0 -4
  127. package/dist/packem_shared/schema-drift-gate-BtBt0as0.mjs +0 -79
  128. package/dist/packem_shared/schemaIrToSnapshot-DdsljJT-.mjs +0 -43
  129. package/dist/packem_shared/storage-BIsph-Vk.mjs +0 -84
  130. package/dist/packem_shared/tui-prompts-BjEN8XgP.mjs +0 -658
  131. package/dist/packem_shared/wrangler-name-cy4yhm9j.mjs +0 -12
  132. package/dist/packem_shared/wrangler-secrets-P2_ZUR-k.mjs +0 -47
@@ -1,89 +1,135 @@
1
1
  ---
2
2
  name: lunora-setup-storage
3
- description: Adds R2-backed file storage to a Lunora app. Use for uploads/downloads via `lunora registry add storage`, signed PUT/GET URLs, the `UPLOADS` R2 bucket binding, `STORAGE_SIGNING_SECRET`, per-tenant key scoping, and verifying downloads in the Worker.
3
+ description: Adds R2-backed file storage to a Lunora app with the `storage` registry item (`@lunora/storage`). Covers worker-signed PUT/GET URLs, the `UPLOADS` R2 bucket binding, `STORAGE_SIGNING_SECRET` and `STORAGE_PUBLIC_BASE_URL`, per-user key scoping, and the `/storage/*` Worker route that verifies signatures and moves the bytes. Use when the user wants file or image uploads, avatars, attachments or gated downloads, runs `lunora add storage` or `lunora registry add storage`, edits `lunora/storage/index.ts`, or sees signed storage URLs return 404/403 or uploads fail CORS.
4
4
  ---
5
5
 
6
6
  # Lunora Setup Storage
7
7
 
8
- Wire R2-backed file storage into a Lunora app using the `storage` registry item,
9
- which is built on `@lunora/storage` (an R2 adapter plus HMAC signed-URL helpers)
10
- and exposes idiomatic Lunora functions for direct browser uploads, gated
11
- downloads, delete, and list — so the bytes never proxy through your Worker.
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
- ## When to Use
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.
14
18
 
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.
19
+ If the project has no Lunora backend yet, start with `lunora-quickstart`.
32
20
 
33
21
  ## Step 1: Add the item
34
22
 
35
23
  ```bash
36
- lunora registry add storage
24
+ lunora add storage # asks for the R2 bucket name
25
+ lunora add storage --bucket my-app-uploads
26
+ lunora registry add storage # low-level: writes bucket_name "replace-me-uploads"
37
27
  ```
38
28
 
39
- This:
29
+ Then run `pnpm install` and `lunora codegen`. The item does the following:
40
30
 
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.
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`.
51
35
 
52
- ## Step 2: Configure the binding + secrets
36
+ ## Step 2: Configure the binding and secrets
53
37
 
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`. |
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. |
59
43
 
60
- Generate a real signing secret with `openssl rand -base64 32` and write it with
61
- `wrangler secret put STORAGE_SIGNING_SECRET` for production.
44
+ Leave `STORAGE_PUBLIC_BASE_URL` empty for most apps. URLs are then signed
45
+ against `ctx.origin`, which is correct in dev, in previews and in production.
46
+ Set it only in two cases:
62
47
 
63
- ## Step 3: Regenerate types
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.
64
50
 
65
- ```bash
66
- lunora codegen
67
- ```
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.
68
54
 
69
- The functions surface in the generated `api` as `api.storage.generateUploadUrl`,
70
- `api.storage.getDownloadUrl`, `api.storage.deleteObject`, and
71
- `api.storage.listObjects`.
55
+ ## Step 3: Add the `/storage/*` route to the Worker
72
56
 
73
- ## Step 4: Verify downloads in the Worker
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.
74
60
 
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):
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.
67
+
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:
78
70
 
79
71
  ```ts
72
+ import { isSafeHeaderValue } from "@lunora/server";
80
73
  import { verifySignedUrl } from "@lunora/storage";
81
74
 
75
+ /** Cap what a single signed PUT may store. */
76
+ const MAX_UPLOAD_BYTES = 25 * 1024 * 1024;
77
+
78
+ /**
79
+ * Origins allowed to upload cross-origin. Leave it empty when
80
+ * `STORAGE_PUBLIC_BASE_URL` is your app's own origin — then `cors` is inert and
81
+ * no browser ever preflights these routes.
82
+ */
83
+ const ALLOWED_ORIGINS = new Set(["https://app.example.com"]);
84
+
85
+ /**
86
+ * Types safe to render in the browser. Everything else downloads — an uploader
87
+ * who pinned `text/html` or `image/svg+xml` must never get a same-origin script.
88
+ * (`serveStorageObject` applies this same list.)
89
+ */
90
+ const INLINE_SAFE = new Set([
91
+ "audio/mpeg",
92
+ "audio/ogg",
93
+ "audio/wav",
94
+ "image/apng",
95
+ "image/avif",
96
+ "image/gif",
97
+ "image/jpeg",
98
+ "image/png",
99
+ "image/webp",
100
+ "video/mp4",
101
+ "video/webm",
102
+ ]);
103
+
82
104
  export default {
83
105
  async fetch(request: Request, env: Env): Promise<Response> {
84
106
  const url = new URL(request.url);
85
107
 
86
108
  if (url.pathname.startsWith("/storage/")) {
109
+ const origin = request.headers.get("origin");
110
+ // `vary` rides on EVERY response, allowed origin or not: a shared
111
+ // cache keyed on the URL alone would otherwise replay one origin's
112
+ // `access-control-allow-origin` to another.
113
+ const cors = {
114
+ vary: "origin",
115
+ ...(origin !== null && ALLOWED_ORIGINS.has(origin)
116
+ ? { "access-control-allow-headers": "content-type", "access-control-allow-methods": "GET, PUT", "access-control-allow-origin": origin }
117
+ : {}),
118
+ };
119
+
120
+ // Before the verb check and the signature check: a preflight carries
121
+ // neither the signed method nor any credentials, so answering it
122
+ // later would 405 every cross-origin upload.
123
+ if (request.method === "OPTIONS") {
124
+ return new Response(null, { headers: cors, status: 204 });
125
+ }
126
+
127
+ // The method is signed, so a GET URL cannot be replayed as a PUT —
128
+ // check the verb anyway rather than relying on that alone.
129
+ if (request.method !== (url.searchParams.get("method") ?? "GET")) {
130
+ return new Response("method not allowed", { status: 405 });
131
+ }
132
+
87
133
  const result = await verifySignedUrl(url, env.STORAGE_SIGNING_SECRET);
88
134
 
89
135
  if (!result.valid || result.key === undefined) {
@@ -91,14 +137,64 @@ export default {
91
137
  return new Response("forbidden", { status: 403 });
92
138
  }
93
139
 
140
+ if (request.method === "PUT") {
141
+ // Store the content type the SIGNATURE pins, never the request's
142
+ // own header: the allowlist ran when the URL was minted, so
143
+ // trusting the header lets a caller mint for `image/png` and PUT
144
+ // `text/html` — stored XSS on this origin.
145
+ if (result.contentType === undefined) {
146
+ return new Response("upload URL carries no content type", { status: 400 });
147
+ }
148
+
149
+ // A declared length is the contract: R2 takes `request.body` as a
150
+ // stream, so there is nothing to measure before the write, and
151
+ // treating an ABSENT header as oversized would 413 every valid
152
+ // streamed upload. Demand it (411) and enforce it (413).
153
+ const declared = request.headers.get("content-length");
154
+
155
+ if (declared === null) {
156
+ return new Response("content-length required", { status: 411 });
157
+ }
158
+
159
+ const length = Number(declared);
160
+
161
+ if (!Number.isFinite(length) || length > MAX_UPLOAD_BYTES) {
162
+ return new Response("upload too large", { status: 413 });
163
+ }
164
+
165
+ await env.UPLOADS.put(result.key, request.body, { httpMetadata: { contentType: result.contentType } });
166
+
167
+ // The preflight's answer does not carry over: without CORS
168
+ // headers HERE too the browser passes preflight and then rejects
169
+ // the actual response.
170
+ return new Response(null, { headers: cors, status: 204 });
171
+ }
172
+
94
173
  const object = await env.UPLOADS.get(result.key);
95
174
 
96
175
  if (!object) {
97
176
  return new Response("not found", { status: 404 });
98
177
  }
99
178
 
179
+ // The stored content type came off an uploader-signed URL, so it is
180
+ // attacker-influenced: a CR/LF/NUL in it either throws inside
181
+ // `Headers` (an unhandled 500) or, on a permissive runtime, splits
182
+ // the response. Reject the value rather than reflect it — this is
183
+ // exactly what `isSafeHeaderValue` does inside `serveStorageObject`.
184
+ const rawContentType = object.httpMetadata?.contentType;
185
+ const contentType = rawContentType !== undefined && isSafeHeaderValue(rawContentType) ? rawContentType : "application/octet-stream";
186
+
100
187
  return new Response(object.body, {
101
- headers: { "content-type": object.httpMetadata?.contentType ?? "application/octet-stream" },
188
+ headers: {
189
+ ...cors,
190
+ // The URL expires; a cached copy would not. Without this a
191
+ // browser or CDN can keep serving private bytes past `exp`,
192
+ // with `verifySignedUrl` never consulted again.
193
+ "cache-control": "private, no-store",
194
+ ...(INLINE_SAFE.has(contentType.split(";")[0]?.trim().toLowerCase() ?? "") ? {} : { "content-disposition": "attachment" }),
195
+ "content-type": contentType,
196
+ "x-content-type-options": "nosniff",
197
+ },
102
198
  });
103
199
  }
104
200
 
@@ -108,51 +204,49 @@ export default {
108
204
  };
109
205
  ```
110
206
 
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.
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.
212
+
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.
114
216
 
115
- ## Step 5: Upload / download from the client
217
+ ## Step 4: Upload and download from the client
116
218
 
117
219
  ```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
- });
220
+ import { api } from "../lunora/_generated/api"; // path relative to your client file
123
221
 
124
- // 2. upload straight to R2 (no Worker proxy)
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.
125
228
  await fetch(url, { method: "PUT", headers: { "content-type": file.type }, body: file });
126
229
 
127
- // 3. later, get a signed GET URL to display it
128
- const { url: downloadUrl } = await client.action("storage/getDownloadUrl", { key: "avatar.png" });
230
+ // 3. Later, mint a signed GET URL to display it.
231
+ const { url: downloadUrl } = await client.action(api.storage.getDownloadUrl, { key: "avatar.png" });
129
232
  ```
130
233
 
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.
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`.