@zhin.js/gateway 1.1.1 → 1.2.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/README.md +4 -4
- 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/database/config.js +31 -0
- package/lib/database/console-role.d.ts +35 -0
- package/lib/database/console-role.js +108 -0
- package/lib/database/gateway-store.d.ts +43 -0
- package/lib/database/gateway-store.js +90 -0
- package/lib/database/index.d.ts +2 -0
- package/lib/database/index.js +2 -0
- package/lib/database/project-database.d.ts +45 -0
- package/lib/database/project-database.js +212 -6
- package/lib/http/console-rpc.d.ts +9 -1
- package/lib/http/gateway-console.d.ts +15 -8
- package/lib/http/gateway-console.js +128 -45
- 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 +218 -0
- package/lib/http/start-gateway.js +15 -0
- package/lib/iam/index.d.ts +4 -3
- package/lib/iam/index.js +4 -3
- package/lib/iam/install.d.ts +17 -11
- package/lib/iam/install.js +39 -18
- package/lib/iam/legacy-import.d.ts +151 -0
- package/lib/iam/legacy-import.js +600 -0
- package/lib/iam/local-admin.d.ts +2 -0
- package/lib/iam/local-admin.js +6 -1
- package/lib/iam/routes.js +10 -2
- package/lib/iam/rpc.js +1 -1
- package/lib/iam/service.d.ts +31 -0
- package/lib/iam/service.js +49 -4
- package/lib/iam/setup-service.d.ts +2 -0
- package/lib/iam/setup-service.js +11 -4
- package/lib/iam/tables.d.ts +6 -6
- package/lib/iam/tables.js +6 -8
- package/lib/local/handler.js +1 -1
- package/lib/local/protocol.d.ts +6 -0
- package/lib/local/protocol.js +6 -0
- 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 +7 -7
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Console access control in the gateway (ADR 0054 §3 / §7, ADR 0049, ADR 0053 §4): the
|
|
3
|
-
*
|
|
4
|
-
* tokens / the bootstrap token locally, and serves `/auth/*`, `iam.*`,
|
|
5
|
-
* setup wizard, the terminal and the audit log — all without an
|
|
2
|
+
* Console access control in the gateway (ADR 0054 §3 / §7, ADR 0049, ADR 0053 §4, ADR 0059): the
|
|
3
|
+
* gateway keeps the access-control tables in its own store `.zhin/gateway.db`, authenticates
|
|
4
|
+
* sessions / API tokens / the bootstrap token locally, and serves `/auth/*`, `iam.*`,
|
|
5
|
+
* `onboarding.update`, the setup wizard, the terminal and the audit log — all without an
|
|
6
|
+
* instance. It holds no connection to the project database: it connects only to import the
|
|
7
|
+
* tables an earlier version left there (and the setup wizard tests a connection).
|
|
6
8
|
*
|
|
7
9
|
* The instance contributes what only it knows over the bridge: the plugin permission catalog
|
|
8
10
|
* (`catalog`) and its own audit entries (`audit`); the gateway tells it when a user's credentials
|
|
@@ -17,27 +19,39 @@ import { formatCompact, getLogger } from '@zhin.js/logger';
|
|
|
17
19
|
import { ConsoleConfigurationStore } from '../config/configuration.js';
|
|
18
20
|
import { readEnvFile, writeEnvFile } from '../config/environment-files.js';
|
|
19
21
|
import { loadExpandedProjectConfig, resolveProjectDatabaseConfig } from '../database/config.js';
|
|
20
|
-
import {
|
|
22
|
+
import { openGatewayStore } from '../database/gateway-store.js';
|
|
21
23
|
import { projectEnvironment } from '../iam/install.js';
|
|
22
24
|
import { createLocalConsoleAdmin } from '../iam/local-admin.js';
|
|
23
25
|
import { registerConsoleAuthRoutes } from '../iam/routes.js';
|
|
24
26
|
import { dispatchIamRpc, isIamRpc } from '../iam/rpc.js';
|
|
27
|
+
import { ensureGatewayStoreMarker, importLegacyIamTables, inspectLegacyIamImport, isTransientLegacyImportError, LegacyImportError, readLegacyImportRecord } from '../iam/legacy-import.js';
|
|
25
28
|
import { ConsoleIamService } from '../iam/service.js';
|
|
26
29
|
import { ConsoleSetupService } from '../iam/setup-service.js';
|
|
27
|
-
import { defineConsoleIamTables } from '../iam/tables.js';
|
|
28
30
|
import { OnboardingError } from '../onboarding/errors.js';
|
|
29
31
|
import { OnboardingService } from '../onboarding/service.js';
|
|
30
32
|
import { findConfigFile } from '../project/config-file.js';
|
|
31
33
|
import { installHostConsoleTerminal } from '../terminal/host.js';
|
|
32
34
|
const logger = getLogger('gateway');
|
|
33
35
|
const DEFAULT_RETRY_MS = 10_000;
|
|
34
|
-
/**
|
|
36
|
+
/** Cap of the retry backoff while the project database stays unreachable. */
|
|
37
|
+
const MAX_RETRY_MS = 5 * 60_000;
|
|
38
|
+
/** A database that never starts: the IAM service then fails closed. */
|
|
35
39
|
const UNAVAILABLE_DATABASE = Object.freeze({ started: false, models: Object.freeze({ get: () => undefined }) });
|
|
36
40
|
export async function startGatewayConsole(options) {
|
|
37
41
|
const { server, projectRoot } = options;
|
|
42
|
+
/** Why access control is closed (undefined once gateway.db is open and the import settled). */
|
|
43
|
+
let pending = 'Console 访问控制正在启动';
|
|
44
|
+
/** Category of `pending` shown to anonymous callers (never the detail). */
|
|
45
|
+
let pendingCode = 'starting';
|
|
46
|
+
/** Consecutive transient failures (backoff exponent). */
|
|
47
|
+
let failures = 0;
|
|
38
48
|
const failClosed = () => ({
|
|
39
|
-
service: new ConsoleIamService({
|
|
40
|
-
|
|
49
|
+
service: new ConsoleIamService({
|
|
50
|
+
database: UNAVAILABLE_DATABASE,
|
|
51
|
+
projectRoot,
|
|
52
|
+
unavailable: () => pending ?? 'Console 访问控制正在重新连接',
|
|
53
|
+
unavailableCode: () => (pending === undefined ? 'starting' : pendingCode),
|
|
54
|
+
}),
|
|
41
55
|
dispose: [],
|
|
42
56
|
});
|
|
43
57
|
const environment = options.environment ?? 'development';
|
|
@@ -52,12 +66,12 @@ export async function startGatewayConsole(options) {
|
|
|
52
66
|
let closed = false;
|
|
53
67
|
let retryTimer;
|
|
54
68
|
let opening;
|
|
55
|
-
const iam = () =>
|
|
69
|
+
const iam = () => connection.service;
|
|
56
70
|
const disposers = [];
|
|
57
71
|
// Level grants need the catalog of the instance's loaded plugins (kept across restarts).
|
|
58
72
|
disposers.push(registry.usePluginPermissions(() => options.instance?.pluginPermissions?.() ?? []));
|
|
59
73
|
const access = Object.freeze({
|
|
60
|
-
initialized: () => connection.service
|
|
74
|
+
initialized: () => connection.service.initialized(),
|
|
61
75
|
authenticate: async (token, meta) => (await iam()?.authenticate(token, meta)) ?? null,
|
|
62
76
|
audit: (entry) => { iam()?.audit(entry); },
|
|
63
77
|
});
|
|
@@ -79,68 +93,134 @@ export async function startGatewayConsole(options) {
|
|
|
79
93
|
}
|
|
80
94
|
catch { /* bridge gone */ }
|
|
81
95
|
};
|
|
96
|
+
const projectDatabase = async () => options.database
|
|
97
|
+
?? resolveProjectDatabaseConfig(projectRoot, await loadExpandedProjectConfig(projectRoot, environment));
|
|
82
98
|
async function connect() {
|
|
83
|
-
let
|
|
99
|
+
let store;
|
|
84
100
|
try {
|
|
85
|
-
|
|
101
|
+
store = await openGatewayStore(projectRoot, options.store ?? {});
|
|
86
102
|
}
|
|
87
103
|
catch (error) {
|
|
88
|
-
|
|
89
|
-
|
|
104
|
+
pending = `无法打开 .zhin/gateway.db:${errorText(error)}`;
|
|
105
|
+
pendingCode = 'store';
|
|
106
|
+
logger.warn(formatCompact({ op: 'gateway_store_open_failed', error: errorText(error) }));
|
|
107
|
+
scheduleRetry();
|
|
90
108
|
return;
|
|
91
109
|
}
|
|
92
|
-
|
|
110
|
+
connection = { database: store, service: connection.service, dispose: [] };
|
|
111
|
+
// ADR 0059 §3: the old console_* tables of the project database come over before anything
|
|
112
|
+
// reads gateway.db; until then access control stays closed (an empty store is not "no owner").
|
|
93
113
|
try {
|
|
94
|
-
|
|
114
|
+
const record = await importLegacyIamTables({
|
|
115
|
+
store,
|
|
116
|
+
projectRoot,
|
|
117
|
+
projectDatabase,
|
|
118
|
+
logger: { info: (message) => logger.info(message), warn: (message) => logger.warn(message) },
|
|
119
|
+
});
|
|
120
|
+
if (record.status === 'done' && record.changed) {
|
|
121
|
+
logger.warn(formatCompact({ op: 'gateway_legacy_import_changed', export: record.changedExport }));
|
|
122
|
+
}
|
|
95
123
|
}
|
|
96
124
|
catch (error) {
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
125
|
+
const code = error instanceof LegacyImportError ? error.code : 'error';
|
|
126
|
+
const transient = isTransientLegacyImportError(error);
|
|
127
|
+
pending = code === 'store_lost' || code === 'store_mismatch'
|
|
128
|
+
? errorText(error)
|
|
129
|
+
: transient
|
|
130
|
+
? `正在等待项目数据库,以迁移旧版的 Console 账号表:${errorText(error)}`
|
|
131
|
+
: `迁移旧版 Console 账号表失败,需要处理后重启网关(zhin stop && zhin start):${errorText(error)}`;
|
|
132
|
+
pendingCode = code === 'store_lost' || code === 'store_mismatch' ? code : transient ? 'migration_pending' : 'migration_failed';
|
|
133
|
+
logger.warn(formatCompact({ op: 'gateway_legacy_import_failed', code, transient, error: errorText(error) }));
|
|
134
|
+
// Retrying cannot fix a conflict, a mismatch or a lost store: stay closed until the operator acts.
|
|
135
|
+
if (transient)
|
|
136
|
+
scheduleRetry();
|
|
105
137
|
return;
|
|
106
138
|
}
|
|
107
139
|
const service = new ConsoleIamService({
|
|
108
|
-
database,
|
|
140
|
+
database: store,
|
|
109
141
|
projectRoot,
|
|
110
142
|
onError: (error) => logger.warn(`console iam: ${errorText(error)}`),
|
|
111
143
|
pluginPermissions: () => registry.pluginPermissions(),
|
|
144
|
+
onOwnerCreated: () => { void ensureStoreMarker(); },
|
|
112
145
|
});
|
|
113
|
-
const dispose = [service.onCredentialsRevoked(({ userId, reason }) => revoke(userId, reason))];
|
|
114
|
-
connection = { database, service, supported: true, dispose };
|
|
115
146
|
try {
|
|
116
|
-
await database.start();
|
|
117
147
|
await service.start();
|
|
118
|
-
logger.info(formatCompact({ op: 'gateway_database_open', dialect: config.dialect }));
|
|
119
148
|
}
|
|
120
149
|
catch (error) {
|
|
121
|
-
|
|
122
|
-
|
|
150
|
+
pending = `Console 访问控制无法启动:${errorText(error)}`;
|
|
151
|
+
pendingCode = 'store';
|
|
152
|
+
logger.warn(formatCompact({ op: 'gateway_store_open_failed', error: errorText(error) }));
|
|
153
|
+
service.stop();
|
|
123
154
|
scheduleRetry();
|
|
155
|
+
return;
|
|
124
156
|
}
|
|
157
|
+
const dispose = [service.onCredentialsRevoked(({ userId, reason }) => revoke(userId, reason))];
|
|
158
|
+
connection = { database: store, service, dispose };
|
|
159
|
+
pending = undefined;
|
|
160
|
+
failures = 0;
|
|
161
|
+
logger.info(formatCompact({ op: 'gateway_store_open', file: store.filename }));
|
|
125
162
|
}
|
|
163
|
+
/** Writes the project database's store marker if missing; a foreign marker closes the Console. */
|
|
164
|
+
async function ensureStoreMarker() {
|
|
165
|
+
const store = connection.database;
|
|
166
|
+
if (!store || pending !== undefined)
|
|
167
|
+
return;
|
|
168
|
+
try {
|
|
169
|
+
await ensureGatewayStoreMarker({ store, projectRoot, projectDatabase, logger: { warn: (message) => logger.warn(message) } });
|
|
170
|
+
}
|
|
171
|
+
catch (error) {
|
|
172
|
+
if (error instanceof LegacyImportError && (error.code === 'store_mismatch' || error.code === 'store_lost')) {
|
|
173
|
+
// The project database belongs to another gateway.db (found after the start, e.g. once the
|
|
174
|
+
// project database became reachable): the same fail-closed state as at connect — Console
|
|
175
|
+
// closed, no retry, until the operator acts (`zhin console init --reset-store`).
|
|
176
|
+
logger.warn(formatCompact({ op: 'gateway_legacy_import_failed', code: error.code, transient: false, error: errorText(error) }));
|
|
177
|
+
opening = (opening ?? Promise.resolve()).then(async () => {
|
|
178
|
+
const current = connection;
|
|
179
|
+
if (closed || current.database !== store)
|
|
180
|
+
return;
|
|
181
|
+
pending = errorText(error);
|
|
182
|
+
pendingCode = error.code;
|
|
183
|
+
const users = await current.service.listUsers().catch(() => []);
|
|
184
|
+
// gateway.db stays open (the local owner can inspect it, `--reset-store` can run).
|
|
185
|
+
connection = { database: store, service: failClosed().service, dispose: [] };
|
|
186
|
+
for (const dispose of current.dispose.splice(0))
|
|
187
|
+
dispose();
|
|
188
|
+
current.service.stop();
|
|
189
|
+
// Sessions and API tokens no longer authenticate (the service is closed); what is already
|
|
190
|
+
// open ends too: terminals and the instance's sockets of every user, as on a revocation.
|
|
191
|
+
for (const user of users)
|
|
192
|
+
revoke(user.id, error.code);
|
|
193
|
+
for (const session of terminal.sessions())
|
|
194
|
+
terminal.terminatePrincipal(session.principalId, 'revoked');
|
|
195
|
+
});
|
|
196
|
+
await opening;
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
logger.warn(formatCompact({ op: 'gateway_store_marker_failed', error: errorText(error) }));
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
/** Transient failure: retry with a growing delay (retryMs, 2×, 4×, … at most MAX_RETRY_MS). */
|
|
126
203
|
function scheduleRetry() {
|
|
127
204
|
if (closed || retryTimer)
|
|
128
205
|
return;
|
|
206
|
+
const delay = Math.min(retryMs * 2 ** failures, Math.max(retryMs, MAX_RETRY_MS));
|
|
207
|
+
failures += 1;
|
|
129
208
|
retryTimer = setTimeout(() => {
|
|
130
209
|
retryTimer = undefined;
|
|
131
210
|
if (closed)
|
|
132
211
|
return;
|
|
133
212
|
void reopen();
|
|
134
|
-
},
|
|
213
|
+
}, delay);
|
|
135
214
|
retryTimer.unref?.();
|
|
136
215
|
}
|
|
137
216
|
async function disconnect() {
|
|
138
217
|
const current = connection;
|
|
218
|
+
pending ??= 'Console 访问控制正在重新连接';
|
|
139
219
|
connection = failClosed();
|
|
140
220
|
for (const dispose of current.dispose.splice(0))
|
|
141
221
|
dispose();
|
|
142
|
-
current.service
|
|
143
|
-
await current.database?.
|
|
222
|
+
current.service.stop();
|
|
223
|
+
await current.database?.close().catch((error) => {
|
|
144
224
|
logger.warn(formatCompact({ op: 'gateway_database_close_failed', error: errorText(error) }));
|
|
145
225
|
});
|
|
146
226
|
}
|
|
@@ -169,7 +249,7 @@ export async function startGatewayConsole(options) {
|
|
|
169
249
|
});
|
|
170
250
|
const onboarding = options.onboarding ?? new OnboardingService({
|
|
171
251
|
projectRoot,
|
|
172
|
-
hasOwner: () => connection.service
|
|
252
|
+
hasOwner: () => connection.service.initialized(),
|
|
173
253
|
});
|
|
174
254
|
const restartInstance = () => {
|
|
175
255
|
void options.restartInstance?.().catch((error) => {
|
|
@@ -179,12 +259,18 @@ export async function startGatewayConsole(options) {
|
|
|
179
259
|
const localAdmin = createLocalConsoleAdmin({
|
|
180
260
|
setup,
|
|
181
261
|
iam,
|
|
182
|
-
//
|
|
262
|
+
// The accounts stay in gateway.db (ADR 0059): only the instance uses the new database.
|
|
183
263
|
switchDatabase: async () => {
|
|
184
|
-
await reopen();
|
|
185
264
|
restartInstance();
|
|
186
265
|
return { restarting: options.restartInstance !== undefined };
|
|
187
266
|
},
|
|
267
|
+
migration: async () => {
|
|
268
|
+
const store = connection.database;
|
|
269
|
+
if (!store)
|
|
270
|
+
return { pending };
|
|
271
|
+
const inspection = await inspectLegacyIamImport({ store, projectRoot, projectDatabase });
|
|
272
|
+
return { ...inspection, record: inspection.record ?? await readLegacyImportRecord(store), ...(pending !== undefined ? { pending, pendingCode } : {}) };
|
|
273
|
+
},
|
|
188
274
|
});
|
|
189
275
|
disposers.push(...registerConsoleAuthRoutes({
|
|
190
276
|
http: server,
|
|
@@ -192,18 +278,14 @@ export async function startGatewayConsole(options) {
|
|
|
192
278
|
iam,
|
|
193
279
|
setup,
|
|
194
280
|
onboarding: () => onboarding.status(),
|
|
195
|
-
//
|
|
196
|
-
requestRestart: () => {
|
|
197
|
-
void reopen()
|
|
198
|
-
.then(() => options.restartInstance?.())
|
|
199
|
-
.catch((error) => logger.warn(formatCompact({ op: 'gateway_database_switch_failed', error: errorText(error) })));
|
|
200
|
-
},
|
|
281
|
+
// The instance restarts on the new project database; the accounts stay in gateway.db (ADR 0059).
|
|
282
|
+
requestRestart: () => { restartInstance(); },
|
|
201
283
|
}));
|
|
202
284
|
// `/auth/*` belongs to the gateway alone: nothing under it is forwarded to the instance.
|
|
203
285
|
disposers.push(server.route('ALL', `${server.apiBase}/auth/*`, () => jsonResponse(404, { success: false, code: 'not_found', error: 'Not found' }), { summary: 'Unknown /auth route', tags: ['auth'] }));
|
|
204
286
|
disposers.push(server.useConsoleRpc(createGatewayAccountRpc({ iam, onboarding })));
|
|
205
287
|
const ready = reopen().catch((error) => {
|
|
206
|
-
logger.warn(formatCompact({ op: '
|
|
288
|
+
logger.warn(formatCompact({ op: 'gateway_store_open_failed', error: errorText(error) }));
|
|
207
289
|
});
|
|
208
290
|
let closeResult;
|
|
209
291
|
return Object.freeze({
|
|
@@ -214,6 +296,7 @@ export async function startGatewayConsole(options) {
|
|
|
214
296
|
onboarding,
|
|
215
297
|
terminal,
|
|
216
298
|
reopenDatabase: reopen,
|
|
299
|
+
ensureStoreMarker,
|
|
217
300
|
localAdmin,
|
|
218
301
|
close() {
|
|
219
302
|
closeResult ??= (async () => {
|
|
@@ -10,10 +10,15 @@
|
|
|
10
10
|
* Console reconnects to the merged stream. `GET {base}/events/history` then answers an empty
|
|
11
11
|
* page that keeps the caller's cursor.
|
|
12
12
|
*
|
|
13
|
+
* A succeeded plugin enable / disable that changed `disable_plugins` also sends
|
|
14
|
+
* `plugin:lifecycle-updated` on `{base}/events` (the Console reloads its entries on it).
|
|
15
|
+
*
|
|
13
16
|
* `GET {base}/gateway/packages/progress` serves the progress frames alone.
|
|
14
17
|
*/
|
|
15
18
|
import type { GatewayPackageEvent } from '@zhin.js/console-protocol';
|
|
16
19
|
import type { GatewayHttpServer } from './gateway-http-server.js';
|
|
20
|
+
/** Console event the client reloads its entries on (`CONSOLE_ENTRY_LIFECYCLE_EVENTS`). */
|
|
21
|
+
export declare const PLUGIN_LIFECYCLE_UPDATED = "plugin:lifecycle-updated";
|
|
17
22
|
export interface GatewayEventSource {
|
|
18
23
|
/** Package transaction events (`GatewayPackageService.subscribe`). */
|
|
19
24
|
subscribe(listener: (event: GatewayPackageEvent) => void): () => void;
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { GATEWAY_PACKAGES_PROGRESS, GATEWAY_PACKAGES_PROGRESS_PATH } from '@zhin.js/console-protocol';
|
|
2
2
|
import { jsonResponse } from '@zhin.js/host-http';
|
|
3
3
|
const KEEPALIVE_MS = 15_000;
|
|
4
|
+
/** Console event the client reloads its entries on (`CONSOLE_ENTRY_LIFECYCLE_EVENTS`). */
|
|
5
|
+
export const PLUGIN_LIFECYCLE_UPDATED = 'plugin:lifecycle-updated';
|
|
4
6
|
/** Gateway frames held while an instance frame is half written. */
|
|
5
7
|
const MAX_PENDING_FRAMES = 1_024;
|
|
6
8
|
/** Chunks queued for a client that does not read; beyond it the stream ends (the client resumes). */
|
|
@@ -63,7 +65,18 @@ export function registerGatewayEventRoutes(options) {
|
|
|
63
65
|
cleanups.push(() => clearInterval(timer));
|
|
64
66
|
return { stream, write, finish, cleanups, get finished() { return finished; } };
|
|
65
67
|
};
|
|
66
|
-
|
|
68
|
+
/**
|
|
69
|
+
* Progress frames; on the Console event stream (`lifecycle`) a succeeded enable / disable that
|
|
70
|
+
* changed `disable_plugins` is followed by `plugin:lifecycle-updated` `{ instanceKey, enabled }`,
|
|
71
|
+
* the event the instance used to publish for the Console switch (the Console reloads its entries).
|
|
72
|
+
*/
|
|
73
|
+
const subscribeProgress = (deliver, lifecycle = false) => options.packages?.subscribe((event) => {
|
|
74
|
+
deliver(gatewayEventFrame(GATEWAY_PACKAGES_PROGRESS, event));
|
|
75
|
+
const plugin = event.type === 'result' && event.result.ok ? event.result.plugin : undefined;
|
|
76
|
+
if (lifecycle && plugin?.changed) {
|
|
77
|
+
deliver(gatewayEventFrame(PLUGIN_LIFECYCLE_UPDATED, { instanceKey: plugin.instanceKey, enabled: plugin.enabled }));
|
|
78
|
+
}
|
|
79
|
+
});
|
|
67
80
|
const events = async (request, context) => {
|
|
68
81
|
if (!server.instanceReady)
|
|
69
82
|
return offlineStream(request);
|
|
@@ -95,7 +108,7 @@ export function registerGatewayEventRoutes(options) {
|
|
|
95
108
|
return;
|
|
96
109
|
}
|
|
97
110
|
client.write(frame);
|
|
98
|
-
});
|
|
111
|
+
}, true);
|
|
99
112
|
if (stop)
|
|
100
113
|
client.cleanups.push(stop);
|
|
101
114
|
void (async () => {
|
|
@@ -121,7 +134,7 @@ export function registerGatewayEventRoutes(options) {
|
|
|
121
134
|
const offlineStream = (request) => {
|
|
122
135
|
const client = open(request);
|
|
123
136
|
client.write(': gateway\n\n');
|
|
124
|
-
const stop = subscribeProgress((frame) => client.write(frame));
|
|
137
|
+
const stop = subscribeProgress((frame) => client.write(frame), true);
|
|
125
138
|
if (stop)
|
|
126
139
|
client.cleanups.push(stop);
|
|
127
140
|
// Reconnect to the merged stream once the instance is back.
|
|
@@ -279,13 +279,17 @@ export class GatewayHttpServer {
|
|
|
279
279
|
catch {
|
|
280
280
|
return jsonResponse(400, { success: false, code: 'invalid_body', error: 'Invalid JSON body' });
|
|
281
281
|
}
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
282
|
+
const claimed = typeof message === 'object' && message !== null && !Array.isArray(message)
|
|
283
|
+
&& (!('claims' in owner) || typeof owner.claims !== 'function' || owner.claims(message));
|
|
284
|
+
if (claimed) {
|
|
285
|
+
try {
|
|
286
|
+
return await owner.handle(message, context, this.tokenRegistry);
|
|
287
|
+
}
|
|
288
|
+
catch (error) {
|
|
289
|
+
if (isDatabaseBusyError(error))
|
|
290
|
+
return databaseBusyResponse({ requestId: message.requestId });
|
|
291
|
+
throw error;
|
|
292
|
+
}
|
|
289
293
|
}
|
|
290
294
|
}
|
|
291
295
|
const headers = new Headers(request.headers);
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { GatewayPackageService } from '../packages/service.js';
|
|
2
|
+
import type { GatewayRpcHandler } from './console-rpc.js';
|
|
3
|
+
/** What the switch needs from the package service. */
|
|
4
|
+
export type GatewayPluginSwitchPackages = Pick<GatewayPackageService, 'setEnabled'>;
|
|
5
|
+
/** The root configuration as written after the transaction. */
|
|
6
|
+
export interface GatewayPluginSwitchConfigRead {
|
|
7
|
+
readonly revision: string;
|
|
8
|
+
readonly document: Readonly<Record<string, unknown>>;
|
|
9
|
+
}
|
|
10
|
+
export interface GatewayPluginSwitchRpcOptions {
|
|
11
|
+
readonly projectRoot: string;
|
|
12
|
+
/** Undefined when the gateway manages no packages: the switch then answers 503 (never forwarded). */
|
|
13
|
+
readonly packages: GatewayPluginSwitchPackages | undefined;
|
|
14
|
+
/** Reads the root configuration (revision and document). Defaults to the project's config file. */
|
|
15
|
+
readonly readConfig?: () => Promise<GatewayPluginSwitchConfigRead>;
|
|
16
|
+
}
|
|
17
|
+
/** Whether the gateway answers this Console RPC message itself (the global plugin switch). */
|
|
18
|
+
export declare function isGatewayPluginSwitch(message: Readonly<Record<string, unknown>>): boolean;
|
|
19
|
+
export declare function createGatewayPluginSwitchRpc(options: GatewayPluginSwitchRpcOptions): GatewayRpcHandler;
|
|
@@ -0,0 +1,218 @@
|
|
|
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
|
+
/**
|
|
201
|
+
* The global plugin switch's warnings about a legacy `overrides` block still in the file (as the
|
|
202
|
+
* instance computed them). Since ADR 0057 §2 adapter / account overrides live in the instance's
|
|
203
|
+
* database, which the gateway does not read, so this only reports a block waiting for its import.
|
|
204
|
+
*/
|
|
205
|
+
function overrideWarnings(document, plugin) {
|
|
206
|
+
const block = document[plugin];
|
|
207
|
+
const overrides = block && typeof block === 'object' && !Array.isArray(block) ? block[FEATURE_OVERRIDES_KEY] : undefined;
|
|
208
|
+
if (overrides === undefined)
|
|
209
|
+
return Object.freeze([]);
|
|
210
|
+
try {
|
|
211
|
+
const enabled = !disabledPlugins(document).includes(plugin);
|
|
212
|
+
return Object.freeze(collectFeatureOverrideWarnings(document, plugin, overrides, enabled)
|
|
213
|
+
.map(({ code, path, message }) => Object.freeze({ code, path, message })));
|
|
214
|
+
}
|
|
215
|
+
catch {
|
|
216
|
+
return Object.freeze([]);
|
|
217
|
+
}
|
|
218
|
+
}
|