@h-ai/cli 0.1.0-alpha.56 → 0.1.0-alpha.58

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 (208) hide show
  1. package/README.en.md +239 -0
  2. package/README.ja.md +239 -0
  3. package/README.md +51 -1
  4. package/dist/chunk-2H7UOFLK.js +0 -0
  5. package/dist/chunk-F4IBPVZ5.js +355 -0
  6. package/dist/chunk-F4IBPVZ5.js.map +1 -0
  7. package/dist/{chunk-O3ANVICQ.js → chunk-WUSFJFW5.js} +13 -7
  8. package/dist/chunk-WUSFJFW5.js.map +1 -0
  9. package/dist/cli-config-templates-6XA7V6CC.js +5 -0
  10. package/dist/{cli-config-templates-OWBEGBZQ.js.map → cli-config-templates-6XA7V6CC.js.map} +1 -1
  11. package/dist/{cli-skill-templates-ZIWYMS4Y.js → cli-skill-templates-45CK4RQ2.js} +3 -3
  12. package/dist/{cli-skill-templates-ZIWYMS4Y.js.map → cli-skill-templates-45CK4RQ2.js.map} +1 -1
  13. package/dist/index.js +134 -125
  14. package/dist/index.js.map +1 -1
  15. package/dist/{src-TBIET7YU.js → src-DUGLX4YB.js} +7270 -2119
  16. package/dist/src-DUGLX4YB.js.map +1 -0
  17. package/package.json +17 -17
  18. package/templates/README.md +4 -4
  19. package/templates/apps/admin/src/routes/+layout.svelte.hbs +7 -0
  20. package/templates/apps/admin/src/routes/+page.svelte.hbs +5 -4
  21. package/templates/apps/admin/src/routes/admin/+layout.svelte.hbs +2 -2
  22. package/templates/apps/api/.gitignore.hbs +13 -0
  23. package/templates/apps/api/.npmrc.hbs +1 -0
  24. package/templates/apps/api/Dockerfile.hbs +6 -5
  25. package/templates/apps/api/apps/{{projectName}}-contract/package.json.hbs +1 -1
  26. package/templates/apps/api/apps/{{projectName}}-contract/src/app-schemas.ts.hbs +2 -0
  27. package/templates/apps/api/apps/{{projectName}}-contract/tsup.config.ts.hbs +1 -1
  28. package/templates/apps/api/apps/{{projectName}}-service/config/_core.yml.hbs +8 -0
  29. package/templates/apps/api/apps/{{projectName}}-service/package.json.hbs +1 -1
  30. package/templates/apps/api/apps/{{projectName}}-service/src/app.ts.hbs +7 -8
  31. package/templates/apps/api/apps/{{projectName}}-service/src/index.ts.hbs +4 -3
  32. package/templates/apps/api/apps/{{projectName}}-service/src/lib/server/init.ts.hbs +0 -1
  33. package/templates/apps/api/apps/{{projectName}}-service/src/server/procedures/app-procedures.ts.hbs +4 -0
  34. package/templates/apps/api/apps/{{projectName}}-service/tests/service.test.ts.hbs +3 -1
  35. package/templates/apps/api/e2e/api-service.spec.ts.hbs +4 -2
  36. package/templates/apps/api/{eslint.config.js → eslint.config.js.hbs} +6 -8
  37. package/templates/apps/api/package.json.hbs +3 -3
  38. package/templates/apps/api/pnpm-workspace.yaml.hbs +26 -0
  39. package/templates/apps/api/tsconfig.base.json +0 -1
  40. package/templates/apps/fullstack/.dockerignore.hbs +3 -0
  41. package/templates/apps/fullstack/.gitignore.hbs +16 -0
  42. package/templates/apps/fullstack/.npmrc.hbs +1 -0
  43. package/templates/apps/fullstack/Dockerfile.hbs +6 -5
  44. package/templates/apps/fullstack/apps/{{projectName}}-app/README.md.hbs +2 -2
  45. package/templates/apps/fullstack/apps/{{projectName}}-app/capacitor.config.ts.hbs +1 -1
  46. package/templates/apps/fullstack/apps/{{projectName}}-app/eslint.config.js.hbs +5 -8
  47. package/templates/apps/fullstack/apps/{{projectName}}-app/index.html.hbs +7 -2
  48. package/templates/apps/fullstack/apps/{{projectName}}-app/messages/en-US.json.hbs +1 -34
  49. package/templates/apps/fullstack/apps/{{projectName}}-app/messages/zh-CN.json.hbs +1 -34
  50. package/templates/apps/fullstack/apps/{{projectName}}-app/package.json.hbs +2 -2
  51. package/templates/apps/fullstack/apps/{{projectName}}-app/src/App.svelte.hbs +30 -170
  52. package/templates/apps/fullstack/apps/{{projectName}}-app/src/app.d.ts.hbs +1 -1
  53. package/templates/apps/fullstack/apps/{{projectName}}-app/src/lib/capacitor.ts.hbs +6 -5
  54. package/templates/apps/fullstack/apps/{{projectName}}-app/src/main.ts.hbs +1 -1
  55. package/templates/apps/fullstack/apps/{{projectName}}-app/vite.config.ts.hbs +48 -22
  56. package/templates/apps/fullstack/apps/{{projectName}}-desktop/README.md.hbs +3 -1
  57. package/templates/apps/fullstack/apps/{{projectName}}-desktop/eslint.config.js.hbs +5 -8
  58. package/templates/apps/fullstack/apps/{{projectName}}-desktop/index.html.hbs +6 -1
  59. package/templates/apps/fullstack/apps/{{projectName}}-desktop/messages/en-US.json.hbs +1 -28
  60. package/templates/apps/fullstack/apps/{{projectName}}-desktop/messages/zh-CN.json.hbs +1 -28
  61. package/templates/apps/fullstack/apps/{{projectName}}-desktop/package.json.hbs +5 -4
  62. package/templates/apps/fullstack/apps/{{projectName}}-desktop/src/App.svelte.hbs +10 -141
  63. package/templates/apps/fullstack/apps/{{projectName}}-desktop/src-tauri/Cargo.toml.hbs +5 -5
  64. package/templates/apps/fullstack/apps/{{projectName}}-desktop/src-tauri/tauri.conf.json.hbs +3 -3
  65. package/templates/apps/fullstack/apps/{{projectName}}-desktop/vite.config.ts.hbs +21 -2
  66. package/templates/apps/fullstack/apps/{{projectName}}-web/README.md.hbs +2 -2
  67. package/templates/apps/fullstack/apps/{{projectName}}-web/eslint.config.js.hbs +5 -8
  68. package/templates/apps/fullstack/apps/{{projectName}}-web/index.html.hbs +6 -1
  69. package/templates/apps/fullstack/apps/{{projectName}}-web/messages/en-US.json.hbs +1 -28
  70. package/templates/apps/fullstack/apps/{{projectName}}-web/messages/zh-CN.json.hbs +1 -28
  71. package/templates/apps/fullstack/apps/{{projectName}}-web/package.json.hbs +2 -2
  72. package/templates/apps/fullstack/apps/{{projectName}}-web/src/App.svelte.hbs +5 -143
  73. package/templates/apps/fullstack/apps/{{projectName}}-web/src/app.d.ts.hbs +1 -1
  74. package/templates/apps/fullstack/apps/{{projectName}}-web/src/main.ts.hbs +1 -1
  75. package/templates/apps/fullstack/apps/{{projectName}}-web/vite.config.ts.hbs +48 -22
  76. package/templates/apps/fullstack/e2e/fullstack.spec.ts.hbs +207 -17
  77. package/templates/apps/fullstack/{eslint.config.js → eslint.config.js.hbs} +6 -8
  78. package/templates/apps/fullstack/package.json.hbs +3 -3
  79. package/templates/apps/fullstack/packages/{{projectName}}-contract/README.md.hbs +2 -0
  80. package/templates/apps/fullstack/packages/{{projectName}}-contract/package.json.hbs +1 -1
  81. package/templates/apps/fullstack/packages/{{projectName}}-contract/src/admin-contract.ts.hbs +66 -0
  82. package/templates/apps/fullstack/packages/{{projectName}}-contract/src/admin-schemas.ts.hbs +96 -0
  83. package/templates/apps/fullstack/packages/{{projectName}}-contract/src/app-schemas.ts.hbs +3 -0
  84. package/templates/apps/fullstack/packages/{{projectName}}-contract/src/index.ts.hbs +3 -1
  85. package/templates/apps/fullstack/packages/{{projectName}}-contract/src/{{projectName}}-contract.ts.hbs +5 -0
  86. package/templates/apps/fullstack/packages/{{projectName}}-contract/tests/contract.test.ts.hbs +3 -0
  87. package/templates/apps/fullstack/packages/{{projectName}}-contract/tsup.config.ts.hbs +1 -1
  88. package/templates/apps/fullstack/packages/{{projectName}}-serv/README.md.hbs +13 -3
  89. package/templates/apps/fullstack/packages/{{projectName}}-serv/config/_audit.yml.hbs +3 -0
  90. package/templates/apps/fullstack/packages/{{projectName}}-serv/config/_cache.yml.hbs +2 -0
  91. package/templates/apps/fullstack/packages/{{projectName}}-serv/config/_core.yml.hbs +10 -3
  92. package/templates/apps/fullstack/packages/{{projectName}}-serv/config/_db.yml.hbs +3 -0
  93. package/templates/apps/fullstack/packages/{{projectName}}-serv/config/_iam.yml.hbs +21 -0
  94. package/templates/apps/fullstack/packages/{{projectName}}-serv/config/_serv.yml.hbs +10 -3
  95. package/templates/apps/fullstack/packages/{{projectName}}-serv/config/_storage.yml.hbs +3 -0
  96. package/templates/apps/fullstack/packages/{{projectName}}-serv/package.json.hbs +10 -3
  97. package/templates/apps/fullstack/packages/{{projectName}}-serv/src/app-server.ts.hbs +63 -28
  98. package/templates/apps/fullstack/packages/{{projectName}}-serv/src/index.ts.hbs +26 -11
  99. package/templates/apps/fullstack/packages/{{projectName}}-serv/src/server/admin-service.ts.hbs +276 -0
  100. package/templates/apps/fullstack/packages/{{projectName}}-serv/src/server/messages.ts.hbs +29 -0
  101. package/templates/apps/fullstack/packages/{{projectName}}-serv/src/server/procedures/admin-procedures.ts.hbs +94 -0
  102. package/templates/apps/fullstack/packages/{{projectName}}-serv/src/server/procedures/app-procedures.ts.hbs +6 -0
  103. package/templates/apps/fullstack/packages/{{projectName}}-serv/src/server/procedures/index.ts.hbs +5 -1
  104. package/templates/apps/fullstack/packages/{{projectName}}-serv/src/server/runtime.ts.hbs +98 -0
  105. package/templates/apps/fullstack/packages/{{projectName}}-serv/tests/app-server.test.ts.hbs +446 -33
  106. package/templates/apps/fullstack/packages/{{projectName}}-serv/tests/runtime.test.ts.hbs +48 -0
  107. package/templates/apps/fullstack/packages/{{projectName}}-serv/tsup.config.ts.hbs +1 -1
  108. package/templates/apps/fullstack/packages/{{projectName}}-shared/README.md.hbs +19 -26
  109. package/templates/apps/fullstack/packages/{{projectName}}-shared/messages/en-US.json.hbs +78 -15
  110. package/templates/apps/fullstack/packages/{{projectName}}-shared/messages/zh-CN.json.hbs +78 -15
  111. package/templates/apps/fullstack/packages/{{projectName}}-shared/package.json.hbs +4 -1
  112. package/templates/apps/fullstack/packages/{{projectName}}-shared/src/index.ts.hbs +3 -4
  113. package/templates/apps/fullstack/packages/{{projectName}}-shared/src/lib/api/api-client.ts.hbs +53 -27
  114. package/templates/apps/fullstack/packages/{{projectName}}-shared/src/lib/components/AdminWorkspace.svelte.hbs +73 -0
  115. package/templates/apps/fullstack/packages/{{projectName}}-shared/src/lib/components/AppShell.svelte.hbs +55 -45
  116. package/templates/apps/fullstack/packages/{{projectName}}-shared/src/lib/components/AuditManagement.svelte.hbs +61 -0
  117. package/templates/apps/fullstack/packages/{{projectName}}-shared/src/lib/components/ProfileSettings.svelte.hbs +57 -0
  118. package/templates/apps/fullstack/packages/{{projectName}}-shared/src/lib/components/RoleManagement.svelte.hbs +49 -0
  119. package/templates/apps/fullstack/packages/{{projectName}}-shared/src/lib/components/UserManagement.svelte.hbs +174 -0
  120. package/templates/apps/fullstack/packages/{{projectName}}-shared/src/lib/stores/management-store.svelte.ts.hbs +399 -0
  121. package/templates/apps/fullstack/packages/{{projectName}}-shared/src/lib/stores/theme-store.svelte.ts.hbs +7 -39
  122. package/templates/apps/fullstack/packages/{{projectName}}-shared/svelte.config.js.hbs +1 -0
  123. package/templates/apps/fullstack/packages/{{projectName}}-shared/tests/api-client.test.ts.hbs +151 -0
  124. package/templates/apps/fullstack/packages/{{projectName}}-shared/tests/management-store.test.ts.hbs +101 -0
  125. package/templates/apps/fullstack/packages/{{projectName}}-shared/vitest.config.ts.hbs +7 -0
  126. package/templates/apps/fullstack/playwright.config.ts.hbs +6 -0
  127. package/templates/apps/fullstack/pnpm-workspace.yaml.hbs +24 -0
  128. package/templates/apps/fullstack/tsconfig.base.json +0 -1
  129. package/templates/apps/h5/src/routes/+layout.svelte.hbs +4 -0
  130. package/templates/apps/h5/src/routes/+page.svelte.hbs +3 -2
  131. package/templates/apps/mobile-app/index.html.hbs +2 -2
  132. package/templates/apps/mobile-app/src/App.svelte.hbs +6 -2
  133. package/templates/apps/mobile-app/src/lib/app-config.ts.hbs +13 -0
  134. package/templates/apps/website/src/routes/+layout.svelte.hbs +8 -2
  135. package/templates/apps/website/src/routes/+page.svelte.hbs +4 -3
  136. package/templates/base/.npmrc.hbs +1 -0
  137. package/templates/base/Dockerfile.hbs +9 -8
  138. package/templates/base/e2e/app.spec.ts.hbs +1 -0
  139. package/templates/base/eslint-rules.js +45 -0
  140. package/templates/base/eslint.config.js.hbs +6 -8
  141. package/templates/base/package.json.hbs +39 -42
  142. package/templates/base/pnpm-workspace.yaml.hbs +26 -0
  143. package/templates/base/src/app.d.ts.hbs +4 -0
  144. package/templates/base/src/hooks.server.ts.hbs +0 -8
  145. package/templates/base/src/lib/server/init.ts.hbs +26 -5
  146. package/templates/base/src/routes/+layout.server.ts.hbs +18 -0
  147. package/templates/base/tests/scaffold.test.ts.hbs +21 -1
  148. package/templates/base/vite.config.ts.hbs +21 -0
  149. package/templates/features/ai/routes/api/chat/+server.ts +6 -7
  150. package/templates/features/iam/routes-admin/(auth)/auth/login/+page.svelte.hbs +1 -1
  151. package/templates/features/iam/routes-h5/auth/login/+page.svelte.hbs +1 -1
  152. package/templates/features/iam/routes-shared/api/auth/login/+server.ts +14 -10
  153. package/templates/features/iam/routes-shared/api/auth/logout/+server.ts +6 -4
  154. package/templates/features/storage/routes/api/upload/+server.ts +10 -8
  155. package/templates/skills/AGENTS.md +9 -8
  156. package/templates/skills/CLAUDE.md +0 -10
  157. package/templates/skills/bridges/admin/AGENTS.md +8 -0
  158. package/templates/skills/bridges/admin/copilot-instructions.md +2 -0
  159. package/templates/skills/bridges/api/AGENTS.md +8 -1
  160. package/templates/skills/bridges/api/copilot-instructions.md +2 -0
  161. package/templates/skills/bridges/fullstack/AGENTS.md.hbs +21 -4
  162. package/templates/skills/bridges/fullstack/copilot-instructions.md.hbs +5 -0
  163. package/templates/skills/bridges/h5/AGENTS.md +8 -0
  164. package/templates/skills/bridges/h5/copilot-instructions.md +2 -0
  165. package/templates/skills/bridges/mobile-app/AGENTS.md +8 -0
  166. package/templates/skills/bridges/mobile-app/copilot-instructions.md +2 -0
  167. package/templates/skills/bridges/website/AGENTS.md +8 -0
  168. package/templates/skills/bridges/website/copilot-instructions.md +2 -0
  169. package/templates/skills/copilot-instructions.md +2 -0
  170. package/templates/skills/hai-api-client/SKILL.md +2 -0
  171. package/templates/skills/hai-app-create/SKILL.md +9 -7
  172. package/templates/skills/hai-app-create/development.md +72 -0
  173. package/templates/skills/hai-app-create/reference.md +44 -32
  174. package/templates/skills/hai-app-review/SKILL.md +50 -144
  175. package/templates/skills/hai-app-tests/SKILL.md +3 -1
  176. package/templates/skills/hai-app-tests/reference.md +47 -27
  177. package/templates/skills/hai-build/SKILL.md +5 -4
  178. package/templates/skills/hai-build/reference.md +56 -34
  179. package/templates/skills/hai-ci/SKILL.md +8 -15
  180. package/templates/skills/hai-core/SKILL.md +2 -0
  181. package/templates/skills/hai-core/reference.md +6 -4
  182. package/templates/skills/hai-framework-sync/SKILL.md +8 -2
  183. package/templates/skills/hai-pr-review/SKILL.md +5 -4
  184. package/templates/skills/hai-reldb/SKILL.md +1 -0
  185. package/templates/skills/hai-reldb/reference.md +3 -0
  186. package/templates/skills/hai-scheduler/SKILL.md +3 -0
  187. package/templates/skills/hai-serv/SKILL.md +2 -0
  188. package/templates/skills/hai-serv/reference.md +18 -2
  189. package/templates/skills/hai-storage/SKILL.md +3 -1
  190. package/templates/skills/hai-ui/SKILL.md +6 -0
  191. package/templates/skills/hai-ui/reference.md +7 -4
  192. package/templates/skills/hai-vecdb/reference.md +53 -49
  193. package/dist/chunk-O3ANVICQ.js.map +0 -1
  194. package/dist/chunk-WUYLYCW2.js +0 -237
  195. package/dist/chunk-WUYLYCW2.js.map +0 -1
  196. package/dist/cli-config-templates-OWBEGBZQ.js +0 -5
  197. package/dist/src-TBIET7YU.js.map +0 -1
  198. package/templates/apps/fullstack/apps/{{projectName}}-app/tests/api.test.ts.hbs +0 -28
  199. package/templates/apps/fullstack/apps/{{projectName}}-desktop/tests/api.test.ts.hbs +0 -28
  200. package/templates/apps/fullstack/apps/{{projectName}}-web/tests/api.test.ts.hbs +0 -55
  201. package/templates/apps/fullstack/packages/{{projectName}}-shared/src/lib/components/LanguageSwitcher.svelte.hbs +0 -27
  202. package/templates/apps/fullstack/packages/{{projectName}}-shared/src/lib/components/ThemeSwitcher.svelte.hbs +0 -18
  203. package/templates/skills/bridges/admin/CLAUDE.md +0 -11
  204. package/templates/skills/bridges/api/CLAUDE.md +0 -11
  205. package/templates/skills/bridges/fullstack/CLAUDE.md.hbs +0 -5
  206. package/templates/skills/bridges/h5/CLAUDE.md +0 -11
  207. package/templates/skills/bridges/mobile-app/CLAUDE.md +0 -11
  208. package/templates/skills/bridges/website/CLAUDE.md +0 -11
