@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.
- package/lib/access-control.d.ts +10 -0
- package/lib/access-control.js +16 -0
- package/lib/console-rpc-handlers.js +12 -3
- package/lib/console-rpc.js +8 -1
- package/lib/host-installer.js +10 -5
- package/lib/index.d.ts +2 -1
- package/lib/index.js +2 -1
- package/lib/node/listener.js +2 -1
- package/lib/port.d.ts +27 -0
- package/lib/port.js +68 -0
- package/package.json +3 -3
- package/src/access-control.ts +19 -0
- package/src/console-rpc-handlers.ts +11 -2
- package/src/console-rpc.ts +8 -1
- package/src/host-installer.ts +10 -4
- package/src/index.ts +11 -0
- package/src/node/listener.ts +2 -1
- package/src/port.ts +60 -0
package/lib/access-control.d.ts
CHANGED
|
@@ -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.
|
package/lib/access-control.js
CHANGED
|
@@ -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
|
+
}
|
package/lib/console-rpc.js
CHANGED
|
@@ -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;
|
package/lib/host-installer.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
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';
|
package/lib/node/listener.js
CHANGED
|
@@ -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 ??
|
|
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
|
+
"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/
|
|
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
|
},
|
package/src/access-control.ts
CHANGED
|
@@ -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
|
+
}
|
package/src/console-rpc.ts
CHANGED
|
@@ -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;
|
package/src/host-installer.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
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,
|
package/src/node/listener.ts
CHANGED
|
@@ -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 ??
|
|
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
|
+
}
|