@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,172 +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 { chunkIntoBatches, executeBatchesSequentially } from "@prosopo/common";
16
- import type { Logger } from "@prosopo/logger";
17
- import type { RedisClientType } from "redis";
18
- import { REDIS_BATCH_SIZE } from "#policy/redis/redisClient.js";
19
- import { type AccessRule, GLOBAL_CLIENT_SCOPE_SENTINEL } from "#policy/rule.js";
20
- import type {
21
- AccessRuleEntry,
22
- AccessRulesWriter,
23
- } from "#policy/rulesStorage.js";
24
- import {
25
- ACCESS_RULE_REDIS_KEY_PREFIX,
26
- getAccessRuleRedisKey,
27
- } from "./redisRuleIndex.js";
28
-
29
- export class RedisRulesWriter implements AccessRulesWriter {
30
- constructor(
31
- private readonly client: RedisClientType,
32
- private readonly logger: Logger,
33
- ) {}
34
-
35
- async insertRules(ruleEntries: AccessRuleEntry[]): Promise<string[]> {
36
- const entryBatches = chunkIntoBatches(ruleEntries, REDIS_BATCH_SIZE);
37
-
38
- const keyBatches = await executeBatchesSequentially(
39
- entryBatches,
40
- async (entriesBatch) => this.insertRuleEntries(entriesBatch),
41
- );
42
-
43
- return keyBatches.flatMap((ruleKey) =>
44
- ruleKey.slice(ACCESS_RULE_REDIS_KEY_PREFIX.length),
45
- );
46
- }
47
-
48
- async deleteRules(ruleIds: string[]): Promise<void> {
49
- const ruleKeys = ruleIds.map(
50
- (ruleId) => ACCESS_RULE_REDIS_KEY_PREFIX + ruleId,
51
- );
52
-
53
- const keyBatches = chunkIntoBatches(ruleKeys, REDIS_BATCH_SIZE);
54
-
55
- await executeBatchesSequentially(keyBatches, async (keysBatch) => {
56
- const queries = this.client.multi();
57
-
58
- for (const ruleKey of keysBatch) {
59
- queries.del(ruleKey);
60
- }
61
-
62
- await queries.exec();
63
- });
64
- }
65
-
66
- async deleteAllRules(): Promise<number> {
67
- let cursor = "0";
68
- let total = 0;
69
-
70
- do {
71
- const reply = await this.client.scan(cursor, {
72
- MATCH: `${ACCESS_RULE_REDIS_KEY_PREFIX}*`,
73
- COUNT: REDIS_BATCH_SIZE,
74
- });
75
-
76
- const ids = reply.keys.map((key) =>
77
- key.slice(ACCESS_RULE_REDIS_KEY_PREFIX.length),
78
- );
79
- await this.deleteRules(ids);
80
-
81
- total += ids.length;
82
- cursor = reply.cursor;
83
- } while ("0" !== cursor);
84
-
85
- return total;
86
- }
87
-
88
- protected async insertRuleEntries(
89
- ruleEntries: AccessRuleEntry[],
90
- ): Promise<string[]> {
91
- const queries = this.client.multi();
92
-
93
- const ruleKeys = ruleEntries.map((ruleEntry) => {
94
- const { rule, expiresUnixTimestamp } = ruleEntry;
95
-
96
- const ruleKey = getAccessRuleRedisKey(rule);
97
- const ruleValue = getRedisRuleValue(rule);
98
-
99
- queries.hSet(ruleKey, ruleValue);
100
-
101
- if (expiresUnixTimestamp) {
102
- // Redis expireAt expects seconds. Validate that timestamp is in seconds, not milliseconds.
103
- // Unix timestamps in milliseconds (since 1970) are > 10 billion
104
- // Unix timestamps in seconds won't reach 10 billion until year 2286
105
- const MILLISECOND_THRESHOLD = 10_000_000_000;
106
- if (expiresUnixTimestamp > MILLISECOND_THRESHOLD) {
107
- throw new Error(
108
- `Invalid expiry timestamp: ${expiresUnixTimestamp}. Timestamp must be in seconds, not milliseconds.`,
109
- );
110
- }
111
- queries.expireAt(ruleKey, expiresUnixTimestamp);
112
- }
113
-
114
- return ruleKey;
115
- });
116
-
117
- await queries.exec();
118
-
119
- return ruleKeys;
120
- }
121
- }
122
-
123
- export const getRedisRuleValue = (rule: AccessRule): Record<string, string> => {
124
- const record: Record<string, string> = {};
125
- for (const [key, value] of Object.entries(rule)) {
126
- if (value === undefined) {
127
- continue;
128
- }
129
- record[key] = String(value);
130
- }
131
- // Global rules used to be stored with no clientId field at all and looked
132
- // up via `ismissing(@clientId)`. Stamp the sentinel so the read path can
133
- // probe `@clientId:{global}` instead — a posting-list intersection is
134
- // orders of magnitude cheaper than ismissing over a large rule set.
135
- // Rules with a real clientId are untouched.
136
- if (record.clientId === undefined) {
137
- record.clientId = GLOBAL_CLIENT_SCOPE_SENTINEL;
138
- }
139
- return record;
140
- };
141
-
142
- export class DummyRedisRulesWriter implements AccessRulesWriter {
143
- constructor(private readonly logger: Logger) {}
144
-
145
- async insertRules(ruleEntries: AccessRuleEntry[]): Promise<string[]> {
146
- this.logger.info(() => ({
147
- msg: "Dummy insertRules() has no effect (redis is not ready)",
148
- data: {
149
- ruleEntries,
150
- },
151
- }));
152
-
153
- return [];
154
- }
155
-
156
- async deleteRules(ruleIds: string[]): Promise<void> {
157
- this.logger.info(() => ({
158
- msg: "Dummy deleteRules() has no effect (redis is not ready)",
159
- data: {
160
- ruleIds,
161
- },
162
- }));
163
- }
164
-
165
- async deleteAllRules(): Promise<number> {
166
- this.logger.info(() => ({
167
- msg: "Dummy deleteAllRules() has no effect (redis is not ready)",
168
- }));
169
-
170
- return 0;
171
- }
172
- }
package/src/rule.ts DELETED
@@ -1,122 +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
- import type { CaptchaType, ResultReason } from "@prosopo/types";
15
-
16
- export enum AccessPolicyType {
17
- Block = "block",
18
- Restrict = "restrict",
19
- // Explicit allow-list: request that matches an Allow rule bypasses the
20
- // challenge flow and gets an `authenticated` session (captchaType =
21
- // authenticated on the frictionless response). Fires for any qualifying
22
- // scope — verified Web Bot Auth agent, allow-listed IP, ja4/ja4_and_ip
23
- // match, UA substring, ASN, country. IP-binding on the resulting
24
- // session token defends against replay from a different IP. Opt-in per
25
- // rule; the operator authors an Allow rule the same way they'd author
26
- // a Block or Restrict rule.
27
- Allow = "allow",
28
- }
29
-
30
- // Sentinel stamped on the Redis `clientId` field for rules that would
31
- // otherwise store undefined (i.e. global rules). Lets the read path
32
- // probe `@clientId:{global}` via the posting-list index instead of the
33
- // expensive `ismissing(@clientId)` set-difference walk that degrades
34
- // sharply once the global rule set grows into the 10k+ range. The
35
- // reader converts it back to `undefined` at parse time so downstream
36
- // code sees the original AccessRule shape. Kept in this types-only
37
- // module so both the writer/index side and the parser/input side can
38
- // import it without pulling in a circular dependency through
39
- // transformRule.
40
- export const GLOBAL_CLIENT_SCOPE_SENTINEL = "global";
41
-
42
- export type AccessPolicy = {
43
- type: AccessPolicyType;
44
- captchaType?: CaptchaType;
45
- description?: string;
46
- solvedImagesCount?: number;
47
- imageThreshold?: number;
48
- powDifficulty?: number;
49
- unsolvedImagesCount?: number;
50
- frictionlessScore?: number;
51
- // When true, a Block policy does NOT fire at the request-time
52
- // blockMiddleware (so the user does not see a 401 on the captcha
53
- // challenge endpoint) — it fires at the verify step instead, marking
54
- // the commitment ACCESS_POLICY_BLOCK / disapproved. The verify
55
- // response returns `{verified:false}` to the dApp's server while the
56
- // user-facing widget completes normally. Mirrors the existing
57
- // coords-rule deferral pattern: the middleware blanks coords out of
58
- // the userScope, so coords rules can only ever be matched in the
59
- // verify path; `deferToVerify` is the explicit form for non-coords
60
- // signals (ja4, headersHash, etc.) when the operator wants the
61
- // attacker to pay the captcha-solving cost before being rejected.
62
- deferToVerify?: boolean;
63
- // Replaces "Forbidden" in the 403 body of a matched Block policy.
64
- messageKey?: ResultReason;
65
- };
66
-
67
- export type PolicyScope = {
68
- clientId?: string;
69
- };
70
-
71
- export type UserIp = {
72
- numericIp?: bigint;
73
- numericIpMaskMin?: bigint;
74
- numericIpMaskMax?: bigint;
75
- };
76
-
77
- export type UserAttributes = {
78
- userId?: string;
79
- ja4Hash?: string;
80
- headersHash?: string;
81
- userAgentHash?: string;
82
- headHash?: string;
83
- coords?: string;
84
- countryCode?: string;
85
- asn?: number;
86
- // Operating system classified server-side from the request User-Agent (see
87
- // `classifyOs`). Stored as a plain lowercase tag — one of `OsName` — and
88
- // matched like `countryCode` (exact equality, not hashed). Lets a rule
89
- // drop/limit requests from a given OS even when the client omits client
90
- // hints.
91
- os?: string;
92
- browser?: string;
93
- // Arbitrary-header matching (see `headerMatch.ts` and the portal Header
94
- // Restriction card). A header rule carries `headerName` + `headerValue` +
95
- // `headerOperator`; unlike the other dimensions these are checked in code
96
- // against the raw request headers — Redis TAG can't express substring
97
- // `contains` or per-rule operators — so they are not part of the Redis
98
- // matching query. `headerMatch` is the indexed sentinel (always
99
- // `HEADER_RULE_MARKER`) that makes such a rule a matching candidate for
100
- // every request, so an allow-list rule still fires on a request that omits
101
- // the target header.
102
- headerMatch?: string;
103
- headerName?: string;
104
- headerValue?: string;
105
- headerOperator?: string;
106
- // Canonical Web Bot Auth Signature-Agent URL — e.g.
107
- // "https://signatures.openai.com/". Matched at runtime against the
108
- // `Signature-Agent` header after RFC 9421 Ed25519 verification succeeds.
109
- // Stored as-is like `countryCode` (exact equality after normalisation).
110
- // Rules with this field only match requests carrying a valid Web Bot
111
- // Auth signature; unverified traffic falls through.
112
- webBotAuthAgent?: string;
113
- };
114
-
115
- export type UserScope = UserAttributes & UserIp;
116
-
117
- // flat structure is used to fit the Redis requirements
118
- export type AccessRule = AccessPolicy &
119
- PolicyScope &
120
- UserScope & {
121
- groupId?: string;
122
- };
@@ -1,19 +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
- export { accessRuleInput, type AccessRulesFilterInput } from "./ruleInput.js";
16
-
17
- export { accessPolicyInput, policyScopeInput } from "./policyInput.js";
18
-
19
- export { userScopeInput } from "./userScopeInput.js";
@@ -1,120 +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 { AllKeys } from "@prosopo/common";
16
- import { CaptchaTypeSchema, ResultReason } from "@prosopo/types";
17
- import { z } from "zod";
18
- import {
19
- type AccessPolicy,
20
- AccessPolicyType,
21
- GLOBAL_CLIENT_SCOPE_SENTINEL,
22
- type PolicyScope,
23
- } from "#policy/rule.js";
24
-
25
- // `satisfies ZodType<AccessPolicy>` is intentionally omitted: the
26
- // `deferToVerify` preprocess widens the schema's input type to `unknown`
27
- // (preprocess accepts anything), which fails the
28
- // `ZodType<T, ZodTypeDef, T>` identity check. The `AllKeys<AccessPolicy>`
29
- // constraint still catches missing-field regressions.
30
- //
31
- // The `superRefine` rejects Block+captchaType and Block+solvedImagesCount
32
- // at write time. Those fields are meaningless on a Block policy — the
33
- // sanitizer used to silently strip them, which meant operators writing
34
- // e.g. "block image captchas for this IP" got a rule that actually
35
- // blocked EVERY captcha type. The bug in #2885 (Block+deferToVerify 400
36
- // on every /captcha/*) was a knock-on of the same silent-strip: the
37
- // captchaType-equality check on the read side compared `undefined` to the
38
- // request's captchaType and rejected. Rejecting at input surfaces the
39
- // mismatch loudly so operators can adjust their intent (they usually
40
- // wanted a Restrict) rather than persisting a rule that doesn't match
41
- // what they typed.
42
- // Base ZodObject shape — reused via `.shape` by `ruleInput` and
43
- // `transformRule` (both compose accessPolicyInput's fields with policy /
44
- // user scope fields via object spread). Split from `accessPolicyInput`
45
- // below because that one applies a `.superRefine` and returns a
46
- // `ZodEffects`, which loses `.shape`.
47
- export const accessPolicyInputShape = z.object({
48
- type: z.nativeEnum(AccessPolicyType),
49
- captchaType: CaptchaTypeSchema.optional(),
50
- description: z.coerce.string().optional(),
51
- // Redis stores values as strings, so coerce is needed to parse properly
52
- solvedImagesCount: z.coerce.number().optional(),
53
- // the percentage of image panels that must be solved per image CAPTCHA
54
- imageThreshold: z.coerce.number().optional(),
55
- // the Proof-of-Work difficulty level
56
- powDifficulty: z.coerce.number().optional(),
57
- // the number of unsolved image CAPTCHA challenges to serve
58
- unsolvedImagesCount: z.coerce.number().optional(),
59
- // used to increase the user's score
60
- frictionlessScore: z.coerce.number().optional(),
61
- // Skip the request-time block middleware and only fire at verify.
62
- // Redis stores booleans as strings — preprocess so "true"/"false"
63
- // round-trip to the JS boolean the matcher expects.
64
- deferToVerify: z
65
- .preprocess((v) => (typeof v === "string" ? v === "true" : v), z.boolean())
66
- .optional(),
67
- messageKey: z.nativeEnum(ResultReason).optional(),
68
- } satisfies AllKeys<AccessPolicy>);
69
-
70
- export const accessPolicyInput = accessPolicyInputShape.superRefine(
71
- (policy, ctx) => {
72
- if (policy.type !== AccessPolicyType.Block) return;
73
- if (policy.captchaType !== undefined) {
74
- ctx.addIssue({
75
- code: z.ZodIssueCode.custom,
76
- path: ["captchaType"],
77
- message:
78
- "Block policies cannot pin a captchaType — Block always applies to every captcha type. Use a Restrict policy if you want to narrow the effect to one captcha type.",
79
- });
80
- }
81
- if (policy.solvedImagesCount !== undefined) {
82
- ctx.addIssue({
83
- code: z.ZodIssueCode.custom,
84
- path: ["solvedImagesCount"],
85
- message:
86
- "Block policies cannot set solvedImagesCount — the field only applies to Restrict policies that adjust image-captcha rounds.",
87
- });
88
- }
89
- },
90
- );
91
-
92
- // Historical helper: strip fields the sanitizer used to remove silently.
93
- // Kept as a no-op-in-practice safety net because Redis records written
94
- // before the superRefine on `accessPolicyInput` (above) may still carry
95
- // the stripped fields — reading them back should still normalize to the
96
- // canonical shape. New writes are rejected at input.
97
- export const sanitizeAccessPolicy = (policy: AccessPolicy): AccessPolicy => {
98
- if (policy.type === AccessPolicyType.Block) {
99
- const { captchaType, solvedImagesCount, ...blockPolicy } = policy;
100
- return blockPolicy;
101
- }
102
- return policy;
103
- };
104
-
105
- // `satisfies ZodType<PolicyScope>` is intentionally omitted (matches
106
- // accessPolicyInput above): the `preprocess` wrapper widens the schema's
107
- // input type to `unknown`, which fails the `ZodType<T, ZodTypeDef, T>`
108
- // identity check. The `AllKeys<PolicyScope>` constraint still catches
109
- // missing-field regressions.
110
- export const policyScopeInput = z.object({
111
- // `getRedisRuleValue` stamps a sentinel string on global rules so the
112
- // read-time query can probe `@clientId:{global}` cheaply. Undo the
113
- // stamp here so consumers (rankCandidateRules, response payloads,
114
- // tests) continue to see `undefined` for global rules — the mongoose
115
- // side and the API input side never emit the sentinel.
116
- clientId: z.preprocess(
117
- (v) => (v === GLOBAL_CLIENT_SCOPE_SENTINEL ? undefined : v),
118
- z.coerce.string().optional(),
119
- ),
120
- } satisfies AllKeys<PolicyScope>);
@@ -1,123 +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 { AllKeys } from "@prosopo/common";
16
- import { type ZodType, z } from "zod";
17
- import type { AccessPolicy, AccessRule, PolicyScope } from "#policy/rule.js";
18
- import {
19
- type AccessRuleEntry,
20
- type AccessRulesFilter,
21
- FilterScopeMatch,
22
- } from "#policy/rulesStorage.js";
23
- import { accessPolicyInputShape, policyScopeInput } from "./policyInput.js";
24
- import { type UserScopeInput, userScopeInput } from "./userScopeInput.js";
25
-
26
- type RuleGroupInput = {
27
- groupId?: string;
28
- ruleGroupId?: string;
29
- };
30
-
31
- export type AccessRuleInput = AccessPolicy &
32
- PolicyScope &
33
- UserScopeInput &
34
- RuleGroupInput;
35
-
36
- const ruleGroupInput = z
37
- .object({
38
- groupId: z.coerce.string().optional(),
39
- ruleGroupId: z.coerce.string().optional(),
40
- } satisfies AllKeys<RuleGroupInput>)
41
- .transform((ruleGroupInput: RuleGroupInput) => {
42
- const { ruleGroupId, ...ruleGroup } = ruleGroupInput;
43
-
44
- if ("string" === typeof ruleGroupId) {
45
- ruleGroup.groupId = ruleGroupId;
46
- }
47
-
48
- return ruleGroup;
49
- });
50
-
51
- // Explicit `ZodType<…, ZodTypeDef, unknown>` annotation rather than the
52
- // strict-identity form because `accessPolicyInputShape.shape.deferToVerify`
53
- // uses `z.preprocess` which widens the input position to `unknown`. The
54
- // relaxed annotation is portable for declaration emit; the `transform`
55
- // pins the OUTPUT to AccessRule.
56
- //
57
- // Uses the unrefined `accessPolicyInputShape` (not `accessPolicyInput`)
58
- // because the API-write refinement rejecting Block+captchaType is a
59
- // write-time input guard — the READ path (Redis reader → accessRuleInput
60
- // parse) must still accept the legacy shapes for records written before
61
- // the refinement landed. Otherwise the reader would throw on every
62
- // pre-existing Block rule that carried a stripped captchaType field.
63
- export const accessRuleInput: ZodType<AccessRule, z.ZodTypeDef, unknown> = z
64
- .object({
65
- ...accessPolicyInputShape.shape,
66
- ...policyScopeInput.shape,
67
- })
68
- .and(userScopeInput)
69
- .and(ruleGroupInput)
70
- .transform((ruleInput: AccessRuleInput): AccessRule => ruleInput);
71
-
72
- export const ruleEntryInput: ZodType<AccessRuleEntry, z.ZodTypeDef, unknown> =
73
- z.object({
74
- rule: accessRuleInput,
75
- expiresUnixTimestamp: z.coerce.number().optional(),
76
- } satisfies AllKeys<AccessRuleEntry>);
77
-
78
- export type AccessRulesFilterInput = AccessRulesFilter & {
79
- userScope?: UserScopeInput;
80
- policyScopes?: PolicyScope[];
81
- };
82
-
83
- // `satisfies ZodType<AccessRulesFilterInput>` is intentionally omitted:
84
- // `policyScopeInput.clientId` uses `z.preprocess` to unwrap the Redis
85
- // `global` sentinel, which widens the input type to `unknown`. The
86
- // output type is still `AccessRulesFilterInput` (Zod's `_output`); the
87
- // downstream `DeleteRulesSchema` / `FindRulesSchema` use the relaxed
88
- // `ZodType<T, ZodTypeDef, unknown>` form for the same reason.
89
- export const accessRulesFilterInput = z.object({
90
- policyScope: policyScopeInput.optional(),
91
- policyScopes: z.array(policyScopeInput).optional(),
92
- policyScopeMatch: z
93
- .nativeEnum(FilterScopeMatch)
94
- .default(FilterScopeMatch.Exact),
95
- userScope: userScopeInput.optional(),
96
- userScopeMatch: z
97
- .nativeEnum(FilterScopeMatch)
98
- .default(FilterScopeMatch.Exact),
99
- groupId: z.string().optional(),
100
- blockOnly: z.boolean().optional(),
101
- includeDeferred: z.boolean().optional(),
102
- } satisfies AllKeys<AccessRulesFilterInput>);
103
-
104
- export const getAccessRuleFiltersFromInput = (
105
- filterInput: AccessRulesFilterInput,
106
- ): AccessRulesFilter[] => {
107
- const { policyScopes, policyScope, ...filterBase } = filterInput;
108
-
109
- const allPolicyScopes = policyScopes || [];
110
-
111
- if (policyScope) {
112
- allPolicyScopes.push(policyScope);
113
- }
114
-
115
- if (allPolicyScopes.length > 0) {
116
- return allPolicyScopes.map((policyScope) => ({
117
- ...filterBase,
118
- policyScope,
119
- }));
120
- }
121
-
122
- return [filterBase];
123
- };