@anteros/core 0.0.39 → 0.0.41

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 (64) hide show
  1. package/database/audit.ts +193 -0
  2. package/database/collection.ts +4 -4
  3. package/database/deleteLog.ts +34 -0
  4. package/database/field.crypto.ts +1 -1
  5. package/database/file.ts +3 -3
  6. package/database/mongodbadapter.ts +321 -147
  7. package/database/replication.ts +780 -0
  8. package/database/schema.ts +140 -125
  9. package/database/tenant.ts +4 -0
  10. package/database/vars.ts +405 -0
  11. package/database/workflow.ts +6 -6
  12. package/index.ts +10 -0
  13. package/lib/audit.ts +271 -0
  14. package/lib/auditResult.ts +120 -0
  15. package/lib/define.ts +20 -1
  16. package/lib/files.ts +64 -11
  17. package/lib/lifecycle.ts +141 -0
  18. package/lib/redact.ts +117 -0
  19. package/lib/workflow.ts +1 -1
  20. package/package.json +1 -1
  21. package/server/api.ts +134 -33
  22. package/server/boot.ts +185 -53
  23. package/server/flags.ts +136 -0
  24. package/server/hono.ts +1 -1
  25. package/tests/audit-file.test.ts +296 -0
  26. package/tests/audit-operations.test.ts +184 -0
  27. package/tests/audit-redact.test.ts +233 -0
  28. package/tests/audit-results.test.ts +188 -0
  29. package/tests/audit-retention-config.test.ts +76 -0
  30. package/tests/audit-run-action.test.ts +120 -0
  31. package/tests/audit.test.ts +320 -0
  32. package/tests/crypto.test.ts +17 -17
  33. package/tests/db-cache.test.ts +7 -7
  34. package/tests/field-encryption.test.ts +25 -25
  35. package/tests/file-collection-replication.test.ts +98 -0
  36. package/tests/fixtures/index-tenant/collections/variants.model.ts +2 -2
  37. package/tests/fixtures/lc-disabled/lifecycle.ts +11 -0
  38. package/tests/fixtures/lc-hang/lifecycle.ts +13 -0
  39. package/tests/fixtures/lc-normal/lifecycle.ts +23 -0
  40. package/tests/fixtures/lc-throw/lifecycle.ts +6 -0
  41. package/tests/fixtures/repl-only/app.ts +26 -0
  42. package/tests/fixtures/repl-only/tenant/collections/orders.model.ts +14 -0
  43. package/tests/fixtures/repl-only/tenant/lifecycle.ts +14 -0
  44. package/tests/fixtures/repl-only/tenant/scripts/probe.run.ts +8 -0
  45. package/tests/fixtures/vars-tenant/vars/config.var.ts +23 -0
  46. package/tests/fixtures/vars-tenant/vars/private.var.ts +6 -0
  47. package/tests/index-spec.test.ts +5 -5
  48. package/tests/lifecycle.test.ts +96 -0
  49. package/tests/replication-control.test.ts +128 -0
  50. package/tests/replication-initial-sync.test.ts +86 -0
  51. package/tests/replication-only.test.ts +358 -0
  52. package/tests/replication.test.ts +188 -0
  53. package/tests/upload.test.ts +239 -0
  54. package/tests/vars-api.test.ts +123 -0
  55. package/tests/vars-replication.test.ts +95 -0
  56. package/tests/vars.test.ts +216 -0
  57. package/types/collection.d.ts +3 -0
  58. package/types/config.d.ts +64 -0
  59. package/types/file.d.ts +4 -1
  60. package/types/lifecycle.d.ts +71 -0
  61. package/types/replication.d.ts +199 -0
  62. package/types/tenant.d.ts +23 -0
  63. package/types/vars.d.ts +147 -0
  64. package/utils/func.ts +67 -52
@@ -11,6 +11,10 @@ import { AppError, fn } from "../lib/error";
11
11
  import { createWorkflow } from "./workflow";
12
12
  import { getTenant } from "./tenant";
13
13
  import { getCollection, getCollectionKeys } from "./collection"
14
+ import { AUDIT_COLLECTION } from "./audit"
15
+ import { writeAuditFile } from "../lib/audit"
16
+ import { redactPayload } from "../lib/redact"
17
+ import { summarizeAuditResult } from "../lib/auditResult"
14
18
  import { encryptFields, encryptUpdate } from "./field.crypto"
