@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.
- package/lib/access-control.d.ts +19 -2
- package/lib/access-control.js +71 -3
- package/lib/console-plugin-rpc.d.ts +8 -1
- package/lib/console-plugin-rpc.js +12 -27
- package/lib/console-rpc-extended/contracts.d.ts +70 -15
- package/lib/console-rpc-extended/dispatcher.js +9 -2
- 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-handlers.d.ts +2 -0
- package/lib/console-rpc-handlers.js +124 -2
- package/lib/console-rpc.d.ts +0 -4
- package/lib/console-rpc.js +30 -5
- package/lib/index.d.ts +3 -3
- package/lib/index.js +2 -2
- package/package.json +3 -3
- package/src/access-control.ts +77 -3
- package/src/console-plugin-rpc.ts +13 -27
- package/src/console-rpc-extended/contracts.ts +65 -16
- package/src/console-rpc-extended/dispatcher.ts +9 -2
- package/src/console-rpc-extended/index.ts +3 -0
- package/src/console-rpc-extended/login-rpc.ts +250 -17
- package/src/console-rpc-handlers.ts +99 -2
- package/src/console-rpc.ts +33 -9
- package/src/index.ts +6 -1
|
@@ -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
|
-
|
|
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:
|
|
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() : '';
|
package/src/console-rpc.ts
CHANGED
|
@@ -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
|
|
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
|
-
{
|
|
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
|
|