@agentback/files 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/LICENSE +33 -0
  2. package/README.md +62 -0
  3. package/dist/__tests__/unit/fs-file-store.unit.d.ts +2 -0
  4. package/dist/__tests__/unit/fs-file-store.unit.d.ts.map +1 -0
  5. package/dist/__tests__/unit/fs-file-store.unit.js +38 -0
  6. package/dist/__tests__/unit/fs-file-store.unit.js.map +1 -0
  7. package/dist/__tests__/unit/in-memory-file-store.unit.d.ts +2 -0
  8. package/dist/__tests__/unit/in-memory-file-store.unit.d.ts.map +1 -0
  9. package/dist/__tests__/unit/in-memory-file-store.unit.js +65 -0
  10. package/dist/__tests__/unit/in-memory-file-store.unit.js.map +1 -0
  11. package/dist/fs/fs-file-store.d.ts +31 -0
  12. package/dist/fs/fs-file-store.d.ts.map +1 -0
  13. package/dist/fs/fs-file-store.js +92 -0
  14. package/dist/fs/fs-file-store.js.map +1 -0
  15. package/dist/in-memory/in-memory-file-store.d.ts +18 -0
  16. package/dist/in-memory/in-memory-file-store.d.ts.map +1 -0
  17. package/dist/in-memory/in-memory-file-store.js +60 -0
  18. package/dist/in-memory/in-memory-file-store.js.map +1 -0
  19. package/dist/index.d.ts +5 -0
  20. package/dist/index.d.ts.map +1 -0
  21. package/dist/index.js +8 -0
  22. package/dist/index.js.map +1 -0
  23. package/dist/keys.d.ts +5 -0
  24. package/dist/keys.d.ts.map +1 -0
  25. package/dist/keys.js +7 -0
  26. package/dist/keys.js.map +1 -0
  27. package/dist/ports.d.ts +64 -0
  28. package/dist/ports.d.ts.map +1 -0
  29. package/dist/ports.js +17 -0
  30. package/dist/ports.js.map +1 -0
  31. package/dist/testing/conformance.d.ts +9 -0
  32. package/dist/testing/conformance.d.ts.map +1 -0
  33. package/dist/testing/conformance.js +62 -0
  34. package/dist/testing/conformance.js.map +1 -0
  35. package/package.json +40 -0
  36. package/src/__tests__/unit/fs-file-store.unit.ts +45 -0
  37. package/src/__tests__/unit/in-memory-file-store.unit.ts +74 -0
  38. package/src/fs/fs-file-store.ts +123 -0
  39. package/src/in-memory/in-memory-file-store.ts +80 -0
  40. package/src/index.ts +8 -0
  41. package/src/keys.ts +9 -0
  42. package/src/ports.ts +79 -0
  43. package/src/testing/conformance.ts +72 -0
