@zerotal/arch 1.7.0

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 (160) hide show
  1. package/CHANGELOG.md +31 -0
  2. package/LICENSE +21 -0
  3. package/README.md +67 -0
  4. package/api-surface.md +283 -0
  5. package/docs/about.md +676 -0
  6. package/docs/admin/actions.md +183 -0
  7. package/docs/admin/auth.md +89 -0
  8. package/docs/admin/dashboard.md +155 -0
  9. package/docs/admin/extending-ui.md +229 -0
  10. package/docs/admin/extending.md +205 -0
  11. package/docs/admin/forms.md +155 -0
  12. package/docs/admin/index.md +144 -0
  13. package/docs/admin/operations.md +224 -0
  14. package/docs/admin/references.md +174 -0
  15. package/docs/admin/resources.md +132 -0
  16. package/docs/admin/structure.md +158 -0
  17. package/docs/admin/tables.md +185 -0
  18. package/docs/admin/testing.md +138 -0
  19. package/docs/ai.md +373 -0
  20. package/docs/application.md +435 -0
  21. package/docs/arch.md +263 -0
  22. package/docs/assets.md +755 -0
  23. package/docs/audit.md +367 -0
  24. package/docs/authentication.md +1000 -0
  25. package/docs/authorization.md +529 -0
  26. package/docs/broadcasting/channels.md +170 -0
  27. package/docs/broadcasting/client.md +108 -0
  28. package/docs/broadcasting/events.md +190 -0
  29. package/docs/broadcasting/index.md +163 -0
  30. package/docs/broadcasting/references.md +84 -0
  31. package/docs/broadcasting/testing.md +132 -0
  32. package/docs/cache.md +387 -0
  33. package/docs/carbon.md +830 -0
  34. package/docs/changelog.md +465 -0
  35. package/docs/client/auth.md +113 -0
  36. package/docs/client/errors.md +139 -0
  37. package/docs/client/files.md +118 -0
  38. package/docs/client/index.md +128 -0
  39. package/docs/client/references.md +58 -0
  40. package/docs/client/requests.md +131 -0
  41. package/docs/client/resilience.md +141 -0
  42. package/docs/client/testing.md +146 -0
  43. package/docs/commands.md +450 -0
  44. package/docs/components.md +2427 -0
  45. package/docs/config-system.md +258 -0
  46. package/docs/container.md +596 -0
  47. package/docs/context.md +579 -0
  48. package/docs/contributing.md +164 -0
  49. package/docs/controllers.md +354 -0
  50. package/docs/conventions.md +466 -0
  51. package/docs/cookies.md +101 -0
  52. package/docs/csrf.md +206 -0
  53. package/docs/database.md +476 -0
  54. package/docs/deployment.md +434 -0
  55. package/docs/devtools.md +1013 -0
  56. package/docs/email-verification.md +320 -0
  57. package/docs/encryption.md +380 -0
  58. package/docs/errors.md +317 -0
  59. package/docs/events.md +420 -0
  60. package/docs/flow/components.md +846 -0
  61. package/docs/flow/decorators.md +458 -0
  62. package/docs/flow/events.md +420 -0
  63. package/docs/flow/forms.md +551 -0
  64. package/docs/flow/index.md +568 -0
  65. package/docs/flow/layouts.md +793 -0
  66. package/docs/flow/lifecycle.md +336 -0
  67. package/docs/flow/pagination.md +402 -0
  68. package/docs/flow/performance.md +262 -0
  69. package/docs/flow/references.md +317 -0
  70. package/docs/flow/routing.md +550 -0
  71. package/docs/flow/testing.md +447 -0
  72. package/docs/getting-started.md +286 -0
  73. package/docs/health.md +269 -0
  74. package/docs/helpers.md +423 -0
  75. package/docs/i18n.md +414 -0
  76. package/docs/index.md +65 -0
  77. package/docs/inertia/build.md +95 -0
  78. package/docs/inertia/devtools.md +197 -0
  79. package/docs/inertia/index.md +267 -0
  80. package/docs/inertia/middleware.md +81 -0
  81. package/docs/inertia/props.md +507 -0
  82. package/docs/inertia/references.md +144 -0
  83. package/docs/inertia/rendering.md +189 -0
  84. package/docs/inertia/ssr.md +102 -0
  85. package/docs/inspirations.md +257 -0
  86. package/docs/lifecycle.md +331 -0
  87. package/docs/lock.md +454 -0
  88. package/docs/logger.md +517 -0
  89. package/docs/media.md +582 -0
  90. package/docs/middleware.md +509 -0
  91. package/docs/migrations.md +529 -0
  92. package/docs/monitor.md +377 -0
  93. package/docs/notifications.md +848 -0
  94. package/docs/orm/casts.md +365 -0
  95. package/docs/orm/factories.md +231 -0
  96. package/docs/orm/index.md +663 -0
  97. package/docs/orm/lifecycle.md +421 -0
  98. package/docs/orm/queries.md +692 -0
  99. package/docs/orm/relationships.md +440 -0
  100. package/docs/orm/serialization.md +251 -0
  101. package/docs/package-development.md +536 -0
  102. package/docs/pagination.md +340 -0
  103. package/docs/password-reset.md +335 -0
  104. package/docs/providers.md +447 -0
  105. package/docs/query-builder.md +465 -0
  106. package/docs/queue.md +549 -0
  107. package/docs/rate-limiting.md +253 -0
  108. package/docs/responses.md +336 -0
  109. package/docs/roles-and-2fa.md +418 -0
  110. package/docs/routing.md +1008 -0
  111. package/docs/scaffolding.md +335 -0
  112. package/docs/scheduler.md +533 -0
  113. package/docs/seeding.md +229 -0
  114. package/docs/session.md +451 -0
  115. package/docs/social.md +524 -0
  116. package/docs/storage.md +622 -0
  117. package/docs/structure.md +230 -0
  118. package/docs/support-policy.md +114 -0
  119. package/docs/telemetry.md +505 -0
  120. package/docs/tenancy.md +608 -0
  121. package/docs/testing/browser.md +153 -0
  122. package/docs/testing/console.md +130 -0
  123. package/docs/testing/database.md +238 -0
  124. package/docs/testing/flow-browser.md +216 -0
  125. package/docs/testing/http.md +441 -0
  126. package/docs/testing/index.md +240 -0
  127. package/docs/testing/mocking.md +410 -0
  128. package/docs/upgrade.md +192 -0
  129. package/docs/validator.md +549 -0
  130. package/docs/view.md +453 -0
  131. package/package.json +60 -0
  132. package/src/bin/mcp.ts +73 -0
  133. package/src/config.ts +63 -0
  134. package/src/errors.ts +27 -0
  135. package/src/index.ts +56 -0
  136. package/src/install/ArchInstallCommand.ts +189 -0
  137. package/src/install/detect.ts +98 -0
  138. package/src/install/guidelines.ts +290 -0
  139. package/src/install/markers.ts +77 -0
  140. package/src/install/mcpConfig.ts +81 -0
  141. package/src/mcp/index.ts +27 -0
  142. package/src/mcp/jsonrpc.ts +133 -0
  143. package/src/mcp/server.ts +307 -0
  144. package/src/mcp/stdio.ts +111 -0
  145. package/src/mcp/types.ts +146 -0
  146. package/src/probe/ArchProbeCommand.ts +48 -0
  147. package/src/probe/sentinel.ts +13 -0
  148. package/src/probe/topics.ts +291 -0
  149. package/src/provider/ArchProvider.ts +101 -0
  150. package/src/tools/_probe.ts +164 -0
  151. package/src/tools/apiSurface.ts +241 -0
  152. package/src/tools/appInfo.ts +88 -0
  153. package/src/tools/baselines.ts +210 -0
  154. package/src/tools/context.ts +37 -0
  155. package/src/tools/doctor.ts +93 -0
  156. package/src/tools/index.ts +50 -0
  157. package/src/tools/logs.ts +265 -0
  158. package/src/tools/routes.ts +133 -0
  159. package/src/tools/schema.ts +119 -0
  160. package/src/tools/searchDocs.ts +345 -0
