@frogfish/k2db 3.0.1 → 3.0.2

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 (4) hide show
  1. package/README.md +351 -13
  2. package/db.d.ts +133 -22
  3. package/db.js +652 -59
  4. package/package.json +1 -1
package/db.js CHANGED
@@ -1,8 +1,7 @@
1
1
  // src/db.ts
2
2
  import { K2Error, ServiceError, wrap } from "@frogfish/k2error"; // Keep the existing error structure
3
3
  import { MongoClient, } from "mongodb";
4
- import { randomBytes, createHash } from "crypto";
5
- // import debugLib from "debug";
4
+ import { randomBytes, createHash, createCipheriv, createDecipheriv } from "crypto";
6
5
  import { Topic } from '@frogfish/ratatouille';
7
6
  import { z } from "zod";
8
7
  // const debug = debugLib("k2:db");
@@ -157,8 +156,74 @@ export class K2DB {
157
156
  initialized = false;
158
157
  initPromise;
159
158
  schemas = new Map();
159
+ ownershipMode;
160
+ aggregationMode;
161
+ secureFieldPrefixes;
162
+ secureFieldEncryptionKey;
163
+ secureFieldEncryptionKeyId;
160
164
  constructor(conf) {
161
165
  this.conf = conf;
166
+ this.ownershipMode = conf.ownershipMode ?? "lax";
167
+ this.aggregationMode = conf.aggregationMode ?? "loose";
168
+ this.secureFieldPrefixes = (conf.secureFieldPrefixes ?? [])
169
+ .map((p) => (typeof p === "string" ? p.trim() : ""))
170
+ .filter((p) => !!p);
171
+ const keyB64 = (conf.secureFieldEncryptionKey ?? "").trim();
172
+ const kid = (conf.secureFieldEncryptionKeyId ?? "").trim();
173
+ this.secureFieldEncryptionKeyId = kid || undefined;
174
+ if (keyB64) {
175
+ let keyBuf;
176
+ try {
177
+ keyBuf = Buffer.from(keyB64, "base64");
178
+ }
179
+ catch (e) {
180
+ throw new K2Error(ServiceError.CONFIGURATION_ERROR, "secureFieldEncryptionKey must be base64-encoded", "sys_mdb_secure_key_invalid");
181
+ }
182
+ if (keyBuf.length !== 32) {
183
+ throw new K2Error(ServiceError.CONFIGURATION_ERROR, "secureFieldEncryptionKey must decode to 32 bytes (AES-256)", "sys_mdb_secure_key_invalid");
184
+ }
185
+ this.secureFieldEncryptionKey = keyBuf;
186
+ }
187
+ }
188
+ /**
189
+ * Normalize a scope value for ownership enforcement.
190
+ */
191
+ normalizeScope(scope) {
192
+ if (scope === undefined || scope === null)
193
+ return undefined;
194
+ if (typeof scope !== "string")
195
+ return undefined;
196
+ const s = scope.trim();
197
+ if (!s)
198
+ return undefined;
199
+ if (s === "*")
200
+ return "*";
201
+ return s.toLowerCase();
202
+ }
203
+ /**
204
+ * Apply a scope constraint to criteria for ownership enforcement.
205
+ */
206
+ applyScopeToCriteria(criteria, scope) {
207
+ const normalizedScope = this.normalizeScope(scope);
208
+ // Strict mode requires an explicit scope per call.
209
+ if (this.ownershipMode === "strict" && !normalizedScope) {
210
+ throw new K2Error(ServiceError.BAD_REQUEST, "Scope is required in strict ownership mode", "sys_mdb_scope_required");
211
+ }
212
+ // Lax mode with no scope: legacy behavior (no owner constraint injected).
213
+ if (!normalizedScope)
214
+ return criteria;
215
+ // Explicit all-scope request: do not constrain by _owner.
216
+ if (normalizedScope === "*")
217
+ return criteria;
218
+ // If caller already provided _owner in criteria, ensure it matches the scope to avoid ambiguity/bypass.
219
+ if (criteria && typeof criteria === "object" && Object.prototype.hasOwnProperty.call(criteria, "_owner")) {
220
+ const existing = criteria._owner;
221
+ if (typeof existing === "string" && existing.trim().toLowerCase() !== normalizedScope) {
222
+ throw new K2Error(ServiceError.BAD_REQUEST, "Conflicting _owner in criteria and provided scope", "sys_mdb_scope_conflict");
223
+ }
224
+ // If it matches (or is non-string), prefer the explicit scope value.
225
+ }
226
+ return { ...(criteria || {}), _owner: normalizedScope };
162
227
  }
163
228
  /**
164
229
  * Initializes the MongoDB connection.
@@ -277,45 +342,81 @@ export class K2DB {
277
342
  throw wrap(err, ServiceError.SYSTEM_ERROR, "sys_mdb_gc", `Error getting collection: ${collectionName}`);
278
343
  }
279
344
  }
280
- async get(collectionName, uuid) {
345
+ /**
346
+ * Retrieves a single document by UUID.
347
+ * @param collectionName - Name of the collection.
348
+ * @param uuid - UUID of the document.
349
+ * @param scope - (optional) Owner selector; "*" means all owners.
350
+ */
351
+ async get(collectionName, uuid, scope) {
281
352
  const id = K2DB.normalizeId(uuid);
353
+ // Note: findOne() decrypts secure-prefixed fields for single-record reads when encryption is enabled.
282
354
  const res = await this.findOne(collectionName, {
283
355
  _uuid: id,
284
356
  _deleted: { $ne: true },
285
- });
357
+ }, undefined, scope);
286
358
  if (!res) {
287
359
  throw new K2Error(ServiceError.NOT_FOUND, "Document not found", "sys_mdb_get_not_found");
288
360
  }
289
361
  return res;
290
362
  }
291
363
  /**
292
- * Retrieves a single document by UUID.
364
+ * Retrieves a single document by criteria.
293
365
  * @param collectionName - Name of the collection.
294
- * @param uuid - UUID of the document.
295
- * @param objectTypeName - Optional object type name.
366
+ * @param criteria - Criteria to find the document.
296
367
  * @param fields - Optional array of fields to include.
368
+ * @param scope - (optional) Owner selector; "*" means all owners.
297
369
  */
