@zhin.js/host-http 1.1.8 → 1.1.10

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.
@@ -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.8",
3
+ "version": "1.1.10",
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.15"
24
+ "@zhin.js/plugin-runtime": "1.1.17"
25
25
  },
26
26
  "devDependencies": {
27
27
  "@types/node": "^26.1.2",
@@ -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';