@h-ai/serv 1.0.0-beta.2 → 1.0.0-beta.20

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
@@ -4,29 +4,13 @@
4
4
 
5
5
  [Framework home](../../README.en.md) · Related modules: [@h-ai/ai](../ai/README.en.md) · [@h-ai/api-contract](../api-contract/README.en.md) · [@h-ai/core](../core/README.en.md) · [@h-ai/crypto](../crypto/README.en.md) · [@h-ai/iam](../iam/README.en.md) · [@h-ai/storage](../storage/README.en.md)
6
6
 
7
- ## Introduction
8
-
9
7
  hai-framework's API service runtime: a stable public HTTP App abstraction that mounts `@h-ai/api-contract` contracts as cross-platform HTTP APIs. Hono is an internal implementation detail.
10
8
 
11
- ## Core features
9
+ Use runtime operations through `serv.xx`. Import `ServFunctions` from the root entry when annotating or re-exporting the namespace. Context factories and generic `implement` signatures support downstream declaration emission through public entries; no internal build paths or standalone helpers are needed.
12
10
 
13
- - Start with `serv.createApp(...)` → `serv.listen(app, { port })`; applications need not import Hono.
14
- - Flat API: `serv.createApp / serv.implement / serv.listen / serv.toFetch / serv.generateSpec / ...`.
15
- - Procedure chains: `implement().context().route().auth/permission/role().handle().build()`.
16
- - Custom pipelines: mount HTTP middleware with `createApp({ middlewares })`; inject request fields through context factories.
17
- - Read-only storage assets: `serv.storageAssets(...)` handles safe keys, MIME allowlists, GET/HEAD, ETags, and cache headers.
18
- - Default feature procedures: `createIamProcedures()`, `createStorageProcedures()`, `createAiProcedures()`.
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
- - 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
- - Built-in security headers and health checks; optional OpenAPI JSON, Scalar docs, and internal RPC endpoints.
22
- - Optional transport encryption: `serv.createApp({ transport: { crypto, serverKeyRing, keyStore } })` mounts key exchange and request/response encryption automatically.
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
-
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
-
27
- ## Getting started
11
+ ## Quick Start
28
12
 
29
- ### Quick start
13
+ The assembly below assumes application-initialized iam, storage, ai, core and logger, plus an application-owned closeApp callback. It is not an initialization script. A health-only app may use an empty contract and procedures. Serv is a factory with no init/close; close the listener and release business modules in reverse order.
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 bind `objectId` to the trusted `context.session.userId` when the c
80
65
 
