@prosopo/user-access-policy 3.14.14 → 3.14.16
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.
- package/.turbo/turbo-build$colon$cjs.log +3 -6
- package/.turbo/turbo-build$colon$tsc.log +14 -17
- package/.turbo/turbo-build.log +4 -10
- package/CHANGELOG.md +37 -0
- package/LICENSE +201 -0
- package/dist/cjs/ruleInput/policyInput.cjs +1 -1
- package/dist/ruleInput/policyInput.d.ts +2 -2
- package/dist/ruleInput/policyInput.d.ts.map +1 -1
- package/dist/ruleInput/policyInput.js +2 -2
- package/dist/ruleInput/policyInput.js.map +1 -1
- package/package.json +83 -81
- package/src/.export.ts +0 -65
- package/src/api/.export.ts +0 -25
- package/src/api/accessRulesApiClient.ts +0 -13
- package/src/api/delete/.export.ts +0 -18
- package/src/api/delete/deleteAllRules.ts +0 -47
- package/src/api/delete/deleteRuleGroups.ts +0 -96
- package/src/api/delete/deleteRules.ts +0 -87
- package/src/api/read/.export.ts +0 -25
- package/src/api/read/fetchRules.ts +0 -96
- package/src/api/read/findRuleIds.ts +0 -97
- package/src/api/read/getMissingIds.ts +0 -81
- package/src/api/ruleApiRoutes.ts +0 -146
- package/src/api/rulesApiClient.ts +0 -154
- package/src/api/write/.export.ts +0 -15
- package/src/api/write/insertRules.ts +0 -185
- package/src/api/write/rehashRules.ts +0 -85
- package/src/classifyBrowser.ts +0 -77
- package/src/classifyOs.ts +0 -74
- package/src/headerMatch.ts +0 -180
- package/src/matchedRule.ts +0 -99
- package/src/mongoose/.export.ts +0 -15
- package/src/mongoose/mongooseRuleSchema.ts +0 -75
- package/src/redis/.export.ts +0 -17
- package/src/redis/reader/redisAggregate.ts +0 -157
- package/src/redis/reader/redisRulesQuery.ts +0 -268
- package/src/redis/reader/redisRulesReader.ts +0 -738
- package/src/redis/reader/redisRulesSplitQuery.ts +0 -234
- package/src/redis/redisClient.ts +0 -125
- package/src/redis/redisRuleIndex.ts +0 -121
- package/src/redis/redisRulesStorage.ts +0 -68
- package/src/redis/redisRulesWriter.ts +0 -172
- package/src/rule.ts +0 -122
- package/src/ruleInput/.export.ts +0 -19
- package/src/ruleInput/policyInput.ts +0 -120
- package/src/ruleInput/ruleInput.ts +0 -123
- package/src/ruleInput/userScopeInput.ts +0 -142
- package/src/ruleRecord.ts +0 -77
- package/src/rulesStorage.ts +0 -90
- package/src/tests/blockingRuleShapes.unit.test.ts +0 -244
- package/src/tests/classifyBrowser.unit.test.ts +0 -138
- package/src/tests/classifyOs.unit.test.ts +0 -97
- package/src/tests/headerMatch.unit.test.ts +0 -350
- package/src/tests/insertRulesEndpoint.unit.test.ts +0 -89
- package/src/tests/matchedRule.unit.test.ts +0 -184
- package/src/tests/policyInput.unit.test.ts +0 -218
- package/src/tests/redis/deferredPoolClause.benchmark.ts +0 -249
- package/src/tests/redis/reader/redisAggregate.unit.test.ts +0 -121
- package/src/tests/redis/reader/redisRulesQuery.unit.test.ts +0 -349
- package/src/tests/redis/reader/redisRulesSplitQuery.unit.test.ts +0 -297
- package/src/tests/redis/redisRulesReaderLoad.benchmark.integration.test.ts +0 -520
- package/src/tests/redis/redisRulesReaderRank.benchmark.integration.test.ts +0 -350
- package/src/tests/redis/redisRulesStorage.integration.test.ts +0 -1811
- package/src/tests/testLogger.ts +0 -38
- package/src/tests/transformRule.unit.test.ts +0 -345
- package/src/transformRule.ts +0 -133
- package/tsconfig.cjs.json +0 -41
- package/tsconfig.json +0 -47
- package/tsconfig.tsbuildinfo +0 -1
- 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
|
-
);
|
package/src/rulesStorage.ts
DELETED
|
@@ -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
|
-
});
|