grafeo-ogm 1.8.3 → 1.8.5

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 CHANGED
@@ -1,5 +1,188 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.8.5 (2026-05-07) — 🚨 SECURITY ADVISORY
4
+
5
+ > **CRITICAL.** When a query traversed a relationship in a WHERE filter (`_SOME` / `_NONE` / `_ALL` / `_SINGLE` / `*Connection*` / union-typed relationships), the target type's NLS `'read'` policy was NOT applied inside the `EXISTS` body. A kill-switched org with a leaked target-type ID could use `where: { contentRel_SOME: { id: 'leakedId' } }` as a confirmation oracle / IDOR vector — even when its source-type policy correctly denied direct reads of the target type. **Upgrade immediately if you use NLS policies on target types reachable via relationship filters.**
6
+
7
+ ### Affected versions
8
+
9
+ `1.7.0` through `1.8.4` (all NLS-enabled releases prior to 1.8.5).
10
+
11
+ ### Severity
12
+
13
+ **CRITICAL** — survived the v1.7.0 NLS audit, the v1.8.0 perf audit, and the v1.8.2 default-deny security fix. Identified by external reviewers running mutation-style analysis on a downstream service.
14
+
15
+ ### Impact
16
+
17
+ `WhereCompiler.tryCompileRelationship`, `compileUnionRelationship`, and `tryCompileConnection`/`compileConnectionWhereInput` recursed into target types via `compileConditions(...)` directly — there was no path for the target's `'read'` policy to AND-stitch into the EXISTS body. `SelectionCompiler.compileNestedWhere` already had the correct pattern (re-resolve `targetPolicy` via `policyContext.resolveForType(targetTypeName, 'read')` and inject a synthesized bundle); WhereCompiler did not.
18
+
19
+ `InterfaceModel.find()` and `aggregate()` had a related gap — they never passed `policyContext` to `whereCompiler.compile()` at all, so even with the source-side fix, user-where relationship traversals on interface entry points still bypassed target-side policy.
20
+
21
+ ### Reproducer
22
+
23
+ ```ts
24
+ // User has a permissive NLS policy that grants 'read' iff !ctx.killed.
25
+ ogm.policies.register('Content', {
26
+ permissive: [{
27
+ operations: ['read'],
28
+ appliesWhen: (ctx) => !ctx.killed,
29
+ when: () => ({}),
30
+ }],
31
+ });
32
+
33
+ // Attacker's ctx has killed = true. Direct reads correctly return [].
34
+ await ogm.withContext({ killed: true })
35
+ .model('Content')
36
+ .findUnique({ where: { id: 'leaked' } });
37
+ // Pre/Post-1.8.5: WHERE false → [] ✅
38
+
39
+ // Pre-1.8.5 IDOR: traverse via a relationship.
40
+ await ogm.withContext({ killed: true, userId: 'attacker' })
41
+ .model('User')
42
+ .find({ where: { contentRel_SOME: { id: 'leaked' } } });
43
+ // Pre-1.8.5 cypher:
44
+ // MATCH (n:User)
45
+ // WHERE EXISTS { MATCH (n)-[:HAS_CONTENT]->(r0:Content) WHERE r0.id = $param0 }
46
+ // RETURN n
47
+ // The User-side policy fires, but Content's appliesWhen never runs.
48
+ // If the attacker has any User row at all (own profile), the EXISTS
49
+ // acts as a confirmation oracle: row exists ↔ leaked Content ID
50
+ // exists in the graph + linked to the attacker's user.
51
+ //
52
+ // Post-1.8.5 cypher:
53
+ // MATCH (n:User)
54
+ // WHERE EXISTS { MATCH (n)-[:HAS_CONTENT]->(r0:Content)
55
+ // WHERE (r0.id = $param0) AND false }
56
+ // RETURN n
57
+ // Content's appliesWhen runs → permissives.length > 0 but all abstain
58
+ // → permClause = 'false' → EXISTS can never match → IDOR closed.
59
+ ```
60
+
61
+ ### Root cause
62
+
63
+ `WhereCompiler.tryCompileRelationship` (line 856 pre-fix) and the union/connection variants recursed via:
64
+
65
+ ```ts
66
+ const innerResult = this.compileConditions(value, relVar, targetNodeDef, ...);
67
+ // ❌ No policyContext threaded — target policy never AND-stitched
68
+ ```
69
+
70
+ The correct pattern, already present in `SelectionCompiler.compileNestedWhere` (selection.compiler.ts:826-847), is:
71
+
72
+ ```ts
73
+ const targetPolicy = policyContext?.resolveForType(targetDef.typeName, 'read');
74
+ const targetBundle = targetPolicy ? { ctx, operation: 'read', resolved: targetPolicy, ... } : undefined;
75
+ const compiled = this.whereCompiler.compile(value, childVar, targetDef, ..., { policyContext: targetBundle });
76
+ // ✅ result.cypher = (user filter) AND (target policy) AND-stitched
77
+ ```
78
+
79
+ The fix mirrors this pattern at every WHERE-side relationship-traversal entry point.
80
+
81
+ ### Fix
82
+
83
+ 5 surfaces closed:
84
+
85
+ - `src/compilers/where.compiler.ts` — `tryCompileRelationship` (`_SOME` / `_NONE` / `_NOT` / `_ALL` / `_SINGLE` quantifiers + bare-relationship shorthand): now builds `targetBundle = buildTargetBundle(targetNodeDef.typeName, policyContext)` and re-enters `this.compile()` with the bundle. Inner result already AND-stitches user + policy; the EXISTS template substitutes it.
86
+ - `src/compilers/where.compiler.ts` — `compileUnionRelationship`: same pattern per union member, so each branch enforces ITS OWN target policy (not the union's).
87
+ - `src/compilers/where.compiler.ts` — `tryCompileConnection` / `compileConnectionWhereInput`: `node` and `node_NOT` keys now route through `this.compile()` with target bundle. Recursive `AND` / `OR` / `NOT` paths inside connection-where propagate `policyContext` for any nested node-keys to pick up.
88
+ - `src/compilers/where.compiler.ts` — `_SINGLE` quantifier: distinguishes user-`@cypher` vs policy-`@cypher` errors so authors get a precise message naming the target type when the policy is the blocker.
89
+ - `src/interface-model.ts` — `find()` and `aggregate()` now pass a synthetic `PolicyContextBundle` with `resolved.overridden: true` (no top-level policy clause emit, since CASE-per-label already enforces source-side policy) and live `resolveForType` (so user-where relationship traversals enforce target-side policy via the new WhereCompiler logic).
90
+
91
+ ### What this DOES NOT change
92
+
93
+ - **No-policy queries** — when no `policyContext` is present, the relationship-traversal code paths short-circuit identically to pre-1.8.5. Byte-identical Cypher emission, byte-identical params, zero overhead.
94
+ - **Source-side policy** — the v1.7.0+ AND-stitch of source-type policy at the top-level `WHERE` is unchanged. `compilePolicyClause` is untouched.
95
+ - **Edge WHERE** — relationship-properties types are NOT registered in the policy registry by design. No NLS on edges. `compileEdgeConditions` is unchanged.
96
+ - **`null` relationship → `NOT EXISTS`** — graph-shape only (no row leakage). Documented as out-of-scope; no change.
97
+ - **Policy-on-policy recursion** — when a policy's own `when()` partial uses `_SOME`, the recursive `compileConditions` calls inside `compilePolicyClause` deliberately do NOT thread `policyContext`. Adding this would create infinite-regress risk requiring cycle detection. Documented as deferred to v1.9.0.
98
+
99
+ ### Tests
100
+
101
+ New file `tests/policy/where-relationship-traversal.spec.ts` — 7 regression tests using a real `WhereCompiler` instance and a real `PolicyContextBundle` with a real `resolveForType` callback. NO mocks of the WhereCompiler or OGM.
102
+
103
+ Cases covered:
104
+ 1. `_SOME` + permissive on target → asserts target permissive predicate inside EXISTS body
105
+ 2. `_NONE` + restrictive on target → asserts restrictive AND-stitched inside `NOT EXISTS { ... }`
106
+ 3. `_ALL` + default-deny → asserts target unreachable
107
+ 4. `*Connection { node: {...} }` + permissive on target → asserts connection EXISTS body has policy
108
+ 5. Union relationship `_SOME: { Image: {...} }` + per-member permissive → policy in `Image` branch only
109
+ 6. `_SINGLE` + permissive (stored properties only) → works
110
+ 7. `_SINGLE` + policy with `@cypher` field → throws with target-type-named error
111
+
112
+ Test count: **1381 → 1388** (+7). All 1388 pass.
113
+
114
+ **Zero existing tests required modification.** The byte-identical regression contract held: when no `policyContext` is passed, every relationship-traversal path emits the same Cypher as pre-1.8.5.
115
+
116
+ ### Compatibility
117
+
118
+ Public API surface unchanged. Cypher emission changes ONLY for queries with `policyContext` AND a relationship traversal AND a registered `'read'` policy on the target type — exactly the queries that were silently leaking pre-1.8.5.
119
+
120
+ ### Acknowledgement
121
+
122
+ Discovered by external reviewers performing mutation-style analysis on a downstream service. Reported, scoped via parallel exploration agents, fixed via parallel implementation agents, tested with real-compiler regression tests, and shipped within hours.
123
+
124
+ ### For consumers writing tests against grafeo-ogm
125
+
126
+ The failure mode that hid CRIT-3 in downstream services was mocking `clientOgm.find()` itself — those tests proved wiring (the service called the OGM with the right ctx) but never proved authorization (the kill-switch policy actually compiled to `WHERE false`). The robust pattern, used throughout grafeo-ogm's own test suite, is to construct a real `OGM` with a mocked `Driver` (capture `cypher` + `params` via `session.run`), let the real WhereCompiler run, and assert on the captured Cypher. See `tests/policy/where-relationship-traversal.spec.ts` for a concrete example. A first-class `TestDriver` API is planned for v1.9.0.
127
+
128
+ ---
129
+
130
+ ## 1.8.4 (2026-05-07)
131
+
132
+ > **Backwards-compat fix.** `Model` now always auto-emits `__typename` in default projections — matching `@neo4j/graphql-ogm` behaviour. Apollo Client cache normalisation, type-discriminated unions in TS callers, and any consumer that uses `__typename` as a discriminator now get it on every concrete-type read and mutation, not just on union/interface targets.
133
+
134
+ ### What was happening
135
+
136
+ ```ts
137
+ const result = await ogm.model('Book').find({ where: { id: 'b1' } });
138
+ // Pre-1.8.4: { id: 'b1', title: 'Dune', pageCount: 412 }
139
+ // Post-1.8.4: { __typename: 'Book', id: 'b1', title: 'Dune', pageCount: 412 }
140
+ ```
141
+
142
+ Pre-1.8.4, `__typename` was auto-emitted ONLY when the target was abstract (union or interface with implementations) — `selection.compiler.ts:145-158`. Reads of concrete `@node` types (`Book`, `User`, etc.) returned the schema's scalar properties and nothing else. Mutations without explicit selection returned the bare Neo4j Node, which has no `__typename` field at all.
143
+
144
+ This was a documented behaviour difference from `@neo4j/graphql-ogm` that consumers running Apollo Client / GraphQL pipelines hit immediately:
145
+
146
+ - Apollo's `InMemoryCache` keys entries by `__typename + id`. Without `__typename` it can't normalise.
147
+ - Discriminated-union pattern matching in TypeScript silently falls into the `default` branch.
148
+ - Test fixtures depending on `__typename === 'Book'` evaluate to `false`.
149
+
150
+ ### Fix
151
+
152
+ Three call sites in `src/model.ts` now route through the default projection (which includes `__typename`) instead of bailing to a bare `RETURN n`:
153
+
154
+ - `defaultSelection()` (line 1728) — `__typename` is now the first scalar in the default selection list. `find()`, `findFirst()`, `findUnique()`, `findFirstOrThrow()`, `findUniqueOrThrow()`, `searchByVector()`, and `searchByPhrase()` all inherit this transitively via `resolveSelection()`.
155
+ - `applySelectionSetToMutation()` (line 1770) — when `selectionSet` is undefined (the no-explicit-selection path used by `create()` / `update()` / `delete()` / `setLabels()`), now compiles `defaultSelection()` instead of leaving the bare `RETURN n`.
156
+ - `applySelectionSetToUpsert()` (line 1888) — same fix for `upsert()`.
157
+
158
+ `SelectionCompiler` already understood `__typename` as a synthetic scalar field (line 164 of `selection.compiler.ts`) — for concrete types it emits the constant string (`__typename: 'Book'`), for unions/interfaces it emits a `head([__label IN labels(n) WHERE __label IN [...]])` expression. The fix is purely a default-selection change; no compiler logic changed.
159
+
160
+ ### What this DOES NOT change
161
+
162
+ - **Explicit `select` / `selectionSet`** — callers who specify their own projection get exactly what they ask for. We do NOT silently inject `__typename` into a user-supplied selection. If you want it, ask for it explicitly: `select: { __typename: true, id: true }`.
163
+ - **InterfaceModel** — already emitted `__typename` via CASE-per-label (resolved from `labels(n)`). Unchanged.
164
+ - **Abstract relationship targets** — `Model.find({ select: { authors: { ... } } })` where `authors` points at a union/interface still auto-emits `__typename` in the nested projection, same as pre-1.8.4.
165
+ - **`@cypher` field** projections, `aggregate()`, `count()` — all unchanged.
166
+
167
+ ### Behaviour change risks
168
+
169
+ Two edge cases for consumers to be aware of:
170
+
171
+ 1. **Wire size** — every entity result now includes a `__typename` field. For a 50-row result this is ~50 × ~13 bytes (`__typename: 'Book'`) = ~650 extra bytes. Negligible for almost all callers.
172
+ 2. **Schema-vs-store divergence** — pre-1.8.4 mutations without selection returned the raw Neo4j Node, including any properties stored on the node that are NOT in the GraphQL schema (e.g., legacy fields removed from the SDL but still present in the database). Post-1.8.4 the projection includes only schema-declared fields plus `__typename`. If your code relied on out-of-schema properties leaking through mutation responses, you must either (a) add them to the schema or (b) supply an explicit `selectionSet` that mentions them. Reads via `find()` already had this constraint pre-1.8.4 and behaved identically.
173
+
174
+ ### Tests
175
+
176
+ - New file `tests/typename-auto-emit.spec.ts` — 11 regression tests covering `find()`, `findFirst()`, `findUnique()`, `create()`, `update()`, `upsert()`, explicit-select preservation, and unchanged InterfaceModel behaviour.
177
+ - One existing test in `tests/policy/model-mutation.spec.ts:305` was updated — it asserted "policy bypass means cypher must NOT contain `ownerId`" via a bare substring match. With the new projection emitting `.\`ownerId\``, the test now asserts the policy's WHERE-clause shape (`n.\`ownerId\` = $...`) is absent, which was the test's actual intent.
178
+ - Test count: **1370 → 1381** (+11). All pass.
179
+
180
+ ### Compatibility
181
+
182
+ Public API surface unchanged. The Cypher emit changes for `find()`-family and mutation calls that did NOT pass an explicit `select` / `selectionSet`. Result shape gains a single `__typename` field; existing fields remain. This is the third backwards-compat fix in the v1.8.x series (after v1.8.1 connection-shape WHERE in nested disconnect/connect and v1.8.2 NLS permissive abstain default-deny).
183
+
184
+ ---
185
+
3
186
  ## 1.8.3 (2026-05-06)
4
187
 
5
188
  > **Bugfix.** `ogm.model('SomeInterface')` no longer silently returns an `InterfaceModel` — it now throws an `OGMError` immediately, naming the correct API. Pre-1.8.3 this returned an `InterfaceModel` cast to `any`, and the first mutation call (`.create()`, `.update()`, `.delete()`, `.upsert()`, `.setLabels()`) failed with `TypeError: this.create is not a function`.
@@ -70,6 +70,21 @@ export declare class WhereCompiler {
70
70
  * to guarantee no collision with `param0..N`.
71
71
  */
72
72
  private compilePolicyClause;
73
+ /**
74
+ * Build a `PolicyContextBundle` for a target type when crossing a
75
+ * node-type boundary inside a relationship traversal (`_SOME` /
76
+ * `_NONE` / `_ALL` / `_SINGLE` / connection-where `node`). Mirrors the
77
+ * canonical pattern at `selection.compiler.ts:826-847`.
78
+ *
79
+ * Returns `undefined` when:
80
+ * - the input `policyContext` is `undefined` (caller has no policy state), OR
81
+ * - `resolveForType(typeName, 'read')` returns `null` (no policy
82
+ * registered for the target type).
83
+ *
84
+ * The synthesized bundle reuses the same `resolveForType` so further
85
+ * nesting (target → target's relationship → ...) keeps cascading.
86
+ */
87
+ private buildTargetBundle;
73
88
  private compileConditions;
74
89
  /**
75
90
  * Resolve the Cypher reference for a where-clause field. For stored
@@ -1 +1 @@
1
- {"version":3,"file":"where.compiler.d.ts","sourceRoot":"","sources":["../../../src/compilers/where.compiler.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,iBAAiB,CAAC;AAC3D,OAAO,EACL,cAAc,EAGd,cAAc,EACf,MAAM,iBAAiB,CAAC;AAezB,MAAM,WAAW,WAAW;IAC1B,MAAM,EAAE,MAAM,CAAC;IACf,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IAChC;;;;;;;;;;OAUG;IACH,QAAQ,CAAC,EAAE,MAAM,EAAE,CAAC;CACrB;AAqDD,KAAK,cAAc,GAAG,MAAM,CAAC;AA4B7B,MAAM,WAAW,oBAAoB;IACnC,mFAAmF;IACnF,iBAAiB,CAAC,EAAE,GAAG,CAAC,cAAc,CAAC,CAAC;IACxC;;;;;;OAMG;IACH,WAAW,CAAC,EAAE,OAAO,CAAC;CACvB;AAED;;GAEG;AACH,qBAAa,aAAa;IAKtB,OAAO,CAAC,MAAM;IAJhB,OAAO,CAAC,iBAAiB,CAAsB;IAC/C,OAAO,CAAC,WAAW,CAAU;gBAGnB,MAAM,EAAE,cAAc,EAC9B,OAAO,CAAC,EAAE,oBAAoB;IAMhC,OAAO,CACL,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,SAAS,GAAG,IAAI,EACjD,OAAO,EAAE,MAAM,EACf,OAAO,EAAE,cAAc,EACvB,YAAY,GAAE;QAAE,KAAK,EAAE,MAAM,CAAA;KAAiB,EAC9C,OAAO,CAAC,EAAE;QACR;;;;;WAKG;QACH,YAAY,CAAC,EAAE,aAAa,CAAC,MAAM,CAAC,CAAC;QACrC;;;;;;;;;;WAUG;QACH,aAAa,CAAC,EAAE,mBAAmB,CAAC;KACrC,GACA,WAAW;IA2Cd;;;;;;;;OAQG;IACH,OAAO,CAAC,mBAAmB;IA6K3B,OAAO,CAAC,iBAAiB;IA4LzB;;;;;OAKG;IACH,OAAO,CAAC,eAAe;IAavB,OAAO,CAAC,oBAAoB;IAyH5B;;;;;;;;;;;OAWG;IACH,OAAO,CAAC,2BAA2B;IAqHnC,OAAO,CAAC,sBAAsB;IA2I9B;;;;;OAKG;IACH,OAAO,CAAC,wBAAwB;IAyFhC,OAAO,CAAC,qBAAqB;IA8B7B;;;;;;OAMG;IACH,OAAO,CAAC,sBAAsB;CAmF/B"}
1
+ {"version":3,"file":"where.compiler.d.ts","sourceRoot":"","sources":["../../../src/compilers/where.compiler.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,iBAAiB,CAAC;AAC3D,OAAO,EACL,cAAc,EAGd,cAAc,EACf,MAAM,iBAAiB,CAAC;AAezB,MAAM,WAAW,WAAW;IAC1B,MAAM,EAAE,MAAM,CAAC;IACf,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IAChC;;;;;;;;;;OAUG;IACH,QAAQ,CAAC,EAAE,MAAM,EAAE,CAAC;CACrB;AAqDD,KAAK,cAAc,GAAG,MAAM,CAAC;AA4B7B,MAAM,WAAW,oBAAoB;IACnC,mFAAmF;IACnF,iBAAiB,CAAC,EAAE,GAAG,CAAC,cAAc,CAAC,CAAC;IACxC;;;;;;OAMG;IACH,WAAW,CAAC,EAAE,OAAO,CAAC;CACvB;AAED;;GAEG;AACH,qBAAa,aAAa;IAKtB,OAAO,CAAC,MAAM;IAJhB,OAAO,CAAC,iBAAiB,CAAsB;IAC/C,OAAO,CAAC,WAAW,CAAU;gBAGnB,MAAM,EAAE,cAAc,EAC9B,OAAO,CAAC,EAAE,oBAAoB;IAMhC,OAAO,CACL,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,SAAS,GAAG,IAAI,EACjD,OAAO,EAAE,MAAM,EACf,OAAO,EAAE,cAAc,EACvB,YAAY,GAAE;QAAE,KAAK,EAAE,MAAM,CAAA;KAAiB,EAC9C,OAAO,CAAC,EAAE;QACR;;;;;WAKG;QACH,YAAY,CAAC,EAAE,aAAa,CAAC,MAAM,CAAC,CAAC;QACrC;;;;;;;;;;WAUG;QACH,aAAa,CAAC,EAAE,mBAAmB,CAAC;KACrC,GACA,WAAW;IAoDd;;;;;;;;OAQG;IACH,OAAO,CAAC,mBAAmB;IA6K3B;;;;;;;;;;;;;OAaG;IACH,OAAO,CAAC,iBAAiB;IAgBzB,OAAO,CAAC,iBAAiB;IA2MzB;;;;;OAKG;IACH,OAAO,CAAC,eAAe;IAavB,OAAO,CAAC,oBAAoB;IA0I5B;;;;;;;;;;;OAWG;IACH,OAAO,CAAC,2BAA2B;IAiKnC,OAAO,CAAC,sBAAsB;IAsK9B;;;;;OAKG;IACH,OAAO,CAAC,wBAAwB;IA8GhC,OAAO,CAAC,qBAAqB;IA8B7B;;;;;;OAMG;IACH,OAAO,CAAC,sBAAsB;CAmF/B"}
@@ -82,7 +82,7 @@ class WhereCompiler {
82
82
  // their own scopes and stitch their preludes into the EXISTS body inline.
83
83
  const scope = new cypher_field_projection_1.CypherFieldScope(nodeVar, options?.preserveVars ?? [], '__where');
84
84
  const userBody = hasUserWhere
85
- ? this.compileConditions(where, nodeVar, nodeDef, paramCounter, 0, scope)
85
+ ? this.compileConditions(where, nodeVar, nodeDef, paramCounter, 0, scope, undefined, policyContext)
86
86
  : { cypher: '', params: {} };
87
87
  let cypher = userBody.cypher;
88
88
  const params = { ...userBody.params };
@@ -236,6 +236,34 @@ class WhereCompiler {
236
236
  ? permClause
237
237
  : `(${permClause} AND ${restClause})`;
238
238
  }
239
+ /**
240
+ * Build a `PolicyContextBundle` for a target type when crossing a
241
+ * node-type boundary inside a relationship traversal (`_SOME` /
242
+ * `_NONE` / `_ALL` / `_SINGLE` / connection-where `node`). Mirrors the
243
+ * canonical pattern at `selection.compiler.ts:826-847`.
244
+ *
245
+ * Returns `undefined` when:
246
+ * - the input `policyContext` is `undefined` (caller has no policy state), OR
247
+ * - `resolveForType(typeName, 'read')` returns `null` (no policy
248
+ * registered for the target type).
249
+ *
250
+ * The synthesized bundle reuses the same `resolveForType` so further
251
+ * nesting (target → target's relationship → ...) keeps cascading.
252
+ */
253
+ buildTargetBundle(typeName, policyContext) {
254
+ if (!policyContext)
255
+ return undefined;
256
+ const targetPolicy = policyContext.resolveForType(typeName, 'read');
257
+ if (!targetPolicy)
258
+ return undefined;
259
+ return {
260
+ ctx: policyContext.ctx,
261
+ operation: 'read',
262
+ resolved: targetPolicy,
263
+ resolveForType: policyContext.resolveForType,
264
+ defaults: policyContext.defaults,
265
+ };
266
+ }
239
267
  compileConditions(where, nodeVar, nodeDef, counter, depth, scope,
240
268
  /**
241
269
  * Optional shared params accumulator. When provided, every leaf
@@ -247,7 +275,18 @@ class WhereCompiler {
247
275
  * deep recursions write into a single owner object. Public callers
248
276
  * keep the old contract (no arg → fresh Map allocated locally).
249
277
  */
250
- paramsTarget) {
278
+ paramsTarget,
279
+ /**
280
+ * Policy context to thread through relationship / connection
281
+ * recursions so target-type `'read'` policies AND-stitch into the
282
+ * EXISTS body when crossing a node-type boundary. NOT used for
283
+ * leaf scalar predicates or for AND/OR/NOT logical recursions
284
+ * within the same node-type scope (those simply forward through).
285
+ *
286
+ * v1.8.5 — fixes CRIT-3 (target-policy bypass via `_SOME`/`_NONE`/
287
+ * `_ALL`/`_SINGLE` and `Connection_*` user-where filters).
288
+ */
289
+ policyContext) {
251
290
  if (depth > MAX_DEPTH)
252
291
  throw new errors_1.OGMError(`WHERE clause nesting depth exceeds maximum (${MAX_DEPTH})`);
253
292
  if (where == null)
@@ -277,7 +316,7 @@ class WhereCompiler {
277
316
  // method with paramsTarget undefined at intermediate levels.
278
317
  const subClauses = [];
279
318
  for (const item of items) {
280
- const sub = this.compileConditions(item, nodeVar, nodeDef, counter, depth + 1, scope, params);
319
+ const sub = this.compileConditions(item, nodeVar, nodeDef, counter, depth + 1, scope, params, policyContext);
281
320
  if (sub.cypher)
282
321
  subClauses.push(sub.cypher);
283
322
  }
@@ -291,7 +330,7 @@ class WhereCompiler {
291
330
  continue;
292
331
  if (!(0, validation_1.isPlainObject)(value))
293
332
  throw new errors_1.OGMError(`NOT operator requires an object value.`);
294
- const sub = this.compileConditions(value, nodeVar, nodeDef, counter, depth + 1, scope, params);
333
+ const sub = this.compileConditions(value, nodeVar, nodeDef, counter, depth + 1, scope, params, policyContext);
295
334
  if (sub.cypher)
296
335
  clauses.push(`NOT (${sub.cypher})`);
297
336
  continue;
@@ -330,14 +369,14 @@ class WhereCompiler {
330
369
  continue;
331
370
  }
332
371
  // Connection operators (e.g. hasStatusConnection_SOME)
333
- const connResult = this.tryCompileConnection(key, value, nodeVar, nodeDef, counter, depth);
372
+ const connResult = this.tryCompileConnection(key, value, nodeVar, nodeDef, counter, depth, policyContext);
334
373
  if (connResult) {
335
374
  clauses.push(connResult.cypher);
336
375
  (0, validation_1.mergeParams)(params, connResult.params);
337
376
  continue;
338
377
  }
339
378
  // Relationship operators (e.g. drugs_SOME, drugs_NONE)
340
- const relResult = this.tryCompileRelationship(key, value, nodeVar, nodeDef, counter, depth);
379
+ const relResult = this.tryCompileRelationship(key, value, nodeVar, nodeDef, counter, depth, policyContext);
341
380
  if (relResult) {
342
381
  clauses.push(relResult.cypher);
343
382
  (0, validation_1.mergeParams)(params, relResult.params);
@@ -377,7 +416,15 @@ class WhereCompiler {
377
416
  return scope.register(fieldName, propDef);
378
417
  return `${nodeVar}.${(0, validation_1.escapeIdentifier)(fieldName)}`;
379
418
  }
380
- tryCompileConnection(key, value, nodeVar, nodeDef, counter, depth) {
419
+ tryCompileConnection(key, value, nodeVar, nodeDef, counter, depth,
420
+ /**
421
+ * Policy context for target-type enforcement inside the connection's
422
+ * `node` filter. v1.8.5 — fixes CRIT-3: the target type's `'read'`
423
+ * policy MUST AND-stitch into the EXISTS body when crossing a node-
424
+ * type boundary via a connection-where filter. Edges have no NLS by
425
+ * design, so this never threads into `compileEdgeConditions`.
426
+ */
427
+ policyContext) {
381
428
  let connSuffix = null;
382
429
  let fieldName = '';
383
430
  for (const suffix of CONNECTION_SUFFIXES)
@@ -419,16 +466,24 @@ class WhereCompiler {
419
466
  const propsDef = relDef.properties
420
467
  ? (this.schema.relationshipProperties.get(relDef.properties) ?? null)
421
468
  : null;
422
- const inner = this.compileConnectionWhereInput(value, relVar, edgeVar, targetNodeDef, propsDef, nodeScope, edgeScope, counter, depth);
469
+ const inner = this.compileConnectionWhereInput(value, relVar, edgeVar, targetNodeDef, propsDef, nodeScope, edgeScope, counter, depth, policyContext);
423
470
  const innerClauses = inner.cypher ? [inner.cypher] : [];
424
471
  const innerParams = inner.params;
425
472
  // Stitch the inner preludes (CALL { ... } + WITH ...) INSIDE the
426
473
  // EXISTS body, between the MATCH pattern and the inner WHERE.
474
+ // - `nodeScope.emit()` — preludes from `@cypher` fields registered
475
+ // directly on the connection-where input's edge-side scope.
476
+ // - `edgeScope.emit()` — same, but edge-side.
477
+ // - `inner.preludes` (v1.8.5) — preludes returned by the recursive
478
+ // `compile()` calls on the node side, which now own their own
479
+ // scopes.
427
480
  const innerPreludes = [];
428
481
  if (nodeScope.hasAny())
429
482
  innerPreludes.push(...nodeScope.emit());
430
483
  if (edgeScope.hasAny())
431
484
  innerPreludes.push(...edgeScope.emit());
485
+ if (inner.preludes && inner.preludes.length > 0)
486
+ innerPreludes.push(...inner.preludes);
432
487
  const preludeFragment = innerPreludes.length
433
488
  ? ` ${innerPreludes.join(' ')}`
434
489
  : '';
@@ -477,14 +532,43 @@ class WhereCompiler {
477
532
  * constrain the same `(relVar, edgeVar)` pair. This matches the codegen
478
533
  * shape declared in `connection-emitter.ts`.
479
534
  */
480
- compileConnectionWhereInput(value, relVar, edgeVar, targetNodeDef, propsDef, nodeScope, edgeScope, counter, depth) {
535
+ compileConnectionWhereInput(value, relVar, edgeVar, targetNodeDef, propsDef, nodeScope, edgeScope, counter, depth,
536
+ /**
537
+ * Policy context to thread into target-side `node` / `node_NOT`
538
+ * filters AND through nested AND/OR/NOT logical groups. Edge-side
539
+ * compilation never receives this — edges have no NLS by design.
540
+ * v1.8.5 — fixes CRIT-3.
541
+ */
542
+ policyContext) {
481
543
  const innerClauses = [];
482
544
  const innerParams = {};
545
+ /**
546
+ * Preludes returned by recursive `compile()` calls on the node side.
547
+ * Pre-1.8.5 the node-side scope was shared via `nodeScope`; now the
548
+ * inner `compile()` owns its own scope and we hoist its preludes back
549
+ * out so the caller (`tryCompileConnection`) can stitch them between
550
+ * `MATCH ${pattern}` and `WHERE ...` inside the EXISTS body.
551
+ */
552
+ const extraPreludes = [];
553
+ // Resolve the target type's `'read'` policy ONCE per connection-where
554
+ // input. The same bundle is reused across every `node` / `node_NOT`
555
+ // key we encounter at this level (and across nested AND/OR/NOT
556
+ // groups via direct policyContext threading).
557
+ const targetBundle = this.buildTargetBundle(targetNodeDef.typeName, policyContext);
483
558
  for (const [key, val] of Object.entries(value)) {
484
559
  if (val === undefined)
485
560
  continue;
486
561
  if (key === 'node' || key === 'node_NOT') {
487
- const nodeResult = this.compileConditions(val, relVar, targetNodeDef, counter, depth + 1, nodeScope);
562
+ // Re-enter the top-level `compile()` so that the target type's
563
+ // `'read'` policy AND-stitches into the same body that the user
564
+ // filter produces. `compile()` manages its own inner scope and
565
+ // returns any preludes via `WhereResult.preludes`; we hoist
566
+ // those into `extraPreludes` so the surrounding `tryCompileConnection`
567
+ // can stitch them inside the EXISTS body alongside the existing
568
+ // `nodeScope.emit()` content.
569
+ const nodeResult = this.compile(val, relVar, targetNodeDef, counter, targetBundle ? { policyContext: targetBundle } : undefined);
570
+ if (nodeResult.preludes && nodeResult.preludes.length > 0)
571
+ extraPreludes.push(...nodeResult.preludes);
488
572
  if (nodeResult.cypher) {
489
573
  innerClauses.push(key === 'node_NOT'
490
574
  ? `NOT (${nodeResult.cypher})`
@@ -508,34 +592,50 @@ class WhereCompiler {
508
592
  if (items.length > MAX_LOGICAL_ARRAY_LENGTH)
509
593
  throw new errors_1.OGMError(`${key} array length ${items.length} exceeds the maximum of ${MAX_LOGICAL_ARRAY_LENGTH}. ` +
510
594
  `Restructure the predicate instead of passing a large logical array inside a connection where.`);
511
- const subResults = items.map((item) => this.compileConnectionWhereInput(item, relVar, edgeVar, targetNodeDef, propsDef, nodeScope, edgeScope, counter, depth + 1));
595
+ // Thread `policyContext` through so deeply-nested connection-
596
+ // where keeps target-policy enforcement.
597
+ const subResults = items.map((item) => this.compileConnectionWhereInput(item, relVar, edgeVar, targetNodeDef, propsDef, nodeScope, edgeScope, counter, depth + 1, policyContext));
512
598
  const subClauses = subResults.map((r) => r.cypher).filter(Boolean);
513
599
  if (subClauses.length > 0) {
514
600
  innerClauses.push(`(${subClauses.join(` ${key} `)})`);
515
- for (const r of subResults)
601
+ for (const r of subResults) {
516
602
  (0, validation_1.mergeParams)(innerParams, r.params);
603
+ if (r.preludes && r.preludes.length > 0)
604
+ extraPreludes.push(...r.preludes);
605
+ }
517
606
  }
518
607
  continue;
519
608
  }
520
609
  if (key === 'NOT') {
521
610
  if (!(0, validation_1.isPlainObject)(val))
522
611
  throw new errors_1.OGMError(`NOT operator inside a connection where requires an object value.`);
523
- const sub = this.compileConnectionWhereInput(val, relVar, edgeVar, targetNodeDef, propsDef, nodeScope, edgeScope, counter, depth + 1);
612
+ const sub = this.compileConnectionWhereInput(val, relVar, edgeVar, targetNodeDef, propsDef, nodeScope, edgeScope, counter, depth + 1, policyContext);
524
613
  if (sub.cypher) {
525
614
  innerClauses.push(`NOT (${sub.cypher})`);
526
615
  (0, validation_1.mergeParams)(innerParams, sub.params);
527
616
  }
617
+ if (sub.preludes && sub.preludes.length > 0)
618
+ extraPreludes.push(...sub.preludes);
528
619
  continue;
529
620
  }
530
621
  // Unknown key — silently ignore to remain forward-compatible with
531
622
  // future codegen additions.
532
623
  }
533
- return {
624
+ const result = {
534
625
  cypher: innerClauses.join(' AND '),
535
626
  params: innerParams,
536
627
  };
628
+ if (extraPreludes.length > 0)
629
+ result.preludes = extraPreludes;
630
+ return result;
537
631
  }
538
- tryCompileRelationship(key, value, nodeVar, nodeDef, counter, depth) {
632
+ tryCompileRelationship(key, value, nodeVar, nodeDef, counter, depth,
633
+ /**
634
+ * Policy context to thread into the target type when crossing the
635
+ * relationship boundary. v1.8.5 — fixes CRIT-3 (target-policy bypass
636
+ * via `_SOME`/`_NONE`/`_ALL`/`_SINGLE` user-where filters).
637
+ */
638
+ policyContext) {
539
639
  let suffix = null;
540
640
  let fieldName = key;
541
641
  for (const s of RELATIONSHIP_SUFFIXES)
@@ -562,7 +662,7 @@ class WhereCompiler {
562
662
  const isUnionTarget = !this.schema.nodes.has(relDef.target) &&
563
663
  this.schema.unions?.has(relDef.target);
564
664
  if (isUnionTarget)
565
- return this.compileUnionRelationship(suffix, value, nodeVar, relDef, counter, depth);
665
+ return this.compileUnionRelationship(suffix, value, nodeVar, relDef, counter, depth, policyContext);
566
666
  const targetNodeDef = (0, utils_1.resolveTargetDef)(relDef.target, this.schema);
567
667
  if (!targetNodeDef)
568
668
  throw new errors_1.OGMError(`Invalid relationship filter: target type for "${fieldName}" is not defined in the schema.`);
@@ -579,12 +679,19 @@ class WhereCompiler {
579
679
  // EXISTS never matches concrete-typed nodes.
580
680
  schema: this.schema,
581
681
  });
582
- // Inner scope for any `@cypher` fields referenced inside the inner
583
- // WHERE — preludes are stitched into the EXISTS body.
584
- const innerScope = new cypher_field_projection_1.CypherFieldScope(relVar, [], '__where');
585
- const innerResult = this.compileConditions(value, relVar, targetNodeDef, counter, depth + 1, innerScope);
586
- const innerPreludeFragment = innerScope.hasAny()
587
- ? ` ${innerScope.emit().join(' ')}`
682
+ // v1.8.5 — re-resolve the TARGET type's `'read'` policy when crossing
683
+ // the relationship boundary, mirroring `selection.compiler.ts:826-847`.
684
+ // Pre-1.8.5 this call site invoked `compileConditions` directly,
685
+ // skipping policy AND-stitching for the target — CRIT-3.
686
+ const targetBundle = this.buildTargetBundle(targetNodeDef.typeName, policyContext);
687
+ // Re-enter the top-level `compile()` so the target's policy clause
688
+ // (when present) AND-stitches into the same body that the user filter
689
+ // produces. `compile()` manages its own inner scope and returns any
690
+ // preludes via `WhereResult.preludes` for stitching inside EXISTS.
691
+ const innerResult = this.compile(value, relVar, targetNodeDef, counter, targetBundle ? { policyContext: targetBundle } : undefined);
692
+ const innerPreludes = innerResult.preludes ?? [];
693
+ const innerPreludeFragment = innerPreludes.length
694
+ ? ` ${innerPreludes.join(' ')}`
588
695
  : '';
589
696
  const whereClause = innerResult.cypher
590
697
  ? ` WHERE ${innerResult.cypher}`
@@ -617,9 +724,17 @@ class WhereCompiler {
617
724
  case '_SINGLE': {
618
725
  // Exactly one relationship satisfies. Pattern comprehensions cannot
619
726
  // contain CALL { ... } subqueries, so reject `@cypher` fields here.
620
- if (innerScope.hasAny())
727
+ // v1.8.5: a `@cypher`-projecting policy on the TARGET type also
728
+ // produces preludes — distinguish that case so the error message
729
+ // points at the right cause.
730
+ if (innerPreludes.length > 0) {
731
+ if (targetBundle)
732
+ throw new errors_1.OGMError(`Policy on "${targetNodeDef.typeName}" requires @cypher field projection, ` +
733
+ `which is not supported inside _SINGLE quantifiers. Refactor the policy ` +
734
+ `to use stored properties, or refactor the predicate to _SOME + _NONE.`);
621
735
  throw new errors_1.OGMError(`_SINGLE quantifiers do not support filtering by @cypher fields. ` +
622
736
  `Refactor the predicate to use _SOME + _NONE, or remove the @cypher reference.`);
737
+ }
623
738
  // Pre-1.7.5 we incremented `counter.count` here a second time
624
739
  // even though no new variable was bound — this branch already
625
740
  // claimed `r${counter.count}` at the top of the function and
@@ -641,10 +756,20 @@ class WhereCompiler {
641
756
  * Each member generates a separate EXISTS pattern using the member's labels.
642
757
  * Multiple members are combined with OR.
643
758
  */
644
- compileUnionRelationship(suffix, value, nodeVar, relDef, counter, depth) {
759
+ compileUnionRelationship(suffix, value, nodeVar, relDef, counter, depth,
760
+ /**
761
+ * Policy context to thread per-member: each union member receives its
762
+ * OWN target-bundle resolved via `policyContext.resolveForType(memberKey,
763
+ * 'read')`. Members without a registered policy compile as before (no
764
+ * extra clause). v1.8.5 — fixes CRIT-3 in the union path.
765
+ */
766
+ policyContext) {
645
767
  const unionMembers = this.schema.unions.get(relDef.target);
646
768
  const memberClauses = [];
647
769
  const allParams = {};
770
+ // depth-only suppression for unused-when-no-policy lint passes; keep
771
+ // for symmetry with `tryCompileRelationship`.
772
+ void depth;
648
773
  for (const [memberKey, memberValue] of Object.entries(value)) {
649
774
  if (!unionMembers.includes(memberKey))
650
775
  throw new errors_1.OGMError(`Invalid union member key "${memberKey}" in WHERE filter. Expected one of: ${unionMembers.join(', ')}.`);
@@ -660,19 +785,25 @@ class WhereCompiler {
660
785
  targetVar: relVar,
661
786
  targetLabelRaw: labelStr,
662
787
  });
663
- // Compile inner WHERE conditions for this member (if any properties specified)
788
+ // Per-member target bundle. `null` member-policy → undefined bundle
789
+ // → no policy stitching for this branch (original behavior).
790
+ const targetBundle = this.buildTargetBundle(memberDef.typeName, policyContext);
791
+ // Compile inner WHERE conditions for this member (if any properties
792
+ // specified). When the member has its own `'read'` policy, we still
793
+ // re-enter `compile()` so the policy AND-stitches into the EXISTS
794
+ // body even when `memberValue` is empty/null.
664
795
  const memberWhere = memberValue;
796
+ const hasUserMemberWhere = memberWhere && Object.keys(memberWhere).length > 0;
665
797
  let whereClause = '';
666
798
  let preludeFragment = '';
667
- if (memberWhere && Object.keys(memberWhere).length > 0) {
668
- const innerScope = new cypher_field_projection_1.CypherFieldScope(relVar, [], '__where');
669
- const innerResult = this.compileConditions(memberWhere, relVar, memberDef, counter, depth + 1, innerScope);
670
- if (innerResult.cypher) {
671
- whereClause = ` WHERE ${innerResult.cypher}`;
672
- (0, validation_1.mergeParams)(allParams, innerResult.params);
799
+ if (hasUserMemberWhere || targetBundle) {
800
+ const memberCompile = this.compile(hasUserMemberWhere ? memberWhere : undefined, relVar, memberDef, counter, targetBundle ? { policyContext: targetBundle } : undefined);
801
+ if (memberCompile.cypher) {
802
+ whereClause = ` WHERE ${memberCompile.cypher}`;
803
+ (0, validation_1.mergeParams)(allParams, memberCompile.params);
673
804
  }
674
- if (innerScope.hasAny())
675
- preludeFragment = ` ${innerScope.emit().join(' ')}`;
805
+ if (memberCompile.preludes && memberCompile.preludes.length > 0)
806
+ preludeFragment = ` ${memberCompile.preludes.join(' ')}`;
676
807
  }
677
808
  memberClauses.push(`EXISTS { MATCH ${pattern}${preludeFragment}${whereClause} }`);
678
809
  }