cabloy 5.1.240 → 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.
Files changed (55) hide show
  1. package/.cabloy-version +1 -1
  2. package/CHANGELOG.md +24 -0
  3. package/package.json +1 -1
  4. package/repo-docs/backend/i18n-guide.md +109 -11
  5. package/repo-docs/backend/openapi-guide.md +18 -0
  6. package/repo-docs/frontend/environment-config-guide.md +43 -0
  7. package/repo-docs/frontend/module-scope.md +88 -0
  8. package/repo-docs/frontend/system-startup-guide.md +31 -0
  9. package/repo-e2e/specs/home-user-account.spec.ts +53 -8
  10. package/vona/packages-vona/vona/package.json +1 -1
  11. package/vona/pnpm-lock.yaml +17 -142
  12. package/vona/src/suite/a-commerce/modules/commerce-siteadmin/src/dto/operatorContext.ts +4 -1
  13. package/vona/src/suite/a-commerce/modules/commerce-siteadmin/src/service/operator.ts +1 -0
  14. package/vona/src/suite/a-commerce/modules/commerce-siteadmin/test/operatorAccess.test.ts +1 -0
  15. package/vona/src/suite/a-home/modules/home-user/src/bean/meta.version.ts +1 -0
  16. package/vona/src/suite/a-home/modules/home-user/src/controller/passport.ts +1 -0
  17. package/vona/src/suite/a-home/modules/home-user/src/dto/accountCurrent.ts +3 -0
  18. package/vona/src/suite/a-home/modules/home-user/src/dto/accountProfileUpdate.ts +9 -2
  19. package/vona/src/suite/a-home/modules/home-user/src/dto/passportUser.ts +3 -0
  20. package/vona/src/suite/a-home/modules/home-user/src/entity/user.ts +3 -0
  21. package/vona/src/suite/a-home/modules/home-user/src/service/account.ts +7 -3
  22. package/vona/src/suite/a-home/modules/home-user/src/service/userAdapter.ts +1 -0
  23. package/vona/src/suite/a-home/modules/home-user/test/account.test.ts +133 -8
  24. package/vona/src/suite/a-home/modules/home-user/test/accountOpenapi.test.ts +32 -5
  25. package/vona/src/suite/a-home/modules/home-user/test/passport.test.ts +159 -1
  26. package/vona/src/suite-vendor/a-cabloy/modules/a-rbac/package.json +1 -1
  27. package/vona/src/suite-vendor/a-cabloy/package.json +2 -2
  28. package/vona/src/suite-vendor/a-captcha/modules/a-captcha/package.json +1 -1
  29. package/vona/src/suite-vendor/a-captcha/modules/a-captcha/src/bean/bean.captcha.ts +271 -137
  30. package/vona/src/suite-vendor/a-captcha/modules/a-captcha/src/bean/cacheRedis.captcha.ts +199 -2
  31. package/vona/src/suite-vendor/a-captcha/modules/a-captcha/src/lib/captchaLua.ts +90 -0
  32. package/vona/src/suite-vendor/a-captcha/modules/a-captcha/src/types/captcha.ts +15 -1
  33. package/vona/src/suite-vendor/a-captcha/modules/a-captcha/src/types/captchaProvider.ts +38 -1
  34. package/vona/src/suite-vendor/a-captcha/package.json +1 -1
  35. package/vona/src/suite-vendor/a-vona/modules/a-permission/package.json +1 -1
  36. package/vona/src/suite-vendor/a-vona/modules/a-permission/src/bean/bean.permission.ts +54 -8
  37. package/vona/src/suite-vendor/a-vona/modules/a-permission/test/permission.test.ts +12 -15
  38. package/vona/src/suite-vendor/a-vona/modules/a-ratelimit/package.json +1 -1
  39. package/vona/src/suite-vendor/a-vona/modules/a-user/package.json +1 -1
  40. package/vona/src/suite-vendor/a-vona/modules/a-user/src/types/user.ts +1 -0
  41. package/vona/src/suite-vendor/a-vona/package.json +1 -1
  42. package/zova/src/suite/a-commerce/modules/commerce-siteadmin/src/api/openapi/schemas.ts +68 -52
  43. package/zova/src/suite/a-commerce/modules/commerce-siteadmin/src/api/openapi/types.ts +530 -344
  44. package/zova/src/suite/a-commerce/modules/commerce-siteadmin/src/page/dashboard/controller.tsx +7 -1
  45. package/zova/src/suite/a-home/modules/home-api/src/api/openapi/types.ts +6 -1
  46. package/zova/src/suite/a-home/modules/home-base/src/config/locale/en-us.ts +1 -0
  47. package/zova/src/suite/a-home/modules/home-base/src/config/locale/zh-cn.ts +1 -0
  48. package/zova/src/suite/a-home/modules/home-layoutadmin/src/component/layoutAdmin/render.user.tsx +1 -1
  49. package/zova/src/suite/a-home/modules/home-layoutweb/src/component/layoutWeb/render.user.tsx +3 -1
  50. package/zova/src/suite/a-home/modules/home-passport/src/model/passport.ts +4 -0
  51. package/zova/src/suite/a-home/modules/home-user/src/config/locale/en-us.ts +1 -0
  52. package/zova/src/suite/a-home/modules/home-user/src/config/locale/zh-cn.ts +1 -0
  53. package/zova/src/suite/a-home/modules/home-user/src/model/account.ts +2 -1
  54. package/zova/src/suite/a-home/modules/home-user/src/page/account/controller.tsx +17 -3
  55. package/vona/src/suite-vendor/a-vona/modules/a-ratelimit/test/aggregate.test.ts +0 -247
