@zhin.js/host-http 1.2.1 → 1.2.2
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 +19 -2
- package/lib/access-control.js +71 -3
- package/lib/console-rpc-extended/contracts.d.ts +70 -15
- package/lib/console-rpc-extended/dispatcher.js +9 -2
- 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-handlers.d.ts +2 -0
- package/lib/console-rpc-handlers.js +124 -2
- package/lib/console-rpc.js +30 -5
- package/lib/index.d.ts +2 -2
- package/lib/index.js +1 -1
- package/package.json +3 -3
- package/src/access-control.ts +77 -3
- package/src/console-rpc-extended/contracts.ts +65 -16
- package/src/console-rpc-extended/dispatcher.ts +9 -2
- package/src/console-rpc-extended/index.ts +3 -0
- package/src/console-rpc-extended/login-rpc.ts +250 -17
- package/src/console-rpc-handlers.ts +99 -2
- package/src/console-rpc.ts +33 -4
- package/src/index.ts +5 -0
|
@@ -250,15 +250,100 @@ export async function dispatchHostResourceConsoleRpc(type, message, ctx, request
|
|
|
250
250
|
const page = positiveInteger(message.page, 1);
|
|
251
251
|
const pageSize = Math.min(positiveInteger(message.pageSize, 50), 500);
|
|
252
252
|
const where = recordField(message.where);
|
|
253
|
+
const order = recordField(message.orderBy);
|
|
254
|
+
if (message.orderBy !== undefined && message.orderBy !== null
|
|
255
|
+
&& (!order || typeof order.column !== 'string' || (order.direction !== 'asc' && order.direction !== 'desc'))) {
|
|
256
|
+
errorReply(payloads, requestId, 'orderBy must be { column, direction: "asc" | "desc" }');
|
|
257
|
+
return true;
|
|
258
|
+
}
|
|
253
259
|
if (!ctx.database) {
|
|
254
260
|
emit({ requestId, data: { rows: [], total: 0, page, pageSize } });
|
|
255
261
|
return true;
|
|
256
262
|
}
|
|
257
263
|
try {
|
|
258
|
-
|
|
264
|
+
const orderBy = order ? { column: order.column, direction: order.direction } : undefined;
|
|
265
|
+
emit({ requestId, data: await ctx.database.select(table, page, pageSize, where, orderBy) });
|
|
266
|
+
}
|
|
267
|
+
catch (error) {
|
|
268
|
+
emit({ requestId, error: `Failed to select: ${errorMessage(error)}`, ...refusalData(error) });
|
|
269
|
+
}
|
|
270
|
+
return true;
|
|
271
|
+
}
|
|
272
|
+
case 'db:query': {
|
|
273
|
+
const sql = sqlField(message);
|
|
274
|
+
if (!sql) {
|
|
275
|
+
errorReply(payloads, requestId, `sql is required (at most ${MAX_CONSOLE_SQL_LENGTH} characters)`);
|
|
276
|
+
return true;
|
|
277
|
+
}
|
|
278
|
+
if (!ctx.database?.query) {
|
|
279
|
+
unsupportedDatabaseFeature(payloads, requestId, 'SQL query');
|
|
280
|
+
return true;
|
|
281
|
+
}
|
|
282
|
+
try {
|
|
283
|
+
const maxRows = typeof message.maxRows === 'number' ? message.maxRows : undefined;
|
|
284
|
+
emit({ requestId, data: await ctx.database.query(sql, maxRows ? { maxRows } : undefined) });
|
|
259
285
|
}
|
|
260
286
|
catch (error) {
|
|
261
|
-
emit({ requestId, error:
|
|
287
|
+
emit({ requestId, error: errorMessage(error), ...refusalData(error) });
|
|
288
|
+
}
|
|
289
|
+
return true;
|
|
290
|
+
}
|
|
291
|
+
case 'db:execute': {
|
|
292
|
+
const sql = sqlField(message);
|
|
293
|
+
if (!sql) {
|
|
294
|
+
errorReply(payloads, requestId, `sql is required (at most ${MAX_CONSOLE_SQL_LENGTH} characters)`);
|
|
295
|
+
return true;
|
|
296
|
+
}
|
|
297
|
+
if (!ctx.database?.execute) {
|
|
298
|
+
unsupportedDatabaseFeature(payloads, requestId, 'SQL execution');
|
|
299
|
+
return true;
|
|
300
|
+
}
|
|
301
|
+
try {
|
|
302
|
+
emit({ requestId, data: await ctx.database.execute(sql, { dryRun: message.dryRun === true }) });
|
|
303
|
+
}
|
|
304
|
+
catch (error) {
|
|
305
|
+
emit({ requestId, error: errorMessage(error), ...refusalData(error) });
|
|
306
|
+
}
|
|
307
|
+
return true;
|
|
308
|
+
}
|
|
309
|
+
case 'db:schema:describe': {
|
|
310
|
+
const table = stringField(message, 'table');
|
|
311
|
+
if (!table) {
|
|
312
|
+
errorReply(payloads, requestId, 'table is required');
|
|
313
|
+
return true;
|
|
314
|
+
}
|
|
315
|
+
if (!ctx.database?.describe) {
|
|
316
|
+
unsupportedDatabaseFeature(payloads, requestId, 'Schema inspection');
|
|
317
|
+
return true;
|
|
318
|
+
}
|
|
319
|
+
try {
|
|
320
|
+
emit({ requestId, data: await ctx.database.describe(table) });
|
|
321
|
+
}
|
|
322
|
+
catch (error) {
|
|
323
|
+
emit({ requestId, error: errorMessage(error), ...refusalData(error) });
|
|
324
|
+
}
|
|
325
|
+
return true;
|
|
326
|
+
}
|
|
327
|
+
case 'db:schema:preview':
|
|
328
|
+
case 'db:schema:apply': {
|
|
329
|
+
const change = recordField(message.change);
|
|
330
|
+
if (!change) {
|
|
331
|
+
errorReply(payloads, requestId, 'change is required');
|
|
332
|
+
return true;
|
|
333
|
+
}
|
|
334
|
+
const database = ctx.database;
|
|
335
|
+
if (!database?.previewSchema || !database.applySchema) {
|
|
336
|
+
unsupportedDatabaseFeature(payloads, requestId, 'Schema editing');
|
|
337
|
+
return true;
|
|
338
|
+
}
|
|
339
|
+
try {
|
|
340
|
+
const plan = type === 'db:schema:apply'
|
|
341
|
+
? await database.applySchema(change, typeof message.confirm === 'string' ? message.confirm : undefined)
|
|
342
|
+
: await database.previewSchema(change);
|
|
343
|
+
emit({ requestId, data: plan });
|
|
344
|
+
}
|
|
345
|
+
catch (error) {
|
|
346
|
+
emit({ requestId, error: errorMessage(error), ...refusalData(error) });
|
|
262
347
|
}
|
|
263
348
|
return true;
|
|
264
349
|
}
|
|
@@ -577,6 +662,43 @@ export async function dispatchHostResourceConsoleRpc(type, message, ctx, request
|
|
|
577
662
|
return false;
|
|
578
663
|
}
|
|
579
664
|
}
|
|
665
|
+
/** Longest statement text the Console SQL editor sends. */
|
|
666
|
+
export const MAX_CONSOLE_SQL_LENGTH = 100_000;
|
|
667
|
+
function sqlField(message) {
|
|
668
|
+
const value = message.sql;
|
|
669
|
+
return typeof value === 'string' && value.trim() && value.length <= MAX_CONSOLE_SQL_LENGTH ? value : '';
|
|
670
|
+
}
|
|
671
|
+
const REFUSAL_NAMES = new Set(['ConsoleSqlRefusal', 'ConsoleSchemaRefusal', 'ConsoleSelectRefusal', 'SqliteQueryTimeoutError']);
|
|
672
|
+
/**
|
|
673
|
+
* `data.code` of a refusal by the database port (`read_only`, `protected_table`, …), and the DDL a
|
|
674
|
+
* failed `db:schema:apply` attempted (`data.statements`, for the audit log).
|
|
675
|
+
*/
|
|
676
|
+
/** SQLSTATE of a driver error (PostgreSQL `code`, mysql2 `sqlState`), e.g. `42501` insufficient privilege. */
|
|
677
|
+
function sqlStateOf(error) {
|
|
678
|
+
const record = error;
|
|
679
|
+
for (const candidate of [record.sqlState, record.code]) {
|
|
680
|
+
if (typeof candidate === 'string' && /^[0-9A-Z]{5}$/u.test(candidate))
|
|
681
|
+
return candidate;
|
|
682
|
+
}
|
|
683
|
+
return undefined;
|
|
684
|
+
}
|
|
685
|
+
function refusalData(error) {
|
|
686
|
+
if (!(error instanceof Error))
|
|
687
|
+
return {};
|
|
688
|
+
const attempted = error.statements;
|
|
689
|
+
const statements = Array.isArray(attempted) && attempted.every(entry => typeof entry === 'string') ? { statements: attempted } : {};
|
|
690
|
+
if (error.name === 'GatewayOwnedTableError')
|
|
691
|
+
return { data: { code: 'protected_table', ...statements } };
|
|
692
|
+
const code = error.code;
|
|
693
|
+
const refused = REFUSAL_NAMES.has(error.name) && typeof code === 'string';
|
|
694
|
+
// The server's SQLSTATE lets the Console explain e.g. 42501 from the restricted account (ADR 0059 §4).
|
|
695
|
+
const sqlState = refused ? undefined : sqlStateOf(error);
|
|
696
|
+
const data = { ...(refused ? { code } : {}), ...(sqlState ? { sqlState } : {}), ...statements };
|
|
697
|
+
return Object.keys(data).length ? { data } : {};
|
|
698
|
+
}
|
|
699
|
+
function unsupportedDatabaseFeature(payloads, requestId, feature) {
|
|
700
|
+
payloads.push({ requestId, error: `${feature} is not supported by this database Host`, data: { code: 'unsupported' } });
|
|
701
|
+
}
|
|
580
702
|
export function stringField(message, key) {
|
|
581
703
|
const value = message[key];
|
|
582
704
|
return typeof value === 'string' ? value.trim() : '';
|
package/lib/console-rpc.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
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, grantsAllow, } from '@zhin.js/console-protocol';
|
|
2
|
+
import { authorizeConsoleRpc, principalAllows } from './access-control.js';
|
|
3
3
|
import { dispatchExtendedConsoleRpc } from './console-rpc-extended/index.js';
|
|
4
4
|
import { dispatchPluginConsoleRpc } from './console-plugin-rpc.js';
|
|
5
5
|
import { configurationSaveMessage, dispatchHostResourceConsoleRpc, invalidProjectPatchOperations, } from './console-rpc-handlers.js';
|
|
@@ -9,6 +9,20 @@ export function pickRpcReply(message, payloads) {
|
|
|
9
9
|
?? payloads[payloads.length - 1]
|
|
10
10
|
?? null);
|
|
11
11
|
}
|
|
12
|
+
/**
|
|
13
|
+
* The Endpoint a principal was admitted for when only an adapter / endpoint-scoped grant covers the
|
|
14
|
+
* call (B-1): handlers must then stay inside it. `undefined` when a global grant covers it.
|
|
15
|
+
*/
|
|
16
|
+
function scopedAuthorizedEndpoint(principal, decision) {
|
|
17
|
+
if (principal.kind === 'bootstrap' || !decision.permission || !decision.rule)
|
|
18
|
+
return undefined;
|
|
19
|
+
if (grantsAllow(principal.grants, decision.permission))
|
|
20
|
+
return undefined;
|
|
21
|
+
const target = decision.rule.target;
|
|
22
|
+
if (target.kind !== 'endpoint')
|
|
23
|
+
return undefined;
|
|
24
|
+
return Object.freeze({ adapter: target.adapter, endpointKey: target.endpoint });
|
|
25
|
+
}
|
|
12
26
|
export async function dispatchRuntimeConsoleRpc(message, ctx) {
|
|
13
27
|
const payloads = [];
|
|
14
28
|
const emit = (payload) => {
|
|
@@ -16,19 +30,30 @@ export async function dispatchRuntimeConsoleRpc(message, ctx) {
|
|
|
16
30
|
};
|
|
17
31
|
const type = String(message.type ?? '');
|
|
18
32
|
const requestId = message.requestId;
|
|
33
|
+
let authorizedEndpoint;
|
|
34
|
+
let canOperateEndpoint;
|
|
19
35
|
if (ctx.principal) {
|
|
20
|
-
const
|
|
36
|
+
const principal = ctx.principal;
|
|
37
|
+
const decision = authorizeConsoleRpc(principal, message);
|
|
21
38
|
if (!decision.allowed) {
|
|
22
39
|
emit({ requestId, error: decision.error, code: decision.code });
|
|
23
40
|
return payloads;
|
|
24
41
|
}
|
|
42
|
+
authorizedEndpoint = scopedAuthorizedEndpoint(principal, decision);
|
|
43
|
+
// Same grant check the map applies to `login.submit` for that Endpoint (ADR 0058 §8).
|
|
44
|
+
canOperateEndpoint = (adapter, endpointKey) => principalAllows(principal, 'endpoint.operate', Object.freeze({ kind: 'endpoint', adapter, endpoint: endpointKey }));
|
|
25
45
|
}
|
|
26
46
|
// Extended RPC surface (cron/schedule、endpoint 社交/inbox)
|
|
27
47
|
if (ctx.extended) {
|
|
28
|
-
const extended = await dispatchExtendedConsoleRpc(type, message, {
|
|
48
|
+
const extended = await dispatchExtendedConsoleRpc(type, message, {
|
|
49
|
+
...ctx.extended,
|
|
50
|
+
fullScope: ctx.authScope === 'full',
|
|
51
|
+
...(authorizedEndpoint ? { authorizedEndpoint } : {}),
|
|
52
|
+
...(canOperateEndpoint ? { canOperateEndpoint } : {}),
|
|
53
|
+
});
|
|
29
54
|
if (extended !== undefined) {
|
|
30
55
|
emit('error' in extended
|
|
31
|
-
? { requestId, error: extended.error }
|
|
56
|
+
? { requestId, error: extended.error, ...(extended.data !== undefined ? { data: extended.data } : {}) }
|
|
32
57
|
: { requestId, data: extended.data });
|
|
33
58
|
return payloads;
|
|
34
59
|
}
|
package/lib/index.d.ts
CHANGED
|
@@ -2,7 +2,7 @@ export { createHttpRouter, httpHostToken, normalizePath, REMOTE_CONSOLE_ORIGIN,
|
|
|
2
2
|
export type { HttpHostAddress, HttpHostOptions, HttpHostTlsOptions } from './host-options.js';
|
|
3
3
|
export { listHttpHostAddresses } from './addresses.js';
|
|
4
4
|
export { TokenRegistry, extractBearerToken, publicConsoleAccess, unrestrictedConsoleAccess, type AuthenticatedTokenPrincipal, type ConsoleAccess, type AuthScope, type TokenRegistryConfig, } from './token-registry.js';
|
|
5
|
-
export { auditActor, authorizeConsoleHttp, authorizeConsoleRpc, authorizeConsoleWebSocket, globalPermissions, isUserPrincipal, isExplicitConsolePrincipal, pluginRpcWriteAllowed, principalAllows, requestMeta, rpcAuditDetail, rpcAuditTarget, type ConsoleAccessProvider, type ConsoleAuditEntry, type ConsoleBootstrapPrincipal, type ConsoleHttpDecision, type ConsolePrincipal, type ConsoleRequestMeta, type ConsoleRpcDecision, type ConsoleUserPrincipal, } from './access-control.js';
|
|
5
|
+
export { auditActor, authorizeConsoleHttp, authorizeConsoleRpc, authorizeConsoleWebSocket, globalPermissions, isUserPrincipal, isExplicitConsolePrincipal, pluginRpcWriteAllowed, principalAllows, requestMeta, rpcAuditDetail, rpcAuditTarget, type RpcAuditOptions, type ConsoleAccessProvider, type ConsoleAuditEntry, type ConsoleBootstrapPrincipal, type ConsoleHttpDecision, type ConsolePrincipal, type ConsoleRequestMeta, type ConsoleRpcDecision, type ConsoleUserPrincipal, } from './access-control.js';
|
|
6
6
|
export { timingSafeEqualString } from './timing-safe-equal.js';
|
|
7
7
|
export { isLoopbackAddress, isLoopbackHost, verifyProtocolAccessToken, type ProtocolAuthRequest } from './protocol-auth.js';
|
|
8
8
|
export { compileTrustProxy, hasForwardingHeaders, parseTrustProxy, resolveClientAddress, TrustProxyConfigError, type TrustProxyPredicate, type TrustProxySetting, } from './trust-proxy.js';
|
|
@@ -14,5 +14,5 @@ export { dispatchRuntimeConsoleRpc, pickRpcReply, type RuntimeConsolePage, type
|
|
|
14
14
|
export { dispatchPluginConsoleRpc, PLUGIN_SWITCH_REQUIRES_GATEWAY } from './console-plugin-rpc.js';
|
|
15
15
|
export { buildProjectFileTree, FILE_MANAGER_ALLOWED, FILE_MANAGER_BLOCKED, isProjectPathAllowed, listEnvFiles, readProjectFile, saveProjectFile, type FileTreeNode, } from './project-files.js';
|
|
16
16
|
export { registerConsoleRestPages, type ConsoleAgentIntrospection, type ConsoleAgentRuntime, type ConsoleAgentSessionTree, type ConsoleRestCtx, type ConsoleRestPagesOptions, } from './console-rest-pages.js';
|
|
17
|
-
export { dispatchExtendedConsoleRpc, type ConsoleRpcExtendedCtx, type ConsoleScheduleEngine, type ConsoleWorkroomProfileControlPort, type ConsoleWorkroomKnowledgeControlPort, type ExtendedRpcResult, } from './console-rpc-extended/index.js';
|
|
17
|
+
export { dispatchExtendedConsoleRpc, type ConsoleRpcExtendedCtx, type ConsoleScheduleEngine, type ConsoleWorkroomProfileControlPort, type ConsoleWorkroomKnowledgeControlPort, type ExtendedRpcResult, type ConsoleLoginAssistPort, type ConsoleEndpointLoginAccess, loginAuditDetail, LOGIN_SUBMIT_WAIT_MS, } from './console-rpc-extended/index.js';
|
|
18
18
|
export * from './host-installer.js';
|
package/lib/index.js
CHANGED
|
@@ -13,5 +13,5 @@ export { dispatchRuntimeConsoleRpc, pickRpcReply, } from './console-rpc.js';
|
|
|
13
13
|
export { dispatchPluginConsoleRpc, PLUGIN_SWITCH_REQUIRES_GATEWAY } from './console-plugin-rpc.js';
|
|
14
14
|
export { buildProjectFileTree, FILE_MANAGER_ALLOWED, FILE_MANAGER_BLOCKED, isProjectPathAllowed, listEnvFiles, readProjectFile, saveProjectFile, } from './project-files.js';
|
|
15
15
|
export { registerConsoleRestPages, } from './console-rest-pages.js';
|
|
16
|
-
export { dispatchExtendedConsoleRpc, } from './console-rpc-extended/index.js';
|
|
16
|
+
export { dispatchExtendedConsoleRpc, loginAuditDetail, LOGIN_SUBMIT_WAIT_MS, } from './console-rpc-extended/index.js';
|
|
17
17
|
export * from './host-installer.js';
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zhin.js/host-http",
|
|
3
|
-
"version": "1.2.
|
|
3
|
+
"version": "1.2.2",
|
|
4
4
|
"description": "Minimal HTTP and WebSocket host for Plugin Runtime (no Koa)",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./lib/index.js",
|
|
@@ -25,11 +25,11 @@
|
|
|
25
25
|
],
|
|
26
26
|
"dependencies": {
|
|
27
27
|
"ws": "^8.21.1",
|
|
28
|
-
"@zhin.js/console-protocol": "1.2.1",
|
|
29
28
|
"@zhin.js/host-http-contract": "1.2.0",
|
|
30
29
|
"@zhin.js/logger": "1.1.3",
|
|
30
|
+
"@zhin.js/console-protocol": "1.2.2",
|
|
31
31
|
"@zhin.js/schedule": "1.1.5",
|
|
32
|
-
"@zhin.js/plugin-runtime": "1.2.
|
|
32
|
+
"@zhin.js/plugin-runtime": "1.2.1"
|
|
33
33
|
},
|
|
34
34
|
"devDependencies": {
|
|
35
35
|
"@types/node": "^26.1.2",
|
package/src/access-control.ts
CHANGED
|
@@ -237,13 +237,87 @@ export function authorizeConsoleWebSocket(principal: ConsolePrincipal, pathname:
|
|
|
237
237
|
return permission !== null && grantsAllow(principal.grants, permission);
|
|
238
238
|
}
|
|
239
239
|
|
|
240
|
-
/**
|
|
241
|
-
|
|
240
|
+
/**
|
|
241
|
+
* JSON length of the detail of one audit entry. The gateway stores at most 4096 characters of
|
|
242
|
+
* JSON detail; statements are cut so the whole detail stays below this budget once serialised
|
|
243
|
+
* (escapes such as `\n` count twice), so the stored detail always parses.
|
|
244
|
+
*/
|
|
245
|
+
export const AUDIT_DETAIL_JSON_LIMIT = 3_500;
|
|
246
|
+
|
|
247
|
+
export interface RpcAuditOptions {
|
|
248
|
+
/**
|
|
249
|
+
* Replaces literal values in SQL text (`'…'`) before it is recorded, with the dialect's own
|
|
250
|
+
* lexing (the database Host's `redactSql`). Without it no statement text is recorded at all.
|
|
251
|
+
*/
|
|
252
|
+
readonly redactSql?: (sql: string) => string;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function statementTexts(message: Readonly<Record<string, unknown>>, reply: Readonly<Record<string, unknown>> | undefined): string[] {
|
|
256
|
+
if (message.type === 'db:execute' || message.type === 'db:query') return typeof message.sql === 'string' && message.sql ? [message.sql] : [];
|
|
257
|
+
const data = reply?.data && typeof reply.data === 'object' ? reply.data as Record<string, unknown> : undefined;
|
|
258
|
+
return (Array.isArray(data?.statements) ? data.statements as unknown[] : []).filter((entry): entry is string => typeof entry === 'string' && !!entry);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* Statement text of Console SQL (`db:query` / `db:execute`, the DDL `db:schema:apply` ran or
|
|
263
|
+
* would have run), literals redacted; result rows are never recorded.
|
|
264
|
+
*/
|
|
265
|
+
function databaseStatementDetail(
|
|
266
|
+
message: Readonly<Record<string, unknown>>,
|
|
267
|
+
reply: Readonly<Record<string, unknown>> | undefined,
|
|
268
|
+
options: RpcAuditOptions | undefined,
|
|
269
|
+
): Record<string, unknown> {
|
|
270
|
+
if (message.type !== 'db:execute' && message.type !== 'db:query' && message.type !== 'db:schema:apply') return {};
|
|
271
|
+
const change = message.type === 'db:schema:apply' && message.change && typeof message.change === 'object' ? message.change as Record<string, unknown> : {};
|
|
272
|
+
const texts = statementTexts(message, reply);
|
|
273
|
+
const redact = options?.redactSql;
|
|
274
|
+
return {
|
|
275
|
+
...(message.type === 'db:execute' && message.dryRun === true ? { dryRun: true } : {}),
|
|
276
|
+
...(typeof change.kind === 'string' ? { change: change.kind.slice(0, 64) } : {}),
|
|
277
|
+
...(typeof change.table === 'string' ? { table: change.table.slice(0, 256) } : {}),
|
|
278
|
+
...(texts.length && redact
|
|
279
|
+
? { statements: texts.map(text => { try { return redact(text); } catch { return '[unavailable]'; } }) }
|
|
280
|
+
: texts.length ? { statementLength: texts.reduce((total, text) => total + text.length, 0) } : {}),
|
|
281
|
+
};
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/** Cuts the longest statement until the serialised detail fits {@link AUDIT_DETAIL_JSON_LIMIT}. */
|
|
285
|
+
function fitAuditDetail(detail: Record<string, unknown>): Record<string, unknown> {
|
|
286
|
+
const statements = Array.isArray(detail.statements) ? [...detail.statements as string[]] : undefined;
|
|
287
|
+
if (!statements) return detail;
|
|
288
|
+
let truncated = false;
|
|
289
|
+
for (;;) {
|
|
290
|
+
const candidate = { ...detail, statements, ...(truncated ? { truncated: true } : {}) };
|
|
291
|
+
const overflow = JSON.stringify(candidate).length - AUDIT_DETAIL_JSON_LIMIT;
|
|
292
|
+
if (overflow <= 0) return { ...candidate, statements: Object.freeze(statements) };
|
|
293
|
+
truncated = true;
|
|
294
|
+
let longest = 0;
|
|
295
|
+
for (let index = 1; index < statements.length; index += 1) if (statements[index]!.length > statements[longest]!.length) longest = index;
|
|
296
|
+
const text = statements[longest]!;
|
|
297
|
+
if (text.length <= 1) { statements.splice(longest, 1); continue; }
|
|
298
|
+
statements[longest] = `${text.slice(0, Math.max(0, text.length - overflow - 1))}…`;
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* Audit detail for an RPC: type, target and parameter key names only — plus the statement text
|
|
304
|
+
* of Console database writes (pass the reply so the DDL a schema change ran is recorded), with
|
|
305
|
+
* literals redacted by `options.redactSql`.
|
|
306
|
+
*/
|
|
307
|
+
export function rpcAuditDetail(
|
|
308
|
+
message: Readonly<Record<string, unknown>>,
|
|
309
|
+
reply?: Readonly<Record<string, unknown>>,
|
|
310
|
+
options?: RpcAuditOptions,
|
|
311
|
+
): Readonly<Record<string, unknown>> {
|
|
242
312
|
const data = message.data && typeof message.data === 'object' && !Array.isArray(message.data)
|
|
243
313
|
? Object.keys(message.data as Record<string, unknown>).sort()
|
|
244
314
|
: [];
|
|
245
315
|
const keys = Object.keys(message).filter(key => key !== 'type' && key !== 'requestId' && key !== 'data').sort();
|
|
246
|
-
return Object.freeze({
|
|
316
|
+
return Object.freeze(fitAuditDetail({
|
|
317
|
+
type: String(message.type ?? '').slice(0, 256),
|
|
318
|
+
keys: Object.freeze([...new Set([...keys, ...data])].slice(0, 64).map(key => key.slice(0, 128))),
|
|
319
|
+
...databaseStatementDetail(message, reply, options),
|
|
320
|
+
}));
|
|
247
321
|
}
|
|
248
322
|
|
|
249
323
|
export function rpcAuditTarget(rule: ConsoleRpcPermissionRule | null): string | undefined {
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { ConsoleAdapterLoginDeclaration, ConsoleLoginFlow, ConsoleLoginOutcome } from '@zhin.js/console-protocol';
|
|
1
2
|
interface LoginEndpointIdentity {
|
|
2
3
|
readonly canonicalId: string;
|
|
3
4
|
readonly instanceId: string;
|
|
@@ -21,25 +22,31 @@ export interface ConsoleRpcExtendedCtx {
|
|
|
21
22
|
databaseHost?: { models: { get(name: string): unknown } };
|
|
22
23
|
/** Narrow generation-bound persistent-job port; null when Assistant is disabled. */
|
|
23
24
|
resolveScheduleEngine?: () => ConsoleScheduleEngine | null | undefined;
|
|
24
|
-
/** LoginAssist list/submit/cancel
|
|
25
|
-
loginAssist?:
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
payload: Record<string, unknown>;
|
|
33
|
-
createdAt: number;
|
|
34
|
-
expiresAt?: number;
|
|
35
|
-
}[];
|
|
36
|
-
submit(id: string, value: string | Record<string, unknown>, endpointIdentity?: LoginEndpointIdentity): boolean;
|
|
37
|
-
cancel(id: string, reason?: string, endpointIdentity?: LoginEndpointIdentity): boolean;
|
|
38
|
-
};
|
|
25
|
+
/** LoginAssist list/submit/cancel(刷新后仍可消费扫码/滑块待办);ADR 0058 flow methods are optional. */
|
|
26
|
+
loginAssist?: ConsoleLoginAssistPort;
|
|
27
|
+
/** ADR 0058: the Endpoint's login declaration and lease-scoped login control (`null` = unknown Endpoint). */
|
|
28
|
+
withEndpointLogin?: <T>(
|
|
29
|
+
adapter: string,
|
|
30
|
+
endpointKey: string,
|
|
31
|
+
run: (login: ConsoleEndpointLoginAccess) => T | Promise<T>,
|
|
32
|
+
) => Promise<T | null>;
|
|
39
33
|
/** Authenticated Root adapter; mutation DTOs deliberately contain no principal/decision fields. */
|
|
40
34
|
workroomProfileControl?: ConsoleWorkroomProfileControlPort;
|
|
41
35
|
/** Authenticated HTTP principal; never accepted from the RPC body. */
|
|
42
36
|
authenticatedPrincipal?: Readonly<{ principalId: string }>;
|
|
37
|
+
/**
|
|
38
|
+
* The Endpoint the caller was authorized for when its grant covers this call only at an adapter /
|
|
39
|
+
* endpoint scope (never set for global grants, owners or in-process callers). Handlers that act on
|
|
40
|
+
* login tasks or flows restrict themselves to it; set by the dispatcher, never read from the body.
|
|
41
|
+
*/
|
|
42
|
+
authorizedEndpoint?: Readonly<{ adapter: string; endpointKey: string }>;
|
|
43
|
+
/**
|
|
44
|
+
* Whether the caller holds `endpoint.operate` on one Endpoint (set by the dispatcher from the
|
|
45
|
+
* authenticated principal, never read from the body). Login read RPCs return live challenge
|
|
46
|
+
* material (QR image, sensitive blocks, verification links, legacy `payload.image` / `url`) only
|
|
47
|
+
* when it says yes; absent (in-process callers without a principal) = trusted.
|
|
48
|
+
*/
|
|
49
|
+
canOperateEndpoint?: (adapter: string, endpointKey: string) => boolean;
|
|
43
50
|
workroomKnowledgeControl?: ConsoleWorkroomKnowledgeControlPort;
|
|
44
51
|
}
|
|
45
52
|
|
|
@@ -98,7 +105,49 @@ export interface ConsoleScheduleEngine {
|
|
|
98
105
|
listJobs(): Promise<Record<string, unknown>[]>;
|
|
99
106
|
}
|
|
100
107
|
|
|
101
|
-
|
|
108
|
+
/** `data` next to `error` carries a machine-readable code (e.g. `{ code: 'unsupported' }`). */
|
|
109
|
+
export type ExtendedRpcResult = { data: unknown } | { error: string; data?: unknown };
|
|
110
|
+
|
|
111
|
+
/** Structural LoginAssist port (host/http must not import `@zhin.js/core`). */
|
|
112
|
+
export interface ConsoleLoginAssistPort {
|
|
113
|
+
listPending(): readonly {
|
|
114
|
+
id: string;
|
|
115
|
+
adapter: string;
|
|
116
|
+
endpointKey: string;
|
|
117
|
+
endpointIdentity?: LoginEndpointIdentity;
|
|
118
|
+
type: string;
|
|
119
|
+
payload: Record<string, unknown>;
|
|
120
|
+
createdAt: number;
|
|
121
|
+
expiresAt?: number;
|
|
122
|
+
flowId?: string;
|
|
123
|
+
challengeId?: string;
|
|
124
|
+
}[];
|
|
125
|
+
submit(id: string, value: string | Record<string, unknown>, endpointIdentity?: LoginEndpointIdentity): boolean;
|
|
126
|
+
cancel(id: string, reason?: string, endpointIdentity?: LoginEndpointIdentity): boolean;
|
|
127
|
+
/** ADR 0058 */
|
|
128
|
+
listFlows?(): readonly ConsoleLoginFlow[];
|
|
129
|
+
getFlow?(adapter: string, endpointKey: string): ConsoleLoginFlow | undefined;
|
|
130
|
+
getFlowById?(id: string): ConsoleLoginFlow | undefined;
|
|
131
|
+
/**
|
|
132
|
+
* Event-safe copy of a flow (core `withoutLoginMedia`): no QR image, sensitive blocks redacted,
|
|
133
|
+
* verification links dropped. Returned to readers without `endpoint.operate` on the flow's
|
|
134
|
+
* Endpoint; without it the host strips every challenge's media and blocks.
|
|
135
|
+
*/
|
|
136
|
+
eventSafeFlow?(flow: ConsoleLoginFlow): ConsoleLoginFlow;
|
|
137
|
+
submitAndWait?(
|
|
138
|
+
id: string,
|
|
139
|
+
value: string | Record<string, unknown>,
|
|
140
|
+
options?: { endpointIdentity?: LoginEndpointIdentity; timeoutMs?: number },
|
|
141
|
+
): Promise<{ accepted: boolean; outcome?: ConsoleLoginOutcome; pending?: boolean; flow?: ConsoleLoginFlow }>;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export interface ConsoleEndpointLoginAccess {
|
|
145
|
+
readonly declaration?: ConsoleAdapterLoginDeclaration;
|
|
146
|
+
readonly control?: {
|
|
147
|
+
relogin?(request: { signal?: AbortSignal }): Promise<void>;
|
|
148
|
+
refreshChallenge?(request: { signal?: AbortSignal }): Promise<void>;
|
|
149
|
+
};
|
|
150
|
+
}
|
|
102
151
|
|
|
103
152
|
const CRON_NOT_WIRED =
|
|
104
153
|
'持久化调度未接线:当前 Plugin Runtime ScheduleHost 仅支持插件注册的内存任务(list),' +
|
|
@@ -4,7 +4,7 @@ import {
|
|
|
4
4
|
actOnRequest, listInbox, listInboxMessages, listPendingRequests, listRecentInboxMessages,
|
|
5
5
|
mapNoticeRow, mapRequestRow, markInboxConsumed, TABLE_NOTICE, TABLE_REQUEST,
|
|
6
6
|
} from './inbox-rpc.js';
|
|
7
|
-
import { cancelLogin, listLoginPending, submitLogin } from './login-rpc.js';
|
|
7
|
+
import { cancelLogin, controlLogin, getLoginFlow, listLoginFlows, listLoginPending, submitLogin } from './login-rpc.js';
|
|
8
8
|
import {
|
|
9
9
|
deleteFriend, groupWriteOp, listChannels, listFriends, listGroupMembers, listGroups,
|
|
10
10
|
} from './endpoint-rpc.js';
|
|
@@ -49,11 +49,18 @@ export async function dispatchExtendedConsoleRpc(
|
|
|
49
49
|
return actOnRequest(type, d, ctx);
|
|
50
50
|
|
|
51
51
|
case 'login.list':
|
|
52
|
-
return listLoginPending(ctx);
|
|
52
|
+
return listLoginPending(d, ctx);
|
|
53
53
|
case 'login.submit':
|
|
54
54
|
return submitLogin(d, ctx);
|
|
55
55
|
case 'login.cancel':
|
|
56
56
|
return cancelLogin(d, ctx);
|
|
57
|
+
case 'login.flow.list':
|
|
58
|
+
return listLoginFlows(d, ctx);
|
|
59
|
+
case 'login.flow.get':
|
|
60
|
+
return getLoginFlow(d, ctx);
|
|
61
|
+
case 'login.relogin':
|
|
62
|
+
case 'login.refresh':
|
|
63
|
+
return controlLogin(type, d, ctx);
|
|
57
64
|
|
|
58
65
|
case 'request.consumed':
|
|
59
66
|
return markInboxConsumed(d, ctx, TABLE_REQUEST);
|
|
@@ -4,5 +4,8 @@ export type {
|
|
|
4
4
|
ConsoleWorkroomKnowledgeControlPort,
|
|
5
5
|
ConsoleWorkroomProfileControlPort,
|
|
6
6
|
ExtendedRpcResult,
|
|
7
|
+
ConsoleLoginAssistPort,
|
|
8
|
+
ConsoleEndpointLoginAccess,
|
|
7
9
|
} from './contracts.js';
|
|
10
|
+
export { loginAuditDetail, LOGIN_SUBMIT_WAIT_MS } from './login-rpc.js';
|
|
8
11
|
export { dispatchExtendedConsoleRpc } from './dispatcher.js';
|