@zhin.js/host-http 1.1.17 → 1.2.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 (89) hide show
  1. package/README.md +60 -21
  2. package/lib/access-control.d.ts +127 -0
  3. package/lib/access-control.js +148 -0
  4. package/lib/addresses.d.ts +9 -0
  5. package/lib/addresses.js +9 -0
  6. package/lib/bridge/bridged-host.d.ts +43 -0
  7. package/lib/bridge/bridged-host.js +497 -0
  8. package/lib/bridge/control-channel.d.ts +59 -0
  9. package/lib/bridge/control-channel.js +131 -0
  10. package/lib/bridge/flow.d.ts +58 -0
  11. package/lib/bridge/flow.js +188 -0
  12. package/lib/bridge/index.d.ts +6 -0
  13. package/lib/bridge/index.js +6 -0
  14. package/lib/bridge/instance-transport.d.ts +112 -0
  15. package/lib/bridge/instance-transport.js +532 -0
  16. package/lib/bridge/protocol.d.ts +242 -0
  17. package/lib/bridge/protocol.js +369 -0
  18. package/lib/console-events.d.ts +5 -3
  19. package/lib/console-events.js +53 -27
  20. package/lib/console-plugin-rpc.d.ts +5 -1
  21. package/lib/console-plugin-rpc.js +5 -40
  22. package/lib/console-rest-pages-helpers.d.ts +4 -5
  23. package/lib/console-rest-pages-helpers.js +62 -79
  24. package/lib/console-rest-pages.js +80 -65
  25. package/lib/console-rpc-extended/dispatcher.js +1 -6
  26. package/lib/console-rpc-handlers.d.ts +0 -2
  27. package/lib/console-rpc-handlers.js +2 -40
  28. package/lib/console-rpc.d.ts +6 -14
  29. package/lib/console-rpc.js +6 -6
  30. package/lib/host-installer.d.ts +6 -3
  31. package/lib/host-installer.js +10 -40
  32. package/lib/host-options.d.ts +47 -0
  33. package/lib/host-options.js +1 -0
  34. package/lib/http-host.d.ts +124 -48
  35. package/lib/http-host.js +319 -332
  36. package/lib/index.d.ts +10 -5
  37. package/lib/index.js +9 -5
  38. package/lib/json-body.d.ts +1 -2
  39. package/lib/json-body.js +1 -2
  40. package/lib/marketplace-registry.d.ts +17 -0
  41. package/lib/marketplace-registry.js +52 -0
  42. package/lib/node/index.d.ts +10 -0
  43. package/lib/node/index.js +10 -0
  44. package/lib/{listener-group.d.ts → node/listener-group.d.ts} +2 -3
  45. package/lib/{listener-group.js → node/listener-group.js} +6 -10
  46. package/lib/node/listener.d.ts +26 -0
  47. package/lib/node/listener.js +231 -0
  48. package/lib/node/node-http.d.ts +38 -0
  49. package/lib/node/node-http.js +272 -0
  50. package/lib/node/server.d.ts +13 -0
  51. package/lib/node/server.js +64 -0
  52. package/lib/protocol-auth.d.ts +24 -0
  53. package/lib/protocol-auth.js +48 -0
  54. package/lib/public-api.d.ts +1 -1
  55. package/lib/token-registry.d.ts +54 -36
  56. package/lib/token-registry.js +133 -126
  57. package/lib/trust-proxy.d.ts +22 -0
  58. package/lib/trust-proxy.js +168 -0
  59. package/package.json +14 -6
  60. package/src/access-control.ts +255 -0
  61. package/src/addresses.ts +16 -0
  62. package/src/bridge/bridged-host.ts +548 -0
  63. package/src/bridge/control-channel.ts +173 -0
  64. package/src/bridge/flow.ts +208 -0
  65. package/src/bridge/index.ts +31 -0
  66. package/src/bridge/instance-transport.ts +627 -0
  67. package/src/bridge/protocol.ts +574 -0
  68. package/src/console-events.ts +59 -28
  69. package/src/console-plugin-rpc.ts +5 -51
  70. package/src/console-rest-pages-helpers.ts +60 -82
  71. package/src/console-rest-pages.ts +87 -67
  72. package/src/console-rpc-extended/dispatcher.ts +1 -6
  73. package/src/console-rpc-handlers.ts +2 -31
  74. package/src/console-rpc.ts +10 -19
  75. package/src/host-installer.ts +12 -44
  76. package/src/host-options.ts +47 -0
  77. package/src/http-host.ts +467 -421
  78. package/src/index.ts +55 -13
  79. package/src/json-body.ts +7 -2
  80. package/src/marketplace-registry.ts +62 -0
  81. package/src/node/index.ts +21 -0
  82. package/src/{listener-group.ts → node/listener-group.ts} +13 -24
  83. package/src/node/listener.ts +265 -0
  84. package/src/node/node-http.ts +263 -0
  85. package/src/node/server.ts +82 -0
  86. package/src/protocol-auth.ts +52 -0
  87. package/src/public-api.ts +2 -0
  88. package/src/token-registry.ts +143 -146
  89. package/src/trust-proxy.ts +178 -0
