@prosopo/user-access-policy 3.12.33 → 3.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (64) hide show
  1. package/.turbo/turbo-build$colon$cjs.log +11 -10
  2. package/.turbo/turbo-build$colon$tsc.log +14 -14
  3. package/.turbo/turbo-build.log +12 -11
  4. package/CHANGELOG.md +53 -0
  5. package/dist/.export.d.ts +1 -0
  6. package/dist/.export.d.ts.map +1 -1
  7. package/dist/.export.js +2 -1
  8. package/dist/.export.js.map +1 -1
  9. package/dist/cjs/.export.cjs +8 -0
  10. package/dist/cjs/headerMatch.cjs +122 -0
  11. package/dist/cjs/mongoose/mongooseRuleSchema.cjs +20 -0
  12. package/dist/cjs/redis/reader/redisRulesReader.cjs +5 -0
  13. package/dist/cjs/redis/redisRuleIndex.cjs +20 -0
  14. package/dist/cjs/rule.cjs +1 -0
  15. package/dist/cjs/ruleInput/userScopeInput.cjs +13 -1
  16. package/dist/cjs/ruleRecord.cjs +6 -1
  17. package/dist/headerMatch.d.ts +13 -0
  18. package/dist/headerMatch.d.ts.map +1 -0
  19. package/dist/headerMatch.js +116 -0
  20. package/dist/headerMatch.js.map +1 -0
  21. package/dist/mongoose/mongooseRuleSchema.d.ts.map +1 -1
  22. package/dist/mongoose/mongooseRuleSchema.js +20 -0
  23. package/dist/mongoose/mongooseRuleSchema.js.map +1 -1
  24. package/dist/redis/reader/redisRulesReader.d.ts.map +1 -1
  25. package/dist/redis/reader/redisRulesReader.js +5 -0
  26. package/dist/redis/reader/redisRulesReader.js.map +1 -1
  27. package/dist/redis/redisRuleIndex.d.ts.map +1 -1
  28. package/dist/redis/redisRuleIndex.js +20 -0
  29. package/dist/redis/redisRuleIndex.js.map +1 -1
  30. package/dist/rule.d.ts +7 -1
  31. package/dist/rule.d.ts.map +1 -1
  32. package/dist/rule.js +1 -0
  33. package/dist/rule.js.map +1 -1
  34. package/dist/ruleInput/ruleInput.d.ts +30 -0
  35. package/dist/ruleInput/ruleInput.d.ts.map +1 -1
  36. package/dist/ruleInput/userScopeInput.d.ts +40 -0
  37. package/dist/ruleInput/userScopeInput.d.ts.map +1 -1
  38. package/dist/ruleInput/userScopeInput.js +13 -1
  39. package/dist/ruleInput/userScopeInput.js.map +1 -1
  40. package/dist/ruleRecord.d.ts +2 -2
  41. package/dist/ruleRecord.d.ts.map +1 -1
  42. package/dist/ruleRecord.js +6 -1
  43. package/dist/ruleRecord.js.map +1 -1
  44. package/dist/tests/headerMatch.unit.test.d.ts +2 -0
  45. package/dist/tests/headerMatch.unit.test.d.ts.map +1 -0
  46. package/dist/tests/headerMatch.unit.test.js +202 -0
  47. package/dist/tests/headerMatch.unit.test.js.map +1 -0
  48. package/dist/tests/redis/reader/redisRulesQuery.unit.test.js +3 -3
  49. package/dist/tests/redis/reader/redisRulesQuery.unit.test.js.map +1 -1
  50. package/dist/tests/transformRule.unit.test.js +5 -0
  51. package/dist/tests/transformRule.unit.test.js.map +1 -1
  52. package/package.json +11 -11
  53. package/src/.export.ts +11 -0
  54. package/src/headerMatch.ts +180 -0
  55. package/src/mongoose/mongooseRuleSchema.ts +5 -0
  56. package/src/redis/reader/redisRulesReader.ts +7 -0
  57. package/src/redis/redisRuleIndex.ts +14 -0
  58. package/src/rule.ts +29 -0
  59. package/src/ruleInput/userScopeInput.ts +28 -0
  60. package/src/ruleRecord.ts +5 -0
  61. package/src/tests/headerMatch.unit.test.ts +350 -0
  62. package/src/tests/redis/reader/redisRulesQuery.unit.test.ts +3 -3
  63. package/src/tests/transformRule.unit.test.ts +5 -0
  64. package/tsconfig.tsbuildinfo +1 -1
