@basaltkit/files 1.0.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Machize Contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,110 @@
1
+ # @basaltkit/files
2
+
3
+ **Upload** pipeline for Basalt, built on top of [`@basaltkit/storage`](https://www.npmjs.com/package/@basaltkit/storage): validates (type/size), enforces **per-tenant quota**, stores the bytes, records metadata, and fires **hooks** (antivirus, thumbnails). You need this module when users upload files — attachments, avatars, documents — and you want to do it safely and with tenant isolation.
4
+
5
+ ## What this module solves
6
+
7
+ Saving an upload "by hand" involves validating type/size, writing to storage in the right place (isolated per tenant), recording metadata (name, size, checksum, who uploaded it), enforcing the plan's quota, and triggering post-processing (antivirus scanning, thumbnails). This module does all of that in one call, leaving post-processing to hooks.
8
+
9
+ ## Installation
10
+
11
+ ```bash
12
+ pnpm add @basaltkit/files @basaltkit/storage
13
+ ```
14
+
15
+ Depends on `@basaltkit/core`, `@basaltkit/storage`, and `@basaltkit/fastify` (routes). Configure a disk in `@basaltkit/storage` (local in dev, S3/GCS in production).
16
+
17
+ ## Get started in 5 minutes
18
+
19
+ ```ts
20
+ import { createApp } from '@basaltkit/core'
21
+ import { filesPlugin, FILES, fileRoutes } from '@basaltkit/files'
22
+ import { fastifyPlugin } from '@basaltkit/fastify'
23
+
24
+ const app = await createApp({
25
+ plugins: [
26
+ // ... storagePlugin({ disks: { uploads: ... } }) and tenancyPlugin
27
+ filesPlugin({
28
+ disk: 'uploads', // disk name (or a Disk instance)
29
+ validate: { maxSize: 5_000_000, allowedTypes: ['image/*', 'application/pdf'] },
30
+ maxTotalBytes: 1_000_000_000, // per-tenant quota (1 GB)
31
+ }),
32
+ fastifyPlugin({ routes: [...fileRoutes()] }),
33
+ ],
34
+ }).boot()
35
+
36
+ const files = app.container.get(FILES)
37
+ const record = await files.upload(buffer, { name: 'contract.pdf', contentType: 'application/pdf', tenantId: 'acme', uploadedBy: 'u1' })
38
+ ```
39
+
40
+ The upload validates, checks the quota, writes the bytes **isolated per tenant**, records the metadata (including a SHA-256 `checksum`), and emits `file:uploaded`.
41
+
42
+ ## Receiving an upload over HTTP
43
+
44
+ The upload itself is *multipart* (adapter-specific), so there's no ready-made route for it. In your handler, read the file and call the service:
45
+
46
+ ```ts
47
+ // example with Fastify and @fastify/multipart
48
+ app.post('/upload', async (req) => {
49
+ const part = await req.file()
50
+ const buffer = await part.toBuffer()
51
+ return files.upload(buffer, { name: part.filename, contentType: part.mimetype })
52
+ // tenantId comes from the request context (tenancy)
53
+ })
54
+ ```
55
+
56
+ The other operations have ready-made routes via `fileRoutes()`:
57
+
58
+ | Route | Description |
59
+ |---|---|
60
+ | `GET /files` | Lists the current tenant's files. |
61
+ | `GET /files/:id` | A file's metadata. |
62
+ | `POST /files/:id/url` `{ expiresIn? }` | Temporary signed URL. |
63
+ | `DELETE /files/:id` | Deletes bytes + metadata. |
64
+
65
+ ## Post-processing with hooks
66
+
67
+ The typical pattern: on `file:uploaded`, dispatch a job (with `@basaltkit/queue`) that scans/processes the file and then calls `markScanned`:
68
+
69
+ ```ts
70
+ hooks.on('file:uploaded', ({ file }) => ScanFile.dispatch({ tenantId: file.tenantId, id: file.id }))
71
+
72
+ // in the job, after scanning:
73
+ await files.markScanned(id, { clean: true }, tenantId) // emits file:scanned
74
+ ```
75
+
76
+ ## API reference
77
+
78
+ ### `filesPlugin(options)`
79
+
80
+ | Option | Type | Description |
81
+ |---|---|---|
82
+ | `disk` | `Disk \| string` | A `Disk` instance or the name of a `@basaltkit/storage` disk. |
83
+ | `validate` | `{ maxSize?, allowedTypes? }` | Size limit and allowed types (`image/*` accepts wildcards). |
84
+ | `maxTotalBytes` | `number` | Total quota per tenant. |
85
+ | `checkQuota` | `(tenantId, size) => void` | Custom quota check (e.g. hook into `@basaltkit/subscriptions`). Throw to reject. |
86
+ | `store` | `FileStore` | Metadata persistence. Default: in-memory. |
87
+
88
+ Registers the `FILES` token.
89
+
90
+ ### `class Files`
91
+
92
+ | Method | Description |
93
+ |---|---|
94
+ | `upload(content, input)` | Validates, enforces quota, stores, records metadata, emits `file:uploaded`. |
95
+ | `download(id, tenantId?)` | `{ record, content }`. |
96
+ | `temporaryUrl(id, expiresIn, tenantId?)` | Signed URL. |
97
+ | `get(id, tenantId?)` · `list(tenantId?)` | Metadata. |
98
+ | `delete(id, tenantId?)` | Deletes bytes + metadata; emits `file:deleted`. |
99
+ | `markScanned(id, result, tenantId?)` | Marks as scanned; emits `file:scanned`. |
100
+
101
+ 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.
102
+
103
+ Errors: `FileTooLargeError` (413), `FileTypeNotAllowedError` (415), `StorageQuotaExceededError` (402), `FileNotFoundError` (404).
104
+
105
+ ## How it connects to other modules
106
+
107
+ - **`@basaltkit/storage`** — where the bytes live (local/S3/GCS), with tenant isolation.
108
+ - **`@basaltkit/subscriptions`** — hook `checkQuota` into `features(tenant).consume(...)` for plan-based quotas.
109
+ - **`@basaltkit/queue`** — processes `file:uploaded` outside the request (antivirus, thumbnails).
110
+ - **`@basaltkit/tenancy`** — supplies the tenant from the context.
@@ -0,0 +1,159 @@
1
+ import * as _basaltkit_core from '@basaltkit/core';
2
+ import { HookBus, DurationInput, BasaltError } from '@basaltkit/core';
3
+ import { Disk } from '@basaltkit/storage';
4
+ import { BasaltRoute } from '@basaltkit/fastify';
5
+
6
+ /** Metadata for one uploaded file. The bytes live in storage; this is the record. */
7
+ interface FileRecord {
8
+ id: string;
9
+ tenantId: string;
10
+ /** Original filename. */
11
+ name: string;
12
+ contentType: string;
13
+ /** Size in bytes. */
14
+ size: number;
15
+ /** Path within the storage disk. */
16
+ path: string;
17
+ /** SHA-256 of the content. */
18
+ checksum: string;
19
+ uploadedBy?: string;
20
+ metadata?: Record<string, unknown>;
21
+ /** Set by a scanning step (antivirus, etc.) via `markScanned`. */
22
+ scanned?: boolean;
23
+ createdAt: number;
24
+ }
25
+ type FilePatch = Partial<Pick<FileRecord, 'scanned' | 'metadata'>>;
26
+ /** Where file metadata lives — the app's database in production. */
27
+ interface FileStore {
28
+ create(record: FileRecord): Promise<void>;
29
+ find(tenantId: string, id: string): Promise<FileRecord | null>;
30
+ list(tenantId: string): Promise<FileRecord[]>;
31
+ update(tenantId: string, id: string, patch: FilePatch): Promise<FileRecord | null>;
32
+ delete(tenantId: string, id: string): Promise<void>;
33
+ /** Total bytes stored by a tenant — used for quota checks. */
34
+ totalSize(tenantId: string): Promise<number>;
35
+ }
36
+ declare class MemoryFileStore implements FileStore {
37
+ private readonly records;
38
+ private key;
39
+ create(record: FileRecord): Promise<void>;
40
+ find(tenantId: string, id: string): Promise<FileRecord | null>;
41
+ list(tenantId: string): Promise<FileRecord[]>;
42
+ update(tenantId: string, id: string, patch: FilePatch): Promise<FileRecord | null>;
43
+ delete(tenantId: string, id: string): Promise<void>;
44
+ totalSize(tenantId: string): Promise<number>;
45
+ }
46
+
47
+ declare class FileTooLargeError extends BasaltError {
48
+ readonly status = 413;
49
+ constructor(size: number, max: number);
50
+ }
51
+ declare class FileTypeNotAllowedError extends BasaltError {
52
+ readonly status = 415;
53
+ constructor(contentType: string);
54
+ }
55
+ /** The tenant's storage allowance is exhausted. */
56
+ declare class StorageQuotaExceededError extends BasaltError {
57
+ readonly status = 402;
58
+ constructor();
59
+ }
60
+ declare class FileNotFoundError extends BasaltError {
61
+ readonly status = 404;
62
+ constructor();
63
+ }
64
+ declare class FileTenantRequiredError extends BasaltError {
65
+ readonly status = 400;
66
+ constructor();
67
+ }
68
+ interface FileValidation {
69
+ /** Max size in bytes. */
70
+ maxSize?: number;
71
+ /** Allowed content types; supports `image/*` wildcards. */
72
+ allowedTypes?: string[];
73
+ }
74
+ interface FilesOptions {
75
+ disk: Disk;
76
+ store?: FileStore;
77
+ hooks?: HookBus;
78
+ validate?: FileValidation;
79
+ /** Max total bytes per tenant (a built-in quota). */
80
+ maxTotalBytes?: number;
81
+ /** Custom quota check — throw to reject (e.g. wire @basaltkit/subscriptions). */
82
+ checkQuota?: (tenantId: string, size: number) => Promise<void> | void;
83
+ now?: () => number;
84
+ }
85
+ interface UploadInput {
86
+ name: string;
87
+ contentType: string;
88
+ tenantId?: string;
89
+ uploadedBy?: string;
90
+ metadata?: Record<string, unknown>;
91
+ }
92
+ /**
93
+ * Upload pipeline over a storage {@link Disk}: validates size/type, enforces a
94
+ * per-tenant quota, writes the bytes, records metadata, and emits hooks. Every
95
+ * operation is tenant-scoped; storage access runs in the resolved tenant's
96
+ * context so files are isolated whether called from a request or a job.
97
+ */
98
+ declare class Files {
99
+ private readonly disk;
100
+ private readonly store;
101
+ private readonly hooks;
102
+ private readonly validation;
103
+ private readonly maxTotalBytes;
104
+ private readonly checkQuota;
105
+ private readonly now;
106
+ constructor(options: FilesOptions);
107
+ upload(content: Buffer, input: UploadInput): Promise<FileRecord>;
108
+ get(id: string, tenantId?: string): Promise<FileRecord | null>;
109
+ list(tenantId?: string): Promise<FileRecord[]>;
110
+ download(id: string, tenantId?: string): Promise<{
111
+ record: FileRecord;
112
+ content: Buffer;
113
+ }>;
114
+ temporaryUrl(id: string, expiresIn: DurationInput, tenantId?: string): Promise<string>;
115
+ delete(id: string, tenantId?: string): Promise<void>;
116
+ /** Records the result of an out-of-band scan (antivirus, moderation, …). */
117
+ markScanned(id: string, result: {
118
+ clean: boolean;
119
+ detail?: string;
120
+ }, tenantId?: string): Promise<FileRecord>;
121
+ private validate;
122
+ private enforceQuota;
123
+ private tenant;
124
+ /** Runs a storage op in the resolved tenant's context so the disk scopes correctly. */
125
+ private inTenant;
126
+ }
127
+
128
+ declare module '@basaltkit/core' {
129
+ interface BasaltHooks {
130
+ 'file:uploaded': {
131
+ file: FileRecord;
132
+ };
133
+ 'file:deleted': {
134
+ tenantId: string;
135
+ id: string;
136
+ };
137
+ 'file:scanned': {
138
+ file: FileRecord;
139
+ };
140
+ }
141
+ }
142
+ declare const FILES: _basaltkit_core.Token<Files>;
143
+ interface FilesPluginOptions {
144
+ /** A `Disk` instance or the name of a disk configured in `@basaltkit/storage`. */
145
+ disk: Disk | string;
146
+ store?: FileStore;
147
+ validate?: FileValidation;
148
+ maxTotalBytes?: number;
149
+ checkQuota?: FilesOptions['checkQuota'];
150
+ }
151
+ declare function filesPlugin(options: FilesPluginOptions): _basaltkit_core.BasaltPlugin<unknown>;
152
+ /**
153
+ * Read/manage routes for the current tenant's files: list, metadata, a signed
154
+ * URL, and delete. Uploading is transport-specific (multipart) — call
155
+ * `FILES.upload(buffer, input)` from your own upload handler.
156
+ */
157
+ declare function fileRoutes(): BasaltRoute[];
158
+
159
+ export { FILES, FileNotFoundError, type FilePatch, type FileRecord, type FileStore, FileTenantRequiredError, FileTooLargeError, FileTypeNotAllowedError, type FileValidation, Files, type FilesOptions, type FilesPluginOptions, MemoryFileStore, StorageQuotaExceededError, type UploadInput, fileRoutes, filesPlugin };
package/dist/index.js ADDED
@@ -0,0 +1,254 @@
1
+ // src/files.ts
2
+ import { createHash, randomUUID } from "crypto";
3
+ import { BasaltError, runWithContext, tryCtx } from "@basaltkit/core";
4
+
5
+ // src/store.ts
6
+ var MemoryFileStore = class {
7
+ records = /* @__PURE__ */ new Map();
8
+ key(tenantId, id) {
9
+ return `${tenantId} ${id}`;
10
+ }
11
+ async create(record) {
12
+ this.records.set(this.key(record.tenantId, record.id), record);
13
+ }
14
+ async find(tenantId, id) {
15
+ return this.records.get(this.key(tenantId, id)) ?? null;
16
+ }
17
+ async list(tenantId) {
18
+ const out = [];
19
+ for (const record of this.records.values()) if (record.tenantId === tenantId) out.push(record);
20
+ return out;
21
+ }
22
+ async update(tenantId, id, patch) {
23
+ const record = this.records.get(this.key(tenantId, id));
24
+ if (!record) return null;
25
+ Object.assign(record, patch);
26
+ return record;
27
+ }
28
+ async delete(tenantId, id) {
29
+ this.records.delete(this.key(tenantId, id));
30
+ }
31
+ async totalSize(tenantId) {
32
+ let total = 0;
33
+ for (const record of this.records.values()) if (record.tenantId === tenantId) total += record.size;
34
+ return total;
35
+ }
36
+ };
37
+
38
+ // src/files.ts
39
+ var FileTooLargeError = class extends BasaltError {
40
+ status = 413;
41
+ constructor(size, max) {
42
+ super("FILE_TOO_LARGE", `File is ${size} bytes; the limit is ${max}.`);
43
+ }
44
+ };
45
+ var FileTypeNotAllowedError = class extends BasaltError {
46
+ status = 415;
47
+ constructor(contentType) {
48
+ super("FILE_TYPE_NOT_ALLOWED", `Content type "${contentType}" is not allowed.`);
49
+ }
50
+ };
51
+ var StorageQuotaExceededError = class extends BasaltError {
52
+ status = 402;
53
+ constructor() {
54
+ super("FILE_QUOTA_EXCEEDED", "Storage quota exceeded for this tenant.");
55
+ }
56
+ };
57
+ var FileNotFoundError = class extends BasaltError {
58
+ status = 404;
59
+ constructor() {
60
+ super("FILE_NOT_FOUND", "File not found.");
61
+ }
62
+ };
63
+ var FileTenantRequiredError = class extends BasaltError {
64
+ status = 400;
65
+ constructor() {
66
+ super("FILE_TENANT_REQUIRED", "A tenant is required \u2014 pass tenantId or run inside a tenant context.");
67
+ }
68
+ };
69
+ var matchesType = (contentType, allowed) => allowed.some((a) => a === contentType || a.endsWith("/*") && contentType.startsWith(a.slice(0, -1)));
70
+ var storagePath = (id) => `files/${id}`;
71
+ var Files = class {
72
+ disk;
73
+ store;
74
+ hooks;
75
+ validation;
76
+ maxTotalBytes;
77
+ checkQuota;
78
+ now;
79
+ constructor(options) {
80
+ this.disk = options.disk;
81
+ this.store = options.store ?? new MemoryFileStore();
82
+ this.hooks = options.hooks;
83
+ this.validation = options.validate ?? {};
84
+ this.maxTotalBytes = options.maxTotalBytes;
85
+ this.checkQuota = options.checkQuota;
86
+ this.now = options.now ?? Date.now;
87
+ }
88
+ async upload(content, input) {
89
+ const tenantId = this.tenant(input.tenantId);
90
+ const size = content.length;
91
+ this.validate(input.contentType, size);
92
+ await this.enforceQuota(tenantId, size);
93
+ const id = randomUUID();
94
+ const path = storagePath(id);
95
+ const checksum = createHash("sha256").update(content).digest("hex");
96
+ await this.inTenant(tenantId, () => this.disk.put(path, content, { contentType: input.contentType }));
97
+ const record = {
98
+ id,
99
+ tenantId,
100
+ name: input.name,
101
+ contentType: input.contentType,
102
+ size,
103
+ path,
104
+ checksum,
105
+ createdAt: this.now(),
106
+ ...input.uploadedBy !== void 0 ? { uploadedBy: input.uploadedBy } : {},
107
+ ...input.metadata !== void 0 ? { metadata: input.metadata } : {}
108
+ };
109
+ await this.store.create(record);
110
+ await this.hooks?.emit("file:uploaded", { file: record });
111
+ return record;
112
+ }
113
+ async get(id, tenantId) {
114
+ return this.store.find(this.tenant(tenantId), id);
115
+ }
116
+ async list(tenantId) {
117
+ return this.store.list(this.tenant(tenantId));
118
+ }
119
+ async download(id, tenantId) {
120
+ const resolved = this.tenant(tenantId);
121
+ const record = await this.store.find(resolved, id);
122
+ if (!record) throw new FileNotFoundError();
123
+ const content = await this.inTenant(resolved, () => this.disk.get(record.path));
124
+ return { record, content };
125
+ }
126
+ async temporaryUrl(id, expiresIn, tenantId) {
127
+ const resolved = this.tenant(tenantId);
128
+ const record = await this.store.find(resolved, id);
129
+ if (!record) throw new FileNotFoundError();
130
+ return this.inTenant(resolved, () => this.disk.temporaryUrl(record.path, expiresIn));
131
+ }
132
+ async delete(id, tenantId) {
133
+ const resolved = this.tenant(tenantId);
134
+ const record = await this.store.find(resolved, id);
135
+ if (!record) return;
136
+ await this.inTenant(resolved, () => this.disk.delete(record.path));
137
+ await this.store.delete(resolved, id);
138
+ await this.hooks?.emit("file:deleted", { tenantId: resolved, id });
139
+ }
140
+ /** Records the result of an out-of-band scan (antivirus, moderation, …). */
141
+ async markScanned(id, result, tenantId) {
142
+ const resolved = this.tenant(tenantId);
143
+ const record = await this.store.find(resolved, id);
144
+ if (!record) throw new FileNotFoundError();
145
+ const patch = { scanned: true, metadata: { ...record.metadata, scan: result } };
146
+ const updated = await this.store.update(resolved, id, patch) ?? record;
147
+ await this.hooks?.emit("file:scanned", { file: updated });
148
+ return updated;
149
+ }
150
+ validate(contentType, size) {
151
+ if (this.validation.maxSize !== void 0 && size > this.validation.maxSize) {
152
+ throw new FileTooLargeError(size, this.validation.maxSize);
153
+ }
154
+ if (this.validation.allowedTypes && !matchesType(contentType, this.validation.allowedTypes)) {
155
+ throw new FileTypeNotAllowedError(contentType);
156
+ }
157
+ }
158
+ async enforceQuota(tenantId, size) {
159
+ if (this.maxTotalBytes !== void 0) {
160
+ const total = await this.store.totalSize(tenantId);
161
+ if (total + size > this.maxTotalBytes) throw new StorageQuotaExceededError();
162
+ }
163
+ await this.checkQuota?.(tenantId, size);
164
+ }
165
+ tenant(explicit) {
166
+ const id = explicit ?? tryCtx()?.["tenant"]?.id;
167
+ if (!id) throw new FileTenantRequiredError();
168
+ return id;
169
+ }
170
+ /** Runs a storage op in the resolved tenant's context so the disk scopes correctly. */
171
+ inTenant(tenantId, fn) {
172
+ return runWithContext({ tenant: { id: tenantId } }, fn);
173
+ }
174
+ };
175
+
176
+ // src/plugin.ts
177
+ import { createToken, ctx, definePlugin } from "@basaltkit/core";
178
+ import { STORAGE } from "@basaltkit/storage";
179
+ import { route } from "@basaltkit/fastify";
180
+ import { z } from "zod";
181
+ var FILES = createToken("files");
182
+ function filesPlugin(options) {
183
+ return definePlugin({
184
+ name: "basalt:files",
185
+ register({ container, hooks }) {
186
+ container.singleton(FILES, () => {
187
+ const disk = typeof options.disk === "string" ? container.get(STORAGE).disk(options.disk) : options.disk;
188
+ return new Files({
189
+ disk,
190
+ hooks,
191
+ ...options.store ? { store: options.store } : {},
192
+ ...options.validate ? { validate: options.validate } : {},
193
+ ...options.maxTotalBytes !== void 0 ? { maxTotalBytes: options.maxTotalBytes } : {},
194
+ ...options.checkQuota ? { checkQuota: options.checkQuota } : {}
195
+ });
196
+ });
197
+ }
198
+ });
199
+ }
200
+ var files = () => ctx().container.get(FILES);
201
+ function fileRoutes() {
202
+ return [
203
+ route({
204
+ method: "GET",
205
+ url: "/files",
206
+ meta: { auth: true },
207
+ async handler() {
208
+ return files().list();
209
+ }
210
+ }),
211
+ route({
212
+ method: "GET",
213
+ url: "/files/:id",
214
+ meta: { auth: true },
215
+ params: z.object({ id: z.string() }),
216
+ async handler({ params, reply }) {
217
+ const record = await files().get(params.id);
218
+ return record ?? reply.code(404).send({ error: { code: "FILE_NOT_FOUND", message: "File not found." } });
219
+ }
220
+ }),
221
+ route({
222
+ method: "POST",
223
+ url: "/files/:id/url",
224
+ meta: { auth: true },
225
+ params: z.object({ id: z.string() }),
226
+ body: z.object({ expiresIn: z.string().optional() }).optional(),
227
+ async handler({ params, body }) {
228
+ return { url: await files().temporaryUrl(params.id, body?.expiresIn ?? "15m") };
229
+ }
230
+ }),
231
+ route({
232
+ method: "DELETE",
233
+ url: "/files/:id",
234
+ meta: { auth: true },
235
+ params: z.object({ id: z.string() }),
236
+ async handler({ params, reply }) {
237
+ await files().delete(params.id);
238
+ return reply.code(204).send();
239
+ }
240
+ })
241
+ ];
242
+ }
243
+ export {
244
+ FILES,
245
+ FileNotFoundError,
246
+ FileTenantRequiredError,
247
+ FileTooLargeError,
248
+ FileTypeNotAllowedError,
249
+ Files,
250
+ MemoryFileStore,
251
+ StorageQuotaExceededError,
252
+ fileRoutes,
253
+ filesPlugin
254
+ };
package/package.json ADDED
@@ -0,0 +1,55 @@
1
+ {
2
+ "name": "@basaltkit/files",
3
+ "version": "1.0.0",
4
+ "description": "File uploads for Basalt: validation (type/size), per-tenant storage quota, metadata records, signed URLs, and hooks (antivirus/thumbnails) over @basaltkit/storage.",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "exports": {
8
+ ".": {
9
+ "types": "./dist/index.d.ts",
10
+ "import": "./dist/index.js"
11
+ }
12
+ },
13
+ "files": [
14
+ "dist"
15
+ ],
16
+ "dependencies": {
17
+ "@basaltkit/core": "^1.0.0",
18
+ "@basaltkit/storage": "^1.0.0",
19
+ "@basaltkit/fastify": "^1.0.0"
20
+ },
21
+ "peerDependencies": {
22
+ "zod": "^3.24.0 || ^4.0.0"
23
+ },
24
+ "devDependencies": {
25
+ "@types/node": "^22.15.0",
26
+ "tsup": "^8.4.0",
27
+ "typescript": "^5.8.0",
28
+ "vitest": "^3.1.0",
29
+ "zod": "^3.24.0",
30
+ "@basaltkit/tsconfig": "^0.24.0"
31
+ },
32
+ "publishConfig": {
33
+ "access": "public"
34
+ },
35
+ "repository": {
36
+ "type": "git",
37
+ "url": "git+https://github.com/Zebedeu/basalt.git",
38
+ "directory": "packages/files"
39
+ },
40
+ "homepage": "https://github.com/Zebedeu/basalt/tree/main/packages/files#readme",
41
+ "bugs": "https://github.com/Zebedeu/basalt/issues",
42
+ "keywords": [
43
+ "basalt",
44
+ "typescript",
45
+ "saas",
46
+ "uploads",
47
+ "files",
48
+ "storage"
49
+ ],
50
+ "scripts": {
51
+ "build": "tsup src/index.ts --format esm --dts --clean",
52
+ "test": "vitest run",
53
+ "typecheck": "tsc --noEmit"
54
+ }
55
+ }