@zhin.js/host-http 1.2.0 → 1.2.2

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.
@@ -257,14 +257,71 @@ export async function dispatchHostResourceConsoleRpc(
257
257
  const page = positiveInteger(message.page, 1);
258
258
  const pageSize = Math.min(positiveInteger(message.pageSize, 50), 500);
259
259
  const where = recordField(message.where);
260
+ const order = recordField(message.orderBy);
261
+ if (message.orderBy !== undefined && message.orderBy !== null
262
+ && (!order || typeof order.column !== 'string' || (order.direction !== 'asc' && order.direction !== 'desc'))) {
263
+ errorReply(payloads, requestId, 'orderBy must be { column, direction: "asc" | "desc" }');
264
+ return true;
265
+ }
260
266
  if (!ctx.database) {
261
267
  emit({ requestId, data: { rows: [], total: 0, page, pageSize } });
262
268
  return true;
263
269
  }
264
270
  try {
265
- emit({ requestId, data: await ctx.database.select(table, page, pageSize, where) });
271
+ const orderBy = order ? { column: order.column as string, direction: order.direction as 'asc' | 'desc' } : undefined;
272
+ emit({ requestId, data: await ctx.database.select(table, page, pageSize, where, orderBy) });
273
+ } catch (error) {
274
+ emit({ requestId, error: `Failed to select: ${errorMessage(error)}`, ...refusalData(error) });
275
+ }
276
+ return true;
277
+ }
278
+ case 'db:query': {
279
+ const sql = sqlField(message);
280
+ if (!sql) { errorReply(payloads, requestId, `sql is required (at most ${MAX_CONSOLE_SQL_LENGTH} characters)`); return true; }
281
+ if (!ctx.database?.query) { unsupportedDatabaseFeature(payloads, requestId, 'SQL query'); return true; }
282
+ try {
283
+ const maxRows = typeof message.maxRows === 'number' ? message.maxRows : undefined;
284
+ emit({ requestId, data: await ctx.database.query(sql, maxRows ? { maxRows } : undefined) });
285
+ } catch (error) {
286
+ emit({ requestId, error: errorMessage(error), ...refusalData(error) });
287
+ }
288
+ return true;
289
+ }
290
+ case 'db:execute': {
291
+ const sql = sqlField(message);
292
+ if (!sql) { errorReply(payloads, requestId, `sql is required (at most ${MAX_CONSOLE_SQL_LENGTH} characters)`); return true; }
293
+ if (!ctx.database?.execute) { unsupportedDatabaseFeature(payloads, requestId, 'SQL execution'); return true; }
294
+ try {
295
+ emit({ requestId, data: await ctx.database.execute(sql, { dryRun: message.dryRun === true }) });
296
+ } catch (error) {
297
+ emit({ requestId, error: errorMessage(error), ...refusalData(error) });
298
+ }
299
+ return true;
300
+ }
301
+ case 'db:schema:describe': {
302
+ const table = stringField(message, 'table');
303
+ if (!table) { errorReply(payloads, requestId, 'table is required'); return true; }
304
+ if (!ctx.database?.describe) { unsupportedDatabaseFeature(payloads, requestId, 'Schema inspection'); return true; }
305
+ try {
306
+ emit({ requestId, data: await ctx.database.describe(table) });
307
+ } catch (error) {
308
+ emit({ requestId, error: errorMessage(error), ...refusalData(error) });
309
+ }
310
+ return true;
311
+ }
312
+ case 'db:schema:preview':
313
+ case 'db:schema:apply': {
314
+ const change = recordField(message.change);
315
+ if (!change) { errorReply(payloads, requestId, 'change is required'); return true; }
316
+ const database = ctx.database;
317
+ if (!database?.previewSchema || !database.applySchema) { unsupportedDatabaseFeature(payloads, requestId, 'Schema editing'); return true; }
318
+ try {
319
+ const plan = type === 'db:schema:apply'
320
+ ? await database.applySchema(change, typeof message.confirm === 'string' ? message.confirm : undefined)
321
+ : await database.previewSchema(change);
322
+ emit({ requestId, data: plan });
266
323
  } catch (error) {
267
- emit({ requestId, error: `Failed to select: ${errorMessage(error)}` });
324
+ emit({ requestId, error: errorMessage(error), ...refusalData(error) });
268
325
  }
269
326
  return true;
270
327
  }
@@ -532,6 +589,46 @@ export async function dispatchHostResourceConsoleRpc(
532
589
  }
533
590
  }
534
591
 
