Repository: digidem/comapeo-core
Pull request #1023: Fix/ensure invite config sync
Author: login: RangerMauve
Created: 2025-04-24T15:54:16Z | Merged: 2025-04-29T20:24:14Z

fix #1022 

- inviter waits for initial sync before considering the accepted invite to be a success
- inviter sets the member as "left" if the initial sync failed
- inviters can now safely try again and both sides will know the invite failed
- updated unexpected disconnect test since it's more reliable now

I'm actually pretty happy with this outcome! This doesn't auto-retry but it makes sure the inviter knows it fails and resets the member to a state where they can try inviting them again.

Sadly I wasn't as aware of how roles worked two weeks ago and wasn't aware this fix was an option

Linked issue #1022: Invites: Account for initial sync failing
## Description

Sometimes inviting fails in the middle of syncing the project after it's been accepted and the invitee's role was set. We should account for this failure.

## Tasks

- [x] Have inviter wait for initial sync before marking the invite as complete
- [x] On failure, mark the role as LEFT
Linked issue comments: user: login: RangerMauve
created_at: 2025-04-24T15:17:17Z
body: Regarding the invites issue. I can now make the inviter reliably know that the invitee failed to get onboarded fully. The core issue now is that there's no way to "undo" an invite once we get to the point where the invitee role was getting added.

On the user side this looks like (even if we can detect failure):
- Inviter tries to add invitee
- Something fails during invite (sync doesn't happen fully, but the role for the invitee was set)
- Invitee knows the project sync failed so don't set it as their active project
- Inviter can't invite them again because having a role removes them from the candidate list

With this in mind I think we should address this by having the Inviter set the role of the Invitee to something new or to something like "Left" (assuming members that left may rejoin)
author_association: CONTRIBUTOR

Diff:
diff --git a/src/errors.js b/src/errors.js
index 85102955..8e5612e9 100644
--- a/src/errors.js
+++ b/src/errors.js
@@ -29,6 +29,38 @@ export class InviteAbortedError extends Error {
   }
 }
 