@@ -1,52 +1,465 @@
1
- import { apiContract } from '@h-ai/api-contract'
2
- import { {{fullstack.contractExportName}} } from '{{fullstack.contractPackageName}}'
3
- import { describe, expect, it } from 'vitest'
1
+ import type { CoreConfig, HaiResult } from '@h-ai/core'
2
+ import type { TransportClient } from '@h-ai/crypto'
3
+ import type { AdminUser } from '{{fullstack.contractPackageName}}'
4
+ import { Buffer } from 'node:buffer'
5
+ import { mkdtemp, rm } from 'node:fs/promises'
6
+ import os from 'node:os'
7
+ import path from 'node:path'
8
+ import { audit } from '@h-ai/audit'
9
+ import { cache } from '@h-ai/cache'
10
+ import { core, err, HaiCommonError } from '@h-ai/core'
11
+ import { crypto } from '@h-ai/crypto'
12
+ import { iam } from '@h-ai/iam'
13
+ import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'
4
14
  import { createServerApp } from '../src/app-server.js'
15
+ import { closeModules, initializeModules } from '../src/server/runtime.js'
5
16
 
6
- const API_PREFIX = '/api/v1'
17
+ const PREFIX = '/api/v1'
18
+ const TEST_PASSWORD = 'TemplateTest123!'
19
+ const PNG = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+aSxkAAAAASUVORK5CYII='
20
+ let app: ReturnType<typeof createServerApp>
21
+ let transport: TransportClient
22
+ let storageRoot: string
23
+ let cookie = ''
24
+ let adminToken = ''
25
+ let adminId = ''
26
+ let userToken = ''
27
+ let user: AdminUser
28
+ let encryptedRequests = 0
29
+ let encryptedResponses = 0
30
+ interface LoginData { user: { id: string }, tokens: { accessToken: string, refreshToken?: string }, roles: string[] }
7
31
 
