cabloy 5.1.241 → 5.1.242
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 +12 -0
- package/package.json +1 -1
- package/repo-docs/backend/i18n-guide.md +109 -11
- package/repo-docs/frontend/environment-config-guide.md +43 -0
- package/repo-docs/frontend/module-scope.md +88 -0
- package/repo-docs/frontend/system-startup-guide.md +31 -0
- package/vona/packages-vona/vona/package.json +1 -1
- package/vona/pnpm-lock.yaml +1 -1
- package/vona/src/suite-vendor/a-captcha/modules/a-captcha/package.json +1 -1
- package/vona/src/suite-vendor/a-captcha/modules/a-captcha/src/bean/bean.captcha.ts +271 -137
- package/vona/src/suite-vendor/a-captcha/modules/a-captcha/src/bean/cacheRedis.captcha.ts +199 -2
- package/vona/src/suite-vendor/a-captcha/modules/a-captcha/src/lib/captchaLua.ts +90 -0
- package/vona/src/suite-vendor/a-captcha/modules/a-captcha/src/types/captcha.ts +15 -1
- package/vona/src/suite-vendor/a-captcha/modules/a-captcha/src/types/captchaProvider.ts +38 -1
- package/vona/src/suite-vendor/a-captcha/package.json +1 -1
- package/vona/src/suite-vendor/a-vona/modules/a-permission/package.json +1 -1
- package/vona/src/suite-vendor/a-vona/modules/a-permission/test/permission.test.ts +1 -216
- package/vona/src/suite-vendor/a-vona/modules/a-ratelimit/package.json +1 -1
- package/vona/src/suite-vendor/a-vona/package.json +1 -1
- package/vona/src/suite-vendor/a-vona/modules/a-ratelimit/test/aggregate.test.ts +0 -247
|
@@ -1,247 +0,0 @@
|
|
|
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
|
-
});
|