@memberjunction/search-engine 5.49.0 → 5.51.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.
Files changed (63) hide show
  1. package/dist/generic/EntitySearchProvider.d.ts +17 -3
  2. package/dist/generic/EntitySearchProvider.d.ts.map +1 -1
  3. package/dist/generic/EntitySearchProvider.js +23 -6
  4. package/dist/generic/EntitySearchProvider.js.map +1 -1
  5. package/dist/generic/ExternalHitMapper.d.ts +48 -0
  6. package/dist/generic/ExternalHitMapper.d.ts.map +1 -0
  7. package/dist/generic/ExternalHitMapper.js +88 -0
  8. package/dist/generic/ExternalHitMapper.js.map +1 -0
  9. package/dist/generic/FullTextSearchProvider.d.ts +9 -1
  10. package/dist/generic/FullTextSearchProvider.d.ts.map +1 -1
  11. package/dist/generic/FullTextSearchProvider.js +11 -3
  12. package/dist/generic/FullTextSearchProvider.js.map +1 -1
  13. package/dist/generic/ScopeDimensionResolver.d.ts +146 -0
  14. package/dist/generic/ScopeDimensionResolver.d.ts.map +1 -0
  15. package/dist/generic/ScopeDimensionResolver.js +464 -0
  16. package/dist/generic/ScopeDimensionResolver.js.map +1 -0
  17. package/dist/generic/ScopeExplanation.d.ts +141 -0
  18. package/dist/generic/ScopeExplanation.d.ts.map +1 -0
  19. package/dist/generic/ScopeExplanation.js +72 -0
  20. package/dist/generic/ScopeExplanation.js.map +1 -0
  21. package/dist/generic/ScopeFilterGuard.d.ts +127 -0
  22. package/dist/generic/ScopeFilterGuard.d.ts.map +1 -0
  23. package/dist/generic/ScopeFilterGuard.js +290 -0
  24. package/dist/generic/ScopeFilterGuard.js.map +1 -0
  25. package/dist/generic/ScopeTemplateRenderer.d.ts +12 -2
  26. package/dist/generic/ScopeTemplateRenderer.d.ts.map +1 -1
  27. package/dist/generic/ScopeTemplateRenderer.js +25 -5
  28. package/dist/generic/ScopeTemplateRenderer.js.map +1 -1
  29. package/dist/generic/ScopeValueEscaper.d.ts +112 -0
  30. package/dist/generic/ScopeValueEscaper.d.ts.map +1 -0
  31. package/dist/generic/ScopeValueEscaper.js +152 -0
  32. package/dist/generic/ScopeValueEscaper.js.map +1 -0
  33. package/dist/generic/SearchEngine.d.ts +188 -8
  34. package/dist/generic/SearchEngine.d.ts.map +1 -1
  35. package/dist/generic/SearchEngine.js +600 -35
  36. package/dist/generic/SearchEngine.js.map +1 -1
  37. package/dist/generic/VectorSearchProvider.d.ts +2 -1
  38. package/dist/generic/VectorSearchProvider.d.ts.map +1 -1
  39. package/dist/generic/VectorSearchProvider.js +23 -26
  40. package/dist/generic/VectorSearchProvider.js.map +1 -1
  41. package/dist/generic/search.types.d.ts +160 -0
  42. package/dist/generic/search.types.d.ts.map +1 -1
  43. package/dist/index.d.ts +5 -0
  44. package/dist/index.d.ts.map +1 -1
  45. package/dist/index.js +5 -0
  46. package/dist/index.js.map +1 -1
  47. package/dist/permissions/SearchScopePermissionResolver.d.ts +37 -2
  48. package/dist/permissions/SearchScopePermissionResolver.d.ts.map +1 -1
  49. package/dist/permissions/SearchScopePermissionResolver.js +74 -2
  50. package/dist/permissions/SearchScopePermissionResolver.js.map +1 -1
  51. package/dist/providers/AzureAISearchProvider.d.ts.map +1 -1
  52. package/dist/providers/AzureAISearchProvider.js +24 -6
  53. package/dist/providers/AzureAISearchProvider.js.map +1 -1
  54. package/dist/providers/ElasticsearchSearchProvider.d.ts.map +1 -1
  55. package/dist/providers/ElasticsearchSearchProvider.js +17 -5
  56. package/dist/providers/ElasticsearchSearchProvider.js.map +1 -1
  57. package/dist/providers/OpenSearchSearchProvider.d.ts.map +1 -1
  58. package/dist/providers/OpenSearchSearchProvider.js +16 -5
  59. package/dist/providers/OpenSearchSearchProvider.js.map +1 -1
  60. package/dist/providers/TypesenseSearchProvider.d.ts.map +1 -1
  61. package/dist/providers/TypesenseSearchProvider.js +17 -6
  62. package/dist/providers/TypesenseSearchProvider.js.map +1 -1
  63. package/package.json +8 -8
