@zhin.js/gateway 1.1.2 → 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 (60) hide show
  1. package/README.md +5 -5
  2. package/lib/config/configuration.d.ts +16 -0
  3. package/lib/config/configuration.js +32 -7
  4. package/lib/config/environment-files.d.ts +42 -0
  5. package/lib/config/environment-files.js +347 -7
  6. package/lib/config/validation-messages.d.ts +13 -0
  7. package/lib/config/validation-messages.js +59 -0
  8. package/lib/database/config.js +31 -0
  9. package/lib/database/console-role.d.ts +35 -0
  10. package/lib/database/console-role.js +108 -0
  11. package/lib/database/gateway-store.d.ts +43 -0
  12. package/lib/database/gateway-store.js +90 -0
  13. package/lib/database/index.d.ts +2 -0
  14. package/lib/database/index.js +2 -0
  15. package/lib/database/project-database.d.ts +45 -0
  16. package/lib/database/project-database.js +212 -6
  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 +15 -8
  20. package/lib/http/gateway-console.js +134 -50
  21. package/lib/http/gateway-http-server.js +39 -1
  22. package/lib/http/plugin-switch-rpc.js +11 -6
  23. package/lib/http/start-gateway.js +9 -0
  24. package/lib/iam/index.d.ts +4 -3
  25. package/lib/iam/index.js +4 -3
  26. package/lib/iam/install.d.ts +17 -11
  27. package/lib/iam/install.js +39 -18
  28. package/lib/iam/legacy-import.d.ts +151 -0
  29. package/lib/iam/legacy-import.js +600 -0
  30. package/lib/iam/local-admin.d.ts +2 -0
  31. package/lib/iam/local-admin.js +6 -1
  32. package/lib/iam/routes.js +10 -2
  33. package/lib/iam/rpc.js +1 -1
  34. package/lib/iam/service.d.ts +31 -0
  35. package/lib/iam/service.js +49 -4
  36. package/lib/iam/setup-service.d.ts +2 -0
  37. package/lib/iam/setup-service.js +11 -4
  38. package/lib/iam/tables.d.ts +6 -6
  39. package/lib/iam/tables.js +6 -8
  40. package/lib/local/protocol.d.ts +6 -0
  41. package/lib/local/protocol.js +6 -0
  42. package/lib/packages/errors.js +1 -0
  43. package/lib/packages/rpc.d.ts +6 -1
  44. package/lib/packages/rpc.js +28 -2
  45. package/lib/packages/service.d.ts +3 -1
  46. package/lib/packages/service.js +22 -4
  47. package/lib/plugin-management/use-cases.js +8 -8
  48. package/lib/plugin-management/workflow.d.ts +8 -2
  49. package/lib/plugin-management/workflow.js +28 -10
  50. package/lib/supervisor/instance-supervisor.d.ts +4 -1
  51. package/lib/supervisor/instance-supervisor.js +18 -1
  52. package/lib/terminal/modules.d.ts +12 -0
  53. package/lib/terminal/modules.js +38 -2
  54. package/lib/usage/aggregate.d.ts +64 -3
  55. package/lib/usage/aggregate.js +199 -26
  56. package/lib/usage/prompt-cache.d.ts +49 -0
  57. package/lib/usage/prompt-cache.js +113 -0
  58. package/lib/usage.d.ts +1 -0
  59. package/lib/usage.js +1 -0
  60. package/package.json +7 -7
