@zhin.js/host-http 1.0.14 → 1.0.16

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.
@@ -12,11 +12,15 @@ export interface ConsoleEventSubscriptionOptions {
12
12
  export interface ConsoleEventHub {
13
13
  publish<Type extends string>(type: Type, data: Type extends KnownConsoleEventType ? ConsoleEventPayloadMap[Type] : unknown): ConsoleEventEnvelope<Type, Type extends KnownConsoleEventType ? ConsoleEventPayloadMap[Type] : unknown>;
14
14
  history(query?: ConsoleEventHistoryQuery): ConsoleEventHistoryPage;
15
+ /** In-process delivery for trusted Host bridges such as Device Protocol. */
16
+ listen(listener: (event: ConsoleEventEnvelope) => void): () => void;
15
17
  /** Atomically replay the resumable suffix, then join live fan-out. */
16
18
  subscribe(response: ServerResponse, options?: ConsoleEventSubscriptionOptions): () => void;
17
19
  readonly runtimeId: string;
18
20
  readonly subscriberCount: number;
19
21
  }
22
+ /** Shared Console event authority installed by the Plugin Runtime composition root. */
23
+ export declare const consoleEventHubToken: import("@zhin.js/plugin-runtime").Token<ConsoleEventHub>;
20
24
  export interface ConsoleEventHubOptions {
21
25
  readonly runtimeId?: string;
22
26
  readonly historyLimit?: number;
@@ -1,4 +1,7 @@
1
1
  import { randomUUID } from 'node:crypto';
2
+ import { createToken } from '@zhin.js/plugin-runtime';
3
+ /** Shared Console event authority installed by the Plugin Runtime composition root. */
4
+ export const consoleEventHubToken = createToken('zhin.host.console-events');
2
5
  const DEFAULT_HISTORY_LIMIT = 256;
3
6
  const DEFAULT_PAGE_LIMIT = 200;
4
7
  const MAX_PAGE_LIMIT = 500;
@@ -14,6 +17,7 @@ export function createConsoleEventHub(options = {}) {
14
17
  const journal = [];
15
18
  let journalBytes = 0;
16
19
  const subscribers = new Set();
20
+ const listeners = new Set();
17
21
  const remove = (response) => {
18
22
  subscribers.delete(response);
19
23
  };
@@ -62,6 +66,12 @@ export function createConsoleEventHub(options = {}) {
62
66
  remove(response);
63
67
  }
64
68
  }
69
+ for (const listener of [...listeners]) {
70
+ try {
71
+ listener(event);
72
+ }
73
+ catch { /* isolate trusted bridge failures */ }
74
+ }
65
75
  return event;
66
76
  },
67
77
  history(query = {}) {
@@ -99,6 +109,10 @@ export function createConsoleEventHub(options = {}) {
99
109
  gap,
100
110
  });
101
111
  },
