@h-ai/serv 0.1.0-alpha.67 → 0.1.0-alpha.68

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 CHANGED
@@ -19,10 +19,10 @@ hai-framework's API service runtime: a stable public HTTP App abstraction that m
19
19
  - `build()` synchronously returns a plain router, so nested default feature procedures can be selected directly. Match the mounted contract to the selected procedures; unselected routes stay unavailable.
20
20
  - Missing routes return localized HaiResult JSON with HTTP 404. Transport encryption preserves that status instead of producing a 500 for a non-JSON response.
21
21
  - Built-in security headers and health checks; optional OpenAPI JSON, Scalar docs, and internal RPC endpoints.
22
- - Optional transport encryption: `serv.createApp({ transport: { crypto } })` mounts key exchange and request/response encryption automatically.
22
+ - Optional transport encryption: `serv.createApp({ transport: { crypto, serverKeyRing, keyStore } })` mounts key exchange and request/response encryption automatically.
23
23
  - Optional audio: `serv.createApp({ audio: { ai, verifyTicket, authorize } })` mounts a unified audio WebSocket. Connections authenticate immediately with one-time tickets (`{ session, grant? }`, separate pre-auth timeout). Every frame uses runtime Zod validation and a strict state machine, with input/queue/send-buffer backpressure and cascading cancellation. Authorization verifies operations, models, voices, quotas, and concurrency on the server.
24
24
 
25
- > Lifecycle: `@h-ai/serv` is a stateless HTTP App assembler and needs no `init()` / `close()`. Initialize `iam` / `storage` / `ai` / `crypto` before app creation, then release them in reverse order through `serv.listen(..., { onClose })`. `serv.createApp()` throws immediately on startup configuration errors such as transport manager creation failure.
25
+ > Lifecycle: `@h-ai/serv` is a stateless HTTP App assembler and needs no `init()` / `close()`. Initialize `iam` / `storage` / `ai` / `crypto` before app creation, then release them in reverse order through `serv.listen(..., { onClose })`. `serv.createApp()` throws immediately on startup configuration errors such as transport manager creation failure. onClose is the SIGINT/SIGTERM shutdown callback. Calling the returned handle.close directly closes HTTP only; the host separately releases business modules in reverse order.
26
26
 
27
27
  ## Getting started
28
28
 
