@nestm/storage 0.1.0-alpha.6 → 0.1.0-alpha.8

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 (87) hide show
  1. package/CHANGELOG.md +128 -0
  2. package/README.md +398 -19
  3. package/SECURITY.md +69 -0
  4. package/dist/ai-sdk/ai-sdk-workspace-tools.d.ts +74 -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 +414 -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/files-sdk/files-sdk.driver.d.ts +64 -6
  13. package/dist/files-sdk/files-sdk.driver.d.ts.map +1 -1
  14. package/dist/files-sdk/files-sdk.driver.js +918 -38
  15. package/dist/files-sdk/files-sdk.driver.js.map +1 -1
  16. package/dist/files-sdk/fs/index.d.ts +21 -2
  17. package/dist/files-sdk/fs/index.d.ts.map +1 -1
  18. package/dist/files-sdk/fs/index.js +833 -1
  19. package/dist/files-sdk/fs/index.js.map +1 -1
  20. package/dist/files-sdk/index.d.ts +1 -1
  21. package/dist/files-sdk/index.d.ts.map +1 -1
  22. package/dist/files-sdk/index.js.map +1 -1
  23. package/dist/files-sdk/provider/index.d.ts +9 -5
  24. package/dist/files-sdk/provider/index.d.ts.map +1 -1
  25. package/dist/files-sdk/provider/index.js +60 -15
  26. package/dist/files-sdk/provider/index.js.map +1 -1
  27. package/dist/files-sdk/s3/construction-metadata.d.ts +7 -0
  28. package/dist/files-sdk/s3/construction-metadata.d.ts.map +1 -0
  29. package/dist/files-sdk/s3/construction-metadata.js +13 -0
  30. package/dist/files-sdk/s3/construction-metadata.js.map +1 -0
  31. package/dist/files-sdk/s3/index.d.ts +43 -14
  32. package/dist/files-sdk/s3/index.d.ts.map +1 -1
  33. package/dist/files-sdk/s3/index.js +1046 -57
  34. package/dist/files-sdk/s3/index.js.map +1 -1
  35. package/dist/gateway/storage-gateway.controller.d.ts.map +1 -1
  36. package/dist/gateway/storage-gateway.controller.js +25 -5
  37. package/dist/gateway/storage-gateway.controller.js.map +1 -1
  38. package/dist/storage-etag.d.ts +13 -0
  39. package/dist/storage-etag.d.ts.map +1 -0
  40. package/dist/storage-etag.js +31 -0
  41. package/dist/storage-etag.js.map +1 -0
  42. package/dist/storage.client.d.ts +7 -1
  43. package/dist/storage.client.d.ts.map +1 -1
  44. package/dist/storage.client.js +124 -10
  45. package/dist/storage.client.js.map +1 -1
  46. package/dist/storage.driver.d.ts +23 -1
  47. package/dist/storage.driver.d.ts.map +1 -1
  48. package/dist/storage.driver.js.map +1 -1
  49. package/dist/storage.service.d.ts.map +1 -1
  50. package/dist/storage.service.js +28 -3
  51. package/dist/storage.service.js.map +1 -1
  52. package/dist/storage.types.d.ts +113 -6
  53. package/dist/storage.types.d.ts.map +1 -1
  54. package/dist/storage.types.js.map +1 -1
  55. package/dist/testing/index.d.ts +1 -0
  56. package/dist/testing/index.d.ts.map +1 -1
  57. package/dist/testing/index.js +1 -0
  58. package/dist/testing/index.js.map +1 -1
  59. package/dist/testing/provider-conformance.d.ts +65 -0
  60. package/dist/testing/provider-conformance.d.ts.map +1 -0
  61. package/dist/testing/provider-conformance.js +853 -0
  62. package/dist/testing/provider-conformance.js.map +1 -0
  63. package/dist/workspace/index.d.ts +5 -0
  64. package/dist/workspace/index.d.ts.map +1 -0
  65. package/dist/workspace/index.js +5 -0
  66. package/dist/workspace/index.js.map +1 -0
  67. package/dist/workspace/storage-workspace.cursor.d.ts +71 -0
  68. package/dist/workspace/storage-workspace.cursor.d.ts.map +1 -0
  69. package/dist/workspace/storage-workspace.cursor.js +408 -0
  70. package/dist/workspace/storage-workspace.cursor.js.map +1 -0
  71. package/dist/workspace/storage-workspace.d.ts +6 -0
  72. package/dist/workspace/storage-workspace.d.ts.map +1 -0
  73. package/dist/workspace/storage-workspace.error.d.ts +28 -0
  74. package/dist/workspace/storage-workspace.error.d.ts.map +1 -0
  75. package/dist/workspace/storage-workspace.error.js +67 -0
  76. package/dist/workspace/storage-workspace.error.js.map +1 -0
  77. package/dist/workspace/storage-workspace.js +755 -0
  78. package/dist/workspace/storage-workspace.js.map +1 -0
  79. package/dist/workspace/storage-workspace.path.d.ts +9 -0
  80. package/dist/workspace/storage-workspace.path.d.ts.map +1 -0
  81. package/dist/workspace/storage-workspace.path.js +68 -0
  82. package/dist/workspace/storage-workspace.path.js.map +1 -0
  83. package/dist/workspace/storage-workspace.types.d.ts +105 -0
  84. package/dist/workspace/storage-workspace.types.d.ts.map +1 -0
  85. package/dist/workspace/storage-workspace.types.js +21 -0
  86. package/dist/workspace/storage-workspace.types.js.map +1 -0
  87. package/package.json +24 -5