+export class ProjectDetailsSendFailError extends Error {
+  /** @param {string} [message] */
+  constructor(message = 'Project details failed to send') {
+    super(message)
+    this.name = 'ProjectDetailsSendFailError'
+  }
+}
+
+export class InviteInitialSyncFailError extends Error {
+  /** @param {string} [message] */
+  constructor(message = 'Failed to sync config for invite') {
+    super(message)
+    this.name = 'InviteInitialSyncFailError'
+  }
+}
+
+export class RPCDisconnectBeforeSendingError extends Error {
+  /** @param {string} [message] */
+  constructor(message = 'RPC disconnected before sending') {
+    super(message)
+    this.name = 'RPCDisconnectBeforeSendingError'
+  }
+}
+
+export class RPCDisconnectBeforeAckError extends Error {
+  /** @param {string} [message] */
+  constructor(message = 'RPC disconnected before receiving acknowledgement') {
+    super(message)
+    this.name = 'RPCDisconnectBeforeAckError'
+  }
+}
+
 export class TimeoutError extends Error {
   /** @param {string} [message] */
   constructor(message = 'TimeoutError') {
diff --git a/src/local-peers.js b/src/local-peers.js
index 2c66e5ae..ddf336ff 100644
--- a/src/local-peers.js
+++ b/src/local-peers.js
@@ -19,6 +19,10 @@ import {
 import pDefer from 'p-defer'
 import { Logger } from './logger.js'
 import pTimeout, { TimeoutError } from 'p-timeout'
+import {
+  RPCDisconnectBeforeAckError,
+  RPCDisconnectBeforeSendingError,
+} from './errors.js'
 /** @import NoiseStream from '@hyperswarm/secret-stream' */
 /** @import { OpenedNoiseStream } from './lib/noise-secret-stream-helpers.js' */
 /** @import {DeferredPromise} from 'p-defer' */
@@ -191,11 +195,11 @@ class Peer {
     // This promise should have already resolved, but if the peer never connected then we reject here
     this.#connected.reject(new PeerFailedConnectionError())
     for (const listener of this.#drainedListeners) {
-      listener.reject(new Error('RPC Disconnected before sending'))
+      listener.reject(new RPCDisconnectBeforeSendingError())
     }
     for (const waiters of this.#ackWaiters.values()) {
       for (const { deferred } of waiters) {
-        deferred.reject(new Error('RPC disconnected before receiving ACK'))
+        deferred.reject(new RPCDisconnectBeforeAckError())
       }
     }
     this.#ackWaiters.clear()
diff --git a/src/member-api.js b/src/member-api.js
index a5b754e3..e03d85c3 100644
--- a/src/member-api.js
+++ b/src/member-api.js
@@ -17,10 +17,15 @@ import { abortSignalAny } from './lib/ponyfills.js'
 import timingSafeEqual from 'string-timing-safe-equal'
 import { isHostnameIpAddress } from './lib/is-hostname-ip-address.js'
 import { ErrorWithCode, getErrorMessage } from './lib/error.js'
-import { InviteAbortedError } from './errors.js'
+import {
+  InviteAbortedError,
+  InviteInitialSyncFailError,
+  ProjectDetailsSendFailError,
+} from './errors.js'
 import { wsCoreReplicator } from './lib/ws-core-replicator.js'
 import {
   BLOCKED_ROLE_ID,
+  FAILED_ROLE_ID,
   MEMBER_ROLE_ID,
   ROLES,
   isRoleIdForNewInvite,
@@ -200,16 +205,34 @@ export class MemberApi extends TypedEmitter {
         case InviteResponse_Decision.DECISION_UNSPECIFIED:
           return InviteResponse_Decision.REJECT
         case InviteResponse_Decision.ACCEPT:
-          await this.#rpc.sendProjectJoinDetails(deviceId, {
-            inviteId,
-            projectKey: this.#projectKey,
-            encryptionKeys: this.#encryptionKeys,
-          })
-
-          // Only add after we know they got the details
-          // Otherwise the joiner will be stuck unable to join
+          // Technically we can assign after sending project details
+          // This lets us test role removal
           await this.#roles.assignRole(deviceId, roleId)
 
+          try {
+            await this.#rpc.sendProjectJoinDetails(deviceId, {
+              inviteId,
+              projectKey: this.#projectKey,
+              encryptionKeys: this.#encryptionKeys,
+            })
+          } catch {
+            try {
+              // Mark them as "failed" so we can retry the flow
+              await this.#roles.assignRole(deviceId, FAILED_ROLE_ID)
+            } catch (e) {
+              console.error(e)
+            }
+            throw new ProjectDetailsSendFailError()
+          }
+
+          try {
+            await this.#waitForInitialSyncWithPeer(deviceId, abortSignal)
+          } catch {
+            // Mark them as "failed" so we can retry the flow
+            await this.#roles.assignRole(deviceId, FAILED_ROLE_ID)
+            throw new InviteInitialSyncFailError()
+          }
+
           return inviteResponse.decision
         default:
           throw new ExhaustivenessError(inviteResponse.decision)
diff --git a/src/roles.js b/src/roles.js
index 596f8a45..a7ba506b 100644
--- a/src/roles.js
+++ b/src/roles.js
@@ -12,6 +12,7 @@ export const COORDINATOR_ROLE_ID = 'f7c150f5a3a9a855'
 export const MEMBER_ROLE_ID = '012fd2d431c0bf60'
 export const BLOCKED_ROLE_ID = '9e6d29263cba36c9'
 export const LEFT_ROLE_ID = '8ced989b1904606b'
+export const FAILED_ROLE_ID = 'a24eaca65ab5d5d0'
 export const NO_ROLE_ID = '08e4251e36f6e7ed'
 
 /**
@@ -27,6 +28,7 @@ const ROLE_IDS = new Set(
     MEMBER_ROLE_ID,
     BLOCKED_ROLE_ID,
     LEFT_ROLE_ID,
+    FAILED_ROLE_ID,
     NO_ROLE_ID,
   ])
 )
@@ -51,6 +53,7 @@ const ROLE_IDS_ASSIGNABLE_TO_ANYONE = new Set(
     MEMBER_ROLE_ID,
     BLOCKED_ROLE_ID,
     LEFT_ROLE_ID,
+    FAILED_ROLE_ID,
   ])
 )
 const isRoleIdAssignableToAnyone = setHas(ROLE_IDS_ASSIGNABLE_TO_ANYONE)
@@ -126,6 +129,34 @@ const BLOCKED_ROLE = {
   },
 }
 
+/**
+ * This role is used for devices that failed to sync after accepting an invite and being added
+ * @type {Role<typeof FAILED_ROLE_ID>}
+ */
+const FAILED_ROLE = {
+  roleId: FAILED_ROLE_ID,
+  name: 'Failed',
+  docs: mapObject(currentSchemaVersions, (key) => {
+    return [
+      key,
+      {
+        readOwn: false,
+        writeOwn: false,
+        readOthers: false,
+        writeOthers: false,
+      },
+    ]
+  }),
+  roleAssignment: [],
+  sync: {
+    auth: 'blocked',
+    config: 'blocked',
+    data: 'blocked',
+    blobIndex: 'blocked',
+    blob: 'blocked',
+  },
+}
+
 /**
  * This is the role assumed for a device when no role record can be found. This
  * can happen when an invited device did not manage to sync with the device that
@@ -219,6 +250,7 @@ export const ROLES = {
     },
   },
   [NO_ROLE_ID]: NO_ROLE,
+  [FAILED_ROLE_ID]: FAILED_ROLE,
 }
 
 /**
@@ -380,6 +412,11 @@ export class Roles extends TypedEmitter {
       if (deviceId !== this.#ownDeviceId) {
         throw new Error('Cannot assign LEFT role to another device')
       }
+    } else if (roleId === FAILED_ROLE_ID) {
+      const ownRole = await this.getRole(this.#ownDeviceId)
+      if (!ownRole.roleAssignment.includes(COORDINATOR_ROLE_ID)) {
+        throw new Error('Lacks permission to assign role ' + roleId)
+      }
     } else {
       const ownRole = await this.getRole(this.#ownDeviceId)
       if (!ownRole.roleAssignment.includes(roleId)) {
diff --git a/test-e2e/manager-invite.js b/test-e2e/manager-invite.js
index 9048f39f..cb9cf336 100644
--- a/test-e2e/manager-invite.js
+++ b/test-e2e/manager-invite.js
@@ -9,7 +9,7 @@ import FakeTimers from '@sinonjs/fake-timers'
 import { randomBytes } from 'node:crypto'
 import { noop } from '../src/utils.js'
 
-const { COORDINATOR_ROLE_ID, MEMBER_ROLE_ID } = roles
+const { COORDINATOR_ROLE_ID, CREATOR_ROLE_ID, MEMBER_ROLE_ID } = roles
 
 test('member invite accepted & invite states', async (t) => {
   const [creator, joiner] = await createManagers(2, t)
@@ -637,48 +637,65 @@ test('disconnect before invite accept', async (t) => {
 
 // TODO: It's not possible in e2e tests to disconnect peers at the right moment.
 // This test is flaky, and sometimes the project details are sent before the
-// peers disconnect, so skipping this test for now.
-test(
-  'disconnect before sending project join details',
-  { skip: true },
-  async (t) => {
-    const clock = FakeTimers.install({ shouldAdvanceTime: true })
-    t.after(() => clock.uninstall())
-    const [creator, joiner] = await createManagers(2, t)
-    const disconnectPeers = connectPeers([creator, joiner])
-    t.after(() => disconnectPeers())
-    await waitForPeers([creator, joiner])
-
-    const createdProjectId = await creator.createProject({ name: 'Mapeo' })
-    const creatorProject = await creator.getProject(createdProjectId)
-
-    const inviteReceivedPromise = pEvent(joiner.invite, 'invite-received')
-    const invitePromise = creatorProject.$member.invite(joiner.deviceId, {
-      roleId: MEMBER_ROLE_ID,
-    })
-    const assertInviteRejectsPromise = assert.rejects(
-      invitePromise,
-      {
-        name: 'PeerDisconnectedError',
-        message: 'Peer disconnected before sending project join details',
-      },
-      'invite promise rejects when peer disconnects'
-    )
-    const invite = await inviteReceivedPromise
-    const assertAcceptRejectsPromise = assert.rejects(
-      () => joiner.invite.accept(invite),
-      {
-        name: 'TimeoutError',
-        message: 'Timed out waiting for project details',
-      },
-      'accepting invite times out when project details are not received'
-    )
-    await disconnectPeers()
-    // Run down the timeout waiting for project details
-    clock.runAll()
-    await Promise.all([assertInviteRejectsPromise, assertAcceptRejectsPromise])
-  }
-)
+// peers disconnect, so can't expect specific errors.
+test('disconnect before sending project join details', async (t) => {
+  const clock = FakeTimers.install({ shouldAdvanceTime: true })
+  t.after(() => clock.uninstall())
+  const [creator, joiner] = await createManagers(2, t)
+  const disconnectPeers = connectPeers([creator, joiner])
+  t.after(() => disconnectPeers())
+  await waitForPeers([creator, joiner])
+
+  const createdProjectId = await creator.createProject({ name: 'Mapeo' })
+  const creatorProject = await creator.getProject(createdProjectId)
+
+  const inviteReceivedPromise = pEvent(joiner.invite, 'invite-received')
+  const invitePromise = creatorProject.$member.invite(joiner.deviceId, {
+    roleId: MEMBER_ROLE_ID,
+  })
+  // Specific error can be different due to race conditions
+  const assertInviteRejectsPromise = assert.rejects(
+    invitePromise,
+    'invite promise rejects when peer disconnects'
+  )
+  const invite = await inviteReceivedPromise
+  const assertAcceptRejectsPromise = assert.rejects(
+    () => joiner.invite.accept(invite),
+    'accepting invite rejects when project details are not received'
+  )
+  await disconnectPeers()
+  // Run down the timeout waiting for project details
+  clock.runAll()
+  await Promise.all([assertInviteRejectsPromise, assertAcceptRejectsPromise])
+
+  const members = (await creatorProject.$member.getMany()).filter(({ role }) =>
+    // @ts-ignore - TS2345
+    [MEMBER_ROLE_ID, COORDINATOR_ROLE_ID, CREATOR_ROLE_ID].includes(role.roleId)
+  )
+
+  assert.equal(members.length, 1, 'Member did not get added after fail')
+
+  const disconnectPeers2 = connectPeers([creator, joiner])
+  t.after(() => disconnectPeers2())
+  await waitForPeers([creator, joiner])
+
+  const inviteReceivedPromise2 = pEvent(joiner.invite, 'invite-received')
+  const invitePromise2 = creatorProject.$member.invite(joiner.deviceId, {
+    roleId: MEMBER_ROLE_ID,
+  })
+
+  const invite2 = await inviteReceivedPromise2
+  await joiner.invite.accept(invite2)
+
+  await invitePromise2
+
+  const members2 = (await creatorProject.$member.getMany()).filter(({ role }) =>
+    // @ts-ignore - TS2345
+    [MEMBER_ROLE_ID, COORDINATOR_ROLE_ID, CREATOR_ROLE_ID].includes(role.roleId)
+  )
+
+  assert.equal(members2.length, 2, 'Member got added after retry')
+})
 
 test('Attempting to accept unknown inviteId throws', async (t) => {
   const [creator, joiner] = await createManagers(2, t)


Reviews:
user: login: achou11
body: Couple of small suggestions and one request to make sure a key behavior is represented in the affected test.


state: CHANGES_REQUESTED
author_association: MEMBER
submitted_at: 2025-04-24T20:12:47Z
user: login: RangerMauve
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-24T20:31:08Z
user: login: RangerMauve
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-24T20:32:27Z
user: login: RangerMauve
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-24T21:16:55Z
user: login: RangerMauve
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-24T21:29:52Z
user: login: achou11
body: Some small changes needed before merging. Also not sure about introducing `MemberApi.getActive()` as part of this PR
state: CHANGES_REQUESTED
author_association: MEMBER
submitted_at: 2025-04-28T18:15:51Z
user: login: RangerMauve
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-28T19:17:54Z
user: login: achou11
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-29T16:56:22Z
user: login: achou11
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-29T17:03:51Z
user: login: achou11
body: Changes strictly related to the fix lgtm! Still have some reservations about the addition to the MemberApi but would consider it non-blocking at this point.
state: APPROVED
author_association: MEMBER
submitted_at: 2025-04-29T17:05:32Z
user: login: RangerMauve
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-29T20:17:27Z

Review comments:
diff_hunk: @@ -29,6 +29,38 @@ export class InviteAbortedError extends Error {
   }
 }
 
+export class ProjectDetailsSendFail extends Error {
+  /** @param {string} [message] */
+  constructor(message = 'Project Details Failed to send') {
path: src/errors.js
user: login: achou11
body: nitpicking but seems like sentence-casing is reasonable for this?
```suggestion
  constructor(message = 'Project details failed to send') {
```
created_at: 2025-04-24T20:01:45Z
author_association: MEMBER
diff_hunk: @@ -29,6 +29,38 @@ export class InviteAbortedError extends Error {
   }
 }
 
+export class ProjectDetailsSendFail extends Error {
+  /** @param {string} [message] */
+  constructor(message = 'Project Details Failed to send') {
+    super(message)
+    this.name = 'ProjectDetailsSendFail'
+  }
+}
+
+export class InviteInitialSyncFail extends Error {
+  /** @param {string} [message] */
+  constructor(message = 'Failed to sync config for invite') {
+    super(message)
+    this.name = 'InviteInitialSyncFail'
+  }
+}
+
+export class RPCDisconnectBeforeSending extends Error {
+  /** @param {string} [message] */
+  constructor(message = 'RPC disconnected before sending') {
+    super(message)
+    this.name = 'RPCDisconnectBeforeSending'
+  }
+}
+
+export class RPCDisconnectBeforeAck extends Error {
+  /** @param {string} [message] */
+  constructor(message = 'RPC disconnected before recieving acknowledgement') {
path: src/errors.js
user: login: achou11
body: ```suggestion
  constructor(message = 'RPC disconnected before receiving acknowledgement') {
```
created_at: 2025-04-24T20:02:00Z
author_association: MEMBER
path: src/errors.js
user: login: achou11
body: is it worth suffixing with names of these custom classes with `Error`? Think I'm more used to the names of custom error classes being very explicit about that.
created_at: 2025-04-24T20:03:07Z
author_association: MEMBER
path: test-e2e/manager-invite.js
user: login: achou11
body: is the todo comment still relevant?
created_at: 2025-04-24T20:08:37Z
author_association: MEMBER
diff_hunk: @@ -637,48 +637,41 @@ test('disconnect before invite accept', async (t) => {
 
 // TODO: It's not possible in e2e tests to disconnect peers at the right moment.
 // This test is flaky, and sometimes the project details are sent before the
-// peers disconnect, so skipping this test for now.
-test(
-  'disconnect before sending project join details',
-  { skip: true },
-  async (t) => {
-    const clock = FakeTimers.install({ shouldAdvanceTime: true })
-    t.after(() => clock.uninstall())
-    const [creator, joiner] = await createManagers(2, t)
-    const disconnectPeers = connectPeers([creator, joiner])
-    t.after(() => disconnectPeers())
-    await waitForPeers([creator, joiner])
-
-    const createdProjectId = await creator.createProject({ name: 'Mapeo' })
-    const creatorProject = await creator.getProject(createdProjectId)
-
-    const inviteReceivedPromise = pEvent(joiner.invite, 'invite-received')
-    const invitePromise = creatorProject.$member.invite(joiner.deviceId, {
-      roleId: MEMBER_ROLE_ID,
-    })
-    const assertInviteRejectsPromise = assert.rejects(
-      invitePromise,
-      {
-        name: 'PeerDisconnectedError',
-        message: 'Peer disconnected before sending project join details',
-      },
-      'invite promise rejects when peer disconnects'
-    )
-    const invite = await inviteReceivedPromise
-    const assertAcceptRejectsPromise = assert.rejects(
-      () => joiner.invite.accept(invite),
-      {
-        name: 'TimeoutError',
-        message: 'Timed out waiting for project details',
-      },
-      'accepting invite times out when project details are not received'
-    )
-    await disconnectPeers()
-    // Run down the timeout waiting for project details
-    clock.runAll()
-    await Promise.all([assertInviteRejectsPromise, assertAcceptRejectsPromise])
-  }
-)
+// peers disconnect, so can't expect specific errors.
+test('disconnect before sending project join details', async (t) => {
path: test-e2e/manager-invite.js
user: login: achou11
body: if it's not already tested elsewhere, I think it would be helpful to extend this test to make sure that re-inviting can work after the failed attempt simulated here
created_at: 2025-04-24T20:10:12Z
author_association: MEMBER
diff_hunk: @@ -200,16 +205,28 @@ export class MemberApi extends TypedEmitter {
         case InviteResponse_Decision.DECISION_UNSPECIFIED:
           return InviteResponse_Decision.REJECT
         case InviteResponse_Decision.ACCEPT:
-          await this.#rpc.sendProjectJoinDetails(deviceId, {
-            inviteId,
-            projectKey: this.#projectKey,
-            encryptionKeys: this.#encryptionKeys,
-          })
+          try {
+            await this.#rpc.sendProjectJoinDetails(deviceId, {
+              inviteId,
+              projectKey: this.#projectKey,
+              encryptionKeys: this.#encryptionKeys,
+            })
+          } catch {
+            throw new ProjectDetailsSendFail()
+          }
 
           // Only add after we know they got the details
           // Otherwise the joiner will be stuck unable to join
           await this.#roles.assignRole(deviceId, roleId)
 
+          try {
+            await this.#waitForInitialSyncWithPeer(deviceId, abortSignal)
+          } catch {
+            // Mark them as "left" so we can retry the flow
+            await this.#roles.assignRole(deviceId, LEFT_ROLE_ID)
path: src/member-api.js
user: login: achou11
body: just noting that I really don't have the best idea about whether this introduces any unintended side effects. think it should be okay but just wanted to be honest about it 😄 
created_at: 2025-04-24T20:12:41Z
author_association: MEMBER
diff_hunk: @@ -637,48 +637,41 @@ test('disconnect before invite accept', async (t) => {
 
 // TODO: It's not possible in e2e tests to disconnect peers at the right moment.
 // This test is flaky, and sometimes the project details are sent before the
-// peers disconnect, so skipping this test for now.
-test(
-  'disconnect before sending project join details',
-  { skip: true },
-  async (t) => {
-    const clock = FakeTimers.install({ shouldAdvanceTime: true })
-    t.after(() => clock.uninstall())
-    const [creator, joiner] = await createManagers(2, t)
-    const disconnectPeers = connectPeers([creator, joiner])
-    t.after(() => disconnectPeers())
-    await waitForPeers([creator, joiner])
-
-    const createdProjectId = await creator.createProject({ name: 'Mapeo' })
-    const creatorProject = await creator.getProject(createdProjectId)
-
-    const inviteReceivedPromise = pEvent(joiner.invite, 'invite-received')
-    const invitePromise = creatorProject.$member.invite(joiner.deviceId, {
-      roleId: MEMBER_ROLE_ID,
-    })
-    const assertInviteRejectsPromise = assert.rejects(
-      invitePromise,
-      {
-        name: 'PeerDisconnectedError',
-        message: 'Peer disconnected before sending project join details',
-      },
-      'invite promise rejects when peer disconnects'
-    )
-    const invite = await inviteReceivedPromise
-    const assertAcceptRejectsPromise = assert.rejects(
-      () => joiner.invite.accept(invite),
-      {
-        name: 'TimeoutError',
-        message: 'Timed out waiting for project details',
-      },
-      'accepting invite times out when project details are not received'
-    )
-    await disconnectPeers()
-    // Run down the timeout waiting for project details
-    clock.runAll()
-    await Promise.all([assertInviteRejectsPromise, assertAcceptRejectsPromise])
-  }
-)
+// peers disconnect, so can't expect specific errors.
+test('disconnect before sending project join details', async (t) => {
path: test-e2e/manager-invite.js
user: login: RangerMauve
body: Good idea!
created_at: 2025-04-24T20:31:08Z
author_association: CONTRIBUTOR
path: test-e2e/manager-invite.js
user: login: RangerMauve
body: Yeah we still can't absolutely guarantee when the disconnect happens in the cycle, sadly.
created_at: 2025-04-24T20:32:27Z
author_association: CONTRIBUTOR
diff_hunk: @@ -637,48 +637,41 @@ test('disconnect before invite accept', async (t) => {
 
 // TODO: It's not possible in e2e tests to disconnect peers at the right moment.
 // This test is flaky, and sometimes the project details are sent before the
-// peers disconnect, so skipping this test for now.
-test(
-  'disconnect before sending project join details',
-  { skip: true },
-  async (t) => {
-    const clock = FakeTimers.install({ shouldAdvanceTime: true })
-    t.after(() => clock.uninstall())
-    const [creator, joiner] = await createManagers(2, t)
-    const disconnectPeers = connectPeers([creator, joiner])
-    t.after(() => disconnectPeers())
-    await waitForPeers([creator, joiner])
-
-    const createdProjectId = await creator.createProject({ name: 'Mapeo' })
-    const creatorProject = await creator.getProject(createdProjectId)
-
-    const inviteReceivedPromise = pEvent(joiner.invite, 'invite-received')
-    const invitePromise = creatorProject.$member.invite(joiner.deviceId, {
-      roleId: MEMBER_ROLE_ID,
-    })
-    const assertInviteRejectsPromise = assert.rejects(
-      invitePromise,
-      {
-        name: 'PeerDisconnectedError',
-        message: 'Peer disconnected before sending project join details',
-      },
-      'invite promise rejects when peer disconnects'
-    )
-    const invite = await inviteReceivedPromise
-    const assertAcceptRejectsPromise = assert.rejects(
-      () => joiner.invite.accept(invite),
-      {
-        name: 'TimeoutError',
-        message: 'Timed out waiting for project details',
-      },
-      'accepting invite times out when project details are not received'
-    )
-    await disconnectPeers()
-    // Run down the timeout waiting for project details
-    clock.runAll()
-    await Promise.all([assertInviteRejectsPromise, assertAcceptRejectsPromise])
-  }
-)
+// peers disconnect, so can't expect specific errors.
+test('disconnect before sending project join details', async (t) => {
path: test-e2e/manager-invite.js
user: login: RangerMauve
body: Good catch, we got an error saying we can't assign the LEFT role to another device.
created_at: 2025-04-24T21:16:55Z
author_association: CONTRIBUTOR
diff_hunk: @@ -637,48 +637,41 @@ test('disconnect before invite accept', async (t) => {
 
 // TODO: It's not possible in e2e tests to disconnect peers at the right moment.
 // This test is flaky, and sometimes the project details are sent before the
-// peers disconnect, so skipping this test for now.
-test(
-  'disconnect before sending project join details',
-  { skip: true },
-  async (t) => {
-    const clock = FakeTimers.install({ shouldAdvanceTime: true })
-    t.after(() => clock.uninstall())
-    const [creator, joiner] = await createManagers(2, t)
-    const disconnectPeers = connectPeers([creator, joiner])
-    t.after(() => disconnectPeers())
-    await waitForPeers([creator, joiner])
-
-    const createdProjectId = await creator.createProject({ name: 'Mapeo' })
-    const creatorProject = await creator.getProject(createdProjectId)
-
-    const inviteReceivedPromise = pEvent(joiner.invite, 'invite-received')
-    const invitePromise = creatorProject.$member.invite(joiner.deviceId, {
-      roleId: MEMBER_ROLE_ID,
-    })
-    const assertInviteRejectsPromise = assert.rejects(
-      invitePromise,
-      {
-        name: 'PeerDisconnectedError',
-        message: 'Peer disconnected before sending project join details',
-      },
-      'invite promise rejects when peer disconnects'
-    )
-    const invite = await inviteReceivedPromise
-    const assertAcceptRejectsPromise = assert.rejects(
-      () => joiner.invite.accept(invite),
-      {
-        name: 'TimeoutError',
-        message: 'Timed out waiting for project details',
-      },
-      'accepting invite times out when project details are not received'
-    )
-    await disconnectPeers()
-    // Run down the timeout waiting for project details
-    clock.runAll()
-    await Promise.all([assertInviteRejectsPromise, assertAcceptRejectsPromise])
-  }
-)
+// peers disconnect, so can't expect specific errors.
+test('disconnect before sending project join details', async (t) => {
path: test-e2e/manager-invite.js
user: login: RangerMauve
body: Yeah looks like I need a new role
created_at: 2025-04-24T21:29:52Z
author_association: CONTRIBUTOR
diff_hunk: @@ -126,6 +129,34 @@ const BLOCKED_ROLE = {
   },
 }
 
+/**
+ * This role is used for devices that failed to sync after accepting an invite and being added
+ * @type {Role<typeof FAILED_ROLE_ID>}
+ */
+const FAILED_ROLE = {
+  roleId: FAILED_ROLE_ID,
+  name: 'Blocked',
path: src/roles.js
user: login: achou11
body: Copy/paste leftover?
```suggestion
  name: 'Failed',
```
created_at: 2025-04-28T17:42:10Z
author_association: MEMBER
diff_hunk: @@ -637,48 +637,59 @@ test('disconnect before invite accept', async (t) => {
 
 // TODO: It's not possible in e2e tests to disconnect peers at the right moment.
 // This test is flaky, and sometimes the project details are sent before the
-// peers disconnect, so skipping this test for now.
-test(
-  'disconnect before sending project join details',
-  { skip: true },
-  async (t) => {
-    const clock = FakeTimers.install({ shouldAdvanceTime: true })
-    t.after(() => clock.uninstall())
-    const [creator, joiner] = await createManagers(2, t)
-    const disconnectPeers = connectPeers([creator, joiner])
-    t.after(() => disconnectPeers())
-    await waitForPeers([creator, joiner])
-
-    const createdProjectId = await creator.createProject({ name: 'Mapeo' })
-    const creatorProject = await creator.getProject(createdProjectId)
-
-    const inviteReceivedPromise = pEvent(joiner.invite, 'invite-received')
-    const invitePromise = creatorProject.$member.invite(joiner.deviceId, {
-      roleId: MEMBER_ROLE_ID,
-    })
-    const assertInviteRejectsPromise = assert.rejects(
-      invitePromise,
-      {
-        name: 'PeerDisconnectedError',
-        message: 'Peer disconnected before sending project join details',
-      },
-      'invite promise rejects when peer disconnects'
-    )
-    const invite = await inviteReceivedPromise
-    const assertAcceptRejectsPromise = assert.rejects(
-      () => joiner.invite.accept(invite),
-      {
-        name: 'TimeoutError',
-        message: 'Timed out waiting for project details',
-      },
-      'accepting invite times out when project details are not received'
-    )
-    await disconnectPeers()
-    // Run down the timeout waiting for project details
-    clock.runAll()
-    await Promise.all([assertInviteRejectsPromise, assertAcceptRejectsPromise])
-  }
-)
+// peers disconnect, so can't expect specific errors.
+test.only('disconnect before sending project join details', async (t) => {
path: test-e2e/manager-invite.js
user: login: achou11
body: ```suggestion
test('disconnect before sending project join details', async (t) => {
```
created_at: 2025-04-28T17:43:10Z
author_association: MEMBER
diff_hunk: @@ -200,16 +207,34 @@ export class MemberApi extends TypedEmitter {
         case InviteResponse_Decision.DECISION_UNSPECIFIED:
           return InviteResponse_Decision.REJECT
         case InviteResponse_Decision.ACCEPT:
-          await this.#rpc.sendProjectJoinDetails(deviceId, {
-            inviteId,
-            projectKey: this.#projectKey,
-            encryptionKeys: this.#encryptionKeys,
-          })
-
-          // Only add after we know they got the details
-          // Otherwise the joiner will be stuck unable to join
+          // Technically we can assign after sending project details
+          // This lets us test role removal
           await this.#roles.assignRole(deviceId, roleId)
 
+          try {
+            await this.#rpc.sendProjectJoinDetails(deviceId, {
+              inviteId,
+              projectKey: this.#projectKey,
+              encryptionKeys: this.#encryptionKeys,
+            })
+          } catch {
+            try {
+              // Mark them as "left" so we can retry the flow
path: src/member-api.js
user: login: achou11
body: ```suggestion
              // Mark them as "failed" so we can retry the flow
```
created_at: 2025-04-28T17:47:02Z
author_association: MEMBER
diff_hunk: @@ -200,16 +207,34 @@ export class MemberApi extends TypedEmitter {
         case InviteResponse_Decision.DECISION_UNSPECIFIED:
           return InviteResponse_Decision.REJECT
         case InviteResponse_Decision.ACCEPT:
-          await this.#rpc.sendProjectJoinDetails(deviceId, {
-            inviteId,
-            projectKey: this.#projectKey,
-            encryptionKeys: this.#encryptionKeys,
-          })
-
-          // Only add after we know they got the details
-          // Otherwise the joiner will be stuck unable to join
+          // Technically we can assign after sending project details
+          // This lets us test role removal
           await this.#roles.assignRole(deviceId, roleId)
 
+          try {
+            await this.#rpc.sendProjectJoinDetails(deviceId, {
+              inviteId,
+              projectKey: this.#projectKey,
+              encryptionKeys: this.#encryptionKeys,
+            })
+          } catch {
+            try {
+              // Mark them as "left" so we can retry the flow
+              await this.#roles.assignRole(deviceId, FAILED_ROLE_ID)
+            } catch (e) {
+              console.error(e)
+            }
+            throw new ProjectDetailsSendFailError()
+          }
+
+          try {
+            await this.#waitForInitialSyncWithPeer(deviceId, abortSignal)
+          } catch {
+            // Mark them as "left" so we can retry the flow
path: src/member-api.js
user: login: achou11
body: ```suggestion
            // Mark them as "failed" so we can retry the flow
```
created_at: 2025-04-28T17:47:13Z
author_association: MEMBER
diff_hunk: @@ -511,6 +536,19 @@ export class MemberApi extends TypedEmitter {
     return result
   }
 
+  /**
+   * Get active members, excludes no role, blocked, and removed
+   * @returns {Promise<Array<MemberInfo>>}
+   */
+  async getActive() {
+    const members = await this.getMany()
+    return members.filter(({ role }) =>
+      [MEMBER_ROLE_ID, COORDINATOR_ROLE_ID, CREATOR_ROLE_ID].includes(
+        role.roleId
+      )
+    )
+  }
path: src/member-api.js
user: login: achou11
body: I'd be in favor of adding this if it actually narrowed the return type, but it doesn't due to existing TS limitations as far as I know. 

Is there a strong need to introduce this right now? Maybe open a separate PR for this specifically to consider its addition as opposed to this one.
created_at: 2025-04-28T18:10:55Z
author_association: MEMBER
diff_hunk: @@ -511,6 +536,19 @@ export class MemberApi extends TypedEmitter {
     return result
   }
 
+  /**
+   * Get active members, excludes no role, blocked, and removed
+   * @returns {Promise<Array<MemberInfo>>}
+   */
+  async getActive() {
+    const members = await this.getMany()
+    return members.filter(({ role }) =>
+      [MEMBER_ROLE_ID, COORDINATOR_ROLE_ID, CREATOR_ROLE_ID].includes(
+        role.roleId
+      )
+    )
+  }
path: src/member-api.js
user: login: RangerMauve
body: It just makes the code much more dry IMO. Else I'd need to filter every time I want to get active members. I'm down to remove if that is better.

Why would narrowing the type be necessary for this to be useful?
created_at: 2025-04-28T19:17:54Z
author_association: CONTRIBUTOR
diff_hunk: @@ -511,6 +536,19 @@ export class MemberApi extends TypedEmitter {
     return result
   }
 
+  /**
+   * Get active members, excludes no role, blocked, and removed
+   * @returns {Promise<Array<MemberInfo>>}
+   */
+  async getActive() {
+    const members = await this.getMany()
+    return members.filter(({ role }) =>
+      [MEMBER_ROLE_ID, COORDINATOR_ROLE_ID, CREATOR_ROLE_ID].includes(
+        role.roleId
+      )
+    )
+  }
path: src/member-api.js
user: login: achou11
body: > Why would narrowing the type be necessary for this to be useful?

I don't think it's necessary but it would make it less confusing/misleading when using with TypeScript. Without the narrowing, the consuming code still has to add checks to consider every role even though usage of the method implies that it's filtered. Basically the question that would come up when using this is "Why do I have to make sure that the member's role isn't blocked/left/failed if I'm using data that's supposed to omit those?"

```tsx
function Example() {
  // 🟠 Theoretical hook that is implemented in core-react that wraps `MemberApi.getActive()`
  const { data: activeMembers } = useActiveMembers()
  
  const activeMember = activeMembers[0]

  // 🟠 This kind of check would be needed to appease TypeScript but it feels odd because the source of the data communicates something to the contrary.
  if (activeMember.role.roleId !== BLOCKED_ROLE_ID) {
    // ...
  }
}
```

To clarify, I think having something like this is generally useful. The thing to note is that additions like these immediately become part of the public API due to how we consume it in the apps so just want to be thoughtful about these kinds of additions when possible. The suggestion to move as a separate PR is to provide isolation between a bug fix and a new tangential feature being added in this PR.
created_at: 2025-04-29T16:56:22Z
author_association: MEMBER
diff_hunk: @@ -511,6 +536,19 @@ export class MemberApi extends TypedEmitter {
     return result
   }
 
+  /**
+   * Get active members, excludes no role, blocked, and removed
+   * @returns {Promise<Array<MemberInfo>>}
+   */
+  async getActive() {
+    const members = await this.getMany()
+    return members.filter(({ role }) =>
+      [MEMBER_ROLE_ID, COORDINATOR_ROLE_ID, CREATOR_ROLE_ID].includes(
+        role.roleId
+      )
+    )
+  }
path: src/member-api.js
user: login: achou11
body: Tangential but worth referencing https://github.com/digidem/comapeo-core/issues/431, which alluded to ideas around how to get a filtered list of members in a project. Might be able to just close that issue if this is added!
created_at: 2025-04-29T17:03:51Z
author_association: MEMBER
diff_hunk: @@ -511,6 +536,19 @@ export class MemberApi extends TypedEmitter {
     return result
   }
 
+  /**
+   * Get active members, excludes no role, blocked, and removed
+   * @returns {Promise<Array<MemberInfo>>}
+   */
+  async getActive() {
+    const members = await this.getMany()
+    return members.filter(({ role }) =>
+      [MEMBER_ROLE_ID, COORDINATOR_ROLE_ID, CREATOR_ROLE_ID].includes(
+        role.roleId
+      )
+    )
+  }
path: src/member-api.js
user: login: RangerMauve
body: Hmm, that makes sense. I keep forgetting TS is super strict and manual. 😅 Lets remove it for now and revisit the new method later then.
created_at: 2025-04-29T20:17:27Z
author_association: CONTRIBUTOR

PR comments:
user: login: achou11
created_at: 2025-04-28T18:20:56Z
body: Also just to check, the existing `No Role` role can't be used instead?

>  This is the role assumed for a device when no role record can be found. This
>  can happen when an invited device did not manage to sync with the device that
> invited them, and they then try to sync with someone else. We want them to be
> able to sync the auth and config store, because that way they may be able to
> receive their role record, and they can get the project config so that they
> can start collecting data.
author_association: MEMBER
user: login: RangerMauve
created_at: 2025-04-28T19:20:29Z
body: So, `no_role` doesn't work well here for a few reasons:

- it's not a role that can be assigned to other members (there's a check)
- it signals they should be able to sync, whereas here we want to mark them as not being succesfully added

I initially tried going the no_role route but it would have required changing a bunch of code related to how it is validated which IMO is a scarier change than a new role.
author_association: CONTRIBUTOR
user: login: RangerMauve
created_at: 2025-04-29T20:24:06Z
body: Removed the method so we can revisit it more seriously later. Merging!
author_association: CONTRIBUTOR