diff --git a/package-lock.json b/package-lock.json index 56aefec..879aacf 100644 --- a/package-lock.json +++ b/package-lock.json @@ -46,6 +46,7 @@ "@types/validator": "^13.15.3", "dotenv": "^17.2.1", "tailwindcss": "^4.1.13", + "tsx": "^4.23.15", "typescript": "^5", "vitest": "^5.1.0" }, @@ -1230,6 +1231,448 @@ "tslib": "^2.4.0" } }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", + "integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz", + "integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz", + "integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz", + "integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz", + "integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz", + "integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz", + "integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz", + "integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz", + "integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz", + "integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz", + "integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz", + "integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz", + "integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz", + "integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz", + "integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz", + "integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz", + "integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz", + "integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz", + "integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz", + "integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz", + "integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz", + "integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz", + "integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz", + "integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz", + "integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz", + "integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, "node_modules/@fastify/busboy": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/@fastify/busboy/-/busboy-2.1.1.tgz", @@ -3410,6 +3853,48 @@ "node": ">= 0.4" } }, + "node_modules/esbuild": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz", + "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.2", + "@esbuild/android-arm": "0.28.2", + "@esbuild/android-arm64": "0.28.2", + "@esbuild/android-x64": "0.28.2", + "@esbuild/darwin-arm64": "0.28.2", + "@esbuild/darwin-x64": "0.28.2", + "@esbuild/freebsd-arm64": "0.28.2", + "@esbuild/freebsd-x64": "0.28.2", + "@esbuild/linux-arm": "0.28.2", + "@esbuild/linux-arm64": "0.28.2", + "@esbuild/linux-ia32": "0.28.2", + "@esbuild/linux-loong64": "0.28.2", + "@esbuild/linux-mips64el": "0.28.2", + "@esbuild/linux-ppc64": "0.28.2", + "@esbuild/linux-riscv64": "0.28.2", + "@esbuild/linux-s390x": "0.28.2", + "@esbuild/linux-x64": "0.28.2", + "@esbuild/netbsd-arm64": "0.28.2", + "@esbuild/netbsd-x64": "0.28.2", + "@esbuild/openbsd-arm64": "0.28.2", + "@esbuild/openbsd-x64": "0.28.2", + "@esbuild/openharmony-arm64": "0.28.2", + "@esbuild/sunos-x64": "0.28.2", + "@esbuild/win32-arm64": "0.28.2", + "@esbuild/win32-ia32": "0.28.2", + "@esbuild/win32-x64": "0.28.2" + } + }, "node_modules/estree-util-is-identifier-name": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/estree-util-is-identifier-name/-/estree-util-is-identifier-name-3.0.0.tgz", @@ -3556,6 +4041,21 @@ "node": ">=12.20.0" } }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/function-bind": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", @@ -5869,6 +6369,25 @@ "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==" }, + "node_modules/tsx": { + "version": "4.23.15", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.15.tgz", + "integrity": "sha512-Yiex1Ovn8z2xPpOWckIiysV1SSyRMY9BkLF++q0yKiDxCqRhosKfMg3janKkiLBwZ5c/YryloKwGZcrEmtwxKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.28.0" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, "node_modules/tweetnacl": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-1.0.3.tgz", @@ -6137,22 +6656,6 @@ } } }, - "node_modules/vite/node_modules/fsevents": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", - "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "peer": true, - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" - } - }, "node_modules/vite/node_modules/lightningcss": { "version": "1.33.0", "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", diff --git a/package.json b/package.json index 8f83aed..a7c4f60 100644 --- a/package.json +++ b/package.json @@ -53,6 +53,7 @@ "@types/validator": "^13.15.3", "dotenv": "^17.2.1", "tailwindcss": "^4.1.13", + "tsx": "^4.23.15", "typescript": "^5", "vitest": "^5.1.0" }, diff --git a/scripts/atlas/README.md b/scripts/atlas/README.md new file mode 100644 index 0000000..85334ac --- /dev/null +++ b/scripts/atlas/README.md @@ -0,0 +1,54 @@ +# Atlas scripts + +`atlas.ts` reads the verifier-plus credentials collection in MongoDB Atlas, the store that `verifierplus.org` used before 3.0.0. It is run by hand, from a maintainer's machine, with a database user that can only read. It is not part of the app, its build or CI. + +```sh +npx tsx scripts/atlas/atlas.ts +``` + +## Settings + +From the environment only. There are no flags for them, so a connection string never lands in shell history as part of a command. + +| Variable | Default | | +|---|---|---| +| `ATLAS_URI` | none; required | The read-only user's connection string, such as `mongodb+srv://:@/` | +| `ATLAS_DB` | `verifier-plus` | The database, as in the app | +| `ATLAS_COLLECTION` | `credentials` | The collection, as in the app | + +The script refuses to run without `ATLAS_URI`. Before reading anything it prints the host it is about to read, never the user name or password. + +## `scan` + +Reports what is in the collection without printing any of it. Reads only, with read preference `secondaryPreferred`. + +1. Lists the collection's indexes: each one's name, key and options, including any `expireAfterSeconds`. +2. Reads every document's `id`, `createdAt`, `shared` and `vp`, and keeps only counts and sizes. A presentation's size is `Buffer.byteLength(JSON.stringify(vp), 'utf8')`, the measure the app's 350 KB limit on sharing uses. +3. Prints: + - counts: documents; shared; unshared; `shared` missing or not a boolean; shared and unexpired (`createdAt` less than 365 days ago); `createdAt` missing or not a date; `id` missing or not a v4 UUID; `vp` missing; + - `vp` size, among all documents and among the shared and unexpired: max, p50, p90 and p99 (nearest rank), and how many are over 350 KB (358,400 bytes) and over 400 KB (DynamoDB's item limit); + - the oldest and newest `createdAt` among the shared and unexpired. + +A document without a valid `createdAt` cannot be dated, so it is never counted as unexpired. + +Run it like this, then clear the variable: + +```sh +export ATLAS_URI='mongodb+srv://:@/' +npx tsx scripts/atlas/atlas.ts scan +unset ATLAS_URI +``` + +To try it locally first, against a MongoDB with no data you care about: + +```sh +docker run -d --name atlas-scan-mongo -p 27017:27017 mongo:7 +ATLAS_URI=mongodb://localhost:27017 npx tsx scripts/atlas/atlas.ts scan +``` + +## What it never does + +- Write anything: no inserts, updates, deletes, and no index creation. Give it a user with the built-in `read` role on the one database, and it could not if it tried. +- Print a credential id, a holder DID (`controller`), or any part of a stored presentation. Output is aggregates and index definitions only. +- Print the connection string, the user name or the password, including in its own error messages. +- Read settings from flags or files. diff --git a/scripts/atlas/atlas.ts b/scripts/atlas/atlas.ts new file mode 100644 index 0000000..857bd48 --- /dev/null +++ b/scripts/atlas/atlas.ts @@ -0,0 +1,287 @@ +/** + * Read-only tools for the verifier-plus credentials collection in MongoDB Atlas. + * See README.md beside this file. + * + * npx tsx scripts/atlas/atlas.ts + * + * Settings come from the environment only, never from flags, so a connection + * string stays out of shell history: + * + * ATLAS_URI required; a read-only user's connection string + * ATLAS_DB default verifier-plus + * ATLAS_COLLECTION default credentials + * + * Aggregates go to stdout and errors to stderr. Nothing here prints a + * credential id, a holder DID or any part of a stored presentation. + */ +import { MongoClient, type Collection, type Document } from 'mongodb'; + +const DAY_MS = 24 * 60 * 60 * 1000; +/** The age at which a shared credential expires: the app's default CREDENTIAL_TTL_DAYS. */ +const TTL_DAYS = 365; +/** The app's limit on a shared presentation (MAX_SHARED_VP_BYTES): 350 KB of UTF-8 JSON. */ +const LIMIT_BYTES = 350 * 1024; +/** DynamoDB's limit on a whole item. */ +const DYNAMODB_ITEM_BYTES = 400 * 1024; +const UUID_V4 = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; + +type Command = { + summary: string; + run: (atlas: Atlas) => Promise; +}; + +const commands: Record = { + scan: { + summary: 'Print the collection\'s indexes and aggregate counts and vp sizes. Reads only.', + run: scan, + }, +}; + +// --------------------------------------------------------------------------- +// Connection + +type Atlas = { + /** Where the URI points, with no user name or password. */ + host: string; + db: string; + collectionName: string; + collection: Collection; + close: () => Promise; +}; + +class UsageError extends Error {} + +function openAtlas(): Atlas { + const uri = process.env.ATLAS_URI; + if (!uri) { + throw new UsageError('ATLAS_URI is not set. Export the read-only user\'s connection string first; see scripts/atlas/README.md.'); + } + const db = process.env.ATLAS_DB || 'verifier-plus'; + const collectionName = process.env.ATLAS_COLLECTION || 'credentials'; + + let client: MongoClient; + try { + // The constructor parses the URI without connecting. + client = new MongoClient(uri, { readPreference: 'secondaryPreferred' }); + } catch { + // The parser's message can quote the URI, password included, so it is not printed. + throw new UsageError('ATLAS_URI is not a valid MongoDB connection string.'); + } + + return { + host: hostOf(client), + db, + collectionName, + collection: client.db(db).collection(collectionName), + close: () => client.close(), + }; +} + +/** The host part of the parsed URI: the SRV name, or the seed list. Never the credentials. */ +function hostOf(client: MongoClient): string { + const { srvHost, hosts } = client.options; + return srvHost ?? hosts.map((host) => host.toString()).join(','); +} + +// --------------------------------------------------------------------------- +// scan + +/** What scan keeps per document: no id, no controller, no content. */ +type Tally = { + documents: number; + shared: number; + unshared: number; + sharedNotBoolean: number; + missingCreatedAt: number; + oddId: number; + missingVp: number; + /** UTF-8 bytes of JSON.stringify(vp), every document that has a vp. */ + sizes: number[]; + sharedUnexpired: { + documents: number; + sizes: number[]; + oldest?: Date; + newest?: Date; + }; +}; + +async function scan(atlas: Atlas): Promise { + const now = new Date(); + const indexes = await atlas.collection.listIndexes().toArray(); + const tally = await tallyDocuments(atlas.collection, now); + printScan(now, indexes, tally); +} + +async function tallyDocuments(collection: Collection, now: Date): Promise { + const tally: Tally = { + documents: 0, + shared: 0, + unshared: 0, + sharedNotBoolean: 0, + missingCreatedAt: 0, + oddId: 0, + missingVp: 0, + sizes: [], + sharedUnexpired: { documents: 0, sizes: [] }, + }; + + const cursor = collection.find({}, { projection: { _id: 0, id: 1, createdAt: 1, shared: 1, vp: 1 } }); + for await (const doc of cursor) { + tally.documents += 1; + + if (doc.shared === true) tally.shared += 1; + else if (doc.shared === false) tally.unshared += 1; + else tally.sharedNotBoolean += 1; + + if (typeof doc.id !== 'string' || !UUID_V4.test(doc.id)) tally.oddId += 1; + + const createdAt = doc.createdAt instanceof Date && !Number.isNaN(doc.createdAt.getTime()) + ? doc.createdAt + : undefined; + if (!createdAt) tally.missingCreatedAt += 1; + + // The same measure as the app's limit on sharing. + const json = doc.vp === undefined ? undefined : JSON.stringify(doc.vp); + const bytes = json === undefined ? undefined : Buffer.byteLength(json, 'utf8'); + if (bytes === undefined) tally.missingVp += 1; + else tally.sizes.push(bytes); + + const unexpired = createdAt !== undefined && createdAt.getTime() + TTL_DAYS * DAY_MS > now.getTime(); + if (doc.shared === true && unexpired) { + const set = tally.sharedUnexpired; + set.documents += 1; + if (bytes !== undefined) set.sizes.push(bytes); + if (!set.oldest || createdAt < set.oldest) set.oldest = createdAt; + if (!set.newest || createdAt > set.newest) set.newest = createdAt; + } + } + return tally; +} + +function printScan(now: Date, indexes: Document[], tally: Tally): void { + const out = (line = '') => process.stdout.write(`${line}\n`); + + out(`Scanned at ${now.toISOString()}`); + out(); + out('Indexes'); + for (const index of indexes) { + const { name, key, v: _v, ns: _ns, ...options } = index; + out(` ${name}`); + out(` key: ${JSON.stringify(key)}`); + if (options.expireAfterSeconds !== undefined) { + const seconds = Number(options.expireAfterSeconds); + out(` expireAfterSeconds: ${seconds} (${formatDays(seconds)})`); + } + const rest = Object.fromEntries(Object.entries(options).filter(([option]) => option !== 'expireAfterSeconds')); + if (Object.keys(rest).length > 0) out(` options: ${JSON.stringify(rest)}`); + } + if (indexes.length === 0) out(' none'); + out(); + + out('Documents'); + const rows: [string, number][] = [ + ['total', tally.documents], + ['shared', tally.shared], + ['unshared', tally.unshared], + ['shared missing or not a boolean', tally.sharedNotBoolean], + [`shared and unexpired (createdAt within ${TTL_DAYS} days)`, tally.sharedUnexpired.documents], + ['createdAt missing or not a date', tally.missingCreatedAt], + ['id missing or not a v4 UUID', tally.oddId], + ['vp missing', tally.missingVp], + ]; + printRows(out, rows); + out(); + + printSizes(out, 'vp size, all documents with a vp', tally.sizes); + out(); + printSizes(out, 'vp size, shared and unexpired', tally.sharedUnexpired.sizes); + out(); + + out('createdAt range, shared and unexpired'); + const { oldest, newest } = tally.sharedUnexpired; + printRows(out, [ + ['oldest', oldest ? oldest.toISOString() : 'none'], + ['newest', newest ? newest.toISOString() : 'none'], + ]); +} + +function printSizes(out: (line?: string) => void, title: string, sizes: number[]): void { + const sorted = [...sizes].sort((a, b) => a - b); + out(title); + printRows(out, [ + ['count', sorted.length], + ['max', formatBytes(sorted[sorted.length - 1])], + ['p50', formatBytes(percentile(sorted, 50))], + ['p90', formatBytes(percentile(sorted, 90))], + ['p99', formatBytes(percentile(sorted, 99))], + [`over ${formatBytes(LIMIT_BYTES)}`, sorted.filter((bytes) => bytes > LIMIT_BYTES).length], + [`over ${formatBytes(DYNAMODB_ITEM_BYTES)}`, sorted.filter((bytes) => bytes > DYNAMODB_ITEM_BYTES).length], + ]); +} + +/** Nearest-rank percentile of an ascending list; undefined when it is empty. */ +function percentile(sorted: number[], p: number): number | undefined { + if (sorted.length === 0) return undefined; + return sorted[Math.max(0, Math.ceil((p / 100) * sorted.length) - 1)]; +} + +function printRows(out: (line?: string) => void, rows: [string, number | string][]): void { + const width = Math.max(...rows.map(([label]) => label.length)); + for (const [label, value] of rows) { + out(` ${label.padEnd(width)} ${typeof value === 'number' ? value.toLocaleString('en-US') : value}`); + } +} + +function formatBytes(bytes: number | undefined): string { + if (bytes === undefined) return 'none'; + return `${bytes.toLocaleString('en-US')} bytes (${(bytes / 1024).toFixed(1)} KB)`; +} + +function formatDays(seconds: number): string { + const days = seconds / (DAY_MS / 1000); + return `${Number.isInteger(days) ? days : days.toFixed(2)} days`; +} + +// --------------------------------------------------------------------------- +// Entry point + +function usage(): string { + const names = Object.keys(commands); + const width = Math.max(...names.map((name) => name.length)); + return [ + 'Usage: npx tsx scripts/atlas/atlas.ts ', + '', + 'Commands:', + ...names.map((name) => ` ${name.padEnd(width)} ${commands[name].summary}`), + '', + 'Settings, from the environment only: ATLAS_URI (required), ATLAS_DB, ATLAS_COLLECTION.', + ].join('\n'); +} + +async function main(argv: string[]): Promise { + const [name, ...extra] = argv; + const command = name === undefined ? undefined : commands[name]; + if (!command || extra.length > 0) { + process.stderr.write(`${usage()}\n`); + return 2; + } + + const atlas = openAtlas(); + process.stdout.write(`Reading ${atlas.db}.${atlas.collectionName} on ${atlas.host}\n`); + try { + await command.run(atlas); + } finally { + await atlas.close(); + } + return 0; +} + +main(process.argv.slice(2)).then( + (code) => { + process.exitCode = code; + }, + (error: unknown) => { + process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`); + process.exitCode = error instanceof UsageError ? 2 : 1; + }, +);