@@ -4,24 +4,29 @@
4
4
  * 统一提供日志、市场、Runtime snapshot 内省和 Agent session tree 页面。
5
5
  *
6
6
  * 数据源不可用时降级:读操作返回空数组 + `note` 说明,session tree 返回 503;
7
- * 写操作要求 `ctx.fullScope && authScope === 'full'`,否则 403。
7
+ * 写操作要求 `ctx.fullScope`,否则 403。
8
8
  */
9
9
  import { readFile } from 'node:fs/promises';
10
10
  import { join } from 'node:path';
11
11
  import type { HttpHost } from './http-host.js';
12
12
  import { readJsonBody } from './json-body.js';
13
- import { MarketplaceRegistry } from './marketplace-registry.js';
13
+ import { jsonResponse } from '@zhin.js/host-http-contract';
14
+ import {
15
+ MarketplaceDetailCache,
16
+ MarketplaceRegistry,
17
+ isValidNpmPackageName,
18
+ } from './marketplace-registry.js';
14
19
  import type { ConsoleMessageEventData } from '@zhin.js/console-protocol';
15
20
  import {
16
21
  clampPositiveInt,
17
22
  decodePathParam,
23
+ logInternalError,
18
24
  normalizeBase,
19
25
  parsePositiveInt,
20
26
  registerAgentSessionRoutes,
21
27
  registerIntrospectionRoutes,
22
28
  requireWriteScope,
23
29
  trimTrailingSlashes,
24
- writeJson,
25
30
  } from './console-rest-pages-helpers.js';
26
31
 
27
32
  /** 与 basic/cli 接线方对齐的上下文约定。 */
@@ -135,6 +140,8 @@ type LogRow = {
135
140
  };
136
141
 
137
142
  const PLUGINS_CACHE_TTL = 5 * 60 * 1000;
143
+ const PACKAGE_DETAIL_CACHE_TTL = 5 * 60 * 1000;
144
+ const PACKAGE_DETAIL_CACHE_MAX_ENTRIES = 64;
138
145
 
