@astrale-domains/shell 0.1.7 → 0.1.8

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 (41) hide show
  1. package/dist/domain.d.ts +24 -2
  2. package/dist/domain.js +13 -1
  3. package/dist/functions/authority/roles.js +45 -30
  4. package/dist/functions/group/assign-member.js +6 -3
  5. package/dist/functions/group/change-group-membership.js +86 -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 +5 -8
  11. package/dist/functions/group/unassign-member.js +6 -3
  12. package/dist/functions/invitation/send-invitation.js +57 -7
  13. package/dist/mutations/group/index.js +1 -1
  14. package/dist/mutations/group/set-group-membership.js +45 -13
  15. package/dist/mutations/invitation/write-invitation.js +19 -2
  16. package/dist/queries/composition/index.js +1 -1
  17. package/dist/queries/composition/read-role-nodes.js +48 -13
  18. package/dist/queries/group/index.js +1 -0
  19. package/dist/queries/group/legacy/index.js +1 -0
  20. package/dist/queries/group/legacy/read-members-of-group.js +27 -0
  21. package/dist/queries/group/read-group-members.js +73 -0
  22. package/dist/queries/invitation/index.js +1 -1
  23. package/dist/queries/invitation/read-invitation.js +33 -8
  24. package/dist/queries/role/index.js +1 -1
  25. package/dist/queries/role/read-role-node.js +8 -0
  26. package/dist/runtime.d.ts +2172 -64
  27. package/dist/runtime.js +3 -1
  28. package/dist/schema/modules/application/legacy/functions/list-entrypoints.js +5 -4
  29. package/dist/schema/modules/group/classes/group-profile-link.d.ts +1 -1
  30. package/dist/schema/modules/group/classes/group.d.ts +14 -0
  31. package/dist/schema/modules/group/classes/group.js +12 -2
  32. package/dist/schema/modules/group/functions/assign-members.js +17 -0
  33. package/dist/schema/modules/group/functions/index.js +2 -0
  34. package/dist/schema/modules/group/functions/unassign-members.js +17 -0
  35. package/dist/schema/modules/group/types/group-membership-input.js +8 -0
  36. package/dist/schema/modules/group/types/index.js +1 -1
  37. package/dist/schema/modules/invitation/classes/invitation.js +4 -2
  38. package/dist/schema/modules/role/core/roles.d.ts +2 -0
  39. package/dist/schema/modules/role/core/roles.js +1 -0
  40. package/dist/schema/schema.d.ts +95 -8
  41. package/package.json +2 -2
package/dist/domain.d.ts CHANGED
@@ -326,6 +326,20 @@ declare const frontend: import("@astrale-os/sdk/view").Frontend<import("@astrale
326
326
  readonly mode: 'value';
327
327
  readonly value: Record<string, never>;
328
328
  }, "authorized", true, false, undefined, undefined>>;
329
+ readonly assignMembers: import("@astrale-os/sdk/schema").Method<import("@astrale-os/sdk/schema").CallableProjection<{
330
+ members: string[];
331
+ group: string;
332
+ }, {
333
+ readonly mode: 'value';
334
+ readonly value: Record<string, never>;
335
+ }, "authorized", true, false, undefined, undefined>>;
336
+ readonly unassignMembers: import("@astrale-os/sdk/schema").Method<import("@astrale-os/sdk/schema").CallableProjection<{
337
+ members: string[];
338
+ group: string;
339
+ }, {
340
+ readonly mode: 'value';
341
+ readonly value: Record<string, never>;
342
+ }, "authorized", true, false, undefined, undefined>>;
329
343
  readonly provision: import("@astrale-os/sdk/schema").Method<import("@astrale-os/sdk/schema").CallableProjection<{
330
344
  directory: string;
331
345
  externalId: string;
@@ -369,7 +383,7 @@ declare const frontend: import("@astrale-os/sdk/view").Frontend<import("@astrale
369
383
  readonly abstract: false;
370
384
  readonly depth: 1;
371
385
  readonly properties: "attributes" | "description" | "directory" | "externalId" | "frozen" | "icon" | "iss" | "name" | "sub";
372
- readonly methods: "assignMember" | "create" | "delete" | "deprovision" | "provision" | "unassignMember" | "update";
386
+ readonly methods: "assignMember" | "assignMembers" | "create" | "delete" | "deprovision" | "provision" | "unassignMember" | "unassignMembers" | "update";
373
387
  }[];
374
388
  readonly outgoing: "0..1";
375
389
  };
