@zhin.js/gateway 1.3.0 → 1.4.0
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/config/configuration.d.ts +16 -3
- package/lib/config/configuration.js +88 -18
- package/lib/config/literal-secrets.d.ts +3 -0
- package/lib/config/literal-secrets.js +4 -1
- package/lib/config/patch-path-messages.d.ts +15 -0
- package/lib/config/patch-path-messages.js +71 -0
- package/lib/config/secret-storage.d.ts +42 -0
- package/lib/config/secret-storage.js +140 -0
- package/lib/config/validation-messages.d.ts +2 -0
- package/lib/config/validation-messages.js +7 -0
- package/lib/config.d.ts +1 -0
- package/lib/config.js +1 -0
- package/lib/database/console-role.js +2 -1
- package/lib/http/gateway-console.d.ts +5 -0
- package/lib/http/gateway-console.js +25 -0
- package/lib/http/gateway-http-server.d.ts +2 -0
- package/lib/http/gateway-http-server.js +3 -0
- package/lib/http/local-principal.d.ts +1 -1
- package/lib/http/local-principal.js +3 -2
- package/lib/http/plugin-switch-rpc.js +1 -1
- package/lib/http/start-gateway.d.ts +1 -1
- package/lib/http/start-gateway.js +2 -0
- package/lib/iam/service.js +12 -0
- package/lib/iam/setup-service.d.ts +3 -1
- package/lib/iam/setup-service.js +2 -1
- package/lib/onboarding/ignored-builds.d.ts +18 -0
- package/lib/onboarding/ignored-builds.js +63 -0
- package/lib/onboarding/index.d.ts +1 -0
- package/lib/onboarding/index.js +1 -0
- package/lib/packages/service.js +52 -5
- package/lib/plugin-management/plugin-candidates.d.ts +9 -0
- package/lib/plugin-management/plugin-candidates.js +55 -0
- package/lib/plugin-management/workflow.d.ts +5 -0
- package/lib/plugin-management/workflow.js +18 -3
- package/lib/plugin-management.d.ts +1 -0
- package/lib/plugin-management.js +1 -0
- package/lib/project/identity.d.ts +2 -0
- package/lib/project/identity.js +21 -0
- package/lib/project.d.ts +1 -0
- package/lib/project.js +1 -0
- package/package.json +7 -7
|
@@ -20,6 +20,14 @@ export declare class ConfigChatRequestRejectedError extends Error {
|
|
|
20
20
|
constructor();
|
|
21
21
|
}
|
|
22
22
|
/** Owns one project's Console configuration projection over the Root config authority. */
|
|
23
|
+
/**
|
|
24
|
+
* What the caller of a configuration write may do besides `ops.config.write`. Set by the Console
|
|
25
|
+
* RPC context from the principal's grants, and by trusted in-process / local-owner callers.
|
|
26
|
+
*/
|
|
27
|
+
export interface ConfigWriteAuthority {
|
|
28
|
+
/** The caller may write `.env` (`ops.env.write`): secret literals may be moved there. Default false. */
|
|
29
|
+
readonly environmentWrite?: boolean;
|
|
30
|
+
}
|
|
23
31
|
export declare class ConsoleConfigurationStore {
|
|
24
32
|
#private;
|
|
25
33
|
constructor(options: {
|
|
@@ -43,7 +51,12 @@ export declare class ConsoleConfigurationStore {
|
|
|
43
51
|
readonly revision: string;
|
|
44
52
|
readonly restartRequired: boolean;
|
|
45
53
|
} & ConsoleConfigApplicationStatus>;
|
|
46
|
-
|
|
54
|
+
/**
|
|
55
|
+
* `options.environmentWrite`: the caller holds `ops.env.write` (or is a trusted in-process / local
|
|
56
|
+
* owner caller). Without it a block whose secret fields hold literals is refused
|
|
57
|
+
* ({@link SecretEnvironmentWriteRequiredError}): those values would be written into `.env`.
|
|
58
|
+
*/
|
|
59
|
+
setKey(pluginName: string, data: unknown, options?: ConfigWriteAuthority): Promise<{
|
|
47
60
|
restartRequired: boolean;
|
|
48
61
|
} & ConsoleConfigApplicationStatus>;
|
|
49
62
|
removeKey(pluginName: string): Promise<{
|
|
@@ -54,14 +67,14 @@ export declare class ConsoleConfigurationStore {
|
|
|
54
67
|
* allowlist are rejected before reading values; a stale revision returns the latest
|
|
55
68
|
* values instead of overwriting; every touched section is validated by its schema.
|
|
56
69
|
*/
|
|
57
|
-
patch(request: ConsoleConfigPatchRequest): Promise<ConsoleConfigPatchResult>;
|
|
70
|
+
patch(request: ConsoleConfigPatchRequest, options?: ConfigWriteAuthority): Promise<ConsoleConfigPatchResult>;
|
|
58
71
|
/**
|
|
59
72
|
* Trusted project administration (`config:patch-project`, full scope only). Same
|
|
60
73
|
* single-CAS core as {@link patch}, without the page allowlist; it does not widen
|
|
61
74
|
* `config:patch` / `HOST_CONFIG_PATCH_PATHS`. Declaration writes are rejected as
|
|
62
75
|
* `invalid`, conflict values are masked, and a thrown commit is `unconfirmed`.
|
|
63
76
|
*/
|
|
64
|
-
patchProject(request: Omit<ConsoleConfigPatchRequest, 'instanceKey'
|
|
77
|
+
patchProject(request: Omit<ConsoleConfigPatchRequest, 'instanceKey'>, options?: ConfigWriteAuthority): Promise<ConsoleConfigProjectPatchResult>;
|
|
65
78
|
/** Host-authenticated owner chat. Policy checks share the mutation's CAS boundary. */
|
|
66
79
|
patchChat(request: Omit<ConsoleConfigPatchRequest, 'instanceKey'>): Promise<ConsoleConfigPatchResult>;
|
|
67
80
|
/**
|
|
@@ -7,7 +7,9 @@ import { readPluginPackageMap } from './plugin-package-map.js';
|
|
|
7
7
|
import { assertUnambiguousConfigKey, configKeyPatch, configKeyRemovePatch, flattenConfigDocument } from './configuration-projection.js';
|
|
8
8
|
import { readEnvFile, readEnvFileView, replaceEnvFileContent, revealEnvVariable, updateEnvVariables, writeEnvFile, } from './environment-files.js';
|
|
9
9
|
import { PluginSchemaCatalog } from './plugin-schema-catalog.js';
|
|
10
|
+
import { declarationKeyMessage, describeArrayPathProblem, unknownConfigKeyMessage } from './patch-path-messages.js';
|
|
10
11
|
import { findProjectLiteralSecrets } from './literal-secrets.js';
|
|
12
|
+
import { planSecretExternalization, SECRET_ENVIRONMENT_WRITE_REQUIRED, SecretEnvironmentWriteRequiredError, secretEnvironmentWriteRequiredMessage, writeSecretAssignments, } from './secret-storage.js';
|
|
11
13
|
import { hostModelOwnerId } from '@zhin.js/plugin-runtime';
|
|
12
14
|
import { assertChatConfigurationAccess, assertChatConfigurationScope, assertChatValueHasNoEnvironmentReference, assertChatOptionalLeaf, chatConfigurationLeaves, chatHostRoots, chatLeafTypes, ConfigChatAccessError, } from './configuration-chat-policy.js';
|
|
13
15
|
import { isForbiddenConfigurationPathSegment } from './configuration-path.js';
|
|
@@ -49,7 +51,6 @@ function chatApplication(application) {
|
|
|
49
51
|
dependsOn: Object.freeze([]),
|
|
50
52
|
}))) });
|
|
51
53
|
}
|
|
52
|
-
/** Owns one project's Console configuration projection over the Root config authority. */
|
|
53
54
|
export class ConsoleConfigurationStore {
|
|
54
55
|
#projectRoot;
|
|
55
56
|
#document;
|
|
@@ -121,16 +122,36 @@ export class ConsoleConfigurationStore {
|
|
|
121
122
|
});
|
|
122
123
|
});
|
|
123
124
|
}
|
|
124
|
-
|
|
125
|
+
/**
|
|
126
|
+
* `options.environmentWrite`: the caller holds `ops.env.write` (or is a trusted in-process / local
|
|
127
|
+
* owner caller). Without it a block whose secret fields hold literals is refused
|
|
128
|
+
* ({@link SecretEnvironmentWriteRequiredError}): those values would be written into `.env`.
|
|
129
|
+
*/
|
|
130
|
+
setKey(pluginName, data, options = {}) {
|
|
125
131
|
return this.#serialize(async () => {
|
|
126
132
|
const current = await this.#document.read();
|
|
127
133
|
await this.#assertUnambiguousKey(current.document, pluginName);
|
|
128
|
-
const
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
+
const patches = [configKeyPatch(current.document, pluginName, data)];
|
|
135
|
+
// Literal values of secret fields go to `.env`; the block keeps `${VAR}` references (#1250).
|
|
136
|
+
const plan = await planSecretExternalization(this.#projectRoot, this.#environmentName(), current.document, applyConfigPatches(current.document, patches), patches.map(patch => patch.path));
|
|
137
|
+
if (plan.assignments.length > 0 && options.environmentWrite !== true) {
|
|
138
|
+
throw new SecretEnvironmentWriteRequiredError(plan.assignments.map(item => item.path));
|
|
139
|
+
}
|
|
140
|
+
if (plan.errors.length > 0)
|
|
141
|
+
throw new Error(plan.errors.map(error => error.message).join(';'));
|
|
142
|
+
patches.push(...plan.patches);
|
|
143
|
+
const restoreEnvironment = await writeSecretAssignments(this.#projectRoot, this.#environmentName(), plan.assignments);
|
|
144
|
+
let committed;
|
|
145
|
+
try {
|
|
146
|
+
const prepared = await this.#document.prepare(current, patches);
|
|
147
|
+
readApplicationPluginDeclaration(prepared.document);
|
|
148
|
+
await this.#validatePrepared(prepared);
|
|
149
|
+
committed = await prepared.commit();
|
|
150
|
+
}
|
|
151
|
+
catch (error) {
|
|
152
|
+
await restoreEnvironment();
|
|
153
|
+
throw error;
|
|
154
|
+
}
|
|
134
155
|
return this.#applySaved(HOST_CONFIG_KEYS.includes(pluginName), committed.revision);
|
|
135
156
|
});
|
|
136
157
|
}
|
|
@@ -152,8 +173,8 @@ export class ConsoleConfigurationStore {
|
|
|
152
173
|
* allowlist are rejected before reading values; a stale revision returns the latest
|
|
153
174
|
* values instead of overwriting; every touched section is validated by its schema.
|
|
154
175
|
*/
|
|
155
|
-
patch(request) {
|
|
156
|
-
return this.#serialize(() => this.#patchCore(request, 'scoped'));
|
|
176
|
+
patch(request, options = {}) {
|
|
177
|
+
return this.#serialize(() => this.#patchCore(request, 'scoped', options));
|
|
157
178
|
}
|
|
158
179
|
/**
|
|
159
180
|
* Trusted project administration (`config:patch-project`, full scope only). Same
|
|
@@ -161,10 +182,11 @@ export class ConsoleConfigurationStore {
|
|
|
161
182
|
* `config:patch` / `HOST_CONFIG_PATCH_PATHS`. Declaration writes are rejected as
|
|
162
183
|
* `invalid`, conflict values are masked, and a thrown commit is `unconfirmed`.
|
|
163
184
|
*/
|
|
164
|
-
patchProject(request) {
|
|
185
|
+
patchProject(request, options = {}) {
|
|
165
186
|
return this.#serialize(async () => {
|
|
166
187
|
try {
|
|
167
|
-
return await this.#patchCore({ expectedRevision: request.expectedRevision, patches: request.patches
|
|
188
|
+
return await this.#patchCore({ expectedRevision: request.expectedRevision, patches: request.patches,
|
|
189
|
+
...(request.secretStorage === undefined ? {} : { secretStorage: request.secretStorage }) }, 'project', options);
|
|
168
190
|
}
|
|
169
191
|
catch (error) {
|
|
170
192
|
if (!(error instanceof ConfigCommitUnconfirmedError))
|
|
@@ -196,11 +218,13 @@ export class ConsoleConfigurationStore {
|
|
|
196
218
|
});
|
|
197
219
|
}
|
|
198
220
|
/** Shared single-CAS write: `scoped` keeps the page allowlist; `project` is the trusted full path. */
|
|
199
|
-
async #patchCore(request, mode) {
|
|
221
|
+
async #patchCore(request, mode, authority = {}) {
|
|
200
222
|
let phase = 'prepare';
|
|
223
|
+
// Restores `.env` when secrets were moved there but the config write does not happen.
|
|
224
|
+
let restoreEnvironment;
|
|
201
225
|
try {
|
|
202
226
|
const instanceKey = request.instanceKey;
|
|
203
|
-
|
|
227
|
+
let patches = normalizeConfigPatchOperations(request.patches);
|
|
204
228
|
const current = await this.#document.read();
|
|
205
229
|
if (mode === 'scoped') {
|
|
206
230
|
const declared = instanceKey === undefined
|
|
@@ -213,8 +237,9 @@ export class ConsoleConfigurationStore {
|
|
|
213
237
|
if (mode !== 'scoped') {
|
|
214
238
|
const rejected = projectDeclarationErrors(current.document, paths);
|
|
215
239
|
if (rejected.length > 0) {
|
|
240
|
+
const declarationWrite = paths.some(path => PROJECT_PATCH_DECLARATION_KEYS.has(path[0]));
|
|
216
241
|
return Object.freeze({ status: 'invalid', revision: current.revision, errors: Object.freeze(rejected),
|
|
217
|
-
missingEnv: Object.freeze([]), message: '插件声明不能通过项目补丁修改,未保存' });
|
|
242
|
+
missingEnv: Object.freeze([]), message: declarationWrite ? '插件声明不能通过项目补丁修改,未保存' : '没有这个配置项,未保存' });
|
|
218
243
|
}
|
|
219
244
|
}
|
|
220
245
|
const conflict = async (snapshot) => Object.freeze({
|
|
@@ -226,7 +251,44 @@ export class ConsoleConfigurationStore {
|
|
|
226
251
|
});
|
|
227
252
|
if (current.revision !== request.expectedRevision)
|
|
228
253
|
return await conflict(current);
|
|
229
|
-
|
|
254
|
+
// Array indexes outside the array (or a key where an index belongs) are a usage problem:
|
|
255
|
+
// answer with an explanation instead of the document layer's internal exception.
|
|
256
|
+
const arrayProblems = patches.flatMap(patch => {
|
|
257
|
+
const message = describeArrayPathProblem(current.document, patch.path, patch.op);
|
|
258
|
+
return message ? [{ path: `$.${patch.path.join('.')}`, message }] : [];
|
|
259
|
+
});
|
|
260
|
+
if (arrayProblems.length > 0) {
|
|
261
|
+
return Object.freeze({ status: 'invalid', revision: current.revision, errors: Object.freeze(arrayProblems),
|
|
262
|
+
missingEnv: Object.freeze([]), message: '配置路径无效,未保存' });
|
|
263
|
+
}
|
|
264
|
+
let candidate = applyConfigPatches(current.document, patches);
|
|
265
|
+
// Secret fields keep `${VAR}` references: a literal written here goes to `.env` (#1250),
|
|
266
|
+
// unless the caller explicitly asked for plain storage. Chat writes never carry literals
|
|
267
|
+
// into secret fields (they are refused by the chat policy).
|
|
268
|
+
let envSecrets = [];
|
|
269
|
+
let secretAssignments = [];
|
|
270
|
+
if (mode !== 'chat' && request.secretStorage !== 'plain') {
|
|
271
|
+
const plan = await planSecretExternalization(this.#projectRoot, this.#environmentName(), current.document, candidate, patches.filter(patch => patch.op === 'set').map(patch => patch.path));
|
|
272
|
+
if (plan.errors.length > 0) {
|
|
273
|
+
return Object.freeze({ status: 'invalid', revision: current.revision, errors: Object.freeze(plan.errors),
|
|
274
|
+
missingEnv: Object.freeze([]), message: '配置校验失败,未保存' });
|
|
275
|
+
}
|
|
276
|
+
// Moving a value into `.env` is an `.env` write: it needs `ops.env.write` (#1250). Without
|
|
277
|
+
// it the write is refused, never stored as plain text behind the caller's back.
|
|
278
|
+
if (plan.assignments.length > 0 && authority.environmentWrite !== true) {
|
|
279
|
+
const paths = plan.assignments.map(item => item.path);
|
|
280
|
+
return Object.freeze({ status: 'invalid', revision: current.revision, code: SECRET_ENVIRONMENT_WRITE_REQUIRED,
|
|
281
|
+
errors: Object.freeze(paths.map(path => Object.freeze({ path: `$.${path.join('.')}`,
|
|
282
|
+
message: '敏感字段的值要写入 .env,需要 ops.env.write 权限' }))),
|
|
283
|
+
missingEnv: Object.freeze([]), message: secretEnvironmentWriteRequiredMessage(paths) });
|
|
284
|
+
}
|
|
285
|
+
if (plan.patches.length > 0) {
|
|
286
|
+
patches = Object.freeze([...patches, ...plan.patches]);
|
|
287
|
+
candidate = applyConfigPatches(current.document, patches);
|
|
288
|
+
secretAssignments = plan.assignments;
|
|
289
|
+
envSecrets = Object.freeze(plan.assignments.map(item => Object.freeze({ path: item.path, variable: item.variable })));
|
|
290
|
+
}
|
|
291
|
+
}
|
|
230
292
|
// Chat writes exactly one leaf (C1/C4); scope and leaf shape are proven before values.
|
|
231
293
|
if (mode === 'chat' && patches.length !== 1)
|
|
232
294
|
throw new ConfigChatAccessError('leaf');
|
|
@@ -267,6 +329,8 @@ export class ConsoleConfigurationStore {
|
|
|
267
329
|
for (const name of validation.missingEnv)
|
|
268
330
|
missingEnv.add(name);
|
|
269
331
|
}
|
|
332
|
+
for (const { variable } of envSecrets)
|
|
333
|
+
missingEnv.delete(variable);
|
|
270
334
|
if (errors.length > 0) {
|
|
271
335
|
return Object.freeze({
|
|
272
336
|
status: 'invalid',
|
|
@@ -277,6 +341,7 @@ export class ConsoleConfigurationStore {
|
|
|
277
341
|
});
|
|
278
342
|
}
|
|
279
343
|
let committed;
|
|
344
|
+
restoreEnvironment = await writeSecretAssignments(this.#projectRoot, this.#environmentName(), secretAssignments);
|
|
280
345
|
try {
|
|
281
346
|
const prepared = await this.#document.prepare(current, patches);
|
|
282
347
|
readApplicationPluginDeclaration(prepared.document);
|
|
@@ -289,6 +354,7 @@ export class ConsoleConfigurationStore {
|
|
|
289
354
|
// Runtime validation may report cross-field constraints without a
|
|
290
355
|
// structured path. Preserve the invalid contract at the document
|
|
291
356
|
// boundary; arbitrary issue strings can contain private values.
|
|
357
|
+
await restoreEnvironment();
|
|
292
358
|
return Object.freeze({ status: 'invalid', revision: current.revision,
|
|
293
359
|
errors: Object.freeze([Object.freeze({ path: '$', message: '完整配置未通过运行校验' })]),
|
|
294
360
|
missingEnv: Object.freeze([...missingEnv].sort()), message: '配置校验失败,未保存' });
|
|
@@ -310,6 +376,8 @@ export class ConsoleConfigurationStore {
|
|
|
310
376
|
}
|
|
311
377
|
}
|
|
312
378
|
catch (error) {
|
|
379
|
+
if (phase === 'prepare')
|
|
380
|
+
await restoreEnvironment();
|
|
313
381
|
if (error instanceof ConfigDocumentConflictError)
|
|
314
382
|
return await conflict(await this.#document.read());
|
|
315
383
|
throw error;
|
|
@@ -322,6 +390,7 @@ export class ConsoleConfigurationStore {
|
|
|
322
390
|
...applied,
|
|
323
391
|
paths,
|
|
324
392
|
missingEnv: Object.freeze([...missingEnv].sort()),
|
|
393
|
+
...(envSecrets.length > 0 ? { envSecrets } : {}),
|
|
325
394
|
message: applied.applicationError === 'superseded' ? '配置已保存,但该次生效申请已被替代'
|
|
326
395
|
: applied.applicationError === 'invalid' ? '配置已保存,但配置校验失败,未生效'
|
|
327
396
|
: applied.applicationError ? '配置已保存,生效结果未确认' : applied.restartRequired
|
|
@@ -688,10 +757,11 @@ function projectDeclarationErrors(document, paths) {
|
|
|
688
757
|
const errors = [];
|
|
689
758
|
for (const section of new Set(paths.map(path => path[0]))) {
|
|
690
759
|
if (PROJECT_PATCH_DECLARATION_KEYS.has(section)) {
|
|
691
|
-
errors.push({ path: `$.${section}`, message:
|
|
760
|
+
errors.push({ path: `$.${section}`, message: declarationKeyMessage(section) });
|
|
692
761
|
}
|
|
693
762
|
else if (!HOST_CONFIG_KEYS.includes(section) && !declared.has(section)) {
|
|
694
|
-
|
|
763
|
+
const known = [...HOST_CONFIG_KEYS, ...declared].filter(key => !PROJECT_PATCH_DECLARATION_KEYS.has(key));
|
|
764
|
+
errors.push({ path: `$.${section}`, message: unknownConfigKeyMessage(section, known) });
|
|
695
765
|
}
|
|
696
766
|
}
|
|
697
767
|
return errors;
|
|
@@ -32,3 +32,6 @@ export interface LiteralSecretFixResult {
|
|
|
32
32
|
* variables already written, so the operation is idempotent.
|
|
33
33
|
*/
|
|
34
34
|
export declare function moveLiteralSecretsToEnv(projectRoot: string, configFile: string): Promise<LiteralSecretFixResult>;
|
|
35
|
+
/** A `NAME=value` dotenv line that parses back to exactly `value`, or undefined when none does. */
|
|
36
|
+
export declare function dotenvLine(name: string, value: string): string | undefined;
|
|
37
|
+
export declare function readDocumentPath(document: unknown, path: readonly string[]): unknown;
|
|
@@ -100,7 +100,7 @@ export async function moveLiteralSecretsToEnv(projectRoot, configFile) {
|
|
|
100
100
|
return Object.freeze({ moved: Object.freeze(moved), skipped: Object.freeze(skipped) });
|
|
101
101
|
}
|
|
102
102
|
/** A `NAME=value` dotenv line that parses back to exactly `value`, or undefined when none does. */
|
|
103
|
-
function dotenvLine(name, value) {
|
|
103
|
+
export function dotenvLine(name, value) {
|
|
104
104
|
if (/[\r\n]/u.test(value))
|
|
105
105
|
return undefined;
|
|
106
106
|
for (const quote of ["'", '`', '"']) {
|
|
@@ -112,6 +112,9 @@ function dotenvLine(name, value) {
|
|
|
112
112
|
}
|
|
113
113
|
return undefined;
|
|
114
114
|
}
|
|
115
|
+
export function readDocumentPath(document, path) {
|
|
116
|
+
return readPath(document, path);
|
|
117
|
+
}
|
|
115
118
|
function readPath(document, path) {
|
|
116
119
|
let current = document;
|
|
117
120
|
for (const segment of path) {
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Chinese explanations for project config patches that cannot apply (#1250): unknown top-level
|
|
3
|
+
* keys (with close candidates), declaration keys, and array indexes outside the array. Shown by
|
|
4
|
+
* `zhin config set|unset` and the Console; they never name RPCs or revisions.
|
|
5
|
+
*/
|
|
6
|
+
import type { RuntimeConfigDocument } from '@zhin.js/plugin-runtime';
|
|
7
|
+
/** Up to three known keys close to `key` (edit distance, prefix or substring). */
|
|
8
|
+
export declare function closeConfigKeys(key: string, known: Iterable<string>): string[];
|
|
9
|
+
export declare function unknownConfigKeyMessage(section: string, known: Iterable<string>): string;
|
|
10
|
+
export declare function declarationKeyMessage(section: string): string;
|
|
11
|
+
/**
|
|
12
|
+
* Why `path` cannot be written in `document` because of an array segment, or undefined. `set`
|
|
13
|
+
* cannot append: an index equal to the length is out of range too.
|
|
14
|
+
*/
|
|
15
|
+
export declare function describeArrayPathProblem(document: RuntimeConfigDocument, path: readonly string[], op: 'set' | 'remove'): string | undefined;
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
function displayPath(path) {
|
|
2
|
+
return path.join('.');
|
|
3
|
+
}
|
|
4
|
+
/** Damerau-free Levenshtein distance, enough for "did you mean" suggestions. */
|
|
5
|
+
function distance(a, b) {
|
|
6
|
+
const row = Array.from({ length: b.length + 1 }, (_, index) => index);
|
|
7
|
+
for (let i = 1; i <= a.length; i++) {
|
|
8
|
+
let previous = row[0];
|
|
9
|
+
row[0] = i;
|
|
10
|
+
for (let j = 1; j <= b.length; j++) {
|
|
11
|
+
const current = row[j];
|
|
12
|
+
row[j] = Math.min(row[j] + 1, row[j - 1] + 1, previous + (a[i - 1] === b[j - 1] ? 0 : 1));
|
|
13
|
+
previous = current;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
return row[b.length];
|
|
17
|
+
}
|
|
18
|
+
/** Up to three known keys close to `key` (edit distance, prefix or substring). */
|
|
19
|
+
export function closeConfigKeys(key, known) {
|
|
20
|
+
const lower = key.toLowerCase();
|
|
21
|
+
return [...new Set(known)]
|
|
22
|
+
.map(candidate => ({ candidate, score: distance(lower, candidate.toLowerCase()) }))
|
|
23
|
+
.filter(({ candidate, score }) => score <= Math.max(2, Math.floor(key.length / 3))
|
|
24
|
+
|| candidate.toLowerCase().startsWith(lower) || lower.startsWith(candidate.toLowerCase())
|
|
25
|
+
|| (lower.length >= 3 && candidate.toLowerCase().includes(lower)))
|
|
26
|
+
.sort((left, right) => left.score - right.score || left.candidate.localeCompare(right.candidate))
|
|
27
|
+
.slice(0, 3)
|
|
28
|
+
.map(({ candidate }) => candidate);
|
|
29
|
+
}
|
|
30
|
+
export function unknownConfigKeyMessage(section, known) {
|
|
31
|
+
const close = closeConfigKeys(section, known);
|
|
32
|
+
return `没有这个配置项:${section}(不是 Host 配置,也不是已声明的插件实例)`
|
|
33
|
+
+ (close.length > 0 ? `。你是不是想写:${close.join('、')}?` : '。')
|
|
34
|
+
+ '要给新插件写配置,先在 Console「插件」页或用 zhin install <包名> 安装并声明它';
|
|
35
|
+
}
|
|
36
|
+
export function declarationKeyMessage(section) {
|
|
37
|
+
return `${section} 是插件声明,不能用配置补丁修改;请在 Console「插件」页管理插件,或直接编辑配置文件`;
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Why `path` cannot be written in `document` because of an array segment, or undefined. `set`
|
|
41
|
+
* cannot append: an index equal to the length is out of range too.
|
|
42
|
+
*/
|
|
43
|
+
export function describeArrayPathProblem(document, path, op) {
|
|
44
|
+
let current = document;
|
|
45
|
+
for (let index = 0; index < path.length; index++) {
|
|
46
|
+
const segment = path[index];
|
|
47
|
+
const here = path.slice(0, index + 1);
|
|
48
|
+
if (Array.isArray(current)) {
|
|
49
|
+
const parent = displayPath(path.slice(0, index));
|
|
50
|
+
if (!/^(0|[1-9]\d*)$/u.test(segment)) {
|
|
51
|
+
return `${parent} 是数组,${displayPath(here)} 里的 "${segment}" 应为数组下标(0–${Math.max(current.length - 1, 0)})`;
|
|
52
|
+
}
|
|
53
|
+
const position = Number(segment);
|
|
54
|
+
if (position >= current.length) {
|
|
55
|
+
const range = current.length === 0 ? '当前是空数组' : `当前有 ${current.length} 项,下标 0–${current.length - 1}`;
|
|
56
|
+
const append = op === 'set'
|
|
57
|
+
? `。要追加一项:先用 zhin config get ${parent} 读出整个数组,加上新项后用 zhin config set ${parent} '<JSON 数组>' 整体写回`
|
|
58
|
+
: '';
|
|
59
|
+
return `${displayPath(here)} 超出数组范围(${range})${append}`;
|
|
60
|
+
}
|
|
61
|
+
current = current[position];
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
if (!current || typeof current !== 'object')
|
|
65
|
+
return undefined;
|
|
66
|
+
current = Object.hasOwn(current, segment) ? current[segment] : undefined;
|
|
67
|
+
if (current === undefined)
|
|
68
|
+
return undefined;
|
|
69
|
+
}
|
|
70
|
+
return undefined;
|
|
71
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import type { ConfigPatch, RuntimeConfigDocument } from '@zhin.js/plugin-runtime';
|
|
2
|
+
/** How a patch stores secret values: `env` (default) or `plain` (explicit opt-out, e.g. `zhin config set --plain`). */
|
|
3
|
+
export type ConfigSecretStorage = 'env' | 'plain';
|
|
4
|
+
/** Result / error code when a write would store secret values in `.env` without `ops.env.write`. */
|
|
5
|
+
export declare const SECRET_ENVIRONMENT_WRITE_REQUIRED = "env-write-required";
|
|
6
|
+
/** Refusal message for {@link SECRET_ENVIRONMENT_WRITE_REQUIRED}; names the fields, never values. */
|
|
7
|
+
export declare function secretEnvironmentWriteRequiredMessage(paths: readonly (readonly string[])[]): string;
|
|
8
|
+
/** `setKey` refusal: the block holds secret literals but the caller may not write `.env`. */
|
|
9
|
+
export declare class SecretEnvironmentWriteRequiredError extends Error {
|
|
10
|
+
readonly paths: readonly (readonly string[])[];
|
|
11
|
+
readonly code = "env-write-required";
|
|
12
|
+
constructor(paths: readonly (readonly string[])[]);
|
|
13
|
+
}
|
|
14
|
+
export interface SecretAssignment {
|
|
15
|
+
readonly path: readonly string[];
|
|
16
|
+
readonly variable: string;
|
|
17
|
+
/** Never serialized into results. */
|
|
18
|
+
readonly value: string;
|
|
19
|
+
}
|
|
20
|
+
export interface SecretExternalizationPlan {
|
|
21
|
+
/** `set path → ${VAR}` patches applied after the caller's patches. */
|
|
22
|
+
readonly patches: readonly ConfigPatch[];
|
|
23
|
+
readonly assignments: readonly SecretAssignment[];
|
|
24
|
+
/** Secret values `.env` cannot hold (line breaks): the write is refused for these paths. */
|
|
25
|
+
readonly errors: readonly {
|
|
26
|
+
readonly path: string;
|
|
27
|
+
readonly message: string;
|
|
28
|
+
}[];
|
|
29
|
+
}
|
|
30
|
+
/** `http.token` → `HTTP_TOKEN` (the `${…}` name the scaffold uses), `qq.endpoints.0.appSecret` → `QQ_ENDPOINTS_0_APP_SECRET`. */
|
|
31
|
+
export declare function canonicalSecretVariable(path: readonly string[]): string;
|
|
32
|
+
/**
|
|
33
|
+
* Plans moving the literal secrets that `candidate` newly holds under `touched` paths (values that
|
|
34
|
+
* differ from `current`) into `.env`.
|
|
35
|
+
*/
|
|
36
|
+
export declare function planSecretExternalization(projectRoot: string, environmentName: string, current: RuntimeConfigDocument, candidate: RuntimeConfigDocument, touched: readonly (readonly string[])[]): Promise<SecretExternalizationPlan>;
|
|
37
|
+
/**
|
|
38
|
+
* Writes the assignments: into `.env.<environment>` when that file already defines the variable
|
|
39
|
+
* (it overrides `.env`), and into `.env` otherwise. Returns a function that restores the previous
|
|
40
|
+
* contents (used when the config write does not happen after all).
|
|
41
|
+
*/
|
|
42
|
+
export declare function writeSecretAssignments(projectRoot: string, environmentName: string, assignments: readonly SecretAssignment[]): Promise<() => Promise<void>>;
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Secret values written through a config patch go to `.env`, not into the config file (#1250).
|
|
3
|
+
*
|
|
4
|
+
* A `set` that writes a literal into a schema-marked secret field (`x-zhin-secret`, `writeOnly`,
|
|
5
|
+
* `format: password`, `x-zhin-config.secret`, or the secret field-name fallback) is rewritten:
|
|
6
|
+
* the value is stored as a `.env` variable and the config keeps (or gets) the `${VAR}` reference.
|
|
7
|
+
* The variable is the field's canonical name derived from its path (`http.token` → `HTTP_TOKEN`,
|
|
8
|
+
* with a numeric suffix when another value already holds that name). A `${VAR}` the field merely
|
|
9
|
+
* references is never overwritten unless it is that canonical name: otherwise a config write could
|
|
10
|
+
* replace any `.env` variable (e.g. point `http.token` at `${OPENAI_API_KEY}`, then set a literal).
|
|
11
|
+
* Writing into `.env` at all needs the caller's `ops.env.write` ({@link SecretEnvironmentWriteRequiredError}).
|
|
12
|
+
* Values are never returned or logged.
|
|
13
|
+
*/
|
|
14
|
+
import { readFile, rm } from 'node:fs/promises';
|
|
15
|
+
import { join } from 'node:path';
|
|
16
|
+
import { parse as parseDotenv } from 'dotenv';
|
|
17
|
+
import { updateDotenvFile } from '@zhin.js/runtime';
|
|
18
|
+
import { dotenvLine, findProjectLiteralSecrets, literalSecretEnvironmentName, readDocumentPath as readPath } from './literal-secrets.js';
|
|
19
|
+
const WHOLE_REFERENCE = /^\$\{([A-Za-z_][A-Za-z0-9_]*)\}$/u;
|
|
20
|
+
/** Result / error code when a write would store secret values in `.env` without `ops.env.write`. */
|
|
21
|
+
export const SECRET_ENVIRONMENT_WRITE_REQUIRED = 'env-write-required';
|
|
22
|
+
/** Refusal message for {@link SECRET_ENVIRONMENT_WRITE_REQUIRED}; names the fields, never values. */
|
|
23
|
+
export function secretEnvironmentWriteRequiredMessage(paths) {
|
|
24
|
+
return `敏感字段 ${paths.map(path => path.join('.')).join('、')} 的值要写入 .env,需要 ops.env.write 权限;未保存。`
|
|
25
|
+
+ '请让有该权限的管理员设置,或在 .env 中设置变量后写入 ${VAR} 引用';
|
|
26
|
+
}
|
|
27
|
+
/** `setKey` refusal: the block holds secret literals but the caller may not write `.env`. */
|
|
28
|
+
export class SecretEnvironmentWriteRequiredError extends Error {
|
|
29
|
+
paths;
|
|
30
|
+
code = SECRET_ENVIRONMENT_WRITE_REQUIRED;
|
|
31
|
+
constructor(paths) {
|
|
32
|
+
super(secretEnvironmentWriteRequiredMessage(paths));
|
|
33
|
+
this.paths = paths;
|
|
34
|
+
this.name = 'SecretEnvironmentWriteRequiredError';
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
/** `http.token` → `HTTP_TOKEN` (the `${…}` name the scaffold uses), `qq.endpoints.0.appSecret` → `QQ_ENDPOINTS_0_APP_SECRET`. */
|
|
38
|
+
export function canonicalSecretVariable(path) {
|
|
39
|
+
return literalSecretEnvironmentName(path).replace(/^ZHIN_/u, '');
|
|
40
|
+
}
|
|
41
|
+
function startsWith(path, prefix) {
|
|
42
|
+
return prefix.length <= path.length && prefix.every((segment, index) => path[index] === segment);
|
|
43
|
+
}
|
|
44
|
+
async function readOptional(file) {
|
|
45
|
+
try {
|
|
46
|
+
return await readFile(file, 'utf8');
|
|
47
|
+
}
|
|
48
|
+
catch (error) {
|
|
49
|
+
if (error instanceof Error && 'code' in error && error.code === 'ENOENT')
|
|
50
|
+
return undefined;
|
|
51
|
+
throw error;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Plans moving the literal secrets that `candidate` newly holds under `touched` paths (values that
|
|
56
|
+
* differ from `current`) into `.env`.
|
|
57
|
+
*/
|
|
58
|
+
export async function planSecretExternalization(projectRoot, environmentName, current, candidate, touched) {
|
|
59
|
+
const literals = (await findProjectLiteralSecrets(projectRoot, candidate))
|
|
60
|
+
.filter(secret => touched.some(path => startsWith(secret.path, path)));
|
|
61
|
+
if (literals.length === 0)
|
|
62
|
+
return { patches: [], assignments: [], errors: [] };
|
|
63
|
+
const layers = await Promise.all(['.env', `.env.${environmentName}`].map(async (name) => parseDotenv((await readOptional(join(projectRoot, name))) ?? '')));
|
|
64
|
+
const taken = new Map();
|
|
65
|
+
for (const layer of layers)
|
|
66
|
+
for (const [name, value] of Object.entries(layer))
|
|
67
|
+
taken.set(name, value);
|
|
68
|
+
const patches = [];
|
|
69
|
+
const assignments = [];
|
|
70
|
+
const errors = [];
|
|
71
|
+
for (const secret of literals) {
|
|
72
|
+
const value = readPath(candidate, secret.path);
|
|
73
|
+
if (typeof value !== 'string' || value === '' || value === readPath(current, secret.path))
|
|
74
|
+
continue;
|
|
75
|
+
const label = secret.path.join('.');
|
|
76
|
+
const canonical = canonicalSecretVariable(secret.path);
|
|
77
|
+
const reference = WHOLE_REFERENCE.exec(String(readPath(current, secret.path) ?? ''))?.[1];
|
|
78
|
+
// Only the field's own canonical variable is updated in place. Any other `${VAR}` the field
|
|
79
|
+
// references may belong to something else (#1250): a fresh name is written instead.
|
|
80
|
+
let variable = canonical;
|
|
81
|
+
if (reference !== canonical) {
|
|
82
|
+
for (let suffix = 2; (taken.has(variable) && taken.get(variable) !== value) || variable === reference; suffix += 1) {
|
|
83
|
+
variable = `${canonical}_${suffix}`;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
if (!dotenvLine(variable, value)) {
|
|
87
|
+
errors.push({ path: `$.${label}`, message: `${label} 是敏感字段,值含换行等字符,无法写入 .env;请手动在 .env 设置后引用 \${${variable}},或明确用明文保存(zhin config set --plain)` });
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
taken.set(variable, value);
|
|
91
|
+
assignments.push({ path: secret.path, variable, value });
|
|
92
|
+
patches.push({ op: 'set', path: secret.path, value: `\${${variable}}` });
|
|
93
|
+
}
|
|
94
|
+
return { patches, assignments, errors };
|
|
95
|
+
}
|
|
96
|
+
function assign(content, variable, line) {
|
|
97
|
+
const pattern = new RegExp(`^[ \\t]*(?:export[ \\t]+)?${variable}[ \\t]*=.*$`, 'mu');
|
|
98
|
+
if (pattern.test(content))
|
|
99
|
+
return content.replace(pattern, line);
|
|
100
|
+
const separator = content === '' || content.endsWith('\n') ? '' : '\n';
|
|
101
|
+
return `${content}${separator}${line}\n`;
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* Writes the assignments: into `.env.<environment>` when that file already defines the variable
|
|
105
|
+
* (it overrides `.env`), and into `.env` otherwise. Returns a function that restores the previous
|
|
106
|
+
* contents (used when the config write does not happen after all).
|
|
107
|
+
*/
|
|
108
|
+
export async function writeSecretAssignments(projectRoot, environmentName, assignments) {
|
|
109
|
+
if (assignments.length === 0)
|
|
110
|
+
return async () => { };
|
|
111
|
+
const files = ['.env', `.env.${environmentName}`].map(name => join(projectRoot, name));
|
|
112
|
+
const originals = await Promise.all(files.map(readOptional));
|
|
113
|
+
const overlay = parseDotenv(originals[1] ?? '');
|
|
114
|
+
const byFile = new Map();
|
|
115
|
+
for (const item of assignments) {
|
|
116
|
+
const file = Object.hasOwn(overlay, item.variable) ? files[1] : files[0];
|
|
117
|
+
byFile.set(file, [...(byFile.get(file) ?? []), item]);
|
|
118
|
+
}
|
|
119
|
+
const written = [];
|
|
120
|
+
try {
|
|
121
|
+
for (const [file, items] of byFile) {
|
|
122
|
+
await updateDotenvFile(file, content => items.reduce((text, item) => assign(text, item.variable, dotenvLine(item.variable, item.value)), content));
|
|
123
|
+
written.push(file);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
catch (error) {
|
|
127
|
+
await restore(written);
|
|
128
|
+
throw error;
|
|
129
|
+
}
|
|
130
|
+
async function restore(targets) {
|
|
131
|
+
for (const file of targets) {
|
|
132
|
+
const original = originals[files.indexOf(file)];
|
|
133
|
+
if (original === undefined)
|
|
134
|
+
await rm(file, { force: true });
|
|
135
|
+
else
|
|
136
|
+
await updateDotenvFile(file, () => original);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
return () => restore(written);
|
|
140
|
+
}
|
|
@@ -5,6 +5,8 @@ export interface SchemaIssue {
|
|
|
5
5
|
readonly params: Readonly<Record<string, unknown>>;
|
|
6
6
|
readonly message?: string;
|
|
7
7
|
}
|
|
8
|
+
/** Schema `pattern` of a value that may be a string only as one whole `${VAR}` reference. */
|
|
9
|
+
export declare const ENVIRONMENT_REFERENCE_SCHEMA_PATTERN = "^\\$\\{[A-Za-z_][A-Za-z0-9_]*(?::[-=][^}]*)?\\}$";
|
|
8
10
|
/**
|
|
9
11
|
* Localized text for one Ajv issue of a Plugin configuration, in the project `locale`
|
|
10
12
|
* (`zh-CN` by default). `location` is the field path inside the configuration block; it is named in
|
|
@@ -10,6 +10,7 @@ const messages = createMessages({
|
|
|
10
10
|
enum: { 'zh-CN': '{field}必须是以下值之一:{values}', en: '{field}must be one of: {values}' },
|
|
11
11
|
const: { 'zh-CN': '{field}必须等于 {value}', en: '{field}must equal {value}' },
|
|
12
12
|
pattern: { 'zh-CN': '{field}格式不符合 {pattern}', en: '{field}must match the pattern {pattern}' },
|
|
13
|
+
environmentReference: { 'zh-CN': '{field}只接受数值或 {reference} 形式的环境变量引用', en: '{field}accepts only a number or a {reference} environment reference' },
|
|
13
14
|
required: { 'zh-CN': '缺少必填字段 {property}', en: 'missing required field {property}' },
|
|
14
15
|
additionalProperties: { 'zh-CN': '不支持的字段 {property}', en: 'unknown field {property}' },
|
|
15
16
|
format: { 'zh-CN': '{field}应为有效的 {format}', en: '{field}must be a valid {format}' },
|
|
@@ -18,6 +19,8 @@ const messages = createMessages({
|
|
|
18
19
|
fallback: { 'zh-CN': '{field}不满足 {keyword} 约束({detail})', en: '{field}does not satisfy {keyword} ({detail})' },
|
|
19
20
|
field: { 'zh-CN': '字段 {path} ', en: 'Field {path} ' },
|
|
20
21
|
});
|
|
22
|
+
/** Schema `pattern` of a value that may be a string only as one whole `${VAR}` reference. */
|
|
23
|
+
export const ENVIRONMENT_REFERENCE_SCHEMA_PATTERN = '^\\$\\{[A-Za-z_][A-Za-z0-9_]*(?::[-=][^}]*)?\\}$';
|
|
21
24
|
const NUMERIC_LIMITS = new Set(['maximum', 'minimum', 'exclusiveMaximum', 'exclusiveMinimum', 'minLength', 'maxLength', 'minItems', 'maxItems']);
|
|
22
25
|
function display(value) {
|
|
23
26
|
return typeof value === 'string' ? value : JSON.stringify(value);
|
|
@@ -43,6 +46,10 @@ export function formatSchemaIssue(issue, location, locale) {
|
|
|
43
46
|
case 'const':
|
|
44
47
|
return messages(lang, 'const', { field, value: display(params.allowedValue) });
|
|
45
48
|
case 'pattern':
|
|
49
|
+
// A string allowed only as a whole `${VAR}` reference (e.g. `http.port`): say so plainly.
|
|
50
|
+
if (String(params.pattern) === ENVIRONMENT_REFERENCE_SCHEMA_PATTERN) {
|
|
51
|
+
return messages(lang, 'environmentReference', { field, reference: '${VAR}' });
|
|
52
|
+
}
|
|
46
53
|
return messages(lang, 'pattern', { field, pattern: String(params.pattern) });
|
|
47
54
|
case 'format':
|
|
48
55
|
return messages(lang, 'format', { field, format: String(params.format) });
|
package/lib/config.d.ts
CHANGED
|
@@ -11,3 +11,4 @@ export * from './config/environment-files.js';
|
|
|
11
11
|
export * from './config/literal-secrets.js';
|
|
12
12
|
export * from './config/plugin-package-map.js';
|
|
13
13
|
export * from './config/plugin-schema-catalog.js';
|
|
14
|
+
export { SECRET_ENVIRONMENT_WRITE_REQUIRED, SecretEnvironmentWriteRequiredError } from './config/secret-storage.js';
|
package/lib/config.js
CHANGED
|
@@ -11,3 +11,4 @@ export * from './config/environment-files.js';
|
|
|
11
11
|
export * from './config/literal-secrets.js';
|
|
12
12
|
export * from './config/plugin-package-map.js';
|
|
13
13
|
export * from './config/plugin-schema-catalog.js';
|
|
14
|
+
export { SECRET_ENVIRONMENT_WRITE_REQUIRED, SecretEnvironmentWriteRequiredError } from './config/secret-storage.js';
|
|
@@ -92,7 +92,8 @@ export async function setupConsoleRole(options) {
|
|
|
92
92
|
const block = document.database && typeof document.database === 'object' && !Array.isArray(document.database)
|
|
93
93
|
? document.database
|
|
94
94
|
: { dialect };
|
|
95
|
-
|
|
95
|
+
// Local owner CLI (`zhin db console-role`): may write `.env`.
|
|
96
|
+
await store.setKey('database', { ...block, console: { ...ENV_REFERENCE } }, { environmentWrite: true });
|
|
96
97
|
return Object.freeze({
|
|
97
98
|
dialect,
|
|
98
99
|
user,
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { type ConsoleAuditEntry } from '@zhin.js/host-http';
|
|
2
2
|
import { type ConsolePluginPermissionInfo } from '@zhin.js/console-protocol';
|
|
3
|
+
import type { BridgeRouteTable } from '@zhin.js/host-http/bridge';
|
|
3
4
|
import { type ProjectDatabaseConfig } from '../database/config.js';
|
|
4
5
|
import { type GatewayStore, type OpenGatewayStoreOptions } from '../database/gateway-store.js';
|
|
5
6
|
import { type LocalConsoleAdmin } from '../iam/local-admin.js';
|
|
@@ -15,6 +16,10 @@ export interface GatewayConsoleInstancePort {
|
|
|
15
16
|
pluginPermissions?(): readonly ConsolePluginPermissionInfo[];
|
|
16
17
|
/** Sends `principal-revoked` (the instance closes that user's live connections). */
|
|
17
18
|
revokePrincipal?(principalId: string, reason?: string): void;
|
|
19
|
+
/** Whether an instance is attached and answering (a message sent now is delivered). */
|
|
20
|
+
readonly ready?: boolean;
|
|
21
|
+
/** Runs on every route table change (an instance's first `ready` included; empty when it goes away). */
|
|
22
|
+
onRoutes?(listener: (table: BridgeRouteTable) => void): () => void;
|
|
18
23
|
/** Audit entries written by the instance. */
|
|
19
24
|
onAudit?(listener: (entry: ConsoleAuditEntry) => void): () => void;
|
|
20
25
|
}
|