@zhin.js/host-http 1.2.0 → 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.
@@ -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 {
@@ -5,10 +5,18 @@ import type {
5
5
  RuntimeConsoleRpcReply,
6
6
  } from './console-rpc.js';
7
7
 
8
+ /**
9
+ * Refusal of the global plugin switch (`plugin:set-enabled`, `feature.set-enabled` at `global`)
10
+ * by an instance: the switch is a gateway transaction (stop → write `disable_plugins` → start,
11
+ * rollback on failure) and is never written by the instance itself (ADR 0054 §4).
12
+ */
13
+ export const PLUGIN_SWITCH_REQUIRES_GATEWAY = '插件全局启停由管理网关执行(停止实例 → 写入 disable_plugins → 启动,失败回滚):请在 zhin start 启动的 Console 中操作,或运行 zhin enable / zhin disable <插件>';
14
+
8
15
  /**
9
16
  * Dispatches the plugin-management slice; undefined means the type belongs elsewhere. Package
10
17
  * installs / updates / uninstalls are not handled in the instance: they are gateway dependency
11
- * transactions (`gateway.packages.*`, ADR 0054 §4) that stop the instance first.
18
+ * transactions (`gateway.packages.*`, ADR 0054 §4) that stop the instance first, and so is the
19
+ * global plugin switch (`plugin:set-enabled`, refused here).
12
20
  */
13
21
  export async function dispatchPluginConsoleRpc(
14
22
  type: string,
@@ -18,32 +26,10 @@ export async function dispatchPluginConsoleRpc(
18
26
  const requestId = message.requestId as number | string | undefined;
19
27
  try {
20
28
  switch (type) {
21
- case PLUGIN_RPC.SET_ENABLED: {
22
- const instanceKey = stringField(message, 'instanceKey');
23
- const enabled = message.enabled;
24
- if (!instanceKey || typeof enabled !== 'boolean') {
25
- return { requestId, error: 'instanceKey and boolean enabled are required' };
26
- }
27
- if (!ctx.setPluginEnabled) {
28
- return { requestId, error: 'Plugin lifecycle management is not configured' };
29
- }
30
- const lifecycle = await ctx.setPluginEnabled(instanceKey, enabled);
31
- ctx.publishEvent?.('plugin:lifecycle-updated', { instanceKey, enabled });
32
- if (ctx.requestRestart) {
33
- setTimeout(() => void Promise.resolve(ctx.requestRestart?.()).catch(() => undefined), 500);
34
- }
35
- return {
36
- requestId,
37
- data: {
38
- success: true,
39
- instanceKey,
40
- enabled,
41
- disabled: lifecycle.disabled,
42
- restartRequired: true,
43
- message: `${instanceKey} 已${enabled ? '启用' : '停用'},Host 正在重启`,
44
- },
45
- };
46
- }
29
+ case PLUGIN_RPC.SET_ENABLED:
30
+ // Global plugin on / off is a management gateway transaction (ADR 0054 §4): the gateway
31
+ // answers it before forwarding. Reaching the instance means no gateway is in front.
32
+ return { requestId, error: PLUGIN_SWITCH_REQUIRES_GATEWAY };
47
33
  case PLUGIN_RPC.INSPECT:
48
34
  case PLUGIN_RPC.APPLY:
49
35
  case PLUGIN_RPC.PLAN_VERIFICATION:
@@ -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';
@@ -1,39 +1,71 @@
1
- import type { ConsoleRpcExtendedCtx, ExtendedRpcResult } from './contracts.js';
1
+ import { CONSOLE_LOGIN_UNSUPPORTED, LOGIN_RPC, type ConsoleLoginFlow } from '@zhin.js/console-protocol';
2
+ import type { ConsoleEndpointLoginAccess, ConsoleLoginAssistPort, ConsoleRpcExtendedCtx, ExtendedRpcResult } from './contracts.js';
2
3
  import { strField } from './rpc-values.js';
3
4
 
4
- export function listLoginPending(ctx: ConsoleRpcExtendedCtx): ExtendedRpcResult {
5
+ const NOT_WIRED = '登录辅助未接线(LoginAssist 未挂载)';
6
+ /** `login.submit` with `wait: true` waits at most this long for the login result. */
7
+ export const LOGIN_SUBMIT_WAIT_MS = 15_000;
8
+
9
+ export function listLoginPending(d: Record<string, unknown>, ctx: ConsoleRpcExtendedCtx): ExtendedRpcResult {
5
10
  const assist = ctx.loginAssist;
6
- if (!assist) return { error: '登录辅助未接线(LoginAssist 未挂载)' };
7
- const tasks = assist.listPending().map((task) => ({
11
+ if (!assist) return { error: NOT_WIRED };
12
+ const scope = loginScope(d, ctx);
13
+ if ('error' in scope) return scope;
14
+ const tasks = assist.listPending().filter((task) => inScope(scope, task)).map((task) => ({
8
15
  id: task.id,
9
16
  adapter: task.adapter,
10
17
  endpointKey: task.endpointKey,
11
18
  ...(task.endpointIdentity ? { endpointIdentity: task.endpointIdentity } : {}),
12
19
  type: task.type,
13
- payload: task.payload,
20
+ payload: canOperate(ctx, task) ? task.payload : eventSafeLoginPayload(task.payload),
14
21
  createdAt: task.createdAt,
15
22
  expiresAt: task.expiresAt,
23
+ ...(task.flowId ? { flowId: task.flowId } : {}),
24
+ ...(task.challengeId ? { challengeId: task.challengeId } : {}),
16
25
  }));
17
26
  return { data: { tasks, count: tasks.length } };
18
27
  }
19
28
 
20
- export function submitLogin(
29
+ /**
30
+ * `login.submit { taskId, value, endpointIdentity?, adapter?, endpointKey?, wait? }`.
31
+ * `value` is a string (legacy) or `{ action, values }`. With `wait: true` the reply carries the login
32
+ * result (`{ accepted, outcome, flow }`) or `{ accepted, pending: true }` after 15 s; without it the
33
+ * legacy `{ success: true }` reply is unchanged.
34
+ */
35
+ export async function submitLogin(
21
36
  d: Record<string, unknown>,
22
37
  ctx: ConsoleRpcExtendedCtx,
23
- ): ExtendedRpcResult {
38
+ ): Promise<ExtendedRpcResult> {
24
39
  const assist = ctx.loginAssist;
25
- if (!assist) return { error: '登录辅助未接线(LoginAssist 未挂载)' };
40
+ if (!assist) return { error: NOT_WIRED };
26
41
  const id = strField(d, 'taskId');
27
42
  if (!id) return { error: 'taskId is required' };
28
- const raw = d.value ?? '';
29
- const value = typeof raw === 'string' || (raw && typeof raw === 'object')
30
- ? raw as string | Record<string, unknown>
31
- : String(raw ?? '');
43
+ const value = readSubmitValue(d.value);
44
+ if (value === false) return { error: 'value must be a string or { action, values }' };
32
45
  const identity = readEndpointIdentity(d.endpointIdentity);
33
46
  if (identity === false) return { error: 'Invalid login Endpoint identity' };
34
- const ok = identity ? assist.submit(id, value, identity) : assist.submit(id, value);
35
- if (!ok) return { error: `login task not found: ${id}` };
36
- return { data: { success: true } };
47
+ const target = checkTaskTarget(d, ctx, id);
48
+ if (target) return target;
49
+ try {
50
+ if (d.wait === true && assist.submitAndWait) {
51
+ const result = await assist.submitAndWait(id, value, {
52
+ ...(identity ? { endpointIdentity: identity } : {}),
53
+ timeoutMs: LOGIN_SUBMIT_WAIT_MS,
54
+ });
55
+ if (!result.accepted) return { error: `login task not found: ${id}` };
56
+ if (result.outcome) return { data: { accepted: true, outcome: result.outcome, ...(result.flow ? { flow: result.flow } : {}) } };
57
+ return { data: { accepted: true, pending: true, ...(result.flow ? { flow: result.flow } : {}) } };
58
+ }
59
+ const ok = identity ? assist.submit(id, value, identity) : assist.submit(id, value);
60
+ if (!ok) return { error: `login task not found: ${id}` };
61
+ return { data: { success: true } };
62
+ } catch (error) {
63
+ // LoginSubmissionError: the challenge stays open; its message never contains the value.
64
+ if (error instanceof Error && error.name === 'LoginSubmissionError') {
65
+ return { error: error.message, data: { code: (error as Error & { code?: string }).code ?? 'input_invalid' } };
66
+ }
67
+ throw error;
68
+ }
37
69
  }
38
70
 
39
71
  export function cancelLogin(
@@ -41,18 +73,219 @@ export function cancelLogin(
41
73
  ctx: ConsoleRpcExtendedCtx,
42
74
  ): ExtendedRpcResult {
43
75
  const assist = ctx.loginAssist;
44
- if (!assist) return { error: '登录辅助未接线(LoginAssist 未挂载)' };
76
+ if (!assist) return { error: NOT_WIRED };
45
77
  const id = strField(d, 'taskId');
46
78
  if (!id) return { error: 'taskId is required' };
47
79
  const reason = strField(d, 'reason') || 'cancelled';
48
80
  const identity = readEndpointIdentity(d.endpointIdentity);
49
81
  if (identity === false) return { error: 'Invalid login Endpoint identity' };
82
+ const target = checkTaskTarget(d, ctx, id);
83
+ if (target) return target;
50
84
  const ok = identity ? assist.cancel(id, reason, identity) : assist.cancel(id, reason);
51
85
  if (!ok) return { error: `login task not found: ${id}` };
52
86
  return { data: { success: true } };
53
87
  }
54
88
 
55
- // ---------------------------------------------------------------- 社交读取
89
+ /**
90
+ * `login.flow.list { adapter?, endpointKey? }`: latest flow per Endpoint. Live challenge material
91
+ * only for Endpoints the caller may operate; the event-safe form otherwise (ADR 0058 §8).
92
+ */
93
+ export function listLoginFlows(d: Record<string, unknown>, ctx: ConsoleRpcExtendedCtx): ExtendedRpcResult {
94
+ const assist = ctx.loginAssist;
95
+ if (!assist) return { error: NOT_WIRED };
96
+ if (!assist.listFlows) return unsupported('login flows');
97
+ const scope = loginScope(d, ctx);
98
+ if ('error' in scope) return scope;
99
+ const flows = assist.listFlows().filter((flow) => inScope(scope, flow)).map((flow) => visibleFlow(assist, ctx, flow));
100
+ return { data: { flows, count: flows.length } };
101
+ }
102
+
103
+ /** `login.flow.get { flowId } | { adapter, endpointKey }` → `{ flow: … | null }`. */
104
+ export function getLoginFlow(d: Record<string, unknown>, ctx: ConsoleRpcExtendedCtx): ExtendedRpcResult {
105
+ const assist = ctx.loginAssist;
106
+ if (!assist) return { error: NOT_WIRED };
107
+ if (!assist.getFlow || !assist.getFlowById) return unsupported('login flows');
108
+ const flowId = strField(d, 'flowId');
109
+ const scope = loginScope(d, ctx);
110
+ if ('error' in scope) return scope;
111
+ if (flowId) {
112
+ const flow = assist.getFlowById(flowId);
113
+ // A flow id names one Endpoint; a scoped caller cannot read another Endpoint's flow by id.
114
+ return { data: { flow: flow && inScope(scope, flow) ? visibleFlow(assist, ctx, flow) : null } };
115
+ }
116
+ if (!scope.adapter || !scope.endpointKey) return { error: 'flowId or adapter and endpointKey are required' };
117
+ const flow = assist.getFlow(scope.adapter, scope.endpointKey);
118
+ return { data: { flow: flow ? visibleFlow(assist, ctx, flow) : null } };
119
+ }
120
+
121
+ /** `login.relogin` / `login.refresh { adapter, endpointKey }` through the Endpoint's login port. */
122
+ export async function controlLogin(
123
+ type: typeof LOGIN_RPC.RELOGIN | typeof LOGIN_RPC.REFRESH,
124
+ d: Record<string, unknown>,
125
+ ctx: ConsoleRpcExtendedCtx,
126
+ ): Promise<ExtendedRpcResult> {
127
+ const scope = loginScope(d, ctx);
128
+ if ('error' in scope) return scope;
129
+ const { adapter, endpointKey } = scope;
130
+ if (!adapter || !endpointKey) return { error: 'adapter and endpointKey are required' };
131
+ const relogin = type === LOGIN_RPC.RELOGIN;
132
+ const label = relogin ? 'relogin' : 'refresh challenge';
133
+ if (!ctx.withEndpointLogin) return unsupported(label);
134
+ const result = await ctx.withEndpointLogin(adapter, endpointKey, async (login: ConsoleEndpointLoginAccess) => {
135
+ const declared = relogin ? login.declaration?.relogin === true : login.declaration?.refreshQr === true;
136
+ const run = relogin ? login.control?.relogin : login.control?.refreshChallenge;
137
+ if (!declared || !run) return unsupported(label, adapter);
138
+ await run.call(login.control, {});
139
+ return { data: { accepted: true } } satisfies ExtendedRpcResult;
140
+ });
141
+ return result ?? { error: `Endpoint not found: ${adapter}/${endpointKey}` };
142
+ }
143
+
144
+ /**
145
+ * Audit detail of a login RPC reply: the result category only (never values, never messages).
146
+ * `undefined` for other RPCs.
147
+ */
148
+ export function loginAuditDetail(type: unknown, data: unknown): Readonly<Record<string, string>> | undefined {
149
+ if (type !== LOGIN_RPC.SUBMIT && type !== LOGIN_RPC.CANCEL && type !== LOGIN_RPC.RELOGIN && type !== LOGIN_RPC.REFRESH) return undefined;
150
+ if (!data || typeof data !== 'object') return undefined;
151
+ const record = data as { outcome?: { result?: unknown; code?: unknown }; pending?: unknown; accepted?: unknown; success?: unknown; code?: unknown };
152
+ if (typeof record.code === 'string') return Object.freeze({ result: record.code });
153
+ if (record.outcome && typeof record.outcome.result === 'string') {
154
+ return Object.freeze({ result: record.outcome.result, ...(typeof record.outcome.code === 'string' ? { code: record.outcome.code } : {}) });
155
+ }
156
+ if (record.pending === true) return Object.freeze({ result: 'pending' });
157
+ if (record.accepted === true || record.success === true) return Object.freeze({ result: 'accepted' });
158
+ return undefined;
159
+ }
160
+
161
+ // ---------------------------------------------------------------- helpers
162
+
163
+ function unsupported(what: string, adapter?: string): ExtendedRpcResult {
164
+ return {
165
+ error: `${CONSOLE_LOGIN_UNSUPPORTED}: ${adapter ? `${adapter} does not support ${what}` : `${what} not available on this host`}`,
166
+ data: { code: CONSOLE_LOGIN_UNSUPPORTED },
167
+ };
168
+ }
169
+
170
+ interface LoginScope { readonly adapter: string; readonly endpointKey: string }
171
+
172
+ /**
173
+ * The Endpoint a login RPC is about: `adapter` / `endpointKey` read from `data` and the top level,
174
+ * exactly the fields the authorization map reads (ADR 0049). Sources that disagree, or a value that
175
+ * is not a string, are rejected. When the dispatcher admitted the caller only through an adapter /
176
+ * endpoint-scoped grant (`ctx.authorizedEndpoint`), the scope is that Endpoint and any other named
177
+ * target is rejected; global callers may omit it (`''` = no filter).
178
+ */
179
+ function loginScope(d: Record<string, unknown>, ctx: ConsoleRpcExtendedCtx): LoginScope | { error: string } {
180
+ const sources = d.data && typeof d.data === 'object' && !Array.isArray(d.data)
181
+ ? [d.data as Record<string, unknown>, d]
182
+ : [d];
183
+ const read = (key: 'adapter' | 'endpointKey'): string | false => {
184
+ let found = '';
185
+ for (const source of sources) {
186
+ const value = source[key];
187
+ if (value === undefined || value === null || value === '') continue;
188
+ if (typeof value !== 'string') return false;
189
+ const text = value.trim();
190
+ if (!text) continue;
191
+ if (found && found !== text) return false;
192
+ found = text;
193
+ }
194
+ return found;
195
+ };
196
+ const adapter = read('adapter');
197
+ const endpointKey = read('endpointKey');
198
+ if (adapter === false || endpointKey === false) return { error: 'Conflicting login target (adapter / endpointKey)' };
199
+ const authorized = ctx.authorizedEndpoint;
200
+ if (!authorized) return { adapter, endpointKey };
201
+ if ((adapter && adapter !== authorized.adapter) || (endpointKey && endpointKey !== authorized.endpointKey)) {
202
+ return { error: 'Login target outside the authorized Endpoint' };
203
+ }
204
+ return { adapter: authorized.adapter, endpointKey: authorized.endpointKey };
205
+ }
206
+
207
+ /** The caller may handle this Endpoint's challenge (`endpoint.operate` there); no principal = trusted. */
208
+ function canOperate(ctx: ConsoleRpcExtendedCtx, item: { readonly adapter: string; readonly endpointKey: string }): boolean {
209
+ return ctx.canOperateEndpoint ? ctx.canOperateEndpoint(item.adapter, item.endpointKey) : true;
210
+ }
211
+
212
+ /**
213
+ * A flow as this caller may see it: live for operators, otherwise the event-safe form (no QR image,
214
+ * sensitive blocks redacted, verification links dropped). A port without `eventSafeFlow` falls back
215
+ * to removing every challenge's media and blocks.
216
+ */
217
+ function visibleFlow(assist: ConsoleLoginAssistPort, ctx: ConsoleRpcExtendedCtx, flow: ConsoleLoginFlow): ConsoleLoginFlow {
218
+ if (canOperate(ctx, flow)) return flow;
219
+ return assist.eventSafeFlow ? assist.eventSafeFlow(flow) : withoutChallengeMaterial(flow);
220
+ }
221
+
222
+ function withoutChallengeMaterial(flow: ConsoleLoginFlow): ConsoleLoginFlow {
223
+ if (!flow.steps.some((step) => step.challenge?.media || step.challenge?.blocks)) return flow;
224
+ return Object.freeze({
225
+ ...flow,
226
+ steps: Object.freeze(flow.steps.map((step) => {
227
+ if (!step.challenge || (!step.challenge.media && !step.challenge.blocks)) return step;
228
+ const { media, blocks: _blocks, ...challenge } = step.challenge;
229
+ return Object.freeze({
230
+ ...step,
231
+ challenge: Object.freeze({
232
+ ...challenge,
233
+ media: Object.freeze({
234
+ ...(media?.expiresAt !== undefined ? { expiresAt: media.expiresAt } : {}),
235
+ ...(media?.refreshable !== undefined ? { refreshable: media.refreshable } : {}),
236
+ ...(media?.qrImage ? { qrImageOmitted: true } : {}),
237
+ ...(media?.link || media?.captcha ? { linkOmitted: true } : {}),
238
+ }),
239
+ }),
240
+ });
241
+ })),
242
+ });
243
+ }
244
+
245
+ /**
246
+ * Legacy task payload without the QR image (`image`) and verification link (`url`, may carry a
247
+ * session id), marked `imageOmitted` / `urlOmitted` like the `endpoint.login.*` events.
248
+ */
249
+ function eventSafeLoginPayload(payload: Record<string, unknown>): Record<string, unknown> {
250
+ // Allow-list: only the message survives; legacy adapters may put sid / ticket / device anywhere.
251
+ const safe: Record<string, unknown> = {};
252
+ if (typeof payload.message === 'string') safe.message = payload.message;
253
+ if (payload.image !== undefined) safe.imageOmitted = true;
254
+ if (payload.url !== undefined) safe.urlOmitted = true;
255
+ if (Object.keys(payload).some((key) => key !== 'message' && key !== 'image' && key !== 'url')) safe.fieldsOmitted = true;
256
+ return safe;
257
+ }
258
+
259
+ function inScope(scope: LoginScope, item: { readonly adapter: string; readonly endpointKey: string }): boolean {
260
+ return (!scope.adapter || item.adapter === scope.adapter) && (!scope.endpointKey || item.endpointKey === scope.endpointKey);
261
+ }
262
+
263
+ /** When the caller names an Endpoint, or was authorized for one, the task must belong to it. */
264
+ function checkTaskTarget(d: Record<string, unknown>, ctx: ConsoleRpcExtendedCtx, id: string): ExtendedRpcResult | undefined {
265
+ const scope = loginScope(d, ctx);
266
+ if ('error' in scope) return scope;
267
+ if (!scope.adapter && !scope.endpointKey) return undefined;
268
+ const task = ctx.loginAssist?.listPending().find((item) => item.id === id);
269
+ if (!task || !inScope(scope, task)) return { error: `login task not found: ${id}` };
270
+ return undefined;
271
+ }
272
+
273
+ function readSubmitValue(raw: unknown): string | Record<string, unknown> | false {
274
+ if (raw === undefined || raw === null) return '';
275
+ if (typeof raw === 'string') return raw;
276
+ if (typeof raw === 'number' || typeof raw === 'boolean') return String(raw);
277
+ if (typeof raw !== 'object' || Array.isArray(raw)) return false;
278
+ const record = raw as Record<string, unknown>;
279
+ if (record.action === undefined) return record;
280
+ if (typeof record.action !== 'string' || !record.action || record.action.length > 64) return false;
281
+ if (record.values !== undefined && (!record.values || typeof record.values !== 'object' || Array.isArray(record.values))) return false;
282
+ const values: Record<string, string> = {};
283
+ for (const [key, item] of Object.entries((record.values ?? {}) as Record<string, unknown>)) {
284
+ if (typeof item !== 'string' && typeof item !== 'number') return false;
285
+ values[key] = String(item);
286
+ }
287
+ return { action: record.action, values };
288
+ }
56
289
 
57
290
  function readEndpointIdentity(value: unknown): { readonly canonicalId: string; readonly instanceId: string; readonly epoch: number; readonly incarnationId: string } | false | undefined {
58
291
  if (value === undefined) return undefined;