@anteros/core 0.0.1-alpha.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.
Files changed (55) hide show
  1. package/README.md +143 -0
  2. package/database/collection.ts +160 -0
  3. package/database/decorator.ts +172 -0
  4. package/database/file.ts +93 -0
  5. package/database/mongodbadapter.ts +1128 -0
  6. package/database/rest.ts +14 -0
  7. package/database/schema.ts +160 -0
  8. package/database/tenant.ts +37 -0
  9. package/database/workflow.ts +384 -0
  10. package/index.ts +28 -0
  11. package/lib/asyncContextStorage.ts +68 -0
  12. package/lib/define.ts +114 -0
  13. package/lib/error.ts +21 -0
  14. package/lib/files.ts +459 -0
  15. package/lib/middleware.ts +66 -0
  16. package/lib/routes.ts +44 -0
  17. package/lib/scripts.ts +47 -0
  18. package/lib/services.ts +45 -0
  19. package/lib/sockets.ts +44 -0
  20. package/lib/workflow.ts +60 -0
  21. package/package.json +31 -0
  22. package/server/api.ts +789 -0
  23. package/server/boot.ts +101 -0
  24. package/server/config.ts +107 -0
  25. package/server/env.ts +16 -0
  26. package/server/hono.ts +176 -0
  27. package/server/io.ts +15 -0
  28. package/server/routes.ts +48 -0
  29. package/server/security.ts +138 -0
  30. package/tests/api.test.ts +281 -0
  31. package/tsconfig.json +36 -0
  32. package/types/activity.d.ts +45 -0
  33. package/types/api.d.ts +85 -0
  34. package/types/collection.d.ts +82 -0
  35. package/types/config.d.ts +55 -0
  36. package/types/field.d.ts +72 -0
  37. package/types/file.d.ts +120 -0
  38. package/types/hook.d.ts +30 -0
  39. package/types/middleware.d.ts +18 -0
  40. package/types/mongo.d.ts +61 -0
  41. package/types/options.d.ts +7 -0
  42. package/types/rest.d.ts +18 -0
  43. package/types/route.d.ts +19 -0
  44. package/types/schema.d.ts +0 -0
  45. package/types/scripts.d.ts +10 -0
  46. package/types/service.d.ts +37 -0
  47. package/types/task.d.ts +12 -0
  48. package/types/tenant.d.ts +16 -0
  49. package/types/token.d.ts +14 -0
  50. package/types/websocket.d.ts +15 -0
  51. package/types/workflow.d.ts +91 -0
  52. package/utils/cache.ts +96 -0
  53. package/utils/crypto.ts +226 -0
  54. package/utils/func.ts +1037 -0
  55. package/utils/index.ts +17 -0
