cabloy 5.1.239 → 5.1.241

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 (51) hide show
  1. package/.cabloy-version +1 -1
  2. package/CHANGELOG.md +19 -0
  3. package/package.json +1 -1
  4. package/repo-docs/backend/openapi-guide.md +18 -0
  5. package/repo-docs/backend/rate-limit-guide.md +54 -9
  6. package/repo-e2e/specs/home-user-account.spec.ts +53 -8
  7. package/vona/packages-vona/vona/package.json +1 -1
  8. package/vona/pnpm-lock.yaml +16 -141
  9. package/vona/src/suite/a-commerce/modules/commerce-siteadmin/src/dto/operatorContext.ts +4 -1
  10. package/vona/src/suite/a-commerce/modules/commerce-siteadmin/src/service/operator.ts +1 -0
  11. package/vona/src/suite/a-commerce/modules/commerce-siteadmin/test/operatorAccess.test.ts +1 -0
  12. package/vona/src/suite/a-home/modules/home-user/src/bean/meta.version.ts +1 -0
  13. package/vona/src/suite/a-home/modules/home-user/src/controller/passport.ts +1 -0
  14. package/vona/src/suite/a-home/modules/home-user/src/dto/accountCurrent.ts +3 -0
  15. package/vona/src/suite/a-home/modules/home-user/src/dto/accountProfileUpdate.ts +9 -2
  16. package/vona/src/suite/a-home/modules/home-user/src/dto/passportUser.ts +3 -0
  17. package/vona/src/suite/a-home/modules/home-user/src/entity/user.ts +3 -0
  18. package/vona/src/suite/a-home/modules/home-user/src/service/account.ts +7 -3
  19. package/vona/src/suite/a-home/modules/home-user/src/service/userAdapter.ts +1 -0
  20. package/vona/src/suite/a-home/modules/home-user/test/account.test.ts +133 -8
  21. package/vona/src/suite/a-home/modules/home-user/test/accountOpenapi.test.ts +32 -5
  22. package/vona/src/suite/a-home/modules/home-user/test/passport.test.ts +159 -1
  23. package/vona/src/suite-vendor/a-cabloy/modules/a-rbac/package.json +1 -1
  24. package/vona/src/suite-vendor/a-cabloy/package.json +2 -2
  25. package/vona/src/suite-vendor/a-vona/modules/a-hmr/package.json +1 -1
  26. package/vona/src/suite-vendor/a-vona/modules/a-hmr/src/service/hmr.ts +5 -12
  27. package/vona/src/suite-vendor/a-vona/modules/a-permission/package.json +1 -1
  28. package/vona/src/suite-vendor/a-vona/modules/a-permission/src/bean/bean.permission.ts +54 -8
  29. package/vona/src/suite-vendor/a-vona/modules/a-permission/test/permission.test.ts +228 -16
  30. package/vona/src/suite-vendor/a-vona/modules/a-ratelimit/package.json +1 -1
  31. package/vona/src/suite-vendor/a-vona/modules/a-ratelimit/src/.metadata/index.ts +26 -0
  32. package/vona/src/suite-vendor/a-vona/modules/a-ratelimit/src/bean/middlewareSystem.aggregate.ts +58 -0
  33. package/vona/src/suite-vendor/a-vona/modules/a-ratelimit/src/service/rateLimit.ts +25 -5
  34. package/vona/src/suite-vendor/a-vona/modules/a-ratelimit/src/types/rateLimit.ts +5 -0
  35. package/vona/src/suite-vendor/a-vona/modules/a-ratelimit/test/aggregate.test.ts +247 -0
  36. package/vona/src/suite-vendor/a-vona/modules/a-user/package.json +1 -1
  37. package/vona/src/suite-vendor/a-vona/modules/a-user/src/types/user.ts +1 -0
  38. package/vona/src/suite-vendor/a-vona/package.json +1 -1
  39. package/zova/src/suite/a-commerce/modules/commerce-siteadmin/src/api/openapi/schemas.ts +68 -52
  40. package/zova/src/suite/a-commerce/modules/commerce-siteadmin/src/api/openapi/types.ts +530 -344
  41. package/zova/src/suite/a-commerce/modules/commerce-siteadmin/src/page/dashboard/controller.tsx +7 -1
  42. package/zova/src/suite/a-home/modules/home-api/src/api/openapi/types.ts +6 -1
  43. package/zova/src/suite/a-home/modules/home-base/src/config/locale/en-us.ts +1 -0
  44. package/zova/src/suite/a-home/modules/home-base/src/config/locale/zh-cn.ts +1 -0
  45. package/zova/src/suite/a-home/modules/home-layoutadmin/src/component/layoutAdmin/render.user.tsx +1 -1
  46. package/zova/src/suite/a-home/modules/home-layoutweb/src/component/layoutWeb/render.user.tsx +3 -1
  47. package/zova/src/suite/a-home/modules/home-passport/src/model/passport.ts +4 -0
  48. package/zova/src/suite/a-home/modules/home-user/src/config/locale/en-us.ts +1 -0
  49. package/zova/src/suite/a-home/modules/home-user/src/config/locale/zh-cn.ts +1 -0
  50. package/zova/src/suite/a-home/modules/home-user/src/model/account.ts +2 -1
  51. package/zova/src/suite/a-home/modules/home-user/src/page/account/controller.tsx +17 -3
