ソースを参照

feat: add designating a survivor in shared vault (#841)

* feat: add designating a survivor in shared vault

* add designated survivor property to http representation

* fix: specs

* fix: more specs

* fix: another spec fix

* fix: yet another spec fix
Karol Sójko 1 年間 前
コミット
230c96dcf1
48 ファイル変更847 行追加3 行削除
  1. 2 0
      jest.config.js
  2. 16 1
      packages/api-gateway/src/Controller/v1/SharedVaultUsersController.ts
  3. 4 0
      packages/api-gateway/src/Service/Resolver/EndpointResolver.ts
  4. BIN
      packages/auth/database.sqlite
  5. 15 0
      packages/auth/migrations/mysql/1695283870612-add-designated-survivor.ts
  6. 43 0
      packages/auth/migrations/sqlite/1695283961201-add-designated-survivor.ts
  7. 24 0
      packages/auth/src/Bootstrap/Container.ts
  8. 4 0
      packages/auth/src/Bootstrap/Types.ts
  9. 26 0
      packages/auth/src/Domain/Handler/UserDesignatedAsSurvivorInSharedVaultEventHandler.ts
  10. 1 0
      packages/auth/src/Domain/SharedVault/SharedVaultUserRepositoryInterface.ts
  11. 1 0
      packages/auth/src/Domain/UseCase/AddSharedVaultUser/AddSharedVaultUser.ts
  12. 1 0
      packages/auth/src/Domain/UseCase/CreateCrossServiceToken/CreateCrossServiceToken.spec.ts
  13. 156 0
      packages/auth/src/Domain/UseCase/DesignateSurvivor/DesignateSurvivor.spec.ts
  14. 66 0
      packages/auth/src/Domain/UseCase/DesignateSurvivor/DesignateSurvivor.ts
  15. 5 0
      packages/auth/src/Domain/UseCase/DesignateSurvivor/DesignateSurvivorDTO.ts
  16. 7 0
      packages/auth/src/Infra/TypeORM/TypeORMSharedVaultUser.ts
  17. 18 0
      packages/auth/src/Infra/TypeORM/TypeORMSharedVaultUserRepository.ts
  18. 2 0
      packages/auth/src/Mapping/SharedVaultUserPersistenceMapper.ts
  19. 1 0
      packages/domain-core/src/Domain/SharedVault/SharedVaultUser.spec.ts
  20. 1 0
      packages/domain-core/src/Domain/SharedVault/SharedVaultUserProps.ts
  21. 7 0
      packages/domain-events/src/Domain/Event/UserDesignatedAsSurvivorInSharedVaultEvent.ts
  22. 5 0
      packages/domain-events/src/Domain/Event/UserDesignatedAsSurvivorInSharedVaultEventPayload.ts
  23. 2 0
      packages/domain-events/src/Domain/index.ts
  24. 13 0
      packages/syncing-server/migrations/mysql-legacy/1695284084365-add-designated-survivor.ts
  25. 13 0
      packages/syncing-server/migrations/mysql/1695284084365-add-designated-survivor.ts
  26. 39 0
      packages/syncing-server/migrations/sqlite/1695284249461-add-designated-survivor.ts
  27. 11 0
      packages/syncing-server/src/Bootstrap/Container.ts
  28. 1 0
      packages/syncing-server/src/Bootstrap/Types.ts
  29. 20 0
      packages/syncing-server/src/Domain/Event/DomainEventFactory.ts
  30. 6 0
      packages/syncing-server/src/Domain/Event/DomainEventFactoryInterface.ts
  31. 6 0
      packages/syncing-server/src/Domain/Item/SaveRule/SharedVaultFilter.spec.ts
  32. 1 0
      packages/syncing-server/src/Domain/UseCase/Messaging/AddNotificationsForUsers/AddNotificationsForUsers.spec.ts
  33. 1 0
      packages/syncing-server/src/Domain/UseCase/SharedVaults/AddUserToSharedVault/AddUserToSharedVault.ts
  34. 13 0
      packages/syncing-server/src/Domain/UseCase/SharedVaults/CreateSharedVaultFileValetToken/CreateSharedVaultFileValetToken.spec.ts
  35. 1 0
      packages/syncing-server/src/Domain/UseCase/SharedVaults/DeleteSharedVault/DeleteSharedVault.spec.ts
  36. 158 0
      packages/syncing-server/src/Domain/UseCase/SharedVaults/DesignateSurvivor/DesignateSurvivor.spec.ts
  37. 97 0
      packages/syncing-server/src/Domain/UseCase/SharedVaults/DesignateSurvivor/DesignateSurvivor.ts
  38. 5 0
      packages/syncing-server/src/Domain/UseCase/SharedVaults/DesignateSurvivor/DesignateSurvivorDTO.ts
  39. 1 0
      packages/syncing-server/src/Domain/UseCase/SharedVaults/GetSharedVaultUsers/GetSharedVaultUsers.spec.ts
  40. 1 0
      packages/syncing-server/src/Domain/UseCase/SharedVaults/GetSharedVaults/GetSharedVaults.spec.ts
  41. 1 0
      packages/syncing-server/src/Domain/UseCase/SharedVaults/InviteUserToSharedVault/InviteUserToSharedVault.spec.ts
  42. 1 0
      packages/syncing-server/src/Domain/UseCase/SharedVaults/RemoveUserFromSharedVault/RemoveUserFromSharedVault.spec.ts
  43. 14 2
      packages/syncing-server/src/Infra/InversifyExpressUtils/AnnotatedSharedVaultUsersController.ts
  44. 26 0
      packages/syncing-server/src/Infra/InversifyExpressUtils/Base/BaseSharedVaultUsersController.ts
  45. 7 0
      packages/syncing-server/src/Infra/TypeORM/TypeORMSharedVaultUser.ts
  46. 1 0
      packages/syncing-server/src/Mapping/Http/SharedVaultUserHttpMapper.ts
  47. 1 0
      packages/syncing-server/src/Mapping/Http/SharedVaultUserHttpRepresentation.ts
  48. 2 0
      packages/syncing-server/src/Mapping/Persistence/SharedVaultUserPersistenceMapper.ts

+ 2 - 0
jest.config.js

@@ -3,6 +3,8 @@ module.exports = {
   testEnvironment: 'node',
   testRegex: '(/__tests__/.*|(\\.|/)(test|spec))\\.ts$',
   testTimeout: 20000,
+  coverageReporters: ['text-summary'],
+  reporters: ['summary'],
   coverageThreshold: {
     global: {
       branches: 100,

+ 16 - 1
packages/api-gateway/src/Controller/v1/SharedVaultUsersController.ts

@@ -1,6 +1,6 @@
 import { Request, Response } from 'express'
 import { inject } from 'inversify'
-import { BaseHttpController, controller, httpDelete, httpGet } from 'inversify-express-utils'
+import { BaseHttpController, controller, httpDelete, httpGet, httpPost } from 'inversify-express-utils'
 import { TYPES } from '../../Bootstrap/Types'
 import { ServiceProxyInterface } from '../../Service/Http/ServiceProxyInterface'
 import { EndpointResolverInterface } from '../../Service/Resolver/EndpointResolverInterface'
@@ -42,4 +42,19 @@ export class SharedVaultUsersController extends BaseHttpController {
       request.body,
     )
   }
+
+  @httpPost('/:userUuid/designate-survivor')
+  async designateSurvivor(request: Request, response: Response): Promise<void> {
+    await this.httpService.callSyncingServer(
+      request,
+      response,
+      this.endpointResolver.resolveEndpointOrMethodIdentifier(
+        'POST',
+        'shared-vaults/:sharedVaultUuid/users/:userUuid/designate-survivor',
+        request.params.sharedVaultUuid,
+        request.params.userUuid,
+      ),
+      request.body,
+    )
+  }
 }

+ 4 - 0
packages/api-gateway/src/Service/Resolver/EndpointResolver.ts

@@ -89,6 +89,10 @@ export class EndpointResolver implements EndpointResolverInterface {
     // Shared Vault Users Controller
     ['[GET]:shared-vaults/:sharedVaultUuid/users', 'sync.shared-vault-users.get-users'],
     ['[DELETE]:shared-vaults/:sharedVaultUuid/users/:userUuid', 'sync.shared-vault-users.remove-user'],
+    [
+      '[POST]:shared-vaults/:sharedVaultUuid/users/:userUuid/designate-survivor',
+      'sync.shared-vault-users.designate-survivor',
+    ],
   ])
 
   resolveEndpointOrMethodIdentifier(method: string, endpoint: string, ...params: string[]): string {

BIN
packages/auth/database.sqlite


+ 15 - 0
packages/auth/migrations/mysql/1695283870612-add-designated-survivor.ts

@@ -0,0 +1,15 @@
+import { MigrationInterface, QueryRunner } from 'typeorm'
+
+export class AddDesignatedSurvivor1695283870612 implements MigrationInterface {
+  name = 'AddDesignatedSurvivor1695283870612'
+
+  public async up(queryRunner: QueryRunner): Promise<void> {
+    await queryRunner.query(
+      'ALTER TABLE `auth_shared_vault_users` ADD `is_designated_survivor` tinyint NOT NULL DEFAULT 0',
+    )
+  }
+
+  public async down(queryRunner: QueryRunner): Promise<void> {
+    await queryRunner.query('ALTER TABLE `auth_shared_vault_users` DROP COLUMN `is_designated_survivor`')
+  }
+}

+ 43 - 0
packages/auth/migrations/sqlite/1695283961201-add-designated-survivor.ts

@@ -0,0 +1,43 @@
+import { MigrationInterface, QueryRunner } from 'typeorm'
+
+export class AddDesignatedSurvivor1695283961201 implements MigrationInterface {
+  name = 'AddDesignatedSurvivor1695283961201'
+
+  public async up(queryRunner: QueryRunner): Promise<void> {
+    await queryRunner.query('DROP INDEX "user_uuid_on_auth_shared_vault_users"')
+    await queryRunner.query('DROP INDEX "shared_vault_uuid_on_auth_shared_vault_users"')
+    await queryRunner.query(
+      'CREATE TABLE "temporary_auth_shared_vault_users" ("uuid" varchar PRIMARY KEY NOT NULL, "shared_vault_uuid" varchar(36) NOT NULL, "user_uuid" varchar(36) NOT NULL, "permission" varchar(24) NOT NULL, "created_at_timestamp" bigint NOT NULL, "updated_at_timestamp" bigint NOT NULL, "is_designated_survivor" boolean NOT NULL DEFAULT (0))',
+    )
+    await queryRunner.query(
+      'INSERT INTO "temporary_auth_shared_vault_users"("uuid", "shared_vault_uuid", "user_uuid", "permission", "created_at_timestamp", "updated_at_timestamp") SELECT "uuid", "shared_vault_uuid", "user_uuid", "permission", "created_at_timestamp", "updated_at_timestamp" FROM "auth_shared_vault_users"',
+    )
+    await queryRunner.query('DROP TABLE "auth_shared_vault_users"')
+    await queryRunner.query('ALTER TABLE "temporary_auth_shared_vault_users" RENAME TO "auth_shared_vault_users"')
+    await queryRunner.query(
+      'CREATE INDEX "user_uuid_on_auth_shared_vault_users" ON "auth_shared_vault_users" ("user_uuid") ',
+    )
+    await queryRunner.query(
+      'CREATE INDEX "shared_vault_uuid_on_auth_shared_vault_users" ON "auth_shared_vault_users" ("shared_vault_uuid") ',
+    )
+  }
+
+  public async down(queryRunner: QueryRunner): Promise<void> {
+    await queryRunner.query('DROP INDEX "shared_vault_uuid_on_auth_shared_vault_users"')
+    await queryRunner.query('DROP INDEX "user_uuid_on_auth_shared_vault_users"')
+    await queryRunner.query('ALTER TABLE "auth_shared_vault_users" RENAME TO "temporary_auth_shared_vault_users"')
+    await queryRunner.query(
+      'CREATE TABLE "auth_shared_vault_users" ("uuid" varchar PRIMARY KEY NOT NULL, "shared_vault_uuid" varchar(36) NOT NULL, "user_uuid" varchar(36) NOT NULL, "permission" varchar(24) NOT NULL, "created_at_timestamp" bigint NOT NULL, "updated_at_timestamp" bigint NOT NULL)',
+    )
+    await queryRunner.query(
+      'INSERT INTO "auth_shared_vault_users"("uuid", "shared_vault_uuid", "user_uuid", "permission", "created_at_timestamp", "updated_at_timestamp") SELECT "uuid", "shared_vault_uuid", "user_uuid", "permission", "created_at_timestamp", "updated_at_timestamp" FROM "temporary_auth_shared_vault_users"',
+    )
+    await queryRunner.query('DROP TABLE "temporary_auth_shared_vault_users"')
+    await queryRunner.query(
+      'CREATE INDEX "shared_vault_uuid_on_auth_shared_vault_users" ON "auth_shared_vault_users" ("shared_vault_uuid") ',
+    )
+    await queryRunner.query(
+      'CREATE INDEX "user_uuid_on_auth_shared_vault_users" ON "auth_shared_vault_users" ("user_uuid") ',
+    )
+  }
+}

+ 24 - 0
packages/auth/src/Bootstrap/Container.ts

@@ -271,6 +271,8 @@ import { AddSharedVaultUser } from '../Domain/UseCase/AddSharedVaultUser/AddShar
 import { RemoveSharedVaultUser } from '../Domain/UseCase/RemoveSharedVaultUser/RemoveSharedVaultUser'
 import { UserAddedToSharedVaultEventHandler } from '../Domain/Handler/UserAddedToSharedVaultEventHandler'
 import { UserRemovedFromSharedVaultEventHandler } from '../Domain/Handler/UserRemovedFromSharedVaultEventHandler'
+import { DesignateSurvivor } from '../Domain/UseCase/DesignateSurvivor/DesignateSurvivor'
+import { UserDesignatedAsSurvivorInSharedVaultEventHandler } from '../Domain/Handler/UserDesignatedAsSurvivorInSharedVaultEventHandler'
 
 export class ContainerConfigLoader {
   constructor(private mode: 'server' | 'worker' = 'server') {}
@@ -957,6 +959,14 @@ export class ContainerConfigLoader {
           container.get<SharedVaultUserRepositoryInterface>(TYPES.Auth_SharedVaultUserRepository),
         ),
       )
+    container
+      .bind<DesignateSurvivor>(TYPES.Auth_DesignateSurvivor)
+      .toConstantValue(
+        new DesignateSurvivor(
+          container.get<SharedVaultUserRepositoryInterface>(TYPES.Auth_SharedVaultUserRepository),
+          container.get<TimerInterface>(TYPES.Auth_Timer),
+        ),
+      )
 
     // Controller
     container
@@ -1122,6 +1132,16 @@ export class ContainerConfigLoader {
           container.get<winston.Logger>(TYPES.Auth_Logger),
         ),
       )
+    container
+      .bind<UserDesignatedAsSurvivorInSharedVaultEventHandler>(
+        TYPES.Auth_UserDesignatedAsSurvivorInSharedVaultEventHandler,
+      )
+      .toConstantValue(
+        new UserDesignatedAsSurvivorInSharedVaultEventHandler(
+          container.get<DesignateSurvivor>(TYPES.Auth_DesignateSurvivor),
+          container.get<winston.Logger>(TYPES.Auth_Logger),
+        ),
+      )
 
     const eventHandlers: Map<string, DomainEventHandlerInterface> = new Map([
       ['USER_REGISTERED', container.get(TYPES.Auth_UserRegisteredEventHandler)],
@@ -1156,6 +1176,10 @@ export class ContainerConfigLoader {
       ['TRANSITION_STATUS_UPDATED', container.get(TYPES.Auth_TransitionStatusUpdatedEventHandler)],
       ['USER_ADDED_TO_SHARED_VAULT', container.get(TYPES.Auth_UserAddedToSharedVaultEventHandler)],
       ['USER_REMOVED_FROM_SHARED_VAULT', container.get(TYPES.Auth_UserRemovedFromSharedVaultEventHandler)],
+      [
+        'USER_DESIGNATED_AS_SURVIVOR_IN_SHARED_VAULT',
+        container.get(TYPES.Auth_UserDesignatedAsSurvivorInSharedVaultEventHandler),
+      ],
     ])
 
     if (isConfiguredForHomeServer) {

+ 4 - 0
packages/auth/src/Bootstrap/Types.ts

@@ -161,6 +161,7 @@ const TYPES = {
   Auth_UpdateTransitionStatus: Symbol.for('Auth_UpdateTransitionStatus'),
   Auth_AddSharedVaultUser: Symbol.for('Auth_AddSharedVaultUser'),
   Auth_RemoveSharedVaultUser: Symbol.for('Auth_RemoveSharedVaultUser'),
+  Auth_DesignateSurvivor: Symbol.for('Auth_DesignateSurvivor'),
   // Handlers
   Auth_UserRegisteredEventHandler: Symbol.for('Auth_UserRegisteredEventHandler'),
   Auth_AccountDeletionRequestedEventHandler: Symbol.for('Auth_AccountDeletionRequestedEventHandler'),
@@ -192,6 +193,9 @@ const TYPES = {
   Auth_TransitionStatusUpdatedEventHandler: Symbol.for('Auth_TransitionStatusUpdatedEventHandler'),
   Auth_UserAddedToSharedVaultEventHandler: Symbol.for('Auth_UserAddedToSharedVaultEventHandler'),
   Auth_UserRemovedFromSharedVaultEventHandler: Symbol.for('Auth_UserRemovedFromSharedVaultEventHandler'),
+  Auth_UserDesignatedAsSurvivorInSharedVaultEventHandler: Symbol.for(
+    'Auth_UserDesignatedAsSurvivorInSharedVaultEventHandler',
+  ),
   // Services
   Auth_DeviceDetector: Symbol.for('Auth_DeviceDetector'),
   Auth_SessionService: Symbol.for('Auth_SessionService'),

+ 26 - 0
packages/auth/src/Domain/Handler/UserDesignatedAsSurvivorInSharedVaultEventHandler.ts

@@ -0,0 +1,26 @@
+import { DomainEventHandlerInterface, UserDesignatedAsSurvivorInSharedVaultEvent } from '@standardnotes/domain-events'
+import { Logger } from 'winston'
+import { DesignateSurvivor } from '../UseCase/DesignateSurvivor/DesignateSurvivor'
+
+export class UserDesignatedAsSurvivorInSharedVaultEventHandler implements DomainEventHandlerInterface {
+  constructor(
+    private designateSurvivorUseCase: DesignateSurvivor,
+    private logger: Logger,
+  ) {}
+
+  async handle(event: UserDesignatedAsSurvivorInSharedVaultEvent): Promise<void> {
+    const result = await this.designateSurvivorUseCase.execute({
+      sharedVaultUuid: event.payload.sharedVaultUuid,
+      userUuid: event.payload.userUuid,
+      timestamp: event.payload.timestamp,
+    })
+
+    if (result.isFailed()) {
+      this.logger.error(
+        `Failed designate survivor for user ${event.payload.userUuid} and shared vault ${
+          event.payload.sharedVaultUuid
+        }: ${result.getError()}`,
+      )
+    }
+  }
+}

+ 1 - 0
packages/auth/src/Domain/SharedVault/SharedVaultUserRepositoryInterface.ts

@@ -3,6 +3,7 @@ import { SharedVaultUser, Uuid } from '@standardnotes/domain-core'
 export interface SharedVaultUserRepositoryInterface {
   findByUserUuidAndSharedVaultUuid(dto: { userUuid: Uuid; sharedVaultUuid: Uuid }): Promise<SharedVaultUser | null>
   findByUserUuid(userUuid: Uuid): Promise<SharedVaultUser[]>
+  findDesignatedSurvivorBySharedVaultUuid(sharedVaultUuid: Uuid): Promise<SharedVaultUser | null>
   save(sharedVaultUser: SharedVaultUser): Promise<void>
   remove(sharedVault: SharedVaultUser): Promise<void>
 }

+ 1 - 0
packages/auth/src/Domain/UseCase/AddSharedVaultUser/AddSharedVaultUser.ts

@@ -43,6 +43,7 @@ export class AddSharedVaultUser implements UseCaseInterface<void> {
       sharedVaultUuid,
       permission,
       timestamps,
+      isDesignatedSurvivor: false,
     })
     if (sharedVaultUserOrError.isFailed()) {
       return Result.fail(sharedVaultUserOrError.getError())

+ 1 - 0
packages/auth/src/Domain/UseCase/CreateCrossServiceToken/CreateCrossServiceToken.spec.ts

@@ -90,6 +90,7 @@ describe('CreateCrossServiceToken', () => {
         sharedVaultUuid: Uuid.create('00000000-0000-0000-0000-000000000000').getValue(),
         timestamps: Timestamps.create(123456789, 123456789).getValue(),
         userUuid: Uuid.create('00000000-0000-0000-0000-000000000000').getValue(),
+        isDesignatedSurvivor: false,
       }).getValue(),
     ])
   })

+ 156 - 0
packages/auth/src/Domain/UseCase/DesignateSurvivor/DesignateSurvivor.spec.ts

@@ -0,0 +1,156 @@
+import { SharedVaultUser, SharedVaultUserPermission, Timestamps, Uuid } from '@standardnotes/domain-core'
+
+import { DesignateSurvivor } from './DesignateSurvivor'
+import { TimerInterface } from '@standardnotes/time'
+import { SharedVaultUserRepositoryInterface } from '../../SharedVault/SharedVaultUserRepositoryInterface'
+
+describe('DesignateSurvivor', () => {
+  let sharedVaultUserRepository: SharedVaultUserRepositoryInterface
+  let sharedVaultUser: SharedVaultUser
+  let timer: TimerInterface
+
+  const createUseCase = () => new DesignateSurvivor(sharedVaultUserRepository, timer)
+
+  beforeEach(() => {
+    timer = {} as jest.Mocked<TimerInterface>
+    timer.getTimestampInMicroseconds = jest.fn().mockReturnValue(123)
+
+    sharedVaultUser = SharedVaultUser.create({
+      permission: SharedVaultUserPermission.create(SharedVaultUserPermission.PERMISSIONS.Write).getValue(),
+      sharedVaultUuid: Uuid.create('00000000-0000-0000-0000-000000000000').getValue(),
+      userUuid: Uuid.create('00000000-0000-0000-0000-000000000000').getValue(),
+      timestamps: Timestamps.create(123, 123).getValue(),
+      isDesignatedSurvivor: false,
+    }).getValue()
+
+    sharedVaultUserRepository = {} as jest.Mocked<SharedVaultUserRepositoryInterface>
+    sharedVaultUserRepository.findDesignatedSurvivorBySharedVaultUuid = jest.fn().mockReturnValue(null)
+    sharedVaultUserRepository.findByUserUuidAndSharedVaultUuid = jest.fn().mockReturnValue(sharedVaultUser)
+    sharedVaultUserRepository.save = jest.fn()
+  })
+
+  it('should fail if shared vault uuid is invalid', async () => {
+    const useCase = createUseCase()
+
+    const result = await useCase.execute({
+      sharedVaultUuid: 'invalid',
+      userUuid: '00000000-0000-0000-0000-000000000000',
+      timestamp: 123,
+    })
+
+    expect(result.isFailed()).toBe(true)
+  })
+
+  it('should fail if user uuid is invalid', async () => {
+    const useCase = createUseCase()
+
+    const result = await useCase.execute({
+      sharedVaultUuid: '00000000-0000-0000-0000-000000000000',
+      userUuid: 'invalid',
+      timestamp: 123,
+    })
+
+    expect(result.isFailed()).toBe(true)
+  })
+
+  it('should fail if shared vault user is not found', async () => {
+    sharedVaultUserRepository.findByUserUuidAndSharedVaultUuid = jest.fn().mockReturnValue(null)
+
+    const useCase = createUseCase()
+
+    const result = await useCase.execute({
+      sharedVaultUuid: '00000000-0000-0000-0000-000000000000',
+      userUuid: '00000000-0000-0000-0000-000000000000',
+      timestamp: 123,
+    })
+
+    expect(result.isFailed()).toBe(true)
+  })
+
+  it('should designate a survivor if the user is a member', async () => {
+    sharedVaultUserRepository.findByUserUuidAndSharedVaultUuid = jest.fn().mockReturnValue(sharedVaultUser)
+
+    const useCase = createUseCase()
+
+    const result = await useCase.execute({
+      sharedVaultUuid: '00000000-0000-0000-0000-000000000000',
+      userUuid: '00000000-0000-0000-0000-000000000000',
+      timestamp: 123,
+    })
+
+    expect(result.isFailed()).toBe(false)
+    expect(sharedVaultUser.props.isDesignatedSurvivor).toBe(true)
+    expect(sharedVaultUserRepository.save).toBeCalledTimes(1)
+  })
+
+  it('should designate a survivor if the user is a member and there is already a survivor', async () => {
+    sharedVaultUserRepository.findDesignatedSurvivorBySharedVaultUuid = jest.fn().mockReturnValue(
+      SharedVaultUser.create({
+        permission: SharedVaultUserPermission.create(SharedVaultUserPermission.PERMISSIONS.Write).getValue(),
+        sharedVaultUuid: Uuid.create('00000000-0000-0000-0000-000000000000').getValue(),
+        userUuid: Uuid.create('00000000-0000-0000-0000-000000000001').getValue(),
+        timestamps: Timestamps.create(123, 123).getValue(),
+        isDesignatedSurvivor: true,
+      }).getValue(),
+    )
+    sharedVaultUserRepository.findByUserUuidAndSharedVaultUuid = jest.fn().mockReturnValue(sharedVaultUser)
+
+    const useCase = createUseCase()
+
+    const result = await useCase.execute({
+      sharedVaultUuid: '00000000-0000-0000-0000-000000000000',
+      userUuid: '00000000-0000-0000-0000-000000000000',
+      timestamp: 123,
+    })
+
+    expect(result.isFailed()).toBe(false)
+    expect(sharedVaultUser.props.isDesignatedSurvivor).toBe(true)
+    expect(sharedVaultUserRepository.save).toBeCalledTimes(2)
+  })
+
+  it('should fail if the timestamp is older than the existing survivor', async () => {
+    sharedVaultUserRepository.findDesignatedSurvivorBySharedVaultUuid = jest.fn().mockReturnValue(
+      SharedVaultUser.create({
+        permission: SharedVaultUserPermission.create(SharedVaultUserPermission.PERMISSIONS.Write).getValue(),
+        sharedVaultUuid: Uuid.create('00000000-0000-0000-0000-000000000000').getValue(),
+        userUuid: Uuid.create('00000000-0000-0000-0000-000000000001').getValue(),
+        timestamps: Timestamps.create(123, 123).getValue(),
+        isDesignatedSurvivor: true,
+      }).getValue(),
+    )
+    sharedVaultUserRepository.findByUserUuidAndSharedVaultUuid = jest.fn().mockReturnValue(sharedVaultUser)
+
+    const useCase = createUseCase()
+
+    const result = await useCase.execute({
+      sharedVaultUuid: '00000000-0000-0000-0000-000000000000',
+      userUuid: '00000000-0000-0000-0000-000000000000',
+      timestamp: 122,
+    })
+
+    expect(result.isFailed()).toBe(true)
+  })
+
+  it('should do nothing if the user is already a survivor', async () => {
+    sharedVaultUserRepository.findDesignatedSurvivorBySharedVaultUuid = jest.fn().mockReturnValue(
+      SharedVaultUser.create({
+        permission: SharedVaultUserPermission.create(SharedVaultUserPermission.PERMISSIONS.Write).getValue(),
+        sharedVaultUuid: Uuid.create('00000000-0000-0000-0000-000000000000').getValue(),
+        userUuid: Uuid.create('00000000-0000-0000-0000-000000000000').getValue(),
+        timestamps: Timestamps.create(123, 123).getValue(),
+        isDesignatedSurvivor: true,
+      }).getValue(),
+    )
+    sharedVaultUserRepository.findByUserUuidAndSharedVaultUuid = jest.fn().mockReturnValue(sharedVaultUser)
+
+    const useCase = createUseCase()
+
+    const result = await useCase.execute({
+      sharedVaultUuid: '00000000-0000-0000-0000-000000000000',
+      userUuid: '00000000-0000-0000-0000-000000000000',
+      timestamp: 200,
+    })
+
+    expect(result.isFailed()).toBe(false)
+  })
+})

+ 66 - 0
packages/auth/src/Domain/UseCase/DesignateSurvivor/DesignateSurvivor.ts

@@ -0,0 +1,66 @@
+import { TimerInterface } from '@standardnotes/time'
+import { Result, Timestamps, UseCaseInterface, Uuid } from '@standardnotes/domain-core'
+
+import { DesignateSurvivorDTO } from './DesignateSurvivorDTO'
+import { SharedVaultUserRepositoryInterface } from '../../SharedVault/SharedVaultUserRepositoryInterface'
+
+export class DesignateSurvivor implements UseCaseInterface<void> {
+  constructor(
+    private sharedVaultUserRepository: SharedVaultUserRepositoryInterface,
+    private timer: TimerInterface,
+  ) {}
+
+  async execute(dto: DesignateSurvivorDTO): Promise<Result<void>> {
+    const sharedVaultUuidOrError = Uuid.create(dto.sharedVaultUuid)
+    if (sharedVaultUuidOrError.isFailed()) {
+      return Result.fail(sharedVaultUuidOrError.getError())
+    }
+    const sharedVaultUuid = sharedVaultUuidOrError.getValue()
+
+    const userUuidOrError = Uuid.create(dto.userUuid)
+    if (userUuidOrError.isFailed()) {
+      return Result.fail(userUuidOrError.getError())
+    }
+    const userUuid = userUuidOrError.getValue()
+
+    const existingSurvivor =
+      await this.sharedVaultUserRepository.findDesignatedSurvivorBySharedVaultUuid(sharedVaultUuid)
+
+    if (existingSurvivor) {
+      if (existingSurvivor.props.timestamps.updatedAt > dto.timestamp) {
+        return Result.fail(
+          'Cannot designate survivor to a previous version of the shared vault. Most probably a race condition.',
+        )
+      }
+      if (existingSurvivor.props.userUuid.value === userUuid.value) {
+        return Result.ok()
+      }
+
+      existingSurvivor.props.isDesignatedSurvivor = false
+      existingSurvivor.props.timestamps = Timestamps.create(
+        existingSurvivor.props.timestamps.createdAt,
+        this.timer.getTimestampInMicroseconds(),
+      ).getValue()
+
+      await this.sharedVaultUserRepository.save(existingSurvivor)
+    }
+
+    const toBeDesignatedAsASurvivor = await this.sharedVaultUserRepository.findByUserUuidAndSharedVaultUuid({
+      userUuid,
+      sharedVaultUuid,
+    })
+    if (!toBeDesignatedAsASurvivor) {
+      return Result.fail('User is not a member of the shared vault')
+    }
+
+    toBeDesignatedAsASurvivor.props.isDesignatedSurvivor = true
+    toBeDesignatedAsASurvivor.props.timestamps = Timestamps.create(
+      toBeDesignatedAsASurvivor.props.timestamps.createdAt,
+      this.timer.getTimestampInMicroseconds(),
+    ).getValue()
+
+    await this.sharedVaultUserRepository.save(toBeDesignatedAsASurvivor)
+
+    return Result.ok()
+  }
+}

+ 5 - 0
packages/auth/src/Domain/UseCase/DesignateSurvivor/DesignateSurvivorDTO.ts

@@ -0,0 +1,5 @@
+export interface DesignateSurvivorDTO {
+  sharedVaultUuid: string
+  userUuid: string
+  timestamp: number
+}

+ 7 - 0
packages/auth/src/Infra/TypeORM/TypeORMSharedVaultUser.ts

@@ -26,6 +26,13 @@ export class TypeORMSharedVaultUser {
   })
   declare permission: string
 
