@prosopo/user-access-policy 3.12.31 → 3.12.33

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 (69) 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 +74 -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 +3 -0
  10. package/dist/cjs/classifyBrowser.cjs +44 -0
  11. package/dist/cjs/mongoose/mongooseRuleSchema.cjs +4 -0
  12. package/dist/cjs/redis/reader/redisRulesReader.cjs +3 -0
  13. package/dist/cjs/redis/reader/redisRulesSplitQuery.cjs +3 -1
  14. package/dist/cjs/redis/redisRuleIndex.cjs +4 -0
  15. package/dist/cjs/ruleInput/userScopeInput.cjs +2 -1
  16. package/dist/cjs/ruleRecord.cjs +2 -1
  17. package/dist/classifyBrowser.d.ts +4 -0
  18. package/dist/classifyBrowser.d.ts.map +1 -0
  19. package/dist/classifyBrowser.js +43 -0
  20. package/dist/classifyBrowser.js.map +1 -0
  21. package/dist/mongoose/mongooseRuleSchema.d.ts.map +1 -1
  22. package/dist/mongoose/mongooseRuleSchema.js +4 -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 +3 -0
  26. package/dist/redis/reader/redisRulesReader.js.map +1 -1
  27. package/dist/redis/reader/redisRulesSplitQuery.d.ts.map +1 -1
  28. package/dist/redis/reader/redisRulesSplitQuery.js +3 -1
  29. package/dist/redis/reader/redisRulesSplitQuery.js.map +1 -1
  30. package/dist/redis/redisRuleIndex.d.ts.map +1 -1
  31. package/dist/redis/redisRuleIndex.js +4 -0
  32. package/dist/redis/redisRuleIndex.js.map +1 -1
  33. package/dist/rule.d.ts +1 -0
  34. package/dist/rule.d.ts.map +1 -1
  35. package/dist/ruleInput/ruleInput.d.ts +6 -0
  36. package/dist/ruleInput/ruleInput.d.ts.map +1 -1
  37. package/dist/ruleInput/userScopeInput.d.ts +8 -0
  38. package/dist/ruleInput/userScopeInput.d.ts.map +1 -1
  39. package/dist/ruleInput/userScopeInput.js +2 -1
  40. package/dist/ruleInput/userScopeInput.js.map +1 -1
  41. package/dist/ruleRecord.d.ts +2 -2
  42. package/dist/ruleRecord.d.ts.map +1 -1
  43. package/dist/ruleRecord.js +2 -1
  44. package/dist/ruleRecord.js.map +1 -1
  45. package/dist/tests/classifyBrowser.unit.test.d.ts +2 -0
  46. package/dist/tests/classifyBrowser.unit.test.d.ts.map +1 -0
  47. package/dist/tests/classifyBrowser.unit.test.js +99 -0
  48. package/dist/tests/classifyBrowser.unit.test.js.map +1 -0
  49. package/dist/tests/redis/reader/redisRulesQuery.unit.test.js +3 -3
  50. package/dist/tests/redis/reader/redisRulesQuery.unit.test.js.map +1 -1
  51. package/dist/tests/redis/reader/redisRulesSplitQuery.unit.test.js +9 -0
  52. package/dist/tests/redis/reader/redisRulesSplitQuery.unit.test.js.map +1 -1
  53. package/dist/tests/transformRule.unit.test.js +1 -0
  54. package/dist/tests/transformRule.unit.test.js.map +1 -1
  55. package/package.json +12 -12
  56. package/src/.export.ts +6 -0
  57. package/src/classifyBrowser.ts +77 -0
  58. package/src/mongoose/mongooseRuleSchema.ts +1 -0
  59. package/src/redis/reader/redisRulesReader.ts +3 -0
  60. package/src/redis/reader/redisRulesSplitQuery.ts +2 -0
  61. package/src/redis/redisRuleIndex.ts +1 -0
  62. package/src/rule.ts +1 -0
  63. package/src/ruleInput/userScopeInput.ts +1 -0
  64. package/src/ruleRecord.ts +1 -0
  65. package/src/tests/classifyBrowser.unit.test.ts +138 -0
  66. package/src/tests/redis/reader/redisRulesQuery.unit.test.ts +3 -3
  67. package/src/tests/redis/reader/redisRulesSplitQuery.unit.test.ts +17 -0
  68. package/src/tests/transformRule.unit.test.ts +1 -0
  69. package/tsconfig.tsbuildinfo +1 -1
