diff --git a/src/MeshWeaver.AI/Data/Skill/create-group.md b/src/MeshWeaver.AI/Data/Skill/create-group.md new file mode 100644 index 000000000..29515c018 --- /dev/null +++ b/src/MeshWeaver.AI/Data/Skill/create-group.md @@ -0,0 +1,152 @@ +--- +nodeType: Skill +name: /create-group +description: Create an access-control Group and put people in it — the group node, granting the group access to a space (one AccessAssignment whose subject is the group), adding members who already exist, and inviting members who don't yet (an email invite that lands group membership the moment they sign in, via the durable AddToGroup event subscription). Covers placement (one partition), the exact node shapes, and verification. +icon: People +category: Skills +order: 12 +--- + +You are creating an **access-control Group** and populating it. A Group is a platform-shipped +`AccessObject` node; membership + access are **data** (like [/access](/access)): you grant the *group* +access once, then every member inherits it. Membership resolves through the permission matview's +group-expansion — so **the group, its memberships, and its space-grant must all live under the SAME +partition** (the matview resolves group access within a schema). Put the group *under the space* it serves. + +# The model — grant the group once, add members forever + +``` +{Space}/{Group} ← the Group node (nodeType: Group) +{Space}/{Group}/{member}_Membership ← one GroupMembership per member (a CHILD of the group) +{Space}/_Access/{Group}_Access ← ONE AccessAssignment: accessObject = the group PATH +``` + +- **The group's `accessObject` in the grant is the group's PATH** (`{Space}/{Group}`), and each + membership's `groups[].group` is that **same path**. They must match exactly — that string is the join key. +- **`member` is the userId** — the User node's id (its partition-root id, e.g. `rbuergi`), the same value + `_Access` grants use as `accessObject`. NOT the email, NOT a display name. +- Everything under `{Space}` so it lands in the space's Postgres schema. A group in another partition + will not resolve access to this space. + +# Recipe 1 — create the group (under the space it serves) + +```bash +mcp create --node '{ + "id": "Team", "namespace": "YouTube", "name": "YouTube Team", + "nodeType": "Group", + "content": { "$type": "AccessObject", "description": "Collaborators on the YouTube space" } +}' +``` + +# Recipe 2 — grant the group access to the space (do this ONCE) + +An `AccessAssignment` at `{Space}/_Access` whose **subject is the group path**. Members inherit this. + +```bash +mcp create --node '{ + "id": "Team_Access", "namespace": "YouTube/_Access", "name": "YouTube Team — Editor", + "nodeType": "AccessAssignment", "mainNode": "YouTube", + "content": { + "$type": "AccessAssignment", + "accessObject": "YouTube/Team", "displayName": "YouTube Team", + "roles": [ { "$type": "RoleAssignment", "role": "Editor" } ] + } +}' +``` + +`mainNode` MUST equal the scope (`YouTube`) — an empty `mainNode` is silently ignored (see [/access](/access)). +Role is `Admin` / `Editor` / `Viewer` / `Commenter` or a custom `Role` id. + +# Recipe 3 — add a member who ALREADY has an account + +A `GroupMembership` node as a **child of the group**, id `{userId}_Membership`, `groups[].group` = the group path: + +```bash +mcp create --node '{ + "id": "rbuergi_Membership", "namespace": "YouTube/Team", "name": "rbuergi — Team", + "nodeType": "GroupMembership", + "content": { + "$type": "GroupMembership", + "member": "rbuergi", "displayName": "rbuergi", + "groups": [ { "$type": "MembershipEntry", "group": "YouTube/Team" } ] + } +}' +``` + +The creator/owner adds themselves this way. For any other person who has logged in before, use their +exact userId (their partition-root id — find it with `search nodeType:User content.email:{email}`). + +# Recipe 4 — invite someone who does NOT have an account yet (durable onboarding) + +Two nodes, both in the **Admin** partition. The `Invitation` triggers the email; the `EventSubscription` +(an `AddToGroup` continuation) adds them to the group the moment their `User` node is created — surviving +restarts (the `EventSubscriptionRunner` reconciles on boot). Both are idempotent by deterministic id. + +```bash +# (a) the invitation — InvitationEmailSender emails any Pending invitation (see caveat below) +mcp create --node '{ + "id": "beat_panimage_ch", "namespace": "Admin/Invitation", "name": "Invitation beat@panimage.ch", + "nodeType": "Invitation", + "content": { "$type": "Invitation", "email": "beat@panimage.ch", "invitedBy": "rbuergi", + "note": "Invited to group YouTube/Team" } +}' + +# (b) the durable subscriber — add to the group on sign-up. Id: addgroup_{slug(email)}_{slug(groupPath)} +mcp create --node '{ + "id": "addgroup_beat_panimage_ch_youtube_team", "namespace": "Admin/EventSubscription", + "name": "NodeChange → AddToGroup", "nodeType": "EventSubscription", + "content": { + "$type": "EventSubscription", + "id": "addgroup_beat_panimage_ch_youtube_team", + "triggerType": "NodeChange", "triggerNodeType": "User", "triggerKind": "Created", + "matchField": "email", "matchValue": "beat@panimage.ch", + "continuationType": "AddToGroup", "targetPath": "YouTube/Team", + "createdBy": "rbuergi" + } +}' +``` + +- **`slug(x)`** = lowercase, every non-alphanumeric → `_` (so `beat@panimage.ch` → `beat_panimage_ch`, + `YouTube/Team` → `youtube_team`). The invitation node id is `slug(email)`; a re-invite upserts the same + nodes instead of duplicating. +- Enums serialise by **name**: `"triggerType": "NodeChange"`, `"triggerKind": "Created"`, + `"continuationType": "AddToGroup"`. +- **The invitation is what unlocks onboarding** in invitation-only mode AND what sends the email — + create it even if you only care about the durable grant. + +**Code equivalent (one call does both branches):** `hub.InviteToGroup(groupPath, email, invitedBy)` — +existing user → membership now; unknown → invitation + `AddToGroup` subscription. Returns +`GroupInviteOutcome.Added` / `.Invited`. It is the group twin of `SpaceInviteService.Invite`. + +# 🚨 Email caveat — verify it actually sends + +The invitation email only goes out when the portal has **`Email:Enabled = true`** (Microsoft Graph +`Mail.Send`). It defaults to **false** → `NoOpEmailSender` just logs. If email is off, the invitation +still authorises onboarding and the durable grant still fires — but **no email is sent**; tell the user +rather than reporting a phantom send. Check the deployment's `Email__Enabled`. + +# Verify — never declare done without this + +1. `mcp get @{Space}/{Group}` → the group exists. +2. `mcp get @{Space}/_Access/{Group}_Access` → the grant exists with `mainNode == {Space}` and the group + path as `accessObject`. +3. Members: `search namespace:{Space}/{Group} nodeType:GroupMembership` lists the memberships. +4. Invitees: `get @Admin/Invitation/{slug}` (Pending) AND + `get @Admin/EventSubscription/addgroup_{slug(email)}_{slug(group)}` (Pending → Fired after they onboard). +5. Effect: an existing member reading a node under `{Space}` no longer sees `Access denied` (propagation ~1 s). + +# Pitfalls — each makes membership silently grant nothing + +| Symptom | Cause | +|---|---| +| Member added, still `Access denied` | group's `AccessAssignment` `mainNode` empty, or `accessObject` ≠ the group PATH | +| Member added, still no access | membership's `groups[].group` ≠ the grant's `accessObject` (must be the same path string) | +| Group in another partition doesn't work | group/memberships/grant not co-located in the space's schema — put them all under `{Space}` | +| Invitee never lands in the group | `EventSubscription` not in `Admin/EventSubscription`, or `matchField`/`matchValue` ≠ the User's email | +| No invite email | `Email:Enabled=false` (NoOp) — the grant still works, but nothing was emailed | + +# Related + +- [/access](/access) — the AccessAssignment shape, roles, and the `mainNode` rule this builds on +- [/create-space](/create-space) — create the space the group serves +- [Granting Access via AccessAssignments](/Doc/Architecture/GrantingAccess) · [Access Control Architecture](/Doc/Architecture/AccessControl) diff --git a/src/MeshWeaver.Graph/EventSubscriptionOps.cs b/src/MeshWeaver.Graph/EventSubscriptionOps.cs index 5cc40b0c5..64890612d 100644 --- a/src/MeshWeaver.Graph/EventSubscriptionOps.cs +++ b/src/MeshWeaver.Graph/EventSubscriptionOps.cs @@ -50,6 +50,28 @@ public static IObservable Grant(IMeshService meshService, string userI return meshService.CreateOrUpdateNode(node); } + /// Adds to the group at by creating the + /// {groupPath}/{user}_Membership node (create-or-update = idempotent). + /// The membership node is a child of the group so it lands in the group's partition schema, where the + /// permission matview's group-expansion CTE reads it (content.member + content.groups[].group) + /// and joins the group's AccessAssignment — so the user inherits whatever the group is granted. + public static IObservable AddToGroup(IMeshService meshService, string userId, string groupPath) + { + var idSafe = userId.Replace('/', '_').Replace('@', '_'); + var node = new MeshNode($"{idSafe}_Membership", groupPath) + { + NodeType = GroupMembershipNodeType.NodeType, + Name = $"{userId} — {groupPath.Split('/').LastOrDefault()}", + Content = new GroupMembership + { + Member = userId, + DisplayName = userId, + Groups = [new MembershipEntry { Group = groupPath }], + }, + }; + return meshService.CreateOrUpdateNode(node); + } + /// Adds to 's pinned dashboard list (idempotent). public static IObservable Pin(IMessageHub hub, string userId, string path) => hub.GetWorkspace().GetMeshNodeStream(userId).Update(node => diff --git a/src/MeshWeaver.Graph/EventSubscriptionRunner.cs b/src/MeshWeaver.Graph/EventSubscriptionRunner.cs index efe6906f4..f4040a2e4 100644 --- a/src/MeshWeaver.Graph/EventSubscriptionRunner.cs +++ b/src/MeshWeaver.Graph/EventSubscriptionRunner.cs @@ -261,6 +261,9 @@ private IObservable BuildContinuation(EventSubscription subscription, .SelectMany(g => subscription.Pin ? AsSystem(() => EventSubscriptionOps.Pin(hub, userId, subscription.TargetPath!)).Select(_ => g) : Observable.Return(g)), + EventContinuationType.AddToGroup + when subscription is { TargetPath.Length: > 0 } && !string.IsNullOrEmpty(userId) => + AsSystem(() => EventSubscriptionOps.AddToGroup(meshService, userId, subscription.TargetPath!)), _ => Observable.Throw(new InvalidOperationException( $"Unsupported or incomplete event subscription {subscription.Id}")), }; diff --git a/src/MeshWeaver.Graph/GroupInviteExtensions.cs b/src/MeshWeaver.Graph/GroupInviteExtensions.cs new file mode 100644 index 000000000..9c4210a2c --- /dev/null +++ b/src/MeshWeaver.Graph/GroupInviteExtensions.cs @@ -0,0 +1,102 @@ +using System.Reactive.Linq; +using MeshWeaver.Data; +using MeshWeaver.Graph.Configuration; +using MeshWeaver.Mesh; +using MeshWeaver.Mesh.Services; +using MeshWeaver.Messaging; +using Microsoft.Extensions.DependencyInjection; + +namespace MeshWeaver.Graph; + +/// The result of an call. +public enum GroupInviteOutcome +{ + /// The email already had an account — the user was added to the group immediately. + Added, + /// The email is not on the system yet — an invitation was created and a durable + /// will add them to the group the moment their account is created. + Invited, +} + +/// +/// Invites a person (by email) to a group — the group twin of , +/// composed from existing pieces. Stateless, so a static extension rather than a DI service: the two deps +/// (, ) are resolved off +/// per the "static handlers for one-shot pipelines" rule. +/// +/// Already on the system → add them to the group now (a GroupMembership node) via +/// — runs under the CALLER's identity. +/// Not yet on the system → create an Invitation (the existing +/// InvitationEmailSender emails any Pending invitation; in invitation-only mode it also unlocks +/// onboarding) AND an whose +/// continuation adds them to the group the moment a User with that email is created — surviving +/// restarts via EventSubscriptionRunner. The Admin-partition writes run as system. +/// +/// Grant the group its access ONCE (an AccessAssignment at {scope}/_Access whose +/// accessObject is the group path); every member then inherits it — membership resolves through the +/// permission matview's group-expansion CTE. Keep the group, its memberships, and its grant under the same +/// partition (the matview resolves group access within a schema). +/// +public static class GroupInviteExtensions +{ + /// Invites to the group at . Adds the + /// user now if they already exist, otherwise invites + schedules the durable add-on-sign-up. + public static IObservable InviteToGroup( + this IMessageHub hub, string groupPath, string email, string? invitedBy) + { + var meshService = hub.ServiceProvider.GetRequiredService(); + var accessService = hub.ServiceProvider.GetRequiredService(); + var normalizedEmail = email.Trim(); + + // Look up an existing account by email (one-shot initial snapshot). + return meshService.Query(MeshQueryRequest.FromQuery($"nodeType:User content.email:{normalizedEmail}")) + .Where(c => c.ChangeType == QueryChangeType.Initial) + .Select(c => c.Items) + .Take(1) + .SelectMany(users => + { + var existing = users.FirstOrDefault(u => EmailMatches(hub, u, normalizedEmail)); + return existing is not null + ? EventSubscriptionOps.AddToGroup(meshService, existing.Id, groupPath) + .Select(_ => GroupInviteOutcome.Added) + : ScheduleAndInvite(meshService, accessService, groupPath, normalizedEmail, invitedBy); + }); + } + + private static IObservable ScheduleAndInvite( + IMeshService meshService, AccessService accessService, string groupPath, string email, string? invitedBy) + { + var subscription = new EventSubscription + { + // Deterministic id per invitee+group → a re-invite upserts the SAME subscription (idempotent). + Id = $"addgroup_{SpaceInviteService.Slug(email)}_{SpaceInviteService.Slug(groupPath)}", + TriggerType = EventTriggerType.NodeChange, + TriggerNodeType = "User", + TriggerKind = MeshChangeKind.Created, + MatchField = "email", + MatchValue = email, + ContinuationType = EventContinuationType.AddToGroup, + TargetPath = groupPath, + CreatedBy = invitedBy, + }; + var invitation = new MeshNode(SpaceInviteService.Slug(email), InvitationNodeType.Namespace) + { + NodeType = InvitationNodeType.NodeType, + Name = $"Invitation {email}", + Content = new Invitation { Email = email, InvitedBy = invitedBy, Note = $"Invited to group {groupPath}" }, + }; + + // Both writes land in the Admin partition → system identity, constructed inside the scope. Both are + // upserts keyed by a deterministic id/slug, so a re-invite overwrites rather than duplicating. + return Observable.Using(accessService.ImpersonateAsSystem, _ => Observable.Defer(() => + EventSubscriptionOps.CreateSubscription(meshService, subscription) + .SelectMany(_ => meshService.CreateOrUpdateNode(invitation)))) + .Select(_ => GroupInviteOutcome.Invited); + } + + private static bool EmailMatches(IMessageHub hub, MeshNode userNode, string email) + { + var actual = EventSubscriptionOps.ReadContentField(userNode, "email", hub.JsonSerializerOptions); + return string.Equals(actual, email, StringComparison.OrdinalIgnoreCase); + } +} diff --git a/src/MeshWeaver.Mesh.Contract/EventSubscription.cs b/src/MeshWeaver.Mesh.Contract/EventSubscription.cs index 43f7ace82..983d38d71 100644 --- a/src/MeshWeaver.Mesh.Contract/EventSubscription.cs +++ b/src/MeshWeaver.Mesh.Contract/EventSubscription.cs @@ -39,7 +39,12 @@ public enum EventContinuationType GrantSpaceAccess = 0, /// Post the watched node's summary back into the thread at /// as a new round (the durable delegation backstop, stage 5). - PostThreadMessage = 1 + PostThreadMessage = 1, + /// Add the triggering user to the group at (a + /// GroupMembership node). The group-invite twin of : an email + /// invite to a group that lands membership — and, transitively, whatever the group is granted — + /// the moment the invitee's account exists. + AddToGroup = 2 } /// @@ -117,7 +122,7 @@ public record EventSubscription public EventContinuationType ContinuationType { get; init; } = EventContinuationType.GrantSpaceAccess; /// The node the continuation targets — for - /// the Space path. + /// the Space path, for the Group path. public string? TargetPath { get; init; } /// The subject the continuation acts on when the trigger carries no node (e.g. a diff --git a/test/MeshWeaver.AI.Test/SkillNodeTypeTest.cs b/test/MeshWeaver.AI.Test/SkillNodeTypeTest.cs index bb0017086..b3e017bc0 100644 --- a/test/MeshWeaver.AI.Test/SkillNodeTypeTest.cs +++ b/test/MeshWeaver.AI.Test/SkillNodeTypeTest.cs @@ -29,7 +29,7 @@ public void BuiltInSkillProvider_ShipsStandardSkills_AsPickSkillNodes() var skills = nodes.Where(n => n.NodeType == SkillNodeType.NodeType).ToList(); skills.Should().OnlyContain(n => n.Namespace == SkillNodeType.RootNamespace); - skills.Select(n => n.Id).OrderBy(x => x).Should().Equal("access", "agent", "code", "create-space", "harness", "layout-area", "maui", "model", "navigate", "provider-keys", "slide"); + skills.Select(n => n.Id).OrderBy(x => x).Should().Equal("access", "agent", "code", "create-group", "create-markdown", "create-space", "harness", "layout-area", "maui", "model", "navigate", "provider-keys", "slide"); var def = (SkillDefinition)skills.Single(n => n.Id == "model").Content!; def.Action!.Kind.Should().Be(SkillActionKind.Pick); diff --git a/test/MeshWeaver.Graph.Test/EventSubscriptionRunnerTest.cs b/test/MeshWeaver.Graph.Test/EventSubscriptionRunnerTest.cs index 4646f631c..9f6c2379e 100644 --- a/test/MeshWeaver.Graph.Test/EventSubscriptionRunnerTest.cs +++ b/test/MeshWeaver.Graph.Test/EventSubscriptionRunnerTest.cs @@ -105,6 +105,72 @@ await Mesh.GetWorkspace().GetMeshNodeStream(InviteeId) .FirstAsync().Timeout(10.Seconds()); } + [Fact(Timeout = 60000)] + public async Task PendingAddToGroup_FiresWhenMatchingUserIsCreated() + { + var meshService = Mesh.ServiceProvider.GetRequiredService(); + var changeFeed = Mesh.ServiceProvider.GetRequiredService(); + var accessService = Mesh.ServiceProvider.GetRequiredService(); + var runnerLogger = Mesh.ServiceProvider + .GetService>(); + + // A group under the Space (so a membership lands in the same partition schema as the group). + var groupPath = $"{Space}/Team"; + using (accessService.ImpersonateAsSystem()) + await meshService.CreateNode(new MeshNode("Team", Space) + { + NodeType = "Group", + Name = "Team", + Content = new AccessObject { Description = "Test group" }, + }).Should().Emit(); + + // Start the runner BEFORE the triggering write so the live change-feed path is armed. + using var runner = new EventSubscriptionRunner(Mesh, changeFeed, meshService, accessService, runnerLogger); + await runner.StartAsync(default); + + // A pending subscription: when a User with email == InviteeEmail is created, add them to the group. + // (What the group-invite feature writes for an invited, not-yet-onboarded person.) + var subscription = new EventSubscription + { + TriggerType = EventTriggerType.NodeChange, + TriggerNodeType = "User", + TriggerKind = MeshChangeKind.Created, + MatchField = "email", + MatchValue = InviteeEmail, + ContinuationType = EventContinuationType.AddToGroup, + TargetPath = groupPath, + }; + await EventSubscriptionOps.CreateSubscription(meshService, subscription).Should().Emit(); + + // The invitee onboards — their User node is created. + using (accessService.ImpersonateAsSystem()) + { + await meshService.CreateNode(new MeshNode(InviteeId) + { + NodeType = "User", + Name = "Newcomer", + Content = new User { Email = InviteeEmail, FullName = "Newcomer" }, + }).Should().Emit(); + } + + // The subscription reaches its terminal state — Fired. + var final = await Mesh.GetWorkspace().GetMeshNodeStream(EventSubscriptionNodeType.Path(subscription.Id)) + .Select(n => n?.Content as EventSubscription) + .Where(s => s is not null and not { Status: EventSubscriptionStatus.Pending }) + .FirstAsync().Timeout(40.Seconds()); + Assert.True(final!.Status == EventSubscriptionStatus.Fired, + $"subscription ended {final.Status}: {final.LastError}"); + + // The membership landed: {groupPath}/{user}_Membership carries Member == userId and the group entry. + var membershipPath = $"{groupPath}/{InviteeId}_Membership"; + var membership = await Mesh.GetWorkspace().GetMeshNodeStream(membershipPath) + .Where(n => n?.Content is GroupMembership gm + && gm.Member == InviteeId + && gm.Groups.Any(e => e.Group == groupPath)) + .FirstAsync().Timeout(10.Seconds()); + Assert.NotNull(membership); + } + [Fact(Timeout = 60000)] public async Task LegacyScheduledAction_IsMigratedAndFires() { diff --git a/test/MeshWeaver.Graph.Test/GroupInviteExtensionsTest.cs b/test/MeshWeaver.Graph.Test/GroupInviteExtensionsTest.cs new file mode 100644 index 000000000..d3fcb80db --- /dev/null +++ b/test/MeshWeaver.Graph.Test/GroupInviteExtensionsTest.cs @@ -0,0 +1,101 @@ +using System.Reactive.Linq; +using MeshWeaver.Data; +using MeshWeaver.Graph.Configuration; +using MeshWeaver.Hosting.Monolith.TestBase; +using MeshWeaver.Mesh; +using MeshWeaver.Mesh.Security; +using MeshWeaver.Mesh.Services; +using MeshWeaver.Messaging; +using Microsoft.Extensions.DependencyInjection; +using Xunit; + +namespace MeshWeaver.Graph.Test; + +/// +/// Tests — the group twin of +/// : an existing account is added to the group immediately (a +/// GroupMembership node); an unknown email is invited (an Invitation node) and scheduled (an +/// with an continuation), so +/// the membership lands automatically when they sign up. +/// +public class GroupInviteExtensionsTest(ITestOutputHelper output) : MonolithMeshTestBase(output) +{ + private const string Space = "GroupSpace"; + private const string GroupPath = Space + "/Team"; + + protected override MeshBuilder ConfigureMesh(MeshBuilder builder) + => ConfigureMeshBase(builder) + .AddMeshNodes(new MeshNode(Space) { Name = "Group Space", NodeType = "Space" }); + + private async Task SeedGroupAsync() + { + var meshService = Mesh.ServiceProvider.GetRequiredService(); + var accessService = Mesh.ServiceProvider.GetRequiredService(); + using (accessService.ImpersonateAsSystem()) + await meshService.CreateNode(new MeshNode("Team", Space) + { + NodeType = "Group", + Name = "Team", + Content = new AccessObject { Description = "Test group" }, + }).Should().Emit(); + } + + [Fact(Timeout = 60000)] + public async Task InviteExistingUser_AddsMembershipImmediately() + { + const string email = "bob@acme.com"; + const string userId = "bob"; + var meshService = Mesh.ServiceProvider.GetRequiredService(); + var accessService = Mesh.ServiceProvider.GetRequiredService(); + + await SeedGroupAsync(); + using (accessService.ImpersonateAsSystem()) + await meshService.CreateNode(new MeshNode(userId) + { + NodeType = "User", + Name = "Bob", + Content = new User { Email = email, FullName = "Bob" }, + }).Should().Emit(); + + // Wait until the account is queryable by email (the extension looks it up that way). + await meshService.Query(MeshQueryRequest.FromQuery($"nodeType:User content.email:{email}")) + .Where(c => c.ChangeType == QueryChangeType.Initial && c.Items.Any(n => n.Id == userId)) + .FirstAsync().Timeout(30.Seconds()); + + var outcome = await Mesh.InviteToGroup(GroupPath, email, invitedBy: "admin") + .FirstAsync().Timeout(30.Seconds()); + Assert.Equal(GroupInviteOutcome.Added, outcome); + + // The membership landed at {group}/{user}_Membership with the group entry. + await Mesh.GetWorkspace().GetMeshNodeStream($"{GroupPath}/{userId}_Membership") + .Where(n => n?.Content is GroupMembership gm + && gm.Member == userId + && gm.Groups.Any(e => e.Group == GroupPath)) + .FirstAsync().Timeout(20.Seconds()); + } + + [Fact(Timeout = 60000)] + public async Task InviteAbsentEmail_SchedulesAddToGroupAndCreatesInvitation() + { + const string email = "carol@acme.com"; + await SeedGroupAsync(); + + var outcome = await Mesh.InviteToGroup(GroupPath, email, invitedBy: "admin") + .FirstAsync().Timeout(30.Seconds()); + Assert.Equal(GroupInviteOutcome.Invited, outcome); + + // An AddToGroup event subscription was created to add this email's User to the group on sign-up. + await Mesh.GetWorkspace().GetQuery("group-inv-subs", + $"path:{EventSubscriptionNodeType.Namespace} scope:children nodeType:{EventSubscriptionNodeType.NodeType}") + .Where(nodes => (nodes ?? []).Any(n => n.Content is EventSubscription s + && s.TriggerType == EventTriggerType.NodeChange + && s.ContinuationType == EventContinuationType.AddToGroup + && s.MatchValue == email && s.TargetPath == GroupPath)) + .FirstAsync().Timeout(30.Seconds()); + + // An Invitation node was created for the email (the InvitationEmailSender emails it). + await Mesh.GetWorkspace().GetMeshNodeStream($"{InvitationNodeType.Namespace}/{SpaceInviteService.Slug(email)}") + .Where(n => n?.Content is Invitation inv && inv.Email == email) + .FirstAsync().Timeout(30.Seconds()); + } +}