@prosopo/user-access-policy 3.12.27 → 3.12.29

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 (50) hide show
  1. package/.turbo/turbo-build$colon$cjs.log +7 -7
  2. package/.turbo/turbo-build$colon$tsc.log +14 -14
  3. package/.turbo/turbo-build.log +8 -8
  4. package/CHANGELOG.md +16 -0
  5. package/dist/cjs/redis/reader/redisRulesQuery.cjs +3 -1
  6. package/dist/cjs/redis/reader/redisRulesReader.cjs +4 -1
  7. package/dist/cjs/redis/reader/redisRulesSplitQuery.cjs +31 -5
  8. package/dist/cjs/redis/redisRuleIndex.cjs +4 -0
  9. package/dist/cjs/ruleInput/ruleInput.cjs +2 -1
  10. package/dist/redis/reader/redisRulesQuery.d.ts +1 -0
  11. package/dist/redis/reader/redisRulesQuery.d.ts.map +1 -1
  12. package/dist/redis/reader/redisRulesQuery.js +3 -2
  13. package/dist/redis/reader/redisRulesQuery.js.map +1 -1
  14. package/dist/redis/reader/redisRulesReader.d.ts.map +1 -1
  15. package/dist/redis/reader/redisRulesReader.js +4 -1
  16. package/dist/redis/reader/redisRulesReader.js.map +1 -1
  17. package/dist/redis/reader/redisRulesSplitQuery.d.ts +1 -0
  18. package/dist/redis/reader/redisRulesSplitQuery.d.ts.map +1 -1
  19. package/dist/redis/reader/redisRulesSplitQuery.js +32 -6
  20. package/dist/redis/reader/redisRulesSplitQuery.js.map +1 -1
  21. package/dist/redis/redisRuleIndex.d.ts.map +1 -1
  22. package/dist/redis/redisRuleIndex.js +4 -0
  23. package/dist/redis/redisRuleIndex.js.map +1 -1
  24. package/dist/ruleInput/ruleInput.d.ts +3 -0
  25. package/dist/ruleInput/ruleInput.d.ts.map +1 -1
  26. package/dist/ruleInput/ruleInput.js +2 -1
  27. package/dist/ruleInput/ruleInput.js.map +1 -1
  28. package/dist/rulesStorage.d.ts +1 -0
  29. package/dist/rulesStorage.d.ts.map +1 -1
  30. package/dist/tests/blockingRuleShapes.unit.test.d.ts +2 -0
  31. package/dist/tests/blockingRuleShapes.unit.test.d.ts.map +1 -0
  32. package/dist/tests/blockingRuleShapes.unit.test.js +176 -0
  33. package/dist/tests/blockingRuleShapes.unit.test.js.map +1 -0
  34. package/dist/tests/redis/deferredPoolClause.benchmark.d.ts +2 -0
  35. package/dist/tests/redis/deferredPoolClause.benchmark.d.ts.map +1 -0
  36. package/dist/tests/redis/deferredPoolClause.benchmark.js +175 -0
  37. package/dist/tests/redis/deferredPoolClause.benchmark.js.map +1 -0
  38. package/dist/tests/redis/reader/redisRulesSplitQuery.unit.test.js +33 -0
  39. package/dist/tests/redis/reader/redisRulesSplitQuery.unit.test.js.map +1 -1
  40. package/package.json +3 -3
  41. package/src/redis/reader/redisRulesQuery.ts +24 -1
  42. package/src/redis/reader/redisRulesReader.ts +1 -0
  43. package/src/redis/reader/redisRulesSplitQuery.ts +51 -14
  44. package/src/redis/redisRuleIndex.ts +7 -0
  45. package/src/ruleInput/ruleInput.ts +1 -0
  46. package/src/rulesStorage.ts +9 -0
  47. package/src/tests/blockingRuleShapes.unit.test.ts +244 -0
  48. package/src/tests/redis/deferredPoolClause.benchmark.ts +249 -0
  49. package/src/tests/redis/reader/redisRulesSplitQuery.unit.test.ts +73 -0
  50. package/tsconfig.tsbuildinfo +1 -1
@@ -12,11 +12,8 @@
12
12
  // See the License for the specific language governing permissions and
13
13
  // limitations under the License.
14
14
 
