@nestm/storage 0.1.0-alpha.0 → 0.1.0-alpha.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (122) hide show
  1. package/CHANGELOG.md +264 -0
  2. package/README.md +724 -25
  3. package/SECURITY.md +101 -0
  4. package/dist/ai-sdk/ai-sdk-workspace-tools.d.ts +91 -0
  5. package/dist/ai-sdk/ai-sdk-workspace-tools.d.ts.map +1 -0
  6. package/dist/ai-sdk/ai-sdk-workspace-tools.js +491 -0
  7. package/dist/ai-sdk/ai-sdk-workspace-tools.js.map +1 -0
  8. package/dist/ai-sdk/index.d.ts +2 -0
  9. package/dist/ai-sdk/index.d.ts.map +1 -0
  10. package/dist/ai-sdk/index.js +2 -0
  11. package/dist/ai-sdk/index.js.map +1 -0
  12. package/dist/core/index.d.ts +6 -0
  13. package/dist/core/index.d.ts.map +1 -0
  14. package/dist/core/index.js +4 -0
  15. package/dist/core/index.js.map +1 -0
  16. package/dist/files-sdk/files-sdk.driver.d.ts +85 -3
  17. package/dist/files-sdk/files-sdk.driver.d.ts.map +1 -1
  18. package/dist/files-sdk/files-sdk.driver.js +1437 -37
  19. package/dist/files-sdk/files-sdk.driver.js.map +1 -1
  20. package/dist/files-sdk/fs/index.d.ts +42 -0
  21. package/dist/files-sdk/fs/index.d.ts.map +1 -0
  22. package/dist/files-sdk/fs/index.js +1117 -0
  23. package/dist/files-sdk/fs/index.js.map +1 -0
  24. package/dist/files-sdk/index.d.ts +1 -1
  25. package/dist/files-sdk/index.d.ts.map +1 -1
  26. package/dist/files-sdk/index.js.map +1 -1
  27. package/dist/files-sdk/provider/index.d.ts +58 -0
  28. package/dist/files-sdk/provider/index.d.ts.map +1 -0
  29. package/dist/files-sdk/provider/index.js +147 -0
  30. package/dist/files-sdk/provider/index.js.map +1 -0
  31. package/dist/files-sdk/s3/construction-metadata.d.ts +9 -0
  32. package/dist/files-sdk/s3/construction-metadata.d.ts.map +1 -0
  33. package/dist/files-sdk/s3/construction-metadata.js +24 -0
  34. package/dist/files-sdk/s3/construction-metadata.js.map +1 -0
  35. package/dist/files-sdk/s3/index.d.ts +54 -0
  36. package/dist/files-sdk/s3/index.d.ts.map +1 -0
  37. package/dist/files-sdk/s3/index.js +1505 -0
  38. package/dist/files-sdk/s3/index.js.map +1 -0
  39. package/dist/gateway/index.d.ts +1 -1
  40. package/dist/gateway/index.d.ts.map +1 -1
  41. package/dist/gateway/index.js.map +1 -1
  42. package/dist/gateway/storage-gateway-fastify-parser.d.ts.map +1 -1
  43. package/dist/gateway/storage-gateway-fastify-parser.js.map +1 -1
  44. package/dist/gateway/storage-gateway.controller.d.ts +12 -11
  45. package/dist/gateway/storage-gateway.controller.d.ts.map +1 -1
  46. package/dist/gateway/storage-gateway.controller.js +240 -70
  47. package/dist/gateway/storage-gateway.controller.js.map +1 -1
  48. package/dist/gateway/storage-gateway.guard.d.ts.map +1 -1
  49. package/dist/gateway/storage-gateway.guard.js.map +1 -1
  50. package/dist/gateway/storage-gateway.module.d.ts.map +1 -1
  51. package/dist/gateway/storage-gateway.module.js +60 -1
  52. package/dist/gateway/storage-gateway.module.js.map +1 -1
  53. package/dist/gateway/storage-gateway.tokens.d.ts +1 -0
  54. package/dist/gateway/storage-gateway.tokens.d.ts.map +1 -1
  55. package/dist/gateway/storage-gateway.tokens.js +1 -0
  56. package/dist/gateway/storage-gateway.tokens.js.map +1 -1
  57. package/dist/gateway/storage-gateway.types.d.ts +53 -10
  58. package/dist/gateway/storage-gateway.types.d.ts.map +1 -1
  59. package/dist/gateway/storage-gateway.types.js.map +1 -1
  60. package/dist/index.d.ts +1 -5
  61. package/dist/index.d.ts.map +1 -1
  62. package/dist/index.js +1 -3
  63. package/dist/index.js.map +1 -1
  64. package/dist/inject-storage.decorator.js.map +1 -1
  65. package/dist/storage-etag.d.ts +13 -0
  66. package/dist/storage-etag.d.ts.map +1 -0
  67. package/dist/storage-etag.js +31 -0
  68. package/dist/storage-etag.js.map +1 -0
  69. package/dist/storage-upload-control.d.ts.map +1 -1
  70. package/dist/storage.client.d.ts +15 -3
  71. package/dist/storage.client.d.ts.map +1 -1
  72. package/dist/storage.client.js +204 -2
  73. package/dist/storage.client.js.map +1 -1
  74. package/dist/storage.driver.d.ts +28 -1
  75. package/dist/storage.driver.d.ts.map +1 -1
  76. package/dist/storage.driver.js.map +1 -1
  77. package/dist/storage.error.d.ts +17 -10
  78. package/dist/storage.error.d.ts.map +1 -1
  79. package/dist/storage.error.js +47 -1
  80. package/dist/storage.error.js.map +1 -1
  81. package/dist/storage.module.d.ts.map +1 -1
  82. package/dist/storage.module.js.map +1 -1
  83. package/dist/storage.service.d.ts.map +1 -1
  84. package/dist/storage.service.js +28 -3
  85. package/dist/storage.service.js.map +1 -1
  86. package/dist/storage.tokens.js.map +1 -1
  87. package/dist/storage.types.d.ts +150 -1
  88. package/dist/storage.types.d.ts.map +1 -1
  89. package/dist/storage.types.js.map +1 -1
  90. package/dist/testing/index.d.ts +1 -0
  91. package/dist/testing/index.d.ts.map +1 -1
  92. package/dist/testing/index.js +1 -0
  93. package/dist/testing/index.js.map +1 -1
  94. package/dist/testing/provider-conformance.d.ts +65 -0
  95. package/dist/testing/provider-conformance.d.ts.map +1 -0
  96. package/dist/testing/provider-conformance.js +869 -0
  97. package/dist/testing/provider-conformance.js.map +1 -0
  98. package/dist/workspace/index.d.ts +5 -0
  99. package/dist/workspace/index.d.ts.map +1 -0
  100. package/dist/workspace/index.js +5 -0
  101. package/dist/workspace/index.js.map +1 -0
  102. package/dist/workspace/storage-workspace.cursor.d.ts +71 -0
  103. package/dist/workspace/storage-workspace.cursor.d.ts.map +1 -0
  104. package/dist/workspace/storage-workspace.cursor.js +408 -0
  105. package/dist/workspace/storage-workspace.cursor.js.map +1 -0
  106. package/dist/workspace/storage-workspace.d.ts +6 -0
  107. package/dist/workspace/storage-workspace.d.ts.map +1 -0
  108. package/dist/workspace/storage-workspace.error.d.ts +32 -0
  109. package/dist/workspace/storage-workspace.error.d.ts.map +1 -0
  110. package/dist/workspace/storage-workspace.error.js +75 -0
  111. package/dist/workspace/storage-workspace.error.js.map +1 -0
  112. package/dist/workspace/storage-workspace.js +849 -0
  113. package/dist/workspace/storage-workspace.js.map +1 -0
  114. package/dist/workspace/storage-workspace.path.d.ts +9 -0
  115. package/dist/workspace/storage-workspace.path.d.ts.map +1 -0
  116. package/dist/workspace/storage-workspace.path.js +68 -0
  117. package/dist/workspace/storage-workspace.path.js.map +1 -0
  118. package/dist/workspace/storage-workspace.types.d.ts +123 -0
  119. package/dist/workspace/storage-workspace.types.d.ts.map +1 -0
  120. package/dist/workspace/storage-workspace.types.js +22 -0
  121. package/dist/workspace/storage-workspace.types.js.map +1 -0
  122. package/package.json +103 -35
