@h-ai/api-client 1.0.0-beta.2 → 1.0.0-beta.21

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.en.md CHANGED
@@ -6,54 +6,119 @@ Requests and token refresh use the current `core.i18n` locale as `Accept-Languag
6
6
 
7
7
  [Framework home](../../README.en.md) · Related modules: [@h-ai/api-contract](../api-contract/README.en.md) · [@h-ai/core](../core/README.en.md) · [@h-ai/crypto](../crypto/README.en.md)
8
8
 
9
- ## Introduction
10
-
11
9
  A cross-platform oRPC/OpenAPI typed client for hai-framework, with consistent API calls and token management across Web, apps, and mini programs.
12
10
 
13
- ## Core features
14
-
15
- - `apiClient`: the default typed client for the iam/storage/ai domains.
16
- - `apiClient.create(contract)`: create a typed client for a custom contract.
17
- - `apiClient.tokenStorage.{memory,httpOnlyCookie}()`: token storage factories. Browsers keep access tokens in memory and refresh tokens in server-managed httpOnly cookies; native clients inject platform secure storage. Credentials are never written to Web Storage.
18
- - Custom `fetch` support for browsers, Node, Capacitor, and mini-program bridges.
19
- - Automatic Bearer token injection, with token refresh and one retry after a 401.
20
- - Network failures are converted to `HaiResult` errors.
21
- - Optional transport encryption: `apiClient.init({ transport: { crypto } })` automatically uses `crypto.transport.createClient()`.
22
- - Transport encryption and automatic refresh can run together: `/auth/refresh` after a 401 still uses encrypted fetch without falling back to plaintext.
11
+ ## Quick Start
23
12
 
24
- ## Getting started
25
-
26
- ### Quick start
13
+ `close()` invalidates retired refreshes and drains credential writes while preserving external TokenStorage values. After close completes, the same storage may be reused. `timeout` covers headers, the complete JSON body, and every unsuccessful response body, including HTML error pages. Timeout or cancellation prevents token refresh. The built-in Cookie adapter includes credentials for login and refresh, requiring explicit credentialed CORS origins; memory and native storage preserve the original request credentials. Custom application streams remain incremental and their bodies use the caller signal for cancellation.
27
14
 
28
15
  ```ts
16
+ import type { IamAuthLoginInput } from '@h-ai/api-client'
29
17
  import { apiClient } from '@h-ai/api-client'
30
18
 
