@zhin.js/host-http 1.2.1 → 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-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.js +30 -5
- package/lib/index.d.ts +2 -2
- package/lib/index.js +1 -1
- package/package.json +3 -3
- package/src/access-control.ts +77 -3
- 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 -4
- package/src/index.ts +5 -0
|
@@ -1,39 +1,71 @@
|
|
|
1
|
-
import
|
|
1
|
+
import { CONSOLE_LOGIN_UNSUPPORTED, LOGIN_RPC, type ConsoleLoginFlow } from '@zhin.js/console-protocol';
|
|
2
|
+
import type { ConsoleEndpointLoginAccess, ConsoleLoginAssistPort, ConsoleRpcExtendedCtx, ExtendedRpcResult } from './contracts.js';
|
|
2
3
|
import { strField } from './rpc-values.js';
|
|
3
4
|
|
|
4
|
-
|
|
5
|
+
const NOT_WIRED = '登录辅助未接线(LoginAssist 未挂载)';
|
|
6
|
+
/** `login.submit` with `wait: true` waits at most this long for the login result. */
|
|
7
|
+
export const LOGIN_SUBMIT_WAIT_MS = 15_000;
|
|
8
|
+
|
|
9
|
+
export function listLoginPending(d: Record<string, unknown>, ctx: ConsoleRpcExtendedCtx): ExtendedRpcResult {
|
|
5
10
|
const assist = ctx.loginAssist;
|
|
6
|
-
if (!assist) return { error:
|
|
7
|
-
const
|
|
11
|
+
if (!assist) return { error: NOT_WIRED };
|
|
12
|
+
const scope = loginScope(d, ctx);
|
|
13
|
+
if ('error' in scope) return scope;
|
|
14
|
+
const tasks = assist.listPending().filter((task) => inScope(scope, task)).map((task) => ({
|
|
8
15
|
id: task.id,
|
|
9
16
|
adapter: task.adapter,
|
|
10
17
|
endpointKey: task.endpointKey,
|
|
11
18
|
...(task.endpointIdentity ? { endpointIdentity: task.endpointIdentity } : {}),
|
|
12
19
|
type: task.type,
|
|
13
|
-
payload: task.payload,
|
|
20
|
+
payload: canOperate(ctx, task) ? task.payload : eventSafeLoginPayload(task.payload),
|
|
14
21
|
createdAt: task.createdAt,
|
|
15
22
|
expiresAt: task.expiresAt,
|
|
23
|
+
...(task.flowId ? { flowId: task.flowId } : {}),
|
|
24
|
+
...(task.challengeId ? { challengeId: task.challengeId } : {}),
|
|
16
25
|
}));
|
|
17
26
|
return { data: { tasks, count: tasks.length } };
|
|
18
27
|
}
|
|
19
28
|
|
|
20
|
-
|
|
29
|
+
/**
|
|
30
|
+
* `login.submit { taskId, value, endpointIdentity?, adapter?, endpointKey?, wait? }`.
|
|
31
|
+
* `value` is a string (legacy) or `{ action, values }`. With `wait: true` the reply carries the login
|
|
32
|
+
* result (`{ accepted, outcome, flow }`) or `{ accepted, pending: true }` after 15 s; without it the
|
|
33
|
+
* legacy `{ success: true }` reply is unchanged.
|
|
34
|
+
*/
|
|
35
|
+
export async function submitLogin(
|
|
21
36
|
d: Record<string, unknown>,
|
|
22
37
|
ctx: ConsoleRpcExtendedCtx,
|
|
23
|
-
): ExtendedRpcResult {
|
|
38
|
+
): Promise<ExtendedRpcResult> {
|
|
24
39
|
const assist = ctx.loginAssist;
|
|
25
|
-
if (!assist) return { error:
|
|
40
|
+
if (!assist) return { error: NOT_WIRED };
|
|
26
41
|
const id = strField(d, 'taskId');
|
|
27
42
|
if (!id) return { error: 'taskId is required' };
|
|
28
|
-
const
|
|
29
|
-
|
|
30
|
-
? raw as string | Record<string, unknown>
|
|
31
|
-
: String(raw ?? '');
|
|
43
|
+
const value = readSubmitValue(d.value);
|
|
44
|
+
if (value === false) return { error: 'value must be a string or { action, values }' };
|
|
32
45
|
const identity = readEndpointIdentity(d.endpointIdentity);
|
|
33
46
|
if (identity === false) return { error: 'Invalid login Endpoint identity' };
|
|
34
|
-
const
|
|
35
|
-
if (
|
|
36
|
-
|
|
47
|
+
const target = checkTaskTarget(d, ctx, id);
|
|
48
|
+
if (target) return target;
|
|
49
|
+
try {
|
|
50
|
+
if (d.wait === true && assist.submitAndWait) {
|
|
51
|
+
const result = await assist.submitAndWait(id, value, {
|
|
52
|
+
...(identity ? { endpointIdentity: identity } : {}),
|
|
53
|
+
timeoutMs: LOGIN_SUBMIT_WAIT_MS,
|
|
54
|
+
});
|
|
55
|
+
if (!result.accepted) return { error: `login task not found: ${id}` };
|
|
56
|
+
if (result.outcome) return { data: { accepted: true, outcome: result.outcome, ...(result.flow ? { flow: result.flow } : {}) } };
|
|
57
|
+
return { data: { accepted: true, pending: true, ...(result.flow ? { flow: result.flow } : {}) } };
|
|
58
|
+
}
|
|
59
|
+
const ok = identity ? assist.submit(id, value, identity) : assist.submit(id, value);
|
|
60
|
+
if (!ok) return { error: `login task not found: ${id}` };
|
|
61
|
+
return { data: { success: true } };
|
|
62
|
+
} catch (error) {
|
|
63
|
+
// LoginSubmissionError: the challenge stays open; its message never contains the value.
|
|
64
|
+
if (error instanceof Error && error.name === 'LoginSubmissionError') {
|
|
65
|
+
return { error: error.message, data: { code: (error as Error & { code?: string }).code ?? 'input_invalid' } };
|
|
66
|
+
}
|
|
67
|
+
throw error;
|
|
68
|
+
}
|
|
37
69
|
}
|
|
38
70
|
|
|
39
71
|
export function cancelLogin(
|
|
@@ -41,18 +73,219 @@ export function cancelLogin(
|
|
|
41
73
|
ctx: ConsoleRpcExtendedCtx,
|
|
42
74
|
): ExtendedRpcResult {
|
|
43
75
|
const assist = ctx.loginAssist;
|
|
44
|
-
if (!assist) return { error:
|
|
76
|
+
if (!assist) return { error: NOT_WIRED };
|
|
45
77
|
const id = strField(d, 'taskId');
|
|
46
78
|
if (!id) return { error: 'taskId is required' };
|
|
47
79
|
const reason = strField(d, 'reason') || 'cancelled';
|
|
48
80
|
const identity = readEndpointIdentity(d.endpointIdentity);
|
|
49
81
|
if (identity === false) return { error: 'Invalid login Endpoint identity' };
|
|
82
|
+
const target = checkTaskTarget(d, ctx, id);
|
|
83
|
+
if (target) return target;
|
|
50
84
|
const ok = identity ? assist.cancel(id, reason, identity) : assist.cancel(id, reason);
|
|
51
85
|
if (!ok) return { error: `login task not found: ${id}` };
|
|
52
86
|
return { data: { success: true } };
|
|
53
87
|
}
|
|
54
88
|
|
|
55
|
-
|
|
89
|
+
/**
|
|
90
|
+
* `login.flow.list { adapter?, endpointKey? }`: latest flow per Endpoint. Live challenge material
|
|
91
|
+
* only for Endpoints the caller may operate; the event-safe form otherwise (ADR 0058 §8).
|
|
92
|
+
*/
|
|
93
|
+
export function listLoginFlows(d: Record<string, unknown>, ctx: ConsoleRpcExtendedCtx): ExtendedRpcResult {
|
|
94
|
+
const assist = ctx.loginAssist;
|
|
95
|
+
if (!assist) return { error: NOT_WIRED };
|
|
96
|
+
if (!assist.listFlows) return unsupported('login flows');
|
|
97
|
+
const scope = loginScope(d, ctx);
|
|
98
|
+
if ('error' in scope) return scope;
|
|
99
|
+
const flows = assist.listFlows().filter((flow) => inScope(scope, flow)).map((flow) => visibleFlow(assist, ctx, flow));
|
|
100
|
+
return { data: { flows, count: flows.length } };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** `login.flow.get { flowId } | { adapter, endpointKey }` → `{ flow: … | null }`. */
|
|
104
|
+
export function getLoginFlow(d: Record<string, unknown>, ctx: ConsoleRpcExtendedCtx): ExtendedRpcResult {
|
|
105
|
+
const assist = ctx.loginAssist;
|
|
106
|
+
if (!assist) return { error: NOT_WIRED };
|
|
107
|
+
if (!assist.getFlow || !assist.getFlowById) return unsupported('login flows');
|
|
108
|
+
const flowId = strField(d, 'flowId');
|
|
109
|
+
const scope = loginScope(d, ctx);
|
|
110
|
+
if ('error' in scope) return scope;
|
|
111
|
+
if (flowId) {
|
|
112
|
+
const flow = assist.getFlowById(flowId);
|
|
113
|
+
// A flow id names one Endpoint; a scoped caller cannot read another Endpoint's flow by id.
|
|
114
|
+
return { data: { flow: flow && inScope(scope, flow) ? visibleFlow(assist, ctx, flow) : null } };
|
|
115
|
+
}
|
|
116
|
+
if (!scope.adapter || !scope.endpointKey) return { error: 'flowId or adapter and endpointKey are required' };
|
|
117
|
+
const flow = assist.getFlow(scope.adapter, scope.endpointKey);
|
|
118
|
+
return { data: { flow: flow ? visibleFlow(assist, ctx, flow) : null } };
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** `login.relogin` / `login.refresh { adapter, endpointKey }` through the Endpoint's login port. */
|
|
122
|
+
export async function controlLogin(
|
|
123
|
+
type: typeof LOGIN_RPC.RELOGIN | typeof LOGIN_RPC.REFRESH,
|
|
124
|
+
d: Record<string, unknown>,
|
|
125
|
+
ctx: ConsoleRpcExtendedCtx,
|
|
126
|
+
): Promise<ExtendedRpcResult> {
|
|
127
|
+
const scope = loginScope(d, ctx);
|
|
128
|
+
if ('error' in scope) return scope;
|
|
129
|
+
const { adapter, endpointKey } = scope;
|
|
130
|
+
if (!adapter || !endpointKey) return { error: 'adapter and endpointKey are required' };
|
|
131
|
+
const relogin = type === LOGIN_RPC.RELOGIN;
|
|
132
|
+
const label = relogin ? 'relogin' : 'refresh challenge';
|
|
133
|
+
if (!ctx.withEndpointLogin) return unsupported(label);
|
|
134
|
+
const result = await ctx.withEndpointLogin(adapter, endpointKey, async (login: ConsoleEndpointLoginAccess) => {
|
|
135
|
+
const declared = relogin ? login.declaration?.relogin === true : login.declaration?.refreshQr === true;
|
|
136
|
+
const run = relogin ? login.control?.relogin : login.control?.refreshChallenge;
|
|
137
|
+
if (!declared || !run) return unsupported(label, adapter);
|
|
138
|
+
await run.call(login.control, {});
|
|
139
|
+
return { data: { accepted: true } } satisfies ExtendedRpcResult;
|
|
140
|
+
});
|
|
141
|
+
return result ?? { error: `Endpoint not found: ${adapter}/${endpointKey}` };
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Audit detail of a login RPC reply: the result category only (never values, never messages).
|
|
146
|
+
* `undefined` for other RPCs.
|
|
147
|
+
*/
|
|
148
|
+
export function loginAuditDetail(type: unknown, data: unknown): Readonly<Record<string, string>> | undefined {
|
|
149
|
+
if (type !== LOGIN_RPC.SUBMIT && type !== LOGIN_RPC.CANCEL && type !== LOGIN_RPC.RELOGIN && type !== LOGIN_RPC.REFRESH) return undefined;
|
|
150
|
+
if (!data || typeof data !== 'object') return undefined;
|
|
151
|
+
const record = data as { outcome?: { result?: unknown; code?: unknown }; pending?: unknown; accepted?: unknown; success?: unknown; code?: unknown };
|
|
152
|
+
if (typeof record.code === 'string') return Object.freeze({ result: record.code });
|
|
153
|
+
if (record.outcome && typeof record.outcome.result === 'string') {
|
|
154
|
+
return Object.freeze({ result: record.outcome.result, ...(typeof record.outcome.code === 'string' ? { code: record.outcome.code } : {}) });
|
|
155
|
+
}
|
|
156
|
+
if (record.pending === true) return Object.freeze({ result: 'pending' });
|
|
157
|
+
if (record.accepted === true || record.success === true) return Object.freeze({ result: 'accepted' });
|
|
158
|
+
return undefined;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// ---------------------------------------------------------------- helpers
|
|
162
|
+
|
|
163
|
+
function unsupported(what: string, adapter?: string): ExtendedRpcResult {
|
|
164
|
+
return {
|
|
165
|
+
error: `${CONSOLE_LOGIN_UNSUPPORTED}: ${adapter ? `${adapter} does not support ${what}` : `${what} not available on this host`}`,
|
|
166
|
+
data: { code: CONSOLE_LOGIN_UNSUPPORTED },
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
interface LoginScope { readonly adapter: string; readonly endpointKey: string }
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* The Endpoint a login RPC is about: `adapter` / `endpointKey` read from `data` and the top level,
|
|
174
|
+
* exactly the fields the authorization map reads (ADR 0049). Sources that disagree, or a value that
|
|
175
|
+
* is not a string, are rejected. When the dispatcher admitted the caller only through an adapter /
|
|
176
|
+
* endpoint-scoped grant (`ctx.authorizedEndpoint`), the scope is that Endpoint and any other named
|
|
177
|
+
* target is rejected; global callers may omit it (`''` = no filter).
|
|
178
|
+
*/
|
|
179
|
+
function loginScope(d: Record<string, unknown>, ctx: ConsoleRpcExtendedCtx): LoginScope | { error: string } {
|
|
180
|
+
const sources = d.data && typeof d.data === 'object' && !Array.isArray(d.data)
|
|
181
|
+
? [d.data as Record<string, unknown>, d]
|
|
182
|
+
: [d];
|
|
183
|
+
const read = (key: 'adapter' | 'endpointKey'): string | false => {
|
|
184
|
+
let found = '';
|
|
185
|
+
for (const source of sources) {
|
|
186
|
+
const value = source[key];
|
|
187
|
+
if (value === undefined || value === null || value === '') continue;
|
|
188
|
+
if (typeof value !== 'string') return false;
|
|
189
|
+
const text = value.trim();
|
|
190
|
+
if (!text) continue;
|
|
191
|
+
if (found && found !== text) return false;
|
|
192
|
+
found = text;
|
|
193
|
+
}
|
|
194
|
+
return found;
|
|
195
|
+
};
|
|
196
|
+
const adapter = read('adapter');
|
|
197
|
+
const endpointKey = read('endpointKey');
|
|
198
|
+
if (adapter === false || endpointKey === false) return { error: 'Conflicting login target (adapter / endpointKey)' };
|
|
199
|
+
const authorized = ctx.authorizedEndpoint;
|
|
200
|
+
if (!authorized) return { adapter, endpointKey };
|
|
201
|
+
if ((adapter && adapter !== authorized.adapter) || (endpointKey && endpointKey !== authorized.endpointKey)) {
|
|
202
|
+
return { error: 'Login target outside the authorized Endpoint' };
|
|
203
|
+
}
|
|
204
|
+
return { adapter: authorized.adapter, endpointKey: authorized.endpointKey };
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/** The caller may handle this Endpoint's challenge (`endpoint.operate` there); no principal = trusted. */
|
|
208
|
+
function canOperate(ctx: ConsoleRpcExtendedCtx, item: { readonly adapter: string; readonly endpointKey: string }): boolean {
|
|
209
|
+
return ctx.canOperateEndpoint ? ctx.canOperateEndpoint(item.adapter, item.endpointKey) : true;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* A flow as this caller may see it: live for operators, otherwise the event-safe form (no QR image,
|
|
214
|
+
* sensitive blocks redacted, verification links dropped). A port without `eventSafeFlow` falls back
|
|
215
|
+
* to removing every challenge's media and blocks.
|
|
216
|
+
*/
|
|
217
|
+
function visibleFlow(assist: ConsoleLoginAssistPort, ctx: ConsoleRpcExtendedCtx, flow: ConsoleLoginFlow): ConsoleLoginFlow {
|
|
218
|
+
if (canOperate(ctx, flow)) return flow;
|
|
219
|
+
return assist.eventSafeFlow ? assist.eventSafeFlow(flow) : withoutChallengeMaterial(flow);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function withoutChallengeMaterial(flow: ConsoleLoginFlow): ConsoleLoginFlow {
|
|
223
|
+
if (!flow.steps.some((step) => step.challenge?.media || step.challenge?.blocks)) return flow;
|
|
224
|
+
return Object.freeze({
|
|
225
|
+
...flow,
|
|
226
|
+
steps: Object.freeze(flow.steps.map((step) => {
|
|
227
|
+
if (!step.challenge || (!step.challenge.media && !step.challenge.blocks)) return step;
|
|
228
|
+
const { media, blocks: _blocks, ...challenge } = step.challenge;
|
|
229
|
+
return Object.freeze({
|
|
230
|
+
...step,
|
|
231
|
+
challenge: Object.freeze({
|
|
232
|
+
...challenge,
|
|
233
|
+
media: Object.freeze({
|
|
234
|
+
...(media?.expiresAt !== undefined ? { expiresAt: media.expiresAt } : {}),
|
|
235
|
+
...(media?.refreshable !== undefined ? { refreshable: media.refreshable } : {}),
|
|
236
|
+
...(media?.qrImage ? { qrImageOmitted: true } : {}),
|
|
237
|
+
...(media?.link || media?.captcha ? { linkOmitted: true } : {}),
|
|
238
|
+
}),
|
|
239
|
+
}),
|
|
240
|
+
});
|
|
241
|
+
})),
|
|
242
|
+
});
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* Legacy task payload without the QR image (`image`) and verification link (`url`, may carry a
|
|
247
|
+
* session id), marked `imageOmitted` / `urlOmitted` like the `endpoint.login.*` events.
|
|
248
|
+
*/
|
|
249
|
+
function eventSafeLoginPayload(payload: Record<string, unknown>): Record<string, unknown> {
|
|
250
|
+
// Allow-list: only the message survives; legacy adapters may put sid / ticket / device anywhere.
|
|
251
|
+
const safe: Record<string, unknown> = {};
|
|
252
|
+
if (typeof payload.message === 'string') safe.message = payload.message;
|
|
253
|
+
if (payload.image !== undefined) safe.imageOmitted = true;
|
|
254
|
+
if (payload.url !== undefined) safe.urlOmitted = true;
|
|
255
|
+
if (Object.keys(payload).some((key) => key !== 'message' && key !== 'image' && key !== 'url')) safe.fieldsOmitted = true;
|
|
256
|
+
return safe;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function inScope(scope: LoginScope, item: { readonly adapter: string; readonly endpointKey: string }): boolean {
|
|
260
|
+
return (!scope.adapter || item.adapter === scope.adapter) && (!scope.endpointKey || item.endpointKey === scope.endpointKey);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/** When the caller names an Endpoint, or was authorized for one, the task must belong to it. */
|
|
264
|
+
function checkTaskTarget(d: Record<string, unknown>, ctx: ConsoleRpcExtendedCtx, id: string): ExtendedRpcResult | undefined {
|
|
265
|
+
const scope = loginScope(d, ctx);
|
|
266
|
+
if ('error' in scope) return scope;
|
|
267
|
+
if (!scope.adapter && !scope.endpointKey) return undefined;
|
|
268
|
+
const task = ctx.loginAssist?.listPending().find((item) => item.id === id);
|
|
269
|
+
if (!task || !inScope(scope, task)) return { error: `login task not found: ${id}` };
|
|
270
|
+
return undefined;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function readSubmitValue(raw: unknown): string | Record<string, unknown> | false {
|
|
274
|
+
if (raw === undefined || raw === null) return '';
|
|
275
|
+
if (typeof raw === 'string') return raw;
|
|
276
|
+
if (typeof raw === 'number' || typeof raw === 'boolean') return String(raw);
|
|
277
|
+
if (typeof raw !== 'object' || Array.isArray(raw)) return false;
|
|
278
|
+
const record = raw as Record<string, unknown>;
|
|
279
|
+
if (record.action === undefined) return record;
|
|
280
|
+
if (typeof record.action !== 'string' || !record.action || record.action.length > 64) return false;
|
|
281
|
+
if (record.values !== undefined && (!record.values || typeof record.values !== 'object' || Array.isArray(record.values))) return false;
|
|
282
|
+
const values: Record<string, string> = {};
|
|
283
|
+
for (const [key, item] of Object.entries((record.values ?? {}) as Record<string, unknown>)) {
|
|
284
|
+
if (typeof item !== 'string' && typeof item !== 'number') return false;
|
|
285
|
+
values[key] = String(item);
|
|
286
|
+
}
|
|
287
|
+
return { action: record.action, values };
|
|
288
|
+
}
|
|
56
289
|
|
|
57
290
|
function readEndpointIdentity(value: unknown): { readonly canonicalId: string; readonly instanceId: string; readonly epoch: number; readonly incarnationId: string } | false | undefined {
|
|
58
291
|
if (value === undefined) return undefined;
|
|
@@ -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 {
|
|
@@ -257,6 +258,21 @@ export function pickRpcReply(
|
|
|
257
258
|
);
|
|
258
259
|
}
|
|
259
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
|
+
|
|
260
276
|
export async function dispatchRuntimeConsoleRpc(
|
|
261
277
|
message: RuntimeConsoleRpcMessage,
|
|
262
278
|
ctx: RuntimeConsoleRpcContext,
|
|
@@ -268,12 +284,20 @@ export async function dispatchRuntimeConsoleRpc(
|
|
|
268
284
|
const type = String(message.type ?? '');
|
|
269
285
|
const requestId = message.requestId as number | string | undefined;
|
|
270
286
|
|
|
287
|
+
let authorizedEndpoint: ConsoleRpcExtendedCtx['authorizedEndpoint'];
|
|
288
|
+
let canOperateEndpoint: ConsoleRpcExtendedCtx['canOperateEndpoint'];
|
|
271
289
|
if (ctx.principal) {
|
|
272
|
-
const
|
|
290
|
+
const principal = ctx.principal;
|
|
291
|
+
const decision = authorizeConsoleRpc(principal, message as Record<string, unknown>);
|
|
273
292
|
if (!decision.allowed) {
|
|
274
293
|
emit({ requestId, error: decision.error, code: decision.code });
|
|
275
294
|
return payloads;
|
|
276
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
|
+
);
|
|
277
301
|
}
|
|
278
302
|
|
|
279
303
|
// Extended RPC surface (cron/schedule、endpoint 社交/inbox)
|
|
@@ -281,11 +305,16 @@ export async function dispatchRuntimeConsoleRpc(
|
|
|
281
305
|
const extended = await dispatchExtendedConsoleRpc(
|
|
282
306
|
type,
|
|
283
307
|
message as Record<string, unknown>,
|
|
284
|
-
{
|
|
308
|
+
{
|
|
309
|
+
...ctx.extended,
|
|
310
|
+
fullScope: ctx.authScope === 'full',
|
|
311
|
+
...(authorizedEndpoint ? { authorizedEndpoint } : {}),
|
|
312
|
+
...(canOperateEndpoint ? { canOperateEndpoint } : {}),
|
|
313
|
+
},
|
|
285
314
|
);
|
|
286
315
|
if (extended !== undefined) {
|
|
287
316
|
emit('error' in extended
|
|
288
|
-
? { requestId, error: extended.error }
|
|
317
|
+
? { requestId, error: extended.error, ...(extended.data !== undefined ? { data: extended.data } : {}) }
|
|
289
318
|
: { requestId, data: extended.data });
|
|
290
319
|
return payloads;
|
|
291
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,
|
|
@@ -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
|
|