@h-ai/cli 0.1.0-alpha.42 → 0.1.0-alpha.43

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 (31) hide show
  1. package/package.json +2 -2
  2. package/templates/apps/api/apps/{{projectName}}-contract/README.md.hbs +4 -2
  3. package/templates/apps/api/apps/{{projectName}}-contract/src/app-contract.ts.hbs +3 -3
  4. package/templates/apps/api/apps/{{projectName}}-contract/src/app-schemas.ts.hbs +0 -7
  5. package/templates/apps/api/apps/{{projectName}}-service/config/_serv.yml.hbs +18 -1
  6. package/templates/apps/api/apps/{{projectName}}-service/src/app.ts.hbs +20 -34
  7. package/templates/apps/api/apps/{{projectName}}-service/tests/service.test.ts.hbs +7 -2
  8. package/templates/apps/api/e2e/api-service.spec.ts.hbs +5 -1
  9. package/templates/apps/api/package.json.hbs +4 -0
  10. package/templates/apps/fullstack/e2e/fullstack.spec.ts.hbs +5 -3
  11. package/templates/apps/fullstack/package.json.hbs +1 -0
  12. package/templates/apps/fullstack/packages/{{projectName}}-contract/README.md.hbs +6 -4
  13. package/templates/apps/fullstack/packages/{{projectName}}-contract/src/app-contract.ts.hbs +6 -7
  14. package/templates/apps/fullstack/packages/{{projectName}}-contract/src/app-schemas.ts.hbs +3 -6
  15. package/templates/apps/fullstack/packages/{{projectName}}-contract/src/index.ts.hbs +0 -1
  16. package/templates/apps/fullstack/packages/{{projectName}}-contract/src/{{projectName}}-contract.ts.hbs +4 -1
  17. package/templates/apps/fullstack/packages/{{projectName}}-contract/tests/contract.test.ts.hbs +2 -17
  18. package/templates/apps/fullstack/packages/{{projectName}}-serv/config/_serv.yml.hbs +23 -5
  19. package/templates/apps/fullstack/packages/{{projectName}}-serv/src/app-server.ts.hbs +17 -19
  20. package/templates/apps/fullstack/packages/{{projectName}}-serv/tests/app-server.test.ts.hbs +8 -5
  21. package/templates/apps/fullstack/packages/{{projectName}}-shared/src/lib/api/api-client.ts.hbs +3 -3
  22. package/templates/apps/mobile-app/src/lib/api.ts.hbs +1 -2
  23. package/templates/base/e2e/app.spec.ts.hbs +4 -1
  24. package/templates/base/package.json.hbs +1 -1
  25. package/templates/skills/hai-ai/SKILL.md +5 -1
  26. package/templates/skills/hai-ai/reference.md +2 -2
  27. package/templates/skills/hai-api-contract/SKILL.md +34 -8
  28. package/templates/skills/hai-capacitor/SKILL.md +0 -2
  29. package/templates/skills/hai-core/SKILL.md +1 -0
  30. package/templates/skills/hai-serv/SKILL.md +35 -0
  31. package/templates/apps/fullstack/packages/{{projectName}}-contract/src/app-routes.ts.hbs +0 -27
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@h-ai/cli",
3
3
  "type": "module",
4
- "version": "0.1.0-alpha.42",
4
+ "version": "0.1.0-alpha.43",
5
5
  "description": "Hai Framework CLI for project scaffolding and code generation.",
6
6
  "license": "Apache-2.0",
7
7
  "keywords": [
@@ -34,7 +34,7 @@
34
34
  "ora": "^9.1.0",
35
35
  "prompts": "^2.4.2",
36
36
  "yaml": "^2.7.0",
37
- "@h-ai/core": "0.1.0-alpha.42"
37
+ "@h-ai/core": "0.1.0-alpha.43"
38
38
  },
