@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.
Files changed (44) hide show
  1. package/lib/access-control.d.ts +50 -5
  2. package/lib/access-control.js +167 -13
  3. package/lib/console-rest-pages-helpers.js +24 -2
  4. package/lib/console-rest-pages.d.ts +4 -1
  5. package/lib/console-rest-pages.js +49 -23
  6. package/lib/console-rpc-extended/contracts.d.ts +92 -15
  7. package/lib/console-rpc-extended/dispatcher.js +10 -3
  8. package/lib/console-rpc-extended/endpoint-rpc.js +21 -4
  9. package/lib/console-rpc-extended/index.d.ts +2 -1
  10. package/lib/console-rpc-extended/index.js +1 -0
  11. package/lib/console-rpc-extended/login-rpc.d.ts +25 -2
  12. package/lib/console-rpc-extended/login-rpc.js +276 -16
  13. package/lib/console-rpc-extended/schedule-rpc.d.ts +1 -1
  14. package/lib/console-rpc-extended/schedule-rpc.js +78 -12
  15. package/lib/console-rpc-handlers.d.ts +11 -1
  16. package/lib/console-rpc-handlers.js +221 -12
  17. package/lib/console-rpc-types.d.ts +2 -0
  18. package/lib/console-rpc-types.js +43 -0
  19. package/lib/console-rpc.d.ts +29 -1
  20. package/lib/console-rpc.js +33 -7
  21. package/lib/http-host.js +5 -4
  22. package/lib/index.d.ts +5 -3
  23. package/lib/index.js +4 -2
  24. package/lib/project-files.d.ts +7 -0
  25. package/lib/project-files.js +88 -11
  26. package/lib/unknown-rpc.d.ts +12 -0
  27. package/lib/unknown-rpc.js +33 -0
  28. package/package.json +3 -3
  29. package/src/access-control.ts +209 -14
  30. package/src/console-rest-pages-helpers.ts +27 -3
  31. package/src/console-rest-pages.ts +52 -15
  32. package/src/console-rpc-extended/contracts.ts +83 -16
  33. package/src/console-rpc-extended/dispatcher.ts +10 -3
  34. package/src/console-rpc-extended/endpoint-rpc.ts +28 -4
  35. package/src/console-rpc-extended/index.ts +4 -0
  36. package/src/console-rpc-extended/login-rpc.ts +250 -17
  37. package/src/console-rpc-extended/schedule-rpc.ts +67 -3
  38. package/src/console-rpc-handlers.ts +198 -11
  39. package/src/console-rpc-types.ts +45 -0
  40. package/src/console-rpc.ts +63 -7
  41. package/src/http-host.ts +6 -2
  42. package/src/index.ts +15 -0
  43. package/src/project-files.ts +95 -11
  44. package/src/unknown-rpc.ts +44 -0
@@ -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
- code: 'setup_required' | 'unmapped' | 'forbidden';
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>>): ConsoleRpcDecision;
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
- /** Audit detail for an RPC: type, target and parameter key names only. */
126
- export declare function rpcAuditDetail(message: Readonly<Record<string, unknown>>): Readonly<Record<string, unknown>>;
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;
@@ -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({ allowed: false, code: 'setup_required', error: '请先完成初始化:创建第一个所有者', permission });
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
- return Object.freeze({ allowed: false, code: 'unmapped', error: `RPC "${String(message.type ?? '')}" 未登记权限`, permission: null });
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({ allowed: false, code: 'forbidden', error: `缺少权限 ${permission}`, permission });
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({ allowed: false, status: 403, code: 'forbidden', error: `缺少权限 ${rule.permission}`, permission });
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
- /** Audit detail for an RPC: type, target and parameter key names only. */
132
- export function rpcAuditDetail(message) {
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({ type: String(message.type ?? ''), keys: Object.freeze([...new Set([...keys, ...data])]) });
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?(): readonly unknown[];
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
- description: meta.description || '',
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: typeof meta.author === 'string'
239
- ? meta.author
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 category = url.searchParams.get('category') ?? undefined;
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
- name: p.name,
260
- displayName: p.displayName || '',
261
- version: p.version || '',
262
- description: p.description || '',
263
- author: p.author || '',
264
- isOfficial: typeof p.name === 'string' && /^@zhin\.js\/[a-z0-9][a-z0-9._-]*$/.test(p.name),
265
- official: typeof p.name === 'string' && /^@zhin\.js\/[a-z0-9][a-z0-9._-]*$/.test(p.name),
266
- category: p.category || 'util',
267
- keywords: p.tags || [],
268
- npm: p.npm || `https://www.npmjs.com/package/${p.name}`,
269
- date: p.lastUpdate || '',
270
- downloads: p.downloads || { weekly: 0, monthly: 0 },
271
- readme: p.readme || '',
272
- license: p.license || '',
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 = [p.name, p.displayName, p.description, ...(p.keywords || [])]
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);