@h-ai/serv 0.1.0-alpha.76 → 1.0.0-beta.2

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.js CHANGED
@@ -1,17 +1,16 @@
1
- import { validateInputOrFail, resolveRequestLocale, localizeZodError } from './chunk-65XNI7TH.js';
2
- import { servM, WILDCARD_ROLE, WILDCARD_PERMISSION, implement, buildHaiErrorBody } from './chunk-JAQIEQJS.js';
1
+ import { validateInputOrFail, resolveRequestLocale, localizeZodError } from './chunk-TWMOZPGU.js';
2
+ import { servM, WILDCARD_ROLE, WILDCARD_PERMISSION, implement, buildHaiErrorBody } from './chunk-I3F7RN4I.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
- import { core, HaiCommonError, err, ok } from '@h-ai/core';
7
+ import { core, RequestAccessConfigSchema, TrustedProxiesSchema, HaiCommonError, err, ok } from '@h-ai/core';
8
8
  import { TRANSPORT_PLAINTEXT_RANGE, createTransportBodyPolicy, TRANSPORT_PROTOCOL, authenticateTransportRequest, TransportTimeError, createTransportErrorResponse, TransportBodyTooLargeError, readTransportBody, TRANSPORT_BODY_LIMITS, isValidSM2PublicKey } from '@h-ai/crypto';
9
9
  import { OpenAPIHandler } from '@orpc/openapi/fetch';
10
10
  import { RPCHandler } from '@orpc/server/fetch';
11
11
  import { Hono } from 'hono';
12
12
  import { WebSocketServer } from 'ws';
13
13
  import { Buffer } from 'buffer';
14
- import { BlockList, isIP } from 'net';
15
14
  import { z } from 'zod';
16
15
  import { getCookie } from 'hono/cookie';
17
16
  import { readFile } from 'fs/promises';
