@prosopo/user-access-policy 3.14.14 → 3.14.15

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 (65) hide show
  1. package/.turbo/turbo-build$colon$cjs.log +2 -5
  2. package/.turbo/turbo-build$colon$tsc.log +14 -17
  3. package/.turbo/turbo-build.log +3 -9
  4. package/CHANGELOG.md +18 -0
  5. package/LICENSE +201 -0
  6. package/package.json +83 -81
  7. package/src/.export.ts +0 -65
  8. package/src/api/.export.ts +0 -25
  9. package/src/api/accessRulesApiClient.ts +0 -13
  10. package/src/api/delete/.export.ts +0 -18
  11. package/src/api/delete/deleteAllRules.ts +0 -47
  12. package/src/api/delete/deleteRuleGroups.ts +0 -96
  13. package/src/api/delete/deleteRules.ts +0 -87
  14. package/src/api/read/.export.ts +0 -25
  15. package/src/api/read/fetchRules.ts +0 -96
  16. package/src/api/read/findRuleIds.ts +0 -97
  17. package/src/api/read/getMissingIds.ts +0 -81
  18. package/src/api/ruleApiRoutes.ts +0 -146
  19. package/src/api/rulesApiClient.ts +0 -154
  20. package/src/api/write/.export.ts +0 -15
  21. package/src/api/write/insertRules.ts +0 -185
  22. package/src/api/write/rehashRules.ts +0 -85
  23. package/src/classifyBrowser.ts +0 -77
  24. package/src/classifyOs.ts +0 -74
  25. package/src/headerMatch.ts +0 -180
  26. package/src/matchedRule.ts +0 -99
  27. package/src/mongoose/.export.ts +0 -15
  28. package/src/mongoose/mongooseRuleSchema.ts +0 -75
  29. package/src/redis/.export.ts +0 -17
  30. package/src/redis/reader/redisAggregate.ts +0 -157
  31. package/src/redis/reader/redisRulesQuery.ts +0 -268
  32. package/src/redis/reader/redisRulesReader.ts +0 -738
  33. package/src/redis/reader/redisRulesSplitQuery.ts +0 -234
  34. package/src/redis/redisClient.ts +0 -125
  35. package/src/redis/redisRuleIndex.ts +0 -121
  36. package/src/redis/redisRulesStorage.ts +0 -68
  37. package/src/redis/redisRulesWriter.ts +0 -172
  38. package/src/rule.ts +0 -122
  39. package/src/ruleInput/.export.ts +0 -19
  40. package/src/ruleInput/policyInput.ts +0 -120
  41. package/src/ruleInput/ruleInput.ts +0 -123
  42. package/src/ruleInput/userScopeInput.ts +0 -142
  43. package/src/ruleRecord.ts +0 -77
  44. package/src/rulesStorage.ts +0 -90
  45. package/src/tests/blockingRuleShapes.unit.test.ts +0 -244
  46. package/src/tests/classifyBrowser.unit.test.ts +0 -138
  47. package/src/tests/classifyOs.unit.test.ts +0 -97
  48. package/src/tests/headerMatch.unit.test.ts +0 -350
  49. package/src/tests/insertRulesEndpoint.unit.test.ts +0 -89
  50. package/src/tests/matchedRule.unit.test.ts +0 -184
  51. package/src/tests/policyInput.unit.test.ts +0 -218
  52. package/src/tests/redis/deferredPoolClause.benchmark.ts +0 -249
  53. package/src/tests/redis/reader/redisAggregate.unit.test.ts +0 -121
  54. package/src/tests/redis/reader/redisRulesQuery.unit.test.ts +0 -349
  55. package/src/tests/redis/reader/redisRulesSplitQuery.unit.test.ts +0 -297
  56. package/src/tests/redis/redisRulesReaderLoad.benchmark.integration.test.ts +0 -520
  57. package/src/tests/redis/redisRulesReaderRank.benchmark.integration.test.ts +0 -350
  58. package/src/tests/redis/redisRulesStorage.integration.test.ts +0 -1811
  59. package/src/tests/testLogger.ts +0 -38
  60. package/src/tests/transformRule.unit.test.ts +0 -345
  61. package/src/transformRule.ts +0 -133
  62. package/tsconfig.cjs.json +0 -41
  63. package/tsconfig.json +0 -47
  64. package/tsconfig.tsbuildinfo +0 -1
  65. package/tsconfig.types.json +0 -9
