@prosopo/user-access-policy 3.12.32 → 3.13.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 (80) hide show
  1. package/.turbo/turbo-build$colon$cjs.log +12 -10
  2. package/.turbo/turbo-build$colon$tsc.log +14 -14
  3. package/.turbo/turbo-build.log +13 -11
  4. package/CHANGELOG.md +91 -0
  5. package/dist/.export.d.ts +2 -0
  6. package/dist/.export.d.ts.map +1 -1
  7. package/dist/.export.js +3 -1
  8. package/dist/.export.js.map +1 -1
  9. package/dist/cjs/.export.cjs +11 -0
  10. package/dist/cjs/classifyBrowser.cjs +44 -0
  11. package/dist/cjs/headerMatch.cjs +122 -0
  12. package/dist/cjs/mongoose/mongooseRuleSchema.cjs +20 -0
  13. package/dist/cjs/redis/reader/redisRulesReader.cjs +8 -0
  14. package/dist/cjs/redis/reader/redisRulesSplitQuery.cjs +3 -1
  15. package/dist/cjs/redis/redisRuleIndex.cjs +20 -0
  16. package/dist/cjs/ruleInput/userScopeInput.cjs +6 -1
  17. package/dist/cjs/ruleRecord.cjs +6 -1
  18. package/dist/classifyBrowser.d.ts +4 -0
  19. package/dist/classifyBrowser.d.ts.map +1 -0
  20. package/dist/classifyBrowser.js +43 -0
  21. package/dist/classifyBrowser.js.map +1 -0
  22. package/dist/headerMatch.d.ts +13 -0
  23. package/dist/headerMatch.d.ts.map +1 -0
  24. package/dist/headerMatch.js +116 -0
  25. package/dist/headerMatch.js.map +1 -0
  26. package/dist/mongoose/mongooseRuleSchema.d.ts.map +1 -1
  27. package/dist/mongoose/mongooseRuleSchema.js +20 -0
  28. package/dist/mongoose/mongooseRuleSchema.js.map +1 -1
  29. package/dist/redis/reader/redisRulesReader.d.ts.map +1 -1
  30. package/dist/redis/reader/redisRulesReader.js +8 -0
  31. package/dist/redis/reader/redisRulesReader.js.map +1 -1
  32. package/dist/redis/reader/redisRulesSplitQuery.d.ts.map +1 -1
  33. package/dist/redis/reader/redisRulesSplitQuery.js +3 -1
  34. package/dist/redis/reader/redisRulesSplitQuery.js.map +1 -1
  35. package/dist/redis/redisRuleIndex.d.ts.map +1 -1
  36. package/dist/redis/redisRuleIndex.js +20 -0
  37. package/dist/redis/redisRuleIndex.js.map +1 -1
  38. package/dist/rule.d.ts +5 -0
  39. package/dist/rule.d.ts.map +1 -1
  40. package/dist/ruleInput/ruleInput.d.ts +30 -0
  41. package/dist/ruleInput/ruleInput.d.ts.map +1 -1
  42. package/dist/ruleInput/userScopeInput.d.ts +40 -0
  43. package/dist/ruleInput/userScopeInput.d.ts.map +1 -1
  44. package/dist/ruleInput/userScopeInput.js +6 -1
  45. package/dist/ruleInput/userScopeInput.js.map +1 -1
  46. package/dist/ruleRecord.d.ts +2 -2
  47. package/dist/ruleRecord.d.ts.map +1 -1
  48. package/dist/ruleRecord.js +6 -1
  49. package/dist/ruleRecord.js.map +1 -1
  50. package/dist/tests/classifyBrowser.unit.test.d.ts +2 -0
  51. package/dist/tests/classifyBrowser.unit.test.d.ts.map +1 -0
  52. package/dist/tests/classifyBrowser.unit.test.js +99 -0
  53. package/dist/tests/classifyBrowser.unit.test.js.map +1 -0
  54. package/dist/tests/headerMatch.unit.test.d.ts +2 -0
  55. package/dist/tests/headerMatch.unit.test.d.ts.map +1 -0
  56. package/dist/tests/headerMatch.unit.test.js +202 -0
  57. package/dist/tests/headerMatch.unit.test.js.map +1 -0
  58. package/dist/tests/redis/reader/redisRulesQuery.unit.test.js +3 -3
  59. package/dist/tests/redis/reader/redisRulesQuery.unit.test.js.map +1 -1
  60. package/dist/tests/redis/reader/redisRulesSplitQuery.unit.test.js +9 -0
  61. package/dist/tests/redis/reader/redisRulesSplitQuery.unit.test.js.map +1 -1
  62. package/dist/tests/transformRule.unit.test.js +5 -0
  63. package/dist/tests/transformRule.unit.test.js.map +1 -1
  64. package/package.json +14 -14
  65. package/src/.export.ts +17 -0
  66. package/src/classifyBrowser.ts +77 -0
  67. package/src/headerMatch.ts +180 -0
  68. package/src/mongoose/mongooseRuleSchema.ts +5 -0
  69. package/src/redis/reader/redisRulesReader.ts +10 -0
  70. package/src/redis/reader/redisRulesSplitQuery.ts +2 -0
  71. package/src/redis/redisRuleIndex.ts +14 -0
  72. package/src/rule.ts +14 -0
  73. package/src/ruleInput/userScopeInput.ts +8 -0
  74. package/src/ruleRecord.ts +5 -0
  75. package/src/tests/classifyBrowser.unit.test.ts +138 -0
  76. package/src/tests/headerMatch.unit.test.ts +350 -0
  77. package/src/tests/redis/reader/redisRulesQuery.unit.test.ts +3 -3
  78. package/src/tests/redis/reader/redisRulesSplitQuery.unit.test.ts +17 -0
  79. package/src/tests/transformRule.unit.test.ts +5 -0
  80. package/tsconfig.tsbuildinfo +1 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@prosopo/user-access-policy",
