@utilarium/overcontext 0.0.4-dev.0 → 0.0.5-dev.0

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/dist/index.cjs CHANGED
@@ -43,7 +43,9 @@ const yaml__namespace = /*#__PURE__*/_interopNamespaceDefault(yaml);
43
43
  * The minimal contract every entity must satisfy.
44
44
  * Consuming libraries extend this with their own fields.
45
45
  */ const BaseEntitySchema = zod.z.object({
46
- /** Unique identifier within the entity type (used as filename) */ id: zod.z.string().min(1),
46
+ /** Unique identifier within the entity type (used as filename) */ id: zod.z.string().min(1).max(255).regex(/^[a-zA-Z0-9][-a-zA-Z0-9_.]*$/, {
47
+ message: 'ID must be filesystem-safe: start with alphanumeric and contain only alphanumeric, hyphens, underscores, and dots'
48
+ }),
47
49
  /** Human-readable name (used for display and search) */ name: zod.z.string().min(1),
48
50
  /** Entity type discriminator (must be a string literal in extensions) */ type: zod.z.string().min(1),
49
51
  /** Optional notes - common enough to include in base */ notes: zod.z.string().optional()
@@ -344,6 +346,7 @@ class NamespaceNotFoundError extends StorageError {
344
346
  type: 'storage:disposed',
345
347
  timestamp: new Date()
346
348
  });
349
+ handlers.clear(); // Clear all handlers to prevent memory leaks
347
350
  await provider.dispose();
348
351
  },