592
+ /** Longest statement text the Console SQL editor sends. */
593
+ export const MAX_CONSOLE_SQL_LENGTH = 100_000;
594
+
595
+ function sqlField(message: RuntimeConsoleRpcMessage): string {
596
+ const value = message.sql;
597
+ return typeof value === 'string' && value.trim() && value.length <= MAX_CONSOLE_SQL_LENGTH ? value : '';
598
+ }
599
+
600
+ const REFUSAL_NAMES: ReadonlySet<string> = new Set(['ConsoleSqlRefusal', 'ConsoleSchemaRefusal', 'ConsoleSelectRefusal', 'SqliteQueryTimeoutError']);
601
+
602
+ /**
603
+ * `data.code` of a refusal by the database port (`read_only`, `protected_table`, …), and the DDL a
604
+ * failed `db:schema:apply` attempted (`data.statements`, for the audit log).
605
+ */
606
+ /** SQLSTATE of a driver error (PostgreSQL `code`, mysql2 `sqlState`), e.g. `42501` insufficient privilege. */
607
+ function sqlStateOf(error: Error): string | undefined {
608
+ const record = error as { code?: unknown; sqlState?: unknown };
609
+ for (const candidate of [record.sqlState, record.code]) {
610
+ if (typeof candidate === 'string' && /^[0-9A-Z]{5}$/u.test(candidate)) return candidate;
611
+ }
612
+ return undefined;
613
+ }
614
+
615
+ function refusalData(error: unknown): { data?: { code?: string; statements?: readonly string[]; sqlState?: string } } {
616
+ if (!(error instanceof Error)) return {};
617
+ const attempted = (error as { statements?: unknown }).statements;
618
+ const statements = Array.isArray(attempted) && attempted.every(entry => typeof entry === 'string') ? { statements: attempted as string[] } : {};
619
+ if (error.name === 'GatewayOwnedTableError') return { data: { code: 'protected_table', ...statements } };
620
+ const code = (error as { code?: unknown }).code;
621
+ const refused = REFUSAL_NAMES.has(error.name) && typeof code === 'string';
622
+ // The server's SQLSTATE lets the Console explain e.g. 42501 from the restricted account (ADR 0059 §4).
623
+ const sqlState = refused ? undefined : sqlStateOf(error);
624
+ const data = { ...(refused ? { code } : {}), ...(sqlState ? { sqlState } : {}), ...statements };
625
+ return Object.keys(data).length ? { data } : {};
626
+ }
627
+
628
+ function unsupportedDatabaseFeature(payloads: RuntimeConsoleRpcReply[], requestId: number | string | undefined, feature: string): void {
629
+ payloads.push({ requestId, error: `${feature} is not supported by this database Host`, data: { code: 'unsupported' } });
630
+ }
631
+
535
632
  export function stringField(message: RuntimeConsoleRpcMessage, key: string): string {
536
633
  const value = message[key];
537
634
  return typeof value === 'string' ? value.trim() : '';
@@ -3,6 +3,7 @@ import type { DatabaseHostConsole } from '@zhin.js/plugin-runtime';
3
3
  import {
4
4
  CONFIG_PATCH_LIMITS,
5
5
  CONFIG_RPC,
6
+ grantsAllow,
6
7
  type ConsoleConfigPatchRequest,
7
8
  type ConsoleConfigPatchResult,
8
9
  type ConsoleConfigProjectPatchResult,
@@ -10,7 +11,7 @@ import {
10
11
  type ConsoleConfigExplanation,
11
12
  type ConsoleEndpointSummary,
12
13
  } from '@zhin.js/console-protocol';
13
- import { authorizeConsoleRpc, type ConsolePrincipal } from './access-control.js';
14
+ import { authorizeConsoleRpc, principalAllows, type ConsolePrincipal } from './access-control.js';
14
15
  import { dispatchExtendedConsoleRpc, type ConsoleRpcExtendedCtx } from './console-rpc-extended/index.js';
15
16
  import { dispatchPluginConsoleRpc } from './console-plugin-rpc.js';
16
17
  import {
@@ -187,11 +188,6 @@ export type RuntimeConsoleRpcContext = {
187
188
  /** Full-scope project read (`config:get-project`); masked unless `reveal`. */
188
189
  readProjectConfig?(request: import('@zhin.js/console-protocol').ConsoleConfigProjectReadRequest): Promise<import('@zhin.js/console-protocol').ConsoleConfigProjectRead>;
189
190
  applyConfig?(expectedRevision: string): Promise<import('@zhin.js/console-protocol').ConsoleConfigApplyResult>;
190
- /** Persist a declared child Plugin lifecycle state. Takes effect after restart. */
191
- setPluginEnabled?(
192
- instanceKey: string,
193
- enabled: boolean,
194
- ): Promise<Readonly<{ disabled: readonly string[] }>>;
195
191
  /** Read-only install planning shared by Console Web and CLI surfaces. */
196
192
  pluginManagement?: PluginManagementPort;
197
193
  validatePluginConfig?(pluginName: string, data: unknown): Promise<PluginConfigValidation>;
@@ -262,6 +258,21 @@ export function pickRpcReply(
262
258
  );
263
259
  }
264
260
 
261
+ /**
262
+ * The Endpoint a principal was admitted for when only an adapter / endpoint-scoped grant covers the
263
+ * call (B-1): handlers must then stay inside it. `undefined` when a global grant covers it.
264
+ */
265
+ function scopedAuthorizedEndpoint(
266
+ principal: ConsolePrincipal,
267
+ decision: Extract<ReturnType<typeof authorizeConsoleRpc>, { allowed: true }>,
268
+ ): ConsoleRpcExtendedCtx['authorizedEndpoint'] {
269
+ if (principal.kind === 'bootstrap' || !decision.permission || !decision.rule) return undefined;
270
+ if (grantsAllow(principal.grants, decision.permission)) return undefined;
271
+ const target = decision.rule.target;
272
+ if (target.kind !== 'endpoint') return undefined;
273
+ return Object.freeze({ adapter: target.adapter, endpointKey: target.endpoint });
274
+ }
275
+
265
276
  export async function dispatchRuntimeConsoleRpc(
266
277
  message: RuntimeConsoleRpcMessage,
267
278
  ctx: RuntimeConsoleRpcContext,
@@ -273,12 +284,20 @@ export async function dispatchRuntimeConsoleRpc(
273
284
  const type = String(message.type ?? '');
274
285
  const requestId = message.requestId as number | string | undefined;
275
286
 
287
+ let authorizedEndpoint: ConsoleRpcExtendedCtx['authorizedEndpoint'];
288
+ let canOperateEndpoint: ConsoleRpcExtendedCtx['canOperateEndpoint'];
276
289
  if (ctx.principal) {
277
- const decision = authorizeConsoleRpc(ctx.principal, message as Record<string, unknown>);
290
+ const principal = ctx.principal;
291
+ const decision = authorizeConsoleRpc(principal, message as Record<string, unknown>);
278
292
  if (!decision.allowed) {
279
293
  emit({ requestId, error: decision.error, code: decision.code });
280
294
  return payloads;
281
295
  }
296
+ authorizedEndpoint = scopedAuthorizedEndpoint(principal, decision);
297
+ // Same grant check the map applies to `login.submit` for that Endpoint (ADR 0058 §8).
298
+ canOperateEndpoint = (adapter, endpointKey) => principalAllows(
299
+ principal, 'endpoint.operate', Object.freeze({ kind: 'endpoint', adapter, endpoint: endpointKey }),
300
+ );
282
301
  }
283
302
 
284
303
  // Extended RPC surface (cron/schedule、endpoint 社交/inbox)
@@ -286,11 +305,16 @@ export async function dispatchRuntimeConsoleRpc(
286
305
  const extended = await dispatchExtendedConsoleRpc(
287
306
  type,
288
307
  message as Record<string, unknown>,
289
- { ...ctx.extended, fullScope: ctx.authScope === 'full' },
308
+ {
309
+ ...ctx.extended,
310
+ fullScope: ctx.authScope === 'full',
311
+ ...(authorizedEndpoint ? { authorizedEndpoint } : {}),
312
+ ...(canOperateEndpoint ? { canOperateEndpoint } : {}),
313
+ },
290
314
  );
291
315
  if (extended !== undefined) {
292
316
  emit('error' in extended
293
- ? { requestId, error: extended.error }
317
+ ? { requestId, error: extended.error, ...(extended.data !== undefined ? { data: extended.data } : {}) }
294
318
  : { requestId, data: extended.data });
295
319
  return payloads;
296
320
  }
package/src/index.ts CHANGED
@@ -43,6 +43,7 @@ export {
43
43
  requestMeta,
44
44
  rpcAuditDetail,
45
45
  rpcAuditTarget,
46
+ type RpcAuditOptions,
46
47
  type ConsoleAccessProvider,
47
48
  type ConsoleAuditEntry,
48
49
  type ConsoleBootstrapPrincipal,
@@ -109,7 +110,7 @@ export {
109
110
  type PluginVerificationPlan,
110
111
  type PluginConfigValidation,
111
112
  } from './console-rpc.js';
112
- export { dispatchPluginConsoleRpc } from './console-plugin-rpc.js';
113
+ export { dispatchPluginConsoleRpc, PLUGIN_SWITCH_REQUIRES_GATEWAY } from './console-plugin-rpc.js';
113
114
  export {
114
115
  buildProjectFileTree,
115
116
  FILE_MANAGER_ALLOWED,
@@ -135,6 +136,10 @@ export {
135
136
  type ConsoleWorkroomProfileControlPort,
136
137
  type ConsoleWorkroomKnowledgeControlPort,
137
138
  type ExtendedRpcResult,
139
+ type ConsoleLoginAssistPort,
140
+ type ConsoleEndpointLoginAccess,
141
+ loginAuditDetail,
142
+ LOGIN_SUBMIT_WAIT_MS,
138
143
  } from './console-rpc-extended/index.js';
139
144
  export * from './host-installer.js';
140
145