15
- import {
16
- AccessPolicyType,
17
- GLOBAL_CLIENT_SCOPE_SENTINEL,
18
- type UserScope,
19
- } from "#policy/rule.js";
15
+ import { getBlockPoolClause } from "#policy/redis/reader/redisRulesQuery.js";
16
+ import { GLOBAL_CLIENT_SCOPE_SENTINEL, type UserScope } from "#policy/rule.js";
20
17
 
21
18
  // Escapes special characters in Redis TAG queries. Mirrors the escape
22
19
  // function in redisRulesQuery.ts — kept local to avoid coupling the two
@@ -113,6 +110,9 @@ type SubQuery = {
113
110
  *
114
111
  * `blockOnly` narrows the candidate set to rules with `type:{block}` —
115
112
  * used by the hard-block middleware which never needs Restrict rules.
113
+ * `includeDeferred` widens that pool back out to
114
+ * `(@type:{block} | @deferToVerify:{true})` for the verify-time
115
+ * hard-block lookup, where a deferred Restrict is a valid block.
116
116
  * Every other caller passes false so both Block and Restrict rules are
117
117
  * fetched; the JS-side ranker picks the right one via specificity +
118
118
  * severity. Merging the block and restrict paths behind one split-query
@@ -124,14 +124,51 @@ type SubQuery = {
124
124
  export const buildScopedRulesSubQueries = (
125
125
  userScope: UserScope,
126
126
  clientId: string | undefined,
127
- options: { blockOnly?: boolean } = {},
127
+ options: { blockOnly?: boolean; includeDeferred?: boolean } = {},
128
128
  ): SubQuery[] => {
129
- const typeClause = options.blockOnly
130
- ? `@type:{${AccessPolicyType.Block}} `
131
- : "";
132
129
  const scopeClause = buildScopeClause(clientId);
133
- const prefix = `${typeClause}${scopeClause}`;
134
130
 
131
+ // `includeDeferred` emits the probe set twice — once for plain hard
132
+ // blocks, once for deferred rules — rather than widening one type
133
+ // clause to `(@type:{block} | @deferToVerify:{true})`.
134
+ //
135
+ // Widening merges both populations into a single probe, so they share
136
+ // one SPLIT_MAX_CANDIDATES_PER_SUB budget and a dense deferred cohort
137
+ // can truncate hard blocks out of the candidate set. Measured on a
138
+ // scope with 400 block + 400 deferred rules on one ja4Hash: the merged
139
+ // probe returns 800 against a cap of 500, so 300 candidates are
140
+ // dropped with no ordering guarantee about which. That is precisely
141
+ // the crowding `blockOnly` was introduced to prevent.
142
+ //
143
+ // Two disjoint probe sets each get their own budget, so a hard block
144
+ // is never displaced by a deferred rule. The pools are disjoint by
145
+ // construction (the block probe excludes deferred), so the union
146
+ // double-counts nothing.
147
+ // Probe `kind` is only namespaced when there are genuinely two pools;
148
+ // single-pool callers keep the original unprefixed labels.
149
+ const typePools: Array<{ tag: string; clause: string }> = options.blockOnly
150
+ ? options.includeDeferred === true
151
+ ? [
152
+ { tag: "block", clause: `${getBlockPoolClause(false)} ` },
153
+ { tag: "deferred", clause: "@deferToVerify:{true} " },
154
+ ]
155
+ : [{ tag: "", clause: `${getBlockPoolClause(false)} ` }]
156
+ : [{ tag: "", clause: "" }];
157
+
158
+ const subQueries: SubQuery[] = [];
159
+ for (const pool of typePools) {
160
+ const prefix = `${pool.clause}${scopeClause}`;
161
+ const label = pool.tag === "" ? "" : `${pool.tag}:`;
162
+ subQueries.push(...buildProbes(userScope, prefix, label));
163
+ }
164
+ return subQueries;
165
+ };
166
+
167
+ const buildProbes = (
168
+ userScope: UserScope,
169
+ prefix: string,
170
+ label: string,
171
+ ): SubQuery[] => {
135
172
  const subQueries: SubQuery[] = [];
136
173
 
137
174
  // One probe per populated scalar user-scope field. Each uses that
@@ -143,7 +180,7 @@ export const buildScopedRulesSubQueries = (
143
180
  continue;
144
181
  }
145
182
  subQueries.push({
146
- kind: `field:${field}`,
183
+ kind: `${label}field:${field}`,
147
184
  query: `${prefix} ${clause}`,
148
185
  });
149
186
  }
@@ -158,11 +195,11 @@ export const buildScopedRulesSubQueries = (
158
195
  const requestIp = userScope.numericIp;
159
196
  if (requestIp !== undefined) {
160
197
  subQueries.push({
161
- kind: "ip:exact",
198
+ kind: `${label}ip:exact`,
162
199
  query: `${prefix} @numericIp:[${requestIp} ${requestIp}]`,
163
200
  });
164
201
  subQueries.push({
165
- kind: "ip:mask",
202
+ kind: `${label}ip:mask`,
166
203
  query: `${prefix} @numericIpMaskMin:[-inf ${requestIp}] @numericIpMaskMax:[${requestIp} +inf]`,
167
204
  });
168
205
  }
@@ -175,7 +212,7 @@ export const buildScopedRulesSubQueries = (
175
212
  (field) => `ismissing(@${field})`,
176
213
  ).join(" ");
177
214
  subQueries.push({
178
- kind: "no-user-scope",
215
+ kind: `${label}no-user-scope`,
179
216
  query: `${prefix} ${noScopeIsmissing}`,
180
217
  });
181
218
 
@@ -73,6 +73,13 @@ export const accessRuleRedisSchema: RediSearchSchema = {
73
73
  // Restrict / routing-Block populations push the hard-block rules out
74
74
  // of the top-N candidate set and the lookup silently misses them.
75
75
  type: { type: SCHEMA_FIELD_TYPE.TAG, INDEXMISSING: true },
76
+ // Indexed so `checkForHardBlock` can widen its Block-only pool to
77
+ // `(@type:{block} | @deferToVerify:{true})`. Deferred rules are
78
+ // skipped at request time and enforced at verify, so a deferred
79
+ // Restrict is a legitimate hard block — without this field in the
80
+ // index it can't be selected server-side and never fires.
81
+ // Stored by `getRedisRuleValue` as the string "true"/"false".
82
+ deferToVerify: { type: SCHEMA_FIELD_TYPE.TAG, INDEXMISSING: true },
76
83
  } satisfies Keys<AccessRule>;
77
84
 
78
85
  export const ACCESS_RULES_REDIS_INDEX_NAME = "index:user-access-rules";
@@ -98,6 +98,7 @@ export const accessRulesFilterInput = z.object({
98
98
  .default(FilterScopeMatch.Exact),
99
99
  groupId: z.string().optional(),
100
100
  blockOnly: z.boolean().optional(),
101
+ includeDeferred: z.boolean().optional(),
101
102
  } satisfies AllKeys<AccessRulesFilterInput>);
102
103
 
103
104
  export const getAccessRuleFiltersFromInput = (
@@ -42,6 +42,15 @@ export type AccessRulesFilter = {
42
42
  * and the lookup misses them.
43
43
  */
44
44
  blockOnly?: boolean;
45
+ /**
46
+ * Widen a `blockOnly` pool to also admit deferred rules of any type.
47
+ * A `deferToVerify` rule is skipped at request time and enforced at
48
+ * verify, so it can legitimately be a Restrict — `blockOnly` alone
49
+ * would filter it out of the verify-time lookup and the rule would
50
+ * never fire. Only `checkForHardBlock` sets this; the request-time
51
+ * middleware keeps the narrow Block-only pool.
52
+ */
53
+ includeDeferred?: boolean;
45
54
  };
46
55
 
47
56
  export type AccessRuleEntry = {
@@ -0,0 +1,244 @@
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
+ // Characterisation tests for the two blocking rule shapes as they pass
16
+ // through the write-side zod types:
17
+ //
18
+ // 1. plain Block — rejects at request time
19
+ // 2. Block + deferToVerify — passes request time, rejects at verify
20
+ //
21
+ // These pin down what each shape may carry, and what survives the write
22
+ // path into storage. Written against the schemas the rule-sync API
23
+ // actually validates with, so a change in either direction fails here
24
+ // first rather than as a 400 in production.
25
+
26
+ import type { Logger } from "@prosopo/logger";
27
+ import { CaptchaType } from "@prosopo/types";
28
+ import { describe, expect, it, vi } from "vitest";
29
+ import { InsertRulesEndpoint } from "#policy/api/write/insertRules.js";
30
+ import { AccessPolicyType } from "#policy/rule.js";
31
+ import {
32
+ accessPolicyInput,
33
+ sanitizeAccessPolicy,
34
+ } from "#policy/ruleInput/policyInput.js";
35
+ import type { AccessRulesWriter } from "#policy/rulesStorage.js";
36
+
37
+ const makeMockLogger = (): Logger =>
38
+ ({
39
+ trace: vi.fn(),
40
+ debug: vi.fn(),
41
+ info: vi.fn(),
42
+ warn: vi.fn(),
43
+ error: vi.fn(),
44
+ fatal: vi.fn(),
45
+ log: vi.fn(),
46
+ setLogLevel: vi.fn(),
47
+ getLogLevel: vi.fn().mockReturnValue("info"),
48
+ with: vi.fn().mockReturnThis(),
49
+ getScope: vi.fn().mockReturnValue("test"),
50
+ getPretty: vi.fn().mockReturnValue(false),
51
+ setPretty: vi.fn(),
52
+ getPrintStack: vi.fn().mockReturnValue(false),
53
+ setPrintStack: vi.fn(),
54
+ getFormat: vi.fn().mockReturnValue("json"),
55
+ setFormat: vi.fn(),
56
+ }) satisfies Logger;
57
+
58
+ const makeMockWriter = (): AccessRulesWriter =>
59
+ ({
60
+ insertRules: vi.fn().mockResolvedValue(["id1"]),
61
+ }) as unknown as AccessRulesWriter;
62
+
63
+ const getInsertSchema = () =>
64
+ new InsertRulesEndpoint(
65
+ makeMockWriter(),
66
+ makeMockLogger(),
67
+ ).getRequestArgsSchema();
68
+
69
+ describe("blocking rule shapes: plain Block", () => {
70
+ it("accepts a bare Block", () => {
71
+ const result = accessPolicyInput.safeParse({
72
+ type: AccessPolicyType.Block,
73
+ description: "bare block",
74
+ });
75
+ expect(result.success).toBe(true);
76
+ });
77
+
78
+ it("rejects captchaType and solvedImagesCount together", () => {
79
+ const result = accessPolicyInput.safeParse({
80
+ type: AccessPolicyType.Block,
81
+ captchaType: CaptchaType.image,
82
+ solvedImagesCount: 2,
83
+ });
84
+ expect(result.success).toBe(false);
85
+ if (!result.success) {
86
+ expect(result.error.issues.map((i) => i.path.at(-1)).sort()).toEqual([
87
+ "captchaType",
88
+ "solvedImagesCount",
89
+ ]);
90
+ }
91
+ });
92
+
93
+ it("strips both fields on the way into storage", () => {
94
+ expect(
95
+ sanitizeAccessPolicy({
96
+ type: AccessPolicyType.Block,
97
+ captchaType: CaptchaType.image,
98
+ solvedImagesCount: 2,
99
+ description: "legacy block",
100
+ }),
101
+ ).toEqual({
102
+ type: AccessPolicyType.Block,
103
+ description: "legacy block",
104
+ });
105
+ });
106
+ });
107
+
108
+ describe("blocking rule shapes: Block + deferToVerify", () => {
109
+ it("accepts the flag on its own, and preserves it", () => {
110
+ const result = accessPolicyInput.safeParse({
111
+ type: AccessPolicyType.Block,
112
+ deferToVerify: true,
113
+ description: "deferred block",
114
+ });
115
+ expect(result.success).toBe(true);
116
+ if (result.success) {
117
+ expect(result.data.deferToVerify).toBe(true);
118
+ }
119
+ });
120
+
121
+ // The shape that 400s the rule-sync push. The refinement has no
122
+ // deferToVerify carve-out, so a deferred Block is held to the same
123
+ // rule as a plain one.
124
+ it("rejects captchaType and solvedImagesCount despite the flag", () => {
125
+ const result = accessPolicyInput.safeParse({
126
+ type: AccessPolicyType.Block,
127
+ deferToVerify: true,
128
+ captchaType: CaptchaType.image,
129
+ solvedImagesCount: 2,
130
+ });
131
+ expect(result.success).toBe(false);
132
+ if (!result.success) {
133
+ expect(result.error.issues.map((i) => i.path.at(-1)).sort()).toEqual([
134
+ "captchaType",
135
+ "solvedImagesCount",
136
+ ]);
137
+ }
138
+ });
139
+
140
+ // Even were the refinement to admit them, the sanitiser drops them
141
+ // before storage — the flag does not exempt a Block here either.
142
+ it("strips both fields on the way into storage", () => {
143
+ expect(
144
+ sanitizeAccessPolicy({
145
+ type: AccessPolicyType.Block,
146
+ deferToVerify: true,
147
+ captchaType: CaptchaType.image,
148
+ solvedImagesCount: 2,
149
+ description: "deferred block",
150
+ }),
151
+ ).toEqual({
152
+ type: AccessPolicyType.Block,
153
+ deferToVerify: true,
154
+ description: "deferred block",
155
+ });
156
+ });
157
+
158
+ it("round-trips the flag from its stored string form", () => {
159
+ const result = accessPolicyInput.safeParse({
160
+ type: AccessPolicyType.Block,
161
+ deferToVerify: "true",
162
+ });
163
+ expect(result.success).toBe(true);
164
+ if (result.success) {
165
+ expect(result.data.deferToVerify).toBe(true);
166
+ }
167
+ });
168
+ });
169
+
170
+ describe("blocking rule shapes: Restrict keeps its tuning fields", () => {
171
+ it("accepts captchaType and solvedImagesCount, with or without the flag", () => {
172
+ for (const deferToVerify of [undefined, true]) {
173
+ const result = accessPolicyInput.safeParse({
174
+ type: AccessPolicyType.Restrict,
175
+ captchaType: CaptchaType.image,
176
+ solvedImagesCount: 2,
177
+ ...(deferToVerify === undefined ? {} : { deferToVerify }),
178
+ });
179
+ expect(result.success).toBe(true);
180
+ }
181
+ });
182
+
183
+ it("survives the sanitiser intact", () => {
184
+ expect(
185
+ sanitizeAccessPolicy({
186
+ type: AccessPolicyType.Restrict,
187
+ deferToVerify: true,
188
+ captchaType: CaptchaType.image,
189
+ solvedImagesCount: 2,
190
+ }),
191
+ ).toEqual({
192
+ type: AccessPolicyType.Restrict,
193
+ deferToVerify: true,
194
+ captchaType: CaptchaType.image,
195
+ solvedImagesCount: 2,
196
+ });
197
+ });
198
+ });
199
+
200
+ // The insert endpoint validates the batch as one `z.array`, so validity
201
+ // is all-or-nothing across the whole push.
202
+ describe("blocking rule shapes: batch validation", () => {
203
+ const validGroup = {
204
+ accessPolicy: {
205
+ type: AccessPolicyType.Block,
206
+ deferToVerify: true,
207
+ description: "deferred block",
208
+ },
209
+ userScopes: [{ ip: "1.1.1.1" }],
210
+ };
211
+
212
+ const invalidGroup = {
213
+ accessPolicy: {
214
+ type: AccessPolicyType.Block,
215
+ deferToVerify: true,
216
+ captchaType: CaptchaType.image,
217
+ solvedImagesCount: 2,
218
+ description: "deferred block with tuning fields",
219
+ },
220
+ userScopes: [{ ip: "2.2.2.2" }],
221
+ };
222
+
223
+ it("accepts a batch of well-formed groups", () => {
224
+ const result = getInsertSchema().safeParse([validGroup, validGroup]);
225
+ expect(result.success).toBe(true);
226
+ });
227
+
228
+ it("rejects the entire batch when a single group is malformed", () => {
229
+ const batch = [
230
+ ...Array.from({ length: 20 }, () => validGroup),
231
+ invalidGroup,
232
+ ...Array.from({ length: 20 }, () => validGroup),
233
+ ];
234
+
235
+ const result = getInsertSchema().safeParse(batch);
236
+
237
+ expect(result.success).toBe(false);
238
+ if (!result.success) {
239
+ // Every issue points at the one bad group; the other 40 are
240
+ // well-formed and are still rejected along with it.
241
+ expect(result.error.issues.map((i) => i.path[0])).toEqual([20, 20]);
242
+ }
243
+ });
244
+ });
@@ -0,0 +1,249 @@
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
+ });