@zhin.js/gateway 1.2.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.
Files changed (61) hide show
  1. package/README.md +1 -1
  2. package/lib/config/configuration.d.ts +32 -3
  3. package/lib/config/configuration.js +120 -25
  4. package/lib/config/environment-files.d.ts +42 -0
  5. package/lib/config/environment-files.js +347 -7
  6. package/lib/config/literal-secrets.d.ts +3 -0
  7. package/lib/config/literal-secrets.js +4 -1
  8. package/lib/config/patch-path-messages.d.ts +15 -0
  9. package/lib/config/patch-path-messages.js +71 -0
  10. package/lib/config/secret-storage.d.ts +42 -0
  11. package/lib/config/secret-storage.js +140 -0
  12. package/lib/config/validation-messages.d.ts +15 -0
  13. package/lib/config/validation-messages.js +66 -0
  14. package/lib/config.d.ts +1 -0
  15. package/lib/config.js +1 -0
  16. package/lib/database/console-role.js +2 -1
  17. package/lib/http/console-rpc.d.ts +3 -1
  18. package/lib/http/console-rpc.js +31 -4
  19. package/lib/http/gateway-console.d.ts +5 -0
  20. package/lib/http/gateway-console.js +31 -5
  21. package/lib/http/gateway-http-server.d.ts +2 -0
  22. package/lib/http/gateway-http-server.js +42 -1
  23. package/lib/http/local-principal.d.ts +1 -1
  24. package/lib/http/local-principal.js +3 -2
  25. package/lib/http/plugin-switch-rpc.js +7 -6
  26. package/lib/http/start-gateway.d.ts +1 -1
  27. package/lib/http/start-gateway.js +2 -0
  28. package/lib/iam/service.js +12 -0
  29. package/lib/iam/setup-service.d.ts +3 -1
  30. package/lib/iam/setup-service.js +2 -1
  31. package/lib/onboarding/ignored-builds.d.ts +18 -0
  32. package/lib/onboarding/ignored-builds.js +63 -0
  33. package/lib/onboarding/index.d.ts +1 -0
  34. package/lib/onboarding/index.js +1 -0
  35. package/lib/packages/errors.js +1 -0
  36. package/lib/packages/rpc.d.ts +6 -1
  37. package/lib/packages/rpc.js +28 -2
  38. package/lib/packages/service.d.ts +3 -1
  39. package/lib/packages/service.js +74 -9
  40. package/lib/plugin-management/plugin-candidates.d.ts +9 -0
  41. package/lib/plugin-management/plugin-candidates.js +55 -0
  42. package/lib/plugin-management/use-cases.js +8 -8
  43. package/lib/plugin-management/workflow.d.ts +13 -2
  44. package/lib/plugin-management/workflow.js +46 -13
  45. package/lib/plugin-management.d.ts +1 -0
  46. package/lib/plugin-management.js +1 -0
  47. package/lib/project/identity.d.ts +2 -0
  48. package/lib/project/identity.js +21 -0
  49. package/lib/project.d.ts +1 -0
  50. package/lib/project.js +1 -0
  51. package/lib/supervisor/instance-supervisor.d.ts +4 -1
  52. package/lib/supervisor/instance-supervisor.js +18 -1
  53. package/lib/terminal/modules.d.ts +12 -0
  54. package/lib/terminal/modules.js +38 -2
  55. package/lib/usage/aggregate.d.ts +64 -3
  56. package/lib/usage/aggregate.js +199 -26
  57. package/lib/usage/prompt-cache.d.ts +49 -0
  58. package/lib/usage/prompt-cache.js +113 -0
  59. package/lib/usage.d.ts +1 -0
  60. package/lib/usage.js +1 -0
  61. package/package.json +8 -8
