@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,14 +4,17 @@
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 { readJsonBody } from './json-body.js';
12
- import { MarketplaceRegistry } from './marketplace-registry.js';
13
- import { clampPositiveInt, decodePathParam, normalizeBase, parsePositiveInt, registerAgentSessionRoutes, registerIntrospectionRoutes, requireWriteScope, trimTrailingSlashes, writeJson, } from './console-rest-pages-helpers.js';
12
+ import { jsonResponse } from '@zhin.js/host-http-contract';
13
+ import { MarketplaceDetailCache, MarketplaceRegistry, isValidNpmPackageName, } from './marketplace-registry.js';
14
+ import { clampPositiveInt, decodePathParam, logInternalError, normalizeBase, parsePositiveInt, registerAgentSessionRoutes, registerIntrospectionRoutes, requireWriteScope, trimTrailingSlashes, } from './console-rest-pages-helpers.js';
14
15
  const PLUGINS_CACHE_TTL = 5 * 60 * 1000;
16
+ const PACKAGE_DETAIL_CACHE_TTL = 5 * 60 * 1000;
17
+ const PACKAGE_DETAIL_CACHE_MAX_ENTRIES = 64;
15
18
  /**
16
19
  * 注册 Console 页面 REST 路由。返回注销函数(注销全部已注册路由)。
17
20
  */
@@ -52,7 +55,7 @@ function registerLogsRoutes(route, base, ctx) {
52
55
  }
53
56
  };
54
57
  const unavailableNote = 'SystemLog 模型不可用(Database 未启动或未注册 DatabaseLogTransport)';
