cabloy 5.1.238 → 5.1.240
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/.cabloy-version +1 -1
- package/CHANGELOG.md +14 -0
- package/package.json +1 -1
- package/repo-docs/backend/rate-limit-guide.md +54 -9
- package/vona/packages-vona/vona/package.json +1 -1
- package/vona/patches/{zova-core@5.1.94.patch → zova-core@5.1.95.patch} +2 -2
- package/vona/pnpm-lock.yaml +146 -21
- package/vona/pnpm-workspace.yaml +1 -1
- package/vona/src/suite-vendor/a-vona/modules/a-hmr/package.json +1 -1
- package/vona/src/suite-vendor/a-vona/modules/a-hmr/src/service/hmr.ts +5 -12
- package/vona/src/suite-vendor/a-vona/modules/a-ratelimit/package.json +1 -1
- package/vona/src/suite-vendor/a-vona/modules/a-ratelimit/src/.metadata/index.ts +26 -0
- package/vona/src/suite-vendor/a-vona/modules/a-ratelimit/src/bean/middlewareSystem.aggregate.ts +58 -0
- package/vona/src/suite-vendor/a-vona/modules/a-ratelimit/src/service/rateLimit.ts +25 -5
- package/vona/src/suite-vendor/a-vona/modules/a-ratelimit/src/types/rateLimit.ts +5 -0
- package/vona/src/suite-vendor/a-vona/modules/a-ratelimit/test/aggregate.test.ts +247 -0
- package/vona/src/suite-vendor/a-vona/package.json +1 -1
package/.cabloy-version
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
5.1.
|
|
1
|
+
5.1.240
|
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,19 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 5.1.240
|
|
4
|
+
|
|
5
|
+
### Improvements
|
|
6
|
+
|
|
7
|
+
- Enhance rate limiting.
|
|
8
|
+
- Update hot module replacement (HMR) handling.
|
|
9
|
+
|
|
10
|
+
## 5.1.239
|
|
11
|
+
|
|
12
|
+
### Improvements
|
|
13
|
+
|
|
14
|
+
- Normalize release workspace formatting.
|
|
15
|
+
- Refresh the Vona zova-core patch for v5.1.95.
|
|
16
|
+
|
|
3
17
|
## 5.1.238
|
|
4
18
|
|
|
5
19
|
No user-facing changes.
|
package/package.json
CHANGED
|
@@ -2,11 +2,56 @@
|
|
|
2
2
|
|
|
3
3
|
## Overview
|
|
4
4
|
|
|
5
|
-
`a-ratelimit`
|
|
5
|
+
`a-ratelimit` provides two independent admission controls:
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
- The identity interceptor, `a-ratelimit:rateLimit`, limits matched Vona HTTP API actions across all application workers. It runs after Passport has resolved an authenticated or anonymous subject, and before request-body parsing, pipes, local middleware, and controller code.
|
|
8
|
+
- The aggregate system middleware, `a-ratelimit:aggregate`, limits incoming HTTP requests across the application before the application/instance readiness middleware and routing, but after the HTTP executor's initial instance lookup. It is disabled by default and requires explicit configuration.
|
|
8
9
|
|
|
9
|
-
|
|
10
|
+
The identity interceptor protects controller APIs only; its route matching and instance isolation do not describe the aggregate middleware. Keep volumetric and network-edge protection in the reverse proxy, WAF, or gateway even when aggregate admission is enabled. See [Controller AOP Guide](/backend/controller-aop-guide) for the general request-stage diagram and the distinction between a local aspect and a route override of an existing global onion.
|
|
11
|
+
|
|
12
|
+
The aggregate middleware documented here is a Cabloy Basic runtime addition. Cabloy Start is maintained separately and does not automatically receive this runtime change; shared documentation does not imply aggregate middleware availability in Start.
|
|
13
|
+
|
|
14
|
+
## Aggregate application admission
|
|
15
|
+
|
|
16
|
+
Enable the system middleware through application configuration, starting in observe mode:
|
|
17
|
+
|
|
18
|
+
```ts
|
|
19
|
+
config.onions.middlewareSystem['a-ratelimit:aggregate'] = {
|
|
20
|
+
enable: true,
|
|
21
|
+
rateLimit: {
|
|
22
|
+
mode: 'observe',
|
|
23
|
+
limit: 100,
|
|
24
|
+
},
|
|
25
|
+
};
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
`100` is an example limit, not a measured application capacity or a recommended production value. An enabled aggregate middleware requires an explicit valid `limit` and a `mode` of either `'observe'` or `'enforce'`; there is no enabled default quota. Review observed traffic and Redis health before changing `mode` to `'enforce'`.
|
|
29
|
+
|
|
30
|
+
### Coverage and ordering
|
|
31
|
+
|
|
32
|
+
The aggregate middleware runs after `httpLog` and before health handling, the application/instance readiness middleware, routing, and authentication. The HTTP listener enters the executor first: it resolves the instance name and, when applicable, loads the instance record before dispatching system middleware. Aggregate admission does not protect that initial lookup or any instance registration it triggers, and requests that fail before middleware dispatch are not counted.
|
|
33
|
+
|
|
34
|
+
By default, aggregate admission covers all requests that reach the system middleware chain, including API, SSR, static-file, preflight, health, and unknown-route requests. Standard system middleware `match` and `ignore` options can narrow this coverage; they match the incoming `ctx.path`, including the API prefix where present, before routing. Traffic served entirely by a proxy or CDN never reaches this counter. Other programs, background work, and later WebSocket messages are outside its coverage.
|
|
35
|
+
|
|
36
|
+
Aggregate admission is independent of the identity interceptor. Both can be enabled, and a controller/action identity policy with `enable: false` does not bypass aggregate admission. There are no built-in aggregate path exemptions: unless explicitly excluded through system middleware matching, saturation can reject health probes and control requests before their handlers run. Account for this operational consequence before enforcing the quota.
|
|
37
|
+
|
|
38
|
+
### Shared counter and fixed windows
|
|
39
|
+
|
|
40
|
+
The aggregate counter uses the `limiter` Redis client and the application Redis prefix. It is shared across users, routes, instances, and workers rather than partitioned by identity or active instance. Multiple hosts using the same prefix and Redis share the quota; this is not physical-host discovery or an automatic per-host capacity limit. Keep custom application prefixes unique for applications or environments that require independent quotas.
|
|
41
|
+
|
|
42
|
+
The window is fixed at `1000` ms and aligned to one-second boundaries. Attempts count toward the window, including requests already rejected for exceeding the limit. As with other fixed-window counters, traffic can burst around a window boundary; the limit is not a rolling-window guarantee.
|
|
43
|
+
|
|
44
|
+
### Modes, responses, and logs
|
|
45
|
+
|
|
46
|
+
In `observe` mode, requests over the limit log `rate_limit.would_reject` and still continue to the next middleware. Redis failures log `rate_limit.redis_error` and fail open, allowing the request to continue.
|
|
47
|
+
|
|
48
|
+
In `enforce` mode, an over-limit request logs `rate_limit.rejected` and returns HTTP `429` with `Retry-After`. A Redis failure logs `rate_limit.redis_error` and returns HTTP `503` instead of allowing unbounded admission. Both responses are direct JSON bodies with the shape `{ code, message }`, even for requests expecting HTML; they do not enter the normal page/error rendering path.
|
|
49
|
+
|
|
50
|
+
Aggregate admission does not add success `RateLimit-*` headers. The identity interceptor's response headers remain unchanged when a request reaches that interceptor.
|
|
51
|
+
|
|
52
|
+
Aggregate logs use the same event names as the identity interceptor, with `scope: 'aggregate'`. Redis failures expose only a sanitized error class, not a raw error message. Do not include raw identities, routes, full URLs, or Redis keys in aggregate logs or metric labels.
|
|
53
|
+
|
|
54
|
+
## Identity interceptor enablement and rollout
|
|
10
55
|
|
|
11
56
|
The interceptor ships with `enable: false` to preserve compatibility. Enable the global interceptor through standard onion configuration and begin in observe mode:
|
|
12
57
|
|
|
@@ -36,9 +81,9 @@ config.onions.interceptor['a-ratelimit:rateLimit'] = {
|
|
|
36
81
|
};
|
|
37
82
|
```
|
|
38
83
|
|
|
39
|
-
Use instance configuration when tenants require different policy values. Each
|
|
84
|
+
Use instance configuration when tenants require different identity policy values. Each identity interceptor key includes the active instance, so one instance never consumes another instance’s identity quota. The aggregate middleware instead shares one application-prefix quota across instances.
|
|
40
85
|
|
|
41
|
-
##
|
|
86
|
+
## Identity route policies
|
|
42
87
|
|
|
43
88
|
Use `Core.rateLimit(...)` on a controller for its baseline policy, or on an action for a stricter policy, an intentional shared bucket, or an explicit exemption:
|
|
44
89
|
|
|
@@ -64,7 +109,7 @@ async requestReset() {}
|
|
|
64
109
|
health() {}
|
|
65
110
|
```
|
|
66
111
|
|
|
67
|
-
`enable`, matching, and ordering remain outer interceptor options. The nested `rateLimit` object contains the quota policy, so partial controller/action overrides inherit the remaining policy fields through normal Vona onion merging. Interceptor options use normal Vona precedence: application config, active-instance config, controller/action decorators, then controlled dynamic onion overrides used by tests. A controller/action can enable a narrower policy while the global interceptor is disabled, or set `enable: false` as a reviewed exemption after global activation.
|
|
112
|
+
`enable`, matching, and ordering remain outer interceptor options. The nested `rateLimit` object contains the quota policy, so partial controller/action overrides inherit the remaining policy fields through normal Vona onion merging. Interceptor options use normal Vona precedence: application config, active-instance config, controller/action decorators, then controlled dynamic onion overrides used by tests. A controller/action can enable a narrower policy while the global interceptor is disabled, or set `enable: false` as a reviewed exemption after global activation. These identity policy fields belong to `a-ratelimit:rateLimit` at the post-Passport interceptor stage, not to another middleware. The aggregate system middleware has its own independent `rateLimit` configuration described above.
|
|
68
113
|
|
|
69
114
|
## Identity and Redis storage
|
|
70
115
|
|
|
@@ -72,11 +117,11 @@ The default `key: 'identity'` combines the proxy-trusted `ctx.ip` with the authe
|
|
|
72
117
|
|
|
73
118
|
Do not parse `X-Forwarded-For` in application code. Vona resolves `ctx.ip` according to its configured proxy trust settings. The trusted reverse proxy must replace client-supplied forwarding headers before forwarding a request.
|
|
74
119
|
|
|
75
|
-
The
|
|
120
|
+
The identity interceptor always uses the dedicated `limiter` Redis client; this infrastructure choice is not a route-policy field. It registers one atomic Lua fixed-window counter command during module startup and invokes that command for each evaluated request. Identity Redis keys contain the instance, policy, normalized route, window start, and a SHA-256 digest of the identity; raw IP addresses and subject IDs are not stored in key text.
|
|
76
121
|
|
|
77
122
|
Fixed windows provide one low-cost atomic operation per request. A limit can burst around a window boundary; choose lower limits or shorter windows for especially sensitive actions.
|
|
78
123
|
|
|
79
|
-
## HTTP response contract
|
|
124
|
+
## Identity HTTP response contract
|
|
80
125
|
|
|
81
126
|
In enforce mode, evaluated requests expose RFC 9333 fields when `headers` is enabled:
|
|
82
127
|
|
|
@@ -90,7 +135,7 @@ If the limiter Redis operation fails during enforce mode, Cabloy returns `503`,
|
|
|
90
135
|
|
|
91
136
|
## Operations
|
|
92
137
|
|
|
93
|
-
The primary structured events are `rate_limit.would_reject`, `rate_limit.rejected`, and `rate_limit.redis_error`.
|
|
138
|
+
The primary structured events are `rate_limit.would_reject`, `rate_limit.rejected`, and `rate_limit.redis_error`. Identity interceptor events contain only route/policy/instance and error-class information. Aggregate events carry `scope: 'aggregate'` and omit route/identity detail, with only a sanitized error class for Redis failures. Do not add raw identities, full URLs, or Redis keys to logs or metric labels.
|
|
94
139
|
|
|
95
140
|
The `limiter` client may initially use the same Redis deployment as other Vona clients, but it is separately configured so production can isolate its endpoint, timeout, and capacity. Ensure Redis remains available before globally setting `enable: true` with `rateLimit.mode: 'enforce'`.
|
|
96
141
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "vona",
|
|
3
|
-
"version": "5.1.
|
|
3
|
+
"version": "5.1.119",
|
|
4
4
|
"gitHead": "a79189b882c17af5911573896a781bbb0046d37d",
|
|
5
5
|
"description": "Vona is an intuitive, elegant and powerful Node.js framework for rapidly developing enterprise applications of any size",
|
|
6
6
|
"keywords": [
|
|
@@ -14,10 +14,10 @@ index 73cebe18f3e260c295bdf42a3d1ab9e86fa2de87..cb0ff5c3b541f646105198ee23ac0fc3
|
|
|
14
14
|
-//# sourceMappingURL=errorGlobal.d.ts.map
|
|
15
15
|
|
|
16
16
|
diff --git a/dist/types/interface/module.d.ts b/dist/types/interface/module.d.ts
|
|
17
|
-
index
|
|
17
|
+
index cfcfdada3ef32dd555da6ab956aef8b6046c6d86..98834454d1a29dd314732b37b210283fc63c37f2 100644
|
|
18
18
|
--- a/dist/types/interface/module.d.ts
|
|
19
19
|
+++ b/dist/types/interface/module.d.ts
|
|
20
|
-
@@ -
|
|
20
|
+
@@ -24,12 +24,6 @@ export interface IModuleResource {
|
|
21
21
|
components: TypeModuleResourceComponents;
|
|
22
22
|
}
|
|
23
23
|
export declare const SymbolInstalled: unique symbol;
|
package/vona/pnpm-lock.yaml
CHANGED
|
@@ -174,7 +174,7 @@ overrides:
|
|
|
174
174
|
zod: npm:@cabloy/zod@4.3.8
|
|
175
175
|
|
|
176
176
|
patchedDependencies:
|
|
177
|
-
zova-core@5.1.
|
|
177
|
+
zova-core@5.1.95: 44197c3f359a73a1a5bb3dc0a8a97e705a55b30f9a434717aa24d75a96f2c6b6
|
|
178
178
|
|
|
179
179
|
importers:
|
|
180
180
|
|
|
@@ -737,11 +737,11 @@ importers:
|
|
|
737
737
|
specifier: npm:@cabloy/zod@4.3.8
|
|
738
738
|
version: '@cabloy/zod@4.3.8'
|
|
739
739
|
zova:
|
|
740
|
-
specifier: ^5.1.
|
|
741
|
-
version: 5.1.
|
|
740
|
+
specifier: ^5.1.177
|
|
741
|
+
version: 5.1.177(debug@4.4.3(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.9.3)(vue@3.5.43(typescript@5.9.3))
|
|
742
742
|
zova-jsx:
|
|
743
|
-
specifier: ^1.1.
|
|
744
|
-
version: 1.1.
|
|
743
|
+
specifier: ^1.1.95
|
|
744
|
+
version: 1.1.95(typescript@5.9.3)
|
|
745
745
|
zova-module-a-api:
|
|
746
746
|
specifier: ^5.1.23
|
|
747
747
|
version: 5.1.23
|
|
@@ -803,8 +803,8 @@ importers:
|
|
|
803
803
|
specifier: ^5.1.36
|
|
804
804
|
version: 5.1.36
|
|
805
805
|
zova-module-a-ssr:
|
|
806
|
-
specifier: ^5.1.
|
|
807
|
-
version: 5.1.
|
|
806
|
+
specifier: ^5.1.42
|
|
807
|
+
version: 5.1.42
|
|
808
808
|
zova-module-a-ssrhmr:
|
|
809
809
|
specifier: ^5.1.24
|
|
810
810
|
version: 5.1.24
|
|
@@ -821,8 +821,8 @@ importers:
|
|
|
821
821
|
specifier: ^5.1.39
|
|
822
822
|
version: 5.1.39
|
|
823
823
|
zova-module-a-zova:
|
|
824
|
-
specifier: ^5.1.
|
|
825
|
-
version: 5.1.
|
|
824
|
+
specifier: ^5.1.114
|
|
825
|
+
version: 5.1.114(typescript@5.9.3)(vue@3.5.43(typescript@5.9.3))
|
|
826
826
|
zova-module-pay-mock:
|
|
827
827
|
specifier: ^5.0.6
|
|
828
828
|
version: 5.0.6
|
|
@@ -866,11 +866,11 @@ importers:
|
|
|
866
866
|
specifier: npm:@cabloy/zod@4.3.8
|
|
867
867
|
version: '@cabloy/zod@4.3.8'
|
|
868
868
|
zova:
|
|
869
|
-
specifier: ^5.1.
|
|
870
|
-
version: 5.1.
|
|
869
|
+
specifier: ^5.1.177
|
|
870
|
+
version: 5.1.177(debug@4.4.3(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.9.3)(vue@3.5.43(typescript@5.9.3))
|
|
871
871
|
zova-jsx:
|
|
872
|
-
specifier: ^1.1.
|
|
873
|
-
version: 1.1.
|
|
872
|
+
specifier: ^1.1.95
|
|
873
|
+
version: 1.1.95(typescript@5.9.3)
|
|
874
874
|
zova-module-a-api:
|
|
875
875
|
specifier: ^5.1.23
|
|
876
876
|
version: 5.1.23
|
|
@@ -932,8 +932,8 @@ importers:
|
|
|
932
932
|
specifier: ^5.1.36
|
|
933
933
|
version: 5.1.36
|
|
934
934
|
zova-module-a-ssr:
|
|
935
|
-
specifier: ^5.1.
|
|
936
|
-
version: 5.1.
|
|
935
|
+
specifier: ^5.1.42
|
|
936
|
+
version: 5.1.42
|
|
937
937
|
zova-module-a-ssrhmr:
|
|
938
938
|
specifier: ^5.1.24
|
|
939
939
|
version: 5.1.24
|
|
@@ -950,8 +950,8 @@ importers:
|
|
|
950
950
|
specifier: ^5.1.39
|
|
951
951
|
version: 5.1.39
|
|
952
952
|
zova-module-a-zova:
|
|
953
|
-
specifier: ^5.1.
|
|
954
|
-
version: 5.1.
|
|
953
|
+
specifier: ^5.1.114
|
|
954
|
+
version: 5.1.114(typescript@5.9.3)(vue@3.5.43(typescript@5.9.3))
|
|
955
955
|
zova-module-pay-mock:
|
|
956
956
|
specifier: ^5.0.6
|
|
957
957
|
version: 5.0.6
|
|
@@ -1990,7 +1990,7 @@ importers:
|
|
|
1990
1990
|
specifier: ^5.0.3
|
|
1991
1991
|
version: link:modules/a-markdown
|
|
1992
1992
|
vona-module-a-rbac:
|
|
1993
|
-
specifier: ^5.0.
|
|
1993
|
+
specifier: ^5.0.17
|
|
1994
1994
|
version: link:modules/a-rbac
|
|
1995
1995
|
vona-module-a-socket:
|
|
1996
1996
|
specifier: workspace:^
|
|
@@ -9415,9 +9415,15 @@ packages:
|
|
|
9415
9415
|
zova-core@5.1.94:
|
|
9416
9416
|
resolution: {integrity: sha512-puo3pqa4/ngwZj//Md457XMloZ4FRaoJlmwf+zyPyxKL7GoV81BaJeUPi9szzY8aM9EkoWXBhSVA0cp6mTXBhw==}
|
|
9417
9417
|
|
|
9418
|
+
zova-core@5.1.95:
|
|
9419
|
+
resolution: {integrity: sha512-nvBb3zVdJEQ9SWi+cl5j2PLs/T0FRRIULfHqU3SBFUn3jFSZFizniRlak+qoSq3mzj+LGD61BfnFJQb2JJ8+Zg==}
|
|
9420
|
+
|
|
9418
9421
|
zova-jsx@1.1.94:
|
|
9419
9422
|
resolution: {integrity: sha512-4QGgNE5J32PXYCgHBt8JBrTRp8CXADgZzIBUsovQmKfIwtMY6DK5+zPQeWBVqHoV5vK95RKhRPwdAKFGOLupeg==}
|
|
9420
9423
|
|
|
9424
|
+
zova-jsx@1.1.95:
|
|
9425
|
+
resolution: {integrity: sha512-gzmR01Qt4oBrMCejH4W0fqfFx62L+XQOfOROs6mBVdFYGmbyXzy/lDXtTH4v4WjlrmVbuoAyZE/BrYmNxn2swA==}
|
|
9426
|
+
|
|
9421
9427
|
zova-module-a-api@5.1.23:
|
|
9422
9428
|
resolution: {integrity: sha512-qKOZOgaEr92GJDO1y56P1s0RA9lz0i4xa2VpY3Eu8KBU+KixiXLKEo0LKW9E0qpWphsXK8l1JHQwxSAPBogFvA==}
|
|
9423
9429
|
|
|
@@ -9481,6 +9487,9 @@ packages:
|
|
|
9481
9487
|
zova-module-a-ssr@5.1.41:
|
|
9482
9488
|
resolution: {integrity: sha512-2p19mMzc9MjvYDdEbV6qjOngxuM7vdzwA1+Z4+/81pOfptU7nBobqDaDUNZxblHEoiIHrDM8NCeghPBO6Fz2sg==}
|
|
9483
9489
|
|
|
9490
|
+
zova-module-a-ssr@5.1.42:
|
|
9491
|
+
resolution: {integrity: sha512-XaZcUHwDxwyQi4VNLK6CxkdG4lV5aUNgPNkiLjN3LWh9l2yS0u8CQqGV8+gEKcpOOj7XhZIubu4gb4GUNNQoGw==}
|
|
9492
|
+
|
|
9484
9493
|
zova-module-a-ssrhmr@5.1.24:
|
|
9485
9494
|
resolution: {integrity: sha512-wB3ud1BYabj0GH8IQea1skcbMquHTtRHJsxDlFNfKtf+jiofN8YuWgrw3XKyOPW98BaMnHPRvpy4G+3Uh4hnOA==}
|
|
9486
9495
|
|
|
@@ -9499,6 +9508,9 @@ packages:
|
|
|
9499
9508
|
zova-module-a-zova@5.1.113:
|
|
9500
9509
|
resolution: {integrity: sha512-PuasOjYgRz8F94oVPSjQepwVxL+LySauwJQKNzly+yD7YCbQJcHy6tw7IejwcaSTjlBe3M1Iy6IWX7IwrOrfyw==}
|
|
9501
9510
|
|
|
9511
|
+
zova-module-a-zova@5.1.114:
|
|
9512
|
+
resolution: {integrity: sha512-H16jQ2YRw3Q9KSO7RDKMl+QRFZSZ7J/AkUUD71BTQA1oDLY+zBLuKTABwicEgS3S44R4qJC79rR/yuTXLlZnkw==}
|
|
9513
|
+
|
|
9502
9514
|
zova-module-pay-mock@5.0.6:
|
|
9503
9515
|
resolution: {integrity: sha512-i133rYinCELjb0WkcRKs1LPNxNxlJ9XUV+NgimSNXUHi6O7TLEHq28Ygq5D6HvG9t+TevXbYS5TxTfPtXpPkOg==}
|
|
9504
9516
|
|
|
@@ -9514,9 +9526,15 @@ packages:
|
|
|
9514
9526
|
zova-suite-a-zova@5.1.175:
|
|
9515
9527
|
resolution: {integrity: sha512-cjN97iaDZ3WhvOtu27YnqfZpEnnKptLGqqpllIhAtOFaTXtuMGiGVTmH7Q1tc/k9OPEHzvvCThsYKRxzYbR5DQ==}
|
|
9516
9528
|
|
|
9529
|
+
zova-suite-a-zova@5.1.176:
|
|
9530
|
+
resolution: {integrity: sha512-w7JQ0cutO3zaZosxfsSLn9/mcY5deftZWTGkiPJTTG6I6eGGZlFJRDCQpu+dBd8eYI66xe4kstdH3KGhLSt74A==}
|
|
9531
|
+
|
|
9517
9532
|
zova@5.1.176:
|
|
9518
9533
|
resolution: {integrity: sha512-7E/Kc9QkuB6ckDX2ual9DFmiVrcPy/umDVlC+QlRFy5A30L8D5/WNKzWxmBubXYsXWmsAd/VZQUQPwditMmNkA==}
|
|
9519
9534
|
|
|
9535
|
+
zova@5.1.177:
|
|
9536
|
+
resolution: {integrity: sha512-35jZVs08Qn4cybwNqu1/PE3qz1J7Vi64y7AgTD98rVv5uOnDPkuwIIIVgMYZllV/C3kCsWPBLoC3tz/UG4SEcA==}
|
|
9537
|
+
|
|
9520
9538
|
zwitch@1.0.5:
|
|
9521
9539
|
resolution: {integrity: sha512-V50KMwwzqJV0NpZIZFwfOD5/lyny3WlSzRiXgA0G7VUnRlqttta1L6UQIHzd6EuBY/cHGfwTIck7w1yH6Q5zUw==}
|
|
9522
9540
|
|
|
@@ -15862,7 +15880,38 @@ snapshots:
|
|
|
15862
15880
|
|
|
15863
15881
|
yocto-queue@0.1.0: {}
|
|
15864
15882
|
|
|
15865
|
-
zova-core@5.1.94(
|
|
15883
|
+
zova-core@5.1.94(typescript@5.9.3):
|
|
15884
|
+
dependencies:
|
|
15885
|
+
'@cabloy/compose': link:packages-utils/compose
|
|
15886
|
+
'@cabloy/extend': link:packages-utils/extend
|
|
15887
|
+
'@cabloy/json5': link:packages-utils/json5
|
|
15888
|
+
'@cabloy/localeutil': link:packages-utils/localeutil
|
|
15889
|
+
'@cabloy/logger': 1.1.25
|
|
15890
|
+
'@cabloy/module-info': 2.0.3
|
|
15891
|
+
'@cabloy/utils': link:packages-utils/utils
|
|
15892
|
+
'@cabloy/vue-compiler-sfc': 3.5.14
|
|
15893
|
+
'@cabloy/vue-reactivity': 3.5.16
|
|
15894
|
+
'@cabloy/vue-router': 4.4.18(vue@3.5.43(typescript@5.9.3))
|
|
15895
|
+
'@cabloy/vue-runtime-core': 3.5.57
|
|
15896
|
+
'@cabloy/vue-runtime-dom': 3.5.13
|
|
15897
|
+
'@cabloy/vue-server-renderer': 3.5.18(vue@3.5.43(typescript@5.9.3))
|
|
15898
|
+
'@cabloy/word-utils': 2.1.14
|
|
15899
|
+
'@cabloy/zod-errors-custom': link:packages-utils/zod-errors-custom
|
|
15900
|
+
'@cabloy/zod-openapi': link:packages-utils/zod-openapi
|
|
15901
|
+
'@cabloy/zod-query': link:packages-utils/zod-query
|
|
15902
|
+
'@types/js-cookie': 3.0.6
|
|
15903
|
+
'@types/ms': 2.1.0
|
|
15904
|
+
'@vue/shared': 3.5.36
|
|
15905
|
+
deep-equal: 2.2.3
|
|
15906
|
+
ms: 2.1.3
|
|
15907
|
+
reflect-metadata: 0.2.2
|
|
15908
|
+
vue: 3.5.43(typescript@5.9.3)
|
|
15909
|
+
vue-plugin-render-freeze: 2.0.4(vue@3.5.43(typescript@5.9.3))
|
|
15910
|
+
zod: '@cabloy/zod@4.3.8'
|
|
15911
|
+
transitivePeerDependencies:
|
|
15912
|
+
- typescript
|
|
15913
|
+
|
|
15914
|
+
zova-core@5.1.95(patch_hash=44197c3f359a73a1a5bb3dc0a8a97e705a55b30f9a434717aa24d75a96f2c6b6)(typescript@5.9.3):
|
|
15866
15915
|
dependencies:
|
|
15867
15916
|
'@cabloy/compose': link:packages-utils/compose
|
|
15868
15917
|
'@cabloy/extend': link:packages-utils/extend
|
|
@@ -15900,7 +15949,18 @@ snapshots:
|
|
|
15900
15949
|
'@cabloy/word-utils': 2.1.14
|
|
15901
15950
|
typestyle: 2.4.0
|
|
15902
15951
|
vue: 3.5.43(typescript@5.9.3)
|
|
15903
|
-
zova-core: 5.1.94(
|
|
15952
|
+
zova-core: 5.1.94(typescript@5.9.3)
|
|
15953
|
+
transitivePeerDependencies:
|
|
15954
|
+
- typescript
|
|
15955
|
+
|
|
15956
|
+
zova-jsx@1.1.95(typescript@5.9.3):
|
|
15957
|
+
dependencies:
|
|
15958
|
+
'@cabloy/compose': link:packages-utils/compose
|
|
15959
|
+
'@cabloy/utils': link:packages-utils/utils
|
|
15960
|
+
'@cabloy/word-utils': 2.1.14
|
|
15961
|
+
typestyle: 2.4.0
|
|
15962
|
+
vue: 3.5.43(typescript@5.9.3)
|
|
15963
|
+
zova-core: 5.1.95(patch_hash=44197c3f359a73a1a5bb3dc0a8a97e705a55b30f9a434717aa24d75a96f2c6b6)(typescript@5.9.3)
|
|
15904
15964
|
transitivePeerDependencies:
|
|
15905
15965
|
- typescript
|
|
15906
15966
|
|
|
@@ -15978,6 +16038,11 @@ snapshots:
|
|
|
15978
16038
|
devalue: 5.9.4
|
|
15979
16039
|
ms: 2.1.3
|
|
15980
16040
|
|
|
16041
|
+
zova-module-a-ssr@5.1.42:
|
|
16042
|
+
dependencies:
|
|
16043
|
+
devalue: 5.9.4
|
|
16044
|
+
ms: 2.1.3
|
|
16045
|
+
|
|
15981
16046
|
zova-module-a-ssrhmr@5.1.24:
|
|
15982
16047
|
dependencies:
|
|
15983
16048
|
'@cabloy/socket': link:packages-utils/socket
|
|
@@ -16022,6 +16087,22 @@ snapshots:
|
|
|
16022
16087
|
- typescript
|
|
16023
16088
|
- vue
|
|
16024
16089
|
|
|
16090
|
+
zova-module-a-zova@5.1.114(typescript@5.9.3)(vue@3.5.43(typescript@5.9.3)):
|
|
16091
|
+
dependencies:
|
|
16092
|
+
'@cabloy/compose': link:packages-utils/compose
|
|
16093
|
+
'@cabloy/deps': link:packages-utils/deps
|
|
16094
|
+
'@cabloy/json5': link:packages-utils/json5
|
|
16095
|
+
'@cabloy/module-info': 2.0.3
|
|
16096
|
+
'@cabloy/utils': link:packages-utils/utils
|
|
16097
|
+
'@cabloy/vue-router': 4.4.18(vue@3.5.43(typescript@5.9.3))
|
|
16098
|
+
'@cabloy/word-utils': 2.1.14
|
|
16099
|
+
defu: 6.1.7
|
|
16100
|
+
luxon: 3.7.2
|
|
16101
|
+
zova-jsx: 1.1.95(typescript@5.9.3)
|
|
16102
|
+
transitivePeerDependencies:
|
|
16103
|
+
- typescript
|
|
16104
|
+
- vue
|
|
16105
|
+
|
|
16025
16106
|
zova-module-pay-mock@5.0.6: {}
|
|
16026
16107
|
|
|
16027
16108
|
zova-module-pay-paypal@5.0.3: {}
|
|
@@ -16063,9 +16144,42 @@ snapshots:
|
|
|
16063
16144
|
- typescript
|
|
16064
16145
|
- vue
|
|
16065
16146
|
|
|
16147
|
+
zova-suite-a-zova@5.1.176(debug@4.4.3(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.9.3)(vue@3.5.43(typescript@5.9.3)):
|
|
16148
|
+
dependencies:
|
|
16149
|
+
zova-module-a-api: 5.1.23
|
|
16150
|
+
zova-module-a-app: 5.1.27
|
|
16151
|
+
zova-module-a-bean: 5.1.37
|
|
16152
|
+
zova-module-a-behavior: 5.1.28
|
|
16153
|
+
zova-module-a-behaviors: 5.1.23
|
|
16154
|
+
zova-module-a-command: 5.1.36
|
|
16155
|
+
zova-module-a-fetch: 5.1.26(debug@4.4.3(supports-color@7.2.0))(supports-color@7.2.0)
|
|
16156
|
+
zova-module-a-form: 5.1.55(vue@3.5.43(typescript@5.9.3))
|
|
16157
|
+
zova-module-a-icon: 5.1.28
|
|
16158
|
+
zova-module-a-interceptor: 5.1.32
|
|
16159
|
+
zova-module-a-logger: 5.1.29
|
|
16160
|
+
zova-module-a-meta: 5.1.23
|
|
16161
|
+
zova-module-a-model: 5.1.39(vue@3.5.43(typescript@5.9.3))
|
|
16162
|
+
zova-module-a-openapi: 5.1.59
|
|
16163
|
+
zova-module-a-router: 5.1.43
|
|
16164
|
+
zova-module-a-routerstack: 5.1.30
|
|
16165
|
+
zova-module-a-routertabs: 5.1.36
|
|
16166
|
+
zova-module-a-ssr: 5.1.42
|
|
16167
|
+
zova-module-a-ssrhmr: 5.1.24
|
|
16168
|
+
zova-module-a-ssrserver: 5.1.27
|
|
16169
|
+
zova-module-a-style: 5.1.36
|
|
16170
|
+
zova-module-a-table: 5.1.45(vue@3.5.43(typescript@5.9.3))
|
|
16171
|
+
zova-module-a-zod: 5.1.39
|
|
16172
|
+
zova-module-a-zova: 5.1.114(typescript@5.9.3)(vue@3.5.43(typescript@5.9.3))
|
|
16173
|
+
transitivePeerDependencies:
|
|
16174
|
+
- '@vue/composition-api'
|
|
16175
|
+
- debug
|
|
16176
|
+
- supports-color
|
|
16177
|
+
- typescript
|
|
16178
|
+
- vue
|
|
16179
|
+
|
|
16066
16180
|
zova@5.1.176(debug@4.4.3(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.9.3)(vue@3.5.43(typescript@5.9.3)):
|
|
16067
16181
|
dependencies:
|
|
16068
|
-
zova-core: 5.1.94(
|
|
16182
|
+
zova-core: 5.1.94(typescript@5.9.3)
|
|
16069
16183
|
zova-suite-a-zova: 5.1.175(debug@4.4.3(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.9.3)(vue@3.5.43(typescript@5.9.3))
|
|
16070
16184
|
transitivePeerDependencies:
|
|
16071
16185
|
- '@vue/composition-api'
|
|
@@ -16074,6 +16188,17 @@ snapshots:
|
|
|
16074
16188
|
- typescript
|
|
16075
16189
|
- vue
|
|
16076
16190
|
|
|
16191
|
+
zova@5.1.177(debug@4.4.3(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.9.3)(vue@3.5.43(typescript@5.9.3)):
|
|
16192
|
+
dependencies:
|
|
16193
|
+
zova-core: 5.1.95(patch_hash=44197c3f359a73a1a5bb3dc0a8a97e705a55b30f9a434717aa24d75a96f2c6b6)(typescript@5.9.3)
|
|
16194
|
+
zova-suite-a-zova: 5.1.176(debug@4.4.3(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.9.3)(vue@3.5.43(typescript@5.9.3))
|
|
16195
|
+
transitivePeerDependencies:
|
|
16196
|
+
- '@vue/composition-api'
|
|
16197
|
+
- debug
|
|
16198
|
+
- supports-color
|
|
16199
|
+
- typescript
|
|
16200
|
+
- vue
|
|
16201
|
+
|
|
16077
16202
|
zwitch@1.0.5: {}
|
|
16078
16203
|
|
|
16079
16204
|
zwitch@2.0.4: {}
|
package/vona/pnpm-workspace.yaml
CHANGED
|
@@ -34,7 +34,7 @@ export class ServiceHmr extends BeanBase {
|
|
|
34
34
|
if (sceneName === '_error') {
|
|
35
35
|
await this._reloadError(moduleName!, fileModule.errors);
|
|
36
36
|
} else if (sceneName === '_locale') {
|
|
37
|
-
await this._reloadLocale(
|
|
37
|
+
await this._reloadLocale();
|
|
38
38
|
} else if (sceneName === '_config') {
|
|
39
39
|
await this._reloadConfig(moduleName!, fileModule.config);
|
|
40
40
|
} else if (sceneName === '_constant') {
|
|
@@ -50,17 +50,10 @@ export class ServiceHmr extends BeanBase {
|
|
|
50
50
|
deepExtend(this.app.meta.error.ebErrors[moduleName], errors);
|
|
51
51
|
}
|
|
52
52
|
|
|
53
|
-
private async _reloadLocale(
|
|
54
|
-
|
|
55
|
-
//
|
|
56
|
-
|
|
57
|
-
this.app.meta.localeModules[moduleName][locale] = Object.assign(
|
|
58
|
-
{},
|
|
59
|
-
moduleLocales,
|
|
60
|
-
this.app.meta.hmrCacheLocaleModules[moduleName]?.[locale],
|
|
61
|
-
);
|
|
62
|
-
// openapi
|
|
63
|
-
await this.bean.openapi.clearAllCaches();
|
|
53
|
+
private async _reloadLocale() {
|
|
54
|
+
// Ordinary dictionaries also contribute to the ordered global fallback map.
|
|
55
|
+
// Restart to remove stale values and restore project/provider precedence in both maps.
|
|
56
|
+
this.app.bean.worker.reload();
|
|
64
57
|
}
|
|
65
58
|
|
|
66
59
|
private async _reloadConfig(moduleName: string, config: TypeModuleResourceConfig) {
|
|
@@ -25,6 +25,32 @@ declare module 'vona-module-a-ratelimit' {
|
|
|
25
25
|
}
|
|
26
26
|
}
|
|
27
27
|
/** interceptor: end */
|
|
28
|
+
/** middlewareSystem: begin */
|
|
29
|
+
export * from '../bean/middlewareSystem.aggregate.ts';
|
|
30
|
+
import type { IMiddlewareSystemOptionsAggregate } from '../bean/middlewareSystem.aggregate.ts';
|
|
31
|
+
import 'vona-module-a-aspect';
|
|
32
|
+
declare module 'vona-module-a-aspect' {
|
|
33
|
+
|
|
34
|
+
export interface IMiddlewareSystemRecord {
|
|
35
|
+
'a-ratelimit:aggregate': IMiddlewareSystemOptionsAggregate;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
}
|
|
40
|
+
declare module 'vona-module-a-ratelimit' {
|
|
41
|
+
|
|
42
|
+
export interface MiddlewareSystemAggregate {
|
|
43
|
+
/** @internal */
|
|
44
|
+
get scope(): ScopeModuleARatelimit;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface MiddlewareSystemAggregate {
|
|
48
|
+
get $beanFullName(): 'a-ratelimit.middlewareSystem.aggregate';
|
|
49
|
+
get $onionName(): 'a-ratelimit:aggregate';
|
|
50
|
+
get $onionOptions(): IMiddlewareSystemOptionsAggregate;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
/** middlewareSystem: end */
|
|
28
54
|
/** service: begin */
|
|
29
55
|
export * from '../service/rateLimit.ts';
|
|
30
56
|
|
package/vona/src/suite-vendor/a-vona/modules/a-ratelimit/src/bean/middlewareSystem.aggregate.ts
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import type { Next } from 'vona';
|
|
2
|
+
import type {
|
|
3
|
+
IDecoratorMiddlewareSystemOptions,
|
|
4
|
+
IMiddlewareSystemExecute,
|
|
5
|
+
} from 'vona-module-a-aspect';
|
|
6
|
+
|
|
7
|
+
import { BeanBase } from 'vona';
|
|
8
|
+
import { MiddlewareSystem } from 'vona-module-a-aspect';
|
|
9
|
+
|
|
10
|
+
import type { IRateLimitAggregatePolicy, IRateLimitResult } from '../types/rateLimit.ts';
|
|
11
|
+
|
|
12
|
+
export interface IMiddlewareSystemOptionsAggregate extends IDecoratorMiddlewareSystemOptions {
|
|
13
|
+
rateLimit?: IRateLimitAggregatePolicy;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
@MiddlewareSystem<IMiddlewareSystemOptionsAggregate>({
|
|
17
|
+
enable: false,
|
|
18
|
+
dependencies: 'a-logger:httpLog',
|
|
19
|
+
dependents: ['a-metrics:health', 'a-instance:app'],
|
|
20
|
+
})
|
|
21
|
+
export class MiddlewareSystemAggregate extends BeanBase implements IMiddlewareSystemExecute {
|
|
22
|
+
async execute(options: IMiddlewareSystemOptionsAggregate, next: Next) {
|
|
23
|
+
const policy = options.rateLimit;
|
|
24
|
+
if (!policy) throw new Error('rate-limit aggregate policy is required when enabled');
|
|
25
|
+
// Validation is synchronous; only Redis admission failures enter the catch below.
|
|
26
|
+
const admission = this.scope.service.rateLimit.admitAggregate(policy);
|
|
27
|
+
let result: IRateLimitResult;
|
|
28
|
+
try {
|
|
29
|
+
result = await admission;
|
|
30
|
+
} catch (error) {
|
|
31
|
+
this.$logger.warn({
|
|
32
|
+
event: 'rate_limit.redis_error',
|
|
33
|
+
scope: 'aggregate',
|
|
34
|
+
mode: policy.mode,
|
|
35
|
+
error: error instanceof Error ? error.name : 'Error',
|
|
36
|
+
});
|
|
37
|
+
if (policy.mode === 'observe') return next();
|
|
38
|
+
this._reject(503, 'Service Unavailable');
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
if (result.allowed) return next();
|
|
42
|
+
this.$logger.warn({
|
|
43
|
+
event: policy.mode === 'observe' ? 'rate_limit.would_reject' : 'rate_limit.rejected',
|
|
44
|
+
scope: 'aggregate',
|
|
45
|
+
mode: policy.mode,
|
|
46
|
+
limit: policy.limit,
|
|
47
|
+
});
|
|
48
|
+
if (policy.mode === 'observe') return next();
|
|
49
|
+
this.ctx.set('Retry-After', String(result.retryAfterSeconds));
|
|
50
|
+
this._reject(429, 'Too Many Requests');
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
private _reject(status: number, message: string) {
|
|
54
|
+
this.ctx.status = status;
|
|
55
|
+
this.ctx.type = 'application/json';
|
|
56
|
+
this.ctx.body = { code: status, message };
|
|
57
|
+
}
|
|
58
|
+
}
|
|
@@ -2,7 +2,11 @@ import { createHash } from 'node:crypto';
|
|
|
2
2
|
import { BeanBase, cast } from 'vona';
|
|
3
3
|
import { Service } from 'vona-module-a-bean';
|
|
4
4
|
|
|
5
|
-
import type {
|
|
5
|
+
import type {
|
|
6
|
+
IRateLimitAggregatePolicy,
|
|
7
|
+
IRateLimitPolicy,
|
|
8
|
+
IRateLimitResult,
|
|
9
|
+
} from '../types/rateLimit.ts';
|
|
6
10
|
|
|
7
11
|
@Service()
|
|
8
12
|
export class ServiceRateLimit extends BeanBase {
|
|
@@ -14,16 +18,32 @@ export class ServiceRateLimit extends BeanBase {
|
|
|
14
18
|
this._validatePolicy(policy);
|
|
15
19
|
const now = Date.now();
|
|
16
20
|
const key = this._createKey(policy, now);
|
|
17
|
-
|
|
21
|
+
return this._admit(key, policy.limit, policy.windowMs);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
admitAggregate(policy: IRateLimitAggregatePolicy): Promise<IRateLimitResult> {
|
|
25
|
+
if (!['observe', 'enforce'].includes(policy.mode)) {
|
|
26
|
+
throw new Error('rate-limit aggregate mode must be observe or enforce');
|
|
27
|
+
}
|
|
28
|
+
if (!Number.isSafeInteger(policy.limit) || policy.limit < 1) {
|
|
29
|
+
throw new Error('rate-limit aggregate limit must be a positive safe integer');
|
|
30
|
+
}
|
|
31
|
+
const windowMs = 1_000;
|
|
32
|
+
const windowStart = Math.floor(Date.now() / windowMs) * windowMs;
|
|
33
|
+
return this._admit(`rate-limit:aggregate:v1:${windowStart}`, policy.limit, windowMs);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
private async _admit(key: string, limit: number, windowMs: number): Promise<IRateLimitResult> {
|
|
37
|
+
const result = (await cast(this.clientRedis).rateLimitAdmit(key, String(windowMs))) as [
|
|
18
38
|
number | string,
|
|
19
39
|
number | string,
|
|
20
40
|
];
|
|
21
41
|
const current = Number(result[0]);
|
|
22
42
|
const resetAfterMs = Math.max(0, Number(result[1]));
|
|
23
43
|
return {
|
|
24
|
-
allowed: current <=
|
|
25
|
-
limit
|
|
26
|
-
remaining: Math.max(0,
|
|
44
|
+
allowed: current <= limit,
|
|
45
|
+
limit,
|
|
46
|
+
remaining: Math.max(0, limit - current),
|
|
27
47
|
resetAfterMs,
|
|
28
48
|
retryAfterSeconds: Math.max(1, Math.ceil(resetAfterMs / 1000)),
|
|
29
49
|
};
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
import type { IMiddlewareSystemOptionsAggregate, RateLimitMode } from 'vona-module-a-ratelimit';
|
|
2
|
+
|
|
3
|
+
import assert from 'node:assert/strict';
|
|
4
|
+
import { randomUUID } from 'node:crypto';
|
|
5
|
+
import { once } from 'node:events';
|
|
6
|
+
import { createServer } from 'node:http';
|
|
7
|
+
import { describe, it } from 'node:test';
|
|
8
|
+
import { setTimeout } from 'node:timers/promises';
|
|
9
|
+
import { cast, compose } from 'vona';
|
|
10
|
+
import { app } from 'vona-mock';
|
|
11
|
+
import { MiddlewareSystemAggregate, ServiceRateLimit } from 'vona-module-a-ratelimit';
|
|
12
|
+
|
|
13
|
+
const exemptPath = '/api/test/vona/performAction/rateLimitExempt';
|
|
14
|
+
const limitedPath = '/api/test/vona/performAction/rateLimit';
|
|
15
|
+
|
|
16
|
+
type AdmitCommand = (key: string, windowMs: string) => Promise<[number, number]>;
|
|
17
|
+
|
|
18
|
+
function createService(command: AdmitCommand) {
|
|
19
|
+
const service = app.bean._newBean(ServiceRateLimit);
|
|
20
|
+
Object.defineProperty(service, 'bean', {
|
|
21
|
+
value: { redis: { get: () => ({ rateLimitAdmit: command }) } },
|
|
22
|
+
});
|
|
23
|
+
return service;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
type Request = (path: string, options?: RequestInit) => Promise<Response>;
|
|
27
|
+
|
|
28
|
+
async function withGate(
|
|
29
|
+
mode: RateLimitMode,
|
|
30
|
+
limit: number,
|
|
31
|
+
command: AdmitCommand,
|
|
32
|
+
fn: (request: Request) => Promise<void>,
|
|
33
|
+
) {
|
|
34
|
+
await app.bean.executor.mockCtx(async () => {
|
|
35
|
+
const middleware = app.bean._newBean(MiddlewareSystemAggregate);
|
|
36
|
+
Object.defineProperty(middleware, 'scope', {
|
|
37
|
+
value: { service: { rateLimit: createService(command) } },
|
|
38
|
+
});
|
|
39
|
+
const policyName = `test-aggregate-${randomUUID()}`;
|
|
40
|
+
// Keep admission and its listener local; never patch the shared application chain.
|
|
41
|
+
const chain = compose([
|
|
42
|
+
(_ctx, next) => middleware.execute({ rateLimit: { mode, limit } }, next),
|
|
43
|
+
...app.middleware,
|
|
44
|
+
]);
|
|
45
|
+
const server = createServer((req, res) => {
|
|
46
|
+
void app.bean.executor
|
|
47
|
+
.newCtx(
|
|
48
|
+
() => {
|
|
49
|
+
app.ctx.onionsDynamic = {
|
|
50
|
+
interceptor: { 'a-ratelimit:rateLimit': { rateLimit: { name: policyName } } },
|
|
51
|
+
};
|
|
52
|
+
return cast(app).handleRequest(app.ctx, chain);
|
|
53
|
+
},
|
|
54
|
+
{ dbInfo: { level: 0 }, innerAccess: false, req, res },
|
|
55
|
+
)
|
|
56
|
+
.catch(error => res.destroy(error));
|
|
57
|
+
});
|
|
58
|
+
try {
|
|
59
|
+
server.listen(0, '127.0.0.1');
|
|
60
|
+
await once(server, 'listening');
|
|
61
|
+
const address = server.address();
|
|
62
|
+
assert.ok(address && typeof address !== 'string');
|
|
63
|
+
await fn((path, options) =>
|
|
64
|
+
fetch(`http://127.0.0.1:${address.port}${path}`, {
|
|
65
|
+
...options,
|
|
66
|
+
headers: { accept: 'application/json', ...options?.headers },
|
|
67
|
+
signal: AbortSignal.timeout(10_000),
|
|
68
|
+
}),
|
|
69
|
+
);
|
|
70
|
+
} finally {
|
|
71
|
+
await new Promise<void>((resolve, reject) => {
|
|
72
|
+
server.close(error => (error ? reject(error) : resolve()));
|
|
73
|
+
server.closeAllConnections();
|
|
74
|
+
});
|
|
75
|
+
const redis = app.bean.redis.get('limiter');
|
|
76
|
+
const prefix = redis.options.keyPrefix ?? '';
|
|
77
|
+
const keys = await redis.keys(`${prefix}rate-limit:v1:*:${encodeURIComponent(policyName)}:*`);
|
|
78
|
+
if (keys.length) await redis.del(...keys.map(key => key.slice(prefix.length)));
|
|
79
|
+
}
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function counter(): AdmitCommand {
|
|
84
|
+
let current = 0;
|
|
85
|
+
return async () => [++current, 1_000];
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
describe('aggregate.test.ts', { concurrency: false }, () => {
|
|
89
|
+
it('shares an atomic Redis budget across request contexts and expires its counter', async () => {
|
|
90
|
+
const key = `test:aggregate:${randomUUID()}`;
|
|
91
|
+
const redis = cast(app.bean.redis.get('limiter'));
|
|
92
|
+
const keys: string[] = [];
|
|
93
|
+
// Isolate this test's bucket without changing the production key dimensions or TTL.
|
|
94
|
+
const service = createService((actualKey, windowMs) => {
|
|
95
|
+
keys.push(actualKey);
|
|
96
|
+
return redis.rateLimitAdmit(key, windowMs);
|
|
97
|
+
});
|
|
98
|
+
try {
|
|
99
|
+
const results = await Promise.all(
|
|
100
|
+
[0, 1, 2, 3].map(index =>
|
|
101
|
+
app.bean.executor.mockCtx(async () => {
|
|
102
|
+
app.ctx.req.url = `/aggregate-${index}`;
|
|
103
|
+
return service.admitAggregate({
|
|
104
|
+
mode: 'enforce',
|
|
105
|
+
limit: 2,
|
|
106
|
+
});
|
|
107
|
+
}),
|
|
108
|
+
),
|
|
109
|
+
);
|
|
110
|
+
assert.equal(results.filter(result => result.allowed).length, 2);
|
|
111
|
+
assert.ok(keys.every(actualKey => /^rate-limit:aggregate:v1:\d+$/.test(actualKey)));
|
|
112
|
+
assert.equal(await redis.get(key), '4');
|
|
113
|
+
const ttl = await redis.pttl(key);
|
|
114
|
+
assert.ok(ttl > 0 && ttl <= 1_000);
|
|
115
|
+
await setTimeout(ttl + 20);
|
|
116
|
+
assert.equal(await redis.get(key), null);
|
|
117
|
+
await app.bean.executor.mockCtx(async () => {
|
|
118
|
+
const result = await service.admitAggregate({
|
|
119
|
+
mode: 'enforce',
|
|
120
|
+
limit: 2,
|
|
121
|
+
});
|
|
122
|
+
assert.equal(result.allowed, true);
|
|
123
|
+
assert.equal(result.remaining, 1);
|
|
124
|
+
});
|
|
125
|
+
} finally {
|
|
126
|
+
await redis.del(key);
|
|
127
|
+
}
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it('counts health, preflight, unknown paths and identity-exempt actions in one HTTP budget', async () => {
|
|
131
|
+
await withGate('enforce', 3, counter(), async request => {
|
|
132
|
+
const health = await request('/health/live');
|
|
133
|
+
assert.equal(health.status, 200);
|
|
134
|
+
await health.text();
|
|
135
|
+
const preflight = await request('/aggregate-unknown', {
|
|
136
|
+
method: 'OPTIONS',
|
|
137
|
+
headers: { 'origin': 'https://example.test', 'access-control-request-method': 'GET' },
|
|
138
|
+
});
|
|
139
|
+
assert.notEqual(preflight.status, 429);
|
|
140
|
+
await preflight.text();
|
|
141
|
+
const unknown = await request(`/aggregate-unknown-${randomUUID()}`);
|
|
142
|
+
assert.equal(unknown.status, 404);
|
|
143
|
+
await unknown.text();
|
|
144
|
+
const rejected = await request(exemptPath);
|
|
145
|
+
assert.equal(rejected.status, 429);
|
|
146
|
+
assert.equal(rejected.headers.get('retry-after'), '1');
|
|
147
|
+
assert.equal(rejected.headers.get('ratelimit-limit'), null);
|
|
148
|
+
assert.deepEqual(await rejected.json(), { code: 429, message: 'Too Many Requests' });
|
|
149
|
+
});
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it('observes excess requests without rejecting or replacing identity headers', async () => {
|
|
153
|
+
await withGate('observe', 1, counter(), async request => {
|
|
154
|
+
for (let index = 0; index < 2; index++) {
|
|
155
|
+
const response = await request(exemptPath);
|
|
156
|
+
assert.equal(response.status, 200);
|
|
157
|
+
assert.equal(response.headers.get('ratelimit-limit'), null);
|
|
158
|
+
assert.equal(response.headers.get('retry-after'), null);
|
|
159
|
+
await response.text();
|
|
160
|
+
}
|
|
161
|
+
});
|
|
162
|
+
await withGate('enforce', 100, counter(), async request => {
|
|
163
|
+
for (let index = 0; index < 2; index++) {
|
|
164
|
+
const response = await request(limitedPath);
|
|
165
|
+
assert.equal(response.status, 200);
|
|
166
|
+
assert.equal(response.headers.get('ratelimit-limit'), '2');
|
|
167
|
+
await response.text();
|
|
168
|
+
}
|
|
169
|
+
const response = await request(limitedPath);
|
|
170
|
+
assert.equal(response.status, 429);
|
|
171
|
+
assert.equal(response.headers.get('ratelimit-limit'), '2');
|
|
172
|
+
await response.text();
|
|
173
|
+
});
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
it('fails open only in observe mode and preserves HTTP 503 for HTML callers', async () => {
|
|
177
|
+
const unavailable: AdmitCommand = async () => {
|
|
178
|
+
throw new Error('redis unavailable');
|
|
179
|
+
};
|
|
180
|
+
await withGate('observe', 1, unavailable, async request => {
|
|
181
|
+
const response = await request('/health/live');
|
|
182
|
+
assert.equal(response.status, 200);
|
|
183
|
+
await response.text();
|
|
184
|
+
});
|
|
185
|
+
await withGate('enforce', 1, unavailable, async request => {
|
|
186
|
+
const response = await request(exemptPath, { headers: { accept: 'text/html' } });
|
|
187
|
+
assert.equal(response.status, 503);
|
|
188
|
+
assert.match(response.headers.get('content-type')!, /application\/json/);
|
|
189
|
+
assert.equal(response.headers.get('retry-after'), null);
|
|
190
|
+
assert.deepEqual(await response.json(), { code: 503, message: 'Service Unavailable' });
|
|
191
|
+
});
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
it('keeps competing gates and the shared application listener independent', async () => {
|
|
195
|
+
await Promise.all([
|
|
196
|
+
withGate(
|
|
197
|
+
'enforce',
|
|
198
|
+
1,
|
|
199
|
+
async () => [2, 1_000],
|
|
200
|
+
async request => {
|
|
201
|
+
const response = await request('/health/live');
|
|
202
|
+
assert.equal(response.status, 429);
|
|
203
|
+
await response.text();
|
|
204
|
+
const address = app.server.address();
|
|
205
|
+
assert.ok(address && typeof address !== 'string');
|
|
206
|
+
const shared = await fetch(`http://127.0.0.1:${address.port}/health/live`, {
|
|
207
|
+
signal: AbortSignal.timeout(10_000),
|
|
208
|
+
});
|
|
209
|
+
assert.equal(shared.status, 200);
|
|
210
|
+
await shared.text();
|
|
211
|
+
},
|
|
212
|
+
),
|
|
213
|
+
withGate(
|
|
214
|
+
'enforce',
|
|
215
|
+
1,
|
|
216
|
+
async () => [1, 1_000],
|
|
217
|
+
async request => {
|
|
218
|
+
const response = await request('/health/live');
|
|
219
|
+
assert.equal(response.status, 200);
|
|
220
|
+
await response.text();
|
|
221
|
+
},
|
|
222
|
+
),
|
|
223
|
+
]);
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
it('requires valid explicit configuration even in observe mode', async () => {
|
|
227
|
+
await app.bean.executor.mockCtx(async () => {
|
|
228
|
+
const middleware = app.bean._newBean(MiddlewareSystemAggregate);
|
|
229
|
+
let downstream = false;
|
|
230
|
+
const next = async () => {
|
|
231
|
+
downstream = true;
|
|
232
|
+
};
|
|
233
|
+
for (const options of [
|
|
234
|
+
{},
|
|
235
|
+
{ rateLimit: { mode: 'observe', limit: 0 } },
|
|
236
|
+
{ rateLimit: { mode: 'observe', limit: 1.5 } },
|
|
237
|
+
{ rateLimit: { mode: 'invalid', limit: 1 } },
|
|
238
|
+
]) {
|
|
239
|
+
await assert.rejects(
|
|
240
|
+
middleware.execute(options as IMiddlewareSystemOptionsAggregate, next),
|
|
241
|
+
/rate-limit aggregate/,
|
|
242
|
+
);
|
|
243
|
+
}
|
|
244
|
+
assert.equal(downstream, false);
|
|
245
|
+
});
|
|
246
|
+
});
|
|
247
|
+
});
|