@@ -0,0 +1,15 @@
1
+ import { type Locale } from '@zhin.js/plugin-runtime';
2
+ /** The parts of an Ajv `ErrorObject` a configuration message needs. */
3
+ export interface SchemaIssue {
4
+ readonly keyword: string;
5
+ readonly params: Readonly<Record<string, unknown>>;
6
+ readonly message?: string;
7
+ }
8
+ /** Schema `pattern` of a value that may be a string only as one whole `${VAR}` reference. */
9
+ export declare const ENVIRONMENT_REFERENCE_SCHEMA_PATTERN = "^\\$\\{[A-Za-z_][A-Za-z0-9_]*(?::[-=][^}]*)?\\}$";
10
+ /**
11
+ * Localized text for one Ajv issue of a Plugin configuration, in the project `locale`
12
+ * (`zh-CN` by default). `location` is the field path inside the configuration block; it is named in
13
+ * the message so a reader sees which field is wrong without matching it to the separate path.
14
+ */
15
+ export declare function formatSchemaIssue(issue: SchemaIssue, location: readonly string[], locale: Locale | unknown): string;
@@ -0,0 +1,66 @@
1
+ import { createMessages, resolveLocale } from '@zhin.js/plugin-runtime';
2
+ const messages = createMessages({
3
+ type: { 'zh-CN': '{field}类型应为 {type}', en: '{field}must be of type {type}' },
4
+ maximum: { 'zh-CN': '{field}不能大于 {limit}', en: '{field}must be at most {limit}' },
5
+ minimum: { 'zh-CN': '{field}不能小于 {limit}', en: '{field}must be at least {limit}' },
6
+ exclusiveMaximum: { 'zh-CN': '{field}必须小于 {limit}', en: '{field}must be less than {limit}' },
7
+ exclusiveMinimum: { 'zh-CN': '{field}必须大于 {limit}', en: '{field}must be greater than {limit}' },
8
+ minLength: { 'zh-CN': '{field}长度不能少于 {limit} 个字符', en: '{field}must be at least {limit} characters long' },
9
+ maxLength: { 'zh-CN': '{field}长度不能超过 {limit} 个字符', en: '{field}must be at most {limit} characters long' },
10
+ enum: { 'zh-CN': '{field}必须是以下值之一:{values}', en: '{field}must be one of: {values}' },
11
+ const: { 'zh-CN': '{field}必须等于 {value}', en: '{field}must equal {value}' },
12
+ pattern: { 'zh-CN': '{field}格式不符合 {pattern}', en: '{field}must match the pattern {pattern}' },
13
+ environmentReference: { 'zh-CN': '{field}只接受数值或 {reference} 形式的环境变量引用', en: '{field}accepts only a number or a {reference} environment reference' },
14
+ required: { 'zh-CN': '缺少必填字段 {property}', en: 'missing required field {property}' },
15
+ additionalProperties: { 'zh-CN': '不支持的字段 {property}', en: 'unknown field {property}' },
16
+ format: { 'zh-CN': '{field}应为有效的 {format}', en: '{field}must be a valid {format}' },
17
+ minItems: { 'zh-CN': '{field}至少需要 {limit} 项', en: '{field}must have at least {limit} items' },
18
+ maxItems: { 'zh-CN': '{field}最多只能有 {limit} 项', en: '{field}must have at most {limit} items' },
19
+ fallback: { 'zh-CN': '{field}不满足 {keyword} 约束({detail})', en: '{field}does not satisfy {keyword} ({detail})' },
20
+ field: { 'zh-CN': '字段 {path} ', en: 'Field {path} ' },
21
+ });
22
+ /** Schema `pattern` of a value that may be a string only as one whole `${VAR}` reference. */
23
+ export const ENVIRONMENT_REFERENCE_SCHEMA_PATTERN = '^\\$\\{[A-Za-z_][A-Za-z0-9_]*(?::[-=][^}]*)?\\}$';
24
+ const NUMERIC_LIMITS = new Set(['maximum', 'minimum', 'exclusiveMaximum', 'exclusiveMinimum', 'minLength', 'maxLength', 'minItems', 'maxItems']);
25
+ function display(value) {
26
+ return typeof value === 'string' ? value : JSON.stringify(value);
27
+ }
28
+ /**
29
+ * Localized text for one Ajv issue of a Plugin configuration, in the project `locale`
30
+ * (`zh-CN` by default). `location` is the field path inside the configuration block; it is named in
31
+ * the message so a reader sees which field is wrong without matching it to the separate path.
32
+ */
33
+ export function formatSchemaIssue(issue, location, locale) {
34
+ const lang = resolveLocale(locale);
35
+ const field = location.length > 0 ? messages(lang, 'field', { path: location.join('.') }) : '';
36
+ const params = issue.params;
37
+ switch (issue.keyword) {
38
+ case 'type': {
39
+ const type = Array.isArray(params.type) ? params.type.join(' | ') : String(params.type);
40
+ return messages(lang, 'type', { field, type: type === 'array' ? 'list' : type });
41
+ }
42
+ case 'enum': {
43
+ const allowed = Array.isArray(params.allowedValues) ? params.allowedValues.map(display).join(', ') : display(params.allowedValues);
44
+ return messages(lang, 'enum', { field, values: allowed });
45
+ }
46
+ case 'const':
47
+ return messages(lang, 'const', { field, value: display(params.allowedValue) });
48
+ case 'pattern':
49
+ // A string allowed only as a whole `${VAR}` reference (e.g. `http.port`): say so plainly.
50
+ if (String(params.pattern) === ENVIRONMENT_REFERENCE_SCHEMA_PATTERN) {
51
+ return messages(lang, 'environmentReference', { field, reference: '${VAR}' });
52
+ }
53
+ return messages(lang, 'pattern', { field, pattern: String(params.pattern) });
54
+ case 'format':
55
+ return messages(lang, 'format', { field, format: String(params.format) });
56
+ case 'required':
57
+ return messages(lang, 'required', { property: [...location, String(params.missingProperty)].join('.') });
58
+ case 'additionalProperties':
59
+ return messages(lang, 'additionalProperties', { property: [...location, String(params.additionalProperty)].join('.') });
60
+ default:
61
+ if (NUMERIC_LIMITS.has(issue.keyword) && (typeof params.limit === 'number' || typeof params.limit === 'string')) {
62
+ return messages(lang, issue.keyword, { field, limit: params.limit });
63
+ }
64
+ return messages(lang, 'fallback', { field, keyword: issue.keyword, detail: issue.message ?? issue.keyword });
65
+ }
66
+ }
package/lib/config.d.ts CHANGED
@@ -11,3 +11,4 @@ export * from './config/environment-files.js';
11
11
  export * from './config/literal-secrets.js';
12
12
  export * from './config/plugin-package-map.js';
13
13
  export * from './config/plugin-schema-catalog.js';
14
+ export { SECRET_ENVIRONMENT_WRITE_REQUIRED, SecretEnvironmentWriteRequiredError } from './config/secret-storage.js';
package/lib/config.js CHANGED
@@ -11,3 +11,4 @@ export * from './config/environment-files.js';
11
11
  export * from './config/literal-secrets.js';
12
12
  export * from './config/plugin-package-map.js';