+  @Column({
+    name: 'is_designated_survivor',
+    type: 'boolean',
+    default: false,
+  })
+  declare isDesignatedSurvivor: boolean
+
   @Column({
     name: 'created_at_timestamp',
     type: 'bigint',

+ 18 - 0
packages/auth/src/Infra/TypeORM/TypeORMSharedVaultUserRepository.ts

@@ -10,6 +10,24 @@ export class TypeORMSharedVaultUserRepository implements SharedVaultUserReposito
     private mapper: MapperInterface<SharedVaultUser, TypeORMSharedVaultUser>,
   ) {}
 
+  async findDesignatedSurvivorBySharedVaultUuid(sharedVaultUuid: Uuid): Promise<SharedVaultUser | null> {
+    const persistence = await this.ormRepository
+      .createQueryBuilder('shared_vault_user')
+      .where('shared_vault_user.shared_vault_uuid = :sharedVaultUuid', {
+        sharedVaultUuid: sharedVaultUuid.value,
+      })
+      .andWhere('shared_vault_user.is_designated_survivor = :isDesignatedSurvivor', {
+        isDesignatedSurvivor: true,
+      })
+      .getOne()
+
+    if (persistence === null) {
+      return null
+    }
+
+    return this.mapper.toDomain(persistence)
+  }
+
   async findByUserUuid(userUuid: Uuid): Promise<SharedVaultUser[]> {
     const persistence = await this.ormRepository
       .createQueryBuilder('shared_vault_user')

+ 2 - 0
packages/auth/src/Mapping/SharedVaultUserPersistenceMapper.ts

@@ -41,6 +41,7 @@ export class SharedVaultUserPersistenceMapper implements MapperInterface<SharedV
         sharedVaultUuid,
         permission,
         timestamps,
+        isDesignatedSurvivor: !!projection.isDesignatedSurvivor,
       },
       new UniqueEntityId(projection.uuid),
     )
