ElvTenant.js

Back
const {ElvUtils} = require("./Utils");
const {ElvAccount} = require("./ElvAccount");

const {ElvClient} = require("@eluvio/elv-client-js");
const Utils = require("@eluvio/elv-client-js/src/Utils.js");

const Ethers = require("ethers");
const fs = require("fs");
const path = require("path");
const {Config} = require("./Config");
const {EluvioLive} = require("./EluvioLive");
const urljoin = require("url-join");
const constants = require("./Constants");
const {ElvFabric} = require("./ElvFabric");

/**
 * Provides tenant administration operations on the Eluvio Content Fabric:
 * managing access groups (tenant admins, content admins, tenant users),
 * configuring faucet funding and sharing keys, and setting tenant status.
 */
class ElvTenant {
  /**
   * Instantiate the ElvTenant object
   *
   * @namedParams
   * @param {string} configUrl - The Content Fabric configuration URL
   * @param {boolean} [debugLogging=false] - Enable verbose debug logging
   * @return {ElvTenant} - New ElvTenant object connected to the specified content fabric and blockchain
   */
  constructor({configUrl, debugLogging = false}) {
    this.configUrl = configUrl;
    this.debug = debugLogging;
  }

  /**
   * Initialize the ElvTenant SDK with the provided private key.
   *
   * @namedParams
   * @param {string} privateKey - Hex-encoded private key for the signing account
   */
  async Init({privateKey}) {
    this.client = await ElvClient.FromConfigurationUrl({
      configUrl: this.configUrl,
    });
    let wallet = this.client.GenerateWallet();
    let signer = wallet.AddAccount({
      privateKey: privateKey,
    });
    this.client.SetSigner({signer});
    this.client.ToggleLogging(this.debug);
  }

  /**
   * Get tenant-level information
   * @param {string} tenantId Tenant ID (iten)
   */
  async TenantInfo({tenantId}) {

    const abi = fs.readFileSync(
      path.resolve(__dirname, "../contracts/v3/BaseTenantSpace.abi")
    );
    const abiSpace = fs.readFileSync(
      path.resolve(__dirname, "../contracts/v3/BaseTenantSpace.abi")
    );
    const abiLib = fs.readFileSync(
      path.resolve(__dirname, "../contracts/v3/BaseLibrary.abi")
    );

    const tenantAddr = Utils.HashToAddress(tenantId);

    // Space owner
    const spaceOwner = await this.client.CallContractMethod({
      contractAddress: this.client.contentSpaceAddress,
      abi: JSON.parse(abiSpace),
      methodName: "creator",
      methodArgs: [],
      formatArguments: true,
    });

    let tenant = {
      id: tenantId,
      warns: [],
      groups: []
    };

    tenant.name = await this.client.CallContractMethod({
      contractAddress: tenantAddr,
      abi: JSON.parse(abi),
      methodName: "name",
      methodArgs: [],
      formatArguments: true,
    });

    tenant.description = await this.client.CallContractMethod({
      contractAddress: tenantAddr,
      abi: JSON.parse(abi),
      methodName: "description",
      methodArgs: [],
      formatArguments: true,
    });

    const kmsAddress = await this.client.CallContractMethod({
      contractAddress: tenantAddr,
      abi: JSON.parse(abi),
      methodName: "addressKMS",
      methodArgs: [],
      formatArguments: true,
    });
    tenant.kmsId = ElvUtils.AddressToId({prefix: "ikms", address: kmsAddress});

    tenant.owner = await this.client.CallContractMethod({
      contractAddress: tenantAddr,
      abi: JSON.parse(abi),
      methodName: "owner",
      methodArgs: [],
      formatArguments: true,
    });

    if (this.client.CurrentAccountAddress() != tenant.owner.toLowerCase()) {
      tenant.warns.push("Not run as tenant admin");
      return tenant;
    }

    tenant.creator = await this.client.CallContractMethod({
      contractAddress: tenantAddr,
      abi: JSON.parse(abi),
      methodName: "creator",
      methodArgs: [],
      formatArguments: true,
    });
    tenant.creatorId = ElvUtils.AddressToId({prefix: "ispc", address: tenant.creator});
    if (tenant.creator != spaceOwner) {
      tenant.warns.push(`Bad space ID creator id=${tenant.creatorId}`);
    }

    tenant.adminsGroup = await this.client.CallContractMethod({
      contractAddress: tenantAddr,
      abi: JSON.parse(abi),
      methodName: "groupsMapping",
      methodArgs: ["tenant_admin", 0],
      formatArguments: true,
    });

    // Fabric object information
    tenant.fabric_object_visibility = await this.client.CallContractMethod({
      contractAddress: tenantAddr,
      abi: JSON.parse(abi),
      methodName: "visibility",
      methodArgs: [],
      formatArguments: true,
    });

    const m = await this.client.ContentObjectMetadata({
      libraryId: ElvUtils.AddressToId({prefix: "ilib", address: tenantAddr}),
      objectId: ElvUtils.AddressToId({prefix: "iq__", address: tenantAddr}),
      noAuth: true
    });

    if (m.public && m.public.eluvio_live_id) {
      tenant.eluvio_live_id = m.public.eluvio_live_id;
    } else {
      tenant.eluvio_live_id = "";
    }

    const groups = await this.client.ListAccessGroups();
    for (const g of groups) {
      tenant.groups.push({id: g.id, name: g.meta.public?.name});
    }

    tenant.libs = await this.client.ContentLibraries();
    for (var i = 0; i < tenant.libs.length; i++) {
      const libTenantAdminsIdHex = await this.client.CallContractMethod({
        contractAddress: Utils.HashToAddress(tenant.libs[i]),
        abi: JSON.parse(abiLib),
        methodName: "getMeta",
        methodArgs: ["_tenantId"],
        formatArguments: true,
      });
      const libTenantAdminsId = new Buffer.from(libTenantAdminsIdHex.substring(2), "hex").toString("utf8");
      const libTenantAdmins = Utils.HashToAddress(libTenantAdminsId);
      if (libTenantAdmins.toLowerCase() != tenant.adminsGroup.toLowerCase()) {
        tenant.warns.push(`Wrong tenant ID library ${tenant.libs[i]} ${libTenantAdmins}`);
      }
    }

    return tenant;
  }

