@astrale-domains/shell 0.1.7 → 0.1.9

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.
Files changed (79) hide show
  1. package/dist/domain.d.ts +54 -25
  2. package/dist/domain.js +21 -1
  3. package/dist/functions/authority/roles.js +58 -35
  4. package/dist/functions/group/assign-member.js +6 -3
  5. package/dist/functions/group/change-group-membership.js +92 -39
  6. package/dist/functions/group/group-membership-errors.js +1 -1
  7. package/dist/functions/group/index.js +2 -2
  8. package/dist/functions/group/legacy/index.js +1 -1
  9. package/dist/functions/group/legacy/membership.js +21 -17
  10. package/dist/functions/group/membership-laws.js +23 -28
  11. package/dist/functions/group/unassign-member.js +6 -3
  12. package/dist/functions/invitation/send-invitation.js +57 -7
  13. package/dist/functions/view-access/index.js +2 -2
  14. package/dist/functions/view-access/legacy/index.js +1 -0
  15. package/dist/functions/view-access/legacy/migrate-view-access.js +29 -0
  16. package/dist/mutations/group/index.js +1 -1
  17. package/dist/mutations/group/set-group-membership.js +45 -13
  18. package/dist/mutations/invitation/write-invitation.js +19 -2
  19. package/dist/mutations/view-access/legacy/index.js +1 -0
  20. package/dist/mutations/view-access/legacy/move-view-access.js +26 -0
  21. package/dist/mutations/view-access/set-view-access.js +1 -1
  22. package/dist/queries/composition/index.js +1 -1
  23. package/dist/queries/composition/read-role-nodes.js +48 -13
  24. package/dist/queries/group/index.js +1 -0
  25. package/dist/queries/group/legacy/index.js +1 -0
  26. package/dist/queries/group/legacy/read-members-of-group.js +27 -0
  27. package/dist/queries/group/read-group-members.js +73 -0
  28. package/dist/queries/invitation/index.js +1 -1
  29. package/dist/queries/invitation/read-invitation.js +33 -8
  30. package/dist/queries/role/index.js +1 -1
  31. package/dist/queries/role/read-role-node.js +8 -0
  32. package/dist/queries/view-access/legacy/index.js +1 -0
  33. package/dist/queries/view-access/legacy/read-view-access-to-move.js +40 -0
  34. package/dist/queries/view-access/read-view-access.js +1 -1
  35. package/dist/runtime.d.ts +3930 -924
  36. package/dist/runtime.js +6 -3
  37. package/dist/schema/index.d.ts +1 -1
  38. package/dist/schema/index.js +2 -1
  39. package/dist/schema/legacy/schema.js +5 -3
  40. package/dist/schema/modules/application/legacy/functions/list-entrypoints.js +5 -4
  41. package/dist/schema/modules/group/classes/group-profile-link.d.ts +1 -1
  42. package/dist/schema/modules/group/classes/group.d.ts +14 -0
  43. package/dist/schema/modules/group/classes/group.js +12 -2
  44. package/dist/schema/modules/group/functions/assign-members.js +17 -0
  45. package/dist/schema/modules/group/functions/index.js +2 -0
  46. package/dist/schema/modules/group/functions/unassign-members.js +17 -0
  47. package/dist/schema/modules/group/types/group-membership-input.js +8 -0
  48. package/dist/schema/modules/group/types/index.js +1 -1
  49. package/dist/schema/modules/invitation/classes/invitation.js +4 -2
  50. package/dist/schema/modules/role/core/roles.d.ts +2 -0
  51. package/dist/schema/modules/role/core/roles.js +1 -0
  52. package/dist/schema/modules/view-access/index.d.ts +1 -2
  53. package/dist/schema/modules/view-access/index.js +2 -2
  54. package/dist/schema/modules/view-access/legacy/classes/index.d.ts +1 -0
  55. package/dist/schema/modules/view-access/legacy/classes/index.js +1 -0
  56. package/dist/schema/modules/view-access/{classes → legacy/classes}/view-access.d.ts +7 -5
  57. package/dist/schema/modules/view-access/legacy/classes/view-access.js +25 -0
  58. package/dist/schema/modules/view-access/legacy/functions/assign-views.d.ts +18 -0
  59. package/dist/schema/modules/view-access/legacy/functions/index.d.ts +2 -0
  60. package/dist/schema/modules/view-access/legacy/functions/index.js +1 -0
  61. package/dist/schema/modules/view-access/legacy/functions/migrate-view-access.d.ts +13 -0
  62. package/dist/schema/modules/view-access/legacy/functions/migrate-view-access.js +22 -0
  63. package/dist/schema/modules/view-access/legacy/index.d.ts +4 -0
  64. package/dist/schema/modules/view-access/legacy/index.js +3 -1
  65. package/dist/schema/modules/view-access/legacy/policies/holds-view-access.d.ts +5 -0
  66. package/dist/schema/modules/view-access/legacy/policies/holds-view-access.js +8 -0
  67. package/dist/schema/modules/view-access/legacy/policies/index.d.ts +1 -0
  68. package/dist/schema/modules/view-access/legacy/policies/index.js +1 -0
  69. package/dist/schema/modules/view-access/legacy/view-assignment.d.ts +21 -0
  70. package/dist/schema/schema.d.ts +207 -99
  71. package/dist/schema/schema.js +1 -4
  72. package/package.json +2 -2
  73. package/dist/schema/modules/view-access/classes/index.d.ts +0 -1
  74. package/dist/schema/modules/view-access/classes/index.js +0 -1
  75. package/dist/schema/modules/view-access/classes/view-access.js +0 -23
  76. package/dist/schema/modules/view-access/policies/holds-view-access.d.ts +0 -2
  77. package/dist/schema/modules/view-access/policies/holds-view-access.js +0 -5
  78. package/dist/schema/modules/view-access/policies/index.d.ts +0 -1
  79. package/dist/schema/modules/view-access/policies/index.js +0 -1
