@zhin.js/host-http 1.1.15 → 1.1.17

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/README.md CHANGED
@@ -4,8 +4,8 @@ Minimal HTTP and WebSocket host for Plugin Runtime. Provides `httpHostToken` so
4
4
  Console can register path-scoped WebSocket / HTTP routes without importing Koa or the legacy
5
5
  `@zhin.js/host-router`.
6
6
 
7
- Root installs the host via `installResources` (see `basic/cli/src/plugin-runtime/http-host-installer.ts`
8
- and `console-host-installer.ts`).
7
+ Root installs the host via `installResources` (see `packages/im/runtime/src/host/http-host-installer.ts`
8
+ and `basic/cli/src/plugin-runtime/console-host-installer.ts`).
9
9
 
10
10
  The composition root owns the process listener through `ProcessHttpHost`. Generation scopes receive
11
11
  only the `HttpHost` routing port: they may register HTTP/WS routes but cannot listen or close the
@@ -0,0 +1,8 @@
1
+ import { type ConfigDocumentPort, type RootResourceInstaller, type RuntimeConfigDocument } from '@zhin.js/plugin-runtime';
2
+ import { type HttpHostOptions, type ProcessHttpHost } from './http-host.js';
3
+ export interface ResolvedHttpConfig extends HttpHostOptions {
4
+ /** Additional listeners that mirror the primary Host route surface. */
5
+ readonly listeners?: readonly HttpHostOptions[];
6
+ }
7
+ export declare function resolveHttpConfig(config: RuntimeConfigDocument | ConfigDocumentPort, envOverlay?: Readonly<Record<string, string | undefined>>, projectRoot?: string): Promise<ResolvedHttpConfig>;
8
+ export declare function installHttpHost(host: ProcessHttpHost): RootResourceInstaller;
@@ -0,0 +1,211 @@
1
+ import { bindGenerationAdmission, expandEnvironmentValue, } from '@zhin.js/plugin-runtime';
2
+ import { readFile } from 'node:fs/promises';
3
+ import { isAbsolute, resolve } from 'node:path';
4
+ import { httpHostToken, } from './http-host.js';
5
+ export async function resolveHttpConfig(config, envOverlay, projectRoot = process.cwd()) {
6
+ const document = await readConfigDocument(config);
7
+ if (!document || typeof document !== 'object')
8
+ return {};
9
+ const http = document.http;
10
+ if (!http || typeof http !== 'object')
11
+ return {};
12
+ const value = expandEnvironmentValue(http, (key) => envOverlay?.[key] ?? process.env[key]);
13
+ const primary = Object.freeze({
14
+ host: typeof value.host === 'string' ? value.host : undefined,
15
+ port: typeof value.port === 'number' ? value.port : undefined,
16
+ token: typeof value.token === 'string' ? value.token : undefined,
17
+ tokens: parseScopedTokens(value.tokens),
18
+ corsOrigins: injectDesktopWebviewOrigins(parseStringList(value.corsOrigins), envOverlay),
19
+ apiBase: typeof value.base === 'string' ? value.base : undefined,
20
+ tls: await parseTlsConfig(value.tls, projectRoot),
21
+ });
22
+ const configuredListeners = await parseAdditionalListeners(value.listeners, primary, projectRoot);
23
+ const listeners = await injectDesktopDeviceListener(configuredListeners, primary, envOverlay, projectRoot);
24
+ return Object.freeze({ ...primary, ...(listeners.length > 0 ? { listeners } : {}) });
25
+ }
26
+ const DESKTOP_WEBVIEW_ORIGINS = Object.freeze([
27
+ 'tauri://localhost',
28
+ 'http://tauri.localhost',
29
+ 'https://tauri.localhost',
30
+ ]);
31
+ function injectDesktopWebviewOrigins(configured, envOverlay) {
32
+ const origin = envOverlay?.ZHIN_DESKTOP_WEBVIEW_ORIGIN
33
+ ?? process.env.ZHIN_DESKTOP_WEBVIEW_ORIGIN;
34
+ if (origin === undefined)
35
+ return configured;
36
+ if (!DESKTOP_WEBVIEW_ORIGINS.includes(origin)) {
37
+ throw new Error('ZHIN_DESKTOP_WEBVIEW_ORIGIN is not a trusted Tauri origin');
38
+ }
39
+ return Object.freeze([...new Set([...(configured ?? []), ...DESKTOP_WEBVIEW_ORIGINS])]);
40
+ }
41
+ /**
42
+ * Desktop releases before the dual-listener migration may already have a
43
+ * user-owned zhin.config.yml without http.listeners. The native shell passes
44
+ * these trusted variables on every launch, so append the secure Device
45
+ * listener without replacing the user's persisted configuration.
46
+ */
47
+ async function injectDesktopDeviceListener(configured, primary, envOverlay, projectRoot) {
48
+ const environment = (key) => envOverlay?.[key] ?? process.env[key];
49
+ if (!parseEnabled(environment('ZHIN_DESKTOP_DEVICE_LISTENER_ENABLED') ?? false, 'ZHIN_DESKTOP_DEVICE_LISTENER_ENABLED'))
50
+ return configured;
51
+ const host = environment('ZHIN_DESKTOP_DEVICE_HOST')?.trim() || '0.0.0.0';
52
+ const portValue = environment('ZHIN_DESKTOP_DEVICE_PORT')?.trim();
53
+ const port = portValue === undefined || portValue === '' ? 17_889 : Number(portValue);
54
+ if (!Number.isSafeInteger(port) || port < 1 || port > 65_535) {
55
+ throw new Error('ZHIN_DESKTOP_DEVICE_PORT must be a valid TCP port');
56
+ }
57
+ const existing = configured.find(listener => listener.host === host && listener.port === port);
58
+ if (existing?.tls)
59
+ return configured;
60
+ if (existing) {
61
+ throw new Error('Desktop Device listener conflicts with a non-TLS http.listeners entry');
62
+ }
63
+ const tls = await parseTlsConfig({
64
+ enabled: true,
65
+ keyFile: environment('ZHIN_DESKTOP_TLS_KEY_FILE'),
66
+ certFile: environment('ZHIN_DESKTOP_TLS_CERT_FILE'),
67
+ minVersion: 'TLSv1.2',
68
+ }, projectRoot);
69
+ return Object.freeze([...configured, Object.freeze({ ...primary, host, port, tls })]);
70
+ }
71
+ async function parseAdditionalListeners(value, primary, projectRoot) {
72
+ if (value === undefined)
73
+ return Object.freeze([]);
74
+ if (!Array.isArray(value))
75
+ throw new Error('http.listeners must be an array');
76
+ const listeners = [];
77
+ for (const [index, entry] of value.entries()) {
78
+ if (!entry || typeof entry !== 'object') {
79
+ throw new Error(`http.listeners[${index}] must be an object`);
80
+ }
81
+ const listener = entry;
82
+ if (!parseEnabled(listener.enabled, `http.listeners[${index}].enabled`))
83
+ continue;
84
+ if (typeof listener.host !== 'string' || !listener.host) {
85
+ throw new Error(`http.listeners[${index}].host is required`);
86
+ }
87
+ if (typeof listener.port !== 'number') {
88
+ throw new Error(`http.listeners[${index}].port is required`);
89
+ }
90
+ listeners.push(Object.freeze({
91
+ ...primary,
92
+ host: listener.host,
93
+ port: listener.port,
94
+ tls: await parseTlsConfig(listener.tls, projectRoot),
95
+ }));
96
+ }
97
+ return Object.freeze(listeners);
98
+ }
99
+ async function parseTlsConfig(value, projectRoot) {
100
+ if (value === undefined || value === null || value === false)
101
+ return undefined;
102
+ if (!value || typeof value !== 'object')
103
+ throw new Error('http.tls must be an object');
104
+ const tls = value;
105
+ const enabled = parseTlsEnabled(tls.enabled);
106
+ if (!enabled)
107
+ return undefined;
108
+ if (typeof tls.keyFile !== 'string' || !tls.keyFile
109
+ || typeof tls.certFile !== 'string' || !tls.certFile) {
110
+ throw new Error('http.tls.keyFile and http.tls.certFile are required');
111
+ }
112
+ const minVersion = tls.minVersion;
113
+ if (minVersion !== undefined
114
+ && !['TLSv1.2', 'TLSv1.3'].includes(String(minVersion))) {
115
+ throw new Error('http.tls.minVersion must be TLSv1.2 or TLSv1.3');
116
+ }
117
+ const [key, cert, ca] = await Promise.all([
118
+ readFile(resolveTlsPath(projectRoot, tls.keyFile)),
119
+ readFile(resolveTlsPath(projectRoot, tls.certFile)),
120
+ typeof tls.caFile === 'string' && tls.caFile
121
+ ? readFile(resolveTlsPath(projectRoot, tls.caFile)) : undefined,
122
+ ]);
123
+ return Object.freeze({
124
+ key, cert,
125
+ ...(ca ? { ca } : {}),
126
+ ...(typeof tls.passphrase === 'string' ? { passphrase: tls.passphrase } : {}),
127
+ ...(minVersion ? { minVersion: minVersion } : {}),
128
+ ...(typeof tls.ciphers === 'string' && tls.ciphers ? { ciphers: tls.ciphers } : {}),
129
+ });
130
+ }
131
+ function parseTlsEnabled(value) {
132
+ return parseEnabled(value, 'http.tls.enabled');
133
+ }
134
+ function parseEnabled(value, field) {
135
+ if (value === undefined || value === true)
136
+ return true;
137
+ if (value === false)
138
+ return false;
139
+ if (typeof value === 'string') {
140
+ const normalized = value.trim().toLowerCase();
141
+ if (['true', '1', 'on'].includes(normalized))
142
+ return true;
143
+ if (['false', '0', 'off', ''].includes(normalized))
144
+ return false;
145
+ }
146
+ throw new Error(`${field} must be a boolean`);
147
+ }
148
+ function resolveTlsPath(projectRoot, value) {
149
+ return isAbsolute(value) ? value : resolve(projectRoot, value);
150
+ }
151
+ export function installHttpHost(host) {
152
+ return ({ resources, admission }) => {
153
+ const port = host;
154
+ resources.provide(httpHostToken, bindGenerationAdmission(port, admission));
155
+ };
156
+ }
157
+ function parseScopedTokens(value) {
158
+ if (!Array.isArray(value))
159
+ return undefined;
160
+ const tokens = [];
161
+ for (const entry of value) {
162
+ if (!entry || typeof entry !== 'object')
163
+ continue;
164
+ const record = entry;
165
+ if (typeof record.token !== 'string' || !record.token)
166
+ continue;
167
+ // `user` is an alias of `demo` (an ordinary Console user); anything but `full` is never the operator.
168
+ const scope = record.scope === 'full' ? 'full' : 'demo';
169
+ const principalId = typeof record.principalId === 'string' && record.principalId.trim() === record.principalId
170
+ && record.principalId.length > 0
171
+ ? record.principalId
172
+ : undefined;
173
+ const permissions = parseAccessList(record.permissions);
174
+ const roles = parseAccessList(record.roles);
175
+ tokens.push(Object.freeze({
176
+ token: record.token,
177
+ scope,
178
+ ...(principalId ? { principalId } : {}),
179
+ ...(permissions ? { permissions } : {}),
180
+ ...(roles ? { roles } : {}),
181
+ }));
182
+ }
183
+ return tokens.length > 0 ? Object.freeze(tokens) : undefined;
184
+ }
185
+ /** Console permissions / roles of a scoped token; blank or non-string entries are dropped. */
186
+ function parseAccessList(value) {
187
+ if (!Array.isArray(value))
188
+ return undefined;
189
+ const result = value
190
+ .filter((item) => typeof item === 'string')
191
+ .map((item) => item.trim())
192
+ .filter((item) => item.length > 0);
193
+ return result.length > 0 ? Object.freeze([...new Set(result)]) : undefined;
194
+ }
195
+ function parseStringList(value) {
196
+ if (!Array.isArray(value))
197
+ return undefined;
198
+ const result = value.filter((item) => typeof item === 'string' && item.length > 0);
199
+ return result.length > 0 ? Object.freeze(result) : undefined;
200
+ }
201
+ async function readConfigDocument(config) {
202
+ if (!isConfigDocumentPort(config))
203
+ return config;
204
+ return (await config.read()).document;
205
+ }
206
+ function isConfigDocumentPort(value) {
207
+ if (!value || typeof value !== 'object')
208
+ return false;
209
+ const candidate = value;
210
+ return typeof candidate.read === 'function';
211
+ }
package/lib/index.d.ts CHANGED
@@ -10,3 +10,4 @@ export { dispatchPluginConsoleRpc } from './console-plugin-rpc.js';
10
10
  export { buildProjectFileTree, FILE_MANAGER_ALLOWED, FILE_MANAGER_BLOCKED, isProjectPathAllowed, listEnvFiles, readProjectFile, saveProjectFile, type FileTreeNode, } from './project-files.js';
