ec-web 0.0.20 → 0.0.21

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.
@@ -3,25 +3,6 @@ import path from 'path';
3
3
  import { resolvePaths } from '../paths.js';
4
4
  import { getSessionsAunDir, listPeers, readJsonFile, readMessages, } 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 });
24
- }
25
6
  async function getParentModules() {
26
7
  const configPath = resolveParentDistModule('config', 'config-manager.js');
27
8
  const rolesPath = resolveParentDistModule('config', 'roles.js');
@@ -32,8 +13,8 @@ async function getParentModules() {
32
13
  return { config, roles, resolver };
33
14
  }
34
15
  async function getCommandModules() {
35
- const permissionPath = resolveParentDistModule('core', 'command', 'command-permission.js');
36
- const auditPath = resolveParentDistModule('core', 'command', 'command-audit.js');
16
+ const permissionPath = resolveParentDistModule('core', 'auth', 'operation-authorizer.js');
17
+ const auditPath = resolveParentDistModule('core', 'auth', 'authorization-audit.js');
37
18
  const permission = await import(toFileUrl(permissionPath));
38
19
  const audit = await import(toFileUrl(auditPath));
39
20
  return { permission, audit };
@@ -77,9 +58,15 @@ async function getAgentsFromIpc() {
77
58
  return [];
78
59
  }
79
60
  }
80
- async function authorizeRoleAssignmentWrite(modules, aid, auth, operation, targetRole, args) {
81
- const { permission, audit } = await getCommandModules();
82
- const actorRole = auth.localDirect
61
+ function resolveEcwebActor(modules, aid, auth) {
62
+ const processConfig = modules.config?.read && modules.config?.ConfigTarget
63
+ ? modules.config.read(modules.config.ConfigTarget.Process)
64
+ : undefined;
65
+ const processOwners = Array.isArray(processConfig?.owners)
66
+ ? processConfig.owners.map((value) => String(value || '').trim()).filter(Boolean)
67
+ : [];
68
+ const daemonOwner = !!auth.localDirect || (!!auth.actorAid && processOwners.includes(auth.actorAid));
69
+ const actorRole = daemonOwner
83
70
  ? 'owner'
84
71
  : auth.actorAid
85
72
  ? (modules.resolver.resolvePeerRoleDetail({
@@ -90,6 +77,61 @@ async function authorizeRoleAssignmentWrite(modules, aid, auth, operation, targe
90
77
  conversationId: auth.actorAid,
91
78
  }).effectiveRole || 'none')
92
79
  : 'none';
80
+ return {
81
+ daemonOwner,
82
+ actorRole,
83
+ actorId: auth.localDirect ? 'local-direct' : auth.actorAid || undefined,
84
+ };
85
+ }
86
+ async function authorizeRoleAssignmentRead(modules, aid, auth) {
87
+ const { permission, audit } = await getCommandModules();
88
+ const actor = resolveEcwebActor(modules, aid, auth);
89
+ const decision = permission.authorizeCommand({
90
+ intent: {
91
+ operation: 'role.relation.read',
92
+ scope: 'agent',
93
+ source: 'ecweb',
94
+ args: { self: aid },
95
+ },
96
+ actorId: actor.actorId,
97
+ selfAid: aid,
98
+ role: actor.actorRole,
99
+ processRole: actor.daemonOwner ? 'daemon-owner' : 'none',
100
+ isDaemonOwner: actor.daemonOwner,
101
+ source: 'ecweb',
102
+ });
103
+ await audit.auditCommandAuthorization({
104
+ ts: Date.now(),
105
+ source: 'ecweb',
106
+ operation: 'role.relation.read',
107
+ scope: 'agent',
108
+ dangerous: false,
109
+ actorId: actor.actorId,
110
+ selfAid: aid,
111
+ role: actor.actorRole,
112
+ processRole: actor.daemonOwner ? 'daemon-owner' : 'none',
113
+ isDaemonOwner: actor.daemonOwner,
114
+ decision: decision.allow ? 'allow' : 'deny',
115
+ code: decision.allow ? undefined : decision.code,
116
+ reason: decision.allow ? undefined : decision.reason,
117
+ matchedRule: decision.matchedRule,
118
+ argsSummary: { self: aid },
119
+ });
120
+ if (decision.allow)
121
+ return { allow: true, actorRole: actor.actorRole };
122
+ return {
123
+ allow: false,
124
+ status: 403,
125
+ error: 'forbidden: role assignment read not allowed',
126
+ code: decision.code,
127
+ reason: decision.reason,
128
+ actorRole: actor.actorRole,
129
+ };
130
+ }
131
+ async function authorizeRoleAssignmentWrite(modules, aid, auth, operation, targetRole, args) {
132
+ const { permission, audit } = await getCommandModules();
133
+ const actor = resolveEcwebActor(modules, aid, auth);
134
+ const { daemonOwner, actorRole } = actor;
93
135
  const targetPeerId = typeof args.peerId === 'string' ? args.peerId : undefined;
94
136
  if (targetPeerId && modules.resolver.isStaticAgentOwner?.(aid, targetPeerId)) {
95
137
  const reason = `Static owner ${targetPeerId} is defined in agents/${aid}/config.json and cannot be changed by relation role assignments`;
@@ -99,9 +141,11 @@ async function authorizeRoleAssignmentWrite(modules, aid, auth, operation, targe
99
141
  operation,
100
142
  scope: 'agent',
101
143
  dangerous: false,
102
- actorId: auth.localDirect ? 'local-direct' : auth.actorAid || undefined,
144
+ actorId: actor.actorId,
103
145
  selfAid: aid,
104
146
  role: actorRole,
147
+ processRole: daemonOwner ? 'daemon-owner' : 'none',
148
+ isDaemonOwner: daemonOwner,
105
149
  decision: 'deny',
106
150
  code: 'IMMUTABLE_STATIC_OWNER',
107
151
  reason,
@@ -126,9 +170,11 @@ async function authorizeRoleAssignmentWrite(modules, aid, auth, operation, targe
126
170
  targetRole,
127
171
  },
128
172
  },
129
- actorId: auth.localDirect ? 'local-direct' : auth.actorAid || undefined,
173
+ actorId: actor.actorId,
130
174
  selfAid: aid,
131
175
  role: actorRole,
176
+ processRole: daemonOwner ? 'daemon-owner' : 'none',
177
+ isDaemonOwner: daemonOwner,
132
178
  source: 'ecweb',
133
179
  });
134
180
  if (!decision.allow) {
@@ -138,9 +184,11 @@ async function authorizeRoleAssignmentWrite(modules, aid, auth, operation, targe
138
184
  operation,
139
185
  scope: 'agent',
140
186
  dangerous: false,
141
- actorId: auth.localDirect ? 'local-direct' : auth.actorAid || undefined,
187
+ actorId: actor.actorId,
142
188
  selfAid: aid,
143
189
  role: actorRole,
190
+ processRole: daemonOwner ? 'daemon-owner' : 'none',
191
+ isDaemonOwner: daemonOwner,
144
192
  decision: 'deny',
145
193
  code: decision.code,
146
194
  reason: decision.reason,
@@ -156,9 +204,9 @@ async function authorizeRoleAssignmentWrite(modules, aid, auth, operation, targe
156
204
  actorRole,
157
205
  };
158
206
  }
159
- const policyAllowed = operation === 'role.assign'
207
+ const policyAllowed = daemonOwner || (operation === 'role.assign'
160
208
  ? !!targetRole && canAssignRole(actorRole, targetRole)
161
- : canRevokeRole(actorRole, targetRole);
209
+ : canRevokeRole(actorRole, targetRole));
162
210
  if (!policyAllowed) {
163
211
  const reason = operation === 'role.assign'
164
212
  ? `Role ${actorRole} cannot assign target role ${targetRole || '(missing)'}`
@@ -169,9 +217,11 @@ async function authorizeRoleAssignmentWrite(modules, aid, auth, operation, targe
169
217
  operation,
170
218
  scope: 'agent',
171
219
  dangerous: false,
172
- actorId: auth.localDirect ? 'local-direct' : auth.actorAid || undefined,
220
+ actorId: actor.actorId,
173
221
  selfAid: aid,
174
222
  role: actorRole,
223
+ processRole: daemonOwner ? 'daemon-owner' : 'none',
224
+ isDaemonOwner: daemonOwner,
175
225
  decision: 'deny',
176
226
  code: 'NOT_ALLOWED',
177
227
  reason,
@@ -193,9 +243,11 @@ async function authorizeRoleAssignmentWrite(modules, aid, auth, operation, targe
193
243
  operation,
194
244
  scope: 'agent',
195
245
  dangerous: false,
196
- actorId: auth.localDirect ? 'local-direct' : auth.actorAid || undefined,
246
+ actorId: actor.actorId,
197
247
  selfAid: aid,
198
248
  role: actorRole,
249
+ processRole: daemonOwner ? 'daemon-owner' : 'none',
250
+ isDaemonOwner: daemonOwner,
199
251
  decision: 'allow',
200
252
  matchedRule: decision.matchedRule,
201
253
  argsSummary: { ...args, targetRole },
@@ -492,8 +544,8 @@ function explicitAssignment(scope, peerKey, role, ids) {
492
544
  function resolveGroupRoleDetail(modules, aid, groupId) {
493
545
  const target = relationTargetFromId(groupId);
494
546
  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
547
+ const assigned = typeof relation.role === 'string' && roleExists(modules.roles, aid, relation.role)
548
+ ? relation.role
497
549
  : null;
498
550
  if (assigned) {
499
551
  return {
@@ -519,7 +571,7 @@ function assignmentForDetail(detail, modules, seed, member) {
519
571
  if (member && detail.source === 'group-member') {
520
572
  return explicitAssignment('group-member', groupTarget.peerKey, role, { groupId: groupTarget.channelId, peerId: member.peerId });
521
573
  }
522
- if (member && detail.source === 'private-inherited') {
574
+ if (member && (detail.source === 'private-inherited' || detail.source === 'relation-actor')) {
523
575
  const privateTarget = relationTargetFromId(member.peerId);
524
576
  return explicitAssignment('private', privateTarget.peerKey, role, { peerId: member.peerId });
525
577
  }
@@ -584,34 +636,14 @@ function listRelationRoleAssignments(config, aid) {
584
636
  continue;
585
637
  const peerKey = entry.name;
586
638
  const relation = readRelationConfig(config, aid, peerKey);
587
- const roles = relation.roles;
588
- if (!roles || typeof roles !== 'object')
589
- continue;
590
639
  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
- }
640
+ if (typeof relation.role !== 'string')
641
+ continue;
642
+ const isGroup = relation.targetKind === 'group'
643
+ || /^group\.agentid\.pub\//i.test(parsed.channelId);
644
+ out.push(isGroup
645
+ ? { scope: 'group', groupId: parsed.channelId, peerKey, role: relation.role, source: 'relation-config' }
646
+ : { scope: 'private', peerId: parsed.channelId, peerKey, role: relation.role, source: 'relation-config' });
615
647
  }
616
648
  return out;
617
649
  }
@@ -624,26 +656,17 @@ async function buildSnapshot(auth = {}) {
624
656
  const aid = agent.aid;
625
657
  if (!aid)
626
658
  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)
659
+ const readDecision = await authorizeRoleAssignmentRead(modules, aid, auth);
660
+ if (!readDecision.allow)
639
661
  continue;
662
+ const roleConfig = modules.roles.readRolesConfig(aid);
640
663
  snapshotAgents.push({
641
664
  aid,
642
665
  displayName: agent.displayName ?? agent.personalName,
643
666
  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,
667
+ roles: Object.keys(roleConfig.roles || {}).filter(role => isAssignableUserRole(role)),
668
+ defaultRoles: roleConfig.defaultRoles ?? { private: null, group: null },
669
+ assignments: listRelationRoleAssignments(modules.config, aid),
647
670
  });
648
671
  for (const seed of loadConversationSeeds(aid)) {
649
672
  conversations.push(withResolvedRoles(seed, modules));
@@ -691,80 +714,12 @@ function scopedAssignmentArgs(body, fallbackTargetId) {
691
714
  const peerId = body.peerId || fallbackTargetId;
692
715
  if (!groupRaw || !peerId)
693
716
  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;
717
+ const groupTarget = relationTargetFromId(groupRaw);
718
+ // The canonical relation schema has one role per relation. A member override
719
+ // is therefore stored on that member's private relation; groupId is retained
720
+ // only for the ECWeb presentation layer.
721
+ const memberTarget = relationTargetFromId(peerId);
722
+ return { scope, groupId: groupTarget.channelId, peerId, peerKey: memberTarget.peerKey };
768
723
  }
769
724
  function setScopedAssignment(configMod, aid, body, fallbackTargetId) {
770
725
  const target = scopedAssignmentArgs(body, fallbackTargetId);
@@ -772,15 +727,12 @@ function setScopedAssignment(configMod, aid, body, fallbackTargetId) {
772
727
  if (!role)
773
728
  throw new Error('role is required');
774
729
  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 });
730
+ const next = { ...config };
731
+ delete next.roles;
732
+ next.$schema_version = 8;
733
+ next.targetKind = target.scope === 'group' ? 'group' : 'private';
734
+ next.role = role;
735
+ writeRelationConfig(configMod, aid, target.peerKey, next);
784
736
  if (target.scope === 'private')
785
737
  return { scope: 'private', peerId: target.peerId, peerKey: target.peerKey, role, source: 'relation-config' };
786
738
  if (target.scope === 'group')
@@ -790,11 +742,10 @@ function setScopedAssignment(configMod, aid, body, fallbackTargetId) {
790
742
  function getScopedAssignment(configMod, aid, body, fallbackTargetId) {
791
743
  const target = scopedAssignmentArgs(body, fallbackTargetId);
792
744
  const config = readRelationConfig(configMod, aid, target.peerKey);
745
+ const role = config.role;
793
746
  if (target.scope === 'group-member') {
794
- const role = config.roles?.members?.[target.peerId];
795
747
  return explicitAssignment('group-member', target.peerKey, role, { groupId: target.groupId, peerId: target.peerId });
796
748
  }
797
- const role = config.roles?.assigned;
798
749
  if (target.scope === 'private')
799
750
  return explicitAssignment('private', target.peerKey, role, { peerId: target.peerId });
800
751
  return explicitAssignment('group', target.peerKey, role, { groupId: target.groupId });
@@ -802,34 +753,27 @@ function getScopedAssignment(configMod, aid, body, fallbackTargetId) {
802
753
  function deleteScopedAssignment(configMod, aid, body, fallbackTargetId) {
803
754
  const target = scopedAssignmentArgs(body, fallbackTargetId);
804
755
  const config = readRelationConfig(configMod, aid, target.peerKey);
805
- if (!config.roles || typeof config.roles !== 'object')
756
+ const deleted = typeof config.role === 'string';
757
+ if (!deleted)
806
758
  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 }));
759
+ const next = { ...config };
760
+ delete next.role;
761
+ delete next.roles;
762
+ writeRelationConfig(configMod, aid, target.peerKey, next);
820
763
  return deleted;
821
764
  }
822
765
  export async function handleRelationRolesApi(req, res, auth = {}) {
823
766
  try {
824
767
  const urlPath = (req.url || '').split('?')[0];
768
+ const modules = await getParentModules();
825
769
  if (req.method === 'GET' && urlPath.startsWith('/api/roles/agent/')) {
826
770
  const aid = decodeURIComponent(urlPath.split('/').filter(Boolean).pop() || '');
827
771
  if (!aid)
828
772
  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);
773
+ const decision = await authorizeRoleAssignmentRead(modules, aid, auth);
774
+ if (!decision.allow)
775
+ return sendAuthorizationDenied(res, decision);
776
+ return sendJson(res, 200, { $schema_version: 1, assignments: listRelationRoleAssignments(modules.config, aid) });
833
777
  }
834
778
  if ((req.method === 'POST' || req.method === 'PUT') && urlPath.startsWith('/api/roles/agent/')) {
835
779
  const aid = decodeURIComponent(urlPath.split('/').filter(Boolean).pop() || '');
@@ -839,10 +783,15 @@ export async function handleRelationRolesApi(req, res, auth = {}) {
839
783
  if (typeof body.role !== 'string' || !body.role) {
840
784
  return sendJson(res, 400, { error: 'role is required' });
841
785
  }
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) });
786
+ if (!roleExists(modules.roles, aid, body.role))
787
+ return sendJson(res, 404, { error: 'role not found' });
788
+ const target = scopedAssignmentArgs(body);
789
+ const decision = await authorizeRoleAssignmentWrite(modules, aid, auth, 'role.assign', body.role, {
790
+ scope: body.scope, peerId: target.peerId, groupId: target.groupId, peerKey: target.peerKey,
791
+ });
792
+ if (!decision.allow)
793
+ return sendAuthorizationDenied(res, decision);
794
+ return sendJson(res, 200, { ok: true, assignment: setScopedAssignment(modules.config, aid, body) });
846
795
  }
847
796
  return sendJson(res, 404, { error: 'not found' });
848
797
  }
@@ -856,22 +805,32 @@ export async function handlePeerRoleApi(req, res, auth = {}) {
856
805
  const target = assignmentFromPath(urlPath);
857
806
  if (!target)
858
807
  return sendJson(res, 400, { error: 'missing aid or target id' });
808
+ const modules = await getParentModules();
859
809
  if (req.method === 'DELETE') {
860
810
  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 });
811
+ const scoped = scopedAssignmentArgs(body, target.targetId);
812
+ const current = getScopedAssignment(modules.config, target.aid, body, target.targetId);
813
+ const decision = await authorizeRoleAssignmentWrite(modules, target.aid, auth, 'role.revoke', current?.role, {
814
+ scope: body.scope, peerId: scoped.peerId, groupId: scoped.groupId, peerKey: scoped.peerKey,
815
+ });
816
+ if (!decision.allow)
817
+ return sendAuthorizationDenied(res, decision);
818
+ return sendJson(res, 200, { ok: true, deleted: deleteScopedAssignment(modules.config, target.aid, body, target.targetId) });
865
819
  }
866
820
  if (req.method === 'PUT') {
867
821
  const body = await parseBody(req);
868
822
  if (typeof body.role !== 'string' || !body.role) {
869
823
  return sendJson(res, 400, { error: 'role is required' });
870
824
  }
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) });
825
+ if (!roleExists(modules.roles, target.aid, body.role))
826
+ return sendJson(res, 404, { error: 'role not found' });
827
+ const scoped = scopedAssignmentArgs(body, target.targetId);
828
+ const decision = await authorizeRoleAssignmentWrite(modules, target.aid, auth, 'role.assign', body.role, {
829
+ scope: body.scope, peerId: scoped.peerId, groupId: scoped.groupId, peerKey: scoped.peerKey,
830
+ });
831
+ if (!decision.allow)
832
+ return sendAuthorizationDenied(res, decision);
833
+ return sendJson(res, 200, { ok: true, assignment: setScopedAssignment(modules.config, target.aid, body, target.targetId) });
875
834
  }
876
835
  return sendJson(res, 404, { error: 'not found' });
877
836
  }