package/README.md CHANGED
@@ -1,27 +1,48 @@
1
1
  # @nestm/storage
2
2
 
3
- NestJS 12 storage integration with named stores, explicit streaming I/O,
4
- cross-store workflows, and an optional guarded HTTP gateway.
3
+ Framework-neutral storage clients with NestJS 12 integration, named stores,
4
+ explicit streaming I/O, capability-scoped agent workspaces, cross-store
5
+ workflows, and an optional guarded HTTP gateway.
5
6
 
6
7
  The package uses [`files-sdk`](https://github.com/haydenbleasel/files-sdk) as
7
8
  its provider engine, but owns the API injected into Nest applications. Provider
8
9
  SDK types, errors, and `files.raw` do not leak through the root package.
9
10
 
10
- > This package targets the NestJS 12 prerelease line and is itself published on
11
- > the `alpha` dist-tag.
11
+ > This package targets stable NestJS 12 and is itself published on the `alpha`
12
+ > dist-tag.
12
13
 
13
14
  ## Requirements
14
15
 
15
16
  - Node.js 22.12 or newer
16
- - NestJS `12.0.0-alpha.5` or newer in the Nest 12 prerelease line
17
17
  - ESM
18
18
 
19
+ The framework-neutral `@nestm/storage/core` entry point does not require
20
+ NestJS. The root entry point and HTTP gateway additionally require NestJS 12,
21
+ `reflect-metadata`, and RxJS. Those framework peers are optional at installation
22
+ time so core-only consumers do not download NestJS.
23
+
19
24
  ## Install
20
25
 
21
26
  ```sh
22
- pnpm add @nestm/storage@alpha files-sdk@2.2.2
27
+ pnpm add @nestm/storage@alpha
28
+ ```
29
+
30
+ The package-owned S3 factory needs no direct `files-sdk` import. Install the
31
+ pinned engine explicitly only when the application imports another adapter
32
+ such as `files-sdk/gcs`:
33
+
34
+ ```sh
35
+ pnpm add files-sdk@2.3.0
23
36
  ```
24
37
 
38
+ Pass AWS-SDK-backed S3 adapters through the package-owned `s3()` and
39
+ `withS3Capabilities()` helpers (or use `createS3StorageDriver()`).
40
+ `createFilesSdkDriver()` rejects a structurally S3-backed raw adapter that has
41
+ not crossed this provenance boundary, even when a wrapper renames or proxies
42
+ the adapter. This prevents callers from bypassing endpoint and provider-profile
43
+ checks by omitting the capability decorator or replacing the public `raw`
44
+ property while retaining methods closed over the original client.
45
+
25
46
  Install only the native SDKs required by the chosen provider. For example:
26
47
 
27
48
  ```sh
@@ -36,26 +57,335 @@ pnpm add @google-cloud/storage google-auth-library
36
57
  pnpm add @azure/storage-blob @azure/core-auth @azure/identity
37
58
  ```
38
59
 
60
+ `@aws-sdk/client-s3` 3.1079.0 or newer is required, matching the Files SDK 2.3
61
+ peer floor. The supported range includes destination `If-Match` and
62
+ `If-None-Match` serialization for `CopyObject`; the package peer range and
63
+ packed minimum-peer smoke test enforce this floor.
64
+
39
65
  `files-sdk` currently declares its optional Nest peer for Nest 10 and 11. This
40
66
  library does not import `files-sdk/nestjs`; the Nest 12 integration is entirely
41
67
  owned here. A package manager may nevertheless report that temporary optional
42
- peer mismatch while Nest 12 remains prerelease.
68
+ peer mismatch until Files SDK widens its declaration to include Nest 12.
69
+
70
+ ## Framework-neutral core
71
+
72
+ Import storage primitives from `@nestm/storage/core` in workers, scripts, and
73
+ applications that do not use NestJS:
74
+
75
+ ```ts
76
+ import {
77
+ StorageClient,
78
+ type StorageDriver,
79
+ type StorageUploadOptions,
80
+ } from '@nestm/storage/core';
81
+
82
+ declare const driver: StorageDriver;
83
+
84
+ const media = new StorageClient('media', driver);
85
+
86
+ await media.upload('avatars/user.png', image, {
87
+ contentType: 'image/png',
88
+ } satisfies StorageUploadOptions);
89
+
90
+ await media.onApplicationShutdown();
91
+ ```
92
+
93
+ The core entry point exports `StorageClient`, the `StorageDriver` contract,
94
+ storage errors and operation types, and `StorageUploadControl`. It has no NestJS
95
+ runtime or declaration imports. Provider adapters remain available through
96
+ `@nestm/storage/files-sdk`.
97
+
98
+ ## Mounted agent workspaces
99
+
100
+ `@nestm/storage/workspace` turns a `StorageClient` into a narrow capability for
101
+ one logical directory. The mount is virtual: the same API works over S3, a
102
+ filesystem driver, or another storage backend without exposing the provider,
103
+ bucket, filesystem root, raw cursor, or internal prefix to its caller.
104
+
105
+ ```mermaid
106
+ flowchart LR
107
+ A["Trusted application context"] -->|"store + opaque prefix + policy"| W["StorageWorkspace"]
108
+ W --> C["StorageClient"]
109
+ C --> D["S3 / filesystem / other driver"]
110
+ W --> T["AI SDK workspace tools"]
111
+ T --> G["ToolLoopAgent"]
112
+ ```
113
+
114
+ Only trusted application code chooses the mount prefix. Every path accepted by
115
+ the workspace is a canonical, relative POSIX path. Absolute paths, backslashes,
116
+ control characters, repeated separators, and `.` or `..` segments are rejected
117
+ rather than normalized. Keys and provider cursors returned by a driver are also
118
+ checked before they are converted back to logical paths.
119
+
120
+ Pagination requires a server-owned cursor configuration. The built-in
121
+ `Aes256GcmStorageWorkspaceCursorCodec` produces versioned, authenticated,
122
+ encrypted tokens that can resume on another request, process, or replica when
123
+ every replica constructs an equivalent codec from the same key ring and uses
124
+ the same stable store identity, physical prefix, mount ID, trusted scope, and
125
+ effective limits. Use one codec instance per process, use a dedicated 32-byte
126
+ key, retain rotated decryption keys for at least one cursor TTL, and derive
127
+ `mountId` and `scope` only from authenticated server context.
128
+
129
+ The underlying driver must also implement the universal replayable list-cursor
130
+ contract against the same logical backend namespace: its cursor cannot be
131
+ consumed or tied to one driver instance. While the provider cursor remains
132
+ valid and available, an outer cursor can be retried until its authenticated
133
+ expiry. That expiry is only an authorization ceiling: it does not extend a
134
+ provider token's lifetime or promise snapshot isolation, provider availability,
135
+ network access, or valid credentials. Provider invalidation is an operational
136
+ list failure, and concurrent object changes remain subject to provider
137
+ continuation semantics. Without a codec, single-page operations still work but
138
+ a continuation fails closed.
139
+
140
+ ```ts
141
+ import {
142
+ Aes256GcmStorageWorkspaceCursorCodec,
143
+ mountStorageWorkspace,
144
+ } from '@nestm/storage/workspace';
145
+
146
+ // cursorKey is a separately validated 32-byte secret from deployment config.
147
+ const cursorCodec = new Aes256GcmStorageWorkspaceCursorCodec({
148
+ activeKeyId: 'v1',
149
+ keys: { v1: cursorKey },
150
+ });
151
+
152
+ const workspace = mountStorageWorkspace(agentFiles, {
153
+ // Use an opaque server-derived run id, never a value selected by the model.
154
+ prefix: `workspaces/${runId}`,
155
+ cursor: {
156
+ codec: cursorCodec,
157
+ mountId: `agent-workspace:${runId}`,
158
+ scope: `organization:${organizationId}/workspace:${workspaceId}`,
159
+ },
160
+ permissions: [
161
+ 'list',
162
+ 'read',
163
+ 'search',
164
+ 'write',
165
+ 'create',
166
+ 'replace',
167
+ 'copy',
168
+ 'move',
169
+ 'delete',
170
+ ],
171
+ limits: {
172
+ cursorTtlMs: 5 * 60 * 1000,
173
+ maxCursorBytes: 4096,
174
+ maxReadBytes: 1024 * 1024,
175
+ maxWriteBytes: 1024 * 1024,
176
+ maxPageSize: 100,
177
+ maxSearchResults: 100,
178
+ maxSearchScan: 1000,
179
+ },
180
+ });
181
+
182
+ const created = await workspace.writeFile(
183
+ 'src/main.ts',
184
+ 'export const ready = true;\n',
185
+ { mode: 'create', contentType: 'text/typescript' },
186
+ );
187
+
188
+ if (created.etag === undefined) {
189
+ throw new Error('This backend cannot safely replace the object.');
190
+ }
191
+
192
+ await workspace.writeFile('src/main.ts', 'export const ready = false;\n', {
193
+ mode: 'replace',
194
+ etag: created.etag,
195
+ contentType: 'text/typescript',
196
+ });
197
+
198
+ const image = await workspace.readBytes('assets/logo.png');
199
+ console.log(image.bytes.byteLength);
200
+ ```
201
+
202
+ `readBytes` is now a required member of the exported `StorageWorkspace`
203
+ interface. Workspaces returned by `mountStorageWorkspace` provide it
204
+ automatically; custom implementations and typed test doubles must add the
205
+ method when adopting this alpha minor.
206
+
207
+ Create, replace, and delete are conditional operations. A driver that cannot
208
+ enforce the requested not-exists or ETag precondition fails with
209
+ `NOT_SUPPORTED`; the workspace never substitutes an `exists()`/`head()` check
210
+ followed by an unconditional mutation. Reads enforce their byte ceiling while
211
+ consuming the stream, and list/search results are bounded. Search supports
212
+ exact, substring, and workspace-coordinate glob matching, but no caller-supplied
213
+ regular expressions.
214
+
215
+ Move is implemented as create-only copy followed by ETag-conditional source
216
+ delete. If source deletion cannot be confirmed, the destination is retained and
217
+ the call returns `CONFLICT`; inspect both logical paths before retrying. This
218
+ preserves at least one copy across provider timeouts and post-operation hook
219
+ failures, but does not pretend a multi-object move is transactionally atomic.
220
+
221
+ Callers that prefer the ordinary Files pipeline can opt into explicit
222
+ last-write-wins variants. The `write` permission is separate from conditional
223
+ `create` and `replace` authority:
43
224
 
44
- NestJS 12 alpha also has prerelease peer declarations that npm may reject under
45
- its strict resolver. If npm reports an `ERESOLVE` error for Nest's own peers,
46
- install with `npm install --legacy-peer-deps`; pnpm works with the repository's
47
- checked-in peer-version policy.
225
+ ```ts
226
+ await workspace.writeFile('notes.txt', 'latest contents', {
227
+ mode: 'overwrite',
228
+ });
229
+ await workspace.copyFile('notes.txt', 'backup.txt', { mode: 'overwrite' });
230
+ await workspace.deleteFile('backup.txt', { mode: 'unconditional' });
231
+ ```
232
+
233
+ Overwrite copy reads the latest source through the ordinary download pipeline,
234
+ enforces `maxWriteBytes` while collecting it, and uploads it through the
235
+ ordinary upload pipeline. It never substitutes the provider's server-side
236
+ copy. These paths compose with Files SDK plugins, hooks, and receipts, including
237
+ the built-in `encryption()` plugin. That plugin is useful compatibility
238
+ evidence, not an Artifact-specific security policy: strict encrypted-only
239
+ reads, tenant/path-bound AAD, key custody and rotation, and copy/move rules
240
+ remain application-owned.
241
+
242
+ Move remains conditional-only. A last-write-wins download/upload/delete
243
+ sequence could copy one source generation and then delete a newer generation
244
+ written during the transfer. Use the ETag-conditional `moveFile` variant when a
245
+ move is required.
246
+
247
+ A child mount may further restrict a directory, permissions, or limits, but it
248
+ cannot widen any of them:
249
+
250
+ ```ts
251
+ const readOnlySource = workspace.mount('src', {
252
+ permissions: ['list', 'read', 'search'],
253
+ limits: { maxReadBytes: 256 * 1024 },
254
+ });
255
+ ```
256
+
257
+ ### AI SDK and NestJS composition
258
+
259
+ Install AI SDK 7 and Zod only in applications that use the optional adapter:
260
+
261
+ ```sh
262
+ pnpm add ai@^7 zod@^4
263
+ ```
264
+
265
+ `files-sdk` 2.3.x still declares an optional `ai@^6` peer for its own adapter,
266
+ so some package managers may print a peer warning when AI SDK 7 is installed.
267
+ This package does not import that adapter; `@nestm/storage/ai-sdk` targets AI
268
+ SDK 7 directly.
269
+
270
+ `@nestm/storage/ai-sdk` converts an already-mounted workspace to an ordinary
271
+ upstream `ToolSet`. It does not import NestJS or `@nestm/ai-sdk`; the application
272
+ composes the tool set through the AI module's existing named-toolset factory.
273
+ For a tenant or run selected per request, make both factories request-scoped and
274
+ derive the mount coordinate from authenticated host context:
275
+
276
+ ```ts
277
+ import { Module, Scope } from '@nestjs/common';
278
+ import { AiSdkModule, AiSdkService, getAiToolsetToken } from '@nestm/ai-sdk';
279
+ import { getStorageToken, type StorageClient } from '@nestm/storage';
280
+ import { createAiSdkWorkspaceTools } from '@nestm/storage/ai-sdk';
281
+ import { mountStorageWorkspace } from '@nestm/storage/workspace';
282
+ import type { ToolSet } from 'ai';
283
+
284
+ @Module({
285
+ imports: [
286
+ AppStorageModule,
287
+ WorkspaceContextModule,
288
+ AiSdkModule.forFeature({
289
+ imports: [AppStorageModule, WorkspaceContextModule],
290
+ toolsets: [
291
+ {
292
+ name: 'workspace',
293
+ scope: Scope.REQUEST,
294
+ inject: [getStorageToken('agent-files'), WorkspaceContext],
295
+ useFactory: (storage: StorageClient, context: WorkspaceContext) =>
296
+ createAiSdkWorkspaceTools({
297
+ workspace: mountStorageWorkspace(storage, {
298
+ // A validated, opaque coordinate from trusted auth/run state.
299
+ // It is never accepted from a prompt or tool input.
300
+ prefix: context.storagePrefix,
301
+ // Includes the singleton codec plus stable mountId and scope.
302
+ cursor: context.cursorConfiguration,
303
+ permissions: [
304
+ 'list',
305
+ 'read',
306
+ 'search',
307
+ 'write',
308
+ 'create',
309
+ 'replace',
310
+ 'copy',
311
+ 'move',
312
+ 'delete',
313
+ ],
314
+ }),
315
+ }),
316
+ },
317
+ ],
318
+ agents: [
319
+ {
320
+ name: 'workspace-agent',
321
+ scope: Scope.REQUEST,
322
+ inject: [AiSdkService, getAiToolsetToken('workspace')],
323
+ useFactory: (ai: AiSdkService, tools: ToolSet) => ({
324
+ model: ai.languageModel(),
325
+ instructions:
326
+ 'Use only the mounted workspace tools for file operations.',
327
+ tools,
328
+ }),
329
+ },
330
+ ],
331
+ }),
332
+ ],
333
+ })
334
+ export class WorkspaceAgentModule {}
335
+ ```
336
+
337
+ The generated set contains only tools allowed by the workspace permissions:
338
+ bounded list, stat, UTF-8 read, and search tools plus conditional create,
339
+ replace, copy, move, and delete tools when granted. Mutation tools require AI
340
+ SDK user approval by default; approval can be configured per tool, but the
341
+ workspace capability remains the authorization boundary even when approval is
342
+ disabled. The module's `AiSdkService.files()` API is the model provider's file
343
+ upload facility and is unrelated to storage workspaces.
344
+
345
+ `mutationMode` defaults to `'conditional'`. A trusted composition can instead
346
+ select `{ mutationMode: 'last-write-wins' }`; generated mutation schemas then
347
+ omit ETags and modes, hardcode the explicit overwrite/unconditional workspace
348
+ variants, and require `write` permission for destination mutations.
349
+ Unconditional delete requires both `write` and `delete`. The move tool is
350
+ omitted in last-write-wins mode because Workspace move remains conditional-only.
351
+
352
+ Atomic create collisions remain sanitized tool errors by default. Applications
353
+ that model an existing destination as a normal tool result can map that one
354
+ case while preserving replace/ETag conflicts as failures:
355
+
356
+ ```ts
357
+ const tools = createAiSdkWorkspaceTools({
358
+ workspace,
359
+ mapCreateConflict: ({ path }) => ({
360
+ kind: 'artifact-conflict' as const,
361
+ path,
362
+ status: 'already-exists' as const,
363
+ }),
364
+ });
365
+ ```
366
+
367
+ The mapper receives only the logical workspace path; provider errors, object
368
+ keys, and mount coordinates are never exposed. `mapCreateConflict` is valid
369
+ only in conditional mode and is rejected with last-write-wins mode.
370
+
371
+ This logical confinement is sufficient for a `ToolLoopAgent` whose only file
372
+ capabilities are these tools. It cannot constrain a coding harness that already
373
+ has shell, `node:fs`, or subprocess access. For Codex/Claude-style harnesses,
374
+ materialize the workspace into a per-session container or VM, mount only that
375
+ directory, run the harness there, and synchronize reviewed changes back through
376
+ `StorageWorkspace`. A working directory alone is not a sandbox.
48
377
 
49
378
  ## Configure named stores
50
379
 
51
- Create provider adapters with `files-sdk`, wrap them through the explicit
52
- bridge, and register the resulting drivers:
380
+ Use the package-owned S3 factory when applicable. For other providers, create a
381
+ `files-sdk` adapter, wrap it through the explicit bridge, and register the
382
+ resulting driver:
53
383
 
54
384
  ```ts
55
385
  import { Module } from '@nestjs/common';
56
- import { s3 } from 'files-sdk/s3';
57
386
  import { gcs } from 'files-sdk/gcs';
58
387
  import { createFilesSdkDriver } from '@nestm/storage/files-sdk';
388
+ import { createS3StorageDriver } from '@nestm/storage/files-sdk/s3';
59
389
  import { StorageModule } from '@nestm/storage';
60
390
 
61
391
  export const StorageKey = {
@@ -70,11 +400,11 @@ export const StorageKey = {
70
400
  stores: [
71
401
  {
72
402
  name: StorageKey.MEDIA,
73
- driver: createFilesSdkDriver({
74
- adapter: s3({
403
+ driver: createS3StorageDriver({
404
+ adapter: {
75
405
  bucket: 'media',
76
406
  region: 'us-east-1',
77
- }),
407
+ },
78
408
  }),
79
409
  },
80
410
  {
@@ -160,11 +490,11 @@ StorageModule.forRootAsync({
160
490
  name: 'media',
161
491
  inject: [ConfigService],
162
492
  useFactory: (config: ConfigService) =>
163
- createFilesSdkDriver({
164
- adapter: s3({
493
+ createS3StorageDriver({
494
+ adapter: {
165
495
  bucket: config.getOrThrow('MEDIA_BUCKET'),
166
496
  region: config.getOrThrow('AWS_REGION'),
167
- }),
497
+ },
168
498
  }),
169
499
  },
170
500
  ],
@@ -182,11 +512,136 @@ modules retain their own DI scope rather than mutating an application-wide
182
512
  registry. Names are case-sensitive and cannot contain leading or trailing
183
513
  whitespace.
184
514
 
515
+ ### Select the provider at runtime
516
+
517
+ An application that ships to more than one environment usually cannot name its
518
+ provider at build time. `createProviderStorageDriver` takes the slug as data and
519
+ imports that provider's adapter — and only that one — on demand, so a deployment
520
+ picks its store with an environment variable and installs one native SDK:
521
+
522
+ ```ts
523
+ import { createProviderStorageDriver } from '@nestm/storage/files-sdk/provider';
524
+
525
+ StorageModule.forRootAsync({
526
+ imports: [ConfigModule],
527
+ stores: [
528
+ {
529
+ name: 'media',
530
+ inject: [ConfigService],
531
+ useFactory: (config: ConfigService) =>
532
+ createProviderStorageDriver({
533
+ provider: config.getOrThrow('STORAGE_PROVIDER'),
534
+ prefix: config.get('STORAGE_PREFIX'),
535
+ config: {
536
+ bucket: config.get('STORAGE_BUCKET'),
537
+ region: config.get('STORAGE_REGION'),
538
+ root: config.get('STORAGE_ROOT'),
539
+ },
540
+ }),
541
+ },
542
+ ],
543
+ });
544
+ ```
545
+
546
+ `config` is one flat bag of provider settings — `bucket` and `region` for an
547
+ object store, `root` for the filesystem, `accountName` and `container` for
548
+ Azure. Each provider reads what it needs and ignores the rest, so the same shape
549
+ survives a provider change. Credentials may be omitted wherever the provider's
550
+ SDK resolves its own chain (an IAM role, Application Default Credentials, a
551
+ shared profile).
552
+
553
+ An unknown slug fails closed with `INVALID_ARGUMENT` before anything is
554
+ imported. Validate untrusted input up front with `isStorageProvider`, and drive
555
+ config validation from the catalog rather than a hand-kept list:
556
+
557
+ ```ts
558
+ import {
559
+ getStorageProvider,
560
+ isStorageProvider,
561
+ listStorageProviders,
562
+ listStorageProviderSecretEnvVars,
563
+ } from '@nestm/storage/files-sdk/provider';
564
+
565
+ listStorageProviders().map((provider) => provider.slug); // 'akamai', 'alibaba', …
566
+ getStorageProvider('gcs')?.peerDeps; // ['@google-cloud/storage', …]
567
+ listStorageProviderSecretEnvVars('s3').map((variable) => variable.key);
568
+ ```
569
+
570
+ The catalog is pure data and pulls in no adapter, so it is safe in config UIs,
571
+ health checks, and startup validation.
572
+
573
+ The `s3` slug additionally carries the verified per-operation profile and
574
+ signed-policy capabilities described under
575
+ [Exact provider conditions and staged-object promotion](#exact-provider-conditions-and-staged-object-promotion);
576
+ every provider not backed by the AWS S3 SDK exposes what its adapter declares.
577
+ When the provider _is_ known at build time, import
578
+ `@nestm/storage/files-sdk/s3` or `@nestm/storage/files-sdk/fs` directly and skip
579
+ the indirection.
580
+
581
+ S3 endpoint and public-URL provenance is resolved from the adapter that
582
+ `files-sdk` actually constructs, including values merged from `configJson`.
583
+ An unaudited endpoint forces the driver read-only, and a `publicBaseUrl` removes
584
+ the signed-download TTL guarantee because the resulting public URL does not
585
+ expire. AWS-SDK-backed noncanonical provider slugs (for example an S3-compatible
586
+ provider wrapper) also default to unverified/read-only; only the canonical
587
+ `s3` provider may infer the native AWS profile, and a custom endpoint becomes
588
+ writable only with an explicit branded `S3ProviderProfile`.
589
+
590
+ Before enabling conditional operations for a custom S3-compatible endpoint,
591
+ run the reusable
592
+ [provider conformance contract](https://github.com/nestm-dev/storage/blob/main/docs/provider-conformance.md)
593
+ against dedicated test credentials. Unknown endpoints are forced read-only and
594
+ receive no inferred conditional capabilities.
595
+
596
+ ## Files SDK responsibility boundary
597
+
598
+ Files SDK is the upstream authority for the generic storage data plane:
599
+ provider adapters, generic CRUD, bulk and list operations, retries, transfers
600
+ and sync, its plugin pipeline, and framework-neutral gateway mechanics.
601
+ `@nestm/storage` retains the guarantees that Files SDK does not currently
602
+ provide: NestJS 12 named stores, exact native conditional/CAS capabilities,
603
+ `StorageWorkspace` permissions and limits, bounded storage errors, and
604
+ capability-scoped AI tools.
605
+
606
+ Files SDK 2.3 provides native conditional operations through the same
607
+ interception boundary as ordinary CRUD. NestM adapters expose their exact
608
+ create, replace, ETag read, delete, and paired conditional-copy primitives to
609
+ that boundary. These operations now pass through caller-configured Files
610
+ plugins, hooks, retries, and receipts; a body transform, veto, retry observer,
611
+ or audit policy therefore sees the conditional operation instead of being
612
+ bypassed.
613
+
614
+ NestM retains a narrow direct fallback only for conditional shapes Files SDK
615
+ 2.3 cannot represent: immutable version predicates, conditional
616
+ multipart/resumable completion, and a copy with only its source or only its
617
+ destination conditioned. Because those fallbacks cannot traverse the Files
618
+ operation pipeline, they remain fail-closed when caller Files policy is active:
619
+
620
+ | Operation shape | Execution path | With Files plugins, active hooks, or receipts |
621
+ | ----------------------------------------------------------- | --------------------- | --------------------------------------------- |
622
+ | Ordinary operations | Files pipeline | Available |
623
+ | Create/replace/ETag read/delete/paired conditional copy | Files 2.3 pipeline | Available |
624
+ | Version, conditional multipart/resumable, or one-sided copy | NestM direct fallback | Hidden; invocation returns `NOT_SUPPORTED` |
625
+
626
+ An empty plugin list, an empty hooks object, and `receipts: false` do not count
627
+ as caller policy. When available, a direct fallback still applies prefixing,
628
+ the physical-key budget, read-only restrictions, default retry/signal/timeout
629
+ options, and bounded error mapping. `StoragePlugin` remains a separate
630
+ veto/observation boundary; it is not a substitute for Files body or result
631
+ transforms.
632
+
633
+ `StorageWorkspace` uses the Files pipeline whenever its conditional operation
634
+ has an upstream representation. Lower-level conditional client and driver APIs
635
+ remain available for applications that intentionally use the policy-free
636
+ NestM-only fallback shapes.
637
+
185
638
  ## Storage API
186
639
 
187
640
  `StorageClient` exposes:
188
641
 
189
642
  - `upload`, `downloadStream`, `head`, `exists`, `delete`, `copy`, and `move`;
643
+ - exact `uploadConditional`, `downloadConditional`, `deleteConditional`, and
644
+ staged-object `promote` operations when the driver advertises each primitive;
190
645
  - `list`, cursor-aware `listAll`, and lazy `search`;
191
646
  - `signDownload` and discriminated PUT/POST `signUpload`;
192
647
  - `uploadMany`, `downloadMany`, `headMany`, `existsMany`, and `deleteMany`;
@@ -194,6 +649,24 @@ whitespace.
194
649
  - provider capability inspection; and
195
650
  - pause/resume/abort through `StorageUploadControl`.
196
651
 
652
+ Provider list cursors are opaque, non-consuming continuation tokens. Replaying
653
+ the same cursor and page limit against unchanged provider-visible state must
654
+ return an equivalent page and continuation position, even after a descendant
655
+ cursor has been used. A cursor is bound to the logical store, `prefix`, and
656
+ `delimiter`, but not to `limit`, retries, timeout, or abort signal; callers may
657
+ change those transport/page-size options while resuming the same position.
658
+
659
+ The cursor must work through a newly constructed compatible driver targeting
660
+ the same backend namespace while the provider token remains valid and
661
+ available; it cannot depend on process-, client-, or session-local state. An
662
+ adapter for a consuming or instance-bound provider token must materialize a
663
+ stable continuation before it can provide conforming paginated
664
+ `StorageDriver.list` results. This contract lets a caller safely retry, replay,
665
+ or resume pagination on another replica. It does not promise a provider-token
666
+ lifetime, snapshot isolation across concurrent mutations, or provider,
667
+ network, credential, or authorization availability. Provider invalidation is
668
+ an ordinary list-operation failure.
669
+
197
670
  Downloads are streaming by default:
198
671
 
199
672
  ```ts
@@ -216,6 +689,138 @@ Node `Readable` uploads are accepted and converted to Web streams without
216
689
  buffering. Provider capability gaps fail closed with `StorageError` rather than
217
690
  silently discarding a range, metadata, or cache-control request.
218
691
 
692
+ ### Exact provider conditions and staged-object promotion
693
+
694
+ Capabilities distinguish conditional create, replace, delete, read, source
695
+ copy, destination copy, atomic source-and-destination promotion, and multipart
696
+ completion. They also declare the complete physical-key byte budget. Callers
697
+ must check the exact primitive they need; a missing field is unsupported and is
698
+ never widened from another operation.
699
+
700
+ Some adapters expose conditional copy only as a paired source-and-destination
701
+ operation. In that case,
702
+ `capabilities.conditionalCopySource.requiresDestinationPredicate` and
703
+ `capabilities.conditionalCopyDestination.requiresSourcePredicate` are `true`.
704
+ `StorageClient.promote` rejects a request missing the required counterpart
705
+ before provider I/O. A paired request must also satisfy the advertised
706
+ create/replace bit and
707
+ `capabilities.conditionalCopyDestination.atomicWithSource`.
708
+
709
+ The physical-key budget applies to the exact key sent to the adapter. It
710
+ therefore counts leading slashes for unprefixed drivers, the separator added to
711
+ a configured driver prefix, and provider prefixes derived for `list` or
712
+ `search`. Over-budget object keys and explicit list/search prefixes fail before
713
+ provider dispatch rather than being normalized into a shorter key. A glob's
714
+ provider prefix is the exact prefix inferred by files-sdk itself, and that
715
+ derived list operation passes through the same final guard. A non-positive
716
+ `maxResults` performs no provider walk and therefore dispatches no prefix.
717
+
718
+ Adapters and plugins execute as trusted in-process code; this package does not
719
+ attempt to sandbox a plugin that performs its own network or filesystem I/O.
720
+ For supported plugin pipelines that forward operations through `next`, the
721
+ physical-key guard is the innermost wrapper, after caller plugins and before
722
+ the adapter call. A plugin therefore cannot widen an upload key or list/search
723
+ prefix past the declared byte ceiling while still using the normal dispatch
724
+ pipeline.
725
+
726
+ Storage-facing ETags have one canonical representation: a bare, case-sensitive
727
+ opaque token with no HTTP quotes. Canonical values contain 1–1024 visible
728
+ ASCII bytes and exclude commas, backslashes, whitespace, control characters,
729
+ `DEL`, non-ASCII text, the `*` wildcard, and case-insensitive `W/` weak-tag
730
+ prefixes. Treat the value as opaque: preserve the exact string returned by
731
+ `head`, reads, or writes and pass it back unchanged to a conditional operation.
732
+ Do not add or remove quotes in application code. S3-compatible drivers remove
733
+ exactly one valid provider-owned quote pair on ingress and add exactly one pair
734
+ when serializing the HTTP header.
735
+
736
+ This tightens the precondition boundary. Quoted or otherwise non-canonical
737
+ ETags that older versions happened to accept now fail with `INVALID_ARGUMENT`,
738
+ and an unsafe provider result fails with a sanitized `PROVIDER` error instead
739
+ of being exposed as a usable validator. Applications that persisted quoted
740
+ ETags must refresh them with `head` rather than trimming them heuristically.
741
+ Strict normalization prevents a caller-controlled wildcard, entity-tag list,
742
+ weak validator, or malformed header value from widening an operation that
743
+ promises one exact strong match.
744
+
745
+ The native AWS S3 profile advertises ETag- and version-conditioned server-side
746
+ copy. This lets an application validate a staged object and copy that exact
747
+ source to its final key instead of re-reading whichever bytes occupy the
748
+ staging key later:
749
+
750
+ ```ts
751
+ import { StorageError, StorageErrorCode } from '@nestm/storage';
752
+
753
+ const staged = await media.head(stagingKey);
754
+ if (staged.etag === undefined) {
755
+ throw new StorageError('Provider did not return a source ETag.', {
756
+ code: StorageErrorCode.NOT_SUPPORTED,
757
+ });
758
+ }
759
+
760
+ // Validate size, declared MIME, and magic bytes before this call.
761
+ await media.file(stagingKey).promoteTo(finalKey, {
762
+ sourceEtag: staged.etag,
763
+ });
764
+
765
+ // Commit ready metadata first. Promotion deliberately retains the staged
766
+ // object so a failed database commit remains recoverable.
767
+ await media.delete(stagingKey);
768
+ ```
769
+
770
+ `sourceVersion` can select an immutable S3 version and may be combined with
771
+ `sourceEtag`. A destination condition can independently require create-only or
772
+ replacement of an exact ETag. Combining source and destination predicates also
773
+ requires `capabilities.conditionalCopyDestination.atomicWithSource`; otherwise
774
+ the request fails with `NOT_SUPPORTED`. A promotion must contain at least one
775
+ source or destination predicate.
776
+
777
+ Cloudflare R2 has a separate stable profile: create, replace, ETag-conditioned
778
+ read, and ETag-conditioned source copy are enabled, while conditional delete,
779
+ destination copy, atomic promotion, version predicates, and conditional
780
+ multipart completion remain absent. R2 proves content-type binding for
781
+ presigned PUT requests but not POST-form size ranges, so its signed-upload
782
+ policy is `{ contentType: true, sizeRange: false }` and the gateway refuses to
783
+ mint its POST upload form. Cloudflare documents that presigned `POST` form
784
+ uploads are not supported in its
785
+ [presigned URL contract](https://developers.cloudflare.com/r2/api/s3/presigned-urls/).
786
+ Direct R2 signed-upload calls that request a size bound likewise fail with
787
+ `NOT_SUPPORTED` before signing.
788
+ Custom S3-compatible endpoints start with no conditional operations and the
789
+ entire driver is forced read-only until an explicit conformance-verified
790
+ `S3ProviderProfile` is supplied. Omitting `signedUploadPolicy` while defining a
791
+ custom profile normalizes both policy claims to `false`; providers may opt in
792
+ only to constraints their conformance evidence proves.
793
+
794
+ Successful S3 signed uploads enforce every requested constraint. A request
795
+ with `contentType` and no `maxSize` uses a presigned PUT whose signature
796
+ includes the `content-type` header. A request with `maxSize` uses a POST policy
797
+ with `content-length-range` and, when present, an exact `Content-Type`
798
+ condition. S3 cannot express a lower-only `minSize` through this contract, so
799
+ that shape fails with `NOT_SUPPORTED`; an unclaimed profile constraint also
800
+ fails before credentials are resolved or a URL is minted. Literal physical
801
+ keys ending in AWS's `${filename}` POST template are rejected for bounded
802
+ uploads because the SDK otherwise widens the exact key condition to a prefix.
803
+
804
+ `withS3Capabilities()` decorates a raw S3 adapter in place and may be applied
805
+ only once. Construct a fresh raw adapter when selecting a different profile;
806
+ reapplying the helper is rejected so a previous broader profile cannot survive
807
+ a later narrower declaration. The selected profile is bound to the exact
808
+ reserved capability and operation members installed by that decoration;
809
+ same-client aliases may change display metadata but cannot add or replace those
810
+ members to widen the profile.
811
+
812
+ An explicit profile applied to a native AWS SDK endpoint may only narrow the
813
+ immutable `AWS_S3_PROVIDER_PROFILE`. It cannot raise the complete-key budget
814
+ above 1,024 bytes or claim an operation/policy bit absent from the built-in
815
+ profile. This containment follows actual SDK endpoint provenance even when an
816
+ adapter alias changes its display name.
817
+
818
+ The package-owned `s3()` factory also retains whether `publicBaseUrl` was
819
+ configured even when the second `withS3Capabilities()` options object is
820
+ omitted. In that case `signedDownloadPolicy.expiresIn` is false. Foreign S3
821
+ adapters whose construction metadata is unavailable receive the same
822
+ conservative false value instead of claiming an enforceable TTL.
823
+
219
824
  ### Resumable uploads
220
825
 
221
826
  ```ts
@@ -267,20 +872,42 @@ The gateway lives at `@nestm/storage/gateway` and is never mounted by
267
872
  `StorageModule`.
268
873
 
269
874
  ```ts
270
- import { Module } from '@nestjs/common';
875
+ import { Injectable, Module } from '@nestjs/common';
271
876
  import {
272
877
  StorageGatewayModule,
273
878
  StorageGatewayOperation,
879
+ type StorageGatewayKeyPolicy,
274
880
  } from '@nestm/storage/gateway';
275
881
 
882
+ @Injectable()
883
+ class TenantStorageKeyPolicy implements StorageGatewayKeyPolicy {
884
+ resolve({ input, request, target }) {
885
+ const tenantId = tenantIdFromAuthenticatedRequest(request);
886
+ const root = `tenants/${base64url(tenantId)}`;
887
+ if (target === 'pattern') {
888
+ // Search is already constrained by the separately resolved prefix.
889
+ return input?.value ?? '*';
890
+ }
891
+ return `${root}/${input?.value ?? ''}`;
892
+ }
893
+ }
894
+
895
+ @Module({
896
+ providers: [TenantStorageKeyPolicy],
897
+ exports: [TenantStorageKeyPolicy],
898
+ })
899
+ class StoragePolicyModule {}
900
+
276
901
  @Module({
277
902
  imports: [
278
903
  AppStorageModule,
279
904
  AuthModule,
905
+ StoragePolicyModule,
280
906
  StorageGatewayModule.register({
281
- imports: [AppStorageModule, AuthModule],
907
+ imports: [AppStorageModule, AuthModule, StoragePolicyModule],
282
908
  store: 'media',
283
909
  guards: [JwtAuthGuard],
910
+ keyPolicy: TenantStorageKeyPolicy,
284
911
  mode: 'hybrid',
285
912
  operations: [
286
913
  StorageGatewayOperation.DOWNLOAD,
@@ -291,6 +918,9 @@ import {
291
918
  StorageGatewayOperation.SIGN_UPLOAD,
292
919
  ],
293
920
  maxUploadBytes: 100 * 1024 * 1024,
921
+ maxSignedUploadBytes: 10 * 1024 * 1024,
922
+ signedUploadContentTypes: ['image/jpeg', 'image/png'],
923
+ maxSignedUrlExpiresIn: 900,
294
924
  maxListResults: 1000,
295
925
  maxSearchResults: 1000,
296
926
  proxyInlineContentTypes: ['image/jpeg', 'image/png'],
@@ -304,12 +934,42 @@ Registration fails without at least one existing Nest guard. The only bypass is
304
934
  the explicit `allowUnauthenticated: true` development escape hatch. Operations
305
935
  are deny-by-default and must be allowlisted individually.
306
936
 
937
+ Registration also fails without a `keyPolicy`. Guards answer whether a request
938
+ may reach the gateway; the key policy independently resolves every parsed
939
+ `key`, `prefix`, search `pattern`, `from`, and `to` value to the exact provider
940
+ path. It runs even when a list/search prefix was omitted, so the policy can
941
+ always impose a tenant root. Key-policy providers may be request scoped.
942
+ Returned paths are parsed again and reject absolute paths, backslashes, control
943
+ characters, empty segments, and dot/parent segments.
944
+
945
+ Existing single-tenant applications can temporarily set
946
+ `unsafeAllowUnscopedKeys: true` instead of `keyPolicy`. The name is intentional:
947
+ it preserves caller-controlled provider keys and must not be used on an exposed
948
+ or multi-tenant gateway. It cannot be combined with `keyPolicy`.
949
+
307
950
  Proxy downloads default to `Content-Disposition: attachment` and always send
308
951
  `X-Content-Type-Options: nosniff`. Add only trusted, non-active MIME types to
309
952
  `proxyInlineContentTypes` when browser rendering is required. Search responses
310
953
  are capped by `maxSearchResults`, and list pages by `maxListResults` (both
311
954
  1,000 by default).
312
955
 
956
+ Every signed URL is capped by `maxSignedUrlExpiresIn` (3,600 seconds by
957
+ default). Signed uploads always carry a provider-enforced maximum size, capped
958
+ by `maxSignedUploadBytes`, and require an exact lowercase MIME type from
959
+ `signedUploadContentTypes`. The default direct-upload allowlist contains only
960
+ `application/octet-stream`. Gateway callers may request only the literal
961
+ `attachment` or `inline` response disposition; arbitrary response-header text
962
+ and filenames are rejected. A driver must also advertise
963
+ `signedUploadPolicy.contentType` and `signedUploadPolicy.sizeRange`; otherwise
964
+ the gateway refuses to mint the URL. Native AWS advertises both and uses S3
965
+ POST policy conditions. R2 advertises content-type enforcement but not a POST
966
+ size range, while omitted custom declarations normalize both claims to false;
967
+ the gateway therefore fails closed for those profiles. Signed downloads
968
+ similarly require
969
+ `signedDownloadPolicy.expiresIn`. The S3 factory advertises it only when no
970
+ permanent `publicBaseUrl` was configured, preventing a configured TTL from
971
+ silently returning a non-expiring public link.
972
+
313
973
  The fixed gateway prefix is `/storage`:
314
974
 
315
975
  | Method | Path | Operation |
@@ -360,9 +1020,26 @@ try {
360
1020
  }
361
1021
  ```
362
1022
 
1023
+ `files-sdk` `NotFound` failures retain `StorageErrorCode.NOT_FOUND`, including
1024
+ when a provider adapter and this driver resolve separate copies of `files-sdk`.
1025
+ `isStorageError()` likewise recognizes branded and exact legacy structural
1026
+ errors produced by a duplicated `@nestm/storage` package copy.
1027
+
1028
+ For a conditional mutation, `error.applied === true` means the provider commit
1029
+ succeeded but acknowledgement failed afterward, for example in an awaited
1030
+ post-operation plugin. Conditional uploads also expose `error.appliedEtag` when
1031
+ the committed generation is known. Do not blindly retry the original
1032
+ predicate: reconcile the logical destination first, using an exact ETag read
1033
+ when `appliedEtag` is present. This is a one-way signal: `applied === false`
1034
+ does not prove that a remote mutation did not commit. A timeout, connection
1035
+ loss, or exhausted retry can lose the provider's success response, so reconcile
1036
+ ambiguous transport/provider failures before repeating a conditional mutation.
1037
+ The sanitized workspace, AI-tool, and gateway error boundaries retain only
1038
+ this bounded reconciliation metadata.
1039
+
363
1040
  Capability flags cover range reads, native byte-level upload progress,
364
1041
  delimiter listing, metadata, cache control, resumable uploads, server-side
365
- copy, and signed transfers.
1042
+ copy, conditional promotion, and signed transfers.
366
1043
  Provider-specific native clients are intentionally not exposed from the root
367
1044
  package.
368
1045
 
@@ -378,8 +1055,30 @@ StorageModule.forRoot({
378
1055
  });
379
1056
  ```
380
1057
 
381
- For local filesystem storage, import `fs` from `files-sdk/fs` and pass it to
382
- `createFilesSdkDriver` exactly like a cloud adapter.
1058
+ For local filesystem storage, use the package-owned factory. The adapter reaches
1059
+ only `node:fs`, so it needs no native SDK:
1060
+
1061
+ ```ts
1062
+ import { createFsStorageDriver } from '@nestm/storage/files-sdk/fs';
1063
+
1064
+ StorageModule.forRoot({
1065
+ stores: [
1066
+ {
1067
+ name: 'artifacts',
1068
+ driver: createFsStorageDriver({ adapter: { root: './var/artifacts' } }),
1069
+ },
1070
+ ],
1071
+ });
1072
+ ```
1073
+
1074
+ Bodies are written verbatim at `<root>/<key>`. A `<key>.meta.json` sidecar beside
1075
+ each one carries the content type, ETag, and custom metadata a filesystem has
1076
+ nowhere else to put; sidecars never surface as keys, and uploading a key ending
1077
+ in `.meta.json` fails closed rather than colliding with one. Ordinary and
1078
+ conditional mutations made through the decorated adapter share one
1079
+ process-local lock domain. Conditional guarantees therefore require a
1080
+ dedicated root: do not mutate it through another process, an unwrapped adapter,
1081
+ or direct filesystem calls.
383
1082
 
384
1083
  ## License
385
1084