31
- await apiClient.init({
32
- baseUrl: 'https://api.example.com/api/v1',
33
- auth: {}, // 默认使用 httpOnly cookie 存储(推荐);SSR 测试请显式传入 apiClient.tokenStorage.memory()
34
- })
19
+ async function loginAndLoadProfile(input: IamAuthLoginInput) {
20
+ const initialized = await apiClient.init({ baseUrl: '/api/v1', auth: {} })
21
+ if (!initialized.success)
22
+ return initialized
23
+ try {
24
+ const actions = apiClient.createAuthActions(apiClient)
25
+ const loggedIn = await actions.login(input)
26
+ if (!loggedIn.success)
27
+ return loggedIn
28
+ const profile = await apiClient.iam.auth.currentUser()
29
+ if (!profile.success)
30
+ return profile
31
+ return profile
32
+ }
33
+ catch (writeError) {
34
+ try {
35
+ await apiClient.auth.clear()
36
+ }
37
+ catch (cleanupError) {
38
+ throw new AggregateError([writeError, cleanupError], 'Credential persistence and cleanup failed')
39
+ }
40
+ throw writeError
41
+ }
42
+ finally {
43
+ await apiClient.close()
44
+ }
45
+ }
46
+ ```
35
47
 
36
- const login = await apiClient.iam.auth.login({
37
- identifier: 'alice',
38
- password: 'secret',
39
- })
48
+ ## Features
40
49
 
41
- if (login.success) {
42
- // httpOnly cookie 模式下 refresh token 由服务端 Set-Cookie 管理,
43
- // api-client 默认存储只需要把 access token 写入内存。
44
- await apiClient.auth.setTokens(login.data.tokens)
45
- }
50
+ ### Purpose, inputs and outputs
46
51
 
47
- const me = await apiClient.iam.auth.currentUser()
48
- if (me.success) {
49
- // 身份资料与服务端重新校验后的访问范围一次返回,客户端无需持久化权限快照。
50
- const { roles, permissions, ...user } = me.data
51
- }
52
+ Initialize first. Named variables are validated host input; check HaiResult.success. auth and storage Promise<void> operations may reject: surface the failure and handle cleanup faults instead of reporting successful login from a server result alone.
52
53
 
53
- await apiClient.close()
54
+ | Entry | Input | Output | Purpose and failure boundary | Example |
55
+ | --------------------------------------- | ------------------------------- | -------------------------- | ------------------------------------------------------------------------- | ------------------------------------------------------------ |
56
+ | `apiClient.init` | config: baseUrl、认证及传输选项 | `Promise<HaiResult<void>>` | Initialize; stop on failure | `await apiClient.init({ baseUrl: '/api/v1', auth: {} })` |
57
+ | `apiClient.close` | none | `Promise<void>` | Invalidate refresh and release this instance; not server logout | `await apiClient.close()` |
58
+ | `apiClient.create` | shared contract | `ApiClient<TContract>` | Create isolated instances, one per SSR request | `apiClient.create(contract)` |
59
+ | `apiClient.auth.setTokens` | trusted response | `Promise<void>` | Serialize credentials and invalidate old refresh; storage failures reject | `await apiClient.auth.setTokens(tokens)` |
60
+ | `apiClient.auth.clear` | none | `Promise<void>` | Clear local credentials; may reject; server still needs logout | `await apiClient.auth.clear()` |
61
+ | `apiClient.auth.onTokenRefreshed` | callback(TokenPair) | `() => void` | Observe after persistence; returns unsubscribe; never log tokens | `apiClient.auth.onTokenRefreshed(() => refreshProfile())` |
62
+ | `apiClient.tokenStorage.memory` | none | `TokenStorage` | Isolated in-memory adapter for Node/SSR/tests | `apiClient.tokenStorage.memory()` |
63
+ | `apiClient.tokenStorage.httpOnlyCookie` | none | `TokenStorage` | Browser access in memory, refresh in server Cookie | `apiClient.tokenStorage.httpOnlyCookie()` |
64
+ | `apiClient.createAuthActions` | standard IAM contract | `Auth actions object` | Bind auth forms after init with auth enabled | `apiClient.createAuthActions(apiClient)` |
65
+ | `apiClient.observeRequests` | observer(ApiRequestRecord) | `() => void` | Observe sanitized logical outcomes; returns unsubscribe | `apiClient.observeRequests(record => reportOutcome(record))` |
66
+
67
+ #### Authentication form actions
68
+
69
+ `const actions = apiClient.createAuthActions(apiClient)`
70
+
71
+ | Entry | Input | Output | Purpose and failure boundary | Example |
72
+ | ------------------------------ | ----------------------------------------------- | --------------------------------------------- | ------------------------------------------------------------ | -------------------------------------------------------------------- |
73
+ | `actions.publicConfig` | — | `Promise<HaiResult<T>>` → IamPublicConfig | Read public auth policy | `await actions.publicConfig()` |
74
+ | `actions.createCaptcha` | { operation } | `Promise<HaiResult<T>>` → IamCaptchaChallenge | Request an operation challenge | `await actions.createCaptcha({ operation: 'login' })` |
75
+ | `actions.login` | IamAuthLoginInput | `Promise<HaiResult<T>>` → auth result | Map password/ldap/email/sms and await credential persistence | `await actions.login({ identifier, password })` |
76
+ | `actions.register` | IamRegisterInput | `Promise<HaiResult<T>>` → auth result | Register under server policy and save credentials | `await actions.register({ username, password })` |
77
+ | `actions.sendOtp` | identifier, { captcha? }? | `Promise<HaiResult<T>>` → { expiresAt } | Send email/phone OTP; does not log in | `await actions.sendOtp(identifier)` |
78
+ | `actions.requestPasswordReset` | identifier, { captcha? }? | `Promise<HaiResult<T>>` → void | Request reset under server account-enumeration policy | `await actions.requestPasswordReset(identifier)` |
79
+ | `actions.confirmPasswordReset` | token, newPassword | `Promise<HaiResult<T>>` → void | Consume the dedicated reset token | `await actions.confirmPasswordReset(resetToken, newPassword)` |
80
+ | `actions.renewPassword` | { identifier, password, newPassword, captcha? } | `Promise<HaiResult<T>>` → void | Renew an expired password | `await actions.renewPassword({ identifier, password, newPassword })` |
81
+ | `actions.reauthenticate` | password, operation | `Promise<HaiResult<T>>` → purpose ticket | Obtain a short-lived operation-bound credential | `await actions.reauthenticate(password, 'users:delete')` |
82
+ | `actions.bindPageExit` | { enabled? } | `() => void` | Bind best-effort page-exit revocation; returns cleanup | `actions.bindPageExit({ enabled: endOnPageHide })` |
83
+
84
+ #### Secure storage instance
85
+
86
+ `const storage = apiClient.tokenStorage.memory()`
87
+
88
+ | Entry | Input | Output | Purpose and failure boundary | Example |
89
+ | ------------------------- | ----- | ------------------------- | ---------------------------------------------------------------- | --------------------------------------------- |
90
+ | `storage.getAccessToken` | — | `Promise<string \| null>` | Read access; null means absent only | `await storage.getAccessToken()` |
91
+ | `storage.setAccessToken` | token | `Promise<void>` | Save access; bound clients use auth.setTokens | `await storage.setAccessToken(accessToken)` |
92
+ | `storage.getRefreshToken` | — | `Promise<string \| null>` | Cookie mode yields an internal sentinel, not Cookie contents | `await storage.getRefreshToken()` |
93
+ | `storage.setRefreshToken` | token | `Promise<void>` | Memory saves; Cookie adapter does not change server Cookie | `await storage.setRefreshToken(refreshToken)` |
94
+ | `storage.clear` | — | `Promise<void>` | Clear adapter credentials; failures reject; no server revocation | `await storage.clear()` |
95
+
96
+ #### Default business domains
97
+
98
+ Default domains mirror HTTP contracts in api-contract, not the complete server module objects. A custom client contains only the supplied contract.
99
+
100
+ - `apiClient.iam`: IAM login, registration, users, roles and permissions — [API Contract](../api-contract/README.md), [IAM](../iam/README.md).
101
+ - `apiClient.storage`: Storage listing, metadata, presigning and deletion — [API Contract](../api-contract/README.md), [Storage](../storage/README.md).
102
+ - `apiClient.ai`: Non-streaming AI chat — [API Contract](../api-contract/README.md), [AI](../ai/README.md).
103
+
104
+ Follow the actual contract for arrays, permissions and business errors. The server authorizes identity/resources; client settings grant no permission.
105
+
106
+ ```ts
107
+ const files = await apiClient.storage.files.list({ prefix: 'reports/', maxKeys: 20 })
108
+ if (!files.success)
109
+ throw new Error(files.error.message)
110
+ const chat = await apiClient.ai.chats.createCompletion({
111
+ messages: [
112
+ { role: 'user', content: 'Summarize this public report.' }
113
+ ],
114
+ })
115
+ if (!chat.success)
116
+ throw new Error(chat.error.message)
54
117
  ```
55
118
 
56
- #### Enable transport encryption
119
+ `apiClient.config` is a redacted runtime snapshot (null before initialization), not a source for restoring secrets. `apiClient.isInitialized` is the ready boolean. Both are read-only properties and make no network request.
120
+
121
+ ### Enable transport encryption
57
122
 
58
123
  First enable `serv.createApp({ transport: { crypto, serverKeyRing, keyStore } })` on the server. The client only needs the same `@h-ai/crypto` service instance; no manual key exchange code is required.
59
124
 
@@ -75,7 +140,7 @@ await apiClient.close()
75
140
  await crypto.close()
76
141
  ```
