@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.
@@ -1,43 +1,80 @@
1
+ import { CONSOLE_LOGIN_UNSUPPORTED, LOGIN_RPC } from '@zhin.js/console-protocol';
1
2
  import { strField } from './rpc-values.js';
2
- export function listLoginPending(ctx) {
3
+ const NOT_WIRED = '登录辅助未接线(LoginAssist 未挂载)';
4
+ /** `login.submit` with `wait: true` waits at most this long for the login result. */
5
+ export const LOGIN_SUBMIT_WAIT_MS = 15_000;
6
+ export function listLoginPending(d, ctx) {
3
7
  const assist = ctx.loginAssist;
4
8
  if (!assist)
5
- return { error: '登录辅助未接线(LoginAssist 未挂载)' };
6
- const tasks = assist.listPending().map((task) => ({
9
+ return { error: NOT_WIRED };
10
+ const scope = loginScope(d, ctx);
11
+ if ('error' in scope)
12
+ return scope;
13
+ const tasks = assist.listPending().filter((task) => inScope(scope, task)).map((task) => ({
7
14
  id: task.id,
8
15
  adapter: task.adapter,
9
16
  endpointKey: task.endpointKey,
10
17
  ...(task.endpointIdentity ? { endpointIdentity: task.endpointIdentity } : {}),
11
18
  type: task.type,
12
- payload: task.payload,
19
+ payload: canOperate(ctx, task) ? task.payload : eventSafeLoginPayload(task.payload),
13
20
  createdAt: task.createdAt,
14
21
  expiresAt: task.expiresAt,
22
+ ...(task.flowId ? { flowId: task.flowId } : {}),
23
+ ...(task.challengeId ? { challengeId: task.challengeId } : {}),
15
24
  }));
16
25
  return { data: { tasks, count: tasks.length } };
17
26
  }
18
- export function submitLogin(d, ctx) {
27
+ /**
28
+ * `login.submit { taskId, value, endpointIdentity?, adapter?, endpointKey?, wait? }`.
29
+ * `value` is a string (legacy) or `{ action, values }`. With `wait: true` the reply carries the login
30
+ * result (`{ accepted, outcome, flow }`) or `{ accepted, pending: true }` after 15 s; without it the
31
+ * legacy `{ success: true }` reply is unchanged.
32
+ */
33
+ export async function submitLogin(d, ctx) {
19
34
  const assist = ctx.loginAssist;
20
35
  if (!assist)
21
- return { error: '登录辅助未接线(LoginAssist 未挂载)' };
36
+ return { error: NOT_WIRED };
22
37
  const id = strField(d, 'taskId');
23
38
  if (!id)
24
39
  return { error: 'taskId is required' };
25
- const raw = d.value ?? '';
26
- const value = typeof raw === 'string' || (raw && typeof raw === 'object')
27
- ? raw
28
- : String(raw ?? '');
40
+ const value = readSubmitValue(d.value);
41
+ if (value === false)
42
+ return { error: 'value must be a string or { action, values }' };
29
43
  const identity = readEndpointIdentity(d.endpointIdentity);
30
44
  if (identity === false)
31
45
  return { error: 'Invalid login Endpoint identity' };
32
- const ok = identity ? assist.submit(id, value, identity) : assist.submit(id, value);
33
- if (!ok)
34
- return { error: `login task not found: ${id}` };
35
- return { data: { success: true } };
46
+ const target = checkTaskTarget(d, ctx, id);
47
+ if (target)
48
+ 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)
56
+ return { error: `login task not found: ${id}` };
57
+ if (result.outcome)
58
+ return { data: { accepted: true, outcome: result.outcome, ...(result.flow ? { flow: result.flow } : {}) } };
59
+ return { data: { accepted: true, pending: true, ...(result.flow ? { flow: result.flow } : {}) } };
60
+ }
61
+ const ok = identity ? assist.submit(id, value, identity) : assist.submit(id, value);
62
+ if (!ok)
63
+ return { error: `login task not found: ${id}` };
64
+ return { data: { success: true } };
65
+ }
66
+ catch (error) {
67
+ // LoginSubmissionError: the challenge stays open; its message never contains the value.
68
+ if (error instanceof Error && error.name === 'LoginSubmissionError') {
69
+ return { error: error.message, data: { code: error.code ?? 'input_invalid' } };
70
+ }
71
+ throw error;
72
+ }
36
73
  }
37
74
  export function cancelLogin(d, ctx) {
38
75
  const assist = ctx.loginAssist;
39
76
  if (!assist)
40
- return { error: '登录辅助未接线(LoginAssist 未挂载)' };
77
+ return { error: NOT_WIRED };
41
78
  const id = strField(d, 'taskId');
42
79
  if (!id)
43
80
  return { error: 'taskId is required' };
@@ -45,12 +82,235 @@ export function cancelLogin(d, ctx) {
45
82
  const identity = readEndpointIdentity(d.endpointIdentity);
46
83
  if (identity === false)
47
84
  return { error: 'Invalid login Endpoint identity' };
85
+ const target = checkTaskTarget(d, ctx, id);
86
+ if (target)
87
+ return target;
48
88
  const ok = identity ? assist.cancel(id, reason, identity) : assist.cancel(id, reason);
49
89
  if (!ok)
50
90
  return { error: `login task not found: ${id}` };
51
91
  return { data: { success: true } };
52
92
  }
53
- // ---------------------------------------------------------------- 社交读取
93
+ /**
94
+ * `login.flow.list { adapter?, endpointKey? }`: latest flow per Endpoint. Live challenge material
95
+ * only for Endpoints the caller may operate; the event-safe form otherwise (ADR 0058 §8).
96
+ */
97
+ export function listLoginFlows(d, ctx) {
98
+ const assist = ctx.loginAssist;
99
+ if (!assist)
100
+ return { error: NOT_WIRED };
101
+ if (!assist.listFlows)
102
+ return unsupported('login flows');
103
+ const scope = loginScope(d, ctx);
104
+ if ('error' in scope)
105
+ return scope;
106
+ const flows = assist.listFlows().filter((flow) => inScope(scope, flow)).map((flow) => visibleFlow(assist, ctx, flow));
107
+ return { data: { flows, count: flows.length } };
108
+ }
109
+ /** `login.flow.get { flowId } | { adapter, endpointKey }` → `{ flow: … | null }`. */
110
+ export function getLoginFlow(d, ctx) {
111
+ const assist = ctx.loginAssist;
112
+ if (!assist)
113
+ return { error: NOT_WIRED };
114
+ if (!assist.getFlow || !assist.getFlowById)
115
+ return unsupported('login flows');
116
+ const flowId = strField(d, 'flowId');
117
+ const scope = loginScope(d, ctx);
118
+ if ('error' in scope)
119
+ return scope;
120
+ if (flowId) {
121
+ const flow = assist.getFlowById(flowId);
122
+ // A flow id names one Endpoint; a scoped caller cannot read another Endpoint's flow by id.
123
+ return { data: { flow: flow && inScope(scope, flow) ? visibleFlow(assist, ctx, flow) : null } };
124
+ }
125
+ if (!scope.adapter || !scope.endpointKey)
126
+ return { error: 'flowId or adapter and endpointKey are required' };
127
+ const flow = assist.getFlow(scope.adapter, scope.endpointKey);
128
+ return { data: { flow: flow ? visibleFlow(assist, ctx, flow) : null } };
129
+ }
130
+ /** `login.relogin` / `login.refresh { adapter, endpointKey }` through the Endpoint's login port. */
131
+ export async function controlLogin(type, d, ctx) {
132
+ const scope = loginScope(d, ctx);
133
+ if ('error' in scope)
134
+ return scope;
135
+ const { adapter, endpointKey } = scope;
136
+ if (!adapter || !endpointKey)
137
+ return { error: 'adapter and endpointKey are required' };
138
+ const relogin = type === LOGIN_RPC.RELOGIN;
139
+ const label = relogin ? 'relogin' : 'refresh challenge';
140
+ if (!ctx.withEndpointLogin)
141
+ return unsupported(label);
142
+ const result = await ctx.withEndpointLogin(adapter, endpointKey, async (login) => {
143
+ const declared = relogin ? login.declaration?.relogin === true : login.declaration?.refreshQr === true;
144
+ const run = relogin ? login.control?.relogin : login.control?.refreshChallenge;
145
+ if (!declared || !run)
146
+ return unsupported(label, adapter);
147
+ await run.call(login.control, {});
148
+ return { data: { accepted: true } };
149
+ });
150
+ return result ?? { error: `Endpoint not found: ${adapter}/${endpointKey}` };
151
+ }
152
+ /**
153
+ * Audit detail of a login RPC reply: the result category only (never values, never messages).
154
+ * `undefined` for other RPCs.
155
+ */
156
+ export function loginAuditDetail(type, data) {
157
+ if (type !== LOGIN_RPC.SUBMIT && type !== LOGIN_RPC.CANCEL && type !== LOGIN_RPC.RELOGIN && type !== LOGIN_RPC.REFRESH)
158
+ return undefined;
159
+ if (!data || typeof data !== 'object')
160
+ return undefined;
161
+ const record = data;
162
+ if (typeof record.code === 'string')
163
+ return Object.freeze({ result: record.code });
164
+ if (record.outcome && typeof record.outcome.result === 'string') {
165
+ return Object.freeze({ result: record.outcome.result, ...(typeof record.outcome.code === 'string' ? { code: record.outcome.code } : {}) });
166
+ }
167
+ if (record.pending === true)
168
+ return Object.freeze({ result: 'pending' });
169
+ if (record.accepted === true || record.success === true)
170
+ return Object.freeze({ result: 'accepted' });
171
+ return undefined;
172
+ }
173
+ // ---------------------------------------------------------------- helpers
174
+ function unsupported(what, adapter) {
175
+ return {
176
+ error: `${CONSOLE_LOGIN_UNSUPPORTED}: ${adapter ? `${adapter} does not support ${what}` : `${what} not available on this host`}`,
177
+ data: { code: CONSOLE_LOGIN_UNSUPPORTED },
178
+ };
179
+ }
180
+ /**
181
+ * The Endpoint a login RPC is about: `adapter` / `endpointKey` read from `data` and the top level,
182
+ * exactly the fields the authorization map reads (ADR 0049). Sources that disagree, or a value that
183
+ * is not a string, are rejected. When the dispatcher admitted the caller only through an adapter /
184
+ * endpoint-scoped grant (`ctx.authorizedEndpoint`), the scope is that Endpoint and any other named
185
+ * target is rejected; global callers may omit it (`''` = no filter).
186
+ */
187
+ function loginScope(d, ctx) {
188
+ const sources = d.data && typeof d.data === 'object' && !Array.isArray(d.data)
189
+ ? [d.data, d]
190
+ : [d];
191
+ const read = (key) => {
192
+ let found = '';
193
+ for (const source of sources) {
194
+ const value = source[key];
195
+ if (value === undefined || value === null || value === '')
196
+ continue;
197
+ if (typeof value !== 'string')
198
+ return false;
199
+ const text = value.trim();
200
+ if (!text)
201
+ continue;
202
+ if (found && found !== text)
203
+ return false;
204
+ found = text;
205
+ }
206
+ return found;
207
+ };
208
+ const adapter = read('adapter');
209
+ const endpointKey = read('endpointKey');
210
+ if (adapter === false || endpointKey === false)
211
+ return { error: 'Conflicting login target (adapter / endpointKey)' };
212
+ const authorized = ctx.authorizedEndpoint;
213
+ if (!authorized)
214
+ return { adapter, endpointKey };
215
+ if ((adapter && adapter !== authorized.adapter) || (endpointKey && endpointKey !== authorized.endpointKey)) {
216
+ return { error: 'Login target outside the authorized Endpoint' };
217
+ }
218
+ return { adapter: authorized.adapter, endpointKey: authorized.endpointKey };
219
+ }
220
+ /** The caller may handle this Endpoint's challenge (`endpoint.operate` there); no principal = trusted. */
221
+ function canOperate(ctx, item) {
222
+ return ctx.canOperateEndpoint ? ctx.canOperateEndpoint(item.adapter, item.endpointKey) : true;
223
+ }
224
+ /**
225
+ * A flow as this caller may see it: live for operators, otherwise the event-safe form (no QR image,
226
+ * sensitive blocks redacted, verification links dropped). A port without `eventSafeFlow` falls back
227
+ * to removing every challenge's media and blocks.
228
+ */
229
+ function visibleFlow(assist, ctx, flow) {
230
+ if (canOperate(ctx, flow))
231
+ return flow;
232
+ return assist.eventSafeFlow ? assist.eventSafeFlow(flow) : withoutChallengeMaterial(flow);
233
+ }
234
+ function withoutChallengeMaterial(flow) {
235
+ if (!flow.steps.some((step) => step.challenge?.media || step.challenge?.blocks))
236
+ return flow;
237
+ return Object.freeze({
238
+ ...flow,
239
+ steps: Object.freeze(flow.steps.map((step) => {
240
+ if (!step.challenge || (!step.challenge.media && !step.challenge.blocks))
241
+ return step;
242
+ const { media, blocks: _blocks, ...challenge } = step.challenge;
243
+ return Object.freeze({
244
+ ...step,
245
+ challenge: Object.freeze({
246
+ ...challenge,
247
+ media: Object.freeze({
248
+ ...(media?.expiresAt !== undefined ? { expiresAt: media.expiresAt } : {}),
249
+ ...(media?.refreshable !== undefined ? { refreshable: media.refreshable } : {}),
250
+ ...(media?.qrImage ? { qrImageOmitted: true } : {}),
251
+ ...(media?.link || media?.captcha ? { linkOmitted: true } : {}),
252
+ }),
253
+ }),
254
+ });
255
+ })),
256
+ });
257
+ }
258
+ /**
259
+ * Legacy task payload without the QR image (`image`) and verification link (`url`, may carry a
260
+ * session id), marked `imageOmitted` / `urlOmitted` like the `endpoint.login.*` events.
261
+ */
262
+ function eventSafeLoginPayload(payload) {
263
+ // Allow-list: only the message survives; legacy adapters may put sid / ticket / device anywhere.
264
+ const safe = {};
265
+ if (typeof payload.message === 'string')
266
+ safe.message = payload.message;
267
+ if (payload.image !== undefined)
268
+ safe.imageOmitted = true;
269
+ if (payload.url !== undefined)
270
+ safe.urlOmitted = true;
271
+ if (Object.keys(payload).some((key) => key !== 'message' && key !== 'image' && key !== 'url'))
272
+ safe.fieldsOmitted = true;
273
+ return safe;
274
+ }
275
+ function inScope(scope, item) {
276
+ return (!scope.adapter || item.adapter === scope.adapter) && (!scope.endpointKey || item.endpointKey === scope.endpointKey);
277
+ }
278
+ /** When the caller names an Endpoint, or was authorized for one, the task must belong to it. */
279
+ function checkTaskTarget(d, ctx, id) {
280
+ const scope = loginScope(d, ctx);
281
+ if ('error' in scope)
282
+ return scope;
283
+ if (!scope.adapter && !scope.endpointKey)
284
+ return undefined;
285
+ const task = ctx.loginAssist?.listPending().find((item) => item.id === id);
286
+ if (!task || !inScope(scope, task))
287
+ return { error: `login task not found: ${id}` };
288
+ return undefined;
289
+ }
290
+ function readSubmitValue(raw) {
291
+ if (raw === undefined || raw === null)
292
+ return '';
293
+ if (typeof raw === 'string')
294
+ return raw;
295
+ if (typeof raw === 'number' || typeof raw === 'boolean')
296
+ return String(raw);
297
+ if (typeof raw !== 'object' || Array.isArray(raw))
298
+ return false;
299
+ const record = raw;
300
+ if (record.action === undefined)
301
+ return record;
302
+ if (typeof record.action !== 'string' || !record.action || record.action.length > 64)
303
+ return false;
304
+ if (record.values !== undefined && (!record.values || typeof record.values !== 'object' || Array.isArray(record.values)))
305
+ return false;
306
+ const values = {};
307
+ for (const [key, item] of Object.entries((record.values ?? {}))) {
308
+ if (typeof item !== 'string' && typeof item !== 'number')
309
+ return false;
310
+ values[key] = String(item);
311
+ }
312
+ return { action: record.action, values };
313
+ }
54
314
  function readEndpointIdentity(value) {
55
315
  if (value === undefined)
56
316
  return undefined;
@@ -8,6 +8,8 @@ export declare function wireConversation(channelType: string, channelId: string,
8
8
  */
9
9
  export declare function invalidProjectPatchOperations(patches: readonly unknown[]): string | undefined;
10
10
  export declare function dispatchHostResourceConsoleRpc(type: string, message: RuntimeConsoleRpcMessage, ctx: RuntimeConsoleRpcContext, requestId: number | string | undefined, payloads: RuntimeConsoleRpcReply[]): Promise<boolean>;
11
+ /** Longest statement text the Console SQL editor sends. */
12
+ export declare const MAX_CONSOLE_SQL_LENGTH = 100000;
11
13
  export declare function stringField(message: RuntimeConsoleRpcMessage, key: string): string;
12
14
  export declare function recordField(value: unknown): Record<string, unknown> | undefined;
13
15
  export declare function positiveInteger(value: unknown, fallback: number): number;
@@ -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() : '';
@@ -166,10 +166,6 @@ export type RuntimeConsoleRpcContext = {
166
166
  /** Full-scope project read (`config:get-project`); masked unless `reveal`. */
167
167
  readProjectConfig?(request: import('@zhin.js/console-protocol').ConsoleConfigProjectReadRequest): Promise<import('@zhin.js/console-protocol').ConsoleConfigProjectRead>;
168
168
  applyConfig?(expectedRevision: string): Promise<import('@zhin.js/console-protocol').ConsoleConfigApplyResult>;
169
- /** Persist a declared child Plugin lifecycle state. Takes effect after restart. */
170
- setPluginEnabled?(instanceKey: string, enabled: boolean): Promise<Readonly<{
171
- disabled: readonly string[];
172
- }>>;
173
169
  /** Read-only install planning shared by Console Web and CLI surfaces. */
174
170
  pluginManagement?: PluginManagementPort;
175
171
  validatePluginConfig?(pluginName: string, data: unknown): Promise<PluginConfigValidation>;
@@ -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';
@@ -11,8 +11,8 @@ export { buildOpenApiDocument, patternToOpenApiPath, routeRequiresBearerAuth, ty
11
11
  export { HttpBodyError, readBodyBytes, readJsonBody, readTextBody, type HttpBodyReadOptions, } from './json-body.js';
12
12
  export { createResponseStream, emptyResponse, jsonResponse, SSE_HEADERS, type HttpResponseStream, type HttpResponseStreamOptions, } from '@zhin.js/host-http-contract';
13
13
  export { dispatchRuntimeConsoleRpc, pickRpcReply, type RuntimeConsolePage, type RuntimeConsoleRpcContext, type RuntimeConsoleRpcMessage, type RuntimeConsoleRpcReply, type RuntimeEndpointSendInput, type PluginInstallPlan, type PluginUninstallPlan, type PluginUpdatePlan, type PluginManagementPort, type PluginManagementState, type PluginVerificationResult, type PluginVerificationPlan, type PluginConfigValidation, } from './console-rpc.js';
14
- export { dispatchPluginConsoleRpc } from './console-plugin-rpc.js';
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
@@ -10,8 +10,8 @@ export { buildOpenApiDocument, patternToOpenApiPath, routeRequiresBearerAuth, }
10
10
  export { HttpBodyError, readBodyBytes, readJsonBody, readTextBody, } from './json-body.js';
11
11
  export { createResponseStream, emptyResponse, jsonResponse, SSE_HEADERS, } from '@zhin.js/host-http-contract';
12
12
  export { dispatchRuntimeConsoleRpc, pickRpcReply, } from './console-rpc.js';
13
- export { dispatchPluginConsoleRpc } from './console-plugin-rpc.js';
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.0",
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.0",
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",