@@ -1,12 +1,209 @@
1
+ import { createHash } from 'node:crypto';
1
2
  import { ServiceCacheRedisBase, CacheRedis } from 'vona-module-a-cache';
2
3
 
3
4
  import type { ICaptchaDataCache } from '../types/captcha.ts';
5
+ import type { ICaptchaAuthorization } from '../types/captchaProvider.ts';
6
+
7
+ import {
8
+ captchaCasLua,
9
+ captchaConsumeLua,
10
+ captchaFinalizeLua,
11
+ captchaReserveLua,
12
+ } from '../lib/captchaLua.ts';
4
13
 
5
14
  export type TCacheRedisCaptchaKey = string;
6
15
  export type TCacheRedisCaptchaData = ICaptchaDataCache;
7
16
 
8
- @CacheRedis({ disableTransactionCompensate: true })
17
+ export interface ICaptchaSnapshot {
18
+ raw: string;
19
+ data: ICaptchaDataCache;
20
+ }
21
+
22
+ @CacheRedis({ disableTransactionCompensate: true, updateAgeOnGet: false })
9
23
  export class CacheRedisCaptcha extends ServiceCacheRedisBase<
10
24
  TCacheRedisCaptchaKey,
11
25
  TCacheRedisCaptchaData
12
- > {}
26
+ > {
27
+ async snapshot(id: string): Promise<ICaptchaSnapshot | undefined> {
28
+ const { redis, keys } = this._redisKeys([id]);
29
+ const raw = await redis.get(keys[0]);
30
+ return raw ? { raw, data: JSON.parse(raw) } : undefined;
31
+ }
32
+
33
+ async createImage(id: string, data: ICaptchaDataCache, ttl: number) {
34
+ this._validateTtl(ttl);
35
+ const { redis, keys } = this._redisKeys([id]);
36
+ return (await redis.set(keys[0], JSON.stringify(data), 'PX', ttl, 'NX')) === 'OK';
37
+ }
38
+
39
+ async compareAndSet(
40
+ id: string,
41
+ snapshot: ICaptchaSnapshot,
42
+ data?: ICaptchaDataCache,
43
+ ttl?: number,
44
+ ) {
45
+ if (ttl !== undefined) this._validateTtl(ttl);
46
+ const result = await this._eval(
47
+ captchaCasLua,
48
+ [id],
49
+ [snapshot.raw, data === undefined ? '' : JSON.stringify(data), ttl ?? 0],
50
+ );
51
+ return result === 1;
52
+ }
53
+
54
+ boundPrefix() {
55
+ const instance = createHash('sha256').update(String(this.__getInstanceIdScope())).digest('hex');
56
+ return `{captcha:${instance}}:`;
57
+ }
58
+
59
+ /** Opaque, instance-local cache keys, also available for precise test cleanup. */
60
+ boundKeys(
61
+ authorization: Pick<ICaptchaAuthorization, 'scope' | 'ip' | 'recipientLimits' | 'ipLimits'>,
62
+ ) {
63
+ const prefix = this.boundPrefix();
64
+ return {
65
+ pointer: `${prefix}current:${authorization.scope}`,
66
+ cooldown: `${prefix}cooldown:${authorization.scope}`,
67
+ budgets: [
68
+ ...authorization.recipientLimits.map(
69
+ limit => `${prefix}recipient:${authorization.scope}:${limit.windowMs}`,
70
+ ),
71
+ ...authorization.ipLimits.map(limit => `${prefix}ip:${authorization.ip}:${limit.windowMs}`),
72
+ ],
73
+ };
74
+ }
75
+
76
+ async reserveBound(id: string, data: ICaptchaDataCache, authorization: ICaptchaAuthorization) {
77
+ this._validateAuthorization(authorization);
78
+ this._validateBoundId(id);
79
+ const keys = this.boundKeys(authorization);
80
+ const policy = {
81
+ ttl: authorization.ttl,
82
+ cooldown: authorization.cooldown,
83
+ budgets: [...authorization.recipientLimits, ...authorization.ipLimits],
84
+ };
85
+ const result = (await this._eval(
86
+ captchaReserveLua,
87
+ [id, keys.pointer, keys.cooldown, ...keys.budgets],
88
+ [id, JSON.stringify(policy), JSON.stringify(data)],
89
+ )) as [number, string, number, number];
90
+ if (result[0] === -1) throw new Error('captcha identity collision');
91
+ if (result[0] === 0) {
92
+ return { reserved: false as const, retryAt: result[2], resendAt: result[3] };
93
+ }
94
+ if (result[0] !== 1 || !result[1]) throw new Error('invalid captcha reservation');
95
+ return {
96
+ reserved: true as const,
97
+ snapshot: { raw: result[1], data: JSON.parse(result[1]) as ICaptchaDataCache },
98
+ };
99
+ }
100
+
101
+ async finalizeBound(id: string, snapshot: ICaptchaSnapshot, data?: ICaptchaDataCache) {
102
+ this._validateBoundId(id);
103
+ const scope = snapshot.data.bound!.scope;
104
+ const pointer = `${this.boundPrefix()}current:${scope}`;
105
+ return (
106
+ (await this._eval(
107
+ captchaFinalizeLua,
108
+ [id, pointer],
109
+ [snapshot.raw, id, data === undefined ? '' : JSON.stringify(data)],
110
+ )) === 1
111
+ );
112
+ }
113
+
114
+ /** -1 asks the verifier to reread a competing wrong-attempt update. */
115
+ async consumeBound(id: string, snapshot: ICaptchaSnapshot, verified: boolean) {
116
+ this._validateBoundId(id);
117
+ const bound = snapshot.data.bound!;
118
+ const pointer = `${this.boundPrefix()}current:${bound.scope}`;
119
+ const next = {
120
+ ...snapshot.data,
121
+ bound: {
122
+ ...bound,
123
+ attemptsRemaining: bound.attemptsRemaining - 1,
124
+ version: bound.version + 1,
125
+ },
126
+ };
127
+ return (await this._eval(
128
+ captchaConsumeLua,
129
+ [id, pointer],
130
+ [snapshot.raw, id, verified ? '1' : '0', JSON.stringify(next)],
131
+ )) as number;
132
+ }
133
+
134
+ validateAuthorization(authorization: ICaptchaAuthorization) {
135
+ this._validateAuthorization(authorization);
136
+ }
137
+
138
+ private _validateAuthorization(authorization: ICaptchaAuthorization) {
139
+ if (!authorization || typeof authorization !== 'object')
140
+ throw new Error('invalid captcha authorization');
141
+ const { binding, scope, ip, ttl, cooldown, maxAttempts, recipientLimits, ipLimits } =
142
+ authorization;
143
+ const text = (value: unknown) =>
144
+ typeof value === 'string' && value.length > 0 && value.length <= 512;
145
+ const hash = (value: unknown) => typeof value === 'string' && /^[a-f0-9]{64}$/.test(value);
146
+ if (
147
+ !binding ||
148
+ !text(binding.recipient) ||
149
+ !text(binding.purpose) ||
150
+ (binding.subject !== undefined && !text(binding.subject)) ||
151
+ !hash(scope) ||
152
+ !hash(ip)
153
+ ) {
154
+ throw new Error('invalid captcha binding');
155
+ }
156
+ this._validateTtl(ttl);
157
+ if (
158
+ !Number.isSafeInteger(cooldown) ||
159
+ cooldown < 0 ||
160
+ cooldown > Number.MAX_SAFE_INTEGER - Date.now() ||
161
+ maxAttempts !== 5
162
+ ) {
163
+ throw new Error('invalid captcha policy');
164
+ }
165
+ for (const limits of [recipientLimits, ipLimits]) {
166
+ if (!Array.isArray(limits) || limits.length === 0 || limits.length > 16)
167
+ throw new Error('invalid captcha limits');
168
+ const windows = new Set<number>();
169
+ for (const limit of limits) {
170
+ if (
171
+ !limit ||
172
+ !Number.isSafeInteger(limit.limit) ||
173
+ limit.limit <= 0 ||
174
+ windows.has(limit.windowMs)
175
+ ) {
176
+ throw new Error('invalid captcha limit');
177
+ }
178
+ this._validateTtl(limit.windowMs);
179
+ windows.add(limit.windowMs);
180
+ }
181
+ }
182
+ }
183
+
184
+ private _validateTtl(ttl: number) {
185
+ if (!Number.isSafeInteger(ttl) || ttl <= 0 || ttl > Number.MAX_SAFE_INTEGER - Date.now()) {
186
+ throw new Error('invalid captcha ttl');
187
+ }
188
+ }
189
+
190
+ private _validateBoundId(id: string) {
191
+ if (
192
+ !id.startsWith(this.boundPrefix()) ||
193
+ !/^[a-f0-9-]{36}$/.test(id.slice(this.boundPrefix().length))
194
+ ) {
195
+ throw new Error('invalid bound captcha identity');
196
+ }
197
+ }
198
+
199
+ private _redisKeys(ids: string[]) {
200
+ const redis = this.__cacheInstance;
201
+ if (!redis) throw new Error('captcha cache not enabled');
202
+ return { redis, keys: ids.map(id => this.getRedisKey(id)!) };
203
+ }
204
+
205
+ private async _eval(lua: string, ids: string[], args: Array<string | number>) {
206
+ const { redis, keys } = this._redisKeys(ids);
207
+ return await redis.eval(lua, keys.length, ...keys, ...args);
208
+ }
209
+ }
@@ -0,0 +1,90 @@
1
+ // All keys are supplied through the existing captcha cache owner. Bound keys share
2
+ // one instance hash tag; image transitions use their original single key.
3
+ export const captchaCasLua = `
4
+ local current = redis.call('GET', KEYS[1])
5
+ if current ~= ARGV[1] then return 0 end
6
+ if ARGV[2] == '' then
7
+ redis.call('DEL', KEYS[1])
8
+ else
9
+ local ttl = tonumber(ARGV[3])
10
+ if ttl > 0 then
11
+ redis.call('SET', KEYS[1], ARGV[2], 'PX', ttl)
12
+ else
13
+ redis.call('SET', KEYS[1], ARGV[2], 'KEEPTTL')
14
+ end
15
+ end
16
+ return 1
17
+ `;
18
+
19
+ export const captchaReserveLua = `
20
+ local time = redis.call('TIME')
21
+ local now = tonumber(time[1]) * 1000 + math.floor(tonumber(time[2]) / 1000)
22
+ local policy = cjson.decode(ARGV[2])
23
+ local cooldown = tonumber(redis.call('GET', KEYS[3]) or '0')
24
+ local retryAt = cooldown
25
+ local counters = {}
26
+ for i, budget in ipairs(policy.budgets) do
27
+ local value = tonumber(redis.call('GET', KEYS[i + 3]) or '0')
28
+ local deadline = (math.floor(now / budget.windowMs) + 1) * budget.windowMs
29
+ local remaining = redis.call('PTTL', KEYS[i + 3])
30
+ if remaining <= 0 then value = 0 end
31
+ counters[i] = { value = value, deadline = deadline }
32
+ if value >= budget.limit and deadline > retryAt then retryAt = deadline end
33
+ end
34
+ if retryAt > now then return { 0, '', retryAt, cooldown } end
35
+ if redis.call('EXISTS', KEYS[1]) == 1 then return { -1 } end
36
+ local data = cjson.decode(ARGV[3])
37
+ data.bound.expiresAt = now + policy.ttl
38
+ data.bound.resendAt = now + policy.cooldown
39
+ local raw = cjson.encode(data)
40
+ for i, counter in ipairs(counters) do
41
+ redis.call('SET', KEYS[i + 3], tostring(counter.value + 1))
42
+ redis.call('PEXPIREAT', KEYS[i + 3], counter.deadline)
43
+ end
44
+ redis.call('SET', KEYS[1], raw)
45
+ redis.call('PEXPIREAT', KEYS[1], data.bound.expiresAt)
46
+ redis.call('SET', KEYS[2], ARGV[1])
47
+ redis.call('PEXPIREAT', KEYS[2], math.max(data.bound.expiresAt, data.bound.resendAt))
48
+ if policy.cooldown > 0 then
49
+ redis.call('SET', KEYS[3], tostring(data.bound.resendAt))
50
+ redis.call('PEXPIREAT', KEYS[3], data.bound.resendAt)
51
+ end
52
+ return { 1, raw, data.bound.expiresAt, data.bound.resendAt }
53
+ `;
54
+
55
+ export const captchaFinalizeLua = `
56
+ local current = redis.call('GET', KEYS[1])
57
+ if current ~= ARGV[1] or redis.call('GET', KEYS[2]) ~= ARGV[2] then return 0 end
58
+ local data = cjson.decode(current)
59
+ local time = redis.call('TIME')
60
+ local now = tonumber(time[1]) * 1000 + math.floor(tonumber(time[2]) / 1000)
61
+ if data.bound.state ~= 'pending' or data.bound.expiresAt <= now then return 0 end
62
+ if ARGV[3] == '' then
63
+ redis.call('DEL', KEYS[1])
64
+ else
65
+ redis.call('SET', KEYS[1], ARGV[3])
66
+ redis.call('PEXPIREAT', KEYS[1], data.bound.expiresAt)
67
+ end
68
+ return 1
69
+ `;
70
+
71
+ // Verification happens in the provider. Only the caller holding this exact active
72
+ // snapshot can commit its verdict, so success cannot race refresh or another use.
73
+ export const captchaConsumeLua = `
74
+ local current = redis.call('GET', KEYS[1])
75
+ if current ~= ARGV[1] then return -1 end
76
+ if redis.call('GET', KEYS[2]) ~= ARGV[2] then return 0 end
77
+ local data = cjson.decode(current)
78
+ local time = redis.call('TIME')
79
+ local now = tonumber(time[1]) * 1000 + math.floor(tonumber(time[2]) / 1000)
80
+ if data.bound.state ~= 'active' or data.bound.expiresAt <= now or data.bound.attemptsRemaining <= 0 then
81
+ return 0
82
+ end
83
+ if ARGV[3] == '1' or data.bound.attemptsRemaining == 1 then
84
+ redis.call('DEL', KEYS[1])
85
+ else
86
+ redis.call('SET', KEYS[1], ARGV[4])
87
+ redis.call('PEXPIREAT', KEYS[1], data.bound.expiresAt)
88
+ end
89
+ return 1
90
+ `;
@@ -1,4 +1,4 @@
1
- import type { ICaptchaProviderRecord } from './captchaProvider.ts';
1
+ import type { ICaptchaBinding, ICaptchaProviderRecord } from './captchaProvider.ts';
2
2
  import type { ICaptchaSceneRecord } from './captchaScene.ts';