3
- "version": "3.12.32",
3
+ "version": "3.13.0",
4
4
  "type": "module",
5
5
  "engines": {
6
6
  "node": "^24",
@@ -43,29 +43,29 @@
43
43
  "test": "npm run test:unit && npm run test:integration"
44
44
  },
45
45
  "dependencies": {
46
- "@prosopo/api": "4.1.4",
47
- "@prosopo/api-route": "2.6.56",
48
- "@prosopo/common": "3.1.52",
49
- "@prosopo/logger": "2.0.7",
50
- "@prosopo/redis-client": "1.0.33",
51
- "@prosopo/types": "5.5.3",
52
- "@prosopo/util": "3.3.7",
46
+ "@prosopo/api": "4.1.6",
47
+ "@prosopo/api-route": "2.6.58",
48
+ "@prosopo/common": "3.1.54",
49
+ "@prosopo/logger": "2.0.9",
50
+ "@prosopo/redis-client": "1.0.35",
51
+ "@prosopo/types": "5.7.0",
52
+ "@prosopo/util": "3.3.9",
53
53
  "@redis/search": "5.0.0",
54
54
  "cidr-calc": "1.0.4",
55
55
  "dotenv": "16.4.5",
56
- "ip-address": "10.5.0",
56
+ "ip-address": "10.7.0",
57
57
  "redis": "5.0.0",
58
58
  "zod": "3.23.8"
59
59
  },
60
60
  "devDependencies": {
61
- "@prosopo/config": "3.3.12",
62
- "@prosopo/util-crypto": "13.5.30",
61
+ "@prosopo/config": "3.3.14",
62
+ "@prosopo/util-crypto": "13.5.31",
63
63
  "@types/node": "22.10.2",
64
64
  "fast-glob": "3.3.3",
65
- "mongoose": "8.24.1",
65
+ "mongoose": "9.9.4",
66
66
  "vite": "8.1.5",
67
- "vitest": "4.1.10",
68
- "yargs": "17.7.2"
67
+ "vitest": "4.1.11",
68
+ "yargs": "18.1.0"
69
69
  },
