@h-ai/serv 1.0.0-beta.2 → 1.0.0-beta.21
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 +172 -124
- package/README.ja.md +172 -124
- package/README.md +172 -124
- package/dist/{chunk-TWMOZPGU.js → chunk-CZ6VA2EN.js} +3 -3
- package/dist/{chunk-TWMOZPGU.js.map → chunk-CZ6VA2EN.js.map} +1 -1
- package/dist/{chunk-I3F7RN4I.js → chunk-PLGCHDZM.js} +30 -13
- package/dist/chunk-PLGCHDZM.js.map +1 -0
- package/dist/features/ai.d.ts +9 -8
- package/dist/features/ai.js +34 -13
- package/dist/features/ai.js.map +1 -1
- package/dist/features/feedback.d.ts +20 -19
- package/dist/features/feedback.js +1 -1
- package/dist/features/feedback.js.map +1 -1
- package/dist/features/iam.d.ts +4 -3
- package/dist/features/iam.js +4 -2
- package/dist/features/iam.js.map +1 -1
- package/dist/features/storage.d.ts +4 -3
- package/dist/features/storage.js +34 -9
- package/dist/features/storage.js.map +1 -1
- package/dist/index.d.ts +97 -68
- package/dist/index.js +338 -222
- package/dist/index.js.map +1 -1
- package/dist/{serv-context-DE1JH5bd.d.ts → serv-context-DSsS_ksL.d.ts} +4 -60
- package/dist/{serv-router-BJk04-oS.d.ts → serv-router-DWjYC0zD.d.ts} +2 -18
- package/package.json +29 -29
- package/dist/chunk-I3F7RN4I.js.map +0 -1
package/README.md
CHANGED
|
@@ -4,29 +4,13 @@
|
|
|
4
4
|
|
|
5
5
|
[框架首页](../../README.md) · 相关模块:[@h-ai/ai](../ai/README.md) · [@h-ai/api-contract](../api-contract/README.md) · [@h-ai/core](../core/README.md) · [@h-ai/crypto](../crypto/README.md) · [@h-ai/iam](../iam/README.md) · [@h-ai/storage](../storage/README.md)
|
|
6
6
|
|
|
7
|
-
## 简介
|
|
8
|
-
|
|
9
7
|
hai-framework 的 API 服务运行时:对外暴露稳定的 HTTP App 抽象,把 `@h-ai/api-contract` 契约挂载为跨端可访问的 HTTP API;Hono 仅作为内部运行时实现。
|
|
10
8
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
- 一行启动:`serv.createApp(...)` → `serv.listen(app, { port })`,应用代码不需要 import Hono
|
|
14
|
-
- 扁平 API(最小知识):`serv.createApp / serv.implement / serv.listen / serv.toFetch / serv.generateSpec / ...`
|
|
15
|
-
- 链式 procedure:`implement().context().route().auth/permission/role().handle().build()`
|
|
16
|
-
- 自定义 pipeline:`createApp({ middlewares })` 挂 HTTP middleware;请求级字段通过 context 工厂注入
|
|
17
|
-
- 只读 Storage 资源:`serv.storageAssets(...)` 统一处理安全 key、MIME 白名单、GET/HEAD、ETag 与缓存头
|
|
18
|
-
- 默认 feature procedures:`createIamProcedures()`、`createStorageProcedures()`、`createAiProcedures()`
|
|
19
|
-
- `build()` 同步返回普通 router,可按嵌套属性选取默认 feature procedures;挂载的 contract 与所选 procedures 保持一致,未选入口不开放。
|
|
20
|
-
- 缺失路由返回本地化的 HaiResult JSON 404,启用传输加密后保留 404,不误报为非 JSON 响应导致的 500。
|
|
21
|
-
- 内置安全响应头、健康检查、可选 OpenAPI JSON、可选 Scalar 文档页、可选内部 RPC endpoint
|
|
22
|
-
- 可选传输加密:`serv.createApp({ transport: { crypto, serverKeyRing, keyStore } })` 自动挂载密钥协商与请求/响应加解密
|
|
23
|
-
- 可选语音入口:`serv.createApp({ audio: { ai, verifyTicket, authorize } })` 挂载统一语音 WebSocket;连接建立即用一次性 ticket 鉴权(返回 `{ session, grant? }`,独立预鉴权超时),每帧运行时 Zod 校验 + 严格状态机,输入/积压/发送缓冲背压与级联取消;授权回调负责服务端确认操作、模型、音色、配额与并发占用
|
|
24
|
-
|
|
25
|
-
> 生命周期说明:`@h-ai/serv` 本身是无状态 HTTP App 装配器,不需要 `init()` / `close()`;请在创建 app 前初始化 `iam` / `storage` / `ai` / `crypto` 等依赖,并在 `serv.listen(..., { onClose })` 中反向释放它们。`serv.createApp()` 遇到启动期配置错误(例如 transport manager 创建失败)会 fail-fast 抛出,便于进程启动阶段暴露问题。 onClose 是 SIGINT/SIGTERM 的关闭回调;主动调用返回句柄的 close 仅关闭 HTTP,业务模块由宿主另行反序释放。
|
|
9
|
+
运行时统一通过 `serv.xx` 使用。需要标注或转导出整个命名空间时,从根入口导入 `ServFunctions`;上下文工厂与泛型 `implement` 的公开签名可直接用于消费者声明生成,无需引用内部构建文件或独立 helper。
|
|
26
10
|
|
|
27
|
-
##
|
|
11
|
+
## 快速开始
|
|
28
12
|
|
|
29
|
-
|
|
13
|
+
下面业务装配片段使用应用已初始化的 `iam`、`storage`、`ai`、`core` 与 logger,并由应用提供 `closeApp`;不是独立初始化脚本。只需要健康检查时可用空 contract 和空 procedures 创建 App。`serv` 是工厂,无 init/close,退出时关闭 listen 返回的句柄并反序释放业务模块。
|
|
30
14
|
|
|
31
15
|
```ts
|
|
32
16
|
import { apiContract } from '@h-ai/api-contract'
|
|
@@ -53,6 +37,7 @@ const procedures = {
|
|
|
53
37
|
const app = serv.createApp({
|
|
54
38
|
contract,
|
|
55
39
|
procedures,
|
|
40
|
+
iam,
|
|
56
41
|
http: {
|
|
57
42
|
apiPrefix: '/api/v1',
|
|
58
43
|
openapi: { path: '/openapi.json' },
|
|
@@ -80,7 +65,47 @@ AI 默认 procedures 从可信 `context.session.userId` 绑定 `objectId`,客
|
|
|
80
65
|
|
|
81
66
|
`chats.createCompletion`(`POST /ai/chats/completions`)只返回一次非流式 `HaiResult<AiChatCompletionData>`。完整 function `tools`(含嵌套 JSON Schema、`strict`)和 `tool_choice`(`auto` / `none` / `required` / 指定名称)传递到模型;消息保留角色、`name`、用户图片和工具调用。工具结果必须使用 `role: 'tool'` 与非空 `tool_call_id`,指定工具选择须引用本次提供的工具。默认 procedure 不自动执行工具,应用授权并执行后,将助手的 `tool_calls` 与对应工具结果一并回传。输入不提供 `stream` 字段,传入 `true` 或 `false` 均在 HTTP 边界返回 400;需要流式时由应用使用 `ai.llm.chatStream()` 定义匹配流输出的独立 endpoint。
|
|
82
67
|
|
|
83
|
-
|
|
68
|
+
## 功能
|
|
69
|
+
|
|
70
|
+
### 用途、输入与输出
|
|
71
|
+
|
|
72
|
+
下表的调用片段复用本页已初始化的服务;命名变量代表应用已提供的请求、Schema、资源定义或配置。完整步骤和失败处理见对应功能示例。`HaiResult` 先判断 `success`,异步项按签名 `await`。
|
|
73
|
+
|
|
74
|
+
| 入口 | 场景 | 输入 | 输出 | 调用片段 |
|
|
75
|
+
| ---------------------------------- | ---------------- | ----------------------------------------------------------------------- | -------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------- |
|
|
76
|
+
| `serv.createApp` | 组装接口应用 | `contract, procedures, http?, iam?, middlewares?, transport?` | `ServHttpApp (fetch/request)` | `serv.createApp({ contract, procedures })` |
|
|
77
|
+
| `serv.listen` | 启动 Node 监听 | `app, { host?, port?, onClose?, onListening? }` | `ServNodeServer { server, address, close(): Promise<void> }` | `serv.listen(app, { host: "127.0.0.1", port: 3000 })` |
|
|
78
|
+
| `serv.toFetch` | 接入 Fetch 宿主 | `ServHttpApp` | `(Request) => Promise<Response>` | `serv.toFetch(app)` |
|
|
79
|
+
| `serv.implement` | 实现领域路由 | `typed contract` | `ServImplementer; context/route/auth/permission/role/handle/build` | `serv.implement(contract)` |
|
|
80
|
+
| `serv.parseRequestContext` | 解析追踪与令牌 | `{ request, platform? }` | `ServContext with requestId/locale/accessToken; no verified session` | `serv.parseRequestContext({ request })` |
|
|
81
|
+
| `serv.buildAuthContextFactory` | 构造可信会话 | `(accessToken) => Promise<HaiResult<ServSession>>` | `CreateServContext; invalid credentials leave session absent` | `serv.buildAuthContextFactory(token => iam.auth.verifyToken(token))` |
|
|
82
|
+
| `serv.cors` | 限定跨域入口 | `{ origin?, methods?, allowedHeaders?, exposedHeaders?, credentials? }` | `ServMiddleware` | `serv.cors({ origin: "https://app.example.com", credentials: true })` |
|
|
83
|
+
| `serv.createRuntimeSecurityPolicy` | 收敛环境安全策略 | `{ environment, corsOrigin, nativeOrigins? }` | `allowOrigin, secureRefreshCookie, exposeApiDocs` | `serv.createRuntimeSecurityPolicy({ environment: "production", corsOrigin: "https://app.example.com" })` |
|
|
84
|
+
| `serv.storageAssets` | 授权的只读资源 | `ServStorageAssetsConfig with reader/key/MIME policy` | `GET/HEAD middleware with ETag` | `serv.storageAssets(assetOptions)` |
|
|
85
|
+
| `serv.generateSpec` | 生成 OpenAPI | `contract, { title?, version?, description?, apiPrefix? }` | `Promise<OpenAPI.Document>` | `serv.generateSpec(contract, { title: "Orders", version: "1.0.0" })` |
|
|
86
|
+
| `serv.validateInputOrFail` | 校验业务输入 | `Zod schema, input, locale?` | `HaiResult<z.infer<Schema>>; failure contains safe field errors` | `serv.validateInputOrFail(InputSchema, input, context.locale)` |
|
|
87
|
+
| `serv.resolveRequestLocale` | 解析请求语言 | `Headers` | `normalized supported locale` | `serv.resolveRequestLocale(request.headers)` |
|
|
88
|
+
| `serv.m` | 翻译模块提示 | `message key, { locale?, ...messageOptions }` | `string` | `serv.m("serv_errorForbidden", { locale: "zh-CN" })` |
|
|
89
|
+
| `serv.WILDCARD_PERMISSION` | 显式全权限常量 | `none` | `string constant; only use in trusted permission policy` | `serv.WILDCARD_PERMISSION` |
|
|
90
|
+
| `serv.WILDCARD_ROLE` | 显式全角色常量 | `none` | `string constant; does not authorize arbitrary requests` | `serv.WILDCARD_ROLE` |
|
|
91
|
+
|
|
92
|
+
- 一行启动:`serv.createApp(...)` → `serv.listen(app, { port })`,应用代码不需要 import Hono
|
|
93
|
+
- 扁平 API(最小知识):`serv.createApp / serv.implement / serv.listen / serv.toFetch / serv.generateSpec / ...`
|
|
94
|
+
- 链式 procedure:`implement().context().route().auth/permission/role().handle().build()`
|
|
95
|
+
- 自定义 pipeline:`createApp({ middlewares })` 挂 HTTP middleware;请求级字段通过 context 工厂注入
|
|
96
|
+
- 只读 Storage 资源:`serv.storageAssets(...)` 统一处理安全 key、MIME 白名单、GET/HEAD、ETag 与缓存头
|
|
97
|
+
- 默认 feature procedures:`createIamProcedures()`、`createStorageProcedures()`、`createAiProcedures()`
|
|
98
|
+
- `build()` 同步返回普通 router,可按嵌套属性选取默认 feature procedures;挂载的 contract 与所选 procedures 保持一致,未选入口不开放。
|
|
99
|
+
- 缺失路由返回本地化的 HaiResult JSON 404,启用传输加密后保留 404,不误报为非 JSON 响应导致的 500。
|
|
100
|
+
- 内置安全响应头、健康检查、可选 OpenAPI JSON、可选 Scalar 文档页
|
|
101
|
+
- 可选传输加密:`serv.createApp({ transport: { crypto, serverKeyRing, keyStore } })` 自动挂载密钥协商与请求/响应加解密
|
|
102
|
+
- 可选语音入口:`serv.createApp({ audio: { ai, verifyTicket, authorize } })` 挂载统一语音 WebSocket;连接建立即用一次性 ticket 鉴权(返回 `{ session, grant? }`,独立预鉴权超时),每帧运行时 Zod 校验 + 严格状态机,输入/积压/发送缓冲背压与级联取消;授权回调负责服务端确认操作、模型、音色、配额与并发占用
|
|
103
|
+
|
|
104
|
+
> 生命周期说明:`@h-ai/serv` 本身是无状态 HTTP App 装配器,不需要 `init()` / `close()`;请在创建 app 前初始化 `iam` / `storage` / `ai` / `crypto` 等依赖,并在 `serv.listen(..., { onClose })` 中反向释放它们。`serv.createApp()` 遇到启动期配置错误(例如 transport manager 创建失败)会 fail-fast 抛出,便于进程启动阶段暴露问题。 onClose 是 SIGINT/SIGTERM 的关闭回调;主动调用返回句柄的 close 关闭 HTTP 监听与语音连接,业务模块由宿主另行反序释放。
|
|
105
|
+
|
|
106
|
+
监听句柄可在启动后立即关闭,以取消尚未完成的绑定;重复关闭复用同一结果。地址占用等真实监听失败仍会拒绝关闭结果,不会当作正常退出。
|
|
107
|
+
|
|
108
|
+
### host / port 配置
|
|
84
109
|
|
|
85
110
|
host/port 统一由 `config/_serv.yml` 的 `server` 提供,优先级:`HOST` / `PORT` 环境变量 > 配置文件 > 默认 `127.0.0.1:3000`。默认 `127.0.0.1` 仅本机可达,容器对外暴露时改为 `0.0.0.0`:
|
|
86
111
|
|
|
@@ -93,7 +118,7 @@ server:
|
|
|
93
118
|
|
|
94
119
|
应用把解析后的 `server` 传给 `serv.listen`(`serv.listen` 也可直接传 `host` / `port` 显式覆盖)。
|
|
95
120
|
|
|
96
|
-
|
|
121
|
+
### 标准 fetch 签名适配
|
|
97
122
|
|
|
98
123
|
toFetch 提供标准 fetch 调用签名,适合 Node 兼容宿主与测试注入;包根仍依赖 Node API,不能据此承诺无需 Node 能力的 Workers/Deno/边缘部署。
|
|
99
124
|
|
|
@@ -102,11 +127,11 @@ const handler = serv.toFetch(app)
|
|
|
102
127
|
export default { fetch: handler }
|
|
103
128
|
```
|
|
104
129
|
|
|
105
|
-
Node `serv.listen()` 自动提供 socket 对端地址;默认忽略 `X-Forwarded-For` / `X-Real-IP`。只有 `http.trustedProxies` 中列出的代理 IP/CIDR 才能提供转发头,XFF 从右向左停在首个不可信地址,忽略其左侧内容;可信链无法解析或过长时 IP 为 undefined,不能回退为代理身份。代理必须清洗或追加转发头。Fetch 平台通过 `app.fetch(request, { remoteAddress: platformPeerAddress })` 显式提供可信对端,缺失时 IP 为 undefined,不能从任意请求头复制。自定义 `createContext(input)` 复用完整 input,保留框架已解析的 `input.ip
|
|
130
|
+
Node `serv.listen()` 自动提供 socket 对端地址;默认忽略 `X-Forwarded-For` / `X-Real-IP`。只有 `http.trustedProxies` 中列出的代理 IP/CIDR 才能提供转发头,XFF 从右向左停在首个不可信地址,忽略其左侧内容;可信链无法解析或过长时 IP 为 undefined,不能回退为代理身份。代理必须清洗或追加转发头。Fetch 平台通过 `app.fetch(request, { remoteAddress: platformPeerAddress })` 显式提供可信对端,缺失时 IP 为 undefined,不能从任意请求头复制。自定义 `createContext(input)` 复用完整 input,保留框架已解析的 `input.ip`。
|
|
106
131
|
|
|
107
132
|
toFetch 对 Request、URL、字符串统一调用标准 Request(input, init),RequestInit 可覆盖 method、headers、body、signal 等字段;不修改原 Request 的字段,正文已消费/锁定或非法正文组合会以 Promise 拒绝。预取消和派发前取消不进入 handler,进行中取消结束调用方等待并把有效 signal 传给应用;监听器在完成/失败/取消后释放,取消后的迟到响应会取消未消费正文。取消不能回滚已经开始的外部副作用;Response 交付后的流由应用监听 request.signal 并自行关闭。
|
|
108
133
|
|
|
109
|
-
|
|
134
|
+
### Audio WebSocket:ticket 与服务端授权
|
|
110
135
|
|
|
111
136
|
语音入口必须使用短期、一次性 ticket。应用通过已登录 HTTP endpoint 签发 ticket(推荐 `iam.ticket.issue`),并在 `verifyTicket` 中原子消费(推荐 `iam.ticket.consume`);连接建立即完成鉴权(独立预鉴权超时),未鉴权不处理任何业务帧。框架保存返回的 `ServSession` 与可选 `grant`,再调用 `authorize` 确认付费参数:
|
|
112
137
|
|
|
@@ -132,7 +157,7 @@ const app = serv.createApp({
|
|
|
132
157
|
- `onSessionEnd` 在成功、失败或断连后至多调用一次,供应用释放并发占用。
|
|
133
158
|
- 浏览器 `createAudioClient({ url, getTicket })` 只把一次性 `ticket` 放入 WebSocket URL;`getTicket(request)` 接收本次操作摘要(`operation` / `model`),可签发与本次操作严格绑定的票据;禁止传普通 IAM access token。
|
|
134
159
|
|
|
135
|
-
|
|
160
|
+
### 传输加密(与 `@h-ai/crypto` 统一)
|
|
136
161
|
|
|
137
162
|
serv 不暴露本地传输加密工厂;统一从 `crypto.transport` 创建服务端管理器。服务端显式注入顶层 `crypto`、受管密钥环和共享 registry:
|
|
138
163
|
|
|
@@ -199,14 +224,16 @@ const app = serv.createApp({
|
|
|
199
224
|
contract,
|
|
200
225
|
procedures,
|
|
201
226
|
http: servConfig.http,
|
|
202
|
-
middlewares: [
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
227
|
+
middlewares: [
|
|
228
|
+
{
|
|
229
|
+
middleware: serv.cors({
|
|
230
|
+
origin: servConfig.cors.origin,
|
|
231
|
+
credentials: servConfig.cors.credentials,
|
|
232
|
+
allowedHeaders: [...servConfig.cors.allowedHeaders],
|
|
233
|
+
exposedHeaders: [...servConfig.cors.exposedHeaders],
|
|
234
|
+
}),
|
|
235
|
+
}
|
|
236
|
+
],
|
|
210
237
|
transport: servConfig.transport === false
|
|
211
238
|
? undefined
|
|
212
239
|
: {
|
|
@@ -223,17 +250,15 @@ const app = serv.createApp({
|
|
|
223
250
|
|
|
224
251
|
> **安全策略**:启用 `transport` 后,除 `excludePaths` 与密钥协商端点外,业务请求必须携带有效 `X-Client-Id`。缺失或未知 clientId 会返回 400;响应体无法加密、非 JSON 业务响应或超过单次加密上限时返回错误,不会明文透传业务数据。
|
|
225
252
|
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
空 POST 也发送空明文的认证封装;GET/HEAD 将封装放在 X-Hai-Transport 头,204/304/HEAD 等无正文响应也使用同名头承载认证封装。其它请求/响应在 JSON 正文承载封装并带 X-Encrypted: true。客户端必须关联原请求上下文和 HTTP status,缺标记、旧格式或认证失败一律 reject;明文接口由 Serv/Kit 路径规则 `encryption: disabled` 或宿主 `excludePaths` 显式允许;调用方同步选择普通请求,API Client/Kit 包装器使用自己的 `excludePaths`。CORS 须允许并暴露 X-Hai-Transport。握手公钥的真实性依赖 TLS,不替代 IAM 业务授权。
|
|
253
|
+
传输使用认证 v2 封装,先验签和 MAC 再解密,并绑定方法、路径、时间与响应状态。共享 registry 原子消费 nonce;TLS、可信代理、业务授权及幂等仍由宿主负责。自定义跨域入口须允许并暴露 `X-Hai-Transport`。完整字段、无正文响应、时钟窗口和防重放要求统一见 [Transport 协议](../crypto/TRANSPORT-PROTOCOL.md)。
|
|
229
254
|
|
|
230
255
|
业务容量通过 `transport.maxPlaintextBytes` 统一配置:默认 1 MiB,整数范围 1 KiB~64 MiB,请求和响应共用 UTF-8 明文字节预算。客户端从握手自动获取上限,密文与部署代理预算由 `resolveTransportBodyLimits()` 推导(两倍明文 + 32 KiB)。握手、公钥、认证头和防重放约束独立;超限取消原流并释放 reader,请求 413、响应为加密的 500 容量错误,客户端识别 `TransportBodyTooLargeError` / `hai:crypto:070`。 [Transport capacity and upgrade](../crypto/TRANSPORT-CAPACITY.md)。
|
|
231
256
|
|
|
232
257
|
普通业务仍使用 `transport.maxPlaintextBytes`(auto/省略为 1 MiB);Feedback 的报告受理、证据、附件及 ZIP 下载路由通过 `routePlaintextBytes` 单独声明 `feedback.budget.maxTransportBytes`。路径中的 `*` 仅匹配一个完整路径段;宿主显式路由值优先。握手、adapter-node 与代理使用各路由最大容量,普通业务不会随材料预算扩大。HTTP 适配器为单独声明且大于 1 MiB 的材料路由保留最多 2 个槽,普通业务另有 16 个槽,完整响应/失败后释放;忙碌返回 503 / `hai:crypto:072`,不自动重放业务写入。
|
|
233
258
|
|
|
234
|
-
|
|
259
|
+
Serv/Kit 对通过请求绑定、签名和 MAC 校验但超出时间窗口的请求返回加密的 HTTP 400 错误。客户端抛出 `TransportTimeError`(`hai:crypto:071`),显示校验时的服务器 UTC 时间,提示开启设备的自动校时或手工修改时间后刷新。`serverTime` 为 Unix 毫秒,旧服务端未提供该值时仍显示校时提示。`api-client` 映射为 `VALIDATION_FAILED`,在 `cause.transportCode` 和 `cause.serverTime` 保留诊断信息。
|
|
235
260
|
|
|
236
|
-
|
|
261
|
+
### Token 认证(`iam` / `refreshCookie`)
|
|
237
262
|
|
|
238
263
|
`serv.createApp` 推荐直接传入顶层 `iam` 句柄,自动派生两件事:
|
|
239
264
|
|
|
@@ -277,7 +302,7 @@ const app = serv.createApp({
|
|
|
277
302
|
|
|
278
303
|
标准登出入口 `auth.logout` 从 input.accessToken 或 Authorization Bearer 取得要撤销的完整令牌,不要求 access 仍有效。它不接受 userId 授权或撤销目标;缺少令牌返回失败,未知令牌幂等成功。共享存储故障原样保留失败 HaiResult,Cookie middleware 只对成功结果清理 refresh Cookie。普通 procedure 继续使用既有 HTTP 200 / HaiResult 协议,客户端必须检查 success,不能只检查状态码。
|
|
279
304
|
|
|
280
|
-
|
|
305
|
+
### 自定义 procedure(认证 + 权限)
|
|
281
306
|
|
|
282
307
|
```ts
|
|
283
308
|
const procedures = serv
|
|
@@ -291,7 +316,7 @@ const procedures = serv
|
|
|
291
316
|
.build()
|
|
292
317
|
```
|
|
293
318
|
|
|
294
|
-
|
|
319
|
+
### 高级:自定义 pipeline(HTTP middleware + context + route guard)
|
|
295
320
|
|
|
296
321
|
`@h-ai/serv` 的“pipeline”分三层,分别对应三种扩展方式:
|
|
297
322
|
|
|
@@ -299,7 +324,7 @@ const procedures = serv
|
|
|
299
324
|
2. **context 层**:通过 `verifyToken` / `createContext` / `serv.buildAuthContextFactory()` 定制请求上下文。
|
|
300
325
|
3. **route guard 层**:通过 `.auth()` / `.permission()` / `.role()` 声明认证授权。
|
|
301
326
|
|
|
302
|
-
|
|
327
|
+
#### 1) 自定义 HTTP middleware
|
|
303
328
|
|
|
304
329
|
当你需要加请求日志、trace、限流、CORS、租户头校验等 **HTTP 层横切逻辑**,或挂载不适合进入 JSON/oRPC contract 的 WebSocket、文件和二进制端点时,使用 `middlewares`。常见 CORS 场景可直接复用 `serv.cors(...)`:
|
|
305
330
|
|
|
@@ -355,7 +380,7 @@ const app = serv.createApp({
|
|
|
355
380
|
2. 内置 CORS 响应包装;密钥交换和预检先检查访问策略
|
|
356
381
|
3. 内置 `transport`(若启用)及每次请求访问策略
|
|
357
382
|
4. 你的 `middlewares`
|
|
358
|
-
5. health / refresh-cookie / OpenAPI /
|
|
383
|
+
5. health / refresh-cookie / OpenAPI / docs / oRPC routes
|
|
359
384
|
|
|
360
385
|
说明:
|
|
361
386
|
|
|
@@ -368,7 +393,7 @@ const app = serv.createApp({
|
|
|
368
393
|
- 浏览器若需读取自定义响应头(例如 transport 的 `X-Encrypted`),请通过 `serv.cors({ exposedHeaders: [...] })` 显式暴露
|
|
369
394
|
- 这一层直接操作 HTTP middleware context,返回的是 **HTTP Response**,不是 `HaiResult`
|
|
370
395
|
|
|
371
|
-
|
|
396
|
+
#### 2) 自定义 context pipeline
|
|
372
397
|
|
|
373
398
|
如果你只想复用 `Bearer token -> session` 这一段逻辑,再额外补充租户、工作区、来源系统等字段,
|
|
374
399
|
优先用 `serv.buildAuthContextFactory()` 组装自定义 `createContext`:
|
|
@@ -395,7 +420,7 @@ const app = serv.createApp({
|
|
|
395
420
|
- 想在默认认证上下文之上追加字段:用 `buildAuthContextFactory(...) + createContext`
|
|
396
421
|
- 想完全接管上下文构造:直接用 `createContext`
|
|
397
422
|
|
|
398
|
-
|
|
423
|
+
#### 3) Route guard
|
|
399
424
|
|
|
400
425
|
公开 procedure 使用 `.route(path, handler)`;需要 guard 时使用
|
|
401
426
|
`.route(path).auth/permission/role().handle(handler)`:
|
|
@@ -419,6 +444,7 @@ const procedures = serv
|
|
|
419
444
|
|
|
420
445
|
- `.permission()` 与 `.role()` 自动要求认证;未登录/授权不足返回失败 HaiResult,`error.httpStatus` 分别为 401/403;普通 procedure 的 HTTP 响应默认仍为 200。
|
|
421
446
|
- 同一路由声明多个 permission/role 时必须全部满足;`serv.WILDCARD_PERMISSION` / `serv.WILDCARD_ROLE` 可放行对应类别。
|
|
447
|
+
- 权限还支持 IAM/Kit 一致的冒号前缀授权:`users:*` 匹配 `users:read`,不匹配 `users2:read` 或点号权限。
|
|
422
448
|
- handler 抛出的未处理异常统一转成 INTERNAL_ERROR HaiResult;预期业务失败仍应直接返回 `err(...)`。
|
|
423
449
|
- 路径、input、output 从 contract 推导;重复或未知路径会被拒绝,未实现完整 contract 时不能调用 `.build()`。
|
|
424
450
|
|
|
@@ -426,9 +452,9 @@ const procedures = serv
|
|
|
426
452
|
|
|
427
453
|
- `src/pipelines/*` 是 `@h-ai/serv` 的内部默认实现目录,不作为公开子路径 API 承诺
|
|
428
454
|
- 对应用层公开的稳定入口仍然是根入口 `@h-ai/serv`
|
|
429
|
-
- `securityHeaders` / `
|
|
455
|
+
- `securityHeaders` / `buildHaiErrorBody` 等内部 helper/middleware 不作为单独子路径文档暴露
|
|
430
456
|
|
|
431
|
-
|
|
457
|
+
### httpOnly Cookie 认证(推荐生产方案)
|
|
432
458
|
|
|
433
459
|
httpOnly cookie 模式将 refresh token 存储在服务端管理的 cookie 中(浏览器 JS 无法读取),避免 XSS 风险。
|
|
434
460
|
|
|
@@ -485,13 +511,16 @@ await apiClient.init({
|
|
|
485
511
|
})
|
|
486
512
|
```
|
|
487
513
|
|
|
488
|
-
>
|
|
514
|
+
> 刷新 Cookie 带有 `HttpOnly` 和 `SameSite=Strict`,路径限制为 `{apiPrefix}/auth/refresh`。生产默认还会设置 `Secure`。
|
|
515
|
+
|
|
516
|
+
刷新失败也经过统一公开错误投影:5xx 和缺失或非法状态返回安全提示与 traceId,内部原因只写入服务端日志。只有 401 认证失效会清除刷新 Cookie;限流和后端故障保留凭据,允许稍后重试。自动审计使用同一归一化状态记录失败。
|
|
489
517
|
|
|
490
518
|
### API 概览
|
|
491
519
|
|
|
492
|
-
- `serv.createApp(options)`:创建 HTTP App 抽象,挂载自定义 `middlewares`、健康检查、OpenAPI handler
|
|
520
|
+
- `serv.createApp(options)`:创建 HTTP App 抽象,挂载自定义 `middlewares`、健康检查、OpenAPI handler 与可选文档
|
|
493
521
|
- `serv.parseRequestContext({ request })`:默认上下文解析(提取 Bearer token + requestId,不填充 session)
|
|
494
522
|
- `serv.listen(app, options)`:在 Node.js 启动 HTTP 服务,返回 `{ server, address, close }`
|
|
523
|
+
返回句柄的 `close()` 可重复调用:停止接入并关闭 Audio WebSocket,1 秒后强制终止未配合的音频连接,随后等待 HTTP 关闭。主动关闭也移除进程信号监听器;业务模块仍由宿主反序释放。
|
|
495
524
|
- `serv.toFetch(app)`:包装为标准 `fetch(Request)` handler
|
|
496
525
|
- `serv.generateSpec(contract, options)`:由 contract 生成 OpenAPI 3.1 spec
|
|
497
526
|
- `serv.storageAssets(config)`:创建只读 Storage 资源 middleware,内置 key/MIME 白名单、GET/HEAD 与 ETag
|
|
@@ -524,7 +553,81 @@ await apiClient.init({
|
|
|
524
553
|
|
|
525
554
|
`http.trustedProxies` 默认 `auto`:普通宿主忽略转发头;受管 Compose 的私有代理拓扑才自动采用容器网段。自管代理指定真实 IP/CIDR,`[]` 显式关闭。代理必须清洗转发头,Node 监听器或 Fetch 平台必须提供可信对端元数据。独立文件、外层代理和部署回滚步骤见[请求访问控制手册](../../docs/usage/configuration/request-access.md)。
|
|
526
555
|
|
|
527
|
-
###
|
|
556
|
+
### 配置驱动认证页面
|
|
557
|
+
|
|
558
|
+
协议支持配置中的 userAgreementMarkdown/privacyPolicyMarkdown 正文及 URL;同主题优先 Markdown,由安全渲染器在弹窗显示。
|
|
559
|
+
|
|
560
|
+
`createIamProcedures({ iam })` 直接实现公开配置、密码/LDAP/OTP、注册与密码重置,无需应用复制 procedure。LDAP 登录沿用现有浏览器 httpOnly refresh Cookie 和可信原生 Token 传输。管理端创建调用 iam.user.createUser,自助注册关闭时管理功能仍可用;授权仍由既有 permission guard 执行。
|
|
561
|
+
|
|
562
|
+
[完整 IAM 配置 / IAM configuration](../iam/README.md)
|
|
563
|
+
|
|
564
|
+
### 请求语言隔离
|
|
565
|
+
|
|
566
|
+
Node 请求与 SSR 使用 `core.i18n.runWithLocale(locale, () => handleRequest())`;SvelteKit 可使用 `kit.i18n.runWithLocale(locale, () => resolve(event))`,serv 自动按请求头建立上下文。异步子任务继承请求语言,优先级为显式 `MessageOptions.locale`、请求语言、进程启动默认语言。`setGlobalLocale` 在请求上下文中只更新当前请求;上下文外仅用于启动默认值,不能在请求 hook 中调用它修改默认值。
|
|
567
|
+
|
|
568
|
+
浏览器继续使用应用级 `setGlobalLocale` / `createLocaleStore`。SSR 的 UI store 初始化和 setter 只修改自身状态,不写全局语言;UI 消息从请求上下文读取。`runWithLocale` 仅支持 Node 入口,浏览器通过显式 locale 或应用状态翻译。
|
|
569
|
+
|
|
570
|
+
### 输入约束与 HTTP 能力
|
|
571
|
+
|
|
572
|
+
存储 key 按 UTF-8 最多 1024 字节,并拒绝绝对路径、反斜线、控制字符及点路径段;公开 HTTP 批量删除每次 1–1000 项。`@h-ai/storage/schemas` 提供浏览器安全的共同规则。预签名有效期为整数 1–604800 秒(Node 默认 3600,HTTP 默认 900);HTTP 上传仅接受 key/contentType/expiresIn,不支持 contentLength/maxSize/metadata/checksum。无效输入返回 HTTP 400,typed client 映射 VALIDATION_FAILED。FileUpload 的大小/类型限制仅为前端体验,实际对象须由应用服务端复核。Node 流式读写/checksum/条件写等没有默认 HTTP 端点。完整映射与授权边界见 [存储 API 能力](../storage/README.md#存储输入约束与跨端能力)。
|
|
573
|
+
|
|
574
|
+
### 可选的反馈服务与可信范围
|
|
575
|
+
|
|
576
|
+
通过 `@h-ai/serv/features/feedback` 的 `createFeedbackProcedures` 挂载 `apiContract.feedback`;只导入运行时根入口不会加载反馈服务。宿主先初始化 reldb/storage/reach/feedback,再从已验证的会话与部署策略映射反馈范围。resolveContext 返回 null 或用户不匹配时拒绝访问;领域模块再次校验范围与权限。
|
|
577
|
+
|
|
578
|
+
所有 HTTP 响应(包括探活、404、中间件短路)携带有界 X-Request-Id。普通业务日志需由已有 createContext 的可选 scopeId 建立可信范围,scopeId 从不读取客户端头。HTTP 入口清理旧身份,认证后的异步业务日志自动继承当前请求 ID 与用户。
|
|
579
|
+
|
|
580
|
+
```ts
|
|
581
|
+
import { apiContract } from '@h-ai/api-contract'
|
|
582
|
+
import { feedback } from '@h-ai/feedback'
|
|
583
|
+
import { iam } from '@h-ai/iam'
|
|
584
|
+
import { serv } from '@h-ai/serv'
|
|
585
|
+
import { createFeedbackProcedures } from '@h-ai/serv/features/feedback'
|
|
586
|
+
|
|
587
|
+
const contract = apiContract.create({ feedback: apiContract.feedback })
|
|
588
|
+
const authenticate = serv.buildAuthContextFactory(token => iam.auth.verifyToken(token))
|
|
589
|
+
const procedures = {
|
|
590
|
+
feedback: createFeedbackProcedures({
|
|
591
|
+
feedback,
|
|
592
|
+
resolveContext: context => context.scopeId
|
|
593
|
+
? { scopeId: context.scopeId, userId: context.session.userId, permissions: context.session.permissions }
|
|
594
|
+
: null,
|
|
595
|
+
}),
|
|
596
|
+
}
|
|
597
|
+
const app = serv.createApp({
|
|
598
|
+
contract,
|
|
599
|
+
procedures,
|
|
600
|
+
createContext: async input => ({
|
|
601
|
+
...await authenticate(input),
|
|
602
|
+
scopeId: 'configured-product'
|
|
603
|
+
}),
|
|
604
|
+
})
|
|
605
|
+
```
|
|
606
|
+
|
|
607
|
+
### 可选交互挑战
|
|
608
|
+
|
|
609
|
+
标准 IAM procedure 包含 POST /auth/captcha,输入 operation,返回可公开的 ALTCHA 挑战。开启 IAM captcha 时密码/OTP/LDAP、注册、通知请求及匿名原密码续期均由 IAM 强制检查单用证明;请求映射保留 captcha,不依赖菜单或客户端判断。
|
|
610
|
+
|
|
611
|
+
策略、默认值和恢复边界见 [IAM](../iam/README.md)。
|
|
612
|
+
|
|
613
|
+
### 页面离开会话策略
|
|
614
|
+
|
|
615
|
+
`session.endOnPageHide` 默认 `false`。启用后宿主调用 `actions.bindPageExit({ enabled: config.session.endOnPageHide })` 并在卸载时调用返回的清理函数;标准 admin/H5/fullstack 模板已经接入。非 BFCache 的 `pagehide`(也包括刷新、整页导航)通过 POST `/auth/session/close` 尽力撤销当前 access 及关联 refresh,沿用 CSRF/传输加密,使用 `keepalive` 且不自动刷新会话。该响应不清 Cookie/本地凭据,避免旧响应覆盖下一次登录。显式退出仍使用正常 logout,可靠撤销并清理当前凭据。浏览器可能不发送页面离开请求,因此必须按业务配合服务端 `idleTimeout`/`absoluteTimeout`;不能据此保证关闭即撤销。
|
|
616
|
+
|
|
617
|
+
## 配置
|
|
618
|
+
|
|
619
|
+
### 按场景选择配置入口
|
|
620
|
+
|
|
621
|
+
| 配置入口 | 场景 | 输入 | 生效结果 | 示例 |
|
|
622
|
+
| ------------------------------------------------------ | ---------------- | ------------------------------------------------------------------------ | -------------------------------------------------------------------- | -------------------------------------------------------------- |
|
|
623
|
+
| `server` | Node 监听地址 | host/port; HOST and PORT take precedence | options passed to serv.listen; default 127.0.0.1:3000 | `server: { host: 127.0.0.1, port: 3000 }` |
|
|
624
|
+
| `http.apiPrefix` | 业务路径前缀 | absolute validated prefix, default /api/v1 | OpenAPI/REST mounting prefix | `http: { apiPrefix: /api/v1 }` |
|
|
625
|
+
| `http.health` | 存活与就绪检查 | path, readyPath | health/readiness routes | `health: { path: /health, readyPath: /ready }` |
|
|
626
|
+
| `http.openapi / http.docs` | 接口说明 | false or explicit path/authorization settings | spec/Scalar routes; production exposure controlled by host policy | `openapi: false` |
|
|
627
|
+
| `http.trustedProxies / http.access` | 入口可信边界 | proxy list/auto and access policy | trusted address and per-request entry policy | `trustedProxies: []` |
|
|
628
|
+
| `cors` | 跨端来源与请求头 | origin/nativeOrigins/credentials/allowedHeaders/exposedHeaders | serv.cors middleware input; explicit production allowlist | `cors: { origin: https://app.example.com, credentials: true }` |
|
|
629
|
+
| `transport` | 双向认证加密 | false or public paths/limits; serverKeyRing/keyStore injected separately | handshake and authenticated request/response handling | `transport: { maxPlaintextBytes: auto }` |
|
|
630
|
+
| `createApp.iam / refreshCookie / nativeTokenTransport` | 宿主认证装配 | initialized IAM, secure Cookie policy and native transport rule | trusted session/token channel; frontend receives only allowed fields | `serv.createApp({ contract, procedures, iam })` |
|
|
528
631
|
|
|
529
632
|
`@h-ai/serv` 本身不主动扫描 YAML;推荐由 `@h-ai/core` 统一加载 `config/_serv.yml`,再把解析结果传给 `serv.createApp()`。
|
|
530
633
|
|
|
@@ -574,7 +677,6 @@ http:
|
|
|
574
677
|
health:
|
|
575
678
|
path: /health
|
|
576
679
|
readyPath: /ready
|
|
577
|
-
rpc: false
|
|
578
680
|
|
|
579
681
|
cors:
|
|
580
682
|
origin: https://app.example.com
|
|
@@ -601,11 +703,10 @@ transport:
|
|
|
601
703
|
|
|
602
704
|
`ServHttpConfigInput`:
|
|
603
705
|
|
|
604
|
-
- `apiPrefix`:默认 `/api/v1`
|
|
706
|
+
- `apiPrefix`:默认 `/api/v1`;支持 `/somepath/api/v1` 等完整绝对前缀。路径段只含字母、数字、下划线、连字符和点,不接受末尾斜杠、空段、单独的 `.`/`..`、查询或片段。健康、文档和存储公开路径分别配置到同一目录。
|
|
605
707
|
- `health`:默认 `{ path: '/health', readyPath: '/ready' }`
|
|
606
708
|
- `openapi`:默认 `false`;启用:`{ path: '/openapi.json' }`
|
|
607
|
-
- `docs`:默认 `false`;启用:`{ path: '/docs' }
|
|
608
|
-
- `rpc`:默认 `false`;启用:`{ prefix: '/rpc', access: 'loopback' | 'private-network' | 'gateway-only', gatewayHeader?, gatewaySecret? }`
|
|
709
|
+
- `docs`:默认 `false`;启用:`{ path: '/docs' }`;文档目录下自动挂载 `_hai/scalar.js`(默认 `/_hai/scalar.js`),提供本地 Scalar UI,无需外网 CDN。页面中的脚本和 spec 使用相对地址;网关保留前缀时 docs/openapi 都应配置在转发范围内,剥离前缀时仍可用后端原路径。`requireAuth` 仍由后端校验。
|
|
609
710
|
- `cors`:配置文件中的跨端 origin、credentials、allowedHeaders 与 exposedHeaders;应用通过 `serv.cors(...)` 装配。
|
|
610
711
|
- `transport`:`config/_serv.yml` 顶层配置;启用后可通过 `servConfig.transport` 装配到 `createApp({ transport })`,默认密钥协商子路径 `/_hai/key-exchange`
|
|
611
712
|
|
|
@@ -626,33 +727,34 @@ const app = serv.createApp({
|
|
|
626
727
|
openapi: security.exposeApiDocs ? servConfig.http.openapi : false,
|
|
627
728
|
docs: security.exposeApiDocs ? servConfig.http.docs : false,
|
|
628
729
|
},
|
|
730
|
+
iam,
|
|
629
731
|
refreshCookie: { secure: security.secureRefreshCookie },
|
|
630
|
-
middlewares: [
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
732
|
+
middlewares: [
|
|
733
|
+
{
|
|
734
|
+
middleware: serv.cors({
|
|
735
|
+
origin: security.allowOrigin,
|
|
736
|
+
credentials: servConfig.cors.credentials,
|
|
737
|
+
allowedHeaders: [...servConfig.cors.allowedHeaders],
|
|
738
|
+
exposedHeaders: [...servConfig.cors.exposedHeaders],
|
|
739
|
+
}),
|
|
740
|
+
}
|
|
741
|
+
],
|
|
638
742
|
})
|
|
639
743
|
```
|
|
640
744
|
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
### 配置驱动认证页面
|
|
745
|
+
生产环境要求 `_serv.yml` 的 `cors.origin` 显式列出允许的来源,并关闭 OpenAPI 和 docs。Web 与 Capacitor/Tauri Origin 按规范化后的完整值精确匹配。
|
|
644
746
|
|
|
645
|
-
|
|
747
|
+
受控内网或 VPN 的生产 HTTP 入口可以在后端进程环境设置 `HAI_SERV_REFRESH_COOKIE_SECURE=false`。重新启动后端并登录后,登录和刷新响应会下发省略 `Secure` 的 Cookie,刷新凭据会经 HTTP 明文传输。设置 `true` 可强制启用 `Secure`,留空或其他值沿用环境默认值。直接调用 `serv.createApp` 时,显式 `refreshCookie.secure` 优先,其次是此变量,再按 `NODE_ENV` 或 `HAI_CORE_ENV` 判断是否为生产。
|
|
646
748
|
|
|
647
|
-
|
|
749
|
+
### 传输容量与模块预算
|
|
648
750
|
|
|
649
|
-
|
|
751
|
+
配置 `transport.maxPlaintextBytes` 支持 1 KiB~64 MiB 的显式整数或 `auto`。auto/省略在解析后为 undefined:无模块需求时仍为 1 MiB,反馈宿主使用 crypto.resolveTransportBodyLimits 的 requiredPlaintextBytes 统一推导。显式上限不足时启动/打包失败,不能被反馈覆盖;代理、adapter-node、握手与运行时使用一致预算。业务附件、解压和数据行数上限继续独立校验。
|
|
650
752
|
|
|
651
|
-
|
|
753
|
+
## 错误处理
|
|
652
754
|
|
|
653
755
|
普通 procedure 的认证、授权、业务失败和被捕获的 handler 异常都作为 `HaiResult` 输出,默认通过 HTTP 200 返回;`error.httpStatus` 是领域错误元数据,不会自动改写响应状态。客户端必须检查 `success` 与 `error.code`。例如未登录为 `error.httpStatus=401`,数据库查询失败或 INTERNAL_ERROR 为 500。
|
|
654
756
|
|
|
655
|
-
oRPC contract 输入校验在 procedure 之前失败时返回 HTTP 400,本地化失败体包含 `error.httpStatus=400` 和字段错误;procedure 内 `serv.validateInputOrFail` 返回的失败仍为 HTTP 200。HTTP middleware、refresh-cookie、OpenAPI
|
|
757
|
+
oRPC contract 输入校验在 procedure 之前失败时返回 HTTP 400,本地化失败体包含 `error.httpStatus=400` 和字段错误;procedure 内 `serv.validateInputOrFail` 返回的失败仍为 HTTP 200。HTTP middleware、refresh-cookie、OpenAPI 权限与 transport 协议错误另按自身契约返回真实 HTTP 状态。api-client 在真实 HTTP 401 或 HTTP 200 的有效失败 HaiResult(`success=false`、字符串 `error.code/message`、`error.httpStatus=401`)后自动刷新,成功后只重试一次;内置登录、注册和配置的刷新路径除外,403/500 不刷新。启用 transport 时刷新和重试复用加密链路。
|
|
656
758
|
|
|
657
759
|
- Default procedures 全部返回 `HaiResult<T>`,客户端直接判断 `result.success`
|
|
658
760
|
- 认证失败 → `HaiCommonError.UNAUTHORIZED`(由 `.auth()` 按 `context.locale` 本地化)
|
|
@@ -697,49 +799,9 @@ const procedures = serv
|
|
|
697
799
|
.build()
|
|
698
800
|
```
|
|
699
801
|
|
|
700
|
-
>
|
|
701
|
-
|
|
702
|
-
### 请求语言隔离
|
|
703
|
-
|
|
704
|
-
Node 请求与 SSR 使用 `core.i18n.runWithLocale(locale, () => handleRequest())`;SvelteKit 可使用 `kit.i18n.runWithLocale(locale, () => resolve(event))`,serv 自动按请求头建立上下文。异步子任务继承请求语言,优先级为显式 `MessageOptions.locale`、请求语言、进程启动默认语言。`setGlobalLocale` 在请求上下文中只更新当前请求;上下文外仅用于启动默认值,不能在请求 hook 中调用它修改默认值。
|
|
705
|
-
|
|
706
|
-
浏览器继续使用应用级 `setGlobalLocale` / `createLocaleStore`。SSR 的 UI store 初始化和 setter 只修改自身状态,不写全局语言;UI 消息从请求上下文读取。`runWithLocale` 仅支持 Node 入口,浏览器通过显式 locale 或应用状态翻译。
|
|
707
|
-
|
|
708
|
-
## 输入约束与 HTTP 能力
|
|
709
|
-
|
|
710
|
-
存储 key 按 UTF-8 最多 1024 字节,并拒绝绝对路径、反斜线、控制字符及点路径段;公开 HTTP 批量删除每次 1–1000 项。`@h-ai/storage/schemas` 提供浏览器安全的共同规则。预签名有效期为整数 1–604800 秒(Node 默认 3600,HTTP 默认 900);HTTP 上传仅接受 key/contentType/expiresIn,不支持 contentLength/maxSize/metadata/checksum。无效输入返回 HTTP 400,typed client 映射 VALIDATION_FAILED。FileUpload 的大小/类型限制仅为前端体验,实际对象须由应用服务端复核。Node 流式读写/checksum/条件写等没有默认 HTTP 端点。完整映射与授权边界见 [存储 API 能力](../storage/README.md#存储输入约束与跨端能力)。
|
|
711
|
-
|
|
712
|
-
## 可选的反馈服务与可信范围
|
|
802
|
+
> 下游 `HaiResult` 的 4xx 消息经脱敏后公开;5xx 和缺失或非法状态统一为安全内部错误,保留 traceId,完整原因只记录到服务端日志。`HaiError` 不携带 `messageKey/params`,领域消息的请求级 i18n 仍由创建消息的模块接收 locale 后完成。
|
|
713
803
|
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
所有 HTTP 响应(包括探活、404、中间件短路)携带有界 X-Request-Id。普通业务日志需由已有 createContext 的可选 scopeId 建立可信范围,scopeId 从不读取客户端头。HTTP 入口清理旧身份,认证后的异步业务日志自动继承当前请求 ID 与用户。
|
|
717
|
-
|
|
718
|
-
```ts
|
|
719
|
-
import { apiContract } from '@h-ai/api-contract'
|
|
720
|
-
import { feedback } from '@h-ai/feedback'
|
|
721
|
-
import { iam } from '@h-ai/iam'
|
|
722
|
-
import { serv } from '@h-ai/serv'
|
|
723
|
-
import { createFeedbackProcedures } from '@h-ai/serv/features/feedback'
|
|
724
|
-
|
|
725
|
-
const contract = apiContract.create({ feedback: apiContract.feedback })
|
|
726
|
-
const authenticate = serv.buildAuthContextFactory(token => iam.auth.verifyToken(token))
|
|
727
|
-
const procedures = {
|
|
728
|
-
feedback: createFeedbackProcedures({
|
|
729
|
-
feedback,
|
|
730
|
-
resolveContext: context => context.scopeId
|
|
731
|
-
? { scopeId: context.scopeId, userId: context.session.userId, permissions: context.session.permissions }
|
|
732
|
-
: null,
|
|
733
|
-
}),
|
|
734
|
-
}
|
|
735
|
-
const app = serv.createApp({
|
|
736
|
-
contract,
|
|
737
|
-
procedures,
|
|
738
|
-
createContext: async input => ({ ...await authenticate(input), scopeId: 'configured-product' }),
|
|
739
|
-
})
|
|
740
|
-
```
|
|
741
|
-
|
|
742
|
-
## 错误提示与关联日志
|
|
804
|
+
### 错误提示与关联日志
|
|
743
805
|
|
|
744
806
|
原生 Audio WebSocket 同样使用安全错误边界:内部 Provider/SDK 错误与堆栈只进入脱敏日志,客户端收到稳定错误码、安全提示和 traceId。自定义跨域配置需保留敏感操作使用的 X-Hai-Reauthentication;默认 Serv/Kit 已允许。
|
|
745
807
|
|
|
@@ -749,20 +811,6 @@ HTTP 错误返回本地化安全文案及追踪编号;ToastContainer 默认隐
|
|
|
749
811
|
|
|
750
812
|
audit 已初始化时,procedure 自动绑定经过验证的身份、可信连接地址与请求/范围编号,业务 audit.log 继承此上下文。写操作及失败/拒绝记录 request.execute;契约校验、协议、中间件和缺失路由的 HTTP 拒绝记录 http.reject。入口只记录操作、方法、状态和原因码,不复制请求体、动态 URL、令牌或错误详情。capture.enabled 可关闭自动采集,capture.includeReads 可开启成功读取;显式业务 log 不受影响。业务语义仍须应用在完成/失败边界记录。
|
|
751
813
|
|
|
752
|
-
## 可选交互挑战
|
|
753
|
-
|
|
754
|
-
标准 IAM procedure 包含 POST /auth/captcha,输入 operation,返回可公开的 ALTCHA 挑战。开启 IAM captcha 时密码/OTP/LDAP、注册、通知请求及匿名原密码续期均由 IAM 强制检查单用证明;请求映射保留 captcha,不依赖菜单或客户端判断。
|
|
755
|
-
|
|
756
|
-
策略、默认值和恢复边界见 [IAM](../iam/README.md)。
|
|
757
|
-
|
|
758
|
-
## 页面离开会话策略
|
|
759
|
-
|
|
760
|
-
`session.endOnPageHide` 默认 `false`。启用后宿主调用 `actions.bindPageExit({ enabled: config.session.endOnPageHide })` 并在卸载时调用返回的清理函数;标准 admin/H5/fullstack 模板已经接入。非 BFCache 的 `pagehide`(也包括刷新、整页导航)通过 POST `/auth/session/close` 尽力撤销当前 access 及关联 refresh,沿用 CSRF/传输加密,使用 `keepalive` 且不自动刷新会话。该响应不清 Cookie/本地凭据,避免旧响应覆盖下一次登录。显式退出仍使用正常 logout,可靠撤销并清理当前凭据。浏览器可能不发送页面离开请求,因此必须按业务配合服务端 `idleTimeout`/`absoluteTimeout`;不能据此保证关闭即撤销。
|
|
761
|
-
|
|
762
|
-
## 传输容量与模块预算
|
|
763
|
-
|
|
764
|
-
配置 `transport.maxPlaintextBytes` 支持 1 KiB~64 MiB 的显式整数或 `auto`。auto/省略在解析后为 undefined:无模块需求时仍为 1 MiB,反馈宿主使用 crypto.resolveTransportBodyLimits 的 requiredPlaintextBytes 统一推导。显式上限不足时启动/打包失败,不能被反馈覆盖;代理、adapter-node、握手与运行时使用一致预算。业务附件、解压和数据行数上限继续独立校验。
|
|
765
|
-
|
|
766
814
|
## 开发测试
|
|
767
815
|
|
|
768
816
|
```bash
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { normalizeServLocale, servM } from './chunk-
|
|
1
|
+
import { normalizeServLocale, servM } from './chunk-PLGCHDZM.js';
|
|
2
2
|
import { core, ok, err, HaiCommonError } from '@h-ai/core';
|
|
3
3
|
|
|
4
4
|
var DEFAULT_LOCALE = "zh-CN";
|
|
@@ -33,5 +33,5 @@ function validateInputOrFail(schema, input, locale = DEFAULT_LOCALE) {
|
|
|
33
33
|
}
|
|
34
34
|
|
|
35
35
|
export { localizeZodError, resolveRequestLocale, validateInputOrFail };
|
|
36
|
-
//# sourceMappingURL=chunk-
|
|
37
|
-
//# sourceMappingURL=chunk-
|
|
36
|
+
//# sourceMappingURL=chunk-CZ6VA2EN.js.map
|
|
37
|
+
//# sourceMappingURL=chunk-CZ6VA2EN.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/serv-validation.ts"],"names":[],"mappings":";;;AAiBA,IAAM,cAAA,GAAiB,OAAA;AAmDhB,SAAS,qBAAqB,OAAA,EAA+D;AAClG,EAAA,MAAM,GAAA,GAAM,CAAC,IAAA,KAAqC;AAChD,IAAA,IAAI,OAAA,YAAmB,OAAA;AACrB,MAAA,OAAO,OAAA,CAAQ,GAAA,CAAI,IAAI,CAAA,IAAK,MAAA;AAC9B,IAAA,OAAO,QAAQ,IAAI,CAAA,IAAK,OAAA,CAAQ,IAAA,CAAK,aAAa,CAAA;AAAA,EACpD,CAAA;AAEA,EAAA,MAAM,QAAA,GAAW,IAAI,cAAc,CAAA;AACnC,EAAA,IAAI,QAAA;AACF,IAAA,OAAO,oBAAoB,QAAQ,CAAA;AAErC,EAAA,OAAO,oBAAoB,GAAA,CAAI,iBAAiB,KAAK,GAAA,CAAI,iBAAiB,KAAK,cAAc,CAAA;AAC/F;AAiBO,SAAS,gBAAA,CAAiB,UAAmB,MAAA,EAAuC;AACzF,EAAA,MAAM,cAAA,GAAiB,oBAAoB,MAAM,CAAA;AACjD,EAAA,OAAO,KAAK,aAAA,CAAc,uBAAA;AAAA,IACxB,QAAA;AAAA,IACA,KAAK,aAAA,CAAc,8BAAA;AAAA,MACjB,MAAA;AAAA,MACA,CAAC,YAAY,MAAA,KAAW,KAAA,CAAM,YAAY,EAAE,MAAA,EAAQ,cAAA,EAAgB,MAAA,EAAQ;AAAA;AAC9E,GACF;AACF;AA2CO,SAAS,mBAAA,CACd,MAAA,EACA,KAAA,EACA,MAAA,GAAiB,cAAA,EACG;AACpB,EAAA,MAAM,cAAA,GAAiB,oBAAoB,MAAM,CAAA;AACjD,EAAA,MAAM,MAAA,GAAS,MAAA,CAAO,SAAA,CAAU,KAAK,CAAA;AACrC,EAAA,IAAI,MAAA,CAAO,OAAA;AACT,IAAA,OAAO,EAAA,CAAG,OAAO,IAAI,CAAA;AACvB,EAAA,MAAM,MAAA,GAAS,gBAAA,CAAiB,MAAA,CAAO,KAAA,EAAO,cAAc,CAAA;AAC5D,EAAA,OAAO,GAAA,CAAI,cAAA,CAAe,gBAAA,EAAkB,KAAA,CAAM,uBAAA,EAAyB,EAAE,MAAA,EAAQ,cAAA,EAAgB,CAAA,EAAG,MAAM,CAAA;AAChH","file":"chunk-
|
|
1
|
+
{"version":3,"sources":["../src/serv-validation.ts"],"names":[],"mappings":";;;AAiBA,IAAM,cAAA,GAAiB,OAAA;AAmDhB,SAAS,qBAAqB,OAAA,EAA+D;AAClG,EAAA,MAAM,GAAA,GAAM,CAAC,IAAA,KAAqC;AAChD,IAAA,IAAI,OAAA,YAAmB,OAAA;AACrB,MAAA,OAAO,OAAA,CAAQ,GAAA,CAAI,IAAI,CAAA,IAAK,MAAA;AAC9B,IAAA,OAAO,QAAQ,IAAI,CAAA,IAAK,OAAA,CAAQ,IAAA,CAAK,aAAa,CAAA;AAAA,EACpD,CAAA;AAEA,EAAA,MAAM,QAAA,GAAW,IAAI,cAAc,CAAA;AACnC,EAAA,IAAI,QAAA;AACF,IAAA,OAAO,oBAAoB,QAAQ,CAAA;AAErC,EAAA,OAAO,oBAAoB,GAAA,CAAI,iBAAiB,KAAK,GAAA,CAAI,iBAAiB,KAAK,cAAc,CAAA;AAC/F;AAiBO,SAAS,gBAAA,CAAiB,UAAmB,MAAA,EAAuC;AACzF,EAAA,MAAM,cAAA,GAAiB,oBAAoB,MAAM,CAAA;AACjD,EAAA,OAAO,KAAK,aAAA,CAAc,uBAAA;AAAA,IACxB,QAAA;AAAA,IACA,KAAK,aAAA,CAAc,8BAAA;AAAA,MACjB,MAAA;AAAA,MACA,CAAC,YAAY,MAAA,KAAW,KAAA,CAAM,YAAY,EAAE,MAAA,EAAQ,cAAA,EAAgB,MAAA,EAAQ;AAAA;AAC9E,GACF;AACF;AA2CO,SAAS,mBAAA,CACd,MAAA,EACA,KAAA,EACA,MAAA,GAAiB,cAAA,EACG;AACpB,EAAA,MAAM,cAAA,GAAiB,oBAAoB,MAAM,CAAA;AACjD,EAAA,MAAM,MAAA,GAAS,MAAA,CAAO,SAAA,CAAU,KAAK,CAAA;AACrC,EAAA,IAAI,MAAA,CAAO,OAAA;AACT,IAAA,OAAO,EAAA,CAAG,OAAO,IAAI,CAAA;AACvB,EAAA,MAAM,MAAA,GAAS,gBAAA,CAAiB,MAAA,CAAO,KAAA,EAAO,cAAc,CAAA;AAC5D,EAAA,OAAO,GAAA,CAAI,cAAA,CAAe,gBAAA,EAAkB,KAAA,CAAM,uBAAA,EAAyB,EAAE,MAAA,EAAQ,cAAA,EAAgB,CAAA,EAAG,MAAM,CAAA;AAChH","file":"chunk-CZ6VA2EN.js","sourcesContent":["/**\n * @h-ai/serv — 校验工具\n *\n * 为 API service 提供与 kit 对等的校验 i18n 能力:\n * - {@link resolveRequestLocale}:从请求头(`x-hai-locale` / `Accept-Language`)解析语言。\n * - {@link validateInputOrFail}:在 procedure 内显式校验数据,失败返回 `HaiResult` 错误。\n * - oRPC contract 输入校验失败的自动本地化由 `serv-app.ts` 中的 oRPC 响应拦截器处理。\n *\n * @module serv-validation\n */\n\nimport type { HaiResult, ValidationFormError } from '@h-ai/core'\nimport type { ZodType } from 'zod'\nimport type { ServMessageKey } from './serv-i18n.js'\nimport { core, err, HaiCommonError, ok } from '@h-ai/core'\nimport { normalizeServLocale, servM } from './serv-i18n.js'\n\nconst DEFAULT_LOCALE = 'zh-CN'\n\n/**\n * 校验失败响应体(含 i18n 错误列表)。\n *\n * 复用 `HaiResult` 失败分支结构。字段错误同时在两处出现:\n * - `error.cause`:与 `validateInputOrFail` 输出对齐,HaiResult 标准位置\n * - 顶层 `errors`:保留以兼容已有客户端\n *\n * @example\n * ```ts\n * import type { ServValidationFailureBody } from '@h-ai/serv'\n * import { serv } from '@h-ai/serv'\n *\n * function fields(body: ServValidationFailureBody) {\n * return body.error.cause ?? body.errors\n * }\n * // 业务调用先判断 success,字段错误仅包含安全的本地化文案。\n * ```\n */\nexport interface ServValidationFailureBody {\n readonly success: false\n readonly error: {\n readonly code: string | number\n readonly message: string\n readonly traceId?: string\n readonly httpStatus: number\n readonly system: string\n readonly module: string\n readonly cause?: readonly ValidationFormError[]\n }\n readonly errors: readonly ValidationFormError[]\n}\n\n/**\n * 从请求头解析客户端期望 locale。\n *\n * 优先级:`x-hai-locale` > `Accept-Language` 第一个匹配项 > 默认值(`zh-CN`)。\n * 返回值会被规范化为 serv 当前支持的 locale(如 `en` → `en-US`)。\n *\n * @param headers - 请求头(`Headers` 或与之等价的对象)\n * @returns 解析得到的 locale\n *\n * @example\n * ```ts\n * import { serv } from '@h-ai/serv'\n *\n * const locale = serv.resolveRequestLocale(new Headers({ 'x-hai-locale': 'en-US' }))\n * const fallback = serv.resolveRequestLocale(new Headers())\n * ```\n */\nexport function resolveRequestLocale(headers: Headers | Record<string, string | undefined>): string {\n const get = (name: string): string | undefined => {\n if (headers instanceof Headers)\n return headers.get(name) ?? undefined\n return headers[name] ?? headers[name.toLowerCase()]\n }\n\n const explicit = get('x-hai-locale')\n if (explicit)\n return normalizeServLocale(explicit)\n\n return normalizeServLocale(get('accept-language') ?? get('Accept-Language') ?? DEFAULT_LOCALE)\n}\n\n/**\n * 把 Zod 错误转为本地化的扁平错误列表。\n *\n * @param zodError - Zod `SafeParseError` 或 `ZodError`\n * @param locale - 期望 locale\n *\n * @example\n * ```ts\n * // 模块内部上下文:示例复用当前声明所在模块的实际绑定。\n * const { z } = await import('zod')\n * const parsed = z.object({ name: z.string().min(1) }).safeParse({ name: '' })\n * if (parsed.success) return []\n * return localizeZodError(parsed.error, 'en-US')\n * ```\n */\nexport function localizeZodError(zodError: unknown, locale: string): ValidationFormError[] {\n const resolvedLocale = normalizeServLocale(locale)\n return core.zodValidation.mapZodErrorToFormErrors(\n zodError,\n core.zodValidation.createPrefixedZodMessageGetter<ServMessageKey>(\n 'serv',\n (messageKey, params) => servM(messageKey, { locale: resolvedLocale, params }),\n ),\n )\n}\n\n/**\n * 在 procedure handler 内显式校验数据。\n *\n * 与 oRPC contract 自动校验不同,本函数用于业务层二次校验(例如对从数据库读到的对象再校验)。\n * 失败时返回 `HaiResult` 错误分支,错误的 `cause` 字段携带 `ValidationFormError[]`。\n *\n * 推荐使用方式:\n * ```ts\n * import { z } from 'zod'\n * import { serv } from '@h-ai/serv'\n *\n * const Schema = z.object({ name: z.string().min(1) })\n *\n * export const procedures = serv\n * .implement(contract)\n * .context<ServContext>()\n * .route('profile.update', ({ input, context }) => {\n * const result = serv.validateInputOrFail(Schema, input, context.locale)\n * if (!result.success) return result\n * return profileService.update(result.data)\n * })\n * .build()\n * ```\n *\n * @param schema - Zod schema\n * @param input - 待校验数据\n * @param locale - 期望 locale(通常来自请求上下文)\n *\n * @example\n * ```ts\n * import { serv } from '@h-ai/serv'\n * import { z } from 'zod'\n *\n * const result = serv.validateInputOrFail(z.object({ name: z.string().min(1) }), { name: '' }, 'en-US')\n * if (!result.success) {\n * const fields = result.error.cause\n * } else {\n * const name = result.data.name\n * }\n * ```\n */\nexport function validateInputOrFail<TOutput>(\n schema: ZodType<TOutput>,\n input: unknown,\n locale: string = DEFAULT_LOCALE,\n): HaiResult<TOutput> {\n const resolvedLocale = normalizeServLocale(locale)\n const result = schema.safeParse(input)\n if (result.success)\n return ok(result.data)\n const errors = localizeZodError(result.error, resolvedLocale)\n return err(HaiCommonError.VALIDATION_ERROR, servM('serv_validationFailed', { locale: resolvedLocale }), errors)\n}\n"]}
|