@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.
Files changed (130) hide show
  1. package/database/audit.ts +143 -0
  2. package/database/collection.ts +6 -4
  3. package/database/deleteLog.ts +98 -0
  4. package/database/file.ts +7 -6
  5. package/database/mongodbadapter.ts +420 -185
  6. package/database/replication.ts +858 -0
  7. package/database/schema.ts +140 -125
  8. package/database/tenant.ts +4 -0
  9. package/database/ttl.ts +68 -0
  10. package/database/vars.ts +405 -0
  11. package/database/workflow.ts +466 -240
  12. package/index.ts +16 -0
  13. package/lib/agent.ts +1327 -0
  14. package/lib/agentMemory.ts +586 -0
  15. package/lib/agents.ts +157 -0
  16. package/lib/ai.ts +0 -0
  17. package/lib/api.ts +246 -0
  18. package/lib/attachment.ts +351 -0
  19. package/lib/audit.ts +271 -0
  20. package/lib/auditResult.ts +133 -0
  21. package/lib/define.ts +62 -1
  22. package/lib/endpoints.ts +80 -0
  23. package/lib/files.ts +66 -12
  24. package/lib/jsonSchema.ts +183 -0
  25. package/lib/lifecycle.ts +147 -0
  26. package/lib/load.ts +17 -0
  27. package/lib/mcp.ts +3 -2
  28. package/lib/messages.ts +299 -0
  29. package/lib/providers.ts +867 -0
  30. package/lib/redact.ts +117 -0
  31. package/lib/routes.ts +14 -1
  32. package/lib/scriptWorker.ts +4 -1
  33. package/lib/scripts.ts +6 -1
  34. package/lib/sockets.ts +3 -1
  35. package/lib/workflow.ts +209 -37
  36. package/lib/zip.ts +109 -0
  37. package/package.json +1 -1
  38. package/server/access.ts +96 -0
  39. package/server/agents.ts +451 -0
  40. package/server/api.ts +121 -95
  41. package/server/boot.ts +196 -53
  42. package/server/flags.ts +136 -0
  43. package/server/hono.ts +9 -4
  44. package/server/mcp.ts +17 -63
  45. package/server/routes.ts +19 -24
  46. package/tests/agent-memory.test.ts +617 -0
  47. package/tests/agent-working-memory.test.ts +402 -0
  48. package/tests/agent.test.ts +1239 -0
  49. package/tests/agents-api.test.ts +593 -0
  50. package/tests/api.test.ts +51 -0
  51. package/tests/attachment.test.ts +241 -0
  52. package/tests/audit-file.test.ts +296 -0
  53. package/tests/audit-operations.test.ts +184 -0
  54. package/tests/audit-redact.test.ts +259 -0
  55. package/tests/audit-results.test.ts +188 -0
  56. package/tests/audit-retention-config.test.ts +76 -0
  57. package/tests/audit-run-action.test.ts +120 -0
  58. package/tests/audit.test.ts +320 -0
  59. package/tests/db-cache.test.ts +7 -7
  60. package/tests/endpoints.test.ts +80 -0
  61. package/tests/file-collection-replication.test.ts +98 -0
  62. package/tests/fixtures/agent-tenant/agents/support.agent.ts +9 -0
  63. package/tests/fixtures/agent-tenant/agents/weather.agent.ts +21 -0
  64. package/tests/fixtures/agents-api/agents/assistant.agent.ts +46 -0
  65. package/tests/fixtures/agents-api/agents/plain.agent.ts +23 -0
  66. package/tests/fixtures/agents-api/agents/private.agent.ts +15 -0
  67. package/tests/fixtures/agents-api/collections/notes.model.ts +26 -0
  68. package/tests/fixtures/agents-api/routes/probe.route.ts +21 -0
  69. package/tests/fixtures/agents-api/services/probe.service.ts +19 -0
  70. package/tests/fixtures/attachment/data.csv +2 -0
  71. package/tests/fixtures/fake-provider.ts +128 -0
  72. package/tests/fixtures/lc-disabled/lifecycle.ts +11 -0
  73. package/tests/fixtures/lc-hang/lifecycle.ts +13 -0
  74. package/tests/fixtures/lc-normal/lifecycle.ts +23 -0
  75. package/tests/fixtures/lc-throw/lifecycle.ts +6 -0
  76. package/tests/fixtures/repl-only/app.ts +26 -0
  77. package/tests/fixtures/repl-only/tenant/collections/orders.model.ts +14 -0
  78. package/tests/fixtures/repl-only/tenant/lifecycle.ts +14 -0
  79. package/tests/fixtures/repl-only/tenant/scripts/probe.run.ts +8 -0
  80. package/tests/fixtures/replication-meta/agents/chat.agent.ts +16 -0
  81. package/tests/fixtures/replication-meta/collections/counters.model.ts +13 -0
  82. package/tests/fixtures/replication-meta/collections/orders.model.ts +9 -0
  83. package/tests/fixtures/replication-meta/workflows/run.workflow.ts +8 -0
  84. package/tests/fixtures/vars-tenant/vars/config.var.ts +23 -0
  85. package/tests/fixtures/vars-tenant/vars/private.var.ts +9 -0
  86. package/tests/fixtures/workflow-engine/workflows/failing.workflow.ts +34 -0
  87. package/tests/fixtures/workflow-engine/workflows/flaky.workflow.ts +23 -0
  88. package/tests/fixtures/workflow-engine/workflows/hang.workflow.ts +17 -0
  89. package/tests/fixtures/workflow-engine/workflows/simple.workflow.ts +19 -0
  90. package/tests/fixtures/workflow-engine/workflows/slow.workflow.ts +23 -0
  91. package/tests/fixtures/workflow-engine/workflows/versioned.workflow.ts +19 -0
  92. package/tests/fixtures/workflow-tenant/workflows/broken.workflow.ts +3 -0
  93. package/tests/fixtures/workflow-tenant/workflows/disabled.workflow.ts +8 -0
  94. package/tests/fixtures/workflow-tenant/workflows/invoice.workflow.ts +21 -0
  95. package/tests/fixtures/workflow-tenant/workflows/no-id.workflow.ts +7 -0
  96. package/tests/fixtures/workflow-tenant/workflows/order-duplicate.workflow.ts +13 -0
  97. package/tests/fixtures/workflow-tenant/workflows/order.workflow.ts +17 -0
  98. package/tests/hono.test.ts +6 -3
  99. package/tests/lifecycle.test.ts +96 -0
  100. package/tests/locks.test.ts +120 -0
  101. package/tests/mcp.test.ts +11 -11
  102. package/tests/messages.test.ts +258 -0
  103. package/tests/replication-control.test.ts +128 -0
  104. package/tests/replication-initial-sync.test.ts +86 -0
  105. package/tests/replication-meta.test.ts +243 -0
  106. package/tests/replication-only.test.ts +358 -0
  107. package/tests/replication.test.ts +188 -0
  108. package/tests/upload.test.ts +141 -8
  109. package/tests/vars-api.test.ts +123 -0
  110. package/tests/vars-replication.test.ts +96 -0
  111. package/tests/vars.test.ts +216 -0
  112. package/tests/workflow-engine.test.ts +340 -0
  113. package/tests/workflow-retention.test.ts +103 -0
  114. package/tests/workflow.test.ts +186 -0
  115. package/types/agent.d.ts +837 -0
  116. package/types/collection.d.ts +3 -0
  117. package/types/config.d.ts +80 -0
  118. package/types/file.d.ts +11 -8
  119. package/types/hook.d.ts +6 -0
  120. package/types/lifecycle.d.ts +77 -0
  121. package/types/mcp.d.ts +11 -1
  122. package/types/replication.d.ts +228 -0
  123. package/types/route.d.ts +7 -1
  124. package/types/scripts.d.ts +6 -0
  125. package/types/service.d.ts +7 -1
  126. package/types/tenant.d.ts +36 -0
  127. package/types/vars.d.ts +147 -0
  128. package/types/websocket.d.ts +6 -0
  129. package/types/workflow.d.ts +50 -3
  130. 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
