@alfe.ai/openclaw-database 0.0.42 → 0.0.44

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/README.md CHANGED
@@ -10,3 +10,19 @@ OpenClaw plugin that gives an agent direct MongoDB access to its org's Atlas
10
10
  cluster via `db_*` MCP tools (find / insert / update / delete / aggregate /
11
11
  index / schema). Credentials are fetched from the Alfe database service at
12
12
  activation time.
13
+
14
+ The retained implementation is defense-in-depth for existing installations:
15
+ it validates the credential response and database allowlist, blocks protected
16
+ collections and unsafe executable/cross-collection aggregation operators,
17
+ bounds query/input/output sizes, and requires explicit intent for destructive
18
+ collection-wide or drop operations. It remains `onStartup: false` and is not
19
+ present in any integration manifest.
20
+
21
+ ## Development
22
+
23
+ ```bash
24
+ pnpm --filter @alfe.ai/openclaw-database lint
25
+ pnpm --filter @alfe.ai/openclaw-database typecheck
26
+ pnpm --filter @alfe.ai/openclaw-database test
27
+ pnpm --filter @alfe.ai/openclaw-database build
28
+ ```
package/dist/plugin2.cjs CHANGED
@@ -4,18 +4,132 @@ let _alfe_ai_agent_api_client = require("@alfe.ai/agent-api-client");
4
4
  let node_module = require("node:module");
5
5
  //#region src/tools.ts
6
6
  const DEFAULT_DB = "org_default";
