@anteros/core 0.0.39 → 0.0.41
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/database/audit.ts +193 -0
- package/database/collection.ts +4 -4
- package/database/deleteLog.ts +34 -0
- package/database/field.crypto.ts +1 -1
- package/database/file.ts +3 -3
- package/database/mongodbadapter.ts +321 -147
- package/database/replication.ts +780 -0
- package/database/schema.ts +140 -125
- package/database/tenant.ts +4 -0
- package/database/vars.ts +405 -0
- package/database/workflow.ts +6 -6
- package/index.ts +10 -0
- package/lib/audit.ts +271 -0
- package/lib/auditResult.ts +120 -0
- package/lib/define.ts +20 -1
- package/lib/files.ts +64 -11
- package/lib/lifecycle.ts +141 -0
- package/lib/redact.ts +117 -0
- package/lib/workflow.ts +1 -1
- package/package.json +1 -1
- package/server/api.ts +134 -33
- package/server/boot.ts +185 -53
- package/server/flags.ts +136 -0
- package/server/hono.ts +1 -1
- package/tests/audit-file.test.ts +296 -0
- package/tests/audit-operations.test.ts +184 -0
- package/tests/audit-redact.test.ts +233 -0
- package/tests/audit-results.test.ts +188 -0
- package/tests/audit-retention-config.test.ts +76 -0
- package/tests/audit-run-action.test.ts +120 -0
- package/tests/audit.test.ts +320 -0
- package/tests/crypto.test.ts +17 -17
- package/tests/db-cache.test.ts +7 -7
- package/tests/field-encryption.test.ts +25 -25
- package/tests/file-collection-replication.test.ts +98 -0
- package/tests/fixtures/index-tenant/collections/variants.model.ts +2 -2
- package/tests/fixtures/lc-disabled/lifecycle.ts +11 -0
- package/tests/fixtures/lc-hang/lifecycle.ts +13 -0
- package/tests/fixtures/lc-normal/lifecycle.ts +23 -0
- package/tests/fixtures/lc-throw/lifecycle.ts +6 -0
- package/tests/fixtures/repl-only/app.ts +26 -0
- package/tests/fixtures/repl-only/tenant/collections/orders.model.ts +14 -0
- package/tests/fixtures/repl-only/tenant/lifecycle.ts +14 -0
- package/tests/fixtures/repl-only/tenant/scripts/probe.run.ts +8 -0
- package/tests/fixtures/vars-tenant/vars/config.var.ts +23 -0
- package/tests/fixtures/vars-tenant/vars/private.var.ts +6 -0
- package/tests/index-spec.test.ts +5 -5
- package/tests/lifecycle.test.ts +96 -0
- package/tests/replication-control.test.ts +128 -0
- package/tests/replication-initial-sync.test.ts +86 -0
- package/tests/replication-only.test.ts +358 -0
- package/tests/replication.test.ts +188 -0
- package/tests/upload.test.ts +239 -0
- package/tests/vars-api.test.ts +123 -0
- package/tests/vars-replication.test.ts +95 -0
- package/tests/vars.test.ts +216 -0
- package/types/collection.d.ts +3 -0
- package/types/config.d.ts +64 -0
- package/types/file.d.ts +4 -1
- package/types/lifecycle.d.ts +71 -0
- package/types/replication.d.ts +199 -0
- package/types/tenant.d.ts +23 -0
- package/types/vars.d.ts +147 -0
- package/utils/func.ts +67 -52
package/utils/func.ts
CHANGED
|
@@ -159,14 +159,14 @@ function pick<T extends DeepRecord>(data: T | T[], keys: string[]): Partial<T> |
|
|
|
159
159
|
}
|
|
160
160
|
|
|
161
161
|
/**
|
|
162
|
-
*
|
|
162
|
+
* Converts MongoDB dot-notation into a nested structure for Joi validation.
|
|
163
163
|
* Ex: { "address.zip": "BP28", "items.0.name": "x" } → { address: { zip }, items: [{ name }] }
|
|
164
|
-
*
|
|
164
|
+
* Operator keys ($inc, $push…) are left as-is.
|
|
165
165
|
*/
|
|
166
166
|
function unflattenKeys(input: Record<string, unknown>): Record<string, unknown> {
|
|
167
167
|
const result: Record<string, unknown> = {};
|
|
168
168
|
for (const [key, value] of Object.entries(input)) {
|
|
169
|
-
//
|
|
169
|
+
// Mongo operators → leave as-is (forbidden in $set anyway)
|
|
170
170
|
if (key.startsWith('$')) {
|
|
171
171
|
result[key] = value;
|
|
172
172
|
continue;
|
|
@@ -183,7 +183,7 @@ function unflattenKeys(input: Record<string, unknown>): Record<string, unknown>
|
|
|
183
183
|
target = target[part];
|
|
184
184
|
}
|
|
185
185
|
const last = parts[parts.length - 1]!;
|
|
186
|
-
//
|
|
186
|
+
// Merge when both sides are objects (e.g. "a.b" + a: { c })
|
|
187
187
|
if (
|
|
188
188
|
target[last] &&
|
|
189
189
|
typeof target[last] === 'object' &&
|
|
@@ -257,15 +257,15 @@ function cleanDeep<T extends DeepRecord | DeepRecord[]>(data: T): Partial<T> {
|
|
|
257
257
|
}
|
|
258
258
|
|
|
259
259
|
/**
|
|
260
|
-
*
|
|
261
|
-
*
|
|
262
|
-
*
|
|
260
|
+
* Checks whether a string is a valid ISO 8601 date.
|
|
261
|
+
* Numeric timestamps (e.g. "1704067200") are NOT converted here —
|
|
262
|
+
* they go through `isEpochNumber` when the value is a `number`.
|
|
263
263
|
*/
|
|
264
264
|
function isDate(value: string): boolean {
|
|
265
265
|
if (!value || typeof value !== 'string') return false;
|
|
266
266
|
const trimmed = value.trim();
|
|
267
267
|
|
|
268
|
-
// ISO 8601
|
|
268
|
+
// ISO 8601: YYYY-MM-DD with optional time
|
|
269
269
|
if (!/^\d{4}-\d{2}-\d{2}(T\d{2}:\d{2}(:\d{2}(\.\d{1,3})?)?(Z|[+-]\d{2}:\d{2})?)?$/.test(trimmed)) {
|
|
270
270
|
return false;
|
|
271
271
|
}
|
|
@@ -274,14 +274,14 @@ function isDate(value: string): boolean {
|
|
|
274
274
|
return !isNaN(date.getTime());
|
|
275
275
|
}
|
|
276
276
|
|
|
277
|
-
/**
|
|
277
|
+
/** Converts an ISO 8601 string to a Date. */
|
|
278
278
|
function stringToDate(value: string): Date {
|
|
279
279
|
return new Date(value.trim());
|
|
280
280
|
}
|
|
281
281
|
|
|
282
282
|
/**
|
|
283
|
-
*
|
|
284
|
-
*
|
|
283
|
+
* Integer number of UNIX timestamp type (s or ms).
|
|
284
|
+
* Restricted to ≥ year 2000 to avoid false positives (e.g. phone numbers).
|
|
285
285
|
*/
|
|
286
286
|
function isEpochNumber(value: number): boolean {
|
|
287
287
|
if (typeof value !== 'number' || !Number.isFinite(value)) return false;
|
|
@@ -289,12 +289,12 @@ function isEpochNumber(value: number): boolean {
|
|
|
289
289
|
if (int !== value) return false;
|
|
290
290
|
const s = String(Math.abs(int));
|
|
291
291
|
if (s.length === 10) {
|
|
292
|
-
//
|
|
292
|
+
// UNIX seconds: ≥ 2000-01-01
|
|
293
293
|
if (int < 946684800) return false;
|
|
294
294
|
return !isNaN(new Date(int * 1000).getTime());
|
|
295
295
|
}
|
|
296
296
|
if (s.length === 13) {
|
|
297
|
-
//
|
|
297
|
+
// UNIX milliseconds: ≥ 2000-01-01
|
|
298
298
|
if (int < 946684800000) return false;
|
|
299
299
|
return !isNaN(new Date(int).getTime());
|
|
300
300
|
}
|
|
@@ -320,11 +320,11 @@ type FormatToDateResult<T> = T extends string
|
|
|
320
320
|
? { [K in keyof T]: FormatToDateResult<T[K]> }
|
|
321
321
|
: T;
|
|
322
322
|
|
|
323
|
-
/** Options
|
|
323
|
+
/** Options to filter paths; without options, everything that passes `isDate` is converted to `Date`. */
|
|
324
324
|
type FormatToDateOptions = {
|
|
325
|
-
/**
|
|
325
|
+
/** Field names (dot notation) not to convert; exact match or suffix (e.g. `$set.createdAt`). */
|
|
326
326
|
omitKeys?: string[];
|
|
327
|
-
/**
|
|
327
|
+
/** If set and non-empty: only these fields are converted (equivalent to "everything except the non-dates"). Exact match or path suffix. */
|
|
328
328
|
includeKeys?: string[];
|
|
329
329
|
};
|
|
330
330
|
|
|
@@ -334,9 +334,9 @@ function joinFormatToDatePath(parent: string, segment: string | number): string
|
|
|
334
334
|
}
|
|
335
335
|
|
|
336
336
|
/**
|
|
337
|
-
*
|
|
338
|
-
*
|
|
339
|
-
*
|
|
337
|
+
* Matches a schema field name (`createdAt`, `user.birthDate`)
|
|
338
|
+
* including at depth (`$set.createdAt`) or behind a MongoDB
|
|
339
|
+
* operator (`createdAt.$lte`, `$match.createdAt.$gte`).
|
|
340
340
|
*/
|
|
341
341
|
function pathMatchesFieldKey(path: string, key: string): boolean {
|
|
342
342
|
// Strip trailing MongoDB operator segments: createdAt.$lte → createdAt
|
|
@@ -345,16 +345,16 @@ function pathMatchesFieldKey(path: string, key: string): boolean {
|
|
|
345
345
|
}
|
|
346
346
|
|
|
347
347
|
function shouldFormatDateAtPath(path: string, options?: FormatToDateOptions): boolean {
|
|
348
|
-
//
|
|
348
|
+
// No options → convert everywhere `isDate` is true
|
|
349
349
|
if (!options) return true;
|
|
350
350
|
|
|
351
|
-
//
|
|
352
|
-
// →
|
|
353
|
-
//
|
|
351
|
+
// MongoDB operator context ($lte, $gte, $eq, $in, $nin, $exists, etc.)
|
|
352
|
+
// → always convert: an operator on a date only makes sense with a real Date
|
|
353
|
+
// We check whether a path segment starts with '$' (e.g. createdAt.$lte, $in.0)
|
|
354
354
|
if (path.split('.').some((seg) => /^\$[a-z]+$/i.test(seg))) return true;
|
|
355
355
|
|
|
356
356
|
if (options.omitKeys?.some((k) => pathMatchesFieldKey(path, k))) return false;
|
|
357
|
-
// Whitelist
|
|
357
|
+
// Whitelist only if includeKeys is a non-empty array
|
|
358
358
|
if (options.includeKeys && options.includeKeys.length > 0) {
|
|
359
359
|
return options.includeKeys.some((k) => pathMatchesFieldKey(path, k));
|
|
360
360
|
}
|
|
@@ -366,7 +366,7 @@ function formatToDate(data: any, options?: FormatToDateOptions, path = ''): any
|
|
|
366
366
|
if (data instanceof Date) return data;
|
|
367
367
|
if (data instanceof ObjectId) return data;
|
|
368
368
|
|
|
369
|
-
//
|
|
369
|
+
// Root string (e.g. $in array item) — check before the object branch
|
|
370
370
|
if (typeof data === 'string' && isDate(data)) {
|
|
371
371
|
return shouldFormatDateAtPath(path, options) ? stringToDate(data) : data;
|
|
372
372
|
}
|
|
@@ -391,7 +391,7 @@ function formatToDate(data: any, options?: FormatToDateOptions, path = ''): any
|
|
|
391
391
|
}
|
|
392
392
|
} else if (typeof value === 'number' && isEpochNumber(value)) {
|
|
393
393
|
if (shouldFormatDateAtPath(p, options)) {
|
|
394
|
-
// 10
|
|
394
|
+
// 10 digits = seconds (×1000), 13 digits = milliseconds (already in ms)
|
|
395
395
|
const absVal = Math.abs(Math.trunc(value));
|
|
396
396
|
const asMs = String(absVal).length === 10 ? value * 1000 : value;
|
|
397
397
|
result[key] = new Date(asMs);
|
|
@@ -467,9 +467,9 @@ export type GenerateRandomOptions = {
|
|
|
467
467
|
useNumbers?: boolean;
|
|
468
468
|
includeSymbols?: string;
|
|
469
469
|
excludeSymbols?: string;
|
|
470
|
-
/**
|
|
470
|
+
/** Prefix (fixed string or function that receives the generated value). Ex: `(v) => 'BP-' + v` */
|
|
471
471
|
startWith?: string | ((value: string) => string);
|
|
472
|
-
/**
|
|
472
|
+
/** Suffix (fixed string or function that receives the generated value). Ex: `(v) => v + '-X'` */
|
|
473
473
|
endWith?: string | ((value: string) => string);
|
|
474
474
|
toLowerCase?: boolean;
|
|
475
475
|
toUpperCase?: boolean;
|
|
@@ -523,8 +523,8 @@ async function generateRandom(options: GenerateRandomOptions, ctx: {
|
|
|
523
523
|
result += charset[randomIndex];
|
|
524
524
|
}
|
|
525
525
|
|
|
526
|
-
// String →
|
|
527
|
-
//
|
|
526
|
+
// String → classic prefix/suffix. Function → it receives the value
|
|
527
|
+
// and returns the final result (no re-concatenation to avoid duplication).
|
|
528
528
|
if (typeof startWith === 'function') {
|
|
529
529
|
result = startWith(result);
|
|
530
530
|
} else {
|
|
@@ -703,14 +703,14 @@ function buildPipeline(p: FindOptions, options?: {
|
|
|
703
703
|
status: 400
|
|
704
704
|
});
|
|
705
705
|
}
|
|
706
|
-
//
|
|
706
|
+
// Flaw 13: bound $regex (ReDoS) and $in/$nin (memory DoS)
|
|
707
707
|
boundMatchOperators(p.$match);
|
|
708
708
|
pipeline.push({
|
|
709
709
|
$match: p.$match
|
|
710
710
|
})
|
|
711
711
|
}
|
|
712
712
|
|
|
713
|
-
// $sort —
|
|
713
|
+
// $sort — Flaw 14: values must be 1 or -1
|
|
714
714
|
if (p?.$sort) {
|
|
715
715
|
for (const [key, dir] of Object.entries(p.$sort)) {
|
|
716
716
|
if (dir !== 1 && dir !== -1) {
|
|
@@ -722,7 +722,7 @@ function buildPipeline(p: FindOptions, options?: {
|
|
|
722
722
|
}
|
|
723
723
|
}
|
|
724
724
|
|
|
725
|
-
// $skip —
|
|
725
|
+
// $skip — Flaw 14: must be a safe non-negative integer
|
|
726
726
|
if (p?.$skip !== undefined && p?.$skip !== null) {
|
|
727
727
|
if (!Number.isSafeInteger(p.$skip) || p.$skip < 0) {
|
|
728
728
|
throw new AppError('Invalid $skip (expected a non-negative integer)', {
|
|
@@ -829,7 +829,7 @@ function buildPipeline(p: FindOptions, options?: {
|
|
|
829
829
|
|
|
830
830
|
let as = include.as || include.localField
|
|
831
831
|
|
|
832
|
-
//
|
|
832
|
+
// Flaw 3: never trust client-supplied $lookup sub-pipeline — scan it
|
|
833
833
|
if (include.pipeline && Array.isArray(include.pipeline)) {
|
|
834
834
|
const check = isSafeAggregatePipeline(include.pipeline as Array<Record<string, unknown>>)
|
|
835
835
|
if (!check.isSafe) {
|
|
@@ -906,7 +906,7 @@ function clone<T>(data: T): T {
|
|
|
906
906
|
}
|
|
907
907
|
|
|
908
908
|
/**
|
|
909
|
-
*
|
|
909
|
+
* Flaw 13: bounds dangerous $match operators to prevent ReDoS ($regex) and memory DoS ($in).
|
|
910
910
|
* Walks nested objects/arrays ($and, $or, $nor, dotted fields).
|
|
911
911
|
*/
|
|
912
912
|
const MAX_REGEX_LENGTH = 1000;
|
|
@@ -960,19 +960,19 @@ function boundMatchOperators(node: unknown): void {
|
|
|
960
960
|
.replace(/^-|-$/g, '');
|
|
961
961
|
}
|
|
962
962
|
|
|
963
|
-
/**
|
|
963
|
+
/** Checks that a string is a valid slug (lowercase letters, digits, single hyphens) */
|
|
964
964
|
function isSlug(value: string): boolean {
|
|
965
965
|
return /^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(value);
|
|
966
966
|
}
|
|
967
967
|
|
|
968
968
|
|
|
969
969
|
/**
|
|
970
|
-
*
|
|
971
|
-
*
|
|
972
|
-
*
|
|
973
|
-
* @param data -
|
|
974
|
-
* @param keys -
|
|
975
|
-
* @returns
|
|
970
|
+
* Recursively walks `data` (object or array, any depth)
|
|
971
|
+
* and returns the keys from `keys` that appear at least once as an
|
|
972
|
+
* own property name of an object.
|
|
973
|
+
* @param data - The object or array to inspect.
|
|
974
|
+
* @param keys - The forbidden keys to look for.
|
|
975
|
+
* @returns An array of the forbidden keys found.
|
|
976
976
|
*/
|
|
977
977
|
function hasUnauthorizedKeys(data: unknown, keys: string[]): string[] {
|
|
978
978
|
const found = new Set<string>();
|
|
@@ -1041,7 +1041,7 @@ function isAggregationFieldPathRef(value: unknown): value is string {
|
|
|
1041
1041
|
return typeof value === 'string' && value.startsWith('$') && !value.startsWith('$$');
|
|
1042
1042
|
}
|
|
1043
1043
|
|
|
1044
|
-
/**
|
|
1044
|
+
/** Map of output keys → source ref for any illicit rename (`out !==` last path segment). */
|
|
1045
1045
|
function getProjectFieldRenames(project: unknown): Record<string, string> {
|
|
1046
1046
|
const renames: Record<string, string> = {};
|
|
1047
1047
|
if (!project || typeof project !== 'object' || Array.isArray(project)) {
|
|
@@ -1063,12 +1063,12 @@ function getProjectFieldRenames(project: unknown): Record<string, string> {
|
|
|
1063
1063
|
}
|
|
1064
1064
|
|
|
1065
1065
|
/**
|
|
1066
|
-
*
|
|
1067
|
-
*
|
|
1066
|
+
* Checks `$project` entries that rename a field through a Mongo path
|
|
1067
|
+
* reference (string value `"$..."`), e.g. `{ pw: "$password" }`.
|
|
1068
1068
|
*
|
|
1069
|
-
*
|
|
1070
|
-
* segment
|
|
1071
|
-
* @throws AppError 400 `UNAUTHORIZED_PROJECT_FIELD_RENAME`
|
|
1069
|
+
* A "rename" is considered to occur when the output key differs from the last
|
|
1070
|
+
* segment of the source path (`password` for `$password`, `email` for `user.email`).
|
|
1071
|
+
* @throws AppError 400 `UNAUTHORIZED_PROJECT_FIELD_RENAME` if a rename is detected
|
|
1072
1072
|
*/
|
|
1073
1073
|
function protectFieldRenameOnProject(project: unknown): Record<string, unknown> {
|
|
1074
1074
|
if (!project || typeof project !== 'object' || Array.isArray(project)) {
|
|
@@ -1087,7 +1087,7 @@ function protectFieldRenameOnProject(project: unknown): Record<string, unknown>
|
|
|
1087
1087
|
|
|
1088
1088
|
|
|
1089
1089
|
/**
|
|
1090
|
-
*
|
|
1090
|
+
* Recursively walks a MongoDB aggregation pipeline (stages `$facet`,
|
|
1091
1091
|
* `$lookup.pipeline`, `$unionWith.pipeline`, `$graphLookup.pipeline`).
|
|
1092
1092
|
*/
|
|
1093
1093
|
function walkAggregatePipeline(
|
|
@@ -1151,7 +1151,7 @@ function isSafeAggregatePipeline(pipeline: Array<Record<string, unknown>>, allow
|
|
|
1151
1151
|
error: AppError | null;
|
|
1152
1152
|
} {
|
|
1153
1153
|
|
|
1154
|
-
// Unauthorized pipeline keys —
|
|
1154
|
+
// Unauthorized pipeline keys — Flaw 12: include cross-collection exfiltration stages
|
|
1155
1155
|
const UNAUTHORIZED_PIPELINE_KEYS = [
|
|
1156
1156
|
'$out', '$merge', '$function', '$where', '$accumulator',
|
|
1157
1157
|
'$unionWith', '$collStats', '$indexStats', '$planCacheStats',
|
|
@@ -1166,7 +1166,7 @@ function isSafeAggregatePipeline(pipeline: Array<Record<string, unknown>>, allow
|
|
|
1166
1166
|
}
|
|
1167
1167
|
}
|
|
1168
1168
|
|
|
1169
|
-
//
|
|
1169
|
+
// Flaw 12: restrict $lookup / $graphLookup targets to declared tenant collections
|
|
1170
1170
|
if (allowedCollections && allowedCollections.length > 0) {
|
|
1171
1171
|
const badTargets: string[] = [];
|
|
1172
1172
|
walkAggregatePipeline(pipeline, (stage) => {
|
|
@@ -1188,7 +1188,7 @@ function isSafeAggregatePipeline(pipeline: Array<Record<string, unknown>>, allow
|
|
|
1188
1188
|
}
|
|
1189
1189
|
}
|
|
1190
1190
|
|
|
1191
|
-
// $project
|
|
1191
|
+
// $project with rename: root + nested ($facet, $lookup.pipeline, …)
|
|
1192
1192
|
let renameForbiddenKeys: string[] = [];
|
|
1193
1193
|
let renameError: AppError | null = null;
|
|
1194
1194
|
walkAggregatePipeline(pipeline, (stage) => {
|
|
@@ -1225,6 +1225,20 @@ function isSafeAggregatePipeline(pipeline: Array<Record<string, unknown>>, allow
|
|
|
1225
1225
|
}
|
|
1226
1226
|
}
|
|
1227
1227
|
|
|
1228
|
+
/**
|
|
1229
|
+
* Parse a human duration (`'500ms'`, `'30s'`, `'5m'`, `'2h'`, `'1d'`) or a number
|
|
1230
|
+
* of milliseconds. Returns `null` when the value cannot be parsed.
|
|
1231
|
+
*/
|
|
1232
|
+
function parseDuration(value: string | number): number | null {
|
|
1233
|
+
if (typeof value === 'number') return Number.isFinite(value) ? Math.max(0, value) : null;
|
|
1234
|
+
const match = String(value).trim().match(/^(\d+(?:\.\d+)?)\s*(ms|s|m|h|d)?$/i);
|
|
1235
|
+
if (!match) return null;
|
|
1236
|
+
const amount = Number.parseFloat(match[1] ?? '0');
|
|
1237
|
+
const unit = (match[2] ?? 'ms').toLowerCase();
|
|
1238
|
+
const factor = unit === 's' ? 1_000 : unit === 'm' ? 60_000 : unit === 'h' ? 3_600_000 : unit === 'd' ? 86_400_000 : 1;
|
|
1239
|
+
return Math.round(amount * factor);
|
|
1240
|
+
}
|
|
1241
|
+
|
|
1228
1242
|
export {
|
|
1229
1243
|
buildPipeline,
|
|
1230
1244
|
reorder,
|
|
@@ -1253,6 +1267,7 @@ export {
|
|
|
1253
1267
|
walkAggregatePipeline,
|
|
1254
1268
|
countAggregatePipelineStages,
|
|
1255
1269
|
isSafeAggregatePipeline,
|
|
1270
|
+
parseDuration,
|
|
1256
1271
|
}
|
|
1257
1272
|
|
|
1258
1273
|
export type { PaginationResult, FormatToDateOptions }
|