@h-ai/cli 0.1.0-alpha.12 → 0.1.0-alpha.15

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.
@@ -0,0 +1,658 @@
1
+ ---
2
+ name: hai-serv
3
+ description: 使用 @h-ai/serv 将 oRPC contract 挂载为 Hono HTTP API Service;当需求涉及创建 API 服务、装配 procedure、挂载 OpenAPI 文档、配置健康检查、添加认证/权限 pipeline 包装器或切换 Node/Fetch 运行时适配器时使用。
4
+ ---
5
+
6
+ # hai-serv
7
+
8
+ > `@h-ai/serv` 是 hai-framework 的 API Service 运行时,基于 Hono + oRPC 将 `@h-ai/api-contract` 的领域 contract 挂载成跨端可访问的 HTTP API。
9
+
10
+ ---
11
+
12
+ ## 运行环境
13
+
14
+ > ⚠️ **服务端模块(Node.js / Fetch Runtime)。** 浏览器端通过 `@h-ai/api-client` 调用由本模块暴露的 HTTP API。
15
+
16
+ ---
17
+
18
+ ## 适用场景
19
+
20
+ - 将 `@h-ai/api-contract` contract 装配成 Hono HTTP app
21
+ - 配置 `/health`、`/ready`、`/openapi.json`、`/docs` 等系统端点
22
+ - 在 procedure 中添加认证(`requireAuth`)或权限(`requirePermission`)检查
23
+ - 切换 Node.js (`@hono/node-server`) 或 Fetch Runtime(Cloudflare Workers / Deno)部署
24
+ - 自定义 `ServContext`(注入 session、tenant 等请求级数据)
25
+ - 生成 OpenAPI 3.1 文档供外部工具消费
26
+ - 通过 `config/_serv.yml` 管理 API 前缀、OpenAPI、docs、health、rpc 等 HTTP 挂载配置
27
+ - 启用传输加密:`serv.createApp({ transport: { crypto } })`
28
+ - 高级场景通过 `createApp({ middlewares })` 自定义 HTTP middleware,或通过共享类型自定义 procedure wrapper
29
+
30
+ ---
31
+
32
+ ## 使用步骤
33
+
34
+ ### 1. 安装依赖
35
+
36
+ ```typescript
37
+ // @h-ai/serv 依赖 @h-ai/api-contract 提供 contract 定义
38
+ // 业务 procedure 依赖对应领域模块(iam/storage/ai)
39
+ ```
40
+
41
+ ### 2. 创建并启动 app
42
+
43
+ ```typescript
44
+ import { ai } from '@h-ai/ai'
45
+ import { apiContract } from '@h-ai/api-contract'
46
+ import { iam } from '@h-ai/iam'
47
+ import { serv } from '@h-ai/serv'
48
+ import { createAiProcedures } from '@h-ai/serv/features/ai'
49
+ import { createIamProcedures } from '@h-ai/serv/features/iam'
50
+ import { createStorageProcedures } from '@h-ai/serv/features/storage'
51
+ import { storage } from '@h-ai/storage'
52
+
53
+ const contract = apiContract.create({ iam: apiContract.iam, storage: apiContract.storage, ai: apiContract.ai })
54
+
55
+ // 装配 procedures(每个 feature 对应 contract 中的一个领域)
56
+ const procedures = {
57
+ iam: createIamProcedures({ iam }),
58
+ storage: createStorageProcedures({ storage }),
59
+ ai: createAiProcedures({ ai }),
60
+ }
61
+
62
+ // 创建 Hono app
63
+ const app = serv.createApp({
64
+ contract,
65
+ procedures,
66
+ http: {
67
+ apiPrefix: '/api/v1',
68
+ openapi: { path: '/openapi.json' },
69
+ docs: { path: '/docs' },
70
+ health: { path: '/health', readyPath: '/ready' },
71
+ },
72
+ })
73
+
74
+ // Node.js 启动(自动读取 PORT / HOST 环境变量;onClose 自动监听 SIGINT/SIGTERM)
75
+ serv.listen(app, {
76
+ onListening: info => logger.info('API service listening', { port: info.port }),
77
+ onClose: closeApp, // 业务模块关闭函数(ai/storage/iam/reldb 等反向释放)
78
+ })
79
+ ```
80
+
81
+ ### 3. 扩展应用自有 contract + procedures
82
+
83
+ `apiContract.create({...})` 接受任意 key——除了框架提供的 `iam/storage/ai`,应用可以挂入自己的领域 contract,
84
+ 客户端通过 `client.<key>.<procedure>(...)` 调用,类型完全推导自同一份 contract。
85
+
86
+ ```typescript
87
+ // apps/api-service-contract/src/app-contract.ts
88
+ import { apiContract } from '@h-ai/api-contract'
89
+ import { z } from 'zod'
90
+
91
+ const InfoOutput = apiContract.haiResultSchema(z.object({ name: z.string(), version: z.string() }))
92
+ const EchoInput = z.object({ message: z.string().min(1).max(2000) })
93
+ const EchoOutput = apiContract.haiResultSchema(z.object({ message: z.string(), userId: z.string() }))
94
+
95
+ export const appContract = {
96
+ info: apiContract.route({ method: 'POST', path: '/app/info', operationId: 'app.info', tags: ['app'] })
97
+ .output(InfoOutput),
98
+ echo: apiContract.route({ method: 'POST', path: '/app/echo', operationId: 'app.echo', tags: ['app'] })
99
+ .input(EchoInput).output(EchoOutput),
100
+ }
101
+ ```
102
+
103
+ ```typescript
104
+ // src/server/procedures/app-procedures.ts
105
+ import type { ServContext } from '@h-ai/serv'
106
+ import { appContract } from '@h-ai/api-service-contract'
107
+ import { ok } from '@h-ai/core'
108
+ import { serv } from '@h-ai/serv'
109
+
110
+ export function createAppProcedures(deps: { name: string; version: string }) {
111
+ const p = serv.implement(appContract).$context<ServContext>()
112
+ return p.router({
113
+ // 公开 procedure:handler 直接返回 HaiResult
114
+ info: p.info.handler(() => ok({ name: deps.name, version: deps.version })),
115
+ // 鉴权 procedure:用 serv.requireAuth 包一层,未登录时短路返回 ApiUnauthorized
116
+ echo: p.echo.handler(serv.requireAuth(({ input, context }) => ok({
117
+ message: input.message,
118
+ userId: context.session!.userId,
119
+ }))),
120
+ })
121
+ }
122
+ ```
123
+
124
+ 把自有 contract / procedures 与框架的 iam/storage/ai 平铺合并:
125
+
126
+ ```typescript
127
+ const contract = apiContract.create({
128
+ iam: apiContract.iam,
129
+ storage: apiContract.storage,
130
+ ai: apiContract.ai,
131
+ app: appContract, // ← 自有契约
132
+ })
133
+
134
+ const procedures = {
135
+ iam: createIamProcedures({ iam }),
136
+ storage: createStorageProcedures({ storage }),
137
+ ai: createAiProcedures({ ai }),
138
+ app: createAppProcedures({ name: 'demo', version: '1.0.0' }),
139
+ }
140
+
141
+ const app = serv.createApp({ contract, procedures, http, iam })
142
+ ```
143
+
144
+ 注意要点:
145
+
146
+ - **无 input 的 POST 过程**:默认不发送 body;transport 加密层会自动跳过空 body 请求,客户端可直接 `client.app.info()`。
147
+ - **`serv.requireAuth` 仅可在过程内调用**:必须在 `context` 中有 `session`;公开过程不要包装它。
148
+ - **客户端导入相同 contract**:把服务端与客户端共享的 contract 独立成 package(如 `@h-ai/api-service-contract`),禁止从 app 源码目录跨应用 import。
149
+
150
+ ### 5. Fetch Runtime(Cloudflare Workers / Deno / Bun)
151
+
152
+ ```typescript
153
+ import { serv } from '@h-ai/serv'
154
+
155
+ // toFetch 返回标准 Fetch handler
156
+ const handler = serv.toFetch(app)
157
+
158
+ export default { fetch: handler }
159
+ ```
160
+
161
+ ### 6. 通过 `config/_serv.yml` 管理 HTTP 配置
162
+
163
+ `@h-ai/serv` 不自己扫描 YAML;推荐由 `@h-ai/core` 统一加载,再用 `ServConfigSchema` 校验:
164
+
165
+ ```typescript
166
+ import { core } from '@h-ai/core'
167
+ import { serv, ServConfigSchema } from '@h-ai/serv'
168
+
169
+ core.init({ configDir: './config' })
170
+
171
+ const validation = core.config.validate('serv', ServConfigSchema)
172
+ if (!validation.success)
173
+ throw new Error(validation.error.message)
174
+
175
+ const servConfig = core.config.getOrThrow<import('@h-ai/serv').ServConfig>('serv')
176
+
177
+ const app = serv.createApp({
178
+ contract,
179
+ procedures,
180
+ http: servConfig.http,
181
+ transport: servConfig.transport === false
182
+ ? undefined
183
+ : {
184
+ crypto,
185
+ keyExchangePath: servConfig.transport.keyExchangePath,
186
+ excludePaths: [...servConfig.transport.excludePaths],
187
+ maxClients: servConfig.transport.maxClients,
188
+ },
189
+ })
190
+ ```
191
+
192
+ ```yaml
193
+ # config/_serv.yml
194
+ http:
195
+ apiPrefix: /api/v1
196
+ openapi:
197
+ path: /openapi.json
198
+ docs:
199
+ path: /docs
200
+ health:
201
+ path: /health
202
+ readyPath: /ready
203
+ rpc: false
204
+
205
+ transport:
206
+ keyExchangePath: /_hai/key-exchange
207
+ excludePaths:
208
+ - /health
209
+ - /ready
210
+ - /openapi.json
211
+ - /docs
212
+ - /_hai/scalar.js
213
+ maxClients: 10000
214
+ ```
215
+
216
+ ---
217
+
218
+ ## 核心 API
219
+
220
+ ### `serv.createApp(options)` — 创建 Hono app
221
+
222
+ ```typescript
223
+ import type { CreateServAppOptions } from '@h-ai/serv'
224
+
225
+ const app = serv.createApp({
226
+ contract, // AnyContractRouter — 通过 apiContract.create() 组合的 contract
227
+ procedures, // Router<AnyContractRouter, ServContext> — procedure 实现
228
+ http?, // ServHttpConfigInput — HTTP 端点配置(见下方配置节)
229
+ middlewares?, // readonly ServMiddlewareMount[] — 自定义 Hono middleware(日志/CORS/限流/租户头校验)
230
+ iam?, // ServIam — 顶层 IAM 句柄,同时驱动 access token 校验与 refresh cookie【推荐】
231
+ refreshCookie?, // RefreshCookieConfig — httpOnly refresh cookie 刷新路径(见下方 cookie 节)
232
+ transport?, // { crypto, keyExchangePath?, excludePaths?, maxClients? } — 统一传输加密
233
+ verifyToken?, // (token) => Promise<HaiResult<ServSession>> — 逃脱口:不使用 iam 时提供自定义校验
234
+ createContext?, // CreateServContext — 高级:完全接管上下文构造(设置后 serv 不再自动填充 session)
235
+ })
236
+ ```
237
+
238
+ ### `apiContract.route` / `serv.implement` — contract 与运行时分层
239
+
240
+ 应用代码不要直接 `import { oc } from '@orpc/contract'` 或 `import { implement } from '@orpc/server'`。
241
+ contract 路由统一用 `@h-ai/api-contract` 定义,procedure 运行时统一用 `@h-ai/serv` 实现:
242
+
243
+ ```typescript
244
+ import { apiContract } from '@h-ai/api-contract'
245
+ import { serv } from '@h-ai/serv'
246
+
247
+ // 等价于 oc.route(...),但不让应用感知 @orpc/contract
248
+ const route = apiContract.route({ method: 'POST', path: '/x', operationId: 'x', tags: ['x'] })
249
+
250
+ // 等价于 implement(contract)
251
+ const p = serv.implement(appContract).$context<ServContext>()
252
+ ```
253
+
254
+ ### 传输加密
255
+
256
+ serv 不暴露本地传输加密工厂;内部统一调用 `crypto.transport.createServer()`。客户端用 `@h-ai/api-client` 的 `transport` 配置自动协商。
257
+
258
+ ```typescript
259
+ import { crypto } from '@h-ai/crypto'
260
+ import { serv } from '@h-ai/serv'
261
+
262
+ await crypto.init()
263
+
264
+ const app = serv.createApp({
265
+ contract,
266
+ procedures,
267
+ http: { apiPrefix: '/api/v1' },
268
+ transport: {
269
+ crypto,
270
+ // keyExchangePath 默认 '/_hai/key-exchange'
271
+ // maxClients: 10000,
272
+ },
273
+ })
274
+ ```
275
+
276
+ 客户端:
277
+
278
+ ```typescript
279
+ await apiClient.init({
280
+ baseUrl: 'https://api.example.com/api/v1',
281
+ transport: { crypto },
282
+ })
283
+ ```
284
+
285
+ 若服务端通过 `config/_serv.yml` 自定义了 `transport.keyExchangePath`,客户端也必须传入同一路径:
286
+
287
+ ```typescript
288
+ await apiClient.init({
289
+ baseUrl: 'https://api.example.com/api/v1',
290
+ transport: {
291
+ crypto,
292
+ keyExchangePath: '/custom/key-exchange',
293
+ },
294
+ })
295
+ ```
296
+
297
+ > 不要导入或暴露子目录内部 transport 工厂;公共装配点只有 `serv.createApp({ transport: { crypto } })`。
298
+
299
+ **上下文工厂优先级**(`context.session` 填充来源):
300
+
301
+ 1. 显式 `createContext`(高级场景:多租户、额外字段)
302
+ 2. `verifyToken`(逃脱口:使用非 @h-ai/iam 的认证服务)
303
+ 3. `iam.session.verifyToken`(推荐:传入 `iam` 后自动启用)
304
+ 4. 默认 `parseRequestContext`:仅解析请求元数据,`session` 为 `undefined`
305
+
306
+ > ⚠️ `verifyToken` 会在每次请求调用(不缓存);verifyToken 失败或抛错统一收敛为 `session=undefined`,由 `requireAuth` 统一返回 401。
307
+
308
+ ### Procedure 包装器(所有导出为 `serv.xxx` 扁平 API)
309
+
310
+ | 函数 | 说明 |
311
+ | --- | --- |
312
+ | `serv.mapHaiError(handler)` | 捕获未处理异常,转换为 `HaiResult` |
313
+ | `serv.requireAuth(handler)` | 验证 session(`context.session` 非空,即 token 已通过 `verifyToken` 校验) |
314
+ | `serv.requirePermission(permission, handler)` | 验证权限码,支持通配符 `serv.WILDCARD_PERMISSION`(`'*'`) |
315
+ | `serv.requireRole(role, handler)` | 验证角色,支持通配符 `serv.WILDCARD_ROLE`(`'*'`) |
316
+ | `serv.validateInputOrFail(zodSchema, input, locale)` | 在 procedure 内执行 Zod 二次校验,失败时返回本地化 `HaiResult` + `ValidationFormError[]` |
317
+ | `serv.resolveRequestLocale(headers)` | 从 `x-hai-locale` / `Accept-Language` 解析并规范化 locale |
318
+ | `serv.m(key, { locale, params })` | 读取 serv 自身 i18n 消息(请求级本地化) |
319
+
320
+ ```typescript
321
+ import { serv } from '@h-ai/serv'
322
+
323
+ // 组合包装(从外到内:error → auth → permission/role → handler)
324
+ const handler = serv.mapHaiError(
325
+ serv.requireAuth(
326
+ serv.requirePermission('user.write', actualHandler)
327
+ )
328
+ )
329
+
330
+ // 角色检查示例
331
+ const adminOnly = serv.requireAuth(
332
+ serv.requireRole('admin', actualHandler)
333
+ )
334
+ ```
335
+
336
+ ### 高级:自定义 pipeline(HTTP middleware + context + procedure wrapper)
337
+
338
+ `@h-ai/serv` 的 pipeline 分三层:
339
+
340
+ 1. **HTTP middleware 层**:通过 `serv.createApp({ middlewares })` 注入 Hono middleware
341
+ 2. **context 层**:通过 `verifyToken` / `createContext` / `serv.buildAuthContextFactory()` 自定义请求上下文
342
+ 3. **procedure wrapper 层**:通过 `ServProcedureWrapper` / `ServGuardedProcedureWrapper` 自定义业务包装器
343
+
344
+ #### HTTP middleware
345
+
346
+ 适用于请求日志、trace、限流、CORS、租户头校验等 **HTTP 层** 横切逻辑:
347
+
348
+ ```typescript
349
+ import type { ServMiddleware } from '@h-ai/serv'
350
+
351
+ const requestMetrics: ServMiddleware = async (c, next) => {
352
+ const startedAt = Date.now()
353
+ await next()
354
+ c.header('x-response-time-ms', String(Date.now() - startedAt))
355
+ }
356
+
357
+ const app = serv.createApp({
358
+ contract,
359
+ procedures,
360
+ http: { apiPrefix: '/api/v1' },
361
+ middlewares: [
362
+ { middleware: requestMetrics },
363
+ {
364
+ path: '/api/v1/*',
365
+ middleware: async (c, next) => {
366
+ if (!c.req.header('x-tenant-id'))
367
+ return c.text('Missing x-tenant-id', 400)
368
+ await next()
369
+ },
370
+ },
371
+ ],
372
+ })
373
+ ```
374
+
375
+ 执行顺序固定为:`securityHeaders` → `transport`(若启用)→ `middlewares` → health/refresh-cookie/OpenAPI/RPC/docs/oRPC routes。
376
+
377
+ #### Context pipeline
378
+
379
+ 如果想复用“Bearer token → session”这段逻辑,再追加租户/工作区等字段,优先:
380
+
381
+ ```typescript
382
+ const baseContext = serv.buildAuthContextFactory(token => iam.session.verifyToken(token))
383
+
384
+ const app = serv.createApp({
385
+ contract,
386
+ procedures,
387
+ createContext: async ({ request }) => {
388
+ const context = await baseContext({ request })
389
+ return {
390
+ ...context,
391
+ tenantId: request.headers.get('x-tenant-id') ?? null,
392
+ }
393
+ },
394
+ })
395
+ ```
396
+
397
+ 选择建议:
398
+
399
+ - 只换 token 校验实现:`verifyToken`
400
+ - 在默认认证上下文之上追加字段:`buildAuthContextFactory(...) + createContext`
401
+ - 完全接管上下文构造:`createContext`
402
+
403
+ #### Procedure wrapper
404
+
405
+ 适用于已进入 procedure 后的审计、租户约束、业务 guard:
406
+
407
+ ```typescript
408
+ import type { ServGuardedProcedureWrapper, ServProcedureWrapper } from '@h-ai/serv'
409
+ import { err, HaiCommonError } from '@h-ai/core'
410
+ import { serv } from '@h-ai/serv'
411
+
412
+ const withAudit: ServProcedureWrapper = handler => async (options) => {
413
+ options.context.logger.info('procedure.start', { requestId: options.context.requestId })
414
+ return await handler(options)
415
+ }
416
+
417
+ const requireTenant: ServGuardedProcedureWrapper<string> = (tenantId, handler) => async (options) => {
418
+ if (options.context.request.headers.get('x-tenant-id') !== tenantId) {
419
+ return err(
420
+ HaiCommonError.FORBIDDEN,
421
+ serv.m('serv_errorForbidden', { locale: options.context.locale }),
422
+ )
423
+ }
424
+ return await handler(options)
425
+ }
426
+
427
+ const createWidget = serv.mapHaiError(
428
+ serv.requireAuth(
429
+ withAudit(
430
+ requireTenant('tenant-a', async ({ input }) => widgetService.create(input)),
431
+ ),
432
+ ),
433
+ )
434
+ ```
435
+
436
+ 说明:
437
+
438
+ - `ServMiddleware` / `ServMiddlewareMount` / `ServProcedureWrapper` / `ServGuardedProcedureWrapper` 等共享类型由根入口 `@h-ai/serv` 对外暴露
439
+ - `src/pipelines/*` 是模块内部默认实现目录,不作为公开子路径 API 文档承诺
440
+ - 常规场景优先继续用 `serv.requireAuth / requirePermission / requireRole / mapHaiError`
441
+
442
+ ### 正常业务请求中的错误与 i18n
443
+
444
+ 按三层处理:
445
+
446
+ 1. **oRPC contract 输入校验**:进入 handler 前自动发生;`serv.createApp()` 会把默认英文 Zod 错误改写为本地化 `errors[]`。
447
+ 2. **handler 内二次校验**:对跨字段规则、数据库回读对象等适合 Zod 表达的场景,使用 `serv.validateInputOrFail(zodSchema, input, context.locale)`;简单业务规则直接 `err(...)` 即可。
448
+ 3. **业务/领域错误**:不要 throw 预期失败;直接返回 `err(...)`,并在**创建错误消息的那一层**用对应模块的 i18n getter 按 `context.locale` 出消息。
449
+
450
+ ```typescript
451
+ import { err, HaiCommonError } from '@h-ai/core'
452
+ import { z } from 'zod'
453
+ import { serv } from '@h-ai/serv'
454
+
455
+ const Schema = z.object({ title: z.string().min(1) })
456
+
457
+ const handler = serv.mapHaiError(async ({ input, context }) => {
458
+ const validated = serv.validateInputOrFail(Schema, input, context.locale)
459
+ if (!validated.success)
460
+ return validated
461
+
462
+ if (!context.session)
463
+ return err(HaiCommonError.UNAUTHORIZED, serv.m('serv_errorUnauthorized', { locale: context.locale }))
464
+
465
+ return widgetService.create(validated.data)
466
+ })
467
+ ```
468
+
469
+ > 如果错误来自下游模块(如 `iam` / `storage`)并且该模块已经返回 `HaiResult`,serv 默认**透传**它的 `error.message`。由于 `HaiError` 目前不携带 `messageKey/params`,serv 边界不能对任意下游错误再做通用重翻译;要做请求级 i18n,必须在创建该错误的模块/feature 里就拿到 locale。
470
+
471
+ ### `serv.parseRequestContext` — 默认 Context 解析器
472
+
473
+ 从 HTTP 请求头自动解析:
474
+
475
+ | 字段 | 来源 |
476
+ | --- | --- |
477
+ | `accessToken` | `Authorization: Bearer <token>` |
478
+ | `requestId` | `x-request-id` 或自动生成 UUID |
479
+ | `ip` | `x-forwarded-for` / `x-real-ip` |
480
+ | `locale` | `accept-language` |
481
+ | `userAgent` | `user-agent` |
482
+
483
+ ### `serv.generateSpec(contract, options)` — 生成 OpenAPI 文档
484
+
485
+ ```typescript
486
+ const spec = await serv.generateSpec(contract, {
487
+ title: 'My API',
488
+ version: '1.0.0',
489
+ apiPrefix: '/api/v1',
490
+ description: '接口说明',
491
+ })
492
+ // spec 为 OpenAPI 3.1 Document 对象
493
+ ```
494
+
495
+ ---
496
+
497
+ ## HTTP 配置(`ServHttpConfigInput`)
498
+
499
+ | 字段 | 默认值 | 说明 |
500
+ | --- | --- | --- |
501
+ | `apiPrefix` | `'/api/v1'` | oRPC OpenAPI handler 挂载前缀 |
502
+ | `health` | `{ path: '/health', readyPath: '/ready' }` | 健康与就绪检查端点 |
503
+ | `openapi` | `false` | OpenAPI JSON endpoint,显式开启 |
504
+ | `docs` | `false` | Scalar 文档页,显式开启;启用后自动挂载 `/_hai/scalar.js` 本地脚本路由 |
505
+ | `rpc` | `false` | 内部 RPC endpoint,显式开启;`loopback` / `private-network` 基于真实连接 IP,网关场景用 `gateway-only` |
506
+ | `transport` | `undefined` | 顶层配置;启用后默认挂载 `{apiPrefix}/_hai/key-exchange` |
507
+
508
+ ```typescript
509
+ http: {
510
+ apiPrefix: '/api/v1',
511
+ openapi: { path: '/openapi.json' },
512
+ docs: { path: '/docs' },
513
+ health: { path: '/health', readyPath: '/ready' },
514
+ rpc: false, // 关闭内部 RPC
515
+ }
516
+ ```
517
+
518
+ ---
519
+
520
+ ## Feature Procedures(默认实现)
521
+
522
+ `@h-ai/serv` 内置了三个 feature 模块,开箱即用:
523
+
524
+ | 导入路径 | 工厂函数 | 所需依赖 |
525
+ | --- | --- | --- |
526
+ | `@h-ai/serv/features/iam` | `createIamProcedures({ iam })` | `@h-ai/iam` |
527
+ | `@h-ai/serv/features/storage` | `createStorageProcedures({ storage })` | `@h-ai/storage` |
528
+ | `@h-ai/serv/features/ai` | `createAiProcedures({ ai })` | `@h-ai/ai` |
529
+
530
+ ---
531
+
532
+ ## 常见模式
533
+
534
+ ### 自定义领域 procedure
535
+
536
+ ```typescript
537
+ import type { ServContext } from '@h-ai/serv'
538
+ import { serv } from '@h-ai/serv'
539
+ import { myContract } from './my-contract.js'
540
+
541
+ const { requireAuth, requirePermission } = serv
542
+
543
+ export function createMyProcedures() {
544
+ return {
545
+ widget: {
546
+ list: requireAuth(async ({ input, context }) => {
547
+ // input 类型来自 contract 定义
548
+ return ok(await widgetService.list(input))
549
+ }),
550
+ create: requirePermission('widget:write', async ({ input }) => {
551
+ return ok(await widgetService.create(input))
552
+ }),
553
+ },
554
+ }
555
+ }
556
+ ```
557
+
558
+ ### 自定义 contract 组合
559
+
560
+ ```typescript
561
+ import { apiContract } from '@h-ai/api-contract'
562
+ import { widgetContract } from './widget-contract.js'
563
+
564
+ export const myAppContract = apiContract.create({
565
+ iam: apiContract.iam,
566
+ widget: widgetContract,
567
+ })
568
+
569
+ const app = serv.createApp({
570
+ contract: myAppContract,
571
+ procedures: {
572
+ iam: createIamProcedures({ iam }),
573
+ widget: createMyProcedures(),
574
+ },
575
+ http: { apiPrefix: '/api/v1' },
576
+ })
577
+ ```
578
+
579
+ ---
580
+
581
+ ## 错误码
582
+
583
+ procedure 包装器内置以下错误:
584
+
585
+ | 错误码 | 触发条件 |
586
+ | --- | --- |
587
+ | `HaiCommonError.UNAUTHORIZED` | `requireAuth`:`context.session` 为空(无 token 或 token 校验失败) |
588
+ | `HaiCommonError.FORBIDDEN` | `requirePermission`:无对应权限 |
589
+ | `HaiCommonError.INTERNAL_ERROR` | `mapHaiError`:procedure 抛出未处理异常 |
590
+
591
+ ---
592
+
593
+ ## httpOnly Cookie 认证
594
+
595
+ 将 refresh token 存储在 `HttpOnly` cookie 中,避免 XSS 风险(浏览器 JS 无法读取)。
596
+
597
+ 先区分两个 token:
598
+
599
+ - `accessToken`:短期凭证,客户端放在内存,通过 `Authorization: Bearer <token>` 发送;
600
+ `serv.parseRequestContext()` / `extractBearerToken()` 解析、`buildAuthContextFactory()` 校验的就是它。
601
+ - `refreshToken`:长期凭证,启用 `refreshCookie` 后只保存在 httpOnly cookie;
602
+ 它不会走 `Authorization`,也不会被 `extractBearerToken()` 读取,只会在 `/auth/refresh` 被服务端取出。
603
+
604
+ ### 适用场景
605
+
606
+ - 浏览器端(Web / H5)使用 `@h-ai/api-client` 的 `apiClient.tokenStorage.httpOnlyCookie()`
607
+ - 对 refresh token 有更高安全要求,不希望存储在 localStorage
608
+
609
+ ### 工作流程
610
+
611
+ 1. 浏览器 POST `/auth/login` → serv 拦截 oRPC 成功响应 → `Set-Cookie: hai_refresh_token=...;HttpOnly;SameSite=Strict`,并从 JSON 响应体擦除 `refreshToken`
612
+ 2. Access token 由客户端存内存(不持久化)
613
+ 3. Access token 过期 → 客户端 POST `/auth/refresh`(浏览器自动携带 cookie)
614
+ 4. serv 读取 cookie → 调用 `iam.session.refresh` → 返回新 access token + 更新 cookie(响应体不暴露 refresh token)
615
+ 5. POST `/auth/logout` → serv 清除 cookie(`Max-Age=0`)
616
+
617
+ 若同时启用 `transport: { crypto }`,`/auth/refresh` 也必须走加密链路;cookie-only 刷新请求允许空 body,服务端不要把 `Request.body === null` 误判为需要解密的请求体。
618
+
619
+ ### 配置
620
+
621
+ ```typescript
622
+ const app = serv.createApp({
623
+ contract,
624
+ procedures,
625
+ http: { apiPrefix: '/api/v1' },
626
+ iam, // 顶层句柄:同时驱动 verifyToken 与 refresh
627
+ refreshCookie: {
628
+ // cookieName?: string 默认 'hai_refresh_token'
629
+ // maxAge?: number 默认 30 * 24 * 3600(30 天)
630
+ // secure?: boolean 默认 NODE_ENV=production 时开启
631
+ // onRefresh?: (token) => ... 可选:覆盖默认的 iam.session.refresh
632
+ },
633
+ // ✅ 顶层 iam 同时为 access token 校验提供默认实现,无需再传 verifyToken
634
+ })
635
+ ```
636
+
637
+ > **逃脱口**:不使用 `@h-ai/iam` 时,可显式传入顶层 `verifyToken: token => myAuthService.verify(token)`。
638
+
639
+ ### Cookie 规格
640
+
641
+ | 属性 | 值 |
642
+ | --- | --- |
643
+ | Name | `hai_refresh_token`(可配置) |
644
+ | Path | `{apiPrefix}/auth/refresh`(最小范围) |
645
+ | HttpOnly | ✓ |
646
+ | SameSite | `Strict` |
647
+ | Secure | 生产自动开启,`secure: false` 可在 HTTP 开发环境关闭 |
648
+ | Max-Age | 30 天(可配置) |
649
+
650
+ ---
651
+
652
+ ## 测试
653
+
654
+ ```bash
655
+ pnpm --filter @h-ai/serv test
656
+ pnpm --filter @h-ai/serv typecheck
657
+ pnpm --filter @h-ai/serv lint
658
+ ```