Checkpoint

This commit is contained in:
Steve Faulkner
2021-01-02 17:15:18 -06:00
parent 5652f29d03
commit 2e10b96678
8 changed files with 147 additions and 262 deletions

View File

@@ -5,6 +5,7 @@ import { IContextualMenuProps } from "office-ui-fabric-react/lib/ContextualMenu"
import { Dropdown, IDropdownProps } from "office-ui-fabric-react/lib/Dropdown";
import { useSubscriptions } from "../../../hooks/useSubscriptions";
import { useDatabaseAccounts } from "../../../hooks/useDatabaseAccounts";
import { DatabaseAccount } from "../../../Contracts/DataModels";
const buttonStyles: IButtonStyles = {
root: {
@@ -37,12 +38,27 @@ const buttonStyles: IButtonStyles = {
}
};
export const AccountSwitchComponent: React.FunctionComponent<{ armToken: string }> = ({ armToken }) => {
const cachedSubscriptionId = localStorage.getItem("cachedSubscriptionId");
const cachedDatabaseAccountName = localStorage.getItem("cachedDatabaseAccountName");
export const AccountSwitchComponent: React.FunctionComponent<{
armToken: string;
setDatabaseAccount: (account: DatabaseAccount) => void;
}> = ({ armToken, setDatabaseAccount }) => {
const subscriptions = useSubscriptions(armToken);
const cachedSubscriptionId = localStorage.getItem("cachedSubscriptionId");
const [selectedSubscriptionId, setSelectedSubscriptionId] = React.useState<string>(cachedSubscriptionId);
const accounts = useDatabaseAccounts(selectedSubscriptionId, armToken);
const [selectedAccountName, setSelectedAccoutName] = React.useState<string>();
const [selectedAccountName, setSelectedAccoutName] = React.useState<string>(cachedDatabaseAccountName);
React.useEffect(() => {
if (accounts && selectedAccountName) {
const account = accounts.find(account => account.name == selectedAccountName);
// Only set a new account if one is found
if (account) {
setDatabaseAccount(account);
}
}
}, [accounts, selectedAccountName]);
const menuProps: IContextualMenuProps = {
directionalHintFixed: true,
@@ -85,30 +101,28 @@ export const AccountSwitchComponent: React.FunctionComponent<{ armToken: string
{
key: "switchAccount",
onRender: (item, dismissMenu) => {
const isLoadingAccounts = false;
const options = accounts.map(account => ({
key: account.name,
text: account.name,
data: account
}));
const placeHolderText = isLoadingAccounts
? "Loading Cosmos DB accounts"
: !options || !options.length
? "No Cosmos DB accounts found"
: "Select Cosmos DB account from list";
// const placeHolderText = isLoadingAccounts
// ? "Loading Cosmos DB accounts"
// : !options || !options.length
// ? "No Cosmos DB accounts found"
// : "Select Cosmos DB account from list";
const dropdownProps: IDropdownProps = {
label: "Cosmos DB Account Name",
className: "accountSwitchAccountDropdown",
options,
options: accounts.map(account => ({
key: account.name,
text: account.name,
data: account
})),
onChange: (event, option) => {
const accountName = String(option.key);
setSelectedAccoutName(String(option.key));
localStorage.setItem("cachedDatabaseAccountName", accountName);
dismissMenu();
},
defaultSelectedKey: selectedAccountName,
placeholder: placeHolderText,
placeholder: "No Cosmos DB accounts found",
styles: {
callout: "accountSwitchAccountDropdownMenu"
}

View File

@@ -1843,7 +1843,7 @@ export default class Explorer {
if (inputs != null) {
// In development mode, save the iframe message from the portal in session storage.
// This allows webpack hot reload to funciton properly
if (process.env.NODE_ENV === "development") {
if (process.env.NODE_ENV === "development" && configContext.platform === Platform.Portal) {
sessionStorage.setItem("portalDataExplorerInitMessage", JSON.stringify(inputs));
}

View File

@@ -27,7 +27,7 @@ import { useDirectories } from "./hooks/useDirectories";
import * as Msal from "msal";
import { configContext } from "./ConfigContext";
import { HttpHeaders } from "./Common/Constants";
import { GenerateTokenResponse } from "./Contracts/DataModels";
import { GenerateTokenResponse, DatabaseAccount } from "./Contracts/DataModels";
import { AuthType } from "./AuthType";
initializeIcons();
@@ -43,6 +43,12 @@ const msal = new Msal.UserAgentApplication({
}
});
interface HostedExplorerChildFrame extends Window {
authType: AuthType;
databaseAccount: DatabaseAccount;
authorizationToken: string;
}
const cachedAccount = msal.getAllAccounts()?.[0];
const cachedTenantId = localStorage.getItem("cachedTenantId");
@@ -65,6 +71,9 @@ const App: React.FunctionComponent = () => {
const [graphToken, setGraphToken] = React.useState<string>();
const [armToken, setArmToken] = React.useState<string>();
const [connectionString, setConnectionString] = React.useState<string>("");
const [databaseAccount, setDatabaseAccount] = React.useState<DatabaseAccount>();
const ref = React.useRef<HTMLIFrameElement>();
const login = React.useCallback(async () => {
const response = await msal.loginPopup();
@@ -96,6 +105,21 @@ const App: React.FunctionComponent = () => {
}
}, [account, tenantId]);
React.useEffect(() => {
// If ref.current is undefined no iframe has been rendered
if (ref.current) {
const frameWindow = ref.current.contentWindow as HostedExplorerChildFrame;
frameWindow.authType = AuthType.AAD;
frameWindow.databaseAccount = databaseAccount;
frameWindow.authorizationToken = armToken;
// const frameWindow = ref.current.contentWindow;
// frameWindow.authType = AuthType.EncryptedToken;
// frameWindow.encryptedToken = encryptedToken;
// frameWindow.encryptedTokenMetadata = encryptedTokenMetadata;
// frameWindow.parsedConnectionString = "foo";
}
}, [ref, encryptedToken, encryptedTokenMetadata, isLoggedIn, databaseAccount]);
const photo = useGraphPhoto(graphToken);
const directories = useDirectories(armToken);
@@ -118,7 +142,7 @@ const App: React.FunctionComponent = () => {
)}
{isLoggedIn && (
<span className="accountSwitchComponentContainer">
<AccountSwitchComponent armToken={armToken} />
<AccountSwitchComponent armToken={armToken} setDatabaseAccount={setDatabaseAccount} />
</span>
)}
{!isLoggedIn && encryptedTokenMetadata?.accountName && (
@@ -202,26 +226,22 @@ const App: React.FunctionComponent = () => {
</div>
</div>
</header>
{encryptedTokenMetadata && !isLoggedIn && (
{databaseAccount && (
// Ideally we would import and render data explorer like any other React component, however
// because it still has a significant amount of Knockout code, this would lead to memory leaks.
// Knockout does not have a way to tear down all of its binding and listeners with a single method.
// It's possible this can be changed once all knockout code has been removed.
<iframe
// Setting key is needed so React will re-render this element on any account change
key={databaseAccount.id}
ref={ref}
id="explorerMenu"
name="explorer"
className="iframe"
title="explorer"
src={`explorer.html?v=1.0.1&platform=Hosted&authType=${AuthType.EncryptedToken}&key=${encodeURIComponent(
encryptedToken
)}&metadata=${JSON.stringify(encryptedTokenMetadata)}`}
src="explorer.html?v=1.0.1&platform=Hosted"
></iframe>
)}
{/* {!encryptedTokenMetadata && isLoggedIn && (
<iframe
id="explorerMenu"
name="explorer"
className="iframe"
title="explorer"
src={`explorer.html?v=1.0.1&platform=Hosted&authType=${AuthType.AAD}`}
></iframe>
)} */}
{!isLoggedIn && !encryptedTokenMetadata && (
<div id="connectExplorer" className="connectExplorerContainer" style={{ display: "flex" }}>
<div className="connectExplorerFormContainer">

View File

@@ -60,7 +60,7 @@ import { AuthType } from "./AuthType";
import { initializeIcons } from "office-ui-fabric-react/lib/Icons";
import { applyExplorerBindings } from "./applyExplorerBindings";
import { initializeConfiguration, Platform } from "./ConfigContext";
import { configContext, initializeConfiguration, Platform } from "./ConfigContext";
import Explorer from "./Explorer/Explorer";
import React, { useEffect } from "react";
import ReactDOM from "react-dom";
@@ -71,6 +71,10 @@ import refreshImg from "../images/refresh-cosmos.svg";
import arrowLeftImg from "../images/imgarrowlefticon.svg";
import { KOCommentEnd, KOCommentIfStart } from "./koComment";
import { AccountKind, DefaultAccountExperience, TagNames } from "./Common/Constants";
import { updateUserContext } from "./UserContext";
import AuthHeadersUtil from "./Platform/Hosted/Authorization";
import { CollectionCreation } from "./Shared/Constants";
import { extractFeatures } from "./Platform/Hosted/extractFeatures";
// TODO: Encapsulate and reuse all global variables as environment variables
window.authType = AuthType.AAD;
@@ -97,7 +101,39 @@ const App: React.FunctionComponent = () => {
initializeConfiguration().then(config => {
let explorer: Explorer;
if (config.platform === Platform.Hosted) {
explorer = Hosted.initializeExplorer();
const authType: AuthType = window.authType;
explorer = new Explorer();
if (window.authType === AuthType.EncryptedToken) {
updateUserContext({
accessToken: window.encryptedToken
});
Hosted.initDataExplorerFrameInputs(explorer);
} else if (window.authType === AuthType.AAD) {
const account = window.databaseAccount;
const serverId = AuthHeadersUtil.serverId;
const accountResourceId = account.id;
const subscriptionId = accountResourceId && accountResourceId.split("subscriptions/")[1].split("/")[0];
const resourceGroup = accountResourceId && accountResourceId.split("resourceGroups/")[1].split("/")[0];
const inputs: DataExplorerInputsFrame = {
databaseAccount: account,
subscriptionId,
resourceGroup,
masterKey: "",
hasWriteAccess: true, //TODO: 425017 - support read access
authorizationToken: `Bearer ${window.authorizationToken}`,
features: extractFeatures(),
csmEndpoint: undefined,
dnsSuffix: undefined,
serverId: serverId,
extensionEndpoint: configContext.BACKEND_ENDPOINT,
subscriptionType: CollectionCreation.DefaultSubscriptionType,
quotaId: undefined,
addCollectionDefaultFlight: explorer.flight(),
isTryCosmosDBSubscription: explorer.isTryCosmosDBSubscription()
};
explorer.initDataExplorerWithFrameInputs(inputs);
explorer.isAccountReady(true);
}
} else if (config.platform === Platform.Emulator) {
window.authType = AuthType.MasterKey;
const explorer = new Explorer();
@@ -118,7 +154,7 @@ const App: React.FunctionComponent = () => {
}
}
window.addEventListener("message", explorer.handleMessage.bind(explorer), false);
window.addEventListener("message", message => explorer.handleMessage(message), false);
}
applyExplorerBindings(explorer);
});
@@ -177,7 +213,7 @@ const App: React.FunctionComponent = () => {
aria-label="Share url link"
className="shareLink"
type="text"
read-only
read-only={true}
data-bind="value: shareAccessUrl"
/>
<span

View File

@@ -1,9 +1,9 @@
import { AccessInputMetadata } from "../../Contracts/DataModels";
import { HostedUtils } from "./HostedUtils";
import { getDatabaseAccountPropertiesFromMetadata } from "./HostedUtils";
describe("getDatabaseAccountPropertiesFromMetadata", () => {
it("should only return an object with the mongoEndpoint key if the apiKind is mongoCompute (5)", () => {
let mongoComputeAccount: AccessInputMetadata = {
const mongoComputeAccount: AccessInputMetadata = {
accountName: "compute-batch2",
apiEndpoint: "compute-batch2.mongo.cosmos.azure.com:10255",
apiKind: 5,
@@ -11,21 +11,21 @@ describe("getDatabaseAccountPropertiesFromMetadata", () => {
expiryTimestamp: "1234",
mongoEndpoint: "https://compute-batch2.mongo.cosmos.azure.com:443/"
};
expect(HostedUtils.getDatabaseAccountPropertiesFromMetadata(mongoComputeAccount)).toEqual({
expect(getDatabaseAccountPropertiesFromMetadata(mongoComputeAccount)).toEqual({
mongoEndpoint: mongoComputeAccount.mongoEndpoint,
documentEndpoint: mongoComputeAccount.documentEndpoint
});
});
it("should not return an object with the mongoEndpoint key if the apiKind is mongo (1)", () => {
let mongoAccount: AccessInputMetadata = {
const mongoAccount: AccessInputMetadata = {
accountName: "compute-batch2",
apiEndpoint: "compute-batch2.mongo.cosmos.azure.com:10255",
apiKind: 1,
documentEndpoint: "https://compute-batch2.documents.azure.com:443/",
expiryTimestamp: "1234"
};
expect(HostedUtils.getDatabaseAccountPropertiesFromMetadata(mongoAccount)).toEqual({
expect(getDatabaseAccountPropertiesFromMetadata(mongoAccount)).toEqual({
documentEndpoint: mongoAccount.documentEndpoint
});
});

View File

@@ -3,33 +3,31 @@ import * as DataModels from "../../Contracts/DataModels";
import { AccessInputMetadata } from "../../Contracts/DataModels";
import { DefaultExperienceUtility } from "../../Shared/DefaultExperienceUtility";
export class HostedUtils {
static getDatabaseAccountPropertiesFromMetadata(metadata: AccessInputMetadata): any {
let properties = { documentEndpoint: metadata.documentEndpoint };
const apiExperience: string = DefaultExperienceUtility.getDefaultExperienceFromApiKind(metadata.apiKind);
export function getDatabaseAccountPropertiesFromMetadata(metadata: AccessInputMetadata): unknown {
let properties = { documentEndpoint: metadata.documentEndpoint };
const apiExperience: string = DefaultExperienceUtility.getDefaultExperienceFromApiKind(metadata.apiKind);
if (apiExperience === Constants.DefaultAccountExperience.Cassandra) {
if (apiExperience === Constants.DefaultAccountExperience.Cassandra) {
properties = Object.assign(properties, {
cassandraEndpoint: metadata.apiEndpoint,
capabilities: [{ name: Constants.CapabilityNames.EnableCassandra }]
});
} else if (apiExperience === Constants.DefaultAccountExperience.Table) {
properties = Object.assign(properties, {
tableEndpoint: metadata.apiEndpoint,
capabilities: [{ name: Constants.CapabilityNames.EnableTable }]
});
} else if (apiExperience === Constants.DefaultAccountExperience.Graph) {
properties = Object.assign(properties, {
gremlinEndpoint: metadata.apiEndpoint,
capabilities: [{ name: Constants.CapabilityNames.EnableGremlin }]
});
} else if (apiExperience === Constants.DefaultAccountExperience.MongoDB) {
if (metadata.apiKind === DataModels.ApiKind.MongoDBCompute) {
properties = Object.assign(properties, {
cassandraEndpoint: metadata.apiEndpoint,
capabilities: [{ name: Constants.CapabilityNames.EnableCassandra }]
mongoEndpoint: metadata.mongoEndpoint
});
} else if (apiExperience === Constants.DefaultAccountExperience.Table) {
properties = Object.assign(properties, {
tableEndpoint: metadata.apiEndpoint,
capabilities: [{ name: Constants.CapabilityNames.EnableTable }]
});
} else if (apiExperience === Constants.DefaultAccountExperience.Graph) {
properties = Object.assign(properties, {
gremlinEndpoint: metadata.apiEndpoint,
capabilities: [{ name: Constants.CapabilityNames.EnableGremlin }]
});
} else if (apiExperience === Constants.DefaultAccountExperience.MongoDB) {
if (metadata.apiKind === DataModels.ApiKind.MongoDBCompute) {
properties = Object.assign(properties, {
mongoEndpoint: metadata.mongoEndpoint
});
}
}
return properties;
}
return properties;
}

View File

@@ -1,60 +1,17 @@
import * as Constants from "../../Common/Constants";
import AuthHeadersUtil from "./Authorization";
import Q from "q";
import {
AccessInputMetadata,
ApiKind,
DatabaseAccount,
GenerateTokenResponse,
resourceTokenConnectionStringProperties
} from "../../Contracts/DataModels";
import { AuthType } from "../../AuthType";
import { CollectionCreation } from "../../Shared/Constants";
import { DataExplorerInputsFrame } from "../../Contracts/ViewModels";
import { DefaultExperienceUtility } from "../../Shared/DefaultExperienceUtility";
import { HostedUtils } from "./HostedUtils";
import { sendMessage } from "../../Common/MessageHandler";
import { SessionStorageUtility, StorageKey } from "../../Shared/StorageUtility";
import { SubscriptionUtilMappings } from "../../Shared/Constants";
import "../../Explorer/Tables/DataTable/DataTableBindingManager";
import Explorer from "../../Explorer/Explorer";
import { updateUserContext } from "../../UserContext";
import * as Constants from "../../Common/Constants";
import { configContext } from "../../ConfigContext";
import { getErrorMessage } from "../../Common/ErrorHandlingUtils";
import { ApiKind, DatabaseAccount, resourceTokenConnectionStringProperties } from "../../Contracts/DataModels";
import { DataExplorerInputsFrame } from "../../Contracts/ViewModels";
import Explorer from "../../Explorer/Explorer";
import "../../Explorer/Tables/DataTable/DataTableBindingManager";
import { CollectionCreation, SubscriptionUtilMappings } from "../../Shared/Constants";
import { DefaultExperienceUtility } from "../../Shared/DefaultExperienceUtility";
import AuthHeadersUtil from "./Authorization";
import { extractFeatures } from "./extractFeatures";
import { getDatabaseAccountPropertiesFromMetadata } from "./HostedUtils";
export default class Main {
private static _databaseAccountId: string;
private static _encryptedToken: string;
private static _accessInputMetadata: AccessInputMetadata;
public static initializeExplorer(): Explorer {
const params = new URLSearchParams(window.location.search);
let authType: string = params && params.get("authType");
// Encrypted token flow
if (params && params.has("key")) {
Main._encryptedToken = encodeURIComponent(params.get("key"));
Main._accessInputMetadata = JSON.parse(params.get("metadata"));
authType = AuthType.EncryptedToken;
}
const explorer = new Explorer();
// workaround to resolve cyclic refs with view // TODO. Is this even needed anymore?
explorer.renewExplorerShareAccess = Main.renewExplorerAccess;
window.addEventListener("message", explorer.handleMessage.bind(explorer), false);
if (authType === AuthType.EncryptedToken) {
updateUserContext({
accessToken: Main._encryptedToken
});
Main._initDataExplorerFrameInputs(explorer);
} else if (authType === AuthType.AAD) {
} else {
Main._initDataExplorerFrameInputs(explorer);
}
return explorer;
}
public static parseResourceTokenConnectionString(connectionString: string): resourceTokenConnectionStringProperties {
let accountEndpoint: string;
let collectionId: string;
@@ -87,75 +44,7 @@ export default class Main {
};
}
public static renewExplorerAccess = (explorer: Explorer, connectionString: string): Q.Promise<void> => {
if (!connectionString) {
console.error("Missing or invalid connection string input");
Q.reject("Missing or invalid connection string input");
}
if (Main._isResourceToken(connectionString)) {
return Main._renewExplorerAccessWithResourceToken(explorer, connectionString);
}
const deferred: Q.Deferred<void> = Q.defer<void>();
AuthHeadersUtil.generateUnauthenticatedEncryptedTokenForConnectionString(connectionString).then(
(encryptedToken: GenerateTokenResponse) => {
if (!encryptedToken || !encryptedToken.readWrite) {
deferred.reject("Encrypted token is empty or undefined");
}
Main._encryptedToken = encryptedToken.readWrite;
window.authType = AuthType.EncryptedToken;
updateUserContext({
accessToken: Main._encryptedToken
});
Main._getAccessInputMetadata(Main._encryptedToken).then(
() => {
if (explorer.isConnectExplorerVisible()) {
explorer.notificationConsoleData([]);
explorer.hideConnectExplorerForm();
}
if (Main._accessInputMetadata.apiKind != ApiKind.Graph) {
// do not save encrypted token for graphs because we cannot extract master key in the client
SessionStorageUtility.setEntryString(StorageKey.EncryptedKeyToken, Main._encryptedToken);
window.parent &&
window.parent.history.replaceState(
{ encryptedToken: encryptedToken },
"",
`?key=${Main._encryptedToken}${(window.parent && window.parent.location.hash) || ""}`
); // replace query params if any
} else {
SessionStorageUtility.removeEntry(StorageKey.EncryptedKeyToken);
window.parent &&
window.parent.history.replaceState(
{ encryptedToken: encryptedToken },
"",
`?${(window.parent && window.parent.location.hash) || ""}`
); // replace query params if any
}
const masterKey: string = Main._getMasterKeyFromConnectionString(connectionString);
Main._setExplorerReady(explorer, masterKey);
deferred.resolve();
},
(error: any) => {
console.error(error);
deferred.reject(error);
}
);
},
(error: any) => {
deferred.reject(`Failed to generate encrypted token: ${getErrorMessage(error)}`);
}
);
return deferred.promise.timeout(Constants.ClientDefaults.requestTimeoutMs);
};
private static _initDataExplorerFrameInputs(
public static initDataExplorerFrameInputs(
explorer: Explorer,
masterKey?: string /* master key extracted from connection string if available */,
account?: DatabaseAccount,
@@ -187,7 +76,7 @@ export default class Main {
id: Main._databaseAccountId,
name: Main._accessInputMetadata.accountName,
kind: this._getDatabaseAccountKindFromExperience(apiExperience),
properties: HostedUtils.getDatabaseAccountPropertiesFromMetadata(Main._accessInputMetadata),
properties: getDatabaseAccountPropertiesFromMetadata(Main._accessInputMetadata),
tags: { defaultExperience: apiExperience }
},
subscriptionId,
@@ -237,7 +126,7 @@ export default class Main {
id: Main._databaseAccountId,
name: Main._accessInputMetadata.accountName,
kind: this._getDatabaseAccountKindFromExperience(apiExperience),
properties: HostedUtils.getDatabaseAccountPropertiesFromMetadata(Main._accessInputMetadata),
properties: getDatabaseAccountPropertiesFromMetadata(Main._accessInputMetadata),
tags: { defaultExperience: apiExperience }
},
subscriptionId,
@@ -273,11 +162,6 @@ export default class Main {
return Constants.AccountKind.GlobalDocumentDB;
}
private static async _getAccessInputMetadata(accessInput: string): Promise<void> {
const metadata = await AuthHeadersUtil.getAccessInputMetadata(accessInput);
Main._accessInputMetadata = metadata;
}
private static _getMasterKeyFromConnectionString(connectionString: string): string {
if (!connectionString || Main._accessInputMetadata == null || Main._accessInputMetadata.apiKind !== ApiKind.Graph) {
// client only needs master key for Graph API
@@ -291,69 +175,4 @@ export default class Main {
private static _isResourceToken(connectionString: string): boolean {
return connectionString && connectionString.includes("type=resource");
}
private static _renewExplorerAccessWithResourceToken = (
explorer: Explorer,
connectionString: string
): Q.Promise<void> => {
window.authType = AuthType.ResourceToken;
const properties: resourceTokenConnectionStringProperties = Main.parseResourceTokenConnectionString(
connectionString
);
if (
!properties.accountEndpoint ||
!properties.resourceToken ||
!properties.databaseId ||
!properties.collectionId
) {
console.error("Invalid connection string input");
Q.reject("Invalid connection string input");
}
updateUserContext({
resourceToken: properties.resourceToken,
endpoint: properties.accountEndpoint
});
explorer.resourceTokenDatabaseId(properties.databaseId);
explorer.resourceTokenCollectionId(properties.collectionId);
if (properties.partitionKey) {
explorer.resourceTokenPartitionKey(properties.partitionKey);
}
Main._accessInputMetadata = Main._getAccessInputMetadataFromAccountEndpoint(properties.accountEndpoint);
if (explorer.isConnectExplorerVisible()) {
explorer.notificationConsoleData([]);
explorer.hideConnectExplorerForm();
}
Main._setExplorerReady(explorer);
return Q.resolve();
};
private static _getAccessInputMetadataFromAccountEndpoint = (accountEndpoint: string): AccessInputMetadata => {
const documentEndpoint: string = accountEndpoint;
const result: RegExpMatchArray = accountEndpoint.match("https://([^\\.]+)\\..+");
const accountName: string = result && result[1];
const apiEndpoint: string = accountEndpoint.substring(8);
const apiKind: number = ApiKind.SQL;
return {
accountName,
apiEndpoint,
apiKind,
documentEndpoint,
expiryTimestamp: ""
};
};
private static _setExplorerReady(
explorer: Explorer,
masterKey?: string,
account?: DatabaseAccount,
authorizationToken?: string
) {
Main._initDataExplorerFrameInputs(explorer, masterKey, account, authorizationToken);
explorer.isAccountReady.valueHasMutated();
sendMessage("ready");
}
}