@@ -1,180 +0,0 @@
1
- // Copyright 2021-2026 Prosopo (UK) Ltd.
2
- //
3
- // Licensed under the Apache License, Version 2.0 (the "License");
4
- // you may not use this file except in compliance with the License.
5
- // You may obtain a copy of the License at
6
- //
7
- // http://www.apache.org/licenses/LICENSE-2.0
8
- //
9
- // Unless required by applicable law or agreed to in writing, software
10
- // distributed under the License is distributed on an "AS IS" BASIS,
11
- // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
- // See the License for the specific language governing permissions and
13
- // limitations under the License.
14
-
15
- /**
16
- * Operators for an access rule's arbitrary-header condition.
17
- *
18
- * `equals` / `contains` are the deny-list operators (block when the request's
19
- * header matches). `notEquals` / `notContains` are their allow-list negations
20
- * (block when the request's header does NOT match — including when the header
21
- * is absent), so an allow-list card that says "only let requests with header X
22
- * through" desugars to `notEquals` / `notContains` Block rules, exactly the way
23
- * the OS allow-list desugars to Block rules on the complement.
24
- *
25
- * `notEqualsAny` / `notContainsAny` are the multi-value form of those two: the
26
- * rule's `headerValue` holds a list (see `encodeHeaderValueList`) and the rule
27
- * blocks unless the request's header matches *one of* the listed values. They
28
- * exist because an allow-list over several values of the SAME header cannot be
29
- * expressed as several single-value rules — each rule fires independently, so
30
- * `notEquals ios` + `notEquals android` blocks both an iOS and an Android
31
- * request. One rule holding both values is the only way to get the "any of"
32
- * semantics the allow-list needs.
33
- */
34
- export const HEADER_OPERATORS = [
35
- "equals",
36
- "contains",
37
- "notEquals",
38
- "notContains",
39
- "notEqualsAny",
40
- "notContainsAny",
41
- ] as const;
42
-
43
- export type HeaderOperator = (typeof HEADER_OPERATORS)[number];
44
-
45
- export const isHeaderOperator = (
46
- value: string | undefined,
47
- ): value is HeaderOperator =>
48
- value !== undefined &&
49
- (HEADER_OPERATORS as ReadonlyArray<string>).includes(value);
50
-
51
- /**
52
- * Sentinel value carried both on every request's user scope (see
53
- * `getRequestUserScope`) and on every header-restriction rule. Header rules
54
- * match on data (`headerName` / `headerValue` / `headerOperator`) that Redis
55
- * cannot evaluate — substring `contains` and per-rule operators aren't
56
- * expressible as a TAG query, and an allow-list rule must still fire on a
57
- * request that OMITS the target header, so candidate selection can't key off
58
- * header presence. Redis returns header rules via the reader's
59
- * `no-user-scope` fall-through probe (they constrain none of the indexed
60
- * scope dimensions); this marker is what keeps them candidates through the
61
- * JS-side `ruleApplies` scalar check, which would otherwise drop a rule
62
- * carrying a scope field the request lacks. The concrete condition is then
63
- * checked by `accessRuleHeaderMatches`.
64
- */
65
- export const HEADER_RULE_MARKER = "1";
66
-
67
- /**
68
- * Encode the value list carried by a multi-value operator (`notEqualsAny` /
69
- * `notContainsAny`) into the single `headerValue` string an access rule can
70
- * store. JSON rather than a delimiter because header values are arbitrary
71
- * text — any separator we picked could legitimately occur inside a value.
72
- */
73
- export const encodeHeaderValueList = (values: ReadonlyArray<string>): string =>
74
- JSON.stringify(values);
75
-
76
- /**
77
- * Inverse of {@link encodeHeaderValueList}, tolerant of rules written by hand
78
- * (e.g. via the access-policy CLI) that carry a single literal value instead of
79
- * an encoded list.
80
- *
81
- * Returns `undefined` for a value that looks list-encoded but isn't usable — an
82
- * empty list, a non-array, non-string members, or malformed JSON. Callers treat
83
- * that as a malformed rule and decline to fire it, so a garbled list can't
84
- * silently block all traffic.
85
- */
86
- export const decodeHeaderValueList = (value: string): string[] | undefined => {
87
- if (!value.trimStart().startsWith("[")) {
88
- // Not list-encoded: treat the whole string as a one-value list.
89
- return [value];
90
- }
91
- let parsed: unknown;
92
- try {
93
- parsed = JSON.parse(value);
94
- } catch {
95
- return undefined;
96
- }
97
- if (
98
- !Array.isArray(parsed) ||
99
- parsed.length === 0 ||
100
- !parsed.every((entry): entry is string => typeof entry === "string")
101
- ) {
102
- return undefined;
103
- }
104
- return parsed;
105
- };
106
-
107
- /**
108
- * Evaluate a single header condition against a request's headers. Returns
109
- * `true` when the condition is satisfied — i.e. when a Block rule carrying it
110
- * should fire for this request.
111
- *
112
- * `headers` is expected to be keyed by lower-cased header name (HTTP header
113
- * names are case-insensitive); `headerName` is lower-cased here defensively.
114
- * The negated operators treat an absent header as "not matching the value",
115
- * which is what makes an allow-list block a request that drops the header.
116
- */
117
- export const evaluateHeaderCondition = (
118
- headerName: string,
119
- operator: HeaderOperator,
120
- value: string,
121
- headers: Record<string, string>,
122
- ): boolean => {
123
- const actual = headers[headerName.toLowerCase()];
124
- switch (operator) {
125
- case "equals":
126
- return actual !== undefined && actual === value;
127
- case "contains":
128
- return actual?.includes(value) ?? false;
129
- case "notEquals":
130
- return actual === undefined || actual !== value;
131
- case "notContains":
132
- return actual === undefined || !actual.includes(value);
133
- case "notEqualsAny": {
134
- const values = decodeHeaderValueList(value);
135
- if (values === undefined) {
136
- return false;
137
- }
138
- return actual === undefined || !values.includes(actual);
139
- }
140
- case "notContainsAny": {
141
- const values = decodeHeaderValueList(value);
142
- if (values === undefined) {
143
- return false;
144
- }
145
- return (
146
- actual === undefined ||
147
- !values.some((candidate) => actual.includes(candidate))
148
- );
149
- }
150
- }
151
- };
152
-
153
- /**
154
- * Whether a rule's header condition (if any) is satisfied by the request's
155
- * headers. A rule with no `headerName` carries no header constraint, so it is
156
- * treated as satisfied and the rule's other dimensions decide the match. A
157
- * rule with a `headerName` but an unrecognised operator is malformed and never
158
- * matches (fail-safe: a garbled rule must not silently block traffic).
159
- */
160
- export const accessRuleHeaderMatches = (
161
- rule: {
162
- headerName?: string;
163
- headerValue?: string;
164
- headerOperator?: string;
165
- },
166
- headers: Record<string, string>,
167
- ): boolean => {
168
- if (rule.headerName === undefined) {
169
- return true;
170
- }
171
- if (!isHeaderOperator(rule.headerOperator)) {
172
- return false;
173
- }
174
- return evaluateHeaderCondition(
175
- rule.headerName,
176
- rule.headerOperator,
177
- rule.headerValue ?? "",
178
- headers,
179
- );
180
- };
@@ -1,99 +0,0 @@
1
- // Copyright 2021-2026 Prosopo (UK) Ltd.
2
- //
3
- // Licensed under the Apache License, Version 2.0 (the "License");
4
- // you may not use this file except in compliance with the License.
5
- // You may obtain a copy of the License at
6
- //
7
- // http://www.apache.org/licenses/LICENSE-2.0
8
- //
9
- // Unless required by applicable law or agreed to in writing, software
10
- // distributed under the License is distributed on an "AS IS" BASIS,
11
- // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
- // See the License for the specific language governing permissions and
13
- // limitations under the License.
14
-
15
- import type { MatchedAccessRule, MatchedRuleCondition } from "@prosopo/types";
16
- import type { AccessRule } from "#policy/rule.js";
17
- import {
18
- type UserScopeRecordField,
19
- getUserScopeRecordFromAccessRuleRecord,
20
- userScopeRecordFields,
21
- } from "#policy/ruleRecord.js";
22
- import {
23
- makeAccessRuleHash,
24
- transformAccessRuleIntoRecord,
25
- } from "#policy/transformRule.js";
26
-
27
- // A `coords` polygon serialises to an unbounded JSON string. The audit UI only
28
- // needs enough to recognise the rule, and the snapshot is copied onto every
29
- // record the rule acts on, so cap it rather than dragging kilobytes through
30
- // Mongo on each blocked request.
31
- const MAX_CONDITION_VALUE_LENGTH = 256;
32
-
33
- const toCondition = (
34
- field: UserScopeRecordField,
35
- value: unknown,
36
- ): MatchedRuleCondition | undefined => {
37
- if (value === undefined || value === null) return undefined;
38
- // `asn` is a number on the rule; every other scope field is a string.
39
- const asString = typeof value === "string" ? value : String(value);
40
- if (asString.length === 0) return undefined;
41
- return asString.length > MAX_CONDITION_VALUE_LENGTH
42
- ? {
43
- field,
44
- value: `${asString.slice(0, MAX_CONDITION_VALUE_LENGTH)}…`,
45
- truncated: true,
46
- }
47
- : { field, value: asString };
48
- };
49
-
50
- /**
51
- * Derive the rule's populated scope fields in record form.
52
- *
53
- * Goes through `transformAccessRuleIntoRecord` so the IP triple collapses back
54
- * to the human-readable `ip` / `ipMask` the operator originally typed, rather
55
- * than the numeric range the matcher works in, and so the field names match
56
- * the vocabulary the portal's Access Control page already labels conditions
57
- * with (`ip`, `ipMask`, `userAgent`, `ja4Hash`, …).
58
- */
59
- const deriveConditions = (rule: AccessRule): MatchedRuleCondition[] => {
60
- let scope: Partial<Record<UserScopeRecordField, unknown>>;
61
- try {
62
- scope = getUserScopeRecordFromAccessRuleRecord(
63
- transformAccessRuleIntoRecord(rule),
64
- );
65
- } catch {
66
- // Never let audit bookkeeping break enforcement. A rule shape the
67
- // record transform rejects (or an IP range cidr-calc can't express)
68
- // still blocks — it just describes itself without conditions.
69
- return [];
70
- }
71
-
72
- const conditions: MatchedRuleCondition[] = [];
73
- for (const field of userScopeRecordFields) {
74
- const condition = toCondition(field, scope[field]);
75
- if (condition) {
76
- conditions.push(condition);
77
- }
78
- }
79
- return conditions;
80
- };
81
-
82
- /**
83
- * Snapshot a matched rule for persistence alongside the request it acted on.
84
- *
85
- * Total function: every rule shape yields a value, so callers can inline it
86
- * into a record literal without a try/catch of their own.
87
- */
88
- export const describeMatchedRule = (rule: AccessRule): MatchedAccessRule => ({
89
- ruleHash: makeAccessRuleHash(rule),
90
- policyType: rule.type,
91
- conditions: deriveConditions(rule),
92
- ...(rule.description !== undefined && { description: rule.description }),
93
- ...(rule.captchaType !== undefined && { captchaType: rule.captchaType }),
94
- ...(rule.deferToVerify !== undefined && {
95
- deferToVerify: rule.deferToVerify,
96
- }),
97
- ...(rule.groupId !== undefined && { ruleGroupId: rule.groupId }),
98
- ...(rule.clientId !== undefined && { clientId: rule.clientId }),
99
- });
@@ -1,15 +0,0 @@
1
- // Copyright 2021-2026 Prosopo (UK) Ltd.
2
- //
3
- // Licensed under the Apache License, Version 2.0 (the "License");
4
- // you may not use this file except in compliance with the License.
5
- // You may obtain a copy of the License at
6
- //
7
- // http://www.apache.org/licenses/LICENSE-2.0
8
- //
9
- // Unless required by applicable law or agreed to in writing, software
10
- // distributed under the License is distributed on an "AS IS" BASIS,
11
- // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
- // See the License for the specific language governing permissions and
13
- // limitations under the License.
14
-
15
- export { accessRuleMongooseSchema } from "./mongooseRuleSchema.js";
@@ -1,75 +0,0 @@
1
- // Copyright 2021-2026 Prosopo (UK) Ltd.
2
- //
3
- // Licensed under the Apache License, Version 2.0 (the "License");
4
- // you may not use this file except in compliance with the License.
5
- // You may obtain a copy of the License at
6
- //
7
- // http://www.apache.org/licenses/LICENSE-2.0
8
- //
9
- // Unless required by applicable law or agreed to in writing, software
10
- // distributed under the License is distributed on an "AS IS" BASIS,
11
- // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
- // See the License for the specific language governing permissions and
13
- // limitations under the License.
14
-
15
- import type { AllKeys, Keys } from "@prosopo/common";
16
- import type { SchemaDefinition } from "mongoose";
17
- import type { AccessPolicy, PolicyScope } from "#policy/rule.js";
18
- import type {
19
- AccessRuleRecord,
20
- UserAttributesRecord,
21
- UserIpRecord,
22
- UserScopeRecord,
23
- } from "#policy/ruleRecord.js";
24
-
25
- const userAttributesSchema: SchemaDefinition<UserAttributesRecord> = {
26
- userId: { type: String, required: false },
27
- ja4Hash: { type: String, required: false },
28
- userAgent: { type: String, required: false },
29
- headersHash: { type: String, required: false },
30
- headHash: { type: String, required: false },
31
- coords: { type: String, required: false },
32
- countryCode: { type: String, required: false },
33
- asn: { type: Number, required: false },
34
- os: { type: String, required: false },
35
- browser: { type: String, required: false },
36
- headerMatch: { type: String, required: false },
37
- headerName: { type: String, required: false },
38
- headerValue: { type: String, required: false },
39
- headerOperator: { type: String, required: false },
40
- webBotAuthAgent: { type: String, required: false },
41
- } satisfies AllKeys<UserAttributesRecord>;
42
-
43
- const userIpSchema: SchemaDefinition<UserIpRecord> = {
44
- ip: { type: String, required: false },
45
- ipMask: { type: String, required: false },
46
- } satisfies AllKeys<UserIpRecord>;
47
-
48
- const userScopeSchema: SchemaDefinition<UserScopeRecord> = {
49
- ...userAttributesSchema,
50
- ...userIpSchema,
51
- } satisfies Keys<UserScopeRecord>;
52
-
53
- const policyScopeSchema: SchemaDefinition<PolicyScope> = {
54
- clientId: { type: String, required: false },
55
- } satisfies AllKeys<PolicyScope>;
56
-
57
- const accessPolicySchema: SchemaDefinition<AccessPolicy> = {
58
- type: { type: String, required: true },
59
- captchaType: { type: String, required: false },
60
- description: { type: String, required: false },
61
- solvedImagesCount: { type: Number, required: false },
62
- imageThreshold: { type: Number, required: false },
63
- powDifficulty: { type: Number, required: false },
64
- unsolvedImagesCount: { type: Number, required: false },
65
- frictionlessScore: { type: Number, required: false },
66
- deferToVerify: { type: Boolean, required: false },
67
- messageKey: { type: String, required: false },
68
- } satisfies AllKeys<AccessPolicy>;
69
-
70
- export const accessRuleMongooseSchema: SchemaDefinition<AccessRuleRecord> = {
71
- ...accessPolicySchema,
72
- ...policyScopeSchema,
73
- ...userScopeSchema,
74
- ruleGroupId: { type: String, required: false },
75
- } satisfies Keys<AccessRuleRecord>;
@@ -1,17 +0,0 @@
1
- // Copyright 2021-2026 Prosopo (UK) Ltd.
2
- //
3
- // Licensed under the Apache License, Version 2.0 (the "License");
4
- // you may not use this file except in compliance with the License.
5
- // You may obtain a copy of the License at
6
- //
7
- // http://www.apache.org/licenses/LICENSE-2.0
8
- //
9
- // Unless required by applicable law or agreed to in writing, software
10
- // distributed under the License is distributed on an "AS IS" BASIS,
11
- // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
- // See the License for the specific language governing permissions and
13
- // limitations under the License.
14
-
15
- export { createRedisAccessRulesStorage } from "./redisRulesStorage.js";
16
-
17
- export { accessRulesRedisIndex } from "./redisRuleIndex.js";
@@ -1,157 +0,0 @@
1
- // Copyright 2021-2026 Prosopo (UK) Ltd.
2
- //
3
- // Licensed under the Apache License, Version 2.0 (the "License");
4
- // you may not use this file except in compliance with the License.
5
- // You may obtain a copy of the License at
6
- //
7
- // http://www.apache.org/licenses/LICENSE-2.0
8
- //
9
- // Unless required by applicable law or agreed to in writing, software
10
- // distributed under the License is distributed on an "AS IS" BASIS,
11
- // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
- // See the License for the specific language governing permissions and
13
- // limitations under the License.
14
-
15
- import type { Logger } from "@prosopo/logger";
16
- import type { FtAggregateWithCursorOptions } from "@redis/search/dist/lib/commands/AGGREGATE_WITHCURSOR.js";
17
- import type { RedisClientType } from "redis";
18
- import { z } from "zod";
19
- import { REDIS_QUERY_DIALECT } from "#policy/redis/reader/redisRulesQuery.js";
20
- import {
21
- REDIS_BATCH_SIZE,
22
- parseRedisRecords,
23
- } from "#policy/redis/redisClient.js";
24
- import { ACCESS_RULES_REDIS_INDEX_NAME } from "#policy/redis/redisRuleIndex.js";
25
-
26
- // Bounded-cap variant of `aggregateRedisKeys` for hot-path sub-queries.
27
- // Uses FT.SEARCH NOCONTENT rather than FT.AGGREGATE + LOAD @__key so the
28
- // reply is a flat `[total, key1, key2, ...]` list decoded by
29
- // `SEARCH_NOCONTENT.transformReply`, never a nested tuples array. That
30
- // side-steps a crash in `@redis/client` 5.x where a `null` tuple returned
31
- // by the RediSearch coordinator (seen consistently on the split-query
32
- // ja4Hash probes in prod — several thousand per hour of
33
- // `TypeError: Cannot read properties of null (reading 'length')`) makes
34
- // `transformTuplesReply` throw and the whole sub-query resolve empty.
35
- // Cap fits in a single call because callers pass a small `maxKeys`
36
- // (SPLIT_MAX_CANDIDATES_PER_SUB, currently 500) — no cursor needed.
37
- export const searchRedisKeysBounded = async (
38
- client: RedisClientType,
39
- query: string,
40
- maxKeys: number,
41
- ): Promise<string[]> => {
42
- const reply = await client.ft.searchNoContent(
43
- ACCESS_RULES_REDIS_INDEX_NAME,
44
- query,
45
- {
46
- DIALECT: REDIS_QUERY_DIALECT,
47
- LIMIT: { from: 0, size: maxKeys },
48
- },
49
- );
50
-
51
- return reply.documents;
52
- };
53
-
54
- // aggregation is used for cases when we need to get "unlimited" search results
55
- export const aggregateRedisKeys = async (
56
- client: RedisClientType,
57
- query: string,
58
- logger: Logger,
59
- batchHandler?: (keys: string[]) => Promise<void>,
60
- maxKeys?: number,
61
- ): Promise<string[]> => {
62
- const keyField = "__key";
63
-
64
- const recordSchema = z.object({
65
- // it's a reserved name for the record key
66
- [keyField]: z.string(),
67
- });
68
-
69
- const foundKeys: string[] = [];
70
- let stopRequested = false;
71
-
72
- const addRecordKeys = async (records: object[]) => {
73
- if (stopRequested) {
74
- return;
75
- }
76
-
77
- const parsedRecords = parseRedisRecords(records, recordSchema, logger);
78
-
79
- const recordKeys = parsedRecords.map((record) => record[keyField]);
80
-
81
- if (batchHandler) {
82
- await batchHandler(recordKeys);
83
- } else {
84
- if (
85
- maxKeys !== undefined &&
86
- foundKeys.length + recordKeys.length > maxKeys
87
- ) {
88
- const remaining = Math.max(0, maxKeys - foundKeys.length);
89
- foundKeys.push(...recordKeys.slice(0, remaining));
90
- stopRequested = true;
91
-
92
- logger.warn(() => ({
93
- msg: "Redis aggregation candidate cap hit; truncating result set. This can suppress less-frequent rules and should be investigated.",
94
- data: {
95
- maxKeys,
96
- query,
97
- },
98
- }));
99
- return;
100
- }
101
-
102
- foundKeys.push(...recordKeys);
103
-
104
- logger.debug(() => ({
105
- msg: "Processed aggregation batch",
106
- data: {
107
- size: recordKeys.length,
108
- },
109
- }));
110
- }
111
- };
112
-
113
- await executeAggregation(
114
- client,
115
- query,
116
- {
117
- // #2 is a required option when the 'ismissing()' function is in the query body
118
- DIALECT: REDIS_QUERY_DIALECT,
119
- COUNT: REDIS_BATCH_SIZE,
120
- LOAD: `@${keyField}`,
121
- },
122
- addRecordKeys,
123
- () => stopRequested,
124
- );
125
-
126
- return foundKeys;
127
- };
128
-
129
- const executeAggregation = async (
130
- client: RedisClientType,
131
- query: string,
132
- aggregateOptions: FtAggregateWithCursorOptions,
133
- handleBatch: (records: object[]) => Promise<void>,
134
- shouldStop?: () => boolean,
135
- ): Promise<void> => {
136
- const initialReply = await client.ft.aggregateWithCursor(
137
- ACCESS_RULES_REDIS_INDEX_NAME,
138
- query,
139
- aggregateOptions,
140
- );
141
-
142
- await handleBatch(initialReply.results);
143
-
144
- let cursor = initialReply.cursor;
145
-
146
- while (0 !== cursor && !shouldStop?.()) {
147
- const batchReply = await client.ft.cursorRead(
148
- ACCESS_RULES_REDIS_INDEX_NAME,
149
- cursor,
150
- { COUNT: aggregateOptions.COUNT },
151
- );
152
-
153
- await handleBatch(batchReply.results);
154
-
155
- cursor = batchReply.cursor;
156
- }
157
- };