Skip to content

Commit 3ba220b

Browse files
cyber-haribavi-r
authored andcommitted
feat: fix issues and improve tests
1 parent a25ede9 commit 3ba220b

40 files changed

Lines changed: 1899 additions & 78 deletions

src/array/array.utils.ts

Lines changed: 83 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { getValueByPath } from '@catbee/utils/obj';
1+
import { getValueByPath } from '@catbee/utils/object';
22
import { randomBytes } from 'node:crypto';
33

44
/**
@@ -85,6 +85,8 @@ export function random<T>(array: readonly T[]): T | undefined {
8585
return array[secureIndex(array.length)];
8686
}
8787

88+
type StrNumSym = string | number | symbol;
89+
8890
/* eslint-disable no-redeclare */
8991
/**
9092
* Groups items in an array by a nested key or key function.
@@ -96,20 +98,17 @@ export function random<T>(array: readonly T[]): T | undefined {
9698
* @returns {Record<string, readonly T[]>}
9799
* @overload
98100
* @param {T[]} array - The array to group.
99-
* @param {(item: T) => string | number | symbol} keyFn - Function to generate group key from item.
101+
* @param {(item: T) => StrNumSym} keyFn - Function to generate group key from item.
100102
* @returns {Record<K, readonly T[]>}
101103
* @param {T[]} array - The array to group.
102-
* @param {keyof T | ((item: T) => string | number | symbol)} keyOrFn - Nested property key or key selector.
103-
* @returns {Record<string | number | symbol, readonly T[]>} Grouped result object.
104+
* @param {keyof T | ((item: T) => StrNumSym)} keyOrFn - Nested property key or key selector.
105+
* @returns {Record<StrNumSym, readonly T[]>} Grouped result object.
104106
*/
105107
export function groupBy<T>(array: T[], key: keyof T): Record<string, readonly T[]>;
106-
export function groupBy<T, K extends string | number | symbol>(
107-
array: T[],
108-
keyFn: (item: T) => K
109-
): Record<K, readonly T[]>;
108+
export function groupBy<T, K extends StrNumSym>(array: T[], keyFn: (item: T) => K): Record<K, readonly T[]>;
110109
export function groupBy<T>(
111110
array: readonly T[],
112-
keyOrFn: keyof T | ((item: T) => string | number | symbol)
111+
keyOrFn: keyof T | ((item: T) => StrNumSym)
113112
): Record<string, readonly T[]> {
114113
if (!Array.isArray(array) || array.length === 0) return {};
115114

@@ -119,7 +118,7 @@ export function groupBy<T>(
119118
const result: Record<string, T[]> = {};
120119
for (const item of array) {
121120
const key = String(keyFn(item));
122-
if (Object.hasOwn?.(result, key) ?? Object.prototype.hasOwnProperty.call(result, key)) {
121+
if (Object.hasOwn(result, key)) {
123122
result[key].push(item);
124123
} else {
125124
result[key] = [item];
@@ -403,10 +402,10 @@ export function compact<T>(array: readonly T[]): NonNullable<T>[] {
403402
*
404403
* @template T The type of array elements.
405404
* @param {T[]} array - The input array.
406-
* @param {(item: T) => string | number | symbol} keyFn - Function to generate count key.
405+
* @param {(item: T) => StrNumSym} keyFn - Function to generate count key.
407406
* @returns {Record<string, number>} Object with counts by key.
408407
*/
409-
export function countBy<T>(array: readonly T[], keyFn: (item: T) => string | number | symbol): Record<string, number> {
408+
export function countBy<T>(array: readonly T[], keyFn: (item: T) => StrNumSym): Record<string, number> {
410409
if (!Array.isArray(array)) return {};
411410
const result: Record<string, number> = {};
412411
for (const item of array) {
@@ -598,3 +597,75 @@ export function headOfArr<T>(array: readonly T[]): T | undefined {
598597
export function lastOfArr<T>(array: readonly T[]): T | undefined {
599598
return Array.isArray(array) && array.length > 0 ? array.at(-1) : undefined;
600599
}
600+
601+
/**
602+
* Drops the first n elements from an array.
603+
*
604+
* @template T
605+
* @param {readonly T[]} array - The source array.
606+
* @param {number} n - Number of elements to drop.
607+
* @returns {T[]} Array with first n elements removed.
608+
*
609+
* @example
610+
* drop([1, 2, 3, 4, 5], 2); // [3, 4, 5]
611+
*/
612+
export function drop<T>(array: readonly T[], n: number): T[] {
613+
if (!Array.isArray(array) || n <= 0) return [...array];
614+
return array.slice(n);
615+
}
616+
617+
/**
618+
* Drops elements from the start of an array while predicate returns true.
619+
*
620+
* @template T
621+
* @param {readonly T[]} array - The source array.
622+
* @param {(item: T, index: number) => boolean} predicate - Condition function.
623+
* @returns {T[]} Array with elements dropped.
624+
*
625+
* @example
626+
* dropWhile([1, 2, 3, 4, 1], x => x < 3); // [3, 4, 1]
627+
*/
628+
export function dropWhile<T>(array: readonly T[], predicate: (item: T, index: number) => boolean): T[] {
629+
if (!Array.isArray(array)) return [];
630+
let i = 0;
631+
while (i < array.length && predicate(array[i], i)) {
632+
i++;
633+
}
634+
return array.slice(i);
635+
}
636+
637+
/**
638+
* Finds the element with the maximum value for a given key or function.
639+
*
640+
* @template T
641+
* @param {readonly T[]} array - The source array.
642+
* @param {keyof T | ((item: T) => number)} keyOrFn - Property key or function.
643+
* @returns {T | undefined} Element with maximum value.
644+
*
645+
* @example
646+
* maxBy([{a: 1}, {a: 5}, {a: 3}], 'a'); // {a: 5}
647+
* maxBy([{a: 1}, {a: 5}, {a: 3}], x => x.a); // {a: 5}
648+
*/
649+
export function maxBy<T>(array: readonly T[], keyOrFn: keyof T | ((item: T) => number)): T | undefined {
650+
if (!Array.isArray(array) || array.length === 0) return undefined;
651+
const fn = typeof keyOrFn === 'function' ? keyOrFn : (item: T) => item[keyOrFn] as unknown as number;
652+
return array.reduce<T>((max, item) => (fn(item) > fn(max) ? item : max), array[0]);
653+
}
654+
655+
/**
656+
* Finds the element with the minimum value for a given key or function.
657+
*
658+
* @template T
659+
* @param {readonly T[]} array - The source array.
660+
* @param {keyof T | ((item: T) => number)} keyOrFn - Property key or function.
661+
* @returns {T | undefined} Element with minimum value.
662+
*
663+
* @example
664+
* minBy([{a: 1}, {a: 5}, {a: 3}], 'a'); // {a: 1}
665+
* minBy([{a: 1}, {a: 5}, {a: 3}], x => x.a); // {a: 1}
666+
*/
667+
export function minBy<T>(array: readonly T[], keyOrFn: keyof T | ((item: T) => number)): T | undefined {
668+
if (!Array.isArray(array) || array.length === 0) return undefined;
669+
const fn = typeof keyOrFn === 'function' ? keyOrFn : (item: T) => item[keyOrFn] as unknown as number;
670+
return array.reduce<T>((min, item) => (fn(item) < fn(min) ? item : min), array[0]);
671+
}

src/async/async.utils.ts

Lines changed: 35 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,11 @@ export function debounce<T extends (...args: any[]) => void>(fn: T, delay: numbe
6161
};
6262
}
6363

64+
const DEFAULT_THROTTLE_OPTS = {
65+
leading: true,
66+
trailing: false
67+
} as const;
68+
6469
/**
6570
* Creates a throttled version of a function that limits its execution rate.
6671
* Allows control over leading/trailing invocation.
@@ -74,10 +79,7 @@ export function debounce<T extends (...args: any[]) => void>(fn: T, delay: numbe
7479
export function throttle<T extends (...args: any[]) => void>(
7580
fn: T,
7681
limit: number,
77-
opts: { leading?: boolean; trailing?: boolean } = {
78-
leading: true,
79-
trailing: false
80-
}
82+
opts: { leading?: boolean; trailing?: boolean } = DEFAULT_THROTTLE_OPTS
8183
): (...args: Parameters<T>) => void {
8284
let lastCall = 0;
8385
let timer: NodeJS.Timeout | null = null;
@@ -749,3 +751,32 @@ export function optionalRequire<T = any>(name: string): T | null {
749751
return null;
750752
}
751753
}
754+
755+
/**
756+
* Races promises and returns the first fulfilled value with its index.
757+
* If all promises reject, the last rejection is thrown.
758+
*
759+
* @template T
760+
* @param {Promise<T>[]} promises - Array of promises.
761+
* @returns {Promise<{ value: T; index: number }>} First fulfilled value with index.
762+
*
763+
* @example
764+
* const result = await raceWithValue([fetchA(), fetchB(), fetchC()]);
765+
* console.log(result.value, result.index); // First fulfilled promise result
766+
*/
767+
export async function raceWithValue<T>(promises: Promise<T>[]): Promise<{ value: T; index: number }> {
768+
return new Promise((resolve, reject) => {
769+
let rejectedCount = 0;
770+
let lastError: any;
771+
772+
promises.forEach((p, index) => {
773+
p.then(value => resolve({ value, index })).catch(err => {
774+
rejectedCount++;
775+
lastError = err;
776+
if (rejectedCount === promises.length) {
777+
reject(lastError);
778+
}
779+
});
780+
});
781+
});
782+
}

src/config/config.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import type { CatbeeConfig, CatbeeGlobalServerConfig } from '@catbee/utils/types
22
import type { Logger, LoggerLevels } from '@catbee/utils/logger';
33
import { Env } from '@catbee/utils/env';
44
import { uuid } from '@catbee/utils/id';
5-
import { deepClone, deepObjMerge } from '@catbee/utils/obj';
5+
import { deepClone, deepObjMerge } from '@catbee/utils/object';
66

77
/**
88
* Extends Express Request interface to add request ID tracking.
@@ -83,9 +83,9 @@ export const defaultServerConfig: CatbeeGlobalServerConfig = {
8383
serviceVersion: {
8484
enable: Env.getBoolean('SERVER_SERVICE_VERSION_ENABLE', false),
8585
headerName: Env.get('SERVER_SERVICE_VERSION_HEADER_NAME', 'x-service-version'),
86-
version: Env.get('SERVER_SERVICE_VERSION', '0.0.0')
86+
version: Env.get('${npm_package_version}', '0.0.0')
8787
},
88-
skipHealthz: Env.getBoolean('SERVER_SKIP_HEALTHZ', false)
88+
skipHealthzChecksValidation: Env.getBoolean('SERVER_SKIP_HEALTHZ_CHECKS_VALIDATION', false)
8989
} as const;
9090

9191
/** Default Catbee configuration loaded from environment variables. */

src/context-store/context-store.utils.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -263,8 +263,7 @@ export class TypedContextKey<T> {
263263
* @returns The value or defaultValue if not found
264264
*/
265265
get(): T | undefined {
266-
const value = ContextStore.get<T>(this.symbol);
267-
return value !== undefined ? value : this.defaultValue;
266+
return ContextStore.get<T>(this.symbol) ?? this.defaultValue;
268267
}
269268

270269
/**

src/crypto/crypto.utils.ts

Lines changed: 120 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,18 @@ import {
66
createCipheriv,
77
createDecipheriv,
88
scrypt,
9-
timingSafeEqual
9+
timingSafeEqual,
10+
pbkdf2
1011
} from 'node:crypto';
1112
import type { CipherGCMTypes } from 'node:crypto';
1213
import { promisify } from 'node:util';
1314
import { uuid } from '@catbee/utils/id';
1415

16+
// Promisified version of pbkdf2 for key derivation
17+
const pbkdf2Async = promisify(pbkdf2);
18+
// Promisified version of scrypt for key derivation
19+
const scryptAsync = promisify<string | Buffer, string | Buffer, number, Buffer>(scrypt);
20+
1521
export type BufferEncoding =
1622
| 'ascii'
1723
| 'utf8'
@@ -203,11 +209,10 @@ export interface EncryptionResult {
203209
authTag?: Buffer;
204210
/** Algorithm used */
205211
algorithm: string;
212+
/** Salt used for key derivation */
213+
salt: Buffer;
206214
}
207215

208-
// Promisified version of scrypt for key derivation
209-
const scryptAsync = promisify<string | Buffer, string | Buffer, number, Buffer>(scrypt);
210-
211216
/**
212217
* Encrypts data using a symmetric key with secure defaults (AES-256-GCM).
213218
*
@@ -229,7 +234,8 @@ export async function encrypt(
229234
const iv = randomBytes(16);
230235

231236
// Derive key using scrypt if key is a string (passphrase)
232-
const derivedKey = typeof key === 'string' ? await scryptAsync(key, iv.slice(0, 8), 32) : key;
237+
const salt = randomBytes(8);
238+
const derivedKey = typeof key === 'string' ? await scryptAsync(key, salt, 32) : key;
233239

234240
// Create cipher
235241
const cipher = createCipheriv(algorithm, derivedKey, iv);
@@ -252,7 +258,8 @@ export async function encrypt(
252258
ciphertext,
253259
iv,
254260
authTag,
255-
algorithm
261+
algorithm,
262+
salt
256263
};
257264
}
258265

@@ -274,7 +281,7 @@ export async function decrypt(
274281
const outputEncoding = options.outputEncoding || 'utf8';
275282

276283
// Derive key using scrypt if key is a string (passphrase)
277-
const derivedKey = typeof key === 'string' ? await scryptAsync(key, encryptedData.iv.slice(0, 8), 32) : key;
284+
const derivedKey = typeof key === 'string' ? await scryptAsync(key, encryptedData.salt, 32) : key;
278285

279286
// Create decipher
280287
const decipher = createDecipheriv(algorithm, derivedKey, encryptedData.iv);
@@ -362,3 +369,109 @@ export function verifySignedToken(token: string, secret: string): Record<string,
362369
return null;
363370
}
364371
}
372+
373+
/**
374+
* Derives a cryptographic key using PBKDF2 (SHA-256).
375+
*
376+
* @param password - Password to derive key from
377+
* @param salt - Cryptographic salt (use unique per password)
378+
* @param keyLength - Output key length in bytes (default 32)
379+
* @param iterations - Number of hashing iterations (default 310000, OWASP recommended)
380+
* @returns Derived key as Buffer
381+
*
382+
* @example
383+
* const key = await pbkdf2Hash('myPassword', 'mySalt');
384+
*/
385+
export async function pbkdf2Hash(
386+
password: string,
387+
salt: string | Buffer,
388+
keyLength = 32,
389+
iterations = 310_000
390+
): Promise<Buffer> {
391+
return pbkdf2Async(password, salt, iterations, keyLength, 'sha256') as Promise<Buffer>;
392+
}
393+
394+
/**
395+
* Generates a cryptographically secure nonce (number used once).
396+
*
397+
* @param {number} [byteLength=16] - Length of nonce in bytes.
398+
* @param {BinaryToTextEncoding} [encoding='hex'] - Output encoding.
399+
* @returns {string} Nonce string.
400+
*
401+
* @example
402+
* const nonce = generateNonce(16, 'base64'); // Random nonce
403+
*/
404+
export function generateNonce(byteLength: number = 16, encoding: BinaryToTextEncoding = 'hex'): string {
405+
return randomBytes(byteLength).toString(encoding);
406+
}
407+
408+
/**
409+
* Generates a cryptographically secure random integer in a range.
410+
*
411+
* @param {number} min - Minimum value (inclusive).
412+
* @param {number} max - Maximum value (inclusive).
413+
* @returns {number} Random integer.
414+
*
415+
* @example
416+
* const random = secureRandomInt(1, 100); // Random number 1-100
417+
*/
418+
export function secureRandomInt(min: number, max: number): number {
419+
if (min > max) throw new Error('min must be less than or equal to max');
420+
const range = max - min + 1;
421+
const bytesNeeded = Math.ceil(Math.log2(range) / 8);
422+
const maxValid = Math.floor(256 ** bytesNeeded / range) * range;
423+
424+
let randomValue: number;
425+
do {
426+
const bytes = randomBytes(bytesNeeded);
427+
randomValue = bytes.reduce((acc, byte, i) => acc + byte * 256 ** i, 0);
428+
} while (randomValue >= maxValid);
429+
430+
return min + (randomValue % range);
431+
}
432+
433+
/**
434+
* Hashes a password using scrypt (memory-hard function).
435+
*
436+
* @param {string} password - The password to hash.
437+
* @param {number} [saltLength=16] - Length of salt in bytes.
438+
* @param {number} [keyLength=32] - Length of derived key.
439+
* @returns {Promise<string>} Hash string containing salt and key.
440+
*
441+
* @example
442+
* const hash = await hashPassword('myPassword');
443+
* // Returns format: salt:hash (both base64)
444+
*/
445+
export async function hashPassword(password: string, saltLength: number = 16, keyLength: number = 32): Promise<string> {
446+
const salt = randomBytes(saltLength);
447+
const scryptAsync = promisify(scrypt);
448+
const derivedKey = (await scryptAsync(password, salt, keyLength)) as Buffer;
449+
return `${salt.toString('base64')}:${derivedKey.toString('base64')}`;
450+
}
451+
452+
/**
453+
* Verifies a password against a scrypt hash.
454+
*
455+
* @param {string} password - The password to verify.
456+
* @param {string} hash - The hash to verify against (from hashPassword).
457+
* @returns {Promise<boolean>} True if password matches.
458+
*
459+
* @example
460+
* const isValid = await verifyPassword('myPassword', storedHash);
461+
*/
462+
export async function verifyPassword(password: string, hash: string): Promise<boolean> {
463+
try {
464+
const [saltB64, keyB64] = hash.split(':');
465+
if (!saltB64 || !keyB64) return false;
466+
467+
const salt = Buffer.from(saltB64, 'base64');
468+
const key = Buffer.from(keyB64, 'base64');
469+
470+
const scryptAsync = promisify(scrypt);
471+
const derivedKey = (await scryptAsync(password, salt, key.length)) as Buffer;
472+
473+
return timingSafeEqual(key, derivedKey);
474+
} catch {
475+
return false;
476+
}
477+
}

0 commit comments

Comments
 (0)