@zhin.js/host-http 1.0.10 → 1.0.12

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.
@@ -8,7 +8,9 @@
8
8
  * 数据源约定(只读真实数据源,不做内存假数据):
9
9
  * - cron:`ctx.scheduleHost`(Plugin Runtime ScheduleHost,仅 register/list,
10
10
  * 无持久化引擎,故 cron:add/remove/pause/resume 报"未接线")。
11
- * - inbox:`ctx.databaseHost.models`(unified_inbox_message/request/notice 三张表,
11
+ * - request.list:优先 `management.listRequests()`(live endpoint);否则回退
12
+ * unified_inbox_request。审批始终走 approveRequest/rejectRequest。
13
+ * - inbox(历史):`ctx.databaseHost.models`(unified_inbox_message/request/notice,
12
14
  * 表不存在或查询失败 → 空数组 + inboxEnabled:false;requestConsumed/noticeConsumed
13
15
  * 按行 id update consumed=1,表/model 缺失时报"未接线")。
14
16
  * - 社交/群管:`ctx.withEndpointManagement` 在固定 generation lease 内
@@ -19,6 +21,7 @@ import { assertDemoConsoleRpcAllowed, normalizeConsoleRpcType, } from '@zhin.js/
19
21
  const TABLE_MESSAGE = 'unified_inbox_message';
20
22
  const TABLE_REQUEST = 'unified_inbox_request';
21
23
  const TABLE_NOTICE = 'unified_inbox_notice';
24
+ const MAX_INBOX_OFFSET = 10_000;
22
25
  const CRON_NOT_WIRED = '持久化调度未接线:当前 Plugin Runtime ScheduleHost 仅支持插件注册的内存任务(list),' +
23
26
  '不支持 add/remove/pause/resume(需要 @zhin.js/agent 持久化调度引擎)';
24
27
  const CONSUMED_NOT_WIRED = '收件箱已读标记未接线:unified_inbox_request/notice 表未注册或 DatabaseHost 未启动';
@@ -51,6 +54,12 @@ export async function dispatchExtendedConsoleRpc(type, data, ctx) {
51
54
  case 'request.approve':
52
55
  case 'request.reject':
53
56
  return actOnRequest(type, d, ctx);
57
+ case 'login.list':
58
+ return listLoginPending(ctx);
59
+ case 'login.submit':
60
+ return submitLogin(d, ctx);
61
+ case 'login.cancel':
62
+ return cancelLogin(d, ctx);
54
63
  case 'request.consumed':
55
64
  return markInboxConsumed(d, ctx, TABLE_REQUEST);
56
65
  case 'notice.consumed':
@@ -86,10 +95,126 @@ export async function dispatchExtendedConsoleRpc(type, data, ctx) {
86
95
  });
87
96
  case 'endpoint.delete_friend':
88
97
  return deleteFriend(d, ctx);
98
+ case 'workroom.profile.pack.publish':
99
+ case 'workroom.profile.publish':
100
+ case 'workroom.profile.rollback':
101
+ case 'workroom.profile.policy.publish':
102
+ return mutateWorkroomProfile(type, d, ctx);
103
+ case 'workroom.knowledge.get':
104
+ case 'workroom.knowledge.publish':
105
+ case 'workroom.knowledge.rollback':
106
+ return mutateWorkroomKnowledge(type, d, ctx);
89
107
  default:
90
108
  return undefined;
91
109
  }
92
110
  }
