@zhin.js/host-http 1.2.1 → 1.3.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.
Files changed (44) hide show
  1. package/lib/access-control.d.ts +50 -5
  2. package/lib/access-control.js +167 -13
  3. package/lib/console-rest-pages-helpers.js +24 -2
  4. package/lib/console-rest-pages.d.ts +4 -1
  5. package/lib/console-rest-pages.js +49 -23
  6. package/lib/console-rpc-extended/contracts.d.ts +92 -15
  7. package/lib/console-rpc-extended/dispatcher.js +10 -3
  8. package/lib/console-rpc-extended/endpoint-rpc.js +21 -4
  9. package/lib/console-rpc-extended/index.d.ts +2 -1
  10. package/lib/console-rpc-extended/index.js +1 -0
  11. package/lib/console-rpc-extended/login-rpc.d.ts +25 -2
  12. package/lib/console-rpc-extended/login-rpc.js +276 -16
  13. package/lib/console-rpc-extended/schedule-rpc.d.ts +1 -1
  14. package/lib/console-rpc-extended/schedule-rpc.js +78 -12
  15. package/lib/console-rpc-handlers.d.ts +11 -1
  16. package/lib/console-rpc-handlers.js +221 -12
  17. package/lib/console-rpc-types.d.ts +2 -0
  18. package/lib/console-rpc-types.js +43 -0
  19. package/lib/console-rpc.d.ts +29 -1
  20. package/lib/console-rpc.js +33 -7
  21. package/lib/http-host.js +5 -4
  22. package/lib/index.d.ts +5 -3
  23. package/lib/index.js +4 -2
  24. package/lib/project-files.d.ts +7 -0
  25. package/lib/project-files.js +88 -11
  26. package/lib/unknown-rpc.d.ts +12 -0
  27. package/lib/unknown-rpc.js +33 -0
  28. package/package.json +3 -3
  29. package/src/access-control.ts +209 -14
  30. package/src/console-rest-pages-helpers.ts +27 -3
  31. package/src/console-rest-pages.ts +52 -15
  32. package/src/console-rpc-extended/contracts.ts +83 -16
  33. package/src/console-rpc-extended/dispatcher.ts +10 -3
  34. package/src/console-rpc-extended/endpoint-rpc.ts +28 -4
  35. package/src/console-rpc-extended/index.ts +4 -0
  36. package/src/console-rpc-extended/login-rpc.ts +250 -17
  37. package/src/console-rpc-extended/schedule-rpc.ts +67 -3
  38. package/src/console-rpc-handlers.ts +198 -11
  39. package/src/console-rpc-types.ts +45 -0
  40. package/src/console-rpc.ts +63 -7
  41. package/src/http-host.ts +6 -2
  42. package/src/index.ts +15 -0
  43. package/src/project-files.ts +95 -11
  44. package/src/unknown-rpc.ts +44 -0
package/lib/http-host.js CHANGED
@@ -12,7 +12,7 @@ import { createToken, generationAdmissionBinder, } from '@zhin.js/plugin-runtime
12
12
  import { HttpBodyError, jsonResponse } from '@zhin.js/host-http-contract';
13
13
  import { TokenRegistry, extractBearerToken, } from './token-registry.js';
14
14
  import { buildOpenApiDocument, } from './openapi.js';
15
- import { auditActor, authorizeConsoleHttp, authorizeConsoleWebSocket, requestMeta, } from './access-control.js';
15
+ import { auditActor, authorizeConsoleHttp, authorizeConsoleWebSocket, requestMeta, webSocketDenialReason, } from './access-control.js';
16
16
  const logger = getLogger('http');
17
17
  export const REMOTE_CONSOLE_ORIGIN = 'https://console.zhin.dev';
18
18
  const DEFAULT_CORS_ORIGINS = Object.freeze([REMOTE_CONSOLE_ORIGIN]);
@@ -85,12 +85,12 @@ export function createHttpRouter(options = {}) {
85
85
  if (!principal)
86
86
  return undefined;
87
87
  if (!authorizeConsoleWebSocket(principal, pathname)) {
88
- audit(principal, remoteAddress, `ws:${pathname}`, 'denied');
88
+ audit(principal, remoteAddress, `ws:${pathname}`, 'denied', webSocketDenialReason(principal, pathname));
89
89
  return undefined;
90
90
  }
91
91
  return principal;
92
92
  }
93
- function audit(principal, remoteAddress, action, outcome) {
93
+ function audit(principal, remoteAddress, action, outcome, reason) {
94
94
  const sink = tokenRegistry.accessProvider?.audit;
95
95
  if (!sink)
96
96
  return;
@@ -99,6 +99,7 @@ export function createHttpRouter(options = {}) {
99
99
  actor: auditActor(principal),
100
100
  action,
101
101
  outcome,
102
+ ...(reason ? { detail: { reason } } : {}),
102
103
  ...(remoteAddress ? { ip: remoteAddress } : {}),
103
104
  });
104
105
  }