@@ -0,0 +1,72 @@
1
+ /**
2
+ * @fileoverview The shape of a scope decision — what a search WOULD be able to reach, and why.
3
+ *
4
+ * Everything the dimensional-scope work added is a decision made from inputs that are gone by
5
+ * the time anyone asks about them. A grant applied because a time window happened to be open;
6
+ * a dimension was discarded because it was caller-authored on a ServerDerived key; a lane was
7
+ * skipped because its filter lost a clause. Each of those is invisible afterwards — the result
8
+ * set looks the same whether it was correctly bounded or accidentally widened, so neither an
9
+ * administrator configuring a scope nor an auditor reconstructing an incident can tell.
10
+ *
11
+ * `ScopeExplanation` is that decision, captured. It is used in two places deliberately:
12
+ *
13
+ * 1. **Before** a search — `SearchEngine.ExplainScope()` runs the entire resolution chain
14
+ * (entitlement → dimensions → lane filters) and returns this WITHOUT querying anything.
15
+ * "As this user, on this date, with this skill active: what could this reach?"
16
+ * 2. **After** a search — the same object is serialized into
17
+ * `SearchExecutionLog.ScopeDecisionJSON`.
18
+ *
19
+ * One shape for both is the point. The preview an administrator approves is structurally the
20
+ * record the audit log keeps, so a claim made at configuration time is checkable against what
21
+ * actually ran, rather than being two representations that can quietly disagree.
22
+ *
23
+ * @module @memberjunction/search-engine
24
+ */
25
+ /**
26
+ * Render an explanation as human-readable lines.
27
+ *
28
+ * Kept as a pure function next to the type rather than inside the engine so a CLI, an
29
+ * Explorer panel, or a test can format one without constructing a `SearchEngine`.
30
+ */
31
+ export function SummarizeExplanation(explanation) {
32
+ const lines = [];
33
+ lines.push(`Scope: ${explanation.ScopeName} (${explanation.ScopeID})`);
34
+ const ent = explanation.Entitlement;
35
+ lines.push(` Reachable: ${explanation.Reachable ? 'YES' : 'NO'} — ` +
36
+ (ent
37
+ ? `entitlement ${ent.Allowed ? 'granted' : 'DENIED'} at ${ent.Level} via ${ent.Source}`
38
+ : 'entitlement not evaluated at this layer'));
39
+ if (ent)
40
+ lines.push(` Why: ${ent.Reason}`);
41
+ if (explanation.Dimensions.length) {
42
+ lines.push(' Dimensions:');
43
+ for (const d of explanation.Dimensions) {
44
+ const bound = d.Restricts ? ' [BOUND]' : '';
45
+ const note = d.Note ? ` — ${d.Note}` : '';
46
+ lines.push(` ${d.Name}${bound} = ${JSON.stringify(d.Value)} (${d.Provenance})${note}`);
47
+ }
48
+ }
49
+ else {
50
+ lines.push(' Dimensions: none declared (legacy scope — the caller context passes through unchecked)');
51
+ }
52
+ lines.push(' Lanes:');
53
+ if (!explanation.Lanes.length) {
54
+ lines.push(' (NONE CONFIGURED — this scope is UNSCOPED: providers apply no filter)');
55
+ }
56
+ for (const lane of explanation.Lanes) {
57
+ lines.push(` [${lane.Status}] ${lane.Kind}: ${lane.Target}`);
58
+ if (lane.RequiredMetadataKeys?.length) {
59
+ lines.push(` requires: ${lane.RequiredMetadataKeys.join(', ')}`);
60
+ }
61
+ if (lane.RenderedFilter) {
62
+ lines.push(` filter: ${lane.RenderedFilter.substring(0, 200)}`);
63
+ }
64
+ if (lane.Reason) {
65
+ lines.push(` SKIPPED: ${lane.Reason}`);
66
+ }
67
+ }
68
+ for (const d of explanation.Diagnostics)
69
+ lines.push(` note: ${d}`);
70
+ return lines;
71
+ }
72
+ //# sourceMappingURL=ScopeExplanation.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"ScopeExplanation.js","sourceRoot":"","sources":["../../src/generic/ScopeExplanation.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AAqHH;;;;;GAKG;AACH,MAAM,UAAU,oBAAoB,CAAC,WAA6B;IAC9D,MAAM,KAAK,GAAa,EAAE,CAAC;IAC3B,KAAK,CAAC,IAAI,CAAC,UAAU,WAAW,CAAC,SAAS,KAAK,WAAW,CAAC,OAAO,GAAG,CAAC,CAAC;IACvE,MAAM,GAAG,GAAG,WAAW,CAAC,WAAW,CAAC;IACpC,KAAK,CAAC,IAAI,CACN,gBAAgB,WAAW,CAAC,SAAS,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC,IAAI,KAAK;QACzD,CAAC,GAAG;YACA,CAAC,CAAC,eAAe,GAAG,CAAC,OAAO,CAAC,CAAC,CAAC,SAAS,CAAC,CAAC,CAAC,QAAQ,OAAO,GAAG,CAAC,KAAK,QAAQ,GAAG,CAAC,MAAM,EAAE;YACvF,CAAC,CAAC,yCAAyC,CAAC,CACnD,CAAC;IACF,IAAI,GAAG;QAAE,KAAK,CAAC,IAAI,CAAC,UAAU,GAAG,CAAC,MAAM,EAAE,CAAC,CAAC;IAE5C,IAAI,WAAW,CAAC,UAAU,CAAC,MAAM,EAAE,CAAC;QAChC,KAAK,CAAC,IAAI,CAAC,eAAe,CAAC,CAAC;QAC5B,KAAK,MAAM,CAAC,IAAI,WAAW,CAAC,UAAU,EAAE,CAAC;YACrC,MAAM,KAAK,GAAG,CAAC,CAAC,SAAS,CAAC,CAAC,CAAC,UAAU,CAAC,CAAC,CAAC,EAAE,CAAC;YAC5C,MAAM,IAAI,GAAG,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,MAAM,CAAC,CAAC,IAAI,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;YAC1C,KAAK,CAAC,IAAI,CAAC,OAAO,CAAC,CAAC,IAAI,GAAG,KAAK,MAAM,IAAI,CAAC,SAAS,CAAC,CAAC,CAAC,KAAK,CAAC,KAAK,CAAC,CAAC,UAAU,IAAI,IAAI,EAAE,CAAC,CAAC;QAC9F,CAAC;IACL,CAAC;SAAM,CAAC;QACJ,KAAK,CAAC,IAAI,CAAC,0FAA0F,CAAC,CAAC;IAC3G,CAAC;IAED,KAAK,CAAC,IAAI,CAAC,UAAU,CAAC,CAAC;IACvB,IAAI,CAAC,WAAW,CAAC,KAAK,CAAC,MAAM,EAAE,CAAC;QAC5B,KAAK,CAAC,IAAI,CAAC,2EAA2E,CAAC,CAAC;IAC5F,CAAC;IACD,KAAK,MAAM,IAAI,IAAI,WAAW,CAAC,KAAK,EAAE,CAAC;QACnC,KAAK,CAAC,IAAI,CAAC,QAAQ,IAAI,CAAC,MAAM,KAAK,IAAI,CAAC,IAAI,KAAK,IAAI,CAAC,MAAM,EAAE,CAAC,CAAC;QAChE,IAAI,IAAI,CAAC,oBAAoB,EAAE,MAAM,EAAE,CAAC;YACpC,KAAK,CAAC,IAAI,CAAC,qBAAqB,IAAI,CAAC,oBAAoB,CAAC,IAAI,CAAC,IAAI,CAAC,EAAE,CAAC,CAAC;QAC5E,CAAC;QACD,IAAI,IAAI,CAAC,cAAc,EAAE,CAAC;YACtB,KAAK,CAAC,IAAI,CAAC,mBAAmB,IAAI,CAAC,cAAc,CAAC,SAAS,CAAC,CAAC,EAAE,GAAG,CAAC,EAAE,CAAC,CAAC;QAC3E,CAAC;QACD,IAAI,IAAI,CAAC,MAAM,EAAE,CAAC;YACd,KAAK,CAAC,IAAI,CAAC,oBAAoB,IAAI,CAAC,MAAM,EAAE,CAAC,CAAC;QAClD,CAAC;IACL,CAAC;IAED,KAAK,MAAM,CAAC,IAAI,WAAW,CAAC,WAAW;QAAE,KAAK,CAAC,IAAI,CAAC,WAAW,CAAC,EAAE,CAAC,CAAC;IACpE,OAAO,KAAK,CAAC;AACjB,CAAC"}
@@ -0,0 +1,127 @@
1
+ /**
2
+ * @fileoverview Guard for a scope's rendered filter values.
3
+ *
4
+ * A scope's `MetadataFilter` is where tenant and permission push-down lives for every
5
+ * external-index lane. The providers previously applied it behind a bare type guard:
6
+ *
7
+ * ```typescript
8
+ * if (idx.MetadataFilter && typeof idx.MetadataFilter === 'string') { body.filter = idx.MetadataFilter; }
9
+ * ```
10
+ *
11
+ * That conflates two very different situations. If no filter was authored, running
12
+ * unfiltered is correct. But if a filter **was** authored and merely arrived in an
13
+ * unusable shape — a template that failed to render, a JSON filter that didn't parse,
14
+ * an object where the lane needs a string — the guard fell through and the lane ran
15
+ * **completely unfiltered**, silently dropping the tenant predicate. Breaking the
16
+ * filter was therefore the cheapest way to defeat it.
17
+ *
18
+ * This module makes the distinction explicit so a lane can fail closed:
19
+ *
20
+ * - `absent` — nothing authored; run unfiltered (unchanged behaviour)
21
+ * - `usable` — apply it
22
+ * - `unusable` — a filter was authored but cannot be applied; the caller MUST skip
23
+ * the lane rather than query without it
24
+ *
25
+ * @module @memberjunction/search-engine
26
+ */
27
+ /**
28
+ * Outcome of checking a scope's rendered filter value for a particular lane.
29
+ *
30
+ * `unusable` is the case that matters: it means an author intended a restriction that
31
+ * the lane cannot express, so proceeding would silently widen the search.
32
+ */
33
+ export type ScopeFilterCheck<T> = {
34
+ Status: 'absent';
35
+ } | {
36
+ Status: 'usable';
37
+ Value: T;
38
+ } | {
39
+ Status: 'unusable';
40
+ Reason: string;
41
+ };
42
+ /**
43
+ * Check a filter for a lane that needs a **string** (Azure AI Search OData `$filter`,
44
+ * Typesense `filter_by`).
45
+ *
46
+ * An object here means the scope's template rendered JSON for a lane that cannot consume
47
+ * it — authored intent that cannot be applied, hence `unusable` rather than ignored.
48
+ */
49
+ export declare function CheckScopeStringFilter(raw: unknown): ScopeFilterCheck<string>;
50
+ /**
51
+ * Check a filter for a lane that needs a structured **object** (Elasticsearch /
52
+ * OpenSearch filter DSL).
53
+ *
54
+ * A leftover string means the rendered template never parsed as JSON — usually a render
55
+ * failure or malformed JSON in the scope definition.
56
+ */
57
+ export declare function CheckScopeObjectFilter(raw: unknown): ScopeFilterCheck<object>;
58
+ /**
59
+ * Check a **rendered** scope template against its source, for fields that RESTRICT
60
+ * (`ExtraFilter`, `MetadataFilter`, `ExternalIndexConfig`).
61
+ *
62
+ * This catches the two ways a restriction can evaporate during rendering, neither of which
63
+ * the value alone can reveal — by the time a provider sees the constraint, the source
64
+ * template has been discarded, so "authored but rendered to nothing" is indistinguishable
65
+ * from "never authored":
66
+ *
67
+ * 1. **Rendered empty.** `RenderScopeTemplate` runs Nunjucks with `throwOnUndefined: false`,
68
+ * so a mistyped dimension (`SecondaryScopes.EffectiveChanneID`) makes a `{% if %}` guard
69
+ * false and the entire restricting clause silently disappears — the lane then runs
70
+ * unrestricted. (We deliberately do NOT flip `throwOnUndefined`: legitimate templates
71
+ * output optional dimensions, and flipping it would break them all.)
72
+ * 2. **Template leaked through.** On a render error `RenderScopeTemplate` returns the RAW
73
+ * template, so `{% if %}`/`{{ }}` syntax reaches a filter as literal text.
74
+ *
75
+ * @param source the un-rendered template from the scope row
76
+ * @param rendered the output of RenderScopeTemplate / RenderScopeJsonTemplate
77
+ */
78
+ export declare function CheckRenderedTemplate(source: string | null | undefined, rendered: unknown): ScopeFilterCheck<unknown>;
79
+ /**
80
+ * Parse a `RequiredMetadataKeys` declaration into a list of key names.
81
+ *
82
+ * Accepts a JSON array (`["OrganizationID","ContentSourceID"]`) or a comma-separated
83
+ * string, because both shapes turn up in hand-authored metadata. Returns an empty array
84
+ * when nothing is declared.
85
+ *
86
+ * @throws {Error} when a value IS declared but cannot be parsed. A malformed contract must
87
+ * not silently degrade to "no contract" — that would turn a typo into an unguarded lane,
88
+ * which is the exact failure this feature exists to prevent.
89
+ */
90
+ export declare function ParseRequiredMetadataKeys(raw: string | null | undefined): string[];
91
+ /**
92
+ * Check that a **rendered** metadata filter actually constrains on every key its scope
93
+ * declared in `RequiredMetadataKeys`.
94
+ *
95
+ * This catches the failure mode {@link CheckRenderedTemplate} structurally cannot: a filter
96
+ * that rendered **partially**. A realistic scope filter is several optional clauses —
97
+ *
98
+ * ```
99
+ * {"OrganizationID": "{{ ctx.PrimaryScopeRecordID }}"
100
+ * {% if ctx.SecondaryScopes.EffectiveChannelID %}, "ContentSourceID": {...}{% endif %}}
101
+ * ```
102
+ *
103
+ * — and if the channel dimension is absent (a mistyped dimension name, a caller that omitted
104
+ * it, a value the dimension resolver discarded as spoofed) the org clause still renders. The
105
+ * filter is non-empty, contains no leftover template syntax, and passes every other guard,
106
+ * yet the lane now searches the entire tenant instead of the one channel. The restriction
107
+ * did not fail; it evaporated, and nothing downstream can tell the difference.
108
+ *
109
+ * Declaring the keys makes the author's intent checkable. The check is deliberately a
110
+ * **presence** test rather than a semantic one: proving that a filter genuinely restricts on
111
+ * a key would mean interpreting five different provider filter dialects. Presence is cheap,
112
+ * dialect-agnostic, and catches the whole vanished-clause class, which is what actually
113
+ * happens in practice.
114
+ *
115
+ * @param rendered the rendered filter — a JSON string, or an already-parsed object
116
+ * @param requiredKeys key names from `ParseRequiredMetadataKeys`
117
+ */
118
+ export declare function CheckRequiredMetadataKeys(rendered: unknown, requiredKeys: string[]): ScopeFilterCheck<unknown>;
119
+ /**
120
+ * Check a filter for a lane that needs **JSON** and will accept either an already-parsed
121
+ * object or a JSON string it can parse itself (vector metadata filters).
122
+ *
123
+ * A string that fails to parse is `unusable`. This is the specific path that previously
124
+ * dropped the whole filter — including the tenant clause — and queried the entire index.
125
+ */
126
+ export declare function CheckScopeJsonFilter(raw: unknown): ScopeFilterCheck<object>;
127
+ //# sourceMappingURL=ScopeFilterGuard.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"ScopeFilterGuard.d.ts","sourceRoot":"","sources":["../../src/generic/ScopeFilterGuard.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;GAyBG;AAEH;;;;;GAKG;AACH,MAAM,MAAM,gBAAgB,CAAC,CAAC,IACxB;IAAE,MAAM,EAAE,QAAQ,CAAA;CAAE,GACpB;IAAE,MAAM,EAAE,QAAQ,CAAC;IAAC,KAAK,EAAE,CAAC,CAAA;CAAE,GAC9B;IAAE,MAAM,EAAE,UAAU,CAAC;IAAC,MAAM,EAAE,MAAM,CAAA;CAAE,CAAC;AAQ7C;;;;;;GAMG;AACH,wBAAgB,sBAAsB,CAAC,GAAG,EAAE,OAAO,GAAG,gBAAgB,CAAC,MAAM,CAAC,CAO7E;AAED;;;;;;GAMG;AACH,wBAAgB,sBAAsB,CAAC,GAAG,EAAE,OAAO,GAAG,gBAAgB,CAAC,MAAM,CAAC,CAU7E;AAED;;;;;;;;;;;;;;;;;;;GAmBG;AACH,wBAAgB,qBAAqB,CAAC,MAAM,EAAE,MAAM,GAAG,IAAI,GAAG,SAAS,EAAE,QAAQ,EAAE,OAAO,GAAG,gBAAgB,CAAC,OAAO,CAAC,CAkBrH;AAED;;;;;;;;;;GAUG;AACH,wBAAgB,yBAAyB,CAAC,GAAG,EAAE,MAAM,GAAG,IAAI,GAAG,SAAS,GAAG,MAAM,EAAE,CAsClF;AAKD;;;;;;;;;;;;;;;;;;;;;;;;;;GA0BG;AACH,wBAAgB,yBAAyB,CAAC,QAAQ,EAAE,OAAO,EAAE,YAAY,EAAE,MAAM,EAAE,GAAG,gBAAgB,CAAC,OAAO,CAAC,CAiB9G;AAyDD;;;;;;GAMG;AACH,wBAAgB,oBAAoB,CAAC,GAAG,EAAE,OAAO,GAAG,gBAAgB,CAAC,MAAM,CAAC,CAoB3E"}
@@ -0,0 +1,290 @@
1
+ /**
2
+ * @fileoverview Guard for a scope's rendered filter values.
3
+ *
4
+ * A scope's `MetadataFilter` is where tenant and permission push-down lives for every
5
+ * external-index lane. The providers previously applied it behind a bare type guard:
6
+ *
7
+ * ```typescript
8
+ * if (idx.MetadataFilter && typeof idx.MetadataFilter === 'string') { body.filter = idx.MetadataFilter; }
9
+ * ```
10
+ *
11
+ * That conflates two very different situations. If no filter was authored, running
12
+ * unfiltered is correct. But if a filter **was** authored and merely arrived in an
13
+ * unusable shape — a template that failed to render, a JSON filter that didn't parse,
14
+ * an object where the lane needs a string — the guard fell through and the lane ran
15
+ * **completely unfiltered**, silently dropping the tenant predicate. Breaking the
16
+ * filter was therefore the cheapest way to defeat it.
17
+ *
18
+ * This module makes the distinction explicit so a lane can fail closed:
19
+ *
20
+ * - `absent` — nothing authored; run unfiltered (unchanged behaviour)
21
+ * - `usable` — apply it
22
+ * - `unusable` — a filter was authored but cannot be applied; the caller MUST skip
23
+ * the lane rather than query without it
24
+ *
25
+ * @module @memberjunction/search-engine
26
+ */
27
+ /** True when a value carries no authored filter at all (null/undefined/blank string). */
28
+ function isAbsent(raw) {
29
+ if (raw === null || raw === undefined)
30
+ return true;
31
+ return typeof raw === 'string' && raw.trim().length === 0;
32
+ }
33
+ /**
34
+ * Check a filter for a lane that needs a **string** (Azure AI Search OData `$filter`,
35
+ * Typesense `filter_by`).
36
+ *
37
+ * An object here means the scope's template rendered JSON for a lane that cannot consume
38
+ * it — authored intent that cannot be applied, hence `unusable` rather than ignored.
39
+ */
40
+ export function CheckScopeStringFilter(raw) {
41
+ if (isAbsent(raw))
42
+ return { Status: 'absent' };
43
+ if (typeof raw === 'string')
44
+ return { Status: 'usable', Value: raw };
45
+ return {
46
+ Status: 'unusable',
47
+ Reason: `expected a string filter for this lane but got ${typeof raw}; the scope's MetadataFilter likely rendered JSON for a lane that requires a string`,
48
+ };
49
+ }
50
+ /**
51
+ * Check a filter for a lane that needs a structured **object** (Elasticsearch /
52
+ * OpenSearch filter DSL).
53
+ *
54
+ * A leftover string means the rendered template never parsed as JSON — usually a render
55
+ * failure or malformed JSON in the scope definition.
56
+ */
57
+ export function CheckScopeObjectFilter(raw) {
58
+ if (isAbsent(raw))
59
+ return { Status: 'absent' };
60
+ if (typeof raw === 'object')
61
+ return { Status: 'usable', Value: raw };
62
+ if (typeof raw === 'string') {
63
+ return {
64
+ Status: 'unusable',
65
+ Reason: `expected a structured filter object but got a string; the scope's MetadataFilter did not render to valid JSON: ${raw.trim().substring(0, 120)}`,
66
+ };
67
+ }
68
+ return { Status: 'unusable', Reason: `expected a structured filter object but got ${typeof raw}` };
69
+ }
70
+ /**
71
+ * Check a **rendered** scope template against its source, for fields that RESTRICT
72
+ * (`ExtraFilter`, `MetadataFilter`, `ExternalIndexConfig`).
73
+ *
74
+ * This catches the two ways a restriction can evaporate during rendering, neither of which
75
+ * the value alone can reveal — by the time a provider sees the constraint, the source
76
+ * template has been discarded, so "authored but rendered to nothing" is indistinguishable
77
+ * from "never authored":
78
+ *
79
+ * 1. **Rendered empty.** `RenderScopeTemplate` runs Nunjucks with `throwOnUndefined: false`,
80
+ * so a mistyped dimension (`SecondaryScopes.EffectiveChanneID`) makes a `{% if %}` guard
81
+ * false and the entire restricting clause silently disappears — the lane then runs
82
+ * unrestricted. (We deliberately do NOT flip `throwOnUndefined`: legitimate templates
83
+ * output optional dimensions, and flipping it would break them all.)
84
+ * 2. **Template leaked through.** On a render error `RenderScopeTemplate` returns the RAW
85
+ * template, so `{% if %}`/`{{ }}` syntax reaches a filter as literal text.
86
+ *
87
+ * @param source the un-rendered template from the scope row
88
+ * @param rendered the output of RenderScopeTemplate / RenderScopeJsonTemplate
89
+ */
90
+ export function CheckRenderedTemplate(source, rendered) {
91
+ if (isAbsent(source))
92
+ return { Status: 'absent' };
93
+ const renderedIsEmpty = rendered === null || rendered === undefined
94
+ || (typeof rendered === 'string' && rendered.trim().length === 0);
95
+ if (renderedIsEmpty) {
96
+ return {
97
+ Status: 'unusable',
98
+ Reason: `a restricting template was authored but rendered to nothing, so the restriction would vanish — usually an undefined dimension in a {% if %} guard (Nunjucks runs with throwOnUndefined:false). Template: ${String(source).substring(0, 160)}`,
99
+ };
100
+ }
101
+ if (typeof rendered === 'string' && (rendered.includes('{{') || rendered.includes('{%'))) {
102
+ return {
103
+ Status: 'unusable',
104
+ Reason: `the template did not render — raw template syntax survived into the value, which means RenderScopeTemplate hit an error and returned the source verbatim: ${rendered.substring(0, 160)}`,
105
+ };
106
+ }
107
+ return { Status: 'usable', Value: rendered };
108
+ }
109
+ /**
110
+ * Parse a `RequiredMetadataKeys` declaration into a list of key names.
111
+ *
112
+ * Accepts a JSON array (`["OrganizationID","ContentSourceID"]`) or a comma-separated
113
+ * string, because both shapes turn up in hand-authored metadata. Returns an empty array
114
+ * when nothing is declared.
115
+ *
116
+ * @throws {Error} when a value IS declared but cannot be parsed. A malformed contract must
117
+ * not silently degrade to "no contract" — that would turn a typo into an unguarded lane,
118
+ * which is the exact failure this feature exists to prevent.
119
+ */
120
+ export function ParseRequiredMetadataKeys(raw) {
121
+ if (isAbsent(raw))
122
+ return [];
123
+ const trimmed = String(raw).trim();
124
+ if (trimmed.startsWith('{')) {
125
+ // A JSON object is a shape the author clearly meant as structured data. Falling through
126
+ // to the comma-split branch would turn the entire literal into one nonsense "key name"
127
+ // that no filter can ever satisfy, permanently skipping the lane with a reason nobody
128
+ // can act on. Reject it as the malformed declaration it is.
129
+ throw new Error(`RequiredMetadataKeys must be a JSON array or a comma-separated list of key names, but got a JSON object: ${trimmed.substring(0, 120)}`);
130
+ }
131
+ let keys;
132
+ if (trimmed.startsWith('[')) {
133
+ let parsed;
134
+ try {
135
+ parsed = JSON.parse(trimmed);
136
+ }
137
+ catch {
138
+ throw new Error(`RequiredMetadataKeys is not valid JSON: ${trimmed.substring(0, 120)}`);
139
+ }
140
+ if (!Array.isArray(parsed))
141
+ throw new Error(`RequiredMetadataKeys must be a JSON array, got ${typeof parsed}`);
142
+ keys = parsed.map((k) => String(k).trim());
143
+ if (keys.some((k) => k.length === 0))
144
+ throw new Error('RequiredMetadataKeys contains a blank key name');
145
+ }
146
+ else {
147
+ keys = trimmed.split(',').map((k) => k.trim()).filter((k) => k.length > 0);
148
+ }
149
+ // Metadata keys are identifiers. Anything else is a typo — an unbalanced bracket, a stray
150
+ // quote — and a typo'd key silently disables the lane forever, since no rendered filter can
151
+ // contain it. Naming the bad token is far more useful than a lane that never runs.
152
+ const malformed = keys.filter((k) => !VALID_METADATA_KEY_RE.test(k));
153
+ if (malformed.length) {
154
+ throw new Error(`RequiredMetadataKeys contains value(s) that are not valid metadata key names: ${malformed.map((k) => JSON.stringify(k)).join(', ')}`);
155
+ }
156
+ return keys;
157
+ }
158
+ /** Metadata key names are identifiers; dots and dashes are allowed for nested/hyphenated labels. */
159
+ const VALID_METADATA_KEY_RE = /^[A-Za-z_][A-Za-z0-9_.\-]*$/;
160
+ /**
161
+ * Check that a **rendered** metadata filter actually constrains on every key its scope
162
+ * declared in `RequiredMetadataKeys`.
163
+ *
164
+ * This catches the failure mode {@link CheckRenderedTemplate} structurally cannot: a filter
165
+ * that rendered **partially**. A realistic scope filter is several optional clauses —
166
+ *
167
+ * ```
168
+ * {"OrganizationID": "{{ ctx.PrimaryScopeRecordID }}"
169
+ * {% if ctx.SecondaryScopes.EffectiveChannelID %}, "ContentSourceID": {...}{% endif %}}
170
+ * ```
171
+ *
172
+ * — and if the channel dimension is absent (a mistyped dimension name, a caller that omitted
173
+ * it, a value the dimension resolver discarded as spoofed) the org clause still renders. The
174
+ * filter is non-empty, contains no leftover template syntax, and passes every other guard,
175
+ * yet the lane now searches the entire tenant instead of the one channel. The restriction
176
+ * did not fail; it evaporated, and nothing downstream can tell the difference.
177
+ *
178
+ * Declaring the keys makes the author's intent checkable. The check is deliberately a
179
+ * **presence** test rather than a semantic one: proving that a filter genuinely restricts on
180
+ * a key would mean interpreting five different provider filter dialects. Presence is cheap,
181
+ * dialect-agnostic, and catches the whole vanished-clause class, which is what actually
182
+ * happens in practice.
183
+ *
184
+ * @param rendered the rendered filter — a JSON string, or an already-parsed object
185
+ * @param requiredKeys key names from `ParseRequiredMetadataKeys`
186
+ */
187
+ export function CheckRequiredMetadataKeys(rendered, requiredKeys) {
188
+ if (requiredKeys.length === 0)
189
+ return { Status: 'usable', Value: rendered };
190
+ if (isAbsent(rendered)) {
191
+ return {
192
+ Status: 'unusable',
193
+ Reason: `this lane declares RequiredMetadataKeys [${requiredKeys.join(', ')}] but no filter was rendered at all, so none of them constrain the search`,
194
+ };
195
+ }
196
+ const present = collectFilterKeys(rendered);
197
+ const missing = requiredKeys.filter((k) => !present.has(k.toLowerCase()));
198
+ if (missing.length === 0)
199
+ return { Status: 'usable', Value: rendered };
200
+ return {
201
+ Status: 'unusable',
202
+ Reason: `the rendered filter does not constrain on required metadata key${missing.length > 1 ? 's' : ''} [${missing.join(', ')}] — the clause was almost certainly dropped because a dimension it depends on was absent or discarded, which would widen this lane. Rendered: ${renderedPreview(rendered)}`,
203
+ };
204
+ }
205
+ /**
206
+ * Collect every key name a rendered filter mentions, lowercased.
207
+ *
208
+ * For an object (or a JSON string that parses to one) this walks the structure and gathers
209
+ * property names at every depth, so a key nested inside a provider's boolean wrapper
210
+ * (`{"$and":[{"OrganizationID":…}]}`) still counts. For a non-JSON string — Azure OData,
211
+ * Typesense `filter_by` — it falls back to identifier tokens, since those dialects express
212
+ * a constraint as `Key eq 'value'` rather than as structure.
213
+ */
214
+ function collectFilterKeys(rendered) {
215
+ const keys = new Set();
216
+ const walk = (node) => {
217
+ if (Array.isArray(node)) {
218
+ for (const item of node)
219
+ walk(item);
220
+ return;
221
+ }
222
+ if (node !== null && typeof node === 'object') {
223
+ for (const [key, value] of Object.entries(node)) {
224
+ keys.add(key.toLowerCase());
225
+ walk(value);
226
+ }
227
+ }
228
+ };
229
+ if (typeof rendered === 'string') {
230
+ const trimmed = rendered.trim();
231
+ if (trimmed.startsWith('{') || trimmed.startsWith('[')) {
232
+ try {
233
+ walk(JSON.parse(trimmed));
234
+ return keys;
235
+ }
236
+ catch {
237
+ // Not JSON after all — fall through to token scanning.
238
+ }
239
+ }
240
+ // Strip quoted literals BEFORE tokenizing. Without this, a filter that merely mentions
241
+ // the key name as a VALUE — `Title eq 'ContentSourceID'` — satisfies a contract it does
242
+ // not actually constrain on, which is a fail-open. The structured path never had this
243
+ // problem because a JSON walk distinguishes keys from values by position; a flat string
244
+ // has to earn that distinction by removing the places values live.
245
+ const withoutLiterals = trimmed.replace(/'(?:[^']|'')*'/g, "''").replace(/"(?:[^"\\]|\\.)*"/g, '""');
246
+ for (const token of withoutLiterals.match(/[A-Za-z_][A-Za-z0-9_]*/g) ?? [])
247
+ keys.add(token.toLowerCase());
248
+ return keys;
249
+ }
250
+ walk(rendered);
251
+ return keys;
252
+ }
253
+ /** Short, safe preview of a rendered filter for an error message. */
254
+ function renderedPreview(rendered) {
255
+ const text = typeof rendered === 'string' ? rendered : JSON.stringify(rendered);
256
+ return (text ?? '').substring(0, 160);
257
+ }
258
+ /**
259
+ * Check a filter for a lane that needs **JSON** and will accept either an already-parsed
260
+ * object or a JSON string it can parse itself (vector metadata filters).
261
+ *
262
+ * A string that fails to parse is `unusable`. This is the specific path that previously
263
+ * dropped the whole filter — including the tenant clause — and queried the entire index.
264
+ */
265
+ export function CheckScopeJsonFilter(raw) {
266
+ if (isAbsent(raw))
267
+ return { Status: 'absent' };
268
+ if (typeof raw === 'object')
269
+ return { Status: 'usable', Value: raw };
270
+ if (typeof raw === 'string') {
271
+ const trimmed = raw.trim();
272
+ try {
273
+ const parsed = JSON.parse(trimmed);
274
+ if (parsed !== null && typeof parsed === 'object')
275
+ return { Status: 'usable', Value: parsed };
276
+ return {
277
+ Status: 'unusable',
278
+ Reason: `MetadataFilter parsed to a ${typeof parsed} rather than an object: ${trimmed.substring(0, 120)}`,
279
+ };
280
+ }
281
+ catch {
282
+ return {
283
+ Status: 'unusable',
284
+ Reason: `MetadataFilter is not valid JSON: ${trimmed.substring(0, 120)}`,
285
+ };
286
+ }
287
+ }
288
+ return { Status: 'unusable', Reason: `MetadataFilter has unsupported type ${typeof raw}` };
289
+ }
290
+ //# sourceMappingURL=ScopeFilterGuard.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"ScopeFilterGuard.js","sourceRoot":"","sources":["../../src/generic/ScopeFilterGuard.ts"],"names":[],"mappings":"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"}
@@ -17,13 +17,23 @@
17
17
  *
