ec-web 0.0.20 → 0.0.22
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/dist/ipc-client.js +7 -1
- package/dist/paths.js +18 -1
- package/dist/server.js +159 -28
- package/dist/sources/aid.js +69 -49
- package/dist/sources/baseagent-detector.js +60 -6
- package/dist/sources/basic-settings.js +818 -0
- package/dist/sources/gateway.js +5 -4
- package/dist/sources/monitor.js +22 -5
- package/dist/sources/msg.js +4 -96
- package/dist/sources/relation-roles.js +216 -207
- package/dist/sources/role-definitions.js +162 -331
- package/dist/sources/session-codex.js +207 -84
- package/dist/sources/system.js +10 -9
- package/dist/sources/triggers.js +82 -13
- package/dist/sources/types.js +20 -1
- package/dist/static/app.js +175 -15
- package/dist/static/basic-settings.js +1576 -0
- package/dist/static/index.html +43 -1
- package/dist/static/style.css +586 -2
- package/ecweb-wrapper.cjs +5 -1
- package/package.json +1 -1
|
@@ -1,26 +1,16 @@
|
|
|
1
1
|
import fs from 'fs';
|
|
2
2
|
import path from 'path';
|
|
3
3
|
import { resolvePaths } from '../paths.js';
|
|
4
|
-
import { getSessionsAunDir,
|
|
4
|
+
import { getSessionsAunDir, isTransientProtocolMessage, readJsonFile, readAllJsonlLines, scanChatDirs, } from '../fs-utils.js';
|
|
5
5
|
import { resolveParentDistModule, toFileUrl } from './parent-package.js';
|
|
6
|
-
import
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
throw new Error(response?.error || 'Role Menu is unavailable');
|
|
15
|
-
return response.response;
|
|
16
|
-
}
|
|
17
|
-
function sendRoleMenuError(res, response) {
|
|
18
|
-
const error = response?.error ?? { code: 'INTERNAL_ERROR', message: 'Role Menu request failed' };
|
|
19
|
-
const status = error.code === 'NOT_FOUND' ? 404
|
|
20
|
-
: error.code === 'CONFLICT' ? 409
|
|
21
|
-
: error.code === 'PERMISSION_DENIED' || error.code === 'NOT_ALLOWED' ? 403
|
|
22
|
-
: error.code === 'TEMPORARILY_UNAVAILABLE' ? 503 : 400;
|
|
23
|
-
sendJson(res, status, { error: error.message, code: error.code, data: error.data });
|
|
6
|
+
import crypto from 'node:crypto';
|
|
7
|
+
function ecwebAuditContext(auth, aid, operation) {
|
|
8
|
+
return {
|
|
9
|
+
ecwebRequestId: auth.ecwebRequestId ?? crypto.randomUUID(),
|
|
10
|
+
targetAgentAid: aid,
|
|
11
|
+
endpoint: auth.endpoint ?? 'ECWeb relation roles',
|
|
12
|
+
logicalToolName: operation,
|
|
13
|
+
};
|
|
24
14
|
}
|
|
25
15
|
async function getParentModules() {
|
|
26
16
|
const configPath = resolveParentDistModule('config', 'config-manager.js');
|
|
@@ -32,8 +22,8 @@ async function getParentModules() {
|
|
|
32
22
|
return { config, roles, resolver };
|
|
33
23
|
}
|
|
34
24
|
async function getCommandModules() {
|
|
35
|
-
const permissionPath = resolveParentDistModule('core', '
|
|
36
|
-
const auditPath = resolveParentDistModule('core', '
|
|
25
|
+
const permissionPath = resolveParentDistModule('core', 'auth', 'operation-authorizer.js');
|
|
26
|
+
const auditPath = resolveParentDistModule('core', 'auth', 'authorization-audit.js');
|
|
37
27
|
const permission = await import(toFileUrl(permissionPath));
|
|
38
28
|
const audit = await import(toFileUrl(auditPath));
|
|
39
29
|
return { permission, audit };
|
|
@@ -77,9 +67,15 @@ async function getAgentsFromIpc() {
|
|
|
77
67
|
return [];
|
|
78
68
|
}
|
|
79
69
|
}
|
|
80
|
-
|
|
81
|
-
const
|
|
82
|
-
|
|
70
|
+
function resolveEcwebActor(modules, aid, auth) {
|
|
71
|
+
const processConfig = modules.config?.read && modules.config?.ConfigTarget
|
|
72
|
+
? modules.config.read(modules.config.ConfigTarget.Process)
|
|
73
|
+
: undefined;
|
|
74
|
+
const processOwners = Array.isArray(processConfig?.owners)
|
|
75
|
+
? processConfig.owners.map((value) => String(value || '').trim()).filter(Boolean)
|
|
76
|
+
: [];
|
|
77
|
+
const daemonOwner = !!auth.localDirect || (!!auth.actorAid && processOwners.includes(auth.actorAid));
|
|
78
|
+
const actorRole = daemonOwner
|
|
83
79
|
? 'owner'
|
|
84
80
|
: auth.actorAid
|
|
85
81
|
? (modules.resolver.resolvePeerRoleDetail({
|
|
@@ -90,6 +86,62 @@ async function authorizeRoleAssignmentWrite(modules, aid, auth, operation, targe
|
|
|
90
86
|
conversationId: auth.actorAid,
|
|
91
87
|
}).effectiveRole || 'none')
|
|
92
88
|
: 'none';
|
|
89
|
+
return {
|
|
90
|
+
daemonOwner,
|
|
91
|
+
actorRole,
|
|
92
|
+
actorId: auth.localDirect ? 'local-direct' : auth.actorAid || undefined,
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
async function authorizeRoleAssignmentRead(modules, aid, auth) {
|
|
96
|
+
const { permission, audit } = await getCommandModules();
|
|
97
|
+
const actor = resolveEcwebActor(modules, aid, auth);
|
|
98
|
+
const decision = permission.authorizeCommand({
|
|
99
|
+
intent: {
|
|
100
|
+
operation: 'role.relation.read',
|
|
101
|
+
scope: 'agent',
|
|
102
|
+
source: 'ecweb',
|
|
103
|
+
args: { self: aid },
|
|
104
|
+
},
|
|
105
|
+
actorId: actor.actorId,
|
|
106
|
+
selfAid: aid,
|
|
107
|
+
role: actor.actorRole,
|
|
108
|
+
processRole: actor.daemonOwner ? 'daemon-owner' : 'none',
|
|
109
|
+
isDaemonOwner: actor.daemonOwner,
|
|
110
|
+
source: 'ecweb',
|
|
111
|
+
});
|
|
112
|
+
await audit.auditCommandAuthorization({
|
|
113
|
+
ts: Date.now(),
|
|
114
|
+
source: 'ecweb',
|
|
115
|
+
operation: 'role.relation.read',
|
|
116
|
+
scope: 'agent',
|
|
117
|
+
dangerous: false,
|
|
118
|
+
actorId: actor.actorId,
|
|
119
|
+
selfAid: aid,
|
|
120
|
+
role: actor.actorRole,
|
|
121
|
+
processRole: actor.daemonOwner ? 'daemon-owner' : 'none',
|
|
122
|
+
isDaemonOwner: actor.daemonOwner,
|
|
123
|
+
decision: decision.allow ? 'allow' : 'deny',
|
|
124
|
+
code: decision.allow ? undefined : decision.code,
|
|
125
|
+
reason: decision.allow ? undefined : decision.reason,
|
|
126
|
+
matchedRule: decision.matchedRule,
|
|
127
|
+
argsSummary: { self: aid },
|
|
128
|
+
...ecwebAuditContext(auth, aid, 'role.relation.read'),
|
|
129
|
+
});
|
|
130
|
+
if (decision.allow)
|
|
131
|
+
return { allow: true, actorRole: actor.actorRole };
|
|
132
|
+
return {
|
|
133
|
+
allow: false,
|
|
134
|
+
status: 403,
|
|
135
|
+
error: 'forbidden: role assignment read not allowed',
|
|
136
|
+
code: decision.code,
|
|
137
|
+
reason: decision.reason,
|
|
138
|
+
actorRole: actor.actorRole,
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
async function authorizeRoleAssignmentWrite(modules, aid, auth, operation, targetRole, args) {
|
|
142
|
+
const { permission, audit } = await getCommandModules();
|
|
143
|
+
const actor = resolveEcwebActor(modules, aid, auth);
|
|
144
|
+
const { daemonOwner, actorRole } = actor;
|
|
93
145
|
const targetPeerId = typeof args.peerId === 'string' ? args.peerId : undefined;
|
|
94
146
|
if (targetPeerId && modules.resolver.isStaticAgentOwner?.(aid, targetPeerId)) {
|
|
95
147
|
const reason = `Static owner ${targetPeerId} is defined in agents/${aid}/config.json and cannot be changed by relation role assignments`;
|
|
@@ -99,13 +151,16 @@ async function authorizeRoleAssignmentWrite(modules, aid, auth, operation, targe
|
|
|
99
151
|
operation,
|
|
100
152
|
scope: 'agent',
|
|
101
153
|
dangerous: false,
|
|
102
|
-
actorId:
|
|
154
|
+
actorId: actor.actorId,
|
|
103
155
|
selfAid: aid,
|
|
104
156
|
role: actorRole,
|
|
157
|
+
processRole: daemonOwner ? 'daemon-owner' : 'none',
|
|
158
|
+
isDaemonOwner: daemonOwner,
|
|
105
159
|
decision: 'deny',
|
|
106
160
|
code: 'IMMUTABLE_STATIC_OWNER',
|
|
107
161
|
reason,
|
|
108
162
|
argsSummary: { ...args, targetRole },
|
|
163
|
+
...ecwebAuditContext(auth, aid, operation),
|
|
109
164
|
});
|
|
110
165
|
return {
|
|
111
166
|
allow: false,
|
|
@@ -126,9 +181,11 @@ async function authorizeRoleAssignmentWrite(modules, aid, auth, operation, targe
|
|
|
126
181
|
targetRole,
|
|
127
182
|
},
|
|
128
183
|
},
|
|
129
|
-
actorId:
|
|
184
|
+
actorId: actor.actorId,
|
|
130
185
|
selfAid: aid,
|
|
131
186
|
role: actorRole,
|
|
187
|
+
processRole: daemonOwner ? 'daemon-owner' : 'none',
|
|
188
|
+
isDaemonOwner: daemonOwner,
|
|
132
189
|
source: 'ecweb',
|
|
133
190
|
});
|
|
134
191
|
if (!decision.allow) {
|
|
@@ -138,14 +195,17 @@ async function authorizeRoleAssignmentWrite(modules, aid, auth, operation, targe
|
|
|
138
195
|
operation,
|
|
139
196
|
scope: 'agent',
|
|
140
197
|
dangerous: false,
|
|
141
|
-
actorId:
|
|
198
|
+
actorId: actor.actorId,
|
|
142
199
|
selfAid: aid,
|
|
143
200
|
role: actorRole,
|
|
201
|
+
processRole: daemonOwner ? 'daemon-owner' : 'none',
|
|
202
|
+
isDaemonOwner: daemonOwner,
|
|
144
203
|
decision: 'deny',
|
|
145
204
|
code: decision.code,
|
|
146
205
|
reason: decision.reason,
|
|
147
206
|
matchedRule: decision.matchedRule,
|
|
148
207
|
argsSummary: { ...args, targetRole },
|
|
208
|
+
...ecwebAuditContext(auth, aid, operation),
|
|
149
209
|
});
|
|
150
210
|
return {
|
|
151
211
|
allow: false,
|
|
@@ -156,9 +216,9 @@ async function authorizeRoleAssignmentWrite(modules, aid, auth, operation, targe
|
|
|
156
216
|
actorRole,
|
|
157
217
|
};
|
|
158
218
|
}
|
|
159
|
-
const policyAllowed = operation === 'role.assign'
|
|
219
|
+
const policyAllowed = daemonOwner || (operation === 'role.assign'
|
|
160
220
|
? !!targetRole && canAssignRole(actorRole, targetRole)
|
|
161
|
-
: canRevokeRole(actorRole, targetRole);
|
|
221
|
+
: canRevokeRole(actorRole, targetRole));
|
|
162
222
|
if (!policyAllowed) {
|
|
163
223
|
const reason = operation === 'role.assign'
|
|
164
224
|
? `Role ${actorRole} cannot assign target role ${targetRole || '(missing)'}`
|
|
@@ -169,14 +229,17 @@ async function authorizeRoleAssignmentWrite(modules, aid, auth, operation, targe
|
|
|
169
229
|
operation,
|
|
170
230
|
scope: 'agent',
|
|
171
231
|
dangerous: false,
|
|
172
|
-
actorId:
|
|
232
|
+
actorId: actor.actorId,
|
|
173
233
|
selfAid: aid,
|
|
174
234
|
role: actorRole,
|
|
235
|
+
processRole: daemonOwner ? 'daemon-owner' : 'none',
|
|
236
|
+
isDaemonOwner: daemonOwner,
|
|
175
237
|
decision: 'deny',
|
|
176
238
|
code: 'NOT_ALLOWED',
|
|
177
239
|
reason,
|
|
178
240
|
matchedRule: decision.matchedRule,
|
|
179
241
|
argsSummary: { ...args, targetRole },
|
|
242
|
+
...ecwebAuditContext(auth, aid, operation),
|
|
180
243
|
});
|
|
181
244
|
return {
|
|
182
245
|
allow: false,
|
|
@@ -193,12 +256,15 @@ async function authorizeRoleAssignmentWrite(modules, aid, auth, operation, targe
|
|
|
193
256
|
operation,
|
|
194
257
|
scope: 'agent',
|
|
195
258
|
dangerous: false,
|
|
196
|
-
actorId:
|
|
259
|
+
actorId: actor.actorId,
|
|
197
260
|
selfAid: aid,
|
|
198
261
|
role: actorRole,
|
|
262
|
+
processRole: daemonOwner ? 'daemon-owner' : 'none',
|
|
263
|
+
isDaemonOwner: daemonOwner,
|
|
199
264
|
decision: 'allow',
|
|
200
265
|
matchedRule: decision.matchedRule,
|
|
201
266
|
argsSummary: { ...args, targetRole },
|
|
267
|
+
...ecwebAuditContext(auth, aid, operation),
|
|
202
268
|
});
|
|
203
269
|
return { allow: true, actorRole };
|
|
204
270
|
}
|
|
@@ -271,12 +337,6 @@ function relationTargetFromId(raw) {
|
|
|
271
337
|
}
|
|
272
338
|
return { peerKey: formatPeerKey('aun', value), channelId: value };
|
|
273
339
|
}
|
|
274
|
-
function readActive(aunDir, aid, conversationId) {
|
|
275
|
-
return readJsonFile(path.join(aunDir, encodeSegmentForRoleSource(aid), encodeSegmentForRoleSource(conversationId), 'active.json'));
|
|
276
|
-
}
|
|
277
|
-
function encodeSegmentForRoleSource(s) {
|
|
278
|
-
return s.replace(/[<>:"/\\|?*\x00-\x1F%]/g, ch => '%' + ch.charCodeAt(0).toString(16).toUpperCase().padStart(2, '0'));
|
|
279
|
-
}
|
|
280
340
|
function frontmatterValue(frontmatter, key) {
|
|
281
341
|
const re = new RegExp(`^${key}:\\s*(?:"([^"]*)"|'([^']*)'|([^\\r\\n#]*))\\s*(?:#.*)?$`, 'm');
|
|
282
342
|
const match = frontmatter.match(re);
|
|
@@ -328,6 +388,20 @@ function createLocalAgentMdLookup() {
|
|
|
328
388
|
return info;
|
|
329
389
|
};
|
|
330
390
|
}
|
|
391
|
+
function readPeerVerificationStatus(selfAid, peerId) {
|
|
392
|
+
if (!selfAid || !peerId)
|
|
393
|
+
return 'unknown';
|
|
394
|
+
const peerKey = formatPeerKey('aun', peerId);
|
|
395
|
+
const identity = readJsonFile(path.join(resolvePaths().root, 'agents', selfAid, 'relations', peerKey, 'peer-identity.json'));
|
|
396
|
+
const raw = String(identity?.source ?? identity?.verifyStatus ?? identity?.verify_status ?? '').trim().toLowerCase();
|
|
397
|
+
if (raw === 'agentmd' || raw === 'verified')
|
|
398
|
+
return 'agentmd';
|
|
399
|
+
if (raw === 'agentmd-unverified' || raw === 'unverified')
|
|
400
|
+
return 'agentmd-unverified';
|
|
401
|
+
if (raw === 'invalid')
|
|
402
|
+
return 'invalid';
|
|
403
|
+
return 'unknown';
|
|
404
|
+
}
|
|
331
405
|
function latestMessageAt(messages) {
|
|
332
406
|
let lastAt = 0;
|
|
333
407
|
for (const message of messages) {
|
|
@@ -366,7 +440,7 @@ function firstMessagePeerType(messages, peerId) {
|
|
|
366
440
|
function conversationSortName(seed) {
|
|
367
441
|
return (seed.name || seed.peerAid || seed.groupId || seed.conversationId).toLowerCase();
|
|
368
442
|
}
|
|
369
|
-
function groupMembersFromMessages(selfAid, messages, localAgentMdLookup) {
|
|
443
|
+
function groupMembersFromMessages(selfAid, messages, localAgentMdLookup, peerVerificationLookup) {
|
|
370
444
|
const members = new Map();
|
|
371
445
|
for (const message of messages) {
|
|
372
446
|
const sender = message.from;
|
|
@@ -396,6 +470,7 @@ function groupMembersFromMessages(selfAid, messages, localAgentMdLookup) {
|
|
|
396
470
|
const localInfo = localAgentMdLookup(member.peerId);
|
|
397
471
|
member.peerName = localInfo?.name ?? member.peerName;
|
|
398
472
|
member.peerType = localInfo?.peerType ?? member.peerType;
|
|
473
|
+
member.verifyStatus = peerVerificationLookup(selfAid, member.peerId);
|
|
399
474
|
}
|
|
400
475
|
return [...members.values()].sort((a, b) => {
|
|
401
476
|
const lastDiff = (b.lastAt ?? 0) - (a.lastAt ?? 0);
|
|
@@ -404,7 +479,7 @@ function groupMembersFromMessages(selfAid, messages, localAgentMdLookup) {
|
|
|
404
479
|
return (a.peerName || a.peerAid).localeCompare(b.peerName || b.peerAid);
|
|
405
480
|
});
|
|
406
481
|
}
|
|
407
|
-
export function buildConversationSeeds(aid, peerInfos, localAgentMdLookup = () => undefined) {
|
|
482
|
+
export function buildConversationSeeds(aid, peerInfos, localAgentMdLookup = () => undefined, peerVerificationLookup = readPeerVerificationStatus) {
|
|
408
483
|
const seeds = [];
|
|
409
484
|
for (const peer of peerInfos) {
|
|
410
485
|
const messages = peer.messages || [];
|
|
@@ -426,7 +501,7 @@ export function buildConversationSeeds(aid, peerInfos, localAgentMdLookup = () =
|
|
|
426
501
|
lastAt,
|
|
427
502
|
inbound: counts.inbound,
|
|
428
503
|
outbound: counts.outbound,
|
|
429
|
-
members: groupMembersFromMessages(aid, messages, localAgentMdLookup),
|
|
504
|
+
members: groupMembersFromMessages(aid, messages, localAgentMdLookup, peerVerificationLookup),
|
|
430
505
|
});
|
|
431
506
|
continue;
|
|
432
507
|
}
|
|
@@ -447,6 +522,7 @@ export function buildConversationSeeds(aid, peerInfos, localAgentMdLookup = () =
|
|
|
447
522
|
peerKey: peerId,
|
|
448
523
|
peerName,
|
|
449
524
|
peerType,
|
|
525
|
+
verifyStatus: peerVerificationLookup(aid, peerId),
|
|
450
526
|
lastAt,
|
|
451
527
|
inbound: counts.inbound,
|
|
452
528
|
outbound: counts.outbound,
|
|
@@ -463,13 +539,20 @@ export function buildConversationSeeds(aid, peerInfos, localAgentMdLookup = () =
|
|
|
463
539
|
}
|
|
464
540
|
function loadConversationSeeds(aid) {
|
|
465
541
|
const aunDir = getSessionsAunDir();
|
|
542
|
+
const peerInfoById = new Map();
|
|
543
|
+
for (const chat of scanChatDirs(aunDir).filter(item => item.channelType === 'aun' && item.selfAID === aid)) {
|
|
544
|
+
const current = peerInfoById.get(chat.channelId) ?? { conversationId: chat.channelId, messages: [] };
|
|
545
|
+
const active = readJsonFile(path.join(chat.dirPath, 'active.json'));
|
|
546
|
+
if (active && (!current.active || Number(active.updatedAt || 0) >= Number(current.active.updatedAt || 0))) {
|
|
547
|
+
current.active = active;
|
|
548
|
+
}
|
|
549
|
+
current.messages.push(...readAllJsonlLines(path.join(chat.dirPath, 'messages.jsonl'))
|
|
550
|
+
.filter(message => !isTransientProtocolMessage(message)));
|
|
551
|
+
peerInfoById.set(chat.channelId, current);
|
|
552
|
+
}
|
|
466
553
|
const localAgentMdLookup = createLocalAgentMdLookup();
|
|
467
|
-
const peerInfos =
|
|
468
|
-
|
|
469
|
-
active: readActive(aunDir, aid, conversationId),
|
|
470
|
-
messages: readMessages(aunDir, aid, conversationId),
|
|
471
|
-
}));
|
|
472
|
-
return buildConversationSeeds(aid, peerInfos, localAgentMdLookup);
|
|
554
|
+
const peerInfos = [...peerInfoById.values()];
|
|
555
|
+
return buildConversationSeeds(aid, peerInfos, localAgentMdLookup, readPeerVerificationStatus);
|
|
473
556
|
}
|
|
474
557
|
function readRelationConfig(config, aid, peerKey) {
|
|
475
558
|
return config.read(config.ConfigTarget.Relation, { self: aid, peerKey }, { cache: true }) || {};
|
|
@@ -492,8 +575,8 @@ function explicitAssignment(scope, peerKey, role, ids) {
|
|
|
492
575
|
function resolveGroupRoleDetail(modules, aid, groupId) {
|
|
493
576
|
const target = relationTargetFromId(groupId);
|
|
494
577
|
const relation = readRelationConfig(modules.config, aid, target.peerKey);
|
|
495
|
-
const assigned = typeof relation.
|
|
496
|
-
? relation.
|
|
578
|
+
const assigned = typeof relation.role === 'string' && roleExists(modules.roles, aid, relation.role)
|
|
579
|
+
? relation.role
|
|
497
580
|
: null;
|
|
498
581
|
if (assigned) {
|
|
499
582
|
return {
|
|
@@ -519,7 +602,7 @@ function assignmentForDetail(detail, modules, seed, member) {
|
|
|
519
602
|
if (member && detail.source === 'group-member') {
|
|
520
603
|
return explicitAssignment('group-member', groupTarget.peerKey, role, { groupId: groupTarget.channelId, peerId: member.peerId });
|
|
521
604
|
}
|
|
522
|
-
if (member && detail.source === 'private-inherited') {
|
|
605
|
+
if (member && (detail.source === 'private-inherited' || detail.source === 'relation-actor')) {
|
|
523
606
|
const privateTarget = relationTargetFromId(member.peerId);
|
|
524
607
|
return explicitAssignment('private', privateTarget.peerKey, role, { peerId: member.peerId });
|
|
525
608
|
}
|
|
@@ -584,38 +667,23 @@ function listRelationRoleAssignments(config, aid) {
|
|
|
584
667
|
continue;
|
|
585
668
|
const peerKey = entry.name;
|
|
586
669
|
const relation = readRelationConfig(config, aid, peerKey);
|
|
587
|
-
const roles = relation.roles;
|
|
588
|
-
if (!roles || typeof roles !== 'object')
|
|
589
|
-
continue;
|
|
590
670
|
const parsed = parsePeerKey(peerKey);
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
source: 'relation-config',
|
|
599
|
-
});
|
|
600
|
-
}
|
|
601
|
-
if (roles.members && typeof roles.members === 'object') {
|
|
602
|
-
for (const [peerId, role] of Object.entries(roles.members)) {
|
|
603
|
-
if (typeof role !== 'string')
|
|
604
|
-
continue;
|
|
605
|
-
out.push({
|
|
606
|
-
scope: 'group-member',
|
|
607
|
-
peerKey,
|
|
608
|
-
groupId: parsed.channelId,
|
|
609
|
-
peerId,
|
|
610
|
-
role,
|
|
611
|
-
source: 'relation-config',
|
|
612
|
-
});
|
|
613
|
-
}
|
|
614
|
-
}
|
|
671
|
+
if (typeof relation.role !== 'string')
|
|
672
|
+
continue;
|
|
673
|
+
const isGroup = relation.targetKind === 'group'
|
|
674
|
+
|| /^group\.agentid\.pub\//i.test(parsed.channelId);
|
|
675
|
+
out.push(isGroup
|
|
676
|
+
? { scope: 'group', groupId: parsed.channelId, peerKey, role: relation.role, source: 'relation-config' }
|
|
677
|
+
: { scope: 'private', peerId: parsed.channelId, peerKey, role: relation.role, source: 'relation-config' });
|
|
615
678
|
}
|
|
616
679
|
return out;
|
|
617
680
|
}
|
|
618
681
|
async function buildSnapshot(auth = {}) {
|
|
682
|
+
auth = {
|
|
683
|
+
...auth,
|
|
684
|
+
ecwebRequestId: auth.ecwebRequestId ?? crypto.randomUUID(),
|
|
685
|
+
endpoint: auth.endpoint ?? 'WS snapshot:roles',
|
|
686
|
+
};
|
|
619
687
|
const modules = await getParentModules();
|
|
620
688
|
const agents = await getAgentsFromIpc();
|
|
621
689
|
const snapshotAgents = [];
|
|
@@ -624,26 +692,17 @@ async function buildSnapshot(auth = {}) {
|
|
|
624
692
|
const aid = agent.aid;
|
|
625
693
|
if (!aid)
|
|
626
694
|
continue;
|
|
627
|
-
const
|
|
628
|
-
|
|
629
|
-
type: 'menu.options', id: `ecweb-relation-role-options-${Date.now()}`,
|
|
630
|
-
args: { option: 'roles' },
|
|
631
|
-
}, auth),
|
|
632
|
-
execRoleMenu(aid, {
|
|
633
|
-
type: 'menu.query', id: `ecweb-relation-role-defaults-${Date.now()}`,
|
|
634
|
-
args: { view: 'defaults' },
|
|
635
|
-
}, auth),
|
|
636
|
-
listAssignmentsThroughMenu(aid, auth),
|
|
637
|
-
]);
|
|
638
|
-
if (roleOptions.error || defaults.error || assignmentResult.error)
|
|
695
|
+
const readDecision = await authorizeRoleAssignmentRead(modules, aid, auth);
|
|
696
|
+
if (!readDecision.allow)
|
|
639
697
|
continue;
|
|
698
|
+
const roleConfig = modules.roles.readRolesConfig(aid);
|
|
640
699
|
snapshotAgents.push({
|
|
641
700
|
aid,
|
|
642
701
|
displayName: agent.displayName ?? agent.personalName,
|
|
643
702
|
name: agent.name,
|
|
644
|
-
roles:
|
|
645
|
-
defaultRoles:
|
|
646
|
-
assignments:
|
|
703
|
+
roles: Object.keys(roleConfig.roles || {}).filter(role => isAssignableUserRole(role)),
|
|
704
|
+
defaultRoles: roleConfig.defaultRoles ?? { private: null, group: null },
|
|
705
|
+
assignments: listRelationRoleAssignments(modules.config, aid),
|
|
647
706
|
});
|
|
648
707
|
for (const seed of loadConversationSeeds(aid)) {
|
|
649
708
|
conversations.push(withResolvedRoles(seed, modules));
|
|
@@ -655,7 +714,11 @@ export const relationRolesSource = {
|
|
|
655
714
|
kind: 'roles',
|
|
656
715
|
async snapshot(params = {}) {
|
|
657
716
|
try {
|
|
658
|
-
return await buildSnapshot(
|
|
717
|
+
return await buildSnapshot({
|
|
718
|
+
...(params.__menuAuth ?? {}),
|
|
719
|
+
ecwebRequestId: params.__ecwebRequestId,
|
|
720
|
+
endpoint: params.__ecwebEndpoint,
|
|
721
|
+
});
|
|
659
722
|
}
|
|
660
723
|
catch (err) {
|
|
661
724
|
return { agents: [], conversations: [], error: String(err) };
|
|
@@ -691,80 +754,12 @@ function scopedAssignmentArgs(body, fallbackTargetId) {
|
|
|
691
754
|
const peerId = body.peerId || fallbackTargetId;
|
|
692
755
|
if (!groupRaw || !peerId)
|
|
693
756
|
throw new Error('groupId and peerId are required');
|
|
694
|
-
const
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
if (target.scope === 'group')
|
|
701
|
-
return { kind: 'group', groupId: target.groupId };
|
|
702
|
-
return { kind: 'group-member', groupId: target.groupId, memberAid: target.peerId };
|
|
703
|
-
}
|
|
704
|
-
function toLegacyAssignment(target, role) {
|
|
705
|
-
if (target.scope === 'private')
|
|
706
|
-
return { scope: 'private', peerId: target.peerId, peerKey: target.peerKey, role, source: 'relation-config' };
|
|
707
|
-
if (target.scope === 'group')
|
|
708
|
-
return { scope: 'group', groupId: target.groupId, peerKey: target.peerKey, role, source: 'relation-config' };
|
|
709
|
-
return { scope: 'group-member', groupId: target.groupId, peerId: target.peerId, peerKey: target.peerKey, role, source: 'relation-config' };
|
|
710
|
-
}
|
|
711
|
-
async function updateAssignmentThroughMenu(aid, body, fallbackTargetId, role, auth) {
|
|
712
|
-
const target = scopedAssignmentArgs(body, fallbackTargetId);
|
|
713
|
-
const menuTarget = toMenuTarget(target);
|
|
714
|
-
const snapshot = await execRoleMenu(aid, {
|
|
715
|
-
type: 'menu.query', id: `ecweb-assignment-target-${Date.now()}`,
|
|
716
|
-
args: { view: 'target', target: menuTarget },
|
|
717
|
-
}, auth);
|
|
718
|
-
if (snapshot.error)
|
|
719
|
-
return { response: snapshot, target };
|
|
720
|
-
const response = await execRoleMenu(aid, {
|
|
721
|
-
type: 'menu.update', id: `ecweb-assignment-update-${Date.now()}`,
|
|
722
|
-
args: {
|
|
723
|
-
resource: 'assignment',
|
|
724
|
-
target: menuTarget,
|
|
725
|
-
expectedPolicyRevision: snapshot.data.policyRevision,
|
|
726
|
-
expectedRelationRevision: snapshot.data.relationRevision,
|
|
727
|
-
},
|
|
728
|
-
value: JSON.stringify(role),
|
|
729
|
-
}, auth);
|
|
730
|
-
return { response, target };
|
|
731
|
-
}
|
|
732
|
-
async function listAssignmentsThroughMenu(aid, auth) {
|
|
733
|
-
const items = [];
|
|
734
|
-
let cursor = null;
|
|
735
|
-
do {
|
|
736
|
-
const response = await execRoleMenu(aid, {
|
|
737
|
-
type: 'menu.query', id: `ecweb-assignment-list-${Date.now()}-${items.length}`,
|
|
738
|
-
args: { view: 'assignments', limit: 200, cursor },
|
|
739
|
-
}, auth);
|
|
740
|
-
if (response.error)
|
|
741
|
-
return response;
|
|
742
|
-
items.push(...response.data.items);
|
|
743
|
-
cursor = response.data.nextCursor;
|
|
744
|
-
} while (cursor);
|
|
745
|
-
const assignments = items
|
|
746
|
-
.filter((item) => item.target?.kind !== 'default' && item.target?.kind !== 'unknown')
|
|
747
|
-
.map((item) => {
|
|
748
|
-
const target = item.target;
|
|
749
|
-
if (target.kind === 'private')
|
|
750
|
-
return { scope: 'private', peerId: target.peerAid, peerKey: target.peerKey, role: item.roleId, source: 'relation-config' };
|
|
751
|
-
if (target.kind === 'group')
|
|
752
|
-
return { scope: 'group', groupId: target.groupId, peerKey: target.peerKey, role: item.roleId, source: 'relation-config' };
|
|
753
|
-
return { scope: 'group-member', groupId: target.groupId, peerId: target.memberAid, peerKey: target.peerKey, role: item.roleId, source: 'relation-config' };
|
|
754
|
-
});
|
|
755
|
-
return { data: { $schema_version: 1, assignments } };
|
|
756
|
-
}
|
|
757
|
-
function pruneEmptyRoles(config) {
|
|
758
|
-
const roles = config.roles;
|
|
759
|
-
if (!roles || typeof roles !== 'object')
|
|
760
|
-
return config;
|
|
761
|
-
if (roles.members && typeof roles.members === 'object' && Object.keys(roles.members).length === 0) {
|
|
762
|
-
delete roles.members;
|
|
763
|
-
}
|
|
764
|
-
if (!Object.prototype.hasOwnProperty.call(roles, 'assigned') && !roles.members) {
|
|
765
|
-
delete config.roles;
|
|
766
|
-
}
|
|
767
|
-
return config;
|
|
757
|
+
const groupTarget = relationTargetFromId(groupRaw);
|
|
758
|
+
// The canonical relation schema has one role per relation. A member override
|
|
759
|
+
// is therefore stored on that member's private relation; groupId is retained
|
|
760
|
+
// only for the ECWeb presentation layer.
|
|
761
|
+
const memberTarget = relationTargetFromId(peerId);
|
|
762
|
+
return { scope, groupId: groupTarget.channelId, peerId, peerKey: memberTarget.peerKey };
|
|
768
763
|
}
|
|
769
764
|
function setScopedAssignment(configMod, aid, body, fallbackTargetId) {
|
|
770
765
|
const target = scopedAssignmentArgs(body, fallbackTargetId);
|
|
@@ -772,15 +767,12 @@ function setScopedAssignment(configMod, aid, body, fallbackTargetId) {
|
|
|
772
767
|
if (!role)
|
|
773
768
|
throw new Error('role is required');
|
|
774
769
|
const config = readRelationConfig(configMod, aid, target.peerKey);
|
|
775
|
-
const
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
roles.assigned = role;
|
|
782
|
-
}
|
|
783
|
-
writeRelationConfig(configMod, aid, target.peerKey, { ...config, roles });
|
|
770
|
+
const next = { ...config };
|
|
771
|
+
delete next.roles;
|
|
772
|
+
next.$schema_version = 8;
|
|
773
|
+
next.targetKind = target.scope === 'group' ? 'group' : 'private';
|
|
774
|
+
next.role = role;
|
|
775
|
+
writeRelationConfig(configMod, aid, target.peerKey, next);
|
|
784
776
|
if (target.scope === 'private')
|
|
785
777
|
return { scope: 'private', peerId: target.peerId, peerKey: target.peerKey, role, source: 'relation-config' };
|
|
786
778
|
if (target.scope === 'group')
|
|
@@ -790,11 +782,10 @@ function setScopedAssignment(configMod, aid, body, fallbackTargetId) {
|
|
|
790
782
|
function getScopedAssignment(configMod, aid, body, fallbackTargetId) {
|
|
791
783
|
const target = scopedAssignmentArgs(body, fallbackTargetId);
|
|
792
784
|
const config = readRelationConfig(configMod, aid, target.peerKey);
|
|
785
|
+
const role = config.role;
|
|
793
786
|
if (target.scope === 'group-member') {
|
|
794
|
-
const role = config.roles?.members?.[target.peerId];
|
|
795
787
|
return explicitAssignment('group-member', target.peerKey, role, { groupId: target.groupId, peerId: target.peerId });
|
|
796
788
|
}
|
|
797
|
-
const role = config.roles?.assigned;
|
|
798
789
|
if (target.scope === 'private')
|
|
799
790
|
return explicitAssignment('private', target.peerKey, role, { peerId: target.peerId });
|
|
800
791
|
return explicitAssignment('group', target.peerKey, role, { groupId: target.groupId });
|
|
@@ -802,34 +793,32 @@ function getScopedAssignment(configMod, aid, body, fallbackTargetId) {
|
|
|
802
793
|
function deleteScopedAssignment(configMod, aid, body, fallbackTargetId) {
|
|
803
794
|
const target = scopedAssignmentArgs(body, fallbackTargetId);
|
|
804
795
|
const config = readRelationConfig(configMod, aid, target.peerKey);
|
|
805
|
-
|
|
796
|
+
const deleted = typeof config.role === 'string';
|
|
797
|
+
if (!deleted)
|
|
806
798
|
return false;
|
|
807
|
-
const
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
deleted = Object.prototype.hasOwnProperty.call(members, target.peerId);
|
|
812
|
-
delete members[target.peerId];
|
|
813
|
-
roles.members = members;
|
|
814
|
-
}
|
|
815
|
-
else {
|
|
816
|
-
deleted = Object.prototype.hasOwnProperty.call(roles, 'assigned');
|
|
817
|
-
delete roles.assigned;
|
|
818
|
-
}
|
|
819
|
-
writeRelationConfig(configMod, aid, target.peerKey, pruneEmptyRoles({ ...config, roles }));
|
|
799
|
+
const next = { ...config };
|
|
800
|
+
delete next.role;
|
|
801
|
+
delete next.roles;
|
|
802
|
+
writeRelationConfig(configMod, aid, target.peerKey, next);
|
|
820
803
|
return deleted;
|
|
821
804
|
}
|
|
822
805
|
export async function handleRelationRolesApi(req, res, auth = {}) {
|
|
823
806
|
try {
|
|
824
807
|
const urlPath = (req.url || '').split('?')[0];
|
|
808
|
+
auth = {
|
|
809
|
+
...auth,
|
|
810
|
+
ecwebRequestId: auth.ecwebRequestId ?? crypto.randomUUID(),
|
|
811
|
+
endpoint: auth.endpoint ?? `${req.method ?? 'UNKNOWN'} ${urlPath}`,
|
|
812
|
+
};
|
|
813
|
+
const modules = await getParentModules();
|
|
825
814
|
if (req.method === 'GET' && urlPath.startsWith('/api/roles/agent/')) {
|
|
826
815
|
const aid = decodeURIComponent(urlPath.split('/').filter(Boolean).pop() || '');
|
|
827
816
|
if (!aid)
|
|
828
817
|
return sendJson(res, 400, { error: 'missing aid' });
|
|
829
|
-
const
|
|
830
|
-
if (
|
|
831
|
-
return
|
|
832
|
-
return sendJson(res, 200,
|
|
818
|
+
const decision = await authorizeRoleAssignmentRead(modules, aid, auth);
|
|
819
|
+
if (!decision.allow)
|
|
820
|
+
return sendAuthorizationDenied(res, decision);
|
|
821
|
+
return sendJson(res, 200, { $schema_version: 1, assignments: listRelationRoleAssignments(modules.config, aid) });
|
|
833
822
|
}
|
|
834
823
|
if ((req.method === 'POST' || req.method === 'PUT') && urlPath.startsWith('/api/roles/agent/')) {
|
|
835
824
|
const aid = decodeURIComponent(urlPath.split('/').filter(Boolean).pop() || '');
|
|
@@ -839,10 +828,15 @@ export async function handleRelationRolesApi(req, res, auth = {}) {
|
|
|
839
828
|
if (typeof body.role !== 'string' || !body.role) {
|
|
840
829
|
return sendJson(res, 400, { error: 'role is required' });
|
|
841
830
|
}
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
831
|
+
if (!roleExists(modules.roles, aid, body.role))
|
|
832
|
+
return sendJson(res, 404, { error: 'role not found' });
|
|
833
|
+
const target = scopedAssignmentArgs(body);
|
|
834
|
+
const decision = await authorizeRoleAssignmentWrite(modules, aid, auth, 'role.assign', body.role, {
|
|
835
|
+
scope: body.scope, peerId: target.peerId, groupId: target.groupId, peerKey: target.peerKey,
|
|
836
|
+
});
|
|
837
|
+
if (!decision.allow)
|
|
838
|
+
return sendAuthorizationDenied(res, decision);
|
|
839
|
+
return sendJson(res, 200, { ok: true, assignment: setScopedAssignment(modules.config, aid, body) });
|
|
846
840
|
}
|
|
847
841
|
return sendJson(res, 404, { error: 'not found' });
|
|
848
842
|
}
|
|
@@ -853,25 +847,40 @@ export async function handleRelationRolesApi(req, res, auth = {}) {
|
|
|
853
847
|
export async function handlePeerRoleApi(req, res, auth = {}) {
|
|
854
848
|
try {
|
|
855
849
|
const urlPath = (req.url || '').split('?')[0];
|
|
850
|
+
auth = {
|
|
851
|
+
...auth,
|
|
852
|
+
ecwebRequestId: auth.ecwebRequestId ?? crypto.randomUUID(),
|
|
853
|
+
endpoint: auth.endpoint ?? `${req.method ?? 'UNKNOWN'} ${urlPath}`,
|
|
854
|
+
};
|
|
856
855
|
const target = assignmentFromPath(urlPath);
|
|
857
856
|
if (!target)
|
|
858
857
|
return sendJson(res, 400, { error: 'missing aid or target id' });
|
|
858
|
+
const modules = await getParentModules();
|
|
859
859
|
if (req.method === 'DELETE') {
|
|
860
860
|
const body = await parseBody(req);
|
|
861
|
-
const
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
861
|
+
const scoped = scopedAssignmentArgs(body, target.targetId);
|
|
862
|
+
const current = getScopedAssignment(modules.config, target.aid, body, target.targetId);
|
|
863
|
+
const decision = await authorizeRoleAssignmentWrite(modules, target.aid, auth, 'role.revoke', current?.role, {
|
|
864
|
+
scope: body.scope, peerId: scoped.peerId, groupId: scoped.groupId, peerKey: scoped.peerKey,
|
|
865
|
+
});
|
|
866
|
+
if (!decision.allow)
|
|
867
|
+
return sendAuthorizationDenied(res, decision);
|
|
868
|
+
return sendJson(res, 200, { ok: true, deleted: deleteScopedAssignment(modules.config, target.aid, body, target.targetId) });
|
|
865
869
|
}
|
|
866
870
|
if (req.method === 'PUT') {
|
|
867
871
|
const body = await parseBody(req);
|
|
868
872
|
if (typeof body.role !== 'string' || !body.role) {
|
|
869
873
|
return sendJson(res, 400, { error: 'role is required' });
|
|
870
874
|
}
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
+
if (!roleExists(modules.roles, target.aid, body.role))
|
|
876
|
+
return sendJson(res, 404, { error: 'role not found' });
|
|
877
|
+
const scoped = scopedAssignmentArgs(body, target.targetId);
|
|
878
|
+
const decision = await authorizeRoleAssignmentWrite(modules, target.aid, auth, 'role.assign', body.role, {
|
|
879
|
+
scope: body.scope, peerId: scoped.peerId, groupId: scoped.groupId, peerKey: scoped.peerKey,
|
|
880
|
+
});
|
|
881
|
+
if (!decision.allow)
|
|
882
|
+
return sendAuthorizationDenied(res, decision);
|
|
883
|
+
return sendJson(res, 200, { ok: true, assignment: setScopedAssignment(modules.config, target.aid, body, target.targetId) });
|
|
875
884
|
}
|
|
876
885
|
return sendJson(res, 404, { error: 'not found' });
|
|
877
886
|
}
|