From 93d3226080f097b75d996d667055416e319a936d Mon Sep 17 00:00:00 2001 From: Nate Otto Date: Tue, 10 Nov 2026 21:44:11 -0800 Subject: [PATCH] Add a read-only scan of the Atlas credentials collection scripts/atlas/atlas.ts scan lists the collection's indexes, including any TTL, and prints aggregate counts and vp sizes against the 350 KB sharing limit and DynamoDB's 400 KB item limit. It reads only, takes its connection string from ATLAS_URI in the environment, prints the host but never the credentials, and never prints ids, holder DIDs or credential content. Run it by hand with tsx, now a dev dependency. Co-Authored-By: Claude Opus 5.5 --- package-lock.json | 535 ++++++++++++++++++++++++++++++++++++++-- package.json | 1 + scripts/atlas/README.md | 54 ++++ scripts/atlas/atlas.ts | 287 +++++++++++++++++++++ 4 files changed, 861 insertions(+), 16 deletions(-) create mode 100644 scripts/atlas/README.md create mode 100644 scripts/atlas/atlas.ts 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; + }, +);