15
19
  import type { RestActions, BulkUpdateOperation, RestOptions, CursorOptions, TenantCache } from "../types/rest";
16
20
  import type Joi from "joi";
@@ -22,6 +26,8 @@ import * as os from 'node:os';
22
26
  import { cfg } from '../server/config';
23
27
  import { io } from '../server/io';
24
28
  import type { Service } from '../types/service';
29
+ import { createVars } from "./vars";
30
+ import type { TenantVars } from "../types/vars";
25
31
 
26
32
  class MongoRest {
27
33
  client!: MongoClient;
@@ -126,7 +132,7 @@ class MongoRest {
126
132
  validationResult = partialSchema.validate(options.update.$setOnInsert, { allowUnknown: false })
127
133
  }
128
134
 
129
- // Faille 9: updateOne/updateMany — unflatten dot-notation ("address.zip") before Joi validation
135
+ // Flaw 9: updateOne/updateMany — unflatten dot-notation ("address.zip") before Joi validation
130
136
  if ((options.action == 'updateOne' || options.action == 'updateMany') && options.update?.$set) {
131
137
  const unflattened = func.unflattenKeys(options.update.$set)
132
138
  validationResult = partialSchema.validate(unflattened, { allowUnknown: false })
@@ -185,10 +191,14 @@ class MongoRest {
185
191
  core_version: cfg.version ?? restMeta?.core_version,
186
192
  }
187
193
 
188
- // Determine collection type
189
- const colMeta = cfg.collections?.find(c => c.slug === data.collection && c._tenant_ === this.tenant_id)
190
- ?? cfg.fileCollections?.find(c => c.slug === data.collection && c._tenant_ === this.tenant_id)
191
- const collectionType = colMeta && 'type' in colMeta ? colMeta.type : colMeta ? 'file' : undefined
194
+ // Determine collection type — a file collection is 'file'; a declared
195
+ // document collection is 'document' unless it explicitly declares a
196
+ // `type`. Never inferred from the absence of a declaration.
197
+ const fileCol = cfg.fileCollections?.find(c => c.slug === data.collection && c._tenant_ === this.tenant_id)
198
+ const docCol = cfg.collections?.find(c => c.slug === data.collection && c._tenant_ === this.tenant_id)
199
+ const collectionType: ActivityInput['operation']['collectionType'] = fileCol
200
+ ? 'file'
201
+ : docCol ? (docCol.type ?? 'document') : undefined
192
202
 
193
203
  const activity: ActivityInput = {
194
204
  internal: requestCtxStorage.get<boolean>('internal') ?? this.#internal,
@@ -202,7 +212,7 @@ class MongoRest {
202
212
  collectionType,
203
213
  status: data.error ? 'error' : 'success',
204
214
  input: data.input,
205
- result: data.result,
215
+ result: summarizeAuditResult(data.action, data.result),
206
216
  error: data.error ?? null,
207
217
  duration: data.duration,
208
218
  transaction: this.session?.inTransaction?.() ?? false,
@@ -219,13 +229,49 @@ class MongoRest {
219
229
  collection: string,
220
230
  input: any,
221
231
  fn: () => Promise<T>,
222
- options?: { invalidate?: boolean | string[] }
232
+ options?: {
233
+ invalidate?: boolean | string[]
234
+ /**
235
+ * `false` → the result is **not** stored in the audit entry
236
+ * (`operation.result` stays `null`). Used by reads and custom actions,
237
+ * whose payload is arbitrary, potentially huge or sensitive — the trail
238
+ * records *who did what, with which parameters, when*.
239
+ * Default: `true`.
240
+ */
241
+ includeResult?: boolean
242
+ /**
243
+ * `false` → on error, log it and rethrow the **original** error as-is.
244
+ * Default: `true` (non-`AppError` errors are wrapped in `INTERNAL_API_ERROR`).
245
+ */
246
+ wrapErrors?: boolean
247
+ /**
248
+ * `true` (default) → the audit entry is written **before** the operation
249
+ * returns, so a successful mutation can never lose its trace. Reads pass
250
+ * `false` to avoid an extra round-trip.
251
+ */
252
+ awaitAudit?: boolean
253
+ }
223
254
  ): Promise<T> {
224
255
  const start = Date.now()
256
+ const includeResult = options?.includeResult ?? true
257
+ const wrapErrors = options?.wrapErrors ?? true
258
+ const awaitAudit = options?.awaitAudit ?? true
259
+ const log = async (entry: {
260
+ action: string
261
+ collection: string
262
+ input?: any
263
+ result?: any
264
+ error?: { message: string; code: string }
265
+ duration: number
266
+ }) => {
267
+ const promise = this.#logActivity(entry)
268
+ if (awaitAudit) await promise
269
+ else promise.catch(() => { })
270
+ }
225
271
  try {
226
272
  const result = await fn()
227
273
  const duration = Date.now() - start
228
- this.#logActivity({ action, collection, input, result, duration })
274
+ await log({ action, collection, input, result: includeResult ? result : null, duration })
229
275
  // Writes invalidate the collection's query cache (and its $include dependents).
230
276
  // An array of ids = targeted invalidation (only queries containing those ids),
231
277
  // `true` = clear the whole collection namespace.
@@ -235,7 +281,8 @@ class MongoRest {
235
281
  return result
236
282
  } catch (err: any) {
237
283
  const duration = Date.now() - start
238
- this.#logActivity({ action, collection, input, error: { message: err?.message, code: err?.code || 'INTERNAL_API_ERROR' }, duration })
284
+ await log({ action, collection, input, result: null, error: { message: err?.message, code: err?.code || 'INTERNAL_API_ERROR' }, duration })
285
+ if (!wrapErrors) throw err
239
286
  throw err instanceof AppError ? err : new AppError(err?.message || 'Internal server error', { code: 'INTERNAL_API_ERROR', status: 500 })
240
287
  }
241
288
  }
@@ -245,6 +292,24 @@ class MongoRest {
245
292
  return dbInvalidate(collection, this.tenant_id, ids)
246
293
  }
247
294
 
295
+ /**
296
+ * Audit a read operation — `find`, `findOne`, `aggregate`.
297
+ *
298
+ * Reads are logged with their **parameters only** (filters, pipeline, options):
299
+ * `operation.result` is always `null`. What a query returned is huge, redundant
300
+ * with the collection itself, and often a privacy leak — the audit trail answers
301
+ * "who read what, with which filter, when", not "what did they see".
302
+ */
303
+ async #executeReadWithAudit<T>(
304
+ action: string,
305
+ collection: string,
306
+ input: any,
307
+ fn: () => Promise<T>
308
+ ): Promise<T> {
309
+ // Reads stay fire-and-forget: they must not pay for an extra round-trip
310
+ return this.#executeWithAudit(action, collection, input, fn, { includeResult: false, awaitAudit: false })
311
+ }
312
+
248
313
  async connect(options?: {
249
314
  uri?: string;
250
315
  options?: MongoClientOptions;
@@ -271,60 +336,64 @@ class MongoRest {
271
336
 
272
337
  @CheckIfCollectionExists()
273
338
  async watch(collection: string, pipeline: any[], options: ChangeStreamOptions): Promise<ChangeStream> {
274
- const col = getCollection(collection, this.#tenant.id) as Collection
275
- const isSafe = func.isSafeAggregatePipeline(pipeline);
276
- if (!isSafe.isSafe) {
277
- throw isSafe.error;
278
- }
279
- pipeline = await func.buildInput(pipeline)
280
- pipeline = func.toBson(pipeline, { col })
281
- return await this.db.collection(collection).watch(pipeline, {
282
- allowDiskUse: true,
283
- session: this.session,
284
- ...options
285
- })
339
+ // Audited with the parameters only — the change stream itself is a live object
340
+ return this.#executeWithAudit('watch', collection, { pipeline, options }, async () => {
341
+ const col = getCollection(collection, this.#tenant.id) as Collection
342
+ const isSafe = func.isSafeAggregatePipeline(pipeline);
343
+ if (!isSafe.isSafe) {
344
+ throw isSafe.error;
345
+ }
346
+ pipeline = await func.buildInput(pipeline)
347
+ pipeline = func.toBson(pipeline, { col })
348
+ return await this.db.collection(collection).watch(pipeline, {
349
+ allowDiskUse: true,
350
+ session: this.session,
351
+ ...options
352
+ })
353
+ }, { includeResult: false, awaitAudit: false })
286
354
  }
287
355
 
288
356
  @LogSlowQuery()
289
357
  @CheckIfCollectionExists()
290
358
  async aggregate(collection: string, pipeline: any[]): Promise<Document[]> {
291
359
  const action = 'aggregate' as ActionsApiList
292
- try {
293
-
294
- const col = getCollection(collection, this.#tenant.id) as Collection
295
- // Faille 12: restrict $lookup targets to this tenant's declared collections
296
- const allowedCollections = [
297
- ...(cfg.collections ?? [])
298
- .filter(c => c._tenant_ === this.tenant_id)
299
- .map(c => c.slug),
300
- ...(cfg.fileCollections ?? [])
301
- .filter(fc => fc._tenant_ === this.tenant_id)
302
- .map(fc => fc.slug),
303
- '_activities_', '_locks_', '_workflows_',
304
- ];
305
- const isSafe = func.isSafeAggregatePipeline(pipeline, allowedCollections);
306
- if (!isSafe.isSafe) {
307
- throw isSafe.error;
308
- }
309
- pipeline = func.toBson(pipeline, { col })
310
- const meta: any = { action, collection, pipeline }
311
- if (col.hooks?.beforeOperation) {
312
- await col.hooks.beforeOperation({ rest: this, io, action, meta })
313
- }
360
+ return this.#executeReadWithAudit(action, collection, { pipeline }, async () => {
361
+ try {
362
+ const col = getCollection(collection, this.#tenant.id) as Collection
363
+ // Flaw 12: restrict $lookup targets to this tenant's declared collections
364
+ const allowedCollections = [
365
+ ...(cfg.collections ?? [])
366
+ .filter(c => c._tenant_ === this.tenant_id)
367
+ .map(c => c.slug),
368
+ ...(cfg.fileCollections ?? [])
369
+ .filter(fc => fc._tenant_ === this.tenant_id)
370
+ .map(fc => fc.slug),
371
+ AUDIT_COLLECTION, '_locks_', '_workflows_',
372
+ ];
373
+ const isSafe = func.isSafeAggregatePipeline(pipeline, allowedCollections);
374
+ if (!isSafe.isSafe) {
375
+ throw isSafe.error;
376
+ }
377
+ pipeline = func.toBson(pipeline, { col })
378
+ const meta: any = { action, collection, pipeline }
379
+ if (col.hooks?.beforeOperation) {
380
+ await col.hooks.beforeOperation({ rest: this, io, action, meta })
381
+ }
314
382
 
315
- let result = await this.db.collection(collection).aggregate(pipeline, {
316
- session: this.session,
317
- allowDiskUse: true,
318
- }).toArray()
319
- result = func.toJson(result)
320
- meta.result = result
383
+ let result = await this.db.collection(collection).aggregate(pipeline, {
384
+ session: this.session,
385
+ allowDiskUse: true,
386
+ }).toArray()
387
+ result = func.toJson(result)
388
+ meta.result = result
321
389
 
322
- await col.hooks?.afterOperation?.({ rest: this, io, action, meta })
390
+ await col.hooks?.afterOperation?.({ rest: this, io, action, meta })
323
391
 
324
- return result as any[]
325
- } catch (err: any) {
326
- throw err instanceof AppError ? err : new AppError(err?.message || 'Internal server error', { code: 'INTERNAL_API_ERROR', status: 500 })
327
- }
392
+ return result as any[]
393
+ } catch (err: any) {
394
+ throw err instanceof AppError ? err : new AppError(err?.message || 'Internal server error', { code: 'INTERNAL_API_ERROR', status: 500 })
395
+ }
396
+ })
328
397
  }
329
398
 
330
399
  /**
@@ -359,6 +428,10 @@ class MongoRest {
359
428
  */
360
429
  @CheckIfCollectionExists()
361
430
  async *findStream<T = any>(collection: string, params: FindOptions = {}, options: CursorOptions = {}): AsyncGenerator<any> {
431
+ // The entry is written when the stream ends (completion, error, or early
432
+ // `break`/`return()` — which triggers the `finally` below). Parameters only.
433
+ const start = Date.now();
434
+ let failure: any = null;
362
435
  const pageSize = options.pageSize;
363
436
  // Page mode counts by default (opt-out with withCount: false); per-doc mode counts only when withCount: true
364
437
  const withCount = pageSize ? options.withCount !== false : options.withCount === true;
@@ -410,7 +483,18 @@ class MongoRest {
410
483
  }
411
484
  }
412
485
  }
486
+ } catch (err: any) {
487
+ failure = err
488
+ throw err
413
489
  } finally {
490
+ this.#logActivity({
491
+ action: 'findStream',
492
+ collection,
493
+ input: { params, options },
494
+ result: null,
495
+ error: failure ? { message: failure?.message, code: failure?.code || 'INTERNAL_API_ERROR' } : undefined,
496
+ duration: Date.now() - start,
497
+ })
414
498
  await cursor.close().catch(() => {})
415
499
  }
416
500
  }
@@ -427,11 +511,15 @@ class MongoRest {
427
511
  */
428
512
  @CheckIfCollectionExists()
429
513
  async *aggregateStream<T = any>(collection: string, pipeline: any[] = [], options: CursorOptions = {}): AsyncGenerator<any> {
514
+ // The entry is written when the stream ends (completion, error, or early
515
+ // `break`/`return()` — which triggers the `finally` below). Parameters only.
516
+ const start = Date.now();
517
+ let failure: any = null;
430
518
  const col = getCollection(collection, this.#tenant.id) as Collection
431
519
  const allowedCollections = [
432
520
  ...(cfg.collections ?? []).filter((c) => c._tenant_ === this.tenant_id).map((c) => c.slug),
433
521
  ...(cfg.fileCollections ?? []).filter((fc) => fc._tenant_ === this.tenant_id).map((fc) => fc.slug),
434
- '_activities_', '_locks_', '_workflows_',
522
+ AUDIT_COLLECTION, '_locks_', '_workflows_',
435
523
  ];
436
524
  const isSafe = func.isSafeAggregatePipeline(pipeline, allowedCollections);
437
525
  if (!isSafe.isSafe) {
@@ -466,7 +554,18 @@ class MongoRest {
466
554
  yield value
467
555
  }
468
556
  }
557
+ } catch (err: any) {
558
+ failure = err
559
+ throw err
469
560
  } finally {
561
+ this.#logActivity({
562
+ action: 'aggregateStream',
563
+ collection,
564
+ input: { pipeline, options },
565
+ result: null,
566
+ error: failure ? { message: failure?.message, code: failure?.code || 'INTERNAL_API_ERROR' } : undefined,
567
+ duration: Date.now() - start,
568
+ })
470
569
  await cursor.close().catch(() => {})
471
570
  }
472
571
  }
@@ -475,23 +574,69 @@ class MongoRest {
475
574
  @CheckIfCollectionExists()
476
575
  async find(collection: string, params: FindOptions = {}, options: FindCallOptions = {}): Promise<Document[]> {
477
576
  const action = 'find' as ActionsApiList
478
- try {
479
- // Real query — hooks + pipeline + toArray
480
- const run = async (): Promise<Document[]> => {
577
+ return this.#executeReadWithAudit(action, collection, { params, options }, async () => {
578
+ try {
579
+ // Real query — hooks + pipeline + toArray
580
+ const run = async (): Promise<Document[]> => {
581
+ const col = getCollection(collection, this.#tenant.id) as Collection
582
+ let pipeline = func.buildPipeline(params, { col: col })
583
+ pipeline = await func.buildInput(pipeline, { rest: this })
584
+ pipeline = func.toBson(pipeline, { col })
585
+
586
+ const meta: any = { action, collection, params, options }
587
+ if (col.hooks?.beforeOperation) {
588
+ await col.hooks.beforeOperation({ rest: this, io, action, meta })
589
+ }
590
+
591
+ let result = await this.db.collection(collection).aggregate(pipeline, {
592
+ session: this.session,
593
+ allowDiskUse: true
594
+ }).toArray()
595
+ result = func.toJson(result)
596
+ meta.result = result
597
+
598
+ if (col.hooks?.afterOperation) {
599
+ await col.hooks.afterOperation({ rest: this, io, action, meta })
600
+ }
601
+
602
+ return result as any[]
603
+ }
604
+
605
+ // Cache-first when `useCache: true` — served from the query cache, populated on miss
606
+ if (options.useCache === true) {
607
+ return await dbGetOrSet(collection, this.tenant_id, params, options, run)
608
+ }
609
+ return await run()
610
+ } catch (err: any) {
611
+ throw err instanceof AppError ? err : new AppError(err?.message || 'Internal server error', { code: 'INTERNAL_API_ERROR', status: 500 })
612
+ }
613
+ })
614
+ }
615
+
616
+ @LogSlowQuery()
617
+ @CheckIfCollectionExists()
618
+ async findOne(collection: string, _id: string, params?: findOneOptions): Promise<any> {
619
+ const action = 'findOne' as ActionsApiList
620
+ return this.#executeReadWithAudit(action, collection, { id: _id, params }, async () => {
621
+ try {
481
622
  const col = getCollection(collection, this.#tenant.id) as Collection
482
- let pipeline = func.buildPipeline(params, { col: col })
623
+ let pipeline = func.buildPipeline({
624
+ ...params,
625
+ $match: { _id },
626
+ $limit: 1
627
+ }, { col: col })
483
628
  pipeline = await func.buildInput(pipeline, { rest: this })
484
629
  pipeline = func.toBson(pipeline, { col })
485
630
 
486
- const meta: any = { action, collection, params, options }
631
+ const meta: any = { action, collection, params, id: _id }
487
632
  if (col.hooks?.beforeOperation) {
488
633
  await col.hooks.beforeOperation({ rest: this, io, action, meta })
489
634
  }
490
635
 
491
- let result = await this.db.collection(collection).aggregate(pipeline, {
636
+ let result = (await this.db.collection(collection).aggregate(pipeline, {
492
637
  session: this.session,
493
638
  allowDiskUse: true
494
- }).toArray()
639
+ }).toArray()).at(0) ?? null
495
640
  result = func.toJson(result)
496
641
  meta.result = result
497
642
 
@@ -499,53 +644,11 @@ class MongoRest {
499
644
  await col.hooks.afterOperation({ rest: this, io, action, meta })
500
645
  }
501
646
 
502
- return result as any[]
503
- }
504
-
505
- // Cache-first when `useCache: true` — served from the query cache, populated on miss
506
- if (options.useCache === true) {
507
- return await dbGetOrSet(collection, this.tenant_id, params, options, run)
647
+ return result
648
+ } catch (err: any) {
649
+ throw err instanceof AppError ? err : new AppError(err?.message || 'Internal server error', { code: 'INTERNAL_API_ERROR', status: 500 })
508
650
  }
509
- return await run()
510
- } catch (err: any) {
511
- throw err instanceof AppError ? err : new AppError(err?.message || 'Internal server error', { code: 'INTERNAL_API_ERROR', status: 500 })
512
- }
513
- }
514
-
515
- @LogSlowQuery()
516
- @CheckIfCollectionExists()
517
- async findOne(collection: string, _id: string, params?: findOneOptions): Promise<any> {
518
- const action = 'findOne' as ActionsApiList
519
- try {
520
- const col = getCollection(collection, this.#tenant.id) as Collection
521
- let pipeline = func.buildPipeline({
522
- ...params,
523
- $match: { _id },
524
- $limit: 1
525
- }, { col: col })
526
- pipeline = await func.buildInput(pipeline, { rest: this })
527
- pipeline = func.toBson(pipeline, { col })
528
-
529
- const meta: any = { action, collection, params, id: _id }
530
- if (col.hooks?.beforeOperation) {
531
- await col.hooks.beforeOperation({ rest: this, io, action, meta })
532
- }
533
-
534
- let result = (await this.db.collection(collection).aggregate(pipeline, {
535
- session: this.session,
536
- allowDiskUse: true
537
- }).toArray()).at(0) ?? null
538
- result = func.toJson(result)
539
- meta.result = result
540
-
541
- if (col.hooks?.afterOperation) {
542
- await col.hooks.afterOperation({ rest: this, io, action, meta })
543
- }
544
-
545
- return result
546
- } catch (err: any) {
547
- throw err instanceof AppError ? err : new AppError(err?.message || 'Internal server error', { code: 'INTERNAL_API_ERROR', status: 500 })
548
- }
651
+ })
549
652
  }
550
653
 
551
654
  @LogSlowQuery()
@@ -973,49 +1076,60 @@ class MongoRest {
973
1076
  }
974
1077
 
975
1078
  async runAction<T = any>(collection: string, action: string, data?: any): Promise<T> {
976
- const col = getCollection(collection, this.#tenant.id)
977
- if (!col) {
978
- throw new AppError(`Collection '${collection}' not found`, { code: 'COLLECTION_NOT_FOUND', status: 500 })
979
- }
980
-
981
- if (!Object.hasOwn(col?.actions ?? {}, action)) {
982
- throw new AppError(`Action '${action}' not found on collection '${collection}'`, { code: 'ACTION_NOT_FOUND', status: 500 })
983
- }
1079
+ // Audited like any other operation: the custom action **name** and its
1080
+ // `data` are recorded, the returned payload never is (`includeResult: false`).
1081
+ // `wrapErrors: false` — a custom action's own error must reach the caller unchanged.
1082
+ return this.#executeWithAudit('runAction', collection, { action, data }, async () => {
1083
+ const col = getCollection(collection, this.#tenant.id)
1084
+ if (!col) {
1085
+ throw new AppError(`Collection '${collection}' not found`, { code: 'COLLECTION_NOT_FOUND', status: 500 })
1086
+ }
984
1087
 
985
- const token = requestCtxStorage.get<{ value: string | null, decoded: Record<string, unknown> | null, provided: boolean, expired: boolean }>('token')
1088
+ if (!Object.hasOwn(col?.actions ?? {}, action)) {
1089
+ throw new AppError(`Action '${action}' not found on collection '${collection}'`, { code: 'ACTION_NOT_FOUND', status: 500 })
1090
+ }
986
1091
 
987
- return await col.actions?.[action]?.({
988
- rest: this,
989
- io,
990
- data,
991
- error: fn.error,
992
- jwt: func.jwt,
993
- token: token ?? { value: null, decoded: null, provided: false, expired: false },
994
- } as any)
1092
+ const token = requestCtxStorage.get<{ value: string | null, decoded: Record<string, unknown> | null, provided: boolean, expired: boolean }>('token')
1093
+
1094
+ return await col.actions?.[action]?.({
1095
+ rest: this,
1096
+ io,
1097
+ data,
1098
+ error: fn.error,
1099
+ jwt: func.jwt,
1100
+ token: token ?? { value: null, decoded: null, provided: false, expired: false },
1101
+ } as any)
1102
+ }, { includeResult: false, wrapErrors: false })
995
1103
  }
996
1104
 
997
1105
  async runService<T = any>(service: string, action: string, data?: any): Promise<T> {
998
- const serviceInstance = cfg.services?.find(s => s.name === service && s._tenant_ === this.tenant_id) as Service | undefined
999
- if (!serviceInstance) {
1000
- throw new AppError(`Service '${service}' not found`, { code: 'SERVICE_NOT_FOUND', status: 500 })
1001
- }
1002
- if (!serviceInstance.enabled) {
1003
- throw new AppError(`Service '${service}' is not enabled`, { code: 'SERVICE_NOT_ENABLED', status: 500 })
1004
- }
1005
- if (!Object.hasOwn(serviceInstance.actions, action) || !serviceInstance.actions?.[action]) {
1006
- throw new AppError(`Action '${action}' not found on service '${service}'`, { code: 'SERVICE_ACTION_NOT_FOUND', status: 500 })
1007
- }
1008
-
1009
- const token = requestCtxStorage.get<{ value: string | null, decoded: Record<string, unknown> | null, provided: boolean, expired: boolean }>('token')
1106
+ // Audited like `runAction`: the service name (`collection`), the invoked
1107
+ // action and its `data` are recorded — never the returned payload.
1108
+ // `internal` tells whether the call came from server-side code or the route.
1109
+ // `wrapErrors: false` — a service's own error must reach the caller unchanged.
1110
+ return this.#executeWithAudit('runService', service, { action, data }, async () => {
1111
+ const serviceInstance = cfg.services?.find(s => s.name === service && s._tenant_ === this.tenant_id) as Service | undefined
1112
+ if (!serviceInstance) {
1113
+ throw new AppError(`Service '${service}' not found`, { code: 'SERVICE_NOT_FOUND', status: 500 })
1114
+ }
1115
+ if (!serviceInstance.enabled) {
1116
+ throw new AppError(`Service '${service}' is not enabled`, { code: 'SERVICE_NOT_ENABLED', status: 500 })
1117
+ }
1118
+ if (!Object.hasOwn(serviceInstance.actions, action) || !serviceInstance.actions?.[action]) {
1119
+ throw new AppError(`Action '${action}' not found on service '${service}'`, { code: 'SERVICE_ACTION_NOT_FOUND', status: 500 })
1120
+ }
1010
1121
 
1011
- return await serviceInstance.actions?.[action]?.({
1012
- data,
1013
- error: fn.error,
1014
- io,
1015
- jwt: func.jwt,
1016
- token: token ?? { value: null, decoded: null, provided: false, expired: false },
1017
- rest: this,
1018
- } as any)
1122
+ const token = requestCtxStorage.get<{ value: string | null, decoded: Record<string, unknown> | null, provided: boolean, expired: boolean }>('token')
1123
+
1124
+ return await serviceInstance.actions?.[action]?.({
1125
+ data,
1126
+ error: fn.error,
1127
+ io,
1128
+ jwt: func.jwt,
1129
+ token: token ?? { value: null, decoded: null, provided: false, expired: false },
1130
+ rest: this,
1131
+ } as any)
1132
+ }, { includeResult: false, wrapErrors: false })
1019
1133
  }
