@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.
- package/dist/domain.d.ts +24 -2
- package/dist/domain.js +13 -1
- package/dist/functions/authority/roles.js +45 -30
- package/dist/functions/group/assign-member.js +6 -3
- package/dist/functions/group/change-group-membership.js +86 -39
- package/dist/functions/group/group-membership-errors.js +1 -1
- package/dist/functions/group/index.js +2 -2
- package/dist/functions/group/legacy/index.js +1 -1
- package/dist/functions/group/legacy/membership.js +21 -17
- package/dist/functions/group/membership-laws.js +5 -8
- package/dist/functions/group/unassign-member.js +6 -3
- package/dist/functions/invitation/send-invitation.js +57 -7
- package/dist/mutations/group/index.js +1 -1
- package/dist/mutations/group/set-group-membership.js +45 -13
- package/dist/mutations/invitation/write-invitation.js +19 -2
- package/dist/queries/composition/index.js +1 -1
- package/dist/queries/composition/read-role-nodes.js +48 -13
- package/dist/queries/group/index.js +1 -0
- package/dist/queries/group/legacy/index.js +1 -0
- package/dist/queries/group/legacy/read-members-of-group.js +27 -0
- package/dist/queries/group/read-group-members.js +73 -0
- package/dist/queries/invitation/index.js +1 -1
- package/dist/queries/invitation/read-invitation.js +33 -8
- package/dist/queries/role/index.js +1 -1
- package/dist/queries/role/read-role-node.js +8 -0
- package/dist/runtime.d.ts +2172 -64
- package/dist/runtime.js +3 -1
- package/dist/schema/modules/application/legacy/functions/list-entrypoints.js +5 -4
- package/dist/schema/modules/group/classes/group-profile-link.d.ts +1 -1
- package/dist/schema/modules/group/classes/group.d.ts +14 -0
- package/dist/schema/modules/group/classes/group.js +12 -2
- package/dist/schema/modules/group/functions/assign-members.js +17 -0
- package/dist/schema/modules/group/functions/index.js +2 -0
- package/dist/schema/modules/group/functions/unassign-members.js +17 -0
- package/dist/schema/modules/group/types/group-membership-input.js +8 -0
- package/dist/schema/modules/group/types/index.js +1 -1
- package/dist/schema/modules/invitation/classes/invitation.js +4 -2
- package/dist/schema/modules/role/core/roles.d.ts +2 -0
- package/dist/schema/modules/role/core/roles.js +1 -0
- package/dist/schema/schema.d.ts +95 -8
- 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
|
-
|
|
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 {
|
|
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
|
-
/**
|
|
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
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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 =
|
|
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 {
|
|
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
|
-
|
|
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 {
|
|
2
|
-
import {
|
|
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 {
|
|
8
|
+
import { legacyChangeForeignMembership, legacyMembersEdges } from './legacy/index.js';
|
|
8
9
|
import { membershipRefusal } from './membership-laws.js';
|
|
9
10
|
/**
|
|
10
|
-
* Add or withdraw
|
|
11
|
-
* group (G1), under G2
|
|
12
|
-
*
|
|
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
|
|
17
|
+
export async function changeGroupMembers(context, input, present, { checkMembers = true } = {}) {
|
|
15
18
|
const invalid = () => context.error('INVALID_GROUP_MEMBERSHIP_REFERENCE', {});
|
|
16
|
-
const
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
const
|
|
21
|
-
|
|
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
|
-
|
|
27
|
-
const
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
return
|
|
31
|
-
}
|
|
32
|
-
if (
|
|
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
|
-
|
|
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.
|
|
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
|
|
62
|
-
if (
|
|
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: '
|
|
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,
|
|
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`
|
|
6
|
-
* (L-SHELL-8): joining one of the legacy Core groups writes it
|
|
7
|
-
* that still check it; leaving any group removes it.
|
|
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
|
|
11
|
-
const observed = await
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
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
|
|
49
|
+
const current = await observe();
|
|
46
50
|
if (matches(current.edges))
|
|
47
51
|
return {};
|
|
48
|
-
const result = await
|
|
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
|
|
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
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
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
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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';
|