111
+ async function mutateWorkroomProfile(type, data, ctx) {
112
+ const control = ctx.workroomProfileControl;
113
+ if (!control)
114
+ return { error: 'Workroom Profile authority control is unavailable' };
115
+ for (const forbidden of [
116
+ 'authenticatedPrincipalId', 'principalId', 'decision', 'governance', 'approval', 'authority',
117
+ ]) {
118
+ if (forbidden in data)
119
+ return { error: `Console Workroom Profile request cannot carry ${forbidden}` };
120
+ }
121
+ const principal = ctx.authenticatedPrincipal;
122
+ if (!principal)
123
+ return { error: 'Workroom Profile control requires an authenticated principal' };
124
+ try {
125
+ const operationId = requiredRpcText(data.operationId, 'operationId');
126
+ if (type === 'workroom.profile.pack.publish') {
127
+ return { data: await control.publishPack(Object.freeze({ operationId, pack: requiredRpcValue(data.pack, 'pack') }), principal) };
128
+ }
129
+ const projectId = requiredRpcText(data.projectId, 'projectId');
130
+ if (type === 'workroom.profile.policy.publish') {
131
+ const expectedPreviousDigest = optionalRpcText(data.expectedPreviousDigest, 'expectedPreviousDigest');
132
+ return { data: await control.publishPlanningPolicy(Object.freeze({
133
+ operationId,
134
+ projectId,
135
+ profileRevisionId: requiredRpcText(data.profileRevisionId, 'profileRevisionId'),
136
+ revision: requiredRpcInteger(data.revision, 'revision'),
137
+ ...(expectedPreviousDigest ? { expectedPreviousDigest } : {}),
138
+ policy: requiredRpcValue(data.policy, 'policy'),
139
+ }), principal) };
140
+ }
141
+ const common = {
142
+ operationId,
143
+ projectId,
144
+ expectedRegistryRevision: requiredRpcInteger(data.expectedRegistryRevision, 'expectedRegistryRevision', -1),
145
+ overlay: requiredRpcValue(data.overlay, 'overlay'),
146
+ activate: requiredRpcBoolean(data.activate, 'activate'),
147
+ };
148
+ return type === 'workroom.profile.rollback'
149
+ ? { data: await control.publishRollback(Object.freeze({
150
+ ...common,
151
+ restoredFromRevisionId: requiredRpcText(data.restoredFromRevisionId, 'restoredFromRevisionId'),
152
+ }), principal) }
153
+ : { data: await control.publishProfile(Object.freeze(common), principal) };
154
+ }
155
+ catch (error) {
156
+ return { error: errorMessage(error) };
157
+ }
158
+ }
159
+ async function mutateWorkroomKnowledge(type, data, ctx) {
160
+ const control = ctx.workroomKnowledgeControl;
161
+ if (!control)
162
+ return { error: 'Workroom Knowledge control is unavailable' };
163
+ for (const forbidden of [
164
+ 'authenticatedPrincipalId', 'principalId', 'ownerPrincipalId', 'source', 'decision',
165
+ 'governance', 'approval', 'authority', 'body', 'content',
166
+ ]) {
167
+ if (forbidden in data)
168
+ return { error: `Console Workroom Knowledge request cannot carry ${forbidden}` };
169
+ }
170
+ try {
171
+ const projectId = requiredRpcText(data.projectId, 'projectId');
172
+ if (type === 'workroom.knowledge.get')
173
+ return { data: await control.read(projectId) };
174
+ const principal = ctx.authenticatedPrincipal;
175
+ if (!principal)
176
+ return { error: 'Workroom Knowledge mutation requires an authenticated principal' };
177
+ const common = {
178
+ operationId: requiredRpcText(data.operationId, 'operationId'), projectId,
179
+ expectedRevision: requiredRpcInteger(data.expectedRevision, 'expectedRevision', -1),
180
+ };
181
+ if (type === 'workroom.knowledge.rollback') {
182
+ return { data: await control.rollback(Object.freeze({
183
+ ...common, restoreRevision: requiredRpcInteger(data.restoreRevision, 'restoreRevision', 0),
184
+ }), principal) };
185
+ }
186
+ if (!Array.isArray(data.entries))
187
+ throw new Error('entries is invalid');
188
+ return { data: await control.publish(Object.freeze({ ...common, entries: structuredClone(data.entries) }), principal) };
189
+ }
190
+ catch (error) {
191
+ return { error: errorMessage(error) };
192
+ }
193
+ }
194
+ function requiredRpcValue(value, name) {
195
+ if (value === undefined || value === null)
196
+ throw new Error(`${name} is required`);
197
+ return structuredClone(value);
198
+ }
199
+ function requiredRpcText(value, name) {
200
+ if (typeof value !== 'string' || !value.trim() || value !== value.trim()) {
201
+ throw new Error(`${name} is invalid`);
202
+ }
203
+ return value;
204
+ }
205
+ function optionalRpcText(value, name) {
206
+ return value === undefined ? undefined : requiredRpcText(value, name);
207
+ }
208
+ function requiredRpcInteger(value, name, minimum = 1) {
209
+ if (!Number.isSafeInteger(value) || Number(value) < minimum)
210
+ throw new Error(`${name} is invalid`);
211
+ return Number(value);
212
+ }
213
+ function requiredRpcBoolean(value, name) {
214
+ if (typeof value !== 'boolean')
215
+ throw new Error(`${name} is invalid`);
216
+ return value;
217
+ }
93
218
  // ---------------------------------------------------------------- cron