  /**
   * Return tenant admins group and content admins group corresponding to this tenant.
   * @param {string} tenantId - The ID of the tenant (iten***)
   * @param {string} asUrl - authority service URL
   * @param {boolean} show_metadata - Enable retrieving metadata from tenant object
   */
  async TenantShow({tenantId, asUrl, show_metadata = false}) {
    let contractType = await this.client.authClient.AccessType(tenantId);
    if (contractType !== this.client.authClient.ACCESS_TYPES.TENANT) {
      throw Error("the contract corresponding to this tenantId is not a tenant contract");
    }

    let tenantInfo = {};

    const tenantAddr = Utils.HashToAddress(tenantId);
    const abi = fs.readFileSync(
      path.resolve(__dirname, "../contracts/v3/BaseTenantSpace.abi")
    );

    let errors = [];

    let owner = await this.client.CallContractMethod({
      contractAddress: tenantAddr,
      abi: JSON.parse(abi),
      methodName: "owner",
      methodArgs: [],
    });

    let tenantAdminAddr;
    try {
      tenantAdminAddr = await this.client.CallContractMethod({
        contractAddress: tenantAddr,
        abi: JSON.parse(abi),
        methodName: "groupsMapping",
        methodArgs: ["tenant_admin", 0],
        formatArguments: true,
      });
    } catch (e) {
      tenantAdminAddr = null;
      errors.push("missing tenant admins");
    }
    tenantInfo["tenant_admin_address"] = tenantAdminAddr;

    //Content admins group might not exist for the tenant with this tenantId due to legacy reasons.
    //Running ./elv-admin tenant-fix to update this tenant.
    let contentAdminAddr;
    try {
      contentAdminAddr = await this.client.CallContractMethod({
        contractAddress: tenantAddr,
        abi: JSON.parse(abi),
        methodName: "groupsMapping",
        methodArgs: ["content_admin", 0],
        formatArguments: true,
      });
    } catch (e) {
      contentAdminAddr = null;
      errors.push("missing content admins");
    }
    tenantInfo["content_admin_address"] = contentAdminAddr;

    let tenantUsersAddr;
    try {
      tenantUsersAddr = await this.client.CallContractMethod({
        contractAddress: tenantAddr,
        abi: JSON.parse(abi),
        methodName: "groupsMapping",
        methodArgs: ["tenant_users", 0],
        formatArguments: true,
      });
    } catch (e) {
      tenantUsersAddr = null;
      errors.push("missing tenant users group");
    }
    if (tenantUsersAddr) {
      tenantInfo["tenant_users_address"] = tenantUsersAddr;
    }


    //Check if the groups have _ELV_TENANT_ID set correctly
    for (const group in tenantInfo) {
      let groupAddr = tenantInfo[group];
      let args = group.split("_");
      if (groupAddr) {
        let res = await this.TenantCheckGroupConfig({tenantId, groupAddr, tenantOwner: owner});
        if (!res.success) {
          errors.push(`${args[0]} ${args[1]} ${res.message}`);
        }
      }
    }

    tenantInfo["tenant_root_key"] = owner;
    tenantInfo["tenant_status"] = await this.TenantStatus({tenantContractId: tenantId});

    try {

      tenantInfo["faucet"] = await this.TenantGetFaucet({
        asUrl,
        tenantId,
      });
    } catch (e) {
      tenantInfo["faucet"] = null;
      errors.push("faucet error: " + JSON.stringify(e));
    }

    try {
      tenantInfo["sharing"] = await this.TenantGetSharingKey({
        asUrl,
        tenantId,
      });
    } catch (e) {
      tenantInfo["sharing"] = null;
      errors.push("sharing key error: " + JSON.stringify(e));
    }

    if (show_metadata) {
      let services = [];
      let tenantObjectId = ElvUtils.AddressToId({prefix: "iq__", address: tenantAddr});
      let tenantLibraryId = await this.client.ContentObjectLibraryId({objectId: tenantObjectId});

      try {
        let liveId = await this.client.ContentObjectMetadata({
          libraryId: tenantLibraryId,
          objectId: tenantObjectId,
          noAuth: true,
          select: "public/eluvio_live_id",
        });
        if (liveId["public"]) {
          services.push(liveId["public"]);
        }
      } catch (e) {
        console.log(e);
        errors.push("Encountered an error when getting metadata for the eluvio_live_id service");
      }
      if (services.length !== 0) {
        tenantInfo["services"] = services;
      }
    }

    if (errors.length !== 0) {
      tenantInfo["errors"] = errors;
    }

    return tenantInfo;
  }

