@zhin.js/host-http 1.3.0 → 1.4.0

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.
@@ -58,6 +58,16 @@ export interface ConsoleAccessProvider {
58
58
  audit?(entry: ConsoleAuditEntry): void;
59
59
  }
60
60
  export declare function isUserPrincipal(principal: ConsolePrincipal | undefined): principal is ConsoleUserPrincipal;
61
+ /** User id and credential id of the gateway control-socket principal (ADR 0054 §6). */
62
+ export declare const LOCAL_SOCKET_USER_ID = "local";
63
+ export declare const LOCAL_SOCKET_CREDENTIAL_ID = "local-socket";
64
+ /**
65
+ * The principal of a request from the project owner's CLI over `.zhin/gateway.sock` (no token).
66
+ * Sessions and API tokens never carry this credential id (theirs are token hashes / token ids).
67
+ */
68
+ export declare function isLocalSocketPrincipal(principal: ConsolePrincipal | undefined): boolean;
69
+ /** A user principal with a global `owner` binding (the local socket principal is one). */
70
+ export declare function isGlobalOwnerPrincipal(principal: ConsolePrincipal | undefined): boolean;
61
71
  /**
62
72
  * Whether the request carried an explicit credential: a user session / API token, or the
63
73
  * still-valid bootstrap token (`{ kind: 'bootstrap', open: false }`). An open Host is not explicit.
@@ -3,6 +3,22 @@ import { hasForwardingHeaders } from './trust-proxy.js';
3
3
  export function isUserPrincipal(principal) {
4
4
  return principal?.kind === 'user' || principal?.kind === 'api-token';
5
5
  }
6
+ /** User id and credential id of the gateway control-socket principal (ADR 0054 §6). */
7
+ export const LOCAL_SOCKET_USER_ID = 'local';
8
+ export const LOCAL_SOCKET_CREDENTIAL_ID = 'local-socket';
9
+ /**
10
+ * The principal of a request from the project owner's CLI over `.zhin/gateway.sock` (no token).
11
+ * Sessions and API tokens never carry this credential id (theirs are token hashes / token ids).
12
+ */
13
+ export function isLocalSocketPrincipal(principal) {
14
+ return principal?.kind === 'user' && principal.userId === LOCAL_SOCKET_USER_ID
15
+ && principal.credentialId === LOCAL_SOCKET_CREDENTIAL_ID;
16
+ }
17
+ /** A user principal with a global `owner` binding (the local socket principal is one). */
18
+ export function isGlobalOwnerPrincipal(principal) {
19
+ return isUserPrincipal(principal)
20
+ && principal.grants.some((grant) => grant.role === 'owner' && grant.scope.kind === 'global');
21
+ }
6
22
  /**
7
23
  * Whether the request carried an explicit credential: a user session / API token, or the
8
24
  * still-valid bootstrap token (`{ kind: 'bootstrap', open: false }`). An open Host is not explicit.
@@ -1,4 +1,4 @@
1
- import { CONFIG_PATCH_LIMITS, CONSOLE_SEND_FAILURE_MESSAGES, ENDPOINT_RPC, classifyConsoleSendFailure, } from '@zhin.js/console-protocol';
1
+ import { CONFIG_PATCH_LIMITS, CONSOLE_SEND_FAILURE_MESSAGES, ENDPOINT_RPC, classifyConsoleSendFailure, endpointTransportLabel, } from '@zhin.js/console-protocol';
2
2
  /** Canonical Console wire fields → runtime conversation address. */