@@ -61,6 +62,7 @@ export class SharedVaultUserPersistenceMapper implements MapperInterface<SharedV
     typeorm.permission = domain.props.permission.value
     typeorm.createdAtTimestamp = domain.props.timestamps.createdAt
     typeorm.updatedAtTimestamp = domain.props.timestamps.updatedAt
+    typeorm.isDesignatedSurvivor = !!domain.props.isDesignatedSurvivor
 
     return typeorm
   }

+ 1 - 0
packages/domain-core/src/Domain/SharedVault/SharedVaultUser.spec.ts

@@ -10,6 +10,7 @@ describe('SharedVaultUser', () => {
       sharedVaultUuid: Uuid.create('00000000-0000-0000-0000-000000000000').getValue(),
       timestamps: Timestamps.create(123456789, 123456789).getValue(),
       userUuid: Uuid.create('00000000-0000-0000-0000-000000000000').getValue(),
+      isDesignatedSurvivor: false,
     })
 
     expect(entityOrError.isFailed()).toBeFalsy()

+ 1 - 0
packages/domain-core/src/Domain/SharedVault/SharedVaultUserProps.ts

@@ -6,5 +6,6 @@ export interface SharedVaultUserProps {
   sharedVaultUuid: Uuid
   userUuid: Uuid
   permission: SharedVaultUserPermission
+  isDesignatedSurvivor: boolean
   timestamps: Timestamps
 }

