cabloy 5.1.241 → 5.1.243
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/.github/workflows/vona-test-sqlite3.yml +1 -1
- package/CHANGELOG.md +24 -0
- package/package.json +1 -1
- package/repo-docs/backend/demonstration-mode-guide.md +5 -2
- 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-cli/cli/package.json +1 -1
- package/vona/packages-cli/cli-set-api/package.json +1 -1
- package/vona/packages-cli/cli-set-api/src/lib/bean/cli.bin.build.ts +11 -0
- package/vona/packages-cli/cli-set-api/test/binBuildFreshInputs.test.ts +152 -0
- package/vona/packages-vona/vona/package.json +1 -1
- package/vona/packages-vona/vona-core/package.json +1 -1
- package/vona/packages-vona/vona-core/src/lib/core/application.ts +2 -2
- package/vona/packages-vona/vona-mock/package.json +1 -1
- package/vona/pnpm-lock.yaml +1 -1
- package/vona/src/suite/a-demo/modules/demo-demonstration/src/bean/guard.demonstration.ts +1 -1
- package/vona/src/suite/a-demo/modules/demo-demonstration/test/guardDemonstration.test.ts +4 -3
- 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-core/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,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
|
-
|
|
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
|
-
|
|
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,14 +1,10 @@
|
|
|
1
|
-
import type { VonaContext } from 'vona';
|
|
2
1
|
import type { ICachingActionKeyInfo } from 'vona-module-a-caching';
|
|
3
2
|
import type { IRbacPolicyDecision } from 'vona-module-a-rbac';
|
|
4
|
-
import type { ContextRoute } from 'vona-module-a-web';
|
|
5
3
|
|
|
6
4
|
import assert from 'node:assert';
|
|
7
5
|
import { describe, it } from 'node:test';
|
|
8
|
-
import {
|
|
9
|
-
import { app } from 'vona-mock';
|
|
6
|
+
import { beanFullNameFromOnionName } from 'vona';
|
|
10
7
|
import { getRbacDecision, setRbacDecision, SymbolRbacDecision } from 'vona-module-a-rbac';
|
|
11
|
-
import { getCacheControllerRoutes, recordResourceNameToRoutePath } from 'vona-module-a-web';
|
|
12
8
|
|
|
13
9
|
import { BeanPermission } from '../src/bean/bean.permission.ts';
|
|
14
10
|
|
|
@@ -55,64 +51,6 @@ function createCachePermission(clearedCacheNames: string[]) {
|
|
|
55
51
|
return permission;
|
|
56
52
|
}
|
|
57
53
|
|
|
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
|
-
|
|
116
54
|
describe('permission.test.ts', { concurrency: false }, () => {
|
|
117
55
|
it('normalizes role cache keys by role identity', () => {
|
|
118
56
|
const first = createPermission({
|
|
@@ -366,159 +304,6 @@ describe('permission.test.ts', { concurrency: false }, () => {
|
|
|
366
304
|
assert.equal(ctx.innerAccess, true);
|
|
367
305
|
});
|
|
368
306
|
|
|
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
|
-
|
|
522
307
|
it('reduces an RBAC action projection to its final boolean result', async () => {
|
|
523
308
|
const allowed = createPermission({});
|
|
524
309
|
const denied = createPermission({});
|