@zhin.js/host-http 1.2.1 → 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/lib/access-control.d.ts +50 -5
- package/lib/access-control.js +167 -13
- 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 +92 -15
- package/lib/console-rpc-extended/dispatcher.js +10 -3
- package/lib/console-rpc-extended/endpoint-rpc.js +21 -4
- package/lib/console-rpc-extended/index.d.ts +2 -1
- package/lib/console-rpc-extended/index.js +1 -0
- package/lib/console-rpc-extended/login-rpc.d.ts +25 -2
- package/lib/console-rpc-extended/login-rpc.js +276 -16
- 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 +11 -1
- package/lib/console-rpc-handlers.js +221 -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 +33 -7
- package/lib/http-host.js +5 -4
- package/lib/index.d.ts +5 -3
- package/lib/index.js +4 -2
- 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 +3 -3
- package/src/access-control.ts +209 -14
- package/src/console-rest-pages-helpers.ts +27 -3
- package/src/console-rest-pages.ts +52 -15
- package/src/console-rpc-extended/contracts.ts +83 -16
- package/src/console-rpc-extended/dispatcher.ts +10 -3
- package/src/console-rpc-extended/endpoint-rpc.ts +28 -4
- package/src/console-rpc-extended/index.ts +4 -0
- package/src/console-rpc-extended/login-rpc.ts +250 -17
- package/src/console-rpc-extended/schedule-rpc.ts +67 -3
- package/src/console-rpc-handlers.ts +198 -11
- package/src/console-rpc-types.ts +45 -0
- package/src/console-rpc.ts +63 -7
- package/src/http-host.ts +6 -2
- package/src/index.ts +15 -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
|
|
@@ -93,12 +93,36 @@ export type ConsoleRpcDecision = Readonly<{
|
|
|
93
93
|
delegated?: true;
|
|
94
94
|
}> | Readonly<{
|
|
95
95
|
allowed: false;
|
|
96
|
-
|
|
96
|
+
/**
|
|
97
|
+
* `unknown_rpc`: the Host has no handler and no permission entry for the type (zhinjs/zhin#1250).
|
|
98
|
+
* Nothing runs; it is not a security refusal and callers do not audit it (see
|
|
99
|
+
* {@link isAuditedRpcDenial}). Every other code is a refusal that is audited.
|
|
100
|
+
*/
|
|
101
|
+
code: 'setup_required' | 'unmapped' | 'forbidden' | typeof CONSOLE_UNKNOWN_RPC_CODE;
|
|
97
102
|
error: string;
|
|
98
103
|
permission: ConsolePermission | null;
|
|
104
|
+
/** Audit `detail.reason`; absent for `unknown_rpc`. */
|
|
105
|
+
reason?: ConsoleDenialReason;
|
|
99
106
|
}>;
|
|
107
|
+
export interface AuthorizeConsoleRpcOptions {
|
|
108
|
+
/**
|
|
109
|
+
* Whether this Host has a handler for an RPC type that is missing from the permission map. True
|
|
110
|
+
* (or no predicate) keeps the audited `unmapped` refusal — a handler without a permission entry
|
|
111
|
+
* is a development error; false answers `unknown_rpc`. Mapped types never consult it.
|
|
112
|
+
*/
|
|
113
|
+
readonly registered?: (type: string) => boolean;
|
|
114
|
+
}
|
|
115
|
+
/** Whether a refused RPC is written to the audit log (`unknown_rpc` is not). */
|
|
116
|
+
export declare function isAuditedRpcDenial(decision: ConsoleRpcDecision): boolean;
|
|
117
|
+
/** Error text of an unknown RPC: the (truncated) type and nothing else. */
|
|
118
|
+
export declare function unknownConsoleRpcError(type: unknown): string;
|
|
100
119
|
/** RPC authorization for one principal and message (deny by default). */
|
|
101
|
-
export declare function authorizeConsoleRpc(principal: ConsolePrincipal, message: Readonly<Record<string, unknown
|
|
120
|
+
export declare function authorizeConsoleRpc(principal: ConsolePrincipal, message: Readonly<Record<string, unknown>>, options?: AuthorizeConsoleRpcOptions): ConsoleRpcDecision;
|
|
121
|
+
/**
|
|
122
|
+
* Why grants refuse `permission` at a target: `scope_denied` when some binding holds it at another
|
|
123
|
+
* adapter / endpoint, else `insufficient_permission`.
|
|
124
|
+
*/
|
|
125
|
+
export declare function grantsDenialReason(grants: readonly ConsoleGrant[], permission: string): ConsoleDenialReason;
|
|
102
126
|
/**
|
|
103
127
|
* For plugin RPC: whether the principal holds the rule's write permission (`feature.configure`,
|
|
104
128
|
* or `endpoint.manage` for adapter account methods) at its target. The plugin RPC dispatcher
|
|
@@ -114,6 +138,8 @@ export type ConsoleHttpDecision = Readonly<{
|
|
|
114
138
|
code: 'unauthorized' | 'forbidden' | 'setup_required';
|
|
115
139
|
error: string;
|
|
116
140
|
permission: string | null;
|
|
141
|
+
/** Audit `detail.reason`. */
|
|
142
|
+
reason: ConsoleDenialReason;
|
|
117
143
|
}>;
|
|
118
144
|
/**
|
|
119
145
|
* HTTP authorization of a route under the API base. `principal` is undefined when no valid
|
|
@@ -121,7 +147,26 @@ export type ConsoleHttpDecision = Readonly<{
|
|
|
121
147
|
*/
|
|
122
148
|
export declare function authorizeConsoleHttp(principal: ConsolePrincipal | undefined, method: string, relativePath: string): ConsoleHttpDecision;
|
|
123
149
|
/** WebSocket upgrade authorization for non-bootstrap principals. */
|
|
150
|
+
/** Audit `detail.reason` of a WebSocket {@link authorizeConsoleWebSocket} refused. */
|
|
151
|
+
export declare function webSocketDenialReason(principal: ConsolePrincipal, pathname: string): ConsoleDenialReason;
|
|
124
152
|
export declare function authorizeConsoleWebSocket(principal: ConsolePrincipal, pathname: string): boolean;
|
|
125
|
-
/**
|
|
126
|
-
|
|
153
|
+
/**
|
|
154
|
+
* JSON length of the detail of one audit entry. The gateway stores at most 4096 characters of
|
|
155
|
+
* JSON detail; statements are cut so the whole detail stays below this budget once serialised
|
|
156
|
+
* (escapes such as `\n` count twice), so the stored detail always parses.
|
|
157
|
+
*/
|
|
158
|
+
export declare const AUDIT_DETAIL_JSON_LIMIT = 3500;
|
|
159
|
+
export interface RpcAuditOptions {
|
|
160
|
+
/**
|
|
161
|
+
* Replaces literal values in SQL text (`'…'`) before it is recorded, with the dialect's own
|
|
162
|
+
* lexing (the database Host's `redactSql`). Without it no statement text is recorded at all.
|
|
163
|
+
*/
|
|
164
|
+
readonly redactSql?: (sql: string) => string;
|
|
165
|
+
}
|
|
166
|
+
/**
|
|
167
|
+
* Audit detail for an RPC: type, target and parameter key names only — plus the statement text
|
|
168
|
+
* of Console database writes (pass the reply so the DDL a schema change ran is recorded), with
|
|
169
|
+
* literals redacted by `options.redactSql`.
|
|
170
|
+
*/
|
|
171
|
+
export declare function rpcAuditDetail(message: Readonly<Record<string, unknown>>, reply?: Readonly<Record<string, unknown>>, options?: RpcAuditOptions): Readonly<Record<string, unknown>>;
|
|
127
172
|
export declare function rpcAuditTarget(rule: ConsoleRpcPermissionRule | null): string | undefined;
|
package/lib/access-control.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
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';
|
|
@@ -55,27 +55,67 @@ export function globalPermissions(principal, pluginPermissions = []) {
|
|
|
55
55
|
}
|
|
56
56
|
return expandPluginPermissions([...held], pluginPermissions);
|
|
57
57
|
}
|
|
58
|
+
/** Whether a refused RPC is written to the audit log (`unknown_rpc` is not). */
|
|
59
|
+
export function isAuditedRpcDenial(decision) {
|
|
60
|
+
return !decision.allowed && decision.code !== CONSOLE_UNKNOWN_RPC_CODE;
|
|
61
|
+
}
|
|
62
|
+
/** Error text of an unknown RPC: the (truncated) type and nothing else. */
|
|
63
|
+
export function unknownConsoleRpcError(type) {
|
|
64
|
+
return `RPC "${String(type ?? '').slice(0, 64)}" 不存在`;
|
|
65
|
+
}
|
|
58
66
|
/** RPC authorization for one principal and message (deny by default). */
|
|
59
|
-
export function authorizeConsoleRpc(principal, message) {
|
|
67
|
+
export function authorizeConsoleRpc(principal, message, options = {}) {
|
|
60
68
|
const rule = resolveConsoleRpcPermission(message.type, message);
|
|
61
69
|
const permission = rule ? (rule.kind === 'exact' ? rule.permission : rule.read) : null;
|
|
62
70
|
if (principal.kind === 'bootstrap') {
|
|
63
71
|
if (principal.open) {
|
|
64
72
|
return Object.freeze({ allowed: true, rule, permission, write: rule ? isWriteRule(rule) : true });
|
|
65
73
|
}
|
|
66
|
-
return Object.freeze({
|
|
74
|
+
return Object.freeze({
|
|
75
|
+
allowed: false, code: 'setup_required', error: '请先完成初始化:创建第一个所有者', permission,
|
|
76
|
+
reason: CONSOLE_DENIAL_REASONS.SETUP_REQUIRED,
|
|
77
|
+
});
|
|
67
78
|
}
|
|
68
79
|
if (!rule) {
|
|
69
|
-
|
|
80
|
+
const type = typeof message.type === 'string' ? message.type : '';
|
|
81
|
+
if (options.registered && !options.registered(type)) {
|
|
82
|
+
return Object.freeze({ allowed: false, code: CONSOLE_UNKNOWN_RPC_CODE, error: unknownConsoleRpcError(message.type), permission: null });
|
|
83
|
+
}
|
|
84
|
+
return Object.freeze({
|
|
85
|
+
allowed: false, code: 'unmapped', error: `RPC "${String(message.type ?? '')}" 未登记权限`, permission: null,
|
|
86
|
+
reason: CONSOLE_DENIAL_REASONS.UNMAPPED_PERMISSION,
|
|
87
|
+
});
|
|
70
88
|
}
|
|
71
89
|
if (isPluginConsoleRpcType(message.type)) {
|
|
72
90
|
return Object.freeze({ allowed: true, rule, permission, write: isWriteRule(rule), delegated: true });
|
|
73
91
|
}
|
|
74
92
|
if (!grantsAllow(principal.grants, permission, rule.target)) {
|
|
75
|
-
return Object.freeze({
|
|
93
|
+
return Object.freeze({
|
|
94
|
+
allowed: false, code: 'forbidden', error: `缺少权限 ${permission}`, permission,
|
|
95
|
+
reason: grantsDenialReason(principal.grants, permission),
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
// `alsoRequires` is held globally (same semantics as grantsAllowAlsoRequired): find the first missing one for the denial reason.
|
|
99
|
+
const missing = rule.kind === 'exact' && !grantsAllowAlsoRequired(principal.grants, rule)
|
|
100
|
+
? rule.alsoRequires?.find(extra => !grantsAllowAlsoRequired(principal.grants, { ...rule, alsoRequires: [extra] }))
|
|
101
|
+
: undefined;
|
|
102
|
+
if (missing) {
|
|
103
|
+
return Object.freeze({
|
|
104
|
+
allowed: false, code: 'forbidden', error: `缺少权限 ${missing}`, permission,
|
|
105
|
+
reason: grantsDenialReason(principal.grants, missing),
|
|
106
|
+
});
|
|
76
107
|
}
|
|
77
108
|
return Object.freeze({ allowed: true, rule, permission, write: isWriteRule(rule) });
|
|
78
109
|
}
|
|
110
|
+
/**
|
|
111
|
+
* Why grants refuse `permission` at a target: `scope_denied` when some binding holds it at another
|
|
112
|
+
* adapter / endpoint, else `insufficient_permission`.
|
|
113
|
+
*/
|
|
114
|
+
export function grantsDenialReason(grants, permission) {
|
|
115
|
+
return grants.some(grant => grant.scope.kind !== 'global' && grant.permissions.includes(permission))
|
|
116
|
+
? CONSOLE_DENIAL_REASONS.SCOPE_DENIED
|
|
117
|
+
: CONSOLE_DENIAL_REASONS.INSUFFICIENT_PERMISSION;
|
|
118
|
+
}
|
|
79
119
|
function isWriteRule(rule) {
|
|
80
120
|
return rule.kind === 'exact' ? CONSOLE_WRITE_PERMISSIONS.has(rule.permission) : false;
|
|
81
121
|
}
|
|
@@ -90,7 +130,7 @@ export function pluginRpcWriteAllowed(principal, rule) {
|
|
|
90
130
|
if (!rule)
|
|
91
131
|
return false;
|
|
92
132
|
const permission = rule.kind === 'exact' ? rule.permission : rule.write;
|
|
93
|
-
return grantsAllow(principal.grants, permission, rule.target);
|
|
133
|
+
return grantsAllow(principal.grants, permission, rule.target) && grantsAllowAlsoRequired(principal.grants, rule);
|
|
94
134
|
}
|
|
95
135
|
/**
|
|
96
136
|
* HTTP authorization of a route under the API base. `principal` is undefined when no valid
|
|
@@ -102,15 +142,15 @@ export function authorizeConsoleHttp(principal, method, relativePath) {
|
|
|
102
142
|
if (rule?.permission === 'public')
|
|
103
143
|
return Object.freeze({ allowed: true, permission });
|
|
104
144
|
if (!principal) {
|
|
105
|
-
return Object.freeze({ allowed: false, status: 401, code: 'unauthorized', error: 'Invalid or missing token', permission });
|
|
145
|
+
return Object.freeze({ allowed: false, status: 401, code: 'unauthorized', error: 'Invalid or missing token', permission, reason: CONSOLE_DENIAL_REASONS.NOT_AUTHENTICATED });
|
|
106
146
|
}
|
|
107
147
|
if (principal.kind === 'bootstrap') {
|
|
108
148
|
if (principal.open || rule?.permission === 'bootstrap')
|
|
109
149
|
return Object.freeze({ allowed: true, permission });
|
|
110
|
-
return Object.freeze({ allowed: false, status: 403, code: 'setup_required', error: '初始化令牌只能用于创建第一个所有者', permission });
|
|
150
|
+
return Object.freeze({ allowed: false, status: 403, code: 'setup_required', error: '初始化令牌只能用于创建第一个所有者', permission, reason: CONSOLE_DENIAL_REASONS.SETUP_REQUIRED });
|
|
111
151
|
}
|
|
112
152
|
if (!rule) {
|
|
113
|
-
return Object.freeze({ allowed: false, status: 403, code: 'forbidden', error: 'Route is not registered with a Console permission', permission: null });
|
|
153
|
+
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
154
|
}
|
|
115
155
|
if (rule.permission === 'rpc' || rule.permission === 'authenticated' || rule.permission === 'bootstrap') {
|
|
116
156
|
return Object.freeze({ allowed: true, permission });
|
|
@@ -118,9 +158,20 @@ export function authorizeConsoleHttp(principal, method, relativePath) {
|
|
|
118
158
|
// Scoped permissions over plain HTTP have no knowable target: a global binding is required.
|
|
119
159
|
if (grantsAllow(principal.grants, rule.permission))
|
|
120
160
|
return Object.freeze({ allowed: true, permission });
|
|
121
|
-
return Object.freeze({
|
|
161
|
+
return Object.freeze({
|
|
162
|
+
allowed: false, status: 403, code: 'forbidden', error: `缺少权限 ${rule.permission}`, permission,
|
|
163
|
+
reason: CONSOLE_DENIAL_REASONS.INSUFFICIENT_PERMISSION,
|
|
164
|
+
});
|
|
122
165
|
}
|
|
123
166
|
/** WebSocket upgrade authorization for non-bootstrap principals. */
|
|
167
|
+
/** Audit `detail.reason` of a WebSocket {@link authorizeConsoleWebSocket} refused. */
|
|
168
|
+
export function webSocketDenialReason(principal, pathname) {
|
|
169
|
+
if (principal.kind === 'bootstrap')
|
|
170
|
+
return CONSOLE_DENIAL_REASONS.SETUP_REQUIRED;
|
|
171
|
+
return resolveConsoleWsPermission(pathname) === null
|
|
172
|
+
? CONSOLE_DENIAL_REASONS.UNMAPPED_ROUTE
|
|
173
|
+
: CONSOLE_DENIAL_REASONS.INSUFFICIENT_PERMISSION;
|
|
174
|
+
}
|
|
124
175
|
export function authorizeConsoleWebSocket(principal, pathname) {
|
|
125
176
|
// Only the Console's own sockets are host-authenticated; plugin sockets use `auth: 'protocol'`.
|
|
126
177
|
const permission = resolveConsoleWsPermission(pathname);
|
|
@@ -128,13 +179,116 @@ export function authorizeConsoleWebSocket(principal, pathname) {
|
|
|
128
179
|
return principal.open;
|
|
129
180
|
return permission !== null && grantsAllow(principal.grants, permission);
|
|
130
181
|
}
|
|
131
|
-
/**
|
|
132
|
-
|
|
182
|
+
/**
|
|
183
|
+
* JSON length of the detail of one audit entry. The gateway stores at most 4096 characters of
|
|
184
|
+
* JSON detail; statements are cut so the whole detail stays below this budget once serialised
|
|
185
|
+
* (escapes such as `\n` count twice), so the stored detail always parses.
|
|
186
|
+
*/
|
|
187
|
+
export const AUDIT_DETAIL_JSON_LIMIT = 3_500;
|
|
188
|
+
function statementTexts(message, reply) {
|
|
189
|
+
if (message.type === 'db:execute' || message.type === 'db:query')
|
|
190
|
+
return typeof message.sql === 'string' && message.sql ? [message.sql] : [];
|
|
191
|
+
const data = reply?.data && typeof reply.data === 'object' ? reply.data : undefined;
|
|
192
|
+
return (Array.isArray(data?.statements) ? data.statements : []).filter((entry) => typeof entry === 'string' && !!entry);
|
|
193
|
+
}
|
|
194
|
+
/**
|
|
195
|
+
* Statement text of Console SQL (`db:query` / `db:execute`, the DDL `db:schema:apply` ran or
|
|
196
|
+
* would have run), literals redacted; result rows are never recorded.
|
|
197
|
+
*/
|
|
198
|
+
function databaseStatementDetail(message, reply, options) {
|
|
199
|
+
if (message.type !== 'db:execute' && message.type !== 'db:query' && message.type !== 'db:schema:apply')
|
|
200
|
+
return {};
|
|
201
|
+
const change = message.type === 'db:schema:apply' && message.change && typeof message.change === 'object' ? message.change : {};
|
|
202
|
+
const texts = statementTexts(message, reply);
|
|
203
|
+
const redact = options?.redactSql;
|
|
204
|
+
return {
|
|
205
|
+
...(message.type === 'db:execute' && message.dryRun === true ? { dryRun: true } : {}),
|
|
206
|
+
...(typeof change.kind === 'string' ? { change: change.kind.slice(0, 64) } : {}),
|
|
207
|
+
...(typeof change.table === 'string' ? { table: change.table.slice(0, 256) } : {}),
|
|
208
|
+
...(texts.length && redact
|
|
209
|
+
? { statements: texts.map(text => { try {
|
|
210
|
+
return redact(text);
|
|
211
|
+
}
|
|
212
|
+
catch {
|
|
213
|
+
return '[unavailable]';
|
|
214
|
+
} }) }
|
|
215
|
+
: texts.length ? { statementLength: texts.reduce((total, text) => total + text.length, 0) } : {}),
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
/** Cuts the longest statement until the serialised detail fits {@link AUDIT_DETAIL_JSON_LIMIT}. */
|
|
219
|
+
function fitAuditDetail(detail) {
|
|
220
|
+
const statements = Array.isArray(detail.statements) ? [...detail.statements] : undefined;
|
|
221
|
+
if (!statements)
|
|
222
|
+
return detail;
|
|
223
|
+
let truncated = false;
|
|
224
|
+
for (;;) {
|
|
225
|
+
const candidate = { ...detail, statements, ...(truncated ? { truncated: true } : {}) };
|
|
226
|
+
const overflow = JSON.stringify(candidate).length - AUDIT_DETAIL_JSON_LIMIT;
|
|
227
|
+
if (overflow <= 0)
|
|
228
|
+
return { ...candidate, statements: Object.freeze(statements) };
|
|
229
|
+
truncated = true;
|
|
230
|
+
let longest = 0;
|
|
231
|
+
for (let index = 1; index < statements.length; index += 1)
|
|
232
|
+
if (statements[index].length > statements[longest].length)
|
|
233
|
+
longest = index;
|
|
234
|
+
const text = statements[longest];
|
|
235
|
+
if (text.length <= 1) {
|
|
236
|
+
statements.splice(longest, 1);
|
|
237
|
+
continue;
|
|
238
|
+
}
|
|
239
|
+
statements[longest] = `${text.slice(0, Math.max(0, text.length - overflow - 1))}…`;
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
/**
|
|
243
|
+
* Audit detail for an RPC: type, target and parameter key names only — plus the statement text
|
|
244
|
+
* of Console database writes (pass the reply so the DDL a schema change ran is recorded), with
|
|
245
|
+
* literals redacted by `options.redactSql`.
|
|
246
|
+
*/
|
|
247
|
+
export function rpcAuditDetail(message, reply, options) {
|
|
133
248
|
const data = message.data && typeof message.data === 'object' && !Array.isArray(message.data)
|
|
134
249
|
? Object.keys(message.data).sort()
|
|
135
250
|
: [];
|
|
136
251
|
const keys = Object.keys(message).filter(key => key !== 'type' && key !== 'requestId' && key !== 'data').sort();
|
|
137
|
-
return Object.freeze({
|
|
252
|
+
return Object.freeze(fitAuditDetail({
|
|
253
|
+
type: String(message.type ?? '').slice(0, 256),
|
|
254
|
+
keys: Object.freeze([...new Set([...keys, ...data])].slice(0, 64).map(key => key.slice(0, 128))),
|
|
255
|
+
...databaseStatementDetail(message, reply, options),
|
|
256
|
+
...envDetail(message),
|
|
257
|
+
}));
|
|
258
|
+
}
|
|
259
|
+
const auditName = (value, max) => typeof value === 'string' && value ? value.slice(0, max) : undefined;
|
|
260
|
+
/**
|
|
261
|
+
* Which dotenv file and variable names an `env:reveal` / `env:save` touched (issue #1250). Never a
|
|
262
|
+
* value: `changes[].value` and `content` are not read.
|
|
263
|
+
*/
|
|
264
|
+
function envDetail(message) {
|
|
265
|
+
if (message.type !== 'env:reveal' && message.type !== 'env:save')
|
|
266
|
+
return {};
|
|
267
|
+
const data = message.data && typeof message.data === 'object' && !Array.isArray(message.data) ? message.data : {};
|
|
268
|
+
const pick = (key) => message[key] ?? data[key];
|
|
269
|
+
const file = auditName(pick('filename'), 64);
|
|
270
|
+
if (message.type === 'env:reveal') {
|
|
271
|
+
const key = auditName(pick('key'), 128);
|
|
272
|
+
return { ...(file ? { file } : {}), ...(key ? { key } : {}) };
|
|
273
|
+
}
|
|
274
|
+
const changes = Array.isArray(pick('changes')) ? pick('changes') : undefined;
|
|
275
|
+
if (!changes)
|
|
276
|
+
return { ...(file ? { file } : {}), ...(pick('content') !== undefined ? { mode: 'replace' } : {}) };
|
|
277
|
+
const set = [];
|
|
278
|
+
const deleted = [];
|
|
279
|
+
for (const change of changes.slice(0, 64)) {
|
|
280
|
+
if (!change || typeof change !== 'object')
|
|
281
|
+
continue;
|
|
282
|
+
const key = auditName(change.key, 128);
|
|
283
|
+
if (key)
|
|
284
|
+
(change.delete === true ? deleted : set).push(key);
|
|
285
|
+
}
|
|
286
|
+
return {
|
|
287
|
+
...(file ? { file } : {}),
|
|
288
|
+
set: Object.freeze(set),
|
|
289
|
+
deleted: Object.freeze(deleted),
|
|
290
|
+
...(changes.length > 64 ? { truncated: true } : {}),
|
|
291
|
+
};
|
|
138
292
|
}
|
|
139
293
|
export function rpcAuditTarget(rule) {
|
|
140
294
|
if (!rule)
|
|
@@ -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);
|