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.
@@ -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, listPeers, readJsonFile, readMessages, } from '../fs-utils.js';
4
+ import { getSessionsAunDir, isTransientProtocolMessage, readJsonFile, readAllJsonlLines, scanChatDirs, } from '../fs-utils.js';
5
5
  import { resolveParentDistModule, toFileUrl } from './parent-package.js';
6
- import { ipcQuery } from '../ipc-client.js';
7
- async function execRoleMenu(aid, payload, auth) {
8
- const response = await ipcQuery(resolvePaths().socket, {
9
- type: 'menu.exec',
10
- payload: { ...payload, name: 'role', agent: aid, args: { self: aid, ...(payload.args ?? {}) } },
11
- auth: { source: 'ecweb', actorAid: auth.actorAid ?? null, localDirect: !!auth.localDirect },
12
- }, 10_000);
13
- if (!response?.ok || !response.response)
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', 'command', 'command-permission.js');
36
- const auditPath = resolveParentDistModule('core', 'command', 'command-audit.js');
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
- async function authorizeRoleAssignmentWrite(modules, aid, auth, operation, targetRole, args) {
81
- const { permission, audit } = await getCommandModules();
82
- const actorRole = auth.localDirect
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: auth.localDirect ? 'local-direct' : auth.actorAid || undefined,
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: auth.localDirect ? 'local-direct' : auth.actorAid || undefined,
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: auth.localDirect ? 'local-direct' : auth.actorAid || undefined,
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: auth.localDirect ? 'local-direct' : auth.actorAid || undefined,
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: auth.localDirect ? 'local-direct' : auth.actorAid || undefined,
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 = listPeers(aunDir, aid).map(conversationId => ({
468
- conversationId,
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.roles?.assigned === 'string' && roleExists(modules.roles, aid, relation.roles.assigned)
496
- ? relation.roles.assigned
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
- const hasMembers = roles.members && typeof roles.members === 'object' && Object.keys(roles.members).length > 0;
592
- if (typeof roles.assigned === 'string') {
593
- out.push({
594
- scope: hasMembers ? 'group' : 'private',
595
- peerKey,
596
- role: roles.assigned,
597
- ...(hasMembers ? { groupId: parsed.channelId } : { peerId: parsed.channelId }),
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 [roleOptions, defaults, assignmentResult] = await Promise.all([
628
- execRoleMenu(aid, {
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: roleOptions.data.filter((item) => item.assignable).map((item) => item.value),
645
- defaultRoles: defaults.data.defaultRoles,
646
- assignments: assignmentResult.data.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(params.__menuAuth ?? {});
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 target = relationTargetFromId(groupRaw);
695
- return { scope, groupId: target.channelId, peerId, peerKey: target.peerKey };
696
- }
697
- function toMenuTarget(target) {
698
- if (target.scope === 'private')
699
- return { kind: 'private', peerAid: target.peerId };
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 roles = config.roles && typeof config.roles === 'object' ? { ...config.roles } : {};
776
- if (target.scope === 'group-member') {
777
- roles.members = roles.members && typeof roles.members === 'object' ? { ...roles.members } : {};
778
- roles.members[target.peerId] = role;
779
- }
780
- else {
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
- if (!config.roles || typeof config.roles !== 'object')
796
+ const deleted = typeof config.role === 'string';
797
+ if (!deleted)
806
798
  return false;
807
- const roles = { ...config.roles };
808
- let deleted = false;
809
- if (target.scope === 'group-member') {
810
- const members = roles.members && typeof roles.members === 'object' ? { ...roles.members } : {};
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 result = await listAssignmentsThroughMenu(aid, auth);
830
- if (result.error)
831
- return sendRoleMenuError(res, result);
832
- return sendJson(res, 200, result.data);
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
- const result = await updateAssignmentThroughMenu(aid, body, undefined, body.role, auth);
843
- if (result.response.error)
844
- return sendRoleMenuError(res, result.response);
845
- return sendJson(res, 200, { ok: true, assignment: toLegacyAssignment(result.target, body.role) });
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 result = await updateAssignmentThroughMenu(target.aid, body, target.targetId, null, auth);
862
- if (result.response.error)
863
- return sendRoleMenuError(res, result.response);
864
- return sendJson(res, 200, { ok: true, deleted: result.response.data.changed });
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
- const result = await updateAssignmentThroughMenu(target.aid, body, target.targetId, body.role, auth);
872
- if (result.response.error)
873
- return sendRoleMenuError(res, result.response);
874
- return sendJson(res, 200, { ok: true, assignment: toLegacyAssignment(result.target, body.role) });
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
  }