@@ -0,0 +1,622 @@
1
+ ---
2
+ title: Storage
3
+ description: Read and write files through one API that works the same on local disk or any S3-compatible service.
4
+ ---
5
+
6
+ # Storage
7
+
8
+ Unified file storage across the local filesystem and S3-compatible services (AWS S3, Cloudflare R2, MinIO, DigitalOcean Spaces). Every operation goes through the same driver API regardless of backend — swap the disk in config and your code stays the same.
9
+
10
+ ## Getting Started
11
+
12
+ Storage ships inside `@zerotal/core` — there is nothing to install. Import it
13
+ from the `core/storage` subpath:
14
+
15
+ ```ts
16
+ import { Storage, StorageProvider } from "zerotal/storage";
17
+ ```
18
+
19
+ It lives in core because file writes are not an optional concern: the logger's
20
+ own trail, uploads, and the media library all need one way to put bytes
21
+ somewhere. Keeping the abstraction next to them means every file operation in
22
+ the framework goes through the same driver API.
23
+
24
+ ## Register the provider
25
+
26
+ Add `StorageProvider` to the providers array in `bootstrap/providers.ts`:
27
+
28
+ ```ts
29
+ // bootstrap/providers.ts
30
+ import { StorageProvider } from "zerotal/storage";
31
+
32
+ export default [
33
+ // …your other providers
34
+ StorageProvider,
35
+ ];
36
+ ```
37
+
38
+ Registering the provider switches on the following hooks, in lifecycle order:
39
+
40
+ - `onRegister` — binds the `storage` container key as a lazy singleton (a `StorageManager` built from `config/storage.ts`).
41
+ - `onBooting` — eagerly resolves the `storage` binding so the manager is ready before the first request.
42
+
43
+ ## Private by default, one public directory
44
+
45
+ Two rules, both enforced rather than documented:
46
+
47
+ 1. **Every local disk lives under `storage/`.** A disk rooted anywhere else
48
+ throws when it is constructed.
49
+ 2. **Everything under `storage/` is private except `storage/public/`.** A disk
50
+ outside that directory cannot be served openly — only behind a signature.
51
+
52
+ ```text
53
+ storage/
54
+ app/ private uploads → no URL
55
+ logs/ the log trail → no URL
56
+ public/ the only public dir → /storage/public/**
57
+ ```
58
+
59
+ The filesystem and the URL are the same shape on purpose:
60
+ `storage/public/a.png` is `/storage/public/a.png`. "Is this file public?" is
61
+ answered by where it lives, which is checkable, instead of by which config block
62
+ someone edited last.
63
+
64
+ A disk outside `storage/public` can still be exposed — with `signed: true`, so
65
+ every request carries a signature you issued. Serving one openly throws
66
+ `UnsafePublicMountError` **at boot**, before the server accepts a request:
67
+
68
+ ```ts
69
+ // config/storage.ts — refused
70
+ invoices: { driver: "local", root: "./storage/invoices", serve: { path: "/invoices" } }
71
+ // ^ no `signed`
72
+
73
+ // config/storage.ts — fine
74
+ invoices: { driver: "local", root: "./storage/invoices",
75
+ serve: { path: "/invoices", signed: true } }
76
+ ```
77
+
78
+ S3 disks are exempt: a bucket is outside the filesystem entirely, and its
79
+ exposure is the bucket policy's business.
80
+
81
+ ### Why two boundaries
82
+
83
+ Each disk already rejects paths that climb out of _its own_ root, which stops
84
+ `../../etc/passwd` reaching past a disk. The storage root stops the disk itself
85
+ from being pointed at the filesystem in the first place — a stray
86
+ `root: "/etc"` throws `StorageRootEscapeError` at construction, so it fails on
87
+ boot rather than on the first upload. Nothing the framework writes — uploads,
88
+ the media library, the [log trail](/docs/logger) — lands outside it.
89
+
90
+ Set `ZT_STORAGE_ROOT` if your data volume is mounted elsewhere. The built-in
91
+ disks derive their roots from it, so they move with it:
92
+
93
+ ```ini
94
+ ZT_STORAGE_ROOT=/var/lib/myapp/storage
95
+ ```
96
+
97
+ ## Serving files over HTTP
98
+
99
+ A disk is reachable over the network only if it declares `serve`. The default
100
+ `public` disk does; the default `local` disk does not, which is what makes it a
101
+ sensible place for private uploads.
102
+
103
+ ```ts
104
+ // config/storage.ts
105
+ export default StorageConfig({
106
+ disks: {
107
+ // Inside storage/public, so it may be served openly.
108
+ public: { driver: "local", root: "./storage/public", serve: { path: "/storage/public" } },
109
+
110
+ // Outside it, so it may only be served signed — reachable through a link
111
+ // you issued, and only until it expires.
112
+ invoices: {
113
+ driver: "local",
114
+ root: "./storage/invoices",
115
+ serve: { path: "/invoices", signed: true },
116
+ },
117
+
118
+ // No `serve` block: no URL at all.
119
+ scratch: { driver: "local", root: "./storage/app/scratch" },
120
+ },
121
+ });
122
+ ```
123
+
124
+ | Field | Required | Description |
125
+ | --------- | -------- | --------------------------------------------------------------------- |
126
+ | `path` | yes | URL prefix the disk is mounted at, e.g. `/storage`. |
127
+ | `signed` | no | Require a valid `?expires=&signature=`, as `temporaryUrl()` produces. |
128
+ | `headers` | no | Extra response headers, e.g. `Cache-Control`. |
129
+
130
+ `StorageProvider` registers the serving middleware only when at least one disk
131
+ declares `serve`, and mounts the longest prefix first so `/files/private` is
132
+ matched before `/files`.
133
+
134
+ > **Note** — This is not [`Router.static()`](/docs/routing#static-file-serving). A static mount
135
+ > registers the files it finds when the server boots, so anything uploaded
136
+ > afterwards is invisible until a restart — which looks exactly like a broken
137
+ > upload. Serving resolves per request, through the driver, so an S3-backed disk
138
+ > can be proxied the same way a local one is served.
139
+
140
+ ### Signed disks
141
+
142
+ With `signed: true`, a request without a valid signature gets a `404` — not a
143
+ `403`. A rejection that says "forbidden" confirms the file exists to someone
144
+ guessing paths; a `404` tells them nothing.
145
+
146
+ ```ts
147
+ // in a controller
148
+ const url = await Storage.disk("invoices").temporaryUrl("2026-q1.pdf", 900);
149
+ // → /invoices/2026-q1.pdf?expires=1769990400&signature=…
150
+ ```
151
+
152
+ The signature covers the path as well as the expiry, so a link signed for one
153
+ file cannot be replayed against another.
154
+
155
+ ## Configuration
156
+
157
+ Create `config/storage.ts` with the `StorageConfig()` helper. It supplies the built-in `local` and `public` disks by default and deep-merges your disks on top, so you only declare what you add:
158
+
159
+ ```ts
160
+ // config/storage.ts
161
+ import { StorageConfig } from "zerotal/storage";
162
+ import { env } from "zerotal";
163
+
164
+ export default StorageConfig({
165
+ default: "local",
166
+ disks: {
167
+ s3: {
168
+ driver: "s3",
169
+ key: env("AWS_ACCESS_KEY_ID", ""),
170
+ secret: env("AWS_SECRET_ACCESS_KEY", ""),
171
+ region: env("AWS_DEFAULT_REGION", "us-east-1"),
172
+ bucket: env("AWS_BUCKET", ""),
173
+ url: env("AWS_URL", ""), // optional CDN or public domain
174
+ },
175
+ },
176
+ });
177
+ ```
178
+
179
+ | Field | Required | Default | Description |
180
+ | --------- | -------- | ------------------- | ---------------------------------------------------------------------------------------------- |
181
+ | `default` | no | `"local"` | Name of the disk used when you call `Storage.disk()` with no argument. |
182
+ | `disks` | no | `{ local, public }` | Name-keyed map of disk configs. Your disks merge with the built-in `local` and `public` disks. |
183
+
184
+ > **Note** — The defaults always include a private `local` disk (root
185
+ > `storage/app`, no URL) and a `public` disk (root `storage/public`, served at
186
+ > `/storage/public`). Both roots derive from the storage root, so they follow
187
+ > `ZT_STORAGE_ROOT`. You don't need to redeclare them.
188
+
189
+ Each disk is either a local or an S3 disk:
190
+
191
+ ```ts
192
+ // config/storage.ts — disk shapes
193
+ local: { driver: "local", root: "./storage/app", url: "/storage" /* optional */ }
194
+ s3: { driver: "s3", key, secret, region, bucket, endpoint?, url? }
195
+ ```
196
+
197
+ | Disk field | Driver | Required | Description |
198
+ | ---------- | ------ | -------- | ------------------------------------------------------ |
199
+ | `root` | local | yes | Directory files are read from and written to. |
200
+ | `url` | local | no | Base path/URL prepended by `.url()` (e.g. `/storage`). |
201
+ | `key` | s3 | yes | Access key ID. |
202
+ | `secret` | s3 | yes | Secret access key. |
203
+ | `region` | s3 | yes | Bucket region (`auto` for Cloudflare R2). |
204
+ | `bucket` | s3 | yes | Bucket name. |
205
+ | `endpoint` | s3 | no | Custom endpoint for R2, MinIO, etc. |
206
+ | `url` | s3 | no | Custom public URL base (CDN or R2 public domain). |
207
+
208
+ The `public` disk is already served at `/storage/public` — there is no static
209
+ route to register. See [Serving files over HTTP](#serving-files-over-http); a
210
+ static mount would be the wrong tool anyway, because it only registers the files
211
+ that existed when the server booted.
212
+
213
+ ## Selecting a disk
214
+
215
+ `Storage.disk(name?)` returns the driver for a configured disk, or the default disk when called with no name:
216
+
217
+ ```ts
218
+ // in a controller or service
219
+ import { Storage } from "zerotal/storage";
220
+
221
+ Storage.disk(); // default disk (config.default)
222
+ Storage.disk("local"); // local disk
223
+ Storage.disk("public"); // local/public disk (served at /storage/*)
224
+ Storage.disk("s3"); // S3 disk
225
+ ```
226
+
227
+ > **Note** — Naming a disk that isn't in your config throws `DiskNotConfiguredError`.
228
+
229
+ ## Writing files
230
+
231
+ `put()` accepts a string, `Uint8Array`, or `Blob`:
232
+
233
+ ```ts
234
+ function put(
235
+ path: string,
236
+ content: string | Uint8Array | Blob,
237
+ options?: PutOptions,
238
+ ): Promise<void>;
239
+ ```
240
+
241
+ ```ts
242
+ // in a controller
243
+ import { Storage } from "zerotal/storage";
244
+
245
+ // String content
246
+ await Storage.disk().put("docs/readme.txt", "Hello world", { contentType: "text/plain" });
247
+
248
+ // Uint8Array / ArrayBuffer (e.g. from a file upload)
249
+ await Storage.disk("s3").put("avatars/alice.jpg", buffer, { contentType: "image/jpeg" });
250
+
251
+ // Blob (from fetch / file upload)
252
+ const blob = await response.blob();
253
+ await Storage.disk("s3").put("reports/2026-q1.pdf", blob, { contentType: "application/pdf" });
254
+ ```
255
+
256
+ `PutOptions` has two fields:
257
+
258
+ | Field | Type | Description |
259
+ | ------------- | ----------------------- | ------------------------------------------------------------------ |
260
+ | `contentType` | `string` | MIME type. S3 defaults to `application/octet-stream` when omitted. |
261
+ | `visibility` | `"public" \| "private"` | Reserved for ACL handling. See the warning below. |
262
+
263
+ > **Warning** — `visibility` is accepted by `PutOptions`, but the current `local` and `s3` drivers do not act on it. On S3, set bucket policies/ACLs out of band; on `local`, control access with `Router.static()` and signed `temporaryUrl()`s.
264
+
265
+ ## Reading files
266
+
267
+ ```ts
268
+ // in a service
269
+ import { Storage } from "zerotal/storage";
270
+
271
+ // Read as string — returns null on miss
272
+ const text = await Storage.disk().get("docs/readme.txt");
273
+
274
+ // Read as Uint8Array — returns null on miss
275
+ const bytes = await Storage.disk().getBuffer("uploads/invoice.pdf");
276
+ ```
277
+
278
+ > **Note** — `get()` and `getBuffer()` return `null` when the file is absent rather than throwing, so you can branch on the result.
279
+
280
+ ## Checking existence
281
+
282
+ ```ts
283
+ // in a service
284
+ const exists = await Storage.disk().exists("avatars/alice.jpg"); // boolean
285
+ ```
286
+
287
+ ## Deleting files
288
+
289
+ ```ts
290
+ // in a service
291
+ await Storage.disk().delete("avatars/old.jpg");
292
+ ```
293
+
294
+ > **Note** — On the `local` driver, `delete()` silently ignores a missing file (it won't throw).
295
+
296
+ ## Copying and moving
297
+
298
+ Both operate within the same disk:
299
+
300
+ ```ts
301
+ // in a service
302
+ // Copy
303
+ await Storage.disk().copy("avatars/alice.jpg", "backups/alice-2026.jpg");
304
+
305
+ // Move / rename (copy, then delete the source)
306
+ await Storage.disk().move("tmp/upload.jpg", "avatars/alice.jpg");
307
+ ```
308
+
309
+ ## File metadata
310
+
311
+ ```ts
312
+ // in a service
313
+ const size = await Storage.disk().size("avatars/alice.jpg"); // bytes | null
314
+ const modified = await Storage.disk().lastModified("avatars/alice.jpg"); // ms epoch | null
315
+ ```
316
+
317
+ Both return `null` when the file isn't found.
318
+
319
+ ## URLs
320
+
321
+ ### `publicUrl()` — the one to reach for
322
+
323
+ When you need a URL for a template, ask for one and let the disk's config decide
324
+ what kind:
325
+
326
+ ```ts
327
+ // in a controller or view
328
+ const src = await Storage.publicUrl("avatars/alice.jpg", { disk: "public" });
329
+ // → /storage/avatars/alice.jpg (served, unsigned: permanent)
330
+
331
+ const invoice = await Storage.publicUrl("q1.pdf", { disk: "invoices" });
332
+ // → /invoices/q1.pdf?expires=…&signature=… (served + signed: expiring)
333
+
334
+ await Storage.publicUrl("secret.pdf", { disk: "local" });
335
+ // → throws DiskNotServedError — that disk has no public URL
336
+ ```
337
+
338
+ | Disk | `publicUrl()` returns |
339
+ | -------------------- | ----------------------------------------- |
340
+ | Served, unsigned | A permanent URL under the mount. |
341
+ | Served with `signed` | A signed URL valid for `serve.expiresIn`. |
342
+ | `url` set (CDN) | A URL under that base. |
343
+ | Neither | Throws `DiskNotServedError`. |
344
+
345
+ Throwing is deliberate. The alternative — handing back the stored path — is the
346
+ worst failure available: it produces a **relative** `src`, which the browser
347
+ resolves against whatever page embedded it. A media library at `/admin/shop/media`
348
+ asking for `media/photo.jpg` fetches `/admin/shop/media/media/photo.jpg` and gets
349
+ the panel's own 404, which looks like a broken upload and is not one.
350
+
351
+ Branch instead of catching when a disk may legitimately have no URL:
352
+
353
+ ```ts
354
+ // in a view
355
+ const src = Storage.isServed("public") ? await Storage.publicUrl(path, { disk: "public" }) : null;
356
+ ```
357
+
358
+ ### `url()` and `temporaryUrl()`
359
+
360
+ The lower-level pair `publicUrl()` is built on. `url()` builds a permanent URL
361
+ from the disk's base; `temporaryUrl()` signs one that expires. Reach for them
362
+ when you already know which kind you want.
363
+
364
+ A served disk's URL base **defaults to where it is mounted**, so `url()` and the
365
+ serving prefix cannot drift apart. Set `url` explicitly only to put a CDN in
366
+ front.
367
+
368
+ ```ts
369
+ // in a controller
370
+ // Permanent public URL (uses the disk's `url` base)
371
+ const url = Storage.disk("public").url("avatars/alice.jpg");
372
+ // → "/storage/avatars/alice.jpg"
373
+
374
+ const s3Url = Storage.disk("s3").url("avatars/alice.jpg");
375
+ // → "https://mybucket.s3.us-east-1.amazonaws.com/avatars/alice.jpg"
376
+ // Or your CDN, when the disk's `url` is set: "https://cdn.example.com/avatars/alice.jpg"
377
+
378
+ // Temporary signed URL — expires after N seconds
379
+ const signed = await Storage.disk("s3").temporaryUrl("reports/2026-q1.pdf", 3600);
380
+
381
+ // Local disk temporary URL — validated for you when the disk is served with
382
+ // `signed: true`; otherwise verify it yourself with Storage.verifyTemporaryUrl()
383
+ const localSigned = await Storage.disk("invoices").temporaryUrl("2026-q1.pdf", 900);
384
+ ```
385
+
386
+ > **Tip** — Use `temporaryUrl()` for private files that should be reachable for a limited window, and `url()` for files you intend to be publicly readable.
387
+
388
+ > **Note** — The `local` driver signs `temporaryUrl()`s with `APP_KEY` (it **throws** if `APP_KEY` is unset — a signed URL is never emitted with a guessable key). The signature is validated server-side; the framework gives you a verifier so you don't hand-roll the HMAC.
389
+
390
+ Serving the disk with `signed: true` validates the link for you — no route to
391
+ write. Reach for the verifiers only when you are serving the file yourself:
392
+
393
+ ```ts
394
+ Router.get("/files/:path*", async ({ params, request, response }) => {
395
+ const path = params.path;
396
+ // Reads ?expires= & ?signature= off the request URL and checks them against `path`.
397
+ if (!Storage.verifyTemporaryUrlFor(path, request.url)) {
398
+ return response.status(404).send("Not found");
399
+ }
400
+ const bytes = await Storage.disk("invoices").getBuffer(path);
401
+ return bytes ? response.send(bytes) : response.status(404).send("Not found");
402
+ });
403
+ ```
404
+
405
+ `Storage.verifyTemporaryUrl(path, expiresAt, signature)` is the lower-level form when
406
+ you already have the two values. S3 presigned URLs are verified by S3 itself and don't
407
+ pass through these helpers.
408
+
409
+ > **Path safety** — The `local` driver confines every path to its configured `root`.
410
+ > A path containing `..` that would resolve outside the root is rejected with a
411
+ > `PathTraversalError`, so forwarding a user-supplied key to `Storage.disk().get(key)`
412
+ > cannot read files elsewhere on disk.
413
+
414
+ ## File uploads from multipart forms
415
+
416
+ Handle an uploaded file in a controller action:
417
+
418
+ ```ts
419
+ // app/controllers/AvatarController.ts
420
+ import { Storage } from "zerotal/storage";
421
+ import type { HttpContext } from "zerotal";
422
+
423
+ export class AvatarController {
424
+ async store(ctx: HttpContext): Promise<Response> {
425
+ const formData = await ctx.request.formData();
426
+ const file = formData.get("avatar") as File | null;
427
+
428
+ if (!file || typeof file === "string") {
429
+ return Response.json({ error: "No file provided." }, { status: 422 });
430
+ }
431
+
432
+ // Generate a unique filename, preserving the original extension
433
+ const ext = file.name.split(".").pop() ?? "bin";
434
+ const filename = `avatars/${crypto.randomUUID()}.${ext}`;
435
+
436
+ await Storage.disk("s3").put(filename, await file.arrayBuffer(), {
437
+ contentType: file.type,
438
+ });
439
+
440
+ const url = Storage.disk("s3").url(filename);
441
+
442
+ await ctx.user?.update({ avatarUrl: url });
443
+
444
+ return Response.json({ url });
445
+ }
446
+ }
447
+ ```
448
+
449
+ > **Note** — The authenticated user is available as `ctx.user` when the auth middleware has run. See [Authentication](/docs/authentication).
450
+
451
+ Validate the upload first with a `FormRequest`:
452
+
453
+ ```ts
454
+ // app/requests/AvatarRequest.ts
455
+ import { FormRequest } from "@zerotal/validator";
456
+ import type { RuleBuilder } from "@zerotal/validator";
457
+
458
+ export class AvatarRequest extends FormRequest {
459
+ rules(v: RuleBuilder) {
460
+ return {
461
+ avatar: v.file().mimes(["jpg", "jpeg", "png", "webp"]).max(2048), // max 2 MB
462
+ };
463
+ }
464
+ }
465
+ ```
466
+
467
+ ## Organising files by date
468
+
469
+ A common pattern is to store uploads under a date-partitioned path to keep directories manageable:
470
+
471
+ ```ts
472
+ // in a service
473
+ function uploadPath(filename: string): string {
474
+ const now = new Date();
475
+ const year = now.getFullYear();
476
+ const month = String(now.getMonth() + 1).padStart(2, "0");
477
+ return `uploads/${year}/${month}/${filename}`;
478
+ }
479
+
480
+ const path = uploadPath(`${crypto.randomUUID()}.jpg`);
481
+ await Storage.disk("s3").put(path, buffer, { contentType: "image/jpeg" });
482
+ ```
483
+
484
+ ## Drivers
485
+
486
+ ### Which driver should I use?
487
+
488
+ - **`local`** — single-server deployments and local development. Files live on disk under `root` and are served via `Router.static()`.
489
+ - **`s3`** — any S3-compatible service (AWS S3, Cloudflare R2, MinIO, DigitalOcean Spaces, Backblaze B2). Use this for multi-server deployments, large objects, or when you want a CDN in front of your files.
490
+
491
+ ### local
492
+
493
+ Stores files on the local filesystem under the configured `root`, backed by Bun's `Bun.file`/`Bun.write`. Config keys: `root` (required) and `url` (optional — the base prepended by `.url()`).
494
+
495
+ ### s3
496
+
497
+ Stores files in any S3-compatible service, powered by Bun's native `S3Client` (no extra npm dependencies). Config keys: `key`, `secret`, `region`, `bucket`, plus optional `endpoint` and `url`.
498
+
499
+ **Cloudflare R2:**
500
+
501
+ ```ts
502
+ // config/storage.ts — a disk inside disks: { … }
503
+ r2: {
504
+ driver: "s3",
505
+ key: env("R2_ACCESS_KEY_ID", ""),
506
+ secret: env("R2_SECRET_ACCESS_KEY", ""),
507
+ region: "auto",
508
+ bucket: env("R2_BUCKET", ""),
509
+ endpoint: `https://${env("CF_ACCOUNT_ID", "")}.r2.cloudflarestorage.com`,
510
+ url: env("R2_PUBLIC_URL", ""), // your R2 custom domain
511
+ }
512
+ ```
513
+
514
+ **MinIO (self-hosted):**
515
+
516
+ ```ts
517
+ // config/storage.ts — a disk inside disks: { … }
518
+ minio: {
519
+ driver: "s3",
520
+ key: env("MINIO_ACCESS_KEY", "minioadmin"),
521
+ secret: env("MINIO_SECRET_KEY", "minioadmin"),
522
+ region: "us-east-1",
523
+ bucket: env("MINIO_BUCKET", "my-bucket"),
524
+ endpoint: env("MINIO_ENDPOINT", "http://localhost:9000"),
525
+ }
526
+ ```
527
+
528
+ ## Testing
529
+
530
+ Set your suite up once as described in [Testing](/docs/testing). Storage ships
531
+ two assertions, and they take either a disk name or a driver instance:
532
+
533
+ ```typescript
534
+ // tests/http/avatars.test.ts
535
+ import { test } from "bun:test";
536
+ import { assertStoredFile, assertMissingFile } from "@zerotal/testing";
537
+ import { createApp } from "../helpers.ts";
538
+
539
+ test("uploading an avatar writes it to the disk", async () => {
540
+ const app = await createApp();
541
+
542
+ const res = await app.actingAs(user).post("/avatar", { file: someUpload });
543
+
544
+ res.assertOk();
545
+ await assertStoredFile("local", `avatars/${user.id}.jpg`);
546
+ await app.close();
547
+ });
548
+ ```
549
+
550
+ **Point the test suite at a throwaway disk.** A test that writes to your real
551
+ `local` disk leaves files behind and passes on the second run for the wrong
552
+ reason. Configure a temp directory in `tests/helpers.ts` and clear it between
553
+ runs:
554
+
555
+ ```typescript
556
+ // tests/helpers.ts
557
+ .useConfig({
558
+ storage: { default: "local", disks: { local: { driver: "local", root: "./storage/tmp-test" } } },
559
+ })
560
+ ```
561
+
562
+ **Assert the deletion too.** `assertMissingFile` is the other half of any test
563
+ that removes a file — a delete endpoint returning `204` proves the route ran, not
564
+ that the bytes are gone:
565
+
566
+ ```typescript
567
+ // tests/http/avatars.test.ts
568
+ (await app.actingAs(user).delete("/avatar")).assertNoContent();
569
+
570
+ await assertMissingFile("local", `avatars/${user.id}.jpg`);
571
+ ```
572
+
573
+ **For a unit test, pass a driver instead of a disk name** — no application, no
574
+ config, no container:
575
+
576
+ ```typescript
577
+ // tests/services/ReportWriter.test.ts
578
+ import { LocalDriver } from "zerotal/storage";
579
+
580
+ const driver = new LocalDriver("./storage/tmp-test", "/");
581
+ await new ReportWriter(driver).write(report);
582
+
583
+ await assertStoredFile(driver, "reports/q3.pdf");
584
+ ```
585
+
586
+ > **Warning** — An S3 or R2 disk in a test hits the network and bills you.
587
+ > Override `storage.default` to a local disk in the test config rather than
588
+ > trusting that no code path reaches the remote one.
589
+
590
+ ## References
591
+
592
+ Methods on the disk driver returned by `Storage.disk()`:
593
+
594
+ | Method | Signature | Description |
595
+ | -------------- | ---------------------------------------------------------------------------------------------- | ------------------------------------------------ |
596
+ | `put` | `(path: string, content: string \| Uint8Array \| Blob, options?: PutOptions) => Promise<void>` | Write a file. |
597
+ | `get` | `(path: string) => Promise<string \| null>` | Read a file as text; `null` if absent. |
598
+ | `getBuffer` | `(path: string) => Promise<Uint8Array \| null>` | Read a file as bytes; `null` if absent. |
599
+ | `exists` | `(path: string) => Promise<boolean>` | Whether the file exists. |
600
+ | `delete` | `(path: string) => Promise<void>` | Remove a file. |
601
+ | `copy` | `(source: string, destination: string) => Promise<void>` | Copy within the same disk. |
602
+ | `move` | `(source: string, destination: string) => Promise<void>` | Move/rename within the same disk. |
603
+ | `size` | `(path: string) => Promise<number \| null>` | File size in bytes; `null` if absent. |
604
+ | `lastModified` | `(path: string) => Promise<number \| null>` | Last-modified time (ms epoch); `null` if absent. |
605
+ | `url` | `(path: string) => string` | Permanent public URL. |
606
+ | `temporaryUrl` | `(path: string, expiresInSeconds: number) => Promise<string>` | Time-limited signed URL. |
607
+
608
+ Manager and exports from `zerotal/storage`:
609
+
610
+ | Member | Signature | Description |
611
+ | ------------------------ | --------------------------------------------------------------- | ------------------------------------------------ |
612
+ | `Storage.disk` | `(name?: string) => StorageDriver` | Get a disk's driver (default disk when omitted). |
613
+ | `StorageConfig` | `(options?: Partial<StorageConfigShape>) => StorageConfigShape` | Build the storage config with merged defaults. |
614
+ | `StorageProvider` | `class` | Registers the `storage` binding. |
615
+ | `DiskNotConfiguredError` | `class` | Thrown when a named disk is missing from config. |
616
+
617
+ ## Next steps
618
+
619
+ - [Validator](/docs/validator) — validate file uploads before storing them.
620
+ - [Requests Context](/docs/context#uploaded-files) — read multipart form data in a controller.
621
+ - [Authentication](/docs/authentication) — access the current user via `ctx.user`.
622
+ - [Deployment](/docs/deployment) — configure S3 credentials per environment.