11
11
  export { registerConsoleRestPages, type ConsoleAgentIntrospection, type ConsoleAgentRuntime, type ConsoleAgentSessionTree, type ConsoleRestCtx, type ConsoleRestPagesOptions, } from './console-rest-pages.js';
12
12
  export { dispatchExtendedConsoleRpc, type ConsoleRpcExtendedCtx, type ConsoleScheduleEngine, type ConsoleWorkroomProfileControlPort, type ConsoleWorkroomKnowledgeControlPort, type ExtendedRpcResult, } from './console-rpc-extended/index.js';
13
+ export * from './host-installer.js';
package/lib/index.js CHANGED
@@ -10,3 +10,4 @@ export { dispatchPluginConsoleRpc } from './console-plugin-rpc.js';
10
10
  export { buildProjectFileTree, FILE_MANAGER_ALLOWED, FILE_MANAGER_BLOCKED, isProjectPathAllowed, listEnvFiles, readProjectFile, saveProjectFile, } from './project-files.js';
11
11
  export { registerConsoleRestPages, } from './console-rest-pages.js';
12
12
  export { dispatchExtendedConsoleRpc, } from './console-rpc-extended/index.js';
13
+ export * from './host-installer.js';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zhin.js/host-http",
3
- "version": "1.1.15",
3
+ "version": "1.1.17",
4
4
  "description": "Minimal HTTP and WebSocket host for Plugin Runtime (no Koa)",