package/src/rule.ts CHANGED
@@ -16,6 +16,15 @@ import type { CaptchaType } from "@prosopo/types";
16
16
  export enum AccessPolicyType {
17
17
  Block = "block",
18
18
  Restrict = "restrict",
19
+ // Explicit allow-list: request that matches an Allow rule bypasses the
20
+ // challenge flow and gets an `authenticated` session (captchaType =
21
+ // authenticated on the frictionless response). Fires for any qualifying
22
+ // scope — verified Web Bot Auth agent, allow-listed IP, ja4/ja4_and_ip
23
+ // match, UA substring, ASN, country. IP-binding on the resulting
24
+ // session token defends against replay from a different IP. Opt-in per
25
+ // rule; the operator authors an Allow rule the same way they'd author
26
+ // a Block or Restrict rule.
27
+ Allow = "allow",
19
28
  }
20
29
 
21
30
  // Sentinel stamped on the Redis `clientId` field for rules that would
@@ -79,6 +88,26 @@ export type UserAttributes = {
79
88
  // hints.
80
89
  os?: string;
81
90
  browser?: string;
91
+ // Arbitrary-header matching (see `headerMatch.ts` and the portal Header
92
+ // Restriction card). A header rule carries `headerName` + `headerValue` +
93
+ // `headerOperator`; unlike the other dimensions these are checked in code
94
+ // against the raw request headers — Redis TAG can't express substring
95
+ // `contains` or per-rule operators — so they are not part of the Redis
96
+ // matching query. `headerMatch` is the indexed sentinel (always
97
+ // `HEADER_RULE_MARKER`) that makes such a rule a matching candidate for
98
+ // every request, so an allow-list rule still fires on a request that omits
99
+ // the target header.
100
+ headerMatch?: string;
101
+ headerName?: string;
102
+ headerValue?: string;
103
+ headerOperator?: string;
104
+ // Canonical Web Bot Auth Signature-Agent URL — e.g.
105
+ // "https://signatures.openai.com/". Matched at runtime against the
106
+ // `Signature-Agent` header after RFC 9421 Ed25519 verification succeeds.
107
+ // Stored as-is like `countryCode` (exact equality after normalisation).
108
+ // Rules with this field only match requests carrying a valid Web Bot
109
+ // Auth signature; unverified traffic falls through.
110
+ webBotAuthAgent?: string;
82
111
  };
83
112
 
84
113
  export type UserScope = UserAttributes & UserIp;
@@ -22,6 +22,18 @@ import type { UserAttributesRecord, UserIpRecord } from "#policy/ruleRecord.js";
22
22
 
23
23
  export type UserAttributesInput = UserAttributes & UserAttributesRecord;
24
24
 