package/utils/func.ts ADDED
@@ -0,0 +1,1037 @@
1
+ import { ObjectId } from 'mongodb';
2
+ import type { RestActions } from '../types/rest';
3
+ import type { FindOptions } from '../types/mongo';
4
+ import type { Collection } from '../types/collection';
5
+ import { useRest } from '../database/rest';
6
+ import { MongoRest } from '../database/mongodbadapter';
7
+ import type { Field } from '../types/field';
8
+ import type { ActionsApiList } from '../types/api';
9
+ import * as Jose from "jose"
10
+ import { cfg } from '../server/config';
11
+ import { AppError } from '../lib/error';
12
+ type DeepRecord = Record<string, unknown>;
13
+
14
+
15
+ const jwt = {
16
+ sign: async (payload: Record<string, unknown>, options?: {
17
+ expiresIn?: string;
18
+ issuer?: string;
19
+ audience?: string | string[];
20
+ subject?: string;
21
+ }) => {
22
+ let SECRET_ = cfg.server.jwt?.secret || Bun.env.JWT_SECRET
23
+ if (!SECRET_) throw new Error('JWT_SECRET is not set');
24
+ let EXPIRES_IN = cfg.server.jwt?.expiresIn || '1h'
25
+
26
+ const secret = new TextEncoder().encode(SECRET_);
27
+ const signer = new Jose.SignJWT(payload)
28
+ .setProtectedHeader({ alg: 'HS256' })
29
+ .setIssuedAt()
30
+ .setExpirationTime(EXPIRES_IN || '1h')
31
+
32
+ if (options?.issuer) signer.setIssuer(options.issuer)
33
+ if (options?.audience) signer.setAudience(options.audience)
34
+ if (options?.subject) signer.setSubject(options.subject)
35
+
36
+ const token = await signer.sign(secret);
37
+ return token;
38
+ },
39
+ verify: async (token: string): Promise<{ value: Record<string, unknown> | null, error: string | null }> => {
40
+ try {
41
+ if (!token) throw new Error('Token is required');
42
+ let SECRET_ = cfg.server.jwt?.secret || Bun.env.JWT_SECRET
43
+ if (!SECRET_) throw new Error('JWT_SECRET is not set');
44
+ const secret = new TextEncoder().encode(SECRET_);
45
+ const { payload, protectedHeader } = await Jose.jwtVerify(token, secret);
46
+ return {
47
+ value: payload,
48
+ error: null
49
+ }
50
+ } catch (error) {
51
+ return {
52
+ value: null,
53
+ error: error instanceof Error ? error.message : 'Invalid token'
54
+ }
55
+ }
56
+ }
57
+ }
58
+
59
+
60
+ function getDeep(obj: DeepRecord, path: string): unknown {
61
+ return path.split('.').reduce<unknown>((curr, key) =>
62
+ curr && typeof curr === 'object' ? (curr as DeepRecord)[key] : undefined, obj);
63
+ }
64
+
65
+ function setDeep(obj: DeepRecord, path: string, value: unknown): void {
66
+ const keys = path.split('.');
67
+ const lastKey = keys.pop()!;
68
+ const target = keys.reduce<DeepRecord>((curr, key) => {
69
+ if (!(key in curr) || typeof curr[key] !== 'object') {
70
+ curr[key] = {};
71
+ }
72
+ return curr[key] as DeepRecord;
73
+ }, obj);
74
+ target[lastKey] = value;
75
+ }
76
+
77
+ function deleteDeep(obj: DeepRecord, path: string): void {
78
+ const keys = path.split('.');
79
+ const lastKey = keys.pop()!;
80
+ const target = keys.reduce<DeepRecord | undefined>((curr, key) =>
81
+ curr && typeof curr === 'object' ? (curr as DeepRecord)[key] as DeepRecord : undefined, obj);
82
+ if (target && typeof target === 'object') {
83
+ delete target[lastKey];
84
+ }
85
+ }
86
+
87
+ function reorder<T extends DeepRecord>(data: T, fieldOrder: string[]): T {
88
+ const ordered: DeepRecord = {};
89
+ for (const key of fieldOrder) {
90
+ if (key in data) {
91
+ ordered[key] = data[key];
92
+ }
93
+ }
94
+ for (const key of Object.keys(data)) {
95
+ if (!(key in ordered)) {
96
+ ordered[key] = data[key];
97
+ }
98
+ }
99
+ return ordered as T;
100
+ }
101
+
102
+ function omitDeep(obj: DeepRecord, matchers: ((key: string) => boolean)[], fieldOrder?: string[]): DeepRecord {
103
+ for (const key of Object.keys(obj)) {
104
+ if (matchers.some(m => m(key))) {
105
+ delete obj[key];
106
+ continue;
107
+ }
108
+ const value = obj[key];
109
+ if (Array.isArray(value)) {
110
+ obj[key] = value.map(item =>
111
+ item && typeof item === 'object' && !Array.isArray(item)
112
+ ? omitDeep(item as DeepRecord, matchers, fieldOrder)
113
+ : item
114
+ );
115
+ } else if (value && typeof value === 'object' && !Array.isArray(value)) {
116
+ obj[key] = omitDeep(value as DeepRecord, matchers, fieldOrder);
117
+ }
118
+ }
119
+ return fieldOrder ? reorder(obj, fieldOrder) : obj;
120
+ }
121
+
122
+ function omit<T extends DeepRecord>(data: T | T[], keys: (string | RegExp)[], fieldOrder?: string[]): Partial<T> | Partial<T>[] {
123
+ if (data === null || data === undefined) return data as any;
124
+
125
+ const matchers = keys.map(k =>
126
+ k instanceof RegExp
127
+ ? (key: string) => k.test(key)
128
+ : (key: string) => key === k
129
+ );
130
+
131
+ if (Array.isArray(data)) {
132
+ return data.map(item => {
133
+ const result = clone(item) as DeepRecord;
134
+ return omitDeep(result, matchers, fieldOrder) as Partial<T>;
135
+ });
136
+ }
137
+ const result = clone(data) as DeepRecord;
138
+ return omitDeep(result, matchers, fieldOrder) as Partial<T>;
139
+ }
140
+
141
+ function pick<T extends DeepRecord>(data: T | T[], keys: string[]): Partial<T> | Partial<T>[] {
142
+ if (Array.isArray(data)) {
143
+ return data.map(item => pick(item, keys) as Partial<T>);
144
+ }
145
+ const result: DeepRecord = {};
146
+ for (const key of keys) {
147
+ const value = getDeep(data, key);
148
+ if (value !== undefined) {
149
+ setDeep(result, key, value);
150
+ }
151
+ }
152
+ return result as Partial<T>;
153
+ }
154
+
155
+ function toJson<T>(data: any): T {
156
+ if (data === null || data === undefined) return data;
157
+ try {
158
+ return JSON.parse(JSON.stringify(data, (key, value) =>
159
+ value instanceof ObjectId ? value.toHexString() : value
160
+ ));
161
+ } catch (error) {
162
+ console.error('toJson failed:', error);
163
+ return data;
164
+ }
165
+ }
166
+
167
+ function stringToBoolean(value: string): boolean {
168
+ // convert string to boolean
169
+ // convert value in lowercase
170
+ value = value.toLowerCase();
171
+ return value === 'true';
172
+ }
173
+
174
+ function isEmpty(value: unknown): boolean {
175
+ if (value === null || value === undefined) return true;
176
+ if (value === '') return true;
177
+ if (Array.isArray(value) && value.length === 0) return true;
178
+ if (typeof value === 'object' && Object.keys(value).length === 0) return true;
179
+ return false;
180
+ }
181
+
182
+ function cleanDeep<T extends DeepRecord | DeepRecord[]>(data: T): Partial<T> {
183
+ if (Array.isArray(data)) {
184
+ const cleaned = data
185
+ .map(item => cleanDeep(item))
186
+ .filter(item => !isEmpty(item));
187
+ return cleaned as unknown as Partial<T>;
188
+ }
189
+
190
+ const result: DeepRecord = {};
191
+ for (const key of Object.keys(data)) {
192
+ let value = data[key];
193
+
194
+ if (value !== null && value !== undefined) {
195
+ if (Array.isArray(value)) {
196
+ value = value
197
+ .map(item => typeof item === 'object' && item !== null ? cleanDeep(item as DeepRecord) : item)
198
+ .filter(item => !isEmpty(item));
199
+ } else if (typeof value === 'object') {
200
+ value = cleanDeep(value as DeepRecord);
201
+ }
202
+
203
+ if (!isEmpty(value)) {
204
+ result[key] = value;
205
+ }
206
+ }
207
+ }
208
+ return result as Partial<T>;
209
+ }
210
+
211
+ function isDate(value: string): boolean {
212
+ if (!value || typeof value !== 'string') return false;
213
+ const trimmed = value.trim();
214
+ // Chaînes 100 % chiffres : seuls timestamps UNIX s (10) ou ms (13) — sinon codes type "170273" seraient des dates via `new Date("170273")`.
215
+ if (/^\d+$/.test(trimmed)) {
216
+ const len = trimmed.length;
217
+ if (len !== 10 && len !== 13) return false;
218
+ const ms = new Date(Number(trimmed)).getTime();
219
+ return !isNaN(ms);
220
+ }
221
+ const date = new Date(trimmed);
222
+ return !isNaN(date.getTime());
223
+ }
224
+
225
+ /** Aligné sur `isDate` : `new Date("1438839831092")` est NaN, il faut passer par `Number`. */
226
+ function stringToDate(value: string): Date {
227
+ const trimmed = value.trim();
228
+ if (/^\d+$/.test(trimmed)) {
229
+ const len = trimmed.length;
230
+ if (len === 10 || len === 13) {
231
+ return new Date(Number(trimmed));
232
+ }
233
+ }
234
+ return new Date(trimmed);
235
+ }
236
+
237
+ /** Nombre entier type timestamp UNIX (s ou ms), ex. `1438839831092`. */
238
+ function isEpochNumber(value: number): boolean {
239
+ if (typeof value !== 'number' || !Number.isFinite(value)) return false;
240
+ const int = Math.trunc(value);
241
+ if (int !== value) return false;
242
+ const s = String(Math.abs(int));
243
+ if (s.length !== 10 && s.length !== 13) return false;
244
+ return !isNaN(new Date(int).getTime());
245
+ }
246
+
247
+ function deepCopy<T>(value: T): T {
248
+ return clone(value);
249
+ }
250
+
251
+ type FormatToDateResult<T> = T extends string
252
+ ? Date | string
253
+ : T extends (infer U)[]
254
+ ? FormatToDateResult<U>[]
255
+ : T extends object
256
+ ? { [K in keyof T]: FormatToDateResult<T[K]> }
257
+ : T;
258
+
259
+ /** Options pour filtrer les chemins ; sans options, tout ce qui passe `isDate` est converti en `Date`. */
260
+ type FormatToDateOptions = {
261
+ /** Noms de champs (notation pointée) à ne pas convertir ; correspondance exacte ou suffixe (ex. `$set.createdAt`). */
262
+ omitKeys?: string[];
263
+ /** Si défini et non vide : seuls ces champs sont convertis (équivalent à « tout sauf les non-date »). Correspondance exacte ou suffixe de chemin. */
264
+ includeKeys?: string[];
265
+ };
266
+
267
+ function joinFormatToDatePath(parent: string, segment: string | number): string {
268
+ const s = String(segment);
269
+ return parent === '' ? s : `${parent}.${s}`;
270
+ }
271
+
272
+ /** Correspond à un nom de champ schéma (`createdAt`, `user.birthDate`) y compris en profondeur (`$set.createdAt`). */
273
+ function pathMatchesFieldKey(path: string, key: string): boolean {
274
+ return path === key || path.endsWith('.' + key);
275
+ }
276
+
277
+ function shouldFormatDateAtPath(path: string, options?: FormatToDateOptions): boolean {
278
+ // Aucune option → convertir partout où `isDate` est vrai
279
+ if (!options) return true;
280
+ if (options.omitKeys?.some((k) => pathMatchesFieldKey(path, k))) return false;
281
+ // Whitelist seulement si includeKeys est un tableau non vide
282
+ if (options.includeKeys && options.includeKeys.length > 0) {
283
+ return options.includeKeys.some((k) => pathMatchesFieldKey(path, k));
284
+ }
285
+ return true;
286
+ }
287
+
288
+ function formatToDate(data: any, options?: FormatToDateOptions, path = ''): any {
289
+ if (data === null || data === undefined) return data;
290
+ if (data instanceof Date) return data;
291
+ if (data instanceof ObjectId) return data;
292
+
293
+ if (Array.isArray(data)) {
294
+ return data.map((item, i) => {
295
+ const p = joinFormatToDatePath(path, i);
296
+ return formatToDate(item, options, p);
297
+ });
298
+ }
299
+
300
+ if (typeof data === 'object') {
301
+ const result: Record<string, unknown> = {};
302
+ for (const key of Object.keys(data)) {
303
+ const p = joinFormatToDatePath(path, key);
304
+ const value = (data as Record<string, unknown>)[key];
305
+ if (typeof value === 'string' && isDate(value)) {
306
+ if (shouldFormatDateAtPath(p, options)) {
307
+ result[key] = stringToDate(value);
308
+ } else {
309
+ result[key] = value;
310
+ }
311
+ } else if (typeof value === 'number' && isEpochNumber(value)) {
312
+ if (shouldFormatDateAtPath(p, options)) {
313
+ result[key] = new Date(value);
314
+ } else {
315
+ result[key] = value;
316
+ }
317
+ } else {
318
+ result[key] = formatToDate(value, options, p);
319
+ }
320
+ }
321
+ return result;
322
+ }
323
+
324
+ return data;
325
+ }
326
+
327
+ function isObjectId(value: string): boolean {
328
+ let isValid = typeof value === 'string' && /^[a-fA-F0-9]{24}$/.test(value) && ObjectId.isValid(value);
329
+
330
+ return isValid;
331
+ }
332
+
333
+ type FormatToObjectIdResult<T> = T extends string
334
+ ? ObjectId | string
335
+ : T extends (infer U)[]
336
+ ? FormatToObjectIdResult<U>[]
337
+ : T extends object
338
+ ? { [K in keyof T]: FormatToObjectIdResult<T[K]> }
339
+ : T;
340
+
341
+ function formatToObjectId<T>(value: T): FormatToObjectIdResult<T> {
342
+ if (value === null || value === undefined) {
343
+ return value as FormatToObjectIdResult<T>;
344
+ }
345
+
346
+
347
+
348
+ if (typeof value === 'string') {
349
+
350
+ if (isObjectId(value)) {
351
+ return new ObjectId(value) as FormatToObjectIdResult<T>;
352
+ }
353
+ return value as FormatToObjectIdResult<T>;
354
+ }
355
+
356
+ if (Array.isArray(value) && typeof value === 'object') {
357
+
358
+ return value.map(item => formatToObjectId(item)) as FormatToObjectIdResult<T>;
359
+ }
360
+
361
+ if (typeof value === 'object' && !Array.isArray(value)) {
362
+
363
+ if (value instanceof ObjectId) {
364
+ return value as FormatToObjectIdResult<T>;
365
+ }
366
+
367
+ const result: Record<string, unknown> = {};
368
+ for (const key of Object.keys(value)) {
369
+ result[key] = formatToObjectId((value as Record<string, unknown>)[key]);
370
+ }
371
+ return result as FormatToObjectIdResult<T>;
372
+ }
373
+
374
+ return value as FormatToObjectIdResult<T>;
375
+ }
376
+
377
+ export type GenerateRandomOptions = {
378
+ length: number;
379
+ useLetters?: boolean;
380
+ useNumbers?: boolean;
381
+ includeSymbols?: string;
382
+ excludeSymbols?: string;
383
+ startWith?: string;
384
+ endWith?: string;
385
+ toLowerCase?: boolean;
386
+ toUpperCase?: boolean;
387
+ toNumber?: boolean;
388
+ }
389
+ async function generateRandom(options: GenerateRandomOptions, ctx: {
390
+ col?: Collection,
391
+ rest?: MongoRest,
392
+ field?: Field
393
+ }, _attempts = 0): Promise<string | number> {
394
+ if (_attempts > 10) {
395
+ throw new AppError('Unable to generate unique random value after 10 attempts', {
396
+ code: 'RANDOM_GENERATION_FAILED', status: 500
397
+ });
398
+ }
399
+ const {
400
+ length,
401
+ useLetters = false,
402
+ useNumbers = true,
403
+ includeSymbols = '',
404
+ excludeSymbols = '',
405
+ startWith = '',
406
+ endWith = '',
407
+ toLowerCase = false,
408
+ toUpperCase = false,
409
+ toNumber = false
410
+ } = options;
411
+
412
+ const letters = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ';
413
+ const numbers = '0123456789';
414
+
415
+ let charset = '';
416
+ if (useLetters) charset += letters;
417
+ if (useNumbers) charset += numbers;
418
+ if (includeSymbols) charset += includeSymbols;
419
+
420
+ if (excludeSymbols) {
421
+ for (const symbol of excludeSymbols) {
422
+ charset = charset.split(symbol).join('');
423
+ }
424
+ }
425
+
426
+ if (charset.length === 0) {
427
+ charset = letters + numbers;
428
+ }
429
+
430
+ let result = '';
431
+
432
+ for (let i = 0; i < length; i++) {
433
+ const randomIndex = Math.floor(Math.random() * charset.length);
434
+ result += charset[randomIndex];
435
+ }
436
+
437
+ result = startWith + result + endWith;
438
+
439
+ if (toLowerCase) {
440
+ result = result.toLowerCase();
441
+ } else if (toUpperCase) {
442
+ result = result.toUpperCase();
443
+ }
444
+
445
+ if (toNumber) {
446
+ return parseInt(result, 10) || 0;
447
+ }
448
+
449
+
450
+
451
+ if (ctx?.col && ctx.field && ctx.rest) {
452
+ let foundItem = await ctx.rest.find(ctx.col.slug, {
453
+ $match: {
454
+ [ctx.field.name]: result
455
+ }
456
+ })
457
+ if (foundItem.length > 0) {
458
+ return generateRandom(options, ctx, _attempts + 1)
459
+ }
460
+ }
461
+
462
+ return result;
463
+ }
464
+
465
+
466
+ type FormatInputOptions = {
467
+ action?: ActionsApiList;
468
+ col?: Collection
469
+ rest?: MongoRest
470
+ }
471
+
472
+ async function buildInput<T>(data: any | any[], options?: FormatInputOptions): Promise<T> {
473
+
474
+ data = toJson(data)
475
+ let currentDate = new Date().toISOString()
476
+
477
+
478
+
479
+
480
+
481
+ // Field specification formatting
482
+ for (const f of options?.col?.fields || []) {
483
+ let dataValue = data[f.name]
484
+ let fieldName = f.name
485
+ let fieldType = f.type
486
+
487
+
488
+ /* Set Default Value For Insert */
489
+ if (options?.action?.match(/(insert)/)) {
490
+ if (Object.hasOwn(f, 'defaultValue') && !dataValue) {
491
+ data[fieldName] = f.defaultValue
492
+ }
493
+ }
494
+
495
+
496
+
497
+ // Set Password Hash For action:Insert | action:Update
498
+ if (options?.action?.match(/(insert|update)/)) {
499
+
500
+
501
+
502
+ /* Set Password Hash For Single */
503
+ if (fieldType == 'password' && dataValue) {
504
+ data[fieldName] = Bun.password.hashSync(dataValue)
505
+ }
506
+ }
507
+
508
+ // Set Id on relationship fields
509
+ if (fieldType == 'relationship' && dataValue && !f.relation?.hasMany) {
510
+ data[fieldName] = dataValue._id || dataValue;
511
+ }
512
+
513
+ if (fieldType == 'relationship' && dataValue && f.relation?.hasMany) {
514
+ data[fieldName] = dataValue.map((item: any) => item._id || item);
515
+ }
516
+
517
+
518
+
519
+ //** Generate Random For Random Fields */
520
+ if (fieldType == 'random' && f.randomOptions && options?.action?.match(/(insert)/)) {
521
+ data[fieldName] = await generateRandom(f.randomOptions, {
522
+ rest: options?.rest,
523
+ col: options?.col,
524
+ field: f
525
+ })
526
+ }
527
+
528
+ }
529
+ /* Set Created At And Updated At For action:Insert */
530
+ if (options?.action?.match(/(insert)/)) {
531
+ if (!Array.isArray(data)) {
532
+ data['createdAt'] = currentDate
533
+ data['updatedAt'] = currentDate
534
+
535
+ } else {
536
+ data?.map(item => {
537
+ item['createdAt'] = currentDate
538
+ item['updatedAt'] = currentDate
539
+ })
540
+ }
541
+ }
542
+
543
+
544
+ /* Set Updated At For action:Update */
545
+ if (options?.action?.match(/(update)/)) {
546
+
547
+ data['updatedAt'] = currentDate
548
+ }
549
+
550
+
551
+ return data
552
+ }
553
+
554
+ function collectDateFieldPathsFromCollection(col: Collection): string[] {
555
+ const paths = new Set<string>();
556
+ for (const f of col.fields) {
557
+ if (f.type === 'date' || f.type === 'datetime-local') {
558
+ paths.add(f.name);
559
+ }
560
+ }
561
+ paths.add('createdAt');
562
+ paths.add('updatedAt');
563
+ return [...paths];
564
+ }
565
+
566
+ function toBson<T>(data: any | any[], options?: FormatInputOptions): T {
567
+ let data_ = formatToObjectId(data);
568
+ const dateOpts: FormatToDateOptions | undefined =
569
+ options?.col !== undefined
570
+ ? { includeKeys: collectDateFieldPathsFromCollection(options.col) }
571
+ : undefined;
572
+ data_ = formatToDate(data_, dateOpts);
573
+
574
+ if (Array.isArray(data)) {
575
+ data.length = 0;
576
+ data.push(...data_);
577
+ } else {
578
+ Object.assign(data, data_);
579
+ }
580
+ return data;
581
+ }
582
+
583
+ function buildPipeline(p: FindOptions, options?: {
584
+ col: Collection
585
+ }) {
586
+
587
+ let pipeline = []
588
+ // $Match
589
+ if (p?.$match) {
590
+ const FORBIDDEN_MATCH_KEYS = ['$where', '$expr', '$accumulator', '$function'];
591
+ const forbiddenFound = hasUnauthorizedKeys(p.$match, FORBIDDEN_MATCH_KEYS);
592
+ if (forbiddenFound.length > 0) {
593
+ throw new AppError('Unauthorized match keys: ' + forbiddenFound.join(', '), {
594
+ code: 'UNAUTHORIZED_MATCH_KEYS',
595
+ status: 400
596
+ });
597
+ }
598
+ pipeline.push({
599
+ $match: p.$match
600
+ })
601
+ }
602
+
603
+ // $sort
604
+ if (!p?.$sort?.createdAt) {
605
+ pipeline.push({
606
+ $sort: {
607
+ ...p.$sort,
608
+ createdAt: -1,
609
+ }
610
+ })
611
+ } else {
612
+ pipeline.push({
613
+ $sort: p.$sort
614
+ })
615
+ }
616
+
617
+ // $skip
618
+ if (p?.$skip) {
619
+ pipeline.push({
620
+ $skip: p.$skip
621
+ })
622
+ }
623
+
624
+ // $limit
625
+ p.$limit = p?.$limit ?? 100
626
+ if (p?.$limit > 0) {
627
+ pipeline.push({
628
+ $limit: p.$limit
629
+ })
630
+ }
631
+
632
+ // $include
633
+ if (p?.$include && Array.isArray(p?.$include)) {
634
+
635
+ for (const include of p?.$include) {
636
+ if (typeof include === 'string') {
637
+ let f = options?.col.fields.find(f => f.name === include)
638
+
639
+ if (!f) {
640
+ throw new AppError(`Field '${include}' not found in collection '${options?.col.slug}'`, {
641
+ code: 'INCLUDE_FIELD_NOT_FOUND',
642
+ status: 400
643
+ })
644
+ }
645
+
646
+ if (f.type !== 'relationship') {
647
+ throw new AppError(`Field '${include}' is not a relationship type`, {
648
+ code: 'INCLUDE_FIELD_NOT_RELATIONSHIP',
649
+ status: 400
650
+ })
651
+ }
652
+
653
+ pipeline.push({
654
+ $lookup: {
655
+ from: f?.relation?.to,
656
+ localField: include || f?.name,
657
+ foreignField: '_id',
658
+ as: include
659
+ }
660
+ })
661
+ if (!f?.relation?.hasMany) {
662
+ pipeline.push({
663
+ $unwind: {
664
+ path: '$' + (include || f?.name),
665
+ preserveNullAndEmptyArrays: true,
666
+ }
667
+ })
668
+ }
669
+ } else {
670
+ let f = options?.col.fields.find(f => f.name === include.localField)
671
+
672
+ if (!f) {
673
+ throw new AppError(`localField '${include.localField}' not found in collection '${options?.col.slug}'`, {
674
+ code: 'INCLUDE_LOCALFIELD_NOT_FOUND',
675
+ status: 400
676
+ })
677
+ }
678
+
679
+ if (f.type !== 'relationship') {
680
+ throw new AppError(`localField '${include.localField}' is not a relationship type`, {
681
+ code: 'INCLUDE_LOCALFIELD_NOT_RELATIONSHIP',
682
+ status: 400
683
+ })
684
+ }
685
+
686
+ if (include.from !== f.relation?.to) {
687
+ throw new AppError(`'from' must be '${f.relation?.to}' for localField '${include.localField}'`, {
688
+ code: 'INCLUDE_FROM_MISMATCH',
689
+ status: 400
690
+ })
691
+ }
692
+
693
+ let as = include.as || include.localField
694
+
695
+ pipeline.push({
696
+ $lookup: {
697
+ from: include.from,
698
+ localField: include.localField,
699
+ foreignField: include.foreignField,
700
+ pipeline: include.pipeline,
701
+ let: include.let,
702
+ as: as
703
+ }
704
+ })
705
+ if (!include.unwind) {
706
+ pipeline.push({
707
+ $unwind: {
708
+ path: '$' + as,
709
+ preserveNullAndEmptyArrays: true,
710
+ }
711
+ })
712
+ }
713
+ }
714
+
715
+ }
716
+
717
+ }
718
+
719
+
720
+
721
+ // $lookup — must use $include (validated against collection schema)
722
+ if (p?.$lookup) {
723
+ throw new AppError('Direct $lookup is not allowed. Use $include instead.', {
724
+ code: 'UNAUTHORIZED_PIPELINE_KEYS',
725
+ status: 400
726
+ });
727
+ }
728
+
729
+ // $graphLookup — not supported via params
730
+ if (p?.$graphLookup) {
731
+ throw new AppError('$graphLookup is not allowed via query params.', {
732
+ code: 'UNAUTHORIZED_PIPELINE_KEYS',
733
+ status: 400
734
+ });
735
+ }
736
+
737
+ // $project
738
+ if (p?.$project) {
739
+ pipeline.push({
740
+ $project: protectFieldRenameOnProject(p.$project),
741
+ })
742
+ }
743
+
744
+
745
+
746
+ return pipeline || []
747
+ }
748
+
749
+ function clone<T>(data: T): T {
750
+ let data_ = data;
751
+ // Use structured clone to clone the data
752
+ try {
753
+ data_ = structuredClone(toJson(data_))
754
+ } catch (error) {
755
+ data_ = toJson(data_)
756
+ }
757
+ return data_
758
+ }
759
+
760
+
761
+
762
+
763
+ //slugify function
764
+ function slugify(text: string): string {
765
+ return text
766
+ .normalize('NFD')
767
+ .replace(/[\u0300-\u036f]/g, '')
768
+ .toLowerCase()
769
+ .replace(/[^\w\s-]/g, '')
770
+ .replace(/[\s_]+/g, '-')
771
+ .replace(/-+/g, '-')
772
+ .replace(/^-|-$/g, '');
773
+ }
774
+
775
+
776
+ /**
777
+ * Parcourt récursivement `data` (objet ou tableau, profondeur quelconque)
778
+ * et retourne les clés de `keys` qui apparaissent au moins une fois comme
779
+ * nom de propriété propre (own key) d'un objet.
780
+ * @param data - L'objet ou le tableau à inspecter.
781
+ * @param keys - Les clés prohibées à rechercher.
782
+ * @returns Un tableau des clés prohibées trouvées.
783
+ */
784
+ function hasUnauthorizedKeys(data: unknown, keys: string[]): string[] {
785
+ const found = new Set<string>();
786
+ const forbidden = new Set(keys);
787
+
788
+ function walk(node: unknown): void {
789
+ if (node === null || node === undefined || typeof node !== 'object') {
790
+ return;
791
+ }
792
+
793
+ if (Array.isArray(node)) {
794
+ for (const item of node) {
795
+ walk(item);
796
+ }
797
+ return;
798
+ }
799
+
800
+ const obj = node as Record<string, unknown>;
801
+ for (const key of Object.keys(obj)) {
802
+ if (forbidden.has(key)) {
803
+ found.add(key);
804
+ }
805
+ walk(obj[key]);
806
+ }
807
+ }
808
+
809
+ walk(data);
810
+ return [...found];
811
+ }
812
+
813
+ interface PaginationResult<T> {
814
+ data: T[];
815
+ total: number;
816
+ perPage: number;
817
+ totalPages: number;
818
+ currentPage: number;
819
+ from: number;
820
+ to: number;
821
+ hasNext: boolean;
822
+ hasPrev: boolean;
823
+ }
824
+
825
+ function paginate<T>(array: T[], options: { page?: number; perPage?: number } = {}): PaginationResult<T> {
826
+ const total = array.length;
827
+ const perPage = Math.max(1, options.perPage ?? 25);
828
+ const totalPages = Math.ceil(total / perPage) || 1;
829
+ const currentPage = Math.min(Math.max(1, options.page ?? 1), totalPages);
830
+ const start = (currentPage - 1) * perPage;
831
+ const data = array.slice(start, start + perPage);
832
+
833
+ return {
834
+ data,
835
+ total,
836
+ perPage,
837
+ totalPages,
838
+ currentPage,
839
+ from: total === 0 ? 0 : start + 1,
840
+ to: start + data.length,
841
+ hasNext: currentPage < totalPages,
842
+ hasPrev: currentPage > 1,
843
+ };
844
+ }
845
+
846
+
847
+ function isAggregationFieldPathRef(value: unknown): value is string {
848
+ return typeof value === 'string' && value.startsWith('$') && !value.startsWith('$$');
849
+ }
850
+
851
+ /** Carte des clés de sortie → ref source pour tout renommage illicite (`out !==` dernier segment du chemin). */
852
+ function getProjectFieldRenames(project: unknown): Record<string, string> {
853
+ const renames: Record<string, string> = {};
854
+ if (!project || typeof project !== 'object' || Array.isArray(project)) {
855
+ return renames;
856
+ }
857
+ for (const [outputKey, val] of Object.entries(project as Record<string, unknown>)) {
858
+ if (!isAggregationFieldPathRef(val)) {
859
+ continue;
860
+ }
861
+ const sourcePath = val.slice(1);
862
+ const leaf = sourcePath.includes('.')
863
+ ? sourcePath.slice(sourcePath.lastIndexOf('.') + 1)
864
+ : sourcePath;
865
+ if (outputKey !== leaf) {
866
+ renames[outputKey] = val;
867
+ }
868
+ }
869
+ return renames;
870
+ }
871
+
872
+ /**
873
+ * Vérifie les entrées `$project` qui renomment un champ via une référence de chemin
874
+ * Mongo (valeur string `"$..."`), ex. `{ pw: "$password" }`.
875
+ *
876
+ * On considère qu'il y a « renommage » lorsque la clé de sortie diffère du dernier
877
+ * segment du chemin source (`password` pour `$password`, `email` pour `user.email`).
878
+ * @throws AppError 400 `UNAUTHORIZED_PROJECT_FIELD_RENAME` si un renommage est détecté
879
+ */
880
+ function protectFieldRenameOnProject(project: unknown): Record<string, unknown> {
881
+ if (!project || typeof project !== 'object' || Array.isArray(project)) {
882
+ return {};
883
+ }
884
+ const obj = project as Record<string, unknown>;
885
+ const renames = getProjectFieldRenames(obj);
886
+ if (Object.keys(renames).length > 0) {
887
+ throw new AppError('Unauthorized to rename field on $project', {
888
+ status: 400,
889
+ code: 'UNAUTHORIZED_PROJECT_FIELD_RENAME',
890
+ }, { renames });
891
+ }
892
+ return obj;
893
+ }
894
+
895
+
896
+ /**
897
+ * Parcourt récursivement un pipeline d'agrégation MongoDB (stages `$facet`,
898
+ * `$lookup.pipeline`, `$unionWith.pipeline`, `$graphLookup.pipeline`).
899
+ */
900
+ function walkAggregatePipeline(
901
+ pipeline: unknown,
902
+ visitor: (stage: Record<string, unknown>) => void,
903
+ ): void {
904
+ if (!Array.isArray(pipeline)) {
905
+ return;
906
+ }
907
+ for (const stage of pipeline) {
908
+ if (!stage || typeof stage !== 'object' || Array.isArray(stage)) {
909
+ continue;
910
+ }
911
+ const s = stage as Record<string, unknown>;
912
+ visitor(s);
913
+
914
+ if (s.$facet && typeof s.$facet === 'object' && !Array.isArray(s.$facet)) {
915
+ for (const sub of Object.values(s.$facet as Record<string, unknown>)) {
916
+ if (Array.isArray(sub)) {
917
+ walkAggregatePipeline(sub, visitor);
918
+ }
919
+ }
920
+ }
921
+
922
+ if (s.$lookup && typeof s.$lookup === 'object' && !Array.isArray(s.$lookup)) {
923
+ const l = s.$lookup as Record<string, unknown>;
924
+ if (Array.isArray(l.pipeline)) {
925
+ walkAggregatePipeline(l.pipeline, visitor);
926
+ }
927
+ }
928
+
929
+ if (s.$unionWith && typeof s.$unionWith === 'object' && !Array.isArray(s.$unionWith)) {
930
+ const u = s.$unionWith as Record<string, unknown>;
931
+ if (Array.isArray(u.pipeline)) {
932
+ walkAggregatePipeline(u.pipeline, visitor);
933
+ }
934
+ }
935
+
936
+ if (s.$graphLookup && typeof s.$graphLookup === 'object' && !Array.isArray(s.$graphLookup)) {
937
+ const g = s.$graphLookup as Record<string, unknown>;
938
+ if (Array.isArray(g.pipeline)) {
939
+ walkAggregatePipeline(g.pipeline, visitor);
940
+ }
941
+ }
942
+ }
943
+ }
944
+
945
+
946
+ function countAggregatePipelineStages(pipeline: unknown): number {
947
+ let n = 0;
948
+ walkAggregatePipeline(pipeline, () => {
949
+ n++;
950
+ });
951
+ return n;
952
+ }
953
+
954
+
955
+ function isSafeAggregatePipeline(pipeline: Array<Record<string, unknown>>): {
956
+ isSafe: boolean;
957
+ forbiddenKeys: string[];
958
+ error: AppError | null;
959
+ } {
960
+
961
+ // Unauthorized pipeline keys
962
+ const UNAUTHORIZED_PIPELINE_KEYS = ['$out', '$merge', '$function', '$where', '$accumulator'];
963
+ const forbiddenFound = hasUnauthorizedKeys(pipeline, UNAUTHORIZED_PIPELINE_KEYS);
964
+ if (forbiddenFound.length > 0) {
965
+ return {
966
+ isSafe: false,
967
+ forbiddenKeys: forbiddenFound,
968
+ error: new AppError('Unauthorized pipeline keys: ' + forbiddenFound.join(', '), { code: 'UNAUTHORIZED_PIPELINE_KEYS', status: 400 })
969
+ }
970
+ }
971
+
972
+ // $project avec renommage : racine + imbriqué ($facet, $lookup.pipeline, …)
973
+ let renameForbiddenKeys: string[] = [];
974
+ let renameError: AppError | null = null;
975
+ walkAggregatePipeline(pipeline, (stage) => {
976
+ if (renameError || !('$project' in stage)) {
977
+ return;
978
+ }
979
+ const p = stage.$project;
980
+ if (p == null || typeof p !== 'object' || Array.isArray(p)) {
981
+ return;
982
+ }
983
+ const renames = getProjectFieldRenames(p);
984
+ if (Object.keys(renames).length > 0) {
985
+ renameForbiddenKeys = Object.keys(renames);
986
+ renameError = new AppError('Unauthorized to rename field on $project', {
987
+ code: 'UNAUTHORIZED_PROJECT_FIELD_RENAME',
988
+ status: 400,
989
+ }, { renames });
990
+ }
991
+ });
992
+ if (renameError) {
993
+ return {
994
+ isSafe: false,
995
+ forbiddenKeys: renameForbiddenKeys,
996
+ error: renameError,
997
+ };
998
+ }
999
+
1000
+
1001
+
1002
+ return {
1003
+ isSafe: true,
1004
+ forbiddenKeys: [],
1005
+ error: null
1006
+ }
1007
+ }
1008
+
1009
+ export {
1010
+ buildPipeline,
1011
+ reorder,
1012
+ omit,
1013
+ pick,
1014
+ toJson,
1015
+ stringToBoolean,
1016
+ cleanDeep,
1017
+ isDate,
1018
+ deepCopy,
1019
+ clone,
1020
+ formatToDate,
1021
+ isObjectId,
1022
+ formatToObjectId,
1023
+ generateRandom,
1024
+ buildInput,
1025
+ toBson,
1026
+ jwt,
1027
+ isEmpty,
1028
+ slugify,
1029
+ paginate,
1030
+ hasUnauthorizedKeys,
1031
+ protectFieldRenameOnProject,
1032
+ walkAggregatePipeline,
1033
+ countAggregatePipelineStages,
1034
+ isSafeAggregatePipeline,
1035
+ }
1036
+
1037
+ export type { PaginationResult, FormatToDateOptions }