@basaltkit/files 1.0.2 → 1.1.1
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 +58 -11
- package/dist/files.d.ts +11 -1
- package/dist/files.js +13 -3
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/package.json +9 -5
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
|
-
###
|
|
84
|
+
### Options reference
|
|
85
85
|
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
|
89
|
-
|
|
90
|
-
| `
|
|
91
|
-
| `
|
|
92
|
-
| `
|
|
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
|
-
|
|
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
|
-
|
|
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
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { BasaltError, type DurationInput, type HookBus } from '@basaltkit/core';
|
|
2
2
|
import type { Disk } from '@basaltkit/storage';
|
|
3
3
|
import { type FileRecord, type FileStore } from './store.js';
|
|
4
|
+
/** Default upload cap (25 MiB) applied when `validate.maxSize` is not set. */
|
|
5
|
+
export declare const DEFAULT_MAX_FILE_SIZE: number;
|
|
4
6
|
export declare class FileTooLargeError extends BasaltError {
|
|
5
7
|
readonly status = 413;
|
|
6
8
|
constructor(size: number, max: number);
|
|
@@ -68,7 +70,15 @@ export declare class Files {
|
|
|
68
70
|
record: FileRecord;
|
|
69
71
|
content: Buffer;
|
|
70
72
|
}>;
|
|
71
|
-
|
|
73
|
+
/**
|
|
74
|
+
* Signed download URL — served `Content-Disposition: attachment` by default
|
|
75
|
+
* so an uploaded HTML/SVG file can never render top-level on the storage
|
|
76
|
+
* origin; pass `{ disposition: 'inline' }` when in-browser rendering is
|
|
77
|
+
* deliberate (embedded <img>/<video> uses render regardless).
|
|
78
|
+
*/
|
|
79
|
+
temporaryUrl(id: string, expiresIn: DurationInput, tenantId?: string, options?: {
|
|
80
|
+
disposition?: 'attachment' | 'inline';
|
|
81
|
+
}): Promise<string>;
|
|
72
82
|
delete(id: string, tenantId?: string): Promise<void>;
|
|
73
83
|
/** Records the result of an out-of-band scan (antivirus, moderation, …). */
|
|
74
84
|
markScanned(id: string, result: {
|
package/dist/files.js
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { createHash, randomUUID } from 'node:crypto';
|
|
2
2
|
import { BasaltError, runWithContext, tryCtx } from '@basaltkit/core';
|
|
3
3
|
import { MemoryFileStore } from './store.js';
|
|
4
|
+
/** Default upload cap (25 MiB) applied when `validate.maxSize` is not set. */
|
|
5
|
+
export const DEFAULT_MAX_FILE_SIZE = 25 * 1024 * 1024;
|
|
4
6
|
export class FileTooLargeError extends BasaltError {
|
|
5
7
|
status = 413;
|
|
6
8
|
constructor(size, max) {
|
|
@@ -52,7 +54,9 @@ export class Files {
|
|
|
52
54
|
this.disk = options.disk;
|
|
53
55
|
this.store = options.store ?? new MemoryFileStore();
|
|
54
56
|
this.hooks = options.hooks;
|
|
55
|
-
|
|
57
|
+
// Secure by default (review 2026-08-b, S-3): uploads are capped even when
|
|
58
|
+
// the app configures nothing. Raise (or set Infinity) via validate.maxSize.
|
|
59
|
+
this.validation = { maxSize: DEFAULT_MAX_FILE_SIZE, ...options.validate };
|
|
56
60
|
this.maxTotalBytes = options.maxTotalBytes;
|
|
57
61
|
this.checkQuota = options.checkQuota;
|
|
58
62
|
this.now = options.now ?? Date.now;
|
|
@@ -96,12 +100,18 @@ export class Files {
|
|
|
96
100
|
const content = await this.inTenant(resolved, () => this.disk.get(record.path));
|
|
97
101
|
return { record, content };
|
|
98
102
|
}
|
|
99
|
-
|
|
103
|
+
/**
|
|
104
|
+
* Signed download URL — served `Content-Disposition: attachment` by default
|
|
105
|
+
* so an uploaded HTML/SVG file can never render top-level on the storage
|
|
106
|
+
* origin; pass `{ disposition: 'inline' }` when in-browser rendering is
|
|
107
|
+
* deliberate (embedded <img>/<video> uses render regardless).
|
|
108
|
+
*/
|
|
109
|
+
async temporaryUrl(id, expiresIn, tenantId, options = {}) {
|
|
100
110
|
const resolved = this.tenant(tenantId);
|
|
101
111
|
const record = await this.store.find(resolved, id);
|
|
102
112
|
if (!record)
|
|
103
113
|
throw new FileNotFoundError();
|
|
104
|
-
return this.inTenant(resolved, () => this.disk.temporaryUrl(record.path, expiresIn));
|
|
114
|
+
return this.inTenant(resolved, () => this.disk.temporaryUrl(record.path, expiresIn, options));
|
|
105
115
|
}
|
|
106
116
|
async delete(id, tenantId) {
|
|
107
117
|
const resolved = this.tenant(tenantId);
|
package/dist/index.d.ts
CHANGED
|
@@ -1,3 +1,3 @@
|
|
|
1
|
-
export { Files, 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, 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, FileTooLargeError, FileTypeNotAllowedError, StorageQuotaExceededError, FileNotFoundError, FileTenantRequiredError, } from './files.js';
|
|
1
|
+
export { Files, DEFAULT_MAX_FILE_SIZE, FileTooLargeError, FileTypeNotAllowedError, StorageQuotaExceededError, FileNotFoundError, FileTenantRequiredError, } from './files.js';
|
|
2
2
|
export { MemoryFileStore, } from './store.js';
|
|
3
3
|
export { filesPlugin, fileRoutes, FILES } from './plugin.js';
|
package/package.json
CHANGED
|
@@ -1,9 +1,13 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@basaltkit/files",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.1.1",
|
|
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/
|
|
18
|
-
"@basaltkit/
|
|
19
|
-
"@basaltkit/
|
|
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.
|
|
33
|
+
"@basaltkit/fastify": "^1.8.1",
|
|
30
34
|
"@basaltkit/tsconfig": "^0.24.0"
|
|
31
35
|
},
|
|
32
36
|
"publishConfig": {
|