package/dist/domain.d.ts CHANGED
@@ -35,6 +35,28 @@ declare const frontend: import("@astrale-os/sdk/view").Frontend<import("@astrale
35
35
  readonly constraints: Record<never, never>;
36
36
  readonly effectiveProperties: never;
37
37
  }>;
38
+ readonly view_access: import("@astrale-os/sdk/schema").DirectedEdgeClass<{
39
+ readonly depth: 0;
40
+ readonly abstract: false;
41
+ readonly extends: readonly [];
42
+ readonly properties: never;
43
+ readonly source: {
44
+ readonly as: "audience";
45
+ readonly accepts: readonly (import("@astrale-os/sdk/schema").ClassRef<"Identity"> & {
46
+ readonly origin: "kernel.astrale.ai";
47
+ })[];
48
+ readonly outgoing: "0..*";
49
+ };
50
+ readonly target: {
51
+ readonly as: "view";
52
+ readonly accepts: readonly (import("@astrale-os/sdk/schema").ClassRef<"View"> & {
53
+ readonly origin: "kernel.astrale.ai";
54
+ })[];
55
+ readonly incoming: "0..*";
56
+ };
57
+ readonly constraints: Record<never, never>;
58
+ readonly effectiveProperties: never;
59
+ }>;
38
60
  readonly User: import("@astrale-os/sdk/schema").AuthoredNodeClass<{
39
61
  readonly icon: import("@astrale-os/sdk/schema").ClassIcon;
40
62
  readonly inheritance: "open";
@@ -326,6 +348,20 @@ declare const frontend: import("@astrale-os/sdk/view").Frontend<import("@astrale
326
348
  readonly mode: 'value';
327
349
  readonly value: Record<string, never>;
328
350
  }, "authorized", true, false, undefined, undefined>>;
351
+ readonly assignMembers: import("@astrale-os/sdk/schema").Method<import("@astrale-os/sdk/schema").CallableProjection<{
352
+ members: string[];
353
+ group: string;
354
+ }, {
355
+ readonly mode: 'value';
356
+ readonly value: Record<string, never>;
357
+ }, "authorized", true, false, undefined, undefined>>;
358
+ readonly unassignMembers: import("@astrale-os/sdk/schema").Method<import("@astrale-os/sdk/schema").CallableProjection<{
359
+ members: string[];
360
+ group: string;
361
+ }, {
362
+ readonly mode: 'value';
363
+ readonly value: Record<string, never>;
364
+ }, "authorized", true, false, undefined, undefined>>;
329
365
  readonly provision: import("@astrale-os/sdk/schema").Method<import("@astrale-os/sdk/schema").CallableProjection<{
330
366
  directory: string;
331
367
  externalId: string;
@@ -369,7 +405,7 @@ declare const frontend: import("@astrale-os/sdk/view").Frontend<import("@astrale
369
405
  readonly abstract: false;
370
406
  readonly depth: 1;
371
407
  readonly properties: "attributes" | "description" | "directory" | "externalId" | "frozen" | "icon" | "iss" | "name" | "sub";
372
- readonly methods: "assignMember" | "create" | "delete" | "deprovision" | "provision" | "unassignMember" | "update";
408
+ readonly methods: "assignMember" | "assignMembers" | "create" | "delete" | "deprovision" | "provision" | "unassignMember" | "unassignMembers" | "update";
373
409
  }[];
374
410
  readonly outgoing: "0..1";
375
411
  };
@@ -427,6 +463,13 @@ declare const frontend: import("@astrale-os/sdk/view").Frontend<import("@astrale
427
463
  readonly description?: string;
428
464
  readonly immutable?: import("@astrale-os/sdk/schema").PropertyImmutability;
429
465
  }>;
466
+ email: import("@astrale-os/sdk/schema").AuthoredProperty<{
467
+ readonly type: "string";
468
+ readonly minLength: 3;
469
+ readonly maxLength: 320;
470
+ }, {
471
+ readonly required: false;
472
+ }>;
430
473
  };
