@utilarium/overcontext 0.0.4-dev.0 → 0.0.4
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/api/query-builder.d.ts +3 -0
- package/dist/api/query-builder.js +11 -4
- package/dist/api/query-builder.js.map +1 -1
- package/dist/api/search.js +33 -3
- package/dist/api/search.js.map +1 -1
- package/dist/discovery/hierarchical-provider.js +10 -1
- package/dist/discovery/hierarchical-provider.js.map +1 -1
- package/dist/discovery/walker.js +14 -0
- package/dist/discovery/walker.js.map +1 -1
- package/dist/index.cjs +173 -20
- package/dist/index.cjs.map +1 -1
- package/dist/schema/base.js +3 -1
- package/dist/schema/base.js.map +1 -1
- package/dist/storage/filesystem.js +87 -7
- package/dist/storage/filesystem.js.map +1 -1
- package/dist/storage/memory.js +16 -4
- package/dist/storage/memory.js.map +1 -1
- package/dist/storage/observable.js +1 -0
- package/dist/storage/observable.js.map +1 -1
- package/package.json +1 -1
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
|
-
|
|
420
|
-
|
|
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
|
-
|
|
477
|
+
// Verify the path stays within basePath
|
|
478
|
+
verifyPathWithinBase(dir);
|
|
479
|
+
return dir;
|
|
423
480
|
};
|
|
424
481
|
const getEntityPath = (type, id, namespace)=>{
|
|
425
|
-
|
|
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
|
-
...
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
778
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1156
|
-
|
|
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(
|
|
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;
|