+ 7 - 0
packages/domain-events/src/Domain/Event/UserDesignatedAsSurvivorInSharedVaultEvent.ts

@@ -0,0 +1,7 @@
+import { DomainEventInterface } from './DomainEventInterface'
+import { UserDesignatedAsSurvivorInSharedVaultEventPayload } from './UserDesignatedAsSurvivorInSharedVaultEventPayload'
+
+export interface UserDesignatedAsSurvivorInSharedVaultEvent extends DomainEventInterface {
+  type: 'USER_DESIGNATED_AS_SURVIVOR_IN_SHARED_VAULT'
+  payload: UserDesignatedAsSurvivorInSharedVaultEventPayload
+}

+ 5 - 0
packages/domain-events/src/Domain/Event/UserDesignatedAsSurvivorInSharedVaultEventPayload.ts

@@ -0,0 +1,5 @@
+export interface UserDesignatedAsSurvivorInSharedVaultEventPayload {
+  userUuid: string
+  sharedVaultUuid: string
+  timestamp: number
+}

+ 2 - 0
packages/domain-events/src/Domain/index.ts

@@ -104,6 +104,8 @@ export * from './Event/TransitionStatusUpdatedEvent'
 export * from './Event/TransitionStatusUpdatedEventPayload'
 export * from './Event/UserAddedToSharedVaultEvent'
 export * from './Event/UserAddedToSharedVaultEventPayload'
+export * from './Event/UserDesignatedAsSurvivorInSharedVaultEvent'
+export * from './Event/UserDesignatedAsSurvivorInSharedVaultEventPayload'
 export * from './Event/UserDisabledSessionUserAgentLoggingEvent'
 export * from './Event/UserDisabledSessionUserAgentLoggingEventPayload'
 export * from './Event/UserEmailChangedEvent'

+ 13 - 0
packages/syncing-server/migrations/mysql-legacy/1695284084365-add-designated-survivor.ts

@@ -0,0 +1,13 @@
+import { MigrationInterface, QueryRunner } from 'typeorm'
+
+export class AddDesignatedSurvivor1695284084365 implements MigrationInterface {
+  name = 'AddDesignatedSurvivor1695284084365'
+
+  public async up(queryRunner: QueryRunner): Promise<void> {
+    await queryRunner.query('ALTER TABLE `shared_vault_users` ADD `is_designated_survivor` tinyint NOT NULL DEFAULT 0')
+  }
+
+  public async down(queryRunner: QueryRunner): Promise<void> {
+    await queryRunner.query('ALTER TABLE `shared_vault_users` DROP COLUMN `is_designated_survivor`')
+  }
+}

+ 13 - 0
packages/syncing-server/migrations/mysql/1695284084365-add-designated-survivor.ts

@@ -0,0 +1,13 @@
+import { MigrationInterface, QueryRunner } from 'typeorm'
+
+export class AddDesignatedSurvivor1695284084365 implements MigrationInterface {
+  name = 'AddDesignatedSurvivor1695284084365'
+
+  public async up(queryRunner: QueryRunner): Promise<void> {
+    await queryRunner.query('ALTER TABLE `shared_vault_users` ADD `is_designated_survivor` tinyint NOT NULL DEFAULT 0')
+  }
+
+  public async down(queryRunner: QueryRunner): Promise<void> {
+    await queryRunner.query('ALTER TABLE `shared_vault_users` DROP COLUMN `is_designated_survivor`')
+  }
+}

+ 39 - 0
packages/syncing-server/migrations/sqlite/1695284249461-add-designated-survivor.ts

@@ -0,0 +1,39 @@
+import { MigrationInterface, QueryRunner } from 'typeorm'
+
+export class AddDesignatedSurvivor1695284249461 implements MigrationInterface {
+  name = 'AddDesignatedSurvivor1695284249461'
+
+  public async up(queryRunner: QueryRunner): Promise<void> {
+    await queryRunner.query('DROP INDEX "user_uuid_on_shared_vault_users"')
+    await queryRunner.query('DROP INDEX "shared_vault_uuid_on_shared_vault_users"')
+    await queryRunner.query(
+      'CREATE TABLE "temporary_shared_vault_users" ("uuid" varchar PRIMARY KEY NOT NULL, "shared_vault_uuid" varchar(36) NOT NULL, "user_uuid" varchar(36) NOT NULL, "permission" varchar(24) NOT NULL, "created_at_timestamp" bigint NOT NULL, "updated_at_timestamp" bigint NOT NULL, "is_designated_survivor" boolean NOT NULL DEFAULT (0))',
+    )
+    await queryRunner.query(
+      'INSERT INTO "temporary_shared_vault_users"("uuid", "shared_vault_uuid", "user_uuid", "permission", "created_at_timestamp", "updated_at_timestamp") SELECT "uuid", "shared_vault_uuid", "user_uuid", "permission", "created_at_timestamp", "updated_at_timestamp" FROM "shared_vault_users"',
+    )
+    await queryRunner.query('DROP TABLE "shared_vault_users"')
+    await queryRunner.query('ALTER TABLE "temporary_shared_vault_users" RENAME TO "shared_vault_users"')
+    await queryRunner.query('CREATE INDEX "user_uuid_on_shared_vault_users" ON "shared_vault_users" ("user_uuid") ')
+    await queryRunner.query(
+      'CREATE INDEX "shared_vault_uuid_on_shared_vault_users" ON "shared_vault_users" ("shared_vault_uuid") ',
+    )
+  }
+
+  public async down(queryRunner: QueryRunner): Promise<void> {
+    await queryRunner.query('DROP INDEX "shared_vault_uuid_on_shared_vault_users"')
+    await queryRunner.query('DROP INDEX "user_uuid_on_shared_vault_users"')
+    await queryRunner.query('ALTER TABLE "shared_vault_users" RENAME TO "temporary_shared_vault_users"')
+    await queryRunner.query(
+      'CREATE TABLE "shared_vault_users" ("uuid" varchar PRIMARY KEY NOT NULL, "shared_vault_uuid" varchar(36) NOT NULL, "user_uuid" varchar(36) NOT NULL, "permission" varchar(24) NOT NULL, "created_at_timestamp" bigint NOT NULL, "updated_at_timestamp" bigint NOT NULL)',
+    )
+    await queryRunner.query(
+      'INSERT INTO "shared_vault_users"("uuid", "shared_vault_uuid", "user_uuid", "permission", "created_at_timestamp", "updated_at_timestamp") SELECT "uuid", "shared_vault_uuid", "user_uuid", "permission", "created_at_timestamp", "updated_at_timestamp" FROM "temporary_shared_vault_users"',
+    )
+    await queryRunner.query('DROP TABLE "temporary_shared_vault_users"')
+    await queryRunner.query(
+      'CREATE INDEX "shared_vault_uuid_on_shared_vault_users" ON "shared_vault_users" ("shared_vault_uuid") ',
+    )
+    await queryRunner.query('CREATE INDEX "user_uuid_on_shared_vault_users" ON "shared_vault_users" ("user_uuid") ')
+  }
+}