18
18
  * @module @memberjunction/search-engine
19
19
  */
20
+ import { type ScopeLaneKind } from './ScopeValueEscaper.js';
20
21
  import { SearchContext } from './search.types.js';
21
22
  /**
22
23
  * Render a scope template string with the supplied SearchContext. Returns the original
23
24
  * string unchanged when `template` is null/empty. Returns the original string on render
24
25
  * failure (logged via LogError) so a single bad template does not bring down a search.
25
26
  */
26
- export declare function RenderScopeTemplate(template: string | null | undefined, context: SearchContext | undefined, extraData?: Record<string, unknown>): string;
27
+ export declare function RenderScopeTemplate(template: string | null | undefined, context: SearchContext | undefined, extraData?: Record<string, unknown>,
28
+ /**
29
+ * The dialect this template renders into (§5.4). Every interpolated context value is escaped
30
+ * for it automatically, so an author never has to remember.
31
+ *
32
+ * Defaults to `'none'` rather than to a guess: this function also renders NON-filter fields
33
+ * (`UserSearchString`, query transforms) where the value becomes search text, and escaping
34
+ * those would corrupt the query rather than protect it. Filter call sites pass their real kind.
35
+ */
36
+ laneKind?: ScopeLaneKind): string;
27
37
  /**
28
38
  * Render and parse a JSON-valued scope template (used for `MetadataFilter` values).
29
39
  * Returns:
@@ -32,5 +42,5 @@ export declare function RenderScopeTemplate(template: string | null | undefined,
32
42
  * - the raw rendered string when it is non-empty but not JSON (provider can decide what to do)
33
43
  * - `undefined` when render fails outright
34
44
  */