  /**
   * Create a new content admin group corresponding to this tenant.
   * @param {string} tenantId - The ID of the tenant (iten***)
   * @param {string} contentAdminAddr - Content Admin Group's address, new group will be created if not specified (optional)
   * @returns {string} Content Admin Group's address
   */
  async TenantSetContentAdmins({tenantId, contentAdminAddr}) {
    //Check that the user is the owner of the tenant
    const tenantOwner = await this.client.authClient.Owner({id: tenantId});
    if (tenantOwner.toLowerCase() != this.client.signer.address.toLowerCase()) {
      throw Error("Content Admin must be set by the owner of tenant " + tenantId);
    }

    //The tenant must not already have a content admin group - can only have 1 content admin group for each tenant.
    const abi = fs.readFileSync(
      path.resolve(__dirname, "../contracts/v3/BaseTenantSpace.abi")
    );

    const tenantAddr = Utils.HashToAddress(tenantId);
    const tenantName = await this.client.CallContractMethod({
      contractAddress: tenantAddr,
      abi: JSON.parse(abi),
      methodName: "name",
      methodArgs: [],
      formatArguments: true,
    });

    let contentAdmin;
    try {
      contentAdmin = await this.client.CallContractMethod({
        contractAddress: tenantAddr,
        abi: JSON.parse(abi),
        methodName: "groupsMapping",
        methodArgs: ["content_admin", 0],
        formatArguments: true,
      });
    } catch (e) {
      //call cannot override gasLimit error will be thrown if content admin group doesn't exist for this tenant.
      contentAdmin = null;
    }

    if (contentAdmin) {
      let logMsg = `Tenant ${tenantId} already has a content admin group: ${contentAdmin}, aborting...`;
      return logMsg;
    }

    let elvAccount = new ElvAccount({configUrl: this.configUrl, debugLogging: this.debug});
    elvAccount.InitWithClient({elvClient: this.client});

    //Arguments don't contain content admin group address, creating a new content admin group for the user's account.
    if (!contentAdminAddr) {
      let contentAdminGroup = await elvAccount.CreateAccessGroup({
        name: `${tenantName} Content Admins`,
      });

      contentAdminAddr = contentAdminGroup.address;

      await elvAccount.AddToAccessGroup({
        groupAddress: contentAdminAddr,
        accountAddress: this.client.signer.address.toLowerCase(),
        isManager: true,
      });
    }

    //Associate the group with this tenant - set the content admin group's _ELV_TENANT_ID to this tenant's tenant id.
    await this.TenantSetGroupConfig({tenantId: tenantId, groupAddress: contentAdminAddr});

    //Associate the tenant with this group - set tenant's content admin group on the tenant's contract.
    await this.client.CallContractMethodAndWait({
      contractAddress: tenantAddr,
      abi: JSON.parse(abi),
      methodName: "addGroup",
      methodArgs: ["content_admin", contentAdminAddr],
      formatArguments: true,
    });

    return contentAdminAddr;
  }

  /**
   * Remove a content admin from this tenant.
   * @param {string} tenantId - The ID of the tenant (iten***)
   * @param {string} contentAdminsAddress - Address of content admin we want to remove.
   */
  async TenantRemoveContentAdmin({tenantId, contentAdminsAddress}) {
    const abi = fs.readFileSync(
      path.resolve(__dirname, "../contracts/v3/BaseTenantSpace.abi")
    );

    const tenantAddr = Utils.HashToAddress(tenantId);

    let res = await this.client.CallContractMethodAndWait({
      contractAddress: tenantAddr,
      abi: JSON.parse(abi),
      methodName: "removeGroup",
      methodArgs: ["content_admin", contentAdminsAddress],
      formatArguments: true,
    });

    return res;
  }

  /**
   * Create a new tenant users group corresponding to this tenant.
   * @param {string} tenantId - The ID of the tenant (iten***)
   * @param {string} tenantUsersAddr - Tenant users Group's address, new group will be created if not specified (optional)
   * @returns {string} Tenant users Group's address
   */
  async TenantSetTenantUsers({tenantId, tenantUsersAddr}) {
    //Check that the user is the owner of the tenant
    const tenantOwner = await this.client.authClient.Owner({id: tenantId});
    if (tenantOwner.toLowerCase() !== this.client.signer.address.toLowerCase()) {
      throw Error("Tenant users must be set by the owner of tenant " + tenantId);
    }

    // The tenant must not already have a tenant users group - can only have 1 tenant users group for each tenant.
    const abi = fs.readFileSync(
      path.resolve(__dirname, "../contracts/v3/BaseTenantSpace.abi")
    );

    const tenantAddr = Utils.HashToAddress(tenantId);
    const tenantName = await this.client.CallContractMethod({
      contractAddress: tenantAddr,
      abi: JSON.parse(abi),
      methodName: "name",
      methodArgs: [],
      formatArguments: true,
    });

    let existingTenantUsers;
    try {
      existingTenantUsers = await this.client.CallContractMethod({
        contractAddress: tenantAddr,
        abi: JSON.parse(abi),
        methodName: "groupsMapping",
        methodArgs: ["tenant_users", 0],
        formatArguments: true,
      });
    } catch (e) {
      //call cannot override gasLimit error will be thrown if content admin group doesn't exist for this tenant.
      existingTenantUsers = null;
    }

    if (existingTenantUsers) {
      let logMsg = `Tenant ${tenantId} already has a tenant users group: ${existingTenantUsers}, aborting...`;
      return logMsg;
    }

    let elvAccount = new ElvAccount({configUrl: this.configUrl, debugLogging: this.debug});
    elvAccount.InitWithClient({elvClient: this.client});

    //Arguments don't contain tenant users group address, creating a new tenant users group for the user's account.
    if (!tenantUsersAddr) {
      let tenantUsersGroup = await elvAccount.CreateAccessGroup({
        name: `${tenantName} Tenant Users`,
      });

      tenantUsersAddr = tenantUsersGroup.address;

      await elvAccount.AddToAccessGroup({
        groupAddress: tenantUsersAddr,
        accountAddress: this.client.signer.address.toLowerCase(),
        isManager: true,
      });
    }

    //Associate the group with this tenant - set the tenant users group's _ELV_TENANT_ID to this tenant's tenant id.
    await this.TenantSetGroupConfig({tenantId: tenantId, groupAddress: tenantUsersAddr});

    //Associate the tenant with this group - set tenant's users group on the tenant's contract.
    await this.client.CallContractMethodAndWait({
      contractAddress: tenantAddr,
      abi: JSON.parse(abi),
      methodName: "addGroup",
      methodArgs: ["tenant_users", tenantUsersAddr],
      formatArguments: true,
    });

    return tenantUsersAddr;
  }

