UNPKG

@oddjs/odd

Version:
657 lines (655 loc) 28.3 kB
/* %@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@% @@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@ %@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@% @@@@@% %@@@@@@% %@@@@@@@% %@@@@@ @@@@@ @@@@@% @@@@@@ @@@@@ @@@@@% @@@@@ %@@@@@ %@@@@@ @@@@@@% @@@@@ %@@% @@@@@ %@@@@@@ @@@@@@@ @@@@@ %@@@@% @@@@@ @@@@@@@ @@@@@@@ @@@@% @@@@@@ @@@@@ @@@@@@@ @@@@@@@ %@@@@ @@@@@@ @@@@@% @@@@@@@ @@@@@@@ @@@@@ @@@@@@ %@@@@@ @@@@@@@ @@@@@@@ @@@@@@@@@@@@@@@@ @@@@@ @@@@@@@ @@@@@@@ %@@@@@@@@@@@@@@@ @@@@% @@@@@@@ @@@@@@@ %@@% @@@@@@ %@@% @@@@@@@ @@@@@@@ @@@@@@ @@@@@@@ @@@@@@@% %@@@@@@% %@@@@@@@ @@@@@@@@@% %@@@@@@@@@@% %@@@@@@@@@ %@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@% @@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@ %@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@% */ import * as Uint8arrays from "uint8arrays"; import localforage from "localforage"; import * as Capabilities from "./capabilities.js"; import * as DID from "./did/local.js"; import * as Events from "./events.js"; import * as Extension from "./extension/index.js"; import * as FileSystemData from "./fs/data.js"; import * as RootKey from "./common/root-key.js"; import * as Semver from "./common/semver.js"; import * as SessionMod from "./session.js"; import * as Ucan from "./ucan/index.js"; import { SESSION_TYPE as CAPABILITIES_SESSION_TYPE } from "./capabilities.js"; import { TYPE as WEB_CRYPTO_SESSION_TYPE } from "./components/auth/implementation/base.js"; import { VERSION } from "./common/version.js"; import { createConsumer, createProducer } from "./linking/index.js"; import { namespace } from "./configuration.js"; import { isString } from "./common/index.js"; import { Session } from "./session.js"; import { loadFileSystem, recoverFileSystem } from "./filesystem.js"; import * as BrowserCrypto from "./components/crypto/implementation/browser.js"; import * as BrowserStorage from "./components/storage/implementation/browser.js"; import * as FissionIpfsProduction from "./components/depot/implementation/fission-ipfs-production.js"; import * as FissionIpfsStaging from "./components/depot/implementation/fission-ipfs-staging.js"; import * as FissionAuthBaseProduction from "./components/auth/implementation/fission-base-production.js"; import * as FissionAuthBaseStaging from "./components/auth/implementation/fission-base-staging.js"; import * as FissionAuthWnfsProduction from "./components/auth/implementation/fission-wnfs-production.js"; import * as FissionAuthWnfsStaging from "./components/auth/implementation/fission-wnfs-staging.js"; import * as FissionLobbyProduction from "./components/capabilities/implementation/fission-lobby-production.js"; import * as FissionLobbyStaging from "./components/capabilities/implementation/fission-lobby-staging.js"; import * as FissionReferenceProduction from "./components/reference/implementation/fission-production.js"; import * as FissionReferenceStaging from "./components/reference/implementation/fission-staging.js"; import * as MemoryStorage from "./components/storage/implementation/memory.js"; import * as ProperManners from "./components/manners/implementation/base.js"; // RE-EXPORTS export * from "./appInfo.js"; export * from "./components.js"; export * from "./configuration.js"; export * from "./common/cid.js"; export * from "./common/types.js"; export * from "./common/version.js"; export * from "./permissions.js"; export * as apps from "./apps/index.js"; export * as did from "./did/index.js"; export * as fission from "./common/fission.js"; export * as path from "./path/index.js"; export * as ucan from "./ucan/index.js"; export { FileSystem } from "./fs/filesystem.js"; export { Session } from "./session.js"; export var ProgramError; (function (ProgramError) { ProgramError["InsecureContext"] = "INSECURE_CONTEXT"; ProgramError["UnsupportedBrowser"] = "UNSUPPORTED_BROWSER"; })(ProgramError || (ProgramError = {})); // ENTRY POINTS /** * 🚀 Build an ODD program. * * This will give you a `Program` object which has the following properties: * - `session`, a `Session` object if a session was created before. * - `auth`, a means to control the various auth strategies you configured. Use this to create sessions. Read more about auth components in the toplevel `auth` object documention. * - `capabilities`, a means to control capabilities. Use this to collect & request capabilities, and to create a session based on them. Read more about capabilities in the toplevel `capabilities` object documentation. * - `components`, your full set of `Components`. * * This object also has a few other functions, for example to load a filesystem. * These are called "shorthands" because they're the same functions available * through other places in the ODD SDK, but you don't have to pass in the components. * * See `assemble` for more information. Note that this function checks for browser support, * while `assemble` does not. Use the latter in case you want to bypass the indexedDB check, * which might not be needed, or available, in certain environments or using certain components. */ export async function program(settings) { if (!settings) throw new Error("Expected a settings object of the type `Partial<Components> & Configuration` as the first parameter"); // Check if the browser and context is supported if (globalThis.isSecureContext === false) throw ProgramError.InsecureContext; if (await isSupported() === false) throw ProgramError.UnsupportedBrowser; // Initialise components & assemble program const components = await gatherComponents(settings); return assemble(extractConfig(settings), components); } // PREDEFINED COMPONENTS /** * Predefined auth configurations. * * This component goes hand in hand with the "reference" and "depot" components. * The "auth" component registers a DID and the reference looks it up. * The reference component also manages the "data root", the pointer to an account's entire filesystem. * The depot component is responsible for getting data to and from the other side. * * For example, using the Fission architecture, when a data root is updated on the Fission server, * the server fetches the data from the depot in your app. * * So if you want to build a service independent of Fission's infrastructure, * you will need to write your own reference and depot implementations (see source code). * * NOTE: If you're using a non-default component, you'll want to pass that in here as a parameter as well. * Dependencies: crypto, manners, reference, storage. */ export const auth = { /** * A standalone authentication system that uses the browser's Web Crypto API * to create an identity based on a RSA key-pair. * * NOTE: This uses a Fission server to register an account (DID). * Check out the `wnfs` and `base` auth implementations if * you want to build something without the Fission infrastructure. */ async fissionWebCrypto(settings) { const { disableWnfs, staging } = settings; const manners = settings.manners || defaultMannersComponent(settings); const crypto = settings.crypto || await defaultCryptoComponent(settings); const storage = settings.storage || defaultStorageComponent(settings); const reference = settings.reference || await defaultReferenceComponent({ crypto, manners, storage }); if (disableWnfs) { if (staging) return FissionAuthBaseStaging.implementation({ crypto, reference, storage }); return FissionAuthBaseProduction.implementation({ crypto, reference, storage }); } else { if (staging) return FissionAuthWnfsStaging.implementation({ crypto, reference, storage }); return FissionAuthWnfsProduction.implementation({ crypto, reference, storage }); } } }; /** * Predefined capabilities configurations. * * If you want partial read and/or write access to the filesystem you'll want * a "capabilities" component. This component is responsible for requesting * and receiving UCANs, read keys and namefilters from other sources to enable this. * * NOTE: If you're using a non-default component, you'll want to pass that in here as a parameter as well. * Dependencies: crypto, depot. */ export const capabilities = { /** * A secure enclave in the form of a ODD app which serves as the root authority. * Your app is redirected to the lobby where the user can create an account or link a device, * and then request permissions from the user for reading or write to specific parts of the filesystem. */ async fissionLobby(settings) { const { staging } = settings; const crypto = settings.crypto || await defaultCryptoComponent(settings); if (staging) return FissionLobbyStaging.implementation({ crypto }); return FissionLobbyProduction.implementation({ crypto }); } }; /** * Predefined crypto configurations. * * The crypto component is responsible for various cryptographic operations. * This includes AES and RSA encryption & decryption, creating and storing * key pairs, verifying DIDs and defining their magic bytes, etc. */ export const crypto = { /** * The default crypto component, uses primarily the Web Crypto API and [keystore-idb](https://github.com/fission-codes/keystore-idb). * Keys are stored in a non-exportable way in indexedDB using the Web Crypto API. * * IndexedDB store is namespaced. */ browser(settings) { return defaultCryptoComponent(settings); } }; /** * Predefined depot configurations. * * The depot component gets data in and out your program. * For example, say I want to load and then update a file system. * The depot will get that file system data for me, * and after updating it, send the data to where it needs to be. */ export const depot = { /** * This depot uses IPFS and the Fission servers. * The data is transferred to the Fission IPFS node, * where all of your encrypted and public data lives. * Other ODD programs with this depot fetch the data from there. */ async fissionIPFS(settings) { const repoName = `${namespace(settings)}/ipfs`; const storage = settings.storage || defaultStorageComponent(settings); if (settings.staging) return FissionIpfsStaging.implementation({ storage }, repoName); return FissionIpfsProduction.implementation({ storage }, repoName); } }; /** * Predefined manners configurations. * * The manners component allows you to tweak various behaviours of an ODD program, * such as logging and file system hooks (eg. what to do after a new file system is created). */ export const manners = { /** * The default ODD SDK behaviour. */ default(settings) { return defaultMannersComponent(settings); } }; /** * Predefined reference configurations. * * The reference component is responsible for looking up and updating various pointers. * Specifically, the data root, a user's DID root, DNSLinks, DNS TXT records. * It also holds repositories (see `Repository` class), which contain UCANs and CIDs. * * NOTE: If you're using a non-default component, you'll want to pass that in here as a parameter as well. * Dependencies: crypto, manners, storage. */ export const reference = { /** * Use the Fission servers as your reference. */ async fission(settings) { const { staging } = settings; const manners = settings.manners || defaultMannersComponent(settings); const crypto = settings.crypto || await defaultCryptoComponent(settings); const storage = settings.storage || defaultStorageComponent(settings); if (staging) return FissionReferenceStaging.implementation({ crypto, manners, storage }); return FissionReferenceProduction.implementation({ crypto, manners, storage }); } }; /** * Predefined storage configuration. * * A key-value storage abstraction responsible for storing various * pieces of data, such as session data and UCANs. */ export const storage = { /** * IndexedDB through the `localForage` library, automatically namespaced. */ browser(settings) { return defaultStorageComponent(settings); }, /** * In-memory store. */ memory() { return MemoryStorage.implementation(); } }; // ASSEMBLE /** * Build an ODD Program based on a given set of `Components`. * These are various customisable components that determine how an ODD app works. * Use `program` to work with a default, or partial, set of components. * * Additionally this does a few other things: * - Restores a session if one was made before and loads the user's file system if needed. * - Attempts to collect capabilities if the configuration has permissions. * - Provides shorthands to functions so you don't have to pass in components. * - Ensure backwards compatibility with older ODD SDK clients. * * See the `program.fileSystem.load` function if you want to load the user's file system yourself. */ export async function assemble(config, components) { const permissions = config.permissions; // Backwards compatibility (data) await ensureBackwardsCompatibility(components, config); // Event emitters const fsEvents = Events.createEmitter(); const sessionEvents = Events.createEmitter(); const allEvents = Events.merge(fsEvents, sessionEvents); // Authenticated user const sessionInfo = await SessionMod.restore(components.storage); // Auth implementations const auth = (method => { return { implementation: method, accountConsumer(username) { return createConsumer({ auth: method, crypto: components.crypto, manners: components.manners }, { username }); }, accountProducer(username) { return createProducer({ auth: method, crypto: components.crypto, manners: components.manners }, { username }); }, isUsernameAvailable: method.isUsernameAvailable, isUsernameValid: method.isUsernameValid, register: method.register, async session() { const newSessionInfo = await SessionMod.restore(components.storage); if (!newSessionInfo) return null; return this.implementation.session(components, newSessionInfo.username, config, { fileSystem: fsEvents, session: sessionEvents }); } }; })(components.auth); // Capabilities const capabilities = { async collect() { const c = await components.capabilities.collect(); if (!c) return null; await Capabilities.collect({ capabilities: c, crypto: components.crypto, reference: components.reference, storage: components.storage }); return c.username; }, request(options) { return components.capabilities.request({ permissions, ...(options || {}) }); }, async session(username) { const ucan = Capabilities.validatePermissions(components.reference.repositories.ucans, permissions || {}); if (!ucan) { console.warn("The present UCANs did not satisfy the configured permissions."); return null; } const accountDID = await components.reference.didRoot.lookup(username); const validSecrets = await Capabilities.validateSecrets(components.crypto, accountDID, permissions || {}); if (!validSecrets) { console.warn("The present filesystem secrets did not satisfy the configured permissions."); return null; } await SessionMod.provide(components.storage, { type: CAPABILITIES_SESSION_TYPE, username }); const fs = config.fileSystem?.loadImmediately === false ? undefined : await loadFileSystem({ config, dependencies: components, eventEmitter: fsEvents, username, }); return new Session({ fs, username, crypto: components.crypto, storage: components.storage, type: CAPABILITIES_SESSION_TYPE, eventEmitter: sessionEvents }); } }; // Session let session = null; if (isCapabilityBasedAuthConfiguration(config)) { const username = await capabilities.collect(); if (username) session = await capabilities.session(username); if (sessionInfo && sessionInfo.type === CAPABILITIES_SESSION_TYPE) session = await capabilities.session(sessionInfo.username); } else if (sessionInfo && sessionInfo.type !== CAPABILITIES_SESSION_TYPE) { session = await auth.session(); } // Shorthands const shorthands = { // DIDs accountDID: (username) => components.reference.didRoot.lookup(username), agentDID: () => DID.agent(components.crypto), sharingDID: () => DID.sharing(components.crypto), // File system fileSystem: { addPublicExchangeKey: (fs) => FileSystemData.addPublicExchangeKey(components.crypto, fs), addSampleData: (fs) => FileSystemData.addSampleData(fs), hasPublicExchangeKey: (fs) => FileSystemData.hasPublicExchangeKey(components.crypto, fs), load: (username) => loadFileSystem({ config, username, dependencies: components, eventEmitter: fsEvents }), recover: (params) => recoverFileSystem({ auth, dependencies: components, ...params }), } }; // Create `Program` const program = { ...shorthands, ...Events.listenTo(allEvents), configuration: { ...config }, auth, components, capabilities, session, }; // Inject into global context if necessary if (config.debug) { const inject = config.debugging?.injectIntoGlobalContext === undefined ? true : config.debugging?.injectIntoGlobalContext; if (inject) { const container = globalThis; container.__odd = container.__odd || {}; container.__odd.programs = container.__odd.programs || {}; container.__odd.programs[namespace(config)] = program; } const emitMessages = config.debugging?.emitWindowPostMessages === undefined ? true : config.debugging?.emitWindowPostMessages; if (emitMessages) { const { connect, disconnect } = await Extension.create({ namespace: config.namespace, session, capabilities: config.permissions, dependencies: components, eventEmitters: { fileSystem: fsEvents, session: sessionEvents } }); const container = globalThis; container.__odd = container.__odd || {}; container.__odd.extension = container.__odd.extension || {}; container.__odd.extension.connect = connect; container.__odd.extension.disconnect = disconnect; // Notify extension that the ODD SDK is ready globalThis.postMessage({ id: "odd-devtools-ready-message", }); } } // Fin return program; } // COMPOSITIONS /** * Full component sets. */ export const compositions = { /** * The default Fission stack using web crypto auth. */ async fission(settings) { const crypto = settings.crypto || await defaultCryptoComponent(settings); const manners = settings.manners || defaultMannersComponent(settings); const storage = settings.storage || defaultStorageComponent(settings); const settingsWithComponents = { ...settings, crypto, manners, storage }; const r = await reference.fission(settingsWithComponents); const d = await depot.fissionIPFS(settingsWithComponents); const c = await capabilities.fissionLobby(settingsWithComponents); const a = await auth.fissionWebCrypto({ ...settingsWithComponents, reference: r }); return { auth: a, capabilities: c, depot: d, reference: r, crypto, manners, storage, }; } }; export async function gatherComponents(setup) { const config = extractConfig(setup); const crypto = setup.crypto || await defaultCryptoComponent(config); const manners = setup.manners || defaultMannersComponent(config); const storage = setup.storage || defaultStorageComponent(config); const reference = setup.reference || await defaultReferenceComponent({ crypto, manners, storage }); const depot = setup.depot || await defaultDepotComponent({ storage }, config); const capabilities = setup.capabilities || defaultCapabilitiesComponent({ crypto }); const auth = setup.auth || defaultAuthComponent({ crypto, reference, storage }); return { auth, capabilities, crypto, depot, manners, reference, storage, }; } // DEFAULT COMPONENTS export function defaultAuthComponent({ crypto, reference, storage }) { return FissionAuthWnfsProduction.implementation({ crypto, reference, storage, }); } export function defaultCapabilitiesComponent({ crypto }) { return FissionLobbyProduction.implementation({ crypto }); } export function defaultCryptoComponent(config) { return BrowserCrypto.implementation({ storeName: namespace(config), exchangeKeyName: "exchange-key", writeKeyName: "write-key" }); } export function defaultDepotComponent({ storage }, config) { return FissionIpfsProduction.implementation({ storage }, `${namespace(config)}/ipfs`); } export function defaultMannersComponent(config) { return ProperManners.implementation({ configuration: config }); } export function defaultReferenceComponent({ crypto, manners, storage }) { return FissionReferenceProduction.implementation({ crypto, manners, storage, }); } export function defaultStorageComponent(config) { return BrowserStorage.implementation({ name: namespace(config) }); } // 🛟 /** * Is this browser supported? */ export async function isSupported() { return localforage.supports(localforage.INDEXEDDB) // Firefox in private mode can't use indexedDB properly, // so we test if we can actually make a database. && await (() => new Promise(resolve => { const db = indexedDB.open("testDatabase"); db.onsuccess = () => resolve(true); db.onerror = () => resolve(false); }))(); } // BACKWARDS COMPAT async function ensureBackwardsCompatibility(components, config) { // Old pieces: // - Key pairs: IndexedDB → keystore → exchange-key & write-key // - UCAN used for account linking/delegation: IndexedDB → localforage → ucan // - Root read key of the filesystem: IndexedDB → localforage → readKey // - Authenticated username: IndexedDB → localforage → webnative.auth_username const [migK, migV] = ["migrated", VERSION]; const currentVersion = Semver.fromString(VERSION); if (!currentVersion) throw new Error("The ODD SDK VERSION should be a semver string"); // If already migrated, stop here. const migrationOccurred = await components.storage .getItem(migK) .then(v => typeof v === "string" ? Semver.fromString(v) : null) .then(v => v && Semver.isBiggerThanOrEqualTo(v, currentVersion)); if (migrationOccurred) return; // Only try to migrate if environment supports indexedDB if (!globalThis.indexedDB) return; // Migration const existingDatabases = globalThis.indexedDB.databases ? (await globalThis.indexedDB.databases()).map(db => db.name) : ["keystore", "localforage"]; const keystoreDB = existingDatabases.includes("keystore") ? await bwOpenDatabase("keystore") : null; if (keystoreDB) { const exchangeKeyPair = await bwGetValue(keystoreDB, "keyvaluepairs", "exchange-key"); const writeKeyPair = await bwGetValue(keystoreDB, "keyvaluepairs", "write-key"); if (exchangeKeyPair && writeKeyPair) { await components.storage.setItem("exchange-key", exchangeKeyPair); await components.storage.setItem("write-key", writeKeyPair); } } const localforageDB = existingDatabases.includes("localforage") ? await bwOpenDatabase("localforage") : null; if (localforageDB) { const accountUcan = await bwGetValue(localforageDB, "keyvaluepairs", "ucan"); const permissionedUcans = await bwGetValue(localforageDB, "keyvaluepairs", "webnative.auth_ucans"); const rootKey = await bwGetValue(localforageDB, "keyvaluepairs", "readKey"); const authedUser = await bwGetValue(localforageDB, "keyvaluepairs", "webnative.auth_username"); if (rootKey && isString(rootKey)) { const anyUcan = accountUcan || (Array.isArray(permissionedUcans) ? permissionedUcans[0] : undefined); const accountDID = anyUcan ? Ucan.rootIssuer(anyUcan) : (typeof authedUser === "string" ? await components.reference.didRoot.lookup(authedUser) : null); if (!accountDID) throw new Error("Failed to retrieve account DID"); await RootKey.store({ accountDID, crypto: components.crypto, readKey: Uint8arrays.fromString(rootKey, "base64pad"), }); } if (accountUcan) { await components.storage.setItem(components.storage.KEYS.ACCOUNT_UCAN, accountUcan); } if (authedUser) { await components.storage.setItem(components.storage.KEYS.SESSION, JSON.stringify({ type: isCapabilityBasedAuthConfiguration(config) ? CAPABILITIES_SESSION_TYPE : WEB_CRYPTO_SESSION_TYPE, username: authedUser })); } } await components.storage.setItem(migK, migV); } function bwGetValue(db, storeName, key) { return new Promise((resolve, reject) => { if (!db.objectStoreNames.contains(storeName)) return resolve(null); const transaction = db.transaction([storeName], "readonly"); const store = transaction.objectStore(storeName); const req = store.get(key); req.onerror = () => { // No store, moving on. resolve(null); }; req.onsuccess = () => { resolve(req.result); }; }); } function bwOpenDatabase(name) { return new Promise((resolve, reject) => { const req = globalThis.indexedDB.open(name); req.onerror = () => { // No database, moving on. resolve(null); }; req.onsuccess = () => { resolve(req.result); }; req.onupgradeneeded = e => { // Don't create database if it didn't exist before req.transaction?.abort(); globalThis.indexedDB.deleteDatabase(name); }; }); } // 🛠 export function extractConfig(opts) { return { namespace: opts.namespace, debug: opts.debug, fileSystem: opts.fileSystem, permissions: opts.permissions, userMessages: opts.userMessages, }; } /** * Is this a configuration that uses capabilities? */ export function isCapabilityBasedAuthConfiguration(config) { return !!config.permissions; } //# sourceMappingURL=index.js.map