@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.
- package/database/audit.ts +193 -0
- package/database/collection.ts +4 -4
- package/database/deleteLog.ts +34 -0
- package/database/field.crypto.ts +1 -1
- package/database/file.ts +3 -3
- package/database/mongodbadapter.ts +321 -147
- package/database/replication.ts +780 -0
- package/database/schema.ts +140 -125
- package/database/tenant.ts +4 -0
- package/database/vars.ts +405 -0
- package/database/workflow.ts +6 -6
- package/index.ts +10 -0
- package/lib/audit.ts +271 -0
- package/lib/auditResult.ts +120 -0
- package/lib/define.ts +20 -1
- package/lib/files.ts +64 -11
- package/lib/lifecycle.ts +141 -0
- package/lib/redact.ts +117 -0
- package/lib/workflow.ts +1 -1
- package/package.json +1 -1
- package/server/api.ts +134 -33
- package/server/boot.ts +185 -53
- package/server/flags.ts +136 -0
- package/server/hono.ts +1 -1
- package/tests/audit-file.test.ts +296 -0
- package/tests/audit-operations.test.ts +184 -0
- package/tests/audit-redact.test.ts +233 -0
- package/tests/audit-results.test.ts +188 -0
- package/tests/audit-retention-config.test.ts +76 -0
- package/tests/audit-run-action.test.ts +120 -0
- package/tests/audit.test.ts +320 -0
- package/tests/crypto.test.ts +17 -17
- package/tests/db-cache.test.ts +7 -7
- package/tests/field-encryption.test.ts +25 -25
- package/tests/file-collection-replication.test.ts +98 -0
- package/tests/fixtures/index-tenant/collections/variants.model.ts +2 -2
- package/tests/fixtures/lc-disabled/lifecycle.ts +11 -0
- package/tests/fixtures/lc-hang/lifecycle.ts +13 -0
- package/tests/fixtures/lc-normal/lifecycle.ts +23 -0
- package/tests/fixtures/lc-throw/lifecycle.ts +6 -0
- package/tests/fixtures/repl-only/app.ts +26 -0
- package/tests/fixtures/repl-only/tenant/collections/orders.model.ts +14 -0
- package/tests/fixtures/repl-only/tenant/lifecycle.ts +14 -0
- package/tests/fixtures/repl-only/tenant/scripts/probe.run.ts +8 -0
- package/tests/fixtures/vars-tenant/vars/config.var.ts +23 -0
- package/tests/fixtures/vars-tenant/vars/private.var.ts +6 -0
- package/tests/index-spec.test.ts +5 -5
- package/tests/lifecycle.test.ts +96 -0
- package/tests/replication-control.test.ts +128 -0
- package/tests/replication-initial-sync.test.ts +86 -0
- package/tests/replication-only.test.ts +358 -0
- package/tests/replication.test.ts +188 -0
- package/tests/upload.test.ts +239 -0
- package/tests/vars-api.test.ts +123 -0
- package/tests/vars-replication.test.ts +95 -0
- package/tests/vars.test.ts +216 -0
- package/types/collection.d.ts +3 -0
- package/types/config.d.ts +64 -0
- package/types/file.d.ts +4 -1
- package/types/lifecycle.d.ts +71 -0
- package/types/replication.d.ts +199 -0
- package/types/tenant.d.ts +23 -0
- package/types/vars.d.ts +147 -0
- 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
|
-
//
|
|
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
|
-
|
|
190
|
-
|
|
191
|
-
const
|
|
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?: {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
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
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
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
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
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
|
-
|
|
390
|
+
await col.hooks?.afterOperation?.({ rest: this, io, action, meta })
|
|
323
391
|
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
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
|
-
|
|
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
|
-
|
|
479
|
-
|
|
480
|
-
|
|
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(
|
|
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,
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
}
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
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
|
-
|
|
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
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
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
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
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
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
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
|
-
|
|
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(
|
|
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.
|