298
- async findOne(collectionName, criteria, fields) {
370
+ async findOne(collectionName, criteria, fields, scope) {
299
371
  const collection = await this.getCollection(collectionName);
300
372
  const projection = {};
301
373
  // Exclude soft-deleted documents by default unless caller specifies otherwise
302
374
  const normalizedCriteria = K2DB.normalizeCriteriaIds(criteria || {});
303
- const query = {
375
+ let query = {
304
376
  ...normalizedCriteria,
305
377
  ...(criteria && Object.prototype.hasOwnProperty.call(criteria, "_deleted")
306
378
  ? {}
307
379
  : { _deleted: { $ne: true } }),
308
380
  };
309
- if (fields && fields.length > 0) {
310
- fields.forEach((field) => {
311
- projection[field] = 1;
381
+ query = this.applyScopeToCriteria(query, scope);
382
+ // If a projection is requested, forbid explicitly projecting secure-prefixed fields.
383
+ // Also ensure _uuid is fetched when secure encryption is enabled so decryption AAD is correct.
384
+ const requestedFields = Array.isArray(fields) ? fields.slice() : undefined;
385
+ if (requestedFields && requestedFields.length > 0) {
386
+ requestedFields.forEach((field) => {
387
+ if (typeof field !== "string")
388
+ return;
389
+ const f = field.trim();
390
+ if (!f)
391
+ return;
392
+ // Deny any projection that includes secure-prefixed fields (including nested dotted paths)
393
+ if (this.pathHasSecureSegment(f)) {
394
+ throw new K2Error(ServiceError.BAD_REQUEST, "Projection cannot include secure-prefixed field(s)", "sys_mdb_projection_secure_field");
395
+ }
396
+ projection[f] = 1;
312
397
  });
398
+ // Ensure we can decrypt secure fields that are present in the returned doc
399
+ if (this.hasSecureEncryption()) {
400
+ projection["_uuid"] = 1;
401
+ }
313
402
  }
314
403
  try {
315
404
  const item = await this.runTimed("findOne", { collectionName, query, projection }, async () => await collection.findOne(query, { projection }));
316
405
  if (item) {
317
406
  const { _id, ...rest } = item;
318
- return rest;
407
+ const out = rest;
408
+ const aadBase = out._uuid
409
+ ? `k2db|${collectionName}|${out._uuid}`
410
+ : `k2db|${collectionName}`;
411
+ const decrypted = this.decryptSecureFieldsDeep(out, aadBase);
412
+ // If caller requested a projection and did NOT request _uuid, hide it again (it may have been added for decrypt AAD).
413
+ if (requestedFields && requestedFields.length > 0) {
414
+ const wantsUuid = requestedFields.some((f) => typeof f === "string" && f.trim() === "_uuid");
415
+ if (!wantsUuid) {
416
+ delete decrypted._uuid;
417
+ }
418
+ }
419
+ return decrypted;
319
420
  }
320
421
  return null;
321
422
  }
@@ -330,12 +431,14 @@ export class K2DB {
330
431
  * @param params - Optional search parameters (for sorting, including/excluding fields).
331
432
  * @param skip - Number of documents to skip (for pagination).
332
433
  * @param limit - Maximum number of documents to return.
434
+ * @param scope - (optional) Owner selector; "*" means all owners.
333
435
  */
334
- async find(collectionName, filter, params = {}, skip = 0, limit = 100) {
436
+ async find(collectionName, filter, params = {}, skip = 0, limit = 100, scope) {
335
437
  const collection = await this.getCollection(collectionName);
336
438
  // Ensure filter is valid, defaulting to an empty object
337
439
  let criteria = { ...(filter || {}) };
338
440
  criteria = K2DB.normalizeCriteriaIds(criteria);
441
+ criteria = this.applyScopeToCriteria(criteria, scope);
339
442
  // Handle the _deleted field if params specify not to include deleted documents
340
443
  if (!params?.includeDeleted && !Object.prototype.hasOwnProperty.call(criteria, "_deleted")) {
341
444
  if (params?.deleted === true) {
@@ -353,7 +456,14 @@ export class K2DB {
353
456
  else if (Array.isArray(params.filter)) {
354
457
  projection = {};
355
458
  params.filter.forEach((field) => {
356
- projection[field] = 1; // Only include the specified fields
459
+ const f = typeof field === "string" ? field.trim() : "";
460
+ if (!f)
461
+ return;
462
+ // Deny any projection that includes secure-prefixed fields (including nested dotted paths)
463
+ if (this.pathHasSecureSegment(f)) {
464
+ throw new K2Error(ServiceError.BAD_REQUEST, "Projection cannot include secure-prefixed field(s)", "sys_mdb_projection_secure_field");
465
+ }
466
+ projection[f] = 1; // Only include the specified fields
357
467
  });
358
468
  projection._id = 0; // Hide _id when using include list
359
469
  }
@@ -385,7 +495,8 @@ export class K2DB {
385
495
  // Remove _id safely from each document
386
496
  const result = data.map((doc) => {
387
497
  const { _id, ...rest } = doc;
388
- return rest;
498
+ // For multi-record reads, never return secure-prefixed fields (even if encrypted-at-rest is enabled)
499
+ return this.stripSecureFieldsDeep(rest);
389
500
  });
390
501
  return result;
391
502
  }
@@ -394,18 +505,25 @@ export class K2DB {
394
505
  }
395
506
  }
396
507
  /**
397
- * Aggregates documents based on criteria with pagination support.
508
+ * Aggregates documents based on criteria with pagination support (may validate/limit stages in guarded/strict aggregationMode). Secure-prefixed fields may be stripped from results when configured.
398
509
  * @param collectionName - Name of the collection.
399
510
  * @param criteria - Aggregation pipeline criteria.
400
511
  * @param skip - Number of documents to skip (for pagination).
401
512
  * @param limit - Maximum number of documents to return.
513
+ * @param scope - (optional) Owner selector; "*" means all owners.
402
514
  */
403
- async aggregate(collectionName, criteria, skip = 0, limit = 100) {
515
+ async aggregate(collectionName, criteria, skip = 0, limit = 100, scope) {
404
516
  if (criteria.length === 0) {
405
517
  throw new K2Error(ServiceError.SYSTEM_ERROR, "Aggregation criteria cannot be empty", "sys_mdb_ag_empty");
406
518
  }
519
+ // Prevent aggregation from reading/deriving values from secure-prefixed fields (when configured)
520
+ this.assertNoSecureFieldRefsInPipeline(criteria);
521
+ // Validate the aggregation pipeline for allowed/disallowed stages and safety caps
522
+ this.validateAggregationPipeline(criteria, skip, limit);
407
523
  // Enforce soft-delete behavior: never return documents marked as deleted
408
524
  criteria = K2DB.enforceNoDeletedInPipeline(criteria);
525
+ // Enforce ownership scope within the pipeline (and nested pipelines)
526
+ criteria = this.enforceScopeInPipeline(criteria, scope);
409
527
  // Add pagination stages to the aggregation pipeline
410
528
  if (skip > 0) {
411
529
  criteria.push({ $skip: skip });
@@ -423,14 +541,400 @@ export class K2DB {
423
541
  return stage;
424
542
  });
425
543
  try {
426
- const data = await this.runTimed("aggregate", { collectionName, pipeline: sanitizedCriteria }, async () => await collection.aggregate(sanitizedCriteria).toArray());
427
- // Enforce BaseDocument type on each document
428
- return data.map((doc) => doc);
544
+ const data = await this.runTimed("aggregate", { collectionName, pipeline: sanitizedCriteria }, async () => {
545
+ const mode = this.aggregationMode ?? "loose";
546
+ const opts = {};
547
+ if (mode !== "loose") {
548
+ opts.maxTimeMS = 2000;
549
+ }
550
+ return await collection.aggregate(sanitizedCriteria, opts).toArray();
551
+ });
552
+ // Enforce BaseDocument type on each document and strip secure-prefixed fields (if configured)
553
+ return data.map((doc) => this.stripSecureFieldsDeep(doc));
429
554
  }
430
555
  catch (err) {
431
556
  throw wrap(err, ServiceError.SYSTEM_ERROR, "sys_mdb_ag", "Aggregation failed");
432
557
  }
433
558
  }
559
+ /**
560
+ * Validate an aggregation pipeline for safety based on aggregationMode.
561
+ * - loose: no validation
562
+ * - guarded: deny obvious footguns (writes, server-side code) and enforce basic caps
563
+ * - strict: allow only a small safe subset of stages and enforce basic caps
564
+ */
565
+ validateAggregationPipeline(pipeline, skip, limit) {
566
+ const mode = this.aggregationMode ?? "loose";
567
+ if (mode === "loose")
568
+ return;
569
+ // Hard caps to reduce accidental/abusive heavy queries
570
+ const maxStages = 50;
571
+ if (pipeline.length > maxStages) {
572
+ throw new K2Error(ServiceError.BAD_REQUEST, `Aggregation pipeline too long (max ${maxStages} stages)`, "sys_mdb_ag_pipeline_too_long");
573
+ }
574
+ // Require a positive limit in guarded/strict to avoid accidental full scans.
575
+ // Note: aggregate() appends $limit later; enforce here based on the provided arg.
576
+ if (!(typeof limit === "number") || !isFinite(limit) || limit <= 0) {
577
+ throw new K2Error(ServiceError.BAD_REQUEST, "Aggregation requires a positive limit in guarded/strict mode", "sys_mdb_ag_limit_required");
578
+ }
579
+ const maxLimit = 1000;
580
+ if (limit > maxLimit) {
581
+ throw new K2Error(ServiceError.BAD_REQUEST, `Aggregation limit too large (max ${maxLimit})`, "sys_mdb_ag_limit_too_large");
582
+ }
583
+ const ops = this.collectStageOps(pipeline);
584
+ const denyGuarded = new Set(["$out", "$merge", "$function", "$accumulator"]);
585
+ const allowStrict = new Set(["$match", "$project", "$sort", "$skip", "$limit"]);
586
+ if (mode === "guarded") {
587
+ for (const op of ops) {
588
+ if (denyGuarded.has(op)) {
589
+ throw new K2Error(ServiceError.BAD_REQUEST, `Aggregation stage ${op} is not allowed in guarded mode`, "sys_mdb_ag_stage_denied");
590
+ }
591
+ }
592
+ return;
593
+ }
594
+ // strict
595
+ for (const op of ops) {
596
+ if (!allowStrict.has(op)) {
597
+ throw new K2Error(ServiceError.BAD_REQUEST, `Aggregation stage ${op} is not allowed in strict mode`, "sys_mdb_ag_stage_denied");
598
+ }
599
+ }
600
+ }
601
+ /** Collect top-level stage operators for a pipeline (e.g. "$match", "$lookup"). */
602
+ collectStageOps(pipeline) {
603
+ const ops = [];
604
+ for (const stage of pipeline || []) {
605
+ if (!stage || typeof stage !== "object")
606
+ continue;
607
+ const keys = Object.keys(stage);
608
+ if (keys.length === 1 && keys[0].startsWith("$")) {
609
+ ops.push(keys[0]);
610
+ }
611
+ else {
612
+ // Unknown/non-canonical stage shape; treat as invalid in strict/guarded
613
+ ops.push("__invalid__");
614
+ }
615
+ }
616
+ return ops;
617
+ }
618
+ /** True if a field key is considered secure and must not be returned. */
619
+ isSecureFieldKey(key) {
620
+ if (!this.secureFieldPrefixes.length)
621
+ return false;
622
+ for (const p of this.secureFieldPrefixes) {
623
+ if (key.startsWith(p))
624
+ return true;
625
+ }
626
+ return false;
627
+ }
628
+ /**
629
+ * Recursively strips secure-prefixed fields from objects/arrays (e.g. "#passport_number").
630
+ * This is applied on read results (e.g. aggregate) so pipelines cannot exfiltrate secure fields.
631
+ */
632
+ stripSecureFieldsDeep(val) {
633
+ if (!this.secureFieldPrefixes.length)
634
+ return val;
635
+ if (val === null || val === undefined)
636
+ return val;
637
+ if (Array.isArray(val)) {
638
+ return val.map((x) => this.stripSecureFieldsDeep(x));
639
+ }
640
+ if (typeof val !== "object") {
641
+ return val;
642
+ }
643
+ const out = {};
644
+ for (const [k, v] of Object.entries(val)) {
645
+ if (this.isSecureFieldKey(k))
646
+ continue;
647
+ out[k] = this.stripSecureFieldsDeep(v);
648
+ }
649
+ return out;
650
+ }
651
+ /** True if secure-field encryption is enabled (requires both key and keyId). */
652
+ hasSecureEncryption() {
653
+ return !!this.secureFieldEncryptionKey && !!this.secureFieldEncryptionKeyId;
654
+ }
655
+ /** Encrypt a JS value using AES-256-GCM and return "<kid>:<ivB64>.<tagB64>.<ctB64>". */
656
+ encryptSecureValueToString(value, aad) {
657
+ const kid = this.secureFieldEncryptionKeyId;
658
+ const iv = randomBytes(12);
659
+ const cipher = createCipheriv("aes-256-gcm", this.secureFieldEncryptionKey, iv);
660
+ cipher.setAAD(Buffer.from(aad, "utf8"));
661
+ const pt = Buffer.from(JSON.stringify(value), "utf8");
662
+ const ct = Buffer.concat([cipher.update(pt), cipher.final()]);
663
+ const tag = cipher.getAuthTag();
664
+ const payload = `${iv.toString("base64")}.${tag.toString("base64")}.${ct.toString("base64")}`;
665
+ return `${kid}:${payload}`;
666
+ }
667
+ /** Decrypt a "<kid>:<ivB64>.<tagB64>.<ctB64>" string back into a JS value. */
668
+ decryptSecureStringToValue(s, aad) {
669
+ if (!this.hasSecureEncryption())
670
+ return s;
671
+ if (typeof s !== "string")
672
+ return s;
673
+ const idx = s.indexOf(":");
674
+ if (idx <= 0)
675
+ return s;
676
+ const kid = s.slice(0, idx);
677
+ const payload = s.slice(idx + 1);
678
+ // Only decrypt if kid matches the configured key id
679
+ if (!this.secureFieldEncryptionKeyId || kid !== this.secureFieldEncryptionKeyId) {
680
+ return s;
681
+ }
682
+ const parts = payload.split(".");
683
+ if (parts.length !== 3)
684
+ return s;
685
+ try {
686
+ const iv = Buffer.from(parts[0], "base64");
687
+ const tag = Buffer.from(parts[1], "base64");
688
+ const ct = Buffer.from(parts[2], "base64");
689
+ const decipher = createDecipheriv("aes-256-gcm", this.secureFieldEncryptionKey, iv);
690
+ decipher.setAAD(Buffer.from(aad, "utf8"));
691
+ decipher.setAuthTag(tag);
692
+ const pt = Buffer.concat([decipher.update(ct), decipher.final()]);
693
+ return JSON.parse(pt.toString("utf8"));
694
+ }
695
+ catch (err) {
696
+ throw wrap(err, ServiceError.SYSTEM_ERROR, "sys_mdb_secure_decrypt_failed", "Failed to decrypt secure field");
697
+ }
698
+ }
699
+ /**
700
+ * Encrypt secure-prefixed fields in an object/array tree.
701
+ * Only keys with secure prefixes are encrypted; other keys are recursed to allow nested secure keys.
702
+ */
703
+ encryptSecureFieldsDeep(val, aadPrefix) {
704
+ if (!this.secureFieldPrefixes.length)
705
+ return val;
706
+ if (!this.hasSecureEncryption())
707
+ return val;
708
+ if (val === null || val === undefined)
709
+ return val;
710
+ if (Array.isArray(val)) {
711
+ return val.map((x) => this.encryptSecureFieldsDeep(x, aadPrefix));
712
+ }
713
+ if (typeof val !== "object")
714
+ return val;
715
+ const out = {};
716
+ for (const [k, v] of Object.entries(val)) {
717
+ if (this.isSecureFieldKey(k)) {
718
+ const aad = `${aadPrefix}|${k}`;
719
+ out[k] = this.encryptSecureValueToString(v, aad);
720
+ }
721
+ else {
722
+ out[k] = this.encryptSecureFieldsDeep(v, aadPrefix);
723
+ }
724
+ }
725
+ return out;
726
+ }
727
+ /**
728
+ * Decrypt secure-prefixed fields in an object/array tree.
729
+ * If a secure field value is not an encrypted string, it is returned as-is.
730
+ */
731
+ decryptSecureFieldsDeep(val, aadPrefix) {
732
+ if (!this.secureFieldPrefixes.length)
733
+ return val;
734
+ if (!this.hasSecureEncryption())
735
+ return val;
736
+ if (val === null || val === undefined)
737
+ return val;
738
+ if (Array.isArray(val)) {
739
+ return val.map((x) => this.decryptSecureFieldsDeep(x, aadPrefix));
740
+ }
741
+ if (typeof val !== "object")
742
+ return val;
743
+ const out = {};
744
+ for (const [k, v] of Object.entries(val)) {
745
+ if (this.isSecureFieldKey(k)) {
746
+ const aad = `${aadPrefix}|${k}`;
747
+ out[k] = typeof v === "string" ? this.decryptSecureStringToValue(v, aad) : v;
748
+ }
749
+ else {
750
+ out[k] = this.decryptSecureFieldsDeep(v, aadPrefix);
751
+ }
752
+ }
753
+ return out;
754
+ }
755
+ /**
756
+ * Throws if an aggregation pipeline references any secure-prefixed field paths.
757
+ * This prevents deriving output from secure fields (e.g. {$group: {x: {$sum: "$#passport_number"}}}).
758
+ */
759
+ assertNoSecureFieldRefsInPipeline(pipeline) {
760
+ if (!this.secureFieldPrefixes.length)
761
+ return;
762
+ if (!Array.isArray(pipeline))
763
+ return;
764
+ if (this.containsSecureFieldRefDeep(pipeline)) {
765
+ throw new K2Error(ServiceError.BAD_REQUEST, "Aggregation pipeline references secure-prefixed field(s)", "sys_mdb_ag_secure_field_ref");
766
+ }
767
+ }
768
+ /** Recursively detects secure field references in an aggregation pipeline AST. */
769
+ containsSecureFieldRefDeep(val) {
770
+ if (!this.secureFieldPrefixes.length)
771
+ return false;
772
+ if (val === null || val === undefined)
773
+ return false;
774
+ if (Array.isArray(val)) {
775
+ return val.some((x) => this.containsSecureFieldRefDeep(x));
776
+ }
777
+ if (typeof val === "string") {
778
+ return this.stringHasSecureFieldPath(val);
779
+ }
780
+ if (typeof val !== "object")
781
+ return false;
782
+ for (const [k, v] of Object.entries(val)) {
783
+ // Object keys can be field names in some expressions; treat them as potential paths too.
784
+ if (this.stringHasSecureFieldPath(k))
785
+ return true;
786
+ if (this.containsSecureFieldRefDeep(v))
787
+ return true;
788
+ }
789
+ return false;
790
+ }
791
+ /**
792
+ * Returns true if a string appears to reference a secure field path.
793
+ * We consider:
794
+ * - "$path.to.field"
795
+ * - "$$var.path.to.field"
796
+ * - plain "path.to.field" (e.g. $getField: { field: "#passport_number" })
797
+ */
798
+ stringHasSecureFieldPath(s) {
799
+ if (!this.secureFieldPrefixes.length)
800
+ return false;
801
+ if (typeof s !== "string")
802
+ return false;
803
+ const raw = s.trim();
804
+ if (!raw)
805
+ return false;
806
+ // Handle "$$var.path" form (aggregation variables)
807
+ if (raw.startsWith("$$")) {
808
+ const after = raw.slice(2);
809
+ const dot = after.indexOf(".");
810
+ if (dot === -1)
811
+ return false; // just a variable name
812
+ const path = after.slice(dot + 1);
813
+ return this.pathHasSecureSegment(path);
814
+ }
815
+ // Handle "$path" form (field paths)
816
+ if (raw.startsWith("$")) {
817
+ const path = raw.slice(1);
818
+ return this.pathHasSecureSegment(path);
819
+ }
820
+ // Handle plain "path" strings (e.g. $getField field names)
821
+ return this.pathHasSecureSegment(raw);
822
+ }
823
+ /** True if any segment in a dotted path starts with a secure prefix. */
824
+ pathHasSecureSegment(path) {
825
+ const p = path.trim();
826
+ if (!p)
827
+ return false;
828
+ const segments = p.split(".");
829
+ for (const seg of segments) {
830
+ if (!seg)
831
+ continue;
832
+ if (this.isSecureFieldKey(seg))
833
+ return true;
834
+ }
835
+ return false;
836
+ }
837
+ /**
838
+ * Ensures an aggregation pipeline respects ownership scope for the root
839
+ * collection and any joined collections ($lookup, $unionWith, $graphLookup, $facet).
840
+ */
841
+ enforceScopeInPipeline(pipeline, scope) {
842
+ const normalizedScope = this.normalizeScope(scope);
843
+ // Strict mode requires an explicit scope per call.
844
+ if (this.ownershipMode === "strict" && !normalizedScope) {
845
+ throw new K2Error(ServiceError.BAD_REQUEST, "Scope is required in strict ownership mode", "sys_mdb_scope_required");
846
+ }
847
+ // Lax mode with no scope, or explicit all-scope: no owner constraint injected.
848
+ if (!normalizedScope || normalizedScope === "*") {
849
+ return Array.isArray(pipeline) ? pipeline.map((s) => ({ ...s })) : [];
850
+ }
851
+ const cloned = Array.isArray(pipeline) ? pipeline.map((s) => ({ ...s })) : [];
852
+ // Insert a $match to constrain owner near the start, but after any
853
+ // first-stage-only operators like $search, $geoNear, $vectorSearch.
854
+ const reservedFirst = ["$search", "$geoNear", "$vectorSearch"];
855
+ let insertIdx = 0;
856
+ while (insertIdx < cloned.length &&
857
+ typeof cloned[insertIdx] === "object" &&
858
+ cloned[insertIdx] !== null &&
859
+ Object.keys(cloned[insertIdx]).length === 1 &&
860
+ reservedFirst.includes(Object.keys(cloned[insertIdx])[0])) {
861
+ insertIdx++;
862
+ }
863
+ const ownerMatch = { $match: { _owner: normalizedScope } };
864
+ cloned.splice(insertIdx, 0, ownerMatch);
865
+ const mapStage = (stage) => {
866
+ if (!stage || typeof stage !== "object")
867
+ return stage;
868
+ if (stage.$lookup) {
869
+ const lu = { ...stage.$lookup };
870
+ if (Array.isArray(lu.pipeline)) {
871
+ lu.pipeline = this.enforceScopeInPipeline(lu.pipeline, normalizedScope);
872
+ }
873
+ else if (lu.localField && lu.foreignField) {
874
+ // Convert simple lookup to pipeline lookup so we can enforce owner scope (and deleted) in foreign coll.
875
+ const localVar = "__lk";
876
+ const ownerVar = "__own";
877
+ lu.let = { [localVar]: `$${lu.localField}`, [ownerVar]: normalizedScope };
878
+ lu.pipeline = [
879
+ {
880
+ $match: {
881
+ $expr: {
882
+ $and: [
883
+ {
884
+ $cond: [
885
+ { $isArray: "$$" + localVar },
886
+ { $in: ["$" + lu.foreignField, "$$" + localVar] },
887
+ { $eq: ["$" + lu.foreignField, "$$" + localVar] },
888
+ ],
889
+ },
890
+ { $eq: ["$_owner", "$$" + ownerVar] },
891
+ { $ne: ["$_deleted", true] },
892
+ ],
893
+ },
894
+ },
895
+ },
896
+ ];
897
+ delete lu.localField;
898
+ delete lu.foreignField;
899
+ }
900
+ return { $lookup: lu };
901
+ }
902
+ if (stage.$unionWith) {
903
+ const uw = stage.$unionWith;
904
+ if (typeof uw === "string") {
905
+ return {
906
+ $unionWith: {
907
+ coll: uw,
908
+ pipeline: [
909
+ { $match: { _owner: normalizedScope } },
910
+ { $match: { _deleted: { $ne: true } } },
911
+ ],
912
+ },
913
+ };
914
+ }
915
+ else if (uw && typeof uw === "object") {
916
+ const uwc = { ...uw };
917
+ uwc.pipeline = this.enforceScopeInPipeline(uwc.pipeline || [], normalizedScope);
918
+ return { $unionWith: uwc };
919
+ }
920
+ }
921
+ if (stage.$graphLookup) {
922
+ const gl = { ...stage.$graphLookup };
923
+ const existing = gl.restrictSearchWithMatch || {};
924
+ gl.restrictSearchWithMatch = { ...existing, _owner: normalizedScope };
925
+ return { $graphLookup: gl };
926
+ }
927
+ if (stage.$facet) {
928
+ const facets = { ...stage.$facet };
929
+ for (const key of Object.keys(facets)) {
930
+ facets[key] = this.enforceScopeInPipeline(facets[key] || [], normalizedScope);
931
+ }
932
+ return { $facet: facets };
933
+ }
934
+ return stage;
935
+ };
936
+ return cloned.map(mapStage);
937
+ }
434
938
  /**
435
939
  * Ensures an aggregation pipeline excludes soft-deleted documents for the root
436
940
  * collection and any joined collections ($lookup, $unionWith, $graphLookup, $facet).
@@ -469,7 +973,13 @@ export class K2DB {
469
973
  $match: {
470
974
  $expr: {
471
975
  $and: [
472
- { $eq: ["$" + lu.foreignField, "$$" + localVar] },
976
+ {
977
+ $cond: [
978
+ { $isArray: "$$" + localVar },
979
+ { $in: ["$" + lu.foreignField, "$$" + localVar] },
980
+ { $eq: ["$" + lu.foreignField, "$$" + localVar] },
981
+ ],
982
+ },
473
983
  { $ne: ["$_deleted", true] },
474
984
  ],
475
985
  },
@@ -527,6 +1037,13 @@ export class K2DB {
527
1037
  if (typeof owner !== "string") {
528
1038
  throw new K2Error(ServiceError.BAD_REQUEST, "Owner must be of a string type", "sys_mdb_crv2");
529
1039
  }
1040
+ const normalizedOwner = owner.trim().toLowerCase();
1041
+ if (!normalizedOwner) {
1042
+ throw new K2Error(ServiceError.BAD_REQUEST, "Owner must be a non-empty string", "sys_mdb_owner_empty");
1043
+ }
1044
+ if (normalizedOwner === "*") {
1045
+ throw new K2Error(ServiceError.BAD_REQUEST, "Owner cannot be '*'", "sys_mdb_owner_star");
1046
+ }
530
1047
  const collection = await this.getCollection(collectionName);
531
1048
  const timestamp = Date.now();
532
1049
  // Generate a new UUIDv7 encoded as Crockford Base32 with hyphens
@@ -534,12 +1051,13 @@ export class K2DB {
534
1051
  // Remove reserved fields from user data, then validate/transform via schema if present
535
1052
  const safeData = K2DB.stripReservedFields(data);
536
1053
  const validated = this.applySchema(collectionName, safeData, /*partial*/ false);
1054
+ const storedData = this.encryptSecureFieldsDeep(validated, `k2db|${collectionName}|${newUuid}`);
537
1055
  // Spread validated data first, then set internal fields to prevent overwriting
538
1056
  const document = {
539
- ...validated,
1057
+ ...storedData,
540
1058
  _created: timestamp,
541
1059
  _updated: timestamp,
542
- _owner: owner,
1060
+ _owner: normalizedOwner,
543
1061
  _uuid: newUuid,
544
1062
  };
545
1063
  try {
@@ -564,8 +1082,9 @@ export class K2DB {
564
1082
  * @param collectionName - Name of the collection.
565
1083
  * @param criteria - Update criteria.
566
1084
  * @param values - Values to update or replace with.
1085
+ * @param scope - (optional) Owner selector; "*" means all owners.
567
1086
  */
568
- async updateAll(collectionName, criteria, values) {
1087
+ async updateAll(collectionName, criteria, values, scope) {
569
1088
  this.validateCollectionName(collectionName);
570
1089
  const collection = await this.getCollection(collectionName);
571
1090
  debug(`Updating ${collectionName} with criteria: ${JSON.stringify(criteria)}`);
@@ -582,7 +1101,10 @@ export class K2DB {
582
1101
  if (deletedFlag !== undefined) {
583
1102
  values._deleted = deletedFlag;
584
1103
  }
1104
+ // Encrypt secure-prefixed fields at rest (no-op unless encryption is configured)
1105
+ values = this.encryptSecureFieldsDeep(values, `k2db|${collectionName}`);
585
1106
  criteria = K2DB.normalizeCriteriaIds(criteria || {});
1107
+ criteria = this.applyScopeToCriteria(criteria, scope);
586
1108
  criteria = {
587
1109
  ...criteria,
588
1110
  _deleted: { $ne: true },
@@ -604,20 +1126,26 @@ export class K2DB {
604
1126
  * @param id - UUID string to identify the document.
605
1127
  * @param data - Data to update or replace with.
606
1128
  * @param replace - If true, replaces the entire document (PUT), otherwise patches (PATCH).
1129
+ * @param scope - (optional) Owner selector; "*" means all owners.
607
1130
  */
608
- async update(collectionName, id, data, replace = false) {
1131
+ async update(collectionName, id, data, replace = false, scope) {
609
1132
  id = K2DB.normalizeId(id);
610
1133
  this.validateCollectionName(collectionName);
611
1134
  const collection = await this.getCollection(collectionName);
612
1135
  data = K2DB.stripReservedFields(data);
613
1136
  data = this.applySchema(collectionName, data, /*partial*/ !replace);
614
1137
  data._updated = Date.now(); // Set the _updated timestamp
1138
+ // PATCH path: encrypt secure-prefixed fields at rest (no-op unless encryption is configured).
1139
+ // REPLACE path: encrypt after underscore-field merge to avoid double-encrypt.
1140
+ if (!replace) {
1141
+ data = this.encryptSecureFieldsDeep(data, `k2db|${collectionName}|${id}`);
1142
+ }
615
1143
  try {
616
1144
  let res;
617
1145
  // If replacing the document, first get the original document
618
1146
  if (replace) {
619
1147
  // Get the original document to preserve fields starting with underscore
620
- const originalDoc = await this.get(collectionName, id);
1148
+ const originalDoc = await this.get(collectionName, id, scope);
621
1149
  // Override all fields starting with underscore from the original document
622
1150
  const fieldsToPreserve = Object.keys(originalDoc).reduce((acc, key) => {
623
1151
  if (key.startsWith("_")) {
@@ -628,12 +1156,14 @@ export class K2DB {
628
1156
  // Merge the preserved fields into the data
629
1157
  data = { ...data, ...fieldsToPreserve };
630
1158
  data._updated = Date.now();
1159
+ // Encrypt secure-prefixed fields at rest (no-op unless encryption is configured)
1160
+ data = this.encryptSecureFieldsDeep(data, `k2db|${collectionName}|${id}`);
631
1161
  // Now replace the document with the merged data
632
- res = await this.runTimed("replaceOne", { collectionName, _uuid: id }, async () => await collection.replaceOne({ _uuid: id, _deleted: { $ne: true } }, data));
1162
+ res = await this.runTimed("replaceOne", { collectionName, _uuid: id }, async () => await collection.replaceOne(this.applyScopeToCriteria({ _uuid: id, _deleted: { $ne: true } }, scope), data));
633
1163
  }
634
1164
  else {
635
1165
  // If patching, just update specific fields using $set
636
- res = await this.runTimed("updateOne", { collectionName, _uuid: id }, async () => await collection.updateOne({ _uuid: id, _deleted: { $ne: true } }, { $set: data }));
1166
+ res = await this.runTimed("updateOne", { collectionName, _uuid: id }, async () => await collection.updateOne(this.applyScopeToCriteria({ _uuid: id, _deleted: { $ne: true } }, scope), { $set: data }));
637
1167
  }
638
1168
  // Use matchedCount to determine existence; modifiedCount indicates actual change
639
1169
  if (res.matchedCount === 0) {
@@ -649,13 +1179,14 @@ export class K2DB {
649
1179
  * Removes (soft deletes) multiple documents based on criteria.
650
1180
  * @param collectionName - Name of the collection.
651
1181
  * @param criteria - Removal criteria.
1182
+ * @param scope - (optional) Owner selector; "*" means all owners.
652
1183
  */
653
- async deleteAll(collectionName, criteria) {
1184
+ async deleteAll(collectionName, criteria, scope) {
654
1185
  this.validateCollectionName(collectionName);
655
1186
  try {
656
1187
  const result = await this.updateAll(collectionName, criteria, {
657
1188
  _deleted: true,
658
- });
1189
+ }, scope);
659
1190
  return { deleted: result.updated };
660
1191
  }
661
1192
  catch (err) {
@@ -666,12 +1197,13 @@ export class K2DB {
666
1197
  * Removes (soft deletes) a single document by UUID.
667
1198
  * @param collectionName - Name of the collection.
668
1199
  * @param id - UUID of the document.
1200
+ * @param scope - (optional) Owner selector; "*" means all owners.
669
1201
  */
670
- async delete(collectionName, id) {
1202
+ async delete(collectionName, id, scope) {
671
1203
  id = K2DB.normalizeId(id);
672
1204
  try {
673
1205
  // Call deleteAll to soft delete the document by UUID
674
- const result = await this.deleteAll(collectionName, { _uuid: id });
1206
+ const result = await this.deleteAll(collectionName, { _uuid: id }, scope);
675
1207
  // Check the result of the deleteAll operation
676
1208
  if (result.deleted === 1) {
677
1209
  // Successfully deleted one document
@@ -694,19 +1226,19 @@ export class K2DB {
694
1226
  * Permanently deletes a document that has been soft-deleted.
695
1227
  * @param collectionName - Name of the collection.
696
1228
  * @param id - UUID of the document.
1229
+ * @param scope - (optional) Owner selector; "*" means all owners.
697
1230
  */
698
- async purge(collectionName, id) {
1231
+ async purge(collectionName, id, scope) {
699
1232
  id = K2DB.normalizeId(id);
700
1233
  const collection = await this.getCollection(collectionName);
701
1234
  try {
702
- const item = await this.runTimed("findOne", { collectionName, _uuid: id, _deleted: true }, async () => await collection.findOne({
703
- _uuid: id,
704
- _deleted: true,
705
- }));
1235
+ const findFilter = this.applyScopeToCriteria({ _uuid: id, _deleted: true }, scope);
1236
+ const item = await this.runTimed("findOne", { collectionName, ...findFilter }, async () => await collection.findOne(findFilter));
706
1237
  if (!item) {
707
1238
  throw new K2Error(ServiceError.SYSTEM_ERROR, "Cannot purge item that is not deleted", "sys_mdb_gcol_pg2");
708
1239
  }
709
- await this.runTimed("deleteOne", { collectionName, _uuid: id }, async () => await collection.deleteOne({ _uuid: id }));
1240
+ const delFilter = this.applyScopeToCriteria({ _uuid: id }, scope);
1241
+ await this.runTimed("deleteOne", { collectionName, ...delFilter }, async () => await collection.deleteOne(delFilter));
710
1242
  return { id };
711
1243
  }
712
1244
  catch (err) {
@@ -719,8 +1251,9 @@ export class K2DB {
719
1251
  * @param collectionName - Name of the collection.
720
1252
  * @param olderThanMs - Age threshold in milliseconds; documents with
721
1253
  * `_updated <= (Date.now() - olderThanMs)` will be purged.
1254
+ * @param scope - (optional) Owner selector; "*" means all owners.
722
1255
  */
723
- async purgeDeletedOlderThan(collectionName, olderThanMs) {
1256
+ async purgeDeletedOlderThan(collectionName, olderThanMs, scope) {
724
1257
  this.validateCollectionName(collectionName);
725
1258
  if (typeof olderThanMs !== 'number' || !isFinite(olderThanMs) || olderThanMs < 0) {
726
1259
  throw new K2Error(ServiceError.BAD_REQUEST, 'olderThanMs must be a non-negative number', 'sys_mdb_purge_older_invalid');
@@ -728,10 +1261,11 @@ export class K2DB {
728
1261
  const collection = await this.getCollection(collectionName);
729
1262
  const cutoff = Date.now() - olderThanMs;
730
1263
  try {
731
- const res = await this.runTimed('deleteMany', { collectionName, olderThanMs, cutoff }, async () => await collection.deleteMany({
1264
+ const delFilter = this.applyScopeToCriteria({
732
1265
  _deleted: true,
733
1266
  _updated: { $lte: cutoff },
734
- }));
1267
+ }, scope);
1268
+ const res = await this.runTimed('deleteMany', { collectionName, olderThanMs, cutoff, ...delFilter }, async () => await collection.deleteMany(delFilter));
735
1269
  return { purged: res.deletedCount ?? 0 };
736
1270
  }
737
1271
  catch (err) {
@@ -742,10 +1276,12 @@ export class K2DB {
742
1276
  * Restores a soft-deleted document.
743
1277
  * @param collectionName - Name of the collection.
744
1278
  * @param criteria - Criteria to identify the document.
1279
+ * @param scope - (optional) Owner selector; "*" means all owners.
745
1280
  */
746
- async restore(collectionName, criteria) {
1281
+ async restore(collectionName, criteria, scope) {
747
1282
  const collection = await this.getCollection(collectionName);
748
- const crit = K2DB.normalizeCriteriaIds(criteria || {});
1283
+ let crit = K2DB.normalizeCriteriaIds(criteria || {});
1284
+ crit = this.applyScopeToCriteria(crit, scope);
749
1285
  const query = { ...crit, _deleted: true };
750
1286
  try {
751
1287
  const res = await this.runTimed("updateMany", { collectionName, query }, async () => await collection.updateMany(query, {
@@ -762,17 +1298,19 @@ export class K2DB {
762
1298
  * Counts documents based on criteria.
763
1299
  * @param collectionName - Name of the collection.
764
1300
  * @param criteria - Counting criteria.
1301
+ * @param scope - (optional) Owner selector; "*" means all owners.
765
1302
  */
766
- async count(collectionName, criteria) {
1303
+ async count(collectionName, criteria, scope) {
767
1304
  const collection = await this.getCollection(collectionName);
768
1305
  try {
769
1306
  const norm = K2DB.normalizeCriteriaIds(criteria || {});
770
- const query = {
1307
+ let query = {
771
1308
  ...norm,
772
1309
  ...(criteria && Object.prototype.hasOwnProperty.call(criteria, "_deleted")
773
1310
  ? {}
774
1311
  : { _deleted: { $ne: true } }),
775
1312
  };
1313
+ query = this.applyScopeToCriteria(query, scope);
776
1314
  const cnt = await this.runTimed("countDocuments", { collectionName, query }, async () => await collection.countDocuments(query));
777
1315
  return { count: cnt };
778
1316
  }
@@ -781,11 +1319,21 @@ export class K2DB {
781
1319
  }
782
1320
  }
783
1321
  /**
784
- * Drops an entire collection.
1322
+ * Drops an entire collection (global destructive operation).
785
1323
  * @param collectionName - Name of the collection.
1324
+ * @param scope - (optional) Must be "*" in strict ownership mode.
786
1325
  */
787
- async drop(collectionName) {
1326
+ async drop(collectionName, scope) {
788
1327
  const collection = await this.getCollection(collectionName);
1328
+ const normalizedScope = this.normalizeScope(scope);
1329
+ // Global destructive operation: require explicit all-scope gate in strict mode.
1330
+ if (this.ownershipMode === "strict" && normalizedScope !== "*") {
1331
+ throw new K2Error(ServiceError.BAD_REQUEST, 'Dropping a collection requires scope="*" in strict ownership mode', "sys_mdb_drop_scope_required");
1332
+ }
1333
+ // In lax mode, allow legacy behavior when scope is omitted; if provided, it must be "*".
1334
+ if (this.ownershipMode === "lax" && normalizedScope !== undefined && normalizedScope !== "*") {
1335
+ throw new K2Error(ServiceError.BAD_REQUEST, 'Dropping a collection only supports scope="*"', "sys_mdb_drop_scope_invalid");
1336
+ }
789
1337
  try {
790
1338
  await collection.drop();
791
1339
  return { status: "ok" };
@@ -817,7 +1365,7 @@ export class K2DB {
817
1365
  }
818
1366
  return criteria;
819
1367
  }
820
- /** Recursively uppercases any values for fields named `_uuid` within a query object. */
1368
+ /** Recursively normalizes query fields: `_uuid` uppercased, `_owner` lowercased. */
821
1369
  static normalizeCriteriaIds(obj) {
822
1370
  if (!obj || typeof obj !== "object")
823
1371
  return obj;
@@ -828,6 +1376,9 @@ export class K2DB {
828
1376
  if (k === "_uuid") {
829
1377
  out[k] = K2DB.normalizeUuidField(v);
830
1378
  }
1379
+ else if (k === "_owner") {
1380
+ out[k] = K2DB.normalizeOwnerField(v);
1381
+ }
831
1382
  else if (v && typeof v === "object") {
832
1383
  out[k] = K2DB.normalizeCriteriaIds(v);
833
1384
  }
@@ -856,6 +1407,23 @@ export class K2DB {
856
1407
  }
857
1408
  return val;
858
1409
  }
1410
+ /** Lowercase helper for `_owner` field supporting operators like $in/$nin/$eq/$ne and arrays. */
1411
+ static normalizeOwnerField(val) {
1412
+ if (typeof val === "string")
1413
+ return val.trim().toLowerCase();
1414
+ if (Array.isArray(val)) {
1415
+ return val.map((x) => (typeof x === "string" ? x.trim().toLowerCase() : x));
1416
+ }
1417
+ if (val && typeof val === "object") {
1418
+ const out = { ...val };
1419
+ for (const op of ["$in", "$nin", "$eq", "$ne", "$all"]) {
1420
+ if (op in out)
1421
+ out[op] = K2DB.normalizeOwnerField(out[op]);
1422
+ }
1423
+ return out;
1424
+ }
1425
+ return val;
1426
+ }
859
1427
  /** Strip any user-provided fields that start with '_' (reserved). */
860
1428
  static stripReservedFields(obj) {
861
1429
  const out = {};
@@ -1095,26 +1663,33 @@ export class K2DB {
1095
1663
  async snapshotCurrent(collectionName, current) {
1096
1664
  const hc = await this.getHistoryCollection(collectionName);
1097
1665
  const version = await this.nextVersion(collectionName, current._uuid);
1666
+ const storedSnapshot = this.encryptSecureFieldsDeep(current, `k2db|${collectionName}|${current._uuid}`);
1098
1667
  await hc.insertOne({
1099
1668
  _uuid: current._uuid,
1100
1669
  _v: version,
1101
1670
  _at: Date.now(),
1102
- snapshot: current,
1671
+ snapshot: storedSnapshot,
1103
1672
  });
1104
1673
  return { version };
1105
1674
  }
1106
1675
  /**
1107
1676
  * Update a document and keep the previous version in a history collection.
1108
1677
  * If maxVersions is provided, prunes oldest snapshots beyond that number.
1678
+ * @param collectionName - Name of the collection.
1679
+ * @param id - UUID string to identify the document.
1680
+ * @param data - Data to update or replace with.
1681
+ * @param replace - If true, replaces the entire document (PUT), otherwise patches (PATCH).
1682
+ * @param maxVersions - Maximum number of versions to keep (optional).
1683
+ * @param scope - (optional) Owner selector; "*" means all owners.
1109
1684
  */
1110
- async updateVersioned(collectionName, id, data, replace = false, maxVersions) {
1685
+ async updateVersioned(collectionName, id, data, replace = false, maxVersions, scope) {
1111
1686
  id = K2DB.normalizeId(id);
1112
1687
  // Get current doc (excludes deleted) and snapshot it
1113
- const current = await this.get(collectionName, id);
1688
+ const current = await this.get(collectionName, id, scope);
1114
1689
  await this.ensureHistoryIndexes(collectionName);
1115
1690
  const { version } = await this.snapshotCurrent(collectionName, current);
1116
1691
  // Perform update
1117
- const res = await this.update(collectionName, id, data, replace);
1692
+ const res = await this.update(collectionName, id, data, replace, scope);
1118
1693
  // Optionally prune old versions
1119
1694
  if (typeof maxVersions === "number" && maxVersions >= 0) {
1120
1695
  const hc = await this.getHistoryCollection(collectionName);
@@ -1135,9 +1710,18 @@ export class K2DB {
1135
1710
  }
1136
1711
  return [{ updated: res.updated, versionSaved: version }];
1137
1712
  }
1138
- /** List versions (latest first). */
1139
- async listVersions(collectionName, id, skip = 0, limit = 20) {
1713
+ /**
1714
+ * List versions (latest first).
1715
+ * @param collectionName - Name of the collection.
1716
+ * @param id - UUID string to identify the document.
1717
+ * @param skip - Number of versions to skip (for pagination).
1718
+ * @param limit - Maximum number of versions to return.
1719
+ * @param scope - (optional) Owner selector; "*" means all owners.
1720
+ */
1721
+ async listVersions(collectionName, id, skip = 0, limit = 20, scope) {
1140
1722
  id = K2DB.normalizeId(id);
1723
+ // Gate history access by ownership of the current document
1724
+ await this.get(collectionName, id, scope);
1141
1725
  const hc = await this.getHistoryCollection(collectionName);
1142
1726
  const rows = await hc
1143
1727
  .find({ _uuid: id })
@@ -1148,21 +1732,30 @@ export class K2DB {
1148
1732
  .toArray();
1149
1733
  return rows;
1150
1734
  }
1151
- /** Revert the current document to a specific historical version (preserves metadata). */
1152
- async revertToVersion(collectionName, id, version) {
1735
+ /**
1736
+ * Revert the current document to a specific historical version (preserves metadata).
1737
+ * @param collectionName - Name of the collection.
1738
+ * @param id - UUID string to identify the document.
1739
+ * @param version - Version number to revert to.
1740
+ * @param scope - (optional) Owner selector; "*" means all owners.
1741
+ */
1742
+ async revertToVersion(collectionName, id, version, scope) {
1153
1743
  id = K2DB.normalizeId(id);
1744
+ // Gate history access by ownership of the current document
1745
+ await this.get(collectionName, id, scope);
1154
1746
  const hc = await this.getHistoryCollection(collectionName);
1155
1747
  const row = await hc.findOne({ _uuid: id, _v: version });
1156
1748
  if (!row) {
1157
1749
  throw new K2Error(ServiceError.NOT_FOUND, `Version ${version} for ${id} not found`, "sys_mdb_version_not_found");
1158
1750
  }
1159
1751
  const snapshot = row.snapshot;
1752
+ const snapshotPlain = this.decryptSecureFieldsDeep(snapshot, `k2db|${collectionName}|${id}`);
1160
1753
  // Only apply non-underscore fields; metadata is preserved by replace=true path
1161
1754
  const apply = {};
1162
- for (const [k, v] of Object.entries(snapshot)) {
1755
+ for (const [k, v] of Object.entries(snapshotPlain)) {
1163
1756
  if (!k.startsWith("_"))
1164
1757
  apply[k] = v;
1165
1758
  }
1166
- return this.update(collectionName, id, apply, true);
1759
+ return this.update(collectionName, id, apply, true, scope);
1167
1760
  }
1168
1761
  }