@zhin.js/host-http 1.2.2 → 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/lib/access-control.d.ts +41 -3
- package/lib/access-control.js +112 -10
- package/lib/console-rest-pages-helpers.js +24 -2
- package/lib/console-rest-pages.d.ts +4 -1
- package/lib/console-rest-pages.js +49 -23
- package/lib/console-rpc-extended/contracts.d.ts +22 -0
- package/lib/console-rpc-extended/dispatcher.js +1 -1
- package/lib/console-rpc-extended/endpoint-rpc.js +21 -4
- package/lib/console-rpc-extended/index.d.ts +1 -1
- package/lib/console-rpc-extended/schedule-rpc.d.ts +1 -1
- package/lib/console-rpc-extended/schedule-rpc.js +78 -12
- package/lib/console-rpc-handlers.d.ts +9 -1
- package/lib/console-rpc-handlers.js +108 -12
- package/lib/console-rpc-types.d.ts +2 -0
- package/lib/console-rpc-types.js +43 -0
- package/lib/console-rpc.d.ts +29 -1
- package/lib/console-rpc.js +13 -5
- package/lib/host-installer.js +10 -5
- package/lib/http-host.js +5 -4
- package/lib/index.d.ts +6 -3
- package/lib/index.js +4 -1
- package/lib/node/listener.js +2 -1
- package/lib/port.d.ts +27 -0
- package/lib/port.js +68 -0
- package/lib/project-files.d.ts +7 -0
- package/lib/project-files.js +88 -11
- package/lib/unknown-rpc.d.ts +12 -0
- package/lib/unknown-rpc.js +33 -0
- package/package.json +4 -4
- package/src/access-control.ts +151 -11
- package/src/console-rest-pages-helpers.ts +27 -3
- package/src/console-rest-pages.ts +52 -15
- package/src/console-rpc-extended/contracts.ts +18 -0
- package/src/console-rpc-extended/dispatcher.ts +1 -1
- package/src/console-rpc-extended/endpoint-rpc.ts +28 -4
- package/src/console-rpc-extended/index.ts +1 -0
- package/src/console-rpc-extended/schedule-rpc.ts +67 -3
- package/src/console-rpc-handlers.ts +110 -11
- package/src/console-rpc-types.ts +45 -0
- package/src/console-rpc.ts +39 -5
- package/src/host-installer.ts +10 -4
- package/src/http-host.ts +6 -2
- package/src/index.ts +21 -0
- package/src/node/listener.ts +2 -1
- package/src/port.ts +60 -0
- package/src/project-files.ts +95 -11
- package/src/unknown-rpc.ts +44 -0
package/lib/access-control.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { type ConsoleGrant, type ConsolePermission, type ConsolePluginPermissionInfo, type ConsolePluginPermissionLevelResolver, type ConsoleRpcPermissionRule, type ConsoleScopeTarget } from '@zhin.js/console-protocol';
|
|
1
|
+
import { CONSOLE_UNKNOWN_RPC_CODE, type ConsoleDenialReason, type ConsoleGrant, type ConsolePermission, type ConsolePluginPermissionInfo, type ConsolePluginPermissionLevelResolver, type ConsoleRpcPermissionRule, type ConsoleScopeTarget } from '@zhin.js/console-protocol';
|
|
2
2
|
/**
|
|
3
3
|
* Console principals (ADR 0049 §1):
|
|
4
4
|
* - `bootstrap`: the `http.token` before the first owner exists (`open: false`), or a Host
|
|
@@ -58,6 +58,16 @@ export interface ConsoleAccessProvider {
|
|
|
58
58
|
audit?(entry: ConsoleAuditEntry): void;
|
|
59
59
|
}
|
|
60
60
|
export declare function isUserPrincipal(principal: ConsolePrincipal | undefined): principal is ConsoleUserPrincipal;
|
|
61
|
+
/** User id and credential id of the gateway control-socket principal (ADR 0054 §6). */
|
|
62
|
+
export declare const LOCAL_SOCKET_USER_ID = "local";
|
|
63
|
+
export declare const LOCAL_SOCKET_CREDENTIAL_ID = "local-socket";
|
|
64
|
+
/**
|
|
65
|
+
* The principal of a request from the project owner's CLI over `.zhin/gateway.sock` (no token).
|
|
66
|
+
* Sessions and API tokens never carry this credential id (theirs are token hashes / token ids).
|
|
67
|
+
*/
|
|
68
|
+
export declare function isLocalSocketPrincipal(principal: ConsolePrincipal | undefined): boolean;
|
|
69
|
+
/** A user principal with a global `owner` binding (the local socket principal is one). */
|
|
70
|
+
export declare function isGlobalOwnerPrincipal(principal: ConsolePrincipal | undefined): boolean;
|
|
61
71
|
/**
|
|
62
72
|
* Whether the request carried an explicit credential: a user session / API token, or the
|
|
63
73
|
* still-valid bootstrap token (`{ kind: 'bootstrap', open: false }`). An open Host is not explicit.
|
|
@@ -93,12 +103,36 @@ export type ConsoleRpcDecision = Readonly<{
|
|
|
93
103
|
delegated?: true;
|
|
94
104
|
}> | Readonly<{
|
|
95
105
|
allowed: false;
|
|
96
|
-
|
|
106
|
+
/**
|
|
107
|
+
* `unknown_rpc`: the Host has no handler and no permission entry for the type (zhinjs/zhin#1250).
|
|
108
|
+
* Nothing runs; it is not a security refusal and callers do not audit it (see
|
|
109
|
+
* {@link isAuditedRpcDenial}). Every other code is a refusal that is audited.
|
|
110
|
+
*/
|
|
111
|
+
code: 'setup_required' | 'unmapped' | 'forbidden' | typeof CONSOLE_UNKNOWN_RPC_CODE;
|
|
97
112
|
error: string;
|
|
98
113
|
permission: ConsolePermission | null;
|
|
114
|
+
/** Audit `detail.reason`; absent for `unknown_rpc`. */
|
|
115
|
+
reason?: ConsoleDenialReason;
|
|
99
116
|
}>;
|
|
117
|
+
export interface AuthorizeConsoleRpcOptions {
|
|
118
|
+
/**
|
|
119
|
+
* Whether this Host has a handler for an RPC type that is missing from the permission map. True
|
|
120
|
+
* (or no predicate) keeps the audited `unmapped` refusal — a handler without a permission entry
|
|
121
|
+
* is a development error; false answers `unknown_rpc`. Mapped types never consult it.
|
|
122
|
+
*/
|
|
123
|
+
readonly registered?: (type: string) => boolean;
|
|
124
|
+
}
|
|
125
|
+
/** Whether a refused RPC is written to the audit log (`unknown_rpc` is not). */
|
|
126
|
+
export declare function isAuditedRpcDenial(decision: ConsoleRpcDecision): boolean;
|
|
127
|
+
/** Error text of an unknown RPC: the (truncated) type and nothing else. */
|
|
128
|
+
export declare function unknownConsoleRpcError(type: unknown): string;
|
|
100
129
|
/** RPC authorization for one principal and message (deny by default). */
|
|
101
|
-
export declare function authorizeConsoleRpc(principal: ConsolePrincipal, message: Readonly<Record<string, unknown
|
|
130
|
+
export declare function authorizeConsoleRpc(principal: ConsolePrincipal, message: Readonly<Record<string, unknown>>, options?: AuthorizeConsoleRpcOptions): ConsoleRpcDecision;
|
|
131
|
+
/**
|
|
132
|
+
* Why grants refuse `permission` at a target: `scope_denied` when some binding holds it at another
|
|
133
|
+
* adapter / endpoint, else `insufficient_permission`.
|
|
134
|
+
*/
|
|
135
|
+
export declare function grantsDenialReason(grants: readonly ConsoleGrant[], permission: string): ConsoleDenialReason;
|
|
102
136
|
/**
|
|
103
137
|
* For plugin RPC: whether the principal holds the rule's write permission (`feature.configure`,
|
|
104
138
|
* or `endpoint.manage` for adapter account methods) at its target. The plugin RPC dispatcher
|
|
@@ -114,6 +148,8 @@ export type ConsoleHttpDecision = Readonly<{
|
|
|
114
148
|
code: 'unauthorized' | 'forbidden' | 'setup_required';
|
|
115
149
|
error: string;
|
|
116
150
|
permission: string | null;
|
|
151
|
+
/** Audit `detail.reason`. */
|
|
152
|
+
reason: ConsoleDenialReason;
|
|
117
153
|
}>;
|
|
118
154
|
/**
|
|
119
155
|
* HTTP authorization of a route under the API base. `principal` is undefined when no valid
|
|
@@ -121,6 +157,8 @@ export type ConsoleHttpDecision = Readonly<{
|
|
|
121
157
|
*/
|
|
122
158
|
export declare function authorizeConsoleHttp(principal: ConsolePrincipal | undefined, method: string, relativePath: string): ConsoleHttpDecision;
|
|
123
159
|
/** WebSocket upgrade authorization for non-bootstrap principals. */
|
|
160
|
+
/** Audit `detail.reason` of a WebSocket {@link authorizeConsoleWebSocket} refused. */
|
|
161
|
+
export declare function webSocketDenialReason(principal: ConsolePrincipal, pathname: string): ConsoleDenialReason;
|
|
124
162
|
export declare function authorizeConsoleWebSocket(principal: ConsolePrincipal, pathname: string): boolean;
|
|
125
163
|
/**
|
|
126
164
|
* JSON length of the detail of one audit entry. The gateway stores at most 4096 characters of
|
package/lib/access-control.js
CHANGED
|
@@ -1,8 +1,24 @@
|
|
|
1
|
-
import { CONSOLE_PERMISSION_LIST, CONSOLE_WRITE_PERMISSIONS, expandPluginPermissions, grantsAllow, isPluginConsoleRpcType, resolveConsoleHttpPermission, resolveConsoleRpcPermission, resolveConsoleWsPermission, } from '@zhin.js/console-protocol';
|
|
1
|
+
import { CONSOLE_DENIAL_REASONS, CONSOLE_PERMISSION_LIST, CONSOLE_UNKNOWN_RPC_CODE, CONSOLE_WRITE_PERMISSIONS, expandPluginPermissions, grantsAllow, grantsAllowAlsoRequired, isPluginConsoleRpcType, resolveConsoleHttpPermission, resolveConsoleRpcPermission, resolveConsoleWsPermission, } from '@zhin.js/console-protocol';
|
|
2
2
|
import { hasForwardingHeaders } from './trust-proxy.js';
|
|
3
3
|
export function isUserPrincipal(principal) {
|
|
4
4
|
return principal?.kind === 'user' || principal?.kind === 'api-token';
|
|
5
5
|
}
|
|
6
|
+
/** User id and credential id of the gateway control-socket principal (ADR 0054 §6). */
|
|
7
|
+
export const LOCAL_SOCKET_USER_ID = 'local';
|
|
8
|
+
export const LOCAL_SOCKET_CREDENTIAL_ID = 'local-socket';
|
|
9
|
+
/**
|
|
10
|
+
* The principal of a request from the project owner's CLI over `.zhin/gateway.sock` (no token).
|
|
11
|
+
* Sessions and API tokens never carry this credential id (theirs are token hashes / token ids).
|
|
12
|
+
*/
|
|
13
|
+
export function isLocalSocketPrincipal(principal) {
|
|
14
|
+
return principal?.kind === 'user' && principal.userId === LOCAL_SOCKET_USER_ID
|
|
15
|
+
&& principal.credentialId === LOCAL_SOCKET_CREDENTIAL_ID;
|
|
16
|
+
}
|
|
17
|
+
/** A user principal with a global `owner` binding (the local socket principal is one). */
|
|
18
|
+
export function isGlobalOwnerPrincipal(principal) {
|
|
19
|
+
return isUserPrincipal(principal)
|
|
20
|
+
&& principal.grants.some((grant) => grant.role === 'owner' && grant.scope.kind === 'global');
|
|
21
|
+
}
|
|
6
22
|
/**
|
|
7
23
|
* Whether the request carried an explicit credential: a user session / API token, or the
|
|
8
24
|
* still-valid bootstrap token (`{ kind: 'bootstrap', open: false }`). An open Host is not explicit.
|
|
@@ -55,27 +71,67 @@ export function globalPermissions(principal, pluginPermissions = []) {
|
|
|
55
71
|
}
|
|
56
72
|
return expandPluginPermissions([...held], pluginPermissions);
|
|
57
73
|
}
|
|
74
|
+
/** Whether a refused RPC is written to the audit log (`unknown_rpc` is not). */
|
|
75
|
+
export function isAuditedRpcDenial(decision) {
|
|
76
|
+
return !decision.allowed && decision.code !== CONSOLE_UNKNOWN_RPC_CODE;
|
|
77
|
+
}
|
|
78
|
+
/** Error text of an unknown RPC: the (truncated) type and nothing else. */
|
|
79
|
+
export function unknownConsoleRpcError(type) {
|
|
80
|
+
return `RPC "${String(type ?? '').slice(0, 64)}" 不存在`;
|
|
81
|
+
}
|
|
58
82
|
/** RPC authorization for one principal and message (deny by default). */
|
|
59
|
-
export function authorizeConsoleRpc(principal, message) {
|
|
83
|
+
export function authorizeConsoleRpc(principal, message, options = {}) {
|
|
60
84
|
const rule = resolveConsoleRpcPermission(message.type, message);
|
|
61
85
|
const permission = rule ? (rule.kind === 'exact' ? rule.permission : rule.read) : null;
|
|
62
86
|
if (principal.kind === 'bootstrap') {
|
|
63
87
|
if (principal.open) {
|
|
64
88
|
return Object.freeze({ allowed: true, rule, permission, write: rule ? isWriteRule(rule) : true });
|
|
65
89
|
}
|
|
66
|
-
return Object.freeze({
|
|
90
|
+
return Object.freeze({
|
|
91
|
+
allowed: false, code: 'setup_required', error: '请先完成初始化:创建第一个所有者', permission,
|
|
92
|
+
reason: CONSOLE_DENIAL_REASONS.SETUP_REQUIRED,
|
|
93
|
+
});
|
|
67
94
|
}
|
|
68
95
|
if (!rule) {
|
|
69
|
-
|
|
96
|
+
const type = typeof message.type === 'string' ? message.type : '';
|
|
97
|
+
if (options.registered && !options.registered(type)) {
|
|
98
|
+
return Object.freeze({ allowed: false, code: CONSOLE_UNKNOWN_RPC_CODE, error: unknownConsoleRpcError(message.type), permission: null });
|
|
99
|
+
}
|
|
100
|
+
return Object.freeze({
|
|
101
|
+
allowed: false, code: 'unmapped', error: `RPC "${String(message.type ?? '')}" 未登记权限`, permission: null,
|
|
102
|
+
reason: CONSOLE_DENIAL_REASONS.UNMAPPED_PERMISSION,
|
|
103
|
+
});
|
|
70
104
|
}
|
|
71
105
|
if (isPluginConsoleRpcType(message.type)) {
|
|
72
106
|
return Object.freeze({ allowed: true, rule, permission, write: isWriteRule(rule), delegated: true });
|
|
73
107
|
}
|
|
74
108
|
if (!grantsAllow(principal.grants, permission, rule.target)) {
|
|
75
|
-
return Object.freeze({
|
|
109
|
+
return Object.freeze({
|
|
110
|
+
allowed: false, code: 'forbidden', error: `缺少权限 ${permission}`, permission,
|
|
111
|
+
reason: grantsDenialReason(principal.grants, permission),
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
// `alsoRequires` is held globally (same semantics as grantsAllowAlsoRequired): find the first missing one for the denial reason.
|
|
115
|
+
const missing = rule.kind === 'exact' && !grantsAllowAlsoRequired(principal.grants, rule)
|
|
116
|
+
? rule.alsoRequires?.find(extra => !grantsAllowAlsoRequired(principal.grants, { ...rule, alsoRequires: [extra] }))
|
|
117
|
+
: undefined;
|
|
118
|
+
if (missing) {
|
|
119
|
+
return Object.freeze({
|
|
120
|
+
allowed: false, code: 'forbidden', error: `缺少权限 ${missing}`, permission,
|
|
121
|
+
reason: grantsDenialReason(principal.grants, missing),
|
|
122
|
+
});
|
|
76
123
|
}
|
|
77
124
|
return Object.freeze({ allowed: true, rule, permission, write: isWriteRule(rule) });
|
|
78
125
|
}
|
|
126
|
+
/**
|
|
127
|
+
* Why grants refuse `permission` at a target: `scope_denied` when some binding holds it at another
|
|
128
|
+
* adapter / endpoint, else `insufficient_permission`.
|
|
129
|
+
*/
|
|
130
|
+
export function grantsDenialReason(grants, permission) {
|
|
131
|
+
return grants.some(grant => grant.scope.kind !== 'global' && grant.permissions.includes(permission))
|
|
132
|
+
? CONSOLE_DENIAL_REASONS.SCOPE_DENIED
|
|
133
|
+
: CONSOLE_DENIAL_REASONS.INSUFFICIENT_PERMISSION;
|
|
134
|
+
}
|
|
79
135
|
function isWriteRule(rule) {
|
|
80
136
|
return rule.kind === 'exact' ? CONSOLE_WRITE_PERMISSIONS.has(rule.permission) : false;
|
|
81
137
|
}
|
|
@@ -90,7 +146,7 @@ export function pluginRpcWriteAllowed(principal, rule) {
|
|
|
90
146
|
if (!rule)
|
|
91
147
|
return false;
|
|
92
148
|
const permission = rule.kind === 'exact' ? rule.permission : rule.write;
|
|
93
|
-
return grantsAllow(principal.grants, permission, rule.target);
|
|
149
|
+
return grantsAllow(principal.grants, permission, rule.target) && grantsAllowAlsoRequired(principal.grants, rule);
|
|
94
150
|
}
|
|
95
151
|
/**
|
|
96
152
|
* HTTP authorization of a route under the API base. `principal` is undefined when no valid
|
|
@@ -102,15 +158,15 @@ export function authorizeConsoleHttp(principal, method, relativePath) {
|
|
|
102
158
|
if (rule?.permission === 'public')
|
|
103
159
|
return Object.freeze({ allowed: true, permission });
|
|
104
160
|
if (!principal) {
|
|
105
|
-
return Object.freeze({ allowed: false, status: 401, code: 'unauthorized', error: 'Invalid or missing token', permission });
|
|
161
|
+
return Object.freeze({ allowed: false, status: 401, code: 'unauthorized', error: 'Invalid or missing token', permission, reason: CONSOLE_DENIAL_REASONS.NOT_AUTHENTICATED });
|
|
106
162
|
}
|
|
107
163
|
if (principal.kind === 'bootstrap') {
|
|
108
164
|
if (principal.open || rule?.permission === 'bootstrap')
|
|
109
165
|
return Object.freeze({ allowed: true, permission });
|
|
110
|
-
return Object.freeze({ allowed: false, status: 403, code: 'setup_required', error: '初始化令牌只能用于创建第一个所有者', permission });
|
|
166
|
+
return Object.freeze({ allowed: false, status: 403, code: 'setup_required', error: '初始化令牌只能用于创建第一个所有者', permission, reason: CONSOLE_DENIAL_REASONS.SETUP_REQUIRED });
|
|
111
167
|
}
|
|
112
168
|
if (!rule) {
|
|
113
|
-
return Object.freeze({ allowed: false, status: 403, code: 'forbidden', error: 'Route is not registered with a Console permission', permission: null });
|
|
169
|
+
return Object.freeze({ allowed: false, status: 403, code: 'forbidden', error: 'Route is not registered with a Console permission', permission: null, reason: CONSOLE_DENIAL_REASONS.UNMAPPED_ROUTE });
|
|
114
170
|
}
|
|
115
171
|
if (rule.permission === 'rpc' || rule.permission === 'authenticated' || rule.permission === 'bootstrap') {
|
|
116
172
|
return Object.freeze({ allowed: true, permission });
|
|
@@ -118,9 +174,20 @@ export function authorizeConsoleHttp(principal, method, relativePath) {
|
|
|
118
174
|
// Scoped permissions over plain HTTP have no knowable target: a global binding is required.
|
|
119
175
|
if (grantsAllow(principal.grants, rule.permission))
|
|
120
176
|
return Object.freeze({ allowed: true, permission });
|
|
121
|
-
return Object.freeze({
|
|
177
|
+
return Object.freeze({
|
|
178
|
+
allowed: false, status: 403, code: 'forbidden', error: `缺少权限 ${rule.permission}`, permission,
|
|
179
|
+
reason: CONSOLE_DENIAL_REASONS.INSUFFICIENT_PERMISSION,
|
|
180
|
+
});
|
|
122
181
|
}
|
|
123
182
|
/** WebSocket upgrade authorization for non-bootstrap principals. */
|
|
183
|
+
/** Audit `detail.reason` of a WebSocket {@link authorizeConsoleWebSocket} refused. */
|
|
184
|
+
export function webSocketDenialReason(principal, pathname) {
|
|
185
|
+
if (principal.kind === 'bootstrap')
|
|
186
|
+
return CONSOLE_DENIAL_REASONS.SETUP_REQUIRED;
|
|
187
|
+
return resolveConsoleWsPermission(pathname) === null
|
|
188
|
+
? CONSOLE_DENIAL_REASONS.UNMAPPED_ROUTE
|
|
189
|
+
: CONSOLE_DENIAL_REASONS.INSUFFICIENT_PERMISSION;
|
|
190
|
+
}
|
|
124
191
|
export function authorizeConsoleWebSocket(principal, pathname) {
|
|
125
192
|
// Only the Console's own sockets are host-authenticated; plugin sockets use `auth: 'protocol'`.
|
|
126
193
|
const permission = resolveConsoleWsPermission(pathname);
|
|
@@ -202,8 +269,43 @@ export function rpcAuditDetail(message, reply, options) {
|
|
|
202
269
|
type: String(message.type ?? '').slice(0, 256),
|
|
203
270
|
keys: Object.freeze([...new Set([...keys, ...data])].slice(0, 64).map(key => key.slice(0, 128))),
|
|
204
271
|
...databaseStatementDetail(message, reply, options),
|
|
272
|
+
...envDetail(message),
|
|
205
273
|
}));
|
|
206
274
|
}
|
|
275
|
+
const auditName = (value, max) => typeof value === 'string' && value ? value.slice(0, max) : undefined;
|
|
276
|
+
/**
|
|
277
|
+
* Which dotenv file and variable names an `env:reveal` / `env:save` touched (issue #1250). Never a
|
|
278
|
+
* value: `changes[].value` and `content` are not read.
|
|
279
|
+
*/
|
|
280
|
+
function envDetail(message) {
|
|
281
|
+
if (message.type !== 'env:reveal' && message.type !== 'env:save')
|
|
282
|
+
return {};
|
|
283
|
+
const data = message.data && typeof message.data === 'object' && !Array.isArray(message.data) ? message.data : {};
|
|
284
|
+
const pick = (key) => message[key] ?? data[key];
|
|
285
|
+
const file = auditName(pick('filename'), 64);
|
|
286
|
+
if (message.type === 'env:reveal') {
|
|
287
|
+
const key = auditName(pick('key'), 128);
|
|
288
|
+
return { ...(file ? { file } : {}), ...(key ? { key } : {}) };
|
|
289
|
+
}
|
|
290
|
+
const changes = Array.isArray(pick('changes')) ? pick('changes') : undefined;
|
|
291
|
+
if (!changes)
|
|
292
|
+
return { ...(file ? { file } : {}), ...(pick('content') !== undefined ? { mode: 'replace' } : {}) };
|
|
293
|
+
const set = [];
|
|
294
|
+
const deleted = [];
|
|
295
|
+
for (const change of changes.slice(0, 64)) {
|
|
296
|
+
if (!change || typeof change !== 'object')
|
|
297
|
+
continue;
|
|
298
|
+
const key = auditName(change.key, 128);
|
|
299
|
+
if (key)
|
|
300
|
+
(change.delete === true ? deleted : set).push(key);
|
|
301
|
+
}
|
|
302
|
+
return {
|
|
303
|
+
...(file ? { file } : {}),
|
|
304
|
+
set: Object.freeze(set),
|
|
305
|
+
deleted: Object.freeze(deleted),
|
|
306
|
+
...(changes.length > 64 ? { truncated: true } : {}),
|
|
307
|
+
};
|
|
308
|
+
}
|
|
207
309
|
export function rpcAuditTarget(rule) {
|
|
208
310
|
if (!rule)
|
|
209
311
|
return undefined;
|
|
@@ -15,7 +15,13 @@ const INTROSPECTION_PAGE_SIZES = {
|
|
|
15
15
|
endpoints: 30,
|
|
16
16
|
bindings: 30,
|
|
17
17
|
mcp: 30,
|
|
18
|
+
'recent-turns': 20,
|
|
18
19
|
};
|
|
20
|
+
/** Owning plugin of a capability row (`ConsoleIntrospectionOwner`, #1250): searchable by `q`. */
|
|
21
|
+
const OWNER_FIELDS = [
|
|
22
|
+
(item) => stringProp(item, 'pluginInstance'),
|
|
23
|
+
(item) => stringProp(item, 'pluginPackage'),
|
|
24
|
+
];
|
|
19
25
|
// ---------------------------------------------------------------------------
|
|
20
26
|
// Runtime snapshot introspection
|
|
21
27
|
// ---------------------------------------------------------------------------
|
|
@@ -28,6 +34,7 @@ export function registerIntrospectionRoutes(route, base, ctx, options) {
|
|
|
28
34
|
(c) => stringProp(c, 'pattern'),
|
|
29
35
|
(c) => stringProp(c, 'desc'),
|
|
30
36
|
(c) => stringProp(c, 'plugin'),
|
|
37
|
+
...OWNER_FIELDS,
|
|
31
38
|
],
|
|
32
39
|
},
|
|
33
40
|
{
|
|
@@ -39,6 +46,7 @@ export function registerIntrospectionRoutes(route, base, ctx, options) {
|
|
|
39
46
|
(m) => stringProp(m, 'phase'),
|
|
40
47
|
(m) => stringProp(m, 'target'),
|
|
41
48
|
(m) => stringProp(m, 'source'),
|
|
49
|
+
...OWNER_FIELDS,
|
|
42
50
|
],
|
|
43
51
|
},
|
|
44
52
|
{
|
|
@@ -48,6 +56,7 @@ export function registerIntrospectionRoutes(route, base, ctx, options) {
|
|
|
48
56
|
(c) => stringProp(c, 'name'),
|
|
49
57
|
(c) => stringProp(c, 'owner'),
|
|
50
58
|
(c) => stringProp(c, 'source'),
|
|
59
|
+
...OWNER_FIELDS,
|
|
51
60
|
],
|
|
52
61
|
},
|
|
53
62
|
{
|
|
@@ -64,11 +73,22 @@ export function registerIntrospectionRoutes(route, base, ctx, options) {
|
|
|
64
73
|
},
|
|
65
74
|
{
|
|
66
75
|
kind: 'bindings',
|
|
67
|
-
collect: (value) => ({ rows: value?.bindings?.() ?? [] }),
|
|
76
|
+
collect: async (value) => ({ rows: await value?.bindings?.() ?? [] }),
|
|
68
77
|
fields: [
|
|
69
78
|
(a) => stringProp(a, 'name'),
|
|
70
79
|
(a) => stringProp(a, 'provider'),
|
|
71
80
|
(a) => stringProp(a, 'model'),
|
|
81
|
+
(a) => stringProp(a, 'displayName'),
|
|
82
|
+
],
|
|
83
|
+
},
|
|
84
|
+
{
|
|
85
|
+
// #1250: the Agent workbench's recent conversations come from the turn trace archive, newest first.
|
|
86
|
+
kind: 'recent-turns',
|
|
87
|
+
collect: async (value) => ({ rows: await value?.recentTurns?.() ?? [] }),
|
|
88
|
+
fields: [
|
|
89
|
+
(t) => stringProp(t, 'sessionDisplayName'),
|
|
90
|
+
(t) => stringProp(t, 'sessionKey'),
|
|
91
|
+
(t) => stringProp(t, 'status'),
|
|
72
92
|
],
|
|
73
93
|
},
|
|
74
94
|
{
|
|
@@ -78,6 +98,7 @@ export function registerIntrospectionRoutes(route, base, ctx, options) {
|
|
|
78
98
|
(t) => stringProp(t, 'name'),
|
|
79
99
|
(t) => stringProp(t, 'source'),
|
|
80
100
|
(t) => stringProp(t, 'description'),
|
|
101
|
+
...OWNER_FIELDS,
|
|
81
102
|
],
|
|
82
103
|
},
|
|
83
104
|
{
|
|
@@ -91,6 +112,7 @@ export function registerIntrospectionRoutes(route, base, ctx, options) {
|
|
|
91
112
|
(section) => stringProp(section, 'layer'),
|
|
92
113
|
(section) => stringProp(section, 'retention'),
|
|
93
114
|
(section) => stringProp(section, 'source'),
|
|
115
|
+
...OWNER_FIELDS,
|
|
94
116
|
],
|
|
95
117
|
},
|
|
96
118
|
{
|
|
@@ -113,7 +135,7 @@ export function registerIntrospectionRoutes(route, base, ctx, options) {
|
|
|
113
135
|
const lease = kind === 'endpoints' ? null : ctx.acquireAgentRuntime?.() ?? null;
|
|
114
136
|
try {
|
|
115
137
|
const introspection = lease?.value.introspection;
|
|
116
|
-
const { rows, note } = collect(introspection);
|
|
138
|
+
const { rows, note } = await collect(introspection);
|
|
117
139
|
const query = url.searchParams;
|
|
118
140
|
const filter = query.get('filter')?.trim() || undefined;
|
|
119
141
|
const filtered = filterByFields([...rows], filter, fields);
|
|
@@ -35,7 +35,10 @@ export interface ConsoleAgentIntrospection {
|
|
|
35
35
|
commands?(): readonly unknown[];
|
|
36
36
|
middlewares?(): readonly unknown[];
|
|
37
37
|
components?(): readonly unknown[];
|
|
38
|
-
bindings
|
|
38
|
+
/** Agent bindings with the provider / model they actually run on (may read the provider graph). */
|
|
39
|
+
bindings?(): readonly unknown[] | Promise<readonly unknown[]>;
|
|
40
|
+
/** Recent Agent turns from the trace archive, newest first (#1250). */
|
|
41
|
+
recentTurns?(): readonly unknown[] | Promise<readonly unknown[]>;
|
|
39
42
|
tools?(): readonly unknown[];
|
|
40
43
|
promptSections?(): readonly unknown[];
|
|
41
44
|
mcp?(): {
|
|
@@ -11,6 +11,7 @@ import { join } from 'node:path';
|
|
|
11
11
|
import { readJsonBody } from './json-body.js';
|
|
12
12
|
import { jsonResponse } from '@zhin.js/host-http-contract';
|
|
13
13
|
import { MarketplaceDetailCache, MarketplaceRegistry, isValidNpmPackageName, } from './marketplace-registry.js';
|
|
14
|
+
import { informativeKeywords, isLocale, normalizePluginCategory, packageAuthorName, pickLocalizedText, readPluginMarketplaceMetadata, } from '@zhin.js/plugin-runtime';
|
|
14
15
|
import { clampPositiveInt, decodePathParam, logInternalError, normalizeBase, parsePositiveInt, registerAgentSessionRoutes, registerIntrospectionRoutes, requireWriteScope, trimTrailingSlashes, } from './console-rest-pages-helpers.js';
|
|
15
16
|
const PLUGINS_CACHE_TTL = 5 * 60 * 1000;
|
|
16
17
|
const PACKAGE_DETAIL_CACHE_TTL = 5 * 60 * 1000;
|
|
@@ -202,6 +203,10 @@ function registerLogsRoutes(route, base, ctx) {
|
|
|
202
203
|
});
|
|
203
204
|
}, { summary: 'Cleanup old system logs', tags: ['console', 'logs'] });
|
|
204
205
|
}
|
|
206
|
+
/** Locale-keyed texts from an untrusted registry entry (reuses the published-manifest reader). */
|
|
207
|
+
function localizedTextOf(value) {
|
|
208
|
+
return readPluginMarketplaceMetadata({ metadata: { displayNameI18n: value } }).displayNameI18n;
|
|
209
|
+
}
|
|
205
210
|
// ---------------------------------------------------------------------------
|
|
206
211
|
// marketplace(legacy marketplace-rest-api.ts;plugins.json + npmmirror)
|
|
207
212
|
// ---------------------------------------------------------------------------
|
|
@@ -225,20 +230,25 @@ function registerMarketplaceRoutes(route, base, ctx, fetchFn, pluginRegistryUrl,
|
|
|
225
230
|
const versions = meta.versions;
|
|
226
231
|
const latestInfo = latest && versions ? versions[latest] : undefined;
|
|
227
232
|
const time = meta.time;
|
|
233
|
+
// Marketplace fields come from the published manifest (`zhin.metadata`); the author is the
|
|
234
|
+
// package's author / maintainers, never the publishing account (`_npmUser`, a release bot).
|
|
235
|
+
const market = readPluginMarketplaceMetadata(latestInfo?.zhin);
|
|
228
236
|
return {
|
|
229
237
|
name: meta.name,
|
|
230
238
|
version: latest,
|
|
231
|
-
|
|
239
|
+
...(market.displayName ? { displayName: market.displayName } : {}),
|
|
240
|
+
...(market.displayNameI18n ? { displayNameI18n: market.displayNameI18n } : {}),
|
|
241
|
+
...(market.descriptionI18n ? { descriptionI18n: market.descriptionI18n } : {}),
|
|
242
|
+
...(market.category ? { category: market.category } : {}),
|
|
243
|
+
description: latestInfo?.description || meta.description || '',
|
|
232
244
|
readme: meta.readme || '',
|
|
233
245
|
license: meta.license || latestInfo?.license || '',
|
|
234
246
|
homepage: meta.homepage || latestInfo?.homepage || '',
|
|
235
247
|
repository: meta.repository?.url ||
|
|
236
248
|
latestInfo?.repository?.url ||
|
|
237
249
|
'',
|
|
238
|
-
author:
|
|
239
|
-
|
|
240
|
-
: meta.author?.name || '',
|
|
241
|
-
keywords: latestInfo?.keywords || [],
|
|
250
|
+
author: packageAuthorName(latestInfo ?? {}) ?? packageAuthorName(meta) ?? '',
|
|
251
|
+
keywords: informativeKeywords(latestInfo?.keywords),
|
|
242
252
|
engines: latestInfo?.engines || {},
|
|
243
253
|
peerDependencies: latestInfo?.peerDependencies || {},
|
|
244
254
|
versions: Object.keys(versions || {}),
|
|
@@ -250,30 +260,46 @@ function registerMarketplaceRoutes(route, base, ctx, fetchFn, pluginRegistryUrl,
|
|
|
250
260
|
const q = url.searchParams.get('q') ?? url.searchParams.get('keyword') ?? '';
|
|
251
261
|
const pageNum = Math.max(1, parseInt(url.searchParams.get('page') ?? '1', 10) || 1);
|
|
252
262
|
const pageSize = Math.min(100, Math.max(1, parseInt(url.searchParams.get('size') ?? url.searchParams.get('limit') ?? '20', 10) || 20));
|
|
253
|
-
const
|
|
263
|
+
const rawCategory = url.searchParams.get('category') || undefined;
|
|
264
|
+
const category = rawCategory === undefined ? undefined : normalizePluginCategory(rawCategory) ?? rawCategory;
|
|
254
265
|
const official = url.searchParams.get('official') ?? undefined;
|
|
266
|
+
const rawLocale = url.searchParams.get('locale');
|
|
267
|
+
const locale = isLocale(rawLocale) ? rawLocale : undefined;
|
|
255
268
|
const searchKeyword = q.trim().toLowerCase();
|
|
256
269
|
try {
|
|
257
270
|
const allPlugins = (await registry.list());
|
|
258
|
-
let plugins = allPlugins.map((p) =>
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
description
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
271
|
+
let plugins = allPlugins.map((p) => {
|
|
272
|
+
const displayNameI18n = localizedTextOf(p.displayNameI18n);
|
|
273
|
+
const descriptionI18n = localizedTextOf(p.descriptionI18n);
|
|
274
|
+
const displayName = typeof p.displayName === 'string' ? p.displayName : '';
|
|
275
|
+
const description = typeof p.description === 'string' ? p.description : '';
|
|
276
|
+
return {
|
|
277
|
+
name: p.name,
|
|
278
|
+
// `locale` resolves the display texts for the caller; translations stay available either way.
|
|
279
|
+
displayName: locale ? pickLocalizedText(displayNameI18n, locale, displayName) ?? '' : displayName,
|
|
280
|
+
...(displayNameI18n ? { displayNameI18n } : {}),
|
|
281
|
+
version: p.version || '',
|
|
282
|
+
description: locale ? pickLocalizedText(descriptionI18n, locale, description) ?? '' : description,
|
|
283
|
+
...(descriptionI18n ? { descriptionI18n } : {}),
|
|
284
|
+
author: p.author || '',
|
|
285
|
+
isOfficial: typeof p.name === 'string' && /^@zhin\.js\/[a-z0-9][a-z0-9._-]*$/.test(p.name),
|
|
286
|
+
official: typeof p.name === 'string' && /^@zhin\.js\/[a-z0-9][a-z0-9._-]*$/.test(p.name),
|
|
287
|
+
category: normalizePluginCategory(p.category) ?? 'utility',
|
|
288
|
+
keywords: informativeKeywords(p.tags),
|
|
289
|
+
npm: p.npm || `https://www.npmjs.com/package/${p.name}`,
|
|
290
|
+
date: p.lastUpdate || '',
|
|
291
|
+
downloads: p.downloads || { weekly: 0, monthly: 0 },
|
|
292
|
+
readme: p.readme || '',
|
|
293
|
+
license: p.license || '',
|
|
294
|
+
};
|
|
295
|
+
});
|
|
274
296
|
if (searchKeyword) {
|
|
275
297
|
plugins = plugins.filter((p) => {
|
|
276
|
-
const haystack = [
|
|
298
|
+
const haystack = [
|
|
299
|
+
p.name, p.displayName, p.description,
|
|
300
|
+
...Object.values(p.displayNameI18n ?? {}), ...Object.values(p.descriptionI18n ?? {}),
|
|
301
|
+
...p.keywords,
|
|
302
|
+
]
|
|
277
303
|
.join(' ')
|
|
278
304
|
.toLowerCase();
|
|
279
305
|
return haystack.includes(searchKeyword);
|
|
@@ -21,6 +21,11 @@ export interface ConsoleRpcExtendedCtx {
|
|
|
21
21
|
};
|
|
22
22
|
/** Narrow generation-bound persistent-job port; null when Assistant is disabled. */
|
|
23
23
|
resolveScheduleEngine?: () => ConsoleScheduleEngine | null | undefined;
|
|
24
|
+
/**
|
|
25
|
+
* Resolves a persistent job's delivery target (#1250): `{ resolved, display?, reason? }`, where
|
|
26
|
+
* `resolved: false` means the job's results cannot be delivered (no target / unknown account).
|
|
27
|
+
*/
|
|
28
|
+
describeScheduleTarget?: (notify: unknown) => ConsoleScheduleTarget;
|
|
24
29
|
/** LoginAssist list/submit/cancel(刷新后仍可消费扫码/滑块待办);ADR 0058 flow methods are optional. */
|
|
25
30
|
loginAssist?: ConsoleLoginAssistPort;
|
|
26
31
|
/** ADR 0058: the Endpoint's login declaration and lease-scoped login control (`null` = unknown Endpoint). */
|
|
@@ -182,6 +187,16 @@ export interface ScheduleJobRow {
|
|
|
182
187
|
id: string;
|
|
183
188
|
cron: string;
|
|
184
189
|
description?: string;
|
|
190
|
+
/** Host display metadata (`ScheduleJobDisplay` in `@zhin.js/plugin-runtime`). */
|
|
191
|
+
title?: string;
|
|
192
|
+
titleI18n?: Readonly<Partial<Record<string, string>>>;
|
|
193
|
+
kind?: 'plugin-internal' | 'user-task';
|
|
194
|
+
lastRun?: {
|
|
195
|
+
startedAt: number;
|
|
196
|
+
finishedAt: number;
|
|
197
|
+
status: 'succeeded' | 'failed';
|
|
198
|
+
error?: string;
|
|
199
|
+
};
|
|
185
200
|
}
|
|
186
201
|
export interface InboxSelection {
|
|
187
202
|
where(query: Record<string, unknown>): InboxSelection;
|
|
@@ -264,4 +279,11 @@ export interface EndpointManagementPort {
|
|
|
264
279
|
setGroupAdmin?(request: EndpointAdminCommand): Promise<void>;
|
|
265
280
|
deleteFriend?(request: EndpointUserCommand): Promise<void>;
|
|
266
281
|
}
|
|
282
|
+
/** `target` of a `schedule:list` persistent row (#1250). */
|
|
283
|
+
export interface ConsoleScheduleTarget {
|
|
284
|
+
readonly resolved: boolean;
|
|
285
|
+
readonly display?: string;
|
|
286
|
+
/** `missing_target` | `incomplete_target` | `endpoint_not_found` */
|
|
287
|
+
readonly reason?: string;
|
|
288
|
+
}
|
|
267
289
|
export {};
|
|
@@ -9,7 +9,7 @@ export async function dispatchExtendedConsoleRpc(type, data, ctx) {
|
|
|
9
9
|
switch (type) {
|
|
10
10
|
case 'schedule:list':
|
|
11
11
|
case 'cron:list':
|
|
12
|
-
return listSchedule(ctx);
|
|
12
|
+
return listSchedule(ctx, d);
|
|
13
13
|
case 'cron:validate':
|
|
14
14
|
return validateCron(d);
|
|
15
15
|
case 'cron:add':
|
|
@@ -8,7 +8,7 @@ export async function listFriends(d, ctx) {
|
|
|
8
8
|
const friends = [...await management.listFriends()];
|
|
9
9
|
return { data: { friends, count: friends.length } };
|
|
10
10
|
}
|
|
11
|
-
return
|
|
11
|
+
return unsupportedList('friends', 'listFriends', endpoint.adapter, '好友列表');
|
|
12
12
|
});
|
|
13
13
|
}
|
|
14
14
|
export async function listGroups(d, ctx) {
|
|
@@ -20,7 +20,7 @@ export async function listGroups(d, ctx) {
|
|
|
20
20
|
const groups = [...await management.listGroups()];
|
|
21
21
|
return { data: { groups, count: groups.length } };
|
|
22
22
|
}
|
|
23
|
-
return
|
|
23
|
+
return unsupportedList('groups', 'listGroups', endpoint.adapter, '群列表');
|
|
24
24
|
});
|
|
25
25
|
}
|
|
26
26
|
export async function listChannels(d, ctx) {
|
|
@@ -32,7 +32,7 @@ export async function listChannels(d, ctx) {
|
|
|
32
32
|
const channels = [...await management.listChannels()];
|
|
33
33
|
return { data: { channels, count: channels.length } };
|
|
34
34
|
}
|
|
35
|
-
return
|
|
35
|
+
return unsupportedList('channels', 'listChannels', endpoint.adapter, '频道列表');
|
|
36
36
|
});
|
|
37
37
|
}
|
|
38
38
|
export async function listGroupMembers(d, ctx) {
|
|
@@ -45,12 +45,29 @@ export async function listGroupMembers(d, ctx) {
|
|
|
45
45
|
return withLiveEndpoint(ctx, adapter, endpointKey, async (management) => {
|
|
46
46
|
const method = management.listGroupMembers;
|
|
47
47
|
if (!method) {
|
|
48
|
-
return
|
|
48
|
+
return unsupportedList('members', 'listGroupMembers', adapter, '群成员列表');
|
|
49
49
|
}
|
|
50
50
|
const members = [...await method.call(management, { groupId })];
|
|
51
51
|
return { data: { members, count: members.length } };
|
|
52
52
|
});
|
|
53
53
|
}
|
|
54
|
+
/**
|
|
55
|
+
* A read the Adapter does not declare is not a failure (#1250): reply with an empty list plus
|
|
56
|
+
* `status: 'unsupported'` (`ConsoleEndpointCapabilityUnsupported`) instead of `{ error }`, so the
|
|
57
|
+
* Console can render "该平台不提供" rather than a red read error. Older clients still see an empty
|
|
58
|
+
* list with `count: 0`.
|
|
59
|
+
*/
|
|
60
|
+
function unsupportedList(key, capability, adapter, label) {
|
|
61
|
+
return {
|
|
62
|
+
data: {
|
|
63
|
+
[key]: [],
|
|
64
|
+
count: 0,
|
|
65
|
+
status: 'unsupported',
|
|
66
|
+
capability,
|
|
67
|
+
message: `当前适配器(${adapter})不支持${label}`,
|
|
68
|
+
},
|
|
69
|
+
};
|
|
70
|
+
}
|
|
54
71
|
export async function groupWriteOp(d, ctx, spec) {
|
|
55
72
|
const adapter = strField(d, 'adapter');
|
|
56
73
|
const endpointKey = strField(d, 'endpointKey');
|
|
@@ -1,3 +1,3 @@
|
|
|
1
|
-
export type { ConsoleRpcExtendedCtx, ConsoleScheduleEngine, ConsoleWorkroomKnowledgeControlPort, ConsoleWorkroomProfileControlPort, ExtendedRpcResult, ConsoleLoginAssistPort, ConsoleEndpointLoginAccess, } from './contracts.js';
|
|
1
|
+
export type { ConsoleRpcExtendedCtx, ConsoleScheduleEngine, ConsoleScheduleTarget, ConsoleWorkroomKnowledgeControlPort, ConsoleWorkroomProfileControlPort, ExtendedRpcResult, ConsoleLoginAssistPort, ConsoleEndpointLoginAccess, } from './contracts.js';
|
|
2
2
|
export { loginAuditDetail, LOGIN_SUBMIT_WAIT_MS } from './login-rpc.js';
|
|
3
3
|
export { dispatchExtendedConsoleRpc } from './dispatcher.js';
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { ConsoleRpcExtendedCtx, ExtendedRpcResult } from './contracts.js';
|
|
2
|
-
export declare function listSchedule(ctx: ConsoleScheduleListCtx): Promise<ExtendedRpcResult> | ExtendedRpcResult;
|
|
2
|
+
export declare function listSchedule(ctx: ConsoleScheduleListCtx, d?: Record<string, unknown>): Promise<ExtendedRpcResult> | ExtendedRpcResult;
|
|
3
3
|
type ConsoleScheduleListCtx = ConsoleRpcExtendedCtx;
|
|
4
4
|
export declare function addCron(d: Record<string, unknown>, ctx: ConsoleRpcExtendedCtx): Promise<ExtendedRpcResult>;
|
|
5
5
|
export declare function mutateCron(type: 'cron:remove' | 'cron:pause' | 'cron:resume', d: Record<string, unknown>, ctx: ConsoleRpcExtendedCtx): Promise<ExtendedRpcResult>;
|