@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
@@ -2,7 +2,33 @@ import { parseCron } from '@zhin.js/schedule';
2
2
  import { errorMessage } from './rpc-values.js';
3
3
  const CRON_NOT_WIRED = '持久化调度未接线:当前 Plugin Runtime ScheduleHost 仅支持插件注册的内存任务(list),' +
4
4
  '不支持 add/remove/pause/resume(需要 @zhin.js/agent 持久化调度引擎)';
5
- export function listSchedule(ctx) {
5
+ /** `titleI18n[locale]` → `titleI18n[language]` → `title`. */
6
+ function localizedTitle(job, locale) {
7
+ const map = job.titleI18n && typeof job.titleI18n === 'object' ? job.titleI18n : undefined;
8
+ if (locale && map) {
9
+ const exact = map[locale];
10
+ if (typeof exact === 'string' && exact.trim())
11
+ return exact.trim();
12
+ const language = locale.split('-')[0];
13
+ const byLanguage = map[language];
14
+ if (typeof byLanguage === 'string' && byLanguage.trim())
15
+ return byLanguage.trim();
16
+ }
17
+ return typeof job.title === 'string' && job.title.trim() ? job.title.trim() : undefined;
18
+ }
19
+ function lastRunFields(job) {
20
+ const run = job.lastRun;
21
+ if (!run || typeof run !== 'object' || typeof run.startedAt !== 'number')
22
+ return {};
23
+ return {
24
+ lastExecutedAt: run.startedAt,
25
+ ...(typeof run.finishedAt === 'number' ? { lastFinishedAt: run.finishedAt, lastDurationMs: Math.max(0, run.finishedAt - run.startedAt) } : {}),
26
+ lastStatus: run.status === 'failed' ? 'failed' : 'succeeded',
27
+ ...(run.status === 'failed' && typeof run.error === 'string' ? { lastError: run.error } : {}),
28
+ };
29
+ }
30
+ export function listSchedule(ctx, d = {}) {
31
+ const locale = typeof d.locale === 'string' && d.locale.trim() ? d.locale.trim() : undefined;
6
32
  const host = ctx.scheduleHost;
7
33
  if (!host || typeof host.list !== 'function') {
8
34
  return { error: '调度服务未配置(scheduleHost 未挂载)' };
@@ -13,17 +39,26 @@ export function listSchedule(ctx) {
13
39
  .filter((job) => !!job && typeof job === 'object'
14
40
  && typeof job.id === 'string'
15
41
  && typeof job.cron === 'string')
16
- .map((job) => ({
17
- id: job.id,
18
- cron: job.cron,
19
- ...(typeof job.description === 'string' ? { description: job.description } : {}),
20
- // console cron 页渲染字段(由 schedule host 提供时透传)
21
- expression: job.expression ?? job.cron,
22
- running: job.running ?? true,
23
- ...(job.plugin ? { plugin: job.plugin } : {}),
24
- ...(job.nextExecution
25
- ? { nextExecution: job.nextExecution } : {}),
26
- }));
42
+ .map((job) => {
43
+ const title = localizedTitle(job, locale);
44
+ return {
45
+ id: job.id,
46
+ cron: job.cron,
47
+ ...(typeof job.description === 'string' ? { description: job.description } : {}),
48
+ // #1250: plugin polling is not a user reminder; title is localized, displayName never falls back to a bare id when a title exists.
49
+ kind: job.kind === 'user-task' ? 'user-task' : 'plugin-internal',
50
+ ...(title ? { title } : {}),
51
+ ...(job.titleI18n && typeof job.titleI18n === 'object' ? { titleI18n: job.titleI18n } : {}),
52
+ displayName: title ?? (typeof job.description === 'string' && job.description ? job.description : job.id),
53
+ ...lastRunFields(job),
54
+ // console cron 页渲染字段(由 schedule host 提供时透传)
55
+ expression: job.expression ?? job.cron,
56
+ running: job.running ?? true,
57
+ ...(job.plugin ? { plugin: job.plugin } : {}),
58
+ ...(job.nextExecution
59
+ ? { nextExecution: job.nextExecution } : {}),
60
+ };
61
+ });
27
62
  return listPersistentJobs(ctx).then((persistent) => ({ data: { memory, persistent, capabilities: { persistent: !!ctx.resolveScheduleEngine?.() && ctx.fullScope } } })).catch((error) => ({ error: errorMessage(error) }));
28
63
  }
29
64
  catch (error) {
@@ -44,10 +79,14 @@ async function listPersistentJobs(ctx) {
44
79
  prompt: job.action?.prompt ?? '',
45
80
  enabled: job.enabled !== false,
46
81
  source: job.source ?? 'manual',
82
+ // Persistent jobs are created by a person or on their behalf by the Agent.
83
+ kind: 'user-task',
47
84
  createdAt: job.createdAt,
48
85
  nextExecution: job.state?.nextRunAtMs,
49
86
  ...(job.notify?.target
50
87
  ? { context: { target: job.notify.target } } : {}),
88
+ // #1250: the resolved delivery target; `resolved: false` = results cannot be delivered.
89
+ ...(ctx.describeScheduleTarget ? { target: ctx.describeScheduleTarget(job.notify) } : {}),
51
90
  lastExecutedAt: job.state?.lastExecutedAt,
52
91
  lastStatus: job.state?.lastStatus,
53
92
  }));
@@ -56,6 +95,23 @@ async function listPersistentJobs(ctx) {
56
95
  throw new Error(`读取持久化任务失败: ${errorMessage(error)}`);
57
96
  }
58
97
  }
98
+ /** The account (`adapter` / `endpoint`) a schedule delivery target sends from, if it names one. */
99
+ function deliveryAccount(target) {
100
+ if (typeof target === 'string') {
101
+ // `<adapter>:<endpoint>:<kind>:<id>`
102
+ const [adapter, endpoint] = target.split(':');
103
+ return adapter && endpoint ? { adapter, endpoint } : undefined;
104
+ }
105
+ if (!target || typeof target !== 'object')
106
+ return undefined;
107
+ const scene = target.scene;
108
+ if (!scene || typeof scene !== 'object')
109
+ return undefined;
110
+ const { platform, endpointKey } = scene;
111
+ return typeof platform === 'string' && platform && typeof endpointKey === 'string' && endpointKey
112
+ ? { adapter: platform, endpoint: endpointKey }
113
+ : undefined;
114
+ }
59
115
  export async function addCron(d, ctx) {
60
116
  const engine = ctx.resolveScheduleEngine?.();
61
117
  if (!engine)
@@ -75,6 +131,16 @@ export async function addCron(d, ctx) {
75
131
  const label = typeof d.label === 'string' && d.label.trim() ? d.label.trim() : undefined;
76
132
  const context = (d.context ?? {});
77
133
  const target = context.target ?? context.channel;
134
+ // A delivery target sends to that account on every run: `endpoint.operate` there, like
135
+ // `/message/send`, on top of `ops.schedule.write` (absent check = trusted in-process caller).
136
+ if (target && ctx.canOperateEndpoint) {
137
+ const account = deliveryAccount(target);
138
+ if (!account || !ctx.canOperateEndpoint(account.adapter, account.endpoint)) {
139
+ return { error: account
140
+ ? `没有向该账号投递的权限:需要 ${account.adapter} / ${account.endpoint} 上的 endpoint.operate`
141
+ : '投递目标需要指明账号(scene.platform 与 scene.endpointKey)' };
142
+ }
143
+ }
78
144
  const job = {
79
145
  id: `console-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 7)}`,
80
146
  ...(label ? { label } : {}),
@@ -1,13 +1,23 @@
1
- import { type ConsoleConfigApplicationStatus } from '@zhin.js/console-protocol';
1
+ import { type ConsoleConfigApplicationStatus, type ConsoleSendFailure } from '@zhin.js/console-protocol';
2
2
  import type { ConversationAddress, RuntimeConsoleRpcContext, RuntimeConsoleRpcMessage, RuntimeConsoleRpcReply } from './console-rpc.js';
3
3
  /** Canonical Console wire fields → runtime conversation address. */
4
4
  export declare function wireConversation(channelType: string, channelId: string, parent: unknown): ConversationAddress;
5
+ /**
6
+ * Structural read of an outbound failure (#1250): Core's `EndpointDeliveryError` carries `code`,
7
+ * `disposition` and the canonical receipt; host/http must not import Core, so only those fields are
8
+ * read. `endpoint not found` is the Endpoint directory's plain error. An error counts as a send
9
+ * failure only when its `code` is a known delivery code or it carries a `disposition` / `receipt`;
10
+ * anything else (a Node `ENOENT`, a database error, …) is unclassified and keeps its own message.
11
+ */
12
+ export declare function consoleSendFailure(error: unknown): ConsoleSendFailure | undefined;
5
13
  /**
6
14
  * Structural check for `config:patch-project` operations at the RPC boundary, so no project
7
15
  * port implementation receives a malformed request. The store repeats the full validation.
8
16
  */
9
17
  export declare function invalidProjectPatchOperations(patches: readonly unknown[]): string | undefined;
10
18
  export declare function dispatchHostResourceConsoleRpc(type: string, message: RuntimeConsoleRpcMessage, ctx: RuntimeConsoleRpcContext, requestId: number | string | undefined, payloads: RuntimeConsoleRpcReply[]): Promise<boolean>;
19
+ /** Longest statement text the Console SQL editor sends. */
20
+ export declare const MAX_CONSOLE_SQL_LENGTH = 100000;
11
21
  export declare function stringField(message: RuntimeConsoleRpcMessage, key: string): string;
12
22
  export declare function recordField(value: unknown): Record<string, unknown> | undefined;
13
23
  export declare function positiveInteger(value: unknown, fallback: number): number;
@@ -1,4 +1,4 @@
1
- import { CONFIG_PATCH_LIMITS, ENDPOINT_RPC, } from '@zhin.js/console-protocol';
1
+ import { CONFIG_PATCH_LIMITS, CONSOLE_SEND_FAILURE_MESSAGES, ENDPOINT_RPC, classifyConsoleSendFailure, } from '@zhin.js/console-protocol';
2
2
  /** Canonical Console wire fields → runtime conversation address. */
3
3
  export function wireConversation(channelType, channelId, parent) {
4
4
  const kind = channelType === 'group' || channelType === 'channel' ? channelType : 'private';
@@ -13,6 +13,43 @@ export function wireConversation(channelType, channelId, parent) {
13
13
  ...(parentKind && parentId ? { parent: { kind: parentKind, id: parentId } } : {}),
14
14
  };
15
15
  }
16
+ const SEND_FAILURE_STATUSES = new Set(['failed', 'rejected', 'unsupported', 'suppressed']);
17
+ /**
18
+ * Structural read of an outbound failure (#1250): Core's `EndpointDeliveryError` carries `code`,
19
+ * `disposition` and the canonical receipt; host/http must not import Core, so only those fields are
20
+ * read. `endpoint not found` is the Endpoint directory's plain error. An error counts as a send
21
+ * failure only when its `code` is a known delivery code or it carries a `disposition` / `receipt`;
22
+ * anything else (a Node `ENOENT`, a database error, …) is unclassified and keeps its own message.
23
+ */
24
+ export function consoleSendFailure(error) {
25
+ if (!(error instanceof Error))
26
+ return undefined;
27
+ const record = error;
28
+ if (typeof record.code !== 'string' || !record.code) {
29
+ return error.message === 'endpoint not found'
30
+ ? { code: 'endpoint_not_found', reason: 'endpoint_not_found', status: 'failed' }
31
+ : undefined;
32
+ }
33
+ // Every known delivery code maps to a reason other than the catch-all `failed`.
34
+ const knownCode = classifyConsoleSendFailure(record.code) !== 'failed';
35
+ const delivery = typeof record.disposition === 'string' || (!!record.receipt && typeof record.receipt === 'object');
36
+ if (!knownCode && !delivery)
37
+ return undefined;
38
+ const receipt = record.receipt && typeof record.receipt === 'object'
39
+ ? record.receipt
40
+ : undefined;
41
+ const status = record.disposition === 'unknown' || receipt?.failure?.deliveryUnknown === true
42
+ ? 'unknown'
43
+ : typeof receipt?.status === 'string' && SEND_FAILURE_STATUSES.has(receipt.status)
44
+ ? receipt.status
45
+ : record.disposition === 'rejected' ? 'rejected' : 'failed';
46
+ return {
47
+ code: record.code,
48
+ reason: classifyConsoleSendFailure(record.code, status),
49
+ status,
50
+ ...(receipt?.failure?.retryable === true ? { retryable: true } : {}),
51
+ };
52
+ }
16
53
  const FORBIDDEN_PATCH_SEGMENTS = new Set(['__proto__', 'constructor', 'prototype']);
17
54
  /**
18
55
  * Structural check for `config:patch-project` operations at the RPC boundary, so no project
@@ -129,12 +166,12 @@ export async function dispatchHostResourceConsoleRpc(type, message, ctx, request
129
166
  emit({ requestId, error: 'filename is required' });
130
167
  return true;
131
168
  }
132
- if (!ctx.readEnvFile) {
169
+ // Masked only (issue #1250): the file as written is never answered here.
170
+ if (!ctx.readEnvFileView) {
133
171
  emit({ requestId, error: 'Env read is not configured' });
134
172
  return true;
135
173
  }
136
- const content = await ctx.readEnvFile(filename);
137
- emit({ requestId, data: { content } });
174
+ emit({ requestId, data: await ctx.readEnvFileView(filename) });
138
175
  }
139
176
  catch (error) {
140
177
  emit({
@@ -144,23 +181,71 @@ export async function dispatchHostResourceConsoleRpc(type, message, ctx, request
144
181
  }
145
182
  return true;
146
183
  }
184
+ case 'env:reveal': {
185
+ try {
186
+ const filename = String(message.filename ?? '');
187
+ const key = message.key;
188
+ if (!filename) {
189
+ emit({ requestId, error: 'filename is required' });
190
+ return true;
191
+ }
192
+ if (typeof key !== 'string' || !key) {
193
+ emit({ requestId, error: 'key is required' });
194
+ return true;
195
+ }
196
+ if (!ctx.revealEnvVariable) {
197
+ emit({ requestId, error: 'Env reveal is not configured' });
198
+ return true;
199
+ }
200
+ emit({ requestId, data: await ctx.revealEnvVariable(filename, key) });
201
+ }
202
+ catch (error) {
203
+ emit({
204
+ requestId,
205
+ error: `Failed to reveal env variable: ${error instanceof Error ? error.message : String(error)}`,
206
+ });
207
+ }
208
+ return true;
209
+ }
147
210
  case 'env:save': {
148
211
  try {
149
212
  const filename = String(message.filename ?? '');
150
- const content = message.content;
151
213
  if (!filename) {
152
214
  emit({ requestId, error: 'filename is required' });
153
215
  return true;
154
216
  }
155
- if (typeof content !== 'string') {
156
- emit({ requestId, error: 'content field is required' });
217
+ const hasChanges = message.changes !== undefined;
218
+ const hasContent = message.content !== undefined;
219
+ if (hasChanges && hasContent) {
220
+ emit({ requestId, error: 'changes 与 content 只能提供一个' });
221
+ return true;
222
+ }
223
+ if (hasChanges) {
224
+ if (!ctx.updateEnvVariables) {
225
+ emit({ requestId, error: 'Env write is not configured' });
226
+ return true;
227
+ }
228
+ const { changed } = await ctx.updateEnvVariables(filename, message.changes);
229
+ emit({ requestId, data: { success: true, message: `环境文件已保存: ${filename}`, changed } });
230
+ return true;
231
+ }
232
+ if (typeof message.content !== 'string') {
233
+ emit({ requestId, error: 'changes field is required' });
157
234
  return true;
158
235
  }
159
- if (!ctx.writeEnvFile) {
236
+ // The whole-file form must be asked for: an older Console sends back what `env:get` showed it.
237
+ if (message.mode !== 'replace') {
238
+ emit({
239
+ requestId,
240
+ error: '此 Host 只接受逐键保存(changes);整文件保存需显式声明 mode: "replace"。请升级 Console 后重试',
241
+ });
242
+ return true;
243
+ }
244
+ if (!ctx.replaceEnvFile) {
160
245
  emit({ requestId, error: 'Env write is not configured' });
161
246
  return true;
162
247
  }
163
- await ctx.writeEnvFile(filename, content);
248
+ await ctx.replaceEnvFile(filename, message.content);
164
249
  emit({ requestId, data: { success: true, message: `环境文件已保存: ${filename}` } });
165
250
  }
166
251
  catch (error) {
@@ -250,15 +335,100 @@ export async function dispatchHostResourceConsoleRpc(type, message, ctx, request
250
335
  const page = positiveInteger(message.page, 1);
251
336
  const pageSize = Math.min(positiveInteger(message.pageSize, 50), 500);
252
337
  const where = recordField(message.where);
338
+ const order = recordField(message.orderBy);
339
+ if (message.orderBy !== undefined && message.orderBy !== null
340
+ && (!order || typeof order.column !== 'string' || (order.direction !== 'asc' && order.direction !== 'desc'))) {
341
+ errorReply(payloads, requestId, 'orderBy must be { column, direction: "asc" | "desc" }');
342
+ return true;
343
+ }
253
344
  if (!ctx.database) {
254
345
  emit({ requestId, data: { rows: [], total: 0, page, pageSize } });
255
346
  return true;
256
347
  }
257
348
  try {
258
- emit({ requestId, data: await ctx.database.select(table, page, pageSize, where) });
349
+ const orderBy = order ? { column: order.column, direction: order.direction } : undefined;
350
+ emit({ requestId, data: await ctx.database.select(table, page, pageSize, where, orderBy) });
259
351
  }
260
352
  catch (error) {
261
- emit({ requestId, error: `Failed to select: ${errorMessage(error)}` });
353
+ emit({ requestId, error: `Failed to select: ${errorMessage(error)}`, ...refusalData(error) });
354
+ }
355
+ return true;
356
+ }
357
+ case 'db:query': {
358
+ const sql = sqlField(message);
359
+ if (!sql) {
360
+ errorReply(payloads, requestId, `sql is required (at most ${MAX_CONSOLE_SQL_LENGTH} characters)`);
361
+ return true;
362
+ }
363
+ if (!ctx.database?.query) {
364
+ unsupportedDatabaseFeature(payloads, requestId, 'SQL query');
365
+ return true;
366
+ }
367
+ try {
368
+ const maxRows = typeof message.maxRows === 'number' ? message.maxRows : undefined;
369
+ emit({ requestId, data: await ctx.database.query(sql, maxRows ? { maxRows } : undefined) });
370
+ }
371
+ catch (error) {
372
+ emit({ requestId, error: errorMessage(error), ...refusalData(error) });
373
+ }
374
+ return true;
375
+ }
376
+ case 'db:execute': {
377
+ const sql = sqlField(message);
378
+ if (!sql) {
379
+ errorReply(payloads, requestId, `sql is required (at most ${MAX_CONSOLE_SQL_LENGTH} characters)`);
380
+ return true;
381
+ }
382
+ if (!ctx.database?.execute) {
383
+ unsupportedDatabaseFeature(payloads, requestId, 'SQL execution');
384
+ return true;
385
+ }
386
+ try {
387
+ emit({ requestId, data: await ctx.database.execute(sql, { dryRun: message.dryRun === true }) });
388
+ }
389
+ catch (error) {
390
+ emit({ requestId, error: errorMessage(error), ...refusalData(error) });
391
+ }
392
+ return true;
393
+ }
394
+ case 'db:schema:describe': {
395
+ const table = stringField(message, 'table');
396
+ if (!table) {
397
+ errorReply(payloads, requestId, 'table is required');
398
+ return true;
399
+ }
400
+ if (!ctx.database?.describe) {
401
+ unsupportedDatabaseFeature(payloads, requestId, 'Schema inspection');
402
+ return true;
403
+ }
404
+ try {
405
+ emit({ requestId, data: await ctx.database.describe(table) });
406
+ }
407
+ catch (error) {
408
+ emit({ requestId, error: errorMessage(error), ...refusalData(error) });
409
+ }
410
+ return true;
411
+ }
412
+ case 'db:schema:preview':
413
+ case 'db:schema:apply': {
414
+ const change = recordField(message.change);
415
+ if (!change) {
416
+ errorReply(payloads, requestId, 'change is required');
417
+ return true;
418
+ }
419
+ const database = ctx.database;
420
+ if (!database?.previewSchema || !database.applySchema) {
421
+ unsupportedDatabaseFeature(payloads, requestId, 'Schema editing');
422
+ return true;
423
+ }
424
+ try {
425
+ const plan = type === 'db:schema:apply'
426
+ ? await database.applySchema(change, typeof message.confirm === 'string' ? message.confirm : undefined)
427
+ : await database.previewSchema(change);
428
+ emit({ requestId, data: plan });
429
+ }
430
+ catch (error) {
431
+ emit({ requestId, error: errorMessage(error), ...refusalData(error) });
262
432
  }
263
433
  return true;
264
434
  }
@@ -543,9 +713,11 @@ export async function dispatchHostResourceConsoleRpc(type, message, ctx, request
543
713
  emit({ requestId, data: { messageId: result.messageId } });
544
714
  }
545
715
  catch (error) {
716
+ const failure = consoleSendFailure(error);
546
717
  emit({
547
718
  requestId,
548
- error: error instanceof Error ? error.message : String(error),
719
+ error: failure ? CONSOLE_SEND_FAILURE_MESSAGES[failure.reason] : error instanceof Error ? error.message : String(error),
720
+ ...(failure ? { data: failure } : {}),
549
721
  });
550
722
  }
551
723
  return true;
@@ -577,6 +749,43 @@ export async function dispatchHostResourceConsoleRpc(type, message, ctx, request
577
749
  return false;
578
750
  }
579
751
  }
752
+ /** Longest statement text the Console SQL editor sends. */
753
+ export const MAX_CONSOLE_SQL_LENGTH = 100_000;
754
+ function sqlField(message) {
755
+ const value = message.sql;
756
+ return typeof value === 'string' && value.trim() && value.length <= MAX_CONSOLE_SQL_LENGTH ? value : '';
757
+ }
758
+ const REFUSAL_NAMES = new Set(['ConsoleSqlRefusal', 'ConsoleSchemaRefusal', 'ConsoleSelectRefusal', 'SqliteQueryTimeoutError']);
759
+ /**
760
+ * `data.code` of a refusal by the database port (`read_only`, `protected_table`, …), and the DDL a
761
+ * failed `db:schema:apply` attempted (`data.statements`, for the audit log).
762
+ */
763
+ /** SQLSTATE of a driver error (PostgreSQL `code`, mysql2 `sqlState`), e.g. `42501` insufficient privilege. */
764
+ function sqlStateOf(error) {
765
+ const record = error;
766
+ for (const candidate of [record.sqlState, record.code]) {
767
+ if (typeof candidate === 'string' && /^[0-9A-Z]{5}$/u.test(candidate))
768
+ return candidate;
769
+ }
770
+ return undefined;
771
+ }
772
+ function refusalData(error) {
773
+ if (!(error instanceof Error))
774
+ return {};
775
+ const attempted = error.statements;
776
+ const statements = Array.isArray(attempted) && attempted.every(entry => typeof entry === 'string') ? { statements: attempted } : {};
777
+ if (error.name === 'GatewayOwnedTableError')
778
+ return { data: { code: 'protected_table', ...statements } };
779
+ const code = error.code;
780
+ const refused = REFUSAL_NAMES.has(error.name) && typeof code === 'string';
781
+ // The server's SQLSTATE lets the Console explain e.g. 42501 from the restricted account (ADR 0059 §4).
782
+ const sqlState = refused ? undefined : sqlStateOf(error);
783
+ const data = { ...(refused ? { code } : {}), ...(sqlState ? { sqlState } : {}), ...statements };
784
+ return Object.keys(data).length ? { data } : {};
785
+ }
786
+ function unsupportedDatabaseFeature(payloads, requestId, feature) {
787
+ payloads.push({ requestId, error: `${feature} is not supported by this database Host`, data: { code: 'unsupported' } });
788
+ }
580
789
  export function stringField(message, key) {
581
790
  const value = message[key];
582
791
  return typeof value === 'string' ? value.trim() : '';
@@ -0,0 +1,2 @@
1
+ export declare const RUNTIME_CONSOLE_RPC_TYPES: ReadonlySet<string>;
2
+ export declare function isRuntimeConsoleRpcType(type: unknown): type is string;
@@ -0,0 +1,43 @@
1
+ /**
2
+ * RPC types the runtime Console dispatcher of this package answers (`dispatchRuntimeConsoleRpc`:
3
+ * shell, configuration, plugin management, project files / environment / database, endpoints,
4
+ * schedules, inbox, login assist, workrooms). Used to tell an RPC this Host has but forgot to map
5
+ * (audited `unmapped`) from one it does not have at all (`unknown_rpc`, zhinjs/zhin#1250), and to
6
+ * build the Host's capability list. `console-rpc-types.test.ts` keeps it in step with the
7
+ * dispatchers' `case` labels.
8
+ */
9
+ import { CONFIG_RPC, ENDPOINT_RPC, PLUGIN_RPC } from '@zhin.js/console-protocol';
10
+ export const RUNTIME_CONSOLE_RPC_TYPES = new Set([
11
+ // Shell
12
+ 'ping', 'entries:get', 'pages:list',
13
+ // Configuration
14
+ ...Object.values(CONFIG_RPC),
15
+ 'workrooms:get', 'workrooms:set',
16
+ // Plugin management (the global switch is refused with a pointer to the gateway)
17
+ ...Object.values(PLUGIN_RPC),
18
+ // Project files / environment / schemas / database / process
19
+ 'files:tree', 'files:read', 'files:save',
20
+ 'env:list', 'env:get', 'env:reveal', 'env:save',
21
+ 'schema:get', 'schema:get-all',
22
+ 'db:info', 'db:tables', 'db:select', 'db:query', 'db:execute',
23
+ 'db:schema:describe', 'db:schema:preview', 'db:schema:apply',
24
+ 'db:insert', 'db:update', 'db:delete', 'db:drop-table',
25
+ 'db:kv:get', 'db:kv:set', 'db:kv:delete', 'db:kv:entries',
26
+ 'system:restart',
27
+ // Endpoints
28
+ ...Object.values(ENDPOINT_RPC),
29
+ // Schedules
30
+ 'schedule:list', 'cron:list', 'cron:validate', 'cron:add', 'cron:remove', 'cron:pause', 'cron:resume',
31
+ // Requests / notices / inbox
32
+ 'request.list', 'request.approve', 'request.reject', 'request.consumed', 'notice.consumed',
33
+ 'inbox.requests', 'inbox.notices', 'inbox.messages', 'inbox.recent',
34
+ // Login assist
35
+ 'login.list', 'login.submit', 'login.cancel', 'login.flow.list', 'login.flow.get', 'login.relogin', 'login.refresh',
36
+ // Workroom profiles / knowledge
37
+ 'workroom.profile.status', 'workroom.profile.bootstrap', 'workroom.profile.pack.publish',
38
+ 'workroom.profile.publish', 'workroom.profile.rollback', 'workroom.profile.policy.publish',
39
+ 'workroom.knowledge.get', 'workroom.knowledge.publish', 'workroom.knowledge.rollback',
40
+ ]);
41
+ export function isRuntimeConsoleRpcType(type) {
42
+ return typeof type === 'string' && RUNTIME_CONSOLE_RPC_TYPES.has(type);
43
+ }
@@ -101,9 +101,22 @@ export interface PluginVerificationResult {
101
101
  readonly evidence: readonly string[];
102
102
  readonly evidenceI18n?: import('@zhin.js/plugin-runtime').PluginVerificationOutcome['evidenceI18n'];
103
103
  }
104
+ /** `inspect` options. `dependencies: 'skip'` leaves the project-wide graph check out (`dependencies: 'unchecked'`). */
105
+ export interface PluginInspectOptions {
106
+ readonly dependencies?: 'check' | 'skip';
107
+ }
108
+ /** Result of the project-wide dependency graph check (the same check `inspect` runs per plugin). */
109
+ export type PluginDependencyCheck = {
110
+ readonly status: 'passed';
111
+ } | {
112
+ readonly status: 'failed';
113
+ readonly error: string;
114
+ };
104
115
  export interface PluginManagementPort {
105
116
  explainConfiguration?(instanceKey: string, expectedRevision: string): Promise<ConsoleConfigExplanation>;
106
- inspect?(packageName: string): Promise<PluginManagementState>;
117
+ inspect?(packageName: string, options?: PluginInspectOptions): Promise<PluginManagementState>;
118
+ /** Runs the project-wide dependency graph check once, for callers that inspect many plugins. */
119
+ checkDependencies?(): Promise<PluginDependencyCheck>;
107
120
  apply?(packageName: string, expectedProjectRevision?: string): Promise<{
108
121
  readonly requested: boolean;
109
122
  readonly state: PluginManagementState;
@@ -193,8 +206,23 @@ export type RuntimeConsoleRpcContext = {
193
206
  name: string;
194
207
  exists: boolean;
195
208
  }[]>;
209
+ /**
210
+ * @deprecated Not used any more: `env:get` answers {@link readEnvFileView} only, so the file as
211
+ * written never reaches the Console (issue #1250).
212
+ */
196
213
  readEnvFile?(filename: string): Promise<string>;
214
+ /** @deprecated Not used any more: `env:save` writes through {@link updateEnvVariables} / {@link replaceEnvFile}. */
197
215
  writeEnvFile?(filename: string, content: string): Promise<void>;
216
+ /** `env:get`: masked view (key names, masks, lengths; comments and blank lines in place). */
217
+ readEnvFileView?(filename: string): Promise<import('@zhin.js/console-protocol').ConsoleEnvFileView>;
218
+ /** `env:reveal`: one variable as written. The route requires `ops.env.write` + `ops.env.read` and audits. */
219
+ revealEnvVariable?(filename: string, key: string): Promise<import('@zhin.js/console-protocol').ConsoleEnvRevealResult>;
220
+ /** `env:save { changes }`: validates and writes only the named keys (mask placeholders refused). */
221
+ updateEnvVariables?(filename: string, changes: unknown): Promise<{
222
+ readonly changed: readonly string[];
223
+ }>;
224
+ /** `env:save { content, mode: 'replace' }`: whole file; refused when it holds mask placeholders. */
225
+ replaceEnvFile?(filename: string, content: string): Promise<void>;
198
226
  /** Optional schema accessors (plugin schema.json / Runtime schema registry). */
199
227
  getSchema?(pluginName?: string): Promise<unknown>;
200
228
  getAllSchemas?(): Promise<Record<string, unknown>>;
@@ -1,5 +1,6 @@
1
- import { CONFIG_PATCH_LIMITS, CONFIG_RPC, } from '@zhin.js/console-protocol';
2
- import { authorizeConsoleRpc } from './access-control.js';
1
+ import { CONFIG_PATCH_LIMITS, CONFIG_RPC, CONSOLE_UNKNOWN_RPC_CODE, grantsAllow, } from '@zhin.js/console-protocol';
2
+ import { authorizeConsoleRpc, principalAllows } from './access-control.js';
3
+ import { isRuntimeConsoleRpcType } from './console-rpc-types.js';
3
4
  import { dispatchExtendedConsoleRpc } from './console-rpc-extended/index.js';
4
5
  import { dispatchPluginConsoleRpc } from './console-plugin-rpc.js';
5
6
  import { configurationSaveMessage, dispatchHostResourceConsoleRpc, invalidProjectPatchOperations, } from './console-rpc-handlers.js';
@@ -9,6 +10,20 @@ export function pickRpcReply(message, payloads) {
9
10
  ?? payloads[payloads.length - 1]
10
11
  ?? null);
11
12
  }
13
+ /**
14
+ * The Endpoint a principal was admitted for when only an adapter / endpoint-scoped grant covers the
15
+ * call (B-1): handlers must then stay inside it. `undefined` when a global grant covers it.
16
+ */
17
+ function scopedAuthorizedEndpoint(principal, decision) {
18
+ if (principal.kind === 'bootstrap' || !decision.permission || !decision.rule)
19
+ return undefined;
20
+ if (grantsAllow(principal.grants, decision.permission))
21
+ return undefined;
22
+ const target = decision.rule.target;
23
+ if (target.kind !== 'endpoint')
24
+ return undefined;
25
+ return Object.freeze({ adapter: target.adapter, endpointKey: target.endpoint });
26
+ }
12
27
  export async function dispatchRuntimeConsoleRpc(message, ctx) {
13
28
  const payloads = [];
14
29
  const emit = (payload) => {
@@ -16,19 +31,30 @@ export async function dispatchRuntimeConsoleRpc(message, ctx) {
16
31
  };
17
32
  const type = String(message.type ?? '');
18
33
  const requestId = message.requestId;
34
+ let authorizedEndpoint;
35
+ let canOperateEndpoint;
19
36
  if (ctx.principal) {
20
- const decision = authorizeConsoleRpc(ctx.principal, message);
37
+ const principal = ctx.principal;
38
+ const decision = authorizeConsoleRpc(principal, message, { registered: isRuntimeConsoleRpcType });
21
39
  if (!decision.allowed) {
22
- emit({ requestId, error: decision.error, code: decision.code });
40
+ emit({ requestId, error: decision.error, code: decision.code, ...(decision.reason ? { reason: decision.reason } : {}) });
23
41
  return payloads;
24
42
  }
43
+ authorizedEndpoint = scopedAuthorizedEndpoint(principal, decision);
44
+ // Same grant check the map applies to `login.submit` for that Endpoint (ADR 0058 §8).
45
+ canOperateEndpoint = (adapter, endpointKey) => principalAllows(principal, 'endpoint.operate', Object.freeze({ kind: 'endpoint', adapter, endpoint: endpointKey }));
25
46
  }
26
47
  // Extended RPC surface (cron/schedule、endpoint 社交/inbox)
27
48
  if (ctx.extended) {
28
- const extended = await dispatchExtendedConsoleRpc(type, message, { ...ctx.extended, fullScope: ctx.authScope === 'full' });
49
+ const extended = await dispatchExtendedConsoleRpc(type, message, {
50
+ ...ctx.extended,
51
+ fullScope: ctx.authScope === 'full',
52
+ ...(authorizedEndpoint ? { authorizedEndpoint } : {}),
53
+ ...(canOperateEndpoint ? { canOperateEndpoint } : {}),
54
+ });
29
55
  if (extended !== undefined) {
30
56
  emit('error' in extended
31
- ? { requestId, error: extended.error }
57
+ ? { requestId, error: extended.error, ...(extended.data !== undefined ? { data: extended.data } : {}) }
32
58
  : { requestId, data: extended.data });
33
59
  return payloads;
34
60
  }
@@ -374,7 +400,7 @@ export async function dispatchRuntimeConsoleRpc(message, ctx) {
374
400
  });
375
401
  return payloads;
376
402
  }
377
- emit({ requestId, error: `Unknown RPC type: ${type || '<empty>'}` });
403
+ emit({ requestId, error: `Unknown RPC type: ${type.slice(0, 64) || '<empty>'}`, code: CONSOLE_UNKNOWN_RPC_CODE });
378
404
  return payloads;
379
405
  }
380
406
  }