1020
1134
 
1021
1135
  async stats() {
@@ -1101,12 +1215,59 @@ class MongoRest {
1101
1215
 
1102
1216
  get audit() {
1103
1217
  return {
1218
+ /**
1219
+ * Log an audit entry the way the framework does (same envelope: trace,
1220
+ * request, meta, `internal`, redaction, result policy).
1221
+ *
1222
+ * @example
1223
+ * await rest.audit.log({ action: 'export', collection: 'orders', input: { from, to } })
1224
+ */
1225
+ log: async (entry: {
1226
+ action: string;
1227
+ collection: string;
1228
+ input?: any;
1229
+ result?: any;
1230
+ error?: { message: string; code?: string } | null;
1231
+ duration?: number;
1232
+ }): Promise<void> => {
1233
+ await this.#logActivity({
1234
+ action: entry.action,
1235
+ collection: entry.collection,
1236
+ input: entry.input,
1237
+ result: entry.result,
1238
+ error: entry.error
1239
+ ? { message: entry.error.message, code: entry.error.code ?? 'INTERNAL_API_ERROR' }
1240
+ : undefined,
1241
+ duration: entry.duration ?? 0,
1242
+ })
1243
+ },
1104
1244
  addActivities: async (activities: ActivityInput[]) => {
1105
- await this.db.collection('_activities_').insertMany(activities)
1245
+ // Single choke point for the audit trail: every payload is redacted
1246
+ // here (`server.audit.redact`), whatever wrote the entry — including a
1247
+ // direct `rest.audit.addActivities()` call. Never store a password,
1248
+ // token or secret in clear, in `_audit_` or in the local file.
1249
+ const entries = activities.map((activity) => ({
1250
+ ...activity,
1251
+ // `request` (headers, query string) is attacker-controlled too — it
1252
+ // carries `x-api-key`, `?token=…`, … just like a payload
1253
+ request: redactPayload(activity?.request),
1254
+ meta: redactPayload(activity?.meta),
1255
+ operation: {
1256
+ ...activity?.operation,
1257
+ input: redactPayload(activity?.operation?.input),
1258
+ result: redactPayload(activity?.operation?.result),
1259
+ },
1260
+ }))
1261
+ await this.db.collection(AUDIT_COLLECTION).insertMany(entries)
1106
1262
  .then(e => e)
1107
1263
  .catch(err => {
1108
1264
  console.error('Error inserting activities', err)
1109
1265
  })
1266
+ // Local append-only backup (opt-in) — fire-and-forget, never fatal.
1267
+ // Runs after the insert on purpose: `insertMany` mutates the documents
1268
+ // with the `_id` generated by MongoDB, so every line of the local file
1269
+ // can be joined back to the `_audit_` collection.
1270
+ writeAuditFile(getTenant(this.tenant_id), entries)
1110
1271
  },
1111
1272
  getActivities: async (opts = {
1112
1273
  $match: {},
@@ -1114,7 +1275,7 @@ class MongoRest {
1114
1275
  }) => {
1115
1276
 
1116
1277
  let pipeline = func.buildPipeline(opts)
1117
- return await this.db.collection('_activities_').aggregate(pipeline).toArray()
1278
+ return await this.db.collection(AUDIT_COLLECTION).aggregate(pipeline).toArray()
1118
1279
  }
1119
1280
  }
1120
1281
  }
@@ -1151,6 +1312,19 @@ class MongoRest {
1151
1312
  };
1152
1313
  }
1153
1314
 
1315
+ /**
1316
+ * Tenant-scoped key/value store — like Redis, MongoDB-backed (durable, no TTL).
1317
+ * Meant for configuration-like values (`rest.cache` is for ephemeral data).
1318
+ *
1319
+ * @example
1320
+ * await rest.vars.set('config', 'licence', 'RDX00');
1321
+ * const licence = await rest.vars.get('config', 'licence');
1322
+ * await rest.vars.del('config', 'licence');
1323
+ */
1324
+ get vars(): TenantVars {
1325
+ return createVars(this.tenant_id);
1326
+ }
1327
+
1154
1328
  /**
1155
1329
  * Acquire a distributed lock for this tenant.
1156
1330
  * Uses MongoDB atomic findOneAndUpdate with upsert.