112
+ listen(listener) {
113
+ listeners.add(listener);
114
+ return () => listeners.delete(listener);
115
+ },
102
116
  subscribe(response, options = {}) {
103
117
  // Replay and subscription are synchronous relative to publish(), closing
104
118
  // the race between HTTP recovery and the live stream hand-off.
@@ -55,6 +55,18 @@ export interface ConsoleRpcExtendedCtx {
55
55
  workroomKnowledgeControl?: ConsoleWorkroomKnowledgeControlPort;
56
56
  }
57
57
  export interface ConsoleWorkroomProfileControlPort {
58
+ getPlanningStatus(projectId: string, authenticatedPrincipal?: Readonly<{
59
+ principalId: string;
60
+ }>): Promise<unknown>;
61
+ bootstrapPlanning(command: Readonly<{
62
+ operationId: string;
63
+ projectId: string;
64
+ expectedRegistryRevision: number;
65
+ includeTools?: readonly string[];
66
+ includeSkills?: readonly string[];
67
+ }>, authenticatedPrincipal: Readonly<{
68
+ principalId: string;
69
+ }>): Promise<unknown>;
58
70
  publishPack(command: Readonly<{
59
71
  operationId: string;
60
72
  pack: unknown;
@@ -51,6 +51,8 @@ export async function dispatchExtendedConsoleRpc(type, data, ctx) {
51
51
  return listInbox(d, ctx, TABLE_NOTICE, 'notices', mapNoticeRow);
52
52
  case 'inbox.messages':
53
53
  return listInboxMessages(d, ctx);
54
+ case 'inbox.recent':
55
+ return listRecentInboxMessages(d, ctx);
54
56
  case 'request.approve':
55
57
  case 'request.reject':
56
58
  return actOnRequest(type, d, ctx);
@@ -95,6 +97,8 @@ export async function dispatchExtendedConsoleRpc(type, data, ctx) {
95
97
  });
96
98
  case 'endpoint.delete_friend':
97
99
  return deleteFriend(d, ctx);
100
+ case 'workroom.profile.status':
101
+ case 'workroom.profile.bootstrap':
98
102
  case 'workroom.profile.pack.publish':
99
103
  case 'workroom.profile.publish':
100
104
  case 'workroom.profile.rollback':
@@ -118,11 +122,31 @@ async function mutateWorkroomProfile(type, data, ctx) {
118
122
  if (forbidden in data)
119
123
  return { error: `Console Workroom Profile request cannot carry ${forbidden}` };
120
124
  }
125
+ if (type === 'workroom.profile.status') {
126
+ try {
127
+ return { data: await control.getPlanningStatus(requiredRpcText(data.projectId, 'projectId'), ctx.authenticatedPrincipal) };
128
+ }
129
+ catch (error) {
130
+ return { error: errorMessage(error) };
131
+ }
132
+ }
121
133
  const principal = ctx.authenticatedPrincipal;
122
134
  if (!principal)
123
135
  return { error: 'Workroom Profile control requires an authenticated principal' };
124
136
  try {
125
137
  const operationId = requiredRpcText(data.operationId, 'operationId');
138
+ if (type === 'workroom.profile.bootstrap') {
139
+ const projectId = requiredRpcText(data.projectId, 'projectId');
140
+ const includeTools = optionalRpcTextArray(data.includeTools, 'includeTools');
141
+ const includeSkills = optionalRpcTextArray(data.includeSkills, 'includeSkills');
142
+ return { data: await control.bootstrapPlanning(Object.freeze({
143
+ operationId,
144
+ projectId,
145
+ expectedRegistryRevision: requiredRpcInteger(data.expectedRegistryRevision, 'expectedRegistryRevision', -1),
146
+ ...(includeTools ? { includeTools } : {}),
147
+ ...(includeSkills ? { includeSkills } : {}),
148
+ }), principal) };
149
+ }
126
150
  if (type === 'workroom.profile.pack.publish') {
127
151
  return { data: await control.publishPack(Object.freeze({ operationId, pack: requiredRpcValue(data.pack, 'pack') }), principal) };
128
152
  }
@@ -205,6 +229,16 @@ function requiredRpcText(value, name) {
205
229
  function optionalRpcText(value, name) {
206
230
  return value === undefined ? undefined : requiredRpcText(value, name);
207
231
  }
232
+ function optionalRpcTextArray(value, name) {
233
+ if (value === undefined)
234
+ return undefined;
235
+ if (!Array.isArray(value))
236
+ throw new Error(`${name} is invalid`);
237
+ const result = value.map((item, index) => requiredRpcText(item, `${name}.${index}`));
238
+ if (new Set(result).size !== result.length)
239
+ throw new Error(`${name} contains duplicates`);
240
+ return Object.freeze(result);
241
+ }
208
242
  function requiredRpcInteger(value, name, minimum = 1) {
209
243
  if (!Number.isSafeInteger(value) || Number(value) < minimum)
210
244
  throw new Error(`${name} is invalid`);
@@ -472,6 +506,47 @@ async function listInboxMessages(d, ctx) {
472
506
  }));
473
507
  return { data: { messages, inboxEnabled: enabled } };
474
508
  }
509
+ /**
510
+ * Cross-endpoint recent inbox used by paired Mobile Console clients.
511
+ * Unlike endpoint-detail `inbox.messages`, every row carries its complete
512
+ * routable address so selecting a conversation can safely call
513
+ * `endpoint.send_message` through the Device Protocol write allowlist.
514
+ */
515
+ async function listRecentInboxMessages(d, ctx) {
516
+ const limit = boundedIntegerField(d, 50, 1, 100, '$limit', 'limit');
517
+ const [messagePage, requestPage] = await Promise.all([
518
+ readInboxRows(ctx, TABLE_MESSAGE, {}, {
519
+ orderBy: { field: 'created_at', direction: 'DESC' }, limit,
520
+ }),
521
+ readInboxRows(ctx, TABLE_REQUEST, { resolved: 0 }, {
522
+ orderBy: { field: 'created_at', direction: 'DESC' }, limit,
523
+ }),
524
+ ]);
525
+ const messages = messagePage.rows
526
+ .slice()
527
+ .sort((left, right) => Number(right.created_at ?? 0) - Number(left.created_at ?? 0))
528
+ .slice(0, limit)
529
+ .map(row => ({
530
+ id: row.id,
531
+ adapter: row.adapter,
532
+ endpoint_id: row.endpoint_id,
533
+ platform_message_id: row.platform_message_id,
534
+ sender_id: row.sender_id,
535
+ sender_name: row.sender_name,
536
+ content: row.content,
537
+ raw: row.raw,
538
+ created_at: row.created_at,
539
+ channel: channelFromStoredRow(row),
540
+ parent: parentFromStoredRow(row),
541
+ }));
542
+ const requests = requestPage.rows
543
+ .filter(row => Number(row.resolved ?? 0) === 0)
544
+ .slice()
545
+ .sort((left, right) => Number(right.created_at ?? 0) - Number(left.created_at ?? 0))
546
+ .slice(0, limit)
547
+ .map(row => ({ ...mapRequestRow(row), adapter: row.adapter, endpoint_id: row.endpoint_id }));
548
+ return { data: { messages, requests, inboxEnabled: messagePage.enabled || requestPage.enabled } };
549
+ }
475
550
  function mapRequestRow(row) {
476
551
  const actorId = row.actor_id;
477
552
  const actorName = row.actor_name ?? undefined;
@@ -580,6 +655,12 @@ async function actOnRequest(type, d, ctx) {
580
655
  ? strField(d, '$remark', 'remark')
581
656
  : strField(d, '$reason', 'reason');
582
657
  await method.call(management, requestId, extra || undefined);
658
+ const model = getInboxModel(ctx, TABLE_REQUEST);
659
+ if (model && typeof model.update === 'function') {
660
+ await model.update({ resolved: 1, resolved_at: Date.now() }).where({
661
+ adapter, endpoint_id: endpointKey, platform_request_id: requestId,
662
+ });
663
+ }
583
664
  return { data: { success: true } };
584
665
  });
585
666
  }
@@ -50,6 +50,10 @@ export type RuntimeConsoleRpcContext = {
50
50
  setConfigKey?(pluginName: string, data: unknown): Promise<{
51
51
  restartRequired: boolean;
52
52
  }>;
53
+ /** Persist a declared child Plugin lifecycle state. Takes effect after restart. */
54
+ setPluginEnabled?(instanceKey: string, enabled: boolean): Promise<Readonly<{
55
+ disabled: readonly string[];
56
+ }>>;
53
57
  /** Atomic, revision-checked runtime Workroom Catalog read/write. */
54
58
  readWorkroomCatalog?(): Promise<Readonly<{
55
59
  agents: Readonly<Record<string, unknown>>;
@@ -661,6 +661,42 @@ export async function dispatchRuntimeConsoleRpc(message, ctx) {
661
661
  }
662
662
  return payloads;
663
663
  }
664
+ case 'plugin:set-enabled': {
665
+ try {
666
+ const instanceKey = typeof message.instanceKey === 'string' ? message.instanceKey : '';
667
+ const enabled = message.enabled;
668
+ if (!instanceKey || typeof enabled !== 'boolean') {
669
+ emit({ requestId, error: 'instanceKey and boolean enabled are required' });
670
+ return payloads;
671
+ }
672
+ if (!ctx.setPluginEnabled) {
673
+ emit({ requestId, error: 'Plugin lifecycle management is not configured' });
674
+ return payloads;
675
+ }
676
+ const lifecycle = await ctx.setPluginEnabled(instanceKey, enabled);
677
+ ctx.publishEvent?.('plugin:lifecycle-updated', { instanceKey, enabled });
678
+ emit({
679
+ requestId,
680
+ data: {
681
+ success: true,
682
+ instanceKey,
683
+ enabled,
684
+ disabled: lifecycle.disabled,
685
+ restartRequired: true,
686
+ message: `${instanceKey} 已${enabled ? '启用' : '停用'},Host 正在重启`,
687
+ },
688
+ });
689
+ if (ctx.requestRestart) {
690
+ setTimeout(() => {
691
+ void Promise.resolve(ctx.requestRestart?.()).catch(() => undefined);
692
+ }, 500);
693
+ }
694
+ }
695
+ catch (error) {
696
+ emit({ requestId, error: error instanceof Error ? error.message : String(error) });
697
+ }
698
+ return payloads;
699
+ }
664
700
  default:
665
701
  if (type.startsWith('db:')) {
666
702
  emit({
@@ -1,4 +1,5 @@
1
1
  import { type IncomingMessage, type ServerResponse } from 'node:http';
2
+ import { type ServerOptions as HttpsServerOptions } from 'node:https';
2
3
  import { WebSocket } from 'ws';
3
4
  import { type GenerationAdmissionBindable } from '@zhin.js/plugin-runtime';
4
5
  import { TokenRegistry, type AuthenticatedTokenPrincipal, type AuthScope, type ScopedTokenConfig } from './token-registry.js';
@@ -13,9 +14,27 @@ export interface WsHandle {
13
14
  onConnection(listener: (connection: WsConnection) => void): () => void;
14
15
  close(): void;
15
16
  }
17
+ export interface WsRouteOptions {
18
+ /**
19
+ * `host` applies the Host Bearer-token registry before upgrade. `protocol`
20
+ * leaves upgrade authentication to the registered application protocol.
21
+ */
22
+ readonly auth?: 'host' | 'protocol';
23
+ }
16
24
  export interface HttpHostAddress {
17
25
  readonly host: string;
18
26
  readonly port: number;
27
+ readonly protocol: 'http' | 'https';
28
+ readonly secure: boolean;
29
+ readonly origin: string;
30
+ }
31
+ export interface HttpHostTlsOptions {
32
+ readonly key: NonNullable<HttpsServerOptions['key']>;
33
+ readonly cert: NonNullable<HttpsServerOptions['cert']>;
34
+ readonly ca?: HttpsServerOptions['ca'];
35
+ readonly passphrase?: string;
36
+ readonly minVersion?: HttpsServerOptions['minVersion'];
37
+ readonly ciphers?: string;
19
38
  }
20
39
  export interface HttpHostOptions {
21
40
  readonly host?: string;
@@ -33,6 +52,8 @@ export interface HttpHostOptions {
33
52
  readonly apiBase?: string;
34
53
  /** Extra HTTP path prefixes that skip auth even under `apiBase`. */
35
54
  readonly authExemptPaths?: readonly string[];
55
+ /** Enables HTTPS and WSS on this listener. Certificate lifecycle belongs to the process composition root. */
56
+ readonly tls?: HttpHostTlsOptions;
36
57
  }
37
58
  export type HttpHandler = (request: IncomingMessage, response: ServerResponse, url: URL, authScope: AuthScope, authenticatedPrincipal?: AuthenticatedTokenPrincipal) => void | Promise<void>;
38
59
  export interface HttpRouteRegistration {
@@ -40,7 +61,7 @@ export interface HttpRouteRegistration {
40
61
  }
41
62
  /** @public Stable HTTP and WebSocket Host contract resolved through `httpHostToken`. */
42
63
  export interface HttpHost {
43
- ws(path: string): WsHandle;
64
+ ws(path: string, options?: WsRouteOptions): WsHandle;
44
65
  route(method: string, path: string, handler: HttpHandler, meta?: RouteMeta): HttpRouteRegistration;
45
66
  listRoutes(): readonly ListedRoute[];
46
67
  get address(): HttpHostAddress | undefined;
package/lib/http-host.js CHANGED
@@ -1,4 +1,5 @@
1
- import { createServer, } from 'node:http';
1
+ import { createServer as createHttpServer, } from 'node:http';
2
+ import { createServer as createHttpsServer, } from 'node:https';
2
3
  import { WebSocket, WebSocketServer } from 'ws';
3
4
  import { formatCompact, getLogger } from '@zhin.js/logger';
4
5
  import { createToken, generationAdmissionBinder, } from '@zhin.js/plugin-runtime';
@@ -29,9 +30,13 @@ export function createHttpHost(options = {}) {
29
30
  let address;
30
31
  let closed = false;
31
32
  let closeResult;
32
- const server = createServer((request, response) => {
33
+ const secure = options.tls !== undefined;
34
+ const handleRequest = (request, response) => {
33
35
  void dispatchHttp(request, response);
34
- });
36
+ };
37
+ const server = secure
38
+ ? createHttpsServer(options.tls, handleRequest)
39
+ : createHttpServer(handleRequest);
35
40
  // Track live TCP sockets so close() can destroy long-lived connections
36
41
  // (SSE /api/events, keep-alive) that would otherwise block server.close().
37
42
  const sockets = new Set();
@@ -66,7 +71,7 @@ export function createHttpHost(options = {}) {
66
71
  meta: Object.freeze({ summary: 'OpenAPI 3.1 document', tags: ['pub'] }),
67
72
  handler: (request, response) => {
68
73
  const hostHeader = headerValue(request.headers.host) ?? 'localhost';
69
- const proto = headerValue(request.headers['x-forwarded-proto']) ?? 'http';
74
+ const proto = headerValue(request.headers['x-forwarded-proto']) ?? (secure ? 'https' : 'http');
70
75
  writeJson(response, 200, buildOpenApiDocument(listListedRoutes(), {
71
76
  apiBase,
72
77
  serverUrl: `${proto}://${hostHeader}`,
@@ -92,7 +97,10 @@ export function createHttpHost(options = {}) {
92
97
  socket.destroy();
93
98
  return;
94
99
  }
95
- const auth = authenticateUpgrade(request, pathname);
100
+ const protocolAuthenticated = admitted.every(({ entry }) => entry.options.auth === 'protocol');
101
+ const auth = protocolAuthenticated
102
+ ? { ok: true, scope: 'full' }
103
+ : authenticateUpgrade(request, pathname);
96
104
  if (!auth.ok) {
97
105
  socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n');
98
106
  socket.destroy();
@@ -286,7 +294,7 @@ export function createHttpHost(options = {}) {
286
294
  meta: route.meta,
287
295
  })));
288
296
  }
289
- function registerWs(pathname, admission) {
297
+ function registerWs(pathname, admission, options = {}) {
290
298
  const normalized = normalizePath(pathname);
291
299
  let listeners = wsRoutes.get(normalized);
292
300
  if (!listeners) {
@@ -296,7 +304,7 @@ export function createHttpHost(options = {}) {
296
304
  const owned = new Set();
297
305
  return {
298
306
  onConnection(listener) {
299
- const entry = Object.freeze({ listener, admission });
307
+ const entry = Object.freeze({ listener, admission, options: Object.freeze({ ...options }) });
300
308
  listeners.add(entry);
301
309
  owned.add(entry);
302
310
  return () => {
@@ -335,7 +343,7 @@ export function createHttpHost(options = {}) {
335
343
  function createBoundHost(admission) {
336
344
  const bound = {
337
345
  [generationAdmissionBinder]: (next) => createBoundHost(next),
338
- ws: (path) => registerWs(path, admission),
346
+ ws: (path, options) => registerWs(path, admission, options),
339
347
  route: (method, path, handler, meta) => registerRoute(method, path, handler, meta, admission),
340
348
  listRoutes: () => listListedRoutes(),
341
349
  get address() { return runtime.address; },
@@ -345,7 +353,7 @@ export function createHttpHost(options = {}) {
345
353
  }
346
354
  const runtime = {
347
355
  [generationAdmissionBinder]: (admission) => createBoundHost(admission),
348
- ws: (pathname) => registerWs(pathname),
356
+ ws: (pathname, options) => registerWs(pathname, undefined, options),
349
357
  route: (method, path, handler, meta) => registerRoute(method, path, handler, meta),
350
358
  listRoutes() {
351
359
  return listListedRoutes();
@@ -366,8 +374,13 @@ export function createHttpHost(options = {}) {
366
374
  const bound = server.address();
367
375
  const listenPort = typeof bound === 'object' && bound ? bound.port : port;
368
376
  const publicHost = host === '0.0.0.0' || host === '::' ? '127.0.0.1' : host;
369
- address = Object.freeze({ host: publicHost, port: listenPort });
370
- logger.info(`listening http://${publicHost}:${listenPort}`
377
+ const protocol = secure ? 'https' : 'http';
378
+ const originHost = publicHost.includes(':') ? `[${publicHost}]` : publicHost;
379
+ address = Object.freeze({
380
+ host: publicHost, port: listenPort, protocol, secure,
381
+ origin: `${protocol}://${originHost}:${listenPort}`,
382
+ });
383
+ logger.info(`listening ${address.origin}`
371
384
  + ` | routes: ${httpRoutes.length}`
372
385
  + ` | token: ${tokenRegistry.hasAnyToken()
373
386
  ? `${tokenRegistry.primaryTokenPrefixForLog()}…`
package/lib/index.d.ts CHANGED
@@ -1,7 +1,8 @@
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 AuthenticatedTokenPrincipal, type AuthScope, type ScopedTokenConfig, type TokenRegistryConfig, } from './token-registry.js';
1
+ export { createHttpHost, httpHostToken, REMOTE_CONSOLE_ORIGIN, type HttpHandler, type HttpHost, type ProcessHttpHost, type HttpHostAddress, type HttpHostOptions, type HttpHostTlsOptions, type HttpRouteRegistration, type WsConnection, type WsHandle, type WsRouteOptions, } from './http-host.js';
2
+ export { createHttpHostGroup, listHttpHostAddresses, } from './listener-group.js';
3
+ export { TokenRegistry, extractBearerToken, isDemoWebSocketPath, type AuthenticatedTokenPrincipal, type AuthScope, type DynamicTokenConfig, type ScopedTokenConfig, type TokenRegistryConfig, } from './token-registry.js';
3
4
  export { timingSafeEqualString } from './timing-safe-equal.js';
4
- export { createConsoleEventHub, type ConsoleEventHub, } from './console-events.js';
5
+ export { createConsoleEventHub, consoleEventHubToken, type ConsoleEventHub, } from './console-events.js';
5
6
  export { buildOpenApiDocument, patternToOpenApiPath, routeRequiresBearerAuth, type BuildOpenApiOptions, type ListedRoute, type OpenApiParameter, type RouteMeta, } from './openapi.js';
6
7
  export { HttpBodyError, readJsonBody, } from './json-body.js';
7
8
  export { dispatchRuntimeConsoleRpc, isDemoHttpAllowed, pickRpcReply, type RuntimeConsolePage, type RuntimeConsoleRpcContext, type RuntimeConsoleRpcMessage, type RuntimeConsoleRpcReply, type RuntimeEndpointSendInput, type RuntimeEndpointSummary, } from './console-rpc.js';
package/lib/index.js CHANGED
@@ -1,7 +1,8 @@
1
1
  export { createHttpHost, httpHostToken, REMOTE_CONSOLE_ORIGIN, } from './http-host.js';
2
+ export { createHttpHostGroup, listHttpHostAddresses, } from './listener-group.js';
2
3
  export { TokenRegistry, extractBearerToken, isDemoWebSocketPath, } from './token-registry.js';
3
4
  export { timingSafeEqualString } from './timing-safe-equal.js';
4
- export { createConsoleEventHub, } from './console-events.js';
5
+ export { createConsoleEventHub, consoleEventHubToken, } from './console-events.js';
5
6
  export { buildOpenApiDocument, patternToOpenApiPath, routeRequiresBearerAuth, } from './openapi.js';
6
7
  export { HttpBodyError, readJsonBody, } from './json-body.js';
7
8
  export { dispatchRuntimeConsoleRpc, isDemoHttpAllowed, pickRpcReply, } from './console-rpc.js';
@@ -0,0 +1,9 @@
1
+ import { type HttpHost, type HttpHostAddress, type HttpHostOptions, type ProcessHttpHost } from './http-host.js';
2
+ /**
3
+ * Runs one logical Host surface on multiple network listeners. Route and WS
4
+ * registration stays generation-owned and is mirrored to every listener,
5
+ * while `address` intentionally remains the primary/local listener.
6
+ */
7
+ export declare function createHttpHostGroup(options: readonly HttpHostOptions[]): ProcessHttpHost;
8
+ /** Returns every bound listener, ordered primary/local first. */
9
+ export declare function listHttpHostAddresses(host: HttpHost): readonly HttpHostAddress[];
@@ -0,0 +1,88 @@
1
+ import { bindGenerationAdmission, generationAdmissionBinder, } from '@zhin.js/plugin-runtime';
2
+ import { createHttpHost, } from './http-host.js';
3
+ const addressProvider = Symbol('HttpHostAddressProvider');
4
+ /**
5
+ * Runs one logical Host surface on multiple network listeners. Route and WS
6
+ * registration stays generation-owned and is mirrored to every listener,
7
+ * while `address` intentionally remains the primary/local listener.
8
+ */
9
+ export function createHttpHostGroup(options) {
10
+ if (options.length === 0)
11
+ throw new Error('HttpHostGroup requires at least one listener');
12
+ const listeners = options.map((entry) => createHttpHost(entry));
13
+ const createSurface = (hosts) => {
14
+ const primary = hosts[0];
15
+ const surface = {
16
+ [generationAdmissionBinder](admission) {
17
+ return createSurface(hosts.map((host) => bindGenerationAdmission(host, admission)));
18
+ },
19
+ [addressProvider]: () => listeners.flatMap((host) => host.address ? [host.address] : []),
20
+ ws(path, wsOptions) {
21
+ const handles = hosts.map((host) => host.ws(path, wsOptions));
22
+ return {
23
+ onConnection(listener) {
24
+ const disposers = handles.map((handle) => handle.onConnection(listener));
25
+ return () => { for (const dispose of disposers.reverse())
26
+ dispose(); };
27
+ },
28
+ close() { for (const handle of handles)
29
+ handle.close(); },
30
+ };
31
+ },
32
+ route(method, path, handler, meta) {
33
+ const disposers = hosts.map((host) => host.route(method, path, handler, meta));
34
+ return () => { for (const dispose of disposers.reverse())
35
+ dispose(); };
36
+ },
37
+ listRoutes: () => primary.listRoutes(),
38
+ get address() { return primary.address; },
39
+ get tokenRegistry() { return primary.tokenRegistry; },
40
+ };
41
+ return Object.freeze(surface);
42
+ };
43
+ const surface = createSurface(listeners);
44
+ let closeResult;
45
+ const runtime = {
46
+ [generationAdmissionBinder](admission) {
47
+ return surface[generationAdmissionBinder](admission);
48
+ },
49
+ [addressProvider]: surface[addressProvider],
50
+ ws: (path, wsOptions) => surface.ws(path, wsOptions),
51
+ route: (method, path, handler, meta) => surface.route(method, path, handler, meta),
52
+ listRoutes: () => surface.listRoutes(),
53
+ get address() { return surface.address; },
54
+ get tokenRegistry() { return surface.tokenRegistry; },
55
+ async listen() {
56
+ try {
57
+ for (const listener of listeners) {
58
+ await listener.listen();
59
+ }
60
+ }
61
+ catch (error) {
62
+ await Promise.allSettled([...listeners].reverse().map((listener) => listener.close()));
63
+ throw error;
64
+ }
65
+ return listeners[0].address;
66
+ },
67
+ close() {
68
+ closeResult ??= closeListeners(listeners);
69
+ return closeResult;
70
+ },
71
+ };
72
+ return Object.freeze(runtime);
73
+ }
74
+ /** Returns every bound listener, ordered primary/local first. */
75
+ export function listHttpHostAddresses(host) {
76
+ const provider = host[addressProvider];
77
+ if (typeof provider === 'function')
78
+ return Object.freeze([...provider.call(host)]);
79
+ return Object.freeze(host.address ? [host.address] : []);
80
+ }
81
+ async function closeListeners(listeners) {
82
+ const results = await Promise.allSettled([...listeners].reverse().map((listener) => listener.close()));
83
+ const errors = results.flatMap((result) => result.status === 'rejected' ? [result.reason] : []);
84
+ if (errors.length === 1)
85
+ throw errors[0];
86
+ if (errors.length > 1)
87
+ throw new AggregateError(errors, 'multiple HTTP listeners failed to close');
88
+ }
@@ -14,12 +14,19 @@ export type TokenRegistryConfig = {
14
14
  readonly primaryToken?: string;
15
15
  readonly scopedTokens?: readonly ScopedTokenConfig[];
16
16
  };
17
+ export type DynamicTokenConfig = ScopedTokenConfig & {
18
+ /** Unix epoch milliseconds; omitted credentials remain valid until explicitly revoked. */
19
+ readonly expiresAt?: number;
20
+ };
17
21
  export declare class TokenRegistry {
18
22
  #private;
19
23
  constructor(config?: TokenRegistryConfig);
20
24
  resolve(token: string): AuthScope | null;
21
25
  resolvePrincipal(token: string): AuthenticatedTokenPrincipal | null;
22
26
  hasAnyToken(): boolean;
27
+ /** Registers a runtime-issued credential and returns an idempotent revoker. */
28
+ register(config: DynamicTokenConfig): () => void;
29
+ revoke(token: string): boolean;
23
30
  primaryTokenPrefixForLog(): string;
24
31
  }
25
32
  /** WebSocket upgrade paths allowed for demo scope. */
@@ -2,15 +2,20 @@ import { timingSafeEqualString } from './timing-safe-equal.js';
2
2
  export class TokenRegistry {
3
3
  #entries = new Map();
4
4
  constructor(config = {}) {
5
- if (config.primaryToken)
6
- this.#entries.set(config.primaryToken, Object.freeze({ scope: 'full' }));
5
+ if (config.primaryToken) {
6
+ this.#entries.set(config.primaryToken, Object.freeze({ scope: 'full', dynamic: false }));
7
+ }
7
8
  for (const { token, scope, principalId } of config.scopedTokens ?? []) {
8
9
  if (!token)
9
10
  continue;
10
11
  if (principalId !== undefined && (!principalId.trim() || principalId !== principalId.trim())) {
11
12
  throw new Error('HTTP token principalId is invalid');
12
13
  }
13
- const candidate = Object.freeze({ scope, ...(principalId === undefined ? {} : { principalId }) });
14
+ const candidate = Object.freeze({
15
+ scope,
16
+ ...(principalId === undefined ? {} : { principalId }),
17
+ dynamic: false,
18
+ });
14
19
  const existing = this.#entries.get(token);
15
20
  if (existing && (existing.scope !== candidate.scope || existing.principalId !== candidate.principalId)) {
16
21
  throw new Error('HTTP token has conflicting authority bindings');
@@ -22,8 +27,11 @@ export class TokenRegistry {
22
27
  if (!token)
23
28
  return null;
24
29
  for (const [known, binding] of this.#entries) {
25
- if (timingSafeEqualString(known, token))
26
- return binding.scope;
30
+ if (!timingSafeEqualString(known, token))
31
+ continue;
32
+ if (this.#expired(known, binding))
33
+ return null;
34
+ return binding.scope;
27
35
  }
28
36
  return null;
29
37
  }
@@ -32,6 +40,8 @@ export class TokenRegistry {
32
40
  return null;
33
41
  for (const [known, binding] of this.#entries) {
34
42
  if (timingSafeEqualString(known, token)) {
43
+ if (this.#expired(known, binding))
44
+ return null;
35
45
  return binding.principalId
36
46
  ? Object.freeze({ principalId: binding.principalId, scope: binding.scope })
37
47
  : null;
@@ -40,7 +50,46 @@ export class TokenRegistry {
40
50
  return null;
41
51
  }
42
52
  hasAnyToken() {
43
- return this.#entries.size > 0;
53
+ for (const [token, binding] of this.#entries) {
54
+ if (!this.#expired(token, binding))
55
+ return true;
56
+ }
57
+ return false;
58
+ }
59
+ /** Registers a runtime-issued credential and returns an idempotent revoker. */
60
+ register(config) {
61
+ if (!config.token)
62
+ throw new Error('Dynamic HTTP token is required');
63
+ if (config.principalId !== undefined
64
+ && (!config.principalId.trim() || config.principalId !== config.principalId.trim())) {
65
+ throw new Error('Dynamic HTTP token principalId is invalid');
66
+ }
67
+ if (config.expiresAt !== undefined
68
+ && (!Number.isSafeInteger(config.expiresAt) || config.expiresAt <= Date.now())) {
69
+ throw new Error('Dynamic HTTP token expiresAt must be a future epoch millisecond');
70
+ }
71
+ const candidate = Object.freeze({
72
+ scope: config.scope,
73
+ ...(config.principalId === undefined ? {} : { principalId: config.principalId }),
74
+ ...(config.expiresAt === undefined ? {} : { expiresAt: config.expiresAt }),
75
+ dynamic: true,
76
+ });
77
+ const existing = this.#entries.get(config.token);
78
+ if (existing)
79
+ throw new Error('Dynamic HTTP token conflicts with an existing credential');
80
+ this.#entries.set(config.token, candidate);
81
+ let active = true;
82
+ return () => {
83
+ if (!active)
84
+ return;
85
+ active = false;
86
+ if (this.#entries.get(config.token) === candidate)
87
+ this.#entries.delete(config.token);
88
+ };
89
+ }
90
+ revoke(token) {
91
+ const binding = this.#entries.get(token);
92
+ return binding?.dynamic === true && this.#entries.delete(token);
44
93
  }
45
94
  primaryTokenPrefixForLog() {
46
95
  for (const [tok, binding] of this.#entries) {
@@ -50,6 +99,13 @@ export class TokenRegistry {
50
99
  const first = this.#entries.keys().next().value;
51
100
  return first ? first.slice(0, 6) : '';
52
101
  }
102
+ #expired(token, binding) {
103
+ if (binding.expiresAt === undefined || binding.expiresAt > Date.now())
104
+ return false;
105
+ if (binding.dynamic)
106
+ this.#entries.delete(token);
107
+ return true;
108
+ }
53
109
  }
54
110
  /** WebSocket upgrade paths allowed for demo scope. */
55
111
  export function isDemoWebSocketPath(pathname) {