@zhin.js/gateway 1.1.2 → 1.3.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 +5 -5
- package/lib/config/configuration.d.ts +16 -0
- package/lib/config/configuration.js +32 -7
- package/lib/config/environment-files.d.ts +42 -0
- package/lib/config/environment-files.js +347 -7
- package/lib/config/validation-messages.d.ts +13 -0
- package/lib/config/validation-messages.js +59 -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 +3 -1
- package/lib/http/console-rpc.js +31 -4
- package/lib/http/gateway-console.d.ts +15 -8
- package/lib/http/gateway-console.js +134 -50
- package/lib/http/gateway-http-server.js +39 -1
- package/lib/http/plugin-switch-rpc.js +11 -6
- package/lib/http/start-gateway.js +9 -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/protocol.d.ts +6 -0
- package/lib/local/protocol.js +6 -0
- package/lib/packages/errors.js +1 -0
- package/lib/packages/rpc.d.ts +6 -1
- package/lib/packages/rpc.js +28 -2
- package/lib/packages/service.d.ts +3 -1
- package/lib/packages/service.js +22 -4
- package/lib/plugin-management/use-cases.js +8 -8
- package/lib/plugin-management/workflow.d.ts +8 -2
- package/lib/plugin-management/workflow.js +28 -10
- package/lib/supervisor/instance-supervisor.d.ts +4 -1
- package/lib/supervisor/instance-supervisor.js +18 -1
- package/lib/terminal/modules.d.ts +12 -0
- package/lib/terminal/modules.js +38 -2
- package/lib/usage/aggregate.d.ts +64 -3
- package/lib/usage/aggregate.js +199 -26
- package/lib/usage/prompt-cache.d.ts +49 -0
- package/lib/usage/prompt-cache.js +113 -0
- package/lib/usage.d.ts +1 -0
- package/lib/usage.js +1 -0
- package/package.json +7 -7
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* type is forwarded to the instance by the HTTP server.
|
|
7
7
|
*/
|
|
8
8
|
import { type RouteContext, type TokenRegistry } from '@zhin.js/host-http';
|
|
9
|
-
import type
|
|
9
|
+
import { type GatewayRpcDispatcher } from '../packages/rpc.js';
|
|
10
10
|
/** RPC types with this prefix belong to the gateway (unknown ones are refused as unmapped). */
|
|
11
11
|
export declare const GATEWAY_RPC_PREFIX = "gateway.";
|
|
12
12
|
export interface GatewayConsoleRpc {
|
|
@@ -23,5 +23,7 @@ export interface GatewayRpcHandler extends GatewayConsoleRpc {
|
|
|
23
23
|
* is forwarded to the instance untouched (e.g. `feature.set-enabled` below the global level).
|
|
24
24
|
*/
|
|
25
25
|
claims?(message: Readonly<Record<string, unknown>>): boolean;
|
|
26
|
+
/** RPC types this handler answers, for `GET {base}/console/capabilities`. */
|
|
27
|
+
types?(): readonly string[];
|
|
26
28
|
}
|
|
27
29
|
export declare function createGatewayConsoleRpc(dispatch: GatewayRpcDispatcher): GatewayConsoleRpc;
|
package/lib/http/console-rpc.js
CHANGED
|
@@ -5,31 +5,58 @@
|
|
|
5
5
|
* error, requestId, errors?, missingEnv? }`, see `GatewayRpcHttpErrorReply`), every other
|
|
6
6
|
* type is forwarded to the instance by the HTTP server.
|
|
7
7
|
*/
|
|
8
|
-
import { auditActor, jsonResponse, } from '@zhin.js/host-http';
|
|
9
|
-
import { GATEWAY_RPC } from '@zhin.js/console-protocol';
|
|
8
|
+
import { auditActor, jsonResponse, unknownRpcLog, } from '@zhin.js/host-http';
|
|
9
|
+
import { CONSOLE_DENIAL_REASONS, GATEWAY_RPC, GATEWAY_RPC_PERMISSIONS, } from '@zhin.js/console-protocol';
|
|
10
|
+
import { isGatewayRpcType } from '../packages/rpc.js';
|
|
11
|
+
import { gatewayPrincipalAllows } from '../packages/authorization.js';
|
|
12
|
+
import { LOCAL_GATEWAY_RPC } from '../local/protocol.js';
|
|
10
13
|
/** RPC types with this prefix belong to the gateway (unknown ones are refused as unmapped). */
|
|
11
14
|
export const GATEWAY_RPC_PREFIX = 'gateway.';
|
|
15
|
+
/**
|
|
16
|
+
* Socket-only RPC types ({@link LOCAL_GATEWAY_RPC}): they exist, but never over HTTP. Refused
|
|
17
|
+
* with 403 (`forbidden`, audit reason `socket_only`) rather than `unknown_rpc`, so an attempt to
|
|
18
|
+
* reach Console administration over HTTP stays visible in the audit log.
|
|
19
|
+
*/
|
|
20
|
+
const SOCKET_ONLY_TYPES = new Set(Object.values(LOCAL_GATEWAY_RPC));
|
|
12
21
|
const READ_ONLY_TYPES = new Set([
|
|
13
22
|
GATEWAY_RPC.LIST,
|
|
14
23
|
GATEWAY_RPC.PLAN,
|
|
15
24
|
GATEWAY_RPC.STATUS,
|
|
16
25
|
GATEWAY_RPC.LOGS,
|
|
17
26
|
]);
|
|
27
|
+
/** Why the gateway dispatcher refused `type` (`forbidden`). */
|
|
28
|
+
function gatewayDenialReason(type, principal) {
|
|
29
|
+
if (!principal)
|
|
30
|
+
return CONSOLE_DENIAL_REASONS.NOT_AUTHENTICATED;
|
|
31
|
+
if (isGatewayRpcType(type) && !gatewayPrincipalAllows(principal, GATEWAY_RPC_PERMISSIONS[type])) {
|
|
32
|
+
return principal.kind === 'bootstrap' ? CONSOLE_DENIAL_REASONS.SETUP_REQUIRED : CONSOLE_DENIAL_REASONS.INSUFFICIENT_PERMISSION;
|
|
33
|
+
}
|
|
34
|
+
// The permission map admitted it; the service's own rule refused (e.g. bootstrap package limits).
|
|
35
|
+
return CONSOLE_DENIAL_REASONS.HANDLER_DENIED;
|
|
36
|
+
}
|
|
18
37
|
export function createGatewayConsoleRpc(dispatch) {
|
|
19
38
|
return Object.freeze({
|
|
20
39
|
async handle(message, context, tokenRegistry) {
|
|
21
40
|
const type = typeof message.type === 'string' ? message.type : '';
|
|
22
41
|
const requestId = message.requestId;
|
|
23
42
|
const principal = context.principal;
|
|
24
|
-
const
|
|
43
|
+
const socketOnly = SOCKET_ONLY_TYPES.has(type);
|
|
44
|
+
const result = socketOnly
|
|
45
|
+
? { ok: false, error: { code: 'forbidden', status: 403, message: `RPC "${type.slice(0, 64)}" 只能通过本机网关套接字调用(zhin console …)` } }
|
|
46
|
+
: await dispatch({ type, data: message.data }, principal);
|
|
25
47
|
const write = !READ_ONLY_TYPES.has(type);
|
|
48
|
+
const unknown = !result.ok && result.error.code === 'unknown_rpc';
|
|
26
49
|
const outcome = result.ok ? 'ok' : result.error.code === 'forbidden' ? 'denied' : 'error';
|
|
27
|
-
|
|
50
|
+
// zhinjs/zhin#1250: an unknown `gateway.*` type ran nothing and is not a refusal: not audited.
|
|
51
|
+
if (unknown)
|
|
52
|
+
unknownRpcLog.note(type);
|
|
53
|
+
else if (write || outcome === 'denied') {
|
|
28
54
|
try {
|
|
29
55
|
tokenRegistry.accessProvider?.audit?.({
|
|
30
56
|
actor: auditActor(principal),
|
|
31
57
|
action: `rpc:${type}`.slice(0, 256),
|
|
32
58
|
outcome,
|
|
59
|
+
...(outcome === 'denied' ? { detail: { type: type.slice(0, 256), reason: socketOnly ? CONSOLE_DENIAL_REASONS.SOCKET_ONLY : gatewayDenialReason(type, principal) } } : {}),
|
|
33
60
|
...(context.remoteAddress ? { ip: context.remoteAddress } : {}),
|
|
34
61
|
});
|
|
35
62
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { type ConsoleAuditEntry } from '@zhin.js/host-http';
|
|
2
2
|
import { type ConsolePluginPermissionInfo } from '@zhin.js/console-protocol';
|
|
3
3
|
import { type ProjectDatabaseConfig } from '../database/config.js';
|
|
4
|
-
import {
|
|
4
|
+
import { type GatewayStore, type OpenGatewayStoreOptions } from '../database/gateway-store.js';
|
|
5
5
|
import { type LocalConsoleAdmin } from '../iam/local-admin.js';
|
|
6
6
|
import { ConsoleIamService } from '../iam/service.js';
|
|
7
7
|
import { ConsoleSetupService } from '../iam/setup-service.js';
|
|
@@ -26,26 +26,33 @@ export interface GatewayConsoleOptions {
|
|
|
26
26
|
readonly instance?: GatewayConsoleInstancePort;
|
|
27
27
|
/** Restarts the instance after the setup wizard switched databases (ADR 0054 §7). */
|
|
28
28
|
readonly restartInstance?: () => Promise<void>;
|
|
29
|
-
/** Overrides the project `database` block (tests). */
|
|
29
|
+
/** Overrides the project `database` block the old `console_*` tables are imported from (tests). */
|
|
30
30
|
readonly database?: ProjectDatabaseConfig;
|
|
31
|
+
/** Gateway store overrides (tests; default `.zhin/gateway.db`). */
|
|
32
|
+
readonly store?: OpenGatewayStoreOptions;
|
|
31
33
|
/** Overrides the project `console.terminal` block. */
|
|
32
34
|
readonly terminal?: unknown;
|
|
33
35
|
readonly onboarding?: OnboardingService;
|
|
34
|
-
/** Retry interval while the
|
|
36
|
+
/** Retry interval while gateway.db or the import of the old tables fails. Default 10 s. */
|
|
35
37
|
readonly retryMs?: number;
|
|
36
38
|
}
|
|
37
39
|
export interface GatewayConsole {
|
|
38
|
-
/** Settles once the first
|
|
40
|
+
/** Settles once the first open attempt finished (never rejects). */
|
|
39
41
|
readonly ready: Promise<void>;
|
|
40
|
-
/** The IAM service
|
|
42
|
+
/** The IAM service; fails closed (`unavailableReason()`) until gateway.db is open and imported. */
|
|
41
43
|
iam(): ConsoleIamService | undefined;
|
|
42
|
-
/** The gateway
|
|
43
|
-
database():
|
|
44
|
+
/** The gateway store `.zhin/gateway.db` (undefined before it opened). */
|
|
45
|
+
database(): GatewayStore | undefined;
|
|
44
46
|
readonly setup: ConsoleSetupService;
|
|
45
47
|
readonly onboarding: OnboardingService;
|
|
46
48
|
readonly terminal: ConsoleTerminalHandle;
|
|
47
|
-
/** Closes
|
|
49
|
+
/** Closes gateway.db and opens it again (and retries a pending import). */
|
|
48
50
|
reopenDatabase(): Promise<void>;
|
|
51
|
+
/**
|
|
52
|
+
* Writes the store marker into the project database if it is missing (after the first owner was
|
|
53
|
+
* created and after the instance first reported ready: a first-run project file exists by then).
|
|
54
|
+
*/
|
|
55
|
+
ensureStoreMarker(): Promise<void>;
|
|
49
56
|
/** Socket-only `gateway.console.*` (`zhin console init` / `zhin console user` beside the gateway). */
|
|
50
57
|
readonly localAdmin: LocalConsoleAdmin;
|
|
51
58
|
close(): Promise<void>;
|
|
@@ -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
|
|
@@ -10,34 +12,46 @@
|
|
|
10
12
|
*/
|
|
11
13
|
import { join } from 'node:path';
|
|
12
14
|
import { auditActor, authorizeConsoleRpc, jsonResponse, rpcAuditDetail, rpcAuditTarget, } from '@zhin.js/host-http';
|
|
13
|
-
import { ONBOARDING_RPC } from '@zhin.js/console-protocol';
|
|
15
|
+
import { CONSOLE_DENIAL_REASONS, IAM_RPC, ONBOARDING_RPC } from '@zhin.js/console-protocol';
|
|
14
16
|
import { DEFAULT_ROOT_CONFIG_FILE_NAME } from '@zhin.js/plugin-runtime';
|
|
15
17
|
import { createConfigDocument } from '@zhin.js/runtime';
|
|
16
18
|
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 () => {
|
|
@@ -247,6 +330,7 @@ const ONBOARDING_STEPS = new Set(['ai', 'adapters', 'finish']);
|
|
|
247
330
|
export function createGatewayAccountRpc(options) {
|
|
248
331
|
return Object.freeze({
|
|
249
332
|
owns: (type) => isIamRpc(type) || type === ONBOARDING_RPC.UPDATE,
|
|
333
|
+
types: () => [...Object.values(IAM_RPC), ONBOARDING_RPC.UPDATE],
|
|
250
334
|
async handle(message, context, registry) {
|
|
251
335
|
const noStore = { 'cache-control': 'no-store' };
|
|
252
336
|
const requestId = message.requestId;
|
|
@@ -255,21 +339,21 @@ export function createGatewayAccountRpc(options) {
|
|
|
255
339
|
return jsonResponse(401, { success: false, code: 'unauthorized', error: 'Invalid or missing token', requestId }, noStore);
|
|
256
340
|
}
|
|
257
341
|
const decision = authorizeConsoleRpc(principal, message);
|
|
258
|
-
const audit = (outcome) => {
|
|
342
|
+
const audit = (outcome, reason) => {
|
|
259
343
|
try {
|
|
260
344
|
registry.accessProvider?.audit?.({
|
|
261
345
|
actor: auditActor(principal),
|
|
262
346
|
action: `rpc:${String(message.type ?? '')}`.slice(0, 256),
|
|
263
347
|
...(decision.allowed && rpcAuditTarget(decision.rule) ? { target: rpcAuditTarget(decision.rule) } : {}),
|
|
264
348
|
outcome,
|
|
265
|
-
detail: rpcAuditDetail(message),
|
|
349
|
+
detail: reason ? { ...rpcAuditDetail(message), reason } : rpcAuditDetail(message),
|
|
266
350
|
...(context.remoteAddress ? { ip: context.remoteAddress } : {}),
|
|
267
351
|
});
|
|
268
352
|
}
|
|
269
353
|
catch { /* audit never breaks a request */ }
|
|
270
354
|
};
|
|
271
355
|
if (!decision.allowed) {
|
|
272
|
-
audit('denied');
|
|
356
|
+
audit('denied', decision.reason);
|
|
273
357
|
return jsonResponse(403, { success: false, code: decision.code, error: decision.error, requestId }, noStore);
|
|
274
358
|
}
|
|
275
359
|
const result = isIamRpc(message.type)
|
|
@@ -278,7 +362,7 @@ export function createGatewayAccountRpc(options) {
|
|
|
278
362
|
if (decision.write)
|
|
279
363
|
audit(result.status < 400 && result.body.success !== false ? 'ok' : 'error');
|
|
280
364
|
else if (result.status === 403)
|
|
281
|
-
audit('denied');
|
|
365
|
+
audit('denied', CONSOLE_DENIAL_REASONS.HANDLER_DENIED);
|
|
282
366
|
return jsonResponse(result.status, result.body, { ...noStore, ...result.headers });
|
|
283
367
|
},
|
|
284
368
|
});
|
|
@@ -14,9 +14,10 @@
|
|
|
14
14
|
* answer in time gives `504 { code: 'instance_timeout' }`, any other bridge failure
|
|
15
15
|
* `502 { code: 'instance_error' }` (mapped from {@link InstanceTransportError}).
|
|
16
16
|
*/
|
|
17
|
-
import { authorizeConsoleWebSocket, auditActor, createHttpRouter, extractBearerToken, jsonResponse, normalizePath, requestMeta, TokenRegistry, } from '@zhin.js/host-http';
|
|
17
|
+
import { authorizeConsoleWebSocket, auditActor, createHttpRouter, extractBearerToken, jsonResponse, normalizePath, requestMeta, TokenRegistry, webSocketDenialReason, } from '@zhin.js/host-http';
|
|
18
18
|
import { InstanceTransportError, } from '@zhin.js/host-http/bridge';
|
|
19
19
|
import { createNodeHttpListener } from '@zhin.js/host-http/node';
|
|
20
|
+
import { CONSOLE_CAPABILITIES_PATH, CONSOLE_FEATURES, GATEWAY_RPC, createConsoleCapabilities, mergeConsoleCapabilities, parseConsoleCapabilities, } from '@zhin.js/console-protocol';
|
|
20
21
|
import { formatCompact, getLogger } from '@zhin.js/logger';
|
|
21
22
|
import { databaseBusyResponse, isDatabaseBusyError } from '../database/project-database.js';
|
|
22
23
|
import { GATEWAY_RPC_PREFIX } from './console-rpc.js';
|
|
@@ -55,6 +56,8 @@ export class GatewayHttpServer {
|
|
|
55
56
|
#retryAfter;
|
|
56
57
|
#maxBody;
|
|
57
58
|
#consoleRpc;
|
|
59
|
+
/** The instance connection (and route table) that has no capability route: asked once. */
|
|
60
|
+
#capabilitiesMissing;
|
|
58
61
|
#rpcHandlers = new Set();
|
|
59
62
|
#closed = false;
|
|
60
63
|
#closeResult;
|
|
@@ -84,6 +87,10 @@ export class GatewayHttpServer {
|
|
|
84
87
|
summary: 'Console RPC (gateway-owned types answered by the gateway, the rest forwarded)',
|
|
85
88
|
tags: ['console'],
|
|
86
89
|
});
|
|
90
|
+
router.route('GET', `${this.apiBase}${CONSOLE_CAPABILITIES_PATH}`, (request, context) => this.#capabilities(request, context), {
|
|
91
|
+
summary: 'Console capabilities (gateway-owned RPC types merged with the instance\'s)',
|
|
92
|
+
tags: ['console'],
|
|
93
|
+
});
|
|
87
94
|
// Lowest precedence: every path no gateway route claims goes to the instance.
|
|
88
95
|
router.route('ALL', '/*', (request, context) => this.#forward(request, context));
|
|
89
96
|
}
|
|
@@ -243,6 +250,36 @@ export class GatewayHttpServer {
|
|
|
243
250
|
}));
|
|
244
251
|
return this.#instanceError(502, 'instance_error', 'unknown', 'The instance failed to answer', false);
|
|
245
252
|
}
|
|
253
|
+
/**
|
|
254
|
+
* `GET {base}/console/capabilities` (zhinjs/zhin#1250): the RPC types the gateway answers itself,
|
|
255
|
+
* merged with the instance's reply. While the instance is not answering (or predates the route)
|
|
256
|
+
* the reply says `complete: false`, so the Console reads it again later.
|
|
257
|
+
*/
|
|
258
|
+
async #capabilities(request, context) {
|
|
259
|
+
const own = [...this.#rpcHandlers].flatMap(handler => handler.types?.() ?? []);
|
|
260
|
+
const gateway = createConsoleCapabilities({
|
|
261
|
+
rpc: [...(this.#consoleRpc ? Object.values(GATEWAY_RPC) : []), ...own],
|
|
262
|
+
features: [CONSOLE_FEATURES.UNKNOWN_RPC_CODE, CONSOLE_FEATURES.DENIAL_REASONS, CONSOLE_FEATURES.GATEWAY],
|
|
263
|
+
});
|
|
264
|
+
let instance = createConsoleCapabilities({ rpc: [], complete: false });
|
|
265
|
+
const transport = this.#readyTransport();
|
|
266
|
+
// An instance that predates the route refuses it (and audits that): ask it once per route table.
|
|
267
|
+
const revision = transport?.routes().revision;
|
|
268
|
+
const missing = this.#capabilitiesMissing;
|
|
269
|
+
if (transport && !(missing?.transport === transport && missing.revision === revision)) {
|
|
270
|
+
try {
|
|
271
|
+
const response = await this.#forward(request, context);
|
|
272
|
+
const body = response.ok ? await response.json().catch(() => null) : null;
|
|
273
|
+
const parsed = parseConsoleCapabilities(body?.data);
|
|
274
|
+
if (parsed)
|
|
275
|
+
instance = parsed;
|
|
276
|
+
else if (response.status === 403 || response.status === 404)
|
|
277
|
+
this.#capabilitiesMissing = { transport, revision };
|
|
278
|
+
}
|
|
279
|
+
catch { /* the instance part stays unknown */ }
|
|
280
|
+
}
|
|
281
|
+
return jsonResponse(200, { success: true, data: mergeConsoleCapabilities(gateway, instance) }, { 'cache-control': 'no-store' });
|
|
282
|
+
}
|
|
246
283
|
/**
|
|
247
284
|
* `POST {base}/console/request`: the router authenticated and authorized the route; `gateway.*`
|
|
248
285
|
* types are dispatched here (the dispatcher checks the per-RPC permission), the rest are
|
|
@@ -392,6 +429,7 @@ export class GatewayHttpServer {
|
|
|
392
429
|
actor: auditActor(principal),
|
|
393
430
|
action: `ws:${pathname}`,
|
|
394
431
|
outcome: 'denied',
|
|
432
|
+
detail: { reason: webSocketDenialReason(principal, pathname) },
|
|
395
433
|
...(request.remoteAddress ? { ip: request.remoteAddress } : {}),
|
|
396
434
|
});
|
|
397
435
|
}
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
*/
|
|
18
18
|
import { join } from 'node:path';
|
|
19
19
|
import { auditActor, authorizeConsoleRpc, jsonResponse, rpcAuditDetail, rpcAuditTarget, } from '@zhin.js/host-http';
|
|
20
|
-
import { FEATURE_RPC, PLUGIN_RPC, isGlobalFeatureSwitch, } from '@zhin.js/console-protocol';
|
|
20
|
+
import { CONSOLE_DENIAL_REASONS, FEATURE_RPC, PLUGIN_RPC, isGlobalFeatureSwitch, } from '@zhin.js/console-protocol';
|
|
21
21
|
import { DEFAULT_ROOT_CONFIG_FILE_NAME, FEATURE_OVERRIDES_KEY } from '@zhin.js/plugin-runtime';
|
|
22
22
|
import { collectFeatureOverrideWarnings, createConfigDocument } from '@zhin.js/runtime';
|
|
23
23
|
import { formatCompact, getLogger } from '@zhin.js/logger';
|
|
@@ -39,6 +39,7 @@ export function createGatewayPluginSwitchRpc(options) {
|
|
|
39
39
|
return Object.freeze({
|
|
40
40
|
owns: (type) => type === PLUGIN_RPC.SET_ENABLED || type === FEATURE_RPC.SET_ENABLED,
|
|
41
41
|
claims: isGatewayPluginSwitch,
|
|
42
|
+
types: () => [PLUGIN_RPC.SET_ENABLED, FEATURE_RPC.SET_ENABLED],
|
|
42
43
|
async handle(message, context, registry) {
|
|
43
44
|
const noStore = { 'cache-control': 'no-store' };
|
|
44
45
|
const type = String(message.type ?? '');
|
|
@@ -49,27 +50,27 @@ export function createGatewayPluginSwitchRpc(options) {
|
|
|
49
50
|
}
|
|
50
51
|
// plugin.manage: `plugin:set-enabled` is mapped to it, `feature.set-enabled` is refined by level.
|
|
51
52
|
const decision = authorizeConsoleRpc(principal, message);
|
|
52
|
-
const audit = (outcome) => {
|
|
53
|
+
const audit = (outcome, reason) => {
|
|
53
54
|
try {
|
|
54
55
|
registry.accessProvider?.audit?.({
|
|
55
56
|
actor: auditActor(principal),
|
|
56
57
|
action: `rpc:${type}`.slice(0, 256),
|
|
57
58
|
...(decision.allowed && rpcAuditTarget(decision.rule) ? { target: rpcAuditTarget(decision.rule) } : {}),
|
|
58
59
|
outcome,
|
|
59
|
-
detail: rpcAuditDetail(message),
|
|
60
|
+
detail: reason ? { ...rpcAuditDetail(message), reason } : rpcAuditDetail(message),
|
|
60
61
|
...(context.remoteAddress ? { ip: context.remoteAddress } : {}),
|
|
61
62
|
});
|
|
62
63
|
}
|
|
63
64
|
catch { /* audit never breaks a request */ }
|
|
64
65
|
};
|
|
65
66
|
if (!decision.allowed) {
|
|
66
|
-
audit('denied');
|
|
67
|
+
audit('denied', decision.reason);
|
|
67
68
|
return jsonResponse(403, { success: false, code: decision.code, error: decision.error, requestId }, noStore);
|
|
68
69
|
}
|
|
69
70
|
const reply = type === PLUGIN_RPC.SET_ENABLED
|
|
70
71
|
? await setPluginEnabled(message, principal, options, readConfig)
|
|
71
72
|
: await setFeatureGlobal(message, principal, options, readConfig);
|
|
72
|
-
audit(reply.status < 400 && reply.body.success !== false ? 'ok' : reply.status === 403 ? 'denied' : 'error');
|
|
73
|
+
audit(reply.status < 400 && reply.body.success !== false ? 'ok' : reply.status === 403 ? 'denied' : 'error', reply.status === 403 ? CONSOLE_DENIAL_REASONS.HANDLER_DENIED : undefined);
|
|
73
74
|
return jsonResponse(reply.status, { ...reply.body, requestId }, noStore);
|
|
74
75
|
},
|
|
75
76
|
});
|
|
@@ -197,7 +198,11 @@ function disabledPlugins(document) {
|
|
|
197
198
|
const list = document?.disable_plugins;
|
|
198
199
|
return Object.freeze(Array.isArray(list) ? list.filter((item) => typeof item === 'string') : []);
|
|
199
200
|
}
|
|
200
|
-
/**
|
|
201
|
+
/**
|
|
202
|
+
* The global plugin switch's warnings about a legacy `overrides` block still in the file (as the
|
|
203
|
+
* instance computed them). Since ADR 0057 §2 adapter / account overrides live in the instance's
|
|
204
|
+
* database, which the gateway does not read, so this only reports a block waiting for its import.
|
|
205
|
+
*/
|
|
201
206
|
function overrideWarnings(document, plugin) {
|
|
202
207
|
const block = document[plugin];
|
|
203
208
|
const overrides = block && typeof block === 'object' && !Array.isArray(block) ? block[FEATURE_OVERRIDES_KEY] : undefined;
|
|
@@ -141,6 +141,15 @@ export async function startGateway(options) {
|
|
|
141
141
|
},
|
|
142
142
|
});
|
|
143
143
|
await settleWithin(gatewayConsole.ready, options.databaseWaitMs ?? 10_000);
|
|
144
|
+
// ADR 0059 §3: once the instance runs, a first-run SQLite project file exists; make sure the
|
|
145
|
+
// store marker is there (normally written at connect time already).
|
|
146
|
+
let markerChecked = false;
|
|
147
|
+
supervisor.on('state', (status) => {
|
|
148
|
+
if (markerChecked || status.state !== 'running')
|
|
149
|
+
return;
|
|
150
|
+
markerChecked = true;
|
|
151
|
+
void gatewayConsole?.ensureStoreMarker();
|
|
152
|
+
});
|
|
144
153
|
}
|
|
145
154
|
assertNotOpenOnNetwork(http, server.tokenRegistry);
|
|
146
155
|
// Settings for the first instance start, read before listening so it starts right away.
|