package/CHANGELOG.md CHANGED
@@ -1,5 +1,133 @@
1
1
  # @nestm/storage
2
2
 
3
+ ## 0.1.0-alpha.8
4
+
5
+ ### Minor Changes
6
+
7
+ - d81c6f4: Add a typed `mapCreateConflict` hook to the AI SDK workspace adapter so
8
+ applications can represent atomic create collisions as domain results without
9
+ mutating generated tools. Keep replace/ETag conflicts fail-closed and sanitize
10
+ mapper failures at the tool boundary.
11
+
12
+ Mark workspace tools with optional inputs or a combined create/replace union as
13
+ non-strict for provider schema generation while retaining strict Zod runtime
14
+ validation.
15
+
16
+ - a0ea392: Add injectable, replica-safe workspace pagination cursors. The workspace now
17
+ binds versioned cursor payloads to stable store, mount, tenant/workspace, prefix,
18
+ operation, query, limit, and expiry context; authorizes non-consuming replay
19
+ before that expiry; and rejects altered or cross-context continuations.
20
+ Successful continuation still depends on the embedded provider cursor remaining
21
+ valid and available.
22
+
23
+ Export an AES-256-GCM key-ring codec for stateless multi-replica deployments and
24
+ an asynchronous byte-payload codec contract for shared durable opaque-token
25
+ stores. Cursor payloads and tokens are bounded, provider continuations remain
26
+ opaque, and pagination fails closed when no cursor mechanism is configured.
27
+ Compatible replicas rely on the universal driver contract for non-consuming,
28
+ instance-portable provider cursors whose position is independent of page size
29
+ while the provider token remains valid. Cursor expiry is not a provider-token
30
+ retention, snapshot-isolation, or uptime promise; provider invalidation remains
31
+ an operational list failure.
32
+
33
+ - d996b92: Split the aggregate S3 conditional-mutation and copy declarations into exact
34
+ create, replace, delete, read, source-copy, destination-copy, atomic-promotion,
35
+ and multipart-completion capabilities. Add independent AWS S3, Cloudflare R2,
36
+ and fail-closed custom-endpoint profiles that force unverified drivers
37
+ read-only; enforce complete physical-key byte budgets; normalize provider
38
+ errors without retaining raw provider payloads or causes; and publish a reusable
39
+ provider conformance contract with gated filesystem, AWS, R2, and custom suites.
40
+
41
+ Normalize every provider ETag to a canonical bare 1–1024-byte visible ASCII
42
+ token and serialize exactly one HTTP quote pair at S3 request boundaries.
43
+ Quoted, weak, wildcard, list-shaped, control-bearing, non-ASCII, and otherwise
44
+ unsafe values now fail closed instead of being accepted as arbitrary non-empty
45
+ strings. Applications that persisted quoted ETags must refresh them from
46
+ provider metadata before conditional mutation; this prevents wildcard/list and
47
+ header-ambiguity inputs from widening an exact-match precondition.
48
+
49
+ Specify and test provider list cursors as non-consuming replayable tokens for
50
+ unchanged provider state, so higher-level replayable pagination can fail closed
51
+ when a provider cannot meet that contract.
52
+
53
+ Exercise the complete advertised source-condition by destination-condition
54
+ promotion matrix, including stale-state preservation and competing stale/valid
55
+ requests. Provider documentation or audited implementation evidence remains
56
+ required for the internal one-linearization-point claim.
57
+
58
+ Ordinary and conditional provider failures now expose stable public messages
59
+ and preserve only normalized codes and retry flags; raw provider bodies,
60
+ request metadata, and nested causes are not retained in loggable error shapes.
61
+
62
+ Raise the `@aws-sdk/client-s3` peer floor to 3.919.0, the first release that
63
+ serializes destination conditions for `CopyObject`, and verify the real wire
64
+ headers in the packed minimum-peer consumer. Native AWS construction now
65
+ disables environment and shared-config endpoint URL overrides, while the public
66
+ capability helper derives custom-endpoint provenance from the actual SDK client
67
+ instead of a duplicated caller hint. Capability decoration is now single-use
68
+ per raw S3 adapter, preventing broader operations from surviving a later
69
+ narrower profile application.
70
+
71
+ Bind S3 provider authority to package-private raw-client and adapter-method
72
+ identity plus the exact surface snapshot installed by capability decoration.
73
+ Structurally S3-backed raw adapters are rejected until they pass through the
74
+ package helper, regardless of adapter name, proxying, or forged global symbols;
75
+ same-client aliases cannot replace their raw client, ordinary methods, policies,
76
+ or conditional operations. Unverified custom endpoints and noncanonical
77
+ S3-backed provider slugs are forced read-only, while an explicit branded profile
78
+ unlocks only its declared conditional operations. Endpoint and public-URL
79
+ provenance now follows the adapter actually produced by the provider loader,
80
+ including `configJson`.
81
+
82
+ Retain `publicBaseUrl` construction policy in the package-owned `s3()` helper
83
+ so omitted decorator hints cannot re-enable an expiring-download claim. Unknown
84
+ foreign S3 construction conservatively disables that claim. Validate the exact
85
+ physical adapter key without stripping leading slashes, and include configured
86
+ separators plus list/search-derived prefixes in the provider byte budget before
87
+ dispatch. Search uses files-sdk's own inferred glob-prefix and zero-result
88
+ semantics instead of duplicating its matcher logic. The innermost dispatch guard
89
+ repeats these checks after supported in-process plugins have transformed an
90
+ operation; adapters and plugins remain trusted code rather than a sandbox
91
+ boundary.
92
+
93
+ Derive signed-upload policy claims from the branded provider profile instead
94
+ of granting them to every S3-compatible endpoint. Native AWS proves content
95
+ type and POST size-range enforcement; Cloudflare R2 proves content type but not
96
+ POST form size ranges; omitted custom declarations normalize to false/false so
97
+ the gateway fails closed. Profile authority is backed by a package-private
98
+ WeakSet after deep freezing, so reflecting and copying the nominal brand symbol
99
+ cannot forge conformance evidence.
100
+
101
+ Treat the built-in AWS profile as an immutable ceiling for every SDK client
102
+ with native endpoint provenance, independent of mutable adapter display names.
103
+ Explicit profiles may narrow its operations, policy bits, and key limit but
104
+ cannot raise the 1,024-byte physical-key budget or add unsupported claims.
105
+
106
+ Enforce every requested signed-upload constraint at URL creation time.
107
+ Content-type-constrained PUT URLs now sign the `content-type` header; bounded
108
+ AWS uploads use exact POST MIME and byte-range conditions; unsupported profile
109
+ constraints and lower-only S3 ranges fail before signing. Bounded POST uploads
110
+ also reject physical keys ending in AWS's `${filename}` template so an exact
111
+ authorized key cannot be widened into a prefix policy.
112
+
113
+ ## 0.1.0-alpha.7
114
+
115
+ ### Minor Changes
116
+
117
+ - 842ff34: Add a backend-neutral `StorageWorkspace` capability and optional AI SDK 7 tool
118
+ adapter. Workspaces expose only canonical mount-relative paths, enforce
119
+ permissions and resource limits, hide provider coordinates and cursors, and use
120
+ atomic create/ETag mutation preconditions. S3 now advertises and implements the
121
+ conditional mutation primitives used by writable workspaces.
122
+
123
+ Harden local filesystem workspace reads and conditional mutations against
124
+ symlink aliases. Moves retain their create-only destination whenever source
125
+ deletion cannot be confirmed, avoiding data loss after provider or
126
+ post-operation hook ambiguity.
127
+
128
+ Fix cross-store sync so `destinationPrefix` is applied to uploaded keys as well
129
+ as pruning, keeping every mutation inside the selected destination scope.
130
+
3
131
  ## 0.1.0-alpha.6