81
66
  `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
67
 
83
- #### host / port configuration
68
+ ## Features
69
+
70
+ ### Purpose, inputs and outputs
71
+
72
+ Call fragments reuse the initialized service from this page. Named variables stand for application-provided requests, schemas, resource definitions or options. Follow the corresponding examples for setup and failure handling. Check HaiResult.success and await asynchronous calls.
73
+
74
+ | Entry | Use case | Input | Output | Call fragment |
75
+ | ---------------------------------- | ---------------------------- | ----------------------------------------------------------------------- | -------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------- |
76
+ | `serv.createApp` | Compose API app | `contract, procedures, http?, iam?, middlewares?, transport?` | `ServHttpApp (fetch/request)` | `serv.createApp({ contract, procedures })` |
77
+ | `serv.listen` | Start Node listener | `app, { host?, port?, onClose?, onListening? }` | `ServNodeServer { server, address, close(): Promise<void> }` | `serv.listen(app, { host: "127.0.0.1", port: 3000 })` |
78
+ | `serv.toFetch` | Adapt Fetch host | `ServHttpApp` | `(Request) => Promise<Response>` | `serv.toFetch(app)` |
79
+ | `serv.implement` | Implement domain routes | `typed contract` | `ServImplementer; context/route/auth/permission/role/handle/build` | `serv.implement(contract)` |
80
+ | `serv.parseRequestContext` | Parse request metadata | `{ request, platform? }` | `ServContext with requestId/locale/accessToken; no verified session` | `serv.parseRequestContext({ request })` |
81
+ | `serv.buildAuthContextFactory` | Build authenticated context | `(accessToken) => Promise<HaiResult<ServSession>>` | `CreateServContext; invalid credentials leave session absent` | `serv.buildAuthContextFactory(token => iam.auth.verifyToken(token))` |
82
+ | `serv.cors` | Restrict cross-origin access | `{ origin?, methods?, allowedHeaders?, exposedHeaders?, credentials? }` | `ServMiddleware` | `serv.cors({ origin: "https://app.example.com", credentials: true })` |
83
+ | `serv.createRuntimeSecurityPolicy` | Derive runtime security | `{ environment, corsOrigin, nativeOrigins? }` | `allowOrigin, secureRefreshCookie, exposeApiDocs` | `serv.createRuntimeSecurityPolicy({ environment: "production", corsOrigin: "https://app.example.com" })` |
84
+ | `serv.storageAssets` | Authorized read-only assets | `ServStorageAssetsConfig with reader/key/MIME policy` | `GET/HEAD middleware with ETag` | `serv.storageAssets(assetOptions)` |
85
+ | `serv.generateSpec` | Generate OpenAPI | `contract, { title?, version?, description?, apiPrefix? }` | `Promise<OpenAPI.Document>` | `serv.generateSpec(contract, { title: "Orders", version: "1.0.0" })` |
86
+ | `serv.validateInputOrFail` | Validate domain input | `Zod schema, input, locale?` | `HaiResult<z.infer<Schema>>; failure contains safe field errors` | `serv.validateInputOrFail(InputSchema, input, context.locale)` |
87
+ | `serv.resolveRequestLocale` | Resolve request locale | `Headers` | `normalized supported locale` | `serv.resolveRequestLocale(request.headers)` |
88
+ | `serv.m` | Translate module message | `message key, { locale?, ...messageOptions }` | `string` | `serv.m("serv_errorForbidden", { locale: "zh-CN" })` |
89
+ | `serv.WILDCARD_PERMISSION` | Wildcard permission | `none` | `string constant; only use in trusted permission policy` | `serv.WILDCARD_PERMISSION` |
90
+ | `serv.WILDCARD_ROLE` | Wildcard role | `none` | `string constant; does not authorize arbitrary requests` | `serv.WILDCARD_ROLE` |
91
+
92
+ - Start with `serv.createApp(...)` → `serv.listen(app, { port })`; applications need not import Hono.
93
+ - Flat API: `serv.createApp / serv.implement / serv.listen / serv.toFetch / serv.generateSpec / ...`.
94
+ - Procedure chains: `implement().context().route().auth/permission/role().handle().build()`.
95
+ - Custom pipelines: mount HTTP middleware with `createApp({ middlewares })`; inject request fields through context factories.
96
+ - Read-only storage assets: `serv.storageAssets(...)` handles safe keys, MIME allowlists, GET/HEAD, ETags, and cache headers.
97
+ - Default feature procedures: `createIamProcedures()`, `createStorageProcedures()`, `createAiProcedures()`.
98
+ - `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.
99
+ - Missing routes return localized HaiResult JSON with HTTP 404. Transport encryption preserves that status instead of producing a 500 for a non-JSON response.
100
+ - Built-in security headers and health checks; optional OpenAPI JSON, Scalar docs.
101
+ - Optional transport encryption: `serv.createApp({ transport: { crypto, serverKeyRing, keyStore } })` mounts key exchange and request/response encryption automatically.
102
+ - 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.
103
+
104
+ > 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 the HTTP listener and audio connections; the host separately releases business modules in reverse order.
105
+
106
+ The listener handle can close immediately after startup to cancel a pending bind; repeated closes share the same result. Actual listen failures, such as an occupied address, still reject the close result instead of reporting a normal shutdown.
107
+
108
+ ### host / port configuration
84
109
 
