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

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,26 @@ 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
+ Set `transport.maxPlaintextBytes` for both request and response JSON plaintext, measured in UTF-8 bytes: default 1 MiB, integer range 1 KiB–64 MiB. Clients learn the server limit during key exchange. `resolveTransportBodyLimits()` derives encrypted/proxy bytes as twice the plaintext limit plus 32 KiB. Handshake, public-key, header and replay constraints remain independent. Oversized streams are cancelled and readers released: requests return 413; oversized responses return an encrypted 500 capacity error, recognized as `TransportBodyTooLargeError` / `hai:crypto:070`. [Transport capacity and upgrade](../crypto/TRANSPORT-CAPACITY.md)。
230
+
231
+ serv/kit return an encrypted HTTP 400 time error for an out-of-window message only after request binding, signature and MAC verification. The client throws `TransportTimeError` (`hai:crypto:071`) with advice to synchronize device/server clocks and refresh. `api-client` maps it to `VALIDATION_FAILED` and preserves `cause.transportCode`. Requests are not replayed automatically and replay windows remain unchanged; tampered or unauthenticated messages retain the generic authentication failure.
232
+
211
233
  #### Token authentication (`iam` / `refreshCookie`)
212
234
 
213
235
  Pass the top-level `iam` handle to `serv.createApp` to derive:
@@ -250,6 +272,8 @@ const app = serv.createApp({
250
272
  })
251
273
  ```
252
274
 
275
+ 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.
276
+
253
277
  #### Custom procedures (authentication + permissions)
254
278
 
255
279
  ```ts