349
352
  async save (entity, namespace) {
@@ -411,18 +414,78 @@ class NamespaceNotFoundError extends StorageError {
411
414
  const createFileSystemProvider = async (options)=>{
412
415
  const { basePath, registry, createIfMissing = true, extension = '.yaml', readonly = false, defaultNamespace } = options;
413
416
  // --- Helper Functions ---
417
+ /**
418
+ * Sanitize a path component to prevent directory traversal attacks.
419
+ * Rejects path separators, parent directory references, and other unsafe characters.
420
+ */ const sanitizePathComponent = (input, componentName)=>{
421
+ // Reject path separators and parent directory references
422
+ if (input.includes('/') || input.includes('\\') || input.includes('..')) {
423
+ throw new ValidationError(`Invalid characters in ${componentName}: cannot contain path separators or ".."`, [
424
+ {
425
+ path: componentName,
426
+ message: 'Path component cannot contain path separators or ".."'
427
+ }
428
+ ]);
429
+ }
430
+ // Reject null bytes and control characters
431
+ if (input.includes('\0')) {
432
+ throw new ValidationError(`Invalid characters in ${componentName}: cannot contain null bytes`, [
433
+ {
434
+ path: componentName,
435
+ message: 'Path component cannot contain null bytes'
436
+ }
437
+ ]);
438
+ }
439
+ // Check for control characters (0x00-0x1F and 0x7F)
440
+ for(let i = 0; i < input.length; i++){
441
+ const code = input.charCodeAt(i);
442
+ if (code >= 0 && code <= 0x1F || code === 0x7F) {
443
+ throw new ValidationError(`Invalid characters in ${componentName}: cannot contain control characters`, [
444
+ {
445
+ path: componentName,
446
+ message: 'Path component cannot contain control characters'
447
+ }
448
+ ]);
449
+ }
450
+ }
451
+ return input;
452
+ };
453
+ /**
454
+ * Verify that a resolved path stays within the basePath to prevent path traversal.
455
+ */ const verifyPathWithinBase = (resolvedPath)=>{
456
+ const resolvedBase = path__namespace.resolve(basePath);
457
+ const resolvedTarget = path__namespace.resolve(resolvedPath);
458
+ if (!resolvedTarget.startsWith(resolvedBase + path__namespace.sep) && resolvedTarget !== resolvedBase) {
459
+ throw new StorageAccessError('Path traversal attempt detected');
460
+ }
461
+ };
414
462
  const getEntityDir = (type, namespace)=>{
415
463
  const dirName = registry.getDirectoryName(type);
416
464
  if (!dirName) {
417
465
  throw new SchemaNotRegisteredError(type);
418
466
  }
419
- if (namespace) {
420
- return path__namespace.join(basePath, namespace, dirName);
467
+ // Sanitize namespace if provided
468
+ const safeNamespace = namespace ? sanitizePathComponent(namespace, 'namespace') : undefined;
469
+ // Sanitize directory name (should already be safe from registry, but double-check)
470
+ const safeDirName = sanitizePathComponent(dirName, 'directoryName');
471
+ let dir;
472
+ if (safeNamespace) {
473
+ dir = path__namespace.join(basePath, safeNamespace, safeDirName);
474
+ } else {
475
+ dir = path__namespace.join(basePath, safeDirName);
421
476
  }
422
- return path__namespace.join(basePath, dirName);
477
+ // Verify the path stays within basePath
478
+ verifyPathWithinBase(dir);
479
+ return dir;
423
480
  };
424
481
  const getEntityPath = (type, id, namespace)=>{
425
- return path__namespace.join(getEntityDir(type, namespace), `${id}${extension}`);
482
+ // Sanitize ID to prevent path traversal
483
+ const safeId = sanitizePathComponent(id, 'id');
484
+ const dir = getEntityDir(type, namespace);
485
+ const fullPath = path__namespace.join(dir, `${safeId}${extension}`);
486
+ // Verify the final path stays within basePath
487
+ verifyPathWithinBase(fullPath);
488
+ return fullPath;
426
489
  };
427
490
  const ensureDir = async (dir)=>{
428
491
  if (!node_fs.existsSync(dir) && createIfMissing && !readonly) {
@@ -445,9 +508,17 @@ const createFileSystemProvider = async (options)=>{
445
508
  if (!parsed || typeof parsed !== 'object') {
446
509
  return undefined;
447
510
  }
511
+ // Protect against prototype pollution from malicious YAML
512
+ // Only __proto__ is dangerous - constructor/prototype as string keys are safe
513
+ const parsedObj = parsed;
514
+ if (Object.prototype.hasOwnProperty.call(parsedObj, '__proto__')) {
515
+ // eslint-disable-next-line no-console
516
+ console.warn(`Potential prototype pollution attempt in ${filePath}: __proto__ key detected`);
517
+ return undefined;
518
+ }
448
519
  // Validate against registered schema
449
520
  const result = registry.validateAs(type, {
450
- ...parsed,
521
+ ...parsedObj,
451
522
  source: filePath
452
523
  });
453
524
  if (!result.success) {
@@ -672,18 +743,37 @@ const createFileSystemProvider = async (options)=>{
672
743
  return namespaces;
673
744
  },
674
745
  async namespaceExists (namespace) {
675
- const namespacePath = path__namespace.join(basePath, namespace);
746
+ // Sanitize namespace to prevent path traversal
747
+ const safeNamespace = sanitizePathComponent(namespace, 'namespace');
748
+ const namespacePath = path__namespace.join(basePath, safeNamespace);
749
+ // Verify path stays within basePath
750
+ verifyPathWithinBase(namespacePath);
676
751
  return node_fs.existsSync(namespacePath);
677
752
  },
678
753
  async listTypes (namespace) {
679
754
  const ns = namespace !== null && namespace !== void 0 ? namespace : defaultNamespace;
680
- const searchPath = ns ? path__namespace.join(basePath, ns) : basePath;
755
+ let searchPath;
756
+ if (ns) {
757
+ // Sanitize namespace to prevent path traversal
758
+ const safeNamespace = sanitizePathComponent(ns, 'namespace');
759
+ searchPath = path__namespace.join(basePath, safeNamespace);
760
+ verifyPathWithinBase(searchPath);
761
+ } else {
762
+ searchPath = basePath;
763
+ }
681
764
  return listDirectoryTypes(searchPath);
682
765
  }
683
766
  };
684
767
  return provider;
685
768
  };
686
769
 
770
+ /**
771
+ * Create a deep clone of an entity to prevent external mutation of stored data.
772
+ * Uses structured clone for proper handling of dates and nested objects.
773
+ */ const cloneEntity = (entity)=>{
774
+ // structuredClone handles Date objects properly
775
+ return structuredClone(entity);
776
+ };
687
777
  const createMemoryProvider = (options)=>{
688
778
  const { registry, readonly = false, defaultNamespace, initialData = [] } = options;
689
779
  // Storage: namespace -> type -> id -> entity
@@ -723,11 +813,14 @@ const createMemoryProvider = (options)=>{
723
813
  },
724
814
  async get (type, id, namespace) {
725
815
  const ns = resolveNamespace(namespace);
726
- return getTypeStore(ns, type).get(id);
816
+ const entity = getTypeStore(ns, type).get(id);
817
+ // Return a defensive copy to prevent external mutation of stored data
818
+ return entity ? cloneEntity(entity) : undefined;
727
819
  },
728
820
  async getAll (type, namespace) {
729
821
  const ns = resolveNamespace(namespace);
730
- return Array.from(getTypeStore(ns, type).values());
822
+ // Return defensive copies to prevent external mutation
823
+ return Array.from(getTypeStore(ns, type).values()).map((e)=>cloneEntity(e));
731
824
  },
732
825
  async find (filter) {
733
826
  var _filter_ids;
@@ -774,8 +867,10 @@ const createMemoryProvider = (options)=>{
774
867
  createdAt: (existing === null || existing === void 0 ? void 0 : existing.createdAt) || now,
775
868
  updatedAt: now
776
869
  };
777
- getTypeStore(ns, entity.type).set(entity.id, saved);
778
- return saved;
870
+ // Store a cloned copy to prevent external mutation from affecting stored data
871
+ getTypeStore(ns, entity.type).set(entity.id, cloneEntity(saved));
872
+ // Return a clone so caller can't mutate stored data through returned reference
873
+ return cloneEntity(saved);
779
874
  },
780
875
  async delete (type, id, namespace) {
781
876
  if (readonly) {
@@ -861,6 +956,10 @@ const createMemoryProvider = (options)=>{
861
956
  return `${baseId}-${Date.now()}`;
862
957
  };
863
958
 
959
+ /**
960
+ * Maximum number of entities that can be loaded into memory during search.
961
+ * Prevents memory exhaustion with large datasets.
962
+ */ const MAX_SEARCH_ENTITIES = 10000;
864
963
  const createSearchEngine = (options)=>{
865
964
  const { provider, registry, defaultNamespace } = options;
866
965
  const textMatch = (text, query, caseSensitive)=>{
@@ -891,6 +990,23 @@ const createSearchEngine = (options)=>{
891
990
  }
892
991
  return false;
893
992
  };
993
+ /**
994
+ * Check if a value looks like an ISO date string.
995
+ */ const isISODateString = (value)=>{
996
+ if (typeof value !== 'string') return false;
997
+ // Match ISO 8601 date format (YAML deserializes dates as ISO strings)
998
+ return /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/.test(value);
999
+ };
1000
+ /**
1001
+ * Try to parse a value as a Date for comparison.
1002
+ */ const toDateValue = (value)=>{
1003
+ if (value instanceof Date) return value.getTime();
1004
+ if (isISODateString(value)) {
1005
+ const parsed = new Date(value);
1006
+ return isNaN(parsed.getTime()) ? null : parsed.getTime();
1007
+ }
1008
+ return null;
1009
+ };
894
1010
  const sortEntities = (entities, sort)=>{
895
1011
  return [
896
1012
  ...entities
@@ -902,10 +1018,13 @@ const createSearchEngine = (options)=>{
902
1018
  if (aVal === undefined || aVal === null) return direction === 'asc' ? 1 : -1;
903
1019
  if (bVal === undefined || bVal === null) return direction === 'asc' ? -1 : 1;
904
1020
  let cmp;
905
- if (typeof aVal === 'string' && typeof bVal === 'string') {
1021
+ // Try date comparison first (handles both Date objects and ISO strings)
1022
+ const aDate = toDateValue(aVal);
1023
+ const bDate = toDateValue(bVal);
1024
+ if (aDate !== null && bDate !== null) {
1025
+ cmp = aDate - bDate;
1026
+ } else if (typeof aVal === 'string' && typeof bVal === 'string') {
906
1027
  cmp = aVal.localeCompare(bVal);
907
- } else if (aVal instanceof Date && bVal instanceof Date) {
908
- cmp = aVal.getTime() - bVal.getTime();
909
1028
  } else {
910
1029
  cmp = aVal < bVal ? -1 : 1;
911
1030
  }
@@ -940,6 +1059,10 @@ const createSearchEngine = (options)=>{
940
1059
  allEntities = allEntities.concat(entities);
941
1060
  }
942
1061
  }
1062
+ // Check for memory exhaustion risk
1063
+ if (allEntities.length > MAX_SEARCH_ENTITIES) {
1064
+ throw new StorageAccessError(`Search returned too many results (${allEntities.length}). ` + `Please narrow your query by specifying types, namespaces, or search terms. ` + `Maximum allowed: ${MAX_SEARCH_ENTITIES} entities.`);
1065
+ }
943
1066
  // Apply ID filter
944
1067
  if (ids === null || ids === void 0 ? void 0 : ids.length) {
945
1068
  allEntities = allEntities.filter((e)=>ids.includes(e.id));
@@ -1139,21 +1262,28 @@ function _define_property(obj, key, value) {
1139
1262
  }
1140
1263
  /**
1141
1264
  * Set result limit.
1265
+ * Must be a positive integer (minimum 1).
1142
1266
  */ limit(n) {
1143
- this.options.limit = n;
1267
+ this.options.limit = Math.max(1, Math.floor(n));
1144
1268
  return this;
1145
1269
  }
1146
1270
  /**
1147
1271
  * Set result offset.
1272
+ * Must be a non-negative integer (minimum 0).
1148
1273
  */ offset(n) {
1149
- this.options.offset = n;
1274
+ this.options.offset = Math.max(0, Math.floor(n));
1150
1275
  return this;
1151
1276
  }
1152
1277
  /**
1153
1278
  * Set page (calculates offset from limit).
1279
+ * Page numbers are 1-indexed (first page is 1).
1154
1280
  */ page(pageNum, pageSize) {
1155
- this.options.limit = pageSize;
1156
- this.options.offset = (pageNum - 1) * pageSize;
1281
+ // Ensure valid page number (minimum 1)
1282
+ const safePage = Math.max(1, Math.floor(pageNum));
1283
+ // Ensure valid page size (minimum 1)
1284
+ const safeSize = Math.max(1, Math.floor(pageSize));
1285
+ this.options.limit = safeSize;
1286
+ this.options.offset = (safePage - 1) * safeSize;
1157
1287
  return this;
1158
1288
  }
1159
1289
  /**
@@ -1382,7 +1512,21 @@ const createDirectoryWalker = (options)=>{
1382
1512
  const discovered = [];
1383
1513
  let currentDir = path__namespace.resolve(startDir);
1384
1514
  let level = 0;
1515
+ const visited = new Set();
1385
1516
  while(level < maxLevels){
1517
+ // Resolve real path to handle symlinks and prevent cycles
1518
+ let realPath;
1519
+ try {
1520
+ realPath = await fs__namespace.realpath(currentDir);
1521
+ } catch {
1522
+ // If realpath fails, use resolved path (may happen with permissions)
1523
+ realPath = currentDir;
1524
+ }
1525
+ // Check for symlink cycles
1526
+ if (visited.has(realPath)) {
1527
+ break; // Already visited this real path, prevent infinite loop
1528
+ }
1529
+ visited.add(realPath);
1386
1530
  const contextDir = path__namespace.join(currentDir, contextDirName);
1387
1531
  if (node_fs.existsSync(contextDir)) {
1388
1532
  const { namespaces, types } = await getNamespacesAndTypes(contextDir);
@@ -1477,15 +1621,24 @@ const discoverContextRoot = async (options = {})=>{
1477
1621
  await primaryProvider.initialize();
1478
1622
  const findEntities = async (filter)=>{
1479
1623
  const byId = new Map();
1624
+ // Create a filter without pagination - we'll apply pagination after merging
1625
+ // This prevents double-pagination (sub-providers paginating, then us paginating again)
1626
+ const filterWithoutPagination = {
1627
+ type: filter.type,
1628
+ namespace: filter.namespace,
1629
+ ids: filter.ids,
1630
+ search: filter.search
1631
+ };
1480
1632
  for (const p of [
1481
1633
  ...readProviders
1482
1634
  ].reverse()){
1483
- const results = await p.find(filter);
1635
+ const results = await p.find(filterWithoutPagination);
1484
1636
  for (const entity of results){
1485
1637
  byId.set(entity.id, entity);
1486
1638
  }
1487
1639
  }
1488
1640
  let results = Array.from(byId.values());
1641
+ // Apply pagination once, after merging all results
1489
1642
  if (filter.offset) results = results.slice(filter.offset);
1490
1643
  if (filter.limit) results = results.slice(0, filter.limit);
1491
1644
  return results;