94
219
  function listSchedule(ctx) {
95
220
  const host = ctx.scheduleHost;
@@ -206,21 +331,30 @@ function getInboxModel(ctx, table) {
206
331
  async function readInboxRows(ctx, table, where, options) {
207
332
  const model = getInboxModel(ctx, table);
208
333
  if (!model)
209
- return { rows: [], enabled: false };
334
+ return { rows: [], enabled: false, offsetApplied: false };
210
335
  try {
211
336
  let selection = model.select().where(where);
337
+ let orderApplied = options?.orderBy == null;
212
338
  if (options?.orderBy && typeof selection.orderBy === 'function') {
213
339
  selection = selection.orderBy(options.orderBy.field, options.orderBy.direction ?? 'DESC');
340
+ orderApplied = true;
341
+ }
342
+ const offset = options?.offset ?? 0;
343
+ const offsetSelection = orderApplied ? selection.offset : undefined;
344
+ const canApplyOffset = typeof offsetSelection === 'function';
345
+ const offsetApplied = offset === 0 || canApplyOffset;
346
+ if (offset > 0 && canApplyOffset) {
347
+ selection = offsetSelection.call(selection, offset);
214
348
  }
215
- if (options?.limit != null && typeof selection.limit === 'function') {
216
- selection = selection.limit(options.limit);
349
+ if (options?.limit != null && orderApplied && typeof selection.limit === 'function') {
350
+ selection = selection.limit(options.limit + (offsetApplied ? 0 : offset));
217
351
  }
218
352
  const rows = await selection;
219
- return { rows: Array.isArray(rows) ? rows : [], enabled: true };
353
+ return { rows: Array.isArray(rows) ? rows : [], enabled: true, offsetApplied };
220
354
  }
221
355
  catch {
222
356
  // 表未创建 / 方言未启动等场景降级为空,不向上抛。
223
- return { rows: [], enabled: false };
357
+ return { rows: [], enabled: false, offsetApplied: false };
224
358
  }
225
359
  }
226
360
  async function listInbox(d, ctx, table, key, mapRow) {
@@ -228,29 +362,60 @@ async function listInbox(d, ctx, table, key, mapRow) {
228
362
  const endpointKey = strField(d, '$endpoint', 'endpointKey', 'endpoint');
229
363
  if (!adapter || !endpointKey)
230
364
  return { error: '$adapter and $endpoint required' };
231
- const limit = Math.min(numField(d, 30, '$limit', 'limit'), 100);
232
- const offset = Math.max(0, numField(d, 0, '$offset', 'offset'));
233
- const { rows, enabled } = await readInboxRows(ctx, table, {
365
+ const limit = boundedIntegerField(d, 30, 1, 100, '$limit', 'limit');
366
+ const offset = boundedIntegerField(d, 0, 0, MAX_INBOX_OFFSET, '$offset', 'offset');
367
+ const unreadOnly = table === TABLE_NOTICE
368
+ && boolField(d, false, '$unread_only', 'unreadOnly', 'unread_only');
369
+ const { rows, enabled, offsetApplied } = await readInboxRows(ctx, table, {
234
370
  adapter,
235
371
  endpoint_id: endpointKey,
372
+ ...(unreadOnly ? { consumed: 0 } : {}),
236
373
  }, {
237
374
  // sort/limit 下推到 DB 侧(内存 sort/slice 保留,作为无下推能力模型的兜底)
238
375
  orderBy: { field: 'created_at', direction: 'DESC' },
239
- limit: offset + limit,
376
+ limit,
377
+ offset,
240
378
  });
241
379
  const sorted = rows
242
380
  .slice()
243
381
  .sort((a, b) => Number(b.created_at ?? 0) - Number(a.created_at ?? 0))
244
- .slice(offset, offset + limit)
382
+ .slice(offsetApplied ? 0 : offset, (offsetApplied ? 0 : offset) + limit)
245
383
  .map(mapRow);
246
384
  return { data: { [key]: sorted, inboxEnabled: enabled } };
247
385
  }
248
- /** request.list —— 未处理的好友/群请求(unified_inbox_request 中 resolved=0)。 */
386
+ /** request.list —— 优先 live EndpointManagement.listRequests;否则回退 unified_inbox_request。 */
249
387
  async function listPendingRequests(d, ctx) {
250
388
  const adapter = strField(d, '$adapter', 'adapter');
251
389
  const endpointKey = strField(d, '$endpoint', 'endpointKey', 'endpoint');
252
390
  if (!adapter || !endpointKey)
253
391
  return { error: '$adapter and $endpoint required' };
392
+ if (ctx.withEndpointManagement) {
393
+ try {
394
+ const live = await ctx.withEndpointManagement(adapter, endpointKey, async (management) => {
395
+ if (!management.listRequests)
396
+ return undefined;
397
+ const pending = await management.listRequests();
398
+ const requests = pending.map((row) => mapRequestRow({
399
+ platform_request_id: row.platform_request_id,
400
+ type: row.type,
401
+ scene_type: row.scene_type ?? null,
402
+ scene_id: row.scene_id,
403
+ sub_type: row.sub_type ?? null,
404
+ actor_id: row.actor_id,
405
+ actor_name: row.actor_name ?? null,
406
+ comment: row.comment ?? null,
407
+ created_at: row.created_at,
408
+ resolved: 0,
409
+ }));
410
+ return { data: { requests, inboxEnabled: false, source: 'endpoint' } };
411
+ });
412
+ if (live)
413
+ return live;
414
+ }
415
+ catch (error) {
416
+ return { error: errorMessage(error) };
417
+ }
418
+ }
254
419
  const { rows, enabled } = await readInboxRows(ctx, TABLE_REQUEST, {
255
420
  adapter,
256
421
  endpoint_id: endpointKey,
@@ -261,7 +426,7 @@ async function listPendingRequests(d, ctx) {
261
426
  .filter((row) => Number(row.resolved ?? 0) === 0)
262
427
  .sort((a, b) => Number(a.created_at ?? 0) - Number(b.created_at ?? 0))
263
428
  .map(mapRequestRow);
264
- return { data: { requests, inboxEnabled: enabled } };
429
+ return { data: { requests, inboxEnabled: enabled, source: 'inbox' } };
265
430
  }
266
431
  async function listInboxMessages(d, ctx) {
267
432
  const adapter = strField(d, '$adapter', 'adapter');
@@ -271,7 +436,7 @@ async function listInboxMessages(d, ctx) {
271
436
  if (!adapter || !endpointKey || !channelId || !channelType) {
272
437
  return { error: '$adapter, $endpoint, $channel_id, $channel_type required' };
273
438
  }
274
- const limit = Math.min(numField(d, 50, '$limit', 'limit'), 100);
439
+ const limit = boundedIntegerField(d, 50, 1, 100, '$limit', 'limit');
275
440
  const beforeTs = optionalNum(d, '$before_ts', 'beforeTs', 'before_ts');
276
441
  const beforeId = optionalNum(d, '$before_id', 'beforeId', 'before_id');
277
442
  const parent = normalizeParent(d.$parent ?? d.parent);
@@ -338,16 +503,18 @@ function mapRequestRow(row) {
338
503
  function mapNoticeRow(row) {
339
504
  const actorId = row.actor_id ?? undefined;
340
505
  const actorName = row.actor_name ?? undefined;
341
- const sceneId = row.scene_id;
342
- const sceneType = row.scene_type ?? undefined;
506
+ const sceneId = String(row.scene_id ?? '');
507
+ const sceneType = row.scene_type == null ? undefined : String(row.scene_type);
343
508
  return {
344
509
  id: row.id,
345
510
  platform_notice_id: row.platform_notice_id,
346
511
  type: row.type,
512
+ noticeType: row.type,
347
513
  scene_type: sceneType,
348
514
  scene_id: sceneId,
349
515
  channel_id: sceneId,
350
516
  channel_type: sceneType,
517
+ channel: { id: sceneId, type: sceneType ?? '' },
351
518
  sub_type: row.sub_type ?? undefined,
352
519
  actor_id: actorId,
353
520
  actor_name: actorName,
@@ -359,6 +526,8 @@ function mapNoticeRow(row) {
359
526
  payload: row.payload,
360
527
  created_at: row.created_at,
361
528
  timestamp: row.created_at,
529
+ consumed: row.consumed,
530
+ consumed_at: row.consumed_at ?? undefined,
362
531
  };
363
532
  }
364
533
  // ---------------------------------------------------------------- consumed 标记
@@ -414,6 +583,50 @@ async function actOnRequest(type, d, ctx) {
414
583
  return { data: { success: true } };
415
584
  });
416
585
  }
586
+ function listLoginPending(ctx) {
587
+ const assist = ctx.loginAssist;
588
+ if (!assist)
589
+ return { error: '登录辅助未接线(LoginAssist 未挂载)' };
590
+ const tasks = assist.listPending().map((task) => ({
591
+ id: task.id,
592
+ adapter: task.adapter,
593
+ endpointKey: task.endpointKey,
594
+ type: task.type,
595
+ payload: task.payload,
596
+ createdAt: task.createdAt,
597
+ expiresAt: task.expiresAt,
598
+ }));
599
+ return { data: { tasks, count: tasks.length } };
600
+ }
601
+ function submitLogin(d, ctx) {
602
+ const assist = ctx.loginAssist;
603
+ if (!assist)
604
+ return { error: '登录辅助未接线(LoginAssist 未挂载)' };
605
+ const id = strField(d, '$id', 'id', 'taskId');
606
+ if (!id)
607
+ return { error: '$id required' };
608
+ const raw = d.$value ?? d.value ?? d.ticket ?? d.code ?? '';
609
+ const value = typeof raw === 'string' || (raw && typeof raw === 'object')
610
+ ? raw
611
+ : String(raw ?? '');
612
+ const ok = assist.submit(id, value);
613
+ if (!ok)
614
+ return { error: `login task not found: ${id}` };
615
+ return { data: { success: true } };
616
+ }
617
+ function cancelLogin(d, ctx) {
618
+ const assist = ctx.loginAssist;
619
+ if (!assist)
620
+ return { error: '登录辅助未接线(LoginAssist 未挂载)' };
621
+ const id = strField(d, '$id', 'id', 'taskId');
622
+ if (!id)
623
+ return { error: '$id required' };
624
+ const reason = strField(d, '$reason', 'reason') || 'cancelled';
625
+ const ok = assist.cancel(id, reason);
626
+ if (!ok)
627
+ return { error: `login task not found: ${id}` };
628
+ return { data: { success: true } };
629
+ }
417
630
  // ---------------------------------------------------------------- 社交读取
418
631
  async function listFriends(d, ctx) {
419
632
  const endpoint = requireEndpointAddress(d);
@@ -530,6 +743,20 @@ function numField(d, fallback, ...keys) {
530
743
  }
531
744
  return fallback;
532
745
  }
746
+ function boundedIntegerField(d, fallback, minimum, maximum, ...keys) {
747
+ const value = numField(d, fallback, ...keys);
748
+ if (!Number.isFinite(value))
749
+ return fallback;
750
+ return Math.min(maximum, Math.max(minimum, Math.trunc(value)));
751
+ }
752
+ function boolField(d, fallback, ...keys) {
753
+ for (const key of keys) {
754
+ const value = d[key];
755
+ if (typeof value === 'boolean')
756
+ return value;
757
+ }
758
+ return fallback;
759
+ }
533
760
  function optionalNum(d, ...keys) {
534
761
  for (const key of keys) {
535
762
  if (d[key] == null)
@@ -50,6 +50,16 @@ export type RuntimeConsoleRpcContext = {
50
50
  setConfigKey?(pluginName: string, data: unknown): Promise<{
51
51
  restartRequired: boolean;
52
52
  }>;
53
+ /** Atomic, revision-checked runtime Workroom Catalog read/write. */
54
+ readWorkroomCatalog?(): Promise<Readonly<{
55
+ agents: Readonly<Record<string, unknown>>;
56
+ workrooms: Readonly<Record<string, unknown>>;
57
+ revision: string;
58
+ }>>;
59
+ setWorkroomCatalog?(workrooms: unknown, expectedRevision: string): Promise<Readonly<{
60
+ revision: string;
61
+ restartRequired: boolean;
62
+ }>>;
53
63
  /** Project file manager (allowlisted paths under project root). */
54
64
  listProjectFiles?(): Promise<readonly unknown[]>;
55
65
  readProjectFile?(filePath: string): Promise<{
@@ -116,6 +116,41 @@ export async function dispatchRuntimeConsoleRpc(message, ctx) {
116
116
  }
117
117
  return payloads;
118
118
  }
119
+ case 'workrooms:get': {
120
+ try {
121
+ if (!ctx.readWorkroomCatalog) {
122
+ emit({ requestId, error: 'Workroom Catalog read is not configured' });
123
+ return payloads;
124
+ }
125
+ emit({ requestId, data: await ctx.readWorkroomCatalog() });
126
+ }
127
+ catch (error) {
128
+ emit({ requestId, error: error instanceof Error ? error.message : String(error) });
129
+ }
130
+ return payloads;
131
+ }
132
+ case 'workrooms:set': {
133
+ try {
134
+ if (!ctx.setWorkroomCatalog) {
135
+ emit({ requestId, error: 'Workroom Catalog write is not configured' });
136
+ return payloads;
137
+ }
138
+ const expectedRevision = typeof message.expectedRevision === 'string'
139
+ ? message.expectedRevision
140
+ : '';
141
+ if (!/^[a-f0-9]{64}$/u.test(expectedRevision)) {
142
+ emit({ requestId, error: 'expectedRevision is required' });
143
+ return payloads;
144
+ }
145
+ const result = await ctx.setWorkroomCatalog(message.data, expectedRevision);
146
+ ctx.publishEvent?.('workrooms:updated', { revision: result.revision });
147
+ emit({ requestId, data: { success: true, ...result } });
148
+ }
149
+ catch (error) {
150
+ emit({ requestId, error: error instanceof Error ? error.message : String(error) });
151
+ }
152
+ return payloads;
153
+ }
119
154
  case 'config:save-yaml': {
120
155
  try {
121
156
  const yaml = message.yaml;
@@ -664,6 +699,10 @@ export function isDemoHttpAllowed(method, pathname, apiBase) {
664
699
  const m = method.toUpperCase();
665
700
  if (m === 'GET' && pathname === `${normalizedBase}/events`)
666
701
  return true;
702
+ if (m === 'GET' && pathname === `${normalizedBase}/events/history`)
703
+ return true;
704
+ if (m === 'GET' && DEMO_INTROSPECTION_PATHS.has(pathname.slice(normalizedBase.length)))
705
+ return true;
667
706
  if (m === 'POST' && pathname === `${normalizedBase}/console/request`)
668
707
  return true;
669
708
  // Read-only dashboard / plugin inspection routes (legacy host-api parity).
@@ -676,3 +715,12 @@ export function isDemoHttpAllowed(method, pathname, apiBase) {
676
715
  return true;
677
716
  return false;
678
717
  }
718
+ const DEMO_INTROSPECTION_PATHS = new Set([
719
+ '/introspection/commands',
720
+ '/introspection/middlewares',
721
+ '/introspection/components',
722
+ '/introspection/endpoints',
723
+ '/introspection/bindings',
724
+ '/introspection/tools',
725
+ '/introspection/mcp',
726
+ ]);
@@ -1,7 +1,7 @@
1
1
  import { type IncomingMessage, type ServerResponse } from 'node:http';
2
2
  import { WebSocket } from 'ws';
3
3
  import { type GenerationAdmissionBindable } from '@zhin.js/plugin-runtime';
4
- import { TokenRegistry, type AuthScope, type ScopedTokenConfig } from './token-registry.js';
4
+ import { TokenRegistry, type AuthenticatedTokenPrincipal, type AuthScope, type ScopedTokenConfig } from './token-registry.js';
5
5
  import { type ListedRoute, type RouteMeta } from './openapi.js';
6
6
  export declare const REMOTE_CONSOLE_ORIGIN = "https://console.zhin.dev";
7
7
  export interface WsConnection {
@@ -34,7 +34,7 @@ export interface HttpHostOptions {
34
34
  /** Extra HTTP path prefixes that skip auth even under `apiBase`. */
35
35
  readonly authExemptPaths?: readonly string[];
36
36
  }
37
- export type HttpHandler = (request: IncomingMessage, response: ServerResponse, url: URL, authScope: AuthScope) => void | Promise<void>;
37
+ export type HttpHandler = (request: IncomingMessage, response: ServerResponse, url: URL, authScope: AuthScope, authenticatedPrincipal?: AuthenticatedTokenPrincipal) => void | Promise<void>;
38
38
  export interface HttpRouteRegistration {
39
39
  (): void;
40
40
  }
package/lib/http-host.js CHANGED
@@ -218,7 +218,7 @@ export function createHttpHost(options = {}) {
218
218
  return;
219
219
  }
220
220
  if (!release) {
221
- await route.handler(request, response, url, auth.scope);
221
+ await route.handler(request, response, url, auth.scope, auth.principal);
222
222
  return;
223
223
  }
224
224
  let released = false;
@@ -232,7 +232,7 @@ export function createHttpHost(options = {}) {
232
232
  };
233
233
  response.once('finish', releaseResponse);
234
234
  response.once('close', releaseResponse);
235
- await route.handler(request, response, url, auth.scope);
235
+ await route.handler(request, response, url, auth.scope, auth.principal);
236
236
  if (response.writableFinished || response.destroyed)
237
237
  releaseResponse();
238
238
  }
@@ -272,7 +272,8 @@ export function createHttpHost(options = {}) {
272
272
  if (!isDemoHttpAllowed(method, pathname, apiBase))
273
273
  return { ok: false };
274
274
  }
275
- return { ok: true, scope };
275
+ const principal = tokenRegistry.resolvePrincipal(token);
276
+ return { ok: true, scope, ...(principal ? { principal } : {}) };
276
277
  }
277
278
  function listListedRoutes() {
278
279
  return Object.freeze(httpRoutes
package/lib/index.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  export { createHttpHost, httpHostToken, REMOTE_CONSOLE_ORIGIN, type HttpHandler, type HttpHost, type ProcessHttpHost, type HttpHostAddress, type HttpHostOptions, type HttpRouteRegistration, type WsConnection, type WsHandle, } from './http-host.js';
2
- export { TokenRegistry, extractBearerToken, isDemoWebSocketPath, type AuthScope, type ScopedTokenConfig, type TokenRegistryConfig, } from './token-registry.js';
2
+ export { TokenRegistry, extractBearerToken, isDemoWebSocketPath, type AuthenticatedTokenPrincipal, type AuthScope, type ScopedTokenConfig, type TokenRegistryConfig, } from './token-registry.js';
3
3
  export { timingSafeEqualString } from './timing-safe-equal.js';
4
4
  export { createConsoleEventHub, type ConsoleEventHub, } from './console-events.js';
5
5
  export { buildOpenApiDocument, patternToOpenApiPath, routeRequiresBearerAuth, type BuildOpenApiOptions, type ListedRoute, type OpenApiParameter, type RouteMeta, } from './openapi.js';
@@ -7,4 +7,4 @@ export { HttpBodyError, readJsonBody, } from './json-body.js';
7
7
  export { dispatchRuntimeConsoleRpc, isDemoHttpAllowed, pickRpcReply, type RuntimeConsolePage, type RuntimeConsoleRpcContext, type RuntimeConsoleRpcMessage, type RuntimeConsoleRpcReply, type RuntimeEndpointSendInput, type RuntimeEndpointSummary, } from './console-rpc.js';
8
8
  export { buildProjectFileTree, FILE_MANAGER_ALLOWED, FILE_MANAGER_BLOCKED, isProjectPathAllowed, listEnvFiles, readProjectFile, saveProjectFile, type FileTreeNode, } from './project-files.js';
9
9
  export { registerConsoleRestPages, type ConsoleAgentIntrospection, type ConsoleAgentRuntime, type ConsoleAgentSessionTree, type ConsoleRestCtx, type ConsoleRestPagesOptions, } from './console-rest-pages.js';
10
- export { dispatchExtendedConsoleRpc, type ConsoleRpcExtendedCtx, type ConsoleScheduleEngine, type ExtendedRpcResult, } from './console-rpc-extended.js';
10
+ export { dispatchExtendedConsoleRpc, type ConsoleRpcExtendedCtx, type ConsoleScheduleEngine, type ConsoleWorkroomProfileControlPort, type ConsoleWorkroomKnowledgeControlPort, type ExtendedRpcResult, } from './console-rpc-extended.js';
@@ -3,7 +3,13 @@ export type AuthScope = 'full' | 'demo';
3
3
  export type ScopedTokenConfig = {
4
4
  readonly token: string;
5
5
  readonly scope: AuthScope;
6
+ /** Root-configured subject binding; never accepted from an HTTP payload. */
7
+ readonly principalId?: string;
6
8
  };
9
+ export interface AuthenticatedTokenPrincipal {
10
+ readonly principalId: string;
11
+ readonly scope: AuthScope;
12
+ }
7
13
  export type TokenRegistryConfig = {
8
14
  readonly primaryToken?: string;
9
15
  readonly scopedTokens?: readonly ScopedTokenConfig[];
@@ -12,6 +18,7 @@ export declare class TokenRegistry {
12
18
  #private;
13
19
  constructor(config?: TokenRegistryConfig);
14
20
  resolve(token: string): AuthScope | null;
21
+ resolvePrincipal(token: string): AuthenticatedTokenPrincipal | null;
15
22
  hasAnyToken(): boolean;
16
23
  primaryTokenPrefixForLog(): string;
17
24
  }
@@ -3,18 +3,39 @@ export class TokenRegistry {
3
3
  #entries = new Map();
4
4
  constructor(config = {}) {
5
5
  if (config.primaryToken)
6
- this.#entries.set(config.primaryToken, 'full');
7
- for (const { token, scope } of config.scopedTokens ?? []) {
8
- if (token)
9
- this.#entries.set(token, scope);
6
+ this.#entries.set(config.primaryToken, Object.freeze({ scope: 'full' }));
7
+ for (const { token, scope, principalId } of config.scopedTokens ?? []) {
8
+ if (!token)
9
+ continue;
10
+ if (principalId !== undefined && (!principalId.trim() || principalId !== principalId.trim())) {
11
+ throw new Error('HTTP token principalId is invalid');
12
+ }
13
+ const candidate = Object.freeze({ scope, ...(principalId === undefined ? {} : { principalId }) });
14
+ const existing = this.#entries.get(token);
15
+ if (existing && (existing.scope !== candidate.scope || existing.principalId !== candidate.principalId)) {
16
+ throw new Error('HTTP token has conflicting authority bindings');
17
+ }
18
+ this.#entries.set(token, candidate);
10
19
  }
11
20
  }
12
21
  resolve(token) {
13
22
  if (!token)
14
23
  return null;
15
- for (const [known, scope] of this.#entries) {
24
+ for (const [known, binding] of this.#entries) {
16
25
  if (timingSafeEqualString(known, token))
17
- return scope;
26
+ return binding.scope;
27
+ }
28
+ return null;
29
+ }
30
+ resolvePrincipal(token) {
31
+ if (!token)
32
+ return null;
33
+ for (const [known, binding] of this.#entries) {
34
+ if (timingSafeEqualString(known, token)) {
35
+ return binding.principalId
36
+ ? Object.freeze({ principalId: binding.principalId, scope: binding.scope })
37
+ : null;
38
+ }
18
39
  }
19
40
  return null;
20
41
  }
@@ -22,8 +43,8 @@ export class TokenRegistry {
22
43
  return this.#entries.size > 0;
23
44
  }
24
45
  primaryTokenPrefixForLog() {
25
- for (const [tok, scope] of this.#entries) {
26
- if (scope === 'full')
46
+ for (const [tok, binding] of this.#entries) {
47
+ if (binding.scope === 'full')
27
48
  return tok.slice(0, 6);
28
49
  }
29
50
  const first = this.#entries.keys().next().value;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zhin.js/host-http",
3
- "version": "1.0.10",
3
+ "version": "1.0.12",
4
4
  "description": "Minimal HTTP and WebSocket host for Plugin Runtime (no Koa)",
5
5
  "type": "module",
6
6
  "main": "./lib/index.js",
@@ -18,10 +18,10 @@
18
18
  ],
19
19
  "dependencies": {
20
20
  "ws": "^8.21.1",
21
- "@zhin.js/console-protocol": "1.1.2",
21
+ "@zhin.js/console-protocol": "1.1.4",
22
22
  "@zhin.js/host-http-contract": "1.0.1",
23
23
  "@zhin.js/logger": "1.0.76",
24
- "@zhin.js/plugin-runtime": "1.1.6"
24
+ "@zhin.js/plugin-runtime": "1.1.7"
25
25
  },
26
26
  "devDependencies": {
27
27
  "@types/node": "^26.1.2",