@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.
- package/lib/access-control.d.ts +50 -5
- package/lib/access-control.js +167 -13
- package/lib/console-rest-pages-helpers.js +24 -2
- package/lib/console-rest-pages.d.ts +4 -1
- package/lib/console-rest-pages.js +49 -23
- package/lib/console-rpc-extended/contracts.d.ts +92 -15
- package/lib/console-rpc-extended/dispatcher.js +10 -3
- package/lib/console-rpc-extended/endpoint-rpc.js +21 -4
- package/lib/console-rpc-extended/index.d.ts +2 -1
- package/lib/console-rpc-extended/index.js +1 -0
- package/lib/console-rpc-extended/login-rpc.d.ts +25 -2
- package/lib/console-rpc-extended/login-rpc.js +276 -16
- package/lib/console-rpc-extended/schedule-rpc.d.ts +1 -1
- package/lib/console-rpc-extended/schedule-rpc.js +78 -12
- package/lib/console-rpc-handlers.d.ts +11 -1
- package/lib/console-rpc-handlers.js +221 -12
- package/lib/console-rpc-types.d.ts +2 -0
- package/lib/console-rpc-types.js +43 -0
- package/lib/console-rpc.d.ts +29 -1
- package/lib/console-rpc.js +33 -7
- package/lib/http-host.js +5 -4
- package/lib/index.d.ts +5 -3
- package/lib/index.js +4 -2
- package/lib/project-files.d.ts +7 -0
- package/lib/project-files.js +88 -11
- package/lib/unknown-rpc.d.ts +12 -0
- package/lib/unknown-rpc.js +33 -0
- package/package.json +3 -3
- package/src/access-control.ts +209 -14
- package/src/console-rest-pages-helpers.ts +27 -3
- package/src/console-rest-pages.ts +52 -15
- package/src/console-rpc-extended/contracts.ts +83 -16
- package/src/console-rpc-extended/dispatcher.ts +10 -3
- package/src/console-rpc-extended/endpoint-rpc.ts +28 -4
- package/src/console-rpc-extended/index.ts +4 -0
- package/src/console-rpc-extended/login-rpc.ts +250 -17
- package/src/console-rpc-extended/schedule-rpc.ts +67 -3
- package/src/console-rpc-handlers.ts +198 -11
- package/src/console-rpc-types.ts +45 -0
- package/src/console-rpc.ts +63 -7
- package/src/http-host.ts +6 -2
- package/src/index.ts +15 -0
- package/src/project-files.ts +95 -11
- 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
|
-
|
|
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
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
? {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
156
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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,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
|
+
}
|
package/lib/console-rpc.d.ts
CHANGED
|
@@ -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>>;
|
package/lib/console-rpc.js
CHANGED
|
@@ -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
|
|
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, {
|
|
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
|
}
|