diff --git a/.vscode/settings.json b/.vscode/settings.json index c431e0a23..34fc55e9b 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,7 +1,7 @@ { "editor.formatOnSave": true, "editor.codeActionsOnSave": { - "source.fixAll.eslint": true + "source.fixAll.eslint": "explicit" }, "[typescript]": { "editor.defaultFormatter": "esbenp.prettier-vscode" diff --git a/README.md b/README.md index 708f08cc0..46675d946 100644 --- a/README.md +++ b/README.md @@ -253,6 +253,18 @@ const isOnProdNetwork = await Client.canMessage( ) ``` +### Request and respect user consent + +![Feature status](https://img.shields.io/badge/Feature_status-Alpha-orange) + +The user consent feature enables your app to request and respect user consent preferences. With this feature, another blockchain account address registered on the XMTP network can have one of three consent preference values: + +- Unknown +- Allowed +- Denied + +To learn more, see [Request and respect user consent](https://xmtp.org/docs/build/user-consent). + ### Send a broadcast message You can send a broadcast message (1:many message or announcement) with XMTP. The recipient sees the message as a DM from the sending wallet address. diff --git a/build/esbuild-plugin-resolve-extensions/index.ts b/build/esbuild-plugin-resolve-extensions/index.ts new file mode 100644 index 000000000..5613e093c --- /dev/null +++ b/build/esbuild-plugin-resolve-extensions/index.ts @@ -0,0 +1,58 @@ +import { basename, dirname, extname, format } from 'node:path' +import { existsSync } from 'node:fs' +import { getResolvedPath, loadCompilerOptions } from './utils' +import { Plugin } from 'esbuild' + +type ResolveExtensionsPluginOptions = { + extensions?: string[] + tsconfigPath?: string +} + +export const resolveExtensionsPlugin = ( + options?: ResolveExtensionsPluginOptions +): Plugin => { + const { tsconfigPath, extensions = [] } = options ?? {} + const compilerOptions = loadCompilerOptions(tsconfigPath) + return { + name: 'resolve-extensions', + setup({ onResolve }) { + onResolve({ filter: /.*/ }, async ({ kind, importer, path }) => { + let result: null | { path: string } = null + switch (kind) { + case 'import-statement': + case 'require-call': + case 'dynamic-import': + case 'require-resolve': + { + const resolvedPath = getResolvedPath( + path, + importer, + compilerOptions + ) + if (resolvedPath) { + const ext = extname(resolvedPath) + const base = basename(resolvedPath, ext) + const dir = dirname(resolvedPath) + // check for extensions + extensions.some((extension) => { + const newPath = format({ + dir, + name: base, + ext: `${extension}${ext}`, + }) + const exists = existsSync(newPath) + if (exists) { + result = { path: newPath } + return true + } + return false + }) + } + } + break + } + return result + }) + }, + } +} diff --git a/build/esbuild-plugin-resolve-extensions/utils.ts b/build/esbuild-plugin-resolve-extensions/utils.ts new file mode 100644 index 000000000..e8cc4d171 --- /dev/null +++ b/build/esbuild-plugin-resolve-extensions/utils.ts @@ -0,0 +1,48 @@ +import { readFileSync, existsSync } from 'node:fs' +import type { CompilerOptions } from 'typescript' +import ts from 'typescript' +import { findUpSync } from 'find-up' + +const { nodeModuleNameResolver, sys } = ts + +type TSConfig = { + compilerOptions?: CompilerOptions +} + +const loadJSON = (jsonPath: string) => + JSON.parse(readFileSync(jsonPath, 'utf8')) as TSConfig + +export const loadCompilerOptions = (tsconfigPath?: string) => { + let config: TSConfig = {} + if (!tsconfigPath) { + const configPath = findUpSync('tsconfig.json') + if (configPath) { + config = loadJSON(configPath) + } + } else { + if (existsSync(tsconfigPath)) { + config = loadJSON(tsconfigPath) + } + } + return config?.compilerOptions ?? {} +} + +export const getResolvedPath = ( + path: string, + importer: string, + compilerOptions: CompilerOptions +) => { + const { resolvedModule } = nodeModuleNameResolver( + path, + importer, + compilerOptions, + sys + ) + + const resolvedFileName = resolvedModule?.resolvedFileName + if (!resolvedFileName || resolvedFileName.endsWith('.d.ts')) { + return null + } + + return sys.resolvePath(resolvedFileName) +} diff --git a/package-lock.json b/package-lock.json index 4f00959e3..f705d7510 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,7 +10,8 @@ "license": "MIT", "dependencies": { "@noble/secp256k1": "^1.5.2", - "@xmtp/proto": "^3.29.0", + "@xmtp/proto": "^3.34.0", + "@xmtp/user-preferences-bindings-wasm": "^0.3.4", "async-mutex": "^0.4.0", "elliptic": "^6.5.4", "ethers": "^5.5.3", @@ -31,7 +32,6 @@ "benny": "^3.7.1", "dd-trace": "^2.12.2", "esbuild": "^0.17.16", - "esbuild-plugin-external-global": "^1.0.1", "eslint": "^8.50.0", "eslint-config-prettier": "^9.0.0", "eslint-config-standard": "^17.1.0", @@ -40,6 +40,7 @@ "eslint-plugin-node": "^11.1.0", "eslint-plugin-prettier": "^5.0.0", "eslint-plugin-promise": "^6.1.1", + "find-up": "^7.0.0", "husky": "^7.0.4", "jest": "^29.6.0", "jest-environment-jsdom": "^28.1.3", @@ -2170,6 +2171,58 @@ "node": ">=8" } }, + "node_modules/@istanbuljs/load-nyc-config/node_modules/find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "dev": true, + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@istanbuljs/load-nyc-config/node_modules/locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "dev": true, + "dependencies": { + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@istanbuljs/load-nyc-config/node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "dev": true, + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@istanbuljs/load-nyc-config/node_modules/p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "dev": true, + "dependencies": { + "p-limit": "^2.2.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/@istanbuljs/schema": { "version": "0.1.3", "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.3.tgz", @@ -4219,51 +4272,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/@semantic-release/release-notes-generator/node_modules/locate-path": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-7.2.0.tgz", - "integrity": "sha512-gvVijfZvn7R+2qyPX8mAuKcFGDf6Nc61GdvGafQsHL0sBIxfKzA+usWn4GFC/bk+QdwPUD4kWFJLhElipq+0VA==", - "dev": true, - "dependencies": { - "p-locate": "^6.0.0" - }, - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/@semantic-release/release-notes-generator/node_modules/p-limit": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-4.0.0.tgz", - "integrity": "sha512-5b0R4txpzjPWVw/cXXUResoD4hb6U/x9BH08L7nw+GN1sezDzPdxeRvpc9c433fZhBan/wusjbCsqwqm4EIBIQ==", - "dev": true, - "dependencies": { - "yocto-queue": "^1.0.0" - }, - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/@semantic-release/release-notes-generator/node_modules/p-locate": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-6.0.0.tgz", - "integrity": "sha512-wPrq66Llhl7/4AGC6I+cqxT07LhXvWL08LNXz1fENOw0Ap4sRZZ/gZpTTJ5jpurzzzfS2W/Ge9BY3LgLjCShcw==", - "dev": true, - "dependencies": { - "p-limit": "^4.0.0" - }, - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/@semantic-release/release-notes-generator/node_modules/path-exists": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-5.0.0.tgz", @@ -4320,18 +4328,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/@semantic-release/release-notes-generator/node_modules/yocto-queue": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-1.0.0.tgz", - "integrity": "sha512-9bnSc/HEW2uRy67wc+T8UwauLuPJVn28jb+GtJY16iiKWyvmYJRXVT4UamsAEGQfPohgr2q4Tq0sQbQlxTfi1g==", - "dev": true, - "engines": { - "node": ">=12.20" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/@sinclair/typebox": { "version": "0.24.51", "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.24.51.tgz", @@ -4820,9 +4816,9 @@ } }, "node_modules/@xmtp/proto": { - "version": "3.29.0", - "resolved": "https://registry.npmjs.org/@xmtp/proto/-/proto-3.29.0.tgz", - "integrity": "sha512-+ibo+u6NwdzfLN3QEDMiNrnXd7eT1/+F2j5WWz3b4mk91wgn8lJ66fxFPwLTQs6AbaBBUmhO2cdpgIL/g4kvZg==", + "version": "3.34.0", + "resolved": "https://registry.npmjs.org/@xmtp/proto/-/proto-3.34.0.tgz", + "integrity": "sha512-UJ0doz01peGEi5+fJ6th6JsUXFLMHaVk9L9avrv7L7FhfmAzM/V5iqHao8YpIKyMrJ7BelsGrbDcTfe28SsxFg==", "dependencies": { "long": "^5.2.0", "protobufjs": "^7.0.0", @@ -4830,6 +4826,11 @@ "undici": "^5.8.1" } }, + "node_modules/@xmtp/user-preferences-bindings-wasm": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/@xmtp/user-preferences-bindings-wasm/-/user-preferences-bindings-wasm-0.3.4.tgz", + "integrity": "sha512-4d0j8QDZT8Z9DXIjxRJh7M1DjLNWcPV6807eeMN79gwF9SWbR1CXGSnBNqSrOgVu9nQSWqtg6qfyrrlQ3yHybA==" + }, "node_modules/abab": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/abab/-/abab-2.0.6.tgz", @@ -6922,12 +6923,6 @@ "@esbuild/win32-x64": "0.17.16" } }, - "node_modules/esbuild-plugin-external-global": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/esbuild-plugin-external-global/-/esbuild-plugin-external-global-1.0.1.tgz", - "integrity": "sha512-NDzYHRoShpvLqNcrgV8ZQh61sMIFAry5KLTQV83BPG5iTXCCu7h72SCfJ97bW0GqtuqDD/1aqLbKinI/rNgUsg==", - "dev": true - }, "node_modules/escalade": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.1.1.tgz", @@ -7902,16 +7897,29 @@ } }, "node_modules/find-up": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", - "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-7.0.0.tgz", + "integrity": "sha512-YyZM99iHrqLKjmt4LJDj58KI+fYyufRLBSYcqycxf//KpBk9FoewoGX0450m9nB44qrZnovzC2oeP5hUibxc/g==", "dev": true, "dependencies": { - "locate-path": "^5.0.0", - "path-exists": "^4.0.0" + "locate-path": "^7.2.0", + "path-exists": "^5.0.0", + "unicorn-magic": "^0.1.0" }, "engines": { - "node": ">=8" + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/find-up/node_modules/path-exists": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-5.0.0.tgz", + "integrity": "sha512-RjhtfwJOxzcFmNOi6ltcbcu4Iu+FL3zEj83dk4kAS+fVpTxXLO1b38RvJgT/0QwvV/L3aY9TAnyv0EOqW4GoMQ==", + "dev": true, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" } }, "node_modules/find-versions": { @@ -11872,15 +11880,18 @@ } }, "node_modules/locate-path": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", - "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-7.2.0.tgz", + "integrity": "sha512-gvVijfZvn7R+2qyPX8mAuKcFGDf6Nc61GdvGafQsHL0sBIxfKzA+usWn4GFC/bk+QdwPUD4kWFJLhElipq+0VA==", "dev": true, "dependencies": { - "p-locate": "^4.1.0" + "p-locate": "^6.0.0" }, "engines": { - "node": ">=8" + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/lodash": { @@ -15873,30 +15884,45 @@ } }, "node_modules/p-limit": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", - "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-4.0.0.tgz", + "integrity": "sha512-5b0R4txpzjPWVw/cXXUResoD4hb6U/x9BH08L7nw+GN1sezDzPdxeRvpc9c433fZhBan/wusjbCsqwqm4EIBIQ==", "dev": true, "dependencies": { - "p-try": "^2.0.0" + "yocto-queue": "^1.0.0" }, "engines": { - "node": ">=6" + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-limit/node_modules/yocto-queue": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-1.0.0.tgz", + "integrity": "sha512-9bnSc/HEW2uRy67wc+T8UwauLuPJVn28jb+GtJY16iiKWyvmYJRXVT4UamsAEGQfPohgr2q4Tq0sQbQlxTfi1g==", + "dev": true, + "engines": { + "node": ">=12.20" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/p-locate": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", - "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-6.0.0.tgz", + "integrity": "sha512-wPrq66Llhl7/4AGC6I+cqxT07LhXvWL08LNXz1fENOw0Ap4sRZZ/gZpTTJ5jpurzzzfS2W/Ge9BY3LgLjCShcw==", "dev": true, "dependencies": { - "p-limit": "^2.2.0" + "p-limit": "^4.0.0" }, "engines": { - "node": ">=8" + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/p-map": { @@ -16172,6 +16198,58 @@ "node": ">=8" } }, + "node_modules/pkg-dir/node_modules/find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "dev": true, + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/pkg-dir/node_modules/locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "dev": true, + "dependencies": { + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/pkg-dir/node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "dev": true, + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/pkg-dir/node_modules/p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "dev": true, + "dependencies": { + "p-limit": "^2.2.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/platform": { "version": "1.3.6", "resolved": "https://registry.npmjs.org/platform/-/platform-1.3.6.tgz", @@ -16472,6 +16550,58 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/read-pkg-up/node_modules/find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "dev": true, + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/read-pkg-up/node_modules/locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "dev": true, + "dependencies": { + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/read-pkg-up/node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "dev": true, + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/read-pkg-up/node_modules/p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "dev": true, + "dependencies": { + "p-limit": "^2.2.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/read-pkg-up/node_modules/type-fest": { "version": "0.8.1", "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.8.1.tgz", @@ -17086,21 +17216,6 @@ "js-yaml": "bin/js-yaml.js" } }, - "node_modules/semantic-release/node_modules/locate-path": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-7.2.0.tgz", - "integrity": "sha512-gvVijfZvn7R+2qyPX8mAuKcFGDf6Nc61GdvGafQsHL0sBIxfKzA+usWn4GFC/bk+QdwPUD4kWFJLhElipq+0VA==", - "dev": true, - "dependencies": { - "p-locate": "^6.0.0" - }, - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/semantic-release/node_modules/lru-cache": { "version": "7.18.3", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-7.18.3.tgz", @@ -17152,36 +17267,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/semantic-release/node_modules/p-limit": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-4.0.0.tgz", - "integrity": "sha512-5b0R4txpzjPWVw/cXXUResoD4hb6U/x9BH08L7nw+GN1sezDzPdxeRvpc9c433fZhBan/wusjbCsqwqm4EIBIQ==", - "dev": true, - "dependencies": { - "yocto-queue": "^1.0.0" - }, - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/semantic-release/node_modules/p-locate": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-6.0.0.tgz", - "integrity": "sha512-wPrq66Llhl7/4AGC6I+cqxT07LhXvWL08LNXz1fENOw0Ap4sRZZ/gZpTTJ5jpurzzzfS2W/Ge9BY3LgLjCShcw==", - "dev": true, - "dependencies": { - "p-limit": "^4.0.0" - }, - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/semantic-release/node_modules/path-exists": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-5.0.0.tgz", @@ -17262,18 +17347,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/semantic-release/node_modules/yocto-queue": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-1.0.0.tgz", - "integrity": "sha512-9bnSc/HEW2uRy67wc+T8UwauLuPJVn28jb+GtJY16iiKWyvmYJRXVT4UamsAEGQfPohgr2q4Tq0sQbQlxTfi1g==", - "dev": true, - "engines": { - "node": ">=12.20" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/semver": { "version": "7.5.4", "resolved": "https://registry.npmjs.org/semver/-/semver-7.5.4.tgz", @@ -18601,6 +18674,18 @@ "node": ">=14.0" } }, + "node_modules/unicorn-magic": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/unicorn-magic/-/unicorn-magic-0.1.0.tgz", + "integrity": "sha512-lRfVq8fE8gz6QMBuDM6a+LO3IAzTi05H6gCVaUpir2E1Rwpo4ZUog45KpNXKC/Mn3Yb9UDuHumeFTo9iV/D9FQ==", + "dev": true, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/unique-string": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/unique-string/-/unique-string-3.0.0.tgz", diff --git a/package.json b/package.json index 1485823f8..79d096d53 100644 --- a/package.json +++ b/package.json @@ -13,15 +13,37 @@ "browser": "./dist/web/index.js", "import": "./dist/index.js", "require": "./dist/index.cjs" + }, + "./node": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js", + "require": "./dist/index.cjs" + }, + "./node/esm": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + }, + "./node/cjs": { + "types": "./dist/index.d.ts", + "default": "./dist/index.cjs" + }, + "./browser": { + "types": "./dist/index.d.ts", + "default": "./dist/web/index.js" + }, + "./browser/bundler": { + "types": "./dist/index.d.ts", + "default": "./dist/bundler/index.js" } }, "scripts": { "prebench": "npm run build:bench", "bench": "node dist/bench/index.cjs", - "build": "npm run clean:dist && npm run build:node && npm run build:web", + "build": "npm run clean:dist && npm run build:node && npm run build:web && npm run build:bundler", "build:bench": "tsup --out-dir dist/bench --entry.0 bench/index.ts --format cjs", "build:node": "tsup", "build:web": "tsup --platform browser --target esnext", + "build:bundler": "tsup --config tsup.bundler.config.ts", "build:docs": "rimraf docs && mkdir -p tmp && cp README.md tmp/ && sed -i.bak '/badge.svg/d' tmp/README.md && typedoc --excludePrivate --readme tmp/README.md src/index.ts", "clean": "npm run clean:dist && npm run clean:proto", "clean:dist": "rimraf dist", @@ -84,7 +106,8 @@ }, "dependencies": { "@noble/secp256k1": "^1.5.2", - "@xmtp/proto": "^3.29.0", + "@xmtp/proto": "^3.34.0", + "@xmtp/user-preferences-bindings-wasm": "^0.3.4", "async-mutex": "^0.4.0", "elliptic": "^6.5.4", "ethers": "^5.5.3", @@ -105,7 +128,6 @@ "benny": "^3.7.1", "dd-trace": "^2.12.2", "esbuild": "^0.17.16", - "esbuild-plugin-external-global": "^1.0.1", "eslint": "^8.50.0", "eslint-config-prettier": "^9.0.0", "eslint-config-standard": "^17.1.0", @@ -114,6 +136,7 @@ "eslint-plugin-node": "^11.1.0", "eslint-plugin-prettier": "^5.0.0", "eslint-plugin-promise": "^6.1.1", + "find-up": "^7.0.0", "husky": "^7.0.4", "jest": "^29.6.0", "jest-environment-jsdom": "^28.1.3", diff --git a/src/Client.ts b/src/Client.ts index 50b233abf..ca6611753 100644 --- a/src/Client.ts +++ b/src/Client.ts @@ -7,6 +7,8 @@ import { buildUserInviteTopic, isBrowser, getSigner, + EnvelopeMapperWithMessage, + EnvelopeWithMessage, } from './utils' import { utils } from 'ethers' import { Signer } from './types/Signer' @@ -44,6 +46,7 @@ import { hasMetamaskWithSnaps } from './keystore/snapHelpers' import { version as snapVersion, package as snapPackage } from './snapInfo.json' import { ExtractDecodedType } from './types/client' import type { WalletClient } from 'viem' +import { Contacts } from './Contacts' const { Compression } = proto // eslint-disable @typescript-eslint/explicit-module-boundary-types @@ -251,7 +254,7 @@ export default class Client { address: string keystore: Keystore apiClient: ApiClient - contacts: Set // address which we have connected to + contacts: Contacts publicKeyBundle: PublicKeyBundle private knownPublicKeyBundles: Map< string, @@ -270,7 +273,6 @@ export default class Client { backupClient: BackupClient, keystore: Keystore ) { - this.contacts = new Set() this.knownPublicKeyBundles = new Map< string, PublicKeyBundle | SignedPublicKeyBundle @@ -284,6 +286,7 @@ export default class Client { this._maxContentSize = MaxContentSize this.apiClient = apiClient this._backupClient = backupClient + this.contacts = new Contacts(this) } /** @@ -701,7 +704,7 @@ export default class Client { */ async listEnvelopes( topic: string, - mapper: EnvelopeMapper, + mapper: EnvelopeMapperWithMessage, opts?: ListMessagesOptions ): Promise { if (!opts) { @@ -721,7 +724,7 @@ export default class Client { for (const env of envelopes) { if (!env.message) continue try { - const res = await mapper(env) + const res = await mapper(env as EnvelopeWithMessage) results.push(res) } catch (e) { console.warn('Error in listEnvelopes mapper', e) diff --git a/src/Contacts.ts b/src/Contacts.ts new file mode 100644 index 000000000..33d9e8144 --- /dev/null +++ b/src/Contacts.ts @@ -0,0 +1,324 @@ +import Client from './Client' +import { privatePreferences } from '@xmtp/proto' +import { + EnvelopeWithMessage, + buildUserPrivatePreferencesTopic, + fromNanoString, +} from './utils' +import Stream from './Stream' +import { OnConnectionLostCallback } from './ApiClient' +import JobRunner from './conversations/JobRunner' + +export type ConsentState = 'allowed' | 'denied' | 'unknown' + +export type ConsentListEntryType = 'address' + +export type PrivatePreferencesAction = + privatePreferences.PrivatePreferencesAction + +export class ConsentListEntry { + value: string + entryType: ConsentListEntryType + permissionType: ConsentState + + constructor( + value: string, + entryType: ConsentListEntryType, + permissionType: ConsentState + ) { + this.value = value + this.entryType = entryType + this.permissionType = permissionType + } + + get key(): string { + return `${this.entryType}-${this.value}` + } + + static fromAddress( + address: string, + permissionType: ConsentState = 'unknown' + ): ConsentListEntry { + return new ConsentListEntry(address, 'address', permissionType) + } +} + +export class ConsentList { + client: Client + entries: Map + lastEntryTimestamp?: Date + private _identifier: string | undefined + + constructor(client: Client) { + this.entries = new Map() + this.client = client + } + + allow(address: string) { + const entry = ConsentListEntry.fromAddress(address, 'allowed') + this.entries.set(entry.key, 'allowed') + return entry + } + + deny(address: string) { + const entry = ConsentListEntry.fromAddress(address, 'denied') + this.entries.set(entry.key, 'denied') + return entry + } + + state(address: string) { + const entry = ConsentListEntry.fromAddress(address) + return this.entries.get(entry.key) ?? 'unknown' + } + + async getIdentifier(): Promise { + if (!this._identifier) { + const { identifier } = + await this.client.keystore.getPrivatePreferencesTopicIdentifier() + this._identifier = identifier + } + return this._identifier + } + + async decodeMessages(messages: Uint8Array[]) { + // decrypt messages + const { responses } = await this.client.keystore.selfDecrypt({ + requests: messages.map((message) => ({ payload: message })), + }) + + // decoded actions + const actions = responses.reduce((result, response) => { + return response.result?.decrypted + ? result.concat( + privatePreferences.PrivatePreferencesAction.decode( + response.result.decrypted + ) + ) + : result + }, [] as PrivatePreferencesAction[]) + + return actions + } + + processActions( + actions: privatePreferences.PrivatePreferencesAction[], + lastTimestampNs?: string + ) { + const entries: ConsentListEntry[] = [] + actions.forEach((action) => { + action.allow?.walletAddresses.forEach((address) => { + entries.push(this.allow(address)) + }) + action.block?.walletAddresses.forEach((address) => { + entries.push(this.deny(address)) + }) + }) + + if (lastTimestampNs) { + this.lastEntryTimestamp = fromNanoString(lastTimestampNs) + } + + return entries + } + + async stream(onConnectionLost?: OnConnectionLostCallback) { + const identifier = await this.getIdentifier() + const contentTopic = buildUserPrivatePreferencesTopic(identifier) + + return Stream.create( + this.client, + [contentTopic], + async (envelope) => { + if (!envelope.message) { + return undefined + } + const actions = await this.decodeMessages([envelope.message]) + + // update consent list + this.processActions(actions, envelope.timestampNs) + + return actions[0] + }, + undefined, + onConnectionLost + ) + } + + reset() { + // clear existing entries + this.entries.clear() + } + + async load(startTime?: Date) { + const identifier = await this.getIdentifier() + const contentTopic = buildUserPrivatePreferencesTopic(identifier) + + let lastTimestampNs: string | undefined + + const messages = await this.client.listEnvelopes( + contentTopic, + async ({ message, timestampNs }: EnvelopeWithMessage) => { + if (timestampNs) { + lastTimestampNs = timestampNs + } + return message + }, + { + startTime, + } + ) + + const actions = await this.decodeMessages(messages) + + // update consent list + return this.processActions(actions, lastTimestampNs) + } + + async publish(entries: ConsentListEntry[]) { + const identifier = await this.getIdentifier() + + // encoded actions + const actions = entries.reduce((result, entry) => { + // only handle address entries for now + if (entry.entryType === 'address') { + const action: PrivatePreferencesAction = { + allow: + entry.permissionType === 'allowed' + ? { + walletAddresses: [entry.value], + } + : undefined, + block: + entry.permissionType === 'denied' + ? { + walletAddresses: [entry.value], + } + : undefined, + } + return result.concat( + privatePreferences.PrivatePreferencesAction.encode(action).finish() + ) + } + return result + }, [] as Uint8Array[]) + + const { responses } = await this.client.keystore.selfEncrypt({ + requests: actions.map((action) => ({ payload: action })), + }) + + // encrypted messages + const messages = responses.reduce((result, response) => { + return response.result?.encrypted + ? result.concat(response.result?.encrypted) + : result + }, [] as Uint8Array[]) + + const contentTopic = buildUserPrivatePreferencesTopic(identifier) + const timestamp = new Date() + + // envelopes to publish + const envelopes = messages.map((message) => ({ + contentTopic, + message, + timestamp, + })) + + // publish entries + await this.client.publishEnvelopes(envelopes) + + // update local entries after publishing + entries.forEach((entry) => { + this.entries.set(entry.key, entry.permissionType) + }) + } +} + +export class Contacts { + /** + * Addresses that the client has connected to + */ + addresses: Set + /** + * XMTP client + */ + client: Client + private consentList: ConsentList + private jobRunner: JobRunner + + constructor(client: Client) { + this.addresses = new Set() + this.consentList = new ConsentList(client) + this.client = client + this.jobRunner = new JobRunner('user-preferences', client.keystore) + } + + async loadConsentList(startTime?: Date) { + return this.jobRunner.run(async (lastRun) => { + // allow for override of startTime + return this.consentList.load(startTime ?? lastRun) + }) + } + + async refreshConsentList() { + // clear existing consent list + this.consentList.reset() + // reset last run time to the epoch + await this.jobRunner.resetLastRunTime() + // reload the consent list + return this.loadConsentList() + } + + async streamConsentList(onConnectionLost?: OnConnectionLostCallback) { + return this.consentList.stream(onConnectionLost) + } + + /** + * The timestamp of the last entry in the consent list + */ + get lastConsentListEntryTimestamp() { + return this.consentList.lastEntryTimestamp + } + + setConsentListEntries(entries: ConsentListEntry[]) { + if (!entries.length) { + return + } + this.consentList.reset() + entries.forEach((entry) => { + if (entry.permissionType === 'allowed') { + this.consentList.allow(entry.value) + } + if (entry.permissionType === 'denied') { + this.consentList.deny(entry.value) + } + }) + } + + isAllowed(address: string) { + return this.consentList.state(address) === 'allowed' + } + + isDenied(address: string) { + return this.consentList.state(address) === 'denied' + } + + consentState(address: string) { + return this.consentList.state(address) + } + + async allow(addresses: string[]) { + await this.consentList.publish( + addresses.map((address) => + ConsentListEntry.fromAddress(address, 'allowed') + ) + ) + } + + async deny(addresses: string[]) { + await this.consentList.publish( + addresses.map((address) => + ConsentListEntry.fromAddress(address, 'denied') + ) + ) + } +} diff --git a/src/conversations/Conversation.ts b/src/conversations/Conversation.ts index 65fb7fccf..a12b4a47c 100644 --- a/src/conversations/Conversation.ts +++ b/src/conversations/Conversation.ts @@ -32,6 +32,7 @@ import { PreparedMessage } from '../PreparedMessage' import { sha256 } from '../crypto/encryption' import { buildDecryptV1Request, getResultOrThrow } from '../utils/keystore' import { ContentTypeText } from '../codecs/Text' +import { ConsentState } from '../Contacts' /** * Conversation represents either a V1 or V2 conversation with a common set of methods. @@ -66,6 +67,28 @@ export interface Conversation { */ context?: InvitationContext | undefined + /** + * Add conversation peer address to allow list + */ + allow(): Promise + /** + * Add conversation peer address to deny list + */ + deny(): Promise + + /** + * Returns true if conversation peer address is on the allow list + */ + isAllowed: boolean + /** + * Returns true if conversation peer address is on the deny list + */ + isDenied: boolean + /** + * Returns the consent state of the conversation peer address + */ + consentState: ConsentState + /** * Retrieve messages in this conversation. Default to returning all messages. * @@ -164,6 +187,26 @@ export class ConversationV1 return this.client.address } + async allow() { + await this.client.contacts.allow([this.peerAddress]) + } + + async deny() { + await this.client.contacts.deny([this.peerAddress]) + } + + get isAllowed() { + return this.client.contacts.isAllowed(this.peerAddress) + } + + get isDenied() { + return this.client.contacts.isDenied(this.peerAddress) + } + + get consentState() { + return this.client.contacts.consentState(this.peerAddress) + } + get topic(): string { return buildDirectMessageTopic(this.peerAddress, this.client.address) } @@ -237,13 +280,13 @@ export class ConversationV1 const topic = options?.ephemeral ? this.ephemeralTopic : this.topic - if (!this.client.contacts.has(this.peerAddress)) { + if (!this.client.contacts.addresses.has(this.peerAddress)) { topics = [ buildUserIntroTopic(this.peerAddress), buildUserIntroTopic(this.client.address), topic, ] - this.client.contacts.add(this.peerAddress) + this.client.contacts.addresses.add(this.peerAddress) } else { topics = [topic] } @@ -345,13 +388,13 @@ export class ConversationV1 const topic = options?.ephemeral ? this.ephemeralTopic : this.topic - if (!this.client.contacts.has(this.peerAddress)) { + if (!this.client.contacts.addresses.has(this.peerAddress)) { topics = [ buildUserIntroTopic(this.peerAddress), buildUserIntroTopic(this.client.address), topic, ] - this.client.contacts.add(this.peerAddress) + this.client.contacts.addresses.add(this.peerAddress) } else { topics = [topic] } @@ -367,6 +410,13 @@ export class ConversationV1 })) ) + // if the conversation consent state is unknown, we assume the user has + // consented to the conversation by sending a message into it + if (this.consentState === 'unknown') { + // add conversation to the allow list + await this.allow() + } + return DecodedMessage.fromV1Message( msg, content, @@ -475,6 +525,26 @@ export class ConversationV2 return this.client.address } + async allow() { + await this.client.contacts.allow([this.peerAddress]) + } + + async deny() { + await this.client.contacts.deny([this.peerAddress]) + } + + get isAllowed() { + return this.client.contacts.isAllowed(this.peerAddress) + } + + get isDenied() { + return this.client.contacts.isDenied(this.peerAddress) + } + + get consentState() { + return this.client.contacts.consentState(this.peerAddress) + } + /** * Returns a list of all messages to/from the peerAddress */ @@ -552,6 +622,13 @@ export class ConversationV2 ]) const contentType = options?.contentType || ContentTypeText + // if the conversation consent state is unknown, we assume the user has + // consented to the conversation by sending a message into it + if (this.consentState === 'unknown') { + // add conversation to the allow list + await this.allow() + } + return DecodedMessage.fromV2Message( msg, content, diff --git a/src/conversations/Conversations.ts b/src/conversations/Conversations.ts index ab1f5a4d4..a4706587f 100644 --- a/src/conversations/Conversations.ts +++ b/src/conversations/Conversations.ts @@ -20,8 +20,6 @@ import { SortDirection } from '../ApiClient' import Long from 'long' import JobRunner from './JobRunner' -const CLOCK_SKEW_OFFSET_MS = 10000 - const messageHasHeaders = (msg: MessageV1): boolean => { return Boolean(msg.recipientAddress && msg.senderAddress) } @@ -75,9 +73,7 @@ export default class Conversations { private async listV1Conversations(): Promise[]> { return this.v1JobRunner.run(async (latestSeen) => { const seenPeers = await this.getIntroductionPeers({ - startTime: latestSeen - ? new Date(+latestSeen - CLOCK_SKEW_OFFSET_MS) - : undefined, + startTime: latestSeen, direction: SortDirection.SORT_DIRECTION_ASCENDING, }) @@ -145,9 +141,7 @@ export default class Conversations { startTime?: Date ): Promise[]> { const envelopes = await this.client.listInvitations({ - startTime: startTime - ? new Date(+startTime - CLOCK_SKEW_OFFSET_MS) - : undefined, + startTime, direction: SortDirection.SORT_DIRECTION_ASCENDING, }) @@ -570,6 +564,9 @@ export default class Conversations { }, ]) + // add peer address to allow list + await this.client.contacts.allow([peerAddress]) + return this.conversationReferenceToV2(conversation) } diff --git a/src/conversations/JobRunner.ts b/src/conversations/JobRunner.ts index 00e83c83d..2a58eb76f 100644 --- a/src/conversations/JobRunner.ts +++ b/src/conversations/JobRunner.ts @@ -4,7 +4,9 @@ import { Keystore } from '../keystore' import Long from 'long' import { dateToNs, nsToDate } from '../utils' -type JobType = 'v1' | 'v2' +const CLOCK_SKEW_OFFSET_MS = 10000 + +type JobType = 'v1' | 'v2' | 'user-preferences' type UpdateJob = (lastRun: Date | undefined) => Promise @@ -12,6 +14,7 @@ export default class JobRunner { readonly jobType: JobType readonly mutex: Mutex readonly keystore: Keystore + disableOffset: boolean = false constructor(jobType: JobType, keystore: Keystore) { this.jobType = jobType @@ -27,12 +30,25 @@ export default class JobRunner { return this.mutex.runExclusive(async () => { const lastRun = await this.getLastRunTime() const startTime = new Date() - const result = await callback(lastRun) + const result = await callback( + lastRun + ? !this.disableOffset + ? new Date(lastRun.getTime() - CLOCK_SKEW_OFFSET_MS) + : lastRun + : undefined + ) await this.setLastRunTime(startTime) return result }) } + async resetLastRunTime() { + await this.keystore.setRefreshJob({ + jobType: this.protoJobType, + lastRunNs: dateToNs(new Date(0)), + }) + } + private async getLastRunTime(): Promise { const { lastRunNs } = await this.keystore.getRefreshJob( keystore.GetRefreshJobRequest.fromPartial({ @@ -53,10 +69,11 @@ export default class JobRunner { } } -function getProtoJobType(jobType: 'v1' | 'v2'): keystore.JobType { +function getProtoJobType(jobType: JobType): keystore.JobType { const protoJobType = { v1: keystore.JobType.JOB_TYPE_REFRESH_V1, v2: keystore.JobType.JOB_TYPE_REFRESH_V2, + 'user-preferences': keystore.JobType.JOB_TYPE_REFRESH_PPPP, }[jobType] if (!protoJobType) { diff --git a/src/crypto/crypto.browser.ts b/src/crypto/crypto.browser.ts new file mode 100644 index 000000000..037947be4 --- /dev/null +++ b/src/crypto/crypto.browser.ts @@ -0,0 +1,5 @@ +/*********************************************************************************************** + * DO NOT IMPORT THIS FILE DIRECTLY + ***********************************************************************************************/ +const crypto = window.crypto +export default crypto diff --git a/src/crypto/crypto.ts b/src/crypto/crypto.ts index 24b434040..862a098a1 100644 --- a/src/crypto/crypto.ts +++ b/src/crypto/crypto.ts @@ -1,16 +1,4 @@ -/** - * This file is necessary to ensure that the crypto library is available - * in node and the browser - */ - // eslint-disable-next-line no-restricted-syntax -import { webcrypto as nodeCrypto } from 'crypto' - -const webcrypto = - typeof globalThis === 'object' && 'crypto' in globalThis - ? (globalThis.crypto as nodeCrypto.Crypto) - : undefined - -const crypto = webcrypto ?? nodeCrypto - +import { webcrypto } from 'crypto' +const crypto = webcrypto export default crypto diff --git a/src/crypto/ecies.ts b/src/crypto/ecies.ts index 056d325a9..57d448f06 100644 --- a/src/crypto/ecies.ts +++ b/src/crypto/ecies.ts @@ -110,7 +110,6 @@ async function hmacSha256Verify(key: Buffer, msg: Buffer, sig: Buffer) { /** * Generate a new valid private key. Will use the window.crypto or window.msCrypto as source * depending on your browser. - * * @returns {Buffer} A 32-byte private key. * @function */ diff --git a/src/crypto/selfEncryption.browser.ts b/src/crypto/selfEncryption.browser.ts new file mode 100644 index 000000000..d4087f701 --- /dev/null +++ b/src/crypto/selfEncryption.browser.ts @@ -0,0 +1,45 @@ +/*********************************************************************************************** + * DO NOT IMPORT THIS FILE DIRECTLY + ***********************************************************************************************/ + +import init, { + // eslint-disable-next-line camelcase + generate_private_preferences_topic, + // eslint-disable-next-line camelcase + user_preferences_decrypt, + // eslint-disable-next-line camelcase + user_preferences_encrypt, +} from '@xmtp/user-preferences-bindings-wasm/web' +import { PrivateKey } from './PrivateKey' + +export async function userPreferencesEncrypt( + identityKey: PrivateKey, + payload: Uint8Array +) { + // wait for WASM to be initialized + await init() + const publicKey = identityKey.publicKey.secp256k1Uncompressed.bytes + const privateKey = identityKey.secp256k1.bytes + // eslint-disable-next-line camelcase + return user_preferences_encrypt(publicKey, privateKey, payload) +} + +export async function userPreferencesDecrypt( + identityKey: PrivateKey, + payload: Uint8Array +) { + // wait for WASM to be initialized + await init() + const publicKey = identityKey.publicKey.secp256k1Uncompressed.bytes + const privateKey = identityKey.secp256k1.bytes + // eslint-disable-next-line camelcase + return user_preferences_decrypt(publicKey, privateKey, payload) +} + +export async function generateUserPreferencesTopic(identityKey: PrivateKey) { + // wait for WASM to be initialized + await init() + const privateKey = identityKey.secp256k1.bytes + // eslint-disable-next-line camelcase + return generate_private_preferences_topic(privateKey) +} diff --git a/src/crypto/selfEncryption.bundler.ts b/src/crypto/selfEncryption.bundler.ts new file mode 100644 index 000000000..8b540532d --- /dev/null +++ b/src/crypto/selfEncryption.bundler.ts @@ -0,0 +1,39 @@ +/*********************************************************************************************** + * DO NOT IMPORT THIS FILE DIRECTLY + ***********************************************************************************************/ + +import { + // eslint-disable-next-line camelcase + generate_private_preferences_topic, + // eslint-disable-next-line camelcase + user_preferences_decrypt, + // eslint-disable-next-line camelcase + user_preferences_encrypt, +} from '@xmtp/user-preferences-bindings-wasm/bundler' +import { PrivateKey } from './PrivateKey' + +export async function userPreferencesEncrypt( + identityKey: PrivateKey, + payload: Uint8Array +) { + const publicKey = identityKey.publicKey.secp256k1Uncompressed.bytes + const privateKey = identityKey.secp256k1.bytes + // eslint-disable-next-line camelcase + return user_preferences_encrypt(publicKey, privateKey, payload) +} + +export async function userPreferencesDecrypt( + identityKey: PrivateKey, + payload: Uint8Array +) { + const publicKey = identityKey.publicKey.secp256k1Uncompressed.bytes + const privateKey = identityKey.secp256k1.bytes + // eslint-disable-next-line camelcase + return user_preferences_decrypt(publicKey, privateKey, payload) +} + +export async function generateUserPreferencesTopic(identityKey: PrivateKey) { + const privateKey = identityKey.secp256k1.bytes + // eslint-disable-next-line camelcase + return generate_private_preferences_topic(privateKey) +} diff --git a/src/crypto/selfEncryption.ts b/src/crypto/selfEncryption.ts new file mode 100644 index 000000000..f6a5cefa3 --- /dev/null +++ b/src/crypto/selfEncryption.ts @@ -0,0 +1,35 @@ +import { + // eslint-disable-next-line camelcase + generate_private_preferences_topic, + // eslint-disable-next-line camelcase + user_preferences_decrypt, + // eslint-disable-next-line camelcase + user_preferences_encrypt, +} from '@xmtp/user-preferences-bindings-wasm' +import { PrivateKey } from '../crypto' + +export async function userPreferencesEncrypt( + identityKey: PrivateKey, + payload: Uint8Array +) { + const publicKey = identityKey.publicKey.secp256k1Uncompressed.bytes + const privateKey = identityKey.secp256k1.bytes + // eslint-disable-next-line camelcase + return user_preferences_encrypt(publicKey, privateKey, payload) +} + +export async function userPreferencesDecrypt( + identityKey: PrivateKey, + payload: Uint8Array +) { + const publicKey = identityKey.publicKey.secp256k1Uncompressed.bytes + const privateKey = identityKey.secp256k1.bytes + // eslint-disable-next-line camelcase + return user_preferences_decrypt(publicKey, privateKey, payload) +} + +export async function generateUserPreferencesTopic(identityKey: PrivateKey) { + const privateKey = identityKey.secp256k1.bytes + // eslint-disable-next-line camelcase + return generate_private_preferences_topic(privateKey) +} diff --git a/src/index.ts b/src/index.ts index 196ad7bbc..f0a75608c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -112,3 +112,9 @@ export type { GetMessageContentTypeFromClient, ExtractDecodedType, } from './types/client' +export type { + ConsentState, + ConsentListEntryType, + PrivatePreferencesAction, +} from './Contacts' +export { ConsentListEntry } from './Contacts' diff --git a/src/keystore/InMemoryKeystore.ts b/src/keystore/InMemoryKeystore.ts index 1c773ec03..39ee8f687 100644 --- a/src/keystore/InMemoryKeystore.ts +++ b/src/keystore/InMemoryKeystore.ts @@ -29,6 +29,11 @@ import { hmacSha256Sign } from '../crypto/ecies' import crypto from '../crypto/crypto' import { bytesToHex } from '../crypto/utils' import Long from 'long' +import { + userPreferencesDecrypt, + userPreferencesEncrypt, + generateUserPreferencesTopic, +} from '../crypto/selfEncryption' const { ErrorCode } = keystore @@ -197,6 +202,75 @@ export default class InMemoryKeystore implements Keystore { ) } + async selfEncrypt( + req: keystore.SelfEncryptRequest + ): Promise { + const responses = await mapAndConvertErrors( + req.requests, + async (req) => { + const { payload } = req + + if (!payload) { + throw new KeystoreError( + ErrorCode.ERROR_CODE_INVALID_INPUT, + 'Missing field payload' + ) + } + + return { + encrypted: await userPreferencesEncrypt( + this.v1Keys.identityKey, + payload + ), + } + }, + ErrorCode.ERROR_CODE_INVALID_INPUT + ) + + return keystore.SelfEncryptResponse.fromPartial({ + responses, + }) + } + + async selfDecrypt( + req: keystore.SelfDecryptRequest + ): Promise { + const responses = await mapAndConvertErrors( + req.requests, + async (req) => { + const { payload } = req + + if (!payload) { + throw new KeystoreError( + ErrorCode.ERROR_CODE_INVALID_INPUT, + 'Missing field payload' + ) + } + + return { + decrypted: await userPreferencesDecrypt( + this.v1Keys.identityKey, + payload + ), + } + }, + ErrorCode.ERROR_CODE_INVALID_INPUT + ) + + return keystore.DecryptResponse.fromPartial({ + responses, + }) + } + + async getPrivatePreferencesTopicIdentifier(): Promise { + const identifier = await generateUserPreferencesTopic( + this.v1Keys.identityKey + ) + return keystore.GetPrivatePreferencesTopicIdentifierResponse.fromPartial({ + identifier, + }) + } + async encryptV2( req: keystore.EncryptV2Request ): Promise { diff --git a/src/keystore/interfaces.ts b/src/keystore/interfaces.ts index 4e70e9b7b..15382f7b2 100644 --- a/src/keystore/interfaces.ts +++ b/src/keystore/interfaces.ts @@ -81,6 +81,22 @@ export interface Keystore { * Get the account address of the wallet used to create the Keystore */ getAccountAddress(): Promise + /** + * Encrypt a batch of messages to yourself + */ + selfEncrypt( + req: keystore.SelfEncryptRequest + ): Promise + /** + * Decrypt a batch of messages to yourself + */ + selfDecrypt( + req: keystore.SelfDecryptRequest + ): Promise + /** + * Get the private preferences topic identifier + */ + getPrivatePreferencesTopicIdentifier(): Promise } export type TopicData = WithoutUndefined diff --git a/src/keystore/rpcDefinitions.ts b/src/keystore/rpcDefinitions.ts index b362d3e7f..3d04d5208 100644 --- a/src/keystore/rpcDefinitions.ts +++ b/src/keystore/rpcDefinitions.ts @@ -73,4 +73,16 @@ export const apiDefs: ApiDefs = { req: keystore.SetRefeshJobRequest, res: keystore.SetRefreshJobResponse, }, + selfEncrypt: { + req: keystore.SelfEncryptRequest, + res: keystore.SelfEncryptResponse, + }, + selfDecrypt: { + req: keystore.SelfDecryptRequest, + res: keystore.DecryptResponse, + }, + getPrivatePreferencesTopicIdentifier: { + req: null, + res: keystore.GetPrivatePreferencesTopicIdentifierResponse, + }, } as const diff --git a/src/message-backup/BackupClientFactory.ts b/src/message-backup/BackupClientFactory.ts index 1aa0d6ee3..5784cc000 100644 --- a/src/message-backup/BackupClientFactory.ts +++ b/src/message-backup/BackupClientFactory.ts @@ -10,12 +10,11 @@ import TopicStoreBackupClient from './TopicStoreBackupClient' * Creates a backup client of the correct provider type (e.g. xmtp backup, no backup, etc). * Uses an existing user preference from the backend if it exists, else prompts for a new * one using the `providerSelector` - * * @param walletAddress The public address of the user's wallet * @param selectBackupProvider A callback for determining the provider to use, in the event there is no * existing user preference. The app can define the policy to use here (e.g. prompt the user, * or default to a certain provider type). - * @returns A backup client of the correct type + * @returns {Promise} A backup client of the correct type */ export async function createBackupClient( walletAddress: string, diff --git a/src/utils/async.ts b/src/utils/async.ts index 9bb98bd23..38242a5e2 100644 --- a/src/utils/async.ts +++ b/src/utils/async.ts @@ -1,4 +1,5 @@ import { messageApi } from '@xmtp/proto' +import { Flatten } from './typedefs' export type IsRetryable = (err?: Error) => boolean @@ -49,6 +50,13 @@ export async function retry any>( } } +export type EnvelopeWithMessage = Flatten< + messageApi.Envelope & Required> +> +export type EnvelopeMapperWithMessage = ( + env: EnvelopeWithMessage +) => Promise + export type EnvelopeMapper = (env: messageApi.Envelope) => Promise // Takes an async generator returning pages of envelopes and converts to an async diff --git a/src/utils/topic.ts b/src/utils/topic.ts index 7e4b25e63..98d28006f 100644 --- a/src/utils/topic.ts +++ b/src/utils/topic.ts @@ -31,11 +31,15 @@ export const buildUserInviteTopic = (walletAddr: string): string => { // EIP55 normalize the address case. return buildContentTopic(`invite-${utils.getAddress(walletAddr)}`) } + export const buildUserPrivateStoreTopic = (addrPrefixedKey: string): string => { // e.g. "0x1111111111222222222233333333334444444444/key_bundle" return buildContentTopic(`privatestore-${addrPrefixedKey}`) } +export const buildUserPrivatePreferencesTopic = (identifier: string) => + buildContentTopic(`userpreferences-${identifier}`) + // validate that a topic only contains ASCII characters 33-127 export const isValidTopic = (topic: string): boolean => { // eslint-disable-next-line no-control-regex diff --git a/test/Contacts.test.ts b/test/Contacts.test.ts new file mode 100644 index 000000000..26285bf80 --- /dev/null +++ b/test/Contacts.test.ts @@ -0,0 +1,193 @@ +import { privatePreferences } from '@xmtp/proto' +import Client from '../src/Client' +import { Contacts } from '../src/Contacts' +import { newWallet } from './helpers' + +const alice = newWallet() +const bob = newWallet() +const carol = newWallet() + +let aliceClient: Client +let bobClient: Client +let carolClient: Client + +describe('Contacts', () => { + beforeEach(async () => { + aliceClient = await Client.create(alice, { + env: 'local', + }) + bobClient = await Client.create(bob, { + env: 'local', + }) + carolClient = await Client.create(carol, { + env: 'local', + }) + }) + + it('should initialize with client', async () => { + expect(aliceClient.contacts).toBeInstanceOf(Contacts) + expect(aliceClient.contacts.addresses).toBeInstanceOf(Set) + expect(Array.from(aliceClient.contacts.addresses.keys()).length).toBe(0) + }) + + it('should allow and deny addresses', async () => { + await aliceClient.contacts.allow([bob.address]) + expect(aliceClient.contacts.consentState(bob.address)).toBe('allowed') + expect(aliceClient.contacts.isAllowed(bob.address)).toBe(true) + expect(aliceClient.contacts.isDenied(bob.address)).toBe(false) + + await aliceClient.contacts.deny([bob.address]) + expect(aliceClient.contacts.consentState(bob.address)).toBe('denied') + expect(aliceClient.contacts.isAllowed(bob.address)).toBe(false) + expect(aliceClient.contacts.isDenied(bob.address)).toBe(true) + }) + + it('should allow an address when a conversation is started', async () => { + const conversation = await aliceClient.conversations.newConversation( + carol.address + ) + + expect(aliceClient.contacts.consentState(carol.address)).toBe('allowed') + expect(aliceClient.contacts.isAllowed(carol.address)).toBe(true) + expect(aliceClient.contacts.isDenied(carol.address)).toBe(false) + + expect(conversation.isAllowed).toBe(true) + expect(conversation.isDenied).toBe(false) + expect(conversation.consentState).toBe('allowed') + }) + + it('should allow an address when a conversation has an unknown consent state and a message is sent into it', async () => { + await aliceClient.conversations.newConversation(carol.address) + + expect(carolClient.contacts.consentState(alice.address)).toBe('unknown') + expect(carolClient.contacts.isAllowed(carol.address)).toBe(false) + expect(carolClient.contacts.isDenied(carol.address)).toBe(false) + + const carolConversation = await carolClient.conversations.newConversation( + alice.address + ) + expect(carolConversation.consentState).toBe('unknown') + expect(carolConversation.isAllowed).toBe(false) + expect(carolConversation.isDenied).toBe(false) + + await carolConversation.send('gm') + + expect(carolConversation.consentState).toBe('allowed') + expect(carolConversation.isAllowed).toBe(true) + expect(carolConversation.isDenied).toBe(false) + }) + + it('should allow or deny an address from a conversation', async () => { + const conversation = await aliceClient.conversations.newConversation( + carol.address + ) + + await conversation.deny() + + expect(aliceClient.contacts.consentState(carol.address)).toBe('denied') + expect(aliceClient.contacts.isAllowed(carol.address)).toBe(false) + expect(aliceClient.contacts.isDenied(carol.address)).toBe(true) + + expect(conversation.isAllowed).toBe(false) + expect(conversation.isDenied).toBe(true) + expect(conversation.consentState).toBe('denied') + + await conversation.allow() + + expect(aliceClient.contacts.consentState(carol.address)).toBe('allowed') + expect(aliceClient.contacts.isAllowed(carol.address)).toBe(true) + expect(aliceClient.contacts.isDenied(carol.address)).toBe(false) + + expect(conversation.isAllowed).toBe(true) + expect(conversation.isDenied).toBe(false) + expect(conversation.consentState).toBe('allowed') + }) + + it('should retrieve consent state', async () => { + const entries = await bobClient.contacts.refreshConsentList() + + expect(entries.length).toBe(0) + + await bobClient.contacts.deny([alice.address]) + await bobClient.contacts.allow([carol.address]) + await bobClient.contacts.allow([alice.address]) + await bobClient.contacts.deny([carol.address]) + await bobClient.contacts.deny([alice.address]) + await bobClient.contacts.allow([carol.address]) + + expect(bobClient.contacts.consentState(alice.address)).toBe('denied') + expect(bobClient.contacts.isAllowed(alice.address)).toBe(false) + expect(bobClient.contacts.isDenied(alice.address)).toBe(true) + + expect(bobClient.contacts.consentState(carol.address)).toBe('allowed') + expect(bobClient.contacts.isAllowed(carol.address)).toBe(true) + expect(bobClient.contacts.isDenied(carol.address)).toBe(false) + + bobClient = await Client.create(bob, { + env: 'local', + }) + + expect(bobClient.contacts.consentState(alice.address)).toBe('unknown') + expect(bobClient.contacts.consentState(carol.address)).toBe('unknown') + + const latestEntries = await bobClient.contacts.refreshConsentList() + + expect(latestEntries.length).toBe(6) + expect(latestEntries).toEqual([ + { + entryType: 'address', + permissionType: 'denied', + value: alice.address, + }, + { + entryType: 'address', + permissionType: 'allowed', + value: carol.address, + }, + { + entryType: 'address', + permissionType: 'allowed', + value: alice.address, + }, + { + entryType: 'address', + permissionType: 'denied', + value: carol.address, + }, + { + entryType: 'address', + permissionType: 'denied', + value: alice.address, + }, + { + entryType: 'address', + permissionType: 'allowed', + value: carol.address, + }, + ]) + + expect(bobClient.contacts.consentState(alice.address)).toBe('denied') + expect(bobClient.contacts.isAllowed(alice.address)).toBe(false) + expect(bobClient.contacts.isDenied(alice.address)).toBe(true) + + expect(bobClient.contacts.consentState(carol.address)).toBe('allowed') + expect(bobClient.contacts.isAllowed(carol.address)).toBe(true) + expect(bobClient.contacts.isDenied(carol.address)).toBe(false) + }) + + it('should stream consent updates', async () => { + const aliceStream = await aliceClient.contacts.streamConsentList() + await aliceClient.conversations.newConversation(bob.address) + + let numActions = 0 + const actions: privatePreferences.PrivatePreferencesAction[] = [] + for await (const action of aliceStream) { + numActions++ + expect(action.block).toBeUndefined() + expect(action.allow?.walletAddresses).toEqual([bob.address]) + break + } + expect(numActions).toBe(1) + await aliceStream.return() + }) +}) diff --git a/test/conversations/JobRunner.test.ts b/test/conversations/JobRunner.test.ts index cfdd6a904..9bd539c26 100644 --- a/test/conversations/JobRunner.test.ts +++ b/test/conversations/JobRunner.test.ts @@ -81,4 +81,25 @@ describe('JobRunner', () => { }) ).rejects.toThrow('foo') }) + + it('resets the last run time', async () => { + const userPreferencesRunner = new JobRunner('user-preferences', keystore) + await userPreferencesRunner.run(async () => {}) + + const { lastRunNs: userPreferencesLastRunNs } = + await keystore.getRefreshJob({ + jobType: keystoreProto.JobType.JOB_TYPE_REFRESH_PPPP, + }) + + expect(userPreferencesLastRunNs.gt(0)).toBeTruthy() + + await userPreferencesRunner.resetLastRunTime() + + const { lastRunNs: userPreferencesLastRunNs2 } = + await keystore.getRefreshJob({ + jobType: keystoreProto.JobType.JOB_TYPE_REFRESH_PPPP, + }) + + expect(userPreferencesLastRunNs2.eq(0)).toBeTruthy() + }) }) diff --git a/tsconfig.json b/tsconfig.json index a1c113223..76de14990 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,73 +1,15 @@ { "compilerOptions": { - /* Visit https://aka.ms/tsconfig.json to read more about this file */ - - /* Basic Options */ - // "incremental": true, /* Enable incremental compilation */ - "target": "es2021" /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019', 'ES2020', or 'ESNEXT'. */, - // "module": "commonjs", /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', 'es2020', or 'ESNext'. */ - "lib": [ - "dom" - ] /* Specify library files to be included in the compilation. */, - // "allowJs": true, /* Allow javascript files to be compiled. */ - // "checkJs": true, /* Report errors in .js files. */ - // "jsx": "preserve", /* Specify JSX code generation: 'preserve', 'react-native', or 'react'. */ - // "declaration": true, /* Generates corresponding '.d.ts' file. */ - // "declarationMap": true, /* Generates a sourcemap for each corresponding '.d.ts' file. */ - "sourceMap": true /* Generates corresponding '.map' file. */, - // "outFile": "./", /* Concatenate and emit output to single file. */ - // "outDir": "./", /* Redirect output structure to the directory. */ - // "rootDir": "./", /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */ - // "composite": true, /* Enable project compilation */ - // "tsBuildInfoFile": "./", /* Specify file to store incremental compilation information */ - // "removeComments": true, /* Do not emit comments to output. */ - "noEmit": true /* Do not emit outputs. */, - // "importHelpers": true, /* Import emit helpers from 'tslib'. */ - "downlevelIteration": true /* Provide full support for iterables in 'for-of', spread, and destructuring when targeting 'ES5' or 'ES3'. */, - // "isolatedModules": true, /* Transpile each file as a separate module (similar to 'ts.transpileModule'). */ - - /* Strict Type-Checking Options */ - "strict": true /* Enable all strict type-checking options. */, - // "noImplicitAny": true, /* Raise error on expressions and declarations with an implied 'any' type. */ - // "strictNullChecks": true, /* Enable strict null checks. */ - // "strictFunctionTypes": true, /* Enable strict checking of function types. */ - // "strictBindCallApply": true, /* Enable strict 'bind', 'call', and 'apply' methods on functions. */ - // "strictPropertyInitialization": true, /* Enable strict checking of property initialization in classes. */ - // "noImplicitThis": true, /* Raise error on 'this' expressions with an implied 'any' type. */ - // "alwaysStrict": true, /* Parse in strict mode and emit "use strict" for each source file. */ - - /* Additional Checks */ - // "noUnusedLocals": true, /* Report errors on unused locals. */ - // "noUnusedParameters": true, /* Report errors on unused parameters. */ - // "noImplicitReturns": true, /* Report error when not all code paths in function return a value. */ - // "noFallthroughCasesInSwitch": true, /* Report errors for fallthrough cases in switch statement. */ - - /* Module Resolution Options */ - "moduleResolution": "node" /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */, - // "baseUrl": "./", /* Base directory to resolve non-absolute module names. */ - // "paths": {}, /* A series of entries which re-map imports to lookup locations relative to the 'baseUrl'. */ - // "rootDirs": [], /* List of root folders whose combined content represents the structure of the project at runtime. */ - // "typeRoots": [], /* List of folders to include type definitions from. */ - // "types": [], /* Type declaration files to be included in compilation. */ - // "allowSyntheticDefaultImports": true, /* Allow default imports from modules with no default export. This does not affect code emit, just typechecking. */ + "target": "es2021", + "lib": ["dom"], + "sourceMap": true, + "noEmit": true, + "downlevelIteration": true, + "strict": true, + "moduleResolution": "bundler", "resolveJsonModule": true, - "esModuleInterop": true /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */, - // "preserveSymlinks": true, /* Do not resolve the real path of symlinks. */ - // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */ - - /* Source Map Options */ - // "sourceRoot": "", /* Specify the location where debugger should locate TypeScript files instead of source locations. */ - // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */ - // "inlineSourceMap": true, /* Emit a single file with source maps instead of having a separate file. */ - // "inlineSources": true, /* Emit the source alongside the sourcemaps within a single file; requires '--inlineSourceMap' or '--sourceMap' to be set. */ - - /* Experimental Options */ - // "experimentalDecorators": true, /* Enables experimental support for ES7 decorators. */ - // "emitDecoratorMetadata": true, /* Enables experimental support for emitting type metadata for decorators. */ - - /* Advanced Options */ - // "skipLibCheck": true, /* Skip type checking of declaration files. */ - "forceConsistentCasingInFileNames": true /* Disallow inconsistently-cased references to the same file. */ + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true }, "include": ["src/**/*", "bench/**/*"] } diff --git a/tsup.bundler.config.ts b/tsup.bundler.config.ts new file mode 100644 index 000000000..e0d0f3595 --- /dev/null +++ b/tsup.bundler.config.ts @@ -0,0 +1,32 @@ +import { defineConfig } from 'tsup' +import { resolveExtensionsPlugin } from './build/esbuild-plugin-resolve-extensions/index.ts' +import { Plugin } from 'esbuild' + +export default defineConfig((options) => { + const esbuildPlugins: Plugin[] = [] + + // replace imports if there's a file with the same name but with a + // `.bundler` or `.browser` extension + // i.e. crypto.ts -> crypto.browser.ts + esbuildPlugins.push( + resolveExtensionsPlugin({ + extensions: ['.bundler', '.browser'], + }) + ) + + return { + entry: ['src/index.ts'], + outDir: 'dist/bundler', + splitting: false, + sourcemap: true, + treeshake: true, + clean: true, + bundle: true, + platform: 'browser', + minify: true, + dts: false, + format: ['esm'], + esbuildPlugins, + target: 'esnext', + } +}) diff --git a/tsup.config.ts b/tsup.config.ts index 8e7da5196..fd2fffcb6 100644 --- a/tsup.config.ts +++ b/tsup.config.ts @@ -1,16 +1,17 @@ import { defineConfig } from 'tsup' -import externalGlobal from 'esbuild-plugin-external-global' +import { resolveExtensionsPlugin } from './build/esbuild-plugin-resolve-extensions/index.ts' import { Plugin } from 'esbuild' export default defineConfig((options) => { const esbuildPlugins: Plugin[] = [] - // for the browser bundle, replace `crypto` import with an object that - // returns the browser's built-in crypto library + // for browsers, replace imports if there's a file with the same name + // but with a `.browser` extension + // i.e. crypto.ts -> crypto.browser.ts if (options.platform === 'browser') { esbuildPlugins.push( - externalGlobal.externalGlobalPlugin({ - crypto: '{ webcrypto: window.crypto }', + resolveExtensionsPlugin({ + extensions: ['.browser'], }) ) }