+ 11 - 0
packages/syncing-server/src/Bootstrap/Container.ts

@@ -168,6 +168,7 @@ import { TransitionRequestedEventHandler } from '../Domain/Handler/TransitionReq
 import { DeleteSharedVaults } from '../Domain/UseCase/SharedVaults/DeleteSharedVaults/DeleteSharedVaults'
 import { RemoveItemsFromSharedVault } from '../Domain/UseCase/SharedVaults/RemoveItemsFromSharedVault/RemoveItemsFromSharedVault'
 import { SharedVaultRemovedEventHandler } from '../Domain/Handler/SharedVaultRemovedEventHandler'
+import { DesignateSurvivor } from '../Domain/UseCase/SharedVaults/DesignateSurvivor/DesignateSurvivor'
 
 export class ContainerConfigLoader {
   private readonly DEFAULT_CONTENT_SIZE_TRANSFER_LIMIT = 10_000_000
@@ -865,6 +866,16 @@ export class ContainerConfigLoader {
             : container.get<ItemRepositoryInterface>(TYPES.Sync_SQLItemRepository),
         ),
       )
+    container
+      .bind<DesignateSurvivor>(TYPES.Sync_DesignateSurvivor)
+      .toConstantValue(
+        new DesignateSurvivor(
+          container.get<SharedVaultUserRepositoryInterface>(TYPES.Sync_SharedVaultUserRepository),
+          container.get<TimerInterface>(TYPES.Sync_Timer),
+          container.get<DomainEventFactoryInterface>(TYPES.Sync_DomainEventFactory),
+          container.get<DomainEventPublisherInterface>(TYPES.Sync_DomainEventPublisher),
+        ),
+      )
 
     // Services
     container

+ 1 - 0
packages/syncing-server/src/Bootstrap/Types.ts

@@ -87,6 +87,7 @@ const TYPES = {
   ),
   Sync_SendEventToClient: Symbol.for('Sync_SendEventToClient'),
   Sync_RemoveItemsFromSharedVault: Symbol.for('Sync_RemoveItemsFromSharedVault'),
+  Sync_DesignateSurvivor: Symbol.for('Sync_DesignateSurvivor'),
   // Handlers
   Sync_AccountDeletionRequestedEventHandler: Symbol.for('Sync_AccountDeletionRequestedEventHandler'),
   Sync_DuplicateItemSyncedEventHandler: Symbol.for('Sync_DuplicateItemSyncedEventHandler'),

+ 20 - 0
packages/syncing-server/src/Domain/Event/DomainEventFactory.ts

