@zhin.js/host-http 1.0.9 → 1.0.11
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.d.ts +15 -5
- package/lib/console-rest-pages.js +162 -16
- package/lib/console-rpc-extended.d.ts +92 -2
- package/lib/console-rpc-extended.js +198 -3
- package/lib/console-rpc.d.ts +10 -0
- package/lib/console-rpc.js +35 -0
- package/lib/http-host.d.ts +2 -2
- package/lib/http-host.js +4 -3
- package/lib/index.d.ts +2 -2
- package/lib/token-registry.d.ts +7 -0
- package/lib/token-registry.js +29 -8
- package/package.json +3 -3
- package/src/console-rest-pages.ts +181 -20
- package/src/console-rpc-extended.ts +274 -4
- package/src/console-rpc.ts +43 -0
- package/src/http-host.ts +7 -4
- package/src/index.ts +3 -0
- package/src/token-registry.ts +36 -8
package/src/console-rpc.ts
CHANGED
|
@@ -81,6 +81,16 @@ 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
|
+
/** Atomic, revision-checked runtime Workroom Catalog read/write. */
|
|
85
|
+
readWorkroomCatalog?(): Promise<Readonly<{
|
|
86
|
+
agents: Readonly<Record<string, unknown>>;
|
|
87
|
+
workrooms: Readonly<Record<string, unknown>>;
|
|
88
|
+
revision: string;
|
|
89
|
+
}>>;
|
|
90
|
+
setWorkroomCatalog?(
|
|
91
|
+
workrooms: unknown,
|
|
92
|
+
expectedRevision: string,
|
|
93
|
+
): Promise<Readonly<{ revision: string; restartRequired: boolean }>>;
|
|
84
94
|
/** Project file manager (allowlisted paths under project root). */
|
|
85
95
|
listProjectFiles?(): Promise<readonly unknown[]>;
|
|
86
96
|
readProjectFile?(filePath: string): Promise<{ content: string; size: number }>;
|
|
@@ -240,6 +250,39 @@ export async function dispatchRuntimeConsoleRpc(
|
|
|
240
250
|
}
|
|
241
251
|
return payloads;
|
|
242
252
|
}
|
|
253
|
+
case 'workrooms:get': {
|
|
254
|
+
try {
|
|
255
|
+
if (!ctx.readWorkroomCatalog) {
|
|
256
|
+
emit({ requestId, error: 'Workroom Catalog read is not configured' });
|
|
257
|
+
return payloads;
|
|
258
|
+
}
|
|
259
|
+
emit({ requestId, data: await ctx.readWorkroomCatalog() });
|
|
260
|
+
} catch (error) {
|
|
261
|
+
emit({ requestId, error: error instanceof Error ? error.message : String(error) });
|
|
262
|
+
}
|
|
263
|
+
return payloads;
|
|
264
|
+
}
|
|
265
|
+
case 'workrooms:set': {
|
|
266
|
+
try {
|
|
267
|
+
if (!ctx.setWorkroomCatalog) {
|
|
268
|
+
emit({ requestId, error: 'Workroom Catalog write is not configured' });
|
|
269
|
+
return payloads;
|
|
270
|
+
}
|
|
271
|
+
const expectedRevision = typeof message.expectedRevision === 'string'
|
|
272
|
+
? message.expectedRevision
|
|
273
|
+
: '';
|
|
274
|
+
if (!/^[a-f0-9]{64}$/u.test(expectedRevision)) {
|
|
275
|
+
emit({ requestId, error: 'expectedRevision is required' });
|
|
276
|
+
return payloads;
|
|
277
|
+
}
|
|
278
|
+
const result = await ctx.setWorkroomCatalog(message.data, expectedRevision);
|
|
279
|
+
ctx.publishEvent?.('workrooms:updated', { revision: result.revision });
|
|
280
|
+
emit({ requestId, data: { success: true, ...result } });
|
|
281
|
+
} catch (error) {
|
|
282
|
+
emit({ requestId, error: error instanceof Error ? error.message : String(error) });
|
|
283
|
+
}
|
|
284
|
+
return payloads;
|
|
285
|
+
}
|
|
243
286
|
case 'config:save-yaml': {
|
|
244
287
|
try {
|
|
245
288
|
const yaml = message.yaml;
|
package/src/http-host.ts
CHANGED
|
@@ -17,6 +17,7 @@ import {
|
|
|
17
17
|
TokenRegistry,
|
|
18
18
|
extractBearerToken,
|
|
19
19
|
isDemoWebSocketPath,
|
|
20
|
+
type AuthenticatedTokenPrincipal,
|
|
20
21
|
type AuthScope,
|
|
21
22
|
type ScopedTokenConfig,
|
|
22
23
|
type TokenRegistryConfig,
|
|
@@ -73,6 +74,7 @@ export type HttpHandler = (
|
|
|
73
74
|
response: ServerResponse,
|
|
74
75
|
url: URL,
|
|
75
76
|
authScope: AuthScope,
|
|
77
|
+
authenticatedPrincipal?: AuthenticatedTokenPrincipal,
|
|
76
78
|
) => void | Promise<void>;
|
|
77
79
|
|
|
78
80
|
export interface HttpRouteRegistration {
|
|
@@ -323,7 +325,7 @@ export function createHttpHost(options: HttpHostOptions = {}): ProcessHttpHost {
|
|
|
323
325
|
return;
|
|
324
326
|
}
|
|
325
327
|
if (!release) {
|
|
326
|
-
await route.handler(request, response, url, auth.scope);
|
|
328
|
+
await route.handler(request, response, url, auth.scope, auth.principal);
|
|
327
329
|
return;
|
|
328
330
|
}
|
|
329
331
|
let released = false;
|
|
@@ -336,7 +338,7 @@ export function createHttpHost(options: HttpHostOptions = {}): ProcessHttpHost {
|
|
|
336
338
|
};
|
|
337
339
|
response.once('finish', releaseResponse);
|
|
338
340
|
response.once('close', releaseResponse);
|
|
339
|
-
await route.handler(request, response, url, auth.scope);
|
|
341
|
+
await route.handler(request, response, url, auth.scope, auth.principal);
|
|
340
342
|
if (response.writableFinished || response.destroyed) releaseResponse();
|
|
341
343
|
} catch (err) {
|
|
342
344
|
// 统一把请求体错误(400/413)映射回对应状态码,
|
|
@@ -363,7 +365,7 @@ export function createHttpHost(options: HttpHostOptions = {}): ProcessHttpHost {
|
|
|
363
365
|
request: IncomingMessage,
|
|
364
366
|
url: URL,
|
|
365
367
|
pathname: string,
|
|
366
|
-
): { ok: true; scope: AuthScope } | { ok: false } {
|
|
368
|
+
): { ok: true; scope: AuthScope; principal?: AuthenticatedTokenPrincipal } | { ok: false } {
|
|
367
369
|
if (!requiresHttpAuth(pathname, apiBase, authExempt)) {
|
|
368
370
|
return { ok: true, scope: 'full' };
|
|
369
371
|
}
|
|
@@ -380,7 +382,8 @@ export function createHttpHost(options: HttpHostOptions = {}): ProcessHttpHost {
|
|
|
380
382
|
const method = (request.method ?? 'GET').toUpperCase();
|
|
381
383
|
if (!isDemoHttpAllowed(method, pathname, apiBase)) return { ok: false };
|
|
382
384
|
}
|
|
383
|
-
|
|
385
|
+
const principal = tokenRegistry.resolvePrincipal(token);
|
|
386
|
+
return { ok: true, scope, ...(principal ? { principal } : {}) };
|
|
384
387
|
}
|
|
385
388
|
|
|
386
389
|
function listListedRoutes(): readonly ListedRoute[] {
|
package/src/index.ts
CHANGED
|
@@ -15,6 +15,7 @@ export {
|
|
|
15
15
|
TokenRegistry,
|
|
16
16
|
extractBearerToken,
|
|
17
17
|
isDemoWebSocketPath,
|
|
18
|
+
type AuthenticatedTokenPrincipal,
|
|
18
19
|
type AuthScope,
|
|
19
20
|
type ScopedTokenConfig,
|
|
20
21
|
type TokenRegistryConfig,
|
|
@@ -70,5 +71,7 @@ export {
|
|
|
70
71
|
dispatchExtendedConsoleRpc,
|
|
71
72
|
type ConsoleRpcExtendedCtx,
|
|
72
73
|
type ConsoleScheduleEngine,
|
|
74
|
+
type ConsoleWorkroomProfileControlPort,
|
|
75
|
+
type ConsoleWorkroomKnowledgeControlPort,
|
|
73
76
|
type ExtendedRpcResult,
|
|
74
77
|
} from './console-rpc-extended.js';
|
package/src/token-registry.ts
CHANGED
|
@@ -6,27 +6,55 @@ export type AuthScope = 'full' | 'demo';
|
|
|
6
6
|
export type ScopedTokenConfig = {
|
|
7
7
|
readonly token: string;
|
|
8
8
|
readonly scope: AuthScope;
|
|
9
|
+
/** Root-configured subject binding; never accepted from an HTTP payload. */
|
|
10
|
+
readonly principalId?: string;
|
|
9
11
|
};
|
|
10
12
|
|
|
13
|
+
export interface AuthenticatedTokenPrincipal {
|
|
14
|
+
readonly principalId: string;
|
|
15
|
+
readonly scope: AuthScope;
|
|
16
|
+
}
|
|
17
|
+
|
|
11
18
|
export type TokenRegistryConfig = {
|
|
12
19
|
readonly primaryToken?: string;
|
|
13
20
|
readonly scopedTokens?: readonly ScopedTokenConfig[];
|
|
14
21
|
};
|
|
15
22
|
|
|
16
23
|
export class TokenRegistry {
|
|
17
|
-
readonly #entries = new Map<string, AuthScope
|
|
24
|
+
readonly #entries = new Map<string, Readonly<{ scope: AuthScope; principalId?: string }>>();
|
|
18
25
|
|
|
19
26
|
constructor(config: TokenRegistryConfig = {}) {
|
|
20
|
-
if (config.primaryToken) this.#entries.set(config.primaryToken, 'full');
|
|
21
|
-
for (const { token, scope } of config.scopedTokens ?? []) {
|
|
22
|
-
if (token)
|
|
27
|
+
if (config.primaryToken) this.#entries.set(config.primaryToken, Object.freeze({ scope: 'full' }));
|
|
28
|
+
for (const { token, scope, principalId } of config.scopedTokens ?? []) {
|
|
29
|
+
if (!token) continue;
|
|
30
|
+
if (principalId !== undefined && (!principalId.trim() || principalId !== principalId.trim())) {
|
|
31
|
+
throw new Error('HTTP token principalId is invalid');
|
|
32
|
+
}
|
|
33
|
+
const candidate = Object.freeze({ scope, ...(principalId === undefined ? {} : { principalId }) });
|
|
34
|
+
const existing = this.#entries.get(token);
|
|
35
|
+
if (existing && (existing.scope !== candidate.scope || existing.principalId !== candidate.principalId)) {
|
|
36
|
+
throw new Error('HTTP token has conflicting authority bindings');
|
|
37
|
+
}
|
|
38
|
+
this.#entries.set(token, candidate);
|
|
23
39
|
}
|
|
24
40
|
}
|
|
25
41
|
|
|
26
42
|
resolve(token: string): AuthScope | null {
|
|
27
43
|
if (!token) return null;
|
|
28
|
-
for (const [known,
|
|
29
|
-
if (timingSafeEqualString(known, token)) return scope;
|
|
44
|
+
for (const [known, binding] of this.#entries) {
|
|
45
|
+
if (timingSafeEqualString(known, token)) return binding.scope;
|
|
46
|
+
}
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
resolvePrincipal(token: string): AuthenticatedTokenPrincipal | null {
|
|
51
|
+
if (!token) return null;
|
|
52
|
+
for (const [known, binding] of this.#entries) {
|
|
53
|
+
if (timingSafeEqualString(known, token)) {
|
|
54
|
+
return binding.principalId
|
|
55
|
+
? Object.freeze({ principalId: binding.principalId, scope: binding.scope })
|
|
56
|
+
: null;
|
|
57
|
+
}
|
|
30
58
|
}
|
|
31
59
|
return null;
|
|
32
60
|
}
|
|
@@ -36,8 +64,8 @@ export class TokenRegistry {
|
|
|
36
64
|
}
|
|
37
65
|
|
|
38
66
|
primaryTokenPrefixForLog(): string {
|
|
39
|
-
for (const [tok,
|
|
40
|
-
if (scope === 'full') return tok.slice(0, 6);
|
|
67
|
+
for (const [tok, binding] of this.#entries) {
|
|
68
|
+
if (binding.scope === 'full') return tok.slice(0, 6);
|
|
41
69
|
}
|
|
42
70
|
const first = this.#entries.keys().next().value;
|
|
43
71
|
return first ? first.slice(0, 6) : '';
|