@zhin.js/host-http 1.2.1 → 1.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/access-control.d.ts +50 -5
- package/lib/access-control.js +167 -13
- package/lib/console-rest-pages-helpers.js +24 -2
- package/lib/console-rest-pages.d.ts +4 -1
- package/lib/console-rest-pages.js +49 -23
- package/lib/console-rpc-extended/contracts.d.ts +92 -15
- package/lib/console-rpc-extended/dispatcher.js +10 -3
- package/lib/console-rpc-extended/endpoint-rpc.js +21 -4
- package/lib/console-rpc-extended/index.d.ts +2 -1
- package/lib/console-rpc-extended/index.js +1 -0
- package/lib/console-rpc-extended/login-rpc.d.ts +25 -2
- package/lib/console-rpc-extended/login-rpc.js +276 -16
- package/lib/console-rpc-extended/schedule-rpc.d.ts +1 -1
- package/lib/console-rpc-extended/schedule-rpc.js +78 -12
- package/lib/console-rpc-handlers.d.ts +11 -1
- package/lib/console-rpc-handlers.js +221 -12
- package/lib/console-rpc-types.d.ts +2 -0
- package/lib/console-rpc-types.js +43 -0
- package/lib/console-rpc.d.ts +29 -1
- package/lib/console-rpc.js +33 -7
- package/lib/http-host.js +5 -4
- package/lib/index.d.ts +5 -3
- package/lib/index.js +4 -2
- package/lib/project-files.d.ts +7 -0
- package/lib/project-files.js +88 -11
- package/lib/unknown-rpc.d.ts +12 -0
- package/lib/unknown-rpc.js +33 -0
- package/package.json +3 -3
- package/src/access-control.ts +209 -14
- package/src/console-rest-pages-helpers.ts +27 -3
- package/src/console-rest-pages.ts +52 -15
- package/src/console-rpc-extended/contracts.ts +83 -16
- package/src/console-rpc-extended/dispatcher.ts +10 -3
- package/src/console-rpc-extended/endpoint-rpc.ts +28 -4
- package/src/console-rpc-extended/index.ts +4 -0
- package/src/console-rpc-extended/login-rpc.ts +250 -17
- package/src/console-rpc-extended/schedule-rpc.ts +67 -3
- package/src/console-rpc-handlers.ts +198 -11
- package/src/console-rpc-types.ts +45 -0
- package/src/console-rpc.ts +63 -7
- package/src/http-host.ts +6 -2
- package/src/index.ts +15 -0
- package/src/project-files.ts +95 -11
- package/src/unknown-rpc.ts +44 -0
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { ConsoleAdapterLoginDeclaration, ConsoleLoginFlow, ConsoleLoginOutcome } from '@zhin.js/console-protocol';
|
|
1
2
|
interface LoginEndpointIdentity {
|
|
2
3
|
readonly canonicalId: string;
|
|
3
4
|
readonly instanceId: string;
|
|
@@ -20,27 +21,37 @@ export interface ConsoleRpcExtendedCtx {
|
|
|
20
21
|
};
|
|
21
22
|
/** Narrow generation-bound persistent-job port; null when Assistant is disabled. */
|
|
22
23
|
resolveScheduleEngine?: () => ConsoleScheduleEngine | null | undefined;
|
|
23
|
-
/**
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
createdAt: number;
|
|
33
|
-
expiresAt?: number;
|
|
34
|
-
}[];
|
|
35
|
-
submit(id: string, value: string | Record<string, unknown>, endpointIdentity?: LoginEndpointIdentity): boolean;
|
|
36
|
-
cancel(id: string, reason?: string, endpointIdentity?: LoginEndpointIdentity): boolean;
|
|
37
|
-
};
|
|
24
|
+
/**
|
|
25
|
+
* Resolves a persistent job's delivery target (#1250): `{ resolved, display?, reason? }`, where
|
|
26
|
+
* `resolved: false` means the job's results cannot be delivered (no target / unknown account).
|
|
27
|
+
*/
|
|
28
|
+
describeScheduleTarget?: (notify: unknown) => ConsoleScheduleTarget;
|
|
29
|
+
/** LoginAssist list/submit/cancel(刷新后仍可消费扫码/滑块待办);ADR 0058 flow methods are optional. */
|
|
30
|
+
loginAssist?: ConsoleLoginAssistPort;
|
|
31
|
+
/** ADR 0058: the Endpoint's login declaration and lease-scoped login control (`null` = unknown Endpoint). */
|
|
32
|
+
withEndpointLogin?: <T>(adapter: string, endpointKey: string, run: (login: ConsoleEndpointLoginAccess) => T | Promise<T>) => Promise<T | null>;
|
|
38
33
|
/** Authenticated Root adapter; mutation DTOs deliberately contain no principal/decision fields. */
|
|
39
34
|
workroomProfileControl?: ConsoleWorkroomProfileControlPort;
|
|
40
35
|
/** Authenticated HTTP principal; never accepted from the RPC body. */
|
|
41
36
|
authenticatedPrincipal?: Readonly<{
|
|
42
37
|
principalId: string;
|
|
43
38
|
}>;
|
|
39
|
+
/**
|
|
40
|
+
* The Endpoint the caller was authorized for when its grant covers this call only at an adapter /
|
|
41
|
+
* endpoint scope (never set for global grants, owners or in-process callers). Handlers that act on
|
|
42
|
+
* login tasks or flows restrict themselves to it; set by the dispatcher, never read from the body.
|
|
43
|
+
*/
|
|
44
|
+
authorizedEndpoint?: Readonly<{
|
|
45
|
+
adapter: string;
|
|
46
|
+
endpointKey: string;
|
|
47
|
+
}>;
|
|
48
|
+
/**
|
|
49
|
+
* Whether the caller holds `endpoint.operate` on one Endpoint (set by the dispatcher from the
|
|
50
|
+
* authenticated principal, never read from the body). Login read RPCs return live challenge
|
|
51
|
+
* material (QR image, sensitive blocks, verification links, legacy `payload.image` / `url`) only
|
|
52
|
+
* when it says yes; absent (in-process callers without a principal) = trusted.
|
|
53
|
+
*/
|
|
54
|
+
canOperateEndpoint?: (adapter: string, endpointKey: string) => boolean;
|
|
44
55
|
workroomKnowledgeControl?: ConsoleWorkroomKnowledgeControlPort;
|
|
45
56
|
}
|
|
46
57
|
export interface ConsoleWorkroomProfileControlPort {
|
|
@@ -118,15 +129,74 @@ export interface ConsoleScheduleEngine {
|
|
|
118
129
|
resumeJob(id: string): Promise<boolean>;
|
|
119
130
|
listJobs(): Promise<Record<string, unknown>[]>;
|
|
120
131
|
}
|
|
132
|
+
/** `data` next to `error` carries a machine-readable code (e.g. `{ code: 'unsupported' }`). */
|
|
121
133
|
export type ExtendedRpcResult = {
|
|
122
134
|
data: unknown;
|
|
123
135
|
} | {
|
|
124
136
|
error: string;
|
|
137
|
+
data?: unknown;
|
|
125
138
|
};
|
|
139
|
+
/** Structural LoginAssist port (host/http must not import `@zhin.js/core`). */
|
|
140
|
+
export interface ConsoleLoginAssistPort {
|
|
141
|
+
listPending(): readonly {
|
|
142
|
+
id: string;
|
|
143
|
+
adapter: string;
|
|
144
|
+
endpointKey: string;
|
|
145
|
+
endpointIdentity?: LoginEndpointIdentity;
|
|
146
|
+
type: string;
|
|
147
|
+
payload: Record<string, unknown>;
|
|
148
|
+
createdAt: number;
|
|
149
|
+
expiresAt?: number;
|
|
150
|
+
flowId?: string;
|
|
151
|
+
challengeId?: string;
|
|
152
|
+
}[];
|
|
153
|
+
submit(id: string, value: string | Record<string, unknown>, endpointIdentity?: LoginEndpointIdentity): boolean;
|
|
154
|
+
cancel(id: string, reason?: string, endpointIdentity?: LoginEndpointIdentity): boolean;
|
|
155
|
+
/** ADR 0058 */
|
|
156
|
+
listFlows?(): readonly ConsoleLoginFlow[];
|
|
157
|
+
getFlow?(adapter: string, endpointKey: string): ConsoleLoginFlow | undefined;
|
|
158
|
+
getFlowById?(id: string): ConsoleLoginFlow | undefined;
|
|
159
|
+
/**
|
|
160
|
+
* Event-safe copy of a flow (core `withoutLoginMedia`): no QR image, sensitive blocks redacted,
|
|
161
|
+
* verification links dropped. Returned to readers without `endpoint.operate` on the flow's
|
|
162
|
+
* Endpoint; without it the host strips every challenge's media and blocks.
|
|
163
|
+
*/
|
|
164
|
+
eventSafeFlow?(flow: ConsoleLoginFlow): ConsoleLoginFlow;
|
|
165
|
+
submitAndWait?(id: string, value: string | Record<string, unknown>, options?: {
|
|
166
|
+
endpointIdentity?: LoginEndpointIdentity;
|
|
167
|
+
timeoutMs?: number;
|
|
168
|
+
}): Promise<{
|
|
169
|
+
accepted: boolean;
|
|
170
|
+
outcome?: ConsoleLoginOutcome;
|
|
171
|
+
pending?: boolean;
|
|
172
|
+
flow?: ConsoleLoginFlow;
|
|
173
|
+
}>;
|
|
174
|
+
}
|
|
175
|
+
export interface ConsoleEndpointLoginAccess {
|
|
176
|
+
readonly declaration?: ConsoleAdapterLoginDeclaration;
|
|
177
|
+
readonly control?: {
|
|
178
|
+
relogin?(request: {
|
|
179
|
+
signal?: AbortSignal;
|
|
180
|
+
}): Promise<void>;
|
|
181
|
+
refreshChallenge?(request: {
|
|
182
|
+
signal?: AbortSignal;
|
|
183
|
+
}): Promise<void>;
|
|
184
|
+
};
|
|
185
|
+
}
|
|
126
186
|
export interface ScheduleJobRow {
|
|
127
187
|
id: string;
|
|
128
188
|
cron: string;
|
|
129
189
|
description?: string;
|
|
190
|
+
/** Host display metadata (`ScheduleJobDisplay` in `@zhin.js/plugin-runtime`). */
|
|
191
|
+
title?: string;
|
|
192
|
+
titleI18n?: Readonly<Partial<Record<string, string>>>;
|
|
193
|
+
kind?: 'plugin-internal' | 'user-task';
|
|
194
|
+
lastRun?: {
|
|
195
|
+
startedAt: number;
|
|
196
|
+
finishedAt: number;
|
|
197
|
+
status: 'succeeded' | 'failed';
|
|
198
|
+
error?: string;
|
|
199
|
+
};
|
|
130
200
|
}
|
|
131
201
|
export interface InboxSelection {
|
|
132
202
|
where(query: Record<string, unknown>): InboxSelection;
|
|
@@ -209,4 +279,11 @@ export interface EndpointManagementPort {
|
|
|
209
279
|
setGroupAdmin?(request: EndpointAdminCommand): Promise<void>;
|
|
210
280
|
deleteFriend?(request: EndpointUserCommand): Promise<void>;
|
|
211
281
|
}
|
|
282
|
+
/** `target` of a `schedule:list` persistent row (#1250). */
|
|
283
|
+
export interface ConsoleScheduleTarget {
|
|
284
|
+
readonly resolved: boolean;
|
|
285
|
+
readonly display?: string;
|
|
286
|
+
/** `missing_target` | `incomplete_target` | `endpoint_not_found` */
|
|
287
|
+
readonly reason?: string;
|
|
288
|
+
}
|
|
212
289
|
export {};
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { addCron, listSchedule, mutateCron, validateCron } from './schedule-rpc.js';
|
|
2
2
|
import { actOnRequest, listInbox, listInboxMessages, listPendingRequests, listRecentInboxMessages, mapNoticeRow, mapRequestRow, markInboxConsumed, TABLE_NOTICE, TABLE_REQUEST, } from './inbox-rpc.js';
|
|
3
|
-
import { cancelLogin, listLoginPending, submitLogin } from './login-rpc.js';
|
|
3
|
+
import { cancelLogin, controlLogin, getLoginFlow, listLoginFlows, listLoginPending, submitLogin } from './login-rpc.js';
|
|
4
4
|
import { deleteFriend, groupWriteOp, listChannels, listFriends, listGroupMembers, listGroups, } from './endpoint-rpc.js';
|
|
5
5
|
import { mutateWorkroomKnowledge, mutateWorkroomProfile } from './workroom-rpc.js';
|
|
6
6
|
export async function dispatchExtendedConsoleRpc(type, data, ctx) {
|
|
@@ -9,7 +9,7 @@ export async function dispatchExtendedConsoleRpc(type, data, ctx) {
|
|
|
9
9
|
switch (type) {
|
|
10
10
|
case 'schedule:list':
|
|
11
11
|
case 'cron:list':
|
|
12
|
-
return listSchedule(ctx);
|
|
12
|
+
return listSchedule(ctx, d);
|
|
13
13
|
case 'cron:validate':
|
|
14
14
|
return validateCron(d);
|
|
15
15
|
case 'cron:add':
|
|
@@ -32,11 +32,18 @@ export async function dispatchExtendedConsoleRpc(type, data, ctx) {
|
|
|
32
32
|
case 'request.reject':
|
|
33
33
|
return actOnRequest(type, d, ctx);
|
|
34
34
|
case 'login.list':
|
|
35
|
-
return listLoginPending(ctx);
|
|
35
|
+
return listLoginPending(d, ctx);
|
|
36
36
|
case 'login.submit':
|
|
37
37
|
return submitLogin(d, ctx);
|
|
38
38
|
case 'login.cancel':
|
|
39
39
|
return cancelLogin(d, ctx);
|
|
40
|
+
case 'login.flow.list':
|
|
41
|
+
return listLoginFlows(d, ctx);
|
|
42
|
+
case 'login.flow.get':
|
|
43
|
+
return getLoginFlow(d, ctx);
|
|
44
|
+
case 'login.relogin':
|
|
45
|
+
case 'login.refresh':
|
|
46
|
+
return controlLogin(type, d, ctx);
|
|
40
47
|
case 'request.consumed':
|
|
41
48
|
return markInboxConsumed(d, ctx, TABLE_REQUEST);
|
|
42
49
|
case 'notice.consumed':
|
|
@@ -8,7 +8,7 @@ export async function listFriends(d, ctx) {
|
|
|
8
8
|
const friends = [...await management.listFriends()];
|
|
9
9
|
return { data: { friends, count: friends.length } };
|
|
10
10
|
}
|
|
11
|
-
return
|
|
11
|
+
return unsupportedList('friends', 'listFriends', endpoint.adapter, '好友列表');
|
|
12
12
|
});
|
|
13
13
|
}
|
|
14
14
|
export async function listGroups(d, ctx) {
|
|
@@ -20,7 +20,7 @@ export async function listGroups(d, ctx) {
|
|
|
20
20
|
const groups = [...await management.listGroups()];
|
|
21
21
|
return { data: { groups, count: groups.length } };
|
|
22
22
|
}
|
|
23
|
-
return
|
|
23
|
+
return unsupportedList('groups', 'listGroups', endpoint.adapter, '群列表');
|
|
24
24
|
});
|
|
25
25
|
}
|
|
26
26
|
export async function listChannels(d, ctx) {
|
|
@@ -32,7 +32,7 @@ export async function listChannels(d, ctx) {
|
|
|
32
32
|
const channels = [...await management.listChannels()];
|
|
33
33
|
return { data: { channels, count: channels.length } };
|
|
34
34
|
}
|
|
35
|
-
return
|
|
35
|
+
return unsupportedList('channels', 'listChannels', endpoint.adapter, '频道列表');
|
|
36
36
|
});
|
|
37
37
|
}
|
|
38
38
|
export async function listGroupMembers(d, ctx) {
|
|
@@ -45,12 +45,29 @@ export async function listGroupMembers(d, ctx) {
|
|
|
45
45
|
return withLiveEndpoint(ctx, adapter, endpointKey, async (management) => {
|
|
46
46
|
const method = management.listGroupMembers;
|
|
47
47
|
if (!method) {
|
|
48
|
-
return
|
|
48
|
+
return unsupportedList('members', 'listGroupMembers', adapter, '群成员列表');
|
|
49
49
|
}
|
|
50
50
|
const members = [...await method.call(management, { groupId })];
|
|
51
51
|
return { data: { members, count: members.length } };
|
|
52
52
|
});
|
|
53
53
|
}
|
|
54
|
+
/**
|
|
55
|
+
* A read the Adapter does not declare is not a failure (#1250): reply with an empty list plus
|
|
56
|
+
* `status: 'unsupported'` (`ConsoleEndpointCapabilityUnsupported`) instead of `{ error }`, so the
|
|
57
|
+
* Console can render "该平台不提供" rather than a red read error. Older clients still see an empty
|
|
58
|
+
* list with `count: 0`.
|
|
59
|
+
*/
|
|
60
|
+
function unsupportedList(key, capability, adapter, label) {
|
|
61
|
+
return {
|
|
62
|
+
data: {
|
|
63
|
+
[key]: [],
|
|
64
|
+
count: 0,
|
|
65
|
+
status: 'unsupported',
|
|
66
|
+
capability,
|
|
67
|
+
message: `当前适配器(${adapter})不支持${label}`,
|
|
68
|
+
},
|
|
69
|
+
};
|
|
70
|
+
}
|
|
54
71
|
export async function groupWriteOp(d, ctx, spec) {
|
|
55
72
|
const adapter = strField(d, 'adapter');
|
|
56
73
|
const endpointKey = strField(d, 'endpointKey');
|
|
@@ -1,2 +1,3 @@
|
|
|
1
|
-
export type { ConsoleRpcExtendedCtx, ConsoleScheduleEngine, ConsoleWorkroomKnowledgeControlPort, ConsoleWorkroomProfileControlPort, ExtendedRpcResult, } from './contracts.js';
|
|
1
|
+
export type { ConsoleRpcExtendedCtx, ConsoleScheduleEngine, ConsoleScheduleTarget, ConsoleWorkroomKnowledgeControlPort, ConsoleWorkroomProfileControlPort, ExtendedRpcResult, ConsoleLoginAssistPort, ConsoleEndpointLoginAccess, } from './contracts.js';
|
|
2
|
+
export { loginAuditDetail, LOGIN_SUBMIT_WAIT_MS } from './login-rpc.js';
|
|
2
3
|
export { dispatchExtendedConsoleRpc } from './dispatcher.js';
|
|
@@ -1,4 +1,27 @@
|
|
|
1
|
+
import { LOGIN_RPC } from '@zhin.js/console-protocol';
|
|
1
2
|
import type { ConsoleRpcExtendedCtx, ExtendedRpcResult } from './contracts.js';
|
|
2
|
-
|
|
3
|
-
export declare
|
|
3
|
+
/** `login.submit` with `wait: true` waits at most this long for the login result. */
|
|
4
|
+
export declare const LOGIN_SUBMIT_WAIT_MS = 15000;
|
|
5
|
+
export declare function listLoginPending(d: Record<string, unknown>, ctx: ConsoleRpcExtendedCtx): ExtendedRpcResult;
|
|
6
|
+
/**
|
|
7
|
+
* `login.submit { taskId, value, endpointIdentity?, adapter?, endpointKey?, wait? }`.
|
|
8
|
+
* `value` is a string (legacy) or `{ action, values }`. With `wait: true` the reply carries the login
|
|
9
|
+
* result (`{ accepted, outcome, flow }`) or `{ accepted, pending: true }` after 15 s; without it the
|
|
10
|
+
* legacy `{ success: true }` reply is unchanged.
|
|
11
|
+
*/
|
|
12
|
+
export declare function submitLogin(d: Record<string, unknown>, ctx: ConsoleRpcExtendedCtx): Promise<ExtendedRpcResult>;
|
|
4
13
|
export declare function cancelLogin(d: Record<string, unknown>, ctx: ConsoleRpcExtendedCtx): ExtendedRpcResult;
|
|
14
|
+
/**
|
|
15
|
+
* `login.flow.list { adapter?, endpointKey? }`: latest flow per Endpoint. Live challenge material
|
|
16
|
+
* only for Endpoints the caller may operate; the event-safe form otherwise (ADR 0058 §8).
|
|
17
|
+
*/
|
|
18
|
+
export declare function listLoginFlows(d: Record<string, unknown>, ctx: ConsoleRpcExtendedCtx): ExtendedRpcResult;
|
|
19
|
+
/** `login.flow.get { flowId } | { adapter, endpointKey }` → `{ flow: … | null }`. */
|
|
20
|
+
export declare function getLoginFlow(d: Record<string, unknown>, ctx: ConsoleRpcExtendedCtx): ExtendedRpcResult;
|
|
21
|
+
/** `login.relogin` / `login.refresh { adapter, endpointKey }` through the Endpoint's login port. */
|
|
22
|
+
export declare function controlLogin(type: typeof LOGIN_RPC.RELOGIN | typeof LOGIN_RPC.REFRESH, d: Record<string, unknown>, ctx: ConsoleRpcExtendedCtx): Promise<ExtendedRpcResult>;
|
|
23
|
+
/**
|
|
24
|
+
* Audit detail of a login RPC reply: the result category only (never values, never messages).
|
|
25
|
+
* `undefined` for other RPCs.
|
|
26
|
+
*/
|
|
27
|
+
export declare function loginAuditDetail(type: unknown, data: unknown): Readonly<Record<string, string>> | undefined;
|
|
@@ -1,43 +1,80 @@
|
|
|
1
|
+
import { CONSOLE_LOGIN_UNSUPPORTED, LOGIN_RPC } from '@zhin.js/console-protocol';
|
|
1
2
|
import { strField } from './rpc-values.js';
|
|
2
|
-
|
|
3
|
+
const NOT_WIRED = '登录辅助未接线(LoginAssist 未挂载)';
|
|
4
|
+
/** `login.submit` with `wait: true` waits at most this long for the login result. */
|
|
5
|
+
export const LOGIN_SUBMIT_WAIT_MS = 15_000;
|
|
6
|
+
export function listLoginPending(d, ctx) {
|
|
3
7
|
const assist = ctx.loginAssist;
|
|
4
8
|
if (!assist)
|
|
5
|
-
return { error:
|
|
6
|
-
const
|
|
9
|
+
return { error: NOT_WIRED };
|
|
10
|
+
const scope = loginScope(d, ctx);
|
|
11
|
+
if ('error' in scope)
|
|
12
|
+
return scope;
|
|
13
|
+
const tasks = assist.listPending().filter((task) => inScope(scope, task)).map((task) => ({
|
|
7
14
|
id: task.id,
|
|
8
15
|
adapter: task.adapter,
|
|
9
16
|
endpointKey: task.endpointKey,
|
|
10
17
|
...(task.endpointIdentity ? { endpointIdentity: task.endpointIdentity } : {}),
|
|
11
18
|
type: task.type,
|
|
12
|
-
payload: task.payload,
|
|
19
|
+
payload: canOperate(ctx, task) ? task.payload : eventSafeLoginPayload(task.payload),
|
|
13
20
|
createdAt: task.createdAt,
|
|
14
21
|
expiresAt: task.expiresAt,
|
|
22
|
+
...(task.flowId ? { flowId: task.flowId } : {}),
|
|
23
|
+
...(task.challengeId ? { challengeId: task.challengeId } : {}),
|
|
15
24
|
}));
|
|
16
25
|
return { data: { tasks, count: tasks.length } };
|
|
17
26
|
}
|
|
18
|
-
|
|
27
|
+
/**
|
|
28
|
+
* `login.submit { taskId, value, endpointIdentity?, adapter?, endpointKey?, wait? }`.
|
|
29
|
+
* `value` is a string (legacy) or `{ action, values }`. With `wait: true` the reply carries the login
|
|
30
|
+
* result (`{ accepted, outcome, flow }`) or `{ accepted, pending: true }` after 15 s; without it the
|
|
31
|
+
* legacy `{ success: true }` reply is unchanged.
|
|
32
|
+
*/
|
|
33
|
+
export async function submitLogin(d, ctx) {
|
|
19
34
|
const assist = ctx.loginAssist;
|
|
20
35
|
if (!assist)
|
|
21
|
-
return { error:
|
|
36
|
+
return { error: NOT_WIRED };
|
|
22
37
|
const id = strField(d, 'taskId');
|
|
23
38
|
if (!id)
|
|
24
39
|
return { error: 'taskId is required' };
|
|
25
|
-
const
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
: String(raw ?? '');
|
|
40
|
+
const value = readSubmitValue(d.value);
|
|
41
|
+
if (value === false)
|
|
42
|
+
return { error: 'value must be a string or { action, values }' };
|
|
29
43
|
const identity = readEndpointIdentity(d.endpointIdentity);
|
|
30
44
|
if (identity === false)
|
|
31
45
|
return { error: 'Invalid login Endpoint identity' };
|
|
32
|
-
const
|
|
33
|
-
if (
|
|
34
|
-
return
|
|
35
|
-
|
|
46
|
+
const target = checkTaskTarget(d, ctx, id);
|
|
47
|
+
if (target)
|
|
48
|
+
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)
|
|
56
|
+
return { error: `login task not found: ${id}` };
|
|
57
|
+
if (result.outcome)
|
|
58
|
+
return { data: { accepted: true, outcome: result.outcome, ...(result.flow ? { flow: result.flow } : {}) } };
|
|
59
|
+
return { data: { accepted: true, pending: true, ...(result.flow ? { flow: result.flow } : {}) } };
|
|
60
|
+
}
|
|
61
|
+
const ok = identity ? assist.submit(id, value, identity) : assist.submit(id, value);
|
|
62
|
+
if (!ok)
|
|
63
|
+
return { error: `login task not found: ${id}` };
|
|
64
|
+
return { data: { success: true } };
|
|
65
|
+
}
|
|
66
|
+
catch (error) {
|
|
67
|
+
// LoginSubmissionError: the challenge stays open; its message never contains the value.
|
|
68
|
+
if (error instanceof Error && error.name === 'LoginSubmissionError') {
|
|
69
|
+
return { error: error.message, data: { code: error.code ?? 'input_invalid' } };
|
|
70
|
+
}
|
|
71
|
+
throw error;
|
|
72
|
+
}
|
|
36
73
|
}
|
|
37
74
|
export function cancelLogin(d, ctx) {
|
|
38
75
|
const assist = ctx.loginAssist;
|
|
39
76
|
if (!assist)
|
|
40
|
-
return { error:
|
|
77
|
+
return { error: NOT_WIRED };
|
|
41
78
|
const id = strField(d, 'taskId');
|
|
42
79
|
if (!id)
|
|
43
80
|
return { error: 'taskId is required' };
|
|
@@ -45,12 +82,235 @@ export function cancelLogin(d, ctx) {
|
|
|
45
82
|
const identity = readEndpointIdentity(d.endpointIdentity);
|
|
46
83
|
if (identity === false)
|
|
47
84
|
return { error: 'Invalid login Endpoint identity' };
|
|
85
|
+
const target = checkTaskTarget(d, ctx, id);
|
|
86
|
+
if (target)
|
|
87
|
+
return target;
|
|
48
88
|
const ok = identity ? assist.cancel(id, reason, identity) : assist.cancel(id, reason);
|
|
49
89
|
if (!ok)
|
|
50
90
|
return { error: `login task not found: ${id}` };
|
|
51
91
|
return { data: { success: true } };
|
|
52
92
|
}
|
|
53
|
-
|
|
93
|
+
/**
|
|
94
|
+
* `login.flow.list { adapter?, endpointKey? }`: latest flow per Endpoint. Live challenge material
|
|
95
|
+
* only for Endpoints the caller may operate; the event-safe form otherwise (ADR 0058 §8).
|
|
96
|
+
*/
|
|
97
|
+
export function listLoginFlows(d, ctx) {
|
|
98
|
+
const assist = ctx.loginAssist;
|
|
99
|
+
if (!assist)
|
|
100
|
+
return { error: NOT_WIRED };
|
|
101
|
+
if (!assist.listFlows)
|
|
102
|
+
return unsupported('login flows');
|
|
103
|
+
const scope = loginScope(d, ctx);
|
|
104
|
+
if ('error' in scope)
|
|
105
|
+
return scope;
|
|
106
|
+
const flows = assist.listFlows().filter((flow) => inScope(scope, flow)).map((flow) => visibleFlow(assist, ctx, flow));
|
|
107
|
+
return { data: { flows, count: flows.length } };
|
|
108
|
+
}
|
|
109
|
+
/** `login.flow.get { flowId } | { adapter, endpointKey }` → `{ flow: … | null }`. */
|
|
110
|
+
export function getLoginFlow(d, ctx) {
|
|
111
|
+
const assist = ctx.loginAssist;
|
|
112
|
+
if (!assist)
|
|
113
|
+
return { error: NOT_WIRED };
|
|
114
|
+
if (!assist.getFlow || !assist.getFlowById)
|
|
115
|
+
return unsupported('login flows');
|
|
116
|
+
const flowId = strField(d, 'flowId');
|
|
117
|
+
const scope = loginScope(d, ctx);
|
|
118
|
+
if ('error' in scope)
|
|
119
|
+
return scope;
|
|
120
|
+
if (flowId) {
|
|
121
|
+
const flow = assist.getFlowById(flowId);
|
|
122
|
+
// A flow id names one Endpoint; a scoped caller cannot read another Endpoint's flow by id.
|
|
123
|
+
return { data: { flow: flow && inScope(scope, flow) ? visibleFlow(assist, ctx, flow) : null } };
|
|
124
|
+
}
|
|
125
|
+
if (!scope.adapter || !scope.endpointKey)
|
|
126
|
+
return { error: 'flowId or adapter and endpointKey are required' };
|
|
127
|
+
const flow = assist.getFlow(scope.adapter, scope.endpointKey);
|
|
128
|
+
return { data: { flow: flow ? visibleFlow(assist, ctx, flow) : null } };
|
|
129
|
+
}
|
|
130
|
+
/** `login.relogin` / `login.refresh { adapter, endpointKey }` through the Endpoint's login port. */
|
|
131
|
+
export async function controlLogin(type, d, ctx) {
|
|
132
|
+
const scope = loginScope(d, ctx);
|
|
133
|
+
if ('error' in scope)
|
|
134
|
+
return scope;
|
|
135
|
+
const { adapter, endpointKey } = scope;
|
|
136
|
+
if (!adapter || !endpointKey)
|
|
137
|
+
return { error: 'adapter and endpointKey are required' };
|
|
138
|
+
const relogin = type === LOGIN_RPC.RELOGIN;
|
|
139
|
+
const label = relogin ? 'relogin' : 'refresh challenge';
|
|
140
|
+
if (!ctx.withEndpointLogin)
|
|
141
|
+
return unsupported(label);
|
|
142
|
+
const result = await ctx.withEndpointLogin(adapter, endpointKey, async (login) => {
|
|
143
|
+
const declared = relogin ? login.declaration?.relogin === true : login.declaration?.refreshQr === true;
|
|
144
|
+
const run = relogin ? login.control?.relogin : login.control?.refreshChallenge;
|
|
145
|
+
if (!declared || !run)
|
|
146
|
+
return unsupported(label, adapter);
|
|
147
|
+
await run.call(login.control, {});
|
|
148
|
+
return { data: { accepted: true } };
|
|
149
|
+
});
|
|
150
|
+
return result ?? { error: `Endpoint not found: ${adapter}/${endpointKey}` };
|
|
151
|
+
}
|
|
152
|
+
/**
|
|
153
|
+
* Audit detail of a login RPC reply: the result category only (never values, never messages).
|
|
154
|
+
* `undefined` for other RPCs.
|
|
155
|
+
*/
|
|
156
|
+
export function loginAuditDetail(type, data) {
|
|
157
|
+
if (type !== LOGIN_RPC.SUBMIT && type !== LOGIN_RPC.CANCEL && type !== LOGIN_RPC.RELOGIN && type !== LOGIN_RPC.REFRESH)
|
|
158
|
+
return undefined;
|
|
159
|
+
if (!data || typeof data !== 'object')
|
|
160
|
+
return undefined;
|
|
161
|
+
const record = data;
|
|
162
|
+
if (typeof record.code === 'string')
|
|
163
|
+
return Object.freeze({ result: record.code });
|
|
164
|
+
if (record.outcome && typeof record.outcome.result === 'string') {
|
|
165
|
+
return Object.freeze({ result: record.outcome.result, ...(typeof record.outcome.code === 'string' ? { code: record.outcome.code } : {}) });
|
|
166
|
+
}
|
|
167
|
+
if (record.pending === true)
|
|
168
|
+
return Object.freeze({ result: 'pending' });
|
|
169
|
+
if (record.accepted === true || record.success === true)
|
|
170
|
+
return Object.freeze({ result: 'accepted' });
|
|
171
|
+
return undefined;
|
|
172
|
+
}
|
|
173
|
+
// ---------------------------------------------------------------- helpers
|
|
174
|
+
function unsupported(what, adapter) {
|
|
175
|
+
return {
|
|
176
|
+
error: `${CONSOLE_LOGIN_UNSUPPORTED}: ${adapter ? `${adapter} does not support ${what}` : `${what} not available on this host`}`,
|
|
177
|
+
data: { code: CONSOLE_LOGIN_UNSUPPORTED },
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
/**
|
|
181
|
+
* The Endpoint a login RPC is about: `adapter` / `endpointKey` read from `data` and the top level,
|
|
182
|
+
* exactly the fields the authorization map reads (ADR 0049). Sources that disagree, or a value that
|
|
183
|
+
* is not a string, are rejected. When the dispatcher admitted the caller only through an adapter /
|
|
184
|
+
* endpoint-scoped grant (`ctx.authorizedEndpoint`), the scope is that Endpoint and any other named
|
|
185
|
+
* target is rejected; global callers may omit it (`''` = no filter).
|
|
186
|
+
*/
|
|
187
|
+
function loginScope(d, ctx) {
|
|
188
|
+
const sources = d.data && typeof d.data === 'object' && !Array.isArray(d.data)
|
|
189
|
+
? [d.data, d]
|
|
190
|
+
: [d];
|
|
191
|
+
const read = (key) => {
|
|
192
|
+
let found = '';
|
|
193
|
+
for (const source of sources) {
|
|
194
|
+
const value = source[key];
|
|
195
|
+
if (value === undefined || value === null || value === '')
|
|
196
|
+
continue;
|
|
197
|
+
if (typeof value !== 'string')
|
|
198
|
+
return false;
|
|
199
|
+
const text = value.trim();
|
|
200
|
+
if (!text)
|
|
201
|
+
continue;
|
|
202
|
+
if (found && found !== text)
|
|
203
|
+
return false;
|
|
204
|
+
found = text;
|
|
205
|
+
}
|
|
206
|
+
return found;
|
|
207
|
+
};
|
|
208
|
+
const adapter = read('adapter');
|
|
209
|
+
const endpointKey = read('endpointKey');
|
|
210
|
+
if (adapter === false || endpointKey === false)
|
|
211
|
+
return { error: 'Conflicting login target (adapter / endpointKey)' };
|
|
212
|
+
const authorized = ctx.authorizedEndpoint;
|
|
213
|
+
if (!authorized)
|
|
214
|
+
return { adapter, endpointKey };
|
|
215
|
+
if ((adapter && adapter !== authorized.adapter) || (endpointKey && endpointKey !== authorized.endpointKey)) {
|
|
216
|
+
return { error: 'Login target outside the authorized Endpoint' };
|
|
217
|
+
}
|
|
218
|
+
return { adapter: authorized.adapter, endpointKey: authorized.endpointKey };
|
|
219
|
+
}
|
|
220
|
+
/** The caller may handle this Endpoint's challenge (`endpoint.operate` there); no principal = trusted. */
|
|
221
|
+
function canOperate(ctx, item) {
|
|
222
|
+
return ctx.canOperateEndpoint ? ctx.canOperateEndpoint(item.adapter, item.endpointKey) : true;
|
|
223
|
+
}
|
|
224
|
+
/**
|
|
225
|
+
* A flow as this caller may see it: live for operators, otherwise the event-safe form (no QR image,
|
|
226
|
+
* sensitive blocks redacted, verification links dropped). A port without `eventSafeFlow` falls back
|
|
227
|
+
* to removing every challenge's media and blocks.
|
|
228
|
+
*/
|
|
229
|
+
function visibleFlow(assist, ctx, flow) {
|
|
230
|
+
if (canOperate(ctx, flow))
|
|
231
|
+
return flow;
|
|
232
|
+
return assist.eventSafeFlow ? assist.eventSafeFlow(flow) : withoutChallengeMaterial(flow);
|
|
233
|
+
}
|
|
234
|
+
function withoutChallengeMaterial(flow) {
|
|
235
|
+
if (!flow.steps.some((step) => step.challenge?.media || step.challenge?.blocks))
|
|
236
|
+
return flow;
|
|
237
|
+
return Object.freeze({
|
|
238
|
+
...flow,
|
|
239
|
+
steps: Object.freeze(flow.steps.map((step) => {
|
|
240
|
+
if (!step.challenge || (!step.challenge.media && !step.challenge.blocks))
|
|
241
|
+
return step;
|
|
242
|
+
const { media, blocks: _blocks, ...challenge } = step.challenge;
|
|
243
|
+
return Object.freeze({
|
|
244
|
+
...step,
|
|
245
|
+
challenge: Object.freeze({
|
|
246
|
+
...challenge,
|
|
247
|
+
media: Object.freeze({
|
|
248
|
+
...(media?.expiresAt !== undefined ? { expiresAt: media.expiresAt } : {}),
|
|
249
|
+
...(media?.refreshable !== undefined ? { refreshable: media.refreshable } : {}),
|
|
250
|
+
...(media?.qrImage ? { qrImageOmitted: true } : {}),
|
|
251
|
+
...(media?.link || media?.captcha ? { linkOmitted: true } : {}),
|
|
252
|
+
}),
|
|
253
|
+
}),
|
|
254
|
+
});
|
|
255
|
+
})),
|
|
256
|
+
});
|
|
257
|
+
}
|
|
258
|
+
/**
|
|
259
|
+
* Legacy task payload without the QR image (`image`) and verification link (`url`, may carry a
|
|
260
|
+
* session id), marked `imageOmitted` / `urlOmitted` like the `endpoint.login.*` events.
|
|
261
|
+
*/
|
|
262
|
+
function eventSafeLoginPayload(payload) {
|
|
263
|
+
// Allow-list: only the message survives; legacy adapters may put sid / ticket / device anywhere.
|
|
264
|
+
const safe = {};
|
|
265
|
+
if (typeof payload.message === 'string')
|
|
266
|
+
safe.message = payload.message;
|
|
267
|
+
if (payload.image !== undefined)
|
|
268
|
+
safe.imageOmitted = true;
|
|
269
|
+
if (payload.url !== undefined)
|
|
270
|
+
safe.urlOmitted = true;
|
|
271
|
+
if (Object.keys(payload).some((key) => key !== 'message' && key !== 'image' && key !== 'url'))
|
|
272
|
+
safe.fieldsOmitted = true;
|
|
273
|
+
return safe;
|
|
274
|
+
}
|
|
275
|
+
function inScope(scope, item) {
|
|
276
|
+
return (!scope.adapter || item.adapter === scope.adapter) && (!scope.endpointKey || item.endpointKey === scope.endpointKey);
|
|
277
|
+
}
|
|
278
|
+
/** When the caller names an Endpoint, or was authorized for one, the task must belong to it. */
|
|
279
|
+
function checkTaskTarget(d, ctx, id) {
|
|
280
|
+
const scope = loginScope(d, ctx);
|
|
281
|
+
if ('error' in scope)
|
|
282
|
+
return scope;
|
|
283
|
+
if (!scope.adapter && !scope.endpointKey)
|
|
284
|
+
return undefined;
|
|
285
|
+
const task = ctx.loginAssist?.listPending().find((item) => item.id === id);
|
|
286
|
+
if (!task || !inScope(scope, task))
|
|
287
|
+
return { error: `login task not found: ${id}` };
|
|
288
|
+
return undefined;
|
|
289
|
+
}
|
|
290
|
+
function readSubmitValue(raw) {
|
|
291
|
+
if (raw === undefined || raw === null)
|
|
292
|
+
return '';
|
|
293
|
+
if (typeof raw === 'string')
|
|
294
|
+
return raw;
|
|
295
|
+
if (typeof raw === 'number' || typeof raw === 'boolean')
|
|
296
|
+
return String(raw);
|
|
297
|
+
if (typeof raw !== 'object' || Array.isArray(raw))
|
|
298
|
+
return false;
|
|
299
|
+
const record = raw;
|
|
300
|
+
if (record.action === undefined)
|
|
301
|
+
return record;
|
|
302
|
+
if (typeof record.action !== 'string' || !record.action || record.action.length > 64)
|
|
303
|
+
return false;
|
|
304
|
+
if (record.values !== undefined && (!record.values || typeof record.values !== 'object' || Array.isArray(record.values)))
|
|
305
|
+
return false;
|
|
306
|
+
const values = {};
|
|
307
|
+
for (const [key, item] of Object.entries((record.values ?? {}))) {
|
|
308
|
+
if (typeof item !== 'string' && typeof item !== 'number')
|
|
309
|
+
return false;
|
|
310
|
+
values[key] = String(item);
|
|
311
|
+
}
|
|
312
|
+
return { action: record.action, values };
|
|
313
|
+
}
|
|
54
314
|
function readEndpointIdentity(value) {
|
|
55
315
|
if (value === undefined)
|
|
56
316
|
return undefined;
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { ConsoleRpcExtendedCtx, ExtendedRpcResult } from './contracts.js';
|
|
2
|
-
export declare function listSchedule(ctx: ConsoleScheduleListCtx): Promise<ExtendedRpcResult> | ExtendedRpcResult;
|
|
2
|
+
export declare function listSchedule(ctx: ConsoleScheduleListCtx, d?: Record<string, unknown>): Promise<ExtendedRpcResult> | ExtendedRpcResult;
|
|
3
3
|
type ConsoleScheduleListCtx = ConsoleRpcExtendedCtx;
|
|
4
4
|
export declare function addCron(d: Record<string, unknown>, ctx: ConsoleRpcExtendedCtx): Promise<ExtendedRpcResult>;
|
|
5
5
|
export declare function mutateCron(type: 'cron:remove' | 'cron:pause' | 'cron:resume', d: Record<string, unknown>, ctx: ConsoleRpcExtendedCtx): Promise<ExtendedRpcResult>;
|