/**
* @author Michael Hasenstein <hasenstein@yahoo.com>
* @copyright REFINIO GmbH 2018
* @license CC-BY-NC-SA-2.5; portions MIT License
* @version 0.0.1
*/
/**
* This module contains the function to initialize a ONE application instance.
* @module
*/
/**
* @global
* @typedef {object} InstanceOptions
* @property {string} name - A name given to the instance by the user
* @property {string} email - The email address of the user owning this ONE application
* instance
* @property {string} secret - A secret used to authenticate the given email ID. The string will be
* {@link https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/normalize|normalized}.
* @property {string} [ownerName] - The name of the owner (optional)
* @property {KeyPair} [personEncryptionKeyPair] - Encryption keypair used for instance owner.
* If provided, **all four keys** must be provided together. In that case no new keys will be
* created upon instance creation, instead these keys will be used. The keys are only used for a
* new instance. If they are provided but an instance already exists nothing will happen.
* @property {SignKeyPair} [personSignKeyPair] - Sign keypair used for instance owner.
* Also see the description for `personEncryptionKeyPair`.
* @property {KeyPair} [instanceEncryptionKeyPair] - Encryption keypair used for instance.
* Also see the description for `personEncryptionKeyPair`.
* @property {SignKeyPair} [instanceSignKeyPair] - Sign keypair used for instance.
* Also see the description for `personEncryptionKeyPair`.
* @property {boolean} [wipeStorage] - If `true` **all files in storage will be deleted** when
* the instance is initialized. All files means *every single file*. Storage is wiped clean.
* @property {boolean} [encryptStorage] - On supporting platforms (browser) filenames and
* file contents can be encrypted. `secret` must be a string and not `null`.
* @property {string} [directory] - For filesystem based storage (node.js) the directory
* where this ONE instances can store and read its objects. On browsers the name of the
* IndexedDB database, if this is not set a default of "OneDB" is used. This option is
* unused on mobile (react-native).
* @property {number} [nHashCharsForSubDirs] - In "object" storage, the first `n` characters of o
* files name - a hexadecimal SHA-256 hash string - are used to locate the file in a
* subdirectory of that name. For example, if a file name (hash) starts with "0fe123...." and
* n=2, then the file will be located not in directory `objects/` but in directory
* `objects/0f/`. This hierarchical storage option is only offered on *some* platforms. When
* this option has a number higher than 0 on a platform that does not support it an Error is
* thrown.
* @property {number} [storageInitTimeout] - The browser platform accepts this parameter to time
* out the `indexedDB.open()` attempt in case the request blocks (found on Safari). Default is
* 1000ms. This can or should be used together with `one.core/util/promise method` `retry`. On
* other platforms this parameter is ignored.
* @property {Recipe[]} [initialRecipes] - An optional array of {@link Recipe} ONE objects.
* **This option is only used for initial Instance object creation.** If there is an existing
* Instance it is simply ignored. You can use {@link registerRecipes} to add recipes to an
* existing instance.
* @property {Map<OneObjectTypeNames,null|Set<string>>} [initiallyEnabledReverseMapTypes] - A Map
* object with ONE object type names as keys and an array of "itemprop" properties from the
* recipe for that particular ONE object type for which reverse maps should be written. For all
* other types and/or properties not mentioned in this Map reverse map entries are not created.
* **This option is only used for initial Instance object creation.** If there is an existing
* Instance it is simply ignored. **Right now this option cannot be changed after instance
* creation.** This is a feature to be added later, when this is changed the whole storage has
* to be iterated over to create any missing reverse maps if they are added later.
* @property {Map<OneObjectTypeNamesForIdObjects,null|Set<string>>}
* [initiallyEnabledReverseMapTypesForIdObjects] - A Map object with ONE object type names as
* keys and an array of "itemprop" properties from the recipe for that particular ONE object
* type for which ID object reverse maps should be written. For all other types and/or
* properties not mentioned in this Map reverse map entries are not created. **This option is
* only used for initial Instance object creation.** If there is an existing Instance it is
* simply ignored. **Right now this option cannot be changed after instance creation.** This is
* a feature to be added later, when this is changed the whole storage has to be iterated over
* to create any missing reverse maps if they are added later.
*/
export interface InstanceOptions {
name: string;
email: string;
secret: string;
ownerName?: string;
personEncryptionKeyPair?: KeyPair;
personSignKeyPair?: SignKeyPair;
instanceEncryptionKeyPair?: KeyPair;
instanceSignKeyPair?: SignKeyPair;
wipeStorage?: boolean;
encryptStorage?: boolean;
directory?: string;
nHashCharsForSubDirs?: number;
storageInitTimeout?: number;
initialRecipes?: readonly Recipe[];
initiallyEnabledReverseMapTypes?: Map<OneObjectTypeNames, Set<string>>;
initiallyEnabledReverseMapTypesForIdObjects?: Map<OneVersionedObjectTypeNames, Set<string>>;
}
// NOOP TS-only import - that is a .d.ts file - so that when tsc checks declaration files in
// lib/ this file is included. It looks like it was not when running tsc with the top level
// tsconfig.json, which only checks scripts/*.js, but apparently it also checked lib even with
// the references to src/ and test/ removed and explicitly told (via "exclude") to ignore that
// folder. This line tells tsc to include this file so that there are no problems.
// import './@OneObjectInterfaces';
import {clearCrdtRuntimeSets, isCrdtObjectRecipe, registerCrdtRecipeName} from './crdt-recipes';
import type {KeyPair} from './crypto/encryption';
import type {SignKeyPair} from './crypto/sign';
import {createError} from './errors';
import {createInstance} from './instance-creator';
import {updateInstance} from './instance-updater';
import {lockKeyChain, unlockOrCreateKeyChain} from './keychain/keychain';
import {createMessageBus} from './message-bus';
import {
addCoreRecipesToRuntime,
addRecipeToRuntime,
clearRuntimeRecipes,
hasRecipe
} from './object-recipes';
import type {
Instance,
OneCrdtObjectTypeNames,
OneObjectTypeNames,
OneVersionedObjectTypeNames,
Person,
Recipe
} from './recipes';
import {
addEnabledRvMapType,
addEnabledRvMapTypeForIdObjects,
clearRvMapTypes
} from './reverse-map-updater';
import {getObjectWithType} from './storage-unversioned-objects';
import type {VersionedObjectResult} from './storage-versioned-objects';
import {getObjectByIdHash} from './storage-versioned-objects';
import {createCryptoHash} from './system/crypto-helpers';
import {closeStorage, deleteStorage, doesStorageExist, initStorage} from './system/storage-base';
import {ID_OBJECT_ATTR} from './util/object';
import type {SHA256IdHash} from './util/type-checks';
import {isString} from './util/type-checks-basic';
const MessageBus = createMessageBus('instance');
let InstanceDirectory: undefined | string;
let InstanceName: undefined | string;
let InstaceOwnerEmail: undefined | string;
let InstanceIdHash: undefined | SHA256IdHash<Instance>;
let InstanceOwnerIdHash: undefined | SHA256IdHash<Person>;
/**
* @static
* @returns {string|undefined} Returns instance directory
*/
export function getInstanceDirectory(): undefined | string {
return InstanceDirectory;
}
/**
* @static
* @returns {string|undefined} Returns the `name`property of the {@link Instance} object of the
* currently active instance, or `undefined` if the instance has not been initialized yet
*/
export function getInstanceName(): undefined | string {
return InstanceName;
}
/**
* @static
* @returns {SHA256IdHash<Instance>|undefined} Returns the ID hash of the instance
*/
export function getInstanceIdHash(): undefined | SHA256IdHash<Instance> {
return InstanceIdHash;
}
/**
* @static
* @returns {undefined | SHA256IdHash<Person>} Returns the ID hash of the instance owner person
* object
*/
export function getInstanceOwnerIdHash(): undefined | SHA256IdHash<Person> {
return InstanceOwnerIdHash;
}
/**
* @static
* @returns {string|undefined} Returns the instance owner email
*/
export function getInstanceOwnerEmail(): undefined | string {
return InstaceOwnerEmail;
}
/**
* Load the map files used by the app. If bootstrapping fails because the bootstrap objects
* don't exist they are silently created.
* @static
* @async
* @param {InstanceOptions} options - A mandatory object with options properties
* @returns {Promise<Instance>} Returns the Instance object
*/
export async function initInstance({
name,
email,
secret,
ownerName,
personEncryptionKeyPair,
personSignKeyPair,
instanceEncryptionKeyPair,
instanceSignKeyPair,
wipeStorage = false,
encryptStorage = false,
directory,
nHashCharsForSubDirs = 0,
storageInitTimeout = 1000,
initialRecipes = [],
initiallyEnabledReverseMapTypes,
initiallyEnabledReverseMapTypesForIdObjects
}: InstanceOptions): Promise<Instance> {
// Our architecture is set up so that one Javascript runtime environment can run one
// instance. It is not possible to have two different ONE instances in the same Javascript
// environment. Each instance has its own storage space identified by the Instance ID hash.
if (InstanceName !== undefined) {
throw createError('IN-INIT1', {iName: InstanceName});
}
if (encryptStorage && !isString(secret)) {
throw createError('IN-INIT2', {iName: name});
}
MessageBus.send('log', `Initializing instance "${name}"`);
// Catch-22: We need the Instance ID hash to initialize storage - but we cannot call the
// Instance object creating instance-updater that would return that hash until after storage
// has been initialized. To escape this contradiction we have to do the hash calculation
// ourselves, even though the instance-updater will do the same later.
// Trying to micro-optimize and to avoid doing the same work twice is not worth it though,
// especially given that instance-updater doesn't do the calculation itself, but it happens
// implicitly further down the call stack when it tries to load an already existing owner and
// instance objects.
const instanceIdHash = await calculateInstanceIdHash(name, email);
await initStorage({
instanceIdHash,
wipeStorage,
name: directory,
nHashCharsForSubDirs,
storageInitTimeout,
encryptStorage,
secretForStorageKey: secret
});
let instance: undefined | VersionedObjectResult<Instance>;
// Add the CORE-Recipes. Needs to happen before any function using them is called.
addCoreRecipesToRuntime();
await unlockOrCreateKeyChain(secret);
try {
instance = await getObjectByIdHash(instanceIdHash, 'Instance');
} catch (err) {
if (err.name !== 'FileNotFoundError') {
throw err;
}
instance = await createInstance({
name,
email,
ownerName,
personEncryptionKeyPair,
personSignKeyPair,
instanceEncryptionKeyPair,
instanceSignKeyPair,
initialRecipes,
initiallyEnabledReverseMapTypes,
initiallyEnabledReverseMapTypesForIdObjects
});
}
const recipes = (
await Promise.all(
(instance.obj.recipe || []).map(hash => getObjectWithType(hash, 'Recipe'))
)
).filter(recipe => !hasRecipe(recipe.name));
recipes.map(recipe => {
// TODO It is a bit of a hack, Reason: REFINIO-675 "CRDT Meta recipes not internally
// registered when loaded from Instance"
if (isCrdtObjectRecipe(recipe)) {
registerCrdtRecipeName(recipe.name as OneCrdtObjectTypeNames);
}
return addRecipeToRuntime(recipe);
});
instance.obj.enabledReverseMapTypes.forEach((props, type) => addEnabledRvMapType(type, props));
instance.obj.enabledReverseMapTypesForIdObjects.forEach((props, type) =>
addEnabledRvMapTypeForIdObjects(type, props)
);
InstanceName = name;
InstanceOwnerIdHash = instance.obj.owner;
InstaceOwnerEmail = email;
InstanceDirectory = directory;
// Assign it to the module-level variable only now that all steps finished without error, so
// that it remains undefined if this function exits early with error
InstanceIdHash = instanceIdHash;
return instance.obj;
}
/**
* TODO REMOVE THIS FUNCTION - Just call instance-updater directly. This is a remnant from when
* calling that module was cumbersome because it was a Plan module.
*
* Adds a recipe consisting of an array of objects of type RecipeRule for the given type
* string. The recipe's rules are checked for the property names and the corresponding types but
* no further: For example, if you forget "itemprop" (mandatory, a string) in a rule, or if you
* have the wrong type of item on a rule property an Error is thrown. However, for "type" and
* a rule's "itemprop" string property we only guard against HTML-breaking "<" and ">" and
* against whitespace, and we don't check if any other recipes you refer to exist and a wide
* range of conceivable problems.
*
* A recipe for a ONE object type consists of arrays of objects describing the data properties.
* Allowed rule properties are explained in {@link RecipeRule}:
*
* ### Example
*
* The following code adds a recipe for a ONE object type "Mailbox":
*
* ```javascript
* import * as ObjectRecipes from 'one.core/lib/object-recipes';
*
* if (!ObjectRecipes.hasRecipe('Mailbox')) {
* ObjectRecipes.addRecipeToRuntime({
* $type$: 'Recipe',
* name: 'Mailbox',
* rule: [
* // A SHA-256 hash pointing to an OneTest$ImapAccount object that this mailbox
* // belongs to. Both the account and the name are the ID attributes of this
* // VERSIONED object, meaning any Mailbox object with the same account and name
* // will be a version of the same object, varying only in the data properties not
* // marked as "isID:true".
* { itemprop: 'account', isId: true, referenceToObj: new Set(['Reference','Account'])
* },
* { itemprop: 'name', isId: true },
* // This is an IMAP protocol feature to check if the IMAP-UIDs from last time are
* // still valid. This 32-bit integer can be fully represented by a Javascript number.
* { itemprop: 'uidValidity', valueType: 'number' },
* // A JSON-stringified UID => Email-BLOB-hash map
* { itemprop: 'uidEmailBlobMap', valueType: 'object' },
* ]
* });
* }
* ```
*
* A ONE object created using this recipe would look like this (indentation and newlines added
* fore readability, not present in the actual microdata):
*
* ```html
* <div itemscope itemtype="//refin.io/Mailbox">
* <span itemprop="account">8296cf598c1af767b5287....2bd96eae03448da3066aa</span>
* <span itemprop="name">INBOX</span>
* <span itemprop="uidValidity">1455785767</span>
* <span itemprop="uidEmailBlobMap">...[JSON]...</span>
* </span>
* ```
* @static
* @async
* @param {Recipe[]} recipes - Array of {@link Recipe|ONE "Recipe" objects} to add to
* the currently active instance **permanently**
* @returns {ObjectCreation[]} Returns the result of creating the new Instance object
* and the new Recipe objects
*/
export async function registerRecipes(
recipes: Recipe[]
): Promise<ReturnType<typeof updateInstance>> {
return await updateInstance({recipes});
}
/**
* If initInstance() is to be called more than once close() must be called first. There can be
* only a single ONE instance in a runtime environment. This is used by mocha storage tests which do
* the same storage-init followed by storage-deletion for each test file.
* @static
* @returns {undefined}
*/
export function closeInstance(): void {
closeStorage();
lockKeyChain();
clearRuntimeRecipes();
clearRvMapTypes();
clearCrdtRuntimeSets();
InstanceName = undefined;
InstanceIdHash = undefined;
InstanceDirectory = undefined;
InstaceOwnerEmail = undefined;
}
/**
* Closes & deletes the current instance.
*
* When the InstanceIdHash is undefined the instance was not initialized or is already closed.
* @returns {Promise<void>}
*/
export async function closeAndDeleteCurrentInstance(): Promise<void> {
if (InstanceIdHash === undefined) {
throw createError('IN-CADCI1');
}
const currentInstanceId = InstanceIdHash;
closeInstance();
await deleteStorage(currentInstanceId);
}
/**
* Allows checking if an instance exists without an active instance.
* @param {InstanceOptions.name} instanceName
* @param {InstanceOptions.email} email
* @returns {Promise<boolean>}
*/
export async function instanceExists(
instanceName: InstanceOptions['name'],
email: InstanceOptions['email']
): Promise<boolean> {
const instanceIdHash = await calculateInstanceIdHash(instanceName, email);
return await doesStorageExist(instanceIdHash);
}
/**
* Allows deleting an instance without an active instance.
* This is important e.g. in case of a forgotten password.
*
* @param {InstanceOptions.name} instanceName
* @param {InstanceOptions.email} email
* @returns {Promise<boolean>}
*/
export async function deleteInstance(
instanceName: InstanceOptions['name'],
email: InstanceOptions['email']
): Promise<void> {
const instanceIdHash = await calculateInstanceIdHash(instanceName, email);
if (!(await instanceExists(instanceName, email))) {
// We want to throw an error if someone tries to delete an instance that
// doesn't exist deleteStorage would silently ignore it.
throw createError('IN-DI1', {instanceName, email});
}
await deleteStorage(instanceIdHash);
}
/**
* Allows to calculate an instanceIdHash without an active instance needed to
* 1. check if an inactive instance exists
* 2. delete an inactive instance
*
* IMPORTANT: Needs to be adjusted by microdata changes
*
* @param {InstanceOptions.name} instanceName
* @param {InstanceOptions.email} email
* @returns {Promise<SHA256IdHash<Instance>>}
*/
export async function calculateInstanceIdHash(
instanceName: InstanceOptions['name'],
email: InstanceOptions['email']
): Promise<SHA256IdHash<Instance>> {
const ownerIdHash = (await createCryptoHash(
`<div ${ID_OBJECT_ATTR} itemscope itemtype="//refin.io/Person"><span itemprop="email">${email}</span></div>`
)) as unknown as SHA256IdHash<Person>;
return (await createCryptoHash(
`<div ${ID_OBJECT_ATTR} itemscope itemtype="//refin.io/Instance"><span itemprop="name">${instanceName}</span><a itemprop="owner" data-type="id">${ownerIdHash}</a></div>`
)) as unknown as SHA256IdHash<Instance>;
}