package/LICENSE ADDED
@@ -0,0 +1,33 @@
1
+ Copyright (c) Ninemind.ai 2026.
2
+ Node module: AgentBack
3
+ This project is licensed under the MIT License, full text below.
4
+
5
+ --------
6
+
7
+ MIT License
8
+
9
+ Copyright (c) Ninemind.ai 2026
10
+
11
+ Permission is hereby granted, free of charge, to any person obtaining a copy of
12
+ this software and
13
+ associated documentation files (the "Software"), to deal in the Software without
14
+ restriction, including
15
+ without limitation the rights to use, copy, modify, merge, publish, distribute,
16
+ sublicense, and/or sell
17
+ copies of the Software, and to permit persons to whom the Software is furnished
18
+ to do so, subject to the
19
+ following conditions:
20
+
21
+ The above copyright notice and this permission notice shall be included in all
22
+ copies or substantial
23
+ portions of the Software.
24
+
25
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
26
+ IMPLIED, INCLUDING BUT NOT
27
+ LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE
28
+ AND NONINFRINGEMENT. IN NO
29
+ EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR
30
+ OTHER LIABILITY, WHETHER
31
+ IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
32
+ CONNECTION WITH THE SOFTWARE OR THE
33
+ USE OR OTHER DEALINGS IN THE SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,62 @@
1
+ # @agentback/files
2
+
3
+ > Transport-agnostic file storage: a `FileStore` port + an in-memory adapter.
4
+
5
+ The storage seam behind AgentBack's first-class file upload/download. REST
6
+ handlers never touch a storage SDK — they declare a `fileField()` on a route's
7
+ body schema (uploads stream straight to the bound `FileStore`) and `return
8
+ fileDownload(...)` (downloads stream back out). Bind any `FileStore`
9
+ implementation at `FILE_STORE`; the [`@agentback/files-s3`](../files-s3) adapter
10
+ is the production one.
11
+
12
+ ## The port
13
+
14
+ ```ts
15
+ import {FILE_STORE, type FileStore} from '@agentback/files';
16
+
17
+ interface FileStore {
18
+ put(key, body: Readable | Buffer, opts?): Promise<StoredFile>;
19
+ get(key): Promise<RetrievedFile>; // throws FileNotFoundError if absent
20
+ exists(key): Promise<boolean>;
21
+ delete(key): Promise<void>;
22
+ presignedPut?(key, opts?): Promise<string>; // optional (direct-to-storage)
23
+ presignedGet?(key, opts?): Promise<string>; // optional
24
+ }
25
+ ```
26
+
27
+ Keys are **opaque and server-generated** — callers pass a UUID, never a
28
+ client-controlled path. `get` throws `FileNotFoundError` (HTTP-free; the REST
29
+ layer maps it to 404).
30
+
31
+ ## Flow
32
+
33
+ ```mermaid
34
+ flowchart LR
35
+ C[client] -- multipart/form-data --> R[RestServer route]
36
+ R -- "fileField() → multipart parser" --> S[(FileStore.put<br/>UUID key)]
37
+ R --> H[handler: persist metadata]
38
+ C2[client] -- GET /files/id --> H2[handler]
39
+ H2 -- ownership check --> G[(FileStore.get)]
40
+ G -- fileDownload → pipe(res) --> C2
41
+ ```
42
+
43
+ ## Adapters
44
+
45
+ - **`InMemoryFileStore`** (here) — buffers in a `Map`; tests/dev only.
46
+ - **`FsFileStore`** (here) — local filesystem; streams to/from `<baseDir>/<key>`
47
+ with a metadata sidecar. Single-node / self-hosted / dev-with-persistence.
48
+ - **`S3FileStore`** (`@agentback/files-s3`) — streams to S3 via AWS SDK v3.
49
+
50
+ ## Testing
51
+
52
+ `@agentback/files/testing` exports `runFileStoreConformance(label, makeStore)`
53
+ — the port contract as a reusable suite. Every adapter runs it:
54
+
55
+ ```ts
56
+ import {runFileStoreConformance} from '@agentback/files/testing';
57
+ runFileStoreConformance('MyStore', () => new MyStore());
58
+ ```
59
+
60
+ See [`examples/hello-uploads`](../../examples/hello-uploads) for the end-to-end
61
+ recipe (upload, list, owner-scoped download) and `@agentback/rest`'s
62
+ `fileField` / `fileResponse` for the REST integration.
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=fs-file-store.unit.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"fs-file-store.unit.d.ts","sourceRoot":"","sources":["../../../src/__tests__/unit/fs-file-store.unit.ts"],"names":[],"mappings":""}
@@ -0,0 +1,38 @@
1
+ // Copyright Ninemind.ai 2026. All Rights Reserved.
2
+ // This file is licensed under the MIT License.
3
+ // License text available at https://opensource.org/license/mit/
4
+ import { afterAll, describe, expect, it } from 'vitest';
5
+ import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs';
6
+ import { tmpdir } from 'node:os';
7
+ import { join } from 'node:path';
8
+ import { FsFileStore } from '../../index.js';
9
+ import { runFileStoreConformance } from '../../testing/conformance.js';
10
+ const root = mkdtempSync(join(tmpdir(), 'agentback-fs-'));
11
+ afterAll(() => rmSync(root, { recursive: true, force: true }));
12
+ // Runs unconditionally (no external service needed) — unlike the S3 adapter.
13
+ runFileStoreConformance('FsFileStore', () => new FsFileStore({ baseDir: root }));
14
+ describe('FsFileStore specifics', () => {
15
+ it('writes the bytes to disk under baseDir', async () => {
16
+ const dir = mkdtempSync(join(tmpdir(), 'agentback-fs-spec-'));
17
+ const store = new FsFileStore({ baseDir: dir });
18
+ await store.put('k1', Buffer.from('on disk'), { contentType: 'text/plain' });
19
+ expect(existsSync(join(dir, 'k1'))).toBe(true);
20
+ expect(readFileSync(join(dir, 'k1'), 'utf8')).toBe('on disk');
21
+ rmSync(dir, { recursive: true, force: true });
22
+ });
23
+ it('delete removes both the data and its metadata sidecar', async () => {
24
+ const dir = mkdtempSync(join(tmpdir(), 'agentback-fs-spec-'));
25
+ const store = new FsFileStore({ baseDir: dir });
26
+ await store.put('k2', Buffer.from('x'), { filename: 'x.bin' });
27
+ expect(existsSync(join(dir, 'k2.meta.json'))).toBe(true);
28
+ await store.delete('k2');
29
+ expect(existsSync(join(dir, 'k2'))).toBe(false);
30
+ expect(existsSync(join(dir, 'k2.meta.json'))).toBe(false);
31
+ rmSync(dir, { recursive: true, force: true });
32
+ });
33
+ it('rejects a key that escapes the base directory', async () => {
34
+ const store = new FsFileStore({ baseDir: root });
35
+ await expect(store.put('../escape', Buffer.from('no'))).rejects.toThrow(/escapes the base directory/);
36
+ });
37
+ });
38
+ //# sourceMappingURL=fs-file-store.unit.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"fs-file-store.unit.js","sourceRoot":"","sources":["../../../src/__tests__/unit/fs-file-store.unit.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=in-memory-file-store.unit.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"in-memory-file-store.unit.d.ts","sourceRoot":"","sources":["../../../src/__tests__/unit/in-memory-file-store.unit.ts"],"names":[],"mappings":""}
@@ -0,0 +1,65 @@
1
+ // Copyright Ninemind.ai 2026. All Rights Reserved.
2
+ // This file is licensed under the MIT License.
3
+ // License text available at https://opensource.org/license/mit/
4
+ import { Readable } from 'node:stream';
5
+ import { describe, it, expect, beforeEach } from 'vitest';
6
+ import { InMemoryFileStore, FileNotFoundError } from '../../index.js';
7
+ import { runFileStoreConformance } from '../../testing/conformance.js';
8
+ runFileStoreConformance('InMemoryFileStore', () => new InMemoryFileStore());
9
+ /** Drain a RetrievedFile's stream back to a Buffer for assertions. */
10
+ async function drain(stream) {
11
+ const chunks = [];
12
+ for await (const c of stream)
13
+ chunks.push(Buffer.isBuffer(c) ? c : Buffer.from(c));
14
+ return Buffer.concat(chunks);
15
+ }
16
+ describe('InMemoryFileStore', () => {
17
+ let store;
18
+ beforeEach(() => {
19
+ store = new InMemoryFileStore();
20
+ });
21
+ it('round-trips a Buffer with its metadata', async () => {
22
+ const put = await store.put('k1', Buffer.from('hello'), {
23
+ contentType: 'text/plain',
24
+ filename: 'greeting.txt',
25
+ metadata: { owner: 'u1' },
26
+ });
27
+ expect(put).toEqual({ key: 'k1', size: 5, contentType: 'text/plain' });
28
+ const got = await store.get('k1');
29
+ expect(got.size).toBe(5);
30
+ expect(got.contentType).toBe('text/plain');
31
+ expect(got.filename).toBe('greeting.txt');
32
+ expect(got.metadata).toEqual({ owner: 'u1' });
33
+ expect((await drain(got.stream)).toString()).toBe('hello');
34
+ });
35
+ it('accepts a Readable body and reports the streamed size', async () => {
36
+ const body = Readable.from([Buffer.from('ab'), Buffer.from('cde')]);
37
+ const put = await store.put('k2', body);
38
+ expect(put.size).toBe(5);
39
+ expect((await drain((await store.get('k2')).stream)).toString()).toBe('abcde');
40
+ });
41
+ it('get() throws FileNotFoundError for a missing key', async () => {
42
+ await expect(store.get('nope')).rejects.toBeInstanceOf(FileNotFoundError);
43
+ await expect(store.get('nope')).rejects.toMatchObject({
44
+ code: 'file_not_found',
45
+ key: 'nope',
46
+ });
47
+ });
48
+ it('exists() reflects presence; delete() removes', async () => {
49
+ expect(await store.exists('k3')).toBe(false);
50
+ await store.put('k3', Buffer.from('x'));
51
+ expect(await store.exists('k3')).toBe(true);
52
+ expect(store.count).toBe(1);
53
+ await store.delete('k3');
54
+ expect(await store.exists('k3')).toBe(false);
55
+ expect(store.count).toBe(0);
56
+ // delete of a missing key is a no-op (idempotent)
57
+ await expect(store.delete('k3')).resolves.toBeUndefined();
58
+ });
59
+ it('does not implement the optional presigned hooks', () => {
60
+ const fs = store;
61
+ expect(fs.presignedPut).toBeUndefined();
62
+ expect(fs.presignedGet).toBeUndefined();
63
+ });
64
+ });
65
+ //# sourceMappingURL=in-memory-file-store.unit.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"in-memory-file-store.unit.js","sourceRoot":"","sources":["../../../src/__tests__/unit/in-memory-file-store.unit.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,31 @@
1
+ import { Readable } from 'node:stream';
2
+ import { type FileStore, type PutOptions, type RetrievedFile, type StoredFile } from '../ports.js';
3
+ export interface FsFileStoreOptions {
4
+ /** Directory under which all objects live. Created on demand. */
5
+ baseDir: string;
6
+ /** Optional key prefix (a subdirectory) namespacing every object. */
7
+ keyPrefix?: string;
8
+ }
9
+ /**
10
+ * Local-filesystem {@link FileStore}. Streams object bytes to/from
11
+ * `<baseDir>/<key>` and keeps a small `<key>.meta.json` sidecar for
12
+ * contentType/filename/metadata. Good for single-node deploys, self-hosting,
13
+ * and dev-with-persistence — between {@link InMemoryFileStore} and an
14
+ * `S3FileStore`.
15
+ *
16
+ * Every key is resolved under `baseDir` and rejected if it escapes (defense in
17
+ * depth — REST keys are already server-generated UUIDs, never client paths).
18
+ */
19
+ export declare class FsFileStore implements FileStore {
20
+ private readonly baseDir;
21
+ private readonly prefix;
22
+ constructor(opts: FsFileStoreOptions);
23
+ /** Absolute data path for a key, guaranteed to stay within `baseDir`. */
24
+ private pathFor;
25
+ put(key: string, body: Readable | Buffer, opts?: PutOptions): Promise<StoredFile>;
26
+ get(key: string): Promise<RetrievedFile>;
27
+ exists(key: string): Promise<boolean>;
28
+ delete(key: string): Promise<void>;
29
+ private readSidecar;
30
+ }
31
+ //# sourceMappingURL=fs-file-store.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"fs-file-store.d.ts","sourceRoot":"","sources":["../../src/fs/fs-file-store.ts"],"names":[],"mappings":"AAOA,OAAO,EAAC,QAAQ,EAAC,MAAM,aAAa,CAAC;AAErC,OAAO,EAEL,KAAK,SAAS,EACd,KAAK,UAAU,EACf,KAAK,aAAa,EAClB,KAAK,UAAU,EAChB,MAAM,aAAa,CAAC;AASrB,MAAM,WAAW,kBAAkB;IACjC,iEAAiE;IACjE,OAAO,EAAE,MAAM,CAAC;IAChB,qEAAqE;IACrE,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB;AAED;;;;;;;;;GASG;AACH,qBAAa,WAAY,YAAW,SAAS;IAC3C,OAAO,CAAC,QAAQ,CAAC,OAAO,CAAS;IACjC,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAS;gBAEpB,IAAI,EAAE,kBAAkB;IAKpC,yEAAyE;IACzE,OAAO,CAAC,OAAO;IAQT,GAAG,CACP,GAAG,EAAE,MAAM,EACX,IAAI,EAAE,QAAQ,GAAG,MAAM,EACvB,IAAI,GAAE,UAAe,GACpB,OAAO,CAAC,UAAU,CAAC;IAehB,GAAG,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,aAAa,CAAC;IAsBxC,MAAM,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC;IASrC,MAAM,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;YAM1B,WAAW;CAO1B"}
@@ -0,0 +1,92 @@
1
+ // Copyright Ninemind.ai 2026. All Rights Reserved.
2
+ // This file is licensed under the MIT License.
3
+ // License text available at https://opensource.org/license/mit/
4
+ import { createReadStream, createWriteStream } from 'node:fs';
5
+ import { access, mkdir, readFile, rm, stat, writeFile } from 'node:fs/promises';
6
+ import { dirname, resolve, sep } from 'node:path';
7
+ import { Readable } from 'node:stream';
8
+ import { pipeline } from 'node:stream/promises';
9
+ import { FileNotFoundError, } from '../ports.js';
10
+ /**
11
+ * Local-filesystem {@link FileStore}. Streams object bytes to/from
12
+ * `<baseDir>/<key>` and keeps a small `<key>.meta.json` sidecar for
13
+ * contentType/filename/metadata. Good for single-node deploys, self-hosting,
14
+ * and dev-with-persistence — between {@link InMemoryFileStore} and an
15
+ * `S3FileStore`.
16
+ *
17
+ * Every key is resolved under `baseDir` and rejected if it escapes (defense in
18
+ * depth — REST keys are already server-generated UUIDs, never client paths).
19
+ */
20
+ export class FsFileStore {
21
+ constructor(opts) {
22
+ this.baseDir = resolve(opts.baseDir);
23
+ this.prefix = opts.keyPrefix ?? '';
24
+ }
25
+ /** Absolute data path for a key, guaranteed to stay within `baseDir`. */
26
+ pathFor(key) {
27
+ const full = resolve(this.baseDir, this.prefix + key);
28
+ if (full !== this.baseDir && !full.startsWith(this.baseDir + sep)) {
29
+ throw new Error(`Refusing a key that escapes the base directory: ${key}`);
30
+ }
31
+ return full;
32
+ }
33
+ async put(key, body, opts = {}) {
34
+ const p = this.pathFor(key);
35
+ await mkdir(dirname(p), { recursive: true });
36
+ const source = Buffer.isBuffer(body) ? Readable.from(body) : body;
37
+ await pipeline(source, createWriteStream(p));
38
+ const sidecar = {
39
+ ...(opts.contentType ? { contentType: opts.contentType } : {}),
40
+ ...(opts.filename ? { filename: opts.filename } : {}),
41
+ ...(opts.metadata ? { metadata: opts.metadata } : {}),
42
+ };
43
+ await writeFile(`${p}.meta.json`, JSON.stringify(sidecar));
44
+ const { size } = await stat(p);
45
+ return { key, size, contentType: opts.contentType };
46
+ }
47
+ async get(key) {
48
+ const p = this.pathFor(key);
49
+ let size;
50
+ try {
51
+ ({ size } = await stat(p));
52
+ }
53
+ catch (err) {
54
+ if (err.code === 'ENOENT') {
55
+ throw new FileNotFoundError(key);
56
+ }
57
+ throw err;
58
+ }
59
+ const sidecar = await this.readSidecar(p);
60
+ return {
61
+ key,
62
+ stream: createReadStream(p),
63
+ size,
64
+ contentType: sidecar.contentType,
65
+ filename: sidecar.filename,
66
+ metadata: sidecar.metadata,
67
+ };
68
+ }
69
+ async exists(key) {
70
+ try {
71
+ await access(this.pathFor(key));
72
+ return true;
73
+ }
74
+ catch {
75
+ return false;
76
+ }
77
+ }
78
+ async delete(key) {
79
+ const p = this.pathFor(key);
80
+ await rm(p, { force: true });
81
+ await rm(`${p}.meta.json`, { force: true });
82
+ }
83
+ async readSidecar(p) {
84
+ try {
85
+ return JSON.parse(await readFile(`${p}.meta.json`, 'utf8'));
86
+ }
87
+ catch {
88
+ return {};
89
+ }
90
+ }
91
+ }
92
+ //# sourceMappingURL=fs-file-store.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"fs-file-store.js","sourceRoot":"","sources":["../../src/fs/fs-file-store.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,18 @@
1
+ import { Readable } from 'node:stream';
2
+ import { type FileStore, type PutOptions, type RetrievedFile, type StoredFile } from '../ports.js';
3
+ /**
4
+ * In-memory {@link FileStore} for tests and local dev. Each object is buffered
5
+ * whole in a `Map`, so it is NOT for production — no streaming-to-disk and no
6
+ * size cap (a large upload is held in memory). Use an `S3FileStore` (or similar
7
+ * streaming adapter) in production.
8
+ */
9
+ export declare class InMemoryFileStore implements FileStore {
10
+ private readonly store;
11
+ put(key: string, body: Readable | Buffer, opts?: PutOptions): Promise<StoredFile>;
12
+ get(key: string): Promise<RetrievedFile>;
13
+ exists(key: string): Promise<boolean>;
14
+ delete(key: string): Promise<void>;
15
+ /** Test helper: number of stored objects. */
16
+ get count(): number;
17
+ }
18
+ //# sourceMappingURL=in-memory-file-store.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"in-memory-file-store.d.ts","sourceRoot":"","sources":["../../src/in-memory/in-memory-file-store.ts"],"names":[],"mappings":"AAIA,OAAO,EAAC,QAAQ,EAAC,MAAM,aAAa,CAAC;AACrC,OAAO,EAEL,KAAK,SAAS,EACd,KAAK,UAAU,EACf,KAAK,aAAa,EAClB,KAAK,UAAU,EAChB,MAAM,aAAa,CAAC;AASrB;;;;;GAKG;AACH,qBAAa,iBAAkB,YAAW,SAAS;IACjD,OAAO,CAAC,QAAQ,CAAC,KAAK,CAA4B;IAE5C,GAAG,CACP,GAAG,EAAE,MAAM,EACX,IAAI,EAAE,QAAQ,GAAG,MAAM,EACvB,IAAI,GAAE,UAAe,GACpB,OAAO,CAAC,UAAU,CAAC;IAWhB,GAAG,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,aAAa,CAAC;IAaxC,MAAM,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC;IAIrC,MAAM,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAIxC,6CAA6C;IAC7C,IAAI,KAAK,IAAI,MAAM,CAElB;CACF"}
@@ -0,0 +1,60 @@
1
+ // Copyright Ninemind.ai 2026. All Rights Reserved.
2
+ // This file is licensed under the MIT License.
3
+ // License text available at https://opensource.org/license/mit/
4
+ import { Readable } from 'node:stream';
5
+ import { FileNotFoundError, } from '../ports.js';
6
+ /**
7
+ * In-memory {@link FileStore} for tests and local dev. Each object is buffered
8
+ * whole in a `Map`, so it is NOT for production — no streaming-to-disk and no
9
+ * size cap (a large upload is held in memory). Use an `S3FileStore` (or similar
10
+ * streaming adapter) in production.
11
+ */
12
+ export class InMemoryFileStore {
13
+ constructor() {
14
+ this.store = new Map();
15
+ }
16
+ async put(key, body, opts = {}) {
17
+ const buffer = await toBuffer(body);
18
+ this.store.set(key, {
19
+ buffer,
20
+ contentType: opts.contentType,
21
+ filename: opts.filename,
22
+ metadata: opts.metadata,
23
+ });
24
+ return { key, size: buffer.byteLength, contentType: opts.contentType };
25
+ }
26
+ async get(key) {
27
+ const e = this.store.get(key);
28
+ if (!e)
29
+ throw new FileNotFoundError(key);
30
+ return {
31
+ key,
32
+ stream: Readable.from(e.buffer),
33
+ size: e.buffer.byteLength,
34
+ contentType: e.contentType,
35
+ filename: e.filename,
36
+ metadata: e.metadata,
37
+ };
38
+ }
39
+ async exists(key) {
40
+ return this.store.has(key);
41
+ }
42
+ async delete(key) {
43
+ this.store.delete(key);
44
+ }
45
+ /** Test helper: number of stored objects. */
46
+ get count() {
47
+ return this.store.size;
48
+ }
49
+ }
50
+ /** Collect a Buffer or a Readable into a single Buffer. */
51
+ async function toBuffer(body) {
52
+ if (Buffer.isBuffer(body))
53
+ return body;
54
+ const chunks = [];
55
+ for await (const chunk of body) {
56
+ chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
57
+ }
58
+ return Buffer.concat(chunks);
59
+ }
60
+ //# sourceMappingURL=in-memory-file-store.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"in-memory-file-store.js","sourceRoot":"","sources":["../../src/in-memory/in-memory-file-store.ts"],"names":[],"mappings":"AAAA,mDAAmD;AACnD,+CAA+C;AAC/C,gEAAgE;AAEhE,OAAO,EAAC,QAAQ,EAAC,MAAM,aAAa,CAAC;AACrC,OAAO,EACL,iBAAiB,GAKlB,MAAM,aAAa,CAAC;AASrB;;;;;GAKG;AACH,MAAM,OAAO,iBAAiB;IAA9B;QACmB,UAAK,GAAG,IAAI,GAAG,EAAiB,CAAC;IA0CpD,CAAC;IAxCC,KAAK,CAAC,GAAG,CACP,GAAW,EACX,IAAuB,EACvB,OAAmB,EAAE;QAErB,MAAM,MAAM,GAAG,MAAM,QAAQ,CAAC,IAAI,CAAC,CAAC;QACpC,IAAI,CAAC,KAAK,CAAC,GAAG,CAAC,GAAG,EAAE;YAClB,MAAM;YACN,WAAW,EAAE,IAAI,CAAC,WAAW;YAC7B,QAAQ,EAAE,IAAI,CAAC,QAAQ;YACvB,QAAQ,EAAE,IAAI,CAAC,QAAQ;SACxB,CAAC,CAAC;QACH,OAAO,EAAC,GAAG,EAAE,IAAI,EAAE,MAAM,CAAC,UAAU,EAAE,WAAW,EAAE,IAAI,CAAC,WAAW,EAAC,CAAC;IACvE,CAAC;IAED,KAAK,CAAC,GAAG,CAAC,GAAW;QACnB,MAAM,CAAC,GAAG,IAAI,CAAC,KAAK,CAAC,GAAG,CAAC,GAAG,CAAC,CAAC;QAC9B,IAAI,CAAC,CAAC;YAAE,MAAM,IAAI,iBAAiB,CAAC,GAAG,CAAC,CAAC;QACzC,OAAO;YACL,GAAG;YACH,MAAM,EAAE,QAAQ,CAAC,IAAI,CAAC,CAAC,CAAC,MAAM,CAAC;YAC/B,IAAI,EAAE,CAAC,CAAC,MAAM,CAAC,UAAU;YACzB,WAAW,EAAE,CAAC,CAAC,WAAW;YAC1B,QAAQ,EAAE,CAAC,CAAC,QAAQ;YACpB,QAAQ,EAAE,CAAC,CAAC,QAAQ;SACrB,CAAC;IACJ,CAAC;IAED,KAAK,CAAC,MAAM,CAAC,GAAW;QACtB,OAAO,IAAI,CAAC,KAAK,CAAC,GAAG,CAAC,GAAG,CAAC,CAAC;IAC7B,CAAC;IAED,KAAK,CAAC,MAAM,CAAC,GAAW;QACtB,IAAI,CAAC,KAAK,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC;IACzB,CAAC;IAED,6CAA6C;IAC7C,IAAI,KAAK;QACP,OAAO,IAAI,CAAC,KAAK,CAAC,IAAI,CAAC;IACzB,CAAC;CACF;AAED,2DAA2D;AAC3D,KAAK,UAAU,QAAQ,CAAC,IAAuB;IAC7C,IAAI,MAAM,CAAC,QAAQ,CAAC,IAAI,CAAC;QAAE,OAAO,IAAI,CAAC;IACvC,MAAM,MAAM,GAAa,EAAE,CAAC;IAC5B,IAAI,KAAK,EAAE,MAAM,KAAK,IAAI,IAAI,EAAE,CAAC;QAC/B,MAAM,CAAC,IAAI,CAAC,MAAM,CAAC,QAAQ,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC,MAAM,CAAC,IAAI,CAAC,KAAmB,CAAC,CAAC,CAAC;IACjF,CAAC;IACD,OAAO,MAAM,CAAC,MAAM,CAAC,MAAM,CAAC,CAAC;AAC/B,CAAC"}
@@ -0,0 +1,5 @@
1
+ export * from './ports.js';
2
+ export * from './keys.js';
3
+ export { InMemoryFileStore } from './in-memory/in-memory-file-store.js';
4
+ export { FsFileStore, type FsFileStoreOptions } from './fs/fs-file-store.js';
5
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAIA,cAAc,YAAY,CAAC;AAC3B,cAAc,WAAW,CAAC;AAC1B,OAAO,EAAC,iBAAiB,EAAC,MAAM,qCAAqC,CAAC;AACtE,OAAO,EAAC,WAAW,EAAE,KAAK,kBAAkB,EAAC,MAAM,uBAAuB,CAAC"}
package/dist/index.js ADDED
@@ -0,0 +1,8 @@
1
+ // Copyright Ninemind.ai 2026. All Rights Reserved.
2
+ // This file is licensed under the MIT License.
3
+ // License text available at https://opensource.org/license/mit/
4
+ export * from './ports.js';
5
+ export * from './keys.js';
6
+ export { InMemoryFileStore } from './in-memory/in-memory-file-store.js';
7
+ export { FsFileStore } from './fs/fs-file-store.js';
8
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,mDAAmD;AACnD,+CAA+C;AAC/C,gEAAgE;AAEhE,cAAc,YAAY,CAAC;AAC3B,cAAc,WAAW,CAAC;AAC1B,OAAO,EAAC,iBAAiB,EAAC,MAAM,qCAAqC,CAAC;AACtE,OAAO,EAAC,WAAW,EAA0B,MAAM,uBAAuB,CAAC"}
package/dist/keys.d.ts ADDED
@@ -0,0 +1,5 @@
1
+ import { BindingKey } from '@agentback/core';
2
+ import type { FileStore } from './ports.js';
3
+ /** DI key for the active {@link FileStore}. Bind an adapter; inject to use. */
4
+ export declare const FILE_STORE: BindingKey<FileStore>;
5
+ //# sourceMappingURL=keys.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"keys.d.ts","sourceRoot":"","sources":["../src/keys.ts"],"names":[],"mappings":"AAIA,OAAO,EAAC,UAAU,EAAC,MAAM,iBAAiB,CAAC;AAC3C,OAAO,KAAK,EAAC,SAAS,EAAC,MAAM,YAAY,CAAC;AAE1C,+EAA+E;AAC/E,eAAO,MAAM,UAAU,uBAA8C,CAAC"}
package/dist/keys.js ADDED
@@ -0,0 +1,7 @@
1
+ // Copyright Ninemind.ai 2026. All Rights Reserved.
2
+ // This file is licensed under the MIT License.
3
+ // License text available at https://opensource.org/license/mit/
4
+ import { BindingKey } from '@agentback/core';
5
+ /** DI key for the active {@link FileStore}. Bind an adapter; inject to use. */
6
+ export const FILE_STORE = BindingKey.create('files.store');
7
+ //# sourceMappingURL=keys.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"keys.js","sourceRoot":"","sources":["../src/keys.ts"],"names":[],"mappings":"AAAA,mDAAmD;AACnD,+CAA+C;AAC/C,gEAAgE;AAEhE,OAAO,EAAC,UAAU,EAAC,MAAM,iBAAiB,CAAC;AAG3C,+EAA+E;AAC/E,MAAM,CAAC,MAAM,UAAU,GAAG,UAAU,CAAC,MAAM,CAAY,aAAa,CAAC,CAAC"}
@@ -0,0 +1,64 @@
1
+ import type { Readable } from 'node:stream';
2
+ /** Options accepted when storing a file. */
3
+ export interface PutOptions {
4
+ /** MIME type recorded alongside the bytes (echoed on `get`). */
5
+ contentType?: string;
6
+ /** Original filename, surfaced on download as `Content-Disposition`. */
7
+ filename?: string;
8
+ /** Arbitrary string metadata persisted with the object. */
9
+ metadata?: Record<string, string>;
10
+ }
11
+ /** Result of a successful {@link FileStore.put}. */
12
+ export interface StoredFile {
13
+ key: string;
14
+ size: number;
15
+ contentType?: string;
16
+ /** Backend entity tag (e.g. S3 ETag), when the adapter provides one. */
17
+ etag?: string;
18
+ }
19
+ /** A retrieved file: a readable byte stream plus the metadata stored with it. */
20
+ export interface RetrievedFile {
21
+ key: string;
22
+ stream: Readable;
23
+ size: number;
24
+ contentType?: string;
25
+ filename?: string;
26
+ metadata?: Record<string, string>;
27
+ }
28
+ /** Options for the optional presigned-URL hooks. */
29
+ export interface PresignOptions {
30
+ /** URL lifetime in seconds. Adapter chooses a default when omitted. */
31
+ expiresInSec?: number;
32
+ }
33
+ /**
34
+ * Transport-agnostic file storage seam. Adapters (in-memory, S3, …) implement
35
+ * it; REST handlers stream uploads in via {@link put} and downloads out via
36
+ * {@link get}. Bind an implementation at {@link FILE_STORE} and inject it.
37
+ *
38
+ * Keys are opaque to the store — callers MUST generate them server-side (e.g. a
39
+ * UUID); never pass a client-controlled path, or an S3 adapter is open to
40
+ * key-traversal. `get` throws {@link FileNotFoundError} for a missing key.
41
+ *
42
+ * `presignedPut`/`presignedGet` are optional: a server-proxied adapter omits
43
+ * them; a direct-to-storage adapter implements them. They are declared here so
44
+ * a future presigned flow is an additive capability, not a breaking change.
45
+ */
46
+ export interface FileStore {
47
+ put(key: string, body: Readable | Buffer, opts?: PutOptions): Promise<StoredFile>;
48
+ get(key: string): Promise<RetrievedFile>;
49
+ exists(key: string): Promise<boolean>;
50
+ delete(key: string): Promise<void>;
51
+ presignedPut?(key: string, opts?: PutOptions & PresignOptions): Promise<string>;
52
+ presignedGet?(key: string, opts?: PresignOptions): Promise<string>;
53
+ }
54
+ /**
55
+ * Thrown by {@link FileStore.get} when no object exists at the key. The REST
56
+ * layer maps this to a 404 (it stays here so the port carries no HTTP/openapi
57
+ * dependency).
58
+ */
59
+ export declare class FileNotFoundError extends Error {
60
+ readonly key: string;
61
+ readonly code = "file_not_found";
62
+ constructor(key: string);
63
+ }
64
+ //# sourceMappingURL=ports.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"ports.d.ts","sourceRoot":"","sources":["../src/ports.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,EAAC,QAAQ,EAAC,MAAM,aAAa,CAAC;AAE1C,4CAA4C;AAC5C,MAAM,WAAW,UAAU;IACzB,gEAAgE;IAChE,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,wEAAwE;IACxE,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,2DAA2D;IAC3D,QAAQ,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;CACnC;AAED,oDAAoD;AACpD,MAAM,WAAW,UAAU;IACzB,GAAG,EAAE,MAAM,CAAC;IACZ,IAAI,EAAE,MAAM,CAAC;IACb,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,wEAAwE;IACxE,IAAI,CAAC,EAAE,MAAM,CAAC;CACf;AAED,iFAAiF;AACjF,MAAM,WAAW,aAAa;IAC5B,GAAG,EAAE,MAAM,CAAC;IACZ,MAAM,EAAE,QAAQ,CAAC;IACjB,IAAI,EAAE,MAAM,CAAC;IACb,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,QAAQ,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;CACnC;AAED,oDAAoD;AACpD,MAAM,WAAW,cAAc;IAC7B,uEAAuE;IACvE,YAAY,CAAC,EAAE,MAAM,CAAC;CACvB;AAED;;;;;;;;;;;;GAYG;AACH,MAAM,WAAW,SAAS;IACxB,GAAG,CACD,GAAG,EAAE,MAAM,EACX,IAAI,EAAE,QAAQ,GAAG,MAAM,EACvB,IAAI,CAAC,EAAE,UAAU,GAChB,OAAO,CAAC,UAAU,CAAC,CAAC;IACvB,GAAG,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,aAAa,CAAC,CAAC;IACzC,MAAM,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,CAAC;IACtC,MAAM,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IACnC,YAAY,CAAC,CAAC,GAAG,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE,UAAU,GAAG,cAAc,GAAG,OAAO,CAAC,MAAM,CAAC,CAAC;IAChF,YAAY,CAAC,CAAC,GAAG,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE,cAAc,GAAG,OAAO,CAAC,MAAM,CAAC,CAAC;CACpE;AAED;;;;GAIG;AACH,qBAAa,iBAAkB,SAAQ,KAAK;IAE9B,QAAQ,CAAC,GAAG,EAAE,MAAM;IADhC,QAAQ,CAAC,IAAI,oBAAoB;gBACZ,GAAG,EAAE,MAAM;CAIjC"}
package/dist/ports.js ADDED
@@ -0,0 +1,17 @@
1
+ // Copyright Ninemind.ai 2026. All Rights Reserved.
2
+ // This file is licensed under the MIT License.
3
+ // License text available at https://opensource.org/license/mit/
4
+ /**
5
+ * Thrown by {@link FileStore.get} when no object exists at the key. The REST
6
+ * layer maps this to a 404 (it stays here so the port carries no HTTP/openapi
7
+ * dependency).
8
+ */
9
+ export class FileNotFoundError extends Error {
10
+ constructor(key) {
11
+ super(`No file stored at key '${key}'.`);
12
+ this.key = key;
13
+ this.code = 'file_not_found';
14
+ this.name = 'FileNotFoundError';
15
+ }
16
+ }
17
+ //# sourceMappingURL=ports.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"ports.js","sourceRoot":"","sources":["../src/ports.ts"],"names":[],"mappings":"AAAA,mDAAmD;AACnD,+CAA+C;AAC/C,gEAAgE;AAiEhE;;;;GAIG;AACH,MAAM,OAAO,iBAAkB,SAAQ,KAAK;IAE1C,YAAqB,GAAW;QAC9B,KAAK,CAAC,0BAA0B,GAAG,IAAI,CAAC,CAAC;QADtB,QAAG,GAAH,GAAG,CAAQ;QADvB,SAAI,GAAG,gBAAgB,CAAC;QAG/B,IAAI,CAAC,IAAI,GAAG,mBAAmB,CAAC;IAClC,CAAC;CACF"}
@@ -0,0 +1,9 @@
1
+ import { type FileStore } from '../ports.js';
2
+ /**
3
+ * The {@link FileStore} contract, as a reusable suite. Every adapter
4
+ * (in-memory, S3, …) runs it to prove it honors the port. `makeStore` returns
5
+ * a fresh (or shared) store; keys are unique per assertion so a real bucket is
6
+ * safe.
7
+ */
8
+ export declare function runFileStoreConformance(label: string, makeStore: () => FileStore | Promise<FileStore>): void;
9
+ //# sourceMappingURL=conformance.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"conformance.d.ts","sourceRoot":"","sources":["../../src/testing/conformance.ts"],"names":[],"mappings":"AAMA,OAAO,EAAoB,KAAK,SAAS,EAAC,MAAM,aAAa,CAAC;AAc9D;;;;;GAKG;AACH,wBAAgB,uBAAuB,CACrC,KAAK,EAAE,MAAM,EACb,SAAS,EAAE,MAAM,SAAS,GAAG,OAAO,CAAC,SAAS,CAAC,GAC9C,IAAI,CA0CN"}
@@ -0,0 +1,62 @@
1
+ // Copyright Ninemind.ai 2026. All Rights Reserved.
2
+ // This file is licensed under the MIT License.
3
+ // License text available at https://opensource.org/license/mit/
4
+ import { Readable } from 'node:stream';
5
+ import { describe, it, expect } from 'vitest';
6
+ import { FileNotFoundError } from '../ports.js';
7
+ async function drain(stream) {
8
+ const chunks = [];
9
+ for await (const c of stream)
10
+ chunks.push(Buffer.isBuffer(c) ? c : Buffer.from(c));
11
+ return Buffer.concat(chunks);
12
+ }
13
+ let n = 0;
14
+ /** A unique key per call so a shared backing bucket stays collision-free. */
15
+ function uniqueKey(tag) {
16
+ return `agentback-conformance/${tag}-${process.pid}-${++n}`;
17
+ }
18
+ /**
19
+ * The {@link FileStore} contract, as a reusable suite. Every adapter
20
+ * (in-memory, S3, …) runs it to prove it honors the port. `makeStore` returns
21
+ * a fresh (or shared) store; keys are unique per assertion so a real bucket is
22
+ * safe.
23
+ */
24
+ export function runFileStoreConformance(label, makeStore) {
25
+ describe(`FileStore conformance: ${label}`, () => {
26
+ it('put → get round-trips bytes, contentType, size', async () => {
27
+ const store = await makeStore();
28
+ const key = uniqueKey('roundtrip');
29
+ const put = await store.put(key, Buffer.from('payload'), {
30
+ contentType: 'application/octet-stream',
31
+ filename: 'p.bin',
32
+ });
33
+ expect(put.size).toBe(7);
34
+ const got = await store.get(key);
35
+ expect((await drain(got.stream)).toString()).toBe('payload');
36
+ expect(got.contentType).toBe('application/octet-stream');
37
+ expect(got.size).toBe(7);
38
+ await store.delete(key);
39
+ });
40
+ it('accepts a Readable body', async () => {
41
+ const store = await makeStore();
42
+ const key = uniqueKey('stream');
43
+ await store.put(key, Readable.from([Buffer.from('ab'), Buffer.from('c')]));
44
+ expect((await drain((await store.get(key)).stream)).toString()).toBe('abc');
45
+ await store.delete(key);
46
+ });
47
+ it('exists reflects presence; delete removes', async () => {
48
+ const store = await makeStore();
49
+ const key = uniqueKey('lifecycle');
50
+ expect(await store.exists(key)).toBe(false);
51
+ await store.put(key, Buffer.from('x'));
52
+ expect(await store.exists(key)).toBe(true);
53
+ await store.delete(key);
54
+ expect(await store.exists(key)).toBe(false);
55
+ });
56
+ it('get of a missing key throws FileNotFoundError', async () => {
57
+ const store = await makeStore();
58
+ await expect(store.get(uniqueKey('missing'))).rejects.toBeInstanceOf(FileNotFoundError);
59
+ });
60
+ });
61
+ }
62
+ //# sourceMappingURL=conformance.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"conformance.js","sourceRoot":"","sources":["../../src/testing/conformance.ts"],"names":[],"mappings":"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"}
package/package.json ADDED
@@ -0,0 +1,40 @@
1
+ {
2
+ "name": "@agentback/files",
3
+ "version": "0.3.0",
4
+ "description": "Transport-agnostic file storage port (FileStore) with an in-memory adapter",
5
+ "type": "module",
6
+ "main": "dist/index.js",
7
+ "types": "dist/index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./dist/index.d.ts",
11
+ "import": "./dist/index.js"
12
+ },
13
+ "./testing": {
14
+ "types": "./dist/testing/conformance.d.ts",
15
+ "import": "./dist/testing/conformance.js"
16
+ }
17
+ },
18
+ "files": [
19
+ "dist",
20
+ "src"
21
+ ],
22
+ "dependencies": {
23
+ "tslib": "^2.8.1",
24
+ "@agentback/core": "~0.3.0"
25
+ },
26
+ "devDependencies": {
27
+ "vitest": "~4.1.8"
28
+ },
29
+ "engines": {
30
+ "node": ">=22.13"
31
+ },
32
+ "license": "MIT",
33
+ "publishConfig": {
34
+ "access": "public"
35
+ },
36
+ "scripts": {
37
+ "build": "tsc -b",
38
+ "clean": "rm -rf dist *.tsbuildinfo"
39
+ }
40
+ }
@@ -0,0 +1,45 @@
1
+ // Copyright Ninemind.ai 2026. All Rights Reserved.
2
+ // This file is licensed under the MIT License.
3
+ // License text available at https://opensource.org/license/mit/
4
+
5
+ import {afterAll, describe, expect, it} from 'vitest';
6
+ import {existsSync, mkdtempSync, readFileSync, rmSync} from 'node:fs';
7
+ import {tmpdir} from 'node:os';
8
+ import {join} from 'node:path';
9
+ import {FsFileStore} from '../../index.js';
10
+ import {runFileStoreConformance} from '../../testing/conformance.js';
11
+
12
+ const root = mkdtempSync(join(tmpdir(), 'agentback-fs-'));
13
+ afterAll(() => rmSync(root, {recursive: true, force: true}));
14
+
15
+ // Runs unconditionally (no external service needed) — unlike the S3 adapter.
16
+ runFileStoreConformance('FsFileStore', () => new FsFileStore({baseDir: root}));
17
+
18
+ describe('FsFileStore specifics', () => {
19
+ it('writes the bytes to disk under baseDir', async () => {
20
+ const dir = mkdtempSync(join(tmpdir(), 'agentback-fs-spec-'));
21
+ const store = new FsFileStore({baseDir: dir});
22
+ await store.put('k1', Buffer.from('on disk'), {contentType: 'text/plain'});
23
+ expect(existsSync(join(dir, 'k1'))).toBe(true);
24
+ expect(readFileSync(join(dir, 'k1'), 'utf8')).toBe('on disk');
25
+ rmSync(dir, {recursive: true, force: true});
26
+ });
27
+
28
+ it('delete removes both the data and its metadata sidecar', async () => {
29
+ const dir = mkdtempSync(join(tmpdir(), 'agentback-fs-spec-'));
30
+ const store = new FsFileStore({baseDir: dir});
31
+ await store.put('k2', Buffer.from('x'), {filename: 'x.bin'});
32
+ expect(existsSync(join(dir, 'k2.meta.json'))).toBe(true);
33
+ await store.delete('k2');
34
+ expect(existsSync(join(dir, 'k2'))).toBe(false);
35
+ expect(existsSync(join(dir, 'k2.meta.json'))).toBe(false);
36
+ rmSync(dir, {recursive: true, force: true});
37
+ });
38
+
39
+ it('rejects a key that escapes the base directory', async () => {
40
+ const store = new FsFileStore({baseDir: root});
41
+ await expect(store.put('../escape', Buffer.from('no'))).rejects.toThrow(
42
+ /escapes the base directory/,
43
+ );
44
+ });
45
+ });
@@ -0,0 +1,74 @@
1
+ // Copyright Ninemind.ai 2026. All Rights Reserved.
2
+ // This file is licensed under the MIT License.
3
+ // License text available at https://opensource.org/license/mit/
4
+
5
+ import {Readable} from 'node:stream';
6
+ import {describe, it, expect, beforeEach} from 'vitest';
7
+ import {InMemoryFileStore, FileNotFoundError, type FileStore} from '../../index.js';
8
+ import {runFileStoreConformance} from '../../testing/conformance.js';
9
+
10
+ runFileStoreConformance('InMemoryFileStore', () => new InMemoryFileStore());
11
+
12
+ /** Drain a RetrievedFile's stream back to a Buffer for assertions. */
13
+ async function drain(stream: Readable): Promise<Buffer> {
14
+ const chunks: Buffer[] = [];
15
+ for await (const c of stream) chunks.push(Buffer.isBuffer(c) ? c : Buffer.from(c));
16
+ return Buffer.concat(chunks);
17
+ }
18
+
19
+ describe('InMemoryFileStore', () => {
20
+ let store: InMemoryFileStore;
21
+ beforeEach(() => {
22
+ store = new InMemoryFileStore();
23
+ });
24
+
25
+ it('round-trips a Buffer with its metadata', async () => {
26
+ const put = await store.put('k1', Buffer.from('hello'), {
27
+ contentType: 'text/plain',
28
+ filename: 'greeting.txt',
29
+ metadata: {owner: 'u1'},
30
+ });
31
+ expect(put).toEqual({key: 'k1', size: 5, contentType: 'text/plain'});
32
+
33
+ const got = await store.get('k1');
34
+ expect(got.size).toBe(5);
35
+ expect(got.contentType).toBe('text/plain');
36
+ expect(got.filename).toBe('greeting.txt');
37
+ expect(got.metadata).toEqual({owner: 'u1'});
38
+ expect((await drain(got.stream)).toString()).toBe('hello');
39
+ });
40
+
41
+ it('accepts a Readable body and reports the streamed size', async () => {
42
+ const body = Readable.from([Buffer.from('ab'), Buffer.from('cde')]);
43
+ const put = await store.put('k2', body);
44
+ expect(put.size).toBe(5);
45
+ expect((await drain((await store.get('k2')).stream)).toString()).toBe('abcde');
46
+ });
47
+
48
+ it('get() throws FileNotFoundError for a missing key', async () => {
49
+ await expect(store.get('nope')).rejects.toBeInstanceOf(FileNotFoundError);
50
+ await expect(store.get('nope')).rejects.toMatchObject({
51
+ code: 'file_not_found',
52
+ key: 'nope',
53
+ });
54
+ });
55
+
56
+ it('exists() reflects presence; delete() removes', async () => {
57
+ expect(await store.exists('k3')).toBe(false);
58
+ await store.put('k3', Buffer.from('x'));
59
+ expect(await store.exists('k3')).toBe(true);
60
+ expect(store.count).toBe(1);
61
+
62
+ await store.delete('k3');
63
+ expect(await store.exists('k3')).toBe(false);
64
+ expect(store.count).toBe(0);
65
+ // delete of a missing key is a no-op (idempotent)
66
+ await expect(store.delete('k3')).resolves.toBeUndefined();
67
+ });
68
+
69
+ it('does not implement the optional presigned hooks', () => {
70
+ const fs: FileStore = store;
71
+ expect(fs.presignedPut).toBeUndefined();
72
+ expect(fs.presignedGet).toBeUndefined();
73
+ });
74
+ });
@@ -0,0 +1,123 @@
1
+ // Copyright Ninemind.ai 2026. All Rights Reserved.
2
+ // This file is licensed under the MIT License.
3
+ // License text available at https://opensource.org/license/mit/
4
+
5
+ import {createReadStream, createWriteStream} from 'node:fs';
6
+ import {access, mkdir, readFile, rm, stat, writeFile} from 'node:fs/promises';
7
+ import {dirname, resolve, sep} from 'node:path';
8
+ import {Readable} from 'node:stream';
9
+ import {pipeline} from 'node:stream/promises';
10
+ import {
11
+ FileNotFoundError,
12
+ type FileStore,
13
+ type PutOptions,
14
+ type RetrievedFile,
15
+ type StoredFile,
16
+ } from '../ports.js';
17
+
18
+ /** Per-object metadata persisted next to the bytes as `<key>.meta.json`. */
19
+ interface Sidecar {
20
+ contentType?: string;
21
+ filename?: string;
22
+ metadata?: Record<string, string>;
23
+ }
24
+
25
+ export interface FsFileStoreOptions {
26
+ /** Directory under which all objects live. Created on demand. */
27
+ baseDir: string;
28
+ /** Optional key prefix (a subdirectory) namespacing every object. */
29
+ keyPrefix?: string;
30
+ }
31
+
32
+ /**
33
+ * Local-filesystem {@link FileStore}. Streams object bytes to/from
34
+ * `<baseDir>/<key>` and keeps a small `<key>.meta.json` sidecar for
35
+ * contentType/filename/metadata. Good for single-node deploys, self-hosting,
36
+ * and dev-with-persistence — between {@link InMemoryFileStore} and an
37
+ * `S3FileStore`.
38
+ *
39
+ * Every key is resolved under `baseDir` and rejected if it escapes (defense in
40
+ * depth — REST keys are already server-generated UUIDs, never client paths).
41
+ */
42
+ export class FsFileStore implements FileStore {
43
+ private readonly baseDir: string;
44
+ private readonly prefix: string;
45
+
46
+ constructor(opts: FsFileStoreOptions) {
47
+ this.baseDir = resolve(opts.baseDir);
48
+ this.prefix = opts.keyPrefix ?? '';
49
+ }
50
+
51
+ /** Absolute data path for a key, guaranteed to stay within `baseDir`. */
52
+ private pathFor(key: string): string {
53
+ const full = resolve(this.baseDir, this.prefix + key);
54
+ if (full !== this.baseDir && !full.startsWith(this.baseDir + sep)) {
55
+ throw new Error(`Refusing a key that escapes the base directory: ${key}`);
56
+ }
57
+ return full;
58
+ }
59
+
60
+ async put(
61
+ key: string,
62
+ body: Readable | Buffer,
63
+ opts: PutOptions = {},
64
+ ): Promise<StoredFile> {
65
+ const p = this.pathFor(key);
66
+ await mkdir(dirname(p), {recursive: true});
67
+ const source = Buffer.isBuffer(body) ? Readable.from(body) : body;
68
+ await pipeline(source, createWriteStream(p));
69
+ const sidecar: Sidecar = {
70
+ ...(opts.contentType ? {contentType: opts.contentType} : {}),
71
+ ...(opts.filename ? {filename: opts.filename} : {}),
72
+ ...(opts.metadata ? {metadata: opts.metadata} : {}),
73
+ };
74
+ await writeFile(`${p}.meta.json`, JSON.stringify(sidecar));
75
+ const {size} = await stat(p);
76
+ return {key, size, contentType: opts.contentType};
77
+ }
78
+
79
+ async get(key: string): Promise<RetrievedFile> {
80
+ const p = this.pathFor(key);
81
+ let size: number;
82
+ try {
83
+ ({size} = await stat(p));
84
+ } catch (err) {
85
+ if ((err as NodeJS.ErrnoException).code === 'ENOENT') {
86
+ throw new FileNotFoundError(key);
87
+ }
88
+ throw err;
89
+ }
90
+ const sidecar = await this.readSidecar(p);
91
+ return {
92
+ key,
93
+ stream: createReadStream(p),
94
+ size,
95
+ contentType: sidecar.contentType,
96
+ filename: sidecar.filename,
97
+ metadata: sidecar.metadata,
98
+ };
99
+ }
100
+
101
+ async exists(key: string): Promise<boolean> {
102
+ try {
103
+ await access(this.pathFor(key));
104
+ return true;
105
+ } catch {
106
+ return false;
107
+ }
108
+ }
109
+
110
+ async delete(key: string): Promise<void> {
111
+ const p = this.pathFor(key);
112
+ await rm(p, {force: true});
113
+ await rm(`${p}.meta.json`, {force: true});
114
+ }
115
+
116
+ private async readSidecar(p: string): Promise<Sidecar> {
117
+ try {
118
+ return JSON.parse(await readFile(`${p}.meta.json`, 'utf8')) as Sidecar;
119
+ } catch {
120
+ return {};
121
+ }
122
+ }
123
+ }
@@ -0,0 +1,80 @@
1
+ // Copyright Ninemind.ai 2026. All Rights Reserved.
2
+ // This file is licensed under the MIT License.
3
+ // License text available at https://opensource.org/license/mit/
4
+
5
+ import {Readable} from 'node:stream';
6
+ import {
7
+ FileNotFoundError,
8
+ type FileStore,
9
+ type PutOptions,
10
+ type RetrievedFile,
11
+ type StoredFile,
12
+ } from '../ports.js';
13
+
14
+ interface Entry {
15
+ buffer: Buffer;
16
+ contentType?: string;
17
+ filename?: string;
18
+ metadata?: Record<string, string>;
19
+ }
20
+
21
+ /**
22
+ * In-memory {@link FileStore} for tests and local dev. Each object is buffered
23
+ * whole in a `Map`, so it is NOT for production — no streaming-to-disk and no
24
+ * size cap (a large upload is held in memory). Use an `S3FileStore` (or similar
25
+ * streaming adapter) in production.
26
+ */
27
+ export class InMemoryFileStore implements FileStore {
28
+ private readonly store = new Map<string, Entry>();
29
+
30
+ async put(
31
+ key: string,
32
+ body: Readable | Buffer,
33
+ opts: PutOptions = {},
34
+ ): Promise<StoredFile> {
35
+ const buffer = await toBuffer(body);
36
+ this.store.set(key, {
37
+ buffer,
38
+ contentType: opts.contentType,
39
+ filename: opts.filename,
40
+ metadata: opts.metadata,
41
+ });
42
+ return {key, size: buffer.byteLength, contentType: opts.contentType};
43
+ }
44
+
45
+ async get(key: string): Promise<RetrievedFile> {
46
+ const e = this.store.get(key);
47
+ if (!e) throw new FileNotFoundError(key);
48
+ return {
49
+ key,
50
+ stream: Readable.from(e.buffer),
51
+ size: e.buffer.byteLength,
52
+ contentType: e.contentType,
53
+ filename: e.filename,
54
+ metadata: e.metadata,
55
+ };
56
+ }
57
+
58
+ async exists(key: string): Promise<boolean> {
59
+ return this.store.has(key);
60
+ }
61
+
62
+ async delete(key: string): Promise<void> {
63
+ this.store.delete(key);
64
+ }
65
+
66
+ /** Test helper: number of stored objects. */
67
+ get count(): number {
68
+ return this.store.size;
69
+ }
70
+ }
71
+
72
+ /** Collect a Buffer or a Readable into a single Buffer. */
73
+ async function toBuffer(body: Readable | Buffer): Promise<Buffer> {
74
+ if (Buffer.isBuffer(body)) return body;
75
+ const chunks: Buffer[] = [];
76
+ for await (const chunk of body) {
77
+ chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk as Uint8Array));
78
+ }
79
+ return Buffer.concat(chunks);
80
+ }
package/src/index.ts ADDED
@@ -0,0 +1,8 @@
1
+ // Copyright Ninemind.ai 2026. All Rights Reserved.
2
+ // This file is licensed under the MIT License.
3
+ // License text available at https://opensource.org/license/mit/
4
+
5
+ export * from './ports.js';
6
+ export * from './keys.js';
7
+ export {InMemoryFileStore} from './in-memory/in-memory-file-store.js';
8
+ export {FsFileStore, type FsFileStoreOptions} from './fs/fs-file-store.js';
package/src/keys.ts ADDED
@@ -0,0 +1,9 @@
1
+ // Copyright Ninemind.ai 2026. All Rights Reserved.
2
+ // This file is licensed under the MIT License.
3
+ // License text available at https://opensource.org/license/mit/
4
+
5
+ import {BindingKey} from '@agentback/core';
6
+ import type {FileStore} from './ports.js';
7
+
8
+ /** DI key for the active {@link FileStore}. Bind an adapter; inject to use. */
9
+ export const FILE_STORE = BindingKey.create<FileStore>('files.store');
package/src/ports.ts ADDED
@@ -0,0 +1,79 @@
1
+ // Copyright Ninemind.ai 2026. All Rights Reserved.
2
+ // This file is licensed under the MIT License.
3
+ // License text available at https://opensource.org/license/mit/
4
+
5
+ import type {Readable} from 'node:stream';
6
+
7
+ /** Options accepted when storing a file. */
8
+ export interface PutOptions {
9
+ /** MIME type recorded alongside the bytes (echoed on `get`). */
10
+ contentType?: string;
11
+ /** Original filename, surfaced on download as `Content-Disposition`. */
12
+ filename?: string;
13
+ /** Arbitrary string metadata persisted with the object. */
14
+ metadata?: Record<string, string>;
15
+ }
16
+
17
+ /** Result of a successful {@link FileStore.put}. */
18
+ export interface StoredFile {
19
+ key: string;
20
+ size: number;
21
+ contentType?: string;
22
+ /** Backend entity tag (e.g. S3 ETag), when the adapter provides one. */
23
+ etag?: string;
24
+ }
25
+
26
+ /** A retrieved file: a readable byte stream plus the metadata stored with it. */
27
+ export interface RetrievedFile {
28
+ key: string;
29
+ stream: Readable;
30
+ size: number;
31
+ contentType?: string;
32
+ filename?: string;
33
+ metadata?: Record<string, string>;
34
+ }
35
+
36
+ /** Options for the optional presigned-URL hooks. */
37
+ export interface PresignOptions {
38
+ /** URL lifetime in seconds. Adapter chooses a default when omitted. */
39
+ expiresInSec?: number;
40
+ }
41
+
42
+ /**
43
+ * Transport-agnostic file storage seam. Adapters (in-memory, S3, …) implement
44
+ * it; REST handlers stream uploads in via {@link put} and downloads out via
45
+ * {@link get}. Bind an implementation at {@link FILE_STORE} and inject it.
46
+ *
47
+ * Keys are opaque to the store — callers MUST generate them server-side (e.g. a
48
+ * UUID); never pass a client-controlled path, or an S3 adapter is open to
49
+ * key-traversal. `get` throws {@link FileNotFoundError} for a missing key.
50
+ *
51
+ * `presignedPut`/`presignedGet` are optional: a server-proxied adapter omits
52
+ * them; a direct-to-storage adapter implements them. They are declared here so
53
+ * a future presigned flow is an additive capability, not a breaking change.
54
+ */
55
+ export interface FileStore {
56
+ put(
57
+ key: string,
58
+ body: Readable | Buffer,
59
+ opts?: PutOptions,
60
+ ): Promise<StoredFile>;
61
+ get(key: string): Promise<RetrievedFile>;
62
+ exists(key: string): Promise<boolean>;
63
+ delete(key: string): Promise<void>;
64
+ presignedPut?(key: string, opts?: PutOptions & PresignOptions): Promise<string>;
65
+ presignedGet?(key: string, opts?: PresignOptions): Promise<string>;
66
+ }
67
+
68
+ /**
69
+ * Thrown by {@link FileStore.get} when no object exists at the key. The REST
70
+ * layer maps this to a 404 (it stays here so the port carries no HTTP/openapi
71
+ * dependency).
72
+ */
73
+ export class FileNotFoundError extends Error {
74
+ readonly code = 'file_not_found';
75
+ constructor(readonly key: string) {
76
+ super(`No file stored at key '${key}'.`);
77
+ this.name = 'FileNotFoundError';
78
+ }
79
+ }
@@ -0,0 +1,72 @@
1
+ // Copyright Ninemind.ai 2026. All Rights Reserved.
2
+ // This file is licensed under the MIT License.
3
+ // License text available at https://opensource.org/license/mit/
4
+
5
+ import {Readable} from 'node:stream';
6
+ import {describe, it, expect} from 'vitest';
7
+ import {FileNotFoundError, type FileStore} from '../ports.js';
8
+
9
+ async function drain(stream: Readable): Promise<Buffer> {
10
+ const chunks: Buffer[] = [];
11
+ for await (const c of stream) chunks.push(Buffer.isBuffer(c) ? c : Buffer.from(c));
12
+ return Buffer.concat(chunks);
13
+ }
14
+
15
+ let n = 0;
16
+ /** A unique key per call so a shared backing bucket stays collision-free. */
17
+ function uniqueKey(tag: string): string {
18
+ return `agentback-conformance/${tag}-${process.pid}-${++n}`;
19
+ }
20
+
21
+ /**
22
+ * The {@link FileStore} contract, as a reusable suite. Every adapter
23
+ * (in-memory, S3, …) runs it to prove it honors the port. `makeStore` returns
24
+ * a fresh (or shared) store; keys are unique per assertion so a real bucket is
25
+ * safe.
26
+ */
27
+ export function runFileStoreConformance(
28
+ label: string,
29
+ makeStore: () => FileStore | Promise<FileStore>,
30
+ ): void {
31
+ describe(`FileStore conformance: ${label}`, () => {
32
+ it('put → get round-trips bytes, contentType, size', async () => {
33
+ const store = await makeStore();
34
+ const key = uniqueKey('roundtrip');
35
+ const put = await store.put(key, Buffer.from('payload'), {
36
+ contentType: 'application/octet-stream',
37
+ filename: 'p.bin',
38
+ });
39
+ expect(put.size).toBe(7);
40
+ const got = await store.get(key);
41
+ expect((await drain(got.stream)).toString()).toBe('payload');
42
+ expect(got.contentType).toBe('application/octet-stream');
43
+ expect(got.size).toBe(7);
44
+ await store.delete(key);
45
+ });
46
+
47
+ it('accepts a Readable body', async () => {
48
+ const store = await makeStore();
49
+ const key = uniqueKey('stream');
50
+ await store.put(key, Readable.from([Buffer.from('ab'), Buffer.from('c')]));
51
+ expect((await drain((await store.get(key)).stream)).toString()).toBe('abc');
52
+ await store.delete(key);
53
+ });
54
+
55
+ it('exists reflects presence; delete removes', async () => {
56
+ const store = await makeStore();
57
+ const key = uniqueKey('lifecycle');
58
+ expect(await store.exists(key)).toBe(false);
59
+ await store.put(key, Buffer.from('x'));
60
+ expect(await store.exists(key)).toBe(true);
61
+ await store.delete(key);
62
+ expect(await store.exists(key)).toBe(false);
63
+ });
64
+
65
+ it('get of a missing key throws FileNotFoundError', async () => {
66
+ const store = await makeStore();
67
+ await expect(store.get(uniqueKey('missing'))).rejects.toBeInstanceOf(
68
+ FileNotFoundError,
69
+ );
70
+ });
71
+ });
72
+ }