35
- export declare function RenderScopeJsonTemplate(template: string | null | undefined, context: SearchContext | undefined, extraData?: Record<string, unknown>): unknown;
45
+ export declare function RenderScopeJsonTemplate(template: string | null | undefined, context: SearchContext | undefined, extraData?: Record<string, unknown>, laneKind?: ScopeLaneKind): unknown;
36
46
  //# sourceMappingURL=ScopeTemplateRenderer.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"ScopeTemplateRenderer.d.ts","sourceRoot":"","sources":["../../src/generic/ScopeTemplateRenderer.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;GAkBG;AAIH,OAAO,EAAE,aAAa,EAAE,MAAM,gBAAgB,CAAC;AAwC/C;;;;GAIG;AACH,wBAAgB,mBAAmB,CAC/B,QAAQ,EAAE,MAAM,GAAG,IAAI,GAAG,SAAS,EACnC,OAAO,EAAE,aAAa,GAAG,SAAS,EAClC,SAAS,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GACpC,MAAM,CAmBR;AAED;;;;;;;GAOG;AACH,wBAAgB,uBAAuB,CACnC,QAAQ,EAAE,MAAM,GAAG,IAAI,GAAG,SAAS,EACnC,OAAO,EAAE,aAAa,GAAG,SAAS,EAClC,SAAS,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GACpC,OAAO,CAWT"}