39
39
  "devDependencies": {
40
40
  "@types/fs-extra": "^11.0.4",
@@ -4,10 +4,12 @@
4
4
 
5
5
  ## 包含内容
6
6
 
7
- - `app-contract.ts`:应用级路由定义
8
- - `app-schemas.ts`:输入输出 Zod schema
7
+ - `app-contract.ts`:应用级接口定义,业务 path 与一次性输出 Schema 就近内联
8
+ - `app-schemas.ts`:跨接口、跨层复用的输入/数据 Zod Schema
9
9
  - `{{projectName}}-contract.ts`:工程级 contract 聚合入口
10
10
 
11
+ API 前缀、健康检查与跨端 Header 等传输配置位于 service 的 `config/_serv.yml`。
12
+
11
13
  ## 常用命令
12
14
 
13
15
  ```bash
@@ -1,5 +1,5 @@
1
1
  import { apiContract } from '@h-ai/api-contract'
2
- import { AppEchoInputSchema, AppEchoOutputSchema, AppInfoOutputSchema } from './app-schemas.js'
2
+ import { AppEchoInputSchema, AppEchoOutputDataSchema, AppInfoOutputDataSchema } from './app-schemas.js'
3
3
 
4
4
  /** 应用自有 app contract。 */
5
5
  export const appContract = {
@@ -11,7 +11,7 @@ export const appContract = {
11
11
  summary: 'Get service info',
12
12
  tags: ['app'],
13
13
  })
14
- .output(AppInfoOutputSchema),
14
+ .output(apiContract.haiResultSchema(AppInfoOutputDataSchema)),
15
15
  echo: apiContract
16
16
  .route({
17
17
  method: 'POST',
@@ -21,7 +21,7 @@ export const appContract = {
21
21
  tags: ['app'],
22
22
  })
23
23
  .input(AppEchoInputSchema)
24
- .output(AppEchoOutputSchema),
24
+ .output(apiContract.haiResultSchema(AppEchoOutputDataSchema)),
25
25
  }
26
26
 
27
27
  export type AppContract = typeof appContract
@@ -1,4 +1,3 @@
1
- import { apiContract } from '@h-ai/api-contract'
2
1
  import { z } from 'zod'
3
2
 
4
3
  /** 服务元信息响应数据。 */
@@ -9,9 +8,6 @@ export const AppInfoOutputDataSchema = z.object({
9
8
  transportEnabled: z.boolean(),
10
9
  })
11
10
 
12
- /** app.info 标准 HaiResult 输出。 */
13
- export const AppInfoOutputSchema = apiContract.haiResultSchema(AppInfoOutputDataSchema)
14
-
15
11
  /** 输入:单条文本消息(演示用,限制最大长度防止误用)。 */
16
12
  export const AppEchoInputSchema = z.object({
17
13
  message: z.string().min(1).max(2000),
@@ -25,9 +21,6 @@ export const AppEchoOutputDataSchema = z.object({
25
21
  userId: z.string().optional(),
26
22
  })
27
23
 
28
- /** app.echo 标准 HaiResult 输出。 */
29
- export const AppEchoOutputSchema = apiContract.haiResultSchema(AppEchoOutputDataSchema)
30
-
31
24
  export type AppInfoOutputData = z.infer<typeof AppInfoOutputDataSchema>
32
25
  export type AppEchoInput = z.infer<typeof AppEchoInputSchema>
33
26
  export type AppEchoOutputData = z.infer<typeof AppEchoOutputDataSchema>
@@ -13,6 +13,23 @@ http:
13
13
  readyPath: /ready
14
14
  rpc: false
15
15
 
16
+ cors:
17
+ origin: ${HAI_CORS_ORIGIN:*}
18
+ nativeOrigins: ${HAI_NATIVE_ORIGINS:http://localhost,https://tauri.localhost,tauri://localhost,capacitor://localhost}
19
+ {{#if features.iam}} credentials: true
20
+ {{else}} credentials: false
21
+ {{/if}} allowedHeaders:
22
+ - Authorization
23
+ - Content-Type
24
+ {{#if features.crypto}} - X-Client-Id
25
+ - X-Encrypted
26
+ {{/if}} - X-Hai-Locale
27
+ - X-Request-Id
28
+ - X-Requested-With
29
+ exposedHeaders:
30
+ {{#if features.crypto}} - X-Encrypted
31
+ {{/if}} - X-Request-Id
32
+
16
33
  {{#if features.crypto}}transport:
17
34
  keyExchangePath: /_hai/key-exchange
18
35
  excludePaths:
@@ -23,4 +40,4 @@ http:
23
40
  - /_hai/scalar.js
24
41
  maxClients: 10000
25
42
  {{else}}transport: false
26
- {{/if}}
43
+ {{/if}}
@@ -1,6 +1,6 @@
1
1
  import type { ServConfig } from '@h-ai/serv'
2
2
  import { core } from '@h-ai/core'
3
- {{#if features.crypto}}import { crypto, TRANSPORT_PROTOCOL } from '@h-ai/crypto'
3
+ {{#if features.crypto}}import { crypto } from '@h-ai/crypto'
4
4
  {{/if}}{{#if features.iam}}import { iam } from '@h-ai/iam'
5
5
  {{/if}}import { serv } from '@h-ai/serv'
6
6
  import { {{api.contractExportName}} } from '{{api.contractPackageName}}'
@@ -14,26 +14,18 @@ interface CreateApiServiceAppOptions {
14
14
  refreshCookie?: 'enabled' | 'disabled'
15
15
  }
16
16
 
17
- const LOOPBACK_ORIGIN_RE = /^https?:\/\/(?:localhost|127\.0\.0\.1|\[::1\])(?::\d+)?$/
18
- const WEBVIEW_ORIGINS = new Set([
19
- 'capacitor://localhost',
20
- 'http://localhost',
21
- 'https://tauri.localhost',
22
- 'tauri://localhost',
23
- ])
24
- const DEFAULT_CORS_ALLOWED_HEADERS = [
25
- 'Authorization',
26
- 'Content-Type',
27
- {{#if features.crypto}} TRANSPORT_PROTOCOL.CLIENT_ID_HEADER,
28
- TRANSPORT_PROTOCOL.ENCRYPTED_HEADER,
29
- {{/if}} 'X-Hai-Locale',
30
- 'X-Request-Id',
31
- 'X-Requested-With',
32
- ]
33
-
17
+ interface AppCoreConfig {
18
+ readonly env?: string
19
+ }
34
20
  /** 创建 API Service HTTP 应用。 */
35
21
  export function {{api.serviceAppFactoryName}}(_options: CreateApiServiceAppOptions = {}) {
36
22
  const servConfig = core.config.getOrThrow<ServConfig>('serv')
23
+ const coreConfig = core.config.get<AppCoreConfig>('core')
24
+ const security = serv.createRuntimeSecurityPolicy({
25
+ environment: coreConfig?.env,
26
+ corsOrigin: servConfig.cors.origin,
27
+ nativeOrigins: servConfig.cors.nativeOrigins,
28
+ })
37
29
  {{#if features.crypto}} const transportMode = _options.transport ?? 'config'
38
30
  const transport = transportMode === 'disabled' || servConfig.transport === false
39
31
  ? undefined
@@ -46,7 +38,7 @@ export function {{api.serviceAppFactoryName}}(_options: CreateApiServiceAppOptio
46
38
  {{else}} const transport = undefined
47
39
  {{/if}}
48
40
  {{#if features.iam}}
49
- const refreshCookie = _options.refreshCookie === 'disabled' ? undefined : {}
41
+ const refreshCookie = _options.refreshCookie === 'disabled' ? undefined : { secure: security.secureRefreshCookie }
50
42
  {{/if}}
51
43
 
52
44
  const appOptions: Parameters<typeof serv.createApp>[0] = {
@@ -61,17 +53,21 @@ export function {{api.serviceAppFactoryName}}(_options: CreateApiServiceAppOptio
61
53
  startedAt,
62
54
  },
63
55
  }),
64
- http: servConfig.http,
56
+ http: {
57
+ ...servConfig.http,
58
+ openapi: security.exposeApiDocs ? servConfig.http.openapi : false,
59
+ docs: security.exposeApiDocs ? servConfig.http.docs : false,
60
+ },
65
61
  {{#if features.iam}}
66
62
  iam,
67
63
  {{/if}}
68
64
  middlewares: [
69
65
  {
70
66
  middleware: serv.cors({
71
- origin: origin => resolveCorsOrigin(origin) !== null,
72
- {{#if features.iam}} credentials: true,
73
- {{/if}} allowedHeaders: DEFAULT_CORS_ALLOWED_HEADERS,{{#if features.crypto}}
74
- exposedHeaders: [TRANSPORT_PROTOCOL.ENCRYPTED_HEADER, 'X-Request-Id'],{{/if}}
67
+ origin: security.allowOrigin,
68
+ credentials: servConfig.cors.credentials,
69
+ allowedHeaders: [...servConfig.cors.allowedHeaders],
70
+ exposedHeaders: [...servConfig.cors.exposedHeaders],
75
71
  }),
76
72
  },
77
73
  ],
@@ -86,13 +82,3 @@ export function {{api.serviceAppFactoryName}}(_options: CreateApiServiceAppOptio
86
82
 
87
83
  return serv.createApp(appOptions)
88
84
  }
89
-
90
- function resolveCorsOrigin(origin: string | undefined): string | null {
91
- if (!origin)
92
- return null
93
- if (WEBVIEW_ORIGINS.has(origin))
94
- return origin
95
- if (LOOPBACK_ORIGIN_RE.test(origin))
96
- return origin
97
- return null
98
- }
@@ -1,3 +1,4 @@
1
+ import type { ServConfig } from '@h-ai/serv'
1
2
  import type { AddressInfo } from 'node:net'
2
3
  import { mkdtempSync, rmSync } from 'node:fs'
3
4
  import { tmpdir } from 'node:os'
@@ -20,6 +21,7 @@ let tempRoot = ''
20
21
  let baseUrl = ''
21
22
  let client: ReturnType<typeof apiClient.create<typeof {{api.contractExportName}}>>
22
23
  let server: ReturnType<typeof serv.listen>
24
+ let servConfig: ServConfig
23
25
  {{#if features.storage}}let storageRoot = ''
24
26
  {{/if}}{{#if features.vecdb}}let vecdbRoot = ''
25
27
  {{/if}}
@@ -45,6 +47,7 @@ beforeAll(async () => {
45
47
  configDir: './config',
46
48
  logging: { level: 'warn' },
47
49
  })
50
+ servConfig = core.config.getOrThrow<ServConfig>('serv')
48
51
  {{#if features.db}}
49
52
 
50
53
  const dbResult = await reldb.init({ type: 'sqlite', database: ':memory:' })
@@ -93,7 +96,7 @@ beforeAll(async () => {
93
96
 
94
97
  client = apiClient.create({{api.contractExportName}})
95
98
  const initResult = await client.init({
96
- baseUrl: `${baseUrl}/api/v1`,
99
+ baseUrl: `${baseUrl}${servConfig.http.apiPrefix}`,
97
100
  auth: { storage: apiClient.tokenStorage.memory() },
98
101
  })
99
102
  if (!initResult.success)
@@ -114,7 +117,9 @@ afterAll(async () => {
114
117
 
115
118
  describe('{{api.servicePackageName}}', () => {
116
119
  it('returns health status', async () => {
117
- const response = await fetch(`${baseUrl}/health`)
120
+ if (servConfig.http.health === false)
121
+ throw new Error('service test requires health endpoint')
122
+ const response = await fetch(`${baseUrl}${servConfig.http.health.path}`)
118
123
 
119
124
  expect(response.status).toBe(200)
120
125
  await expect(response.json()).resolves.toEqual({ status: 'ok' })
@@ -1,4 +1,8 @@
1
1
  import { expect, test } from '@playwright/test'
2
+ import { apiContract } from '@h-ai/api-contract'
3
+ import { {{api.contractExportName}} } from '{{api.contractPackageName}}'
4
+
5
+ const API_PREFIX = '/api/v1'
2
6
 
3
7
  test('service health endpoint returns ok', async ({ request }) => {
4
8
  const response = await request.get('/health')
@@ -8,7 +12,7 @@ test('service health endpoint returns ok', async ({ request }) => {
8
12
  })
9
13
 
10
14
  test('service app.info endpoint returns HaiResult', async ({ request }) => {
11
- const response = await request.post('/api/v1/app/info')
15
+ const response = await request.post(`${API_PREFIX}${apiContract.pathOf({{api.contractExportName}}.app.info)}`)
12
16
  const body = await response.json()
13
17
 
14
18
  expect(response.status()).toBe(200)
@@ -15,6 +15,10 @@
15
15
  "test:e2e": "pnpm build && playwright test",
16
16
  "clean": "pnpm -r --if-present clean && rimraf node_modules .turbo"
17
17
  },
18
+ "dependencies": {
19
+ "@h-ai/api-contract": "{{haiPackageSpecifier}}",
20
+ "{{api.contractPackageName}}": "workspace:*"
21
+ },
18
22
  "devDependencies": {
19
23
  "@antfu/eslint-config": "{{api.packageSpecifiers.antfuEslintConfig}}",
20
24
  "@playwright/test": "{{api.packageSpecifiers.playwright}}",
@@ -1,17 +1,19 @@
1
1
  import { expect, test } from '@playwright/test'
2
- import { APP_API_ROUTES } from '{{fullstack.contractPackageName}}'
2
+ import { apiContract } from '@h-ai/api-contract'
3
+ import { {{fullstack.contractExportName}} } from '{{fullstack.contractPackageName}}'
3
4
 
4
5
  const DEFAULT_SERVICE_BASE_URL = 'http://127.0.0.1:3000'
5
6
  const apiBaseUrl = process.env.SERVICE_BASE_URL || DEFAULT_SERVICE_BASE_URL
7
+ const apiPrefix = '/api/v1'
6
8
 
7
9
  test('service health endpoint is ready', async ({ request }) => {
8
- const response = await request.get(apiBaseUrl + APP_API_ROUTES.health)
10
+ const response = await request.get(`${apiBaseUrl}/health`)
9
11
  expect(response.ok()).toBe(true)
10
12
  await expect(response).toBeOK()
11
13
  })
12
14
 
13
15
  test('service echo endpoint returns HaiResult', async ({ request }) => {
14
- const response = await request.post(apiBaseUrl + APP_API_ROUTES.echo, {
16
+ const response = await request.post(`${apiBaseUrl}${apiPrefix}${apiContract.pathOf({{fullstack.contractExportName}}.app.echo)}`, {
15
17
  data: { message: 'hello e2e' },
16
18
  })
17
19
  expect(response.ok()).toBe(true)
@@ -20,6 +20,7 @@
20
20
  "clean": "pnpm -r --if-present clean && rimraf node_modules .turbo"
21
21
  },
22
22
  "dependencies": {
23
+ "@h-ai/api-contract": "{{haiPackageSpecifier}}",
23
24
  "{{fullstack.contractPackageName}}": "workspace:*",
24
25
  "{{fullstack.sharedPackageName}}": "workspace:*"
25
26
  },
@@ -1,14 +1,16 @@
1
1
  # {{fullstack.contractPackageName}}
2
2
 
3
- 共享 API 契约包,包含前后端共用的路径常量、Zod Schema、`apiContract.route()` 路由定义与工程级 contract。
3
+ 共享 API 契约包,包含 Zod Schema、`apiContract.route()` 业务接口定义与工程级 contract。
4
4
 
5
5
  ## 骨架说明
6
6
 
7
- - `src/app-routes.ts`:HTTP 路径常量,供 service、frontend 与 E2E 复用。
8
- - `src/app-schemas.ts`:输入输出 Schema 与类型。
9
- - `src/app-contract.ts`:app 领域 contract 路由定义。
7
+ - `src/app-schemas.ts`:跨接口、跨层复用的输入/数据 Schema 与类型。
8
+ - `src/app-contract.ts`:app 领域 contract;每个业务 path 直接写在对应接口定义中。
10
9
  - `src/{{projectName}}-contract.ts`:工程级 contract,供 `@h-ai/serv` 与 `@h-ai/api-client` 共用。
11
10
 
11
+ 只使用一次的输出 Schema 直接内联到 `.output(...)`;同一 contract 文件内重复使用时才保留私有常量。
12
+ API 前缀、健康检查与跨端 Header 等传输配置位于 serv 包的 `config/_serv.yml`。
13
+
12
14
  ## 开发
13
15
 
14
16
  - `pnpm --filter {{fullstack.contractPackageName}} typecheck`
@@ -1,21 +1,20 @@
1
1
  import { apiContract } from '@h-ai/api-contract'
2
- import { APP_CONTRACT_ROUTES } from './app-routes.js'
3
- import { AppEchoInputSchema, AppEchoOutputSchema, AppInfoOutputSchema } from './app-schemas.js'
2
+ import { AppEchoInputSchema, AppEchoOutputDataSchema, AppInfoOutputDataSchema } from './app-schemas.js'
4
3
 
5
4
  /** app 领域 API contract:路径、方法、输入输出 Schema 的单一来源。 */
6
5
  export const appContract = {
7
6
  info: apiContract.route({
8
7
  method: 'POST',
9
- path: APP_CONTRACT_ROUTES.info,
8
+ path: '/app/info',
10
9
  operationId: 'app.info',
11
10
  tags: ['app'],
12
11
  summary: 'Get application runtime information',
13
- }).output(AppInfoOutputSchema),
12
+ }).output(apiContract.haiResultSchema(AppInfoOutputDataSchema)),
14
13
  echo: apiContract.route({
15
14
  method: 'POST',
16
- path: APP_CONTRACT_ROUTES.echo,
15
+ path: '/app/echo',
17
16
  operationId: 'app.echo',
18
17
  tags: ['app'],
19
18
  summary: 'Echo a message through the generated service route',
20
- }).input(AppEchoInputSchema).output(AppEchoOutputSchema),
21
- }
19
+ }).input(AppEchoInputSchema).output(apiContract.haiResultSchema(AppEchoOutputDataSchema)),
20
+ }
@@ -1,4 +1,4 @@
1
- import { apiContract } from '@h-ai/api-contract'
1
+ import type { HaiResult } from '@h-ai/core'
2
2
  import { z } from 'zod'
3
3
 
4
4
  export const AppFrontendTargetSchema = z.enum(['web', 'app', 'miniapp', 'desktop'])
@@ -23,12 +23,9 @@ export const AppEchoOutputDataSchema = z.object({
23
23
  requestId: z.string(),
24
24
  })
25
25
 
26
- export const AppInfoOutputSchema = apiContract.haiResultSchema(AppInfoOutputDataSchema)
27
- export const AppEchoOutputSchema = apiContract.haiResultSchema(AppEchoOutputDataSchema)
28
-
29
26
  export type AppFrontendTarget = z.infer<typeof AppFrontendTargetSchema>
30
27
  export type AppInfoOutputData = z.infer<typeof AppInfoOutputDataSchema>
31
- export type AppInfoOutput = z.infer<typeof AppInfoOutputSchema>
28
+ export type AppInfoOutput = HaiResult<AppInfoOutputData>
32
29
  export type AppEchoInput = z.infer<typeof AppEchoInputSchema>
33
30
  export type AppEchoOutputData = z.infer<typeof AppEchoOutputDataSchema>
34
- export type AppEchoOutput = z.infer<typeof AppEchoOutputSchema>
31
+ export type AppEchoOutput = HaiResult<AppEchoOutputData>
@@ -1,4 +1,3 @@
1
1
  export * from './app-contract.js'
2
- export * from './app-routes.js'
3
2
  export * from './app-schemas.js'
4
3
  export * from './{{projectName}}-contract.js'
@@ -1,9 +1,12 @@
1
1
  import { apiContract } from '@h-ai/api-contract'
2
2
  import { appContract } from './app-contract.js'
3
3
 
4
+ /** 当前工程启用的前端目标。 */
5
+ export const ENABLED_FRONTENDS = {{{fullstack.selectedFrontendsJson}}} as const
6
+
4
7
  /** 工程级 API contract,前端 typed client 与后端 service 共用。 */
5
8
  export const {{fullstack.contractExportName}} = apiContract.create({
6
9
  app: appContract,
7
10
  })
8
11
 
9
- export type FullstackContract = typeof {{fullstack.contractExportName}}
12
+ export type FullstackContract = typeof {{fullstack.contractExportName}}
@@ -1,10 +1,9 @@
1
+ import { apiContract } from '@h-ai/api-contract'
1
2
  import { describe, expect, it } from 'vitest'
2
3
  import {
3
- APP_API_ROUTES,
4
4
  appContract,
5
5
  AppEchoInputSchema,
6
6
  AppInfoOutputDataSchema,
7
- AppInfoOutputSchema,
8
7
  ENABLED_FRONTENDS,
9
8
  {{fullstack.contractExportName}},
10
9
  } from '../src/index.js'
@@ -13,7 +12,7 @@ describe('{{fullstack.contractPackageName}}', () => {
13
12
  it('defines app contract routes', () => {
14
13
  expect({{fullstack.contractExportName}}.app).toBeDefined()
15
14
  expect(appContract.info).toBeDefined()
16
- expect(APP_API_ROUTES.info).toBe('/api/v1/app/info')
15
+ expect(apiContract.pathOf(appContract.info)).toBe('/app/info')
17
16
  })
18
17
 
19
18
  it('validates app info payload', () => {
@@ -31,18 +30,4 @@ describe('{{fullstack.contractPackageName}}', () => {
31
30
  expect(AppEchoInputSchema.safeParse({ message: 'hello' }).success).toBe(true)
32
31
  expect(AppEchoInputSchema.safeParse({ message: '' }).success).toBe(false)
33
32
  })
34
-
35
- it('validates HaiResult app info response', () => {
36
- const result = AppInfoOutputSchema.safeParse({
37
- success: true,
38
- data: {
39
- name: '{{fullstack.servPackageName}}',
40
- version: '0.1.0',
41
- startedAt: '2026-01-01T00:00:00.000Z',
42
- frontends: [...ENABLED_FRONTENDS],
43
- },
44
- })
45
-
46
- expect(result.success).toBe(true)
47
- })
48
33
  })
@@ -2,12 +2,30 @@
2
2
  # {{fullstack.servPackageName}} - Serv / HTTP 入口配置
3
3
  # =============================================================================
4
4
  #
5
- # HTTP 路由路径(apiPrefix / health / openapi / docs)由共享 contract
6
- # 统一定义,作为前后端唯一事实来源,因此不在此重复声明。
7
- # 本文件仅声明服务端运行时设置。
5
+ # 业务接口路径直接写在对应 *-contract.ts;公共 HTTP 传输配置以本文件为准。
8
6
  # =============================================================================
9
7
 
10
- # CORS 允许的来源。
11
- # 开发环境放开为 *,生产环境应收敛为前端域名(如 https://app.example.com)。
8
+ http:
9
+ apiPrefix: /api/v1
10
+ openapi:
11
+ path: /openapi.json
12
+ docs:
13
+ path: /docs
14
+ health:
15
+ path: /health
16
+ readyPath: /ready
17
+ rpc: false
18
+
12
19
  cors:
13
20
  origin: ${HAI_CORS_ORIGIN:*}
21
+ nativeOrigins: ${HAI_NATIVE_ORIGINS:http://localhost,https://tauri.localhost,tauri://localhost,capacitor://localhost}
22
+ credentials: false
23
+ allowedHeaders:
24
+ - Authorization
25
+ - Content-Type
26
+ - X-Request-Id
27
+ - X-Requested-With
28
+ exposedHeaders:
29
+ - X-Request-Id
30
+
31
+ transport: false
@@ -1,6 +1,7 @@
1
+ import type { ServConfigInput } from '@h-ai/serv'
1
2
  import { core } from '@h-ai/core'
2
- import { serv } from '@h-ai/serv'
3
- import { APP_HTTP_ROUTES, ENABLED_FRONTENDS, {{fullstack.contractExportName}} } from '{{fullstack.contractPackageName}}'
3
+ import { serv, ServConfigSchema } from '@h-ai/serv'
4
+ import { ENABLED_FRONTENDS, {{fullstack.contractExportName}} } from '{{fullstack.contractPackageName}}'
4
5
  import { createServerProcedures } from './server/procedures/index.js'
5
6
 
6
7
  const STARTED_AT = new Date().toISOString()
@@ -9,11 +10,7 @@ const STARTED_AT = new Date().toISOString()
9
10
  interface AppCoreConfig {
10
11
  name?: string
11
12
  version?: string
12
- }
13
-
14
- /** `_serv.yml` 中的服务端运行时设置。 */
15
- interface AppServConfig {
16
- cors?: { origin?: string }
13
+ env?: string
17
14
  }
18
15
 
19
16
  /** 应用侧最小 HTTP App 抽象;不把底层 Hono 类型暴露给项目代码。 */
@@ -27,10 +24,14 @@ export function createServerApp(): ServerApp {
27
24
  // 配置来自 config/_core.yml 与 config/_serv.yml。
28
25
  // 单元测试可不初始化 core,此时回退到内置默认值。
29
26
  const coreConfig = core.config.get<AppCoreConfig>('core')
30
- const servConfig = core.config.get<AppServConfig>('serv')
27
+ const servConfig = ServConfigSchema.parse(core.config.get<ServConfigInput>('serv') ?? {})
31
28
  const appName = coreConfig?.name ?? '{{fullstack.servPackageName}}'
32
29
  const appVersion = coreConfig?.version ?? '0.1.0'
33
- const corsOrigin = servConfig?.cors?.origin ?? '*'
30
+ const security = serv.createRuntimeSecurityPolicy({
31
+ environment: coreConfig?.env,
32
+ corsOrigin: servConfig.cors.origin,
33
+ nativeOrigins: servConfig.cors.nativeOrigins,
34
+ })
34
35
 
35
36
  return serv.createApp({
36
37
  contract: {{fullstack.contractExportName}},
@@ -43,19 +44,16 @@ export function createServerApp(): ServerApp {
43
44
  },
44
45
  }),
45
46
  http: {
46
- apiPrefix: APP_HTTP_ROUTES.apiPrefix,
47
- health: {
48
- path: APP_HTTP_ROUTES.health,
49
- readyPath: APP_HTTP_ROUTES.ready,
50
- },
51
- openapi: { path: APP_HTTP_ROUTES.openapi },
52
- docs: { path: APP_HTTP_ROUTES.docs },
53
- rpc: false,
47
+ ...servConfig.http,
48
+ openapi: security.exposeApiDocs ? servConfig.http.openapi : false,
49
+ docs: security.exposeApiDocs ? servConfig.http.docs : false,
54
50
  },
55
51
  middlewares: [{
56
52
  middleware: serv.cors({
57
- origin: corsOrigin,
58
- allowedHeaders: ['Authorization', 'Content-Type', 'X-Requested-With'],
53
+ origin: security.allowOrigin,
54
+ credentials: servConfig.cors.credentials,
55
+ allowedHeaders: [...servConfig.cors.allowedHeaders],
56
+ exposedHeaders: [...servConfig.cors.exposedHeaders],
59
57
  }),
60
58
  }],
61
59
  })
@@ -1,11 +1,14 @@
1
- import { APP_API_ROUTES } from '{{fullstack.contractPackageName}}'
1
+ import { apiContract } from '@h-ai/api-contract'
2
+ import { {{fullstack.contractExportName}} } from '{{fullstack.contractPackageName}}'
2
3
  import { describe, expect, it } from 'vitest'
3
4
  import { createServerApp } from '../src/app-server.js'
4
5
 
6
+ const API_PREFIX = '/api/v1'
7
+
5
8
  describe('{{fullstack.servPackageName}}', () => {
6
9
  it('returns health status', async () => {
7
10
  const app = createServerApp()
8
- const response = await app.request(APP_API_ROUTES.health)
11
+ const response = await app.request('/health')
9
12
 
10
13
  expect(response.status).toBe(200)
11
14
  await expect(response.json()).resolves.toEqual({ status: 'ok' })
@@ -13,7 +16,7 @@ describe('{{fullstack.servPackageName}}', () => {
13
16
 
14
17
  it('returns app info as HaiResult', async () => {
15
18
  const app = createServerApp()
16
- const response = await app.request(APP_API_ROUTES.info, { method: 'POST' })
19
+ const response = await app.request(`${API_PREFIX}${apiContract.pathOf({{fullstack.contractExportName}}.app.info)}`, { method: 'POST' })
17
20
  const body = await response.json()
18
21
 
19
22
  expect(response.status).toBe(200)
@@ -23,7 +26,7 @@ describe('{{fullstack.servPackageName}}', () => {
23
26
 
24
27
  it('validates echo input', async () => {
25
28
  const app = createServerApp()
26
- const response = await app.request(APP_API_ROUTES.echo, {
29
+ const response = await app.request(`${API_PREFIX}${apiContract.pathOf({{fullstack.contractExportName}}.app.echo)}`, {
27
30
  method: 'POST',
28
31
  headers: { 'content-type': 'application/json' },
29
32
  body: JSON.stringify({ message: '' }),
@@ -34,7 +37,7 @@ describe('{{fullstack.servPackageName}}', () => {
34
37
 
35
38
  it('returns echo result as HaiResult', async () => {
36
39
  const app = createServerApp()
37
- const response = await app.request(APP_API_ROUTES.echo, {
40
+ const response = await app.request(`${API_PREFIX}${apiContract.pathOf({{fullstack.contractExportName}}.app.echo)}`, {
38
41
  method: 'POST',
39
42
  headers: { 'content-type': 'application/json' },
40
43
  body: JSON.stringify({ message: 'hello unit' }),
@@ -1,6 +1,6 @@
1
1
  import type { AppInfoOutputData } from '{{fullstack.contractPackageName}}'
2
2
  import { apiClient } from '@h-ai/api-client'
3
- import { APP_HTTP_ROUTES, {{fullstack.contractExportName}} } from '{{fullstack.contractPackageName}}'
3
+ import { {{fullstack.contractExportName}} } from '{{fullstack.contractPackageName}}'
4
4
 
5
5
  export type AppInfo = AppInfoOutputData
6
6
 
@@ -43,9 +43,9 @@ function resolveDefaultApiOrigin(): string {
43
43
 
44
44
  function normalizeApiBaseUrl(value: string): string {
45
45
  const trimmed = trimTrailingSlash(value)
46
- return trimmed.endsWith(APP_HTTP_ROUTES.apiPrefix)
46
+ return trimmed.endsWith('/api/v1')
47
47
  ? trimmed
48
- : `${trimmed}${APP_HTTP_ROUTES.apiPrefix}`
48
+ : `${trimmed}/api/v1`
49
49
  }
50
50
 
51
51
  function trimTrailingSlash(value: string): string {
@@ -33,7 +33,6 @@ export async function initApi(): Promise<void> {
33
33
  baseUrl: resolveApiBase(),
34
34
  auth: {
35
35
  storage: await createTokenStorage(),
36
- refreshPath: '/auth/refresh',
37
36
  },
38
37
  })
39
38
 
@@ -51,4 +50,4 @@ export async function closeApi(): Promise<void> {
51
50
  initialized = false
52
51
  }
53
52
 
54
- export { apiClient }
53
+ export { apiClient }
@@ -2,6 +2,9 @@ import { expect, test } from '@playwright/test'
2
2
 
3
3
  /** 首页从真实浏览器页面发起,覆盖基础渲染链路。 */
4
4
  test('home page renders from browser', async ({ page }) => {
5
+ const pageErrors: string[] = []
6
+ page.on('pageerror', error => pageErrors.push(error.message))
5
7
  await page.goto('/')
8
+ await expect(pageErrors).toEqual([])
6
9
  await expect(page.locator('main, section').first()).toBeVisible()
7
- })
10
+ })
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "{{projectName}}",
3
- "version": "0.1.0-alpha.42",
3
+ "version": "0.1.0-alpha.43",
4
4
  "private": true,
5
5
  "description": "{{#if_eq appType 'admin'}}Enterprise management system built with hai Agent Framework{{/if_eq}}{{#if_eq appType 'website'}}Enterprise website built with hai Agent Framework{{/if_eq}}{{#if_eq appType 'h5'}}Mobile H5 app built with hai Agent Framework{{/if_eq}}{{#if_eq appType 'api'}}REST API service built with hai Agent Framework{{/if_eq}}{{#if_eq appType 'mobile-app'}}Mobile app built with hai Agent Framework{{/if_eq}}",
6
6
  "keywords": ["hai", {{#unless isSvelteOnlyApp}}"sveltekit", {{/unless}}"svelte5", "{{appType}}"],
@@ -279,11 +279,13 @@ for await (const ev of m.chatStream('展开讲讲', { signal: controller.signal
279
279
 
280
280
  ## 语音(Audio)
281
281
 
282
- `ai.audio` 提供 ASR/TTS,Provider(OpenAI / MiMo / Qwen / 豆包)作为内部实现,凭据未配置时回退平台环境变量。调用方只表达「音频还是文本、完整还是流式」,不感知 WebSocket / SSE / 厂商事件。
282
+ `ai.audio` 提供 ASR/TTS,Provider(OpenAI / MiMo / Qwen / 豆包)作为内部实现,凭据未配置时回退平台环境变量。调用方只表达「音频还是文本、完整还是流式」,不感知 WebSocket / SSE / 厂商事件。LLM 与语音模型确认共用凭据时,可显式设置 `inheritLlmApiKey: true`;该选项默认关闭,避免跨供应商误用密钥。
283
283
 
284
284
  ```yaml
285
285
  # ai.init 配置片段
286
286
  audio:
287
+ # 仅当语音服务与 LLM 确认共用同一密钥时启用
288
+ inheritLlmApiKey: true
287
289
  models:
288
290
  - { id: asr, provider: qwen, model: qwen3-asr-flash-realtime, operations: [transcribe] }
289
291
  - { id: tts, provider: qwen, model: qwen3-tts-flash-realtime, operations: [synthesize] }
@@ -332,6 +334,8 @@ image:
332
334
  generateModel: free
333
335
  ```
334
336
 
337
+ LLM、Audio、Image 的可选密钥统一使用 `OptionalSecretSchema`:配置中的 YAML `null`、空字符串和纯空白字符串均表示未配置。语音密钥优先级为模型条目 → 启用继承后的 LLM 密钥 → 平台环境变量,业务启动代码不应再遍历配置修补 `apiKey`。
338
+
335
339
  ```ts
336
340
  const image = await ai.image.generate({
337
341
  prompt: '水墨风格的未来城市,清晨薄雾',
@@ -179,8 +179,8 @@ const result = await ai.rag.query('核心架构是什么?', {
179
179
 
180
180
  - 音频类型:`AudioContent { data: Uint8Array, format: 'pcm16'|'wav'|'mp3'|'opus', sampleRate?, channels? }`;`pcm16` 等裸音频必须传 `sampleRate`。
181
181
  - 请求可选:识别 `contextHints?: string[]`(热词/提示);合成 `instruction?: string`(自然语言风格指令);均支持 `signal: AbortSignal`。
182
- - 模型配置:`audio.models: [{ id, provider: 'openai'|'mimo'|'qwen'|'doubao', model, operations, apiKey?, baseUrl?, appKey?, accessKey?, resourceId?, workspaceId? }]`;`operations` 必须明确为识别、合成或两者。
183
- - 凭据环境变量回退:`HAI_AI_AUDIO_<PROVIDER>_API_KEY` 或 `OPENAI_API_KEY` / `MIMO_API_KEY` / `DASHSCOPE_API_KEY` / `VOLC_API_KEY`(豆包旧版控制台额外 `VOLC_APP_KEY` / `VOLC_ACCESS_KEY`)。
182
+ - 模型配置:`audio.models: [{ id, provider: 'openai'|'mimo'|'qwen'|'doubao', model, operations, apiKey?, baseUrl?, appKey?, accessKey?, resourceId?, workspaceId? }]`;`operations` 必须明确为识别、合成或两者。确认 LLM 与语音模型共用凭据时,可设置 `audio.inheritLlmApiKey: true`,默认关闭。
183
+ - 凭据优先级:模型条目 `apiKey` → 显式启用继承后的 LLM `apiKey` → `HAI_AI_AUDIO_<PROVIDER>_API_KEY` 或 `OPENAI_API_KEY` / `MIMO_API_KEY` / `DASHSCOPE_API_KEY` / `VOLC_API_KEY`(豆包旧版控制台额外 `VOLC_APP_KEY` / `VOLC_ACCESS_KEY`)。可选密钥中的 YAML `null`、空字符串和纯空白字符串都会规范化为未配置。
184
184
  - 资源上限:`audio.maxAudioBytes`(默 10 MiB)、`audio.maxStreamDurationMs`(默 5 分钟);所有请求支持 `signal: AbortSignal` 打断。
185
185
  - Provider 为内部实现,不从根入口导出;不支持的输入方式招 `AUDIO_UNSUPPORTED_INPUT`,不伪装实时。取消 → `AUDIO_CANCELLED`,超时 → `AUDIO_TIMEOUT`,连接失败或 `end` 前异常断连 → `AUDIO_CONNECTION_FAILED`。浏览器客户端(`@h-ai/ai/client`)严格区分正常结束 / 取消 / 异常断连:取消招 `AUDIO_CANCELLED`、`end` 前断连招 `AUDIO_CONNECTION_FAILED`、服务端 error 帧保留其领域错误码,`synthesize` 不把未完成的部分音频当作成功返回。
186
186
 
@@ -80,7 +80,7 @@ const WidgetCreateInputSchema = z.object({
80
80
  description: z.string().optional(),
81
81
  })
82
82
 
83
- // 输出 Schema(统一包装为 HaiResult<T>)
83
+ // 同一 contract 内两个接口复用,因此保留为文件私有常量,不从 schemas 导出。
84
84
  const WidgetOutputSchema = apiContract.haiResultSchema(z.object({
85
85
  id: z.string(),
86
86
  name: z.string(),
@@ -196,16 +196,27 @@ myContract.ai.chats.createCompletion // POST /ai/chats/completion
196
196
  import { apiContract } from '@h-ai/api-contract'
197
197
  import { z } from 'zod'
198
198
 
199
- const OutputSchema = apiContract.haiResultSchema(z.object({
200
- id: z.string(),
201
- name: z.string(),
202
- }))
199
+ const getWidget = apiContract
200
+ .route({ method: 'GET', path: '/widgets/{id}' })
201
+ .output(apiContract.haiResultSchema(z.object({
202
+ id: z.string(),
203
+ name: z.string(),
204
+ })))
203
205
 
204
206
  // 等价于:
205
207
  // { success: true, data: { id: string, name: string } }
206
208
  // | { success: false, error: HaiError }
207
209
  ```
208
210
 
211
+ ### `apiContract.pathOf(procedure)` — 读取业务路径
212
+
213
+ ```typescript
214
+ const refreshPath = apiContract.pathOf(apiContract.iam.auth.refresh)
215
+ // '/auth/refresh'
216
+ ```
217
+
218
+ middleware、client 与 E2E 需要业务路径时从 contract 元数据读取,不维护第二份 route 常量。服务端挂载时再与 `_serv.yml` 的 `http.apiPrefix` 拼接。
219
+
209
220
  ### 公共 Schema 工具
210
221
 
211
222
  | 访问路径 | 说明 |
@@ -224,6 +235,11 @@ const OutputSchema = apiContract.haiResultSchema(z.object({
224
235
  ### 必须遵循
225
236
 
226
237
  - **输出必须包装为 `apiContract.haiResultSchema`** — 客户端依赖 `success` 判断成功/失败
238
+ - **业务 path 直接写在对应 `*-contract.ts`** — 禁止新增 `*-routes.ts` 或业务路径常量
239
+ - **一次性 `*OutputSchema` 直接内联** — 不在 schemas 文件声明或导出
240
+ - **同一 contract 内重复使用的输出可设私有常量** — 不得为复用扩大公共导出
241
+ - **公共 HTTP 传输配置以配置文件为准** — API 前缀、健康检查、OpenAPI/docs、密钥协商路径与跨端 Header 放在 service 的 `_serv.yml`,不放 contract 包
242
+ - **schemas 只保留真实复用结构** — 至少被多个接口或 contract/procedure 等不同层共同消费
227
243
  - **`operationId` 全局唯一** — 格式为 `domain.resource.action`,如 `iam.auth.login`
228
244
  - **`tags` 至少包含领域名** — 供 OpenAPI 文档分组
229
245
  - **输入 Schema 用 Zod v4** — 与 `@orpc/zod/zod4` 的 `ZodToJsonSchemaConverter` 配套
@@ -234,6 +250,16 @@ const OutputSchema = apiContract.haiResultSchema(z.object({
234
250
  - ❌ Contract 文件中 `import` 任何业务模块(`@h-ai/iam`、`@h-ai/storage` 等)
235
251
  - ❌ 在 contract 中使用 `z.any()` 或 `z.unknown()` 作为输出类型
236
252
  - ❌ 重复定义已存在的领域 contract
253
+ - ❌ 导出只被单个 contract 接口使用的 `*OutputSchema`
254
+ - ❌ 用独立 `*-routes.ts` 维护业务接口路径
255
+
256
+ ### 文件职责
257
+
258
+ | 文件 | 只负责 |
259
+ | --- | --- |
260
+ | `*-contract.ts` | 业务接口 path、method、输入与输出边界 |
261
+ | `*-schemas.ts` | 跨接口或跨层复用的输入、实体、数据结构 |
262
+ | service `config/_serv.yml` | API 前缀、基础设施端点、跨端 Header 与加密 transport 配置 |
237
263
 
238
264
  ---
239
265
 
@@ -266,9 +292,9 @@ const getWidget = apiContract
266
292
  ### 无输入的端点
267
293
 
268
294
  ```typescript
269
- const healthContract = apiContract
270
- .route({ method: 'GET', path: '/health', operationId: 'health.check', tags: ['system'] })
271
- .output(apiContract.haiResultSchema(z.object({ status: z.string() })))
295
+ const versionContract = apiContract
296
+ .route({ method: 'GET', path: '/system/version', operationId: 'system.version', tags: ['system'] })
297
+ .output(apiContract.haiResultSchema(z.object({ version: z.string() })))
272
298
  ```
273
299
 
274
300
  ---
@@ -69,7 +69,6 @@ await apiClient.init({
69
69
  baseUrl: import.meta.env.PUBLIC_API_BASE,
70
70
  auth: {
71
71
  storage: createCapacitorTokenStorage(),
72
- refreshPath: '/api/v1/auth/refresh',
73
72
  },
74
73
  })
75
74
  ```
@@ -289,7 +288,6 @@ export async function initApi() {
289
288
  baseUrl: `${import.meta.env.PUBLIC_API_BASE}/api/v1`,
290
289
  auth: {
291
290
  storage: createCapacitorTokenStorage(),
292
- refreshPath: '/auth/refresh',
293
291
  },
294
292
  })
295
293
  }
@@ -136,6 +136,7 @@ unwatch()
136
136
  - `${VAR}` — 读取 `process.env.VAR`;缺失则返回 `HaiConfigError.CONFIG_ENV_VAR_MISSING` 错误
137
137
  - `${VAR:default}` — 读取 `process.env.VAR`;缺失则使用默认值
138
138
  - **类型还原**:整个值恰好是单个变量表达式时,结果还原为原生类型(number / boolean 等);混合文本始终为字符串
139
+ - **空值语义**:`${VAR:}` 或值为空字符串的 `${VAR}` 保持为 `''`,不会被 YAML 解析为 `null`;是否把空字符串视为未配置由模块 Schema 明确定义
139
140
 
140
141
  ```yaml
141
142
  # config/_core.yml
@@ -36,6 +36,7 @@ description: 使用 @h-ai/serv 将 oRPC contract 挂载为最小 HTTP App 抽象
36
36
  - 自定义 `ServContext`(注入 session、tenant 等请求级数据)
37
37
  - 生成 OpenAPI 3.1 文档供外部工具消费
38
38
  - 通过 `config/_serv.yml` 管理 API 前缀、OpenAPI、docs、health、rpc 等 HTTP 挂载配置
39
+ - 通过 `serv.createRuntimeSecurityPolicy(...)` 对生产 CORS Origin、原生 WebView Origin、Secure Cookie 与 API 文档暴露执行 fail-closed 策略
39
40
  - 启用传输加密:`serv.createApp({ transport: { crypto } })`
40
41
  - 高级场景通过 `createApp({ middlewares })` 自定义 HTTP middleware,或通过共享类型自定义 procedure wrapper
41
42
  - 为 Audio WebSocket 配置一次性 ticket 校验、IAM/模型/配额授权与并发释放钩子
@@ -91,6 +92,40 @@ serv.listen(app, {
91
92
  })
92
93
  ```
93
94
 
95
+ 生产部署先从 `_core.yml.env` 与 `_serv.yml.cors` 创建安全策略;不要在业务应用重复实现 Origin 解析:
96
+
97
+ ```typescript
98
+ const security = serv.createRuntimeSecurityPolicy({
99
+ environment: coreConfig.env,
100
+ corsOrigin: servConfig.cors.origin,
101
+ nativeOrigins: servConfig.cors.nativeOrigins,
102
+ })
103
+
104
+ const app = serv.createApp({
105
+ contract,
106
+ procedures,
107
+ http: {
108
+ ...servConfig.http,
109
+ openapi: security.exposeApiDocs ? servConfig.http.openapi : false,
110
+ docs: security.exposeApiDocs ? servConfig.http.docs : false,
111
+ },
112
+ refreshCookie: { secure: security.secureRefreshCookie },
113
+ middlewares: [{
114
+ middleware: serv.cors({
115
+ origin: security.allowOrigin,
116
+ credentials: servConfig.cors.credentials,
117
+ allowedHeaders: [...servConfig.cors.allowedHeaders],
118
+ exposedHeaders: [...servConfig.cors.exposedHeaders],
119
+ }),
120
+ }],
121
+ })
122
+ ```
123
+
124
+ - 生产环境 `cors.origin` 禁止为空或 `*`,启动时 fail-fast。
125
+ - 普通 Web Origin 仅接受 HTTP(S);原生 Origin 通过 `cors.nativeOrigins` 显式允许 HTTP(S)、Capacitor 或 Tauri。
126
+ - Origin 按完整规范化值精确匹配,不使用前缀/后缀匹配。
127
+ - 生产环境自动要求 Secure refresh cookie,并把 OpenAPI/docs 作为安全上限关闭。
128
+
94
129
  ### 2.1 Audio WebSocket 安全接入
95
130
 
96
131
  ```typescript
@@ -1,27 +0,0 @@
1
- /** HTTP 与业务 API 路径常量,前端、服务端与 E2E 共用。 */
2
- export const APP_HTTP_ROUTES = {
3
- apiPrefix: '/api/v1',
4
- health: '/health',
5
- ready: '/ready',
6
- openapi: '/openapi.json',
7
- docs: '/docs',
8
- } as const
9
-
10
- /** app 领域 contract 内部路径,最终会挂载到 APP_HTTP_ROUTES.apiPrefix 下。 */
11
- export const APP_CONTRACT_ROUTES = {
12
- info: '/app/info',
13
- echo: '/app/echo',
14
- } as const
15
-
16
- /** 已拼接 apiPrefix 的实际 HTTP 路径,便于测试和非 typed client 场景使用。 */
17
- export const APP_API_ROUTES = {
18
- health: APP_HTTP_ROUTES.health,
19
- ready: APP_HTTP_ROUTES.ready,
20
- openapi: APP_HTTP_ROUTES.openapi,
21
- docs: APP_HTTP_ROUTES.docs,
22
- info: `${APP_HTTP_ROUTES.apiPrefix}${APP_CONTRACT_ROUTES.info}`,
23
- echo: `${APP_HTTP_ROUTES.apiPrefix}${APP_CONTRACT_ROUTES.echo}`,
24
- } as const
25
-
26
- /** 当前工程启用的前端目标。 */
27
- export const ENABLED_FRONTENDS = {{{fullstack.selectedFrontendsJson}}} as const