@@ -12,6 +12,7 @@ import {
   SharedVaultRemovedEvent,
   TransitionStatusUpdatedEvent,
   UserAddedToSharedVaultEvent,
+  UserDesignatedAsSurvivorInSharedVaultEvent,
   UserInvitedToSharedVaultEvent,
   UserRemovedFromSharedVaultEvent,
   WebSocketMessageRequestedEvent,
@@ -22,6 +23,25 @@ import { DomainEventFactoryInterface } from './DomainEventFactoryInterface'
 export class DomainEventFactory implements DomainEventFactoryInterface {
   constructor(private timer: TimerInterface) {}
 
+  createUserDesignatedAsSurvivorInSharedVaultEvent(dto: {
+    sharedVaultUuid: string
+    userUuid: string
+    timestamp: number
+  }): UserDesignatedAsSurvivorInSharedVaultEvent {
+    return {
+      type: 'USER_DESIGNATED_AS_SURVIVOR_IN_SHARED_VAULT',
+      createdAt: this.timer.getUTCDate(),
+      meta: {
+        correlation: {
+          userIdentifier: dto.userUuid,
+          userIdentifierType: 'uuid',
+        },
+        origin: DomainEventService.SyncingServer,
+      },
+      payload: dto,
+    }
+  }
+
   createSharedVaultRemovedEvent(dto: { sharedVaultUuid: string }): SharedVaultRemovedEvent {
     return {
       type: 'SHARED_VAULT_REMOVED',

+ 6 - 0
packages/syncing-server/src/Domain/Event/DomainEventFactoryInterface.ts

@@ -10,6 +10,7 @@ import {
   SharedVaultRemovedEvent,
   TransitionStatusUpdatedEvent,
   UserAddedToSharedVaultEvent,
+  UserDesignatedAsSurvivorInSharedVaultEvent,
   UserInvitedToSharedVaultEvent,
   UserRemovedFromSharedVaultEvent,
   WebSocketMessageRequestedEvent,
@@ -102,4 +103,9 @@ export interface DomainEventFactoryInterface {
     userUuid: string
   }): ItemRemovedFromSharedVaultEvent
   createSharedVaultRemovedEvent(dto: { sharedVaultUuid: string }): SharedVaultRemovedEvent
+  createUserDesignatedAsSurvivorInSharedVaultEvent(dto: {
+    sharedVaultUuid: string
+    userUuid: string
+    timestamp: number
+  }): UserDesignatedAsSurvivorInSharedVaultEvent
 }

+ 6 - 0
packages/syncing-server/src/Domain/Item/SaveRule/SharedVaultFilter.spec.ts

@@ -65,6 +65,7 @@ describe('SharedVaultFilter', () => {
       sharedVaultUuid: Uuid.create('00000000-0000-0000-0000-000000000000').getValue(),
       userUuid: Uuid.create('00000000-0000-0000-0000-000000000000').getValue(),
       timestamps: Timestamps.create(123, 123).getValue(),
+      isDesignatedSurvivor: false,
     }).getValue()
 
     determineSharedVaultOperationOnItem = {} as jest.Mocked<DetermineSharedVaultOperationOnItem>
@@ -329,6 +330,7 @@ describe('SharedVaultFilter', () => {
         sharedVaultUuid: Uuid.create('00000000-0000-0000-0000-000000000000').getValue(),
         userUuid: Uuid.create('00000000-0000-0000-0000-000000000000').getValue(),
         timestamps: Timestamps.create(123, 123).getValue(),
+        isDesignatedSurvivor: false,
       }).getValue()
 
       itemHash = ItemHash.create({
@@ -489,6 +491,7 @@ describe('SharedVaultFilter', () => {
         sharedVaultUuid: Uuid.create('00000000-0000-0000-0000-000000000000').getValue(),
         userUuid: Uuid.create('00000000-0000-0000-0000-000000000000').getValue(),
         timestamps: Timestamps.create(123, 123).getValue(),
+        isDesignatedSurvivor: false,
       }).getValue()
 
       itemHash = ItemHash.create({
@@ -649,6 +652,7 @@ describe('SharedVaultFilter', () => {
         sharedVaultUuid: Uuid.create('00000000-0000-0000-0000-000000000000').getValue(),
         userUuid: Uuid.create('00000000-0000-0000-0000-000000000000').getValue(),
         timestamps: Timestamps.create(123, 123).getValue(),
+        isDesignatedSurvivor: false,
       }).getValue()
 
       itemHash = ItemHash.create({
@@ -734,6 +738,7 @@ describe('SharedVaultFilter', () => {
         sharedVaultUuid: Uuid.create('00000000-0000-0000-0000-000000000000').getValue(),
         userUuid: Uuid.create('00000000-0000-0000-0000-000000000000').getValue(),
         timestamps: Timestamps.create(123, 123).getValue(),
+        isDesignatedSurvivor: false,
       }).getValue()
 
       sharedVaultUserRepository.findByUserUuidAndSharedVaultUuid = jest.fn().mockResolvedValue(sharedVaultUser)
@@ -802,6 +807,7 @@ describe('SharedVaultFilter', () => {
         sharedVaultUuid: Uuid.create('00000000-0000-0000-0000-000000000000').getValue(),
         userUuid: Uuid.create('00000000-0000-0000-0000-000000000000').getValue(),
         timestamps: Timestamps.create(123, 123).getValue(),
+        isDesignatedSurvivor: false,
       }).getValue()
 
       itemHash = ItemHash.create({

+ 1 - 0
packages/syncing-server/src/Domain/UseCase/Messaging/AddNotificationsForUsers/AddNotificationsForUsers.spec.ts

@@ -25,6 +25,7 @@ describe('AddNotificationsForUsers', () => {
       sharedVaultUuid: Uuid.create('00000000-0000-0000-0000-000000000000').getValue(),
       userUuid: Uuid.create('00000000-0000-0000-0000-000000000000').getValue(),
       timestamps: Timestamps.create(123, 123).getValue(),
+      isDesignatedSurvivor: false,
     }).getValue()
 
     sharedVaultUserRepository = {} as jest.Mocked<SharedVaultUserRepositoryInterface>

+ 1 - 0
packages/syncing-server/src/Domain/UseCase/SharedVaults/AddUserToSharedVault/AddUserToSharedVault.ts

@@ -63,6 +63,7 @@ export class AddUserToSharedVault implements UseCaseInterface<SharedVaultUser> {
       sharedVaultUuid,
       permission,
       timestamps,
+      isDesignatedSurvivor: false,
     })
     if (sharedVaultUserOrError.isFailed()) {
       return Result.fail(sharedVaultUserOrError.getError())

+ 13 - 0
packages/syncing-server/src/Domain/UseCase/SharedVaults/CreateSharedVaultFileValetToken/CreateSharedVaultFileValetToken.spec.ts

@@ -31,6 +31,7 @@ describe('CreateSharedVaultFileValetToken', () => {
       sharedVaultUuid: Uuid.create('00000000-0000-0000-0000-000000000000').getValue(),
       userUuid: Uuid.create('00000000-0000-0000-0000-000000000000').getValue(),
       timestamps: Timestamps.create(123, 123).getValue(),
+      isDesignatedSurvivor: false,
     }).getValue()
 
     sharedVaultUserRepository = {} as jest.Mocked<SharedVaultUserRepositoryInterface>
@@ -115,6 +116,7 @@ describe('CreateSharedVaultFileValetToken', () => {
       sharedVaultUuid: Uuid.create('00000000-0000-0000-0000-000000000000').getValue(),
       userUuid: Uuid.create('00000000-0000-0000-0000-000000000000').getValue(),
       timestamps: Timestamps.create(123, 123).getValue(),
+      isDesignatedSurvivor: false,
     }).getValue()
     sharedVaultUserRepository.findByUserUuidAndSharedVaultUuid = jest.fn().mockResolvedValue(sharedVaultUser)
 
@@ -140,6 +142,7 @@ describe('CreateSharedVaultFileValetToken', () => {
             sharedVaultUuid: Uuid.create('00000000-0000-0000-0000-000000000000').getValue(),
             userUuid: Uuid.create('00000000-0000-0000-0000-000000000000').getValue(),
             timestamps: Timestamps.create(123, 123).getValue(),
+            isDesignatedSurvivor: false,
           }).getValue(),
         )
         .mockReturnValueOnce(
@@ -148,6 +151,7 @@ describe('CreateSharedVaultFileValetToken', () => {
             sharedVaultUuid: Uuid.create('00000000-0000-0000-0000-000000000000').getValue(),
             userUuid: Uuid.create('00000000-0000-0000-0000-000000000000').getValue(),
             timestamps: Timestamps.create(123, 123).getValue(),
+            isDesignatedSurvivor: false,
           }).getValue(),
         )
     })
@@ -203,6 +207,7 @@ describe('CreateSharedVaultFileValetToken', () => {
             sharedVaultUuid: Uuid.create('00000000-0000-0000-0000-000000000000').getValue(),
             userUuid: Uuid.create('00000000-0000-0000-0000-000000000000').getValue(),
             timestamps: Timestamps.create(123, 123).getValue(),
+            isDesignatedSurvivor: false,
           }).getValue(),
         )
         .mockReturnValueOnce(null)
@@ -230,6 +235,7 @@ describe('CreateSharedVaultFileValetToken', () => {
             sharedVaultUuid: Uuid.create('00000000-0000-0000-0000-000000000000').getValue(),
             userUuid: Uuid.create('00000000-0000-0000-0000-000000000000').getValue(),
             timestamps: Timestamps.create(123, 123).getValue(),
+            isDesignatedSurvivor: false,
           }).getValue(),
         )
         .mockReturnValueOnce(
@@ -238,6 +244,7 @@ describe('CreateSharedVaultFileValetToken', () => {
             sharedVaultUuid: Uuid.create('00000000-0000-0000-0000-000000000000').getValue(),
             userUuid: Uuid.create('00000000-0000-0000-0000-000000000000').getValue(),
             timestamps: Timestamps.create(123, 123).getValue(),
+            isDesignatedSurvivor: false,
           }).getValue(),
         )
 
@@ -281,6 +288,7 @@ describe('CreateSharedVaultFileValetToken', () => {
             sharedVaultUuid: Uuid.create('00000000-0000-0000-0000-000000000000').getValue(),
             userUuid: Uuid.create('00000000-0000-0000-0000-000000000000').getValue(),
             timestamps: Timestamps.create(123, 123).getValue(),
+            isDesignatedSurvivor: false,
           }).getValue(),
         )
         .mockReturnValueOnce(
@@ -289,6 +297,7 @@ describe('CreateSharedVaultFileValetToken', () => {
             sharedVaultUuid: Uuid.create('00000000-0000-0000-0000-000000000000').getValue(),
             userUuid: Uuid.create('00000000-0000-0000-0000-000000000000').getValue(),
             timestamps: Timestamps.create(123, 123).getValue(),
+            isDesignatedSurvivor: false,
           }).getValue(),
         )
 
@@ -315,6 +324,7 @@ describe('CreateSharedVaultFileValetToken', () => {
             sharedVaultUuid: Uuid.create('00000000-0000-0000-0000-000000000000').getValue(),
             userUuid: Uuid.create('00000000-0000-0000-0000-000000000000').getValue(),
             timestamps: Timestamps.create(123, 123).getValue(),
+            isDesignatedSurvivor: false,
           }).getValue(),
         )
         .mockReturnValueOnce(
@@ -323,6 +333,7 @@ describe('CreateSharedVaultFileValetToken', () => {
             sharedVaultUuid: Uuid.create('00000000-0000-0000-0000-000000000000').getValue(),
             userUuid: Uuid.create('00000000-0000-0000-0000-000000000000').getValue(),
             timestamps: Timestamps.create(123, 123).getValue(),
+            isDesignatedSurvivor: false,
           }).getValue(),
         )
 
@@ -349,6 +360,7 @@ describe('CreateSharedVaultFileValetToken', () => {
             sharedVaultUuid: Uuid.create('00000000-0000-0000-0000-000000000000').getValue(),
             userUuid: Uuid.create('00000000-0000-0000-0000-000000000000').getValue(),
             timestamps: Timestamps.create(123, 123).getValue(),
+            isDesignatedSurvivor: false,
           }).getValue(),
         )
         .mockReturnValueOnce(
@@ -357,6 +369,7 @@ describe('CreateSharedVaultFileValetToken', () => {
             sharedVaultUuid: Uuid.create('00000000-0000-0000-0000-000000000000').getValue(),
             userUuid: Uuid.create('00000000-0000-0000-0000-000000000000').getValue(),
             timestamps: Timestamps.create(123, 123).getValue(),
+            isDesignatedSurvivor: false,
           }).getValue(),
         )
 

+ 1 - 0
packages/syncing-server/src/Domain/UseCase/SharedVaults/DeleteSharedVault/DeleteSharedVault.spec.ts

@@ -49,6 +49,7 @@ describe('DeleteSharedVault', () => {
       sharedVaultUuid: Uuid.create('00000000-0000-0000-0000-000000000000').getValue(),
       userUuid: Uuid.create('00000000-0000-0000-0000-000000000000').getValue(),
       timestamps: Timestamps.create(123, 123).getValue(),
+      isDesignatedSurvivor: false,
     }).getValue()
     sharedVaultUserRepository = {} as jest.Mocked<SharedVaultUserRepositoryInterface>
     sharedVaultUserRepository.findBySharedVaultUuid = jest.fn().mockResolvedValue([sharedVaultUser])

+ 158 - 0
packages/syncing-server/src/Domain/UseCase/SharedVaults/DesignateSurvivor/DesignateSurvivor.spec.ts

@@ -0,0 +1,158 @@
+import { SharedVaultUser, SharedVaultUserPermission, Timestamps, Uuid } from '@standardnotes/domain-core'
+
+import { SharedVaultUserRepositoryInterface } from '../../../SharedVault/User/SharedVaultUserRepositoryInterface'
+import { DesignateSurvivor } from './DesignateSurvivor'
+import { TimerInterface } from '@standardnotes/time'
+import { DomainEventInterface, DomainEventPublisherInterface } from '@standardnotes/domain-events'
+import { DomainEventFactoryInterface } from '../../../Event/DomainEventFactoryInterface'
+
+describe('DesignateSurvivor', () => {
+  let sharedVaultUserRepository: SharedVaultUserRepositoryInterface
+  let sharedVaultUser: SharedVaultUser
+  let sharedVaultOwner: SharedVaultUser
+  let timer: TimerInterface
+  let domainEventFactory: DomainEventFactoryInterface
+  let domainEventPublisher: DomainEventPublisherInterface
+
+  const createUseCase = () =>
+    new DesignateSurvivor(sharedVaultUserRepository, timer, domainEventFactory, domainEventPublisher)
+
+  beforeEach(() => {
+    timer = {} as jest.Mocked<TimerInterface>
+    timer.getTimestampInMicroseconds = jest.fn().mockReturnValue(123)
+
+    sharedVaultOwner = SharedVaultUser.create({
+      permission: SharedVaultUserPermission.create(SharedVaultUserPermission.PERMISSIONS.Admin).getValue(),
+      sharedVaultUuid: Uuid.create('00000000-0000-0000-0000-000000000000').getValue(),
+      userUuid: Uuid.create('00000000-0000-0000-0000-000000000002').getValue(),
+      timestamps: Timestamps.create(123, 123).getValue(),
+      isDesignatedSurvivor: false,
+    }).getValue()
+
+    sharedVaultUser = SharedVaultUser.create({
+      permission: SharedVaultUserPermission.create(SharedVaultUserPermission.PERMISSIONS.Write).getValue(),
+      sharedVaultUuid: Uuid.create('00000000-0000-0000-0000-000000000000').getValue(),
+      userUuid: Uuid.create('00000000-0000-0000-0000-000000000000').getValue(),
+      timestamps: Timestamps.create(123, 123).getValue(),
+      isDesignatedSurvivor: false,
+    }).getValue()
+
+    sharedVaultUserRepository = {} as jest.Mocked<SharedVaultUserRepositoryInterface>
+    sharedVaultUserRepository.findBySharedVaultUuid = jest.fn().mockReturnValue([])
+    sharedVaultUserRepository.save = jest.fn()
+
+    domainEventFactory = {} as jest.Mocked<DomainEventFactoryInterface>
+    domainEventFactory.createUserDesignatedAsSurvivorInSharedVaultEvent = jest
+      .fn()
+      .mockReturnValue({} as jest.Mocked<DomainEventInterface>)
+
+    domainEventPublisher = {} as jest.Mocked<DomainEventPublisherInterface>
+    domainEventPublisher.publish = jest.fn()
+  })
+
+  it('should fail if shared vault uuid is invalid', async () => {
+    const useCase = createUseCase()
+
+    const result = await useCase.execute({
+      sharedVaultUuid: 'invalid',
+      userUuid: '00000000-0000-0000-0000-000000000000',
+      originatorUuid: '00000000-0000-0000-0000-000000000002',
+    })
+
+    expect(result.isFailed()).toBe(true)
+  })
+
+  it('should fail if user uuid is invalid', async () => {
+    const useCase = createUseCase()
+
+    const result = await useCase.execute({
+      sharedVaultUuid: '00000000-0000-0000-0000-000000000000',
+      userUuid: 'invalid',
+      originatorUuid: '00000000-0000-0000-0000-000000000002',
+    })
+
+    expect(result.isFailed()).toBe(true)
+  })
+
+  it('should fail if originator uuid is invalid', async () => {
+    const useCase = createUseCase()
+
+    const result = await useCase.execute({
+      sharedVaultUuid: '00000000-0000-0000-0000-000000000000',
+      userUuid: '00000000-0000-0000-0000-000000000000',
+      originatorUuid: 'invalid',
+    })
+
+    expect(result.isFailed()).toBe(true)
+  })
+
+  it('should fail if shared vault user is not found', async () => {
+    sharedVaultUserRepository.findBySharedVaultUuid = jest.fn().mockReturnValue([sharedVaultOwner])
+
+    const useCase = createUseCase()
+
+    const result = await useCase.execute({
+      sharedVaultUuid: '00000000-0000-0000-0000-000000000000',
+      userUuid: '00000000-0000-0000-0000-000000000000',
+      originatorUuid: '00000000-0000-0000-0000-000000000002',
+    })
+
+    expect(result.isFailed()).toBe(true)
+  })
+
+  it('should fail if the originator is not the admin of the shared vault', async () => {
+    sharedVaultUserRepository.findBySharedVaultUuid = jest.fn().mockReturnValue([sharedVaultOwner, sharedVaultUser])
+
+    const useCase = createUseCase()
+
+    const result = await useCase.execute({
+      sharedVaultUuid: '00000000-0000-0000-0000-000000000000',
+      userUuid: '00000000-0000-0000-0000-000000000000',
+      originatorUuid: '00000000-0000-0000-0000-000000000003',
+    })
+
+    expect(result.isFailed()).toBe(true)
+  })
+
+  it('should designate a survivor if the user is a member', async () => {
+    sharedVaultUserRepository.findBySharedVaultUuid = jest.fn().mockReturnValue([sharedVaultOwner, sharedVaultUser])
+
+    const useCase = createUseCase()
+
+    const result = await useCase.execute({
+      sharedVaultUuid: '00000000-0000-0000-0000-000000000000',
+      userUuid: '00000000-0000-0000-0000-000000000000',
+      originatorUuid: '00000000-0000-0000-0000-000000000002',
+    })
+
+    expect(result.isFailed()).toBe(false)
+    expect(sharedVaultUser.props.isDesignatedSurvivor).toBe(true)
+    expect(sharedVaultUserRepository.save).toBeCalledTimes(1)
+  })
+
+  it('should designate a survivor if the user is a member and there is already a survivor', async () => {
+    sharedVaultUserRepository.findBySharedVaultUuid = jest.fn().mockReturnValue([
+      sharedVaultOwner,
+      sharedVaultUser,
+      SharedVaultUser.create({
+        permission: SharedVaultUserPermission.create(SharedVaultUserPermission.PERMISSIONS.Write).getValue(),
+        sharedVaultUuid: Uuid.create('00000000-0000-0000-0000-000000000000').getValue(),
+        userUuid: Uuid.create('00000000-0000-0000-0000-000000000001').getValue(),
+        timestamps: Timestamps.create(123, 123).getValue(),
+        isDesignatedSurvivor: true,
+      }).getValue(),
+    ])
+
+    const useCase = createUseCase()
+
+    const result = await useCase.execute({
+      sharedVaultUuid: '00000000-0000-0000-0000-000000000000',
+      userUuid: '00000000-0000-0000-0000-000000000000',
+      originatorUuid: '00000000-0000-0000-0000-000000000002',
+    })
+
+    expect(result.isFailed()).toBe(false)
+    expect(sharedVaultUser.props.isDesignatedSurvivor).toBe(true)
+    expect(sharedVaultUserRepository.save).toBeCalledTimes(2)
+  })
+})

+ 97 - 0
packages/syncing-server/src/Domain/UseCase/SharedVaults/DesignateSurvivor/DesignateSurvivor.ts

@@ -0,0 +1,97 @@
+import { TimerInterface } from '@standardnotes/time'
+import { DomainEventPublisherInterface } from '@standardnotes/domain-events'
+import {
+  Result,
+  SharedVaultUser,
+  SharedVaultUserPermission,
+  Timestamps,
+  UseCaseInterface,
+  Uuid,
+} from '@standardnotes/domain-core'
+
+import { SharedVaultUserRepositoryInterface } from '../../../SharedVault/User/SharedVaultUserRepositoryInterface'
+import { DesignateSurvivorDTO } from './DesignateSurvivorDTO'
+import { DomainEventFactoryInterface } from '../../../Event/DomainEventFactoryInterface'
+
+export class DesignateSurvivor implements UseCaseInterface<void> {
+  constructor(
+    private sharedVaultUserRepository: SharedVaultUserRepositoryInterface,
+    private timer: TimerInterface,
+    private domainEventFactory: DomainEventFactoryInterface,
+    private domainEventPublisher: DomainEventPublisherInterface,
+  ) {}
+
+  async execute(dto: DesignateSurvivorDTO): Promise<Result<void>> {
+    const sharedVaultUuidOrError = Uuid.create(dto.sharedVaultUuid)
+    if (sharedVaultUuidOrError.isFailed()) {
+      return Result.fail(sharedVaultUuidOrError.getError())
+    }
+    const sharedVaultUuid = sharedVaultUuidOrError.getValue()
+
+    const userUuidOrError = Uuid.create(dto.userUuid)
+    if (userUuidOrError.isFailed()) {
+      return Result.fail(userUuidOrError.getError())
+    }
+    const userUuid = userUuidOrError.getValue()
+
+    const originatorUuidOrError = Uuid.create(dto.originatorUuid)
+    if (originatorUuidOrError.isFailed()) {
+      return Result.fail(originatorUuidOrError.getError())
+    }
+    const originatorUuid = originatorUuidOrError.getValue()
+
+    const sharedVaultUsers = await this.sharedVaultUserRepository.findBySharedVaultUuid(sharedVaultUuid)
+    let sharedVaultExistingSurvivor: SharedVaultUser | undefined
+    let toBeDesignatedAsASurvivor: SharedVaultUser | undefined
+    let isOriginatorTheOwner = false
+    for (const sharedVaultUser of sharedVaultUsers) {
+      if (sharedVaultUser.props.userUuid.equals(userUuid)) {
+        toBeDesignatedAsASurvivor = sharedVaultUser
+      }
+      if (sharedVaultUser.props.isDesignatedSurvivor) {
+        sharedVaultExistingSurvivor = sharedVaultUser
+      }
+      if (
+        sharedVaultUser.props.userUuid.equals(originatorUuid) &&
+        sharedVaultUser.props.permission.value === SharedVaultUserPermission.PERMISSIONS.Admin
+      ) {
+        isOriginatorTheOwner = true
+      }
+    }
+
+    if (!isOriginatorTheOwner) {
+      return Result.fail('Only the owner can designate a survivor')
+    }
+
+    if (!toBeDesignatedAsASurvivor) {
+      return Result.fail('Attempting to designate a survivor for a non-member')
+    }
+
+    if (sharedVaultExistingSurvivor) {
+      sharedVaultExistingSurvivor.props.isDesignatedSurvivor = false
+      sharedVaultExistingSurvivor.props.timestamps = Timestamps.create(
+        sharedVaultExistingSurvivor.props.timestamps.createdAt,
+        this.timer.getTimestampInMicroseconds(),
+      ).getValue()
+      await this.sharedVaultUserRepository.save(sharedVaultExistingSurvivor)
+    }
+
+    toBeDesignatedAsASurvivor.props.isDesignatedSurvivor = true
+    toBeDesignatedAsASurvivor.props.timestamps = Timestamps.create(
+      toBeDesignatedAsASurvivor.props.timestamps.createdAt,
+      this.timer.getTimestampInMicroseconds(),
+    ).getValue()
+
+    await this.sharedVaultUserRepository.save(toBeDesignatedAsASurvivor)
+
+    await this.domainEventPublisher.publish(
+      this.domainEventFactory.createUserDesignatedAsSurvivorInSharedVaultEvent({
+        sharedVaultUuid: sharedVaultUuid.value,
+        userUuid: userUuid.value,
+        timestamp: this.timer.getTimestampInMicroseconds(),
+      }),
+    )
+
+    return Result.ok()
+  }
+}

+ 5 - 0
packages/syncing-server/src/Domain/UseCase/SharedVaults/DesignateSurvivor/DesignateSurvivorDTO.ts

@@ -0,0 +1,5 @@
+export interface DesignateSurvivorDTO {
+  sharedVaultUuid: string
+  userUuid: string
+  originatorUuid: string
+}

+ 1 - 0
packages/syncing-server/src/Domain/UseCase/SharedVaults/GetSharedVaultUsers/GetSharedVaultUsers.spec.ts

@@ -25,6 +25,7 @@ describe('GetSharedVaultUsers', () => {
       sharedVaultUuid: Uuid.create('00000000-0000-0000-0000-000000000000').getValue(),
       userUuid: Uuid.create('00000000-0000-0000-0000-000000000000').getValue(),
       timestamps: Timestamps.create(123, 123).getValue(),
+      isDesignatedSurvivor: false,
     }).getValue()
 
     sharedVaultRepository = {} as jest.Mocked<SharedVaultRepositoryInterface>

+ 1 - 0
packages/syncing-server/src/Domain/UseCase/SharedVaults/GetSharedVaults/GetSharedVaults.spec.ts

@@ -19,6 +19,7 @@ describe('GetSharedVaults', () => {
       sharedVaultUuid: Uuid.create('00000000-0000-0000-0000-000000000000').getValue(),
       permission: SharedVaultUserPermission.create(SharedVaultUserPermission.PERMISSIONS.Admin).getValue(),
       timestamps: Timestamps.create(123, 123).getValue(),
+      isDesignatedSurvivor: false,
     }).getValue()
     sharedVaultUserRepository = {} as jest.Mocked<SharedVaultUserRepositoryInterface>
     sharedVaultUserRepository.findByUserUuid = jest.fn().mockResolvedValue([sharedVaultUser])

+ 1 - 0
packages/syncing-server/src/Domain/UseCase/SharedVaults/InviteUserToSharedVault/InviteUserToSharedVault.spec.ts

@@ -53,6 +53,7 @@ describe('InviteUserToSharedVault', () => {
       userUuid: Uuid.create('00000000-0000-0000-0000-000000000000').getValue(),
       permission: SharedVaultUserPermission.create(SharedVaultUserPermission.PERMISSIONS.Read).getValue(),
       timestamps: Timestamps.create(123, 123).getValue(),
+      isDesignatedSurvivor: false,
     }).getValue()
 
     sharedVaultUserRepository = {} as jest.Mocked<SharedVaultUserRepositoryInterface>

+ 1 - 0
packages/syncing-server/src/Domain/UseCase/SharedVaults/RemoveUserFromSharedVault/RemoveUserFromSharedVault.spec.ts

@@ -51,6 +51,7 @@ describe('RemoveUserFromSharedVault', () => {
       sharedVaultUuid: Uuid.create('00000000-0000-0000-0000-000000000000').getValue(),
       userUuid: Uuid.create('00000000-0000-0000-0000-000000000000').getValue(),
       timestamps: Timestamps.create(123, 123).getValue(),
+      isDesignatedSurvivor: false,
     }).getValue()
     sharedVaultUserRepository = {} as jest.Mocked<SharedVaultUserRepositoryInterface>
     sharedVaultUserRepository.findByUserUuidAndSharedVaultUuid = jest.fn().mockResolvedValue(sharedVaultUser)

+ 14 - 2
packages/syncing-server/src/Infra/InversifyExpressUtils/AnnotatedSharedVaultUsersController.ts

@@ -1,4 +1,4 @@
-import { controller, httpDelete, httpGet, results } from 'inversify-express-utils'
+import { controller, httpDelete, httpGet, httpPost, results } from 'inversify-express-utils'
 import { inject } from 'inversify'
 import { MapperInterface, SharedVaultUser } from '@standardnotes/domain-core'
 import { Request, Response } from 'express'
@@ -8,16 +8,23 @@ import TYPES from '../../Bootstrap/Types'
 import { SharedVaultUserHttpRepresentation } from '../../Mapping/Http/SharedVaultUserHttpRepresentation'
 import { GetSharedVaultUsers } from '../../Domain/UseCase/SharedVaults/GetSharedVaultUsers/GetSharedVaultUsers'
 import { RemoveUserFromSharedVault } from '../../Domain/UseCase/SharedVaults/RemoveUserFromSharedVault/RemoveUserFromSharedVault'
+import { DesignateSurvivor } from '../../Domain/UseCase/SharedVaults/DesignateSurvivor/DesignateSurvivor'
 
 @controller('/shared-vaults/:sharedVaultUuid/users', TYPES.Sync_AuthMiddleware)
 export class AnnotatedSharedVaultUsersController extends BaseSharedVaultUsersController {
   constructor(
     @inject(TYPES.Sync_GetSharedVaultUsers) override getSharedVaultUsersUseCase: GetSharedVaultUsers,
     @inject(TYPES.Sync_RemoveSharedVaultUser) override removeUserFromSharedVaultUseCase: RemoveUserFromSharedVault,
+    @inject(TYPES.Sync_DesignateSurvivor) override designateSurvivorUseCase: DesignateSurvivor,
     @inject(TYPES.Sync_SharedVaultUserHttpMapper)
     override sharedVaultUserHttpMapper: MapperInterface<SharedVaultUser, SharedVaultUserHttpRepresentation>,
   ) {
-    super(getSharedVaultUsersUseCase, removeUserFromSharedVaultUseCase, sharedVaultUserHttpMapper)
+    super(
+      getSharedVaultUsersUseCase,
+      removeUserFromSharedVaultUseCase,
+      designateSurvivorUseCase,
+      sharedVaultUserHttpMapper,
+    )
   }
 
   @httpGet('/')
@@ -29,4 +36,9 @@ export class AnnotatedSharedVaultUsersController extends BaseSharedVaultUsersCon
   override async removeUserFromSharedVault(request: Request, response: Response): Promise<results.JsonResult> {
     return super.removeUserFromSharedVault(request, response)
   }
+
+  @httpPost('/:userUuid/designate-survivor')
+  override async designateSurvivor(request: Request, response: Response): Promise<results.JsonResult> {
+    return super.designateSurvivor(request, response)
+  }
 }

+ 26 - 0
packages/syncing-server/src/Infra/InversifyExpressUtils/Base/BaseSharedVaultUsersController.ts

@@ -6,11 +6,13 @@ import { ControllerContainerInterface, MapperInterface, SharedVaultUser } from '
 import { SharedVaultUserHttpRepresentation } from '../../../Mapping/Http/SharedVaultUserHttpRepresentation'
 import { GetSharedVaultUsers } from '../../../Domain/UseCase/SharedVaults/GetSharedVaultUsers/GetSharedVaultUsers'
 import { RemoveUserFromSharedVault } from '../../../Domain/UseCase/SharedVaults/RemoveUserFromSharedVault/RemoveUserFromSharedVault'
+import { DesignateSurvivor } from '../../../Domain/UseCase/SharedVaults/DesignateSurvivor/DesignateSurvivor'
 
 export class BaseSharedVaultUsersController extends BaseHttpController {
   constructor(
     protected getSharedVaultUsersUseCase: GetSharedVaultUsers,
     protected removeUserFromSharedVaultUseCase: RemoveUserFromSharedVault,
+    protected designateSurvivorUseCase: DesignateSurvivor,
     protected sharedVaultUserHttpMapper: MapperInterface<SharedVaultUser, SharedVaultUserHttpRepresentation>,
     private controllerContainer?: ControllerContainerInterface,
   ) {
@@ -22,6 +24,7 @@ export class BaseSharedVaultUsersController extends BaseHttpController {
         'sync.shared-vault-users.remove-user',
         this.removeUserFromSharedVault.bind(this),
       )
+      this.controllerContainer.register('sync.shared-vault-users.designate-survivor', this.designateSurvivor.bind(this))
     }
   }
 
@@ -71,4 +74,27 @@ export class BaseSharedVaultUsersController extends BaseHttpController {
       success: true,
     })
   }
+
+  async designateSurvivor(request: Request, response: Response): Promise<results.JsonResult> {
+    const result = await this.designateSurvivorUseCase.execute({
+      sharedVaultUuid: request.params.sharedVaultUuid,
+      userUuid: request.params.userUuid,
+      originatorUuid: response.locals.user.uuid,
+    })
+
+    if (result.isFailed()) {
+      return this.json(
+        {
+          error: {
+            message: result.getError(),
+          },
+        },
+        HttpStatusCode.BadRequest,
+      )
+    }
+
+    return this.json({
+      success: true,
+    })
+  }
 }

