@anteros/core 0.0.40 → 0.0.42
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 +143 -0
- package/database/collection.ts +6 -4
- package/database/deleteLog.ts +98 -0
- package/database/file.ts +7 -6
- package/database/mongodbadapter.ts +420 -185
- package/database/replication.ts +858 -0
- package/database/schema.ts +140 -125
- package/database/tenant.ts +4 -0
- package/database/ttl.ts +68 -0
- package/database/vars.ts +405 -0
- package/database/workflow.ts +466 -240
- package/index.ts +16 -0
- package/lib/agent.ts +1327 -0
- package/lib/agentMemory.ts +586 -0
- package/lib/agents.ts +157 -0
- package/lib/ai.ts +0 -0
- package/lib/api.ts +246 -0
- package/lib/attachment.ts +351 -0
- package/lib/audit.ts +271 -0
- package/lib/auditResult.ts +133 -0
- package/lib/define.ts +62 -1
- package/lib/endpoints.ts +80 -0
- package/lib/files.ts +66 -12
- package/lib/jsonSchema.ts +183 -0
- package/lib/lifecycle.ts +147 -0
- package/lib/load.ts +17 -0
- package/lib/mcp.ts +3 -2
- package/lib/messages.ts +299 -0
- package/lib/providers.ts +867 -0
- package/lib/redact.ts +117 -0
- package/lib/routes.ts +14 -1
- package/lib/scriptWorker.ts +4 -1
- package/lib/scripts.ts +6 -1
- package/lib/sockets.ts +3 -1
- package/lib/workflow.ts +209 -37
- package/lib/zip.ts +109 -0
- package/package.json +1 -1
- package/server/access.ts +96 -0
- package/server/agents.ts +451 -0
- package/server/api.ts +121 -95
- package/server/boot.ts +196 -53
- package/server/flags.ts +136 -0
- package/server/hono.ts +9 -4
- package/server/mcp.ts +17 -63
- package/server/routes.ts +19 -24
- package/tests/agent-memory.test.ts +617 -0
- package/tests/agent-working-memory.test.ts +402 -0
- package/tests/agent.test.ts +1239 -0
- package/tests/agents-api.test.ts +593 -0
- package/tests/api.test.ts +51 -0
- package/tests/attachment.test.ts +241 -0
- package/tests/audit-file.test.ts +296 -0
- package/tests/audit-operations.test.ts +184 -0
- package/tests/audit-redact.test.ts +259 -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/db-cache.test.ts +7 -7
- package/tests/endpoints.test.ts +80 -0
- package/tests/file-collection-replication.test.ts +98 -0
- package/tests/fixtures/agent-tenant/agents/support.agent.ts +9 -0
- package/tests/fixtures/agent-tenant/agents/weather.agent.ts +21 -0
- package/tests/fixtures/agents-api/agents/assistant.agent.ts +46 -0
- package/tests/fixtures/agents-api/agents/plain.agent.ts +23 -0
- package/tests/fixtures/agents-api/agents/private.agent.ts +15 -0
- package/tests/fixtures/agents-api/collections/notes.model.ts +26 -0
- package/tests/fixtures/agents-api/routes/probe.route.ts +21 -0
- package/tests/fixtures/agents-api/services/probe.service.ts +19 -0
- package/tests/fixtures/attachment/data.csv +2 -0
- package/tests/fixtures/fake-provider.ts +128 -0
- 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/replication-meta/agents/chat.agent.ts +16 -0
- package/tests/fixtures/replication-meta/collections/counters.model.ts +13 -0
- package/tests/fixtures/replication-meta/collections/orders.model.ts +9 -0
- package/tests/fixtures/replication-meta/workflows/run.workflow.ts +8 -0
- package/tests/fixtures/vars-tenant/vars/config.var.ts +23 -0
- package/tests/fixtures/vars-tenant/vars/private.var.ts +9 -0
- package/tests/fixtures/workflow-engine/workflows/failing.workflow.ts +34 -0
- package/tests/fixtures/workflow-engine/workflows/flaky.workflow.ts +23 -0
- package/tests/fixtures/workflow-engine/workflows/hang.workflow.ts +17 -0
- package/tests/fixtures/workflow-engine/workflows/simple.workflow.ts +19 -0
- package/tests/fixtures/workflow-engine/workflows/slow.workflow.ts +23 -0
- package/tests/fixtures/workflow-engine/workflows/versioned.workflow.ts +19 -0
- package/tests/fixtures/workflow-tenant/workflows/broken.workflow.ts +3 -0
- package/tests/fixtures/workflow-tenant/workflows/disabled.workflow.ts +8 -0
- package/tests/fixtures/workflow-tenant/workflows/invoice.workflow.ts +21 -0
- package/tests/fixtures/workflow-tenant/workflows/no-id.workflow.ts +7 -0
- package/tests/fixtures/workflow-tenant/workflows/order-duplicate.workflow.ts +13 -0
- package/tests/fixtures/workflow-tenant/workflows/order.workflow.ts +17 -0
- package/tests/hono.test.ts +6 -3
- package/tests/lifecycle.test.ts +96 -0
- package/tests/locks.test.ts +120 -0
- package/tests/mcp.test.ts +11 -11
- package/tests/messages.test.ts +258 -0
- package/tests/replication-control.test.ts +128 -0
- package/tests/replication-initial-sync.test.ts +86 -0
- package/tests/replication-meta.test.ts +243 -0
- package/tests/replication-only.test.ts +358 -0
- package/tests/replication.test.ts +188 -0
- package/tests/upload.test.ts +141 -8
- package/tests/vars-api.test.ts +123 -0
- package/tests/vars-replication.test.ts +96 -0
- package/tests/vars.test.ts +216 -0
- package/tests/workflow-engine.test.ts +340 -0
- package/tests/workflow-retention.test.ts +103 -0
- package/tests/workflow.test.ts +186 -0
- package/types/agent.d.ts +837 -0
- package/types/collection.d.ts +3 -0
- package/types/config.d.ts +80 -0
- package/types/file.d.ts +11 -8
- package/types/hook.d.ts +6 -0
- package/types/lifecycle.d.ts +77 -0
- package/types/mcp.d.ts +11 -1
- package/types/replication.d.ts +228 -0
- package/types/route.d.ts +7 -1
- package/types/scripts.d.ts +6 -0
- package/types/service.d.ts +7 -1
- package/types/tenant.d.ts +36 -0
- package/types/vars.d.ts +147 -0
- package/types/websocket.d.ts +6 -0
- package/types/workflow.d.ts +50 -3
- package/utils/func.ts +67 -52
|
@@ -1,6 +1,20 @@
|
|
|
1
1
|
|
|
2
2
|
import type { Tenant } from "../types/tenant";
|
|
3
3
|
import type { MongoClientOptions, Db, ClientSession, UpdateOptions, UpdateFilter, Document, DeleteResult, ChangeStreamOptions, ChangeStream, ChangeStreamDocument, AnyBulkWriteOperation, BulkWriteOptions, BulkWriteResult, UpdateResult } from "mongodb";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Is a process still running **on this host**? `EPERM` means it exists but belongs
|
|
7
|
+
* to another user — which still counts as alive.
|
|
8
|
+
*/
|
|
9
|
+
function isProcessAlive(pid: number): boolean {
|
|
10
|
+
if (!Number.isInteger(pid) || pid <= 0) return false;
|
|
11
|
+
try {
|
|
12
|
+
process.kill(pid, 0);
|
|
13
|
+
return true;
|
|
14
|
+
} catch (err: any) {
|
|
15
|
+
return err?.code === 'EPERM';
|
|
16
|
+
}
|
|
17
|
+
}
|
|
4
18
|
import { MongoClient, ObjectId, AggregationCursor } from "mongodb";
|
|
5
19
|
import type { FindOptions, findOneOptions } from "../types/mongo";
|
|
6
20
|
import type { FindCallOptions } from "../types/mongo";
|
|
@@ -11,6 +25,10 @@ import { AppError, fn } from "../lib/error";
|
|
|
11
25
|
import { createWorkflow } from "./workflow";
|
|
12
26
|
import { getTenant } from "./tenant";
|
|
13
27
|
import { getCollection, getCollectionKeys } from "./collection"
|
|
28
|
+
import { AUDIT_COLLECTION } from "./audit"
|
|
29
|
+
import { writeAuditFile } from "../lib/audit"
|
|
30
|
+
import { redactPayload } from "../lib/redact"
|
|
31
|
+
import { summarizeAuditResult } from "../lib/auditResult"
|
|
14
32
|
import { encryptFields, encryptUpdate } from "./field.crypto"
|
|
15
33
|
import type { RestActions, BulkUpdateOperation, RestOptions, CursorOptions, TenantCache } from "../types/rest";
|
|
16
34
|
import type Joi from "joi";
|
|
@@ -22,6 +40,10 @@ import * as os from 'node:os';
|
|
|
22
40
|
import { cfg } from '../server/config';
|
|
23
41
|
import { io } from '../server/io';
|
|
24
42
|
import type { Service } from '../types/service';
|
|
43
|
+
import { createVars } from "./vars";
|
|
44
|
+
import type { TenantVars } from "../types/vars";
|
|
45
|
+
import { createAgents } from "../lib/agents";
|
|
46
|
+
import { createApi } from "../lib/api";
|
|
25
47
|
|
|
26
48
|
class MongoRest {
|
|
27
49
|
client!: MongoClient;
|
|
@@ -126,7 +148,7 @@ class MongoRest {
|
|
|
126
148
|
validationResult = partialSchema.validate(options.update.$setOnInsert, { allowUnknown: false })
|
|
127
149
|
}
|
|
128
150
|
|
|
129
|
-
//
|
|
151
|
+
// Flaw 9: updateOne/updateMany — unflatten dot-notation ("address.zip") before Joi validation
|
|
130
152
|
if ((options.action == 'updateOne' || options.action == 'updateMany') && options.update?.$set) {
|
|
131
153
|
const unflattened = func.unflattenKeys(options.update.$set)
|
|
132
154
|
validationResult = partialSchema.validate(unflattened, { allowUnknown: false })
|
|
@@ -157,6 +179,15 @@ class MongoRest {
|
|
|
157
179
|
return options.data;
|
|
158
180
|
}
|
|
159
181
|
|
|
182
|
+
/**
|
|
183
|
+
* Context handed to a collection hook. `agents` is the tenant-scoped registry,
|
|
184
|
+
* bound to `this` — so a hook runs an agent exactly like it reads a collection:
|
|
185
|
+
* `await agents.get('support')?.generate(...)`.
|
|
186
|
+
*/
|
|
187
|
+
#hookCtx(action: any, meta: any) {
|
|
188
|
+
return { rest: this, io, action, meta, agents: createAgents(this.tenant_id, this), api: createApi(this) };
|
|
189
|
+
}
|
|
190
|
+
|
|
160
191
|
async #logActivity(data: {
|
|
161
192
|
action: string,
|
|
162
193
|
collection: string,
|
|
@@ -185,10 +216,14 @@ class MongoRest {
|
|
|
185
216
|
core_version: cfg.version ?? restMeta?.core_version,
|
|
186
217
|
}
|
|
187
218
|
|
|
188
|
-
// Determine collection type
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
const
|
|
219
|
+
// Determine collection type — a file collection is 'file'; a declared
|
|
220
|
+
// document collection is 'document' unless it explicitly declares a
|
|
221
|
+
// `type`. Never inferred from the absence of a declaration.
|
|
222
|
+
const fileCol = cfg.fileCollections?.find(c => c.slug === data.collection && c._tenant_ === this.tenant_id)
|
|
223
|
+
const docCol = cfg.collections?.find(c => c.slug === data.collection && c._tenant_ === this.tenant_id)
|
|
224
|
+
const collectionType: ActivityInput['operation']['collectionType'] = fileCol
|
|
225
|
+
? 'file'
|
|
226
|
+
: docCol ? (docCol.type ?? 'document') : undefined
|
|
192
227
|
|
|
193
228
|
const activity: ActivityInput = {
|
|
194
229
|
internal: requestCtxStorage.get<boolean>('internal') ?? this.#internal,
|
|
@@ -202,7 +237,7 @@ class MongoRest {
|
|
|
202
237
|
collectionType,
|
|
203
238
|
status: data.error ? 'error' : 'success',
|
|
204
239
|
input: data.input,
|
|
205
|
-
result: data.result,
|
|
240
|
+
result: summarizeAuditResult(data.action, data.result),
|
|
206
241
|
error: data.error ?? null,
|
|
207
242
|
duration: data.duration,
|
|
208
243
|
transaction: this.session?.inTransaction?.() ?? false,
|
|
@@ -219,13 +254,49 @@ class MongoRest {
|
|
|
219
254
|
collection: string,
|
|
220
255
|
input: any,
|
|
221
256
|
fn: () => Promise<T>,
|
|
222
|
-
options?: {
|
|
257
|
+
options?: {
|
|
258
|
+
invalidate?: boolean | string[]
|
|
259
|
+
/**
|
|
260
|
+
* `false` → the result is **not** stored in the audit entry
|
|
261
|
+
* (`operation.result` stays `null`). Used by reads and custom actions,
|
|
262
|
+
* whose payload is arbitrary, potentially huge or sensitive — the trail
|
|
263
|
+
* records *who did what, with which parameters, when*.
|
|
264
|
+
* Default: `true`.
|
|
265
|
+
*/
|
|
266
|
+
includeResult?: boolean
|
|
267
|
+
/**
|
|
268
|
+
* `false` → on error, log it and rethrow the **original** error as-is.
|
|
269
|
+
* Default: `true` (non-`AppError` errors are wrapped in `INTERNAL_API_ERROR`).
|
|
270
|
+
*/
|
|
271
|
+
wrapErrors?: boolean
|
|
272
|
+
/**
|
|
273
|
+
* `true` (default) → the audit entry is written **before** the operation
|
|
274
|
+
* returns, so a successful mutation can never lose its trace. Reads pass
|
|
275
|
+
* `false` to avoid an extra round-trip.
|
|
276
|
+
*/
|
|
277
|
+
awaitAudit?: boolean
|
|
278
|
+
}
|
|
223
279
|
): Promise<T> {
|
|
224
280
|
const start = Date.now()
|
|
281
|
+
const includeResult = options?.includeResult ?? true
|
|
282
|
+
const wrapErrors = options?.wrapErrors ?? true
|
|
283
|
+
const awaitAudit = options?.awaitAudit ?? true
|
|
284
|
+
const log = async (entry: {
|
|
285
|
+
action: string
|
|
286
|
+
collection: string
|
|
287
|
+
input?: any
|
|
288
|
+
result?: any
|
|
289
|
+
error?: { message: string; code: string }
|
|
290
|
+
duration: number
|
|
291
|
+
}) => {
|
|
292
|
+
const promise = this.#logActivity(entry)
|
|
293
|
+
if (awaitAudit) await promise
|
|
294
|
+
else promise.catch(() => { })
|
|
295
|
+
}
|
|
225
296
|
try {
|
|
226
297
|
const result = await fn()
|
|
227
298
|
const duration = Date.now() - start
|
|
228
|
-
|
|
299
|
+
await log({ action, collection, input, result: includeResult ? result : null, duration })
|
|
229
300
|
// Writes invalidate the collection's query cache (and its $include dependents).
|
|
230
301
|
// An array of ids = targeted invalidation (only queries containing those ids),
|
|
231
302
|
// `true` = clear the whole collection namespace.
|
|
@@ -235,7 +306,8 @@ class MongoRest {
|
|
|
235
306
|
return result
|
|
236
307
|
} catch (err: any) {
|
|
237
308
|
const duration = Date.now() - start
|
|
238
|
-
|
|
309
|
+
await log({ action, collection, input, result: null, error: { message: err?.message, code: err?.code || 'INTERNAL_API_ERROR' }, duration })
|
|
310
|
+
if (!wrapErrors) throw err
|
|
239
311
|
throw err instanceof AppError ? err : new AppError(err?.message || 'Internal server error', { code: 'INTERNAL_API_ERROR', status: 500 })
|
|
240
312
|
}
|
|
241
313
|
}
|
|
@@ -245,6 +317,24 @@ class MongoRest {
|
|
|
245
317
|
return dbInvalidate(collection, this.tenant_id, ids)
|
|
246
318
|
}
|
|
247
319
|
|
|
320
|
+
/**
|
|
321
|
+
* Audit a read operation — `find`, `findOne`, `aggregate`.
|
|
322
|
+
*
|
|
323
|
+
* Reads are logged with their **parameters only** (filters, pipeline, options):
|
|
324
|
+
* `operation.result` is always `null`. What a query returned is huge, redundant
|
|
325
|
+
* with the collection itself, and often a privacy leak — the audit trail answers
|
|
326
|
+
* "who read what, with which filter, when", not "what did they see".
|
|
327
|
+
*/
|
|
328
|
+
async #executeReadWithAudit<T>(
|
|
329
|
+
action: string,
|
|
330
|
+
collection: string,
|
|
331
|
+
input: any,
|
|
332
|
+
fn: () => Promise<T>
|
|
333
|
+
): Promise<T> {
|
|
334
|
+
// Reads stay fire-and-forget: they must not pay for an extra round-trip
|
|
335
|
+
return this.#executeWithAudit(action, collection, input, fn, { includeResult: false, awaitAudit: false })
|
|
336
|
+
}
|
|
337
|
+
|
|
248
338
|
async connect(options?: {
|
|
249
339
|
uri?: string;
|
|
250
340
|
options?: MongoClientOptions;
|
|
@@ -271,60 +361,64 @@ class MongoRest {
|
|
|
271
361
|
|
|
272
362
|
@CheckIfCollectionExists()
|
|
273
363
|
async watch(collection: string, pipeline: any[], options: ChangeStreamOptions): Promise<ChangeStream> {
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
364
|
+
// Audited with the parameters only — the change stream itself is a live object
|
|
365
|
+
return this.#executeWithAudit('watch', collection, { pipeline, options }, async () => {
|
|
366
|
+
const col = getCollection(collection, this.#tenant.id) as Collection
|
|
367
|
+
const isSafe = func.isSafeAggregatePipeline(pipeline);
|
|
368
|
+
if (!isSafe.isSafe) {
|
|
369
|
+
throw isSafe.error;
|
|
370
|
+
}
|
|
371
|
+
pipeline = await func.buildInput(pipeline)
|
|
372
|
+
pipeline = func.toBson(pipeline, { col })
|
|
373
|
+
return await this.db.collection(collection).watch(pipeline, {
|
|
374
|
+
allowDiskUse: true,
|
|
375
|
+
session: this.session,
|
|
376
|
+
...options
|
|
377
|
+
})
|
|
378
|
+
}, { includeResult: false, awaitAudit: false })
|
|
286
379
|
}
|
|
287
380
|
|
|
288
381
|
@LogSlowQuery()
|
|
289
382
|
@CheckIfCollectionExists()
|
|
290
383
|
async aggregate(collection: string, pipeline: any[]): Promise<Document[]> {
|
|
291
384
|
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
|
-
|
|
385
|
+
return this.#executeReadWithAudit(action, collection, { pipeline }, async () => {
|
|
386
|
+
try {
|
|
387
|
+
const col = getCollection(collection, this.#tenant.id) as Collection
|
|
388
|
+
// Flaw 12: restrict $lookup targets to this tenant's declared collections
|
|
389
|
+
const allowedCollections = [
|
|
390
|
+
...(cfg.collections ?? [])
|
|
391
|
+
.filter(c => c._tenant_ === this.tenant_id)
|
|
392
|
+
.map(c => c.slug),
|
|
393
|
+
...(cfg.fileCollections ?? [])
|
|
394
|
+
.filter(fc => fc._tenant_ === this.tenant_id)
|
|
395
|
+
.map(fc => fc.slug),
|
|
396
|
+
AUDIT_COLLECTION, '_locks_', '_workflows_',
|
|
397
|
+
];
|
|
398
|
+
const isSafe = func.isSafeAggregatePipeline(pipeline, allowedCollections);
|
|
399
|
+
if (!isSafe.isSafe) {
|
|
400
|
+
throw isSafe.error;
|
|
401
|
+
}
|
|
402
|
+
pipeline = func.toBson(pipeline, { col })
|
|
403
|
+
const meta: any = { action, collection, pipeline }
|
|
404
|
+
if (col.hooks?.beforeOperation) {
|
|
405
|
+
await col.hooks.beforeOperation(this.#hookCtx(action, meta))
|
|
406
|
+
}
|
|
314
407
|
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
408
|
+
let result = await this.db.collection(collection).aggregate(pipeline, {
|
|
409
|
+
session: this.session,
|
|
410
|
+
allowDiskUse: true,
|
|
411
|
+
}).toArray()
|
|
412
|
+
result = func.toJson(result)
|
|
413
|
+
meta.result = result
|
|
321
414
|
|
|
322
|
-
|
|
415
|
+
await col.hooks?.afterOperation?.(this.#hookCtx(action, meta))
|
|
323
416
|
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
417
|
+
return result as any[]
|
|
418
|
+
} catch (err: any) {
|
|
419
|
+
throw err instanceof AppError ? err : new AppError(err?.message || 'Internal server error', { code: 'INTERNAL_API_ERROR', status: 500 })
|
|
420
|
+
}
|
|
421
|
+
})
|
|
328
422
|
}
|
|
329
423
|
|
|
330
424
|
/**
|
|
@@ -359,6 +453,10 @@ class MongoRest {
|
|
|
359
453
|
*/
|
|
360
454
|
@CheckIfCollectionExists()
|
|
361
455
|
async *findStream<T = any>(collection: string, params: FindOptions = {}, options: CursorOptions = {}): AsyncGenerator<any> {
|
|
456
|
+
// The entry is written when the stream ends (completion, error, or early
|
|
457
|
+
// `break`/`return()` — which triggers the `finally` below). Parameters only.
|
|
458
|
+
const start = Date.now();
|
|
459
|
+
let failure: any = null;
|
|
362
460
|
const pageSize = options.pageSize;
|
|
363
461
|
// Page mode counts by default (opt-out with withCount: false); per-doc mode counts only when withCount: true
|
|
364
462
|
const withCount = pageSize ? options.withCount !== false : options.withCount === true;
|
|
@@ -410,7 +508,18 @@ class MongoRest {
|
|
|
410
508
|
}
|
|
411
509
|
}
|
|
412
510
|
}
|
|
511
|
+
} catch (err: any) {
|
|
512
|
+
failure = err
|
|
513
|
+
throw err
|
|
413
514
|
} finally {
|
|
515
|
+
this.#logActivity({
|
|
516
|
+
action: 'findStream',
|
|
517
|
+
collection,
|
|
518
|
+
input: { params, options },
|
|
519
|
+
result: null,
|
|
520
|
+
error: failure ? { message: failure?.message, code: failure?.code || 'INTERNAL_API_ERROR' } : undefined,
|
|
521
|
+
duration: Date.now() - start,
|
|
522
|
+
})
|
|
414
523
|
await cursor.close().catch(() => {})
|
|
415
524
|
}
|
|
416
525
|
}
|
|
@@ -427,11 +536,15 @@ class MongoRest {
|
|
|
427
536
|
*/
|
|
428
537
|
@CheckIfCollectionExists()
|
|
429
538
|
async *aggregateStream<T = any>(collection: string, pipeline: any[] = [], options: CursorOptions = {}): AsyncGenerator<any> {
|
|
539
|
+
// The entry is written when the stream ends (completion, error, or early
|
|
540
|
+
// `break`/`return()` — which triggers the `finally` below). Parameters only.
|
|
541
|
+
const start = Date.now();
|
|
542
|
+
let failure: any = null;
|
|
430
543
|
const col = getCollection(collection, this.#tenant.id) as Collection
|
|
431
544
|
const allowedCollections = [
|
|
432
545
|
...(cfg.collections ?? []).filter((c) => c._tenant_ === this.tenant_id).map((c) => c.slug),
|
|
433
546
|
...(cfg.fileCollections ?? []).filter((fc) => fc._tenant_ === this.tenant_id).map((fc) => fc.slug),
|
|
434
|
-
|
|
547
|
+
AUDIT_COLLECTION, '_locks_', '_workflows_',
|
|
435
548
|
];
|
|
436
549
|
const isSafe = func.isSafeAggregatePipeline(pipeline, allowedCollections);
|
|
437
550
|
if (!isSafe.isSafe) {
|
|
@@ -466,7 +579,18 @@ class MongoRest {
|
|
|
466
579
|
yield value
|
|
467
580
|
}
|
|
468
581
|
}
|
|
582
|
+
} catch (err: any) {
|
|
583
|
+
failure = err
|
|
584
|
+
throw err
|
|
469
585
|
} finally {
|
|
586
|
+
this.#logActivity({
|
|
587
|
+
action: 'aggregateStream',
|
|
588
|
+
collection,
|
|
589
|
+
input: { pipeline, options },
|
|
590
|
+
result: null,
|
|
591
|
+
error: failure ? { message: failure?.message, code: failure?.code || 'INTERNAL_API_ERROR' } : undefined,
|
|
592
|
+
duration: Date.now() - start,
|
|
593
|
+
})
|
|
470
594
|
await cursor.close().catch(() => {})
|
|
471
595
|
}
|
|
472
596
|
}
|
|
@@ -475,77 +599,81 @@ class MongoRest {
|
|
|
475
599
|
@CheckIfCollectionExists()
|
|
476
600
|
async find(collection: string, params: FindOptions = {}, options: FindCallOptions = {}): Promise<Document[]> {
|
|
477
601
|
const action = 'find' as ActionsApiList
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
const
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
602
|
+
return this.#executeReadWithAudit(action, collection, { params, options }, async () => {
|
|
603
|
+
try {
|
|
604
|
+
// Real query — hooks + pipeline + toArray
|
|
605
|
+
const run = async (): Promise<Document[]> => {
|
|
606
|
+
const col = getCollection(collection, this.#tenant.id) as Collection
|
|
607
|
+
let pipeline = func.buildPipeline(params, { col: col })
|
|
608
|
+
pipeline = await func.buildInput(pipeline, { rest: this })
|
|
609
|
+
pipeline = func.toBson(pipeline, { col })
|
|
610
|
+
|
|
611
|
+
const meta: any = { action, collection, params, options }
|
|
612
|
+
if (col.hooks?.beforeOperation) {
|
|
613
|
+
await col.hooks.beforeOperation(this.#hookCtx(action, meta))
|
|
614
|
+
}
|
|
485
615
|
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
616
|
+
let result = await this.db.collection(collection).aggregate(pipeline, {
|
|
617
|
+
session: this.session,
|
|
618
|
+
allowDiskUse: true
|
|
619
|
+
}).toArray()
|
|
620
|
+
result = func.toJson(result)
|
|
621
|
+
meta.result = result
|
|
490
622
|
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
}).toArray()
|
|
495
|
-
result = func.toJson(result)
|
|
496
|
-
meta.result = result
|
|
623
|
+
if (col.hooks?.afterOperation) {
|
|
624
|
+
await col.hooks.afterOperation(this.#hookCtx(action, meta))
|
|
625
|
+
}
|
|
497
626
|
|
|
498
|
-
|
|
499
|
-
await col.hooks.afterOperation({ rest: this, io, action, meta })
|
|
627
|
+
return result as any[]
|
|
500
628
|
}
|
|
501
629
|
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
630
|
+
// Cache-first when `useCache: true` — served from the query cache, populated on miss
|
|
631
|
+
if (options.useCache === true) {
|
|
632
|
+
return await dbGetOrSet(collection, this.tenant_id, params, options, run)
|
|
633
|
+
}
|
|
634
|
+
return await run()
|
|
635
|
+
} catch (err: any) {
|
|
636
|
+
throw err instanceof AppError ? err : new AppError(err?.message || 'Internal server error', { code: 'INTERNAL_API_ERROR', status: 500 })
|
|
508
637
|
}
|
|
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
|
-
}
|
|
638
|
+
})
|
|
513
639
|
}
|
|
514
640
|
|
|
515
641
|
@LogSlowQuery()
|
|
516
642
|
@CheckIfCollectionExists()
|
|
517
643
|
async findOne(collection: string, _id: string, params?: findOneOptions): Promise<any> {
|
|
518
644
|
const action = 'findOne' as ActionsApiList
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
645
|
+
return this.#executeReadWithAudit(action, collection, { id: _id, params }, async () => {
|
|
646
|
+
try {
|
|
647
|
+
const col = getCollection(collection, this.#tenant.id) as Collection
|
|
648
|
+
let pipeline = func.buildPipeline({
|
|
649
|
+
...params,
|
|
650
|
+
$match: { _id },
|
|
651
|
+
$limit: 1
|
|
652
|
+
}, { col: col })
|
|
653
|
+
pipeline = await func.buildInput(pipeline, { rest: this })
|
|
654
|
+
pipeline = func.toBson(pipeline, { col })
|
|
528
655
|
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
656
|
+
const meta: any = { action, collection, params, id: _id }
|
|
657
|
+
if (col.hooks?.beforeOperation) {
|
|
658
|
+
await col.hooks.beforeOperation(this.#hookCtx(action, meta))
|
|
659
|
+
}
|
|
533
660
|
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
661
|
+
let result = (await this.db.collection(collection).aggregate(pipeline, {
|
|
662
|
+
session: this.session,
|
|
663
|
+
allowDiskUse: true
|
|
664
|
+
}).toArray()).at(0) ?? null
|
|
665
|
+
result = func.toJson(result)
|
|
666
|
+
meta.result = result
|
|
540
667
|
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
668
|
+
if (col.hooks?.afterOperation) {
|
|
669
|
+
await col.hooks.afterOperation(this.#hookCtx(action, meta))
|
|
670
|
+
}
|
|
544
671
|
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
672
|
+
return result
|
|
673
|
+
} catch (err: any) {
|
|
674
|
+
throw err instanceof AppError ? err : new AppError(err?.message || 'Internal server error', { code: 'INTERNAL_API_ERROR', status: 500 })
|
|
675
|
+
}
|
|
676
|
+
})
|
|
549
677
|
}
|
|
550
678
|
|
|
551
679
|
@LogSlowQuery()
|
|
@@ -562,7 +690,7 @@ class MongoRest {
|
|
|
562
690
|
|
|
563
691
|
const meta: any = { action, collection, data }
|
|
564
692
|
if (col.hooks?.beforeOperation) {
|
|
565
|
-
await col.hooks.beforeOperation(
|
|
693
|
+
await col.hooks.beforeOperation(this.#hookCtx(action, meta))
|
|
566
694
|
}
|
|
567
695
|
// Validate AFTER hooks (they may fill required fields or apply Joi defaults)
|
|
568
696
|
meta.data = this._validate({ collection, action: action, data: meta.data })
|
|
@@ -578,7 +706,7 @@ class MongoRest {
|
|
|
578
706
|
const result: T & { _id: string } = { ...func.toJson(meta.data as any), _id: (inserted.insertedId as ObjectId).toHexString() }
|
|
579
707
|
meta.result = result
|
|
580
708
|
if (col?.hooks?.afterOperation) {
|
|
581
|
-
await col.hooks.afterOperation(
|
|
709
|
+
await col.hooks.afterOperation(this.#hookCtx(action, meta))
|
|
582
710
|
}
|
|
583
711
|
|
|
584
712
|
return result
|
|
@@ -605,7 +733,7 @@ class MongoRest {
|
|
|
605
733
|
|
|
606
734
|
const meta: any = { action, collection, data: dataInput }
|
|
607
735
|
if (col.hooks?.beforeOperation) {
|
|
608
|
-
await col.hooks.beforeOperation(
|
|
736
|
+
await col.hooks.beforeOperation(this.#hookCtx(action, meta))
|
|
609
737
|
}
|
|
610
738
|
// Validate AFTER hooks (they may fill required fields or apply Joi defaults)
|
|
611
739
|
meta.data = this._validate({ collection, action: action, data: meta.data })
|
|
@@ -624,7 +752,7 @@ class MongoRest {
|
|
|
624
752
|
meta.result = result
|
|
625
753
|
|
|
626
754
|
if (col.hooks?.afterOperation) {
|
|
627
|
-
await col.hooks.afterOperation(
|
|
755
|
+
await col.hooks.afterOperation(this.#hookCtx(action, meta))
|
|
628
756
|
}
|
|
629
757
|
|
|
630
758
|
return result as (T & { _id: string })[]
|
|
@@ -651,7 +779,7 @@ class MongoRest {
|
|
|
651
779
|
|
|
652
780
|
const meta: any = { action, collection, update, id: _id }
|
|
653
781
|
if (col.hooks?.beforeOperation) {
|
|
654
|
-
await col.hooks.beforeOperation(
|
|
782
|
+
await col.hooks.beforeOperation(this.#hookCtx(action, meta))
|
|
655
783
|
}
|
|
656
784
|
|
|
657
785
|
const data = await this.db.collection(collection).findOneAndUpdate(
|
|
@@ -664,7 +792,7 @@ class MongoRest {
|
|
|
664
792
|
meta.result = result
|
|
665
793
|
|
|
666
794
|
if (col.hooks?.afterOperation) {
|
|
667
|
-
await col.hooks.afterOperation(
|
|
795
|
+
await col.hooks.afterOperation(this.#hookCtx(action, meta))
|
|
668
796
|
}
|
|
669
797
|
|
|
670
798
|
return result
|
|
@@ -718,7 +846,7 @@ class MongoRest {
|
|
|
718
846
|
|
|
719
847
|
const meta: any = { action, collection, filter, update }
|
|
720
848
|
if (col.hooks?.beforeOperation) {
|
|
721
|
-
await col.hooks.beforeOperation(
|
|
849
|
+
await col.hooks.beforeOperation(this.#hookCtx(action, meta))
|
|
722
850
|
}
|
|
723
851
|
|
|
724
852
|
const data = await this.db.collection(collection).findOneAndUpdate(
|
|
@@ -734,7 +862,7 @@ class MongoRest {
|
|
|
734
862
|
meta.result = result
|
|
735
863
|
|
|
736
864
|
if (col.hooks?.afterOperation) {
|
|
737
|
-
await col.hooks.afterOperation(
|
|
865
|
+
await col.hooks.afterOperation(this.#hookCtx(action, meta))
|
|
738
866
|
}
|
|
739
867
|
|
|
740
868
|
return result as Document | null
|
|
@@ -760,7 +888,7 @@ class MongoRest {
|
|
|
760
888
|
|
|
761
889
|
const meta: any = { action, collection, update, ids: _ids }
|
|
762
890
|
if (col.hooks?.beforeOperation) {
|
|
763
|
-
await col.hooks.beforeOperation(
|
|
891
|
+
await col.hooks.beforeOperation(this.#hookCtx(action, meta))
|
|
764
892
|
}
|
|
765
893
|
|
|
766
894
|
const result = await this.db.collection(collection).updateMany(
|
|
@@ -771,7 +899,7 @@ class MongoRest {
|
|
|
771
899
|
meta.result = result
|
|
772
900
|
|
|
773
901
|
if (col.hooks?.afterOperation) {
|
|
774
|
-
await col.hooks.afterOperation(
|
|
902
|
+
await col.hooks.afterOperation(this.#hookCtx(action, meta))
|
|
775
903
|
}
|
|
776
904
|
|
|
777
905
|
return result as UpdateResult
|
|
@@ -788,7 +916,7 @@ class MongoRest {
|
|
|
788
916
|
|
|
789
917
|
const meta: any = { action, collection, id: _id }
|
|
790
918
|
if (col.hooks?.beforeOperation) {
|
|
791
|
-
await col.hooks.beforeOperation(
|
|
919
|
+
await col.hooks.beforeOperation(this.#hookCtx(action, meta))
|
|
792
920
|
}
|
|
793
921
|
|
|
794
922
|
let result = await this.db.collection(collection).findOneAndDelete(
|
|
@@ -799,7 +927,7 @@ class MongoRest {
|
|
|
799
927
|
meta.result = result
|
|
800
928
|
|
|
801
929
|
if (col.hooks?.afterOperation) {
|
|
802
|
-
await col.hooks.afterOperation(
|
|
930
|
+
await col.hooks.afterOperation(this.#hookCtx(action, meta))
|
|
803
931
|
}
|
|
804
932
|
|
|
805
933
|
return result
|
|
@@ -817,7 +945,7 @@ class MongoRest {
|
|
|
817
945
|
|
|
818
946
|
const meta: any = { action, collection, ids: _ids }
|
|
819
947
|
if (col.hooks?.beforeOperation) {
|
|
820
|
-
await col.hooks.beforeOperation(
|
|
948
|
+
await col.hooks.beforeOperation(this.#hookCtx(action, meta))
|
|
821
949
|
}
|
|
822
950
|
|
|
823
951
|
let result: DeleteResult = await this.db.collection(collection).deleteMany(
|
|
@@ -828,7 +956,7 @@ class MongoRest {
|
|
|
828
956
|
meta.result = result
|
|
829
957
|
|
|
830
958
|
if (col.hooks?.afterOperation) {
|
|
831
|
-
await col.hooks.afterOperation(
|
|
959
|
+
await col.hooks.afterOperation(this.#hookCtx(action, meta))
|
|
832
960
|
}
|
|
833
961
|
|
|
834
962
|
return result
|
|
@@ -973,49 +1101,64 @@ class MongoRest {
|
|
|
973
1101
|
}
|
|
974
1102
|
|
|
975
1103
|
async runAction<T = any>(collection: string, action: string, data?: any): Promise<T> {
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
}
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
1104
|
+
// Audited like any other operation: the custom action **name** and its
|
|
1105
|
+
// `data` are recorded, the returned payload never is (`includeResult: false`).
|
|
1106
|
+
// `wrapErrors: false` — a custom action's own error must reach the caller unchanged.
|
|
1107
|
+
return this.#executeWithAudit('runAction', collection, { action, data }, async () => {
|
|
1108
|
+
const col = getCollection(collection, this.#tenant.id)
|
|
1109
|
+
if (!col) {
|
|
1110
|
+
throw new AppError(`Collection '${collection}' not found`, { code: 'COLLECTION_NOT_FOUND', status: 500 })
|
|
1111
|
+
}
|
|
984
1112
|
|
|
985
|
-
|
|
1113
|
+
if (!Object.hasOwn(col?.actions ?? {}, action)) {
|
|
1114
|
+
throw new AppError(`Action '${action}' not found on collection '${collection}'`, { code: 'ACTION_NOT_FOUND', status: 500 })
|
|
1115
|
+
}
|
|
986
1116
|
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
1117
|
+
const token = requestCtxStorage.get<{ value: string | null, decoded: Record<string, unknown> | null, provided: boolean, expired: boolean }>('token')
|
|
1118
|
+
|
|
1119
|
+
return await col.actions?.[action]?.({
|
|
1120
|
+
rest: this,
|
|
1121
|
+
io,
|
|
1122
|
+
agents: createAgents(this.tenant_id, this),
|
|
1123
|
+
api: createApi(this),
|
|
1124
|
+
data,
|
|
1125
|
+
error: fn.error,
|
|
1126
|
+
jwt: func.jwt,
|
|
1127
|
+
token: token ?? { value: null, decoded: null, provided: false, expired: false },
|
|
1128
|
+
} as any)
|
|
1129
|
+
}, { includeResult: false, wrapErrors: false })
|
|
995
1130
|
}
|
|
996
1131
|
|
|
997
1132
|
async runService<T = any>(service: string, action: string, data?: any): Promise<T> {
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1133
|
+
// Audited like `runAction`: the service name (`collection`), the invoked
|
|
1134
|
+
// action and its `data` are recorded — never the returned payload.
|
|
1135
|
+
// `internal` tells whether the call came from server-side code or the route.
|
|
1136
|
+
// `wrapErrors: false` — a service's own error must reach the caller unchanged.
|
|
1137
|
+
return this.#executeWithAudit('runService', service, { action, data }, async () => {
|
|
1138
|
+
const serviceInstance = cfg.services?.find(s => s.name === service && s._tenant_ === this.tenant_id) as Service | undefined
|
|
1139
|
+
if (!serviceInstance) {
|
|
1140
|
+
throw new AppError(`Service '${service}' not found`, { code: 'SERVICE_NOT_FOUND', status: 500 })
|
|
1141
|
+
}
|
|
1142
|
+
if (!serviceInstance.enabled) {
|
|
1143
|
+
throw new AppError(`Service '${service}' is not enabled`, { code: 'SERVICE_NOT_ENABLED', status: 500 })
|
|
1144
|
+
}
|
|
1145
|
+
if (!Object.hasOwn(serviceInstance.actions, action) || !serviceInstance.actions?.[action]) {
|
|
1146
|
+
throw new AppError(`Action '${action}' not found on service '${service}'`, { code: 'SERVICE_ACTION_NOT_FOUND', status: 500 })
|
|
1147
|
+
}
|
|
1010
1148
|
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1149
|
+
const token = requestCtxStorage.get<{ value: string | null, decoded: Record<string, unknown> | null, provided: boolean, expired: boolean }>('token')
|
|
1150
|
+
|
|
1151
|
+
return await serviceInstance.actions?.[action]?.({
|
|
1152
|
+
data,
|
|
1153
|
+
error: fn.error,
|
|
1154
|
+
io,
|
|
1155
|
+
agents: createAgents(this.tenant_id, this),
|
|
1156
|
+
api: createApi(this),
|
|
1157
|
+
jwt: func.jwt,
|
|
1158
|
+
token: token ?? { value: null, decoded: null, provided: false, expired: false },
|
|
1159
|
+
rest: this,
|
|
1160
|
+
} as any)
|
|
1161
|
+
}, { includeResult: false, wrapErrors: false })
|
|
1019
1162
|
}
|
|
1020
1163
|
|
|
1021
1164
|
async stats() {
|
|
@@ -1101,12 +1244,59 @@ class MongoRest {
|
|
|
1101
1244
|
|
|
1102
1245
|
get audit() {
|
|
1103
1246
|
return {
|
|
1247
|
+
/**
|
|
1248
|
+
* Log an audit entry the way the framework does (same envelope: trace,
|
|
1249
|
+
* request, meta, `internal`, redaction, result policy).
|
|
1250
|
+
*
|
|
1251
|
+
* @example
|
|
1252
|
+
* await rest.audit.log({ action: 'export', collection: 'orders', input: { from, to } })
|
|
1253
|
+
*/
|
|
1254
|
+
log: async (entry: {
|
|
1255
|
+
action: string;
|
|
1256
|
+
collection: string;
|
|
1257
|
+
input?: any;
|
|
1258
|
+
result?: any;
|
|
1259
|
+
error?: { message: string; code?: string } | null;
|
|
1260
|
+
duration?: number;
|
|
1261
|
+
}): Promise<void> => {
|
|
1262
|
+
await this.#logActivity({
|
|
1263
|
+
action: entry.action,
|
|
1264
|
+
collection: entry.collection,
|
|
1265
|
+
input: entry.input,
|
|
1266
|
+
result: entry.result,
|
|
1267
|
+
error: entry.error
|
|
1268
|
+
? { message: entry.error.message, code: entry.error.code ?? 'INTERNAL_API_ERROR' }
|
|
1269
|
+
: undefined,
|
|
1270
|
+
duration: entry.duration ?? 0,
|
|
1271
|
+
})
|
|
1272
|
+
},
|
|
1104
1273
|
addActivities: async (activities: ActivityInput[]) => {
|
|
1105
|
-
|
|
1274
|
+
// Single choke point for the audit trail: every payload is redacted
|
|
1275
|
+
// here (`server.audit.redact`), whatever wrote the entry — including a
|
|
1276
|
+
// direct `rest.audit.addActivities()` call. Never store a password,
|
|
1277
|
+
// token or secret in clear, in `_audit_` or in the local file.
|
|
1278
|
+
const entries = activities.map((activity) => ({
|
|
1279
|
+
...activity,
|
|
1280
|
+
// `request` (headers, query string) is attacker-controlled too — it
|
|
1281
|
+
// carries `x-api-key`, `?token=…`, … just like a payload
|
|
1282
|
+
request: redactPayload(activity?.request),
|
|
1283
|
+
meta: redactPayload(activity?.meta),
|
|
1284
|
+
operation: {
|
|
1285
|
+
...activity?.operation,
|
|
1286
|
+
input: redactPayload(activity?.operation?.input),
|
|
1287
|
+
result: redactPayload(activity?.operation?.result),
|
|
1288
|
+
},
|
|
1289
|
+
}))
|
|
1290
|
+
await this.db.collection(AUDIT_COLLECTION).insertMany(entries)
|
|
1106
1291
|
.then(e => e)
|
|
1107
1292
|
.catch(err => {
|
|
1108
1293
|
console.error('Error inserting activities', err)
|
|
1109
1294
|
})
|
|
1295
|
+
// Local append-only backup (opt-in) — fire-and-forget, never fatal.
|
|
1296
|
+
// Runs after the insert on purpose: `insertMany` mutates the documents
|
|
1297
|
+
// with the `_id` generated by MongoDB, so every line of the local file
|
|
1298
|
+
// can be joined back to the `_audit_` collection.
|
|
1299
|
+
writeAuditFile(getTenant(this.tenant_id), entries)
|
|
1110
1300
|
},
|
|
1111
1301
|
getActivities: async (opts = {
|
|
1112
1302
|
$match: {},
|
|
@@ -1114,7 +1304,7 @@ class MongoRest {
|
|
|
1114
1304
|
}) => {
|
|
1115
1305
|
|
|
1116
1306
|
let pipeline = func.buildPipeline(opts)
|
|
1117
|
-
return await this.db.collection(
|
|
1307
|
+
return await this.db.collection(AUDIT_COLLECTION).aggregate(pipeline).toArray()
|
|
1118
1308
|
}
|
|
1119
1309
|
}
|
|
1120
1310
|
}
|
|
@@ -1151,6 +1341,19 @@ class MongoRest {
|
|
|
1151
1341
|
};
|
|
1152
1342
|
}
|
|
1153
1343
|
|
|
1344
|
+
/**
|
|
1345
|
+
* Tenant-scoped key/value store — like Redis, MongoDB-backed (durable, no TTL).
|
|
1346
|
+
* Meant for configuration-like values (`rest.cache` is for ephemeral data).
|
|
1347
|
+
*
|
|
1348
|
+
* @example
|
|
1349
|
+
* await rest.vars.set('config', 'licence', 'RDX00');
|
|
1350
|
+
* const licence = await rest.vars.get('config', 'licence');
|
|
1351
|
+
* await rest.vars.del('config', 'licence');
|
|
1352
|
+
*/
|
|
1353
|
+
get vars(): TenantVars {
|
|
1354
|
+
return createVars(this.tenant_id);
|
|
1355
|
+
}
|
|
1356
|
+
|
|
1154
1357
|
/**
|
|
1155
1358
|
* Acquire a distributed lock for this tenant.
|
|
1156
1359
|
* Uses MongoDB atomic findOneAndUpdate with upsert.
|
|
@@ -1161,39 +1364,71 @@ class MongoRest {
|
|
|
1161
1364
|
const now = Date.now();
|
|
1162
1365
|
const expiresAt = now + ttlMs;
|
|
1163
1366
|
const id = `${this.tenant_id}:${name}`;
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1367
|
+
const hostname = os.hostname();
|
|
1368
|
+
const col = this.db.collection('_locks_');
|
|
1369
|
+
|
|
1370
|
+
// A lock is free when it expired, or when it is held by a process that is
|
|
1371
|
+
// gone **on this host** (Bun `reusePort` / forked processes: a crashed
|
|
1372
|
+
// worker must not block a run for the whole TTL). A remote owner cannot be
|
|
1373
|
+
// probed — its TTL is the only guarantee.
|
|
1374
|
+
const stale = (lock: any): boolean => {
|
|
1375
|
+
if ((lock.expiresAt ?? 0) < now) return true;
|
|
1376
|
+
if (lock.hostname === hostname && typeof lock.pid === 'number') {
|
|
1377
|
+
if (lock.pid === process.pid) return false; // ours: not stale
|
|
1378
|
+
return !isProcessAlive(lock.pid);
|
|
1176
1379
|
}
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1380
|
+
return false;
|
|
1381
|
+
};
|
|
1382
|
+
|
|
1383
|
+
const claim = async (): Promise<boolean> => {
|
|
1384
|
+
const existing: any = await col.findOne({ _id: id as any });
|
|
1385
|
+
|
|
1386
|
+
if (!existing) {
|
|
1387
|
+
try {
|
|
1388
|
+
await col.insertOne({
|
|
1389
|
+
_id: id as any,
|
|
1390
|
+
tid: this.tenant_id,
|
|
1391
|
+
name,
|
|
1392
|
+
acquiredAt: now,
|
|
1393
|
+
expiresAt,
|
|
1394
|
+
pid: process.pid,
|
|
1395
|
+
hostname,
|
|
1396
|
+
} as any);
|
|
1397
|
+
return true;
|
|
1398
|
+
} catch (err: any) {
|
|
1399
|
+
if (err?.code === 11000) return false; // another node won the race
|
|
1400
|
+
throw err;
|
|
1401
|
+
}
|
|
1185
1402
|
}
|
|
1186
|
-
|
|
1403
|
+
|
|
1404
|
+
if (!stale(existing)) return false;
|
|
1405
|
+
|
|
1406
|
+
// Compare-and-set on `expiresAt` (+ owner pid): another node may take it
|
|
1407
|
+
// over in the same instant, only one of us matches
|
|
1408
|
+
const res = await col.updateOne(
|
|
1409
|
+
{ _id: id as any, expiresAt: existing.expiresAt ?? null, pid: existing.pid ?? null },
|
|
1410
|
+
{ $set: { tid: this.tenant_id, name, acquiredAt: now, expiresAt, pid: process.pid, hostname } },
|
|
1411
|
+
);
|
|
1412
|
+
return (res.matchedCount ?? 0) > 0;
|
|
1413
|
+
};
|
|
1414
|
+
|
|
1415
|
+
if (!(await claim())) {
|
|
1416
|
+
throw new AppError(`Lock '${name}' is already held by another node`, {
|
|
1417
|
+
code: 'LOCK_ACQUISITION_FAILED',
|
|
1418
|
+
status: 409,
|
|
1419
|
+
});
|
|
1187
1420
|
}
|
|
1188
1421
|
}
|
|
1189
1422
|
|
|
1190
1423
|
/**
|
|
1191
|
-
* Release a distributed lock
|
|
1424
|
+
* Release a distributed lock — only when **we** own it: another node may have
|
|
1425
|
+
* taken it over after our process was considered gone.
|
|
1192
1426
|
*/
|
|
1193
1427
|
async unlock(name: string): Promise<void> {
|
|
1194
1428
|
await this.db.collection('_locks_').deleteOne({
|
|
1195
1429
|
tid: this.tenant_id,
|
|
1196
1430
|
name,
|
|
1431
|
+
$or: [{ pid: process.pid }, { pid: { $exists: false } }],
|
|
1197
1432
|
});
|
|
1198
1433
|
}
|
|
1199
1434
|
|