@basaltkit/files 1.0.0 → 1.0.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/LICENSE CHANGED
@@ -1,6 +1,6 @@
1
1
  MIT License
2
2
 
3
- Copyright (c) 2026 Machize Contributors
3
+ Copyright (c) 2026 Basalt Contributors
4
4
 
5
5
  Permission is hereby granted, free of charge, to any person obtaining a copy
6
6
  of this software and associated documentation files (the "Software"), to deal
package/README.md CHANGED
@@ -1,3 +1,9 @@
1
+ <p align="center">
2
+ <a href="https://basaltkit-docs.pages.dev">
3
+ <img src="https://basaltkit-docs.pages.dev/social-card.png" alt="Basalt" width="440">
4
+ </a>
5
+ </p>
6
+
1
7
  # @basaltkit/files
2
8
 
3
9
  **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.
@@ -0,0 +1,83 @@
1
+ import { BasaltError, type DurationInput, type HookBus } from '@basaltkit/core';
2
+ import type { Disk } from '@basaltkit/storage';
3
+ import { type FileRecord, type FileStore } from './store.js';
4
+ export declare class FileTooLargeError extends BasaltError {
5
+ readonly status = 413;
6
+ constructor(size: number, max: number);
7
+ }
8
+ export declare class FileTypeNotAllowedError extends BasaltError {
9
+ readonly status = 415;
10
+ constructor(contentType: string);
11
+ }
12
+ /** The tenant's storage allowance is exhausted. */
13
+ export declare class StorageQuotaExceededError extends BasaltError {
14
+ readonly status = 402;
15
+ constructor();
16
+ }
17
+ export declare class FileNotFoundError extends BasaltError {
18
+ readonly status = 404;
19
+ constructor();
20
+ }
21
+ export declare class FileTenantRequiredError extends BasaltError {
22
+ readonly status = 400;
23
+ constructor();
24
+ }
25
+ export interface FileValidation {
26
+ /** Max size in bytes. */
27
+ maxSize?: number;
28
+ /** Allowed content types; supports `image/*` wildcards. */
29
+ allowedTypes?: string[];
30
+ }
31
+ export interface FilesOptions {
32
+ disk: Disk;
33
+ store?: FileStore;
34
+ hooks?: HookBus;
35
+ validate?: FileValidation;
36
+ /** Max total bytes per tenant (a built-in quota). */
37
+ maxTotalBytes?: number;
38
+ /** Custom quota check — throw to reject (e.g. wire @basaltkit/subscriptions). */
39
+ checkQuota?: (tenantId: string, size: number) => Promise<void> | void;
40
+ now?: () => number;
41
+ }
42
+ export interface UploadInput {
43
+ name: string;
44
+ contentType: string;
45
+ tenantId?: string;
46
+ uploadedBy?: string;
47
+ metadata?: Record<string, unknown>;
48
+ }
49
+ /**
50
+ * Upload pipeline over a storage {@link Disk}: validates size/type, enforces a
51
+ * per-tenant quota, writes the bytes, records metadata, and emits hooks. Every
52
+ * operation is tenant-scoped; storage access runs in the resolved tenant's
53
+ * context so files are isolated whether called from a request or a job.
54
+ */
55
+ export declare class Files {
56
+ private readonly disk;
57
+ private readonly store;
58
+ private readonly hooks;
59
+ private readonly validation;
60
+ private readonly maxTotalBytes;
61
+ private readonly checkQuota;
62
+ private readonly now;
63
+ constructor(options: FilesOptions);
64
+ upload(content: Buffer, input: UploadInput): Promise<FileRecord>;
65
+ get(id: string, tenantId?: string): Promise<FileRecord | null>;
66
+ list(tenantId?: string): Promise<FileRecord[]>;
67
+ download(id: string, tenantId?: string): Promise<{
68
+ record: FileRecord;
69
+ content: Buffer;
70
+ }>;
71
+ temporaryUrl(id: string, expiresIn: DurationInput, tenantId?: string): Promise<string>;
72
+ delete(id: string, tenantId?: string): Promise<void>;
73
+ /** Records the result of an out-of-band scan (antivirus, moderation, …). */
74
+ markScanned(id: string, result: {
75
+ clean: boolean;
76
+ detail?: string;
77
+ }, tenantId?: string): Promise<FileRecord>;
78
+ private validate;
79
+ private enforceQuota;
80
+ private tenant;
81
+ /** Runs a storage op in the resolved tenant's context so the disk scopes correctly. */
82
+ private inTenant;
83
+ }
package/dist/files.js ADDED
@@ -0,0 +1,152 @@
1
+ import { createHash, randomUUID } from 'node:crypto';
2
+ import { BasaltError, runWithContext, tryCtx } from '@basaltkit/core';
3
+ import { MemoryFileStore } from './store.js';
4
+ export class FileTooLargeError extends BasaltError {
5
+ status = 413;
6
+ constructor(size, max) {
7
+ super('FILE_TOO_LARGE', `File is ${size} bytes; the limit is ${max}.`);
8
+ }
9
+ }
10
+ export class FileTypeNotAllowedError extends BasaltError {
11
+ status = 415;
12
+ constructor(contentType) {
13
+ super('FILE_TYPE_NOT_ALLOWED', `Content type "${contentType}" is not allowed.`);
14
+ }
15
+ }
16
+ /** The tenant's storage allowance is exhausted. */
17
+ export class StorageQuotaExceededError extends BasaltError {
18
+ status = 402;
19
+ constructor() {
20
+ super('FILE_QUOTA_EXCEEDED', 'Storage quota exceeded for this tenant.');
21
+ }
22
+ }
23
+ export class FileNotFoundError extends BasaltError {
24
+ status = 404;
25
+ constructor() {
26
+ super('FILE_NOT_FOUND', 'File not found.');
27
+ }
28
+ }
29
+ export class FileTenantRequiredError extends BasaltError {
30
+ status = 400;
31
+ constructor() {
32
+ super('FILE_TENANT_REQUIRED', 'A tenant is required — pass tenantId or run inside a tenant context.');
33
+ }
34
+ }
35
+ const matchesType = (contentType, allowed) => allowed.some((a) => a === contentType || (a.endsWith('/*') && contentType.startsWith(a.slice(0, -1))));
36
+ const storagePath = (id) => `files/${id}`;
37
+ /**
38
+ * Upload pipeline over a storage {@link Disk}: validates size/type, enforces a
39
+ * per-tenant quota, writes the bytes, records metadata, and emits hooks. Every
40
+ * operation is tenant-scoped; storage access runs in the resolved tenant's
41
+ * context so files are isolated whether called from a request or a job.
42
+ */
43
+ export class Files {
44
+ disk;
45
+ store;
46
+ hooks;
47
+ validation;
48
+ maxTotalBytes;
49
+ checkQuota;
50
+ now;
51
+ constructor(options) {
52
+ this.disk = options.disk;
53
+ this.store = options.store ?? new MemoryFileStore();
54
+ this.hooks = options.hooks;
55
+ this.validation = options.validate ?? {};
56
+ this.maxTotalBytes = options.maxTotalBytes;
57
+ this.checkQuota = options.checkQuota;
58
+ this.now = options.now ?? Date.now;
59
+ }
60
+ async upload(content, input) {
61
+ const tenantId = this.tenant(input.tenantId);
62
+ const size = content.length;
63
+ this.validate(input.contentType, size);
64
+ await this.enforceQuota(tenantId, size);
65
+ const id = randomUUID();
66
+ const path = storagePath(id);
67
+ const checksum = createHash('sha256').update(content).digest('hex');
68
+ await this.inTenant(tenantId, () => this.disk.put(path, content, { contentType: input.contentType }));
69
+ const record = {
70
+ id,
71
+ tenantId,
72
+ name: input.name,
73
+ contentType: input.contentType,
74
+ size,
75
+ path,
76
+ checksum,
77
+ createdAt: this.now(),
78
+ ...(input.uploadedBy !== undefined ? { uploadedBy: input.uploadedBy } : {}),
79
+ ...(input.metadata !== undefined ? { metadata: input.metadata } : {}),
80
+ };
81
+ await this.store.create(record);
82
+ await this.hooks?.emit('file:uploaded', { file: record });
83
+ return record;
84
+ }
85
+ async get(id, tenantId) {
86
+ return this.store.find(this.tenant(tenantId), id);
87
+ }
88
+ async list(tenantId) {
89
+ return this.store.list(this.tenant(tenantId));
90
+ }
91
+ async download(id, tenantId) {
92
+ const resolved = this.tenant(tenantId);
93
+ const record = await this.store.find(resolved, id);
94
+ if (!record)
95
+ throw new FileNotFoundError();
96
+ const content = await this.inTenant(resolved, () => this.disk.get(record.path));
97
+ return { record, content };
98
+ }
99
+ async temporaryUrl(id, expiresIn, tenantId) {
100
+ const resolved = this.tenant(tenantId);
101
+ const record = await this.store.find(resolved, id);
102
+ if (!record)
103
+ throw new FileNotFoundError();
104
+ return this.inTenant(resolved, () => this.disk.temporaryUrl(record.path, expiresIn));
105
+ }
106
+ async delete(id, tenantId) {
107
+ const resolved = this.tenant(tenantId);
108
+ const record = await this.store.find(resolved, id);
109
+ if (!record)
110
+ return;
111
+ await this.inTenant(resolved, () => this.disk.delete(record.path));
112
+ await this.store.delete(resolved, id);
113
+ await this.hooks?.emit('file:deleted', { tenantId: resolved, id });
114
+ }
115
+ /** Records the result of an out-of-band scan (antivirus, moderation, …). */
116
+ async markScanned(id, result, tenantId) {
117
+ const resolved = this.tenant(tenantId);
118
+ const record = await this.store.find(resolved, id);
119
+ if (!record)
120
+ throw new FileNotFoundError();
121
+ const patch = { scanned: true, metadata: { ...record.metadata, scan: result } };
122
+ const updated = (await this.store.update(resolved, id, patch)) ?? record;
123
+ await this.hooks?.emit('file:scanned', { file: updated });
124
+ return updated;
125
+ }
126
+ validate(contentType, size) {
127
+ if (this.validation.maxSize !== undefined && size > this.validation.maxSize) {
128
+ throw new FileTooLargeError(size, this.validation.maxSize);
129
+ }
130
+ if (this.validation.allowedTypes && !matchesType(contentType, this.validation.allowedTypes)) {
131
+ throw new FileTypeNotAllowedError(contentType);
132
+ }
133
+ }
134
+ async enforceQuota(tenantId, size) {
135
+ if (this.maxTotalBytes !== undefined) {
136
+ const total = await this.store.totalSize(tenantId);
137
+ if (total + size > this.maxTotalBytes)
138
+ throw new StorageQuotaExceededError();
139
+ }
140
+ await this.checkQuota?.(tenantId, size);
141
+ }
142
+ tenant(explicit) {
143
+ const id = explicit ?? tryCtx()?.['tenant']?.id;
144
+ if (!id)
145
+ throw new FileTenantRequiredError();
146
+ return id;
147
+ }
148
+ /** Runs a storage op in the resolved tenant's context so the disk scopes correctly. */
149
+ inTenant(tenantId, fn) {
150
+ return runWithContext({ tenant: { id: tenantId } }, fn);
151
+ }
152
+ }
package/dist/index.d.ts CHANGED
@@ -1,159 +1,3 @@
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 };
1
+ export { Files, FileTooLargeError, FileTypeNotAllowedError, StorageQuotaExceededError, FileNotFoundError, FileTenantRequiredError, type FilesOptions, type FileValidation, type UploadInput, } from './files.js';
2
+ export { MemoryFileStore, type FileRecord, type FileStore, type FilePatch, } from './store.js';
3
+ export { filesPlugin, fileRoutes, FILES, type FilesPluginOptions } from './plugin.js';
package/dist/index.js CHANGED
@@ -1,254 +1,3 @@
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
- };
1
+ export { Files, FileTooLargeError, FileTypeNotAllowedError, StorageQuotaExceededError, FileNotFoundError, FileTenantRequiredError, } from './files.js';
2
+ export { MemoryFileStore, } from './store.js';
3
+ export { filesPlugin, fileRoutes, FILES } from './plugin.js';
@@ -0,0 +1,34 @@
1
+ import { type Disk } from '@basaltkit/storage';
2
+ import { type BasaltRoute } from '@basaltkit/fastify';
3
+ import { Files, type FileValidation, type FilesOptions } from './files.js';
4
+ import type { FileRecord, FileStore } from './store.js';
5
+ declare module '@basaltkit/core' {
6
+ interface BasaltHooks {
7
+ 'file:uploaded': {
8
+ file: FileRecord;
9
+ };
10
+ 'file:deleted': {
11
+ tenantId: string;
12
+ id: string;
13
+ };
14
+ 'file:scanned': {
15
+ file: FileRecord;
16
+ };
17
+ }
18
+ }
19
+ export declare const FILES: import("@basaltkit/core").Token<Files>;
20
+ export interface FilesPluginOptions {
21
+ /** A `Disk` instance or the name of a disk configured in `@basaltkit/storage`. */
22
+ disk: Disk | string;
23
+ store?: FileStore;
24
+ validate?: FileValidation;
25
+ maxTotalBytes?: number;
26
+ checkQuota?: FilesOptions['checkQuota'];
27
+ }
28
+ export declare function filesPlugin(options: FilesPluginOptions): import("@basaltkit/core").BasaltPlugin<unknown>;
29
+ /**
30
+ * Read/manage routes for the current tenant's files: list, metadata, a signed
31
+ * URL, and delete. Uploading is transport-specific (multipart) — call
32
+ * `FILES.upload(buffer, input)` from your own upload handler.
33
+ */
34
+ export declare function fileRoutes(): BasaltRoute[];
package/dist/plugin.js ADDED
@@ -0,0 +1,72 @@
1
+ import { createToken, ctx, definePlugin } from '@basaltkit/core';
2
+ import { STORAGE } from '@basaltkit/storage';
3
+ import { route } from '@basaltkit/fastify';
4
+ import { z } from 'zod';
5
+ import { Files } from './files.js';
6
+ export const FILES = createToken('files');
7
+ export function filesPlugin(options) {
8
+ return definePlugin({
9
+ name: 'basalt:files',
10
+ register({ container, hooks }) {
11
+ container.singleton(FILES, () => {
12
+ const disk = typeof options.disk === 'string' ? container.get(STORAGE).disk(options.disk) : options.disk;
13
+ return new Files({
14
+ disk,
15
+ hooks,
16
+ ...(options.store ? { store: options.store } : {}),
17
+ ...(options.validate ? { validate: options.validate } : {}),
18
+ ...(options.maxTotalBytes !== undefined ? { maxTotalBytes: options.maxTotalBytes } : {}),
19
+ ...(options.checkQuota ? { checkQuota: options.checkQuota } : {}),
20
+ });
21
+ });
22
+ },
23
+ });
24
+ }
25
+ const files = () => ctx().container.get(FILES);
26
+ /**
27
+ * Read/manage routes for the current tenant's files: list, metadata, a signed
28
+ * URL, and delete. Uploading is transport-specific (multipart) — call
29
+ * `FILES.upload(buffer, input)` from your own upload handler.
30
+ */
31
+ export function fileRoutes() {
32
+ return [
33
+ route({
34
+ method: 'GET',
35
+ url: '/files',
36
+ meta: { auth: true },
37
+ async handler() {
38
+ return files().list();
39
+ },
40
+ }),
41
+ route({
42
+ method: 'GET',
43
+ url: '/files/:id',
44
+ meta: { auth: true },
45
+ params: z.object({ id: z.string() }),
46
+ async handler({ params, reply }) {
47
+ const record = await files().get(params.id);
48
+ return record ?? reply.code(404).send({ error: { code: 'FILE_NOT_FOUND', message: 'File not found.' } });
49
+ },
50
+ }),
51
+ route({
52
+ method: 'POST',
53
+ url: '/files/:id/url',
54
+ meta: { auth: true },
55
+ params: z.object({ id: z.string() }),
56
+ body: z.object({ expiresIn: z.string().optional() }).optional(),
57
+ async handler({ params, body }) {
58
+ return { url: await files().temporaryUrl(params.id, body?.expiresIn ?? '15m') };
59
+ },
60
+ }),
61
+ route({
62
+ method: 'DELETE',
63
+ url: '/files/:id',
64
+ meta: { auth: true },
65
+ params: z.object({ id: z.string() }),
66
+ async handler({ params, reply }) {
67
+ await files().delete(params.id);
68
+ return reply.code(204).send();
69
+ },
70
+ }),
71
+ ];
72
+ }
@@ -0,0 +1,40 @@
1
+ /** Metadata for one uploaded file. The bytes live in storage; this is the record. */
2
+ export interface FileRecord {
3
+ id: string;
4
+ tenantId: string;
5
+ /** Original filename. */
6
+ name: string;
7
+ contentType: string;
8
+ /** Size in bytes. */
9
+ size: number;
10
+ /** Path within the storage disk. */
11
+ path: string;
12
+ /** SHA-256 of the content. */
13
+ checksum: string;
14
+ uploadedBy?: string;
15
+ metadata?: Record<string, unknown>;
16
+ /** Set by a scanning step (antivirus, etc.) via `markScanned`. */
17
+ scanned?: boolean;
18
+ createdAt: number;
19
+ }
20
+ export type FilePatch = Partial<Pick<FileRecord, 'scanned' | 'metadata'>>;
21
+ /** Where file metadata lives — the app's database in production. */
22
+ export interface FileStore {
23
+ create(record: FileRecord): Promise<void>;
24
+ find(tenantId: string, id: string): Promise<FileRecord | null>;
25
+ list(tenantId: string): Promise<FileRecord[]>;
26
+ update(tenantId: string, id: string, patch: FilePatch): Promise<FileRecord | null>;
27
+ delete(tenantId: string, id: string): Promise<void>;
28
+ /** Total bytes stored by a tenant — used for quota checks. */
29
+ totalSize(tenantId: string): Promise<number>;
30
+ }
31
+ export declare class MemoryFileStore implements FileStore {
32
+ private readonly records;
33
+ private key;
34
+ create(record: FileRecord): Promise<void>;
35
+ find(tenantId: string, id: string): Promise<FileRecord | null>;
36
+ list(tenantId: string): Promise<FileRecord[]>;
37
+ update(tenantId: string, id: string, patch: FilePatch): Promise<FileRecord | null>;
38
+ delete(tenantId: string, id: string): Promise<void>;
39
+ totalSize(tenantId: string): Promise<number>;
40
+ }
package/dist/store.js ADDED
@@ -0,0 +1,36 @@
1
+ export class MemoryFileStore {
2
+ records = new Map();
3
+ key(tenantId, id) {
4
+ return `${tenantId} ${id}`;
5
+ }
6
+ async create(record) {
7
+ this.records.set(this.key(record.tenantId, record.id), record);
8
+ }
9
+ async find(tenantId, id) {
10
+ return this.records.get(this.key(tenantId, id)) ?? null;
11
+ }
12
+ async list(tenantId) {
13
+ const out = [];
14
+ for (const record of this.records.values())
15
+ if (record.tenantId === tenantId)
16
+ out.push(record);
17
+ return out;
18
+ }
19
+ async update(tenantId, id, patch) {
20
+ const record = this.records.get(this.key(tenantId, id));
21
+ if (!record)
22
+ return null;
23
+ Object.assign(record, patch);
24
+ return record;
25
+ }
26
+ async delete(tenantId, id) {
27
+ this.records.delete(this.key(tenantId, id));
28
+ }
29
+ async totalSize(tenantId) {
30
+ let total = 0;
31
+ for (const record of this.records.values())
32
+ if (record.tenantId === tenantId)
33
+ total += record.size;
34
+ return total;
35
+ }
36
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@basaltkit/files",
3
- "version": "1.0.0",
3
+ "version": "1.0.1",
4
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
5
  "license": "MIT",
6
6
  "type": "module",
@@ -14,19 +14,18 @@
14
14
  "dist"
15
15
  ],
