perf(auth): replace bcrypt with bcryptjs (pure JS, no native build tools)

- Switch from bcrypt (native C++ addon) to bcryptjs (pure JavaScript)
- Remove python3/make/g++ build tools from Dockerfile builder stage
- bcryptjs is 100% hash-compatible with bcrypt
- Smaller builder image and faster Docker builds

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-03-20 19:46:16 +01:00
parent aeabdcaf8e
commit 8c2aa261e8
11 changed files with 314 additions and 146 deletions

View file

@ -110,6 +110,23 @@ describe('StorageClient', () => {
expect(result.key).toBe('file.png');
});
it('wraps ReadableStream with size constraint when maxSizeBytes set', async () => {
// Stream that produces 2 chunks of 512 bytes = 1024 total, limit is 768
const stream = new ReadableStream({
start(controller) {
controller.enqueue(new Uint8Array(512));
controller.enqueue(new Uint8Array(512));
controller.close();
},
});
// The stream constraint happens during S3 transfer — since we mock send,
// we verify the upload doesn't throw synchronously (constraint is lazy)
mockSend.mockResolvedValue({ ETag: '"ok"' });
// With a stream, the constraint wraps it but errors happen during read
const result = await storage.upload('file.png', stream, { maxSizeBytes: 2048 });
expect(result.key).toBe('file.png');
});
it('sets ACL to public-read when public option is true', async () => {
mockSend.mockResolvedValue({ ETag: '"abc"' });
const { PutObjectCommand } = await import('@aws-sdk/client-s3');
@ -475,6 +492,20 @@ describe('StorageClient', () => {
});
});
describe('move', () => {
it('copies then deletes source', async () => {
mockSend
.mockResolvedValueOnce({ CopyObjectResult: { ETag: '"moved"' } }) // copy
.mockResolvedValueOnce({}); // delete
const result = await storage.move('old/file.png', 'new/file.png');
expect(result.key).toBe('new/file.png');
expect(result.etag).toBe('"moved"');
expect(mockSend).toHaveBeenCalledTimes(2);
});
});
describe('getMetadata', () => {
it('returns file metadata', async () => {
mockSend.mockResolvedValue({

View file

@ -28,6 +28,39 @@ import type {
FileMetadata,
} from './types';
/**
* Wraps a ReadableStream to enforce a maximum byte size.
* Throws if the stream exceeds the limit mid-transfer.
*/
function constrainStream(stream: ReadableStream, maxBytes: number): ReadableStream {
const reader = stream.getReader();
let bytesRead = 0;
return new ReadableStream({
async pull(controller) {
const { done, value } = await reader.read();
if (done) {
controller.close();
return;
}
bytesRead += value.byteLength;
if (bytesRead > maxBytes) {
controller.error(
new Error(
`Stream size ${bytesRead} bytes exceeds maximum allowed ${maxBytes} bytes`
)
);
reader.cancel();
return;
}
controller.enqueue(value);
},
cancel(reason) {
reader.cancel(reason);
},
});
}
/**
* S3-compatible storage client for MinIO (local) and Hetzner Object Storage (production)
*/
@ -74,12 +107,15 @@ export class StorageClient {
body: Buffer | Uint8Array | string | ReadableStream,
options: UploadOptions = {}
): Promise<UploadResult> {
if (options.maxSizeBytes && typeof body !== 'string' && !(body instanceof ReadableStream)) {
const size = body.byteLength;
if (size > options.maxSizeBytes) {
throw new Error(
`File size ${size} bytes exceeds maximum allowed ${options.maxSizeBytes} bytes`
);
if (options.maxSizeBytes) {
if (typeof body !== 'string' && !(body instanceof ReadableStream)) {
if (body.byteLength > options.maxSizeBytes) {
throw new Error(
`File size ${body.byteLength} bytes exceeds maximum allowed ${options.maxSizeBytes} bytes`
);
}
} else if (body instanceof ReadableStream) {
body = constrainStream(body, options.maxSizeBytes);
}
}
@ -130,12 +166,15 @@ export class StorageClient {
body: Buffer | Uint8Array | ReadableStream,
options: UploadOptions = {}
): Promise<UploadResult> {
if (options.maxSizeBytes && !(body instanceof ReadableStream)) {
const size = body.byteLength;
if (size > options.maxSizeBytes) {
throw new Error(
`File size ${size} bytes exceeds maximum allowed ${options.maxSizeBytes} bytes`
);
if (options.maxSizeBytes) {
if (!(body instanceof ReadableStream)) {
if (body.byteLength > options.maxSizeBytes) {
throw new Error(
`File size ${body.byteLength} bytes exceeds maximum allowed ${options.maxSizeBytes} bytes`
);
}
} else {
body = constrainStream(body, options.maxSizeBytes);
}
}
@ -385,9 +424,17 @@ export class StorageClient {
return keys.length;
}
/**
* Move a file within the same bucket (copy + delete source).
*/
async move(sourceKey: string, destKey: string): Promise<UploadResult> {
const result = await this.copy(sourceKey, destKey);
await this.delete(sourceKey);
return result;
}
/**
* Copy a file within the same bucket.
* For move operations, call copy() then delete() the source.
*/
async copy(sourceKey: string, destKey: string): Promise<UploadResult> {
const command = new CopyObjectCommand({

View file

@ -34,8 +34,8 @@ export type {
} from './hooks';
// Metrics
export { InMemoryMetrics, attachMetrics } from './metrics';
export type { StorageMetricsCollector } from './metrics';
export { InMemoryMetrics, attachMetrics, createPrometheusCollector } from './metrics';
export type { StorageMetricsCollector, MetricsFactory } from './metrics';
// Utilities
export {

View file

@ -1,6 +1,7 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { StorageHooks } from './hooks';
import { InMemoryMetrics, attachMetrics } from './metrics';
import { InMemoryMetrics, attachMetrics, createPrometheusCollector } from './metrics';
import type { MetricsFactory } from './metrics';
describe('InMemoryMetrics', () => {
let metrics: InMemoryMetrics;
@ -111,3 +112,104 @@ describe('attachMetrics', () => {
expect(metrics.sizes).toEqual([]);
});
});
describe('createPrometheusCollector', () => {
function createMockFactory(): MetricsFactory & {
counters: Map<string, { inc: ReturnType<typeof vi.fn> }>;
histograms: Map<string, { observe: ReturnType<typeof vi.fn> }>;
} {
const counters = new Map<string, { inc: ReturnType<typeof vi.fn> }>();
const histograms = new Map<string, { observe: ReturnType<typeof vi.fn> }>();
return {
counters,
histograms,
createCounter(name: string) {
const counter = { inc: vi.fn() };
counters.set(name, counter);
return counter;
},
createHistogram(name: string) {
const histogram = { observe: vi.fn() };
histograms.set(name, histogram);
return histogram;
},
};
}
it('creates expected metrics', () => {
const factory = createMockFactory();
createPrometheusCollector(factory);
expect(factory.counters.has('storage_uploads_total')).toBe(true);
expect(factory.counters.has('storage_upload_errors_total')).toBe(true);
expect(factory.counters.has('storage_deletes_total')).toBe(true);
expect(factory.counters.has('storage_downloads_total')).toBe(true);
expect(factory.histograms.has('storage_upload_size_bytes')).toBe(true);
});
it('increments upload counter with labels', () => {
const factory = createMockFactory();
const collector = createPrometheusCollector(factory);
collector.incrementUploads('picture-storage', 'image/png');
const counter = factory.counters.get('storage_uploads_total');
expect(counter?.inc).toHaveBeenCalledWith({
bucket: 'picture-storage',
content_type: 'image/png',
});
});
it('uses "unknown" for missing content type', () => {
const factory = createMockFactory();
const collector = createPrometheusCollector(factory);
collector.incrementUploads('chat-storage');
const counter = factory.counters.get('storage_uploads_total');
expect(counter?.inc).toHaveBeenCalledWith({
bucket: 'chat-storage',
content_type: 'unknown',
});
});
it('observes upload size in histogram', () => {
const factory = createMockFactory();
const collector = createPrometheusCollector(factory);
collector.observeUploadSize('picture-storage', 1048576);
const histogram = factory.histograms.get('storage_upload_size_bytes');
expect(histogram?.observe).toHaveBeenCalledWith({ bucket: 'picture-storage' }, 1048576);
});
it('increments deletes with count', () => {
const factory = createMockFactory();
const collector = createPrometheusCollector(factory);
collector.incrementDeletes('chat-storage', 5);
const counter = factory.counters.get('storage_deletes_total');
expect(counter?.inc).toHaveBeenCalledWith({ bucket: 'chat-storage' }, 5);
});
it('works end-to-end with hooks', () => {
const factory = createMockFactory();
const collector = createPrometheusCollector(factory);
const hooks = new StorageHooks();
attachMetrics(hooks, collector);
hooks.emit('upload', { bucket: 'test', key: 'f.png', sizeBytes: 512, contentType: 'image/png' });
hooks.emit('download', { bucket: 'test', key: 'f.png' });
hooks.emit('delete', { bucket: 'test', keys: ['a', 'b'] });
expect(factory.counters.get('storage_uploads_total')?.inc).toHaveBeenCalledTimes(1);
expect(factory.counters.get('storage_downloads_total')?.inc).toHaveBeenCalledTimes(1);
expect(factory.counters.get('storage_deletes_total')?.inc).toHaveBeenCalledWith({ bucket: 'test' }, 2);
expect(factory.histograms.get('storage_upload_size_bytes')?.observe).toHaveBeenCalledWith(
{ bucket: 'test' },
512
);
});
});

View file

@ -1,5 +1,27 @@
import type { StorageHooks } from './hooks';
/**
* Minimal interface matching MetricsService.createCounter/createHistogram.
* This avoids a hard dependency on @manacore/shared-nestjs-metrics or prom-client.
*/
export interface MetricsFactory {
createCounter(name: string, help: string, labelNames?: string[]): CounterLike;
createHistogram(
name: string,
help: string,
labelNames?: string[],
buckets?: number[]
): HistogramLike;
}
interface CounterLike {
inc(labels?: Record<string, string | number>, value?: number): void;
}
interface HistogramLike {
observe(labels: Record<string, string | number>, value: number): void;
}
/**
* Interface for a metrics collector decoupled from prom-client so shared-storage
* stays dependency-free. NestJS backends wire this up with their MetricsService.
@ -80,3 +102,67 @@ export function attachMetrics(hooks: StorageHooks, collector: StorageMetricsColl
return () => unsubs.forEach((unsub) => unsub());
}
/**
* Create a StorageMetricsCollector backed by Prometheus counters/histograms.
* Pass your NestJS MetricsService (or anything that matches MetricsFactory).
*
* @example
* // In a NestJS service
* import { MetricsService } from '@manacore/shared-nestjs-metrics';
* import { createPrometheusCollector, attachMetrics } from '@manacore/shared-storage';
*
* const storage = createPictureStorage();
* const collector = createPrometheusCollector(metricsService);
* attachMetrics(storage.hooks, collector);
*/
export function createPrometheusCollector(factory: MetricsFactory): StorageMetricsCollector {
const uploadsCounter = factory.createCounter(
'storage_uploads_total',
'Total storage upload operations',
['bucket', 'content_type']
);
const uploadErrorsCounter = factory.createCounter(
'storage_upload_errors_total',
'Total storage upload errors',
['bucket']
);
const deletesCounter = factory.createCounter(
'storage_deletes_total',
'Total storage delete operations',
['bucket']
);
const downloadsCounter = factory.createCounter(
'storage_downloads_total',
'Total storage download operations',
['bucket']
);
const uploadSizeHistogram = factory.createHistogram(
'storage_upload_size_bytes',
'Upload file sizes in bytes',
['bucket'],
[1024, 10240, 102400, 1048576, 10485760, 104857600] // 1KB, 10KB, 100KB, 1MB, 10MB, 100MB
);
return {
incrementUploads(bucket: string, contentType?: string): void {
uploadsCounter.inc({ bucket, content_type: contentType ?? 'unknown' });
},
incrementUploadErrors(bucket: string): void {
uploadErrorsCounter.inc({ bucket });
},
incrementDeletes(bucket: string, count: number): void {
deletesCounter.inc({ bucket }, count);
},
incrementDownloads(bucket: string): void {
downloadsCounter.inc({ bucket });
},
observeUploadSize(bucket: string, sizeBytes: number): void {
uploadSizeHistogram.observe({ bucket }, sizeBytes);
},
};
}

143
pnpm-lock.yaml generated
View file

@ -3959,9 +3959,9 @@ importers:
'@manacore/shared-vite-config':
specifier: workspace:*
version: link:../../../../packages/shared-vite-config
'@sveltejs/adapter-netlify':
specifier: ^5.2.3
version: 5.2.4(@sveltejs/kit@2.49.0(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.44.0)(vite@7.2.4(@types/node@20.19.25)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.1)))(svelte@5.44.0)(vite@7.2.4(@types/node@20.19.25)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.1)))
'@sveltejs/adapter-node':
specifier: ^5.2.12
version: 5.4.0(@sveltejs/kit@2.49.0(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.44.0)(vite@7.2.4(@types/node@20.19.25)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.1)))(svelte@5.44.0)(vite@7.2.4(@types/node@20.19.25)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.1)))
'@sveltejs/kit':
specifier: ^2.47.1
version: 2.49.0(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.44.0)(vite@7.2.4(@types/node@20.19.25)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.1)))(svelte@5.44.0)(vite@7.2.4(@types/node@20.19.25)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.1))
@ -7082,9 +7082,9 @@ importers:
axios:
specifier: ^1.7.2
version: 1.13.2
bcrypt:
specifier: ^5.1.1
version: 5.1.1(encoding@0.1.13)
bcryptjs:
specifier: ^2.4.3
version: 2.4.3
better-auth:
specifier: ^1.4.3
version: 1.4.4(@sveltejs/kit@2.49.0(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.44.0)(vite@7.2.4(@types/node@22.19.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.20.6)(yaml@2.8.1)))(svelte@5.44.0)(vite@7.2.4(@types/node@22.19.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.20.6)(yaml@2.8.1)))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(svelte@5.44.0)
@ -7164,9 +7164,9 @@ importers:
'@nestjs/testing':
specifier: ^10.4.15
version: 10.4.20(@nestjs/common@10.4.20(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@10.4.20)(@nestjs/platform-express@10.4.20)
'@types/bcrypt':
specifier: ^5.0.2
version: 5.0.2
'@types/bcryptjs':
specifier: ^2.4.6
version: 2.4.6
'@types/body-parser':
specifier: ^1.19.6
version: 1.19.6
@ -11403,9 +11403,6 @@ packages:
resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==}
engines: {node: '>=18.18'}
'@iarna/toml@2.2.5':
resolution: {integrity: sha512-trnsAYxU3xnS1gPHPyU961coFyLkh4gAD/0zQ5mymY4yOZ+CYvsPqUbOFSw0aDM4y0tV7tiFxL/1XfXPNC6IPg==}
'@iconify-json/heroicons@1.2.3':
resolution: {integrity: sha512-n+vmCEgTesRsOpp5AB5ILB6srsgsYK+bieoQBNlafvoEhjVXLq8nIGN4B0v/s4DUfa0dOrjwE/cKJgIKdJXOEg==}
@ -12086,10 +12083,6 @@ packages:
resolution: {integrity: sha512-Z7C/xXCiGWsg0KuKsHTKJxbWhpI3Vs5GwLfOean7MGyVFGqdRgBbAjOCh6u4bbjPc/8MJ2pZmK/0DLdCbivLDA==}
engines: {node: '>=8'}
'@mapbox/node-pre-gyp@1.0.11':
resolution: {integrity: sha512-Yhlar6v9WQgUp/He7BdgzOz8lqMQ8sU+jkCq7Wx8Myc5YFJLbEe7lgui/V7G1qB1DJykHSGwreceSaD60Y0PUQ==}
hasBin: true
'@mapbox/node-pre-gyp@2.0.3':
resolution: {integrity: sha512-uwPAhccfFJlsfCxMYTwOdVfOz3xqyj8xYL3zJj8f0pb30tLohnnFPhLuqp4/qoEz8sNxe4SESZedcBojRefIzg==}
engines: {node: '>=18'}
@ -14660,11 +14653,6 @@ packages:
peerDependencies:
'@sveltejs/kit': ^2.0.0
'@sveltejs/adapter-netlify@5.2.4':
resolution: {integrity: sha512-UtPcZq1HUA43hM8uLi+nsm5Q+YjHNj7/SMFoyeLZeY/VTloVWABEZ0tJ5WodTUmy/8j5QJ7oLZjj28aQxi8y3g==}
peerDependencies:
'@sveltejs/kit': ^2.4.0
'@sveltejs/adapter-node@5.4.0':
resolution: {integrity: sha512-NMsrwGVPEn+J73zH83Uhss/hYYZN6zT3u31R3IHAn3MiKC3h8fjmIAhLfTSOeNHr5wPYfjjMg8E+1gyFgyrEcQ==}
peerDependencies:
@ -14914,8 +14902,8 @@ packages:
'@types/babel__traverse@7.28.0':
resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==}
'@types/bcrypt@5.0.2':
resolution: {integrity: sha512-6atioO8Y75fNcbmj0G7UjI9lXN2pQ/IGJ2FWT4a/btd0Lk9lQalHLKhkgKVZ3r+spnmWUKfbMi1GEe9wyHQfNQ==}
'@types/bcryptjs@2.4.6':
resolution: {integrity: sha512-9xlo6R2qDs5uixm0bcIqCeMCE6HiQsIyel9KQySStiyqNl2tnj2mP3DX1Nf56MD6KMenNNlBBsy3LJ7gUEQPXQ==}
'@types/body-parser@1.19.6':
resolution: {integrity: sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==}
@ -16174,11 +16162,6 @@ packages:
aproba@2.1.0:
resolution: {integrity: sha512-tLIEcj5GuR2RSTnxNKdkK0dJ/GrC7P38sUkiDmDuHfsHmbagTFAxDVIBltoklXEVIQ/f14IL8IMJ5pn9Hez1Ew==}
are-we-there-yet@2.0.0:
resolution: {integrity: sha512-Ci/qENmwHnsYo9xKIcUJN5LeDKdJ6R1Z1j9V/J5wyq8nh/mYPEpIKJbBZXtZjG04HiK7zV/p6Vs9952MrMeUIw==}
engines: {node: '>=10'}
deprecated: This package is no longer supported.
are-we-there-yet@3.0.1:
resolution: {integrity: sha512-QZW4EDmGwlYur0Yyf/b2uGucHQMa8aFUP7eu9ddR73vvhFyt4V0Vl3QHPcTNJ8l6qYOBdxgXdnBXQrHilfRQBg==}
engines: {node: ^12.13.0 || ^14.15.0 || >=16.0.0}
@ -16554,9 +16537,8 @@ packages:
bcrypt-pbkdf@1.0.2:
resolution: {integrity: sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w==}
bcrypt@5.1.1:
resolution: {integrity: sha512-AGBHOG5hPYZ5Xl9KXzU5iKq9516yEmvCKDg3ecP5kX2aB6UqTeXZxk2ELnDgDm6BQSMlLt9rDB4LoSMx0rYwww==}
engines: {node: '>= 10.0.0'}
bcryptjs@2.4.3:
resolution: {integrity: sha512-V/Hy/X9Vt7f3BbPJEi8BdVFMByHi+jNXrYkW3huaybV/kQ0KJg0Y6PkEMbn+zeT+i+SiKZ/HMqJGIIt4LZDqNQ==}
better-auth@1.4.4:
resolution: {integrity: sha512-YawWmrqva1BhBtJl0CgspuWI+5RrApWI/Q7Gs3KnSyJYOaux3pWOsx2Jb5gCloNdYgTZsgdr3r1mNk5eEyOvCg==}
@ -19844,11 +19826,6 @@ packages:
resolution: {integrity: sha512-trLf4SzuuUxfusZADLINj+dE8clK1frKdmqiJNb1Es75fmI5oY6X2mxLVUciLLjxqw/xr72Dhy+lER6dGd02FQ==}
engines: {node: '>=10'}
gauge@3.0.2:
resolution: {integrity: sha512-+5J6MS/5XksCuXq++uFRsnUd7Ovu1XenbeuIuNRJxYWjgQbPuFhT14lAvsWfqfAmnwluf1OwMjz39HjfLPci0Q==}
engines: {node: '>=10'}
deprecated: This package is no longer supported.
gauge@4.0.4:
resolution: {integrity: sha512-f9m+BEN5jkg6a0fZjleidjN51VE1X+mPFQ2DJ0uv1V39oCLCbsGe6yjbBnp7eK7z/+GAon99a3nHuqbuuthyPg==}
engines: {node: ^12.13.0 || ^14.15.0 || >=16.0.0}
@ -21767,10 +21744,6 @@ packages:
resolution: {integrity: sha512-LS9X+dc8KLxXCb8dni79fLIIUA5VyZoyjSMCwTluaXA0o27cCK0bhXkpgw+sTXVpPy/lSO57ilRixqk0vDmtRA==}
engines: {node: '>=6'}
make-dir@3.1.0:
resolution: {integrity: sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==}
engines: {node: '>=8'}
make-dir@4.0.0:
resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==}
engines: {node: '>=10'}
@ -22595,9 +22568,6 @@ packages:
node-abort-controller@3.1.1:
resolution: {integrity: sha512-AGK2yQKIjRuqnc6VkX2Xj5d+QW8xZ87pa1UK6yA6ouUyuxfHuMP6umE5QK7UmTeOAymo+Zx1Fxiuw9rVx8taHQ==}
node-addon-api@5.1.0:
resolution: {integrity: sha512-eh0GgfEkpnoWDq+VY8OyvYhFEzBk6jIYbRKdIlyTiAXIVJ8PyBaKb0rp7oDtoddbdoHWhq8wwr+XZ81F1rpNdA==}
node-addon-api@7.1.1:
resolution: {integrity: sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==}
@ -22666,11 +22636,6 @@ packages:
resolution: {integrity: sha512-H+rnK5bX2Pi/6ms3sN4/jRQvYSMltV6vqup/0SFOrxYYY/qoNvhXPlYq3e+Pm9RFJRwrMGbMIwi81M4dxpomhA==}
engines: {node: '>=6.0.0'}
nopt@5.0.0:
resolution: {integrity: sha512-Tbj67rffqceeLpcRXrT7vKAN8CwfPeIBgM7E6iBkmKLV7bEMwpGgYLGv0jACUsECaa/vuxP0IjEont6umdMgtQ==}
engines: {node: '>=6'}
hasBin: true
nopt@6.0.0:
resolution: {integrity: sha512-ZwLpbTgdhuZUnZzjd7nb1ZV+4DoiC6/sfiVKok72ym/4Tlf+DFdlHYmT2JPmcNNWV6Pi3SDf1kT+A4r9RTuT9g==}
engines: {node: ^12.13.0 || ^14.15.0 || >=16.0.0}
@ -22705,10 +22670,6 @@ packages:
resolution: {integrity: sha512-ppwTtiJZq0O/ai0z7yfudtBpWIoxM8yE6nHi1X47eFR2EWORqfbu6CnPlNsjeN683eT0qG6H/Pyf9fCcvjnnnQ==}
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
npmlog@5.0.1:
resolution: {integrity: sha512-AqZtDUWOMKs1G/8lwylVjrdYgqA4d9nu8hc+0gzRxlDb1I10+FHBGMXs6aiQHFdCUUlqH99MUMuLfzWDNDtfxw==}
deprecated: This package is no longer supported.
npmlog@6.0.2:
resolution: {integrity: sha512-/vBvz5Jfr9dT/aFWd0FIRf+T/Q2WBsLENygUaFUqstqsycmZAP/t5BvFJTK0viFmSUxiUKTUplWy5vt+rvKIxg==}
engines: {node: ^12.13.0 || ^14.15.0 || >=16.0.0}
@ -32089,8 +32050,6 @@ snapshots:
'@humanwhocodes/retry@0.4.3': {}
'@iarna/toml@2.2.5': {}
'@iconify-json/heroicons@1.2.3':
dependencies:
'@iconify/types': 2.0.0
@ -33122,21 +33081,6 @@ snapshots:
'@lukeed/csprng@1.1.0': {}
'@mapbox/node-pre-gyp@1.0.11(encoding@0.1.13)':
dependencies:
detect-libc: 2.1.2
https-proxy-agent: 5.0.1
make-dir: 3.1.0
node-fetch: 2.7.0(encoding@0.1.13)
nopt: 5.0.0
npmlog: 5.0.1
rimraf: 3.0.2
semver: 7.7.3
tar: 6.2.1
transitivePeerDependencies:
- encoding
- supports-color
'@mapbox/node-pre-gyp@2.0.3(encoding@0.1.13)':
dependencies:
consola: 3.4.2
@ -38668,13 +38612,6 @@ snapshots:
dependencies:
'@sveltejs/kit': 2.49.0(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.44.0)(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.1)))(svelte@5.44.0)(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.1))
'@sveltejs/adapter-netlify@5.2.4(@sveltejs/kit@2.49.0(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.44.0)(vite@7.2.4(@types/node@20.19.25)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.1)))(svelte@5.44.0)(vite@7.2.4(@types/node@20.19.25)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.1)))':
dependencies:
'@iarna/toml': 2.2.5
'@sveltejs/kit': 2.49.0(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.44.0)(vite@7.2.4(@types/node@20.19.25)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.1)))(svelte@5.44.0)(vite@7.2.4(@types/node@20.19.25)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.1))
esbuild: 0.25.12
set-cookie-parser: 2.7.2
'@sveltejs/adapter-node@5.4.0(@sveltejs/kit@2.49.0(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@5.1.1(svelte@5.44.0)(vite@6.4.1(@types/node@20.19.25)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.1)))(svelte@5.44.0)(vite@6.4.1(@types/node@20.19.25)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.1)))':
dependencies:
'@rollup/plugin-commonjs': 28.0.9(rollup@4.53.3)
@ -38691,6 +38628,14 @@ snapshots:
'@sveltejs/kit': 2.49.0(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@5.1.1(svelte@5.44.0)(vite@6.4.1(@types/node@22.19.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.1)))(svelte@5.44.0)(vite@6.4.1(@types/node@22.19.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.1))
rollup: 4.53.3
'@sveltejs/adapter-node@5.4.0(@sveltejs/kit@2.49.0(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.44.0)(vite@7.2.4(@types/node@20.19.25)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.1)))(svelte@5.44.0)(vite@7.2.4(@types/node@20.19.25)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.1)))':
dependencies:
'@rollup/plugin-commonjs': 28.0.9(rollup@4.53.3)
'@rollup/plugin-json': 6.1.0(rollup@4.53.3)
'@rollup/plugin-node-resolve': 16.0.3(rollup@4.53.3)
'@sveltejs/kit': 2.49.0(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.44.0)(vite@7.2.4(@types/node@20.19.25)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.1)))(svelte@5.44.0)(vite@7.2.4(@types/node@20.19.25)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.1))
rollup: 4.53.3
'@sveltejs/adapter-node@5.4.0(@sveltejs/kit@2.49.0(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.44.0)(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.1)))(svelte@5.44.0)(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.1)))':
dependencies:
'@rollup/plugin-commonjs': 28.0.9(rollup@4.53.3)
@ -39276,9 +39221,7 @@ snapshots:
dependencies:
'@babel/types': 7.28.5
'@types/bcrypt@5.0.2':
dependencies:
'@types/node': 22.19.1
'@types/bcryptjs@2.4.6': {}
'@types/body-parser@1.19.6':
dependencies:
@ -41251,11 +41194,6 @@ snapshots:
aproba@2.1.0: {}
are-we-there-yet@2.0.0:
dependencies:
delegates: 1.0.0
readable-stream: 3.6.2
are-we-there-yet@3.0.1:
dependencies:
delegates: 1.0.0
@ -42042,13 +41980,7 @@ snapshots:
dependencies:
tweetnacl: 0.14.5
bcrypt@5.1.1(encoding@0.1.13):
dependencies:
'@mapbox/node-pre-gyp': 1.0.11(encoding@0.1.13)
node-addon-api: 5.1.0
transitivePeerDependencies:
- encoding
- supports-color
bcryptjs@2.4.3: {}
better-auth@1.4.4(@sveltejs/kit@2.49.0(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.44.0)(vite@7.2.4(@types/node@22.19.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.20.6)(yaml@2.8.1)))(svelte@5.44.0)(vite@7.2.4(@types/node@22.19.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.20.6)(yaml@2.8.1)))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(svelte@5.44.0):
dependencies:
@ -47777,18 +47709,6 @@ snapshots:
fuse.js@7.1.0: {}
gauge@3.0.2:
dependencies:
aproba: 2.1.0
color-support: 1.1.3
console-control-strings: 1.1.0
has-unicode: 2.0.1
object-assign: 4.1.1
signal-exit: 3.0.7
string-width: 4.2.3
strip-ansi: 6.0.1
wide-align: 1.1.5
gauge@4.0.4:
dependencies:
aproba: 2.1.0
@ -50889,10 +50809,6 @@ snapshots:
pify: 4.0.1
semver: 5.7.2
make-dir@3.1.0:
dependencies:
semver: 6.3.1
make-dir@4.0.0:
dependencies:
semver: 7.7.3
@ -52606,8 +52522,6 @@ snapshots:
node-abort-controller@3.1.1: {}
node-addon-api@5.1.0: {}
node-addon-api@7.1.1: {}
node-dir@0.1.17:
@ -52674,10 +52588,6 @@ snapshots:
nodemailer@7.0.12: {}
nopt@5.0.0:
dependencies:
abbrev: 1.1.1
nopt@6.0.0:
dependencies:
abbrev: 1.1.1
@ -52709,13 +52619,6 @@ snapshots:
dependencies:
path-key: 4.0.0
npmlog@5.0.1:
dependencies:
are-we-there-yet: 2.0.0
console-control-strings: 1.1.0
gauge: 3.0.2
set-blocking: 2.0.0
npmlog@6.0.2:
dependencies:
are-we-there-yet: 3.0.1