@@ -41,7 +41,11 @@ const contract = apiContract.create({ iam: apiContract.iam, storage: apiContract
41
41
  // 2) 组装 procedures(注入 iam / storage / ai 已初始化的 functions)
42
42
  const procedures = {
43
43
  iam: createIamProcedures({ iam }),
44
- storage: createStorageProcedures({ storage }),
44
+ storage: createStorageProcedures({
45
+ storage,
46
+ authorize: ({ context, keys }) => keys.every(key =>
47
+ key.startsWith(`users/${encodeURIComponent(context.session.userId)}/`)),
48
+ }),
45
49
  ai: createAiProcedures({ ai }),
46
50
  }
47
51
 
@@ -68,6 +72,14 @@ serv.listen(app, {
68
72
  // 5) 优雅关闭由 serv 托管,无需手动注册 process.once('SIGINT', ...)
69
73
  ```
70
74
 
75
+ Storage procedures require `authorize({ context, operation, keys })` and deny access if the policy is missing. Derive resource ownership from the authenticated session for downloads, uploads, listing, metadata and deletion. The example only permits keys under `users/<encoded userId>/`; list requests must include that prefix. A batch is executed only after every key is authorized. Grant administrative access explicitly in the application policy.
76
+
77
+ Batch deletion via `storage.files.deleteMany` returns `{ deletedKeys, failures: [] }` on success. Failures preserve progress in `error.ext.deleteMany` (`failed / unknown / notAttempted`); HTTP 200 alone does not mean completion. See [Storage batch deletion](../storage/README.en.md#api-overview).
78
+
79
+ AI procedures bind `objectId` to the trusted `context.session.userId` when the client omits it. Chat/message writes, history, session lists and memory recall/list all use the authorized owner. Sessions are scoped by `objectId + sessionId`; writes default to session `default`. A different objectId is forbidden unless an explicit `authorizeObject` policy grants access using trusted permissions and tenant ownership, including for administrators.
80
+
81
+ `chats.createCompletion` (`POST /ai/chats/completions`) returns one non-streaming `HaiResult<AiChatCompletionData>`. Function `tools` retain their complete nested JSON Schema and `strict`, and `tool_choice` supports `auto`, `none`, `required`, or a provided function name. Messages preserve roles, `name`, user images, and tool calls. Tool results require `role: 'tool'` and a nonempty `tool_call_id`; named choices must reference a supplied tool. The procedure does not execute tools: the application authorizes and executes them, then sends the assistant's `tool_calls` with their tool results. The input has no `stream` field; either boolean value is rejected with HTTP 400 before the provider runs. For streaming, define a separate endpoint with a matching stream output using `ai.llm.chatStream()`.
82
+
71
83
  #### host / port configuration
72
84
 
73
85
  Host/port come from `server` in `config/_serv.yml`: `HOST` / `PORT` environment variables > configuration > `127.0.0.1:3000`. The default `127.0.0.1` is local-only; use `0.0.0.0` to expose a container:
@@ -81,7 +93,9 @@ server:
81
93
 
82
94
  Pass the resolved `server` to `serv.listen`; explicit `host` / `port` arguments can also override it.
83
95
 
84
- #### Fetch Runtime(Workers / Bun / Deno)
96
+ #### Standard fetch signature adapter
97
+
98
+ toFetch provides the standard fetch signature for Node-compatible hosts and test injection. The package root still depends on Node APIs; this does not establish support for Workers, Deno or edge deployment without those APIs.
85
99
 
86
100
  ```ts
87
101
  const handler = serv.toFetch(app)
@@ -90,6 +104,8 @@ export default { fetch: handler }
90
104
 
91
105
  Node `serv.listen()` supplies the socket peer address. Forwarded headers are ignored by default. Only proxy IPs/CIDRs in `http.trustedProxies` may supply them: XFF is walked from right to left until the first untrusted address; content to the left of that address is ignored. An invalid or excessively long trusted chain leaves IP undefined rather than promoting the proxy to client identity. Proxies must sanitize or append forwarded headers. Fetch platforms explicitly provide a trusted peer through `app.fetch(request, { remoteAddress: platformPeerAddress })`; without metadata IP is undefined. Do not copy arbitrary headers into metadata. Custom `createContext(input)` forwards the complete input to preserve resolved `input.ip`. Internal RPC checks the connection peer.
92
106
 
107
+ toFetch constructs Request(input, init) for Request, URL and string inputs, applying RequestInit overrides including method, headers, body and signal without mutating input fields. Consumed or locked bodies and invalid body combinations reject the returned Promise. Cancellation before dispatch skips the handler; in-flight cancellation ends the caller wait and reaches the app through the effective signal. Listeners are released on completion, failure or cancellation, and a discarded late response body is cancelled. Cancellation cannot roll back external effects already started; after Response delivery the app owns streaming cleanup through request.signal.
108
+
93
109
  #### Audio WebSocket: tickets and server authorization
94
110
 
95
111
  Audio connections require short-lived, one-time tickets. Issue them through an authenticated HTTP endpoint (recommended: `iam.ticket.issue`) and consume atomically in `verifyTicket` (recommended: `iam.ticket.consume`). Authentication runs at connection time with a separate timeout; no business frames are processed beforehand. The framework stores the returned `ServSession` and optional `grant`, then calls `authorize` to approve paid parameters:
@@ -114,7 +130,7 @@ const app = serv.createApp({
114
130
  - Each frame is validated with Zod and a strict state machine: transcription accepts audio, synthesis accepts text, `start`/`done` occur once, and `segmentId` is unique per session. Input, pending-message, and send buffers are bounded; limits or cancellation close with domain errors and abort upstream work.
115
131
  - Tunables: `preAuthTimeoutMs` (5000), `maxPendingMessages` (256), `maxSendBufferBytes` (8 MiB), and `maxMessageBytes` / `maxBufferedBytes` / `maxTextBytes` / `maxSessionMs`.
116
132
  - `onSessionEnd` runs at most once after success, failure, or disconnection to release concurrency slots.
117
- - Browser `createAIClient({ audio: { url, getTicket } })` puts only one-time `ticket` in the WebSocket URL. `getTicket(request)` receives the operation summary (`operation` / `model`) for a tightly bound ticket. Never pass ordinary IAM access tokens.
133
+ - Browser `createAudioClient({ url, getTicket })` puts only one-time `ticket` in the WebSocket URL. `getTicket(request)` receives the operation summary (`operation` / `model`) for a tightly bound ticket. Never pass ordinary IAM access tokens.
118
134
 
119
135
  #### Transport encryption (shared with `@h-ai/crypto`)
120
136
 
@@ -122,10 +138,14 @@ serv does not expose its own encryption factory; the server manager comes from `
122
138
 
123
139
  ```ts
124
140
  import { cache } from '@h-ai/cache'
125
- import { createRedisTransportKeyStore, crypto } from '@h-ai/crypto'
141
+ import { createRedisTransportKeyStore, crypto, parseTransportServerKeyRing } from '@h-ai/crypto'
126
142
  import { serv } from '@h-ai/serv'
127
143
 
128
144
  await crypto.init()
145
+ const ring = parseTransportServerKeyRing(process.env.HAI_CRYPTO_TRANSPORT_SERVERKEYRING)
146
+ if (!ring.success)
147
+ throw new Error(ring.error.message)
148
+ const serverKeyRing = ring.data
129
149
  await cache.init({ type: 'redis', host: '127.0.0.1', port: 6379 })
130
150
 
131
151
  const app = serv.createApp({
@@ -134,9 +154,9 @@ const app = serv.createApp({
134
154
  http: { apiPrefix: '/api/v1' },
135
155
  transport: {
136
156
  crypto,
157
+ serverKeyRing,
137
158
  // keyExchangePath 默认 '/_hai/key-exchange',最终端点为 '/api/v1/_hai/key-exchange'
138
159
  // excludePaths: ['/health'],
139
- // maxClients: 10000,
140
160
  keyStore: createRedisTransportKeyStore({ cache, ttlSeconds: 3600 }),
141
161
  },
142
162
  })
@@ -169,7 +189,6 @@ transport:
169
189
  - /openapi.json
170
190
  - /docs
171
191
  - /_hai/scalar.js
172
- maxClients: 10000
173
192
  ```
174
193
 
175
194
  ```ts
@@ -191,23 +210,24 @@ const app = serv.createApp({
191
210
  ? undefined
192
211
  : {
193
212
  crypto,
213
+ serverKeyRing,
214
+ keyStore,
194
215
  keyExchangePath: servConfig.transport.keyExchangePath,
195
216
  excludePaths: [...servConfig.transport.excludePaths],
196
- maxClients: servConfig.transport.maxClients,
197
217
  },
198
218
  })
199
219
  ```
200
220
 
201
- > **Multi-node deployments**: transport session keys are stored **in the server process** by default. Replicas require one of these:
202
- >
203
- > - Enable **sticky sessions** in the load balancer so each client reaches the same node.
204
- > - Perform a separate initial key exchange for each client on each node.
205
- > - Inject a shared `keyStore`, such as `createRedisTransportKeyStore()` / `createReldbTransportKeyStore()`.
206
- >
207
- > `keyStore` is a runtime object dependency and must not go in `config/_serv.yml`; configuration contains only static paths, allowlists, and capacities.
221
+ > Servers must explicitly inject the same managed `serverKeyRing` and a shared `keyStore` (Redis or a shared database). Sharing client public keys alone cannot enable cross-node decryption. There is no implicit process key or memory-registry fallback; development/tests may explicitly use `createInMemoryKeyStore(capacity)`. Reload the same ring after restart; sticky sessions are unnecessary.
208
222
 
209
223
  > **Security**: with `transport` enabled, business requests need valid `X-Client-Id` except `excludePaths` and key exchange. Missing/unknown clientId returns 400. Unencryptable bodies, non-JSON responses, or encryption size limits return errors rather than forwarding plaintext business data.
210
224
 
225
+ Only authenticated protocol v2 is accepted. Independent random SM4-CBC and HMAC-SM3 keys are wrapped with SM2; verify the sender signature and MAC before CBC decryption. Clients sign requests and the selected server key version signs responses. Authentication covers version, direction, serverKeyId, clientId, a random 128-bit requestId acting as the nonce, issuedAt, HTTP method, the SM3 pathHash of pathname plus the original search, and response status. Requests have a five-minute lifetime and at most 30 seconds of future clock skew. The shared registry atomically consumes (clientId, requestId), retaining it until at least issuedAt plus six minutes to cover node skew; storage failures reject requests. Synchronize node clocks and use a Redis noeviction/durable policy that preserves live nonces. Transport replay prevention does not replace business idempotency keys.
226
+
227
+ Empty POSTs still send an authenticated envelope for empty plaintext. GET/HEAD carry it in X-Hai-Transport; bodyless responses such as 204/304/HEAD use the same header. Other requests/responses carry a JSON envelope and X-Encrypted: true. The client must match the original request context and HTTP status; missing markers, old formats and authentication failures reject. Exclude plaintext endpoints explicitly with excludePaths. CORS must allow and expose X-Hai-Transport. TLS authenticates handshake keys; this protocol does not replace IAM authorization.
228
+
229
+ Fixed protocol budgets come from `@h-ai/crypto`'s `TRANSPORT_BODY_LIMITS`: 16 KiB key exchange JSON, 4 KiB per public key, 1 MiB plaintext, and 2 MiB + 32 KiB encrypted JSON, measured in UTF-8 bytes. The original stream is read incrementally; limits or cancellation stop/cancel it and release the reader without cloning and fully buffering it. Oversized requests return 413, oversized responses return 500, and invalid public keys return 400. Rebuilt requests preserve signals and clear Hono's body cache; rebuilt requests/responses remove stale Content-Length/Content-Encoding. These budgets are protocol constants, not YAML or environment settings.
230
+
211
231
  #### Token authentication (`iam` / `refreshCookie`)
212
232
 
213
233
  Pass the top-level `iam` handle to `serv.createApp` to derive:
@@ -250,6 +270,8 @@ const app = serv.createApp({
250
270
  })
251
271
  ```
252
272
 
273
+ The default `auth.logout` endpoint takes the complete token from input.accessToken or Authorization Bearer without requiring live access authentication. It grants no userId-based authority and accepts no userId revocation target. Missing tokens fail; unknown tokens succeed idempotently. Shared-store failures remain failed HaiResult values, and cookie middleware clears refresh cookies only after success. Ordinary procedures retain the existing HTTP 200 / HaiResult envelope: clients must inspect success, not only the HTTP status.
274
+
253
275
  #### Custom procedures (authentication + permissions)
254
276
 
255
277
  ```ts
@@ -434,7 +456,7 @@ const app = serv.createApp({
434
456
  refreshCookie: {
435
457
  // cookieName: 'hai_refresh_token', // 默认
436
458
  // maxAge: 30 * 24 * 3600, // 默认 30 天
437
- // secure: true, // 默认在 NODE_ENV=production 时开启
459
+ // secure: true, // NODE_ENV 或 HAI_CORE_ENV 为 production 时默认开启
438
460
  // onRefresh: customFn, // 可覆盖 iam.session.refresh。
439
461
  nativeTokenTransport: {
440
462
  // 必须 fail-closed:同时校验平台标识与允许的原生 Origin。
@@ -510,9 +532,10 @@ const app = serv.createApp({
510
532
  ? undefined
511
533
  : {
512
534
  crypto,
535
+ serverKeyRing,
536
+ keyStore,
513
537
  keyExchangePath: servConfig.transport.keyExchangePath,
514
538
  excludePaths: [...servConfig.transport.excludePaths],
515
- maxClients: servConfig.transport.maxClients,
516
539
  },
517
540
  })
518
541
  ```
@@ -555,7 +578,6 @@ transport:
555
578
  - /openapi.json
556
579
  - /docs
557
580
  - /_hai/scalar.js
558
- maxClients: 10000
559
581
  ```
560
582
 
561
583
  `server` (host/port): `HOST` / `PORT` environment variables > configuration > `127.0.0.1:3000`. Applications pass `servConfig.server` to `serv.listen`.
@@ -660,6 +682,16 @@ const procedures = serv
660
682
 
661
683
  > When downstream modules (such as `iam.user.xxx()`) return `HaiResult` directly, serv forwards `error.message` unchanged. `HaiError` holds only the final `message`, not `messageKey/params`, so **serv cannot generically retranslate arbitrary downstream errors**. Request-level localization must receive locale at the module that creates the error message.
662
684
 
685
+ ### Request locale isolation
686
+
687
+ Wrap Node requests and SSR in `core.i18n.runWithLocale(locale, () => handleRequest())`. SvelteKit can use `kit.i18n.runWithLocale(locale, () => resolve(event))`; serv creates the scope automatically from request headers. Async descendants inherit it. Explicit `MessageOptions.locale` takes precedence over the request locale and the startup default. Within a scope, `setGlobalLocale` only changes that request; outside a scope it configures the startup default and must not be called by request hooks.
688
+
689
+ Browsers keep application-level `setGlobalLocale` / `createLocaleStore`. During SSR, UI store initialization and setters only update the store; messages read the request scope. `runWithLocale` requires the Node entry; browser translations use explicit locale options or application state.
690
+
691
+ ## Input limits and HTTP capabilities
692
+
693
+ Object keys are limited to 1024 UTF-8 bytes and reject absolute paths, backslashes, control characters and dot segments. Each public HTTP batch delete accepts 1–1000 entries. `@h-ai/storage/schemas` exposes the shared browser-safe rules. Presign expiry is an integer from 1 to 604800 seconds (Node default 3600, HTTP default 900). Upload signing accepts only key/contentType/expiresIn; contentLength/maxSize/metadata/checksum are unsupported. Invalid input returns HTTP 400 and the typed client maps it to VALIDATION_FAILED. FileUpload limits guide file selection; applications must validate actual objects on the server. Node streaming/checksum/conditional-write options have no default HTTP endpoints. See the [capability and authorization matrix (Chinese)](../storage/README.md#存储输入约束与跨端能力).
694
+
663
695
  ## Development and testing
664
696
 
665
697
  ```bash
@@ -668,6 +700,8 @@ pnpm --filter @h-ai/serv typecheck
668
700
  pnpm --filter @h-ai/serv lint
669
701
  ```
670
702
 
703
+ Declaration JSDoc examples import actual `@h-ai/serv` entries or published feature subpaths. Internal Hono, guard, router and transport examples are marked as internal module context and are not root APIs. Examples distinguish startup configuration exceptions, domain HaiResult, HTTP middleware statuses, and listener shutdown returning Promise<void>; default AI completion still returns non-streaming JSON only. Maintainers can run `node scripts/check-module-jsdoc.mjs --package serv` against actual declarations and internal lexical bindings. Compilation does not replace real HTTP, authorization or distributed replay-protection validation.
704
+
671
705
  ## License
672
706
 
673
707
  Apache-2.0
package/README.ja.md CHANGED
@@ -19,10 +19,10 @@ hai-framework の API サービスランタイム。公開 HTTP App 抽象を通
19
19
  - `build()` は通常の router を同期的に返すため、標準 feature のネストした procedures を直接選択できます。接続する contract と procedures を一致させ、未選択の入口は公開しません。
20
20
  - 存在しないルートは翻訳された HaiResult JSON と HTTP 404 を返します。通信暗号化でも 404 を保ち、非 JSON 応答による 500 にしません。
21
21
  - 安全な応答ヘッダー・ヘルスチェックを内蔵し、OpenAPI JSON・Scalar 文書・内部 RPC endpoint は任意。
22
- - 任意の通信暗号化:`serv.createApp({ transport: { crypto } })` で鍵交換とリクエスト・レスポンスの暗号化を自動接続。
22
+ - 任意の通信暗号化:`serv.createApp({ transport: { crypto, serverKeyRing, keyStore } })` で鍵交換とリクエスト・レスポンスの暗号化を自動接続。
23
23
  - 任意の音声入口:`serv.createApp({ audio: { ai, verifyTicket, authorize } })` が共通 WebSocket を接続します。接続直後に単回 ticket で認証(`{ session, grant? }`、独立した事前認証 timeout)。各フレームを Zod と厳密な状態機械で検証し、入力・キュー・送信バッファの背圧と連鎖キャンセルを管理します。サーバーの認可処理で操作・モデル・声・割当量・同時使用数を確認します。
24
24
 
25
- > ライフサイクル:`@h-ai/serv` は状態を持たない HTTP App の構成機能で、`init()` / `close()` は不要です。app 作成前に `iam` / `storage` / `ai` / `crypto` などを初期化し、`serv.listen(..., { onClose })` で逆順に解放します。`serv.createApp()` は transport 管理器の生成失敗など起動設定のエラーを即座に throw します。
25
+ > ライフサイクル:`@h-ai/serv` は状態を持たない HTTP App の構成機能で、`init()` / `close()` は不要です。app 作成前に `iam` / `storage` / `ai` / `crypto` などを初期化し、`serv.listen(..., { onClose })` で逆順に解放します。`serv.createApp()` は transport 管理器の生成失敗など起動設定のエラーを即座に throw します。 onClose は SIGINT/SIGTERM 用の終了コールバックです。返された handle.close の直接呼出しは HTTP だけを閉じ、業務モジュールはホストが別途逆順に解放します。
26
26
 
27
27
  ## 使い始める
28
28
 
@@ -41,7 +41,11 @@ const contract = apiContract.create({ iam: apiContract.iam, storage: apiContract
41
41
  // 2) 组装 procedures(注入 iam / storage / ai 已初始化的 functions)
42
42
  const procedures = {
43
43
  iam: createIamProcedures({ iam }),
44
- storage: createStorageProcedures({ storage }),
44
+ storage: createStorageProcedures({
45
+ storage,
46
+ authorize: ({ context, keys }) => keys.every(key =>
47
+ key.startsWith(`users/${encodeURIComponent(context.session.userId)}/`)),
48
+ }),
45
49
  ai: createAiProcedures({ ai }),
46
50
  }
47
51
 
@@ -68,6 +72,14 @@ serv.listen(app, {
68
72
  // 5) 优雅关闭由 serv 托管,无需手动注册 process.once('SIGINT', ...)
69
73
  ```
70
74
 
75
+ Storage procedures には `authorize({ context, operation, keys })` が必須で、未設定の場合はアクセスを拒否します。認証済み session から所有者・テナントを判定し、ダウンロード、アップロード、一覧、メタデータ、削除に適用します。上記の例は `users/<エンコードした userId>/` 配下だけを許可し、一覧にもその prefix が必要です。一括処理はすべての key が許可された後に実行し、管理者アクセスはアプリ側で明示的に許可します。
76
+
77
+ `storage.files.deleteMany` の成功データは `{ deletedKeys, failures: [] }` です。失敗進捗は `error.ext.deleteMany`(`failed / unknown / notAttempted`)にあり、HTTP 200 だけで完了と判断しません。[Storage の一括削除](../storage/README.ja.md#api-一覧)を参照してください。
78
+
79
+ AI procedures は省略された `objectId` を信頼できる `context.session.userId` に結び付けます。会話・メッセージの保存、履歴、会話一覧、記憶 recall/list は認可された主体で処理し、会話は `objectId + sessionId` で分離します。書き込み時の sessionId の既定値は `default` です。他の objectId へのアクセスは既定で拒否し、管理者も信頼できる権限とテナント関係を検証する明示的な `authorizeObject` ポリシーが必要です。
80
+
81
+ `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` とも Provider 呼び出し前に HTTP 400 で拒否します。ストリーミングには `ai.llm.chatStream()` と対応する出力型を使う専用 endpoint を定義します。
82
+
71
83
  #### host / port 設定
72
84
 
73
85
  host/port は `config/_serv.yml` の `server` で指定し、`HOST` / `PORT` 環境変数 > 設定ファイル > `127.0.0.1:3000` の順です。既定の `127.0.0.1` はローカル専用で、コンテナー外に公開する場合は `0.0.0.0` にします:
@@ -81,7 +93,9 @@ server:
81
93
 
82
94
  解決済みの `server` を `serv.listen` に渡します。`host` / `port` の明示的な指定で上書きすることもできます。
83
95
 
84
- #### Fetch Runtime(Workers / Bun / Deno)
96
+ #### 標準 fetch シグネチャのアダプター
97
+
98
+ toFetch は Node 互換ホストやテスト注入用の標準 fetch シグネチャです。パッケージルートは Node API に依存しており、それらを持たない Workers・Deno・エッジへの対応を保証しません。
85
99
 
86
100
  ```ts
87
101
  const handler = serv.toFetch(app)
@@ -90,6 +104,8 @@ export default { fetch: handler }
90
104
 
91
105
  Node `serv.listen()` は socket の接続元を自動で渡し、既定では転送ヘッダーを無視します。`http.trustedProxies` の IP/CIDR のみ信頼し、XFF を右から左へ調べ、最初の非信頼アドレスで停止します。その左側は無視します。信頼チェーンが不正・長すぎる場合は IP 未定義となり、代理をクライアントとして扱いません。代理は転送ヘッダーを清掃・追加してください。Fetch は `app.fetch(request, { remoteAddress: platformPeerAddress })` で信頼できる接続元を渡します。メタデータなしは IP 未定義です。任意のヘッダーをコピーしないでください。独自 `createContext(input)` は input 全体を再利用し、解決済み `input.ip` を保持します。内部 RPC は接続元で制限します。
92
106
 
107
+ toFetch は Request・URL・文字列を標準 Request(input, init) で構築し、入力フィールドを変更せず method・headers・body・signal などを上書きします。消費済み・ロック中の本文や不正な本文指定は返却 Promise を reject します。派発前の取消しは handler を呼ばず、実行中の取消しは呼出側の待機を終了し、有効な signal をアプリへ伝えます。終了・失敗・取消しで listener を外し、取消し後に遅れて返る本文も cancel します。開始済みの外部効果は巻き戻せず、Response 返却後のストリーム終了はアプリが request.signal を消費して管理します。
108
+
93
109
  #### Audio WebSocket:ticket とサーバー認可
94
110
 
95
111
  音声接続には短期・単回 ticket が必要です。認証済み HTTP endpoint で発行(推奨:`iam.ticket.issue`)し、`verifyTicket` でアトミックに消費します(推奨:`iam.ticket.consume`)。接続直後に独立 timeout で認証し、認証前に業務フレームを処理しません。返された `ServSession` と任意の `grant` を保存してから `authorize` で有料パラメーターを確認します:
@@ -114,7 +130,7 @@ const app = serv.createApp({
114
130
  - 各フレームを Zod と厳密な状態機械で検証します。認識は音声、合成はテキストのみ、`start`/`done` は各 1 回、`segmentId` はセッション内で一意です。入力・滞留メッセージ・送信バッファを制限し、超過・キャンセル時はドメインエラーで閉じて上流も中止します。
115
131
  - 調整項目:`preAuthTimeoutMs`(既定 5000)、`maxPendingMessages`(256)、`maxSendBufferBytes`(8 MiB)、`maxMessageBytes` / `maxBufferedBytes` / `maxTextBytes` / `maxSessionMs`。
116
132
  - `onSessionEnd` は成功・失敗・切断後に最大 1 回呼び、同時実行枠の解放に使用。
117
- - ブラウザーの `createAIClient({ audio: { url, getTicket } })` は単回 `ticket` のみを URL に付けます。`getTicket(request)` は `operation` / `model` の概要から今回の操作専用 ticket を発行できます。通常の IAM access token は渡さないでください。
133
+ - ブラウザーの `createAudioClient({ url, getTicket })` は単回 `ticket` のみを URL に付けます。`getTicket(request)` は `operation` / `model` の概要から今回の操作専用 ticket を発行できます。通常の IAM access token は渡さないでください。
118
134
 
119
135
  #### 通信暗号化(`@h-ai/crypto` と共通)
120
136
 
@@ -122,10 +138,14 @@ serv 独自の暗号化ファクトリーはなく、`crypto.transport` から
122
138
 
123
139
  ```ts
124
140
  import { cache } from '@h-ai/cache'
125
- import { createRedisTransportKeyStore, crypto } from '@h-ai/crypto'
141
+ import { createRedisTransportKeyStore, crypto, parseTransportServerKeyRing } from '@h-ai/crypto'
126
142
  import { serv } from '@h-ai/serv'
127
143
 
128
144
  await crypto.init()
145
+ const ring = parseTransportServerKeyRing(process.env.HAI_CRYPTO_TRANSPORT_SERVERKEYRING)
146
+ if (!ring.success)
147
+ throw new Error(ring.error.message)
148
+ const serverKeyRing = ring.data
129
149
  await cache.init({ type: 'redis', host: '127.0.0.1', port: 6379 })
130
150
 
131
151
  const app = serv.createApp({
@@ -134,9 +154,9 @@ const app = serv.createApp({
134
154
  http: { apiPrefix: '/api/v1' },
135
155
  transport: {
136
156
  crypto,
157
+ serverKeyRing,
137
158
  // keyExchangePath 默认 '/_hai/key-exchange',最终端点为 '/api/v1/_hai/key-exchange'
138
159
  // excludePaths: ['/health'],
139
- // maxClients: 10000,
140
160
  keyStore: createRedisTransportKeyStore({ cache, ttlSeconds: 3600 }),
141
161
  },
142
162
  })
@@ -169,7 +189,6 @@ transport:
169
189
  - /openapi.json
170
190
  - /docs
171
191
  - /_hai/scalar.js
172
- maxClients: 10000
173
192
  ```
174
193
 
175
194
  ```ts
@@ -191,23 +210,24 @@ const app = serv.createApp({
191
210
  ? undefined
192
211
  : {
193
212
  crypto,
213
+ serverKeyRing,
214
+ keyStore,
194
215
  keyExchangePath: servConfig.transport.keyExchangePath,
195
216
  excludePaths: [...servConfig.transport.excludePaths],
196
- maxClients: servConfig.transport.maxClients,
197
217
  },
198
218
  })
199
219
  ```
200
220
 
201
- > **複数ノード**:通信セッション鍵は既定で**サーバープロセス内**に保存します。複数レプリカでは次のいずれかが必要です:
202
- >
203
- > - ロードバランサーで **sticky session** を有効化し、同じクライアントを同じノードへ接続。
204
- > - 各クライアントが各ノードへの初回接続時に個別に鍵交換。
205
- > - `createRedisTransportKeyStore()` / `createReldbTransportKeyStore()` などの共有 `keyStore` を注入。
206
- >
207
- > `keyStore` は実行時オブジェクトの依存で、`config/_serv.yml` に保存しません。設定にはパス・許可リスト・容量などの静的項目のみを記載します。
221
+ > サーバーには同じ管理済み `serverKeyRing` と共有 `keyStore`(Redis または共有 DB)を明示的に渡します。クライアント公開鍵の共有だけではノード間で復号できません。暗黙のプロセス鍵・メモリー registry はなく、開発・テストでは `createInMemoryKeyStore(capacity)` を明示します。再起動後は同じ鍵環を読み込み、sticky session は不要です。
208
222
 
209
223
  > **安全方針**:`transport` 有効時は `excludePaths` と鍵交換以外で有効な `X-Client-Id` が必要です。不足・未知の clientId は 400、暗号化不能・非 JSON 応答・上限超過はエラーになり、平文の業務データは渡しません。
210
224
 
225
+ 認証済み v2 のみを受け付けます。独立したランダム SM4-CBC 鍵と HMAC-SM3 鍵を SM2 で包み、送信者署名、MAC の順に検証してから CBC を復号します。要求はクライアント秘密鍵、応答は選択されたサーバー鍵バージョンで署名します。version、direction、serverKeyId、clientId、nonce を兼ねるランダム 128 ビット requestId、issuedAt、HTTP method、pathname と元の search の SM3 pathHash、応答 status を認証します。要求の有効期限は 5 分、未来方向の時計差は最大 30 秒です。共有 registry が (clientId, requestId) を原子的に消費し、ノード間の時計差を含め issuedAt + 6 分まで保持します。保存障害では拒否します。時計同期と Redis の noeviction/永続化により有効な nonce を保持してください。業務の再試行には別の冪等キーが必要です。
226
+
227
+ 空の POST も空平文の認証封装を送ります。GET/HEAD は X-Hai-Transport ヘッダー、204/304/HEAD など本文なし応答も同じヘッダーを使います。他の要求・応答は JSON 本文と X-Encrypted: true を使います。クライアントは元の要求コンテキストと HTTP status を照合し、マーカー欠落、旧形式、認証失敗では reject します。平文 endpoint は excludePaths で明示的に除外し、CORS では X-Hai-Transport を許可・公開してください。鍵交換の公開鍵認証は TLS に依存し、IAM の業務認可は別に必要です。
228
+
229
+ 固定上限は `@h-ai/crypto` の `TRANSPORT_BODY_LIMITS` を共有し、鍵交換 JSON は 16 KiB、公開鍵は 1 個あたり 4 KiB、平文は 1 MiB、暗号文 JSON は 2 MiB + 32 KiB です。実際の UTF-8 バイトを元のストリームから逐次読み取り、超過・キャンセル時は停止・キャンセルして reader を解放します。clone して全量を読み取る処理は行いません。リクエスト超過は 413、応答超過は 500、不正な公開鍵は 400 です。再構築したリクエストは signal を維持して Hono の本文キャッシュを消去し、リクエスト・応答の古い Content-Length/Content-Encoding を削除します。上限はプロトコル定数であり、YAML・環境変数では設定しません。
230
+
211
231
  #### Token 認証(`iam` / `refreshCookie`)
212
232
 
213
233
  `serv.createApp` にトップレベルの `iam` を渡すと、次を自動構成します:
@@ -250,6 +270,8 @@ const app = serv.createApp({
250
270
  })
251
271
  ```
252
272
 
273
+ 標準 `auth.logout` は input.accessToken または Authorization Bearer の完全な Token を取り消し、有効な access 認証を前提にしません。userId による権限や取消対象は受け付けません。Token 不足は失敗、不存在 Token は冪等成功です。共有ストア障害は失敗 HaiResult を保ち、Cookie middleware は成功時だけ refresh Cookie を消去します。通常 procedure の HTTP 200 / HaiResult 契約は同じなので、クライアントは HTTP status だけでなく success を確認します。
274
+
253
275
  #### 独自 procedure(認証 + 権限)
254
276
 
255
277
  ```ts
@@ -434,7 +456,7 @@ const app = serv.createApp({
434
456
  refreshCookie: {
435
457
  // cookieName: 'hai_refresh_token', // 默认
436
458
  // maxAge: 30 * 24 * 3600, // 默认 30 天
437
- // secure: true, // 默认在 NODE_ENV=production 时开启
459
+ // secure: true, // NODE_ENV 或 HAI_CORE_ENV 为 production 时默认开启
438
460
  // onRefresh: customFn, // 可覆盖 iam.session.refresh。
439
461
  nativeTokenTransport: {
440
462
  // 必须 fail-closed:同时校验平台标识与允许的原生 Origin。
@@ -510,9 +532,10 @@ const app = serv.createApp({
510
532
  ? undefined
511
533
  : {
512
534
  crypto,
535
+ serverKeyRing,
536
+ keyStore,
513
537
  keyExchangePath: servConfig.transport.keyExchangePath,
514
538
  excludePaths: [...servConfig.transport.excludePaths],
515
- maxClients: servConfig.transport.maxClients,
516
539
  },
517
540
  })
518
541
  ```
@@ -555,7 +578,6 @@ transport:
555
578
  - /openapi.json
556
579
  - /docs
557
580
  - /_hai/scalar.js
558
- maxClients: 10000
559
581
  ```
560
582
 
561
583
  `server`(host/port):`HOST` / `PORT` 環境変数 > 設定ファイル > `127.0.0.1:3000`。アプリが `servConfig.server` を `serv.listen` に渡します。
@@ -660,6 +682,16 @@ const procedures = serv
660
682
 
661
683
  > `iam.user.xxx()` など下流が直接 `HaiResult` を返す場合、serv は `error.message` をそのまま渡します。`HaiError` は最終 `message` のみで `messageKey/params` を持たないため、**serv の境界で任意の下流エラーを汎用的に再翻訳することはできません**。リクエストごとの i18n はエラーを生成するモジュールで locale を受け取る必要があります。
662
684
 
685
+ ### リクエスト言語の分離
686
+
687
+ Node リクエストと SSR は `core.i18n.runWithLocale(locale, () => handleRequest())` で囲みます。SvelteKit は `kit.i18n.runWithLocale(locale, () => resolve(event))` を使えます。serv はリクエストヘッダーから自動設定します。非同期の子処理も言語を継承し、明示的な `MessageOptions.locale`、リクエスト言語、起動時の既定値の順で優先します。コンテキスト内の `setGlobalLocale` はそのリクエストだけを変更します。コンテキスト外の設定は起動時に限定し、リクエスト hook から呼びません。
688
+
689
+ ブラウザーはアプリ全体の `setGlobalLocale` / `createLocaleStore` を使います。SSR の UI store 初期化と setter は自身の状態のみ変更し、メッセージはリクエストコンテキストを参照します。`runWithLocale` は Node 入口専用です。
690
+
691
+ ## 入力制約と HTTP 機能
692
+
693
+ キーは UTF-8 で最大 1024 バイトで、絶対パス、バックスラッシュ、制御文字、ドットセグメントを拒否します。公開 HTTP 一括削除は一度に 1–1000 件です。共通規則はブラウザー対応の `@h-ai/storage/schemas` から参照できます。署名有効期限は整数 1–604800 秒(Node 既定 3600、HTTP 既定 900)。アップロード署名の入力は key/contentType/expiresIn のみで、contentLength/maxSize/metadata/checksum は未対応です。不正入力は HTTP 400、typed client では VALIDATION_FAILED です。FileUpload の制限は選択時の補助であり、実際のオブジェクトはサーバーで検証してください。Node のストリーム/checksum/条件付き書き込みには既定の HTTP エンドポイントがありません。[機能と認可の対応表(中国語)](../storage/README.md#存储输入约束与跨端能力)を参照してください。
694
+
663
695
  ## 開発とテスト
664
696
 
665
697
  ```bash
@@ -668,6 +700,8 @@ pnpm --filter @h-ai/serv typecheck
668
700
  pnpm --filter @h-ai/serv lint
669
701
  ```
670
702
 
703
+ 宣言の JSDoc 例は実在する `@h-ai/serv` または公開 features サブパスから import します。内部 Hono・guard・router・transport は「模块内部上下文」と明記し、ルート API として扱いません。起動設定の例外、業務 HaiResult、HTTP middleware の status、listener の Promise<void> 終了を区別し、既定の AI completion は非ストリーミング JSON のままです。保守時は `node scripts/check-module-jsdoc.mjs --package serv` で実際の宣言と内部スコープに対して例をコンパイルします。実 HTTP、認可、共有リプレイ防止の検証を代替しません。
704
+
671
705
  ## ライセンス
672
706
 
673
707
  Apache-2.0
package/README.md CHANGED
@@ -19,10 +19,10 @@ hai-framework 的 API 服务运行时:对外暴露稳定的 HTTP App 抽象,
19
19
  - `build()` 同步返回普通 router,可按嵌套属性选取默认 feature procedures;挂载的 contract 与所选 procedures 保持一致,未选入口不开放。
20
20
  - 缺失路由返回本地化的 HaiResult JSON 404,启用传输加密后保留 404,不误报为非 JSON 响应导致的 500。
21
21
  - 内置安全响应头、健康检查、可选 OpenAPI JSON、可选 Scalar 文档页、可选内部 RPC endpoint
22
- - 可选传输加密:`serv.createApp({ transport: { crypto } })` 自动挂载密钥协商与请求/响应加解密
22
+ - 可选传输加密:`serv.createApp({ transport: { crypto, serverKeyRing, keyStore } })` 自动挂载密钥协商与请求/响应加解密
23
23
  - 可选语音入口:`serv.createApp({ audio: { ai, verifyTicket, authorize } })` 挂载统一语音 WebSocket;连接建立即用一次性 ticket 鉴权(返回 `{ session, grant? }`,独立预鉴权超时),每帧运行时 Zod 校验 + 严格状态机,输入/积压/发送缓冲背压与级联取消;授权回调负责服务端确认操作、模型、音色、配额与并发占用
24
24
 
25
- > 生命周期说明:`@h-ai/serv` 本身是无状态 HTTP App 装配器,不需要 `init()` / `close()`;请在创建 app 前初始化 `iam` / `storage` / `ai` / `crypto` 等依赖,并在 `serv.listen(..., { onClose })` 中反向释放它们。`serv.createApp()` 遇到启动期配置错误(例如 transport manager 创建失败)会 fail-fast 抛出,便于进程启动阶段暴露问题。
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,业务模块由宿主另行反序释放。
26
26
 
27
27
  ## 使用入门
28
28
 
@@ -41,7 +41,11 @@ const contract = apiContract.create({ iam: apiContract.iam, storage: apiContract
41
41
  // 2) 组装 procedures(注入 iam / storage / ai 已初始化的 functions)
42
42
  const procedures = {
43
43
  iam: createIamProcedures({ iam }),
44
- storage: createStorageProcedures({ storage }),
44
+ storage: createStorageProcedures({
45
+ storage,
46
+ authorize: ({ context, keys }) => keys.every(key =>
47
+ key.startsWith(`users/${encodeURIComponent(context.session.userId)}/`)),
48
+ }),
45
49
  ai: createAiProcedures({ ai }),
46
50
  }
47
51
 
@@ -68,6 +72,14 @@ serv.listen(app, {
68
72
  // 5) 优雅关闭由 serv 托管,无需手动注册 process.once('SIGINT', ...)
69
73
  ```
70
74
 
75
+ Storage 默认 procedures 必须注入 `authorize({ context, operation, keys })`;未提供策略时默认拒绝。策略从已认证 session 确定资源范围,并覆盖下载、上传、列表、元数据和删除。上例只允许 `users/<编码后的 userId>/` 下的 key,列表同样必须携带该用户前缀;批量中任何一个 key 未获授权时全部不执行。管理员访问须由应用显式授权。
76
+
77
+ 批量删除 `storage.files.deleteMany` 成功时返回 `{ deletedKeys, failures: [] }`;失败进度位于 `error.ext.deleteMany`(`failed / unknown / notAttempted`),不能只根据 HTTP 200 判断完成。详见 [Storage 批量删除](../storage/README.md#api-概览)。
78
+
79
+ AI 默认 procedures 从可信 `context.session.userId` 绑定 `objectId`,客户端可省略该字段。聊天与消息写入、历史、会话列表和记忆 recall/list 均使用该主体;会话按 `objectId + sessionId` 隔离,写入未指定 sessionId 时使用 `default`。传入其它 objectId 默认返回 FORBIDDEN,管理员跨主体访问也必须显式传入 `authorizeObject` 并根据可信权限及租户关系授权。
80
+
81
+ `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
+
71
83
  #### host / port 配置
72
84
 
73
85
  host/port 统一由 `config/_serv.yml` 的 `server` 提供,优先级:`HOST` / `PORT` 环境变量 > 配置文件 > 默认 `127.0.0.1:3000`。默认 `127.0.0.1` 仅本机可达,容器对外暴露时改为 `0.0.0.0`:
@@ -81,7 +93,9 @@ server:
81
93
 
82
94
  应用把解析后的 `server` 传给 `serv.listen`(`serv.listen` 也可直接传 `host` / `port` 显式覆盖)。
83
95
 
84
- #### Fetch Runtime(Workers / Bun / Deno)
96
+ #### 标准 fetch 签名适配
97
+
98
+ toFetch 提供标准 fetch 调用签名,适合 Node 兼容宿主与测试注入;包根仍依赖 Node API,不能据此承诺无需 Node 能力的 Workers/Deno/边缘部署。
85
99
 
86
100
  ```ts
87
101
  const handler = serv.toFetch(app)
@@ -90,6 +104,8 @@ export default { fetch: handler }
90
104
 
91
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`。内部 RPC 仍按连接对端限制。
92
106
 
107
+ toFetch 对 Request、URL、字符串统一调用标准 Request(input, init),RequestInit 可覆盖 method、headers、body、signal 等字段;不修改原 Request 的字段,正文已消费/锁定或非法正文组合会以 Promise 拒绝。预取消和派发前取消不进入 handler,进行中取消结束调用方等待并把有效 signal 传给应用;监听器在完成/失败/取消后释放,取消后的迟到响应会取消未消费正文。取消不能回滚已经开始的外部副作用;Response 交付后的流由应用监听 request.signal 并自行关闭。
108
+
93
109
  #### Audio WebSocket:ticket 与服务端授权
94
110
 
95
111
  语音入口必须使用短期、一次性 ticket。应用通过已登录 HTTP endpoint 签发 ticket(推荐 `iam.ticket.issue`),并在 `verifyTicket` 中原子消费(推荐 `iam.ticket.consume`);连接建立即完成鉴权(独立预鉴权超时),未鉴权不处理任何业务帧。框架保存返回的 `ServSession` 与可选 `grant`,再调用 `authorize` 确认付费参数:
@@ -114,18 +130,22 @@ const app = serv.createApp({
114
130
  - 协议边界对每一帧做运行时 Zod 校验并按严格状态机接受(识别只收音频、合成只收文本、`start`/`done` 各一次、`segmentId` 会话内唯一);输入队列、消息积压与发送缓冲均有上限,超限或取消时以领域错误关闭并级联中止上游。
115
131
  - 可调参数:`preAuthTimeoutMs`(默认 5000)、`maxPendingMessages`(默认 256)、`maxSendBufferBytes`(默认 8 MiB)、`maxMessageBytes` / `maxBufferedBytes` / `maxTextBytes` / `maxSessionMs`。
116
132
  - `onSessionEnd` 在成功、失败或断连后至多调用一次,供应用释放并发占用。
117
- - 浏览器 `createAIClient({ audio: { url, getTicket } })` 只把一次性 `ticket` 放入 WebSocket URL;`getTicket(request)` 接收本次操作摘要(`operation` / `model`),可签发与本次操作严格绑定的票据;禁止传普通 IAM access token。
133
+ - 浏览器 `createAudioClient({ url, getTicket })` 只把一次性 `ticket` 放入 WebSocket URL;`getTicket(request)` 接收本次操作摘要(`operation` / `model`),可签发与本次操作严格绑定的票据;禁止传普通 IAM access token。
118
134
 
119
135
  #### 传输加密(与 `@h-ai/crypto` 统一)
120
136
 
121
- serv 不暴露本地传输加密工厂;统一从 `crypto.transport` 创建服务端管理器。使用方只需传入顶层 `crypto` 句柄:
137
+ serv 不暴露本地传输加密工厂;统一从 `crypto.transport` 创建服务端管理器。服务端显式注入顶层 `crypto`、受管密钥环和共享 registry:
122
138
 
123
139
  ```ts
124
140
  import { cache } from '@h-ai/cache'
125
- import { createRedisTransportKeyStore, crypto } from '@h-ai/crypto'
141
+ import { createRedisTransportKeyStore, crypto, parseTransportServerKeyRing } from '@h-ai/crypto'
126
142
  import { serv } from '@h-ai/serv'
127
143
 
128
144
  await crypto.init()
145
+ const ring = parseTransportServerKeyRing(process.env.HAI_CRYPTO_TRANSPORT_SERVERKEYRING)
146
+ if (!ring.success)
147
+ throw new Error(ring.error.message)
148
+ const serverKeyRing = ring.data
129
149
  await cache.init({ type: 'redis', host: '127.0.0.1', port: 6379 })
130
150
 
131
151
  const app = serv.createApp({
@@ -134,9 +154,9 @@ const app = serv.createApp({
134
154
  http: { apiPrefix: '/api/v1' },
135
155
  transport: {
136
156
  crypto,
157
+ serverKeyRing,
137
158
  // keyExchangePath 默认 '/_hai/key-exchange',最终端点为 '/api/v1/_hai/key-exchange'
138
159
  // excludePaths: ['/health'],
139
- // maxClients: 10000,
140
160
  keyStore: createRedisTransportKeyStore({ cache, ttlSeconds: 3600 }),
141
161
  },
142
162
  })
@@ -169,7 +189,6 @@ transport:
169
189
  - /openapi.json
170
190
  - /docs
171
191
  - /_hai/scalar.js
172
- maxClients: 10000
173
192
  ```
174
193
 
175
194
  ```ts
@@ -191,23 +210,24 @@ const app = serv.createApp({
191
210
  ? undefined
192
211
  : {
193
212
  crypto,
213
+ serverKeyRing,
214
+ keyStore,
194
215
  keyExchangePath: servConfig.transport.keyExchangePath,
195
216
  excludePaths: [...servConfig.transport.excludePaths],
196
- maxClients: servConfig.transport.maxClients,
197
217
  },
198
218
  })
199
219
  ```
200
220
 
201
- > **多节点部署提示**:传输加密的会话密钥默认保存在 **服务端进程内**,多副本部署时必须满足下列条件之一才能正常工作:
202
- >
203
- > - 在负载均衡层启用**会话粘性(sticky session)**,确保同一客户端命中同一节点;
204
- > - 或为每个客户端在每个节点首次访问时分别完成一次密钥协商;
205
- > - 或注入共享 `keyStore`(如 `createRedisTransportKeyStore()` / `createReldbTransportKeyStore()`)。
206
- >
207
- > `keyStore` 属于运行时对象依赖,不应写入 `config/_serv.yml`;配置文件仍只保存路径、白名单和容量等静态项。
221
+ > 服务端必须显式注入相同的受管 `serverKeyRing` 和共享 `keyStore`(Redis 或共享数据库)。仅共享客户端公钥不足以跨节点解密。没有隐式进程私钥或内存 registry 回退;开发/测试显式用 `createInMemoryKeyStore(capacity)`。服务端重启后重新加载同一密钥环即可,无需 sticky session。
208
222
 
209
223
  > **安全策略**:启用 `transport` 后,除 `excludePaths` 与密钥协商端点外,业务请求必须携带有效 `X-Client-Id`。缺失或未知 clientId 会返回 400;响应体无法加密、非 JSON 业务响应或超过单次加密上限时返回错误,不会明文透传业务数据。
210
224
 
225
+ 认证协议仅支持 v2:独立随机 SM4-CBC 密钥和 HMAC-SM3 密钥由 SM2 封装,先验签、再验证 MAC,最后解密 CBC。请求由客户端私钥签名,响应由所选服务端版本签名。认证字段包括 version、direction、serverKeyId、clientId、128 位随机 requestId(兼 nonce)、issuedAt、HTTP method、pathname + 原始 search 的 SM3 pathHash,以及响应 status。请求有效期 5 分钟,未来时钟偏差最多 30 秒;共享 registry 原子消费 (clientId, requestId),nonce 至少保留到 issuedAt + 6 分钟以覆盖节点偏差,存储故障拒绝请求。Redis 必须保留有效 nonce,使用 noeviction/可靠持久化策略;各节点时钟须同步。此能力防止同一传输消息重放,业务重试仍需自己的幂等键。
226
+
227
+ 空 POST 也发送空明文的认证封装;GET/HEAD 将封装放在 X-Hai-Transport 头,204/304/HEAD 等无正文响应也使用同名头承载认证封装。其它请求/响应在 JSON 正文承载封装并带 X-Encrypted: true。客户端必须关联原请求上下文和 HTTP status,缺标记、旧格式或认证失败一律 reject;明文接口只能经 excludePaths 显式排除。CORS 须允许并暴露 X-Hai-Transport。握手公钥的真实性依赖 TLS,不替代 IAM 业务授权。
228
+
229
+ 固定协议预算统一来自 `@h-ai/crypto` 的 `TRANSPORT_BODY_LIMITS`:握手 JSON 16 KiB,单个公钥 4 KiB,明文 1 MiB,密文 JSON 2 MiB + 32 KiB,均按 UTF-8 字节计数。原始流逐块有界读取,超限或取消会停止读取、取消流并释放 reader,不克隆后完整缓冲。请求正文超限返回 413,响应超限返回 500;无效公钥返回 400。重建请求时保留 signal、清空 Hono 正文缓存;重建请求/响应时清除旧 Content-Length/Content-Encoding。这些预算是协议常量,不是 YAML 或环境变量配置。
230
+
211
231
  #### Token 认证(`iam` / `refreshCookie`)
212
232
 
213
233
  `serv.createApp` 推荐直接传入顶层 `iam` 句柄,自动派生两件事:
@@ -250,6 +270,8 @@ const app = serv.createApp({
250
270
  })
251
271
  ```
252
272
 
273
+ 标准登出入口 `auth.logout` 从 input.accessToken 或 Authorization Bearer 取得要撤销的完整令牌,不要求 access 仍有效。它不接受 userId 授权或撤销目标;缺少令牌返回失败,未知令牌幂等成功。共享存储故障原样保留失败 HaiResult,Cookie middleware 只对成功结果清理 refresh Cookie。普通 procedure 继续使用既有 HTTP 200 / HaiResult 协议,客户端必须检查 success,不能只检查状态码。
274
+
253
275
  #### 自定义 procedure(认证 + 权限)
254
276
 
255
277
  ```ts
@@ -434,7 +456,7 @@ const app = serv.createApp({
434
456
  refreshCookie: {
435
457
  // cookieName: 'hai_refresh_token', // 默认
436
458
  // maxAge: 30 * 24 * 3600, // 默认 30 天
437
- // secure: true, // 默认在 NODE_ENV=production 时开启
459
+ // secure: true, // NODE_ENV 或 HAI_CORE_ENV 为 production 时默认开启
438
460
  // onRefresh: customFn, // 可覆盖 iam.session.refresh。
439
461
  nativeTokenTransport: {
440
462
  // 必须 fail-closed:同时校验平台标识与允许的原生 Origin。
@@ -510,9 +532,10 @@ const app = serv.createApp({
510
532
  ? undefined
511
533
  : {
512
534
  crypto,
535
+ serverKeyRing,
536
+ keyStore,
513
537
  keyExchangePath: servConfig.transport.keyExchangePath,
514
538
  excludePaths: [...servConfig.transport.excludePaths],
515
- maxClients: servConfig.transport.maxClients,
516
539
  },
517
540
  })
518
541
  ```
@@ -555,7 +578,6 @@ transport:
555
578
  - /openapi.json
556
579
  - /docs
557
580
  - /_hai/scalar.js
558
- maxClients: 10000
559
581
  ```
560
582
 
561
583
  `server`(host/port):优先级 `HOST` / `PORT` 环境变量 > 配置文件 > 默认 `127.0.0.1:3000`;由应用把 `servConfig.server` 传给 `serv.listen`。
@@ -660,6 +682,16 @@ const procedures = serv
660
682
 
661
683
  > 如果错误来自下游业务模块(如 `iam.user.xxx()`)且该模块已经直接返回 `HaiResult`,serv 会原样透传它的 `error.message`。当前 `HaiError` 只携带最终 `message`,不携带 `messageKey/params`,因此 **serv 边界无法对任意下游错误再做一次通用 i18n 重翻译**;要支持请求级 i18n,必须在创建该错误消息的模块里就拿到 locale。
662
684
 
685
+ ### 请求语言隔离
686
+
687
+ Node 请求与 SSR 使用 `core.i18n.runWithLocale(locale, () => handleRequest())`;SvelteKit 可使用 `kit.i18n.runWithLocale(locale, () => resolve(event))`,serv 自动按请求头建立上下文。异步子任务继承请求语言,优先级为显式 `MessageOptions.locale`、请求语言、进程启动默认语言。`setGlobalLocale` 在请求上下文中只更新当前请求;上下文外仅用于启动默认值,不能在请求 hook 中调用它修改默认值。
688
+
689
+ 浏览器继续使用应用级 `setGlobalLocale` / `createLocaleStore`。SSR 的 UI store 初始化和 setter 只修改自身状态,不写全局语言;UI 消息从请求上下文读取。`runWithLocale` 仅支持 Node 入口,浏览器通过显式 locale 或应用状态翻译。
690
+
691
+ ## 输入约束与 HTTP 能力
692
+
693
+ 存储 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#存储输入约束与跨端能力)。
694
+
663
695
  ## 开发测试
664
696
 
665
697
  ```bash
@@ -668,6 +700,8 @@ pnpm --filter @h-ai/serv typecheck
668
700
  pnpm --filter @h-ai/serv lint
669
701
  ```
670
702
 
703
+ 声明旁的 JSDoc 示例从实际 `@h-ai/serv` 或已公开的 features 子路径导入;内部 Hono/guard/router/transport 示例明确标注“模块内部上下文”,不代表根 API。示例区分工厂的启动配置异常、领域 HaiResult、HTTP middleware 状态码和监听句柄的 Promise<void> 关闭;默认 AI completion 仍只返回非流式 JSON。框架维护运行 `node scripts/check-module-jsdoc.mjs --package serv` 对实际声明和内部词法绑定编译示例,编译不代替真实 HTTP、授权与分布式防重放验收。
704
+
671
705
  ## 许可证
672
706
 
673
707
  Apache-2.0