@h-ai/cli 0.1.0-alpha.57 → 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.
- package/README.en.md +37 -3
- package/README.ja.md +37 -3
- package/README.md +37 -3
- package/dist/chunk-2H7UOFLK.js +0 -0
- package/dist/{chunk-MJGUBOAV.js → chunk-F4IBPVZ5.js} +6 -42
- package/dist/chunk-F4IBPVZ5.js.map +1 -0
- package/dist/{chunk-O3ANVICQ.js → chunk-WUSFJFW5.js} +13 -7
- package/dist/chunk-WUSFJFW5.js.map +1 -0
- package/dist/cli-config-templates-6XA7V6CC.js +5 -0
- package/dist/{cli-config-templates-OWBEGBZQ.js.map → cli-config-templates-6XA7V6CC.js.map} +1 -1
- package/dist/{cli-skill-templates-ZYXES3NT.js → cli-skill-templates-45CK4RQ2.js} +3 -3
- package/dist/{cli-skill-templates-ZYXES3NT.js.map → cli-skill-templates-45CK4RQ2.js.map} +1 -1
- package/dist/index.js +107 -61
- package/dist/index.js.map +1 -1
- package/dist/{src-TBIET7YU.js → src-DUGLX4YB.js} +7270 -2119
- package/dist/src-DUGLX4YB.js.map +1 -0
- package/package.json +17 -17
- package/templates/README.md +4 -4
- package/templates/apps/admin/src/routes/+layout.svelte.hbs +7 -0
- package/templates/apps/admin/src/routes/+page.svelte.hbs +5 -4
- package/templates/apps/admin/src/routes/admin/+layout.svelte.hbs +2 -2
- package/templates/apps/api/.npmrc.hbs +1 -2
- package/templates/apps/api/Dockerfile.hbs +6 -5
- package/templates/apps/api/apps/{{projectName}}-contract/package.json.hbs +1 -1
- package/templates/apps/api/apps/{{projectName}}-contract/src/app-schemas.ts.hbs +2 -0
- package/templates/apps/api/apps/{{projectName}}-contract/tsup.config.ts.hbs +1 -1
- package/templates/apps/api/apps/{{projectName}}-service/config/_core.yml.hbs +8 -0
- package/templates/apps/api/apps/{{projectName}}-service/package.json.hbs +1 -1
- package/templates/apps/api/apps/{{projectName}}-service/src/app.ts.hbs +7 -8
- package/templates/apps/api/apps/{{projectName}}-service/src/index.ts.hbs +4 -3
- package/templates/apps/api/apps/{{projectName}}-service/src/lib/server/init.ts.hbs +0 -1
- package/templates/apps/api/apps/{{projectName}}-service/src/server/procedures/app-procedures.ts.hbs +4 -0
- package/templates/apps/api/apps/{{projectName}}-service/tests/service.test.ts.hbs +3 -1
- package/templates/apps/api/e2e/api-service.spec.ts.hbs +3 -1
- package/templates/apps/api/package.json.hbs +2 -2
- package/templates/apps/api/pnpm-workspace.yaml.hbs +26 -0
- package/templates/apps/api/tsconfig.base.json +0 -1
- package/templates/apps/fullstack/.dockerignore.hbs +3 -0
- package/templates/apps/fullstack/.npmrc.hbs +1 -2
- package/templates/apps/fullstack/Dockerfile.hbs +6 -5
- package/templates/apps/fullstack/apps/{{projectName}}-app/README.md.hbs +2 -2
- package/templates/apps/fullstack/apps/{{projectName}}-app/capacitor.config.ts.hbs +1 -1
- package/templates/apps/fullstack/apps/{{projectName}}-app/index.html.hbs +7 -2
- package/templates/apps/fullstack/apps/{{projectName}}-app/messages/en-US.json.hbs +1 -34
- package/templates/apps/fullstack/apps/{{projectName}}-app/messages/zh-CN.json.hbs +1 -34
- package/templates/apps/fullstack/apps/{{projectName}}-app/package.json.hbs +2 -2
- package/templates/apps/fullstack/apps/{{projectName}}-app/src/App.svelte.hbs +30 -170
- package/templates/apps/fullstack/apps/{{projectName}}-app/src/app.d.ts.hbs +1 -1
- package/templates/apps/fullstack/apps/{{projectName}}-app/src/lib/capacitor.ts.hbs +6 -5
- package/templates/apps/fullstack/apps/{{projectName}}-app/src/main.ts.hbs +1 -1
- package/templates/apps/fullstack/apps/{{projectName}}-app/vite.config.ts.hbs +48 -22
- package/templates/apps/fullstack/apps/{{projectName}}-desktop/README.md.hbs +3 -1
- package/templates/apps/fullstack/apps/{{projectName}}-desktop/index.html.hbs +6 -1
- package/templates/apps/fullstack/apps/{{projectName}}-desktop/messages/en-US.json.hbs +1 -28
- package/templates/apps/fullstack/apps/{{projectName}}-desktop/messages/zh-CN.json.hbs +1 -28
- package/templates/apps/fullstack/apps/{{projectName}}-desktop/package.json.hbs +5 -4
- package/templates/apps/fullstack/apps/{{projectName}}-desktop/src/App.svelte.hbs +10 -141
- package/templates/apps/fullstack/apps/{{projectName}}-desktop/src-tauri/Cargo.toml.hbs +5 -5
- package/templates/apps/fullstack/apps/{{projectName}}-desktop/src-tauri/tauri.conf.json.hbs +3 -3
- package/templates/apps/fullstack/apps/{{projectName}}-desktop/vite.config.ts.hbs +21 -2
- package/templates/apps/fullstack/apps/{{projectName}}-web/README.md.hbs +2 -2
- package/templates/apps/fullstack/apps/{{projectName}}-web/index.html.hbs +6 -1
- package/templates/apps/fullstack/apps/{{projectName}}-web/messages/en-US.json.hbs +1 -28
- package/templates/apps/fullstack/apps/{{projectName}}-web/messages/zh-CN.json.hbs +1 -28
- package/templates/apps/fullstack/apps/{{projectName}}-web/package.json.hbs +2 -2
- package/templates/apps/fullstack/apps/{{projectName}}-web/src/App.svelte.hbs +5 -143
- package/templates/apps/fullstack/apps/{{projectName}}-web/src/app.d.ts.hbs +1 -1
- package/templates/apps/fullstack/apps/{{projectName}}-web/src/main.ts.hbs +1 -1
- package/templates/apps/fullstack/apps/{{projectName}}-web/vite.config.ts.hbs +48 -22
- package/templates/apps/fullstack/e2e/fullstack.spec.ts.hbs +206 -16
- package/templates/apps/fullstack/package.json.hbs +2 -2
- package/templates/apps/fullstack/packages/{{projectName}}-contract/README.md.hbs +2 -0
- package/templates/apps/fullstack/packages/{{projectName}}-contract/package.json.hbs +1 -1
- package/templates/apps/fullstack/packages/{{projectName}}-contract/src/admin-contract.ts.hbs +66 -0
- package/templates/apps/fullstack/packages/{{projectName}}-contract/src/admin-schemas.ts.hbs +96 -0
- package/templates/apps/fullstack/packages/{{projectName}}-contract/src/app-schemas.ts.hbs +3 -0
- package/templates/apps/fullstack/packages/{{projectName}}-contract/src/index.ts.hbs +3 -1
- package/templates/apps/fullstack/packages/{{projectName}}-contract/src/{{projectName}}-contract.ts.hbs +5 -0
- package/templates/apps/fullstack/packages/{{projectName}}-contract/tests/contract.test.ts.hbs +3 -0
- package/templates/apps/fullstack/packages/{{projectName}}-contract/tsup.config.ts.hbs +1 -1
- package/templates/apps/fullstack/packages/{{projectName}}-serv/README.md.hbs +13 -3
- package/templates/apps/fullstack/packages/{{projectName}}-serv/config/_audit.yml.hbs +3 -0
- package/templates/apps/fullstack/packages/{{projectName}}-serv/config/_cache.yml.hbs +2 -0
- package/templates/apps/fullstack/packages/{{projectName}}-serv/config/_core.yml.hbs +10 -3
- package/templates/apps/fullstack/packages/{{projectName}}-serv/config/_db.yml.hbs +3 -0
- package/templates/apps/fullstack/packages/{{projectName}}-serv/config/_iam.yml.hbs +21 -0
- package/templates/apps/fullstack/packages/{{projectName}}-serv/config/_serv.yml.hbs +10 -3
- package/templates/apps/fullstack/packages/{{projectName}}-serv/config/_storage.yml.hbs +3 -0
- package/templates/apps/fullstack/packages/{{projectName}}-serv/package.json.hbs +10 -3
- package/templates/apps/fullstack/packages/{{projectName}}-serv/src/app-server.ts.hbs +63 -28
- package/templates/apps/fullstack/packages/{{projectName}}-serv/src/index.ts.hbs +26 -11
- package/templates/apps/fullstack/packages/{{projectName}}-serv/src/server/admin-service.ts.hbs +276 -0
- package/templates/apps/fullstack/packages/{{projectName}}-serv/src/server/messages.ts.hbs +29 -0
- package/templates/apps/fullstack/packages/{{projectName}}-serv/src/server/procedures/admin-procedures.ts.hbs +94 -0
- package/templates/apps/fullstack/packages/{{projectName}}-serv/src/server/procedures/app-procedures.ts.hbs +6 -0
- package/templates/apps/fullstack/packages/{{projectName}}-serv/src/server/procedures/index.ts.hbs +5 -1
- package/templates/apps/fullstack/packages/{{projectName}}-serv/src/server/runtime.ts.hbs +98 -0
- package/templates/apps/fullstack/packages/{{projectName}}-serv/tests/app-server.test.ts.hbs +446 -33
- package/templates/apps/fullstack/packages/{{projectName}}-serv/tests/runtime.test.ts.hbs +48 -0
- package/templates/apps/fullstack/packages/{{projectName}}-serv/tsup.config.ts.hbs +1 -1
- package/templates/apps/fullstack/packages/{{projectName}}-shared/README.md.hbs +19 -26
- package/templates/apps/fullstack/packages/{{projectName}}-shared/messages/en-US.json.hbs +78 -15
- package/templates/apps/fullstack/packages/{{projectName}}-shared/messages/zh-CN.json.hbs +78 -15
- package/templates/apps/fullstack/packages/{{projectName}}-shared/package.json.hbs +4 -1
- package/templates/apps/fullstack/packages/{{projectName}}-shared/src/index.ts.hbs +3 -4
- package/templates/apps/fullstack/packages/{{projectName}}-shared/src/lib/api/api-client.ts.hbs +53 -27
- package/templates/apps/fullstack/packages/{{projectName}}-shared/src/lib/components/AdminWorkspace.svelte.hbs +73 -0
- package/templates/apps/fullstack/packages/{{projectName}}-shared/src/lib/components/AppShell.svelte.hbs +55 -45
- package/templates/apps/fullstack/packages/{{projectName}}-shared/src/lib/components/AuditManagement.svelte.hbs +61 -0
- package/templates/apps/fullstack/packages/{{projectName}}-shared/src/lib/components/ProfileSettings.svelte.hbs +57 -0
- package/templates/apps/fullstack/packages/{{projectName}}-shared/src/lib/components/RoleManagement.svelte.hbs +49 -0
- package/templates/apps/fullstack/packages/{{projectName}}-shared/src/lib/components/UserManagement.svelte.hbs +174 -0
- package/templates/apps/fullstack/packages/{{projectName}}-shared/src/lib/stores/management-store.svelte.ts.hbs +399 -0
- package/templates/apps/fullstack/packages/{{projectName}}-shared/src/lib/stores/theme-store.svelte.ts.hbs +7 -39
- package/templates/apps/fullstack/packages/{{projectName}}-shared/svelte.config.js.hbs +1 -0
- package/templates/apps/fullstack/packages/{{projectName}}-shared/tests/api-client.test.ts.hbs +151 -0
- package/templates/apps/fullstack/packages/{{projectName}}-shared/tests/management-store.test.ts.hbs +101 -0
- package/templates/apps/fullstack/packages/{{projectName}}-shared/vitest.config.ts.hbs +7 -0
- package/templates/apps/fullstack/playwright.config.ts.hbs +6 -0
- package/templates/apps/fullstack/pnpm-workspace.yaml.hbs +24 -0
- package/templates/apps/fullstack/tsconfig.base.json +0 -1
- package/templates/apps/h5/src/routes/+layout.svelte.hbs +4 -0
- package/templates/apps/h5/src/routes/+page.svelte.hbs +3 -2
- package/templates/apps/mobile-app/index.html.hbs +2 -2
- package/templates/apps/mobile-app/src/App.svelte.hbs +6 -2
- package/templates/apps/mobile-app/src/lib/app-config.ts.hbs +13 -0
- package/templates/apps/website/src/routes/+layout.svelte.hbs +8 -2
- package/templates/apps/website/src/routes/+page.svelte.hbs +4 -3
- package/templates/base/.npmrc.hbs +1 -15
- package/templates/base/Dockerfile.hbs +9 -8
- package/templates/base/e2e/app.spec.ts.hbs +1 -0
- package/templates/base/package.json.hbs +39 -42
- package/templates/base/pnpm-workspace.yaml.hbs +26 -0
- package/templates/base/src/app.d.ts.hbs +4 -0
- package/templates/base/src/hooks.server.ts.hbs +0 -8
- package/templates/base/src/lib/server/init.ts.hbs +26 -5
- package/templates/base/src/routes/+layout.server.ts.hbs +18 -0
- package/templates/base/tests/scaffold.test.ts.hbs +21 -1
- package/templates/base/vite.config.ts.hbs +21 -0
- package/templates/features/ai/routes/api/chat/+server.ts +6 -7
- package/templates/features/iam/routes-admin/(auth)/auth/login/+page.svelte.hbs +1 -1
- package/templates/features/iam/routes-h5/auth/login/+page.svelte.hbs +1 -1
- package/templates/features/iam/routes-shared/api/auth/login/+server.ts +14 -10
- package/templates/features/iam/routes-shared/api/auth/logout/+server.ts +6 -4
- package/templates/features/storage/routes/api/upload/+server.ts +10 -8
- package/templates/skills/CLAUDE.md +0 -14
- package/templates/skills/bridges/fullstack/AGENTS.md.hbs +13 -2
- package/templates/skills/bridges/fullstack/copilot-instructions.md.hbs +3 -0
- package/templates/skills/hai-api-client/SKILL.md +1 -1
- package/templates/skills/hai-app-create/development.md +6 -0
- package/templates/skills/hai-build/reference.md +4 -2
- package/templates/skills/hai-core/SKILL.md +2 -0
- package/templates/skills/hai-core/reference.md +6 -4
- package/templates/skills/hai-framework-sync/SKILL.md +1 -2
- package/templates/skills/hai-serv/SKILL.md +1 -0
- package/templates/skills/hai-serv/reference.md +3 -1
- package/templates/skills/hai-ui/SKILL.md +6 -0
- package/templates/skills/hai-ui/reference.md +7 -4
- package/templates/skills/hai-vecdb/reference.md +53 -49
- package/dist/chunk-MJGUBOAV.js.map +0 -1
- package/dist/chunk-O3ANVICQ.js.map +0 -1
- package/dist/cli-config-templates-OWBEGBZQ.js +0 -5
- package/dist/src-TBIET7YU.js.map +0 -1
- package/templates/apps/fullstack/apps/{{projectName}}-app/tests/api.test.ts.hbs +0 -28
- package/templates/apps/fullstack/apps/{{projectName}}-desktop/tests/api.test.ts.hbs +0 -28
- package/templates/apps/fullstack/apps/{{projectName}}-web/tests/api.test.ts.hbs +0 -55
- package/templates/apps/fullstack/packages/{{projectName}}-shared/src/lib/components/LanguageSwitcher.svelte.hbs +0 -27
- package/templates/apps/fullstack/packages/{{projectName}}-shared/src/lib/components/ThemeSwitcher.svelte.hbs +0 -18
- package/templates/skills/bridges/admin/CLAUDE.md +0 -15
- package/templates/skills/bridges/api/CLAUDE.md +0 -15
- package/templates/skills/bridges/fullstack/CLAUDE.md.hbs +0 -9
- package/templates/skills/bridges/h5/CLAUDE.md +0 -15
- package/templates/skills/bridges/mobile-app/CLAUDE.md +0 -15
- package/templates/skills/bridges/website/CLAUDE.md +0 -15
- package/templates/skills/claude-skill.md.hbs +0 -7
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
{
|
|
2
|
-
"engines": { "node": "
|
|
2
|
+
"engines": { "node": "^22.22.2 || ^24.15.0 || >=26.0.0" },
|
|
3
3
|
"name": "{{fullstack.servPackageName}}",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"version": "0.1.0",
|
|
@@ -25,12 +25,19 @@
|
|
|
25
25
|
"test": "vitest run",
|
|
26
26
|
"test:watch": "vitest",
|
|
27
27
|
"clean": "rimraf dist .turbo",
|
|
28
|
-
"dev": "pnpm build && node dist/index.js",
|
|
29
|
-
"start": "node dist/index.js"
|
|
28
|
+
"dev": "pnpm build && node --env-file-if-exists=../../.env dist/index.js",
|
|
29
|
+
"start": "node --env-file-if-exists=../../.env dist/index.js"
|
|
30
30
|
},
|
|
31
31
|
"dependencies": {
|
|
32
|
+
"@h-ai/audit": "{{haiPackageSpecifier}}",
|
|
33
|
+
"@h-ai/cache": "{{haiPackageSpecifier}}",
|
|
32
34
|
"@h-ai/core": "{{haiPackageSpecifier}}",
|
|
35
|
+
"@h-ai/crypto": "{{haiPackageSpecifier}}",
|
|
36
|
+
"@h-ai/iam": "{{haiPackageSpecifier}}",
|
|
37
|
+
"@h-ai/reldb": "{{haiPackageSpecifier}}",
|
|
33
38
|
"@h-ai/serv": "{{haiPackageSpecifier}}",
|
|
39
|
+
"@h-ai/storage": "{{haiPackageSpecifier}}",
|
|
40
|
+
"zod": "{{fullstack.packageSpecifiers.zod}}",
|
|
34
41
|
"{{fullstack.contractPackageName}}": "workspace:*"
|
|
35
42
|
}
|
|
36
43
|
}
|
|
@@ -1,48 +1,73 @@
|
|
|
1
|
-
import type {
|
|
2
|
-
import {
|
|
1
|
+
import type { CoreConfig } from '@h-ai/core'
|
|
2
|
+
import type { ServHttpApp } from '@h-ai/serv'
|
|
3
|
+
import { core, err, HaiCommonError, ok } from '@h-ai/core'
|
|
4
|
+
import { createReldbTransportKeyStore, crypto } from '@h-ai/crypto'
|
|
5
|
+
import { iam } from '@h-ai/iam'
|
|
6
|
+
import { reldb } from '@h-ai/reldb'
|
|
3
7
|
import { serv, ServConfigSchema } from '@h-ai/serv'
|
|
4
|
-
import {
|
|
8
|
+
import { storage } from '@h-ai/storage'
|
|
9
|
+
import { ENABLED_FRONTENDS, {{fullstack.contractExportName}}, SUPPORTED_LOCALES } from '{{fullstack.contractPackageName}}'
|
|
5
10
|
import { createServerProcedures } from './server/procedures/index.js'
|
|
6
11
|
|
|
7
12
|
const STARTED_AT = new Date().toISOString()
|
|
8
13
|
|
|
9
|
-
/** `_core.yml` 中的应用元信息(由 core.config 加载,未初始化时回退到默认值)。 */
|
|
10
|
-
interface AppCoreConfig {
|
|
11
|
-
name?: string
|
|
12
|
-
version?: string
|
|
13
|
-
env?: string
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
/** 应用侧最小 HTTP App 抽象;不把底层 Hono 类型暴露给项目代码。 */
|
|
17
|
-
export interface ServerApp {
|
|
18
|
-
readonly fetch: (request: Request) => Response | Promise<Response>
|
|
19
|
-
readonly request: (input: RequestInfo | URL, init?: RequestInit) => Response | Promise<Response>
|
|
20
|
-
}
|
|
21
|
-
|
|
22
14
|
/** 创建后端 HTTP App;与 @h-ai/serv 的 createApp 返回语义保持一致。 */
|
|
23
|
-
export function createServerApp():
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
const coreConfig = core.config.get<AppCoreConfig>('core')
|
|
27
|
-
const servConfig = ServConfigSchema.parse(core.config.get<ServConfigInput>('serv') ?? {})
|
|
28
|
-
const appName = coreConfig?.name ?? '{{fullstack.servPackageName}}'
|
|
29
|
-
const appVersion = coreConfig?.version ?? '0.1.0'
|
|
15
|
+
export function createServerApp(): ServHttpApp {
|
|
16
|
+
const coreConfig = core.config.getOrThrow<CoreConfig>('core')
|
|
17
|
+
const servConfig = ServConfigSchema.parse(core.config.getOrThrow('serv'))
|
|
30
18
|
const security = serv.createRuntimeSecurityPolicy({
|
|
31
|
-
environment: coreConfig
|
|
19
|
+
environment: coreConfig.env,
|
|
32
20
|
corsOrigin: servConfig.cors.origin,
|
|
33
21
|
nativeOrigins: servConfig.cors.nativeOrigins,
|
|
34
22
|
})
|
|
23
|
+
const publicPaths = [
|
|
24
|
+
...(servConfig.http.health ? [servConfig.http.health.path, servConfig.http.health.readyPath].filter((path): path is `/${string}` => Boolean(path)) : []),
|
|
25
|
+
...(servConfig.http.openapi && security.exposeApiDocs ? [servConfig.http.openapi.path] : []),
|
|
26
|
+
...(servConfig.http.docs && security.exposeApiDocs ? [servConfig.http.docs.path] : []),
|
|
27
|
+
]
|
|
35
28
|
|
|
36
29
|
return serv.createApp({
|
|
37
30
|
contract: {{fullstack.contractExportName}},
|
|
38
31
|
procedures: createServerProcedures({
|
|
39
32
|
app: {
|
|
40
|
-
name:
|
|
41
|
-
version:
|
|
33
|
+
name: coreConfig.name,
|
|
34
|
+
version: coreConfig.version,
|
|
35
|
+
env: coreConfig.env,
|
|
36
|
+
defaultLocale: coreConfig.defaultLocale,
|
|
37
|
+
supportedLocales: SUPPORTED_LOCALES,
|
|
42
38
|
startedAt: STARTED_AT,
|
|
43
39
|
frontends: [...ENABLED_FRONTENDS],
|
|
44
40
|
},
|
|
45
41
|
}),
|
|
42
|
+
iam,
|
|
43
|
+
verifyToken: async (token) => {
|
|
44
|
+
const session = await iam.session.verifyToken(token)
|
|
45
|
+
if (!session.success)
|
|
46
|
+
return session
|
|
47
|
+
const user = await iam.user.getUser(session.data.userId)
|
|
48
|
+
if (!user.success)
|
|
49
|
+
return user
|
|
50
|
+
if (!user.data?.enabled)
|
|
51
|
+
return err(HaiCommonError.UNAUTHORIZED, core.i18n.coreM('core_errorUnauthorized'))
|
|
52
|
+
// 会话中的授权同步属于最大努力,业务守卫始终读取最新持久化角色与权限。
|
|
53
|
+
const [roles, permissions] = await Promise.all([
|
|
54
|
+
iam.authz.getUserRoles(user.data.id),
|
|
55
|
+
iam.authz.getUserPermissions(user.data.id),
|
|
56
|
+
])
|
|
57
|
+
if (!roles.success)
|
|
58
|
+
return roles
|
|
59
|
+
if (!permissions.success)
|
|
60
|
+
return permissions
|
|
61
|
+
return ok({ ...session.data, roles: roles.data.map(role => role.code), permissions: permissions.data.map(permission => permission.code) })
|
|
62
|
+
},
|
|
63
|
+
refreshCookie: {
|
|
64
|
+
secure: security.secureRefreshCookie,
|
|
65
|
+
nativeTokenTransport: { isRequest: request => request.headers.get('x-hai-token-transport') === 'body'
|
|
66
|
+
&& ['{{projectName}}-app', '{{projectName}}-desktop'].includes(request.headers.get('x-request-client') ?? '')
|
|
67
|
+
&& security.isNativeOrigin(request.headers.get('origin') ?? '') },
|
|
68
|
+
},
|
|
69
|
+
// 管理业务默认强制双向加密;客户端公钥复用 reldb 保存,服务端密钥仅驻留进程。
|
|
70
|
+
transport: { ...(servConfig.transport || {}), excludePaths: publicPaths, crypto, keyStore: createReldbTransportKeyStore({ reldb }) },
|
|
46
71
|
http: {
|
|
47
72
|
...servConfig.http,
|
|
48
73
|
openapi: security.exposeApiDocs ? servConfig.http.openapi : false,
|
|
@@ -52,8 +77,18 @@ export function createServerApp(): ServerApp {
|
|
|
52
77
|
middleware: serv.cors({
|
|
53
78
|
origin: security.allowOrigin,
|
|
54
79
|
credentials: servConfig.cors.credentials,
|
|
55
|
-
allowedHeaders: [...servConfig.cors.allowedHeaders],
|
|
56
|
-
exposedHeaders: [...servConfig.cors.exposedHeaders],
|
|
80
|
+
allowedHeaders: [...new Set([...servConfig.cors.allowedHeaders, 'X-Client-Id', 'X-Encrypted', 'X-Request-Client', 'X-HAI-Token-Transport'])],
|
|
81
|
+
exposedHeaders: [...new Set([...servConfig.cors.exposedHeaders, 'X-Encrypted'])],
|
|
82
|
+
}),
|
|
83
|
+
}, {
|
|
84
|
+
path: `${servConfig.http.apiPrefix}/assets/*`,
|
|
85
|
+
middleware: serv.storageAssets({
|
|
86
|
+
storage,
|
|
87
|
+
pathPrefix: `${servConfig.http.apiPrefix}/assets/`,
|
|
88
|
+
keyPattern: /^avatars\/[\w-]+\/[\w-]+\.(png|jpg|webp)$/,
|
|
89
|
+
allowedContentTypes: ['image/png', 'image/jpeg', 'image/webp'],
|
|
90
|
+
cacheControl: 'public, max-age=31536000, immutable',
|
|
91
|
+
crossOriginResourcePolicy: 'cross-origin',
|
|
57
92
|
}),
|
|
58
93
|
}],
|
|
59
94
|
})
|
|
@@ -1,18 +1,33 @@
|
|
|
1
|
+
import type { CoreConfig } from '@h-ai/core'
|
|
1
2
|
import type { ServConfigInput } from '@h-ai/serv'
|
|
3
|
+
import process from 'node:process'
|
|
2
4
|
import { core } from '@h-ai/core'
|
|
3
5
|
import { serv, ServConfigSchema } from '@h-ai/serv'
|
|
4
6
|
import { createServerApp } from './app-server.js'
|
|
7
|
+
import { closeModules, initializeModules } from './server/runtime.js'
|
|
5
8
|
|
|
6
|
-
|
|
7
|
-
|
|
9
|
+
async function startServer(): Promise<void> {
|
|
10
|
+
// 加载配置后按依赖顺序初始化;日志设置沿用 _core.yml。
|
|
11
|
+
core.init({ configDir: './config' })
|
|
12
|
+
await initializeModules()
|
|
13
|
+
try {
|
|
14
|
+
// HOST/PORT 环境变量优先于 config/_serv.yml。
|
|
15
|
+
const { server } = ServConfigSchema.parse(core.config.getOrThrow<ServConfigInput>('serv'))
|
|
16
|
+
const app = createServerApp()
|
|
17
|
+
serv.listen(app, {
|
|
18
|
+
host: server.host,
|
|
19
|
+
port: server.port,
|
|
20
|
+
onListening: info => core.logger.info('Application listening', { name: core.config.getOrThrow<CoreConfig>('core').name, host: server.host, port: info.port }),
|
|
21
|
+
onClose: closeModules,
|
|
22
|
+
})
|
|
23
|
+
}
|
|
24
|
+
catch (error) {
|
|
25
|
+
await closeModules()
|
|
26
|
+
throw error
|
|
27
|
+
}
|
|
28
|
+
}
|
|
8
29
|
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
const listenApp = app as Parameters<typeof serv.listen>[0]
|
|
13
|
-
|
|
14
|
-
serv.listen(listenApp, {
|
|
15
|
-
host: server.host,
|
|
16
|
-
port: server.port,
|
|
17
|
-
onListening: info => core.logger.info('{{fullstack.servPackageName}} listening', { host: server.host, port: info.port }),
|
|
30
|
+
void startServer().catch((error: unknown) => {
|
|
31
|
+
core.logger.fatal('Application startup failed', { error: error instanceof Error ? error.message : 'Unknown startup failure' })
|
|
32
|
+
process.exitCode = 1
|
|
18
33
|
})
|
package/templates/apps/fullstack/packages/{{projectName}}-serv/src/server/admin-service.ts.hbs
ADDED
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
import type { HaiResult } from '@h-ai/core'
|
|
2
|
+
import type { Role, User } from '@h-ai/iam'
|
|
3
|
+
import type { ServConfig, ServContext } from '@h-ai/serv'
|
|
4
|
+
import type { AdminCreateUser, AdminRole, AdminRoleCode, AdminUpdateUser, AdminUser, AvatarUpload, ProfileUpdate } from '{{fullstack.contractPackageName}}'
|
|
5
|
+
import { Buffer } from 'node:buffer'
|
|
6
|
+
import { audit } from '@h-ai/audit'
|
|
7
|
+
import { cache } from '@h-ai/cache'
|
|
8
|
+
import { core, err, HaiCommonError, ok } from '@h-ai/core'
|
|
9
|
+
import { iam } from '@h-ai/iam'
|
|
10
|
+
import { storage } from '@h-ai/storage'
|
|
11
|
+
import { SUPPORTED_LOCALES } from '{{fullstack.contractPackageName}}'
|
|
12
|
+
import { serverM } from './messages.js'
|
|
13
|
+
|
|
14
|
+
const ROLE_CODES = new Set<AdminRoleCode>(['admin', 'user'])
|
|
15
|
+
const SUPPORTED_THEMES = [{{#each fullstack.theme.themes}}'{{this}}'{{#unless @last}}, {{/unless}}{{/each}}] as readonly string[]
|
|
16
|
+
let adminMutationActive = false
|
|
17
|
+
|
|
18
|
+
/** 管理写操作串行化,锁内重新鉴权,防止两个管理员同时互相删除或降级。 */
|
|
19
|
+
export async function withAdminMutation<T>(context: ServContext, work: () => Promise<HaiResult<T>>): Promise<HaiResult<T>> {
|
|
20
|
+
// mini 的管理写入仅支持单写实例;进程内保护无租期,慢请求不会在 TTL 到期后被并发穿透。
|
|
21
|
+
if (adminMutationActive)
|
|
22
|
+
return err(HaiCommonError.CONFLICT, serverM('busy', { locale: context.locale }))
|
|
23
|
+
adminMutationActive = true
|
|
24
|
+
const owner = core.id.uuid()
|
|
25
|
+
const key = 'hai:app:admin-mutation'
|
|
26
|
+
let acquired = false
|
|
27
|
+
let leaseLost = false
|
|
28
|
+
let renewalPending = false
|
|
29
|
+
let renewalTimer: ReturnType<typeof setInterval> | undefined
|
|
30
|
+
try {
|
|
31
|
+
const locked = await cache.lock.acquire(key, { owner, ttl: 300 })
|
|
32
|
+
if (!locked.success)
|
|
33
|
+
return locked
|
|
34
|
+
if (!locked.data)
|
|
35
|
+
return err(HaiCommonError.CONFLICT, serverM('busy', { locale: context.locale }))
|
|
36
|
+
acquired = true
|
|
37
|
+
renewalTimer = setInterval(() => {
|
|
38
|
+
if (renewalPending || leaseLost)
|
|
39
|
+
return
|
|
40
|
+
renewalPending = true
|
|
41
|
+
void cache.lock.extend(key, 300, owner).then((renewed) => {
|
|
42
|
+
if (!renewed.success || !renewed.data) {
|
|
43
|
+
leaseLost = true
|
|
44
|
+
core.logger.error('Admin mutation lease renewal failed', { owner, error: renewed.success ? 'lease_not_owned' : renewed.error.code })
|
|
45
|
+
}
|
|
46
|
+
}).catch(() => {
|
|
47
|
+
leaseLost = true
|
|
48
|
+
core.logger.error('Admin mutation lease renewal failed', { owner, error: 'unexpected_renewal_failure' })
|
|
49
|
+
}).finally(() => {
|
|
50
|
+
renewalPending = false
|
|
51
|
+
})
|
|
52
|
+
}, 100_000)
|
|
53
|
+
renewalTimer.unref()
|
|
54
|
+
const actor = await getAdminUser(context.session?.userId ?? '')
|
|
55
|
+
if (!actor.success)
|
|
56
|
+
return actor
|
|
57
|
+
if (!actor.data.enabled || !actor.data.roles.includes('admin'))
|
|
58
|
+
return err(HaiCommonError.FORBIDDEN, serverM('selfProtection', { locale: context.locale }))
|
|
59
|
+
if (leaseLost)
|
|
60
|
+
return err(HaiCommonError.CONFLICT, serverM('busy', { locale: context.locale }))
|
|
61
|
+
return await work()
|
|
62
|
+
}
|
|
63
|
+
finally {
|
|
64
|
+
if (renewalTimer)
|
|
65
|
+
clearInterval(renewalTimer)
|
|
66
|
+
try {
|
|
67
|
+
if (acquired) {
|
|
68
|
+
const released = await cache.lock.release(key, owner)
|
|
69
|
+
if (!released.success || !released.data)
|
|
70
|
+
core.logger.error('Failed to release admin mutation lock', { owner, error: released.success ? 'lease_not_owned' : released.error.code })
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
finally {
|
|
74
|
+
adminMutationActive = false
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** 用户与角色读取失败直接透传,不能把角色查询失败当作普通用户。 */
|
|
80
|
+
export async function getAdminUser(id: string): Promise<HaiResult<AdminUser>> {
|
|
81
|
+
const user = await iam.user.getUser(id)
|
|
82
|
+
if (!user.success)
|
|
83
|
+
return user
|
|
84
|
+
if (!user.data)
|
|
85
|
+
return err(HaiCommonError.NOT_FOUND, serverM('notFound'))
|
|
86
|
+
const roles = await iam.authz.getUserRoles(id)
|
|
87
|
+
if (!roles.success)
|
|
88
|
+
return roles
|
|
89
|
+
return ok(toAdminUser(user.data, roles.data))
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function toAdminUser(user: User, roles: Role[]): AdminUser {
|
|
93
|
+
return {
|
|
94
|
+
id: user.id,
|
|
95
|
+
username: user.username,
|
|
96
|
+
email: user.email,
|
|
97
|
+
displayName: user.displayName,
|
|
98
|
+
avatarUrl: user.avatarUrl,
|
|
99
|
+
enabled: user.enabled,
|
|
100
|
+
roles: roles.map(role => role.code).filter(isRoleCode),
|
|
101
|
+
createdAt: user.createdAt.toISOString(),
|
|
102
|
+
updatedAt: user.updatedAt.toISOString(),
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function isRoleCode(code: string): code is AdminRoleCode {
|
|
107
|
+
return ROLE_CODES.has(code as AdminRoleCode)
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export async function createAdminUser(input: AdminCreateUser, context: ServContext): Promise<HaiResult<AdminUser>> {
|
|
111
|
+
const role = await iam.authz.getRoleByCode(input.role)
|
|
112
|
+
if (!role.success)
|
|
113
|
+
return role
|
|
114
|
+
if (!role.data)
|
|
115
|
+
return err(HaiCommonError.NOT_FOUND, serverM('notFound', { locale: context.locale }))
|
|
116
|
+
const registered = await iam.user.register(input)
|
|
117
|
+
if (!registered.success)
|
|
118
|
+
return registered
|
|
119
|
+
const userId = registered.data.user.id
|
|
120
|
+
const assigned = await iam.authz.syncRoles(userId, [role.data.id])
|
|
121
|
+
if (!assigned.success) {
|
|
122
|
+
await rollbackCreatedUser(userId)
|
|
123
|
+
return assigned
|
|
124
|
+
}
|
|
125
|
+
if (!input.enabled) {
|
|
126
|
+
const disabled = await iam.user.updateUser(userId, { enabled: false })
|
|
127
|
+
if (!disabled.success) {
|
|
128
|
+
await rollbackCreatedUser(userId)
|
|
129
|
+
return disabled
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
await recordAudit(context, 'create', 'users', userId, { role: input.role, enabled: input.enabled })
|
|
133
|
+
return getAdminUser(userId)
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
async function rollbackCreatedUser(id: string): Promise<void> {
|
|
137
|
+
const removed = await iam.user.deleteUser(id)
|
|
138
|
+
if (!removed.success)
|
|
139
|
+
core.logger.error('Failed to roll back user creation', { userId: id, error: removed.error.code })
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export async function updateAdminUser(input: AdminUpdateUser, context: ServContext): Promise<HaiResult<AdminUser>> {
|
|
143
|
+
if (input.id === context.session?.userId && (input.enabled === false || input.role === 'user'))
|
|
144
|
+
return err(HaiCommonError.FORBIDDEN, serverM('selfProtection', { locale: context.locale }))
|
|
145
|
+
const existing = await iam.user.getUser(input.id)
|
|
146
|
+
if (!existing.success)
|
|
147
|
+
return existing
|
|
148
|
+
if (!existing.data)
|
|
149
|
+
return err(HaiCommonError.NOT_FOUND, serverM('notFound', { locale: context.locale }))
|
|
150
|
+
const role = input.role ? await iam.authz.getRoleByCode(input.role) : undefined
|
|
151
|
+
if (role && !role.success)
|
|
152
|
+
return role
|
|
153
|
+
if (role?.success && !role.data)
|
|
154
|
+
return err(HaiCommonError.NOT_FOUND, serverM('notFound', { locale: context.locale }))
|
|
155
|
+
const { id, role: _role, ...profile } = input
|
|
156
|
+
const previousRoles = await iam.authz.getUserRoles(id)
|
|
157
|
+
if (!previousRoles.success)
|
|
158
|
+
return previousRoles
|
|
159
|
+
if (role?.success && role.data) {
|
|
160
|
+
const assigned = await iam.authz.syncRoles(id, [role.data.id])
|
|
161
|
+
if (!assigned.success)
|
|
162
|
+
return assigned
|
|
163
|
+
}
|
|
164
|
+
const updated = await iam.user.updateUser(id, profile)
|
|
165
|
+
if (!updated.success) {
|
|
166
|
+
// 角色 ID 可精确还原,避免 undefined 资料字段被 updateUser 忽略后无法补偿。
|
|
167
|
+
if (role?.success && role.data) {
|
|
168
|
+
const restored = await iam.authz.syncRoles(id, previousRoles.data.map(item => item.id))
|
|
169
|
+
if (!restored.success)
|
|
170
|
+
core.logger.error('Failed to restore roles after user update failure', { userId: id, error: restored.error.code })
|
|
171
|
+
}
|
|
172
|
+
return updated
|
|
173
|
+
}
|
|
174
|
+
await recordAudit(context, 'update', 'users', id, { fields: Object.keys(profile), role: input.role })
|
|
175
|
+
return getAdminUser(id)
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
export async function getAdminRoles(): Promise<HaiResult<{ items: AdminRole[] }>> {
|
|
179
|
+
const roles = await iam.authz.getAllRoles({ page: 1, pageSize: 100 })
|
|
180
|
+
if (!roles.success)
|
|
181
|
+
return roles
|
|
182
|
+
const items = roles.data.items.filter(role => isRoleCode(role.code))
|
|
183
|
+
const counts = await iam.authz.getRoleUserCounts(items.map(role => role.id))
|
|
184
|
+
if (!counts.success)
|
|
185
|
+
return counts
|
|
186
|
+
return ok({ items: items.map(role => ({
|
|
187
|
+
id: role.id,
|
|
188
|
+
code: role.code as AdminRoleCode,
|
|
189
|
+
name: role.name,
|
|
190
|
+
description: role.description,
|
|
191
|
+
userCount: counts.data.get(role.id) ?? 0,
|
|
192
|
+
})) })
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
export async function updateProfile(input: ProfileUpdate, context: ServContext): Promise<HaiResult<AdminUser>> {
|
|
196
|
+
const token = context.accessToken ?? ''
|
|
197
|
+
const current = await iam.user.getCurrentUser(token)
|
|
198
|
+
if (!current.success)
|
|
199
|
+
return current
|
|
200
|
+
if ((input.locale && !(SUPPORTED_LOCALES as readonly string[]).includes(input.locale)) || (input.theme && !SUPPORTED_THEMES.includes(input.theme)))
|
|
201
|
+
return err(HaiCommonError.VALIDATION_ERROR, serverM('unsupportedPreference', { locale: context.locale }))
|
|
202
|
+
const metadata = { ...current.data.metadata }
|
|
203
|
+
if (input.locale)
|
|
204
|
+
metadata.locale = input.locale
|
|
205
|
+
if (input.theme)
|
|
206
|
+
metadata.theme = input.theme
|
|
207
|
+
const updated = await iam.user.updateCurrentUser(token, { displayName: input.displayName, email: input.email, metadata })
|
|
208
|
+
if (!updated.success)
|
|
209
|
+
return updated
|
|
210
|
+
await recordAudit(context, 'update', 'profile', current.data.id, { fields: Object.keys(input) })
|
|
211
|
+
return getAdminUser(current.data.id)
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/** 上传路径由服务端生成,禁止客户端写入任意 storage key。 */
|
|
215
|
+
export async function uploadAvatar(userId: string, input: AvatarUpload, context: ServContext): Promise<HaiResult<{ avatarUrl: string }>> {
|
|
216
|
+
const current = await iam.user.getUser(userId)
|
|
217
|
+
if (!current.success)
|
|
218
|
+
return current
|
|
219
|
+
if (!current.data)
|
|
220
|
+
return err(HaiCommonError.NOT_FOUND, serverM('notFound', { locale: context.locale }))
|
|
221
|
+
const bytes = Buffer.from(input.base64, 'base64')
|
|
222
|
+
if (bytes.length === 0 || bytes.length > 512 * 1024 || bytes.toString('base64') !== input.base64 || !matchesImage(bytes, input.contentType))
|
|
223
|
+
return err(HaiCommonError.VALIDATION_ERROR, serverM('invalidAvatar', { locale: context.locale }))
|
|
224
|
+
const extension = { 'image/png': 'png', 'image/jpeg': 'jpg', 'image/webp': 'webp' }[input.contentType]
|
|
225
|
+
const key = `avatars/${userId}/${core.id.uuid()}.${extension}`
|
|
226
|
+
const stored = await storage.file.put(key, bytes, { contentType: input.contentType })
|
|
227
|
+
if (!stored.success)
|
|
228
|
+
return stored
|
|
229
|
+
const apiPrefix = core.config.getOrThrow<ServConfig>('serv').http.apiPrefix
|
|
230
|
+
// 只保存生成资源路径,公开 Origin 由客户端已配置的 API 地址决定,禁止信任 Host。
|
|
231
|
+
const avatarUrl = `${apiPrefix}/assets/${key}`
|
|
232
|
+
const updated = await iam.user.updateUser(userId, { avatarUrl })
|
|
233
|
+
if (!updated.success) {
|
|
234
|
+
const cleaned = await storage.file.delete(key)
|
|
235
|
+
if (!cleaned.success)
|
|
236
|
+
core.logger.warn('Failed to clean up rejected avatar', { error: cleaned.error.code })
|
|
237
|
+
return updated
|
|
238
|
+
}
|
|
239
|
+
// 更新成功后删除旧头像;只清理此用户且符合服务端生成规则的资源。
|
|
240
|
+
const oldKey = current.data.avatarUrl?.split('/assets/')[1]
|
|
241
|
+
if (oldKey && oldKey.startsWith(`avatars/${userId}/`) && /^avatars\/[\w-]+\/[\w-]+\.(?:png|jpg|webp)$/.test(oldKey)) {
|
|
242
|
+
const cleaned = await storage.file.delete(oldKey)
|
|
243
|
+
if (!cleaned.success)
|
|
244
|
+
core.logger.warn('Failed to clean up previous avatar', { error: cleaned.error.code })
|
|
245
|
+
}
|
|
246
|
+
await recordAudit(context, 'update', 'avatars', userId)
|
|
247
|
+
return ok({ avatarUrl })
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function matchesImage(bytes: Buffer, contentType: AvatarUpload['contentType']): boolean {
|
|
251
|
+
if (contentType === 'image/png') {
|
|
252
|
+
if (bytes.length < 33 || bytes.subarray(0, 8).toString('hex') !== '89504e470d0a1a0a' || bytes.toString('ascii', 12, 16) !== 'IHDR')
|
|
253
|
+
return false
|
|
254
|
+
const width = bytes.readUInt32BE(16)
|
|
255
|
+
const height = bytes.readUInt32BE(20)
|
|
256
|
+
return width > 0 && height > 0 && width <= 4096 && height <= 4096
|
|
257
|
+
}
|
|
258
|
+
if (contentType === 'image/jpeg')
|
|
259
|
+
return bytes.length >= 4 && bytes.subarray(0, 3).toString('hex') === 'ffd8ff' && bytes.subarray(-2).toString('hex') === 'ffd9'
|
|
260
|
+
return bytes.length >= 20 && bytes.toString('ascii', 0, 4) === 'RIFF' && bytes.toString('ascii', 8, 12) === 'WEBP' && bytes.readUInt32LE(4) + 8 === bytes.length
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/** 审计记录仅含操作字段与资源标识,不记录密码、token、头像内容或联系信息。 */
|
|
264
|
+
export async function recordAudit(context: ServContext, action: string, resource: string, resourceId?: string, details?: Record<string, unknown>): Promise<void> {
|
|
265
|
+
const result = await audit.log({
|
|
266
|
+
userId: context.session?.userId,
|
|
267
|
+
action,
|
|
268
|
+
resource,
|
|
269
|
+
resourceId,
|
|
270
|
+
details,
|
|
271
|
+
ipAddress: context.ip,
|
|
272
|
+
userAgent: context.userAgent,
|
|
273
|
+
})
|
|
274
|
+
if (!result.success)
|
|
275
|
+
core.logger.error('Failed to record audit event', { action, resource, error: result.error.code })
|
|
276
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { core } from '@h-ai/core'
|
|
2
|
+
|
|
3
|
+
/** 服务端业务提示与角色种子名称同步维护中英文。 */
|
|
4
|
+
export const serverM = core.i18n.createMessageGetter({
|
|
5
|
+
'zh-CN': {
|
|
6
|
+
admin: '管理员',
|
|
7
|
+
user: '普通用户',
|
|
8
|
+
adminDescription: '管理用户、角色和审计日志',
|
|
9
|
+
userDescription: '仅可维护个人设置',
|
|
10
|
+
notFound: '用户或角色不存在',
|
|
11
|
+
selfProtection: '不能删除、禁用或降级当前管理员账号',
|
|
12
|
+
invalidAvatar: '请选择不超过 512 KB 的有效 PNG、JPEG 或 WebP 图片',
|
|
13
|
+
busy: '系统繁忙,请稍后重试',
|
|
14
|
+
unsupportedPreference: '不支持此语言或主题',
|
|
15
|
+
invalidDateRange: '结束时间不能早于开始时间',
|
|
16
|
+
},
|
|
17
|
+
'en-US': {
|
|
18
|
+
admin: 'Administrator',
|
|
19
|
+
user: 'User',
|
|
20
|
+
adminDescription: 'Manage users, roles and audit logs',
|
|
21
|
+
userDescription: 'Manage personal settings only',
|
|
22
|
+
notFound: 'User or role does not exist',
|
|
23
|
+
selfProtection: 'You cannot delete, disable or demote your own administrator account',
|
|
24
|
+
invalidAvatar: 'Choose a valid PNG, JPEG or WebP image up to 512 KB',
|
|
25
|
+
busy: 'The system is busy. Please try again',
|
|
26
|
+
unsupportedPreference: 'This language or theme is not supported',
|
|
27
|
+
invalidDateRange: 'End date must not precede start date',
|
|
28
|
+
},
|
|
29
|
+
})
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import type { ServContext } from '@h-ai/serv'
|
|
2
|
+
import { audit } from '@h-ai/audit'
|
|
3
|
+
import { err, HaiCommonError, ok } from '@h-ai/core'
|
|
4
|
+
import { iam } from '@h-ai/iam'
|
|
5
|
+
import { serv } from '@h-ai/serv'
|
|
6
|
+
import { createIamProcedures } from '@h-ai/serv/features/iam'
|
|
7
|
+
import { adminContract, authContract, profileContract } from '{{fullstack.contractPackageName}}'
|
|
8
|
+
import {
|
|
9
|
+
createAdminUser,
|
|
10
|
+
getAdminRoles,
|
|
11
|
+
isRoleCode,
|
|
12
|
+
recordAudit,
|
|
13
|
+
toAdminUser,
|
|
14
|
+
updateAdminUser,
|
|
15
|
+
updateProfile,
|
|
16
|
+
uploadAvatar,
|
|
17
|
+
withAdminMutation,
|
|
18
|
+
} from '../admin-service.js'
|
|
19
|
+
import { serverM } from '../messages.js'
|
|
20
|
+
|
|
21
|
+
/** 身份认证沿用框架公共契约与 refresh-cookie 传输。 */
|
|
22
|
+
export function createAuthProcedures() {
|
|
23
|
+
const { currentUser, refresh } = createIamProcedures({ iam }).auth
|
|
24
|
+
const { login, logout, changePassword } = authContract
|
|
25
|
+
// 仅覆写需要业务审计的认证操作,其余直接复用框架;未选认证入口不挂载。
|
|
26
|
+
const audited = serv.implement({ login, logout, changePassword }).context<ServContext>().route('login', async ({ input, context }) => {
|
|
27
|
+
const result = await iam.auth.login(input)
|
|
28
|
+
if (!result.success)
|
|
29
|
+
return result
|
|
30
|
+
await recordAudit({ ...context, session: { userId: result.data.user.id, roles: result.data.roles, permissions: result.data.permissions } }, 'login', 'auth')
|
|
31
|
+
return result
|
|
32
|
+
}).route('logout').auth().handle(async ({ context }) => {
|
|
33
|
+
const result = await iam.auth.logout(context.accessToken ?? '')
|
|
34
|
+
if (!result.success)
|
|
35
|
+
return result
|
|
36
|
+
await recordAudit(context, 'logout', 'auth')
|
|
37
|
+
return result
|
|
38
|
+
}).route('changePassword').auth().handle(async ({ input, context }) => {
|
|
39
|
+
const result = await iam.user.changeCurrentUserPassword(context.accessToken ?? '', input.oldPassword, input.newPassword)
|
|
40
|
+
if (!result.success)
|
|
41
|
+
return result
|
|
42
|
+
await recordAudit(context, 'password-change', 'profile', context.session.userId)
|
|
43
|
+
return result
|
|
44
|
+
}).build()
|
|
45
|
+
return { ...audited, currentUser, refresh }
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function createAdminProcedures() {
|
|
49
|
+
return serv.implement(adminContract).context<ServContext>().route('users.list').role('admin').handle(async ({ input }) => {
|
|
50
|
+
const users = await iam.user.listUsers(input)
|
|
51
|
+
if (!users.success)
|
|
52
|
+
return users
|
|
53
|
+
const roles = await iam.authz.getUserRolesForMany(users.data.items.map(user => user.id))
|
|
54
|
+
if (!roles.success)
|
|
55
|
+
return roles
|
|
56
|
+
return ok({ ...users.data, items: users.data.items.map(user => toAdminUser(user, roles.data.get(user.id) ?? [])) })
|
|
57
|
+
}).route('users.create').role('admin').handle(({ input, context }) => withAdminMutation(context, () => createAdminUser(input, context))).route('users.update').role('admin').handle(({ input, context }) => withAdminMutation(context, () => updateAdminUser(input, context))).route('users.delete').role('admin').handle(({ input, context }) => withAdminMutation(context, async () => {
|
|
58
|
+
if (context.session.userId === input.id)
|
|
59
|
+
return err(HaiCommonError.FORBIDDEN, serverM('selfProtection', { locale: context.locale }))
|
|
60
|
+
const result = await iam.user.deleteUser(input.id)
|
|
61
|
+
if (!result.success)
|
|
62
|
+
return result
|
|
63
|
+
await recordAudit(context, 'delete', 'users', input.id)
|
|
64
|
+
return ok({ deleted: true as const })
|
|
65
|
+
})).route('users.avatar').role('admin').handle(({ input, context }) => withAdminMutation(context, () => uploadAvatar(input.id, input, context))).route('roles.list').role('admin').handle(() => getAdminRoles()).route('roles.update').role('admin').handle(({ input, context }) => withAdminMutation(context, async () => {
|
|
66
|
+
const role = await iam.authz.getRole(input.id)
|
|
67
|
+
if (!role.success)
|
|
68
|
+
return role
|
|
69
|
+
if (!role.data || !isRoleCode(role.data.code))
|
|
70
|
+
return err(HaiCommonError.NOT_FOUND, serverM('notFound', { locale: context.locale }))
|
|
71
|
+
const updated = await iam.authz.updateRole(input.id, { description: input.description })
|
|
72
|
+
if (!updated.success)
|
|
73
|
+
return updated
|
|
74
|
+
const roles = await getAdminRoles()
|
|
75
|
+
if (!roles.success)
|
|
76
|
+
return roles
|
|
77
|
+
const result = roles.data.items.find(item => item.id === input.id)
|
|
78
|
+
if (!result)
|
|
79
|
+
return err(HaiCommonError.NOT_FOUND, serverM('notFound', { locale: context.locale }))
|
|
80
|
+
await recordAudit(context, 'update', 'roles', input.id, { fields: ['description'] })
|
|
81
|
+
return ok(result)
|
|
82
|
+
})).route('audit.list').role('admin').handle(async ({ input, context }) => {
|
|
83
|
+
if (input.startDate && input.endDate && input.startDate > input.endDate)
|
|
84
|
+
return err(HaiCommonError.VALIDATION_ERROR, serverM('invalidDateRange', { locale: context.locale }))
|
|
85
|
+
const result = await audit.list({ ...input, startDate: input.startDate ? new Date(input.startDate) : undefined, endDate: input.endDate ? new Date(input.endDate) : undefined })
|
|
86
|
+
if (!result.success)
|
|
87
|
+
return result
|
|
88
|
+
return ok({ ...result.data, page: input.page, pageSize: input.pageSize, items: result.data.items.map(item => ({ ...item, createdAt: new Date(item.createdAt).toISOString() })) })
|
|
89
|
+
}).build()
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function createProfileProcedures() {
|
|
93
|
+
return serv.implement(profileContract).context<ServContext>().route('update').auth().handle(({ input, context }) => updateProfile(input, context)).route('avatar').auth().handle(({ input, context }) => uploadAvatar(context.session.userId, input, context)).build()
|
|
94
|
+
}
|
|
@@ -7,6 +7,9 @@ import { appContract } from '{{fullstack.contractPackageName}}'
|
|
|
7
7
|
export interface CreateAppProceduresOptions {
|
|
8
8
|
readonly name: string
|
|
9
9
|
readonly version: string
|
|
10
|
+
readonly env: string
|
|
11
|
+
readonly defaultLocale: string
|
|
12
|
+
readonly supportedLocales: readonly string[]
|
|
10
13
|
readonly startedAt: string
|
|
11
14
|
readonly frontends: readonly AppFrontendTarget[]
|
|
12
15
|
}
|
|
@@ -32,6 +35,9 @@ function createAppInfo(options: CreateAppProceduresOptions): AppInfoOutputData {
|
|
|
32
35
|
return {
|
|
33
36
|
name: options.name,
|
|
34
37
|
version: options.version,
|
|
38
|
+
env: options.env,
|
|
39
|
+
defaultLocale: options.defaultLocale,
|
|
40
|
+
supportedLocales: [...options.supportedLocales],
|
|
35
41
|
startedAt: options.startedAt,
|
|
36
42
|
frontends: [...options.frontends],
|
|
37
43
|
}
|
package/templates/apps/fullstack/packages/{{projectName}}-serv/src/server/procedures/index.ts.hbs
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { CreateAppProceduresOptions } from './app-procedures.js'
|
|
2
|
+
import { createAdminProcedures, createAuthProcedures, createProfileProcedures } from './admin-procedures.js'
|
|
2
3
|
import { createAppProcedures } from './app-procedures.js'
|
|
3
4
|
|
|
4
5
|
export interface CreateServerProceduresOptions {
|
|
@@ -9,5 +10,8 @@ export interface CreateServerProceduresOptions {
|
|
|
9
10
|
export function createServerProcedures(options: CreateServerProceduresOptions) {
|
|
10
11
|
return {
|
|
11
12
|
app: createAppProcedures(options.app),
|
|
13
|
+
iam: { auth: createAuthProcedures() },
|
|
14
|
+
admin: createAdminProcedures(),
|
|
15
|
+
profile: createProfileProcedures(),
|
|
12
16
|
}
|
|
13
|
-
}
|
|
17
|
+
}
|