cabloy 5.1.187 → 5.1.189
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 +22 -0
- package/package.json +1 -1
- package/repo-docs/backend/runtime-and-flavors.md +1 -1
- package/repo-e2e/specs/markdown.spec.ts +46 -1
- package/vona/docker-compose-original/config/nginx/conf.d/nginx.conf +2 -2
- package/vona/packages-cli/cli/package.json +1 -1
- package/vona/packages-cli/cli-set-api/package.json +1 -1
- 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 +12 -5
- package/vona/packages-vona/vona-core/src/lib/core/context.ts +1 -1
- package/vona/packages-vona/vona-mock/package.json +1 -1
- package/vona/pnpm-lock.yaml +14 -136
- package/vona/src/suite/a-home/modules/home-user/src/service/account.ts +1 -1
- package/vona/src/suite/a-home/modules/home-user/test/accountPasswordReset.test.ts +24 -5
- package/vona/src/suite/a-home/modules/home-user/test/accountPasswordSet.test.ts +22 -5
- package/vona/src/suite-vendor/a-auth/modules/a-auth/package.json +1 -1
- package/vona/src/suite-vendor/a-auth/modules/a-auth/src/lib/utils.ts +42 -0
- package/vona/src/suite-vendor/a-auth/modules/a-auth/src/service/auth.ts +60 -2
- package/vona/src/suite-vendor/a-auth/modules/a-auth/test/authCallbackRedirect.test.ts +44 -0
- package/vona/src/suite-vendor/a-auth/modules/a-auth/test/oauthRequestContext.test.ts +171 -0
- package/vona/src/suite-vendor/a-auth/package.json +1 -1
- package/vona/src/suite-vendor/a-cabloy/modules/a-socket/package.json +1 -1
- package/vona/src/suite-vendor/a-cabloy/modules/a-socket/src/bean/socketConnection.cors.ts +1 -1
- package/vona/src/suite-vendor/a-cabloy/modules/a-ssr/package.json +1 -1
- package/vona/src/suite-vendor/a-cabloy/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-security/package.json +1 -1
- package/vona/src/suite-vendor/a-vona/modules/a-security/src/bean/middlewareSystem.cors.ts +1 -1
- package/vona/src/suite-vendor/a-vona/modules/a-version/package.json +1 -1
- package/vona/src/suite-vendor/a-vona/modules/a-version/src/lib/databaseTestName.ts +47 -0
- package/vona/src/suite-vendor/a-vona/modules/a-version/src/service/database.ts +8 -3
- package/vona/src/suite-vendor/a-vona/modules/a-version/test/databaseName.test.ts +72 -0
- package/vona/src/suite-vendor/a-vona/package.json +1 -1
- package/zova/packages-cli/cli/package.json +2 -2
- package/zova/packages-cli/cli-set-front/package.json +2 -2
- package/zova/packages-utils/zova-vite/package.json +1 -1
- package/zova/packages-utils/zova-vite/src/collect-css-ssr.ts +7 -5
- package/zova/pnpm-lock.yaml +16 -16
- package/zova/src/suite/a-home/modules/home-base/src/config/config.ts +0 -8
- package/zova/src/suite/a-home/modules/home-base/src/service/ssrLayout.ts +2 -3
- package/zova/src/suite/a-home/modules/home-layoutadmin/src/component/layoutAdmin/controller.tsx +1 -0
- package/zova/src/suite/a-home/modules/home-layoutadmin/src/component/layoutAdmin/render.sidebar.tsx +1 -4
- package/zova/src/suite/a-home/modules/home-layoutadmin/src/config/config.ts +4 -0
- package/zova/src/suite/a-home/modules/home-layoutweb/src/component/layoutWeb/controller.tsx +1 -0
- package/zova/src/suite/cabloy-basic/modules/basic-markdown/src/lib/richTextContentStyle.ts +16 -2
|
@@ -7,3 +7,45 @@ export function getStrategyOauth2Profile<T = IAuthUserProfile>(
|
|
|
7
7
|
): T {
|
|
8
8
|
return args[2];
|
|
9
9
|
}
|
|
10
|
+
|
|
11
|
+
export type TypeOauthRedirectKind =
|
|
12
|
+
| 'absolute'
|
|
13
|
+
| 'invalid'
|
|
14
|
+
| 'missing'
|
|
15
|
+
| 'non_http'
|
|
16
|
+
| 'protocol_relative'
|
|
17
|
+
| 'relative';
|
|
18
|
+
|
|
19
|
+
export interface IOauthRedirectDiagnostic {
|
|
20
|
+
redirectKind: TypeOauthRedirectKind;
|
|
21
|
+
redirectOrigin?: string;
|
|
22
|
+
redirectProtocol?: string;
|
|
23
|
+
redirectHost?: string;
|
|
24
|
+
redirectHostname?: string;
|
|
25
|
+
redirectPort?: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function getOauthRedirectDiagnostic(redirect: string): IOauthRedirectDiagnostic {
|
|
29
|
+
if (redirect.startsWith('//')) return { redirectKind: 'protocol_relative' };
|
|
30
|
+
if (redirect.startsWith('/')) return { redirectKind: 'relative' };
|
|
31
|
+
|
|
32
|
+
let url: URL;
|
|
33
|
+
try {
|
|
34
|
+
url = new URL(redirect);
|
|
35
|
+
} catch {
|
|
36
|
+
return { redirectKind: 'invalid' };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
|
|
40
|
+
return { redirectKind: 'non_http', redirectProtocol: url.protocol };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
return {
|
|
44
|
+
redirectKind: 'absolute',
|
|
45
|
+
redirectOrigin: url.origin,
|
|
46
|
+
redirectProtocol: url.protocol,
|
|
47
|
+
redirectHost: url.host,
|
|
48
|
+
redirectHostname: url.hostname,
|
|
49
|
+
redirectPort: url.port || undefined,
|
|
50
|
+
};
|
|
51
|
+
}
|
|
@@ -20,6 +20,7 @@ import type {
|
|
|
20
20
|
} from '../types/authProvider.ts';
|
|
21
21
|
|
|
22
22
|
import { StrategyMock } from '../lib/strategyMock.ts';
|
|
23
|
+
import { getOauthRedirectDiagnostic } from '../lib/utils.ts';
|
|
23
24
|
|
|
24
25
|
@Service()
|
|
25
26
|
export class ServiceAuth extends BeanBase {
|
|
@@ -247,10 +248,27 @@ export class ServiceAuth extends BeanBase {
|
|
|
247
248
|
if (clientOptions.mockUsername) {
|
|
248
249
|
return resolve(jwt);
|
|
249
250
|
}
|
|
251
|
+
// redirect
|
|
252
|
+
if (!strategyState.redirect) {
|
|
253
|
+
this._logOauthRedirectRejected(entityAuthProvider, strategyState, 'redirect_missing');
|
|
254
|
+
return reject(new Error('redirect not specified'));
|
|
255
|
+
}
|
|
256
|
+
if (!strategyState.redirect.startsWith('/')) {
|
|
257
|
+
const redirectAllowed = !!this.bean.security.checkOrigin(
|
|
258
|
+
strategyState.redirect,
|
|
259
|
+
this.app.util.host,
|
|
260
|
+
);
|
|
261
|
+
if (!redirectAllowed) {
|
|
262
|
+
this._logOauthRedirectRejected(
|
|
263
|
+
entityAuthProvider,
|
|
264
|
+
strategyState,
|
|
265
|
+
'origin_disallowed',
|
|
266
|
+
);
|
|
267
|
+
return this.app.throw(403);
|
|
268
|
+
}
|
|
269
|
+
}
|
|
250
270
|
// code
|
|
251
271
|
const code = await this.bean.passport.createOauthCodeFromOauthAuthToken(jwt.accessToken);
|
|
252
|
-
// redirect
|
|
253
|
-
if (!strategyState.redirect) return reject(new Error('redirect not specified'));
|
|
254
272
|
const redirectUrl = combineQueries(strategyState.redirect, {
|
|
255
273
|
[this.scope.config.oauthCodeField]: code,
|
|
256
274
|
});
|
|
@@ -266,6 +284,46 @@ export class ServiceAuth extends BeanBase {
|
|
|
266
284
|
});
|
|
267
285
|
}
|
|
268
286
|
|
|
287
|
+
private _logOauthRedirectRejected(
|
|
288
|
+
entityAuthProvider: EntityAuthProvider,
|
|
289
|
+
strategyState: IAuthenticateStrategyState,
|
|
290
|
+
reason: 'origin_disallowed' | 'redirect_missing',
|
|
291
|
+
) {
|
|
292
|
+
const redirectDiagnostic = strategyState.redirect
|
|
293
|
+
? getOauthRedirectDiagnostic(strategyState.redirect)
|
|
294
|
+
: { redirectKind: 'missing' as const };
|
|
295
|
+
const onionCors = this.bean.onion.middlewareSystem.getOnionSlice('a-security:cors');
|
|
296
|
+
const whiteList = onionCors?.beanOptions.options?.whiteList;
|
|
297
|
+
const whiteListEntries =
|
|
298
|
+
typeof whiteList === 'string' ? whiteList.split(',').filter(Boolean) : whiteList;
|
|
299
|
+
this.$logger.warn('OAuth callback final redirect rejected', {
|
|
300
|
+
event: 'auth.oauth_final_redirect_rejected',
|
|
301
|
+
stage: 'redirect_validation',
|
|
302
|
+
outcome: 'rejected',
|
|
303
|
+
reason,
|
|
304
|
+
authProviderId: entityAuthProvider.id,
|
|
305
|
+
providerName: entityAuthProvider.providerName,
|
|
306
|
+
clientName: entityAuthProvider.clientName,
|
|
307
|
+
intention: strategyState.intention ?? 'login',
|
|
308
|
+
instanceName: strategyState.instanceName,
|
|
309
|
+
mockMode: !!strategyState.clientOptions?.mockUsername,
|
|
310
|
+
requestHost: this.app.util.host,
|
|
311
|
+
requestProtocol: this.app.util.protocol,
|
|
312
|
+
serveHost: this.ctx.config.server.serve.host || null,
|
|
313
|
+
serveProtocol: this.ctx.config.server.serve.protocol || null,
|
|
314
|
+
proxyEnabled: this.app.proxy,
|
|
315
|
+
hostMatches:
|
|
316
|
+
redirectDiagnostic.redirectHost === undefined
|
|
317
|
+
? undefined
|
|
318
|
+
: redirectDiagnostic.redirectHost === this.app.util.host,
|
|
319
|
+
whiteListConfigured: Array.isArray(whiteList) ? whiteList.length > 0 : !!whiteList,
|
|
320
|
+
whiteListKind: Array.isArray(whiteList) ? 'array' : typeof whiteList,
|
|
321
|
+
whiteListEntryCount: Array.isArray(whiteListEntries) ? whiteListEntries.length : 0,
|
|
322
|
+
whiteListWildcard: whiteList === '*',
|
|
323
|
+
...redirectDiagnostic,
|
|
324
|
+
});
|
|
325
|
+
}
|
|
326
|
+
|
|
269
327
|
public async getStrategyConstructable(
|
|
270
328
|
beanAuthProvider: IAuthProviderVerify & IAuthProviderStrategy,
|
|
271
329
|
clientOptions: IAuthProviderClientOptions,
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import assert from 'node:assert';
|
|
2
|
+
import { describe, it } from 'node:test';
|
|
3
|
+
|
|
4
|
+
import { getOauthRedirectDiagnostic } from '../src/lib/utils.ts';
|
|
5
|
+
|
|
6
|
+
describe('authCallbackRedirect.test.ts', () => {
|
|
7
|
+
it('keeps absolute redirect diagnostics free of sensitive URL components', () => {
|
|
8
|
+
const code = 'oauth-authorization-code-sentinel';
|
|
9
|
+
const state = 'oauth-state-sentinel';
|
|
10
|
+
const token = 'oauth-token-sentinel';
|
|
11
|
+
const redirect = `https://user:password@app.example.test:8443/auth/callback?code=${code}&state=${state}&token=${token}#fragment-sentinel`;
|
|
12
|
+
|
|
13
|
+
const diagnostic = getOauthRedirectDiagnostic(redirect);
|
|
14
|
+
const serialized = JSON.stringify(diagnostic);
|
|
15
|
+
|
|
16
|
+
assert.deepEqual(diagnostic, {
|
|
17
|
+
redirectKind: 'absolute',
|
|
18
|
+
redirectOrigin: 'https://app.example.test:8443',
|
|
19
|
+
redirectProtocol: 'https:',
|
|
20
|
+
redirectHost: 'app.example.test:8443',
|
|
21
|
+
redirectHostname: 'app.example.test',
|
|
22
|
+
redirectPort: '8443',
|
|
23
|
+
});
|
|
24
|
+
for (const value of [code, state, token, 'user', 'password', 'callback', 'fragment-sentinel']) {
|
|
25
|
+
assert.equal(serialized.includes(value), false);
|
|
26
|
+
}
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it('classifies redirect shapes without retaining raw values', () => {
|
|
30
|
+
assert.deepEqual(getOauthRedirectDiagnostic('/home/base/authCallback?token=secret'), {
|
|
31
|
+
redirectKind: 'relative',
|
|
32
|
+
});
|
|
33
|
+
assert.deepEqual(getOauthRedirectDiagnostic('//app.example.test/authCallback?token=secret'), {
|
|
34
|
+
redirectKind: 'protocol_relative',
|
|
35
|
+
});
|
|
36
|
+
assert.deepEqual(getOauthRedirectDiagnostic('https://[invalid'), {
|
|
37
|
+
redirectKind: 'invalid',
|
|
38
|
+
});
|
|
39
|
+
assert.deepEqual(getOauthRedirectDiagnostic('javascript:alert(secret)'), {
|
|
40
|
+
redirectKind: 'non_http',
|
|
41
|
+
redirectProtocol: 'javascript:',
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
});
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
import assert from 'node:assert';
|
|
2
|
+
import { after, before, describe, it } from 'node:test';
|
|
3
|
+
import { acquireTestLock, app } from 'vona-mock';
|
|
4
|
+
|
|
5
|
+
describe('oauthRequestContext.test.ts', { concurrency: false }, () => {
|
|
6
|
+
const releases: Array<() => void> = [];
|
|
7
|
+
|
|
8
|
+
before(async () => {
|
|
9
|
+
releases.push(await acquireTestLock('a-security'));
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
after(() => {
|
|
13
|
+
for (const release of releases.reverse()) release();
|
|
14
|
+
});
|
|
15
|
+
it('passes the callback request through an isolated context without rewriting it', async () => {
|
|
16
|
+
await app.bean.executor.mockCtx(async () => {
|
|
17
|
+
const proxyPrevious = app.proxy;
|
|
18
|
+
app.proxy = true;
|
|
19
|
+
try {
|
|
20
|
+
const req = {
|
|
21
|
+
method: 'GET',
|
|
22
|
+
url: '/api/auth/passport/callback?a=1&b=2',
|
|
23
|
+
headers: {
|
|
24
|
+
'host': 'origin.internal.test',
|
|
25
|
+
'x-forwarded-host': 'demo.example.test',
|
|
26
|
+
'x-forwarded-proto': 'https',
|
|
27
|
+
'user-agent': 'oauth-callback-test',
|
|
28
|
+
},
|
|
29
|
+
httpVersionMajor: 1,
|
|
30
|
+
socket: {
|
|
31
|
+
remoteAddress: '192.0.2.10',
|
|
32
|
+
remotePort: 443,
|
|
33
|
+
},
|
|
34
|
+
};
|
|
35
|
+
const originalUrl = req.url;
|
|
36
|
+
const originalHeaders = { ...req.headers };
|
|
37
|
+
const outerContext = app.ctx;
|
|
38
|
+
const result = await app.bean.executor.newCtx(
|
|
39
|
+
async () => {
|
|
40
|
+
return {
|
|
41
|
+
context: app.ctx,
|
|
42
|
+
request: app.ctx.req,
|
|
43
|
+
method: app.ctx.method,
|
|
44
|
+
host: app.ctx.host,
|
|
45
|
+
protocol: app.ctx.protocol,
|
|
46
|
+
secure: app.ctx.secure,
|
|
47
|
+
query: app.ctx.request.query,
|
|
48
|
+
userAgent: app.ctx.get('user-agent'),
|
|
49
|
+
};
|
|
50
|
+
},
|
|
51
|
+
{
|
|
52
|
+
instanceName: outerContext.instanceName!,
|
|
53
|
+
req,
|
|
54
|
+
},
|
|
55
|
+
);
|
|
56
|
+
|
|
57
|
+
assert.notEqual(result.context, outerContext);
|
|
58
|
+
assert.equal(result.request, req);
|
|
59
|
+
assert.equal(result.method, 'GET');
|
|
60
|
+
assert.equal(result.host, 'demo.example.test');
|
|
61
|
+
assert.equal(result.protocol, 'https');
|
|
62
|
+
assert.equal(result.secure, true);
|
|
63
|
+
assert.deepEqual(result.query, { a: '1', b: '2' });
|
|
64
|
+
assert.equal(result.userAgent, 'oauth-callback-test');
|
|
65
|
+
assert.equal(req.url, originalUrl);
|
|
66
|
+
assert.deepEqual(req.headers, originalHeaders);
|
|
67
|
+
assert.equal(app.ctx, outerContext);
|
|
68
|
+
} finally {
|
|
69
|
+
app.proxy = proxyPrevious;
|
|
70
|
+
}
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('logs rejected redirects against the canonical server origin', async () => {
|
|
75
|
+
await app.bean.executor.mockCtx(async () => {
|
|
76
|
+
const proxyPrevious = app.proxy;
|
|
77
|
+
const serve = app.ctx.config.server.serve;
|
|
78
|
+
const servePrevious = { ...serve };
|
|
79
|
+
app.proxy = true;
|
|
80
|
+
(serve as any).host = 'canonical.example.test';
|
|
81
|
+
(serve as any).protocol = 'http';
|
|
82
|
+
try {
|
|
83
|
+
const req = {
|
|
84
|
+
method: 'GET',
|
|
85
|
+
url: '/api/auth/passport/callback',
|
|
86
|
+
headers: {
|
|
87
|
+
'host': 'origin.internal.test',
|
|
88
|
+
'x-forwarded-host': 'edge.example.test',
|
|
89
|
+
'x-forwarded-proto': 'https',
|
|
90
|
+
},
|
|
91
|
+
httpVersionMajor: 1,
|
|
92
|
+
socket: {
|
|
93
|
+
remoteAddress: '192.0.2.10',
|
|
94
|
+
remotePort: 443,
|
|
95
|
+
},
|
|
96
|
+
};
|
|
97
|
+
await app.bean.executor.newCtx(
|
|
98
|
+
async () => {
|
|
99
|
+
assert.equal(app.ctx.host, 'edge.example.test');
|
|
100
|
+
assert.equal(app.ctx.protocol, 'https');
|
|
101
|
+
assert.equal(app.util.host, 'canonical.example.test');
|
|
102
|
+
assert.equal(app.util.protocol, 'http');
|
|
103
|
+
|
|
104
|
+
const service = app.scope('a-auth').service.auth;
|
|
105
|
+
const logger = (service as any).$logger;
|
|
106
|
+
const warnPrevious = logger.warn;
|
|
107
|
+
const warnings: unknown[][] = [];
|
|
108
|
+
logger.warn = (...args: unknown[]) => warnings.push(args);
|
|
109
|
+
try {
|
|
110
|
+
(service as any)._logOauthRedirectRejected(
|
|
111
|
+
{
|
|
112
|
+
id: -1,
|
|
113
|
+
providerName: 'auth-oauth:oauth',
|
|
114
|
+
clientName: 'github',
|
|
115
|
+
},
|
|
116
|
+
{
|
|
117
|
+
authProviderId: -1,
|
|
118
|
+
instanceName: app.ctx.instanceName!,
|
|
119
|
+
locale: app.ctx.locale,
|
|
120
|
+
redirect: 'https://edge.example.test/oauth/callback',
|
|
121
|
+
},
|
|
122
|
+
'origin_disallowed',
|
|
123
|
+
);
|
|
124
|
+
} finally {
|
|
125
|
+
logger.warn = warnPrevious;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
assert.equal(warnings.length, 1);
|
|
129
|
+
assert.equal(warnings[0][0], 'OAuth callback final redirect rejected');
|
|
130
|
+
const diagnostic = warnings[0][1] as Record<string, unknown>;
|
|
131
|
+
assert.deepEqual(
|
|
132
|
+
{
|
|
133
|
+
event: diagnostic.event,
|
|
134
|
+
stage: diagnostic.stage,
|
|
135
|
+
outcome: diagnostic.outcome,
|
|
136
|
+
reason: diagnostic.reason,
|
|
137
|
+
requestHost: diagnostic.requestHost,
|
|
138
|
+
requestProtocol: diagnostic.requestProtocol,
|
|
139
|
+
serveHost: diagnostic.serveHost,
|
|
140
|
+
serveProtocol: diagnostic.serveProtocol,
|
|
141
|
+
proxyEnabled: diagnostic.proxyEnabled,
|
|
142
|
+
redirectHost: diagnostic.redirectHost,
|
|
143
|
+
hostMatches: diagnostic.hostMatches,
|
|
144
|
+
},
|
|
145
|
+
{
|
|
146
|
+
event: 'auth.oauth_final_redirect_rejected',
|
|
147
|
+
stage: 'redirect_validation',
|
|
148
|
+
outcome: 'rejected',
|
|
149
|
+
reason: 'origin_disallowed',
|
|
150
|
+
requestHost: 'canonical.example.test',
|
|
151
|
+
requestProtocol: 'http',
|
|
152
|
+
serveHost: 'canonical.example.test',
|
|
153
|
+
serveProtocol: 'http',
|
|
154
|
+
proxyEnabled: true,
|
|
155
|
+
redirectHost: 'edge.example.test',
|
|
156
|
+
hostMatches: false,
|
|
157
|
+
},
|
|
158
|
+
);
|
|
159
|
+
},
|
|
160
|
+
{
|
|
161
|
+
instanceName: app.ctx.instanceName!,
|
|
162
|
+
req,
|
|
163
|
+
},
|
|
164
|
+
);
|
|
165
|
+
} finally {
|
|
166
|
+
app.proxy = proxyPrevious;
|
|
167
|
+
Object.assign(serve, servePrevious);
|
|
168
|
+
}
|
|
169
|
+
});
|
|
170
|
+
});
|
|
171
|
+
});
|
|
@@ -14,7 +14,7 @@ export interface ISocketConnectionOptionsCors extends IDecoratorSocketConnection
|
|
|
14
14
|
export class SocketConnectionCors extends BeanBase implements ISocketConnectionExecute {
|
|
15
15
|
async enter(ws: WebSocket, _options: ISocketConnectionOptionsCors, next: Next): Promise<void> {
|
|
16
16
|
// cors
|
|
17
|
-
const origin = this.bean.security.checkOrigin(this.ctx.get('origin'), this.
|
|
17
|
+
const origin = this.bean.security.checkOrigin(this.ctx.get('origin'), this.app.util.host);
|
|
18
18
|
if (!origin) {
|
|
19
19
|
ws.terminate();
|
|
20
20
|
return;
|
|
@@ -52,5 +52,5 @@ export class MiddlewareSystemCors extends BeanBase implements IMiddlewareSystemE
|
|
|
52
52
|
}
|
|
53
53
|
|
|
54
54
|
function _corsOrigin(ctx: VonaContext) {
|
|
55
|
-
return ctx.app.bean.security.checkOrigin(ctx.get('origin'), ctx.host);
|
|
55
|
+
return ctx.app.bean.security.checkOrigin(ctx.get('origin'), ctx.app.util.host);
|
|
56
56
|
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
|
|
3
|
+
const DATABASE_NAME_MAX_BYTES = 63;
|
|
4
|
+
const APP_NAME_MAX_BYTES = 13;
|
|
5
|
+
const HASH_LENGTH = 10;
|
|
6
|
+
const TIMESTAMP_LENGTH = 15;
|
|
7
|
+
|
|
8
|
+
export function getDatabaseTestPrefix(appName: string, scope: string): string {
|
|
9
|
+
const readablePrefix = getReadablePrefix(appName, scope);
|
|
10
|
+
if (Buffer.byteLength(readablePrefix, 'utf8') + TIMESTAMP_LENGTH <= DATABASE_NAME_MAX_BYTES) {
|
|
11
|
+
return readablePrefix;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const appHead = truncateUtf8(appName, APP_NAME_MAX_BYTES);
|
|
15
|
+
const scopeKind = scope === 'share' ? 's' : 'i';
|
|
16
|
+
const namespaceHash = hash(`${appName}\0${scope}`);
|
|
17
|
+
return `vona-test-${appHead}-${scopeKind}-${namespaceHash}-`;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function createDatabaseTestName(databasePrefix: string, timestamp: string): string {
|
|
21
|
+
const databaseName = `${databasePrefix}${timestamp}`;
|
|
22
|
+
if (Buffer.byteLength(databaseName, 'utf8') > DATABASE_NAME_MAX_BYTES) {
|
|
23
|
+
throw new Error(`test database name exceeds ${DATABASE_NAME_MAX_BYTES} bytes`);
|
|
24
|
+
}
|
|
25
|
+
return databaseName;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function getReadablePrefix(appName: string, scope: string): string {
|
|
29
|
+
if (scope === 'share') return `vona-test-${appName}-s-`;
|
|
30
|
+
return `vona-test-${appName}-i-${scope.slice('isolate-'.length)}-`;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function hash(value: string): string {
|
|
34
|
+
return createHash('sha256').update(value).digest('base64url').slice(0, HASH_LENGTH);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function truncateUtf8(value: string, maxBytes: number): string {
|
|
38
|
+
let result = '';
|
|
39
|
+
let bytes = 0;
|
|
40
|
+
for (const char of value) {
|
|
41
|
+
const charBytes = Buffer.byteLength(char, 'utf8');
|
|
42
|
+
if (bytes + charBytes > maxBytes) break;
|
|
43
|
+
result += char;
|
|
44
|
+
bytes += charBytes;
|
|
45
|
+
}
|
|
46
|
+
return result;
|
|
47
|
+
}
|
|
@@ -7,6 +7,8 @@ import { DateTime } from 'luxon';
|
|
|
7
7
|
import { BeanBase } from 'vona';
|
|
8
8
|
import { Service } from 'vona-module-a-bean';
|
|
9
9
|
|
|
10
|
+
import { createDatabaseTestName, getDatabaseTestPrefix } from '../lib/databaseTestName.ts';
|
|
11
|
+
|
|
10
12
|
const __separator = '-';
|
|
11
13
|
const __timeFormat = `yyyyMMdd${__separator}HHmmss`;
|
|
12
14
|
|
|
@@ -20,8 +22,8 @@ export class ServiceDatabase extends BeanBase {
|
|
|
20
22
|
instanceName?: keyof IInstanceRecord,
|
|
21
23
|
configInstanceBase?: ConfigInstanceBase,
|
|
22
24
|
) {
|
|
23
|
-
const
|
|
24
|
-
return
|
|
25
|
+
const scope = configInstanceBase?.isolate ? `isolate${__separator}${instanceName}` : 'share';
|
|
26
|
+
return getDatabaseTestPrefix(this.app.name, scope);
|
|
25
27
|
}
|
|
26
28
|
|
|
27
29
|
public async databaseInitStartup() {
|
|
@@ -60,7 +62,10 @@ export class ServiceDatabase extends BeanBase {
|
|
|
60
62
|
configInstanceBase?: ConfigInstanceBase,
|
|
61
63
|
) {
|
|
62
64
|
const databasePrefix = this.getDatabasePrefix(instanceName, configInstanceBase);
|
|
63
|
-
const databaseName =
|
|
65
|
+
const databaseName = createDatabaseTestName(
|
|
66
|
+
databasePrefix,
|
|
67
|
+
DateTime.now().toFormat(__timeFormat),
|
|
68
|
+
);
|
|
64
69
|
return await client.connection.schema.createDatabase(databaseName);
|
|
65
70
|
}
|
|
66
71
|
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import assert from 'node:assert';
|
|
2
|
+
import { describe, it } from 'node:test';
|
|
3
|
+
|
|
4
|
+
import { createDatabaseTestName, getDatabaseTestPrefix } from '../src/lib/databaseTestName.ts';
|
|
5
|
+
|
|
6
|
+
const timestamp = '20260923-104500';
|
|
7
|
+
|
|
8
|
+
describe('databaseTestName', () => {
|
|
9
|
+
it('keeps a short shared app name readable with its shared marker', () => {
|
|
10
|
+
const prefix = getDatabaseTestPrefix('cabloy-start', 'share');
|
|
11
|
+
const databaseName = createDatabaseTestName(prefix, timestamp);
|
|
12
|
+
|
|
13
|
+
assert.equal(prefix, 'vona-test-cabloy-start-s-');
|
|
14
|
+
assert.equal(databaseName, 'vona-test-cabloy-start-s-20260923-104500');
|
|
15
|
+
assert.ok(Buffer.byteLength(databaseName, 'utf8') <= 63);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it('keeps a short isolated app name readable with its isolate identity', () => {
|
|
19
|
+
const sharedPrefix = getDatabaseTestPrefix('cabloy-start', 'share');
|
|
20
|
+
const isolatedPrefix = getDatabaseTestPrefix('cabloy-start', 'isolate-isolateTest');
|
|
21
|
+
|
|
22
|
+
assert.equal(isolatedPrefix, 'vona-test-cabloy-start-i-isolateTest-');
|
|
23
|
+
assert.notEqual(sharedPrefix, isolatedPrefix);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it('uses the readable format through 63 bytes and the fallback at 64 bytes', () => {
|
|
27
|
+
const readableAppName = 'a'.repeat(35);
|
|
28
|
+
const fallbackAppName = `${readableAppName}a`;
|
|
29
|
+
const readablePrefix = getDatabaseTestPrefix(readableAppName, 'share');
|
|
30
|
+
const fallbackPrefix = getDatabaseTestPrefix(fallbackAppName, 'share');
|
|
31
|
+
const readableName = createDatabaseTestName(readablePrefix, timestamp);
|
|
32
|
+
const fallbackName = createDatabaseTestName(fallbackPrefix, timestamp);
|
|
33
|
+
|
|
34
|
+
assert.equal(readablePrefix, `vona-test-${readableAppName}-s-`);
|
|
35
|
+
assert.equal(Buffer.byteLength(readableName, 'utf8'), 63);
|
|
36
|
+
assert.match(fallbackPrefix, /^vona-test-a{13}-s-[\w-]{10}-$/);
|
|
37
|
+
assert.ok(Buffer.byteLength(fallbackName, 'utf8') <= 63);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('uses UTF-8 byte length and preserves code points in the fallback', () => {
|
|
41
|
+
const readableAppName = '商'.repeat(11);
|
|
42
|
+
const fallbackAppName = `${readableAppName}店`;
|
|
43
|
+
const readablePrefix = getDatabaseTestPrefix(readableAppName, 'share');
|
|
44
|
+
const fallbackPrefix = getDatabaseTestPrefix(fallbackAppName, 'share');
|
|
45
|
+
const fallbackName = createDatabaseTestName(fallbackPrefix, timestamp);
|
|
46
|
+
|
|
47
|
+
assert.equal(readablePrefix, `vona-test-${readableAppName}-s-`);
|
|
48
|
+
assert.match(fallbackPrefix, /^vona-test-商{4}-s-[\w-]{10}-$/);
|
|
49
|
+
assert.ok(!fallbackPrefix.includes('�'));
|
|
50
|
+
assert.ok(Buffer.byteLength(fallbackName, 'utf8') <= 63);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('distinguishes fallback namespaces with the same visible app head and distinct scopes', () => {
|
|
54
|
+
const appHead = 'same-readable';
|
|
55
|
+
const first = getDatabaseTestPrefix(
|
|
56
|
+
`${appHead}-worktree-one-that-overflows`,
|
|
57
|
+
'isolate-isolateTest',
|
|
58
|
+
);
|
|
59
|
+
const second = getDatabaseTestPrefix(
|
|
60
|
+
`${appHead}-worktree-two-that-overflows`,
|
|
61
|
+
'isolate-isolateTest',
|
|
62
|
+
);
|
|
63
|
+
const third = getDatabaseTestPrefix(
|
|
64
|
+
`${appHead}-worktree-one-that-overflows`,
|
|
65
|
+
'isolate-isolatePeerTest',
|
|
66
|
+
);
|
|
67
|
+
|
|
68
|
+
assert.notEqual(first, second);
|
|
69
|
+
assert.notEqual(first, third);
|
|
70
|
+
assert.match(first, /-i-[\w-]{10}-$/);
|
|
71
|
+
});
|
|
72
|
+
});
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "zova-cli",
|
|
3
|
-
"version": "1.2.
|
|
3
|
+
"version": "1.2.111",
|
|
4
4
|
"gitHead": "6f675a8cc46d596142c591c28a40cc4d82fcc6cc",
|
|
5
5
|
"description": "zova cli",
|
|
6
6
|
"keywords": [
|
|
@@ -44,7 +44,7 @@
|
|
|
44
44
|
"@cabloy/process-helper": "^3.1.8",
|
|
45
45
|
"fs-extra": "^11.3.5",
|
|
46
46
|
"semver": "^7.6.2",
|
|
47
|
-
"zova-cli-set-front": "^1.2.
|
|
47
|
+
"zova-cli-set-front": "^1.2.109"
|
|
48
48
|
},
|
|
49
49
|
"devDependencies": {
|
|
50
50
|
"clean-package": "^2.2.0",
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "zova-cli-set-front",
|
|
3
|
-
"version": "1.2.
|
|
3
|
+
"version": "1.2.109",
|
|
4
4
|
"gitHead": "6f675a8cc46d596142c591c28a40cc4d82fcc6cc",
|
|
5
5
|
"description": "zova cli-set-front",
|
|
6
6
|
"keywords": [
|
|
@@ -70,7 +70,7 @@
|
|
|
70
70
|
"vite": "^8.0.14",
|
|
71
71
|
"yaml": "^2.8.3",
|
|
72
72
|
"zova-openapi": "^1.1.17",
|
|
73
|
-
"zova-vite": "^1.1.
|
|
73
|
+
"zova-vite": "^1.1.47"
|
|
74
74
|
},
|
|
75
75
|
"devDependencies": {
|
|
76
76
|
"clean-package": "^2.2.0",
|
|
@@ -58,11 +58,13 @@ function _collectCss(
|
|
|
58
58
|
}
|
|
59
59
|
});
|
|
60
60
|
|
|
61
|
-
styles.
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
61
|
+
Array.from(styles.entries())
|
|
62
|
+
.reverse()
|
|
63
|
+
.forEach(([id, content]) => {
|
|
64
|
+
result = result.concat(
|
|
65
|
+
`<style vite-css-module-id="${hashCode(id)}">${(content || '').replaceAll('\n', '')}</style>`,
|
|
66
|
+
);
|
|
67
|
+
});
|
|
66
68
|
|
|
67
69
|
return result;
|
|
68
70
|
}
|