25
+ // Cloudflare's Web Bot Auth reference implementation compares Signature-Agent
26
+ // values as URL objects, which are case-insensitive on scheme/host but
27
+ // case-sensitive on path. Store the canonical form so runtime matcher can use
28
+ // plain string equality.
29
+ function normaliseSignatureAgentUrl(raw: string): string {
30
+ const url = new URL(raw);
31
+ url.hostname = url.hostname.toLowerCase();
32
+ url.protocol = url.protocol.toLowerCase();
33
+ if (url.pathname === "/") url.pathname = "";
34
+ return url.toString().replace(/\/$/, "");
35
+ }
36
+
25
37
  const userAttributesSchema = z.object({
26
38
  // coerce is used for safety, as e.g., incoming userId can be digital
27
39
  userId: z.coerce.string().optional(),
@@ -37,6 +49,22 @@ const userAttributesSchema = z.object({
37
49
  // than failing the whole rule parse.
38
50
  os: z.coerce.string().optional(),
39
51
  browser: z.coerce.string().optional(),
52
+ // Arbitrary-header matching (see `headerMatch.ts`). `headerMatch` is the
53
+ // indexed sentinel; name/value/operator are rule data checked in code, not
54
+ // via the Redis query. Loose `string` like the other tags.
55
+ headerMatch: z.coerce.string().optional(),
56
+ headerName: z.coerce.string().optional(),
57
+ headerValue: z.coerce.string().optional(),
58
+ headerOperator: z.coerce.string().optional(),
59
+ // Web Bot Auth Signature-Agent URL. Normalised at parse time
60
+ // (lowercase scheme+host, no trailing slash) so the runtime matcher can
61
+ // use exact string equality against the verified `Signature-Agent`
62
+ // header value.
63
+ webBotAuthAgent: z.coerce
64
+ .string()
65
+ .url()
66
+ .transform(normaliseSignatureAgentUrl)
67
+ .optional(),
40
68
  } satisfies AllKeys<UserAttributes>) satisfies ZodType<UserAttributes>;
41
69
 
42
70
  const userAttributesInput = z
package/src/ruleRecord.ts CHANGED
@@ -33,6 +33,11 @@ export const userAttributesRecordFields = [
33
33
  "asn",
34
34
  "os",
35
35
  "browser",
36
+ "headerMatch",
37
+ "headerName",
38
+ "headerValue",
39
+ "headerOperator",
40
+ "webBotAuthAgent",
36
41
  ] as const satisfies (keyof UserAttributesRecord)[];
37
42
 
38
43
  export type UserIpRecord = {
@@ -0,0 +1,350 @@
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 { describe, expect, it } from "vitest";
16
+ import {
17
+ type HeaderOperator,
18
+ accessRuleHeaderMatches,
19
+ decodeHeaderValueList,
20
+ encodeHeaderValueList,
21
+ evaluateHeaderCondition,
22
+ isHeaderOperator,
23
+ } from "#policy/headerMatch.js";
24
+
25
+ describe("isHeaderOperator", () => {
26
+ it("accepts the six known operators", () => {
27
+ for (const op of [
28
+ "equals",
29
+ "contains",
30
+ "notEquals",
31
+ "notContains",
32
+ "notEqualsAny",
33
+ "notContainsAny",
34
+ ]) {
35
+ expect(isHeaderOperator(op)).toBe(true);
36
+ }
37
+ });
38
+
39
+ it("rejects unknown / undefined values", () => {
40
+ expect(isHeaderOperator("startsWith")).toBe(false);
41
+ expect(isHeaderOperator("")).toBe(false);
42
+ expect(isHeaderOperator(undefined)).toBe(false);
43
+ });
44
+ });
45
+
46
+ describe("evaluateHeaderCondition", () => {
47
+ const headers: Record<string, string> = {
48
+ "x-app-version": "1.2.3-beta",
49
+ "cf-worker": "example.com",
50
+ };
51
+
52
+ const cases: ReadonlyArray<{
53
+ name: string;
54
+ op: HeaderOperator;
55
+ value: string;
56
+ header: string;
57
+ expected: boolean;
58
+ }> = [
59
+ // equals: present + exact
60
+ {
61
+ name: "equals hit",
62
+ op: "equals",
63
+ value: "example.com",
64
+ header: "cf-worker",
65
+ expected: true,
66
+ },
67
+ {
68
+ name: "equals miss (different value)",
69
+ op: "equals",
70
+ value: "evil.com",
71
+ header: "cf-worker",
72
+ expected: false,
73
+ },
74
+ {
75
+ name: "equals miss (header absent)",
76
+ op: "equals",
77
+ value: "anything",
78
+ header: "x-missing",
79
+ expected: false,
80
+ },
81
+ // contains: substring
82
+ {
83
+ name: "contains hit",
84
+ op: "contains",
85
+ value: "beta",
86
+ header: "x-app-version",
87
+ expected: true,
88
+ },
89
+ {
90
+ name: "contains miss (no substring)",
91
+ op: "contains",
92
+ value: "alpha",
93
+ header: "x-app-version",
94
+ expected: false,
95
+ },
96
+ {
97
+ name: "contains miss (header absent)",
98
+ op: "contains",
99
+ value: "beta",
100
+ header: "x-missing",
101
+ expected: false,
102
+ },
103
+ // notEquals: allow-list "equals" — blocks when NOT exactly equal
104
+ {
105
+ name: "notEquals blocks a different value",
106
+ op: "notEquals",
107
+ value: "example.com",
108
+ header: "cf-worker",
109
+ expected: false,
110
+ },
111
+ {
112
+ name: "notEquals lets an exact match through",
113
+ op: "notEquals",
114
+ value: "evil.com",
115
+ header: "cf-worker",
116
+ expected: true,
117
+ },
118
+ {
119
+ name: "notEquals blocks an absent header",
120
+ op: "notEquals",
121
+ value: "secret",
122
+ header: "x-secret",
123
+ expected: true,
124
+ },
125
+ // notContains: allow-list "contains" — blocks when substring absent
126
+ {
127
+ name: "notContains blocks when substring present",
128
+ op: "notContains",
129
+ value: "beta",
130
+ header: "x-app-version",
131
+ expected: false,
132
+ },
133
+ {
134
+ name: "notContains lets a substring hit through",
135
+ op: "notContains",
136
+ value: "alpha",
137
+ header: "x-app-version",
138
+ expected: true,
139
+ },
140
+ {
141
+ name: "notContains blocks an absent header",
142
+ op: "notContains",
143
+ value: "beta",
144
+ header: "x-missing",
145
+ expected: true,
146
+ },
147
+ ];
148
+
149
+ for (const c of cases) {
150
+ it(c.name, () => {
151
+ expect(evaluateHeaderCondition(c.header, c.op, c.value, headers)).toBe(
152
+ c.expected,
153
+ );
154
+ });
155
+ }
156
+
157
+ it("matches header names case-insensitively", () => {
158
+ expect(
159
+ evaluateHeaderCondition("CF-Worker", "equals", "example.com", headers),
160
+ ).toBe(true);
161
+ });
162
+ });
163
+
164
+ describe("accessRuleHeaderMatches", () => {
165
+ const headers: Record<string, string> = { "x-debug": "1" };
166
+
167
+ it("passes trivially when the rule has no header condition", () => {
168
+ expect(accessRuleHeaderMatches({}, headers)).toBe(true);
169
+ });
170
+
171
+ it("evaluates a well-formed header condition", () => {
172
+ expect(
173
+ accessRuleHeaderMatches(
174
+ { headerName: "x-debug", headerOperator: "equals", headerValue: "1" },
175
+ headers,
176
+ ),
177
+ ).toBe(true);
178
+ expect(
179
+ accessRuleHeaderMatches(
180
+ { headerName: "x-debug", headerOperator: "equals", headerValue: "0" },
181
+ headers,
182
+ ),
183
+ ).toBe(false);
184
+ });
185
+
186
+ it("never matches a rule with a header name but an invalid operator", () => {
187
+ expect(
188
+ accessRuleHeaderMatches(
189
+ { headerName: "x-debug", headerOperator: "wat", headerValue: "1" },
190
+ headers,
191
+ ),
192
+ ).toBe(false);
193
+ });
194
+
195
+ it("treats a missing headerValue as an empty string", () => {
196
+ // "contains empty string" is always true for a present header.
197
+ expect(
198
+ accessRuleHeaderMatches(
199
+ { headerName: "x-debug", headerOperator: "contains" },
200
+ headers,
201
+ ),
202
+ ).toBe(true);
203
+ });
204
+ });
205
+
206
+ describe("encode/decodeHeaderValueList", () => {
207
+ it("round-trips a list of values", () => {
208
+ const values = ["ios", "android", "web"];
209
+ expect(decodeHeaderValueList(encodeHeaderValueList(values))).toEqual(
210
+ values,
211
+ );
212
+ });
213
+
214
+ it("round-trips values containing the characters a delimiter would break on", () => {
215
+ const values = ["en-GB,en;q=0.9", 'a"b', "c\\d", "[bracketed]"];
216
+ expect(decodeHeaderValueList(encodeHeaderValueList(values))).toEqual(
217
+ values,
218
+ );
219
+ });
220
+
221
+ it("treats a plain (non list-encoded) value as a one-value list", () => {
222
+ expect(decodeHeaderValueList("ios")).toEqual(["ios"]);
223
+ expect(decodeHeaderValueList("")).toEqual([""]);
224
+ });
225
+
226
+ it("rejects a list-shaped value that is not a usable list", () => {
227
+ expect(decodeHeaderValueList("[")).toBeUndefined();
228
+ expect(decodeHeaderValueList("[not json]")).toBeUndefined();
229
+ expect(decodeHeaderValueList("[]")).toBeUndefined();
230
+ expect(decodeHeaderValueList("[1,2]")).toBeUndefined();
231
+ expect(decodeHeaderValueList('["ok", null]')).toBeUndefined();
232
+ });
233
+ });
234
+
235
+ describe("evaluateHeaderCondition — multi-value allow-list operators", () => {
236
+ const headers: Record<string, string> = {
237
+ "x-app": "android",
238
+ "user-agent": "Mozilla/5.0 (Linux; Android 14) Chrome/120",
239
+ };
240
+
241
+ const allowedApps = encodeHeaderValueList(["ios", "android"]);
242
+
243
+ it("does not block a request whose header is one of the allowed values", () => {
244
+ expect(
245
+ evaluateHeaderCondition("x-app", "notEqualsAny", allowedApps, headers),
246
+ ).toBe(false);
247
+ expect(
248
+ evaluateHeaderCondition(
249
+ "x-app",
250
+ "notEqualsAny",
251
+ encodeHeaderValueList(["android"]),
252
+ headers,
253
+ ),
254
+ ).toBe(false);
255
+ });
256
+
257
+ it("blocks a request whose header matches none of the allowed values", () => {
258
+ expect(
259
+ evaluateHeaderCondition(
260
+ "x-app",
261
+ "notEqualsAny",
262
+ encodeHeaderValueList(["ios", "web"]),
263
+ headers,
264
+ ),
265
+ ).toBe(true);
266
+ });
267
+
268
+ it("blocks a request that omits the header entirely", () => {
269
+ expect(
270
+ evaluateHeaderCondition(
271
+ "x-missing",
272
+ "notEqualsAny",
273
+ allowedApps,
274
+ headers,
275
+ ),
276
+ ).toBe(true);
277
+ expect(
278
+ evaluateHeaderCondition(
279
+ "x-missing",
280
+ "notContainsAny",
281
+ allowedApps,
282
+ headers,
283
+ ),
284
+ ).toBe(true);
285
+ });
286
+
287
+ it("matches a substring of the header for notContainsAny", () => {
288
+ expect(
289
+ evaluateHeaderCondition(
290
+ "user-agent",
291
+ "notContainsAny",
292
+ encodeHeaderValueList(["Android", "iPhone"]),
293
+ headers,
294
+ ),
295
+ ).toBe(false);
296
+ expect(
297
+ evaluateHeaderCondition(
298
+ "user-agent",
299
+ "notContainsAny",
300
+ encodeHeaderValueList(["iPhone", "Windows"]),
301
+ headers,
302
+ ),
303
+ ).toBe(true);
304
+ });
305
+
306
+ it("is exact for notEqualsAny — a substring is not a match", () => {
307
+ expect(
308
+ evaluateHeaderCondition(
309
+ "x-app",
310
+ "notEqualsAny",
311
+ encodeHeaderValueList(["droid"]),
312
+ headers,
313
+ ),
314
+ ).toBe(true);
315
+ });
316
+
317
+ it("never fires on a malformed value list, rather than blocking everything", () => {
318
+ for (const operator of ["notEqualsAny", "notContainsAny"] as const) {
319
+ expect(evaluateHeaderCondition("x-app", operator, "[]", headers)).toBe(
320
+ false,
321
+ );
322
+ expect(
323
+ evaluateHeaderCondition("x-missing", operator, "[bad", headers),
324
+ ).toBe(false);
325
+ }
326
+ });
327
+
328
+ it("is reachable through accessRuleHeaderMatches", () => {
329
+ expect(
330
+ accessRuleHeaderMatches(
331
+ {
332
+ headerName: "X-App",
333
+ headerOperator: "notEqualsAny",
334
+ headerValue: allowedApps,
335
+ },
336
+ headers,
337
+ ),
338
+ ).toBe(false);
339
+ expect(
340
+ accessRuleHeaderMatches(
341
+ {
342
+ headerName: "X-App",
343
+ headerOperator: "notEqualsAny",
344
+ headerValue: encodeHeaderValueList(["ios"]),
345
+ },
346
+ headers,
347
+ ),
348
+ ).toBe(true);
349
+ });
350
+ });
@@ -50,7 +50,7 @@ describe("getRulesRedisQuery", () => {
50
50
  const query = getRulesRedisQuery(filter, true);
51
51
 
52
52
  expect(query).toBe(
53
- "( ( @numericIp:[100 100] | ( @numericIpMaskMin:[-inf 100] @numericIpMaskMax:[100 +inf] ) ) @ja4Hash:{ja4Hash} ismissing(@userAgentHash) ismissing(@userId) ismissing(@headersHash) ismissing(@headHash) ismissing(@coords) ismissing(@countryCode) ismissing(@asn) ismissing(@os) ismissing(@browser) )",
53
+ "( ( @numericIp:[100 100] | ( @numericIpMaskMin:[-inf 100] @numericIpMaskMax:[100 +inf] ) ) @ja4Hash:{ja4Hash} ismissing(@userAgentHash) ismissing(@userId) ismissing(@headersHash) ismissing(@headHash) ismissing(@coords) ismissing(@countryCode) ismissing(@asn) ismissing(@os) ismissing(@browser) ismissing(@headerMatch) ismissing(@headerName) ismissing(@headerValue) ismissing(@headerOperator) ismissing(@webBotAuthAgent) )",
54
54
  );
55
55
  });
56
56
 
@@ -126,7 +126,7 @@ describe("getRulesRedisQuery", () => {
126
126
  const query = getRulesRedisQuery(filter, true);
127
127
 
128
128
  expect(query).toBe(
129
- "( ( @numericIp:[100 100] | ( @numericIpMaskMin:[-inf 100] @numericIpMaskMax:[100 +inf] ) ) @ja4Hash:{ja4Hash} ismissing(@userAgentHash) ismissing(@headersHash) ismissing(@userId) ismissing(@headHash) ismissing(@coords) ismissing(@countryCode) ismissing(@asn) ismissing(@os) ismissing(@browser) )",
129
+ "( ( @numericIp:[100 100] | ( @numericIpMaskMin:[-inf 100] @numericIpMaskMax:[100 +inf] ) ) @ja4Hash:{ja4Hash} ismissing(@userAgentHash) ismissing(@headersHash) ismissing(@userId) ismissing(@headHash) ismissing(@coords) ismissing(@countryCode) ismissing(@asn) ismissing(@os) ismissing(@browser) ismissing(@headerMatch) ismissing(@headerName) ismissing(@headerValue) ismissing(@headerOperator) ismissing(@webBotAuthAgent) )",
130
130
  );
131
131
  });
132
132
 
@@ -146,7 +146,7 @@ describe("getRulesRedisQuery", () => {
146
146
  const query = getRulesRedisQuery(filter, true);
147
147
 
148
148
  expect(query).toBe(
149
- "( @numericIpMaskMin:[-inf 100] @numericIpMaskMax:[200 +inf] @ja4Hash:{ja4Hash} ismissing(@userAgentHash) ismissing(@headersHash) ismissing(@userId) ismissing(@headHash) ismissing(@coords) ismissing(@countryCode) ismissing(@asn) ismissing(@os) ismissing(@browser) )",
149
+ "( @numericIpMaskMin:[-inf 100] @numericIpMaskMax:[200 +inf] @ja4Hash:{ja4Hash} ismissing(@userAgentHash) ismissing(@headersHash) ismissing(@userId) ismissing(@headHash) ismissing(@coords) ismissing(@countryCode) ismissing(@asn) ismissing(@os) ismissing(@browser) ismissing(@headerMatch) ismissing(@headerName) ismissing(@headerValue) ismissing(@headerOperator) ismissing(@webBotAuthAgent) )",
150
150
  );
151
151
  });
152
152
 
@@ -117,6 +117,11 @@ describe("transformRule", () => {
117
117
  asn: 205016,
118
118
  os: "macos",
119
119
  browser: "firefox",
120
+ headerMatch: "1",
121
+ headerName: "x-test",
122
+ headerValue: "test",
123
+ headerOperator: "equals",
124
+ webBotAuthAgent: "https://signatures.openai.com",
120
125
  } satisfies AccessRule;
121
126
 
122
127
  it("should transform access rule record into rule", () => {