@basaltkit/files 1.1.0 → 1.2.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.
package/README.md CHANGED
@@ -81,17 +81,30 @@ await files.markScanned(id, { clean: true }, tenantId) // emits file:scanned
81
81
 
82
82
  ## API reference
83
83
 
84
- ### `filesPlugin(options)`
84
+ ### Options reference
85
85
 
86
- | Option | Type | Description |
87
- |---|---|---|
88
- | `disk` | `Disk \| string` | A `Disk` instance or the name of a `@basaltkit/storage` disk. |
89
- | `validate` | `{ maxSize?, allowedTypes? }` | Size limit and allowed types (`image/*` accepts wildcards). |
90
- | `maxTotalBytes` | `number` | Total quota per tenant. |
91
- | `checkQuota` | `(tenantId, size) => void` | Custom quota check (e.g. hook into `@basaltkit/subscriptions`). Throw to reject. |
92
- | `store` | `FileStore` | Metadata persistence. Default: in-memory. |
86
+ `filesPlugin(options)` registers a `Files` singleton under the `FILES` token:
87
+
88
+ | Option | Type | Default | Purpose |
89
+ |---|---|---|---|
90
+ | `disk` | `Disk \| string` | (required) | A `Disk` instance, or the name of a disk declared in `storagePlugin`. A string is resolved from the `STORAGE` token at first use. |
91
+ | `validate` | `FileValidation` | `{ maxSize: DEFAULT_MAX_FILE_SIZE }` | Size limit and content-type allowlist. See below the size cap applies **even if you pass nothing**. |
92
+ | `maxTotalBytes` | `number` | (no quota) | Built-in per-tenant quota: rejects an upload when the tenant's stored bytes plus this file would exceed it. Costs one `store.totalSize()` read per upload. |
93
+ | `checkQuota` | `(tenantId, size) => Promise<void> \| void` | — | Custom quota check, run after the built-in one. Throw to reject — this is where you wire `@basaltkit/subscriptions` plan limits. |
94
+ | `store` | `FileStore` | `MemoryFileStore` | Where file metadata lives. In-memory means records vanish on restart while the bytes stay in storage — implement `FileStore` over your database in production. |
95
+
96
+ `FileValidation`:
97
+
98
+ | Option | Type | Default | Purpose |
99
+ |---|---|---|---|
100
+ | `maxSize` | `number` | `DEFAULT_MAX_FILE_SIZE` = **25 MiB** (`26214400`) | Per-file byte cap. Secure by default: uploads are capped even when you configure nothing. Raise it, or pass `Infinity` to disable. |
101
+ | `allowedTypes` | `string[]` | — (anything) | Content-type allowlist. Supports trailing wildcards: `'image/*'` matches `image/png`. |
93
102
 
94
- Registers the `FILES` token.
103
+ `DEFAULT_MAX_FILE_SIZE` is exported, so you can express a limit relative to it.
104
+
105
+ > The cap applies to the buffer you hand to `upload()`. Your HTTP adapter's own
106
+ > body limit still applies first, and `@basaltkit/storage` itself caps nothing
107
+ > unless you pass `maxBytes` per `put()`.
95
108
 
96
109
  ### `class Files`
97
110
 
@@ -99,14 +112,46 @@ Registers the `FILES` token.
99
112
  |---|---|
100
113
  | `upload(content, input)` | Validates, enforces quota, stores, records metadata, emits `file:uploaded`. |
101
114
  | `download(id, tenantId?)` | `{ record, content }`. |
102
- | `temporaryUrl(id, expiresIn, tenantId?)` | Signed URL. |
115
+ | `temporaryUrl(id, expiresIn, tenantId?, options?)` | Signed URL. Served `Content-Disposition: attachment` by default; pass `{ disposition: 'inline' }` only when top-level rendering is deliberate — an uploaded HTML/SVG file served inline is stored XSS on the storage origin. Embedded `<img>`/`<video>` uses render regardless. |
103
116
  | `get(id, tenantId?)` · `list(tenantId?)` | Metadata. |
