@h-ai/serv 0.1.0-alpha.74 → 0.1.0-alpha.75

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/dist/index.d.ts CHANGED
@@ -250,6 +250,8 @@ interface ServRpcHttpConfig {
250
250
  interface ServTransportRuntimeConfig {
251
251
  /** 双向明文 UTF-8 字节上限;省略使用 1 MiB,范围 1 KiB~64 MiB。 */
252
252
  readonly maxPlaintextBytes?: number;
253
+ /** 按完整路径段匹配的明文容量;* 匹配单段。 */
254
+ readonly routePlaintextBytes?: Readonly<Record<string, number>>;
253
255
  readonly keyExchangePath: `/${string}`;
254
256
  readonly excludePaths: readonly `/${string}`[];
255
257
  }
@@ -337,6 +339,7 @@ type ServCorsRuntimeConfigInput = Partial<ServCorsRuntimeConfig>;
337
339
  */
338
340
  type ServTransportRuntimeConfigInput = Partial<{
339
341
  readonly maxPlaintextBytes: number | 'auto';
342
+ readonly routePlaintextBytes: Readonly<Record<string, number>>;
340
343
  readonly keyExchangePath: `/${string}`;
341
344
  readonly excludePaths: readonly `/${string}`[];
342
345
  }>;
@@ -432,6 +435,7 @@ declare const ServConfigSchema: z.ZodPipe<z.ZodObject<{
432
435
  }, z.core.$strip>>;
433
436
  transport: z.ZodOptional<z.ZodUnion<readonly [z.ZodLiteral<false>, z.ZodObject<{
434
437
  maxPlaintextBytes: z.ZodOptional<z.ZodPipe<z.ZodUnion<readonly [z.ZodNumber, z.ZodLiteral<"auto">]>, z.ZodTransform<number | undefined, number | "auto">>>;
438
+ routePlaintextBytes: z.ZodOptional<z.ZodRecord<z.ZodCustom<`/${string}`, `/${string}`>, z.ZodNumber>>;
435
439
  keyExchangePath: z.ZodDefault<z.ZodCustom<`/${string}`, `/${string}`>>;
436
440
  excludePaths: z.ZodDefault<z.ZodArray<z.ZodCustom<`/${string}`, `/${string}`>>>;
437
441
  }, z.core.$strip>]>>;
@@ -477,27 +481,10 @@ declare const ServConfigSchema: z.ZodPipe<z.ZodObject<{
477
481
  keyExchangePath: `/${string}`;
478
482
  excludePaths: `/${string}`[];
479
483
  maxPlaintextBytes?: number | undefined;
484
+ routePlaintextBytes?: Record<`/${string}`, number> | undefined;
480
485
  } | undefined;
481
486
  }>>;
482
487
 
483
- /**
484
- * @h-ai/serv — 传输加密集成
485
- *
486
- * 提供 Hono 中间件 + 密钥协商端点,直接依赖 `@h-ai/crypto` 的
487
- * `TransportEncryptionManager` 与 `TRANSPORT_PROTOCOL` 协议常量,
488
- * 确保前后端协议不漂移。
489
- *
490
- * 单次请求的处理顺序:
491
- * 1. 特判密钥协商端点
492
- * 2. 跳过不参与加解密的路径
493
- * 3. 校验 clientId 与已注册公钥
494
- * 4. 解密请求体并把明文 request 回写给下游
495
- * 5. 执行业务逻辑
496
- * 6. 尝试加密 JSON 响应
497
- *
498
- * @module serv-transport
499
- */
500
-
501
488
  /**
502
489
  * {@link createApp} 的 `transport` 配置项。
503
490
  *
@@ -518,6 +505,8 @@ interface ServTransportConfig {
518
505
  /** 受管服务端密钥环,仅运行时从私有 Secret 注入。 */
519
506
  /** 双向业务明文 UTF-8 字节上限;默认 1 MiB,整数范围 1 KiB~64 MiB。 */