1
+ {"version":3,"file":"ScopeTemplateRenderer.d.ts","sourceRoot":"","sources":["../../src/generic/ScopeTemplateRenderer.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;GAkBG;AAGH,OAAO,EAAwB,KAAK,aAAa,EAAE,MAAM,qBAAqB,CAAC;AAE/E,OAAO,EAAE,aAAa,EAAE,MAAM,gBAAgB,CAAC;AAwC/C;;;;GAIG;AACH,wBAAgB,mBAAmB,CAC/B,QAAQ,EAAE,MAAM,GAAG,IAAI,GAAG,SAAS,EACnC,OAAO,EAAE,aAAa,GAAG,SAAS,EAClC,SAAS,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC;AACnC;;;;;;;GAOG;AACH,QAAQ,GAAE,aAAsB,GACjC,MAAM,CA6BR;AAED;;;;;;;GAOG;AACH,wBAAgB,uBAAuB,CACnC,QAAQ,EAAE,MAAM,GAAG,IAAI,GAAG,SAAS,EACnC,OAAO,EAAE,aAAa,GAAG,SAAS,EAClC,SAAS,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EACnC,QAAQ,GAAE,aAAsB,GACjC,OAAO,CAWT"}
@@ -18,6 +18,7 @@
18
18
  * @module @memberjunction/search-engine
19
19
  */
20
20
  import nunjucks from 'nunjucks';
21
+ import { EscapeScopeValueDeep } from './ScopeValueEscaper.js';
21
22
  import { LogError } from '@memberjunction/core';
22
23
  /** Environment used for all ad-hoc scope template rendering. */
23
24
  const env = new nunjucks.Environment(null, {
@@ -64,16 +65,35 @@ env.addFilter('jsonparse', (value) => {
64
65
  * string unchanged when `template` is null/empty. Returns the original string on render
65
66
  * failure (logged via LogError) so a single bad template does not bring down a search.
66
67
  */
67
- export function RenderScopeTemplate(template, context, extraData) {
68
+ export function RenderScopeTemplate(template, context, extraData,
69
+ /**
70
+ * The dialect this template renders into (§5.4). Every interpolated context value is escaped
71
+ * for it automatically, so an author never has to remember.
72
+ *
73
+ * Defaults to `'none'` rather than to a guess: this function also renders NON-filter fields
74
+ * (`UserSearchString`, query transforms) where the value becomes search text, and escaping
75
+ * those would corrupt the query rather than protect it. Filter call sites pass their real kind.
76
+ */
77
+ laneKind = 'none') {
68
78
  if (!template)
69
79
  return '';
70
80
  if (!template.includes('{{') && !template.includes('{%')) {
71
81
  // No templating syntax — skip the renderer entirely
72
82
  return template;
73
83
  }
84
+ // `context` is escaped for the lane; `contextRaw` is the untouched original. Deliberate raw
85
+ // insertion is therefore spelled `{{ contextRaw.X }}` — one grep finds every such site, which
86
+ // is the point. A post-escape `| raw` filter could not do this: by the time a filter runs the
87
+ // original value is already gone.
74
88
  const data = {
75
- context: context ?? {},
76
- ...(extraData ?? {})
89
+ context: EscapeScopeValueDeep(context ?? {}, laneKind),
90
+ contextRaw: context ?? {},
91
+ // extraData is escaped too. It previously spread in un-escaped, which meant the one exported
92
+ // function whose entire job is escaping had a silent bypass sitting in its signature. No
93
+ // in-repo caller passes it today, so this was latent rather than live — but leaving an
94
+ // unescaped channel in a security primitive is how a future caller introduces an injection
95
+ // without touching this file or noticing anything.
96
+ ...EscapeScopeValueDeep(extraData ?? {}, laneKind)
77
97
  };
78
98
  try {
79
99
  return env.renderString(template, data);
@@ -92,10 +112,10 @@ export function RenderScopeTemplate(template, context, extraData) {
92
112
  * - the raw rendered string when it is non-empty but not JSON (provider can decide what to do)
93
113
  * - `undefined` when render fails outright
94
114
  */
95
- export function RenderScopeJsonTemplate(template, context, extraData) {
115
+ export function RenderScopeJsonTemplate(template, context, extraData, laneKind = 'json') {
96
116
  if (!template)
97
117
  return undefined;
98
- const rendered = RenderScopeTemplate(template, context, extraData);
118
+ const rendered = RenderScopeTemplate(template, context, extraData, laneKind);
99
119
  const trimmed = rendered.trim();
100
120
  if (!trimmed)
101
121
  return undefined;
@@ -1 +1 @@
1
- {"version":3,"file":"ScopeTemplateRenderer.js","sourceRoot":"","sources":["../../src/generic/ScopeTemplateRenderer.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"ScopeTemplateRenderer.js","sourceRoot":"","sources":["../../src/generic/ScopeTemplateRenderer.ts"],"names":[],"mappings":"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"}