@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
|
@@ -1,7 +1,11 @@
|
|
|
1
1
|
import {
|
|
2
2
|
CONFIG_PATCH_LIMITS,
|
|
3
|
+
CONSOLE_SEND_FAILURE_MESSAGES,
|
|
3
4
|
ENDPOINT_RPC,
|
|
5
|
+
classifyConsoleSendFailure,
|
|
4
6
|
type ConsoleConfigApplicationStatus,
|
|
7
|
+
type ConsoleSendFailure,
|
|
8
|
+
type ConsoleSendFailureStatus,
|
|
5
9
|
} from '@zhin.js/console-protocol';
|
|
6
10
|
import type {
|
|
7
11
|
ConversationAddress,
|
|
@@ -30,6 +34,43 @@ export function wireConversation(
|
|
|
30
34
|
};
|
|
31
35
|
}
|
|
32
36
|
|
|
37
|
+
const SEND_FAILURE_STATUSES: ReadonlySet<string> = new Set(['failed', 'rejected', 'unsupported', 'suppressed']);
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Structural read of an outbound failure (#1250): Core's `EndpointDeliveryError` carries `code`,
|
|
41
|
+
* `disposition` and the canonical receipt; host/http must not import Core, so only those fields are
|
|
42
|
+
* read. `endpoint not found` is the Endpoint directory's plain error. An error counts as a send
|
|
43
|
+
* failure only when its `code` is a known delivery code or it carries a `disposition` / `receipt`;
|
|
44
|
+
* anything else (a Node `ENOENT`, a database error, …) is unclassified and keeps its own message.
|
|
45
|
+
*/
|
|
46
|
+
export function consoleSendFailure(error: unknown): ConsoleSendFailure | undefined {
|
|
47
|
+
if (!(error instanceof Error)) return undefined;
|
|
48
|
+
const record = error as Error & { code?: unknown; disposition?: unknown; receipt?: unknown };
|
|
49
|
+
if (typeof record.code !== 'string' || !record.code) {
|
|
50
|
+
return error.message === 'endpoint not found'
|
|
51
|
+
? { code: 'endpoint_not_found', reason: 'endpoint_not_found', status: 'failed' }
|
|
52
|
+
: undefined;
|
|
53
|
+
}
|
|
54
|
+
// Every known delivery code maps to a reason other than the catch-all `failed`.
|
|
55
|
+
const knownCode = classifyConsoleSendFailure(record.code) !== 'failed';
|
|
56
|
+
const delivery = typeof record.disposition === 'string' || (!!record.receipt && typeof record.receipt === 'object');
|
|
57
|
+
if (!knownCode && !delivery) return undefined;
|
|
58
|
+
const receipt = record.receipt && typeof record.receipt === 'object'
|
|
59
|
+
? record.receipt as { status?: unknown; failure?: { retryable?: unknown; deliveryUnknown?: unknown } }
|
|
60
|
+
: undefined;
|
|
61
|
+
const status: ConsoleSendFailureStatus = record.disposition === 'unknown' || receipt?.failure?.deliveryUnknown === true
|
|
62
|
+
? 'unknown'
|
|
63
|
+
: typeof receipt?.status === 'string' && SEND_FAILURE_STATUSES.has(receipt.status)
|
|
64
|
+
? receipt.status as ConsoleSendFailureStatus
|
|
65
|
+
: record.disposition === 'rejected' ? 'rejected' : 'failed';
|
|
66
|
+
return {
|
|
67
|
+
code: record.code,
|
|
68
|
+
reason: classifyConsoleSendFailure(record.code, status),
|
|
69
|
+
status,
|
|
70
|
+
...(receipt?.failure?.retryable === true ? { retryable: true } : {}),
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
33
74
|
const FORBIDDEN_PATCH_SEGMENTS = new Set(['__proto__', 'constructor', 'prototype']);
|
|
34
75
|
|
|
35
76
|
/**
|
|
@@ -145,12 +186,12 @@ export async function dispatchHostResourceConsoleRpc(
|
|
|
145
186
|
emit({ requestId, error: 'filename is required' });
|
|
146
187
|
return true;
|
|
147
188
|
}
|
|
148
|
-
|
|
189
|
+
// Masked only (issue #1250): the file as written is never answered here.
|
|
190
|
+
if (!ctx.readEnvFileView) {
|
|
149
191
|
emit({ requestId, error: 'Env read is not configured' });
|
|
150
192
|
return true;
|
|
151
193
|
}
|
|
152
|
-
|
|
153
|
-
emit({ requestId, data: { content } });
|
|
194
|
+
emit({ requestId, data: await ctx.readEnvFileView(filename) });
|
|
154
195
|
} catch (error) {
|
|
155
196
|
emit({
|
|
156
197
|
requestId,
|
|
@@ -159,23 +200,70 @@ export async function dispatchHostResourceConsoleRpc(
|
|
|
159
200
|
}
|
|
160
201
|
return true;
|
|
161
202
|
}
|
|
203
|
+
case 'env:reveal': {
|
|
204
|
+
try {
|
|
205
|
+
const filename = String(message.filename ?? '');
|
|
206
|
+
const key = message.key;
|
|
207
|
+
if (!filename) {
|
|
208
|
+
emit({ requestId, error: 'filename is required' });
|
|
209
|
+
return true;
|
|
210
|
+
}
|
|
211
|
+
if (typeof key !== 'string' || !key) {
|
|
212
|
+
emit({ requestId, error: 'key is required' });
|
|
213
|
+
return true;
|
|
214
|
+
}
|
|
215
|
+
if (!ctx.revealEnvVariable) {
|
|
216
|
+
emit({ requestId, error: 'Env reveal is not configured' });
|
|
217
|
+
return true;
|
|
218
|
+
}
|
|
219
|
+
emit({ requestId, data: await ctx.revealEnvVariable(filename, key) });
|
|
220
|
+
} catch (error) {
|
|
221
|
+
emit({
|
|
222
|
+
requestId,
|
|
223
|
+
error: `Failed to reveal env variable: ${error instanceof Error ? error.message : String(error)}`,
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
return true;
|
|
227
|
+
}
|
|
162
228
|
case 'env:save': {
|
|
163
229
|
try {
|
|
164
230
|
const filename = String(message.filename ?? '');
|
|
165
|
-
const content = message.content;
|
|
166
231
|
if (!filename) {
|
|
167
232
|
emit({ requestId, error: 'filename is required' });
|
|
168
233
|
return true;
|
|
169
234
|
}
|
|
170
|
-
|
|
171
|
-
|
|
235
|
+
const hasChanges = message.changes !== undefined;
|
|
236
|
+
const hasContent = message.content !== undefined;
|
|
237
|
+
if (hasChanges && hasContent) {
|
|
238
|
+
emit({ requestId, error: 'changes 与 content 只能提供一个' });
|
|
172
239
|
return true;
|
|
173
240
|
}
|
|
174
|
-
if (
|
|
241
|
+
if (hasChanges) {
|
|
242
|
+
if (!ctx.updateEnvVariables) {
|
|
243
|
+
emit({ requestId, error: 'Env write is not configured' });
|
|
244
|
+
return true;
|
|
245
|
+
}
|
|
246
|
+
const { changed } = await ctx.updateEnvVariables(filename, message.changes);
|
|
247
|
+
emit({ requestId, data: { success: true, message: `环境文件已保存: ${filename}`, changed } });
|
|
248
|
+
return true;
|
|
249
|
+
}
|
|
250
|
+
if (typeof message.content !== 'string') {
|
|
251
|
+
emit({ requestId, error: 'changes field is required' });
|
|
252
|
+
return true;
|
|
253
|
+
}
|
|
254
|
+
// The whole-file form must be asked for: an older Console sends back what `env:get` showed it.
|
|
255
|
+
if (message.mode !== 'replace') {
|
|
256
|
+
emit({
|
|
257
|
+
requestId,
|
|
258
|
+
error: '此 Host 只接受逐键保存(changes);整文件保存需显式声明 mode: "replace"。请升级 Console 后重试',
|
|
259
|
+
});
|
|
260
|
+
return true;
|
|
261
|
+
}
|
|
262
|
+
if (!ctx.replaceEnvFile) {
|
|
175
263
|
emit({ requestId, error: 'Env write is not configured' });
|
|
176
264
|
return true;
|
|
177
265
|
}
|
|
178
|
-
await ctx.
|
|
266
|
+
await ctx.replaceEnvFile(filename, message.content);
|
|
179
267
|
emit({ requestId, data: { success: true, message: `环境文件已保存: ${filename}` } });
|
|
180
268
|
} catch (error) {
|
|
181
269
|
emit({
|
|
@@ -257,14 +345,71 @@ export async function dispatchHostResourceConsoleRpc(
|
|
|
257
345
|
const page = positiveInteger(message.page, 1);
|
|
258
346
|
const pageSize = Math.min(positiveInteger(message.pageSize, 50), 500);
|
|
259
347
|
const where = recordField(message.where);
|
|
348
|
+
const order = recordField(message.orderBy);
|
|
349
|
+
if (message.orderBy !== undefined && message.orderBy !== null
|
|
350
|
+
&& (!order || typeof order.column !== 'string' || (order.direction !== 'asc' && order.direction !== 'desc'))) {
|
|
351
|
+
errorReply(payloads, requestId, 'orderBy must be { column, direction: "asc" | "desc" }');
|
|
352
|
+
return true;
|
|
353
|
+
}
|
|
260
354
|
if (!ctx.database) {
|
|
261
355
|
emit({ requestId, data: { rows: [], total: 0, page, pageSize } });
|
|
262
356
|
return true;
|
|
263
357
|
}
|
|
264
358
|
try {
|
|
265
|
-
|
|
359
|
+
const orderBy = order ? { column: order.column as string, direction: order.direction as 'asc' | 'desc' } : undefined;
|
|
360
|
+
emit({ requestId, data: await ctx.database.select(table, page, pageSize, where, orderBy) });
|
|
361
|
+
} catch (error) {
|
|
362
|
+
emit({ requestId, error: `Failed to select: ${errorMessage(error)}`, ...refusalData(error) });
|
|
363
|
+
}
|
|
364
|
+
return true;
|
|
365
|
+
}
|
|
366
|
+
case 'db:query': {
|
|
367
|
+
const sql = sqlField(message);
|
|
368
|
+
if (!sql) { errorReply(payloads, requestId, `sql is required (at most ${MAX_CONSOLE_SQL_LENGTH} characters)`); return true; }
|
|
369
|
+
if (!ctx.database?.query) { unsupportedDatabaseFeature(payloads, requestId, 'SQL query'); return true; }
|
|
370
|
+
try {
|
|
371
|
+
const maxRows = typeof message.maxRows === 'number' ? message.maxRows : undefined;
|
|
372
|
+
emit({ requestId, data: await ctx.database.query(sql, maxRows ? { maxRows } : undefined) });
|
|
373
|
+
} catch (error) {
|
|
374
|
+
emit({ requestId, error: errorMessage(error), ...refusalData(error) });
|
|
375
|
+
}
|
|
376
|
+
return true;
|
|
377
|
+
}
|
|
378
|
+
case 'db:execute': {
|
|
379
|
+
const sql = sqlField(message);
|
|
380
|
+
if (!sql) { errorReply(payloads, requestId, `sql is required (at most ${MAX_CONSOLE_SQL_LENGTH} characters)`); return true; }
|
|
381
|
+
if (!ctx.database?.execute) { unsupportedDatabaseFeature(payloads, requestId, 'SQL execution'); return true; }
|
|
382
|
+
try {
|
|
383
|
+
emit({ requestId, data: await ctx.database.execute(sql, { dryRun: message.dryRun === true }) });
|
|
384
|
+
} catch (error) {
|
|
385
|
+
emit({ requestId, error: errorMessage(error), ...refusalData(error) });
|
|
386
|
+
}
|
|
387
|
+
return true;
|
|
388
|
+
}
|
|
389
|
+
case 'db:schema:describe': {
|
|
390
|
+
const table = stringField(message, 'table');
|
|
391
|
+
if (!table) { errorReply(payloads, requestId, 'table is required'); return true; }
|
|
392
|
+
if (!ctx.database?.describe) { unsupportedDatabaseFeature(payloads, requestId, 'Schema inspection'); return true; }
|
|
393
|
+
try {
|
|
394
|
+
emit({ requestId, data: await ctx.database.describe(table) });
|
|
395
|
+
} catch (error) {
|
|
396
|
+
emit({ requestId, error: errorMessage(error), ...refusalData(error) });
|
|
397
|
+
}
|
|
398
|
+
return true;
|
|
399
|
+
}
|
|
400
|
+
case 'db:schema:preview':
|
|
401
|
+
case 'db:schema:apply': {
|
|
402
|
+
const change = recordField(message.change);
|
|
403
|
+
if (!change) { errorReply(payloads, requestId, 'change is required'); return true; }
|
|
404
|
+
const database = ctx.database;
|
|
405
|
+
if (!database?.previewSchema || !database.applySchema) { unsupportedDatabaseFeature(payloads, requestId, 'Schema editing'); return true; }
|
|
406
|
+
try {
|
|
407
|
+
const plan = type === 'db:schema:apply'
|
|
408
|
+
? await database.applySchema(change, typeof message.confirm === 'string' ? message.confirm : undefined)
|
|
409
|
+
: await database.previewSchema(change);
|
|
410
|
+
emit({ requestId, data: plan });
|
|
266
411
|
} catch (error) {
|
|
267
|
-
emit({ requestId, error:
|
|
412
|
+
emit({ requestId, error: errorMessage(error), ...refusalData(error) });
|
|
268
413
|
}
|
|
269
414
|
return true;
|
|
270
415
|
}
|
|
@@ -498,9 +643,11 @@ export async function dispatchHostResourceConsoleRpc(
|
|
|
498
643
|
});
|
|
499
644
|
emit({ requestId, data: { messageId: result.messageId } });
|
|
500
645
|
} catch (error) {
|
|
646
|
+
const failure = consoleSendFailure(error);
|
|
501
647
|
emit({
|
|
502
648
|
requestId,
|
|
503
|
-
error: error instanceof Error ? error.message : String(error),
|
|
649
|
+
error: failure ? CONSOLE_SEND_FAILURE_MESSAGES[failure.reason] : error instanceof Error ? error.message : String(error),
|
|
650
|
+
...(failure ? { data: failure } : {}),
|
|
504
651
|
});
|
|
505
652
|
}
|
|
506
653
|
return true;
|
|
@@ -532,6 +679,46 @@ export async function dispatchHostResourceConsoleRpc(
|
|
|
532
679
|
}
|
|
533
680
|
}
|
|
534
681
|
|
|
682
|
+
/** Longest statement text the Console SQL editor sends. */
|
|
683
|
+
export const MAX_CONSOLE_SQL_LENGTH = 100_000;
|
|
684
|
+
|
|
685
|
+
function sqlField(message: RuntimeConsoleRpcMessage): string {
|
|
686
|
+
const value = message.sql;
|
|
687
|
+
return typeof value === 'string' && value.trim() && value.length <= MAX_CONSOLE_SQL_LENGTH ? value : '';
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
const REFUSAL_NAMES: ReadonlySet<string> = new Set(['ConsoleSqlRefusal', 'ConsoleSchemaRefusal', 'ConsoleSelectRefusal', 'SqliteQueryTimeoutError']);
|
|
691
|
+
|
|
692
|
+
/**
|
|
693
|
+
* `data.code` of a refusal by the database port (`read_only`, `protected_table`, …), and the DDL a
|
|
694
|
+
* failed `db:schema:apply` attempted (`data.statements`, for the audit log).
|
|
695
|
+
*/
|
|
696
|
+
/** SQLSTATE of a driver error (PostgreSQL `code`, mysql2 `sqlState`), e.g. `42501` insufficient privilege. */
|
|
697
|
+
function sqlStateOf(error: Error): string | undefined {
|
|
698
|
+
const record = error as { code?: unknown; sqlState?: unknown };
|
|
699
|
+
for (const candidate of [record.sqlState, record.code]) {
|
|
700
|
+
if (typeof candidate === 'string' && /^[0-9A-Z]{5}$/u.test(candidate)) return candidate;
|
|
701
|
+
}
|
|
702
|
+
return undefined;
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
function refusalData(error: unknown): { data?: { code?: string; statements?: readonly string[]; sqlState?: string } } {
|
|
706
|
+
if (!(error instanceof Error)) return {};
|
|
707
|
+
const attempted = (error as { statements?: unknown }).statements;
|
|
708
|
+
const statements = Array.isArray(attempted) && attempted.every(entry => typeof entry === 'string') ? { statements: attempted as string[] } : {};
|
|
709
|
+
if (error.name === 'GatewayOwnedTableError') return { data: { code: 'protected_table', ...statements } };
|
|
710
|
+
const code = (error as { code?: unknown }).code;
|
|
711
|
+
const refused = REFUSAL_NAMES.has(error.name) && typeof code === 'string';
|
|
712
|
+
// The server's SQLSTATE lets the Console explain e.g. 42501 from the restricted account (ADR 0059 §4).
|
|
713
|
+
const sqlState = refused ? undefined : sqlStateOf(error);
|
|
714
|
+
const data = { ...(refused ? { code } : {}), ...(sqlState ? { sqlState } : {}), ...statements };
|
|
715
|
+
return Object.keys(data).length ? { data } : {};
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
function unsupportedDatabaseFeature(payloads: RuntimeConsoleRpcReply[], requestId: number | string | undefined, feature: string): void {
|
|
719
|
+
payloads.push({ requestId, error: `${feature} is not supported by this database Host`, data: { code: 'unsupported' } });
|
|
720
|
+
}
|
|
721
|
+
|
|
535
722
|
export function stringField(message: RuntimeConsoleRpcMessage, key: string): string {
|
|
536
723
|
const value = message[key];
|
|
537
724
|
return typeof value === 'string' ? value.trim() : '';
|
|
@@ -0,0 +1,45 @@
|
|
|
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
|
+
|
|
11
|
+
export const RUNTIME_CONSOLE_RPC_TYPES: ReadonlySet<string> = new Set<string>([
|
|
12
|
+
// Shell
|
|
13
|
+
'ping', 'entries:get', 'pages:list',
|
|
14
|
+
// Configuration
|
|
15
|
+
...Object.values(CONFIG_RPC),
|
|
16
|
+
'workrooms:get', 'workrooms:set',
|
|
17
|
+
// Plugin management (the global switch is refused with a pointer to the gateway)
|
|
18
|
+
...Object.values(PLUGIN_RPC),
|
|
19
|
+
// Project files / environment / schemas / database / process
|
|
20
|
+
'files:tree', 'files:read', 'files:save',
|
|
21
|
+
'env:list', 'env:get', 'env:reveal', 'env:save',
|
|
22
|
+
'schema:get', 'schema:get-all',
|
|
23
|
+
'db:info', 'db:tables', 'db:select', 'db:query', 'db:execute',
|
|
24
|
+
'db:schema:describe', 'db:schema:preview', 'db:schema:apply',
|
|
25
|
+
'db:insert', 'db:update', 'db:delete', 'db:drop-table',
|
|
26
|
+
'db:kv:get', 'db:kv:set', 'db:kv:delete', 'db:kv:entries',
|
|
27
|
+
'system:restart',
|
|
28
|
+
// Endpoints
|
|
29
|
+
...Object.values(ENDPOINT_RPC),
|
|
30
|
+
// Schedules
|
|
31
|
+
'schedule:list', 'cron:list', 'cron:validate', 'cron:add', 'cron:remove', 'cron:pause', 'cron:resume',
|
|
32
|
+
// Requests / notices / inbox
|
|
33
|
+
'request.list', 'request.approve', 'request.reject', 'request.consumed', 'notice.consumed',
|
|
34
|
+
'inbox.requests', 'inbox.notices', 'inbox.messages', 'inbox.recent',
|
|
35
|
+
// Login assist
|
|
36
|
+
'login.list', 'login.submit', 'login.cancel', 'login.flow.list', 'login.flow.get', 'login.relogin', 'login.refresh',
|
|
37
|
+
// Workroom profiles / knowledge
|
|
38
|
+
'workroom.profile.status', 'workroom.profile.bootstrap', 'workroom.profile.pack.publish',
|
|
39
|
+
'workroom.profile.publish', 'workroom.profile.rollback', 'workroom.profile.policy.publish',
|
|
40
|
+
'workroom.knowledge.get', 'workroom.knowledge.publish', 'workroom.knowledge.rollback',
|
|
41
|
+
]);
|
|
42
|
+
|
|
43
|
+
export function isRuntimeConsoleRpcType(type: unknown): type is string {
|
|
44
|
+
return typeof type === 'string' && RUNTIME_CONSOLE_RPC_TYPES.has(type);
|
|
45
|
+
}
|
package/src/console-rpc.ts
CHANGED
|
@@ -3,6 +3,8 @@ import type { DatabaseHostConsole } from '@zhin.js/plugin-runtime';
|
|
|
3
3
|
import {
|
|
4
4
|
CONFIG_PATCH_LIMITS,
|
|
5
5
|
CONFIG_RPC,
|
|
6
|
+
CONSOLE_UNKNOWN_RPC_CODE,
|
|
7
|
+
grantsAllow,
|
|
6
8
|
type ConsoleConfigPatchRequest,
|
|
7
9
|
type ConsoleConfigPatchResult,
|
|
8
10
|
type ConsoleConfigProjectPatchResult,
|
|
@@ -10,7 +12,8 @@ import {
|
|
|
10
12
|
type ConsoleConfigExplanation,
|
|
11
13
|
type ConsoleEndpointSummary,
|
|
12
14
|
} from '@zhin.js/console-protocol';
|
|
13
|
-
import { authorizeConsoleRpc, type ConsolePrincipal } from './access-control.js';
|
|
15
|
+
import { authorizeConsoleRpc, principalAllows, type ConsolePrincipal } from './access-control.js';
|
|
16
|
+
import { isRuntimeConsoleRpcType } from './console-rpc-types.js';
|
|
14
17
|
import { dispatchExtendedConsoleRpc, type ConsoleRpcExtendedCtx } from './console-rpc-extended/index.js';
|
|
15
18
|
import { dispatchPluginConsoleRpc } from './console-plugin-rpc.js';
|
|
16
19
|
import {
|
|
@@ -127,9 +130,21 @@ export interface PluginVerificationResult {
|
|
|
127
130
|
readonly evidenceI18n?: import('@zhin.js/plugin-runtime').PluginVerificationOutcome['evidenceI18n'];
|
|
128
131
|
}
|
|
129
132
|
|
|
133
|
+
/** `inspect` options. `dependencies: 'skip'` leaves the project-wide graph check out (`dependencies: 'unchecked'`). */
|
|
134
|
+
export interface PluginInspectOptions {
|
|
135
|
+
readonly dependencies?: 'check' | 'skip';
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** Result of the project-wide dependency graph check (the same check `inspect` runs per plugin). */
|
|
139
|
+
export type PluginDependencyCheck =
|
|
140
|
+
| { readonly status: 'passed' }
|
|
141
|
+
| { readonly status: 'failed'; readonly error: string };
|
|
142
|
+
|
|
130
143
|
export interface PluginManagementPort {
|
|
131
144
|
explainConfiguration?(instanceKey: string, expectedRevision: string): Promise<ConsoleConfigExplanation>;
|
|
132
|
-
inspect?(packageName: string): Promise<PluginManagementState>;
|
|
145
|
+
inspect?(packageName: string, options?: PluginInspectOptions): Promise<PluginManagementState>;
|
|
146
|
+
/** Runs the project-wide dependency graph check once, for callers that inspect many plugins. */
|
|
147
|
+
checkDependencies?(): Promise<PluginDependencyCheck>;
|
|
133
148
|
apply?(packageName: string, expectedProjectRevision?: string): Promise<{ readonly requested: boolean; readonly state: PluginManagementState }>;
|
|
134
149
|
verificationPlan?(packageName: string): Promise<PluginVerificationPlan>;
|
|
135
150
|
verify?(packageName: string, checkId: string, expectedGeneration: number | null, expectedProjectRevision: string, expectedCheckRevision: string): Promise<PluginVerificationResult>;
|
|
@@ -208,8 +223,21 @@ export type RuntimeConsoleRpcContext = {
|
|
|
208
223
|
readProjectFile?(filePath: string): Promise<{ content: string; size: number }>;
|
|
209
224
|
saveProjectFile?(filePath: string, content: string): Promise<void>;
|
|
210
225
|
listEnvFiles?(): Promise<readonly { name: string; exists: boolean }[]>;
|
|
226
|
+
/**
|
|
227
|
+
* @deprecated Not used any more: `env:get` answers {@link readEnvFileView} only, so the file as
|
|
228
|
+
* written never reaches the Console (issue #1250).
|
|
229
|
+
*/
|
|
211
230
|
readEnvFile?(filename: string): Promise<string>;
|
|
231
|
+
/** @deprecated Not used any more: `env:save` writes through {@link updateEnvVariables} / {@link replaceEnvFile}. */
|
|
212
232
|
writeEnvFile?(filename: string, content: string): Promise<void>;
|
|
233
|
+
/** `env:get`: masked view (key names, masks, lengths; comments and blank lines in place). */
|
|
234
|
+
readEnvFileView?(filename: string): Promise<import('@zhin.js/console-protocol').ConsoleEnvFileView>;
|
|
235
|
+
/** `env:reveal`: one variable as written. The route requires `ops.env.write` + `ops.env.read` and audits. */
|
|
236
|
+
revealEnvVariable?(filename: string, key: string): Promise<import('@zhin.js/console-protocol').ConsoleEnvRevealResult>;
|
|
237
|
+
/** `env:save { changes }`: validates and writes only the named keys (mask placeholders refused). */
|
|
238
|
+
updateEnvVariables?(filename: string, changes: unknown): Promise<{ readonly changed: readonly string[] }>;
|
|
239
|
+
/** `env:save { content, mode: 'replace' }`: whole file; refused when it holds mask placeholders. */
|
|
240
|
+
replaceEnvFile?(filename: string, content: string): Promise<void>;
|
|
213
241
|
/** Optional schema accessors (plugin schema.json / Runtime schema registry). */
|
|
214
242
|
getSchema?(pluginName?: string): Promise<unknown>;
|
|
215
243
|
getAllSchemas?(): Promise<Record<string, unknown>>;
|
|
@@ -257,6 +285,21 @@ export function pickRpcReply(
|
|
|
257
285
|
);
|
|
258
286
|
}
|
|
259
287
|
|
|
288
|
+
/**
|
|
289
|
+
* The Endpoint a principal was admitted for when only an adapter / endpoint-scoped grant covers the
|
|
290
|
+
* call (B-1): handlers must then stay inside it. `undefined` when a global grant covers it.
|
|
291
|
+
*/
|
|
292
|
+
function scopedAuthorizedEndpoint(
|
|
293
|
+
principal: ConsolePrincipal,
|
|
294
|
+
decision: Extract<ReturnType<typeof authorizeConsoleRpc>, { allowed: true }>,
|
|
295
|
+
): ConsoleRpcExtendedCtx['authorizedEndpoint'] {
|
|
296
|
+
if (principal.kind === 'bootstrap' || !decision.permission || !decision.rule) return undefined;
|
|
297
|
+
if (grantsAllow(principal.grants, decision.permission)) return undefined;
|
|
298
|
+
const target = decision.rule.target;
|
|
299
|
+
if (target.kind !== 'endpoint') return undefined;
|
|
300
|
+
return Object.freeze({ adapter: target.adapter, endpointKey: target.endpoint });
|
|
301
|
+
}
|
|
302
|
+
|
|
260
303
|
export async function dispatchRuntimeConsoleRpc(
|
|
261
304
|
message: RuntimeConsoleRpcMessage,
|
|
262
305
|
ctx: RuntimeConsoleRpcContext,
|
|
@@ -268,12 +311,20 @@ export async function dispatchRuntimeConsoleRpc(
|
|
|
268
311
|
const type = String(message.type ?? '');
|
|
269
312
|
const requestId = message.requestId as number | string | undefined;
|
|
270
313
|
|
|
314
|
+
let authorizedEndpoint: ConsoleRpcExtendedCtx['authorizedEndpoint'];
|
|
315
|
+
let canOperateEndpoint: ConsoleRpcExtendedCtx['canOperateEndpoint'];
|
|
271
316
|
if (ctx.principal) {
|
|
272
|
-
const
|
|
317
|
+
const principal = ctx.principal;
|
|
318
|
+
const decision = authorizeConsoleRpc(principal, message as Record<string, unknown>, { registered: isRuntimeConsoleRpcType });
|
|
273
319
|
if (!decision.allowed) {
|
|
274
|
-
emit({ requestId, error: decision.error, code: decision.code });
|
|
320
|
+
emit({ requestId, error: decision.error, code: decision.code, ...(decision.reason ? { reason: decision.reason } : {}) });
|
|
275
321
|
return payloads;
|
|
276
322
|
}
|
|
323
|
+
authorizedEndpoint = scopedAuthorizedEndpoint(principal, decision);
|
|
324
|
+
// Same grant check the map applies to `login.submit` for that Endpoint (ADR 0058 §8).
|
|
325
|
+
canOperateEndpoint = (adapter, endpointKey) => principalAllows(
|
|
326
|
+
principal, 'endpoint.operate', Object.freeze({ kind: 'endpoint', adapter, endpoint: endpointKey }),
|
|
327
|
+
);
|
|
277
328
|
}
|
|
278
329
|
|
|
279
330
|
// Extended RPC surface (cron/schedule、endpoint 社交/inbox)
|
|
@@ -281,11 +332,16 @@ export async function dispatchRuntimeConsoleRpc(
|
|
|
281
332
|
const extended = await dispatchExtendedConsoleRpc(
|
|
282
333
|
type,
|
|
283
334
|
message as Record<string, unknown>,
|
|
284
|
-
{
|
|
335
|
+
{
|
|
336
|
+
...ctx.extended,
|
|
337
|
+
fullScope: ctx.authScope === 'full',
|
|
338
|
+
...(authorizedEndpoint ? { authorizedEndpoint } : {}),
|
|
339
|
+
...(canOperateEndpoint ? { canOperateEndpoint } : {}),
|
|
340
|
+
},
|
|
285
341
|
);
|
|
286
342
|
if (extended !== undefined) {
|
|
287
343
|
emit('error' in extended
|
|
288
|
-
? { requestId, error: extended.error }
|
|
344
|
+
? { requestId, error: extended.error, ...(extended.data !== undefined ? { data: extended.data } : {}) }
|
|
289
345
|
: { requestId, data: extended.data });
|
|
290
346
|
return payloads;
|
|
291
347
|
}
|
|
@@ -589,7 +645,7 @@ export async function dispatchRuntimeConsoleRpc(
|
|
|
589
645
|
});
|
|
590
646
|
return payloads;
|
|
591
647
|
}
|
|
592
|
-
emit({ requestId, error: `Unknown RPC type: ${type || '<empty>'}
|
|
648
|
+
emit({ requestId, error: `Unknown RPC type: ${type.slice(0, 64) || '<empty>'}`, code: CONSOLE_UNKNOWN_RPC_CODE });
|
|
593
649
|
return payloads;
|
|
594
650
|
}
|
|
595
651
|
}
|
package/src/http-host.ts
CHANGED
|
@@ -30,8 +30,10 @@ import {
|
|
|
30
30
|
authorizeConsoleHttp,
|
|
31
31
|
authorizeConsoleWebSocket,
|
|
32
32
|
requestMeta,
|
|
33
|
+
webSocketDenialReason,
|
|
33
34
|
type ConsolePrincipal,
|
|
34
35
|
} from './access-control.js';
|
|
36
|
+
import type { ConsoleDenialReason } from '@zhin.js/console-protocol';
|
|
35
37
|
import type { HttpHostAddress } from './host-options.js';
|
|
36
38
|
|
|
37
39
|
export type { HttpHostAddress } from './host-options.js';
|
|
@@ -301,7 +303,7 @@ export function createHttpRouter(options: HttpRouterOptions = {}): HttpRouter {
|
|
|
301
303
|
);
|
|
302
304
|
if (!principal) return undefined;
|
|
303
305
|
if (!authorizeConsoleWebSocket(principal, pathname)) {
|
|
304
|
-
audit(principal, remoteAddress, `ws:${pathname}`, 'denied');
|
|
306
|
+
audit(principal, remoteAddress, `ws:${pathname}`, 'denied', webSocketDenialReason(principal, pathname));
|
|
305
307
|
return undefined;
|
|
306
308
|
}
|
|
307
309
|
return principal;
|
|
@@ -312,6 +314,7 @@ export function createHttpRouter(options: HttpRouterOptions = {}): HttpRouter {
|
|
|
312
314
|
remoteAddress: string | undefined,
|
|
313
315
|
action: string,
|
|
314
316
|
outcome: 'ok' | 'denied' | 'error',
|
|
317
|
+
reason?: ConsoleDenialReason,
|
|
315
318
|
): void {
|
|
316
319
|
const sink = tokenRegistry.accessProvider?.audit;
|
|
317
320
|
if (!sink) return;
|
|
@@ -320,6 +323,7 @@ export function createHttpRouter(options: HttpRouterOptions = {}): HttpRouter {
|
|
|
320
323
|
actor: auditActor(principal),
|
|
321
324
|
action,
|
|
322
325
|
outcome,
|
|
326
|
+
...(reason ? { detail: { reason } } : {}),
|
|
323
327
|
...(remoteAddress ? { ip: remoteAddress } : {}),
|
|
324
328
|
});
|
|
325
329
|
} catch { /* audit never breaks a request */ }
|
|
@@ -508,7 +512,7 @@ export function createHttpRouter(options: HttpRouterOptions = {}): HttpRouter {
|
|
|
508
512
|
const relative = pathname === apiBase ? '/' : pathname.slice(apiBase.length);
|
|
509
513
|
const decision = authorizeConsoleHttp(principal, method, relative);
|
|
510
514
|
if (!decision.allowed) {
|
|
511
|
-
if (principal) audit(principal, remoteAddress, `http:${method} ${relative}`, 'denied');
|
|
515
|
+
if (principal) audit(principal, remoteAddress, `http:${method} ${relative}`, 'denied', decision.reason);
|
|
512
516
|
return { ok: false, status: decision.status, code: decision.code, error: decision.error };
|
|
513
517
|
}
|
|
514
518
|
return { ok: true, ...(principal ? { principal } : {}) };
|
package/src/index.ts
CHANGED
|
@@ -36,6 +36,11 @@ export {
|
|
|
36
36
|
authorizeConsoleRpc,
|
|
37
37
|
authorizeConsoleWebSocket,
|
|
38
38
|
globalPermissions,
|
|
39
|
+
grantsDenialReason,
|
|
40
|
+
isAuditedRpcDenial,
|
|
41
|
+
unknownConsoleRpcError,
|
|
42
|
+
webSocketDenialReason,
|
|
43
|
+
type AuthorizeConsoleRpcOptions,
|
|
39
44
|
isUserPrincipal,
|
|
40
45
|
isExplicitConsolePrincipal,
|
|
41
46
|
pluginRpcWriteAllowed,
|
|
@@ -43,6 +48,7 @@ export {
|
|
|
43
48
|
requestMeta,
|
|
44
49
|
rpcAuditDetail,
|
|
45
50
|
rpcAuditTarget,
|
|
51
|
+
type RpcAuditOptions,
|
|
46
52
|
type ConsoleAccessProvider,
|
|
47
53
|
type ConsoleAuditEntry,
|
|
48
54
|
type ConsoleBootstrapPrincipal,
|
|
@@ -92,6 +98,8 @@ export {
|
|
|
92
98
|
type HttpResponseStream,
|
|
93
99
|
type HttpResponseStreamOptions,
|
|
94
100
|
} from '@zhin.js/host-http-contract';
|
|
101
|
+
export { isRuntimeConsoleRpcType, RUNTIME_CONSOLE_RPC_TYPES } from './console-rpc-types.js';
|
|
102
|
+
export { createUnknownRpcLog, unknownRpcLog, type UnknownRpcLog, type UnknownRpcLogEntry } from './unknown-rpc.js';
|
|
95
103
|
export {
|
|
96
104
|
dispatchRuntimeConsoleRpc,
|
|
97
105
|
pickRpcReply,
|
|
@@ -105,6 +113,8 @@ export {
|
|
|
105
113
|
type PluginUpdatePlan,
|
|
106
114
|
type PluginManagementPort,
|
|
107
115
|
type PluginManagementState,
|
|
116
|
+
type PluginInspectOptions,
|
|
117
|
+
type PluginDependencyCheck,
|
|
108
118
|
type PluginVerificationResult,
|
|
109
119
|
type PluginVerificationPlan,
|
|
110
120
|
type PluginConfigValidation,
|
|
@@ -132,9 +142,14 @@ export {
|
|
|
132
142
|
dispatchExtendedConsoleRpc,
|
|
133
143
|
type ConsoleRpcExtendedCtx,
|
|
134
144
|
type ConsoleScheduleEngine,
|
|
145
|
+
type ConsoleScheduleTarget,
|
|
135
146
|
type ConsoleWorkroomProfileControlPort,
|
|
136
147
|
type ConsoleWorkroomKnowledgeControlPort,
|
|
137
148
|
type ExtendedRpcResult,
|
|
149
|
+
type ConsoleLoginAssistPort,
|
|
150
|
+
type ConsoleEndpointLoginAccess,
|
|
151
|
+
loginAuditDetail,
|
|
152
|
+
LOGIN_SUBMIT_WAIT_MS,
|
|
138
153
|
} from './console-rpc-extended/index.js';
|
|
139
154
|
export * from './host-installer.js';
|
|
140
155
|
|