package/lib/iam/rpc.js CHANGED
@@ -18,7 +18,7 @@ export async function dispatchIamRpc(message, service, principal) {
18
18
  const ok = (data) => ({ status: 200, body: { success: true, data, type, requestId } });
19
19
  try {
20
20
  if (!service?.ready)
21
- throw new IamError('unavailable', '当前数据库不能存储账号,或尚未就绪');
21
+ throw new IamError('unavailable', service?.publicUnavailable()?.message ?? 'Console 访问控制尚未就绪');
22
22
  const id = (key = 'id') => {
23
23
  const value = payload[key];
24
24
  if (typeof value !== 'string' || !value)
@@ -83,7 +83,24 @@ export interface ConsoleIamServiceOptions {
83
83
  * Without it, levels are inferred from the permission suffix.
84
84
  */
85
85
  readonly pluginPermissions?: () => readonly ConsolePluginPermissionInfo[];
86
+ /**
87
+ * Why access control is closed right now (gateway.db not open, the import of the old project
88
+ * database tables not finished, ADR 0059 §3), or undefined. While closed the service fails
89
+ * closed: {@link ConsoleIamService.initialized} is true (the bootstrap token is refused),
90
+ * nothing authenticates and `/auth/*` answers 503 — an empty store never reads as "no owner".
91
+ */
92
+ readonly unavailable?: () => string | undefined;
93
+ /**
94
+ * Category of `unavailable` (`starting`, `store`, `migration`, `store_lost`, …). Anonymous HTTP
95
+ * callers only ever see this code and a generic message; the detail (paths, driver messages)
96
+ * goes to the logs and to the local owner (`zhin doctor`, `zhin console migrate`).
97
+ */
98
+ readonly unavailableCode?: () => string | undefined;
99
+ /** Called after the first owner was created (the gateway then makes sure its store marker exists). */
100
+ readonly onOwnerCreated?: () => void;
86
101
  }
102
+ /** What anonymous callers learn while access control is closed (no paths or driver messages). */
103
+ export declare const IAM_UNAVAILABLE_PUBLIC_MESSAGE = "Console \u8BBF\u95EE\u63A7\u5236\u6682\u4E0D\u53EF\u7528\uFF1A\u8BF7\u5728\u670D\u52A1\u5668\u4E0A\u8FD0\u884C zhin doctor \u67E5\u770B\u539F\u56E0";
87
104
  /**
88
105
  * What is wrong with a new account's username / password, or null — the rules
89
106
  * {@link ConsoleIamService.createUser} / `createOwner` enforce, for callers that must check before
@@ -104,6 +121,13 @@ export declare class ConsoleIamService implements ConsoleAccessProvider {
104
121
  #private;
105
122
  constructor(options: ConsoleIamServiceOptions);
106
123
  get ready(): boolean;
124
+ /** See {@link ConsoleIamServiceOptions.unavailable} (detail: logs and the local owner only). */
125
+ unavailableReason(): string | undefined;
126
+ /** `{ code, message }` safe for any HTTP caller while closed, or undefined. */
127
+ publicUnavailable(): {
128
+ readonly code: string;
129
+ readonly message: string;
130
+ } | undefined;
107
131
  /** Seeds built-in roles and loads the owner state; call after the database started. */
108
132
  start(options?: Readonly<{
109
133
  timers?: boolean;
@@ -201,3 +225,10 @@ export declare class ConsoleIamService implements ConsoleAccessProvider {
201
225
  /** Daily: audit older than the retention window and expired sessions. */
202
226
  cleanup(): Promise<void>;
203
227
  }
228
+ /** Longest stored audit `detail` (characters of JSON). */
229
+ export declare const AUDIT_DETAIL_MAX_LENGTH = 4096;
230
+ /**
231
+ * Audit detail as JSON that always parses back: a detail longer than the column budget is
232
+ * replaced by `{ truncated, length, preview }` instead of being cut mid-string.
233
+ */
234
+ export declare function auditDetailJson(detail: unknown): string;
@@ -21,6 +21,8 @@ export class IamError extends Error {
21
21
  this.status = STATUS[code];
22
22
  }
23
23
  }
24
+ /** What anonymous callers learn while access control is closed (no paths or driver messages). */
25
+ export const IAM_UNAVAILABLE_PUBLIC_MESSAGE = 'Console 访问控制暂不可用:请在服务器上运行 zhin doctor 查看原因';
24
26
  const USERNAME = /^[a-z0-9][a-z0-9._-]{1,63}$/u;
25
27
  /**
26
28
  * What is wrong with a new account's username / password, or null — the rules
@@ -48,6 +50,9 @@ export class ConsoleIamService {
48
50
  #limiter;
49
51
  #onError;
50
52
  #pluginPermissions;
53
+ #unavailable;
54
+ #unavailableCode;
55
+ #onOwnerCreated;
51
56
  #ready = false;
52
57
  #initialized;
53
58
  #cleanupTimer;
@@ -64,9 +69,22 @@ export class ConsoleIamService {
64
69
  this.#limiter = options.rateLimiter ?? new LoginRateLimiter({ now: this.#now });
65
70
  this.#onError = options.onError ?? (() => undefined);
66
71
  this.#pluginPermissions = options.pluginPermissions ?? (() => []);
72
+ this.#unavailable = options.unavailable ?? (() => undefined);
73
+ this.#unavailableCode = options.unavailableCode ?? (() => undefined);
74
+ this.#onOwnerCreated = options.onOwnerCreated;
67
75
  }
68
76
  get ready() {
69
- return this.#ready;
77
+ return this.#ready && this.#unavailable() === undefined;
78
+ }
79
+ /** See {@link ConsoleIamServiceOptions.unavailable} (detail: logs and the local owner only). */
80
+ unavailableReason() {
81
+ return this.#unavailable();
82
+ }
83
+ /** `{ code, message }` safe for any HTTP caller while closed, or undefined. */
84
+ publicUnavailable() {
85
+ if (this.#unavailable() === undefined)
86
+ return undefined;
87
+ return Object.freeze({ code: this.#unavailableCode() ?? 'unavailable', message: IAM_UNAVAILABLE_PUBLIC_MESSAGE });
70
88
  }
71
89
  /** Seeds built-in roles and loads the owner state; call after the database started. */
72
90
  async start(options = {}) {
@@ -115,6 +133,8 @@ export class ConsoleIamService {
115
133
  clearInterval(this.#refreshTimer);
116
134
  }
117
135
  initialized() {
136
+ if (this.#unavailable() !== undefined)
137
+ return true;
118
138
  if (this.#initialized === true)
119
139
  return true;
120
140
  // Database not up yet, or another process (`zhin console init` beside a running gateway)
@@ -156,7 +176,7 @@ export class ConsoleIamService {
156
176
  }
157
177
  // ------------------------------------------------------------------ authentication
158
178
  async authenticate(token, meta = {}) {
159
- if (!this.#ready || !token || token.length > 256)
179
+ if (!this.ready || !token || token.length > 256)
160
180
  return null;
161
181
  const hash = sha256(token);
162
182
  const now = this.#now();
@@ -199,7 +219,7 @@ export class ConsoleIamService {
199
219
  action: entry.action.slice(0, 256),
200
220
  target: entry.target?.slice(0, 256) ?? null,
201
221
  outcome: entry.outcome,
202
- detail: entry.detail ? JSON.stringify(entry.detail).slice(0, 4096) : null,
222
+ detail: entry.detail ? auditDetailJson(entry.detail) : null,
203
223
  ip: entry.ip?.slice(0, 64) ?? null,
204
224
  }).catch(this.#onError);
205
225
  }
@@ -312,7 +332,14 @@ export class ConsoleIamService {
312
332
  this.#requireReady();
313
333
  if ((await this.#activeOwnerIds()).size > 0)
314
334
  throw new IamError('conflict', '已初始化:所有者已存在');
315
- return this.#createUser({ ...input, bindings: [{ role: 'owner', scopeKind: 'global' }] });
335
+ const owner = await this.#createUser({ ...input, bindings: [{ role: 'owner', scopeKind: 'global' }] });
336
+ try {
337
+ this.#onOwnerCreated?.();
338
+ }
339
+ catch (error) {
340
+ this.#onError(error);
341
+ }
342
+ return owner;
316
343
  });
317
344
  }
318
345
  /** Issues a session for a user (setup returns one for the new owner). */
@@ -782,3 +809,21 @@ function auditView(row) {
782
809
  ip: row.ip === null || row.ip === undefined ? null : String(row.ip),
783
810
  });
784
811
  }
812
+ /** Longest stored audit `detail` (characters of JSON). */
813
+ export const AUDIT_DETAIL_MAX_LENGTH = 4096;
814
+ /**
815
+ * Audit detail as JSON that always parses back: a detail longer than the column budget is
816
+ * replaced by `{ truncated, length, preview }` instead of being cut mid-string.
817
+ */
818
+ export function auditDetailJson(detail) {
819
+ const json = JSON.stringify(detail) ?? 'null';
820
+ if (json.length <= AUDIT_DETAIL_MAX_LENGTH)
821
+ return json;
822
+ let keep = AUDIT_DETAIL_MAX_LENGTH - 64;
823
+ for (;;) {
824
+ const replacement = JSON.stringify({ truncated: true, length: json.length, preview: json.slice(0, Math.max(0, keep)) });
825
+ if (replacement.length <= AUDIT_DETAIL_MAX_LENGTH || keep <= 0)
826
+ return replacement;
827
+ keep -= replacement.length - AUDIT_DETAIL_MAX_LENGTH;
828
+ }
829
+ }
@@ -55,6 +55,8 @@ export interface ConsoleSetupServiceOptions {
55
55
  readonly driverAvailable?: (dialect: SetupDialect) => Promise<boolean>;
56
56
  }
57
57
  export declare const SETUP_NO_MIGRATION_NOTICE = "\u5207\u6362\u6570\u636E\u5E93\u4E0D\u4F1A\u8FC1\u79FB\u65E7\u5E93\u6570\u636E\uFF1A\u63D2\u4EF6\u6570\u636E\u4ECD\u7559\u5728\u539F\u6570\u636E\u5E93\u3002";
58
+ /** PostgreSQL / MySQL: raw Console SQL needs the restricted account (ADR 0059 §4). */
59
+ export declare const SETUP_CONSOLE_ROLE_NOTICE = "PostgreSQL / MySQL \u4E0A Console \u7684\u539F\u59CB SQL \u53EA\u7528\u53D7\u9650\u8D26\u53F7\u6267\u884C\uFF1A\u8FD0\u884C zhin db console-role \u521B\u5EFA\uFF08\u65E0\u5EFA\u53F7\u6743\u9650\u65F6\u52A0 --print-sql \u4EA4\u7ED9 DBA\uFF09\u3002";
58
60
  /**
59
61
  * The one setup flow (ADR 0049 §7): `/auth/setup*` and `zhin console init` are thin wrappers.
60
62
  * Database changes are written to the config file / `.env`; restarting is the caller's job.
@@ -3,7 +3,6 @@ import { isDatabaseDriverAvailable, probeDatabaseConnection, } from '@zhin.js/da
3
3
  import { expandEnvironmentValue } from '@zhin.js/plugin-runtime';
4
4
  import { DATABASE_DIALECT_DEFINITIONS, validateDatabaseConfig, } from '@zhin.js/scaffold-wizard';
5
5
  import { IamError } from './service.js';
6
- import { IAM_DIALECTS } from './tables.js';
7
6
  const DEFAULT_SQLITE_FILE = '.zhin/data.sqlite';
8
7
  const SECRET_FIELDS = new Set(['password']);
9
8
  const ENV_REFERENCE = /^\$\{[A-Za-z_][A-Za-z0-9_]*\}$/u;
@@ -34,7 +33,14 @@ function fieldsOf(definition) {
34
33
  })];
35
34
  }));
36
35
  }
37
- const SETUP_DEFINITIONS = DATABASE_DIALECT_DEFINITIONS.filter(definition => IAM_DIALECTS.has(definition.dialect));
36
+ /**
37
+ * Dialects the wizard can test a connection for. Console accounts no longer depend on it: they
38
+ * live in `.zhin/gateway.db` (ADR 0059); a redis / memory project keeps its database block.
39
+ */
40
+ const SETUP_DIALECTS = new Set(['sqlite', 'mysql', 'pg', 'mongodb']);
41
+ const SETUP_DEFINITIONS = DATABASE_DIALECT_DEFINITIONS.filter(definition => SETUP_DIALECTS.has(definition.dialect));
42
+ /** PostgreSQL / MySQL: raw Console SQL needs the restricted account (ADR 0059 §4). */
43
+ export const SETUP_CONSOLE_ROLE_NOTICE = 'PostgreSQL / MySQL 上 Console 的原始 SQL 只用受限账号执行:运行 zhin db console-role 创建(无建号权限时加 --print-sql 交给 DBA)。';
38
44
  /**
39
45
  * The one setup flow (ADR 0049 §7): `/auth/setup*` and `zhin console init` are thin wrappers.
40
46
  * Database changes are written to the config file / `.env`; restarting is the caller's job.
@@ -96,12 +102,13 @@ export class ConsoleSetupService {
96
102
  await this.#options.envFile.write(upsertDotenv(await this.#options.envFile.read(), envUpdates));
97
103
  }
98
104
  await this.#options.config.setKey('database', block);
99
- return Object.freeze({ restartRequired: true, notice: SETUP_NO_MIGRATION_NOTICE });
105
+ const notice = prepared.dialect === 'pg' || prepared.dialect === 'mysql' ? `${SETUP_NO_MIGRATION_NOTICE}${SETUP_CONSOLE_ROLE_NOTICE}` : SETUP_NO_MIGRATION_NOTICE;
106
+ return Object.freeze({ restartRequired: true, notice });
100
107
  }
101
108
  async createOwner(input) {
102
109
  const iam = this.#options.iam();
103
110
  if (!iam?.ready)
104
- throw new IamError('unavailable', '当前数据库不能存储账号,或尚未就绪');
111
+ throw new IamError('unavailable', iam?.publicUnavailable()?.message ?? 'Console 访问控制尚未就绪');
105
112
  if (iam.initialized())
106
113
  throw new IamError('conflict', '已初始化:请改用 zhin console user 管理用户');
107
114
  return { user: await iam.createOwner(input) };
@@ -1,4 +1,7 @@
1
- /** Console access-control tables (ADR 0049 §2). Defined by the composition root before DB start. */
1
+ /**
2
+ * Console access-control tables (ADR 0049 §2). Since ADR 0059 they live in the gateway's own store
3
+ * `.zhin/gateway.db` (SQLite), whatever the project database is.
4
+ */
2
5
  export declare const IAM_TABLES: Readonly<{
3
6
  readonly users: "console_users";
4
7
  readonly roles: "console_roles";
@@ -8,13 +11,10 @@ export declare const IAM_TABLES: Readonly<{
8
11
  readonly audit: "console_audit_log";
9
12
  }>;
10
13
  export declare const IAM_TABLE_DEFINITIONS: Readonly<Record<string, Record<string, unknown>>>;
11
- /** Dialects that can hold accounts (redis / memory cannot, ADR 0049 §8). */
12
- export declare const IAM_DIALECTS: ReadonlySet<string>;
13
14
  export interface IamTableHost {
14
- readonly dialect: string;
15
15
  readonly started: boolean;
16
16
  define(name: string, definition: Record<string, unknown>): void;
17
17
  tables(): readonly string[];
18
18
  }
19
- /** Idempotent; must run before the database starts. Returns false for unsupported dialects. */
20
- export declare function defineConsoleIamTables(host: IamTableHost): boolean;
19
+ /** Idempotent; must run before the database starts. */
20
+ export declare function defineConsoleIamTables(host: IamTableHost): void;
package/lib/iam/tables.js CHANGED
@@ -1,4 +1,7 @@
1
- /** Console access-control tables (ADR 0049 §2). Defined by the composition root before DB start. */
1
+ /**
2
+ * Console access-control tables (ADR 0049 §2). Since ADR 0059 they live in the gateway's own store
3
+ * `.zhin/gateway.db` (SQLite), whatever the project database is.
4
+ */
2
5
  export const IAM_TABLES = Object.freeze({
3
6
  users: 'console_users',
4
7
  roles: 'console_roles',
@@ -65,18 +68,13 @@ export const IAM_TABLE_DEFINITIONS = Object.freeze({
65
68
  ip: text(true),
66
69
  },
67
70
  });
68
- /** Dialects that can hold accounts (redis / memory cannot, ADR 0049 §8). */
69
- export const IAM_DIALECTS = new Set(['sqlite', 'mysql', 'pg', 'mongodb']);
70
- /** Idempotent; must run before the database starts. Returns false for unsupported dialects. */
71
+ /** Idempotent; must run before the database starts. */
71
72
  export function defineConsoleIamTables(host) {
72
- if (!IAM_DIALECTS.has(host.dialect))
73
- return false;
74
73
  if (host.started)
75
- return true;
74
+ return;
76
75
  const existing = new Set(host.tables());
77
76
  for (const [name, definition] of Object.entries(IAM_TABLE_DEFINITIONS)) {
78
77
  if (!existing.has(name))
79
78
  host.define(name, definition);
80
79
  }
81
- return true;
82
80
  }
@@ -33,6 +33,12 @@ export declare const LOCAL_GATEWAY_RPC: Readonly<{
33
33
  readonly CONSOLE_USER_PASSWD: "gateway.console.user.passwd";
34
34
  /** `{ username, disabled }`. */
35
35
  readonly CONSOLE_USER_DISABLE: "gateway.console.user.disable";
36
+ /**
37
+ * `zhin console migrate` / `zhin doctor` beside a running gateway (ADR 0059 §3): the state of the
38
+ * import of the old `console_*` tables (`{ record, pending?, reappeared?, problem? }`), read by
39
+ * the gateway, the only process that opens `.zhin/gateway.db` while it runs.
40
+ */
41
+ readonly CONSOLE_MIGRATION_STATUS: "gateway.console.migration.status";
36
42
  /**
37
43
  * `{base}/…` request on behalf of the local owner ({@link LocalGatewayHttpRequest}): what
38
44
  * `zhin config` / `schedule` / `send` / `watch` use while a gateway runs, so they need no
@@ -39,6 +39,12 @@ export const LOCAL_GATEWAY_RPC = Object.freeze({
39
39
  CONSOLE_USER_PASSWD: 'gateway.console.user.passwd',
40
40
  /** `{ username, disabled }`. */
41
41
  CONSOLE_USER_DISABLE: 'gateway.console.user.disable',
42
+ /**
43
+ * `zhin console migrate` / `zhin doctor` beside a running gateway (ADR 0059 §3): the state of the
44
+ * import of the old `console_*` tables (`{ record, pending?, reappeared?, problem? }`), read by
45
+ * the gateway, the only process that opens `.zhin/gateway.db` while it runs.
46
+ */
47
+ CONSOLE_MIGRATION_STATUS: 'gateway.console.migration.status',
42
48
  /**
43
49
  * `{base}/…` request on behalf of the local owner ({@link LocalGatewayHttpRequest}): what
44
50
  * `zhin config` / `schedule` / `send` / `watch` use while a gateway runs, so they need no
@@ -2,6 +2,7 @@ const STATUS = {
2
2
  invalid_input: 400,
3
3
  forbidden: 403,
4
4
  unmapped: 403,
5
+ unknown_rpc: 404,
5
6
  conflict: 409,
6
7
  unavailable: 503,
7
8
  internal: 500,
@@ -3,7 +3,7 @@
3
3
  * `gateway.instance.*`, ADR 0054 §4 / §5). The HTTP server maps envelopes onto it; the permission
4
4
  * map in `@zhin.js/console-protocol` is checked here, then each service applies its own rules.
5
5
  */
6
- import { type GatewayInstanceStatus, type GatewayRpcRequest, type GatewayRpcResponse, type GatewayRpcType } from '@zhin.js/console-protocol';
6
+ import { type GatewayInstanceStatus, type GatewayRpcRequest, type GatewayRpcResponse, type GatewayRpcType, type ConsoleProcessSample } from '@zhin.js/console-protocol';
7
7
  import type { InstanceStatus, InstanceStopOptions } from '../supervisor/instance-supervisor.js';
8
8
  import type { InstanceLogLine } from '../supervisor/instance-logs.js';
9
9
  import { type GatewayPrincipal } from './authorization.js';
@@ -28,3 +28,8 @@ export declare function createGatewayRpcDispatcher(dependencies: GatewayRpcDepen
28
28
  /** Dispatches one gateway RPC; never throws (errors become `{ ok: false }`). */
29
29
  export declare function dispatchGatewayRpc(request: GatewayRpcRequest, principal: GatewayPrincipal | undefined, dependencies: GatewayRpcDependencies): Promise<GatewayRpcResponse>;
30
30
  export declare function instanceStatus(status: InstanceStatus): GatewayInstanceStatus;
31
+ /**
32
+ * The gateway's own process sample (#1250): one `process.memoryUsage()` call, labeled
33
+ * `role: 'gateway'`, so the Console never mixes it with the instance's numbers.
34
+ */
35
+ export declare function sampleGatewayProcess(): ConsoleProcessSample | undefined;
@@ -20,8 +20,9 @@ export function createGatewayRpcDispatcher(dependencies) {
20
20
  export async function dispatchGatewayRpc(request, principal, dependencies) {
21
21
  try {
22
22
  const type = request?.type;
23
+ // zhinjs/zhin#1250: a `gateway.*` type this gateway does not have is not a refusal (not audited).
23
24
  if (!isGatewayRpcType(type))
24
- throw new GatewayError('unmapped', `RPC "${String(type ?? '').slice(0, 64)}" 未登记权限`);
25
+ throw new GatewayError('unknown_rpc', `RPC "${String(type ?? '').slice(0, 64)}" 不存在`);
25
26
  if (!principal)
26
27
  throw new GatewayError('forbidden', '未登录');
27
28
  const packageMutation = type === GATEWAY_RPC.PLAN || type === GATEWAY_RPC.INSTALL;
@@ -69,7 +70,10 @@ async function handle(type, data, principal, deps) {
69
70
  return packagesOf(deps).setEnabled({ plugin: data.plugin }, type === 'gateway.plugins.enable', {
70
71
  principal, ...optionalBoolean(data, 'startInstance'),
71
72
  });
72
- case 'gateway.instance.status': return instanceStatus(instanceOf(deps).status());
73
+ case 'gateway.instance.status': {
74
+ const gateway = sampleGatewayProcess();
75
+ return Object.freeze({ ...instanceStatus(instanceOf(deps).status()), ...(gateway ? { gateway } : {}) });
76
+ }
73
77
  case 'gateway.instance.logs': {
74
78
  const lines = data.lines === undefined ? DEFAULT_LOG_LINES : data.lines;
75
79
  if (typeof lines !== 'number' || !Number.isInteger(lines) || lines < 1 || lines > MAX_LOG_LINES) {
@@ -126,6 +130,28 @@ export function instanceStatus(status) {
126
130
  recentRestarts: status.recentRestarts,
127
131
  });
128
132
  }
133
+ /**
134
+ * The gateway's own process sample (#1250): one `process.memoryUsage()` call, labeled
135
+ * `role: 'gateway'`, so the Console never mixes it with the instance's numbers.
136
+ */
137
+ export function sampleGatewayProcess() {
138
+ try {
139
+ const memory = process.memoryUsage();
140
+ return Object.freeze({
141
+ role: 'gateway',
142
+ pid: process.pid,
143
+ uptime: process.uptime(),
144
+ memory: Object.freeze({
145
+ rss: memory.rss, heapUsed: memory.heapUsed, heapTotal: memory.heapTotal,
146
+ external: memory.external, arrayBuffers: memory.arrayBuffers,
147
+ }),
148
+ sampledAt: Date.now(),
149
+ });
150
+ }
151
+ catch {
152
+ return undefined;
153
+ }
154
+ }
129
155
  function record(value) {
130
156
  if (value === undefined || value === null)
131
157
  return {};
@@ -1,4 +1,4 @@
1
- import type { GatewayAuthorizedPackage, GatewayPackageEvent, GatewayPackageList, GatewayPackageOperation, GatewayPackagePlan, GatewayPackageTransactionResult } from '@zhin.js/console-protocol';
1
+ import type { GatewayAuthorizedPackage, GatewayInstalledPackageKind, GatewayPackageEvent, GatewayPackageList, GatewayPackageOperation, GatewayPackagePlan, GatewayPackageTransactionResult } from '@zhin.js/console-protocol';
2
2
  import { type InstallAllowlist, type MarketCatalogEntry } from '../onboarding/allowlist.js';
3
3
  import { type PackageManagerOutput } from '../onboarding/package-manager.js';
4
4
  import { type PluginManagementOptions } from '../plugin-management/workflow.js';
@@ -137,3 +137,5 @@ export declare class GatewayPackageService {
137
137
  readonly result: Promise<GatewayPackageTransactionResult>;
138
138
  };
139
139
  }
140
+ /** `package.json#zhin` → what the dependency is to Zhin; anything without a Zhin manifest is a library. */
141
+ export declare function installedPackageKind(zhin: unknown): GatewayInstalledPackageKind;
@@ -90,7 +90,12 @@ export class GatewayPackageService {
90
90
  if (typeof range !== 'string')
91
91
  continue;
92
92
  const catalog = allowlist.get(name)?.kind;
93
- packages.push(Object.freeze({ name, range, dependencyType, installedVersion: await this.#installedVersion(name), ...(catalog ? { catalog } : {}) }));
93
+ const installed = await this.#installedManifest(name);
94
+ packages.push(Object.freeze({
95
+ name, range, dependencyType, installedVersion: installed?.version ?? null,
96
+ ...(installed ? { kind: installed.kind } : {}),
97
+ ...(catalog ? { catalog } : {}),
98
+ }));
94
99
  }
95
100
  }
96
101
  packages.sort((a, b) => a.name.localeCompare(b.name));
@@ -416,13 +421,19 @@ export class GatewayPackageService {
416
421
  #instanceState() {
417
422
  return this.#options.instance?.state ?? 'unmanaged';
418
423
  }
419
- async #installedVersion(name) {
424
+ /**
425
+ * Installed version plus what the package is to Zhin (#1250): `package.json#zhin.type` `plugin`
426
+ * → `plugin`, `feature` → `feature`, no `zhin` manifest → `library`. Undefined when not installed.
427
+ */
428
+ async #installedManifest(name) {
420
429
  try {
421
430
  const manifest = JSON.parse(await readFile(join(this.#root, 'node_modules', ...name.split('/'), 'package.json'), 'utf8'));
422
- return manifest.name === name && typeof manifest.version === 'string' ? manifest.version : null;
431
+ if (manifest.name !== name || typeof manifest.version !== 'string')
432
+ return undefined;
433
+ return { version: manifest.version, kind: installedPackageKind(manifest.zhin) };
423
434
  }
424
435
  catch {
425
- return null;
436
+ return undefined;
426
437
  }
427
438
  }
428
439
  /** Cross-process exclusion (CLI and gateway); separate from the workflow's own lock file. */
@@ -502,3 +513,10 @@ function failureMessage(error) {
502
513
  const cause = error instanceof PluginManagementOperationError ? error.cause : error;
503
514
  return cause instanceof Error ? cause.message : String(cause);
504
515
  }
516
+ /** `package.json#zhin` → what the dependency is to Zhin; anything without a Zhin manifest is a library. */
517
+ export function installedPackageKind(zhin) {
518
+ if (!zhin || typeof zhin !== 'object' || Array.isArray(zhin))
519
+ return 'library';
520
+ const type = zhin.type;
521
+ return type === 'plugin' ? 'plugin' : type === 'feature' ? 'feature' : 'library';
522
+ }
@@ -1,11 +1,11 @@
1
1
  export const pluginUseCases = Object.freeze([
2
2
  {
3
- id: 'rss-subscription', title: '订阅 RSS 新文章', summary: '在任务中心选择精确目标,创建独立管理员 RSS 订阅。',
3
+ id: 'rss-subscription', title: '订阅 RSS 新文章', summary: '在 Console「运维 → 定时任务 → 管理员任务」选择精确目标,创建独立管理员 RSS 订阅。',
4
4
  packageName: '@zhin.js/plugin-rss', mode: 'administrator-task',
5
5
  steps: ['通过现有安装计划安装 RSS 插件,配置持久 Database Host、Schedule Host 与支持精确目标的 Outbound Host,保存并应用。',
6
- '在 Console 任务中心选择当前已加载的 RSS 实例,核对账号、会话、父容器和线程。',
6
+ '在 Console「运维 → 定时任务」的「管理员任务」页签选择当前已加载的 RSS 实例,核对账号、会话、父容器和线程。',
7
7
  '填写 HTTP(S) RSS / Atom 地址,阅读抓取和发送影响后创建;保存不会抓取源站或发送测试消息。',
8
- '等待实例轮询首次成功抓取建立基线;后续新文章才推送,可在任务中心暂停、编辑、恢复或取消。'],
8
+ '等待实例轮询首次成功抓取建立基线;后续新文章才推送,可在「运维 → 定时任务 → 管理员任务」暂停、编辑、恢复或取消。'],
9
9
  limitations: ['首次和暂停恢复后的首次成功抓取只建基线,不补发历史;每轮只检查源前 20 条,最多展示 maxItems 条,其余提示查看源站,不续轮补发。',
10
10
  '管理员订阅与聊天订阅独立;聊天 rss-list、rss-remove 和 rss-check 不管理或触发管理员任务。',
11
11
  '投递未确认时不自动重试或恢复,只能取消;先核实目标会话,确需继续时再新建。'],
@@ -14,7 +14,7 @@ export const pluginUseCases = Object.freeze([
14
14
  id: 'reminder', title: '创建定时提醒', summary: '指定账号、会话与时间,创建可查看、暂停和取消的管理员提醒。',
15
15
  packageName: '@zhin.js/plugin-reminder', mode: 'administrator-task',
16
16
  steps: ['通过现有安装计划安装持久提醒插件,配置 Database Host 并应用。',
17
- '在 Console 任务中心选择当前已加载的提醒实例,检查当前版本与可用表单。',
17
+ '在 Console「运维 → 定时任务」的「管理员任务」页签选择当前已加载的提醒实例,检查当前版本与可用表单。',
18
18
  '选择精确投递账号及会话,填写含时区的时间和内容,确认未来发送的效果后创建。',
19
19
  '查看下次执行时间与实际结果;投递不确定时先核对目标会话,不盲目重建。'],
20
20
  limitations: ['Console 只管理管理员任务;聊天创建的提醒仍归原发送者。',
@@ -54,11 +54,11 @@ export const pluginUseCases = Object.freeze([
54
54
  ].map(item => Object.freeze({ ...item, steps: Object.freeze(item.steps), limitations: Object.freeze(item.limitations) })));
55
55
  const englishUseCases = {
56
56
  'rss-subscription': {
57
- title: 'Subscribe to RSS updates', summary: 'Choose an exact delivery target and create a separate administrator RSS subscription.',
57
+ title: 'Subscribe to RSS updates', summary: 'In Operations → Scheduled tasks → Administrator tasks, choose an exact delivery target and create a separate administrator RSS subscription.',
58
58
  steps: ['Install the RSS plugin through the existing installation plan. Configure persistent Database, Schedule and Outbound Hosts with exact-target support, then save and apply.',
59
- 'Select a loaded RSS instance in Console Tasks. Check its account, conversation, parent container and thread.',
59
+ 'In Console, open Operations → Scheduled tasks → Administrator tasks and select a loaded RSS instance. Check its account, conversation, parent container and thread.',
60
60
  'Enter an HTTP(S) RSS or Atom URL, review fetching and delivery effects, then create. Saving does not fetch the feed or send a test message.',
61
- 'Wait for the first successful poll to establish a baseline. Only later articles are delivered. Use Tasks to pause, edit, resume or cancel.'],
61
+ 'Wait for the first successful poll to establish a baseline. Only later articles are delivered. Pause, edit, resume or cancel it under Operations → Scheduled tasks → Administrator tasks.'],
62
62
  limitations: ['The first successful fetch after creation or resuming only establishes a baseline; it does not replay history. Each poll examines the first 20 feed entries and displays at most maxItems, with a link to the feed for the remainder; there is no later catch-up.',
63
63
  'Administrator subscriptions and chat subscriptions are separate. Chat rss-list, rss-remove and rss-check do not manage or trigger administrator tasks.',
64
64
  'An unconfirmed delivery is not automatically retried or resumed. Cancel it, check the destination, and create a new task only if needed.'],
@@ -66,7 +66,7 @@ const englishUseCases = {
66
66
  reminder: {
67
67
  title: 'Create a scheduled reminder', summary: 'Choose an account, conversation and time, then manage an administrator reminder.',
68
68
  steps: ['Install the persistent reminder plugin, configure a Database Host and apply.',
69
- 'Select a loaded reminder instance in Console Tasks and check its current version and form.',
69
+ 'In Console, open Operations → Scheduled tasks → Administrator tasks, select a loaded reminder instance and check its current version and form.',
70
70
  'Choose the exact account and conversation, enter a time with an offset and the content, and confirm the future delivery effect.',
71
71
  'Check the next execution time and actual result. If delivery is uncertain, inspect the destination before creating another task.'],
72
72
  limitations: ['Console manages administrator tasks only. Chat reminders still belong to the actual sender.',
@@ -1,5 +1,5 @@
1
1
  import { type SnapshotReader } from '@zhin.js/plugin-runtime';
2
- import type { PluginInstallPlan, PluginManagementPort, PluginManagementState, PluginUninstallPlan, PluginUpdatePlan, PluginVerificationResult, PluginVerificationPlan } from '@zhin.js/host-http';
2
+ import type { PluginDependencyCheck, PluginInspectOptions, PluginInstallPlan, PluginManagementPort, PluginManagementState, PluginUninstallPlan, PluginUpdatePlan, PluginVerificationResult, PluginVerificationPlan } from '@zhin.js/host-http';
3
3
  import type { ConsoleConfigExplanation, GatewayPluginConfigurationNeed } from '@zhin.js/console-protocol';
4
4
  import type { PluginManagementActivity } from './activity.js';
5
5
  export interface PluginManagementOptions {
@@ -67,7 +67,13 @@ export interface PluginEnablementPlan {
67
67
  export declare class PluginManagementWorkflow implements PluginManagementPort {
68
68
  #private;
69
69
  constructor(projectRoot: string, options?: PluginManagementOptions);
70
- inspect(request: string): Promise<PluginManagementState>;
70
+ inspect(request: string, options?: PluginInspectOptions): Promise<PluginManagementState>;
71
+ /**
72
+ * The project-wide dependency graph check `inspect` runs for every declared, enabled plugin.
73
+ * Callers inspecting many plugins (Console attention) run it once here and pass
74
+ * `{ dependencies: 'skip' }` to `inspect`.
75
+ */
76
+ checkDependencies(): Promise<PluginDependencyCheck>;
71
77
  /** Full-scope, metadata-only projection over the same file and generation observation as inspect. */
72
78
  explainConfiguration(instanceKey: string, expectedRevision: string): Promise<ConsoleConfigExplanation>;
73
79
  planInstall(request: string, options?: Pick<PluginInstallOptions, 'enable'>): Promise<PluginInstallPlan>;
@@ -209,16 +209,32 @@ export class PluginManagementWorkflow {
209
209
  const revision = config ? (await config.document.read()).revision : null;
210
210
  return { revision, projectRevision: createHash('sha256').update(JSON.stringify([source.revision, revision])).digest('hex') };
211
211
  }
212
- async inspect(request) {
212
+ async inspect(request, options = {}) {
213
213
  const lease = this.#options.snapshots?.acquire();
214
214
  try {
215
- return await this.#inspect(request, lease?.value);
215
+ return await this.#inspect(request, lease?.value, options);
216
216
  }
217
217
  finally {
218
218
  lease?.release();
219
219
  }
220
220
  }
221
- async #inspect(request, snapshot) {
221
+ /**
222
+ * The project-wide dependency graph check `inspect` runs for every declared, enabled plugin.
223
+ * Callers inspecting many plugins (Console attention) run it once here and pass
224
+ * `{ dependencies: 'skip' }` to `inspect`.
225
+ */
226
+ async checkDependencies() {
227
+ const configuration = this.#configuration();
228
+ const document = configuration ? (await configuration.document.read()).document : {};
229
+ try {
230
+ await this.#checkDependencies(undefined, document);
231
+ return Object.freeze({ status: 'passed' });
232
+ }
233
+ catch (error) {
234
+ return Object.freeze({ status: 'failed', error: errorMessage(error) });
235
+ }
236
+ }
237
+ async #inspect(request, snapshot, options = {}) {
222
238
  const { configuration, source, document, declaration, projectRevision } = await this.#configurationObservation(snapshot);
223
239
  const entries = declaration.instances;
224
240
  const target = await this.#resolveTarget(request, entries);
@@ -236,13 +252,15 @@ export class PluginManagementWorkflow {
236
252
  throw new Error('Legacy plugin lifecycle state detected; run zhin migrate instances');
237
253
  const disabled = declaration.disabled.includes(target.instanceKey);
238
254
  if (installed.plugin && declared && !disabled) {
239
- try {
240
- await this.#checkDependencies(undefined, document);
241
- dependencies = 'passed';
242
- }
243
- catch (error) {
244
- dependencies = 'failed';
245
- issues.push(errorMessage(error));
255
+ if (options.dependencies !== 'skip') {
256
+ try {
257
+ await this.#checkDependencies(undefined, document);
258
+ dependencies = 'passed';
259
+ }
260
+ catch (error) {
261
+ dependencies = 'failed';
262
+ issues.push(errorMessage(error));
263
+ }
246
264
  }
247
265
  validation = await configuration.store.validatePluginConfig(target.instanceKey, config[target.instanceKey] ?? {}, document);
248
266
  }
@@ -17,7 +17,10 @@ export interface InstanceStatus {
17
17
  readonly pid?: number;
18
18
  /** Epoch ms of the current child's `ready`. */
19
19
  readonly startedAt?: number;
20
- /** Runtime generation reported by the instance in `ready`. */
20
+ /**
21
+ * Committed Runtime generation of the current instance: reported in `ready`, then advanced by
22
+ * every route table the instance publishes after a hot reload commits (#1250).
23
+ */
21
24
  readonly generation?: number;
22
25
  /** Protocol version reported by the instance in `ready`. */
23
26
  readonly protocolVersion?: number;