  /**
   * Remove a tenant user group from this tenant.
   * @param {string} tenantId - The ID of the tenant (iten***)
   * @param {string} tenantUsersAddress - Address of tenant users address we want to remove.
   */
  async TenantRemoveTenantUsers({tenantId, tenantUsersAddr}) {
    const abi = fs.readFileSync(
      path.resolve(__dirname, "../contracts/v3/BaseTenantSpace.abi")
    );

    const tenantAddr = Utils.HashToAddress(tenantId);

    let res = await this.client.CallContractMethodAndWait({
      contractAddress: tenantAddr,
      abi: JSON.parse(abi),
      methodName: "removeGroup",
      methodArgs: ["tenant_users", tenantUsersAddr],
      formatArguments: true,
    });

    // TODO remove tenant details from the tenant_users_group

    return res;
  }


  /**
   * Associate group with the tenant with tenantId.
   * @param {string} tenantId - The ID of the tenant (iten***)
   * @param {string} groupAddress - Address of the group we want to remove.
   */
  async TenantSetGroupConfig({tenantId, groupAddress}) {
    // TODO: use elv-client-js methods
    let idHex;
    let contractHasMeta = true;
    try {
      idHex = await this.client.CallContractMethod({
        contractAddress: groupAddress,
        methodName: "getMeta",
        methodArgs: ["_ELV_TENANT_ID"],
      });
    } catch (e) {
      console.log(`Log: The group contract with group address ${groupAddress} doesn't support metadata. Some operations with this group contract may fail.`);
      contractHasMeta = false;
    }

    // Set _ELV_TENANT_ID in the group contract's metadata if possible
    let res;
    if (contractHasMeta) {
      if (idHex != "0x") {
        let id = Ethers.utils.toUtf8String(idHex);
        if (!Utils.EqualHash(tenantId, id)) {
          throw Error(`Group ${groupAddress} already has _ELV_TENANT_ID metadata set to ${id}, aborting...`);
        } else {
          console.log(`Group ${groupAddress} already has _ELV_TENANT_ID metadata set correctly to ${id}`);
        }
      } else {
        res = await this.client.CallContractMethod({
          contractAddress: groupAddress,
          methodName: "putMeta",
          methodArgs: [
            "_ELV_TENANT_ID",
            tenantId
          ],
        });
      }
      // Set the tenant field on the contract to tenantId so that it is consistent with the metadata
      try {
        await this.client.CallContractMethod({
          contractAddress: groupAddress,
          methodName: "setTenant",
          methodArgs: [this.client.utils.HashToAddress(tenantId)],
        });
      } catch (e) {
        if (e.message.includes("Unknown method: setTenant")) {
          console.log(`Log: The group contract with address ${groupAddress} doesn't support setTenant method`);
        } else {
          throw e;
        }
      }
    }

    // If the contract doesn't have metadata, the group's fabric metadata is the main identification point and can't be replaced if set
    let groupObjectId = ElvUtils.AddressToId({prefix: "iq__", address: groupAddress});
    let groupLibraryId = await this.client.ContentObjectLibraryId({objectId: groupObjectId});

    let groupMeta = await this.client.ContentObjectMetadata({
      libraryId: groupLibraryId,
      objectId: groupObjectId,
      select: "elv/tenant_id",
    });
    if (groupMeta && !contractHasMeta) {
      let tenantContractId = groupMeta.elv.tenant_id;
      if (tenantContractId != tenantId) {
        throw Error(`Group ${groupAddress} already has elv/tenant_id content fabric metadata set to ${tenantContractId}, aborting...`);
      }
    }

    // Add tenant id to fabric meta
    var e = await this.client.EditContentObject({
      libraryId: groupLibraryId,
      objectId: groupObjectId,
    });
    await this.client.ReplaceMetadata({
      libraryId: groupLibraryId,
      objectId: groupObjectId,
      writeToken: e.write_token,
      metadataSubtree: "elv/tenant_id",
      metadata: tenantId,
    });
    await this.client.FinalizeContentObject({
      libraryId: groupLibraryId,
      objectId: groupObjectId,
      writeToken: e.write_token,
      commitMessage: "Set tenant ID " + tenantId,
    });

    return res;
  }

