@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.
Files changed (164) hide show
  1. package/README.en.md +40 -4
  2. package/README.ja.md +40 -4
  3. package/README.md +40 -4
  4. package/dist/{src-2V6VP4I5.js → chunk-FI56GONY.js} +32 -3103
  5. package/dist/chunk-FI56GONY.js.map +1 -0
  6. package/dist/chunk-MBPB37FR.js +1348 -0
  7. package/dist/chunk-MBPB37FR.js.map +1 -0
  8. package/dist/{chunk-7DSURTJ4.js → chunk-ZFNBUF77.js} +327 -199
  9. package/dist/chunk-ZFNBUF77.js.map +1 -0
  10. package/dist/cli-config-templates-W2LLPMB6.js +5 -0
  11. package/dist/{cli-config-templates-6AHNUGBN.js.map → cli-config-templates-W2LLPMB6.js.map} +1 -1
  12. package/dist/{cli-skill-templates-FC3QRKDO.js → cli-skill-templates-US5CCAJ4.js} +3 -3
  13. package/dist/{cli-skill-templates-FC3QRKDO.js.map → cli-skill-templates-US5CCAJ4.js.map} +1 -1
  14. package/dist/index.js +920 -336
  15. package/dist/index.js.map +1 -1
  16. package/dist/metafile-esm.json +1 -0
  17. package/dist/src-DSJJXM2F.js +3849 -0
  18. package/dist/src-DSJJXM2F.js.map +1 -0
  19. package/package.json +9 -8
  20. package/templates/apps/admin/messages/ja-JP.json +38 -0
  21. package/templates/apps/admin/src/routes/+layout.svelte.hbs +16 -6
  22. package/templates/apps/admin/src/routes/admin/+layout.svelte.hbs +3 -3
  23. package/templates/apps/api/apps/{{projectName}}-contract/src/index.ts.hbs +2 -0
  24. package/templates/apps/api/apps/{{projectName}}-contract/src/{{projectName}}-contract.ts.hbs +4 -1
  25. package/templates/apps/api/apps/{{projectName}}-service/README.md.hbs +5 -1
  26. package/templates/apps/api/apps/{{projectName}}-service/config/_core.yml.hbs +0 -10
  27. package/templates/apps/api/apps/{{projectName}}-service/config/_serv.yml.hbs +2 -1
  28. package/templates/apps/api/apps/{{projectName}}-service/src/app.ts.hbs +24 -11
  29. package/templates/apps/api/apps/{{projectName}}-service/src/index.ts.hbs +8 -1
  30. package/templates/apps/api/apps/{{projectName}}-service/src/lib/server/init.ts.hbs +68 -11
  31. package/templates/apps/api/apps/{{projectName}}-service/src/server/procedures/index.ts.hbs +10 -2
  32. package/templates/apps/api/e2e/api-service.spec.ts.hbs +2 -2
  33. package/templates/apps/api/package.json.hbs +1 -1
  34. package/templates/apps/api/playwright.config.ts.hbs +15 -7
  35. package/templates/apps/fullstack/apps/{{projectName}}-app/README.md.hbs +1 -1
  36. package/templates/apps/fullstack/apps/{{projectName}}-app/messages/ja-JP.json.hbs +7 -0
  37. package/templates/apps/fullstack/apps/{{projectName}}-app/src/App.svelte.hbs +7 -1
  38. package/templates/apps/fullstack/apps/{{projectName}}-app/vite.config.ts.hbs +1 -1
  39. package/templates/apps/fullstack/apps/{{projectName}}-desktop/README.md.hbs +1 -1
  40. package/templates/apps/fullstack/apps/{{projectName}}-desktop/messages/ja-JP.json.hbs +7 -0
  41. package/templates/apps/fullstack/apps/{{projectName}}-desktop/vite.config.ts.hbs +1 -1
  42. package/templates/apps/fullstack/apps/{{projectName}}-miniapp/messages/ja-JP.json.hbs +12 -0
  43. package/templates/apps/fullstack/apps/{{projectName}}-web/README.md.hbs +1 -1
  44. package/templates/apps/fullstack/apps/{{projectName}}-web/messages/ja-JP.json.hbs +7 -0
  45. package/templates/apps/fullstack/apps/{{projectName}}-web/vite.config.ts.hbs +1 -1
  46. package/templates/apps/fullstack/e2e/fullstack.spec.ts.hbs +19 -8
  47. package/templates/apps/fullstack/package.json.hbs +1 -1
  48. package/templates/apps/fullstack/packages/{{projectName}}-contract/src/admin-schemas.ts.hbs +1 -1
  49. package/templates/apps/fullstack/packages/{{projectName}}-contract/src/index.ts.hbs +2 -0
  50. package/templates/apps/fullstack/packages/{{projectName}}-contract/src/{{projectName}}-contract.ts.hbs +3 -0
  51. package/templates/apps/fullstack/packages/{{projectName}}-serv/README.md.hbs +5 -2
  52. package/templates/apps/fullstack/packages/{{projectName}}-serv/config/_core.yml.hbs +0 -6
  53. package/templates/apps/fullstack/packages/{{projectName}}-serv/config/_reach.yml.hbs +8 -0
  54. package/templates/apps/fullstack/packages/{{projectName}}-serv/config/_serv.yml.hbs +2 -1
  55. package/templates/apps/fullstack/packages/{{projectName}}-serv/package.json.hbs +6 -0
  56. package/templates/apps/fullstack/packages/{{projectName}}-serv/src/app-server.ts.hbs +9 -5
  57. package/templates/apps/fullstack/packages/{{projectName}}-serv/src/server/admin-service.ts.hbs +3 -2
  58. package/templates/apps/fullstack/packages/{{projectName}}-serv/src/server/messages.ts.hbs +16 -1
  59. package/templates/apps/fullstack/packages/{{projectName}}-serv/src/server/procedures/admin-procedures.ts.hbs +7 -3
  60. package/templates/apps/fullstack/packages/{{projectName}}-serv/src/server/procedures/index.ts.hbs +3 -0
  61. package/templates/apps/fullstack/packages/{{projectName}}-serv/src/server/runtime.ts.hbs +58 -7
  62. package/templates/apps/fullstack/packages/{{projectName}}-serv/tests/app-server.test.ts.hbs +13 -3
  63. package/templates/apps/fullstack/packages/{{projectName}}-shared/README.md.hbs +9 -4
  64. package/templates/apps/fullstack/packages/{{projectName}}-shared/messages/ja-JP.json.hbs +105 -0
  65. package/templates/apps/fullstack/packages/{{projectName}}-shared/src/lib/components/AdminWorkspace.svelte.hbs +3 -3
  66. package/templates/apps/fullstack/packages/{{projectName}}-shared/src/lib/components/AppShell.svelte.hbs +5 -5
  67. package/templates/apps/fullstack/packages/{{projectName}}-shared/src/lib/pages/audit.svelte.hbs +7 -7
  68. package/templates/apps/fullstack/packages/{{projectName}}-shared/src/lib/pages/settings.svelte.hbs +1 -1
  69. package/templates/apps/fullstack/packages/{{projectName}}-shared/src/lib/pages/users.svelte.hbs +8 -8
  70. package/templates/apps/fullstack/packages/{{projectName}}-shared/tests/api-client.test.ts.hbs +11 -12
  71. package/templates/apps/fullstack/playwright.config.ts.hbs +15 -6
  72. package/templates/apps/h5/messages/ja-JP.json +40 -0
  73. package/templates/apps/h5/src/routes/+layout.svelte.hbs +14 -3
  74. package/templates/apps/mobile-app/messages/ja-JP.json +36 -0
  75. package/templates/apps/mobile-app/src/App.svelte.hbs +13 -3
  76. package/templates/apps/mobile-app/src/lib/api.ts.hbs +173 -14
  77. package/templates/apps/website/messages/ja-JP.json +32 -0
  78. package/templates/apps/website/src/routes/+layout.svelte.hbs +10 -2
  79. package/templates/base/e2e/app.spec.ts.hbs +13 -8
  80. package/templates/base/package.json.hbs +4 -1
  81. package/templates/base/playwright.config.ts +15 -4
  82. package/templates/base/src/hooks.server.ts.hbs +15 -6
  83. package/templates/base/src/lib/server/init.ts.hbs +139 -30
  84. package/templates/base/vite.config.ts.hbs +2 -2
  85. package/templates/features/iam/routes-shared/api/auth/logout/+server.ts +11 -6
  86. package/templates/features/payment/routes/api/v1/payment/create/+server.ts +18 -25
  87. package/templates/features/payment/routes/api/v1/payment/notify/[provider]/+server.ts +4 -0
  88. package/templates/i18n/project.inlang/settings.json +1 -1
  89. package/templates/shared/e2e-env.ts +98 -0
  90. package/templates/shared/run-e2e.mjs +51 -12
  91. package/templates/skills/AGENTS.md +4 -0
  92. package/templates/skills/bridges/admin/AGENTS.md +5 -1
  93. package/templates/skills/bridges/api/AGENTS.md +2 -0
  94. package/templates/skills/bridges/fullstack/AGENTS.md.hbs +4 -0
  95. package/templates/skills/bridges/h5/AGENTS.md +5 -1
  96. package/templates/skills/bridges/mobile-app/AGENTS.md +5 -1
  97. package/templates/skills/bridges/website/AGENTS.md +5 -1
  98. package/templates/skills/hai-ai/SKILL.md +8 -2
  99. package/templates/skills/hai-ai/guide.md +134 -60
  100. package/templates/skills/hai-ai/reference.md +51 -11
  101. package/templates/skills/hai-api-client/SKILL.md +2 -0
  102. package/templates/skills/hai-api-client/reference.md +17 -5
  103. package/templates/skills/hai-api-contract/SKILL.md +1 -0
  104. package/templates/skills/hai-api-contract/reference.md +4 -2
  105. package/templates/skills/hai-app-create/SKILL.md +4 -0
  106. package/templates/skills/hai-app-create/development.md +20 -4
  107. package/templates/skills/hai-app-create/reference.md +2 -1
  108. package/templates/skills/hai-app-review/SKILL.md +6 -2
  109. package/templates/skills/hai-app-review/differential.md +34 -0
  110. package/templates/skills/hai-app-tests/SKILL.md +3 -1
  111. package/templates/skills/hai-app-tests/reference.md +4 -4
  112. package/templates/skills/hai-app-tests/validation.md +10 -3
  113. package/templates/skills/hai-audit/SKILL.md +10 -8
  114. package/templates/skills/hai-audit/reference.md +3 -0
  115. package/templates/skills/hai-build/reference.md +6 -4
  116. package/templates/skills/hai-cache/SKILL.md +9 -3
  117. package/templates/skills/hai-cache/reference.md +53 -11
  118. package/templates/skills/hai-capacitor/SKILL.md +3 -1
  119. package/templates/skills/hai-capacitor/reference.md +9 -5
  120. package/templates/skills/hai-core/SKILL.md +4 -0
  121. package/templates/skills/hai-core/reference.md +25 -5
  122. package/templates/skills/hai-crypto/SKILL.md +10 -2
  123. package/templates/skills/hai-crypto/reference.md +64 -10
  124. package/templates/skills/hai-datapipe/SKILL.md +2 -0
  125. package/templates/skills/hai-datapipe/reference.md +3 -1
  126. package/templates/skills/hai-deploy/SKILL.md +9 -4
  127. package/templates/skills/hai-deploy/middleware.md +51 -37
  128. package/templates/skills/hai-iam/SKILL.md +24 -8
  129. package/templates/skills/hai-iam/reference.md +181 -154
  130. package/templates/skills/hai-kit/SKILL.md +23 -7
  131. package/templates/skills/hai-kit/reference.md +42 -24
  132. package/templates/skills/hai-payment/SKILL.md +13 -11
  133. package/templates/skills/hai-payment/reference.md +80 -41
  134. package/templates/skills/hai-pr-review/SKILL.md +21 -88
  135. package/templates/skills/hai-reach/SKILL.md +5 -2
  136. package/templates/skills/hai-reach/reference.md +83 -22
  137. package/templates/skills/hai-reldb/SKILL.md +5 -2
  138. package/templates/skills/hai-reldb/reference.md +68 -25
  139. package/templates/skills/hai-scheduler/SKILL.md +8 -5
  140. package/templates/skills/hai-scheduler/reference.md +38 -24
  141. package/templates/skills/hai-serv/SKILL.md +23 -8
  142. package/templates/skills/hai-serv/reference.md +123 -87
  143. package/templates/skills/hai-spec-implement/SKILL.md +4 -0
  144. package/templates/skills/hai-spec-implement/evaluation.md +18 -0
  145. package/templates/skills/hai-spec-implement/handoff.md +47 -0
  146. package/templates/skills/hai-spec-implement/templates.md +5 -0
  147. package/templates/skills/hai-spec-implement/workflow.md +2 -0
  148. package/templates/skills/hai-spec-review/SKILL.md +4 -0
  149. package/templates/skills/hai-storage/SKILL.md +8 -2
  150. package/templates/skills/hai-storage/reference.md +64 -25
  151. package/templates/skills/hai-ui/SKILL.md +16 -2
  152. package/templates/skills/hai-ui/guide.md +3 -3
  153. package/templates/skills/hai-ui/reference.md +17 -6
  154. package/templates/skills/hai-ui-design/SKILL.md +31 -0
  155. package/templates/skills/hai-ui-design/patterns.md +26 -0
  156. package/templates/skills/hai-ui-review/SKILL.md +30 -0
  157. package/templates/skills/hai-ui-review/checklist.md +22 -0
  158. package/templates/skills/hai-vecdb/SKILL.md +12 -8
  159. package/templates/skills/hai-vecdb/reference.md +33 -3
  160. package/dist/chunk-7DSURTJ4.js.map +0 -1
  161. package/dist/chunk-AOQ4N657.js +0 -640
  162. package/dist/chunk-AOQ4N657.js.map +0 -1
  163. package/dist/cli-config-templates-6AHNUGBN.js +0 -5
  164. 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 = process.env.HAI_CORE_ENV || 'test'
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
- await expect(page.locator('input[name="username"]')).toBeVisible()
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${Date.now()}`
267
- await page.locator('input[name="username"]').fill(username)
268
- await page.locator('input[name="email"]').fill(`${username}@example.com`)
269
- await page.locator('input[type="password"]').nth(0).fill('SignupPassword123')
270
- await page.locator('input[type="password"]').nth(1).fill('SignupPassword123')
271
- await page.locator('button[type="submit"]').click()
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 --filter {{fullstack.contractPackageName}} build && pnpm i18n:compile && pnpm -r --if-present build",
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(['zh-CN', 'en-US']).optional(),
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 传输,上传及响应均经过统一加密。 */
@@ -3,3 +3,5 @@ export * from './app-schemas.js'
3
3
  export * from './admin-contract.js'
4
4
  export * from './admin-schemas.js'
5
5
  export * from './{{fullstack.contractPackageName}}.js'
6
+
7
+ // @hai-generator:contracts:exports
@@ -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 不提供任意多写副本的最后管理员原子保证。另需配置传输会话粘性路由:服务端私钥驻留单个进程,服务重启后客户端需重新协商。Web 使用 HttpOnly refresh Cookie + 内存 access token;仅同时满足客户端标识、body 通道头与受信原生 Origin 的 App/Desktop 请求返回 refresh token body。
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。协议在 Modal 显示;LoginForm/RegisterForm 提供 header/extraFields/footer,AuthPanel 透传 loginHeader/loginFields/loginFooter 和 registerHeader/registerFields/registerFooter。配置及例子见生成项目的 hai-iam/hai-ui skills。
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
- // 管理业务默认强制双向加密;客户端公钥复用 reldb 保存,服务端密钥仅驻留进程。
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/*`,
@@ -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
- const owner = core.id.uuid()
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, { owner, ttl: 300 })
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').auth().handle(async ({ context }) => {
33
- const result = await iam.auth.logout(context.accessToken ?? '')
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
- await recordAudit(context, 'logout', 'auth')
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)
@@ -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
- await closeModules()
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
- for (const [name, module] of [['audit', audit], ['iam', iam], ['reach', reach], ['storage', storage], ['crypto', crypto], ['cache', cache], ['reldb', reldb]] as const) {
95
- if (!module.isInitialized)
96
- continue
97
- const result = await module.close()
98
- if (result && !result.success)
99
- core.logger.error('Failed to close application module', { module: name, error: result.error.code })
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 stale = await iam.session.update(userToken, { roles: ['admin'], permissions: ['*'] })
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', { owner: 'test-owner', ttl: 10 })
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', 'test-owner')).success).toBe(true)
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 session affinity as described in the project README.
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