8
- describe('{{fullstack.servPackageName}}', () => {
9
- it('returns health status', async () => {
10
- const app = createServerApp()
11
- const response = await app.request('/health')
12
-
13
- expect(response.status).toBe(200)
14
- await expect(response.json()).resolves.toEqual({ status: 'ok' })
32
+ /** 使用真实加密协议与 HTTP 服务,同时保留浏览器 refresh cookie。 */
33
+ async function call<T>(route: string, input?: object, token?: string, method = 'POST'): Promise<HaiResult<T>> {
34
+ const response = await transport.encryptedFetch(`http://localhost${PREFIX}${route}`, {
35
+ method,
36
+ headers: { 'content-type': 'application/json', ...(token ? { authorization: `Bearer ${token}` } : {}) },
37
+ ...(input ? { body: JSON.stringify(input) } : {}),
15
38
  })
39
+ return response.json() as Promise<HaiResult<T>>
40
+ }
16
41
 
17
- it('returns app info as HaiResult', async () => {
18
- const app = createServerApp()
19
- const response = await app.request(`${API_PREFIX}${apiContract.pathOf({{fullstack.contractExportName}}.app.info)}`, { method: 'POST' })
20
- const body = await response.json()
21
-
22
- expect(response.status).toBe(200)
23
- expect(body.success).toBe(true)
24
- expect(body.data.name).toBe('{{fullstack.servPackageName}}')
42
+ beforeAll(async () => {
43
+ storageRoot = await mkdtemp(path.join(os.tmpdir(), '{{projectName}}-avatars-'))
44
+ vi.stubEnv('HAI_DB_DATABASE', '":memory:"')
45
+ vi.stubEnv('HAI_STORAGE_ROOT', storageRoot)
46
+ vi.stubEnv('HAI_ADMIN_DEFAULT_PASSWORD', TEST_PASSWORD)
47
+ core.init({ configDir: './config' })
48
+ await initializeModules()
49
+ app = createServerApp()
50
+ transport = crypto.transport.createClient({
51
+ keyExchangeUrl: `http://localhost${PREFIX}/_hai/key-exchange`,
52
+ fetch: async (input, init) => {
53
+ const request = new Request(input, init)
54
+ if (cookie && request.url.endsWith('/auth/refresh'))
55
+ request.headers.set('cookie', cookie)
56
+ if (request.headers.get('x-encrypted') === 'true')
57
+ encryptedRequests++
58
+ const response = await app.fetch(request)
59
+ if (response.headers.get('x-encrypted') === 'true') {
60
+ encryptedResponses++
61
+ const wire = await response.clone().text()
62
+ expect(wire).not.toContain(TEST_PASSWORD)
63
+ expect(wire).not.toContain('"username"')
64
+ }
65
+ const nextCookie = response.headers.get('set-cookie')
66
+ if (nextCookie)
67
+ cookie = nextCookie.split(';')[0] ?? ''
68
+ return response
69
+ },
25
70
  })
71
+ }, 30_000)
26
72
 
27
- it('validates echo input', async () => {
28
- const app = createServerApp()
29
- const response = await app.request(`${API_PREFIX}${apiContract.pathOf({{fullstack.contractExportName}}.app.echo)}`, {
73
+ afterAll(async () => {
74
+ transport?.destroy()
75
+ await closeModules()
76
+ vi.unstubAllEnvs()
77
+ await rm(storageRoot, { recursive: true, force: true })
78
+ })
79
+
80
+ describe('{{fullstack.servPackageName}} mini administration', () => {
81
+ it('keeps health public, requires encryption and reads runtime identity from core', async () => {
82
+ expect((await app.request('/health')).status).toBe(200)
83
+ expect((await app.request(`${PREFIX}/app/info`, { method: 'POST' })).status).toBe(400)
84
+ const result = await call<{ name: string, version: string, env: string, defaultLocale: string }>('/app/info')
85
+ expect(result.success).toBe(true)
86
+ if (!result.success)
87
+ return
88
+ const config = core.config.getOrThrow<CoreConfig>('core')
89
+ expect(result.data).toMatchObject({ name: config.name, version: config.version, env: config.env, defaultLocale: config.defaultLocale })
90
+ const invalid = await transport.encryptedFetch(`http://localhost${PREFIX}/app/echo`, {
30
91
  method: 'POST',
31
92
  headers: { 'content-type': 'application/json' },
32
93
  body: JSON.stringify({ message: '' }),
33
94
  })
95
+ expect(invalid.status).toBe(400)
96
+ })
97
+
98
+ it('logs in and refreshes with a browser cookie without exposing its refresh token', async () => {
99
+ const result = await call<LoginData>('/auth/login', { identifier: 'admin', password: TEST_PASSWORD })
100
+ expect(result.success).toBe(true)
101
+ if (!result.success)
102
+ return
103
+ adminToken = result.data.tokens.accessToken
104
+ adminId = result.data.user.id
105
+ expect(result.data.roles).toEqual(['admin'])
106
+ expect(result.data.tokens.refreshToken).toBeUndefined()
107
+ expect(cookie).toContain('hai_refresh_token=')
108
+ const refreshed = await call<{ tokens: { accessToken: string, refreshToken?: string } }>('/auth/refresh', {})
109
+ expect(refreshed.success).toBe(true)
110
+ if (refreshed.success) {
111
+ expect(refreshed.data.tokens.refreshToken).toBeUndefined()
112
+ adminToken = refreshed.data.tokens.accessToken
113
+ }
114
+ })
115
+
116
+ it('does not mount unselected default IAM routes', async () => {
117
+ for (const [route, method] of [
118
+ ['/auth/register', 'POST'],
119
+ ['/auth/login/otp', 'POST'],
120
+ ['/auth/otp/send', 'POST'],
121
+ ['/iam/users', 'GET'],
122
+ ['/iam/roles', 'POST'],
123
+ ] as const) {
124
+ const response = await transport.encryptedFetch(`http://localhost${PREFIX}${route}`, {
125
+ method,
126
+ headers: { 'content-type': 'application/json', 'authorization': `Bearer ${adminToken}` },
127
+ ...(method === 'POST' ? { body: '{}' } : {}),
128
+ })
129
+ expect(response.status).toBe(404)
130
+ }
131
+ })
34
132
 
35
- expect(response.status).toBe(400)
133
+ it('creates a normal user and enforces guards on every administration API', async () => {
134
+ const created = await call<AdminUser>('/admin/users/create', { username: 'normal-user', password: TEST_PASSWORD, role: 'user', enabled: true }, adminToken)
135
+ expect(created.success).toBe(true)
136
+ if (!created.success)
137
+ return
138
+ user = created.data
139
+ const loggedIn = await call<LoginData>('/auth/login', { identifier: user.username, password: TEST_PASSWORD })
140
+ expect(loggedIn.success).toBe(true)
141
+ if (!loggedIn.success)
142
+ return
143
+ userToken = loggedIn.data.tokens.accessToken
144
+ for (const [route, input] of [
145
+ ['/admin/users/list', { page: 1, pageSize: 20 }],
146
+ ['/admin/users/create', { username: 'forbidden', password: TEST_PASSWORD }],
147
+ ['/admin/users/update', { id: adminId, enabled: false }],
148
+ ['/admin/users/delete', { id: adminId }],
149
+ ['/admin/users/avatar', { id: adminId, contentType: 'image/png', base64: PNG }],
150
+ ['/admin/roles/list', undefined],
151
+ ['/admin/roles/update', { id: 'missing', description: 'x' }],
152
+ ['/admin/audit/list', { page: 1, pageSize: 20 }],
153
+ ] as const) {
154
+ const denied = await call(route, input, userToken)
155
+ expect(denied.success).toBe(false)
156
+ if (!denied.success)
157
+ expect(denied.error.httpStatus).toBe(403)
158
+ }
36
159
  })
37
160
 
38
- it('returns echo result as HaiResult', async () => {
39
- const app = createServerApp()
40
- const response = await app.request(`${API_PREFIX}${apiContract.pathOf({{fullstack.contractExportName}}.app.echo)}`, {
161
+ it('allows refresh-token body transport only for an identified trusted native origin', async () => {
162
+ for (const [clientName, origin] of [['{{projectName}}-app', 'capacitor://localhost'], ['{{projectName}}-desktop', 'http://tauri.localhost']] as const) {
163
+ const headers = { 'content-type': 'application/json', 'x-request-client': clientName, 'x-hai-token-transport': 'body', origin }
164
+ const login = await transport.encryptedFetch(`http://localhost${PREFIX}/auth/login`, {
165
+ method: 'POST',
166
+ headers,
167
+ body: JSON.stringify({ identifier: 'admin', password: TEST_PASSWORD }),
168
+ })
169
+ const result = await login.json() as HaiResult<LoginData>
170
+ expect(result.success).toBe(true)
171
+ if (!result.success)
172
+ continue
173
+ expect(result.data.tokens.refreshToken).toBeTruthy()
174
+ expect(login.headers.get('set-cookie')).toBeNull()
175
+ const refresh = await transport.encryptedFetch(`http://localhost${PREFIX}/auth/refresh`, {
176
+ method: 'POST',
177
+ headers,
178
+ body: JSON.stringify({ refreshToken: result.data.tokens.refreshToken }),
179
+ })
180
+ const refreshed = await refresh.json() as HaiResult<{ tokens: { refreshToken: string } }>
181
+ expect(refreshed.success).toBe(true)
182
+ if (refreshed.success)
183
+ expect(refreshed.data.tokens.refreshToken).not.toBe(result.data.tokens.refreshToken)
184
+ }
185
+ for (const [clientName, origin] of [['{{projectName}}-app', 'http://untrusted.example'], ['unrecognized-client', 'capacitor://localhost']] as const) {
186
+ const response = await transport.encryptedFetch(`http://localhost${PREFIX}/auth/login`, {
187
+ method: 'POST',
188
+ headers: { 'content-type': 'application/json', 'x-request-client': clientName, 'x-hai-token-transport': 'body', origin },
189
+ body: JSON.stringify({ identifier: 'admin', password: TEST_PASSWORD }),
190
+ })
191
+ const result = await response.json() as HaiResult<LoginData>
192
+ expect(result.success).toBe(true)
193
+ if (result.success)
194
+ expect(result.data.tokens.refreshToken).toBeUndefined()
195
+ }
196
+ })
197
+
198
+ it('paginates users and protects administrators from self deletion, disable and downgrade', async () => {
199
+ const listed = await call<{ items: AdminUser[], total: number }>('/admin/users/list', { page: 1, pageSize: 1 }, adminToken)
200
+ expect(listed.success).toBe(true)
201
+ if (listed.success) {
202
+ expect(listed.data.items).toHaveLength(1)
203
+ expect(listed.data.total).toBe(2)
204
+ }
205
+ for (const [route, input] of [['/admin/users/delete', { id: adminId }], ['/admin/users/update', { id: adminId, enabled: false }], ['/admin/users/update', { id: adminId, role: 'user' }]] as const) {
206
+ const denied = await call(route, input, adminToken)
207
+ expect(denied.success).toBe(false)
208
+ if (!denied.success)
209
+ expect(denied.error.httpStatus).toBe(403)
210
+ }
211
+ })
212
+
213
+ it('updates personal preferences, validates images and serves only allowed avatar resources', async () => {
214
+ expect((await call('/profile/update', { displayName: 'Updated', locale: 'en-US', theme: '{{fullstack.theme.defaultTheme}}' }, userToken)).success).toBe(true)
215
+ const current = await call<{ metadata: { locale: string, theme: string } }>('/auth/me', undefined, userToken, 'GET')
216
+ expect(current.success).toBe(true)
217
+ if (current.success)
218
+ expect(current.data.metadata).toMatchObject({ locale: 'en-US', theme: '{{fullstack.theme.defaultTheme}}' })
219
+ expect((await call('/profile/update', { email: 'temporary@example.com' }, userToken)).success).toBe(true)
220
+ const cleared = await call<AdminUser>('/profile/update', { email: '' }, userToken)
221
+ expect(cleared.success).toBe(true)
222
+ if (cleared.success)
223
+ expect(cleared.data.email).toBe('')
224
+ const invalid = await call('/profile/avatar', { contentType: 'image/png', base64: 'PHN2Zz48L3N2Zz4=' }, userToken)
225
+ expect(invalid.success).toBe(false)
226
+ if (!invalid.success)
227
+ expect(invalid.error.httpStatus).toBe(400)
228
+ const oversized = await call('/profile/avatar', { contentType: 'image/png', base64: Buffer.alloc(512 * 1024 + 1).toString('base64') }, userToken)
229
+ expect(oversized.success).toBe(false)
230
+ const anonymous = await call('/profile/avatar', { contentType: 'image/png', base64: PNG })
231
+ expect(anonymous.success).toBe(false)
232
+ if (!anonymous.success)
233
+ expect(anonymous.error.httpStatus).toBe(401)
234
+ const injected = await transport.encryptedFetch(`http://localhost${PREFIX}/profile/update`, {
41
235
  method: 'POST',
42
- headers: { 'content-type': 'application/json' },
43
- body: JSON.stringify({ message: 'hello unit' }),
236
+ headers: { 'content-type': 'application/json', 'authorization': `Bearer ${userToken}` },
237
+ body: JSON.stringify({ id: adminId, enabled: false, displayName: 'Intruder' }),
238
+ })
239
+ expect(injected.status).toBe(400)
240
+ const uploaded = await call<{ avatarUrl: string }>('/profile/avatar', { contentType: 'image/png', base64: PNG }, userToken)
241
+ expect(uploaded.success).toBe(true)
242
+ if (!uploaded.success)
243
+ return
244
+ expect(uploaded.data.avatarUrl).toMatch(/^\/api\/v1\/assets\/avatars\/[\w-]+\/[\w-]+\.png$/)
245
+ const asset = await app.request(uploaded.data.avatarUrl)
246
+ expect(asset.status).toBe(200)
247
+ expect(asset.headers.get('content-type')).toBe('image/png')
248
+ expect((await asset.arrayBuffer()).byteLength).toBeGreaterThan(0)
249
+ expect((await app.request(`${PREFIX}/assets/avatars/%2e%2e/config/_core.yml`)).status).toBe(404)
250
+ expect((await call('/admin/users/avatar', { id: user.id, contentType: 'image/png', base64: PNG }, adminToken)).success).toBe(true)
251
+ expect((await app.request(uploaded.data.avatarUrl)).status).toBe(404)
252
+ const hostile = await transport.encryptedFetch(`http://attacker.example${PREFIX}/profile/avatar`, {
253
+ method: 'POST',
254
+ headers: { 'content-type': 'application/json', 'authorization': `Bearer ${userToken}` },
255
+ body: JSON.stringify({ contentType: 'image/png', base64: PNG }),
256
+ })
257
+ const hostileResult = await hostile.json() as HaiResult<{ avatarUrl: string }>
258
+ expect(hostileResult.success).toBe(true)
259
+ if (hostileResult.success) {
260
+ expect(hostileResult.data.avatarUrl).toMatch(/^\/api\/v1\/assets\//)
261
+ expect(hostileResult.data.avatarUrl).not.toContain('attacker.example')
262
+ const stored = await iam.user.getUser(user.id)
263
+ expect(stored.success).toBe(true)
264
+ if (stored.success)
265
+ expect(stored.data?.avatarUrl).toBe(hostileResult.data.avatarUrl)
266
+ }
267
+ })
268
+
269
+ it('lists two roles, updates descriptions and queries audit records without credentials or file content', async () => {
270
+ const roles = await call<{ items: { id: string, code: string, userCount: number }[] }>('/admin/roles/list', undefined, adminToken)
271
+ expect(roles.success).toBe(true)
272
+ if (roles.success) {
273
+ expect(roles.data.items.map(item => item.code).sort()).toEqual(['admin', 'user'])
274
+ const role = roles.data.items.find(item => item.code === 'user')!
275
+ expect(role.userCount).toBe(1)
276
+ expect((await call('/admin/roles/update', { id: role.id, description: 'Updated description' }, adminToken)).success).toBe(true)
277
+ }
278
+ const logs = await call<{ items: { action: string, details: string | null }[], total: number }>('/admin/audit/list', { page: 1, pageSize: 100 }, adminToken)
279
+ expect(logs.success).toBe(true)
280
+ if (logs.success) {
281
+ expect(logs.data.total).toBeGreaterThan(0)
282
+ expect(JSON.stringify(logs.data)).not.toContain(TEST_PASSWORD)
283
+ expect(JSON.stringify(logs.data)).not.toContain(PNG)
284
+ }
285
+ })
286
+
287
+ it('does not write profile fields when role synchronization fails', async () => {
288
+ const before = await iam.user.getUser(user.id)
289
+ expect(before.success).toBe(true)
290
+ const sync = vi.spyOn(iam.authz, 'syncRoles').mockResolvedValueOnce(err(HaiCommonError.API_ERROR, 'Injected role persistence failure'))
291
+ const failed = await call('/admin/users/update', { id: user.id, role: 'admin', email: 'changed@example.com', displayName: 'Should not change' }, adminToken)
292
+ sync.mockRestore()
293
+ expect(failed.success).toBe(false)
294
+ const after = await iam.user.getUser(user.id)
295
+ expect(after.success).toBe(true)
296
+ if (before.success && after.success)
297
+ expect(after.data).toMatchObject({ email: before.data?.email, displayName: before.data?.displayName })
298
+ })
299
+
300
+ it('keeps an already committed profile update successful when best-effort audit fails', async () => {
301
+ const log = vi.spyOn(audit, 'log').mockResolvedValueOnce(err(HaiCommonError.API_ERROR, 'Injected audit persistence failure'))
302
+ const logger = vi.spyOn(core.logger, 'error').mockImplementation(() => {})
303
+ try {
304
+ const result = await call<AdminUser>('/profile/update', { displayName: 'Committed profile' }, userToken)
305
+ expect(result.success).toBe(true)
306
+ if (result.success)
307
+ expect(result.data.displayName).toBe('Committed profile')
308
+ expect(logger).toHaveBeenCalledWith('Failed to record audit event', expect.objectContaining({ action: 'update', resource: 'profile' }))
309
+ }
310
+ finally {
311
+ log.mockRestore()
312
+ logger.mockRestore()
313
+ }
314
+ })
315
+
316
+ it('rolls back roles when profile persistence fails and keeps role changes effective in active sessions', async () => {
317
+ const update = vi.spyOn(iam.user, 'updateUser').mockResolvedValueOnce(err(HaiCommonError.API_ERROR, 'Injected user persistence failure'))
318
+ const failed = await call('/admin/users/update', { id: user.id, role: 'admin', email: 'changed@example.com' }, adminToken)
319
+ update.mockRestore()
320
+ expect(failed.success).toBe(false)
321
+ const roles = await iam.authz.getUserRoles(user.id)
322
+ expect(roles.success).toBe(true)
323
+ if (roles.success)
324
+ expect(roles.data.map(role => role.code)).toEqual(['user'])
325
+ expect((await call('/admin/users/update', { id: user.id, role: 'admin' }, adminToken)).success).toBe(true)
326
+ expect((await call('/admin/users/list', { page: 1, pageSize: 20 }, userToken)).success).toBe(true)
327
+ expect((await call('/admin/users/update', { id: user.id, role: 'user' }, adminToken)).success).toBe(true)
328
+ const denied = await call('/admin/users/list', { page: 1, pageSize: 20 }, userToken)
329
+ expect(denied.success).toBe(false)
330
+ if (!denied.success)
331
+ expect(denied.error.httpStatus).toBe(403)
332
+ })
333
+
334
+ it('rejects stale administrator snapshots and reports fresh user roles', async () => {
335
+ const stale = await iam.session.update(userToken, { roles: ['admin'], permissions: ['*'] })
336
+ expect(stale.success).toBe(true)
337
+ 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) {
338
+ const denied = await call(route, input, userToken)
339
+ expect(denied.success).toBe(false)
340
+ if (!denied.success)
341
+ expect(denied.error.httpStatus).toBe(403)
342
+ }
343
+ const current = await call<{ roles: string[] }>('/auth/me', undefined, userToken, 'GET')
344
+ expect(current.success).toBe(true)
345
+ if (current.success)
346
+ expect(current.data.roles).toEqual(['user'])
347
+ })
348
+
349
+ it('returns a conflict when another administrator holds the mutation lock and resumes after release', async () => {
350
+ const locked = await cache.lock.acquire('hai:app:admin-mutation', { owner: 'test-owner', ttl: 10 })
351
+ expect(locked.success).toBe(true)
352
+ try {
353
+ const result = await call('/admin/users/update', { id: user.id, displayName: 'Locked' }, adminToken)
354
+ expect(result.success).toBe(false)
355
+ if (!result.success)
356
+ expect(result.error.httpStatus).toBe(409)
357
+ }
358
+ finally {
359
+ expect((await cache.lock.release('hai:app:admin-mutation', 'test-owner')).success).toBe(true)
360
+ }
361
+ expect((await call('/admin/users/update', { id: user.id, displayName: 'Unlocked' }, adminToken)).success).toBe(true)
362
+ })
363
+
364
+ it('keeps single-writer protection after an expired lease and resumes after a slow mutation', async () => {
365
+ const other = await call<AdminUser>('/admin/users/create', { username: 'lease-admin', password: TEST_PASSWORD, role: 'admin' }, adminToken)
366
+ expect(other.success).toBe(true)
367
+ if (!other.success)
368
+ return
369
+ const otherLogin = await call<LoginData>('/auth/login', { identifier: 'lease-admin', password: TEST_PASSWORD })
370
+ expect(otherLogin.success).toBe(true)
371
+ if (!otherLogin.success)
372
+ return
373
+ let resume: () => void = () => {}
374
+ let entered: () => void = () => {}
375
+ const paused = new Promise<void>((resolve) => {
376
+ resume = resolve
377
+ })
378
+ const entry = new Promise<void>((resolve) => {
379
+ entered = resolve
44
380
  })
45
- const body = await response.json()
381
+ const original = iam.user.updateUser
382
+ const update = vi.spyOn(iam.user, 'updateUser').mockImplementationOnce(async (id, data) => {
383
+ entered()
384
+ await paused
385
+ return original(id, data)
386
+ })
387
+ const first = call('/admin/users/update', { id: other.data.id, enabled: false }, adminToken)
388
+ await entry
389
+ const now = vi.spyOn(Date, 'now').mockReturnValue(Date.now() + 301_000)
390
+ try {
391
+ const conflicting = await call('/admin/users/update', { id: adminId, enabled: false }, otherLogin.data.tokens.accessToken)
392
+ expect(conflicting.success).toBe(false)
393
+ if (!conflicting.success)
394
+ expect(conflicting.error.httpStatus).toBe(409)
395
+ resume()
396
+ expect((await first).success).toBe(true)
397
+ const admin = await iam.user.getUser(adminId)
398
+ expect(admin.success).toBe(true)
399
+ if (admin.success)
400
+ expect(admin.data?.enabled).toBe(true)
401
+ }
402
+ finally {
403
+ resume()
404
+ now.mockRestore()
405
+ update.mockRestore()
406
+ }
407
+ expect((await call('/admin/users/delete', { id: other.data.id }, adminToken)).success).toBe(true)
408
+ })
409
+
410
+ it('serializes mutually destructive admin requests and preserves an enabled administrator', async () => {
411
+ const created = await call<AdminUser>('/admin/users/create', { username: 'second-admin', password: TEST_PASSWORD, role: 'admin' }, adminToken)
412
+ expect(created.success).toBe(true)
413
+ if (!created.success)
414
+ return
415
+ const loggedIn = await call<LoginData>('/auth/login', { identifier: 'second-admin', password: TEST_PASSWORD })
416
+ expect(loggedIn.success).toBe(true)
417
+ if (!loggedIn.success)
418
+ return
419
+ // 对手同时请求禁用原管理员;锁内重新鉴权阻止两个请求全部成功。
420
+ const results = await Promise.all([
421
+ call('/admin/users/update', { id: created.data.id, enabled: false }, adminToken),
422
+ call('/admin/users/update', { id: adminId, enabled: false }, loggedIn.data.tokens.accessToken),
423
+ ])
424
+ expect(results.filter(result => result.success)).toHaveLength(1)
425
+ const admins = await iam.user.listUsers({ enabled: true, include: ['roles'] })
426
+ expect(admins.success).toBe(true)
427
+ if (admins.success) {
428
+ const surviving = admins.data.items.filter(item => item.roles?.some(role => role.code === 'admin'))
429
+ expect(surviving).toHaveLength(1)
430
+ // 后续用仍有效的管理员会话验证其他操作。
431
+ if (surviving[0]?.id !== adminId) {
432
+ adminToken = loggedIn.data.tokens.accessToken
433
+ adminId = created.data.id
434
+ }
435
+ }
436
+ })
46
437
 
47
- expect(response.status).toBe(200)
48
- expect(body.success).toBe(true)
49
- expect(body.data.message).toBe('hello unit')
50
- expect(body.data.requestId).toBeDefined()
438
+ it('invalidates sessions on disable and password change and proves encrypted traffic', async () => {
439
+ expect((await call('/admin/users/update', { id: user.id, enabled: false }, adminToken)).success).toBe(true)
440
+ const denied = await call('/auth/me', undefined, userToken, 'GET')
441
+ expect(denied.success).toBe(false)
442
+ if (!denied.success)
443
+ expect(denied.error.httpStatus).toBe(401)
444
+ expect((await call('/admin/users/update', { id: user.id, enabled: true }, adminToken)).success).toBe(true)
445
+ const loggedIn = await call<LoginData>('/auth/login', { identifier: user.username, password: TEST_PASSWORD })
446
+ expect(loggedIn.success).toBe(true)
447
+ if (!loggedIn.success)
448
+ return
449
+ expect((await call('/auth/change-password', { oldPassword: TEST_PASSWORD, newPassword: 'ChangedTest123!' }, loggedIn.data.tokens.accessToken)).success).toBe(true)
450
+ expect((await call('/auth/me', undefined, loggedIn.data.tokens.accessToken, 'GET')).success).toBe(false)
451
+ const changedLogin = await call<LoginData>('/auth/login', { identifier: user.username, password: 'ChangedTest123!' })
452
+ expect(changedLogin.success).toBe(true)
453
+ if (changedLogin.success) {
454
+ expect((await call('/auth/logout', {}, changedLogin.data.tokens.accessToken)).success).toBe(true)
455
+ expect((await call('/auth/me', undefined, changedLogin.data.tokens.accessToken, 'GET')).success).toBe(false)
456
+ }
457
+ expect((await call('/admin/users/delete', { id: user.id }, adminToken)).success).toBe(true)
458
+ const removed = await iam.user.getUser(user.id)
459
+ expect(removed.success).toBe(true)
460
+ if (removed.success)
461
+ expect(removed.data).toBeNull()
462
+ expect(encryptedRequests).toBeGreaterThan(10)
463
+ expect(encryptedResponses).toBeGreaterThan(10)
51
464
  })
52
465
  })
@@ -0,0 +1,48 @@
1
+ import { mkdtemp, rm } from 'node:fs/promises'
2
+ import os from 'node:os'
3
+ import path from 'node:path'
4
+ import { audit } from '@h-ai/audit'
5
+ import { cache } from '@h-ai/cache'
6
+ import { core } from '@h-ai/core'
7
+ import { crypto } from '@h-ai/crypto'
8
+ import { iam } from '@h-ai/iam'
9
+ import { reldb } from '@h-ai/reldb'
10
+ import { storage } from '@h-ai/storage'
11
+ import { afterEach, beforeEach, expect, it, vi } from 'vitest'
12
+ import { closeModules, initializeModules } from '../src/server/runtime.js'
13
+
14
+ let storageRoot: string
15
+
16
+ beforeEach(async () => {
17
+ storageRoot = await mkdtemp(path.join(os.tmpdir(), '{{projectName}}-bootstrap-'))
18
+ vi.stubEnv('HAI_DB_DATABASE', '":memory:"')
19
+ vi.stubEnv('HAI_STORAGE_ROOT', storageRoot)
20
+ vi.stubEnv('HAI_IAM_PASSWORD_REQUIREUPPERCASE', 'true')
21
+ core.init({ configDir: './config' })
22
+ })
23
+
24
+ afterEach(async () => {
25
+ await closeModules()
26
+ vi.unstubAllEnvs()
27
+ await rm(storageRoot, { recursive: true, force: true })
28
+ })
29
+
30
+ it.each([
31
+ { password: 'abc', reason: /at least 8 characters|至少为 8 个字符/ },
32
+ { password: 'lowercase123!', reason: /uppercase letters|大写字母/ },
33
+ ])('startup explains the failed password policy and permits a clean retry: $password', async ({ password, reason }) => {
34
+ vi.stubEnv('HAI_ADMIN_DEFAULT_PASSWORD', password)
35
+ await expect(initializeModules()).rejects.toThrow(reason)
36
+ for (const module of [audit, iam, storage, crypto, cache, reldb])
37
+ expect(module.isInitialized).toBe(false)
38
+
39
+ // 失败后再次初始化,确认未保留半成品管理员或未释放的模块。
40
+ vi.stubEnv('HAI_ADMIN_DEFAULT_PASSWORD', 'Bootstrap-retry-Admin123!')
41
+ await initializeModules()
42
+ const users = await iam.user.listUsers({ page: 1, pageSize: 10 })
43
+ expect(users.success).toBe(true)
44
+ if (users.success) {
45
+ expect(users.data.total).toBe(1)
46
+ expect(users.data.items[0]?.username).toBe('admin')
47
+ }
48
+ })
@@ -3,7 +3,7 @@ import { defineConfig } from 'tsup'
3
3
  export default defineConfig({
4
4
  entry: ['src/index.ts'],
5
5
  format: ['esm'],
6
- dts: true,
6
+ dts: { compilerOptions: { ignoreDeprecations: '6.0' } },
7
7
  sourcemap: true,
8
8
  clean: true,
9
9
  })
@@ -1,39 +1,32 @@
1
1
  # {{fullstack.sharedPackageName}}
2
2
 
3
- Frontend shared package for **{{projectName}}**. Provides:
3
+ Shared mini administration workspace for **{{projectName}}**. Web, Capacitor App and Tauri desktop use the same `AdminWorkspace`, responsive `AppShell`, typed API client and management store.
4
4
 
5
- - `AppShell` — common layout (header / footer / theme + language switchers)
6
- - `ThemeSwitcher`, `LanguageSwitcher`
7
- - `themeStore` — daisyUI theme persistence
8
- - `fetchAppInfo` — typed API client wrapping `@h-ai/api-client`
9
- - Shared Inlang Paraglide messages compiled from this package's `project.inlang/` + `messages/`
5
+ - Administrators manage users, upload user avatars, assign the built-in administrator/user roles, edit role descriptions and query audit logs.
6
+ - Ordinary users can only update their own profile, avatar, language, appearance and password. The server enforces access on every request.
7
+ - `ManagementStore` owns API calls and business state; components render the state and forward user interactions.
8
+ - Application name, version, environment and default locale come from the backend `app.info` contract, which reads `core` configuration. Do not duplicate these values in translations or frontend constants.
9
+ - Requests, token refresh and JSON responses use the same `@h-ai/api-client` + `@h-ai/crypto` transport. Avatar uploads use encrypted JSON and validate PNG/JPEG/WebP content, with a 512 KiB limit.
10
+ - Read metadata through the same initialized `managementApi.app.info()` client; inspect `success` before using `data`.
10
11
 
11
- Consumed by `{{projectName}}-web`, `{{projectName}}-app`, `{{projectName}}-desktop`, and the reserved `{{projectName}}-miniapp` guidance.
12
+ All three Vite apps read the workspace root .env. Development and preview servers proxy /api to the local backend when no explicit API base is configured, so browser clients can use a single origin. In deployments, configure PUBLIC_API_BASE or the Web container runtime API origin for the actual service.
12
13
 
13
- ## i18n collaboration
14
+ An origin-only API base uses `/api/v1`. For a custom service `http.apiPrefix`, include that path in `PUBLIC_API_BASE` or the runtime API base; the client preserves the configured path and restricts avatar URLs to its matching resource prefix.
14
15
 
15
- - `packages/{{fullstack.sharedPackageName}}/messages/` stores **shared UI copy** used by reusable components such as `AppShell`, `ThemeSwitcher`, and `LanguageSwitcher`.
16
- - `apps/{{projectName}}-web/messages/`, `apps/{{projectName}}-app/messages/`, and `apps/{{projectName}}-desktop/messages/` store **frontend-specific product copy**.
17
- - `apps/{{projectName}}-miniapp/messages/` is prepared for mini-app-specific copy even though the mini target is currently a placeholder.
18
- - Keep `baseLocale` and `locales` aligned across shared and every frontend `project.inlang/settings.json`. The language switcher updates shared and the host app runtime together.
16
+ ## Authentication and preferences
19
17
 
20
- Rule of thumb: if a string appears inside reusable shared components, put it here; if it belongs to one product surface, put it in that frontend's `messages/` directory.
18
+ Web and browser previews keep access tokens in memory and refresh tokens in the server's httpOnly Cookie. Requests include credentials; reload restores the session through the encrypted refresh flow. Never store authentication tokens in localStorage.
21
19
 
22
- ## Scripts
20
+ Capacitor native uses `createCapacitorTokenStorage()` and the trusted native token channel. Tauri native uses memory token storage and requires a new login after restarting the application. Native builds require a reachable `PUBLIC_API_BASE` in the workspace root `.env`; missing configuration produces a connection configuration error. Both send their frontend `clientName` and `X-HAI-Token-Transport: body`; the service verifies the allowed native origin before returning a refresh token in JSON.
23
21
 
24
- - `pnpm paraglide:compile` — compile messages → `src/lib/paraglide/`
25
- - `pnpm paraglide:watch` — watch + recompile on message edits
26
- - `pnpm typecheck`, `pnpm lint`, `pnpm test`
22
+ Language and theme preferences are saved in IAM user metadata, restored after login and synchronized with the shared/host Paraglide runtimes and `core.i18n`. The small reactive theme store delegates DOM updates and safe appearance caching to `@h-ai/ui/theme` (`applyTheme` / `getSavedTheme`); the framework uses the `theme` localStorage key. Appearance selectors live in personal settings and save through the authenticated profile contract.
27
23
 
28
- ## Adding a locale
24
+ ## i18n and themes
29
25
 
30
- 1. Add the locale to `project.inlang/settings.json` `locales` array.
31
- 2. Add the same locale to every enabled frontend `project.inlang/settings.json`.
32
- 3. Create `messages/{locale}.json` in shared and each enabled frontend.
33
- 4. Run root `pnpm i18n:compile` to compile shared and every enabled frontend Paraglide output. You can also run `pnpm paraglide:compile` inside one package when editing that package only.
26
+ 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.
34
27
 
35
- ## Customizing theme and shell
28
+ The scaffold theme configuration determines the themes compiled into frontend CSS. Personal settings offer only those available themes. Customize the common layout in `src/lib/components/AppShell.svelte`, the management pages in adjacent components, and business behavior in `src/lib/stores/management-store.svelte.ts`.
36
29
 
37
- - Theme names come from the scaffold `fullstack.theme` config and are rendered into each frontend `src/app.css` daisyUI plugin block.
38
- - `themeStore` persists the selected theme in local storage under `{{projectName}}:theme`.
39
- - To customize the global chrome, edit `src/lib/components/AppShell.svelte`; to customize a single frontend page, edit that frontend's route and messages.
30
+ ## Validation
31
+
32
+ Run root `pnpm typecheck`, `pnpm lint`, `pnpm build`, `pnpm test` and `pnpm test:e2e`. Shared API tests exercise real transport key exchange, encrypted request/response handling, configured core metadata and credential forwarding. Root browser tests cover administrator and ordinary-user workflows, avatar rendering, persisted preferences, password/session invalidation and narrow-screen layout.