@h-ai/serv 0.1.0-alpha.42 → 0.1.0-alpha.43

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.md CHANGED
@@ -156,6 +156,14 @@ const app = serv.createApp({
156
156
  contract,
157
157
  procedures,
158
158
  http: servConfig.http,
159
+ middlewares: [{
160
+ middleware: serv.cors({
161
+ origin: servConfig.cors.origin,
162
+ credentials: servConfig.cors.credentials,
163
+ allowedHeaders: [...servConfig.cors.allowedHeaders],
164
+ exposedHeaders: [...servConfig.cors.exposedHeaders],
165
+ }),
166
+ }],
159
167
  transport: servConfig.transport === false
160
168
  ? undefined
161
169
  : {
@@ -484,6 +492,17 @@ http:
484
492
  readyPath: /ready
485
493
  rpc: false
486
494
 
495
+ cors:
496
+ origin: https://app.example.com
497
+ nativeOrigins: capacitor://app.example.local,tauri://app.example.local
498
+ credentials: true
499
+ allowedHeaders:
500
+ - Authorization
501
+ - Content-Type
502
+ - X-Client-Id
503
+ exposedHeaders:
504
+ - X-Encrypted
505
+
487
506
  transport:
488
507
  keyExchangePath: /_hai/key-exchange
489
508
  excludePaths:
@@ -502,8 +521,40 @@ transport:
502
521
  - `openapi`:默认 `false`;启用:`{ path: '/openapi.json' }`
503
522
  - `docs`:默认 `false`;启用:`{ path: '/docs' }`(依赖 `openapi`);启用后自动挂载 `/_hai/scalar.js`,从 `@scalar/api-reference` 的 browser bundle 提供本地 Scalar UI 脚本,无需外网 CDN
504
523
  - `rpc`:默认 `false`;启用:`{ prefix: '/rpc', access: 'loopback' | 'private-network' | 'gateway-only', gatewayHeader?, gatewaySecret? }`
524
+ - `cors`:配置文件中的跨端 origin、credentials、allowedHeaders 与 exposedHeaders;应用通过 `serv.cors(...)` 装配。
505
525
  - `transport`:`config/_serv.yml` 顶层配置;启用后可通过 `servConfig.transport` 装配到 `createApp({ transport })`,默认密钥协商子路径 `/_hai/key-exchange`
506
526
 
527
+ 生产环境应先通过 `serv.createRuntimeSecurityPolicy(...)` 收敛 CORS、Cookie 与文档暴露策略:
528
+
529
+ ```ts
530
+ const security = serv.createRuntimeSecurityPolicy({
531
+ environment: coreConfig.env,
532
+ corsOrigin: servConfig.cors.origin,
533
+ nativeOrigins: servConfig.cors.nativeOrigins,
534
+ })
535
+
536
+ const app = serv.createApp({
537
+ contract,
538
+ procedures,
539
+ http: {
540
+ ...servConfig.http,
541
+ openapi: security.exposeApiDocs ? servConfig.http.openapi : false,
542
+ docs: security.exposeApiDocs ? servConfig.http.docs : false,
543
+ },
544
+ refreshCookie: { secure: security.secureRefreshCookie },
545
+ middlewares: [{
546
+ middleware: serv.cors({
547
+ origin: security.allowOrigin,
548
+ credentials: servConfig.cors.credentials,
549
+ allowedHeaders: [...servConfig.cors.allowedHeaders],
550
+ exposedHeaders: [...servConfig.cors.exposedHeaders],
551
+ }),
552
+ }],
553
+ })
554
+ ```
555
+
556
+ 生产环境下 `_serv.yml` 的 `cors.origin` 为空或为 `*` 会在启动时失败;Web 与 Capacitor/Tauri Origin 均按规范化后的完整 Origin 精确匹配,避免后缀伪造。生产环境同时强制 Secure refresh cookie 并关闭 OpenAPI/docs,配置文件仍决定非生产环境具体挂载哪些端点。
557
+
507
558
  ## 错误处理
508
559
 
509
560
  - Default procedures 全部返回 `HaiResult<T>`,客户端直接判断 `result.success`
package/dist/index.d.ts CHANGED
@@ -115,6 +115,14 @@ interface ServTransportRuntimeConfig {
115
115
  readonly excludePaths: readonly `/${string}`[];
116
116
  readonly maxClients: number;
117
117
  }
118
+ /** `config/_serv.yml` 中可序列化的跨端 CORS 配置。 */
119
+ interface ServCorsRuntimeConfig {
120
+ readonly origin?: string;
121
+ readonly nativeOrigins?: string;
122
+ readonly allowedHeaders: readonly string[];
123
+ readonly exposedHeaders: readonly string[];
124
+ readonly credentials: boolean;
125
+ }
118
126
  /** ServHttpApp 的 HTTP 挂载配置。 */
119
127
  interface ServHttpConfig {
120
128
  readonly apiPrefix: `/api/${string}`;
@@ -126,8 +134,11 @@ interface ServHttpConfig {
126
134
  /** `config/_serv.yml` 的顶层配置结构。 */
127
135
  interface ServConfig {
128
136
  readonly http: ServHttpConfig;
137
+ readonly cors: ServCorsRuntimeConfig;
129
138
  readonly transport: false | ServTransportRuntimeConfig;
130
139
  }
140
+ /** `config/_serv.yml` 中的 CORS 输入类型。 */
141
+ type ServCorsRuntimeConfigInput = Partial<ServCorsRuntimeConfig>;
131
142
  /** `config/_serv.yml` 中的传输加密输入类型。 */
132
143
  type ServTransportRuntimeConfigInput = Partial<{
133
144
  readonly keyExchangePath: `/${string}`;
@@ -145,6 +156,7 @@ type ServHttpConfigInput = Partial<{
145
156
  /** `config/_serv.yml` 的顶层输入类型。 */
146
157
  interface ServConfigInput {
147
158
  readonly http?: ServHttpConfigInput;
159
+ readonly cors?: ServCorsRuntimeConfigInput;
148
160
  readonly transport?: false | ServTransportRuntimeConfigInput;
149
161
  }
150
162
  /** `config/_serv.yml` 对应的配置 Schema。 */
@@ -173,6 +185,13 @@ declare const ServConfigSchema: z.ZodPipe<z.ZodObject<{
173
185
  gatewaySecret: z.ZodOptional<z.ZodString>;
174
186
  }, z.core.$strip>]>>;
175
187
  }, z.core.$strip>>;
188
+ cors: z.ZodOptional<z.ZodObject<{
189
+ origin: z.ZodOptional<z.ZodString>;
190
+ nativeOrigins: z.ZodOptional<z.ZodString>;
191
+ allowedHeaders: z.ZodDefault<z.ZodArray<z.ZodString>>;
192
+ exposedHeaders: z.ZodDefault<z.ZodArray<z.ZodString>>;
193
+ credentials: z.ZodDefault<z.ZodBoolean>;
194
+ }, z.core.$strip>>;
176
195
  transport: z.ZodOptional<z.ZodUnion<readonly [z.ZodLiteral<false>, z.ZodObject<{
177
196
  keyExchangePath: z.ZodDefault<z.ZodCustom<`/${string}`, `/${string}`>>;
178
197
  excludePaths: z.ZodDefault<z.ZodArray<z.ZodCustom<`/${string}`, `/${string}`>>>;
@@ -180,6 +199,7 @@ declare const ServConfigSchema: z.ZodPipe<z.ZodObject<{
180
199
  }, z.core.$strip>]>>;
181
200
  }, z.core.$strip>, z.ZodTransform<{
182
201
  http: ServHttpConfig;
202
+ cors: ServCorsRuntimeConfig;
183
203
  transport: false | ServTransportRuntimeConfig;
184
204
  }, {
185
205
  http?: {
@@ -202,6 +222,13 @@ declare const ServConfigSchema: z.ZodPipe<z.ZodObject<{
202
222
  gatewaySecret?: string | undefined;
203
223
  };
204
224
  } | undefined;
225
+ cors?: {
226
+ allowedHeaders: string[];
227
+ exposedHeaders: string[];
228
+ credentials: boolean;
229
+ origin?: string | undefined;
230
+ nativeOrigins?: string | undefined;
231
+ } | undefined;
205
232
  transport?: false | {
206
233
  keyExchangePath: `/${string}`;
207
234
  excludePaths: `/${string}`[];
@@ -717,6 +744,33 @@ interface GenerateOpenAPISpecOptions {
717
744
  */
718
745
  declare function generateSpec(contract: AnyContractRouter, options?: GenerateOpenAPISpecOptions): Promise<OpenAPI.Document>;
719
746
 
747
+ /**
748
+ * @h-ai/serv — 部署环境 HTTP 安全策略
749
+ *
750
+ * 将环境与 CORS 配置收敛为 fail-closed 的运行时策略,避免每个业务应用重复实现
751
+ * Origin 解析、生产通配符保护、Cookie Secure 与 API 文档暴露规则。
752
+ * @module serv-runtime-security
753
+ */
754
+ /** 运行时安全策略输入;通常来自 `_core.yml.env` 与 `_serv.yml.cors`。 */
755
+ interface ServRuntimeSecurityPolicyInput {
756
+ readonly environment?: string;
757
+ readonly corsOrigin?: string;
758
+ readonly nativeOrigins?: string;
759
+ }
760
+ /** 可直接装配到 CORS、refresh cookie 与 HTTP 文档配置的安全策略。 */
761
+ interface ServRuntimeSecurityPolicy {
762
+ readonly allowOrigin: (origin: string) => boolean;
763
+ readonly isNativeOrigin: (origin: string) => boolean;
764
+ readonly secureRefreshCookie: boolean;
765
+ readonly exposeApiDocs: boolean;
766
+ }
767
+ /**
768
+ * 创建部署环境安全策略。
769
+ *
770
+ * 生产环境必须显式配置 HTTP(S) Origin,禁止缺省或 `*`;开发环境保留通配符便利性。
771
+ */
772
+ declare function createRuntimeSecurityPolicy(input: ServRuntimeSecurityPolicyInput): ServRuntimeSecurityPolicy;
773
+
720
774
  /**
721
775
  * @h-ai/serv — 校验工具
722
776
  *
@@ -793,6 +847,7 @@ declare const serv: {
793
847
  listen: typeof listen;
794
848
  toFetch: typeof toFetch;
795
849
  cors: typeof cors;
850
+ createRuntimeSecurityPolicy: typeof createRuntimeSecurityPolicy;
796
851
  generateSpec: typeof generateSpec;
797
852
  /** 基于 contract 创建可注入 context 的 procedure builder(封装 `@orpc/server` 的 `implement`)。 */
798
853
  implement: typeof implement;
@@ -806,4 +861,4 @@ declare const serv: {
806
861
  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;
807
862
  };
808
863
 
809
- export { type AudioTicketGrant, type AudioTicketVerification, type AuthorizedAudioRequest, type CreateServAppOptions, CreateServContext, type GenerateOpenAPISpecOptions, RefreshCookieConfig, type ServAudioConfig, type ServConfig, type ServConfigInput, ServConfigSchema, ServContext, type ServCorsConfig, type ServDocsHttpConfig, type ServFetchHandler, type ServGuardedProcedureWrapper, 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, type ServProcedureWrapper, type ServRpcHttpConfig, ServSession, type ServTransportConfig, type ServTransportRuntimeConfig, type ServTransportRuntimeConfigInput, type ServValidationFailureBody, serv };
864
+ 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 ServGuardedProcedureWrapper, 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, type ServProcedureWrapper, type ServRpcHttpConfig, type ServRuntimeSecurityPolicy, type ServRuntimeSecurityPolicyInput, ServSession, type ServTransportConfig, type ServTransportRuntimeConfig, type ServTransportRuntimeConfigInput, type ServValidationFailureBody, serv };
package/dist/index.js CHANGED
@@ -3,7 +3,7 @@ import { servM, mapHaiError, requireAuth, normalizeServLocale, buildHaiErrorBody
3
3
  import { implement } from '@orpc/server';
4
4
  import process2 from 'process';
5
5
  import { serve } from '@hono/node-server';
6
- import { AUDIO_WS_PATH, IAM_AUTH_ROUTES, AudioWsClientMessageSchema } from '@h-ai/api-contract';
6
+ import { apiContract, AUDIO_WS_PATH, AudioWsClientMessageSchema } from '@h-ai/api-contract';
7
7
  import { core, ok, err, HaiCommonError } from '@h-ai/core';
8
8
  import { createNodeWebSocket } from '@hono/node-ws';
9
9
  import { OpenAPIHandler } from '@orpc/openapi/fetch';
@@ -662,6 +662,13 @@ var TransportRuntimeConfigInputSchema = z.union([
662
662
  maxClients: z.number().int().positive().default(1e4)
663
663
  })
664
664
  ]);
665
+ var CorsRuntimeConfigInputSchema = z.object({
666
+ origin: z.string().optional(),
667
+ nativeOrigins: z.string().optional(),
668
+ allowedHeaders: z.array(z.string()).default(["Authorization", "Content-Type", "X-Requested-With"]),
669
+ exposedHeaders: z.array(z.string()).default([]),
670
+ credentials: z.boolean().default(false)
671
+ });
665
672
  var ServHttpConfigInputSchema = z.object({
666
673
  apiPrefix: ApiPrefixSchema.default("/api/v1"),
667
674
  openapi: OpenAPIHttpConfigInputSchema.default(false),
@@ -671,14 +678,19 @@ var ServHttpConfigInputSchema = z.object({
671
678
  });
672
679
  var ServConfigSchema = z.object({
673
680
  http: ServHttpConfigInputSchema.optional(),
681
+ cors: CorsRuntimeConfigInputSchema.optional(),
674
682
  transport: TransportRuntimeConfigInputSchema.optional()
675
- }).transform(({ http, transport }) => ({
683
+ }).transform(({ http, cors: cors2, transport }) => ({
676
684
  http: resolveServHttpConfig(http ?? {}),
685
+ cors: resolveServCorsRuntimeConfig(cors2 ?? {}),
677
686
  transport: resolveServTransportRuntimeConfig(transport ?? false)
678
687
  }));
679
688
  function resolveServHttpConfig(input = {}) {
680
689
  return ServHttpConfigInputSchema.parse(input);
681
690
  }
691
+ function resolveServCorsRuntimeConfig(input = {}) {
692
+ return CorsRuntimeConfigInputSchema.parse(input);
693
+ }
682
694
  function resolveServTransportRuntimeConfig(input = false) {
683
695
  return TransportRuntimeConfigInputSchema.parse(input);
684
696
  }
@@ -727,6 +739,13 @@ function buildAuthContextFactory(verifyToken) {
727
739
  }
728
740
  };
729
741
  }
742
+ var IAM_AUTH_PATHS = {
743
+ login: apiContract.pathOf(apiContract.iam.auth.login),
744
+ loginWithOtp: apiContract.pathOf(apiContract.iam.auth.loginWithOtp),
745
+ register: apiContract.pathOf(apiContract.iam.auth.register),
746
+ logout: apiContract.pathOf(apiContract.iam.auth.logout),
747
+ refresh: apiContract.pathOf(apiContract.iam.auth.refresh)
748
+ };
730
749
  function resolveRefreshCookieConfig(apiPrefix, config, iam) {
731
750
  const onRefresh = config.onRefresh ?? (iam ? (refreshToken) => iam.session.refresh(refreshToken) : void 0);
732
751
  if (!onRefresh) {
@@ -739,7 +758,7 @@ function resolveRefreshCookieConfig(apiPrefix, config, iam) {
739
758
  cookieName: config.cookieName ?? "hai_refresh_token",
740
759
  maxAge: config.maxAge ?? 30 * 24 * 3600,
741
760
  isSecure: config.secure ?? process2.env.NODE_ENV === "production",
742
- refreshCookiePath: `${apiPrefix}${IAM_AUTH_ROUTES.refresh}`,
761
+ refreshCookiePath: `${apiPrefix}${IAM_AUTH_PATHS.refresh}`,
743
762
  onRefresh
744
763
  };
745
764
  }
@@ -792,9 +811,9 @@ function stripRefreshTokenFromPair(tokens) {
792
811
  function mountRefreshCookieRoutes(app, apiPrefix, config, iam) {
793
812
  const { cookieName, maxAge, isSecure, refreshCookiePath, onRefresh } = resolveRefreshCookieConfig(apiPrefix, config, iam);
794
813
  const loginPaths = [
795
- `${apiPrefix}${IAM_AUTH_ROUTES.login}`,
796
- `${apiPrefix}${IAM_AUTH_ROUTES.loginWithOtp}`,
797
- `${apiPrefix}${IAM_AUTH_ROUTES.register}`
814
+ `${apiPrefix}${IAM_AUTH_PATHS.login}`,
815
+ `${apiPrefix}${IAM_AUTH_PATHS.loginWithOtp}`,
816
+ `${apiPrefix}${IAM_AUTH_PATHS.register}`
798
817
  ];
799
818
  for (const path of loginPaths) {
800
819
  app.post(path, async (c, next) => {
@@ -819,7 +838,7 @@ function mountRefreshCookieRoutes(app, apiPrefix, config, iam) {
819
838
  c.res = new Response(responseBody, { status: c.res.status, statusText: c.res.statusText, headers });
820
839
  });
821
840
  }
822
- app.post(`${apiPrefix}${IAM_AUTH_ROUTES.logout}`, async (c, next) => {
841
+ app.post(`${apiPrefix}${IAM_AUTH_PATHS.logout}`, async (c, next) => {
823
842
  await next();
824
843
  if (c.res.status !== 200)
825
844
  return;
@@ -833,7 +852,7 @@ function mountRefreshCookieRoutes(app, apiPrefix, config, iam) {
833
852
  }
834
853
  c.res = new Response(bodyText, { status: c.res.status, statusText: c.res.statusText, headers });
835
854
  });
836
- app.post(`${apiPrefix}${IAM_AUTH_ROUTES.refresh}`, async (c) => {
855
+ app.post(`${apiPrefix}${IAM_AUTH_PATHS.refresh}`, async (c) => {
837
856
  const refreshToken = getCookie(c, cookieName);
838
857
  if (!refreshToken) {
839
858
  const locale = resolveRequestLocale(c.req.raw.headers);
@@ -1382,6 +1401,75 @@ function buildCorsHeaders(options) {
1382
1401
  return headers;
1383
1402
  }
1384
1403
 
1404
+ // src/serv-runtime-security.ts
1405
+ function createRuntimeSecurityPolicy(input) {
1406
+ const production = input.environment?.trim().toLowerCase() === "production";
1407
+ const configuredOrigins = parseOrigins(input.corsOrigin);
1408
+ const configuredNativeOrigins = parseNativeOrigins(input.nativeOrigins);
1409
+ const allowsEveryOrigin = configuredOrigins === "*";
1410
+ if (production && (allowsEveryOrigin || configuredOrigins.length === 0))
1411
+ throw new Error("serv.cors.origin must contain explicit HTTP(S) origins in production");
1412
+ const allowedOrigins = allowsEveryOrigin ? void 0 : new Set(configuredOrigins);
1413
+ const allowedNativeOrigins = new Set(configuredNativeOrigins);
1414
+ return {
1415
+ allowOrigin: (origin) => {
1416
+ const normalizedNativeOrigin = tryNormalizeNativeOrigin(origin);
1417
+ if (normalizedNativeOrigin !== void 0 && allowedNativeOrigins.has(normalizedNativeOrigin))
1418
+ return true;
1419
+ if (allowedOrigins === void 0)
1420
+ return true;
1421
+ const normalizedWebOrigin = tryNormalizeWebOrigin(origin);
1422
+ return normalizedWebOrigin !== void 0 && allowedOrigins.has(normalizedWebOrigin);
1423
+ },
1424
+ isNativeOrigin: (origin) => allowedNativeOrigins.has(tryNormalizeNativeOrigin(origin) ?? ""),
1425
+ secureRefreshCookie: production,
1426
+ exposeApiDocs: !production
1427
+ };
1428
+ }
1429
+ function parseOrigins(value) {
1430
+ const entries = (value ?? "*").split(",").map((origin) => origin.trim()).filter(Boolean);
1431
+ if (entries.includes("*"))
1432
+ return "*";
1433
+ return entries.map(normalizeWebOrigin);
1434
+ }
1435
+ function parseNativeOrigins(value) {
1436
+ return (value ?? "").split(",").map((origin) => origin.trim()).filter(Boolean).map(normalizeNativeOrigin);
1437
+ }
1438
+ function normalizeWebOrigin(value) {
1439
+ const normalized = tryNormalizeWebOrigin(value);
1440
+ if (normalized === void 0)
1441
+ throw new Error(`serv.cors.origin contains an invalid origin: ${value}`);
1442
+ return normalized;
1443
+ }
1444
+ function normalizeNativeOrigin(value) {
1445
+ const normalized = tryNormalizeNativeOrigin(value);
1446
+ if (normalized === void 0)
1447
+ throw new Error(`serv.cors.nativeOrigins contains an invalid origin: ${value}`);
1448
+ return normalized;
1449
+ }
1450
+ function tryNormalizeWebOrigin(value) {
1451
+ let url;
1452
+ try {
1453
+ url = new URL(value);
1454
+ } catch {
1455
+ return void 0;
1456
+ }
1457
+ if (!["http:", "https:"].includes(url.protocol) || url.origin === "null" || url.pathname !== "/" || url.search || url.hash)
1458
+ return void 0;
1459
+ return url.origin;
1460
+ }
1461
+ function tryNormalizeNativeOrigin(value) {
1462
+ let url;
1463
+ try {
1464
+ url = new URL(value);
1465
+ } catch {
1466
+ return void 0;
1467
+ }
1468
+ if (!["http:", "https:", "capacitor:", "tauri:"].includes(url.protocol) || !url.hostname || url.username || url.password || !["", "/"].includes(url.pathname) || url.search || url.hash)
1469
+ return void 0;
1470
+ return ["capacitor:", "tauri:"].includes(url.protocol) ? `${url.protocol}//${url.host}` : url.origin;
1471
+ }
1472
+
1385
1473
  // src/serv-main.ts
1386
1474
  var serv = {
1387
1475
  // 应用与上下文
@@ -1393,6 +1481,7 @@ var serv = {
1393
1481
  listen,
1394
1482
  toFetch,
1395
1483
  cors,
1484
+ createRuntimeSecurityPolicy,
1396
1485
  // OpenAPI / 导出
1397
1486
  generateSpec,
1398
1487
  // Procedure 装配(contract 路由定义统一使用 `apiContract.route`)。