7
- function str(required = true) {
7
+ const MAX_SKIP = 1e6;
8
+ const MAX_RESULT_BYTES = 5 * 1024 * 1024;
9
+ const MAX_DATABASE_NAME_BYTES = 63;
10
+ const MAX_COLLECTION_NAME_BYTES = 255;
11
+ const MAX_QUERY_TIME_MS = 1e4;
12
+ const BLOCKED_FILTER_OPERATORS = new Set([
13
+ "$where",
14
+ "$function",
15
+ "$accumulator"
16
+ ]);
17
+ const BLOCKED_AGGREGATION_STAGES = new Set([
18
+ "$out",
19
+ "$merge",
20
+ "$lookup",
21
+ "$graphLookup",
22
+ "$unionWith",
23
+ "$collStats",
24
+ "$indexStats",
25
+ "$currentOp",
26
+ "$listSessions",
27
+ "$listLocalSessions",
28
+ "$planCacheStats",
29
+ "$changeStream",
30
+ "$changeStreamSplitLargeEvent"
31
+ ]);
32
+ const BLOCKED_PIPELINE_KEYS = new Set([...BLOCKED_FILTER_OPERATORS, ...BLOCKED_AGGREGATION_STAGES]);
33
+ function str() {
8
34
  return {
9
35
  type: "string",
10
- ...required ? {} : { default: "" }
36
+ minLength: 1,
37
+ maxLength: MAX_COLLECTION_NAME_BYTES
11
38
  };
12
39
  }
13
40
  function optStr() {
14
41
  return {
15
42
  type: "string",
43
+ maxLength: MAX_DATABASE_NAME_BYTES,
16
44
  default: ""
17
45
  };
18
46
  }
47
+ function isRecord$1(value) {
48
+ return Boolean(value) && typeof value === "object" && !Array.isArray(value);
49
+ }
50
+ function requireRecord(value, label) {
51
+ if (!isRecord$1(value)) throw new Error(`${label} must be an object`);
52
+ return value;
53
+ }
54
+ function requireRecordArray(value, label, maxItems, minItems = 1) {
55
+ if (!Array.isArray(value) || value.length < minItems || value.length > maxItems || !value.every(isRecord$1)) throw new Error(`${label} must contain between ${String(minItems)} and ${String(maxItems)} objects`);
56
+ return value;
57
+ }
58
+ function checkKeys(value, blocked, context) {
59
+ if (!value || typeof value !== "object") return;
60
+ if (Array.isArray(value)) {
61
+ for (const item of value) checkKeys(item, blocked, context);
62
+ return;
63
+ }
64
+ for (const [key, child] of Object.entries(value)) {
65
+ if (blocked.has(key)) throw new Error(`Operator "${key}" is not allowed in ${context}`);
66
+ checkKeys(child, blocked, context);
67
+ }
68
+ }
69
+ function normalizeDatabaseName(value) {
70
+ if (value === void 0 || value === null || value === "") return DEFAULT_DB;
71
+ if (typeof value !== "string" || value.trim().length === 0) throw new Error("database must be a non-empty string");
72
+ if (Buffer.byteLength(value, "utf8") > MAX_DATABASE_NAME_BYTES || /[\0 /\\."$*<>:|?]/.test(value)) throw new Error("database name is invalid");
73
+ return value;
74
+ }
75
+ function validateCollectionName(value) {
76
+ if (typeof value !== "string" || value.trim().length === 0) throw new Error("collection must be a non-empty string");
77
+ if (Buffer.byteLength(value, "utf8") > MAX_COLLECTION_NAME_BYTES || value.includes("\0")) throw new Error("collection name is invalid");
78
+ if (value === "_audit" || value.startsWith("system.")) throw new Error(`Collection "${value}" is reserved for service internals`);
79
+ return value;
80
+ }
81
+ function validateIndexName(value) {
82
+ if (typeof value !== "string" || value.trim().length === 0 || Buffer.byteLength(value, "utf8") > MAX_COLLECTION_NAME_BYTES || value.includes("\0")) throw new Error("indexName is invalid");
83
+ return value;
84
+ }
85
+ function sanitizeFilter(value) {
86
+ const filter = requireRecord(value, "filter");
87
+ checkKeys(filter, BLOCKED_FILTER_OPERATORS, "filter");
88
+ return filter;
89
+ }
90
+ function sanitizeSort(value) {
91
+ const sort = requireRecord(value, "sort");
92
+ const result = {};
93
+ for (const [key, direction] of Object.entries(sort)) {
94
+ if (direction !== 1 && direction !== -1) throw new Error(`Invalid sort value for "${key}": must be 1 or -1`);
95
+ result[key] = direction;
96
+ }
97
+ return result;
98
+ }
99
+ function sanitizePipeline(value) {
100
+ const pipeline = requireRecordArray(value, "pipeline", 50, 0);
101
+ for (const stage of pipeline) for (const [key, child] of Object.entries(stage)) {
102
+ if (BLOCKED_AGGREGATION_STAGES.has(key)) throw new Error(`Aggregation stage "${key}" is not allowed`);
103
+ checkKeys(child, BLOCKED_PIPELINE_KEYS, `pipeline stage "${key}"`);
104
+ }
105
+ return pipeline;
106
+ }
107
+ function boundedInteger(value, fallback, minimum, maximum, label) {
108
+ const normalized = value === void 0 ? fallback : value;
109
+ if (typeof normalized !== "number" || !Number.isInteger(normalized) || normalized < minimum || normalized > maximum) throw new Error(`${label} must be an integer between ${String(minimum)} and ${String(maximum)}`);
110
+ return normalized;
111
+ }
112
+ function serializedBytes(value) {
113
+ const json = JSON.stringify(value, (_key, item) => typeof item === "bigint" ? item.toString() : item);
114
+ return Buffer.byteLength(json, "utf8");
115
+ }
116
+ function assertWithinByteBudget(value, label) {
117
+ if (serializedBytes(value) > 5242880) throw new Error(`${label} exceeds the ${String(MAX_RESULT_BYTES)}-byte limit`);
118
+ }
119
+ async function collectBounded(cursor, maxDocuments = 200) {
120
+ const results = [];
121
+ let totalBytes = 0;
122
+ for await (const document of cursor) {
123
+ if (results.length >= maxDocuments) throw new Error(`Query returned more than ${String(maxDocuments)} documents`);
124
+ totalBytes += serializedBytes(document);
125
+ if (totalBytes > 5242880) throw new Error(`Query result exceeds the ${String(MAX_RESULT_BYTES)}-byte limit`);
126
+ results.push(document);
127
+ }
128
+ return results;
129
+ }
130
+ function requireDestructiveIntent(filter, allowAll) {
131
+ if (Object.keys(filter).length === 0 && allowAll !== true) throw new Error("An empty filter requires allowAll: true");
132
+ }
19
133
  function registerTools(api, _client, _databases, audit, lazyInit) {
20
134
  async function db(name) {
21
135
  const { mongoClient, databases } = lazyInit ? await lazyInit() : {
@@ -24,10 +138,14 @@ function registerTools(api, _client, _databases, audit, lazyInit) {
24
138
  })(),
25
139
  databases: _databases
26
140
  };
27
- const dbName = name ?? DEFAULT_DB;
141
+ const dbName = normalizeDatabaseName(name);
28
142
  if (!databases.includes(dbName)) throw new Error(`Access denied: database "${dbName}" is not in the allowed list`);
29
143
  return mongoClient.db(dbName);
30
144
  }
145
+ async function collection(database, name) {
146
+ const collectionName = validateCollectionName(name);
147
+ return (await db(database)).collection(collectionName);
148
+ }
31
149
  api.registerTool({
32
150
  name: "db_find",
33
151
  description: "Find documents matching a filter",
@@ -46,11 +164,15 @@ function registerTools(api, _client, _databases, audit, lazyInit) {
46
164
  default: {}
47
165
  },
48
166
  limit: {
49
- type: "number",
167
+ type: "integer",
168
+ minimum: 1,
169
+ maximum: 200,
50
170
  default: 20
51
171
  },
52
172
  skip: {
53
- type: "number",
173
+ type: "integer",
174
+ minimum: 0,
175
+ maximum: MAX_SKIP,
54
176
  default: 0
55
177
  },
56
178
  projection: {
@@ -61,7 +183,9 @@ function registerTools(api, _client, _databases, audit, lazyInit) {
61
183
  required: ["collection"]
62
184
  },
63
185
  execute: async (_id, p) => {
64
- const docs = await (await db(p.database)).collection(p.collection).find(p.filter).sort(p.sort).project(p.projection).skip(p.skip || 0).limit(p.limit || 20).toArray();
186
+ const limit = boundedInteger(p.limit, 20, 1, 200, "limit");
187
+ const skip = boundedInteger(p.skip, 0, 0, MAX_SKIP, "skip");
188
+ const docs = await collectBounded((await collection(p.database, p.collection)).find(sanitizeFilter(p.filter ?? {}), { maxTimeMS: MAX_QUERY_TIME_MS }).sort(sanitizeSort(p.sort ?? {})).project(requireRecord(p.projection ?? {}, "projection")).skip(skip).limit(limit), limit);
65
189
  return {
66
190
  documents: docs,
67
191
  count: docs.length
@@ -89,7 +213,12 @@ function registerTools(api, _client, _databases, audit, lazyInit) {
89
213
  required: ["collection"]
90
214
  },
91
215
  execute: async (_id, p) => {
92
- return await (await db(p.database)).collection(p.collection).findOne(p.filter, { projection: p.projection }) ?? { _not_found: true };
216
+ const doc = await (await collection(p.database, p.collection)).findOne(sanitizeFilter(p.filter ?? {}), {
217
+ projection: requireRecord(p.projection ?? {}, "projection"),
218
+ maxTimeMS: MAX_QUERY_TIME_MS
219
+ });
220
+ assertWithinByteBudget(doc, "Query result");
221
+ return doc ?? { _not_found: true };
93
222
  }
94
223
  });
95
224
  api.registerTool({
@@ -103,17 +232,20 @@ function registerTools(api, _client, _databases, audit, lazyInit) {
103
232
  collection: str(),
104
233
  documents: {
105
234
  type: "array",
235
+ minItems: 1,
236
+ maxItems: 100,
106
237
  items: { type: "object" }
107
238
  }
108
239
  },
109
240
  required: ["collection", "documents"]
110
241
  },
111
242
  execute: async (_id, p) => {
112
- const docs = p.documents;
113
- const result = await (await db(p.database)).collection(p.collection).insertMany(docs);
243
+ const docs = requireRecordArray(p.documents, "documents", 100);
244
+ assertWithinByteBudget(docs, "Insert batch");
245
+ const result = await (await collection(p.database, p.collection)).insertMany(docs);
114
246
  audit({
115
- database: p.database || DEFAULT_DB,
116
- collection: p.collection,
247
+ database: normalizeDatabaseName(p.database),
248
+ collection: validateCollectionName(p.collection),
117
249
  operation: "insert",
118
250
  summary: `Inserted ${String(docs.length)} document(s)`
119
251
  });
@@ -137,6 +269,11 @@ function registerTools(api, _client, _databases, audit, lazyInit) {
137
269
  upsert: {
138
270
  type: "boolean",
139
271
  default: false
272
+ },
273
+ allowAll: {
274
+ type: "boolean",
275
+ default: false,
276
+ description: "Must be true when using an empty filter intentionally"
140
277
  }
141
278
  },
142
279
  required: [
@@ -146,10 +283,15 @@ function registerTools(api, _client, _databases, audit, lazyInit) {
146
283
  ]
147
284
  },
148
285
  execute: async (_id, p) => {
149
- const result = await (await db(p.database)).collection(p.collection).updateMany(p.filter, p.update, { upsert: p.upsert || false });
286
+ const filter = sanitizeFilter(p.filter);
287
+ requireDestructiveIntent(filter, p.allowAll);
288
+ const update = requireRecord(p.update, "update");
289
+ if (Object.keys(update).length === 0) throw new Error("update must not be empty");
290
+ checkKeys(update, BLOCKED_FILTER_OPERATORS, "update");
291
+ const result = await (await collection(p.database, p.collection)).updateMany(filter, update, { upsert: p.upsert === true });
150
292
  audit({
151
- database: p.database || DEFAULT_DB,
152
- collection: p.collection,
293
+ database: normalizeDatabaseName(p.database),
294
+ collection: validateCollectionName(p.collection),
153
295
  operation: "update",
154
296
  summary: `Modified ${String(result.modifiedCount)}`
155
297
  });
@@ -169,15 +311,22 @@ function registerTools(api, _client, _databases, audit, lazyInit) {
169
311
  properties: {
170
312
  database: optStr(),
171
313
  collection: str(),
172
- filter: { type: "object" }
314
+ filter: { type: "object" },
315
+ allowAll: {
316
+ type: "boolean",
317
+ default: false,
318
+ description: "Must be true when using an empty filter intentionally"
319
+ }
173
320
  },
174
321
  required: ["collection", "filter"]
175
322
  },
176
323
  execute: async (_id, p) => {
177
- const result = await (await db(p.database)).collection(p.collection).deleteMany(p.filter);
324
+ const filter = sanitizeFilter(p.filter);
325
+ requireDestructiveIntent(filter, p.allowAll);
326
+ const result = await (await collection(p.database, p.collection)).deleteMany(filter);
178
327
  audit({
179
- database: p.database || DEFAULT_DB,
180
- collection: p.collection,
328
+ database: normalizeDatabaseName(p.database),
329
+ collection: validateCollectionName(p.collection),
181
330
  operation: "delete",
182
331
  summary: `Deleted ${String(result.deletedCount)}`
183
332
  });
@@ -201,7 +350,7 @@ function registerTools(api, _client, _databases, audit, lazyInit) {
201
350
  required: ["collection"]
202
351
  },
203
352
  execute: async (_id, p) => {
204
- return { count: await (await db(p.database)).collection(p.collection).countDocuments(p.filter) };
353
+ return { count: await (await collection(p.database, p.collection)).countDocuments(sanitizeFilter(p.filter ?? {}), { maxTimeMS: MAX_QUERY_TIME_MS }) };
205
354
  }
206
355
  });
207
356
  api.registerTool({
@@ -215,13 +364,16 @@ function registerTools(api, _client, _databases, audit, lazyInit) {
215
364
  collection: str(),
216
365
  pipeline: {
217
366
  type: "array",
367
+ minItems: 0,
368
+ maxItems: 50,
218
369
  items: { type: "object" }
219
370
  }
220
371
  },
221
372
  required: ["collection", "pipeline"]
222
373
  },
223
374
  execute: async (_id, p) => {
224
- const results = await (await db(p.database)).collection(p.collection).aggregate(p.pipeline).toArray();
375
+ const pipeline = sanitizePipeline(p.pipeline);
376
+ const results = await collectBounded((await collection(p.database, p.collection)).aggregate([...pipeline, { $limit: 201 }], { maxTimeMS: MAX_QUERY_TIME_MS }), 200);
225
377
  return {
226
378
  results,
227
379
  count: results.length
@@ -250,9 +402,9 @@ function registerTools(api, _client, _databases, audit, lazyInit) {
250
402
  properties: { database: optStr() }
251
403
  },
252
404
  execute: async (_id, p) => {
253
- return { collections: (await (await db(p.database)).listCollections().toArray()).map((c) => ({
254
- name: c.name,
255
- type: c.type
405
+ return { collections: (await (await db(p.database)).listCollections({}, { nameOnly: true }).toArray()).filter((item) => item.name !== "_audit" && !item.name.startsWith("system.")).map((item) => ({
406
+ name: item.name,
407
+ type: item.type
256
408
  })) };
257
409
  }
258
410
  });
@@ -269,10 +421,11 @@ function registerTools(api, _client, _databases, audit, lazyInit) {
269
421
  required: ["collection"]
270
422
  },
271
423
  execute: async (_id, p) => {
272
- await (await db(p.database)).createCollection(p.collection);
424
+ const collectionName = validateCollectionName(p.collection);
425
+ await (await db(p.database)).createCollection(collectionName);
273
426
  audit({
274
- database: p.database || DEFAULT_DB,
275
- collection: p.collection,
427
+ database: normalizeDatabaseName(p.database),
428
+ collection: collectionName,
276
429
  operation: "create_collection"
277
430
  });
278
431
  return { success: true };
@@ -286,15 +439,22 @@ function registerTools(api, _client, _databases, audit, lazyInit) {
286
439
  type: "object",
287
440
  properties: {
288
441
  database: optStr(),
289
- collection: str()
442
+ collection: str(),
443
+ confirm: {
444
+ type: "boolean",
445
+ const: true,
446
+ description: "Must be true to confirm permanent collection deletion"
447
+ }
290
448
  },
291
- required: ["collection"]
449
+ required: ["collection", "confirm"]
292
450
  },
293
451
  execute: async (_id, p) => {
294
- await (await db(p.database)).dropCollection(p.collection);
452
+ if (p.confirm !== true) throw new Error("Dropping a collection requires confirm: true");
453
+ const collectionName = validateCollectionName(p.collection);
454
+ await (await db(p.database)).dropCollection(collectionName);
295
455
  audit({
296
- database: p.database || DEFAULT_DB,
297
- collection: p.collection,
456
+ database: normalizeDatabaseName(p.database),
457
+ collection: collectionName,
298
458
  operation: "drop_collection"
299
459
  });
300
460
  return { success: true };
@@ -313,7 +473,7 @@ function registerTools(api, _client, _databases, audit, lazyInit) {
313
473
  required: ["collection"]
314
474
  },
315
475
  execute: async (_id, p) => {
316
- const sample = await (await db(p.database)).collection(p.collection).find().limit(100).toArray();
476
+ const sample = await collectBounded((await collection(p.database, p.collection)).find({}, { maxTimeMS: MAX_QUERY_TIME_MS }).limit(100), 100);
317
477
  const fieldTypes = /* @__PURE__ */ new Map();
318
478
  for (const doc of sample) for (const [key, value] of Object.entries(doc)) {
319
479
  const types = fieldTypes.get(key) ?? /* @__PURE__ */ new Set();
@@ -342,8 +502,8 @@ function registerTools(api, _client, _databases, audit, lazyInit) {
342
502
  required: ["collection"]
343
503
  },
344
504
  execute: async (_id, p) => {
345
- const coll = (await db(p.database)).collection(p.collection);
346
- const count = await coll.countDocuments();
505
+ const coll = await collection(p.database, p.collection);
506
+ const count = await coll.countDocuments({}, { maxTimeMS: MAX_QUERY_TIME_MS });
347
507
  const indexes = await coll.indexes();
348
508
  return {
349
509
  count,
@@ -373,10 +533,10 @@ function registerTools(api, _client, _databases, audit, lazyInit) {
373
533
  required: ["collection", "keys"]
374
534
  },
375
535
  execute: async (_id, p) => {
376
- const indexName = await (await db(p.database)).collection(p.collection).createIndex(p.keys, p.options);
536
+ const indexName = await (await collection(p.database, p.collection)).createIndex(sanitizeSort(p.keys), requireRecord(p.options ?? {}, "options"));
377
537
  audit({
378
- database: p.database || DEFAULT_DB,
379
- collection: p.collection,
538
+ database: normalizeDatabaseName(p.database),
539
+ collection: validateCollectionName(p.collection),
380
540
  operation: "create_index",
381
541
  summary: indexName
382
542
  });
@@ -396,7 +556,7 @@ function registerTools(api, _client, _databases, audit, lazyInit) {
396
556
  required: ["collection"]
397
557
  },
398
558
  execute: async (_id, p) => {
399
- return { indexes: (await (await db(p.database)).collection(p.collection).indexes()).map((i) => ({
559
+ return { indexes: (await (await collection(p.database, p.collection)).indexes()).map((i) => ({
400
560
  name: i.name,
401
561
  key: i.key,
402
562
  unique: i.unique
@@ -412,56 +572,107 @@ function registerTools(api, _client, _databases, audit, lazyInit) {
412
572
  properties: {
413
573
  database: optStr(),
414
574
  collection: str(),
415
- indexName: str()
575
+ indexName: str(),
576
+ confirm: {
577
+ type: "boolean",
578
+ const: true,
579
+ description: "Must be true to confirm permanent index deletion"
580
+ }
416
581
  },
417
- required: ["collection", "indexName"]
582
+ required: [
583
+ "collection",
584
+ "indexName",
585
+ "confirm"
586
+ ]
418
587
  },
419
588
  execute: async (_id, p) => {
420
- await (await db(p.database)).collection(p.collection).dropIndex(p.indexName);
589
+ if (p.confirm !== true) throw new Error("Dropping an index requires confirm: true");
590
+ const indexName = validateIndexName(p.indexName);
591
+ await (await collection(p.database, p.collection)).dropIndex(indexName);
421
592
  audit({
422
- database: p.database || DEFAULT_DB,
423
- collection: p.collection,
593
+ database: normalizeDatabaseName(p.database),
594
+ collection: validateCollectionName(p.collection),
424
595
  operation: "drop_index",
425
- summary: p.indexName
596
+ summary: indexName
426
597
  });
427
598
  return { success: true };
428
599
  }
429
600
  });
430
601
  }
431
602
  //#endregion
603
+ //#region src/database-credentials.ts
604
+ function isRecord(value) {
605
+ return Boolean(value) && typeof value === "object" && !Array.isArray(value);
606
+ }
607
+ /** Validate the control-plane credential response before it reaches MongoDB. */
608
+ function parseDatabaseCredentials(value) {
609
+ if (!isRecord(value)) throw new Error("Invalid database credential response");
610
+ const { connectionString, username, password, databases } = value;
611
+ if (typeof connectionString !== "string" || typeof username !== "string" || username.length < 1 || typeof password !== "string" || password.length < 1 || !Array.isArray(databases) || databases.length < 1) throw new Error("Invalid database credential response");
612
+ const normalizedDatabases = [];
613
+ for (const database of databases) {
614
+ if (typeof database !== "string" || database.length < 1) throw new Error("Invalid database credential response");
615
+ normalizedDatabases.push(database);
616
+ }
617
+ const url = new URL(connectionString);
618
+ if (url.protocol !== "mongodb:" && url.protocol !== "mongodb+srv:") throw new Error("Unsupported database connection protocol");
619
+ if (!url.hostname) throw new Error("Invalid database credential response");
620
+ url.username = username;
621
+ url.password = password;
622
+ return {
623
+ connectionString: url.toString(),
624
+ databases: [...new Set(normalizedDatabases)]
625
+ };
626
+ }
627
+ //#endregion
432
628
  //#region src/plugin.ts
433
629
  const pkg = (0, node_module.createRequire)(require("url").pathToFileURL(__filename).href)("../package.json");
434
630
  let mongoClient = null;
435
631
  let initPromise = null;
632
+ let connectionGeneration = 0;
633
+ function errorClass(error) {
634
+ return error instanceof Error && error.name ? error.name : "UnknownError";
635
+ }
636
+ async function closeDatabaseConnection() {
637
+ connectionGeneration += 1;
638
+ initPromise = null;
639
+ const current = mongoClient;
640
+ mongoClient = null;
641
+ if (current) await current.close();
642
+ }
436
643
  /**
437
644
  * Lazy initializer — fetches credentials and connects to MongoDB on first use.
438
645
  * Returns a shared promise so concurrent calls don't duplicate work.
439
646
  */
440
647
  function ensureInitialized(client) {
441
648
  if (initPromise) return initPromise;
442
- initPromise = (async () => {
443
- const credentials = await client.registerDatabaseCredentials();
444
- if (!credentials.connectionString) throw new Error("No connection string returned — cluster may still be provisioning");
445
- const url = new URL(credentials.connectionString);
446
- url.username = credentials.username;
447
- url.password = credentials.password;
448
- const mc = new mongodb.MongoClient(url.toString(), {
649
+ const generation = connectionGeneration;
650
+ const pending = (async () => {
651
+ const credentials = parseDatabaseCredentials(await client.registerDatabaseCredentials());
652
+ const mc = new mongodb.MongoClient(credentials.connectionString, {
449
653
  maxPoolSize: 3,
450
654
  minPoolSize: 1,
451
655
  serverSelectionTimeoutMS: 5e3
452
656
  });
453
- await mc.connect();
454
- mongoClient = mc;
455
- return {
456
- mongoClient: mc,
457
- databases: credentials.databases,
458
- client
459
- };
657
+ try {
658
+ await mc.connect();
659
+ if (generation !== connectionGeneration) throw new Error("Database service stopped during initialization");
660
+ mongoClient = mc;
661
+ return {
662
+ mongoClient: mc,
663
+ databases: credentials.databases,
664
+ client
665
+ };
666
+ } catch (error) {
667
+ await mc.close().catch(() => void 0);
668
+ throw error;
669
+ }
460
670
  })();
461
- initPromise.catch(() => {
462
- initPromise = null;
671
+ initPromise = pending;
672
+ pending.catch(() => {
673
+ if (initPromise === pending) initPromise = null;
463
674
  });
464
- return initPromise;
675
+ return pending;
465
676
  }
466
677
  const ACTIVATED_KEY = "__alfeDatabasePluginActivated";
467
678
  const g = globalThis;
@@ -481,7 +692,7 @@ const plugin = {
481
692
  apiUrl: config.apiUrl
482
693
  });
483
694
  } catch (err) {
484
- log.error(`Database plugin: failed to resolve config ${err instanceof Error ? err.message : String(err)}`);
695
+ log.error(`Database plugin: failed to resolve config (${errorClass(err)})`);
485
696
  return;
486
697
  }
487
698
  registerTools(api, null, [], (entry) => {
@@ -496,16 +707,12 @@ const plugin = {
496
707
  ensureInitialized(client).then(({ databases }) => {
497
708
  log.info(`Database plugin connected — ${String(databases.length)} databases available`);
498
709
  }).catch((err) => {
499
- log.warn(`Database plugin: background init failed (will retry on first tool use) ${err instanceof Error ? err.message : String(err)}`);
710
+ log.warn(`Database plugin: background init failed; first tool use will retry (${errorClass(err)})`);
500
711
  });
501
712
  };
502
713
  const stopDatabaseService = async () => {
503
714
  g[ACTIVATED_KEY] = false;
504
- if (mongoClient) {
505
- await mongoClient.close();
506
- mongoClient = null;
507
- initPromise = null;
508
- }
715
+ await closeDatabaseConnection();
509
716
  log.info("Database plugin deactivated");
510
717
  };
511
718
  api.registerService({
@@ -521,11 +728,7 @@ const plugin = {
521
728
  async deactivate(api) {
522
729
  g[ACTIVATED_KEY] = false;
523
730
  api.logger.info("Database plugin deactivating...");
524
- if (mongoClient) {
525
- await mongoClient.close();
526
- mongoClient = null;
527
- initPromise = null;
528
- }
731
+ await closeDatabaseConnection();
529
732
  }
530
733
  };
531
734
  //#endregion
package/dist/plugin2.js CHANGED
@@ -4,18 +4,132 @@ import { resolveConfig } from "@alfe.ai/config";
4
4
  import { AgentApiClient, installToolErrorCapture } from "@alfe.ai/agent-api-client";
5
5
  //#region src/tools.ts
6
6
  const DEFAULT_DB = "org_default";
7
- function str(required = true) {
7
+ const MAX_SKIP = 1e6;
8
+ const MAX_RESULT_BYTES = 5 * 1024 * 1024;
9
+ const MAX_DATABASE_NAME_BYTES = 63;
10
+ const MAX_COLLECTION_NAME_BYTES = 255;
11
+ const MAX_QUERY_TIME_MS = 1e4;
12
+ const BLOCKED_FILTER_OPERATORS = new Set([
13
+ "$where",
14
+ "$function",
15
+ "$accumulator"
16
+ ]);
17
+ const BLOCKED_AGGREGATION_STAGES = new Set([
18
+ "$out",
19
+ "$merge",
20
+ "$lookup",
21
+ "$graphLookup",
22
+ "$unionWith",
23
+ "$collStats",
24
+ "$indexStats",
25
+ "$currentOp",
26
+ "$listSessions",
27
+ "$listLocalSessions",
28
+ "$planCacheStats",
29
+ "$changeStream",
30
+ "$changeStreamSplitLargeEvent"
31
+ ]);
32
+ const BLOCKED_PIPELINE_KEYS = new Set([...BLOCKED_FILTER_OPERATORS, ...BLOCKED_AGGREGATION_STAGES]);
33
+ function str() {
8
34
  return {
9
35
  type: "string",
10
- ...required ? {} : { default: "" }
36
+ minLength: 1,
37
+ maxLength: MAX_COLLECTION_NAME_BYTES
11
38
  };
12
39
  }
13
40
  function optStr() {
14
41
  return {
15
42
  type: "string",
43
+ maxLength: MAX_DATABASE_NAME_BYTES,
16
44
  default: ""
17
45
  };
18
46
  }
47
+ function isRecord$1(value) {
48
+ return Boolean(value) && typeof value === "object" && !Array.isArray(value);
49
+ }
50
+ function requireRecord(value, label) {
51
+ if (!isRecord$1(value)) throw new Error(`${label} must be an object`);
52
+ return value;
53
+ }
54
+ function requireRecordArray(value, label, maxItems, minItems = 1) {
55
+ if (!Array.isArray(value) || value.length < minItems || value.length > maxItems || !value.every(isRecord$1)) throw new Error(`${label} must contain between ${String(minItems)} and ${String(maxItems)} objects`);
56
+ return value;
57
+ }
58
+ function checkKeys(value, blocked, context) {
59
+ if (!value || typeof value !== "object") return;
60
+ if (Array.isArray(value)) {
61
+ for (const item of value) checkKeys(item, blocked, context);
62
+ return;
63
+ }
64
+ for (const [key, child] of Object.entries(value)) {
65
+ if (blocked.has(key)) throw new Error(`Operator "${key}" is not allowed in ${context}`);
66
+ checkKeys(child, blocked, context);
67
+ }
68
+ }
69
+ function normalizeDatabaseName(value) {
70
+ if (value === void 0 || value === null || value === "") return DEFAULT_DB;
71
+ if (typeof value !== "string" || value.trim().length === 0) throw new Error("database must be a non-empty string");
72
+ if (Buffer.byteLength(value, "utf8") > MAX_DATABASE_NAME_BYTES || /[\0 /\\."$*<>:|?]/.test(value)) throw new Error("database name is invalid");
73
+ return value;
74
+ }
75
+ function validateCollectionName(value) {
76
+ if (typeof value !== "string" || value.trim().length === 0) throw new Error("collection must be a non-empty string");
77
+ if (Buffer.byteLength(value, "utf8") > MAX_COLLECTION_NAME_BYTES || value.includes("\0")) throw new Error("collection name is invalid");
78
+ if (value === "_audit" || value.startsWith("system.")) throw new Error(`Collection "${value}" is reserved for service internals`);
79
+ return value;
80
+ }
81
+ function validateIndexName(value) {
82
+ if (typeof value !== "string" || value.trim().length === 0 || Buffer.byteLength(value, "utf8") > MAX_COLLECTION_NAME_BYTES || value.includes("\0")) throw new Error("indexName is invalid");
83
+ return value;
84
+ }
85
+ function sanitizeFilter(value) {
86
+ const filter = requireRecord(value, "filter");
87
+ checkKeys(filter, BLOCKED_FILTER_OPERATORS, "filter");
88
+ return filter;
89
+ }
90
+ function sanitizeSort(value) {
91
+ const sort = requireRecord(value, "sort");
92
+ const result = {};
93
+ for (const [key, direction] of Object.entries(sort)) {
94
+ if (direction !== 1 && direction !== -1) throw new Error(`Invalid sort value for "${key}": must be 1 or -1`);
95
+ result[key] = direction;
96
+ }
97
+ return result;
98
+ }
99
+ function sanitizePipeline(value) {
100
+ const pipeline = requireRecordArray(value, "pipeline", 50, 0);
101
+ for (const stage of pipeline) for (const [key, child] of Object.entries(stage)) {
102
+ if (BLOCKED_AGGREGATION_STAGES.has(key)) throw new Error(`Aggregation stage "${key}" is not allowed`);
103
+ checkKeys(child, BLOCKED_PIPELINE_KEYS, `pipeline stage "${key}"`);
104
+ }
105
+ return pipeline;
106
+ }
107
+ function boundedInteger(value, fallback, minimum, maximum, label) {
108
+ const normalized = value === void 0 ? fallback : value;
109
+ if (typeof normalized !== "number" || !Number.isInteger(normalized) || normalized < minimum || normalized > maximum) throw new Error(`${label} must be an integer between ${String(minimum)} and ${String(maximum)}`);
110
+ return normalized;
111
+ }
112
+ function serializedBytes(value) {
113
+ const json = JSON.stringify(value, (_key, item) => typeof item === "bigint" ? item.toString() : item);
114
+ return Buffer.byteLength(json, "utf8");
115
+ }
116
+ function assertWithinByteBudget(value, label) {
117
+ if (serializedBytes(value) > 5242880) throw new Error(`${label} exceeds the ${String(MAX_RESULT_BYTES)}-byte limit`);
118
+ }
119
+ async function collectBounded(cursor, maxDocuments = 200) {
120
+ const results = [];
121
+ let totalBytes = 0;
122
+ for await (const document of cursor) {
123
+ if (results.length >= maxDocuments) throw new Error(`Query returned more than ${String(maxDocuments)} documents`);
124
+ totalBytes += serializedBytes(document);
125
+ if (totalBytes > 5242880) throw new Error(`Query result exceeds the ${String(MAX_RESULT_BYTES)}-byte limit`);
126
+ results.push(document);
127
+ }
128
+ return results;
129
+ }
130
+ function requireDestructiveIntent(filter, allowAll) {
131
+ if (Object.keys(filter).length === 0 && allowAll !== true) throw new Error("An empty filter requires allowAll: true");
132
+ }
19
133
  function registerTools(api, _client, _databases, audit, lazyInit) {
20
134
  async function db(name) {
21
135
  const { mongoClient, databases } = lazyInit ? await lazyInit() : {
@@ -24,10 +138,14 @@ function registerTools(api, _client, _databases, audit, lazyInit) {
24
138
  })(),
25
139
  databases: _databases
26
140
  };
27
- const dbName = name ?? DEFAULT_DB;
141
+ const dbName = normalizeDatabaseName(name);
28
142
  if (!databases.includes(dbName)) throw new Error(`Access denied: database "${dbName}" is not in the allowed list`);
29
143
  return mongoClient.db(dbName);
30
144
  }
145
+ async function collection(database, name) {
146
+ const collectionName = validateCollectionName(name);
147
+ return (await db(database)).collection(collectionName);
148
+ }
31
149
  api.registerTool({
32
150
  name: "db_find",
33
151
  description: "Find documents matching a filter",
@@ -46,11 +164,15 @@ function registerTools(api, _client, _databases, audit, lazyInit) {
46
164
  default: {}
47
165
  },
48
166
  limit: {
49
- type: "number",
167
+ type: "integer",
168
+ minimum: 1,
169
+ maximum: 200,
50
170
  default: 20
51
171
  },
52
172
  skip: {
53
- type: "number",
173
+ type: "integer",
174
+ minimum: 0,
175
+ maximum: MAX_SKIP,
54
176
  default: 0
55
177
  },
56
178
  projection: {
@@ -61,7 +183,9 @@ function registerTools(api, _client, _databases, audit, lazyInit) {
61
183
  required: ["collection"]
62
184
  },
63
185
  execute: async (_id, p) => {
64
- const docs = await (await db(p.database)).collection(p.collection).find(p.filter).sort(p.sort).project(p.projection).skip(p.skip || 0).limit(p.limit || 20).toArray();
186
+ const limit = boundedInteger(p.limit, 20, 1, 200, "limit");
187
+ const skip = boundedInteger(p.skip, 0, 0, MAX_SKIP, "skip");
188
+ const docs = await collectBounded((await collection(p.database, p.collection)).find(sanitizeFilter(p.filter ?? {}), { maxTimeMS: MAX_QUERY_TIME_MS }).sort(sanitizeSort(p.sort ?? {})).project(requireRecord(p.projection ?? {}, "projection")).skip(skip).limit(limit), limit);
65
189
  return {
66
190
  documents: docs,
67
191
  count: docs.length
@@ -89,7 +213,12 @@ function registerTools(api, _client, _databases, audit, lazyInit) {
89
213
  required: ["collection"]
90
214
  },
91
215
  execute: async (_id, p) => {
92
- return await (await db(p.database)).collection(p.collection).findOne(p.filter, { projection: p.projection }) ?? { _not_found: true };
216
+ const doc = await (await collection(p.database, p.collection)).findOne(sanitizeFilter(p.filter ?? {}), {
217
+ projection: requireRecord(p.projection ?? {}, "projection"),
218
+ maxTimeMS: MAX_QUERY_TIME_MS
219
+ });
220
+ assertWithinByteBudget(doc, "Query result");
221
+ return doc ?? { _not_found: true };
93
222
  }
94
223
  });
95
224
  api.registerTool({
@@ -103,17 +232,20 @@ function registerTools(api, _client, _databases, audit, lazyInit) {
103
232
  collection: str(),
104
233
  documents: {
105
234
  type: "array",
235
+ minItems: 1,
236
+ maxItems: 100,
106
237
  items: { type: "object" }
107
238
  }
108
239
  },
109
240
  required: ["collection", "documents"]
110
241
  },
111
242
  execute: async (_id, p) => {
112
- const docs = p.documents;
113
- const result = await (await db(p.database)).collection(p.collection).insertMany(docs);
243
+ const docs = requireRecordArray(p.documents, "documents", 100);
244
+ assertWithinByteBudget(docs, "Insert batch");
245
+ const result = await (await collection(p.database, p.collection)).insertMany(docs);
114
246
  audit({
115
- database: p.database || DEFAULT_DB,
116
- collection: p.collection,
247
+ database: normalizeDatabaseName(p.database),
248
+ collection: validateCollectionName(p.collection),
117
249
  operation: "insert",
118
250
  summary: `Inserted ${String(docs.length)} document(s)`
119
251
  });
@@ -137,6 +269,11 @@ function registerTools(api, _client, _databases, audit, lazyInit) {
137
269
  upsert: {
138
270
  type: "boolean",
139
271
  default: false
272
+ },
273
+ allowAll: {
274
+ type: "boolean",
275
+ default: false,
276
+ description: "Must be true when using an empty filter intentionally"
140
277
  }
141
278
  },
142
279
  required: [
@@ -146,10 +283,15 @@ function registerTools(api, _client, _databases, audit, lazyInit) {
146
283
  ]
147
284
  },
148
285
  execute: async (_id, p) => {
149
- const result = await (await db(p.database)).collection(p.collection).updateMany(p.filter, p.update, { upsert: p.upsert || false });
286
+ const filter = sanitizeFilter(p.filter);
287
+ requireDestructiveIntent(filter, p.allowAll);
288
+ const update = requireRecord(p.update, "update");
289
+ if (Object.keys(update).length === 0) throw new Error("update must not be empty");
290
+ checkKeys(update, BLOCKED_FILTER_OPERATORS, "update");
291
+ const result = await (await collection(p.database, p.collection)).updateMany(filter, update, { upsert: p.upsert === true });
150
292
  audit({
151
- database: p.database || DEFAULT_DB,
152
- collection: p.collection,
293
+ database: normalizeDatabaseName(p.database),
294
+ collection: validateCollectionName(p.collection),
153
295
  operation: "update",
154
296
  summary: `Modified ${String(result.modifiedCount)}`
155
297
  });
@@ -169,15 +311,22 @@ function registerTools(api, _client, _databases, audit, lazyInit) {
169
311
  properties: {
170
312
  database: optStr(),
171
313
  collection: str(),
172
- filter: { type: "object" }
314
+ filter: { type: "object" },
315
+ allowAll: {
316
+ type: "boolean",
317
+ default: false,
318
+ description: "Must be true when using an empty filter intentionally"
319
+ }
173
320
  },
174
321
  required: ["collection", "filter"]
175
322
  },
176
323
  execute: async (_id, p) => {
177
- const result = await (await db(p.database)).collection(p.collection).deleteMany(p.filter);
324
+ const filter = sanitizeFilter(p.filter);
325
+ requireDestructiveIntent(filter, p.allowAll);
326
+ const result = await (await collection(p.database, p.collection)).deleteMany(filter);
178
327
  audit({
179
- database: p.database || DEFAULT_DB,
180
- collection: p.collection,
328
+ database: normalizeDatabaseName(p.database),
329
+ collection: validateCollectionName(p.collection),
181
330
  operation: "delete",
182
331
  summary: `Deleted ${String(result.deletedCount)}`
183
332
  });
@@ -201,7 +350,7 @@ function registerTools(api, _client, _databases, audit, lazyInit) {
201
350
  required: ["collection"]
202
351
  },
203
352
  execute: async (_id, p) => {
204
- return { count: await (await db(p.database)).collection(p.collection).countDocuments(p.filter) };
353
+ return { count: await (await collection(p.database, p.collection)).countDocuments(sanitizeFilter(p.filter ?? {}), { maxTimeMS: MAX_QUERY_TIME_MS }) };
205
354
  }
206
355
  });
207
356
  api.registerTool({
@@ -215,13 +364,16 @@ function registerTools(api, _client, _databases, audit, lazyInit) {
215
364
  collection: str(),
216
365
  pipeline: {
217
366
  type: "array",
367
+ minItems: 0,
368
+ maxItems: 50,
218
369
  items: { type: "object" }
219
370
  }
220
371
  },
221
372
  required: ["collection", "pipeline"]
222
373
  },
223
374
  execute: async (_id, p) => {
224
- const results = await (await db(p.database)).collection(p.collection).aggregate(p.pipeline).toArray();
375
+ const pipeline = sanitizePipeline(p.pipeline);
376
+ const results = await collectBounded((await collection(p.database, p.collection)).aggregate([...pipeline, { $limit: 201 }], { maxTimeMS: MAX_QUERY_TIME_MS }), 200);
225
377
  return {
226
378
  results,
227
379
  count: results.length
@@ -250,9 +402,9 @@ function registerTools(api, _client, _databases, audit, lazyInit) {
250
402
  properties: { database: optStr() }
251
403
  },
252
404
  execute: async (_id, p) => {
253
- return { collections: (await (await db(p.database)).listCollections().toArray()).map((c) => ({
254
- name: c.name,
255
- type: c.type
405
+ return { collections: (await (await db(p.database)).listCollections({}, { nameOnly: true }).toArray()).filter((item) => item.name !== "_audit" && !item.name.startsWith("system.")).map((item) => ({
406
+ name: item.name,
407
+ type: item.type
256
408
  })) };
257
409
  }
258
410
  });
@@ -269,10 +421,11 @@ function registerTools(api, _client, _databases, audit, lazyInit) {
269
421
  required: ["collection"]
270
422
  },
271
423
  execute: async (_id, p) => {
272
- await (await db(p.database)).createCollection(p.collection);
424
+ const collectionName = validateCollectionName(p.collection);
425
+ await (await db(p.database)).createCollection(collectionName);
273
426
  audit({
274
- database: p.database || DEFAULT_DB,
275
- collection: p.collection,
427
+ database: normalizeDatabaseName(p.database),
428
+ collection: collectionName,
276
429
  operation: "create_collection"
277
430
  });
278
431
  return { success: true };
@@ -286,15 +439,22 @@ function registerTools(api, _client, _databases, audit, lazyInit) {
286
439
  type: "object",
287
440
  properties: {
288
441
  database: optStr(),
289
- collection: str()
442
+ collection: str(),
443
+ confirm: {
444
+ type: "boolean",
445
+ const: true,
446
+ description: "Must be true to confirm permanent collection deletion"
447
+ }
290
448
  },
291
- required: ["collection"]
449
+ required: ["collection", "confirm"]
292
450
  },
293
451
  execute: async (_id, p) => {
294
- await (await db(p.database)).dropCollection(p.collection);
452
+ if (p.confirm !== true) throw new Error("Dropping a collection requires confirm: true");
453
+ const collectionName = validateCollectionName(p.collection);
454
+ await (await db(p.database)).dropCollection(collectionName);
295
455
  audit({
296
- database: p.database || DEFAULT_DB,
297
- collection: p.collection,
456
+ database: normalizeDatabaseName(p.database),
457
+ collection: collectionName,
298
458
  operation: "drop_collection"
299
459
  });
300
460
  return { success: true };
@@ -313,7 +473,7 @@ function registerTools(api, _client, _databases, audit, lazyInit) {
313
473
  required: ["collection"]
314
474
  },
315
475
  execute: async (_id, p) => {
316
- const sample = await (await db(p.database)).collection(p.collection).find().limit(100).toArray();
476
+ const sample = await collectBounded((await collection(p.database, p.collection)).find({}, { maxTimeMS: MAX_QUERY_TIME_MS }).limit(100), 100);
317
477
  const fieldTypes = /* @__PURE__ */ new Map();
318
478
  for (const doc of sample) for (const [key, value] of Object.entries(doc)) {
319
479
  const types = fieldTypes.get(key) ?? /* @__PURE__ */ new Set();
@@ -342,8 +502,8 @@ function registerTools(api, _client, _databases, audit, lazyInit) {
342
502
  required: ["collection"]
343
503
  },
344
504
  execute: async (_id, p) => {
345
- const coll = (await db(p.database)).collection(p.collection);
346
- const count = await coll.countDocuments();
505
+ const coll = await collection(p.database, p.collection);
506
+ const count = await coll.countDocuments({}, { maxTimeMS: MAX_QUERY_TIME_MS });
347
507
  const indexes = await coll.indexes();
348
508
  return {
349
509
  count,
@@ -373,10 +533,10 @@ function registerTools(api, _client, _databases, audit, lazyInit) {
373
533
  required: ["collection", "keys"]
374
534
  },
375
535
  execute: async (_id, p) => {
376
- const indexName = await (await db(p.database)).collection(p.collection).createIndex(p.keys, p.options);
536
+ const indexName = await (await collection(p.database, p.collection)).createIndex(sanitizeSort(p.keys), requireRecord(p.options ?? {}, "options"));
377
537
  audit({
378
- database: p.database || DEFAULT_DB,
379
- collection: p.collection,
538
+ database: normalizeDatabaseName(p.database),
539
+ collection: validateCollectionName(p.collection),
380
540
  operation: "create_index",
381
541
  summary: indexName
382
542
  });
@@ -396,7 +556,7 @@ function registerTools(api, _client, _databases, audit, lazyInit) {
396
556
  required: ["collection"]
397
557
  },
398
558
  execute: async (_id, p) => {
399
- return { indexes: (await (await db(p.database)).collection(p.collection).indexes()).map((i) => ({
559
+ return { indexes: (await (await collection(p.database, p.collection)).indexes()).map((i) => ({
400
560
  name: i.name,
401
561
  key: i.key,
402
562
  unique: i.unique
@@ -412,56 +572,107 @@ function registerTools(api, _client, _databases, audit, lazyInit) {
412
572
  properties: {
413
573
  database: optStr(),
414
574
  collection: str(),
415
- indexName: str()
575
+ indexName: str(),
576
+ confirm: {
577
+ type: "boolean",
578
+ const: true,
579
+ description: "Must be true to confirm permanent index deletion"
580
+ }
416
581
  },
417
- required: ["collection", "indexName"]
582
+ required: [
583
+ "collection",
584
+ "indexName",
585
+ "confirm"
586
+ ]
418
587
  },
419
588
  execute: async (_id, p) => {
420
- await (await db(p.database)).collection(p.collection).dropIndex(p.indexName);
589
+ if (p.confirm !== true) throw new Error("Dropping an index requires confirm: true");
590
+ const indexName = validateIndexName(p.indexName);
591
+ await (await collection(p.database, p.collection)).dropIndex(indexName);
421
592
  audit({
422
- database: p.database || DEFAULT_DB,
423
- collection: p.collection,
593
+ database: normalizeDatabaseName(p.database),
594
+ collection: validateCollectionName(p.collection),
424
595
  operation: "drop_index",
425
- summary: p.indexName
596
+ summary: indexName
426
597
  });
427
598
  return { success: true };
428
599
  }
429
600
  });
430
601
  }
431
602
  //#endregion
603
+ //#region src/database-credentials.ts
604
+ function isRecord(value) {
605
+ return Boolean(value) && typeof value === "object" && !Array.isArray(value);
606
+ }
607
+ /** Validate the control-plane credential response before it reaches MongoDB. */
608
+ function parseDatabaseCredentials(value) {
609
+ if (!isRecord(value)) throw new Error("Invalid database credential response");
610
+ const { connectionString, username, password, databases } = value;
611
+ if (typeof connectionString !== "string" || typeof username !== "string" || username.length < 1 || typeof password !== "string" || password.length < 1 || !Array.isArray(databases) || databases.length < 1) throw new Error("Invalid database credential response");
612
+ const normalizedDatabases = [];
613
+ for (const database of databases) {
614
+ if (typeof database !== "string" || database.length < 1) throw new Error("Invalid database credential response");
615
+ normalizedDatabases.push(database);
616
+ }
617
+ const url = new URL(connectionString);
618
+ if (url.protocol !== "mongodb:" && url.protocol !== "mongodb+srv:") throw new Error("Unsupported database connection protocol");
619
+ if (!url.hostname) throw new Error("Invalid database credential response");
620
+ url.username = username;
621
+ url.password = password;
622
+ return {
623
+ connectionString: url.toString(),
624
+ databases: [...new Set(normalizedDatabases)]
625
+ };
626
+ }
627
+ //#endregion
432
628
  //#region src/plugin.ts
433
629
  const pkg = createRequire(import.meta.url)("../package.json");
434
630
  let mongoClient = null;
435
631
  let initPromise = null;
632
+ let connectionGeneration = 0;
633
+ function errorClass(error) {
634
+ return error instanceof Error && error.name ? error.name : "UnknownError";
635
+ }
636
+ async function closeDatabaseConnection() {
637
+ connectionGeneration += 1;
638
+ initPromise = null;
639
+ const current = mongoClient;
640
+ mongoClient = null;
641
+ if (current) await current.close();
642
+ }
436
643
  /**
437
644
  * Lazy initializer — fetches credentials and connects to MongoDB on first use.
438
645
  * Returns a shared promise so concurrent calls don't duplicate work.
439
646
  */
440
647
  function ensureInitialized(client) {
441
648
  if (initPromise) return initPromise;
442
- initPromise = (async () => {
443
- const credentials = await client.registerDatabaseCredentials();
444
- if (!credentials.connectionString) throw new Error("No connection string returned — cluster may still be provisioning");
445
- const url = new URL(credentials.connectionString);
446
- url.username = credentials.username;
447
- url.password = credentials.password;
448
- const mc = new MongoClient(url.toString(), {
649
+ const generation = connectionGeneration;
650
+ const pending = (async () => {
651
+ const credentials = parseDatabaseCredentials(await client.registerDatabaseCredentials());
652
+ const mc = new MongoClient(credentials.connectionString, {
449
653
  maxPoolSize: 3,
450
654
  minPoolSize: 1,
451
655
  serverSelectionTimeoutMS: 5e3
452
656
  });
453
- await mc.connect();
454
- mongoClient = mc;
455
- return {
456
- mongoClient: mc,
457
- databases: credentials.databases,
458
- client
459
- };
657
+ try {
658
+ await mc.connect();
659
+ if (generation !== connectionGeneration) throw new Error("Database service stopped during initialization");
660
+ mongoClient = mc;
661
+ return {
662
+ mongoClient: mc,
663
+ databases: credentials.databases,
664
+ client
665
+ };
666
+ } catch (error) {
667
+ await mc.close().catch(() => void 0);
668
+ throw error;
669
+ }
460
670
  })();
461
- initPromise.catch(() => {
462
- initPromise = null;
671
+ initPromise = pending;
672
+ pending.catch(() => {
673
+ if (initPromise === pending) initPromise = null;
463
674
  });
464
- return initPromise;
675
+ return pending;
465
676
  }
466
677
  const ACTIVATED_KEY = "__alfeDatabasePluginActivated";
467
678
  const g = globalThis;
@@ -481,7 +692,7 @@ const plugin = {
481
692
  apiUrl: config.apiUrl
482
693
  });
483
694
  } catch (err) {
484
- log.error(`Database plugin: failed to resolve config ${err instanceof Error ? err.message : String(err)}`);
695
+ log.error(`Database plugin: failed to resolve config (${errorClass(err)})`);
485
696
  return;
486
697
  }
487
698
  registerTools(api, null, [], (entry) => {
@@ -496,16 +707,12 @@ const plugin = {
496
707
  ensureInitialized(client).then(({ databases }) => {
497
708
  log.info(`Database plugin connected — ${String(databases.length)} databases available`);
498
709
  }).catch((err) => {
499
- log.warn(`Database plugin: background init failed (will retry on first tool use) ${err instanceof Error ? err.message : String(err)}`);
710
+ log.warn(`Database plugin: background init failed; first tool use will retry (${errorClass(err)})`);
500
711
  });
501
712
  };
502
713
  const stopDatabaseService = async () => {
503
714
  g[ACTIVATED_KEY] = false;
504
- if (mongoClient) {
505
- await mongoClient.close();
506
- mongoClient = null;
507
- initPromise = null;
508
- }
715
+ await closeDatabaseConnection();
509
716
  log.info("Database plugin deactivated");
510
717
  };
511
718
  api.registerService({
@@ -521,11 +728,7 @@ const plugin = {
521
728
  async deactivate(api) {
522
729
  g[ACTIVATED_KEY] = false;
523
730
  api.logger.info("Database plugin deactivating...");
524
- if (mongoClient) {
525
- await mongoClient.close();
526
- mongoClient = null;
527
- initPromise = null;
528
- }
731
+ await closeDatabaseConnection();
529
732
  }
530
733
  };
531
734
  //#endregion
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@alfe.ai/openclaw-database",
3
- "version": "0.0.42",
3
+ "version": "0.0.44",
4
4
  "description": "OpenClaw database plugin — MongoDB access for agents via MCP tools",
5
5
  "type": "module",
6
6
  "main": "./dist/plugin.js",
@@ -28,8 +28,8 @@
28
28
  ],
29
29
  "dependencies": {
30
30
  "mongodb": "^6.12.0",
31
- "@alfe.ai/agent-api-client": "0.13.0",
32
- "@alfe.ai/config": "0.3.0"
31
+ "@alfe.ai/agent-api-client": "0.15.0",
32
+ "@alfe.ai/config": "0.4.1"
33
33
  },
34
34
  "peerDependencies": {
35
35
  "openclaw": ">=2026.3.0"
@@ -53,6 +53,7 @@
53
53
  "scripts": {
54
54
  "build": "tsdown",
55
55
  "dev": "tsdown --watch",
56
+ "test": "vitest run",
56
57
  "typecheck": "tsc --noEmit",
57
58
  "lint": "eslint ."
58
59
  }