package/src/.export.ts CHANGED
@@ -32,6 +32,12 @@ 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
+
35
41
  export {
36
42
  type AccessRulesFilter,
37
43
  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
+ };
@@ -32,6 +32,7 @@ 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 },
35
36
  } satisfies AllKeys<UserAttributesRecord>;
36
37
 
37
38
  const userIpSchema: SchemaDefinition<UserIpRecord> = {
@@ -95,6 +95,7 @@ const SPECIFICITY_EXPR = [
95
95
  "exists(@countryCode)",
96
96
  "exists(@asn)",
97
97
  "exists(@os)",
98
+ "exists(@browser)",
98
99
  "exists(@numericIp)",
99
100
  "exists(@numericIpMaskMin)",
100
101
  ].join(" + ");
@@ -135,6 +136,7 @@ const RULE_LOAD_FIELDS = [
135
136
  "@countryCode",
136
137
  "@asn",
137
138
  "@os",
139
+ "@browser",
138
140
  "@numericIp",
139
141
  "@numericIpMaskMin",
140
142
  "@numericIpMaskMax",
@@ -156,6 +158,7 @@ const readerSpecificity = (rule: AccessRule): number => {
156
158
  if (rule.countryCode !== undefined) score++;
157
159
  if (rule.asn !== undefined) score++;
158
160
  if (rule.os !== undefined) score++;
161
+ if (rule.browser !== undefined) score++;
159
162
  if (rule.numericIp !== undefined) score++;
160
163
  if (rule.numericIpMaskMin !== undefined) score++;
161
164
  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,7 @@ 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 },
44
45
  } satisfies AllKeys<UserAttributes>;
45
46
 
46
47
  export const userScopeRedisSchema: RediSearchSchema = {
package/src/rule.ts CHANGED
@@ -78,6 +78,7 @@ 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;
81
82
  };
82
83
 
83
84
  export type UserScope = UserAttributes & UserIp;
@@ -36,6 +36,7 @@ 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(),
39
40
  } satisfies AllKeys<UserAttributes>) satisfies ZodType<UserAttributes>;
40
41
 
41
42
  const userAttributesInput = z
package/src/ruleRecord.ts CHANGED
@@ -32,6 +32,7 @@ export const userAttributesRecordFields = [
32
32
  "countryCode",
33
33
  "asn",
34
34
  "os",
35
+ "browser",
35
36
  ] as const satisfies (keyof UserAttributesRecord)[];
36
37
 
37
38
  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
+ });
@@ -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) )",
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) )",
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) )",
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) )",
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) )",
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) )",
150
150
  );
151
151
  });
152
152
 
@@ -101,6 +101,8 @@ describe("buildScopedBlockSubQueries", () => {
101
101
  "coords",
102
102
  "countryCode",
103
103
  "asn",
104
+ "os",
105
+ "browser",
104
106
  "numericIp",
105
107
  "numericIpMaskMin",
106
108
  "numericIpMaskMax",
@@ -109,6 +111,21 @@ describe("buildScopedBlockSubQueries", () => {
109
111
  }
110
112
  });
111
113
 
114
+ it("emits field probes for the UA-derived os and browser scopes", () => {
115
+ const subs = buildScopedBlockSubQueries(
116
+ { os: "windows", browser: "firefox" },
117
+ "client-A",
118
+ );
119
+
120
+ // Without its own probe a browser-only rule is reachable only via the
121
+ // no-user-scope fall-through, sharing that probe's candidate budget.
122
+ const osSub = subs.find((s) => s.kind === "field:os");
123
+ expect(osSub?.query).toContain("@os:{windows}");
124
+
125
+ const browserSub = subs.find((s) => s.kind === "field:browser");
126
+ expect(browserSub?.query).toContain("@browser:{firefox}");
127
+ });
128
+
112
129
  it("uses NUMERIC range syntax for asn, not TAG syntax", () => {
113
130
  const subs = buildScopedBlockSubQueries({ asn: 205016 }, "client-A");
114
131
 
@@ -116,6 +116,7 @@ describe("transformRule", () => {
116
116
  countryCode: "US",
117
117
  asn: 205016,
118
118
  os: "macos",
119
+ browser: "firefox",
119
120
  } satisfies AccessRule;
120
121
 
121
122
  it("should transform access rule record into rule", () => {