@zhin.js/host-http 1.2.1 → 1.3.0

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