13
13
  export * from './config/plugin-schema-catalog.js';
14
+ export { SECRET_ENVIRONMENT_WRITE_REQUIRED, SecretEnvironmentWriteRequiredError } from './config/secret-storage.js';
@@ -92,7 +92,8 @@ export async function setupConsoleRole(options) {
92
92
  const block = document.database && typeof document.database === 'object' && !Array.isArray(document.database)
93
93
  ? document.database
94
94
  : { dialect };
95
- await store.setKey('database', { ...block, console: { ...ENV_REFERENCE } });
95
+ // Local owner CLI (`zhin db console-role`): may write `.env`.
96
+ await store.setKey('database', { ...block, console: { ...ENV_REFERENCE } }, { environmentWrite: true });
96
97
  return Object.freeze({
97
98
  dialect,
98
99
  user,
@@ -6,7 +6,7 @@
6
6
  * type is forwarded to the instance by the HTTP server.
7
7
  */
8
8
  import { type RouteContext, type TokenRegistry } from '@zhin.js/host-http';
9
- import type { GatewayRpcDispatcher } from '../packages/rpc.js';
9
+ import { type GatewayRpcDispatcher } from '../packages/rpc.js';
10
10
  /** RPC types with this prefix belong to the gateway (unknown ones are refused as unmapped). */
11
11
  export declare const GATEWAY_RPC_PREFIX = "gateway.";
12
12
  export interface GatewayConsoleRpc {
@@ -23,5 +23,7 @@ export interface GatewayRpcHandler extends GatewayConsoleRpc {
23
23
  * is forwarded to the instance untouched (e.g. `feature.set-enabled` below the global level).
24
24
  */
25
25
  claims?(message: Readonly<Record<string, unknown>>): boolean;
26
+ /** RPC types this handler answers, for `GET {base}/console/capabilities`. */
27
+ types?(): readonly string[];
26
28
  }
27
29
  export declare function createGatewayConsoleRpc(dispatch: GatewayRpcDispatcher): GatewayConsoleRpc;
@@ -5,31 +5,58 @@
5
5
  * error, requestId, errors?, missingEnv? }`, see `GatewayRpcHttpErrorReply`), every other
6
6
  * type is forwarded to the instance by the HTTP server.
7
7
  */
8
- import { auditActor, jsonResponse, } from '@zhin.js/host-http';
9
- import { GATEWAY_RPC } from '@zhin.js/console-protocol';
8
+ import { auditActor, jsonResponse, unknownRpcLog, } from '@zhin.js/host-http';
9
+ import { CONSOLE_DENIAL_REASONS, GATEWAY_RPC, GATEWAY_RPC_PERMISSIONS, } from '@zhin.js/console-protocol';
10
+ import { isGatewayRpcType } from '../packages/rpc.js';
11
+ import { gatewayPrincipalAllows } from '../packages/authorization.js';
12
+ import { LOCAL_GATEWAY_RPC } from '../local/protocol.js';
10
13
  /** RPC types with this prefix belong to the gateway (unknown ones are refused as unmapped). */
11
14
  export const GATEWAY_RPC_PREFIX = 'gateway.';
15
+ /**
16
+ * Socket-only RPC types ({@link LOCAL_GATEWAY_RPC}): they exist, but never over HTTP. Refused
17
+ * with 403 (`forbidden`, audit reason `socket_only`) rather than `unknown_rpc`, so an attempt to
18
+ * reach Console administration over HTTP stays visible in the audit log.
19
+ */
20
+ const SOCKET_ONLY_TYPES = new Set(Object.values(LOCAL_GATEWAY_RPC));
12
21
  const READ_ONLY_TYPES = new Set([
13
22
  GATEWAY_RPC.LIST,
14
23
  GATEWAY_RPC.PLAN,
15
24
  GATEWAY_RPC.STATUS,
16
25
  GATEWAY_RPC.LOGS,
17
26
  ]);
27
+ /** Why the gateway dispatcher refused `type` (`forbidden`). */
28
+ function gatewayDenialReason(type, principal) {
29
+ if (!principal)
30
+ return CONSOLE_DENIAL_REASONS.NOT_AUTHENTICATED;
31
+ if (isGatewayRpcType(type) && !gatewayPrincipalAllows(principal, GATEWAY_RPC_PERMISSIONS[type])) {
32
+ return principal.kind === 'bootstrap' ? CONSOLE_DENIAL_REASONS.SETUP_REQUIRED : CONSOLE_DENIAL_REASONS.INSUFFICIENT_PERMISSION;
33
+ }
34
+ // The permission map admitted it; the service's own rule refused (e.g. bootstrap package limits).
35
+ return CONSOLE_DENIAL_REASONS.HANDLER_DENIED;
36
+ }
18
37
  export function createGatewayConsoleRpc(dispatch) {
19
38
  return Object.freeze({
20
39
  async handle(message, context, tokenRegistry) {
21
40
  const type = typeof message.type === 'string' ? message.type : '';
22
41
  const requestId = message.requestId;
23
42
  const principal = context.principal;
24
- const result = await dispatch({ type, data: message.data }, principal);
43
+ const socketOnly = SOCKET_ONLY_TYPES.has(type);
44
+ const result = socketOnly
45
+ ? { ok: false, error: { code: 'forbidden', status: 403, message: `RPC "${type.slice(0, 64)}" 只能通过本机网关套接字调用(zhin console …)` } }
46
+ : await dispatch({ type, data: message.data }, principal);
25
47
  const write = !READ_ONLY_TYPES.has(type);
48
+ const unknown = !result.ok && result.error.code === 'unknown_rpc';
26
49
  const outcome = result.ok ? 'ok' : result.error.code === 'forbidden' ? 'denied' : 'error';
27
- if (write || outcome === 'denied') {
50
+ // zhinjs/zhin#1250: an unknown `gateway.*` type ran nothing and is not a refusal: not audited.
51
+ if (unknown)
52
+ unknownRpcLog.note(type);
53
+ else if (write || outcome === 'denied') {
28
54
  try {
29
55
  tokenRegistry.accessProvider?.audit?.({
30
56
  actor: auditActor(principal),
31
57
  action: `rpc:${type}`.slice(0, 256),
32
58
  outcome,
59
+ ...(outcome === 'denied' ? { detail: { type: type.slice(0, 256), reason: socketOnly ? CONSOLE_DENIAL_REASONS.SOCKET_ONLY : gatewayDenialReason(type, principal) } } : {}),
33
60
  ...(context.remoteAddress ? { ip: context.remoteAddress } : {}),
34
61
  });
35
62
  }
@@ -1,5 +1,6 @@
1
1
  import { type ConsoleAuditEntry } from '@zhin.js/host-http';
2
2
  import { type ConsolePluginPermissionInfo } from '@zhin.js/console-protocol';
3
+ import type { BridgeRouteTable } from '@zhin.js/host-http/bridge';
3
4
  import { type ProjectDatabaseConfig } from '../database/config.js';
4
5
  import { type GatewayStore, type OpenGatewayStoreOptions } from '../database/gateway-store.js';
5
6
  import { type LocalConsoleAdmin } from '../iam/local-admin.js';
@@ -15,6 +16,10 @@ export interface GatewayConsoleInstancePort {
15
16
  pluginPermissions?(): readonly ConsolePluginPermissionInfo[];
16
17
  /** Sends `principal-revoked` (the instance closes that user's live connections). */
17
18
  revokePrincipal?(principalId: string, reason?: string): void;
19
+ /** Whether an instance is attached and answering (a message sent now is delivered). */
20
+ readonly ready?: boolean;
21
+ /** Runs on every route table change (an instance's first `ready` included; empty when it goes away). */
22
+ onRoutes?(listener: (table: BridgeRouteTable) => void): () => void;
18
23
  /** Audit entries written by the instance. */
19
24
  onAudit?(listener: (entry: ConsoleAuditEntry) => void): () => void;
20
25
  }
@@ -12,7 +12,7 @@
12
12
  */
13
13
  import { join } from 'node:path';
14
14
  import { auditActor, authorizeConsoleRpc, jsonResponse, rpcAuditDetail, rpcAuditTarget, } from '@zhin.js/host-http';
15
- import { ONBOARDING_RPC } from '@zhin.js/console-protocol';
15
+ import { CONSOLE_DENIAL_REASONS, IAM_RPC, ONBOARDING_RPC } from '@zhin.js/console-protocol';
16
16
  import { DEFAULT_ROOT_CONFIG_FILE_NAME } from '@zhin.js/plugin-runtime';
17
17
  import { createConfigDocument } from '@zhin.js/runtime';
18
18
  import { formatCompact, getLogger } from '@zhin.js/logger';
@@ -86,13 +86,38 @@ export async function startGatewayConsole(options) {
86
86
  iam,
87
87
  logger: { warn: (message) => logger.warn(message), info: (message) => logger.info(message) },
88
88
  });
89
+ // ADR 0060 §2.4: a deleted / disabled Console user's chat person link is removed by the
90
+ // instance on `principal-revoked`. Those two reasons are kept while no instance is attached
91
+ // and sent once one reports ready, so a link never outlives its user because the instance was
92
+ // down (bounded; in memory only: a gateway restart forgets them).
93
+ const undelivered = new Map();
89
94
  const revoke = (userId, reason) => {
90
95
  terminal.terminatePrincipal(`user:${userId}`, 'revoked');
96
+ const ending = reason === 'deleted' || reason === 'disabled';
97
+ if (ending && options.instance?.ready === false) {
98
+ undelivered.delete(userId);
99
+ undelivered.set(userId, reason);
100
+ if (undelivered.size > 1024)
101
+ undelivered.delete(undelivered.keys().next().value);
102
+ }
91
103
  try {
92
104
  options.instance?.revokePrincipal?.(userId, reason);
93
105
  }
94
106
  catch { /* bridge gone */ }
95
107
  };
108
+ const stopUndelivered = options.instance?.onRoutes?.(() => {
109
+ if (options.instance?.ready !== true)
110
+ return;
111
+ for (const [userId, reason] of [...undelivered]) {
112
+ undelivered.delete(userId);
113
+ try {
114
+ options.instance?.revokePrincipal?.(userId, reason);
115
+ }
116
+ catch { /* bridge gone */ }
117
+ }
118
+ });
119
+ if (stopUndelivered)
120
+ disposers.push(stopUndelivered);
96
121
  const projectDatabase = async () => options.database
97
122
  ?? resolveProjectDatabaseConfig(projectRoot, await loadExpandedProjectConfig(projectRoot, environment));
98
123
  async function connect() {
@@ -330,6 +355,7 @@ const ONBOARDING_STEPS = new Set(['ai', 'adapters', 'finish']);
330
355
  export function createGatewayAccountRpc(options) {
331
356
  return Object.freeze({
332
357
  owns: (type) => isIamRpc(type) || type === ONBOARDING_RPC.UPDATE,
358
+ types: () => [...Object.values(IAM_RPC), ONBOARDING_RPC.UPDATE],
333
359
  async handle(message, context, registry) {
334
360
  const noStore = { 'cache-control': 'no-store' };
335
361
  const requestId = message.requestId;
@@ -338,21 +364,21 @@ export function createGatewayAccountRpc(options) {
338
364
  return jsonResponse(401, { success: false, code: 'unauthorized', error: 'Invalid or missing token', requestId }, noStore);
339
365
  }
340
366
  const decision = authorizeConsoleRpc(principal, message);
341
- const audit = (outcome) => {
367
+ const audit = (outcome, reason) => {
342
368
  try {
343
369
  registry.accessProvider?.audit?.({
344
370
  actor: auditActor(principal),
345
371
  action: `rpc:${String(message.type ?? '')}`.slice(0, 256),
346
372
  ...(decision.allowed && rpcAuditTarget(decision.rule) ? { target: rpcAuditTarget(decision.rule) } : {}),
347
373
  outcome,
348
- detail: rpcAuditDetail(message),
374
+ detail: reason ? { ...rpcAuditDetail(message), reason } : rpcAuditDetail(message),
349
375
  ...(context.remoteAddress ? { ip: context.remoteAddress } : {}),
350
376
  });
351
377
  }
352
378
  catch { /* audit never breaks a request */ }
353
379
  };
354
380
  if (!decision.allowed) {
355
- audit('denied');
381
+ audit('denied', decision.reason);
356
382
  return jsonResponse(403, { success: false, code: decision.code, error: decision.error, requestId }, noStore);
357
383
  }
358
384
  const result = isIamRpc(message.type)
@@ -361,7 +387,7 @@ export function createGatewayAccountRpc(options) {
361
387
  if (decision.write)
362
388
  audit(result.status < 400 && result.body.success !== false ? 'ok' : 'error');
363
389
  else if (result.status === 403)
364
- audit('denied');
390
+ audit('denied', CONSOLE_DENIAL_REASONS.HANDLER_DENIED);
365
391
  return jsonResponse(result.status, result.body, { ...noStore, ...result.headers });
366
392
  },
367
393
  });
@@ -43,6 +43,8 @@ export interface GatewayHttpServerOptions {
43
43
  * RPC type on that route is forwarded to the instance unchanged.
44
44
  */
45
45
  readonly consoleRpc?: GatewayConsoleRpc;
46
+ /** Reported on `/pub/health` as `project.id` (`projectIdentity(projectRoot)`). */
47
+ readonly projectId?: string;
46
48
  }
47
49
  /** One router, many listeners: every listener serves the same surface. */
48
50
  export declare class GatewayHttpServer {
@@ -14,9 +14,10 @@
14
14
  * answer in time gives `504 { code: 'instance_timeout' }`, any other bridge failure
15
15
  * `502 { code: 'instance_error' }` (mapped from {@link InstanceTransportError}).
16
16
  */
17
- import { authorizeConsoleWebSocket, auditActor, createHttpRouter, extractBearerToken, jsonResponse, normalizePath, requestMeta, TokenRegistry, } from '@zhin.js/host-http';
17
+ import { authorizeConsoleWebSocket, auditActor, createHttpRouter, extractBearerToken, jsonResponse, normalizePath, requestMeta, TokenRegistry, webSocketDenialReason, } from '@zhin.js/host-http';
18
18
  import { InstanceTransportError, } from '@zhin.js/host-http/bridge';
19
19
  import { createNodeHttpListener } from '@zhin.js/host-http/node';
20
+ import { CONSOLE_CAPABILITIES_PATH, CONSOLE_FEATURES, GATEWAY_RPC, createConsoleCapabilities, mergeConsoleCapabilities, parseConsoleCapabilities, } from '@zhin.js/console-protocol';
20
21
  import { formatCompact, getLogger } from '@zhin.js/logger';
21
22
  import { databaseBusyResponse, isDatabaseBusyError } from '../database/project-database.js';
22
23
  import { GATEWAY_RPC_PREFIX } from './console-rpc.js';
@@ -55,6 +56,9 @@ export class GatewayHttpServer {
55
56
  #retryAfter;
56
57
  #maxBody;
57
58
  #consoleRpc;
59
+ /** The instance connection (and route table) that has no capability route: asked once. */
60
+ #capabilitiesMissing;
61
+ #projectId;
58
62
  #rpcHandlers = new Set();
59
63
  #closed = false;
60
64
  #closeResult;
@@ -65,6 +69,7 @@ export class GatewayHttpServer {
65
69
  this.#retryAfter = String(Math.max(1, Math.round(options.retryAfterSeconds ?? DEFAULT_GATEWAY_RETRY_AFTER_SECONDS)));
66
70
  this.#maxBody = options.maxRequestBodyBytes ?? DEFAULT_GATEWAY_MAX_REQUEST_BODY_BYTES;
67
71
  this.#consoleRpc = options.consoleRpc;
72
+ this.#projectId = options.projectId;
68
73
  this.tokenRegistry = options.tokenRegistry ?? new TokenRegistry({ primaryToken: primary.token });
69
74
  this.apiBase = normalizePath(primary.apiBase ?? '/api');
70
75
  const routerOptions = {
@@ -84,6 +89,10 @@ export class GatewayHttpServer {
84
89
  summary: 'Console RPC (gateway-owned types answered by the gateway, the rest forwarded)',
85
90
  tags: ['console'],
86
91
  });
92
+ router.route('GET', `${this.apiBase}${CONSOLE_CAPABILITIES_PATH}`, (request, context) => this.#capabilities(request, context), {
93
+ summary: 'Console capabilities (gateway-owned RPC types merged with the instance\'s)',
94
+ tags: ['console'],
95
+ });
87
96
  // Lowest precedence: every path no gateway route claims goes to the instance.
88
97
  router.route('ALL', '/*', (request, context) => this.#forward(request, context));
89
98
  }
@@ -178,6 +187,7 @@ export class GatewayHttpServer {
178
187
  status: 'ok',
179
188
  timestamp: new Date().toISOString(),
180
189
  instance: { state: this.#instance.state(), ready: this.#readyTransport() !== undefined },
190
+ ...(this.#projectId ? { project: { id: this.#projectId } } : {}),
181
191
  };
182
192
  // Polled by the Console's instance banner: never cached.
183
193
  return jsonResponse(200, body, { 'cache-control': 'no-store' });
@@ -243,6 +253,36 @@ export class GatewayHttpServer {
243
253
  }));
244
254
  return this.#instanceError(502, 'instance_error', 'unknown', 'The instance failed to answer', false);
245
255
  }
256
+ /**
257
+ * `GET {base}/console/capabilities` (zhinjs/zhin#1250): the RPC types the gateway answers itself,
258
+ * merged with the instance's reply. While the instance is not answering (or predates the route)
259
+ * the reply says `complete: false`, so the Console reads it again later.
260
+ */
261
+ async #capabilities(request, context) {
262
+ const own = [...this.#rpcHandlers].flatMap(handler => handler.types?.() ?? []);
263
+ const gateway = createConsoleCapabilities({
264
+ rpc: [...(this.#consoleRpc ? Object.values(GATEWAY_RPC) : []), ...own],
265
+ features: [CONSOLE_FEATURES.UNKNOWN_RPC_CODE, CONSOLE_FEATURES.DENIAL_REASONS, CONSOLE_FEATURES.GATEWAY],
266
+ });
267
+ let instance = createConsoleCapabilities({ rpc: [], complete: false });
268
+ const transport = this.#readyTransport();
269
+ // An instance that predates the route refuses it (and audits that): ask it once per route table.
270
+ const revision = transport?.routes().revision;
271
+ const missing = this.#capabilitiesMissing;
272
+ if (transport && !(missing?.transport === transport && missing.revision === revision)) {
273
+ try {
274
+ const response = await this.#forward(request, context);
275
+ const body = response.ok ? await response.json().catch(() => null) : null;
276
+ const parsed = parseConsoleCapabilities(body?.data);
277
+ if (parsed)
278
+ instance = parsed;
279
+ else if (response.status === 403 || response.status === 404)
280
+ this.#capabilitiesMissing = { transport, revision };
281
+ }
282
+ catch { /* the instance part stays unknown */ }
283
+ }
284
+ return jsonResponse(200, { success: true, data: mergeConsoleCapabilities(gateway, instance) }, { 'cache-control': 'no-store' });
285
+ }
246
286
  /**
247
287
  * `POST {base}/console/request`: the router authenticated and authorized the route; `gateway.*`
248
288
  * types are dispatched here (the dispatcher checks the per-RPC permission), the rest are
@@ -392,6 +432,7 @@ export class GatewayHttpServer {
392
432
  actor: auditActor(principal),
393
433
  action: `ws:${pathname}`,
394
434
  outcome: 'denied',
435
+ detail: { reason: webSocketDenialReason(principal, pathname) },
395
436
  ...(request.remoteAddress ? { ip: request.remoteAddress } : {}),
396
437
  });
397
438
  }
@@ -1,2 +1,2 @@
1
- import type { ConsoleUserPrincipal } from '@zhin.js/host-http';
1
+ import { type ConsoleUserPrincipal } from '@zhin.js/host-http';
2
2
  export declare const LOCAL_OWNER_PRINCIPAL: ConsoleUserPrincipal;
@@ -5,12 +5,13 @@
5
5
  * entries name it `user:local`.
6
6
  */
7
7
  import { CONSOLE_PERMISSION_LIST } from '@zhin.js/console-protocol';
8
+ import { LOCAL_SOCKET_CREDENTIAL_ID, LOCAL_SOCKET_USER_ID } from '@zhin.js/host-http';
8
9
  export const LOCAL_OWNER_PRINCIPAL = Object.freeze({
9
10
  kind: 'user',
10
- userId: 'local',
11
+ userId: LOCAL_SOCKET_USER_ID,
11
12
  username: 'local',
12
13
  displayName: '本机 CLI(网关控制套接字)',
13
- credentialId: 'local-socket',
14
+ credentialId: LOCAL_SOCKET_CREDENTIAL_ID,
14
15
  grants: Object.freeze([Object.freeze({ role: 'owner', scope: Object.freeze({ kind: 'global' }), permissions: CONSOLE_PERMISSION_LIST })]),
15
16
  roles: Object.freeze(['owner']),
16
17
  });
@@ -17,7 +17,7 @@
17
17
  */
18
18
  import { join } from 'node:path';
19
19
  import { auditActor, authorizeConsoleRpc, jsonResponse, rpcAuditDetail, rpcAuditTarget, } from '@zhin.js/host-http';
20
- import { FEATURE_RPC, PLUGIN_RPC, isGlobalFeatureSwitch, } from '@zhin.js/console-protocol';
20
+ import { CONSOLE_DENIAL_REASONS, FEATURE_RPC, PLUGIN_RPC, isGlobalFeatureSwitch, } from '@zhin.js/console-protocol';
21
21
  import { DEFAULT_ROOT_CONFIG_FILE_NAME, FEATURE_OVERRIDES_KEY } from '@zhin.js/plugin-runtime';
22
22
  import { collectFeatureOverrideWarnings, createConfigDocument } from '@zhin.js/runtime';
23
23
  import { formatCompact, getLogger } from '@zhin.js/logger';
@@ -39,6 +39,7 @@ export function createGatewayPluginSwitchRpc(options) {
39
39
  return Object.freeze({
40
40
  owns: (type) => type === PLUGIN_RPC.SET_ENABLED || type === FEATURE_RPC.SET_ENABLED,
41
41
  claims: isGatewayPluginSwitch,
42
+ types: () => [PLUGIN_RPC.SET_ENABLED, FEATURE_RPC.SET_ENABLED],
42
43
  async handle(message, context, registry) {
43
44
  const noStore = { 'cache-control': 'no-store' };
44
45
  const type = String(message.type ?? '');
@@ -49,27 +50,27 @@ export function createGatewayPluginSwitchRpc(options) {
49
50
  }
50
51
  // plugin.manage: `plugin:set-enabled` is mapped to it, `feature.set-enabled` is refined by level.
51
52
  const decision = authorizeConsoleRpc(principal, message);
52
- const audit = (outcome) => {
53
+ const audit = (outcome, reason) => {
53
54
  try {
54
55
  registry.accessProvider?.audit?.({
55
56
  actor: auditActor(principal),
56
57
  action: `rpc:${type}`.slice(0, 256),
57
58
  ...(decision.allowed && rpcAuditTarget(decision.rule) ? { target: rpcAuditTarget(decision.rule) } : {}),
58
59
  outcome,
59
- detail: rpcAuditDetail(message),
60
+ detail: reason ? { ...rpcAuditDetail(message), reason } : rpcAuditDetail(message),
60
61
  ...(context.remoteAddress ? { ip: context.remoteAddress } : {}),
61
62
  });
62
63
  }
63
64
  catch { /* audit never breaks a request */ }
64
65
  };
65
66
  if (!decision.allowed) {
66
- audit('denied');
67
+ audit('denied', decision.reason);
67
68
  return jsonResponse(403, { success: false, code: decision.code, error: decision.error, requestId }, noStore);
68
69
  }
69
70
  const reply = type === PLUGIN_RPC.SET_ENABLED
70
71
  ? await setPluginEnabled(message, principal, options, readConfig)
71
72
  : await setFeatureGlobal(message, principal, options, readConfig);
72
- audit(reply.status < 400 && reply.body.success !== false ? 'ok' : reply.status === 403 ? 'denied' : 'error');
73
+ audit(reply.status < 400 && reply.body.success !== false ? 'ok' : reply.status === 403 ? 'denied' : 'error', reply.status === 403 ? CONSOLE_DENIAL_REASONS.HANDLER_DENIED : undefined);
73
74
  return jsonResponse(reply.status, { ...reply.body, requestId }, noStore);
74
75
  },
75
76
  });
@@ -176,7 +177,7 @@ async function runSwitch(plugin, enabled, principal, options, type) {
176
177
  return { result };
177
178
  const action = enabled ? '启用' : '停用';
178
179
  const text = result.status === 'rollbackFailed'
179
- ? `${action} ${plugin} 失败,且回滚未完成:${result.rollbackError ?? '未知错误'}(原因:${result.error ?? '未知错误'})`
180
+ ? `${action} ${plugin} 失败,且回滚未完成:${[result.rollbackError, result.restartError ? `重新启动实例失败:${result.restartError}` : undefined].filter(Boolean).join(';') || '未知错误'}(原因:${result.error ?? '未知错误'})`
180
181
  : `${action} ${plugin} 失败,已回滚:${result.error ?? '未知错误'}`;
181
182
  return {
182
183
  reply: error(500, result.status === 'rollbackFailed' ? 'rollback_failed' : 'transaction_failed', text, type, {
@@ -14,7 +14,7 @@ import { GatewayHttpServer } from './gateway-http-server.js';
14
14
  * optional {@link GatewayConsoleInstancePort} methods (plugin catalog, instance audit,
15
15
  * `principal-revoked`) when the transport has them (`IpcInstanceTransport` does).
16
16
  */
17
- export interface GatewayInstanceTransport extends InstanceTransport, GatewayConsoleInstancePort {
17
+ export interface GatewayInstanceTransport extends InstanceTransport, Omit<GatewayConsoleInstancePort, 'ready' | 'onRoutes'> {
18
18
  dispose?(): void | Promise<void>;
19
19
  /** Told the bound listener addresses after listen (the instance's `HttpHost.address`). */
20
20
  setAddresses?(addresses: readonly import('@zhin.js/host-http').HttpHostAddress[]): void;
@@ -7,6 +7,7 @@ import { join } from 'node:path';
7
7
  import { formatCompact, getLogger } from '@zhin.js/logger';
8
8
  import { createConfigDocument } from '@zhin.js/runtime';
9
9
  import { findConfigFile } from '../project/config-file.js';
10
+ import { projectIdentity } from '../project/identity.js';
10
11
  import { loadRuntimeEnvironmentLayers } from '../project/environment.js';
11
12
  import { InstanceSupervisor } from '../supervisor/instance-supervisor.js';
12
13
  import { GatewayPackageService } from '../packages/service.js';
@@ -113,6 +114,7 @@ export async function startGateway(options) {
113
114
  ...(options.retryAfterSeconds !== undefined ? { retryAfterSeconds: options.retryAfterSeconds } : {}),
114
115
  ...(options.maxRequestBodyBytes !== undefined ? { maxRequestBodyBytes: options.maxRequestBodyBytes } : {}),
115
116
  consoleRpc: createGatewayConsoleRpc(rpc),
117
+ projectId: projectIdentity(options.projectRoot),
116
118
  });
117
119
  registry = server.tokenRegistry;
118
120
  // The Console's global plugin switch is a gateway transaction (stop → write → start, rollback);
@@ -460,9 +460,18 @@ export class ConsoleIamService {
460
460
  row.permissions = JSON.stringify(permissionList(patch.permissions));
461
461
  if (Object.keys(row).length)
462
462
  await this.#model('roles').update(row).where({ id });
463
+ // Grants of every holder may have shrunk: their long-lived connections and anything the
464
+ // instance derived from their grants (open chat bind codes) re-authorize from scratch.
465
+ if (patch.permissions !== undefined)
466
+ for (const userId of await this.#usersBoundTo(id))
467
+ this.#emitRevocation(userId, 'bindings');
463
468
  return withPluginPermissions((await this.#role(id)), this.pluginPermissionCatalog());
464
469
  });
465
470
  }
471
+ async #usersBoundTo(roleId) {
472
+ const rows = await this.#model('bindings').select('user_id').where({ role_id: roleId });
473
+ return [...new Set(rows.map(row => String(row.user_id)))];
474
+ }
466
475
  deleteRole(id) {
467
476
  return this.#serialize(async () => {
468
477
  const role = await this.#role(id);
@@ -470,8 +479,11 @@ export class ConsoleIamService {
470
479
  throw new IamError('not_found', '角色不存在');
471
480
  if (role.builtin)
472
481
  throw new IamError('forbidden', '内置角色不可删除');
482
+ const holders = await this.#usersBoundTo(id);
473
483
  await this.#model('bindings').delete().where({ role_id: id });
474
484
  await this.#model('roles').delete().where({ id });
485
+ for (const userId of holders)
486
+ this.#emitRevocation(userId, 'bindings');
475
487
  });
476
488
  }
477
489
  /** Replaces all bindings of a user. */
@@ -37,7 +37,9 @@ export interface SetupDatabaseInput {
37
37
  /** Config-file and dotenv access the setup flow needs (ConsoleConfigurationStore satisfies `config`). */
38
38
  export interface SetupConfigPort {
39
39
  readDocument(): Promise<Record<string, unknown>>;
40
- setKey(key: string, value: unknown): Promise<unknown>;
40
+ setKey(key: string, value: unknown, options?: {
41
+ readonly environmentWrite?: boolean;
42
+ }): Promise<unknown>;
41
43
  }
42
44
  export interface SetupEnvFilePort {
43
45
  read(): Promise<string>;
@@ -101,7 +101,8 @@ export class ConsoleSetupService {
101
101
  if (Object.keys(envUpdates).length) {
102
102
  await this.#options.envFile.write(upsertDotenv(await this.#options.envFile.read(), envUpdates));
103
103
  }
104
- await this.#options.config.setKey('database', block);
104
+ // The setup flow writes `.env` itself (bootstrap / owner): secret literals may go there too.
105
+ await this.#options.config.setKey('database', block, { environmentWrite: true });
105
106
  const notice = prepared.dialect === 'pg' || prepared.dialect === 'mysql' ? `${SETUP_NO_MIGRATION_NOTICE}${SETUP_CONSOLE_ROLE_NOTICE}` : SETUP_NO_MIGRATION_NOTICE;
106
107
  return Object.freeze({ restartRequired: true, notice });
107
108
  }
@@ -0,0 +1,18 @@
1
+ export declare const PNPM_IGNORED_BUILDS_CODE = "ERR_PNPM_IGNORED_BUILDS";
2
+ /** Cap on the package-manager output kept to recognise the error (its summary comes last). */
3
+ export declare const IGNORED_BUILDS_OUTPUT_LIMIT: number;
4
+ /**
5
+ * The packages pnpm reports in `Ignored build scripts: a@1.0.0, @scope/b@2.0.0.`; `undefined` when
6
+ * the output does not carry `ERR_PNPM_IGNORED_BUILDS` (an empty list when the list is unreadable).
7
+ */
8
+ export declare function parseIgnoredBuilds(output: string): readonly string[] | undefined;
9
+ /** Package names named by an `add` invocation (`add [-w] [-D] <spec>…`); empty for other commands. */
10
+ export declare function addedPackageNames(args: readonly string[]): readonly string[];
11
+ /**
12
+ * Whether the dependencies of `args` are in place after pnpm reported ignored builds: pnpm wrote
13
+ * `node_modules/.modules.yaml` (it does so after linking) and every package an `add` named is in
14
+ * `node_modules`. Only then is the exit treated as a warning.
15
+ */
16
+ export declare function dependenciesInPlace(projectRoot: string, args: readonly string[]): Promise<boolean>;
17
+ /** The hint printed for ignored builds: same allowance the scaffold writes for native packages. */
18
+ export declare function ignoredBuildsHint(names: readonly string[]): string;