77
142
 
78
- 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.
143
+ 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. By default, requests expire after five minutes and client timestamps may be up to 30 seconds ahead. Set `maxRequestAgeSeconds` (1–600) and `maxFutureSkewSeconds` (0–300) on the server to adjust these limits in whole seconds. The shared registry atomically consumes (clientId, requestId) and retains the nonce until issuedAt plus 11 minutes, covering the longest configurable window and server clock differences. 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.
79
144
 
80
145
  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.
81
146
 
@@ -96,30 +161,12 @@ await client.init({ baseUrl: 'https://api.example.com/api/v1' })
96
161
  const result = await client.iam.auth.login({ identifier: 'alice', password: 'secret' })
97
162
  ```
98
163
 
99
- ### API overview
100
-
101
- - `apiClient.init(config)`: initialize the default client.
102
- - `apiClient.close()`: clear client state.
103
- - `apiClient.auth.setTokens(tokens)`: replace credentials and store the access token; non-httpOnly storage saves the supplied refresh token or clears its previous value when omitted.
104
- - `apiClient.auth.clear()`: clear tokens.
105
- - `apiClient.create(contract)`: create a custom typed client.
164
+ ### Credential changes and closing
106
165
 
107
166
  `auth.clear()` and `auth.setTokens()` immediately cancel and invalidate this client's previous refresh request. A late success or 401 cannot restore a logged-out session, overwrite a new account, or notify stale refresh callbacks. Storage writes are serialized, so an earlier asynchronous write cannot overwrite credentials after either method completes. Use these methods when logging out or switching accounts instead of writing to storage directly. The server logout endpoint must still revoke the httpOnly cookie and server session.
108
167
 
109
168
  Calling `init()` on an initialized client returns `CONFIG_ERROR` and leaves the existing client intact. To change `baseUrl` / `fetch` / `transport`, call `await apiClient.close()` before initializing again.
110
169
 
111
- ### Configuration
112
-
113
- - `baseUrl`: API base URL, usually including `/api/v1`.
114
- - `auth.storage`: token storage adapter; defaults to `apiClient.tokenStorage.httpOnlyCookie()` (recommended for browsers). Explicitly use `apiClient.tokenStorage.memory()` for SSR / tests.
115
- - `auth.refreshPath`: token refresh path; defaults to `/auth/refresh`.
116
- - `timeout`: request timeout; defaults to 30000 ms.
117
- - `headers`: static or dynamic shared request headers.
118
- - `fetch`: custom fetch implementation.
119
- - `transport.crypto`: transparent request/response encryption and decryption; requires an initialized `@h-ai/crypto` instance.
120
- - `transport.keyExchangePath`: key exchange subpath; defaults to `/_hai/key-exchange` and is appended to `baseUrl` automatically.
121
- - `apiClient.config`: a **redacted snapshot of runtime configuration**; credentials embedded in `baseUrl`, `headers.authorization`, and other sensitive values are hidden automatically.
122
-
123
170
  ### Configuration driven authentication
124
171
 
125
172
  Password login submits only `identifier` and `password`; `AuthPanel` maps the `LoginForm` field `username` to `identifier`. Server settings `session.maxAge` / `session.refreshTokenMaxAge` control session lifetimes. The login form has no per-login persistent-session option.
@@ -130,31 +177,11 @@ apiClient.createAuthActions(client) supplies publicConfig, login, register, send
130
177
 
131
178
  [完整 IAM 配置 / IAM configuration](../iam/README.en.md)
132
179
 
133
- ### Error handling
134
-
135
- Business APIs return `HaiResult<T>`:
136
-
137
- ```ts
138
- const result = await apiClient.iam.auth.currentUser()
139
- if (!result.success) {
140
- // result.error.code / result.error.message
141
- }
142
- else {
143
- // result.data = User & { roles: string[], permissions: string[] }
144
- }
145
- ```
146
-
147
- Client-side failures such as missing initialization, network errors, timeouts, and 401/403/404 responses are also converted to failed `HaiResult` values.
148
-
149
- A valid failed `HaiResult` returned with a non-2xx HTTP status retains its business error code, message and `traceId`; a 409 conflict can therefore explain how to retry. The actual HTTP response determines `httpStatus`. Internal 5xx diagnostics remain hidden even if the body claims status 400. Other response formats continue to use client error mappings.
150
-
151
- Automatic refresh handles an actual HTTP 401 (`Response.status`) or a valid failed HaiResult over HTTP 200 (`success=false`, string `error.code/message` and `error.httpStatus=401`). After a successful refresh, the original request is retried once. Built-in login and registration routes and the configured `auth.refreshPath` never trigger automatic refresh; other failures such as 403/500 do not trigger it either. Ordinary serv procedures still return HTTP 200. Callers must check the final `success` and `error.code` and handle expired authentication when refresh fails.
152
-
153
- ## Input limits and HTTP capabilities
180
+ ### Input limits and HTTP capabilities
154
181
 
155
182
  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#存储输入约束与跨端能力).
156
183
 
157
- ## Logical request observation
184
+ ### Logical request observation
158
185
 
159
186
  `client.observeRequests` observes each logical call once and returns an unsubscribe function. Records contain requestId, procedure, duration, HTTP status, final success/errorCode, and business request attempts. A failed HaiResult with HTTP 200 is a failure. Authentication retries retain the same requestId. Records exclude bodies, tokens, and URL query parameters. Refresh and key exchange are not presented as user operations. close clears subscriptions, so subscribe again after reinitializing.
160
187
 
@@ -168,33 +195,65 @@ const unsubscribe = apiClient.observeRequests((record) => {
168
195
  unsubscribe()
169
196
  ```