431
474
  readonly policies: {
432
475
  readonly read: ({ check, ref }: import("@astrale-os/sdk/schema").ClassRuleContext) => import("@astrale-os/sdk/schema").PolicyCheckExpression<import("@astrale-os/sdk/schema").PolicyRef, import("@astrale-os/sdk/schema").ClassRuleObject>;
@@ -471,7 +514,7 @@ declare const frontend: import("@astrale-os/sdk/view").Frontend<import("@astrale
471
514
  readonly kind: 'node';
472
515
  readonly abstract: false;
473
516
  readonly depth: 0;
474
- readonly properties: "execution" | "expiresAt" | "round" | "status";
517
+ readonly properties: "email" | "execution" | "expiresAt" | "round" | "status";
475
518
  readonly methods: "accept" | "cancel" | "resend" | "send";
476
519
  }[];
477
520
  readonly outgoing: "0..1";
@@ -557,28 +600,6 @@ declare const frontend: import("@astrale-os/sdk/view").Frontend<import("@astrale
557
600
  readonly constraints: Record<never, never>;
558
601
  readonly effectiveProperties: never;
559
602
  }>;
560
- readonly view_access: import("@astrale-os/sdk/schema").DirectedEdgeClass<{
561
- readonly depth: 0;
562
- readonly abstract: false;
563
- readonly extends: readonly [];
564
- readonly properties: never;
565
- readonly source: {
566
- readonly as: "audience";
567
- readonly accepts: readonly (import("@astrale-os/sdk/schema").ClassRef<"Identity"> & {
568
- readonly origin: "kernel.astrale.ai";
569
- })[];
570
- readonly outgoing: "0..*";
571
- };
572
- readonly target: {
573
- readonly as: "view";
574
- readonly accepts: readonly (import("@astrale-os/sdk/schema").ClassRef<"View"> & {
575
- readonly origin: "kernel.astrale.ai";
576
- })[];
577
- readonly incoming: "0..*";
578
- };
579
- readonly constraints: Record<never, never>;
580
- readonly effectiveProperties: never;
581
- }>;
582
603
  };