16
16
  "dependencies": {
17
- "@basaltkit/core": "^1.0.0",
18
- "@basaltkit/storage": "^1.0.0",
19
- "@basaltkit/fastify": "^1.0.0"
17
+ "@basaltkit/core": "^1.1.2",
18
+ "@basaltkit/storage": "^1.2.1",
19
+ "@basaltkit/fastify": "^1.6.1"
20
20
  },
21
21
  "peerDependencies": {
22
22
  "zod": "^3.24.0 || ^4.0.0"
23
23
  },
24
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",
25
+ "@types/node": "^26.3.0",
26
+ "typescript": "^7.0.2",
27
+ "vitest": "^4.1.11",
28
+ "zod": "^3.24.0 || ^4.0.0",
30
29
  "@basaltkit/tsconfig": "^0.24.0"
31
30
  },
32
31
  "publishConfig": {
@@ -34,11 +33,11 @@
34
33
  },
35
34
  "repository": {
36
35
  "type": "git",
37
- "url": "git+https://github.com/Zebedeu/basalt.git",
36
+ "url": "git+https://github.com/basaltkit/basalt.git",
38
37
  "directory": "packages/files"
39
38
  },
40
- "homepage": "https://github.com/Zebedeu/basalt/tree/main/packages/files#readme",
41
- "bugs": "https://github.com/Zebedeu/basalt/issues",
39
+ "homepage": "https://github.com/basaltkit/basalt/tree/main/packages/files#readme",
40
+ "bugs": "https://github.com/basaltkit/basalt/issues",
42
41
  "keywords": [
43
42
  "basalt",
44
43
  "typescript",
@@ -48,7 +47,7 @@
48
47
  "storage"
49
48
  ],
50
49
  "scripts": {
51
- "build": "tsup src/index.ts --format esm --dts --clean",
50
+ "build": "tsc -p tsconfig.build.json",
52
51
  "test": "vitest run",
53
52
  "typecheck": "tsc --noEmit"
54
53
  }