170
197
 
171
- ## Error messages and correlated logs
172
-
173
- HTTP errors carry safe localized messages and a reference ID. ToastContainer hides the ID by default and reveals it through its diagnostic icon. The same ID remains in `error.traceId` and `X-Trace-Id`. The framework reuses the request ID to correlate browser and server logs. Sanitized exceptions, stacks and operation context remain in logs; internal cause/ext are not exposed. Field validation returns localized field names and messages only.
174
-
175
- Use `core.error.toPublic(error, traceId?)` at HTTP/UI boundaries and `core.error.formatMessage(error)` after translating messages; formatting is idempotent. Pass complete Error objects to logs instead of discarding their context. Database failures log driver codes, sanitized messages/stacks and table/column context; SQL text, bound values and connection credentials are excluded.
176
-
177
- ## Optional interactive challenges
198
+ ### Optional interactive challenges
178
199
 
179
200
  createAuthActions(client) supplies createCaptcha({operation}) and preserves captcha in login mappings, renewal and registration. sendOtp(identifier,{captcha}) and requestPasswordReset(identifier,{captcha}) take an optional second argument. AuthPanel solves automatically; custom forms reuse ALTCHA without persisting proofs in TokenStorage.
180
201
 
181
202
  See policy, defaults and recovery boundaries in [IAM](../iam/README.en.md).