5
5
  "type": "module",
6
6
  "main": "./lib/index.js",
@@ -21,7 +21,7 @@
21
21
  "@zhin.js/host-http-contract": "1.1.1",
22
22
  "@zhin.js/logger": "1.1.2",
23
23
  "@zhin.js/schedule": "1.1.4",
24
- "@zhin.js/plugin-runtime": "1.1.21"
24
+ "@zhin.js/plugin-runtime": "1.1.23"
25
25
  },
26
26
  "devDependencies": {
27
27
  "@types/node": "^26.1.2",
@@ -0,0 +1,253 @@
1
+ import {
2
+ bindGenerationAdmission,
3
+ expandEnvironmentValue,
4
+ type ConfigDocumentPort,
5
+ type RootResourceInstaller,
6
+ type RuntimeConfigDocument,
7
+ } from '@zhin.js/plugin-runtime';
8
+ import { readFile } from 'node:fs/promises';
9
+ import { isAbsolute, resolve } from 'node:path';
10
+ import {
11
+ httpHostToken,
12
+ type HttpHost,
13
+ type HttpHostOptions,
14
+ type ProcessHttpHost,
15
+ } from './http-host.js';
16
+ import type { ScopedTokenConfig } from './token-registry.js';
17
+
18
+ export interface ResolvedHttpConfig extends HttpHostOptions {
19
+ /** Additional listeners that mirror the primary Host route surface. */
20
+ readonly listeners?: readonly HttpHostOptions[];
21
+ }
22
+
23
+ export async function resolveHttpConfig(
24
+ config: RuntimeConfigDocument | ConfigDocumentPort,
25
+ envOverlay?: Readonly<Record<string, string | undefined>>,
26
+ projectRoot = process.cwd(),
27
+ ): Promise<ResolvedHttpConfig> {
28
+ const document = await readConfigDocument(config);
29
+ if (!document || typeof document !== 'object') return {};
30
+ const http = (document as Record<string, unknown>).http;
31
+ if (!http || typeof http !== 'object') return {};
32
+ const value = expandEnvironmentValue(
33
+ http,
34
+ (key) => envOverlay?.[key] ?? process.env[key],
35
+ ) as Record<string, unknown>;
36
+ const primary: HttpHostOptions = Object.freeze({
37
+ host: typeof value.host === 'string' ? value.host : undefined,
38
+ port: typeof value.port === 'number' ? value.port : undefined,
39
+ token: typeof value.token === 'string' ? value.token : undefined,
40
+ tokens: parseScopedTokens(value.tokens),
41
+ corsOrigins: injectDesktopWebviewOrigins(parseStringList(value.corsOrigins), envOverlay),
42
+ apiBase: typeof value.base === 'string' ? value.base : undefined,
43
+ tls: await parseTlsConfig(value.tls, projectRoot),
44
+ });
45
+ const configuredListeners = await parseAdditionalListeners(value.listeners, primary, projectRoot);
46
+ const listeners = await injectDesktopDeviceListener(
47
+ configuredListeners,
48
+ primary,
49
+ envOverlay,
50
+ projectRoot,
51
+ );
52
+ return Object.freeze({...primary, ...(listeners.length > 0 ? {listeners} : {})});
53
+ }
54
+
55
+ const DESKTOP_WEBVIEW_ORIGINS = Object.freeze([
56
+ 'tauri://localhost',
57
+ 'http://tauri.localhost',
58
+ 'https://tauri.localhost',
59
+ ]);
60
+
61
+ function injectDesktopWebviewOrigins(
62
+ configured: readonly string[] | undefined,
63
+ envOverlay: Readonly<Record<string, string | undefined>> | undefined,
64
+ ): readonly string[] | undefined {
65
+ const origin = envOverlay?.ZHIN_DESKTOP_WEBVIEW_ORIGIN
66
+ ?? process.env.ZHIN_DESKTOP_WEBVIEW_ORIGIN;
67
+ if (origin === undefined) return configured;
68
+ if (!DESKTOP_WEBVIEW_ORIGINS.includes(origin)) {
69
+ throw new Error('ZHIN_DESKTOP_WEBVIEW_ORIGIN is not a trusted Tauri origin');
70
+ }
71
+ return Object.freeze([...new Set([...(configured ?? []), ...DESKTOP_WEBVIEW_ORIGINS])]);
72
+ }
73
+
74
+ /**
75
+ * Desktop releases before the dual-listener migration may already have a
76
+ * user-owned zhin.config.yml without http.listeners. The native shell passes
77
+ * these trusted variables on every launch, so append the secure Device
78
+ * listener without replacing the user's persisted configuration.
79
+ */
80
+ async function injectDesktopDeviceListener(
81
+ configured: readonly HttpHostOptions[],
82
+ primary: HttpHostOptions,
83
+ envOverlay: Readonly<Record<string, string | undefined>> | undefined,
84
+ projectRoot: string,
85
+ ): Promise<readonly HttpHostOptions[]> {
86
+ const environment = (key: string) => envOverlay?.[key] ?? process.env[key];
87
+ if (!parseEnabled(
88
+ environment('ZHIN_DESKTOP_DEVICE_LISTENER_ENABLED') ?? false,
89
+ 'ZHIN_DESKTOP_DEVICE_LISTENER_ENABLED',
90
+ )) return configured;
91
+ const host = environment('ZHIN_DESKTOP_DEVICE_HOST')?.trim() || '0.0.0.0';
92
+ const portValue = environment('ZHIN_DESKTOP_DEVICE_PORT')?.trim();
93
+ const port = portValue === undefined || portValue === '' ? 17_889 : Number(portValue);
94
+ if (!Number.isSafeInteger(port) || port < 1 || port > 65_535) {
95
+ throw new Error('ZHIN_DESKTOP_DEVICE_PORT must be a valid TCP port');
96
+ }
97
+ const existing = configured.find(listener => listener.host === host && listener.port === port);
98
+ if (existing?.tls) return configured;
99
+ if (existing) {
100
+ throw new Error('Desktop Device listener conflicts with a non-TLS http.listeners entry');
101
+ }
102
+ const tls = await parseTlsConfig({
103
+ enabled: true,
104
+ keyFile: environment('ZHIN_DESKTOP_TLS_KEY_FILE'),
105
+ certFile: environment('ZHIN_DESKTOP_TLS_CERT_FILE'),
106
+ minVersion: 'TLSv1.2',
107
+ }, projectRoot);
108
+ return Object.freeze([...configured, Object.freeze({...primary, host, port, tls})]);
109
+ }
110
+
111
+ async function parseAdditionalListeners(
112
+ value: unknown,
113
+ primary: HttpHostOptions,
114
+ projectRoot: string,
115
+ ): Promise<readonly HttpHostOptions[]> {
116
+ if (value === undefined) return Object.freeze([]);
117
+ if (!Array.isArray(value)) throw new Error('http.listeners must be an array');
118
+ const listeners: HttpHostOptions[] = [];
119
+ for (const [index, entry] of value.entries()) {
120
+ if (!entry || typeof entry !== 'object') {
121
+ throw new Error(`http.listeners[${index}] must be an object`);
122
+ }
123
+ const listener = entry as Record<string, unknown>;
124
+ if (!parseEnabled(listener.enabled, `http.listeners[${index}].enabled`)) continue;
125
+ if (typeof listener.host !== 'string' || !listener.host) {
126
+ throw new Error(`http.listeners[${index}].host is required`);
127
+ }
128
+ if (typeof listener.port !== 'number') {
129
+ throw new Error(`http.listeners[${index}].port is required`);
130
+ }
131
+ listeners.push(Object.freeze({
132
+ ...primary,
133
+ host: listener.host,
134
+ port: listener.port,
135
+ tls: await parseTlsConfig(listener.tls, projectRoot),
136
+ }));
137
+ }
138
+ return Object.freeze(listeners);
139
+ }
140
+
141
+ async function parseTlsConfig(
142
+ value: unknown,
143
+ projectRoot: string,
144
+ ): Promise<HttpHostOptions['tls']> {
145
+ if (value === undefined || value === null || value === false) return undefined;
146
+ if (!value || typeof value !== 'object') throw new Error('http.tls must be an object');
147
+ const tls = value as Record<string, unknown>;
148
+ const enabled = parseTlsEnabled(tls.enabled);
149
+ if (!enabled) return undefined;
150
+ if (typeof tls.keyFile !== 'string' || !tls.keyFile
151
+ || typeof tls.certFile !== 'string' || !tls.certFile) {
152
+ throw new Error('http.tls.keyFile and http.tls.certFile are required');
153
+ }
154
+ const minVersion = tls.minVersion;
155
+ if (minVersion !== undefined
156
+ && !['TLSv1.2', 'TLSv1.3'].includes(String(minVersion))) {
157
+ throw new Error('http.tls.minVersion must be TLSv1.2 or TLSv1.3');
158
+ }
159
+ const [key, cert, ca] = await Promise.all([
160
+ readFile(resolveTlsPath(projectRoot, tls.keyFile)),
161
+ readFile(resolveTlsPath(projectRoot, tls.certFile)),
162
+ typeof tls.caFile === 'string' && tls.caFile
163
+ ? readFile(resolveTlsPath(projectRoot, tls.caFile)) : undefined,
164
+ ]);
165
+ return Object.freeze({
166
+ key, cert,
167
+ ...(ca ? {ca} : {}),
168
+ ...(typeof tls.passphrase === 'string' ? {passphrase: tls.passphrase} : {}),
169
+ ...(minVersion ? {minVersion: minVersion as 'TLSv1.2' | 'TLSv1.3'} : {}),
170
+ ...(typeof tls.ciphers === 'string' && tls.ciphers ? {ciphers: tls.ciphers} : {}),
171
+ });
172
+ }
173
+
174
+ function parseTlsEnabled(value: unknown): boolean {
175
+ return parseEnabled(value, 'http.tls.enabled');
176
+ }
177
+
178
+ function parseEnabled(value: unknown, field: string): boolean {
179
+ if (value === undefined || value === true) return true;
180
+ if (value === false) return false;
181
+ if (typeof value === 'string') {
182
+ const normalized = value.trim().toLowerCase();
183
+ if (['true', '1', 'on'].includes(normalized)) return true;
184
+ if (['false', '0', 'off', ''].includes(normalized)) return false;
185
+ }
186
+ throw new Error(`${field} must be a boolean`);
187
+ }
188
+
189
+ function resolveTlsPath(projectRoot: string, value: string): string {
190
+ return isAbsolute(value) ? value : resolve(projectRoot, value);
191
+ }
192
+
193
+ export function installHttpHost(host: ProcessHttpHost): RootResourceInstaller {
194
+ return ({ resources, admission }) => {
195
+ const port: HttpHost = host;
196
+ resources.provide(httpHostToken, bindGenerationAdmission(port, admission));
197
+ };
198
+ }
199
+
200
+ function parseScopedTokens(value: unknown): readonly ScopedTokenConfig[] | undefined {
201
+ if (!Array.isArray(value)) return undefined;
202
+ const tokens: ScopedTokenConfig[] = [];
203
+ for (const entry of value) {
204
+ if (!entry || typeof entry !== 'object') continue;
205
+ const record = entry as Record<string, unknown>;
206
+ if (typeof record.token !== 'string' || !record.token) continue;
207
+ // `user` is an alias of `demo` (an ordinary Console user); anything but `full` is never the operator.
208
+ const scope = record.scope === 'full' ? 'full' : 'demo';
209
+ const principalId = typeof record.principalId === 'string' && record.principalId.trim() === record.principalId
210
+ && record.principalId.length > 0
211
+ ? record.principalId
212
+ : undefined;
213
+ const permissions = parseAccessList(record.permissions);
214
+ const roles = parseAccessList(record.roles);
215
+ tokens.push(Object.freeze({
216
+ token: record.token,
217
+ scope,
218
+ ...(principalId ? { principalId } : {}),
219
+ ...(permissions ? { permissions } : {}),
220
+ ...(roles ? { roles } : {}),
221
+ }));
222
+ }
223
+ return tokens.length > 0 ? Object.freeze(tokens) : undefined;
224
+ }
225
+
226
+ /** Console permissions / roles of a scoped token; blank or non-string entries are dropped. */
227
+ function parseAccessList(value: unknown): readonly string[] | undefined {
228
+ if (!Array.isArray(value)) return undefined;
229
+ const result = value
230
+ .filter((item): item is string => typeof item === 'string')
231
+ .map((item) => item.trim())
232
+ .filter((item) => item.length > 0);
233
+ return result.length > 0 ? Object.freeze([...new Set(result)]) : undefined;
234
+ }
235
+
236
+ function parseStringList(value: unknown): readonly string[] | undefined {
237
+ if (!Array.isArray(value)) return undefined;
238
+ const result = value.filter((item): item is string => typeof item === 'string' && item.length > 0);
239
+ return result.length > 0 ? Object.freeze(result) : undefined;
240
+ }
241
+
242
+ async function readConfigDocument(
243
+ config: RuntimeConfigDocument | ConfigDocumentPort,
244
+ ): Promise<unknown> {
245
+ if (!isConfigDocumentPort(config)) return config;
246
+ return (await config.read()).document;
247
+ }
248
+
249
+ function isConfigDocumentPort(value: unknown): value is ConfigDocumentPort {
250
+ if (!value || typeof value !== 'object') return false;
251
+ const candidate = value as Partial<ConfigDocumentPort>;
252
+ return typeof candidate.read === 'function';
253
+ }
package/src/index.ts CHANGED
@@ -94,3 +94,5 @@ export {
94
94
  type ConsoleWorkroomKnowledgeControlPort,
95
95
  type ExtendedRpcResult,
96
96
  } from './console-rpc-extended/index.js';
97
+ export * from './host-installer.js';
98
+