70
70
  "author": "PROSOPO LIMITED <info@prosopo.io>",
71
71
  "license": "Apache-2.0",
package/src/.export.ts CHANGED
@@ -32,6 +32,23 @@ export { describeMatchedRule } from "./matchedRule.js";
32
32
 
33
33
  export { classifyOs, OS_NAMES, type OsName } from "./classifyOs.js";
34
34
 
35
+ export {
36
+ classifyBrowser,
37
+ BROWSER_NAMES,
38
+ type BrowserName,
39
+ } from "./classifyBrowser.js";
40
+
41
+ export {
42
+ HEADER_OPERATORS,
43
+ type HeaderOperator,
44
+ HEADER_RULE_MARKER,
45
+ isHeaderOperator,
46
+ evaluateHeaderCondition,
47
+ accessRuleHeaderMatches,
48
+ encodeHeaderValueList,
49
+ decodeHeaderValueList,
50
+ } from "./headerMatch.js";
51
+
35
52
  export {
36
53
  type AccessRulesFilter,
37
54
  type AccessRulesStorage,
@@ -0,0 +1,77 @@
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
+ * Kept in step with `@prosopo/decision-machines`' `uaClassify` — as `classifyOs`
17
+ * is — but defined here because the provider request path cannot depend on that
18
+ * package.
19
+ */
20
+ export const BROWSER_NAMES = [
21
+ "chrome",
22
+ "safari",
23
+ "firefox",
24
+ "edge",
25
+ "opera",
26
+ "samsung_internet",
27
+ "wechat",
28
+ "facebook",
29
+ "instagram",
30
+ "ie",
31
+ "unknown",
32
+ ] as const;
33
+
34
+ export type BrowserName = (typeof BROWSER_NAMES)[number];
35
+
36
+ /**
37
+ * Branch order is load-bearing: every browser below Chrome also carries a
38
+ * `chrome/` token, and Safari's signature appears in nearly every WebKit UA.
39
+ *
40
+ * Reads the User-Agent rather than the `sec-ch-ua` client hint because a client
41
+ * can simply omit client hints, but stripping the UA breaks far more.
42
+ */
43
+ export const classifyBrowser = (userAgent: string | undefined): BrowserName => {
44
+ const ua = (userAgent || "").toLowerCase();
45
+
46
+ if (/\bedg(?:e|a|ios)?\//.test(ua)) {
47
+ return "edge";
48
+ }
49
+ if (/samsungbrowser/.test(ua)) {
50
+ return "samsung_internet";
51
+ }
52
+ if (/\bopr\/|opera/.test(ua)) {
53
+ return "opera";
54
+ }
55
+ if (/micromessenger/.test(ua)) {
56
+ return "wechat";
57
+ }
58
+ if (/fban|fbav|fbios/.test(ua)) {
59
+ return "facebook";
60
+ }
61
+ if (/instagram/.test(ua)) {
62
+ return "instagram";
63
+ }
64
+ if (/firefox|fxios/.test(ua)) {
65
+ return "firefox";
66
+ }
67
+ if (/msie|trident/.test(ua)) {
68
+ return "ie";
69
+ }
70
+ if (/\bchrome\/|\bcrios\//.test(ua)) {
71
+ return "chrome";
72
+ }
73
+ if (/safari\//.test(ua)) {
74
+ return "safari";
75
+ }
76
+ return "unknown";
77
+ };
@@ -0,0 +1,180 @@
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
+ };
@@ -32,6 +32,11 @@ const userAttributesSchema: SchemaDefinition<UserAttributesRecord> = {
32
32
  countryCode: { type: String, required: false },
33
33
  asn: { type: Number, required: false },
34
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 },
35
40
  } satisfies AllKeys<UserAttributesRecord>;
