Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .changeset/fail-closed-conditional-files-policy.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
'@nestm/storage': patch
---

Fail conditional storage operations closed when caller-configured Files SDK
plugins, hooks, or receipts would be bypassed by native adapter extensions.
Ordinary operations continue through Files SDK while incompatible conditional
capabilities are hidden until Files SDK exposes one shared interception boundary.
35 changes: 35 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -555,6 +555,41 @@ run the reusable
against dedicated test credentials. Unknown endpoints are forced read-only and
receive no inferred conditional capabilities.

## Files SDK responsibility boundary

Files SDK is the upstream authority for the generic storage data plane:
provider adapters, generic CRUD, bulk and list operations, retries, transfers
and sync, its plugin pipeline, and framework-neutral gateway mechanics.
`@nestm/storage` retains the guarantees that Files SDK does not currently
provide: NestJS 12 named stores, exact native conditional/CAS capabilities,
`StorageWorkspace` permissions and limits, bounded storage errors, and
capability-scoped AI tools.

On the alpha.8 base, ordinary `FilesSdkStorageDriver` operations already
delegate to the Files SDK pipeline. The exception is the native conditional
adapter extensions: the current Files SDK operation union does not include
them, so they cannot run through caller-configured Files plugins, hooks, or
receipts. Until Files SDK provides one interception boundary for ordinary and
conditional operations, the driver applies this interim fail-closed
compatibility rule:

| Caller Files configuration | Ordinary operations | Conditional operations |
| ------------------------------------------------- | ------------------------------------ | -------------------------------------------------------- |
| No plugins, active hooks, or receipts | Files pipeline | Advertised when the adapter supports the exact primitive |
| One or more plugins | Files pipeline, including transforms | Hidden; direct invocation returns `NOT_SUPPORTED` |
| Any active hook | Files pipeline and hook callbacks | Hidden; direct invocation returns `NOT_SUPPORTED` |
| Receipts enabled with `true` or an options object | Files pipeline and receipts | Hidden; direct invocation returns `NOT_SUPPORTED` |

An empty plugin list, an empty hooks object, and `receipts: false` do not trigger
the gate. NestM's internal physical-key guard does not trigger it either. When
available, direct conditional paths independently apply prefixing, the
physical-key budget, mutation read-only restrictions, default
retry/signal/timeout options, and bounded error mapping. `StoragePlugin` remains
a separate veto/observation boundary; it is not a substitute for Files body or
result transforms. This compatibility gate is intended to be removed once
native CAS can traverse the upstream operation and plugin pipeline rather than
becoming a second generic CRUD facade here.

## Storage API

`StorageClient` exposes:
Expand Down
205 changes: 204 additions & 1 deletion src/files-sdk/files-sdk.driver.spec.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { Readable } from 'node:stream';
import { inspect } from 'node:util';

import { createStoredFile, handlers } from 'files-sdk';
import { createStoredFile, handlers, type FilesHooks } from 'files-sdk';
import { memory } from 'files-sdk/memory';