104
117
  | `delete(id, tenantId?)` | Deletes bytes + metadata; emits `file:deleted`. |
105
118
  | `markScanned(id, result, tenantId?)` | Marks as scanned; emits `file:scanned`. |
106
119
 
107
120
  Without an explicit `tenantId`, it uses `ctx().tenant.id`; without a tenant, it throws `FileTenantRequiredError`. Storage access runs in the resolved tenant's context, so files stay isolated even from a background job.
108
121
 
109
- Errors: `FileTooLargeError` (413), `FileTypeNotAllowedError` (415), `StorageQuotaExceededError` (402), `FileNotFoundError` (404).
122
+ ### Failure modes
123
+
124
+ | Error | Code | HTTP | When |
125
+ |---|---|---|---|
126
+ | `FileTooLargeError` | `FILE_TOO_LARGE` | 413 | The buffer exceeds `validate.maxSize` — 25 MiB when you configured nothing. |
127
+ | `FileTypeNotAllowedError` | `FILE_TYPE_NOT_ALLOWED` | 415 | `contentType` doesn't match `validate.allowedTypes`. |
128
+ | `StorageQuotaExceededError` | `FILE_QUOTA_EXCEEDED` | 402 | The tenant's total stored bytes plus this upload would pass `maxTotalBytes`. |
129
+ | `FileNotFoundError` | `FILE_NOT_FOUND` | 404 | `download` / `temporaryUrl` / `markScanned` for an id absent from this tenant's metadata store. |
130
+ | `FileTenantRequiredError` | `FILE_TENANT_REQUIRED` | 400 | No `tenantId` argument and no `ctx().tenant` — every operation is tenant-scoped and fails closed rather than querying unscoped. |
131
+
132
+ All extend `BasaltError` and declare a `status`, so the adapters map them to the
133
+ HTTP code above with the real error `code` in the body. Errors thrown by the
134
+ underlying disk (`STORAGE_*`) do **not** — they surface as 500 `INTERNAL_ERROR`.
135
+
136
+ - **`FILE_NOT_FOUND` for a file that exists in the bucket** — the metadata
137
+ record is gone, not the bytes. `MemoryFileStore` loses everything on restart;
138
+ wire a durable `FileStore`.
139
+ - **`FILE_TOO_LARGE` at exactly 25 MiB** — that's the default, not your adapter.
140
+ Set `validate: { maxSize: … }`.
141
+ - **`FILE_TENANT_REQUIRED` inside a queue job** — jobs don't inherit the request
142
+ context. Pass `tenantId` explicitly, or run the job body inside
143
+ `tenancy.run(tenantId, …)`.
144
+
145
+ ### Hooks & events
146
+
147
+ | Hook | Payload | When |
148
+ |---|---|---|
149
+ | `file:uploaded` | `{ file: FileRecord }` | After the bytes are written and the metadata recorded. |
150
+ | `file:deleted` | `{ tenantId: string; id: string }` | After the bytes and the record are removed. |
151
+ | `file:scanned` | `{ file: FileRecord }` | After `markScanned()` records an out-of-band scan result. |
152
+
153
+ They are declared on `BasaltHooks`, so `hooks.on('file:uploaded', …)` is fully
154
+ typed.
110
155
 
111
156
  ## How it connects to other modules
112
157
 