  /**
   * Verify that an access group is correctly associated with the given tenant.
   * Checks ownership, contract type, and tenant ID via contract metadata,
   * the `tenant` contract field, and fabric object metadata (in that order).
   *
   * @namedParams
   * @param {string} tenantId - Tenant ID (iten***)
   * @param {string} groupAddr - Address of the access group to verify
   * @param {string} tenantOwner - Expected owner address of the tenant contract
   * @returns {Promise<Object>} Object with `success` boolean, optional `message` and `need_format` fields
   */
  async TenantCheckGroupConfig({tenantId, groupAddr, tenantOwner}) {
    let groupOwner = await this.client.CallContractMethod({
      contractAddress: groupAddr,
      methodName: "owner",
      methodArgs: [],
    });
    if (groupOwner !== tenantOwner) {
      return {
        success: false,
        message: `The owner of the group (${groupOwner}) is not the same as the owner of the tenant (${tenantOwner}).`
      };
    }

    //Ensure groupAddr actually belongs to a group contract.
    if (await this.client.authClient.AccessType("igrp" + Utils.AddressToHash(groupAddr)) !== this.client.authClient.ACCESS_TYPES.GROUP) {
      return {success: false, message: "on the tenant contract is not a group", need_format: true};
    }

    let verified = false;
    //Retrieve tenant contract id associated with this group from the contract's or fabric metadata
    try {
      let tenantContractId = await this.client.TenantContractId({contractAddress: groupAddr});
      if (tenantContractId === "") {
        return {
          success: false,
          message: "group can't be verified or is not associated with any tenant",
          need_format: true
        };
      }

      if (tenantId !== tenantContractId) {
        return {success: false, message: "group doesn't belong to this tenant", need_format: true};
      }
      verified = true;
    } catch (e) {
      if (e.message.includes("Unknown method: getMeta")) {
        console.log(`Log: The group contract with group address ${groupAddr} doesn't support metadata.`);
      } else {
        throw e;
      }
    }

    if (!verified) {
      //Retrieve tenant contract id associated with this group from the contract's tenant field
      try {
        let tenantContractAddress = await this.client.CallContractMethod({
          contractAddress: groupAddr,
          methodName: "tenant",
          methodArgs: [],
        });
        let tenantContractId = "iten" + this.client.utils.AddressToHash(tenantContractAddress);
        if (tenantId !== tenantContractId) {
          return {
            success: false,
            message: "group can't be verified or is not associated with any tenant",
            need_format: true
          };
        }
        verified = true;
      } catch (e) {
        if (e.message.includes("Unknown method: tenant")) {
          console.log(`Log: the group contract with group address ${groupAddr} doesn't contain tenant information on contract.`);
        } else {
          throw e;
        }
      }
    }

    if (!verified) {
      //Retrieve tenant contract id associated with this group from its content fabric metadata
      try {
        let groupObjectId = ElvUtils.AddressToId({prefix: "iq__", address: groupAddr});
        let groupLibraryId = await this.client.ContentObjectLibraryId({objectId: groupObjectId});

        let groupMeta = await this.client.ContentObjectMetadata({
          libraryId: groupLibraryId,
          objectId: groupObjectId,
          select: "elv/tenant_id",
        });
        if (!groupMeta) {
          return {
            success: false,
            message: "group can't be verified or is not associated with any tenant",
            need_format: true
          };
        }

        let tenantContractId = groupMeta.elv.tenant_id;
        if (tenantContractId !== tenantId) {
          return {
            success: false,
            message: "group can't be verified or is not associated with any tenant",
            need_format: true
          };
        }
        verified = true;
      } catch (e) {
        if (e.message.includes("Forbidden")) {
          console.log("Log: can't verify the group's content fabric metadata - must be a tenant admin user to do so.");
        }
      }
    }

    if (verified) {
      return {success: true};
    } else {
      throw Error(`Unable to verify group ${groupAddr} - must be logged in with an account in the tenant admins group.`);
    }
  }

  /**
   * Set the Eluvio Live tenant object ID on the tenant contract.
   * @param {string} tenantId Tenant ID (iten)
   * @param {string} eluvioLiveId Object ID of the tenant-leve Eluvio Live object
   */
  async TenantSetEluvioLiveId({tenantId, eluvioLiveId}) {

    const tenantAddr = Utils.HashToAddress(tenantId);

    var e = await this.client.EditContentObject({
      libraryId: ElvUtils.AddressToId({prefix: "ilib", address: tenantAddr}),
      objectId: ElvUtils.AddressToId({prefix: "iq__", address: tenantAddr}),
    });

    await this.client.ReplaceMetadata({
      libraryId: ElvUtils.AddressToId({prefix: "ilib", address: tenantAddr}),
      objectId: ElvUtils.AddressToId({prefix: "iq__", address: tenantAddr}),
      writeToken: e.write_token,
      metadataSubtree: "public/eluvio_live_id",
      metadata: eluvioLiveId,
    });

    const res = await this.client.FinalizeContentObject({
      libraryId: ElvUtils.AddressToId({prefix: "ilib", address: tenantAddr}),
      objectId: ElvUtils.AddressToId({prefix: "iq__", address: tenantAddr}),
      writeToken: e.write_token,
      commitMessage: "Set Eluvio Live object ID " + eluvioLiveId,
    });

    return res;
  }

