@zhin.js/gateway 1.2.0 → 1.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/lib/config/configuration.d.ts +32 -3
- package/lib/config/configuration.js +120 -25
- package/lib/config/environment-files.d.ts +42 -0
- package/lib/config/environment-files.js +347 -7
- package/lib/config/literal-secrets.d.ts +3 -0
- package/lib/config/literal-secrets.js +4 -1
- package/lib/config/patch-path-messages.d.ts +15 -0
- package/lib/config/patch-path-messages.js +71 -0
- package/lib/config/secret-storage.d.ts +42 -0
- package/lib/config/secret-storage.js +140 -0
- package/lib/config/validation-messages.d.ts +15 -0
- package/lib/config/validation-messages.js +66 -0
- package/lib/config.d.ts +1 -0
- package/lib/config.js +1 -0
- package/lib/database/console-role.js +2 -1
- package/lib/http/console-rpc.d.ts +3 -1
- package/lib/http/console-rpc.js +31 -4
- package/lib/http/gateway-console.d.ts +5 -0
- package/lib/http/gateway-console.js +31 -5
- package/lib/http/gateway-http-server.d.ts +2 -0
- package/lib/http/gateway-http-server.js +42 -1
- package/lib/http/local-principal.d.ts +1 -1
- package/lib/http/local-principal.js +3 -2
- package/lib/http/plugin-switch-rpc.js +7 -6
- package/lib/http/start-gateway.d.ts +1 -1
- package/lib/http/start-gateway.js +2 -0
- package/lib/iam/service.js +12 -0
- package/lib/iam/setup-service.d.ts +3 -1
- package/lib/iam/setup-service.js +2 -1
- package/lib/onboarding/ignored-builds.d.ts +18 -0
- package/lib/onboarding/ignored-builds.js +63 -0
- package/lib/onboarding/index.d.ts +1 -0
- package/lib/onboarding/index.js +1 -0
- package/lib/packages/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 +74 -9
- package/lib/plugin-management/plugin-candidates.d.ts +9 -0
- package/lib/plugin-management/plugin-candidates.js +55 -0
- package/lib/plugin-management/use-cases.js +8 -8
- package/lib/plugin-management/workflow.d.ts +13 -2
- package/lib/plugin-management/workflow.js +46 -13
- package/lib/plugin-management.d.ts +1 -0
- package/lib/plugin-management.js +1 -0
- package/lib/project/identity.d.ts +2 -0
- package/lib/project/identity.js +21 -0
- package/lib/project.d.ts +1 -0
- package/lib/project.js +1 -0
- package/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 +8 -8
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { type Locale } from '@zhin.js/plugin-runtime';
|
|
2
|
+
/** The parts of an Ajv `ErrorObject` a configuration message needs. */
|
|
3
|
+
export interface SchemaIssue {
|
|
4
|
+
readonly keyword: string;
|
|
5
|
+
readonly params: Readonly<Record<string, unknown>>;
|
|
6
|
+
readonly message?: string;
|
|
7
|
+
}
|
|
8
|
+
/** Schema `pattern` of a value that may be a string only as one whole `${VAR}` reference. */
|
|
9
|
+
export declare const ENVIRONMENT_REFERENCE_SCHEMA_PATTERN = "^\\$\\{[A-Za-z_][A-Za-z0-9_]*(?::[-=][^}]*)?\\}$";
|
|
10
|
+
/**
|
|
11
|
+
* Localized text for one Ajv issue of a Plugin configuration, in the project `locale`
|
|
12
|
+
* (`zh-CN` by default). `location` is the field path inside the configuration block; it is named in
|
|
13
|
+
* the message so a reader sees which field is wrong without matching it to the separate path.
|
|
14
|
+
*/
|
|
15
|
+
export declare function formatSchemaIssue(issue: SchemaIssue, location: readonly string[], locale: Locale | unknown): string;
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { createMessages, resolveLocale } from '@zhin.js/plugin-runtime';
|
|
2
|
+
const messages = createMessages({
|
|
3
|
+
type: { 'zh-CN': '{field}类型应为 {type}', en: '{field}must be of type {type}' },
|
|
4
|
+
maximum: { 'zh-CN': '{field}不能大于 {limit}', en: '{field}must be at most {limit}' },
|
|
5
|
+
minimum: { 'zh-CN': '{field}不能小于 {limit}', en: '{field}must be at least {limit}' },
|
|
6
|
+
exclusiveMaximum: { 'zh-CN': '{field}必须小于 {limit}', en: '{field}must be less than {limit}' },
|
|
7
|
+
exclusiveMinimum: { 'zh-CN': '{field}必须大于 {limit}', en: '{field}must be greater than {limit}' },
|
|
8
|
+
minLength: { 'zh-CN': '{field}长度不能少于 {limit} 个字符', en: '{field}must be at least {limit} characters long' },
|
|
9
|
+
maxLength: { 'zh-CN': '{field}长度不能超过 {limit} 个字符', en: '{field}must be at most {limit} characters long' },
|
|
10
|
+
enum: { 'zh-CN': '{field}必须是以下值之一:{values}', en: '{field}must be one of: {values}' },
|
|
11
|
+
const: { 'zh-CN': '{field}必须等于 {value}', en: '{field}must equal {value}' },
|
|
12
|
+
pattern: { 'zh-CN': '{field}格式不符合 {pattern}', en: '{field}must match the pattern {pattern}' },
|
|
13
|
+
environmentReference: { 'zh-CN': '{field}只接受数值或 {reference} 形式的环境变量引用', en: '{field}accepts only a number or a {reference} environment reference' },
|
|
14
|
+
required: { 'zh-CN': '缺少必填字段 {property}', en: 'missing required field {property}' },
|
|
15
|
+
additionalProperties: { 'zh-CN': '不支持的字段 {property}', en: 'unknown field {property}' },
|
|
16
|
+
format: { 'zh-CN': '{field}应为有效的 {format}', en: '{field}must be a valid {format}' },
|
|
17
|
+
minItems: { 'zh-CN': '{field}至少需要 {limit} 项', en: '{field}must have at least {limit} items' },
|
|
18
|
+
maxItems: { 'zh-CN': '{field}最多只能有 {limit} 项', en: '{field}must have at most {limit} items' },
|
|
19
|
+
fallback: { 'zh-CN': '{field}不满足 {keyword} 约束({detail})', en: '{field}does not satisfy {keyword} ({detail})' },
|
|
20
|
+
field: { 'zh-CN': '字段 {path} ', en: 'Field {path} ' },
|
|
21
|
+
});
|
|
22
|
+
/** Schema `pattern` of a value that may be a string only as one whole `${VAR}` reference. */
|
|
23
|
+
export const ENVIRONMENT_REFERENCE_SCHEMA_PATTERN = '^\\$\\{[A-Za-z_][A-Za-z0-9_]*(?::[-=][^}]*)?\\}$';
|
|
24
|
+
const NUMERIC_LIMITS = new Set(['maximum', 'minimum', 'exclusiveMaximum', 'exclusiveMinimum', 'minLength', 'maxLength', 'minItems', 'maxItems']);
|
|
25
|
+
function display(value) {
|
|
26
|
+
return typeof value === 'string' ? value : JSON.stringify(value);
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Localized text for one Ajv issue of a Plugin configuration, in the project `locale`
|
|
30
|
+
* (`zh-CN` by default). `location` is the field path inside the configuration block; it is named in
|
|
31
|
+
* the message so a reader sees which field is wrong without matching it to the separate path.
|
|
32
|
+
*/
|
|
33
|
+
export function formatSchemaIssue(issue, location, locale) {
|
|
34
|
+
const lang = resolveLocale(locale);
|
|
35
|
+
const field = location.length > 0 ? messages(lang, 'field', { path: location.join('.') }) : '';
|
|
36
|
+
const params = issue.params;
|
|
37
|
+
switch (issue.keyword) {
|
|
38
|
+
case 'type': {
|
|
39
|
+
const type = Array.isArray(params.type) ? params.type.join(' | ') : String(params.type);
|
|
40
|
+
return messages(lang, 'type', { field, type: type === 'array' ? 'list' : type });
|
|
41
|
+
}
|
|
42
|
+
case 'enum': {
|
|
43
|
+
const allowed = Array.isArray(params.allowedValues) ? params.allowedValues.map(display).join(', ') : display(params.allowedValues);
|
|
44
|
+
return messages(lang, 'enum', { field, values: allowed });
|
|
45
|
+
}
|
|
46
|
+
case 'const':
|
|
47
|
+
return messages(lang, 'const', { field, value: display(params.allowedValue) });
|
|
48
|
+
case 'pattern':
|
|
49
|
+
// A string allowed only as a whole `${VAR}` reference (e.g. `http.port`): say so plainly.
|
|
50
|
+
if (String(params.pattern) === ENVIRONMENT_REFERENCE_SCHEMA_PATTERN) {
|
|
51
|
+
return messages(lang, 'environmentReference', { field, reference: '${VAR}' });
|
|
52
|
+
}
|
|
53
|
+
return messages(lang, 'pattern', { field, pattern: String(params.pattern) });
|
|
54
|
+
case 'format':
|
|
55
|
+
return messages(lang, 'format', { field, format: String(params.format) });
|
|
56
|
+
case 'required':
|
|
57
|
+
return messages(lang, 'required', { property: [...location, String(params.missingProperty)].join('.') });
|
|
58
|
+
case 'additionalProperties':
|
|
59
|
+
return messages(lang, 'additionalProperties', { property: [...location, String(params.additionalProperty)].join('.') });
|
|
60
|
+
default:
|
|
61
|
+
if (NUMERIC_LIMITS.has(issue.keyword) && (typeof params.limit === 'number' || typeof params.limit === 'string')) {
|
|
62
|
+
return messages(lang, issue.keyword, { field, limit: params.limit });
|
|
63
|
+
}
|
|
64
|
+
return messages(lang, 'fallback', { field, keyword: issue.keyword, detail: issue.message ?? issue.keyword });
|
|
65
|
+
}
|
|
66
|
+
}
|
package/lib/config.d.ts
CHANGED
|
@@ -11,3 +11,4 @@ export * from './config/environment-files.js';
|
|
|
11
11
|
export * from './config/literal-secrets.js';
|
|
12
12
|
export * from './config/plugin-package-map.js';
|
|
13
13
|
export * from './config/plugin-schema-catalog.js';
|
|
14
|
+
export { SECRET_ENVIRONMENT_WRITE_REQUIRED, SecretEnvironmentWriteRequiredError } from './config/secret-storage.js';
|
package/lib/config.js
CHANGED
|
@@ -11,3 +11,4 @@ export * from './config/environment-files.js';
|
|
|
11
11
|
export * from './config/literal-secrets.js';
|
|
12
12
|
export * from './config/plugin-package-map.js';
|
|
13
13
|
export * from './config/plugin-schema-catalog.js';
|
|
14
|
+
export { SECRET_ENVIRONMENT_WRITE_REQUIRED, SecretEnvironmentWriteRequiredError } from './config/secret-storage.js';
|
|
@@ -92,7 +92,8 @@ export async function setupConsoleRole(options) {
|
|
|
92
92
|
const block = document.database && typeof document.database === 'object' && !Array.isArray(document.database)
|
|
93
93
|
? document.database
|
|
94
94
|
: { dialect };
|
|
95
|
-
|
|
95
|
+
// Local owner CLI (`zhin db console-role`): may write `.env`.
|
|
96
|
+
await store.setKey('database', { ...block, console: { ...ENV_REFERENCE } }, { environmentWrite: true });
|
|
96
97
|
return Object.freeze({
|
|
97
98
|
dialect,
|
|
98
99
|
user,
|
|
@@ -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,5 +1,6 @@
|
|
|
1
1
|
import { type ConsoleAuditEntry } from '@zhin.js/host-http';
|
|
2
2
|
import { type ConsolePluginPermissionInfo } from '@zhin.js/console-protocol';
|
|
3
|
+
import type { BridgeRouteTable } from '@zhin.js/host-http/bridge';
|
|
3
4
|
import { type ProjectDatabaseConfig } from '../database/config.js';
|
|
4
5
|
import { type GatewayStore, type OpenGatewayStoreOptions } from '../database/gateway-store.js';
|
|
5
6
|
import { type LocalConsoleAdmin } from '../iam/local-admin.js';
|
|
@@ -15,6 +16,10 @@ export interface GatewayConsoleInstancePort {
|
|
|
15
16
|
pluginPermissions?(): readonly ConsolePluginPermissionInfo[];
|
|
16
17
|
/** Sends `principal-revoked` (the instance closes that user's live connections). */
|
|
17
18
|
revokePrincipal?(principalId: string, reason?: string): void;
|
|
19
|
+
/** Whether an instance is attached and answering (a message sent now is delivered). */
|
|
20
|
+
readonly ready?: boolean;
|
|
21
|
+
/** Runs on every route table change (an instance's first `ready` included; empty when it goes away). */
|
|
22
|
+
onRoutes?(listener: (table: BridgeRouteTable) => void): () => void;
|
|
18
23
|
/** Audit entries written by the instance. */
|
|
19
24
|
onAudit?(listener: (entry: ConsoleAuditEntry) => void): () => void;
|
|
20
25
|
}
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
*/
|
|
13
13
|
import { join } from 'node:path';
|
|
14
14
|
import { auditActor, authorizeConsoleRpc, jsonResponse, rpcAuditDetail, rpcAuditTarget, } from '@zhin.js/host-http';
|
|
15
|
-
import { ONBOARDING_RPC } from '@zhin.js/console-protocol';
|
|
15
|
+
import { CONSOLE_DENIAL_REASONS, IAM_RPC, ONBOARDING_RPC } from '@zhin.js/console-protocol';
|
|
16
16
|
import { DEFAULT_ROOT_CONFIG_FILE_NAME } from '@zhin.js/plugin-runtime';
|
|
17
17
|
import { createConfigDocument } from '@zhin.js/runtime';
|
|
18
18
|
import { formatCompact, getLogger } from '@zhin.js/logger';
|
|
@@ -86,13 +86,38 @@ export async function startGatewayConsole(options) {
|
|
|
86
86
|
iam,
|
|
87
87
|
logger: { warn: (message) => logger.warn(message), info: (message) => logger.info(message) },
|
|
88
88
|
});
|
|
89
|
+
// ADR 0060 §2.4: a deleted / disabled Console user's chat person link is removed by the
|
|
90
|
+
// instance on `principal-revoked`. Those two reasons are kept while no instance is attached
|
|
91
|
+
// and sent once one reports ready, so a link never outlives its user because the instance was
|
|
92
|
+
// down (bounded; in memory only: a gateway restart forgets them).
|
|
93
|
+
const undelivered = new Map();
|
|
89
94
|
const revoke = (userId, reason) => {
|
|
90
95
|
terminal.terminatePrincipal(`user:${userId}`, 'revoked');
|
|
96
|
+
const ending = reason === 'deleted' || reason === 'disabled';
|
|
97
|
+
if (ending && options.instance?.ready === false) {
|
|
98
|
+
undelivered.delete(userId);
|
|
99
|
+
undelivered.set(userId, reason);
|
|
100
|
+
if (undelivered.size > 1024)
|
|
101
|
+
undelivered.delete(undelivered.keys().next().value);
|
|
102
|
+
}
|
|
91
103
|
try {
|
|
92
104
|
options.instance?.revokePrincipal?.(userId, reason);
|
|
93
105
|
}
|
|
94
106
|
catch { /* bridge gone */ }
|
|
95
107
|
};
|
|
108
|
+
const stopUndelivered = options.instance?.onRoutes?.(() => {
|
|
109
|
+
if (options.instance?.ready !== true)
|
|
110
|
+
return;
|
|
111
|
+
for (const [userId, reason] of [...undelivered]) {
|
|
112
|
+
undelivered.delete(userId);
|
|
113
|
+
try {
|
|
114
|
+
options.instance?.revokePrincipal?.(userId, reason);
|
|
115
|
+
}
|
|
116
|
+
catch { /* bridge gone */ }
|
|
117
|
+
}
|
|
118
|
+
});
|
|
119
|
+
if (stopUndelivered)
|
|
120
|
+
disposers.push(stopUndelivered);
|
|
96
121
|
const projectDatabase = async () => options.database
|
|
97
122
|
?? resolveProjectDatabaseConfig(projectRoot, await loadExpandedProjectConfig(projectRoot, environment));
|
|
98
123
|
async function connect() {
|
|
@@ -330,6 +355,7 @@ const ONBOARDING_STEPS = new Set(['ai', 'adapters', 'finish']);
|
|
|
330
355
|
export function createGatewayAccountRpc(options) {
|
|
331
356
|
return Object.freeze({
|
|
332
357
|
owns: (type) => isIamRpc(type) || type === ONBOARDING_RPC.UPDATE,
|
|
358
|
+
types: () => [...Object.values(IAM_RPC), ONBOARDING_RPC.UPDATE],
|
|
333
359
|
async handle(message, context, registry) {
|
|
334
360
|
const noStore = { 'cache-control': 'no-store' };
|
|
335
361
|
const requestId = message.requestId;
|
|
@@ -338,21 +364,21 @@ export function createGatewayAccountRpc(options) {
|
|
|
338
364
|
return jsonResponse(401, { success: false, code: 'unauthorized', error: 'Invalid or missing token', requestId }, noStore);
|
|
339
365
|
}
|
|
340
366
|
const decision = authorizeConsoleRpc(principal, message);
|
|
341
|
-
const audit = (outcome) => {
|
|
367
|
+
const audit = (outcome, reason) => {
|
|
342
368
|
try {
|
|
343
369
|
registry.accessProvider?.audit?.({
|
|
344
370
|
actor: auditActor(principal),
|
|
345
371
|
action: `rpc:${String(message.type ?? '')}`.slice(0, 256),
|
|
346
372
|
...(decision.allowed && rpcAuditTarget(decision.rule) ? { target: rpcAuditTarget(decision.rule) } : {}),
|
|
347
373
|
outcome,
|
|
348
|
-
detail: rpcAuditDetail(message),
|
|
374
|
+
detail: reason ? { ...rpcAuditDetail(message), reason } : rpcAuditDetail(message),
|
|
349
375
|
...(context.remoteAddress ? { ip: context.remoteAddress } : {}),
|
|
350
376
|
});
|
|
351
377
|
}
|
|
352
378
|
catch { /* audit never breaks a request */ }
|
|
353
379
|
};
|
|
354
380
|
if (!decision.allowed) {
|
|
355
|
-
audit('denied');
|
|
381
|
+
audit('denied', decision.reason);
|
|
356
382
|
return jsonResponse(403, { success: false, code: decision.code, error: decision.error, requestId }, noStore);
|
|
357
383
|
}
|
|
358
384
|
const result = isIamRpc(message.type)
|
|
@@ -361,7 +387,7 @@ export function createGatewayAccountRpc(options) {
|
|
|
361
387
|
if (decision.write)
|
|
362
388
|
audit(result.status < 400 && result.body.success !== false ? 'ok' : 'error');
|
|
363
389
|
else if (result.status === 403)
|
|
364
|
-
audit('denied');
|
|
390
|
+
audit('denied', CONSOLE_DENIAL_REASONS.HANDLER_DENIED);
|
|
365
391
|
return jsonResponse(result.status, result.body, { ...noStore, ...result.headers });
|
|
366
392
|
},
|
|
367
393
|
});
|
|
@@ -43,6 +43,8 @@ export interface GatewayHttpServerOptions {
|
|
|
43
43
|
* RPC type on that route is forwarded to the instance unchanged.
|
|
44
44
|
*/
|
|
45
45
|
readonly consoleRpc?: GatewayConsoleRpc;
|
|
46
|
+
/** Reported on `/pub/health` as `project.id` (`projectIdentity(projectRoot)`). */
|
|
47
|
+
readonly projectId?: string;
|
|
46
48
|
}
|
|
47
49
|
/** One router, many listeners: every listener serves the same surface. */
|
|
48
50
|
export declare class GatewayHttpServer {
|
|
@@ -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,9 @@ 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;
|
|
61
|
+
#projectId;
|
|
58
62
|
#rpcHandlers = new Set();
|
|
59
63
|
#closed = false;
|
|
60
64
|
#closeResult;
|
|
@@ -65,6 +69,7 @@ export class GatewayHttpServer {
|
|
|
65
69
|
this.#retryAfter = String(Math.max(1, Math.round(options.retryAfterSeconds ?? DEFAULT_GATEWAY_RETRY_AFTER_SECONDS)));
|
|
66
70
|
this.#maxBody = options.maxRequestBodyBytes ?? DEFAULT_GATEWAY_MAX_REQUEST_BODY_BYTES;
|
|
67
71
|
this.#consoleRpc = options.consoleRpc;
|
|
72
|
+
this.#projectId = options.projectId;
|
|
68
73
|
this.tokenRegistry = options.tokenRegistry ?? new TokenRegistry({ primaryToken: primary.token });
|
|
69
74
|
this.apiBase = normalizePath(primary.apiBase ?? '/api');
|
|
70
75
|
const routerOptions = {
|
|
@@ -84,6 +89,10 @@ export class GatewayHttpServer {
|
|
|
84
89
|
summary: 'Console RPC (gateway-owned types answered by the gateway, the rest forwarded)',
|
|
85
90
|
tags: ['console'],
|
|
86
91
|
});
|
|
92
|
+
router.route('GET', `${this.apiBase}${CONSOLE_CAPABILITIES_PATH}`, (request, context) => this.#capabilities(request, context), {
|
|
93
|
+
summary: 'Console capabilities (gateway-owned RPC types merged with the instance\'s)',
|
|
94
|
+
tags: ['console'],
|
|
95
|
+
});
|
|
87
96
|
// Lowest precedence: every path no gateway route claims goes to the instance.
|
|
88
97
|
router.route('ALL', '/*', (request, context) => this.#forward(request, context));
|
|
89
98
|
}
|
|
@@ -178,6 +187,7 @@ export class GatewayHttpServer {
|
|
|
178
187
|
status: 'ok',
|
|
179
188
|
timestamp: new Date().toISOString(),
|
|
180
189
|
instance: { state: this.#instance.state(), ready: this.#readyTransport() !== undefined },
|
|
190
|
+
...(this.#projectId ? { project: { id: this.#projectId } } : {}),
|
|
181
191
|
};
|
|
182
192
|
// Polled by the Console's instance banner: never cached.
|
|
183
193
|
return jsonResponse(200, body, { 'cache-control': 'no-store' });
|
|
@@ -243,6 +253,36 @@ export class GatewayHttpServer {
|
|
|
243
253
|
}));
|
|
244
254
|
return this.#instanceError(502, 'instance_error', 'unknown', 'The instance failed to answer', false);
|
|
245
255
|
}
|
|
256
|
+
/**
|
|
257
|
+
* `GET {base}/console/capabilities` (zhinjs/zhin#1250): the RPC types the gateway answers itself,
|
|
258
|
+
* merged with the instance's reply. While the instance is not answering (or predates the route)
|
|
259
|
+
* the reply says `complete: false`, so the Console reads it again later.
|
|
260
|
+
*/
|
|
261
|
+
async #capabilities(request, context) {
|
|
262
|
+
const own = [...this.#rpcHandlers].flatMap(handler => handler.types?.() ?? []);
|
|
263
|
+
const gateway = createConsoleCapabilities({
|
|
264
|
+
rpc: [...(this.#consoleRpc ? Object.values(GATEWAY_RPC) : []), ...own],
|
|
265
|
+
features: [CONSOLE_FEATURES.UNKNOWN_RPC_CODE, CONSOLE_FEATURES.DENIAL_REASONS, CONSOLE_FEATURES.GATEWAY],
|
|
266
|
+
});
|
|
267
|
+
let instance = createConsoleCapabilities({ rpc: [], complete: false });
|
|
268
|
+
const transport = this.#readyTransport();
|
|
269
|
+
// An instance that predates the route refuses it (and audits that): ask it once per route table.
|
|
270
|
+
const revision = transport?.routes().revision;
|
|
271
|
+
const missing = this.#capabilitiesMissing;
|
|
272
|
+
if (transport && !(missing?.transport === transport && missing.revision === revision)) {
|
|
273
|
+
try {
|
|
274
|
+
const response = await this.#forward(request, context);
|
|
275
|
+
const body = response.ok ? await response.json().catch(() => null) : null;
|
|
276
|
+
const parsed = parseConsoleCapabilities(body?.data);
|
|
277
|
+
if (parsed)
|
|
278
|
+
instance = parsed;
|
|
279
|
+
else if (response.status === 403 || response.status === 404)
|
|
280
|
+
this.#capabilitiesMissing = { transport, revision };
|
|
281
|
+
}
|
|
282
|
+
catch { /* the instance part stays unknown */ }
|
|
283
|
+
}
|
|
284
|
+
return jsonResponse(200, { success: true, data: mergeConsoleCapabilities(gateway, instance) }, { 'cache-control': 'no-store' });
|
|
285
|
+
}
|
|
246
286
|
/**
|
|
247
287
|
* `POST {base}/console/request`: the router authenticated and authorized the route; `gateway.*`
|
|
248
288
|
* types are dispatched here (the dispatcher checks the per-RPC permission), the rest are
|
|
@@ -392,6 +432,7 @@ export class GatewayHttpServer {
|
|
|
392
432
|
actor: auditActor(principal),
|
|
393
433
|
action: `ws:${pathname}`,
|
|
394
434
|
outcome: 'denied',
|
|
435
|
+
detail: { reason: webSocketDenialReason(principal, pathname) },
|
|
395
436
|
...(request.remoteAddress ? { ip: request.remoteAddress } : {}),
|
|
396
437
|
});
|
|
397
438
|
}
|
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import { type ConsoleUserPrincipal } from '@zhin.js/host-http';
|
|
2
2
|
export declare const LOCAL_OWNER_PRINCIPAL: ConsoleUserPrincipal;
|
|
@@ -5,12 +5,13 @@
|
|
|
5
5
|
* entries name it `user:local`.
|
|
6
6
|
*/
|
|
7
7
|
import { CONSOLE_PERMISSION_LIST } from '@zhin.js/console-protocol';
|
|
8
|
+
import { LOCAL_SOCKET_CREDENTIAL_ID, LOCAL_SOCKET_USER_ID } from '@zhin.js/host-http';
|
|
8
9
|
export const LOCAL_OWNER_PRINCIPAL = Object.freeze({
|
|
9
10
|
kind: 'user',
|
|
10
|
-
userId:
|
|
11
|
+
userId: LOCAL_SOCKET_USER_ID,
|
|
11
12
|
username: 'local',
|
|
12
13
|
displayName: '本机 CLI(网关控制套接字)',
|
|
13
|
-
credentialId:
|
|
14
|
+
credentialId: LOCAL_SOCKET_CREDENTIAL_ID,
|
|
14
15
|
grants: Object.freeze([Object.freeze({ role: 'owner', scope: Object.freeze({ kind: 'global' }), permissions: CONSOLE_PERMISSION_LIST })]),
|
|
15
16
|
roles: Object.freeze(['owner']),
|
|
16
17
|
});
|
|
@@ -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
|
});
|
|
@@ -176,7 +177,7 @@ async function runSwitch(plugin, enabled, principal, options, type) {
|
|
|
176
177
|
return { result };
|
|
177
178
|
const action = enabled ? '启用' : '停用';
|
|
178
179
|
const text = result.status === 'rollbackFailed'
|
|
179
|
-
? `${action} ${plugin} 失败,且回滚未完成:${result.rollbackError
|
|
180
|
+
? `${action} ${plugin} 失败,且回滚未完成:${[result.rollbackError, result.restartError ? `重新启动实例失败:${result.restartError}` : undefined].filter(Boolean).join(';') || '未知错误'}(原因:${result.error ?? '未知错误'})`
|
|
180
181
|
: `${action} ${plugin} 失败,已回滚:${result.error ?? '未知错误'}`;
|
|
181
182
|
return {
|
|
182
183
|
reply: error(500, result.status === 'rollbackFailed' ? 'rollback_failed' : 'transaction_failed', text, type, {
|
|
@@ -14,7 +14,7 @@ import { GatewayHttpServer } from './gateway-http-server.js';
|
|
|
14
14
|
* optional {@link GatewayConsoleInstancePort} methods (plugin catalog, instance audit,
|
|
15
15
|
* `principal-revoked`) when the transport has them (`IpcInstanceTransport` does).
|
|
16
16
|
*/
|
|
17
|
-
export interface GatewayInstanceTransport extends InstanceTransport, GatewayConsoleInstancePort {
|
|
17
|
+
export interface GatewayInstanceTransport extends InstanceTransport, Omit<GatewayConsoleInstancePort, 'ready' | 'onRoutes'> {
|
|
18
18
|
dispose?(): void | Promise<void>;
|
|
19
19
|
/** Told the bound listener addresses after listen (the instance's `HttpHost.address`). */
|
|
20
20
|
setAddresses?(addresses: readonly import('@zhin.js/host-http').HttpHostAddress[]): void;
|
|
@@ -7,6 +7,7 @@ import { join } from 'node:path';
|
|
|
7
7
|
import { formatCompact, getLogger } from '@zhin.js/logger';
|
|
8
8
|
import { createConfigDocument } from '@zhin.js/runtime';
|
|
9
9
|
import { findConfigFile } from '../project/config-file.js';
|
|
10
|
+
import { projectIdentity } from '../project/identity.js';
|
|
10
11
|
import { loadRuntimeEnvironmentLayers } from '../project/environment.js';
|
|
11
12
|
import { InstanceSupervisor } from '../supervisor/instance-supervisor.js';
|
|
12
13
|
import { GatewayPackageService } from '../packages/service.js';
|
|
@@ -113,6 +114,7 @@ export async function startGateway(options) {
|
|
|
113
114
|
...(options.retryAfterSeconds !== undefined ? { retryAfterSeconds: options.retryAfterSeconds } : {}),
|
|
114
115
|
...(options.maxRequestBodyBytes !== undefined ? { maxRequestBodyBytes: options.maxRequestBodyBytes } : {}),
|
|
115
116
|
consoleRpc: createGatewayConsoleRpc(rpc),
|
|
117
|
+
projectId: projectIdentity(options.projectRoot),
|
|
116
118
|
});
|
|
117
119
|
registry = server.tokenRegistry;
|
|
118
120
|
// The Console's global plugin switch is a gateway transaction (stop → write → start, rollback);
|
package/lib/iam/service.js
CHANGED
|
@@ -460,9 +460,18 @@ export class ConsoleIamService {
|
|
|
460
460
|
row.permissions = JSON.stringify(permissionList(patch.permissions));
|
|
461
461
|
if (Object.keys(row).length)
|
|
462
462
|
await this.#model('roles').update(row).where({ id });
|
|
463
|
+
// Grants of every holder may have shrunk: their long-lived connections and anything the
|
|
464
|
+
// instance derived from their grants (open chat bind codes) re-authorize from scratch.
|
|
465
|
+
if (patch.permissions !== undefined)
|
|
466
|
+
for (const userId of await this.#usersBoundTo(id))
|
|
467
|
+
this.#emitRevocation(userId, 'bindings');
|
|
463
468
|
return withPluginPermissions((await this.#role(id)), this.pluginPermissionCatalog());
|
|
464
469
|
});
|
|
465
470
|
}
|
|
471
|
+
async #usersBoundTo(roleId) {
|
|
472
|
+
const rows = await this.#model('bindings').select('user_id').where({ role_id: roleId });
|
|
473
|
+
return [...new Set(rows.map(row => String(row.user_id)))];
|
|
474
|
+
}
|
|
466
475
|
deleteRole(id) {
|
|
467
476
|
return this.#serialize(async () => {
|
|
468
477
|
const role = await this.#role(id);
|
|
@@ -470,8 +479,11 @@ export class ConsoleIamService {
|
|
|
470
479
|
throw new IamError('not_found', '角色不存在');
|
|
471
480
|
if (role.builtin)
|
|
472
481
|
throw new IamError('forbidden', '内置角色不可删除');
|
|
482
|
+
const holders = await this.#usersBoundTo(id);
|
|
473
483
|
await this.#model('bindings').delete().where({ role_id: id });
|
|
474
484
|
await this.#model('roles').delete().where({ id });
|
|
485
|
+
for (const userId of holders)
|
|
486
|
+
this.#emitRevocation(userId, 'bindings');
|
|
475
487
|
});
|
|
476
488
|
}
|
|
477
489
|
/** Replaces all bindings of a user. */
|
|
@@ -37,7 +37,9 @@ export interface SetupDatabaseInput {
|
|
|
37
37
|
/** Config-file and dotenv access the setup flow needs (ConsoleConfigurationStore satisfies `config`). */
|
|
38
38
|
export interface SetupConfigPort {
|
|
39
39
|
readDocument(): Promise<Record<string, unknown>>;
|
|
40
|
-
setKey(key: string, value: unknown
|
|
40
|
+
setKey(key: string, value: unknown, options?: {
|
|
41
|
+
readonly environmentWrite?: boolean;
|
|
42
|
+
}): Promise<unknown>;
|
|
41
43
|
}
|
|
42
44
|
export interface SetupEnvFilePort {
|
|
43
45
|
read(): Promise<string>;
|
package/lib/iam/setup-service.js
CHANGED
|
@@ -101,7 +101,8 @@ export class ConsoleSetupService {
|
|
|
101
101
|
if (Object.keys(envUpdates).length) {
|
|
102
102
|
await this.#options.envFile.write(upsertDotenv(await this.#options.envFile.read(), envUpdates));
|
|
103
103
|
}
|
|
104
|
-
|
|
104
|
+
// The setup flow writes `.env` itself (bootstrap / owner): secret literals may go there too.
|
|
105
|
+
await this.#options.config.setKey('database', block, { environmentWrite: true });
|
|
105
106
|
const notice = prepared.dialect === 'pg' || prepared.dialect === 'mysql' ? `${SETUP_NO_MIGRATION_NOTICE}${SETUP_CONSOLE_ROLE_NOTICE}` : SETUP_NO_MIGRATION_NOTICE;
|
|
106
107
|
return Object.freeze({ restartRequired: true, notice });
|
|
107
108
|
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
export declare const PNPM_IGNORED_BUILDS_CODE = "ERR_PNPM_IGNORED_BUILDS";
|
|
2
|
+
/** Cap on the package-manager output kept to recognise the error (its summary comes last). */
|
|
3
|
+
export declare const IGNORED_BUILDS_OUTPUT_LIMIT: number;
|
|
4
|
+
/**
|
|
5
|
+
* The packages pnpm reports in `Ignored build scripts: a@1.0.0, @scope/b@2.0.0.`; `undefined` when
|
|
6
|
+
* the output does not carry `ERR_PNPM_IGNORED_BUILDS` (an empty list when the list is unreadable).
|
|
7
|
+
*/
|
|
8
|
+
export declare function parseIgnoredBuilds(output: string): readonly string[] | undefined;
|
|
9
|
+
/** Package names named by an `add` invocation (`add [-w] [-D] <spec>…`); empty for other commands. */
|
|
10
|
+
export declare function addedPackageNames(args: readonly string[]): readonly string[];
|
|
11
|
+
/**
|
|
12
|
+
* Whether the dependencies of `args` are in place after pnpm reported ignored builds: pnpm wrote
|
|
13
|
+
* `node_modules/.modules.yaml` (it does so after linking) and every package an `add` named is in
|
|
14
|
+
* `node_modules`. Only then is the exit treated as a warning.
|
|
15
|
+
*/
|
|
16
|
+
export declare function dependenciesInPlace(projectRoot: string, args: readonly string[]): Promise<boolean>;
|
|
17
|
+
/** The hint printed for ignored builds: same allowance the scaffold writes for native packages. */
|
|
18
|
+
export declare function ignoredBuildsHint(names: readonly string[]): string;
|