@classytic/repo-core 0.13.0 → 0.14.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/CHANGELOG.md +74 -0
- package/dist/filter/index.d.mts +2 -1
- package/dist/filter/index.mjs +2 -1
- package/dist/filter/match-record.d.mts +19 -0
- package/dist/filter/match-record.mjs +187 -0
- package/dist/filter/match.mjs +103 -32
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,80 @@ All notable changes to `@classytic/repo-core` are documented here.
|
|
|
4
4
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
5
5
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
6
6
|
|
|
7
|
+
## [0.14.0] - 2026-07-16
|
|
8
|
+
|
|
9
|
+
### Added — canonical `matchesRecordFilter` (the `DataAdapter.matchesFilter` home)
|
|
10
|
+
|
|
11
|
+
- **`matchesRecordFilter(item, record)`** + **`policyRecordToFilter(record)`**
|
|
12
|
+
(`@classytic/repo-core/filter`) — THE single, shared implementation of the
|
|
13
|
+
`DataAdapter.matchesFilter` seam. Evaluates arc's Mongo-record `_policyFilters`
|
|
14
|
+
(`{ ownerId }`, `{ organizationId }`, `{ $or: [{ ownerId }, { _id: { $in } }] }`)
|
|
15
|
+
against an already-fetched document IN PROCESS, by converting to the portable
|
|
16
|
+
`Filter` IR and delegating to `matchFilter` — the SAME IR kits compile to
|
|
17
|
+
SQL/Mongo, so in-memory and DB enforcement agree by construction. Every kit's
|
|
18
|
+
adapter now delegates here; **no per-kit matcher, one contract, one IR.**
|
|
19
|
+
Operator scope: `$or`/`$and`/`$nor`/`$not`, `$eq`/`$ne`/`$gt`/`$gte`/`$lt`/`$lte`,
|
|
20
|
+
`$in`/`$nin`, `$exists`, implicit-eq; fails LOUD on anything else. (Distinct from
|
|
21
|
+
`recordToFilter`, the bare-operator query normalizer that does not accept
|
|
22
|
+
`$`-prefixed or logical operators.)
|
|
23
|
+
- **`matchFilter` is now fully array + id + date aware** (additive superset;
|
|
24
|
+
primitive/Date-vs-Date behavior unchanged — strict cases still short-circuit):
|
|
25
|
+
- **id coercion** — an id-like object with a meaningful `toString` (Mongo
|
|
26
|
+
`ObjectId`, `Buffer`, `Decimal128`) matches its string form, so one shared
|
|
27
|
+
matcher serves Mongo (`ObjectId` `_id`) and SQL (primitive ids) alike.
|
|
28
|
+
- **array semantics** — dot-paths fan out over subdocument arrays
|
|
29
|
+
(`items.sku` on `[{sku},{sku}]`), and a scalar condition on a leaf array
|
|
30
|
+
field matches when ANY element satisfies it (`{ tags: 'x' }`,
|
|
31
|
+
`{ scores: { $gt: 5 } }`, regex on array elements). All array unwrapping is
|
|
32
|
+
concentrated in one helper; the comparators stay pure scalar.
|
|
33
|
+
- **Date⇄ISO-string range** — `compare` coerces the string side to a date
|
|
34
|
+
instant when the other side is a genuine `Date` (mirrors `equals`), and
|
|
35
|
+
never claims ordering across a number/string type boundary (fails closed
|
|
36
|
+
instead of matching spuriously).
|
|
37
|
+
- **prototype-pollution-safe reads** — path resolution uses `Object.hasOwn`,
|
|
38
|
+
so a crafted `{ '__proto__.x': … }` / `{ constructor.name: … }` filter can
|
|
39
|
+
never traverse the prototype chain.
|
|
40
|
+
- **Ability parity across kits**: mongokit's earlier standalone matcher
|
|
41
|
+
(subdocument-array fan-out, array-contains, `$regex`, ObjectId coercion) is now
|
|
42
|
+
fully covered by the shared engine — nothing was lost in consolidation.
|
|
43
|
+
- **MongoDB-parity hardening** (validated against the MongoDB manual + sift.js +
|
|
44
|
+
mingo — the two industry-standard in-memory matchers). The authorization-critical
|
|
45
|
+
rule "an absent field participates in comparisons as null/undefined" is now
|
|
46
|
+
fully honored:
|
|
47
|
+
- `{ field: null }`, `$ne`, `$nin`, and a `null` MEMBER of `$in`/`$nin` all
|
|
48
|
+
match a MISSING field (a policy filter `{ status: { $ne: 'archived' } }`
|
|
49
|
+
correctly returns docs that lack the field, exactly as MongoDB does — the top
|
|
50
|
+
silent-authorization-divergence trap). `$ne: null` remains the exception
|
|
51
|
+
(requires present + non-null).
|
|
52
|
+
- `$in` accepts RegExp-literal members (`{ name: { $in: [/^a/] } }`).
|
|
53
|
+
- Comparison ops are type-bracketed (no cross-type ordering; `$gt: null` matches
|
|
54
|
+
nothing); NaN equals NaN for `$eq` (via `Object.is`, not `===`).
|
|
55
|
+
- Numeric dot-path segments resolve as positional array indices (`items.0.sku`).
|
|
56
|
+
- Documented DELIBERATE divergences: `$exists` = present-and-non-null (matches
|
|
57
|
+
the IR `exists` op + SQL `IS NOT NULL` + sift; Mongo/mingo count present-null
|
|
58
|
+
as existing); `Date`⇄ISO-string range leniency; array-literal operands are
|
|
59
|
+
element-matched not exact-matched.
|
|
60
|
+
- New: 41-case matcher suite incl. the 11 researched MongoDB gotchas + adversarial
|
|
61
|
+
(prototype pollution, NaN/Infinity, boolean/zero/empty-string, empty
|
|
62
|
+
`$in`/`$or`/`$and`/`$nor`, fail-loud on unsupported operators) + a 100k-doc
|
|
63
|
+
performance smoke (linear, cached regex).
|
|
64
|
+
- **Security hardening** (from a review against sift.js/mingo CVEs + the OWASP
|
|
65
|
+
NoSQL-injection / ReDoS / prototype-pollution literature):
|
|
66
|
+
- **Prototype-key denylist** on path segments — `__proto__` / `constructor` /
|
|
67
|
+
`prototype` resolve to nothing (fail-closed), string-normalized (the
|
|
68
|
+
object-path CVE-2021-23434 lesson: an array-typed segment bypassed a `===`
|
|
69
|
+
check). Closes the "match an inherited member → wrong auth answer" case and
|
|
70
|
+
the `JSON.parse('{"__proto__":…}')` own-property vector, on top of the
|
|
71
|
+
already-`Object.hasOwn` reads.
|
|
72
|
+
- **`$regex` input-length cap** (64 KiB) — ReDoS is `pattern × input`; a field
|
|
73
|
+
value longer than the cap is treated as no-match (fail-closed) so one slow
|
|
74
|
+
match can't stall the event loop and amplify across a realtime fan-out
|
|
75
|
+
(matcher runs once per subscriber per record). Pattern-side ReDoS is a
|
|
76
|
+
non-issue for framework-supplied (trusted) patterns.
|
|
77
|
+
- Reaffirmed **fail-closed** posture: comparisons never coerce across a
|
|
78
|
+
number/string type boundary (return no-match rather than JS-coerced nonsense
|
|
79
|
+
— the classic over-visibility leak), and unsupported operators throw.
|
|
80
|
+
|
|
7
81
|
## [0.13.0] - 2026-07-15
|
|
8
82
|
|
|
9
83
|
### Added — `StandardRepo.applyTransition?()` contract (state-machine CAS with history)
|
package/dist/filter/index.d.mts
CHANGED
|
@@ -3,6 +3,7 @@ import { FALSE, TRUE, and, anyOf as in_, between, contains, endsWith, eq, exists
|
|
|
3
3
|
import { recordToFilter } from "./from-record.mjs";
|
|
4
4
|
import { isFilter } from "./guard.mjs";
|
|
5
5
|
import { asPredicate, matchFilter } from "./match.mjs";
|
|
6
|
+
import { matchesRecordFilter, policyRecordToFilter } from "./match-record.mjs";
|
|
6
7
|
import { SCOPE_ANY, buildTenantScope, mergeScope } from "./scope.mjs";
|
|
7
8
|
import { collectFields, mapFilter, walkFilter } from "./walk.mjs";
|
|
8
|
-
export { FALSE, type Filter, type FilterAnd, type FilterEq, type FilterExists, type FilterFalse, type FilterGt, type FilterGte, type FilterIn, type FilterLike, type FilterLt, type FilterLte, type FilterNe, type FilterNin, type FilterNot, type FilterOp, type FilterOr, type FilterRaw, type FilterRegex, type FilterTrue, SCOPE_ANY, TRUE, and, in_ as anyOf, asPredicate, between, buildTenantScope, collectFields, contains, endsWith, eq, exists, gt, gte, iEq, in_, not as invert, isFilter, isNotNull, isNull, like, lt, lte, mapFilter, matchFilter, mergeScope, ne, nin, nin as noneOf, not, or, raw, recordToFilter, regex, startsWith, walkFilter };
|
|
9
|
+
export { FALSE, type Filter, type FilterAnd, type FilterEq, type FilterExists, type FilterFalse, type FilterGt, type FilterGte, type FilterIn, type FilterLike, type FilterLt, type FilterLte, type FilterNe, type FilterNin, type FilterNot, type FilterOp, type FilterOr, type FilterRaw, type FilterRegex, type FilterTrue, SCOPE_ANY, TRUE, and, in_ as anyOf, asPredicate, between, buildTenantScope, collectFields, contains, endsWith, eq, exists, gt, gte, iEq, in_, not as invert, isFilter, isNotNull, isNull, like, lt, lte, mapFilter, matchFilter, matchesRecordFilter, mergeScope, ne, nin, nin as noneOf, not, or, policyRecordToFilter, raw, recordToFilter, regex, startsWith, walkFilter };
|
package/dist/filter/index.mjs
CHANGED
|
@@ -3,5 +3,6 @@ import { collectFields, mapFilter, walkFilter } from "./walk.mjs";
|
|
|
3
3
|
import { FALSE, TRUE, and, anyOf as in_, between, contains, endsWith, eq, exists, gt, gte, iEq, invert as not, isNotNull, isNull, like, lt, lte, ne, nin, or, raw, regex, startsWith } from "./builders.mjs";
|
|
4
4
|
import { recordToFilter } from "./from-record.mjs";
|
|
5
5
|
import { asPredicate, matchFilter } from "./match.mjs";
|
|
6
|
+
import { matchesRecordFilter, policyRecordToFilter } from "./match-record.mjs";
|
|
6
7
|
import { SCOPE_ANY, buildTenantScope, mergeScope } from "./scope.mjs";
|
|
7
|
-
export { FALSE, SCOPE_ANY, TRUE, and, in_ as anyOf, asPredicate, between, buildTenantScope, collectFields, contains, endsWith, eq, exists, gt, gte, iEq, in_, not as invert, isFilter, isNotNull, isNull, like, lt, lte, mapFilter, matchFilter, mergeScope, ne, nin, nin as noneOf, not, or, raw, recordToFilter, regex, startsWith, walkFilter };
|
|
8
|
+
export { FALSE, SCOPE_ANY, TRUE, and, in_ as anyOf, asPredicate, between, buildTenantScope, collectFields, contains, endsWith, eq, exists, gt, gte, iEq, in_, not as invert, isFilter, isNotNull, isNull, like, lt, lte, mapFilter, matchFilter, matchesRecordFilter, mergeScope, ne, nin, nin as noneOf, not, or, policyRecordToFilter, raw, recordToFilter, regex, startsWith, walkFilter };
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { Filter } from "./types.mjs";
|
|
2
|
+
//#region src/filter/match-record.d.ts
|
|
3
|
+
/**
|
|
4
|
+
* Convert an arc Mongo-record `_policyFilters` object into Filter IR.
|
|
5
|
+
* `{}` → `TRUE`. Throws on unsupported top-level operators.
|
|
6
|
+
*/
|
|
7
|
+
declare function policyRecordToFilter(record: Record<string, unknown>): Filter;
|
|
8
|
+
/**
|
|
9
|
+
* Evaluate an arc Mongo-record `_policyFilters` object against a document —
|
|
10
|
+
* converts to Filter IR, then delegates to the shared {@link matchFilter}
|
|
11
|
+
* engine. THE canonical `DataAdapter.matchesFilter` implementation; every
|
|
12
|
+
* kit's adapter delegates here.
|
|
13
|
+
*
|
|
14
|
+
* @param item The already-fetched document / row.
|
|
15
|
+
* @param filters Arc's `_policyFilters` in Mongo record syntax.
|
|
16
|
+
*/
|
|
17
|
+
declare function matchesRecordFilter(item: unknown, filters: Record<string, unknown>): boolean;
|
|
18
|
+
//#endregion
|
|
19
|
+
export { matchesRecordFilter, policyRecordToFilter };
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
import { FALSE, TRUE, and, anyOf as in_, eq, gt, gte, invert as not, isNotNull, isNull, lt, lte, ne, or, regex } from "./builders.mjs";
|
|
2
|
+
import { matchFilter } from "./match.mjs";
|
|
3
|
+
//#region src/filter/match-record.ts
|
|
4
|
+
/**
|
|
5
|
+
* Mongo-record `_policyFilters` → Filter IR conversion + in-memory match.
|
|
6
|
+
*
|
|
7
|
+
* The CANONICAL, single home for evaluating arc's row-level policy filters
|
|
8
|
+
* against an already-fetched document IN PROCESS (no DB round-trip). Every
|
|
9
|
+
* kit's `DataAdapter.matchesFilter` delegates here — one contract, one IR,
|
|
10
|
+
* no per-kit matcher.
|
|
11
|
+
*
|
|
12
|
+
* Arc's permission helpers emit policy filters in Mongo record syntax,
|
|
13
|
+
* kit-agnostic (`requireOwnership` → `{ ownerId }`, multiTenant →
|
|
14
|
+
* `{ organizationId }`, `requireGrant` list resolutions →
|
|
15
|
+
* `{ $or: [{ ownerId }, { _id: { $in } }] }`). This module converts that
|
|
16
|
+
* record into the portable {@link Filter} IR and evaluates it with the
|
|
17
|
+
* shared {@link matchFilter} engine — the SAME IR kits compile to SQL /
|
|
18
|
+
* Mongo, so in-memory and DB-level enforcement agree by construction.
|
|
19
|
+
*
|
|
20
|
+
* `matchFilter` is id-coercion aware (Mongo `ObjectId` `_id` matches its
|
|
21
|
+
* string form — no kit-specific coercion) and array-aware (dot-paths fan
|
|
22
|
+
* out over subdocument arrays; scalar conditions on array fields match any
|
|
23
|
+
* element). See `match.ts`.
|
|
24
|
+
*
|
|
25
|
+
* SCOPE — the operators arc's policy filters emit. Fails LOUD on anything
|
|
26
|
+
* else so a silent mismatch never masquerades as a denial:
|
|
27
|
+
*
|
|
28
|
+
* logical: $or, $and, $nor, $not
|
|
29
|
+
* comparison: implicit-eq, $eq, $ne, $gt, $gte, $lt, $lte
|
|
30
|
+
* membership: $in, $nin
|
|
31
|
+
* existence: $exists (see divergence note below)
|
|
32
|
+
* pattern: $regex (+ $options; RegExp literal accepted)
|
|
33
|
+
*
|
|
34
|
+
* MongoDB parity (validated against the MongoDB manual + sift/mingo):
|
|
35
|
+
* - Missing field ≡ null for `{field: null}`, `$ne`/`$nin`, and a `null`
|
|
36
|
+
* member of `$in`/`$nin` — the authorization-critical rule (a policy
|
|
37
|
+
* filter `{ status: { $ne: 'archived' } }` MUST return docs lacking the
|
|
38
|
+
* field, exactly as MongoDB does).
|
|
39
|
+
* - `$in` accepts RegExp-literal members (Mongo allows `/re/` in `$in`).
|
|
40
|
+
* - Comparison ops are TYPE-BRACKETED: no cross-type ordering
|
|
41
|
+
* (`{ n: { $gt: 5 } }` never matches a string `n`); `$gt: null` matches
|
|
42
|
+
* nothing. NaN equals NaN for `$eq`.
|
|
43
|
+
* - Dot-paths fan out over arrays AND resolve numeric segments as
|
|
44
|
+
* positional indices (`items.0.sku`).
|
|
45
|
+
*
|
|
46
|
+
* DELIBERATE divergences (documented, not bugs):
|
|
47
|
+
* - `$exists` = present-AND-non-null (a null value reads as absent),
|
|
48
|
+
* matching the shared IR `exists` op + SQL `IS NOT NULL` + sift.js.
|
|
49
|
+
* MongoDB/mingo treat present-null as existing; that would require a
|
|
50
|
+
* separate key-presence IR op threaded through every kit's SQL/Mongo
|
|
51
|
+
* compiler. Arc's built-in policy helpers never emit `$exists`; a
|
|
52
|
+
* custom filter that needs Mongo key-presence should use
|
|
53
|
+
* `{ field: { $ne: null } }` (present + non-null) or `{ field: null }`
|
|
54
|
+
* (null OR missing) instead.
|
|
55
|
+
* - `$gt`/`$lt` allow ONE cross-type leniency: a `Date` field compares
|
|
56
|
+
* against an ISO-string operand (JSON policy filters carry dates as
|
|
57
|
+
* strings). Consistent with `$eq`'s Date⇄string coercion.
|
|
58
|
+
* - An array-literal operand (`{ tags: ['a','b'] }`) is element-matched,
|
|
59
|
+
* not exact-array-matched — policy filters never assert whole-array
|
|
60
|
+
* equality.
|
|
61
|
+
*
|
|
62
|
+
* Distinct from {@link recordToFilter}, which is the ergonomic
|
|
63
|
+
* record→IR normalizer for BARE-operator query shorthand (`{ price:
|
|
64
|
+
* { gte } }`) and deliberately does NOT accept `$`-prefixed operators or
|
|
65
|
+
* logical `$or`/`$and`. This function is the arc-policy-filter dialect
|
|
66
|
+
* (`$`-prefixed, with logical operators).
|
|
67
|
+
*/
|
|
68
|
+
/** Field operators understood inside a `{ field: { ... } }` condition. */
|
|
69
|
+
const FIELD_OPS = [
|
|
70
|
+
"$eq",
|
|
71
|
+
"$ne",
|
|
72
|
+
"$gt",
|
|
73
|
+
"$gte",
|
|
74
|
+
"$lt",
|
|
75
|
+
"$lte",
|
|
76
|
+
"$in",
|
|
77
|
+
"$nin",
|
|
78
|
+
"$exists",
|
|
79
|
+
"$regex"
|
|
80
|
+
];
|
|
81
|
+
function isOperatorObject(value) {
|
|
82
|
+
if (value === null || typeof value !== "object") return false;
|
|
83
|
+
if (Array.isArray(value) || value instanceof Date) return false;
|
|
84
|
+
const keys = Object.keys(value);
|
|
85
|
+
return keys.length > 0 && keys.every((k) => k.startsWith("$"));
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* `$in` with MongoDB parity: a `null` member also matches a MISSING field
|
|
89
|
+
* (inherits `{field: null}` semantics), and RegExp-literal members match by
|
|
90
|
+
* pattern (Mongo allows `/re/` inside `$in`). Split members into
|
|
91
|
+
* null / regex / scalar and OR the branches.
|
|
92
|
+
*/
|
|
93
|
+
function buildIn(field, members) {
|
|
94
|
+
const branches = [];
|
|
95
|
+
const scalars = [];
|
|
96
|
+
let hasNull = false;
|
|
97
|
+
for (const m of members) if (m === null || m === void 0) hasNull = true;
|
|
98
|
+
else if (m instanceof RegExp) branches.push(regex(field, m.source, m.flags));
|
|
99
|
+
else scalars.push(m);
|
|
100
|
+
if (hasNull) branches.push(isNull(field));
|
|
101
|
+
if (scalars.length > 0) branches.push(in_(field, scalars));
|
|
102
|
+
if (branches.length === 0) return FALSE;
|
|
103
|
+
return branches.length === 1 ? branches[0] : or(...branches);
|
|
104
|
+
}
|
|
105
|
+
/** `$nin` is the negation of `$in` — none of the members may match. */
|
|
106
|
+
function buildNin(field, members) {
|
|
107
|
+
const inFilter = buildIn(field, members);
|
|
108
|
+
return inFilter.op === "false" ? TRUE : not(inFilter);
|
|
109
|
+
}
|
|
110
|
+
/** Convert a single `{ field: condition }` entry into a Filter IR node. */
|
|
111
|
+
function fieldFilter(field, condition) {
|
|
112
|
+
if (!isOperatorObject(condition)) return condition === null ? isNull(field) : eq(field, condition);
|
|
113
|
+
const parts = [];
|
|
114
|
+
const options = typeof condition["$options"] === "string" ? condition["$options"] : void 0;
|
|
115
|
+
for (const [op, operand] of Object.entries(condition)) switch (op) {
|
|
116
|
+
case "$options": break;
|
|
117
|
+
case "$eq":
|
|
118
|
+
parts.push(operand === null ? isNull(field) : eq(field, operand));
|
|
119
|
+
break;
|
|
120
|
+
case "$ne":
|
|
121
|
+
parts.push(operand === null ? isNotNull(field) : ne(field, operand));
|
|
122
|
+
break;
|
|
123
|
+
case "$gt":
|
|
124
|
+
parts.push(gt(field, operand));
|
|
125
|
+
break;
|
|
126
|
+
case "$gte":
|
|
127
|
+
parts.push(gte(field, operand));
|
|
128
|
+
break;
|
|
129
|
+
case "$lt":
|
|
130
|
+
parts.push(lt(field, operand));
|
|
131
|
+
break;
|
|
132
|
+
case "$lte":
|
|
133
|
+
parts.push(lte(field, operand));
|
|
134
|
+
break;
|
|
135
|
+
case "$in":
|
|
136
|
+
parts.push(buildIn(field, operand ?? []));
|
|
137
|
+
break;
|
|
138
|
+
case "$nin":
|
|
139
|
+
parts.push(buildNin(field, operand ?? []));
|
|
140
|
+
break;
|
|
141
|
+
case "$exists":
|
|
142
|
+
parts.push(operand ? isNotNull(field) : isNull(field));
|
|
143
|
+
break;
|
|
144
|
+
case "$regex": {
|
|
145
|
+
const pattern = operand instanceof RegExp ? operand.source : String(operand);
|
|
146
|
+
const flags = operand instanceof RegExp ? operand.flags : options;
|
|
147
|
+
parts.push(flags ? regex(field, pattern, flags) : regex(field, pattern));
|
|
148
|
+
break;
|
|
149
|
+
}
|
|
150
|
+
default: throw new Error(`[repo-core] matchesRecordFilter: unsupported field operator '${op}'. Supported: ${FIELD_OPS.join(", ")}.`);
|
|
151
|
+
}
|
|
152
|
+
return parts.length === 1 ? parts[0] : and(...parts);
|
|
153
|
+
}
|
|
154
|
+
/**
|
|
155
|
+
* Convert an arc Mongo-record `_policyFilters` object into Filter IR.
|
|
156
|
+
* `{}` → `TRUE`. Throws on unsupported top-level operators.
|
|
157
|
+
*/
|
|
158
|
+
function policyRecordToFilter(record) {
|
|
159
|
+
const parts = [];
|
|
160
|
+
for (const [key, value] of Object.entries(record)) if (key === "$or") parts.push(or(...asFilterArray(value)));
|
|
161
|
+
else if (key === "$and") parts.push(and(...asFilterArray(value)));
|
|
162
|
+
else if (key === "$nor") parts.push(not(or(...asFilterArray(value))));
|
|
163
|
+
else if (key === "$not") parts.push(not(policyRecordToFilter(value)));
|
|
164
|
+
else if (key.startsWith("$")) throw new Error(`[repo-core] matchesRecordFilter: unsupported top-level operator '${key}'. Supported: $and, $or, $nor, $not.`);
|
|
165
|
+
else parts.push(fieldFilter(key, value));
|
|
166
|
+
if (parts.length === 0) return TRUE;
|
|
167
|
+
return parts.length === 1 ? parts[0] : and(...parts);
|
|
168
|
+
}
|
|
169
|
+
function asFilterArray(value) {
|
|
170
|
+
if (!Array.isArray(value)) throw new Error("[repo-core] matchesRecordFilter: $or/$and/$nor operand must be an array");
|
|
171
|
+
return value.map((entry) => policyRecordToFilter(entry));
|
|
172
|
+
}
|
|
173
|
+
/**
|
|
174
|
+
* Evaluate an arc Mongo-record `_policyFilters` object against a document —
|
|
175
|
+
* converts to Filter IR, then delegates to the shared {@link matchFilter}
|
|
176
|
+
* engine. THE canonical `DataAdapter.matchesFilter` implementation; every
|
|
177
|
+
* kit's adapter delegates here.
|
|
178
|
+
*
|
|
179
|
+
* @param item The already-fetched document / row.
|
|
180
|
+
* @param filters Arc's `_policyFilters` in Mongo record syntax.
|
|
181
|
+
*/
|
|
182
|
+
function matchesRecordFilter(item, filters) {
|
|
183
|
+
if (item === null || typeof item !== "object") return false;
|
|
184
|
+
return matchFilter(item, policyRecordToFilter(filters));
|
|
185
|
+
}
|
|
186
|
+
//#endregion
|
|
187
|
+
export { matchesRecordFilter, policyRecordToFilter };
|
package/dist/filter/match.mjs
CHANGED
|
@@ -7,35 +7,26 @@ function matchFilter(doc, filter) {
|
|
|
7
7
|
case "and": return filter.children.every((child) => matchFilter(doc, child));
|
|
8
8
|
case "or": return filter.children.some((child) => matchFilter(doc, child));
|
|
9
9
|
case "not": return !matchFilter(doc, filter.child);
|
|
10
|
-
case "eq": return
|
|
11
|
-
case "ne": return !
|
|
12
|
-
case "gt": return
|
|
13
|
-
case "gte": return
|
|
14
|
-
case "lt": return
|
|
15
|
-
case "lte": return
|
|
16
|
-
case "in":
|
|
17
|
-
|
|
18
|
-
return filter.values.some((candidate) => equals(v, candidate));
|
|
19
|
-
}
|
|
20
|
-
case "nin": {
|
|
21
|
-
const v = getField(doc, filter.field);
|
|
22
|
-
return !filter.values.some((candidate) => equals(v, candidate));
|
|
23
|
-
}
|
|
10
|
+
case "eq": return someValue(resolve(doc, filter.field), (v) => equals(v, filter.value));
|
|
11
|
+
case "ne": return !someValue(resolve(doc, filter.field), (v) => equals(v, filter.value));
|
|
12
|
+
case "gt": return someValue(resolve(doc, filter.field), (v) => compare(v, filter.value) > 0);
|
|
13
|
+
case "gte": return someValue(resolve(doc, filter.field), (v) => compare(v, filter.value) >= 0);
|
|
14
|
+
case "lt": return someValue(resolve(doc, filter.field), (v) => compare(v, filter.value) < 0);
|
|
15
|
+
case "lte": return someValue(resolve(doc, filter.field), (v) => compare(v, filter.value) <= 0);
|
|
16
|
+
case "in": return someValue(resolve(doc, filter.field), (v) => filter.values.some((candidate) => equals(v, candidate)));
|
|
17
|
+
case "nin": return !someValue(resolve(doc, filter.field), (v) => filter.values.some((candidate) => equals(v, candidate)));
|
|
24
18
|
case "exists": {
|
|
25
|
-
const
|
|
26
|
-
const present = v !== void 0 && v !== null;
|
|
19
|
+
const present = fieldPresent(doc, filter.field);
|
|
27
20
|
return filter.exists ? present : !present;
|
|
28
21
|
}
|
|
29
22
|
case "like": {
|
|
30
|
-
const v = getField(doc, filter.field);
|
|
31
|
-
if (typeof v !== "string") return false;
|
|
32
23
|
const flags = filter.caseSensitivity === "sensitive" ? "" : "i";
|
|
33
|
-
|
|
24
|
+
const re = getOrCompileLike(filter.pattern, flags);
|
|
25
|
+
return someValue(resolve(doc, filter.field), (v) => regexTest(re, v));
|
|
34
26
|
}
|
|
35
27
|
case "regex": {
|
|
36
|
-
const
|
|
37
|
-
|
|
38
|
-
return getOrCompileRegex(filter.pattern, filter.flags).test(v);
|
|
28
|
+
const re = getOrCompileRegex(filter.pattern, filter.flags);
|
|
29
|
+
return someValue(resolve(doc, filter.field), (v) => regexTest(re, v));
|
|
39
30
|
}
|
|
40
31
|
case "raw": return false;
|
|
41
32
|
}
|
|
@@ -47,27 +38,92 @@ function matchFilter(doc, filter) {
|
|
|
47
38
|
function asPredicate(filter) {
|
|
48
39
|
return (doc) => matchFilter(doc, filter);
|
|
49
40
|
}
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
41
|
+
/**
|
|
42
|
+
* Resolve a dot-path to the SET of values it reaches, fanning out over
|
|
43
|
+
* arrays on intermediate segments (Mongo/JSON-path array semantics):
|
|
44
|
+
* `items.sku` on `{ items: [{ sku: 1 }, { sku: 2 }] }` → `[1, 2]`. A path
|
|
45
|
+
* with no array yields a single-element list, so scalar leaf ops behave
|
|
46
|
+
* exactly as before. A leaf array field (`tags`) is returned as one value
|
|
47
|
+
* (the array) so `equals`'s array-contains handles it.
|
|
48
|
+
*/
|
|
49
|
+
/**
|
|
50
|
+
* Path segments that must never be resolved — reading them can surface an
|
|
51
|
+
* inherited member (or, for a JSON-parsed doc where `JSON.parse('{"__proto__":
|
|
52
|
+
* …}')` created a real OWN `__proto__`, a crafted value) and produce a WRONG
|
|
53
|
+
* authorization answer. Denied string-normalized (the object-path CVE-2021-23434
|
|
54
|
+
* lesson: an array-typed segment bypassed a `===` check). Fail closed: any
|
|
55
|
+
* path touching one of these resolves to no values → no match.
|
|
56
|
+
*/
|
|
57
|
+
const DANGEROUS_SEGMENTS = /* @__PURE__ */ new Set([
|
|
58
|
+
"__proto__",
|
|
59
|
+
"constructor",
|
|
60
|
+
"prototype"
|
|
61
|
+
]);
|
|
62
|
+
function resolve(doc, path) {
|
|
63
|
+
let frontier = [doc];
|
|
64
|
+
for (const segment of path.split(".")) {
|
|
65
|
+
if (DANGEROUS_SEGMENTS.has(String(segment))) return [];
|
|
66
|
+
const next = [];
|
|
67
|
+
for (const node of frontier) {
|
|
68
|
+
if (node === null || node === void 0 || typeof node !== "object") continue;
|
|
69
|
+
if (Array.isArray(node)) {
|
|
70
|
+
const idx = Number(segment);
|
|
71
|
+
if (Number.isInteger(idx) && idx >= 0 && idx < node.length) next.push(node[idx]);
|
|
72
|
+
for (const el of node) if (el && typeof el === "object" && Object.hasOwn(el, segment)) next.push(el[segment]);
|
|
73
|
+
} else if (Object.hasOwn(node, segment)) next.push(node[segment]);
|
|
74
|
+
}
|
|
75
|
+
if (next.length === 0) return [];
|
|
76
|
+
frontier = next;
|
|
58
77
|
}
|
|
59
|
-
return
|
|
78
|
+
return frontier;
|
|
79
|
+
}
|
|
80
|
+
/** Is any value reachable at `path` present (defined + non-null)? */
|
|
81
|
+
function fieldPresent(doc, path) {
|
|
82
|
+
return resolve(doc, path).some((v) => v !== void 0 && v !== null);
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* Apply a scalar predicate to a resolved value SET, unwrapping leaf array
|
|
86
|
+
* values so a scalar condition on an array field (`tags`, `scores`)
|
|
87
|
+
* matches when ANY element satisfies it — Mongo + SQL array semantics,
|
|
88
|
+
* concentrated in ONE place so `equals`/`compare`/regex stay pure scalar.
|
|
89
|
+
*/
|
|
90
|
+
function someValue(values, pred) {
|
|
91
|
+
for (const v of values) if (Array.isArray(v)) {
|
|
92
|
+
if (v.some(pred)) return true;
|
|
93
|
+
} else if (pred(v)) return true;
|
|
94
|
+
return false;
|
|
60
95
|
}
|
|
61
96
|
function equals(a, b) {
|
|
97
|
+
if (a === b) return true;
|
|
98
|
+
if (typeof a === "number" && typeof b === "number") return Number.isNaN(a) && Number.isNaN(b);
|
|
62
99
|
if (a instanceof Date && b instanceof Date) return a.getTime() === b.getTime();
|
|
63
100
|
if (a instanceof Date && typeof b === "string") return a.toISOString() === b;
|
|
64
101
|
if (b instanceof Date && typeof a === "string") return b.toISOString() === a;
|
|
65
|
-
|
|
102
|
+
const as = idString(a);
|
|
103
|
+
const bs = idString(b);
|
|
104
|
+
if (as !== void 0 && bs !== void 0) return as === bs;
|
|
105
|
+
return false;
|
|
106
|
+
}
|
|
107
|
+
/** String form of an id-like value for coercing comparison; else undefined. */
|
|
108
|
+
function idString(value) {
|
|
109
|
+
if (typeof value === "string") return value;
|
|
110
|
+
if (value === null || value === void 0 || typeof value !== "object") return void 0;
|
|
111
|
+
if (Array.isArray(value)) return void 0;
|
|
112
|
+
const s = String(value);
|
|
113
|
+
return s === "[object Object]" ? void 0 : s;
|
|
66
114
|
}
|
|
67
115
|
function compare(a, b) {
|
|
116
|
+
if (a instanceof Date && typeof b === "string") {
|
|
117
|
+
const t = Date.parse(b);
|
|
118
|
+
if (!Number.isNaN(t)) b = new Date(t);
|
|
119
|
+
} else if (b instanceof Date && typeof a === "string") {
|
|
120
|
+
const t = Date.parse(a);
|
|
121
|
+
if (!Number.isNaN(t)) a = new Date(t);
|
|
122
|
+
}
|
|
68
123
|
const aNum = toComparable(a);
|
|
69
124
|
const bNum = toComparable(b);
|
|
70
125
|
if (aNum === void 0 || bNum === void 0) return NaN;
|
|
126
|
+
if (typeof aNum !== typeof bNum) return NaN;
|
|
71
127
|
if (aNum < bNum) return -1;
|
|
72
128
|
if (aNum > bNum) return 1;
|
|
73
129
|
return 0;
|
|
@@ -114,6 +170,21 @@ function getOrCompileRegex(pattern, flags) {
|
|
|
114
170
|
regexCache.set(key, re);
|
|
115
171
|
return re;
|
|
116
172
|
}
|
|
173
|
+
/**
|
|
174
|
+
* Max string length fed to a regex `.test()`. ReDoS is `pattern × input`;
|
|
175
|
+
* even a benign developer-written pattern can go quadratic on a pathological
|
|
176
|
+
* INPUT string — and in a realtime fan-out the matcher runs once per
|
|
177
|
+
* subscriber per record, so one slow match blocks the event loop and
|
|
178
|
+
* amplifies across the whole subscriber set. A field value longer than this
|
|
179
|
+
* is treated as NO MATCH (fail closed) rather than risking a stall; policy
|
|
180
|
+
* filters never regex-test megabyte fields. (Trusted-source patterns make
|
|
181
|
+
* pattern-side ReDoS a non-issue; this caps the input side.)
|
|
182
|
+
*/
|
|
183
|
+
const MAX_REGEX_INPUT = 64 * 1024;
|
|
184
|
+
/** Guarded regex test: string-only, input-length-capped (see MAX_REGEX_INPUT). */
|
|
185
|
+
function regexTest(re, v) {
|
|
186
|
+
return typeof v === "string" && v.length <= MAX_REGEX_INPUT && re.test(v);
|
|
187
|
+
}
|
|
117
188
|
/** SQL `LIKE` pattern → JS regex body. Escapes regex metachars; `%` → `.*`, `_` → `.`. */
|
|
118
189
|
function likeToRegex(pattern) {
|
|
119
190
|
let out = "";
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@classytic/repo-core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.14.0",
|
|
4
4
|
"description": "Driver-agnostic repository primitives: hooks, Filter IR, operations, pagination, cache contract. Foundation for mongokit, sqlitekit, pgkit, and prismakit. Lean by design — no plugins ship here; each kit owns its own.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"sideEffects": false,
|