@prosopo/user-access-policy 3.14.14 → 3.14.15

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 (65) hide show
  1. package/.turbo/turbo-build$colon$cjs.log +2 -5
  2. package/.turbo/turbo-build$colon$tsc.log +14 -17
  3. package/.turbo/turbo-build.log +3 -9
  4. package/CHANGELOG.md +18 -0
  5. package/LICENSE +201 -0
  6. package/package.json +83 -81
  7. package/src/.export.ts +0 -65
  8. package/src/api/.export.ts +0 -25
  9. package/src/api/accessRulesApiClient.ts +0 -13
  10. package/src/api/delete/.export.ts +0 -18
  11. package/src/api/delete/deleteAllRules.ts +0 -47
  12. package/src/api/delete/deleteRuleGroups.ts +0 -96
  13. package/src/api/delete/deleteRules.ts +0 -87
  14. package/src/api/read/.export.ts +0 -25
  15. package/src/api/read/fetchRules.ts +0 -96
  16. package/src/api/read/findRuleIds.ts +0 -97
  17. package/src/api/read/getMissingIds.ts +0 -81
  18. package/src/api/ruleApiRoutes.ts +0 -146
  19. package/src/api/rulesApiClient.ts +0 -154
  20. package/src/api/write/.export.ts +0 -15
  21. package/src/api/write/insertRules.ts +0 -185
  22. package/src/api/write/rehashRules.ts +0 -85
  23. package/src/classifyBrowser.ts +0 -77
  24. package/src/classifyOs.ts +0 -74
  25. package/src/headerMatch.ts +0 -180
  26. package/src/matchedRule.ts +0 -99
  27. package/src/mongoose/.export.ts +0 -15
  28. package/src/mongoose/mongooseRuleSchema.ts +0 -75
  29. package/src/redis/.export.ts +0 -17
  30. package/src/redis/reader/redisAggregate.ts +0 -157
  31. package/src/redis/reader/redisRulesQuery.ts +0 -268
  32. package/src/redis/reader/redisRulesReader.ts +0 -738
  33. package/src/redis/reader/redisRulesSplitQuery.ts +0 -234
  34. package/src/redis/redisClient.ts +0 -125
  35. package/src/redis/redisRuleIndex.ts +0 -121
  36. package/src/redis/redisRulesStorage.ts +0 -68
  37. package/src/redis/redisRulesWriter.ts +0 -172
  38. package/src/rule.ts +0 -122
  39. package/src/ruleInput/.export.ts +0 -19
  40. package/src/ruleInput/policyInput.ts +0 -120
  41. package/src/ruleInput/ruleInput.ts +0 -123
  42. package/src/ruleInput/userScopeInput.ts +0 -142
  43. package/src/ruleRecord.ts +0 -77
  44. package/src/rulesStorage.ts +0 -90
  45. package/src/tests/blockingRuleShapes.unit.test.ts +0 -244
  46. package/src/tests/classifyBrowser.unit.test.ts +0 -138
  47. package/src/tests/classifyOs.unit.test.ts +0 -97
  48. package/src/tests/headerMatch.unit.test.ts +0 -350
  49. package/src/tests/insertRulesEndpoint.unit.test.ts +0 -89
  50. package/src/tests/matchedRule.unit.test.ts +0 -184
  51. package/src/tests/policyInput.unit.test.ts +0 -218
  52. package/src/tests/redis/deferredPoolClause.benchmark.ts +0 -249
  53. package/src/tests/redis/reader/redisAggregate.unit.test.ts +0 -121
  54. package/src/tests/redis/reader/redisRulesQuery.unit.test.ts +0 -349
  55. package/src/tests/redis/reader/redisRulesSplitQuery.unit.test.ts +0 -297
  56. package/src/tests/redis/redisRulesReaderLoad.benchmark.integration.test.ts +0 -520
  57. package/src/tests/redis/redisRulesReaderRank.benchmark.integration.test.ts +0 -350
  58. package/src/tests/redis/redisRulesStorage.integration.test.ts +0 -1811
  59. package/src/tests/testLogger.ts +0 -38
  60. package/src/tests/transformRule.unit.test.ts +0 -345
  61. package/src/transformRule.ts +0 -133
  62. package/tsconfig.cjs.json +0 -41
  63. package/tsconfig.json +0 -47
  64. package/tsconfig.tsbuildinfo +0 -1
  65. package/tsconfig.types.json +0 -9