182
203
 
183
- ## Sensitive request reauthentication
204
+ ### Sensitive request reauthentication
184
205
 
185
206
  Pass the purpose-bound proof returned by `reauthenticate` through the individual call’s `options.context.reauthentication`; the request pipeline emits `X-Hai-Reauthentication`. Never place it in global client headers, persistent storage or logs. The server checks current permissions, resource scope and proof purpose before atomic consumption. Concurrent ordinary calls do not inherit the header. Cross-origin hosts must allow it; Serv/Kit defaults already include it.
186
207
 
187
- ## Recovering an expired session in place
208
+ ### Recovering an expired session in place
188
209
 
189
210
  Set `auth.recoverSession` to sign in again without discarding the current page. Use UI `createSessionRecovery` / `SessionRecoveryDialog`: configure the public policy and original user in the root layout, pass `recovery.recover` as the callback, provide standard authentication actions, and cancel on teardown. IAM checks login `expectedUserId` before issuing a session; captcha, lockout, password renewal and access rules remain active. Only an explicitly authentication-rejected request is retried once. Cancellation, 5xx, timeouts and ambiguous writes are not replayed. Sensitive-operation proofs from an old session may need a new confirmation. Without the callback, behavior is unchanged; with it, recovery handles failure instead of immediate onRefreshFailed navigation. auth.terminalType (web/app/desktop/api; default web) selects a session group and does not attest hardware identity.
190
211
 
191
- ## Page exit session policy
212
+ ### Page exit session policy
192
213
 
