@nestm/storage 0.1.0-alpha.2 → 0.1.0-alpha.20

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 (198) hide show
  1. package/CHANGELOG.md +320 -0
  2. package/README.md +975 -29
  3. package/SECURITY.md +128 -0
  4. package/dist/ai-sdk/ai-sdk-file-workflow-tools.d.ts +53 -0
  5. package/dist/ai-sdk/ai-sdk-file-workflow-tools.d.ts.map +1 -0
  6. package/dist/ai-sdk/ai-sdk-file-workflow-tools.js +370 -0
  7. package/dist/ai-sdk/ai-sdk-file-workflow-tools.js.map +1 -0
  8. package/dist/ai-sdk/ai-sdk-workspace-tools.d.ts +91 -0
  9. package/dist/ai-sdk/ai-sdk-workspace-tools.d.ts.map +1 -0
  10. package/dist/ai-sdk/ai-sdk-workspace-tools.js +497 -0
  11. package/dist/ai-sdk/ai-sdk-workspace-tools.js.map +1 -0
  12. package/dist/ai-sdk/index.d.ts +3 -0
  13. package/dist/ai-sdk/index.d.ts.map +1 -0
  14. package/dist/ai-sdk/index.js +3 -0
  15. package/dist/ai-sdk/index.js.map +1 -0
  16. package/dist/bytes/index.d.ts +30 -0
  17. package/dist/bytes/index.d.ts.map +1 -0
  18. package/dist/bytes/index.js +96 -0
  19. package/dist/bytes/index.js.map +1 -0
  20. package/dist/core/index.d.ts +5 -0
  21. package/dist/core/index.d.ts.map +1 -1
  22. package/dist/core/index.js +5 -0
  23. package/dist/core/index.js.map +1 -1
  24. package/dist/core/storage-staged-content.d.ts +39 -0
  25. package/dist/core/storage-staged-content.d.ts.map +1 -0
  26. package/dist/core/storage-staged-content.js +109 -0
  27. package/dist/core/storage-staged-content.js.map +1 -0
  28. package/dist/core/storage-streams.d.ts +19 -0
  29. package/dist/core/storage-streams.d.ts.map +1 -0
  30. package/dist/core/storage-streams.js +95 -0
  31. package/dist/core/storage-streams.js.map +1 -0
  32. package/dist/core/storage-text-edit.d.ts +38 -0
  33. package/dist/core/storage-text-edit.d.ts.map +1 -0
  34. package/dist/core/storage-text-edit.js +86 -0
  35. package/dist/core/storage-text-edit.js.map +1 -0
  36. package/dist/core/storage-text-stream-edit.d.ts +8 -0
  37. package/dist/core/storage-text-stream-edit.d.ts.map +1 -0
  38. package/dist/core/storage-text-stream-edit.js +114 -0
  39. package/dist/core/storage-text-stream-edit.js.map +1 -0
  40. package/dist/core/storage-text.d.ts +22 -0
  41. package/dist/core/storage-text.d.ts.map +1 -0
  42. package/dist/core/storage-text.js +69 -0
  43. package/dist/core/storage-text.js.map +1 -0
  44. package/dist/crypto/index.d.ts +2 -0
  45. package/dist/crypto/index.d.ts.map +1 -0
  46. package/dist/crypto/index.js +2 -0
  47. package/dist/crypto/index.js.map +1 -0
  48. package/dist/crypto/storage-encrypted-content.d.ts +49 -0
  49. package/dist/crypto/storage-encrypted-content.d.ts.map +1 -0
  50. package/dist/crypto/storage-encrypted-content.js +212 -0
  51. package/dist/crypto/storage-encrypted-content.js.map +1 -0
  52. package/dist/files-sdk/azure/azure-conditional.d.ts +5 -0
  53. package/dist/files-sdk/azure/azure-conditional.d.ts.map +1 -0
  54. package/dist/files-sdk/azure/azure-conditional.js +184 -0
  55. package/dist/files-sdk/azure/azure-conditional.js.map +1 -0
  56. package/dist/files-sdk/azure/index.d.ts +10 -0
  57. package/dist/files-sdk/azure/index.d.ts.map +1 -0
  58. package/dist/files-sdk/azure/index.js +34 -0
  59. package/dist/files-sdk/azure/index.js.map +1 -0
  60. package/dist/files-sdk/files-sdk.driver.d.ts +85 -3
  61. package/dist/files-sdk/files-sdk.driver.d.ts.map +1 -1
  62. package/dist/files-sdk/files-sdk.driver.js +1437 -37
  63. package/dist/files-sdk/files-sdk.driver.js.map +1 -1
  64. package/dist/files-sdk/fs/index.d.ts +42 -0
  65. package/dist/files-sdk/fs/index.d.ts.map +1 -0
  66. package/dist/files-sdk/fs/index.js +1117 -0
  67. package/dist/files-sdk/fs/index.js.map +1 -0
  68. package/dist/files-sdk/index.d.ts +1 -1
  69. package/dist/files-sdk/index.d.ts.map +1 -1
  70. package/dist/files-sdk/index.js.map +1 -1
  71. package/dist/files-sdk/memory.d.ts +7 -0
  72. package/dist/files-sdk/memory.d.ts.map +1 -0
  73. package/dist/files-sdk/memory.js +72 -0
  74. package/dist/files-sdk/memory.js.map +1 -0
  75. package/dist/files-sdk/provider/index.d.ts +58 -0
  76. package/dist/files-sdk/provider/index.d.ts.map +1 -0
  77. package/dist/files-sdk/provider/index.js +169 -0
  78. package/dist/files-sdk/provider/index.js.map +1 -0
  79. package/dist/files-sdk/s3/construction-metadata.d.ts +9 -0
  80. package/dist/files-sdk/s3/construction-metadata.d.ts.map +1 -0
  81. package/dist/files-sdk/s3/construction-metadata.js +24 -0
  82. package/dist/files-sdk/s3/construction-metadata.js.map +1 -0
  83. package/dist/files-sdk/s3/index.d.ts +54 -0
  84. package/dist/files-sdk/s3/index.d.ts.map +1 -0
  85. package/dist/files-sdk/s3/index.js +1505 -0
  86. package/dist/files-sdk/s3/index.js.map +1 -0
  87. package/dist/gateway/index.d.ts +1 -1
  88. package/dist/gateway/index.d.ts.map +1 -1
  89. package/dist/gateway/index.js.map +1 -1
  90. package/dist/gateway/storage-gateway-fastify-parser.d.ts.map +1 -1
  91. package/dist/gateway/storage-gateway-fastify-parser.js.map +1 -1
  92. package/dist/gateway/storage-gateway.controller.d.ts +12 -11
  93. package/dist/gateway/storage-gateway.controller.d.ts.map +1 -1
  94. package/dist/gateway/storage-gateway.controller.js +240 -70
  95. package/dist/gateway/storage-gateway.controller.js.map +1 -1
  96. package/dist/gateway/storage-gateway.guard.d.ts.map +1 -1
  97. package/dist/gateway/storage-gateway.guard.js.map +1 -1
  98. package/dist/gateway/storage-gateway.module.d.ts.map +1 -1
  99. package/dist/gateway/storage-gateway.module.js +60 -1
  100. package/dist/gateway/storage-gateway.module.js.map +1 -1
  101. package/dist/gateway/storage-gateway.tokens.d.ts +1 -0
  102. package/dist/gateway/storage-gateway.tokens.d.ts.map +1 -1
  103. package/dist/gateway/storage-gateway.tokens.js +1 -0
  104. package/dist/gateway/storage-gateway.tokens.js.map +1 -1
  105. package/dist/gateway/storage-gateway.types.d.ts +53 -10
  106. package/dist/gateway/storage-gateway.types.d.ts.map +1 -1
  107. package/dist/gateway/storage-gateway.types.js.map +1 -1
  108. package/dist/inject-storage.decorator.js.map +1 -1
  109. package/dist/storage-etag.d.ts +13 -0
  110. package/dist/storage-etag.d.ts.map +1 -0
  111. package/dist/storage-etag.js +31 -0
  112. package/dist/storage-etag.js.map +1 -0
  113. package/dist/storage-upload-control.d.ts.map +1 -1
  114. package/dist/storage.client.d.ts +14 -1
  115. package/dist/storage.client.d.ts.map +1 -1
  116. package/dist/storage.client.js +204 -2
  117. package/dist/storage.client.js.map +1 -1
  118. package/dist/storage.driver.d.ts +28 -1
  119. package/dist/storage.driver.d.ts.map +1 -1
  120. package/dist/storage.driver.js.map +1 -1
  121. package/dist/storage.error.d.ts +17 -10
  122. package/dist/storage.error.d.ts.map +1 -1
  123. package/dist/storage.error.js +47 -1
  124. package/dist/storage.error.js.map +1 -1
  125. package/dist/storage.module.d.ts.map +1 -1
  126. package/dist/storage.module.js.map +1 -1
  127. package/dist/storage.service.d.ts.map +1 -1
  128. package/dist/storage.service.js +28 -3
  129. package/dist/storage.service.js.map +1 -1
  130. package/dist/storage.tokens.js.map +1 -1
  131. package/dist/storage.types.d.ts +150 -1
  132. package/dist/storage.types.d.ts.map +1 -1
  133. package/dist/storage.types.js.map +1 -1
  134. package/dist/testing/index.d.ts +1 -0
  135. package/dist/testing/index.d.ts.map +1 -1
  136. package/dist/testing/index.js +3 -1
  137. package/dist/testing/index.js.map +1 -1
  138. package/dist/testing/provider-conformance.d.ts +65 -0
  139. package/dist/testing/provider-conformance.d.ts.map +1 -0
  140. package/dist/testing/provider-conformance.js +869 -0
  141. package/dist/testing/provider-conformance.js.map +1 -0
  142. package/dist/workspace/index.d.ts +12 -0
  143. package/dist/workspace/index.d.ts.map +1 -0
  144. package/dist/workspace/index.js +10 -0
  145. package/dist/workspace/index.js.map +1 -0
  146. package/dist/workspace/storage-catalog-checkout.d.ts +7 -0
  147. package/dist/workspace/storage-catalog-checkout.d.ts.map +1 -0
  148. package/dist/workspace/storage-catalog-checkout.js +13 -0
  149. package/dist/workspace/storage-catalog-checkout.js.map +1 -0
  150. package/dist/workspace/storage-draft-stream.d.ts +3 -0
  151. package/dist/workspace/storage-draft-stream.d.ts.map +1 -0
  152. package/dist/workspace/storage-draft-stream.js +57 -0
  153. package/dist/workspace/storage-draft-stream.js.map +1 -0
  154. package/dist/workspace/storage-file-catalog.types.d.ts +73 -0
  155. package/dist/workspace/storage-file-catalog.types.d.ts.map +1 -0
  156. package/dist/workspace/storage-file-catalog.types.js +2 -0
  157. package/dist/workspace/storage-file-catalog.types.js.map +1 -0
  158. package/dist/workspace/storage-file-workflow.d.ts +7 -0
  159. package/dist/workspace/storage-file-workflow.d.ts.map +1 -0
  160. package/dist/workspace/storage-file-workflow.js +657 -0
  161. package/dist/workspace/storage-file-workflow.js.map +1 -0
  162. package/dist/workspace/storage-file-workflow.protection.d.ts +31 -0
  163. package/dist/workspace/storage-file-workflow.protection.d.ts.map +1 -0
  164. package/dist/workspace/storage-file-workflow.protection.js +339 -0
  165. package/dist/workspace/storage-file-workflow.protection.js.map +1 -0
  166. package/dist/workspace/storage-file-workflow.types.d.ts +184 -0
  167. package/dist/workspace/storage-file-workflow.types.d.ts.map +1 -0
  168. package/dist/workspace/storage-file-workflow.types.js +10 -0
  169. package/dist/workspace/storage-file-workflow.types.js.map +1 -0
  170. package/dist/workspace/storage-text-draft.d.ts +21 -0
  171. package/dist/workspace/storage-text-draft.d.ts.map +1 -0
  172. package/dist/workspace/storage-text-draft.js +77 -0
  173. package/dist/workspace/storage-text-draft.js.map +1 -0
  174. package/dist/workspace/storage-working-files.d.ts +34 -0
  175. package/dist/workspace/storage-working-files.d.ts.map +1 -0
  176. package/dist/workspace/storage-working-files.js +326 -0
  177. package/dist/workspace/storage-working-files.js.map +1 -0
  178. package/dist/workspace/storage-workspace.cursor.d.ts +71 -0
  179. package/dist/workspace/storage-workspace.cursor.d.ts.map +1 -0
  180. package/dist/workspace/storage-workspace.cursor.js +408 -0
  181. package/dist/workspace/storage-workspace.cursor.js.map +1 -0
  182. package/dist/workspace/storage-workspace.d.ts +6 -0
  183. package/dist/workspace/storage-workspace.d.ts.map +1 -0
  184. package/dist/workspace/storage-workspace.error.d.ts +32 -0
  185. package/dist/workspace/storage-workspace.error.d.ts.map +1 -0
  186. package/dist/workspace/storage-workspace.error.js +75 -0
  187. package/dist/workspace/storage-workspace.error.js.map +1 -0
  188. package/dist/workspace/storage-workspace.js +849 -0
  189. package/dist/workspace/storage-workspace.js.map +1 -0
  190. package/dist/workspace/storage-workspace.path.d.ts +9 -0
  191. package/dist/workspace/storage-workspace.path.d.ts.map +1 -0
  192. package/dist/workspace/storage-workspace.path.js +68 -0
  193. package/dist/workspace/storage-workspace.path.js.map +1 -0
  194. package/dist/workspace/storage-workspace.types.d.ts +123 -0
  195. package/dist/workspace/storage-workspace.types.d.ts.map +1 -0
  196. package/dist/workspace/storage-workspace.types.js +22 -0
  197. package/dist/workspace/storage-workspace.types.js.map +1 -0
  198. package/package.json +99 -20