@@ -1,142 +0,0 @@
1
- // Copyright 2021-2026 Prosopo (UK) Ltd.
2
- //
3
- // Licensed under the Apache License, Version 2.0 (the "License");
4
- // you may not use this file except in compliance with the License.
5
- // You may obtain a copy of the License at
6
- //
7
- // http://www.apache.org/licenses/LICENSE-2.0
8
- //
9
- // Unless required by applicable law or agreed to in writing, software
10
- // distributed under the License is distributed on an "AS IS" BASIS,
11
- // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
- // See the License for the specific language governing permissions and
13
- // limitations under the License.
14
-
15
- import crypto from "node:crypto";
16
- import type { AllKeys } from "@prosopo/common";
17
- import { getIPAddress } from "@prosopo/util";
18
- import { Address4, Address6 } from "ip-address";
19
- import { type ZodType, z } from "zod";
20
- import type { UserAttributes, UserIp, UserScope } from "#policy/rule.js";
21
- import type { UserAttributesRecord, UserIpRecord } from "#policy/ruleRecord.js";
22
-
23
- export type UserAttributesInput = UserAttributes & UserAttributesRecord;
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
-
37
- const userAttributesSchema = z.object({
38
- // coerce is used for safety, as e.g., incoming userId can be digital
39
- userId: z.coerce.string().optional(),
40
- ja4Hash: z.coerce.string().optional(),
41
- headersHash: z.coerce.string().optional(),
42
- userAgentHash: z.coerce.string().optional(),
43
- headHash: z.coerce.string().optional(),
44
- coords: z.coerce.string().optional(),
45
- countryCode: z.coerce.string().optional(),
46
- asn: z.coerce.number().int().nonnegative().optional(),
47
- // Plain lowercase OS tag (see `classifyOs`). Loose `string` like
48
- // countryCode: a stale/unknown value just never matches a request rather
49
- // than failing the whole rule parse.
50
- os: z.coerce.string().optional(),
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(),
68
- } satisfies AllKeys<UserAttributes>) satisfies ZodType<UserAttributes>;
69
-
70
- const userAttributesInput = z
71
- .object({
72
- ...userAttributesSchema.shape,
73
- userAgent: z.coerce.string().optional(),
74
- } satisfies AllKeys<UserAttributesInput>)
75
- .transform((userAttributesInput: UserAttributesInput): UserAttributes => {
76
- // this line creates a new "userAttributes", without userAgent
77
- const { userAgent, ...userScope } = userAttributesInput;
78
-
79
- if ("string" === typeof userAgent) {
80
- userScope.userAgentHash = hashUserAgent(userAgent);
81
- }
82
-
83
- return userScope;
84
- });
85
-
86
- const hashUserAgent = (userAgent: string): string =>
87
- crypto.createHash("sha256").update(userAgent).digest("hex");
88
-
89
- export type UserIpInput = UserIp & UserIpRecord;
90
-
91
- const userIpSchema = z.object({
92
- numericIp: z.coerce.bigint().optional(),
93
- numericIpMaskMin: z.coerce.bigint().optional(),
94
- numericIpMaskMax: z.coerce.bigint().optional(),
95
- } satisfies AllKeys<UserIp>) satisfies ZodType<UserIp>;
96
-
97
- const userIpInput = z
98
- .object({
99
- ...userIpSchema.shape,
100
- ip: z.string().optional(),
101
- ipMask: z.string().optional(),
102
- } satisfies AllKeys<UserIpInput>)
103
- .transform((userIpInput: UserIpInput): UserIp => {
104
- // this line creates a new "userScope", without ip and ipMask
105
- const { ip, ipMask, ...numericUserIp } = userIpInput;
106
-
107
- if ("string" === typeof ip) {
108
- numericUserIp.numericIp = getIPAddress(ip).bigInt();
109
- }
110
-
111
- // Assuming ipMask is already validated to be a string in CIDR format
112
- if ("string" === typeof ipMask) {
113
- // Try IPv4 CIDR first (e.g., 192.168.1.0/24); fall back to IPv6
114
- // CIDR (e.g., 2001:db8::/32). Both Address4 and Address6 understand
115
- // CIDR notation and expose start/end addresses for the network.
116
- const ipObject = Address4.isValid(ipMask)
117
- ? new Address4(ipMask)
118
- : new Address6(ipMask);
119
-
120
- numericUserIp.numericIpMaskMin = ipObject.startAddress().bigInt();
121
- numericUserIp.numericIpMaskMax = ipObject.endAddress().bigInt();
122
- }
123
-
124
- return numericUserIp;
125
- });
126
-
127
- export type UserScopeInput = UserAttributesInput & UserIpInput;
128
-
129
- export const userScopeSchema = z.object({
130
- ...userIpSchema.shape,
131
- ...userAttributesSchema.shape,
132
- } satisfies AllKeys<UserScope>) satisfies ZodType<UserScope>;
133
-
134
- export const userScopeInput = z
135
- .object({})
136
- // unlike ...shape(), .and() includes transformations
137
- .and(userIpInput)
138
- .and(userAttributesInput)
139
- .transform(
140
- // transform is used for type safety only - plain "satisfies ZodType<x>" doesn't work after ".and()"
141
- (userScopeInput): UserScopeInput => userScopeInput,
142
- );
package/src/ruleRecord.ts DELETED
@@ -1,77 +0,0 @@
1
- // Copyright 2021-2026 Prosopo (UK) Ltd.
2
- //
3
- // Licensed under the Apache License, Version 2.0 (the "License");
4
- // you may not use this file except in compliance with the License.
5
- // You may obtain a copy of the License at
6
- //
7
- // http://www.apache.org/licenses/LICENSE-2.0
8
- //
9
- // Unless required by applicable law or agreed to in writing, software
10
- // distributed under the License is distributed on an "AS IS" BASIS,
11
- // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
- // See the License for the specific language governing permissions and
13
- // limitations under the License.
14
-
15
- import type {
16
- AccessPolicy,
17
- PolicyScope,
18
- UserAttributes,
19
- } from "#policy/rule.js";
20
-
21
- export type UserAttributesRecord = Omit<UserAttributes, "userAgentHash"> & {
22
- userAgent?: string;
23
- };
24
-
25
- export const userAttributesRecordFields = [
26
- "userId",
27
- "ja4Hash",
28
- "headersHash",
29
- "userAgent",
30
- "headHash",
31
- "coords",
32
- "countryCode",
33
- "asn",
34
- "os",
35
- "browser",
36
- "headerMatch",
37
- "headerName",
38
- "headerValue",
39
- "headerOperator",
40
- "webBotAuthAgent",
41
- ] as const satisfies (keyof UserAttributesRecord)[];
42
-
43
- export type UserIpRecord = {
44
- // human-friendly ip versions.
45
- ip?: string;
46
- // 127.0.0.1/24
47
- ipMask?: string;
48
- };
49
-
50
- export const userIpRecordFields = [
51
- "ip",
52
- "ipMask",
53
- ] as const satisfies (keyof UserIpRecord)[];
54
-
55
- export type UserScopeRecord = UserAttributesRecord & UserIpRecord;
56
-
57
- export const userScopeRecordFields = [
58
- ...userAttributesRecordFields,
59
- ...userIpRecordFields,
60
- ] as const satisfies (keyof UserScopeRecord)[];
61
-
62
- export type UserScopeRecordField = (typeof userScopeRecordFields)[number];
63
-
64
- export type AccessRuleRecord = AccessPolicy &
65
- PolicyScope &
66
- UserScopeRecord & {
67
- ruleGroupId?: string;
68
- };
69
-
70
- export const getUserScopeRecordFromAccessRuleRecord = (
71
- ruleRecord: AccessRuleRecord,
72
- ): UserScopeRecord =>
73
- Object.fromEntries(
74
- userScopeRecordFields
75
- .map((field) => [field, ruleRecord[field]])
76
- .filter(([, value]) => value !== undefined),
77
- );
@@ -1,90 +0,0 @@
1
- // Copyright 2021-2026 Prosopo (UK) Ltd.
2
- //
3
- // Licensed under the Apache License, Version 2.0 (the "License");
4
- // you may not use this file except in compliance with the License.
5
- // You may obtain a copy of the License at
6
- //
7
- // http://www.apache.org/licenses/LICENSE-2.0
8
- //
9
- // Unless required by applicable law or agreed to in writing, software
10
- // distributed under the License is distributed on an "AS IS" BASIS,
11
- // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
- // See the License for the specific language governing permissions and
13
- // limitations under the License.
14
-
15
- import type { AccessRule, PolicyScope, UserScope } from "#policy/rule.js";
16
-
17
- export enum FilterScopeMatch {
18
- Exact = "exact",
19
- Greedy = "greedy",
20
- }
21
-
22
- export type AccessRulesFilter = {
23
- policyScope?: PolicyScope;
24
- /**
25
- * Exact: "clientId" => client rules, "undefined" => global rules. Used by the API
26
- * Greedy: "clientId" => client + global rules, "undefined" => any rules. Used by the Express middleware
27
- */
28
- policyScopeMatch?: FilterScopeMatch;
29
- userScope?: UserScope;
30
- /**
31
- * Exact: "clientId" => client rules, "undefined" => global rules. Used by the API
32
- * Greedy: "clientId" => client + global rules, "undefined" => any rules. Used by the Express middleware
33
- */
34
- userScopeMatch?: FilterScopeMatch;
35
- groupId?: string;
36
- /**
37
- * Restrict the result to Block-type rules. Hot-path callers
38
- * (checkForHardBlock, blockMiddleware) set this so the Redis-side
39
- * candidate pool excludes Restrict and routing-Block rules before
40
- * the SERVER_SIDE_RANK_TOP_N cap is applied — without it, dense
41
- * Restrict populations can push the hard-block rules past the cap
42
- * and the lookup misses them.
43
- */
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;
54
- };
55
-
56
- export type AccessRuleEntry = {
57
- rule: AccessRule;
58
- expiresUnixTimestamp?: number;
59
- };
60
-
61
- export type AccessRulesReader = {
62
- fetchRules(ruleIds: string[]): Promise<AccessRuleEntry[]>;
63
-
64
- getMissingRuleIds(ruleIds: string[]): Promise<string[]>;
65
-
66
- findRules(
67
- filter: AccessRulesFilter,
68
- matchingFieldsOnly?: boolean,
69
- skipEmptyUserScopes?: boolean,
70
- ): Promise<AccessRule[]>;
71
-
72
- findRuleIds(
73
- filter: AccessRulesFilter,
74
- matchingFieldsOnly?: boolean,
75
- ): Promise<string[]>;
76
-
77
- fetchAllRuleIds(
78
- batchHandler: (ruleIds: string[]) => Promise<void>,
79
- ): Promise<void>;
80
- };
81
-
82
- export type AccessRulesWriter = {
83
- insertRules(ruleEntries: AccessRuleEntry[]): Promise<string[]>;
84
-
85
- deleteRules(ruleIds: string[]): Promise<void>;
86
-
87
- deleteAllRules(): Promise<number>;
88
- };
89
-
90
- export type AccessRulesStorage = AccessRulesReader & AccessRulesWriter;
@@ -1,244 +0,0 @@
1
- // Copyright 2021-2026 Prosopo (UK) Ltd.
2
- //
3
- // Licensed under the Apache License, Version 2.0 (the "License");
4
- // you may not use this file except in compliance with the License.
5
- // You may obtain a copy of the License at
6
- //
7
- // http://www.apache.org/licenses/LICENSE-2.0
8
- //
9
- // Unless required by applicable law or agreed to in writing, software
10
- // distributed under the License is distributed on an "AS IS" BASIS,
11
- // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
- // See the License for the specific language governing permissions and
13
- // limitations under the License.
14
-
15
- // 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
- });
@@ -1,138 +0,0 @@
1
- // Copyright 2021-2026 Prosopo (UK) Ltd.
2
- //
3
- // Licensed under the Apache License, Version 2.0 (the "License");
4
- // you may not use this file except in compliance with the License.
5
- // You may obtain a copy of the License at
6
- //
7
- // http://www.apache.org/licenses/LICENSE-2.0
8
- //
9
- // Unless required by applicable law or agreed to in writing, software
10
- // distributed under the License is distributed on an "AS IS" BASIS,
11
- // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
- // See the License for the specific language governing permissions and
13
- // limitations under the License.
14
-
15
- import { 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
- });