From d7a500c01455ae785bb806995bf8de5d600a2cbb Mon Sep 17 00:00:00 2001 From: Steven Vandevelde Date: Wed, 22 Apr 2020 16:12:28 +0200 Subject: [PATCH] User creation and authentication (#25) --- package.json | 5 +- src/@types/index.d.ts | 1 + src/common.ts | 24 ++ src/ffs/ffs.ts | 27 ++- src/ffs/types.ts | 63 +++-- src/index.ts | 4 +- src/keystore/basic.ts | 6 + src/user/blacklist.ts | 552 ++++++++++++++++++++++++++++++++++++++++++ src/user/identity.ts | 85 +++++++ src/user/index.ts | 82 ++++++- src/user/types.ts | 4 + yarn.lock | 20 +- 12 files changed, 837 insertions(+), 36 deletions(-) create mode 100644 src/common.ts create mode 100644 src/user/blacklist.ts create mode 100644 src/user/identity.ts create mode 100644 src/user/types.ts diff --git a/package.json b/package.json index b5de4d24b..bddbbd250 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "fission-sdk", - "version": "0.3.2", + "version": "0.4.0", "description": "Fission Typescript SDK", "keywords": [], "main": "index.cjs.js", @@ -93,10 +93,11 @@ "typescript": "^3.8.2" }, "dependencies": { + "base58-universal": "^1.0.0", "borc": "^2.1.1", "get-ipfs": "^1.2.0", "ipld-dag-pb": "^0.18.2", - "keystore-idb": "^0.8.0", + "keystore-idb": "^0.11.1", "query-string": "^6.11.1" } } diff --git a/src/@types/index.d.ts b/src/@types/index.d.ts index e330ae764..29289939b 100644 --- a/src/@types/index.d.ts +++ b/src/@types/index.d.ts @@ -1,2 +1,3 @@ +declare module 'base58-universal/main.js' declare module 'ipld-dag-pb' declare module 'borc' diff --git a/src/common.ts b/src/common.ts new file mode 100644 index 000000000..069346b1f --- /dev/null +++ b/src/common.ts @@ -0,0 +1,24 @@ +import { KeyStore } from 'keystore-idb/types' + + +// CONSTANTS + + +export const API_ENDPOINT = 'https://runfission.com' + + + +// BASE64 + + +export function base64UrlDecode(a: string): string { + return atob(a).replace(/\_/g, "/").replace(/\-/g, "+") +} + +export function base64UrlEncode(b: string): string { + return makeBase64UrlSafe(btoa(b)) +} + +export function makeBase64UrlSafe(a: string): string { + return a.replace(/\//g, "_").replace(/\+/g, "-").replace(/=+$/, "") +} diff --git a/src/ffs/ffs.ts b/src/ffs/ffs.ts index f72f9db01..e54c28934 100644 --- a/src/ffs/ffs.ts +++ b/src/ffs/ffs.ts @@ -1,6 +1,6 @@ import PublicTree from './public' import PrivateTree from './private' -import { Tree, File, Links } from './types' +import { File, Links, SyncHook, Tree } from './types' import { CID, FileContent } from '../ipfs' import pathUtil from './path' import link from './link' @@ -12,6 +12,8 @@ export class FileSystem { root: PublicTree publicTree: PublicTree privateTree: PrivateTree + syncHooks: Array + private key: string constructor(root: PublicTree, publicTree: PublicTree, privateTree: PrivateTree, key: string) { @@ -19,6 +21,7 @@ export class FileSystem { this.publicTree = publicTree this.privateTree = privateTree this.key = key + this.syncHooks = [] } static async empty(keyName: string = 'filesystem-root'): Promise { @@ -92,10 +95,28 @@ export class FileSystem { const privCID = await this.privateTree.putEncrypted(this.key) const pubLink = link.make('public', pubCID, false) const privLink = link.make('private', privCID, false) + this.root = this.root .updateLink(pubLink) .updateLink(privLink) - return this.root.put() + + const cid = await this.root.put() + + this.syncHooks.forEach(hook => { + hook(cid) + }) + + return cid + } + + addSyncHook(hook: SyncHook): Array { + this.syncHooks = [...this.syncHooks, hook] + return this.syncHooks + } + + removeSyncHook(hook: SyncHook): Array { + this.syncHooks = this.syncHooks.filter(h => h !== hook) + return this.syncHooks } async runOnTree(path: string, updateTree: boolean, fn: (tree: Tree, relPath: string) => Promise): Promise { @@ -107,7 +128,7 @@ export class FileSystem { result = await fn(this.publicTree, relPath) if(updateTree && PublicTree.instanceOf(result)){ this.publicTree = result - } + } }else if(head === 'private') { result = await fn(this.privateTree, relPath) if(updateTree && PrivateTree.instanceOf(result)){ diff --git a/src/ffs/types.ts b/src/ffs/types.ts index 39fdb92f9..3fa9798f7 100644 --- a/src/ffs/types.ts +++ b/src/ffs/types.ts @@ -1,43 +1,55 @@ import { FileContent, CID } from '../ipfs' -export type AddLinkOpts = { - shouldOverwrite?: boolean + +// FILES +// ----- + +export interface File { + content: FileContent + put(): Promise } -export type NonEmptyPath = [string, ...string[]] +export interface FileStatic { + create: (content: FileContent) => File + fromCID: (cid: CID) => Promise +} -export type PrivateTreeData = { - key: string - links: Links +export interface PrivateFileStatic extends FileStatic{ + fromCIDWithKey: (cid: CID, key: string) => Promise +} + + +// LINKS +// ----- + +export type AddLinkOpts = { + shouldOverwrite?: boolean } export type Link = { name: string cid: CID - size?: number + size?: number mtime?: number isFile: boolean } export type Links = { [name: string]: Link } -export interface FileStatic { - create: (content: FileContent) => File - fromCID: (cid: CID) => Promise -} -export interface PrivateFileStatic extends FileStatic{ - fromCIDWithKey: (cid: CID, key: string) => Promise -} +// MISC +// ---- -export interface File { - content: FileContent - put(): Promise -} +export type NonEmptyPath = [string, ...string[]] +export type SyncHook = (cid: CID) => any -export interface TreeStatic { - empty: () => Promise - fromCID: (cid: CID) => Promise + +// TREE +// ---- + +export type PrivateTreeData = { + key: string + links: Links } export interface PrivateTreeStatic extends TreeStatic { @@ -47,18 +59,18 @@ export interface PrivateTreeStatic extends TreeStatic { export interface Tree { links: Links isFile: boolean + static: { tree: TreeStatic file: FileStatic } - ls(path: string): Promise mkdir(path: string): Promise cat(path: string): Promise add(path: string, content: FileContent): Promise get(path: string): Promise - pathExists(path: string): Promise + pathExists(path: string): Promise addChild(path: string, toAdd: Tree | File): Promise put(): Promise @@ -71,3 +83,8 @@ export interface Tree { rmLink(name: string): Tree copyWithLinks(links: Links): Tree } + +export interface TreeStatic { + empty: () => Promise + fromCID: (cid: CID) => Promise +} diff --git a/src/index.ts b/src/index.ts index 77f4728b2..35be41115 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2,10 +2,12 @@ import auth from './auth' import * as ffs from './ffs' import ipfs from './ipfs' import keystore from './keystore' +import user from './user' export default { auth, ffs, ipfs, - keystore + keystore, + user } diff --git a/src/keystore/basic.ts b/src/keystore/basic.ts index 3bba1e784..510b4f83e 100644 --- a/src/keystore/basic.ts +++ b/src/keystore/basic.ts @@ -1,4 +1,9 @@ import { getKeystore } from './config' +import keystore from 'keystore-idb' + +export const clear = (): Promise => { + return keystore.clear() +} export const getKeyByName = async (keyName: string): Promise => { const ks = await getKeystore() @@ -6,5 +11,6 @@ export const getKeyByName = async (keyName: string): Promise => { } export default { + clear, getKeyByName } diff --git a/src/user/blacklist.ts b/src/user/blacklist.ts new file mode 100644 index 000000000..07cf2ef98 --- /dev/null +++ b/src/user/blacklist.ts @@ -0,0 +1,552 @@ +/** + * Blacklist for usernames. + * + * Keep in sync with the Fission API. + * https://github.com/fission-suite/fission/blob/master/library/Fission/User/Username/Validation.hs + */ +export const USERNAME_BLACKLIST = + [ "fission" + , ".htaccess" + , "htaccess" + , ".htpasswd" + , "htpasswd" + , ".well-known" + , "well-known" + , "400" + , "401" + , "403" + , "404" + , "405" + , "406" + , "407" + , "408" + , "409" + , "410" + , "411" + , "412" + , "413" + , "414" + , "415" + , "416" + , "417" + , "421" + , "422" + , "423" + , "424" + , "426" + , "428" + , "429" + , "431" + , "500" + , "501" + , "502" + , "503" + , "504" + , "505" + , "506" + , "507" + , "508" + , "509" + , "510" + , "511" + , "_domainkey" + , "about" + , "about-us" + , "abuse" + , "access" + , "account" + , "accounts" + , "ad" + , "add" + , "admin" + , "administration" + , "administrator" + , "ads" + , "ads.txt" + , "advertise" + , "advertising" + , "aes128-ctr" + , "aes128-gcm" + , "aes192-ctr" + , "aes256-ctr" + , "aes256-gcm" + , "affiliate" + , "affiliates" + , "ajax" + , "alert" + , "alerts" + , "alpha" + , "amp" + , "analytics" + , "api" + , "app" + , "app-ads.txt" + , "apps" + , "asc" + , "assets" + , "atom" + , "auth" + , "authentication" + , "authorize" + , "autoconfig" + , "autodiscover" + , "avatar" + , "backup" + , "banner" + , "banners" + , "bbs" + , "beta" + , "billing" + , "billings" + , "blog" + , "blogs" + , "board" + , "bookmark" + , "bookmarks" + , "broadcasthost" + , "business" + , "buy" + , "cache" + , "calendar" + , "campaign" + , "captcha" + , "careers" + , "cart" + , "cas" + , "categories" + , "category" + , "cdn" + , "cgi" + , "cgi-bin" + , "chacha20-poly1305" + , "change" + , "channel" + , "channels" + , "chart" + , "chat" + , "checkout" + , "clear" + , "client" + , "close" + , "cloud" + , "cms" + , "com" + , "comment" + , "comments" + , "community" + , "compare" + , "compose" + , "config" + , "connect" + , "contact" + , "contest" + , "cookies" + , "copy" + , "copyright" + , "count" + , "cp" + , "cpanel" + , "create" + , "crossdomain.xml" + , "css" + , "curve25519-sha256" + , "customer" + , "customers" + , "customize" + , "dashboard" + , "db" + , "deals" + , "debug" + , "delete" + , "desc" + , "destroy" + , "dev" + , "developer" + , "developers" + , "diffie-hellman-group-exchange-sha256" + , "diffie-hellman-group14-sha1" + , "disconnect" + , "discuss" + , "dns" + , "dns0" + , "dns1" + , "dns2" + , "dns3" + , "dns4" + , "docs" + , "documentation" + , "domain" + , "download" + , "downloads" + , "downvote" + , "draft" + , "drop" + , "ecdh-sha2-nistp256" + , "ecdh-sha2-nistp384" + , "ecdh-sha2-nistp521" + , "edit" + , "editor" + , "email" + , "enterprise" + , "error" + , "errors" + , "event" + , "events" + , "example" + , "exception" + , "exit" + , "explore" + , "export" + , "extensions" + , "false" + , "family" + , "faq" + , "faqs" + , "favicon.ico" + , "features" + , "feed" + , "feedback" + , "feeds" + , "file" + , "files" + , "filter" + , "follow" + , "follower" + , "followers" + , "following" + , "fonts" + , "forgot" + , "forgot-password" + , "forgotpassword" + , "form" + , "forms" + , "forum" + , "forums" + , "friend" + , "friends" + , "ftp" + , "get" + , "git" + , "go" + , "graphql" + , "group" + , "groups" + , "guest" + , "guidelines" + , "guides" + , "head" + , "header" + , "help" + , "hide" + , "hmac-sha" + , "hmac-sha1" + , "hmac-sha1-etm" + , "hmac-sha2-256" + , "hmac-sha2-256-etm" + , "hmac-sha2-512" + , "hmac-sha2-512-etm" + , "home" + , "host" + , "hosting" + , "hostmaster" + , "htpasswd" + , "http" + , "httpd" + , "https" + , "humans.txt" + , "icons" + , "images" + , "imap" + , "img" + , "import" + , "index" + , "info" + , "insert" + , "investors" + , "invitations" + , "invite" + , "invites" + , "invoice" + , "is" + , "isatap" + , "issues" + , "it" + , "jobs" + , "join" + , "js" + , "json" + , "keybase.txt" + , "learn" + , "legal" + , "license" + , "licensing" + , "like" + , "limit" + , "live" + , "load" + , "local" + , "localdomain" + , "localhost" + , "lock" + , "login" + , "logout" + , "lost-password" + , "m" + , "mail" + , "mail0" + , "mail1" + , "mail2" + , "mail3" + , "mail4" + , "mail5" + , "mail6" + , "mail7" + , "mail8" + , "mail9" + , "mailer-daemon" + , "mailerdaemon" + , "map" + , "marketing" + , "marketplace" + , "master" + , "me" + , "media" + , "member" + , "members" + , "message" + , "messages" + , "metrics" + , "mis" + , "mobile" + , "moderator" + , "modify" + , "more" + , "mx" + , "mx1" + , "my" + , "net" + , "network" + , "new" + , "news" + , "newsletter" + , "newsletters" + , "next" + , "nil" + , "no-reply" + , "nobody" + , "noc" + , "none" + , "noreply" + , "notification" + , "notifications" + , "ns" + , "ns0" + , "ns1" + , "ns2" + , "ns3" + , "ns4" + , "ns5" + , "ns6" + , "ns7" + , "ns8" + , "ns9" + , "null" + , "oauth" + , "oauth2" + , "offer" + , "offers" + , "online" + , "openid" + , "order" + , "orders" + , "overview" + , "owa" + , "owner" + , "page" + , "pages" + , "partners" + , "passwd" + , "password" + , "pay" + , "payment" + , "payments" + , "photo" + , "photos" + , "pixel" + , "plans" + , "plugins" + , "policies" + , "policy" + , "pop" + , "pop3" + , "popular" + , "portal" + , "portfolio" + , "post" + , "postfix" + , "postmaster" + , "poweruser" + , "preferences" + , "premium" + , "press" + , "previous" + , "pricing" + , "print" + , "privacy" + , "privacy-policy" + , "private" + , "prod" + , "product" + , "production" + , "profile" + , "profiles" + , "project" + , "projects" + , "public" + , "purchase" + , "put" + , "quota" + , "recover" + , "recovery" + , "redirect" + , "reduce" + , "refund" + , "refunds" + , "register" + , "registration" + , "remove" + , "replies" + , "reply" + , "report" + , "request" + , "request-password" + , "reset" + , "reset-password" + , "response" + , "return" + , "returns" + , "review" + , "reviews" + , "robots.txt" + , "root" + , "rootuser" + , "rsa-sha2-2" + , "rsa-sha2-512" + , "rss" + , "rules" + , "sales" + , "save" + , "script" + , "sdk" + , "search" + , "secure" + , "security" + , "select" + , "services" + , "session" + , "sessions" + , "settings" + , "setup" + , "share" + , "shift" + , "shop" + , "signin" + , "signup" + , "site" + , "sitemap" + , "sites" + , "smtp" + , "sort" + , "source" + , "sql" + , "ssh" + , "ssh-rsa" + , "ssl" + , "ssladmin" + , "ssladministrator" + , "sslwebmaster" + , "stage" + , "staging" + , "stat" + , "static" + , "statistics" + , "stats" + , "status" + , "store" + , "style" + , "styles" + , "stylesheet" + , "stylesheets" + , "subdomain" + , "subscribe" + , "sudo" + , "super" + , "superuser" + , "support" + , "survey" + , "sync" + , "sysadmin" + , "system" + , "tablet" + , "tag" + , "tags" + , "team" + , "telnet" + , "terms" + , "terms-of-use" + , "test" + , "testimonials" + , "theme" + , "themes" + , "today" + , "tools" + , "topic" + , "topics" + , "tour" + , "training" + , "translate" + , "translations" + , "trending" + , "trial" + , "true" + , "umac-128" + , "umac-128-etm" + , "umac-64" + , "umac-64-etm" + , "undefined" + , "unfollow" + , "unlike" + , "unsubscribe" + , "update" + , "upgrade" + , "usenet" + , "user" + , "username" + , "users" + , "uucp" + , "var" + , "verify" + , "video" + , "view" + , "void" + , "vote" + , "vpn" + , "webmail" + , "webmaster" + , "website" + , "widget" + , "widgets" + , "wiki" + , "wpad" + , "write" + , "www" + , "www-data" + , "www1" + , "www2" + , "www3" + , "www4" + , "you" + , "yourname" + , "yourusername" + , "zlib" + ] diff --git a/src/user/identity.ts b/src/user/identity.ts new file mode 100644 index 000000000..8c7f24ed1 --- /dev/null +++ b/src/user/identity.ts @@ -0,0 +1,85 @@ +import * as base58 from 'base58-universal/main.js' +import { CryptoSystem } from 'keystore-idb/types' +import utils from 'keystore-idb/utils' + +import { base64UrlEncode, makeBase64UrlSafe } from '../common' +import { getKeystore } from '../keystore' + + +const EDW_DID_PREFIX: ArrayBuffer = new Uint8Array([ 0xed, 0x01 ]).buffer +const RSA_DID_PREFIX: ArrayBuffer = new Uint8Array([ 0x00, 0xf5, 0x02 ]).buffer + + +/** + * Create a DID key to authenticate with and wrap it in a JWT. + */ +export const didJWT = async () => { + const ks = await getKeystore() + + // Parts + const header = { + alg: jwtAlgorithm(ks.cfg.type) || 'unknownAlgorithm', + typ: 'JWT' + } + + const payload = { + iss: await didKey(), + exp: Math.floor((Date.now() + 30 * 1000) / 1000), // JWT expires in 30 seconds + } + + // Encode parts in JSON & Base64Url + const encodedHeader = base64UrlEncode(JSON.stringify(header)) + const encodedPayload = base64UrlEncode(JSON.stringify(payload)) + + // Signature + const signed = await ks.sign(`${encodedHeader}.${encodedPayload}`, { charSize: 8 }) + const encodedSignature = makeBase64UrlSafe(signed) + + // Make JWT + return encodedHeader + '.' + + encodedPayload + '.' + + encodedSignature +} + +/** + * Create a DID key to authenticate with. + */ +export const didKey = async () => { + const ks = await getKeystore() + + // Public-write key + const pwB64 = await ks.publicWriteKey() + const pwBuf = utils.base64ToArrBuf(pwB64) + + // Prefix public-write key + const prefix = magicBytes(ks.cfg.type) || new ArrayBuffer(0) + const prefixedBuf = utils.joinBufs(prefix, pwBuf) + + // Encode prefixed + return 'did:key:z' + base58.encode(new Uint8Array(prefixedBuf)) +} + + +// 🧙 + + +/** + * JWT algorithm to be used in a JWT header. + */ +function jwtAlgorithm(cryptoSystem: CryptoSystem): string | null { + switch (cryptoSystem) { + case CryptoSystem.RSA: return 'RS256'; + default: return null + } +} + + +/** + * Magic bytes + */ +function magicBytes(cryptoSystem: CryptoSystem): ArrayBuffer | null { + switch (cryptoSystem) { + case CryptoSystem.RSA: return RSA_DID_PREFIX; + default: return null + } +} diff --git a/src/user/index.ts b/src/user/index.ts index 9f0a6cd25..4985b2b68 100644 --- a/src/user/index.ts +++ b/src/user/index.ts @@ -1,14 +1,90 @@ +import type { UserProperties } from './types' + +import { API_ENDPOINT } from '../common' +import { FileSystem } from '../ffs/ffs' + +import { USERNAME_BLACKLIST } from './blacklist' +import { didJWT, didKey } from './identity' + import ipfs, { CID } from '../ipfs' -export const fileRoot = async(username: string): Promise => { + +/** + * Create a user account. + */ +export const createAccount = async ( + userProps: UserProperties, + apiEndpoint: string = API_ENDPOINT +): Promise => { + return fetch(`${apiEndpoint}/user`, { + method: 'PUT', + headers: { + 'authorization': `Bearer ${await didJWT()}`, + 'content-type': 'application/json' + }, + body: JSON.stringify(userProps) + }) +} + +/** + * Get the CID of a user's data root. + */ +export const fileRoot = async (username: string): Promise => { try { - const result = await ipfs.dns(`files.${username}.fission.name`) + // TODO: This'll be `files.${username}.fission.name` later + const result = await ipfs.dns(`${username}.fission.name`) return result.replace(/^\/ipfs\//, "") } catch(err) { throw new Error("Could not locate user root in dns") } } +/** + * Check if a username is available. + */ +export const isUsernameAvailable = async (username: string): Promise => { + try { + const resp = await fetch(`https://${username}.fission.name`, { method: "HEAD" }) + return resp.status >= 300 + } catch (_) { + return true + } +} + +/** + * Check if a username is valid. + */ +export const isUsernameValid = (username: string): boolean => { + return !username.startsWith("-") && + !username.endsWith("-") && + !!username.match(/[a-zA-Z1-9\-]+/) && + !USERNAME_BLACKLIST.includes(username) +} + +/** + * Update a user's data root. + */ +export const updateRoot = async ( + ffs: FileSystem, + apiEndpoint: string = API_ENDPOINT +): Promise => { + const cid = await ffs.sync().toString() + + return fetch(`${apiEndpoint}/user/data/${cid}`, { + method: 'PATCH', + headers: { + 'authorization': `Bearer ${await didJWT()}` + } + }) +} + + export default { - fileRoot + createAccount, + didJWT, + didKey, + fileRoot, + isUsernameAvailable, + isUsernameValid, + updateRoot } diff --git a/src/user/types.ts b/src/user/types.ts new file mode 100644 index 000000000..a072aa29b --- /dev/null +++ b/src/user/types.ts @@ -0,0 +1,4 @@ +export type UserProperties = { + email: string, + username: string +} diff --git a/yarn.lock b/yarn.lock index a13651e69..e15722f79 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1415,6 +1415,13 @@ base-x@^3.0.2: dependencies: safe-buffer "^5.0.1" +base58-universal@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/base58-universal/-/base58-universal-1.0.0.tgz#6da5b89c2c9be8fc40bcf6b3a5a03f123ad3825e" + integrity sha512-v0Ja4jwaQP8gBZPNXpfaXlLht2ed/Gp3AsVUZXtlZgY1qbKS0CjxvYs43U0Gh00zbVc1neMe+q/ULJ7ubVyB+w== + dependencies: + esm "^3.2.25" + base64-js@^1.0.2: version "1.3.1" resolved "https://registry.yarnpkg.com/base64-js/-/base64-js-1.3.1.tgz#58ece8cb75dd07e71ed08c736abc5fac4dbf8df1" @@ -2249,6 +2256,11 @@ eslint@^6.8.0: text-table "^0.2.0" v8-compile-cache "^2.0.3" +esm@^3.2.25: + version "3.2.25" + resolved "https://registry.yarnpkg.com/esm/-/esm-3.2.25.tgz#342c18c29d56157688ba5ce31f8431fbb795cc10" + integrity sha512-U1suiZ2oDVWv4zPO56S0NcR5QriEahGtdN2OR6FiOG4WJvcjBVFB0qI4+eKoWFH483PKGuLuu6V8Z4T5g63UVA== + espree@^6.1.2: version "6.2.0" resolved "https://registry.yarnpkg.com/espree/-/espree-6.2.0.tgz#349fef01a202bbab047748300deb37fa44da79d7" @@ -3739,10 +3751,10 @@ jsprim@^1.2.2: json-schema "0.2.3" verror "1.10.0" -keystore-idb@^0.8.0: - version "0.8.0" - resolved "https://registry.yarnpkg.com/keystore-idb/-/keystore-idb-0.8.0.tgz#012afc0f20c2419b08ad9c517c4fc33393d76c4f" - integrity sha512-6jsDL8j7LnDfvMSdKrfhxt+ziE6RrXkY/ftfXkOqV4u/chvDN6qRLFkstVVRvU9ulCQ8XAGaW90nhtYBM0VouA== +keystore-idb@^0.11.1: + version "0.11.1" + resolved "https://registry.yarnpkg.com/keystore-idb/-/keystore-idb-0.11.1.tgz#375169c932ddd74cbbc8aef62cd565eb0827d0e6" + integrity sha512-1+0mkbriz9591KvYK3ZJ62m7HFmIhmboSxZLKOvcolspFc6F274tIMZ6oUx7iISVIBfShQqbANPZceZARV8Kzw== dependencies: localforage "^1.7.3"