+ 7 - 0
packages/syncing-server/src/Infra/TypeORM/TypeORMSharedVaultUser.ts

@@ -26,6 +26,13 @@ export class TypeORMSharedVaultUser {
   })
   declare permission: string
 
+  @Column({
+    name: 'is_designated_survivor',
+    type: 'boolean',
+    default: false,
+  })
+  declare isDesignatedSurvivor: boolean
+
   @Column({
     name: 'created_at_timestamp',
     type: 'bigint',

+ 1 - 0
packages/syncing-server/src/Mapping/Http/SharedVaultUserHttpMapper.ts

@@ -13,6 +13,7 @@ export class SharedVaultUserHttpMapper implements MapperInterface<SharedVaultUse
       user_uuid: domain.props.userUuid.value,
       permission: domain.props.permission.value,
       shared_vault_uuid: domain.props.sharedVaultUuid.value,
+      is_designated_survivor: domain.props.isDesignatedSurvivor,
       created_at_timestamp: domain.props.timestamps.createdAt,
       updated_at_timestamp: domain.props.timestamps.updatedAt,
     }

+ 1 - 0
packages/syncing-server/src/Mapping/Http/SharedVaultUserHttpRepresentation.ts

@@ -3,6 +3,7 @@ export interface SharedVaultUserHttpRepresentation {
   shared_vault_uuid: string
   user_uuid: string
   permission: string
+  is_designated_survivor: boolean
   created_at_timestamp: number
   updated_at_timestamp: number
 }

+ 2 - 0
packages/syncing-server/src/Mapping/Persistence/SharedVaultUserPersistenceMapper.ts

@@ -41,6 +41,7 @@ export class SharedVaultUserPersistenceMapper implements MapperInterface<SharedV
         sharedVaultUuid,
         permission,
         timestamps,
+        isDesignatedSurvivor: !!projection.isDesignatedSurvivor,
       },
       new UniqueEntityId(projection.uuid),
     )
@@ -61,6 +62,7 @@ export class SharedVaultUserPersistenceMapper implements MapperInterface<SharedV
     typeorm.permission = domain.props.permission.value
     typeorm.createdAtTimestamp = domain.props.timestamps.createdAt
     typeorm.updatedAtTimestamp = domain.props.timestamps.updatedAt
+    typeorm.isDesignatedSurvivor = !!domain.props.isDesignatedSurvivor
 
     return typeorm
   }