@prosopo/user-access-policy 3.14.14 → 3.14.16

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 (70) hide show
  1. package/.turbo/turbo-build$colon$cjs.log +3 -6
  2. package/.turbo/turbo-build$colon$tsc.log +14 -17
  3. package/.turbo/turbo-build.log +4 -10
  4. package/CHANGELOG.md +37 -0
  5. package/LICENSE +201 -0
  6. package/dist/cjs/ruleInput/policyInput.cjs +1 -1
  7. package/dist/ruleInput/policyInput.d.ts +2 -2
  8. package/dist/ruleInput/policyInput.d.ts.map +1 -1
  9. package/dist/ruleInput/policyInput.js +2 -2
  10. package/dist/ruleInput/policyInput.js.map +1 -1
  11. package/package.json +83 -81
  12. package/src/.export.ts +0 -65
  13. package/src/api/.export.ts +0 -25
  14. package/src/api/accessRulesApiClient.ts +0 -13
  15. package/src/api/delete/.export.ts +0 -18
  16. package/src/api/delete/deleteAllRules.ts +0 -47
  17. package/src/api/delete/deleteRuleGroups.ts +0 -96
  18. package/src/api/delete/deleteRules.ts +0 -87
  19. package/src/api/read/.export.ts +0 -25
  20. package/src/api/read/fetchRules.ts +0 -96
  21. package/src/api/read/findRuleIds.ts +0 -97
  22. package/src/api/read/getMissingIds.ts +0 -81
  23. package/src/api/ruleApiRoutes.ts +0 -146
  24. package/src/api/rulesApiClient.ts +0 -154
  25. package/src/api/write/.export.ts +0 -15
  26. package/src/api/write/insertRules.ts +0 -185
  27. package/src/api/write/rehashRules.ts +0 -85
  28. package/src/classifyBrowser.ts +0 -77
  29. package/src/classifyOs.ts +0 -74
  30. package/src/headerMatch.ts +0 -180
  31. package/src/matchedRule.ts +0 -99
  32. package/src/mongoose/.export.ts +0 -15
  33. package/src/mongoose/mongooseRuleSchema.ts +0 -75
  34. package/src/redis/.export.ts +0 -17
  35. package/src/redis/reader/redisAggregate.ts +0 -157
  36. package/src/redis/reader/redisRulesQuery.ts +0 -268
  37. package/src/redis/reader/redisRulesReader.ts +0 -738
  38. package/src/redis/reader/redisRulesSplitQuery.ts +0 -234
  39. package/src/redis/redisClient.ts +0 -125
  40. package/src/redis/redisRuleIndex.ts +0 -121
  41. package/src/redis/redisRulesStorage.ts +0 -68
  42. package/src/redis/redisRulesWriter.ts +0 -172
  43. package/src/rule.ts +0 -122
  44. package/src/ruleInput/.export.ts +0 -19
  45. package/src/ruleInput/policyInput.ts +0 -120
  46. package/src/ruleInput/ruleInput.ts +0 -123
  47. package/src/ruleInput/userScopeInput.ts +0 -142
  48. package/src/ruleRecord.ts +0 -77
  49. package/src/rulesStorage.ts +0 -90
  50. package/src/tests/blockingRuleShapes.unit.test.ts +0 -244
  51. package/src/tests/classifyBrowser.unit.test.ts +0 -138
  52. package/src/tests/classifyOs.unit.test.ts +0 -97
  53. package/src/tests/headerMatch.unit.test.ts +0 -350
  54. package/src/tests/insertRulesEndpoint.unit.test.ts +0 -89
  55. package/src/tests/matchedRule.unit.test.ts +0 -184
  56. package/src/tests/policyInput.unit.test.ts +0 -218
  57. package/src/tests/redis/deferredPoolClause.benchmark.ts +0 -249
  58. package/src/tests/redis/reader/redisAggregate.unit.test.ts +0 -121
  59. package/src/tests/redis/reader/redisRulesQuery.unit.test.ts +0 -349
  60. package/src/tests/redis/reader/redisRulesSplitQuery.unit.test.ts +0 -297
  61. package/src/tests/redis/redisRulesReaderLoad.benchmark.integration.test.ts +0 -520
  62. package/src/tests/redis/redisRulesReaderRank.benchmark.integration.test.ts +0 -350
  63. package/src/tests/redis/redisRulesStorage.integration.test.ts +0 -1811
  64. package/src/tests/testLogger.ts +0 -38
  65. package/src/tests/transformRule.unit.test.ts +0 -345
  66. package/src/transformRule.ts +0 -133
  67. package/tsconfig.cjs.json +0 -41
  68. package/tsconfig.json +0 -47
  69. package/tsconfig.tsbuildinfo +0 -1
  70. package/tsconfig.types.json +0 -9
package/src/classifyOs.ts DELETED
@@ -1,74 +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
- * The operating systems the classifier can resolve a request to. Kept
17
- * deliberately in step with the OS vocabulary in
18
- * `@prosopo/decision-machines`' `uaClassify` (so a routing-rule `osNameIn` and
19
- * an access-rule `os` agree on the same names), but defined here because the
20
- * provider / user-access-policy request path cannot depend on the
21
- * decision-machines package.
22
- *
23
- * `unknown` is the catch-all when no signature matches. It is a first-class
24
- * value so an allow-list ("only let Windows through") can block unrecognised
25
- * User-Agents instead of silently passing them.
26
- */
27
- export const OS_NAMES = [
28
- "windows",
29
- "macos",
30
- "ios",
31
- "android",
32
- "linux",
33
- "unknown",
34
- ] as const;
35
-
36
- export type OsName = (typeof OS_NAMES)[number];
37
-
38
- /**
39
- * Classify the operating system from a raw User-Agent string. Pure — no
40
- * navigator/window/DOM dependence — so it is safe to call on the provider's
41
- * server-side request path.
42
- *
43
- * Detection order matches `uaClassify.classifyUserAgent`: iOS (iphone/ipad/
44
- * ipod) is tested before the generic mac signature, and Android before Linux,
45
- * because those User-Agents contain the more general token too. Detection is
46
- * conservative — anything unrecognised returns `unknown` rather than guessing,
47
- * so an `os === "ios"` rule never fires on a UA we don't understand.
48
- *
49
- * The classification is driven off the full User-Agent rather than the
50
- * `sec-ch-ua-platform` client hint on purpose: a client can simply omit client
51
- * hints, but stripping the User-Agent breaks far more, so the UA is the more
52
- * reliable signal for an OS-restriction rule that is meant to be hard to
53
- * bypass.
54
- */
55
- export const classifyOs = (userAgent: string | undefined): OsName => {
56
- const ua = (userAgent || "").toLowerCase();
57
-
58
- if (/iphone|ipad|ipod/.test(ua)) {
59
- return "ios";
60
- }
61
- if (/android/.test(ua)) {
62
- return "android";
63
- }
64
- if (/windows/.test(ua)) {
65
- return "windows";
66
- }
67
- if (/macintosh|mac os/.test(ua)) {
68
- return "macos";
69
- }
70
- if (/linux/.test(ua)) {
71
- return "linux";
72
- }
73
- return "unknown";
74
- };
@@ -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
- };