55
- route('GET', `${base}/logs`, async (_request, response, url) => {
58
+ route('GET', `${base}/logs`, async (_request, { url }) => {
56
59
  const pageSize = clampPositiveInt(parsePositiveInt(url.searchParams.get('pageSize') ?? url.searchParams.get('limit'), 100), 100, 1000);
57
60
  const requestedPage = clampPositiveInt(parsePositiveInt(url.searchParams.get('page'), 1), 1, Number.MAX_SAFE_INTEGER);
58
61
  const level = url.searchParams.get('level');
@@ -64,8 +67,7 @@ function registerLogsRoutes(route, base, ctx) {
64
67
  const where = { ...baseWhere, ...(source ? { source } : {}) };
65
68
  const LogModel = getModel();
66
69
  if (!LogModel) {
67
- writeJson(response, 200, { success: true, data: [], total: 0, page: 1, pageSize, totalPages: 1, sources: [], note: unavailableNote });
68
- return;
70
+ return jsonResponse(200, { success: true, data: [], total: 0, page: 1, pageSize, totalPages: 1, sources: [], note: unavailableNote });
69
71
  }
70
72
  // Database $like has no portable ESCAPE contract. Keep q literal (including
71
73
  // %/_ and Unicode) and filter actual retained rows on the Host, not wildcard SQL.
@@ -93,7 +95,7 @@ function registerLogsRoutes(route, base, ctx) {
93
95
  continue;
94
96
  counts.set(name, (counts.get(name) ?? 0) + 1);
95
97
  }
96
- writeJson(response, 200, {
98
+ return jsonResponse(200, {
97
99
  success: true,
98
100
  data: logs.map((log) => ({
99
101
  id: log.id,
@@ -107,19 +109,20 @@ function registerLogsRoutes(route, base, ctx) {
107
109
  sources: [...counts].sort(([a], [b]) => a.localeCompare(b)).map(([source, count]) => ({ source, count })),
108
110
  });
109
111
  }, { summary: 'List system logs', tags: ['console', 'logs'] });
110
- route('DELETE', `${base}/logs`, async (_request, response, _url, authScope) => {
111
- if (!requireWriteScope(response, ctx, authScope))
112
- return;
112
+ route('DELETE', `${base}/logs`, async () => {
113
+ const denied = requireWriteScope(ctx);
114
+ if (denied)
115
+ return denied;
113
116
  const LogModel = getModel();
114
117
  if (LogModel) {
115
118
  await LogModel.delete({});
116
119
  }
117
- writeJson(response, 200, { success: true, message: '日志已清空' });
120
+ return jsonResponse(200, { success: true, message: '日志已清空' });
118
121
  }, { summary: 'Clear system logs', tags: ['console', 'logs'] });
119
- route('GET', `${base}/logs/stats`, async (_request, response) => {
122
+ route('GET', `${base}/logs/stats`, async () => {
120
123
  const LogModel = getModel();
121
124
  if (!LogModel) {
122
- writeJson(response, 200, {
125
+ return jsonResponse(200, {
123
126
  success: true,
124
127
  data: {
125
128
  total: 0,
@@ -128,7 +131,6 @@ function registerLogsRoutes(route, base, ctx) {
128
131
  },
129
132
  note: unavailableNote,
130
133
  });
131
- return;
132
134
  }
133
135
  // 计数走 DB 侧聚合(count);模型未实现 count 时降级为 select('id') 只取主键列
134
136
  const countRows = async (where) => {
@@ -152,23 +154,23 @@ function registerLogsRoutes(route, base, ctx) {
152
154
  ? oldestLog[0].timestamp.toISOString()
153
155
  : (oldestLog[0].timestamp ?? null)
154
156
  : null;
155
- writeJson(response, 200, {
157
+ return jsonResponse(200, {
156
158
  success: true,
157
159
  data: { total, byLevel: levelCounts, oldestTimestamp },
158
160
  });
159
161
  }, { summary: 'System log stats', tags: ['console', 'logs'] });
160
- route('POST', `${base}/logs/cleanup`, async (request, response, _url, authScope) => {
161
- if (!requireWriteScope(response, ctx, authScope))
162
- return;
162
+ route('POST', `${base}/logs/cleanup`, async (request) => {
163
+ const denied = requireWriteScope(ctx);
164
+ if (denied)
165
+ return denied;
163
166
  const LogModel = getModel();
164
167
  if (!LogModel) {
165
- writeJson(response, 200, {
168
+ return jsonResponse(200, {
166
169
  success: true,
167
170
  message: '已清理 0 条日志',
168
171
  deletedCount: 0,
169
172
  note: unavailableNote,
170
173
  });
171
- return;
172
174
  }
173
175
  const body = (await readJsonBody(request) ?? {});
174
176
  const { days, maxRecords } = body;
@@ -193,7 +195,7 @@ function registerLogsRoutes(route, base, ctx) {
193
195
  }
194
196
  }
195
197
  }
196
- writeJson(response, 200, {
198
+ return jsonResponse(200, {
197
199
  success: true,
198
200
  message: `已清理 ${deletedCount} 条日志`,
199
201
  deletedCount,
@@ -209,7 +211,42 @@ function registerMarketplaceRoutes(route, base, ctx, fetchFn, pluginRegistryUrl,
209
211
  url: pluginRegistryUrl,
210
212
  ttlMs: PLUGINS_CACHE_TTL,
211
213
  });
212
- route('GET', '/pub/marketplace/search', async (_request, response, url) => {
214
+ // The detail route is unauthenticated: cache the projected packument so repeated
215
+ // requests cannot be used to hammer the npm registry.
216
+ const details = new MarketplaceDetailCache({
217
+ ttlMs: PACKAGE_DETAIL_CACHE_TTL,
218
+ maxEntries: PACKAGE_DETAIL_CACHE_MAX_ENTRIES,
219
+ load: async (pkgName) => {
220
+ const metaResp = await fetchFn(`${npmRegistryUrl}/${encodeURIComponent(pkgName)}`, { signal: AbortSignal.timeout(30_000) });
221
+ if (!metaResp.ok)
222
+ throw new Error(`Package not found: ${metaResp.status}`);
223
+ const meta = (await metaResp.json());
224
+ const latest = meta['dist-tags']?.latest;
225
+ const versions = meta.versions;
226
+ const latestInfo = latest && versions ? versions[latest] : undefined;
227
+ const time = meta.time;
228
+ return {
229
+ name: meta.name,
230
+ version: latest,
231
+ description: meta.description || '',
232
+ readme: meta.readme || '',
233
+ license: meta.license || latestInfo?.license || '',
234
+ homepage: meta.homepage || latestInfo?.homepage || '',
235
+ repository: meta.repository?.url ||
236
+ latestInfo?.repository?.url ||
237
+ '',
238
+ author: typeof meta.author === 'string'
239
+ ? meta.author
240
+ : meta.author?.name || '',
241
+ keywords: latestInfo?.keywords || [],
242
+ engines: latestInfo?.engines || {},
243
+ peerDependencies: latestInfo?.peerDependencies || {},
244
+ versions: Object.keys(versions || {}),
245
+ lastPublish: (latest && time?.[latest]) || '',
246
+ };
247
+ },
248
+ });
249
+ route('GET', '/pub/marketplace/search', async (_request, { url }) => {
213
250
  const q = url.searchParams.get('q') ?? url.searchParams.get('keyword') ?? '';
214
251
  const pageNum = Math.max(1, parseInt(url.searchParams.get('page') ?? '1', 10) || 1);
215
252
  const pageSize = Math.min(100, Math.max(1, parseInt(url.searchParams.get('size') ?? url.searchParams.get('limit') ?? '20', 10) || 20));
@@ -251,7 +288,7 @@ function registerMarketplaceRoutes(route, base, ctx, fetchFn, pluginRegistryUrl,
251
288
  const total = plugins.length;
252
289
  const start = (pageNum - 1) * pageSize;
253
290
  const items = plugins.slice(start, start + pageSize);
254
- writeJson(response, 200, {
291
+ return jsonResponse(200, {
255
292
  success: true,
256
293
  data: items,
257
294
  total,
@@ -260,22 +297,25 @@ function registerMarketplaceRoutes(route, base, ctx, fetchFn, pluginRegistryUrl,
260
297
  });
261
298
  }
262
299
  catch (err) {
263
- writeJson(response, 502, {
300
+ logInternalError('marketplace_search_failed', err);
301
+ return jsonResponse(502, {
264
302
  success: false,
265
- error: err instanceof Error ? err.message : 'Search failed',
303
+ error: 'Search failed',
266
304
  });
267
305
  }
268
306
  }, { summary: 'Search plugin marketplace', tags: ['pub', 'marketplace'] });
269
- route('GET', '/pub/marketplace/detail/*', async (_request, response, url) => {
307
+ route('GET', '/pub/marketplace/detail/*', async (_request, { url }) => {
270
308
  const prefix = '/pub/marketplace/detail/';
271
309
  const rawName = url.pathname.startsWith(prefix)
272
310
  ? url.pathname.slice(prefix.length).replace(/\/+$/u, '')
273
311
  : '';
274
312
  if (!rawName) {
275
- writeJson(response, 404, { success: false, error: 'Package name is required' });
276
- return;
313
+ return jsonResponse(404, { success: false, error: 'Package name is required' });
277
314
  }
278
315
  const pkgName = decodePathParam(rawName);
316
+ if (!isValidNpmPackageName(pkgName)) {
317
+ return jsonResponse(400, { success: false, error: 'Invalid package name' });
318
+ }
279
319
  try {
280
320
  let cachedDownloads = { weekly: 0, monthly: 0 };
281
321
  try {
@@ -288,51 +328,25 @@ function registerMarketplaceRoutes(route, base, ctx, fetchFn, pluginRegistryUrl,
288
328
  catch {
289
329
  /* ignore cache miss */
290
330
  }
291
- const metaResp = await fetchFn(`${npmRegistryUrl}/${encodeURIComponent(pkgName)}`, { signal: AbortSignal.timeout(30_000) });
292
- if (!metaResp.ok)
293
- throw new Error(`Package not found: ${metaResp.status}`);
294
- const meta = (await metaResp.json());
295
- const latest = meta['dist-tags']?.latest;
296
- const versions = meta.versions;
297
- const latestInfo = latest && versions ? versions[latest] : undefined;
298
- const time = meta.time;
299
- writeJson(response, 200, {
331
+ const detail = await details.get(pkgName);
332
+ return jsonResponse(200, {
300
333
  success: true,
301
- data: {
302
- name: meta.name,
303
- version: latest,
304
- description: meta.description || '',
305
- readme: meta.readme || '',
306
- license: meta.license || latestInfo?.license || '',
307
- homepage: meta.homepage || latestInfo?.homepage || '',
308
- repository: meta.repository?.url ||
309
- latestInfo?.repository?.url ||
310
- '',
311
- author: typeof meta.author === 'string'
312
- ? meta.author
313
- : meta.author?.name || '',
314
- keywords: latestInfo?.keywords || [],
315
- engines: latestInfo?.engines || {},
316
- peerDependencies: latestInfo?.peerDependencies || {},
317
- downloads: cachedDownloads,
318
- versions: Object.keys(versions || {}),
319
- lastPublish: (latest && time?.[latest]) || '',
320
- },
334
+ data: { ...detail, downloads: cachedDownloads },
321
335
  });
322
336
  }
323
337
  catch (err) {
324
- writeJson(response, 502, {
338
+ logInternalError('marketplace_detail_failed', err);
339
+ return jsonResponse(502, {
325
340
  success: false,
326
- error: err instanceof Error ? err.message : 'Detail fetch failed',
341
+ error: 'Detail fetch failed',
327
342
  });
328
343
  }
329
344
  }, { summary: 'Plugin marketplace detail', tags: ['pub', 'marketplace'] });
330
- route('GET', `${base}/marketplace/updates`, async (_request, response) => {
345
+ route('GET', `${base}/marketplace/updates`, async () => {
331
346
  try {
332
347
  const installed = await listInstalledPluginPackages(ctx.projectRoot);
333
348
  if (!installed.length) {
334
- writeJson(response, 200, { success: true, data: [] });
335
- return;
349
+ return jsonResponse(200, { success: true, data: [] });
336
350
  }
337
351
  const updates = await Promise.all(installed.map(async ({ name, current }) => {
338
352
  try {
@@ -352,12 +366,13 @@ function registerMarketplaceRoutes(route, base, ctx, fetchFn, pluginRegistryUrl,
352
366
  return null;
353
367
  }
354
368
  }));
355
- writeJson(response, 200, { success: true, data: updates.filter(Boolean) });
369
+ return jsonResponse(200, { success: true, data: updates.filter(Boolean) });
356
370
  }
357
371
  catch (err) {
358
- writeJson(response, 500, {
372
+ logInternalError('marketplace_updates_failed', err);
373
+ return jsonResponse(500, {
359
374
  success: false,
360
- error: err instanceof Error ? err.message : 'Update check failed',
375
+ error: 'Update check failed',
361
376
  });
362
377
  }
363
378
  }, { summary: 'Check installed plugin updates', tags: ['console', 'marketplace'] });
@@ -1,16 +1,11 @@
1
- import { assertDemoConsoleRpcAllowed } from '@zhin.js/console-protocol';
2
1
  import { addCron, listSchedule, mutateCron, validateCron } from './schedule-rpc.js';
3
2
  import { actOnRequest, listInbox, listInboxMessages, listPendingRequests, listRecentInboxMessages, mapNoticeRow, mapRequestRow, markInboxConsumed, TABLE_NOTICE, TABLE_REQUEST, } from './inbox-rpc.js';
4
3
  import { cancelLogin, listLoginPending, submitLogin } from './login-rpc.js';
5
4
  import { deleteFriend, groupWriteOp, listChannels, listFriends, listGroupMembers, listGroups, } from './endpoint-rpc.js';
6
5
  import { mutateWorkroomKnowledge, mutateWorkroomProfile } from './workroom-rpc.js';
7
6
  export async function dispatchExtendedConsoleRpc(type, data, ctx) {
7
+ // Callers authorize every message against the ADR 0049 permission map before dispatch.
8
8
  const d = data ?? {};
9
- if (!ctx.fullScope) {
10
- const denied = assertDemoConsoleRpcAllowed(type);
11
- if (denied)
12
- return { error: denied };
13
- }
14
9
  switch (type) {
15
10
  case 'schedule:list':
16
11
  case 'cron:list':
@@ -13,8 +13,6 @@ export declare function recordField(value: unknown): Record<string, unknown> | u
13
13
  export declare function positiveInteger(value: unknown, fallback: number): number;
14
14
  export declare function errorMessage(error: unknown): string;
15
15
  export declare function errorReply(payloads: RuntimeConsoleRpcReply[], requestId: number | string | undefined, error: string): RuntimeConsoleRpcReply[];
16
- /** HTTP paths under apiBase that demo scope may call (ADR 0016). */
17
- export declare function isDemoHttpAllowed(method: string, pathname: string, apiBase: string): boolean;
18
16
  export declare function configurationSaveMessage(result: {
19
17
  restartRequired: boolean;
20
18
  revision?: string;
@@ -230,7 +230,8 @@ export async function dispatchHostResourceConsoleRpc(type, message, ctx, request
230
230
  return true;
231
231
  }
232
232
  const tables = ctx.dbTables ? await ctx.dbTables() : [];
233
- emit({ requestId, data: tables });
233
+ // Same `{ tables }` envelope as the database branch above; clients read `.tables`.
234
+ emit({ requestId, data: { tables } });
234
235
  }
235
236
  catch (error) {
236
237
  emit({
@@ -596,35 +597,6 @@ export function errorReply(payloads, requestId, error) {
596
597
  payloads.push({ requestId, error });
597
598
  return payloads;
598
599
  }
599
- /** HTTP paths under apiBase that demo scope may call (ADR 0016). */
600
- export function isDemoHttpAllowed(method, pathname, apiBase) {
601
- const normalizedBase = apiBase.replace(/\/$/u, '') || '/api';
602
- const m = method.toUpperCase();
603
- // Each inference handler resolves a static authenticated personal binding before reading or mutating anything.
604
- if (m === 'GET' && ['', '/owners', '/export', '/mutation', '/sessions', '/session'].some(path => pathname === `${normalizedBase}/agent/inference${path}`))
605
- return true;
606
- if (m === 'PUT' && pathname === `${normalizedBase}/agent/inference/session`)
607
- return true;
608
- if (m === 'POST' && ['/plan', '/apply', '/key/prepare', '/discover', '/profile-schema'].some(path => pathname === `${normalizedBase}/agent/inference${path}`))
609
- return true;
610
- if (m === 'GET' && pathname === `${normalizedBase}/events`)
611
- return true;
612
- if (m === 'GET' && pathname === `${normalizedBase}/events/history`)
613
- return true;
614
- if (m === 'GET' && DEMO_INTROSPECTION_PATHS.has(pathname.slice(normalizedBase.length)))
615
- return true;
616
- if (m === 'POST' && pathname === `${normalizedBase}/console/request`)
617
- return true;
618
- // Read-only dashboard / plugin inspection routes (legacy host-api parity).
619
- if (m === 'GET' && pathname === `${normalizedBase}/system/status`)
620
- return true;
621
- if (m === 'GET' && pathname === `${normalizedBase}/stats`)
622
- return true;
623
- if (m === 'GET' && (pathname === `${normalizedBase}/plugins`
624
- || pathname.startsWith(`${normalizedBase}/plugins/`)))
625
- return true;
626
- return false;
627
- }
628
600
  export function configurationSaveMessage(result) {
629
601
  if (result.applicationError === 'superseded')
630
602
  return '配置已保存,但该次生效申请已被替代';
@@ -647,13 +619,3 @@ export function configurationSaveMessage(result) {
647
619
  }
648
620
  return result.restartRequired ? '配置已保存,需重启进程才能生效' : '配置已保存';
649
621
  }
650
- const DEMO_INTROSPECTION_PATHS = new Set([
651
- '/introspection/commands',
652
- '/introspection/middlewares',
653
- '/introspection/components',
654
- '/introspection/endpoints',
655
- '/introspection/bindings',
656
- '/introspection/tools',
657
- '/introspection/prompt-sections',
658
- '/introspection/mcp',
659
- ]);
@@ -1,8 +1,8 @@
1
1
  import type { AuthScope } from './token-registry.js';
2
2
  import type { DatabaseHostConsole } from '@zhin.js/plugin-runtime';
3
3
  import { type ConsoleConfigPatchRequest, type ConsoleConfigPatchResult, type ConsoleConfigProjectPatchResult, type ConsoleConfigSource, type ConsoleConfigExplanation, type ConsoleEndpointSummary } from '@zhin.js/console-protocol';
4
+ import { type ConsolePrincipal } from './access-control.js';
4
5
  import { type ConsoleRpcExtendedCtx } from './console-rpc-extended/index.js';
5
- export { isDemoHttpAllowed } from './console-rpc-handlers.js';
6
6
  /**
7
7
  * Console RPC 请求结构:未锚定 endpoint 的会话地址(结构对齐
8
8
  * `Omit<ConversationRef,'endpoint'>`;host/http 架构分层不允许依赖
@@ -111,21 +111,8 @@ export interface PluginManagementPort {
111
111
  verificationPlan?(packageName: string): Promise<PluginVerificationPlan>;
112
112
  verify?(packageName: string, checkId: string, expectedGeneration: number | null, expectedProjectRevision: string, expectedCheckRevision: string): Promise<PluginVerificationResult>;
113
113
  planInstall(packageName: string): Promise<PluginInstallPlan>;
114
- install?(packageName: string, expectedRevision?: string, expectedProjectRevision?: string): Promise<Readonly<{
115
- readonly plan: PluginInstallPlan;
116
- readonly restartRequired: boolean;
117
- }>>;
118
114
  planUninstall?(packageName: string): Promise<PluginUninstallPlan>;
119
- uninstall?(packageName: string, expectedRevision?: string, expectedProjectRevision?: string): Promise<Readonly<{
120
- readonly plan: PluginUninstallPlan;
121
- readonly restartRequired: boolean;
122
- }>>;
123
115
  planUpdate?(packageName: string, targetVersion: string): Promise<PluginUpdatePlan>;
124
- update?(packageName: string, targetVersion: string, expectedRevision?: string, expectedProjectRevision?: string): Promise<Readonly<{
125
- readonly plan: PluginUpdatePlan;
126
- readonly installedVersion: string;
127
- readonly restartRequired: boolean;
128
- }>>;
129
116
  }
130
117
  export interface PluginConfigValidation {
131
118
  /** False means only environment-reference checks ran; Runtime must validate TS-only declarations. */
@@ -148,6 +135,11 @@ export type RuntimeConsolePage = {
148
135
  };
149
136
  export type RuntimeConsoleRpcContext = {
150
137
  readonly authScope: AuthScope;
138
+ /**
139
+ * Caller principal. When set, every message is checked against the ADR 0049 permission map
140
+ * before dispatch (defence in depth; the route also checks).
141
+ */
142
+ readonly principal?: ConsolePrincipal;
151
143
  listPages(): Promise<readonly RuntimeConsolePage[]>;
152
144
  /** Optional project config accessors for read-only config RPCs. */
153
145
  readConfigSource?(): Promise<ConsoleConfigSource>;
@@ -1,8 +1,8 @@
1
- import { CONFIG_PATCH_LIMITS, CONFIG_RPC, assertDemoConsoleRpcAllowed, } from '@zhin.js/console-protocol';
1
+ import { CONFIG_PATCH_LIMITS, CONFIG_RPC, } from '@zhin.js/console-protocol';
2
+ import { authorizeConsoleRpc } from './access-control.js';
2
3
  import { dispatchExtendedConsoleRpc } from './console-rpc-extended/index.js';
3
4
  import { dispatchPluginConsoleRpc } from './console-plugin-rpc.js';
4
5
  import { configurationSaveMessage, dispatchHostResourceConsoleRpc, invalidProjectPatchOperations, } from './console-rpc-handlers.js';
5
- export { isDemoHttpAllowed } from './console-rpc-handlers.js';
6
6
  export function pickRpcReply(message, payloads) {
7
7
  const requestId = message.requestId;
8
8
  return ((requestId != null ? payloads.find((payload) => payload.requestId === requestId) : undefined)
@@ -16,10 +16,10 @@ export async function dispatchRuntimeConsoleRpc(message, ctx) {
16
16
  };
17
17
  const type = String(message.type ?? '');
18
18
  const requestId = message.requestId;
19
- if (ctx.authScope === 'demo') {
20
- const denied = assertDemoConsoleRpcAllowed(type);
21
- if (denied) {
22
- emit({ requestId, error: denied });
19
+ if (ctx.principal) {
20
+ const decision = authorizeConsoleRpc(ctx.principal, message);
21
+ if (!decision.allowed) {
22
+ emit({ requestId, error: decision.error, code: decision.code });
23
23
  return payloads;
24
24
  }
25
25
  }
@@ -1,8 +1,11 @@
1
- import { type ConfigDocumentPort, type RootResourceInstaller, type RuntimeConfigDocument } from '@zhin.js/plugin-runtime';
2
- import { type HttpHostOptions, type ProcessHttpHost } from './http-host.js';
1
+ import { type ConfigDocumentPort, type GenerationAdmissionBindable, type RootResourceInstaller, type RuntimeConfigDocument } from '@zhin.js/plugin-runtime';
2
+ import { type HttpHost } from './http-host.js';
3
+ import type { HttpHostOptions } from './host-options.js';
3
4
  export interface ResolvedHttpConfig extends HttpHostOptions {
4
5
  /** Additional listeners that mirror the primary Host route surface. */
5
6
  readonly listeners?: readonly HttpHostOptions[];
6
7
  }
7
8
  export declare function resolveHttpConfig(config: RuntimeConfigDocument | ConfigDocumentPort, envOverlay?: Readonly<Record<string, string | undefined>>, projectRoot?: string): Promise<ResolvedHttpConfig>;
8
- export declare function installHttpHost(host: ProcessHttpHost): RootResourceInstaller;
9
+ export declare function installHttpHost(host: HttpHost & GenerationAdmissionBindable<HttpHost>): RootResourceInstaller;
10
+ /** `http.tokens` was removed by ADR 0049; the Host refuses to start rather than ignore it. */
11
+ export declare const HTTP_TOKENS_REMOVED_MESSAGE: string;
@@ -1,7 +1,8 @@
1
1
  import { bindGenerationAdmission, expandEnvironmentValue, } from '@zhin.js/plugin-runtime';
2
2
  import { readFile } from 'node:fs/promises';
3
3
  import { isAbsolute, resolve } from 'node:path';
4
- import { httpHostToken, } from './http-host.js';
4
+ import { httpHostToken } from './http-host.js';
5
+ import { parseTrustProxy } from './trust-proxy.js';
5
6
  export async function resolveHttpConfig(config, envOverlay, projectRoot = process.cwd()) {
6
7
  const document = await readConfigDocument(config);
7
8
  if (!document || typeof document !== 'object')
@@ -10,14 +11,16 @@ export async function resolveHttpConfig(config, envOverlay, projectRoot = proces
10
11
  if (!http || typeof http !== 'object')
11
12
  return {};
12
13
  const value = expandEnvironmentValue(http, (key) => envOverlay?.[key] ?? process.env[key]);
14
+ if (value.tokens !== undefined && value.tokens !== null)
15
+ throw new Error(HTTP_TOKENS_REMOVED_MESSAGE);
13
16
  const primary = Object.freeze({
14
17
  host: typeof value.host === 'string' ? value.host : undefined,
15
18
  port: typeof value.port === 'number' ? value.port : undefined,
16
19
  token: typeof value.token === 'string' ? value.token : undefined,
17
- tokens: parseScopedTokens(value.tokens),
18
20
  corsOrigins: injectDesktopWebviewOrigins(parseStringList(value.corsOrigins), envOverlay),
19
21
  apiBase: typeof value.base === 'string' ? value.base : undefined,
20
22
  tls: await parseTlsConfig(value.tls, projectRoot),
23
+ trustProxy: parseTrustProxy(value.trustProxy),
21
24
  });
22
25
  const configuredListeners = await parseAdditionalListeners(value.listeners, primary, projectRoot);
23
26
  const listeners = await injectDesktopDeviceListener(configuredListeners, primary, envOverlay, projectRoot);
@@ -154,44 +157,11 @@ export function installHttpHost(host) {
154
157
  resources.provide(httpHostToken, bindGenerationAdmission(port, admission));
155
158
  };
156
159
  }
157
- function parseScopedTokens(value) {
158
- if (!Array.isArray(value))
159
- return undefined;
160
- const tokens = [];
161
- for (const entry of value) {
162
- if (!entry || typeof entry !== 'object')
163
- continue;
164
- const record = entry;
165
- if (typeof record.token !== 'string' || !record.token)
166
- continue;
167
- // `user` is an alias of `demo` (an ordinary Console user); anything but `full` is never the operator.
168
- const scope = record.scope === 'full' ? 'full' : 'demo';
169
- const principalId = typeof record.principalId === 'string' && record.principalId.trim() === record.principalId
170
- && record.principalId.length > 0
171
- ? record.principalId
172
- : undefined;
173
- const permissions = parseAccessList(record.permissions);
174
- const roles = parseAccessList(record.roles);
175
- tokens.push(Object.freeze({
176
- token: record.token,
177
- scope,
178
- ...(principalId ? { principalId } : {}),
179
- ...(permissions ? { permissions } : {}),
180
- ...(roles ? { roles } : {}),
181
- }));
182
- }
183
- return tokens.length > 0 ? Object.freeze(tokens) : undefined;
184
- }
185
- /** Console permissions / roles of a scoped token; blank or non-string entries are dropped. */
186
- function parseAccessList(value) {
187
- if (!Array.isArray(value))
188
- return undefined;
189
- const result = value
190
- .filter((item) => typeof item === 'string')
191
- .map((item) => item.trim())
192
- .filter((item) => item.length > 0);
193
- return result.length > 0 ? Object.freeze([...new Set(result)]) : undefined;
194
- }
160
+ /** `http.tokens` was removed by ADR 0049; the Host refuses to start rather than ignore it. */
161
+ export const HTTP_TOKENS_REMOVED_MESSAGE = 'http.tokens 已移除(ADR 0049):请删除该配置,'
162
+ + '改为 Console 用户(`zhin console user create`)与用户签发的 API 令牌;'
163
+ + '`http.token` 只作为首次初始化令牌使用。'
164
+ + ' / http.tokens was removed: delete it and use Console users and their API tokens instead.';
195
165
  function parseStringList(value) {
196
166
  if (!Array.isArray(value))
197
167
  return undefined;
@@ -0,0 +1,47 @@
1
+ import type { TokenRegistry } from './token-registry.js';
2
+ import type { TrustProxySetting } from './trust-proxy.js';
3
+ export interface HttpHostAddress {
4
+ readonly host: string;
5
+ readonly port: number;
6
+ readonly protocol: 'http' | 'https';
7
+ readonly secure: boolean;
8
+ readonly origin: string;
9
+ }
10
+ type TlsMaterial = string | Uint8Array | ReadonlyArray<string | Uint8Array | {
11
+ readonly pem: string | Uint8Array;
12
+ 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
+ /** Listener configuration (`http.*`) for the in-process Node server and the gateway. */
23
+ export interface HttpHostOptions {
24
+ readonly host?: string;
25
+ readonly port?: number;
26
+ /** Bootstrap Bearer token (`http.token`), accepted only until the first owner exists. */
27
+ readonly token?: string;
28
+ /** CORS allowlist; always merged with Remote Console origin. */
29
+ readonly corsOrigins?: readonly string[];
30
+ /**
31
+ * Paths under this prefix require Bearer auth when a token registry is configured.
32
+ * Defaults to `/api`. `/pub/*`, Console shell, and page routes stay public.
33
+ */
34
+ readonly apiBase?: string;
35
+ /** Extra HTTP path prefixes that skip auth even under `apiBase`. */
36
+ readonly authExemptPaths?: readonly string[];
37
+ /** Enables HTTPS and WSS on this listener. Certificate lifecycle belongs to the process composition root. */
38
+ readonly tls?: HttpHostTlsOptions;
39
+ /**
40
+ * `http.trustProxy`: which TCP peers are reverse proxies whose `X-Forwarded-For` / `Forwarded`
41
+ * names the client. Off by default (the peer address is the client address).
42
+ */
43
+ readonly trustProxy?: TrustProxySetting;
44
+ /** @internal Shared registry for listener groups; `token` is ignored when set. */
45
+ readonly tokenRegistry?: TokenRegistry;
46
+ }
47
+ export {};
@@ -0,0 +1 @@
1
+ export {};