85
110
  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:
86
111
 
@@ -93,7 +118,7 @@ server:
93
118
 
94
119
  Pass the resolved `server` to `serv.listen`; explicit `host` / `port` arguments can also override it.
95
120
 
96
- #### Standard fetch signature adapter
121
+ ### Standard fetch signature adapter
97
122
 
98
123
  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.
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()` 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.
130
+ 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`.
106
131
 
107
132
  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
133
 
109
- #### Audio WebSocket: tickets and server authorization
134
+ ### Audio WebSocket: tickets and server authorization
110
135
 
111
136
  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:
112
137
 
@@ -132,7 +157,7 @@ const app = serv.createApp({
132
157
  - `onSessionEnd` runs at most once after success, failure, or disconnection to release concurrency slots.
133
158
  - 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.
134
159
 
135
- #### Transport encryption (shared with `@h-ai/crypto`)
160
+ ### Transport encryption (shared with `@h-ai/crypto`)
136
161
 
137
162
  serv does not expose its own encryption factory; the server manager comes from `crypto.transport`. Applications only pass the top-level `crypto` handle:
138
163
 
@@ -199,14 +224,16 @@ const app = serv.createApp({
199
224
  contract,
200
225
  procedures,
201
226
  http: servConfig.http,
202
- middlewares: [{
203
- middleware: serv.cors({
204
- origin: servConfig.cors.origin,
205
- credentials: servConfig.cors.credentials,
206
- allowedHeaders: [...servConfig.cors.allowedHeaders],
207
- exposedHeaders: [...servConfig.cors.exposedHeaders],
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
  > **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.
225
252
 
226
- 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.
227
-
228
- 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. Serv/Kit hosts explicitly allow plaintext through `encryption: disabled` path rules or host `excludePaths`. Callers must select ordinary requests; API Client/Kit wrappers use their own `excludePaths`. CORS must allow and expose X-Hai-Transport. TLS authenticates handshake keys; this protocol does not replace IAM authorization.
253
+ Transport uses authenticated v2 envelopes, verifying signature/MAC before decryption and binding method, path, time and response status. The shared registry atomically consumes nonces. The host still owns TLS, trusted proxies, authorization and idempotency. Custom CORS hosts must allow and expose X-Hai-Transport. See the [Transport protocol](../crypto/TRANSPORT-PROTOCOL.md) for fields, bodyless responses, clock windows and replay protection.
229
254
 
230
255
  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)。
231
256
 
232
257
  Ordinary routes retain `transport.maxPlaintextBytes` (auto/omitted: 1 MiB). Declare Feedback report intake, evidence, attachments and ZIP download capacity separately using `routePlaintextBytes` and `feedback.budget.maxTransportBytes`; `*` matches one complete path segment and explicit host route values take precedence. Handshakes, adapter-node and proxies use the maximum route capacity. HTTP adapters admit at most 2 explicitly declared material requests above 1 MiB and a separate 16 ordinary business requests, releasing admission after the entire response or failure. Busy admission returns 503 / `hai:crypto:072`; business writes are not automatically replayed.
233
258
 
234
- 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.
259
+ Serv/Kit return an encrypted HTTP 400 for an out-of-window request after validating request binding, signature and MAC. The client throws `TransportTimeError` (`hai:crypto:071`), displays the server’s UTC time at validation, and asks the user to enable automatic synchronization or set the device time manually before refreshing. `serverTime` is Unix milliseconds. Clients still show synchronization guidance when an older server omits it. `api-client` maps the error to `VALIDATION_FAILED` and preserves `cause.transportCode` and `cause.serverTime`.
235
260
 
236
- #### Token authentication (`iam` / `refreshCookie`)
261
+ ### Token authentication (`iam` / `refreshCookie`)
237
262
 
238
263
  Pass the top-level `iam` handle to `serv.createApp` to derive:
239
264
 
@@ -277,7 +302,7 @@ const app = serv.createApp({
277
302
 
278
303
  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.
279
304
 
280
- #### Custom procedures (authentication + permissions)
305
+ ### Custom procedures (authentication + permissions)
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
- #### Advanced pipelines (HTTP middleware + context + route guards)
319
+ ### Advanced pipelines (HTTP middleware + context + route guards)
295
320
 
296
321
  `@h-ai/serv` pipelines have three layers and extension points:
297
322
 
@@ -299,7 +324,7 @@ const procedures = serv
299
324
  2. **Context**: customize through `verifyToken` / `createContext` / `serv.buildAuthContextFactory()`.
300
325
  3. **Route guards**: declare authentication/authorization with `.auth()` / `.permission()` / `.role()`.
301
326
 
302
- ##### 1) Custom HTTP middleware
327
+ #### 1) Custom HTTP middleware
303
328
 
304
329
  Use `middlewares` for **HTTP cross-cutting logic** such as logging, tracing, rate limits, CORS, and tenant headers, or WebSocket/file/binary endpoints unsuited to JSON/oRPC contracts. Reuse `serv.cors(...)` for common CORS needs:
305
330
 
@@ -355,7 +380,7 @@ Execution order is fixed:
355
380
  2. Built-in CORS response wrapping; access checks for key exchange and preflight.
356
381
  3. Built-in `transport`, if enabled, and request access checks.
357
382
  4. Your `middlewares`.
358
- 5. health / refresh-cookie / OpenAPI / RPC / docs / oRPC routes
383
+ 5. health / refresh-cookie / OpenAPI / docs / oRPC routes
359
384
 
360
385
  Notes:
361
386
 
@@ -368,7 +393,7 @@ Notes:
368
393
  - Expose custom response headers such as `X-Encrypted` explicitly through `serv.cors({ exposedHeaders: [...] })` for browsers.
369
394
  - This layer works with HTTP middleware context and returns **HTTP Response**, not `HaiResult`.
370
395
 
371
- ##### 2) Custom context pipelines
396
+ #### 2) Custom context pipelines
372
397
 
373
398
  To reuse `Bearer token -> session` and add tenant, workspace, or source-system fields,
374
399
  prefer `serv.buildAuthContextFactory()` to assemble a custom `createContext`:
@@ -395,7 +420,7 @@ Choose the appropriate extension:
395
420
  - Add fields to default authentication context: use `buildAuthContextFactory(...) + createContext`.
396
421
  - Replace context construction entirely: use `createContext` directly.
397
422
 
398
- ##### 3) Route guard
423
+ #### 3) Route guard
399
424
 
400
425
  Public procedures use `.route(path, handler)`. For guarded routes, use
401
426
  `.route(path).auth/permission/role().handle(handler)`:
@@ -419,6 +444,7 @@ Constraints:
419
444
 
420
445
  - `.permission()` and `.role()` require authentication automatically. Failures return HaiResult with `error.httpStatus` 401/403; ordinary procedure responses remain HTTP 200 by default.
421
446
  - Multiple permissions/roles on one route must all pass. `serv.WILDCARD_PERMISSION` / `serv.WILDCARD_ROLE` allow their respective categories.
447
+ - Permissions also support the IAM/Kit colon-prefix rule: `users:*` matches `users:read`, but not `users2:read` or dot-separated permissions.
422
448
  - Unhandled handler exceptions become INTERNAL_ERROR HaiResult values; expected business failures should return `err(...)` directly.
423
449
  - Paths, input, and output come from the contract. Duplicate/unknown paths are rejected; `.build()` requires complete contract implementation.
424
450
 
@@ -426,9 +452,9 @@ Public API boundaries:
426
452
 
427
453
  - `src/pipelines/*` holds internal defaults and is not a public subpath API.
428
454
  - The stable application-facing entry remains `@h-ai/serv`.
429
- - Internal helpers such as `securityHeaders` / `requireInternalRPC` / `buildHaiErrorBody` are not documented as separate subpath exports.
455
+ - Internal helpers such as `securityHeaders` / `buildHaiErrorBody` are not documented as separate subpath exports.
430
456
 
431
- #### httpOnly cookie authentication (recommended for production)
457
+ ### httpOnly cookie authentication (recommended for production)
432
458
 
433
459
  httpOnly cookie mode stores refresh tokens in server-managed cookies unreadable to browser JavaScript, reducing XSS exposure.
434
460
 
@@ -485,13 +511,16 @@ await apiClient.init({
485
511
  })
486
512
  ```
487
513
 
488
- > **Cookie attributes**: `HttpOnly; SameSite=Strict; Secure` in production; `Path` is limited to `{apiPrefix}/auth/refresh`.
514
+ > Refresh cookies use `HttpOnly` and `SameSite=Strict` and are scoped to `{apiPrefix}/auth/refresh`. Production adds `Secure` by default.
515
+
516
+ Refresh failures use the public error boundary too: 5xx and missing or invalid statuses return a safe message and traceId, while internal causes stay in server logs. Only a 401 authentication failure clears the refresh cookie; rate limits and backend failures preserve credentials for retry. Automatic auditing uses the same normalized failure status.
489
517
 
490
518
  ### API overview
491
519
 
492
- - `serv.createApp(options)`: create the HTTP App with custom middleware, health checks, OpenAPI, optional docs, and RPC.
520
+ - `serv.createApp(options)`: create the HTTP App with custom middleware, health checks, OpenAPI, optional docs.
493
521
  - `serv.parseRequestContext({ request })`: extract Bearer token + requestId without populating session.
494
522
  - `serv.listen(app, options)`: start Node.js HTTP service; return `{ server, address, close }`.
523
+ The returned `close()` is idempotent: it stops accepting connections, closes Audio WebSockets, terminates uncooperative audio clients after one second, and waits for HTTP shutdown. Manual close also removes process signal listeners; the host still releases business modules in reverse order.
495
524
  - `serv.toFetch(app)`: wrap as a standard `fetch(Request)` handler.
496
525
  - `serv.generateSpec(contract, options)`: generate an OpenAPI 3.1 spec from the contract.
497
526
  - `serv.storageAssets(config)`: read-only storage middleware with key/MIME allowlists, GET/HEAD, and ETags.
@@ -524,7 +553,81 @@ Use `http.access: { file: './config/security/request-access.yml' }` for larger p
524
553
 
525
554
  `http.trustedProxies` defaults to `auto`: ordinary hosts ignore forwarded headers; only private managed Compose proxy topologies adopt container networks automatically. Custom ingress uses explicit proxy IPs/CIDRs; `[]` disables trust. Proxies sanitize forwarded headers, and Node listeners or Fetch platforms supply trusted peer metadata. See the [request access handbook](../../docs/usage/configuration/request-access.md) for files, edge proxies and deployment rollback.
526
555
 
527
- ### Configuration
556
+ ### Configuration driven authentication
557
+
558
+ Agreements support configuration-loaded userAgreementMarkdown/privacyPolicyMarkdown and URLs. Markdown takes priority for the same agreement and is safely rendered in a modal.
559
+
560
+ createIamProcedures({ iam }) implements public config, password/LDAP/OTP login, registration and recovery directly. LDAP uses the existing refresh Cookie/native transport policy. Administrator creation uses iam.user.createUser and remains available when public registration is disabled; existing permission guards apply.
561
+
562
+ [完整 IAM 配置 / IAM configuration](../iam/README.en.md)
563
+
564
+ ### Request locale isolation
565
+
566
+ 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.
567
+
568
+ 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.
569
+
570
+ ### Input limits and HTTP capabilities
571
+
572
+ 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#存储输入约束与跨端能力).
573
+
574
+ ### Optional feedback service and trusted scope
575
+
576
+ 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.
577
+
578
+ 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.
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
+ ### Optional interactive challenges
608
+
609
+ Standard IAM procedures include POST /auth/captcha, taking operation and returning a public ALTCHA challenge. With IAM captcha enabled, password/OTP/LDAP, registration, notification requests and anonymous password renewal enforce one-use proofs in IAM; mappings preserve captcha instead of relying on client checks.
610
+
611
+ See policy, defaults and recovery boundaries in [IAM](../iam/README.en.md).
612
+
613
+ ### Page exit session policy
614
+
615
+ `session.endOnPageHide` defaults to `false`. Hosts call `actions.bindPageExit({ enabled: config.session.endOnPageHide })` and invoke its returned cleanup on unmount; standard admin/H5/fullstack templates already bind it. A non-BFCache `pagehide` (including reload and full document navigation) makes a best effort POST to `/auth/session/close`, revoking the held access and related refresh through the existing CSRF/encrypted transport with `keepalive` and no automatic refresh. This response does not clear cookies/local credentials, avoiding a late old response overwriting a new login. Explicit logout still revokes and clears current credentials normally. Browsers may omit page exit requests; configure server `idleTimeout`/`absoluteTimeout` as appropriate. Immediate revocation on browser close is not guaranteed.
616
+
617
+ ## Configuration
618
+
619
+ ### Choose configuration by task
620
+
621
+ | Configuration entry | Scenario | Input | Effect | Example |
622
+ | ------------------------------------------------------ | ----------------------- | ------------------------------------------------------------------------ | -------------------------------------------------------------------- | -------------------------------------------------------------- |
623
+ | `server` | Node listener | 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` | API prefix | absolute validated prefix, default /api/v1 | OpenAPI/REST mounting prefix | `http: { apiPrefix: /api/v1 }` |
625
+ | `http.health` | Health and readiness | path, readyPath | health/readiness routes | `health: { path: /health, readyPath: /ready }` |
626
+ | `http.openapi / http.docs` | API documentation | false or explicit path/authorization settings | spec/Scalar routes; production exposure controlled by host policy | `openapi: false` |
627
+ | `http.trustedProxies / http.access` | Trusted ingress | proxy list/auto and access policy | trusted address and per-request entry policy | `trustedProxies: []` |
628
+ | `cors` | Cross-origin rules | origin/nativeOrigins/credentials/allowedHeaders/exposedHeaders | serv.cors middleware input; explicit production allowlist | `cors: { origin: https://app.example.com, credentials: true }` |
629
+ | `transport` | Authenticated transport | false or public paths/limits; serverKeyRing/keyStore injected separately | handshake and authenticated request/response handling | `transport: { maxPlaintextBytes: auto }` |
630
+ | `createApp.iam / refreshCookie / nativeTokenTransport` | Host auth assembly | 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` does not scan YAML itself. Load `config/_serv.yml` through `@h-ai/core` and pass the result to `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`: defaults to `/api/v1`.
706
+ - `apiPrefix`: defaults to `/api/v1`; accepts full absolute prefixes such as `/somepath/api/v1`. Segments use letters, digits, underscores, hyphens and dots; trailing slashes, empty segments, standalone `.`/`..`, queries and fragments are rejected. Configure health, docs and public storage paths under the same directory separately.
605
707
  - `health`: defaults to `{ path: '/health', readyPath: '/ready' }`.
606
708
  - `openapi`: defaults to `false`; enable with `{ path: '/openapi.json' }`.
607
- - `docs`: defaults to `false`; enable with `{ path: '/docs' }` (requires `openapi`). It mounts `/_hai/scalar.js` from the `@scalar/api-reference` browser bundle for local Scalar UI without an external CDN.
608
- - `rpc`: defaults to `false`; enable with `{ prefix: '/rpc', access: 'loopback' | 'private-network' | 'gateway-only', gatewayHeader?, gatewaySecret? }`.
709
+ - `docs`: defaults to `false`; enable with `{ path: '/docs' }`. Scalar is mounted as `_hai/scalar.js` in the document directory (default `/_hai/scalar.js`), without an external CDN. Script and spec URLs are relative. When a gateway preserves a prefix, place docs and OpenAPI paths within it; stripping a prefix also works. Backend `requireAuth` checks remain enforced.
609
710
  - `cors`: configured origins, credentials, allowedHeaders, and exposedHeaders; mount through `serv.cors(...)`.
610
711
  - `transport`: top-level `config/_serv.yml` option passed as `servConfig.transport` to `createApp({ transport })`; default key exchange subpath is `/_hai/key-exchange`.
611
712
 
@@ -626,33 +727,32 @@ 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
- middleware: serv.cors({
632
- origin: security.allowOrigin,
633
- credentials: servConfig.cors.credentials,
634
- allowedHeaders: [...servConfig.cors.allowedHeaders],
635
- exposedHeaders: [...servConfig.cors.exposedHeaders],
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
- In production, empty or `*` `_serv.yml` `cors.origin` fails startup. Web and Capacitor/Tauri origins use exact normalized full-Origin matching to prevent suffix spoofing. Production also enforces Secure refresh cookies and disables OpenAPI/docs; configuration controls nonproduction endpoints.
642
-
643
- ### Configuration driven authentication
745
+ Production requires an explicit `_serv.yml` `cors.origin` allowlist and disables OpenAPI and docs. Web and Capacitor/Tauri origins match by their exact normalized values.
644
746
 
645
- Agreements support configuration-loaded userAgreementMarkdown/privacyPolicyMarkdown and URLs. Markdown takes priority for the same agreement and is safely rendered in a modal.
747
+ For a production HTTP endpoint on a controlled private network or VPN, set `HAI_SERV_REFRESH_COOKIE_SECURE=false` in the backend process environment. After restarting the backend and signing in, login and refresh responses omit `Secure` from the cookie, and HTTP carries the refresh credential in plaintext. `true` forces `Secure`. An empty or unrecognized value uses the environment default. When calling `serv.createApp` directly, an explicit `refreshCookie.secure` takes precedence over this variable, followed by the production check on `NODE_ENV` or `HAI_CORE_ENV`.
646
748
 
647
- createIamProcedures({ iam }) implements public config, password/LDAP/OTP login, registration and recovery directly. LDAP uses the existing refresh Cookie/native transport policy. Administrator creation uses iam.user.createUser and remains available when public registration is disabled; existing permission guards apply.
648
-
649
- [完整 IAM 配置 / IAM configuration](../iam/README.en.md)
749
+ ### Transport capacity and module budgets
650
750
 
651
- ### Error handling
751
+ ## Error Handling
652
752
 
653
753
  Ordinary procedures return authentication, authorization, business failures and caught handler exceptions as HaiResult over HTTP 200 by default. `error.httpStatus` is domain-error metadata and does not change the response status. Clients must check `success` and `error.code`: an unauthenticated failure has `error.httpStatus=401`, while database query failures or INTERNAL_ERROR have 500.
654
754
 
655
- oRPC contract input validation fails before procedures with HTTP 400 and a localized body containing `error.httpStatus=400` and field errors. Procedure-level `serv.validateInputOrFail` failures remain HTTP 200. HTTP middleware, refresh-cookie, OpenAPI/RPC access checks and transport protocol failures follow their own HTTP response contracts. api-client refreshes after an actual HTTP 401 or a valid failed HaiResult over HTTP 200 (`success=false`, string `error.code/message` and `error.httpStatus=401`) and retries once on success. Built-in login and registration and the configured refresh path are excluded; 403/500 do not trigger refresh. With transport enabled, refresh and retry share the encrypted fetch path.
755
+ oRPC contract input validation fails before procedures with HTTP 400 and a localized body containing `error.httpStatus=400` and field errors. Procedure-level `serv.validateInputOrFail` failures remain HTTP 200. HTTP middleware, refresh-cookie, OpenAPI access checks and transport protocol failures follow their own HTTP response contracts. api-client refreshes after an actual HTTP 401 or a valid failed HaiResult over HTTP 200 (`success=false`, string `error.code/message` and `error.httpStatus=401`) and retries once on success. Built-in login and registration and the configured refresh path are excluded; 403/500 do not trigger refresh. With transport enabled, refresh and retry share the encrypted fetch path.
656
756
 
657
757
  - All default procedures return `HaiResult<T>`; clients check `result.success`.
658
758
  - Authentication failure → `HaiCommonError.UNAUTHORIZED`, localized by `.auth()` using `context.locale`.
@@ -697,49 +797,9 @@ const procedures = serv
697
797
  .build()
698
798
  ```
699
799
 
700
- > 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.
701
-
702
- ### Request locale isolation
703
-
704
- 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.
705
-
706
- 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.
707
-
708
- ## Input limits and HTTP capabilities
709
-
710
- 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#存储输入约束与跨端能力).
800
+ > Downstream 4xx messages are sanitized for public responses. 5xx and missing or invalid statuses use a safe internal-error message with a traceId; full causes remain in server logs. `HaiError` has no `messageKey/params`, so domain-message localization still belongs to the module that creates the message with the request locale.
711
801
 
712
- ## Optional feedback service and trusted scope
713
-
714
- 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.
715
-
716
- 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.
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
- ## Error messages and correlated logs
802
+ ### Error messages and correlated logs
743
803
 
744
804
  Native Audio WebSocket uses the same safe error boundary: internal Provider/SDK messages and stacks remain in sanitized logs, while clients receive stable codes, safe messages and traceId. Custom cross-origin configuration must preserve X-Hai-Reauthentication for sensitive actions; Serv/Kit defaults allow it.
745
805
 
@@ -749,19 +809,7 @@ Use `core.error.toPublic(error, traceId?)` at HTTP/UI boundaries and `core.error
749
809
 
750
810
  With audit initialized, procedures bind verified actors, trusted client addresses and request/scope IDs; domain audit.log inherits this context. Writes and failed/denied procedures produce request.execute. Contract validation, protocol, middleware and missing-route HTTP errors produce http.reject. Events contain operation/method/status or reason codes, without bodies, dynamic URLs, tokens or internal errors. capture.enabled disables automatic capture and capture.includeReads enables successful reads; explicit domain logs remain active. Applications still record meaningful domain completion/failure events.
751
811
 
752
- ## Optional interactive challenges
753
-
754
- Standard IAM procedures include POST /auth/captcha, taking operation and returning a public ALTCHA challenge. With IAM captcha enabled, password/OTP/LDAP, registration, notification requests and anonymous password renewal enforce one-use proofs in IAM; mappings preserve captcha instead of relying on client checks.
755
-
756
- See policy, defaults and recovery boundaries in [IAM](../iam/README.en.md).
757
-
758
- ## Page exit session policy
759
-
760
- `session.endOnPageHide` defaults to `false`. Hosts call `actions.bindPageExit({ enabled: config.session.endOnPageHide })` and invoke its returned cleanup on unmount; standard admin/H5/fullstack templates already bind it. A non-BFCache `pagehide` (including reload and full document navigation) makes a best effort POST to `/auth/session/close`, revoking the held access and related refresh through the existing CSRF/encrypted transport with `keepalive` and no automatic refresh. This response does not clear cookies/local credentials, avoiding a late old response overwriting a new login. Explicit logout still revokes and clears current credentials normally. Browsers may omit page exit requests; configure server `idleTimeout`/`absoluteTimeout` as appropriate. Immediate revocation on browser close is not guaranteed.
761
-
762
- ## Transport capacity and module budgets
763
-
764
- ## Development and testing
812
+ ## Development
765
813
 
766
814
  ```bash
767
815
  pnpm --filter @h-ai/serv test