@@ -427,6 +441,13 @@ declare const frontend: import("@astrale-os/sdk/view").Frontend<import("@astrale
427
441
  readonly description?: string;
428
442
  readonly immutable?: import("@astrale-os/sdk/schema").PropertyImmutability;
429
443
  }>;
444
+ email: import("@astrale-os/sdk/schema").AuthoredProperty<{
445
+ readonly type: "string";
446
+ readonly minLength: 3;
447
+ readonly maxLength: 320;
448
+ }, {
449
+ readonly required: false;
450
+ }>;
430
451
  };
431
452
  readonly policies: {
432
453
  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 +492,7 @@ declare const frontend: import("@astrale-os/sdk/view").Frontend<import("@astrale
471
492
  readonly kind: 'node';
472
493
  readonly abstract: false;
473
494
  readonly depth: 0;
474
- readonly properties: "execution" | "expiresAt" | "round" | "status";
495
+ readonly properties: "email" | "execution" | "expiresAt" | "round" | "status";
475
496
  readonly methods: "accept" | "cancel" | "resend" | "send";
476
497
  }[];
477
498
  readonly outgoing: "0..1";
@@ -651,6 +672,7 @@ declare const frontend: import("@astrale-os/sdk/view").Frontend<import("@astrale
651
672
  readonly 'user-profile.reader': import("@astrale-os/sdk/schema").CoreNode;
652
673
  readonly 'user-profile.self-writer': import("@astrale-os/sdk/schema").CoreNode;
653
674
  readonly 'group-profile.reader': import("@astrale-os/sdk/schema").CoreNode;
675
+ readonly 'os.viewer': import("@astrale-os/sdk/schema").CoreNode;
654
676
  readonly admin: import("@astrale-os/sdk/schema").CoreNode;
655
677
  readonly member: import("@astrale-os/sdk/schema").CoreNode;
656
678
  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,13 @@ 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
+ },
98
110
  { role: K.core.nodes['domain.reader'], granters: [S.core.nodes['workspace.admin']] },
99
111
  { role: K.core.nodes['execution.reader'], granters: [S.core.nodes['workspace.admin']] },
100
112
  { 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,15 @@ 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.
30
+ */
26
31
  export function authority(capabilities) {
27
32
  const S = capabilities.domain;
28
33
  const kernel = S.dependencies.kernel.origin;
29
- let catalogue;
30
- const roles = () => (catalogue ??= capabilities.query(readRoleNodes, {}));
31
34
  const compositions = new Map();
32
35
  const composition = (identity) => {
33
36
  let walked = compositions.get(identity);
@@ -37,22 +40,43 @@ export function authority(capabilities) {
37
40
  }
38
41
  return walked;
39
42
  };
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);
43
+ const held = new Map();
44
+ /** The Core keys of the Roles an Identity holds, `<origin>:<key>`. */
45
+ const heldKeys = (identity) => {
46
+ let keys = held.get(identity);
47
+ if (keys === undefined) {
48
+ keys = (async () => {
49
+ const reached = [...(await composition(identity)).keys()];
50
+ const found = [];
51
+ for (let index = 0; index < reached.length; index += ROLE_STEP) {
52
+ const [first, ...others] = reached.slice(index, index + ROLE_STEP);
53
+ if (first === undefined)
54
+ continue;
55
+ const roles = await capabilities.query(readRoleKeys, { identities: [first, ...others] });
56
+ found.push(...roles.values());
57
+ }
58
+ return found;
59
+ })();
60
+ held.set(identity, keys);
49
61
  }
50
- return ids;
62
+ return keys;
63
+ };
64
+ const nodes = new Map();
65
+ const roleNode = (role) => {
66
+ let node = nodes.get(role);
67
+ if (node === undefined) {
68
+ node = capabilities.query(readRoleId, { role: S.core.nodes[role].ref });
69
+ nodes.set(role, node);
70
+ }
71
+ return node;
72
+ };
73
+ const administrative = (key) => {
74
+ const [origin, name] = [key.slice(0, key.indexOf(':')), key.slice(key.indexOf(':') + 1)];
75
+ return ((origin === S.origin && SHELL_ADMINISTRATIVE.has(name)) ||
76
+ (origin === kernel && !KERNEL_ORDINARY.has(name)));
51
77
  };
52
78
  return {
53
- async roleNode(role) {
54
- return (await roles()).byKey.get(keyOf(S.core.nodes[role].ref));
55
- },
79
+ roleNode,
56
80
  async callerHolds(role) {
57
81
  const caller = capabilities.graph.caller;
58
82
  if (caller === undefined)
@@ -60,9 +84,7 @@ export function authority(capabilities) {
60
84
  return caller.query(readRoleNode, { role: S.core.nodes[role].ref });
61
85
  },
62
86
  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);
87
+ const held = await heldKeys(identity);
66
88
  const admin = keyOf(S.core.nodes['workspace.admin'].ref);
67
89
  const role = held.includes(admin) ? admin : held[0];
68
90
  if (role === undefined)
@@ -70,17 +92,10 @@ export function authority(capabilities) {
70
92
  return role.startsWith(`${S.origin}:`) ? role.slice(S.origin.length + 1) : role;
71
93
  },
72
94
  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);
95
+ return (await heldKeys(identity)).some(administrative);
81
96
  },
82
97
  async activeAdministrators() {
83
- const admin = (await roles()).byKey.get(keyOf(S.core.nodes['workspace.admin'].ref));
98
+ const admin = await roleNode('workspace.admin');
84
99
  if (admin === undefined)
85
100
  return [];
86
101
  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,60 @@
1
- import { setGroupMembership } from '../../mutations/group/index.js';
2
- import { readGroupMembership, readGroupMembershipEndpoints } from '../../queries/group/index.js';
1
+ import { setGroupMemberships } from '../../mutations/group/index.js';
2
+ import { readGroupMembers, readGroupMembersEndpoints } from '../../queries/group/index.js';
3
+ import { readIdentities } from '../../queries/identity/index.js';
3
4
  import { canonicalNodeId } from '../../utils/canonical-node-path/index.js';
4
5
  import { readGroup } from './group.js';
5
6
  // Seam L-SHELL-8, L-SHELL-9: legacy groups also keep `member_of_group`; a Domain's own Group
6
7
  // subclass changes its members under that Domain's authority.
7
- import { legacyMembershipEdges, legacyChangeForeignMembership } from './legacy/index.js';
8
+ import { legacyChangeForeignMembership, legacyMembersEdges } from './legacy/index.js';
8
9
  import { membershipRefusal } from './membership-laws.js';
9
10
  /**
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.
11
+ * Add or withdraw direct members of one Shell group: Kernel `extends_with` from each member to the
12
+ * group (G1), under G2 and G3, checked once for the group. The Kernel refuses a cycle. All the
13
+ * members change in one commit, guarded by what was observed of them; a member already where it is
14
+ * asked to be changes nothing, so a retry is harmless. After an unknown outcome the change is
15
+ * observed again, and an unconfirmed outcome stays explicit.
13
16
  */
14
- export async function changeGroupMembership(context, present) {
17
+ export async function changeGroupMembers(context, input, present, { checkMembers = true } = {}) {
15
18
  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)
19
+ const group = canonicalNodeId(input.group, invalid);
20
+ const [first, ...others] = new Set(input.members.map((member) => canonicalNodeId(member, invalid)));
21
+ if (first === undefined)
22
+ throw invalid();
23
+ const members = [first, ...others];
24
+ // A fixed number of reads, whatever the number of members: one per kind, never one per member.
25
+ const [identities, observed, stored] = await Promise.all([
26
+ // A list of members is not a typed Path input: each must be an account or a group.
27
+ checkMembers ? identitiesOnly(context, members) : true,
28
+ observeMembers(context, { members, group }, present),
29
+ readGroup(context, group),
30
+ ]);
31
+ if (!identities)
32
+ throw invalid();
33
+ if (members.some((member) => !observed.visible.has(member)) || !observed.visible.has(group)) {
22
34
  throw context.error('GROUP_MEMBERSHIP_ENDPOINTS_UNAVAILABLE', {});
23
- if (endpoints.groupClass !== context.domain.classes.Group.key) {
24
- return legacyChangeForeignMembership(context, membership, present);
25
35
  }
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) {
36
+ if (observed.groupClass !== context.domain.classes.Group.key) {
37
+ for (const member of members) {
38
+ await legacyChangeForeignMembership(context, { member, group }, present);
39
+ }
40
+ return {};
41
+ }
42
+ if (stored === undefined)
43
+ throw context.error('GROUP_NOT_FOUND', {});
44
+ const refusal = await membershipRefusal(context, stored);
45
+ if (refusal !== undefined) {
33
46
  if (refusal.code === 'GROUP_ROLE_PROTECTED') {
34
47
  throw context.error(refusal.code, { role: refusal.role });
35
48
  }
36
49
  throw context.error(refusal.code, {});
37
50
  }
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))
51
+ if (observed.changes.length === 0)
52
52
  return {};
53
- const result = await context.step.run('mutate-membership', () => context.mutate(setGroupMembership, { ...membership, ...current }));
53
+ const result = await context.mutate(setGroupMemberships, {
54
+ group,
55
+ guards: observed.guards,
56
+ changes: observed.changes,
57
+ });
54
58
  if (result.ok)
55
59
  return {};
56
60
  if (result.outcomeKnown) {
@@ -58,11 +62,54 @@ export async function changeGroupMembership(context, present) {
58
62
  throw context.error('GROUP_MEMBERSHIP_CYCLE', {});
59
63
  throw context.error('GROUP_MEMBERSHIP_CONFLICT', { cause: result.code });
60
64
  }
61
- const recovered = await context.step.run('recover-membership', observe);
62
- if (matches(recovered.edges))
65
+ const recovered = await observeMembers(context, { members, group }, present);
66
+ if (recovered.changes.length === 0)
63
67
  return {};
64
68
  throw context.error('GROUP_MEMBERSHIP_UNCONFIRMED', {
65
69
  cause: result.code,
66
70
  outcomeKnown: false,
67
71
  });
68
72
  }
73
+ /** Add or withdraw one direct member of a Shell group. */
74
+ export const changeGroupMembership = (context, present) =>
75
+ // The member Path input already restricts its Class to an account or a group.
76
+ changeGroupMembers(context, { group: context.input.group, members: [context.input.member] }, present, { checkMembers: false });
77
+ /** Whether every member is an account or a group, a Domain's subclass included. */
78
+ async function identitiesOnly(context, members) {
79
+ const [first, ...others] = members;
80
+ if (first === undefined)
81
+ return false;
82
+ const ids = [first, ...others];
83
+ const found = await Promise.all([
84
+ context.query(readIdentities, { kind: 'User', ids }),
85
+ context.query(readIdentities, { kind: 'Group', ids }),
86
+ ]);
87
+ const known = new Set(found.flat().map((node) => node.id));
88
+ return members.every((member) => known.has(member));
89
+ }
90
+ /**
91
+ * The members and their membership Edges to the group as they are, in three reads whatever their
92
+ * number, with the guards that keep them so and the Edges to change for the state asked for.
93
+ */
94
+ async function observeMembers(context, selection, present) {
95
+ const [endpoints, authority, legacy] = await Promise.all([
96
+ context.query(readGroupMembersEndpoints, selection),
97
+ context.query(readGroupMembers, selection),
98
+ legacyMembersEdges(context, selection, present),
99
+ ]);
100
+ const changes = [];
101
+ for (const member of selection.members) {
102
+ if (authority.present.has(member) !== present) {
103
+ changes.push({ member, edge: 'authority', present });
104
+ }
105
+ const wanted = legacy.wanted(member);
106
+ if (legacy.present.has(member) !== wanted)
107
+ changes.push({ member, edge: 'legacy', present: wanted });
108
+ }
109
+ return {
110
+ visible: endpoints.visible,
111
+ ...(endpoints.groupClass === undefined ? {} : { groupClass: endpoints.groupClass }),
112
+ guards: [endpoints.guard, authority.guard, legacy.guard],
113
+ changes,
114
+ };
115
+ }
@@ -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,12 +1,12 @@
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.
8
8
  */
9
- export async function membershipRefusal(capabilities, group, member, joining) {
9
+ export async function membershipRefusal(capabilities, group) {
10
10
  const authorities = authority(capabilities);
11
11
  const provided = isProvidedGroup(group);
12
12
  if (provided) {
@@ -22,9 +22,6 @@ export async function membershipRefusal(capabilities, group, member, joining) {
22
22
  if (role !== undefined)
23
23
  return { code: 'GROUP_ROLE_PROTECTED', role };
24
24
  }
25
- if (joining && (await authorities.reaches(group.id, member))) {
26
- return { code: 'GROUP_MEMBERSHIP_CYCLE' };
27
- }
28
25
  return undefined;
29
26
  }
30
27
  /**
@@ -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 unassignMember = defineWorkflow()('Group.unassignMember', { errors: groupMembershipErrors }, (context) => changeGroupMembership(context, false));
4
+ /** Withdraw one account or group from a group, in one read and one write. */
5
+ export const unassignMember = defineAction()('Group.unassignMember', { errors: groupMembershipErrors }, (context) => changeGroupMembership(context, false));
6
+ /** Withdraw several accounts or groups from a group, in one commit. */
7
+ export const unassignMembers = defineAction()('Group.unassignMembers', { errors: groupMembershipErrors }, (context) => changeGroupMembers(context, context.input, false));
@@ -2,6 +2,7 @@ import { defineWorkflow } from '@astrale-os/sdk/workflow';
2
2
  import { inviteErrors, readAccount, userErrors } from '../user/index.js';
3
3
  import { InvitationRequestFailure } from '../../integrations/user-invitation/index.js';
4
4
  import { bindAccount, createInvitation, updateInvitation } from '../../mutations/invitation/index.js';
5
+ import { readInvitation, readPendingInvitations } from '../../queries/invitation/index.js';
5
6
  import { canonicalNodeId } from '../../utils/canonical-node-path/index.js';
6
7
  const errors = { ...inviteErrors, ...userErrors };
7
8
  /** How long an invitation waits for its first sign-in. */
@@ -12,6 +13,10 @@ export const invitationSignal = (round) => `event-${round}`;
12
13
  * Invite an account and keep its invitation until it ends: record it, have Admin send the email,
13
14
  * then wait for the first sign-in (`accept`), a resend, a cancel or the expiry. It grants nothing:
14
15
  * the account holds what `User.create` or an admin gave it.
16
+ *
17
+ * An account has at most one pending invitation. When one is pending for the same email, this run
18
+ * has it resent, with a full time to live again, and ends. For another email, it cancels it and
19
+ * records a new one; Admin replaces its own link when it receives the new request.
15
20
  */
16
21
  export const send = defineWorkflow()('Invitation.send', { errors }, async (context) => {
17
22
  const { step, execution, awaitSignal, cancelled, error } = context;
@@ -24,13 +29,57 @@ export const send = defineWorkflow()('Invitation.send', { errors }, async (conte
24
29
  if (account.email === undefined)
25
30
  throw error('USER_EMAIL_INVALID', {});
26
31
  const email = account.email;
32
+ // Stop the runs of replaced invitations: each finds its invitation closed and ends.
33
+ const stop = (runs) => step.run('stop-replaced', async ({ kernel }) => {
34
+ for (const { execution: ref } of runs) {
35
+ await kernel.execution.cancel(ref).catch(() => undefined);
36
+ }
37
+ });
38
+ const retire = async (invitations) => {
39
+ for (const [index, { id }] of invitations.entries()) {
40
+ await step.mutate(`retire-${index}`, updateInvitation, {
41
+ invitation: id,
42
+ status: 'cancelled',
43
+ });
44
+ }
45
+ await stop(invitations);
46
+ };
47
+ // Close the invitation, unless another run already closed it (a replacement).
48
+ const close = async (name, status) => {
49
+ const current = await step.run(`read-${name}`, ({ query }) => query(readInvitation, { invitation }));
50
+ if (current?.status !== 'pending')
51
+ return { status: 'cancelled' };
52
+ await step.mutate(name, updateInvitation, { invitation, status });
53
+ return { status };
54
+ };
55
+ const pending = await step.run('read-pending', ({ query }) => query(readPendingInvitations, { user }));
56
+ const same = pending.find((previous) => previous.email === email);
57
+ // Its run sends the email again and restarts the time to live. A run that no longer answers
58
+ // has its invitation replaced instead.
59
+ const resent = same !== undefined &&
60
+ (await step.run('resend-pending', ({ kernel }) => kernel.execution
61
+ .signal(same.execution, {
62
+ name: invitationSignal(same.round),
63
+ value: { kind: 'resend' },
64
+ })
65
+ .then(() => true, () => false)));
66
+ if (resent) {
67
+ const others = pending.filter((previous) => previous !== same);
68
+ if (others.length > 0)
69
+ await retire(others);
70
+ return { status: 'pending' };
71
+ }
27
72
  const startedAt = await step.run('clock', () => Date.now());
28
- const expiresAt = startedAt + INVITATION_TTL_MS;
73
+ let expiresAt = startedAt + INVITATION_TTL_MS;
29
74
  const { invitation } = await step.mutate('record-invitation', createInvitation, {
30
75
  user,
31
76
  execution: execution.id,
32
77
  expiresAt: new Date(expiresAt).toISOString(),
78
+ email,
79
+ replaces: pending.map((previous) => previous.id),
33
80
  });
81
+ if (pending.length > 0)
82
+ await stop(pending);
34
83
  const requestedBy = execution.initiator.caller ?? execution.initiator.principal;
35
84
  const sendEmail = async (round) => {
36
85
  try {
@@ -55,27 +104,28 @@ export const send = defineWorkflow()('Invitation.send', { errors }, async (conte
55
104
  const event = await awaitSignal(invitationSignal(round), {
56
105
  timeoutMs: Math.max(expiresAt - now, 1),
57
106
  });
58
- if (event.kind === 'timeout') {
59
- await step.mutate('expire', updateInvitation, { invitation, status: 'expired' });
60
- return { status: 'expired' };
61
- }
107
+ if (event.kind === 'timeout')
108
+ return await close('expire', 'expired');
62
109
  if (event.value.kind === 'accepted') {
63
110
  const { iss, sub } = event.value;
64
111
  await step.mutate('bind-account', bindAccount, { user, iss, sub });
65
112
  await step.mutate('accept', updateInvitation, { invitation, status: 'accepted' });
66
113
  return { status: 'accepted' };
67
114
  }
115
+ // A resend is a new link from Admin, with a full time to live again.
68
116
  await sendEmail(round + 1);
117
+ const resentAt = await step.run(`resent-at-${round + 1}`, () => Date.now());
118
+ expiresAt = resentAt + INVITATION_TTL_MS;
69
119
  await step.mutate(`next-round-${round + 1}`, updateInvitation, {
70
120
  invitation,
71
121
  round: round + 1,
122
+ expiresAt: new Date(expiresAt).toISOString(),
72
123
  });
73
124
  }
74
125
  }
75
126
  catch (cause) {
76
127
  if (!cancelled(cause))
77
128
  throw cause;
78
- await step.mutate('cancel', updateInvitation, { invitation, status: 'cancelled' });
79
- return { status: 'cancelled' };
129
+ return await close('cancel', 'cancelled');
80
130
  }
81
131
  });
@@ -1 +1 @@
1
- export { setGroupMembership } from './set-group-membership.js';
1
+ export { setGroupMembership, setGroupMemberships, } from './set-group-membership.js';