import {
Expand Down Expand Up @@ -658,6 +658,209 @@ describe('FilesSdkStorageDriver', () => {
expect(list).not.toHaveBeenCalled();
});

it('keeps ordinary uploads in the Files plugin pipeline and fails conditional uploads closed', async () => {
const adapter = Object.assign(memory(), {
conditionalCreate: { resultEtag: true },
uploadConditional: vi.fn(async (key: string) => ({
contentType: 'text/plain',
etag: 'conditional-etag',
key,
size: 10,
})),
});
const upload = vi.spyOn(adapter, 'upload');
const transform = vi.fn();
const driver = createFilesSdkDriver({
adapter,
plugins: [
{
name: 'body-transform',
wrap: handlers({
upload: (operation, next) => {
transform(operation.body);
return next({ ...operation, body: 'ciphertext' });
},
}),
},
],
});

expect(driver.capabilities.conditionalCreate).toBeUndefined();
await expect(driver.upload('ordinary.txt', 'plaintext')).resolves.toEqual(
expect.objectContaining({ key: 'ordinary.txt' }),
);
expect(transform).toHaveBeenCalledOnce();
expect(transform).toHaveBeenCalledWith('plaintext');
expect(upload.mock.calls[0]?.[1]).toBe('ciphertext');

await expect(
driver.uploadConditional('conditional.txt', 'plaintext', {
condition: { type: 'create' },
}),
).rejects.toMatchObject({
code: StorageErrorCode.NOT_SUPPORTED,
permanent: true,
});
expect(transform).toHaveBeenCalledOnce();
expect(adapter.uploadConditional).not.toHaveBeenCalled();
});

it.each([
{
filesPolicy: {
plugins: [{ name: 'no-op', wrap: handlers({}) }],
},
policyName: 'a nonempty plugin list',
},
{
filesPolicy: { hooks: { onAction: vi.fn() } },
policyName: 'an active action hook',
},
{
filesPolicy: { hooks: { onError: vi.fn() } },
policyName: 'an active error hook',
},
{
filesPolicy: { hooks: { onRetry: vi.fn() } },
policyName: 'an active retry hook',
},
{
filesPolicy: { receipts: true },
policyName: 'receipts',
},
{
filesPolicy: { receipts: { sha256: false } },
policyName: 'receipt options',
},
])(
'hides and blocks every conditional operation with $policyName',
async ({ filesPolicy }) => {
const adapter = Object.assign(memory(), {
conditionalCopyDestination: {
atomicWithSource: true,
create: true,
replace: true,
},
conditionalCopySource: { etag: true, version: true },
conditionalCreate: { resultEtag: true },
conditionalDelete: { etag: true },
conditionalMultipartCompletion: { create: true, replace: true },
conditionalRead: { etag: true, version: true },
conditionalReplace: { resultEtag: true },
deleteConditional: vi.fn(),
downloadConditional: vi.fn(),
promote: vi.fn(),
uploadConditional: vi.fn(),
});
const driver = createFilesSdkDriver({ adapter, ...filesPolicy });

expect(driver.capabilities.conditionalCopyDestination).toBeUndefined();
expect(driver.capabilities.conditionalCopySource).toBeUndefined();
expect(driver.capabilities.conditionalCreate).toBeUndefined();
expect(driver.capabilities.conditionalDelete).toBeUndefined();
expect(
driver.capabilities.conditionalMultipartCompletion,
).toBeUndefined();
expect(driver.capabilities.conditionalRead).toBeUndefined();
expect(driver.capabilities.conditionalReplace).toBeUndefined();

await expect(
driver.uploadConditional('create.txt', 'plaintext', {
condition: { type: 'create' },
}),
).rejects.toMatchObject({
code: StorageErrorCode.NOT_SUPPORTED,
permanent: true,
});
await expect(
driver.downloadConditional('read.txt', {
condition: { etag: 'current-etag' },
}),
).rejects.toMatchObject({
code: StorageErrorCode.NOT_SUPPORTED,
permanent: true,
});
await expect(
driver.deleteConditional('delete.txt', {
condition: { etag: 'current-etag' },
}),
).rejects.toMatchObject({
code: StorageErrorCode.NOT_SUPPORTED,
permanent: true,
});
await expect(
driver.promote('source.txt', 'destination.txt', {
destination: { type: 'create' },
sourceEtag: 'source-etag',
}),
).rejects.toMatchObject({
code: StorageErrorCode.NOT_SUPPORTED,
permanent: true,
});

expect(adapter.uploadConditional).not.toHaveBeenCalled();
expect(adapter.downloadConditional).not.toHaveBeenCalled();
expect(adapter.deleteConditional).not.toHaveBeenCalled();
expect(adapter.promote).not.toHaveBeenCalled();
},
);

it('keeps conditional operations compatible with explicitly inactive Files options', async () => {
const adapter = Object.assign(memory(), {
conditionalCreate: { resultEtag: true },
uploadConditional: vi.fn(async (key: string) => ({
contentType: 'text/plain',
etag: 'conditional-etag',
key,
size: 4,
})),
});
const driver = createFilesSdkDriver({
adapter,
hooks: {},
plugins: [],
receipts: false,
});

expect(driver.capabilities.conditionalCreate).toEqual({
resultEtag: true,
});
await expect(
driver.uploadConditional('conditional.txt', 'body', {
condition: { type: 'create' },
}),
).resolves.toMatchObject({ key: 'conditional.txt' });
expect(adapter.uploadConditional).toHaveBeenCalledOnce();
});

it('snapshots an inactive hooks object before deciding conditional compatibility', async () => {
const adapter = Object.assign(memory(), {
conditionalCreate: { resultEtag: true },
uploadConditional: vi.fn(async (key: string) => ({
contentType: 'text/plain',
etag: 'conditional-etag',
key,
size: 4,
})),
});
const hooks: FilesHooks = {};
const driver = createFilesSdkDriver({ adapter, hooks });
const onAction = vi.fn();
hooks.onAction = onAction;

await expect(driver.upload('ordinary.txt', 'body')).resolves.toMatchObject({
key: 'ordinary.txt',
});
await expect(
driver.uploadConditional('conditional.txt', 'body', {
condition: { type: 'create' },
}),
).resolves.toMatchObject({ key: 'conditional.txt' });

expect(onAction).not.toHaveBeenCalled();
expect(adapter.uploadConditional).toHaveBeenCalledOnce();
});

it('rejects a raw undecorated s3 adapter before dispatch', () => {
const adapter = s3BackedMemoryAdapter();
const upload = vi.spyOn(adapter, 'upload');
Expand Down
Loading