  /**
   * Retrieve the faucet funding configuration for a tenant from the authority service.
   *
   * @namedParams
   * @param {string} asUrl - Authority service base URL
   * @param {string} tenantId - Tenant ID (iten***)
   * @returns {Promise<Object>} Faucet configuration object from the authority service
   */
  async TenantGetFaucet({asUrl, tenantId}) {
    const config = {
      configUrl: Config.networks[Config.net],
      mainObjectId: Config.mainObjects[Config.net],
    };

    const eluvioLive = new EluvioLive(config);
    await eluvioLive.Init({
      debugLogging: this.debug,
      asUrl
    });

    const elvAccount = new ElvAccount({
      configUrl: Config.networks[Config.net],
      debugLogging: this.debug,
    });
    await elvAccount.Init({privateKey: process.env.PRIVATE_KEY});

    const path = `/tnt/config/${tenantId}/faucet_funding`;
    const faucetGetTenantInfoResponse = await eluvioLive.TenantPathAuthServiceRequest({
      path,
      method: "GET"
    });
    const res = await faucetGetTenantInfoResponse.json();

    if (this.debug) {
      console.log("Faucet Get response:", JSON.stringify(res, null, 2));
    }
    return res;
  }

  /**
   * Create (or retrieve) a faucet funding address for the tenant via the authority
   * service, then optionally transfer ELV from the current account to fund it.
   *
   * @namedParams
   * @param {string} asUrl - Authority service base URL
   * @param {string} tenantId - Tenant ID (iten***)
   * @param {number} [amount=20] - Amount of ELV to transfer to the faucet funding address
   * @param {boolean} [noFunds=false] - Create the faucet address without transferring funds
   * @returns {Promise<Object>} Result including `faucet` config and optional `amount_transferred` / `current_balance`
   */
  async TenantCreateFaucetAndFund({asUrl, tenantId, amount = 20, noFunds = false}) {
    const config = {
      configUrl: Config.networks[Config.net],
      mainObjectId: Config.mainObjects[Config.net],
    };

    const eluvioLive = new EluvioLive(config);
    await eluvioLive.Init({
      debugLogging: this.debug,
      asUrl
    });

    const elvAccount = new ElvAccount({
      configUrl: Config.networks[Config.net],
      debugLogging: this.debug,
    });
    await elvAccount.Init({privateKey: process.env.PRIVATE_KEY});

    var res = {};
    // Create BaseTenantAuth token
    const requestBody = {ts: Date.now()};
    const {multiSig} = await eluvioLive.TenantSign({
      message: JSON.stringify(requestBody),
    });

    // Create/Get faucet funding address
    const faucetPath = urljoin(eluvioLive.asUrlPath, `/tnt/config/${tenantId}/faucet_funding`);
    const faucetResponse = await eluvioLive.client.authClient.MakeAuthServiceRequest({
      method: "POST",
      path: faucetPath,
      body: requestBody,
      headers: {
        Authorization: `Bearer ${multiSig}`,
      },
    });
    const faucetRes = await faucetResponse.json();

    if (this.debug) {
      console.log("Faucet response:", JSON.stringify(faucetRes, null, 2));
    }

    res.faucet = faucetRes;
    let fundingAddress = faucetRes.funding_address;

    if (!noFunds) {
      // Check balances
      const senderAddress = elvAccount.signer.address.toString();
      let initialSenderBalance = await elvAccount.client.GetBalance({address: senderAddress});
      let initialReceiverBalance = await elvAccount.client.GetBalance({address: fundingAddress});

      if (this.debug) {
        console.log(`Funds before transfer: Sender=${senderAddress}, Balance=${initialSenderBalance}`);
        console.log(`Funds before transfer: Receiver=${fundingAddress}, Balance=${initialReceiverBalance}`);
      }

      // Validate sender's balance
      if (initialSenderBalance <= amount) {
        throw new Error(
          `Insufficient balance: Sender account (${senderAddress}) has a balance less than the required amount (${amount} Elv's). Please ensure sufficient funds before retrying.`
        );
      }

      // Transfer funds
      const transferResult = await elvAccount.client.SendFunds({
        recipient: fundingAddress,
        ether: amount,
      });

      if (this.debug) {
        console.log("Transfer Details:", transferResult);
      }
      console.log("Funds transferred successfully.");


      // Check balances after transfer
      let finalSenderBalance = await elvAccount.client.GetBalance({address: senderAddress});
      let finalReceiverBalance = await elvAccount.client.GetBalance({address: fundingAddress});

      if (this.debug) {
        console.log(`Funds after transfer: Sender=${senderAddress}, Balance=${finalSenderBalance}`);
        console.log(`Funds after transfer: Receiver=${fundingAddress}, Balance=${finalReceiverBalance}`);
      }
      res.amount_transferred = finalReceiverBalance - initialReceiverBalance;
      res.current_balance = finalReceiverBalance;
    }

    return res;
  }

