@zhin.js/host-http 1.1.9 → 1.1.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.
@@ -66,6 +66,7 @@ export interface ConsoleAgentSessionTree {
66
66
  resolveActiveSessionId(sessionKey: string): Promise<string | null>;
67
67
  getActiveLeafMessageId(sessionId: string): Promise<number | null>;
68
68
  listBranchPoints(sessionId: string): Promise<readonly unknown[]>;
69
+ readBranchMessage?(sessionId: string, messageId: number): Promise<string | null>;
69
70
  switchActiveLeaf(sessionId: string, messageId: number): Promise<boolean>;
70
71
  jumpToBranchIndex(sessionId: string, index: number): Promise<{
71
72
  ok: boolean;
@@ -676,13 +676,22 @@ function registerAgentSessionRoutes(route, base, ctx) {
676
676
  error: 'Agent session tree runtime 未就绪(新 Runtime 需经 acquireAgentRuntime 装配 sessionTree)',
677
677
  });
678
678
  };
679
- // HttpHost 无 `:param` 路由,用前缀路由匹配 `<sessionKey>/tree|leaf`。
679
+ // HttpHost 无 `:param` 路由,用前缀路由匹配 `<sessionKey>/tree|message|leaf`。
680
680
  route('GET', `${base}/agent/sessions/*`, async (_request, response, url) => {
681
- const parsed = parseSessionAction(url.pathname, prefix, 'tree');
681
+ const messageAction = parseSessionAction(url.pathname, prefix, 'message');
682
+ if (messageAction)
683
+ response.setHeader('Cache-Control', 'no-store');
684
+ const parsed = messageAction ?? parseSessionAction(url.pathname, prefix, 'tree');
682
685
  if (!parsed) {
683
686
  writeJson(response, 404, { success: false, error: 'Not found' });
684
687
  return;
685
688
  }
689
+ const messageId = Number(url.searchParams.get('messageId'));
690
+ const expectedSessionId = url.searchParams.get('sessionId');
691
+ if (messageAction && (!Number.isSafeInteger(messageId) || messageId < 1 || !expectedSessionId)) {
692
+ writeJson(response, 400, { success: false, error: '需要 sessionId 和正安全整数 messageId' });
693
+ return;
694
+ }
686
695
  const lease = acquireSessionTree();
687
696
  if (!lease) {
688
697
  unavailable(response);
@@ -690,7 +699,28 @@ function registerAgentSessionRoutes(route, base, ctx) {
690
699
  }
691
700
  const runtime = lease.tree;
692
701
  try {
702
+ if (messageAction && !runtime.readBranchMessage) {
703
+ writeJson(response, 501, { success: false, error: '当前 Agent runtime 不支持读取完整消息' });
704
+ return;
705
+ }
693
706
  const sessionId = await runtime.resolveActiveSessionId(parsed.sessionKey);
707
+ if (messageAction) {
708
+ if (!sessionId) {
709
+ writeJson(response, 404, { success: false, error: '未找到活跃会话' });
710
+ return;
711
+ }
712
+ if (sessionId !== expectedSessionId) {
713
+ writeJson(response, 409, { success: false, error: '会话已变化,请重新读取会话列表' });
714
+ return;
715
+ }
716
+ const text = await runtime.readBranchMessage(sessionId, messageId);
717
+ if (text === null) {
718
+ writeJson(response, 404, { success: false, error: '未找到此会话的用户消息' });
719
+ return;
720
+ }
721
+ writeJson(response, 200, { success: true, data: { messageId, text } });
722
+ return;
723
+ }
694
724
  if (!sessionId) {
695
725
  if (!isRuntimeImSessionKey(parsed.sessionKey)) {
696
726
  writeJson(response, 404, {
@@ -738,7 +768,7 @@ function registerAgentSessionRoutes(route, base, ctx) {
738
768
  finally {
739
769
  lease.release();
740
770
  }
741
- }, { summary: 'Get agent session tree', tags: ['console', 'agent'] });
771
+ }, { summary: 'Get agent session tree or message', tags: ['console', 'agent'] });
742
772
  route('POST', `${base}/agent/sessions/*`, async (request, response, url, authScope) => {
743
773
  const parsed = parseSessionAction(url.pathname, prefix, 'leaf');
744
774
  if (!parsed) {
@@ -1,6 +1,6 @@
1
1
  import type { AuthScope } from './token-registry.js';
2
2
  import type { DatabaseHostConsole } from '@zhin.js/plugin-runtime';
3
- import { type ConsoleConfigSource, type ConsoleConfigExplanation, type ConsoleEndpointSummary } from '@zhin.js/console-protocol';
3
+ import { type ConsoleConfigPatchRequest, type ConsoleConfigPatchResult, type ConsoleConfigSource, type ConsoleConfigExplanation, type ConsoleEndpointSummary } from '@zhin.js/console-protocol';
4
4
  import { type ConsoleRpcExtendedCtx } from './console-rpc-extended/index.js';
5
5
  /**
6
6
  * Console RPC 请求结构:未锚定 endpoint 的会话地址(结构对齐
@@ -96,7 +96,9 @@ export interface PluginVerificationResult {
96
96
  readonly status: 'passed' | 'failed' | 'not-configured';
97
97
  readonly scope: 'local' | 'platform' | null;
98
98
  readonly summary: string;
99
+ readonly summaryI18n?: import('@zhin.js/plugin-runtime').PluginVerificationOutcome['summaryI18n'];
99
100
  readonly evidence: readonly string[];
101
+ readonly evidenceI18n?: import('@zhin.js/plugin-runtime').PluginVerificationOutcome['evidenceI18n'];
100
102
  }
101
103
  export interface PluginManagementPort {
102
104
  explainConfiguration?(instanceKey: string, expectedRevision: string): Promise<ConsoleConfigExplanation>;
@@ -161,6 +163,11 @@ export type RuntimeConsoleRpcContext = {
161
163
  setConfigKey?(pluginName: string, data: unknown): Promise<{
162
164
  restartRequired: boolean;
163
165
  }>;
166
+ /**
167
+ * Full-scope, path-allowlisted and revision-checked write (`config:patch`).
168
+ * Scope and schema checks belong to the implementation; scope violations throw.
169
+ */
170
+ patchConfig?(request: ConsoleConfigPatchRequest): Promise<ConsoleConfigPatchResult>;
164
171
  /** Persist a declared child Plugin lifecycle state. Takes effect after restart. */
165
172
  setPluginEnabled?(instanceKey: string, enabled: boolean): Promise<Readonly<{
166
173
  disabled: readonly string[];
@@ -212,6 +212,52 @@ export async function dispatchRuntimeConsoleRpc(message, ctx) {
212
212
  }
213
213
  return payloads;
214
214
  }
215
+ case CONFIG_RPC.PATCH: {
216
+ if (ctx.authScope !== 'full') {
217
+ emit({ requestId, error: 'Full scope required' });
218
+ return payloads;
219
+ }
220
+ const { instanceKey, expectedRevision, patches } = message;
221
+ if (instanceKey !== undefined && (typeof instanceKey !== 'string' || !instanceKey || instanceKey.length > 128)) {
222
+ emit({ requestId, error: 'instanceKey 必须是插件实例名,或省略以使用 Host 白名单' });
223
+ return payloads;
224
+ }
225
+ if (typeof expectedRevision !== 'string' || !/^[a-f0-9]{64}$/u.test(expectedRevision)) {
226
+ emit({ requestId, error: 'expectedRevision is required' });
227
+ return payloads;
228
+ }
229
+ if (!Array.isArray(patches)) {
230
+ emit({ requestId, error: 'patches 必须是数组' });
231
+ return payloads;
232
+ }
233
+ if (!ctx.patchConfig) {
234
+ emit({ requestId, error: 'Config patch is not configured' });
235
+ return payloads;
236
+ }
237
+ try {
238
+ const result = await ctx.patchConfig({
239
+ ...(instanceKey === undefined ? {} : { instanceKey }),
240
+ expectedRevision,
241
+ patches,
242
+ });
243
+ if (result.status === 'saved') {
244
+ ctx.publishEvent?.('config:updated', {
245
+ pluginName: instanceKey ?? null,
246
+ keys: [...new Set(result.paths.map(path => path[0]))],
247
+ paths: result.paths,
248
+ revision: result.revision,
249
+ });
250
+ }
251
+ emit({ requestId, data: result });
252
+ }
253
+ catch (error) {
254
+ emit({
255
+ requestId,
256
+ error: `Failed to patch config: ${error instanceof Error ? error.message : String(error)}`,
257
+ });
258
+ }
259
+ return payloads;
260
+ }
215
261
  case CONFIG_RPC.SET: {
216
262
  try {
217
263
  const pluginName = message.pluginName;
package/lib/index.d.ts CHANGED
@@ -1,6 +1,6 @@
1
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
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
+ export { TokenRegistry, extractBearerToken, isDemoWebSocketPath, publicConsoleAccess, unrestrictedConsoleAccess, type AuthenticatedTokenPrincipal, type ConsoleAccess, type AuthScope, type DynamicTokenConfig, type ScopedTokenConfig, type TokenRegistryConfig, } from './token-registry.js';
4
4
  export { timingSafeEqualString } from './timing-safe-equal.js';
5
5
  export { createConsoleEventHub, consoleEventHubToken, type ConsoleEventHub, } from './console-events.js';
6
6
  export { buildOpenApiDocument, patternToOpenApiPath, routeRequiresBearerAuth, type BuildOpenApiOptions, type ListedRoute, type OpenApiParameter, type RouteMeta, } from './openapi.js';
package/lib/index.js CHANGED
@@ -1,6 +1,6 @@
1
1
  export { createHttpHost, httpHostToken, REMOTE_CONSOLE_ORIGIN, } from './http-host.js';
2
2
  export { createHttpHostGroup, listHttpHostAddresses, } from './listener-group.js';
3
- export { TokenRegistry, extractBearerToken, isDemoWebSocketPath, } from './token-registry.js';
3
+ export { TokenRegistry, extractBearerToken, isDemoWebSocketPath, publicConsoleAccess, unrestrictedConsoleAccess, } from './token-registry.js';
4
4
  export { timingSafeEqualString } from './timing-safe-equal.js';
5
5
  export { createConsoleEventHub, consoleEventHubToken, } from './console-events.js';
6
6
  export { buildOpenApiDocument, patternToOpenApiPath, routeRequiresBearerAuth, } from './openapi.js';
@@ -2,10 +2,32 @@
2
2
  export type AuthScope = 'full' | 'demo';
3
3
  export type ScopedTokenConfig = {
4
4
  readonly token: string;
5
- readonly scope: AuthScope;
5
+ /**
6
+ * `full` is the operator. `user` is an alias of `demo`: an ordinary Console user who sees and
7
+ * calls only what its `permissions` / `roles` allow. Stored and reported as `demo`.
8
+ */
9
+ readonly scope: AuthScope | 'user';
6
10
  /** Root-configured subject binding; never accepted from an HTTP payload. */
7
11
  readonly principalId?: string;
12
+ /**
13
+ * Console permissions held by this token (matched against Page `requiredPermissions`).
14
+ * Ignored for `full` scope, which already holds every Console permission.
15
+ */
16
+ readonly permissions?: readonly string[];
17
+ /** Console roles held by this token (matched against Page `requiredRoles`). */
18
+ readonly roles?: readonly string[];
8
19
  };
20
+ /**
21
+ * What a Console caller may see. `unrestricted` is the operator (full scope, or a Host with no
22
+ * tokens configured); otherwise only the listed permissions and roles count.
23
+ */
24
+ export interface ConsoleAccess {
25
+ readonly unrestricted: boolean;
26
+ readonly permissions: readonly string[];
27
+ readonly roles: readonly string[];
28
+ }
29
+ export declare const publicConsoleAccess: ConsoleAccess;
30
+ export declare const unrestrictedConsoleAccess: ConsoleAccess;
9
31
  export interface AuthenticatedTokenPrincipal {
10
32
  readonly principalId: string;
11
33
  readonly scope: AuthScope;
@@ -23,6 +45,11 @@ export declare class TokenRegistry {
23
45
  constructor(config?: TokenRegistryConfig);
24
46
  resolve(token: string): AuthScope | null;
25
47
  resolvePrincipal(token: string): AuthenticatedTokenPrincipal | null;
48
+ /**
49
+ * Console access for a bearer token. With no tokens configured the Host is local and open,
50
+ * so every caller is the operator. An unknown or missing token gets public access only.
51
+ */
52
+ resolveAccess(token: string): ConsoleAccess;
26
53
  hasAnyToken(): boolean;
27
54
  /** Registers a runtime-issued credential and returns an idempotent revoker. */
28
55
  register(config: DynamicTokenConfig): () => void;
@@ -1,23 +1,37 @@
1
1
  import { timingSafeEqualString } from './timing-safe-equal.js';
2
+ export const publicConsoleAccess = Object.freeze({
3
+ unrestricted: false,
4
+ permissions: Object.freeze([]),
5
+ roles: Object.freeze([]),
6
+ });
7
+ export const unrestrictedConsoleAccess = Object.freeze({
8
+ unrestricted: true,
9
+ permissions: Object.freeze([]),
10
+ roles: Object.freeze([]),
11
+ });
2
12
  export class TokenRegistry {
3
13
  #entries = new Map();
4
14
  constructor(config = {}) {
5
15
  if (config.primaryToken) {
6
- this.#entries.set(config.primaryToken, Object.freeze({ scope: 'full', dynamic: false }));
16
+ this.#entries.set(config.primaryToken, Object.freeze({ scope: 'full', permissions: [], roles: [], dynamic: false }));
7
17
  }
8
- for (const { token, scope, principalId } of config.scopedTokens ?? []) {
18
+ for (const { token, scope, principalId, permissions, roles } of config.scopedTokens ?? []) {
9
19
  if (!token)
10
20
  continue;
11
21
  if (principalId !== undefined && (!principalId.trim() || principalId !== principalId.trim())) {
12
22
  throw new Error('HTTP token principalId is invalid');
13
23
  }
14
24
  const candidate = Object.freeze({
15
- scope,
25
+ scope: scope === 'full' ? 'full' : 'demo',
16
26
  ...(principalId === undefined ? {} : { principalId }),
27
+ permissions: accessList(permissions, 'permissions'),
28
+ roles: accessList(roles, 'roles'),
17
29
  dynamic: false,
18
30
  });
19
31
  const existing = this.#entries.get(token);
20
- if (existing && (existing.scope !== candidate.scope || existing.principalId !== candidate.principalId)) {
32
+ if (existing && (existing.scope !== candidate.scope || existing.principalId !== candidate.principalId
33
+ || existing.permissions.join('\n') !== candidate.permissions.join('\n')
34
+ || existing.roles.join('\n') !== candidate.roles.join('\n'))) {
21
35
  throw new Error('HTTP token has conflicting authority bindings');
22
36
  }
23
37
  this.#entries.set(token, candidate);
@@ -49,6 +63,26 @@ export class TokenRegistry {
49
63
  }
50
64
  return null;
51
65
  }
66
+ /**
67
+ * Console access for a bearer token. With no tokens configured the Host is local and open,
68
+ * so every caller is the operator. An unknown or missing token gets public access only.
69
+ */
70
+ resolveAccess(token) {
71
+ if (!this.hasAnyToken())
72
+ return unrestrictedConsoleAccess;
73
+ if (!token)
74
+ return publicConsoleAccess;
75
+ for (const [known, binding] of this.#entries) {
76
+ if (!timingSafeEqualString(known, token))
77
+ continue;
78
+ if (this.#expired(known, binding))
79
+ return publicConsoleAccess;
80
+ if (binding.scope === 'full')
81
+ return unrestrictedConsoleAccess;
82
+ return Object.freeze({ unrestricted: false, permissions: binding.permissions, roles: binding.roles });
83
+ }
84
+ return publicConsoleAccess;
85
+ }
52
86
  hasAnyToken() {
53
87
  for (const [token, binding] of this.#entries) {
54
88
  if (!this.#expired(token, binding))
@@ -69,8 +103,10 @@ export class TokenRegistry {
69
103
  throw new Error('Dynamic HTTP token expiresAt must be a future epoch millisecond');
70
104
  }
71
105
  const candidate = Object.freeze({
72
- scope: config.scope,
106
+ scope: config.scope === 'full' ? 'full' : 'demo',
73
107
  ...(config.principalId === undefined ? {} : { principalId: config.principalId }),
108
+ permissions: accessList(config.permissions, 'permissions'),
109
+ roles: accessList(config.roles, 'roles'),
74
110
  ...(config.expiresAt === undefined ? {} : { expiresAt: config.expiresAt }),
75
111
  dynamic: true,
76
112
  });
@@ -107,6 +143,14 @@ export class TokenRegistry {
107
143
  return true;
108
144
  }
109
145
  }
146
+ function accessList(value, label) {
147
+ if (value === undefined)
148
+ return Object.freeze([]);
149
+ if (!Array.isArray(value) || !value.every((item) => typeof item === 'string' && item.length > 0 && item.trim() === item)) {
150
+ throw new Error(`HTTP token ${label} must be non-empty trimmed strings`);
151
+ }
152
+ return Object.freeze([...new Set(value)].sort());
153
+ }
110
154
  /** WebSocket upgrade paths allowed for demo scope. */
111
155
  export function isDemoWebSocketPath(pathname) {
112
156
  return pathname === '/sandbox';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zhin.js/host-http",
3
- "version": "1.1.9",
3
+ "version": "1.1.11",
4
4
  "description": "Minimal HTTP and WebSocket host for Plugin Runtime (no Koa)",
5
5
  "type": "module",
6
6
  "main": "./lib/index.js",
@@ -17,11 +17,11 @@
17
17
  ],
18
18
  "dependencies": {
19
19
  "ws": "^8.21.1",
20
- "@zhin.js/console-protocol": "1.1.11",
20
+ "@zhin.js/console-protocol": "1.1.12",
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.3",
24
- "@zhin.js/plugin-runtime": "1.1.16"
24
+ "@zhin.js/plugin-runtime": "1.1.17"
25
25
  },
26
26
  "devDependencies": {
27
27
  "@types/node": "^26.1.2",
@@ -71,6 +71,7 @@ export interface ConsoleAgentSessionTree {
71
71
  resolveActiveSessionId(sessionKey: string): Promise<string | null>;
72
72
  getActiveLeafMessageId(sessionId: string): Promise<number | null>;
73
73
  listBranchPoints(sessionId: string): Promise<readonly unknown[]>;
74
+ readBranchMessage?(sessionId: string, messageId: number): Promise<string | null>;
74
75
  switchActiveLeaf(sessionId: string, messageId: number): Promise<boolean>;
75
76
  jumpToBranchIndex(
76
77
  sessionId: string,
@@ -872,13 +873,21 @@ function registerAgentSessionRoutes(
872
873
  });
873
874
  };
874
875
 
875
- // HttpHost 无 `:param` 路由,用前缀路由匹配 `<sessionKey>/tree|leaf`。
876
+ // HttpHost 无 `:param` 路由,用前缀路由匹配 `<sessionKey>/tree|message|leaf`。
876
877
  route('GET', `${base}/agent/sessions/*`, async (_request, response, url) => {
877
- const parsed = parseSessionAction(url.pathname, prefix, 'tree');
878
+ const messageAction = parseSessionAction(url.pathname, prefix, 'message');
879
+ if (messageAction) response.setHeader('Cache-Control', 'no-store');
880
+ const parsed = messageAction ?? parseSessionAction(url.pathname, prefix, 'tree');
878
881
  if (!parsed) {
879
882
  writeJson(response, 404, { success: false, error: 'Not found' });
880
883
  return;
881
884
  }
885
+ const messageId = Number(url.searchParams.get('messageId'));
886
+ const expectedSessionId = url.searchParams.get('sessionId');
887
+ if (messageAction && (!Number.isSafeInteger(messageId) || messageId < 1 || !expectedSessionId)) {
888
+ writeJson(response, 400, { success: false, error: '需要 sessionId 和正安全整数 messageId' });
889
+ return;
890
+ }
882
891
  const lease = acquireSessionTree();
883
892
  if (!lease) {
884
893
  unavailable(response);
@@ -886,7 +895,28 @@ function registerAgentSessionRoutes(
886
895
  }
887
896
  const runtime = lease.tree;
888
897
  try {
898
+ if (messageAction && !runtime.readBranchMessage) {
899
+ writeJson(response, 501, { success: false, error: '当前 Agent runtime 不支持读取完整消息' });
900
+ return;
901
+ }
889
902
  const sessionId = await runtime.resolveActiveSessionId(parsed.sessionKey);
903
+ if (messageAction) {
904
+ if (!sessionId) {
905
+ writeJson(response, 404, { success: false, error: '未找到活跃会话' });
906
+ return;
907
+ }
908
+ if (sessionId !== expectedSessionId) {
909
+ writeJson(response, 409, { success: false, error: '会话已变化,请重新读取会话列表' });
910
+ return;
911
+ }
912
+ const text = await runtime.readBranchMessage!(sessionId, messageId);
913
+ if (text === null) {
914
+ writeJson(response, 404, { success: false, error: '未找到此会话的用户消息' });
915
+ return;
916
+ }
917
+ writeJson(response, 200, { success: true, data: { messageId, text } });
918
+ return;
919
+ }
890
920
  if (!sessionId) {
891
921
  if (!isRuntimeImSessionKey(parsed.sessionKey)) {
892
922
  writeJson(response, 404, {
@@ -934,7 +964,7 @@ function registerAgentSessionRoutes(
934
964
  } finally {
935
965
  lease.release();
936
966
  }
937
- }, { summary: 'Get agent session tree', tags: ['console', 'agent'] });
967
+ }, { summary: 'Get agent session tree or message', tags: ['console', 'agent'] });
938
968
 
939
969
  route('POST', `${base}/agent/sessions/*`, async (request, response, url, authScope) => {
940
970
  const parsed = parseSessionAction(url.pathname, prefix, 'leaf');
@@ -1038,7 +1068,7 @@ function isRuntimeImSessionKey(value: string): boolean {
1038
1068
  function parseSessionAction(
1039
1069
  pathname: string,
1040
1070
  prefix: string,
1041
- action: 'tree' | 'leaf',
1071
+ action: 'tree' | 'message' | 'leaf',
1042
1072
  ): { sessionKey: string } | null {
1043
1073
  if (!pathname.startsWith(prefix)) return null;
1044
1074
  const rest = pathname.slice(prefix.length).replace(/\/+$/u, '');
@@ -3,6 +3,8 @@ import type { DatabaseHostConsole } from '@zhin.js/plugin-runtime';
3
3
  import {
4
4
  CONFIG_RPC,
5
5
  ENDPOINT_RPC,
6
+ type ConsoleConfigPatchRequest,
7
+ type ConsoleConfigPatchResult,
6
8
  type ConsoleConfigSource,
7
9
  type ConsoleConfigExplanation,
8
10
  type ConsoleEndpointSummary,
@@ -133,7 +135,9 @@ export interface PluginVerificationResult {
133
135
  readonly status: 'passed' | 'failed' | 'not-configured';
134
136
  readonly scope: 'local' | 'platform' | null;
135
137
  readonly summary: string;
138
+ readonly summaryI18n?: import('@zhin.js/plugin-runtime').PluginVerificationOutcome['summaryI18n'];
136
139
  readonly evidence: readonly string[];
140
+ readonly evidenceI18n?: import('@zhin.js/plugin-runtime').PluginVerificationOutcome['evidenceI18n'];
137
141
  }
138
142
 
139
143
  export interface PluginManagementPort {
@@ -194,6 +198,11 @@ export type RuntimeConsoleRpcContext = {
194
198
  * Returns whether a process restart is required.
195
199
  */
196
200
  setConfigKey?(pluginName: string, data: unknown): Promise<{ restartRequired: boolean }>;
201
+ /**
202
+ * Full-scope, path-allowlisted and revision-checked write (`config:patch`).
203
+ * Scope and schema checks belong to the implementation; scope violations throw.
204
+ */
205
+ patchConfig?(request: ConsoleConfigPatchRequest): Promise<ConsoleConfigPatchResult>;
197
206
  /** Persist a declared child Plugin lifecycle state. Takes effect after restart. */
198
207
  setPluginEnabled?(
199
208
  instanceKey: string,
@@ -454,6 +463,48 @@ export async function dispatchRuntimeConsoleRpc(
454
463
  }
455
464
  return payloads;
456
465
  }
466
+ case CONFIG_RPC.PATCH: {
467
+ if (ctx.authScope !== 'full') { emit({ requestId, error: 'Full scope required' }); return payloads; }
468
+ const { instanceKey, expectedRevision, patches } = message;
469
+ if (instanceKey !== undefined && (typeof instanceKey !== 'string' || !instanceKey || instanceKey.length > 128)) {
470
+ emit({ requestId, error: 'instanceKey 必须是插件实例名,或省略以使用 Host 白名单' });
471
+ return payloads;
472
+ }
473
+ if (typeof expectedRevision !== 'string' || !/^[a-f0-9]{64}$/u.test(expectedRevision)) {
474
+ emit({ requestId, error: 'expectedRevision is required' });
475
+ return payloads;
476
+ }
477
+ if (!Array.isArray(patches)) {
478
+ emit({ requestId, error: 'patches 必须是数组' });
479
+ return payloads;
480
+ }
481
+ if (!ctx.patchConfig) {
482
+ emit({ requestId, error: 'Config patch is not configured' });
483
+ return payloads;
484
+ }
485
+ try {
486
+ const result = await ctx.patchConfig({
487
+ ...(instanceKey === undefined ? {} : { instanceKey }),
488
+ expectedRevision,
489
+ patches,
490
+ });
491
+ if (result.status === 'saved') {
492
+ ctx.publishEvent?.('config:updated', {
493
+ pluginName: instanceKey ?? null,
494
+ keys: [...new Set(result.paths.map(path => path[0]))],
495
+ paths: result.paths,
496
+ revision: result.revision,
497
+ });
498
+ }
499
+ emit({ requestId, data: result });
500
+ } catch (error) {
501
+ emit({
502
+ requestId,
503
+ error: `Failed to patch config: ${error instanceof Error ? error.message : String(error)}`,
504
+ });
505
+ }
506
+ return payloads;
507
+ }
457
508
  case CONFIG_RPC.SET: {
458
509
  try {
459
510
  const pluginName = message.pluginName;
package/src/index.ts CHANGED
@@ -21,7 +21,10 @@ export {
21
21
  TokenRegistry,
22
22
  extractBearerToken,
23
23
  isDemoWebSocketPath,
24
+ publicConsoleAccess,
25
+ unrestrictedConsoleAccess,
24
26
  type AuthenticatedTokenPrincipal,
27
+ type ConsoleAccess,
25
28
  type AuthScope,
26
29
  type DynamicTokenConfig,
27
30
  type ScopedTokenConfig,
@@ -5,11 +5,44 @@ export type AuthScope = 'full' | 'demo';
5
5
 
6
6
  export type ScopedTokenConfig = {
7
7
  readonly token: string;
8
- readonly scope: AuthScope;
8
+ /**
9
+ * `full` is the operator. `user` is an alias of `demo`: an ordinary Console user who sees and
10
+ * calls only what its `permissions` / `roles` allow. Stored and reported as `demo`.
11
+ */
12
+ readonly scope: AuthScope | 'user';
9
13
  /** Root-configured subject binding; never accepted from an HTTP payload. */
10
14
  readonly principalId?: string;
15
+ /**
16
+ * Console permissions held by this token (matched against Page `requiredPermissions`).
17
+ * Ignored for `full` scope, which already holds every Console permission.
18
+ */
19
+ readonly permissions?: readonly string[];
20
+ /** Console roles held by this token (matched against Page `requiredRoles`). */
21
+ readonly roles?: readonly string[];
11
22
  };
12
23
 
24
+ /**
25
+ * What a Console caller may see. `unrestricted` is the operator (full scope, or a Host with no
26
+ * tokens configured); otherwise only the listed permissions and roles count.
27
+ */
28
+ export interface ConsoleAccess {
29
+ readonly unrestricted: boolean;
30
+ readonly permissions: readonly string[];
31
+ readonly roles: readonly string[];
32
+ }
33
+
34
+ export const publicConsoleAccess: ConsoleAccess = Object.freeze({
35
+ unrestricted: false,
36
+ permissions: Object.freeze([]) as readonly string[],
37
+ roles: Object.freeze([]) as readonly string[],
38
+ });
39
+
40
+ export const unrestrictedConsoleAccess: ConsoleAccess = Object.freeze({
41
+ unrestricted: true,
42
+ permissions: Object.freeze([]) as readonly string[],
43
+ roles: Object.freeze([]) as readonly string[],
44
+ });
45
+
13
46
  export interface AuthenticatedTokenPrincipal {
14
47
  readonly principalId: string;
15
48
  readonly scope: AuthScope;
@@ -28,6 +61,8 @@ export type DynamicTokenConfig = ScopedTokenConfig & {
28
61
  type TokenBinding = Readonly<{
29
62
  scope: AuthScope;
30
63
  principalId?: string;
64
+ permissions: readonly string[];
65
+ roles: readonly string[];
31
66
  expiresAt?: number;
32
67
  dynamic: boolean;
33
68
  }>;
@@ -37,20 +72,24 @@ export class TokenRegistry {
37
72
 
38
73
  constructor(config: TokenRegistryConfig = {}) {
39
74
  if (config.primaryToken) {
40
- this.#entries.set(config.primaryToken, Object.freeze({ scope: 'full', dynamic: false }));
75
+ this.#entries.set(config.primaryToken, Object.freeze({ scope: 'full', permissions: [], roles: [], dynamic: false }));
41
76
  }
42
- for (const { token, scope, principalId } of config.scopedTokens ?? []) {
77
+ for (const { token, scope, principalId, permissions, roles } of config.scopedTokens ?? []) {
43
78
  if (!token) continue;
44
79
  if (principalId !== undefined && (!principalId.trim() || principalId !== principalId.trim())) {
45
80
  throw new Error('HTTP token principalId is invalid');
46
81
  }
47
- const candidate = Object.freeze({
48
- scope,
82
+ const candidate: TokenBinding = Object.freeze({
83
+ scope: scope === 'full' ? 'full' : 'demo',
49
84
  ...(principalId === undefined ? {} : { principalId }),
85
+ permissions: accessList(permissions, 'permissions'),
86
+ roles: accessList(roles, 'roles'),
50
87
  dynamic: false,
51
88
  });
52
89
  const existing = this.#entries.get(token);
53
- if (existing && (existing.scope !== candidate.scope || existing.principalId !== candidate.principalId)) {
90
+ if (existing && (existing.scope !== candidate.scope || existing.principalId !== candidate.principalId
91
+ || existing.permissions.join('\n') !== candidate.permissions.join('\n')
92
+ || existing.roles.join('\n') !== candidate.roles.join('\n'))) {
54
93
  throw new Error('HTTP token has conflicting authority bindings');
55
94
  }
56
95
  this.#entries.set(token, candidate);
@@ -80,6 +119,22 @@ export class TokenRegistry {
80
119
  return null;
81
120
  }
82
121
 
122
+ /**
123
+ * Console access for a bearer token. With no tokens configured the Host is local and open,
124
+ * so every caller is the operator. An unknown or missing token gets public access only.
125
+ */
126
+ resolveAccess(token: string): ConsoleAccess {
127
+ if (!this.hasAnyToken()) return unrestrictedConsoleAccess;
128
+ if (!token) return publicConsoleAccess;
129
+ for (const [known, binding] of this.#entries) {
130
+ if (!timingSafeEqualString(known, token)) continue;
131
+ if (this.#expired(known, binding)) return publicConsoleAccess;
132
+ if (binding.scope === 'full') return unrestrictedConsoleAccess;
133
+ return Object.freeze({ unrestricted: false, permissions: binding.permissions, roles: binding.roles });
134
+ }
135
+ return publicConsoleAccess;
136
+ }
137
+
83
138
  hasAnyToken(): boolean {
84
139
  for (const [token, binding] of this.#entries) {
85
140
  if (!this.#expired(token, binding)) return true;
@@ -99,8 +154,10 @@ export class TokenRegistry {
99
154
  throw new Error('Dynamic HTTP token expiresAt must be a future epoch millisecond');
100
155
  }
101
156
  const candidate: TokenBinding = Object.freeze({
102
- scope: config.scope,
157
+ scope: config.scope === 'full' ? 'full' : 'demo',
103
158
  ...(config.principalId === undefined ? {} : { principalId: config.principalId }),
159
+ permissions: accessList(config.permissions, 'permissions'),
160
+ roles: accessList(config.roles, 'roles'),
104
161
  ...(config.expiresAt === undefined ? {} : { expiresAt: config.expiresAt }),
105
162
  dynamic: true,
106
163
  });
@@ -135,6 +192,14 @@ export class TokenRegistry {
135
192
  }
136
193
  }
137
194
 
195
+ function accessList(value: readonly string[] | undefined, label: string): readonly string[] {
196
+ if (value === undefined) return Object.freeze([]);
197
+ if (!Array.isArray(value) || !value.every((item) => typeof item === 'string' && item.length > 0 && item.trim() === item)) {
198
+ throw new Error(`HTTP token ${label} must be non-empty trimmed strings`);
199
+ }
200
+ return Object.freeze([...new Set(value)].sort());
201
+ }
202
+
138
203
  /** WebSocket upgrade paths allowed for demo scope. */
139
204
  export function isDemoWebSocketPath(pathname: string): boolean {
140
205
  return pathname === '/sandbox';