193
214
  `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.
194
215
 
195
216
  For a sensitive call, supply `reauthorize: () => sensitiveActions.authorize(operation)` in the third Kit apiFetch argument or typed-client options.context. After session rotation/recovery or an explicit proof rejection, obtain a new purpose-bound proof before one retry. Cancellation sends no further write; without a callback, an old proof is never replayed.
196
217
 
197
- ## Development and testing
218
+ ## Configuration
219
+
220
+ - `baseUrl`: API base URL, usually including `/api/v1`.
221
+ - `auth.storage`: token storage adapter; defaults to `apiClient.tokenStorage.httpOnlyCookie()` (recommended for browsers). Explicitly use `apiClient.tokenStorage.memory()` for SSR / tests.
222
+ - `auth.refreshPath`: token refresh path; defaults to `/auth/refresh`.
223
+ - `timeout`: request timeout; defaults to 30000 ms.
224
+ - `headers`: static or dynamic shared request headers.
225
+ - `fetch`: custom fetch implementation.
226
+ - `transport.crypto`: transparent request/response encryption and decryption; requires an initialized `@h-ai/crypto` instance.
227
+ - `transport.keyExchangePath`: key exchange subpath; defaults to `/_hai/key-exchange` and is appended to `baseUrl` automatically.
228
+ - `apiClient.config`: a **redacted snapshot of runtime configuration**; credentials embedded in `baseUrl`, `headers.authorization`, and other sensitive values are hidden automatically.
229
+
230
+ ## Error Handling
231
+
232
+ Business APIs return `HaiResult<T>`:
233
+
234
+ ```ts
235
+ const result = await apiClient.iam.auth.currentUser()
236
+ if (!result.success) {
237
+ // result.error.code / result.error.message
238
+ }
239
+ else {
240
+ // result.data = User & { roles: string[], permissions: string[] }
241
+ }
242
+ ```
243
+
244
+ Client-side failures such as missing initialization, network errors, timeouts, and 401/403/404 responses are also converted to failed `HaiResult` values.
245
+
246
+ A valid failed `HaiResult` returned with a non-2xx HTTP status retains its business error code, message and `traceId`; a 409 conflict can therefore explain how to retry. The actual HTTP response determines `httpStatus`. Internal 5xx diagnostics remain hidden even if the body claims status 400. Other response formats continue to use client error mappings.
247
+
248
+ Automatic refresh handles an actual HTTP 401 (`Response.status`) or a valid failed HaiResult over HTTP 200 (`success=false`, string `error.code/message` and `error.httpStatus=401`). After a successful refresh, the original request is retried once. Built-in login and registration routes and the configured `auth.refreshPath` never trigger automatic refresh; other failures such as 403/500 do not trigger it either. Ordinary serv procedures still return HTTP 200. Callers must check the final `success` and `error.code` and handle expired authentication when refresh fails.
249
+
250
+ ### Error messages and correlated logs
251
+
252
+ HTTP errors carry safe localized messages and a reference ID. ToastContainer hides the ID by default and reveals it through its diagnostic icon. The same ID remains in `error.traceId` and `X-Trace-Id`. The framework reuses the request ID to correlate browser and server logs. Sanitized exceptions, stacks and operation context remain in logs; internal cause/ext are not exposed. Field validation returns localized field names and messages only.
253
+
254
+ Use `core.error.toPublic(error, traceId?)` at HTTP/UI boundaries and `core.error.formatMessage(error)` after translating messages; formatting is idempotent. Pass complete Error objects to logs instead of discarding their context. Database failures log driver codes, sanitized messages/stacks and table/column context; SQL text, bound values and connection credentials are excluded.
255
+
256
+ ## Development
198
257
 
199
258
  ```bash
200
259
  pnpm --filter @h-ai/api-client test
@@ -203,7 +262,7 @@ pnpm --filter @h-ai/api-client typecheck
203
262
 
204
263
  Clients learn `transport.maxPlaintextBytes` from server key exchange. An explicit local limit selects the smaller value. Capacity failures return `HaiApiClientError.PAYLOAD_TOO_LARGE` (`hai:api-client:020`) with direction, stage and byte diagnostics. See [capacity and upgrade](../crypto/TRANSPORT-CAPACITY.md).
205
264
 
206
- 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.
265
+ 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`.
207
266
 
208
267
  ## License
209
268