  /**
   * Delete the faucet funding configuration for a tenant via the authority service.
   *
   * @namedParams
   * @param {string} asUrl - Authority service base URL
   * @param {string} tenantId - Tenant ID (iten***)
   * @returns {Promise<void>}
   */
  async TenantDeleteFaucet({asUrl, tenantId}) {
    const config = {
      configUrl: Config.networks[Config.net],
      mainObjectId: Config.mainObjects[Config.net],
    };

    const eluvioLive = new EluvioLive(config);
    await eluvioLive.Init({
      debugLogging: this.debug,
      asUrl
    });

    const path = `/tnt/config/${tenantId}/faucet_funding`;
    const faucetResponse = await eluvioLive.TenantPathAuthServiceRequest({
      path,
      method: "DELETE",
      queryParams: {purge:false}
    });

    if (!faucetResponse.ok) {
      throw new Error(`${faucetResponse.status} ${faucetResponse.statusText}`);
    }
  }

  /**
   * Retrieve the sharing key configuration for a tenant from the authority service.
   * The request is signed with the tenant's multi-sig token.
   *
   * @namedParams
   * @param {string} asUrl - Authority service base URL
   * @param {string} tenantId - Tenant ID (iten***)
   * @returns {Promise<Object>} Sharing key configuration from the authority service
   */
  async TenantGetSharingKey({asUrl, tenantId}) {
    const config = {
      configUrl: Config.networks[Config.net],
      mainObjectId: Config.mainObjects[Config.net],
    };

    const eluvioLive = new EluvioLive(config);
    await eluvioLive.Init({
      debugLogging: this.debug,
      asUrl
    });

    const elvAccount = new ElvAccount({
      configUrl: Config.networks[Config.net],
      debugLogging: this.debug,
    });
    await elvAccount.Init({privateKey: process.env.PRIVATE_KEY});

    let ts = Date.now();
    let params = {ts};
    const paramString = new URLSearchParams(params).toString();
    let path = `/tnt/config/${tenantId}/sharing`;

    let newPath = path + "?" + paramString;

    const {multiSig} = await eluvioLive.TenantSign({
      message: newPath,
    });
    if (this.debug) {
      console.log(`Authorization: Bearer ${multiSig}`);
    }

    const sharingKeyGetTenantInfoUrl = urljoin(eluvioLive.asUrlPath, path);
    const sharingKeyGetTenantInfoResponse = await eluvioLive.client.authClient.MakeAuthServiceRequest({
      method: "GET",
      path: sharingKeyGetTenantInfoUrl,
      headers: {
        Authorization: `Bearer ${multiSig}`,
      },
      queryParams: {ts},
    });
    const res = await sharingKeyGetTenantInfoResponse.json();
    if (this.debug) {
      console.log("Sharing Service Get response:", JSON.stringify(res, null, 2));
    }
    return res;
  }

  /**
   * Create a sharing key for the tenant via the authority service and add the
   * resulting signing address to the tenant's content admins group.
   *
   * @namedParams
   * @param {string} asUrl - Authority service base URL
   * @param {string} tenantId - Tenant ID (iten***)
   * @returns {Promise<Object>} Result including `sharing` key info from the authority service
   */
  async TenantCreateSharingKey({asUrl, tenantId}) {
    const config = {
      configUrl: Config.networks[Config.net],
      mainObjectId: Config.mainObjects[Config.net],
    };

    const eluvioLive = new EluvioLive(config);
    await eluvioLive.Init({
      debugLogging: this.debug,
      asUrl
    });

    const elvAccount = new ElvAccount({
      configUrl: Config.networks[Config.net],
      debugLogging: this.debug,
    });
    await elvAccount.Init({privateKey: process.env.PRIVATE_KEY});

    let elvFabric = new ElvFabric({
      configUrl: Config.networks[Config.net],
      debugLogging: this.debug,
    });

    await elvFabric.Init({
      privateKey: process.env.PRIVATE_KEY
    });

    let contentAdminGroup = await this.TenantContentAdminGroup({tenantId});

    var res = {};
    // Create BaseTenantAuth token
    const requestBody = {ts: Date.now()};
    const {multiSig} = await eluvioLive.TenantSign({
      message: JSON.stringify(requestBody),
    });

    // Create/Get sharing key address
    const sharingKeyPath = urljoin(eluvioLive.asUrlPath, `/tnt/config/${tenantId}/sharing`);
    const sharingKeyRes = await eluvioLive.client.authClient.MakeAuthServiceRequest({
      method: "POST",
      path: sharingKeyPath,
      body: requestBody,
      headers: {
        Authorization: `Bearer ${multiSig}`,
      },
    });
    res.sharing = await sharingKeyRes.json();

    // add to content admins group
    if (contentAdminGroup && res.sharing.share_signing_address) {

      let isMember = await elvFabric.AccessGroupMember({
        group: contentAdminGroup,
        addr: res.sharing.share_signing_address
      });
      if (!isMember) {
        await elvAccount.AddToAccessGroup({
          groupAddress: contentAdminGroup,
          accountAddress: res.sharing.share_signing_address,
          isManager: false
        });
      }
    }
    return res;
  }

  /**
   * Retrieve the address of the content admins group registered on the tenant contract.
   *
   * @namedParams
   * @param {string} tenantId - Tenant ID (iten***)
   * @returns {Promise<string>} Address of the content admins group
   */
  async TenantContentAdminGroup({tenantId}) {
    let contractType = await this.client.authClient.AccessType(tenantId);
    if (contractType !== this.client.authClient.ACCESS_TYPES.TENANT) {
      throw Error("the contract corresponding to this tenantId is not a tenant contract");
    }

    const tenantAddr = Utils.HashToAddress(tenantId);
    const abi = fs.readFileSync(
      path.resolve(__dirname, "../contracts/v3/BaseTenantSpace.abi")
    );

    try {
      return await this.client.CallContractMethod({
        contractAddress: tenantAddr,
        abi: JSON.parse(abi),
        methodName: "groupsMapping",
        methodArgs: ["content_admin", 0],
        formatArguments: true,
      });
    } catch (e) {
      throw Error(`tenant ${tenantId} missing content_admin group`);
    }
  }

