@zhin.js/gateway 0.0.1-bootstrap.0 → 1.1.2
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/LICENSE +21 -0
- package/README.md +61 -0
- package/lib/config/ai-config-loader.d.ts +22 -0
- package/lib/config/ai-config-loader.js +96 -0
- package/lib/config/configuration-chat-policy.d.ts +57 -0
- package/lib/config/configuration-chat-policy.js +423 -0
- package/lib/config/configuration-patch.d.ts +16 -0
- package/lib/config/configuration-patch.js +105 -0
- package/lib/config/configuration-path.d.ts +6 -0
- package/lib/config/configuration-path.js +40 -0
- package/lib/config/configuration-projection.d.ts +6 -0
- package/lib/config/configuration-projection.js +28 -0
- package/lib/config/configuration-redaction.d.ts +18 -0
- package/lib/config/configuration-redaction.js +75 -0
- package/lib/config/configuration.d.ts +81 -0
- package/lib/config/configuration.js +686 -0
- package/lib/config/console-schema.d.ts +11 -0
- package/lib/config/console-schema.js +223 -0
- package/lib/config/environment-files.d.ts +3 -0
- package/lib/config/environment-files.js +14 -0
- package/lib/config/literal-secrets.d.ts +34 -0
- package/lib/config/literal-secrets.js +136 -0
- package/lib/config/plugin-package-map.d.ts +2 -0
- package/lib/config/plugin-package-map.js +14 -0
- package/lib/config/plugin-schema-catalog.d.ts +22 -0
- package/lib/config/plugin-schema-catalog.js +262 -0
- package/lib/config.d.ts +13 -0
- package/lib/config.js +13 -0
- package/lib/database/config.d.ts +22 -0
- package/lib/database/config.js +159 -0
- package/lib/database/index.d.ts +2 -0
- package/lib/database/index.js +2 -0
- package/lib/database/project-database.d.ts +31 -0
- package/lib/database/project-database.js +130 -0
- package/lib/database.d.ts +2 -0
- package/lib/database.js +2 -0
- package/lib/http/console-rpc.d.ts +27 -0
- package/lib/http/console-rpc.js +51 -0
- package/lib/http/gateway-console.d.ts +63 -0
- package/lib/http/gateway-console.js +309 -0
- package/lib/http/gateway-events.d.ts +40 -0
- package/lib/http/gateway-events.js +185 -0
- package/lib/http/gateway-http-server.d.ts +95 -0
- package/lib/http/gateway-http-server.js +522 -0
- package/lib/http/local-principal.d.ts +2 -0
- package/lib/http/local-principal.js +16 -0
- package/lib/http/plugin-switch-rpc.d.ts +19 -0
- package/lib/http/plugin-switch-rpc.js +214 -0
- package/lib/http/run-gateway.d.ts +43 -0
- package/lib/http/run-gateway.js +268 -0
- package/lib/http/start-gateway.d.ts +114 -0
- package/lib/http/start-gateway.js +226 -0
- package/lib/http-response.d.ts +6 -0
- package/lib/http-response.js +25 -0
- package/lib/http.d.ts +6 -0
- package/lib/http.js +6 -0
- package/lib/iam/crypto.d.ts +12 -0
- package/lib/iam/crypto.js +58 -0
- package/lib/iam/index.d.ts +8 -0
- package/lib/iam/index.js +8 -0
- package/lib/iam/install.d.ts +36 -0
- package/lib/iam/install.js +74 -0
- package/lib/iam/local-admin.d.ts +27 -0
- package/lib/iam/local-admin.js +102 -0
- package/lib/iam/rate-limit.d.ts +15 -0
- package/lib/iam/rate-limit.js +52 -0
- package/lib/iam/routes.d.ts +23 -0
- package/lib/iam/routes.js +157 -0
- package/lib/iam/rpc.d.ts +14 -0
- package/lib/iam/rpc.js +100 -0
- package/lib/iam/service.d.ts +203 -0
- package/lib/iam/service.js +784 -0
- package/lib/iam/setup-service.d.ts +83 -0
- package/lib/iam/setup-service.js +206 -0
- package/lib/iam/tables.d.ts +20 -0
- package/lib/iam/tables.js +82 -0
- package/lib/iam.d.ts +2 -0
- package/lib/iam.js +2 -0
- package/lib/index.d.ts +22 -0
- package/lib/index.js +22 -0
- package/lib/local/client.d.ts +71 -0
- package/lib/local/client.js +296 -0
- package/lib/local/handler.d.ts +30 -0
- package/lib/local/handler.js +86 -0
- package/lib/local/local-socket.d.ts +55 -0
- package/lib/local/local-socket.js +232 -0
- package/lib/local/paths.d.ts +34 -0
- package/lib/local/paths.js +58 -0
- package/lib/local/protocol.d.ts +140 -0
- package/lib/local/protocol.js +90 -0
- package/lib/local/state-file.d.ts +8 -0
- package/lib/local/state-file.js +45 -0
- package/lib/local.d.ts +10 -0
- package/lib/local.js +10 -0
- package/lib/onboarding/allowlist.d.ts +54 -0
- package/lib/onboarding/allowlist.js +106 -0
- package/lib/onboarding/errors.d.ts +7 -0
- package/lib/onboarding/errors.js +17 -0
- package/lib/onboarding/index.d.ts +6 -0
- package/lib/onboarding/index.js +6 -0
- package/lib/onboarding/install.d.ts +99 -0
- package/lib/onboarding/install.js +132 -0
- package/lib/onboarding/package-manager.d.ts +47 -0
- package/lib/onboarding/package-manager.js +179 -0
- package/lib/onboarding/service.d.ts +53 -0
- package/lib/onboarding/service.js +144 -0
- package/lib/onboarding/state-store.d.ts +26 -0
- package/lib/onboarding/state-store.js +97 -0
- package/lib/onboarding.d.ts +2 -0
- package/lib/onboarding.js +2 -0
- package/lib/packages/authorization.d.ts +24 -0
- package/lib/packages/authorization.js +128 -0
- package/lib/packages/errors.d.ts +11 -0
- package/lib/packages/errors.js +26 -0
- package/lib/packages/index.d.ts +5 -0
- package/lib/packages/index.js +5 -0
- package/lib/packages/rpc.d.ts +30 -0
- package/lib/packages/rpc.js +160 -0
- package/lib/packages/service.d.ts +139 -0
- package/lib/packages/service.js +504 -0
- package/lib/packages/snapshot.d.ts +14 -0
- package/lib/packages/snapshot.js +73 -0
- package/lib/packages.d.ts +2 -0
- package/lib/packages.js +2 -0
- package/lib/plugin-management/activity.d.ts +6 -0
- package/lib/plugin-management/activity.js +14 -0
- package/lib/plugin-management/config-explanation.d.ts +6 -0
- package/lib/plugin-management/config-explanation.js +113 -0
- package/lib/plugin-management/use-cases.d.ts +15 -0
- package/lib/plugin-management/use-cases.js +120 -0
- package/lib/plugin-management/workflow.d.ts +136 -0
- package/lib/plugin-management/workflow.js +878 -0
- package/lib/plugin-management.d.ts +5 -0
- package/lib/plugin-management.js +5 -0
- package/lib/project/config-file.d.ts +6 -0
- package/lib/project/config-file.js +58 -0
- package/lib/project/environment.d.ts +13 -0
- package/lib/project/environment.js +52 -0
- package/lib/project/instance-declarations.d.ts +55 -0
- package/lib/project/instance-declarations.js +542 -0
- package/lib/project/legacy-config.d.ts +25 -0
- package/lib/project/legacy-config.js +70 -0
- package/lib/project/node-requirements.d.ts +7 -0
- package/lib/project/node-requirements.js +20 -0
- package/lib/project/tsx-module-loader.d.ts +7 -0
- package/lib/project/tsx-module-loader.js +29 -0
- package/lib/project.d.ts +7 -0
- package/lib/project.js +7 -0
- package/lib/server.d.ts +11 -0
- package/lib/server.js +11 -0
- package/lib/supervisor/instance-logs.d.ts +39 -0
- package/lib/supervisor/instance-logs.js +152 -0
- package/lib/supervisor/instance-supervisor.d.ts +140 -0
- package/lib/supervisor/instance-supervisor.js +408 -0
- package/lib/supervisor/run-instance.d.ts +35 -0
- package/lib/supervisor/run-instance.js +161 -0
- package/lib/supervisor/terminal-state.d.ts +30 -0
- package/lib/supervisor/terminal-state.js +57 -0
- package/lib/supervisor.d.ts +4 -0
- package/lib/supervisor.js +4 -0
- package/lib/terminal/backends.d.ts +38 -0
- package/lib/terminal/backends.js +193 -0
- package/lib/terminal/config.d.ts +63 -0
- package/lib/terminal/config.js +237 -0
- package/lib/terminal/host.d.ts +26 -0
- package/lib/terminal/host.js +76 -0
- package/lib/terminal/index.d.ts +60 -0
- package/lib/terminal/index.js +135 -0
- package/lib/terminal/modules.d.ts +87 -0
- package/lib/terminal/modules.js +44 -0
- package/lib/terminal/service.d.ts +101 -0
- package/lib/terminal/service.js +455 -0
- package/lib/terminal/tickets.d.ts +46 -0
- package/lib/terminal/tickets.js +76 -0
- package/lib/terminal.d.ts +3 -0
- package/lib/terminal.js +3 -0
- package/lib/usage/aggregate.d.ts +87 -0
- package/lib/usage/aggregate.js +212 -0
- package/lib/usage.d.ts +2 -0
- package/lib/usage.js +2 -0
- package/package.json +117 -4
|
@@ -0,0 +1,686 @@
|
|
|
1
|
+
import { isDeepStrictEqual } from 'node:util';
|
|
2
|
+
import { HOST_CONFIG_PATCH_PATHS, } from '@zhin.js/console-protocol';
|
|
3
|
+
import { applyConfigPatches, readPluginConfigurationMap, rootPluginId, } from '@zhin.js/plugin-runtime';
|
|
4
|
+
import { ConfigDocumentConflictError, ConfigValidationError, ConfigurationSupersededError, HOST_CONFIG_KEYS, readApplicationPluginDeclaration, parsePluginConfigSchema, createEnvStore } from '@zhin.js/runtime';
|
|
5
|
+
import { loadRuntimeEnvironmentLayers } from '../project/environment.js';
|
|
6
|
+
import { readPluginPackageMap } from './plugin-package-map.js';
|
|
7
|
+
import { assertUnambiguousConfigKey, configKeyPatch, configKeyRemovePatch, flattenConfigDocument } from './configuration-projection.js';
|
|
8
|
+
import { readEnvFile, writeEnvFile } from './environment-files.js';
|
|
9
|
+
import { PluginSchemaCatalog } from './plugin-schema-catalog.js';
|
|
10
|
+
import { hostModelOwnerId } from '@zhin.js/plugin-runtime';
|
|
11
|
+
import { assertChatConfigurationAccess, assertChatConfigurationScope, assertChatValueHasNoEnvironmentReference, assertChatOptionalLeaf, chatConfigurationLeaves, chatHostRoots, chatLeafTypes, ConfigChatAccessError, } from './configuration-chat-policy.js';
|
|
12
|
+
import { isForbiddenConfigurationPathSegment } from './configuration-path.js';
|
|
13
|
+
import { assertPatchesInScope, normalizeConfigPatchOperations, readDeclaredPatchPaths, readPath, resolvePatchScope, } from './configuration-patch.js';
|
|
14
|
+
import { maskConfigValue, REDACTED_CONFIG_VALUE } from './configuration-redaction.js';
|
|
15
|
+
/** A Host-classified rejection before this operation attempted any commit. */
|
|
16
|
+
export class ConfigChatRequestRejectedError extends Error {
|
|
17
|
+
code = 'configuration_request_rejected';
|
|
18
|
+
constructor() { super('配置操作在保存前失败,未保存。'); this.name = 'ConfigChatRequestRejectedError'; }
|
|
19
|
+
}
|
|
20
|
+
/** Only a thrown commit crosses this uncertainty boundary; never prepared cleanup. */
|
|
21
|
+
class ConfigCommitUnconfirmedError extends Error {
|
|
22
|
+
constructor(cause) { super('Configuration commit outcome is unconfirmed', { cause }); }
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Top-level keys a trusted project patch must not write: Plugin declarations
|
|
26
|
+
* (`configVersion` / `plugins` / `disable_plugins`) and the root Plugin section
|
|
27
|
+
* (`plugin`). Declarations change which schema interprets business config, so they
|
|
28
|
+
* stay with the source editor and Plugin management, not path patches.
|
|
29
|
+
*/
|
|
30
|
+
const PROJECT_PATCH_DECLARATION_KEYS = new Set(['configVersion', 'plugins', 'disable_plugins', 'plugin']);
|
|
31
|
+
/** C3: Host chat whitelist is exactly the ordinary page patch allowlist. */
|
|
32
|
+
const CHAT_SCOPE = Object.freeze({
|
|
33
|
+
hostKeys: HOST_CONFIG_KEYS, hostPaths: HOST_CONFIG_PATCH_PATHS, declarationKeys: PROJECT_PATCH_DECLARATION_KEYS,
|
|
34
|
+
});
|
|
35
|
+
const MAX_CHAT_FIELDS = 256;
|
|
36
|
+
/** Allow only Runtime's fixed metadata vocabulary into a chat response. */
|
|
37
|
+
function chatApplication(application) {
|
|
38
|
+
if (!application)
|
|
39
|
+
return undefined;
|
|
40
|
+
const nonnegative = (value) => Number.isSafeInteger(value) && value >= 0 ? value : 0;
|
|
41
|
+
return Object.freeze({ operationId: /^config:[a-f0-9-]+:[0-9]+$/u.test(application.operationId) ? application.operationId : 'configuration-status',
|
|
42
|
+
sourceRevision: /^[a-f0-9]{64}$/u.test(application.sourceRevision) ? application.sourceRevision : '', saved: application.saved === true,
|
|
43
|
+
generation: nonnegative(application.generation), applicationRevision: nonnegative(application.applicationRevision),
|
|
44
|
+
groups: Object.freeze(application.groups.map((group, index) => Object.freeze({ id: `group-${index + 1}`, paths: Object.freeze([]),
|
|
45
|
+
effect: ['in-place', 'replace-resource', 'reconnect-endpoint', 'reload-plugin', 'restart-process'].includes(group.effect) ? group.effect : 'replace-resource',
|
|
46
|
+
status: ['applied', 'restart-pending', 'blocked', 'failed', 'superseded', 'unknown'].includes(group.status) ? group.status : 'unknown',
|
|
47
|
+
dependsOn: Object.freeze([]),
|
|
48
|
+
}))) });
|
|
49
|
+
}
|
|
50
|
+
/** Owns one project's Console configuration projection over the Root config authority. */
|
|
51
|
+
export class ConsoleConfigurationStore {
|
|
52
|
+
#projectRoot;
|
|
53
|
+
#document;
|
|
54
|
+
#schemas;
|
|
55
|
+
#environmentName;
|
|
56
|
+
#environmentForPlugin;
|
|
57
|
+
#applyConfiguration;
|
|
58
|
+
#readApplication;
|
|
59
|
+
#validateConfiguration;
|
|
60
|
+
#applicationError;
|
|
61
|
+
#applicationErrorAtOperationId;
|
|
62
|
+
#mutationTail = Promise.resolve();
|
|
63
|
+
constructor(options) {
|
|
64
|
+
this.#projectRoot = options.projectRoot;
|
|
65
|
+
this.#document = options.document;
|
|
66
|
+
this.#schemas = new PluginSchemaCatalog(options.projectRoot, options.configDeclarations);
|
|
67
|
+
const environmentName = options.environmentName;
|
|
68
|
+
this.#environmentName = typeof environmentName === 'function' ? environmentName : () => environmentName ?? (process.env.NODE_ENV === 'production' ? 'production' : 'development');
|
|
69
|
+
this.#environmentForPlugin = options.environmentForPlugin;
|
|
70
|
+
this.#applyConfiguration = options.applyConfiguration;
|
|
71
|
+
this.#readApplication = options.readApplication;
|
|
72
|
+
this.#validateConfiguration = options.validateConfiguration;
|
|
73
|
+
}
|
|
74
|
+
readSource() {
|
|
75
|
+
return this.#afterMutations(async () => {
|
|
76
|
+
const snapshot = await this.#document.readSource();
|
|
77
|
+
const configKeys = await this.#listKeys(snapshot.document);
|
|
78
|
+
const declaredPlugins = await readPluginPackageMap(this.#projectRoot, snapshot.document);
|
|
79
|
+
const pluginConfigs = readPluginConfigurationMap(snapshot.document);
|
|
80
|
+
const application = this.#readApplication?.();
|
|
81
|
+
if (application && application.operationId !== this.#applicationErrorAtOperationId)
|
|
82
|
+
this.#applicationError = undefined;
|
|
83
|
+
return Object.freeze({
|
|
84
|
+
source: snapshot.source,
|
|
85
|
+
format: snapshot.format === 'YAML' ? 'yaml' : 'json',
|
|
86
|
+
revision: snapshot.revision,
|
|
87
|
+
...(application ? { application } : {}),
|
|
88
|
+
...(this.#applicationError ? { applicationError: this.#applicationError } : {}),
|
|
89
|
+
configKeys,
|
|
90
|
+
configPaths: Object.freeze(Object.fromEntries(configKeys.filter(key => !HOST_CONFIG_KEYS.includes(key)
|
|
91
|
+
|| (!declaredPlugins.has(key)
|
|
92
|
+
&& !Object.prototype.hasOwnProperty.call(pluginConfigs, key))).map(key => [key, configKeyPatch(snapshot.document, key, undefined).path]))),
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
readDocument() {
|
|
97
|
+
return this.#afterMutations(async () => {
|
|
98
|
+
const snapshot = await this.#document.read();
|
|
99
|
+
return flattenConfigDocument(snapshot.document);
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
replaceSource(source, expectedRevision) {
|
|
103
|
+
return this.#serialize(async () => {
|
|
104
|
+
const current = await this.#document.read();
|
|
105
|
+
const prepared = await this.#document.prepareReplacement(expectedRevision, source);
|
|
106
|
+
readApplicationPluginDeclaration(prepared.document);
|
|
107
|
+
await this.#validatePrepared(prepared);
|
|
108
|
+
const committed = await prepared.commit();
|
|
109
|
+
return Object.freeze({
|
|
110
|
+
revision: committed.revision,
|
|
111
|
+
...await this.#applySaved(HOST_CONFIG_KEYS.some((key) => !isDeepStrictEqual(current.document[key], prepared.document[key])), committed.revision),
|
|
112
|
+
});
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
setKey(pluginName, data) {
|
|
116
|
+
return this.#serialize(async () => {
|
|
117
|
+
const current = await this.#document.read();
|
|
118
|
+
await this.#assertUnambiguousKey(current.document, pluginName);
|
|
119
|
+
const prepared = await this.#document.prepare(current, [
|
|
120
|
+
configKeyPatch(current.document, pluginName, data),
|
|
121
|
+
]);
|
|
122
|
+
readApplicationPluginDeclaration(prepared.document);
|
|
123
|
+
await this.#validatePrepared(prepared);
|
|
124
|
+
const committed = await prepared.commit();
|
|
125
|
+
return this.#applySaved(HOST_CONFIG_KEYS.includes(pluginName), committed.revision);
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
removeKey(pluginName) {
|
|
129
|
+
return this.#serialize(async () => {
|
|
130
|
+
const current = await this.#document.read();
|
|
131
|
+
await this.#assertUnambiguousKey(current.document, pluginName);
|
|
132
|
+
const prepared = await this.#document.prepare(current, [
|
|
133
|
+
configKeyRemovePatch(current.document, pluginName),
|
|
134
|
+
]);
|
|
135
|
+
readApplicationPluginDeclaration(prepared.document);
|
|
136
|
+
await this.#validatePrepared(prepared);
|
|
137
|
+
const committed = await prepared.commit();
|
|
138
|
+
return this.#applySaved(HOST_CONFIG_KEYS.includes(pluginName), committed.revision);
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
/**
|
|
142
|
+
* Scoped, revision-checked path writes (`config:patch`). Paths outside the caller's
|
|
143
|
+
* allowlist are rejected before reading values; a stale revision returns the latest
|
|
144
|
+
* values instead of overwriting; every touched section is validated by its schema.
|
|
145
|
+
*/
|
|
146
|
+
patch(request) {
|
|
147
|
+
return this.#serialize(() => this.#patchCore(request, 'scoped'));
|
|
148
|
+
}
|
|
149
|
+
/**
|
|
150
|
+
* Trusted project administration (`config:patch-project`, full scope only). Same
|
|
151
|
+
* single-CAS core as {@link patch}, without the page allowlist; it does not widen
|
|
152
|
+
* `config:patch` / `HOST_CONFIG_PATCH_PATHS`. Declaration writes are rejected as
|
|
153
|
+
* `invalid`, conflict values are masked, and a thrown commit is `unconfirmed`.
|
|
154
|
+
*/
|
|
155
|
+
patchProject(request) {
|
|
156
|
+
return this.#serialize(async () => {
|
|
157
|
+
try {
|
|
158
|
+
return await this.#patchCore({ expectedRevision: request.expectedRevision, patches: request.patches }, 'project');
|
|
159
|
+
}
|
|
160
|
+
catch (error) {
|
|
161
|
+
if (!(error instanceof ConfigCommitUnconfirmedError))
|
|
162
|
+
throw error;
|
|
163
|
+
// The file may already hold the candidate. Do not retry or roll back.
|
|
164
|
+
return Object.freeze({ status: 'unconfirmed', expectedRevision: request.expectedRevision,
|
|
165
|
+
message: 'Configuration save outcome is unconfirmed; inspect source and runtime status before any further write' });
|
|
166
|
+
}
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
/** Host-authenticated owner chat. Policy checks share the mutation's CAS boundary. */
|
|
170
|
+
patchChat(request) {
|
|
171
|
+
return this.#serialize(async () => {
|
|
172
|
+
try {
|
|
173
|
+
const result = await this.#patchCore(request, 'chat');
|
|
174
|
+
if (result.status === 'conflict')
|
|
175
|
+
return Object.freeze({ ...result, current: Object.freeze([]) });
|
|
176
|
+
if (result.status === 'invalid')
|
|
177
|
+
return Object.freeze({ ...result, errors: Object.freeze([{ path: '$', message: '配置校验失败,未保存' }]), missingEnv: Object.freeze([]) });
|
|
178
|
+
return Object.freeze({ ...result, missingEnv: Object.freeze([]), application: chatApplication(result.application) });
|
|
179
|
+
}
|
|
180
|
+
catch (error) {
|
|
181
|
+
if (error instanceof ConfigChatAccessError || error instanceof ConfigChatRequestRejectedError)
|
|
182
|
+
throw error;
|
|
183
|
+
// The save boundary may have been crossed. Never misclassify an unknown
|
|
184
|
+
// operation as an access denial or recommend repeating the write.
|
|
185
|
+
throw new Error('配置保存或生效结果未确认,请先在 CLI 或 Console 核验当前状态。', { cause: error });
|
|
186
|
+
}
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
/** Shared single-CAS write: `scoped` keeps the page allowlist; `project` is the trusted full path. */
|
|
190
|
+
async #patchCore(request, mode) {
|
|
191
|
+
let phase = 'prepare';
|
|
192
|
+
try {
|
|
193
|
+
const instanceKey = request.instanceKey;
|
|
194
|
+
const patches = normalizeConfigPatchOperations(request.patches);
|
|
195
|
+
const current = await this.#document.read();
|
|
196
|
+
if (mode === 'scoped') {
|
|
197
|
+
const declared = instanceKey === undefined
|
|
198
|
+
? []
|
|
199
|
+
: readDeclaredPatchPaths(await this.#ownSchema(current.document, instanceKey), instanceKey);
|
|
200
|
+
const allowed = resolvePatchScope(current.document, instanceKey, declared);
|
|
201
|
+
assertPatchesInScope(patches, allowed, instanceKey === undefined ? 'Host 白名单' : `插件 ${instanceKey}`);
|
|
202
|
+
}
|
|
203
|
+
const paths = Object.freeze(patches.map(patch => patch.path));
|
|
204
|
+
if (mode !== 'scoped') {
|
|
205
|
+
const rejected = projectDeclarationErrors(current.document, paths);
|
|
206
|
+
if (rejected.length > 0) {
|
|
207
|
+
return Object.freeze({ status: 'invalid', revision: current.revision, errors: Object.freeze(rejected),
|
|
208
|
+
missingEnv: Object.freeze([]), message: '插件声明不能通过项目补丁修改,未保存' });
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
const conflict = async (snapshot) => Object.freeze({
|
|
212
|
+
status: 'conflict',
|
|
213
|
+
revision: snapshot.revision,
|
|
214
|
+
current: Object.freeze(await Promise.all(paths.map(async (path) => Object.freeze({ path,
|
|
215
|
+
value: mode !== 'scoped' ? await this.#maskedValue(snapshot.document, path, { unknownSchema: 'redact' }) : readPath(snapshot.document, path) })))),
|
|
216
|
+
message: '配置已被其他地方修改,未保存。请查看最新值后重试。',
|
|
217
|
+
});
|
|
218
|
+
if (current.revision !== request.expectedRevision)
|
|
219
|
+
return await conflict(current);
|
|
220
|
+
const candidate = applyConfigPatches(current.document, patches);
|
|
221
|
+
// Chat writes exactly one leaf (C1/C4); scope and leaf shape are proven before values.
|
|
222
|
+
if (mode === 'chat' && patches.length !== 1)
|
|
223
|
+
throw new ConfigChatAccessError('leaf');
|
|
224
|
+
if (mode === 'chat')
|
|
225
|
+
for (const patch of patches) {
|
|
226
|
+
assertChatConfigurationScope(patch.path, CHAT_SCOPE);
|
|
227
|
+
if (patch.op === 'remove' && readPath(current.document, patch.path) === undefined)
|
|
228
|
+
throw new ConfigChatAccessError('missing');
|
|
229
|
+
const schema = await this.#chatSchema(current.document, patch.path);
|
|
230
|
+
if (patch.op === 'remove')
|
|
231
|
+
assertChatOptionalLeaf(schema, patch.path.slice(1));
|
|
232
|
+
else {
|
|
233
|
+
chatLeafTypes(schema, patch.path.slice(1));
|
|
234
|
+
// M1: no new `${…}` / `env://` reference may be planted from chat.
|
|
235
|
+
assertChatValueHasNoEnvironmentReference(patch.value);
|
|
236
|
+
}
|
|
237
|
+
await this.#assertChatPath(candidate, patch.path, [readPath(candidate, patch.path)]);
|
|
238
|
+
if (readPath(current.document, patch.path) !== undefined) {
|
|
239
|
+
await this.#assertChatPath(current.document, patch.path, [readPath(current.document, patch.path)]);
|
|
240
|
+
}
|
|
241
|
+
// Array removal shifts subsequent values into other schema positions.
|
|
242
|
+
const parent = patch.path.slice(0, -1);
|
|
243
|
+
if (patch.op === 'remove' && Array.isArray(readPath(current.document, parent))) {
|
|
244
|
+
const values = [readPath(current.document, parent), readPath(candidate, parent)];
|
|
245
|
+
await this.#assertChatPath(current.document, parent, values);
|
|
246
|
+
await this.#assertChatPath(candidate, parent, values);
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
const sections = [...new Set(patches.map(patch => patch.path[0]))];
|
|
250
|
+
const errors = [];
|
|
251
|
+
const missingEnv = new Set();
|
|
252
|
+
for (const section of sections) {
|
|
253
|
+
if (candidate[section] === undefined)
|
|
254
|
+
continue;
|
|
255
|
+
const validation = await this.validatePluginConfig(section, candidate[section], current.document);
|
|
256
|
+
for (const error of validation.errors)
|
|
257
|
+
errors.push({ path: `$.${section}${error.path.slice(1)}`, message: error.message });
|
|
258
|
+
for (const name of validation.missingEnv)
|
|
259
|
+
missingEnv.add(name);
|
|
260
|
+
}
|
|
261
|
+
if (errors.length > 0) {
|
|
262
|
+
return Object.freeze({
|
|
263
|
+
status: 'invalid',
|
|
264
|
+
revision: current.revision,
|
|
265
|
+
errors: Object.freeze(errors),
|
|
266
|
+
missingEnv: Object.freeze([...missingEnv].sort()),
|
|
267
|
+
message: '配置校验失败,未保存',
|
|
268
|
+
});
|
|
269
|
+
}
|
|
270
|
+
let committed;
|
|
271
|
+
try {
|
|
272
|
+
const prepared = await this.#document.prepare(current, patches);
|
|
273
|
+
readApplicationPluginDeclaration(prepared.document);
|
|
274
|
+
try {
|
|
275
|
+
await this.#validatePrepared(prepared);
|
|
276
|
+
}
|
|
277
|
+
catch (error) {
|
|
278
|
+
if (!(error instanceof ConfigValidationError))
|
|
279
|
+
throw error;
|
|
280
|
+
// Runtime validation may report cross-field constraints without a
|
|
281
|
+
// structured path. Preserve the invalid contract at the document
|
|
282
|
+
// boundary; arbitrary issue strings can contain private values.
|
|
283
|
+
return Object.freeze({ status: 'invalid', revision: current.revision,
|
|
284
|
+
errors: Object.freeze([Object.freeze({ path: '$', message: '完整配置未通过运行校验' })]),
|
|
285
|
+
missingEnv: Object.freeze([...missingEnv].sort()), message: '配置校验失败,未保存' });
|
|
286
|
+
}
|
|
287
|
+
phase = 'commit';
|
|
288
|
+
if (mode === 'scoped')
|
|
289
|
+
committed = await prepared.commit();
|
|
290
|
+
else {
|
|
291
|
+
try {
|
|
292
|
+
committed = await prepared.commit();
|
|
293
|
+
}
|
|
294
|
+
catch (error) {
|
|
295
|
+
if (error instanceof ConfigDocumentConflictError) {
|
|
296
|
+
phase = 'prepare';
|
|
297
|
+
throw error;
|
|
298
|
+
}
|
|
299
|
+
throw new ConfigCommitUnconfirmedError(error);
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
catch (error) {
|
|
304
|
+
if (error instanceof ConfigDocumentConflictError)
|
|
305
|
+
return await conflict(await this.#document.read());
|
|
306
|
+
throw error;
|
|
307
|
+
}
|
|
308
|
+
phase = 'committed';
|
|
309
|
+
const applied = await this.#applySaved(sections.some(section => HOST_CONFIG_KEYS.includes(section)), committed.revision);
|
|
310
|
+
return Object.freeze({
|
|
311
|
+
status: 'saved',
|
|
312
|
+
revision: committed.revision,
|
|
313
|
+
...applied,
|
|
314
|
+
paths,
|
|
315
|
+
missingEnv: Object.freeze([...missingEnv].sort()),
|
|
316
|
+
message: applied.applicationError === 'superseded' ? '配置已保存,但该次生效申请已被替代'
|
|
317
|
+
: applied.applicationError === 'invalid' ? '配置已保存,但配置校验失败,未生效'
|
|
318
|
+
: applied.applicationError ? '配置已保存,生效结果未确认' : applied.restartRequired
|
|
319
|
+
? (applied.application ? '配置已保存,部分配置待重启生效' : '配置已保存,需重启进程才能生效') : applied.application
|
|
320
|
+
? '配置已保存,请查看各项生效结果' : '配置已保存',
|
|
321
|
+
});
|
|
322
|
+
}
|
|
323
|
+
catch (error) {
|
|
324
|
+
if (mode === 'chat') {
|
|
325
|
+
if (phase === 'prepare') {
|
|
326
|
+
if (error instanceof ConfigChatAccessError)
|
|
327
|
+
throw error;
|
|
328
|
+
throw new ConfigChatRequestRejectedError();
|
|
329
|
+
}
|
|
330
|
+
// A callback cannot reclassify a crossed commit as a pre-save failure.
|
|
331
|
+
throw new Error('Configuration operation outcome is unconfirmed', { cause: error });
|
|
332
|
+
}
|
|
333
|
+
throw error;
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
/**
|
|
337
|
+
* Trusted project read (`config:get-project`, full scope only). Values are masked by
|
|
338
|
+
* section schema, secret-like keys and unexpanded environment references unless the
|
|
339
|
+
* caller explicitly asks to `reveal`; a section whose schema cannot be read is masked whole.
|
|
340
|
+
*/
|
|
341
|
+
readProject(request = {}) {
|
|
342
|
+
return this.#afterMutations(async () => {
|
|
343
|
+
const { document, revision } = await this.#document.read();
|
|
344
|
+
const path = Object.freeze([...(request.path ?? [])]);
|
|
345
|
+
const reveal = request.reveal === true;
|
|
346
|
+
const value = path.length ? readPath(document, path) : document;
|
|
347
|
+
if (value === undefined)
|
|
348
|
+
return Object.freeze({ revision, path, exists: false, redacted: !reveal });
|
|
349
|
+
// Only a verbatim whole-document reply needs its own copy; masking builds new values.
|
|
350
|
+
if (reveal)
|
|
351
|
+
return Object.freeze({ revision, path, exists: true, value: path.length ? value : structuredClone(document), redacted: false });
|
|
352
|
+
let masked;
|
|
353
|
+
if (path.length)
|
|
354
|
+
masked = await this.#maskedValue(document, path, { unknownSchema: 'redact' });
|
|
355
|
+
else {
|
|
356
|
+
const keys = Object.keys(document);
|
|
357
|
+
const schemas = await this.#schemas.readJsonMany(keys, document);
|
|
358
|
+
masked = Object.freeze(Object.fromEntries(keys.map(key => {
|
|
359
|
+
const schema = schemas.get(key);
|
|
360
|
+
// Fail closed: without the schema we cannot rule out a schema-marked secret under an ordinary key.
|
|
361
|
+
return [key, schema instanceof Error || schema === undefined ? REDACTED_CONFIG_VALUE
|
|
362
|
+
: maskConfigValue(schema, [key], document[key], { unknownSchema: 'redact' })];
|
|
363
|
+
})));
|
|
364
|
+
}
|
|
365
|
+
return Object.freeze({ revision, path, exists: true, value: masked, redacted: true });
|
|
366
|
+
});
|
|
367
|
+
}
|
|
368
|
+
/** Conflict evidence for trusted writes: never echo secrets or unexpanded references. */
|
|
369
|
+
async #maskedValue(document, path, options) {
|
|
370
|
+
const value = readPath(document, path);
|
|
371
|
+
if (value === undefined)
|
|
372
|
+
return undefined;
|
|
373
|
+
let schema;
|
|
374
|
+
// Fail closed: without the schema we cannot rule out a schema-marked secret under an ordinary key.
|
|
375
|
+
try {
|
|
376
|
+
schema = await this.#schemas.readJson(path[0], document);
|
|
377
|
+
}
|
|
378
|
+
catch {
|
|
379
|
+
return REDACTED_CONFIG_VALUE;
|
|
380
|
+
}
|
|
381
|
+
return maskConfigValue(schema, path, value, options);
|
|
382
|
+
}
|
|
383
|
+
async #chatSchema(document, path) {
|
|
384
|
+
if (!path.length)
|
|
385
|
+
throw new ConfigChatAccessError('leaf');
|
|
386
|
+
try {
|
|
387
|
+
return await this.#schemas.readJson(path[0], document);
|
|
388
|
+
}
|
|
389
|
+
catch {
|
|
390
|
+
throw new ConfigChatAccessError('unknown');
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
async #assertChatPath(document, path, values, deep = true) {
|
|
394
|
+
assertChatConfigurationScope(path, CHAT_SCOPE);
|
|
395
|
+
const schema = await this.#chatSchema(document, path);
|
|
396
|
+
assertChatConfigurationAccess(schema, path.slice(1), values, deep, path);
|
|
397
|
+
chatLeafTypes(schema, path.slice(1), values);
|
|
398
|
+
return schema;
|
|
399
|
+
}
|
|
400
|
+
/** Leaves under `prefix` that pass the same scope, leaf and value checks as `get`. */
|
|
401
|
+
async #chatFields(document, prefix) {
|
|
402
|
+
if (prefix.length) {
|
|
403
|
+
if (CHAT_SCOPE.declarationKeys.has(prefix[0]))
|
|
404
|
+
throw new ConfigChatAccessError('scope');
|
|
405
|
+
if (!CHAT_SCOPE.hostKeys.includes(prefix[0]))
|
|
406
|
+
assertChatConfigurationScope(prefix, CHAT_SCOPE);
|
|
407
|
+
}
|
|
408
|
+
const roots = [];
|
|
409
|
+
if (!prefix.length || CHAT_SCOPE.hostKeys.includes(prefix[0]))
|
|
410
|
+
roots.push(...chatHostRoots(prefix, CHAT_SCOPE));
|
|
411
|
+
if (!prefix.length) {
|
|
412
|
+
for (const key of await this.#listKeys(document)) {
|
|
413
|
+
if (!CHAT_SCOPE.hostKeys.includes(key) && !CHAT_SCOPE.declarationKeys.has(key))
|
|
414
|
+
roots.push([key]);
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
else if (!CHAT_SCOPE.hostKeys.includes(prefix[0]))
|
|
418
|
+
roots.push(prefix);
|
|
419
|
+
if (prefix.length && !roots.length)
|
|
420
|
+
throw new ConfigChatAccessError('scope');
|
|
421
|
+
const fields = [];
|
|
422
|
+
for (const root of roots) {
|
|
423
|
+
let schema;
|
|
424
|
+
try {
|
|
425
|
+
schema = await this.#chatSchema(document, root);
|
|
426
|
+
}
|
|
427
|
+
catch (error) {
|
|
428
|
+
if (error instanceof ConfigChatAccessError)
|
|
429
|
+
continue;
|
|
430
|
+
throw error;
|
|
431
|
+
}
|
|
432
|
+
for (const relative of chatConfigurationLeaves(schema, root.slice(1), MAX_CHAT_FIELDS)) {
|
|
433
|
+
if (fields.length >= MAX_CHAT_FIELDS)
|
|
434
|
+
break;
|
|
435
|
+
const path = Object.freeze([root[0], ...relative]);
|
|
436
|
+
try {
|
|
437
|
+
await this.#assertChatPath(document, path, [readPath(document, path)]);
|
|
438
|
+
fields.push(Object.freeze({ path, type: chatLeafTypes(schema, relative) }));
|
|
439
|
+
}
|
|
440
|
+
catch (error) {
|
|
441
|
+
if (!(error instanceof ConfigChatAccessError))
|
|
442
|
+
throw error;
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
return Object.freeze(fields);
|
|
447
|
+
}
|
|
448
|
+
/**
|
|
449
|
+
* Host-authenticated owner chat reads (C1). `get` returns one proven-safe leaf;
|
|
450
|
+
* `fields` lists manageable leaves and their types without values. Denials carry
|
|
451
|
+
* only a reason code.
|
|
452
|
+
*/
|
|
453
|
+
readChat(path, mode) {
|
|
454
|
+
return this.#afterMutations(async () => {
|
|
455
|
+
try {
|
|
456
|
+
if (!Array.isArray(path) || path.some(key => typeof key !== 'string' || !key || isForbiddenConfigurationPathSegment(key)))
|
|
457
|
+
throw new ConfigChatAccessError('unknown');
|
|
458
|
+
const { document, revision } = await this.#document.read();
|
|
459
|
+
if (mode === 'fields')
|
|
460
|
+
return Object.freeze({ revision, fields: await this.#chatFields(document, path) });
|
|
461
|
+
assertChatConfigurationScope(path, CHAT_SCOPE);
|
|
462
|
+
const schema = await this.#chatSchema(document, path);
|
|
463
|
+
chatLeafTypes(schema, path.slice(1));
|
|
464
|
+
const value = readPath(document, path);
|
|
465
|
+
await this.#assertChatPath(document, path, [value]);
|
|
466
|
+
if (value === undefined)
|
|
467
|
+
throw new ConfigChatAccessError('missing');
|
|
468
|
+
return Object.freeze({ revision, value });
|
|
469
|
+
}
|
|
470
|
+
catch (error) {
|
|
471
|
+
if (error instanceof ConfigChatAccessError)
|
|
472
|
+
throw error;
|
|
473
|
+
throw new ConfigChatAccessError('unknown');
|
|
474
|
+
}
|
|
475
|
+
});
|
|
476
|
+
}
|
|
477
|
+
async #validatePrepared(prepared) {
|
|
478
|
+
try {
|
|
479
|
+
await this.#validateConfiguration?.(prepared.document);
|
|
480
|
+
}
|
|
481
|
+
catch (error) {
|
|
482
|
+
const failures = [error];
|
|
483
|
+
try {
|
|
484
|
+
await prepared.rollback();
|
|
485
|
+
}
|
|
486
|
+
catch (rollback) {
|
|
487
|
+
failures.push(rollback);
|
|
488
|
+
}
|
|
489
|
+
if (failures.length > 1)
|
|
490
|
+
throw new AggregateError(failures, 'Configuration validation and preparation cleanup failed', { cause: error });
|
|
491
|
+
throw error;
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
apply(expectedRevision) {
|
|
495
|
+
return this.#serialize(async () => {
|
|
496
|
+
const current = await this.#document.read();
|
|
497
|
+
if (current.revision !== expectedRevision)
|
|
498
|
+
return Object.freeze({ status: 'conflict', revision: current.revision,
|
|
499
|
+
message: '配置已被其他地方修改,请重新读取后申请应用' });
|
|
500
|
+
if (!this.#applyConfiguration)
|
|
501
|
+
return Object.freeze({ status: 'unconfirmed', message: '当前 Host 未提供配置应用能力' });
|
|
502
|
+
try {
|
|
503
|
+
const application = await this.#applyConfiguration(expectedRevision);
|
|
504
|
+
this.#applicationError = undefined;
|
|
505
|
+
return Object.freeze({ status: 'applied', application });
|
|
506
|
+
}
|
|
507
|
+
catch (error) {
|
|
508
|
+
if (error instanceof ConfigurationSupersededError)
|
|
509
|
+
return Object.freeze({ status: 'conflict',
|
|
510
|
+
revision: (await this.#document.read()).revision, message: '配置已被较新修订替代,请重新读取' });
|
|
511
|
+
if (error instanceof ConfigValidationError)
|
|
512
|
+
return Object.freeze({ status: 'invalid', message: '配置校验失败,未应用' });
|
|
513
|
+
this.#applicationError = 'unconfirmed';
|
|
514
|
+
this.#applicationErrorAtOperationId = this.#readApplication?.()?.operationId;
|
|
515
|
+
return Object.freeze({ status: 'unconfirmed', message: '配置生效结果未确认,请读取运行状态核对' });
|
|
516
|
+
}
|
|
517
|
+
});
|
|
518
|
+
}
|
|
519
|
+
async #applySaved(legacyRestartRequired, expectedRevision) {
|
|
520
|
+
if (!this.#applyConfiguration)
|
|
521
|
+
return Object.freeze({ restartRequired: legacyRestartRequired });
|
|
522
|
+
try {
|
|
523
|
+
const application = await this.#applyConfiguration(expectedRevision);
|
|
524
|
+
this.#applicationError = undefined;
|
|
525
|
+
return Object.freeze({ application,
|
|
526
|
+
restartRequired: application.groups.some(group => group.status === 'restart-pending') });
|
|
527
|
+
}
|
|
528
|
+
catch (error) {
|
|
529
|
+
// Saving has already succeeded. An exception cannot prove whether handoff
|
|
530
|
+
// published a generation; do not turn it into a misleading save failure.
|
|
531
|
+
this.#applicationError = error instanceof ConfigurationSupersededError ? 'superseded'
|
|
532
|
+
: error instanceof ConfigValidationError ? 'invalid' : 'unconfirmed';
|
|
533
|
+
this.#applicationErrorAtOperationId = this.#readApplication?.()?.operationId;
|
|
534
|
+
return Object.freeze({ restartRequired: false, applicationError: this.#applicationError });
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
async #ownSchema(document, instanceKey) {
|
|
538
|
+
if (HOST_CONFIG_KEYS.includes(instanceKey))
|
|
539
|
+
return null;
|
|
540
|
+
try {
|
|
541
|
+
assertUnambiguousConfigKey(document, instanceKey);
|
|
542
|
+
}
|
|
543
|
+
catch {
|
|
544
|
+
return null;
|
|
545
|
+
}
|
|
546
|
+
return this.#schemas.readJson(instanceKey, document);
|
|
547
|
+
}
|
|
548
|
+
readEnvironmentFile(filename) {
|
|
549
|
+
return readEnvFile(this.#projectRoot, filename);
|
|
550
|
+
}
|
|
551
|
+
writeEnvironmentFile(filename, content) {
|
|
552
|
+
return writeEnvFile(this.#projectRoot, filename, content);
|
|
553
|
+
}
|
|
554
|
+
async readSchema(pluginName) {
|
|
555
|
+
const { document } = await this.#document.read();
|
|
556
|
+
if (pluginName && !HOST_CONFIG_KEYS.includes(pluginName)
|
|
557
|
+
&& !readApplicationPluginDeclaration(document).instances.some(item => item.instanceKey === pluginName)) {
|
|
558
|
+
// Installed but undeclared Plugin package (`needsConfiguration`): read-only form for the declare flow.
|
|
559
|
+
const undeclared = await this.#schemas.readUndeclared(pluginName, document);
|
|
560
|
+
if (undeclared !== undefined) {
|
|
561
|
+
// Package resolution is async: re-check against the latest document so a declaration
|
|
562
|
+
// committed meanwhile is not reported as `declared: false`.
|
|
563
|
+
const latest = (await this.#document.read()).document;
|
|
564
|
+
const stillUndeclared = await this.#schemas.readUndeclared(pluginName, latest);
|
|
565
|
+
if (stillUndeclared !== undefined)
|
|
566
|
+
return stillUndeclared;
|
|
567
|
+
await this.#assertUnambiguousKey(latest, pluginName);
|
|
568
|
+
return this.#schemas.read(pluginName, latest);
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
if (pluginName)
|
|
572
|
+
await this.#assertUnambiguousKey(document, pluginName);
|
|
573
|
+
return this.#schemas.read(pluginName, document);
|
|
574
|
+
}
|
|
575
|
+
async validatePluginConfig(pluginName, data, capturedDocument) {
|
|
576
|
+
const document = capturedDocument ?? (await this.#document.read()).document;
|
|
577
|
+
await this.#assertUnambiguousKey(document, pluginName);
|
|
578
|
+
const schema = await this.#schemas.readJson(pluginName, document);
|
|
579
|
+
const errors = [];
|
|
580
|
+
const missingEnv = new Set();
|
|
581
|
+
const candidate = schema ? parsePluginConfigSchema(schema, data) : { value: data, errors: [] };
|
|
582
|
+
if (pluginName === 'ai') {
|
|
583
|
+
// Startup composition deliberately retains legacy data for explicit
|
|
584
|
+
// migration. A normal structured save must still pass its real consumer.
|
|
585
|
+
const { loadAiConfigUtils } = await import('./ai-config-loader.js');
|
|
586
|
+
const validate = loadAiConfigUtils(this.#projectRoot)?.validateModelRuntimeConfig;
|
|
587
|
+
const messages = validate
|
|
588
|
+
? validate(candidate.errors.length === 0 ? candidate.value : data, hostModelOwnerId(this.#projectRoot))
|
|
589
|
+
: ['AI configuration validation requires the installed Agent package'];
|
|
590
|
+
if (messages.length)
|
|
591
|
+
return Object.freeze({ schemaAvailable: schema !== null, valid: false,
|
|
592
|
+
errors: Object.freeze(messages.map(message => ({ path: '$', message }))), missingEnv: Object.freeze([]) });
|
|
593
|
+
}
|
|
594
|
+
const environmentName = this.#environmentName();
|
|
595
|
+
const environment = this.#environmentForPlugin?.(pluginName) ?? createEnvStore(rootPluginId(), { name: environmentName, mode: environmentName === 'production' ? 'production' : 'development', platform: 'node' }, await loadRuntimeEnvironmentLayers(this.#projectRoot, environmentName));
|
|
596
|
+
if (schema) {
|
|
597
|
+
for (const issue of candidate.errors) {
|
|
598
|
+
const location = issue.instancePath.replace(/^\/candidate/u, '').split('/').slice(1)
|
|
599
|
+
.map(key => key.replaceAll('~1', '/').replaceAll('~0', '~'));
|
|
600
|
+
if (issue.keyword === 'required' && typeof issue.params.missingProperty === 'string') {
|
|
601
|
+
location.push(issue.params.missingProperty);
|
|
602
|
+
}
|
|
603
|
+
errors.push({
|
|
604
|
+
path: `$${location.map(key => `.${key}`).join('')}`,
|
|
605
|
+
message: issue.keyword === 'type'
|
|
606
|
+
? `类型应为 ${issue.params.type === 'array' ? 'list' : String(issue.params.type)}`
|
|
607
|
+
: issue.message ?? issue.keyword,
|
|
608
|
+
});
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
collectEnvironmentReferences(candidate.value, missingEnv, environment);
|
|
612
|
+
return Object.freeze({
|
|
613
|
+
schemaAvailable: schema !== null,
|
|
614
|
+
valid: errors.length === 0,
|
|
615
|
+
errors: Object.freeze(errors),
|
|
616
|
+
missingEnv: Object.freeze([...missingEnv].sort()),
|
|
617
|
+
});
|
|
618
|
+
}
|
|
619
|
+
async readAllSchemas() {
|
|
620
|
+
return this.#afterMutations(async () => {
|
|
621
|
+
const { document } = await this.#document.read();
|
|
622
|
+
return this.#schemas.readAll(await this.#listKeys(document), document);
|
|
623
|
+
});
|
|
624
|
+
}
|
|
625
|
+
listKeys() {
|
|
626
|
+
return this.#afterMutations(async () => {
|
|
627
|
+
const { document } = await this.#document.read();
|
|
628
|
+
return this.#listKeys(document);
|
|
629
|
+
});
|
|
630
|
+
}
|
|
631
|
+
async #listKeys(document) {
|
|
632
|
+
const keys = new Set(Object.keys(readPluginConfigurationMap(document)));
|
|
633
|
+
for (const key of HOST_CONFIG_KEYS)
|
|
634
|
+
keys.add(key);
|
|
635
|
+
for (const key of (await readPluginPackageMap(this.#projectRoot, document)).keys())
|
|
636
|
+
keys.add(key);
|
|
637
|
+
return [...keys].sort((left, right) => left.localeCompare(right));
|
|
638
|
+
}
|
|
639
|
+
async #assertUnambiguousKey(document, key) {
|
|
640
|
+
assertUnambiguousConfigKey(document, key);
|
|
641
|
+
if (HOST_CONFIG_KEYS.includes(key) && (await readPluginPackageMap(this.#projectRoot, document)).has(key)) {
|
|
642
|
+
throw new Error(`配置键 ${key} 同时指向 Host 与已声明插件;请在配置源码中明确编辑 ${key} 或 plugins.${key}`);
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
#afterMutations(operation) {
|
|
646
|
+
return this.#mutationTail.then(operation);
|
|
647
|
+
}
|
|
648
|
+
/**
|
|
649
|
+
* Lock order: this Console store's domain lock (the mutation tail) is taken first,
|
|
650
|
+
* then the Root configuration queue via `#applyConfiguration` / `#validateConfiguration`.
|
|
651
|
+
* Root control must never call back into this store while holding its queue, or the
|
|
652
|
+
* two locks would wait on each other.
|
|
653
|
+
*/
|
|
654
|
+
#serialize(operation) {
|
|
655
|
+
const result = this.#mutationTail.then(operation);
|
|
656
|
+
this.#mutationTail = result.then(() => undefined, () => undefined);
|
|
657
|
+
return result;
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
/** Project patches may write Host and declared Plugin business config, never declarations. */
|
|
661
|
+
function projectDeclarationErrors(document, paths) {
|
|
662
|
+
const declared = new Set(readApplicationPluginDeclaration(document).instances.map(instance => instance.instanceKey));
|
|
663
|
+
const errors = [];
|
|
664
|
+
for (const section of new Set(paths.map(path => path[0]))) {
|
|
665
|
+
if (PROJECT_PATCH_DECLARATION_KEYS.has(section)) {
|
|
666
|
+
errors.push({ path: `$.${section}`, message: '插件声明与根插件配置请用 gateway.plugins.declare、配置源码或插件管理修改' });
|
|
667
|
+
}
|
|
668
|
+
else if (!HOST_CONFIG_KEYS.includes(section) && !declared.has(section)) {
|
|
669
|
+
errors.push({ path: `$.${section}`, message: '未声明的插件实例;请先用 gateway.plugins.declare 声明' });
|
|
670
|
+
}
|
|
671
|
+
}
|
|
672
|
+
return errors;
|
|
673
|
+
}
|
|
674
|
+
function collectEnvironmentReferences(value, missingEnv, environment) {
|
|
675
|
+
if (typeof value === 'string') {
|
|
676
|
+
for (const name of value.matchAll(/\$\{([A-Za-z_][A-Za-z0-9_]*)\}/gu)) {
|
|
677
|
+
if (!environment.has(name[1]))
|
|
678
|
+
missingEnv.add(name[1]);
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
if (value && typeof value === 'object') {
|
|
682
|
+
for (const item of Object.values(value))
|
|
683
|
+
collectEnvironmentReferences(item, missingEnv, environment);
|
|
684
|
+
}
|
|
685
|
+
}
|
|
686
|
+
export { configKeyPatch, configKeyRemovePatch, flattenConfigDocument } from './configuration-projection.js';
|