3
3
  export function wireConversation(channelType, channelId, parent) {
4
4
  const kind = channelType === 'group' || channelType === 'channel' ? channelType : 'private';
@@ -595,7 +595,7 @@ export async function dispatchHostResourceConsoleRpc(type, message, ctx, request
595
595
  case 'endpoint.list': {
596
596
  try {
597
597
  const endpoints = ctx.listEndpoints ? await ctx.listEndpoints() : [];
598
- emit({ requestId, data: { endpoints } });
598
+ emit({ requestId, data: { endpoints: endpoints.map(withTransportLabel) } });
599
599
  }
600
600
  catch (error) {
601
601
  emit({
@@ -623,7 +623,7 @@ export async function dispatchHostResourceConsoleRpc(type, message, ctx, request
623
623
  emit({ requestId, error: 'endpoint not found' });
624
624
  return true;
625
625
  }
626
- emit({ requestId, data: endpoint });
626
+ emit({ requestId, data: withTransportLabel(endpoint) });
627
627
  }
628
628
  catch (error) {
629
629
  emit({
@@ -828,3 +828,12 @@ export function configurationSaveMessage(result) {
828
828
  }
829
829
  return result.restartRequired ? '配置已保存,需重启进程才能生效' : '配置已保存';
830
830
  }
831
+ /** Adds the readable `transportLabel` next to the raw `transportState` of an Endpoint row (#1250). */
832
+ function withTransportLabel(endpoint) {
833
+ if (!endpoint || typeof endpoint !== 'object')
834
+ return endpoint;
835
+ const state = endpoint.transportState;
836
+ if (typeof state !== 'string')
837
+ return endpoint;
838
+ return { ...endpoint, transportLabel: endpointTransportLabel(state) };
839
+ }
@@ -283,7 +283,11 @@ export async function dispatchRuntimeConsoleRpc(message, ctx) {
283
283
  return payloads;
284
284
  }
285
285
  const projectPatch = message.type === CONFIG_RPC.PATCH_PROJECT;
286
- const { instanceKey, expectedRevision, patches } = message;
286
+ const { instanceKey, expectedRevision, patches, secretStorage } = message;
287
+ if (secretStorage !== undefined && secretStorage !== 'env' && secretStorage !== 'plain') {
288
+ emit({ requestId, error: 'secretStorage 只能是 env 或 plain' });
289
+ return payloads;
290
+ }
287
291
  if (projectPatch && instanceKey !== undefined) {
288
292
  emit({ requestId, error: 'Project patches must not specify instanceKey' });
289
293
  return payloads;
@@ -318,6 +322,7 @@ export async function dispatchRuntimeConsoleRpc(message, ctx) {
318
322
  ...(instanceKey === undefined ? {} : { instanceKey }),
319
323
  expectedRevision,
320
324
  patches,
325
+ ...(secretStorage === undefined ? {} : { secretStorage }),
321
326
  });
322
327
  }
323
328
  catch (error) {
@@ -382,9 +387,11 @@ export async function dispatchRuntimeConsoleRpc(message, ctx) {
382
387
  });
383
388
  }
384
389
  catch (error) {
390
+ const code = error instanceof Error && 'code' in error && error.code === 'env-write-required' ? { code: 'env-write-required' } : {};
385
391
  emit({
386
392
  requestId,
387
393
  error: `Failed to set config: ${error instanceof Error ? error.message : String(error)}`,
394
+ ...code,
388
395
  });
389
396
  }
390
397
  return payloads;
@@ -3,6 +3,7 @@ import { readFile } from 'node:fs/promises';
3
3
  import { isAbsolute, resolve } from 'node:path';
4
4
  import { httpHostToken } from './http-host.js';
5
5
  import { parseTrustProxy } from './trust-proxy.js';
6
+ import { parseHttpPort } from './port.js';
6
7
  export async function resolveHttpConfig(config, envOverlay, projectRoot = process.cwd()) {
7
8
  const document = await readConfigDocument(config);
8
9
  if (!document || typeof document !== 'object')
@@ -11,18 +12,20 @@ export async function resolveHttpConfig(config, envOverlay, projectRoot = proces
11
12
  if (!http || typeof http !== 'object')
12
13
  return {};
13
14
  const value = expandEnvironmentValue(http, (key) => envOverlay?.[key] ?? process.env[key]);
15
+ const stored = http;
14
16
  if (value.tokens !== undefined && value.tokens !== null)
15
17
  throw new Error(HTTP_TOKENS_REMOVED_MESSAGE);
16
18
  const primary = Object.freeze({
17
19
  host: typeof value.host === 'string' ? value.host : undefined,
18
- port: typeof value.port === 'number' ? value.port : undefined,
20
+ // An invalid port is an error, never a silent fallback to the default (#1250).
21
+ port: parseHttpPort(value.port, 'http.port', stored.port),
19
22
  token: typeof value.token === 'string' ? value.token : undefined,
20
23
  corsOrigins: injectDesktopWebviewOrigins(parseStringList(value.corsOrigins), envOverlay),
21
24
  apiBase: typeof value.base === 'string' ? value.base : undefined,
22
25
  tls: await parseTlsConfig(value.tls, projectRoot),
23
26
  trustProxy: parseTrustProxy(value.trustProxy),
24
27
  });
25
- const configuredListeners = await parseAdditionalListeners(value.listeners, primary, projectRoot);
28
+ const configuredListeners = await parseAdditionalListeners(value.listeners, primary, projectRoot, stored.listeners);
26
29
  const listeners = await injectDesktopDeviceListener(configuredListeners, primary, envOverlay, projectRoot);
27
30
  return Object.freeze({ ...primary, ...(listeners.length > 0 ? { listeners } : {}) });
28
31
  }
@@ -71,7 +74,7 @@ async function injectDesktopDeviceListener(configured, primary, envOverlay, proj
71
74
  }, projectRoot);
72
75
  return Object.freeze([...configured, Object.freeze({ ...primary, host, port, tls })]);
73
76
  }
74
- async function parseAdditionalListeners(value, primary, projectRoot) {
77
+ async function parseAdditionalListeners(value, primary, projectRoot, stored) {
75
78
  if (value === undefined)
76
79
  return Object.freeze([]);
77
80
  if (!Array.isArray(value))
@@ -87,13 +90,15 @@ async function parseAdditionalListeners(value, primary, projectRoot) {
87
90
  if (typeof listener.host !== 'string' || !listener.host) {
88
91
  throw new Error(`http.listeners[${index}].host is required`);
89
92
  }
90
- if (typeof listener.port !== 'number') {
93
+ const storedPort = Array.isArray(stored) ? stored[index]?.port : undefined;
94
+ const port = parseHttpPort(listener.port, `http.listeners[${index}].port`, storedPort);
95
+ if (port === undefined) {
91
96
  throw new Error(`http.listeners[${index}].port is required`);
92
97
  }
93
98
  listeners.push(Object.freeze({
94
99
  ...primary,
95
100
  host: listener.host,
96
- port: listener.port,
101
+ port,
97
102
  tls: await parseTlsConfig(listener.tls, projectRoot),
98
103
  }));
99
104
  }
package/lib/index.d.ts CHANGED
@@ -1,8 +1,9 @@
1
1
  export { createHttpRouter, httpHostToken, normalizePath, REMOTE_CONSOLE_ORIGIN, type HttpHandler, type HttpHost, type HttpRequestInfo, type HttpRouter, type HttpRouterOptions, type HttpRouteRegistration, type RouteContext, type WsConnection, type WsHandle, type WsRouteOptions, type WsTransport, type WsUpgradeDecision, type WsUpgradeRequest, type ListedWsRoute, } from './http-host.js';
2
2
  export type { HttpHostAddress, HttpHostOptions, HttpHostTlsOptions } from './host-options.js';
3
3
  export { listHttpHostAddresses } from './addresses.js';
4
+ export { DEFAULT_HTTP_PORT, ENVIRONMENT_REFERENCE_PATTERN, InvalidHttpPortError, describeInvalidPortValue, parseHttpPort, } from './port.js';
4
5
  export { TokenRegistry, extractBearerToken, publicConsoleAccess, unrestrictedConsoleAccess, type AuthenticatedTokenPrincipal, type ConsoleAccess, type AuthScope, type TokenRegistryConfig, } from './token-registry.js';
5
- export { auditActor, authorizeConsoleHttp, authorizeConsoleRpc, authorizeConsoleWebSocket, globalPermissions, grantsDenialReason, isAuditedRpcDenial, unknownConsoleRpcError, webSocketDenialReason, type AuthorizeConsoleRpcOptions, isUserPrincipal, isExplicitConsolePrincipal, pluginRpcWriteAllowed, principalAllows, requestMeta, rpcAuditDetail, rpcAuditTarget, type RpcAuditOptions, type ConsoleAccessProvider, type ConsoleAuditEntry, type ConsoleBootstrapPrincipal, type ConsoleHttpDecision, type ConsolePrincipal, type ConsoleRequestMeta, type ConsoleRpcDecision, type ConsoleUserPrincipal, } from './access-control.js';
6
+ export { auditActor, authorizeConsoleHttp, authorizeConsoleRpc, authorizeConsoleWebSocket, globalPermissions, grantsDenialReason, isAuditedRpcDenial, unknownConsoleRpcError, webSocketDenialReason, type AuthorizeConsoleRpcOptions, isUserPrincipal, isExplicitConsolePrincipal, isGlobalOwnerPrincipal, isLocalSocketPrincipal, LOCAL_SOCKET_CREDENTIAL_ID, LOCAL_SOCKET_USER_ID, pluginRpcWriteAllowed, principalAllows, requestMeta, rpcAuditDetail, rpcAuditTarget, type RpcAuditOptions, type ConsoleAccessProvider, type ConsoleAuditEntry, type ConsoleBootstrapPrincipal, type ConsoleHttpDecision, type ConsolePrincipal, type ConsoleRequestMeta, type ConsoleRpcDecision, type ConsoleUserPrincipal, } from './access-control.js';
6
7
  export { timingSafeEqualString } from './timing-safe-equal.js';
7
8
  export { isLoopbackAddress, isLoopbackHost, verifyProtocolAccessToken, type ProtocolAuthRequest } from './protocol-auth.js';
8
9
  export { compileTrustProxy, hasForwardingHeaders, parseTrustProxy, resolveClientAddress, TrustProxyConfigError, type TrustProxyPredicate, type TrustProxySetting, } from './trust-proxy.js';
package/lib/index.js CHANGED
@@ -1,7 +1,8 @@
1
1
  export { createHttpRouter, httpHostToken, normalizePath, REMOTE_CONSOLE_ORIGIN, } from './http-host.js';
2
2
  export { listHttpHostAddresses } from './addresses.js';
3
+ export { DEFAULT_HTTP_PORT, ENVIRONMENT_REFERENCE_PATTERN, InvalidHttpPortError, describeInvalidPortValue, parseHttpPort, } from './port.js';
3
4
  export { TokenRegistry, extractBearerToken, publicConsoleAccess, unrestrictedConsoleAccess, } from './token-registry.js';
4
- export { auditActor, authorizeConsoleHttp, authorizeConsoleRpc, authorizeConsoleWebSocket, globalPermissions, grantsDenialReason, isAuditedRpcDenial, unknownConsoleRpcError, webSocketDenialReason, isUserPrincipal, isExplicitConsolePrincipal, pluginRpcWriteAllowed, principalAllows, requestMeta, rpcAuditDetail, rpcAuditTarget, } from './access-control.js';
5
+ export { auditActor, authorizeConsoleHttp, authorizeConsoleRpc, authorizeConsoleWebSocket, globalPermissions, grantsDenialReason, isAuditedRpcDenial, unknownConsoleRpcError, webSocketDenialReason, isUserPrincipal, isExplicitConsolePrincipal, isGlobalOwnerPrincipal, isLocalSocketPrincipal, LOCAL_SOCKET_CREDENTIAL_ID, LOCAL_SOCKET_USER_ID, pluginRpcWriteAllowed, principalAllows, requestMeta, rpcAuditDetail, rpcAuditTarget, } from './access-control.js';
5
6
  export { timingSafeEqualString } from './timing-safe-equal.js';
6
7
  export { isLoopbackAddress, isLoopbackHost, verifyProtocolAccessToken } from './protocol-auth.js';
7
8
  export { compileTrustProxy, hasForwardingHeaders, parseTrustProxy, resolveClientAddress, TrustProxyConfigError, } from './trust-proxy.js';
@@ -7,13 +7,14 @@ import { createServer as createHttpServer, STATUS_CODES, } from 'node:http';
7
7
  import { createServer as createHttpsServer, } from 'node:https';
8
8
  import { WebSocketServer } from 'ws';
9
9
  import { formatCompact, getLogger } from '@zhin.js/logger';
10
+ import { DEFAULT_HTTP_PORT } from '../port.js';
10
11
  import { compileTrustProxy, resolveClientAddress } from '../trust-proxy.js';
11
12
  import { createNodeRequest, nodeRequestHeaders, writeNodeResponse, wrapNodeWebSocket } from './node-http.js';
12
13
  const logger = getLogger('http');
13
14
  /** A Node listener over {@link NodeHttpListenerOptions.handle} / `upgrade`. */
14
15
  export function createNodeHttpListener(options) {
15
16
  const host = options.host ?? '127.0.0.1';
16
- const port = options.port ?? 8086;
17
+ const port = options.port ?? DEFAULT_HTTP_PORT;
17
18
  const secure = options.tls !== undefined;
18
19
  let address;
19
20
  let closed = false;
package/lib/port.d.ts ADDED
@@ -0,0 +1,27 @@
1
+ /**
2
+ * `http.port` (and `http.listeners[].port`): an integer from 1 to 65535, written either as a number
3
+ * or as a `${VAR}` environment reference that expands to one (#1250); `0` keeps its listener meaning
4
+ * (the system picks a free port — tests and embedded use). Anything else is a configuration error:
5
+ * the Host never falls back to the default port for an invalid value.
6
+ */
7
+ /** Port the Host listens on when `http.port` is not configured (same as the scaffold writes). */
8
+ export declare const DEFAULT_HTTP_PORT = 8068;
9
+ /** A whole `${VAR}` / `${VAR:-default}` reference (the form the config schema accepts for a port). */
10
+ export declare const ENVIRONMENT_REFERENCE_PATTERN: RegExp;
11
+ export declare class InvalidHttpPortError extends Error {
12
+ readonly field: string;
13
+ readonly value: unknown;
14
+ readonly reason: string;
15
+ constructor(field: string, value: unknown, reason: string);
16
+ }
17
+ /**
18
+ * Why a stored (unexpanded) port value is invalid, in Chinese; undefined when it is valid.
19
+ * `undefined` / `null` (not configured) are valid; `${VAR}` references are checked after expansion.
20
+ */
21
+ export declare function describeInvalidPortValue(value: unknown): string | undefined;
22
+ /**
23
+ * The listening port from an expanded config value (`${VAR}` already replaced: a digit string is
24
+ * accepted). Returns undefined when not configured; throws {@link InvalidHttpPortError} otherwise.
25
+ * `raw` is the stored value, named in the message when it was an environment reference.
26
+ */
27
+ export declare function parseHttpPort(value: unknown, field: string, raw?: unknown): number | undefined;
package/lib/port.js ADDED
@@ -0,0 +1,68 @@
1
+ /**
2
+ * `http.port` (and `http.listeners[].port`): an integer from 1 to 65535, written either as a number
3
+ * or as a `${VAR}` environment reference that expands to one (#1250); `0` keeps its listener meaning
4
+ * (the system picks a free port — tests and embedded use). Anything else is a configuration error:
5
+ * the Host never falls back to the default port for an invalid value.
6
+ */
7
+ /** Port the Host listens on when `http.port` is not configured (same as the scaffold writes). */
8
+ export const DEFAULT_HTTP_PORT = 8068;
9
+ /** A whole `${VAR}` / `${VAR:-default}` reference (the form the config schema accepts for a port). */
10
+ export const ENVIRONMENT_REFERENCE_PATTERN = /^\$\{([A-Za-z_][A-Za-z0-9_]*)(?::[-=][^}]*)?\}$/u;
11
+ export class InvalidHttpPortError extends Error {
12
+ field;
13
+ value;
14
+ reason;
15
+ constructor(field, value, reason) {
16
+ super(`${field} 无效:${reason}`);
17
+ this.field = field;
18
+ this.value = value;
19
+ this.reason = reason;
20
+ this.name = 'InvalidHttpPortError';
21
+ }
22
+ }
23
+ function show(value) {
24
+ return typeof value === 'string' ? JSON.stringify(value) : String(value);
25
+ }
26
+ /**
27
+ * Why a stored (unexpanded) port value is invalid, in Chinese; undefined when it is valid.
28
+ * `undefined` / `null` (not configured) are valid; `${VAR}` references are checked after expansion.
29
+ */
30
+ export function describeInvalidPortValue(value) {
31
+ if (value === undefined || value === null)
32
+ return undefined;
33
+ if (typeof value === 'number') {
34
+ if (!Number.isInteger(value))
35
+ return `端口必须是整数,当前值 ${value}`;
36
+ if (value < 0 || value > 65_535)
37
+ return `端口必须在 1–65535 之间,当前值 ${value}`;
38
+ return undefined;
39
+ }
40
+ if (typeof value === 'string' && ENVIRONMENT_REFERENCE_PATTERN.test(value.trim()))
41
+ return undefined;
42
+ return `端口必须是 1–65535 的整数或 \${VAR} 形式的环境变量引用,当前值 ${show(value)}`;
43
+ }
44
+ /**
45
+ * The listening port from an expanded config value (`${VAR}` already replaced: a digit string is
46
+ * accepted). Returns undefined when not configured; throws {@link InvalidHttpPortError} otherwise.
47
+ * `raw` is the stored value, named in the message when it was an environment reference.
48
+ */
49
+ export function parseHttpPort(value, field, raw) {
50
+ if (value === undefined || value === null)
51
+ return undefined;
52
+ const fromReference = typeof raw === 'string' && ENVIRONMENT_REFERENCE_PATTERN.test(raw.trim()) ? `(来自 ${raw.trim()})` : '';
53
+ let port;
54
+ if (typeof value === 'number')
55
+ port = value;
56
+ else if (typeof value === 'string' && /^\d+$/u.test(value.trim()))
57
+ port = Number(value.trim());
58
+ else if (typeof value === 'string' && value.trim() === '' && fromReference) {
59
+ throw new InvalidHttpPortError(field, value, `环境变量未设置或为空${fromReference};请在 .env 中设置为 1–65535 的整数`);
60
+ }
61
+ else {
62
+ throw new InvalidHttpPortError(field, value, `端口必须是 1–65535 的整数或 \${VAR} 形式的环境变量引用,当前值 ${show(value)}${fromReference}`);
63
+ }
64
+ if (!Number.isInteger(port) || port < 0 || port > 65_535) {
65
+ throw new InvalidHttpPortError(field, value, `端口必须在 1–65535 之间,当前值 ${show(value)}${fromReference}`);
66
+ }
67
+ return port;
68
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zhin.js/host-http",
3
- "version": "1.3.0",
3
+ "version": "1.4.0",
4
4
  "description": "Minimal HTTP and WebSocket host for Plugin Runtime (no Koa)",
5
5
  "type": "module",
6
6
  "main": "./lib/index.js",
@@ -25,9 +25,9 @@
25
25
  ],
26
26
  "dependencies": {
27
27
  "ws": "^8.21.1",
28
+ "@zhin.js/console-protocol": "1.4.0",
28
29
  "@zhin.js/host-http-contract": "1.2.0",
29
- "@zhin.js/console-protocol": "1.3.0",
30
- "@zhin.js/logger": "1.1.3",
30
+ "@zhin.js/logger": "1.2.0",
31
31
  "@zhin.js/schedule": "1.1.5",
32
32
  "@zhin.js/plugin-runtime": "1.3.0"
33
33
  },
@@ -88,6 +88,25 @@ export function isUserPrincipal(principal: ConsolePrincipal | undefined): princi
88
88
  return principal?.kind === 'user' || principal?.kind === 'api-token';
89
89
  }
90
90
 
91
+ /** User id and credential id of the gateway control-socket principal (ADR 0054 §6). */
92
+ export const LOCAL_SOCKET_USER_ID = 'local';
93
+ export const LOCAL_SOCKET_CREDENTIAL_ID = 'local-socket';
94
+
95
+ /**
96
+ * The principal of a request from the project owner's CLI over `.zhin/gateway.sock` (no token).
97
+ * Sessions and API tokens never carry this credential id (theirs are token hashes / token ids).
98
+ */
99
+ export function isLocalSocketPrincipal(principal: ConsolePrincipal | undefined): boolean {
100
+ return principal?.kind === 'user' && principal.userId === LOCAL_SOCKET_USER_ID
101
+ && principal.credentialId === LOCAL_SOCKET_CREDENTIAL_ID;
102
+ }
103
+
104
+ /** A user principal with a global `owner` binding (the local socket principal is one). */
105
+ export function isGlobalOwnerPrincipal(principal: ConsolePrincipal | undefined): boolean {
106
+ return isUserPrincipal(principal)
107
+ && principal.grants.some((grant) => grant.role === 'owner' && grant.scope.kind === 'global');
108
+ }
109
+
91
110
  /**
92
111
  * Whether the request carried an explicit credential: a user session / API token, or the
93
112
  * still-valid bootstrap token (`{ kind: 'bootstrap', open: false }`). An open Host is not explicit.
@@ -3,6 +3,7 @@ import {
3
3
  CONSOLE_SEND_FAILURE_MESSAGES,
4
4
  ENDPOINT_RPC,
5
5
  classifyConsoleSendFailure,
6
+ endpointTransportLabel,
6
7
  type ConsoleConfigApplicationStatus,
7
8
  type ConsoleSendFailure,
8
9
  type ConsoleSendFailureStatus,
@@ -529,7 +530,7 @@ export async function dispatchHostResourceConsoleRpc(
529
530
  case 'endpoint.list': {
530
531
  try {
531
532
  const endpoints = ctx.listEndpoints ? await ctx.listEndpoints() : [];
532
- emit({ requestId, data: { endpoints } });
533
+ emit({ requestId, data: { endpoints: endpoints.map(withTransportLabel) } });
533
534
  } catch (error) {
534
535
  emit({
535
536
  requestId,
@@ -556,7 +557,7 @@ export async function dispatchHostResourceConsoleRpc(
556
557
  emit({ requestId, error: 'endpoint not found' });
557
558
  return true;
558
559
  }
559
- emit({ requestId, data: endpoint });
560
+ emit({ requestId, data: withTransportLabel(endpoint) });
560
561
  } catch (error) {
561
562
  emit({
562
563
  requestId,
@@ -764,3 +765,11 @@ export function configurationSaveMessage(result: { restartRequired: boolean; rev
764
765
  return result.restartRequired ? '配置已保存,需重启进程才能生效' : '配置已保存';
765
766
  }
766
767
 
768
+
769
+ /** Adds the readable `transportLabel` next to the raw `transportState` of an Endpoint row (#1250). */
770
+ function withTransportLabel<T>(endpoint: T): T {
771
+ if (!endpoint || typeof endpoint !== 'object') return endpoint;
772
+ const state = (endpoint as { transportState?: unknown }).transportState;
773
+ if (typeof state !== 'string') return endpoint;
774
+ return { ...endpoint, transportLabel: endpointTransportLabel(state) };
775
+ }
@@ -534,7 +534,11 @@ export async function dispatchRuntimeConsoleRpc(
534
534
  case CONFIG_RPC.PATCH: {
535
535
  if (ctx.authScope !== 'full') { emit({ requestId, error: 'Full scope required' }); return payloads; }
536
536
  const projectPatch = message.type === CONFIG_RPC.PATCH_PROJECT;
537
- const { instanceKey, expectedRevision, patches } = message;
537
+ const { instanceKey, expectedRevision, patches, secretStorage } = message;
538
+ if (secretStorage !== undefined && secretStorage !== 'env' && secretStorage !== 'plain') {
539
+ emit({ requestId, error: 'secretStorage 只能是 env 或 plain' });
540
+ return payloads;
541
+ }
538
542
  if (projectPatch && instanceKey !== undefined) {
539
543
  emit({ requestId, error: 'Project patches must not specify instanceKey' });
540
544
  return payloads;
@@ -566,6 +570,7 @@ export async function dispatchRuntimeConsoleRpc(
566
570
  ...(instanceKey === undefined ? {} : { instanceKey }),
567
571
  expectedRevision,
568
572
  patches,
573
+ ...(secretStorage === undefined ? {} : { secretStorage }),
569
574
  });
570
575
  } catch (error) {
571
576
  emit({
@@ -627,9 +632,11 @@ export async function dispatchRuntimeConsoleRpc(
627
632
  },
628
633
  });
629
634
  } catch (error) {
635
+ const code = error instanceof Error && 'code' in error && error.code === 'env-write-required' ? { code: 'env-write-required' } : {};
630
636
  emit({
631
637
  requestId,
632
638
  error: `Failed to set config: ${error instanceof Error ? error.message : String(error)}`,
639
+ ...code,
633
640
  });
634
641
  }
635
642
  return payloads;
@@ -11,6 +11,7 @@ import { isAbsolute, resolve } from 'node:path';
11
11
  import { httpHostToken, type HttpHost } from './http-host.js';
12
12
  import type { HttpHostOptions } from './host-options.js';
13
13
  import { parseTrustProxy } from './trust-proxy.js';
14
+ import { parseHttpPort } from './port.js';
14
15
 
15
16
  export interface ResolvedHttpConfig extends HttpHostOptions {
16
17
  /** Additional listeners that mirror the primary Host route surface. */
@@ -30,17 +31,19 @@ export async function resolveHttpConfig(
30
31
  http,
31
32
  (key) => envOverlay?.[key] ?? process.env[key],
32
33
  ) as Record<string, unknown>;
34
+ const stored = http as Record<string, unknown>;
33
35
  if (value.tokens !== undefined && value.tokens !== null) throw new Error(HTTP_TOKENS_REMOVED_MESSAGE);
34
36
  const primary: HttpHostOptions = Object.freeze({
35
37
  host: typeof value.host === 'string' ? value.host : undefined,
36
- port: typeof value.port === 'number' ? value.port : undefined,
38
+ // An invalid port is an error, never a silent fallback to the default (#1250).
39
+ port: parseHttpPort(value.port, 'http.port', stored.port),
37
40
  token: typeof value.token === 'string' ? value.token : undefined,
38
41
  corsOrigins: injectDesktopWebviewOrigins(parseStringList(value.corsOrigins), envOverlay),
39
42
  apiBase: typeof value.base === 'string' ? value.base : undefined,
40
43
  tls: await parseTlsConfig(value.tls, projectRoot),
41
44
  trustProxy: parseTrustProxy(value.trustProxy),
42
45
  });
43
- const configuredListeners = await parseAdditionalListeners(value.listeners, primary, projectRoot);
46
+ const configuredListeners = await parseAdditionalListeners(value.listeners, primary, projectRoot, stored.listeners);
44
47
  const listeners = await injectDesktopDeviceListener(
45
48
  configuredListeners,
46
49
  primary,
@@ -110,6 +113,7 @@ async function parseAdditionalListeners(
110
113
  value: unknown,
111
114
  primary: HttpHostOptions,
112
115
  projectRoot: string,
116
+ stored?: unknown,
113
117
  ): Promise<readonly HttpHostOptions[]> {
114
118
  if (value === undefined) return Object.freeze([]);
115
119
  if (!Array.isArray(value)) throw new Error('http.listeners must be an array');
@@ -123,13 +127,15 @@ async function parseAdditionalListeners(
123
127
  if (typeof listener.host !== 'string' || !listener.host) {
124
128
  throw new Error(`http.listeners[${index}].host is required`);
125
129
  }
126
- if (typeof listener.port !== 'number') {
130
+ const storedPort = Array.isArray(stored) ? (stored[index] as Record<string, unknown> | undefined)?.port : undefined;
131
+ const port = parseHttpPort(listener.port, `http.listeners[${index}].port`, storedPort);
132
+ if (port === undefined) {
127
133
  throw new Error(`http.listeners[${index}].port is required`);
128
134
  }
129
135
  listeners.push(Object.freeze({
130
136
  ...primary,
131
137
  host: listener.host,
132
- port: listener.port,
138
+ port,
133
139
  tls: await parseTlsConfig(listener.tls, projectRoot),
134
140
  }));
135
141
  }
package/src/index.ts CHANGED
@@ -20,6 +20,13 @@ export {
20
20
  } from './http-host.js';
21
21
  export type { HttpHostAddress, HttpHostOptions, HttpHostTlsOptions } from './host-options.js';
22
22
  export { listHttpHostAddresses } from './addresses.js';
23
+ export {
24
+ DEFAULT_HTTP_PORT,
25
+ ENVIRONMENT_REFERENCE_PATTERN,
26
+ InvalidHttpPortError,
27
+ describeInvalidPortValue,
28
+ parseHttpPort,
29
+ } from './port.js';
23
30
  export {
24
31
  TokenRegistry,
25
32
  extractBearerToken,
@@ -43,6 +50,10 @@ export {
43
50
  type AuthorizeConsoleRpcOptions,
44
51
  isUserPrincipal,
45
52
  isExplicitConsolePrincipal,
53
+ isGlobalOwnerPrincipal,
54
+ isLocalSocketPrincipal,
55
+ LOCAL_SOCKET_CREDENTIAL_ID,
56
+ LOCAL_SOCKET_USER_ID,
46
57
  pluginRpcWriteAllowed,
47
58
  principalAllows,
48
59
  requestMeta,
@@ -21,6 +21,7 @@ import { WebSocketServer } from 'ws';
21
21
  import { formatCompact, getLogger } from '@zhin.js/logger';
22
22
  import type { HttpRequestInfo, WsUpgradeDecision, WsUpgradeRequest } from '../http-host.js';
23
23
  import type { HttpHostAddress, HttpHostTlsOptions } from '../host-options.js';
24
+ import { DEFAULT_HTTP_PORT } from '../port.js';
24
25
  import { compileTrustProxy, resolveClientAddress, type TrustProxySetting } from '../trust-proxy.js';
25
26
  import { createNodeRequest, nodeRequestHeaders, writeNodeResponse, wrapNodeWebSocket } from './node-http.js';
26
27
 
@@ -52,7 +53,7 @@ export interface NodeHttpListener {
52
53
  /** A Node listener over {@link NodeHttpListenerOptions.handle} / `upgrade`. */
53
54
  export function createNodeHttpListener(options: NodeHttpListenerOptions): NodeHttpListener {
54
55
  const host = options.host ?? '127.0.0.1';
55
- const port = options.port ?? 8086;
56
+ const port = options.port ?? DEFAULT_HTTP_PORT;
56
57
  const secure = options.tls !== undefined;
57
58
  let address: HttpHostAddress | undefined;
58
59
  let closed = false;
package/src/port.ts ADDED
@@ -0,0 +1,60 @@
1
+ /**
2
+ * `http.port` (and `http.listeners[].port`): an integer from 1 to 65535, written either as a number
3
+ * or as a `${VAR}` environment reference that expands to one (#1250); `0` keeps its listener meaning
4
+ * (the system picks a free port — tests and embedded use). Anything else is a configuration error:
5
+ * the Host never falls back to the default port for an invalid value.
6
+ */
7
+
8
+ /** Port the Host listens on when `http.port` is not configured (same as the scaffold writes). */
9
+ export const DEFAULT_HTTP_PORT = 8068;
10
+
11
+ /** A whole `${VAR}` / `${VAR:-default}` reference (the form the config schema accepts for a port). */
12
+ export const ENVIRONMENT_REFERENCE_PATTERN = /^\$\{([A-Za-z_][A-Za-z0-9_]*)(?::[-=][^}]*)?\}$/u;
13
+
14
+ export class InvalidHttpPortError extends Error {
15
+ constructor(readonly field: string, readonly value: unknown, readonly reason: string) {
16
+ super(`${field} 无效:${reason}`);
17
+ this.name = 'InvalidHttpPortError';
18
+ }
19
+ }
20
+
21
+ function show(value: unknown): string {
22
+ return typeof value === 'string' ? JSON.stringify(value) : String(value);
23
+ }
24
+
25
+ /**
26
+ * Why a stored (unexpanded) port value is invalid, in Chinese; undefined when it is valid.
27
+ * `undefined` / `null` (not configured) are valid; `${VAR}` references are checked after expansion.
28
+ */
29
+ export function describeInvalidPortValue(value: unknown): string | undefined {
30
+ if (value === undefined || value === null) return undefined;
31
+ if (typeof value === 'number') {
32
+ if (!Number.isInteger(value)) return `端口必须是整数,当前值 ${value}`;
33
+ if (value < 0 || value > 65_535) return `端口必须在 1–65535 之间,当前值 ${value}`;
34
+ return undefined;
35
+ }
36
+ if (typeof value === 'string' && ENVIRONMENT_REFERENCE_PATTERN.test(value.trim())) return undefined;
37
+ return `端口必须是 1–65535 的整数或 \${VAR} 形式的环境变量引用,当前值 ${show(value)}`;
38
+ }
39
+
40
+ /**
41
+ * The listening port from an expanded config value (`${VAR}` already replaced: a digit string is
42
+ * accepted). Returns undefined when not configured; throws {@link InvalidHttpPortError} otherwise.
43
+ * `raw` is the stored value, named in the message when it was an environment reference.
44
+ */
45
+ export function parseHttpPort(value: unknown, field: string, raw?: unknown): number | undefined {
46
+ if (value === undefined || value === null) return undefined;
47
+ const fromReference = typeof raw === 'string' && ENVIRONMENT_REFERENCE_PATTERN.test(raw.trim()) ? `(来自 ${raw.trim()})` : '';
48
+ let port: number;
49
+ if (typeof value === 'number') port = value;
50
+ else if (typeof value === 'string' && /^\d+$/u.test(value.trim())) port = Number(value.trim());
51
+ else if (typeof value === 'string' && value.trim() === '' && fromReference) {
52
+ throw new InvalidHttpPortError(field, value, `环境变量未设置或为空${fromReference};请在 .env 中设置为 1–65535 的整数`);
53
+ } else {
54
+ throw new InvalidHttpPortError(field, value, `端口必须是 1–65535 的整数或 \${VAR} 形式的环境变量引用,当前值 ${show(value)}${fromReference}`);
55
+ }
56
+ if (!Number.isInteger(port) || port < 0 || port > 65_535) {
57
+ throw new InvalidHttpPortError(field, value, `端口必须在 1–65535 之间,当前值 ${show(value)}${fromReference}`);
58
+ }
59
+ return port;
60
+ }