139
146
  /**
140
147
  * 注册 Console 页面 REST 路由。返回注销函数(注销全部已注册路由)。
@@ -188,7 +195,7 @@ function registerLogsRoutes(
188
195
  };
189
196
  const unavailableNote = 'SystemLog 模型不可用(Database 未启动或未注册 DatabaseLogTransport)';
190
197
 
191
- route('GET', `${base}/logs`, async (_request, response, url) => {
198
+ route('GET', `${base}/logs`, async (_request, { url }) => {
192
199
  const pageSize = clampPositiveInt(parsePositiveInt(url.searchParams.get('pageSize') ?? url.searchParams.get('limit'), 100), 100, 1000);
193
200
  const requestedPage = clampPositiveInt(parsePositiveInt(url.searchParams.get('page'), 1), 1, Number.MAX_SAFE_INTEGER);
194
201
  const level = url.searchParams.get('level');
@@ -200,8 +207,7 @@ function registerLogsRoutes(
200
207
  const where = { ...baseWhere, ...(source ? { source } : {}) };
201
208
  const LogModel = getModel();
202
209
  if (!LogModel) {
203
- writeJson(response, 200, { success: true, data: [], total: 0, page: 1, pageSize, totalPages: 1, sources: [], note: unavailableNote });
204
- return;
210
+ return jsonResponse(200, { success: true, data: [], total: 0, page: 1, pageSize, totalPages: 1, sources: [], note: unavailableNote });
205
211
  }
206
212
  // Database $like has no portable ESCAPE contract. Keep q literal (including
207
213
  // %/_ and Unicode) and filter actual retained rows on the Host, not wildcard SQL.
@@ -228,7 +234,7 @@ function registerLogsRoutes(
228
234
  if (!name || !matchesQuery(row)) continue;
229
235
  counts.set(name, (counts.get(name) ?? 0) + 1);
230
236
  }
231
- writeJson(response, 200, {
237
+ return jsonResponse(200, {
232
238
  success: true,
233
239
  data: logs.map((log) => ({
234
240
  id: log.id,
@@ -243,19 +249,20 @@ function registerLogsRoutes(
243
249
  });
244
250
  }, { summary: 'List system logs', tags: ['console', 'logs'] });
245
251
 
246
- route('DELETE', `${base}/logs`, async (_request, response, _url, authScope) => {
247
- if (!requireWriteScope(response, ctx, authScope)) return;
252
+ route('DELETE', `${base}/logs`, async () => {
253
+ const denied = requireWriteScope(ctx);
254
+ if (denied) return denied;
248
255
  const LogModel = getModel();
249
256
  if (LogModel) {
250
257
  await LogModel.delete({});
251
258
  }
252
- writeJson(response, 200, { success: true, message: '日志已清空' });
259
+ return jsonResponse(200, { success: true, message: '日志已清空' });
253
260
  }, { summary: 'Clear system logs', tags: ['console', 'logs'] });
254
261
 
255
- route('GET', `${base}/logs/stats`, async (_request, response) => {
262
+ route('GET', `${base}/logs/stats`, async () => {
256
263
  const LogModel = getModel();
257
264
  if (!LogModel) {
258
- writeJson(response, 200, {
265
+ return jsonResponse(200, {
259
266
  success: true,
260
267
  data: {
261
268
  total: 0,
@@ -264,7 +271,6 @@ function registerLogsRoutes(
264
271
  },
265
272
  note: unavailableNote,
266
273
  });
267
- return;
268
274
  }
269
275
 
270
276
  // 计数走 DB 侧聚合(count);模型未实现 count 时降级为 select('id') 只取主键列
@@ -293,23 +299,23 @@ function registerLogsRoutes(
293
299
  : (oldestLog[0].timestamp ?? null)
294
300
  : null;
295
301
 
296
- writeJson(response, 200, {
302
+ return jsonResponse(200, {
297
303
  success: true,
298
304
  data: { total, byLevel: levelCounts, oldestTimestamp },
299
305
  });
300
306
  }, { summary: 'System log stats', tags: ['console', 'logs'] });
301
307
 
302
- route('POST', `${base}/logs/cleanup`, async (request, response, _url, authScope) => {
303
- if (!requireWriteScope(response, ctx, authScope)) return;
308
+ route('POST', `${base}/logs/cleanup`, async (request) => {
309
+ const denied = requireWriteScope(ctx);
310
+ if (denied) return denied;
304
311
  const LogModel = getModel();
305
312
  if (!LogModel) {
306
- writeJson(response, 200, {
313
+ return jsonResponse(200, {
307
314
  success: true,
308
315
  message: '已清理 0 条日志',
309
316
  deletedCount: 0,
310
317
  note: unavailableNote,
311
318
  });
312
- return;
313
319
  }
314
320
 
315
321
  const body = (await readJsonBody(request) ?? {}) as {
@@ -342,7 +348,7 @@ function registerLogsRoutes(
342
348
  }
343
349
  }
344
350
 
345
- writeJson(response, 200, {
351
+ return jsonResponse(200, {
346
352
  success: true,
347
353
  message: `已清理 ${deletedCount} 条日志`,
348
354
  deletedCount,
@@ -368,7 +374,47 @@ function registerMarketplaceRoutes(
368
374
  ttlMs: PLUGINS_CACHE_TTL,
369
375
  });
370
376
 
371
- route('GET', '/pub/marketplace/search', async (_request, response, url) => {
377
+ // The detail route is unauthenticated: cache the projected packument so repeated
378
+ // requests cannot be used to hammer the npm registry.
379
+ const details = new MarketplaceDetailCache<Record<string, unknown>>({
380
+ ttlMs: PACKAGE_DETAIL_CACHE_TTL,
381
+ maxEntries: PACKAGE_DETAIL_CACHE_MAX_ENTRIES,
382
+ load: async (pkgName) => {
383
+ const metaResp = await fetchFn(
384
+ `${npmRegistryUrl}/${encodeURIComponent(pkgName)}`,
385
+ { signal: AbortSignal.timeout(30_000) },
386
+ );
387
+ if (!metaResp.ok) throw new Error(`Package not found: ${metaResp.status}`);
388
+ const meta = (await metaResp.json()) as Record<string, unknown>;
389
+ const latest = (meta['dist-tags'] as Record<string, string> | undefined)?.latest;
390
+ const versions = meta.versions as Record<string, Record<string, unknown>> | undefined;
391
+ const latestInfo = latest && versions ? versions[latest] : undefined;
392
+ const time = meta.time as Record<string, string> | undefined;
393
+ return {
394
+ name: meta.name,
395
+ version: latest,
396
+ description: meta.description || '',
397
+ readme: meta.readme || '',
398
+ license: meta.license || latestInfo?.license || '',
399
+ homepage: meta.homepage || latestInfo?.homepage || '',
400
+ repository:
401
+ (meta.repository as { url?: string } | undefined)?.url ||
402
+ (latestInfo?.repository as { url?: string } | undefined)?.url ||
403
+ '',
404
+ author:
405
+ typeof meta.author === 'string'
406
+ ? meta.author
407
+ : (meta.author as { name?: string } | undefined)?.name || '',
408
+ keywords: (latestInfo?.keywords as string[]) || [],
409
+ engines: latestInfo?.engines || {},
410
+ peerDependencies: latestInfo?.peerDependencies || {},
411
+ versions: Object.keys(versions || {}),
412
+ lastPublish: (latest && time?.[latest]) || '',
413
+ };
414
+ },
415
+ });
416
+
417
+ route('GET', '/pub/marketplace/search', async (_request, { url }) => {
372
418
  const q = url.searchParams.get('q') ?? url.searchParams.get('keyword') ?? '';
373
419
  const pageNum = Math.max(1, parseInt(url.searchParams.get('page') ?? '1', 10) || 1);
374
420
  const pageSize = Math.min(
@@ -413,7 +459,7 @@ function registerMarketplaceRoutes(
413
459
  const total = plugins.length;
414
460
  const start = (pageNum - 1) * pageSize;
415
461
  const items = plugins.slice(start, start + pageSize);
416
- writeJson(response, 200, {
462
+ return jsonResponse(200, {
417
463
  success: true,
418
464
  data: items,
419
465
  total,
@@ -421,23 +467,26 @@ function registerMarketplaceRoutes(
421
467
  size: pageSize,
422
468
  });
423
469
  } catch (err) {
424
- writeJson(response, 502, {
470
+ logInternalError('marketplace_search_failed', err);
471
+ return jsonResponse(502, {
425
472
  success: false,
426
- error: err instanceof Error ? err.message : 'Search failed',
473
+ error: 'Search failed',
427
474
  });
428
475
  }
429
476
  }, { summary: 'Search plugin marketplace', tags: ['pub', 'marketplace'] });
430
477
 
431
- route('GET', '/pub/marketplace/detail/*', async (_request, response, url) => {
478
+ route('GET', '/pub/marketplace/detail/*', async (_request, { url }) => {
432
479
  const prefix = '/pub/marketplace/detail/';
433
480
  const rawName = url.pathname.startsWith(prefix)
434
481
  ? url.pathname.slice(prefix.length).replace(/\/+$/u, '')
435
482
  : '';
436
483
  if (!rawName) {
437
- writeJson(response, 404, { success: false, error: 'Package name is required' });
438
- return;
484
+ return jsonResponse(404, { success: false, error: 'Package name is required' });
439
485
  }
440
486
  const pkgName = decodePathParam(rawName);
487
+ if (!isValidNpmPackageName(pkgName)) {
488
+ return jsonResponse(400, { success: false, error: 'Invalid package name' });
489
+ }
441
490
  try {
442
491
  let cachedDownloads = { weekly: 0, monthly: 0 };
443
492
  try {
@@ -453,55 +502,25 @@ function registerMarketplaceRoutes(
453
502
  /* ignore cache miss */
