@h-ai/serv 1.0.0-beta.14 → 1.0.0-beta.16

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
@@ -223,7 +223,7 @@ const app = serv.createApp({
223
223
 
224
224
  > **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
225
 
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.
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. 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.
227
227
 
228
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.
229
229
 
@@ -231,7 +231,7 @@ Set `transport.maxPlaintextBytes` for both request and response JSON plaintext,
231
231
 
232
232
  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
233
 
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.
234
+ 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
235
 
236
236
  #### Token authentication (`iam` / `refreshCookie`)
237
237
 
@@ -485,7 +485,7 @@ await apiClient.init({
485
485
  })
486
486
  ```
487
487
 
488
- > **Cookie attributes**: `HttpOnly; SameSite=Strict; Secure` in production; `Path` is limited to `{apiPrefix}/auth/refresh`.
488
+ > Refresh cookies use `HttpOnly` and `SameSite=Strict` and are scoped to `{apiPrefix}/auth/refresh`. Production adds `Secure` by default.
489
489
 
490
490
  ### API overview
491
491
 
@@ -638,7 +638,9 @@ const app = serv.createApp({
638
638
  })
639
639
  ```
640
640
 
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.
641
+ 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.
642
+
643
+ 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`.
642
644
 
643
645
  ### Configuration driven authentication
644
646
 
package/README.ja.md CHANGED
@@ -223,7 +223,7 @@ const app = serv.createApp({
223
223
 
224
224
  > **安全方針**:`transport` 有効時は `excludePaths` と鍵交換以外で有効な `X-Client-Id` が必要です。不足・未知の clientId は 400、暗号化不能・非 JSON 応答・上限超過はエラーになり、平文の業務データは渡しません。
225
225
 
226
- 認証済み 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
+ 認証済み 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 秒先まで許容されます。サーバー側の `maxRequestAgeSeconds`(1~600)と `maxFutureSkewSeconds`(0~300)で整数秒単位に調整できます。共有レジストリは (clientId, requestId) をアトミックに消費し、設定可能な最長の有効期間とノード間の時刻差をカバーするため、nonce を issuedAt の 11 分後まで保持します。ストレージ障害時は要求を拒否します。時計同期と Redis の noeviction/永続化により有効な nonce を保持してください。業務の再試行には別の冪等キーが必要です。
227
227
 
228
228
  空の POST も空平文の認証封装を送ります。GET/HEAD は X-Hai-Transport ヘッダー、204/304/HEAD など本文なし応答も同じヘッダーを使います。他の要求・応答は JSON 本文と X-Encrypted: true を使います。クライアントは元の要求コンテキストと HTTP status を照合し、マーカー欠落、旧形式、認証失敗では reject します。平文 endpoint は Serv/Kit の `encryption: disabled` ルールまたはホストの `excludePaths` で明示的に許可し、呼出側も通常の要求を選びます。API Client/Kit のラッパーは自身の `excludePaths` を使用します。CORS では X-Hai-Transport を許可・公開してください。鍵交換の公開鍵認証は TLS に依存し、IAM の業務認可は別に必要です。
229
229
 
@@ -231,7 +231,7 @@ const app = serv.createApp({
231
231
 
232
232
  通常業務は `transport.maxPlaintextBytes`(auto/省略は 1 MiB)を維持します。Feedback の受理・証拠・添付・ZIP ダウンロードは `routePlaintextBytes` と `feedback.budget.maxTransportBytes` で別途宣言します。`*` は完全な1パス区間のみ一致し、ホストの明示値が優先です。ハンドシェイク、adapter-node、プロキシは経路の最大値を使用します。HTTP アダプターは個別宣言された 1 MiB 超の資料経路を最大2件、通常業務を別枠で16件まで許可し、応答完了・失敗時に解放します。混雑は503 / `hai:crypto:072` を返し、業務書込は自動再送しません。
233
233
 
234
- serv/kit は要求の結合、署名、MAC を検証した後、時間窓を超えた報文に暗号化した HTTP 400 時刻エラーを返します。クライアントは `TransportTimeError`(`hai:crypto:071`)で端末とサーバーの時刻同期・再読み込みを案内します。`api-client` は `VALIDATION_FAILED` に変換し `cause.transportCode` に原因を保持します。自動再送やリプレイ時間窓の緩和は行わず、改ざん・未認証の要求は従来の認証エラーです。
234
+ Serv/Kit は要求の結合、署名、MAC を検証した後、時刻の許容範囲を超えた要求に暗号化した HTTP 400 を返します。クライアントは `TransportTimeError`(`hai:crypto:071`)をスローし、検証時のサーバー UTC 時刻を表示して、自動時刻同期を有効にするか手動で時刻を設定してから再読み込みするよう案内します。`serverTime` は Unix ミリ秒です。旧サーバーが値を返さない場合も時刻調整の案内を表示します。`api-client` は `VALIDATION_FAILED` に変換し、`cause.transportCode` と `cause.serverTime` を保持します。
235
235
 
236
236
  #### Token 認証(`iam` / `refreshCookie`)
237
237
 
@@ -485,7 +485,7 @@ await apiClient.init({
485
485
  })
486
486
  ```
487
487
 
488
- > **Cookie 属性**:本番は `HttpOnly; SameSite=Strict; Secure`。`Path` は `{apiPrefix}/auth/refresh` に限定。
488
+ > リフレッシュ Cookie には `HttpOnly` と `SameSite=Strict` が付き、パスは `{apiPrefix}/auth/refresh` に限定されます。本番では既定で `Secure` も付きます。
489
489
 
490
490
  ### API 一覧
491
491
 
@@ -638,7 +638,9 @@ const app = serv.createApp({
638
638
  })
639
639
  ```
640
640
 
641
- 本番では `_serv.yml` の `cors.origin` が空・`*` なら起動に失敗します。Web・Capacitor/Tauri は正規化済み Origin 全体を厳密照合し、末尾偽装を防ぎます。本番は Secure refresh cookie を強制し OpenAPI/docs を無効化します。非本番の endpoint は設定ファイルで決めます。
641
+ 本番では `_serv.yml` の `cors.origin` に許可するオリジンを明示し、OpenAPI と docs は無効になります。Web と Capacitor/Tauri のオリジンは、正規化した値全体で照合します。
642
+
643
+ 管理されたプライベートネットワークや VPN の本番 HTTP 入口では、バックエンドのプロセス環境に `HAI_SERV_REFRESH_COOKIE_SECURE=false` を設定できます。再起動してログインし直すと、ログイン応答と更新応答の Cookie から `Secure` が外れ、HTTP でリフレッシュ認証情報が平文で送信されます。`true` は `Secure` を強制し、空欄や未対応の値では環境の既定値が使われます。`serv.createApp` を直接呼ぶ場合は、明示した `refreshCookie.secure`、この環境変数、`NODE_ENV` または `HAI_CORE_ENV` による本番判定の順に適用されます。
642
644
 
643
645
  ### 設定による認証画面
644
646
 
package/README.md CHANGED
@@ -223,7 +223,7 @@ const app = serv.createApp({
223
223
 
224
224
  > **安全策略**:启用 `transport` 后,除 `excludePaths` 与密钥协商端点外,业务请求必须携带有效 `X-Client-Id`。缺失或未知 clientId 会返回 400;响应体无法加密、非 JSON 业务响应或超过单次加密上限时返回错误,不会明文透传业务数据。
225
225
 
226
- 认证协议仅支持 v2:独立随机 SM4-CBC 密钥和 HMAC-SM3 密钥由 SM2 封装,先验签、再验证 MAC,最后解密 CBC。请求由客户端私钥签名,响应由所选服务端版本签名。认证字段包括 version、direction、serverKeyId、clientId、128 位随机 requestId(兼 nonce)、issuedAt、HTTP method、pathname + 原始 search 的 SM3 pathHash,以及响应 status。请求有效期 5 分钟,未来时钟偏差最多 30 秒;共享 registry 原子消费 (clientId, requestId),nonce 至少保留到 issuedAt + 6 分钟以覆盖节点偏差,存储故障拒绝请求。Redis 必须保留有效 nonce,使用 noeviction/可靠持久化策略;各节点时钟须同步。此能力防止同一传输消息重放,业务重试仍需自己的幂等键。
226
+ 认证协议仅支持 v2:独立随机 SM4-CBC 密钥和 HMAC-SM3 密钥由 SM2 封装,先验签、再验证 MAC,最后解密 CBC。请求由客户端私钥签名,响应由所选服务端版本签名。认证字段包括 version、direction、serverKeyId、clientId、128 位随机 requestId(兼 nonce)、issuedAt、HTTP method、pathname + 原始 search 的 SM3 pathHash,以及响应 status。请求默认有效期为 5 分钟,客户端时间最多可领先服务器 30 秒。服务端可用 `maxRequestAgeSeconds`(1~600)和 `maxFutureSkewSeconds`(0~300)调整,单位为整数秒。共享 registry 原子消费 (clientId, requestId),nonce 保留到 issuedAt + 11 分钟,覆盖最长可配窗口及节点时钟差,存储故障拒绝请求。Redis 必须保留有效 nonce,使用 noeviction/可靠持久化策略;各节点时钟须同步。此能力防止同一传输消息重放,业务重试仍需自己的幂等键。
227
227
 
228
228
  空 POST 也发送空明文的认证封装;GET/HEAD 将封装放在 X-Hai-Transport 头,204/304/HEAD 等无正文响应也使用同名头承载认证封装。其它请求/响应在 JSON 正文承载封装并带 X-Encrypted: true。客户端必须关联原请求上下文和 HTTP status,缺标记、旧格式或认证失败一律 reject;明文接口由 Serv/Kit 路径规则 `encryption: disabled` 或宿主 `excludePaths` 显式允许;调用方同步选择普通请求,API Client/Kit 包装器使用自己的 `excludePaths`。CORS 须允许并暴露 X-Hai-Transport。握手公钥的真实性依赖 TLS,不替代 IAM 业务授权。
229
229
 
@@ -231,7 +231,7 @@ const app = serv.createApp({
231
231
 
232
232
  普通业务仍使用 `transport.maxPlaintextBytes`(auto/省略为 1 MiB);Feedback 的报告受理、证据、附件及 ZIP 下载路由通过 `routePlaintextBytes` 单独声明 `feedback.budget.maxTransportBytes`。路径中的 `*` 仅匹配一个完整路径段;宿主显式路由值优先。握手、adapter-node 与代理使用各路由最大容量,普通业务不会随材料预算扩大。HTTP 适配器为单独声明且大于 1 MiB 的材料路由保留最多 2 个槽,普通业务另有 16 个槽,完整响应/失败后释放;忙碌返回 503 / `hai:crypto:072`,不自动重放业务写入。
233
233
 
234
- serv/kit 对通过请求绑定、签名和 MAC 校验但超出时间窗口的报文返回加密的 HTTP 400 时间错误;客户端抛 `TransportTimeError`(`hai:crypto:071`),提示同步设备和服务器时间后刷新重试。`api-client` 映射为 `VALIDATION_FAILED`,在 `cause.transportCode` 保留该原因。不会自动重放请求或放宽防重放窗口;篡改或未认证报文仍返回通用认证失败。
234
+ Serv/Kit 对通过请求绑定、签名和 MAC 校验但超出时间窗口的请求返回加密的 HTTP 400 错误。客户端抛出 `TransportTimeError`(`hai:crypto:071`),显示校验时的服务器 UTC 时间,提示开启设备的自动校时或手工修改时间后刷新。`serverTime` 为 Unix 毫秒,旧服务端未提供该值时仍显示校时提示。`api-client` 映射为 `VALIDATION_FAILED`,在 `cause.transportCode` 和 `cause.serverTime` 保留诊断信息。
235
235
 
236
236
  #### Token 认证(`iam` / `refreshCookie`)
237
237
 
@@ -485,7 +485,7 @@ await apiClient.init({
485
485
  })
486
486
  ```
487
487
 
488
- > **Cookie 规格**:`HttpOnly; SameSite=Strict; Secure`(生产);`Path` 限制为 `{apiPrefix}/auth/refresh`。
488
+ > 刷新 Cookie 带有 `HttpOnly` 和 `SameSite=Strict`,路径限制为 `{apiPrefix}/auth/refresh`。生产默认还会设置 `Secure`。
489
489
 
490
490
  ### API 概览
491
491
 
@@ -638,7 +638,9 @@ const app = serv.createApp({
638
638
  })
639
639
  ```
640
640
 
641
- 生产环境下 `_serv.yml` 的 `cors.origin` 为空或为 `*` 会在启动时失败;Web 与 Capacitor/Tauri Origin 均按规范化后的完整 Origin 精确匹配,避免后缀伪造。生产环境同时强制 Secure refresh cookie 并关闭 OpenAPI/docs,配置文件仍决定非生产环境具体挂载哪些端点。
641
+ 生产环境要求 `_serv.yml` 的 `cors.origin` 显式列出允许的来源,并关闭 OpenAPI 和 docs。Web 与 Capacitor/Tauri Origin 按规范化后的完整值精确匹配。
642
+
643
+ 受控内网或 VPN 的生产 HTTP 入口可以在后端进程环境设置 `HAI_SERV_REFRESH_COOKIE_SECURE=false`。重新启动后端并登录后,登录和刷新响应会下发省略 `Secure` 的 Cookie,刷新凭据会经 HTTP 明文传输。设置 `true` 可强制启用 `Secure`,留空或其他值沿用环境默认值。直接调用 `serv.createApp` 时,显式 `refreshCookie.secure` 优先,其次是此变量,再按 `NODE_ENV` 或 `HAI_CORE_ENV` 判断是否为生产。
642
644
 
643
645
  ### 配置驱动认证页面
644
646
 
@@ -2,8 +2,8 @@ import * as _orpc_server from '@orpc/server';
2
2
  import * as zod_v4_core from 'zod/v4/core';
3
3
  import * as zod from 'zod';
4
4
  import { AIFunctions } from '@h-ai/ai';
5
- import { S as ServContext } from '../serv-context-DE1JH5bd.js';
6
- import { A as AuthenticatedServContext } from '../serv-router-BJk04-oS.js';
5
+ import { S as ServContext } from '../serv-context-BsNvQ-bb.js';
6
+ import { A as AuthenticatedServContext } from '../serv-router-BnkxPvS6.js';
7
7
  import '@h-ai/core';
8
8
 
9
9
  /**
@@ -2,8 +2,8 @@ import * as _orpc_server from '@orpc/server';
2
2
  import * as zod from 'zod';
3
3
  import * as zod_v4_core from 'zod/v4/core';
4
4
  import { FeedbackFunctions, FeedbackContext } from '@h-ai/feedback';
5
- import { S as ServContext } from '../serv-context-DE1JH5bd.js';
6
- import { A as AuthenticatedServContext } from '../serv-router-BJk04-oS.js';
5
+ import { S as ServContext } from '../serv-context-BsNvQ-bb.js';
6
+ import { A as AuthenticatedServContext } from '../serv-router-BnkxPvS6.js';
7
7
  import '@h-ai/core';
8
8
 
9
9
  /**
@@ -4,7 +4,7 @@ import * as zod_v4_core from 'zod/v4/core';
4
4
  import * as zod from 'zod';
5
5
  import { IamFunctions } from '@h-ai/iam';
6
6
  export { TokenPair } from '@h-ai/iam';
7
- import { S as ServContext } from '../serv-context-DE1JH5bd.js';
7
+ import { S as ServContext } from '../serv-context-BsNvQ-bb.js';
8
8
  import '@h-ai/core';
9
9
 
10
10
  /**
@@ -2,8 +2,8 @@ import * as _orpc_server from '@orpc/server';
2
2
  import * as zod_v4_core from 'zod/v4/core';
3
3
  import * as zod from 'zod';
4
4
  import { StorageFunctions } from '@h-ai/storage';
5
- import { S as ServContext } from '../serv-context-DE1JH5bd.js';
6
- import { A as AuthenticatedServContext } from '../serv-router-BJk04-oS.js';
5
+ import { S as ServContext } from '../serv-context-BsNvQ-bb.js';
6
+ import { A as AuthenticatedServContext } from '../serv-router-BnkxPvS6.js';
7
7
  import '@h-ai/core';
8
8
 
9
9
  /**
package/dist/index.d.ts CHANGED
@@ -3,15 +3,15 @@ import { HaiResult, TrustedProxies, RequestAccessConfigInput, ValidationFormErro
3
3
  import { AIFunctions, AudioWsStartMessage, AudioFormat } from '@h-ai/ai';
4
4
  import { AnyContractRouter, OpenAPI } from '@orpc/contract';
5
5
  import { ProcedureHandler, ErrorMap, Meta, ProcedureHandlerOptions, ORPCErrorConstructorMap, Router } from '@orpc/server';
6
- import { a as ServSession, S as ServContext, b as ServIam, R as RefreshCookieConfig, C as CreateServContext, c as ServRequestMetadata, p as parseRequestContext, d as buildAuthContextFactory } from './serv-context-DE1JH5bd.js';
7
- export { e as CreateServContextInput, N as NativeRefreshTokenTransportConfig, f as RefreshTokenPair } from './serv-context-DE1JH5bd.js';
6
+ import { a as ServSession, S as ServContext, b as ServIam, R as RefreshCookieConfig, C as CreateServContext, c as ServRequestMetadata, p as parseRequestContext, d as buildAuthContextFactory } from './serv-context-BsNvQ-bb.js';
7
+ export { e as CreateServContextInput, N as NativeRefreshTokenTransportConfig, f as RefreshTokenPair } from './serv-context-BsNvQ-bb.js';
8
8
  import { MiddlewareHandler } from 'hono';
9
9
  import { z, ZodType } from 'zod';
10
10
  import { TransportServerKeyRing, CryptoFunctions, TransportKeyStore } from '@h-ai/crypto';
11
11
  import { ServerType } from '@hono/node-server';
12
12
  import { AddressInfo } from 'node:net';
13
- import { i as implement } from './serv-router-BJk04-oS.js';
14
- export { A as AuthenticatedServContext, S as ServImplementer, a as ServRouteHandler, b as ServRoutePath, c as ServRouterBuilder } from './serv-router-BJk04-oS.js';
13
+ import { i as implement } from './serv-router-BnkxPvS6.js';
14
+ export { A as AuthenticatedServContext, S as ServImplementer, a as ServRouteHandler, b as ServRoutePath, c as ServRouterBuilder } from './serv-router-BnkxPvS6.js';
15
15
  import { StorageFunctions } from '@h-ai/storage';
16
16
 
17
17
  /**
@@ -232,6 +232,10 @@ interface ServHealthHttpConfig {
232
232
  interface ServTransportRuntimeConfig {
233
233
  /** 双向明文 UTF-8 字节上限;省略使用 1 MiB,范围 1 KiB~64 MiB。 */
234
234
  readonly maxPlaintextBytes?: number;
235
+ /** 请求时间允许领先服务器的秒数,默认 30,整数范围 0~300。 */
236
+ readonly maxFutureSkewSeconds?: number;
237
+ /** 请求从签发起的有效秒数,默认 300,整数范围 1~600。 */
238
+ readonly maxRequestAgeSeconds?: number;
235
239
  /** 按完整路径段匹配的明文容量;* 匹配单段。 */
236
240
  readonly routePlaintextBytes?: Readonly<Record<string, number>>;
237
241
  readonly keyExchangePath: `/${string}`;
@@ -322,6 +326,8 @@ type ServCorsRuntimeConfigInput = Partial<ServCorsRuntimeConfig>;
322
326
  */
323
327
  type ServTransportRuntimeConfigInput = Partial<{
324
328
  readonly maxPlaintextBytes: number | 'auto';
329
+ readonly maxFutureSkewSeconds: number;
330
+ readonly maxRequestAgeSeconds: number;
325
331
  readonly routePlaintextBytes: Readonly<Record<string, number>>;
326
332
  readonly keyExchangePath: `/${string}`;
327
333
  readonly excludePaths: readonly `/${string}`[];
@@ -449,6 +455,8 @@ declare const ServConfigSchema: z.ZodPipe<z.ZodObject<{
449
455
  }, z.core.$strip>>;
450
456
  transport: z.ZodOptional<z.ZodUnion<readonly [z.ZodLiteral<false>, z.ZodObject<{
451
457
  maxPlaintextBytes: z.ZodOptional<z.ZodPipe<z.ZodUnion<readonly [z.ZodNumber, z.ZodLiteral<"auto">]>, z.ZodTransform<number | undefined, number | "auto">>>;
458
+ maxFutureSkewSeconds: z.ZodOptional<z.ZodNumber>;
459
+ maxRequestAgeSeconds: z.ZodOptional<z.ZodNumber>;
452
460
  routePlaintextBytes: z.ZodOptional<z.ZodRecord<z.ZodCustom<`/${string}`, `/${string}`>, z.ZodNumber>>;
453
461
  keyExchangePath: z.ZodDefault<z.ZodCustom<`/${string}`, `/${string}`>>;
454
462
  excludePaths: z.ZodDefault<z.ZodArray<z.ZodCustom<`/${string}`, `/${string}`>>>;
@@ -517,6 +525,8 @@ declare const ServConfigSchema: z.ZodPipe<z.ZodObject<{
517
525
  keyExchangePath: `/${string}`;
518
526
  excludePaths: `/${string}`[];
519
527
  maxPlaintextBytes?: number | undefined;
528
+ maxFutureSkewSeconds?: number | undefined;
529
+ maxRequestAgeSeconds?: number | undefined;
520
530
  routePlaintextBytes?: Record<`/${string}`, number> | undefined;
521
531
  } | undefined;
522
532
  }>>;
@@ -541,6 +551,10 @@ interface ServTransportConfig {
541
551
  /** 受管服务端密钥环,仅运行时从私有 Secret 注入。 */
542
552
  /** 双向业务明文 UTF-8 字节上限;默认 1 MiB,整数范围 1 KiB~64 MiB。 */
543
553
  readonly maxPlaintextBytes?: number;
554
+ /** 请求时间允许领先服务器的秒数,默认 30,整数范围 0~300。 */
555
+ readonly maxFutureSkewSeconds?: number;
556
+ /** 请求从签发起的有效秒数,默认 300,整数范围 1~600。 */
557
+ readonly maxRequestAgeSeconds?: number;
544
558
  /** 路由明文容量;* 匹配一个完整路径段。 */
545
559
  routePlaintextBytes?: Readonly<Record<string, number>>;
546
560
  readonly serverKeyRing: TransportServerKeyRing;
@@ -1106,11 +1120,13 @@ interface ServRuntimeSecurityPolicyInput {
1106
1120
  interface ServRuntimeSecurityPolicy {
1107
1121
  readonly allowOrigin: (origin: string) => boolean;
1108
1122
  readonly isNativeOrigin: (origin: string) => boolean;
1123
+ /** 刷新 Cookie 是否启用 Secure,默认在生产开启。 */
1109
1124
  readonly secureRefreshCookie: boolean;
1110
1125
  readonly exposeApiDocs: boolean;
1111
1126
  }
1112
1127
  /**
1113
1128
  * 校验部署 Origin 并生成 Cookie、文档与跨端 CORS 安全策略。
1129
+ * `HAI_SERV_REFRESH_COOKIE_SECURE` 可覆盖刷新 Cookie 的 Secure 默认值。
1114
1130
  *
1115
1131
  * @param input - 部署环境与显式允许的 Web/原生 Origin
1116
1132
  * @returns 可用于 HTTP 装配的策略
package/dist/index.js CHANGED
@@ -6,7 +6,7 @@ import { posix } from 'path';
6
6
  import { apiContract, AUDIO_WS_PATH, AudioWsClientMessageSchema } from '@h-ai/api-contract';
7
7
  import { audit } from '@h-ai/audit';
8
8
  import { core, RequestAccessConfigSchema, TrustedProxiesSchema, HaiCommonError, err, ok } from '@h-ai/core';
9
- import { TRANSPORT_PLAINTEXT_RANGE, createTransportBodyPolicy, TRANSPORT_PROTOCOL, authenticateTransportRequest, TransportTimeError, createTransportErrorResponse, TransportBodyTooLargeError, readTransportBody, TRANSPORT_BODY_LIMITS, isValidSM2PublicKey } from '@h-ai/crypto';
9
+ import { TRANSPORT_PLAINTEXT_RANGE, TRANSPORT_TIME_LIMITS, TRANSPORT_PROTOCOL, createTransportBodyPolicy, authenticateTransportRequest, TransportTimeError, createTransportErrorResponse, TransportBodyTooLargeError, readTransportBody, TRANSPORT_BODY_LIMITS, isValidSM2PublicKey } from '@h-ai/crypto';
10
10
  import { OpenAPIHandler } from '@orpc/openapi/fetch';
11
11
  import { Hono } from 'hono';
12
12
  import { WebSocketServer } from 'ws';
@@ -593,6 +593,8 @@ var TransportRuntimeConfigInputSchema = z.union([
593
593
  z.literal(false),
594
594
  z.object({
595
595
  maxPlaintextBytes: z.union([z.number().int().min(TRANSPORT_PLAINTEXT_RANGE.min).max(TRANSPORT_PLAINTEXT_RANGE.max), z.literal("auto")]).transform((value) => value === "auto" ? void 0 : value).optional().describe("\u666E\u901A\u4E1A\u52A1\u53CC\u5411\u660E\u6587\u4E0A\u9650\uFF1Bauto \u4E3A 1 MiB\uFF1B\u5927\u6750\u6599\u4F7F\u7528 routePlaintextBytes \u5355\u72EC\u58F0\u660E").meta({ default: "auto" }),
596
+ maxFutureSkewSeconds: z.number().int().min(0).max(TRANSPORT_TIME_LIMITS.maxFutureSkewSeconds).optional().describe("\u5141\u8BB8\u5BA2\u6237\u7AEF\u8BF7\u6C42\u65F6\u95F4\u9886\u5148\u670D\u52A1\u5668\u7684\u79D2\u6570").meta({ default: TRANSPORT_PROTOCOL.CLOCK_SKEW_MS / 1e3 }),
597
+ maxRequestAgeSeconds: z.number().int().min(TRANSPORT_TIME_LIMITS.minRequestAgeSeconds).max(TRANSPORT_TIME_LIMITS.maxRequestAgeSeconds).optional().describe("\u8BF7\u6C42\u4ECE\u7B7E\u53D1\u8D77\u7684\u6709\u6548\u79D2\u6570").meta({ default: TRANSPORT_PROTOCOL.REQUEST_WINDOW_MS / 1e3 }),
596
598
  routePlaintextBytes: z.record(AbsolutePathSchema, z.number().int().min(TRANSPORT_PLAINTEXT_RANGE.min).max(TRANSPORT_PLAINTEXT_RANGE.max)).optional().describe("\u5B8C\u6574\u8DEF\u5F84\u6BB5\u5230\u53CC\u5411\u660E\u6587\u5B57\u8282\u4E0A\u9650\u7684\u6620\u5C04\uFF1B\u661F\u53F7\u5339\u914D\u5355\u6BB5\uFF0C\u4F9B\u9644\u4EF6\u548C\u5F52\u6863\u72EC\u7ACB\u5BB9\u91CF"),
597
599
  keyExchangePath: AbsolutePathSchema.default("/_hai/key-exchange"),
598
600
  excludePaths: z.array(AbsolutePathSchema).default([])
@@ -700,6 +702,81 @@ function buildAuthContextFactory(verifyToken) {
700
702
  }
701
703
  };
702
704
  }
705
+ function createRuntimeSecurityPolicy(input) {
706
+ const production = input.environment?.trim().toLowerCase() === "production";
707
+ const configuredOrigins = parseOrigins(input.corsOrigin);
708
+ const configuredNativeOrigins = parseNativeOrigins(input.nativeOrigins);
709
+ const allowsEveryOrigin = configuredOrigins === "*";
710
+ if (production && (allowsEveryOrigin || configuredOrigins.length === 0))
711
+ throw new Error("serv.cors.origin must contain explicit HTTP(S) origins in production");
712
+ const allowedOrigins = allowsEveryOrigin ? void 0 : new Set(configuredOrigins);
713
+ const allowedNativeOrigins = new Set(configuredNativeOrigins);
714
+ return {
715
+ allowOrigin: (origin) => {
716
+ const normalizedNativeOrigin = tryNormalizeNativeOrigin(origin);
717
+ if (normalizedNativeOrigin !== void 0 && allowedNativeOrigins.has(normalizedNativeOrigin))
718
+ return true;
719
+ if (allowedOrigins === void 0)
720
+ return true;
721
+ const normalizedWebOrigin = tryNormalizeWebOrigin(origin);
722
+ return normalizedWebOrigin !== void 0 && allowedOrigins.has(normalizedWebOrigin);
723
+ },
724
+ isNativeOrigin: (origin) => allowedNativeOrigins.has(tryNormalizeNativeOrigin(origin) ?? ""),
725
+ secureRefreshCookie: resolveSecureRefreshCookie(production),
726
+ exposeApiDocs: !production
727
+ };
728
+ }
729
+ function resolveSecureRefreshCookie(defaultSecure) {
730
+ const override = process.env.HAI_SERV_REFRESH_COOKIE_SECURE;
731
+ if (override === "true" || override === "false")
732
+ return override === "true";
733
+ return defaultSecure;
734
+ }
735
+ function parseOrigins(value) {
736
+ const entries = (value ?? "*").split(",").map((origin) => origin.trim()).filter(Boolean);
737
+ if (entries.includes("*"))
738
+ return "*";
739
+ return entries.map(normalizeWebOrigin);
740
+ }
741
+ function parseNativeOrigins(value) {
742
+ return (value ?? "").split(",").map((origin) => origin.trim()).filter(Boolean).map(normalizeNativeOrigin);
743
+ }
744
+ function normalizeWebOrigin(value) {
745
+ const normalized = tryNormalizeWebOrigin(value);
746
+ if (normalized === void 0)
747
+ throw new Error(`serv.cors.origin contains an invalid origin: ${value}`);
748
+ return normalized;
749
+ }
750
+ function normalizeNativeOrigin(value) {
751
+ const normalized = tryNormalizeNativeOrigin(value);
752
+ if (normalized === void 0)
753
+ throw new Error(`serv.cors.nativeOrigins contains an invalid origin: ${value}`);
754
+ return normalized;
755
+ }
756
+ function tryNormalizeWebOrigin(value) {
757
+ let url;
758
+ try {
759
+ url = new URL(value);
760
+ } catch {
761
+ return void 0;
762
+ }
763
+ if (!["http:", "https:"].includes(url.protocol) || url.origin === "null" || url.pathname !== "/" || url.search || url.hash)
764
+ return void 0;
765
+ return url.origin;
766
+ }
767
+ function tryNormalizeNativeOrigin(value) {
768
+ let url;
769
+ try {
770
+ url = new URL(value);
771
+ } catch {
772
+ return void 0;
773
+ }
774
+ if (!["http:", "https:", "capacitor:", "tauri:"].includes(url.protocol) || !url.hostname || url.username || url.password || !["", "/"].includes(url.pathname) || url.search || url.hash)
775
+ return void 0;
776
+ return ["capacitor:", "tauri:"].includes(url.protocol) ? `${url.protocol}//${url.host}` : url.origin;
777
+ }
778
+
779
+ // src/serv-cookie-auth.ts
703
780
  var IAM_AUTH_PATHS = {
704
781
  login: apiContract.pathOf(apiContract.iam.auth.login),
705
782
  loginWithOtp: apiContract.pathOf(apiContract.iam.auth.loginWithOtp),
@@ -719,7 +796,7 @@ function resolveRefreshCookieConfig(apiPrefix, config, iam) {
719
796
  return {
720
797
  cookieName: config.cookieName ?? "hai_refresh_token",
721
798
  maxAge: config.maxAge ?? 30 * 24 * 3600,
722
- isSecure: config.secure ?? (process.env.NODE_ENV === "production" || process.env.HAI_CORE_ENV === "production"),
799
+ isSecure: config.secure ?? resolveSecureRefreshCookie(process.env.NODE_ENV === "production" || process.env.HAI_CORE_ENV === "production"),
723
800
  refreshCookiePath: `${apiPrefix}${IAM_AUTH_PATHS.refresh}`,
724
801
  onRefresh,
725
802
  nativeTokenTransport: config.nativeTokenTransport
@@ -1292,7 +1369,9 @@ function createApp(options) {
1292
1369
  const mgr = options.transport.crypto.transport.createServer({
1293
1370
  serverKeyRing: options.transport.serverKeyRing,
1294
1371
  keyStore: options.transport.keyStore,
1295
- maxPlaintextBytes: policy.maximum.plaintextBytes
1372
+ maxPlaintextBytes: policy.maximum.plaintextBytes,
1373
+ maxFutureSkewSeconds: options.transport.maxFutureSkewSeconds,
1374
+ maxRequestAgeSeconds: options.transport.maxRequestAgeSeconds
1296
1375
  });
1297
1376
  if (!mgr.success) {
1298
1377
  throw core.error.buildHaiErrorInst(
@@ -1478,75 +1557,6 @@ function listen(app, options = {}) {
1478
1557
  return handle;
1479
1558
  }
1480
1559
 
1481
- // src/serv-runtime-security.ts
1482
- function createRuntimeSecurityPolicy(input) {
1483
- const production = input.environment?.trim().toLowerCase() === "production";
1484
- const configuredOrigins = parseOrigins(input.corsOrigin);
1485
- const configuredNativeOrigins = parseNativeOrigins(input.nativeOrigins);
1486
- const allowsEveryOrigin = configuredOrigins === "*";
1487
- if (production && (allowsEveryOrigin || configuredOrigins.length === 0))
1488
- throw new Error("serv.cors.origin must contain explicit HTTP(S) origins in production");
1489
- const allowedOrigins = allowsEveryOrigin ? void 0 : new Set(configuredOrigins);
1490
- const allowedNativeOrigins = new Set(configuredNativeOrigins);
1491
- return {
1492
- allowOrigin: (origin) => {
1493
- const normalizedNativeOrigin = tryNormalizeNativeOrigin(origin);
1494
- if (normalizedNativeOrigin !== void 0 && allowedNativeOrigins.has(normalizedNativeOrigin))
1495
- return true;
1496
- if (allowedOrigins === void 0)
1497
- return true;
1498
- const normalizedWebOrigin = tryNormalizeWebOrigin(origin);
1499
- return normalizedWebOrigin !== void 0 && allowedOrigins.has(normalizedWebOrigin);
1500
- },
1501
- isNativeOrigin: (origin) => allowedNativeOrigins.has(tryNormalizeNativeOrigin(origin) ?? ""),
1502
- secureRefreshCookie: production,
1503
- exposeApiDocs: !production
1504
- };
1505
- }
1506
- function parseOrigins(value) {
1507
- const entries = (value ?? "*").split(",").map((origin) => origin.trim()).filter(Boolean);
1508
- if (entries.includes("*"))
1509
- return "*";
1510
- return entries.map(normalizeWebOrigin);
1511
- }
1512
- function parseNativeOrigins(value) {
1513
- return (value ?? "").split(",").map((origin) => origin.trim()).filter(Boolean).map(normalizeNativeOrigin);
1514
- }
1515
- function normalizeWebOrigin(value) {
1516
- const normalized = tryNormalizeWebOrigin(value);
1517
- if (normalized === void 0)
1518
- throw new Error(`serv.cors.origin contains an invalid origin: ${value}`);
1519
- return normalized;
1520
- }
1521
- function normalizeNativeOrigin(value) {
1522
- const normalized = tryNormalizeNativeOrigin(value);
1523
- if (normalized === void 0)
1524
- throw new Error(`serv.cors.nativeOrigins contains an invalid origin: ${value}`);
1525
- return normalized;
1526
- }
1527
- function tryNormalizeWebOrigin(value) {
1528
- let url;
1529
- try {
1530
- url = new URL(value);
1531
- } catch {
1532
- return void 0;
1533
- }
1534
- if (!["http:", "https:"].includes(url.protocol) || url.origin === "null" || url.pathname !== "/" || url.search || url.hash)
1535
- return void 0;
1536
- return url.origin;
1537
- }
1538
- function tryNormalizeNativeOrigin(value) {
1539
- let url;
1540
- try {
1541
- url = new URL(value);
1542
- } catch {
1543
- return void 0;
1544
- }
1545
- if (!["http:", "https:", "capacitor:", "tauri:"].includes(url.protocol) || !url.hostname || url.username || url.password || !["", "/"].includes(url.pathname) || url.search || url.hash)
1546
- return void 0;
1547
- return ["capacitor:", "tauri:"].includes(url.protocol) ? `${url.protocol}//${url.host}` : url.origin;
1548
- }
1549
-
1550
1560
  // src/serv-storage-assets.ts
1551
1561
  function storageAssets(config) {
1552
1562
  const pathPrefix = config.pathPrefix.endsWith("/") ? config.pathPrefix : `${config.pathPrefix}/`;