@@ -114,3 +159,5 @@ Errors: `FileTooLargeError` (413), `FileTypeNotAllowedError` (415), `StorageQuot
114
159
  - **`@basaltkit/subscriptions`** — hook `checkQuota` into `features(tenant).consume(...)` for plan-based quotas.
115
160
  - **`@basaltkit/queue`** — processes `file:uploaded` outside the request (antivirus, thumbnails).
116
161
  - **`@basaltkit/tenancy`** — supplies the tenant from the context.
162
+
163
+ Guides: [Files & uploads](/guide/files) · [Storage](/guide/storage) · [Queues](/guide/queues).
package/dist/files.d.ts CHANGED
@@ -48,6 +48,12 @@ export interface UploadInput {
48
48
  uploadedBy?: string;
49
49
  metadata?: Record<string, unknown>;
50
50
  }
51
+ /**
52
+ * Store key every record is filed under when the app has no tenancy at all.
53
+ * The {@link FileStore} contract is tenant-keyed, so a single-tenant app still
54
+ * needs one stable key — it just shouldn't have to invent it.
55
+ */
56
+ export declare const SINGLE_TENANT_SCOPE = "default";
51
57
  /**
52
58
  * Upload pipeline over a storage {@link Disk}: validates size/type, enforces a
53
59
  * per-tenant quota, writes the bytes, records metadata, and emits hooks. Every
@@ -55,6 +61,13 @@ export interface UploadInput {
55
61
  * context so files are isolated whether called from a request or a job.
56
62
  */
57
63
  export declare class Files {
64
+ /**
65
+ * Whether the host app registered `@basaltkit/tenancy`. `filesPlugin` wires
66
+ * this to the container's `'tenancy:active'` metadata marker — a signal,
67
+ * not an import, so this generic package never depends on the opt-in SaaS
68
+ * layer. Defaults to `false` (single-tenant).
69
+ */
70
+ private readonly tenancyActive;
58
71
  private readonly disk;
59
72
  private readonly store;
60
73
  private readonly hooks;
@@ -62,7 +75,14 @@ export declare class Files {
62
75
  private readonly maxTotalBytes;
63
76
  private readonly checkQuota;
64
77
  private readonly now;
65
- constructor(options: FilesOptions);
78
+ constructor(options: FilesOptions,
79
+ /**
80
+ * Whether the host app registered `@basaltkit/tenancy`. `filesPlugin` wires
81
+ * this to the container's `'tenancy:active'` metadata marker — a signal,
82
+ * not an import, so this generic package never depends on the opt-in SaaS
83
+ * layer. Defaults to `false` (single-tenant).
84
+ */
85
+ tenancyActive?: () => boolean);
66
86
  upload(content: Buffer, input: UploadInput): Promise<FileRecord>;
67
87
  get(id: string, tenantId?: string): Promise<FileRecord | null>;
68
88
  list(tenantId?: string): Promise<FileRecord[]>;
@@ -87,7 +107,20 @@ export declare class Files {
87
107
  }, tenantId?: string): Promise<FileRecord>;
88
108
  private validate;
89
109
  private enforceQuota;
110
+ /**
111
+ * The tenant a call is scoped to, or `undefined` when the app has no tenancy.
112
+ *
113
+ * With `@basaltkit/tenancy` registered an unresolvable tenant is an error: an
114
+ * unscoped read or write would cross tenants. Without it there is no tenant
115
+ * dimension and nothing to cross.
116
+ */
90
117
  private tenant;
91
- /** Runs a storage op in the resolved tenant's context so the disk scopes correctly. */
118
+ /** The {@link FileStore} key: the tenant, or {@link SINGLE_TENANT_SCOPE}. */
119
+ private scope;
120
+ /**
121
+ * Runs a storage op in the resolved tenant's context so the disk scopes
122
+ * correctly. With no tenancy there is no tenant context to synthesize — the
123
+ * disk keeps its own (unscoped) default, so paths match plain `@basaltkit/storage`.
124
+ */
92
125
  private inTenant;
93
126
  }
package/dist/files.js CHANGED
@@ -36,6 +36,12 @@ export class FileTenantRequiredError extends BasaltError {
36
36
  }
37
37
  const matchesType = (contentType, allowed) => allowed.some((a) => a === contentType || (a.endsWith('/*') && contentType.startsWith(a.slice(0, -1))));
38
38
  const storagePath = (id) => `files/${id}`;
39
+ /**
40
+ * Store key every record is filed under when the app has no tenancy at all.
41
+ * The {@link FileStore} contract is tenant-keyed, so a single-tenant app still
42
+ * needs one stable key — it just shouldn't have to invent it.
43
+ */
44
+ export const SINGLE_TENANT_SCOPE = 'default';
39
45
  /**
40
46
  * Upload pipeline over a storage {@link Disk}: validates size/type, enforces a
41
47
  * per-tenant quota, writes the bytes, records metadata, and emits hooks. Every
@@ -43,6 +49,7 @@ const storagePath = (id) => `files/${id}`;
43
49
  * context so files are isolated whether called from a request or a job.
44
50
  */
45
51
  export class Files {
52
+ tenancyActive;
46
53
  disk;
47
54
  store;
48
55
  hooks;
@@ -50,7 +57,15 @@ export class Files {
50
57
  maxTotalBytes;
51
58
  checkQuota;
52
59
  now;
53
- constructor(options) {
60
+ constructor(options,
61
+ /**
62
+ * Whether the host app registered `@basaltkit/tenancy`. `filesPlugin` wires
63
+ * this to the container's `'tenancy:active'` metadata marker — a signal,
64
+ * not an import, so this generic package never depends on the opt-in SaaS
65
+ * layer. Defaults to `false` (single-tenant).
66
+ */
67
+ tenancyActive = () => false) {
68
+ this.tenancyActive = tenancyActive;
54
69
  this.disk = options.disk;
55
70
  this.store = options.store ?? new MemoryFileStore();
56
71
  this.hooks = options.hooks;
@@ -63,16 +78,17 @@ export class Files {
63
78
  }
64
79
  async upload(content, input) {
65
80
  const tenantId = this.tenant(input.tenantId);
81
+ const scope = tenantId ?? SINGLE_TENANT_SCOPE;
66
82
  const size = content.length;
67
83
  this.validate(input.contentType, size);
68
- await this.enforceQuota(tenantId, size);
84
+ await this.enforceQuota(scope, size);
69
85
  const id = randomUUID();
70
86
  const path = storagePath(id);
71
87
  const checksum = createHash('sha256').update(content).digest('hex');
72
88
  await this.inTenant(tenantId, () => this.disk.put(path, content, { contentType: input.contentType }));
73
89
  const record = {
74
90
  id,
75
- tenantId,
91
+ tenantId: scope,
76
92
  name: input.name,
77
93
  contentType: input.contentType,
78
94
  size,
@@ -87,14 +103,14 @@ export class Files {
87
103
  return record;
88
104
  }
89
105
  async get(id, tenantId) {
90
- return this.store.find(this.tenant(tenantId), id);
106
+ return this.store.find(this.scope(tenantId), id);
91
107
  }
92
108
  async list(tenantId) {
93
- return this.store.list(this.tenant(tenantId));
109
+ return this.store.list(this.scope(tenantId));
94
110
  }
95
111
  async download(id, tenantId) {
96
112
  const resolved = this.tenant(tenantId);
97
- const record = await this.store.find(resolved, id);
113
+ const record = await this.store.find(resolved ?? SINGLE_TENANT_SCOPE, id);
98
114
  if (!record)
99
115
  throw new FileNotFoundError();
100
116
  const content = await this.inTenant(resolved, () => this.disk.get(record.path));
@@ -108,23 +124,24 @@ export class Files {
108
124
  */
109
125
  async temporaryUrl(id, expiresIn, tenantId, options = {}) {
110
126
  const resolved = this.tenant(tenantId);
111
- const record = await this.store.find(resolved, id);
127
+ const record = await this.store.find(resolved ?? SINGLE_TENANT_SCOPE, id);
112
128
  if (!record)
113
129
  throw new FileNotFoundError();
114
130
  return this.inTenant(resolved, () => this.disk.temporaryUrl(record.path, expiresIn, options));
115
131
  }
116
132
  async delete(id, tenantId) {
117
133
  const resolved = this.tenant(tenantId);
118
- const record = await this.store.find(resolved, id);
134
+ const scope = resolved ?? SINGLE_TENANT_SCOPE;
135
+ const record = await this.store.find(scope, id);
119
136
  if (!record)
120
137
  return;
121
138
  await this.inTenant(resolved, () => this.disk.delete(record.path));
122
- await this.store.delete(resolved, id);
123
- await this.hooks?.emit('file:deleted', { tenantId: resolved, id });
139
+ await this.store.delete(scope, id);
140
+ await this.hooks?.emit('file:deleted', { tenantId: scope, id });
124
141
  }
125
142
  /** Records the result of an out-of-band scan (antivirus, moderation, …). */
126
143
  async markScanned(id, result, tenantId) {
127
- const resolved = this.tenant(tenantId);
144
+ const resolved = this.scope(tenantId);
128
145
  const record = await this.store.find(resolved, id);
129
146
  if (!record)
130
147
  throw new FileNotFoundError();
@@ -149,14 +166,33 @@ export class Files {
149
166
  }
150
167
  await this.checkQuota?.(tenantId, size);
151
168
  }
169
+ /**
170
+ * The tenant a call is scoped to, or `undefined` when the app has no tenancy.
171
+ *
172
+ * With `@basaltkit/tenancy` registered an unresolvable tenant is an error: an
173
+ * unscoped read or write would cross tenants. Without it there is no tenant
174
+ * dimension and nothing to cross.
175
+ */
152
176
  tenant(explicit) {
153
177
  const id = explicit ?? tryCtx()?.['tenant']?.id;
154
- if (!id)
178
+ if (id)
179
+ return id;
180
+ if (this.tenancyActive())
155
181
  throw new FileTenantRequiredError();
156
- return id;
182
+ return undefined;
157
183
  }
158
- /** Runs a storage op in the resolved tenant's context so the disk scopes correctly. */
184
+ /** The {@link FileStore} key: the tenant, or {@link SINGLE_TENANT_SCOPE}. */
185
+ scope(explicit) {
186
+ return this.tenant(explicit) ?? SINGLE_TENANT_SCOPE;
187
+ }
188
+ /**
189
+ * Runs a storage op in the resolved tenant's context so the disk scopes
190
+ * correctly. With no tenancy there is no tenant context to synthesize — the
191
+ * disk keeps its own (unscoped) default, so paths match plain `@basaltkit/storage`.
192
+ */
159
193
  inTenant(tenantId, fn) {
194
+ if (tenantId === undefined)
195
+ return fn();
160
196
  return runWithContext({ tenant: { id: tenantId } }, fn);
161
197
  }
162
198
  }
package/dist/index.d.ts CHANGED
@@ -1,3 +1,3 @@
1
- export { Files, DEFAULT_MAX_FILE_SIZE, FileTooLargeError, FileTypeNotAllowedError, StorageQuotaExceededError, FileNotFoundError, FileTenantRequiredError, type FilesOptions, type FileValidation, type UploadInput, } from './files.js';
1
+ export { Files, DEFAULT_MAX_FILE_SIZE, FileTooLargeError, FileTypeNotAllowedError, StorageQuotaExceededError, FileNotFoundError, FileTenantRequiredError, SINGLE_TENANT_SCOPE, type FilesOptions, type FileValidation, type UploadInput, } from './files.js';
2
2
  export { MemoryFileStore, type FileRecord, type FileStore, type FilePatch, } from './store.js';
3
3
  export { filesPlugin, fileRoutes, FILES, type FilesPluginOptions } from './plugin.js';
package/dist/index.js CHANGED
@@ -1,3 +1,3 @@
1
- export { Files, DEFAULT_MAX_FILE_SIZE, FileTooLargeError, FileTypeNotAllowedError, StorageQuotaExceededError, FileNotFoundError, FileTenantRequiredError, } from './files.js';
1
+ export { Files, DEFAULT_MAX_FILE_SIZE, FileTooLargeError, FileTypeNotAllowedError, StorageQuotaExceededError, FileNotFoundError, FileTenantRequiredError, SINGLE_TENANT_SCOPE, } from './files.js';
2
2
  export { MemoryFileStore, } from './store.js';
3
3
  export { filesPlugin, fileRoutes, FILES } from './plugin.js';
package/dist/plugin.js CHANGED
@@ -1,4 +1,4 @@
1
- import { createToken, ctx, definePlugin } from '@basaltkit/core';
1
+ import { createToken, ctx, definePlugin, ensureMetadata } from '@basaltkit/core';
2
2
  import { STORAGE } from '@basaltkit/storage';
3
3
  import { route } from '@basaltkit/http';
4
4
  import { z } from 'zod';
@@ -8,6 +8,9 @@ export function filesPlugin(options) {
8
8
  return definePlugin({
9
9
  name: 'basalt:files',
10
10
  register({ container, hooks }) {
11
+ // 'tenancy:active' is tenancyPlugin's marker: how a generic package
12
+ // learns the app is multi-tenant without importing @basaltkit/tenancy.
13
+ const metadata = ensureMetadata(container);
11
14
  container.singleton(FILES, () => {
12
15
  const disk = typeof options.disk === 'string' ? container.get(STORAGE).disk(options.disk) : options.disk;
13
16
  return new Files({
@@ -17,7 +20,7 @@ export function filesPlugin(options) {
17
20
  ...(options.validate ? { validate: options.validate } : {}),
18
21
  ...(options.maxTotalBytes !== undefined ? { maxTotalBytes: options.maxTotalBytes } : {}),
19
22
  ...(options.checkQuota ? { checkQuota: options.checkQuota } : {}),
20
- });
23
+ }, () => metadata.get('tenancy:active').length > 0);
21
24
  });
22
25
  },
23
26
  });
package/package.json CHANGED
@@ -1,9 +1,13 @@
1
1
  {
2
2
  "name": "@basaltkit/files",
3
- "version": "1.1.0",
3
+ "version": "1.2.0",
4
+ "engines": {
5
+ "node": ">=22.5.0"
6
+ },
4
7
  "description": "File uploads for Basalt: validation (type/size), per-tenant storage quota, metadata records, signed URLs, and hooks (antivirus/thumbnails) over @basaltkit/storage.",
5
8
  "license": "MIT",
6
9
  "type": "module",
10
+ "sideEffects": false,
7
11
  "exports": {
8
12
  ".": {
9
13
  "types": "./dist/index.d.ts",
@@ -14,9 +18,9 @@
14
18
  "dist"
15
19
  ],
16
20
  "dependencies": {
17
- "@basaltkit/core": "^1.3.0",
18
- "@basaltkit/http": "^1.10.0",
19
- "@basaltkit/storage": "^1.3.0"
21
+ "@basaltkit/core": "^1.3.1",
22
+ "@basaltkit/http": "^1.14.0",
23
+ "@basaltkit/storage": "^1.3.1"
20
24
  },
21
25
  "peerDependencies": {
22
26
  "zod": "^3.24.0 || ^4.0.0"
@@ -26,7 +30,7 @@
26
30
  "typescript": "^7.0.2",
27
31
  "vitest": "^4.1.11",
28
32
  "zod": "^3.24.0 || ^4.0.0",
29
- "@basaltkit/fastify": "^1.7.0",
33
+ "@basaltkit/fastify": "^1.8.1",
30
34
  "@basaltkit/tsconfig": "^0.24.0"
31
35
  },
32
36
  "publishConfig": {