4
132
 
5
133
  ### Minor Changes
package/README.md CHANGED
@@ -1,8 +1,8 @@
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
@@ -36,6 +36,14 @@ such as `files-sdk/gcs`:
36
36
  pnpm add files-sdk@2.2.3
37
37
  ```
38
38
 
39
+ Pass AWS-SDK-backed S3 adapters through the package-owned `s3()` and
40
+ `withS3Capabilities()` helpers (or use `createS3StorageDriver()`).
41
+ `createFilesSdkDriver()` rejects a structurally S3-backed raw adapter that has
42
+ not crossed this provenance boundary, even when a wrapper renames or proxies
43
+ the adapter. This prevents callers from bypassing endpoint and provider-profile
44
+ checks by omitting the capability decorator or replacing the public `raw`
45
+ property while retaining methods closed over the original client.
46
+
39
47
  Install only the native SDKs required by the chosen provider. For example:
40
48
 
41
49
  ```sh
@@ -50,6 +58,11 @@ pnpm add @google-cloud/storage google-auth-library
50
58
  pnpm add @azure/storage-blob @azure/core-auth @azure/identity
51
59
  ```
52
60
 
61
+ `@aws-sdk/client-s3` 3.919.0 or newer is required. Earlier releases omit
62
+ destination `If-Match` and `If-None-Match` from the serialized `CopyObject`
63
+ request even when those fields appear in a newer compile-time command shape;
64
+ the package peer range and packed minimum-peer smoke test enforce this floor.
65
+
53
66
  `files-sdk` currently declares its optional Nest peer for Nest 10 and 11. This
54
67
  library does not import `files-sdk/nestjs`; the Nest 12 integration is entirely
55
68
  owned here. A package manager may nevertheless report that temporary optional
@@ -88,6 +101,242 @@ storage errors and operation types, and `StorageUploadControl`. It has no NestJS
88
101
  runtime or declaration imports. Provider adapters remain available through
89
102
  `@nestm/storage/files-sdk`.
90
103
 
104
+ ## Mounted agent workspaces
105
+
106
+ `@nestm/storage/workspace` turns a `StorageClient` into a narrow capability for
107
+ one logical directory. The mount is virtual: the same API works over S3, a
108
+ filesystem driver, or another storage backend without exposing the provider,
109
+ bucket, filesystem root, raw cursor, or internal prefix to its caller.
110
+
111
+ ```mermaid
112
+ flowchart LR
113
+ A["Trusted application context"] -->|"store + opaque prefix + policy"| W["StorageWorkspace"]
114
+ W --> C["StorageClient"]
115
+ C --> D["S3 / filesystem / other driver"]
116
+ W --> T["AI SDK workspace tools"]
117
+ T --> G["ToolLoopAgent"]
118
+ ```
119
+
120
+ Only trusted application code chooses the mount prefix. Every path accepted by
121
+ the workspace is a canonical, relative POSIX path. Absolute paths, backslashes,
122
+ control characters, repeated separators, and `.` or `..` segments are rejected
123
+ rather than normalized. Keys and provider cursors returned by a driver are also
124
+ checked before they are converted back to logical paths.
125
+
126
+ Pagination requires a server-owned cursor configuration. The built-in
127
+ `Aes256GcmStorageWorkspaceCursorCodec` produces versioned, authenticated,
128
+ encrypted tokens that can resume on another request, process, or replica when
129
+ every replica constructs an equivalent codec from the same key ring and uses
130
+ the same stable store identity, physical prefix, mount ID, trusted scope, and
131
+ effective limits. Use one codec instance per process, use a dedicated 32-byte
132
+ key, retain rotated decryption keys for at least one cursor TTL, and derive
133
+ `mountId` and `scope` only from authenticated server context.
134
+
135
+ The underlying driver must also implement the universal replayable list-cursor
136
+ contract against the same logical backend namespace: its cursor cannot be
137
+ consumed or tied to one driver instance. While the provider cursor remains
138
+ valid and available, an outer cursor can be retried until its authenticated
139
+ expiry. That expiry is only an authorization ceiling: it does not extend a
140
+ provider token's lifetime or promise snapshot isolation, provider availability,
141
+ network access, or valid credentials. Provider invalidation is an operational
142
+ list failure, and concurrent object changes remain subject to provider
143
+ continuation semantics. Without a codec, single-page operations still work but
144
+ a continuation fails closed.
145
+
146
+ ```ts
147
+ import {
148
+ Aes256GcmStorageWorkspaceCursorCodec,
149
+ mountStorageWorkspace,
150
+ } from '@nestm/storage/workspace';
151
+
152
+ // cursorKey is a separately validated 32-byte secret from deployment config.
153
+ const cursorCodec = new Aes256GcmStorageWorkspaceCursorCodec({
154
+ activeKeyId: 'v1',
155
+ keys: { v1: cursorKey },
156
+ });
157
+
158
+ const workspace = mountStorageWorkspace(agentFiles, {
159
+ // Use an opaque server-derived run id, never a value selected by the model.
160
+ prefix: `workspaces/${runId}`,
161
+ cursor: {
162
+ codec: cursorCodec,
163
+ mountId: `agent-workspace:${runId}`,
164
+ scope: `organization:${organizationId}/workspace:${workspaceId}`,
165
+ },
166
+ permissions: [
167
+ 'list',
168
+ 'read',
169
+ 'search',
170
+ 'create',
171
+ 'replace',
172
+ 'copy',
173
+ 'move',
174
+ 'delete',
175
+ ],
176
+ limits: {
177
+ cursorTtlMs: 5 * 60 * 1000,
178
+ maxCursorBytes: 4096,
179
+ maxReadBytes: 1024 * 1024,
180
+ maxWriteBytes: 1024 * 1024,
181
+ maxPageSize: 100,
182
+ maxSearchResults: 100,
183
+ maxSearchScan: 1000,
184
+ },
185
+ });
186
+
187
+ const created = await workspace.writeFile(
188
+ 'src/main.ts',
189
+ 'export const ready = true;\n',
190
+ { mode: 'create', contentType: 'text/typescript' },
191
+ );
192
+
193
+ if (created.etag === undefined) {
194
+ throw new Error('This backend cannot safely replace the object.');
195
+ }
196
+
197
+ await workspace.writeFile('src/main.ts', 'export const ready = false;\n', {
198
+ mode: 'replace',
199
+ etag: created.etag,
200
+ contentType: 'text/typescript',
201
+ });
202
+ ```
203
+
204
+ Create, replace, and delete are conditional operations. A driver that cannot
205
+ enforce the requested not-exists or ETag precondition fails with
206
+ `NOT_SUPPORTED`; the workspace never substitutes an `exists()`/`head()` check
207
+ followed by an unconditional mutation. Reads enforce their byte ceiling while
208
+ consuming the stream, and list/search results are bounded. Search supports
209
+ exact, substring, and workspace-coordinate glob matching, but no caller-supplied
210
+ regular expressions.
211
+
212
+ Move is implemented as create-only copy followed by ETag-conditional source
213
+ delete. If source deletion cannot be confirmed, the destination is retained and
214
+ the call returns `CONFLICT`; inspect both logical paths before retrying. This
215
+ preserves at least one copy across provider timeouts and post-operation hook
216
+ failures, but does not pretend a multi-object move is transactionally atomic.
217
+
218
+ A child mount may further restrict a directory, permissions, or limits, but it
219
+ cannot widen any of them:
220
+
221
+ ```ts
222
+ const readOnlySource = workspace.mount('src', {
223
+ permissions: ['list', 'read', 'search'],
224
+ limits: { maxReadBytes: 256 * 1024 },
225
+ });
226
+ ```
227
+
228
+ ### AI SDK and NestJS composition
229
+
230
+ Install AI SDK 7 and Zod only in applications that use the optional adapter:
231
+
232
+ ```sh
233
+ pnpm add ai@^7 zod@^4
234
+ ```
235
+
236
+ `files-sdk` 2.2.x still declares an optional `ai@^6` peer for its own adapter,
237
+ so some package managers may print a peer warning when AI SDK 7 is installed.
238
+ This package does not import that adapter; `@nestm/storage/ai-sdk` targets AI
239
+ SDK 7 directly.
240
+
241
+ `@nestm/storage/ai-sdk` converts an already-mounted workspace to an ordinary
242
+ upstream `ToolSet`. It does not import NestJS or `@nestm/ai-sdk`; the application
243
+ composes the tool set through the AI module's existing named-toolset factory.
244
+ For a tenant or run selected per request, make both factories request-scoped and
245
+ derive the mount coordinate from authenticated host context:
246
+
247
+ ```ts
248
+ import { Module, Scope } from '@nestjs/common';
249
+ import { AiSdkModule, AiSdkService, getAiToolsetToken } from '@nestm/ai-sdk';
250
+ import { getStorageToken, type StorageClient } from '@nestm/storage';
251
+ import { createAiSdkWorkspaceTools } from '@nestm/storage/ai-sdk';
252
+ import { mountStorageWorkspace } from '@nestm/storage/workspace';
253
+ import type { ToolSet } from 'ai';
254
+
255
+ @Module({
256
+ imports: [
257
+ AppStorageModule,
258
+ WorkspaceContextModule,
259
+ AiSdkModule.forFeature({
260
+ imports: [AppStorageModule, WorkspaceContextModule],
261
+ toolsets: [
262
+ {
263
+ name: 'workspace',
264
+ scope: Scope.REQUEST,
265
+ inject: [getStorageToken('agent-files'), WorkspaceContext],
266
+ useFactory: (storage: StorageClient, context: WorkspaceContext) =>
267
+ createAiSdkWorkspaceTools({
268
+ workspace: mountStorageWorkspace(storage, {
269
+ // A validated, opaque coordinate from trusted auth/run state.
270
+ // It is never accepted from a prompt or tool input.
271
+ prefix: context.storagePrefix,
272
+ // Includes the singleton codec plus stable mountId and scope.
273
+ cursor: context.cursorConfiguration,
274
+ permissions: [
275
+ 'list',
276
+ 'read',
277
+ 'search',
278
+ 'create',
279
+ 'replace',
280
+ 'copy',
281
+ 'move',
282
+ 'delete',
283
+ ],
284
+ }),
285
+ }),
286
+ },
287
+ ],
288
+ agents: [
289
+ {
290
+ name: 'workspace-agent',
291
+ scope: Scope.REQUEST,
292
+ inject: [AiSdkService, getAiToolsetToken('workspace')],
293
+ useFactory: (ai: AiSdkService, tools: ToolSet) => ({
294
+ model: ai.languageModel(),
295
+ instructions:
296
+ 'Use only the mounted workspace tools for file operations.',
297
+ tools,
298
+ }),
299
+ },
300
+ ],
301
+ }),
302
+ ],
303
+ })
304
+ export class WorkspaceAgentModule {}
305
+ ```
306
+
307
+ The generated set contains only tools allowed by the workspace permissions:
308
+ bounded list, stat, UTF-8 read, and search tools plus conditional create,
309
+ replace, copy, move, and delete tools when granted. Mutation tools require AI
310
+ SDK user approval by default; approval can be configured per tool, but the
311
+ workspace capability remains the authorization boundary even when approval is
312
+ disabled. The module's `AiSdkService.files()` API is the model provider's file
313
+ upload facility and is unrelated to storage workspaces.
314
+
315
+ Atomic create collisions remain sanitized tool errors by default. Applications
316
+ that model an existing destination as a normal tool result can map that one
317
+ case while preserving replace/ETag conflicts as failures:
318
+
319
+ ```ts
320
+ const tools = createAiSdkWorkspaceTools({
321
+ workspace,
322
+ mapCreateConflict: ({ path }) => ({
323
+ kind: 'artifact-conflict' as const,
324
+ path,
325
+ status: 'already-exists' as const,
326
+ }),
327
+ });
328
+ ```
329
+
330
+ The mapper receives only the logical workspace path; provider errors, object
331
+ keys, and mount coordinates are never exposed.
332
+
333
+ This logical confinement is sufficient for a `ToolLoopAgent` whose only file
334
+ capabilities are these tools. It cannot constrain a coding harness that already
335
+ has shell, `node:fs`, or subprocess access. For Codex/Claude-style harnesses,
336
+ materialize the workspace into a per-session container or VM, mount only that
337
+ directory, run the harness there, and synchronize reviewed changes back through
338
+ `StorageWorkspace`. A working directory alone is not a sandbox.
339
+
91
340
  ## Configure named stores
92
341
 
93
342
  Use the package-owned S3 factory when applicable. For other providers, create a
@@ -283,19 +532,36 @@ listStorageProviderSecretEnvVars('s3').map((variable) => variable.key);
283
532
  The catalog is pure data and pulls in no adapter, so it is safe in config UIs,
284
533
  health checks, and startup validation.
285
534
 
286
- The `s3` slug additionally carries the conditional-promotion and signed-policy
287
- capabilities described under
288
- [Race-free staged-object promotion](#race-free-staged-object-promotion); every
289
- other provider exposes exactly what its adapter declares. When the provider _is_
290
- known at build time, import `@nestm/storage/files-sdk/s3` or
291
- `@nestm/storage/files-sdk/fs` directly and skip the indirection.
535
+ The `s3` slug additionally carries the verified per-operation profile and
536
+ signed-policy capabilities described under
537
+ [Exact provider conditions and staged-object promotion](#exact-provider-conditions-and-staged-object-promotion);
538
+ every provider not backed by the AWS S3 SDK exposes what its adapter declares.
539
+ When the provider _is_ known at build time, import
540
+ `@nestm/storage/files-sdk/s3` or `@nestm/storage/files-sdk/fs` directly and skip
541
+ the indirection.
542
+
543
+ S3 endpoint and public-URL provenance is resolved from the adapter that
544
+ `files-sdk` actually constructs, including values merged from `configJson`.
545
+ An unaudited endpoint forces the driver read-only, and a `publicBaseUrl` removes
546
+ the signed-download TTL guarantee because the resulting public URL does not
547
+ expire. AWS-SDK-backed noncanonical provider slugs (for example an S3-compatible
548
+ provider wrapper) also default to unverified/read-only; only the canonical
549
+ `s3` provider may infer the native AWS profile, and a custom endpoint becomes
550
+ writable only with an explicit branded `S3ProviderProfile`.
551
+
552
+ Before enabling conditional operations for a custom S3-compatible endpoint,
553
+ run the reusable
554
+ [provider conformance contract](https://github.com/nestm-dev/storage/blob/main/docs/provider-conformance.md)
555
+ against dedicated test credentials. Unknown endpoints are forced read-only and
556
+ receive no inferred conditional capabilities.
292
557
 
293
558
  ## Storage API
294
559
 
295
560
  `StorageClient` exposes:
296
561
 
297
562
  - `upload`, `downloadStream`, `head`, `exists`, `delete`, `copy`, and `move`;
298
- - conditional staged-object `promote` when the driver advertises it;
563
+ - exact `uploadConditional`, `downloadConditional`, `deleteConditional`, and
564
+ staged-object `promote` operations when the driver advertises each primitive;
299
565
  - `list`, cursor-aware `listAll`, and lazy `search`;
300
566
  - `signDownload` and discriminated PUT/POST `signUpload`;
301
567
  - `uploadMany`, `downloadMany`, `headMany`, `existsMany`, and `deleteMany`;
@@ -303,6 +569,24 @@ known at build time, import `@nestm/storage/files-sdk/s3` or
303
569
  - provider capability inspection; and
304
570
  - pause/resume/abort through `StorageUploadControl`.
305
571
 
572
+ Provider list cursors are opaque, non-consuming continuation tokens. Replaying
573
+ the same cursor and page limit against unchanged provider-visible state must
574
+ return an equivalent page and continuation position, even after a descendant
575
+ cursor has been used. A cursor is bound to the logical store, `prefix`, and
576
+ `delimiter`, but not to `limit`, retries, timeout, or abort signal; callers may
577
+ change those transport/page-size options while resuming the same position.
578
+
579
+ The cursor must work through a newly constructed compatible driver targeting
580
+ the same backend namespace while the provider token remains valid and
581
+ available; it cannot depend on process-, client-, or session-local state. An
582
+ adapter for a consuming or instance-bound provider token must materialize a
583
+ stable continuation before it can provide conforming paginated
584
+ `StorageDriver.list` results. This contract lets a caller safely retry, replay,
585
+ or resume pagination on another replica. It does not promise a provider-token
586
+ lifetime, snapshot isolation across concurrent mutations, or provider,
587
+ network, credential, or authorization availability. Provider invalidation is
588
+ an ordinary list-operation failure.
589
+
306
590
  Downloads are streaming by default:
307
591
 
308
592
  ```ts