3
3
  import 'vona';
4
4
 
@@ -7,6 +7,16 @@ export interface ICaptchaDataCache {
7
7
  provider: keyof ICaptchaProviderRecord;
8
8
  token?: unknown;
9
9
  token2?: string;
10
+ revision?: string;
11
+ bound?: {
12
+ binding: ICaptchaBinding;
13
+ scope: string;
14
+ state: 'pending' | 'active';
15
+ expiresAt: number;
16
+ resendAt: number;
17
+ attemptsRemaining: number;
18
+ version: number;
19
+ };
10
20
  }
11
21
 
12
22
  export interface ICaptchaData {
@@ -14,6 +24,10 @@ export interface ICaptchaData {
14
24
  provider: keyof ICaptchaProviderRecord;
15
25
  token?: unknown;
16
26
  payload: unknown;
27
+ status?: 'accepted' | 'rejected' | 'unknown' | 'limited';
28
+ expiresAt?: number;
29
+ resendAt?: number;
30
+ retryAt?: number;
17
31
  }
18
32
 
19
33
  declare module 'vona' {
@@ -1,10 +1,44 @@
1
1
  import type { OmitNever } from 'vona';
2
2
  import type { ServiceOnion, TypeOnionOptionsEnableSimple } from 'vona-module-a-onion';
3
3
 
4
+ import type { ICaptchaSceneRecord } from './captchaScene.ts';
5
+
4
6
  export interface ICaptchaProviderRecord {}
5
7
 
8
+ export interface ICaptchaBinding {
9
+ recipient: string;
10
+ purpose: string;
11
+ subject?: string;
12
+ }
13
+
14
+ export interface ICaptchaLimit {
15
+ limit: number;
16
+ windowMs: number;
17
+ }
18
+
19
+ /** Server-authorized policy, never populated from generic captcha request JSON. */
20
+ export interface ICaptchaAuthorization {
21
+ binding: ICaptchaBinding;
22
+ scope: string;
23
+ ip: string;
24
+ ttl: number;
25
+ cooldown: number;
26
+ maxAttempts: number;
27
+ recipientLimits: ICaptchaLimit[];
28
+ ipLimits: ICaptchaLimit[];
29
+ }
30
+
6
31
  export interface ICaptchaProviderExecute<TOKEN = unknown, PAYLOAD = unknown> {
7
- create(options: IDecoratorCaptchaProviderOptions): Promise<ICaptchaProviderData<TOKEN, PAYLOAD>>;
32
+ readonly bound?: boolean;
33
+ authorize?(
34
+ context: unknown,
35
+ operation: 'issue' | 'consume',
36
+ sceneName: keyof ICaptchaSceneRecord,
37
+ ): ICaptchaAuthorization | undefined | Promise<ICaptchaAuthorization | undefined>;
38
+ create(
39
+ options: IDecoratorCaptchaProviderOptions,
40
+ context?: unknown,
41
+ ): Promise<ICaptchaProviderData<TOKEN, PAYLOAD>>;
8
42
  verify(
9
43
  token: TOKEN,
10
44
  tokenInput: TOKEN,
@@ -15,6 +49,9 @@ export interface ICaptchaProviderExecute<TOKEN = unknown, PAYLOAD = unknown> {
15
49
  export interface ICaptchaProviderData<TOKEN = unknown, PAYLOAD = unknown> {
16
50
  token: TOKEN;
17
51
  payload: PAYLOAD;
52
+ outcome?: 'accepted' | 'rejected' | 'unknown';
53
+ /** Bound providers alone decide whether their actual transport may expose the token. */
54
+ exposeToken?: boolean;
18
55
  }
19
56
 
20
57
  export interface IDecoratorCaptchaProviderOptions extends TypeOnionOptionsEnableSimple {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vona-suite-a-captcha",
3
- "version": "5.1.19",
3
+ "version": "5.1.20",
4
4
  "gitHead": "a79189b882c17af5911573896a781bbb0046d37d",
5
5
  "description": "",
6
6
  "author": "",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vona-module-a-permission",
3
- "version": "5.1.33",
3
+ "version": "5.1.35",
4
4
  "gitHead": "a79189b882c17af5911573896a781bbb0046d37d",
5
5
  "description": "",
6
6
  "keywords": [
@@ -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
  }
@@ -62,10 +62,10 @@ describe('permission.test.ts', { concurrency: false }, () => {
62
62
  roleIds: ['role-a', 'role-b'],
63
63
  });
64
64
 
65
- const info = cacheInfo('training-student:student', 'select');
65
+ const info = cacheInfo('test:resource', 'select');
66
66
  assert.equal(
67
67
  (first as any).retrievePermissionActionByRolesCacheKey(info),
68
- 'action:training-student:student:select:roles:role-a,role-b',
68
+ 'action:test:resource:select:roles:role-a,role-b',
69
69
  );
70
70
  assert.equal(
71
71
  (first as any).retrievePermissionActionByRolesCacheKey(info),
@@ -78,7 +78,7 @@ describe('permission.test.ts', { concurrency: false }, () => {
78
78
  userId: 'user-1',
79
79
  roleIds: ['role-a'],
80
80
  });
81
- const info = cacheInfo('training-student:student', 'select');
81
+ const info = cacheInfo('test:resource', 'select');
82
82
 
83
83
  assert.notEqual(
84
84
  (permission as any).retrievePermissionActionByRolesCacheKey(info),
@@ -100,10 +100,10 @@ describe('permission.test.ts', { concurrency: false }, () => {
100
100
  roleIds: ['role-b'],
101
101
  });
102
102
 
103
- const info = cacheInfo('training-student:student', 'select');
103
+ const info = cacheInfo('test:resource', 'select');
104
104
  assert.equal(
105
105
  (first as any).retrievePermissionActionByUserCacheKey(info),
106
- 'action:training-student:student:select:user:user-1:roles:role-a',
106
+ 'action:test:resource:select:user:user-1:roles:role-a',
107
107
  );
108
108
  assert.notEqual(
109
109
  (first as any).retrievePermissionActionByUserCacheKey(info),
@@ -152,24 +152,21 @@ describe('permission.test.ts', { concurrency: false }, () => {
152
152
  });
153
153
 
154
154
  assert.equal(
155
- await permission.retrievePermissionAction('training-student:student', 'ordinary'),
155
+ await permission.retrievePermissionAction('test:resource' as never, 'ordinary'),
156
156
  false,
157
157
  );
158
- assert.equal(
159
- await permission.retrievePermissionAction('training-student:student', 'rbac'),
160
- true,
161
- );
158
+ assert.equal(await permission.retrievePermissionAction('test:resource' as never, 'rbac'), true);
162
159
  assert.deepEqual(calls, ['roles', 'user']);
163
160
  });
164
161
 
165
162
  it('isolates resource permission snapshots by user', () => {
166
163
  const first = createPermission({ userId: 'user-1' });
167
164
  const otherUser = createPermission({ userId: 'user-2' });
168
- const info = cacheInfo('training-student:student');
165
+ const info = cacheInfo('test:resource');
169
166
 
170
167
  assert.equal(
171
168
  (first as any).retrievePermissionsCacheKey(info),
172
- 'user:training-student:student:user:user-1',
169
+ 'user:test:resource:user:user-1',
173
170
  );
174
171
  assert.notEqual(
175
172
  (first as any).retrievePermissionsCacheKey(info),
@@ -333,8 +330,8 @@ describe('permission.test.ts', { concurrency: false }, () => {
333
330
  retrievePermissionAction: { value: async () => true },
334
331
  });
335
332
 
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);
333
+ assert.equal(await allowed.checkPermissionAction('test:resource' as never, 'select'), true);
334
+ assert.equal(await denied.checkPermissionAction('test:resource' as never, 'select'), false);
335
+ assert.equal(await legacy.checkPermissionAction('test:resource' as never, 'select'), true);
339
336
  });
340
337
  });
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vona-module-a-ratelimit",
3
- "version": "5.1.4",
3
+ "version": "5.1.5",
4
4
  "description": "",
5
5
  "keywords": [
6
6
  "Vona Module"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vona-module-a-user",
3
- "version": "5.1.25",
3
+ "version": "5.1.26",
4
4
  "gitHead": "a79189b882c17af5911573896a781bbb0046d37d",
5
5
  "description": "",
6
6
  "keywords": [
@@ -16,6 +16,7 @@ export interface IUserIdRecord {
16
16
  export interface IUser {
17
17
  id: TableIdentity;
18
18
  name: string;
19
+ displayName?: string;
19
20
  avatar?: string;
20
21
  email?: string;
21
22
  mobile?: string;
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vona-suite-a-vona",
3
- "version": "5.1.110",
3
+ "version": "5.1.112",
4
4
  "gitHead": "a79189b882c17af5911573896a781bbb0046d37d",
5
5
  "description": "",
6
6
  "author": "",