@@ -11,7 +11,7 @@ import type { ContextRoute, IRecordResourceNameToRoutePathItem } from 'vona-modu
11
11
  import { appResource, BeanBase, beanFullNameFromOnionName } from 'vona';
12
12
  import { Bean } from 'vona-module-a-bean';
13
13
  import { Caching } from 'vona-module-a-caching';
14
- import { clearRbacDecision, setRbacDecision, SymbolRbacDecision } from 'vona-module-a-rbac';
14
+ import { clearRbacDecision, getRbacDecision, SymbolRbacDecision } from 'vona-module-a-rbac';
15
15
  import {
16
16
  composeGuards,
17
17
  getCacheControllerRoutes,
@@ -107,6 +107,23 @@ export class BeanPermission extends BeanBase {
107
107
  return typeof permission === 'boolean' ? permission : permission.allowed;
108
108
  }
109
109
 
110
+ /**
111
+ * Checks current guards without permission events, result caches or matcher projection.
112
+ * A true result does not authorize individual records or prepare data scope/owner values.
113
+ * The target action's RBAC decision is not retained; scoped operations must authorize separately.
114
+ */
115
+ public async checkPermissionActionGuards(
116
+ resource: keyof IResourceRecord,
117
+ actionKey: string,
118
+ ): Promise<boolean> {
119
+ if (!Object.hasOwn(recordResourceNameToRoutePath, resource)) return false;
120
+ const routePathInfo = recordResourceNameToRoutePath[resource];
121
+ if (!routePathInfo || !appResource.getBean(routePathInfo.controller)) return false;
122
+ const route = this._getControllerActionRoute(resource, actionKey);
123
+ if (!route?.route?.meta || !this._matchPassportMeta(route.route.meta)) return false;
124
+ return await this._evaluatePermissionAction(route, true);
125
+ }
126
+
110
127
  public async retrievePermissionAction(
111
128
  resource: keyof IResourceRecord,
112
129
  actionKey: string,
@@ -201,12 +218,17 @@ export class BeanPermission extends BeanBase {
201
218
  return true;
202
219
  }
203
220
 
204
- private async _evaluatePermissionAction(route: ContextRoute): Promise<IOpenapiPermissionAction> {
221
+ private async _evaluatePermissionAction(route: ContextRoute, guardsOnly: true): Promise<boolean>;
222
+ private async _evaluatePermissionAction(route: ContextRoute): Promise<IOpenapiPermissionAction>;
223
+ private async _evaluatePermissionAction(
224
+ route: ContextRoute,
225
+ guardsOnly = false,
226
+ ): Promise<IOpenapiPermissionAction> {
205
227
  const ctx = this.ctx as any;
206
- const routePrevious = ctx.route;
228
+ const routeDescriptorPrevious = Object.getOwnPropertyDescriptor(ctx, 'route');
229
+ const innerAccessDescriptorPrevious = Object.getOwnPropertyDescriptor(ctx, 'innerAccess');
207
230
  const innerAccessPrevious = ctx.innerAccess;
208
- const rbacDecisionHadOwnProperty = Object.hasOwn(ctx, SymbolRbacDecision);
209
- const rbacDecisionPrevious = ctx[SymbolRbacDecision];
231
+ const rbacDecisionDescriptorPrevious = Object.getOwnPropertyDescriptor(ctx, SymbolRbacDecision);
210
232
  try {
211
233
  ctx.route = route;
212
234
  ctx.innerAccess = false;
@@ -214,19 +236,43 @@ export class BeanPermission extends BeanBase {
214
236
  const result = await composeGuards(this.app, route)(this.ctx);
215
237
  if (result === false) return false;
216
238
  if (!route.route.meta?.[BeanFullNameGuardRbac]) return true;
239
+ if (guardsOnly) {
240
+ const action = this.bean.rbacCatalog.getAction(route);
241
+ const decision = getRbacDecision(this.ctx);
242
+ return Boolean(
243
+ action &&
244
+ Object.hasOwn(ctx, SymbolRbacDecision) &&
245
+ decision?.allowed === true &&
246
+ decision.actionKey === action.actionKey &&
247
+ decision.action?.actionKey === action.actionKey &&
248
+ decision.action.actionInheritKey === action.actionInheritKey &&
249
+ decision.action.controllerBeanFullName === action.controllerBeanFullName &&
250
+ decision.action.action === action.action,
251
+ );
252
+ }
217
253
  const rbacScopeCurrent = await this.bean.rbacScope.current();
218
254
  return rbacScopeCurrent.permissionProjection();
219
255
  } catch (err: any) {
220
256
  if ([401, 403].includes(err?.code)) return false;
221
257
  throw err;
222
258
  } finally {
223
- if (rbacDecisionHadOwnProperty) {
224
- setRbacDecision(ctx, rbacDecisionPrevious);
259
+ if (rbacDecisionDescriptorPrevious) {
260
+ Object.defineProperty(ctx, SymbolRbacDecision, rbacDecisionDescriptorPrevious);
225
261
  } else {
226
262
  clearRbacDecision(ctx);
227
263
  }
228
- ctx.route = routePrevious;
264
+ if (routeDescriptorPrevious) {
265
+ Object.defineProperty(ctx, 'route', routeDescriptorPrevious);
266
+ } else {
267
+ delete ctx.route;
268
+ }
269
+ // Vona's inherited innerAccess accessor owns its value in a context symbol.
229
270
  ctx.innerAccess = innerAccessPrevious;
271
+ if (innerAccessDescriptorPrevious) {
272
+ Object.defineProperty(ctx, 'innerAccess', innerAccessDescriptorPrevious);
273
+ } else {
274
+ delete ctx.innerAccess;
275
+ }
230
276
  }
231
277
  }
232
278
  }
@@ -1,10 +1,14 @@
1
+ import type { VonaContext } from 'vona';
1
2
  import type { ICachingActionKeyInfo } from 'vona-module-a-caching';
2
3
  import type { IRbacPolicyDecision } from 'vona-module-a-rbac';
4
+ import type { ContextRoute } from 'vona-module-a-web';
3
5
 
4
6
  import assert from 'node:assert';
5
7
  import { describe, it } from 'node:test';
6
- import { beanFullNameFromOnionName } from 'vona';
8
+ import { appResource, beanFullNameFromOnionName } from 'vona';
9
+ import { app } from 'vona-mock';
7
10
  import { getRbacDecision, setRbacDecision, SymbolRbacDecision } from 'vona-module-a-rbac';
11
+ import { getCacheControllerRoutes, recordResourceNameToRoutePath } from 'vona-module-a-web';
8
12
 
9
13
  import { BeanPermission } from '../src/bean/bean.permission.ts';
10
14
 
@@ -51,6 +55,64 @@ function createCachePermission(clearedCacheNames: string[]) {
51
55
  return permission;
52
56
  }
53
57
 
58
+ const BeanFullNameGuardRbac = beanFullNameFromOnionName('a-rbac:rbac', 'guard');
59
+ const GuardResource = 'test-rest:product';
60
+
61
+ async function withGuardPermission(
62
+ guard: (ctx: VonaContext) => Promise<boolean | void>,
63
+ callback: (fixture: {
64
+ permission: BeanPermission;
65
+ ctx: VonaContext;
66
+ passport: { isAuthenticated: boolean; isAccountActive: boolean; isActivated: boolean };
67
+ action: IRbacPolicyDecision['action'];
68
+ }) => Promise<void>,
69
+ rbac = false,
70
+ ) {
71
+ await app.bean.executor.mockCtx(async () => {
72
+ const controller = recordResourceNameToRoutePath[GuardResource].controller;
73
+ const controllerBeanFullName = appResource.getBean(controller)!.beanFullName;
74
+ const route = {
75
+ controller,
76
+ controllerBeanFullName,
77
+ action: 'create',
78
+ route: { meta: rbac ? { [BeanFullNameGuardRbac]: {} } : {} },
79
+ } as ContextRoute;
80
+ const action: IRbacPolicyDecision['action'] = {
81
+ actionKey: `${controllerBeanFullName}#create`,
82
+ actionInheritKey: `${controllerBeanFullName}#update`,
83
+ controllerBeanFullName,
84
+ action: 'create',
85
+ route,
86
+ options: {},
87
+ };
88
+ const unexpected = () =>
89
+ assert.fail('guard-only checks must not use permission retrieval or scope projection');
90
+ const fixtureApp = {
91
+ meta: {},
92
+ bean: { onion: { guard: { compose: () => guard } } },
93
+ };
94
+ getCacheControllerRoutes(fixtureApp as never)[controllerBeanFullName] = [route];
95
+ const passport = { isAuthenticated: true, isAccountActive: true, isActivated: true };
96
+ const permission = Object.create(BeanPermission.prototype) as BeanPermission;
97
+ Object.defineProperties(permission, {
98
+ app: { value: fixtureApp },
99
+ ctx: { value: app.ctx },
100
+ bean: {
101
+ value: {
102
+ passport,
103
+ rbacCatalog: { getAction: () => action },
104
+ rbacScope: { current: unexpected },
105
+ },
106
+ },
107
+ scope: { value: { event: { retrievePermissionAction: { emit: unexpected } } } },
108
+ retrievePermissionAction: { value: unexpected },
109
+ retrievePermissionActionByRoles: { value: unexpected },
110
+ retrievePermissionActionByUser: { value: unexpected },
111
+ });
112
+ await callback({ permission, ctx: app.ctx, passport, action });
113
+ });
114
+ }
115
+
54
116
  describe('permission.test.ts', { concurrency: false }, () => {
55
117
  it('normalizes role cache keys by role identity', () => {
56
118
  const first = createPermission({
@@ -62,10 +124,10 @@ describe('permission.test.ts', { concurrency: false }, () => {
62
124
  roleIds: ['role-a', 'role-b'],
63
125
  });
64
126
 
65
- const info = cacheInfo('training-student:student', 'select');
127
+ const info = cacheInfo('test:resource', 'select');
66
128
  assert.equal(
67
129
  (first as any).retrievePermissionActionByRolesCacheKey(info),
68
- 'action:training-student:student:select:roles:role-a,role-b',
130
+ 'action:test:resource:select:roles:role-a,role-b',
69
131
  );
70
132
  assert.equal(
71
133
  (first as any).retrievePermissionActionByRolesCacheKey(info),
@@ -78,7 +140,7 @@ describe('permission.test.ts', { concurrency: false }, () => {
78
140
  userId: 'user-1',
79
141
  roleIds: ['role-a'],
80
142
  });
81
- const info = cacheInfo('training-student:student', 'select');
143
+ const info = cacheInfo('test:resource', 'select');
82
144
 
83
145
  assert.notEqual(
84
146
  (permission as any).retrievePermissionActionByRolesCacheKey(info),
@@ -100,10 +162,10 @@ describe('permission.test.ts', { concurrency: false }, () => {
100
162
  roleIds: ['role-b'],
101
163
  });
102
164
 
103
- const info = cacheInfo('training-student:student', 'select');
165
+ const info = cacheInfo('test:resource', 'select');
104
166
  assert.equal(
105
167
  (first as any).retrievePermissionActionByUserCacheKey(info),
106
- 'action:training-student:student:select:user:user-1:roles:role-a',
168
+ 'action:test:resource:select:user:user-1:roles:role-a',
107
169
  );
108
170
  assert.notEqual(
109
171
  (first as any).retrievePermissionActionByUserCacheKey(info),
@@ -152,24 +214,21 @@ describe('permission.test.ts', { concurrency: false }, () => {
152
214
  });
153
215
 
154
216
  assert.equal(
155
- await permission.retrievePermissionAction('training-student:student', 'ordinary'),
217
+ await permission.retrievePermissionAction('test:resource' as never, 'ordinary'),
156
218
  false,
157
219
  );
158
- assert.equal(
159
- await permission.retrievePermissionAction('training-student:student', 'rbac'),
160
- true,
161
- );
220
+ assert.equal(await permission.retrievePermissionAction('test:resource' as never, 'rbac'), true);
162
221
  assert.deepEqual(calls, ['roles', 'user']);
163
222
  });
164
223
 
165
224
  it('isolates resource permission snapshots by user', () => {
166
225
  const first = createPermission({ userId: 'user-1' });
167
226
  const otherUser = createPermission({ userId: 'user-2' });
168
- const info = cacheInfo('training-student:student');
227
+ const info = cacheInfo('test:resource');
169
228
 
170
229
  assert.equal(
171
230
  (first as any).retrievePermissionsCacheKey(info),
172
- 'user:training-student:student:user:user-1',
231
+ 'user:test:resource:user:user-1',
173
232
  );
174
233
  assert.notEqual(
175
234
  (first as any).retrievePermissionsCacheKey(info),
@@ -307,6 +366,159 @@ describe('permission.test.ts', { concurrency: false }, () => {
307
366
  assert.equal(ctx.innerAccess, true);
308
367
  });
309
368
 
369
+ it('checks current guards without permission events, result caches, or projection', async () => {
370
+ let allowed = true;
371
+ let calls = 0;
372
+ await withGuardPermission(
373
+ async () => {
374
+ calls++;
375
+ return allowed;
376
+ },
377
+ async ({ permission }) => {
378
+ assert.equal(await permission.checkPermissionActionGuards(GuardResource, 'create'), true);
379
+ allowed = false;
380
+ assert.equal(await permission.checkPermissionActionGuards(GuardResource, 'create'), false);
381
+ assert.equal(calls, 2);
382
+ },
383
+ );
384
+ });
385
+
386
+ it('fails closed before guards for unknown routes and Passport admission failures', async () => {
387
+ await withGuardPermission(
388
+ async () => assert.fail('guards must not run before admission'),
389
+ async ({ permission, passport }) => {
390
+ assert.equal(
391
+ await permission.checkPermissionActionGuards('test:missing' as never, 'create'),
392
+ false,
393
+ );
394
+ assert.equal(await permission.checkPermissionActionGuards(GuardResource, 'missing'), false);
395
+ passport.isAuthenticated = false;
396
+ assert.equal(await permission.checkPermissionActionGuards(GuardResource, 'create'), false);
397
+ },
398
+ );
399
+ });
400
+
401
+ it('converts guard authorization errors to false and propagates unexpected errors', async () => {
402
+ let failure: Error;
403
+ await withGuardPermission(
404
+ async () => {
405
+ throw failure;
406
+ },
407
+ async ({ permission }) => {
408
+ for (const code of [401, 403]) {
409
+ failure = Object.assign(new Error('authorization failure'), { code });
410
+ assert.equal(
411
+ await permission.checkPermissionActionGuards(GuardResource, 'create'),
412
+ false,
413
+ );
414
+ }
415
+ failure = new Error('unexpected guard failure');
416
+ await assert.rejects(
417
+ () => permission.checkPermissionActionGuards(GuardResource, 'create'),
418
+ failure,
419
+ );
420
+ },
421
+ );
422
+ });
423
+
424
+ it('requires a fresh allowed RBAC decision matching the full catalog action identity', async () => {
425
+ let decision: IRbacPolicyDecision | undefined;
426
+ await withGuardPermission(
427
+ async ctx => {
428
+ assert.equal(getRbacDecision(ctx), undefined);
429
+ if (decision) setRbacDecision(ctx, decision);
430
+ return true;
431
+ },
432
+ async ({ permission, ctx, action }) => {
433
+ const valid: IRbacPolicyDecision = {
434
+ allowed: true,
435
+ actionKey: action.actionKey,
436
+ action: { ...action },
437
+ terms: [{ dataScope: 'all' }],
438
+ };
439
+ setRbacDecision(ctx, valid);
440
+ assert.equal(await permission.checkPermissionActionGuards(GuardResource, 'create'), false);
441
+ assert.equal(getRbacDecision(ctx), valid);
442
+ for (const invalid of [
443
+ { ...valid, allowed: false },
444
+ { ...valid, actionKey: 'other#action' },
445
+ ...[
446
+ { actionKey: 'other#action' },
447
+ { actionInheritKey: undefined },
448
+ { controllerBeanFullName: 'other.controller' },
449
+ { action: 'other' },
450
+ ].map(mismatch => ({ ...valid, action: { ...action, ...mismatch } })),
451
+ ]) {
452
+ decision = invalid;
453
+ assert.equal(
454
+ await permission.checkPermissionActionGuards(GuardResource, 'create'),
455
+ false,
456
+ );
457
+ assert.equal(getRbacDecision(ctx), valid);
458
+ }
459
+ decision = valid;
460
+ assert.equal(await permission.checkPermissionActionGuards(GuardResource, 'create'), true);
461
+ assert.equal(getRbacDecision(ctx), valid);
462
+ delete ctx[SymbolRbacDecision];
463
+ assert.equal(await permission.checkPermissionActionGuards(GuardResource, 'create'), true);
464
+ assert.equal(Object.hasOwn(ctx, SymbolRbacDecision), false);
465
+ },
466
+ true,
467
+ );
468
+ });
469
+
470
+ it('restores property descriptors and inherited innerAccess after success and failure', async () => {
471
+ let failure: Error | undefined;
472
+ await withGuardPermission(
473
+ async ctx => {
474
+ assert.equal(ctx.route.action, 'create');
475
+ assert.equal(ctx.innerAccess, false);
476
+ assert.equal(getRbacDecision(ctx), undefined);
477
+ if (failure) throw failure;
478
+ return true;
479
+ },
480
+ async ({ permission, ctx, action }) => {
481
+ assert.equal(Object.hasOwn(ctx, 'innerAccess'), false);
482
+ ctx.innerAccess = true;
483
+ Object.defineProperty(ctx, 'route', {
484
+ value: { action: 'previous' },
485
+ writable: true,
486
+ configurable: true,
487
+ enumerable: false,
488
+ });
489
+ Object.defineProperty(ctx, SymbolRbacDecision, {
490
+ value: {
491
+ allowed: true,
492
+ actionKey: action.actionKey,
493
+ action,
494
+ terms: [{ dataScope: 'all' }],
495
+ },
496
+ writable: true,
497
+ configurable: true,
498
+ enumerable: false,
499
+ });
500
+ const descriptors = Object.getOwnPropertyDescriptors(ctx);
501
+ assert.equal(await permission.checkPermissionActionGuards(GuardResource, 'create'), true);
502
+ assert.deepEqual(Object.getOwnPropertyDescriptors(ctx), descriptors);
503
+ assert.equal(ctx.innerAccess, true);
504
+ failure = new Error('guard failure');
505
+ await assert.rejects(
506
+ () => permission.checkPermissionActionGuards(GuardResource, 'create'),
507
+ failure,
508
+ );
509
+ assert.deepEqual(Object.getOwnPropertyDescriptors(ctx), descriptors);
510
+ assert.equal(ctx.innerAccess, true);
511
+
512
+ delete (ctx as any).route;
513
+ delete ctx[SymbolRbacDecision];
514
+ const absentDescriptors = Object.getOwnPropertyDescriptors(ctx);
515
+ failure = undefined;
516
+ assert.equal(await permission.checkPermissionActionGuards(GuardResource, 'create'), true);
517
+ assert.deepEqual(Object.getOwnPropertyDescriptors(ctx), absentDescriptors);
518
+ },
519
+ );
520
+ });
521
+
310
522
  it('reduces an RBAC action projection to its final boolean result', async () => {
311
523
  const allowed = createPermission({});
312
524
  const denied = createPermission({});
@@ -333,8 +545,8 @@ describe('permission.test.ts', { concurrency: false }, () => {
333
545
  retrievePermissionAction: { value: async () => true },
334
546
  });
335
547
 
336
- assert.equal(await allowed.checkPermissionAction('training-student:student', 'select'), true);
337
- assert.equal(await denied.checkPermissionAction('training-student:student', 'select'), false);
338
- assert.equal(await legacy.checkPermissionAction('training-student:student', 'select'), true);
548
+ assert.equal(await allowed.checkPermissionAction('test:resource' as never, 'select'), true);
549
+ assert.equal(await denied.checkPermissionAction('test:resource' as never, 'select'), false);
550
+ assert.equal(await legacy.checkPermissionAction('test:resource' as never, 'select'), true);
339
551
  });
340
552
  });
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vona-module-a-ratelimit",
3
- "version": "5.1.3",
3
+ "version": "5.1.4",
4
4
  "description": "",
5
5
  "keywords": [
6
6
  "Vona Module"
@@ -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
 
@@ -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 { IRateLimitPolicy, IRateLimitResult } from '../types/rateLimit.ts';
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
- const result = (await cast(this.clientRedis).rateLimitAdmit(key, String(policy.windowMs))) as [
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 <= policy.limit,
25
- limit: policy.limit,
26
- remaining: Math.max(0, policy.limit - current),
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
  };
@@ -14,6 +14,11 @@ export interface IRateLimitPolicy {
14
14
  headers: boolean;
15
15
  }
16
16
 
17
+ export interface IRateLimitAggregatePolicy {
18
+ mode: RateLimitMode;
19
+ limit: number;
20
+ }
21
+
17
22
  export interface IRateLimitResult {
18
23
  allowed: boolean;
19
24
  limit: number;