@zhin.js/gateway 1.1.1 → 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/lib/config/configuration-chat-policy.js +2 -2
- package/lib/config/configuration-redaction.d.ts +2 -8
- package/lib/config/configuration-redaction.js +15 -68
- package/lib/config/console-schema.js +4 -3
- package/lib/config/environment-files.d.ts +1 -0
- package/lib/config/environment-files.js +4 -11
- package/lib/config/literal-secrets.d.ts +34 -0
- package/lib/config/literal-secrets.js +136 -0
- package/lib/config.d.ts +1 -0
- package/lib/config.js +1 -0
- package/lib/http/console-rpc.d.ts +9 -1
- package/lib/http/gateway-events.d.ts +5 -0
- package/lib/http/gateway-events.js +16 -3
- package/lib/http/gateway-http-server.js +11 -7
- package/lib/http/plugin-switch-rpc.d.ts +19 -0
- package/lib/http/plugin-switch-rpc.js +214 -0
- package/lib/http/start-gateway.js +6 -0
- package/lib/local/handler.js +1 -1
- package/lib/packages/authorization.d.ts +2 -1
- package/lib/packages/authorization.js +20 -1
- package/lib/packages/rpc.js +5 -0
- package/lib/packages/service.d.ts +18 -0
- package/lib/packages/service.js +66 -3
- package/lib/plugin-management/config-explanation.js +3 -3
- package/lib/plugin-management/workflow.d.ts +44 -0
- package/lib/plugin-management/workflow.js +109 -3
- package/package.json +5 -5
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Console's global plugin switch, answered by the gateway (ADR 0054 §4, ADR 0050 §4):
|
|
3
|
+
* `plugin:set-enabled` and `feature.set-enabled` with `level: 'global'` both write
|
|
4
|
+
* `disable_plugins`, which is plugin lifecycle rather than a feature switch. The gateway
|
|
5
|
+
* intercepts them on `POST {base}/console/request` before anything is forwarded, authorizes them
|
|
6
|
+
* with `plugin.manage` (the shared permission map refines `feature.set-enabled` by level), and
|
|
7
|
+
* runs the enable / disable transaction of {@link GatewayPackageService.setEnabled}: stop the
|
|
8
|
+
* instance → write `disable_plugins` → start it, restoring the file and the old instance on
|
|
9
|
+
* failure. `feature.set-enabled` below the global level is forwarded to the instance untouched.
|
|
10
|
+
*
|
|
11
|
+
* Replies keep the shapes the instance used to send, so a published Console keeps working:
|
|
12
|
+
* - `plugin:set-enabled`: `{ success, instanceKey, enabled, disabled, restartRequired, message }`;
|
|
13
|
+
* - `feature.set-enabled`: `ConsoleFeatureWriteResult` (`saved` / `unchanged`) with the
|
|
14
|
+
* configuration revision after the write and the override warnings of the new state.
|
|
15
|
+
* Both add `transaction` (the gateway transaction result). A failed or rolled-back transaction
|
|
16
|
+
* answers the RPC's error envelope (`success: false`, `code`, `error`) with `transaction`.
|
|
17
|
+
*/
|
|
18
|
+
import { join } from 'node:path';
|
|
19
|
+
import { auditActor, authorizeConsoleRpc, jsonResponse, rpcAuditDetail, rpcAuditTarget, } from '@zhin.js/host-http';
|
|
20
|
+
import { FEATURE_RPC, PLUGIN_RPC, isGlobalFeatureSwitch, } from '@zhin.js/console-protocol';
|
|
21
|
+
import { DEFAULT_ROOT_CONFIG_FILE_NAME, FEATURE_OVERRIDES_KEY } from '@zhin.js/plugin-runtime';
|
|
22
|
+
import { collectFeatureOverrideWarnings, createConfigDocument } from '@zhin.js/runtime';
|
|
23
|
+
import { formatCompact, getLogger } from '@zhin.js/logger';
|
|
24
|
+
import { GatewayError } from '../packages/errors.js';
|
|
25
|
+
import { findConfigFile } from '../project/config-file.js';
|
|
26
|
+
const logger = getLogger('gateway');
|
|
27
|
+
/** Top-level plugin instance alias (the global switch only applies to top-level instances). */
|
|
28
|
+
const INSTANCE_KEY = /^[a-z0-9][a-z0-9-]*$/u;
|
|
29
|
+
/** Whether the gateway answers this Console RPC message itself (the global plugin switch). */
|
|
30
|
+
export function isGatewayPluginSwitch(message) {
|
|
31
|
+
return message.type === PLUGIN_RPC.SET_ENABLED || isGlobalFeatureSwitch(message);
|
|
32
|
+
}
|
|
33
|
+
export function createGatewayPluginSwitchRpc(options) {
|
|
34
|
+
const readConfig = options.readConfig ?? (async () => {
|
|
35
|
+
const file = join(options.projectRoot, findConfigFile(options.projectRoot) ?? DEFAULT_ROOT_CONFIG_FILE_NAME);
|
|
36
|
+
const { revision, document } = await createConfigDocument(file).read();
|
|
37
|
+
return { revision, document: document };
|
|
38
|
+
});
|
|
39
|
+
return Object.freeze({
|
|
40
|
+
owns: (type) => type === PLUGIN_RPC.SET_ENABLED || type === FEATURE_RPC.SET_ENABLED,
|
|
41
|
+
claims: isGatewayPluginSwitch,
|
|
42
|
+
async handle(message, context, registry) {
|
|
43
|
+
const noStore = { 'cache-control': 'no-store' };
|
|
44
|
+
const type = String(message.type ?? '');
|
|
45
|
+
const requestId = message.requestId;
|
|
46
|
+
const principal = context.principal;
|
|
47
|
+
if (!principal) {
|
|
48
|
+
return jsonResponse(401, { success: false, code: 'unauthorized', error: 'Invalid or missing token', requestId }, noStore);
|
|
49
|
+
}
|
|
50
|
+
// plugin.manage: `plugin:set-enabled` is mapped to it, `feature.set-enabled` is refined by level.
|
|
51
|
+
const decision = authorizeConsoleRpc(principal, message);
|
|
52
|
+
const audit = (outcome) => {
|
|
53
|
+
try {
|
|
54
|
+
registry.accessProvider?.audit?.({
|
|
55
|
+
actor: auditActor(principal),
|
|
56
|
+
action: `rpc:${type}`.slice(0, 256),
|
|
57
|
+
...(decision.allowed && rpcAuditTarget(decision.rule) ? { target: rpcAuditTarget(decision.rule) } : {}),
|
|
58
|
+
outcome,
|
|
59
|
+
detail: rpcAuditDetail(message),
|
|
60
|
+
...(context.remoteAddress ? { ip: context.remoteAddress } : {}),
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
catch { /* audit never breaks a request */ }
|
|
64
|
+
};
|
|
65
|
+
if (!decision.allowed) {
|
|
66
|
+
audit('denied');
|
|
67
|
+
return jsonResponse(403, { success: false, code: decision.code, error: decision.error, requestId }, noStore);
|
|
68
|
+
}
|
|
69
|
+
const reply = type === PLUGIN_RPC.SET_ENABLED
|
|
70
|
+
? await setPluginEnabled(message, principal, options, readConfig)
|
|
71
|
+
: await setFeatureGlobal(message, principal, options, readConfig);
|
|
72
|
+
audit(reply.status < 400 && reply.body.success !== false ? 'ok' : reply.status === 403 ? 'denied' : 'error');
|
|
73
|
+
return jsonResponse(reply.status, { ...reply.body, requestId }, noStore);
|
|
74
|
+
},
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
/** `plugin:set-enabled` `{ instanceKey, enabled }` (top-level fields, as the Console sends them). */
|
|
78
|
+
async function setPluginEnabled(message, principal, options, readConfig) {
|
|
79
|
+
const type = PLUGIN_RPC.SET_ENABLED;
|
|
80
|
+
const instanceKey = typeof message.instanceKey === 'string' ? message.instanceKey.trim() : '';
|
|
81
|
+
const enabled = message.enabled;
|
|
82
|
+
if (!instanceKey || typeof enabled !== 'boolean')
|
|
83
|
+
return error(400, 'invalid_input', 'instanceKey and boolean enabled are required', type);
|
|
84
|
+
const outcome = await runSwitch(instanceKey, enabled, principal, options, type);
|
|
85
|
+
if ('reply' in outcome)
|
|
86
|
+
return outcome.reply;
|
|
87
|
+
const { result } = outcome;
|
|
88
|
+
const config = await readConfig().catch(() => undefined);
|
|
89
|
+
const key = result.plugin?.instanceKey ?? instanceKey;
|
|
90
|
+
const changed = result.plugin?.changed === true;
|
|
91
|
+
return {
|
|
92
|
+
status: 200,
|
|
93
|
+
body: {
|
|
94
|
+
success: true,
|
|
95
|
+
type,
|
|
96
|
+
data: {
|
|
97
|
+
success: true,
|
|
98
|
+
instanceKey: key,
|
|
99
|
+
enabled,
|
|
100
|
+
disabled: disabledPlugins(config?.document),
|
|
101
|
+
// The gateway restarted the instance for a change (it already is back): the Console re-syncs.
|
|
102
|
+
restartRequired: changed,
|
|
103
|
+
message: switchMessage(key, enabled, changed),
|
|
104
|
+
transaction: result,
|
|
105
|
+
},
|
|
106
|
+
},
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
/** `feature.set-enabled` with `level: 'global'`, payload in `data` (or on the message). */
|
|
110
|
+
async function setFeatureGlobal(message, principal, options, readConfig) {
|
|
111
|
+
const type = FEATURE_RPC.SET_ENABLED;
|
|
112
|
+
const data = message.data;
|
|
113
|
+
const input = (data && typeof data === 'object' && !Array.isArray(data) ? data : message);
|
|
114
|
+
const plugin = input.plugin;
|
|
115
|
+
if (typeof plugin !== 'string' || !plugin)
|
|
116
|
+
return error(400, undefined, '缺少 plugin', type);
|
|
117
|
+
if (input.key !== undefined && input.key !== '')
|
|
118
|
+
return error(400, undefined, 'global 层不需要 key', type);
|
|
119
|
+
if (!INSTANCE_KEY.test(plugin)) {
|
|
120
|
+
return error(400, undefined, plugin.includes('/') ? '全局开关只适用于顶层插件实例' : 'plugin 必须是插件实例别名', type);
|
|
121
|
+
}
|
|
122
|
+
if (typeof input.enabled !== 'boolean')
|
|
123
|
+
return error(400, undefined, 'enabled 必须是布尔值', type);
|
|
124
|
+
const enabled = input.enabled;
|
|
125
|
+
const outcome = await runSwitch(plugin, enabled, principal, options, type);
|
|
126
|
+
if ('reply' in outcome)
|
|
127
|
+
return outcome.reply;
|
|
128
|
+
const { result } = outcome;
|
|
129
|
+
const config = await readConfig().catch((failure) => {
|
|
130
|
+
logger.warn(formatCompact({ op: 'gateway_plugin_switch_config_read_failed', error: failure instanceof Error ? failure.message : String(failure) }));
|
|
131
|
+
return undefined;
|
|
132
|
+
});
|
|
133
|
+
const revision = config?.revision ?? '';
|
|
134
|
+
const changed = result.plugin?.changed === true;
|
|
135
|
+
if (!changed) {
|
|
136
|
+
return { status: 200, body: { success: true, type, data: Object.freeze({ status: 'unchanged', revision, warnings: Object.freeze([]), transaction: result }) } };
|
|
137
|
+
}
|
|
138
|
+
return {
|
|
139
|
+
status: 200,
|
|
140
|
+
body: {
|
|
141
|
+
success: true,
|
|
142
|
+
type,
|
|
143
|
+
data: Object.freeze({
|
|
144
|
+
status: 'saved',
|
|
145
|
+
revision,
|
|
146
|
+
// The instance was restarted by the gateway transaction (it already is back).
|
|
147
|
+
restartRequired: true,
|
|
148
|
+
warnings: config ? overrideWarnings(config.document, plugin) : Object.freeze([]),
|
|
149
|
+
message: switchMessage(plugin, enabled, true),
|
|
150
|
+
transaction: result,
|
|
151
|
+
}),
|
|
152
|
+
},
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
/** Runs the transaction; a refusal or a failed / rolled-back transaction becomes an error reply. */
|
|
156
|
+
async function runSwitch(plugin, enabled, principal, options, type) {
|
|
157
|
+
if (!options.packages)
|
|
158
|
+
return { reply: error(503, 'unavailable', '插件启停不可用:网关未管理依赖', type) };
|
|
159
|
+
let result;
|
|
160
|
+
try {
|
|
161
|
+
result = await options.packages.setEnabled({ plugin }, enabled, { principal });
|
|
162
|
+
}
|
|
163
|
+
catch (failure) {
|
|
164
|
+
if (failure instanceof GatewayError) {
|
|
165
|
+
return {
|
|
166
|
+
reply: error(failure.status, failure.code, failure.message, type, {
|
|
167
|
+
...(failure.details?.errors ? { errors: failure.details.errors } : {}),
|
|
168
|
+
...(failure.details?.missingEnv ? { missingEnv: failure.details.missingEnv } : {}),
|
|
169
|
+
}),
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
logger.warn(formatCompact({ op: 'gateway_plugin_switch_failed', plugin, error: failure instanceof Error ? failure.message : String(failure) }));
|
|
173
|
+
return { reply: error(500, 'internal', '插件启停失败', type) };
|
|
174
|
+
}
|
|
175
|
+
if (result.ok)
|
|
176
|
+
return { result };
|
|
177
|
+
const action = enabled ? '启用' : '停用';
|
|
178
|
+
const text = result.status === 'rollbackFailed'
|
|
179
|
+
? `${action} ${plugin} 失败,且回滚未完成:${result.rollbackError ?? '未知错误'}(原因:${result.error ?? '未知错误'})`
|
|
180
|
+
: `${action} ${plugin} 失败,已回滚:${result.error ?? '未知错误'}`;
|
|
181
|
+
return {
|
|
182
|
+
reply: error(500, result.status === 'rollbackFailed' ? 'rollback_failed' : 'transaction_failed', text, type, {
|
|
183
|
+
transaction: result,
|
|
184
|
+
...(result.errors?.length ? { errors: result.errors } : {}),
|
|
185
|
+
...(result.missingEnv?.length ? { missingEnv: result.missingEnv } : {}),
|
|
186
|
+
}),
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
function error(status, code, message, type, extra = {}) {
|
|
190
|
+
return { status, body: { success: false, ...(code ? { code } : {}), error: message, type, ...extra } };
|
|
191
|
+
}
|
|
192
|
+
function switchMessage(plugin, enabled, changed) {
|
|
193
|
+
const action = enabled ? '启用' : '停用';
|
|
194
|
+
return changed ? `${plugin} 已${action},实例已由管理网关重启` : `${plugin} 已处于${action}状态`;
|
|
195
|
+
}
|
|
196
|
+
function disabledPlugins(document) {
|
|
197
|
+
const list = document?.disable_plugins;
|
|
198
|
+
return Object.freeze(Array.isArray(list) ? list.filter((item) => typeof item === 'string') : []);
|
|
199
|
+
}
|
|
200
|
+
/** The global plugin switch's warnings about the plugin's own overrides (as the instance computed them). */
|
|
201
|
+
function overrideWarnings(document, plugin) {
|
|
202
|
+
const block = document[plugin];
|
|
203
|
+
const overrides = block && typeof block === 'object' && !Array.isArray(block) ? block[FEATURE_OVERRIDES_KEY] : undefined;
|
|
204
|
+
if (overrides === undefined)
|
|
205
|
+
return Object.freeze([]);
|
|
206
|
+
try {
|
|
207
|
+
const enabled = !disabledPlugins(document).includes(plugin);
|
|
208
|
+
return Object.freeze(collectFeatureOverrideWarnings(document, plugin, overrides, enabled)
|
|
209
|
+
.map(({ code, path, message }) => Object.freeze({ code, path, message })));
|
|
210
|
+
}
|
|
211
|
+
catch {
|
|
212
|
+
return Object.freeze([]);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
@@ -12,6 +12,7 @@ import { InstanceSupervisor } from '../supervisor/instance-supervisor.js';
|
|
|
12
12
|
import { GatewayPackageService } from '../packages/service.js';
|
|
13
13
|
import { createGatewayRpcDispatcher } from '../packages/rpc.js';
|
|
14
14
|
import { createGatewayConsoleRpc } from './console-rpc.js';
|
|
15
|
+
import { createGatewayPluginSwitchRpc } from './plugin-switch-rpc.js';
|
|
15
16
|
import { startGatewayConsole } from './gateway-console.js';
|
|
16
17
|
import { registerGatewayEventRoutes } from './gateway-events.js';
|
|
17
18
|
import { GatewayHttpServer } from './gateway-http-server.js';
|
|
@@ -114,6 +115,9 @@ export async function startGateway(options) {
|
|
|
114
115
|
consoleRpc: createGatewayConsoleRpc(rpc),
|
|
115
116
|
});
|
|
116
117
|
registry = server.tokenRegistry;
|
|
118
|
+
// The Console's global plugin switch is a gateway transaction (stop → write → start, rollback);
|
|
119
|
+
// registered even without package management so it is never forwarded to the instance.
|
|
120
|
+
const disposePluginSwitch = server.useConsoleRpc(createGatewayPluginSwitchRpc({ projectRoot: options.projectRoot, packages }));
|
|
117
121
|
const disposeEvents = registerGatewayEventRoutes({
|
|
118
122
|
server,
|
|
119
123
|
...(packages ? { packages } : {}),
|
|
@@ -146,6 +150,7 @@ export async function startGateway(options) {
|
|
|
146
150
|
}
|
|
147
151
|
catch (error) {
|
|
148
152
|
disposeEvents();
|
|
153
|
+
disposePluginSwitch();
|
|
149
154
|
await gatewayConsole?.close().catch(() => undefined);
|
|
150
155
|
await transport.dispose?.();
|
|
151
156
|
supervisor.dispose();
|
|
@@ -179,6 +184,7 @@ export async function startGateway(options) {
|
|
|
179
184
|
}
|
|
180
185
|
finally {
|
|
181
186
|
disposeEvents();
|
|
187
|
+
disposePluginSwitch();
|
|
182
188
|
await gatewayConsole?.close().catch(() => undefined);
|
|
183
189
|
supervisor.dispose();
|
|
184
190
|
await server.close();
|
package/lib/local/handler.js
CHANGED
|
@@ -53,7 +53,7 @@ async function handleHttp(options, data) {
|
|
|
53
53
|
const result = { status: response.status, headers, body: text };
|
|
54
54
|
return { ok: true, data: result };
|
|
55
55
|
}
|
|
56
|
-
const STREAMED = new Set([GATEWAY_RPC.INSTALL, GATEWAY_RPC.UNINSTALL, GATEWAY_RPC.UPDATE, GATEWAY_RPC.DECLARE]);
|
|
56
|
+
const STREAMED = new Set([GATEWAY_RPC.INSTALL, GATEWAY_RPC.UNINSTALL, GATEWAY_RPC.UPDATE, GATEWAY_RPC.DECLARE, GATEWAY_RPC.ENABLE, GATEWAY_RPC.DISABLE]);
|
|
57
57
|
export function createLocalRpcHandler(options) {
|
|
58
58
|
return async (request, emit) => {
|
|
59
59
|
if (request.type === LOCAL_GATEWAY_RPC.INFO)
|
|
@@ -18,6 +18,7 @@ export declare function gatewayPrincipalAllows(principal: GatewayPrincipal | und
|
|
|
18
18
|
* - bootstrap token: `install` of allowlisted database drivers only, before the first owner (ADR 0053 §3);
|
|
19
19
|
* - `plugin.manage` without a global owner binding: allowlisted packages only (ADR 0053 §3);
|
|
20
20
|
* - owners and the local socket: any npm package by name, with the same strict spec rules.
|
|
21
|
-
* `'all'` (update every dependency) is owner-only. `declare` takes bare names, like `uninstall
|
|
21
|
+
* `'all'` (update every dependency) is owner-only. `declare` takes bare names, like `uninstall`;
|
|
22
|
+
* `enable` / `disable` take the declared package (a bare name, or a `./` local plugin for owners).
|
|
22
23
|
*/
|
|
23
24
|
export declare function authorizePackages(principal: GatewayPrincipal | undefined, operation: GatewayTransactionOperation, requests: readonly unknown[] | 'all', options: AuthorizePackagesOptions): readonly GatewayAuthorizedPackage[];
|
|
@@ -3,6 +3,8 @@ import { authorizeInstall, parseInstallRequest } from '../onboarding/allowlist.j
|
|
|
3
3
|
import { OnboardingError } from '../onboarding/errors.js';
|
|
4
4
|
import { GatewayError } from './errors.js';
|
|
5
5
|
const MAX_PACKAGES = 32;
|
|
6
|
+
/** A project-local plugin package reference as declared in `plugins.<instanceKey>`. */
|
|
7
|
+
const LOCAL_PACKAGE = /^\.\/[A-Za-z0-9._@-]+(?:\/[A-Za-z0-9._@-]+)*$/u;
|
|
6
8
|
const EXACT_VERSION = /^\d{1,9}\.\d{1,9}\.\d{1,9}(?:-[0-9A-Za-z][0-9A-Za-z.-]{0,63})?$/u;
|
|
7
9
|
/** A global `owner` binding, or the local control socket. */
|
|
8
10
|
export function isGatewayOwner(principal) {
|
|
@@ -32,7 +34,8 @@ function rethrow(error) {
|
|
|
32
34
|
* - bootstrap token: `install` of allowlisted database drivers only, before the first owner (ADR 0053 §3);
|
|
33
35
|
* - `plugin.manage` without a global owner binding: allowlisted packages only (ADR 0053 §3);
|
|
34
36
|
* - owners and the local socket: any npm package by name, with the same strict spec rules.
|
|
35
|
-
* `'all'` (update every dependency) is owner-only. `declare` takes bare names, like `uninstall
|
|
37
|
+
* `'all'` (update every dependency) is owner-only. `declare` takes bare names, like `uninstall`;
|
|
38
|
+
* `enable` / `disable` take the declared package (a bare name, or a `./` local plugin for owners).
|
|
36
39
|
*/
|
|
37
40
|
export function authorizePackages(principal, operation, requests, options) {
|
|
38
41
|
if (!principal)
|
|
@@ -73,7 +76,21 @@ export function authorizePackages(principal, operation, requests, options) {
|
|
|
73
76
|
}
|
|
74
77
|
const seen = new Set();
|
|
75
78
|
const authorized = [];
|
|
79
|
+
const toggle = operation === 'enable' || operation === 'disable';
|
|
76
80
|
for (const request of requests) {
|
|
81
|
+
// enable / disable name the declared package, which may be a project-local plugin directory.
|
|
82
|
+
if (toggle && typeof request === 'string' && request.startsWith('./')) {
|
|
83
|
+
if (!LOCAL_PACKAGE.test(request) || request.split('/').slice(1).some(part => part === '.' || part === '..')) {
|
|
84
|
+
throw new GatewayError('invalid_input', `本地插件路径无效:${request.slice(0, 64)}`);
|
|
85
|
+
}
|
|
86
|
+
if (!owner)
|
|
87
|
+
throw new GatewayError('forbidden', '只有所有者可以启停本地插件');
|
|
88
|
+
if (seen.has(request))
|
|
89
|
+
throw new GatewayError('invalid_input', `重复的包:${request}`);
|
|
90
|
+
seen.add(request);
|
|
91
|
+
authorized.push(Object.freeze({ name: request, spec: request, source: 'local' }));
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
77
94
|
let parsed;
|
|
78
95
|
try {
|
|
79
96
|
parsed = parseInstallRequest(request);
|
|
@@ -93,6 +110,8 @@ export function authorizePackages(principal, operation, requests, options) {
|
|
|
93
110
|
throw new GatewayError('invalid_input', `卸载 ${name} 时不能指定版本`);
|
|
94
111
|
if (operation === 'declare' && version !== undefined)
|
|
95
112
|
throw new GatewayError('invalid_input', `声明 ${name} 时不能指定版本`);
|
|
113
|
+
if (toggle && version !== undefined)
|
|
114
|
+
throw new GatewayError('invalid_input', `启停 ${name} 时不能指定版本`);
|
|
96
115
|
if (operation === 'update' && version !== undefined && !EXACT_VERSION.test(version)) {
|
|
97
116
|
throw new GatewayError('invalid_input', `${name} 的更新版本必须是精确 semver 版本`);
|
|
98
117
|
}
|
package/lib/packages/rpc.js
CHANGED
|
@@ -64,6 +64,11 @@ async function handle(type, data, principal, deps) {
|
|
|
64
64
|
principal, ...optionalBoolean(data, 'startInstance'),
|
|
65
65
|
});
|
|
66
66
|
}
|
|
67
|
+
case 'gateway.plugins.enable':
|
|
68
|
+
case 'gateway.plugins.disable':
|
|
69
|
+
return packagesOf(deps).setEnabled({ plugin: data.plugin }, type === 'gateway.plugins.enable', {
|
|
70
|
+
principal, ...optionalBoolean(data, 'startInstance'),
|
|
71
|
+
});
|
|
67
72
|
case 'gateway.instance.status': return instanceStatus(instanceOf(deps).status());
|
|
68
73
|
case 'gateway.instance.logs': {
|
|
69
74
|
const lines = data.lines === undefined ? DEFAULT_LOG_LINES : data.lines;
|
|
@@ -36,6 +36,13 @@ export interface PackageWorkflowPort {
|
|
|
36
36
|
declare?(request: string, config?: unknown, options?: {
|
|
37
37
|
readonly instanceKey?: string;
|
|
38
38
|
}): Promise<unknown>;
|
|
39
|
+
/** Validates an enable / disable without writing (throws `PluginEnablementError`). */
|
|
40
|
+
planSetEnabled?(request: string, enabled: boolean): Promise<{
|
|
41
|
+
readonly packageName: string;
|
|
42
|
+
readonly instanceKey: string;
|
|
43
|
+
readonly changed: boolean;
|
|
44
|
+
}>;
|
|
45
|
+
setEnabled?(request: string, enabled: boolean): Promise<unknown>;
|
|
39
46
|
}
|
|
40
47
|
/**
|
|
41
48
|
* Runs the package manager with pnpm-style args (`add|remove|update|install …`), streaming output.
|
|
@@ -88,6 +95,10 @@ export interface GatewayDeclareRequest {
|
|
|
88
95
|
readonly instanceKey?: unknown;
|
|
89
96
|
readonly config?: unknown;
|
|
90
97
|
}
|
|
98
|
+
export interface GatewaySetEnabledRequest {
|
|
99
|
+
/** A declared instance key, or the package name of a package declared exactly once. */
|
|
100
|
+
readonly plugin: unknown;
|
|
101
|
+
}
|
|
91
102
|
export declare const DEFAULT_PACKAGE_READY_TIMEOUT_MS = 60000;
|
|
92
103
|
export declare const DEFAULT_PACKAGE_STABLE_MS = 5000;
|
|
93
104
|
export declare class GatewayPackageService {
|
|
@@ -111,6 +122,13 @@ export declare class GatewayPackageService {
|
|
|
111
122
|
* the configuration file back and starts the previous instance again.
|
|
112
123
|
*/
|
|
113
124
|
declare(request: GatewayDeclareRequest, options: GatewayTransactionOptions): Promise<GatewayPackageTransactionResult>;
|
|
125
|
+
/**
|
|
126
|
+
* `gateway.plugins.enable` / `gateway.plugins.disable`: rewrites `disable_plugins` for one
|
|
127
|
+
* declared instance, then restarts the instance (a failed start restores the configuration file
|
|
128
|
+
* and starts the previous instance again). Validation runs before the instance is touched; an
|
|
129
|
+
* instance already in the requested state succeeds without stopping anything or writing.
|
|
130
|
+
*/
|
|
131
|
+
setEnabled(request: GatewaySetEnabledRequest, enabled: boolean, options: GatewayTransactionOptions): Promise<GatewayPackageTransactionResult>;
|
|
114
132
|
uninstall(packages: readonly unknown[], options: GatewayUninstallOptions): Promise<GatewayPackageTransactionResult>;
|
|
115
133
|
/** Exact versions go through the workflow (verified); bare names / `'all'` run the manager's update. */
|
|
116
134
|
update(packages: readonly unknown[] | 'all', options: GatewayTransactionOptions): Promise<GatewayPackageTransactionResult>;
|
package/lib/packages/service.js
CHANGED
|
@@ -10,7 +10,7 @@ import { CONSOLE_PERMISSIONS } from '@zhin.js/console-protocol';
|
|
|
10
10
|
import { createInstallAllowlist } from '../onboarding/allowlist.js';
|
|
11
11
|
import { InstallOutputRelay, streamEvents } from '../onboarding/install.js';
|
|
12
12
|
import { createStreamingPackageManager, detectPackageManager } from '../onboarding/package-manager.js';
|
|
13
|
-
import { PluginDeclarationError, PluginManagementOperationError, PluginManagementWorkflow } from '../plugin-management/workflow.js';
|
|
13
|
+
import { PluginDeclarationError, PluginEnablementError, PluginManagementOperationError, PluginManagementWorkflow } from '../plugin-management/workflow.js';
|
|
14
14
|
import { InstanceStartError } from '../supervisor/instance-supervisor.js';
|
|
15
15
|
import { authorizePackages, gatewayPrincipalAllows } from './authorization.js';
|
|
16
16
|
import { GatewayError } from './errors.js';
|
|
@@ -20,6 +20,8 @@ export const DEFAULT_PACKAGE_STABLE_MS = 5_000;
|
|
|
20
20
|
const LOCK_FILE = 'gateway-packages.lock';
|
|
21
21
|
const LOG_TAIL_LINES = 50;
|
|
22
22
|
const RUNNING_STATES = new Set(['running', 'starting', 'backoff']);
|
|
23
|
+
/** Transactions that only write the configuration file: no package manager, no reinstall on rollback. */
|
|
24
|
+
const CONFIG_ONLY_OPERATIONS = new Set(['declare', 'enable', 'disable']);
|
|
23
25
|
/** Thrown inside a transaction when the instance does not come up; carries its log tail. */
|
|
24
26
|
class InstanceStartFailure extends Error {
|
|
25
27
|
logTail;
|
|
@@ -145,6 +147,60 @@ export class GatewayPackageService {
|
|
|
145
147
|
await ctx.workflow.declare(authorized[0].name, config, declareOptions);
|
|
146
148
|
});
|
|
147
149
|
}
|
|
150
|
+
/**
|
|
151
|
+
* `gateway.plugins.enable` / `gateway.plugins.disable`: rewrites `disable_plugins` for one
|
|
152
|
+
* declared instance, then restarts the instance (a failed start restores the configuration file
|
|
153
|
+
* and starts the previous instance again). Validation runs before the instance is touched; an
|
|
154
|
+
* instance already in the requested state succeeds without stopping anything or writing.
|
|
155
|
+
*/
|
|
156
|
+
async setEnabled(request, enabled, options) {
|
|
157
|
+
const workflow = this.#workflow;
|
|
158
|
+
if (!workflow.planSetEnabled || !workflow.setEnabled)
|
|
159
|
+
throw new GatewayError('unavailable', '插件启停不可用');
|
|
160
|
+
const operation = enabled ? 'enable' : 'disable';
|
|
161
|
+
// Permission before anything about the project is read.
|
|
162
|
+
if (!options.principal || options.principal !== 'local' && options.principal.kind === 'bootstrap'
|
|
163
|
+
|| !gatewayPrincipalAllows(options.principal, CONSOLE_PERMISSIONS.PLUGIN_MANAGE)) {
|
|
164
|
+
throw new GatewayError('forbidden', `需要 ${CONSOLE_PERMISSIONS.PLUGIN_MANAGE} 权限`);
|
|
165
|
+
}
|
|
166
|
+
if (typeof request.plugin !== 'string' || !request.plugin.trim() || request.plugin.length > 256) {
|
|
167
|
+
throw new GatewayError('invalid_input', 'plugin 必须是插件包名或 instanceKey');
|
|
168
|
+
}
|
|
169
|
+
let plan;
|
|
170
|
+
try {
|
|
171
|
+
plan = await workflow.planSetEnabled(request.plugin, enabled);
|
|
172
|
+
}
|
|
173
|
+
catch (error) {
|
|
174
|
+
throw enablementError(error);
|
|
175
|
+
}
|
|
176
|
+
const plugin = Object.freeze({ instanceKey: plan.instanceKey, packageName: plan.packageName, enabled, changed: plan.changed });
|
|
177
|
+
if (!plan.changed)
|
|
178
|
+
return this.#unchanged(operation, await this.authorize(options.principal, operation, [plan.packageName]), plugin, options);
|
|
179
|
+
return this.#transaction(operation, [plan.packageName], options, async (ctx) => {
|
|
180
|
+
// The exact instance key: the declaration may not be re-resolved to another instance.
|
|
181
|
+
await ctx.workflow.setEnabled(plan.instanceKey, enabled);
|
|
182
|
+
return { plugin };
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
/** The result of an enable / disable that found nothing to change (instance untouched). */
|
|
186
|
+
#unchanged(operation, authorized, plugin, options) {
|
|
187
|
+
const now = (this.#options.now ?? Date.now)();
|
|
188
|
+
const id = randomUUID();
|
|
189
|
+
const state = this.#instanceState();
|
|
190
|
+
const result = Object.freeze({
|
|
191
|
+
id, operation, packages: authorized, ok: true, status: 'succeeded', plugin,
|
|
192
|
+
instance: Object.freeze({ before: state, after: state }), startedAt: now, finishedAt: now,
|
|
193
|
+
});
|
|
194
|
+
for (const event of [{ type: 'start', id, operation, packages: authorized }, { type: 'phase', id, phase: 'done' }, { type: 'result', id, result }]) {
|
|
195
|
+
for (const listener of [options.onEvent, ...this.#listeners]) {
|
|
196
|
+
try {
|
|
197
|
+
listener?.(event);
|
|
198
|
+
}
|
|
199
|
+
catch { /* listeners never break a request */ }
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
return result;
|
|
203
|
+
}
|
|
148
204
|
uninstall(packages, options) {
|
|
149
205
|
return this.#transaction('uninstall', packages, options, async (ctx, authorized) => {
|
|
150
206
|
for (const item of authorized) {
|
|
@@ -229,7 +285,7 @@ export class GatewayPackageService {
|
|
|
229
285
|
// Rollback log tails only show what this transaction's instances printed.
|
|
230
286
|
const logMark = instance?.logs?.tail(1).at(-1)?.seq ?? -1;
|
|
231
287
|
// A declaration only writes the configuration file: no package manager, no reinstall on rollback.
|
|
232
|
-
const configOnly = operation
|
|
288
|
+
const configOnly = CONFIG_ONLY_OPERATIONS.has(operation);
|
|
233
289
|
let extras = undefined;
|
|
234
290
|
let snapshot;
|
|
235
291
|
let mutated = false;
|
|
@@ -296,6 +352,7 @@ export class GatewayPackageService {
|
|
|
296
352
|
...(rollbackFailure === undefined ? {} : { rollbackError: failureMessage(rollbackFailure) }),
|
|
297
353
|
...(logTail?.length ? { logTail } : {}),
|
|
298
354
|
...(status === 'succeeded' && extras?.needsConfiguration?.length ? { needsConfiguration: extras.needsConfiguration } : {}),
|
|
355
|
+
...(status === 'succeeded' && extras?.plugin ? { plugin: extras.plugin } : {}),
|
|
299
356
|
instance: Object.freeze({ before, after: this.#instanceState() }),
|
|
300
357
|
startedAt,
|
|
301
358
|
finishedAt: now(),
|
|
@@ -424,10 +481,16 @@ function declarationError(error) {
|
|
|
424
481
|
? new GatewayError('invalid_input', error.message, { cause: error, details: { errors: error.errors, missingEnv: error.missingEnv } })
|
|
425
482
|
: error;
|
|
426
483
|
}
|
|
484
|
+
/** A refused enable / disable is the caller's input problem; anything else stays internal. */
|
|
485
|
+
function enablementError(error) {
|
|
486
|
+
return error instanceof PluginEnablementError
|
|
487
|
+
? new GatewayError('invalid_input', error.message, { cause: error, details: { errors: error.errors, missingEnv: error.missingEnv } })
|
|
488
|
+
: error;
|
|
489
|
+
}
|
|
427
490
|
/** Structured feedback of a declaration refused inside the transaction (config changed after preflight). */
|
|
428
491
|
function declarationDetails(error) {
|
|
429
492
|
const cause = error instanceof PluginManagementOperationError ? error.cause : error;
|
|
430
|
-
if (!(cause instanceof PluginDeclarationError))
|
|
493
|
+
if (!(cause instanceof PluginDeclarationError) && !(cause instanceof PluginEnablementError))
|
|
431
494
|
return {};
|
|
432
495
|
return {
|
|
433
496
|
...(cause.errors?.length ? { errors: cause.errors } : {}),
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { isDeepStrictEqual } from 'node:util';
|
|
2
|
-
import { parsePluginConfigSchema } from '@zhin.js/runtime';
|
|
2
|
+
import { isSecretConfigSchema, parsePluginConfigSchema } from '@zhin.js/runtime';
|
|
3
3
|
import { readStringLiteralOptions } from '../config/console-schema.js';
|
|
4
4
|
const complexKeywords = ['$ref', '$dynamicRef', 'anyOf', 'oneOf', 'allOf', 'not', 'if', 'then', 'else', 'dependentSchemas', 'dependentRequired', 'patternProperties', 'prefixItems', 'unevaluatedProperties'];
|
|
5
5
|
const forbiddenKeys = new Set(['__proto__', 'prototype', 'constructor']);
|
|
@@ -28,7 +28,7 @@ export function explainConfigFields(schema, input, current, committedInput) {
|
|
|
28
28
|
return;
|
|
29
29
|
}
|
|
30
30
|
const metadata = record(node['x-zhin-config']) ? node['x-zhin-config'] : {};
|
|
31
|
-
const secret = inheritedSecret || node
|
|
31
|
+
const secret = inheritedSecret || isSecretConfigSchema(node) || metadata.hidden === true;
|
|
32
32
|
if (node.type === 'object') {
|
|
33
33
|
if (node.properties !== undefined && !record(node.properties)) {
|
|
34
34
|
issue = 'complex-schema';
|
|
@@ -54,7 +54,7 @@ export function explainConfigFields(schema, input, current, committedInput) {
|
|
|
54
54
|
}
|
|
55
55
|
const items = list && record(node.items) ? node.items : {};
|
|
56
56
|
const itemMetadata = record(items['x-zhin-config']) ? items['x-zhin-config'] : {};
|
|
57
|
-
const itemSecret = items
|
|
57
|
+
const itemSecret = isSecretConfigSchema(items) || itemMetadata.hidden === true;
|
|
58
58
|
fields.push({ path: Object.freeze([...path]), secret: secret || itemSecret, list });
|
|
59
59
|
};
|
|
60
60
|
visit(schema, [], false);
|
|
@@ -33,6 +33,36 @@ export declare class PluginDeclarationError extends Error {
|
|
|
33
33
|
message: string;
|
|
34
34
|
}[], missingEnv?: readonly string[]);
|
|
35
35
|
}
|
|
36
|
+
/** Why an enable / disable request was refused before anything was written. */
|
|
37
|
+
export type PluginEnablementErrorCode = 'invalid-request' | 'reserved' | 'root' | 'not-declared' | 'not-installed' | 'invalid-config';
|
|
38
|
+
/** An enable / disable refused before anything was written (invalid target or configuration). */
|
|
39
|
+
export declare class PluginEnablementError extends Error {
|
|
40
|
+
readonly code: PluginEnablementErrorCode;
|
|
41
|
+
readonly errors: readonly {
|
|
42
|
+
readonly path: string;
|
|
43
|
+
readonly message: string;
|
|
44
|
+
}[];
|
|
45
|
+
readonly missingEnv: readonly string[];
|
|
46
|
+
constructor(code: PluginEnablementErrorCode, message: string, errors?: readonly {
|
|
47
|
+
path: string;
|
|
48
|
+
message: string;
|
|
49
|
+
}[], missingEnv?: readonly string[]);
|
|
50
|
+
}
|
|
51
|
+
/** What `setEnabled` would write to `disable_plugins`; changes nothing. */
|
|
52
|
+
export interface PluginEnablementPlan {
|
|
53
|
+
readonly packageName: string;
|
|
54
|
+
readonly instanceKey: string;
|
|
55
|
+
/** The requested state. */
|
|
56
|
+
readonly enabled: boolean;
|
|
57
|
+
/** False when the instance already is in the requested state: nothing would be written. */
|
|
58
|
+
readonly changed: boolean;
|
|
59
|
+
/** `disable_plugins` after the change. */
|
|
60
|
+
readonly disabled: readonly string[];
|
|
61
|
+
readonly projectRevision: string;
|
|
62
|
+
readonly configurationRevision: string;
|
|
63
|
+
/** A running instance must restart to reflect the (current or requested) state. */
|
|
64
|
+
readonly restartRequired: boolean;
|
|
65
|
+
}
|
|
36
66
|
/** One installation authority for CLI and Console. No generation or package parser of its own. */
|
|
37
67
|
export declare class PluginManagementWorkflow implements PluginManagementPort {
|
|
38
68
|
#private;
|
|
@@ -64,6 +94,20 @@ export declare class PluginManagementWorkflow implements PluginManagementPort {
|
|
|
64
94
|
restartRequired: boolean;
|
|
65
95
|
state: PluginManagementState;
|
|
66
96
|
}>>;
|
|
97
|
+
/**
|
|
98
|
+
* Validates enabling / disabling one declared instance (an instance key, or a package name
|
|
99
|
+
* declared exactly once) without writing. Enabling needs an installed plugin package whose
|
|
100
|
+
* dependencies and configuration pass; disabling also accepts a declared package that is no
|
|
101
|
+
* longer installed, so a broken instance can be taken out of the next generation.
|
|
102
|
+
*/
|
|
103
|
+
planSetEnabled(request: string, enabled: boolean): Promise<PluginEnablementPlan>;
|
|
104
|
+
/** Writes `disable_plugins` in one atomic config write; already in the requested state → nothing written. */
|
|
105
|
+
setEnabled(request: string, enabled: boolean, expectedRevision?: string, expectedProjectRevision?: string): Promise<Readonly<{
|
|
106
|
+
plan: PluginEnablementPlan;
|
|
107
|
+
changed: boolean;
|
|
108
|
+
restartRequired: boolean;
|
|
109
|
+
state: PluginManagementState;
|
|
110
|
+
}>>;
|
|
67
111
|
planUninstall(request: string): Promise<PluginUninstallPlan>;
|
|
68
112
|
uninstall(request: string, expectedRevision?: string, expectedProjectRevision?: string, options?: {
|
|
69
113
|
removeDependency?: boolean;
|