583
604
  readonly functions: {
584
605
  readonly listEntrypoints: import("@astrale-os/sdk/schema").Function<import("@astrale-os/sdk/schema").CallableProjection<Record<string, never>, {
@@ -612,6 +633,13 @@ declare const frontend: import("@astrale-os/sdk/view").Frontend<import("@astrale
612
633
  profiles: number;
613
634
  };
614
635
  }, "authorized", false, false, undefined, undefined>>;
636
+ readonly legacyMigrateViewAccess: import("@astrale-os/sdk/schema").Function<import("@astrale-os/sdk/schema").CallableProjection<Record<string, never>, {
637
+ readonly mode: 'value';
638
+ readonly value: {
639
+ copied: number;
640
+ removed: number;
641
+ };
642
+ }, "authorized", false, false, undefined, undefined>>;
615
643
  readonly disconnectDirectory: import("@astrale-os/sdk/schema").Function<import("@astrale-os/sdk/schema").CallableProjection<{
616
644
  directory: string;
617
645
  }, {
@@ -629,8 +657,8 @@ declare const frontend: import("@astrale-os/sdk/view").Frontend<import("@astrale
629
657
  readonly usesFunction: import("@astrale-os/sdk/schema").Policy;
630
658
  readonly isSelf: import("@astrale-os/sdk/schema").Policy;
631
659
  readonly legacyOwnsMembershipGroup: import("@astrale-os/sdk/schema").Policy;
632
- readonly hasRole: import("@astrale-os/sdk/schema").Policy;
633
660
  readonly holdsViewAccess: import("@astrale-os/sdk/schema").Policy;
661
+ readonly hasRole: import("@astrale-os/sdk/schema").Policy;
634
662
  readonly describesSubject: import("@astrale-os/sdk/schema").Policy;
635
663
  readonly isProfileSource: import("@astrale-os/sdk/schema").Policy;
636
664
  readonly describesGroupOfSubject: import("@astrale-os/sdk/schema").Policy;
@@ -651,6 +679,7 @@ declare const frontend: import("@astrale-os/sdk/view").Frontend<import("@astrale
651
679
  readonly 'user-profile.reader': import("@astrale-os/sdk/schema").CoreNode;
652
680
  readonly 'user-profile.self-writer': import("@astrale-os/sdk/schema").CoreNode;
653
681
  readonly 'group-profile.reader': import("@astrale-os/sdk/schema").CoreNode;
682
+ readonly 'os.viewer': import("@astrale-os/sdk/schema").CoreNode;
654
683
  readonly admin: import("@astrale-os/sdk/schema").CoreNode;
655
684
  readonly member: import("@astrale-os/sdk/schema").CoreNode;
656
685
  readonly applications: import("@astrale-os/sdk/schema").CoreNode;
package/dist/domain.js CHANGED
@@ -63,6 +63,12 @@ const domainRequirements = requirements(legacyRelease({
63
63
  members: [S.core.nodes['workspace.member']],
64
64
  granters: [S.core.nodes['workspace.admin']],
65
65
  },
66
+ // Whoever holds it opens the OS view; a member holds it, and an administrator through member.
67
+ {
68
+ role: S.core.nodes['os.viewer'],
69
+ members: [S.core.nodes['workspace.member']],
70
+ granters: [S.core.nodes['workspace.admin']],
71
+ },
66
72
  // Each profile holds the Kernel profile of its rank; Kernel admin is granted by Root alone.
67
73
  // Kernel reader is no profile's: admin grants it to whoever only opens the Views it is given
68
74
  // (AM10).
@@ -94,7 +100,21 @@ const domainRequirements = requirements(legacyRelease({
94
100
  { role: K.core.nodes['blob.reader'], granters: [S.core.nodes['workspace.admin']] },
95
101
  { role: K.core.nodes['blob.writer'], granters: [S.core.nodes['workspace.admin']] },
96
102
  { role: K.core.nodes['blob.manager'], granters: [S.core.nodes['workspace.admin']] },
97
- { role: K.core.nodes['view.reader'], granters: [S.core.nodes['workspace.admin']] },
103
+ // A member reads every View, so the OS view lists every application; anyone else reads only
104
+ // the Views given to it by `view_access`. Independent of `os.viewer`, which only shows the OS.
105
+ {
106
+ role: K.core.nodes['view.reader'],
107
+ members: [S.core.nodes['workspace.member']],
108
+ granters: [S.core.nodes['workspace.admin']],
109
+ },
110
+ // Shell writes the Kernel's view_access for assignViews and its migration (L-SHELL-12,
111
+ // L-SHELL-20); an administrator writes it from the console through Kernel admin. Transition
112
+ // only: removed with those two Functions (step 5).
113
+ {
114
+ role: K.core.nodes['view.manager'],
115
+ members: [requirements.domain],
116
+ granters: [S.core.nodes['workspace.admin']],
117
+ },
98
118
  { role: K.core.nodes['domain.reader'], granters: [S.core.nodes['workspace.admin']] },
99
119
  { role: K.core.nodes['execution.reader'], granters: [S.core.nodes['workspace.admin']] },
100
120
  { role: K.core.nodes['journal.reader'], granters: [S.core.nodes['workspace.admin']] },
@@ -1,6 +1,6 @@
1
- import { readRoleNodes } from '../../queries/composition/index.js';
1
+ import { readRoleKeys } from '../../queries/composition/index.js';
2
2
  import { readIdentities } from '../../queries/identity/index.js';
3
- import { readRoleNode } from '../../queries/role/index.js';
3
+ import { readRoleId, readRoleNode } from '../../queries/role/index.js';
4
4
  import { walkComposition } from './composition.js';
5
5
  /** Kernel reader and writer and their parts: every other Kernel Role administers (U4). */
6
6
  const KERNEL_ORDINARY = new Set([
@@ -22,12 +22,16 @@ const SHELL_ADMINISTRATIVE = new Set([
22
22
  'directory.provisioner',
23
23
  ]);
24
24
  const keyOf = (ref) => `${ref.origin}:${ref.name}`;
25
- /** One handler's view of authority; each composition and the Role catalogue is read once. */
25
+ /** Identities one Role read starts from. */
26
+ const ROLE_STEP = 256;
27
+ /**
28
+ * One handler's view of authority. Each composition, and the Roles it holds, is read once; only the
29
+ * Roles a composition reaches are read, never the catalogue of every installed Role. What the
30
+ * caller holds is asked once per Role too, so a handler may ask early and use the answer later.
31
+ */
26
32
  export function authority(capabilities) {
27
33
  const S = capabilities.domain;
28
34
  const kernel = S.dependencies.kernel.origin;
29
- let catalogue;
30
- const roles = () => (catalogue ??= capabilities.query(readRoleNodes, {}));
31
35
  const compositions = new Map();
32
36
  const composition = (identity) => {
33
37
  let walked = compositions.get(identity);
@@ -37,32 +41,58 @@ export function authority(capabilities) {
37
41
  }
38
42
  return walked;
39
43
  };
40
- const administrative = async () => {
41
- const { byKey } = await roles();
42
- const ids = new Set();
43
- for (const [key, id] of byKey) {
44
- const [origin, name] = [key.slice(0, key.indexOf(':')), key.slice(key.indexOf(':') + 1)];
45
- if (origin === S.origin && SHELL_ADMINISTRATIVE.has(name))
46
- ids.add(id);
47
- if (origin === kernel && !KERNEL_ORDINARY.has(name))
48
- ids.add(id);
44
+ const held = new Map();
45
+ /** The Core keys of the Roles an Identity holds, `<origin>:<key>`. */
46
+ const heldKeys = (identity) => {
47
+ let keys = held.get(identity);
48
+ if (keys === undefined) {
49
+ keys = (async () => {
50
+ const reached = [...(await composition(identity)).keys()];
51
+ const found = [];
52
+ for (let index = 0; index < reached.length; index += ROLE_STEP) {
53
+ const [first, ...others] = reached.slice(index, index + ROLE_STEP);
54
+ if (first === undefined)
55
+ continue;
56
+ const roles = await capabilities.query(readRoleKeys, { identities: [first, ...others] });
57
+ found.push(...roles.values());
58
+ }
59
+ return found;
60
+ })();
61
+ held.set(identity, keys);
49
62
  }
50
- return ids;
63
+ return keys;
51
64
  };
65
+ const nodes = new Map();
66
+ const roleNode = (role) => {
67
+ let node = nodes.get(role);
68
+ if (node === undefined) {
69
+ node = capabilities.query(readRoleId, { role: S.core.nodes[role].ref });
70
+ nodes.set(role, node);
71
+ }
72
+ return node;
73
+ };
74
+ const administrative = (key) => {
75
+ const [origin, name] = [key.slice(0, key.indexOf(':')), key.slice(key.indexOf(':') + 1)];
76
+ return ((origin === S.origin && SHELL_ADMINISTRATIVE.has(name)) ||
77
+ (origin === kernel && !KERNEL_ORDINARY.has(name)));
78
+ };
79
+ const callerRoles = new Map();
52
80
  return {
53
- async roleNode(role) {
54
- return (await roles()).byKey.get(keyOf(S.core.nodes[role].ref));
55
- },
56
- async callerHolds(role) {
57
- const caller = capabilities.graph.caller;
58
- if (caller === undefined)
59
- return false;
60
- return caller.query(readRoleNode, { role: S.core.nodes[role].ref });
81
+ roleNode,
82
+ callerHolds(role) {
83
+ let holds = callerRoles.get(role);
84
+ if (holds === undefined) {
85
+ const caller = capabilities.graph.caller;
86
+ holds =
87
+ caller === undefined
88
+ ? Promise.resolve(false)
89
+ : caller.query(readRoleNode, { role: S.core.nodes[role].ref });
90
+ callerRoles.set(role, holds);
91
+ }
92
+ return holds;
61
93
  },
62
94
  async heldRole(identity) {
63
- const { byKey } = await roles();
64
- const reached = await composition(identity);
65
- const held = [...byKey].filter(([, id]) => reached.has(id)).map(([key]) => key);
95
+ const held = await heldKeys(identity);
66
96
  const admin = keyOf(S.core.nodes['workspace.admin'].ref);
67
97
  const role = held.includes(admin) ? admin : held[0];
68
98
  if (role === undefined)
@@ -70,17 +100,10 @@ export function authority(capabilities) {
70
100
  return role.startsWith(`${S.origin}:`) ? role.slice(S.origin.length + 1) : role;
71
101
  },
72
102
  async holdsAdministration(identity) {
73
- const ids = await administrative();
74
- for (const reached of (await composition(identity)).keys())
75
- if (ids.has(reached))
76
- return true;
77
- return false;
78
- },
79
- async reaches(from, to) {
80
- return from === to || (await composition(from)).has(to);
103
+ return (await heldKeys(identity)).some(administrative);
81
104
  },
82
105
  async activeAdministrators() {
83
- const admin = (await roles()).byKey.get(keyOf(S.core.nodes['workspace.admin'].ref));
106
+ const admin = await roleNode('workspace.admin');
84
107
  if (admin === undefined)
85
108
  return [];
86
109
  const holders = [...(await walkComposition(capabilities, admin, 'incoming')).keys()];
@@ -1,4 +1,7 @@
1
- import { defineWorkflow } from '@astrale-os/sdk/workflow';
2
- import { changeGroupMembership } from './change-group-membership.js';
1
+ import { defineAction } from '@astrale-os/sdk/action';
2
+ import { changeGroupMembers, changeGroupMembership } from './change-group-membership.js';
3
3
  import { groupMembershipErrors } from './group-membership-errors.js';
4
- export const assignMember = defineWorkflow()('Group.assignMember', { errors: groupMembershipErrors }, (context) => changeGroupMembership(context, true));
4
+ /** Make one account or group a member of a group, in one read and one write. */
5
+ export const assignMember = defineAction()('Group.assignMember', { errors: groupMembershipErrors }, (context) => changeGroupMembership(context, true));
6
+ /** Make several accounts or groups members of a group, in one commit. */
7
+ export const assignMembers = defineAction()('Group.assignMembers', { errors: groupMembershipErrors }, (context) => changeGroupMembers(context, context.input, true));
@@ -1,56 +1,66 @@
1
- import { setGroupMembership } from '../../mutations/group/index.js';
2
- import { readGroupMembership, readGroupMembershipEndpoints } from '../../queries/group/index.js';
1
+ import { authority } from '../authority/index.js';
2
+ import { setGroupMemberships } from '../../mutations/group/index.js';
3
+ import { readGroupMembers, readGroupMembersEndpoints } from '../../queries/group/index.js';
4
+ import { readIdentities } from '../../queries/identity/index.js';
3
5
  import { canonicalNodeId } from '../../utils/canonical-node-path/index.js';
4
6
  import { readGroup } from './group.js';
5
7
  // Seam L-SHELL-8, L-SHELL-9: legacy groups also keep `member_of_group`; a Domain's own Group
6
8
  // subclass changes its members under that Domain's authority.
7
- import { legacyMembershipEdges, legacyChangeForeignMembership } from './legacy/index.js';
9
+ import { legacyChangeForeignMembership, legacyMembersEdges } from './legacy/index.js';
8
10
  import { membershipRefusal } from './membership-laws.js';
9
11
  /**
10
- * Add or withdraw one direct member of a Shell group: Kernel `extends_with` from the member to the
11
- * group (G1), under G2, G3 and acyclicity. The write converges what it observed, guarded by it;
12
- * after an unknown outcome it observes again, and an unconfirmed outcome stays explicit.
12
+ * Add or withdraw direct members of one Shell group: Kernel `extends_with` from each member to the
13
+ * group (G1), under G2 and G3, checked once for the group. The Kernel refuses a cycle. All the
14
+ * members change in one commit, guarded by what was observed of them; a member already where it is
15
+ * asked to be changes nothing, so a retry is harmless. After an unknown outcome the change is
16
+ * observed again, and an unconfirmed outcome stays explicit.
13
17
  */
14
- export async function changeGroupMembership(context, present) {
18
+ export async function changeGroupMembers(context, input, present, { checkMembers = true } = {}) {
15
19
  const invalid = () => context.error('INVALID_GROUP_MEMBERSHIP_REFERENCE', {});
16
- const membership = {
17
- member: canonicalNodeId(context.input.member, invalid),
18
- group: canonicalNodeId(context.input.group, invalid),
19
- };
20
- const endpoints = await context.step.run('observe-endpoints', () => context.query(readGroupMembershipEndpoints, membership));
21
- if (!endpoints.visible)
20
+ const group = canonicalNodeId(input.group, invalid);
21
+ const [first, ...others] = new Set(input.members.map((member) => canonicalNodeId(member, invalid)));
22
+ if (first === undefined)
23
+ throw invalid();
24
+ const members = [first, ...others];
25
+ const authorities = authority(context);
26
+ // A fixed number of reads, whatever the number of members: one per kind, never one per member.
27
+ // What the caller holds is asked with them, so G2 and G3 wait on no further round trip for it.
28
+ const [identities, observed, stored] = await Promise.all([
29
+ // A list of members is not a typed Path input: each must be an account or a group.
30
+ checkMembers ? identitiesOnly(context, members) : true,
31
+ observeMembers(context, { members, group }, present),
32
+ readGroup(context, group),
33
+ authorities.callerHolds('directory.provisioner'),
34
+ authorities.callerHolds('group.writer'),
35
+ authorities.callerHolds('workspace.admin'),
36
+ ]);
37
+ if (!identities)
38
+ throw invalid();
39
+ if (members.some((member) => !observed.visible.has(member)) || !observed.visible.has(group)) {
22
40
  throw context.error('GROUP_MEMBERSHIP_ENDPOINTS_UNAVAILABLE', {});
23
- if (endpoints.groupClass !== context.domain.classes.Group.key) {
24
- return legacyChangeForeignMembership(context, membership, present);
25
41
  }
26
- const refusal = await context.step.run('check-laws', async () => {
27
- const group = await readGroup(context, membership.group);
28
- if (group === undefined)
29
- return { code: 'GROUP_NOT_FOUND' };
30
- return (await membershipRefusal(context, group, membership.member, present)) ?? null;
31
- });
32
- if (refusal !== null) {
42
+ if (observed.groupClass !== context.domain.classes.Group.key) {
43
+ for (const member of members) {
44
+ await legacyChangeForeignMembership(context, { member, group }, present);
45
+ }
46
+ return {};
47
+ }
48
+ if (stored === undefined)
49
+ throw context.error('GROUP_NOT_FOUND', {});
50
+ const refusal = await membershipRefusal(context, stored, authorities);
51
+ if (refusal !== undefined) {
33
52
  if (refusal.code === 'GROUP_ROLE_PROTECTED') {
34
53
  throw context.error(refusal.code, { role: refusal.role });
35
54
  }
36
55
  throw context.error(refusal.code, {});
37
56
  }
38
- const observe = async () => {
39
- const current = await context.query(readGroupMembershipEndpoints, membership);
40
- if (!current.visible)
41
- throw context.error('GROUP_MEMBERSHIP_ENDPOINTS_UNAVAILABLE', {});
42
- const authority = await context.query(readGroupMembership, membership);
43
- const edges = [
44
- { edge: 'authority', current: authority.present, present, guard: authority.guard },
45
- ...(await legacyMembershipEdges(context, membership, present)),
46
- ];
47
- return { endpoints: current.guard, edges };
48
- };
49
- const matches = (edges) => edges.every(({ current, present: wanted }) => current === wanted);
50
- const current = await context.step.run('observe-membership', observe);
51
- if (matches(current.edges))
57
+ if (observed.changes.length === 0)
52
58
  return {};
53
- const result = await context.step.run('mutate-membership', () => context.mutate(setGroupMembership, { ...membership, ...current }));
59
+ const result = await context.mutate(setGroupMemberships, {
60
+ group,
61
+ guards: observed.guards,
62
+ changes: observed.changes,
63
+ });
54
64
  if (result.ok)
55
65
  return {};
56
66
  if (result.outcomeKnown) {
@@ -58,11 +68,54 @@ export async function changeGroupMembership(context, present) {
58
68
  throw context.error('GROUP_MEMBERSHIP_CYCLE', {});
59
69
  throw context.error('GROUP_MEMBERSHIP_CONFLICT', { cause: result.code });
60
70
  }
61
- const recovered = await context.step.run('recover-membership', observe);
62
- if (matches(recovered.edges))
71
+ const recovered = await observeMembers(context, { members, group }, present);
72
+ if (recovered.changes.length === 0)
63
73
  return {};
64
74
  throw context.error('GROUP_MEMBERSHIP_UNCONFIRMED', {
65
75
  cause: result.code,
66
76
  outcomeKnown: false,
67
77
  });
68
78
  }
79
+ /** Add or withdraw one direct member of a Shell group. */
80
+ export const changeGroupMembership = (context, present) =>
81
+ // The member Path input already restricts its Class to an account or a group.
82
+ changeGroupMembers(context, { group: context.input.group, members: [context.input.member] }, present, { checkMembers: false });
83
+ /** Whether every member is an account or a group, a Domain's subclass included. */
84
+ async function identitiesOnly(context, members) {
85
+ const [first, ...others] = members;
86
+ if (first === undefined)
87
+ return false;
88
+ const ids = [first, ...others];
89
+ const found = await Promise.all([
90
+ context.query(readIdentities, { kind: 'User', ids }),
91
+ context.query(readIdentities, { kind: 'Group', ids }),
92
+ ]);
93
+ const known = new Set(found.flat().map((node) => node.id));
94
+ return members.every((member) => known.has(member));
95
+ }
96
+ /**
97
+ * The members and their membership Edges to the group as they are, in three reads whatever their
98
+ * number, with the guards that keep them so and the Edges to change for the state asked for.
99
+ */
100
+ async function observeMembers(context, selection, present) {
101
+ const [endpoints, authority, legacy] = await Promise.all([
102
+ context.query(readGroupMembersEndpoints, selection),
103
+ context.query(readGroupMembers, selection),
104
+ legacyMembersEdges(context, selection, present),
105
+ ]);
106
+ const changes = [];
107
+ for (const member of selection.members) {
108
+ if (authority.present.has(member) !== present) {
109
+ changes.push({ member, edge: 'authority', present });
110
+ }
111
+ const wanted = legacy.wanted(member);
112
+ if (legacy.present.has(member) !== wanted)
113
+ changes.push({ member, edge: 'legacy', present: wanted });
114
+ }
115
+ return {
116
+ visible: endpoints.visible,
117
+ ...(endpoints.groupClass === undefined ? {} : { groupClass: endpoints.groupClass }),
118
+ guards: [endpoints.guard, authority.guard, legacy.guard],
119
+ changes,
120
+ };
121
+ }
@@ -9,7 +9,7 @@ export const groupMembershipErrors = {
9
9
  },
10
10
  INVALID_GROUP_MEMBERSHIP_REFERENCE: {
11
11
  family: 'VALIDATION_ERROR',
12
- message: 'Member and group must be exact canonical @NodeId paths.',
12
+ message: 'Members and group must be exact canonical @NodeId paths of accounts or groups.',
13
13
  details: z.strictObject({}),
14
14
  },
15
15
  GROUP_MEMBERSHIP_CONFLICT: {
@@ -1,8 +1,8 @@
1
1
  export { create } from './create-group.js';
2
2
  export { update } from './update-group.js';
3
3
  export { remove } from './delete-group.js';
4
- export { assignMember } from './assign-member.js';
5
- export { unassignMember } from './unassign-member.js';
4
+ export { assignMember, assignMembers } from './assign-member.js';
5
+ export { unassignMember, unassignMembers } from './unassign-member.js';
6
6
  export { provision } from './provision-group.js';
7
7
  export { deprovision } from './deprovision-group.js';
8
8
  export { toGroup } from './group.js';
@@ -1 +1 @@
1
- export { legacyChangeForeignMembership, legacyMembershipEdges } from './membership.js';
1
+ export { legacyChangeForeignMembership, legacyMembersEdges } from './membership.js';
@@ -1,20 +1,24 @@
1
1
  import { setGroupMembership } from '../../../mutations/group/index.js';
2
- import { readGroupMembership, readGroupMembershipEndpoints } from '../../../queries/group/index.js';
3
- import { legacyReadCoreGroups, legacyReadMemberOfGroup } from '../../../queries/group/legacy/index.js';
2
+ import { readGroupMembership, readGroupMembershipEndpoints, } from '../../../queries/group/index.js';
3
+ import { legacyReadCoreGroups, legacyReadMemberOfGroup, legacyReadMembersOfGroup, } from '../../../queries/group/legacy/index.js';
4
4
  /**
5
- * @deprecated The legacy `member_of_group` Edge a change of a Shell group's members also converges
6
- * (L-SHELL-8): joining one of the legacy Core groups writes it beside `extends_with`, for the readers
7
- * that still check it; leaving any group removes it. Replaced by `extends_with` alone. Removal:
8
- * Shell major (delivery step 5) after 1Pact step 4.
5
+ * @deprecated The legacy `member_of_group` Edges a change of a Shell group's members also converges
6
+ * (L-SHELL-8), read in one go for all the members: joining one of the legacy Core groups writes it
7
+ * beside `extends_with`, for the readers that still check it; leaving any group removes it.
8
+ * Replaced by `extends_with` alone. Removal: Shell major (delivery step 5) after 1Pact step 4.
9
9
  */
10
- export async function legacyMembershipEdges(context, membership, present) {
11
- const observed = await context.query(legacyReadMemberOfGroup, membership);
12
- let wanted = false;
13
- if (present) {
14
- const legacyGroups = Object.values(await context.query(legacyReadCoreGroups, {}));
15
- wanted = observed.present || legacyGroups.includes(membership.group);
16
- }
17
- return [{ edge: 'legacy', current: observed.present, present: wanted, guard: observed.guard }];
10
+ export async function legacyMembersEdges(context, selection, present) {
11
+ const [observed, legacyGroups] = await Promise.all([
12
+ context.query(legacyReadMembersOfGroup, selection),
13
+ present ? context.query(legacyReadCoreGroups, {}) : undefined,
14
+ ]);
15
+ const core = legacyGroups !== undefined && Object.values(legacyGroups).includes(selection.group);
16
+ return {
17
+ present: observed.present,
18
+ guard: observed.guard,
19
+ /** Whether a member is to keep `member_of_group`: never when it leaves the group. */
20
+ wanted: (member) => present && (core || observed.present.has(member)),
21
+ };
18
22
  }
19
23
  /**
20
24
  * @deprecated A Domain that extends Group changes the members of its own groups (L-SHELL-9): Shell
@@ -42,10 +46,10 @@ export async function legacyChangeForeignMembership(context, membership, present
42
46
  return { endpoints: endpoints.guard, edges };
43
47
  };
44
48
  const matches = (edges) => edges.every(({ current, present: wanted }) => current === wanted);
45
- const current = await context.step.run('observe-foreign-membership', observe);
49
+ const current = await observe();
46
50
  if (matches(current.edges))
47
51
  return {};
48
- const result = await context.step.run('mutate-foreign-membership', () => caller.mutate(setGroupMembership, { ...membership, ...current }));
52
+ const result = await caller.mutate(setGroupMembership, { ...membership, ...current });
49
53
  if (result.ok)
50
54
  return {};
51
55
  if (result.outcomeKnown) {
@@ -53,7 +57,7 @@ export async function legacyChangeForeignMembership(context, membership, present
53
57
  throw context.error('GROUP_MEMBERSHIP_CYCLE', {});
54
58
  throw context.error('GROUP_MEMBERSHIP_CONFLICT', { cause: result.code });
55
59
  }
56
- const recovered = await context.step.run('recover-foreign-membership', observe);
60
+ const recovered = await observe();
57
61
  if (matches(recovered.edges))
58
62
  return {};
59
63
  throw context.error('GROUP_MEMBERSHIP_UNCONFIRMED', { cause: result.code, outcomeKnown: false });
@@ -1,40 +1,35 @@
1
1
  import { authority } from '../authority/index.js';
2
2
  import { isProvidedGroup } from './group.js';
3
3
  /**
4
- * G2, G3 and acyclicity for one change of a Shell group's members. A provided group changes its
5
- * members only through its directory, a local one through `group.writer`; a group that holds a
6
- * Role, directly or through what it extends, only by an admin or its directory; and a member never
7
- * joins a group it already holds.
4
+ * G2 and G3 for one change of a Shell group's members. A provided group changes its members only
5
+ * through its directory, a local one through `group.writer`; a group that holds a Role, directly or
6
+ * through what it extends, only by an admin or its directory. A member that would join a group it
7
+ * already holds is refused by the Kernel, which keeps `extends_with` acyclic. What the caller holds
8
+ * is asked at once; the group's composition is walked only for a caller who is no admin.
8
9
  */
9
- export async function membershipRefusal(capabilities, group, member, joining) {
10
- const authorities = authority(capabilities);
11
- const provided = isProvidedGroup(group);
12
- if (provided) {
13
- if (!(await authorities.callerHolds('directory.provisioner'))) {
14
- return { code: 'GROUP_DIRECTORY_MANAGED' };
15
- }
10
+ export async function membershipRefusal(capabilities, group, authorities = authority(capabilities)) {
11
+ if (isProvidedGroup(group)) {
12
+ return (await authorities.callerHolds('directory.provisioner'))
13
+ ? undefined
14
+ : { code: 'GROUP_DIRECTORY_MANAGED' };
16
15
  }
17
- else if (!(await authorities.callerHolds('group.writer'))) {
16
+ const [writer, admin] = await Promise.all([
17
+ authorities.callerHolds('group.writer'),
18
+ authorities.callerHolds('workspace.admin'),
19
+ ]);
20
+ if (!writer)
18
21
  return { code: 'GROUP_NOT_DIRECTORY_MANAGED' };
19
- }
20
- if (!provided) {
21
- const role = await roleProtected(capabilities, group);
22
- if (role !== undefined)
23
- return { code: 'GROUP_ROLE_PROTECTED', role };
24
- }
25
- if (joining && (await authorities.reaches(group.id, member))) {
26
- return { code: 'GROUP_MEMBERSHIP_CYCLE' };
27
- }
28
- return undefined;
22
+ if (admin)
23
+ return undefined;
24
+ const role = await authorities.heldRole(group.id);
25
+ return role === undefined ? undefined : { code: 'GROUP_ROLE_PROTECTED', role };
29
26
  }
30
27
  /**
31
28
  * G3: the Role a group holds, when the caller is no admin and so may not change who holds it,
32
- * through its members or by deleting it.
29
+ * through its members or by deleting it. An admin is never refused, so its composition is not read.
33
30
  */
34
- export async function roleProtected(capabilities, group) {
35
- const authorities = authority(capabilities);
36
- const role = await authorities.heldRole(group.id);
37
- if (role === undefined || (await authorities.callerHolds('workspace.admin')))
31
+ export async function roleProtected(capabilities, group, authorities = authority(capabilities)) {
32
+ if (await authorities.callerHolds('workspace.admin'))
38
33
  return undefined;
39
- return role;
34
+ return authorities.heldRole(group.id);
40
35
  }