@@ -552,66 +551,7 @@ function toArrayBuffer(data) {
552
551
  function readRemoteAddress(metadata) {
553
552
  if (!metadata || typeof metadata !== "object" || !("remoteAddress" in metadata) || typeof metadata.remoteAddress !== "string")
554
553
  return void 0;
555
- return normalizeClientIp(metadata.remoteAddress);
556
- }
557
- function normalizeClientIp(value) {
558
- if (!value)
559
- return void 0;
560
- const address = value.trim();
561
- if (!isIP(address))
562
- return void 0;
563
- const mapped = address.match(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/i);
564
- return mapped?.[1] ?? address;
565
- }
566
- function addProxy(blockList, entry) {
567
- const [address, prefix, extra] = entry.split("/");
568
- const family = address ? isIP(address) : 0;
569
- if (!family || extra !== void 0)
570
- throw new Error("trustedProxies entries must be IP addresses or CIDRs");
571
- const type = family === 4 ? "ipv4" : "ipv6";
572
- if (prefix === void 0) {
573
- blockList.addAddress(address, type);
574
- return;
575
- }
576
- const bits = Number(prefix);
577
- if (!/^\d+$/.test(prefix) || bits < 0 || bits > (family === 4 ? 32 : 128))
578
- throw new Error("Invalid trustedProxies CIDR prefix");
579
- blockList.addSubnet(address, bits, type);
580
- }
581
- function isTrustedProxyEntry(entry) {
582
- try {
583
- addProxy(new BlockList(), entry);
584
- return true;
585
- } catch {
586
- return false;
587
- }
588
- }
589
- function createClientIpResolver(trustedProxies) {
590
- const proxies = new BlockList();
591
- for (const entry of trustedProxies)
592
- addProxy(proxies, entry);
593
- const trusted = (address) => proxies.check(address, isIP(address) === 4 ? "ipv4" : "ipv6");
594
- return (request, remoteAddress) => {
595
- const peer = normalizeClientIp(remoteAddress);
596
- if (!peer || !trusted(peer))
597
- return peer;
598
- const forwarded = request.headers.get("x-forwarded-for");
599
- if (forwarded === null) {
600
- const realIp = request.headers.get("x-real-ip");
601
- return realIp === null ? peer : normalizeClientIp(realIp);
602
- }
603
- const chain = forwarded.split(",");
604
- if (chain.length > 32)
605
- return void 0;
606
- let current = peer;
607
- for (let index = chain.length - 1; index >= 0 && trusted(current); index--) {
608
- const address = normalizeClientIp(chain[index]);
609
- if (!address)
610
- return void 0;
611
- current = address;
612
- }
613
- return current;
614
- };
554
+ return core.access.normalizeClientIp(metadata.remoteAddress);
615
555
  }
616
556
 
617
557
  // src/pipelines/serv-pipeline-require-internal-rpc.ts
@@ -743,7 +683,8 @@ var CorsRuntimeConfigInputSchema = z.object({
743
683
  credentials: z.boolean().default(false)
744
684
  });
745
685
  var ServHttpConfigInputSchema = z.object({
746
- trustedProxies: z.array(z.string().trim().refine(isTrustedProxyEntry, "Expected an IP address or CIDR")).default([]),
686
+ trustedProxies: TrustedProxiesSchema.default("auto"),
687
+ access: RequestAccessConfigSchema.default(false),
747
688
  apiPrefix: ApiPrefixSchema.default("/api/v1"),
748
689
  openapi: OpenAPIHttpConfigInputSchema.default(false),
749
690
  docs: DocsHttpConfigInputSchema.default(false),
@@ -803,7 +744,7 @@ function extractBearerToken(value) {
803
744
  function parseRequestContext(input) {
804
745
  const headers = input.request.headers;
805
746
  const requestId = core.context.get().requestId ?? resolveRequestId(headers.get(HEADER_REQUEST_ID));
806
- const ip = normalizeClientIp(Object.hasOwn(input, "ip") ? input.ip : input.remoteAddress);
747
+ const ip = core.access.normalizeClientIp(Object.hasOwn(input, "ip") ? input.ip : input.remoteAddress);
807
748
  const locale = resolveRequestLocale(headers);
808
749
  const accessToken = extractBearerToken(headers.get(HEADER_AUTHORIZATION));
809
750
  const terminal = headers.get("x-hai-terminal");
@@ -999,6 +940,96 @@ async function readRefreshTokenFromBody(request) {
999
940
  const body = await request.json().catch(() => void 0);
1000
941
  return typeof body?.refreshToken === "string" && body.refreshToken.length > 0 ? body.refreshToken : void 0;
1001
942
  }
943
+
944
+ // src/serv-cors.ts
945
+ var DEFAULT_CORS_METHODS = ["GET", "HEAD", "PUT", "PATCH", "POST", "DELETE", "OPTIONS"];
946
+ var DEFAULT_CORS_ALLOWED_HEADERS = ["Authorization", "Content-Type", "X-Requested-With", "X-Hai-Reauthentication", "X-Hai-Terminal"];
947
+ var DEFAULT_CORS_MAX_AGE = 86400;
948
+ var corsMiddlewares = /* @__PURE__ */ new WeakSet();
949
+ function isCorsMiddleware(middleware) {
950
+ return corsMiddlewares.has(middleware);
951
+ }
952
+ function cors(config = {}) {
953
+ const methods = [...config.methods ?? DEFAULT_CORS_METHODS];
954
+ const allowedHeaders = [...config.allowedHeaders ?? DEFAULT_CORS_ALLOWED_HEADERS];
955
+ const exposedHeaders = [.../* @__PURE__ */ new Set(["X-Request-Id", "X-Trace-Id", ...config.exposedHeaders ?? []])];
956
+ const credentials = config.credentials ?? false;
957
+ const maxAge = config.maxAge ?? DEFAULT_CORS_MAX_AGE;
958
+ const originConfig = config.origin ?? "*";
959
+ const middleware = async (c, next) => {
960
+ const allowedOrigin = resolveCorsOrigin(c.req.header("Origin"), originConfig);
961
+ if (c.req.method === "OPTIONS") {
962
+ return new Response(null, {
963
+ status: 204,
964
+ headers: buildCorsHeaders({
965
+ allowedOrigin,
966
+ allowedHeaders,
967
+ credentials,
968
+ exposedHeaders,
969
+ maxAge,
970
+ methods,
971
+ request: c.req.raw
972
+ })
973
+ });
974
+ }
975
+ await next();
976
+ const corsHeaders = buildCorsHeaders({
977
+ allowedOrigin,
978
+ allowedHeaders,
979
+ credentials,
980
+ exposedHeaders,
981
+ maxAge,
982
+ methods,
983
+ request: c.req.raw
984
+ });
985
+ for (const [key, value] of corsHeaders) {
986
+ if (key.toLowerCase() === "vary") {
987
+ c.res.headers.append(key, value);
988
+ continue;
989
+ }
990
+ c.res.headers.set(key, value);
991
+ }
992
+ return void 0;
993
+ };
994
+ corsMiddlewares.add(middleware);
995
+ return middleware;
996
+ }
997
+ function resolveCorsOrigin(origin, configOrigin) {
998
+ if (!origin)
999
+ return null;
1000
+ if (configOrigin === "*")
1001
+ return "*";
1002
+ if (typeof configOrigin === "string")
1003
+ return origin === configOrigin ? origin : null;
1004
+ if (Array.isArray(configOrigin))
1005
+ return configOrigin.includes(origin) ? origin : null;
1006
+ if (typeof configOrigin === "function")
1007
+ return configOrigin(origin) ? origin : null;
1008
+ return null;
1009
+ }
1010
+ function buildCorsHeaders(options) {
1011
+ const headers = new Headers();
1012
+ if (!options.allowedOrigin)
1013
+ return headers;
1014
+ const requestedHeaders = options.request.headers.get("Access-Control-Request-Headers");
1015
+ headers.set("Access-Control-Allow-Origin", options.allowedOrigin);
1016
+ if (options.credentials)
1017
+ headers.set("Access-Control-Allow-Credentials", "true");
1018
+ if (options.allowedOrigin !== "*") {
1019
+ headers.set(
1020
+ "Vary",
1021
+ requestedHeaders ? "Origin, Access-Control-Request-Headers" : "Origin"
1022
+ );
1023
+ }
1024
+ if (options.exposedHeaders.length > 0)
1025
+ headers.set("Access-Control-Expose-Headers", options.exposedHeaders.join(", "));
1026
+ if (options.request.method === "OPTIONS") {
1027
+ headers.set("Access-Control-Allow-Methods", options.methods.join(", "));
1028
+ headers.set("Access-Control-Allow-Headers", requestedHeaders ?? options.allowedHeaders.join(", "));
1029
+ headers.set("Access-Control-Max-Age", String(options.maxAge));
1030
+ }
1031
+ return headers;
1032
+ }
1002
1033
  var SCALAR_ROUTE = "/_hai/scalar.js";
1003
1034
  var SCALAR_BROWSER_ENTRY_FALLBACK = "./dist/browser/standalone.js";
1004
1035
  var scalarScriptCache;
@@ -1063,12 +1094,20 @@ function readScalarBrowserEntry(packageInfo) {
1063
1094
  }
1064
1095
  var logger2 = core.logger.child({ module: "serv", scope: "transport" });
1065
1096
  var ENCRYPTABLE_RESPONSE_CONTENT_TYPES = ["application/json"];
1066
- function createTransportMiddleware(manager, keyExchangePath, excludePaths = [], policy = createTransportBodyPolicy(manager.bodyLimits.plaintextBytes)) {
1097
+ function createTransportMiddleware(manager, keyExchangePath, excludePaths = [], policy = createTransportBodyPolicy(manager.bodyLimits.plaintextBytes), encryption) {
1067
1098
  return async (c, next) => {
1068
1099
  const pathname = new URL(c.req.url).pathname;
1100
+ if (c.req.method === "OPTIONS")
1101
+ return next();
1069
1102
  if (pathname === keyExchangePath && c.req.method === "POST")
1070
1103
  return handleKeyExchange(c.req.raw, manager);
1071
- if (shouldExclude(pathname, excludePaths, keyExchangePath))
1104
+ const mode = encryption?.(pathname, c.req.method);
1105
+ if (mode === "disabled") {
1106
+ if (c.req.raw.headers.has(TRANSPORT_PROTOCOL.ENCRYPTED_HEADER) || c.req.raw.headers.has(TRANSPORT_PROTOCOL.ENVELOPE_HEADER))
1107
+ return Response.json(err({ ...HaiCommonError.VALIDATION_ERROR, message: servM("serv_transportPlaintextRequired"), traceId: core.context.get().traceId ?? core.context.get().requestId }), { status: 400 });
1108
+ return next();
1109
+ }
1110
+ if (mode !== "required" && shouldExclude(pathname, excludePaths, keyExchangePath))
1072
1111
  return next();
1073
1112
  const clientId = c.req.header(TRANSPORT_PROTOCOL.CLIENT_ID_HEADER);
1074
1113
  if (!clientId)
@@ -1222,7 +1261,10 @@ var API_METHODS = ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"];
1222
1261
  var audioWsServers = /* @__PURE__ */ new WeakMap();
1223
1262
  function createApp(options) {
1224
1263
  const http = resolveServHttpConfig(options.http);
1225
- const resolveIp = createClientIpResolver(http.trustedProxies);
1264
+ const resolveIp = core.access.createClientIpResolver(http.trustedProxies);
1265
+ const checkAccess = core.access.createRequestCheck(http.access);
1266
+ if (checkAccess.requiresEncryption && !options.transport)
1267
+ throw new Error("Request access rules require transport encryption, but Serv transport is not configured");
1226
1268
  const app = new Hono();
1227
1269
  const requestAuditContexts = /* @__PURE__ */ new WeakMap();
1228
1270
  app.onError((error, c) => {
@@ -1280,8 +1322,45 @@ function createApp(options) {
1280
1322
  return cachedSpec;
1281
1323
  };
1282
1324
  app.use("*", securityHeaders());
1283
- if (options.middlewares)
1284
- mountCustomMiddlewares(app, options.middlewares);
1325
+ const accessDenied = (c) => {
1326
+ const ipAddress = resolveIp(c.req.raw, readRemoteAddress(c.env));
1327
+ const decision = checkAccess(c.req.path, c.req.method, ipAddress);
1328
+ if (decision.allowed) {
1329
+ if (!options.transport && c.req.method !== "OPTIONS" && (c.req.raw.headers.has(TRANSPORT_PROTOCOL.ENCRYPTED_HEADER) || c.req.raw.headers.has(TRANSPORT_PROTOCOL.ENVELOPE_HEADER)) && checkAccess.encryption(c.req.path, c.req.method) === "disabled") {
1330
+ return c.json(buildHaiErrorBody(HaiCommonError.VALIDATION_ERROR, servM("serv_transportPlaintextRequired")), 400);
1331
+ }
1332
+ return void 0;
1333
+ }
1334
+ core.logger.info("Request access policy denied", { ruleId: decision.ruleId, reason: decision.reason, method: c.req.method, ipAddress });
1335
+ return c.json(buildHaiErrorBody(HaiCommonError.REQUEST_ACCESS_DENIED, core.i18n.coreM("core_requestAccessDenied")), 403);
1336
+ };
1337
+ const cors2 = options.middlewares?.filter((entry) => isCorsMiddleware(entry.middleware)) ?? [];
1338
+ const custom = options.middlewares?.filter((entry) => !isCorsMiddleware(entry.middleware)) ?? [];
1339
+ for (const entry of cors2) {
1340
+ if (http.access === false) {
1341
+ app.use(entry.path ?? "*", entry.middleware);
1342
+ continue;
1343
+ }
1344
+ app.use(entry.path ?? "*", async (c, next) => {
1345
+ if (c.req.method !== "OPTIONS")
1346
+ return entry.middleware(c, next);
1347
+ const rejected = accessDenied(c);
1348
+ const preflight = await entry.middleware(c, async () => {
1349
+ });
1350
+ if (!rejected)
1351
+ return preflight;
1352
+ if (preflight instanceof Response) {
1353
+ for (const [key, value] of preflight.headers)
1354
+ rejected.headers.set(key, value);
1355
+ rejected.headers.set("content-type", "application/json; charset=utf-8");
1356
+ }
1357
+ return rejected;
1358
+ });
1359
+ }
1360
+ const keyExchangePath = options.transport ? `${http.apiPrefix}${options.transport.keyExchangePath ?? "/_hai/key-exchange"}` : void 0;
1361
+ if (http.access !== false && keyExchangePath) {
1362
+ app.use("*", async (c, next) => c.req.path === keyExchangePath && c.req.method === "POST" ? accessDenied(c) ?? next() : next());
1363
+ }
1285
1364
  if (options.transport) {
1286
1365
  const policy = createTransportBodyPolicy(options.transport.maxPlaintextBytes, options.transport.routePlaintextBytes);
1287
1366
  const mgr = options.transport.crypto.transport.createServer({
@@ -1296,9 +1375,11 @@ function createApp(options) {
1296
1375
  mgr.error
1297
1376
  );
1298
1377
  }
1299
- const keyExchangePath = `${http.apiPrefix}${options.transport.keyExchangePath ?? "/_hai/key-exchange"}`;
1300
- app.use("*", createTransportMiddleware(mgr.data, keyExchangePath, options.transport.excludePaths, policy));
1378
+ app.use("*", createTransportMiddleware(mgr.data, keyExchangePath, options.transport.excludePaths, policy, checkAccess.encryption));
1301
1379
  }
1380
+ if (http.access !== false)
1381
+ app.use("*", async (c, next) => accessDenied(c) ?? next());
1382
+ mountCustomMiddlewares(app, custom);
1302
1383
  if (http.health !== false)
1303
1384
  mountHealthRoutes(app, http.health);
1304
1385
  if (options.refreshCookie)
@@ -1478,90 +1559,6 @@ function listen(app, options = {}) {
1478
1559
  return handle;
1479
1560
  }
1480
1561
 
1481
- // src/serv-cors.ts
1482
- var DEFAULT_CORS_METHODS = ["GET", "HEAD", "PUT", "PATCH", "POST", "DELETE", "OPTIONS"];
1483
- var DEFAULT_CORS_ALLOWED_HEADERS = ["Authorization", "Content-Type", "X-Requested-With", "X-Hai-Reauthentication", "X-Hai-Terminal"];
1484
- var DEFAULT_CORS_MAX_AGE = 86400;
1485
- function cors(config = {}) {
1486
- const methods = [...config.methods ?? DEFAULT_CORS_METHODS];
1487
- const allowedHeaders = [...config.allowedHeaders ?? DEFAULT_CORS_ALLOWED_HEADERS];
1488
- const exposedHeaders = [.../* @__PURE__ */ new Set(["X-Request-Id", "X-Trace-Id", ...config.exposedHeaders ?? []])];
1489
- const credentials = config.credentials ?? false;
1490
- const maxAge = config.maxAge ?? DEFAULT_CORS_MAX_AGE;
1491
- const originConfig = config.origin ?? "*";
1492
- return async (c, next) => {
1493
- const allowedOrigin = resolveCorsOrigin(c.req.header("Origin"), originConfig);
1494
- if (c.req.method === "OPTIONS") {
1495
- return new Response(null, {
1496
- status: 204,
1497
- headers: buildCorsHeaders({
1498
- allowedOrigin,
1499
- allowedHeaders,
1500
- credentials,
1501
- exposedHeaders,
1502
- maxAge,
1503
- methods,
1504
- request: c.req.raw
1505
- })
1506
- });
1507
- }
1508
- await next();
1509
- const corsHeaders = buildCorsHeaders({
1510
- allowedOrigin,
1511
- allowedHeaders,
1512
- credentials,
1513
- exposedHeaders,
1514
- maxAge,
1515
- methods,
1516
- request: c.req.raw
1517
- });
1518
- for (const [key, value] of corsHeaders) {
1519
- if (key.toLowerCase() === "vary") {
1520
- c.res.headers.append(key, value);
1521
- continue;
1522
- }
1523
- c.res.headers.set(key, value);
1524
- }
1525
- return void 0;
1526
- };
1527
- }
1528
- function resolveCorsOrigin(origin, configOrigin) {
1529
- if (!origin)
1530
- return null;
1531
- if (configOrigin === "*")
1532
- return "*";
1533
- if (typeof configOrigin === "string")
1534
- return origin === configOrigin ? origin : null;
1535
- if (Array.isArray(configOrigin))
1536
- return configOrigin.includes(origin) ? origin : null;
1537
- if (typeof configOrigin === "function")
1538
- return configOrigin(origin) ? origin : null;
1539
- return null;
1540
- }
1541
- function buildCorsHeaders(options) {
1542
- const headers = new Headers();
1543
- if (!options.allowedOrigin)
1544
- return headers;
1545
- const requestedHeaders = options.request.headers.get("Access-Control-Request-Headers");
1546
- headers.set("Access-Control-Allow-Origin", options.allowedOrigin);
1547
- if (options.credentials)
1548
- headers.set("Access-Control-Allow-Credentials", "true");
1549
- if (options.allowedOrigin !== "*") {
1550
- headers.set(
1551
- "Vary",
1552
- requestedHeaders ? "Origin, Access-Control-Request-Headers" : "Origin"
1553
- );
1554
- }
1555
- if (options.exposedHeaders.length > 0)
1556
- headers.set("Access-Control-Expose-Headers", options.exposedHeaders.join(", "));
1557
- if (options.request.method === "OPTIONS") {
1558
- headers.set("Access-Control-Allow-Methods", options.methods.join(", "));
1559
- headers.set("Access-Control-Allow-Headers", requestedHeaders ?? options.allowedHeaders.join(", "));
1560
- headers.set("Access-Control-Max-Age", String(options.maxAge));
1561
- }
1562
- return headers;
1563
- }
1564
-
1565
1562
  // src/serv-runtime-security.ts
1566
1563
  function createRuntimeSecurityPolicy(input) {
1567
1564
  const production = input.environment?.trim().toLowerCase() === "production";