package/README.md CHANGED
@@ -1,15 +1,15 @@
1
1
  # @nestm/storage
2
2
 
3
3
  Framework-neutral storage clients with NestJS 12 integration, named stores,
4
- explicit streaming I/O, cross-store workflows, and an optional guarded HTTP
5
- gateway.
4
+ explicit streaming I/O, capability-scoped agent workspaces, cross-store
5
+ workflows, and an optional guarded HTTP gateway.
6
6
 
7
7
  The package uses [`files-sdk`](https://github.com/haydenbleasel/files-sdk) as
8
8
  its provider engine, but owns the API injected into Nest applications. Provider
9
9
  SDK types, errors, and `files.raw` do not leak through the root package.
10
10
 
11
- > This package targets the NestJS 12 prerelease line and is itself published on
12
- > the `alpha` dist-tag.
11
+ > This package targets stable NestJS 12 and is itself published on the `alpha`
12
+ > dist-tag.
13
13
 
14
14
  ## Requirements
15
15
 
@@ -17,17 +17,32 @@ SDK types, errors, and `files.raw` do not leak through the root package.
17
17
  - ESM
18
18
 
19
19
  The framework-neutral `@nestm/storage/core` entry point does not require
20
- NestJS. The root entry point and HTTP gateway additionally require NestJS
21
- `12.0.0-alpha.5` or newer in the Nest 12 prerelease line, `reflect-metadata`,
22
- and RxJS. Those framework peers are optional at installation time so core-only
23
- consumers do not download NestJS.
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.
24
23
 
25
24
  ## Install
26
25
 
27
26
  ```sh
28
- pnpm add @nestm/storage@alpha files-sdk@2.2.2
27
+ pnpm add @nestm/storage@alpha
29
28
  ```
30
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
36
+ ```
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
+
31
46
  Install only the native SDKs required by the chosen provider. For example:
32
47
 
33
48
  ```sh
@@ -42,15 +57,15 @@ pnpm add @google-cloud/storage google-auth-library
42
57
  pnpm add @azure/storage-blob @azure/core-auth @azure/identity
43
58
  ```
44
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
+
45
65
  `files-sdk` currently declares its optional Nest peer for Nest 10 and 11. This
46
66
  library does not import `files-sdk/nestjs`; the Nest 12 integration is entirely
47
67
  owned here. A package manager may nevertheless report that temporary optional
48
- peer mismatch while Nest 12 remains prerelease.
49
-
50
- NestJS 12 alpha also has prerelease peer declarations that npm may reject under
51
- its strict resolver. If npm reports an `ERESOLVE` error for Nest's own peers,
52
- install with `npm install --legacy-peer-deps`; pnpm works with the repository's
53
- checked-in peer-version policy.
68
+ peer mismatch until Files SDK widens its declaration to include Nest 12.
54
69
 
55
70
  ## Framework-neutral core
56
71
 
@@ -80,16 +95,558 @@ storage errors and operation types, and `StorageUploadControl`. It has no NestJS
80
95
  runtime or declaration imports. Provider adapters remain available through
81
96
  `@nestm/storage/files-sdk`.
82
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:
224
+
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.
377
+
378
+ ## Durable file workflows and catalog tools
379
+
380
+ `@nestm/storage` owns byte mechanics and the draft state machine. The host owns
381
+ logical file heads, authorization, persistence and transaction atomicity. No
382
+ database, NestJS, tenant model or runtime lease is required by these contracts.
383
+
384
+ | Entry point | Exports |
385
+ | -------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
386
+ | `@nestm/storage/core` | `StorageStagedContentStore`, `collectStorageBytes`, `storageBytesStream`, `readStorageTextWindow`, `searchStorageText`, `applyStorageTextEdit` and their types |
387
+ | `@nestm/storage/workspace` | `StorageFileWorkflow`, draft/persistence/catalog contracts, `protectStorageFileWorkflowWorkspace`, `protectStorageFileWorkflowOperation`, `getStorageFileWorkflow`, `getStorageFileCatalog` |
388
+ | `@nestm/storage/ai-sdk` | `createAiSdkFileWorkflowTools`, `createAiSdkCatalogFileTools`, their options and tool-name constants; existing `createAiSdkWorkspaceTools` is unchanged |
389
+ | `@nestm/storage/bytes` | Browser-safe `sha256StorageBytes`, `verifyStorageChunkReceipt`, `trimStorageUtf8Chunk`, `encodeStorageBase64`, `isStorageUtf8Blob` |
390
+
391
+ ```ts
392
+ import { StorageStagedContentStore } from '@nestm/storage/core';
393
+ import {
394
+ StorageFileWorkflow,
395
+ protectStorageFileWorkflowWorkspace,
396
+ } from '@nestm/storage/workspace';
397
+ import {
398
+ createAiSdkCatalogFileTools,
399
+ createAiSdkFileWorkflowTools,
400
+ } from '@nestm/storage/ai-sdk';
401
+
402
+ // These values are supplied by the host, never by the model.
403
+ const content = new StorageStagedContentStore({
404
+ client,
405
+ key: (scope: HostScope, payloadId) => hostBodyKey(scope, payloadId),
406
+ });
407
+ const workflows = new StorageFileWorkflow({ content, persistence });
408
+ const workspace = protectStorageFileWorkflowWorkspace({
409
+ workspace: hostCatalogWorkspace,
410
+ catalog: hostCatalog,
411
+ workflows: workflows.mount(trustedScope, {
412
+ permissions: ['read', 'write', 'commit'],
413
+ signal: executionSignal,
414
+ }),
415
+ signal: executionSignal,
416
+ authorize: ({ permission, signal }) =>
417
+ authorizeCurrentHostScope(trustedScope, permission, signal),
418
+ });
419
+ const tools = {
420
+ ...createAiSdkCatalogFileTools({
421
+ catalog: workspace.catalog!,
422
+ commandId: (toolCallId) => hostCommandId(toolCallId),
423
+ }),
424
+ ...createAiSdkFileWorkflowTools({
425
+ workflow: workspace.workflows,
426
+ idempotencyKey: (toolCallId) => hostCommandId(toolCallId),
427
+ }),
428
+ };
429
+ ```
430
+
431
+ The catalog delegate implements the existing `StorageWorkspace` against the
432
+ host's logical catalog; do not pass a raw provider-prefix workspace alongside
433
+ an unrelated file service. `StorageFileCatalogCapability` adds opaque file IDs,
434
+ byte windows, literal content search and idempotent small write/edit commands.
435
+ Its `commandId` is host-scoped, stable across retries, and compared against a
436
+ canonical request fingerprint in host persistence. The host preserves its
437
+ additional policy when a generic edit targets a specialized file.
438
+
439
+ `createAiSdkCatalogFileEditSchemas(maxWriteBytes)` exposes typed append/edit
440
+ Zod objects used by the generic factory itself. A host can `.extend()` these
441
+ with an optional product-only metadata field while retaining the same UTF-8
442
+ byte rules. Delegate ordinary input to the generic tool; dispatch specialized
443
+ input through the same leased product capability and command identity. Do not
444
+ cast an opaque tool schema or rebuild the generic schema locally.
445
+
446
+ The protector retains the lease/caller signal and reauthorizes every call.
447
+ Create-only base grants cannot replace or edit a catalog file. Workflow
448
+ `restrict({ permissions, mutations, limits, signal })` intersects existing
449
+ grants/ceilings and retains parent cancellation; it never changes scope. The
450
+ protector restricts persisted draft mutations to the base create/replace grants,
451
+ including resumed sealed drafts and replay, without requiring a public read
452
+ grant to perform an otherwise authorized commit. Child mounts do
453
+ not inherit broader catalog/workflow handles. Configure both delegates with the
454
+ same scope and compatible limits; the host catalog must enforce scan budgets
455
+ inside its implementation. Structured extensions on the returned workspace
456
+ are supported: owning features can attach protected product closures while
457
+ retaining the same `.catalog` and `.workflows` members. The structural getters
458
+ are contract checks for trusted handles, not authentication of arbitrary objects.
459
+ These static file operations acquire no lease themselves.
460
+
461
+ ### Host persistence transaction
462
+
463
+ Implement `StorageFileWorkflowPersistence<Scope, Receipt>.transaction(scope,
464
+ { permission, signal }, work)`. On **every** call (including replay and body
465
+ preparation reads), reauthorize that intent and serialize the scope's draft and
466
+ idempotency records across replicas. `read` admits readers; `write` and `commit`
467
+ must enforce the host's mutation authority. The callback receives detached
468
+ records through `findDraftByKey`, `getDraft`, `saveDraft`, `listDrafts`,
469
+ `listParts`, `putPart` and `commitHeads`.
470
+
471
+ `commitHeads` receives sorted `{ draft, body }` changes. It must compare every
472
+ create/expected-ETag predicate and change all logical heads **in the same host
473
+ transaction** as the subsequent committed draft records and receipts. Return
474
+ one non-null receipt per change in the supplied order. A rejection rolls back
475
+ all callback effects. Serial object writes or independently committed database
476
+ calls do not satisfy this port. The package does not claim to implement database
477
+ atomicity. See the packed consumer fixture and filesystem e2e test for executable
478
+ minimal hosts; production hosts need their own cross-replica transaction/locking
479
+ implementation and rollback tests.
480
+
481
+ Drafts transition `open → sealed → committed` or `open/sealed → cancelled`.
482
+ Appends accept a bounded snapshot of bytes at the exact current byte offset.
483
+ Identical offset/size/SHA-256 replay succeeds; changed bytes or gaps conflict.
484
+ Text chunks must contain complete valid UTF-8. Public summaries and part receipts
485
+ omit staged body IDs and provider keys. A commit seals all requested drafts,
486
+ streams verified chunks into immutable bodies, then commits all heads and draft
487
+ receipts in one host transaction. Failed preparation or stale heads leave sealed
488
+ drafts retryable/cancellable. Mixed committed/uncommitted batches conflict;
489
+ replaying an entirely committed batch returns its stored receipts after checking
490
+ the requested size and optional digest. Cancellation cannot undo committed heads.
491
+
492
+ Check the signal before durable transaction commit. After commit, return the
493
+ receipt even if cancellation races the response. Do not automatically retry the
494
+ transaction callback. A lost response requires replay/reconciliation, not an
495
+ assumption of rollback. Provider writes happen before the metadata transaction;
496
+ failures, losing concurrent preparations, cancellations and superseded files can
497
+ leave unreferenced bodies. Retention eligibility, reference tracking and cleanup
498
+ scheduling remain host-owned. `StorageStagedContentStore.remove` performs an
499
+ ETag-conditional deletion only after the host proves eligibility; it is not a
500
+ collector and is never called automatically by the workflow.
501
+
502
+ ### Text editing and checkpoints
503
+
504
+ `applyStorageTextEdit` accepts one exact replacement/append or `{ kind: 'batch',
505
+ changes }`. A batch applies in order to an intermediate buffer: later targets
506
+ see earlier changes. Persist only its final return value. Each target must match
507
+ exactly once, including overlapping matches; there is no fuzzy replacement.
508
+ `StorageTextEditConflict.diagnostic` identifies the failing zero-based edit index,
509
+ reason, and at most two source windows of 320 Unicode characters each. Window
510
+ offsets count UTF-8 bytes; line/column are one-based UTF-16 coordinates. Contexts
511
+ are untrusted source data from the intermediate buffer, not persisted changes.
512
+
513
+ `workflow.readText({ draftId, expectedSize })` provides a bounded exact source
514
+ buffer for host validation; concurrent size changes and cancellation conflict.
515
+ `workflow.stageText` saves a bounded text buffer as a sealed checkpoint.
516
+ `workflow.reviseText({ draftId, expectedSize, idempotencyKey, changes })` saves a
517
+ new sealed checkpoint, retains the original path/head ETag, and records
518
+ `sourceDraftId`. Its predecessor is sealed in the same host transaction; a
519
+ failed edit leaves even an open predecessor unchanged. A competing append,
520
+ cancellation or commit fails the revision check. Replays return the saved draft;
521
+ a key reused for different edits conflicts. Persist the new nullable
522
+ `sourceDraftId` field with every draft record. Keep it scoped like the draft.
523
+
524
+ `checkoutStorageCatalogText(catalog, workflow, input)` streams an exact
525
+ catalog revision into a sealed draft. Supplying the catalog to
526
+ `createAiSdkFileWorkflowTools` enables `workspace_checkout_file`;
527
+ `workspace_edit_file_draft` edits checkpoints without resending the source.
528
+ For path-based authoring, wrap the protected catalog and workflow in
529
+ `new StorageWorkingFiles(catalog, workflow)` and pass its `.catalog` to
530
+ `createAiSdkWorkingFileTools`. Ordinary write, append and edit calls return an
531
+ opaque working ETag. Reads and discovery include unfinished checkpoint leaves;
532
+ listing remains paginated. A host can resolve `working.draft({ path,
533
+ expectedEtag })`, perform domain admission, then commit the exact draft. Failed
534
+ admission leaves that same path and ETag editable. This adapter never commits
535
+ implicitly. Committed checkpoint ETags resolve to their receipt's saved ETag and
536
+ still enforce the current head; concurrent changes cannot be silently adopted.
537
+
538
+ `workflow.stageStream` stages a lazy source using bounded UTF-8-safe chunks and a
539
+ host-provided stable content identity. `reviseText` applies exact ordered edits
540
+ as a stream; file size is independent of the per-operation edit-text budget.
541
+ `maxTextBytes` still bounds buffered `stageText` inputs and `readText` validation.
542
+ Hosts must set their own admission and export resource limits. Stream failures
543
+ leave no partial checkpoint; immutable unreferenced chunks follow normal staged
544
+ content retention. `workflow.lookup` recovers host command receipts after
545
+ response loss, with fresh scope authorization. Replaying the original mutation
546
+ still verifies its full fingerprint and current mutation authority.
547
+
548
+ The catalog factory also exposes `workspace_edit_file_batch`. Tool inputs bound
549
+ all edit text together to the configured write/chunk budget. Exact-match failures
550
+ return structured `{ applied: false, code: 'CONFLICT', diagnostic, guidance }`;
551
+ provider failures retain the existing sanitized error contract.
552
+
553
+ Text staging, checkout and draft editing explicitly buffer at most `maxTextBytes`
554
+ (default 16 MiB); `maxEdits` defaults to 64 and restrictions only narrow limits.
555
+ Chunked creation and commit remain streaming. Read and write authority are both
556
+ required to revise a draft. The current file changes only when the host accepts
557
+ `commitHeads`; content validation, authorization, preview, retention and repair
558
+ budgets belong to that host. Earlier checkpoints remain readable until explicitly
559
+ cancelled. They do not implement published version history or undo committed
560
+ heads; restoring one after a commit requires the host's current-head precondition.
561
+
562
+ ### Byte and provider guarantees
563
+
564
+ Staging requires native create-only writes that return an ETag and native exact
565
+ ETag reads; byte ranges additionally require `rangeRead`. Missing primitives
566
+ fail with `NOT_SUPPORTED`, without an existence-check/unconditional-write or
567
+ full-download fallback. Files SDK remains the provider, streaming, range and
568
+ conditional-policy engine. Its multipart upload controls remain distinct from
569
+ the host-persisted draft protocol; it does not provide atomic catalog commits.
570
+ The bundled filesystem and memory drivers and verified native S3 profiles
571
+ support staging. Memory stores are process-local and volatile; their conditional
572
+ comparison/publication is synchronous after asynchronous body consumption.
573
+ Memory publications use canonical SHA-256 ETags. Direct raw-map mutation remains
574
+ a trusted testing escape hatch, outside protected workspace authority.
575
+
576
+ Windows count UTF-8 bytes, use inclusive provider range ends and preserve BOMs.
577
+ Offsets splitting a character and malformed/truncated UTF-8 at EOF fail. Search
578
+ budgets include the actual requested bytes; follow `nextOffset` even on an empty
579
+ match page. `applyStorageTextEdit` works on an already buffered string, requires a
580
+ result byte ceiling and rejects non-unique replacement targets; it is not a
581
+ streaming document editor. Model text appends default to 8192 bytes per call,
582
+ clamped to the capability. Mutation tools require approval by default; hosts may
583
+ apply an existing approval policy explicitly.
584
+
585
+ The browser bytes entrypoint imports no Node, provider, NestJS or AI modules.
586
+ `verifyStorageChunkReceipt(blob, receipt, { offset, maxBytes, signal })` verifies
587
+ ordering, bounds and the actual local SHA-256 before returning the next offset.
588
+ Filename/size/mtime are insufficient proof of resumed content identity. Filename
589
+ classification, browser session storage and HTTP transport remain host policy.
590
+
591
+ ### Azure Blob staged content
592
+
593
+ Use the Azure entrypoint for native create-only uploads, ETag-conditional
594
+ replacement/deletion, and exact ETag reads, including byte ranges. These operations
595
+ run through the Files SDK conditional pipeline, so hooks, retries, prefix policy,
596
+ readonly checks, and sanitized errors apply to them. The named `azure` provider
597
+ also receives these primitives.
598
+
599
+ ```ts
600
+ import { DefaultAzureCredential } from '@azure/identity';
601
+ import { createAzureStorageDriver } from '@nestm/storage/files-sdk/azure';
602
+
603
+ const driver = createAzureStorageDriver({
604
+ adapter: {
605
+ accountName: 'workspacestorage',
606
+ container: 'private-content',
607
+ credential: new DefaultAzureCredential(),
608
+ useUserDelegationSas: false,
609
+ },
610
+ });
611
+ ```
612
+
613
+ Install `@azure/storage-blob`, `@azure/core-auth`, and the credential provider used
614
+ by the host. The Blob container must already exist. Grant the workload identity
615
+ the required Blob data permissions; signed URLs are unnecessary for protected
616
+ server-side content operations. Explicit token credentials cannot be combined
617
+ with shared keys, SAS tokens, or connection strings, and cannot be overridden by
618
+ ambient shared-key environment variables.
619
+
620
+ Conditional uploads stream through bounded Azure SDK blocks and atomically apply
621
+ their predicate when committing. They return the committed ETag and actual byte
622
+ count without a separate metadata read. Failed uploads may leave uncommitted
623
+ blocks for Azure's own expiration; they never fall back to unconditional writes.
624
+ Exact reads verify the response ETag before exposing its body. The driver uses a
625
+ conservative 1024-byte UTF-8 physical-key budget within Azure's name limit.
626
+ Conditional server-side copy, version-ID predicates, and explicit conditional
627
+ multipart completion are not advertised. Workspace safe copy/move can use the
628
+ existing exact-read/create/delete workflow. The generic staged and encrypted
629
+ content stores require no Azure-specific changes.
630
+
631
+ Run `test/azure-content.e2e-spec.ts` with `STORAGE_AZURE_CONFORMANCE=true` and
632
+ `STORAGE_AZURE_TEST_CONNECTION_STRING` against a dedicated account or Azurite.
633
+ For live identity tests, supply `STORAGE_AZURE_TEST_ACCOUNT_NAME` instead; the
634
+ suite uses `DefaultAzureCredential`. It creates and deletes its own uniquely
635
+ named test container. The CI Azure job covers native predicates, competing
636
+ writes, bounded streams, ranges, encrypted content, and the provider conformance
637
+ suite on Azurite. Live Azure identity remains a separate deployment check.
638
+
83
639
  ## Configure named stores
84
640
 
85
- Create provider adapters with `files-sdk`, wrap them through the explicit
86
- bridge, and register the resulting drivers:
641
+ Use the package-owned S3 factory when applicable. For other providers, create a
642
+ `files-sdk` adapter, wrap it through the explicit bridge, and register the
643
+ resulting driver:
87
644
 
88
645
  ```ts
89
646
  import { Module } from '@nestjs/common';
90
- import { s3 } from 'files-sdk/s3';
91
647
  import { gcs } from 'files-sdk/gcs';
92
648
  import { createFilesSdkDriver } from '@nestm/storage/files-sdk';
649
+ import { createS3StorageDriver } from '@nestm/storage/files-sdk/s3';
93
650
  import { StorageModule } from '@nestm/storage';
94
651
 
95
652
  export const StorageKey = {
@@ -104,11 +661,11 @@ export const StorageKey = {
104
661
  stores: [
105
662
  {
106
663
  name: StorageKey.MEDIA,
107
- driver: createFilesSdkDriver({
108
- adapter: s3({
664
+ driver: createS3StorageDriver({
665
+ adapter: {
109
666
  bucket: 'media',
110
667
  region: 'us-east-1',
111
- }),
668
+ },
112
669
  }),
113
670
  },
114
671
  {
@@ -194,11 +751,11 @@ StorageModule.forRootAsync({
194
751
  name: 'media',
195
752
  inject: [ConfigService],
196
753
  useFactory: (config: ConfigService) =>
197
- createFilesSdkDriver({
198
- adapter: s3({
754
+ createS3StorageDriver({
755
+ adapter: {
199
756
  bucket: config.getOrThrow('MEDIA_BUCKET'),
200
757
  region: config.getOrThrow('AWS_REGION'),
201
- }),
758
+ },
202
759
  }),
203
760
  },
204
761
  ],
@@ -216,11 +773,136 @@ modules retain their own DI scope rather than mutating an application-wide
216
773
  registry. Names are case-sensitive and cannot contain leading or trailing
217
774
  whitespace.
218
775
 
776
+ ### Select the provider at runtime
777
+
778
+ An application that ships to more than one environment usually cannot name its
779
+ provider at build time. `createProviderStorageDriver` takes the slug as data and
780
+ imports that provider's adapter — and only that one — on demand, so a deployment
781
+ picks its store with an environment variable and installs one native SDK:
782
+
783
+ ```ts
784
+ import { createProviderStorageDriver } from '@nestm/storage/files-sdk/provider';
785
+
786
+ StorageModule.forRootAsync({
787
+ imports: [ConfigModule],
788
+ stores: [
789
+ {
790
+ name: 'media',
791
+ inject: [ConfigService],
792
+ useFactory: (config: ConfigService) =>
793
+ createProviderStorageDriver({
794
+ provider: config.getOrThrow('STORAGE_PROVIDER'),
795
+ prefix: config.get('STORAGE_PREFIX'),
796
+ config: {
797
+ bucket: config.get('STORAGE_BUCKET'),
798
+ region: config.get('STORAGE_REGION'),
799
+ root: config.get('STORAGE_ROOT'),
800
+ },
801
+ }),
802
+ },
803
+ ],
804
+ });
805
+ ```
806
+
807
+ `config` is one flat bag of provider settings — `bucket` and `region` for an
808
+ object store, `root` for the filesystem, `accountName` and `container` for
809
+ Azure. Each provider reads what it needs and ignores the rest, so the same shape
810
+ survives a provider change. Credentials may be omitted wherever the provider's
811
+ SDK resolves its own chain (an IAM role, Application Default Credentials, a
812
+ shared profile).
813
+
814
+ An unknown slug fails closed with `INVALID_ARGUMENT` before anything is
815
+ imported. Validate untrusted input up front with `isStorageProvider`, and drive
816
+ config validation from the catalog rather than a hand-kept list:
817
+
818
+ ```ts
819
+ import {
820
+ getStorageProvider,
821
+ isStorageProvider,
822
+ listStorageProviders,
823
+ listStorageProviderSecretEnvVars,
824
+ } from '@nestm/storage/files-sdk/provider';
825
+
826
+ listStorageProviders().map((provider) => provider.slug); // 'akamai', 'alibaba', …
827
+ getStorageProvider('gcs')?.peerDeps; // ['@google-cloud/storage', …]
828
+ listStorageProviderSecretEnvVars('s3').map((variable) => variable.key);
829
+ ```
830
+
831
+ The catalog is pure data and pulls in no adapter, so it is safe in config UIs,
832
+ health checks, and startup validation.
833
+
834
+ The `s3` slug additionally carries the verified per-operation profile and
835
+ signed-policy capabilities described under
836
+ [Exact provider conditions and staged-object promotion](#exact-provider-conditions-and-staged-object-promotion);
837
+ every provider not backed by the AWS S3 SDK exposes what its adapter declares.
838
+ When the provider _is_ known at build time, import
839
+ `@nestm/storage/files-sdk/s3` or `@nestm/storage/files-sdk/fs` directly and skip
840
+ the indirection.
841
+
842
+ S3 endpoint and public-URL provenance is resolved from the adapter that
843
+ `files-sdk` actually constructs, including values merged from `configJson`.
844
+ An unaudited endpoint forces the driver read-only, and a `publicBaseUrl` removes
845
+ the signed-download TTL guarantee because the resulting public URL does not
846
+ expire. AWS-SDK-backed noncanonical provider slugs (for example an S3-compatible
847
+ provider wrapper) also default to unverified/read-only; only the canonical
848
+ `s3` provider may infer the native AWS profile, and a custom endpoint becomes
849
+ writable only with an explicit branded `S3ProviderProfile`.
850
+
851
+ Before enabling conditional operations for a custom S3-compatible endpoint,
852
+ run the reusable
853
+ [provider conformance contract](https://github.com/nestm-dev/storage/blob/main/docs/provider-conformance.md)
854
+ against dedicated test credentials. Unknown endpoints are forced read-only and
855
+ receive no inferred conditional capabilities.
856
+
857
+ ## Files SDK responsibility boundary
858
+
859
+ Files SDK is the upstream authority for the generic storage data plane:
860
+ provider adapters, generic CRUD, bulk and list operations, retries, transfers
861
+ and sync, its plugin pipeline, and framework-neutral gateway mechanics.
862
+ `@nestm/storage` retains the guarantees that Files SDK does not currently
863
+ provide: NestJS 12 named stores, exact native conditional/CAS capabilities,
864
+ `StorageWorkspace` permissions and limits, bounded storage errors, and
865
+ capability-scoped AI tools.
866
+
867
+ Files SDK 2.3 provides native conditional operations through the same
868
+ interception boundary as ordinary CRUD. NestM adapters expose their exact
869
+ create, replace, ETag read, delete, and paired conditional-copy primitives to
870
+ that boundary. These operations now pass through caller-configured Files
871
+ plugins, hooks, retries, and receipts; a body transform, veto, retry observer,
872
+ or audit policy therefore sees the conditional operation instead of being
873
+ bypassed.
874
+
875
+ NestM retains a narrow direct fallback only for conditional shapes Files SDK
876
+ 2.3 cannot represent: immutable version predicates, conditional
877
+ multipart/resumable completion, and a copy with only its source or only its
878
+ destination conditioned. Because those fallbacks cannot traverse the Files
879
+ operation pipeline, they remain fail-closed when caller Files policy is active:
880
+
881
+ | Operation shape | Execution path | With Files plugins, active hooks, or receipts |
882
+ | ----------------------------------------------------------- | --------------------- | --------------------------------------------- |
883
+ | Ordinary operations | Files pipeline | Available |
884
+ | Create/replace/ETag read/delete/paired conditional copy | Files 2.3 pipeline | Available |
885
+ | Version, conditional multipart/resumable, or one-sided copy | NestM direct fallback | Hidden; invocation returns `NOT_SUPPORTED` |
886
+
887
+ An empty plugin list, an empty hooks object, and `receipts: false` do not count
888
+ as caller policy. When available, a direct fallback still applies prefixing,
889
+ the physical-key budget, read-only restrictions, default retry/signal/timeout
890
+ options, and bounded error mapping. `StoragePlugin` remains a separate
891
+ veto/observation boundary; it is not a substitute for Files body or result
892
+ transforms.
893
+
894
+ `StorageWorkspace` uses the Files pipeline whenever its conditional operation
895
+ has an upstream representation. Lower-level conditional client and driver APIs
896
+ remain available for applications that intentionally use the policy-free
897
+ NestM-only fallback shapes.
898
+
219
899
  ## Storage API
220
900
 
221
901
  `StorageClient` exposes:
222
902
 
223
903
  - `upload`, `downloadStream`, `head`, `exists`, `delete`, `copy`, and `move`;
904
+ - exact `uploadConditional`, `downloadConditional`, `deleteConditional`, and
905
+ staged-object `promote` operations when the driver advertises each primitive;
224
906
  - `list`, cursor-aware `listAll`, and lazy `search`;
225
907
  - `signDownload` and discriminated PUT/POST `signUpload`;
226
908
  - `uploadMany`, `downloadMany`, `headMany`, `existsMany`, and `deleteMany`;
@@ -228,6 +910,24 @@ whitespace.
228
910
  - provider capability inspection; and
229
911
  - pause/resume/abort through `StorageUploadControl`.
230
912
 
913
+ Provider list cursors are opaque, non-consuming continuation tokens. Replaying
914
+ the same cursor and page limit against unchanged provider-visible state must
915
+ return an equivalent page and continuation position, even after a descendant
916
+ cursor has been used. A cursor is bound to the logical store, `prefix`, and
917
+ `delimiter`, but not to `limit`, retries, timeout, or abort signal; callers may
918
+ change those transport/page-size options while resuming the same position.
919
+
920
+ The cursor must work through a newly constructed compatible driver targeting
921
+ the same backend namespace while the provider token remains valid and
922
+ available; it cannot depend on process-, client-, or session-local state. An
923
+ adapter for a consuming or instance-bound provider token must materialize a
924
+ stable continuation before it can provide conforming paginated
925
+ `StorageDriver.list` results. This contract lets a caller safely retry, replay,
926
+ or resume pagination on another replica. It does not promise a provider-token
927
+ lifetime, snapshot isolation across concurrent mutations, or provider,
928
+ network, credential, or authorization availability. Provider invalidation is
929
+ an ordinary list-operation failure.
930
+
231
931
  Downloads are streaming by default:
232
932
 
233
933
  ```ts
@@ -250,6 +950,138 @@ Node `Readable` uploads are accepted and converted to Web streams without
250
950
  buffering. Provider capability gaps fail closed with `StorageError` rather than
251
951
  silently discarding a range, metadata, or cache-control request.
252
952
 
953
+ ### Exact provider conditions and staged-object promotion
954
+
955
+ Capabilities distinguish conditional create, replace, delete, read, source
956
+ copy, destination copy, atomic source-and-destination promotion, and multipart
957
+ completion. They also declare the complete physical-key byte budget. Callers
958
+ must check the exact primitive they need; a missing field is unsupported and is
959
+ never widened from another operation.
960
+
961
+ Some adapters expose conditional copy only as a paired source-and-destination
962
+ operation. In that case,
963
+ `capabilities.conditionalCopySource.requiresDestinationPredicate` and
964
+ `capabilities.conditionalCopyDestination.requiresSourcePredicate` are `true`.
965
+ `StorageClient.promote` rejects a request missing the required counterpart
966
+ before provider I/O. A paired request must also satisfy the advertised
967
+ create/replace bit and
968
+ `capabilities.conditionalCopyDestination.atomicWithSource`.
969
+
970
+ The physical-key budget applies to the exact key sent to the adapter. It
971
+ therefore counts leading slashes for unprefixed drivers, the separator added to
972
+ a configured driver prefix, and provider prefixes derived for `list` or
973
+ `search`. Over-budget object keys and explicit list/search prefixes fail before
974
+ provider dispatch rather than being normalized into a shorter key. A glob's
975
+ provider prefix is the exact prefix inferred by files-sdk itself, and that
976
+ derived list operation passes through the same final guard. A non-positive
977
+ `maxResults` performs no provider walk and therefore dispatches no prefix.
978
+
979
+ Adapters and plugins execute as trusted in-process code; this package does not
980
+ attempt to sandbox a plugin that performs its own network or filesystem I/O.
981
+ For supported plugin pipelines that forward operations through `next`, the
982
+ physical-key guard is the innermost wrapper, after caller plugins and before
983
+ the adapter call. A plugin therefore cannot widen an upload key or list/search
984
+ prefix past the declared byte ceiling while still using the normal dispatch
985
+ pipeline.
986
+
987
+ Storage-facing ETags have one canonical representation: a bare, case-sensitive
988
+ opaque token with no HTTP quotes. Canonical values contain 1–1024 visible
989
+ ASCII bytes and exclude commas, backslashes, whitespace, control characters,
990
+ `DEL`, non-ASCII text, the `*` wildcard, and case-insensitive `W/` weak-tag
991
+ prefixes. Treat the value as opaque: preserve the exact string returned by
992
+ `head`, reads, or writes and pass it back unchanged to a conditional operation.
993
+ Do not add or remove quotes in application code. S3-compatible drivers remove
994
+ exactly one valid provider-owned quote pair on ingress and add exactly one pair
995
+ when serializing the HTTP header.
996
+
997
+ This tightens the precondition boundary. Quoted or otherwise non-canonical
998
+ ETags that older versions happened to accept now fail with `INVALID_ARGUMENT`,
999
+ and an unsafe provider result fails with a sanitized `PROVIDER` error instead
1000
+ of being exposed as a usable validator. Applications that persisted quoted
1001
+ ETags must refresh them with `head` rather than trimming them heuristically.
1002
+ Strict normalization prevents a caller-controlled wildcard, entity-tag list,
1003
+ weak validator, or malformed header value from widening an operation that
1004
+ promises one exact strong match.
1005
+
1006
+ The native AWS S3 profile advertises ETag- and version-conditioned server-side
1007
+ copy. This lets an application validate a staged object and copy that exact
1008
+ source to its final key instead of re-reading whichever bytes occupy the
1009
+ staging key later:
1010
+
1011
+ ```ts
1012
+ import { StorageError, StorageErrorCode } from '@nestm/storage';
1013
+
1014
+ const staged = await media.head(stagingKey);
1015
+ if (staged.etag === undefined) {
1016
+ throw new StorageError('Provider did not return a source ETag.', {
1017
+ code: StorageErrorCode.NOT_SUPPORTED,
1018
+ });
1019
+ }
1020
+
1021
+ // Validate size, declared MIME, and magic bytes before this call.
1022
+ await media.file(stagingKey).promoteTo(finalKey, {
1023
+ sourceEtag: staged.etag,
1024
+ });
1025
+
1026
+ // Commit ready metadata first. Promotion deliberately retains the staged
1027
+ // object so a failed database commit remains recoverable.
1028
+ await media.delete(stagingKey);
1029
+ ```
1030
+
1031
+ `sourceVersion` can select an immutable S3 version and may be combined with
1032
+ `sourceEtag`. A destination condition can independently require create-only or
1033
+ replacement of an exact ETag. Combining source and destination predicates also
1034
+ requires `capabilities.conditionalCopyDestination.atomicWithSource`; otherwise
1035
+ the request fails with `NOT_SUPPORTED`. A promotion must contain at least one
1036
+ source or destination predicate.
1037
+
1038
+ Cloudflare R2 has a separate stable profile: create, replace, ETag-conditioned
1039
+ read, and ETag-conditioned source copy are enabled, while conditional delete,
1040
+ destination copy, atomic promotion, version predicates, and conditional
1041
+ multipart completion remain absent. R2 proves content-type binding for
1042
+ presigned PUT requests but not POST-form size ranges, so its signed-upload
1043
+ policy is `{ contentType: true, sizeRange: false }` and the gateway refuses to
1044
+ mint its POST upload form. Cloudflare documents that presigned `POST` form
1045
+ uploads are not supported in its
1046
+ [presigned URL contract](https://developers.cloudflare.com/r2/api/s3/presigned-urls/).
1047
+ Direct R2 signed-upload calls that request a size bound likewise fail with
1048
+ `NOT_SUPPORTED` before signing.
1049
+ Custom S3-compatible endpoints start with no conditional operations and the
1050
+ entire driver is forced read-only until an explicit conformance-verified
1051
+ `S3ProviderProfile` is supplied. Omitting `signedUploadPolicy` while defining a
1052
+ custom profile normalizes both policy claims to `false`; providers may opt in
1053
+ only to constraints their conformance evidence proves.
1054
+
1055
+ Successful S3 signed uploads enforce every requested constraint. A request
1056
+ with `contentType` and no `maxSize` uses a presigned PUT whose signature
1057
+ includes the `content-type` header. A request with `maxSize` uses a POST policy
1058
+ with `content-length-range` and, when present, an exact `Content-Type`
1059
+ condition. S3 cannot express a lower-only `minSize` through this contract, so
1060
+ that shape fails with `NOT_SUPPORTED`; an unclaimed profile constraint also
1061
+ fails before credentials are resolved or a URL is minted. Literal physical
1062
+ keys ending in AWS's `${filename}` POST template are rejected for bounded
1063
+ uploads because the SDK otherwise widens the exact key condition to a prefix.
1064
+
1065
+ `withS3Capabilities()` decorates a raw S3 adapter in place and may be applied
1066
+ only once. Construct a fresh raw adapter when selecting a different profile;
1067
+ reapplying the helper is rejected so a previous broader profile cannot survive
1068
+ a later narrower declaration. The selected profile is bound to the exact
1069
+ reserved capability and operation members installed by that decoration;
1070
+ same-client aliases may change display metadata but cannot add or replace those
1071
+ members to widen the profile.
1072
+
1073
+ An explicit profile applied to a native AWS SDK endpoint may only narrow the
1074
+ immutable `AWS_S3_PROVIDER_PROFILE`. It cannot raise the complete-key budget
1075
+ above 1,024 bytes or claim an operation/policy bit absent from the built-in
1076
+ profile. This containment follows actual SDK endpoint provenance even when an
1077
+ adapter alias changes its display name.
1078
+
1079
+ The package-owned `s3()` factory also retains whether `publicBaseUrl` was
1080
+ configured even when the second `withS3Capabilities()` options object is
1081
+ omitted. In that case `signedDownloadPolicy.expiresIn` is false. Foreign S3
1082
+ adapters whose construction metadata is unavailable receive the same
1083
+ conservative false value instead of claiming an enforceable TTL.
1084
+
253
1085
  ### Resumable uploads
254
1086
 
255
1087
  ```ts
@@ -301,20 +1133,42 @@ The gateway lives at `@nestm/storage/gateway` and is never mounted by
301
1133
  `StorageModule`.
302
1134
 
303
1135
  ```ts
304
- import { Module } from '@nestjs/common';
1136
+ import { Injectable, Module } from '@nestjs/common';
305
1137
  import {
306
1138
  StorageGatewayModule,
307
1139
  StorageGatewayOperation,
1140
+ type StorageGatewayKeyPolicy,
308
1141
  } from '@nestm/storage/gateway';
309
1142
 
1143
+ @Injectable()
1144
+ class TenantStorageKeyPolicy implements StorageGatewayKeyPolicy {
1145
+ resolve({ input, request, target }) {
1146
+ const tenantId = tenantIdFromAuthenticatedRequest(request);
1147
+ const root = `tenants/${base64url(tenantId)}`;
1148
+ if (target === 'pattern') {
1149
+ // Search is already constrained by the separately resolved prefix.
1150
+ return input?.value ?? '*';
1151
+ }
1152
+ return `${root}/${input?.value ?? ''}`;
1153
+ }
1154
+ }
1155
+
1156
+ @Module({
1157
+ providers: [TenantStorageKeyPolicy],
1158
+ exports: [TenantStorageKeyPolicy],
1159
+ })
1160
+ class StoragePolicyModule {}
1161
+
310
1162
  @Module({
311
1163
  imports: [
312
1164
  AppStorageModule,
313
1165
  AuthModule,
1166
+ StoragePolicyModule,
314
1167
  StorageGatewayModule.register({
315
- imports: [AppStorageModule, AuthModule],
1168
+ imports: [AppStorageModule, AuthModule, StoragePolicyModule],
316
1169
  store: 'media',
317
1170
  guards: [JwtAuthGuard],
1171
+ keyPolicy: TenantStorageKeyPolicy,
318
1172
  mode: 'hybrid',
319
1173
  operations: [
320
1174
  StorageGatewayOperation.DOWNLOAD,
@@ -325,6 +1179,9 @@ import {
325
1179
  StorageGatewayOperation.SIGN_UPLOAD,
326
1180
  ],
327
1181
  maxUploadBytes: 100 * 1024 * 1024,
1182
+ maxSignedUploadBytes: 10 * 1024 * 1024,
1183
+ signedUploadContentTypes: ['image/jpeg', 'image/png'],
1184
+ maxSignedUrlExpiresIn: 900,
328
1185
  maxListResults: 1000,
329
1186
  maxSearchResults: 1000,
330
1187
  proxyInlineContentTypes: ['image/jpeg', 'image/png'],
@@ -338,12 +1195,42 @@ Registration fails without at least one existing Nest guard. The only bypass is
338
1195
  the explicit `allowUnauthenticated: true` development escape hatch. Operations
339
1196
  are deny-by-default and must be allowlisted individually.
340
1197
 
1198
+ Registration also fails without a `keyPolicy`. Guards answer whether a request
1199
+ may reach the gateway; the key policy independently resolves every parsed
1200
+ `key`, `prefix`, search `pattern`, `from`, and `to` value to the exact provider
1201
+ path. It runs even when a list/search prefix was omitted, so the policy can
1202
+ always impose a tenant root. Key-policy providers may be request scoped.
1203
+ Returned paths are parsed again and reject absolute paths, backslashes, control
1204
+ characters, empty segments, and dot/parent segments.
1205
+
1206
+ Existing single-tenant applications can temporarily set
1207
+ `unsafeAllowUnscopedKeys: true` instead of `keyPolicy`. The name is intentional:
1208
+ it preserves caller-controlled provider keys and must not be used on an exposed
1209
+ or multi-tenant gateway. It cannot be combined with `keyPolicy`.
1210
+
341
1211
  Proxy downloads default to `Content-Disposition: attachment` and always send
342
1212
  `X-Content-Type-Options: nosniff`. Add only trusted, non-active MIME types to
343
1213
  `proxyInlineContentTypes` when browser rendering is required. Search responses
344
1214
  are capped by `maxSearchResults`, and list pages by `maxListResults` (both
345
1215
  1,000 by default).
346
1216
 
1217
+ Every signed URL is capped by `maxSignedUrlExpiresIn` (3,600 seconds by
1218
+ default). Signed uploads always carry a provider-enforced maximum size, capped
1219
+ by `maxSignedUploadBytes`, and require an exact lowercase MIME type from
1220
+ `signedUploadContentTypes`. The default direct-upload allowlist contains only
1221
+ `application/octet-stream`. Gateway callers may request only the literal
1222
+ `attachment` or `inline` response disposition; arbitrary response-header text
1223
+ and filenames are rejected. A driver must also advertise
1224
+ `signedUploadPolicy.contentType` and `signedUploadPolicy.sizeRange`; otherwise
1225
+ the gateway refuses to mint the URL. Native AWS advertises both and uses S3
1226
+ POST policy conditions. R2 advertises content-type enforcement but not a POST
1227
+ size range, while omitted custom declarations normalize both claims to false;
1228
+ the gateway therefore fails closed for those profiles. Signed downloads
1229
+ similarly require
1230
+ `signedDownloadPolicy.expiresIn`. The S3 factory advertises it only when no
1231
+ permanent `publicBaseUrl` was configured, preventing a configured TTL from
1232
+ silently returning a non-expiring public link.
1233
+
347
1234
  The fixed gateway prefix is `/storage`:
348
1235
 
349
1236
  | Method | Path | Operation |
@@ -394,9 +1281,26 @@ try {
394
1281
  }
395
1282
  ```
396
1283
 
1284
+ `files-sdk` `NotFound` failures retain `StorageErrorCode.NOT_FOUND`, including
1285
+ when a provider adapter and this driver resolve separate copies of `files-sdk`.
1286
+ `isStorageError()` likewise recognizes branded and exact legacy structural
1287
+ errors produced by a duplicated `@nestm/storage` package copy.
1288
+
1289
+ For a conditional mutation, `error.applied === true` means the provider commit
1290
+ succeeded but acknowledgement failed afterward, for example in an awaited
1291
+ post-operation plugin. Conditional uploads also expose `error.appliedEtag` when
1292
+ the committed generation is known. Do not blindly retry the original
1293
+ predicate: reconcile the logical destination first, using an exact ETag read
1294
+ when `appliedEtag` is present. This is a one-way signal: `applied === false`
1295
+ does not prove that a remote mutation did not commit. A timeout, connection
1296
+ loss, or exhausted retry can lose the provider's success response, so reconcile
1297
+ ambiguous transport/provider failures before repeating a conditional mutation.
1298
+ The sanitized workspace, AI-tool, and gateway error boundaries retain only
1299
+ this bounded reconciliation metadata.
1300
+
397
1301
  Capability flags cover range reads, native byte-level upload progress,
398
1302
  delimiter listing, metadata, cache control, resumable uploads, server-side
399
- copy, and signed transfers.
1303
+ copy, conditional promotion, and signed transfers.
400
1304
  Provider-specific native clients are intentionally not exposed from the root
401
1305
  package.
402
1306
 
@@ -412,9 +1316,51 @@ StorageModule.forRoot({
412
1316
  });
413
1317
  ```
414
1318
 
415
- For local filesystem storage, import `fs` from `files-sdk/fs` and pass it to
416
- `createFilesSdkDriver` exactly like a cloud adapter.
1319
+ For local filesystem storage, use the package-owned factory. The adapter reaches
1320
+ only `node:fs`, so it needs no native SDK:
1321
+
1322
+ ```ts
1323
+ import { createFsStorageDriver } from '@nestm/storage/files-sdk/fs';
1324
+
1325
+ StorageModule.forRoot({
1326
+ stores: [
1327
+ {
1328
+ name: 'artifacts',
1329
+ driver: createFsStorageDriver({ adapter: { root: './var/artifacts' } }),
1330
+ },
1331
+ ],
1332
+ });
1333
+ ```
1334
+
1335
+ Bodies are written verbatim at `<root>/<key>`. A `<key>.meta.json` sidecar beside
1336
+ each one carries the content type, ETag, and custom metadata a filesystem has
1337
+ nowhere else to put; sidecars never surface as keys, and uploading a key ending
1338
+ in `.meta.json` fails closed rather than colliding with one. Ordinary and
1339
+ conditional mutations made through the decorated adapter share one
1340
+ process-local lock domain. Conditional guarantees therefore require a
1341
+ dedicated root: do not mutate it through another process, an unwrapped adapter,
1342
+ or direct filesystem calls.
417
1343
 
418
1344
  ## License
419
1345
 
420
1346
  MIT
1347
+
1348
+ ## Encrypted staged content
1349
+
1350
+ `@nestm/storage/crypto` exports `StorageEncryptedContentStore<Scope>`. Install the optional `@nestm/crypto` peer only for this entry point. The store uses a host-owned `FileCipherEngine`, object-address function, fresh AAD buffers, allowed key providers, and metadata callbacks. It prepares detached encryption metadata before uploading and completes metadata only after plaintext and ciphertext accounting agree. Reads pin physical ETags and authenticate full content or bounded frame ranges.
1351
+
1352
+ The host authorizes reads, serializes metadata, enforces key policy and atomically proves reference eligibility before `metadata.discard` removes anything. A missing upload receipt means completion is uncertain: keep prepared cleanup inventory for a later sweep. A rejected prepare does not authorize cleanup of another writer's reservation. Engine shutdown stays with its owner. See [the standalone archive consumer](scripts/fixtures/encrypted-content-consumer.ts).
1353
+
1354
+ `StorageStagedContentStore.writeReserved(scope, payloadId, stream, options)` accepts a fresh UUID from a trusted caller. It retains create-only semantics and returns that exact identity in the receipt; it never overwrites an existing payload. Ordinary writers continue to use `write`.
1355
+
1356
+ ### Revision-pinned host streams
1357
+
1358
+ Catalog `readStream({ path, expectedEtag, start?, end?, signal? })` and workflow
1359
+ `readStream({ draftId, expectedSize, start?, end?, signal? })` return metadata and
1360
+ a byte stream. Ranges use an inclusive start and exclusive end. These host-only
1361
+ operations retain read authorization and linked cancellation; they are never
1362
+ projected as unbounded model tools. Catalog hosts must pin immutable content to
1363
+ the requested revision and reject stale heads. Saved-file working edits consume
1364
+ one stream directly into an atomic candidate without creating a checkout first.
1365
+ `stageStream.body` may asynchronously open its source after authorization and
1366
+ replay validation.