36
41
 
37
42
  const userIpSchema: SchemaDefinition<UserIpRecord> = {
@@ -95,6 +95,10 @@ const SPECIFICITY_EXPR = [
95
95
  "exists(@countryCode)",
96
96
  "exists(@asn)",
97
97
  "exists(@os)",
98
+ "exists(@browser)",
99
+ // A header rule is one logical dimension: only the sentinel counts toward
100
+ // specificity, not the name/value/operator triple it also carries.
101
+ "exists(@headerMatch)",
98
102
  "exists(@numericIp)",
99
103
  "exists(@numericIpMaskMin)",
100
104
  ].join(" + ");
@@ -135,6 +139,11 @@ const RULE_LOAD_FIELDS = [
135
139
  "@countryCode",
136
140
  "@asn",
137
141
  "@os",
142
+ "@browser",
143
+ "@headerMatch",
144
+ "@headerName",
145
+ "@headerValue",
146
+ "@headerOperator",
138
147
  "@numericIp",
139
148
  "@numericIpMaskMin",
140
149
  "@numericIpMaskMax",
@@ -156,6 +165,7 @@ const readerSpecificity = (rule: AccessRule): number => {
156
165
  if (rule.countryCode !== undefined) score++;
157
166
  if (rule.asn !== undefined) score++;
158
167
  if (rule.os !== undefined) score++;
168
+ if (rule.browser !== undefined) score++;
159
169
  if (rule.numericIp !== undefined) score++;
160
170
  if (rule.numericIpMaskMin !== undefined) score++;
161
171
  return score;
@@ -41,6 +41,8 @@ const SCALAR_USER_SCOPE_FIELDS: ReadonlyArray<keyof UserScope> = [
41
41
  "coords",
42
42
  "countryCode",
43
43
  "asn",
44
+ "os",
45
+ "browser",
44
46
  ];
45
47
 
46
48
  const ALL_USER_SCOPE_FIELDS: ReadonlyArray<keyof UserScope> = [
@@ -41,6 +41,20 @@ export const userAttributesRedisSchema: RediSearchSchema = {
41
41
  countryCode: { type: SCHEMA_FIELD_TYPE.TAG, INDEXMISSING: true },
42
42
  asn: { type: SCHEMA_FIELD_TYPE.NUMERIC, INDEXMISSING: true },
43
43
  os: { type: SCHEMA_FIELD_TYPE.TAG, INDEXMISSING: true },
44
+ browser: { type: SCHEMA_FIELD_TYPE.TAG, INDEXMISSING: true },
45
+ // Header-restriction fields. NONE of these are queried on the hot path:
46
+ // the split-query builder probes neither `headerMatch` nor the
47
+ // name/value/operator triple, and a header rule populates none of the
48
+ // fields that builder does probe, so it is returned by the `no-user-scope`
49
+ // fall-through probe (the same route `os` rules take) and the concrete
50
+ // condition is evaluated in code by `accessRuleHeaderMatches`. They are
51
+ // indexed to satisfy the schema exhaustiveness check, and `headerMatch`
52
+ // additionally gates candidacy JS-side in `ruleApplies` and scores one
53
+ // specificity point in the reader's SPECIFICITY_EXPR.
54
+ headerMatch: { type: SCHEMA_FIELD_TYPE.TAG, INDEXMISSING: true },
55
+ headerName: { type: SCHEMA_FIELD_TYPE.TAG, INDEXMISSING: true },
56
+ headerValue: { type: SCHEMA_FIELD_TYPE.TAG, INDEXMISSING: true },
57
+ headerOperator: { type: SCHEMA_FIELD_TYPE.TAG, INDEXMISSING: true },
44
58
  } satisfies AllKeys<UserAttributes>;
45
59
 
46
60
  export const userScopeRedisSchema: RediSearchSchema = {
package/src/rule.ts CHANGED
@@ -78,6 +78,20 @@ export type UserAttributes = {
78
78
  // drop/limit requests from a given OS even when the client omits client
79
79
  // hints.
80
80
  os?: string;
81
+ browser?: string;
82
+ // Arbitrary-header matching (see `headerMatch.ts` and the portal Header
83
+ // Restriction card). A header rule carries `headerName` + `headerValue` +
84
+ // `headerOperator`; unlike the other dimensions these are checked in code
85
+ // against the raw request headers — Redis TAG can't express substring
86
+ // `contains` or per-rule operators — so they are not part of the Redis
87
+ // matching query. `headerMatch` is the indexed sentinel (always
88
+ // `HEADER_RULE_MARKER`) that makes such a rule a matching candidate for
89
+ // every request, so an allow-list rule still fires on a request that omits
90
+ // the target header.
91
+ headerMatch?: string;
92
+ headerName?: string;
93
+ headerValue?: string;
94
+ headerOperator?: string;
81
95
  };
82
96
 
83
97
  export type UserScope = UserAttributes & UserIp;
@@ -36,6 +36,14 @@ const userAttributesSchema = z.object({
36
36
  // countryCode: a stale/unknown value just never matches a request rather
37
37
  // than failing the whole rule parse.
38
38
  os: z.coerce.string().optional(),
39
+ browser: z.coerce.string().optional(),
40
+ // Arbitrary-header matching (see `headerMatch.ts`). `headerMatch` is the
41
+ // indexed sentinel; name/value/operator are rule data checked in code, not
42
+ // via the Redis query. Loose `string` like the other tags.
43
+ headerMatch: z.coerce.string().optional(),
44
+ headerName: z.coerce.string().optional(),
45
+ headerValue: z.coerce.string().optional(),
46
+ headerOperator: z.coerce.string().optional(),
39
47
  } satisfies AllKeys<UserAttributes>) satisfies ZodType<UserAttributes>;
40
48
 
41
49
  const userAttributesInput = z
package/src/ruleRecord.ts CHANGED
@@ -32,6 +32,11 @@ export const userAttributesRecordFields = [
32
32
  "countryCode",
33
33
  "asn",
34
34
  "os",
35
+ "browser",
36
+ "headerMatch",
37
+ "headerName",
38
+ "headerValue",
39
+ "headerOperator",
35
40
  ] as const satisfies (keyof UserAttributesRecord)[];
36
41
 
37
42
  export type UserIpRecord = {
@@ -0,0 +1,138 @@
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
+ BROWSER_NAMES,
18
+ type BrowserName,
19
+ classifyBrowser,
20
+ } from "#policy/classifyBrowser.js";
21
+
22
+ describe("classifyBrowser", () => {
23
+ const cases: Array<{ name: string; ua: string; expected: BrowserName }> = [
24
+ {
25
+ name: "Chrome on Windows",
26
+ ua: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
27
+ expected: "chrome",
28
+ },
29
+ {
30
+ name: "Chrome on iOS",
31
+ ua: "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/120.0.0.0 Mobile/15E148 Safari/604.1",
32
+ expected: "chrome",
33
+ },
34
+ {
35
+ name: "Safari on macOS",
36
+ ua: "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Safari/605.1.15",
37
+ expected: "safari",
38
+ },
39
+ {
40
+ name: "Firefox on Linux",
41
+ ua: "Mozilla/5.0 (X11; Linux x86_64; rv:120.0) Gecko/20100101 Firefox/120.0",
42
+ expected: "firefox",
43
+ },
44
+ {
45
+ name: "Firefox on iOS",
46
+ ua: "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) FxiOS/120.0 Mobile/15E148 Safari/605.1.15",
47
+ expected: "firefox",
48
+ },
49
+ {
50
+ name: "Edge on Windows",
51
+ ua: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36 Edg/120.0.0.0",
52
+ expected: "edge",
53
+ },
54
+ {
55
+ name: "Opera",
56
+ ua: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36 OPR/106.0.0.0",
57
+ expected: "opera",
58
+ },
59
+ {
60
+ name: "Samsung Internet",
61
+ ua: "Mozilla/5.0 (Linux; Android 14; SM-S918B) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/23.0 Chrome/115.0.0.0 Mobile Safari/537.36",
62
+ expected: "samsung_internet",
63
+ },
64
+ {
65
+ name: "WeChat in-app browser",
66
+ ua: "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148 MicroMessenger/8.0.42",
67
+ expected: "wechat",
68
+ },
69
+ {
70
+ name: "Facebook in-app browser",
71
+ ua: "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148 [FBAN/FBIOS;FBAV/442.0.0]",
72
+ expected: "facebook",
73
+ },
74
+ {
75
+ name: "Instagram in-app browser",
76
+ ua: "Mozilla/5.0 (Linux; Android 14) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Mobile Safari/537.36 Instagram 310.0.0.0 Android",
77
+ expected: "instagram",
78
+ },
79
+ {
80
+ name: "Internet Explorer 11",
81
+ ua: "Mozilla/5.0 (Windows NT 10.0; Trident/7.0; rv:11.0) like Gecko",
82
+ expected: "ie",
83
+ },
84
+ ];
85
+
86
+ for (const { name, ua, expected } of cases) {
87
+ it(`classifies ${name} as ${expected}`, () => {
88
+ expect(classifyBrowser(ua)).toBe(expected);
89
+ });
90
+ }
91
+
92
+ it("returns 'unknown' for an empty User-Agent", () => {
93
+ expect(classifyBrowser("")).toBe("unknown");
94
+ });
95
+
96
+ it("returns 'unknown' for an undefined User-Agent", () => {
97
+ expect(classifyBrowser(undefined)).toBe("unknown");
98
+ });
99
+
100
+ it("returns 'unknown' for an unrecognised User-Agent", () => {
101
+ expect(classifyBrowser("curl/8.4.0")).toBe("unknown");
102
+ });
103
+
104
+ it("does not classify a Chrome UA as Safari", () => {
105
+ expect(
106
+ classifyBrowser(
107
+ "Mozilla/5.0 (Windows NT 10.0) AppleWebKit/537.36 Chrome/120.0.0.0 Safari/537.36",
108
+ ),
109
+ ).toBe("chrome");
110
+ });
111
+
112
+ it("does not classify an Edge UA as Chrome", () => {
113
+ expect(
114
+ classifyBrowser(
115
+ "Mozilla/5.0 (Windows NT 10.0) AppleWebKit/537.36 Chrome/120.0.0.0 Safari/537.36 Edg/120.0.0.0",
116
+ ),
117
+ ).toBe("edge");
118
+ });
119
+
120
+ it("does not classify a Samsung Internet UA as Chrome", () => {
121
+ expect(
122
+ classifyBrowser(
123
+ "Mozilla/5.0 (Linux; Android 14) AppleWebKit/537.36 SamsungBrowser/23.0 Chrome/115.0.0.0 Mobile Safari/537.36",
124
+ ),
125
+ ).toBe("samsung_internet");
126
+ });
127
+
128
+ it("is case-insensitive", () => {
129
+ expect(classifyBrowser("MOZILLA/5.0 FIREFOX/120.0")).toBe("firefox");
130
+ });
131
+
132
+ it("only ever returns a value from BROWSER_NAMES", () => {
133
+ const uas = [...cases.map((c) => c.ua), "", "curl/8.4.0", "garbage"];
134
+ for (const ua of uas) {
135
+ expect(BROWSER_NAMES).toContain(classifyBrowser(ua));
136
+ }
137
+ });
138
+ });