- // Faille 9: updateOne/updateMany — unflatten dot-notation ("address.zip") before Joi validation
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
- 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
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?: { invalidate?: boolean | string[] }
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
- this.#logActivity({ action, collection, input, result, duration })
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
- this.#logActivity({ action, collection, input, error: { message: err?.message, code: err?.code || 'INTERNAL_API_ERROR' }, duration })
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
- 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
- })
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
- 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
- }
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
- 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
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
- await col.hooks?.afterOperation?.({ rest: this, io, action, meta })
415
+ await col.hooks?.afterOperation?.(this.#hookCtx(action, meta))
323
416
 
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
- }
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
- '_activities_', '_locks_', '_workflows_',
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
- try {
479
- // Real query — hooks + pipeline + toArray
480
- const run = async (): Promise<Document[]> => {
481
- const col = getCollection(collection, this.#tenant.id) as Collection
482
- let pipeline = func.buildPipeline(params, { col: col })
483
- pipeline = await func.buildInput(pipeline, { rest: this })
484
- pipeline = func.toBson(pipeline, { col })
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
- const meta: any = { action, collection, params, options }
487
- if (col.hooks?.beforeOperation) {
488
- await col.hooks.beforeOperation({ rest: this, io, action, meta })
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
- let result = await this.db.collection(collection).aggregate(pipeline, {
492
- session: this.session,
493
- allowDiskUse: true
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
- if (col.hooks?.afterOperation) {
499
- await col.hooks.afterOperation({ rest: this, io, action, meta })
627
+ return result as any[]
500
628
  }
501
629
 
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)
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
- 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
- }
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
- 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 })
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
- const meta: any = { action, collection, params, id: _id }
530
- if (col.hooks?.beforeOperation) {
531
- await col.hooks.beforeOperation({ rest: this, io, action, meta })
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
- 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
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
- if (col.hooks?.afterOperation) {
542
- await col.hooks.afterOperation({ rest: this, io, action, meta })
543
- }
668
+ if (col.hooks?.afterOperation) {
669
+ await col.hooks.afterOperation(this.#hookCtx(action, meta))
670
+ }
544
671
 
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
- }
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({ rest: this, io, action, meta })
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({ rest: this, io, action, meta })
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({ rest: this, io, action, meta })
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({ rest: this, io, action, meta })
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({ rest: this, io, action, meta })
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({ rest: this, io, action, meta })
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({ rest: this, io, action, meta })
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({ rest: this, io, action, meta })
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({ rest: this, io, action, meta })
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({ rest: this, io, action, meta })
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({ rest: this, io, action, meta })
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({ rest: this, io, action, meta })
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({ rest: this, io, action, meta })
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({ rest: this, io, action, meta })
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
- 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
- }
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
- const token = requestCtxStorage.get<{ value: string | null, decoded: Record<string, unknown> | null, provided: boolean, expired: boolean }>('token')
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
- 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)
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
- 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')
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
- 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)
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
- await this.db.collection('_activities_').insertMany(activities)
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('_activities_').aggregate(pipeline).toArray()
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
- try {
1166
- const result = await this.db.collection('_locks_').findOneAndUpdate(
1167
- { _id: id as any, expiresAt: { $lt: now } },
1168
- { $set: { tid: this.tenant_id, name, acquiredAt: now, expiresAt } },
1169
- { upsert: true, returnDocument: 'after' }
1170
- );
1171
- if (!result) {
1172
- throw new AppError(`Lock '${name}' is already held by another node`, {
1173
- code: 'LOCK_ACQUISITION_FAILED',
1174
- status: 409
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
- } catch (err: any) {
1178
- if (err instanceof AppError) throw err;
1179
- // E11000 duplicate key = another node holds a valid lock
1180
- if (err?.code === 11000) {
1181
- throw new AppError(`Lock '${name}' is already held by another node`, {
1182
- code: 'LOCK_ACQUISITION_FAILED',
1183
- status: 409
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
- throw err;
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