From 3cd866f9cfd5de19c4c998bbb47a908066c5560a Mon Sep 17 00:00:00 2001 From: Kauan Guesser Date: Fri, 14 Aug 2026 13:37:34 -0300 Subject: [PATCH] fix: make workspace cursors replica-safe --- .changeset/fix-workspace-cursor-continuity.md | 20 + README.md | 60 +- SECURITY.md | 39 +- docs/provider-conformance.md | 53 +- scripts/test-packed-core-consumer.mjs | 271 ++++--- src/ai-sdk/ai-sdk-workspace-tools.spec.ts | 14 +- src/ai-sdk/ai-sdk-workspace-tools.ts | 15 +- src/storage.driver.ts | 19 + src/storage.types.ts | 15 +- src/testing/provider-conformance.spec.ts | 278 +++++-- src/testing/provider-conformance.ts | 246 +++++- src/workspace/index.ts | 9 + .../storage-workspace.cursor.spec.ts | 100 +++ src/workspace/storage-workspace.cursor.ts | 699 ++++++++++++++++-- src/workspace/storage-workspace.spec.ts | 373 +++++++++- src/workspace/storage-workspace.ts | 119 +-- src/workspace/storage-workspace.types.ts | 12 +- test/provider-conformance.e2e-spec.ts | 22 +- 18 files changed, 2025 insertions(+), 339 deletions(-) create mode 100644 .changeset/fix-workspace-cursor-continuity.md create mode 100644 src/workspace/storage-workspace.cursor.spec.ts diff --git a/.changeset/fix-workspace-cursor-continuity.md b/.changeset/fix-workspace-cursor-continuity.md new file mode 100644 index 0000000..19734bb --- /dev/null +++ b/.changeset/fix-workspace-cursor-continuity.md @@ -0,0 +1,20 @@ +--- +'@nestm/storage': minor +--- + +Add injectable, replica-safe workspace pagination cursors. The workspace now +binds versioned cursor payloads to stable store, mount, tenant/workspace, prefix, +operation, query, limit, and expiry context; authorizes non-consuming replay +before that expiry; and rejects altered or cross-context continuations. +Successful continuation still depends on the embedded provider cursor remaining +valid and available. + +Export an AES-256-GCM key-ring codec for stateless multi-replica deployments and +an asynchronous byte-payload codec contract for shared durable opaque-token +stores. Cursor payloads and tokens are bounded, provider continuations remain +opaque, and pagination fails closed when no cursor mechanism is configured. +Compatible replicas rely on the universal driver contract for non-consuming, +instance-portable provider cursors whose position is independent of page size +while the provider token remains valid. Cursor expiry is not a provider-token +retention, snapshot-isolation, or uptime promise; provider invalidation remains +an operational list failure. diff --git a/README.md b/README.md index 4d8dcf8..42743ac 100644 --- a/README.md +++ b/README.md @@ -123,12 +123,46 @@ control characters, repeated separators, and `.` or `..` segments are rejected rather than normalized. Keys and provider cursors returned by a driver are also checked before they are converted back to logical paths. +Pagination requires a server-owned cursor configuration. The built-in +`Aes256GcmStorageWorkspaceCursorCodec` produces versioned, authenticated, +encrypted tokens that can resume on another request, process, or replica when +every replica constructs an equivalent codec from the same key ring and uses +the same stable store identity, physical prefix, mount ID, trusted scope, and +effective limits. Use one codec instance per process, use a dedicated 32-byte +key, retain rotated decryption keys for at least one cursor TTL, and derive +`mountId` and `scope` only from authenticated server context. + +The underlying driver must also implement the universal replayable list-cursor +contract against the same logical backend namespace: its cursor cannot be +consumed or tied to one driver instance. While the provider cursor remains +valid and available, an outer cursor can be retried until its authenticated +expiry. That expiry is only an authorization ceiling: it does not extend a +provider token's lifetime or promise snapshot isolation, provider availability, +network access, or valid credentials. Provider invalidation is an operational +list failure, and concurrent object changes remain subject to provider +continuation semantics. Without a codec, single-page operations still work but +a continuation fails closed. + ```ts -import { mountStorageWorkspace } from '@nestm/storage/workspace'; +import { + Aes256GcmStorageWorkspaceCursorCodec, + mountStorageWorkspace, +} from '@nestm/storage/workspace'; + +// cursorKey is a separately validated 32-byte secret from deployment config. +const cursorCodec = new Aes256GcmStorageWorkspaceCursorCodec({ + activeKeyId: 'v1', + keys: { v1: cursorKey }, +}); const workspace = mountStorageWorkspace(agentFiles, { // Use an opaque server-derived run id, never a value selected by the model. prefix: `workspaces/${runId}`, + cursor: { + codec: cursorCodec, + mountId: `agent-workspace:${runId}`, + scope: `organization:${organizationId}/workspace:${workspaceId}`, + }, permissions: [ 'list', 'read', @@ -140,6 +174,8 @@ const workspace = mountStorageWorkspace(agentFiles, { 'delete', ], limits: { + cursorTtlMs: 5 * 60 * 1000, + maxCursorBytes: 4096, maxReadBytes: 1024 * 1024, maxWriteBytes: 1024 * 1024, maxPageSize: 100, @@ -233,6 +269,8 @@ import type { ToolSet } from 'ai'; // A validated, opaque coordinate from trusted auth/run state. // It is never accepted from a prompt or tool input. prefix: context.storagePrefix, + // Includes the singleton codec plus stable mountId and scope. + cursor: context.cursorConfiguration, permissions: [ 'list', 'read', @@ -514,10 +552,22 @@ receive no inferred conditional capabilities. - pause/resume/abort through `StorageUploadControl`. Provider list cursors are opaque, non-consuming continuation tokens. Replaying -the same cursor with the same list options against unchanged provider state -must return an equivalent page and continuation cursor. This contract lets a -caller safely retry or replay pagination; it does not promise snapshot -isolation across concurrent provider mutations. +the same cursor and page limit against unchanged provider-visible state must +return an equivalent page and continuation position, even after a descendant +cursor has been used. A cursor is bound to the logical store, `prefix`, and +`delimiter`, but not to `limit`, retries, timeout, or abort signal; callers may +change those transport/page-size options while resuming the same position. + +The cursor must work through a newly constructed compatible driver targeting +the same backend namespace while the provider token remains valid and +available; it cannot depend on process-, client-, or session-local state. An +adapter for a consuming or instance-bound provider token must materialize a +stable continuation before it can provide conforming paginated +`StorageDriver.list` results. This contract lets a caller safely retry, replay, +or resume pagination on another replica. It does not promise a provider-token +lifetime, snapshot isolation across concurrent mutations, or provider, +network, credential, or authorization availability. Provider invalidation is +an ordinary list-operation failure. Downloads are streaming by default: diff --git a/SECURITY.md b/SECURITY.md index d1f059f..f1c055c 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -56,11 +56,40 @@ untrusted agent code. A tenant id, run id, prefix, provider cursor, snapshot id, or fork id supplied by a model is not a safe mount coordinate. The workspace accepts only canonical mount-relative POSIX paths and rechecks -every key returned by a driver before unscoping it. Its cursors are opaque and -bound to the mount and query. Permissions, byte limits, result limits, and -conditional mutation preconditions are enforced inside the capability; tool -omission and user approval are additional workflow controls, not the -authorization boundary. +every key returned by a driver before unscoping it. Pagination cursors are bound +to the store, normalized physical prefix, stable mount identity, trusted +tenant/workspace scope, operation, complete effective limits, normalized query, +and expiry. Provider continuations and prefixes stay inside the encrypted or +server-side payload. Both layers are non-consuming: a durable opaque token +store must read rather than consume a record, and an embedded provider cursor +must remain replayable through a fresh compatible driver against the same +backend namespace while that provider cursor remains valid and available. +Provider cursors cannot depend on process-local state; an adapter for a +consuming or instance-bound backend token must materialize a stable continuation +before exposing paginated `StorageDriver.list` results. + +Under unchanged provider-visible state, cursors are reusable while their +provider continuation remains valid and available. The authenticated expiry is +an authorization ceiling, not a guarantee of provider-token lifetime, snapshot +isolation, provider/network availability, or valid credentials. Provider +invalidation remains an operational failure, and concurrent mutations remain +subject to provider ordering, duplicate, and omission semantics. + +Production pagination must configure either the built-in AES-256-GCM codec with +a dedicated shared 32-byte key ring or an authenticated shared durable token +store. Do not reuse an authentication, session, storage-provider, or encryption +key from another purpose. All replicas must use the same stable identities and +key ring, compatible driver configuration, and logical backend namespace. New +cursors use the active key id; retain prior keys for at least the maximum cursor +TTL during rotation. Dropping a key, changing a binding field, or restarting +with a different ephemeral key intentionally invalidates outstanding cursors. +Tokens and decoded payloads are bounded, and malformed, altered, expired, +cross-query, cross-operation, cross-workspace, and cross-store cursors fail +closed. + +Permissions, byte limits, result limits, and conditional mutation preconditions +are enforced inside the capability; tool omission and user approval are +additional workflow controls, not the authorization boundary. Conditional mutations can still have an ambiguous outcome when a remote provider commits and then loses or violates its response, or when a configured diff --git a/docs/provider-conformance.md b/docs/provider-conformance.md index 1195c68..0c12dee 100644 --- a/docs/provider-conformance.md +++ b/docs/provider-conformance.md @@ -4,8 +4,8 @@ `createStorageProviderConformanceCases()`, a runner-agnostic contract harness for the conditional storage boundary. It verifies the exact declared capability matrix, supported operations, fail-closed unsupported operations, -the complete physical-key byte budget, conflict normalization, and public -error sanitation. +replayable and replica-portable list cursors, the complete physical-key byte +budget, conflict normalization, and public error sanitation. Every failure observed by the harness must be log-safe as a nested object, via `util.inspect`, and via JSON serialization. Provider bodies, request/host IDs, @@ -50,16 +50,41 @@ discarded and refreshed from provider metadata before mutation tests run. ## List cursor replay contract -Provider list cursors are opaque and non-consuming. Reusing the same cursor -with the same prefix, delimiter, and page limit against unchanged provider -state must return an equivalent page and the same continuation state. The -conformance harness creates three objects, advances beyond a cursor-backed -page, then replays the original cursor and compares the full page metadata and -next cursor. A consuming or unstable provider cursor fails conformance; callers -must not advertise replayable higher-level cursors for that provider. - -This is an idempotent-replay guarantee, not snapshot isolation. Results may -change when another actor mutates the provider namespace between list calls. +Provider list cursors are opaque, non-consuming, and portable. They are bound +to the original `prefix` and `delimiter`, but not to `limit`: callers may choose +a different supported page size when they resume. Against unchanged provider +state, reusing a cursor must resume from the same logical position even after a +caller has followed a descendant cursor. + +The same token must work through an independently constructed compatible +`StorageDriver` and `StorageClient` that address the same logical store with the +same backend configuration while the provider cursor remains valid and +available. A driver therefore cannot keep cursor state only in process memory +or bind it to one client/session. + +Equivalent continuation positions do not require byte-identical continuation +tokens. The harness always runs a delimiter-free case and additionally covers +common prefixes when the driver advertises delimiter support. It follows a +one-entry page and its descendant, replays the ancestor, changes the page limit, +and repeats both page sizes through a fresh replica. It compares page metadata, +common prefixes, and the page reached by each returned continuation instead of +comparing opaque token bytes. A partial page with matching entries remaining +must include a cursor; silently omitting one is a contract failure, not an +opt-out. Fixtures must provide `createReplica()` using a new driver and client +for the same root, bucket, logical store name, and provider configuration; the +harness shuts that client down even when a case fails. + +A one-shot, session-bound, or otherwise consuming custom driver violates the +public `StorageDriver.list` contract and fails this case. Such a driver must be +adapted to issue stable portable cursors before it can implement +`StorageDriver`. + +This is an idempotent-replay guarantee, not snapshot isolation or a cursor +lifetime/uptime SLA. Results may change when another actor mutates the provider +namespace between list calls. Provider token invalidation and ordinary backend, +network, credential, or authorization failures may still make a list request +fail. The deterministic harness proves immediate replay and replica portability; +it does not time-prove provider cursor retention. ## Combined copy atomicity @@ -220,7 +245,8 @@ register each returned case with their test runner. A case may return `{ status: "skipped", reason }` for a provider prerequisite such as bucket versioning; translate that result into the runner's native skip mechanism. -Fixtures can supply provider-aware cleanup and a version resolver: +Fixtures supply an independently constructed replica for cursor portability +checks and may also supply provider-aware cleanup and a version resolver: ```ts import { @@ -233,6 +259,7 @@ const options: StorageProviderConformanceOptions = { expected: verifiedCapabilities, createFixture: async () => ({ client: await createDedicatedTestClient(), + createReplica: createAnotherClientForTheSameStore, cleanup: deleteEveryTestObjectIdentity, resolveVersion: resolveCurrentProviderVersion, }), diff --git a/scripts/test-packed-core-consumer.mjs b/scripts/test-packed-core-consumer.mjs index 8d4297d..d7d9a8c 100644 --- a/scripts/test-packed-core-consumer.mjs +++ b/scripts/test-packed-core-consumer.mjs @@ -153,6 +153,11 @@ import { type StorageObjectMetadata, } from '@nestm/storage/core'; import { createS3StorageDriver } from '@nestm/storage/files-sdk/s3'; +import { + Aes256GcmStorageWorkspaceCursorCodec, + STORAGE_WORKSPACE_MAX_CURSOR_BYTES, + mountStorageWorkspace, +} from '@nestm/storage/workspace'; const capabilities = { cacheControl: true, @@ -181,98 +186,114 @@ function metadata(key: string): StorageObjectMetadata { }; } -const driver = { - capabilities, - name: 'packed-memory', - async upload(key, body, options) { - if (typeof body !== 'string') { - throw new TypeError('The smoke driver accepts string bodies only.'); - } - const stored = { - body: new TextEncoder().encode(body), - contentType: options?.contentType ?? 'application/octet-stream', - }; - objects.set(key, stored); - return { - contentType: stored.contentType, - key, - size: stored.body.byteLength, - }; - }, - async download(key) { - const object = metadata(key); - const stored = objects.get(key); - assert.ok(stored); - return { - ...object, - body: new ReadableStream({ - start(controller) { - controller.enqueue(stored.body); - controller.close(); - }, - }), - }; - }, - async head(key) { - return metadata(key); - }, - async exists(key) { - return objects.has(key); - }, - async delete(key) { - objects.delete(key); - }, - async copy(sourceKey, destinationKey) { - const source = objects.get(sourceKey); - if (source === undefined) { - throw new Error(\`Missing object: \${sourceKey}\`); - } - objects.set(destinationKey, { - body: source.body.slice(), - contentType: source.contentType, - }); - }, - async move(sourceKey, destinationKey) { - const source = objects.get(sourceKey); - if (source === undefined) { - throw new Error(\`Missing object: \${sourceKey}\`); - } - objects.set(destinationKey, source); - objects.delete(sourceKey); - }, - async list(options) { - return { - items: [...objects.keys()] - .filter((key) => key.startsWith(options?.prefix ?? '')) - .map(metadata), - }; - }, - async *search(pattern, options) { - const expression = - pattern instanceof RegExp ? pattern : new RegExp(pattern.replace('*', '.*')); - for (const key of objects.keys()) { - if (!key.startsWith(options?.prefix ?? '')) { - continue; +function createDriver(): StorageDriver { + return { + capabilities, + name: 'packed-memory', + async upload(key, body, options) { + if (typeof body !== 'string') { + throw new TypeError('The smoke driver accepts string bodies only.'); } + const stored = { + body: new TextEncoder().encode(body), + contentType: options?.contentType ?? 'application/octet-stream', + }; + objects.set(key, stored); + return { + contentType: stored.contentType, + key, + size: stored.body.byteLength, + }; + }, + async download(key) { const object = metadata(key); - if (expression.test(object.key)) { - yield object; + const stored = objects.get(key); + assert.ok(stored); + return { + ...object, + body: new ReadableStream({ + start(controller) { + controller.enqueue(stored.body); + controller.close(); + }, + }), + }; + }, + async head(key) { + return metadata(key); + }, + async exists(key) { + return objects.has(key); + }, + async delete(key) { + objects.delete(key); + }, + async copy(sourceKey, destinationKey) { + const source = objects.get(sourceKey); + if (source === undefined) { + throw new Error(\`Missing object: \${sourceKey}\`); } - } - }, - async signDownload(key) { - return \`https://storage.invalid/download/\${encodeURIComponent(key)}\`; - }, - async signUpload(key) { - return { - method: 'PUT', - url: \`https://storage.invalid/upload/\${encodeURIComponent(key)}\`, - }; - }, - async close() { - closeCalls += 1; - }, -} satisfies StorageDriver; + objects.set(destinationKey, { + body: source.body.slice(), + contentType: source.contentType, + }); + }, + async move(sourceKey, destinationKey) { + const source = objects.get(sourceKey); + if (source === undefined) { + throw new Error(\`Missing object: \${sourceKey}\`); + } + objects.set(destinationKey, source); + objects.delete(sourceKey); + }, + async list(options) { + const keys = [...objects.keys()] + .filter((key) => key.startsWith(options?.prefix ?? '')) + .toSorted(); + const startIndex = + options?.cursor === undefined + ? 0 + : keys.findIndex((key) => key > options.cursor!); + const start = startIndex < 0 ? keys.length : startIndex; + const limit = options?.limit ?? 1_000; + const selected = keys.slice(start, start + limit); + const lastKey = selected.at(-1); + return { + items: selected.map(metadata), + ...(lastKey !== undefined && start + selected.length < keys.length + ? { cursor: lastKey } + : {}), + }; + }, + async *search(pattern, options) { + const expression = + pattern instanceof RegExp ? pattern : new RegExp(pattern.replace('*', '.*')); + for (const key of objects.keys()) { + if (!key.startsWith(options?.prefix ?? '')) { + continue; + } + const object = metadata(key); + if (expression.test(object.key)) { + yield object; + } + } + }, + async signDownload(key) { + return \`https://storage.invalid/download/\${encodeURIComponent(key)}\`; + }, + async signUpload(key) { + return { + method: 'PUT', + url: \`https://storage.invalid/upload/\${encodeURIComponent(key)}\`, + }; + }, + async close() { + closeCalls += 1; + }, + } satisfies StorageDriver; +} + +const driver = createDriver(); let nestResolved = true; try { @@ -285,6 +306,17 @@ assert.equal(nestResolved, false); assert.equal(DEFAULT_BUFFER_LIMIT, 10 * 1024 * 1024); assert.equal(StorageErrorCode.NOT_FOUND, 'NOT_FOUND'); assert.equal(new StorageUploadControl().status, 'idle'); +assert.equal(STORAGE_WORKSPACE_MAX_CURSOR_BYTES, 4096); +const cursorCodec = new Aes256GcmStorageWorkspaceCursorCodec({ + activeKeyId: 'packed', + keys: { packed: new Uint8Array(32).fill(7) }, +}); +const cursorPayload = new TextEncoder().encode('provider-secret'); +const cursorToken = cursorCodec.encode(cursorPayload, { + expiresAt: Date.now() + 60_000, +}); +assert.match(cursorToken, /^swc1\\.packed\\./u); +assert.deepEqual(cursorCodec.decode(cursorToken), cursorPayload); const foreignStorageError = Object.assign(new Error('foreign'), { [Symbol.for('@nestm/storage/StorageError')]: true, aborted: false, @@ -347,9 +379,68 @@ const uploaded = await client.upload('hello.txt', 'hello core', { assert.equal(uploaded.key, 'hello.txt'); assert.equal(await client.downloadText('hello.txt'), 'hello core'); +const workspaceCursorConfiguration = () => ({ + codec: new Aes256GcmStorageWorkspaceCursorCodec({ + activeKeyId: 'packed-workspace', + keys: { 'packed-workspace': new Uint8Array(32).fill(8) }, + }), + mountId: 'packed-artifacts', + scope: 'organization:packed/workspace:packed', +}); +const workspaceClientA = new StorageClient('packed-workspace', createDriver()); +for (const name of ['a.txt', 'b.txt', 'c.txt']) { + await workspaceClientA.upload('scope/' + name, name); +} +const workspaceA = mountStorageWorkspace(workspaceClientA, { + cursor: workspaceCursorConfiguration(), + prefix: 'scope', +}); +const firstList = await workspaceA.list({ limit: 1, recursive: true }); +const firstListCursor = firstList.cursor; +assert.ok(firstListCursor); +const firstSearch = await workspaceA.search('*.txt', { limit: 1 }); +const firstSearchCursor = firstSearch.cursor; +assert.ok(firstSearchCursor); +await workspaceClientA.onApplicationShutdown(); + +const workspaceClientB = new StorageClient('packed-workspace', createDriver()); +const workspaceB = mountStorageWorkspace(workspaceClientB, { + cursor: workspaceCursorConfiguration(), + prefix: 'scope', +}); +const continuedList = await workspaceB.list({ cursor: firstListCursor }); +assert.ok(continuedList.cursor); +const continuedListDescendant = await workspaceB.list({ + cursor: continuedList.cursor, +}); +const replayedList = await workspaceB.list({ cursor: firstListCursor }); +assert.deepEqual(replayedList.entries, continuedList.entries); +assert.ok(replayedList.cursor); +assert.deepEqual( + (await workspaceB.list({ cursor: replayedList.cursor })).entries, + continuedListDescendant.entries, +); +const continuedSearch = await workspaceB.search('', { + cursor: firstSearchCursor, +}); +assert.ok(continuedSearch.cursor); +const continuedSearchDescendant = await workspaceB.search('', { + cursor: continuedSearch.cursor, +}); +const replayedSearch = await workspaceB.search('', { + cursor: firstSearchCursor, +}); +assert.deepEqual(replayedSearch.entries, continuedSearch.entries); +assert.ok(replayedSearch.cursor); +assert.deepEqual( + (await workspaceB.search('', { cursor: replayedSearch.cursor })).entries, + continuedSearchDescendant.entries, +); +await workspaceClientB.onApplicationShutdown(); + await client.onApplicationShutdown(); await client.onApplicationShutdown(); -assert.equal(closeCalls, 1); +assert.equal(closeCalls, 3); `; } diff --git a/src/ai-sdk/ai-sdk-workspace-tools.spec.ts b/src/ai-sdk/ai-sdk-workspace-tools.spec.ts index ee70c97..b7a8b4f 100644 --- a/src/ai-sdk/ai-sdk-workspace-tools.spec.ts +++ b/src/ai-sdk/ai-sdk-workspace-tools.spec.ts @@ -94,6 +94,7 @@ function createWorkspaceDouble( const workspace = { permissions: permissionSet, limits: { + maxCursorBytes: 4_096, maxPathBytes, maxReadBytes: 100, maxWriteBytes: 200, @@ -324,7 +325,7 @@ describe('createAiSdkWorkspaceTools', () => { const searchSchema = z.toJSONSchema( viewTool(tools, 'workspace_search').inputSchema, ); - const cursor = 'a'.repeat(32); + const cursor = `swc1.test.${'a'.repeat(32)}`; expect( viewTool(tools, 'workspace_list').inputSchema.safeParse({ cursor }) @@ -332,7 +333,12 @@ describe('createAiSdkWorkspaceTools', () => { ).toBe(true); expect( viewTool(tools, 'workspace_list').inputSchema.safeParse({ - cursor: 'a'.repeat(31), + cursor: '', + }).success, + ).toBe(false); + expect( + viewTool(tools, 'workspace_list').inputSchema.safeParse({ + cursor: 'a'.repeat(4_097), }).success, ).toBe(false); expect( @@ -346,7 +352,7 @@ describe('createAiSdkWorkspaceTools', () => { properties: { cursor: { description: - 'Opaque one-use cursor returned by the preceding list call. Repeat the same directory, recursive, and limit options when continuing.', + 'Opaque cursor returned by a preceding list call. It may be replayed before expiry while its provider continuation remains valid; repeat the same directory, recursive, and limit options when continuing.', }, }, }); @@ -354,7 +360,7 @@ describe('createAiSdkWorkspaceTools', () => { properties: { cursor: { description: - 'Opaque one-use cursor returned by the preceding search call. Repeat the same query, directory, match, caseInsensitive, and limit options when continuing.', + 'Opaque cursor returned by a preceding search call. It may be replayed before expiry while its provider continuation remains valid; repeat the same query, directory, match, caseInsensitive, and limit options when continuing.', }, }, }); diff --git a/src/ai-sdk/ai-sdk-workspace-tools.ts b/src/ai-sdk/ai-sdk-workspace-tools.ts index 8ee6865..36af38f 100644 --- a/src/ai-sdk/ai-sdk-workspace-tools.ts +++ b/src/ai-sdk/ai-sdk-workspace-tools.ts @@ -2,6 +2,7 @@ import { tool, type ToolSet } from 'ai'; import { z } from 'zod'; import { + STORAGE_WORKSPACE_MAX_CURSOR_BYTES, isStorageWorkspaceError, type StorageWorkspace, type StorageWorkspaceEntry, @@ -128,7 +129,7 @@ const mutationToolNames = new Set([ ]); const utf8Encoder = new TextEncoder(); const forbiddenUnicodeCharacter = /\p{C}/u; -const workspaceCursor = /^[A-Za-z0-9_-]{32}$/u; +const workspaceCursor = /^[A-Za-z0-9._-]+$/u; const windowsDeviceName = /^(?:con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\..*)?$/iu; function isLogicalPath(value: string): boolean { @@ -198,12 +199,14 @@ function boundedEtag() { ); } -function continuationCursor(description: string) { +function continuationCursor(description: string, maxBytes: number) { return z .string() + .min(1) + .max(Math.min(STORAGE_WORKSPACE_MAX_CURSOR_BYTES, maxBytes)) .regex( workspaceCursor, - 'Cursor must be the opaque base64url token returned by the workspace.', + 'Cursor must be the bounded opaque token returned by the workspace.', ) .optional() .describe(description); @@ -360,7 +363,8 @@ export function createAiSdkWorkspaceTools({ `Maximum entries to return, up to ${workspace.limits.maxPageSize}.`, ), cursor: continuationCursor( - 'Opaque one-use cursor returned by the preceding list call. Repeat the same directory, recursive, and limit options when continuing.', + 'Opaque cursor returned by a preceding list call. It may be replayed before expiry while its provider continuation remains valid; repeat the same directory, recursive, and limit options when continuing.', + workspace.limits.maxCursorBytes, ), }) .strict(), @@ -432,7 +436,8 @@ export function createAiSdkWorkspaceTools({ `Maximum matches to return, up to ${workspace.limits.maxSearchResults}.`, ), cursor: continuationCursor( - 'Opaque one-use cursor returned by the preceding search call. Repeat the same query, directory, match, caseInsensitive, and limit options when continuing.', + 'Opaque cursor returned by a preceding search call. It may be replayed before expiry while its provider continuation remains valid; repeat the same query, directory, match, caseInsensitive, and limit options when continuing.', + workspace.limits.maxCursorBytes, ), }) .strict(), diff --git a/src/storage.driver.ts b/src/storage.driver.ts index 130fe94..6cc6b07 100644 --- a/src/storage.driver.ts +++ b/src/storage.driver.ts @@ -70,6 +70,25 @@ export interface StorageDriver { destinationKey: string, options: StoragePromotionOptions, ): Promise; + /** + * Lists one page and, when more entries remain, returns an opaque cursor for + * the next logical position. Cursors are bound to the request's `prefix` and + * `delimiter`; callers may choose a different supported `limit` when they + * resume. + * + * A cursor is non-consuming. Reusing it, including after following any + * descendant cursor, must resume from the same logical position while the + * provider namespace is unchanged. It must also work with an independently + * constructed compatible driver and client that address the same store with + * the same backend configuration while the provider token remains valid and + * available. Drivers must not make cursor state local to one process, client, + * or session. + * + * This contract does not provide snapshot isolation: concurrent namespace + * mutations may change subsequent pages. It does not guarantee provider + * token lifetime or backend availability, and provider invalidation remains + * an ordinary list-operation failure. + */ list(options?: StorageListOptions): Promise; search( pattern: string | RegExp, diff --git a/src/storage.types.ts b/src/storage.types.ts index 3edd77d..15659a0 100644 --- a/src/storage.types.ts +++ b/src/storage.types.ts @@ -142,9 +142,13 @@ export interface StorageBufferedDownloadOptions extends StorageDownloadOptions { export interface StorageListOptions extends StorageOperationOptions { prefix?: string; /** - * Opaque, non-consuming continuation token returned by `list`. Replaying a - * cursor with the same options against unchanged provider state must return - * an equivalent page and continuation cursor. + * Opaque, non-consuming continuation token returned by `list`. + * + * The token is bound to the original `prefix` and `delimiter`, but callers + * may change `limit` when resuming. While the provider token remains valid + * and available, replaying it against unchanged provider state must return + * the same logical position; token bytes returned for the following position + * need not be stable. */ cursor?: string; limit?: number; @@ -154,7 +158,10 @@ export interface StorageListOptions extends StorageOperationOptions { export interface StorageListResult { items: StorageObjectMetadata[]; prefixes?: string[]; - /** Opaque replayable continuation token for the next page, when present. */ + /** + * Opaque, replayable continuation token for the next logical position. + * See `StorageDriver.list` for the complete portability contract. + */ cursor?: string; } diff --git a/src/testing/provider-conformance.spec.ts b/src/testing/provider-conformance.spec.ts index f3b183f..e60e3c4 100644 --- a/src/testing/provider-conformance.spec.ts +++ b/src/testing/provider-conformance.spec.ts @@ -10,6 +10,8 @@ import type { StorageDriver } from '../storage.driver.js'; import type { StorageBody, StorageCapabilities, + StorageListOptions, + StorageListResult, StoragePromotionOptions, StorageUploadOptions, } from '../storage.types.js'; @@ -116,13 +118,17 @@ describe('createStorageProviderConformanceCases', () => { const root = await mkdtemp( join(tmpdir(), 'nestm-restricted-conformance-'), ); - const driver = withoutConditionalCapabilities( - createFsStorageDriver({ adapter: { root } }), - ); + const createDriver = () => + withoutConditionalCapabilities( + createFsStorageDriver({ adapter: { root } }), + ); + const driver = createDriver(); const observed = observeDispatches(driver); return { client: new StorageClient('restricted-filesystem', observed.driver), close: () => rm(root, { force: true, recursive: true }), + createReplica: () => + new StorageClient('restricted-filesystem', createDriver()), dispatchCount: observed.dispatchCount, }; }, @@ -134,6 +140,63 @@ describe('createStorageProviderConformanceCases', () => { }); } }); + + it('fails cursor conformance for a deliberately one-shot custom driver', async () => { + const contracts = createStorageProviderConformanceCases({ + provider: 'one-shot-filesystem', + expected: { physicalKey: { maxBytes: 4096 } }, + async createFixture() { + const root = await mkdtemp(join(tmpdir(), 'nestm-one-shot-cursor-')); + const consumed = new Set(); + const createDriver = () => + withOneShotListCursors( + createFsStorageDriver({ adapter: { root } }), + consumed, + ); + return { + client: new StorageClient('one-shot-filesystem', createDriver()), + close: () => rm(root, { force: true, recursive: true }), + createReplica: () => + new StorageClient('one-shot-filesystem', createDriver()), + }; + }, + }); + const cursorContract = contracts.find(({ name }) => + name.startsWith('replays portable list cursors'), + ); + expect(cursorContract).toBeDefined(); + if (cursorContract === undefined) return; + + await expect(cursorContract.run()).rejects.toMatchObject({ + code: StorageErrorCode.INVALID_ARGUMENT, + message: 'The deliberately one-shot cursor was reused.', + }); + }); + + it('replays cursors without requesting unsupported delimiter behavior', async () => { + const contracts = createStorageProviderConformanceCases({ + provider: 'flat-filesystem', + expected: { physicalKey: { maxBytes: 4096 } }, + async createFixture() { + const root = await mkdtemp(join(tmpdir(), 'nestm-flat-cursor-')); + const createDriver = () => + withoutDelimiterSupport(createFsStorageDriver({ adapter: { root } })); + return { + client: new StorageClient('flat-filesystem', createDriver()), + close: () => rm(root, { force: true, recursive: true }), + createReplica: () => + new StorageClient('flat-filesystem', createDriver()), + }; + }, + }); + const cursorContract = contracts.find(({ name }) => + name.startsWith('replays portable list cursors'), + ); + expect(cursorContract).toBeDefined(); + if (cursorContract === undefined) return; + + await expect(cursorContract.run()).resolves.toEqual({ status: 'passed' }); + }); }); function fullCopyCapabilities(atomicWithSource: boolean) { @@ -152,92 +215,163 @@ async function createVersionedFixture( atomicWithSource: boolean, ): Promise { const root = await mkdtemp(join(tmpdir(), 'nestm-versioned-conformance-')); - const base = createFsStorageDriver({ adapter: { root } }); const versions = new Map>(); const currentVersions = new Map(); - const upload = base.upload.bind(base); - const promote = base.promote?.bind(base); - const capabilities: StorageCapabilities = { - ...base.capabilities, - conditionalCopyDestination: { - atomicWithSource, - create: true, - replace: true, - }, - conditionalCopySource: { etag: true, version: true }, + const createDriver = (): StorageDriver => { + const base = createFsStorageDriver({ adapter: { root } }); + const upload = base.upload.bind(base); + const promote = base.promote?.bind(base); + const capabilities: StorageCapabilities = { + ...base.capabilities, + conditionalCopyDestination: { + atomicWithSource, + create: true, + replace: true, + }, + conditionalCopySource: { etag: true, version: true }, + }; + + return new Proxy(base, { + get(target, property) { + if (property === 'capabilities') return capabilities; + if (property === 'upload') { + return async ( + key: string, + body: StorageBody, + options?: StorageUploadOptions, + ) => { + if (typeof body !== 'string') { + throw new TypeError( + 'Fake versioned fixture accepts string bodies.', + ); + } + const result = await upload(key, body, options); + const version = randomUUID(); + const keyVersions = versions.get(key) ?? new Map(); + keyVersions.set(version, body); + versions.set(key, keyVersions); + currentVersions.set(key, version); + return result; + }; + } + if (property === 'promote') { + return async ( + sourceKey: string, + destinationKey: string, + options: StoragePromotionOptions, + ): Promise => { + if (options.sourceVersion === undefined) { + if (promote === undefined) { + throw new TypeError('Filesystem promotion is unavailable.'); + } + return promote(sourceKey, destinationKey, options); + } + const body = versions.get(sourceKey)?.get(options.sourceVersion); + if (body === undefined) { + throw new StorageError('Fake provider version was not found.', { + code: StorageErrorCode.NOT_FOUND, + permanent: true, + }); + } + const destination = options.destination; + if (destination === undefined) { + throw new StorageError('Destination condition is required.', { + code: StorageErrorCode.INVALID_ARGUMENT, + permanent: true, + }); + } + if (destination.type === 'create') { + await base.uploadConditional?.(destinationKey, body, { + condition: { type: 'create' }, + }); + } else { + await base.uploadConditional?.(destinationKey, body, { + condition: { etag: destination.etag, type: 'replace' }, + }); + } + }; + } + const value = Reflect.get(target, property, target) as unknown; + return typeof value === 'function' ? value.bind(target) : value; + }, + }); }; - const versioned = new Proxy(base, { + const versioned = createDriver(); + const observed = observeDispatches(versioned); + return { + client: new StorageClient('fake-versioned', observed.driver), + close: () => rm(root, { force: true, recursive: true }), + createReplica: () => new StorageClient('fake-versioned', createDriver()), + dispatchCount: observed.dispatchCount, + resolveVersion: async (key) => currentVersions.get(key), + }; +} + +function withOneShotListCursors( + driver: StorageDriver, + consumed: Set, +): StorageDriver { + const list = driver.list.bind(driver); + return new Proxy(driver, { get(target, property) { - if (property === 'capabilities') return capabilities; - if (property === 'upload') { + if (property === 'list') { return async ( - key: string, - body: StorageBody, - options?: StorageUploadOptions, - ) => { - if (typeof body !== 'string') { - throw new TypeError( - 'Fake versioned fixture accepts string bodies.', - ); + options?: StorageListOptions, + ): Promise => { + const cursor = options?.cursor; + if (cursor !== undefined) { + if (consumed.has(cursor)) { + throw new StorageError( + 'The deliberately one-shot cursor was reused.', + { + code: StorageErrorCode.INVALID_ARGUMENT, + operation: 'list', + permanent: true, + }, + ); + } + consumed.add(cursor); } - const result = await upload(key, body, options); - const version = randomUUID(); - const keyVersions = versions.get(key) ?? new Map(); - keyVersions.set(version, body); - versions.set(key, keyVersions); - currentVersions.set(key, version); - return result; + return list(options); }; } - if (property === 'promote') { + const value = Reflect.get(target, property, target) as unknown; + return typeof value === 'function' ? value.bind(target) : value; + }, + }); +} + +function withoutDelimiterSupport(driver: StorageDriver): StorageDriver { + const capabilities: StorageCapabilities = { + ...driver.capabilities, + delimiter: false, + }; + const list = driver.list.bind(driver); + return new Proxy(driver, { + get(target, property) { + if (property === 'capabilities') return capabilities; + if (property === 'list') { return async ( - sourceKey: string, - destinationKey: string, - options: StoragePromotionOptions, - ): Promise => { - if (options.sourceVersion === undefined) { - if (promote === undefined) { - throw new TypeError('Filesystem promotion is unavailable.'); - } - return promote(sourceKey, destinationKey, options); - } - const body = versions.get(sourceKey)?.get(options.sourceVersion); - if (body === undefined) { - throw new StorageError('Fake provider version was not found.', { - code: StorageErrorCode.NOT_FOUND, - permanent: true, - }); - } - const destination = options.destination; - if (destination === undefined) { - throw new StorageError('Destination condition is required.', { - code: StorageErrorCode.INVALID_ARGUMENT, - permanent: true, - }); - } - if (destination.type === 'create') { - await base.uploadConditional?.(destinationKey, body, { - condition: { type: 'create' }, - }); - } else { - await base.uploadConditional?.(destinationKey, body, { - condition: { etag: destination.etag, type: 'replace' }, - }); + options?: StorageListOptions, + ): Promise => { + if (options?.delimiter !== undefined) { + throw new StorageError( + 'The flat test driver does not support delimiters.', + { + code: StorageErrorCode.NOT_SUPPORTED, + operation: 'list', + permanent: true, + }, + ); } + return list(options); }; } const value = Reflect.get(target, property, target) as unknown; return typeof value === 'function' ? value.bind(target) : value; }, }); - const observed = observeDispatches(versioned); - return { - client: new StorageClient('fake-versioned', observed.driver), - close: () => rm(root, { force: true, recursive: true }), - dispatchCount: observed.dispatchCount, - resolveVersion: async (key) => currentVersions.get(key), - }; } function withoutConditionalCapabilities(driver: StorageDriver): StorageDriver { diff --git a/src/testing/provider-conformance.ts b/src/testing/provider-conformance.ts index 361cecd..d1a350a 100644 --- a/src/testing/provider-conformance.ts +++ b/src/testing/provider-conformance.ts @@ -50,6 +50,11 @@ export interface StorageProviderConformanceCapabilities { export interface StorageProviderConformanceFixture { readonly client: StorageClient; + /** + * Builds a new client and driver for the same logical store and backend + * configuration. The harness owns the returned client and shuts it down. + */ + readonly createReplica: () => StorageClient | Promise; /** * Returns the number of operations dispatched to the provider driver. When * supplied, the contract proves invalid inputs fail before provider I/O. @@ -91,6 +96,7 @@ export interface StorageProviderConformanceCase { interface CaseContext { readonly client: StorageClient; readonly dispatchCount?: () => number; + createReplica(): Promise; key(label: string): string; resolveVersion(key: string): Promise; track(key: string): string; @@ -195,47 +201,151 @@ function listCursorReplayCase( ): StorageProviderConformanceCase { return providerCase( options, - 'replays list cursors without consuming or changing the page', + 'replays portable list cursors after descendants and limit changes', async (context) => { - const firstKey = context.key('cursor-replay/01.txt'); - const prefix = firstKey.slice(0, -'01.txt'.length); - await seed(context, firstKey, '01.txt'); - for (const name of ['02.txt', '03.txt']) { - await seed(context, context.track(`${prefix}${name}`), name); + await verifyCursorReplay(context, 'cursor-replay-flat', [ + '01.txt', + '02.txt', + '03.txt', + '04.txt', + '05.txt', + ]); + if (context.client.capabilities.delimiter) { + await verifyCursorReplay( + context, + 'cursor-replay-delimited', + [ + '01.txt', + '02-dir/inside.txt', + '03.txt', + '04-dir/inside.txt', + '05.txt', + ], + '/', + ); } + }, + ); +} - const request = { limit: 1, prefix } as const; - const first = await context.client.list(request); - equal(first.items.length, 1, 'provider ignored the list page limit'); - ok( - first.cursor !== undefined && first.cursor.length > 0, - 'provider did not return a continuation cursor for a partial page', - ); +async function verifyCursorReplay( + context: CaseContext, + label: string, + names: readonly [string, string, string, string, string], + delimiter?: string, +): Promise { + const [firstName, ...remainingNames] = names; + const firstKey = context.key(`${label}/${firstName}`); + const prefix = firstKey.slice(0, -firstName.length); + await seed(context, firstKey, firstName); + for (const name of remainingNames) { + await seed(context, context.track(`${prefix}${name}`), name); + } - const replayRequest = { ...request, cursor: first.cursor }; - const expected = await context.client.list(replayRequest); - equal(expected.items.length, 1, 'provider ignored the replay page limit'); - ok( - expected.cursor !== undefined && expected.cursor.length > 0, - 'provider did not return a continuation cursor before the final page', - ); + const request: CursorReplayRequest = { + ...(delimiter === undefined ? {} : { delimiter }), + limit: 1, + prefix, + }; + const first = await context.client.list(request); + assertPageSize(first, 1); + const cursor = requiredCursor( + first, + 'provider omitted a continuation cursor while matching entries remained', + ); - // Advance beyond the page before replaying its input token. A consuming - // cursor implementation will now fail or return a different page. - await context.client.list({ ...request, cursor: expected.cursor }); - const replayed = await context.client.list(replayRequest); - deepStrictEqual( - comparableListPage(replayed), - comparableListPage(expected), - 'reusing the same provider cursor changed its page or continuation state', - ); - }, + const replayRequest = { ...request, cursor }; + const expected = await context.client.list(replayRequest); + assertPageSize(expected, 1); + const descendantCursor = requiredCursor( + expected, + 'provider omitted a descendant cursor while matching entries remained', + ); + + // Follow a cursor derived from the input before replaying its ancestor. A + // consuming cursor implementation will now fail or change the page. + const descendant = await context.client.list({ + ...request, + cursor: descendantCursor, + }); + assertPageSize(descendant, 1); + const replayed = await context.client.list(replayRequest); + assertEquivalentListPage( + replayed, + expected, + 'reusing a provider cursor after its descendant changed the page', + ); + await assertEquivalentNextPage( + context.client, + replayed, + descendant, + request, + 'replayed cursor returned a continuation for a different position', + ); + + const wider = await context.client.list({ + ...replayRequest, + limit: 2, + }); + assertPageSize(wider, 2); + deepStrictEqual( + logicalEntryIds(wider), + [...logicalEntryIds(expected), ...logicalEntryIds(descendant)].sort(), + 'changing the page limit changed the cursor starting position', + ); + const afterWider = await context.client.list({ + ...request, + cursor: requiredCursor( + descendant, + 'provider ended before the wider-page continuation could be verified', + ), + }); + await assertEquivalentNextPage( + context.client, + wider, + afterWider, + request, + 'changed-limit cursor returned a continuation for a different position', + ); + + const replica = await context.createReplica(); + const replicaReplay = await replica.list(replayRequest); + assertEquivalentListPage( + replicaReplay, + expected, + 'an independently constructed replica resumed at a different page', + ); + await assertEquivalentNextPage( + replica, + replicaReplay, + descendant, + request, + 'replica cursor returned a continuation for a different position', + ); + + const replicaWider = await replica.list({ ...replayRequest, limit: 2 }); + assertEquivalentListPage( + replicaWider, + wider, + 'a replica changed the page returned with a different limit', ); + await assertEquivalentNextPage( + replica, + replicaWider, + afterWider, + request, + 'replica changed-limit cursor returned a continuation for a different position', + ); +} + +interface CursorReplayRequest { + readonly delimiter?: string; + readonly limit: number; + readonly prefix: string; } function comparableListPage(page: StorageListResult): unknown { return { - cursor: page.cursor, items: page.items.map((item) => ({ contentType: item.contentType, etag: item.etag, @@ -249,6 +359,58 @@ function comparableListPage(page: StorageListResult): unknown { }; } +function assertEquivalentListPage( + actual: StorageListResult, + expected: StorageListResult, + message: string, +): void { + deepStrictEqual( + comparableListPage(actual), + comparableListPage(expected), + message, + ); + equal( + actual.cursor === undefined, + expected.cursor === undefined, + `${message}: continuation presence differed`, + ); +} + +async function assertEquivalentNextPage( + client: StorageClient, + actual: StorageListResult, + expectedNext: StorageListResult, + request: CursorReplayRequest, + message: string, +): Promise { + const cursor = requiredCursor( + actual, + `${message}: provider did not return a continuation cursor`, + ); + const actualNext = await client.list({ ...request, cursor }); + assertEquivalentListPage(actualNext, expectedNext, message); +} + +function assertPageSize(page: StorageListResult, expected: number): void { + equal( + page.items.length + (page.prefixes?.length ?? 0), + expected, + 'provider ignored the list page limit across items and common prefixes', + ); +} + +function requiredCursor(page: StorageListResult, message: string): string { + ok(page.cursor !== undefined && page.cursor.length > 0, message); + return page.cursor; +} + +function logicalEntryIds(page: StorageListResult): string[] { + return [ + ...page.items.map((item) => `item:${item.key}`), + ...(page.prefixes ?? []).map((prefix) => `prefix:${prefix}`), + ].sort(); +} + function invalidPreconditionEtagCase( options: StorageProviderConformanceOptions, ): StorageProviderConformanceCase { @@ -1165,12 +1327,27 @@ async function withFixture( ): Promise { const fixture = await options.createFixture(); const keys = new Set(); + const replicas: StorageClient[] = []; const namespace = `nestm-conformance/${safeSegment(options.provider)}/${randomUUID()}`; const context: CaseContext = { client: fixture.client, ...(fixture.dispatchCount === undefined ? {} : { dispatchCount: fixture.dispatchCount }), + async createReplica() { + const replica = await fixture.createReplica(); + ok( + replica !== fixture.client && !replicas.includes(replica), + 'createReplica must return a fresh StorageClient instance', + ); + replicas.push(replica); + equal( + replica.name, + fixture.client.name, + 'createReplica must address the same logical store name', + ); + return replica; + }, key(label) { const key = `${namespace}/${label}`; keys.add(key); @@ -1204,6 +1381,13 @@ async function withFixture( } catch (error: unknown) { cleanupError = error; } + for (const replica of replicas.reverse()) { + try { + await replica.onApplicationShutdown(); + } catch (error: unknown) { + if (cleanupError === noError) cleanupError = error; + } + } try { await fixture.client.onApplicationShutdown(); } catch (error: unknown) { diff --git a/src/workspace/index.ts b/src/workspace/index.ts index 02d594f..7a1a770 100644 --- a/src/workspace/index.ts +++ b/src/workspace/index.ts @@ -2,6 +2,15 @@ export { StorageWorkspaceError, isStorageWorkspaceError, } from './storage-workspace.error.js'; +export { + Aes256GcmStorageWorkspaceCursorCodec, + STORAGE_WORKSPACE_CURSOR_VERSION, + STORAGE_WORKSPACE_MAX_CURSOR_BYTES, + type Aes256GcmStorageWorkspaceCursorCodecOptions, + type StorageWorkspaceCursorCodec, + type StorageWorkspaceCursorConfiguration, + type StorageWorkspaceCursorEncodeOptions, +} from './storage-workspace.cursor.js'; export { createStorageWorkspace, mountStorageWorkspace, diff --git a/src/workspace/storage-workspace.cursor.spec.ts b/src/workspace/storage-workspace.cursor.spec.ts new file mode 100644 index 0000000..4b9f6c6 --- /dev/null +++ b/src/workspace/storage-workspace.cursor.spec.ts @@ -0,0 +1,100 @@ +import { StorageErrorCode } from '../storage.error.js'; + +import { + Aes256GcmStorageWorkspaceCursorCodec, + STORAGE_WORKSPACE_MAX_CURSOR_BYTES, +} from './storage-workspace.cursor.js'; + +const OLD_KEY = new Uint8Array(32).fill(0x11); +const NEW_KEY = new Uint8Array(32).fill(0x22); +const expiresAt = new Date('2026-08-14T12:00:00.000Z').getTime(); + +function codec( + activeKeyId = 'current', + keys: Readonly> = { current: OLD_KEY }, +): Aes256GcmStorageWorkspaceCursorCodec { + return new Aes256GcmStorageWorkspaceCursorCodec({ activeKeyId, keys }); +} + +describe('Aes256GcmStorageWorkspaceCursorCodec', () => { + it('round-trips opaque payloads across independently constructed replicas', () => { + const payload = new TextEncoder().encode( + JSON.stringify({ + providerCursor: 'provider-secret', + prefix: 'private/root', + }), + ); + const first = codec(); + const second = codec(); + + const token = first.encode(payload, { expiresAt }); + + expect(token).toMatch(/^swc1\.current\.[A-Za-z0-9_-]+$/u); + expect(token.length).toBeLessThanOrEqual( + STORAGE_WORKSPACE_MAX_CURSOR_BYTES, + ); + expect(token).not.toContain('provider-secret'); + expect(token).not.toContain('private/root'); + expect(second.decode(token)).toEqual(payload); + }); + + it('supports key rotation while issuing only with the active key', () => { + const payload = new TextEncoder().encode('continuation'); + const oldCodec = codec('old', { old: OLD_KEY }); + const oldToken = oldCodec.encode(payload, { expiresAt }); + const rotating = codec('new', { new: NEW_KEY, old: OLD_KEY }); + + expect(rotating.decode(oldToken)).toEqual(payload); + const newToken = rotating.encode(payload, { expiresAt }); + expect(newToken).toMatch(/^swc1\.new\./u); + expect(rotating.decode(newToken)).toEqual(payload); + expect(() => codec('new', { new: NEW_KEY }).decode(oldToken)).toThrow( + expect.objectContaining({ code: StorageErrorCode.INVALID_ARGUMENT }), + ); + }); + + it.each([ + '', + 'swc0.current.payload', + 'swc1.unknown.payload', + 'swc1.current.not+base64url', + `swc1.current.${'a'.repeat(STORAGE_WORKSPACE_MAX_CURSOR_BYTES)}`, + ])('rejects malformed token %j', (token) => { + expect(() => codec().decode(token)).toThrow( + expect.objectContaining({ code: StorageErrorCode.INVALID_ARGUMENT }), + ); + }); + + it('rejects ciphertext and authentication-tag tampering', () => { + const instance = codec(); + const token = instance.encode(new TextEncoder().encode('payload'), { + expiresAt, + }); + const final = token.at(-1); + const tampered = `${token.slice(0, -1)}${final === 'A' ? 'B' : 'A'}`; + + expect(() => instance.decode(tampered)).toThrow( + expect.objectContaining({ code: StorageErrorCode.INVALID_ARGUMENT }), + ); + }); + + it('rejects invalid key rings and oversized payloads', () => { + expect( + () => + new Aes256GcmStorageWorkspaceCursorCodec({ + activeKeyId: 'missing', + keys: { current: OLD_KEY }, + }), + ).toThrow(/activeKeyId/u); + expect( + () => + new Aes256GcmStorageWorkspaceCursorCodec({ + activeKeyId: 'short', + keys: { short: new Uint8Array(31) }, + }), + ).toThrow(/exactly 32 bytes/u); + expect(() => codec().encode(new Uint8Array(3_000), { expiresAt })).toThrow( + expect.objectContaining({ code: StorageErrorCode.LIMIT_EXCEEDED }), + ); + }); +}); diff --git a/src/workspace/storage-workspace.cursor.ts b/src/workspace/storage-workspace.cursor.ts index b52839d..008fcca 100644 --- a/src/workspace/storage-workspace.cursor.ts +++ b/src/workspace/storage-workspace.cursor.ts @@ -1,71 +1,686 @@ -import { randomBytes } from 'node:crypto'; +import { + createCipheriv, + createDecipheriv, + createHash, + randomBytes, +} from 'node:crypto'; -import { StorageErrorCode } from '../storage.error.js'; +import { StorageErrorCode, isStorageError } from '../storage.error.js'; import { workspaceError } from './storage-workspace.error.js'; +import type { + StorageWorkspaceLimits, + StorageWorkspaceSearchMatch, +} from './storage-workspace.types.js'; -interface CursorRecord { - readonly binding: string; +export const STORAGE_WORKSPACE_CURSOR_VERSION = 1 as const; +export const STORAGE_WORKSPACE_MAX_CURSOR_BYTES = 4_096; + +const AES_KEY_BYTES = 32; +const AES_GCM_IV_BYTES = 12; +const AES_GCM_TAG_BYTES = 16; +const MAX_CURSOR_KEYS = 32; +const MAX_CURSOR_KEY_ID_BYTES = 64; +const MAX_CURSOR_PAYLOAD_BYTES = 2_990; +const MAX_CURSOR_SCOPE_BYTES = 1_024; +const TOKEN_VERSION = 'swc1'; +const TOKEN_PART_PATTERN = /^[A-Za-z0-9_-]+$/u; +const TOKEN_PATTERN = /^[A-Za-z0-9._-]+$/u; +const BINDING_PATTERN = /^[A-Za-z0-9_-]{43}$/u; +const forbiddenIdentityCharacter = /\p{C}/u; +const encoder = new TextEncoder(); +const decoder = new TextDecoder('utf-8', { fatal: true }); + +export interface StorageWorkspaceCursorEncodeOptions { + /** Absolute Unix timestamp in milliseconds used by durable stores for TTL. */ readonly expiresAt: number; - readonly state: State; } -const MAX_ACTIVE_CURSORS = 1024; +/** + * Server-side opaque cursor contract. Implementations must either authenticate + * and encrypt payloads or keep them behind a shared durable random token. + * Tokens use only `[A-Za-z0-9._-]`, are at most 4,096 bytes, and `decode` must + * be non-destructive so the same token remains replayable until its authenticated + * expiry. Durable stores must expire records at `options.expiresAt` and read + * rather than consume them. Missing or expired records should fail with + * `StorageErrorCode.NOT_FOUND` or `StorageErrorCode.INVALID_ARGUMENT`; + * availability failures retain their operational storage error code. + */ +export interface StorageWorkspaceCursorCodec { + encode( + payload: Uint8Array, + options: StorageWorkspaceCursorEncodeOptions, + ): Promise | string; + decode(token: string): Promise | Uint8Array; +} + +export interface StorageWorkspaceCursorConfiguration { + /** Codec shared by every replica that serves this logical mount. */ + readonly codec: StorageWorkspaceCursorCodec; + /** Stable server-derived mount or binding identity. */ + readonly mountId: string; + /** Stable trusted tenant/workspace scope. */ + readonly scope: string; +} + +export interface Aes256GcmStorageWorkspaceCursorCodecOptions { + /** Key used for newly issued cursors. */ + readonly activeKeyId: string; + /** Decryption key ring. Retain rotated keys for at least one cursor TTL. */ + readonly keys: Readonly>; +} -export class StorageWorkspaceCursorStore { - readonly #records = new Map>(); +/** Stateless AES-256-GCM cursor codec suitable for shared multi-replica use. */ +export class Aes256GcmStorageWorkspaceCursorCodec implements StorageWorkspaceCursorCodec { + readonly #activeKeyId: string; + readonly #keys: ReadonlyMap; - issue(binding: string, state: State, ttlMs: number): string { - this.#prune(); - if (this.#records.size >= MAX_ACTIVE_CURSORS) { + constructor(options: Aes256GcmStorageWorkspaceCursorCodecOptions) { + if ( + typeof options !== 'object' || + options === null || + !validKeyId(options.activeKeyId) + ) { + throw new TypeError( + 'Workspace cursor activeKeyId must be a 1-64 byte base64url identifier.', + ); + } + if ( + typeof options.keys !== 'object' || + options.keys === null || + Array.isArray(options.keys) + ) { + throw new TypeError('Workspace cursor keys must be a key-id record.'); + } + const entries = Object.entries(options.keys); + if (entries.length === 0 || entries.length > MAX_CURSOR_KEYS) { + throw new TypeError( + `Workspace cursor key ring must contain 1-${MAX_CURSOR_KEYS} keys.`, + ); + } + const keys = new Map(); + for (const [keyId, key] of entries) { + if (!validKeyId(keyId)) { + throw new TypeError( + 'Workspace cursor key IDs must be 1-64 byte base64url identifiers.', + ); + } + if (!(key instanceof Uint8Array) || key.byteLength !== AES_KEY_BYTES) { + throw new TypeError( + `Workspace cursor key "${keyId}" must contain exactly ${AES_KEY_BYTES} bytes.`, + ); + } + keys.set(keyId, Buffer.from(key)); + } + if (!keys.has(options.activeKeyId)) { + throw new TypeError( + 'Workspace cursor activeKeyId must identify a configured key.', + ); + } + this.#activeKeyId = options.activeKeyId; + this.#keys = keys; + } + + encode( + payload: Uint8Array, + options: StorageWorkspaceCursorEncodeOptions, + ): string { + if (!(payload instanceof Uint8Array) || payload.byteLength === 0) { + throw new TypeError('Workspace cursor payload must not be empty.'); + } + if (!Number.isSafeInteger(options.expiresAt) || options.expiresAt <= 0) { + throw new TypeError( + 'Workspace cursor expiry must be a positive safe integer.', + ); + } + if (payload.byteLength > MAX_CURSOR_PAYLOAD_BYTES) { throw workspaceError( StorageErrorCode.LIMIT_EXCEEDED, - 'Workspace has too many active cursors.', + 'Workspace cursor payload exceeds the bounded token size.', { permanent: true }, ); } - let cursor: string; - do { - cursor = randomBytes(24).toString('base64url'); - } while (this.#records.has(cursor)); - this.#records.set(cursor, { - binding, - expiresAt: Date.now() + ttlMs, - state, - }); - return cursor; - } - consume(cursor: string, binding: string): State { - if (!/^[A-Za-z0-9_-]{32}$/u.test(cursor)) { + const key = this.#keys.get(this.#activeKeyId); + if (key === undefined) { + throw new TypeError('Workspace cursor active key is unavailable.'); + } + const iv = randomBytes(AES_GCM_IV_BYTES); + const cipher = createCipheriv('aes-256-gcm', key, iv, { + authTagLength: AES_GCM_TAG_BYTES, + }); + cipher.setAAD(cursorAad(this.#activeKeyId)); + const ciphertext = Buffer.concat([cipher.update(payload), cipher.final()]); + const body = Buffer.concat([iv, ciphertext, cipher.getAuthTag()]); + const token = `${TOKEN_VERSION}.${this.#activeKeyId}.${body.toString('base64url')}`; + if (token.length > STORAGE_WORKSPACE_MAX_CURSOR_BYTES) { throw workspaceError( - StorageErrorCode.INVALID_ARGUMENT, - 'Workspace cursor has an invalid format.', + StorageErrorCode.LIMIT_EXCEEDED, + 'Workspace cursor exceeds the bounded token size.', { permanent: true }, ); } - const record = this.#records.get(cursor); - this.#records.delete(cursor); + return token; + } + + decode(token: string): Uint8Array { if ( - record === undefined || - record.expiresAt <= Date.now() || - record.binding !== binding + typeof token !== 'string' || + token.length === 0 || + token.length > STORAGE_WORKSPACE_MAX_CURSOR_BYTES || + !TOKEN_PATTERN.test(token) ) { - throw workspaceError( - StorageErrorCode.INVALID_ARGUMENT, - 'Workspace cursor is invalid, expired, or belongs to another query.', - { permanent: true }, + throw invalidCursor(); + } + const parts = token.split('.'); + const version = parts[0]; + const keyId = parts[1]; + const encodedBody = parts[2]; + if ( + parts.length !== 3 || + version !== TOKEN_VERSION || + keyId === undefined || + encodedBody === undefined || + !validKeyId(keyId) || + !TOKEN_PART_PATTERN.test(encodedBody) + ) { + throw invalidCursor(); + } + const key = this.#keys.get(keyId); + if (key === undefined) { + throw invalidCursor(); + } + const body = Buffer.from(encodedBody, 'base64url'); + if ( + body.toString('base64url') !== encodedBody || + body.byteLength <= AES_GCM_IV_BYTES + AES_GCM_TAG_BYTES + ) { + throw invalidCursor(); + } + const iv = body.subarray(0, AES_GCM_IV_BYTES); + const ciphertext = body.subarray( + AES_GCM_IV_BYTES, + body.byteLength - AES_GCM_TAG_BYTES, + ); + const tag = body.subarray(body.byteLength - AES_GCM_TAG_BYTES); + try { + const decipher = createDecipheriv('aes-256-gcm', key, iv, { + authTagLength: AES_GCM_TAG_BYTES, + }); + decipher.setAAD(cursorAad(keyId)); + decipher.setAuthTag(tag); + return Uint8Array.from( + Buffer.concat([decipher.update(ciphertext), decipher.final()]), ); + } catch { + throw invalidCursor(); } - return record.state as State; + } +} + +interface StorageWorkspaceCursorBinding { + readonly limits: Readonly; + readonly prefix: string; + readonly store: string; +} + +export interface StorageWorkspaceListCursorData { + readonly backendCursor: string; + readonly directory: string; + readonly limit: number; + readonly operation: 'list'; + readonly recursive: boolean; +} + +export interface StorageWorkspaceSearchCursorData { + readonly backendCursor: string; + readonly caseInsensitive: boolean; + readonly directory: string; + readonly limit: number; + readonly match: StorageWorkspaceSearchMatch; + readonly operation: 'search'; + readonly query: string; + readonly scanned: number; +} + +export type StorageWorkspaceCursorData = + StorageWorkspaceListCursorData | StorageWorkspaceSearchCursorData; + +interface WireCursorCommon { + readonly b: string; + readonly e: number; + readonly v: typeof STORAGE_WORKSPACE_CURSOR_VERSION; +} + +interface WireListCursor extends WireCursorCommon { + readonly o: 'list'; + readonly q: { + readonly d: string; + readonly l: number; + readonly r: boolean; + }; + readonly s: { readonly c: string }; +} + +interface WireSearchCursor extends WireCursorCommon { + readonly o: 'search'; + readonly q: { + readonly c: boolean; + readonly d: string; + readonly l: number; + readonly m: StorageWorkspaceSearchMatch; + readonly q: string; + }; + readonly s: { + readonly c: string; + readonly n: number; + }; +} + +type WireCursor = WireListCursor | WireSearchCursor; + +export function resolveStorageWorkspaceCursorConfiguration( + configuration: StorageWorkspaceCursorConfiguration | undefined, +): Readonly | undefined { + if (configuration === undefined) { + return undefined; + } + if ( + typeof configuration !== 'object' || + configuration === null || + typeof configuration.codec !== 'object' || + configuration.codec === null || + typeof configuration.codec.encode !== 'function' || + typeof configuration.codec.decode !== 'function' + ) { + throw workspaceError( + StorageErrorCode.INVALID_ARGUMENT, + 'Workspace cursor configuration requires a codec.', + { permanent: true }, + ); + } + assertStableIdentity(configuration.mountId, 'cursor.mountId'); + assertStableIdentity(configuration.scope, 'cursor.scope'); + return Object.freeze({ + codec: configuration.codec, + mountId: configuration.mountId, + scope: configuration.scope, + }); +} + +export async function issueStorageWorkspaceCursor( + configuration: Readonly | undefined, + binding: StorageWorkspaceCursorBinding, + cursor: StorageWorkspaceCursorData, +): Promise { + if (configuration === undefined) { + throw cursorNotConfigured(); + } + const expiresAt = Date.now() + binding.limits.cursorTtlMs; + if (!Number.isSafeInteger(expiresAt)) { + throw workspaceError( + StorageErrorCode.LIMIT_EXCEEDED, + 'Workspace cursor expiry exceeds the supported range.', + { permanent: true }, + ); + } + const common: WireCursorCommon = { + b: cursorBindingDigest(configuration, binding, cursor.operation), + e: expiresAt, + v: STORAGE_WORKSPACE_CURSOR_VERSION, + }; + const wire: WireCursor = + cursor.operation === 'list' + ? { + ...common, + o: 'list', + q: { + d: cursor.directory, + l: cursor.limit, + r: cursor.recursive, + }, + s: { c: cursor.backendCursor }, + } + : { + ...common, + o: 'search', + q: { + c: cursor.caseInsensitive, + d: cursor.directory, + l: cursor.limit, + m: cursor.match, + q: cursor.query, + }, + s: { c: cursor.backendCursor, n: cursor.scanned }, + }; + const payload = encoder.encode(JSON.stringify(wire)); + if (payload.byteLength > MAX_CURSOR_PAYLOAD_BYTES) { + throw workspaceError( + StorageErrorCode.LIMIT_EXCEEDED, + 'Workspace cursor state exceeds the bounded token size.', + { permanent: true }, + ); + } + + let token: string; + try { + token = await configuration.codec.encode(payload, { expiresAt }); + } catch (error) { + throw issueFailure(error); + } + if ( + typeof token !== 'string' || + token.length === 0 || + !TOKEN_PATTERN.test(token) + ) { + throw workspaceError( + StorageErrorCode.PROVIDER, + 'Workspace cursor codec returned an invalid token.', + ); + } + if ( + token.length > + Math.min(STORAGE_WORKSPACE_MAX_CURSOR_BYTES, binding.limits.maxCursorBytes) + ) { + throw workspaceError( + StorageErrorCode.LIMIT_EXCEEDED, + 'Workspace cursor exceeds the configured byte limit.', + { permanent: true }, + ); + } + return token; +} + +export function decodeStorageWorkspaceCursor( + configuration: Readonly | undefined, + binding: StorageWorkspaceCursorBinding, + operation: 'list', + token: string, +): Promise; +export function decodeStorageWorkspaceCursor( + configuration: Readonly | undefined, + binding: StorageWorkspaceCursorBinding, + operation: 'search', + token: string, +): Promise; +export async function decodeStorageWorkspaceCursor( + configuration: Readonly | undefined, + binding: StorageWorkspaceCursorBinding, + operation: StorageWorkspaceCursorData['operation'], + token: string, +): Promise { + if (configuration === undefined) { + throw cursorNotConfigured(); + } + if ( + typeof token !== 'string' || + token.length === 0 || + token.length > + Math.min( + STORAGE_WORKSPACE_MAX_CURSOR_BYTES, + binding.limits.maxCursorBytes, + ) || + !TOKEN_PATTERN.test(token) + ) { + throw invalidCursor(); } - #prune(): void { - const now = Date.now(); - for (const [cursor, record] of this.#records) { - if (record.expiresAt <= now) { - this.#records.delete(cursor); + let payload: Uint8Array; + try { + payload = await configuration.codec.decode(token); + } catch (error) { + throw decodeFailure(error); + } + if ( + !(payload instanceof Uint8Array) || + payload.byteLength === 0 || + payload.byteLength > MAX_CURSOR_PAYLOAD_BYTES + ) { + throw invalidCursor(); + } + const wire = parseWireCursor(payload); + const now = Date.now(); + if ( + wire.e <= now || + wire.o !== operation || + wire.b !== cursorBindingDigest(configuration, binding, operation) + ) { + throw invalidCursor(); + } + if ( + (wire.o === 'list' && wire.q.l > binding.limits.maxPageSize) || + (wire.o === 'search' && + (wire.q.l > binding.limits.maxSearchResults || + wire.s.n > binding.limits.maxSearchScan)) + ) { + throw invalidCursor(); + } + return wire.o === 'list' + ? { + backendCursor: wire.s.c, + directory: wire.q.d, + limit: wire.q.l, + operation: 'list', + recursive: wire.q.r, } + : { + backendCursor: wire.s.c, + caseInsensitive: wire.q.c, + directory: wire.q.d, + limit: wire.q.l, + match: wire.q.m, + operation: 'search', + query: wire.q.q, + scanned: wire.s.n, + }; +} + +function parseWireCursor(payload: Uint8Array): WireCursor { + let value: unknown; + try { + value = JSON.parse(decoder.decode(payload)); + } catch { + throw invalidCursor(); + } + if ( + !isRecord(value) || + !hasExactKeys(value, ['b', 'e', 'o', 'q', 's', 'v']) + ) { + throw invalidCursor(); + } + if ( + value.v !== STORAGE_WORKSPACE_CURSOR_VERSION || + typeof value.b !== 'string' || + !BINDING_PATTERN.test(value.b) || + !positiveSafeInteger(value.e) || + !isRecord(value.q) || + !isRecord(value.s) + ) { + throw invalidCursor(); + } + if (value.o === 'list') { + if ( + !hasExactKeys(value.q, ['d', 'l', 'r']) || + !hasExactKeys(value.s, ['c']) || + typeof value.q.d !== 'string' || + !positiveSafeInteger(value.q.l) || + typeof value.q.r !== 'boolean' || + !nonEmptyString(value.s.c) + ) { + throw invalidCursor(); + } + return { + b: value.b, + e: value.e, + o: 'list', + q: { d: value.q.d, l: value.q.l, r: value.q.r }, + s: { c: value.s.c }, + v: STORAGE_WORKSPACE_CURSOR_VERSION, + }; + } + if (value.o === 'search') { + if ( + !hasExactKeys(value.q, ['c', 'd', 'l', 'm', 'q']) || + !hasExactKeys(value.s, ['c', 'n']) || + typeof value.q.c !== 'boolean' || + typeof value.q.d !== 'string' || + !positiveSafeInteger(value.q.l) || + !searchMatch(value.q.m) || + !nonEmptyString(value.q.q) || + !nonEmptyString(value.s.c) || + !nonNegativeSafeInteger(value.s.n) + ) { + throw invalidCursor(); + } + return { + b: value.b, + e: value.e, + o: 'search', + q: { + c: value.q.c, + d: value.q.d, + l: value.q.l, + m: value.q.m, + q: value.q.q, + }, + s: { c: value.s.c, n: value.s.n }, + v: STORAGE_WORKSPACE_CURSOR_VERSION, + }; + } + throw invalidCursor(); +} + +function cursorBindingDigest( + configuration: Readonly, + binding: StorageWorkspaceCursorBinding, + operation: StorageWorkspaceCursorData['operation'], +): string { + const limits = binding.limits; + const canonical = JSON.stringify({ + limits: { + cursorTtlMs: limits.cursorTtlMs, + maxCursorBytes: limits.maxCursorBytes, + maxPageSize: limits.maxPageSize, + maxPathBytes: limits.maxPathBytes, + maxReadBytes: limits.maxReadBytes, + maxSearchResults: limits.maxSearchResults, + maxSearchScan: limits.maxSearchScan, + maxWriteBytes: limits.maxWriteBytes, + }, + mountId: configuration.mountId, + operation, + prefix: binding.prefix, + scope: configuration.scope, + store: binding.store, + }); + return createHash('sha256').update(canonical, 'utf8').digest('base64url'); +} + +function issueFailure(error: unknown): Error { + if (isStorageError(error)) { + return workspaceError(error.code, 'Workspace cursor could not be issued.', { + permanent: error.permanent, + }); + } + return workspaceError( + StorageErrorCode.PROVIDER, + 'Workspace cursor codec failed while issuing a token.', + ); +} + +function decodeFailure(error: unknown): Error { + if (isStorageError(error)) { + if ( + error.code === StorageErrorCode.INVALID_ARGUMENT || + error.code === StorageErrorCode.NOT_FOUND + ) { + return invalidCursor(); } + return workspaceError( + error.code, + 'Workspace cursor codec failed while decoding a token.', + { permanent: error.permanent }, + ); } + return workspaceError( + StorageErrorCode.PROVIDER, + 'Workspace cursor codec failed while decoding a token.', + ); +} + +function cursorNotConfigured(): Error { + return workspaceError( + StorageErrorCode.NOT_SUPPORTED, + 'Workspace pagination requires a configured cursor codec.', + { permanent: true }, + ); +} + +function invalidCursor(): Error { + return workspaceError( + StorageErrorCode.INVALID_ARGUMENT, + 'Workspace cursor is invalid, expired, or belongs to another query.', + { permanent: true }, + ); +} + +function cursorAad(keyId: string): Buffer { + return Buffer.from( + `@nestm/storage/workspace-cursor\0${TOKEN_VERSION}\0${keyId}`, + ); +} + +function validKeyId(value: unknown): value is string { + return ( + typeof value === 'string' && + TOKEN_PART_PATTERN.test(value) && + encoder.encode(value).byteLength <= MAX_CURSOR_KEY_ID_BYTES + ); +} + +function assertStableIdentity(value: string, label: string): void { + if ( + typeof value !== 'string' || + value.trim().length === 0 || + value !== value.normalize('NFC') || + forbiddenIdentityCharacter.test(value) || + encoder.encode(value).byteLength > MAX_CURSOR_SCOPE_BYTES + ) { + throw workspaceError( + StorageErrorCode.INVALID_ARGUMENT, + `${label} must be a non-empty stable NFC identity of at most ${MAX_CURSOR_SCOPE_BYTES} bytes.`, + { permanent: true }, + ); + } +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function hasExactKeys( + value: Record, + expected: readonly string[], +): boolean { + const keys = Object.keys(value); + return ( + keys.length === expected.length && + expected.every((key) => Object.hasOwn(value, key)) + ); +} + +function positiveSafeInteger(value: unknown): value is number { + return Number.isSafeInteger(value) && (value as number) > 0; +} + +function nonNegativeSafeInteger(value: unknown): value is number { + return Number.isSafeInteger(value) && (value as number) >= 0; +} + +function nonEmptyString(value: unknown): value is string { + return typeof value === 'string' && value.length > 0; +} + +function searchMatch(value: unknown): value is StorageWorkspaceSearchMatch { + return value === 'glob' || value === 'substring' || value === 'exact'; } diff --git a/src/workspace/storage-workspace.spec.ts b/src/workspace/storage-workspace.spec.ts index cd1c6cb..8998f04 100644 --- a/src/workspace/storage-workspace.spec.ts +++ b/src/workspace/storage-workspace.spec.ts @@ -16,8 +16,10 @@ import type { StorageDriver } from '../storage.driver.js'; import type { StorageObject } from '../storage.types.js'; import { + Aes256GcmStorageWorkspaceCursorCodec, isStorageWorkspaceError, mountStorageWorkspace, + type StorageWorkspaceCursorConfiguration, type StorageWorkspacePermission, } from './index.js'; @@ -31,6 +33,21 @@ const ALL_PERMISSIONS: readonly StorageWorkspacePermission[] = [ 'move', 'delete', ]; +const CURSOR_KEY = new Uint8Array(32).fill(0x42); + +function cursorConfiguration( + mountId = 'artifact-files', + scope = 'organization:one/workspace:one', +): StorageWorkspaceCursorConfiguration { + return { + codec: new Aes256GcmStorageWorkspaceCursorCodec({ + activeKeyId: 'test', + keys: { test: CURSOR_KEY }, + }), + mountId, + scope, + }; +} const MALICIOUS_ETAGS = [ '', @@ -58,6 +75,7 @@ function mountedFs(root: string) { return { client, workspace: mountStorageWorkspace(client, { + cursor: cursorConfiguration(), permissions: ALL_PERMISSIONS, prefix: 'runs/run-1', }), @@ -85,11 +103,12 @@ describe('StorageWorkspace', () => { expect(workspace.allows('delete')).toBe(false); const child = workspace.mount('src', { - limits: { maxReadBytes: 10 }, + limits: { maxCursorBytes: 2_048, maxReadBytes: 10 }, permissions: ['read'], }); expect([...child.permissions]).toEqual(['read']); expect(child.limits.maxReadBytes).toBe(10); + expect(child.limits.maxCursorBytes).toBe(2_048); expect(() => child.mount('nested', { permissions: ['create'] })).toThrow( expect.objectContaining({ code: StorageErrorCode.UNAUTHORIZED }), ); @@ -98,6 +117,14 @@ describe('StorageWorkspace', () => { ).toThrow( expect.objectContaining({ code: StorageErrorCode.INVALID_ARGUMENT }), ); + expect(() => + mountStorageWorkspace(client, { + limits: { maxCursorBytes: 4_097 }, + prefix: 'runs/two', + }), + ).toThrow( + expect.objectContaining({ code: StorageErrorCode.INVALID_ARGUMENT }), + ); }); it.each([ @@ -301,12 +328,12 @@ describe('StorageWorkspace', () => { recursive: true, }); expect(first.entries).toHaveLength(1); - expect(first.cursor).toMatch(/^[A-Za-z0-9_-]{32}$/u); + expect(first.cursor).toMatch(/^swc1\.test\.[A-Za-z0-9_-]+$/u); const second = await workspace.list({ cursor: first.cursor }); expect(second.entries).toHaveLength(1); await expect( workspace.list({ cursor: first.cursor }), - ).rejects.toMatchObject({ code: StorageErrorCode.INVALID_ARGUMENT }); + ).resolves.toMatchObject({ entries: second.entries }); const searched = await workspace.search('*.ts', { directory: 'src', @@ -338,6 +365,344 @@ describe('StorageWorkspace', () => { await expect( workspace.list({ cursor: '../not-a-token' }), ).rejects.toMatchObject({ code: StorageErrorCode.INVALID_ARGUMENT }); + await expect(workspace.list({ cursor: '' })).rejects.toMatchObject({ + code: StorageErrorCode.INVALID_ARGUMENT, + }); + }); + + it('continues across replicas while binding store, mount, scope, prefix, and limits', async () => { + const createClient = (name = 'artifacts') => + new StorageClient(name, createFsStorageDriver({ adapter: { root } })); + const client = createClient(); + let defaultClient = client; + for (const name of ['a.txt', 'b.txt', 'c.txt']) { + await client.upload(`scope/${name}`, name); + } + const mount = ( + overrides: { + client?: StorageClient; + mountId?: string; + prefix?: string; + scope?: string; + maxReadBytes?: number; + } = {}, + ) => + mountStorageWorkspace(overrides.client ?? defaultClient, { + cursor: cursorConfiguration( + overrides.mountId ?? 'artifact-files', + overrides.scope ?? 'organization:one/workspace:one', + ), + limits: { maxReadBytes: overrides.maxReadBytes ?? 100 }, + prefix: overrides.prefix ?? 'scope', + }); + const firstMount = mount(); + const first = await firstMount.list({ limit: 1, recursive: true }); + expect(first.cursor).toBeTypeOf('string'); + const searched = await firstMount.search('*.txt', { limit: 1 }); + expect(searched.cursor).toBeTypeOf('string'); + + await client.onApplicationShutdown(); + const replicaClient = createClient(); + defaultClient = replicaClient; + + const replica = mount(); + const listedContinuation = await replica.list({ cursor: first.cursor }); + expect(listedContinuation).toMatchObject({ + entries: [expect.objectContaining({ kind: 'file' })], + }); + expect(listedContinuation.cursor).toBeTypeOf('string'); + const listedDescendant = await replica.list({ + cursor: listedContinuation.cursor, + }); + const listedReplay = await replica.list({ cursor: first.cursor }); + expect(listedReplay).toMatchObject({ entries: listedContinuation.entries }); + expect(listedReplay.cursor).toBeTypeOf('string'); + await expect( + replica.list({ cursor: listedReplay.cursor }), + ).resolves.toMatchObject({ entries: listedDescendant.entries }); + const searchContinuation = await replica.search('', { + cursor: searched.cursor, + }); + expect(searchContinuation).toMatchObject({ + entries: [expect.objectContaining({ kind: 'file' })], + }); + expect(searchContinuation.cursor).toBeTypeOf('string'); + const searchDescendant = await replica.search('', { + cursor: searchContinuation.cursor, + }); + const searchReplay = await replica.search('', { cursor: searched.cursor }); + expect(searchReplay).toMatchObject({ entries: searchContinuation.entries }); + expect(searchReplay.cursor).toBeTypeOf('string'); + await expect( + replica.search('', { cursor: searchReplay.cursor }), + ).resolves.toMatchObject({ entries: searchDescendant.entries }); + const otherStoreClient = createClient('other-store'); + await expect( + mount({ client: otherStoreClient }).list({ cursor: first.cursor }), + ).rejects.toMatchObject({ code: StorageErrorCode.INVALID_ARGUMENT }); + await expect( + mount({ mountId: 'other-mount' }).list({ cursor: first.cursor }), + ).rejects.toMatchObject({ code: StorageErrorCode.INVALID_ARGUMENT }); + await expect( + mount({ scope: 'organization:one/workspace:two' }).list({ + cursor: first.cursor, + }), + ).rejects.toMatchObject({ code: StorageErrorCode.INVALID_ARGUMENT }); + await expect( + mount({ prefix: 'other-scope' }).list({ cursor: first.cursor }), + ).rejects.toMatchObject({ code: StorageErrorCode.INVALID_ARGUMENT }); + await expect( + mount({ maxReadBytes: 99 }).list({ cursor: first.cursor }), + ).rejects.toMatchObject({ code: StorageErrorCode.INVALID_ARGUMENT }); + await otherStoreClient.onApplicationShutdown(); + await replicaClient.onApplicationShutdown(); + }); + + it('rejects every explicitly conflicting normalized list and search field', async () => { + const driver = createMemoryStorageDriver({ + adapter: { + initial: { + 'scope/src/a.ts': 'a', + 'scope/src/b.ts': 'b', + 'scope/src/c.ts': 'c', + 'scope/other/d.ts': 'd', + }, + }, + }); + const workspace = mountStorageWorkspace( + new StorageClient('queries', driver), + { cursor: cursorConfiguration(), prefix: 'scope' }, + ); + const listed = await workspace.list({ + directory: 'src', + limit: 1, + recursive: true, + }); + expect(listed.cursor).toBeTypeOf('string'); + for (const options of [ + { cursor: listed.cursor, directory: 'other' }, + { cursor: listed.cursor, limit: 2 }, + { cursor: listed.cursor, recursive: false }, + ]) { + await expect(workspace.list(options)).rejects.toMatchObject({ + code: StorageErrorCode.INVALID_ARGUMENT, + }); + } + + const searched = await workspace.search('*.ts', { + caseInsensitive: false, + directory: 'src', + limit: 1, + match: 'glob', + }); + expect(searched.cursor).toBeTypeOf('string'); + const mismatches = [ + workspace.search('*.md', { cursor: searched.cursor }), + workspace.search('', { cursor: searched.cursor, directory: 'other' }), + workspace.search('', { cursor: searched.cursor, limit: 2 }), + workspace.search('', { + caseInsensitive: true, + cursor: searched.cursor, + }), + workspace.search('', { cursor: searched.cursor, match: 'substring' }), + ]; + for (const mismatch of mismatches) { + await expect(mismatch).rejects.toMatchObject({ + code: StorageErrorCode.INVALID_ARGUMENT, + }); + } + }); + + it('rejects expired and altered cursors without exposing provider state', async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-08-14T12:00:00.000Z')); + try { + const driver = createMemoryStorageDriver({ + adapter: { + initial: { 'private/a.txt': 'a', 'private/b.txt': 'b' }, + }, + }); + const workspace = mountStorageWorkspace( + new StorageClient('expiry', driver), + { + cursor: cursorConfiguration(), + limits: { cursorTtlMs: 100 }, + prefix: 'private', + }, + ); + const first = await workspace.list({ limit: 1, recursive: true }); + expect(first.cursor).toBeTypeOf('string'); + expect(first.cursor).not.toContain('private'); + const final = first.cursor?.at(-1); + const altered = `${first.cursor?.slice(0, -1)}${final === 'A' ? 'B' : 'A'}`; + await expect(workspace.list({ cursor: altered })).rejects.toMatchObject({ + code: StorageErrorCode.INVALID_ARGUMENT, + }); + + vi.advanceTimersByTime(99); + await expect( + workspace.list({ cursor: first.cursor }), + ).resolves.toBeDefined(); + vi.advanceTimersByTime(1); + await expect( + workspace.list({ cursor: first.cursor }), + ).rejects.toMatchObject({ code: StorageErrorCode.INVALID_ARGUMENT }); + } finally { + vi.useRealTimers(); + } + }); + + it('requires cursor configuration only when a continuation is used', async () => { + const single = mountStorageWorkspace( + new StorageClient( + 'single-page', + createMemoryStorageDriver({ + adapter: { initial: { 'scope/only.txt': 'only' } }, + }), + ), + { prefix: 'scope' }, + ); + await expect(single.list({ recursive: true })).resolves.toMatchObject({ + entries: [expect.objectContaining({ path: 'only.txt' })], + }); + + const driver = createMemoryStorageDriver(); + driver.list = vi.fn(async () => ({ + cursor: 'provider-secret-continuation', + items: [ + { + contentType: 'text/plain', + key: 'scope/a.txt', + name: 'scope/a.txt', + size: 1, + }, + ], + })); + const unconfigured = mountStorageWorkspace( + new StorageClient('unconfigured', driver), + { prefix: 'scope' }, + ); + await expect( + unconfigured.list({ limit: 1, recursive: true }), + ).rejects.toMatchObject({ code: StorageErrorCode.NOT_SUPPORTED }); + await expect(unconfigured.list({ cursor: 'opaque' })).rejects.toMatchObject( + { + code: StorageErrorCode.NOT_SUPPORTED, + }, + ); + }); + + it('bounds issued cursor state and sanitizes codec failures', async () => { + const driver = createMemoryStorageDriver(); + driver.list = vi.fn(async () => ({ + cursor: 'p'.repeat(3_000), + items: [ + { + contentType: 'text/plain', + key: 'scope/a.txt', + name: 'scope/a.txt', + size: 1, + }, + ], + })); + const workspace = mountStorageWorkspace( + new StorageClient('oversized', driver), + { cursor: cursorConfiguration(), prefix: 'scope' }, + ); + await expect( + workspace.list({ limit: 1, recursive: true }), + ).rejects.toMatchObject({ code: StorageErrorCode.LIMIT_EXCEEDED }); + + driver.list = vi.fn(async () => ({ + cursor: 'provider-cursor', + items: [ + { + contentType: 'text/plain', + key: 'scope/a.txt', + name: 'scope/a.txt', + size: 1, + }, + ], + })); + const narrowDecode = vi.fn(() => new Uint8Array([1])); + const narrow = mountStorageWorkspace( + new StorageClient('narrow-cursor', driver), + { + cursor: { + codec: { + decode: narrowDecode, + encode: () => 'a'.repeat(65), + }, + mountId: 'artifact-files', + scope: 'organization:one/workspace:one', + }, + limits: { maxCursorBytes: 64 }, + prefix: 'scope', + }, + ); + await expect( + narrow.list({ limit: 1, recursive: true }), + ).rejects.toMatchObject({ code: StorageErrorCode.LIMIT_EXCEEDED }); + await expect(narrow.list({ cursor: 'a'.repeat(65) })).rejects.toMatchObject( + { + code: StorageErrorCode.INVALID_ARGUMENT, + }, + ); + expect(narrowDecode).not.toHaveBeenCalled(); + + const failing = mountStorageWorkspace( + new StorageClient('failing-codec', driver), + { + cursor: { + codec: { + decode: () => { + throw new Error('private decode failure'); + }, + encode: () => { + throw new Error('private encode failure'); + }, + }, + mountId: 'artifact-files', + scope: 'organization:one/workspace:one', + }, + prefix: 'scope', + }, + ); + const error = await failing + .list({ limit: 1, recursive: true }) + .catch((caught: unknown) => caught); + expect(error).toMatchObject({ code: StorageErrorCode.PROVIDER }); + expect((error as Error).message).not.toContain('private encode failure'); + await expect(failing.list({ cursor: 'opaque' })).rejects.toMatchObject({ + code: StorageErrorCode.PROVIDER, + }); + }); + + it('rejects malformed decoded envelopes before calling the provider', async () => { + const driver = createMemoryStorageDriver(); + const list = vi.spyOn(driver, 'list'); + const workspace = mountStorageWorkspace( + new StorageClient('malformed-envelope', driver), + { + cursor: { + codec: { + decode: async () => + new TextEncoder().encode( + JSON.stringify({ b: 'forged', e: Date.now() + 1_000, v: 2 }), + ), + encode: async () => 'opaque', + }, + mountId: 'artifact-files', + scope: 'organization:one/workspace:one', + }, + prefix: 'scope', + }, + ); + + await expect(workspace.list({ cursor: 'opaque' })).rejects.toMatchObject({ + code: StorageErrorCode.INVALID_ARGUMENT, + }); + expect(list).not.toHaveBeenCalled(); }); it('fails closed on out-of-scope and wrong-coordinate provider results', async () => { @@ -542,7 +907,7 @@ describe('StorageWorkspace', () => { })); const workspace = mountStorageWorkspace( new StorageClient('malformed-list', driver), - { prefix: 'scope' }, + { cursor: cursorConfiguration(), prefix: 'scope' }, ); await expect( diff --git a/src/workspace/storage-workspace.ts b/src/workspace/storage-workspace.ts index 14d3e02..2d25e36 100644 --- a/src/workspace/storage-workspace.ts +++ b/src/workspace/storage-workspace.ts @@ -7,7 +7,13 @@ import type { StorageUploadResult, } from '../storage.types.js'; -import { StorageWorkspaceCursorStore } from './storage-workspace.cursor.js'; +import { + STORAGE_WORKSPACE_MAX_CURSOR_BYTES, + decodeStorageWorkspaceCursor, + issueStorageWorkspaceCursor, + resolveStorageWorkspaceCursorConfiguration, +} from './storage-workspace.cursor.js'; +import type { StorageWorkspaceCursorConfiguration } from './storage-workspace.cursor.js'; import { sanitizeWorkspaceError, workspaceError, @@ -57,25 +63,12 @@ const WORKSPACE_CONSTRUCTOR = Symbol('StorageWorkspace.constructor'); interface WorkspaceState { readonly client: StorageClient; - readonly cursorStore: StorageWorkspaceCursorStore; - readonly id: string; + readonly cursor: Readonly | undefined; readonly prefix: string; } -interface ListCursorState { - readonly backendCursor: string; - readonly directory: string; - readonly limit: number; - readonly recursive: boolean; -} - -interface SearchCursorState { +interface SearchPageState { readonly backendCursor: string | undefined; - readonly caseInsensitive: boolean; - readonly directory: string; - readonly limit: number; - readonly match: StorageWorkspaceSearchMatch; - readonly query: string; readonly scanned: number; } @@ -120,6 +113,7 @@ function resolveLimits( const baseline = parent ?? DEFAULT_STORAGE_WORKSPACE_LIMITS; const next: StorageWorkspaceLimits = { cursorTtlMs: requested?.cursorTtlMs ?? baseline.cursorTtlMs, + maxCursorBytes: requested?.maxCursorBytes ?? baseline.maxCursorBytes, maxPageSize: requested?.maxPageSize ?? baseline.maxPageSize, maxPathBytes: requested?.maxPathBytes ?? baseline.maxPathBytes, maxReadBytes: requested?.maxReadBytes ?? baseline.maxReadBytes, @@ -138,6 +132,13 @@ function resolveLimits( ); } } + if (next.maxCursorBytes > STORAGE_WORKSPACE_MAX_CURSOR_BYTES) { + throw workspaceError( + StorageErrorCode.INVALID_ARGUMENT, + `limits.maxCursorBytes cannot exceed ${STORAGE_WORKSPACE_MAX_CURSOR_BYTES}.`, + { permanent: true }, + ); + } return Object.freeze(next); } @@ -503,12 +504,15 @@ class StorageWorkspaceImplementation implements StorageWorkspaceContract { options: StorageWorkspaceListOptions = {}, ): Promise { this.#require('list'); - const continuation = options.cursor - ? this.#state.cursorStore.consume( - options.cursor, - this.#binding('list'), - ) - : undefined; + const continuation = + options.cursor !== undefined + ? await decodeStorageWorkspaceCursor( + this.#state.cursor, + this.#cursorBinding(), + 'list', + options.cursor, + ) + : undefined; if ( continuation !== undefined && ((options.directory !== undefined && @@ -531,7 +535,6 @@ class StorageWorkspaceImplementation implements StorageWorkspaceContract { this.#limits.maxPageSize, ); const recursive = continuation?.recursive ?? options.recursive === true; - const binding = this.#binding('list'); const scopedDirectory = this.#scopeDirectory(directory); try { const page = await this.#state.client.list({ @@ -589,15 +592,16 @@ class StorageWorkspaceImplementation implements StorageWorkspaceContract { } return { ...(page.cursor !== undefined && { - cursor: this.#state.cursorStore.issue( - binding, + cursor: await issueStorageWorkspaceCursor( + this.#state.cursor, + this.#cursorBinding(), { backendCursor: page.cursor, directory, limit, + operation: 'list', recursive, - } satisfies ListCursorState, - this.#limits.cursorTtlMs, + }, ), }), entries, @@ -615,12 +619,15 @@ class StorageWorkspaceImplementation implements StorageWorkspaceContract { options: StorageWorkspaceSearchOptions = {}, ): Promise { this.#require('search'); - const continuation = options.cursor - ? this.#state.cursorStore.consume( - options.cursor, - this.#binding('search'), - ) - : undefined; + const continuation = + options.cursor !== undefined + ? await decodeStorageWorkspaceCursor( + this.#state.cursor, + this.#cursorBinding(), + 'search', + options.cursor, + ) + : undefined; if ( continuation !== undefined && ((query.length > 0 && query !== continuation.query) || @@ -676,18 +683,11 @@ class StorageWorkspaceImplementation implements StorageWorkspaceContract { continuation?.limit ?? options.limit, this.#limits.maxSearchResults, ); - const binding = this.#binding('search'); - const prior = continuation ?? { - backendCursor: undefined, - caseInsensitive, - directory, - limit, - match, - query, - scanned: 0, + const prior: SearchPageState = { + backendCursor: continuation?.backendCursor, + scanned: continuation?.scanned ?? 0, }; return this.#searchPage(query, { - binding, caseInsensitive, directory, limit, @@ -884,7 +884,6 @@ class StorageWorkspaceImplementation implements StorageWorkspaceContract { WORKSPACE_CONSTRUCTOR, { ...this.#state, - id: `${this.#state.id}/${path}`, prefix: joinWorkspacePath(this.#state.prefix, path), }, resolvePermissions(options.permissions, this.#permissions), @@ -943,13 +942,12 @@ class StorageWorkspaceImplementation implements StorageWorkspaceContract { async #searchPage( query: string, context: { - binding: string; caseInsensitive: boolean; directory: string; limit: number; match: StorageWorkspaceSearchMatch; operation: StorageOperationOptions | undefined; - state: SearchCursorState; + state: SearchPageState; }, ): Promise { const entries: StorageWorkspaceEntry[] = []; @@ -1034,20 +1032,22 @@ class StorageWorkspaceImplementation implements StorageWorkspaceContract { { permanent: true }, ); } + const nextBackendCursor = backendCursor; return { - ...(hasMore && { - cursor: this.#state.cursorStore.issue( - context.binding, + ...(nextBackendCursor !== undefined && { + cursor: await issueStorageWorkspaceCursor( + this.#state.cursor, + this.#cursorBinding(), { - backendCursor, + backendCursor: nextBackendCursor, caseInsensitive: context.caseInsensitive, directory: context.directory, limit: context.limit, match: context.match, + operation: 'search', query, scanned, - } satisfies SearchCursorState, - this.#limits.cursorTtlMs, + }, ), }), entries, @@ -1141,8 +1141,16 @@ class StorageWorkspaceImplementation implements StorageWorkspaceContract { return limit; } - #binding(operation: string): string { - return `${this.#state.id}:${operation}`; + #cursorBinding(): { + readonly limits: Readonly; + readonly prefix: string; + readonly store: string; + } { + return { + limits: this.#limits, + prefix: this.#state.prefix, + store: this.#state.client.name, + }; } } @@ -1159,8 +1167,7 @@ export function mountStorageWorkspace( WORKSPACE_CONSTRUCTOR, { client, - cursorStore: new StorageWorkspaceCursorStore(), - id: `workspace:${crypto.randomUUID()}`, + cursor: resolveStorageWorkspaceCursorConfiguration(options.cursor), prefix, }, resolvePermissions(options.permissions), diff --git a/src/workspace/storage-workspace.types.ts b/src/workspace/storage-workspace.types.ts index 51976cc..a1bbf68 100644 --- a/src/workspace/storage-workspace.types.ts +++ b/src/workspace/storage-workspace.types.ts @@ -1,5 +1,7 @@ import type { StorageBody, StorageOperationOptions } from '../storage.types.js'; +import type { StorageWorkspaceCursorConfiguration } from './storage-workspace.cursor.js'; + export const STORAGE_WORKSPACE_PERMISSIONS = [ 'list', 'read', @@ -15,6 +17,8 @@ export type StorageWorkspacePermission = (typeof STORAGE_WORKSPACE_PERMISSIONS)[number]; export interface StorageWorkspaceLimits { + /** Maximum UTF-8 byte length of an opaque continuation cursor. */ + maxCursorBytes: number; /** Maximum UTF-8 byte length of a workspace-relative path. */ maxPathBytes: number; /** Maximum bytes returned by a buffered text read. */ @@ -27,13 +31,17 @@ export interface StorageWorkspaceLimits { maxSearchResults: number; /** Maximum objects inspected by one search query across all pages. */ maxSearchScan: number; - /** Lifetime of an opaque in-memory continuation cursor. */ + /** + * Authorization ceiling for an opaque continuation cursor. It does not + * extend the lifetime or availability of an embedded provider cursor. + */ cursorTtlMs: number; } export const DEFAULT_STORAGE_WORKSPACE_LIMITS: Readonly = Object.freeze({ cursorTtlMs: 5 * 60 * 1000, + maxCursorBytes: 4_096, maxPageSize: 100, maxPathBytes: 1024, maxReadBytes: 1024 * 1024, @@ -78,6 +86,8 @@ export interface StorageWorkspaceMountOptions { export interface MountStorageWorkspaceOptions extends StorageWorkspaceMountOptions { /** Trusted backend namespace. It is never exposed through the workspace. */ prefix: string; + /** Stable server-owned cursor configuration shared across serving replicas. */ + cursor?: StorageWorkspaceCursorConfiguration; } export interface StorageWorkspaceReadOptions extends StorageOperationOptions { diff --git a/test/provider-conformance.e2e-spec.ts b/test/provider-conformance.e2e-spec.ts index a70d55e..b1bead0 100644 --- a/test/provider-conformance.e2e-spec.ts +++ b/test/provider-conformance.e2e-spec.ts @@ -156,12 +156,13 @@ describe('custom provider conformance declarations', () => { registerConformanceSuite('Filesystem provider conformance', { async createFixture(): Promise { const root = await mkdtemp(join(tmpdir(), 'nestm-storage-conformance-')); - const observed = observeDispatches( - createFsStorageDriver({ adapter: { root } }), - ); + const createDriver = () => createFsStorageDriver({ adapter: { root } }); + const observed = observeDispatches(createDriver()); return { client: new StorageClient('filesystem-conformance', observed.driver), close: () => rm(root, { force: true, recursive: true }), + createReplica: () => + new StorageClient('filesystem-conformance', createDriver()), dispatchCount: observed.dispatchCount, }; }, @@ -254,18 +255,24 @@ async function createS3Fixture( profile: Readonly, versionAwareCleanup: boolean, ): Promise { - const observed = observeDispatches( + const createDriver = () => createS3StorageDriver({ adapter: adapterConfiguration(configuration), providerProfile: profile, - }), - ); + }); + const observed = observeDispatches(createDriver()); const client = new StorageClient( `${profile.name}-conformance`, observed.driver, ); + const createReplica = () => + new StorageClient(`${profile.name}-conformance`, createDriver()); if (!versionAwareCleanup) { - return { client, dispatchCount: observed.dispatchCount }; + return { + client, + createReplica, + dispatchCount: observed.dispatchCount, + }; } const raw = new S3Client(sdkConfiguration(configuration)); @@ -273,6 +280,7 @@ async function createS3Fixture( client, cleanup: (keys) => deleteS3ObjectVersions(raw, configuration.bucket, keys), close: () => raw.destroy(), + createReplica, dispatchCount: observed.dispatchCount, async resolveVersion(key): Promise { const result = await raw.send(