@@ -325,11 +609,54 @@ Node `Readable` uploads are accepted and converted to Web streams without
325
609
  buffering. Provider capability gaps fail closed with `StorageError` rather than
326
610
  silently discarding a range, metadata, or cache-control request.
327
611
 
328
- ### Race-free staged-object promotion
329
-
330
- The S3 bridge advertises ETag- and version-conditional server-side copy. This
331
- lets an application validate a staged object and copy that exact source to its
332
- final key instead of re-reading whichever bytes occupy the staging key later:
612
+ ### Exact provider conditions and staged-object promotion
613
+
614
+ Capabilities distinguish conditional create, replace, delete, read, source
615
+ copy, destination copy, atomic source-and-destination promotion, and multipart
616
+ completion. They also declare the complete physical-key byte budget. Callers
617
+ must check the exact primitive they need; a missing field is unsupported and is
618
+ never widened from another operation.
619
+
620
+ The physical-key budget applies to the exact key sent to the adapter. It
621
+ therefore counts leading slashes for unprefixed drivers, the separator added to
622
+ a configured driver prefix, and provider prefixes derived for `list` or
623
+ `search`. Over-budget object keys and explicit list/search prefixes fail before
624
+ provider dispatch rather than being normalized into a shorter key. A glob's
625
+ provider prefix is the exact prefix inferred by files-sdk itself, and that
626
+ derived list operation passes through the same final guard. A non-positive
627
+ `maxResults` performs no provider walk and therefore dispatches no prefix.
628
+
629
+ Adapters and plugins execute as trusted in-process code; this package does not
630
+ attempt to sandbox a plugin that performs its own network or filesystem I/O.
631
+ For supported plugin pipelines that forward operations through `next`, the
632
+ physical-key guard is the innermost wrapper, after caller plugins and before
633
+ the adapter call. A plugin therefore cannot widen an upload key or list/search
634
+ prefix past the declared byte ceiling while still using the normal dispatch
635
+ pipeline.
636
+
637
+ Storage-facing ETags have one canonical representation: a bare, case-sensitive
638
+ opaque token with no HTTP quotes. Canonical values contain 1–1024 visible
639
+ ASCII bytes and exclude commas, backslashes, whitespace, control characters,
640
+ `DEL`, non-ASCII text, the `*` wildcard, and case-insensitive `W/` weak-tag
641
+ prefixes. Treat the value as opaque: preserve the exact string returned by
642
+ `head`, reads, or writes and pass it back unchanged to a conditional operation.
643
+ Do not add or remove quotes in application code. S3-compatible drivers remove
644
+ exactly one valid provider-owned quote pair on ingress and add exactly one pair
645
+ when serializing the HTTP header.
646
+
647
+ This tightens the precondition boundary. Quoted or otherwise non-canonical
648
+ ETags that older versions happened to accept now fail with `INVALID_ARGUMENT`,
649
+ and an unsafe provider result fails with a sanitized `PROVIDER` error instead
650
+ of being exposed as a usable validator. Applications that persisted quoted
651
+ ETags must refresh them with `head` rather than trimming them heuristically.
652
+ Strict normalization prevents a caller-controlled wildcard, entity-tag list,
653
+ weak validator, or malformed header value from widening an operation that
654
+ promises one exact strong match.
655
+
656
+ The native AWS S3 profile advertises ETag- and version-conditioned server-side
657
+ copy. This lets an application validate a staged object and copy that exact
658
+ source to its final key instead of re-reading whichever bytes occupy the
659
+ staging key later:
333
660
 
