@zhin.js/host-http 1.1.14 → 1.1.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-rest-pages-helpers.d.ts +23 -0
- package/lib/console-rest-pages-helpers.js +570 -0
- package/lib/console-rest-pages.js +10 -570
- package/lib/console-rpc-handlers.d.ts +21 -0
- package/lib/console-rpc-handlers.js +659 -0
- package/lib/console-rpc.d.ts +1 -2
- package/lib/console-rpc.js +6 -620
- package/lib/host-installer.d.ts +8 -0
- package/lib/host-installer.js +211 -0
- package/lib/index.d.ts +1 -0
- package/lib/index.js +1 -0
- package/package.json +4 -4
- package/src/console-rest-pages-helpers.ts +619 -0
- package/src/console-rest-pages.ts +12 -612
- package/src/console-rpc-handlers.ts +608 -0
- package/src/console-rpc.ts +10 -579
- package/src/host-installer.ts +253 -0
- package/src/index.ts +2 -0
|
@@ -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
|
+
}
|