@zhin.js/host-http 1.0.15 → 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.
- package/lib/console-events.d.ts +4 -0
- package/lib/console-events.js +14 -0
- package/lib/console-rpc-extended.js +49 -0
- package/lib/console-rpc.d.ts +4 -0
- package/lib/console-rpc.js +36 -0
- package/lib/http-host.d.ts +22 -1
- package/lib/http-host.js +24 -11
- package/lib/index.d.ts +4 -3
- package/lib/index.js +2 -1
- package/lib/listener-group.d.ts +9 -0
- package/lib/listener-group.js +88 -0
- package/lib/token-registry.d.ts +7 -0
- package/lib/token-registry.js +62 -6
- package/package.json +1 -1
- package/src/console-events.ts +14 -0
- package/src/console-rpc-extended.ts +53 -0
- package/src/console-rpc.ts +40 -0
- package/src/http-host.ts +53 -12
- package/src/index.ts +8 -0
- package/src/listener-group.ts +108 -0
- package/src/token-registry.ts +68 -5
package/lib/console-events.d.ts
CHANGED
|
@@ -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;
|
package/lib/console-events.js
CHANGED
|
@@ -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.
|
|
@@ -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);
|
|
@@ -504,6 +506,47 @@ async function listInboxMessages(d, ctx) {
|
|
|
504
506
|
}));
|
|
505
507
|
return { data: { messages, inboxEnabled: enabled } };
|
|
506
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
|
+
}
|
|
507
550
|
function mapRequestRow(row) {
|
|
508
551
|
const actorId = row.actor_id;
|
|
509
552
|
const actorName = row.actor_name ?? undefined;
|
|
@@ -612,6 +655,12 @@ async function actOnRequest(type, d, ctx) {
|
|
|
612
655
|
? strField(d, '$remark', 'remark')
|
|
613
656
|
: strField(d, '$reason', 'reason');
|
|
614
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
|
+
}
|
|
615
664
|
return { data: { success: true } };
|
|
616
665
|
});
|
|
617
666
|
}
|
package/lib/console-rpc.d.ts
CHANGED
|
@@ -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>>;
|
package/lib/console-rpc.js
CHANGED
|
@@ -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({
|
package/lib/http-host.d.ts
CHANGED
|
@@ -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
|
|
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
|
|
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
|
-
|
|
370
|
-
|
|
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 {
|
|
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
|
+
}
|
package/lib/token-registry.d.ts
CHANGED
|
@@ -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. */
|
package/lib/token-registry.js
CHANGED
|
@@ -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({
|
|
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
|
-
|
|
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
|
-
|
|
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) {
|
package/package.json
CHANGED
package/src/console-events.ts
CHANGED
|
@@ -7,6 +7,7 @@ import type {
|
|
|
7
7
|
ConsoleEventPayloadMap,
|
|
8
8
|
KnownConsoleEventType,
|
|
9
9
|
} from '@zhin.js/console-protocol';
|
|
10
|
+
import { createToken } from '@zhin.js/plugin-runtime';
|
|
10
11
|
|
|
11
12
|
/**
|
|
12
13
|
* Console 实时事件枢纽(Plugin Runtime Host 的 `/api/events` SSE 事件源)。
|
|
@@ -27,12 +28,17 @@ export interface ConsoleEventHub {
|
|
|
27
28
|
Type extends KnownConsoleEventType ? ConsoleEventPayloadMap[Type] : unknown
|
|
28
29
|
>;
|
|
29
30
|
history(query?: ConsoleEventHistoryQuery): ConsoleEventHistoryPage;
|
|
31
|
+
/** In-process delivery for trusted Host bridges such as Device Protocol. */
|
|
32
|
+
listen(listener: (event: ConsoleEventEnvelope) => void): () => void;
|
|
30
33
|
/** Atomically replay the resumable suffix, then join live fan-out. */
|
|
31
34
|
subscribe(response: ServerResponse, options?: ConsoleEventSubscriptionOptions): () => void;
|
|
32
35
|
readonly runtimeId: string;
|
|
33
36
|
readonly subscriberCount: number;
|
|
34
37
|
}
|
|
35
38
|
|
|
39
|
+
/** Shared Console event authority installed by the Plugin Runtime composition root. */
|
|
40
|
+
export const consoleEventHubToken = createToken<ConsoleEventHub>('zhin.host.console-events');
|
|
41
|
+
|
|
36
42
|
export interface ConsoleEventHubOptions {
|
|
37
43
|
readonly runtimeId?: string;
|
|
38
44
|
readonly historyLimit?: number;
|
|
@@ -65,6 +71,7 @@ export function createConsoleEventHub(options: ConsoleEventHubOptions = {}): Con
|
|
|
65
71
|
const journal: JournalEntry[] = [];
|
|
66
72
|
let journalBytes = 0;
|
|
67
73
|
const subscribers = new Set<ServerResponse>();
|
|
74
|
+
const listeners = new Set<(event: ConsoleEventEnvelope) => void>();
|
|
68
75
|
|
|
69
76
|
const remove = (response: ServerResponse): void => {
|
|
70
77
|
subscribers.delete(response);
|
|
@@ -120,6 +127,9 @@ export function createConsoleEventHub(options: ConsoleEventHubOptions = {}): Con
|
|
|
120
127
|
remove(response);
|
|
121
128
|
}
|
|
122
129
|
}
|
|
130
|
+
for (const listener of [...listeners]) {
|
|
131
|
+
try { listener(event); } catch { /* isolate trusted bridge failures */ }
|
|
132
|
+
}
|
|
123
133
|
return event as ConsoleEventEnvelope<
|
|
124
134
|
Type,
|
|
125
135
|
Type extends KnownConsoleEventType ? ConsoleEventPayloadMap[Type] : unknown
|
|
@@ -158,6 +168,10 @@ export function createConsoleEventHub(options: ConsoleEventHubOptions = {}): Con
|
|
|
158
168
|
gap,
|
|
159
169
|
});
|
|
160
170
|
},
|
|
171
|
+
listen(listener) {
|
|
172
|
+
listeners.add(listener);
|
|
173
|
+
return () => listeners.delete(listener);
|
|
174
|
+
},
|
|
161
175
|
subscribe(response, options = {}) {
|
|
162
176
|
// Replay and subscription are synchronous relative to publish(), closing
|
|
163
177
|
// the race between HTTP recovery and the live stream hand-off.
|
|
@@ -217,6 +217,8 @@ export async function dispatchExtendedConsoleRpc(
|
|
|
217
217
|
return listInbox(d, ctx, TABLE_NOTICE, 'notices', mapNoticeRow);
|
|
218
218
|
case 'inbox.messages':
|
|
219
219
|
return listInboxMessages(d, ctx);
|
|
220
|
+
case 'inbox.recent':
|
|
221
|
+
return listRecentInboxMessages(d, ctx);
|
|
220
222
|
|
|
221
223
|
case 'request.approve':
|
|
222
224
|
case 'request.reject':
|
|
@@ -719,6 +721,51 @@ async function listInboxMessages(
|
|
|
719
721
|
return { data: { messages, inboxEnabled: enabled } };
|
|
720
722
|
}
|
|
721
723
|
|
|
724
|
+
/**
|
|
725
|
+
* Cross-endpoint recent inbox used by paired Mobile Console clients.
|
|
726
|
+
* Unlike endpoint-detail `inbox.messages`, every row carries its complete
|
|
727
|
+
* routable address so selecting a conversation can safely call
|
|
728
|
+
* `endpoint.send_message` through the Device Protocol write allowlist.
|
|
729
|
+
*/
|
|
730
|
+
async function listRecentInboxMessages(
|
|
731
|
+
d: Record<string, unknown>,
|
|
732
|
+
ctx: ConsoleRpcExtendedCtx,
|
|
733
|
+
): Promise<ExtendedRpcResult> {
|
|
734
|
+
const limit = boundedIntegerField(d, 50, 1, 100, '$limit', 'limit');
|
|
735
|
+
const [messagePage, requestPage] = await Promise.all([
|
|
736
|
+
readInboxRows(ctx, TABLE_MESSAGE, {}, {
|
|
737
|
+
orderBy: {field: 'created_at', direction: 'DESC'}, limit,
|
|
738
|
+
}),
|
|
739
|
+
readInboxRows(ctx, TABLE_REQUEST, {resolved: 0}, {
|
|
740
|
+
orderBy: {field: 'created_at', direction: 'DESC'}, limit,
|
|
741
|
+
}),
|
|
742
|
+
]);
|
|
743
|
+
const messages = messagePage.rows
|
|
744
|
+
.slice()
|
|
745
|
+
.sort((left, right) => Number(right.created_at ?? 0) - Number(left.created_at ?? 0))
|
|
746
|
+
.slice(0, limit)
|
|
747
|
+
.map(row => ({
|
|
748
|
+
id: row.id,
|
|
749
|
+
adapter: row.adapter,
|
|
750
|
+
endpoint_id: row.endpoint_id,
|
|
751
|
+
platform_message_id: row.platform_message_id,
|
|
752
|
+
sender_id: row.sender_id,
|
|
753
|
+
sender_name: row.sender_name,
|
|
754
|
+
content: row.content,
|
|
755
|
+
raw: row.raw,
|
|
756
|
+
created_at: row.created_at,
|
|
757
|
+
channel: channelFromStoredRow(row),
|
|
758
|
+
parent: parentFromStoredRow(row),
|
|
759
|
+
}));
|
|
760
|
+
const requests = requestPage.rows
|
|
761
|
+
.filter(row => Number(row.resolved ?? 0) === 0)
|
|
762
|
+
.slice()
|
|
763
|
+
.sort((left, right) => Number(right.created_at ?? 0) - Number(left.created_at ?? 0))
|
|
764
|
+
.slice(0, limit)
|
|
765
|
+
.map(row => ({...mapRequestRow(row), adapter: row.adapter, endpoint_id: row.endpoint_id}));
|
|
766
|
+
return {data: {messages, requests, inboxEnabled: messagePage.enabled || requestPage.enabled}};
|
|
767
|
+
}
|
|
768
|
+
|
|
722
769
|
function mapRequestRow(row: Record<string, unknown>): Record<string, unknown> {
|
|
723
770
|
const actorId = row.actor_id;
|
|
724
771
|
const actorName = row.actor_name ?? undefined;
|
|
@@ -836,6 +883,12 @@ async function actOnRequest(
|
|
|
836
883
|
? strField(d, '$remark', 'remark')
|
|
837
884
|
: strField(d, '$reason', 'reason');
|
|
838
885
|
await method.call(management, requestId, extra || undefined);
|
|
886
|
+
const model = getInboxModel(ctx, TABLE_REQUEST);
|
|
887
|
+
if (model && typeof model.update === 'function') {
|
|
888
|
+
await model.update({resolved: 1, resolved_at: Date.now()}).where({
|
|
889
|
+
adapter, endpoint_id: endpointKey, platform_request_id: requestId,
|
|
890
|
+
});
|
|
891
|
+
}
|
|
839
892
|
return { data: { success: true } };
|
|
840
893
|
});
|
|
841
894
|
}
|
package/src/console-rpc.ts
CHANGED
|
@@ -81,6 +81,11 @@ export type RuntimeConsoleRpcContext = {
|
|
|
81
81
|
* Returns whether a process restart is required (always true without ConfigFeature).
|
|
82
82
|
*/
|
|
83
83
|
setConfigKey?(pluginName: string, data: unknown): Promise<{ restartRequired: boolean }>;
|
|
84
|
+
/** Persist a declared child Plugin lifecycle state. Takes effect after restart. */
|
|
85
|
+
setPluginEnabled?(
|
|
86
|
+
instanceKey: string,
|
|
87
|
+
enabled: boolean,
|
|
88
|
+
): Promise<Readonly<{ disabled: readonly string[] }>>;
|
|
84
89
|
/** Atomic, revision-checked runtime Workroom Catalog read/write. */
|
|
85
90
|
readWorkroomCatalog?(): Promise<Readonly<{
|
|
86
91
|
agents: Readonly<Record<string, unknown>>;
|
|
@@ -754,6 +759,41 @@ export async function dispatchRuntimeConsoleRpc(
|
|
|
754
759
|
}
|
|
755
760
|
return payloads;
|
|
756
761
|
}
|
|
762
|
+
case 'plugin:set-enabled': {
|
|
763
|
+
try {
|
|
764
|
+
const instanceKey = typeof message.instanceKey === 'string' ? message.instanceKey : '';
|
|
765
|
+
const enabled = message.enabled;
|
|
766
|
+
if (!instanceKey || typeof enabled !== 'boolean') {
|
|
767
|
+
emit({ requestId, error: 'instanceKey and boolean enabled are required' });
|
|
768
|
+
return payloads;
|
|
769
|
+
}
|
|
770
|
+
if (!ctx.setPluginEnabled) {
|
|
771
|
+
emit({ requestId, error: 'Plugin lifecycle management is not configured' });
|
|
772
|
+
return payloads;
|
|
773
|
+
}
|
|
774
|
+
const lifecycle = await ctx.setPluginEnabled(instanceKey, enabled);
|
|
775
|
+
ctx.publishEvent?.('plugin:lifecycle-updated', { instanceKey, enabled });
|
|
776
|
+
emit({
|
|
777
|
+
requestId,
|
|
778
|
+
data: {
|
|
779
|
+
success: true,
|
|
780
|
+
instanceKey,
|
|
781
|
+
enabled,
|
|
782
|
+
disabled: lifecycle.disabled,
|
|
783
|
+
restartRequired: true,
|
|
784
|
+
message: `${instanceKey} 已${enabled ? '启用' : '停用'},Host 正在重启`,
|
|
785
|
+
},
|
|
786
|
+
});
|
|
787
|
+
if (ctx.requestRestart) {
|
|
788
|
+
setTimeout(() => {
|
|
789
|
+
void Promise.resolve(ctx.requestRestart?.()).catch(() => undefined);
|
|
790
|
+
}, 500);
|
|
791
|
+
}
|
|
792
|
+
} catch (error) {
|
|
793
|
+
emit({ requestId, error: error instanceof Error ? error.message : String(error) });
|
|
794
|
+
}
|
|
795
|
+
return payloads;
|
|
796
|
+
}
|
|
757
797
|
default:
|
|
758
798
|
if (type.startsWith('db:')) {
|
|
759
799
|
emit({
|
package/src/http-host.ts
CHANGED
|
@@ -1,9 +1,14 @@
|
|
|
1
1
|
import {
|
|
2
|
-
createServer,
|
|
2
|
+
createServer as createHttpServer,
|
|
3
3
|
type IncomingMessage,
|
|
4
|
-
type Server,
|
|
4
|
+
type Server as HttpServer,
|
|
5
5
|
type ServerResponse,
|
|
6
6
|
} from 'node:http';
|
|
7
|
+
import {
|
|
8
|
+
createServer as createHttpsServer,
|
|
9
|
+
type Server as HttpsServer,
|
|
10
|
+
type ServerOptions as HttpsServerOptions,
|
|
11
|
+
} from 'node:https';
|
|
7
12
|
import type { Socket } from 'node:net';
|
|
8
13
|
import { WebSocket, WebSocketServer } from 'ws';
|
|
9
14
|
import { formatCompact, getLogger } from '@zhin.js/logger';
|
|
@@ -46,9 +51,29 @@ export interface WsHandle {
|
|
|
46
51
|
close(): void;
|
|
47
52
|
}
|
|
48
53
|
|
|
54
|
+
export interface WsRouteOptions {
|
|
55
|
+
/**
|
|
56
|
+
* `host` applies the Host Bearer-token registry before upgrade. `protocol`
|
|
57
|
+
* leaves upgrade authentication to the registered application protocol.
|
|
58
|
+
*/
|
|
59
|
+
readonly auth?: 'host' | 'protocol';
|
|
60
|
+
}
|
|
61
|
+
|
|
49
62
|
export interface HttpHostAddress {
|
|
50
63
|
readonly host: string;
|
|
51
64
|
readonly port: number;
|
|
65
|
+
readonly protocol: 'http' | 'https';
|
|
66
|
+
readonly secure: boolean;
|
|
67
|
+
readonly origin: string;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export interface HttpHostTlsOptions {
|
|
71
|
+
readonly key: NonNullable<HttpsServerOptions['key']>;
|
|
72
|
+
readonly cert: NonNullable<HttpsServerOptions['cert']>;
|
|
73
|
+
readonly ca?: HttpsServerOptions['ca'];
|
|
74
|
+
readonly passphrase?: string;
|
|
75
|
+
readonly minVersion?: HttpsServerOptions['minVersion'];
|
|
76
|
+
readonly ciphers?: string;
|
|
52
77
|
}
|
|
53
78
|
|
|
54
79
|
export interface HttpHostOptions {
|
|
@@ -67,6 +92,8 @@ export interface HttpHostOptions {
|
|
|
67
92
|
readonly apiBase?: string;
|
|
68
93
|
/** Extra HTTP path prefixes that skip auth even under `apiBase`. */
|
|
69
94
|
readonly authExemptPaths?: readonly string[];
|
|
95
|
+
/** Enables HTTPS and WSS on this listener. Certificate lifecycle belongs to the process composition root. */
|
|
96
|
+
readonly tls?: HttpHostTlsOptions;
|
|
70
97
|
}
|
|
71
98
|
|
|
72
99
|
export type HttpHandler = (
|
|
@@ -83,7 +110,7 @@ export interface HttpRouteRegistration {
|
|
|
83
110
|
|
|
84
111
|
/** @public Stable HTTP and WebSocket Host contract resolved through `httpHostToken`. */
|
|
85
112
|
export interface HttpHost {
|
|
86
|
-
ws(path: string): WsHandle;
|
|
113
|
+
ws(path: string, options?: WsRouteOptions): WsHandle;
|
|
87
114
|
route(
|
|
88
115
|
method: string,
|
|
89
116
|
path: string,
|
|
@@ -117,6 +144,7 @@ interface AdmissionBoundHttpHost extends HttpHost, GenerationAdmissionBindable<H
|
|
|
117
144
|
interface WsRoute {
|
|
118
145
|
readonly listener: (connection: WsConnection) => void;
|
|
119
146
|
readonly admission?: GenerationAdmissionGate;
|
|
147
|
+
readonly options: WsRouteOptions;
|
|
120
148
|
}
|
|
121
149
|
|
|
122
150
|
/** @public Stable Plugin Runtime HTTP Host token. */
|
|
@@ -143,9 +171,13 @@ export function createHttpHost(options: HttpHostOptions = {}): ProcessHttpHost {
|
|
|
143
171
|
let closed = false;
|
|
144
172
|
let closeResult: Promise<void> | undefined;
|
|
145
173
|
|
|
146
|
-
const
|
|
174
|
+
const secure = options.tls !== undefined;
|
|
175
|
+
const handleRequest = (request: IncomingMessage, response: ServerResponse): void => {
|
|
147
176
|
void dispatchHttp(request, response);
|
|
148
|
-
}
|
|
177
|
+
};
|
|
178
|
+
const server: HttpServer | HttpsServer = secure
|
|
179
|
+
? createHttpsServer(options.tls, handleRequest)
|
|
180
|
+
: createHttpServer(handleRequest);
|
|
149
181
|
|
|
150
182
|
// Track live TCP sockets so close() can destroy long-lived connections
|
|
151
183
|
// (SSE /api/events, keep-alive) that would otherwise block server.close().
|
|
@@ -184,7 +216,7 @@ export function createHttpHost(options: HttpHostOptions = {}): ProcessHttpHost {
|
|
|
184
216
|
meta: Object.freeze({ summary: 'OpenAPI 3.1 document', tags: ['pub'] }),
|
|
185
217
|
handler: (request: IncomingMessage, response: ServerResponse) => {
|
|
186
218
|
const hostHeader = headerValue(request.headers.host) ?? 'localhost';
|
|
187
|
-
const proto = headerValue(request.headers['x-forwarded-proto']) ?? 'http';
|
|
219
|
+
const proto = headerValue(request.headers['x-forwarded-proto']) ?? (secure ? 'https' : 'http');
|
|
188
220
|
writeJson(response, 200, buildOpenApiDocument(listListedRoutes(), {
|
|
189
221
|
apiBase,
|
|
190
222
|
serverUrl: `${proto}://${hostHeader}`,
|
|
@@ -210,7 +242,10 @@ export function createHttpHost(options: HttpHostOptions = {}): ProcessHttpHost {
|
|
|
210
242
|
socket.destroy();
|
|
211
243
|
return;
|
|
212
244
|
}
|
|
213
|
-
const
|
|
245
|
+
const protocolAuthenticated = admitted.every(({ entry }) => entry.options.auth === 'protocol');
|
|
246
|
+
const auth = protocolAuthenticated
|
|
247
|
+
? { ok: true as const, scope: 'full' as const }
|
|
248
|
+
: authenticateUpgrade(request, pathname);
|
|
214
249
|
if (!auth.ok) {
|
|
215
250
|
socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n');
|
|
216
251
|
socket.destroy();
|
|
@@ -406,6 +441,7 @@ export function createHttpHost(options: HttpHostOptions = {}): ProcessHttpHost {
|
|
|
406
441
|
function registerWs(
|
|
407
442
|
pathname: string,
|
|
408
443
|
admission?: GenerationAdmissionGate,
|
|
444
|
+
options: WsRouteOptions = {},
|
|
409
445
|
): WsHandle {
|
|
410
446
|
const normalized = normalizePath(pathname);
|
|
411
447
|
let listeners = wsRoutes.get(normalized);
|
|
@@ -416,7 +452,7 @@ export function createHttpHost(options: HttpHostOptions = {}): ProcessHttpHost {
|
|
|
416
452
|
const owned = new Set<WsRoute>();
|
|
417
453
|
return {
|
|
418
454
|
onConnection(listener) {
|
|
419
|
-
const entry = Object.freeze({ listener, admission });
|
|
455
|
+
const entry = Object.freeze({ listener, admission, options: Object.freeze({ ...options }) });
|
|
420
456
|
listeners!.add(entry);
|
|
421
457
|
owned.add(entry);
|
|
422
458
|
return () => {
|
|
@@ -463,7 +499,7 @@ export function createHttpHost(options: HttpHostOptions = {}): ProcessHttpHost {
|
|
|
463
499
|
): AdmissionBoundHttpHost {
|
|
464
500
|
const bound: AdmissionBoundHttpHost = {
|
|
465
501
|
[generationAdmissionBinder]: (next) => createBoundHost(next),
|
|
466
|
-
ws: (path) => registerWs(path, admission),
|
|
502
|
+
ws: (path, options) => registerWs(path, admission, options),
|
|
467
503
|
route: (method, path, handler, meta) => registerRoute(
|
|
468
504
|
method,
|
|
469
505
|
path,
|
|
@@ -481,7 +517,7 @@ export function createHttpHost(options: HttpHostOptions = {}): ProcessHttpHost {
|
|
|
481
517
|
const runtime: ProcessHttpHost = {
|
|
482
518
|
[generationAdmissionBinder]: (admission) => createBoundHost(admission),
|
|
483
519
|
|
|
484
|
-
ws: (pathname) => registerWs(pathname),
|
|
520
|
+
ws: (pathname, options) => registerWs(pathname, undefined, options),
|
|
485
521
|
|
|
486
522
|
route: (method, path, handler, meta) => registerRoute(method, path, handler, meta),
|
|
487
523
|
|
|
@@ -506,9 +542,14 @@ export function createHttpHost(options: HttpHostOptions = {}): ProcessHttpHost {
|
|
|
506
542
|
const bound = server.address();
|
|
507
543
|
const listenPort = typeof bound === 'object' && bound ? bound.port : port;
|
|
508
544
|
const publicHost = host === '0.0.0.0' || host === '::' ? '127.0.0.1' : host;
|
|
509
|
-
|
|
545
|
+
const protocol = secure ? 'https' : 'http';
|
|
546
|
+
const originHost = publicHost.includes(':') ? `[${publicHost}]` : publicHost;
|
|
547
|
+
address = Object.freeze({
|
|
548
|
+
host: publicHost, port: listenPort, protocol, secure,
|
|
549
|
+
origin: `${protocol}://${originHost}:${listenPort}`,
|
|
550
|
+
});
|
|
510
551
|
logger.info(
|
|
511
|
-
`listening
|
|
552
|
+
`listening ${address.origin}`
|
|
512
553
|
+ ` | routes: ${httpRoutes.length}`
|
|
513
554
|
+ ` | token: ${tokenRegistry.hasAnyToken()
|
|
514
555
|
? `${tokenRegistry.primaryTokenPrefixForLog()}…`
|
package/src/index.ts
CHANGED
|
@@ -7,22 +7,30 @@ export {
|
|
|
7
7
|
type ProcessHttpHost,
|
|
8
8
|
type HttpHostAddress,
|
|
9
9
|
type HttpHostOptions,
|
|
10
|
+
type HttpHostTlsOptions,
|
|
10
11
|
type HttpRouteRegistration,
|
|
11
12
|
type WsConnection,
|
|
12
13
|
type WsHandle,
|
|
14
|
+
type WsRouteOptions,
|
|
13
15
|
} from './http-host.js';
|
|
16
|
+
export {
|
|
17
|
+
createHttpHostGroup,
|
|
18
|
+
listHttpHostAddresses,
|
|
19
|
+
} from './listener-group.js';
|
|
14
20
|
export {
|
|
15
21
|
TokenRegistry,
|
|
16
22
|
extractBearerToken,
|
|
17
23
|
isDemoWebSocketPath,
|
|
18
24
|
type AuthenticatedTokenPrincipal,
|
|
19
25
|
type AuthScope,
|
|
26
|
+
type DynamicTokenConfig,
|
|
20
27
|
type ScopedTokenConfig,
|
|
21
28
|
type TokenRegistryConfig,
|
|
22
29
|
} from './token-registry.js';
|
|
23
30
|
export { timingSafeEqualString } from './timing-safe-equal.js';
|
|
24
31
|
export {
|
|
25
32
|
createConsoleEventHub,
|
|
33
|
+
consoleEventHubToken,
|
|
26
34
|
type ConsoleEventHub,
|
|
27
35
|
} from './console-events.js';
|
|
28
36
|
export {
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import {
|
|
2
|
+
bindGenerationAdmission,
|
|
3
|
+
generationAdmissionBinder,
|
|
4
|
+
type GenerationAdmissionBindable,
|
|
5
|
+
type GenerationAdmissionGate,
|
|
6
|
+
} from '@zhin.js/plugin-runtime';
|
|
7
|
+
import {
|
|
8
|
+
createHttpHost,
|
|
9
|
+
type HttpHost,
|
|
10
|
+
type HttpHostAddress,
|
|
11
|
+
type HttpHostOptions,
|
|
12
|
+
type HttpRouteRegistration,
|
|
13
|
+
type ProcessHttpHost,
|
|
14
|
+
type WsHandle,
|
|
15
|
+
type WsRouteOptions,
|
|
16
|
+
} from './http-host.js';
|
|
17
|
+
|
|
18
|
+
const addressProvider = Symbol('HttpHostAddressProvider');
|
|
19
|
+
|
|
20
|
+
interface AddressAwareHttpHost {
|
|
21
|
+
readonly [addressProvider]: () => readonly HttpHostAddress[];
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
type BoundHttpHost = HttpHost & GenerationAdmissionBindable<HttpHost> & AddressAwareHttpHost;
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Runs one logical Host surface on multiple network listeners. Route and WS
|
|
28
|
+
* registration stays generation-owned and is mirrored to every listener,
|
|
29
|
+
* while `address` intentionally remains the primary/local listener.
|
|
30
|
+
*/
|
|
31
|
+
export function createHttpHostGroup(
|
|
32
|
+
options: readonly HttpHostOptions[],
|
|
33
|
+
): ProcessHttpHost {
|
|
34
|
+
if (options.length === 0) throw new Error('HttpHostGroup requires at least one listener');
|
|
35
|
+
const listeners = options.map((entry) => createHttpHost(entry));
|
|
36
|
+
|
|
37
|
+
const createSurface = (hosts: readonly HttpHost[]): BoundHttpHost => {
|
|
38
|
+
const primary = hosts[0]!;
|
|
39
|
+
const surface: BoundHttpHost = {
|
|
40
|
+
[generationAdmissionBinder](admission: GenerationAdmissionGate): HttpHost {
|
|
41
|
+
return createSurface(hosts.map((host) => bindGenerationAdmission(host, admission)));
|
|
42
|
+
},
|
|
43
|
+
[addressProvider]: () => listeners.flatMap((host) => host.address ? [host.address] : []),
|
|
44
|
+
ws(path: string, wsOptions?: WsRouteOptions): WsHandle {
|
|
45
|
+
const handles = hosts.map((host) => host.ws(path, wsOptions));
|
|
46
|
+
return {
|
|
47
|
+
onConnection(listener) {
|
|
48
|
+
const disposers = handles.map((handle) => handle.onConnection(listener));
|
|
49
|
+
return () => { for (const dispose of disposers.reverse()) dispose(); };
|
|
50
|
+
},
|
|
51
|
+
close() { for (const handle of handles) handle.close(); },
|
|
52
|
+
};
|
|
53
|
+
},
|
|
54
|
+
route(method, path, handler, meta): HttpRouteRegistration {
|
|
55
|
+
const disposers = hosts.map((host) => host.route(method, path, handler, meta));
|
|
56
|
+
return () => { for (const dispose of disposers.reverse()) dispose(); };
|
|
57
|
+
},
|
|
58
|
+
listRoutes: () => primary.listRoutes(),
|
|
59
|
+
get address() { return primary.address; },
|
|
60
|
+
get tokenRegistry() { return primary.tokenRegistry; },
|
|
61
|
+
};
|
|
62
|
+
return Object.freeze(surface);
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
const surface = createSurface(listeners);
|
|
66
|
+
let closeResult: Promise<void> | undefined;
|
|
67
|
+
const runtime: ProcessHttpHost & AddressAwareHttpHost = {
|
|
68
|
+
[generationAdmissionBinder](admission: GenerationAdmissionGate): HttpHost {
|
|
69
|
+
return surface[generationAdmissionBinder](admission);
|
|
70
|
+
},
|
|
71
|
+
[addressProvider]: surface[addressProvider],
|
|
72
|
+
ws: (path, wsOptions) => surface.ws(path, wsOptions),
|
|
73
|
+
route: (method, path, handler, meta) => surface.route(method, path, handler, meta),
|
|
74
|
+
listRoutes: () => surface.listRoutes(),
|
|
75
|
+
get address() { return surface.address; },
|
|
76
|
+
get tokenRegistry() { return surface.tokenRegistry; },
|
|
77
|
+
async listen(): Promise<HttpHostAddress> {
|
|
78
|
+
try {
|
|
79
|
+
for (const listener of listeners) {
|
|
80
|
+
await listener.listen();
|
|
81
|
+
}
|
|
82
|
+
} catch (error) {
|
|
83
|
+
await Promise.allSettled([...listeners].reverse().map((listener) => listener.close()));
|
|
84
|
+
throw error;
|
|
85
|
+
}
|
|
86
|
+
return listeners[0]!.address!;
|
|
87
|
+
},
|
|
88
|
+
close(): Promise<void> {
|
|
89
|
+
closeResult ??= closeListeners(listeners);
|
|
90
|
+
return closeResult;
|
|
91
|
+
},
|
|
92
|
+
};
|
|
93
|
+
return Object.freeze(runtime);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Returns every bound listener, ordered primary/local first. */
|
|
97
|
+
export function listHttpHostAddresses(host: HttpHost): readonly HttpHostAddress[] {
|
|
98
|
+
const provider = (host as Partial<AddressAwareHttpHost>)[addressProvider];
|
|
99
|
+
if (typeof provider === 'function') return Object.freeze([...provider.call(host)]);
|
|
100
|
+
return Object.freeze(host.address ? [host.address] : []);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
async function closeListeners(listeners: readonly ProcessHttpHost[]): Promise<void> {
|
|
104
|
+
const results = await Promise.allSettled([...listeners].reverse().map((listener) => listener.close()));
|
|
105
|
+
const errors = results.flatMap((result) => result.status === 'rejected' ? [result.reason] : []);
|
|
106
|
+
if (errors.length === 1) throw errors[0];
|
|
107
|
+
if (errors.length > 1) throw new AggregateError(errors, 'multiple HTTP listeners failed to close');
|
|
108
|
+
}
|
package/src/token-registry.ts
CHANGED
|
@@ -20,17 +20,35 @@ export type TokenRegistryConfig = {
|
|
|
20
20
|
readonly scopedTokens?: readonly ScopedTokenConfig[];
|
|
21
21
|
};
|
|
22
22
|
|
|
23
|
+
export type DynamicTokenConfig = ScopedTokenConfig & {
|
|
24
|
+
/** Unix epoch milliseconds; omitted credentials remain valid until explicitly revoked. */
|
|
25
|
+
readonly expiresAt?: number;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
type TokenBinding = Readonly<{
|
|
29
|
+
scope: AuthScope;
|
|
30
|
+
principalId?: string;
|
|
31
|
+
expiresAt?: number;
|
|
32
|
+
dynamic: boolean;
|
|
33
|
+
}>;
|
|
34
|
+
|
|
23
35
|
export class TokenRegistry {
|
|
24
|
-
readonly #entries = new Map<string,
|
|
36
|
+
readonly #entries = new Map<string, TokenBinding>();
|
|
25
37
|
|
|
26
38
|
constructor(config: TokenRegistryConfig = {}) {
|
|
27
|
-
if (config.primaryToken)
|
|
39
|
+
if (config.primaryToken) {
|
|
40
|
+
this.#entries.set(config.primaryToken, Object.freeze({ scope: 'full', dynamic: false }));
|
|
41
|
+
}
|
|
28
42
|
for (const { token, scope, principalId } of config.scopedTokens ?? []) {
|
|
29
43
|
if (!token) continue;
|
|
30
44
|
if (principalId !== undefined && (!principalId.trim() || principalId !== principalId.trim())) {
|
|
31
45
|
throw new Error('HTTP token principalId is invalid');
|
|
32
46
|
}
|
|
33
|
-
const candidate = Object.freeze({
|
|
47
|
+
const candidate = Object.freeze({
|
|
48
|
+
scope,
|
|
49
|
+
...(principalId === undefined ? {} : { principalId }),
|
|
50
|
+
dynamic: false,
|
|
51
|
+
});
|
|
34
52
|
const existing = this.#entries.get(token);
|
|
35
53
|
if (existing && (existing.scope !== candidate.scope || existing.principalId !== candidate.principalId)) {
|
|
36
54
|
throw new Error('HTTP token has conflicting authority bindings');
|
|
@@ -42,7 +60,9 @@ export class TokenRegistry {
|
|
|
42
60
|
resolve(token: string): AuthScope | null {
|
|
43
61
|
if (!token) return null;
|
|
44
62
|
for (const [known, binding] of this.#entries) {
|
|
45
|
-
if (timingSafeEqualString(known, token))
|
|
63
|
+
if (!timingSafeEqualString(known, token)) continue;
|
|
64
|
+
if (this.#expired(known, binding)) return null;
|
|
65
|
+
return binding.scope;
|
|
46
66
|
}
|
|
47
67
|
return null;
|
|
48
68
|
}
|
|
@@ -51,6 +71,7 @@ export class TokenRegistry {
|
|
|
51
71
|
if (!token) return null;
|
|
52
72
|
for (const [known, binding] of this.#entries) {
|
|
53
73
|
if (timingSafeEqualString(known, token)) {
|
|
74
|
+
if (this.#expired(known, binding)) return null;
|
|
54
75
|
return binding.principalId
|
|
55
76
|
? Object.freeze({ principalId: binding.principalId, scope: binding.scope })
|
|
56
77
|
: null;
|
|
@@ -60,7 +81,43 @@ export class TokenRegistry {
|
|
|
60
81
|
}
|
|
61
82
|
|
|
62
83
|
hasAnyToken(): boolean {
|
|
63
|
-
|
|
84
|
+
for (const [token, binding] of this.#entries) {
|
|
85
|
+
if (!this.#expired(token, binding)) return true;
|
|
86
|
+
}
|
|
87
|
+
return false;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Registers a runtime-issued credential and returns an idempotent revoker. */
|
|
91
|
+
register(config: DynamicTokenConfig): () => void {
|
|
92
|
+
if (!config.token) throw new Error('Dynamic HTTP token is required');
|
|
93
|
+
if (config.principalId !== undefined
|
|
94
|
+
&& (!config.principalId.trim() || config.principalId !== config.principalId.trim())) {
|
|
95
|
+
throw new Error('Dynamic HTTP token principalId is invalid');
|
|
96
|
+
}
|
|
97
|
+
if (config.expiresAt !== undefined
|
|
98
|
+
&& (!Number.isSafeInteger(config.expiresAt) || config.expiresAt <= Date.now())) {
|
|
99
|
+
throw new Error('Dynamic HTTP token expiresAt must be a future epoch millisecond');
|
|
100
|
+
}
|
|
101
|
+
const candidate: TokenBinding = Object.freeze({
|
|
102
|
+
scope: config.scope,
|
|
103
|
+
...(config.principalId === undefined ? {} : { principalId: config.principalId }),
|
|
104
|
+
...(config.expiresAt === undefined ? {} : { expiresAt: config.expiresAt }),
|
|
105
|
+
dynamic: true,
|
|
106
|
+
});
|
|
107
|
+
const existing = this.#entries.get(config.token);
|
|
108
|
+
if (existing) throw new Error('Dynamic HTTP token conflicts with an existing credential');
|
|
109
|
+
this.#entries.set(config.token, candidate);
|
|
110
|
+
let active = true;
|
|
111
|
+
return () => {
|
|
112
|
+
if (!active) return;
|
|
113
|
+
active = false;
|
|
114
|
+
if (this.#entries.get(config.token) === candidate) this.#entries.delete(config.token);
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
revoke(token: string): boolean {
|
|
119
|
+
const binding = this.#entries.get(token);
|
|
120
|
+
return binding?.dynamic === true && this.#entries.delete(token);
|
|
64
121
|
}
|
|
65
122
|
|
|
66
123
|
primaryTokenPrefixForLog(): string {
|
|
@@ -70,6 +127,12 @@ export class TokenRegistry {
|
|
|
70
127
|
const first = this.#entries.keys().next().value;
|
|
71
128
|
return first ? first.slice(0, 6) : '';
|
|
72
129
|
}
|
|
130
|
+
|
|
131
|
+
#expired(token: string, binding: TokenBinding): boolean {
|
|
132
|
+
if (binding.expiresAt === undefined || binding.expiresAt > Date.now()) return false;
|
|
133
|
+
if (binding.dynamic) this.#entries.delete(token);
|
|
134
|
+
return true;
|
|
135
|
+
}
|
|
73
136
|
}
|
|
74
137
|
|
|
75
138
|
/** WebSocket upgrade paths allowed for demo scope. */
|