@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.
@@ -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
- emit({ requestId, data: await ctx.database.select(table, page, pageSize, where) });
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: `Failed to select: ${errorMessage(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() : '';
@@ -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 decision = authorizeConsoleRpc(ctx.principal, message);
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, { ...ctx.extended, fullScope: ctx.authScope === 'full' });
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.1",
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.0"
32
+ "@zhin.js/plugin-runtime": "1.2.1"
33
33
  },
34
34
  "devDependencies": {
35
35
  "@types/node": "^26.1.2",
@@ -237,13 +237,87 @@ export function authorizeConsoleWebSocket(principal: ConsolePrincipal, pathname:
237
237
  return permission !== null && grantsAllow(principal.grants, permission);
238
238
  }
239
239
 
240
- /** Audit detail for an RPC: type, target and parameter key names only. */
241
- export function rpcAuditDetail(message: Readonly<Record<string, unknown>>): Readonly<Record<string, unknown>> {
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({ type: String(message.type ?? ''), keys: Object.freeze([...new Set([...keys, ...data])]) });
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
- listPending(): readonly {
27
- id: string;
28
- adapter: string;
29
- endpointKey: string;
30
- endpointIdentity?: LoginEndpointIdentity;
31
- type: string;
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
- export type ExtendedRpcResult = { data: unknown } | { error: string };
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';