@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
@@ -1,184 +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 { CaptchaType, MatchedAccessRuleSchema } from "@prosopo/types";
16
- import { Address4 } from "ip-address";
17
- import { describe, expect, it } from "vitest";
18
- import { describeMatchedRule } from "#policy/matchedRule.js";
19
- import { AccessPolicyType, type AccessRule } from "#policy/rule.js";
20
- import { userScopeRecordFields } from "#policy/ruleRecord.js";
21
- import { makeAccessRuleHash } from "#policy/transformRule.js";
22
-
23
- describe("describeMatchedRule", () => {
24
- it("carries the policy half of the rule", () => {
25
- const rule: AccessRule = {
26
- type: AccessPolicyType.Restrict,
27
- captchaType: CaptchaType.image,
28
- description: "6 rounds for this ASN",
29
- deferToVerify: true,
30
- clientId: "site-key",
31
- groupId: "group-1",
32
- ja4Hash: "t13d1516h2_8daaf6152771_b186095e22b6",
33
- };
34
-
35
- expect(describeMatchedRule(rule)).toEqual({
36
- ruleHash: makeAccessRuleHash(rule),
37
- policyType: AccessPolicyType.Restrict,
38
- captchaType: CaptchaType.image,
39
- description: "6 rounds for this ASN",
40
- deferToVerify: true,
41
- clientId: "site-key",
42
- // `groupId` on the rule is `ruleGroupId` everywhere else — the
43
- // portal joins the audit row back to its Access Control entry on
44
- // this, so the rename has to happen here.
45
- ruleGroupId: "group-1",
46
- conditions: [
47
- {
48
- field: "ja4Hash",
49
- value: "t13d1516h2_8daaf6152771_b186095e22b6",
50
- },
51
- ],
52
- });
53
- });
54
-
55
- it("omits policy fields the rule did not set rather than emitting undefined", () => {
56
- const described = describeMatchedRule({ type: AccessPolicyType.Block });
57
-
58
- expect(described).toEqual({
59
- ruleHash: makeAccessRuleHash({ type: AccessPolicyType.Block }),
60
- policyType: AccessPolicyType.Block,
61
- conditions: [],
62
- });
63
- expect(Object.keys(described)).not.toContain("description");
64
- expect(Object.keys(described)).not.toContain("ruleGroupId");
65
- });
66
-
67
- it("reports a single IP in the form the operator typed, not the numeric one", () => {
68
- const { conditions } = describeMatchedRule({
69
- type: AccessPolicyType.Block,
70
- numericIp: new Address4("192.0.2.44").bigInt(),
71
- });
72
-
73
- expect(conditions).toEqual([{ field: "ip", value: "192.0.2.44" }]);
74
- });
75
-
76
- it("collapses an IP range back to its CIDR", () => {
77
- const { conditions } = describeMatchedRule({
78
- type: AccessPolicyType.Block,
79
- numericIpMaskMin: new Address4("198.51.100.64").bigInt(),
80
- numericIpMaskMax: new Address4("198.51.100.127").bigInt(),
81
- });
82
-
83
- expect(conditions).toEqual([
84
- { field: "ipMask", value: "198.51.100.64/26" },
85
- ]);
86
- });
87
-
88
- it("stringifies a numeric asn", () => {
89
- const { conditions } = describeMatchedRule({
90
- type: AccessPolicyType.Block,
91
- asn: 205016,
92
- });
93
-
94
- expect(conditions).toEqual([{ field: "asn", value: "205016" }]);
95
- });
96
-
97
- it("reports every populated scope field, in record vocabulary", () => {
98
- const { conditions } = describeMatchedRule({
99
- type: AccessPolicyType.Block,
100
- userId: "user",
101
- ja4Hash: "ja4",
102
- headersHash: "headers",
103
- // Hashed on the rule, but surfaces under the record-side name the
104
- // portal labels conditions with.
105
- userAgentHash: "ua",
106
- headHash: "head",
107
- coords: "[[1,2]]",
108
- countryCode: "GB",
109
- asn: 1,
110
- os: "macos",
111
- numericIp: new Address4("203.0.113.5").bigInt(),
112
- });
113
-
114
- expect(conditions.map((condition) => condition.field)).toEqual([
115
- "userId",
116
- "ja4Hash",
117
- "headersHash",
118
- "userAgent",
119
- "headHash",
120
- "coords",
121
- "countryCode",
122
- "asn",
123
- "os",
124
- "ip",
125
- ]);
126
- // Every emitted field is one the portal knows how to label.
127
- for (const condition of conditions) {
128
- expect(userScopeRecordFields).toContain(condition.field);
129
- }
130
- });
131
-
132
- it("truncates an oversized coords polygon and flags it", () => {
133
- const coords = JSON.stringify([Array.from({ length: 200 }, () => [1, 2])]);
134
- const { conditions } = describeMatchedRule({
135
- type: AccessPolicyType.Block,
136
- coords,
137
- });
138
-
139
- const [condition] = conditions;
140
- expect(condition?.truncated).toBe(true);
141
- expect(condition?.value.length).toBe(257);
142
- expect(coords.startsWith(condition?.value.slice(0, -1) ?? "")).toBe(true);
143
- });
144
-
145
- it("leaves a value at the cap untruncated", () => {
146
- const coords = "x".repeat(256);
147
- const { conditions } = describeMatchedRule({
148
- type: AccessPolicyType.Block,
149
- coords,
150
- });
151
-
152
- expect(conditions).toEqual([{ field: "coords", value: coords }]);
153
- });
154
-
155
- it("still describes a rule whose scope cannot be transformed", () => {
156
- // An inverted mask range makes the record transform throw. Enforcement
157
- // must not care: the block still happens, the snapshot just lacks
158
- // conditions.
159
- const rule: AccessRule = {
160
- type: AccessPolicyType.Block,
161
- description: "unrepresentable range",
162
- numericIpMaskMin: new Address4("198.51.100.200").bigInt(),
163
- numericIpMaskMax: new Address4("198.51.100.1").bigInt(),
164
- };
165
-
166
- expect(() => describeMatchedRule(rule)).not.toThrow();
167
- expect(describeMatchedRule(rule)).toEqual({
168
- ruleHash: makeAccessRuleHash(rule),
169
- policyType: AccessPolicyType.Block,
170
- description: "unrepresentable range",
171
- conditions: [],
172
- });
173
- });
174
-
175
- it("produces a value the persisted schema accepts", () => {
176
- const described = describeMatchedRule({
177
- type: AccessPolicyType.Restrict,
178
- captchaType: CaptchaType.pow,
179
- countryCode: "CN",
180
- });
181
-
182
- expect(MatchedAccessRuleSchema.safeParse(described).success).toBe(true);
183
- });
184
- });
@@ -1,218 +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 { CaptchaType } from "@prosopo/types";
16
- import { describe, expect, it } from "vitest";
17
- import { AccessPolicyType } from "#policy/rule.js";
18
- import {
19
- accessPolicyInput,
20
- sanitizeAccessPolicy,
21
- } from "#policy/ruleInput/policyInput.js";
22
-
23
- describe("sanitizeAccessPolicy", () => {
24
- describe("block policies", () => {
25
- it("should remove captchaType from block policies", () => {
26
- const input = {
27
- type: AccessPolicyType.Block,
28
- captchaType: CaptchaType.image,
29
- description: "test block policy",
30
- };
31
-
32
- const result = sanitizeAccessPolicy(input);
33
-
34
- expect(result).toEqual({
35
- type: AccessPolicyType.Block,
36
- description: "test block policy",
37
- });
38
- expect(result.captchaType).toBeUndefined();
39
- });
40
-
41
- it("should remove solvedImagesCount from block policies", () => {
42
- const input = {
43
- type: AccessPolicyType.Block,
44
- solvedImagesCount: 5,
45
- description: "test block policy",
46
- };
47
-
48
- const result = sanitizeAccessPolicy(input);
49
-
50
- expect(result).toEqual({
51
- type: AccessPolicyType.Block,
52
- description: "test block policy",
53
- });
54
- expect(result.solvedImagesCount).toBeUndefined();
55
- });
56
-
57
- it("should remove both captchaType and solvedImagesCount from block policies", () => {
58
- const input = {
59
- type: AccessPolicyType.Block,
60
- captchaType: CaptchaType.image,
61
- solvedImagesCount: 5,
62
- description: "test block policy",
63
- };
64
-
65
- const result = sanitizeAccessPolicy(input);
66
-
67
- expect(result).toEqual({
68
- type: AccessPolicyType.Block,
69
- description: "test block policy",
70
- });
71
- expect(result.captchaType).toBeUndefined();
72
- expect(result.solvedImagesCount).toBeUndefined();
73
- });
74
-
75
- it("should keep other fields in block policies", () => {
76
- const input = {
77
- type: AccessPolicyType.Block,
78
- captchaType: CaptchaType.image,
79
- solvedImagesCount: 5,
80
- description: "test block policy",
81
- imageThreshold: 0.5,
82
- powDifficulty: 10,
83
- unsolvedImagesCount: 3,
84
- frictionlessScore: 100,
85
- };
86
-
87
- const result = sanitizeAccessPolicy(input);
88
-
89
- expect(result).toEqual({
90
- type: AccessPolicyType.Block,
91
- description: "test block policy",
92
- imageThreshold: 0.5,
93
- powDifficulty: 10,
94
- unsolvedImagesCount: 3,
95
- frictionlessScore: 100,
96
- });
97
- expect(result.captchaType).toBeUndefined();
98
- expect(result.solvedImagesCount).toBeUndefined();
99
- });
100
- });
101
-
102
- describe("restrict policies", () => {
103
- it("should keep captchaType in restrict policies", () => {
104
- const input = {
105
- type: AccessPolicyType.Restrict,
106
- captchaType: CaptchaType.image,
107
- description: "test restrict policy",
108
- };
109
-
110
- const result = sanitizeAccessPolicy(input);
111
-
112
- expect(result).toEqual({
113
- type: AccessPolicyType.Restrict,
114
- captchaType: CaptchaType.image,
115
- description: "test restrict policy",
116
- });
117
- });
118
-
119
- it("should keep solvedImagesCount in restrict policies", () => {
120
- const input = {
121
- type: AccessPolicyType.Restrict,
122
- solvedImagesCount: 5,
123
- description: "test restrict policy",
124
- };
125
-
126
- const result = sanitizeAccessPolicy(input);
127
-
128
- expect(result).toEqual({
129
- type: AccessPolicyType.Restrict,
130
- solvedImagesCount: 5,
131
- description: "test restrict policy",
132
- });
133
- });
134
-
135
- it("should keep both captchaType and solvedImagesCount in restrict policies", () => {
136
- const input = {
137
- type: AccessPolicyType.Restrict,
138
- captchaType: CaptchaType.image,
139
- solvedImagesCount: 5,
140
- description: "test restrict policy",
141
- };
142
-
143
- const result = sanitizeAccessPolicy(input);
144
-
145
- expect(result).toEqual({
146
- type: AccessPolicyType.Restrict,
147
- captchaType: CaptchaType.image,
148
- solvedImagesCount: 5,
149
- description: "test restrict policy",
150
- });
151
- });
152
- });
153
- });
154
-
155
- // Write-time input schema — reject the fields the sanitiser used to
156
- // silently strip. The prior "silently strip" behaviour meant operators
157
- // writing `--block --captchaType image` got a rule that blocked EVERY
158
- // captcha type (not just image); rejecting at input surfaces the
159
- // mismatch so they can switch to a Restrict policy instead.
160
- describe("accessPolicyInput refinement", () => {
161
- it("rejects Block + captchaType with a clear message pointing at Restrict", () => {
162
- const result = accessPolicyInput.safeParse({
163
- type: AccessPolicyType.Block,
164
- captchaType: CaptchaType.image,
165
- });
166
- expect(result.success).toBe(false);
167
- if (!result.success) {
168
- const issue = result.error.issues.find((i) =>
169
- i.path.includes("captchaType"),
170
- );
171
- expect(issue).toBeDefined();
172
- expect(issue?.message).toMatch(/Block policies cannot pin a captchaType/);
173
- expect(issue?.message).toMatch(/Restrict policy/);
174
- }
175
- });
176
-
177
- it("rejects Block + solvedImagesCount", () => {
178
- const result = accessPolicyInput.safeParse({
179
- type: AccessPolicyType.Block,
180
- solvedImagesCount: 3,
181
- });
182
- expect(result.success).toBe(false);
183
- if (!result.success) {
184
- const issue = result.error.issues.find((i) =>
185
- i.path.includes("solvedImagesCount"),
186
- );
187
- expect(issue).toBeDefined();
188
- expect(issue?.message).toMatch(
189
- /Block policies cannot set solvedImagesCount/,
190
- );
191
- }
192
- });
193
-
194
- it("accepts Block without the disallowed fields", () => {
195
- const result = accessPolicyInput.safeParse({
196
- type: AccessPolicyType.Block,
197
- description: "clean block",
198
- });
199
- expect(result.success).toBe(true);
200
- });
201
-
202
- it("accepts Block + deferToVerify (deferToVerify is orthogonal to the type-pin restriction)", () => {
203
- const result = accessPolicyInput.safeParse({
204
- type: AccessPolicyType.Block,
205
- deferToVerify: true,
206
- });
207
- expect(result.success).toBe(true);
208
- });
209
-
210
- it("accepts Restrict + captchaType + solvedImagesCount (these are Restrict's whole point)", () => {
211
- const result = accessPolicyInput.safeParse({
212
- type: AccessPolicyType.Restrict,
213
- captchaType: CaptchaType.image,
214
- solvedImagesCount: 5,
215
- });
216
- expect(result.success).toBe(true);
217
- });
218
- });
@@ -1,249 +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
- // Standalone benchmark for the hard-block candidate-pool clauses.
16
- //
17
- // Compares the previous single-field clause against the two new
18
- // two-field clauses on the shapes they actually run in:
19
- //
20
- // baseline @type:{block} (old, both callers)
21
- // middleware @type:{block} -@deferToVerify:{true} (new, request time)
22
- // verify (@type:{block} | @deferToVerify:{true}) (new, verify)
23
- //
24
- // Also runs the MISGROUPED union as a control. `|` binds looser than the
25
- // implicit AND, so `(@a)|(@b) scope ip` lets the left operand escape the
26
- // scope/IP filters — it matches every Block rule in the index. That
27
- // misgrouping is both wrong and ~7x slower, and it is invisible on a
28
- // toy dataset; it only shows up at population scale, which is why this
29
- // benchmark exists.
30
- //
31
- // Population mirrors redisRulesReaderLoad.benchmark.integration.test.ts
32
- // (the 17k bulk-ban incident shape) plus a deferred-rule cohort.
33
- //
34
- // Run: npx tsx <this file> (needs redis-stack on localhost:6379)
35
-
36
- import { createClient } from "redis";
37
-
38
- const IP_RULE_COUNT = 17_000;
39
- const CIDR_RULE_COUNT = 1_300;
40
- const MIXED_RULE_COUNT = 1_000;
41
- // Deferred rules as a share of the population. Production currently has
42
- // ~380 deferred against ~4k live rules; 2_000 here is a deliberate
43
- // over-weight so the union has real work to merge.
44
- const DEFERRED_RULE_COUNT = 2_000;
45
-
46
- const WARMUP = 50;
47
- const ITERATIONS = 500;
48
-
49
- const INDEX = "bench:idx";
50
- const PREFIX = "bench:";
51
-
52
- type Clause = { name: string; clause: string };
53
-
54
- const CLAUSES: Clause[] = [
55
- { name: "baseline @type:{block}", clause: "@type:{block}" },
56
- {
57
- name: "middleware @type:{block} -@deferToVerify:{true}",
58
- clause: "@type:{block} -@deferToVerify:{true}",
59
- },
60
- {
61
- name: "verify (@type:{block} | @deferToVerify:{true})",
62
- clause: "(@type:{block} | @deferToVerify:{true})",
63
- },
64
- {
65
- name: "MISGROUPED (@type:{block})|(@deferToVerify:{true})",
66
- clause: "(@type:{block})|(@deferToVerify:{true})",
67
- },
68
- ];
69
-
70
- const ipv4 = (i: number): bigint => (10n << 24n) + BigInt(i);
71
-
72
- const ipv4CidrRange = (i: number): { min: bigint; max: bigint } => {
73
- const octet = i % 256;
74
- const base = (192n << 24n) + (168n << 16n) + (BigInt(octet) << 8n);
75
- return { min: base, max: base + 255n };
76
- };
77
-
78
- const percentile = (sorted: number[], p: number): number => {
79
- const idx = Math.min(
80
- sorted.length - 1,
81
- Math.floor((p / 100) * sorted.length),
82
- );
83
- return sorted[idx] ?? 0;
84
- };
85
-
86
- const main = async (): Promise<void> => {
87
- const client = createClient({
88
- url: "redis://localhost:6379",
89
- password: "root",
90
- });
91
- await client.connect();
92
-
93
- await client.flushAll();
94
-
95
- // Real index schema shape: type + deferToVerify as TAG INDEXMISSING,
96
- // IP fields NUMERIC INDEXMISSING.
97
- await client.sendCommand([
98
- "FT.CREATE",
99
- INDEX,
100
- "ON",
101
- "HASH",
102
- "PREFIX",
103
- "1",
104
- PREFIX,
105
- "SCHEMA",
106
- "type",
107
- "TAG",
108
- "INDEXMISSING",
109
- "deferToVerify",
110
- "TAG",
111
- "INDEXMISSING",
112
- "clientId",
113
- "TAG",
114
- "INDEXMISSING",
115
- "numericIp",
116
- "NUMERIC",
117
- "INDEXMISSING",
118
- "numericIpMaskMin",
119
- "NUMERIC",
120
- "INDEXMISSING",
121
- "numericIpMaskMax",
122
- "NUMERIC",
123
- "INDEXMISSING",
124
- ]);
125
-
126
- let n = 0;
127
- const write = async (fields: Record<string, string>): Promise<void> => {
128
- await client.hSet(`${PREFIX}${n++}`, fields);
129
- };
130
-
131
- for (let i = 0; i < IP_RULE_COUNT; i++) {
132
- await write({
133
- type: "block",
134
- description: `bulk-ban-ip-${i}`,
135
- numericIp: ipv4(i).toString(),
136
- clientId: "global",
137
- });
138
- }
139
- for (let i = 0; i < CIDR_RULE_COUNT; i++) {
140
- const { min, max } = ipv4CidrRange(i);
141
- await write({
142
- type: "block",
143
- description: `bulk-ban-cidr-${i}`,
144
- numericIpMaskMin: min.toString(),
145
- numericIpMaskMax: max.toString(),
146
- clientId: "global",
147
- });
148
- }
149
- for (let i = 0; i < MIXED_RULE_COUNT; i++) {
150
- await write({
151
- type: i % 2 === 0 ? "restrict" : "block",
152
- description: `mixed-${i}`,
153
- numericIp: ipv4(IP_RULE_COUNT + i).toString(),
154
- clientId: `client-${i % 20}`,
155
- });
156
- }
157
- // Deferred cohort — the shape this change is about. Restrict, so the
158
- // baseline clause would miss them entirely.
159
- for (let i = 0; i < DEFERRED_RULE_COUNT; i++) {
160
- await write({
161
- type: "restrict",
162
- description: `deferred-${i}`,
163
- deferToVerify: "true",
164
- numericIp: ipv4(IP_RULE_COUNT + MIXED_RULE_COUNT + i).toString(),
165
- clientId: `client-${i % 20}`,
166
- });
167
- }
168
-
169
- const total = n;
170
- // Let RediSearch finish indexing.
171
- for (;;) {
172
- const info = (await client.sendCommand(["FT.INFO", INDEX])) as unknown[];
173
- const idx = info.findIndex((v) => String(v) === "indexing");
174
- if (idx === -1 || String(info[idx + 1]) === "0") break;
175
- await new Promise((r) => setTimeout(r, 200));
176
- }
177
-
178
- console.log(`population: ${total} rules`);
179
- console.log(
180
- ` ${IP_RULE_COUNT} ip-block, ${CIDR_RULE_COUNT} cidr-block, ${MIXED_RULE_COUNT} mixed, ${DEFERRED_RULE_COUNT} deferred-restrict\n`,
181
- );
182
-
183
- // The two probe shapes that dominate the split query: an exact-IP hit
184
- // (the common ban case) and the CIDR mask-range probe (the expensive
185
- // one — it walks two numeric ranges).
186
- const hitIp = ipv4(1234).toString();
187
- const cidrIp = ((192n << 24n) + (168n << 16n) + (7n << 8n) + 9n).toString();
188
- const scope = "( @clientId:{client-3} | @clientId:{global} )";
189
-
190
- const probes: Array<{ label: string; suffix: string }> = [
191
- { label: "ip:exact", suffix: `${scope} @numericIp:[${hitIp} ${hitIp}]` },
192
- {
193
- label: "ip:mask ",
194
- suffix: `${scope} @numericIpMaskMin:[-inf ${cidrIp}] @numericIpMaskMax:[${cidrIp} +inf]`,
195
- },
196
- ];
197
-
198
- for (const probe of probes) {
199
- console.log(`--- probe: ${probe.label} ---`);
200
- for (const { name, clause } of CLAUSES) {
201
- const query = `${clause} ${probe.suffix}`;
202
-
203
- for (let i = 0; i < WARMUP; i++) {
204
- await client.sendCommand([
205
- "FT.SEARCH",
206
- INDEX,
207
- query,
208
- "NOCONTENT",
209
- "DIALECT",
210
- "2",
211
- ]);
212
- }
213
-
214
- const samples: number[] = [];
215
- let hits = 0;
216
- for (let i = 0; i < ITERATIONS; i++) {
217
- const t0 = process.hrtime.bigint();
218
- const res = (await client.sendCommand([
219
- "FT.SEARCH",
220
- INDEX,
221
- query,
222
- "NOCONTENT",
223
- "DIALECT",
224
- "2",
225
- ])) as unknown[];
226
- const t1 = process.hrtime.bigint();
227
- samples.push(Number(t1 - t0) / 1e6);
228
- hits = Number(res[0]);
229
- }
230
- samples.sort((a, b) => a - b);
231
- const mean = samples.reduce((s, v) => s + v, 0) / samples.length;
232
- console.log(
233
- ` ${name.padEnd(52)} hits=${String(hits).padStart(5)} ` +
234
- `mean=${mean.toFixed(3)}ms p50=${percentile(samples, 50).toFixed(3)}ms ` +
235
- `p95=${percentile(samples, 95).toFixed(3)}ms p99=${percentile(samples, 99).toFixed(3)}ms`,
236
- );
237
- }
238
- console.log();
239
- }
240
-
241
- await client.sendCommand(["FT.DROPINDEX", INDEX]);
242
- await client.flushAll();
243
- await client.quit();
244
- };
245
-
246
- main().catch((e: unknown) => {
247
- console.error(e);
248
- process.exit(1);
249
- });