@h-ai/cli 0.1.0-alpha.67 → 0.1.0-alpha.68
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/README.en.md +40 -4
- package/README.ja.md +40 -4
- package/README.md +40 -4
- package/dist/{src-2V6VP4I5.js → chunk-FI56GONY.js} +32 -3103
- package/dist/chunk-FI56GONY.js.map +1 -0
- package/dist/chunk-MBPB37FR.js +1348 -0
- package/dist/chunk-MBPB37FR.js.map +1 -0
- package/dist/{chunk-7DSURTJ4.js → chunk-ZFNBUF77.js} +327 -199
- package/dist/chunk-ZFNBUF77.js.map +1 -0
- package/dist/cli-config-templates-W2LLPMB6.js +5 -0
- package/dist/{cli-config-templates-6AHNUGBN.js.map → cli-config-templates-W2LLPMB6.js.map} +1 -1
- package/dist/{cli-skill-templates-FC3QRKDO.js → cli-skill-templates-US5CCAJ4.js} +3 -3
- package/dist/{cli-skill-templates-FC3QRKDO.js.map → cli-skill-templates-US5CCAJ4.js.map} +1 -1
- package/dist/index.js +920 -336
- package/dist/index.js.map +1 -1
- package/dist/metafile-esm.json +1 -0
- package/dist/src-DSJJXM2F.js +3849 -0
- package/dist/src-DSJJXM2F.js.map +1 -0
- package/package.json +9 -8
- package/templates/apps/admin/messages/ja-JP.json +38 -0
- package/templates/apps/admin/src/routes/+layout.svelte.hbs +16 -6
- package/templates/apps/admin/src/routes/admin/+layout.svelte.hbs +3 -3
- package/templates/apps/api/apps/{{projectName}}-contract/src/index.ts.hbs +2 -0
- package/templates/apps/api/apps/{{projectName}}-contract/src/{{projectName}}-contract.ts.hbs +4 -1
- package/templates/apps/api/apps/{{projectName}}-service/README.md.hbs +5 -1
- package/templates/apps/api/apps/{{projectName}}-service/config/_core.yml.hbs +0 -10
- package/templates/apps/api/apps/{{projectName}}-service/config/_serv.yml.hbs +2 -1
- package/templates/apps/api/apps/{{projectName}}-service/src/app.ts.hbs +24 -11
- package/templates/apps/api/apps/{{projectName}}-service/src/index.ts.hbs +8 -1
- package/templates/apps/api/apps/{{projectName}}-service/src/lib/server/init.ts.hbs +68 -11
- package/templates/apps/api/apps/{{projectName}}-service/src/server/procedures/index.ts.hbs +10 -2
- package/templates/apps/api/e2e/api-service.spec.ts.hbs +2 -2
- package/templates/apps/api/package.json.hbs +1 -1
- package/templates/apps/api/playwright.config.ts.hbs +15 -7
- package/templates/apps/fullstack/apps/{{projectName}}-app/README.md.hbs +1 -1
- package/templates/apps/fullstack/apps/{{projectName}}-app/messages/ja-JP.json.hbs +7 -0
- package/templates/apps/fullstack/apps/{{projectName}}-app/src/App.svelte.hbs +7 -1
- package/templates/apps/fullstack/apps/{{projectName}}-app/vite.config.ts.hbs +1 -1
- package/templates/apps/fullstack/apps/{{projectName}}-desktop/README.md.hbs +1 -1
- package/templates/apps/fullstack/apps/{{projectName}}-desktop/messages/ja-JP.json.hbs +7 -0
- package/templates/apps/fullstack/apps/{{projectName}}-desktop/vite.config.ts.hbs +1 -1
- package/templates/apps/fullstack/apps/{{projectName}}-miniapp/messages/ja-JP.json.hbs +12 -0
- package/templates/apps/fullstack/apps/{{projectName}}-web/README.md.hbs +1 -1
- package/templates/apps/fullstack/apps/{{projectName}}-web/messages/ja-JP.json.hbs +7 -0
- package/templates/apps/fullstack/apps/{{projectName}}-web/vite.config.ts.hbs +1 -1
- package/templates/apps/fullstack/e2e/fullstack.spec.ts.hbs +19 -8
- package/templates/apps/fullstack/package.json.hbs +1 -1
- package/templates/apps/fullstack/packages/{{projectName}}-contract/src/admin-schemas.ts.hbs +1 -1
- package/templates/apps/fullstack/packages/{{projectName}}-contract/src/index.ts.hbs +2 -0
- package/templates/apps/fullstack/packages/{{projectName}}-contract/src/{{projectName}}-contract.ts.hbs +3 -0
- package/templates/apps/fullstack/packages/{{projectName}}-serv/README.md.hbs +5 -2
- package/templates/apps/fullstack/packages/{{projectName}}-serv/config/_core.yml.hbs +0 -6
- package/templates/apps/fullstack/packages/{{projectName}}-serv/config/_reach.yml.hbs +8 -0
- package/templates/apps/fullstack/packages/{{projectName}}-serv/config/_serv.yml.hbs +2 -1
- package/templates/apps/fullstack/packages/{{projectName}}-serv/package.json.hbs +6 -0
- package/templates/apps/fullstack/packages/{{projectName}}-serv/src/app-server.ts.hbs +9 -5
- package/templates/apps/fullstack/packages/{{projectName}}-serv/src/server/admin-service.ts.hbs +3 -2
- package/templates/apps/fullstack/packages/{{projectName}}-serv/src/server/messages.ts.hbs +16 -1
- package/templates/apps/fullstack/packages/{{projectName}}-serv/src/server/procedures/admin-procedures.ts.hbs +7 -3
- package/templates/apps/fullstack/packages/{{projectName}}-serv/src/server/procedures/index.ts.hbs +3 -0
- package/templates/apps/fullstack/packages/{{projectName}}-serv/src/server/runtime.ts.hbs +58 -7
- package/templates/apps/fullstack/packages/{{projectName}}-serv/tests/app-server.test.ts.hbs +13 -3
- package/templates/apps/fullstack/packages/{{projectName}}-shared/README.md.hbs +9 -4
- package/templates/apps/fullstack/packages/{{projectName}}-shared/messages/ja-JP.json.hbs +105 -0
- package/templates/apps/fullstack/packages/{{projectName}}-shared/src/lib/components/AdminWorkspace.svelte.hbs +3 -3
- package/templates/apps/fullstack/packages/{{projectName}}-shared/src/lib/components/AppShell.svelte.hbs +5 -5
- package/templates/apps/fullstack/packages/{{projectName}}-shared/src/lib/pages/audit.svelte.hbs +7 -7
- package/templates/apps/fullstack/packages/{{projectName}}-shared/src/lib/pages/settings.svelte.hbs +1 -1
- package/templates/apps/fullstack/packages/{{projectName}}-shared/src/lib/pages/users.svelte.hbs +8 -8
- package/templates/apps/fullstack/packages/{{projectName}}-shared/tests/api-client.test.ts.hbs +11 -12
- package/templates/apps/fullstack/playwright.config.ts.hbs +15 -6
- package/templates/apps/h5/messages/ja-JP.json +40 -0
- package/templates/apps/h5/src/routes/+layout.svelte.hbs +14 -3
- package/templates/apps/mobile-app/messages/ja-JP.json +36 -0
- package/templates/apps/mobile-app/src/App.svelte.hbs +13 -3
- package/templates/apps/mobile-app/src/lib/api.ts.hbs +173 -14
- package/templates/apps/website/messages/ja-JP.json +32 -0
- package/templates/apps/website/src/routes/+layout.svelte.hbs +10 -2
- package/templates/base/e2e/app.spec.ts.hbs +13 -8
- package/templates/base/package.json.hbs +4 -1
- package/templates/base/playwright.config.ts +15 -4
- package/templates/base/src/hooks.server.ts.hbs +15 -6
- package/templates/base/src/lib/server/init.ts.hbs +139 -30
- package/templates/base/vite.config.ts.hbs +2 -2
- package/templates/features/iam/routes-shared/api/auth/logout/+server.ts +11 -6
- package/templates/features/payment/routes/api/v1/payment/create/+server.ts +18 -25
- package/templates/features/payment/routes/api/v1/payment/notify/[provider]/+server.ts +4 -0
- package/templates/i18n/project.inlang/settings.json +1 -1
- package/templates/shared/e2e-env.ts +98 -0
- package/templates/shared/run-e2e.mjs +51 -12
- package/templates/skills/AGENTS.md +4 -0
- package/templates/skills/bridges/admin/AGENTS.md +5 -1
- package/templates/skills/bridges/api/AGENTS.md +2 -0
- package/templates/skills/bridges/fullstack/AGENTS.md.hbs +4 -0
- package/templates/skills/bridges/h5/AGENTS.md +5 -1
- package/templates/skills/bridges/mobile-app/AGENTS.md +5 -1
- package/templates/skills/bridges/website/AGENTS.md +5 -1
- package/templates/skills/hai-ai/SKILL.md +8 -2
- package/templates/skills/hai-ai/guide.md +134 -60
- package/templates/skills/hai-ai/reference.md +51 -11
- package/templates/skills/hai-api-client/SKILL.md +2 -0
- package/templates/skills/hai-api-client/reference.md +17 -5
- package/templates/skills/hai-api-contract/SKILL.md +1 -0
- package/templates/skills/hai-api-contract/reference.md +4 -2
- package/templates/skills/hai-app-create/SKILL.md +4 -0
- package/templates/skills/hai-app-create/development.md +20 -4
- package/templates/skills/hai-app-create/reference.md +2 -1
- package/templates/skills/hai-app-review/SKILL.md +6 -2
- package/templates/skills/hai-app-review/differential.md +34 -0
- package/templates/skills/hai-app-tests/SKILL.md +3 -1
- package/templates/skills/hai-app-tests/reference.md +4 -4
- package/templates/skills/hai-app-tests/validation.md +10 -3
- package/templates/skills/hai-audit/SKILL.md +10 -8
- package/templates/skills/hai-audit/reference.md +3 -0
- package/templates/skills/hai-build/reference.md +6 -4
- package/templates/skills/hai-cache/SKILL.md +9 -3
- package/templates/skills/hai-cache/reference.md +53 -11
- package/templates/skills/hai-capacitor/SKILL.md +3 -1
- package/templates/skills/hai-capacitor/reference.md +9 -5
- package/templates/skills/hai-core/SKILL.md +4 -0
- package/templates/skills/hai-core/reference.md +25 -5
- package/templates/skills/hai-crypto/SKILL.md +10 -2
- package/templates/skills/hai-crypto/reference.md +64 -10
- package/templates/skills/hai-datapipe/SKILL.md +2 -0
- package/templates/skills/hai-datapipe/reference.md +3 -1
- package/templates/skills/hai-deploy/SKILL.md +9 -4
- package/templates/skills/hai-deploy/middleware.md +51 -37
- package/templates/skills/hai-iam/SKILL.md +24 -8
- package/templates/skills/hai-iam/reference.md +181 -154
- package/templates/skills/hai-kit/SKILL.md +23 -7
- package/templates/skills/hai-kit/reference.md +42 -24
- package/templates/skills/hai-payment/SKILL.md +13 -11
- package/templates/skills/hai-payment/reference.md +80 -41
- package/templates/skills/hai-pr-review/SKILL.md +21 -88
- package/templates/skills/hai-reach/SKILL.md +5 -2
- package/templates/skills/hai-reach/reference.md +83 -22
- package/templates/skills/hai-reldb/SKILL.md +5 -2
- package/templates/skills/hai-reldb/reference.md +68 -25
- package/templates/skills/hai-scheduler/SKILL.md +8 -5
- package/templates/skills/hai-scheduler/reference.md +38 -24
- package/templates/skills/hai-serv/SKILL.md +23 -8
- package/templates/skills/hai-serv/reference.md +123 -87
- package/templates/skills/hai-spec-implement/SKILL.md +4 -0
- package/templates/skills/hai-spec-implement/evaluation.md +18 -0
- package/templates/skills/hai-spec-implement/handoff.md +47 -0
- package/templates/skills/hai-spec-implement/templates.md +5 -0
- package/templates/skills/hai-spec-implement/workflow.md +2 -0
- package/templates/skills/hai-spec-review/SKILL.md +4 -0
- package/templates/skills/hai-storage/SKILL.md +8 -2
- package/templates/skills/hai-storage/reference.md +64 -25
- package/templates/skills/hai-ui/SKILL.md +16 -2
- package/templates/skills/hai-ui/guide.md +3 -3
- package/templates/skills/hai-ui/reference.md +17 -6
- package/templates/skills/hai-ui-design/SKILL.md +31 -0
- package/templates/skills/hai-ui-design/patterns.md +26 -0
- package/templates/skills/hai-ui-review/SKILL.md +30 -0
- package/templates/skills/hai-ui-review/checklist.md +22 -0
- package/templates/skills/hai-vecdb/SKILL.md +12 -8
- package/templates/skills/hai-vecdb/reference.md +33 -3
- package/dist/chunk-7DSURTJ4.js.map +0 -1
- package/dist/chunk-AOQ4N657.js +0 -640
- package/dist/chunk-AOQ4N657.js.map +0 -1
- package/dist/cli-config-templates-6AHNUGBN.js +0 -5
- package/dist/src-2V6VP4I5.js.map +0 -1
|
@@ -12,7 +12,7 @@ import { defineConfig } from 'vitest/config'
|
|
|
12
12
|
const envDir = fileURLToPath(new URL('../../', import.meta.url))
|
|
13
13
|
|
|
14
14
|
export default defineConfig(({ mode, command }) => {
|
|
15
|
-
const envResult = command === 'build' ? { success: true as const, data: {} as Record<string, string> } : core.config.loadEnvFiles({ mode, envDir })
|
|
15
|
+
const envResult = command === 'build' || process.env.HAI_E2E === '1' ? { success: true as const, data: {} as Record<string, string> } : core.config.loadEnvFiles({ mode, envDir })
|
|
16
16
|
if (!envResult.success)
|
|
17
17
|
throw new Error(envResult.error.message)
|
|
18
18
|
const env = envResult.data
|
|
@@ -13,7 +13,7 @@ const apiPrefix = '/api/v1'
|
|
|
13
13
|
const adminPassword = process.env.E2E_ADMIN_PASSWORD || 'Scaffold-test-Admin123!'
|
|
14
14
|
const configuredName = process.env.HAI_CORE_NAME || 'Configured scaffold'
|
|
15
15
|
const configuredVersion = process.env.HAI_CORE_VERSION || '9.8.7'
|
|
16
|
-
const configuredEnv =
|
|
16
|
+
const configuredEnv = 'test'
|
|
17
17
|
|
|
18
18
|
// 测试专用的一像素 PNG;上传仍使用完整前后端加密链路。
|
|
19
19
|
const avatar = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+aSxkAAAAASUVORK5CYII=', 'base64')
|
|
@@ -261,14 +261,15 @@ test('shared authentication supports public registration with configuration', as
|
|
|
261
261
|
await expect(page.getByTestId('login-form')).toBeVisible()
|
|
262
262
|
await page.screenshot({ path: path.join(evidence, '{{projectName}}-login.png'), fullPage: true })
|
|
263
263
|
await page.getByRole('button', { name: /立即注册|Register now/i }).click()
|
|
264
|
-
|
|
264
|
+
const registerForm = page.locator('.register-form')
|
|
265
|
+
await expect(registerForm.locator('#register-username')).toBeVisible()
|
|
265
266
|
await page.screenshot({ path: path.join(evidence, '{{projectName}}-register.png'), fullPage: true })
|
|
266
|
-
const username = `signup${
|
|
267
|
-
await
|
|
268
|
-
await
|
|
269
|
-
await
|
|
270
|
-
await
|
|
271
|
-
await
|
|
267
|
+
const username = `signup${randomUUID().replaceAll('-', '').slice(0, 12)}`
|
|
268
|
+
await registerForm.locator('#register-username').fill(username)
|
|
269
|
+
await registerForm.locator('#register-email').fill(`${username}@example.com`)
|
|
270
|
+
await registerForm.locator('#register-password').fill('SignupPassword123')
|
|
271
|
+
await registerForm.locator('#register-confirm-password').fill('SignupPassword123')
|
|
272
|
+
await registerForm.locator('button[type="submit"]').click()
|
|
272
273
|
await expect(page.getByTestId('settings-page')).toBeVisible()
|
|
273
274
|
expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true)
|
|
274
275
|
if (!process.env.IAM_NOTIFICATION_URL)
|
|
@@ -458,6 +459,16 @@ test('administrator and user complete account, avatar, preference, password and
|
|
|
458
459
|
await expect(userPage.getByTestId('profile-form').locator('input[name="displayName"]')).toHaveValue('Updated member')
|
|
459
460
|
await expect(userPage.getByTestId('profile-form').locator('input[name="email"]')).toHaveValue('')
|
|
460
461
|
await expect.poll(() => userPage.getByTestId('profile-form').locator('img').evaluate((image: HTMLImageElement) => image.complete && image.naturalWidth > 0)).toBe(true)
|
|
462
|
+
await userPage.getByTestId('profile-language').click()
|
|
463
|
+
await userPage.getByRole('option', { name: '日本語', exact: true }).click()
|
|
464
|
+
await expect(userPage.getByTestId('nav-settings')).toHaveText('個人設定')
|
|
465
|
+
await expect(userPage.locator('html')).toHaveAttribute('lang', 'ja-JP')
|
|
466
|
+
await userPage.reload()
|
|
467
|
+
await expect(userPage.getByTestId('nav-settings')).toHaveText('個人設定')
|
|
468
|
+
await expect(userPage.locator('html')).toHaveAttribute('lang', 'ja-JP')
|
|
469
|
+
await userPage.getByTestId('profile-language').click()
|
|
470
|
+
await userPage.getByRole('option', { name: 'English', exact: true }).click()
|
|
471
|
+
await expect(userPage.getByTestId('nav-settings')).toHaveText('Personal settings')
|
|
461
472
|
await userPage.setViewportSize({ width: 390, height: 844 })
|
|
462
473
|
await expect.poll(() => userPage.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true)
|
|
463
474
|
await userPage.getByTestId('mobile-menu').click()
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
"start:prod": "pnpm --filter {{fullstack.servPackageName}} start:prod",
|
|
16
16
|
"dev:test": "pnpm --filter {{fullstack.contractPackageName}} build && pnpm i18n:compile && pnpm --parallel --filter {{fullstack.servPackageName}}{{#each fullstack.frontendApps}} --filter {{packageName}}{{/each}} dev:test",
|
|
17
17
|
"dev:prod": "pnpm --filter {{fullstack.contractPackageName}} build && pnpm i18n:compile && pnpm --parallel --filter {{fullstack.servPackageName}}{{#each fullstack.frontendApps}} --filter {{packageName}}{{/each}} dev:prod",
|
|
18
|
-
"compile": "pnpm
|
|
18
|
+
"compile": "pnpm i18n:compile && pnpm -r --if-present build",
|
|
19
19
|
"build": "hai build",
|
|
20
20
|
{{#if features.deploy}}
|
|
21
21
|
"deploy": "hai deploy packages/{{projectName}}-serv",
|
|
@@ -76,7 +76,7 @@ export const AdminAuditSchema = z.object({
|
|
|
76
76
|
export const ProfileUpdateSchema = z.object({
|
|
77
77
|
displayName: z.string().max(100).optional(),
|
|
78
78
|
email: z.union([z.email(), z.literal('')]).optional(),
|
|
79
|
-
locale: z.enum(['
|
|
79
|
+
locale: z.enum([{{#each fullstack.i18n.locales}}'{{this}}'{{#unless @last}}, {{/unless}}{{/each}}]).optional(),
|
|
80
80
|
theme: z.string().min(1).max(40).regex(/^[\w-]+$/).optional(),
|
|
81
81
|
}).strict()
|
|
82
82
|
/** 512 KiB 头像以 JSON 传输,上传及响应均经过统一加密。 */
|
|
@@ -2,12 +2,15 @@ import { apiContract } from '@h-ai/api-contract'
|
|
|
2
2
|
import { adminContract, authContract, profileContract } from './admin-contract.js'
|
|
3
3
|
import { appContract } from './app-contract.js'
|
|
4
4
|
|
|
5
|
+
// @hai-generator:contracts:imports
|
|
6
|
+
|
|
5
7
|
/** 当前工程启用的前端目标。 */
|
|
6
8
|
export const ENABLED_FRONTENDS = {{{fullstack.selectedFrontendsJson}}} as const
|
|
7
9
|
export const SUPPORTED_LOCALES = [{{#each fullstack.i18n.locales}}'{{this}}'{{#unless @last}}, {{/unless}}{{/each}}] as const
|
|
8
10
|
|
|
9
11
|
/** 工程级 API contract,前端 typed client 与后端 service 共用。 */
|
|
10
12
|
export const {{fullstack.contractExportName}} = apiContract.create({
|
|
13
|
+
// @hai-generator:contracts:entries
|
|
11
14
|
app: appContract,
|
|
12
15
|
iam: { auth: authContract },
|
|
13
16
|
admin: adminContract,
|
|
@@ -21,6 +21,7 @@
|
|
|
21
21
|
- `config/_db.yml` / `_cache.yml` / `_iam.yml` / `_storage.yml` / `_audit.yml` / `_reach.yml`:管理及开发通知配置,初始化前使用模块公开 Schema 验证。Reach 默认控制台 Provider,生产按注释替换为 SMTP/短信/API。
|
|
22
22
|
- `config/_ai.yml` / `_vecdb.yml` / `_payment.yml` / `_scheduler.yml` / `_deploy.yml`:保留未启用模块的完整注释示例;配置文件存在不代表模块已接入。先在本 workspace 安装对应依赖,再在 `src/server/runtime.ts` 按公开 Schema 校验、初始化并反序关闭,接入实际业务后按需取消注释。整份注释先去掉一层外层注释,可选字段继续按需开启。
|
|
23
23
|
- `src/index.ts`:进程入口,先 `core.init({ configDir: './config' })`,再初始化 reldb → cache → crypto → storage → reach(有配置时)→ iam → audit;启动失败及退出反序释放。
|
|
24
|
+
- 选择 AI 时自动安装 reldb/vecdb/AI,并在 reldb/cache 之后初始化 vecdb、在其余依赖之后初始化 AI;关闭时先释放 AI,最后释放 vecdb/关系库,关闭失败会返回错误并保留重试义务。生产环境必须使用共享后端,根 `.env.prod` 默认连接 Compose `db/cache/storage/vecdb`,凭据和真实 Reach Provider JSON 私有注入。
|
|
24
25
|
- `src/app-server.ts`:服务装配入口,挂载健康检查、OpenAPI、docs 与 procedures;应用名称 / 版本与 CORS 来源均取自上述配置。
|
|
25
26
|
- `src/server/procedures/app-procedures.ts`:app 领域 procedures,对应 contract 中的 `app.info` / `app.echo` 路由。
|
|
26
27
|
- `src/server/procedures/admin-procedures.ts`:认证、管理员用户/角色/审计管理和个人设置,使用 `.auth()` / `.role('admin')` 守卫。每个请求读取用户最新启用状态、角色和权限。
|
|
@@ -30,7 +31,7 @@
|
|
|
30
31
|
|
|
31
32
|
CLI 已生成根目录 `.env.local`,`HAI_ADMIN_DEFAULT_PASSWORD` 为每个项目独立的随机强密码;`dev`/`start` 自动读取,首次登录使用用户名 `admin` 和该密码。仅在没有用户时创建管理员,不重设已有用户密码。密码不符合 `_iam.yml` 策略时,启动日志显示具体原因及错误码。`.env.local` 被 Git 和容器构建忽略,`.env.example` 不含真实密码;部署时设置目标环境自己的密码。所有业务 JSON 请求和响应强制加密;健康检查和随机路径的只读头像图片可直接访问。头像支持 PNG/JPEG/WebP,最大 512 KiB,校验内容特征和大小后由服务端生成 key,不能指定任意文件路径。
|
|
32
33
|
|
|
33
|
-
默认 SQLite + 本地 storage + memory cache 适合单实例,管理写入仅支持单写实例:进程内保护无过期时间,共享 cache 锁定期续租,丢失锁或释放失败会记录错误。多实例读取可使用共享数据库、Redis cache 和 S3 storage,但管理写请求必须统一路由到同一写实例;仅更换 Redis
|
|
34
|
+
默认 SQLite + 本地 storage + memory cache 适合单实例,管理写入仅支持单写实例:进程内保护无过期时间,共享 cache 锁定期续租,丢失锁或释放失败会记录错误。多实例读取可使用共享数据库、Redis cache 和 S3 storage,但管理写请求必须统一路由到同一写实例;仅更换 Redis 不提供任意多写副本的最后管理员原子保证。传输在所有节点共享受管版本化密钥环与 registry,不依赖会话粘性;重启加载原 ring,轮换先分发新旧版本再切 activeKeyId。Web 使用 HttpOnly refresh Cookie + 内存 access token;仅同时满足客户端标识、body 通道头与受信原生 Origin 的 App/Desktop 请求返回 refresh token body。
|
|
34
35
|
|
|
35
36
|
业务修改提交后的审计记录采用最大努力策略:写入失败输出包含操作及资源的结构化错误日志,业务仍返回原操作结果,避免将已经完成的变更误报为失败;应用启动时 audit 初始化失败则停止启动。
|
|
36
37
|
|
|
@@ -38,6 +39,8 @@ CLI 已生成根目录 `.env.local`,`HAI_ADMIN_DEFAULT_PASSWORD` 为每个项
|
|
|
38
39
|
|
|
39
40
|
所有配置字段以模块 Schema 为准;未使用字段和替代 Provider 用注释保留。叶子环境变量只覆盖已有 YAML 路径,先取消相应键的注释,或用 `HAI_DB` / `HAI_CACHE` 等整体对象覆盖;`connectTimeout` 对应 `HAI_CACHE_CONNECTTIMEOUT`。凭据示例在根 `.env.example`,实际密钥保存在忽略的 `.env.local` 或部署环境。`.gitignore` 的 `**/data/` 忽略所有运行数据及 `.gitkeep`;运行时会创建目录。`crypto.init()` 无配置参数,datapipe 为纯函数,不提供虚构配置。
|
|
40
41
|
|
|
42
|
+
CLI 同时在根 `.env.local` 一次性生成独立开发用 `HAI_CRYPTO_TRANSPORT_SERVERKEYRING`,结构为 `{activeKeyId,keys:{id:{publicKey,privateKey}}}`,运行时不隐式生成。部署由 Secret Manager 向所有节点分发相同受管 ring,客户端 registry 复用共享 reldb。私钥不进入普通 YAML、PUBLIC_/VITE_、日志或公开响应。轮换先分发含新旧版本的 ring,再切 activeKeyId 并滚动重建;过渡期后删除旧 ID,旧客户端 destroy 后重协商。没有 sticky session 依赖。
|
|
43
|
+
|
|
41
44
|
自动生成的 `.env` 包含已启用模块的环境参数;YAML 显式引用相同变量并保留本地回退。使用 `start:test` / `start:prod` 加载对应环境覆盖。
|
|
42
45
|
|
|
43
46
|
- `pnpm --filter {{fullstack.servPackageName}} dev`
|
|
@@ -55,7 +58,7 @@ CLI 已生成根目录 `.env.local`,`HAI_ADMIN_DEFAULT_PASSWORD` 为每个项
|
|
|
55
58
|
|
|
56
59
|
服务端 `config/_iam.yml` 统一控制登录、注册、重置、协议与 `ui` 品牌。邮件/短信可用 `_reach.yml` 中的 Provider/标准模板或自定义发送回调。`AuthPanel` 使用安全公开配置及框架认证动作;无需在页面适配 Token、验证码倒计时和错误提示。单实例可使用 Memory;多实例需共享数据库与 Redis,并按工程根 README 配置写入路由和传输会话。
|
|
57
60
|
|
|
58
|
-
LDAP/Reach 均可选:基础认证无需安装。默认 LDAP 客户端需在 IAM 服务端 workspace 安装 ldapts,再配置 login.ldap 与连接信息。自定义 onOtpSendEmail/onOtpSendSms/onPasswordResetRequest 回调可独立于 Reach 使用,投递失败须拒绝 Promise
|
|
61
|
+
LDAP/Reach 均可选:基础认证无需安装。默认 LDAP 客户端需在 IAM 服务端 workspace 安装 ldapts,再配置 login.ldap 与连接信息。自定义 onOtpSendEmail/onOtpSendSms/onPasswordResetRequest 回调可独立于 Reach 使用,投递失败须拒绝 Promise;回调第三参数 context 提供 expiresAt/idempotencyKey,发送服务按稳定键持久化去重,异常/超时为结果未知且不自动重发。协议在 Modal 显示;LoginForm/RegisterForm 提供 header/extraFields/footer,AuthPanel 透传 loginHeader/loginFields/loginFooter 和 registerHeader/registerFields/registerFooter。配置及例子见生成项目的 hai-iam/hai-ui skills。
|
|
59
62
|
|
|
60
63
|
协议支持配置中的 userAgreementMarkdown/privacyPolicyMarkdown 正文及 URL;同主题优先 Markdown,由安全渲染器在弹窗显示。
|
|
61
64
|
|
|
@@ -9,7 +9,6 @@
|
|
|
9
9
|
{{yaml 'name' fullstack.appName}}
|
|
10
10
|
version: 0.1.0
|
|
11
11
|
env: development
|
|
12
|
-
debug: false
|
|
13
12
|
|
|
14
13
|
defaultLocale: {{fullstack.i18n.baseLocale}}
|
|
15
14
|
logging:
|
|
@@ -21,8 +20,3 @@ logging:
|
|
|
21
20
|
- password
|
|
22
21
|
- token
|
|
23
22
|
- secret
|
|
24
|
-
|
|
25
|
-
# 短 ID 生成配置;未设置时采用框架默认值。
|
|
26
|
-
# id:
|
|
27
|
-
# prefix: app_
|
|
28
|
-
# length: 21
|
|
@@ -28,6 +28,7 @@ providers:
|
|
|
28
28
|
# headers:
|
|
29
29
|
# Authorization: '${HAI_REACH_PROVIDERS_0_HEADERS_AUTHORIZATION}'
|
|
30
30
|
# timeout: 10000
|
|
31
|
+
# idempotent: ${HAI_REACH_PROVIDERS_0_IDEMPOTENT:false} # 仅接收端具备原子持久去重时启用;默认在途不明结果转 unknown
|
|
31
32
|
templates:
|
|
32
33
|
- name: otp_email
|
|
33
34
|
provider: email
|
|
@@ -50,3 +51,10 @@ templates:
|
|
|
50
51
|
# strategy: discard # discard | delay
|
|
51
52
|
# start: '22:00'
|
|
52
53
|
# end: '08:00'
|
|
54
|
+
|
|
55
|
+
# 延迟队列策略随每条消息保存;最多领取 5 次,指数退避 1 分钟至 1 小时。
|
|
56
|
+
# 超限 dead 只经可信服务端人工恢复;unknown 不允许自动重发。
|
|
57
|
+
queue:
|
|
58
|
+
maxAttempts: ${HAI_REACH_QUEUE_MAXATTEMPTS:5}
|
|
59
|
+
retryDelayMs: ${HAI_REACH_QUEUE_RETRYDELAYMS:60000}
|
|
60
|
+
maxRetryDelayMs: ${HAI_REACH_QUEUE_MAXRETRYDELAYMS:3600000}
|
|
@@ -42,11 +42,12 @@ cors:
|
|
|
42
42
|
- X-HAI-Token-Transport
|
|
43
43
|
- X-Client-Id
|
|
44
44
|
- X-Encrypted
|
|
45
|
+
- X-Hai-Transport
|
|
45
46
|
exposedHeaders:
|
|
46
47
|
- X-Request-Id
|
|
47
48
|
- X-Encrypted
|
|
49
|
+
- X-Hai-Transport
|
|
48
50
|
|
|
49
51
|
transport:
|
|
50
52
|
keyExchangePath: /_hai/key-exchange
|
|
51
|
-
maxClients: 10000
|
|
52
53
|
# excludePaths: [/health, /ready]
|
|
@@ -35,6 +35,9 @@
|
|
|
35
35
|
"start:prod": "pnpm start"
|
|
36
36
|
},
|
|
37
37
|
"dependencies": {
|
|
38
|
+
{{#if features.ai}}
|
|
39
|
+
"@h-ai/ai": "{{haiPackageSpecifier}}",
|
|
40
|
+
{{/if}}
|
|
38
41
|
"@h-ai/audit": "{{haiPackageSpecifier}}",
|
|
39
42
|
"@h-ai/cache": "{{haiPackageSpecifier}}",
|
|
40
43
|
"@h-ai/core": "{{haiPackageSpecifier}}",
|
|
@@ -44,6 +47,9 @@
|
|
|
44
47
|
"@h-ai/reldb": "{{haiPackageSpecifier}}",
|
|
45
48
|
"@h-ai/serv": "{{haiPackageSpecifier}}",
|
|
46
49
|
"@h-ai/storage": "{{haiPackageSpecifier}}",
|
|
50
|
+
{{#if features.vecdb}}
|
|
51
|
+
"@h-ai/vecdb": "{{haiPackageSpecifier}}",
|
|
52
|
+
{{/if}}
|
|
47
53
|
"zod": "{{fullstack.packageSpecifiers.zod}}",
|
|
48
54
|
"{{fullstack.contractPackageName}}": "workspace:*"
|
|
49
55
|
}
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import type { CoreConfig } from '@h-ai/core'
|
|
2
2
|
import type { ServHttpApp } from '@h-ai/serv'
|
|
3
|
+
import process from 'node:process'
|
|
3
4
|
import { core } from '@h-ai/core'
|
|
4
|
-
import { createReldbTransportKeyStore, crypto } from '@h-ai/crypto'
|
|
5
|
+
import { createReldbTransportKeyStore, crypto, parseTransportServerKeyRing } from '@h-ai/crypto'
|
|
5
6
|
import { iam } from '@h-ai/iam'
|
|
6
7
|
import { reldb } from '@h-ai/reldb'
|
|
7
8
|
import { serv, ServConfigSchema } from '@h-ai/serv'
|
|
@@ -13,6 +14,9 @@ const STARTED_AT = new Date().toISOString()
|
|
|
13
14
|
|
|
14
15
|
/** 创建后端 HTTP App;与 @h-ai/serv 的 createApp 返回语义保持一致。 */
|
|
15
16
|
export function createServerApp(): ServHttpApp {
|
|
17
|
+
const ring = parseTransportServerKeyRing(process.env.HAI_CRYPTO_TRANSPORT_SERVERKEYRING)
|
|
18
|
+
if (!ring.success)
|
|
19
|
+
throw new Error(ring.error.message)
|
|
16
20
|
const coreConfig = core.config.getOrThrow<CoreConfig>('core')
|
|
17
21
|
const servConfig = ServConfigSchema.parse(core.config.getOrThrow('serv'))
|
|
18
22
|
const security = serv.createRuntimeSecurityPolicy({
|
|
@@ -46,8 +50,8 @@ export function createServerApp(): ServHttpApp {
|
|
|
46
50
|
&& ['{{projectName}}-app', '{{projectName}}-desktop'].includes(request.headers.get('x-request-client') ?? '')
|
|
47
51
|
&& security.isNativeOrigin(request.headers.get('origin') ?? '') },
|
|
48
52
|
},
|
|
49
|
-
//
|
|
50
|
-
transport: { ...(servConfig.transport || {}), excludePaths: publicPaths, crypto, keyStore: createReldbTransportKeyStore({ reldb }) },
|
|
53
|
+
// 管理业务默认强制双向加密;各节点复用受管服务端密钥环与共享 reldb 客户端 registry。
|
|
54
|
+
transport: { ...(servConfig.transport || {}), excludePaths: publicPaths, crypto, serverKeyRing: ring.data, keyStore: createReldbTransportKeyStore({ reldb, ttlSeconds: 3600 }) },
|
|
51
55
|
http: {
|
|
52
56
|
...servConfig.http,
|
|
53
57
|
openapi: security.exposeApiDocs ? servConfig.http.openapi : false,
|
|
@@ -57,8 +61,8 @@ export function createServerApp(): ServHttpApp {
|
|
|
57
61
|
middleware: serv.cors({
|
|
58
62
|
origin: security.allowOrigin,
|
|
59
63
|
credentials: servConfig.cors.credentials,
|
|
60
|
-
allowedHeaders: [...new Set([...servConfig.cors.allowedHeaders, 'X-Client-Id', 'X-Encrypted', 'X-Request-Client', 'X-HAI-Token-Transport'])],
|
|
61
|
-
exposedHeaders: [...new Set([...servConfig.cors.exposedHeaders, 'X-Encrypted'])],
|
|
64
|
+
allowedHeaders: [...new Set([...servConfig.cors.allowedHeaders, 'X-Client-Id', 'X-Encrypted', 'X-Hai-Transport', 'X-Request-Client', 'X-HAI-Token-Transport'])],
|
|
65
|
+
exposedHeaders: [...new Set([...servConfig.cors.exposedHeaders, 'X-Encrypted', 'X-Hai-Transport'])],
|
|
62
66
|
}),
|
|
63
67
|
}, {
|
|
64
68
|
path: `${servConfig.http.apiPrefix}/assets/*`,
|
package/templates/apps/fullstack/packages/{{projectName}}-serv/src/server/admin-service.ts.hbs
CHANGED
|
@@ -21,18 +21,19 @@ export async function withAdminMutation<T>(context: ServContext, work: () => Pro
|
|
|
21
21
|
if (adminMutationActive)
|
|
22
22
|
return err(HaiCommonError.CONFLICT, serverM('busy', { locale: context.locale }))
|
|
23
23
|
adminMutationActive = true
|
|
24
|
-
|
|
24
|
+
let owner = ''
|
|
25
25
|
const key = 'hai:app:admin-mutation'
|
|
26
26
|
let acquired = false
|
|
27
27
|
let leaseLost = false
|
|
28
28
|
let renewalPending = false
|
|
29
29
|
let renewalTimer: ReturnType<typeof setInterval> | undefined
|
|
30
30
|
try {
|
|
31
|
-
const locked = await cache.lock.acquire(key, {
|
|
31
|
+
const locked = await cache.lock.acquire(key, { ttl: 300 })
|
|
32
32
|
if (!locked.success)
|
|
33
33
|
return locked
|
|
34
34
|
if (!locked.data)
|
|
35
35
|
return err(HaiCommonError.CONFLICT, serverM('busy', { locale: context.locale }))
|
|
36
|
+
owner = locked.data
|
|
36
37
|
acquired = true
|
|
37
38
|
renewalTimer = setInterval(() => {
|
|
38
39
|
if (renewalPending || leaseLost)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { core } from '@h-ai/core'
|
|
2
2
|
|
|
3
|
-
/**
|
|
3
|
+
/** 服务端业务提示与角色种子名称同步维护中英日文。 */
|
|
4
4
|
export const serverM = core.i18n.createMessageGetter({
|
|
5
5
|
'zh-CN': {
|
|
6
6
|
admin: '管理员',
|
|
@@ -8,18 +8,33 @@ export const serverM = core.i18n.createMessageGetter({
|
|
|
8
8
|
adminDescription: '管理用户、角色和审计日志',
|
|
9
9
|
userDescription: '仅可维护个人设置',
|
|
10
10
|
notFound: '用户或角色不存在',
|
|
11
|
+
unauthorized: '请提供要撤销的访问令牌',
|
|
11
12
|
selfProtection: '不能删除、禁用或降级当前管理员账号',
|
|
12
13
|
invalidAvatar: '请选择不超过 512 KB 的有效 PNG、JPEG 或 WebP 图片',
|
|
13
14
|
busy: '系统繁忙,请稍后重试',
|
|
14
15
|
unsupportedPreference: '不支持此语言或主题',
|
|
15
16
|
invalidDateRange: '结束时间不能早于开始时间',
|
|
16
17
|
},
|
|
18
|
+
'ja-JP': {
|
|
19
|
+
admin: '管理者',
|
|
20
|
+
user: 'ユーザー',
|
|
21
|
+
adminDescription: 'ユーザー、ロール、監査ログを管理',
|
|
22
|
+
userDescription: '個人設定のみ管理',
|
|
23
|
+
notFound: 'ユーザーまたはロールが存在しません',
|
|
24
|
+
unauthorized: '取り消すアクセストークンを指定してください',
|
|
25
|
+
selfProtection: '自分の管理者アカウントは削除、無効化、降格できません',
|
|
26
|
+
invalidAvatar: '512KB以下の有効なPNG、JPEG、WebP画像を選択してください',
|
|
27
|
+
busy: 'システムが混雑しています。再試行してください',
|
|
28
|
+
unsupportedPreference: 'この言語またはテーマには対応していません',
|
|
29
|
+
invalidDateRange: '終了日は開始日以降にしてください',
|
|
30
|
+
},
|
|
17
31
|
'en-US': {
|
|
18
32
|
admin: 'Administrator',
|
|
19
33
|
user: 'User',
|
|
20
34
|
adminDescription: 'Manage users, roles and audit logs',
|
|
21
35
|
userDescription: 'Manage personal settings only',
|
|
22
36
|
notFound: 'User or role does not exist',
|
|
37
|
+
unauthorized: 'Provide the access token to revoke',
|
|
23
38
|
selfProtection: 'You cannot delete, disable or demote your own administrator account',
|
|
24
39
|
invalidAvatar: 'Choose a valid PNG, JPEG or WebP image up to 512 KB',
|
|
25
40
|
busy: 'The system is busy. Please try again',
|
|
@@ -29,11 +29,15 @@ export function createAuthProcedures() {
|
|
|
29
29
|
return result
|
|
30
30
|
await recordAudit({ ...context, session: { userId: result.data.user.id, roles: result.data.roles, permissions: result.data.permissions } }, 'login', 'auth')
|
|
31
31
|
return result
|
|
32
|
-
}).route('logout'
|
|
33
|
-
const
|
|
32
|
+
}).route('logout', async ({ input, context }) => {
|
|
33
|
+
const token = input.accessToken ?? context.accessToken
|
|
34
|
+
if (!token)
|
|
35
|
+
return err(HaiCommonError.UNAUTHORIZED, serverM('unauthorized', { locale: context.locale }))
|
|
36
|
+
const result = await iam.auth.logout(token)
|
|
34
37
|
if (!result.success)
|
|
35
38
|
return result
|
|
36
|
-
|
|
39
|
+
if (context.session)
|
|
40
|
+
await recordAudit(context, 'logout', 'auth')
|
|
37
41
|
return result
|
|
38
42
|
}).route('changePassword').auth().handle(async ({ input, context }) => {
|
|
39
43
|
const result = await iam.user.changeCurrentUserPassword(context.accessToken ?? '', input.oldPassword, input.newPassword)
|
package/templates/apps/fullstack/packages/{{projectName}}-serv/src/server/procedures/index.ts.hbs
CHANGED
|
@@ -2,6 +2,8 @@ import type { CreateAppProceduresOptions } from './app-procedures.js'
|
|
|
2
2
|
import { createAdminProcedures, createAuthProcedures, createProfileProcedures } from './admin-procedures.js'
|
|
3
3
|
import { createAppProcedures } from './app-procedures.js'
|
|
4
4
|
|
|
5
|
+
// @hai-generator:procedures:imports
|
|
6
|
+
|
|
5
7
|
export interface CreateServerProceduresOptions {
|
|
6
8
|
readonly app: CreateAppProceduresOptions
|
|
7
9
|
}
|
|
@@ -9,6 +11,7 @@ export interface CreateServerProceduresOptions {
|
|
|
9
11
|
/** 工程级 procedures 聚合入口;后续增加领域时在这里追加即可。 */
|
|
10
12
|
export function createServerProcedures(options: CreateServerProceduresOptions) {
|
|
11
13
|
return {
|
|
14
|
+
// @hai-generator:procedures:entries
|
|
12
15
|
app: createAppProcedures(options.app),
|
|
13
16
|
iam: { auth: createAuthProcedures() },
|
|
14
17
|
admin: createAdminProcedures(),
|
|
@@ -2,6 +2,9 @@ import type { CoreConfig, HaiResult } from '@h-ai/core'
|
|
|
2
2
|
import { mkdir } from 'node:fs/promises'
|
|
3
3
|
import path from 'node:path'
|
|
4
4
|
import process from 'node:process'
|
|
5
|
+
{{#if features.ai}}
|
|
6
|
+
import { ai, AIConfigSchema } from '@h-ai/ai'
|
|
7
|
+
{{/if}}
|
|
5
8
|
import { audit, AuditInitConfigSchema } from '@h-ai/audit'
|
|
6
9
|
import { cache, CacheConfigSchema } from '@h-ai/cache'
|
|
7
10
|
import { core } from '@h-ai/core'
|
|
@@ -11,13 +14,20 @@ import { reach, ReachConfigSchema } from '@h-ai/reach'
|
|
|
11
14
|
import { reldb, ReldbConfigSchema } from '@h-ai/reldb'
|
|
12
15
|
import { ServConfigSchema } from '@h-ai/serv'
|
|
13
16
|
import { storage, StorageConfigSchema } from '@h-ai/storage'
|
|
17
|
+
{{#if features.vecdb}}
|
|
18
|
+
import { vecdb, VecdbConfigSchema } from '@h-ai/vecdb'
|
|
19
|
+
{{/if}}
|
|
14
20
|
import { serverM } from './messages.js'
|
|
15
21
|
|
|
16
22
|
let initialized = false
|
|
17
23
|
let initializing: Promise<void> | undefined
|
|
24
|
+
let closing: Promise<void> | undefined
|
|
25
|
+
let cleanupPending = false
|
|
18
26
|
|
|
19
27
|
/** 初始化失败停止启动并反序释放资源;不把功能缺失降级为成功。 */
|
|
20
28
|
export async function initializeModules(): Promise<void> {
|
|
29
|
+
if (closing)
|
|
30
|
+
await closing
|
|
21
31
|
if (initialized)
|
|
22
32
|
return
|
|
23
33
|
initializing ??= runInitialization().finally(() => {
|
|
@@ -27,6 +37,9 @@ export async function initializeModules(): Promise<void> {
|
|
|
27
37
|
}
|
|
28
38
|
|
|
29
39
|
async function runInitialization(): Promise<void> {
|
|
40
|
+
if (cleanupPending)
|
|
41
|
+
await cleanupModules()
|
|
42
|
+
cleanupPending = true
|
|
30
43
|
try {
|
|
31
44
|
requireInitialized(core.config.validate('serv', ServConfigSchema), 'serv-config')
|
|
32
45
|
const db = ReldbConfigSchema.parse(core.config.getOrThrow('db'))
|
|
@@ -38,6 +51,10 @@ async function runInitialization(): Promise<void> {
|
|
|
38
51
|
await mkdir(path.dirname(db.database), { recursive: true })
|
|
39
52
|
requireInitialized(await reldb.init(db), 'reldb')
|
|
40
53
|
requireInitialized(await cache.init(cacheConfig), 'cache')
|
|
54
|
+
{{#if features.vecdb}}
|
|
55
|
+
const vectorConfig = VecdbConfigSchema.parse(core.config.getOrThrow('vecdb'))
|
|
56
|
+
requireInitialized(await vecdb.init(vectorConfig), 'vecdb')
|
|
57
|
+
{{/if}}
|
|
41
58
|
requireInitialized(await crypto.init(), 'crypto')
|
|
42
59
|
requireInitialized(await storage.init(storageConfig), 'storage')
|
|
43
60
|
const reachConfig = core.config.get('reach')
|
|
@@ -45,13 +62,23 @@ async function runInitialization(): Promise<void> {
|
|
|
45
62
|
requireInitialized(await reach.init(ReachConfigSchema.parse(reachConfig)), 'reach')
|
|
46
63
|
requireInitialized(await iam.init({ ...iamConfig, reach }), 'iam')
|
|
47
64
|
requireInitialized(await audit.init(auditConfig), 'audit')
|
|
65
|
+
{{#if features.ai}}
|
|
66
|
+
const aiConfig = AIConfigSchema.parse(core.config.getOrThrow('ai'))
|
|
67
|
+
requireInitialized(await ai.init(aiConfig), 'ai')
|
|
68
|
+
{{/if}}
|
|
48
69
|
await ensureRolesAndAdmin()
|
|
49
70
|
initialized = true
|
|
71
|
+
cleanupPending = false
|
|
50
72
|
const app = core.config.getOrThrow<CoreConfig>('core')
|
|
51
73
|
core.logger.info('Application initialized', { name: app.name, version: app.version, environment: app.env })
|
|
52
74
|
}
|
|
53
75
|
catch (error) {
|
|
54
|
-
|
|
76
|
+
try {
|
|
77
|
+
await cleanupModules()
|
|
78
|
+
}
|
|
79
|
+
catch (cleanupError) {
|
|
80
|
+
throw new AggregateError([error, cleanupError], 'Application startup and cleanup failed')
|
|
81
|
+
}
|
|
55
82
|
throw error
|
|
56
83
|
}
|
|
57
84
|
}
|
|
@@ -91,12 +118,36 @@ async function ensureRolesAndAdmin(): Promise<void> {
|
|
|
91
118
|
|
|
92
119
|
/** 关闭顺序与依赖初始化顺序相反,同时覆盖启动失败路径。 */
|
|
93
120
|
export async function closeModules(): Promise<void> {
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
121
|
+
if (!closing) {
|
|
122
|
+
const pending = initializing
|
|
123
|
+
closing = (async () => {
|
|
124
|
+
if (pending)
|
|
125
|
+
await pending.catch(() => undefined)
|
|
126
|
+
await cleanupModules()
|
|
127
|
+
})().finally(() => {
|
|
128
|
+
closing = undefined
|
|
129
|
+
})
|
|
130
|
+
}
|
|
131
|
+
return closing
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** 不依赖 isInitialized 推断资源是否释放;关闭失败必须保留义务供重试。 */
|
|
135
|
+
async function cleanupModules(): Promise<void> {
|
|
136
|
+
const failures: unknown[] = []
|
|
137
|
+
for (const [name, module] of [{{#if features.ai}}['ai', ai], {{/if}}['audit', audit], ['iam', iam], ['reach', reach], ['storage', storage], ['crypto', crypto], {{#if features.vecdb}}['vecdb', vecdb], {{/if}}['cache', cache], ['reldb', reldb]] as const) {
|
|
138
|
+
try {
|
|
139
|
+
const result = await module.close()
|
|
140
|
+
if (result && !result.success) {
|
|
141
|
+
core.logger.error('Failed to close application module', { module: name, error: result.error.code })
|
|
142
|
+
failures.push(result.error)
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
catch (error) {
|
|
146
|
+
failures.push(error)
|
|
147
|
+
}
|
|
100
148
|
}
|
|
101
149
|
initialized = false
|
|
150
|
+
cleanupPending = failures.length > 0
|
|
151
|
+
if (failures.length > 0)
|
|
152
|
+
throw new AggregateError(failures, 'Application cleanup failed; retry closeModules()')
|
|
102
153
|
}
|
|
@@ -46,6 +46,10 @@ beforeAll(async () => {
|
|
|
46
46
|
vi.stubEnv('HAI_ADMIN_DEFAULT_PASSWORD', TEST_PASSWORD)
|
|
47
47
|
core.init({ configDir: './config' })
|
|
48
48
|
await initializeModules()
|
|
49
|
+
const key = crypto.asymmetric.generateKeyPair()
|
|
50
|
+
if (!key.success)
|
|
51
|
+
throw new Error(key.error.message)
|
|
52
|
+
vi.stubEnv('HAI_CRYPTO_TRANSPORT_SERVERKEYRING', JSON.stringify({ activeKeyId: 'test-v1', keys: { 'test-v1': key.data } }))
|
|
49
53
|
app = createServerApp()
|
|
50
54
|
transport = crypto.transport.createClient({
|
|
51
55
|
keyExchangeUrl: `http://localhost${PREFIX}/_hai/key-exchange`,
|
|
@@ -339,7 +343,10 @@ describe('{{fullstack.servPackageName}} mini administration', () => {
|
|
|
339
343
|
})
|
|
340
344
|
|
|
341
345
|
it('rejects stale administrator snapshots and reports fresh user roles', async () => {
|
|
342
|
-
const
|
|
346
|
+
const snapshot = await iam.session.get(userToken)
|
|
347
|
+
if (!snapshot.success || !snapshot.data)
|
|
348
|
+
throw new Error('Session unavailable')
|
|
349
|
+
const stale = await iam.session.update(userToken, { roles: ['admin'], permissions: ['*'] }, snapshot.data.version)
|
|
343
350
|
expect(stale.success).toBe(true)
|
|
344
351
|
for (const [route, input] of [['/admin/users/list', { page: 1, pageSize: 20 }], ['/admin/roles/list', undefined], ['/admin/audit/list', { page: 1, pageSize: 20 }]] as const) {
|
|
345
352
|
const denied = await call(route, input, userToken)
|
|
@@ -354,8 +361,11 @@ describe('{{fullstack.servPackageName}} mini administration', () => {
|
|
|
354
361
|
})
|
|
355
362
|
|
|
356
363
|
it('returns a conflict when another administrator holds the mutation lock and resumes after release', async () => {
|
|
357
|
-
const locked = await cache.lock.acquire('hai:app:admin-mutation', {
|
|
364
|
+
const locked = await cache.lock.acquire('hai:app:admin-mutation', { ttl: 10 })
|
|
358
365
|
expect(locked.success).toBe(true)
|
|
366
|
+
if (!locked.success || !locked.data)
|
|
367
|
+
throw new Error('Mutation lock unavailable')
|
|
368
|
+
const owner = locked.data
|
|
359
369
|
try {
|
|
360
370
|
const result = await call('/admin/users/update', { id: user.id, displayName: 'Locked' }, adminToken)
|
|
361
371
|
expect(result.success).toBe(false)
|
|
@@ -363,7 +373,7 @@ describe('{{fullstack.servPackageName}} mini administration', () => {
|
|
|
363
373
|
expect(result.error.httpStatus).toBe(409)
|
|
364
374
|
}
|
|
365
375
|
finally {
|
|
366
|
-
expect((await cache.lock.release('hai:app:admin-mutation',
|
|
376
|
+
expect((await cache.lock.release('hai:app:admin-mutation', owner)).success).toBe(true)
|
|
367
377
|
}
|
|
368
378
|
expect((await call('/admin/users/update', { id: user.id, displayName: 'Unlocked' }, adminToken)).success).toBe(true)
|
|
369
379
|
})
|
|
@@ -40,11 +40,11 @@ Language and theme preferences are saved in IAM user metadata, restored after lo
|
|
|
40
40
|
|
|
41
41
|
AdminWorkspace mounts the shared `@h-ai/ui/guide` controller and GuideHost after authentication. AppShell includes a help button; first-use invitations offer start, later and dismiss. Web stores preferences in Cookies, while App/Desktop use localStorage, scoped by the current user and tour version. Help articles link to the overview and profile tours.
|
|
42
42
|
|
|
43
|
-
Extend `src/lib/stores/guide-store.ts` and the shared Chinese/English messages, then add stable data-guide attributes to existing components. Navigation uses the same ManagementStore. Keep business text as i18n callbacks and preserve the host's locale/scope binding. Browser tests cover completion, reload, replay, settings navigation and mobile help.
|
|
43
|
+
Extend `src/lib/stores/guide-store.ts` and the shared Chinese/English/Japanese messages, then add stable data-guide attributes to existing components. Navigation uses the same ManagementStore. Keep business text as i18n callbacks and preserve the host's locale/scope binding. Browser tests cover completion, reload, replay, settings navigation and mobile help.
|
|
44
44
|
|
|
45
45
|
### i18n and themes
|
|
46
46
|
|
|
47
|
-
Shared page copy belongs in `messages/{zh-CN,en-US}.json`. Frontend-specific copy belongs in each app's own messages directory. Keep `baseLocale` and `locales` aligned across all enabled projects, then run root `pnpm i18n:compile`; do not edit generated Paraglide files.
|
|
47
|
+
Shared page copy belongs in `messages/{zh-CN,en-US,ja-JP}.json`. Frontend-specific copy belongs in each app's own messages directory. Keep `baseLocale` and `locales` aligned across all enabled projects, then run root `pnpm i18n:compile`; do not edit generated Paraglide files.
|
|
48
48
|
|
|
49
49
|
All frontend and shared projects load the installed message-format plugin from the workspace root `node_modules`, matching the scaffold's hoisted dependency layout. Compilation uses the workspace dependency version and does not fetch a plugin from a CDN.
|
|
50
50
|
|
|
@@ -56,11 +56,11 @@ Run root `pnpm typecheck`, `pnpm lint`, `pnpm build`, `pnpm test` and `pnpm test
|
|
|
56
56
|
|
|
57
57
|
### IAM configuration
|
|
58
58
|
|
|
59
|
-
The service's `config/_iam.yml` controls login methods, registration, password reset, agreements and `ui` branding. Email and SMS use providers/templates in `_reach.yml` or custom callbacks. `AuthPanel` uses the public configuration and framework auth actions to manage tokens, resend countdowns and errors. A single process can use memory cache. Multiple instances need shared database/cache, write routing and transport
|
|
59
|
+
The service's `config/_iam.yml` controls login methods, registration, password reset, agreements and `ui` branding. Email and SMS use providers/templates in `_reach.yml` or custom callbacks. `AuthPanel` uses the public configuration and framework auth actions to manage tokens, resend countdowns and errors. A single process can use memory cache. Multiple instances need shared database/cache, write routing and a shared managed transport key ring and registry as described in the project README.
|
|
60
60
|
|
|
61
61
|
Authentication defaults to a split layout. Set `ui.variant`, `title`, `subtitle`, `brandTitle`, `brandText`, `logoUrl` and `illustrationUrl` to customize login and registration. Without a `brandTitle`, the displayed application name comes from `app.info` and follows `core` configuration.
|
|
62
62
|
|
|
63
|
-
LDAP and Reach are optional. Install ldapts in the IAM service workspace only when using its default LDAP client, then enable login.ldap and set connection settings. Custom onOtpSendEmail/onOtpSendSms/onPasswordResetRequest callbacks work without Reach and must reject on delivery failure. Agreements open in a Modal. LoginForm/RegisterForm support header/extraFields/footer snippets; AuthPanel forwards loginHeader/loginFields/loginFooter and registerHeader/registerFields/registerFooter. See the generated hai-iam/hai-ui skills for configuration and examples.
|
|
63
|
+
LDAP and Reach are optional. Install ldapts in the IAM service workspace only when using its default LDAP client, then enable login.ldap and set connection settings. Custom onOtpSendEmail/onOtpSendSms/onPasswordResetRequest callbacks work without Reach and must reject on delivery failure. Their third context argument supplies expiresAt/idempotencyKey; senders durably deduplicate that key, and errors/timeouts mean unknown delivery with no automatic resend. Agreements open in a Modal. LoginForm/RegisterForm support header/extraFields/footer snippets; AuthPanel forwards loginHeader/loginFields/loginFooter and registerHeader/registerFields/registerFooter. See the generated hai-iam/hai-ui skills for configuration and examples.
|
|
64
64
|
|
|
65
65
|
Agreements accept Markdown text or a URL. Markdown takes precedence and opens in a safely rendered dialog.
|
|
66
66
|
|
|
@@ -74,6 +74,11 @@ The user editor uses `FormLayout columns={2} labelPosition="left"`. Personal set
|
|
|
74
74
|
|
|
75
75
|
Run root `pnpm typecheck`, `pnpm lint`, `pnpm build`, `pnpm test` and `pnpm test:e2e`. Browser checks cover shared workflows; test native shells on their target platforms separately.
|
|
76
76
|
|
|
77
|
+
## 界面设计与验收
|
|
78
|
+
|
|
79
|
+
共享管理界面的 Web/App/Desktop 使用同一套 UI 组件和状态。设计、实现、审查分别参考根目录 `.agents/skills/hai-ui-design/`、`hai-ui/`、`hai-ui-review/`;正常、加载、空态、错误恢复、键盘、语言/主题和窄容器均按实际场景验收。组件的筛选、排序和分页仍由业务数据回调接入。
|
|
80
|
+
|
|
81
|
+
|
|
77
82
|
## License
|
|
78
83
|
|
|
79
84
|
Apache-2.0
|