454
503
  }
455
504
 
456
- const metaResp = await fetchFn(
457
- `${npmRegistryUrl}/${encodeURIComponent(pkgName)}`,
458
- { signal: AbortSignal.timeout(30_000) },
459
- );
460
- if (!metaResp.ok) throw new Error(`Package not found: ${metaResp.status}`);
461
- const meta = (await metaResp.json()) as Record<string, unknown>;
462
- const latest = (meta['dist-tags'] as Record<string, string> | undefined)?.latest;
463
- const versions = meta.versions as Record<string, Record<string, unknown>> | undefined;
464
- const latestInfo = latest && versions ? versions[latest] : undefined;
465
- const time = meta.time as Record<string, string> | undefined;
466
- writeJson(response, 200, {
505
+ const detail = await details.get(pkgName);
506
+ return jsonResponse(200, {
467
507
  success: true,
468
- data: {
469
- name: meta.name,
470
- version: latest,
471
- description: meta.description || '',
472
- readme: meta.readme || '',
473
- license: meta.license || latestInfo?.license || '',
474
- homepage: meta.homepage || latestInfo?.homepage || '',
475
- repository:
476
- (meta.repository as { url?: string } | undefined)?.url ||
477
- (latestInfo?.repository as { url?: string } | undefined)?.url ||
478
- '',
479
- author:
480
- typeof meta.author === 'string'
481
- ? meta.author
482
- : (meta.author as { name?: string } | undefined)?.name || '',
483
- keywords: (latestInfo?.keywords as string[]) || [],
484
- engines: latestInfo?.engines || {},
485
- peerDependencies: latestInfo?.peerDependencies || {},
486
- downloads: cachedDownloads,
487
- versions: Object.keys(versions || {}),
488
- lastPublish: (latest && time?.[latest]) || '',
489
- },
508
+ data: { ...detail, downloads: cachedDownloads },
490
509
  });
491
510
  } catch (err) {
492
- writeJson(response, 502, {
511
+ logInternalError('marketplace_detail_failed', err);
512
+ return jsonResponse(502, {
493
513
  success: false,
494
- error: err instanceof Error ? err.message : 'Detail fetch failed',
514
+ error: 'Detail fetch failed',
495
515
  });
496
516
  }
497
517
  }, { summary: 'Plugin marketplace detail', tags: ['pub', 'marketplace'] });
498
518
 
499
- route('GET', `${base}/marketplace/updates`, async (_request, response) => {
519
+ route('GET', `${base}/marketplace/updates`, async () => {
500
520
  try {
501
521
  const installed = await listInstalledPluginPackages(ctx.projectRoot);
502
522
  if (!installed.length) {
503
- writeJson(response, 200, { success: true, data: [] });
504
- return;
523
+ return jsonResponse(200, { success: true, data: [] });
505
524
  }
506
525
  const updates = await Promise.all(
507
526
  installed.map(async ({ name, current }) => {
@@ -524,11 +543,12 @@ function registerMarketplaceRoutes(
524
543
  }
525
544
  }),
526
545
  );
527
- writeJson(response, 200, { success: true, data: updates.filter(Boolean) });
546
+ return jsonResponse(200, { success: true, data: updates.filter(Boolean) });
528
547
  } catch (err) {
529
- writeJson(response, 500, {
548
+ logInternalError('marketplace_updates_failed', err);
549
+ return jsonResponse(500, {
530
550
  success: false,
531
- error: err instanceof Error ? err.message : 'Update check failed',
551
+ error: 'Update check failed',
532
552
  });
533
553
  }
534
554
  }, { summary: 'Check installed plugin updates', tags: ['console', 'marketplace'] });
@@ -1,4 +1,3 @@
1
- import { assertDemoConsoleRpcAllowed } from '@zhin.js/console-protocol';
2
1
  import type { ConsoleRpcExtendedCtx, ExtendedRpcResult } from './contracts.js';
3
2
  import { addCron, listSchedule, mutateCron, validateCron } from './schedule-rpc.js';
4
3
  import {
@@ -16,13 +15,9 @@ export async function dispatchExtendedConsoleRpc(
16
15
  data: Record<string, unknown> | undefined,
17
16
  ctx: ConsoleRpcExtendedCtx,
18
17
  ): Promise<ExtendedRpcResult | undefined> {
18
+ // Callers authorize every message against the ADR 0049 permission map before dispatch.
19
19
  const d = data ?? {};
20
20
 
21
- if (!ctx.fullScope) {
22
- const denied = assertDemoConsoleRpcAllowed(type);
23
- if (denied) return { error: denied };
24
- }
25
-
26
21
  switch (type) {
27
22
  case 'schedule:list':
28
23
  case 'cron:list':
@@ -241,7 +241,8 @@ export async function dispatchHostResourceConsoleRpc(
241
241
  return true;
242
242
  }
243
243
  const tables = ctx.dbTables ? await ctx.dbTables() : [];
244
- emit({ requestId, data: tables });
244
+ // Same `{ tables }` envelope as the database branch above; clients read `.tables`.
245
+ emit({ requestId, data: { tables } });
245
246
  } catch (error) {
246
247
  emit({
247
248
  requestId,
@@ -560,26 +561,6 @@ export function errorReply(
560
561
  return payloads;
561
562
  }
562
563
 
563
- /** HTTP paths under apiBase that demo scope may call (ADR 0016). */
564
- export function isDemoHttpAllowed(method: string, pathname: string, apiBase: string): boolean {
565
- const normalizedBase = apiBase.replace(/\/$/u, '') || '/api';
566
- const m = method.toUpperCase();
567
- // Each inference handler resolves a static authenticated personal binding before reading or mutating anything.
568
- if (m === 'GET' && ['', '/owners', '/export', '/mutation', '/sessions', '/session'].some(path => pathname === `${normalizedBase}/agent/inference${path}`)) return true;
569
- if (m === 'PUT' && pathname === `${normalizedBase}/agent/inference/session`) return true;
570
- if (m === 'POST' && ['/plan', '/apply', '/key/prepare', '/discover', '/profile-schema'].some(path => pathname === `${normalizedBase}/agent/inference${path}`)) return true;
571
- if (m === 'GET' && pathname === `${normalizedBase}/events`) return true;
572
- if (m === 'GET' && pathname === `${normalizedBase}/events/history`) return true;
573
- if (m === 'GET' && DEMO_INTROSPECTION_PATHS.has(pathname.slice(normalizedBase.length))) return true;
574
- if (m === 'POST' && pathname === `${normalizedBase}/console/request`) return true;
575
- // Read-only dashboard / plugin inspection routes (legacy host-api parity).
576
- if (m === 'GET' && pathname === `${normalizedBase}/system/status`) return true;
577
- if (m === 'GET' && pathname === `${normalizedBase}/stats`) return true;
578
- if (m === 'GET' && (pathname === `${normalizedBase}/plugins`
579
- || pathname.startsWith(`${normalizedBase}/plugins/`))) return true;
580
- return false;
581
- }
582
-
583
564
  export function configurationSaveMessage(result: { restartRequired: boolean; revision?: string }
584
565
  & ConsoleConfigApplicationStatus): string {
585
566
  if (result.applicationError === 'superseded') return '配置已保存,但该次生效申请已被替代';
@@ -596,13 +577,3 @@ export function configurationSaveMessage(result: { restartRequired: boolean; rev
596
577
  return result.restartRequired ? '配置已保存,需重启进程才能生效' : '配置已保存';
597
578
  }
598
579
 
599
- const DEMO_INTROSPECTION_PATHS: ReadonlySet<string> = new Set([
600
- '/introspection/commands',
601
- '/introspection/middlewares',
602
- '/introspection/components',
603
- '/introspection/endpoints',
604
- '/introspection/bindings',
605
- '/introspection/tools',
606
- '/introspection/prompt-sections',
607
- '/introspection/mcp',
608
- ]);
@@ -9,8 +9,8 @@ import {
9
9
  type ConsoleConfigSource,
10
10
  type ConsoleConfigExplanation,
11
11
  type ConsoleEndpointSummary,
12
- assertDemoConsoleRpcAllowed,
13
12
  } from '@zhin.js/console-protocol';
13
+ import { authorizeConsoleRpc, type ConsolePrincipal } from './access-control.js';
14
14
  import { dispatchExtendedConsoleRpc, type ConsoleRpcExtendedCtx } from './console-rpc-extended/index.js';
15
15
  import { dispatchPluginConsoleRpc } from './console-plugin-rpc.js';
16
16
  import {
@@ -19,7 +19,6 @@ import {
19
19
  invalidProjectPatchOperations,
20
20
  } from './console-rpc-handlers.js';
21
21
 
22
- export { isDemoHttpAllowed } from './console-rpc-handlers.js';
23
22
 
24
23
  /**
25
24
  * Console RPC 请求结构:未锚定 endpoint 的会话地址(结构对齐
@@ -135,21 +134,8 @@ export interface PluginManagementPort {
135
134
  verificationPlan?(packageName: string): Promise<PluginVerificationPlan>;
136
135
  verify?(packageName: string, checkId: string, expectedGeneration: number | null, expectedProjectRevision: string, expectedCheckRevision: string): Promise<PluginVerificationResult>;
137
136
  planInstall(packageName: string): Promise<PluginInstallPlan>;
138
- install?(packageName: string, expectedRevision?: string, expectedProjectRevision?: string): Promise<Readonly<{
139
- readonly plan: PluginInstallPlan;
140
- readonly restartRequired: boolean;
141
- }>>;
142
137
  planUninstall?(packageName: string): Promise<PluginUninstallPlan>;
143
- uninstall?(packageName: string, expectedRevision?: string, expectedProjectRevision?: string): Promise<Readonly<{
144
- readonly plan: PluginUninstallPlan;
145
- readonly restartRequired: boolean;
146
- }>>;
147
138
  planUpdate?(packageName: string, targetVersion: string): Promise<PluginUpdatePlan>;
148
- update?(packageName: string, targetVersion: string, expectedRevision?: string, expectedProjectRevision?: string): Promise<Readonly<{
149
- readonly plan: PluginUpdatePlan;
150
- readonly installedVersion: string;
151
- readonly restartRequired: boolean;
152
- }>>;
153
139
  }
154
140
 
155
141
  export interface PluginConfigValidation {
@@ -172,6 +158,11 @@ export type RuntimeConsolePage = {
172
158
 
173
159
  export type RuntimeConsoleRpcContext = {
174
160
  readonly authScope: AuthScope;
161
+ /**
162
+ * Caller principal. When set, every message is checked against the ADR 0049 permission map
163
+ * before dispatch (defence in depth; the route also checks).
164
+ */
165
+ readonly principal?: ConsolePrincipal;
175
166
  listPages(): Promise<readonly RuntimeConsolePage[]>;
176
167
  /** Optional project config accessors for read-only config RPCs. */
177
168
  readConfigSource?(): Promise<ConsoleConfigSource>;
@@ -282,10 +273,10 @@ export async function dispatchRuntimeConsoleRpc(
282
273
  const type = String(message.type ?? '');
283
274
  const requestId = message.requestId as number | string | undefined;
284
275
 
285
- if (ctx.authScope === 'demo') {
286
- const denied = assertDemoConsoleRpcAllowed(type);
287
- if (denied) {
288
- emit({ requestId, error: denied });
276
+ if (ctx.principal) {
277
+ const decision = authorizeConsoleRpc(ctx.principal, message as Record<string, unknown>);
278
+ if (!decision.allowed) {
279
+ emit({ requestId, error: decision.error, code: decision.code });
289
280
  return payloads;
290
281
  }
291
282
  }
@@ -2,18 +2,15 @@ import {
2
2
  bindGenerationAdmission,
3
3
  expandEnvironmentValue,
4
4
  type ConfigDocumentPort,
5
+ type GenerationAdmissionBindable,
5
6
  type RootResourceInstaller,
6
7
  type RuntimeConfigDocument,
7
8
  } from '@zhin.js/plugin-runtime';
8
9
  import { readFile } from 'node:fs/promises';
9
10
  import { isAbsolute, resolve } from 'node:path';
10
- import {
11
- httpHostToken,
12
- type HttpHost,
13
- type HttpHostOptions,
14
- type ProcessHttpHost,
15
- } from './http-host.js';
16
- import type { ScopedTokenConfig } from './token-registry.js';
11
+ import { httpHostToken, type HttpHost } from './http-host.js';
12
+ import type { HttpHostOptions } from './host-options.js';
13
+ import { parseTrustProxy } from './trust-proxy.js';
17
14
 
18
15
  export interface ResolvedHttpConfig extends HttpHostOptions {
19
16
  /** Additional listeners that mirror the primary Host route surface. */
@@ -33,14 +30,15 @@ export async function resolveHttpConfig(
33
30
  http,
34
31
  (key) => envOverlay?.[key] ?? process.env[key],
35
32
  ) as Record<string, unknown>;
33
+ if (value.tokens !== undefined && value.tokens !== null) throw new Error(HTTP_TOKENS_REMOVED_MESSAGE);
36
34
  const primary: HttpHostOptions = Object.freeze({
37
35
  host: typeof value.host === 'string' ? value.host : undefined,
38
36
  port: typeof value.port === 'number' ? value.port : undefined,
39
37
  token: typeof value.token === 'string' ? value.token : undefined,
40
- tokens: parseScopedTokens(value.tokens),
41
38
  corsOrigins: injectDesktopWebviewOrigins(parseStringList(value.corsOrigins), envOverlay),
42
39
  apiBase: typeof value.base === 'string' ? value.base : undefined,
43
40
  tls: await parseTlsConfig(value.tls, projectRoot),
41
+ trustProxy: parseTrustProxy(value.trustProxy),
44
42
  });
45
43
  const configuredListeners = await parseAdditionalListeners(value.listeners, primary, projectRoot);
46
44
  const listeners = await injectDesktopDeviceListener(
@@ -190,48 +188,18 @@ function resolveTlsPath(projectRoot: string, value: string): string {
190
188
  return isAbsolute(value) ? value : resolve(projectRoot, value);
191
189
  }
192
190
 
193
- export function installHttpHost(host: ProcessHttpHost): RootResourceInstaller {
191
+ export function installHttpHost(host: HttpHost & GenerationAdmissionBindable<HttpHost>): RootResourceInstaller {
194
192
  return ({ resources, admission }) => {
195
193
  const port: HttpHost = host;
196
194
  resources.provide(httpHostToken, bindGenerationAdmission(port, admission));
197
195
  };
198
196
  }
199
197
 
200
- function parseScopedTokens(value: unknown): readonly ScopedTokenConfig[] | undefined {
201
- if (!Array.isArray(value)) return undefined;
202
- const tokens: ScopedTokenConfig[] = [];
203
- for (const entry of value) {
204
- if (!entry || typeof entry !== 'object') continue;
205
- const record = entry as Record<string, unknown>;
206
- if (typeof record.token !== 'string' || !record.token) continue;
207
- // `user` is an alias of `demo` (an ordinary Console user); anything but `full` is never the operator.
208
- const scope = record.scope === 'full' ? 'full' : 'demo';
209
- const principalId = typeof record.principalId === 'string' && record.principalId.trim() === record.principalId
210
- && record.principalId.length > 0
211
- ? record.principalId
212
- : undefined;
213
- const permissions = parseAccessList(record.permissions);
214
- const roles = parseAccessList(record.roles);
215
- tokens.push(Object.freeze({
216
- token: record.token,
217
- scope,
218
- ...(principalId ? { principalId } : {}),
219
- ...(permissions ? { permissions } : {}),
220
- ...(roles ? { roles } : {}),
221
- }));
222
- }
223
- return tokens.length > 0 ? Object.freeze(tokens) : undefined;
224
- }
225
-
226
- /** Console permissions / roles of a scoped token; blank or non-string entries are dropped. */
227
- function parseAccessList(value: unknown): readonly string[] | undefined {
228
- if (!Array.isArray(value)) return undefined;
229
- const result = value
230
- .filter((item): item is string => typeof item === 'string')
231
- .map((item) => item.trim())
232
- .filter((item) => item.length > 0);
233
- return result.length > 0 ? Object.freeze([...new Set(result)]) : undefined;
234
- }
198
+ /** `http.tokens` was removed by ADR 0049; the Host refuses to start rather than ignore it. */
199
+ export const HTTP_TOKENS_REMOVED_MESSAGE = 'http.tokens 已移除(ADR 0049):请删除该配置,'
200
+ + '改为 Console 用户(`zhin console user create`)与用户签发的 API 令牌;'
201
+ + '`http.token` 只作为首次初始化令牌使用。'
202
+ + ' / http.tokens was removed: delete it and use Console users and their API tokens instead.';
235
203
 
236
204
  function parseStringList(value: unknown): readonly string[] | undefined {
237
205
  if (!Array.isArray(value)) return undefined;
@@ -0,0 +1,47 @@
1
+ import type { TokenRegistry } from './token-registry.js';
2
+ import type { TrustProxySetting } from './trust-proxy.js';
3
+
4
+ export interface HttpHostAddress {
5
+ readonly host: string;
6
+ readonly port: number;
7
+ readonly protocol: 'http' | 'https';
8
+ readonly secure: boolean;
9
+ readonly origin: string;
10
+ }
11
+
12
+ type TlsMaterial = string | Uint8Array | ReadonlyArray<string | Uint8Array | { readonly pem: string | Uint8Array; readonly passphrase?: string }>;
13
+
14
+ export interface HttpHostTlsOptions {
15
+ readonly key: TlsMaterial;
16
+ readonly cert: TlsMaterial;
17
+ readonly ca?: string | Uint8Array | ReadonlyArray<string | Uint8Array>;
18
+ readonly passphrase?: string;
19
+ readonly minVersion?: 'TLSv1.3' | 'TLSv1.2' | 'TLSv1.1' | 'TLSv1';
20
+ readonly ciphers?: string;
21
+ }
22
+
23
+ /** Listener configuration (`http.*`) for the in-process Node server and the gateway. */
24
+ export interface HttpHostOptions {
25
+ readonly host?: string;
26
+ readonly port?: number;
27
+ /** Bootstrap Bearer token (`http.token`), accepted only until the first owner exists. */
28
+ readonly token?: string;
29
+ /** CORS allowlist; always merged with Remote Console origin. */
30
+ readonly corsOrigins?: readonly string[];
31
+ /**
32
+ * Paths under this prefix require Bearer auth when a token registry is configured.
33
+ * Defaults to `/api`. `/pub/*`, Console shell, and page routes stay public.
34
+ */
35
+ readonly apiBase?: string;
36
+ /** Extra HTTP path prefixes that skip auth even under `apiBase`. */
37
+ readonly authExemptPaths?: readonly string[];
38
+ /** Enables HTTPS and WSS on this listener. Certificate lifecycle belongs to the process composition root. */
39
+ readonly tls?: HttpHostTlsOptions;
40
+ /**
41
+ * `http.trustProxy`: which TCP peers are reverse proxies whose `X-Forwarded-For` / `Forwarded`
42
+ * names the client. Off by default (the peer address is the client address).
43
+ */
44
+ readonly trustProxy?: TrustProxySetting;
45
+ /** @internal Shared registry for listener groups; `token` is ignored when set. */
46
+ readonly tokenRegistry?: TokenRegistry;
47
+ }