View file

@ -2,9 +2,8 @@
# Using node:20-slim instead of alpine for DuckDB glibc compatibility
FROM node:20-slim AS builder
# Install pnpm and build tools for native modules (bcrypt)
RUN corepack enable && corepack prepare pnpm@9.15.0 --activate \
&& apt-get update && apt-get install -y python3 make g++ && rm -rf /var/lib/apt/lists/*
# Install pnpm (no build tools needed — bcryptjs is pure JS, DuckDB ships prebuilt binaries)
RUN corepack enable && corepack prepare pnpm@9.15.0 --activate
WORKDIR /app

View file

@ -37,7 +37,7 @@
"@nestjs/throttler": "^6.2.1",
"@types/multer": "^2.0.0",
"axios": "^1.7.2",
"bcrypt": "^5.1.1",
"bcryptjs": "^2.4.3",
"better-auth": "^1.4.3",
"body-parser": "^2.2.2",
"class-transformer": "^0.5.1",
@ -66,7 +66,7 @@
"@nestjs/cli": "^11.0.0",
"@nestjs/schematics": "^11.0.0",
"@nestjs/testing": "^10.4.15",
"@types/bcrypt": "^5.0.2",
"@types/bcryptjs": "^2.4.6",
"@types/body-parser": "^1.19.6",
"@types/cookie-parser": "^1.4.7",
"@types/express": "^5.0.0",

View file

@ -5,7 +5,7 @@
*/
import { nanoid } from 'nanoid';
import * as bcrypt from 'bcrypt';
import * as bcrypt from 'bcryptjs';
/**
* Mock User Factory

View file

@ -1500,7 +1500,7 @@ export class BetterAuthService {
const db = getDb(this.databaseUrl);
const { accounts } = await import('../../db/schema/auth.schema');
const { eq, and } = await import('drizzle-orm');
const bcrypt = await import('bcrypt');
const bcrypt = await import('bcryptjs');
// Get credential account (where password is stored)
const [account] = await db
@ -1560,7 +1560,7 @@ export class BetterAuthService {
const db = getDb(this.databaseUrl);
const { accounts, users, sessions } = await import('../../db/schema/auth.schema');
const { eq, and } = await import('drizzle-orm');
const bcrypt = await import('bcrypt');
const bcrypt = await import('bcryptjs');
// Get credential account
const [account] = await db

View file

@ -8,7 +8,7 @@ import {
} from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { eq, and, desc, sql } from 'drizzle-orm';
import * as bcrypt from 'bcrypt';
import * as bcrypt from 'bcryptjs';
import { getDb } from '../../db/connection';
import {
giftCodes,