@zhin.js/host-http 1.1.10 → 1.1.12

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.
@@ -66,6 +66,7 @@ export interface ConsoleAgentSessionTree {
66
66
  resolveActiveSessionId(sessionKey: string): Promise<string | null>;
67
67
  getActiveLeafMessageId(sessionId: string): Promise<number | null>;
68
68
  listBranchPoints(sessionId: string): Promise<readonly unknown[]>;
69
+ readBranchMessage?(sessionId: string, messageId: number): Promise<string | null>;
69
70
  switchActiveLeaf(sessionId: string, messageId: number): Promise<boolean>;
70
71
  jumpToBranchIndex(sessionId: string, index: number): Promise<{
71
72
  ok: boolean;
@@ -676,13 +676,22 @@ function registerAgentSessionRoutes(route, base, ctx) {
676
676
  error: 'Agent session tree runtime 未就绪(新 Runtime 需经 acquireAgentRuntime 装配 sessionTree)',
677
677
  });
678
678
  };
679
- // HttpHost 无 `:param` 路由,用前缀路由匹配 `<sessionKey>/tree|leaf`。
679
+ // HttpHost 无 `:param` 路由,用前缀路由匹配 `<sessionKey>/tree|message|leaf`。
680
680
  route('GET', `${base}/agent/sessions/*`, async (_request, response, url) => {
681
- const parsed = parseSessionAction(url.pathname, prefix, 'tree');
681
+ const messageAction = parseSessionAction(url.pathname, prefix, 'message');
682
+ if (messageAction)
683
+ response.setHeader('Cache-Control', 'no-store');
684
+ const parsed = messageAction ?? parseSessionAction(url.pathname, prefix, 'tree');
682
685
  if (!parsed) {
683
686
  writeJson(response, 404, { success: false, error: 'Not found' });
684
687
  return;
685
688
  }
689
+ const messageId = Number(url.searchParams.get('messageId'));
690
+ const expectedSessionId = url.searchParams.get('sessionId');
691
+ if (messageAction && (!Number.isSafeInteger(messageId) || messageId < 1 || !expectedSessionId)) {
692
+ writeJson(response, 400, { success: false, error: '需要 sessionId 和正安全整数 messageId' });
693
+ return;
694
+ }
686
695
  const lease = acquireSessionTree();
687
696
  if (!lease) {
688
697
  unavailable(response);
@@ -690,7 +699,28 @@ function registerAgentSessionRoutes(route, base, ctx) {
690
699
  }
691
700
  const runtime = lease.tree;
692
701
  try {
702
+ if (messageAction && !runtime.readBranchMessage) {
703
+ writeJson(response, 501, { success: false, error: '当前 Agent runtime 不支持读取完整消息' });
704
+ return;
705
+ }
693
706
  const sessionId = await runtime.resolveActiveSessionId(parsed.sessionKey);
707
+ if (messageAction) {
708
+ if (!sessionId) {
709
+ writeJson(response, 404, { success: false, error: '未找到活跃会话' });
710
+ return;
711
+ }
712
+ if (sessionId !== expectedSessionId) {
713
+ writeJson(response, 409, { success: false, error: '会话已变化,请重新读取会话列表' });
714
+ return;
715
+ }
716
+ const text = await runtime.readBranchMessage(sessionId, messageId);
717
+ if (text === null) {
718
+ writeJson(response, 404, { success: false, error: '未找到此会话的用户消息' });
719
+ return;
720
+ }
721
+ writeJson(response, 200, { success: true, data: { messageId, text } });
722
+ return;
723
+ }
694
724
  if (!sessionId) {
695
725
  if (!isRuntimeImSessionKey(parsed.sessionKey)) {
696
726
  writeJson(response, 404, {
@@ -738,7 +768,7 @@ function registerAgentSessionRoutes(route, base, ctx) {
738
768
  finally {
739
769
  lease.release();
740
770
  }
741
- }, { summary: 'Get agent session tree', tags: ['console', 'agent'] });
771
+ }, { summary: 'Get agent session tree or message', tags: ['console', 'agent'] });
742
772
  route('POST', `${base}/agent/sessions/*`, async (request, response, url, authScope) => {
743
773
  const parsed = parseSessionAction(url.pathname, prefix, 'leaf');
744
774
  if (!parsed) {
@@ -1,3 +1,9 @@
1
+ interface LoginEndpointIdentity {
2
+ readonly canonicalId: string;
3
+ readonly instanceId: string;
4
+ readonly epoch: number;
5
+ readonly incarnationId: string;
6
+ }
1
7
  export interface ConsoleRpcExtendedCtx {
2
8
  /** full scope 才允许写操作;demo scope 只放行只读 RPC。 */
3
9
  fullScope: boolean;
@@ -20,13 +26,14 @@ export interface ConsoleRpcExtendedCtx {
20
26
  id: string;
21
27
  adapter: string;
22
28
  endpointKey: string;
29
+ endpointIdentity?: LoginEndpointIdentity;
23
30
  type: string;
24
31
  payload: Record<string, unknown>;
25
32
  createdAt: number;
26
33
  expiresAt?: number;
27
34
  }[];
28
- submit(id: string, value: string | Record<string, unknown>): boolean;
29
- cancel(id: string, reason?: string): boolean;
35
+ submit(id: string, value: string | Record<string, unknown>, endpointIdentity?: LoginEndpointIdentity): boolean;
36
+ cancel(id: string, reason?: string, endpointIdentity?: LoginEndpointIdentity): boolean;
30
37
  };
31
38
  /** Authenticated Root adapter; mutation DTOs deliberately contain no principal/decision fields. */
32
39
  workroomProfileControl?: ConsoleWorkroomProfileControlPort;
@@ -137,6 +144,33 @@ export interface InboxModel {
137
144
  where(query: Record<string, unknown>): Promise<unknown> | unknown;
138
145
  };
139
146
  }
147
+ /** HTTP's narrow structural seam; implementations keep platform objects and credentials private. */
148
+ export interface EndpointGroupCommand {
149
+ readonly groupId: string;
150
+ readonly signal?: AbortSignal;
151
+ }
152
+ export interface EndpointUserCommand {
153
+ readonly userId: string;
154
+ readonly signal?: AbortSignal;
155
+ }
156
+ export interface EndpointMemberCommand extends EndpointGroupCommand, EndpointUserCommand {
157
+ }
158
+ export interface EndpointMuteCommand extends EndpointMemberCommand {
159
+ readonly durationSeconds: number;
160
+ }
161
+ export interface EndpointAdminCommand extends EndpointMemberCommand {
162
+ readonly enabled: boolean;
163
+ }
164
+ export interface EndpointApproveCommand {
165
+ readonly requestId: string;
166
+ readonly remark?: string;
167
+ readonly signal?: AbortSignal;
168
+ }
169
+ export interface EndpointRejectCommand {
170
+ readonly requestId: string;
171
+ readonly reason?: string;
172
+ readonly signal?: AbortSignal;
173
+ }
140
174
  export interface EndpointManagementPort {
141
175
  listFriends?(): Promise<readonly {
142
176
  user_id: number | string;
@@ -156,7 +190,7 @@ export interface EndpointManagementPort {
156
190
  name?: string;
157
191
  };
158
192
  }[]>;
159
- listGroupMembers?(groupId: string): Promise<readonly unknown[]>;
193
+ listGroupMembers?(request: EndpointGroupCommand): Promise<readonly unknown[]>;
160
194
  listRequests?(): Promise<readonly {
161
195
  platform_request_id: string;
162
196
  type: string;
@@ -168,10 +202,11 @@ export interface EndpointManagementPort {
168
202
  comment?: string | null;
169
203
  created_at: number;
170
204
  }[]>;
171
- approveRequest?(requestId: string, remark?: string): Promise<void>;
172
- rejectRequest?(requestId: string, reason?: string): Promise<void>;
173
- kickGroupMember?(groupId: string, userId: string): Promise<void>;
174
- muteGroupMember?(groupId: string, userId: string, durationSeconds: number): Promise<void>;
175
- setGroupAdmin?(groupId: string, userId: string, enabled: boolean): Promise<void>;
176
- deleteFriend?(userId: string): Promise<void>;
205
+ approveRequest?(request: EndpointApproveCommand): Promise<void>;
206
+ rejectRequest?(request: EndpointRejectCommand): Promise<void>;
207
+ kickGroupMember?(request: EndpointMemberCommand): Promise<void>;
208
+ muteGroupMember?(request: EndpointMuteCommand): Promise<void>;
209
+ setGroupAdmin?(request: EndpointAdminCommand): Promise<void>;
210
+ deleteFriend?(request: EndpointUserCommand): Promise<void>;
177
211
  }
212
+ export {};
@@ -57,22 +57,16 @@ export async function dispatchExtendedConsoleRpc(type, data, ctx) {
57
57
  case 'endpoint.group_kick':
58
58
  return groupWriteOp(d, ctx, {
59
59
  method: 'kickGroupMember',
60
- buildArgs: (gid, uid) => [gid, uid],
61
- requireUser: true,
62
60
  unsupported: '踢出群成员',
63
61
  });
64
62
  case 'endpoint.group_mute':
65
63
  return groupWriteOp(d, ctx, {
66
64
  method: 'muteGroupMember',
67
- buildArgs: (gid, uid, extra) => [gid, uid, extra.duration ?? 600],
68
- requireUser: true,
69
65
  unsupported: '禁言群成员',
70
66
  });
71
67
  case 'endpoint.group_admin':
72
68
  return groupWriteOp(d, ctx, {
73
69
  method: 'setGroupAdmin',
74
- buildArgs: (gid, uid, extra) => [gid, uid, extra.enable !== false],
75
- requireUser: true,
76
70
  unsupported: '设置群管理员',
77
71
  });
78
72
  case 'endpoint.delete_friend':
@@ -5,11 +5,6 @@ export declare function listChannels(d: Record<string, unknown>, ctx: ConsoleRpc
5
5
  export declare function listGroupMembers(d: Record<string, unknown>, ctx: ConsoleRpcExtendedCtx): Promise<ExtendedRpcResult>;
6
6
  interface GroupWriteSpec {
7
7
  method: 'kickGroupMember' | 'muteGroupMember' | 'setGroupAdmin';
8
- buildArgs(groupId: string, userId: string, extra: {
9
- duration?: number;
10
- enable?: boolean;
11
- }): unknown[];
12
- requireUser: boolean;
13
8
  /** 中文操作名,用于"该平台不支持 xxx"。 */
14
9
  unsupported: string;
15
10
  }
@@ -47,7 +47,7 @@ export async function listGroupMembers(d, ctx) {
47
47
  if (!method) {
48
48
  return { error: `当前适配器(${adapter})不支持群成员列表` };
49
49
  }
50
- const members = [...await method(groupId)];
50
+ const members = [...await method.call(management, { groupId })];
51
51
  return { data: { members, count: members.length } };
52
52
  });
53
53
  }
@@ -59,7 +59,7 @@ export async function groupWriteOp(d, ctx, spec) {
59
59
  if (!adapter || !endpointKey || !groupId) {
60
60
  return { error: 'adapter, endpointKey, and groupId are required' };
61
61
  }
62
- if (spec.requireUser && !userId) {
62
+ if (!userId) {
63
63
  return { error: 'userId is required' };
64
64
  }
65
65
  return withLiveEndpoint(ctx, adapter, endpointKey, async (management) => {
@@ -69,13 +69,19 @@ export async function groupWriteOp(d, ctx, spec) {
69
69
  }
70
70
  const durationRaw = d.duration;
71
71
  const enableRaw = d.enable;
72
- const args = spec.buildArgs(groupId, userId, {
73
- duration: typeof durationRaw === 'number' && Number.isFinite(durationRaw)
74
- ? durationRaw
75
- : undefined,
76
- enable: typeof enableRaw === 'boolean' ? enableRaw : undefined,
77
- });
78
- await method.call(management, ...args);
72
+ // The discriminant selects one typed semantic request; no argument arrays or casts.
73
+ switch (spec.method) {
74
+ case 'kickGroupMember':
75
+ await management.kickGroupMember({ groupId, userId });
76
+ break;
77
+ case 'muteGroupMember':
78
+ await management.muteGroupMember({ groupId, userId,
79
+ durationSeconds: typeof durationRaw === 'number' && Number.isFinite(durationRaw) ? durationRaw : 600 });
80
+ break;
81
+ case 'setGroupAdmin':
82
+ await management.setGroupAdmin({ groupId, userId, enabled: enableRaw !== false });
83
+ break;
84
+ }
79
85
  return { data: { success: true } };
80
86
  });
81
87
  }
@@ -91,7 +97,7 @@ export async function deleteFriend(d, ctx) {
91
97
  if (!method) {
92
98
  return { error: '当前适配器暂不支持删除好友' };
93
99
  }
94
- await method(userId);
100
+ await method.call(management, { userId });
95
101
  return { data: { success: true } };
96
102
  });
97
103
  }
@@ -322,7 +322,10 @@ export async function actOnRequest(type, d, ctx) {
322
322
  const extra = approve
323
323
  ? strField(d, 'remark')
324
324
  : strField(d, 'reason');
325
- await method.call(management, requestId, extra || undefined);
325
+ if (approve)
326
+ await management.approveRequest({ requestId, remark: extra || undefined });
327
+ else
328
+ await management.rejectRequest({ requestId, reason: extra || undefined });
326
329
  const model = getInboxModel(ctx, TABLE_REQUEST);
327
330
  if (model && typeof model.update === 'function') {
328
331
  await model.update({ resolved: 1, resolved_at: Date.now() }).where({
@@ -7,6 +7,7 @@ export function listLoginPending(ctx) {
7
7
  id: task.id,
8
8
  adapter: task.adapter,
9
9
  endpointKey: task.endpointKey,
10
+ ...(task.endpointIdentity ? { endpointIdentity: task.endpointIdentity } : {}),
10
11
  type: task.type,
11
12
  payload: task.payload,
12
13
  createdAt: task.createdAt,
@@ -25,7 +26,10 @@ export function submitLogin(d, ctx) {
25
26
  const value = typeof raw === 'string' || (raw && typeof raw === 'object')
26
27
  ? raw
27
28
  : String(raw ?? '');
28
- const ok = assist.submit(id, value);
29
+ const identity = readEndpointIdentity(d.endpointIdentity);
30
+ if (identity === false)
31
+ return { error: 'Invalid login Endpoint identity' };
32
+ const ok = identity ? assist.submit(id, value, identity) : assist.submit(id, value);
29
33
  if (!ok)
30
34
  return { error: `login task not found: ${id}` };
31
35
  return { data: { success: true } };
@@ -38,9 +42,23 @@ export function cancelLogin(d, ctx) {
38
42
  if (!id)
39
43
  return { error: 'taskId is required' };
40
44
  const reason = strField(d, 'reason') || 'cancelled';
41
- const ok = assist.cancel(id, reason);
45
+ const identity = readEndpointIdentity(d.endpointIdentity);
46
+ if (identity === false)
47
+ return { error: 'Invalid login Endpoint identity' };
48
+ const ok = identity ? assist.cancel(id, reason, identity) : assist.cancel(id, reason);
42
49
  if (!ok)
43
50
  return { error: `login task not found: ${id}` };
44
51
  return { data: { success: true } };
45
52
  }
46
53
  // ---------------------------------------------------------------- 社交读取
54
+ function readEndpointIdentity(value) {
55
+ if (value === undefined)
56
+ return undefined;
57
+ if (!value || typeof value !== 'object' || Array.isArray(value))
58
+ return false;
59
+ const identity = value;
60
+ if (Object.keys(identity).length !== 4 || typeof identity.canonicalId !== 'string' || !identity.canonicalId
61
+ || typeof identity.instanceId !== 'string' || !identity.instanceId || typeof identity.incarnationId !== 'string' || !identity.incarnationId || !Number.isSafeInteger(identity.epoch) || Number(identity.epoch) < 1)
62
+ return false;
63
+ return { canonicalId: identity.canonicalId, instanceId: identity.instanceId, epoch: Number(identity.epoch), incarnationId: identity.incarnationId };
64
+ }
@@ -838,6 +838,12 @@ function errorReply(payloads, requestId, error) {
838
838
  export function isDemoHttpAllowed(method, pathname, apiBase) {
839
839
  const normalizedBase = apiBase.replace(/\/$/u, '') || '/api';
840
840
  const m = method.toUpperCase();
841
+ // These handlers require an authenticated principal and verify its bound personal owner.
842
+ if ((m === 'GET' || m === 'PUT') && (pathname === `${normalizedBase}/agent/personal-models`
843
+ || pathname === `${normalizedBase}/agent/personal-session`))
844
+ return true;
845
+ if (m === 'GET' && pathname === `${normalizedBase}/agent/personal-sessions`)
846
+ return true;
841
847
  if (m === 'GET' && pathname === `${normalizedBase}/events`)
842
848
  return true;
843
849
  if (m === 'GET' && pathname === `${normalizedBase}/events/history`)
package/lib/http-host.js CHANGED
@@ -112,26 +112,30 @@ export function createHttpHost(options = {}) {
112
112
  try {
113
113
  wss.handleUpgrade(request, socket, head, (ws) => {
114
114
  let settled = false;
115
- const retireSubscriptions = [];
115
+ const stopSubscriptions = [];
116
116
  const releaseAdmissions = () => {
117
117
  if (settled)
118
118
  return;
119
119
  settled = true;
120
- for (const dispose of retireSubscriptions)
121
- dispose();
120
+ for (const unsubscribe of stopSubscriptions)
121
+ unsubscribe();
122
122
  for (const item of admitted)
123
123
  item.release?.();
124
124
  };
125
125
  ws.once('close', releaseAdmissions);
126
126
  ws.once('error', releaseAdmissions);
127
- for (const item of admitted) {
128
- if (item.entry.admission) {
129
- retireSubscriptions.push(item.entry.admission.onDeactivate(() => {
130
- if (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING) {
131
- ws.close(1012, 'Generation retired');
132
- }
133
- }));
134
- }
127
+ // Replacement closes new upgrades, not accepted plugin work. Explicit Root stop
128
+ // still closes its sockets before waiting for their admission leases to drain.
129
+ const stops = new Set(admitted.map(item => item.entry.admission?.stopSignal).filter(signal => signal !== undefined));
130
+ for (const signal of stops) {
131
+ const stop = () => {
132
+ if (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING)
133
+ ws.close(1012, 'Root stopped');
134
+ };
135
+ signal.addEventListener('abort', stop, { once: true });
136
+ stopSubscriptions.push(() => signal.removeEventListener('abort', stop));
137
+ if (signal.aborted)
138
+ stop();
135
139
  }
136
140
  const connection = Object.freeze({
137
141
  socket: ws,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zhin.js/host-http",
3
- "version": "1.1.10",
3
+ "version": "1.1.12",
4
4
  "description": "Minimal HTTP and WebSocket host for Plugin Runtime (no Koa)",
5
5
  "type": "module",
6
6
  "main": "./lib/index.js",
@@ -21,7 +21,7 @@
21
21
  "@zhin.js/host-http-contract": "1.1.1",
22
22
  "@zhin.js/logger": "1.1.2",
23
23
  "@zhin.js/schedule": "1.1.3",
24
- "@zhin.js/plugin-runtime": "1.1.17"
24
+ "@zhin.js/plugin-runtime": "1.1.18"
25
25
  },
26
26
  "devDependencies": {
27
27
  "@types/node": "^26.1.2",
@@ -71,6 +71,7 @@ export interface ConsoleAgentSessionTree {
71
71
  resolveActiveSessionId(sessionKey: string): Promise<string | null>;
72
72
  getActiveLeafMessageId(sessionId: string): Promise<number | null>;
73
73
  listBranchPoints(sessionId: string): Promise<readonly unknown[]>;
74
+ readBranchMessage?(sessionId: string, messageId: number): Promise<string | null>;
74
75
  switchActiveLeaf(sessionId: string, messageId: number): Promise<boolean>;
75
76
  jumpToBranchIndex(
76
77
  sessionId: string,
@@ -872,13 +873,21 @@ function registerAgentSessionRoutes(
872
873
  });
873
874
  };
874
875
 
875
- // HttpHost 无 `:param` 路由,用前缀路由匹配 `<sessionKey>/tree|leaf`。
876
+ // HttpHost 无 `:param` 路由,用前缀路由匹配 `<sessionKey>/tree|message|leaf`。
876
877
  route('GET', `${base}/agent/sessions/*`, async (_request, response, url) => {
877
- const parsed = parseSessionAction(url.pathname, prefix, 'tree');
878
+ const messageAction = parseSessionAction(url.pathname, prefix, 'message');
879
+ if (messageAction) response.setHeader('Cache-Control', 'no-store');
880
+ const parsed = messageAction ?? parseSessionAction(url.pathname, prefix, 'tree');
878
881
  if (!parsed) {
879
882
  writeJson(response, 404, { success: false, error: 'Not found' });
880
883
  return;
881
884
  }
885
+ const messageId = Number(url.searchParams.get('messageId'));
886
+ const expectedSessionId = url.searchParams.get('sessionId');
887
+ if (messageAction && (!Number.isSafeInteger(messageId) || messageId < 1 || !expectedSessionId)) {
888
+ writeJson(response, 400, { success: false, error: '需要 sessionId 和正安全整数 messageId' });
889
+ return;
890
+ }
882
891
  const lease = acquireSessionTree();
883
892
  if (!lease) {
884
893
  unavailable(response);
@@ -886,7 +895,28 @@ function registerAgentSessionRoutes(
886
895
  }
887
896
  const runtime = lease.tree;
888
897
  try {
898
+ if (messageAction && !runtime.readBranchMessage) {
899
+ writeJson(response, 501, { success: false, error: '当前 Agent runtime 不支持读取完整消息' });
900
+ return;
901
+ }
889
902
  const sessionId = await runtime.resolveActiveSessionId(parsed.sessionKey);
903
+ if (messageAction) {
904
+ if (!sessionId) {
905
+ writeJson(response, 404, { success: false, error: '未找到活跃会话' });
906
+ return;
907
+ }
908
+ if (sessionId !== expectedSessionId) {
909
+ writeJson(response, 409, { success: false, error: '会话已变化,请重新读取会话列表' });
910
+ return;
911
+ }
912
+ const text = await runtime.readBranchMessage!(sessionId, messageId);
913
+ if (text === null) {
914
+ writeJson(response, 404, { success: false, error: '未找到此会话的用户消息' });
915
+ return;
916
+ }
917
+ writeJson(response, 200, { success: true, data: { messageId, text } });
918
+ return;
919
+ }
890
920
  if (!sessionId) {
891
921
  if (!isRuntimeImSessionKey(parsed.sessionKey)) {
892
922
  writeJson(response, 404, {
@@ -934,7 +964,7 @@ function registerAgentSessionRoutes(
934
964
  } finally {
935
965
  lease.release();
936
966
  }
937
- }, { summary: 'Get agent session tree', tags: ['console', 'agent'] });
967
+ }, { summary: 'Get agent session tree or message', tags: ['console', 'agent'] });
938
968
 
939
969
  route('POST', `${base}/agent/sessions/*`, async (request, response, url, authScope) => {
940
970
  const parsed = parseSessionAction(url.pathname, prefix, 'leaf');
@@ -1038,7 +1068,7 @@ function isRuntimeImSessionKey(value: string): boolean {
1038
1068
  function parseSessionAction(
1039
1069
  pathname: string,
1040
1070
  prefix: string,
1041
- action: 'tree' | 'leaf',
1071
+ action: 'tree' | 'message' | 'leaf',
1042
1072
  ): { sessionKey: string } | null {
1043
1073
  if (!pathname.startsWith(prefix)) return null;
1044
1074
  const rest = pathname.slice(prefix.length).replace(/\/+$/u, '');
@@ -1,3 +1,10 @@
1
+ interface LoginEndpointIdentity {
2
+ readonly canonicalId: string;
3
+ readonly instanceId: string;
4
+ readonly epoch: number;
5
+ readonly incarnationId: string;
6
+ }
7
+
1
8
  export interface ConsoleRpcExtendedCtx {
2
9
  /** full scope 才允许写操作;demo scope 只放行只读 RPC。 */
3
10
  fullScope: boolean;
@@ -20,13 +27,14 @@ export interface ConsoleRpcExtendedCtx {
20
27
  id: string;
21
28
  adapter: string;
22
29
  endpointKey: string;
30
+ endpointIdentity?: LoginEndpointIdentity;
23
31
  type: string;
24
32
  payload: Record<string, unknown>;
25
33
  createdAt: number;
26
34
  expiresAt?: number;
27
35
  }[];
28
- submit(id: string, value: string | Record<string, unknown>): boolean;
29
- cancel(id: string, reason?: string): boolean;
36
+ submit(id: string, value: string | Record<string, unknown>, endpointIdentity?: LoginEndpointIdentity): boolean;
37
+ cancel(id: string, reason?: string, endpointIdentity?: LoginEndpointIdentity): boolean;
30
38
  };
31
39
  /** Authenticated Root adapter; mutation DTOs deliberately contain no principal/decision fields. */
32
40
  workroomProfileControl?: ConsoleWorkroomProfileControlPort;
@@ -126,6 +134,15 @@ export interface InboxModel {
126
134
  };
127
135
  }
128
136
 
137
+ /** HTTP's narrow structural seam; implementations keep platform objects and credentials private. */
138
+ export interface EndpointGroupCommand { readonly groupId: string; readonly signal?: AbortSignal }
139
+ export interface EndpointUserCommand { readonly userId: string; readonly signal?: AbortSignal }
140
+ export interface EndpointMemberCommand extends EndpointGroupCommand, EndpointUserCommand {}
141
+ export interface EndpointMuteCommand extends EndpointMemberCommand { readonly durationSeconds: number }
142
+ export interface EndpointAdminCommand extends EndpointMemberCommand { readonly enabled: boolean }
143
+ export interface EndpointApproveCommand { readonly requestId: string; readonly remark?: string; readonly signal?: AbortSignal }
144
+ export interface EndpointRejectCommand { readonly requestId: string; readonly reason?: string; readonly signal?: AbortSignal }
145
+
129
146
  export interface EndpointManagementPort {
130
147
  listFriends?(): Promise<readonly { user_id: number | string; nickname: string; remark: string }[]>;
131
148
  listGroups?(): Promise<readonly { group_id: number | string; name: string }[]>;
@@ -134,7 +151,7 @@ export interface EndpointManagementPort {
134
151
  name?: string;
135
152
  parent?: { type: string; id: string; name?: string };
136
153
  }[]>;
137
- listGroupMembers?(groupId: string): Promise<readonly unknown[]>;
154
+ listGroupMembers?(request: EndpointGroupCommand): Promise<readonly unknown[]>;
138
155
  listRequests?(): Promise<readonly {
139
156
  platform_request_id: string;
140
157
  type: string;
@@ -146,10 +163,10 @@ export interface EndpointManagementPort {
146
163
  comment?: string | null;
147
164
  created_at: number;
148
165
  }[]>;
149
- approveRequest?(requestId: string, remark?: string): Promise<void>;
150
- rejectRequest?(requestId: string, reason?: string): Promise<void>;
151
- kickGroupMember?(groupId: string, userId: string): Promise<void>;
152
- muteGroupMember?(groupId: string, userId: string, durationSeconds: number): Promise<void>;
153
- setGroupAdmin?(groupId: string, userId: string, enabled: boolean): Promise<void>;
154
- deleteFriend?(userId: string): Promise<void>;
166
+ approveRequest?(request: EndpointApproveCommand): Promise<void>;
167
+ rejectRequest?(request: EndpointRejectCommand): Promise<void>;
168
+ kickGroupMember?(request: EndpointMemberCommand): Promise<void>;
169
+ muteGroupMember?(request: EndpointMuteCommand): Promise<void>;
170
+ setGroupAdmin?(request: EndpointAdminCommand): Promise<void>;
171
+ deleteFriend?(request: EndpointUserCommand): Promise<void>;
155
172
  }
@@ -77,22 +77,16 @@ export async function dispatchExtendedConsoleRpc(
77
77
  case 'endpoint.group_kick':
78
78
  return groupWriteOp(d, ctx, {
79
79
  method: 'kickGroupMember',
80
- buildArgs: (gid, uid) => [gid, uid],
81
- requireUser: true,
82
80
  unsupported: '踢出群成员',
83
81
  });
84
82
  case 'endpoint.group_mute':
85
83
  return groupWriteOp(d, ctx, {
86
84
  method: 'muteGroupMember',
87
- buildArgs: (gid, uid, extra) => [gid, uid, extra.duration ?? 600],
88
- requireUser: true,
89
85
  unsupported: '禁言群成员',
90
86
  });
91
87
  case 'endpoint.group_admin':
92
88
  return groupWriteOp(d, ctx, {
93
89
  method: 'setGroupAdmin',
94
- buildArgs: (gid, uid, extra) => [gid, uid, extra.enable !== false],
95
- requireUser: true,
96
90
  unsupported: '设置群管理员',
97
91
  });
98
92
  case 'endpoint.delete_friend':
@@ -61,7 +61,7 @@ export async function listGroupMembers(
61
61
  if (!method) {
62
62
  return { error: `当前适配器(${adapter})不支持群成员列表` };
63
63
  }
64
- const members = [...await method(groupId)];
64
+ const members = [...await method.call(management, { groupId })];
65
65
  return { data: { members, count: members.length } };
66
66
  });
67
67
  }
@@ -70,8 +70,6 @@ export async function listGroupMembers(
70
70
 
71
71
  interface GroupWriteSpec {
72
72
  method: 'kickGroupMember' | 'muteGroupMember' | 'setGroupAdmin';
73
- buildArgs(groupId: string, userId: string, extra: { duration?: number; enable?: boolean }): unknown[];
74
- requireUser: boolean;
75
73
  /** 中文操作名,用于"该平台不支持 xxx"。 */
76
74
  unsupported: string;
77
75
  }
@@ -88,7 +86,7 @@ export async function groupWriteOp(
88
86
  if (!adapter || !endpointKey || !groupId) {
89
87
  return { error: 'adapter, endpointKey, and groupId are required' };
90
88
  }
91
- if (spec.requireUser && !userId) {
89
+ if (!userId) {
92
90
  return { error: 'userId is required' };
93
91
  }
94
92
  return withLiveEndpoint(ctx, adapter, endpointKey, async (management) => {
@@ -98,13 +96,19 @@ export async function groupWriteOp(
98
96
  }
99
97
  const durationRaw = d.duration;
100
98
  const enableRaw = d.enable;
101
- const args = spec.buildArgs(groupId, userId, {
102
- duration: typeof durationRaw === 'number' && Number.isFinite(durationRaw)
103
- ? durationRaw
104
- : undefined,
105
- enable: typeof enableRaw === 'boolean' ? enableRaw : undefined,
106
- });
107
- await (method as (...args: unknown[]) => Promise<void>).call(management, ...args);
99
+ // The discriminant selects one typed semantic request; no argument arrays or casts.
100
+ switch (spec.method) {
101
+ case 'kickGroupMember':
102
+ await management.kickGroupMember!({ groupId, userId });
103
+ break;
104
+ case 'muteGroupMember':
105
+ await management.muteGroupMember!({ groupId, userId,
106
+ durationSeconds: typeof durationRaw === 'number' && Number.isFinite(durationRaw) ? durationRaw : 600 });
107
+ break;
108
+ case 'setGroupAdmin':
109
+ await management.setGroupAdmin!({ groupId, userId, enabled: enableRaw !== false });
110
+ break;
111
+ }
108
112
  return { data: { success: true } };
109
113
  });
110
114
  }
@@ -124,7 +128,7 @@ export async function deleteFriend(
124
128
  if (!method) {
125
129
  return { error: '当前适配器暂不支持删除好友' };
126
130
  }
127
- await method(userId);
131
+ await method.call(management, { userId });
128
132
  return { data: { success: true } };
129
133
  });
130
134
  }
@@ -364,7 +364,8 @@ export async function actOnRequest(
364
364
  const extra = approve
365
365
  ? strField(d, 'remark')
366
366
  : strField(d, 'reason');
367
- await method.call(management, requestId, extra || undefined);
367
+ if (approve) await management.approveRequest!({ requestId, remark: extra || undefined });
368
+ else await management.rejectRequest!({ requestId, reason: extra || undefined });
368
369
  const model = getInboxModel(ctx, TABLE_REQUEST);
369
370
  if (model && typeof model.update === 'function') {
370
371
  await model.update({resolved: 1, resolved_at: Date.now()}).where({
@@ -8,6 +8,7 @@ export function listLoginPending(ctx: ConsoleRpcExtendedCtx): ExtendedRpcResult
8
8
  id: task.id,
9
9
  adapter: task.adapter,
10
10
  endpointKey: task.endpointKey,
11
+ ...(task.endpointIdentity ? { endpointIdentity: task.endpointIdentity } : {}),
11
12
  type: task.type,
12
13
  payload: task.payload,
13
14
  createdAt: task.createdAt,
@@ -28,7 +29,9 @@ export function submitLogin(
28
29
  const value = typeof raw === 'string' || (raw && typeof raw === 'object')
29
30
  ? raw as string | Record<string, unknown>
30
31
  : String(raw ?? '');
31
- const ok = assist.submit(id, value);
32
+ const identity = readEndpointIdentity(d.endpointIdentity);
33
+ if (identity === false) return { error: 'Invalid login Endpoint identity' };
34
+ const ok = identity ? assist.submit(id, value, identity) : assist.submit(id, value);
32
35
  if (!ok) return { error: `login task not found: ${id}` };
33
36
  return { data: { success: true } };
34
37
  }
@@ -42,9 +45,20 @@ export function cancelLogin(
42
45
  const id = strField(d, 'taskId');
43
46
  if (!id) return { error: 'taskId is required' };
44
47
  const reason = strField(d, 'reason') || 'cancelled';
45
- const ok = assist.cancel(id, reason);
48
+ const identity = readEndpointIdentity(d.endpointIdentity);
49
+ if (identity === false) return { error: 'Invalid login Endpoint identity' };
50
+ const ok = identity ? assist.cancel(id, reason, identity) : assist.cancel(id, reason);
46
51
  if (!ok) return { error: `login task not found: ${id}` };
47
52
  return { data: { success: true } };
48
53
  }
49
54
 
50
55
  // ---------------------------------------------------------------- 社交读取
56
+
57
+ function readEndpointIdentity(value: unknown): { readonly canonicalId: string; readonly instanceId: string; readonly epoch: number; readonly incarnationId: string } | false | undefined {
58
+ if (value === undefined) return undefined;
59
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return false;
60
+ const identity = value as Record<string, unknown>;
61
+ if (Object.keys(identity).length !== 4 || typeof identity.canonicalId !== 'string' || !identity.canonicalId
62
+ || typeof identity.instanceId !== 'string' || !identity.instanceId || typeof identity.incarnationId !== 'string' || !identity.incarnationId || !Number.isSafeInteger(identity.epoch) || Number(identity.epoch) < 1) return false;
63
+ return { canonicalId: identity.canonicalId, instanceId: identity.instanceId, epoch: Number(identity.epoch), incarnationId: identity.incarnationId };
64
+ }
@@ -1056,6 +1056,10 @@ function errorReply(
1056
1056
  export function isDemoHttpAllowed(method: string, pathname: string, apiBase: string): boolean {
1057
1057
  const normalizedBase = apiBase.replace(/\/$/u, '') || '/api';
1058
1058
  const m = method.toUpperCase();
1059
+ // These handlers require an authenticated principal and verify its bound personal owner.
1060
+ if ((m === 'GET' || m === 'PUT') && (pathname === `${normalizedBase}/agent/personal-models`
1061
+ || pathname === `${normalizedBase}/agent/personal-session`)) return true;
1062
+ if (m === 'GET' && pathname === `${normalizedBase}/agent/personal-sessions`) return true;
1059
1063
  if (m === 'GET' && pathname === `${normalizedBase}/events`) return true;
1060
1064
  if (m === 'GET' && pathname === `${normalizedBase}/events/history`) return true;
1061
1065
  if (m === 'GET' && DEMO_INTROSPECTION_PATHS.has(pathname.slice(normalizedBase.length))) return true;
package/src/http-host.ts CHANGED
@@ -256,23 +256,25 @@ export function createHttpHost(options: HttpHostOptions = {}): ProcessHttpHost {
256
256
  try {
257
257
  wss.handleUpgrade(request, socket, head, (ws) => {
258
258
  let settled = false;
259
- const retireSubscriptions: Array<() => void> = [];
259
+ const stopSubscriptions: Array<() => void> = [];
260
260
  const releaseAdmissions = () => {
261
261
  if (settled) return;
262
262
  settled = true;
263
- for (const dispose of retireSubscriptions) dispose();
263
+ for (const unsubscribe of stopSubscriptions) unsubscribe();
264
264
  for (const item of admitted) item.release?.();
265
265
  };
266
266
  ws.once('close', releaseAdmissions);
267
267
  ws.once('error', releaseAdmissions);
268
- for (const item of admitted) {
269
- if (item.entry.admission) {
270
- retireSubscriptions.push(item.entry.admission.onDeactivate(() => {
271
- if (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING) {
272
- ws.close(1012, 'Generation retired');
273
- }
274
- }));
275
- }
268
+ // Replacement closes new upgrades, not accepted plugin work. Explicit Root stop
269
+ // still closes its sockets before waiting for their admission leases to drain.
270
+ const stops = new Set(admitted.map(item => item.entry.admission?.stopSignal).filter(signal => signal !== undefined));
271
+ for (const signal of stops) {
272
+ const stop = () => {
273
+ if (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING) ws.close(1012, 'Root stopped');
274
+ };
275
+ signal.addEventListener('abort', stop, { once: true });
276
+ stopSubscriptions.push(() => signal.removeEventListener('abort', stop));
277
+ if (signal.aborted) stop();
276
278
  }
277
279
  const connection = Object.freeze({
278
280
  socket: ws,