@@ -282,7 +283,7 @@ export function createHttpRouter(options = {}) {
282
283
  const decision = authorizeConsoleHttp(principal, method, relative);
283
284
  if (!decision.allowed) {
284
285
  if (principal)
285
- audit(principal, remoteAddress, `http:${method} ${relative}`, 'denied');
286
+ audit(principal, remoteAddress, `http:${method} ${relative}`, 'denied', decision.reason);
286
287
  return { ok: false, status: decision.status, code: decision.code, error: decision.error };
287
288
  }
288
289
  return { ok: true, ...(principal ? { principal } : {}) };
package/lib/index.d.ts CHANGED
@@ -2,7 +2,7 @@ export { createHttpRouter, httpHostToken, normalizePath, REMOTE_CONSOLE_ORIGIN,
2
2
  export type { HttpHostAddress, HttpHostOptions, HttpHostTlsOptions } from './host-options.js';
3
3
  export { listHttpHostAddresses } from './addresses.js';
4
4
  export { TokenRegistry, extractBearerToken, publicConsoleAccess, unrestrictedConsoleAccess, type AuthenticatedTokenPrincipal, type ConsoleAccess, type AuthScope, type TokenRegistryConfig, } from './token-registry.js';
5
- export { auditActor, authorizeConsoleHttp, authorizeConsoleRpc, authorizeConsoleWebSocket, globalPermissions, isUserPrincipal, isExplicitConsolePrincipal, pluginRpcWriteAllowed, principalAllows, requestMeta, rpcAuditDetail, rpcAuditTarget, type ConsoleAccessProvider, type ConsoleAuditEntry, type ConsoleBootstrapPrincipal, type ConsoleHttpDecision, type ConsolePrincipal, type ConsoleRequestMeta, type ConsoleRpcDecision, type ConsoleUserPrincipal, } from './access-control.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
6
  export { timingSafeEqualString } from './timing-safe-equal.js';
7
7
  export { isLoopbackAddress, isLoopbackHost, verifyProtocolAccessToken, type ProtocolAuthRequest } from './protocol-auth.js';
8
8
  export { compileTrustProxy, hasForwardingHeaders, parseTrustProxy, resolveClientAddress, TrustProxyConfigError, type TrustProxyPredicate, type TrustProxySetting, } from './trust-proxy.js';
@@ -10,9 +10,11 @@ export { createConsoleEventHub, consoleEventHubToken, type ConsoleEventHub, } fr
10
10
  export { buildOpenApiDocument, patternToOpenApiPath, routeRequiresBearerAuth, type BuildOpenApiOptions, type ListedRoute, type OpenApiParameter, type RouteMeta, } from './openapi.js';
11
11
  export { HttpBodyError, readBodyBytes, readJsonBody, readTextBody, type HttpBodyReadOptions, } from './json-body.js';
12
12
  export { createResponseStream, emptyResponse, jsonResponse, SSE_HEADERS, type HttpResponseStream, type HttpResponseStreamOptions, } from '@zhin.js/host-http-contract';
13
- export { dispatchRuntimeConsoleRpc, pickRpcReply, type RuntimeConsolePage, type RuntimeConsoleRpcContext, type RuntimeConsoleRpcMessage, type RuntimeConsoleRpcReply, type RuntimeEndpointSendInput, type PluginInstallPlan, type PluginUninstallPlan, type PluginUpdatePlan, type PluginManagementPort, type PluginManagementState, type PluginVerificationResult, type PluginVerificationPlan, type PluginConfigValidation, } from './console-rpc.js';
13
+ export { isRuntimeConsoleRpcType, RUNTIME_CONSOLE_RPC_TYPES } from './console-rpc-types.js';
14
+ export { createUnknownRpcLog, unknownRpcLog, type UnknownRpcLog, type UnknownRpcLogEntry } from './unknown-rpc.js';
15
+ export { dispatchRuntimeConsoleRpc, pickRpcReply, type RuntimeConsolePage, type RuntimeConsoleRpcContext, type RuntimeConsoleRpcMessage, type RuntimeConsoleRpcReply, type RuntimeEndpointSendInput, type PluginInstallPlan, type PluginUninstallPlan, type PluginUpdatePlan, type PluginManagementPort, type PluginManagementState, type PluginInspectOptions, type PluginDependencyCheck, type PluginVerificationResult, type PluginVerificationPlan, type PluginConfigValidation, } from './console-rpc.js';
14
16
  export { dispatchPluginConsoleRpc, PLUGIN_SWITCH_REQUIRES_GATEWAY } from './console-plugin-rpc.js';
15
17
  export { buildProjectFileTree, FILE_MANAGER_ALLOWED, FILE_MANAGER_BLOCKED, isProjectPathAllowed, listEnvFiles, readProjectFile, saveProjectFile, type FileTreeNode, } from './project-files.js';
16
18
  export { registerConsoleRestPages, type ConsoleAgentIntrospection, type ConsoleAgentRuntime, type ConsoleAgentSessionTree, type ConsoleRestCtx, type ConsoleRestPagesOptions, } from './console-rest-pages.js';
17
- export { dispatchExtendedConsoleRpc, type ConsoleRpcExtendedCtx, type ConsoleScheduleEngine, type ConsoleWorkroomProfileControlPort, type ConsoleWorkroomKnowledgeControlPort, type ExtendedRpcResult, } from './console-rpc-extended/index.js';
19
+ export { dispatchExtendedConsoleRpc, type ConsoleRpcExtendedCtx, type ConsoleScheduleEngine, type ConsoleScheduleTarget, type ConsoleWorkroomProfileControlPort, type ConsoleWorkroomKnowledgeControlPort, type ExtendedRpcResult, type ConsoleLoginAssistPort, type ConsoleEndpointLoginAccess, loginAuditDetail, LOGIN_SUBMIT_WAIT_MS, } from './console-rpc-extended/index.js';
18
20
  export * from './host-installer.js';
package/lib/index.js CHANGED
@@ -1,7 +1,7 @@
1
1
  export { createHttpRouter, httpHostToken, normalizePath, REMOTE_CONSOLE_ORIGIN, } from './http-host.js';
2
2
  export { listHttpHostAddresses } from './addresses.js';
3
3
  export { TokenRegistry, extractBearerToken, publicConsoleAccess, unrestrictedConsoleAccess, } from './token-registry.js';
4
- export { auditActor, authorizeConsoleHttp, authorizeConsoleRpc, authorizeConsoleWebSocket, globalPermissions, isUserPrincipal, isExplicitConsolePrincipal, pluginRpcWriteAllowed, principalAllows, requestMeta, rpcAuditDetail, rpcAuditTarget, } from './access-control.js';
4
+ export { auditActor, authorizeConsoleHttp, authorizeConsoleRpc, authorizeConsoleWebSocket, globalPermissions, grantsDenialReason, isAuditedRpcDenial, unknownConsoleRpcError, webSocketDenialReason, isUserPrincipal, isExplicitConsolePrincipal, pluginRpcWriteAllowed, principalAllows, requestMeta, rpcAuditDetail, rpcAuditTarget, } from './access-control.js';
5
5
  export { timingSafeEqualString } from './timing-safe-equal.js';
6
6
  export { isLoopbackAddress, isLoopbackHost, verifyProtocolAccessToken } from './protocol-auth.js';
7
7
  export { compileTrustProxy, hasForwardingHeaders, parseTrustProxy, resolveClientAddress, TrustProxyConfigError, } from './trust-proxy.js';
@@ -9,9 +9,11 @@ export { createConsoleEventHub, consoleEventHubToken, } from './console-events.j
9
9
  export { buildOpenApiDocument, patternToOpenApiPath, routeRequiresBearerAuth, } from './openapi.js';
10
10
  export { HttpBodyError, readBodyBytes, readJsonBody, readTextBody, } from './json-body.js';
11
11
  export { createResponseStream, emptyResponse, jsonResponse, SSE_HEADERS, } from '@zhin.js/host-http-contract';
12
+ export { isRuntimeConsoleRpcType, RUNTIME_CONSOLE_RPC_TYPES } from './console-rpc-types.js';
13
+ export { createUnknownRpcLog, unknownRpcLog } from './unknown-rpc.js';
12
14
  export { dispatchRuntimeConsoleRpc, pickRpcReply, } from './console-rpc.js';
13
15
  export { dispatchPluginConsoleRpc, PLUGIN_SWITCH_REQUIRES_GATEWAY } from './console-plugin-rpc.js';
14
16
  export { buildProjectFileTree, FILE_MANAGER_ALLOWED, FILE_MANAGER_BLOCKED, isProjectPathAllowed, listEnvFiles, readProjectFile, saveProjectFile, } from './project-files.js';
15
17
  export { registerConsoleRestPages, } from './console-rest-pages.js';
16
- export { dispatchExtendedConsoleRpc, } from './console-rpc-extended/index.js';
18
+ export { dispatchExtendedConsoleRpc, loginAuditDetail, LOGIN_SUBMIT_WAIT_MS, } from './console-rpc-extended/index.js';
17
19
  export * from './host-installer.js';
@@ -7,6 +7,13 @@ export type FileTreeNode = {
7
7
  readonly type: 'file' | 'directory';
8
8
  readonly children?: readonly FileTreeNode[];
9
9
  };
10
+ /** Error text for project-file reads and writes that name a dotenv file (issue #1250). */
11
+ export declare const DOTENV_ACCESS_DENIED_MESSAGE: string;
12
+ /**
13
+ * Whether a project-relative path names a dotenv file or goes through one. Dotenv files hold
14
+ * secrets: the project file manager never lists, reads or writes them (`/ops/env` does).
15
+ */
16
+ export declare function isDotenvProjectPath(relativePath: string): boolean;
10
17
  export declare function isProjectPathAllowed(relativePath: string): boolean;
11
18
  export declare function buildProjectFileTree(projectRoot: string): Promise<FileTreeNode[]>;
12
19
  export declare function readProjectFile(projectRoot: string, relativePath: string): Promise<{
@@ -26,7 +26,13 @@ export const FILE_MANAGER_ALLOWED = Object.freeze([
26
26
  'plugin.mjs',
27
27
  'plugin.cjs',
28
28
  'schema.json',
29
+ // Agent workspace bootstrap files (`@zhin.js/agent` bootstrap.ts, timer heartbeat) and the
30
+ // conventional knowledge base directory (`ai.knowledge.baseDir`).
29
31
  'AGENTS.md',
32
+ 'SOUL.md',
33
+ 'TOOLS.md',
34
+ 'HEARTBEAT.md',
35
+ 'knowledge',
30
36
  'tsconfig.json',
31
37
  'zhin.config.yml',
32
38
  'zhin.config.yaml',
@@ -34,25 +40,82 @@ export const FILE_MANAGER_ALLOWED = Object.freeze([
34
40
  'config.yml',
35
41
  'config.yaml',
36
42
  'config.json',
37
- '.env',
38
- '.env.development',
39
- '.env.production',
40
43
  'README.md',
41
44
  ]);
42
45
  export const FILE_MANAGER_BLOCKED = Object.freeze(new Set([
43
46
  'node_modules',
44
47
  '.git',
45
- '.env.local',
46
48
  'data',
47
49
  'lib',
48
50
  'dist',
49
51
  'coverage',
50
52
  ]));
53
+ /** Dotenv files the environment page (`/ops/env`, `env:*` RPCs) manages. */
54
+ const ENV_FILE_NAMES = Object.freeze(['.env', '.env.development', '.env.production']);
55
+ /** Error text for project-file reads and writes that name a dotenv file (issue #1250). */
56
+ export const DOTENV_ACCESS_DENIED_MESSAGE = 'Access denied: .env files are not available in the project file manager; '
57
+ + 'manage environment variables on the Environment variables page (/ops/env)';
58
+ // Control characters (NUL included) never name a project file and can truncate a path in native code.
59
+ // eslint-disable-next-line no-control-regex
60
+ const CONTROL_CHARACTERS = /[\u0000-\u001f\u007f]/u;
61
+ /**
62
+ * Strips trailing dots and spaces (what Windows drops from a file name). A linear scan: the
63
+ * equivalent `/[. ]+$/` backtracks quadratically on long runs of spaces (CodeQL js/polynomial-redos).
64
+ */
65
+ function trimTrailingDotsAndSpaces(value) {
66
+ let end = value.length;
67
+ while (end > 0 && (value[end - 1] === '.' || value[end - 1] === ' '))
68
+ end -= 1;
69
+ return value.slice(0, end);
70
+ }
71
+ /**
72
+ * Whether one path segment names a dotenv file (`.env`, `.env.*`), in every spelling a
73
+ * filesystem may resolve to it: any letter case, trailing dots / spaces (stripped by Windows),
74
+ * an NTFS stream suffix (`.env::$DATA`) and percent-encoding.
75
+ */
76
+ function isDotenvSegment(segment) {
77
+ const variants = [segment];
78
+ try {
79
+ const decoded = decodeURIComponent(segment);
80
+ if (decoded !== segment)
81
+ variants.push(decoded);
82
+ }
83
+ catch { /* not percent-encoded */ }
84
+ return variants.some((variant) => {
85
+ const name = trimTrailingDotsAndSpaces(variant.split(':')[0]).toLowerCase();
86
+ return name === '.env' || name.startsWith('.env.');
87
+ });
88
+ }
89
+ /**
90
+ * Whether a project-relative path names a dotenv file or goes through one. Dotenv files hold
91
+ * secrets: the project file manager never lists, reads or writes them (`/ops/env` does).
92
+ */
93
+ export function isDotenvProjectPath(relativePath) {
94
+ return relativePath.replace(/\\/gu, '/').split('/').some(isDotenvSegment);
95
+ }
96
+ /**
97
+ * Whether one path segment names a dot file or dot directory (`.envrc`, `.npmrc`, `.ssh`, …), also
98
+ * percent-encoded. Such files commonly hold credentials; no entry of {@link FILE_MANAGER_ALLOWED}
99
+ * is one, so the project file manager never reads or writes them (the tree already skips them).
100
+ */
101
+ function isDotSegment(segment) {
102
+ const variants = [segment];
103
+ try {
104
+ const decoded = decodeURIComponent(segment);
105
+ if (decoded !== segment)
106
+ variants.push(decoded);
107
+ }
108
+ catch { /* not percent-encoded */ }
109
+ return variants.some(variant => variant.startsWith('.') && variant !== '.');
110
+ }
51
111
  export function isProjectPathAllowed(relativePath) {
52
112
  if (relativePath.includes('..') || path.isAbsolute(relativePath))
53
113
  return false;
114
+ if (CONTROL_CHARACTERS.test(relativePath) || isDotenvProjectPath(relativePath))
115
+ return false;
54
116
  const normalized = relativePath.replace(/\\/gu, '/').replace(/^\.\//u, '');
55
- if (normalized.split('/').some((segment) => FILE_MANAGER_BLOCKED.has(segment)))
117
+ const segments = normalized.split('/');
118
+ if (segments.some((segment) => FILE_MANAGER_BLOCKED.has(segment) || isDotSegment(segment)))
56
119
  return false;
57
120
  return FILE_MANAGER_ALLOWED.some((allowed) => normalized === allowed || normalized.startsWith(`${allowed}/`));
58
121
  }
@@ -84,6 +147,7 @@ export async function buildProjectFileTree(projectRoot) {
84
147
  return tree.sort(compareTreeNodes);
85
148
  }
86
149
  export async function readProjectFile(projectRoot, relativePath) {
150
+ assertNotDotenv(relativePath);
87
151
  const absPath = await resolveAllowedPath(projectRoot, relativePath);
88
152
  if (!absPath)
89
153
  throw new Error(`Access denied: ${relativePath}`);
@@ -99,6 +163,7 @@ export async function readProjectFile(projectRoot, relativePath) {
99
163
  return { content, size: info.size };
100
164
  }
101
165
  export async function saveProjectFile(projectRoot, relativePath, content) {
166
+ assertNotDotenv(relativePath);
102
167
  const absPath = await resolveAllowedPath(projectRoot, relativePath);
103
168
  if (!absPath)
104
169
  throw new Error(`Access denied: ${relativePath}`);
@@ -109,16 +174,28 @@ export async function saveProjectFile(projectRoot, relativePath, content) {
109
174
  await writeFile(checkedPath, content, 'utf8');
110
175
  }
111
176
  export async function listEnvFiles(projectRoot) {
112
- const names = ['.env', '.env.development', '.env.production'];
113
- const result = await Promise.all(names.map(async (name) => {
114
- const resolved = await resolveAllowedPath(projectRoot, name);
177
+ const result = await Promise.all(ENV_FILE_NAMES.map(async (name) => {
178
+ const resolved = await resolveContainedPath(projectRoot, name, isEnvInventoryTarget);
115
179
  return { name, exists: resolved !== null && await pathExists(resolved) };
116
180
  }));
117
181
  return Object.freeze(result);
118
182
  }
183
+ function assertNotDotenv(relativePath) {
184
+ if (isDotenvProjectPath(relativePath))
185
+ throw new Error(DOTENV_ACCESS_DENIED_MESSAGE);
186
+ }
187
+ /** The env inventory only counts a dotenv file (or link) that stays inside the project and off blocked paths. */
188
+ function isEnvInventoryTarget(relativePath) {
189
+ if (!relativePath || relativePath.startsWith('..') || path.isAbsolute(relativePath))
190
+ return false;
191
+ return !relativePath.replace(/\\/gu, '/').split('/').some((segment) => FILE_MANAGER_BLOCKED.has(segment));
192
+ }
193
+ function resolveAllowedPath(projectRoot, relativePath) {
194
+ return resolveContainedPath(projectRoot, relativePath, isProjectPathAllowed);
195
+ }
119
196
  /** Resolve existing links, or the nearest existing parent for a new file. */
120
- async function resolveAllowedPath(projectRoot, relativePath) {
121
- if (!isProjectPathAllowed(relativePath))
197
+ async function resolveContainedPath(projectRoot, relativePath, allowed) {
198
+ if (!allowed(relativePath))
122
199
  return null;
123
200
  try {
124
201
  const root = await realpath(projectRoot);
@@ -130,7 +207,7 @@ async function resolveAllowedPath(projectRoot, relativePath) {
130
207
  await lstat(candidate);
131
208
  const resolved = path.resolve(await realpath(candidate), ...missing);
132
209
  const relative = path.relative(root, resolved);
133
- return isProjectPathAllowed(relative) ? resolved : null;
210
+ return allowed(relative) ? resolved : null;
134
211
  }
135
212
  catch (error) {
136
213
  if (error.code !== 'ENOENT')
@@ -0,0 +1,12 @@
1
+ export interface UnknownRpcLog {
2
+ /** Records one unknown RPC; returns true when it was logged (not suppressed). */
3
+ note(type: unknown, now?: number): boolean;
4
+ }
5
+ export type UnknownRpcLogEntry = Readonly<{
6
+ op: 'console_rpc_unknown';
7
+ type: string;
8
+ repeated?: number;
9
+ }>;
10
+ export declare function createUnknownRpcLog(log?: (entry: UnknownRpcLogEntry) => void): UnknownRpcLog;
11
+ /** Process-wide log shared by the Host's RPC routes. */
12
+ export declare const unknownRpcLog: UnknownRpcLog;
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Unknown Console RPC (zhinjs/zhin#1250): not a security refusal, so it is never audited. A
3
+ * Console that polls an RPC this Host lacks would otherwise fill the audit log; instead each type
4
+ * is logged at debug level at most once per window, with how often it was seen meanwhile.
5
+ */
6
+ import { formatCompact, getLogger } from '@zhin.js/logger';
7
+ const logger = getLogger('console-rpc');
8
+ const WINDOW_MS = 10 * 60_000;
9
+ const MAX_TYPES = 256;
10
+ export function createUnknownRpcLog(log = entry => logger.debug(formatCompact({ ...entry }))) {
11
+ const seen = new Map();
12
+ return Object.freeze({
13
+ note(type, now = Date.now()) {
14
+ const name = String(type ?? '').slice(0, 64) || '<empty>';
15
+ const entry = seen.get(name);
16
+ if (entry && now - entry.at < WINDOW_MS) {
17
+ entry.suppressed += 1;
18
+ return false;
19
+ }
20
+ if (!entry && seen.size >= MAX_TYPES) {
21
+ const oldest = seen.keys().next().value;
22
+ if (oldest !== undefined)
23
+ seen.delete(oldest);
24
+ }
25
+ seen.delete(name);
26
+ seen.set(name, { at: now, suppressed: 0 });
27
+ log({ op: 'console_rpc_unknown', type: name, ...(entry?.suppressed ? { repeated: entry.suppressed } : {}) });
28
+ return true;
29
+ },
30
+ });
31
+ }
32
+ /** Process-wide log shared by the Host's RPC routes. */
33
+ export const unknownRpcLog = createUnknownRpcLog();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zhin.js/host-http",
3
- "version": "1.2.1",
3
+ "version": "1.3.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,11 +25,11 @@
25
25
  ],
26
26
  "dependencies": {
27
27
  "ws": "^8.21.1",
28
- "@zhin.js/console-protocol": "1.2.1",
29
28
  "@zhin.js/host-http-contract": "1.2.0",
29
+ "@zhin.js/console-protocol": "1.3.0",
30
30
  "@zhin.js/logger": "1.1.3",
31
31
  "@zhin.js/schedule": "1.1.5",
32
- "@zhin.js/plugin-runtime": "1.2.0"
32
+ "@zhin.js/plugin-runtime": "1.3.0"
33
33
  },
34
34
  "devDependencies": {
35
35
  "@types/node": "^26.1.2",
@@ -1,12 +1,16 @@
1
1
  import {
2
+ CONSOLE_DENIAL_REASONS,
2
3
  CONSOLE_PERMISSION_LIST,
4
+ CONSOLE_UNKNOWN_RPC_CODE,
3
5
  CONSOLE_WRITE_PERMISSIONS,
4
6
  expandPluginPermissions,
5
7
  grantsAllow,
8
+ grantsAllowAlsoRequired,
6
9
  isPluginConsoleRpcType,
7
10
  resolveConsoleHttpPermission,
8
11
  resolveConsoleRpcPermission,
9
12
  resolveConsoleWsPermission,
13
+ type ConsoleDenialReason,
10
14
  type ConsoleGrant,
11
15
  type ConsolePermission,
12
16
  type ConsolePluginPermissionInfo,
@@ -155,30 +159,98 @@ export type ConsoleRpcDecision =
155
159
  */
156
160
  delegated?: true;
157
161
  }>
158
- | Readonly<{ allowed: false; code: 'setup_required' | 'unmapped' | 'forbidden'; error: string; permission: ConsolePermission | null }>;
162
+ | Readonly<{
163
+ allowed: false;
164
+ /**
165
+ * `unknown_rpc`: the Host has no handler and no permission entry for the type (zhinjs/zhin#1250).
166
+ * Nothing runs; it is not a security refusal and callers do not audit it (see
167
+ * {@link isAuditedRpcDenial}). Every other code is a refusal that is audited.
168
+ */
169
+ code: 'setup_required' | 'unmapped' | 'forbidden' | typeof CONSOLE_UNKNOWN_RPC_CODE;
170
+ error: string;
171
+ permission: ConsolePermission | null;
172
+ /** Audit `detail.reason`; absent for `unknown_rpc`. */
173
+ reason?: ConsoleDenialReason;
174
+ }>;
175
+
176
+ export interface AuthorizeConsoleRpcOptions {
177
+ /**
178
+ * Whether this Host has a handler for an RPC type that is missing from the permission map. True
179
+ * (or no predicate) keeps the audited `unmapped` refusal — a handler without a permission entry
180
+ * is a development error; false answers `unknown_rpc`. Mapped types never consult it.
181
+ */
182
+ readonly registered?: (type: string) => boolean;
183
+ }
184
+
185
+ /** Whether a refused RPC is written to the audit log (`unknown_rpc` is not). */
186
+ export function isAuditedRpcDenial(decision: ConsoleRpcDecision): boolean {
187
+ return !decision.allowed && decision.code !== CONSOLE_UNKNOWN_RPC_CODE;
188
+ }
189
+
190
+ /** Error text of an unknown RPC: the (truncated) type and nothing else. */
191
+ export function unknownConsoleRpcError(type: unknown): string {
192
+ return `RPC "${String(type ?? '').slice(0, 64)}" 不存在`;
193
+ }
159
194
 
160
195
  /** RPC authorization for one principal and message (deny by default). */
161
- export function authorizeConsoleRpc(principal: ConsolePrincipal, message: Readonly<Record<string, unknown>>): ConsoleRpcDecision {
196
+ export function authorizeConsoleRpc(
197
+ principal: ConsolePrincipal,
198
+ message: Readonly<Record<string, unknown>>,
199
+ options: AuthorizeConsoleRpcOptions = {},
200
+ ): ConsoleRpcDecision {
162
201
  const rule = resolveConsoleRpcPermission(message.type, message);
163
202
  const permission = rule ? (rule.kind === 'exact' ? rule.permission : rule.read) : null;
164
203
  if (principal.kind === 'bootstrap') {
165
204
  if (principal.open) {
166
205
  return Object.freeze({ allowed: true, rule, permission, write: rule ? isWriteRule(rule) : true });
167
206
  }
168
- return Object.freeze({ allowed: false, code: 'setup_required', error: '请先完成初始化:创建第一个所有者', permission });
207
+ return Object.freeze({
208
+ allowed: false, code: 'setup_required', error: '请先完成初始化:创建第一个所有者', permission,
209
+ reason: CONSOLE_DENIAL_REASONS.SETUP_REQUIRED,
210
+ });
169
211
  }
170
212
  if (!rule) {
171
- return Object.freeze({ allowed: false, code: 'unmapped', error: `RPC "${String(message.type ?? '')}" 未登记权限`, permission: null });
213
+ const type = typeof message.type === 'string' ? message.type : '';
214
+ if (options.registered && !options.registered(type)) {
215
+ return Object.freeze({ allowed: false, code: CONSOLE_UNKNOWN_RPC_CODE, error: unknownConsoleRpcError(message.type), permission: null });
216
+ }
217
+ return Object.freeze({
218
+ allowed: false, code: 'unmapped', error: `RPC "${String(message.type ?? '')}" 未登记权限`, permission: null,
219
+ reason: CONSOLE_DENIAL_REASONS.UNMAPPED_PERMISSION,
220
+ });
172
221
  }
173
222
  if (isPluginConsoleRpcType(message.type)) {
174
223
  return Object.freeze({ allowed: true, rule, permission, write: isWriteRule(rule), delegated: true });
175
224
  }
176
225
  if (!grantsAllow(principal.grants, permission!, rule.target)) {
177
- return Object.freeze({ allowed: false, code: 'forbidden', error: `缺少权限 ${permission}`, permission });
226
+ return Object.freeze({
227
+ allowed: false, code: 'forbidden', error: `缺少权限 ${permission}`, permission,
228
+ reason: grantsDenialReason(principal.grants, permission!),
229
+ });
230
+ }
231
+ // `alsoRequires` is held globally (same semantics as grantsAllowAlsoRequired): find the first missing one for the denial reason.
232
+ const missing = rule.kind === 'exact' && !grantsAllowAlsoRequired(principal.grants, rule)
233
+ ? rule.alsoRequires?.find(extra => !grantsAllowAlsoRequired(principal.grants, { ...rule, alsoRequires: [extra] }))
234
+ : undefined;
235
+ if (missing) {
236
+ return Object.freeze({
237
+ allowed: false, code: 'forbidden', error: `缺少权限 ${missing}`, permission,
238
+ reason: grantsDenialReason(principal.grants, missing),
239
+ });
178
240
  }
179
241
  return Object.freeze({ allowed: true, rule, permission, write: isWriteRule(rule) });
180
242
  }
181
243
 
244
+ /**
245
+ * Why grants refuse `permission` at a target: `scope_denied` when some binding holds it at another
246
+ * adapter / endpoint, else `insufficient_permission`.
247
+ */
248
+ export function grantsDenialReason(grants: readonly ConsoleGrant[], permission: string): ConsoleDenialReason {
249
+ return grants.some(grant => grant.scope.kind !== 'global' && grant.permissions.includes(permission))
250
+ ? CONSOLE_DENIAL_REASONS.SCOPE_DENIED
251
+ : CONSOLE_DENIAL_REASONS.INSUFFICIENT_PERMISSION;
252
+ }
253
+
182
254
  function isWriteRule(rule: ConsoleRpcPermissionRule): boolean {
183
255
  return rule.kind === 'exact' ? CONSOLE_WRITE_PERMISSIONS.has(rule.permission) : false;
184
256
  }
@@ -192,12 +264,16 @@ export function pluginRpcWriteAllowed(principal: ConsolePrincipal, rule: Console
192
264
  if (principal.kind === 'bootstrap') return principal.open;
193
265
  if (!rule) return false;
194
266
  const permission = rule.kind === 'exact' ? rule.permission : rule.write;
195
- return grantsAllow(principal.grants, permission, rule.target);
267
+ return grantsAllow(principal.grants, permission, rule.target) && grantsAllowAlsoRequired(principal.grants, rule);
196
268
  }
197
269
 
198
270
  export type ConsoleHttpDecision =
199
271
  | Readonly<{ allowed: true; permission: string | null }>
200
- | Readonly<{ allowed: false; status: 401 | 403; code: 'unauthorized' | 'forbidden' | 'setup_required'; error: string; permission: string | null }>;
272
+ | Readonly<{
273
+ allowed: false; status: 401 | 403; code: 'unauthorized' | 'forbidden' | 'setup_required'; error: string; permission: string | null;
274
+ /** Audit `detail.reason`. */
275
+ reason: ConsoleDenialReason;
276
+ }>;
201
277
 
202
278
  /**
203
279
  * HTTP authorization of a route under the API base. `principal` is undefined when no valid
@@ -212,24 +288,35 @@ export function authorizeConsoleHttp(
212
288
  const permission = rule?.permission ?? null;
213
289
  if (rule?.permission === 'public') return Object.freeze({ allowed: true, permission });
214
290
  if (!principal) {
215
- return Object.freeze({ allowed: false, status: 401, code: 'unauthorized', error: 'Invalid or missing token', permission });
291
+ return Object.freeze({ allowed: false, status: 401, code: 'unauthorized', error: 'Invalid or missing token', permission, reason: CONSOLE_DENIAL_REASONS.NOT_AUTHENTICATED });
216
292
  }
217
293
  if (principal.kind === 'bootstrap') {
218
294
  if (principal.open || rule?.permission === 'bootstrap') return Object.freeze({ allowed: true, permission });
219
- return Object.freeze({ allowed: false, status: 403, code: 'setup_required', error: '初始化令牌只能用于创建第一个所有者', permission });
295
+ return Object.freeze({ allowed: false, status: 403, code: 'setup_required', error: '初始化令牌只能用于创建第一个所有者', permission, reason: CONSOLE_DENIAL_REASONS.SETUP_REQUIRED });
220
296
  }
221
297
  if (!rule) {
222
- return Object.freeze({ allowed: false, status: 403, code: 'forbidden', error: 'Route is not registered with a Console permission', permission: null });
298
+ return Object.freeze({ allowed: false, status: 403, code: 'forbidden', error: 'Route is not registered with a Console permission', permission: null, reason: CONSOLE_DENIAL_REASONS.UNMAPPED_ROUTE });
223
299
  }
224
300
  if (rule.permission === 'rpc' || rule.permission === 'authenticated' || rule.permission === 'bootstrap') {
225
301
  return Object.freeze({ allowed: true, permission });
226
302
  }
227
303
  // Scoped permissions over plain HTTP have no knowable target: a global binding is required.
228
304
  if (grantsAllow(principal.grants, rule.permission)) return Object.freeze({ allowed: true, permission });
229
- return Object.freeze({ allowed: false, status: 403, code: 'forbidden', error: `缺少权限 ${rule.permission}`, permission });
305
+ return Object.freeze({
306
+ allowed: false, status: 403, code: 'forbidden', error: `缺少权限 ${rule.permission}`, permission,
307
+ reason: CONSOLE_DENIAL_REASONS.INSUFFICIENT_PERMISSION,
308
+ });
230
309
  }
231
310
 
232
311
  /** WebSocket upgrade authorization for non-bootstrap principals. */
312
+ /** Audit `detail.reason` of a WebSocket {@link authorizeConsoleWebSocket} refused. */
313
+ export function webSocketDenialReason(principal: ConsolePrincipal, pathname: string): ConsoleDenialReason {
314
+ if (principal.kind === 'bootstrap') return CONSOLE_DENIAL_REASONS.SETUP_REQUIRED;
315
+ return resolveConsoleWsPermission(pathname) === null
316
+ ? CONSOLE_DENIAL_REASONS.UNMAPPED_ROUTE
317
+ : CONSOLE_DENIAL_REASONS.INSUFFICIENT_PERMISSION;
318
+ }
319
+
233
320
  export function authorizeConsoleWebSocket(principal: ConsolePrincipal, pathname: string): boolean {
234
321
  // Only the Console's own sockets are host-authenticated; plugin sockets use `auth: 'protocol'`.
235
322
  const permission = resolveConsoleWsPermission(pathname);
@@ -237,13 +324,121 @@ export function authorizeConsoleWebSocket(principal: ConsolePrincipal, pathname:
237
324
  return permission !== null && grantsAllow(principal.grants, permission);
238
325
  }
239
326
 
240
- /** Audit detail for an RPC: type, target and parameter key names only. */
241
- export function rpcAuditDetail(message: Readonly<Record<string, unknown>>): Readonly<Record<string, unknown>> {
327
+ /**
328
+ * JSON length of the detail of one audit entry. The gateway stores at most 4096 characters of
329
+ * JSON detail; statements are cut so the whole detail stays below this budget once serialised
330
+ * (escapes such as `\n` count twice), so the stored detail always parses.
331
+ */
332
+ export const AUDIT_DETAIL_JSON_LIMIT = 3_500;
333
+
334
+ export interface RpcAuditOptions {
335
+ /**
336
+ * Replaces literal values in SQL text (`'…'`) before it is recorded, with the dialect's own
337
+ * lexing (the database Host's `redactSql`). Without it no statement text is recorded at all.
338
+ */
339
+ readonly redactSql?: (sql: string) => string;
340
+ }
341
+
342
+ function statementTexts(message: Readonly<Record<string, unknown>>, reply: Readonly<Record<string, unknown>> | undefined): string[] {
343
+ if (message.type === 'db:execute' || message.type === 'db:query') return typeof message.sql === 'string' && message.sql ? [message.sql] : [];
344
+ const data = reply?.data && typeof reply.data === 'object' ? reply.data as Record<string, unknown> : undefined;
345
+ return (Array.isArray(data?.statements) ? data.statements as unknown[] : []).filter((entry): entry is string => typeof entry === 'string' && !!entry);
346
+ }
347
+
348
+ /**
349
+ * Statement text of Console SQL (`db:query` / `db:execute`, the DDL `db:schema:apply` ran or
350
+ * would have run), literals redacted; result rows are never recorded.
351
+ */
352
+ function databaseStatementDetail(
353
+ message: Readonly<Record<string, unknown>>,
354
+ reply: Readonly<Record<string, unknown>> | undefined,
355
+ options: RpcAuditOptions | undefined,
356
+ ): Record<string, unknown> {
357
+ if (message.type !== 'db:execute' && message.type !== 'db:query' && message.type !== 'db:schema:apply') return {};
358
+ const change = message.type === 'db:schema:apply' && message.change && typeof message.change === 'object' ? message.change as Record<string, unknown> : {};
359
+ const texts = statementTexts(message, reply);
360
+ const redact = options?.redactSql;
361
+ return {
362
+ ...(message.type === 'db:execute' && message.dryRun === true ? { dryRun: true } : {}),
363
+ ...(typeof change.kind === 'string' ? { change: change.kind.slice(0, 64) } : {}),
364
+ ...(typeof change.table === 'string' ? { table: change.table.slice(0, 256) } : {}),
365
+ ...(texts.length && redact
366
+ ? { statements: texts.map(text => { try { return redact(text); } catch { return '[unavailable]'; } }) }
367
+ : texts.length ? { statementLength: texts.reduce((total, text) => total + text.length, 0) } : {}),
368
+ };
369
+ }
370
+
371
+ /** Cuts the longest statement until the serialised detail fits {@link AUDIT_DETAIL_JSON_LIMIT}. */
372
+ function fitAuditDetail(detail: Record<string, unknown>): Record<string, unknown> {
373
+ const statements = Array.isArray(detail.statements) ? [...detail.statements as string[]] : undefined;
374
+ if (!statements) return detail;
375
+ let truncated = false;
376
+ for (;;) {
377
+ const candidate = { ...detail, statements, ...(truncated ? { truncated: true } : {}) };
378
+ const overflow = JSON.stringify(candidate).length - AUDIT_DETAIL_JSON_LIMIT;
379
+ if (overflow <= 0) return { ...candidate, statements: Object.freeze(statements) };
380
+ truncated = true;
381
+ let longest = 0;
382
+ for (let index = 1; index < statements.length; index += 1) if (statements[index]!.length > statements[longest]!.length) longest = index;
383
+ const text = statements[longest]!;
384
+ if (text.length <= 1) { statements.splice(longest, 1); continue; }
385
+ statements[longest] = `${text.slice(0, Math.max(0, text.length - overflow - 1))}…`;
386
+ }
387
+ }
388
+
389
+ /**
390
+ * Audit detail for an RPC: type, target and parameter key names only — plus the statement text
391
+ * of Console database writes (pass the reply so the DDL a schema change ran is recorded), with
392
+ * literals redacted by `options.redactSql`.
393
+ */
394
+ export function rpcAuditDetail(
395
+ message: Readonly<Record<string, unknown>>,
396
+ reply?: Readonly<Record<string, unknown>>,
397
+ options?: RpcAuditOptions,
398
+ ): Readonly<Record<string, unknown>> {
242
399
  const data = message.data && typeof message.data === 'object' && !Array.isArray(message.data)
243
400
  ? Object.keys(message.data as Record<string, unknown>).sort()
244
401
  : [];
245
402
  const keys = Object.keys(message).filter(key => key !== 'type' && key !== 'requestId' && key !== 'data').sort();
246
- return Object.freeze({ type: String(message.type ?? ''), keys: Object.freeze([...new Set([...keys, ...data])]) });
403
+ return Object.freeze(fitAuditDetail({
404
+ type: String(message.type ?? '').slice(0, 256),
405
+ keys: Object.freeze([...new Set([...keys, ...data])].slice(0, 64).map(key => key.slice(0, 128))),
406
+ ...databaseStatementDetail(message, reply, options),
407
+ ...envDetail(message),
408
+ }));
409
+ }
410
+
411
+ const auditName = (value: unknown, max: number): string | undefined =>
412
+ typeof value === 'string' && value ? value.slice(0, max) : undefined;
413
+
414
+ /**
415
+ * Which dotenv file and variable names an `env:reveal` / `env:save` touched (issue #1250). Never a
416
+ * value: `changes[].value` and `content` are not read.
417
+ */
418
+ function envDetail(message: Readonly<Record<string, unknown>>): Record<string, unknown> {
419
+ if (message.type !== 'env:reveal' && message.type !== 'env:save') return {};
420
+ const data = message.data && typeof message.data === 'object' && !Array.isArray(message.data) ? message.data as Record<string, unknown> : {};
421
+ const pick = (key: string) => message[key] ?? data[key];
422
+ const file = auditName(pick('filename'), 64);
423
+ if (message.type === 'env:reveal') {
424
+ const key = auditName(pick('key'), 128);
425
+ return { ...(file ? { file } : {}), ...(key ? { key } : {}) };
426
+ }
427
+ const changes = Array.isArray(pick('changes')) ? pick('changes') as unknown[] : undefined;
428
+ if (!changes) return { ...(file ? { file } : {}), ...(pick('content') !== undefined ? { mode: 'replace' } : {}) };
429
+ const set: string[] = [];
430
+ const deleted: string[] = [];
431
+ for (const change of changes.slice(0, 64)) {
432
+ if (!change || typeof change !== 'object') continue;
433
+ const key = auditName((change as Record<string, unknown>).key, 128);
434
+ if (key) ((change as Record<string, unknown>).delete === true ? deleted : set).push(key);
435
+ }
436
+ return {
437
+ ...(file ? { file } : {}),
438
+ set: Object.freeze(set),
439
+ deleted: Object.freeze(deleted),
440
+ ...(changes.length > 64 ? { truncated: true } : {}),
441
+ };
247
442
  }
248
443
 
249
444
  export function rpcAuditTarget(rule: ConsoleRpcPermissionRule | null): string | undefined {