  /**
   * Add tenant status
   *
   * @param {string} tenantContractId - The ID of the tenant Id (iten***)
   * @param {string} tenantStatus - tenant status: acive | inactive
   * @returns {Promise<{tenantStatus: string, tenantContractId: string}>}
   */
  async TenantSetStatus({tenantContractId, tenantStatus}) {
    if (tenantStatus !== constants.TENANT_STATE_ACTIVE &&
      tenantStatus !== constants.TENANT_STATE_INACTIVE) {
      throw Error(`Invalid tenant status, require active | inactive | frozen: ${tenantStatus}`);
    }

    //Check that the user is the owner of the tenant
    const tenantOwner = await this.client.authClient.Owner({id: tenantContractId});
    if (tenantOwner.toLowerCase() !== this.client.signer.address.toLowerCase()) {
      throw Error("tenant status must be set by the owner of tenant " + tenantContractId);
    }

    const tenantAddr = Utils.HashToAddress(tenantContractId);
    await this.client.ReplaceContractMetadata({
      contractAddress: tenantAddr,
      metadataKey: constants.TENANT_STATE,
      metadata: tenantStatus,
    });

    tenantStatus = await this.TenantStatus({tenantContractId});
    return {tenantContractId, tenantStatus};
  }

  /**
   * Retrieve tenant status
   *
   * @param {string} tenantContractId - The ID of the tenant Id (iten***)
   * @returns {Promise<string>}
   */
  async TenantStatus({tenantContractId}) {
    const tenantAddr = Utils.HashToAddress(tenantContractId);
    let tenantStatus;
    try {
      tenantStatus = await this.client.ContractMetadata({
        contractAddress: tenantAddr,
        metadataKey: constants.TENANT_STATE
      });
    } catch (e) {
      tenantStatus = "";
    }
    return tenantStatus;
  }

  /**
   * Fund a user wallet address via the tenant's faucet.  The user's balance must
   * be below the faucet's configured `per_top_up_limit`, and the request is
   * signed with the tenant's multi-sig token.
   *
   * @namedParams
   * @param {string} asUrl - Authority service base URL
   * @param {string} tenantId - Tenant ID (iten***)
   * @param {string} userAddress - Ethereum address of the user to fund
   * @returns {Promise<Object>} Faucet fund response from the authority service
   */
  async TenantFundUser({asUrl, tenantId, userAddress}) {

    console.log("TenantFundUser");
    console.log(`as_url: ${asUrl}`);
    console.log(`tenant_id: ${tenantId}`);
    console.log(`user_address: ${userAddress}`);

    const config = {
      configUrl: Config.networks[Config.net],
      mainObjectId: Config.mainObjects[Config.net],
    };

    const eluvioLive = new EluvioLive(config);
    await eluvioLive.Init({
      debugLogging: this.debug,
      asUrl
    });

    const elvAccount = new ElvAccount({
      configUrl: Config.networks[Config.net],
      debugLogging: this.debug,
    });
    await elvAccount.Init({privateKey: process.env.PRIVATE_KEY});

    // check valid user address
    if (!Utils.ValidAddress(userAddress)) {
      throw Error(`Invalid user address provided: ${userAddress}`);
    }
    const usrAddr = Utils.FormatAddress(userAddress);

    // check user balance < tenant faucet per_top_up_limit
    let userBalance = await elvAccount.client.GetBalance({address: usrAddr});
    let perTopUpLimit;
    try {
      const faucetGetTenantInfo = urljoin(eluvioLive.asUrlPath, `/faucet/get_tenant/${tenantId}`);
      const faucetGetTenantInfoResponse = await eluvioLive.client.authClient.MakeAuthServiceRequest({
        method: "GET",
        path: faucetGetTenantInfo,
      });
      const res = await faucetGetTenantInfoResponse.json();

      if (this.debug) {
        console.log(res);
      }
      if (res.status === "success") {
        perTopUpLimit = res.tenant_record.per_top_up_limit;
      }
    } catch (e) {
      throw Error(`Error getting tenant faucet info: ${JSON.stringify(e)}`);
    }

    if (userBalance > perTopUpLimit) {
      throw Error(`user ${usrAddr} has balance > faucet per_top_up_limit = ${perTopUpLimit}`);
    }

    // Create BaseTenantAuth token
    const requestBody = {
      ts: Date.now(),
    };
    const {multiSig} = await eluvioLive.TenantSign({
      message: JSON.stringify(requestBody),
    });

    // fund the user
    const faucetFundPath = urljoin(eluvioLive.asUrlPath, `/faucet/fund/${tenantId}/${usrAddr}`);
    const faucetFundResponse = await eluvioLive.client.authClient.MakeAuthServiceRequest({
      method: "POST",
      path: faucetFundPath,
      body: requestBody,
      headers: {
        Authorization: `Bearer ${multiSig}`,
      },
    });

    return await faucetFundResponse.json();
  }
}

exports.ElvTenant = ElvTenant;