334
661
  ```ts
335
662
  import { StorageError, StorageErrorCode } from '@nestm/storage';
@@ -352,9 +679,58 @@ await media.delete(stagingKey);
352
679
  ```
353
680
 
354
681
  `sourceVersion` can select an immutable S3 version and may be combined with
355
- `sourceEtag`. A promotion without either identity is rejected. Drivers that do
356
- not publish `capabilities.conditionalCopy` fail with `NOT_SUPPORTED` rather
357
- than falling back to an unsafe ordinary copy.
682
+ `sourceEtag`. A destination condition can independently require create-only or
683
+ replacement of an exact ETag. Combining source and destination predicates also
684
+ requires `capabilities.conditionalCopyDestination.atomicWithSource`; otherwise
685
+ the request fails with `NOT_SUPPORTED`. A promotion must contain at least one
686
+ source or destination predicate.
687
+
688
+ Cloudflare R2 has a separate stable profile: create, replace, ETag-conditioned
689
+ read, and ETag-conditioned source copy are enabled, while conditional delete,
690
+ destination copy, atomic promotion, version predicates, and conditional
691
+ multipart completion remain absent. R2 proves content-type binding for
692
+ presigned PUT requests but not POST-form size ranges, so its signed-upload
693
+ policy is `{ contentType: true, sizeRange: false }` and the gateway refuses to
694
+ mint its POST upload form. Cloudflare documents that presigned `POST` form
695
+ uploads are not supported in its
696
+ [presigned URL contract](https://developers.cloudflare.com/r2/api/s3/presigned-urls/).
697
+ Direct R2 signed-upload calls that request a size bound likewise fail with
698
+ `NOT_SUPPORTED` before signing.
699
+ Custom S3-compatible endpoints start with no conditional operations and the
700
+ entire driver is forced read-only until an explicit conformance-verified
701
+ `S3ProviderProfile` is supplied. Omitting `signedUploadPolicy` while defining a
702
+ custom profile normalizes both policy claims to `false`; providers may opt in
703
+ only to constraints their conformance evidence proves.
704
+
705
+ Successful S3 signed uploads enforce every requested constraint. A request
706
+ with `contentType` and no `maxSize` uses a presigned PUT whose signature
707
+ includes the `content-type` header. A request with `maxSize` uses a POST policy
708
+ with `content-length-range` and, when present, an exact `Content-Type`
709
+ condition. S3 cannot express a lower-only `minSize` through this contract, so
710
+ that shape fails with `NOT_SUPPORTED`; an unclaimed profile constraint also
711
+ fails before credentials are resolved or a URL is minted. Literal physical
712
+ keys ending in AWS's `${filename}` POST template are rejected for bounded
713
+ uploads because the SDK otherwise widens the exact key condition to a prefix.
714
+
715
+ `withS3Capabilities()` decorates a raw S3 adapter in place and may be applied
716
+ only once. Construct a fresh raw adapter when selecting a different profile;
717
+ reapplying the helper is rejected so a previous broader profile cannot survive
718
+ a later narrower declaration. The selected profile is bound to the exact
719
+ reserved capability and operation members installed by that decoration;
720
+ same-client aliases may change display metadata but cannot add or replace those
721
+ members to widen the profile.
722
+
723
+ An explicit profile applied to a native AWS SDK endpoint may only narrow the
724
+ immutable `AWS_S3_PROVIDER_PROFILE`. It cannot raise the complete-key budget
725
+ above 1,024 bytes or claim an operation/policy bit absent from the built-in
726
+ profile. This containment follows actual SDK endpoint provenance even when an
727
+ adapter alias changes its display name.
728
+
729
+ The package-owned `s3()` factory also retains whether `publicBaseUrl` was
730
+ configured even when the second `withS3Capabilities()` options object is
731
+ omitted. In that case `signedDownloadPolicy.expiresIn` is false. Foreign S3
732
+ adapters whose construction metadata is unavailable receive the same
733
+ conservative false value instead of claiming an enforceable TTL.
358
734
 
359
735
  ### Resumable uploads
360
736
 
@@ -496,8 +872,11 @@ by `maxSignedUploadBytes`, and require an exact lowercase MIME type from
496
872
  `attachment` or `inline` response disposition; arbitrary response-header text
497
873
  and filenames are rejected. A driver must also advertise
498
874
  `signedUploadPolicy.contentType` and `signedUploadPolicy.sizeRange`; otherwise
499
- the gateway refuses to mint the URL. `createS3StorageDriver()` advertises both
500
- and uses S3 POST policy conditions. Signed downloads similarly require
875
+ the gateway refuses to mint the URL. Native AWS advertises both and uses S3
876
+ POST policy conditions. R2 advertises content-type enforcement but not a POST
877
+ size range, while omitted custom declarations normalize both claims to false;
878
+ the gateway therefore fails closed for those profiles. Signed downloads
879
+ similarly require
501
880
  `signedDownloadPolicy.expiresIn`. The S3 factory advertises it only when no
502
881
  permanent `publicBaseUrl` was configured, preventing a configured TTL from
503
882
  silently returning a non-expiring public link.