@@ -434,7 +458,7 @@ const app = serv.createApp({
434
458
  refreshCookie: {
435
459
  // cookieName: 'hai_refresh_token', // 默认
436
460
  // maxAge: 30 * 24 * 3600, // 默认 30 天
437
- // secure: true, // 默认在 NODE_ENV=production 时开启
461
+ // secure: true, // NODE_ENV 或 HAI_CORE_ENV 为 production 时默认开启
438
462
  // onRefresh: customFn, // 可覆盖 iam.session.refresh。
439
463
  nativeTokenTransport: {
440
464
  // 必须 fail-closed:同时校验平台标识与允许的原生 Origin。
@@ -510,9 +534,10 @@ const app = serv.createApp({
510
534
  ? undefined
511
535
  : {
512
536
  crypto,
537
+ serverKeyRing,
538
+ keyStore,
513
539
  keyExchangePath: servConfig.transport.keyExchangePath,
514
540
  excludePaths: [...servConfig.transport.excludePaths],
515
- maxClients: servConfig.transport.maxClients,
516
541
  },
517
542
  })
518
543
  ```
@@ -555,7 +580,6 @@ transport:
555
580
  - /openapi.json
556
581
  - /docs
557
582
  - /_hai/scalar.js
558
- maxClients: 10000
559
583
  ```
560
584
 
561
585
  `server` (host/port): `HOST` / `PORT` environment variables > configuration > `127.0.0.1:3000`. Applications pass `servConfig.server` to `serv.listen`.
@@ -660,6 +684,16 @@ const procedures = serv
660
684
 
661
685
  > 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
686
 
687
+ ### Request locale isolation
688
+
689
+ 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.
690
+
691
+ 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.
692
+
693
+ ## Input limits and HTTP capabilities
694
+
695
+ 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#存储输入约束与跨端能力).
696
+
663
697
  ## Development and testing
664
698
 
665
699
  ```bash
@@ -668,6 +702,38 @@ pnpm --filter @h-ai/serv typecheck
668
702
  pnpm --filter @h-ai/serv lint
669
703
  ```
670
704
 
705
+ 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.
706
+
707
+ ## Optional feedback service and trusted scope
708
+
709
+ Mount `apiContract.feedback` with `createFeedbackProcedures` from `@h-ai/serv/features/feedback`. The runtime root does not load the feedback service. Initialize reldb/storage/reach/feedback in the host, then map verified sessions and deployment policy to feedback scope. A null resolver result or mismatched user is denied; the domain module also checks scope and permissions.
710
+
711
+ All HTTP responses, including health, 404, and middleware short circuits, carry a bounded X-Request-Id. For ordinary business logs, provide the optional scopeId from the existing createContext factory. It is never read from client headers. The HTTP boundary clears previous identities, and authenticated asynchronous operations inherit the current request and user.
712
+
713
+ ```ts
714
+ import { apiContract } from '@h-ai/api-contract'
715
+ import { feedback } from '@h-ai/feedback'
716
+ import { iam } from '@h-ai/iam'
717
+ import { serv } from '@h-ai/serv'
718
+ import { createFeedbackProcedures } from '@h-ai/serv/features/feedback'
719
+
720
+ const contract = apiContract.create({ feedback: apiContract.feedback })
721
+ const authenticate = serv.buildAuthContextFactory(token => iam.auth.verifyToken(token))
722
+ const procedures = {
723
+ feedback: createFeedbackProcedures({
724
+ feedback,
725
+ resolveContext: context => context.scopeId
726
+ ? { scopeId: context.scopeId, userId: context.session.userId, permissions: context.session.permissions }
727
+ : null,
728
+ }),
729
+ }
730
+ const app = serv.createApp({
731
+ contract,
732
+ procedures,
733
+ createContext: async input => ({ ...await authenticate(input), scopeId: 'configured-product' }),
734
+ })
735
+ ```
736
+
671
737
  ## License
672
738
 
673
739
  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,26 @@ 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
+ `transport.maxPlaintextBytes` でリクエストとレスポンスの JSON 平文容量を共通設定します。単位は UTF-8 バイト、既定値は 1 MiB、有効範囲は 1 KiB~64 MiB の整数です。クライアントは鍵交換で上限を取得し、`resolveTransportBodyLimits()` が暗号文とプロキシ容量(平文の 2 倍 + 32 KiB)を算出します。鍵交換、公開鍵、認証ヘッダー、防リプレイ制約は独立です。超過時は元ストリームを停止して reader を解放します。要求は 413、応答は暗号化された 500 容量エラーとなり、`TransportBodyTooLargeError` / `hai:crypto:070` で識別できます。 [Transport capacity and upgrade](../crypto/TRANSPORT-CAPACITY.md)。
230
+
231
+ serv/kit は要求の結合、署名、MAC を検証した後、時間窓を超えた報文に暗号化した HTTP 400 時刻エラーを返します。クライアントは `TransportTimeError`(`hai:crypto:071`)で端末とサーバーの時刻同期・再読み込みを案内します。`api-client` は `VALIDATION_FAILED` に変換し `cause.transportCode` に原因を保持します。自動再送やリプレイ時間窓の緩和は行わず、改ざん・未認証の要求は従来の認証エラーです。
232
+
211
233
  #### Token 認証(`iam` / `refreshCookie`)
212
234
 
213
235
  `serv.createApp` にトップレベルの `iam` を渡すと、次を自動構成します:
@@ -250,6 +272,8 @@ const app = serv.createApp({
250
272
  })
251
273
  ```
252
274
 
275
+ 標準 `auth.logout` は input.accessToken または Authorization Bearer の完全な Token を取り消し、有効な access 認証を前提にしません。userId による権限や取消対象は受け付けません。Token 不足は失敗、不存在 Token は冪等成功です。共有ストア障害は失敗 HaiResult を保ち、Cookie middleware は成功時だけ refresh Cookie を消去します。通常 procedure の HTTP 200 / HaiResult 契約は同じなので、クライアントは HTTP status だけでなく success を確認します。
276
+
253
277
  #### 独自 procedure(認証 + 権限)
254
278
 
255
279
  ```ts
@@ -434,7 +458,7 @@ const app = serv.createApp({
434
458
  refreshCookie: {
435
459
  // cookieName: 'hai_refresh_token', // 默认
436
460
  // maxAge: 30 * 24 * 3600, // 默认 30 天
437
- // secure: true, // 默认在 NODE_ENV=production 时开启
461
+ // secure: true, // NODE_ENV 或 HAI_CORE_ENV 为 production 时默认开启
438
462
  // onRefresh: customFn, // 可覆盖 iam.session.refresh。
439
463
  nativeTokenTransport: {
440
464
  // 必须 fail-closed:同时校验平台标识与允许的原生 Origin。
@@ -510,9 +534,10 @@ const app = serv.createApp({
510
534
  ? undefined
511
535
  : {
512
536
  crypto,
537
+ serverKeyRing,
538
+ keyStore,
513
539
  keyExchangePath: servConfig.transport.keyExchangePath,
514
540
  excludePaths: [...servConfig.transport.excludePaths],
515
- maxClients: servConfig.transport.maxClients,
516
541
  },
517
542
  })
518
543
  ```
@@ -555,7 +580,6 @@ transport:
555
580
  - /openapi.json
556
581
  - /docs
557
582
  - /_hai/scalar.js
558
- maxClients: 10000
559
583
  ```
560
584
 
561
585
  `server`(host/port):`HOST` / `PORT` 環境変数 > 設定ファイル > `127.0.0.1:3000`。アプリが `servConfig.server` を `serv.listen` に渡します。
@@ -660,6 +684,16 @@ const procedures = serv
660
684
 
661
685
  > `iam.user.xxx()` など下流が直接 `HaiResult` を返す場合、serv は `error.message` をそのまま渡します。`HaiError` は最終 `message` のみで `messageKey/params` を持たないため、**serv の境界で任意の下流エラーを汎用的に再翻訳することはできません**。リクエストごとの i18n はエラーを生成するモジュールで locale を受け取る必要があります。
662
686
 
687
+ ### リクエスト言語の分離
688
+
689
+ Node リクエストと SSR は `core.i18n.runWithLocale(locale, () => handleRequest())` で囲みます。SvelteKit は `kit.i18n.runWithLocale(locale, () => resolve(event))` を使えます。serv はリクエストヘッダーから自動設定します。非同期の子処理も言語を継承し、明示的な `MessageOptions.locale`、リクエスト言語、起動時の既定値の順で優先します。コンテキスト内の `setGlobalLocale` はそのリクエストだけを変更します。コンテキスト外の設定は起動時に限定し、リクエスト hook から呼びません。
690
+
691
+ ブラウザーはアプリ全体の `setGlobalLocale` / `createLocaleStore` を使います。SSR の UI store 初期化と setter は自身の状態のみ変更し、メッセージはリクエストコンテキストを参照します。`runWithLocale` は Node 入口専用です。
692
+
693
+ ## 入力制約と HTTP 機能
694
+
695
+ キーは 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#存储输入约束与跨端能力)を参照してください。
696
+
663
697
  ## 開発とテスト
664
698
 
665
699
  ```bash
@@ -668,6 +702,38 @@ pnpm --filter @h-ai/serv typecheck
668
702
  pnpm --filter @h-ai/serv lint
669
703
  ```
670
704
 
705
+ 宣言の 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、認可、共有リプレイ防止の検証を代替しません。
706
+
707
+ ## オプションのフィードバックサービスと信頼範囲
708
+
709
+ `@h-ai/serv/features/feedback` の `createFeedbackProcedures` で `apiContract.feedback` を接続します。通常のランタイム入口は feedback サービスを読み込みません。ホストで reldb/storage/reach/feedback を初期化し、検証済みセッションと配置ポリシーから範囲を作ります。null またはユーザー不一致は拒否し、ドメイン側も範囲と権限を検証します。
710
+
711
+ ヘルスチェック・404・中間件の早期応答を含む全 HTTP 応答に上限付き X-Request-Id を付けます。通常の業務ログでは既存 createContext の省略可能な scopeId を信頼できるホストから設定します。クライアントのヘッダーからは取得しません。HTTP 境界で古い認証情報を消去し、認証後の非同期処理に現在の ID とユーザーを伝播します。
712
+
713
+ ```ts
714
+ import { apiContract } from '@h-ai/api-contract'
715
+ import { feedback } from '@h-ai/feedback'
716
+ import { iam } from '@h-ai/iam'
717
+ import { serv } from '@h-ai/serv'
718
+ import { createFeedbackProcedures } from '@h-ai/serv/features/feedback'
719
+
720
+ const contract = apiContract.create({ feedback: apiContract.feedback })
721
+ const authenticate = serv.buildAuthContextFactory(token => iam.auth.verifyToken(token))
722
+ const procedures = {
723
+ feedback: createFeedbackProcedures({
724
+ feedback,
725
+ resolveContext: context => context.scopeId
726
+ ? { scopeId: context.scopeId, userId: context.session.userId, permissions: context.session.permissions }
727
+ : null,
728
+ }),
729
+ }
730
+ const app = serv.createApp({
731
+ contract,
732
+ procedures,
733
+ createContext: async input => ({ ...await authenticate(input), scopeId: 'configured-product' }),
734
+ })
735
+ ```
736
+
671
737
  ## ライセンス
672
738
 
673
739
  Apache-2.0