520
507
  readonly maxPlaintextBytes?: number;
508
+ /** 路由明文容量;* 匹配一个完整路径段。 */
509
+ routePlaintextBytes?: Readonly<Record<string, number>>;
521
510
  readonly serverKeyRing: TransportServerKeyRing;
522
511
  /**
523
512
  * crypto 服务实例。serv 通过 `crypto.transport.createServer()` 创建
@@ -537,24 +526,6 @@ interface ServTransportConfig {
537
526
  readonly keyStore: TransportKeyStore;
538
527
  }
539
528
 
540
- /**
541
- * @h-ai/serv — HTTP app 装配
542
- *
543
- * 将 oRPC contract + procedures + http 配置组合为可运行的 ServHttpApp,
544
- * 自动挂载安全响应头、健康检查、OpenAPI JSON、Scalar 文档页与 oRPC handler。
545
- *
546
- * 关键装配顺序:
547
- * 1. 解析 HTTP 配置
548
- * 2. 选择请求上下文工厂
549
- * 3. 挂基础安全 middleware
550
- * 4. 挂自定义 middleware(若提供)
551
- * 5. (可选)挂传输加密 middleware
552
- * 6. 挂基础路由(health / refresh-cookie)
553
- * 7. 挂业务路由(oRPC / RPC)
554
- * 8. 挂文档路由(openapi / docs)
555
- * @module serv-app
556
- */
557
-
558
529
  /**
559
530
  * `serv.createApp()` 的自定义 middleware 挂载项。
560
531
  *
@@ -1325,7 +1296,7 @@ declare const serv: {
1325
1296
  validateInputOrFail: typeof validateInputOrFail;
1326
1297
  WILDCARD_PERMISSION: string;
1327
1298
  WILDCARD_ROLE: string;
1328
- m: (key: "$schema" | "serv_validationFailed" | "serv_validationRequired" | "serv_validationInvalid" | "serv_validationInvalidType" | "serv_validationStringMin" | "serv_validationStringMax" | "serv_validationNumberMin" | "serv_validationNumberMax" | "serv_validationArrayMin" | "serv_validationArrayMax" | "serv_validationTooSmall" | "serv_validationTooBig" | "serv_validationEmail" | "serv_validationUrl" | "serv_validationUuid" | "serv_validationEnum" | "serv_errorUnauthorized" | "serv_errorForbidden" | "serv_errorInternal" | "serv_storageKeyRequired" | "serv_storageKeyTooLong" | "serv_storageKeyIllegalChars" | "serv_storageKeyDotSegments" | "serv_iamCannotDeleteCurrentUser" | "serv_transportInvalidPayload" | "serv_transportUnknownClient" | "serv_transportClientIdRequired" | "serv_transportClientKeyNotFound" | "serv_transportDecryptFailed" | "serv_transportEncryptFailed" | "serv_transportKeyExchangeFailed", options?: _h_ai_core.MessageOptions) => string;
1299
+ m: (key: "$schema" | "serv_validationFailed" | "serv_validationRequired" | "serv_validationInvalid" | "serv_validationInvalidType" | "serv_validationStringMin" | "serv_validationStringMax" | "serv_validationNumberMin" | "serv_validationNumberMax" | "serv_validationArrayMin" | "serv_validationArrayMax" | "serv_validationTooSmall" | "serv_validationTooBig" | "serv_validationEmail" | "serv_validationUrl" | "serv_validationUuid" | "serv_validationEnum" | "serv_errorUnauthorized" | "serv_errorForbidden" | "serv_errorInternal" | "serv_storageKeyRequired" | "serv_storageKeyTooLong" | "serv_storageKeyIllegalChars" | "serv_storageKeyDotSegments" | "serv_iamCannotDeleteCurrentUser" | "serv_transportInvalidPayload" | "serv_transportUnknownClient" | "serv_transportClientIdRequired" | "serv_transportClientKeyNotFound" | "serv_transportDecryptFailed" | "serv_transportEncryptFailed" | "serv_transportKeyExchangeFailed" | "serv_transportBusy" | "serv_feedbackUnavailable", options?: _h_ai_core.MessageOptions) => string;
1329
1300
  };
1330
1301
 
1331
1302
  export { type AudioTicketGrant, type AudioTicketVerification, type AuthorizedAudioRequest, type CreateServAppOptions, CreateServContext, type GenerateOpenAPISpecOptions, RefreshCookieConfig, type ServAudioConfig, type ServConfig, type ServConfigInput, ServConfigSchema, ServContext, type ServCorsConfig, type ServCorsRuntimeConfig, type ServCorsRuntimeConfigInput, type ServDocsHttpConfig, type ServFetchHandler, type ServHealthHttpConfig, type ServHttpApp, type ServHttpConfig, type ServHttpConfigInput, ServIam, type ServListenOptions, type ServMiddleware, type ServMiddlewareFactory, type ServMiddlewareMount, type ServNodeServer, type ServOpenAPIHttpConfig, type ServProcedureHandler, type ServProcedureOptions, ServRequestMetadata, type ServRpcHttpConfig, type ServRuntimeSecurityPolicy, type ServRuntimeSecurityPolicyInput, type ServServerConfig, type ServServerConfigInput, ServSession, type ServStorageAssetsConfig, type ServTransportConfig, type ServTransportRuntimeConfig, type ServTransportRuntimeConfigInput, type ServValidationFailureBody, serv };
package/dist/index.js CHANGED
@@ -1,17 +1,17 @@
1
- import { validateInputOrFail, resolveRequestLocale, localizeZodError } from './chunk-IRZVJW4V.js';
2
- import { servM, WILDCARD_ROLE, WILDCARD_PERMISSION, implement, buildHaiErrorBody } from './chunk-N6IOKLQY.js';
1
+ import { validateInputOrFail, resolveRequestLocale, localizeZodError } from './chunk-65XNI7TH.js';
2
+ import { servM, WILDCARD_ROLE, WILDCARD_PERMISSION, implement, buildHaiErrorBody } from './chunk-JAQIEQJS.js';
3
3
  import process from 'process';
4
4
  import { upgradeWebSocket, serve } from '@hono/node-server';
5
5
  import { apiContract, AUDIO_WS_PATH, AudioWsClientMessageSchema } from '@h-ai/api-contract';
6
6
  import { audit } from '@h-ai/audit';
7
7
  import { core, HaiCommonError, err, ok } from '@h-ai/core';
8
+ import { TRANSPORT_PLAINTEXT_RANGE, createTransportBodyPolicy, TRANSPORT_PROTOCOL, authenticateTransportRequest, TransportTimeError, createTransportErrorResponse, TransportBodyTooLargeError, readTransportBody, TRANSPORT_BODY_LIMITS, isValidSM2PublicKey } from '@h-ai/crypto';
8
9
  import { OpenAPIHandler } from '@orpc/openapi/fetch';
9
10
  import { RPCHandler } from '@orpc/server/fetch';
10
11
  import { Hono } from 'hono';
11
12
  import { WebSocketServer } from 'ws';
12
13
  import { Buffer } from 'buffer';
13
14
  import { BlockList, isIP } from 'net';
14
- import { TRANSPORT_PLAINTEXT_RANGE, TRANSPORT_PROTOCOL, authenticateTransportRequest, TransportTimeError, createTransportErrorResponse, TransportBodyTooLargeError, readTransportBody, TRANSPORT_BODY_LIMITS, isValidSM2PublicKey } from '@h-ai/crypto';
15
15
  import { z } from 'zod';
16
16
  import { getCookie } from 'hono/cookie';
17
17
  import { readFile } from 'fs/promises';
@@ -725,7 +725,8 @@ var RpcHttpConfigInputSchema = z.union([
725
725
  var TransportRuntimeConfigInputSchema = z.union([
726
726
  z.literal(false),
727
727
  z.object({
728
- 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("\u53CC\u5411\u660E\u6587\u4E0A\u9650\uFF1Bauto \u6309\u5DF2\u542F\u7528\u6A21\u5757\u63A8\u5BFC\uFF0C\u65E0\u6A21\u5757\u9700\u6C42\u65F6 1 MiB\uFF1B\u663E\u5F0F\u6574\u6570\u4E0D\u5F97\u5C0F\u4E8E\u6A21\u5757\u9700\u6C42").meta({ default: "auto" }),
728
+ 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" }),
729
+ 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"),
729
730
  keyExchangePath: AbsolutePathSchema.default("/_hai/key-exchange"),
730
731
  excludePaths: z.array(AbsolutePathSchema).default([])
731
732
  })
@@ -1062,7 +1063,7 @@ function readScalarBrowserEntry(packageInfo) {
1062
1063
  }
1063
1064
  var logger2 = core.logger.child({ module: "serv", scope: "transport" });
1064
1065
  var ENCRYPTABLE_RESPONSE_CONTENT_TYPES = ["application/json"];
1065
- function createTransportMiddleware(manager, keyExchangePath, excludePaths = []) {
1066
+ function createTransportMiddleware(manager, keyExchangePath, excludePaths = [], policy = createTransportBodyPolicy(manager.bodyLimits.plaintextBytes)) {
1066
1067
  return async (c, next) => {
1067
1068
  const pathname = new URL(c.req.url).pathname;
1068
1069
  if (pathname === keyExchangePath && c.req.method === "POST")
@@ -1072,76 +1073,83 @@ function createTransportMiddleware(manager, keyExchangePath, excludePaths = [])
1072
1073
  const clientId = c.req.header(TRANSPORT_PROTOCOL.CLIENT_ID_HEADER);
1073
1074
  if (!clientId)
1074
1075
  return jsonError(servM("serv_transportClientIdRequired"), 400);
1075
- let requestContext;
1076
+ const lease = policy.acquire(pathname);
1077
+ if (!lease)
1078
+ return jsonError(servM("serv_transportBusy"), 503);
1076
1079
  try {
1077
- const verified = await authenticateTransportRequest(manager, c.req.raw);
1078
- if (!verified.success) {
1079
- if (verified.error.cause instanceof TransportTimeError && verified.error.cause.context)
1080
- return createTransportErrorResponse(verified.error.cause, { manager, context: verified.error.cause.context });
1081
- return jsonError(servM("serv_transportDecryptFailed"), 400);
1080
+ let requestContext;
1081
+ try {
1082
+ const verified = await authenticateTransportRequest(manager, c.req.raw, lease.limits);
1083
+ if (!verified.success) {
1084
+ if (verified.error.cause instanceof TransportTimeError && verified.error.cause.context)
1085
+ return createTransportErrorResponse(verified.error.cause, { manager, context: verified.error.cause.context });
1086
+ return jsonError(servM("serv_transportDecryptFailed"), 400);
1087
+ }
1088
+ requestContext = verified.data.context;
1089
+ if (c.req.method !== "GET" && c.req.method !== "HEAD")
1090
+ replaceRequestBody(c, verified.data.plaintext);
1091
+ } catch (error) {
1092
+ if (error instanceof TransportBodyTooLargeError)
1093
+ return createTransportErrorResponse(error);
1094
+ return jsonError(servM("serv_transportInvalidPayload"), error instanceof RangeError ? 413 : 400);
1082
1095
  }
1083
- requestContext = verified.data.context;
1084
- if (c.req.method !== "GET" && c.req.method !== "HEAD")
1085
- replaceRequestBody(c, verified.data.plaintext);
1086
- } catch (error) {
1087
- if (error instanceof TransportBodyTooLargeError)
1088
- return createTransportErrorResponse(error);
1089
- return jsonError(servM("serv_transportInvalidPayload"), error instanceof RangeError ? 413 : 400);
1090
- }
1091
- await next();
1092
- const response = c.res;
1093
- if (!response)
1094
- return;
1095
- const contentType = response.headers.get("Content-Type") ?? "";
1096
- const bodyless = c.req.method === "HEAD" || isEmptyResponse(response);
1097
- if (bodyless)
1098
- void response.body?.cancel().catch(() => {
1099
- });
1100
- c.header("Content-Length", void 0);
1101
- c.header("Content-Encoding", void 0);
1102
- c.header(TRANSPORT_PROTOCOL.ENCRYPTED_HEADER, void 0);
1103
- c.header(TRANSPORT_PROTOCOL.ENVELOPE_HEADER, void 0);
1104
- if (!bodyless && !canEncryptResponseContentType(contentType)) {
1105
- void response.body?.cancel().catch(() => {
1106
- });
1107
- c.res = jsonError(servM("serv_transportEncryptFailed"), 500);
1108
- return;
1109
- }
1110
- let bodyText;
1111
- try {
1112
- bodyText = bodyless ? "" : await readTransportBody(response.body, {
1113
- maxBytes: manager.bodyLimits.plaintextBytes,
1114
- direction: "response",
1115
- signal: c.req.raw.signal
1116
- });
1117
- } catch (cause) {
1118
- if (cause instanceof TransportBodyTooLargeError) {
1119
- c.res = await createTransportErrorResponse(cause, { manager, context: requestContext });
1096
+ await next();
1097
+ const response = c.res;
1098
+ if (!response)
1099
+ return;
1100
+ const contentType = response.headers.get("Content-Type") ?? "";
1101
+ const bodyless = c.req.method === "HEAD" || isEmptyResponse(response);
1102
+ if (bodyless)
1103
+ void response.body?.cancel().catch(() => {
1104
+ });
1105
+ c.header("Content-Length", void 0);
1106
+ c.header("Content-Encoding", void 0);
1107
+ c.header(TRANSPORT_PROTOCOL.ENCRYPTED_HEADER, void 0);
1108
+ c.header(TRANSPORT_PROTOCOL.ENVELOPE_HEADER, void 0);
1109
+ if (!bodyless && !canEncryptResponseContentType(contentType)) {
1110
+ void response.body?.cancel().catch(() => {
1111
+ });
1112
+ c.res = jsonError(servM("serv_transportEncryptFailed"), 500);
1120
1113
  return;
1121
1114
  }
1122
- logger2.warn("Failed to read response body for encryption", { error: cause });
1123
- c.res = jsonError(servM("serv_transportEncryptFailed"), 500);
1124
- return;
1125
- }
1126
- const headers = new Headers(response.headers);
1127
- headers.delete(TRANSPORT_PROTOCOL.ENCRYPTED_HEADER);
1128
- headers.delete("Content-Length");
1129
- headers.delete("Content-Encoding");
1130
- const encResult = await manager.encryptResponse(requestContext, bodyText, response.status);
1131
- if (!encResult.success) {
1132
- logger2.warn("Failed to encrypt response", { error: encResult.error });
1133
- c.res = jsonError(servM("serv_transportEncryptFailed"), 500);
1134
- return;
1115
+ let bodyText;
1116
+ try {
1117
+ bodyText = bodyless ? "" : await readTransportBody(response.body, {
1118
+ maxBytes: lease.limits.plaintextBytes,
1119
+ direction: "response",
1120
+ signal: c.req.raw.signal
1121
+ });
1122
+ } catch (cause) {
1123
+ if (cause instanceof TransportBodyTooLargeError) {
1124
+ c.res = await createTransportErrorResponse(cause, { manager, context: requestContext });
1125
+ return;
1126
+ }
1127
+ logger2.warn("Failed to read response body for encryption", { error: cause });
1128
+ c.res = jsonError(servM("serv_transportEncryptFailed"), 500);
1129
+ return;
1130
+ }
1131
+ const headers = new Headers(response.headers);
1132
+ headers.delete(TRANSPORT_PROTOCOL.ENCRYPTED_HEADER);
1133
+ headers.delete("Content-Length");
1134
+ headers.delete("Content-Encoding");
1135
+ const encResult = await manager.encryptResponse(requestContext, bodyText, response.status);
1136
+ if (!encResult.success) {
1137
+ logger2.warn("Failed to encrypt response", { error: encResult.error });
1138
+ c.res = jsonError(servM("serv_transportEncryptFailed"), 500);
1139
+ return;
1140
+ }
1141
+ headers.set("Content-Type", "application/json; charset=utf-8");
1142
+ headers.set(TRANSPORT_PROTOCOL.ENCRYPTED_HEADER, TRANSPORT_PROTOCOL.ENCRYPTED_HEADER_VALUE);
1143
+ if (bodyless)
1144
+ headers.set(TRANSPORT_PROTOCOL.ENVELOPE_HEADER, JSON.stringify(encResult.data));
1145
+ c.res = new Response(bodyless ? null : JSON.stringify(encResult.data), {
1146
+ status: response.status,
1147
+ statusText: response.statusText,
1148
+ headers
1149
+ });
1150
+ } finally {
1151
+ lease.release();
1135
1152
  }
1136
- headers.set("Content-Type", "application/json; charset=utf-8");
1137
- headers.set(TRANSPORT_PROTOCOL.ENCRYPTED_HEADER, TRANSPORT_PROTOCOL.ENCRYPTED_HEADER_VALUE);
1138
- if (bodyless)
1139
- headers.set(TRANSPORT_PROTOCOL.ENVELOPE_HEADER, JSON.stringify(encResult.data));
1140
- c.res = new Response(bodyless ? null : JSON.stringify(encResult.data), {
1141
- status: response.status,
1142
- statusText: response.statusText,
1143
- headers
1144
- });
1145
1153
  };
1146
1154
  }
1147
1155
  async function handleKeyExchange(request, manager) {
@@ -1235,7 +1243,7 @@ function createApp(options) {
1235
1243
  userAgent: c.req.header("user-agent")?.slice(0, 4096)
1236
1244
  }, async () => {
1237
1245
  await next();
1238
- if (c.res.status >= 400) {
1246
+ if (audit.isInitialized && c.res.status >= 400) {
1239
1247
  const trusted = requestAuditContexts.get(c.req.raw);
1240
1248
  const recorded = await audit.capture({
1241
1249
  action: "http.reject",
@@ -1275,10 +1283,11 @@ function createApp(options) {
1275
1283
  if (options.middlewares)
1276
1284
  mountCustomMiddlewares(app, options.middlewares);
1277
1285
  if (options.transport) {
1286
+ const policy = createTransportBodyPolicy(options.transport.maxPlaintextBytes, options.transport.routePlaintextBytes);
1278
1287
  const mgr = options.transport.crypto.transport.createServer({
1279
1288
  serverKeyRing: options.transport.serverKeyRing,
1280
1289
  keyStore: options.transport.keyStore,
1281
- maxPlaintextBytes: options.transport.maxPlaintextBytes
1290
+ maxPlaintextBytes: policy.maximum.plaintextBytes
1282
1291
  });
1283
1292
  if (!mgr.success) {
1284
1293
  throw core.error.buildHaiErrorInst(
@@ -1288,7 +1297,7 @@ function createApp(options) {
1288
1297
  );
1289
1298
  }
1290
1299
  const keyExchangePath = `${http.apiPrefix}${options.transport.keyExchangePath ?? "/_hai/key-exchange"}`;
1291
- app.use("*", createTransportMiddleware(mgr.data, keyExchangePath, options.transport.excludePaths));
1300
+ app.use("*", createTransportMiddleware(mgr.data, keyExchangePath, options.transport.excludePaths, policy));
1292
1301
  }
1293
1302
  if (http.health !== false)
1294
1303
  mountHealthRoutes(app, http.health);