diff --git a/.env.example b/.env.example index 54abb0a..99aca20 100644 --- a/.env.example +++ b/.env.example @@ -28,6 +28,17 @@ FIELD_ENCRYPTION_KEY=replace-with-exactly-32-random-bytes-as-base64 IDENTITY_HMAC_KEY=replace-with-a-distinct-32-random-bytes-as-base64 IDENTITY_TOMBSTONE_RETENTION_DAYS=365 +# Admin console. Enable bootstrap for the first successful startup only, then +# set it back to false and remove all four ADMIN_BOOTSTRAP_* credential values. +ADMIN_ENABLED=false +ADMIN_BOOTSTRAP_ENABLED=false +ADMIN_BOOTSTRAP_OPERATOR_ID=replace-with-random-uuid +ADMIN_BOOTSTRAP_USERNAME=owner +ADMIN_BOOTSTRAP_PASSWORD_HASH=replace-with-argon2id-phc-hash +ADMIN_BOOTSTRAP_TOTP_SECRET_BASE32=replace-with-random-base32-secret +ADMIN_SESSION_HOURS=8 +ADMIN_MAXIMUM_MANUAL_GRANT=100000 + # Apple identifiers are not secrets, but use the values from your own developer account. APPLE_TEAM_ID=replace-with-apple-team-id APPLE_KEY_ID=replace-with-apple-key-id diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8068399..670fb25 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -16,6 +16,11 @@ jobs: steps: - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: "22" + cache: npm + cache-dependency-path: admin-web/package-lock.json - uses: actions/setup-java@v4 with: distribution: temurin diff --git a/admin-web/.gitignore b/admin-web/.gitignore new file mode 100644 index 0000000..3bdd52e --- /dev/null +++ b/admin-web/.gitignore @@ -0,0 +1,3 @@ +node_modules/ +dist/ +.DS_Store diff --git a/admin-web/index.html b/admin-web/index.html new file mode 100644 index 0000000..59999fd --- /dev/null +++ b/admin-web/index.html @@ -0,0 +1,14 @@ + + + + + + + + OSG 运营后台 + + +
+ + + diff --git a/admin-web/package-lock.json b/admin-web/package-lock.json new file mode 100644 index 0000000..40e3087 --- /dev/null +++ b/admin-web/package-lock.json @@ -0,0 +1,2238 @@ +{ + "name": "osg-admin-web", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "osg-admin-web", + "version": "0.1.0", + "devDependencies": { + "@types/node": "^26.2.0", + "jsdom": "^27.0.1", + "typescript": "^5.9.2", + "vite": "^7.1.2", + "vitest": "^3.2.4" + } + }, + "node_modules/@asamuzakjp/css-color": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-4.1.2.tgz", + "integrity": "sha512-NfBUvBaYgKIuq6E/RBLY1m0IohzNHAYyaJGuTK79Z23uNwmz2jl1mPsC5ZxCCxylinKhT1Amn5oNTlx1wN8cQg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@csstools/css-calc": "^3.0.0", + "@csstools/css-color-parser": "^4.0.1", + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0", + "lru-cache": "^11.2.5" + } + }, + "node_modules/@asamuzakjp/dom-selector": { + "version": "6.8.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-6.8.1.tgz", + "integrity": "sha512-MvRz1nCqW0fsy8Qz4dnLIvhOlMzqDVBabZx6lH+YywFDdjXhMY37SmpV1XFX3JzG5GWHn63j6HX6QPr3lZXHvQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/nwsapi": "^2.3.9", + "bidi-js": "^1.0.3", + "css-tree": "^3.1.0", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.2.6" + } + }, + "node_modules/@asamuzakjp/nwsapi": { + "version": "2.3.9", + "resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz", + "integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@csstools/color-helpers": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.1.1.tgz", + "integrity": "sha512-gLNsunvwf3mCi5u5o46/Z/JcJMnhbHSaZ69rkgPzNM3J4s8hWwpPUQB6/tt0EDFyCiWzxANlx+2LJwpYj4zS1w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@csstools/css-calc": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.3.0.tgz", + "integrity": "sha512-c5ihYsPkdG6JCkU2zTMm4+k6r7RXuGxtWYhu5DHMIiF1FHzrfmHL5so11AoFpUv/tu61xfcmT4AmKoFfMPoqdQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.2.0.tgz", + "integrity": "sha512-5+5LEmFuY1AjXdYhmgjTJogtQnP1evJ1zrBZGUNZ0thkpwnnmKxcHdAMn/OtFjAb25zA+jKDVYVRl+5G7rjv1A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^6.1.1", + "@csstools/css-calc": "^3.3.0" + }, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz", + "integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-syntax-patches-for-csstree": { + "version": "1.1.8", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.8.tgz", + "integrity": "sha512-CpMLjAvwQg3BL5S0IeqsZNMH7EQrEWi0kLKOC13ZBF0ZwERiLWlibNPJr8G1kdU3Ms/r2KiNrF81pUh2HwAHdg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "peerDependencies": { + "css-tree": "^3.2.1" + }, + "peerDependenciesMeta": { + "css-tree": { + "optional": true + } + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz", + "integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.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/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@napi-rs/lzma-linux-x64-gnu": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz", + "integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^22.20 || ^24.12 || >=25" + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.4.tgz", + "integrity": "sha512-RrPokAb7dmbxFoeO3TloqHyOjgye8RkBhSqmp4aJMIex4c9r46ZstPnleDQOq1t46VOVjwIuwNogIqbodV1Vvg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.4.tgz", + "integrity": "sha512-JKuJc+pnpks2pjy7L/N3v/cAkZxYlnmuZoD840ldbMI5KDbC4iO9NKwPKYdjYFCMAIIlBzYSFHxIJVYzRo2/8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.4.tgz", + "integrity": "sha512-krw5uS2STmvJ02x0uTXHbqQNuz+9eZ1iw+qXk9dmW2gvV4jV7O2hEoOnuhFrpOPiel1mBFtqbxYZZtC46hXLOw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.4.tgz", + "integrity": "sha512-wsTxtgApb4PrOsNJIm0FZ1h3WvCC+k9uxLJ4ad75hgoS4NiRes2SoJFlDAyMwiUY8IssDqGcHbXuN0sx1tfF1A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.4.tgz", + "integrity": "sha512-GUOnQlyZe3yAXhWOtOMsn5Qkrv5E5mZXa0thbARWi5Ei2szlVXJFQhddZ4HbAzh8q92w5twp+CQvs/eFanz9YQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.4.tgz", + "integrity": "sha512-/Y7f3QuxjzPKsjA/rfEDa3+0vXqyjmJ50Ln8dPpCmWkKTrUoWHG1cWhTqaAMLob2m2nESWuC7yGrREz019Ztqg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.4.tgz", + "integrity": "sha512-81wiiX3v7aqy+T+bT61TJ78yJjRquqFFTTbAPt08imfQQzkPIW8t6aJbkTagtCCrXMNc9D66+geqlK7ydLPNqA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.4.tgz", + "integrity": "sha512-9kmDIvNZqdoHOBZgNtpTBeLWYO/LVipM3H/j62P8848/l/VPEQL6N3uxU9pvP1oZAsXyC2MEnFP3ovRjo7WYNQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.4.tgz", + "integrity": "sha512-CcnXHWnXg69g+DX5VWL3FHts3qMRN2uVEHX+BZvGLdd07/gXkn3ePjYtO1LDJvxkGKVHMclKBRa1QUTH+6toYQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.4.tgz", + "integrity": "sha512-iFOibiHnTRuhrWLlRsOQFdZJJIa7S8OwkneJr4ocALP16u5yk6lWLINFwhHaEqBFMsKDUZofLkGos7+CPzGB3g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.4.tgz", + "integrity": "sha512-XnWYMI7euHlb5a871xPja+Gm7DRCFU+FGRrtS2sMq9N8FvqtpagUy6gD4YOemC5MRk9xbh8+jYMEJbigFQwsgA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.4.tgz", + "integrity": "sha512-qGDAlO0U8xedCcsdRm9oaoQY8DAx/QT7uIxJWhCdx0ceIWX783UC9QSYkdpzAe29wNiVfp24+bZdQmn49o45SQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.4.tgz", + "integrity": "sha512-ru4H6ezD7ysA5EiEK6qkkaEb4modH8CTej6kUy/gQi20u3kB3G7Zn8snXXkeJSCOFKG/rbPPtM/+9Wgas1961w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.4.tgz", + "integrity": "sha512-2W4MO5WQVJnbJaZdvDb9rhBDuFU1nKIepPFpJUBsTh2k1YY2g+ODViaWuyOAjQ5cOP7NvrvLzt3wvHOoiAvc7w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.4.tgz", + "integrity": "sha512-+fxjfuoAmVMCYV5QyjoIpu0cp5DOiOTeqYFk1AVaxGr+/ravWLX89XfQmptsoWcaVy/TGf2hexzbUOrCQIL1CQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.4.tgz", + "integrity": "sha512-jTn8JfHGL4djjFxPuM06LmNUJDsst2jeVlsd9OmIH6zc5sC9K6rIuO4YajXatLUpBmBKl6b35ro1QZocLi+tcA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.4.tgz", + "integrity": "sha512-oCJCJL4pXsoDcP2QZ+JVlPTIRc6266zsIaeJJsWImmF7HO0W8nb6HuSgZlMWxJwaPf8ehbSw8yo0EUw925hKsA==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.4.tgz", + "integrity": "sha512-W69hukhZ3KKNRCaMIEzKvcFye42hh0FE1+YoYaf5+Ikacuftoco6yO/xouz0hc5d5W/s3yBro5jRiuEE/Q5vUw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.4.tgz", + "integrity": "sha512-qiXbGG2jkjXhzXpsFZSR2Xpb8DN/UaxYsbb/STbuR/6fpaDgRmmaq1B/LmtF2wQFOFOSsK2jdE0RZ3a0zHn4QA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.4.tgz", + "integrity": "sha512-nWeM//hxv8mIo6jD7Hu4o48DVmV9pbV6gsKaWU+4NFyqHoPKwrkRiZGLKUhOBk8qNmDmpwFtPKg80Bo/Tn4xiQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.4.tgz", + "integrity": "sha512-s62SQ/vgsRSvMwDkOEfTqfgASF0f26ZNaQuTA6Aok5lrikf89yI2W0gFHvZb2Jpgc6N8JnOKZgCK2iciO3CsxQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.4.tgz", + "integrity": "sha512-J6wGf8TVGbXJq+HH+ttTvrcfNKPbuZecV6KT1B8I18BC5IURUh5kl4Yl5OEP5eFIUoI5BWxCsyYMhFsDx8kekw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.4.tgz", + "integrity": "sha512-zmfrQd/0wu6oJs8Vq8KwY/YtsKSsLtKe/HwAP4Wqy8LhWjeT55fHRAkOhYQ12wI3ayS4Tt12d5CDRD7N96SAYQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.4.tgz", + "integrity": "sha512-qPzHqdj9rfUD+w79dtE07zi/kFwKyCJqplp5K5ygeLTp7jLpAoc16OAH39HSmRC9UpozaecsleI8uAdEj6v2yw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.4.tgz", + "integrity": "sha512-zD6NdeWEByGE9QF9vCrlJ5YQB4oq9q91kPZS37Jwj5hOkvR1lTBSpsKhKDw4IJtbQ35LsTS1HD9DZYGKIshU1Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "26.2.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.2.0.tgz", + "integrity": "sha512-5IviulTZeRNp2vAJ514cc/HUlY5nZ9fCbq9DMyC52BrhFZACo3nI0R7qBxhQmo/d27NFe96ur/b7Wwxklda+kg==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~8.3.0" + } + }, + "node_modules/@vitest/expect": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.7.tgz", + "integrity": "sha512-E8eBXaKibuvH2pSZErOjdVb5vF4PbKYcrnluBTYxEk1l/VhhwZg1kZQsdtjq+CsF5CFydf2Rdkz7jDHKSisi3w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/spy": "3.2.7", + "@vitest/utils": "3.2.7", + "chai": "^5.2.0", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.7.tgz", + "integrity": "sha512-Trr0hYO9CM3Wj6ksWHRhK9IZpIY6wTMO5u/MqXurMxT57sWBaOPEtP3Oq60ihZuh5JsiagKfz95OcxdEP6dBrA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "3.2.7", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.17" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.7.tgz", + "integrity": "sha512-KUHlwqVu0sRlhCdyPdQ/wBoTfRahjUky1MubOmYw9fWfIZy1gNoHpuaaQBPAaMaVYdQYHJLurzj8ECCj5OwTqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.7.tgz", + "integrity": "sha512-sB9y4ovltoQP+WaUPwmSxO9WIg9Ig694Di5PalVPsYHklAdE027mehpWF2SQSVq+k6sFgaivbTjTJwZLSHbedA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "3.2.7", + "pathe": "^2.0.3", + "strip-literal": "^3.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.7.tgz", + "integrity": "sha512-7C+MwShwtBSI5Buwoyg3s/iY1eHL9PKAf+O1wVh/TdnjXUtkoL/9YQtre90i4MtNXM6edP1wJ2zOBpfCyhIS7g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.7", + "magic-string": "^0.30.17", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.7.tgz", + "integrity": "sha512-Q2eQGI6d2L/hBtZ0qNuKcAGid68XK6cv1xsoaIma6PaJhHPoqcEJhYpXZ/5myCMqkNgtP6UKuBhbc0nHKnrkuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^4.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.7.tgz", + "integrity": "sha512-x6BDOd7dyo3PFLY3I9/HJ25X/6OurhGXk2/B9gOZNPF7XDVjeBK4k01lQE5uvDpbuheErh91qYuE1E2OEjK3Rw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.7", + "loupe": "^3.1.4", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/bidi-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", + "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "require-from-string": "^2.0.2" + } + }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/chai": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", + "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/check-error": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", + "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + } + }, + "node_modules/css-tree": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", + "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "mdn-data": "2.27.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, + "node_modules/cssstyle": { + "version": "5.3.7", + "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-5.3.7.tgz", + "integrity": "sha512-7D2EPVltRrsTkhpQmksIu+LxeWAIEk6wRDMJ1qljlv+CKHJM+cJLlfhWIzNA44eAsHXSNe3+vO6DW1yCYx8SuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^4.1.1", + "@csstools/css-syntax-patches-for-csstree": "^1.0.21", + "css-tree": "^3.1.0", + "lru-cache": "^11.2.4" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/data-urls": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-6.0.1.tgz", + "integrity": "sha512-euIQENZg6x8mj3fO6o9+fOW8MimUI4PpD/fZBhJfeioZVy9TUpM4UY7KjQNVZFlqwJ0UdzRDzkycB997HEq1BQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^15.1.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/data-urls/node_modules/whatwg-mimetype": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", + "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, + "node_modules/deep-eql": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/entities": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz", + "integrity": "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, + "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-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "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/html-encoding-sniffer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz", + "integrity": "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-encoding": "^3.1.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/js-tokens": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", + "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/jsdom": { + "version": "27.0.1", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-27.0.1.tgz", + "integrity": "sha512-SNSQteBL1IlV2zqhwwolaG9CwhIhTvVHWg3kTss/cLE7H/X4644mtPQqYvCfsSrGQWt9hSZcgOXX8bOZaMN+kA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/dom-selector": "^6.7.2", + "cssstyle": "^5.3.1", + "data-urls": "^6.0.0", + "decimal.js": "^10.6.0", + "html-encoding-sniffer": "^4.0.0", + "http-proxy-agent": "^7.0.2", + "https-proxy-agent": "^7.0.6", + "is-potential-custom-element-name": "^1.0.1", + "parse5": "^8.0.0", + "rrweb-cssom": "^0.8.0", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^6.0.0", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^8.0.0", + "whatwg-encoding": "^3.1.1", + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^15.1.0", + "ws": "^8.18.3", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/loupe": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", + "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/lru-cache": { + "version": "11.5.2", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz", + "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/mdn-data": { + "version": "2.27.1", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", + "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", + "dev": true, + "license": "CC0-1.0" + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.18", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", + "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/parse5": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz", + "integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^8.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathval": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", + "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.16" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.26", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz", + "integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.17", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/rollup": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.4.tgz", + "integrity": "sha512-RXOqwaPsBGjMNMa4sQjDjHieHEZDFoj/Rdr46l2MU5DfEs16wHJPC2RPTPHWhNl+M3aI472LLqFkFKut4SblOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@napi-rs/lzma-linux-x64-gnu": "1.5.1", + "@rollup/rollup-android-arm-eabi": "4.62.4", + "@rollup/rollup-android-arm64": "4.62.4", + "@rollup/rollup-darwin-arm64": "4.62.4", + "@rollup/rollup-darwin-x64": "4.62.4", + "@rollup/rollup-freebsd-arm64": "4.62.4", + "@rollup/rollup-freebsd-x64": "4.62.4", + "@rollup/rollup-linux-arm-gnueabihf": "4.62.4", + "@rollup/rollup-linux-arm-musleabihf": "4.62.4", + "@rollup/rollup-linux-arm64-gnu": "4.62.4", + "@rollup/rollup-linux-arm64-musl": "4.62.4", + "@rollup/rollup-linux-loong64-gnu": "4.62.4", + "@rollup/rollup-linux-loong64-musl": "4.62.4", + "@rollup/rollup-linux-ppc64-gnu": "4.62.4", + "@rollup/rollup-linux-ppc64-musl": "4.62.4", + "@rollup/rollup-linux-riscv64-gnu": "4.62.4", + "@rollup/rollup-linux-riscv64-musl": "4.62.4", + "@rollup/rollup-linux-s390x-gnu": "4.62.4", + "@rollup/rollup-linux-x64-gnu": "4.62.4", + "@rollup/rollup-linux-x64-musl": "4.62.4", + "@rollup/rollup-openbsd-x64": "4.62.4", + "@rollup/rollup-openharmony-arm64": "4.62.4", + "@rollup/rollup-win32-arm64-msvc": "4.62.4", + "@rollup/rollup-win32-ia32-msvc": "4.62.4", + "@rollup/rollup-win32-x64-gnu": "4.62.4", + "@rollup/rollup-win32-x64-msvc": "4.62.4", + "fsevents": "~2.3.2" + } + }, + "node_modules/rrweb-cssom": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.8.0.tgz", + "integrity": "sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==", + "dev": true, + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "dev": true, + "license": "MIT" + }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, + "node_modules/strip-literal": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.1.0.tgz", + "integrity": "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "js-tokens": "^9.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinypool": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", + "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + } + }, + "node_modules/tinyrainbow": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz", + "integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-4.0.4.tgz", + "integrity": "sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tldts": { + "version": "7.4.10", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.10.tgz", + "integrity": "sha512-GgouD1B+sWwvkaEq8vXC15DjQitxbvs12oIXELpconwm+Tg3zfcEv4jgzq3vtKverDXsg3VI8aRgNL2Nra0Iog==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^7.4.10" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "7.4.10", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.10.tgz", + "integrity": "sha512-KnQjp53ZekKgm/r3l+u8kJGGzYgrWdP8+Mql7a4vijh2WE0IrZWspQj/TpTxDho/YxO+AnOZnIjQcCD+q6iJsw==", + "dev": true, + "license": "MIT" + }, + "node_modules/tough-cookie": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.2.tgz", + "integrity": "sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^7.0.5" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", + "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz", + "integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/vite": { + "version": "7.3.6", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.6.tgz", + "integrity": "sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.27.0 || ^0.28.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite-node": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.2.4.tgz", + "integrity": "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.4.1", + "es-module-lexer": "^1.7.0", + "pathe": "^2.0.3", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vitest": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.7.tgz", + "integrity": "sha512-KrxIJ62Fd89gfysR4WotlgZABiz2dqFPgqGzX7s+CwsqLFomRH7777ZcrOD6+WVAh7khPQP41A+BKbpcJFrdEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/expect": "3.2.7", + "@vitest/mocker": "3.2.7", + "@vitest/pretty-format": "^3.2.7", + "@vitest/runner": "3.2.7", + "@vitest/snapshot": "3.2.7", + "@vitest/spy": "3.2.7", + "@vitest/utils": "3.2.7", + "chai": "^5.2.0", + "debug": "^4.4.1", + "expect-type": "^1.2.1", + "magic-string": "^0.30.17", + "pathe": "^2.0.3", + "picomatch": "^4.0.2", + "std-env": "^3.9.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.2", + "tinyglobby": "^0.2.14", + "tinypool": "^1.1.1", + "tinyrainbow": "^2.0.0", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0", + "vite-node": "3.2.4", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/debug": "^4.1.12", + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "@vitest/browser": "3.2.7", + "@vitest/ui": "3.2.7", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/debug": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/webidl-conversions": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", + "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-encoding": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz", + "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==", + "deprecated": "Use @exodus/bytes instead for a more spec-conformant and faster implementation", + "dev": true, + "license": "MIT", + "dependencies": { + "iconv-lite": "0.6.3" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-mimetype": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", + "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-url": { + "version": "15.1.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-15.1.0.tgz", + "integrity": "sha512-2ytDk0kiEj/yu90JOAp44PVPUkO9+jVhyf+SybKlRHSDlvOOZhdPIrr7xTH64l4WixO2cP+wQIcgujkGBPPz6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/ws": { + "version": "8.21.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.3.tgz", + "integrity": "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + } + } +} diff --git a/admin-web/package.json b/admin-web/package.json new file mode 100644 index 0000000..88e834c --- /dev/null +++ b/admin-web/package.json @@ -0,0 +1,18 @@ +{ + "name": "osg-admin-web", + "private": true, + "version": "0.1.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc --noEmit && vite build", + "test": "vitest run" + }, + "devDependencies": { + "@types/node": "^26.2.0", + "jsdom": "^27.0.1", + "typescript": "^5.9.2", + "vite": "^7.1.2", + "vitest": "^3.2.4" + } +} diff --git a/admin-web/src/api/client.ts b/admin-web/src/api/client.ts new file mode 100644 index 0000000..7ef62f6 --- /dev/null +++ b/admin-web/src/api/client.ts @@ -0,0 +1,232 @@ +import type { + AdminOperator, + AdminOperatorCreateRequest, + AdminOperatorProvisioning, + AdminSecuritySummary, + AdminLoginResponse, + AuditLogEntry, + CreditGrantRequest, + CreditGrantResponse, + LedgerEntry, + Overview, + PageResult, + ReferralOverview, + SessionResponse, + UserDetail, + UserSummary, +} from "./types"; + +const API_BASE = "/v1/admin"; +const REQUEST_TIMEOUT_MS = 12_000; + +interface ApiErrorBody { + code?: string; + error?: { + code?: string; + }; +} + +export class ApiError extends Error { + constructor( + public readonly code: string, + message: string, + public readonly status: number, + ) { + super(message); + this.name = "ApiError"; + } +} + +let csrfToken = ""; + +export function setCsrfToken(token?: string): void { + csrfToken = token?.trim() ?? ""; +} + +function csrfTokenFromCookie(): string { + if (typeof document === "undefined") return ""; + const prefix = "osg_admin_csrf="; + return ( + document.cookie + .split(";") + .map((part) => part.trim()) + .find((part) => part.startsWith(prefix)) + ?.slice(prefix.length) ?? "" + ); +} + +function safeMessage(status: number, code?: string): string { + const messages: Record = { + INVALID_CREDENTIALS: "用户名、密码或动态验证码错误", + INVALID_TOTP: "动态验证码无效或已过期", + VALIDATION_ERROR: "提交内容不符合要求", + USER_NOT_FOUND: "未找到该用户", + INSUFFICIENT_PERMISSION: "无权执行此操作", + IDEMPOTENCY_CONFLICT: "该赠送请求与已有记录冲突,请核对审计日志", + ADMIN_OPERATOR_NOT_FOUND: "未找到该管理员", + ADMIN_USERNAME_CONFLICT: "该管理员用户名已存在", + CANNOT_DISABLE_SELF: "不能停用当前登录的管理员", + LAST_SUPER_ADMIN_REQUIRED: "必须至少保留一名启用的超级管理员", + RATE_LIMITED: "操作过于频繁,请稍后再试", + }; + if (code && messages[code]) return messages[code]; + if (status === 401) return "登录状态已失效,请重新登录"; + if (status === 403) return "无权执行此操作"; + if (status === 409) return "请求冲突,请刷新后重试"; + if (status === 429) return "操作过于频繁,请稍后再试"; + if (status >= 500) return "服务暂时不可用,请稍后再试"; + return "请求失败,请检查后重试"; +} + +async function request( + path: string, + options: RequestInit = {}, +): Promise { + const method = options.method?.toUpperCase() ?? "GET"; + const headers = new Headers(options.headers); + headers.set("Accept", "application/json"); + + if (options.body) headers.set("Content-Type", "application/json"); + const csrfRequired = + !["GET", "HEAD", "OPTIONS"].includes(method) && path !== "/auth/login"; + if (csrfRequired) { + const requestToken = + csrfToken || csrfTokenFromCookie() || headers.get("X-CSRF-Token") || ""; + if (!requestToken) { + throw new ApiError("CSRF_TOKEN_MISSING", "安全令牌缺失,请重新登录", 403); + } + headers.set("X-CSRF-Token", requestToken); + } + + try { + const response = await fetch(`${API_BASE}${path}`, { + ...options, + headers, + credentials: "include", + signal: options.signal ?? AbortSignal.timeout(REQUEST_TIMEOUT_MS), + }); + + if (!response.ok) { + let body: ApiErrorBody = {}; + try { + body = (await response.json()) as ApiErrorBody; + } catch { + // 非 JSON 错误响应不会暴露给界面。 + } + const apiError = new ApiError( + body.code ?? body.error?.code ?? `HTTP_${response.status}`, + safeMessage(response.status, body.code ?? body.error?.code), + response.status, + ); + if ( + response.status === 401 && + !path.startsWith("/auth/") && + typeof window !== "undefined" + ) { + window.dispatchEvent(new Event("admin:unauthorized")); + } + throw apiError; + } + + if (response.status === 204) return undefined as T; + return (await response.json()) as T; + } catch (error) { + if (error instanceof ApiError) throw error; + if (error instanceof DOMException && error.name === "TimeoutError") { + throw new ApiError("REQUEST_TIMEOUT", "请求超时,请稍后再试", 0); + } + throw new ApiError("NETWORK_ERROR", "网络连接失败,请检查网络", 0); + } +} + +function query(params: Record): string { + const search = new URLSearchParams(); + Object.entries(params).forEach(([key, value]) => { + if (value) search.set(key, value); + }); + const result = search.toString(); + return result ? `?${result}` : ""; +} + +export const adminApi = { + session: () => request("/auth/session"), + + login: (username: string, password: string, totpCode: string) => + request("/auth/login", { + method: "POST", + body: JSON.stringify({ username, password, totpCode }), + }), + + logout: () => request("/auth/logout", { method: "POST" }), + + overview: (range: string) => + request(`/overview${query({ range })}`), + + referrals: (range: string) => + request(`/referrals${query({ range })}`), + + users: (search: string, cursor?: string) => + request>( + `/users${query({ q: search.trim(), cursor })}`, + ), + + user: (userId: string) => + request(`/users/${encodeURIComponent(userId)}`), + + ledger: (userId: string, cursor?: string) => + request>( + `/users/${encodeURIComponent(userId)}/ledger${query({ cursor })}`, + ), + + grantCredits: (payload: CreditGrantRequest) => + request("/credits/grants", { + method: "POST", + body: JSON.stringify({ + userId: payload.userId, + amount: payload.amount, + reason: payload.reason, + }), + headers: { "Idempotency-Key": payload.idempotencyKey }, + }), + + auditLogs: (cursor?: string) => + request>(`/audit${query({ cursor })}`), + + operators: (cursor?: string) => + request>(`/operators${query({ cursor })}`), + + operatorSummary: () => + request("/operators/summary"), + + createOperator: (payload: AdminOperatorCreateRequest) => + request("/operators", { + method: "POST", + body: JSON.stringify(payload), + }), + + setOperatorEnabled: (operatorId: string, enabled: boolean) => + request( + `/operators/${encodeURIComponent(operatorId)}/${enabled ? "enable" : "disable"}`, + { method: "POST" }, + ), + + unlockOperator: (operatorId: string) => + request(`/operators/${encodeURIComponent(operatorId)}/unlock`, { + method: "POST", + }), + + resetOperatorCredentials: (operatorId: string, password: string) => + request( + `/operators/${encodeURIComponent(operatorId)}/credentials/reset`, + { + method: "POST", + body: JSON.stringify({ password }), + }, + ), + + revokeOperatorSessions: (operatorId: string) => + request( + `/operators/${encodeURIComponent(operatorId)}/sessions/revoke`, + { method: "POST" }, + ), +}; diff --git a/admin-web/src/api/types.ts b/admin-web/src/api/types.ts new file mode 100644 index 0000000..de380a4 --- /dev/null +++ b/admin-web/src/api/types.ts @@ -0,0 +1,161 @@ +export type AdminRole = "SUPER_ADMIN" | "SUPPORT" | "ANALYST"; + +export type AuthState = + | { status: "anonymous" } + | { status: "authenticated"; operatorName: string; role: AdminRole }; + +export interface SessionResponse { + authenticated: boolean; + operatorName?: string; + role?: AdminRole; +} + +export interface AdminLoginResponse { + operatorName: string; + role: AdminRole; + csrfToken: string; +} + +export interface TrendPoint { + date: string; + registrations: number; + creditsUsed: number; +} + +export interface Overview { + totalUsers: number; + activeUsers: number; + newUsers: number; + totalCreditBalance: number; + creditsGranted: number; + creditsUsed: number; + trend: TrendPoint[]; + usage: UserUsageAggregate[]; +} + +export interface FunnelStep { + label: string; + count: number; +} + +export interface ReferralRankingItem { + userId: string; + invited: number; + qualified: number; + creditsEarned: number; +} + +export interface ReferralOverview { + pendingBindings: number; + ineligibleBindings: number; + funnel: FunnelStep[]; + ranking: ReferralRankingItem[]; +} + +export interface UserSummary { + userId: string; + displayName: string; + maskedEmail?: string; + status: "active" | "suspended" | "closed"; + creditBalance: number; + createdAt: string; +} + +export interface UserDetail extends UserSummary { + lastActiveAt?: string; + qualifiedUsage: boolean; + referralCode?: string; + referredByUserId?: string; + usage?: UserUsageAggregate[]; + referral?: UserReferral; +} + +export interface UserUsageAggregate { + kind: string; + requests: number; + chargedCredits: number; + asrMillis: number; + inputTokens: number; + outputTokens: number; +} + +export interface UserReferral { + inviterUserId?: string; + invitedUsers: number; + rewardedInvites: number; +} + +export interface PageResult { + items: T[]; + nextCursor?: string; +} + +export type LedgerEntryType = + | "grant" + | "reserve" + | "settle" + | "refund" + | "adjustment"; + +export interface LedgerEntry { + entryId: string; + type: LedgerEntryType; + amount: number; + balanceAfter: number; + reasonCode: string; + createdAt: string; +} + +export interface CreditGrantRequest { + userId: string; + amount: number; + reason: string; + idempotencyKey: string; +} + +export interface CreditGrantResponse { + transactionId: string; + balanceAfter: number; +} + +export interface AuditLogEntry { + auditId: string; + operatorName: string; + action: string; + targetType: string; + targetId: string; + requestId?: string; + result: "success" | "rejected"; + createdAt: string; +} + +export interface AdminOperator { + operatorId: string; + username: string; + role: AdminRole; + enabled: boolean; + failedLoginCount: number; + lockedUntil?: string; + lastLoginAt?: string; + createdAt: string; + updatedAt: string; +} + +export interface AdminOperatorProvisioning { + operatorId: string; + operator?: AdminOperator; + totpSecret: string; + otpauthUri: string; +} + +export interface AdminSecuritySummary { + enabledOperators: number; + lockedOperators: number; + activeSessions: number; +} + +export interface AdminOperatorCreateRequest { + username: string; + password: string; + role: AdminRole; +} diff --git a/admin-web/src/app.ts b/admin-web/src/app.ts new file mode 100644 index 0000000..949a43c --- /dev/null +++ b/admin-web/src/app.ts @@ -0,0 +1,289 @@ +import { adminApi, ApiError, setCsrfToken } from "./api/client"; +import type { AdminRole, AuthState } from "./api/types"; +import { renderLoading, showToast } from "./components/ui"; +import { escapeHtml } from "./lib/format"; +import { renderAudit } from "./pages/audit"; +import { renderPasswordLogin } from "./pages/auth"; +import { renderCredits } from "./pages/credits"; +import { renderOverview } from "./pages/overview"; +import { renderReferrals } from "./pages/referrals"; +import { renderSecurity } from "./pages/security"; +import { renderUsers } from "./pages/users"; + +type Route = + | "overview" + | "referrals" + | "users" + | "credits" + | "audit" + | "security"; + +interface RouteDefinition { + id: Route; + label: string; + icon: string; + roles: AdminRole[]; +} + +const ALL_ROLES: AdminRole[] = ["SUPER_ADMIN", "SUPPORT", "ANALYST"]; +const SUPPORT_ROLES: AdminRole[] = ["SUPER_ADMIN", "SUPPORT"]; +const SUPER_ADMIN_ONLY: AdminRole[] = ["SUPER_ADMIN"]; + +const routes: RouteDefinition[] = [ + { + id: "overview", + label: "运营总览", + roles: ALL_ROLES, + icon: '', + }, + { + id: "referrals", + label: "裂变分析", + roles: ALL_ROLES, + icon: + '', + }, + { + id: "users", + label: "用户查询", + roles: SUPPORT_ROLES, + icon: + '', + }, + { + id: "credits", + label: "积分流水", + roles: SUPPORT_ROLES, + icon: + '', + }, + { + id: "audit", + label: "审计日志", + roles: SUPER_ADMIN_ONLY, + icon: + '', + }, + { + id: "security", + label: "安全中心", + roles: SUPER_ADMIN_ONLY, + icon: + '', + }, +]; + +export class AdminApp { + private auth: AuthState = { status: "anonymous" }; + private renderVersion = 0; + + constructor(private readonly root: HTMLElement) { + window.addEventListener("hashchange", () => void this.renderRoute()); + window.addEventListener("admin:unauthorized", () => { + setCsrfToken(); + this.auth = { status: "anonymous" }; + this.render(); + showToast("登录状态已失效,请重新登录", "error"); + }); + } + + async start(): Promise { + renderLoading(this.root, "检查登录状态"); + try { + const session = await adminApi.session(); + if (session.authenticated && session.operatorName && session.role) { + this.auth = { + status: "authenticated", + operatorName: session.operatorName, + role: session.role, + }; + } + } catch (error) { + if (!(error instanceof ApiError) || error.status !== 401) { + showToast("暂时无法确认登录状态", "error"); + } + } + this.render(); + } + + private render(): void { + if (this.auth.status === "anonymous") { + renderPasswordLogin(this.root, { onChange: (state) => this.setAuth(state) }); + return; + } + this.renderShell(); + void this.renderRoute(); + } + + private setAuth(state: AuthState): void { + this.auth = state; + this.render(); + } + + private currentRoute(): Route { + const value = window.location.hash.replace(/^#\/?/, "") as Route; + return this.availableRoutes().some((route) => route.id === value) + ? value + : "overview"; + } + + private availableRoutes(): RouteDefinition[] { + if (this.auth.status !== "authenticated") return []; + const role = this.auth.role; + return routes.filter((route) => route.roles.includes(role)); + } + + private renderShell(): void { + if (this.auth.status !== "authenticated") return; + this.root.innerHTML = ` + +
+ +
+
+ + 运营总览 +
+
+
+ +
+ `; + + const sidebar = this.root.querySelector("[data-sidebar]"); + const menuButton = + this.root.querySelector("[data-menu]"); + const mobileLayout = window.matchMedia("(max-width: 1023px)"); + const syncSidebarState = () => { + if (!sidebar) return; + sidebar.inert = + mobileLayout.matches && !sidebar.classList.contains("sidebar--open"); + }; + const closeMenu = (restoreFocus = false) => { + sidebar?.classList.remove("sidebar--open"); + menuButton?.setAttribute("aria-expanded", "false"); + syncSidebarState(); + if (restoreFocus) menuButton?.focus(); + }; + const openMenu = () => { + sidebar?.classList.add("sidebar--open"); + menuButton?.setAttribute("aria-expanded", "true"); + syncSidebarState(); + sidebar + ?.querySelector('a[aria-current="page"], a') + ?.focus(); + }; + menuButton?.addEventListener("click", openMenu); + this.root + .querySelector("[data-scrim]") + ?.addEventListener("click", () => closeMenu(true)); + this.root.querySelectorAll("[data-route]").forEach((link) => { + link.addEventListener("click", () => closeMenu()); + }); + sidebar?.addEventListener("keydown", (event) => { + if (event.key === "Escape") closeMenu(true); + }); + mobileLayout.addEventListener("change", syncSidebarState); + syncSidebarState(); + this.root + .querySelector("[data-logout]") + ?.addEventListener("click", () => void this.logout()); + } + + private async renderRoute(): Promise { + if (this.auth.status !== "authenticated") return; + const route = this.currentRoute(); + this.root.querySelectorAll("[data-route]").forEach((link) => { + const active = link.dataset.route === route; + link.classList.toggle("active", active); + if (active) { + link.setAttribute("aria-current", "page"); + } else { + link.removeAttribute("aria-current"); + } + }); + const routeDefinition = routes.find((item) => item.id === route); + document.title = `${routeDefinition?.label ?? "管理中心"} · OSG`; + const mobileTitle = + this.root.querySelector("[data-mobile-title]"); + if (mobileTitle) mobileTitle.textContent = routeDefinition?.label ?? "管理中心"; + + const content = this.root.querySelector("[data-content]"); + if (!content) return; + const version = ++this.renderVersion; + const stage = document.createElement("div"); + stage.className = "page-stage"; + renderLoading(content); + + if (route === "overview") await renderOverview(stage); + if (route === "referrals") await renderReferrals(stage); + if (route === "users") renderUsers(stage, this.auth.role); + if (route === "credits") renderCredits(stage); + if (route === "audit") await renderAudit(stage); + if (route === "security") { + await renderSecurity(stage, this.auth.operatorName); + } + + if (version === this.renderVersion) { + content.replaceChildren(stage); + const heading = content.querySelector("h1"); + if (heading) { + heading.tabIndex = -1; + heading.focus({ preventScroll: true }); + } else { + content.focus({ preventScroll: true }); + } + } + } + + private async logout(): Promise { + try { + await adminApi.logout(); + } catch { + // 即使服务端退出失败,也立即清理前端认证状态。 + } finally { + setCsrfToken(); + this.auth = { status: "anonymous" }; + window.history.replaceState(null, "", window.location.pathname); + this.render(); + } + } +} + +function roleLabel(role: AdminRole): string { + const labels: Record = { + SUPER_ADMIN: "超级管理员", + SUPPORT: "支持人员", + ANALYST: "分析员", + }; + return labels[role]; +} diff --git a/admin-web/src/components/charts.ts b/admin-web/src/components/charts.ts new file mode 100644 index 0000000..f11dd6c --- /dev/null +++ b/admin-web/src/components/charts.ts @@ -0,0 +1,80 @@ +import type { FunnelStep, TrendPoint } from "../api/types"; +import { escapeHtml, formatNumber } from "../lib/format"; + +export function trendChart(points: TrendPoint[]): string { + if (points.length === 0) return '
暂无趋势数据
'; + + const width = 720; + const height = 240; + const padding = 28; + const values = points.map((point) => point.registrations); + const max = Math.max(...values, 1); + const step = points.length > 1 ? (width - padding * 2) / (points.length - 1) : 0; + const coordinates = points.map((point, index) => ({ + x: points.length === 1 ? width / 2 : padding + index * step, + y: height - padding - (point.registrations / max) * (height - padding * 2), + point, + })); + const polyline = coordinates.map(({ x, y }) => `${x},${y}`).join(" "); + const midpoint = Math.ceil(max / 2); + + return ` +
+ + 新增用户趋势 + 横轴为 UTC 日期,纵轴为每日新增用户数。图表后提供完整数据表。 + + + + ${formatNumber(max)} + ${formatNumber(midpoint)} + 0 + + ${coordinates + .map( + ({ x, y, point }) => ` + + ${escapeHtml(point.date)}:${formatNumber(point.registrations)} 位新增用户 + + `, + ) + .join("")} + +
+ + + + + ${points + .map( + (point) => + ``, + ) + .join("")} + +
新增用户与积分消耗趋势完整数据
UTC 日期新增用户消耗积分
${escapeHtml(point.date)}${formatNumber(point.registrations)}${formatNumber(point.creditsUsed)}
+ `; +} + +export function funnelChart(steps: FunnelStep[]): string { + if (steps.length === 0) return '
暂无漏斗数据
'; + const max = Math.max(...steps.map((step) => step.count), 1); + + return ` +
+ ${steps + .map((step, index) => { + return ` +
+
+ ${escapeHtml(step.label)} + ${formatNumber(step.count)} +
+ +
+ `; + }) + .join("")} +
+ `; +} diff --git a/admin-web/src/components/ui.ts b/admin-web/src/components/ui.ts new file mode 100644 index 0000000..eb14f7e --- /dev/null +++ b/admin-web/src/components/ui.ts @@ -0,0 +1,66 @@ +import { ApiError } from "../api/client"; +import { escapeHtml } from "../lib/format"; + +export function renderLoading(container: HTMLElement, label = "正在加载"): void { + container.innerHTML = ` +
+ +

${escapeHtml(label)}…

+
+ `; +} + +export function renderError( + container: HTMLElement, + error: unknown, + retry?: () => void, +): void { + const message = + error instanceof ApiError ? error.message : "出现未知错误,请稍后重试"; + container.innerHTML = ` + + `; + container.querySelector("[data-retry]")?.addEventListener( + "click", + () => retry?.(), + ); +} + +export function renderEmpty(message: string): string { + return `

${escapeHtml(message)}

`; +} + +export function showToast(message: string, tone: "success" | "error"): void { + const toast = document.createElement("div"); + toast.className = `toast toast--${tone}`; + toast.setAttribute("role", tone === "error" ? "alert" : "status"); + toast.textContent = message; + document.body.append(toast); + requestAnimationFrame(() => toast.classList.add("toast--visible")); + window.setTimeout(() => { + toast.classList.remove("toast--visible"); + window.setTimeout(() => toast.remove(), 180); + }, 3_200); +} + +export function setButtonBusy( + button: HTMLButtonElement, + busy: boolean, + busyLabel = "处理中…", +): void { + if (busy) { + button.dataset.label = button.textContent ?? ""; + button.textContent = busyLabel; + button.disabled = true; + button.setAttribute("aria-busy", "true"); + } else { + button.textContent = button.dataset.label ?? button.textContent; + button.disabled = false; + button.removeAttribute("aria-busy"); + } +} diff --git a/admin-web/src/lib/format.ts b/admin-web/src/lib/format.ts new file mode 100644 index 0000000..0cbe780 --- /dev/null +++ b/admin-web/src/lib/format.ts @@ -0,0 +1,54 @@ +const dateTimeFormatter = new Intl.DateTimeFormat("zh-CN", { + year: "numeric", + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + hour12: false, +}); + +const numberFormatter = new Intl.NumberFormat("zh-CN"); + +export function formatDateTime(value?: string): string { + if (!value) return "—"; + const date = new Date(value); + return Number.isNaN(date.getTime()) ? "—" : dateTimeFormatter.format(date); +} + +export function formatNumber(value: number): string { + return numberFormatter.format(value); +} + +export function formatSignedCredits(value: number): string { + return `${value > 0 ? "+" : ""}${formatNumber(value)}`; +} + +export function escapeHtml(value: unknown): string { + return String(value ?? "") + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """) + .replaceAll("'", "'"); +} + +export function statusLabel(status: string): string { + const labels: Record = { + active: "正常", + suspended: "已停用", + closed: "已关闭", + success: "成功", + rejected: "已拒绝", + grant: "赠送", + reserve: "预留", + settle: "结算", + refund: "退还", + adjustment: "调整", + }; + return labels[status] ?? status; +} + +export function createIdempotencyKey(): string { + return globalThis.crypto?.randomUUID?.() ?? + `grant-${Date.now()}-${Math.random().toString(36).slice(2)}`; +} diff --git a/admin-web/src/main.ts b/admin-web/src/main.ts new file mode 100644 index 0000000..02beda0 --- /dev/null +++ b/admin-web/src/main.ts @@ -0,0 +1,7 @@ +import "./styles.css"; +import { AdminApp } from "./app"; + +const root = document.querySelector("#app"); +if (!root) throw new Error("应用挂载节点不存在"); + +void new AdminApp(root).start(); diff --git a/admin-web/src/pages/audit.ts b/admin-web/src/pages/audit.ts new file mode 100644 index 0000000..1a53706 --- /dev/null +++ b/admin-web/src/pages/audit.ts @@ -0,0 +1,99 @@ +import { adminApi, ApiError } from "../api/client"; +import type { AuditLogEntry } from "../api/types"; +import { + renderEmpty, + renderError, + renderLoading, + setButtonBusy, + showToast, +} from "../components/ui"; +import { escapeHtml, formatDateTime, statusLabel } from "../lib/format"; + +export async function renderAudit(container: HTMLElement): Promise { + renderLoading(container, "加载审计日志"); + try { + const page = await adminApi.auditLogs(); + container.innerHTML = ` +
+
+
安全与合规
+

审计日志

+

追踪管理员操作、目标和执行结果。

+
+
+
+ ${ + page.items.length === 0 + ? renderEmpty("暂无审计记录") + : ` +
+ + + + ${auditRows(page.items)} +
管理员审计事件
时间操作员操作目标请求 ID结果
+
+ ${ + page.nextCursor + ? '
' + : "" + } + ` + } +
+ `; + if (page.nextCursor) bindAuditPagination(container, page.nextCursor); + } catch (error) { + renderError(container, error, () => void renderAudit(container)); + } +} + +function auditRows(items: AuditLogEntry[]): string { + return items + .map( + (entry) => ` + + ${formatDateTime(entry.createdAt)} + ${escapeHtml(entry.operatorName)} + ${escapeHtml(entry.action)} + + ${escapeHtml(entry.targetType)} +
${escapeHtml(entry.targetId)}
+ + ${escapeHtml(entry.requestId || "—")} + ${statusLabel(entry.result)} + + `, + ) + .join(""); +} + +function bindAuditPagination(container: HTMLElement, initialCursor: string): void { + const button = + container.querySelector("[data-audit-more]"); + const body = container.querySelector("[data-audit-body]"); + if (!button || !body) return; + let cursor: string | undefined = initialCursor; + + button.addEventListener("click", async () => { + if (!cursor) return; + setButtonBusy(button, true, "加载中…"); + try { + const page = await adminApi.auditLogs(cursor); + body.insertAdjacentHTML("beforeend", auditRows(page.items)); + cursor = page.nextCursor; + if (!cursor) { + button.closest(".pagination-actions")?.remove(); + } else { + setButtonBusy(button, false); + button.focus(); + } + } catch (error) { + showToast( + error instanceof ApiError ? error.message : "加载审计记录失败", + "error", + ); + setButtonBusy(button, false); + } + }); +} diff --git a/admin-web/src/pages/auth.ts b/admin-web/src/pages/auth.ts new file mode 100644 index 0000000..c4aa03f --- /dev/null +++ b/admin-web/src/pages/auth.ts @@ -0,0 +1,83 @@ +import { adminApi, ApiError, setCsrfToken } from "../api/client"; +import type { AuthState } from "../api/types"; +import { setButtonBusy } from "../components/ui"; + +interface AuthCallbacks { + onChange: (state: AuthState) => void; +} + +function authFrame(content: string): string { + return ` +
+
+
OSG
+

安全、克制、可追溯的运营管理

+
+
${content}
+
+ `; +} + +export function renderPasswordLogin( + root: HTMLElement, + callbacks: AuthCallbacks, +): void { + root.innerHTML = authFrame(` +
运营后台
+

管理员登录

+

使用管理员凭据和认证器动态验证码登录。凭据不会保存到浏览器。

+
+ + + + + +
+ `); + + const form = root.querySelector("[data-login-form]"); + form?.addEventListener("submit", async (event) => { + event.preventDefault(); + const button = form.querySelector("button"); + const errorNode = form.querySelector("[data-error]"); + const username = new FormData(form).get("username")?.toString().trim() ?? ""; + const password = new FormData(form).get("password")?.toString() ?? ""; + const totpCode = new FormData(form).get("totpCode")?.toString().trim() ?? ""; + if ( + username.length < 3 || + password.length < 12 || + !/^\d{6}$/.test(totpCode) || + !button || + !errorNode + ) { + if (errorNode) errorNode.textContent = "请输入有效的用户名、密码和 6 位动态验证码"; + return; + } + + setButtonBusy(button, true, "验证中…"); + errorNode.textContent = ""; + try { + const response = await adminApi.login(username, password, totpCode); + form.reset(); + setCsrfToken(response.csrfToken); + callbacks.onChange({ + status: "authenticated", + operatorName: response.operatorName, + role: response.role, + }); + } catch (error) { + errorNode.textContent = + error instanceof ApiError ? error.message : "验证码验证失败"; + setButtonBusy(button, false); + } + }); +} diff --git a/admin-web/src/pages/credits.ts b/admin-web/src/pages/credits.ts new file mode 100644 index 0000000..1846abb --- /dev/null +++ b/admin-web/src/pages/credits.ts @@ -0,0 +1,78 @@ +import { adminApi } from "../api/client"; +import { renderEmpty, renderError, renderLoading } from "../components/ui"; +import { + escapeHtml, + formatDateTime, + formatNumber, + formatSignedCredits, + statusLabel, +} from "../lib/format"; + +export function renderCredits(container: HTMLElement): void { + container.innerHTML = ` +
+
+
积分账本
+

积分流水

+

按内部用户 ID 查询不可变积分流水。

+
+
+
+
+ + +
+
${renderEmpty("输入内部用户 ID 开始查询")}
+
+ `; + + const form = container.querySelector("[data-ledger-form]"); + form?.addEventListener("submit", (event) => { + event.preventDefault(); + const userId = new FormData(form).get("userId")?.toString().trim() ?? ""; + if (userId) void loadLedger(container, userId); + }); +} + +async function loadLedger(container: HTMLElement, userId: string): Promise { + const results = container.querySelector("[data-ledger-results]"); + if (!results) return; + renderLoading(results, "加载积分流水"); + try { + const page = await adminApi.ledger(userId); + results.innerHTML = + page.items.length === 0 + ? renderEmpty("该用户暂无积分流水") + : ` +
用户 ${escapeHtml(userId)} · ${formatNumber(page.items.length)} 条记录
+
+ + + + + ${page.items + .map( + (entry) => ` + + + + + + + + + `, + ) + .join("")} + +
用户 ${escapeHtml(userId)} 的积分流水
时间流水号类型变动结余原因
${formatDateTime(entry.createdAt)}${escapeHtml(entry.entryId)}${statusLabel(entry.type)}${formatSignedCredits(entry.amount)}${formatNumber(entry.balanceAfter)}${escapeHtml(entry.reasonCode)}
+
+ `; + } catch (error) { + renderError(results, error, () => void loadLedger(container, userId)); + } +} diff --git a/admin-web/src/pages/overview.ts b/admin-web/src/pages/overview.ts new file mode 100644 index 0000000..6af1c01 --- /dev/null +++ b/admin-web/src/pages/overview.ts @@ -0,0 +1,102 @@ +import { adminApi } from "../api/client"; +import { trendChart } from "../components/charts"; +import { renderError, renderLoading } from "../components/ui"; +import { escapeHtml, formatNumber } from "../lib/format"; + +export async function renderOverview( + container: HTMLElement, + range = "30d", +): Promise { + renderLoading(container, "加载总览"); + try { + const data = await adminApi.overview(range); + container.innerHTML = ` +
+
+
核心指标
+

运营总览

+

快速掌握增长与积分消耗情况。

+
+
+ 统计周期 + ${rangeOption("7d", "7 天", range)} + ${rangeOption("30d", "30 天", range)} + ${rangeOption("90d", "90 天", range)} +
+
+
+ ${metric("用户总数", data.totalUsers, "累计账户")} + ${metric("活跃用户", data.activeUsers, "当前周期")} + ${metric("新增用户", data.newUsers, "当前周期")} + ${metric("积分余额", data.totalCreditBalance, "所有账户")} + ${metric("赠送积分", data.creditsGranted, "整数积分")} + ${metric("消耗积分", data.creditsUsed, "已结算")} +
+
+
+

新增用户趋势

按 UTC 日期统计注册数

+ 新增用户 +
+ ${trendChart(data.trend)} +
+
+

使用汇总

按类型统计,不包含用户内容

+ ${ + data.usage.length === 0 + ? '

当前周期暂无使用记录

' + : ` +
+ + + + + ${data.usage + .map( + (item) => ` + + + + + + + + + `, + ) + .join("")} + +
当前周期使用统计
类型请求消耗积分语音毫秒输入 Token输出 Token
${escapeHtml(item.kind)}${formatNumber(item.requests)}${formatNumber(item.chargedCredits)}${formatNumber(item.asrMillis)}${formatNumber(item.inputTokens)}${formatNumber(item.outputTokens)}
+
+ ` + } +
+ `; + + container.querySelectorAll("[data-range]").forEach((input) => { + input.addEventListener("change", (event) => { + void renderOverview(container, (event.target as HTMLInputElement).value); + }); + }); + } catch (error) { + renderError(container, error, () => void renderOverview(container, range)); + } +} + +function rangeOption(value: string, label: string, selected: string): string { + return ` + + `; +} + +function metric(label: string, value: number, hint: string): string { + return ` +
+

${label}

+ ${formatNumber(value)} + ${hint} +
+ `; +} diff --git a/admin-web/src/pages/referrals.ts b/admin-web/src/pages/referrals.ts new file mode 100644 index 0000000..6e93e8f --- /dev/null +++ b/admin-web/src/pages/referrals.ts @@ -0,0 +1,97 @@ +import { adminApi } from "../api/client"; +import { funnelChart } from "../components/charts"; +import { renderEmpty, renderError, renderLoading } from "../components/ui"; +import { escapeHtml, formatNumber } from "../lib/format"; + +export async function renderReferrals( + container: HTMLElement, + range = "30d", +): Promise { + renderLoading(container, "加载裂变数据"); + try { + const data = await adminApi.referrals(range); + container.innerHTML = ` +
+
+
增长分析
+

裂变与排行

+

奖励以有效使用为前提,不以注册量代替真实转化。

+
+
+ 统计周期 + ${rangeOption("7d", "7 天", range)} + ${rangeOption("30d", "30 天", range)} + ${rangeOption("90d", "90 天", range)} +
+
+
+
+

待资格确认

+ ${formatNumber(data.pendingBindings)} + 等待有效使用 +
+
+

不符合奖励条件

+ ${formatNumber(data.ineligibleBindings)} + 未发放积分 +
+
+
+
+

裂变漏斗

从分享至有效使用

+ ${funnelChart(data.funnel)} +
+
+

邀请排行

按有效邀请数排序

+ ${ + data.ranking.length === 0 + ? renderEmpty("当前周期暂无排行数据") + : ` +
+ + + + + ${data.ranking + .map( + (item, index) => ` + + + + + + + + `, + ) + .join("")} + +
有效邀请用户排行
名次用户 ID邀请有效奖励积分
${index + 1}${escapeHtml(item.userId)}${formatNumber(item.invited)}${formatNumber(item.qualified)}${formatNumber(item.creditsEarned)}
+
+ ` + } +
+
+ `; + + container.querySelectorAll("[data-range]").forEach((input) => { + input.addEventListener("change", (event) => { + void renderReferrals( + container, + (event.target as HTMLInputElement).value, + ); + }); + }); + } catch (error) { + renderError(container, error, () => void renderReferrals(container, range)); + } +} + +function rangeOption(value: string, label: string, selected: string): string { + return ` + + `; +} diff --git a/admin-web/src/pages/security.ts b/admin-web/src/pages/security.ts new file mode 100644 index 0000000..4848978 --- /dev/null +++ b/admin-web/src/pages/security.ts @@ -0,0 +1,585 @@ +import { adminApi, ApiError } from "../api/client"; +import type { + AdminOperator, + AdminOperatorProvisioning, + AdminRole, + AdminSecuritySummary, +} from "../api/types"; +import { + renderError, + renderLoading, + setButtonBusy, + showToast, +} from "../components/ui"; +import { escapeHtml, formatDateTime } from "../lib/format"; + +export async function renderSecurity( + container: HTMLElement, + currentUsername: string, +): Promise { + renderLoading(container, "加载安全中心"); + try { + const [page, summary] = await Promise.all([ + adminApi.operators(), + adminApi.operatorSummary(), + ]); + const operators = [...page.items]; + container.innerHTML = securityTemplate( + operators, + currentUsername, + summary, + page.nextCursor, + ); + bindSecurityActions(container, operators, currentUsername); + bindOperatorPagination( + container, + operators, + currentUsername, + page.nextCursor, + ); + } catch (error) { + renderError(container, error, () => { + void renderSecurity(container, currentUsername); + }); + } +} + +function securityTemplate( + operators: AdminOperator[], + currentUsername: string, + summary: AdminSecuritySummary, + nextCursor?: string, +): string { + return ` +
+
+
访问控制
+

安全中心

+

管理运营人员、角色、登录锁定、会话与双重认证。

+
+ +
+ + + +
+ ${securityMetric("已启用管理员", summary.enabledOperators, "可登录账户")} + ${securityMetric("已锁定账户", summary.lockedOperators, "等待解锁或锁定到期")} + ${securityMetric("活动会话", summary.activeSessions, "尚未过期且未撤销")} +
+ +
+
+

管理员

已加载 ${operators.length} 个账户

+
+
+ + + + + + + ${operators.map((operator) => operatorRow(operator, currentUsername)).join("")} + +
管理员账户与安全状态
管理员角色状态最近登录操作
+
+ ${ + nextCursor + ? '
' + : "" + } +

+
+ + ${operatorFormDialog()} + ${credentialResetDialog()} + `; +} + +function securityMetric(label: string, value: number, hint: string): string { + return ` +
+ ${escapeHtml(label)} + ${value.toLocaleString("zh-CN")} + ${escapeHtml(hint)} +
+ `; +} + +function operatorRow( + operator: AdminOperator, + currentUsername: string, +): string { + const current = operator.username === currentUsername; + const locked = Boolean( + operator.lockedUntil && new Date(operator.lockedUntil).getTime() > Date.now(), + ); + return ` + + + ${escapeHtml(operator.username)} ${current ? '当前' : ""} +
${escapeHtml(operator.operatorId)}
+ + ${roleLabel(operator.role)} + + ${operator.enabled ? "已启用" : "已停用"} + ${locked ? '已锁定' : ""} + + ${formatDateTime(operator.lastLoginAt)} + +
+ ${ + locked + ? '' + : "" + } + + + ${ + operator.enabled + ? `` + : '' + } +
+ + + `; +} + +function bindOperatorPagination( + container: HTMLElement, + operators: AdminOperator[], + currentUsername: string, + initialCursor?: string, +): void { + const button = + container.querySelector("[data-operator-more]"); + const body = + container.querySelector("[data-operator-body]"); + const count = container.querySelector("[data-operator-count]"); + const status = container.querySelector("[data-operator-status]"); + if (!button || !body || !initialCursor) return; + let cursor: string | undefined = initialCursor; + + button.addEventListener("click", async () => { + if (!cursor) return; + setButtonBusy(button, true, "加载中…"); + try { + const page = await adminApi.operators(cursor); + body.insertAdjacentHTML( + "beforeend", + page.items.map((operator) => operatorRow(operator, currentUsername)).join(""), + ); + operators.push(...page.items); + cursor = page.nextCursor; + if (count) count.textContent = `已加载 ${operators.length} 个账户`; + if (status) { + status.textContent = cursor + ? `已加载 ${page.items.length} 个更多管理员` + : `已加载 ${page.items.length} 个管理员,全部账户已加载`; + } + if (!cursor) { + button.closest(".pagination-actions")?.remove(); + } else { + setButtonBusy(button, false); + button.focus(); + } + } catch (error) { + showToast(errorMessage(error, "加载管理员失败"), "error"); + setButtonBusy(button, false); + button.focus(); + } + }); +} + +function operatorFormDialog(): string { + return ` + +
+
访问授权
+

添加管理员

+

创建后,TOTP 密钥只显示一次。

+ + + + + +
+ + +
+
+
+ `; +} + +function credentialResetDialog(): string { + return ` + +
+
高风险操作
+

重置登录凭据

+

将为 重置密码和 TOTP,并立即撤销其全部会话。

+ + + +
+ + +
+
+
+ `; +} + +function bindSecurityActions( + container: HTMLElement, + operators: AdminOperator[], + currentUsername: string, +): void { + const createDialog = + container.querySelector("[data-operator-dialog]"); + const createForm = + container.querySelector("[data-operator-form]"); + container + .querySelector("[data-create-operator]") + ?.addEventListener("click", () => { + createForm?.reset(); + clearError(createForm, "[data-operator-error]"); + createDialog?.showModal(); + createForm?.querySelector('input[name="username"]')?.focus(); + }); + container + .querySelector("[data-close-operator]") + ?.addEventListener("click", () => createDialog?.close()); + createForm?.addEventListener("submit", (event) => { + event.preventDefault(); + void createOperator(container, createDialog, createForm, currentUsername); + }); + + const resetDialog = + container.querySelector("[data-reset-dialog]"); + const resetForm = container.querySelector("[data-reset-form]"); + container + .querySelector("[data-close-reset]") + ?.addEventListener("click", () => resetDialog?.close()); + resetForm?.addEventListener("submit", (event) => { + event.preventDefault(); + void resetCredentials(container, resetDialog, resetForm, currentUsername); + }); + + container.querySelector("tbody")?.addEventListener("click", (event) => { + const button = (event.target as HTMLElement).closest( + "[data-operator-action]", + ); + const operatorId = button?.closest("[data-operator-id]")?.dataset + .operatorId; + const operator = operators.find((item) => item.operatorId === operatorId); + if (!button || !operator) return; + void handleOperatorAction( + container, + button, + operator, + resetDialog, + resetForm, + currentUsername, + ); + }); +} + +async function createOperator( + container: HTMLElement, + dialog: HTMLDialogElement | null, + form: HTMLFormElement, + currentUsername: string, +): Promise { + const data = new FormData(form); + const username = data.get("username")?.toString().trim() ?? ""; + const role = data.get("role")?.toString() as AdminRole; + const password = data.get("password")?.toString() ?? ""; + const confirmation = data.get("passwordConfirmation")?.toString() ?? ""; + const errorNode = form.querySelector("[data-operator-error]"); + const submit = form.querySelector('button[type="submit"]'); + if ( + !/^[A-Za-z0-9][A-Za-z0-9._@-]{2,63}$/.test(username) || + !["SUPER_ADMIN", "SUPPORT", "ANALYST"].includes(role) || + password.length < 12 || + password !== confirmation || + !submit + ) { + if (errorNode) errorNode.textContent = "请检查用户名、角色及两次输入的密码"; + return; + } + + setButtonBusy(submit, true, "创建中…"); + clearError(form, "[data-operator-error]"); + try { + const provisioning = await adminApi.createOperator({ + username, + password, + role, + }); + form.reset(); + dialog?.close(); + await showProvisioning(container, provisioning, "管理员已创建"); + await renderSecurity(container, currentUsername); + } catch (error) { + if (errorNode) errorNode.textContent = errorMessage(error, "创建管理员失败"); + setButtonBusy(submit, false); + } +} + +async function handleOperatorAction( + container: HTMLElement, + button: HTMLButtonElement, + operator: AdminOperator, + resetDialog: HTMLDialogElement | null, + resetForm: HTMLFormElement | null, + currentUsername: string, +): Promise { + const action = button.dataset.operatorAction; + if (action === "reset" && resetDialog && resetForm) { + resetForm.reset(); + resetForm.dataset.operatorId = operator.operatorId; + const username = resetForm.querySelector("[data-reset-username]"); + if (username) username.textContent = operator.username; + clearError(resetForm, "[data-reset-error]"); + resetDialog.showModal(); + resetForm.querySelector('input[name="password"]')?.focus(); + return; + } + + const confirmation = actionConfirmation(action, operator.username); + if (!confirmation || !(await confirmAction(container, confirmation))) return; + + setButtonBusy(button, true); + try { + if (action === "enable") { + await adminApi.setOperatorEnabled(operator.operatorId, true); + } else if (action === "disable") { + await adminApi.setOperatorEnabled(operator.operatorId, false); + } else if (action === "unlock") { + await adminApi.unlockOperator(operator.operatorId); + } else if (action === "sessions") { + await adminApi.revokeOperatorSessions(operator.operatorId); + } + showToast("安全设置已更新", "success"); + await renderSecurity(container, currentUsername); + } catch (error) { + showToast(errorMessage(error, "安全设置更新失败"), "error"); + setButtonBusy(button, false); + } +} + +async function resetCredentials( + container: HTMLElement, + dialog: HTMLDialogElement | null, + form: HTMLFormElement, + currentUsername: string, +): Promise { + const operatorId = form.dataset.operatorId; + const data = new FormData(form); + const password = data.get("password")?.toString() ?? ""; + const confirmation = data.get("passwordConfirmation")?.toString() ?? ""; + const errorNode = form.querySelector("[data-reset-error]"); + const submit = form.querySelector('button[type="submit"]'); + if (!operatorId || password.length < 12 || password !== confirmation || !submit) { + if (errorNode) errorNode.textContent = "请输入至少 12 位且两次一致的新密码"; + return; + } + + setButtonBusy(submit, true, "重置中…"); + clearError(form, "[data-reset-error]"); + try { + const provisioning = await adminApi.resetOperatorCredentials( + operatorId, + password, + ); + form.reset(); + dialog?.close(); + await showProvisioning(container, provisioning, "登录凭据已重置"); + await renderSecurity(container, currentUsername); + } catch (error) { + if (errorNode) errorNode.textContent = errorMessage(error, "凭据重置失败"); + setButtonBusy(submit, false); + } +} + +interface Confirmation { + title: string; + message: string; + label: string; + dangerous?: boolean; +} + +function actionConfirmation( + action: string | undefined, + username: string, +): Confirmation | null { + const target = `“${username}”`; + if (action === "disable") { + return { + title: "停用管理员?", + message: `${target} 将无法登录,全部活动会话会立即失效。`, + label: "停用管理员", + dangerous: true, + }; + } + if (action === "enable") { + return { + title: "启用管理员?", + message: `${target} 将恢复登录权限。`, + label: "确认启用", + }; + } + if (action === "unlock") { + return { + title: "解除登录锁定?", + message: `${target} 可以立即重新尝试登录。`, + label: "确认解锁", + }; + } + if (action === "sessions") { + return { + title: "撤销全部会话?", + message: `${target} 已登录的所有设备都需要重新认证。`, + label: "撤销会话", + dangerous: true, + }; + } + return null; +} + +function confirmAction( + container: HTMLElement, + confirmation: Confirmation, +): Promise { + return new Promise((resolve) => { + const dialog = document.createElement("dialog"); + dialog.className = "dialog"; + dialog.setAttribute("aria-labelledby", "confirm-action-title"); + dialog.innerHTML = ` +
+
确认操作
+

${escapeHtml(confirmation.title)}

+

${escapeHtml(confirmation.message)}

+
+ + +
+
+ `; + container.append(dialog); + dialog.addEventListener( + "close", + () => { + const confirmed = dialog.returnValue === "confirm"; + dialog.remove(); + resolve(confirmed); + }, + { once: true }, + ); + dialog.showModal(); + }); +} + +function showProvisioning( + container: HTMLElement, + provisioning: AdminOperatorProvisioning, + title: string, +): Promise { + return new Promise((resolve) => { + const dialog = document.createElement("dialog"); + dialog.className = "dialog dialog--wide"; + dialog.setAttribute("aria-labelledby", "provisioning-title"); + dialog.innerHTML = ` +
+
仅显示一次
+

${escapeHtml(title)}

+

请立即把 TOTP 密钥交给对应管理员,并确认已添加到认证器。

+
+ Base32 密钥 + ${escapeHtml(provisioning.totpSecret)} + +
+
+ 在认证器中打开 + +
+ + +
+ `; + const close = dialog.querySelector("[data-close-provisioning]"); + dialog + .querySelector("[data-provisioning-saved]") + ?.addEventListener("change", (event) => { + if (close) close.disabled = !(event.target as HTMLInputElement).checked; + }); + dialog.addEventListener("cancel", (event) => event.preventDefault()); + dialog + .querySelector("[data-copy-secret]") + ?.addEventListener("click", () => { + void copySensitiveValue(provisioning.totpSecret, "TOTP 密钥"); + }); + dialog + .querySelector("[data-copy-uri]") + ?.addEventListener("click", () => { + void copySensitiveValue(provisioning.otpauthUri, "认证器配置链接"); + }); + close?.addEventListener("click", () => dialog.close()); + dialog.addEventListener( + "close", + () => { + dialog.remove(); + resolve(); + }, + { once: true }, + ); + container.append(dialog); + dialog.showModal(); + }); +} + +async function copySensitiveValue(value: string, label: string): Promise { + try { + await navigator.clipboard.writeText(value); + showToast(`${label}已复制,请妥善保管`, "success"); + } catch { + showToast(`无法复制${label},请手动选择`, "error"); + } +} + +function clearError( + container: ParentNode | null, + selector: string, +): void { + const node = container?.querySelector(selector); + if (node) node.textContent = ""; +} + +function errorMessage(error: unknown, fallback: string): string { + return error instanceof ApiError ? error.message : fallback; +} + +function roleLabel(role: AdminRole): string { + const labels: Record = { + SUPER_ADMIN: "超级管理员", + SUPPORT: "支持人员", + ANALYST: "分析员", + }; + return labels[role]; +} diff --git a/admin-web/src/pages/users.ts b/admin-web/src/pages/users.ts new file mode 100644 index 0000000..6995fa1 --- /dev/null +++ b/admin-web/src/pages/users.ts @@ -0,0 +1,454 @@ +import { adminApi, ApiError } from "../api/client"; +import type { + AdminRole, + LedgerEntry, + PageResult, + UserDetail, + UserSummary, + UserUsageAggregate, +} from "../api/types"; +import { + renderEmpty, + renderError, + renderLoading, + setButtonBusy, + showToast, +} from "../components/ui"; +import { + createIdempotencyKey, + escapeHtml, + formatDateTime, + formatNumber, + formatSignedCredits, + statusLabel, +} from "../lib/format"; + +export function renderUsers(container: HTMLElement, role: AdminRole): void { + container.innerHTML = ` +
+
+
账户管理
+

用户查询

+

按内部用户 ID 精确查询,不展示 Apple 身份标识。

+
+
+
+
+ + +
+
${renderEmpty("输入查询条件开始搜索")}
+
+ `; + + const form = container.querySelector("[data-search-form]"); + form?.addEventListener("submit", (event) => { + event.preventDefault(); + const search = new FormData(form).get("query")?.toString().trim() ?? ""; + if (search) void searchUsers(container, search, role); + }); +} + +async function searchUsers( + container: HTMLElement, + search: string, + role: AdminRole, +): Promise { + const results = container.querySelector("[data-results]"); + if (!results) return; + renderLoading(results, "搜索用户"); + + try { + const page = await adminApi.users(search); + if (page.items.length === 0) { + results.innerHTML = renderEmpty("未找到匹配用户"); + return; + } + results.innerHTML = ` +
+ + + + + ${page.items.map(userRow).join("")} + +
用户查询结果
用户状态积分余额注册时间操作
+
+ `; + results.querySelectorAll("[data-user-id]").forEach( + (button) => { + button.addEventListener("click", () => { + const userId = button.dataset.userId; + if (userId) void renderUserDetail(container, userId, role); + }); + }, + ); + } catch (error) { + renderError(results, error, () => void searchUsers(container, search, role)); + } +} + +function userRow(user: UserSummary): string { + return ` + + + ${escapeHtml(user.displayName || "未命名用户")} +
${escapeHtml(user.userId)}
+ + ${statusLabel(user.status)} + ${formatNumber(user.creditBalance)} + ${formatDateTime(user.createdAt)} + + + `; +} + +async function renderUserDetail( + container: HTMLElement, + userId: string, + role: AdminRole, +): Promise { + renderLoading(container, "加载用户详情"); + try { + const [user, ledger] = await Promise.all([ + adminApi.user(userId), + adminApi.ledger(userId), + ]); + container.innerHTML = detailTemplate(user, ledger, role); + container + .querySelector("[data-back]") + ?.addEventListener("click", () => { + renderUsers(container, role); + container.querySelector('input[name="query"]')?.focus(); + }); + bindLedgerPagination(container, user, ledger.nextCursor); + if (role === "SUPER_ADMIN") bindGrantDialog(container, user, role); + const heading = container.querySelector("h1"); + if (heading) { + heading.tabIndex = -1; + heading.focus({ preventScroll: true }); + } + } catch (error) { + renderError(container, error, () => + void renderUserDetail(container, userId, role), + ); + } +} + +function detailTemplate( + user: UserDetail, + ledger: PageResult, + role: AdminRole, +): string { + const usage = user.usage ?? []; + const usageRequests = usage.reduce((total, item) => total + item.requests, 0); + const chargedCredits = usage.reduce( + (total, item) => total + item.chargedCredits, + 0, + ); + const referral = user.referral; + const inviterUserId = referral?.inviterUserId ?? user.referredByUserId; + const qualifiedUsage = user.qualifiedUsage || usageRequests > 0; + + return ` +
+
+ +
用户详情
+

${escapeHtml(user.displayName || "未命名用户")}

+

${escapeHtml(user.userId)}

+
+ ${ + role === "SUPER_ADMIN" + ? '' + : "" + } +
+
+
+
+ +
${escapeHtml(user.displayName || "未命名用户")}${statusLabel(user.status)}
+
+
+
积分余额
${formatNumber(user.creditBalance)}
+
注册时间
${formatDateTime(user.createdAt)}
+
最近活跃
${formatDateTime(user.lastActiveAt)}
+
有效使用
${qualifiedUsage ? "已达成" : "未达成"}${usage.length > 0 ? ` · ${formatNumber(usageRequests)} 次请求` : ""}
+ ${ + usage.length > 0 + ? `
累计消耗
${formatNumber(chargedCredits)} 积分
` + : "" + } +
邀请码
${escapeHtml(user.referralCode || "—")}
+
邀请来源
${escapeHtml(inviterUserId || "—")}
+ ${ + referral + ? `
邀请成效
${formatNumber(referral.rewardedInvites)} / ${formatNumber(referral.invitedUsers)} 已奖励
` + : "" + } +
+
+
+

积分流水

不可变账本记录

+ ${ + ledger.items.length === 0 + ? renderEmpty("暂无积分流水") + : ` +
+ + + + ${ledgerRows(ledger.items)} +
${escapeHtml(user.displayName || user.userId)} 的积分流水
时间类型变动结余原因
+
+ ${ + ledger.nextCursor + ? '
' + : "" + } +

+ ` + } +
+ ${usagePanel(usage)} +
+ ${role === "SUPER_ADMIN" ? grantDialog(user) : ""} + `; +} + +function ledgerRows(entries: LedgerEntry[]): string { + return entries + .map( + (entry) => ` + + ${formatDateTime(entry.createdAt)} + ${escapeHtml(statusLabel(entry.type))} + ${formatSignedCredits(entry.amount)} + ${formatNumber(entry.balanceAfter)} + ${escapeHtml(entry.reasonCode)} + + `, + ) + .join(""); +} + +function usagePanel(usage: UserUsageAggregate[]): string { + if (usage.length === 0) return ""; + return ` +
+

使用统计

按使用类型汇总,不包含用户内容

+
+ + + + + ${usage + .map( + (item) => ` + + + + + + + + + `, + ) + .join("")} + +
用户使用统计
类型请求消耗积分语音时长(毫秒)输入 Token输出 Token
${escapeHtml(item.kind)}${formatNumber(item.requests)}${formatNumber(item.chargedCredits)}${formatNumber(item.asrMillis)}${formatNumber(item.inputTokens)}${formatNumber(item.outputTokens)}
+
+
+ `; +} + +function bindLedgerPagination( + container: HTMLElement, + user: UserDetail, + initialCursor?: string, +): void { + const button = + container.querySelector("[data-ledger-more]"); + const body = + container.querySelector("[data-ledger-body]"); + const status = container.querySelector("[data-ledger-status]"); + if (!button || !body || !initialCursor) return; + let cursor: string | undefined = initialCursor; + + button.addEventListener("click", async () => { + if (!cursor) return; + setButtonBusy(button, true, "加载中…"); + try { + const page = await adminApi.ledger(user.userId, cursor); + body.insertAdjacentHTML("beforeend", ledgerRows(page.items)); + cursor = page.nextCursor; + if (status) { + status.textContent = cursor + ? `已加载 ${formatNumber(page.items.length)} 条更多流水` + : `已加载 ${formatNumber(page.items.length)} 条流水,全部记录已加载`; + } + if (!cursor) { + button.closest(".pagination-actions")?.remove(); + } else { + setButtonBusy(button, false); + button.focus(); + } + } catch (error) { + showToast( + error instanceof ApiError ? error.message : "加载积分流水失败", + "error", + ); + setButtonBusy(button, false); + button.focus(); + } + }); +} + +function grantDialog(user: UserDetail): string { + return ` + +
+
+
高风险操作
+

人工赠送积分

+

赠送将写入不可变账本,并记录管理员审计日志。

+ + + + +
+ + +
+
+ +
+
+ `; +} + +function bindGrantDialog( + container: HTMLElement, + user: UserDetail, + role: AdminRole, +): void { + const dialog = container.querySelector("[data-grant-dialog]"); + const form = container.querySelector("[data-grant-form]"); + const inputs = container.querySelector("[data-grant-inputs]"); + const confirm = container.querySelector("[data-grant-confirm]"); + if (!dialog || !form || !inputs || !confirm) return; + let idempotencyKey = createIdempotencyKey(); + let outcomeUnknown = false; + + container + .querySelector("[data-open-grant]") + ?.addEventListener("click", () => { + idempotencyKey = createIdempotencyKey(); + outcomeUnknown = false; + form.reset(); + inputs.hidden = false; + confirm.hidden = true; + form.querySelectorAll(".form-error").forEach((node) => { + node.textContent = ""; + }); + dialog.showModal(); + form.querySelector('input[name="amount"]')?.focus(); + }); + + dialog.addEventListener("cancel", (event) => { + if (!outcomeUnknown) return; + event.preventDefault(); + const errorNode = form.querySelector("[data-confirm-error]"); + if (errorNode) { + errorNode.textContent = "赠送结果尚未确认,请使用当前窗口安全重试"; + } + }); + + container + .querySelector("[data-review-grant]") + ?.addEventListener("click", () => { + const data = new FormData(form); + const amount = Number(data.get("amount")); + const reason = data.get("reason")?.toString().trim() ?? ""; + const errorNode = form.querySelector("[data-error]"); + if (!Number.isSafeInteger(amount) || amount < 1 || amount > 100_000) { + if (errorNode) errorNode.textContent = "积分必须是 1 至 100,000 的整数"; + return; + } + if (reason.length < 4) { + if (errorNode) errorNode.textContent = "请填写至少 4 个字符的赠送原因"; + return; + } + if (errorNode) errorNode.textContent = ""; + const amountNode = form.querySelector("[data-confirm-amount]"); + const reasonNode = form.querySelector("[data-confirm-reason]"); + if (amountNode) amountNode.textContent = formatNumber(amount); + if (reasonNode) reasonNode.textContent = `原因:${reason}`; + inputs.hidden = true; + confirm.hidden = false; + form.querySelector("[data-confirm-title]")?.focus(); + }); + + container + .querySelector("[data-edit-grant]") + ?.addEventListener("click", () => { + if (outcomeUnknown) { + const errorNode = form.querySelector("[data-confirm-error]"); + if (errorNode) { + errorNode.textContent = "结果尚未确认,不能修改本次赠送内容;请直接重试"; + } + return; + } + confirm.hidden = true; + inputs.hidden = false; + form.querySelector('input[name="amount"]')?.focus(); + }); + + const submit = container.querySelector("[data-submit-grant]"); + submit?.addEventListener("click", async () => { + const data = new FormData(form); + const amount = Number(data.get("amount")); + const reason = data.get("reason")?.toString().trim() ?? ""; + const errorNode = form.querySelector("[data-confirm-error]"); + setButtonBusy(submit, true, "赠送中…"); + if (errorNode) errorNode.textContent = ""; + try { + await adminApi.grantCredits({ + userId: user.userId, + amount, + reason, + idempotencyKey, + }); + dialog.close(); + showToast("积分赠送成功,账本已更新", "success"); + await renderUserDetail(container, user.userId, role); + } catch (error) { + const message = + error instanceof ApiError ? error.message : "赠送失败,请重试"; + outcomeUnknown = + !(error instanceof ApiError) || error.status === 0 || error.status >= 500; + if (errorNode) { + errorNode.textContent = outcomeUnknown + ? "赠送结果尚未确认;重试会复用同一请求,不会重复到账" + : message; + } + setButtonBusy(submit, false); + } + }); +} diff --git a/admin-web/src/styles.css b/admin-web/src/styles.css new file mode 100644 index 0000000..b001455 --- /dev/null +++ b/admin-web/src/styles.css @@ -0,0 +1,1428 @@ +:root { + font-family: + -apple-system, BlinkMacSystemFont, "SF Pro Text", "SF Pro Display", + "Helvetica Neue", "PingFang SC", system-ui, sans-serif; + color: #1d1d1f; + background: #f5f5f7; + color-scheme: light dark; + font-synthesis: none; + text-rendering: optimizeLegibility; + --primary: #0071e3; + --primary-dark: #0062c4; + --navy: #1d1d1f; + --muted: #6e6e73; + --tertiary: #8e8e93; + --border: rgb(60 60 67 / 18%); + --surface: #ffffff; + --surface-muted: #f2f2f7; + --surface-elevated: #ffffff; + --input-background: #ffffff; + --sidebar-background: rgb(246 246 248 / 88%); + --hover-fill: rgb(0 0 0 / 5%); + --focus-ring: rgb(0 113 227 / 28%); + --danger: #d70015; + --success: #248a3d; +} + +* { + box-sizing: border-box; +} + +body { + min-width: 320px; + min-height: 100vh; + margin: 0; + background: #f5f5f7; +} + +button, +input, +textarea, +select { + font: inherit; +} + +button, +a, +select { + -webkit-tap-highlight-color: transparent; +} + +.skip-link { + position: fixed; + z-index: 1000; + top: 10px; + left: 12px; + padding: 10px 14px; + color: #fff; + border-radius: 10px; + background: var(--primary); + text-decoration: none; + transform: translateY(-160%); +} + +.skip-link:focus { + transform: translateY(0); +} + +button:focus-visible, +a:focus-visible, +input:focus-visible, +textarea:focus-visible, +select:focus-visible { + outline: 3px solid var(--focus-ring); + outline-offset: 2px; +} + +[hidden] { + display: none !important; +} + +h1, +h2, +p { + margin-top: 0; +} + +h1 { + margin-bottom: 8px; + font-size: clamp(1.7rem, 3vw, 2.25rem); + letter-spacing: -0.035em; +} + +h2 { + margin-bottom: 4px; + font-size: 1.05rem; +} + +.sr-only { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} + +.eyebrow { + margin-bottom: 8px; + color: var(--primary); + font-size: 0.72rem; + font-weight: 800; + letter-spacing: 0.12em; + text-transform: uppercase; +} + +.muted, +.page-heading p, +.panel-heading p { + color: var(--muted); +} + +.mono { + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; + font-size: 0.82rem; +} + +.button { + min-height: 44px; + padding: 0 18px; + border: 1px solid transparent; + border-radius: 10px; + cursor: pointer; + font-weight: 700; + transition: + background 150ms ease, + border-color 150ms ease; +} + +.button:hover:not(:disabled) { + filter: brightness(0.97); +} + +.button:disabled { + cursor: wait; + opacity: 0.65; +} + +.button--primary { + color: #fff; + background: var(--primary); +} + +.button--primary:hover:not(:disabled) { + background: var(--primary-dark); +} + +.button--secondary { + color: var(--navy); + border-color: var(--border); + background: var(--surface-elevated); +} + +.button--danger { + color: #fff; + background: var(--danger); +} + +.button--text { + color: var(--muted); + background: transparent; +} + +.button--wide { + width: 100%; +} + +.button--small { + min-height: 34px; + padding: 0 12px; + font-size: 0.82rem; +} + +.icon-button { + display: grid; + width: 44px; + height: 44px; + padding: 0; + color: inherit; + border: 0; + border-radius: 10px; + background: transparent; + cursor: pointer; + place-items: center; +} + +.icon-button:hover { + background: var(--hover-fill); +} + +.icon-button svg, +.logout-button svg { + width: 21px; + height: 21px; + fill: none; + stroke: currentColor; + stroke-linecap: round; + stroke-linejoin: round; + stroke-width: 1.8; +} + +.brand-mark { + display: grid; + width: 58px; + height: 58px; + color: #fff; + border-radius: 17px; + background: var(--primary); + font-size: 1rem; + font-weight: 900; + letter-spacing: -0.06em; + place-items: center; +} + +.brand-mark--small { + width: 42px; + height: 42px; + border-radius: 12px; + box-shadow: none; + font-size: 0.8rem; +} + +.auth-shell { + display: grid; + min-height: 100vh; + padding: 24px; + grid-template-columns: minmax(280px, 0.9fr) minmax(380px, 1.1fr); + align-items: center; + gap: clamp(30px, 9vw, 140px); +} + +.auth-brand { + max-width: 460px; + margin-left: auto; +} + +.auth-brand::after { + display: block; + width: min(390px, 80vw); + height: 200px; + margin-top: 40px; + border: 1px solid var(--border); + border-radius: 30px; + background: var(--surface-muted); + content: ""; +} + +.auth-brand p { + margin-top: 24px; + color: var(--muted); + font-size: 1.08rem; +} + +.auth-card { + width: min(100%, 460px); + padding: clamp(30px, 5vw, 52px); + border: 1px solid var(--border); + border-radius: 20px; + background: var(--surface-elevated); +} + +.form-stack { + display: grid; + margin-top: 30px; + gap: 18px; +} + +label { + display: grid; + color: var(--navy); + font-size: 0.88rem; + font-weight: 700; + gap: 7px; +} + +input, +textarea, +select { + width: 100%; + color: var(--navy); + border: 1px solid var(--border); + border-radius: 10px; + background: var(--input-background); + transition: + border-color 150ms ease, + box-shadow 150ms ease; +} + +input, +select { + height: 44px; + padding: 0 13px; +} + +textarea { + min-height: 92px; + padding: 12px 13px; + resize: vertical; +} + +input:focus, +textarea:focus, +select:focus { + border-color: var(--primary); + box-shadow: 0 0 0 3px var(--focus-ring); + outline: none; +} + +input:disabled { + color: var(--muted); + background: var(--surface-muted); +} + +.totp-input { + font-size: 1.45rem; + letter-spacing: 0.4em; + text-align: center; +} + +.form-error { + min-height: 20px; + margin: -4px 0 0; + color: var(--danger); + font-size: 0.84rem; +} + +.app-shell { + min-height: 100vh; +} + +.sidebar { + position: fixed; + z-index: 20; + top: 0; + bottom: 0; + left: 0; + display: flex; + width: 250px; + padding: 24px 18px; + color: var(--navy); + border-right: 1px solid var(--border); + background: var(--sidebar-background); + backdrop-filter: saturate(160%) blur(24px); + flex-direction: column; +} + +.sidebar-brand { + display: flex; + padding: 0 8px 28px; + align-items: center; + gap: 12px; +} + +.sidebar-brand strong, +.sidebar-brand span { + display: block; +} + +.sidebar-brand span { + margin-top: 3px; + color: var(--muted); + font-size: 0.72rem; + letter-spacing: 0.12em; +} + +.nav-list { + display: grid; + gap: 7px; +} + +.nav-list a { + display: flex; + min-height: 46px; + padding: 0 13px; + color: var(--muted); + border-radius: 11px; + font-size: 0.91rem; + font-weight: 650; + text-decoration: none; + align-items: center; + gap: 12px; + transition: + color 150ms ease, + background 150ms ease; +} + +.nav-list a:hover, +.nav-list a.active { + color: var(--navy); + background: var(--hover-fill); +} + +.nav-list a.active { + color: var(--primary); + box-shadow: inset 3px 0 var(--primary); +} + +.nav-icon { + width: 22px; + height: 22px; + fill: none; + stroke: currentColor; + stroke-linecap: round; + stroke-linejoin: round; + stroke-width: 1.65; +} + +.sidebar-footer { + display: flex; + padding: 18px 4px 0; + margin-top: auto; + border-top: 1px solid var(--border); + align-items: center; + justify-content: space-between; +} + +.operator { + display: flex; + align-items: center; + gap: 10px; +} + +.operator-avatar { + display: grid; + width: 36px; + height: 36px; + color: var(--primary); + border: 1px solid var(--border); + border-radius: 50%; + background: var(--surface-muted); + place-items: center; +} + +.operator strong, +.operator span { + display: block; + max-width: 110px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.operator strong { + font-size: 0.82rem; +} + +.operator span { + margin-top: 2px; + color: var(--muted); + font-size: 0.75rem; +} + +.logout-button { + display: inline-flex; + min-width: 44px; + min-height: 44px; + padding: 0 10px; + color: var(--muted); + border: 0; + border-radius: 10px; + background: transparent; + cursor: pointer; + align-items: center; + gap: 6px; +} + +.logout-button:hover { + color: var(--navy); + background: var(--hover-fill); +} + +.logout-button span { + font-size: 0.76rem; + font-weight: 650; +} + +.main-column { + min-height: 100vh; + margin-left: 250px; +} + +.mobile-header, +.sidebar-scrim { + display: none; +} + +.page-content { + width: min(100%, 1420px); + padding: clamp(28px, 4vw, 54px); + margin: 0 auto; +} + +.page-stage { + animation: pageIn 180ms ease-out; +} + +@keyframes pageIn { + from { + opacity: 0; + transform: translateY(4px); + } +} + +.page-heading { + display: flex; + margin-bottom: 28px; + align-items: end; + justify-content: space-between; + gap: 24px; +} + +.page-heading p { + margin-bottom: 0; +} + +.page-heading--detail { + align-items: center; +} + +.back-link { + padding: 0; + margin-bottom: 20px; + color: var(--muted); + border: 0; + background: none; + cursor: pointer; +} + +.back-link:hover { + color: var(--primary); +} + +.select-field select { + min-width: 122px; +} + +.segmented-control { + display: inline-flex; + padding: 3px; + margin: 0; + border: 1px solid var(--border); + border-radius: 10px; + background: var(--surface-muted); + gap: 2px; +} + +.segmented-control label { + display: block; + color: var(--muted); + font-size: 0.8rem; + font-weight: 650; +} + +.segmented-control input { + position: absolute; + width: 1px; + height: 1px; + opacity: 0; +} + +.segmented-control span { + display: grid; + min-width: 58px; + min-height: 34px; + padding: 0 10px; + border-radius: 7px; + cursor: pointer; + place-items: center; +} + +.segmented-control input:checked + span { + color: var(--navy); + background: var(--surface-elevated); + box-shadow: 0 1px 2px rgb(0 0 0 / 8%); +} + +.segmented-control input:focus-visible + span { + outline: 3px solid var(--focus-ring); + outline-offset: 1px; +} + +.metric-grid { + display: grid; + margin-bottom: 22px; + grid-template-columns: repeat(4, minmax(150px, 1fr)); + gap: 16px; +} + +.metric-grid--three { + grid-template-columns: repeat(3, minmax(150px, 1fr)); +} + +.metric-card, +.panel { + border: 1px solid var(--border); + background: var(--surface); +} + +.metric-card { + padding: 22px; + border-radius: 15px; +} + +.metric-card p, +.metric-card span { + color: var(--muted); +} + +.metric-card p { + margin-bottom: 18px; + font-size: 0.83rem; + font-weight: 700; +} + +.metric-card strong { + display: block; + margin-bottom: 8px; + font-size: clamp(1.65rem, 3vw, 2.1rem); + letter-spacing: -0.04em; +} + +.metric-card span { + font-size: 0.72rem; +} + +.panel { + padding: clamp(18px, 3vw, 26px); + border-radius: 16px; +} + +.panel-heading { + display: flex; + margin-bottom: 22px; + align-items: center; + justify-content: space-between; + gap: 16px; +} + +.panel-heading p { + margin-bottom: 0; + font-size: 0.79rem; +} + +.security-notice { + display: flex; + padding: 16px 18px; + color: var(--navy); + border: 1px solid color-mix(in srgb, var(--primary) 28%, var(--border)); + border-radius: 14px; + background: color-mix(in srgb, var(--primary) 8%, var(--surface)); + align-items: baseline; + gap: 12px; +} + +.security-notice span { + color: var(--muted); + font-size: 0.86rem; +} + +.row-actions { + display: flex; + min-width: 310px; + align-items: center; + flex-wrap: wrap; + gap: 6px; +} + +.legend { + color: var(--muted); + font-size: 0.74rem; +} + +.legend i { + display: inline-block; + width: 8px; + height: 8px; + margin-right: 6px; + border-radius: 50%; + background: var(--primary); +} + +.chart-scroll { + overflow-x: auto; +} + +.trend-chart { + display: block; + width: 100%; + min-width: 570px; + height: auto; + overflow: visible; +} + +.chart-axis { + stroke: var(--border); + stroke-width: 1; +} + +.chart-label { + fill: var(--muted); + font-size: 10px; +} + +.chart-line { + fill: none; + stroke: var(--primary); + stroke-linecap: round; + stroke-linejoin: round; + stroke-width: 3; +} + +.chart-dot { + fill: #fff; + stroke: var(--primary); + stroke-width: 3; +} + +.two-column, +.detail-grid { + display: grid; + grid-template-columns: minmax(300px, 0.8fr) minmax(440px, 1.2fr); + gap: 20px; +} + +.detail-panel--wide { + grid-column: 1 / -1; +} + +.funnel { + display: grid; + padding: 8px 0; + gap: 22px; +} + +.funnel-label { + display: flex; + margin-bottom: 9px; + color: var(--muted); + font-size: 0.82rem; + justify-content: space-between; +} + +.funnel-label strong { + color: var(--navy); +} + +.funnel-progress { + display: block; + width: 100%; + height: 13px; + overflow: hidden; + border: 0; + border-radius: 99px; + appearance: none; +} + +.funnel-progress::-webkit-progress-bar { + border-radius: inherit; + background: var(--surface-muted); +} + +.funnel-progress::-webkit-progress-value { + border-radius: inherit; + background: var(--primary); +} + +.funnel-progress::-moz-progress-bar { + border-radius: inherit; + background: var(--primary); +} + +.table-wrap { + width: 100%; + overflow-x: auto; +} + +table { + width: 100%; + border-collapse: collapse; + white-space: nowrap; +} + +th, +td { + padding: 13px 12px; + border-bottom: 1px solid var(--border); + font-size: 0.82rem; + text-align: left; +} + +th { + color: var(--muted); + font-size: 0.76rem; + letter-spacing: 0.03em; + text-transform: uppercase; +} + +tbody tr:last-child td { + border-bottom: 0; +} + +tbody tr:hover { + background: var(--hover-fill); +} + +td strong { + display: block; + margin-bottom: 5px; +} + +.subtle { + margin-top: 3px; + color: var(--muted); + font-size: 0.76rem; +} + +.rank { + display: grid; + width: 25px; + height: 25px; + border-radius: 8px; + background: var(--surface-muted); + font-weight: 800; + place-items: center; +} + +.rank--1 { + color: #8a5b00; + background: #fff3bf; +} + +.rank--2 { + color: #475467; + background: #eaecf0; +} + +.rank--3 { + color: #9c4f21; + background: #ffe8cc; +} + +.badge { + display: inline-flex; + padding: 4px 8px; + color: var(--muted); + border-radius: 99px; + background: var(--surface-muted); + font-size: 0.75rem; + font-weight: 750; +} + +.badge--active, +.badge--success { + color: #067647; + background: #ecfdf3; +} + +.badge--suspended, +.badge--rejected { + color: #b42318; + background: #fef3f2; +} + +.badge--closed { + color: #475467; + background: #eaecf0; +} + +.search-form { + display: flex; + max-width: 760px; + margin-bottom: 24px; + gap: 10px; +} + +.search-box { + position: relative; + display: block; + flex: 1; +} + +.search-box > span[aria-hidden="true"] { + position: absolute; + top: 50%; + left: 14px; + color: var(--muted); + font-size: 1.2rem; + transform: translateY(-50%); +} + +.search-box input { + padding-left: 42px; +} + +.empty-state { + display: grid; + min-height: 150px; + color: var(--muted); + text-align: center; + place-items: center; +} + +.empty-state p { + margin: 0; +} + +.state-card { + display: grid; + min-height: 320px; + color: var(--muted); + text-align: center; + align-content: center; + justify-items: center; +} + +.state-card--error { + max-width: 480px; + padding: 36px; + margin: 80px auto; + border: 1px solid #fee4e2; + border-radius: 16px; + background: #fff; +} + +.state-icon, +.confirm-icon { + display: grid; + width: 42px; + height: 42px; + margin-bottom: 15px; + color: var(--danger); + border-radius: 50%; + background: #fee4e2; + font-weight: 900; + place-items: center; +} + +.spinner { + width: 28px; + height: 28px; + margin-bottom: 12px; + border: 3px solid #dbe4ff; + border-top-color: var(--primary); + border-radius: 50%; + animation: spin 700ms linear infinite; +} + +@keyframes spin { + to { + transform: rotate(360deg); + } +} + +.profile-card { + height: fit-content; +} + +.profile-header { + display: flex; + padding-bottom: 22px; + margin-bottom: 12px; + border-bottom: 1px solid var(--border); + align-items: center; + gap: 13px; +} + +.profile-header strong, +.profile-header .badge { + display: block; + width: fit-content; +} + +.profile-header .badge { + margin-top: 6px; +} + +.avatar { + display: grid; + width: 48px; + height: 48px; + color: var(--primary-dark); + border-radius: 14px; + background: #edf2ff; + font-size: 1.15rem; + font-weight: 850; + place-items: center; +} + +.detail-list { + margin: 0; +} + +.detail-list div { + display: flex; + padding: 12px 0; + border-bottom: 1px solid #f0f2f5; + justify-content: space-between; + gap: 20px; +} + +.detail-list div:last-child { + border: 0; +} + +.detail-list dt { + color: var(--muted); + font-size: 0.78rem; +} + +.detail-list dd { + margin: 0; + font-size: 0.8rem; + text-align: right; +} + +.detail-list .credit-value { + color: var(--primary-dark); + font-size: 1.3rem; + font-weight: 850; +} + +.positive { + color: var(--success); + font-weight: 750; +} + +.negative { + color: var(--danger); + font-weight: 750; +} + +.result-summary { + padding: 10px 12px 18px; + color: var(--muted); + font-size: 0.8rem; +} + +.pagination-actions { + display: flex; + padding: 18px 22px 22px; + border-top: 1px solid var(--border); + justify-content: center; +} + +.dialog { + width: min(92vw, 520px); + padding: 0; + border: 0; + border-radius: 20px; + box-shadow: 0 30px 90px rgb(16 24 40 / 28%); +} + +.dialog--wide { + width: min(92vw, 640px); +} + +.dialog::backdrop { + background: rgb(16 24 40 / 58%); + backdrop-filter: blur(3px); +} + +.dialog-card { + display: grid; + padding: clamp(24px, 5vw, 36px); + gap: 18px; +} + +.provisioning-card { + max-width: 600px; +} + +.secret-panel { + display: grid; + padding: 16px; + border: 1px solid var(--border); + border-radius: 12px; + background: var(--surface-muted); + gap: 10px; +} + +.secret-panel > span { + color: var(--muted); + font-size: 0.78rem; + font-weight: 700; +} + +.secret-panel code { + display: block; + padding: 12px; + overflow-wrap: anywhere; + font-size: 0.92rem; + letter-spacing: 0.08em; + user-select: all; +} + +.dialog-card label { + margin-top: 15px; +} + +.dialog-actions { + display: flex; + margin-top: 26px; + justify-content: flex-end; + gap: 10px; +} + +.dialog-actions--spread { + justify-content: space-between; +} + +.confirmation-check { + display: flex; + min-height: 44px; + color: var(--navy); + align-items: center; + gap: 10px; +} + +.confirmation-check input { + width: 20px; + height: 20px; + flex: 0 0 auto; +} + +.confirm-reason { + padding: 12px; + color: var(--muted); + border-radius: 10px; + background: #f7f8fa; + font-size: 0.84rem; +} + +.toast { + position: fixed; + z-index: 100; + right: 22px; + bottom: 22px; + max-width: min(390px, calc(100vw - 44px)); + padding: 14px 18px; + color: #fff; + border-radius: 11px; + background: #344054; + box-shadow: 0 15px 40px rgb(16 24 40 / 25%); + opacity: 0; + transform: translateY(10px); + transition: + opacity 180ms ease, + transform 180ms ease; +} + +.toast--visible { + opacity: 1; + transform: translateY(0); +} + +.toast--success { + background: var(--success); +} + +.toast--error { + background: var(--danger); +} + +code { + padding: 3px 6px; + color: #364fc7; + border-radius: 5px; + background: #edf2ff; + font-size: 0.74rem; +} + +@media (prefers-color-scheme: dark) { + :root { + color: #f5f5f7; + background: #000; + --primary: #0a84ff; + --primary-dark: #409cff; + --navy: #f5f5f7; + --muted: #a1a1a6; + --tertiary: #8e8e93; + --border: rgb(235 235 245 / 20%); + --surface: #1c1c1e; + --surface-muted: #2c2c2e; + --surface-elevated: #242426; + --input-background: #1c1c1e; + --sidebar-background: rgb(28 28 30 / 88%); + --hover-fill: rgb(255 255 255 / 8%); + --focus-ring: rgb(10 132 255 / 38%); + --danger: #ff453a; + --success: #30d158; + } + + body { + background: #000; + } + + .chart-axis { + stroke: var(--border); + } + + .chart-dot { + fill: var(--surface); + } + + .badge--active, + .badge--success { + color: #30d158; + background: rgb(48 209 88 / 14%); + } + + .badge--suspended, + .badge--rejected { + color: #ff6961; + background: rgb(255 69 58 / 14%); + } + + .badge--closed, + .rank--2 { + color: var(--muted); + background: var(--surface-muted); + } + + .rank--1 { + color: #ffd60a; + background: rgb(255 214 10 / 14%); + } + + .rank--3 { + color: #ff9f0a; + background: rgb(255 159 10 / 14%); + } + + .state-card--error, + .dialog, + .auth-card { + background: var(--surface-elevated); + } + + .confirm-reason, + code { + color: var(--primary-dark); + background: var(--surface-muted); + } +} + +@media (max-width: 980px) { + .metric-grid { + grid-template-columns: repeat(2, 1fr); + } + + .two-column, + .detail-grid { + grid-template-columns: 1fr; + } +} + +@media (max-width: 1023px) { + .auth-shell { + display: flex; + padding: 18px; + align-items: center; + justify-content: center; + } + + .auth-brand { + display: none; + } + + .sidebar { + width: min(82vw, 280px); + transform: translateX(-105%); + transition: transform 200ms ease; + } + + .sidebar--open { + transform: translateX(0); + } + + .sidebar--open ~ .main-column + .sidebar-scrim { + display: block; + } + + .sidebar-scrim { + position: fixed; + z-index: 15; + inset: 0; + width: 100%; + height: 100%; + padding: 0; + border: 0; + background: rgb(16 24 40 / 45%); + } + + .main-column { + margin-left: 0; + } + + .mobile-header { + position: sticky; + z-index: 10; + top: 0; + display: flex; + height: 58px; + padding: 0 16px; + border-bottom: 1px solid var(--border); + background: var(--sidebar-background); + backdrop-filter: blur(12px); + align-items: center; + gap: 10px; + } + + .mobile-header .icon-button:hover { + background: var(--hover-fill); + } + + .page-content { + padding: 26px 16px 46px; + } + + .page-heading { + align-items: start; + flex-direction: column; + } + + .page-heading--detail { + align-items: stretch; + } + + .select-field, + .select-field select { + width: 100%; + } + + .segmented-control { + width: 100%; + } + + .segmented-control label { + flex: 1; + } + + .segmented-control span { + width: 100%; + } + + .search-form { + align-items: stretch; + flex-direction: column; + } + + .security-notice { + align-items: start; + flex-direction: column; + gap: 4px; + } + + .row-actions { + min-width: 240px; + } +} + +@media (max-width: 480px) { + .auth-card { + padding: 28px 22px; + } + + .metric-grid { + grid-template-columns: 1fr; + } + + .dialog-actions { + align-items: stretch; + flex-direction: column-reverse; + } + + .dialog-actions .button { + width: 100%; + } +} + +@media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + scroll-behavior: auto !important; + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.01ms !important; + } +} + +@media (prefers-contrast: more) { + :root { + --border: rgb(60 60 67 / 52%); + --muted: #4c4c50; + } +} + +@media (forced-colors: active) { + .button, + .icon-button, + .logout-button, + input, + select, + textarea, + .panel, + .metric-card, + .segmented-control { + border: 1px solid ButtonText; + } + + .nav-list a.active { + outline: 2px solid Highlight; + outline-offset: -2px; + } +} diff --git a/admin-web/src/test/charts.test.ts b/admin-web/src/test/charts.test.ts new file mode 100644 index 0000000..aa918ab --- /dev/null +++ b/admin-web/src/test/charts.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from "vitest"; +import { funnelChart, trendChart } from "../components/charts"; + +describe("accessible charts", () => { + it("provides a titled SVG and complete data table for trends", () => { + const html = trendChart([ + { date: "2026-08-16", registrations: 12, creditsUsed: 345 }, + ]); + + expect(html).toContain('aria-labelledby="trend-chart-title trend-chart-description"'); + expect(html).toContain("新增用户与积分消耗趋势完整数据"); + expect(html).toContain("345"); + }); + + it("renders the funnel without CSP-sensitive inline styles", () => { + const html = funnelChart([ + { label: "访问", count: 20 }, + { label: "注册", count: 10 }, + ]); + + expect(html).toContain(" { + afterEach(() => { + vi.unstubAllGlobals(); + setCsrfToken(); + }); + + it("会话恢复不依赖 csrfToken,且所有请求都携带同源凭据", async () => { + const fetchMock = vi.fn().mockResolvedValue( + new Response( + JSON.stringify({ + authenticated: true, + operatorName: "owner", + role: "SUPER_ADMIN", + }), + { status: 200, headers: { "Content-Type": "application/json" } }, + ), + ); + vi.stubGlobal("fetch", fetchMock); + + const session = await adminApi.session(); + + expect(fetchMock).toHaveBeenCalledOnce(); + expect(session).toEqual({ + authenticated: true, + operatorName: "owner", + role: "SUPER_ADMIN", + }); + expect(fetchMock.mock.calls[0]?.[1]).toMatchObject({ + credentials: "include", + }); + }); + + it("积分赠送携带 CSRF 与幂等请求头", async () => { + const fetchMock = vi.fn().mockResolvedValue( + new Response( + JSON.stringify({ transactionId: "tx-1", balanceAfter: 120 }), + { status: 200, headers: { "Content-Type": "application/json" } }, + ), + ); + vi.stubGlobal("fetch", fetchMock); + setCsrfToken("csrf-test"); + + await adminApi.grantCredits({ + userId: "user-1", + amount: 20, + reason: "客服补偿", + idempotencyKey: "grant-1", + }); + + const request = fetchMock.mock.calls[0]?.[1] as RequestInit; + const headers = request.headers as Headers; + expect(headers.get("X-CSRF-Token")).toBe("csrf-test"); + expect(headers.get("Idempotency-Key")).toBe("grant-1"); + expect(request.credentials).toBe("include"); + }); + + it("登录请求不依赖已有会话 CSRF", async () => { + const fetchMock = vi.fn().mockResolvedValue( + new Response( + JSON.stringify({ + operatorName: "owner", + role: "SUPER_ADMIN", + csrfToken: "new-csrf", + }), + { status: 200, headers: { "Content-Type": "application/json" } }, + ), + ); + vi.stubGlobal("fetch", fetchMock); + + const response = await adminApi.login( + "owner", + "a-strong-password", + "123456", + ); + + const request = fetchMock.mock.calls[0]?.[1] as RequestInit; + expect((request.headers as Headers).has("X-CSRF-Token")).toBe(false); + expect(response.csrfToken).toBe("new-csrf"); + }); + + it("流水与管理员列表把 cursor 安全传入查询参数", async () => { + const fetchMock = vi.fn().mockImplementation(async () => { + return new Response(JSON.stringify({ items: [] }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + }); + vi.stubGlobal("fetch", fetchMock); + + await adminApi.ledger("user/with space", "ledger+/="); + await adminApi.operators("operator+/="); + + expect(fetchMock.mock.calls[0]?.[0]).toBe( + "/v1/admin/users/user%2Fwith%20space/ledger?cursor=ledger%2B%2F%3D", + ); + expect(fetchMock.mock.calls[1]?.[0]).toBe( + "/v1/admin/operators?cursor=operator%2B%2F%3D", + ); + }); + + it("缺少 CSRF 时在发送变更请求前失败", async () => { + const fetchMock = vi.fn(); + vi.stubGlobal("fetch", fetchMock); + + await expect(adminApi.logout()).rejects.toMatchObject({ + code: "CSRF_TOKEN_MISSING", + status: 403, + }); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it("服务错误转换为稳定 ApiError", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue( + new Response(JSON.stringify({ code: "RATE_LIMITED" }), { + status: 429, + headers: { "Content-Type": "application/json" }, + }), + ), + ); + + const promise = adminApi.overview("30d"); + await expect(promise).rejects.toBeInstanceOf(ApiError); + await expect(promise).rejects.toMatchObject({ + code: "RATE_LIMITED", + message: "操作过于频繁,请稍后再试", + status: 429, + }); + }); + + it("幂等冲突显示可操作的审计提示", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue( + new Response(JSON.stringify({ code: "IDEMPOTENCY_CONFLICT" }), { + status: 409, + headers: { "Content-Type": "application/json" }, + }), + ), + ); + setCsrfToken("csrf-test"); + + await expect( + adminApi.grantCredits({ + userId: "user-1", + amount: 20, + reason: "客服补偿", + idempotencyKey: "grant-conflict-1", + }), + ).rejects.toMatchObject({ + code: "IDEMPOTENCY_CONFLICT", + message: "该赠送请求与已有记录冲突,请核对审计日志", + status: 409, + }); + }); + + it("创建管理员携带 CSRF 并返回一次性 TOTP 配置", async () => { + const fetchMock = vi.fn().mockResolvedValue( + new Response( + JSON.stringify({ + operatorId: "operator-1", + totpSecret: "JBSWY3DPEHPK3PXP", + otpauthUri: "otpauth://totp/OSG:operator", + }), + { status: 201, headers: { "Content-Type": "application/json" } }, + ), + ); + vi.stubGlobal("fetch", fetchMock); + setCsrfToken("csrf-test"); + + const result = await adminApi.createOperator({ + username: "support", + password: "a-strong-password", + role: "SUPPORT", + }); + + const request = fetchMock.mock.calls[0]?.[1] as RequestInit; + expect((request.headers as Headers).get("X-CSRF-Token")).toBe("csrf-test"); + expect(request.body).toBe( + JSON.stringify({ + username: "support", + password: "a-strong-password", + role: "SUPPORT", + }), + ); + expect(result.totpSecret).toBe("JBSWY3DPEHPK3PXP"); + }); +}); diff --git a/admin-web/src/test/format.test.ts b/admin-web/src/test/format.test.ts new file mode 100644 index 0000000..a21d97e --- /dev/null +++ b/admin-web/src/test/format.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from "vitest"; +import { + escapeHtml, + formatDateTime, + formatSignedCredits, + statusLabel, +} from "../lib/format"; + +describe("format helpers", () => { + it("转义服务端文本以阻止 HTML 注入", () => { + expect(escapeHtml('')).toBe( + "<img src=x onerror="alert(1)">", + ); + }); + + it("无效日期显示占位符", () => { + expect(formatDateTime("not-a-date")).toBe("—"); + expect(formatDateTime()).toBe("—"); + }); + + it("积分变动保留明确正负号", () => { + expect(formatSignedCredits(15)).toBe("+15"); + expect(formatSignedCredits(-8)).toBe("-8"); + }); + + it("未知状态保持原值", () => { + expect(statusLabel("custom")).toBe("custom"); + }); +}); diff --git a/admin-web/src/test/pages.test.ts b/admin-web/src/test/pages.test.ts new file mode 100644 index 0000000..7ef7681 --- /dev/null +++ b/admin-web/src/test/pages.test.ts @@ -0,0 +1,191 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { adminApi } from "../api/client"; +import type { + AdminOperator, + AdminSecuritySummary, + UserDetail, + UserSummary, +} from "../api/types"; +import { renderSecurity } from "../pages/security"; +import { renderUsers } from "../pages/users"; + +const userId = "11111111-1111-4111-8111-111111111111"; +const userSummary: UserSummary = { + userId, + displayName: "测试用户", + status: "active", + creditBalance: 120, + createdAt: "2026-08-01T08:00:00Z", +}; +const userDetail: UserDetail = { + ...userSummary, + lastActiveAt: "2026-08-16T08:00:00Z", + qualifiedUsage: true, + referralCode: "OSG-TEST", + usage: [ + { + kind: "ASR", + requests: 3, + chargedCredits: 18, + asrMillis: 12_000, + inputTokens: 0, + outputTokens: 0, + }, + ], + referral: { + inviterUserId: "22222222-2222-4222-8222-222222222222", + invitedUsers: 4, + rewardedInvites: 2, + }, +}; + +afterEach(() => { + vi.restoreAllMocks(); + document.body.replaceChildren(); +}); + +describe("用户页", () => { + it("支持人员可查看详情但不显示人工赠送", async () => { + mockUserRequests(); + vi.spyOn(adminApi, "ledger").mockResolvedValue({ items: [] }); + const container = document.createElement("main"); + document.body.append(container); + + await openUserDetail(container, "SUPPORT"); + + expect(container.querySelector("[data-open-grant]")).toBeNull(); + expect(container.querySelector("[data-grant-dialog]")).toBeNull(); + }); + + it("超级管理员可赠送,并展示 usage/referral 与游标流水", async () => { + mockUserRequests(); + vi.spyOn(adminApi, "ledger") + .mockResolvedValueOnce({ + items: [ + { + entryId: "ledger-1", + type: "grant", + amount: 100, + balanceAfter: 100, + reasonCode: "SIGNUP_TRIAL", + createdAt: "2026-08-01T08:00:00Z", + }, + ], + nextCursor: "ledger-next", + }) + .mockResolvedValueOnce({ + items: [ + { + entryId: "ledger-2", + type: "settle", + amount: -18, + balanceAfter: 82, + reasonCode: "USAGE_SETTLE", + createdAt: "2026-08-02T08:00:00Z", + }, + ], + }); + const container = document.createElement("main"); + document.body.append(container); + + await openUserDetail(container, "SUPER_ADMIN"); + + expect(container.querySelector("[data-open-grant]")).not.toBeNull(); + expect(container.textContent).toContain("OSG-TEST"); + expect(container.textContent).toContain("3 次请求"); + expect(container.textContent).toContain("18 积分"); + expect(container.textContent).toContain("2 / 4 已奖励"); + expect(container.textContent).toContain( + "22222222-2222-4222-8222-222222222222", + ); + + container.querySelector("[data-ledger-more]")?.click(); + await vi.waitFor(() => { + expect(adminApi.ledger).toHaveBeenNthCalledWith( + 2, + userId, + "ledger-next", + ); + expect(container.querySelectorAll("[data-ledger-body] tr")).toHaveLength(2); + }); + expect(container.querySelector("[data-ledger-more]")).toBeNull(); + expect(container.querySelector("[data-ledger-status]")?.textContent).toContain( + "全部记录已加载", + ); + }); +}); + +describe("安全中心", () => { + it("按后端 nextCursor 加载更多管理员", async () => { + const firstOperator = operator("operator-1", "owner"); + const secondOperator = operator("operator-2", "support"); + vi.spyOn(adminApi, "operators") + .mockResolvedValueOnce({ + items: [firstOperator], + nextCursor: "operator-next", + }) + .mockResolvedValueOnce({ items: [secondOperator] }); + vi.spyOn(adminApi, "operatorSummary").mockResolvedValue(summary()); + const container = document.createElement("main"); + document.body.append(container); + + await renderSecurity(container, "owner"); + container.querySelector("[data-operator-more]")?.click(); + + await vi.waitFor(() => { + expect(adminApi.operators).toHaveBeenNthCalledWith(2, "operator-next"); + expect(container.querySelectorAll("[data-operator-body] tr")).toHaveLength(2); + }); + expect(container.querySelector("[data-operator-count]")?.textContent).toBe( + "已加载 2 个账户", + ); + expect(container.querySelector("[data-operator-more]")).toBeNull(); + expect( + container.querySelector("[data-operator-status]")?.textContent, + ).toContain("全部账户已加载"); + }); +}); + +function mockUserRequests(): void { + vi.spyOn(adminApi, "users").mockResolvedValue({ items: [userSummary] }); + vi.spyOn(adminApi, "user").mockResolvedValue(userDetail); +} + +async function openUserDetail( + container: HTMLElement, + role: "SUPER_ADMIN" | "SUPPORT", +): Promise { + renderUsers(container, role); + const input = container.querySelector('input[name="query"]'); + const form = container.querySelector("[data-search-form]"); + if (!input || !form) throw new Error("用户查询表单未渲染"); + input.value = userId; + form.dispatchEvent(new Event("submit", { bubbles: true, cancelable: true })); + await vi.waitFor(() => { + expect(container.querySelector("[data-user-id]")).not.toBeNull(); + }); + container.querySelector("[data-user-id]")?.click(); + await vi.waitFor(() => { + expect(container.querySelector("h1")?.textContent).toBe("测试用户"); + }); +} + +function operator(operatorId: string, username: string): AdminOperator { + return { + operatorId, + username, + role: username === "owner" ? "SUPER_ADMIN" : "SUPPORT", + enabled: true, + failedLoginCount: 0, + createdAt: "2026-08-01T08:00:00Z", + updatedAt: "2026-08-01T08:00:00Z", + }; +} + +function summary(): AdminSecuritySummary { + return { + enabledOperators: 2, + lockedOperators: 0, + activeSessions: 1, + }; +} diff --git a/admin-web/tsconfig.json b/admin-web/tsconfig.json new file mode 100644 index 0000000..4f1e293 --- /dev/null +++ b/admin-web/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "target": "ES2022", + "useDefineForClassFields": true, + "module": "ESNext", + "moduleResolution": "Bundler", + "lib": ["ESNext", "DOM", "DOM.Iterable"], + "types": ["node"], + "strict": true, + "noUncheckedIndexedAccess": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "resolveJsonModule": true, + "isolatedModules": true, + "verbatimModuleSyntax": true + }, + "include": ["src", "vite.config.ts"] +} diff --git a/admin-web/vite.config.ts b/admin-web/vite.config.ts new file mode 100644 index 0000000..432774e --- /dev/null +++ b/admin-web/vite.config.ts @@ -0,0 +1,19 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + base: "/admin/", + server: { + proxy: { + "/v1/admin": { + target: "http://localhost:8080", + changeOrigin: true, + }, + }, + }, + build: { + target: "es2022", + }, + test: { + environment: "jsdom", + }, +}); diff --git a/build.gradle.kts b/build.gradle.kts index 65946db..37001b9 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -20,6 +20,50 @@ application { mainClass.set("io.ktor.server.netty.EngineMain") } +val adminWebDirectory = layout.projectDirectory.dir("admin-web") +val adminWebInstall = tasks.register("adminWebInstall") { + workingDir(adminWebDirectory) + inputs.files( + adminWebDirectory.file("package.json"), + adminWebDirectory.file("package-lock.json"), + ) + outputs.dir(adminWebDirectory.dir("node_modules")) + commandLine("npm", "ci", "--no-audit", "--no-fund") +} +val adminWebBuild = tasks.register("adminWebBuild") { + dependsOn(adminWebInstall) + workingDir(adminWebDirectory) + inputs.files( + adminWebDirectory.file("index.html"), + adminWebDirectory.file("tsconfig.json"), + adminWebDirectory.file("vite.config.ts"), + ) + inputs.dir(adminWebDirectory.dir("src")) + outputs.dir(adminWebDirectory.dir("dist")) + commandLine("npm", "run", "build") +} +val adminWebTest = tasks.register("adminWebTest") { + dependsOn(adminWebInstall) + workingDir(adminWebDirectory) + inputs.files( + adminWebDirectory.file("tsconfig.json"), + adminWebDirectory.file("vite.config.ts"), + ) + inputs.dir(adminWebDirectory.dir("src")) + commandLine("npm", "run", "test") +} +val adminWebResources = tasks.register("adminWebResources") { + dependsOn(adminWebBuild) + from(adminWebDirectory.dir("dist")) + into(layout.buildDirectory.dir("generated/admin-web/admin")) +} + +sourceSets { + main { + resources.srcDir(layout.buildDirectory.dir("generated/admin-web")) + } +} + kotlin { jvmToolchain(21) compilerOptions { @@ -40,7 +84,10 @@ dependencies { implementation("io.ktor:ktor-server-auth-jvm:$ktorVersion") implementation("io.ktor:ktor-server-auth-jwt-jvm:$ktorVersion") implementation("io.ktor:ktor-server-status-pages-jvm:$ktorVersion") - implementation("io.ktor:ktor-server-call-logging-jvm:$ktorVersion") + implementation("io.ktor:ktor-server-call-logging-jvm:$ktorVersion") { + // Jansi is Windows-only console coloring here; excluding it keeps /tmp noexec-safe. + exclude(group = "org.fusesource.jansi", module = "jansi") + } implementation("io.ktor:ktor-server-rate-limit-jvm:$ktorVersion") implementation("io.ktor:ktor-server-websockets-jvm:$ktorVersion") implementation("io.ktor:ktor-server-forwarded-header-jvm:$ktorVersion") @@ -85,6 +132,7 @@ dependencies { } tasks.test { + dependsOn(adminWebTest) useJUnitPlatform() testLogging { events("failed", "skipped") @@ -93,6 +141,17 @@ tasks.test { finalizedBy(tasks.jacocoTestReport) } +tasks.processResources { + dependsOn(adminWebResources) +} + +tasks.register("generateAdminCredentials") { + group = "deployment" + description = "Generate private admin bootstrap and operator handoff files" + classpath = sourceSets["main"].runtimeClasspath + mainClass.set("com.osglab.account.tools.AdminCredentialGenerator") +} + jacoco { toolVersion = "0.8.15" } diff --git a/docs/openapi.yaml b/docs/openapi.yaml index 2bf260d..ebd540b 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -412,12 +412,345 @@ paths: "200": { description: Bilingual HTML landing page } "404": { description: Invalid, unknown, or expired invitation } "503": { description: Invitation lookup is temporarily unavailable } + /v1/admin/auth/session: + get: + security: + - adminMtls: [] + summary: Check the current administrator session + responses: + "200": + description: Authenticated or anonymous session state + content: + application/json: + schema: { $ref: "#/components/schemas/AdminSessionState" } + "404": { description: Verified administrator client certificate is absent } + /v1/admin/auth/login: + post: + security: + - adminMtls: [] + summary: Authenticate an administrator with password and TOTP + requestBody: + required: true + content: + application/json: + schema: + type: object + additionalProperties: false + required: [username, password, totpCode] + properties: + username: { type: string, minLength: 3, maxLength: 64 } + password: { type: string, minLength: 1, maxLength: 1024 } + totpCode: { type: string, pattern: "^[0-9]{6}$" } + responses: + "200": + description: Secure session and CSRF cookies created + content: + application/json: + schema: { $ref: "#/components/schemas/AdminLoginResponse" } + "401": { description: Credentials are invalid } + "429": { description: Login is locked or rate limited } + /v1/admin/auth/logout: + post: + security: + - adminMtls: [] + adminSession: [] + summary: Revoke the current administrator session + parameters: + - $ref: "#/components/parameters/AdminCsrf" + responses: + "204": { description: Session revoked } + "401": { description: Session is invalid } + /v1/admin/overview: + get: + security: + - adminMtls: [] + adminSession: [] + summary: Return registration, activity, and credit overview statistics + parameters: + - $ref: "#/components/parameters/AdminRange" + responses: + "200": + description: Overview statistics + content: + application/json: + schema: { $ref: "#/components/schemas/AdminOverview" } + "400": { description: Range is invalid } + "401": { description: Session is invalid } + /v1/admin/referrals: + get: + security: + - adminMtls: [] + adminSession: [] + summary: Return referral funnel and ranking statistics + parameters: + - $ref: "#/components/parameters/AdminRange" + responses: + "200": + description: Referral statistics + content: + application/json: + schema: { $ref: "#/components/schemas/AdminReferralOverview" } + "400": { description: Range is invalid } + "401": { description: Session is invalid } + /v1/admin/users: + get: + security: + - adminMtls: [] + adminSession: [] + summary: List users or search by exact internal user ID + parameters: + - name: q + in: query + schema: { type: string, maxLength: 36 } + - name: cursor + in: query + schema: { type: string, maxLength: 256 } + - $ref: "#/components/parameters/Limit" + responses: + "200": + description: Privacy-minimized user summaries + content: + application/json: + schema: { $ref: "#/components/schemas/AdminUserPage" } + "400": { description: Query or cursor is malformed } + "401": { description: Session is invalid } + "403": { description: ANALYST role cannot access user records } + /v1/admin/users/{userId}: + get: + security: + - adminMtls: [] + adminSession: [] + summary: Return privacy-minimized user details + parameters: + - $ref: "#/components/parameters/AdminUserId" + responses: + "200": + description: User details + content: + application/json: + schema: { $ref: "#/components/schemas/AdminUserDetail" } + "403": { description: ANALYST role cannot access user records } + "404": { description: User was not found } + /v1/admin/users/{userId}/ledger: + get: + security: + - adminMtls: [] + adminSession: [] + summary: Return the immutable credit ledger for a user + parameters: + - $ref: "#/components/parameters/AdminUserId" + - name: cursor + in: query + schema: { type: string, maxLength: 256 } + - $ref: "#/components/parameters/Limit" + responses: + "200": + description: Credit ledger entries ordered by creation time and entry ID + content: + application/json: + schema: { $ref: "#/components/schemas/AdminLedgerPage" } + "400": { description: Cursor is malformed } + "403": { description: ANALYST role cannot access credit ledger records } + "404": { description: User was not found } + /v1/admin/credits/grants: + post: + security: + - adminMtls: [] + adminSession: [] + summary: Grant integer credits through an idempotent ledger transaction + parameters: + - $ref: "#/components/parameters/AdminCsrf" + - $ref: "#/components/parameters/IdempotencyKey" + requestBody: + required: true + content: + application/json: + schema: + type: object + additionalProperties: false + required: [userId, amount, reason] + properties: + userId: { type: string, format: uuid } + amount: { type: integer, format: int64, minimum: 1 } + reason: { type: string, minLength: 4, maxLength: 200 } + responses: + "200": + description: Grant applied or replayed + content: + application/json: + schema: { $ref: "#/components/schemas/AdminGrantResponse" } + "400": { description: Grant input or idempotency key is invalid } + "403": { description: CSRF or role authorization failed } + "404": { description: Target user was not found } + "409": { description: Idempotency key conflicts with another grant } + /v1/admin/operators/summary: + get: + security: + - adminMtls: [] + adminSession: [] + summary: Return administrator and active-session security indicators + responses: + "200": + description: Security indicators + content: + application/json: + schema: + type: object + additionalProperties: false + required: [enabledOperators, lockedOperators, activeSessions] + properties: + enabledOperators: { type: integer, minimum: 0 } + lockedOperators: { type: integer, minimum: 0 } + activeSessions: { type: integer, format: int64, minimum: 0 } + "403": { description: INSUFFICIENT_PERMISSION; SUPER_ADMIN is required } + /v1/admin/operators: + get: + security: + - adminMtls: [] + adminSession: [] + summary: List administrator operators + parameters: + - name: cursor + in: query + schema: { type: string, maxLength: 256 } + - $ref: "#/components/parameters/Limit" + responses: + "200": + description: Operators ordered by creation time + content: + application/json: + schema: { $ref: "#/components/schemas/AdminOperatorPage" } + "403": { description: INSUFFICIENT_PERMISSION; SUPER_ADMIN is required } + "400": { description: Cursor or limit is malformed } + post: + security: + - adminMtls: [] + adminSession: [] + summary: Create an operator and return TOTP provisioning data once + parameters: + - $ref: "#/components/parameters/AdminCsrf" + requestBody: + required: true + content: + application/json: + schema: { $ref: "#/components/schemas/AdminOperatorCreateRequest" } + responses: + "201": + description: Operator created; plaintext TOTP material is returned only here + content: + application/json: + schema: { $ref: "#/components/schemas/AdminOperatorProvisioning" } + "400": { description: VALIDATION_ERROR } + "403": { description: CSRF_INVALID, ORIGIN_INVALID, or INSUFFICIENT_PERMISSION } + "409": { description: ADMIN_USERNAME_CONFLICT } + /v1/admin/operators/{operatorId}/enable: + post: + security: + - adminMtls: [] + adminSession: [] + summary: Enable an operator + parameters: + - $ref: "#/components/parameters/AdminOperatorId" + - $ref: "#/components/parameters/AdminCsrf" + responses: + "204": { description: Operator enabled and action audited } + "403": { description: CSRF_INVALID, ORIGIN_INVALID, or INSUFFICIENT_PERMISSION } + "404": { description: ADMIN_OPERATOR_NOT_FOUND } + /v1/admin/operators/{operatorId}/disable: + post: + security: + - adminMtls: [] + adminSession: [] + summary: Disable an operator and atomically revoke all active sessions + parameters: + - $ref: "#/components/parameters/AdminOperatorId" + - $ref: "#/components/parameters/AdminCsrf" + responses: + "204": { description: Operator disabled and sessions revoked } + "403": { description: CSRF_INVALID, ORIGIN_INVALID, or INSUFFICIENT_PERMISSION } + "404": { description: ADMIN_OPERATOR_NOT_FOUND } + "409": { description: CANNOT_DISABLE_SELF or LAST_SUPER_ADMIN_REQUIRED } + /v1/admin/operators/{operatorId}/unlock: + post: + security: + - adminMtls: [] + adminSession: [] + summary: Clear an operator login lock + parameters: + - $ref: "#/components/parameters/AdminOperatorId" + - $ref: "#/components/parameters/AdminCsrf" + responses: + "204": { description: Operator unlocked } + "403": { description: CSRF_INVALID, ORIGIN_INVALID, or INSUFFICIENT_PERMISSION } + "404": { description: ADMIN_OPERATOR_NOT_FOUND } + /v1/admin/operators/{operatorId}/credentials/reset: + post: + security: + - adminMtls: [] + adminSession: [] + summary: Reset password and TOTP, atomically revoking all active sessions + parameters: + - $ref: "#/components/parameters/AdminOperatorId" + - $ref: "#/components/parameters/AdminCsrf" + requestBody: + required: true + content: + application/json: + schema: { $ref: "#/components/schemas/AdminOperatorPasswordRequest" } + responses: + "200": + description: Credentials reset; plaintext TOTP material is returned only here + content: + application/json: + schema: { $ref: "#/components/schemas/AdminOperatorProvisioning" } + "400": { description: VALIDATION_ERROR } + "403": { description: CSRF_INVALID, ORIGIN_INVALID, or INSUFFICIENT_PERMISSION } + "404": { description: ADMIN_OPERATOR_NOT_FOUND } + /v1/admin/operators/{operatorId}/sessions/revoke: + post: + security: + - adminMtls: [] + adminSession: [] + summary: Revoke every active session for an operator + parameters: + - $ref: "#/components/parameters/AdminOperatorId" + - $ref: "#/components/parameters/AdminCsrf" + responses: + "204": { description: All active sessions revoked } + "403": { description: CSRF_INVALID, ORIGIN_INVALID, or INSUFFICIENT_PERMISSION } + "404": { description: ADMIN_OPERATOR_NOT_FOUND } + /v1/admin/audit: + get: + security: + - adminMtls: [] + adminSession: [] + summary: Return append-only administrator audit events + parameters: + - name: cursor + in: query + schema: { type: string, maxLength: 256 } + - $ref: "#/components/parameters/Limit" + responses: + "200": + description: Recent audit events + content: + application/json: + schema: { $ref: "#/components/schemas/AdminAuditPage" } + "400": { description: Cursor is malformed } + "403": { description: Super-administrator role is required } components: securitySchemes: bearerAuth: type: http scheme: bearer bearerFormat: JWT + adminMtls: + type: mutualTLS + description: Client certificate issued by the dedicated administrator CA. + adminSession: + type: apiKey + in: cookie + name: osg_admin_session parameters: Limit: name: limit @@ -432,7 +765,26 @@ components: name: Idempotency-Key in: header required: true - schema: { type: string, minLength: 1, maxLength: 255 } + schema: { type: string, minLength: 8, maxLength: 128 } + AdminCsrf: + name: X-CSRF-Token + in: header + required: true + schema: { type: string, minLength: 32, maxLength: 512 } + AdminRange: + name: range + in: query + schema: { type: string, enum: [7d, 30d, 90d], default: 30d } + AdminUserId: + name: userId + in: path + required: true + schema: { type: string, format: uuid } + AdminOperatorId: + name: operatorId + in: path + required: true + schema: { type: string, format: uuid } responses: HealthUp: description: Service is healthy @@ -454,6 +806,274 @@ components: application/json: schema: { $ref: "#/components/schemas/GatewayError" } schemas: + AdminSessionState: + type: object + additionalProperties: false + required: [authenticated] + properties: + authenticated: { type: boolean } + operatorName: { type: ["string", "null"], minLength: 3, maxLength: 64 } + role: + type: ["string", "null"] + enum: [SUPER_ADMIN, SUPPORT, ANALYST, null] + description: CSRF material is intentionally not reconstructed or returned by session checks. + AdminLoginResponse: + type: object + additionalProperties: false + required: [operatorName, role, csrfToken] + properties: + operatorName: { type: string, minLength: 3, maxLength: 64 } + role: { type: string, enum: [SUPER_ADMIN, SUPPORT, ANALYST] } + csrfToken: + type: string + minLength: 32 + maxLength: 512 + description: Returned once for the new session; the CSRF cookie is the reload fallback. + AdminUsageAggregate: + type: object + additionalProperties: false + required: [kind, requests, chargedCredits, asrMillis, inputTokens, outputTokens] + properties: + kind: { type: string } + requests: { type: integer, format: int64, minimum: 0 } + chargedCredits: { type: integer, format: int64, minimum: 0 } + asrMillis: { type: integer, format: int64, minimum: 0 } + inputTokens: { type: integer, format: int64, minimum: 0 } + outputTokens: { type: integer, format: int64, minimum: 0 } + AdminTrendPoint: + type: object + additionalProperties: false + required: [date, registrations, creditsUsed] + properties: + date: { type: string, format: date } + registrations: { type: integer, format: int64, minimum: 0 } + creditsUsed: { type: integer, format: int64, minimum: 0 } + AdminOverview: + type: object + additionalProperties: false + required: + - totalUsers + - activeUsers + - newUsers + - totalCreditBalance + - creditsGranted + - creditsUsed + - trend + - usage + properties: + totalUsers: { type: integer, format: int64, minimum: 0 } + activeUsers: { type: integer, format: int64, minimum: 0 } + newUsers: { type: integer, format: int64, minimum: 0 } + totalCreditBalance: { type: integer, format: int64, minimum: 0 } + creditsGranted: { type: integer, format: int64, minimum: 0 } + creditsUsed: { type: integer, format: int64, minimum: 0 } + trend: + type: array + items: { $ref: "#/components/schemas/AdminTrendPoint" } + usage: + type: array + items: { $ref: "#/components/schemas/AdminUsageAggregate" } + AdminFunnelStep: + type: object + additionalProperties: false + required: [label, count] + properties: + label: + type: string + enum: [邀请码创建, 成功绑定, 有效使用并奖励, 待资格确认, 不符合奖励条件] + count: { type: integer, format: int64, minimum: 0 } + AdminReferralRank: + type: object + additionalProperties: false + required: [userId, invited, qualified, creditsEarned] + properties: + userId: { type: string, format: uuid } + invited: { type: integer, format: int64, minimum: 0 } + qualified: { type: integer, format: int64, minimum: 0 } + creditsEarned: { type: integer, format: int64, minimum: 0 } + AdminReferralOverview: + type: object + additionalProperties: false + required: [pendingBindings, ineligibleBindings, funnel, ranking] + properties: + pendingBindings: { type: integer, format: int64, minimum: 0 } + ineligibleBindings: { type: integer, format: int64, minimum: 0 } + funnel: + type: array + items: { $ref: "#/components/schemas/AdminFunnelStep" } + ranking: + type: array + items: { $ref: "#/components/schemas/AdminReferralRank" } + AdminUserSummary: + type: object + additionalProperties: false + required: [userId, displayName, status, creditBalance, createdAt] + properties: + userId: { type: string, format: uuid } + displayName: { type: string } + status: { type: string, enum: [active, suspended, closed] } + creditBalance: { type: integer, format: int64, minimum: 0 } + createdAt: { type: string, format: date-time } + AdminUserPage: + type: object + additionalProperties: false + required: [items] + properties: + items: + type: array + items: { $ref: "#/components/schemas/AdminUserSummary" } + nextCursor: { type: ["string", "null"] } + AdminUserReferral: + type: object + additionalProperties: false + required: [invitedUsers, rewardedInvites] + properties: + inviterUserId: { type: ["string", "null"], format: uuid } + invitedUsers: { type: integer, format: int64, minimum: 0 } + rewardedInvites: { type: integer, format: int64, minimum: 0 } + AdminUserDetail: + type: object + additionalProperties: false + required: + - userId + - displayName + - status + - creditBalance + - createdAt + - qualifiedUsage + - usage + - referral + properties: + userId: { type: string, format: uuid } + displayName: { type: string } + status: { type: string, enum: [active, suspended, closed] } + creditBalance: { type: integer, format: int64, minimum: 0 } + createdAt: { type: string, format: date-time } + lastActiveAt: { type: ["string", "null"], format: date-time } + qualifiedUsage: { type: boolean } + referralCode: { type: ["string", "null"] } + referredByUserId: { type: ["string", "null"], format: uuid } + usage: + type: array + items: { $ref: "#/components/schemas/AdminUsageAggregate" } + referral: { $ref: "#/components/schemas/AdminUserReferral" } + AdminLedgerEntry: + type: object + additionalProperties: false + required: [entryId, type, amount, balanceAfter, reasonCode, createdAt] + properties: + entryId: { type: string, format: uuid } + type: { type: string, enum: [grant, reserve, settle, refund, adjustment] } + amount: { type: integer, format: int64 } + balanceAfter: { type: integer, format: int64, minimum: 0 } + reasonCode: { type: string } + createdAt: { type: string, format: date-time } + AdminLedgerPage: + type: object + additionalProperties: false + required: [items] + properties: + items: + type: array + items: { $ref: "#/components/schemas/AdminLedgerEntry" } + nextCursor: { type: ["string", "null"] } + AdminGrantResponse: + type: object + additionalProperties: false + required: [transactionId, balanceAfter] + properties: + transactionId: { type: string, format: uuid } + balanceAfter: { type: integer, format: int64, minimum: 0 } + AdminAudit: + type: object + additionalProperties: false + required: [auditId, operatorName, action, targetType, targetId, result, createdAt] + properties: + auditId: { type: string, format: uuid } + operatorName: { type: string } + action: { type: string } + targetType: { type: string } + targetId: { type: string } + requestId: { type: ["string", "null"] } + result: { type: string, enum: [success, rejected] } + createdAt: { type: string, format: date-time } + AdminAuditPage: + type: object + additionalProperties: false + required: [items] + properties: + items: + type: array + items: { $ref: "#/components/schemas/AdminAudit" } + nextCursor: { type: ["string", "null"] } + AdminOperator: + type: object + additionalProperties: false + required: + - operatorId + - username + - role + - enabled + - failedLoginCount + - createdAt + - updatedAt + properties: + operatorId: { type: string, format: uuid } + username: + type: string + pattern: "^[a-z0-9][a-z0-9._@-]{2,63}$" + role: { type: string, enum: [SUPER_ADMIN, SUPPORT, ANALYST] } + enabled: { type: boolean } + failedLoginCount: { type: integer, minimum: 0 } + lockedUntil: { type: ["string", "null"], format: date-time } + lastLoginAt: { type: ["string", "null"], format: date-time } + createdAt: { type: string, format: date-time } + updatedAt: { type: string, format: date-time } + AdminOperatorPage: + type: object + additionalProperties: false + required: [items] + properties: + items: + type: array + items: { $ref: "#/components/schemas/AdminOperator" } + nextCursor: { type: ["string", "null"] } + AdminOperatorCreateRequest: + type: object + additionalProperties: false + required: [username, password, role] + properties: + username: + type: string + minLength: 3 + maxLength: 64 + pattern: "^[A-Za-z0-9][A-Za-z0-9._@-]{2,63}$" + password: { type: string, minLength: 12, maxLength: 1024 } + role: { type: string, enum: [SUPER_ADMIN, SUPPORT, ANALYST] } + AdminOperatorPasswordRequest: + type: object + additionalProperties: false + required: [password] + properties: + password: { type: string, minLength: 12, maxLength: 1024 } + AdminOperatorProvisioning: + type: object + additionalProperties: false + required: [operatorId, totpSecret, otpauthUri] + properties: + operatorId: { type: string, format: uuid } + operator: + oneOf: + - $ref: "#/components/schemas/AdminOperator" + - type: "null" + totpSecret: + type: string + pattern: "^[A-Z2-7]{32}$" + description: 160-bit Base32 secret returned once; never persisted in plaintext. + otpauthUri: + type: string + pattern: "^otpauth://totp/" + description: Provisioning URI returned once; never persisted. AppleSignInRequest: type: object additionalProperties: false diff --git a/src/main/kotlin/com/osglab/account/Application.kt b/src/main/kotlin/com/osglab/account/Application.kt index 9ef91b9..9734d65 100644 --- a/src/main/kotlin/com/osglab/account/Application.kt +++ b/src/main/kotlin/com/osglab/account/Application.kt @@ -7,6 +7,27 @@ import com.osglab.account.common.security.SessionJwt import com.osglab.account.common.security.installSessionAuthentication import com.osglab.account.config.AppConfig import com.osglab.account.config.DatabaseFactory +import com.osglab.account.features.admin.grants.services.AdminGrantService +import com.osglab.account.features.admin.repositories.AdminRepository +import com.osglab.account.features.admin.repositories.ExposedAdminRepository +import com.osglab.account.features.admin.routes.adminApiRoutes +import com.osglab.account.features.admin.routes.adminWebRoutes +import com.osglab.account.features.admin.security.AdminPasswordHasher +import com.osglab.account.features.admin.security.AdminTotpVerifier +import com.osglab.account.features.admin.security.BouncyCastleArgon2idPasswordHasher +import com.osglab.account.features.admin.security.HmacTotpVerifier +import com.osglab.account.features.admin.services.AdminAuthService +import com.osglab.account.features.admin.services.AdminAuditService +import com.osglab.account.features.admin.services.AdminBootstrapConfig +import com.osglab.account.features.admin.services.AdminBootstrapService +import com.osglab.account.features.admin.services.AdminOperatorService +import com.osglab.account.features.admin.services.AdminSessionService +import com.osglab.account.features.admin.stats.repositories.AdminStatsRepository +import com.osglab.account.features.admin.stats.repositories.ExposedAdminStatsRepository +import com.osglab.account.features.admin.stats.services.AdminStatsService +import com.osglab.account.features.admin.users.repositories.AdminUsersRepository +import com.osglab.account.features.admin.users.repositories.ExposedAdminUsersRepository +import com.osglab.account.features.admin.users.services.AdminUsersService import com.osglab.account.features.account.AccountRepository import com.osglab.account.features.account.AccountReauthenticator import com.osglab.account.features.account.AccountService @@ -120,6 +141,7 @@ import kotlinx.coroutines.CancellationException import kotlinx.coroutines.delay import kotlinx.coroutines.isActive import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking import org.koin.core.module.Module import org.koin.dsl.module import org.koin.ktor.ext.getKoin @@ -180,6 +202,12 @@ fun Application.module() { register(PUBLIC_RATE_LIMIT) { rateLimiter(limit = 120, refillPeriod = 1.minutes) } + register(ADMIN_AUTH_RATE_LIMIT) { + rateLimiter(limit = 5, refillPeriod = 1.minutes) + } + register(ADMIN_API_RATE_LIMIT) { + rateLimiter(limit = 60, refillPeriod = 1.minutes) + } } installApiStatusPages() @@ -191,6 +219,19 @@ fun Application.module() { val koin = getKoin() // Fail startup before accepting traffic if migrations or database connectivity fail. koin.get().database + if (appConfig.admin.bootstrapEnabled) { + runBlocking { + koin.get().initialize( + AdminBootstrapConfig( + enabled = true, + operatorId = appConfig.admin.bootstrapOperatorId, + username = appConfig.admin.bootstrapUsername, + passwordHash = appConfig.admin.bootstrapPasswordHash, + totpSecretBase32 = appConfig.admin.bootstrapTotpSecretBase32, + ), + ) + } + } val sessionAuthenticator = koin.get() installSessionAuthentication(sessionAuthenticator::authenticate) val asrStreaming = if (appConfig.providers.volcengine.credentialsAvailable) { @@ -220,6 +261,15 @@ fun Application.module() { } catch (_: Exception) { // Durable settlement state is retried without logging provider data. } + if (appConfig.admin.enabled) { + try { + koin.get().cleanupInactive() + } catch (exception: CancellationException) { + throw exception + } catch (_: Exception) { + // Expired sessions are retried in bounded batches on the next cycle. + } + } delay(60_000) } } @@ -253,6 +303,21 @@ fun Application.module() { configureInviteWebRoutes(koin.get(), koin.get()) integrityRoutes(koin.get()) } + if (appConfig.admin.enabled) { + adminWebRoutes() + rateLimit(ADMIN_API_RATE_LIMIT) { + adminApiRoutes( + config = appConfig, + authService = koin.get(), + sessionService = koin.get(), + statsService = koin.get(), + usersService = koin.get(), + grantService = koin.get(), + operatorService = koin.get(), + auditService = koin.get(), + ) + } + } } } @@ -296,6 +361,33 @@ fun accountServerModule(config: AppConfig): Module = module { single { SessionJwt(config.session) } single { FieldEncryptor(config.encryption.key) } single { IdentityFingerprint(config.antiAbuse.identityHmacKey) } + single { ExposedAdminRepository(get()) } + single { BouncyCastleArgon2idPasswordHasher() } + single { HmacTotpVerifier() } + single { + val dummyPassword = "invalid-admin-password-constant-work".toCharArray() + try { + AdminAuthService( + repository = get(), + passwordHasher = get(), + dummyPasswordHash = get().hash(dummyPassword), + totpVerifier = get(), + fieldEncryptor = get(), + sessionTtl = Duration.ofHours(config.admin.sessionHours), + ) + } finally { + dummyPassword.fill('\u0000') + } + } + single { AdminSessionService(get()) } + single { AdminBootstrapService(get(), get()) } + single { AdminOperatorService(get(), get(), get()) } + single { AdminAuditService(get()) } + single { ExposedAdminStatsRepository(get()) } + single { AdminStatsService(get()) } + single { ExposedAdminUsersRepository(get()) } + single { AdminUsersService(get()) } + single { AdminGrantService(get()) } single { RemoteAppleJwksProvider(get(), config.apple.jwksUrl) } @@ -513,3 +605,5 @@ private val AUTH_RATE_LIMIT = RateLimitName("auth") private val ACCOUNT_RATE_LIMIT = RateLimitName("account") private val GATEWAY_RATE_LIMIT = RateLimitName("gateway") private val PUBLIC_RATE_LIMIT = RateLimitName("public") +private val ADMIN_AUTH_RATE_LIMIT = RateLimitName("admin-auth") +private val ADMIN_API_RATE_LIMIT = RateLimitName("admin-api") diff --git a/src/main/kotlin/com/osglab/account/config/AppConfig.kt b/src/main/kotlin/com/osglab/account/config/AppConfig.kt index 86323f8..be463fd 100644 --- a/src/main/kotlin/com/osglab/account/config/AppConfig.kt +++ b/src/main/kotlin/com/osglab/account/config/AppConfig.kt @@ -3,6 +3,7 @@ package com.osglab.account.config import io.ktor.server.config.ApplicationConfig import java.net.URI import java.util.Base64 +import java.util.UUID data class AppConfig( val environment: Environment, @@ -17,6 +18,7 @@ data class AppConfig( val credits: CreditsConfig, val providers: ProvidersConfig, val integrity: IntegrityConfig, + val admin: AdminConfig = AdminConfig(), ) { val isProduction: Boolean = environment == Environment.PRODUCTION @@ -119,6 +121,41 @@ data class AppConfig( 300, ), ) + val adminEnabled = config.booleanOrDefault("app.admin.enabled", false) + val adminBootstrapEnabled = config.booleanOrDefault( + "app.admin.bootstrapEnabled", + false, + ) + require(!adminBootstrapEnabled || adminEnabled) { + "app.admin.bootstrapEnabled requires app.admin.enabled" + } + val admin = AdminConfig( + enabled = adminEnabled, + bootstrapEnabled = adminBootstrapEnabled, + bootstrapOperatorId = config.optionalValue("app.admin.bootstrapOperatorId") + ?.let { + runCatching { UUID.fromString(it) }.getOrElse { cause -> + throw ConfigValidationException( + "app.admin.bootstrapOperatorId must be a UUID", + cause, + ) + } + }, + bootstrapUsername = config.optionalValue("app.admin.bootstrapUsername"), + bootstrapPasswordHash = config.optionalLiteralSecret( + "app.admin.bootstrapPasswordHash", + production && adminBootstrapEnabled, + ), + bootstrapTotpSecretBase32 = config.optionalSecret( + "app.admin.bootstrapTotpSecretBase32", + production && adminBootstrapEnabled, + ), + sessionHours = config.positiveLong("app.admin.sessionHours", 8), + maximumManualGrant = config.positiveLong( + "app.admin.maximumManualGrant", + 100_000, + ), + ) require(session.hmacSecret.size >= MIN_HMAC_SECRET_BYTES) { "app.session.secret must contain at least $MIN_HMAC_SECRET_BYTES bytes" @@ -199,6 +236,26 @@ data class AppConfig( require(!production || apple.clientId == APP_ATTEST_BUNDLE_ID) { "Production Apple client ID must be $APP_ATTEST_BUNDLE_ID" } + require(admin.sessionHours in 1..24) { + "app.admin.sessionHours must be between 1 and 24" + } + require(admin.maximumManualGrant in 1..100_000_000) { + "app.admin.maximumManualGrant must be between 1 and 100000000" + } + if (admin.bootstrapEnabled) { + requireNotNull(admin.bootstrapOperatorId) { + "app.admin.bootstrapOperatorId is required when admin bootstrap is enabled" + } + require(!admin.bootstrapUsername.isNullOrBlank()) { + "app.admin.bootstrapUsername is required when admin bootstrap is enabled" + } + require(!admin.bootstrapPasswordHash.isNullOrBlank()) { + "app.admin.bootstrapPasswordHash is required when admin bootstrap is enabled" + } + require(!admin.bootstrapTotpSecretBase32.isNullOrBlank()) { + "app.admin.bootstrapTotpSecretBase32 is required when admin bootstrap is enabled" + } + } if (production) { requireExactAppleEndpoint(apple.jwksUrl, "/auth/keys", "JWKS") requireExactAppleEndpoint(apple.tokenUrl, "/auth/token", "token") @@ -239,6 +296,7 @@ data class AppConfig( credits = credits, providers = providers, integrity = integrity, + admin = admin, ) } } @@ -335,6 +393,17 @@ data class IntegrityConfig( val appAttestBundleId: String = APP_ATTEST_BUNDLE_ID, ) +data class AdminConfig( + val enabled: Boolean = false, + val bootstrapEnabled: Boolean = false, + val bootstrapOperatorId: UUID? = null, + val bootstrapUsername: String? = null, + val bootstrapPasswordHash: String? = null, + val bootstrapTotpSecretBase32: String? = null, + val sessionHours: Long = 8, + val maximumManualGrant: Long = 100_000, +) + enum class IntegrityPolicy { MONITOR, ENFORCE; @@ -393,6 +462,19 @@ private fun ApplicationConfig.optionalSecret(path: String, production: Boolean): return value?.takeUnless(String::isPlaceholder) } +private fun ApplicationConfig.optionalLiteralSecret(path: String, production: Boolean): String? { + val value = propertyOrNull(path)?.getString()?.trim()?.takeIf(String::isNotEmpty) + val placeholder = value?.let { + it.contains("replace-with", ignoreCase = true) || + it.contains("change-me", ignoreCase = true) || + it.contains("\${") + } == true + if (production && (value == null || placeholder)) { + throw ConfigValidationException("Production secret is missing or uses a placeholder: $path") + } + return value?.takeUnless { placeholder } +} + private fun String.isPlaceholder(): Boolean = PLACEHOLDER_MARKERS.any { marker -> contains(marker, ignoreCase = true) } @@ -419,6 +501,15 @@ private fun ApplicationConfig.boolean(path: String): Boolean = } } +private fun ApplicationConfig.booleanOrDefault(path: String, default: Boolean): Boolean = + propertyOrNull(path)?.getString()?.trim()?.takeIf(String::isNotEmpty)?.let { + when (it.lowercase()) { + "true" -> true + "false" -> false + else -> throw ConfigValidationException("$path must be true or false") + } + } ?: default + private fun ApplicationConfig.base64Key(path: String, production: Boolean): ByteArray { val encoded = secret(path, production) return try { diff --git a/src/main/kotlin/com/osglab/account/features/admin/grants/models/AdminGrantDtos.kt b/src/main/kotlin/com/osglab/account/features/admin/grants/models/AdminGrantDtos.kt new file mode 100644 index 0000000..2bf53c0 --- /dev/null +++ b/src/main/kotlin/com/osglab/account/features/admin/grants/models/AdminGrantDtos.kt @@ -0,0 +1,27 @@ +package com.osglab.account.features.admin.grants.models + +import kotlinx.serialization.Serializable +import java.util.UUID + +data class ManualGrantCommand( + val operatorId: UUID, + val userId: UUID, + val credits: Long, + val reason: String, + val requestId: String? = null, + val idempotencyKey: String, +) + +@Serializable +data class AdminCreditGrantDto( + val id: String, + val operatorId: String, + val userId: String, + val credits: Long, + val reason: String, + val ledgerEntryId: String, + val auditLogId: String, + val balanceAfter: Long, + val createdAt: String, + val replayed: Boolean, +) diff --git a/src/main/kotlin/com/osglab/account/features/admin/grants/repositories/AdminCreditGrantRepository.kt b/src/main/kotlin/com/osglab/account/features/admin/grants/repositories/AdminCreditGrantRepository.kt new file mode 100644 index 0000000..14ee0f6 --- /dev/null +++ b/src/main/kotlin/com/osglab/account/features/admin/grants/repositories/AdminCreditGrantRepository.kt @@ -0,0 +1,84 @@ +package com.osglab.account.features.admin.grants.repositories + +import com.osglab.account.features.admin.models.NewAdminAuditEvent +import com.osglab.account.features.admin.repositories.AdminAuditLogTable +import com.osglab.account.features.credits.domain.ManualCreditGrant +import org.jetbrains.exposed.v1.core.ResultRow +import org.jetbrains.exposed.v1.core.Table +import org.jetbrains.exposed.v1.core.and +import org.jetbrains.exposed.v1.core.eq +import org.jetbrains.exposed.v1.javatime.timestamp +import org.jetbrains.exposed.v1.jdbc.insert +import org.jetbrains.exposed.v1.jdbc.selectAll +import java.util.UUID + +interface AdminCreditGrantRepository { + fun findByIdempotencyKey(idempotencyKey: String): ManualCreditGrant? + + fun insertAudit(event: NewAdminAuditEvent) + fun insert(grant: ManualCreditGrant) +} + +internal object AdminCreditGrantsTable : Table("admin_credit_grants") { + val id = varchar("id", 36) + val operatorId = varchar("operator_id", 36) + val accountId = varchar("account_id", 36) + val amount = long("amount") + val reason = varchar("reason", 500) + val idempotencyKey = varchar("idempotency_key", 128) + val ledgerEntryId = varchar("ledger_entry_id", 36) + val auditLogId = varchar("audit_log_id", 36) + val createdAt = timestamp("created_at") + + override val primaryKey = PrimaryKey(id) +} + +internal object ExposedAdminCreditGrantRepository : AdminCreditGrantRepository { + override fun findByIdempotencyKey( + idempotencyKey: String, + ): ManualCreditGrant? = + AdminCreditGrantsTable + .selectAll() + .where { AdminCreditGrantsTable.idempotencyKey eq idempotencyKey } + .singleOrNull() + ?.toManualCreditGrant() + + override fun insertAudit(event: NewAdminAuditEvent) { + AdminAuditLogTable.insert { + it[id] = event.id.toString() + it[actorOperatorId] = event.actorOperatorId?.toString() + it[action] = event.action.name + it[outcome] = event.outcome.name + it[targetType] = event.targetType + it[targetId] = event.targetId + it[requestId] = event.requestId + it[occurredAt] = event.occurredAt + } + } + + override fun insert(grant: ManualCreditGrant) { + AdminCreditGrantsTable.insert { + it[id] = grant.id.toString() + it[operatorId] = grant.operatorId.toString() + it[accountId] = grant.userId.toString() + it[amount] = grant.amount + it[reason] = grant.reason + it[idempotencyKey] = grant.idempotencyKey + it[ledgerEntryId] = grant.ledgerEntryId.toString() + it[auditLogId] = grant.auditLogId.toString() + it[createdAt] = grant.createdAt + } + } +} + +private fun ResultRow.toManualCreditGrant() = ManualCreditGrant( + id = UUID.fromString(this[AdminCreditGrantsTable.id]), + operatorId = UUID.fromString(this[AdminCreditGrantsTable.operatorId]), + userId = UUID.fromString(this[AdminCreditGrantsTable.accountId]), + amount = this[AdminCreditGrantsTable.amount], + reason = this[AdminCreditGrantsTable.reason], + idempotencyKey = this[AdminCreditGrantsTable.idempotencyKey], + ledgerEntryId = UUID.fromString(this[AdminCreditGrantsTable.ledgerEntryId]), + auditLogId = UUID.fromString(this[AdminCreditGrantsTable.auditLogId]), + createdAt = this[AdminCreditGrantsTable.createdAt], +) diff --git a/src/main/kotlin/com/osglab/account/features/admin/grants/services/AdminGrantService.kt b/src/main/kotlin/com/osglab/account/features/admin/grants/services/AdminGrantService.kt new file mode 100644 index 0000000..86669f8 --- /dev/null +++ b/src/main/kotlin/com/osglab/account/features/admin/grants/services/AdminGrantService.kt @@ -0,0 +1,32 @@ +package com.osglab.account.features.admin.grants.services + +import com.osglab.account.features.admin.grants.models.AdminCreditGrantDto +import com.osglab.account.features.admin.grants.models.ManualGrantCommand +import com.osglab.account.features.credits.services.CreditOperations + +class AdminGrantService( + private val credits: CreditOperations, +) { + suspend fun grant(command: ManualGrantCommand): AdminCreditGrantDto { + val result = credits.grantManual( + operatorId = command.operatorId, + userId = command.userId, + credits = command.credits, + reason = command.reason, + requestId = command.requestId, + idempotencyKey = command.idempotencyKey, + ) + return AdminCreditGrantDto( + id = result.grant.id.toString(), + operatorId = result.grant.operatorId.toString(), + userId = result.grant.userId.toString(), + credits = result.grant.amount, + reason = result.grant.reason, + ledgerEntryId = result.grant.ledgerEntryId.toString(), + auditLogId = result.grant.auditLogId.toString(), + balanceAfter = result.balanceAfter, + createdAt = result.grant.createdAt.toString(), + replayed = result.replayed, + ) + } +} diff --git a/src/main/kotlin/com/osglab/account/features/admin/models/AdminModels.kt b/src/main/kotlin/com/osglab/account/features/admin/models/AdminModels.kt new file mode 100644 index 0000000..6144c38 --- /dev/null +++ b/src/main/kotlin/com/osglab/account/features/admin/models/AdminModels.kt @@ -0,0 +1,189 @@ +package com.osglab.account.features.admin.models + +import java.time.Instant +import java.util.UUID + +enum class AdminRole { + SUPER_ADMIN, + SUPPORT, + ANALYST, +} + +data class AdminOperatorRecord( + val id: UUID, + val normalizedUsername: String, + val role: AdminRole, + val lockState: AdminLockState, + val disabledAt: Instant?, + val lastLoginAt: Instant?, + val createdAt: Instant, + val updatedAt: Instant, +) + +data class AdminOperatorCursor( + val createdAt: Instant, + val id: UUID, +) + +data class NewAdminOperator( + val id: UUID, + val normalizedUsername: String, + val passwordHash: String, + val encryptedTotpSecret: String, + val role: AdminRole, + val createdAt: Instant, +) { + init { + require(normalizedUsername.isNotBlank()) + require(passwordHash.isNotBlank()) + require(encryptedTotpSecret.isNotBlank()) + } +} + +data class AdminLockState( + val failedLoginCount: Int, + val lockedUntil: Instant?, +) { + init { + require(failedLoginCount >= 0) + } + + fun isLockedAt(now: Instant): Boolean = lockedUntil?.isAfter(now) == true +} + +/** + * Authentication-only record. Its string representation deliberately excludes + * password and TOTP material. + */ +class AdminOperatorAuthRecord( + val id: UUID, + val normalizedUsername: String, + val passwordHash: String, + val encryptedTotpSecret: String, + val role: AdminRole, + val lockState: AdminLockState, + val disabledAt: Instant?, +) { + override fun toString(): String = + "AdminOperatorAuthRecord(id=$id, normalizedUsername=$normalizedUsername, " + + "role=$role, lockState=$lockState, disabled=${disabledAt != null})" +} + +data class NewAdminSession( + val id: UUID, + val operatorId: UUID, + val tokenHash: String, + val csrfTokenHash: String, + val createdAt: Instant, + val expiresAt: Instant, +) { + init { + require(expiresAt.isAfter(createdAt)) + } +} + +/** + * Repository session record. Token digests are omitted from logs and errors. + */ +class AdminSessionRecord( + val id: UUID, + val operatorId: UUID, + val normalizedUsername: String, + val role: AdminRole, + val csrfTokenHash: String, + val expiresAt: Instant, +) { + override fun toString(): String = + "AdminSessionRecord(id=$id, operatorId=$operatorId, " + + "normalizedUsername=$normalizedUsername, role=$role, expiresAt=$expiresAt)" +} + +data class AdminPrincipal( + val operatorId: UUID, + val sessionId: UUID, + val normalizedUsername: String, + val role: AdminRole, +) + +enum class AdminAuditAction { + LOGIN_SUCCEEDED, + LOGIN_FAILED, + SESSION_REVOKED, + OPERATOR_CREATED, + OPERATOR_ENABLED, + OPERATOR_DISABLED, + OPERATOR_UNLOCKED, + OPERATOR_CREDENTIALS_RESET, + OPERATOR_SESSIONS_REVOKED, + MANUAL_CREDIT_GRANTED, +} + +enum class AdminAuditOutcome { + SUCCESS, + DENIED, +} + +data class NewAdminAuditEvent( + val id: UUID = UUID.randomUUID(), + val actorOperatorId: UUID?, + val action: AdminAuditAction, + val outcome: AdminAuditOutcome, + val targetType: String? = null, + val targetId: String? = null, + val requestId: String? = null, + val occurredAt: Instant, +) { + init { + require((targetType == null) == (targetId == null)) + require(targetType == null || targetType.isNotBlank()) + require(targetId == null || targetId.isNotBlank()) + require(requestId == null || requestId.isNotBlank()) + } +} + +data class AdminAuditRecord( + val id: UUID, + val actorOperatorId: UUID?, + val action: AdminAuditAction, + val outcome: AdminAuditOutcome, + val targetType: String?, + val targetId: String?, + val requestId: String?, + val occurredAt: Instant, +) + +data class AdminAuditCursor( + val occurredAt: Instant, + val id: UUID, +) + +/** + * Raw credentials are returned once and must only be transported in secure, + * HttpOnly/Secure cookies. They are never persisted by this service. + */ +class AdminSessionCredentials( + val sessionToken: String, + val csrfToken: String, + val expiresAt: Instant, +) { + override fun toString(): String = + "AdminSessionCredentials(sessionToken=[REDACTED], csrfToken=[REDACTED], expiresAt=$expiresAt)" +} + +sealed interface AdminLoginResult { + data class Authenticated( + val principal: AdminPrincipal, + val credentials: AdminSessionCredentials, + ) : AdminLoginResult + + data class Locked(val retryAt: Instant) : AdminLoginResult + + data object InvalidCredentials : AdminLoginResult +} + +enum class AdminOperatorMutationResult { + SUCCESS, + NOT_FOUND, + USERNAME_CONFLICT, + LAST_SUPER_ADMIN, +} diff --git a/src/main/kotlin/com/osglab/account/features/admin/repositories/AdminRepository.kt b/src/main/kotlin/com/osglab/account/features/admin/repositories/AdminRepository.kt new file mode 100644 index 0000000..8d9c68b --- /dev/null +++ b/src/main/kotlin/com/osglab/account/features/admin/repositories/AdminRepository.kt @@ -0,0 +1,641 @@ +package com.osglab.account.features.admin.repositories + +import com.osglab.account.config.DatabaseFactory +import com.osglab.account.features.admin.models.AdminAuditCursor +import com.osglab.account.features.admin.models.AdminLockState +import com.osglab.account.features.admin.models.AdminAuditAction +import com.osglab.account.features.admin.models.AdminAuditOutcome +import com.osglab.account.features.admin.models.AdminAuditRecord +import com.osglab.account.features.admin.models.AdminOperatorAuthRecord +import com.osglab.account.features.admin.models.AdminOperatorCursor +import com.osglab.account.features.admin.models.AdminOperatorMutationResult +import com.osglab.account.features.admin.models.AdminOperatorRecord +import com.osglab.account.features.admin.models.AdminRole +import com.osglab.account.features.admin.models.AdminSessionRecord +import com.osglab.account.features.admin.models.NewAdminAuditEvent +import com.osglab.account.features.admin.models.NewAdminOperator +import com.osglab.account.features.admin.models.NewAdminSession +import org.jetbrains.exposed.v1.core.ResultRow +import org.jetbrains.exposed.v1.core.SortOrder +import org.jetbrains.exposed.v1.core.Table +import org.jetbrains.exposed.v1.core.and +import org.jetbrains.exposed.v1.core.eq +import org.jetbrains.exposed.v1.core.greater +import org.jetbrains.exposed.v1.core.inList +import org.jetbrains.exposed.v1.core.isNotNull +import org.jetbrains.exposed.v1.core.isNull +import org.jetbrains.exposed.v1.core.less +import org.jetbrains.exposed.v1.core.lessEq +import org.jetbrains.exposed.v1.core.or +import org.jetbrains.exposed.v1.javatime.timestamp +import org.jetbrains.exposed.v1.jdbc.andWhere +import org.jetbrains.exposed.v1.jdbc.deleteWhere +import org.jetbrains.exposed.v1.jdbc.insert +import org.jetbrains.exposed.v1.jdbc.insertIgnore +import org.jetbrains.exposed.v1.jdbc.selectAll +import org.jetbrains.exposed.v1.jdbc.update +import java.time.Instant +import java.util.UUID + +internal object AdminOperatorsTable : Table("admin_operators") { + val id = varchar("id", 36) + val username = varchar("username", 64).uniqueIndex() + val passwordHash = varchar("password_hash", 255) + val encryptedTotpSecret = text("encrypted_totp_secret") + val role = varchar("role", 32) + val failedLoginCount = integer("failed_login_count") + val lockedUntil = timestamp("locked_until").nullable() + val lastTotpCounter = long("last_totp_counter").nullable() + val lastLoginAt = timestamp("last_login_at").nullable() + val disabledAt = timestamp("disabled_at").nullable() + val createdAt = timestamp("created_at") + val updatedAt = timestamp("updated_at") + override val primaryKey = PrimaryKey(id) +} + +internal object AdminSessionsTable : Table("admin_sessions") { + val id = varchar("id", 36) + val operatorId = varchar("operator_id", 36).index() + val tokenHash = char("token_hash", 64).uniqueIndex() + val csrfTokenHash = char("csrf_token_hash", 64) + val createdAt = timestamp("created_at") + val expiresAt = timestamp("expires_at").index() + val revokedAt = timestamp("revoked_at").nullable() + override val primaryKey = PrimaryKey(id) +} + +internal object AdminAuditLogTable : Table("admin_audit_log") { + val id = varchar("id", 36) + val actorOperatorId = varchar("actor_operator_id", 36).nullable().index() + val action = varchar("action", 64) + val outcome = varchar("outcome", 32) + val targetType = varchar("target_type", 64).nullable() + val targetId = varchar("target_id", 128).nullable() + val requestId = varchar("request_id", 128).nullable() + val occurredAt = timestamp("occurred_at").index() + override val primaryKey = PrimaryKey(id) +} + +interface AdminRepository { + suspend fun createOperatorIfAbsent(operator: NewAdminOperator): Boolean + suspend fun createOperator( + operator: NewAdminOperator, + auditEvent: NewAdminAuditEvent, + ): AdminOperatorMutationResult + suspend fun listOperators(): List + suspend fun listOperatorsPage( + limit: Int, + before: AdminOperatorCursor? = null, + ): List + suspend fun countActiveSessions(now: Instant): Long + suspend fun findOperator(operatorId: UUID): AdminOperatorRecord? + suspend fun setOperatorEnabled( + operatorId: UUID, + enabled: Boolean, + now: Instant, + auditEvent: NewAdminAuditEvent, + ): AdminOperatorMutationResult + suspend fun unlockOperator( + operatorId: UUID, + now: Instant, + auditEvent: NewAdminAuditEvent, + ): AdminOperatorMutationResult + suspend fun resetOperatorCredentials( + operatorId: UUID, + passwordHash: String, + encryptedTotpSecret: String, + now: Instant, + auditEvent: NewAdminAuditEvent, + ): AdminOperatorMutationResult + suspend fun revokeOperatorSessions( + operatorId: UUID, + now: Instant, + auditEvent: NewAdminAuditEvent, + ): AdminOperatorMutationResult + + suspend fun findOperatorForAuthentication(normalizedUsername: String): AdminOperatorAuthRecord? + + /** + * Executes the transformation while holding the operator row lock. + */ + suspend fun updateLockState( + operatorId: UUID, + now: Instant, + auditEvent: NewAdminAuditEvent, + transform: (AdminLockState) -> AdminLockState, + ): AdminLockState? + + /** + * Atomically consumes a newer TOTP counter, clears the lock state, and + * creates the session. A null result means the operator became unavailable + * or the counter was already consumed. + */ + suspend fun createSessionIfTotpCounterFresh( + session: NewAdminSession, + totpCounter: Long, + now: Instant, + auditEvent: NewAdminAuditEvent, + ): AdminSessionRecord? + + suspend fun findActiveSessionByTokenHash(tokenHash: String, now: Instant): AdminSessionRecord? + suspend fun revokeSessionByTokenHash( + tokenHash: String, + now: Instant, + auditEvent: NewAdminAuditEvent, + ): AdminSessionRecord? + suspend fun purgeInactiveSessions(cutoff: Instant, limit: Int): Int + suspend fun appendAudit(event: NewAdminAuditEvent) + suspend fun listAudit( + limit: Int, + before: AdminAuditCursor? = null, + ): List +} + +class ExposedAdminRepository( + private val databaseFactory: DatabaseFactory, +) : AdminRepository { + override suspend fun createOperatorIfAbsent(operator: NewAdminOperator): Boolean = + databaseFactory.query { + insertOperatorIgnoringConflict(operator) + } + + override suspend fun createOperator( + operator: NewAdminOperator, + auditEvent: NewAdminAuditEvent, + ): AdminOperatorMutationResult = databaseFactory.query { + val result = if (insertOperatorIgnoringConflict(operator)) { + AdminOperatorMutationResult.SUCCESS + } else { + AdminOperatorMutationResult.USERNAME_CONFLICT + } + insertAudit(auditEvent.withResult(result)) + result + } + + override suspend fun listOperators(): List = + databaseFactory.query { + AdminOperatorsTable.selectAll() + .orderBy( + AdminOperatorsTable.createdAt to SortOrder.ASC, + AdminOperatorsTable.id to SortOrder.ASC, + ) + .map(ResultRow::toOperatorRecord) + } + + override suspend fun listOperatorsPage( + limit: Int, + before: AdminOperatorCursor?, + ): List = + databaseFactory.query { + require(limit in 1..101) + val query = AdminOperatorsTable.selectAll() + if (before != null) { + query.andWhere { + (AdminOperatorsTable.createdAt greater before.createdAt) or + ( + (AdminOperatorsTable.createdAt eq before.createdAt) and + (AdminOperatorsTable.id greater before.id.toString()) + ) + } + } + query + .orderBy( + AdminOperatorsTable.createdAt to SortOrder.ASC, + AdminOperatorsTable.id to SortOrder.ASC, + ) + .limit(limit) + .map(ResultRow::toOperatorRecord) + } + + override suspend fun countActiveSessions(now: Instant): Long = + databaseFactory.query { + AdminSessionsTable.selectAll() + .where { + AdminSessionsTable.revokedAt.isNull() and + (AdminSessionsTable.expiresAt greater now) + } + .count() + } + + override suspend fun findOperator(operatorId: UUID): AdminOperatorRecord? = + databaseFactory.query { + AdminOperatorsTable.selectAll() + .where { AdminOperatorsTable.id eq operatorId.toString() } + .limit(1) + .singleOrNull() + ?.toOperatorRecord() + } + + override suspend fun setOperatorEnabled( + operatorId: UUID, + enabled: Boolean, + now: Instant, + auditEvent: NewAdminAuditEvent, + ): AdminOperatorMutationResult = databaseFactory.query { + if (!enabled) { + // Lock every enabled super-administrator in deterministic order so + // concurrent disables cannot both observe themselves as non-last. + AdminOperatorsTable.selectAll() + .where { + (AdminOperatorsTable.role eq AdminRole.SUPER_ADMIN.name) and + AdminOperatorsTable.disabledAt.isNull() + } + .orderBy(AdminOperatorsTable.id to SortOrder.ASC) + .forUpdate() + .toList() + } + val row = AdminOperatorsTable.selectAll() + .where { AdminOperatorsTable.id eq operatorId.toString() } + .forUpdate() + .singleOrNull() + val result = when { + row == null -> AdminOperatorMutationResult.NOT_FOUND + !enabled && + row[AdminOperatorsTable.disabledAt] == null && + row[AdminOperatorsTable.role] == AdminRole.SUPER_ADMIN.name && + enabledSuperAdministratorCount() <= 1 -> AdminOperatorMutationResult.LAST_SUPER_ADMIN + else -> { + AdminOperatorsTable.update({ AdminOperatorsTable.id eq operatorId.toString() }) { + it[disabledAt] = if (enabled) null else now + it[updatedAt] = now + } + if (!enabled) revokeActiveSessions(operatorId, now) + AdminOperatorMutationResult.SUCCESS + } + } + insertAudit(auditEvent.withResult(result)) + result + } + + override suspend fun unlockOperator( + operatorId: UUID, + now: Instant, + auditEvent: NewAdminAuditEvent, + ): AdminOperatorMutationResult = databaseFactory.query { + val row = lockOperator(operatorId) + val result = if (row == null) { + AdminOperatorMutationResult.NOT_FOUND + } else { + AdminOperatorsTable.update({ AdminOperatorsTable.id eq operatorId.toString() }) { + it[failedLoginCount] = 0 + it[lockedUntil] = null + it[updatedAt] = now + } + AdminOperatorMutationResult.SUCCESS + } + insertAudit(auditEvent.withResult(result)) + result + } + + override suspend fun resetOperatorCredentials( + operatorId: UUID, + passwordHash: String, + encryptedTotpSecret: String, + now: Instant, + auditEvent: NewAdminAuditEvent, + ): AdminOperatorMutationResult = databaseFactory.query { + require(passwordHash.isNotBlank()) + require(encryptedTotpSecret.isNotBlank()) + val row = lockOperator(operatorId) + val result = if (row == null) { + AdminOperatorMutationResult.NOT_FOUND + } else { + AdminOperatorsTable.update({ AdminOperatorsTable.id eq operatorId.toString() }) { + it[AdminOperatorsTable.passwordHash] = passwordHash + it[AdminOperatorsTable.encryptedTotpSecret] = encryptedTotpSecret + it[failedLoginCount] = 0 + it[lockedUntil] = null + it[lastTotpCounter] = null + it[updatedAt] = now + } + revokeActiveSessions(operatorId, now) + AdminOperatorMutationResult.SUCCESS + } + insertAudit(auditEvent.withResult(result)) + result + } + + override suspend fun revokeOperatorSessions( + operatorId: UUID, + now: Instant, + auditEvent: NewAdminAuditEvent, + ): AdminOperatorMutationResult = databaseFactory.query { + val row = lockOperator(operatorId) + val result = if (row == null) { + AdminOperatorMutationResult.NOT_FOUND + } else { + revokeActiveSessions(operatorId, now) + AdminOperatorMutationResult.SUCCESS + } + insertAudit(auditEvent.withResult(result)) + result + } + + override suspend fun findOperatorForAuthentication( + normalizedUsername: String, + ): AdminOperatorAuthRecord? = databaseFactory.query { + AdminOperatorsTable.selectAll() + .where { AdminOperatorsTable.username eq normalizedUsername } + .limit(1) + .singleOrNull() + ?.toAuthRecord() + } + + override suspend fun updateLockState( + operatorId: UUID, + now: Instant, + auditEvent: NewAdminAuditEvent, + transform: (AdminLockState) -> AdminLockState, + ): AdminLockState? = databaseFactory.query { + val row = AdminOperatorsTable.selectAll() + .where { AdminOperatorsTable.id eq operatorId.toString() } + .forUpdate() + .singleOrNull() + ?: return@query null + if (row[AdminOperatorsTable.disabledAt] != null) return@query null + + val next = transform( + AdminLockState( + failedLoginCount = row[AdminOperatorsTable.failedLoginCount], + lockedUntil = row[AdminOperatorsTable.lockedUntil], + ), + ) + AdminOperatorsTable.update({ AdminOperatorsTable.id eq operatorId.toString() }) { + it[failedLoginCount] = next.failedLoginCount + it[lockedUntil] = next.lockedUntil + it[updatedAt] = now + } + insertAudit(auditEvent) + next + } + + override suspend fun createSessionIfTotpCounterFresh( + session: NewAdminSession, + totpCounter: Long, + now: Instant, + auditEvent: NewAdminAuditEvent, + ): AdminSessionRecord? = databaseFactory.query { + require(totpCounter >= 0) + val operator = AdminOperatorsTable.selectAll() + .where { AdminOperatorsTable.id eq session.operatorId.toString() } + .forUpdate() + .singleOrNull() + ?: return@query null + if (operator[AdminOperatorsTable.disabledAt] != null) return@query null + if (operator[AdminOperatorsTable.lockedUntil]?.isAfter(now) == true) return@query null + if (operator[AdminOperatorsTable.lastTotpCounter]?.let { it >= totpCounter } == true) { + return@query null + } + + AdminOperatorsTable.update({ AdminOperatorsTable.id eq session.operatorId.toString() }) { + it[lastTotpCounter] = totpCounter + it[failedLoginCount] = 0 + it[lockedUntil] = null + it[lastLoginAt] = now + it[updatedAt] = now + } + AdminSessionsTable.insert { + it[id] = session.id.toString() + it[operatorId] = session.operatorId.toString() + it[tokenHash] = session.tokenHash + it[csrfTokenHash] = session.csrfTokenHash + it[createdAt] = session.createdAt + it[expiresAt] = session.expiresAt + } + insertAudit(auditEvent) + AdminSessionRecord( + id = session.id, + operatorId = session.operatorId, + normalizedUsername = operator[AdminOperatorsTable.username], + role = AdminRole.valueOf(operator[AdminOperatorsTable.role]), + csrfTokenHash = session.csrfTokenHash, + expiresAt = session.expiresAt, + ) + } + + override suspend fun findActiveSessionByTokenHash( + tokenHash: String, + now: Instant, + ): AdminSessionRecord? = databaseFactory.query { + val session = AdminSessionsTable.selectAll() + .where { + (AdminSessionsTable.tokenHash eq tokenHash) and + AdminSessionsTable.revokedAt.isNull() and + (AdminSessionsTable.expiresAt greater now) + } + .limit(1) + .singleOrNull() + ?: return@query null + val operator = activeOperator(session[AdminSessionsTable.operatorId]) ?: return@query null + session.toSessionRecord( + role = AdminRole.valueOf(operator[AdminOperatorsTable.role]), + normalizedUsername = operator[AdminOperatorsTable.username], + ) + } + + override suspend fun revokeSessionByTokenHash( + tokenHash: String, + now: Instant, + auditEvent: NewAdminAuditEvent, + ): AdminSessionRecord? = databaseFactory.query { + val session = AdminSessionsTable.selectAll() + .where { + (AdminSessionsTable.tokenHash eq tokenHash) and + AdminSessionsTable.revokedAt.isNull() + } + .forUpdate() + .singleOrNull() + ?: return@query null + val operator = AdminOperatorsTable.selectAll() + .where { AdminOperatorsTable.id eq session[AdminSessionsTable.operatorId] } + .singleOrNull() + ?: return@query null + AdminSessionsTable.update({ AdminSessionsTable.id eq session[AdminSessionsTable.id] }) { + it[revokedAt] = now + } + insertAudit(auditEvent) + session.toSessionRecord( + role = AdminRole.valueOf(operator[AdminOperatorsTable.role]), + normalizedUsername = operator[AdminOperatorsTable.username], + ) + } + + override suspend fun purgeInactiveSessions(cutoff: Instant, limit: Int): Int = + databaseFactory.query { + require(limit in 1..1_000) + val candidateIds = AdminSessionsTable.selectAll() + .where { + (AdminSessionsTable.expiresAt lessEq cutoff) or + ( + AdminSessionsTable.revokedAt.isNotNull() and + (AdminSessionsTable.revokedAt lessEq cutoff) + ) + } + .orderBy( + AdminSessionsTable.expiresAt to SortOrder.ASC, + AdminSessionsTable.id to SortOrder.ASC, + ) + .limit(limit) + .map { it[AdminSessionsTable.id] } + if (candidateIds.isEmpty()) { + 0 + } else { + AdminSessionsTable.deleteWhere { + AdminSessionsTable.id inList candidateIds + } + } + } + + override suspend fun appendAudit(event: NewAdminAuditEvent) { + databaseFactory.query { + insertAudit(event) + } + } + + override suspend fun listAudit( + limit: Int, + before: AdminAuditCursor?, + ): List = + databaseFactory.query { + require(limit in 1..101) + val query = AdminAuditLogTable.selectAll() + if (before != null) { + query.andWhere { + (AdminAuditLogTable.occurredAt less before.occurredAt) or + ( + (AdminAuditLogTable.occurredAt eq before.occurredAt) and + (AdminAuditLogTable.id less before.id.toString()) + ) + } + } + query + .orderBy( + AdminAuditLogTable.occurredAt to SortOrder.DESC, + AdminAuditLogTable.id to SortOrder.DESC, + ) + .limit(limit) + .map { + AdminAuditRecord( + id = UUID.fromString(it[AdminAuditLogTable.id]), + actorOperatorId = it[AdminAuditLogTable.actorOperatorId]?.let(UUID::fromString), + action = AdminAuditAction.valueOf(it[AdminAuditLogTable.action]), + outcome = AdminAuditOutcome.valueOf(it[AdminAuditLogTable.outcome]), + targetType = it[AdminAuditLogTable.targetType], + targetId = it[AdminAuditLogTable.targetId], + requestId = it[AdminAuditLogTable.requestId], + occurredAt = it[AdminAuditLogTable.occurredAt], + ) + } + } + + private fun insertAudit(event: NewAdminAuditEvent) { + AdminAuditLogTable.insert { + it[id] = event.id.toString() + it[actorOperatorId] = event.actorOperatorId?.toString() + it[action] = event.action.name + it[outcome] = event.outcome.name + it[targetType] = event.targetType + it[targetId] = event.targetId + it[requestId] = event.requestId + it[occurredAt] = event.occurredAt + } + } + + private fun insertOperatorIgnoringConflict(operator: NewAdminOperator): Boolean = + AdminOperatorsTable.insertIgnore { + it[id] = operator.id.toString() + it[username] = operator.normalizedUsername + it[passwordHash] = operator.passwordHash + it[encryptedTotpSecret] = operator.encryptedTotpSecret + it[role] = operator.role.name + it[failedLoginCount] = 0 + it[lockedUntil] = null + it[lastTotpCounter] = null + it[lastLoginAt] = null + it[disabledAt] = null + it[createdAt] = operator.createdAt + it[updatedAt] = operator.createdAt + }.insertedCount > 0 + + private fun lockOperator(operatorId: UUID): ResultRow? = + AdminOperatorsTable.selectAll() + .where { AdminOperatorsTable.id eq operatorId.toString() } + .forUpdate() + .singleOrNull() + + private fun enabledSuperAdministratorCount(): Int = + AdminOperatorsTable.selectAll() + .where { + (AdminOperatorsTable.role eq AdminRole.SUPER_ADMIN.name) and + AdminOperatorsTable.disabledAt.isNull() + } + .count() + .toInt() + + private fun revokeActiveSessions(operatorId: UUID, now: Instant) { + AdminSessionsTable.update({ + (AdminSessionsTable.operatorId eq operatorId.toString()) and + AdminSessionsTable.revokedAt.isNull() + }) { + it[revokedAt] = now + } + } + + private fun activeOperator(operatorId: String): ResultRow? = + AdminOperatorsTable.selectAll() + .where { + (AdminOperatorsTable.id eq operatorId) and + AdminOperatorsTable.disabledAt.isNull() + } + .limit(1) + .singleOrNull() +} + +private fun NewAdminAuditEvent.withResult( + result: AdminOperatorMutationResult, +): NewAdminAuditEvent = copy( + outcome = if (result == AdminOperatorMutationResult.SUCCESS) { + AdminAuditOutcome.SUCCESS + } else { + AdminAuditOutcome.DENIED + }, +) + +private fun ResultRow.toOperatorRecord(): AdminOperatorRecord = AdminOperatorRecord( + id = UUID.fromString(this[AdminOperatorsTable.id]), + normalizedUsername = this[AdminOperatorsTable.username], + role = AdminRole.valueOf(this[AdminOperatorsTable.role]), + lockState = AdminLockState( + failedLoginCount = this[AdminOperatorsTable.failedLoginCount], + lockedUntil = this[AdminOperatorsTable.lockedUntil], + ), + disabledAt = this[AdminOperatorsTable.disabledAt], + lastLoginAt = this[AdminOperatorsTable.lastLoginAt], + createdAt = this[AdminOperatorsTable.createdAt], + updatedAt = this[AdminOperatorsTable.updatedAt], +) + +private fun ResultRow.toAuthRecord(): AdminOperatorAuthRecord = AdminOperatorAuthRecord( + id = UUID.fromString(this[AdminOperatorsTable.id]), + normalizedUsername = this[AdminOperatorsTable.username], + passwordHash = this[AdminOperatorsTable.passwordHash], + encryptedTotpSecret = this[AdminOperatorsTable.encryptedTotpSecret], + role = AdminRole.valueOf(this[AdminOperatorsTable.role]), + lockState = AdminLockState( + failedLoginCount = this[AdminOperatorsTable.failedLoginCount], + lockedUntil = this[AdminOperatorsTable.lockedUntil], + ), + disabledAt = this[AdminOperatorsTable.disabledAt], +) + +private fun ResultRow.toSessionRecord( + role: AdminRole, + normalizedUsername: String, +): AdminSessionRecord = AdminSessionRecord( + id = UUID.fromString(this[AdminSessionsTable.id]), + operatorId = UUID.fromString(this[AdminSessionsTable.operatorId]), + normalizedUsername = normalizedUsername, + role = role, + csrfTokenHash = this[AdminSessionsTable.csrfTokenHash], + expiresAt = this[AdminSessionsTable.expiresAt], +) diff --git a/src/main/kotlin/com/osglab/account/features/admin/routes/AdminRoutes.kt b/src/main/kotlin/com/osglab/account/features/admin/routes/AdminRoutes.kt new file mode 100644 index 0000000..ae45f1a --- /dev/null +++ b/src/main/kotlin/com/osglab/account/features/admin/routes/AdminRoutes.kt @@ -0,0 +1,887 @@ +package com.osglab.account.features.admin.routes + +import com.osglab.account.config.AppConfig +import com.osglab.account.features.admin.grants.models.ManualGrantCommand +import com.osglab.account.features.admin.grants.services.AdminGrantService +import com.osglab.account.features.admin.models.AdminLoginResult +import com.osglab.account.features.admin.models.AdminPrincipal +import com.osglab.account.features.admin.models.AdminRole +import com.osglab.account.features.admin.models.AdminOperatorRecord +import com.osglab.account.features.admin.services.AdminAuditCursorException +import com.osglab.account.features.admin.services.AdminAuditService +import com.osglab.account.features.admin.services.AdminAuthService +import com.osglab.account.features.admin.services.AdminOperatorCredentials +import com.osglab.account.features.admin.services.AdminOperatorCursorException +import com.osglab.account.features.admin.services.AdminOperatorErrorCode +import com.osglab.account.features.admin.services.AdminOperatorException +import com.osglab.account.features.admin.services.AdminOperatorService +import com.osglab.account.features.admin.services.AdminSessionService +import com.osglab.account.features.admin.stats.models.AdminStatsDto +import com.osglab.account.features.admin.stats.models.AdminUsageAggregateDto +import com.osglab.account.features.admin.stats.services.AdminStatsService +import com.osglab.account.features.admin.users.models.AdminUserDetailDto +import com.osglab.account.features.admin.users.models.AdminUserLedgerEntryDto +import com.osglab.account.features.admin.users.models.AdminUserReferralDto +import com.osglab.account.features.admin.users.models.AdminUserSummaryDto +import com.osglab.account.features.admin.users.services.AdminUserNotFoundException +import com.osglab.account.features.admin.users.services.AdminUsersService +import com.osglab.account.features.credits.domain.CreditConflict +import com.osglab.account.features.credits.domain.CreditNotFound +import com.osglab.account.features.credits.domain.InvalidCreditRequest +import io.ktor.http.Cookie +import io.ktor.http.HttpHeaders +import io.ktor.http.HttpStatusCode +import io.ktor.server.application.ApplicationCall +import io.ktor.server.application.call +import io.ktor.server.http.content.staticResources +import io.ktor.server.plugins.BadRequestException +import io.ktor.server.plugins.ratelimit.RateLimitName +import io.ktor.server.plugins.ratelimit.rateLimit +import io.ktor.server.request.header +import io.ktor.server.request.receive +import io.ktor.server.response.respond +import io.ktor.server.routing.Route +import io.ktor.server.routing.delete +import io.ktor.server.routing.get +import io.ktor.server.routing.post +import io.ktor.server.routing.route +import kotlinx.serialization.Serializable +import java.time.Clock +import java.time.Duration +import java.util.UUID + +fun Route.adminWebRoutes() { + staticResources("/admin", "admin", index = "index.html") +} + +fun Route.adminApiRoutes( + config: AppConfig, + authService: AdminAuthService, + sessionService: AdminSessionService, + statsService: AdminStatsService, + usersService: AdminUsersService, + grantService: AdminGrantService, + operatorService: AdminOperatorService, + auditService: AdminAuditService, + clock: Clock = Clock.systemUTC(), +) { + route("/v1/admin") { + rateLimit(ADMIN_AUTH_RATE_LIMIT) { + route("/auth") { + get("/session") { + if (!call.requireVerifiedAdminEdge()) return@get + val principal = call.currentPrincipal(sessionService) + call.respond( + AdminSessionResponse( + authenticated = principal != null, + operatorName = principal?.normalizedUsername, + role = principal?.role?.name, + ), + ) + } + + post("/login") { + if (!call.requireVerifiedAdminEdge() || !call.requireSameOrigin(config)) return@post + val request = call.receive() + val password = request.password.toCharArray() + val result = try { + authService.login( + username = request.username, + password = password, + totpCode = request.totpCode, + requestId = call.request.header("X-Request-ID"), + ) + } finally { + password.fill('\u0000') + } + when (result) { + is AdminLoginResult.Authenticated -> { + call.setAdminCookies(config, result.credentials.sessionToken, result.credentials.csrfToken) + call.respond( + AdminLoginResponse( + operatorName = result.principal.normalizedUsername, + role = result.principal.role.name, + csrfToken = result.credentials.csrfToken, + ), + ) + } + is AdminLoginResult.Locked -> call.respond( + HttpStatusCode.TooManyRequests, + AdminErrorResponse("RATE_LIMITED"), + ) + AdminLoginResult.InvalidCredentials -> call.respond( + HttpStatusCode.Unauthorized, + AdminErrorResponse("INVALID_CREDENTIALS"), + ) + } + } + + post("/logout") { + if (!call.requireVerifiedAdminEdge() || !call.requireSameOrigin(config)) return@post + val sessionToken = call.request.cookies[SESSION_COOKIE] + val csrfToken = call.request.header(CSRF_HEADER) + if ( + sessionToken == null || + csrfToken == null || + !sessionService.revoke( + sessionToken, + csrfToken, + call.request.header("X-Request-ID"), + ) + ) { + call.respond(HttpStatusCode.Unauthorized, AdminErrorResponse("UNAUTHORIZED")) + return@post + } + call.clearAdminCookies(config) + call.respond(HttpStatusCode.NoContent) + } + } + } + + get("/overview") { + if (call.requirePrincipal(sessionService) == null) return@get + val stats = statsService.getRange(call.request.queryParameters["range"], clock) + ?: run { + call.respond(HttpStatusCode.BadRequest, AdminErrorResponse("VALIDATION_ERROR")) + return@get + } + call.respond(stats.toOverviewResponse()) + } + + get("/referrals") { + if (call.requirePrincipal(sessionService) == null) return@get + val stats = statsService.getRange(call.request.queryParameters["range"], clock) + ?: run { + call.respond(HttpStatusCode.BadRequest, AdminErrorResponse("VALIDATION_ERROR")) + return@get + } + call.respond(stats.toReferralResponse()) + } + + get("/users") { + if ( + call.requireRole( + sessionService, + setOf(AdminRole.SUPER_ADMIN, AdminRole.SUPPORT), + ) == null + ) return@get + val limit = call.pageLimit(maximum = 100) ?: run { + call.respond(HttpStatusCode.BadRequest, AdminErrorResponse("VALIDATION_ERROR")) + return@get + } + val query = call.request.queryParameters["q"]?.trim().orEmpty() + val page = try { + if (query.isEmpty()) { + usersService.list( + limit = limit, + cursor = call.request.queryParameters["cursor"], + ) + } else { + usersService.searchByInternalId(query) + } + } catch (_: IllegalArgumentException) { + call.respond(HttpStatusCode.BadRequest, AdminErrorResponse("VALIDATION_ERROR")) + return@get + } + call.respond( + PageResponse( + items = page.items.map(AdminUserSummaryDto::toUserSummaryResponse), + nextCursor = page.nextCursor, + ), + ) + } + + get("/users/{userId}") { + if ( + call.requireRole( + sessionService, + setOf(AdminRole.SUPER_ADMIN, AdminRole.SUPPORT), + ) == null + ) return@get + val userId = call.uuidPathParameter("userId") ?: return@get + try { + call.respond(usersService.detail(userId).toUserDetailResponse()) + } catch (_: AdminUserNotFoundException) { + call.respond(HttpStatusCode.NotFound, AdminErrorResponse("USER_NOT_FOUND")) + } + } + + get("/users/{userId}/ledger") { + if ( + call.requireRole( + sessionService, + setOf(AdminRole.SUPER_ADMIN, AdminRole.SUPPORT), + ) == null + ) return@get + val userId = call.uuidPathParameter("userId") ?: return@get + val limit = call.pageLimit(maximum = 100, default = 100) ?: run { + call.respond(HttpStatusCode.BadRequest, AdminErrorResponse("VALIDATION_ERROR")) + return@get + } + try { + val page = usersService.ledger( + userId = userId, + limit = limit, + cursor = call.request.queryParameters["cursor"], + ) + call.respond( + PageResponse( + page.items.map(AdminUserLedgerEntryDto::toLedgerResponse), + page.nextCursor, + ), + ) + } catch (_: AdminUserNotFoundException) { + call.respond(HttpStatusCode.NotFound, AdminErrorResponse("USER_NOT_FOUND")) + } catch (_: IllegalArgumentException) { + call.respond(HttpStatusCode.BadRequest, AdminErrorResponse("VALIDATION_ERROR")) + } + } + + post("/credits/grants") { + val principal = call.requireMutationPrincipal(config, sessionService) ?: return@post + if (principal.role != AdminRole.SUPER_ADMIN) { + call.respond(HttpStatusCode.Forbidden, AdminErrorResponse("INSUFFICIENT_PERMISSION")) + return@post + } + val request = call.receive() + val idempotencyKey = call.request.header("Idempotency-Key") + val userId = runCatching { UUID.fromString(request.userId) }.getOrNull() + if ( + userId == null || + request.amount !in 1..config.admin.maximumManualGrant || + request.reason.trim().length !in 4..200 || + idempotencyKey == null || + idempotencyKey.length !in 8..128 + ) { + call.respond(HttpStatusCode.BadRequest, AdminErrorResponse("VALIDATION_ERROR")) + return@post + } + val result = try { + grantService.grant( + ManualGrantCommand( + operatorId = principal.operatorId, + userId = userId, + credits = request.amount, + reason = request.reason.trim(), + requestId = call.request.header("X-Request-ID"), + idempotencyKey = idempotencyKey, + ), + ) + } catch (_: CreditNotFound) { + call.respond(HttpStatusCode.NotFound, AdminErrorResponse("USER_NOT_FOUND")) + return@post + } catch (_: CreditConflict) { + call.respond(HttpStatusCode.Conflict, AdminErrorResponse("IDEMPOTENCY_CONFLICT")) + return@post + } catch (_: InvalidCreditRequest) { + call.respond(HttpStatusCode.BadRequest, AdminErrorResponse("VALIDATION_ERROR")) + return@post + } + call.respond( + AdminGrantResponse( + transactionId = result.ledgerEntryId, + balanceAfter = result.balanceAfter, + ), + ) + } + + get("/operators/summary") { + val principal = call.requirePrincipal(sessionService) ?: return@get + try { + val summary = operatorService.summary(principal) + call.respond( + AdminSecuritySummaryResponse( + enabledOperators = summary.enabledOperators, + lockedOperators = summary.lockedOperators, + activeSessions = summary.activeSessions, + ), + ) + } catch (exception: AdminOperatorException) { + call.respondOperatorError(exception) + } + } + + get("/operators") { + val principal = call.requirePrincipal(sessionService) ?: return@get + val limit = call.pageLimit(maximum = 100) ?: run { + call.respond(HttpStatusCode.BadRequest, AdminErrorResponse("VALIDATION_ERROR")) + return@get + } + try { + val page = operatorService.listPage( + actor = principal, + cursor = call.request.queryParameters["cursor"], + limit = limit, + ) + call.respond( + PageResponse( + items = page.items.map(AdminOperatorRecord::toResponse), + nextCursor = page.nextCursor, + ), + ) + } catch (_: AdminOperatorCursorException) { + call.respond(HttpStatusCode.BadRequest, AdminErrorResponse("VALIDATION_ERROR")) + } catch (exception: AdminOperatorException) { + call.respondOperatorError(exception) + } + } + + post("/operators") { + val principal = call.requireMutationPrincipal(config, sessionService) ?: return@post + val request = call.receiveAdminRequest() ?: return@post + val password = request.password.toCharArray() + try { + val created = operatorService.create( + actor = principal, + username = request.username, + password = password, + roleName = request.role, + requestId = call.request.header("X-Request-ID"), + ) + call.respond(HttpStatusCode.Created, created.toProvisioningResponse()) + } catch (exception: AdminOperatorException) { + call.respondOperatorError(exception) + } finally { + password.fill('\u0000') + } + } + + post("/operators/{operatorId}/enable") { + val principal = call.requireMutationPrincipal(config, sessionService) ?: return@post + val operatorId = call.uuidPathParameter("operatorId") ?: return@post + try { + operatorService.setEnabled( + principal, + operatorId, + enabled = true, + call.request.header("X-Request-ID"), + ) + call.respond(HttpStatusCode.NoContent) + } catch (exception: AdminOperatorException) { + call.respondOperatorError(exception) + } + } + + post("/operators/{operatorId}/disable") { + val principal = call.requireMutationPrincipal(config, sessionService) ?: return@post + val operatorId = call.uuidPathParameter("operatorId") ?: return@post + try { + operatorService.setEnabled( + principal, + operatorId, + enabled = false, + call.request.header("X-Request-ID"), + ) + call.respond(HttpStatusCode.NoContent) + } catch (exception: AdminOperatorException) { + call.respondOperatorError(exception) + } + } + + post("/operators/{operatorId}/unlock") { + val principal = call.requireMutationPrincipal(config, sessionService) ?: return@post + val operatorId = call.uuidPathParameter("operatorId") ?: return@post + try { + operatorService.unlock( + principal, + operatorId, + call.request.header("X-Request-ID"), + ) + call.respond(HttpStatusCode.NoContent) + } catch (exception: AdminOperatorException) { + call.respondOperatorError(exception) + } + } + + post("/operators/{operatorId}/credentials/reset") { + val principal = call.requireMutationPrincipal(config, sessionService) ?: return@post + val operatorId = call.uuidPathParameter("operatorId") ?: return@post + val request = call.receiveAdminRequest() ?: return@post + val password = request.password.toCharArray() + try { + val credentials = operatorService.resetCredentials( + principal, + operatorId, + password, + call.request.header("X-Request-ID"), + ) + call.respond(credentials.toProvisioningResponse(operatorId)) + } catch (exception: AdminOperatorException) { + call.respondOperatorError(exception) + } finally { + password.fill('\u0000') + } + } + + post("/operators/{operatorId}/sessions/revoke") { + val principal = call.requireMutationPrincipal(config, sessionService) ?: return@post + val operatorId = call.uuidPathParameter("operatorId") ?: return@post + try { + operatorService.revokeSessions( + principal, + operatorId, + call.request.header("X-Request-ID"), + ) + call.respond(HttpStatusCode.NoContent) + } catch (exception: AdminOperatorException) { + call.respondOperatorError(exception) + } + } + + get("/audit") { + val principal = call.requirePrincipal(sessionService) ?: return@get + val limit = call.pageLimit(maximum = 100) ?: run { + call.respond(HttpStatusCode.BadRequest, AdminErrorResponse("VALIDATION_ERROR")) + return@get + } + val page = try { + auditService.list( + actor = principal, + cursor = call.request.queryParameters["cursor"], + limit = limit, + ) + } catch (exception: AdminOperatorException) { + call.respondOperatorError(exception) + return@get + } catch (_: AdminAuditCursorException) { + call.respond(HttpStatusCode.BadRequest, AdminErrorResponse("VALIDATION_ERROR")) + return@get + } + call.respond( + PageResponse( + items = page.items.map { item -> + val audit = item.record + AdminAuditResponse( + auditId = audit.id.toString(), + operatorName = item.operatorName, + action = audit.action.name, + targetType = audit.targetType ?: "NONE", + targetId = audit.targetId ?: "—", + requestId = audit.requestId, + result = if (audit.outcome.name == "SUCCESS") "success" else "rejected", + createdAt = audit.occurredAt.toString(), + ) + }, + nextCursor = page.nextCursor, + ), + ) + } + } +} + +private suspend fun AdminStatsService.getRange( + range: String?, + clock: Clock, +): AdminStatsDto? { + val days = when (range) { + null, "30d" -> 30L + "7d" -> 7L + "90d" -> 90L + else -> return null + } + val until = clock.instant() + return get(until.minus(Duration.ofDays(days)), until) +} + +private suspend fun ApplicationCall.requirePrincipal( + sessions: AdminSessionService, +): AdminPrincipal? { + if (!requireVerifiedAdminEdge()) return null + val principal = currentPrincipal(sessions) + if (principal == null) { + respond(HttpStatusCode.Unauthorized, AdminErrorResponse("UNAUTHORIZED")) + } + return principal +} + +private suspend fun ApplicationCall.requireRole( + sessions: AdminSessionService, + allowedRoles: Set, +): AdminPrincipal? { + val principal = requirePrincipal(sessions) ?: return null + if (principal.role !in allowedRoles) { + respond(HttpStatusCode.Forbidden, AdminErrorResponse("INSUFFICIENT_PERMISSION")) + return null + } + return principal +} + +private suspend fun ApplicationCall.requireMutationPrincipal( + config: AppConfig, + sessions: AdminSessionService, +): AdminPrincipal? { + if (!requireVerifiedAdminEdge() || !requireSameOrigin(config)) return null + val sessionToken = request.cookies[SESSION_COOKIE] + val csrfToken = request.header(CSRF_HEADER) + val principal = if (sessionToken != null && csrfToken != null) { + sessions.authenticateMutation(sessionToken, csrfToken) + } else { + null + } + if (principal == null) { + respond(HttpStatusCode.Forbidden, AdminErrorResponse("CSRF_INVALID")) + } + return principal +} + +private suspend fun ApplicationCall.requireVerifiedAdminEdge(): Boolean { + if (request.header(MTLS_HEADER) == MTLS_VERIFIED) return true + respond(HttpStatusCode.NotFound) + return false +} + +private suspend fun ApplicationCall.requireSameOrigin(config: AppConfig): Boolean { + if (request.header(HttpHeaders.Origin) == config.publicBaseUrl) return true + respond(HttpStatusCode.Forbidden, AdminErrorResponse("ORIGIN_INVALID")) + return false +} + +private suspend fun ApplicationCall.currentPrincipal( + sessions: AdminSessionService, +): AdminPrincipal? = request.cookies[SESSION_COOKIE]?.let { sessions.authenticate(it) } + +private suspend fun ApplicationCall.uuidPathParameter(name: String): UUID? { + val value = parameters[name] + val parsed = value?.let { runCatching { UUID.fromString(it) }.getOrNull() } + if (parsed == null) respond(HttpStatusCode.BadRequest, AdminErrorResponse("VALIDATION_ERROR")) + return parsed +} + +private fun ApplicationCall.pageLimit( + maximum: Int, + default: Int = 50, +): Int? { + val raw = request.queryParameters["limit"] ?: return default + return raw.toIntOrNull()?.takeIf { it in 1..maximum } +} + +private suspend inline fun ApplicationCall.receiveAdminRequest(): T? = + try { + receive() + } catch (_: BadRequestException) { + respond(HttpStatusCode.BadRequest, AdminErrorResponse("VALIDATION_ERROR")) + null + } + +private suspend fun ApplicationCall.respondOperatorError(exception: AdminOperatorException) { + val status = when (exception.code) { + AdminOperatorErrorCode.VALIDATION_ERROR -> HttpStatusCode.BadRequest + AdminOperatorErrorCode.INSUFFICIENT_PERMISSION -> HttpStatusCode.Forbidden + AdminOperatorErrorCode.ADMIN_OPERATOR_NOT_FOUND -> HttpStatusCode.NotFound + AdminOperatorErrorCode.ADMIN_USERNAME_CONFLICT, + AdminOperatorErrorCode.CANNOT_DISABLE_SELF, + AdminOperatorErrorCode.LAST_SUPER_ADMIN_REQUIRED, + -> HttpStatusCode.Conflict + } + respond(status, AdminErrorResponse(exception.code.name)) +} + +private fun ApplicationCall.setAdminCookies(config: AppConfig, sessionToken: String, csrfToken: String) { + response.cookies.append(adminCookie(SESSION_COOKIE, sessionToken, config, httpOnly = true)) + response.cookies.append(adminCookie(CSRF_COOKIE, csrfToken, config, httpOnly = false)) +} + +private fun ApplicationCall.clearAdminCookies(config: AppConfig) { + response.cookies.append(adminCookie(SESSION_COOKIE, "", config, httpOnly = true, maxAge = 0)) + response.cookies.append(adminCookie(CSRF_COOKIE, "", config, httpOnly = false, maxAge = 0)) +} + +private fun adminCookie( + name: String, + value: String, + config: AppConfig, + httpOnly: Boolean, + maxAge: Int? = null, +): Cookie = Cookie( + name = name, + value = value, + path = "/", + maxAge = maxAge, + secure = config.isProduction, + httpOnly = httpOnly, + extensions = mapOf("SameSite" to "Strict"), +) + +private fun AdminStatsDto.toOverviewResponse(): AdminOverviewResponse { + val consumedByDate = creditFlow.associateBy { it.date } + return AdminOverviewResponse( + totalUsers = overview.totalUsers, + activeUsers = overview.activeUsers, + newUsers = overview.registrations, + totalCreditBalance = overview.totalCreditBalance, + creditsGranted = overview.issuedCredits, + creditsUsed = overview.consumedCredits, + trend = registrationTrend.map { + AdminTrendResponse( + date = it.date, + registrations = it.registrations, + creditsUsed = consumedByDate[it.date]?.consumedCredits ?: 0, + ) + }, + usage = usage, + ) +} + +private fun AdminStatsDto.toReferralResponse(): AdminReferralResponse = + AdminReferralResponse( + pendingBindings = referralFunnel.pendingBindings, + ineligibleBindings = referralFunnel.ineligibleBindings, + funnel = listOf( + AdminFunnelResponse("邀请码创建", referralFunnel.codesCreated), + AdminFunnelResponse("成功绑定", referralFunnel.bindings), + AdminFunnelResponse("有效使用并奖励", referralFunnel.rewardedBindings), + AdminFunnelResponse("待资格确认", referralFunnel.pendingBindings), + AdminFunnelResponse("不符合奖励条件", referralFunnel.ineligibleBindings), + ), + ranking = referralRanking.map { + AdminReferralRankResponse( + userId = it.userId, + invited = it.invitedUsers, + qualified = it.rewardedUsers, + creditsEarned = it.earnedCredits, + ) + }, + ) + +private fun AdminUserSummaryDto.toUserSummaryResponse(): AdminUserSummaryResponse = + AdminUserSummaryResponse( + userId = id, + displayName = "用户 ${id.take(8)}", + status = if (antiAbuseRestricted) "suspended" else "active", + creditBalance = creditBalance, + createdAt = createdAt, + ) + +private fun AdminUserDetailDto.toUserDetailResponse(): AdminUserDetailResponse = + AdminUserDetailResponse( + summary = summary.toUserSummaryResponse(), + lastActiveAt = summary.lastActiveAt, + qualifiedUsage = summary.usageRequests > 0, + referralCode = referralCode, + referredByUserId = referral.inviterUserId, + usage = usage, + referral = referral, + ) + +private fun AdminUserLedgerEntryDto.toLedgerResponse(): AdminLedgerResponse = + AdminLedgerResponse( + entryId = id, + type = when (type) { + "USAGE_RESERVE" -> "reserve" + "USAGE_SETTLE" -> "settle" + "USAGE_RELEASE", "USAGE_REFUND" -> "refund" + "SIGNUP_TRIAL", "MANUAL_GRANT", "REFERRAL_INVITER", "REFERRAL_INVITEE", + "STOREKIT_PURCHASE", "SUBSCRIPTION_GRANT" -> "grant" + else -> "adjustment" + }, + amount = amountDelta, + balanceAfter = balanceAfter, + reasonCode = type, + createdAt = createdAt, + ) + +private fun AdminOperatorRecord.toResponse(): AdminOperatorResponse = + AdminOperatorResponse( + operatorId = id.toString(), + username = normalizedUsername, + role = role.name, + enabled = disabledAt == null, + failedLoginCount = lockState.failedLoginCount, + lockedUntil = lockState.lockedUntil?.toString(), + lastLoginAt = lastLoginAt?.toString(), + createdAt = createdAt.toString(), + updatedAt = updatedAt.toString(), + ) + +private fun AdminOperatorCredentials.toProvisioningResponse( + fallbackOperatorId: UUID? = null, +): AdminOperatorProvisioningResponse = AdminOperatorProvisioningResponse( + operator = operator?.toResponse(), + operatorId = operator?.id?.toString() ?: requireNotNull(fallbackOperatorId).toString(), + totpSecret = totpSecret, + otpauthUri = otpauthUri, +) + +@Serializable +private data class AdminLoginRequest( + val username: String, + val password: String, + val totpCode: String, +) + +@Serializable +private data class AdminLoginResponse( + val operatorName: String, + val role: String, + val csrfToken: String, +) + +@Serializable +private data class AdminSessionResponse( + val authenticated: Boolean, + val operatorName: String? = null, + val role: String? = null, +) + +@Serializable +private data class AdminErrorResponse(val code: String) + +@Serializable +private data class AdminGrantRequest(val userId: String, val amount: Long, val reason: String) + +@Serializable +private data class AdminGrantResponse(val transactionId: String, val balanceAfter: Long) + +@Serializable +private data class AdminOperatorCreateRequest( + val username: String, + val password: String, + val role: String, +) + +@Serializable +private data class AdminOperatorPasswordRequest(val password: String) + +@Serializable +private data class AdminOperatorResponse( + val operatorId: String, + val username: String, + val role: String, + val enabled: Boolean, + val failedLoginCount: Int, + val lockedUntil: String?, + val lastLoginAt: String?, + val createdAt: String, + val updatedAt: String, +) + +@Serializable +private data class AdminOperatorProvisioningResponse( + val operatorId: String, + val operator: AdminOperatorResponse? = null, + val totpSecret: String, + val otpauthUri: String, +) + +@Serializable +private data class AdminSecuritySummaryResponse( + val enabledOperators: Int, + val lockedOperators: Int, + val activeSessions: Long, +) + +@Serializable +private data class PageResponse(val items: List, val nextCursor: String? = null) + +@Serializable +private data class AdminOverviewResponse( + val totalUsers: Long, + val activeUsers: Long, + val newUsers: Long, + val totalCreditBalance: Long, + val creditsGranted: Long, + val creditsUsed: Long, + val trend: List, + val usage: List, +) + +@Serializable +private data class AdminTrendResponse( + val date: String, + val registrations: Long, + val creditsUsed: Long, +) + +@Serializable +private data class AdminReferralResponse( + val pendingBindings: Long, + val ineligibleBindings: Long, + val funnel: List, + val ranking: List, +) + +@Serializable +private data class AdminFunnelResponse(val label: String, val count: Long) + +@Serializable +private data class AdminReferralRankResponse( + val userId: String, + val invited: Long, + val qualified: Long, + val creditsEarned: Long, +) + +@Serializable +private data class AdminUserSummaryResponse( + val userId: String, + val displayName: String, + val status: String, + val creditBalance: Long, + val createdAt: String, +) + +@Serializable +private data class AdminUserDetailResponse( + val userId: String, + val displayName: String, + val status: String, + val creditBalance: Long, + val createdAt: String, + val lastActiveAt: String?, + val qualifiedUsage: Boolean, + val referralCode: String?, + val referredByUserId: String?, + val usage: List, + val referral: AdminUserReferralDto, +) { + constructor( + summary: AdminUserSummaryResponse, + lastActiveAt: String?, + qualifiedUsage: Boolean, + referralCode: String?, + referredByUserId: String?, + usage: List, + referral: AdminUserReferralDto, + ) : this( + userId = summary.userId, + displayName = summary.displayName, + status = summary.status, + creditBalance = summary.creditBalance, + createdAt = summary.createdAt, + lastActiveAt = lastActiveAt, + qualifiedUsage = qualifiedUsage, + referralCode = referralCode, + referredByUserId = referredByUserId, + usage = usage, + referral = referral, + ) +} + +@Serializable +private data class AdminLedgerResponse( + val entryId: String, + val type: String, + val amount: Long, + val balanceAfter: Long, + val reasonCode: String, + val createdAt: String, +) + +@Serializable +private data class AdminAuditResponse( + val auditId: String, + val operatorName: String, + val action: String, + val targetType: String, + val targetId: String, + val requestId: String? = null, + val result: String, + val createdAt: String, +) + +private const val MTLS_HEADER = "X-OSG-mTLS-Verified" +private const val MTLS_VERIFIED = "SUCCESS" +private const val SESSION_COOKIE = "osg_admin_session" +private const val CSRF_COOKIE = "osg_admin_csrf" +private const val CSRF_HEADER = "X-CSRF-Token" +private val ADMIN_AUTH_RATE_LIMIT = RateLimitName("admin-auth") diff --git a/src/main/kotlin/com/osglab/account/features/admin/security/AdminCsrf.kt b/src/main/kotlin/com/osglab/account/features/admin/security/AdminCsrf.kt new file mode 100644 index 0000000..06433c4 --- /dev/null +++ b/src/main/kotlin/com/osglab/account/features/admin/security/AdminCsrf.kt @@ -0,0 +1,20 @@ +package com.osglab.account.features.admin.security + +import com.osglab.account.common.security.TokenHash + +/** + * Validates a double-submit CSRF token against the digest bound to the server + * session. Cookie/header extraction remains a route-layer concern. + */ +class AdminCsrfVerifier { + fun verify(presentedToken: String, expectedHash: String): Boolean { + if (presentedToken.isBlank() || presentedToken.length > MAX_TOKEN_LENGTH) return false + if (!SHA256_HEX.matches(expectedHash)) return false + return TokenHash.matches(presentedToken, expectedHash) + } + + private companion object { + const val MAX_TOKEN_LENGTH = 512 + val SHA256_HEX = Regex("^[0-9a-f]{64}$") + } +} diff --git a/src/main/kotlin/com/osglab/account/features/admin/security/AdminPasswordHasher.kt b/src/main/kotlin/com/osglab/account/features/admin/security/AdminPasswordHasher.kt new file mode 100644 index 0000000..a734182 --- /dev/null +++ b/src/main/kotlin/com/osglab/account/features/admin/security/AdminPasswordHasher.kt @@ -0,0 +1,131 @@ +package com.osglab.account.features.admin.security + +import org.bouncycastle.crypto.generators.Argon2BytesGenerator +import org.bouncycastle.crypto.params.Argon2Parameters +import java.security.MessageDigest +import java.security.SecureRandom +import java.util.Base64 + +interface AdminPasswordHasher { + fun hash(password: CharArray): String + fun verify(password: CharArray, encodedHash: String): Boolean +} + +data class Argon2idConfig( + val memoryKb: Int = 65_536, + val iterations: Int = 3, + val parallelism: Int = 1, + val saltBytes: Int = 16, + val hashBytes: Int = 32, +) { + init { + require(memoryKb in MIN_MEMORY_KB..MAX_MEMORY_KB) + require(iterations in 1..MAX_ITERATIONS) + require(parallelism in 1..MAX_PARALLELISM) + require(memoryKb >= parallelism * 8) + require(saltBytes in 16..MAX_SALT_BYTES) + require(hashBytes in 16..MAX_HASH_BYTES) + } + + private companion object { + const val MIN_MEMORY_KB = 8 + const val MAX_MEMORY_KB = 262_144 + const val MAX_ITERATIONS = 10 + const val MAX_PARALLELISM = 16 + const val MAX_SALT_BYTES = 64 + const val MAX_HASH_BYTES = 64 + } +} + +/** + * PHC-compatible Argon2id password hashing backed by the project's existing + * Bouncy Castle provider. Verification bounds parsed work factors to prevent a + * malformed database value from causing excessive CPU or memory consumption. + */ +class BouncyCastleArgon2idPasswordHasher( + private val config: Argon2idConfig = Argon2idConfig(), + private val secureRandom: SecureRandom = SecureRandom(), +) : AdminPasswordHasher { + override fun hash(password: CharArray): String { + requirePasswordLength(password) + val salt = ByteArray(config.saltBytes).also(secureRandom::nextBytes) + val digest = derive(password, salt, config) + return try { + val encoder = Base64.getEncoder().withoutPadding() + "\$argon2id\$v=19\$m=${config.memoryKb},t=${config.iterations},p=${config.parallelism}\$" + + "${encoder.encodeToString(salt)}\$${encoder.encodeToString(digest)}" + } finally { + salt.fill(0) + digest.fill(0) + } + } + + override fun verify(password: CharArray, encodedHash: String): Boolean { + if (password.isEmpty() || password.size > MAX_PASSWORD_CHARS) return false + val parsed = runCatching { parse(encodedHash) }.getOrNull() ?: return false + val actual = runCatching { derive(password, parsed.salt, parsed.config) }.getOrNull() + ?: return false + return try { + MessageDigest.isEqual(actual, parsed.digest) + } finally { + actual.fill(0) + parsed.salt.fill(0) + parsed.digest.fill(0) + } + } + + private fun derive(password: CharArray, salt: ByteArray, parameters: Argon2idConfig): ByteArray { + val generator = Argon2BytesGenerator() + generator.init( + Argon2Parameters.Builder(Argon2Parameters.ARGON2_id) + .withVersion(Argon2Parameters.ARGON2_VERSION_13) + .withMemoryAsKB(parameters.memoryKb) + .withIterations(parameters.iterations) + .withParallelism(parameters.parallelism) + .withSalt(salt) + .build(), + ) + return ByteArray(parameters.hashBytes).also { generator.generateBytes(password, it) } + } + + private fun parse(encodedHash: String): ParsedHash { + require(encodedHash.length <= MAX_ENCODED_HASH_CHARS) + val parts = encodedHash.split('$') + require(parts.size == 6 && parts[0].isEmpty()) + require(parts[1] == "argon2id" && parts[2] == "v=19") + val parameterComponents = parts[3].split(',') + require(parameterComponents.size == 3) + val values = parameterComponents.associate { component -> + val pair = component.split('=', limit = 2) + require(pair.size == 2) + pair[0] to pair[1].toInt() + } + require(values.keys == setOf("m", "t", "p")) + val salt = Base64.getDecoder().decode(parts[4]) + val digest = Base64.getDecoder().decode(parts[5]) + val parsedConfig = Argon2idConfig( + memoryKb = requireNotNull(values["m"]), + iterations = requireNotNull(values["t"]), + parallelism = requireNotNull(values["p"]), + saltBytes = salt.size, + hashBytes = digest.size, + ) + return ParsedHash(parsedConfig, salt, digest) + } + + private fun requirePasswordLength(password: CharArray) { + require(password.isNotEmpty()) { "Password must not be empty" } + require(password.size <= MAX_PASSWORD_CHARS) { "Password exceeds maximum length" } + } + + private data class ParsedHash( + val config: Argon2idConfig, + val salt: ByteArray, + val digest: ByteArray, + ) + + private companion object { + const val MAX_PASSWORD_CHARS = 1_024 + const val MAX_ENCODED_HASH_CHARS = 512 + } +} diff --git a/src/main/kotlin/com/osglab/account/features/admin/security/AdminTotp.kt b/src/main/kotlin/com/osglab/account/features/admin/security/AdminTotp.kt new file mode 100644 index 0000000..79be699 --- /dev/null +++ b/src/main/kotlin/com/osglab/account/features/admin/security/AdminTotp.kt @@ -0,0 +1,185 @@ +package com.osglab.account.features.admin.security + +import java.io.ByteArrayOutputStream +import java.nio.ByteBuffer +import java.security.MessageDigest +import java.security.SecureRandom +import java.time.Instant +import java.net.URLEncoder +import javax.crypto.Mac +import javax.crypto.spec.SecretKeySpec + +fun interface AdminTotpVerifier { + /** + * Returns the accepted moving counter. Persisting this counter with a + * compare-and-set prevents reuse of an otherwise valid code. + */ + fun verify(secret: ByteArray, code: String, now: Instant): Long? +} + +class HmacTotpVerifier( + private val digits: Int = 6, + private val periodSeconds: Long = 30, + private val allowedWindow: Int = 1, + private val algorithm: String = "HmacSHA1", +) : AdminTotpVerifier { + init { + require(digits in 6..8) + require(periodSeconds in 15..120) + require(allowedWindow in 0..10) + require(algorithm in SUPPORTED_ALGORITHMS) + } + + override fun verify(secret: ByteArray, code: String, now: Instant): Long? { + if (secret.size !in MIN_SECRET_BYTES..MAX_SECRET_BYTES) return null + if (code.length != digits || code.any { it !in '0'..'9' }) return null + if (now.epochSecond < 0) return null + + val currentCounter = now.epochSecond / periodSeconds + var acceptedCounter: Long? = null + for (offset in -allowedWindow..allowedWindow) { + val candidate = currentCounter + offset + if (candidate < 0) continue + val expected = generateForCounter(secret, candidate) + if ( + MessageDigest.isEqual( + code.toByteArray(Charsets.US_ASCII), + expected.toByteArray(Charsets.US_ASCII), + ) + ) { + // Prefer the newest matching counter if a short code collides + // within the configured window. + acceptedCounter = candidate + } + } + return acceptedCounter + } + + fun generate(secret: ByteArray, at: Instant): String { + require(secret.size in MIN_SECRET_BYTES..MAX_SECRET_BYTES) + require(at.epochSecond >= 0) + return generateForCounter(secret, at.epochSecond / periodSeconds) + } + + internal fun generateForCounter(secret: ByteArray, counter: Long): String { + require(secret.size in MIN_SECRET_BYTES..MAX_SECRET_BYTES) + require(counter >= 0) + val mac = Mac.getInstance(algorithm) + mac.init(SecretKeySpec(secret, algorithm)) + val digest = mac.doFinal(ByteBuffer.allocate(Long.SIZE_BYTES).putLong(counter).array()) + val offset = digest.last().toInt() and 0x0f + val binary = ((digest[offset].toInt() and 0x7f) shl 24) or + ((digest[offset + 1].toInt() and 0xff) shl 16) or + ((digest[offset + 2].toInt() and 0xff) shl 8) or + (digest[offset + 3].toInt() and 0xff) + return (binary % POWERS_OF_TEN[digits]).toString().padStart(digits, '0') + } + + private companion object { + val SUPPORTED_ALGORITHMS = setOf("HmacSHA1", "HmacSHA256", "HmacSHA512") + val POWERS_OF_TEN = intArrayOf(1, 10, 100, 1_000, 10_000, 100_000, 1_000_000, 10_000_000, 100_000_000) + const val MIN_SECRET_BYTES = 16 + const val MAX_SECRET_BYTES = 64 + } +} + +object Base32TotpSecret { + fun encode(secret: ByteArray): String { + require(secret.size in MIN_SECRET_BYTES..MAX_SECRET_BYTES) + val result = StringBuilder((secret.size * 8 + 4) / 5) + var buffer = 0 + var bufferedBits = 0 + secret.forEach { byte -> + buffer = (buffer shl Byte.SIZE_BITS) or (byte.toInt() and 0xff) + bufferedBits += Byte.SIZE_BITS + while (bufferedBits >= 5) { + bufferedBits -= 5 + result.append(ALPHABET[(buffer shr bufferedBits) and 0x1f]) + } + buffer = if (bufferedBits == 0) 0 else buffer and ((1 shl bufferedBits) - 1) + } + if (bufferedBits > 0) { + result.append(ALPHABET[(buffer shl (5 - bufferedBits)) and 0x1f]) + } + return result.toString() + } + + fun decode(encoded: String): ByteArray { + val normalized = encoded + .trim() + .replace(" ", "") + .uppercase() + .trimEnd('=') + require(normalized.isNotEmpty()) { "TOTP secret must not be empty" } + + val output = ByteArrayOutputStream() + var buffer = 0 + var bufferedBits = 0 + normalized.forEach { character -> + val value = ALPHABET.indexOf(character) + require(value >= 0) { "TOTP secret is not valid Base32" } + buffer = (buffer shl 5) or value + bufferedBits += 5 + if (bufferedBits >= Byte.SIZE_BITS) { + bufferedBits -= Byte.SIZE_BITS + output.write((buffer shr bufferedBits) and 0xff) + buffer = if (bufferedBits == 0) 0 else buffer and ((1 shl bufferedBits) - 1) + } + } + require(buffer == 0) { "TOTP secret has non-zero trailing bits" } + return output.toByteArray().also { + require(it.size in MIN_SECRET_BYTES..MAX_SECRET_BYTES) { + "TOTP secret must contain 128 to 512 bits" + } + } + } + + private const val ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567" + private const val MIN_SECRET_BYTES = 16 + private const val MAX_SECRET_BYTES = 64 +} + +class AdminTotpProvisioning( + val secretBase32: String, + val otpauthUri: String, +) { + override fun toString(): String = + "AdminTotpProvisioning(secretBase32=[REDACTED], otpauthUri=[REDACTED])" +} + +fun interface AdminTotpSecretGenerator { + fun generate(normalizedUsername: String): AdminTotpProvisioning +} + +class SecureAdminTotpSecretGenerator( + private val secureRandom: SecureRandom = SecureRandom(), + private val issuer: String = "OSGKeyboard", +) : AdminTotpSecretGenerator { + init { + require(issuer.isNotBlank()) + } + + override fun generate(normalizedUsername: String): AdminTotpProvisioning { + require(normalizedUsername.isNotBlank()) + val rawSecret = ByteArray(SECRET_BYTES).also(secureRandom::nextBytes) + val secret = try { + Base32TotpSecret.encode(rawSecret) + } finally { + rawSecret.fill(0) + } + val encodedIssuer = encodeUriComponent(issuer) + val encodedLabel = encodeUriComponent("$issuer:$normalizedUsername") + return AdminTotpProvisioning( + secretBase32 = secret, + otpauthUri = "otpauth://totp/$encodedLabel?secret=$secret&issuer=$encodedIssuer" + + "&algorithm=SHA1&digits=6&period=30", + ) + } + + private fun encodeUriComponent(value: String): String = + URLEncoder.encode(value, Charsets.UTF_8).replace("+", "%20") + + private companion object { + const val SECRET_BYTES = 20 + } +} diff --git a/src/main/kotlin/com/osglab/account/features/admin/services/AdminAuditService.kt b/src/main/kotlin/com/osglab/account/features/admin/services/AdminAuditService.kt new file mode 100644 index 0000000..316e4e5 --- /dev/null +++ b/src/main/kotlin/com/osglab/account/features/admin/services/AdminAuditService.kt @@ -0,0 +1,99 @@ +package com.osglab.account.features.admin.services + +import com.osglab.account.features.admin.models.AdminAuditCursor +import com.osglab.account.features.admin.models.AdminAuditRecord +import com.osglab.account.features.admin.models.AdminPrincipal +import com.osglab.account.features.admin.models.AdminRole +import com.osglab.account.features.admin.repositories.AdminRepository +import java.nio.charset.StandardCharsets +import java.time.Instant +import java.util.Base64 +import java.util.UUID + +class AdminAuditCursorException : RuntimeException("Invalid admin audit cursor") + +data class AdminAuditItem( + val record: AdminAuditRecord, + val operatorName: String, +) + +data class AdminAuditPage( + val items: List, + val nextCursor: String?, +) + +class AdminAuditService( + private val repository: AdminRepository, +) { + suspend fun list( + actor: AdminPrincipal, + cursor: String?, + limit: Int = DEFAULT_PAGE_SIZE, + ): AdminAuditPage { + if (actor.role != AdminRole.SUPER_ADMIN) { + throw AdminOperatorException(AdminOperatorErrorCode.INSUFFICIENT_PERMISSION) + } + require(limit in 1..MAX_PAGE_SIZE) + val decodedCursor = cursor?.let(::decodeCursor) + val records = repository.listAudit(limit + 1, decodedCursor) + val pageRecords = records.take(limit) + val operatorNames = repository.listOperators().associate { + it.id to it.normalizedUsername + } + return AdminAuditPage( + items = pageRecords.map { record -> + AdminAuditItem( + record = record, + operatorName = record.actorOperatorId?.let(operatorNames::get) + ?: record.actorOperatorId?.toString() + ?: "system", + ) + }, + nextCursor = if (records.size > limit) { + pageRecords.lastOrNull()?.let(::encodeCursor) + } else { + null + }, + ) + } + + private fun decodeCursor(value: String): AdminAuditCursor { + if (value.length !in 1..MAX_CURSOR_LENGTH) throw AdminAuditCursorException() + return runCatching { + val decoded = String( + Base64.getUrlDecoder().decode(value), + StandardCharsets.UTF_8, + ) + val parts = decoded.split(':', limit = 3) + require(parts.size == 3) + AdminAuditCursor( + occurredAt = Instant.ofEpochSecond( + parts[0].toLong(), + parts[1].toLong(), + ), + id = UUID.fromString(parts[2]), + ) + }.getOrElse { + throw AdminAuditCursorException() + } + } + + private fun encodeCursor(record: AdminAuditRecord): String { + val payload = buildString { + append(record.occurredAt.epochSecond) + append(':') + append(record.occurredAt.nano) + append(':') + append(record.id) + } + return Base64.getUrlEncoder().withoutPadding().encodeToString( + payload.toByteArray(StandardCharsets.UTF_8), + ) + } + + private companion object { + const val DEFAULT_PAGE_SIZE = 50 + const val MAX_PAGE_SIZE = 100 + const val MAX_CURSOR_LENGTH = 256 + } +} diff --git a/src/main/kotlin/com/osglab/account/features/admin/services/AdminAuthService.kt b/src/main/kotlin/com/osglab/account/features/admin/services/AdminAuthService.kt new file mode 100644 index 0000000..4559fc1 --- /dev/null +++ b/src/main/kotlin/com/osglab/account/features/admin/services/AdminAuthService.kt @@ -0,0 +1,236 @@ +package com.osglab.account.features.admin.services + +import com.osglab.account.common.security.FieldEncryptor +import com.osglab.account.common.security.SecureTokenGenerator +import com.osglab.account.common.security.Sha256SecureTokenGenerator +import com.osglab.account.common.security.TokenHash +import com.osglab.account.features.admin.models.AdminAuditAction +import com.osglab.account.features.admin.models.AdminAuditOutcome +import com.osglab.account.features.admin.models.AdminLockState +import com.osglab.account.features.admin.models.AdminLoginResult +import com.osglab.account.features.admin.models.AdminPrincipal +import com.osglab.account.features.admin.models.AdminSessionCredentials +import com.osglab.account.features.admin.models.NewAdminAuditEvent +import com.osglab.account.features.admin.models.NewAdminSession +import com.osglab.account.features.admin.repositories.AdminRepository +import com.osglab.account.features.admin.security.AdminPasswordHasher +import com.osglab.account.features.admin.security.AdminTotpVerifier +import com.osglab.account.features.admin.security.Base32TotpSecret +import java.time.Clock +import java.time.Duration +import java.time.Instant +import java.util.UUID + +class AdminLoginLockPolicy( + private val maxFailedAttempts: Int = 5, + private val lockDuration: Duration = Duration.ofMinutes(15), +) { + init { + require(maxFailedAttempts in 2..20) + require(!lockDuration.isNegative && !lockDuration.isZero) + require(lockDuration <= Duration.ofHours(24)) + } + + fun afterFailure(current: AdminLockState, now: Instant): AdminLockState { + if (current.isLockedAt(now)) return current + val failuresBeforeAttempt = if ( + current.lockedUntil != null && !current.lockedUntil.isAfter(now) + ) { + 0 + } else { + current.failedLoginCount + } + val failures = failuresBeforeAttempt + 1 + return if (failures >= maxFailedAttempts) { + AdminLockState(failedLoginCount = 0, lockedUntil = now.plus(lockDuration)) + } else { + AdminLockState(failedLoginCount = failures, lockedUntil = null) + } + } +} + +class AdminAuthService( + private val repository: AdminRepository, + private val passwordHasher: AdminPasswordHasher, + private val dummyPasswordHash: String, + private val totpVerifier: AdminTotpVerifier, + private val fieldEncryptor: FieldEncryptor, + sessionTtl: Duration, + private val lockPolicy: AdminLoginLockPolicy = AdminLoginLockPolicy(), + private val tokenGenerator: SecureTokenGenerator = Sha256SecureTokenGenerator(), + private val clock: Clock = Clock.systemUTC(), +) { + private val sessionTtl = sessionTtl.also { + require(it in MIN_SESSION_TTL..MAX_SESSION_TTL) + } + + init { + require(dummyPasswordHash.isNotBlank()) + } + + suspend fun login( + username: String, + password: CharArray, + totpCode: String, + requestId: String? = null, + ): AdminLoginResult { + val normalizedRequestId = validateRequestId(requestId) + val normalizedUsername = normalizeAdminUsername(username) + ?: return AdminLoginResult.InvalidCredentials + if (password.isEmpty() || password.size > MAX_PASSWORD_CHARS) { + return AdminLoginResult.InvalidCredentials + } + + val now = clock.instant() + val operator = repository.findOperatorForAuthentication(normalizedUsername) + val passwordMatches = passwordHasher.verify( + password, + operator?.passwordHash ?: dummyPasswordHash, + ) + if (operator == null) { + repository.appendAudit( + loginAudit(null, AdminAuditOutcome.DENIED, now, normalizedRequestId), + ) + return AdminLoginResult.InvalidCredentials + } + if (operator.disabledAt != null) { + repository.appendAudit( + loginAudit(operator.id, AdminAuditOutcome.DENIED, now, normalizedRequestId), + ) + return AdminLoginResult.InvalidCredentials + } + if (operator.lockState.isLockedAt(now)) { + // Password verification above keeps locked and unknown users on + // the same expensive hashing path. + repository.appendAudit( + loginAudit(operator.id, AdminAuditOutcome.DENIED, now, normalizedRequestId), + ) + return AdminLoginResult.Locked(requireNotNull(operator.lockState.lockedUntil)) + } + if (!passwordMatches) { + return failAuthentication(operator.id, now, normalizedRequestId) + } + + val acceptedCounter = verifyTotp(operator.id, operator.encryptedTotpSecret, totpCode, now) + ?: return failAuthentication(operator.id, now, normalizedRequestId) + val rawSessionToken = tokenGenerator.newRefreshToken() + val rawCsrfToken = tokenGenerator.newRefreshToken() + val session = NewAdminSession( + id = UUID.randomUUID(), + operatorId = operator.id, + tokenHash = TokenHash.sha256(rawSessionToken), + csrfTokenHash = TokenHash.sha256(rawCsrfToken), + createdAt = now, + expiresAt = now.plus(sessionTtl), + ) + val created = repository.createSessionIfTotpCounterFresh( + session = session, + totpCounter = acceptedCounter, + now = now, + auditEvent = loginAudit( + operator.id, + AdminAuditOutcome.SUCCESS, + now, + normalizedRequestId, + ), + ) ?: run { + repository.appendAudit( + loginAudit(operator.id, AdminAuditOutcome.DENIED, now, normalizedRequestId), + ) + return AdminLoginResult.InvalidCredentials + } + + return AdminLoginResult.Authenticated( + principal = AdminPrincipal( + operatorId = created.operatorId, + sessionId = created.id, + normalizedUsername = created.normalizedUsername, + role = created.role, + ), + credentials = AdminSessionCredentials( + sessionToken = rawSessionToken, + csrfToken = rawCsrfToken, + expiresAt = created.expiresAt, + ), + ) + } + + private suspend fun failAuthentication( + operatorId: UUID, + now: Instant, + requestId: String?, + ): AdminLoginResult { + val state = repository.updateLockState( + operatorId = operatorId, + now = now, + auditEvent = loginAudit(operatorId, AdminAuditOutcome.DENIED, now, requestId), + ) { + lockPolicy.afterFailure(it, now) + } + if (state == null) { + repository.appendAudit( + loginAudit(operatorId, AdminAuditOutcome.DENIED, now, requestId), + ) + return AdminLoginResult.InvalidCredentials + } + return state.lockedUntil + ?.takeIf { it.isAfter(now) } + ?.let(AdminLoginResult::Locked) + ?: AdminLoginResult.InvalidCredentials + } + + private fun verifyTotp( + operatorId: UUID, + encryptedSecret: String, + code: String, + now: Instant, + ): Long? { + val secret = runCatching { + Base32TotpSecret.decode( + fieldEncryptor.decrypt(encryptedSecret, adminTotpContext(operatorId)), + ) + }.getOrNull() ?: return null + return try { + totpVerifier.verify(secret, code, now) + } finally { + secret.fill(0) + } + } + + private fun loginAudit( + operatorId: UUID?, + outcome: AdminAuditOutcome, + now: Instant, + requestId: String?, + ) = NewAdminAuditEvent( + actorOperatorId = operatorId, + action = if (outcome == AdminAuditOutcome.SUCCESS) { + AdminAuditAction.LOGIN_SUCCEEDED + } else { + AdminAuditAction.LOGIN_FAILED + }, + outcome = outcome, + targetType = operatorId?.let { OPERATOR_TARGET }, + targetId = operatorId?.toString(), + requestId = requestId, + occurredAt = now, + ) + + private fun validateRequestId(requestId: String?): String? { + val normalized = requestId?.trim() ?: return null + return normalized.takeIf { + it.length in 1..MAX_REQUEST_ID_LENGTH && REQUEST_ID.matches(it) + } + } + + private companion object { + val MIN_SESSION_TTL: Duration = Duration.ofMinutes(5) + val MAX_SESSION_TTL: Duration = Duration.ofHours(24) + const val MAX_PASSWORD_CHARS = 1_024 + const val MAX_REQUEST_ID_LENGTH = 128 + const val OPERATOR_TARGET = "ADMIN_OPERATOR" + val REQUEST_ID = Regex("^[A-Za-z0-9._:-]+$") + } +} + +fun adminTotpContext(operatorId: UUID): String = "admin-totp-secret:$operatorId" diff --git a/src/main/kotlin/com/osglab/account/features/admin/services/AdminBootstrapService.kt b/src/main/kotlin/com/osglab/account/features/admin/services/AdminBootstrapService.kt new file mode 100644 index 0000000..0512734 --- /dev/null +++ b/src/main/kotlin/com/osglab/account/features/admin/services/AdminBootstrapService.kt @@ -0,0 +1,65 @@ +package com.osglab.account.features.admin.services + +import com.osglab.account.common.security.FieldEncryptor +import com.osglab.account.features.admin.models.AdminRole +import com.osglab.account.features.admin.models.NewAdminOperator +import com.osglab.account.features.admin.repositories.AdminRepository +import com.osglab.account.features.admin.security.Base32TotpSecret +import java.time.Clock +import java.util.UUID + +data class AdminBootstrapConfig( + val enabled: Boolean, + val operatorId: UUID?, + val username: String?, + val passwordHash: String?, + val totpSecretBase32: String?, +) { + init { + if (enabled) { + requireNotNull(operatorId) { "Admin bootstrap operator ID is required" } + require(!username.isNullOrBlank()) { "Admin bootstrap username is required" } + require(passwordHash?.startsWith("\$argon2id\$v=19\$") == true) { + "Admin bootstrap password must be an Argon2id PHC hash" + } + require(!totpSecretBase32.isNullOrBlank()) { "Admin bootstrap TOTP secret is required" } + } + } +} + +class AdminBootstrapService( + private val repository: AdminRepository, + private val fieldEncryptor: FieldEncryptor, + private val clock: Clock = Clock.systemUTC(), +) { + suspend fun initialize(config: AdminBootstrapConfig): Boolean { + if (!config.enabled) return false + val operatorId = requireNotNull(config.operatorId) + val username = requireNotNull( + normalizeAdminUsername(requireNotNull(config.username)), + ) { "Admin bootstrap username is invalid" } + val totpSecret = requireNotNull(config.totpSecretBase32).trim().uppercase() + Base32TotpSecret.decode(totpSecret).fill(0) + + val created = repository.createOperatorIfAbsent( + NewAdminOperator( + id = operatorId, + normalizedUsername = username, + passwordHash = requireNotNull(config.passwordHash), + encryptedTotpSecret = fieldEncryptor.encrypt( + totpSecret, + adminTotpContext(operatorId), + ), + role = AdminRole.SUPER_ADMIN, + createdAt = clock.instant(), + ), + ) + if (!created) { + val existing = repository.findOperatorForAuthentication(username) + require(existing?.id == operatorId) { + "Admin bootstrap configuration does not match the existing operator" + } + } + return created + } +} diff --git a/src/main/kotlin/com/osglab/account/features/admin/services/AdminOperatorService.kt b/src/main/kotlin/com/osglab/account/features/admin/services/AdminOperatorService.kt new file mode 100644 index 0000000..c6a1073 --- /dev/null +++ b/src/main/kotlin/com/osglab/account/features/admin/services/AdminOperatorService.kt @@ -0,0 +1,414 @@ +package com.osglab.account.features.admin.services + +import com.osglab.account.common.security.FieldEncryptor +import com.osglab.account.features.admin.models.AdminAuditAction +import com.osglab.account.features.admin.models.AdminAuditOutcome +import com.osglab.account.features.admin.models.AdminOperatorMutationResult +import com.osglab.account.features.admin.models.AdminOperatorCursor +import com.osglab.account.features.admin.models.AdminOperatorRecord +import com.osglab.account.features.admin.models.AdminPrincipal +import com.osglab.account.features.admin.models.AdminRole +import com.osglab.account.features.admin.models.NewAdminAuditEvent +import com.osglab.account.features.admin.models.NewAdminOperator +import com.osglab.account.features.admin.repositories.AdminRepository +import com.osglab.account.features.admin.security.AdminPasswordHasher +import com.osglab.account.features.admin.security.AdminTotpProvisioning +import com.osglab.account.features.admin.security.AdminTotpSecretGenerator +import com.osglab.account.features.admin.security.SecureAdminTotpSecretGenerator +import java.time.Clock +import java.time.Instant +import java.nio.charset.StandardCharsets +import java.util.Base64 +import java.util.Locale +import java.util.UUID + +enum class AdminOperatorErrorCode { + VALIDATION_ERROR, + INSUFFICIENT_PERMISSION, + ADMIN_OPERATOR_NOT_FOUND, + ADMIN_USERNAME_CONFLICT, + CANNOT_DISABLE_SELF, + LAST_SUPER_ADMIN_REQUIRED, +} + +class AdminOperatorException( + val code: AdminOperatorErrorCode, +) : RuntimeException(code.name) + +class AdminOperatorCursorException : RuntimeException("Invalid admin operator cursor") + +class AdminOperatorCredentials( + val operator: AdminOperatorRecord?, + val totpSecret: String, + val otpauthUri: String, +) { + override fun toString(): String = + "AdminOperatorCredentials(operator=${operator?.id}, totpSecret=[REDACTED], otpauthUri=[REDACTED])" +} + +data class AdminSecuritySummary( + val enabledOperators: Int, + val lockedOperators: Int, + val activeSessions: Long, +) + +data class AdminOperatorPage( + val items: List, + val nextCursor: String?, +) + +class AdminOperatorService( + private val repository: AdminRepository, + private val passwordHasher: AdminPasswordHasher, + private val fieldEncryptor: FieldEncryptor, + private val totpSecretGenerator: AdminTotpSecretGenerator = SecureAdminTotpSecretGenerator(), + private val clock: Clock = Clock.systemUTC(), +) { + suspend fun list(actor: AdminPrincipal): List { + requireSuperAdministrator(actor) + return repository.listOperators() + } + + suspend fun listPage( + actor: AdminPrincipal, + cursor: String?, + limit: Int = DEFAULT_PAGE_SIZE, + ): AdminOperatorPage { + requireSuperAdministrator(actor) + if (limit !in 1..MAX_PAGE_SIZE) { + throw AdminOperatorCursorException() + } + val decodedCursor = cursor?.let(::decodeCursor) + val records = repository.listOperatorsPage(limit + 1, decodedCursor) + val items = records.take(limit) + return AdminOperatorPage( + items = items, + nextCursor = if (records.size > limit) items.lastOrNull()?.let(::encodeCursor) else null, + ) + } + + suspend fun summary(actor: AdminPrincipal): AdminSecuritySummary { + requireSuperAdministrator(actor) + val now = clock.instant() + val operators = repository.listOperators() + return AdminSecuritySummary( + enabledOperators = operators.count { it.disabledAt == null }, + lockedOperators = operators.count { + it.lockState.lockedUntil?.isAfter(now) == true + }, + activeSessions = repository.countActiveSessions(now), + ) + } + + suspend fun create( + actor: AdminPrincipal, + username: String, + password: CharArray, + roleName: String, + requestId: String? = null, + ): AdminOperatorCredentials = try { + createInternal(actor, username, password, roleName, requestId) + } finally { + password.fill('\u0000') + } + + suspend fun setEnabled( + actor: AdminPrincipal, + operatorId: UUID, + enabled: Boolean, + requestId: String? = null, + ) { + val action = if (enabled) { + AdminAuditAction.OPERATOR_ENABLED + } else { + AdminAuditAction.OPERATOR_DISABLED + } + authorizeMutation(actor, action, operatorId, requestId) + if (!enabled && actor.operatorId == operatorId) { + auditDenied(actor, action, operatorId, requestId) + throw AdminOperatorException(AdminOperatorErrorCode.CANNOT_DISABLE_SELF) + } + mapMutationResult( + repository.setOperatorEnabled( + operatorId = operatorId, + enabled = enabled, + now = clock.instant(), + auditEvent = audit(actor, action, operatorId, requestId), + ), + ) + } + + suspend fun unlock( + actor: AdminPrincipal, + operatorId: UUID, + requestId: String? = null, + ) { + authorizeMutation(actor, AdminAuditAction.OPERATOR_UNLOCKED, operatorId, requestId) + mapMutationResult( + repository.unlockOperator( + operatorId = operatorId, + now = clock.instant(), + auditEvent = audit(actor, AdminAuditAction.OPERATOR_UNLOCKED, operatorId, requestId), + ), + ) + } + + suspend fun resetCredentials( + actor: AdminPrincipal, + operatorId: UUID, + password: CharArray, + requestId: String? = null, + ): AdminOperatorCredentials = try { + resetCredentialsInternal(actor, operatorId, password, requestId) + } finally { + password.fill('\u0000') + } + + suspend fun revokeSessions( + actor: AdminPrincipal, + operatorId: UUID, + requestId: String? = null, + ) { + authorizeMutation(actor, AdminAuditAction.OPERATOR_SESSIONS_REVOKED, operatorId, requestId) + mapMutationResult( + repository.revokeOperatorSessions( + operatorId = operatorId, + now = clock.instant(), + auditEvent = audit( + actor, + AdminAuditAction.OPERATOR_SESSIONS_REVOKED, + operatorId, + requestId, + ), + ), + ) + } + + private suspend fun createInternal( + actor: AdminPrincipal, + username: String, + password: CharArray, + roleName: String, + requestId: String?, + ): AdminOperatorCredentials { + authorizeMutation(actor, AdminAuditAction.OPERATOR_CREATED, null, requestId) + val normalizedUsername = normalizeAdminUsername(username) + val role = parseRole(roleName) + if (normalizedUsername == null || role == null || !validPassword(password)) { + auditDenied( + actor, + AdminAuditAction.OPERATOR_CREATED, + null, + requestId, + safeUsernameTarget(username), + ) + throw AdminOperatorException(AdminOperatorErrorCode.VALIDATION_ERROR) + } + + val operatorId = UUID.randomUUID() + val now = clock.instant() + val provisioning = totpSecretGenerator.generate(normalizedUsername) + val operator = NewAdminOperator( + id = operatorId, + normalizedUsername = normalizedUsername, + passwordHash = passwordHasher.hash(password), + encryptedTotpSecret = fieldEncryptor.encrypt( + provisioning.secretBase32, + adminTotpContext(operatorId), + ), + role = role, + createdAt = now, + ) + mapMutationResult( + repository.createOperator( + operator, + audit(actor, AdminAuditAction.OPERATOR_CREATED, operatorId, requestId), + ), + ) + return credentialsFromProvisioning( + operator = operator.toRecord(), + provisioning = provisioning, + ) + } + + private suspend fun resetCredentialsInternal( + actor: AdminPrincipal, + operatorId: UUID, + password: CharArray, + requestId: String?, + ): AdminOperatorCredentials { + authorizeMutation(actor, AdminAuditAction.OPERATOR_CREDENTIALS_RESET, operatorId, requestId) + if (!validPassword(password)) { + auditDenied(actor, AdminAuditAction.OPERATOR_CREDENTIALS_RESET, operatorId, requestId) + throw AdminOperatorException(AdminOperatorErrorCode.VALIDATION_ERROR) + } + val current = repository.findOperator(operatorId) + if (current == null) { + auditDenied(actor, AdminAuditAction.OPERATOR_CREDENTIALS_RESET, operatorId, requestId) + throw AdminOperatorException(AdminOperatorErrorCode.ADMIN_OPERATOR_NOT_FOUND) + } + val provisioning = totpSecretGenerator.generate(current.normalizedUsername) + val result = repository.resetOperatorCredentials( + operatorId = operatorId, + passwordHash = passwordHasher.hash(password), + encryptedTotpSecret = fieldEncryptor.encrypt( + provisioning.secretBase32, + adminTotpContext(operatorId), + ), + now = clock.instant(), + auditEvent = audit( + actor, + AdminAuditAction.OPERATOR_CREDENTIALS_RESET, + operatorId, + requestId, + ), + ) + mapMutationResult(result) + return credentialsFromProvisioning(null, provisioning) + } + + private fun requireSuperAdministrator(actor: AdminPrincipal) { + if (actor.role != AdminRole.SUPER_ADMIN) { + throw AdminOperatorException(AdminOperatorErrorCode.INSUFFICIENT_PERMISSION) + } + } + + private suspend fun authorizeMutation( + actor: AdminPrincipal, + action: AdminAuditAction, + operatorId: UUID?, + requestId: String?, + ) { + if (actor.role != AdminRole.SUPER_ADMIN) { + auditDenied(actor, action, operatorId, requestId) + throw AdminOperatorException(AdminOperatorErrorCode.INSUFFICIENT_PERMISSION) + } + } + + private suspend fun auditDenied( + actor: AdminPrincipal, + action: AdminAuditAction, + operatorId: UUID?, + requestId: String?, + targetOverride: String? = null, + ) { + repository.appendAudit( + audit( + actor = actor, + action = action, + operatorId = operatorId, + requestId = requestId, + outcome = AdminAuditOutcome.DENIED, + targetOverride = targetOverride, + ), + ) + } + + private fun audit( + actor: AdminPrincipal, + action: AdminAuditAction, + operatorId: UUID?, + requestId: String?, + outcome: AdminAuditOutcome = AdminAuditOutcome.SUCCESS, + targetOverride: String? = null, + ) = NewAdminAuditEvent( + actorOperatorId = actor.operatorId, + action = action, + outcome = outcome, + targetType = OPERATOR_TARGET, + targetId = targetOverride ?: operatorId?.toString() ?: "NEW", + requestId = normalizeAdminRequestId(requestId), + occurredAt = clock.instant(), + ) + + private fun mapMutationResult(result: AdminOperatorMutationResult) { + when (result) { + AdminOperatorMutationResult.SUCCESS -> Unit + AdminOperatorMutationResult.NOT_FOUND -> + throw AdminOperatorException(AdminOperatorErrorCode.ADMIN_OPERATOR_NOT_FOUND) + AdminOperatorMutationResult.USERNAME_CONFLICT -> + throw AdminOperatorException(AdminOperatorErrorCode.ADMIN_USERNAME_CONFLICT) + AdminOperatorMutationResult.LAST_SUPER_ADMIN -> + throw AdminOperatorException(AdminOperatorErrorCode.LAST_SUPER_ADMIN_REQUIRED) + } + } + + private fun validPassword(password: CharArray): Boolean = + password.size in MIN_PASSWORD_CHARS..MAX_PASSWORD_CHARS + + private fun parseRole(roleName: String): AdminRole? = + runCatching { AdminRole.valueOf(roleName) }.getOrNull() + + private fun safeUsernameTarget(username: String): String = + username.trim().lowercase(Locale.ROOT).take(MAX_AUDIT_TARGET_CHARS).ifBlank { "INVALID" } + + private fun NewAdminOperator.toRecord() = AdminOperatorRecord( + id = id, + normalizedUsername = normalizedUsername, + role = role, + lockState = com.osglab.account.features.admin.models.AdminLockState(0, null), + disabledAt = null, + lastLoginAt = null, + createdAt = createdAt, + updatedAt = createdAt, + ) + + private fun credentialsFromProvisioning( + operator: AdminOperatorRecord?, + provisioning: AdminTotpProvisioning, + ) = AdminOperatorCredentials( + operator = operator, + totpSecret = provisioning.secretBase32, + otpauthUri = provisioning.otpauthUri, + ) + + private companion object { + const val DEFAULT_PAGE_SIZE = 50 + const val MAX_PAGE_SIZE = 100 + const val MAX_CURSOR_LENGTH = 256 + const val MIN_PASSWORD_CHARS = 12 + const val MAX_PASSWORD_CHARS = 1_024 + const val MAX_AUDIT_TARGET_CHARS = 128 + const val OPERATOR_TARGET = "ADMIN_OPERATOR" + } + + private fun decodeCursor(value: String): AdminOperatorCursor { + if (value.length !in 1..MAX_CURSOR_LENGTH) throw AdminOperatorCursorException() + return runCatching { + val decoded = String( + Base64.getUrlDecoder().decode(value), + StandardCharsets.UTF_8, + ) + val parts = decoded.split(':', limit = 3) + require(parts.size == 3) + AdminOperatorCursor( + createdAt = Instant.ofEpochSecond(parts[0].toLong(), parts[1].toLong()), + id = UUID.fromString(parts[2]), + ) + }.getOrElse { + throw AdminOperatorCursorException() + } + } + + private fun encodeCursor(record: AdminOperatorRecord): String { + val payload = "${record.createdAt.epochSecond}:${record.createdAt.nano}:${record.id}" + return Base64.getUrlEncoder().withoutPadding().encodeToString( + payload.toByteArray(StandardCharsets.UTF_8), + ) + } +} + +fun normalizeAdminUsername(value: String): String? { + val normalized = value.trim().lowercase(Locale.ROOT) + return normalized.takeIf { ADMIN_USERNAME.matches(it) } +} + +fun normalizeAdminRequestId(value: String?): String? { + val normalized = value?.trim() ?: return null + return normalized.takeIf { + it.length <= 128 && ADMIN_REQUEST_ID.matches(it) + } +} + +private val ADMIN_USERNAME = Regex("^[a-z0-9][a-z0-9._@-]{2,63}$") +private val ADMIN_REQUEST_ID = Regex("^[A-Za-z0-9._:-]+$") diff --git a/src/main/kotlin/com/osglab/account/features/admin/services/AdminSessionService.kt b/src/main/kotlin/com/osglab/account/features/admin/services/AdminSessionService.kt new file mode 100644 index 0000000..d0ab140 --- /dev/null +++ b/src/main/kotlin/com/osglab/account/features/admin/services/AdminSessionService.kt @@ -0,0 +1,94 @@ +package com.osglab.account.features.admin.services + +import com.osglab.account.common.security.TokenHash +import com.osglab.account.features.admin.models.AdminAuditAction +import com.osglab.account.features.admin.models.AdminAuditOutcome +import com.osglab.account.features.admin.models.AdminPrincipal +import com.osglab.account.features.admin.models.AdminSessionRecord +import com.osglab.account.features.admin.models.NewAdminAuditEvent +import com.osglab.account.features.admin.repositories.AdminRepository +import com.osglab.account.features.admin.security.AdminCsrfVerifier +import java.time.Clock +import java.time.Duration + +class AdminSessionService( + private val repository: AdminRepository, + private val csrfVerifier: AdminCsrfVerifier = AdminCsrfVerifier(), + private val clock: Clock = Clock.systemUTC(), +) { + suspend fun authenticate(sessionToken: String): AdminPrincipal? = + findSession(sessionToken)?.toPrincipal() + + suspend fun authenticateMutation( + sessionToken: String, + csrfToken: String, + ): AdminPrincipal? { + val session = findSession(sessionToken) ?: return null + if (!csrfVerifier.verify(csrfToken, session.csrfTokenHash)) return null + return session.toPrincipal() + } + + suspend fun revoke( + sessionToken: String, + csrfToken: String, + requestId: String? = null, + ): Boolean { + val tokenHash = hashValidToken(sessionToken) ?: return false + val now = clock.instant() + val active = repository.findActiveSessionByTokenHash(tokenHash, now) ?: return false + if (!csrfVerifier.verify(csrfToken, active.csrfTokenHash)) return false + val event = NewAdminAuditEvent( + actorOperatorId = active.operatorId, + action = AdminAuditAction.SESSION_REVOKED, + outcome = AdminAuditOutcome.SUCCESS, + targetType = SESSION_TARGET, + targetId = active.id.toString(), + requestId = validateRequestId(requestId), + occurredAt = now, + ) + return repository.revokeSessionByTokenHash(tokenHash, now, event) != null + } + + suspend fun cleanupInactive( + retention: Duration = DEFAULT_INACTIVE_RETENTION, + limit: Int = DEFAULT_CLEANUP_BATCH_SIZE, + ): Int { + require(!retention.isNegative && !retention.isZero) + require(limit in 1..MAX_CLEANUP_BATCH_SIZE) + return repository.purgeInactiveSessions(clock.instant().minus(retention), limit) + } + + private suspend fun findSession(sessionToken: String): AdminSessionRecord? { + val tokenHash = hashValidToken(sessionToken) ?: return null + return repository.findActiveSessionByTokenHash(tokenHash, clock.instant()) + } + + private fun hashValidToken(token: String): String? { + if (token.isBlank() || token.length > MAX_TOKEN_LENGTH) return null + return TokenHash.sha256(token) + } + + private fun validateRequestId(requestId: String?): String? { + val normalized = requestId?.trim() ?: return null + return normalized.takeIf { + it.length in 1..MAX_REQUEST_ID_LENGTH && REQUEST_ID.matches(it) + } + } + + private fun AdminSessionRecord.toPrincipal() = AdminPrincipal( + operatorId = operatorId, + sessionId = id, + normalizedUsername = normalizedUsername, + role = role, + ) + + private companion object { + const val MAX_TOKEN_LENGTH = 512 + const val MAX_REQUEST_ID_LENGTH = 128 + const val DEFAULT_CLEANUP_BATCH_SIZE = 500 + const val MAX_CLEANUP_BATCH_SIZE = 1_000 + const val SESSION_TARGET = "ADMIN_SESSION" + val DEFAULT_INACTIVE_RETENTION: Duration = Duration.ofDays(7) + val REQUEST_ID = Regex("^[A-Za-z0-9._:-]+$") + } +} diff --git a/src/main/kotlin/com/osglab/account/features/admin/stats/models/AdminStatsDtos.kt b/src/main/kotlin/com/osglab/account/features/admin/stats/models/AdminStatsDtos.kt new file mode 100644 index 0000000..2d99eab --- /dev/null +++ b/src/main/kotlin/com/osglab/account/features/admin/stats/models/AdminStatsDtos.kt @@ -0,0 +1,70 @@ +package com.osglab.account.features.admin.stats.models + +import kotlinx.serialization.Serializable + +@Serializable +data class AdminStatsPeriodDto( + val from: String, + val until: String, +) + +@Serializable +data class AdminOverviewDto( + val totalUsers: Long, + val registrations: Long, + val activeUsers: Long, + val totalCreditBalance: Long, + val issuedCredits: Long, + val consumedCredits: Long, +) + +@Serializable +data class AdminRegistrationPointDto( + val date: String, + val registrations: Long, +) + +@Serializable +data class AdminCreditFlowPointDto( + val date: String, + val issuedCredits: Long, + val consumedCredits: Long, +) + +@Serializable +data class AdminReferralFunnelDto( + val codesCreated: Long, + val bindings: Long, + val rewardedBindings: Long, + val pendingBindings: Long, + val ineligibleBindings: Long, +) + +@Serializable +data class AdminReferralRankDto( + val userId: String, + val invitedUsers: Long, + val rewardedUsers: Long, + val earnedCredits: Long, +) + +@Serializable +data class AdminUsageAggregateDto( + val kind: String, + val requests: Long, + val chargedCredits: Long, + val asrMillis: Long, + val inputTokens: Long, + val outputTokens: Long, +) + +@Serializable +data class AdminStatsDto( + val period: AdminStatsPeriodDto, + val overview: AdminOverviewDto, + val registrationTrend: List, + val creditFlow: List, + val referralFunnel: AdminReferralFunnelDto, + val referralRanking: List, + val usage: List, +) diff --git a/src/main/kotlin/com/osglab/account/features/admin/stats/repositories/AdminStatsRepository.kt b/src/main/kotlin/com/osglab/account/features/admin/stats/repositories/AdminStatsRepository.kt new file mode 100644 index 0000000..1c30b43 --- /dev/null +++ b/src/main/kotlin/com/osglab/account/features/admin/stats/repositories/AdminStatsRepository.kt @@ -0,0 +1,334 @@ +package com.osglab.account.features.admin.stats.repositories + +import com.osglab.account.config.DatabaseFactory +import com.osglab.account.features.admin.stats.models.AdminOverviewDto +import com.osglab.account.features.admin.stats.models.AdminReferralFunnelDto +import com.osglab.account.features.admin.stats.models.AdminReferralRankDto +import com.osglab.account.features.admin.stats.models.AdminUsageAggregateDto +import org.jetbrains.exposed.v1.core.IColumnType +import org.jetbrains.exposed.v1.javatime.JavaInstantColumnType +import org.jetbrains.exposed.v1.jdbc.transactions.TransactionManager +import java.math.BigDecimal +import java.sql.ResultSet +import java.time.Instant +import java.time.LocalDate + +data class AdminStatsRange( + val from: Instant, + val until: Instant, +) { + init { + require(from < until) { "Admin statistics range must be non-empty" } + } +} + +data class AdminStatsSnapshot( + val overview: AdminOverviewDto, + val registrationsByDate: Map, + val issuedCreditsByDate: Map, + val consumedCreditsByDate: Map, + val referralFunnel: AdminReferralFunnelDto, + val referralRanking: List, + val usage: List, +) + +fun interface AdminStatsRepository { + suspend fun load(range: AdminStatsRange): AdminStatsSnapshot +} + +internal data class AdminStatsAggregates( + val overview: AdminOverviewDto, + val registrationsByDate: Map, + val issuedCreditsByDate: Map, + val consumedCreditsByDate: Map, + val referralFunnel: AdminReferralFunnelDto, + val referralBindingsByInviter: List, + val referralCreditsByInviter: Map, + val usage: List, +) + +internal data class ReferralBindingAggregateRow( + val inviterUserId: String, + val invitedUsers: Long, + val rewardedUsers: Long, +) + +class ExposedAdminStatsRepository( + private val databaseFactory: DatabaseFactory, +) : AdminStatsRepository { + override suspend fun load(range: AdminStatsRange): AdminStatsSnapshot = + databaseFactory.query { + assembleAdminStats(loadAggregates(range)) + } + + private fun loadAggregates(range: AdminStatsRange): AdminStatsAggregates = + AdminStatsAggregates( + overview = loadOverview(range), + registrationsByDate = loadDailyAggregates( + """ + SELECT DATE(created_at) AS aggregate_date, COUNT(*) AS aggregate_value + FROM accounts + WHERE created_at >= ? AND created_at < ? + GROUP BY DATE(created_at) + """, + range, + ), + issuedCreditsByDate = loadDailyAggregates( + """ + SELECT DATE(created_at) AS aggregate_date, + COALESCE(SUM(amount_delta), 0) AS aggregate_value + FROM credit_ledger + WHERE created_at >= ? AND created_at < ? + AND amount_delta > 0 + AND entry_type IN ( + 'SIGNUP_TRIAL', 'MANUAL_GRANT', 'REFERRAL_INVITER', + 'REFERRAL_INVITEE', 'STOREKIT_PURCHASE', 'SUBSCRIPTION_GRANT' + ) + GROUP BY DATE(created_at) + """, + range, + ), + consumedCreditsByDate = loadDailyAggregates( + """ + SELECT DATE(created_at) AS aggregate_date, + COALESCE(SUM(charged_credits), 0) AS aggregate_value + FROM credit_usage_records + WHERE created_at >= ? AND created_at < ? + GROUP BY DATE(created_at) + """, + range, + ), + referralFunnel = loadReferralFunnel(range), + referralBindingsByInviter = loadReferralBindingsByInviter(range), + referralCreditsByInviter = loadReferralCreditsByInviter(range), + usage = loadUsage(range), + ) + + private fun loadOverview(range: AdminStatsRange): AdminOverviewDto = + querySingle( + """ + SELECT + (SELECT COUNT(*) FROM accounts) AS total_users, + ( + SELECT COUNT(*) + FROM accounts + WHERE created_at >= ? AND created_at < ? + ) AS registrations, + ( + SELECT COUNT(DISTINCT user_id) + FROM credit_usage_records + WHERE created_at >= ? AND created_at < ? + ) AS active_users, + ( + SELECT COALESCE(SUM(balance), 0) + FROM credit_accounts + ) AS total_credit_balance, + ( + SELECT COALESCE(SUM(amount_delta), 0) + FROM credit_ledger + WHERE created_at >= ? AND created_at < ? + AND amount_delta > 0 + AND entry_type IN ( + 'SIGNUP_TRIAL', 'MANUAL_GRANT', 'REFERRAL_INVITER', + 'REFERRAL_INVITEE', 'STOREKIT_PURCHASE', 'SUBSCRIPTION_GRANT' + ) + ) AS issued_credits, + ( + SELECT COALESCE(SUM(charged_credits), 0) + FROM credit_usage_records + WHERE created_at >= ? AND created_at < ? + ) AS consumed_credits + """, + range.arguments(repetitions = 4), + ) { result -> + AdminOverviewDto( + totalUsers = result.exactLong("total_users"), + registrations = result.exactLong("registrations"), + activeUsers = result.exactLong("active_users"), + totalCreditBalance = result.exactLong("total_credit_balance"), + issuedCredits = result.exactLong("issued_credits"), + consumedCredits = result.exactLong("consumed_credits"), + ) + } + + private fun loadReferralFunnel(range: AdminStatsRange): AdminReferralFunnelDto = + querySingle( + """ + SELECT + ( + SELECT COUNT(*) + FROM referral_codes + WHERE created_at >= ? AND created_at < ? + ) AS codes_created, + ( + SELECT COUNT(*) + FROM referral_bindings + WHERE bound_at >= ? AND bound_at < ? + ) AS bindings, + ( + SELECT COUNT(*) + FROM referral_bindings + WHERE reward_status = 'REWARDED' + AND rewarded_at >= ? AND rewarded_at < ? + ) AS rewarded_bindings, + ( + SELECT COUNT(*) + FROM referral_bindings + WHERE reward_status = 'PENDING' + AND bound_at >= ? AND bound_at < ? + ) AS pending_bindings, + ( + SELECT COUNT(*) + FROM referral_bindings + WHERE reward_status = 'INELIGIBLE_BUDGET' + AND bound_at >= ? AND bound_at < ? + ) AS ineligible_bindings + """, + range.arguments(repetitions = 5), + ) { result -> + AdminReferralFunnelDto( + codesCreated = result.exactLong("codes_created"), + bindings = result.exactLong("bindings"), + rewardedBindings = result.exactLong("rewarded_bindings"), + pendingBindings = result.exactLong("pending_bindings"), + ineligibleBindings = result.exactLong("ineligible_bindings"), + ) + } + + private fun loadReferralBindingsByInviter( + range: AdminStatsRange, + ): List = + queryRows( + """ + SELECT + inviter_user_id, + SUM(CASE WHEN bound_at >= ? AND bound_at < ? THEN 1 ELSE 0 END) AS invited_users, + SUM( + CASE + WHEN reward_status = 'REWARDED' + AND rewarded_at >= ? AND rewarded_at < ? + THEN 1 ELSE 0 + END + ) AS rewarded_users + FROM referral_bindings + WHERE (bound_at >= ? AND bound_at < ?) + OR ( + reward_status = 'REWARDED' + AND rewarded_at >= ? AND rewarded_at < ? + ) + GROUP BY inviter_user_id + """, + range.arguments(repetitions = 4), + ) { result -> + ReferralBindingAggregateRow( + inviterUserId = result.getString("inviter_user_id"), + invitedUsers = result.exactLong("invited_users"), + rewardedUsers = result.exactLong("rewarded_users"), + ) + } + + private fun loadReferralCreditsByInviter(range: AdminStatsRange): Map = + queryRows( + """ + SELECT user_id, COALESCE(SUM(amount_delta), 0) AS earned_credits + FROM credit_ledger + WHERE created_at >= ? AND created_at < ? + AND entry_type = 'REFERRAL_INVITER' + AND amount_delta > 0 + GROUP BY user_id + """, + range.arguments(), + ) { result -> + result.getString("user_id") to result.exactLong("earned_credits") + }.toMap() + + private fun loadUsage(range: AdminStatsRange): List = + queryRows( + """ + SELECT + usage_kind, + COUNT(*) AS requests, + COALESCE(SUM(charged_credits), 0) AS charged_credits, + COALESCE(SUM(asr_millis), 0) AS asr_millis, + COALESCE(SUM(input_tokens), 0) AS input_tokens, + COALESCE(SUM(output_tokens), 0) AS output_tokens + FROM credit_usage_records + WHERE created_at >= ? AND created_at < ? + GROUP BY usage_kind + ORDER BY usage_kind + """, + range.arguments(), + ) { result -> + AdminUsageAggregateDto( + kind = result.getString("usage_kind"), + requests = result.exactLong("requests"), + chargedCredits = result.exactLong("charged_credits"), + asrMillis = result.exactLong("asr_millis"), + inputTokens = result.exactLong("input_tokens"), + outputTokens = result.exactLong("output_tokens"), + ) + } + + private fun loadDailyAggregates(sql: String, range: AdminStatsRange): Map = + queryRows(sql, range.arguments()) { result -> + result.getObject("aggregate_date", LocalDate::class.java) to + result.exactLong("aggregate_value") + }.toMap() +} + +internal fun assembleAdminStats(aggregates: AdminStatsAggregates): AdminStatsSnapshot = + AdminStatsSnapshot( + overview = aggregates.overview, + registrationsByDate = aggregates.registrationsByDate, + issuedCreditsByDate = aggregates.issuedCreditsByDate, + consumedCreditsByDate = aggregates.consumedCreditsByDate, + referralFunnel = aggregates.referralFunnel, + referralRanking = aggregates.referralBindingsByInviter.map { binding -> + AdminReferralRankDto( + userId = binding.inviterUserId, + invitedUsers = binding.invitedUsers, + rewardedUsers = binding.rewardedUsers, + earnedCredits = aggregates.referralCreditsByInviter[binding.inviterUserId] ?: 0, + ) + }.sortedWith( + compareByDescending(AdminReferralRankDto::invitedUsers) + .thenByDescending(AdminReferralRankDto::rewardedUsers) + .thenBy(AdminReferralRankDto::userId), + ), + usage = aggregates.usage.sortedBy(AdminUsageAggregateDto::kind), + ) + +private fun AdminStatsRange.arguments( + repetitions: Int = 1, +): List, Any?>> = buildList { + repeat(repetitions) { + add(INSTANT_COLUMN_TYPE to from) + add(INSTANT_COLUMN_TYPE to until) + } +} + +private fun querySingle( + sql: String, + arguments: List, Any?>>, + transform: (ResultSet) -> T, +): T = queryRows(sql, arguments, transform).single() + +private fun queryRows( + sql: String, + arguments: List, Any?>>, + transform: (ResultSet) -> T, +): List = TransactionManager.current().exec(sql.trimIndent(), arguments) { result -> + buildList { + while (result.next()) { + add(transform(result)) + } + } +} ?: emptyList() + +private fun ResultSet.exactLong(column: String): Long = + requireNotNull(getBigDecimal(column)) { "Aggregate column $column must not be null" } + .toExactLong() + +internal fun BigDecimal.toExactLong(): Long = longValueExact() + +private val INSTANT_COLUMN_TYPE = JavaInstantColumnType() diff --git a/src/main/kotlin/com/osglab/account/features/admin/stats/services/AdminStatsService.kt b/src/main/kotlin/com/osglab/account/features/admin/stats/services/AdminStatsService.kt new file mode 100644 index 0000000..7db2fd7 --- /dev/null +++ b/src/main/kotlin/com/osglab/account/features/admin/stats/services/AdminStatsService.kt @@ -0,0 +1,57 @@ +package com.osglab.account.features.admin.stats.services + +import com.osglab.account.features.admin.stats.models.AdminCreditFlowPointDto +import com.osglab.account.features.admin.stats.models.AdminRegistrationPointDto +import com.osglab.account.features.admin.stats.models.AdminStatsDto +import com.osglab.account.features.admin.stats.models.AdminStatsPeriodDto +import com.osglab.account.features.admin.stats.repositories.AdminStatsRange +import com.osglab.account.features.admin.stats.repositories.AdminStatsRepository +import java.time.Instant +import java.time.LocalDate +import java.time.ZoneOffset + +class AdminStatsService( + private val repository: AdminStatsRepository, +) { + suspend fun get( + from: Instant, + until: Instant, + referralRankLimit: Int = 20, + ): AdminStatsDto { + require(from < until) { "Statistics range must be non-empty" } + require(referralRankLimit in 1..100) { "Referral rank limit must be between 1 and 100" } + val range = AdminStatsRange(from, until) + val snapshot = repository.load(range) + val dates = utcDates(range) + return AdminStatsDto( + period = AdminStatsPeriodDto(from.toString(), until.toString()), + overview = snapshot.overview, + registrationTrend = dates.map { date -> + AdminRegistrationPointDto( + date = date.toString(), + registrations = snapshot.registrationsByDate[date] ?: 0, + ) + }, + creditFlow = dates.map { date -> + AdminCreditFlowPointDto( + date = date.toString(), + issuedCredits = snapshot.issuedCreditsByDate[date] ?: 0, + consumedCredits = snapshot.consumedCreditsByDate[date] ?: 0, + ) + }, + referralFunnel = snapshot.referralFunnel, + referralRanking = snapshot.referralRanking.take(referralRankLimit), + usage = snapshot.usage, + ) + } +} + +private fun utcDates(range: AdminStatsRange): List { + val dates = mutableListOf() + var date = range.from.atZone(ZoneOffset.UTC).toLocalDate() + while (date.atStartOfDay(ZoneOffset.UTC).toInstant() < range.until) { + dates += date + date = date.plusDays(1) + } + return dates +} diff --git a/src/main/kotlin/com/osglab/account/features/admin/users/models/AdminUserDtos.kt b/src/main/kotlin/com/osglab/account/features/admin/users/models/AdminUserDtos.kt new file mode 100644 index 0000000..7f51e2f --- /dev/null +++ b/src/main/kotlin/com/osglab/account/features/admin/users/models/AdminUserDtos.kt @@ -0,0 +1,56 @@ +package com.osglab.account.features.admin.users.models + +import com.osglab.account.features.admin.stats.models.AdminUsageAggregateDto +import kotlinx.serialization.Serializable + +@Serializable +data class AdminUserSummaryDto( + val id: String, + val createdAt: String, + val antiAbuseRestricted: Boolean, + val creditBalance: Long, + val consumedCredits: Long, + val manualGrantedCredits: Long, + val usageRequests: Long, + val lastActiveAt: String?, + val invitedUsers: Long, + val rewardedInvites: Long, +) + +@Serializable +data class AdminUserPageDto( + val items: List, + val nextCursor: String?, +) + +@Serializable +data class AdminUserLedgerEntryDto( + val id: String, + val type: String, + val amountDelta: Long, + val balanceAfter: Long, + val referenceId: String?, + val createdAt: String, +) + +@Serializable +data class AdminUserLedgerPageDto( + val items: List, + val nextCursor: String?, +) + +@Serializable +data class AdminUserReferralDto( + val inviterUserId: String?, + val invitedUsers: Long, + val rewardedInvites: Long, +) + +@Serializable +data class AdminUserDetailDto( + val summary: AdminUserSummaryDto, + val usage: List, + val referral: AdminUserReferralDto, + val recentLedger: List, + val referralCode: String? = null, +) diff --git a/src/main/kotlin/com/osglab/account/features/admin/users/repositories/AdminUsersRepository.kt b/src/main/kotlin/com/osglab/account/features/admin/users/repositories/AdminUsersRepository.kt new file mode 100644 index 0000000..3962345 --- /dev/null +++ b/src/main/kotlin/com/osglab/account/features/admin/users/repositories/AdminUsersRepository.kt @@ -0,0 +1,332 @@ +package com.osglab.account.features.admin.users.repositories + +import com.osglab.account.config.DatabaseFactory +import com.osglab.account.features.admin.stats.models.AdminUsageAggregateDto +import com.osglab.account.features.admin.users.models.AdminUserDetailDto +import com.osglab.account.features.admin.users.models.AdminUserLedgerEntryDto +import com.osglab.account.features.admin.users.models.AdminUserReferralDto +import com.osglab.account.features.admin.users.models.AdminUserSummaryDto +import com.osglab.account.features.credits.domain.LedgerEntryType +import com.osglab.account.features.credits.domain.UsageKind +import com.osglab.account.features.referrals.domain.ReferralRewardStatus +import org.jetbrains.exposed.v1.core.ResultRow +import org.jetbrains.exposed.v1.core.SortOrder +import org.jetbrains.exposed.v1.core.Table +import org.jetbrains.exposed.v1.core.and +import org.jetbrains.exposed.v1.core.eq +import org.jetbrains.exposed.v1.core.inList +import org.jetbrains.exposed.v1.core.less +import org.jetbrains.exposed.v1.core.or +import org.jetbrains.exposed.v1.javatime.timestamp +import org.jetbrains.exposed.v1.jdbc.selectAll +import java.time.Instant +import java.util.UUID + +data class AdminUserCursor( + val createdAt: Instant, + val userId: UUID, +) + +data class AdminUserLedgerCursor( + val createdAt: Instant, + val ledgerEntryId: UUID, +) + +interface AdminUsersRepository { + suspend fun list(limit: Int, cursor: AdminUserCursor?): List + + suspend fun exists(userId: UUID): Boolean + + suspend fun findDetail(userId: UUID, ledgerLimit: Int): AdminUserDetailDto? + + suspend fun listLedger( + userId: UUID, + limit: Int, + cursor: AdminUserLedgerCursor?, + ): List +} + +class ExposedAdminUsersRepository( + private val databaseFactory: DatabaseFactory, +) : AdminUsersRepository { + override suspend fun list( + limit: Int, + cursor: AdminUserCursor?, + ): List = databaseFactory.query { + val query = AdminUsersAccountsTable.selectAll() + if (cursor != null) { + query.where { + (AdminUsersAccountsTable.createdAt less cursor.createdAt) or + ( + (AdminUsersAccountsTable.createdAt eq cursor.createdAt) and + (AdminUsersAccountsTable.id less cursor.userId.toString()) + ) + } + } + val accountRows = query + .orderBy( + AdminUsersAccountsTable.createdAt to SortOrder.DESC, + AdminUsersAccountsTable.id to SortOrder.DESC, + ) + .limit(limit) + .toList() + val support = loadSupport(accountRows.map { it.userId() }.toSet()) + accountRows.map { it.toSummary(support) } + } + + override suspend fun exists(userId: UUID): Boolean = databaseFactory.query { + AdminUsersAccountsTable.selectAll() + .where { AdminUsersAccountsTable.id eq userId.toString() } + .limit(1) + .any() + } + + override suspend fun findDetail( + userId: UUID, + ledgerLimit: Int, + ): AdminUserDetailDto? = databaseFactory.query { + val account = AdminUsersAccountsTable.selectAll() + .where { AdminUsersAccountsTable.id eq userId.toString() } + .singleOrNull() + ?: return@query null + val support = loadSupport(setOf(userId)) + val invitations = support.bindings.filter { it.inviterUserId == userId } + val inviter = support.bindings.singleOrNull { it.inviteeUserId == userId }?.inviterUserId + val usage = support.usage.filter { it.userId == userId } + .groupBy(UserUsageRow::kind) + .map { (kind, records) -> + AdminUsageAggregateDto( + kind = kind.name, + requests = records.size.toLong(), + chargedCredits = records.exactSumOf(UserUsageRow::chargedCredits), + asrMillis = records.exactSumOf { it.asrMillis ?: 0 }, + inputTokens = records.exactSumOf { it.inputTokens ?: 0 }, + outputTokens = records.exactSumOf { it.outputTokens ?: 0 }, + ) + } + .sortedBy(AdminUsageAggregateDto::kind) + val recentLedger = support.ledger.filter { it.userId == userId } + .sortedWith( + compareByDescending(UserLedgerRow::createdAt) + .thenByDescending { it.id.toString() }, + ) + .take(ledgerLimit) + .map(UserLedgerRow::toDto) + AdminUserDetailDto( + summary = account.toSummary(support), + referralCode = findReferralCode(userId), + usage = usage, + referral = AdminUserReferralDto( + inviterUserId = inviter?.toString(), + invitedUsers = invitations.size.toLong(), + rewardedInvites = invitations.count { + it.status == ReferralRewardStatus.REWARDED + }.toLong(), + ), + recentLedger = recentLedger, + ) + } + + override suspend fun listLedger( + userId: UUID, + limit: Int, + cursor: AdminUserLedgerCursor?, + ): List = databaseFactory.query { + val query = AdminUsersCreditLedgerTable.selectAll() + if (cursor == null) { + query.where { AdminUsersCreditLedgerTable.userId eq userId.toString() } + } else { + query.where { + (AdminUsersCreditLedgerTable.userId eq userId.toString()) and + ( + (AdminUsersCreditLedgerTable.createdAt less cursor.createdAt) or + ( + (AdminUsersCreditLedgerTable.createdAt eq cursor.createdAt) and + (AdminUsersCreditLedgerTable.id less cursor.ledgerEntryId.toString()) + ) + ) + } + } + query.orderBy( + AdminUsersCreditLedgerTable.createdAt to SortOrder.DESC, + AdminUsersCreditLedgerTable.id to SortOrder.DESC, + ) + .limit(limit) + .map { it.toUserLedgerRow().toDto() } + } +} + +private data class UserSupportRows( + val balances: Map, + val ledger: List, + val usage: List, + val bindings: List, +) + +private data class UserLedgerRow( + val id: UUID, + val userId: UUID, + val type: LedgerEntryType, + val amountDelta: Long, + val balanceAfter: Long, + val referenceId: UUID?, + val createdAt: Instant, +) + +private data class UserUsageRow( + val userId: UUID, + val kind: UsageKind, + val asrMillis: Long?, + val inputTokens: Long?, + val outputTokens: Long?, + val chargedCredits: Long, + val createdAt: Instant, +) + +private data class UserReferralBindingRow( + val inviterUserId: UUID, + val inviteeUserId: UUID, + val status: ReferralRewardStatus, +) + +private fun loadSupport(userIds: Set): UserSupportRows { + if (userIds.isEmpty()) return UserSupportRows(emptyMap(), emptyList(), emptyList(), emptyList()) + val ids = userIds.map(UUID::toString) + return UserSupportRows( + balances = AdminUsersCreditAccountsTable.selectAll() + .where { AdminUsersCreditAccountsTable.userId inList ids } + .map { UUID.fromString(it[AdminUsersCreditAccountsTable.userId]) to it[AdminUsersCreditAccountsTable.balance] } + .toMap(), + ledger = AdminUsersCreditLedgerTable.selectAll() + .where { AdminUsersCreditLedgerTable.userId inList ids } + .map(ResultRow::toUserLedgerRow), + usage = AdminUsersCreditUsageTable.selectAll() + .where { AdminUsersCreditUsageTable.userId inList ids } + .map(ResultRow::toUserUsageRow), + bindings = AdminUsersReferralBindingsTable.selectAll() + .where { + (AdminUsersReferralBindingsTable.inviterUserId inList ids) or + (AdminUsersReferralBindingsTable.inviteeUserId inList ids) + } + .map(ResultRow::toUserReferralBindingRow), + ) +} + +private fun findReferralCode(userId: UUID): String? = + AdminUsersReferralCodesTable.selectAll() + .where { AdminUsersReferralCodesTable.ownerUserId eq userId.toString() } + .orderBy( + AdminUsersReferralCodesTable.createdAt to SortOrder.DESC, + AdminUsersReferralCodesTable.id to SortOrder.DESC, + ) + .limit(1) + .singleOrNull() + ?.get(AdminUsersReferralCodesTable.code) + +private fun ResultRow.toSummary(support: UserSupportRows): AdminUserSummaryDto { + val userId = userId() + val usage = support.usage.filter { it.userId == userId } + val ledger = support.ledger.filter { it.userId == userId } + val invitations = support.bindings.filter { it.inviterUserId == userId } + return AdminUserSummaryDto( + id = userId.toString(), + createdAt = this[AdminUsersAccountsTable.createdAt].toString(), + antiAbuseRestricted = this[AdminUsersAccountsTable.antiAbuseRestricted], + creditBalance = support.balances[userId] ?: 0, + consumedCredits = usage.exactSumOf(UserUsageRow::chargedCredits), + manualGrantedCredits = ledger.filter { it.type == LedgerEntryType.MANUAL_GRANT } + .exactSumOf(UserLedgerRow::amountDelta), + usageRequests = usage.size.toLong(), + lastActiveAt = usage.maxOfOrNull(UserUsageRow::createdAt)?.toString(), + invitedUsers = invitations.size.toLong(), + rewardedInvites = invitations.count { + it.status == ReferralRewardStatus.REWARDED + }.toLong(), + ) +} + +private object AdminUsersAccountsTable : Table("accounts") { + val id = varchar("id", 36) + val antiAbuseRestricted = bool("anti_abuse_restricted") + val createdAt = timestamp("created_at") + override val primaryKey = PrimaryKey(id) +} + +private object AdminUsersCreditAccountsTable : Table("credit_accounts") { + val userId = varchar("user_id", 36) + val balance = long("balance") + override val primaryKey = PrimaryKey(userId) +} + +private object AdminUsersCreditLedgerTable : Table("credit_ledger") { + val id = varchar("id", 36) + val userId = varchar("user_id", 36) + val entryType = enumerationByName("entry_type", 32) + val amountDelta = long("amount_delta") + val balanceAfter = long("balance_after") + val referenceId = varchar("reference_id", 36).nullable() + val createdAt = timestamp("created_at") +} + +private object AdminUsersCreditUsageTable : Table("credit_usage_records") { + val userId = varchar("user_id", 36) + val usageKind = enumerationByName("usage_kind", 8) + val asrMillis = long("asr_millis").nullable() + val inputTokens = long("input_tokens").nullable() + val outputTokens = long("output_tokens").nullable() + val chargedCredits = long("charged_credits") + val createdAt = timestamp("created_at") +} + +private object AdminUsersReferralBindingsTable : Table("referral_bindings") { + val inviterUserId = varchar("inviter_user_id", 36) + val inviteeUserId = varchar("invitee_user_id", 36) + val rewardStatus = enumerationByName("reward_status", 24) +} + +private object AdminUsersReferralCodesTable : Table("referral_codes") { + val id = varchar("id", 36) + val ownerUserId = varchar("owner_user_id", 36) + val code = varchar("code", 32) + val createdAt = timestamp("created_at") +} + +private fun ResultRow.userId(): UUID = UUID.fromString(this[AdminUsersAccountsTable.id]) + +private fun ResultRow.toUserLedgerRow() = UserLedgerRow( + id = UUID.fromString(this[AdminUsersCreditLedgerTable.id]), + userId = UUID.fromString(this[AdminUsersCreditLedgerTable.userId]), + type = this[AdminUsersCreditLedgerTable.entryType], + amountDelta = this[AdminUsersCreditLedgerTable.amountDelta], + balanceAfter = this[AdminUsersCreditLedgerTable.balanceAfter], + referenceId = this[AdminUsersCreditLedgerTable.referenceId]?.let(UUID::fromString), + createdAt = this[AdminUsersCreditLedgerTable.createdAt], +) + +private fun UserLedgerRow.toDto() = AdminUserLedgerEntryDto( + id = id.toString(), + type = type.name, + amountDelta = amountDelta, + balanceAfter = balanceAfter, + referenceId = referenceId?.toString(), + createdAt = createdAt.toString(), +) + +private fun ResultRow.toUserUsageRow() = UserUsageRow( + userId = UUID.fromString(this[AdminUsersCreditUsageTable.userId]), + kind = this[AdminUsersCreditUsageTable.usageKind], + asrMillis = this[AdminUsersCreditUsageTable.asrMillis], + inputTokens = this[AdminUsersCreditUsageTable.inputTokens], + outputTokens = this[AdminUsersCreditUsageTable.outputTokens], + chargedCredits = this[AdminUsersCreditUsageTable.chargedCredits], + createdAt = this[AdminUsersCreditUsageTable.createdAt], +) + +private fun ResultRow.toUserReferralBindingRow() = UserReferralBindingRow( + inviterUserId = UUID.fromString(this[AdminUsersReferralBindingsTable.inviterUserId]), + inviteeUserId = UUID.fromString(this[AdminUsersReferralBindingsTable.inviteeUserId]), + status = this[AdminUsersReferralBindingsTable.rewardStatus], +) + +private inline fun Iterable.exactSumOf(value: (T) -> Long): Long = + fold(0L) { total, item -> Math.addExact(total, value(item)) } diff --git a/src/main/kotlin/com/osglab/account/features/admin/users/services/AdminUsersService.kt b/src/main/kotlin/com/osglab/account/features/admin/users/services/AdminUsersService.kt new file mode 100644 index 0000000..dbfb850 --- /dev/null +++ b/src/main/kotlin/com/osglab/account/features/admin/users/services/AdminUsersService.kt @@ -0,0 +1,135 @@ +package com.osglab.account.features.admin.users.services + +import com.osglab.account.features.admin.users.models.AdminUserDetailDto +import com.osglab.account.features.admin.users.models.AdminUserLedgerPageDto +import com.osglab.account.features.admin.users.models.AdminUserPageDto +import com.osglab.account.features.admin.users.repositories.AdminUserCursor +import com.osglab.account.features.admin.users.repositories.AdminUserLedgerCursor +import com.osglab.account.features.admin.users.repositories.AdminUsersRepository +import java.nio.charset.StandardCharsets +import java.time.Instant +import java.util.Base64 +import java.util.UUID + +class AdminUserNotFoundException : RuntimeException("Admin user view does not exist") + +class AdminUsersService( + private val repository: AdminUsersRepository, +) { + suspend fun searchByInternalId(query: String): AdminUserPageDto { + val userId = runCatching { UUID.fromString(query.trim()) }.getOrNull() + ?: return AdminUserPageDto(emptyList(), null) + val user = repository.findDetail(userId, ledgerLimit = 1)?.summary + return AdminUserPageDto(user?.let(::listOf) ?: emptyList(), null) + } + + suspend fun list( + limit: Int = 50, + cursor: String? = null, + ): AdminUserPageDto { + require(limit in 1..100) { "User page limit must be between 1 and 100" } + val decodedCursor = cursor?.let(AdminUserCursorCodec::decode) + val results = repository.list(limit + 1, decodedCursor) + val hasMore = results.size > limit + val items = results.take(limit) + val nextCursor = if (hasMore) { + val last = items.last() + AdminUserCursorCodec.encode( + AdminUserCursor( + createdAt = Instant.parse(last.createdAt), + userId = UUID.fromString(last.id), + ), + ) + } else { + null + } + return AdminUserPageDto(items, nextCursor) + } + + suspend fun detail( + userId: UUID, + ledgerLimit: Int = 50, + ): AdminUserDetailDto { + require(ledgerLimit in 1..100) { "Ledger limit must be between 1 and 100" } + return repository.findDetail(userId, ledgerLimit) ?: throw AdminUserNotFoundException() + } + + suspend fun ledger( + userId: UUID, + limit: Int = 50, + cursor: String? = null, + ): AdminUserLedgerPageDto { + require(limit in 1..100) { "Ledger page limit must be between 1 and 100" } + val decodedCursor = cursor?.let(AdminUserLedgerCursorCodec::decode) + if (!repository.exists(userId)) throw AdminUserNotFoundException() + val results = repository.listLedger(userId, limit + 1, decodedCursor) + val hasMore = results.size > limit + val items = results.take(limit) + val nextCursor = if (hasMore) { + val last = items.last() + AdminUserLedgerCursorCodec.encode( + AdminUserLedgerCursor( + createdAt = Instant.parse(last.createdAt), + ledgerEntryId = UUID.fromString(last.id), + ), + ) + } else { + null + } + return AdminUserLedgerPageDto(items, nextCursor) + } +} + +internal object AdminUserCursorCodec { + fun encode(cursor: AdminUserCursor): String { + val value = "${cursor.createdAt}|${cursor.userId}" + return Base64.getUrlEncoder().withoutPadding() + .encodeToString(value.toByteArray(StandardCharsets.UTF_8)) + } + + fun decode(value: String): AdminUserCursor { + require(value.length in 1..256) { "User cursor is invalid" } + return try { + val decoded = String( + Base64.getUrlDecoder().decode(value), + StandardCharsets.UTF_8, + ) + val parts = decoded.split('|') + require(parts.size == 2) + AdminUserCursor( + createdAt = Instant.parse(parts[0]), + userId = UUID.fromString(parts[1]), + ) + } catch (failure: IllegalArgumentException) { + throw IllegalArgumentException("User cursor is invalid", failure) + } + } +} + +internal object AdminUserLedgerCursorCodec { + private const val INVALID_CURSOR_MESSAGE = "User ledger cursor is invalid" + + fun encode(cursor: AdminUserLedgerCursor): String { + val value = "${cursor.createdAt}|${cursor.ledgerEntryId}" + return Base64.getUrlEncoder().withoutPadding() + .encodeToString(value.toByteArray(StandardCharsets.UTF_8)) + } + + fun decode(value: String): AdminUserLedgerCursor { + require(value.length in 1..256) { INVALID_CURSOR_MESSAGE } + return try { + val decoded = String( + Base64.getUrlDecoder().decode(value), + StandardCharsets.UTF_8, + ) + val parts = decoded.split('|') + require(parts.size == 2) + AdminUserLedgerCursor( + createdAt = Instant.parse(parts[0]), + ledgerEntryId = UUID.fromString(parts[1]), + ) + } catch (failure: IllegalArgumentException) { + throw IllegalArgumentException(INVALID_CURSOR_MESSAGE, failure) + } + } +} diff --git a/src/main/kotlin/com/osglab/account/features/credits/domain/CreditDomain.kt b/src/main/kotlin/com/osglab/account/features/credits/domain/CreditDomain.kt index 95005e4..5578d3e 100644 --- a/src/main/kotlin/com/osglab/account/features/credits/domain/CreditDomain.kt +++ b/src/main/kotlin/com/osglab/account/features/credits/domain/CreditDomain.kt @@ -62,6 +62,24 @@ data class LedgerEntry( val createdAt: Instant, ) +data class ManualCreditGrant( + val id: UUID, + val operatorId: UUID, + val userId: UUID, + val amount: Long, + val reason: String, + val idempotencyKey: String, + val ledgerEntryId: UUID, + val auditLogId: UUID, + val createdAt: Instant, +) + +data class ManualCreditGrantResult( + val grant: ManualCreditGrant, + val balanceAfter: Long, + val replayed: Boolean, +) + /** * Billing metadata only. Provider input, audio, prompts and responses must * never be persisted in a usage record. diff --git a/src/main/kotlin/com/osglab/account/features/credits/repositories/BillingRepositories.kt b/src/main/kotlin/com/osglab/account/features/credits/repositories/BillingRepositories.kt index 3faa50d..a123a72 100644 --- a/src/main/kotlin/com/osglab/account/features/credits/repositories/BillingRepositories.kt +++ b/src/main/kotlin/com/osglab/account/features/credits/repositories/BillingRepositories.kt @@ -1,5 +1,6 @@ package com.osglab.account.features.credits.repositories +import com.osglab.account.features.admin.grants.repositories.AdminCreditGrantRepository import com.osglab.account.features.credits.domain.CreditAccount import com.osglab.account.features.credits.domain.CreditRateVersion import com.osglab.account.features.credits.domain.CreditReservation @@ -11,6 +12,8 @@ import java.time.Instant import java.util.UUID interface CreditsRepository { + fun accountExists(userId: UUID): Boolean + fun createAccountIfAbsent(userId: UUID, now: Instant) fun lockAccount(userId: UUID): CreditAccount @@ -48,6 +51,7 @@ interface CreditsRepository { interface BillingUnitOfWork { val credits: CreditsRepository val referrals: ReferralsRepository + val adminCreditGrants: AdminCreditGrantRepository } interface BillingTransactionRunner { diff --git a/src/main/kotlin/com/osglab/account/features/credits/repositories/ExposedBillingTransactionRunner.kt b/src/main/kotlin/com/osglab/account/features/credits/repositories/ExposedBillingTransactionRunner.kt index 6305a4e..74a5ba9 100644 --- a/src/main/kotlin/com/osglab/account/features/credits/repositories/ExposedBillingTransactionRunner.kt +++ b/src/main/kotlin/com/osglab/account/features/credits/repositories/ExposedBillingTransactionRunner.kt @@ -1,5 +1,7 @@ package com.osglab.account.features.credits.repositories +import com.osglab.account.features.admin.grants.repositories.AdminCreditGrantRepository +import com.osglab.account.features.admin.grants.repositories.ExposedAdminCreditGrantRepository import com.osglab.account.features.credits.domain.CreditAccount import com.osglab.account.features.credits.domain.CreditNotFound import com.osglab.account.features.credits.domain.CreditRateVersion @@ -31,6 +33,12 @@ import org.jetbrains.exposed.v1.jdbc.update import java.time.Instant import java.util.UUID +private object Accounts : Table("accounts") { + val id = varchar("id", 36) + + override val primaryKey = PrimaryKey(id) +} + private object CreditAccounts : Table("credit_accounts") { val userId = varchar("user_id", 36) val balance = long("balance") @@ -175,9 +183,17 @@ class ExposedBillingTransactionRunner( private object ExposedBillingUnitOfWork : BillingUnitOfWork { override val credits: CreditsRepository = ExposedCreditsRepository override val referrals: ReferralsRepository = ExposedReferralsRepository + override val adminCreditGrants: AdminCreditGrantRepository = ExposedAdminCreditGrantRepository } private object ExposedCreditsRepository : CreditsRepository { + override fun accountExists(userId: UUID): Boolean = + Accounts + .selectAll() + .where { Accounts.id eq userId.toString() } + .limit(1) + .singleOrNull() != null + override fun createAccountIfAbsent(userId: UUID, now: Instant) { CreditAccounts.insertIgnore { it[CreditAccounts.userId] = userId.toString() diff --git a/src/main/kotlin/com/osglab/account/features/credits/services/CreditService.kt b/src/main/kotlin/com/osglab/account/features/credits/services/CreditService.kt index b005c29..0a76af6 100644 --- a/src/main/kotlin/com/osglab/account/features/credits/services/CreditService.kt +++ b/src/main/kotlin/com/osglab/account/features/credits/services/CreditService.kt @@ -1,5 +1,8 @@ package com.osglab.account.features.credits.services +import com.osglab.account.features.admin.models.AdminAuditAction +import com.osglab.account.features.admin.models.AdminAuditOutcome +import com.osglab.account.features.admin.models.NewAdminAuditEvent import com.osglab.account.features.credits.domain.CreditAccount import com.osglab.account.features.credits.domain.CreditConflict import com.osglab.account.features.credits.domain.CreditCostCalculator @@ -11,6 +14,8 @@ import com.osglab.account.features.credits.domain.InsufficientCredits import com.osglab.account.features.credits.domain.InvalidCreditRequest import com.osglab.account.features.credits.domain.LedgerEntry import com.osglab.account.features.credits.domain.LedgerEntryType +import com.osglab.account.features.credits.domain.ManualCreditGrant +import com.osglab.account.features.credits.domain.ManualCreditGrantResult import com.osglab.account.features.credits.domain.ReservationStatus import com.osglab.account.features.credits.domain.ReservationStateRules import com.osglab.account.features.credits.domain.UsageMeasurement @@ -21,6 +26,7 @@ import com.osglab.account.features.referrals.domain.ReferralBinding import com.osglab.account.features.referrals.domain.ReferralRewardStatus import java.time.Clock import java.time.Instant +import java.security.MessageDigest import java.util.UUID data class ReferralRewardConfig( @@ -52,6 +58,15 @@ interface CreditOperations { idempotencyKey: String, ): CreditAccount + suspend fun grantManual( + operatorId: UUID, + userId: UUID, + credits: Long, + reason: String, + requestId: String?, + idempotencyKey: String, + ): ManualCreditGrantResult + suspend fun reserve( userId: UUID, provider: String, @@ -146,6 +161,96 @@ class CreditService( } } + override suspend fun grantManual( + operatorId: UUID, + userId: UUID, + credits: Long, + reason: String, + requestId: String?, + idempotencyKey: String, + ): ManualCreditGrantResult { + if (credits <= 0) throw InvalidCreditRequest("Manual grant credits must be positive") + val normalizedReason = validatedText(reason, "Manual grant reason", 1, 500) + val normalizedRequestId = validatedAdminRequestId(requestId) + val key = manualGrantIdempotencyKey(idempotencyKey) + return transactions.inTransaction { unit -> + if (!unit.credits.accountExists(userId)) { + throw CreditNotFound("Manual grant target does not exist") + } + val now = clock.instant() + unit.credits.createAccountIfAbsent(userId, now) + val account = unit.credits.lockAccount(userId) + unit.adminCreditGrants.findByIdempotencyKey(key)?.let { existing -> + requireIdempotentManualGrant( + existing, + operatorId, + userId, + credits, + normalizedReason, + ) + val ledger = unit.credits.findLedgerEntry(userId, key) + ?: throw CreditConflict("Manual grant audit exists without its ledger entry") + requireIdempotentLedger( + ledger, + LedgerEntryType.MANUAL_GRANT, + credits, + existing.id, + ) + if (ledger.id != existing.ledgerEntryId) { + throw CreditConflict("Manual grant audit does not match its ledger entry") + } + return@inTransaction ManualCreditGrantResult( + grant = existing, + balanceAfter = ledger.balanceAfter, + replayed = true, + ) + } + ensureUnusedLedgerKey(unit, userId, key) + val grantId = newId() + val ledgerEntryId = newId() + val auditLogId = newId() + val updated = applyLedgerDelta( + unit = unit, + account = account, + delta = credits, + type = LedgerEntryType.MANUAL_GRANT, + key = key, + referenceId = grantId, + now = now, + entryId = ledgerEntryId, + ) + val grant = ManualCreditGrant( + id = grantId, + operatorId = operatorId, + userId = userId, + amount = credits, + reason = normalizedReason, + idempotencyKey = key, + ledgerEntryId = ledgerEntryId, + auditLogId = auditLogId, + createdAt = now, + ) + unit.adminCreditGrants.insertAudit( + NewAdminAuditEvent( + id = auditLogId, + actorOperatorId = operatorId, + action = AdminAuditAction.MANUAL_CREDIT_GRANTED, + outcome = AdminAuditOutcome.SUCCESS, + targetType = "ACCOUNT", + targetId = userId.toString(), + requestId = normalizedRequestId, + occurredAt = now, + ), + ) + unit.adminCreditGrants.insert(grant) + ManualCreditGrantResult( + grant = grant, + balanceAfter = updated.balance, + replayed = false, + ) + } + } + override suspend fun reserve( userId: UUID, provider: String, @@ -503,6 +608,7 @@ class CreditService( key: String, referenceId: UUID?, now: Instant, + entryId: UUID = newId(), ): CreditAccount { val newBalance = try { Math.addExact(account.balance, delta) @@ -512,7 +618,7 @@ class CreditService( if (newBalance < 0) throw InsufficientCredits(account.balance, -delta) unit.credits.insertLedgerEntry( LedgerEntry( - id = newId(), + id = entryId, userId = account.userId, type = type, amountDelta = delta, @@ -539,6 +645,22 @@ class CreditService( } } + private fun requireIdempotentManualGrant( + existing: ManualCreditGrant, + expectedOperatorId: UUID, + expectedUserId: UUID, + expectedAmount: Long, + expectedReason: String, + ) { + if (existing.operatorId != expectedOperatorId || + existing.userId != expectedUserId || + existing.amount != expectedAmount || + existing.reason != expectedReason + ) { + throw CreditConflict("Idempotency key was already used with different manual grant parameters") + } + } + private fun calculatePositiveCost( rate: CreditRateVersion, usage: UsageMeasurement, @@ -557,6 +679,38 @@ class CreditService( return normalized } + private fun validatedText( + value: String, + label: String, + minimumLength: Int, + maximumLength: Int, + ): String { + val normalized = value.trim() + if (normalized.length !in minimumLength..maximumLength) { + throw InvalidCreditRequest( + "$label must contain $minimumLength to $maximumLength characters", + ) + } + return normalized + } + + private fun manualGrantIdempotencyKey(value: String): String { + val normalized = validatedIdempotencyKey(value) + val digest = MessageDigest.getInstance("SHA-256") + .digest(normalized.toByteArray(Charsets.UTF_8)) + .joinToString("") { byte -> "%02x".format(byte.toInt() and 0xff) } + return "internal:manual:$digest" + } + + private fun validatedAdminRequestId(value: String?): String? { + if (value == null) return null + val normalized = value.trim() + if (normalized.length !in 1..128 || !ADMIN_REQUEST_ID.matches(normalized)) { + throw InvalidCreditRequest("Admin request ID is invalid") + } + return normalized + } + private fun addOrNull(left: Long, right: Long): Long? = try { Math.addExact(left, right) @@ -569,4 +723,8 @@ class CreditService( val inviterCredits: Long, val inviteeCredits: Long, ) + + private companion object { + val ADMIN_REQUEST_ID = Regex("^[A-Za-z0-9._:-]+$") + } } diff --git a/src/main/kotlin/com/osglab/account/tools/AdminCredentialGenerator.kt b/src/main/kotlin/com/osglab/account/tools/AdminCredentialGenerator.kt new file mode 100644 index 0000000..ee07ea9 --- /dev/null +++ b/src/main/kotlin/com/osglab/account/tools/AdminCredentialGenerator.kt @@ -0,0 +1,104 @@ +package com.osglab.account.tools + +import com.osglab.account.features.admin.security.BouncyCastleArgon2idPasswordHasher +import java.nio.file.Files +import java.nio.file.Path +import java.nio.file.StandardOpenOption.CREATE_NEW +import java.nio.file.attribute.PosixFilePermission +import java.security.SecureRandom +import java.util.Base64 +import java.util.UUID + +/** + * Generates one bootstrap operator without printing credentials to stdout. + * Both output files are created with owner-only permissions and must not exist. + */ +object AdminCredentialGenerator { + @JvmStatic + fun main(arguments: Array) { + require(arguments.size == 3) { + "Usage: " + } + val username = arguments[0].trim().lowercase() + require(USERNAME.matches(username)) { "Username must match ${USERNAME.pattern}" } + val runtimeOutput = Path.of(arguments[1]).toAbsolutePath() + val handoffOutput = Path.of(arguments[2]).toAbsolutePath() + require(runtimeOutput != handoffOutput) { "Output paths must be different" } + require(Files.notExists(runtimeOutput) && Files.notExists(handoffOutput)) { + "Output files must not already exist" + } + + val random = SecureRandom() + val password = Base64.getUrlEncoder().withoutPadding() + .encodeToString(ByteArray(24).also(random::nextBytes)) + val passwordChars = password.toCharArray() + val passwordHash = try { + BouncyCastleArgon2idPasswordHasher(secureRandom = random).hash(passwordChars) + } finally { + passwordChars.fill('\u0000') + } + val totpSecret = base32(ByteArray(20).also(random::nextBytes)) + val operatorId = UUID.randomUUID() + + writePrivate( + runtimeOutput, + """ + # One-time admin bootstrap values. Never commit, upload, or screenshot. + # After the first successful startup, set ADMIN_BOOTSTRAP_ENABLED=false + # and permanently remove all ADMIN_BOOTSTRAP_* credential values. + ADMIN_ENABLED=true + ADMIN_BOOTSTRAP_ENABLED=true + ADMIN_BOOTSTRAP_OPERATOR_ID=$operatorId + ADMIN_BOOTSTRAP_USERNAME=$username + ADMIN_BOOTSTRAP_PASSWORD_HASH='$passwordHash' + ADMIN_BOOTSTRAP_TOTP_SECRET_BASE32=$totpSecret + ADMIN_SESSION_HOURS=8 + ADMIN_MAXIMUM_MANUAL_GRANT=100000 + """.trimIndent() + "\n", + ) + writePrivate( + handoffOutput, + """ + OSG 运营后台初始管理员 + 用户名:$username + 密码:$password + TOTP 密钥:$totpSecret + 认证器 URI:otpauth://totp/OSG%20Admin:$username?secret=$totpSecret&issuer=OSG%20Admin&algorithm=SHA1&digits=6&period=30 + + 仅保存在受信设备。首次部署成功后,请将密码录入密码管理器,并删除本文件。 + """.trimIndent() + "\n", + ) + } + + private fun writePrivate(path: Path, content: String) { + Files.writeString(path, content, CREATE_NEW) + runCatching { + Files.setPosixFilePermissions( + path, + setOf(PosixFilePermission.OWNER_READ, PosixFilePermission.OWNER_WRITE), + ) + } + } + + private fun base32(bytes: ByteArray): String { + val output = StringBuilder((bytes.size * 8 + 4) / 5) + var buffer = 0 + var bufferedBits = 0 + bytes.forEach { byte -> + buffer = (buffer shl 8) or (byte.toInt() and 0xff) + bufferedBits += 8 + while (bufferedBits >= 5) { + bufferedBits -= 5 + output.append(BASE32_ALPHABET[(buffer shr bufferedBits) and 0x1f]) + buffer = if (bufferedBits == 0) 0 else buffer and ((1 shl bufferedBits) - 1) + } + } + if (bufferedBits > 0) { + output.append(BASE32_ALPHABET[(buffer shl (5 - bufferedBits)) and 0x1f]) + } + return output.toString() + } + + private val USERNAME = Regex("^[a-z0-9][a-z0-9._@-]{2,63}$") + private const val BASE32_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567" +} diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index 2d4ab4e..6727406 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -30,6 +30,15 @@ app: antiAbuse: identityHmacKeyBase64: "$IDENTITY_HMAC_KEY" tombstoneRetentionDays: "$IDENTITY_TOMBSTONE_RETENTION_DAYS:365" + admin: + enabled: "$ADMIN_ENABLED:false" + bootstrapEnabled: "$ADMIN_BOOTSTRAP_ENABLED:false" + bootstrapOperatorId: "$ADMIN_BOOTSTRAP_OPERATOR_ID:" + bootstrapUsername: "$ADMIN_BOOTSTRAP_USERNAME:" + bootstrapPasswordHash: "$ADMIN_BOOTSTRAP_PASSWORD_HASH:" + bootstrapTotpSecretBase32: "$ADMIN_BOOTSTRAP_TOTP_SECRET_BASE32:" + sessionHours: "$ADMIN_SESSION_HOURS:8" + maximumManualGrant: "$ADMIN_MAXIMUM_MANUAL_GRANT:100000" apple: teamId: "$APPLE_TEAM_ID:" keyId: "$APPLE_KEY_ID:" diff --git a/src/main/resources/db/migration/V8__admin_authentication_core.sql b/src/main/resources/db/migration/V8__admin_authentication_core.sql new file mode 100644 index 0000000..33eba41 --- /dev/null +++ b/src/main/resources/db/migration/V8__admin_authentication_core.sql @@ -0,0 +1,107 @@ +CREATE TABLE admin_operators ( + id CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + username VARCHAR(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL, + password_hash VARCHAR(255) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + encrypted_totp_secret TEXT NOT NULL, + role VARCHAR(32) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + failed_login_count INT NOT NULL DEFAULT 0, + locked_until DATETIME(6) NULL, + last_totp_counter BIGINT NULL, + last_login_at DATETIME(6) NULL, + disabled_at DATETIME(6) NULL, + created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), + updated_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), + PRIMARY KEY (id), + UNIQUE KEY uk_admin_operators_username (username), + INDEX idx_admin_operators_status_created (disabled_at, created_at), + CONSTRAINT chk_admin_operators_role + CHECK (role IN ('SUPER_ADMIN', 'SUPPORT', 'ANALYST')), + CONSTRAINT chk_admin_operators_failed_logins + CHECK (failed_login_count >= 0), + CONSTRAINT chk_admin_operators_totp_counter + CHECK (last_totp_counter IS NULL OR last_totp_counter >= 0) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +CREATE TABLE admin_sessions ( + id CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + operator_id CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + token_hash CHAR(64) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + csrf_token_hash CHAR(64) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), + expires_at DATETIME(6) NOT NULL, + revoked_at DATETIME(6) NULL, + PRIMARY KEY (id), + UNIQUE KEY uk_admin_sessions_token_hash (token_hash), + INDEX idx_admin_sessions_operator_active (operator_id, revoked_at, expires_at), + INDEX idx_admin_sessions_expiry (expires_at), + CONSTRAINT fk_admin_sessions_operator + FOREIGN KEY (operator_id) REFERENCES admin_operators (id) ON DELETE RESTRICT, + CONSTRAINT chk_admin_sessions_expiry + CHECK (expires_at > created_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +CREATE TABLE admin_audit_log ( + id CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + actor_operator_id CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NULL, + action VARCHAR(64) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + outcome VARCHAR(32) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + target_type VARCHAR(64) CHARACTER SET ascii COLLATE ascii_bin NULL, + target_id VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NULL, + request_id VARCHAR(128) CHARACTER SET ascii COLLATE ascii_bin NULL, + occurred_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), + PRIMARY KEY (id), + INDEX idx_admin_audit_occurred (occurred_at, id), + INDEX idx_admin_audit_action_occurred (action, occurred_at), + INDEX idx_admin_audit_actor_occurred (actor_operator_id, occurred_at), + INDEX idx_admin_audit_target (target_type, target_id, occurred_at), + CONSTRAINT fk_admin_audit_actor + FOREIGN KEY (actor_operator_id) REFERENCES admin_operators (id) ON DELETE RESTRICT, + CONSTRAINT chk_admin_audit_target_pair CHECK ( + (target_type IS NULL AND target_id IS NULL) + OR (target_type IS NOT NULL AND target_id IS NOT NULL) + ) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +CREATE TABLE admin_credit_grants ( + id CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + operator_id CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + account_id VARCHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + amount BIGINT NOT NULL, + reason VARCHAR(500) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL, + idempotency_key VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL, + ledger_entry_id CHAR(36) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL, + audit_log_id CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), + PRIMARY KEY (id), + UNIQUE KEY uk_admin_credit_grants_idempotency (idempotency_key), + UNIQUE KEY uk_admin_credit_grants_ledger (ledger_entry_id), + UNIQUE KEY uk_admin_credit_grants_audit (audit_log_id), + INDEX idx_admin_credit_grants_account_created (account_id, created_at), + INDEX idx_admin_credit_grants_operator_created (operator_id, created_at), + INDEX idx_admin_credit_grants_created (created_at), + CONSTRAINT fk_admin_credit_grants_operator + FOREIGN KEY (operator_id) REFERENCES admin_operators (id) ON DELETE RESTRICT, + CONSTRAINT fk_admin_credit_grants_ledger + FOREIGN KEY (ledger_entry_id) REFERENCES credit_ledger (id) ON DELETE RESTRICT, + CONSTRAINT fk_admin_credit_grants_audit + FOREIGN KEY (audit_log_id) REFERENCES admin_audit_log (id) ON DELETE RESTRICT, + CONSTRAINT chk_admin_credit_grants_amount CHECK (amount > 0), + CONSTRAINT chk_admin_credit_grants_reason + CHECK (CHAR_LENGTH(TRIM(reason)) BETWEEN 1 AND 500), + CONSTRAINT chk_admin_credit_grants_idempotency + CHECK (CHAR_LENGTH(idempotency_key) BETWEEN 8 AND 128) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +-- account_id intentionally has no foreign key: account deletion must preserve +-- the immutable, pseudonymized grant and ledger records. + +-- Dashboard queries aggregate by creation time and state. These indexes avoid +-- full scans without changing existing business data or ledger semantics. +CREATE INDEX idx_accounts_created ON accounts (created_at); +CREATE INDEX idx_credit_ledger_type_created ON credit_ledger (entry_type, created_at); +CREATE INDEX idx_credit_usage_created ON credit_usage_records (created_at); +CREATE INDEX idx_referral_bindings_status_bound + ON referral_bindings (reward_status, bound_at); + +-- admin_audit_log and admin_credit_grants are append-only. The production +-- runtime database role must receive SELECT/INSERT only on these tables. diff --git a/src/test/kotlin/com/osglab/account/config/AppConfigTest.kt b/src/test/kotlin/com/osglab/account/config/AppConfigTest.kt index 198c507..e47facd 100644 --- a/src/test/kotlin/com/osglab/account/config/AppConfigTest.kt +++ b/src/test/kotlin/com/osglab/account/config/AppConfigTest.kt @@ -34,6 +34,48 @@ class AppConfigTest : FunSpec({ config.database.migrationUsername shouldBe "test_migrator" } + test("production accepts enabled admin bootstrap with Argon2 PHC hash") { + val config = validProductionConfig().apply { + put("app.admin.enabled", "true") + put("app.admin.bootstrapEnabled", "true") + put("app.admin.bootstrapOperatorId", "2c031def-4517-4fde-b592-5db3a3eefdf6") + put("app.admin.bootstrapUsername", "owner") + put( + "app.admin.bootstrapPasswordHash", + "\$argon2id\$v=19\$m=65536,t=3,p=1\$c2FsdHNhbHRzYWx0c2FsdA\$aGFzaGhhc2hoYXNoaGFzaGhhc2hoYXNoaGFzaA", + ) + put("app.admin.bootstrapTotpSecretBase32", "JBSWY3DPEHPK3PXPJBSWY3DPEHPK3PXP") + } + + val admin = AppConfig.from(config).admin + admin.enabled shouldBe true + admin.bootstrapEnabled shouldBe true + } + + test("production accepts established admin without bootstrap credentials") { + val config = validProductionConfig().apply { + put("app.admin.enabled", "true") + put("app.admin.bootstrapEnabled", "false") + } + + val admin = AppConfig.from(config).admin + + admin.enabled shouldBe true + admin.bootstrapEnabled shouldBe false + admin.bootstrapTotpSecretBase32 shouldBe null + } + + test("admin bootstrap cannot be enabled while admin routes are disabled") { + val config = validProductionConfig().apply { + put("app.admin.enabled", "false") + put("app.admin.bootstrapEnabled", "true") + } + + shouldThrow { + AppConfig.from(config) + }.message.orEmpty() shouldContain "bootstrapEnabled requires" + } + test("production fails fast when Apple signing credentials are missing") { val config = validProductionConfig().apply { put("app.apple.keyId", "") diff --git a/src/test/kotlin/com/osglab/account/features/admin/InMemoryAdminRepository.kt b/src/test/kotlin/com/osglab/account/features/admin/InMemoryAdminRepository.kt new file mode 100644 index 0000000..a4f685f --- /dev/null +++ b/src/test/kotlin/com/osglab/account/features/admin/InMemoryAdminRepository.kt @@ -0,0 +1,426 @@ +package com.osglab.account.features.admin + +import com.osglab.account.features.admin.models.AdminAuditCursor +import com.osglab.account.features.admin.models.AdminAuditRecord +import com.osglab.account.features.admin.models.AdminLockState +import com.osglab.account.features.admin.models.AdminOperatorAuthRecord +import com.osglab.account.features.admin.models.AdminOperatorCursor +import com.osglab.account.features.admin.models.AdminOperatorMutationResult +import com.osglab.account.features.admin.models.AdminOperatorRecord +import com.osglab.account.features.admin.models.AdminRole +import com.osglab.account.features.admin.models.AdminSessionRecord +import com.osglab.account.features.admin.models.NewAdminAuditEvent +import com.osglab.account.features.admin.models.NewAdminOperator +import com.osglab.account.features.admin.models.NewAdminSession +import com.osglab.account.features.admin.repositories.AdminRepository +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import java.time.Instant +import java.util.UUID + +internal class InMemoryAdminRepository( + val operatorId: UUID = UUID.randomUUID(), + var username: String = "admin@example.com", + var passwordHash: String = "valid-password-hash", + var encryptedTotpSecret: String, + var role: AdminRole = AdminRole.SUPER_ADMIN, +) : AdminRepository { + private val mutex = Mutex() + var lockState = AdminLockState(0, null) + var disabledAt: Instant? = null + var lastTotpCounter: Long? = null + val sessions = mutableMapOf() + val revokedTokenHashes = mutableSetOf() + private val revokedAtByTokenHash = mutableMapOf() + val audits = mutableListOf() + private val additionalOperators = linkedMapOf() + + override suspend fun createOperatorIfAbsent(operator: NewAdminOperator): Boolean = + mutex.withLock { + if (operator.normalizedUsername == username || usernameExists(operator.normalizedUsername)) { + false + } else { + additionalOperators[operator.id] = MutableOperator(operator) + true + } + } + + override suspend fun createOperator( + operator: NewAdminOperator, + auditEvent: NewAdminAuditEvent, + ): AdminOperatorMutationResult = mutex.withLock { + val result = if (operator.normalizedUsername == username || usernameExists(operator.normalizedUsername)) { + AdminOperatorMutationResult.USERNAME_CONFLICT + } else { + additionalOperators[operator.id] = MutableOperator(operator) + AdminOperatorMutationResult.SUCCESS + } + audits += auditEvent.forResult(result) + result + } + + override suspend fun listOperators(): List = mutex.withLock { + listOf(baseOperatorRecord()) + additionalOperators.values.map(MutableOperator::toRecord) + } + + override suspend fun listOperatorsPage( + limit: Int, + before: AdminOperatorCursor?, + ): List = mutex.withLock { + require(limit in 1..101) + (listOf(baseOperatorRecord()) + additionalOperators.values.map(MutableOperator::toRecord)) + .asSequence() + .filter { + before == null || + it.createdAt.isAfter(before.createdAt) || + (it.createdAt == before.createdAt && it.id.toString() > before.id.toString()) + } + .sortedWith( + compareBy { it.createdAt } + .thenBy { it.id.toString() }, + ) + .take(limit) + .toList() + } + + override suspend fun countActiveSessions(now: Instant): Long = mutex.withLock { + sessions.count { (tokenHash, session) -> + tokenHash !in revokedTokenHashes && + session.expiresAt.isAfter(now) && + operatorDisabledAt(session.operatorId) == null + }.toLong() + } + + override suspend fun findOperator(operatorId: UUID): AdminOperatorRecord? = mutex.withLock { + if (operatorId == this.operatorId) { + baseOperatorRecord() + } else { + additionalOperators[operatorId]?.toRecord() + } + } + + override suspend fun setOperatorEnabled( + operatorId: UUID, + enabled: Boolean, + now: Instant, + auditEvent: NewAdminAuditEvent, + ): AdminOperatorMutationResult = mutex.withLock { + val targetRole = when (operatorId) { + this.operatorId -> role + else -> additionalOperators[operatorId]?.operator?.role + } + val result = when { + targetRole == null -> AdminOperatorMutationResult.NOT_FOUND + !enabled && + operatorDisabledAt(operatorId) == null && + targetRole == AdminRole.SUPER_ADMIN && + enabledSuperAdministrators() <= 1 -> + AdminOperatorMutationResult.LAST_SUPER_ADMIN + else -> { + if (operatorId == this.operatorId) { + disabledAt = if (enabled) null else now + } else { + additionalOperators.getValue(operatorId).apply { + disabledAt = if (enabled) null else now + updatedAt = now + } + } + if (!enabled) revokeSessionsFor(operatorId, now) + AdminOperatorMutationResult.SUCCESS + } + } + audits += auditEvent.forResult(result) + result + } + + override suspend fun unlockOperator( + operatorId: UUID, + now: Instant, + auditEvent: NewAdminAuditEvent, + ): AdminOperatorMutationResult = mutex.withLock { + val result = when (operatorId) { + this.operatorId -> { + lockState = AdminLockState(0, null) + AdminOperatorMutationResult.SUCCESS + } + in additionalOperators -> { + additionalOperators.getValue(operatorId).apply { + lockState = AdminLockState(0, null) + updatedAt = now + } + AdminOperatorMutationResult.SUCCESS + } + else -> AdminOperatorMutationResult.NOT_FOUND + } + audits += auditEvent.forResult(result) + result + } + + override suspend fun resetOperatorCredentials( + operatorId: UUID, + passwordHash: String, + encryptedTotpSecret: String, + now: Instant, + auditEvent: NewAdminAuditEvent, + ): AdminOperatorMutationResult = mutex.withLock { + val result = when (operatorId) { + this.operatorId -> { + this.passwordHash = passwordHash + this.encryptedTotpSecret = encryptedTotpSecret + lockState = AdminLockState(0, null) + lastTotpCounter = null + revokeSessionsFor(operatorId, now) + AdminOperatorMutationResult.SUCCESS + } + in additionalOperators -> { + additionalOperators.getValue(operatorId).apply { + this.passwordHash = passwordHash + this.encryptedTotpSecret = encryptedTotpSecret + lockState = AdminLockState(0, null) + lastTotpCounter = null + updatedAt = now + } + revokeSessionsFor(operatorId, now) + AdminOperatorMutationResult.SUCCESS + } + else -> AdminOperatorMutationResult.NOT_FOUND + } + audits += auditEvent.forResult(result) + result + } + + override suspend fun revokeOperatorSessions( + operatorId: UUID, + now: Instant, + auditEvent: NewAdminAuditEvent, + ): AdminOperatorMutationResult = mutex.withLock { + val result = if (operatorId == this.operatorId || operatorId in additionalOperators) { + revokeSessionsFor(operatorId, now) + AdminOperatorMutationResult.SUCCESS + } else { + AdminOperatorMutationResult.NOT_FOUND + } + audits += auditEvent.forResult(result) + result + } + + override suspend fun findOperatorForAuthentication( + normalizedUsername: String, + ): AdminOperatorAuthRecord? = mutex.withLock { + if (normalizedUsername == username) { + authRecord() + } else { + additionalOperators.values + .firstOrNull { it.operator.normalizedUsername == normalizedUsername } + ?.toAuthRecord() + } + } + + override suspend fun updateLockState( + operatorId: UUID, + now: Instant, + auditEvent: NewAdminAuditEvent, + transform: (AdminLockState) -> AdminLockState, + ): AdminLockState? = mutex.withLock { + if (operatorId != this.operatorId || disabledAt != null) return@withLock null + transform(lockState).also { + lockState = it + audits += auditEvent + } + } + + override suspend fun createSessionIfTotpCounterFresh( + session: NewAdminSession, + totpCounter: Long, + now: Instant, + auditEvent: NewAdminAuditEvent, + ): AdminSessionRecord? = mutex.withLock { + if (session.operatorId != operatorId || disabledAt != null || lockState.isLockedAt(now)) { + return@withLock null + } + if (lastTotpCounter?.let { it >= totpCounter } == true) return@withLock null + lastTotpCounter = totpCounter + lockState = AdminLockState(0, null) + AdminSessionRecord( + id = session.id, + operatorId = operatorId, + normalizedUsername = username, + role = role, + csrfTokenHash = session.csrfTokenHash, + expiresAt = session.expiresAt, + ).also { + sessions[session.tokenHash] = it + audits += auditEvent + } + } + + override suspend fun findActiveSessionByTokenHash( + tokenHash: String, + now: Instant, + ): AdminSessionRecord? = mutex.withLock { + if (disabledAt != null || tokenHash in revokedTokenHashes) return@withLock null + sessions[tokenHash]?.takeIf { it.expiresAt.isAfter(now) } + } + + override suspend fun revokeSessionByTokenHash( + tokenHash: String, + now: Instant, + auditEvent: NewAdminAuditEvent, + ): AdminSessionRecord? = mutex.withLock { + if (tokenHash in revokedTokenHashes) return@withLock null + sessions[tokenHash]?.also { + revokedTokenHashes += tokenHash + revokedAtByTokenHash[tokenHash] = now + audits += auditEvent + } + } + + override suspend fun purgeInactiveSessions(cutoff: Instant, limit: Int): Int = + mutex.withLock { + require(limit in 1..1_000) + val candidates = sessions.entries.asSequence() + .filter { (tokenHash, session) -> + !session.expiresAt.isAfter(cutoff) || + revokedAtByTokenHash[tokenHash]?.isAfter(cutoff) == false + } + .sortedWith( + compareBy> { it.value.expiresAt } + .thenBy { it.value.id.toString() }, + ) + .take(limit) + .map(Map.Entry::key) + .toList() + candidates.forEach { + sessions.remove(it) + revokedTokenHashes.remove(it) + revokedAtByTokenHash.remove(it) + } + candidates.size + } + + override suspend fun appendAudit(event: NewAdminAuditEvent) { + mutex.withLock { + audits += event + } + } + + override suspend fun listAudit( + limit: Int, + before: AdminAuditCursor?, + ): List = + mutex.withLock { + audits.asSequence() + .filter { + before == null || + it.occurredAt.isBefore(before.occurredAt) || + ( + it.occurredAt == before.occurredAt && + it.id.toString() < before.id.toString() + ) + } + .sortedWith( + compareByDescending { it.occurredAt } + .thenByDescending { it.id.toString() }, + ) + .take(limit) + .map { + AdminAuditRecord( + id = it.id, + actorOperatorId = it.actorOperatorId, + action = it.action, + outcome = it.outcome, + targetType = it.targetType, + targetId = it.targetId, + requestId = it.requestId, + occurredAt = it.occurredAt, + ) + } + .toList() + } + + suspend fun seedSession(tokenHash: String, session: AdminSessionRecord) { + mutex.withLock { + sessions[tokenHash] = session + } + } + + private fun authRecord() = AdminOperatorAuthRecord( + id = operatorId, + normalizedUsername = username, + passwordHash = passwordHash, + encryptedTotpSecret = encryptedTotpSecret, + role = role, + lockState = lockState, + disabledAt = disabledAt, + ) + + private fun baseOperatorRecord() = AdminOperatorRecord( + id = operatorId, + normalizedUsername = username, + role = role, + lockState = lockState, + disabledAt = disabledAt, + lastLoginAt = null, + createdAt = Instant.EPOCH, + updatedAt = Instant.EPOCH, + ) + + private fun usernameExists(candidate: String): Boolean = + additionalOperators.values.any { it.operator.normalizedUsername == candidate } + + private fun enabledSuperAdministrators(): Int = + (if (role == AdminRole.SUPER_ADMIN && disabledAt == null) 1 else 0) + + additionalOperators.values.count { + it.operator.role == AdminRole.SUPER_ADMIN && it.disabledAt == null + } + + private fun operatorDisabledAt(operatorId: UUID): Instant? = + if (operatorId == this.operatorId) disabledAt else additionalOperators[operatorId]?.disabledAt + + private fun revokeSessionsFor(operatorId: UUID, now: Instant) { + sessions.filterValues { it.operatorId == operatorId }.keys.forEach { + revokedTokenHashes += it + revokedAtByTokenHash[it] = now + } + } + + private class MutableOperator( + val operator: NewAdminOperator, + var passwordHash: String = operator.passwordHash, + var encryptedTotpSecret: String = operator.encryptedTotpSecret, + var lockState: AdminLockState = AdminLockState(0, null), + var lastTotpCounter: Long? = null, + var disabledAt: Instant? = null, + var updatedAt: Instant = operator.createdAt, + ) { + fun toAuthRecord() = AdminOperatorAuthRecord( + id = operator.id, + normalizedUsername = operator.normalizedUsername, + passwordHash = passwordHash, + encryptedTotpSecret = encryptedTotpSecret, + role = operator.role, + lockState = lockState, + disabledAt = disabledAt, + ) + + fun toRecord() = AdminOperatorRecord( + id = operator.id, + normalizedUsername = operator.normalizedUsername, + role = operator.role, + lockState = lockState, + disabledAt = disabledAt, + lastLoginAt = null, + createdAt = operator.createdAt, + updatedAt = updatedAt, + ) + } +} + +private fun NewAdminAuditEvent.forResult(result: AdminOperatorMutationResult): NewAdminAuditEvent = + copy( + outcome = if (result == AdminOperatorMutationResult.SUCCESS) { + com.osglab.account.features.admin.models.AdminAuditOutcome.SUCCESS + } else { + com.osglab.account.features.admin.models.AdminAuditOutcome.DENIED + }, + ) diff --git a/src/test/kotlin/com/osglab/account/features/admin/repositories/AdminOperatorRepositoryIntegrationTest.kt b/src/test/kotlin/com/osglab/account/features/admin/repositories/AdminOperatorRepositoryIntegrationTest.kt new file mode 100644 index 0000000..797723e --- /dev/null +++ b/src/test/kotlin/com/osglab/account/features/admin/repositories/AdminOperatorRepositoryIntegrationTest.kt @@ -0,0 +1,246 @@ +package com.osglab.account.features.admin.repositories + +import com.osglab.account.config.DatabaseConfig +import com.osglab.account.config.DatabaseFactory +import com.osglab.account.features.admin.models.AdminAuditAction +import com.osglab.account.features.admin.models.AdminAuditOutcome +import com.osglab.account.features.admin.models.AdminOperatorMutationResult +import com.osglab.account.features.admin.models.AdminRole +import com.osglab.account.features.admin.models.NewAdminAuditEvent +import com.osglab.account.features.admin.models.NewAdminOperator +import com.osglab.account.features.admin.models.NewAdminSession +import io.kotest.core.spec.style.FunSpec +import io.kotest.matchers.collections.shouldContainExactlyInAnyOrder +import io.kotest.matchers.shouldBe +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.coroutineScope +import org.opentest4j.TestAbortedException +import org.testcontainers.DockerClientFactory +import org.testcontainers.containers.MySQLContainer +import java.time.Duration +import java.time.Instant +import java.util.UUID + +class AdminOperatorRepositoryIntegrationTest : FunSpec({ + test("row locks preserve one enabled super administrator under concurrent disables") { + withAdminRepositories { first, second -> + val now = Instant.parse("2026-08-17T00:00:00Z") + val firstId = UUID.randomUUID() + val secondId = UUID.randomUUID() + first.createOperatorIfAbsent( + newOperator(firstId, "owner-${firstId.toString().take(8)}", AdminRole.SUPER_ADMIN, now), + ) + first.createOperatorIfAbsent( + newOperator(secondId, "owner-${secondId.toString().take(8)}", AdminRole.SUPER_ADMIN, now), + ) + + val results = coroutineScope { + listOf( + async { + first.setOperatorEnabled( + firstId, + enabled = false, + now = now, + auditEvent = audit(secondId, AdminAuditAction.OPERATOR_DISABLED, firstId, now), + ) + }, + async { + second.setOperatorEnabled( + secondId, + enabled = false, + now = now, + auditEvent = audit(firstId, AdminAuditAction.OPERATOR_DISABLED, secondId, now), + ) + }, + ).awaitAll() + } + + results shouldContainExactlyInAnyOrder listOf( + AdminOperatorMutationResult.SUCCESS, + AdminOperatorMutationResult.LAST_SUPER_ADMIN, + ) + first.listOperators().count { + it.role == AdminRole.SUPER_ADMIN && it.disabledAt == null + } shouldBe 1 + first.listAudit(10).count { + it.action == AdminAuditAction.OPERATOR_DISABLED && + it.outcome == AdminAuditOutcome.DENIED + } shouldBe 1 + } + } + + test("credential reset atomically revokes active sessions") { + withAdminRepositories { repository, _ -> + val now = Instant.parse("2026-08-17T00:00:00Z") + val ownerId = UUID.randomUUID() + val targetId = UUID.randomUUID() + val ownerUsername = "owner-${ownerId.toString().take(8)}" + val targetUsername = "target-${targetId.toString().take(8)}" + repository.createOperatorIfAbsent( + newOperator(ownerId, ownerUsername, AdminRole.SUPER_ADMIN, now), + ) + repository.createOperatorIfAbsent( + newOperator(targetId, targetUsername, AdminRole.SUPPORT, now), + ) + val session = NewAdminSession( + id = UUID.randomUUID(), + operatorId = targetId, + tokenHash = "a".repeat(64), + csrfTokenHash = "b".repeat(64), + createdAt = now, + expiresAt = now.plus(Duration.ofHours(1)), + ) + repository.createSessionIfTotpCounterFresh( + session = session, + totpCounter = 1, + now = now, + auditEvent = audit(targetId, AdminAuditAction.LOGIN_SUCCEEDED, targetId, now), + ) + (repository.findActiveSessionByTokenHash(session.tokenHash, now) != null) shouldBe true + + repository.resetOperatorCredentials( + operatorId = targetId, + passwordHash = "new-password-hash", + encryptedTotpSecret = "new-encrypted-secret", + now = now.plusSeconds(1), + auditEvent = audit( + ownerId, + AdminAuditAction.OPERATOR_CREDENTIALS_RESET, + targetId, + now.plusSeconds(1), + ), + ) shouldBe AdminOperatorMutationResult.SUCCESS + + repository.findActiveSessionByTokenHash(session.tokenHash, now.plusSeconds(1)) shouldBe null + repository.findOperatorForAuthentication(targetUsername)?.run { + passwordHash shouldBe "new-password-hash" + encryptedTotpSecret shouldBe "new-encrypted-secret" + lockState.failedLoginCount shouldBe 0 + lockState.lockedUntil shouldBe null + } + } + } + + test("inactive session cleanup is bounded and preserves active sessions") { + withAdminRepositories { repository, _ -> + val now = Instant.parse("2026-08-17T00:00:00Z") + val operatorId = UUID.randomUUID() + repository.createOperatorIfAbsent( + newOperator(operatorId, "cleanup-${operatorId.toString().take(8)}", AdminRole.SUPPORT, now), + ) + val oldTokens = (1L..3L).map { counter -> + val tokenHash = counter.toString().repeat(64).take(64) + repository.createSessionIfTotpCounterFresh( + session = NewAdminSession( + id = UUID.randomUUID(), + operatorId = operatorId, + tokenHash = tokenHash, + csrfTokenHash = counter.plus(3).toString().repeat(64).take(64), + createdAt = now.minus(Duration.ofDays(10)), + expiresAt = now.minus(Duration.ofDays(9)), + ), + totpCounter = counter, + now = now.minus(Duration.ofDays(10)), + auditEvent = audit( + operatorId, + AdminAuditAction.LOGIN_SUCCEEDED, + operatorId, + now.minus(Duration.ofDays(10)), + ), + ) + tokenHash + } + val activeToken = "f".repeat(64) + repository.createSessionIfTotpCounterFresh( + session = NewAdminSession( + id = UUID.randomUUID(), + operatorId = operatorId, + tokenHash = activeToken, + csrfTokenHash = "e".repeat(64), + createdAt = now, + expiresAt = now.plus(Duration.ofHours(1)), + ), + totpCounter = 4, + now = now, + auditEvent = audit(operatorId, AdminAuditAction.LOGIN_SUCCEEDED, operatorId, now), + ) + + repository.purgeInactiveSessions(now.minus(Duration.ofDays(7)), limit = 2) shouldBe 2 + repository.purgeInactiveSessions(now.minus(Duration.ofDays(7)), limit = 2) shouldBe 1 + repository.purgeInactiveSessions(now.minus(Duration.ofDays(7)), limit = 2) shouldBe 0 + oldTokens.forEach { + repository.findActiveSessionByTokenHash(it, now) shouldBe null + } + (repository.findActiveSessionByTokenHash(activeToken, now) != null) shouldBe true + } + } +}) + +private suspend fun withAdminRepositories( + block: suspend (ExposedAdminRepository, ExposedAdminRepository) -> Unit, +) { + val externalJdbcUrl = System.getenv("TEST_MYSQL_JDBC_URL")?.takeIf(String::isNotBlank) + if (externalJdbcUrl == null && !DockerClientFactory.instance().isDockerAvailable) { + throw TestAbortedException("Docker is unavailable; MySQL integration test skipped") + } + val mysql = if (externalJdbcUrl == null) { + AdminMySqlContainer("mysql:8.4") + .withDatabaseName("osg_admin_repository_test") + .withUsername("test") + .withPassword("test") + .also(AdminMySqlContainer::start) + } else { + null + } + val config = DatabaseConfig( + jdbcUrl = externalJdbcUrl ?: requireNotNull(mysql).jdbcUrl, + username = System.getenv("TEST_MYSQL_USER")?.takeIf(String::isNotBlank) + ?: mysql?.username + ?: "root", + password = System.getenv("TEST_MYSQL_PASSWORD") ?: mysql?.password ?: "", + maximumPoolSize = 4, + ) + val firstFactory = DatabaseFactory(config) + val secondFactory = DatabaseFactory(config) + try { + firstFactory.database + secondFactory.database + block(ExposedAdminRepository(firstFactory), ExposedAdminRepository(secondFactory)) + } finally { + secondFactory.close() + firstFactory.close() + mysql?.stop() + } +} + +private fun newOperator( + id: UUID, + username: String, + role: AdminRole, + now: Instant, +) = NewAdminOperator( + id = id, + normalizedUsername = username, + passwordHash = "password-hash", + encryptedTotpSecret = "encrypted-secret", + role = role, + createdAt = now, +) + +private fun audit( + actorId: UUID, + action: AdminAuditAction, + targetId: UUID, + now: Instant, +) = NewAdminAuditEvent( + actorOperatorId = actorId, + action = action, + outcome = AdminAuditOutcome.SUCCESS, + targetType = "ADMIN_OPERATOR", + targetId = targetId.toString(), + occurredAt = now, +) + +private class AdminMySqlContainer(image: String) : + MySQLContainer(image) diff --git a/src/test/kotlin/com/osglab/account/features/admin/routes/AdminRoutesTest.kt b/src/test/kotlin/com/osglab/account/features/admin/routes/AdminRoutesTest.kt new file mode 100644 index 0000000..7ef19bd --- /dev/null +++ b/src/test/kotlin/com/osglab/account/features/admin/routes/AdminRoutesTest.kt @@ -0,0 +1,331 @@ +package com.osglab.account.features.admin.routes + +import com.osglab.account.config.AdminConfig +import com.osglab.account.config.AppConfig +import com.osglab.account.features.admin.grants.services.AdminGrantService +import com.osglab.account.features.admin.models.AdminPrincipal +import com.osglab.account.features.admin.models.AdminRole +import com.osglab.account.features.admin.services.AdminAuditService +import com.osglab.account.features.admin.services.AdminAuthService +import com.osglab.account.features.admin.services.AdminOperatorService +import com.osglab.account.features.admin.services.AdminOperatorErrorCode +import com.osglab.account.features.admin.services.AdminOperatorException +import com.osglab.account.features.admin.services.AdminSessionService +import com.osglab.account.features.admin.stats.services.AdminStatsService +import com.osglab.account.features.admin.users.services.AdminUsersService +import com.osglab.account.features.credits.domain.CreditConflict +import com.osglab.account.features.credits.domain.CreditNotFound +import com.osglab.account.features.credits.domain.InvalidCreditRequest +import io.kotest.matchers.string.shouldContain +import io.ktor.client.statement.bodyAsText +import io.ktor.client.request.get +import io.ktor.client.request.header +import io.ktor.client.request.post +import io.ktor.client.request.setBody +import io.ktor.http.ContentType +import io.ktor.http.HttpHeaders +import io.ktor.http.HttpStatusCode +import io.ktor.http.contentType +import io.ktor.serialization.kotlinx.json.json +import io.ktor.server.application.install +import io.ktor.server.plugins.contentnegotiation.ContentNegotiation +import io.ktor.server.plugins.ratelimit.RateLimit +import io.ktor.server.plugins.ratelimit.RateLimitName +import io.ktor.server.routing.routing +import io.ktor.server.testing.testApplication +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import kotlinx.serialization.json.Json +import java.util.UUID +import kotlin.time.Duration.Companion.minutes +import kotlin.test.Test +import kotlin.test.assertEquals + +class AdminRoutesTest { + @Test + fun `admin api is hidden without verified edge header`() = testApplication { + application { installAdminTestRoutes() } + + val response = client.get("/v1/admin/auth/session") + + assertEquals(HttpStatusCode.NotFound, response.status) + } + + @Test + fun `verified edge can check anonymous session`() = testApplication { + application { installAdminTestRoutes() } + + val response = client.get("/v1/admin/auth/session") { + header("X-OSG-mTLS-Verified", "SUCCESS") + } + + assertEquals(HttpStatusCode.OK, response.status) + response.bodyAsText() shouldContain """"authenticated":false""" + } + + @Test + fun `authenticated session exposes role for client-side capability navigation`() = testApplication { + application { + installAdminTestRoutes( + sessionService = sessionFixture(AdminRole.SUPER_ADMIN), + ) + } + + val response = client.get("/v1/admin/auth/session") { + header("X-OSG-mTLS-Verified", "SUCCESS") + header(HttpHeaders.Cookie, "osg_admin_session=session-token") + } + + assertEquals(HttpStatusCode.OK, response.status) + response.bodyAsText() shouldContain """"operatorName":"operator"""" + response.bodyAsText() shouldContain """"role":"SUPER_ADMIN"""" + } + + @Test + fun `login rejects requests without exact same origin`() = testApplication { + val authService = mockk(relaxed = true) + application { installAdminTestRoutes(authService) } + + val response = client.post("/v1/admin/auth/login") { + header("X-OSG-mTLS-Verified", "SUCCESS") + header(HttpHeaders.Origin, "https://attacker.example") + contentType(ContentType.Application.Json) + setBody("""{"username":"owner","password":"not-a-real-password","totpCode":"123456"}""") + } + + assertEquals(HttpStatusCode.Forbidden, response.status) + coVerify(exactly = 0) { authService.login(any(), any(), any(), any()) } + } + + @Test + fun `admin web resources are embedded`() = testApplication { + application { + routing { adminWebRoutes() } + } + + val response = client.get("/admin/") + + assertEquals(HttpStatusCode.OK, response.status) + response.bodyAsText() shouldContain "OSG 运营后台" + response.bodyAsText() shouldContain """/admin/assets/""" + } + + @Test + fun `manual grant maps missing user to stable admin error`() = testApplication { + val fixture = grantRouteFixture(CreditNotFound("missing")) + application { + installAdminTestRoutes( + sessionService = fixture.first, + grantService = fixture.second, + ) + } + + val response = client.postGrant() + + assertEquals(HttpStatusCode.NotFound, response.status) + response.bodyAsText() shouldContain """"code":"USER_NOT_FOUND"""" + } + + @Test + fun `manual grant maps idempotency conflict to conflict`() = testApplication { + val fixture = grantRouteFixture(CreditConflict("conflict")) + application { + installAdminTestRoutes( + sessionService = fixture.first, + grantService = fixture.second, + ) + } + + val response = client.postGrant() + + assertEquals(HttpStatusCode.Conflict, response.status) + response.bodyAsText() shouldContain """"code":"IDEMPOTENCY_CONFLICT"""" + } + + @Test + fun `manual grant maps invalid domain request to validation error`() = testApplication { + val fixture = grantRouteFixture(InvalidCreditRequest("invalid")) + application { + installAdminTestRoutes( + sessionService = fixture.first, + grantService = fixture.second, + ) + } + + val response = client.postGrant() + + assertEquals(HttpStatusCode.BadRequest, response.status) + response.bodyAsText() shouldContain """"code":"VALIDATION_ERROR"""" + } + + @Test + fun `operator list maps non-super authorization to stable forbidden response`() = testApplication { + val sessionService = sessionFixture(AdminRole.SUPPORT) + val operatorService = mockk() + coEvery { operatorService.listPage(any(), any(), any()) } throws + AdminOperatorException(AdminOperatorErrorCode.INSUFFICIENT_PERMISSION) + application { + installAdminTestRoutes( + sessionService = sessionService, + operatorService = operatorService, + ) + } + + val response = client.get("/v1/admin/operators") { + header("X-OSG-mTLS-Verified", "SUCCESS") + header(HttpHeaders.Cookie, "osg_admin_session=session-token") + } + + assertEquals(HttpStatusCode.Forbidden, response.status) + response.bodyAsText() shouldContain """"code":"INSUFFICIENT_PERMISSION"""" + } + + @Test + fun `analyst cannot access user records`() = testApplication { + application { + installAdminTestRoutes( + sessionService = sessionFixture(AdminRole.ANALYST), + ) + } + + val response = client.get("/v1/admin/users") { + header("X-OSG-mTLS-Verified", "SUCCESS") + header(HttpHeaders.Cookie, "osg_admin_session=session-token") + } + + assertEquals(HttpStatusCode.Forbidden, response.status) + response.bodyAsText() shouldContain """"code":"INSUFFICIENT_PERMISSION"""" + } + + @Test + fun `operator creation maps normalized username conflict to 409`() = testApplication { + val sessionService = sessionFixture(AdminRole.SUPER_ADMIN) + val operatorService = mockk() + coEvery { + operatorService.create(any(), any(), any(), any(), any()) + } throws AdminOperatorException(AdminOperatorErrorCode.ADMIN_USERNAME_CONFLICT) + application { + installAdminTestRoutes( + sessionService = sessionService, + operatorService = operatorService, + ) + } + + val response = client.post("/v1/admin/operators") { + header("X-OSG-mTLS-Verified", "SUCCESS") + header(HttpHeaders.Origin, "https://account.osglab.com") + header(HttpHeaders.Cookie, "osg_admin_session=session-token") + header("X-CSRF-Token", "csrf-token") + contentType(ContentType.Application.Json) + setBody( + """{"username":"owner","password":"long-enough-password","role":"SUPPORT"}""", + ) + } + + assertEquals(HttpStatusCode.Conflict, response.status) + response.bodyAsText() shouldContain """"code":"ADMIN_USERNAME_CONFLICT"""" + } + + @Test + fun `operator creation maps malformed body to stable validation error`() = testApplication { + application { + installAdminTestRoutes( + sessionService = sessionFixture(AdminRole.SUPER_ADMIN), + ) + } + + val response = client.post("/v1/admin/operators") { + header("X-OSG-mTLS-Verified", "SUCCESS") + header(HttpHeaders.Origin, "https://account.osglab.com") + header(HttpHeaders.Cookie, "osg_admin_session=session-token") + header("X-CSRF-Token", "csrf-token") + contentType(ContentType.Application.Json) + setBody("""{"username":"owner","password":"long-enough-password"}""") + } + + assertEquals(HttpStatusCode.BadRequest, response.status) + response.bodyAsText() shouldContain """"code":"VALIDATION_ERROR"""" + } +} + +private fun io.ktor.server.application.Application.installAdminTestRoutes( + authService: AdminAuthService = mockk(relaxed = true), + sessionService: AdminSessionService = mockk(relaxed = true), + grantService: AdminGrantService = mockk(relaxed = true), + operatorService: AdminOperatorService = mockk(relaxed = true), + auditService: AdminAuditService = mockk(relaxed = true), +) { + install(ContentNegotiation) { + json(Json { explicitNulls = false }) + } + install(RateLimit) { + register(RateLimitName("admin-auth")) { + rateLimiter(limit = 20, refillPeriod = 1.minutes) + } + } + val config = mockk { + every { publicBaseUrl } returns "https://account.osglab.com" + every { isProduction } returns false + every { admin } returns AdminConfig() + } + routing { + adminApiRoutes( + config = config, + authService = authService, + sessionService = sessionService, + statsService = mockk(relaxed = true), + usersService = mockk(relaxed = true), + grantService = grantService, + operatorService = operatorService, + auditService = auditService, + ) + } +} + +private fun grantRouteFixture( + failure: RuntimeException, +): Pair { + val sessionService = mockk() + val grantService = mockk() + coEvery { + sessionService.authenticateMutation("session-token", "csrf-token") + } returns AdminPrincipal( + operatorId = UUID.fromString("5d98fe09-da98-45b4-9466-f3f779dc1a4b"), + sessionId = UUID.fromString("c0271d1b-e5c8-4ca4-8409-1ab7ed148a34"), + normalizedUsername = "owner", + role = AdminRole.SUPER_ADMIN, + ) + coEvery { grantService.grant(any()) } throws failure + return sessionService to grantService +} + +private fun sessionFixture(role: AdminRole): AdminSessionService = + mockk().also { + coEvery { it.authenticate("session-token") } returns AdminPrincipal( + operatorId = UUID.fromString("5d98fe09-da98-45b4-9466-f3f779dc1a4b"), + sessionId = UUID.fromString("c0271d1b-e5c8-4ca4-8409-1ab7ed148a34"), + normalizedUsername = "operator", + role = role, + ) + coEvery { it.authenticateMutation("session-token", "csrf-token") } returns AdminPrincipal( + operatorId = UUID.fromString("5d98fe09-da98-45b4-9466-f3f779dc1a4b"), + sessionId = UUID.fromString("c0271d1b-e5c8-4ca4-8409-1ab7ed148a34"), + normalizedUsername = "operator", + role = role, + ) + } + +private suspend fun io.ktor.client.HttpClient.postGrant() = + post("/v1/admin/credits/grants") { + header("X-OSG-mTLS-Verified", "SUCCESS") + header(HttpHeaders.Origin, "https://account.osglab.com") + header(HttpHeaders.Cookie, "osg_admin_session=session-token") + header("X-CSRF-Token", "csrf-token") + header("Idempotency-Key", "grant-request-1") + contentType(ContentType.Application.Json) + setBody( + """{"userId":"5a33af2f-a878-43c0-8315-31729402b7cd","amount":100,"reason":"support credit"}""", + ) + } diff --git a/src/test/kotlin/com/osglab/account/features/admin/security/AdminPasswordHasherTest.kt b/src/test/kotlin/com/osglab/account/features/admin/security/AdminPasswordHasherTest.kt new file mode 100644 index 0000000..aac9cf5 --- /dev/null +++ b/src/test/kotlin/com/osglab/account/features/admin/security/AdminPasswordHasherTest.kt @@ -0,0 +1,37 @@ +package com.osglab.account.features.admin.security + +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.core.spec.style.FunSpec +import io.kotest.matchers.shouldBe +import io.kotest.matchers.string.shouldStartWith + +class AdminPasswordHasherTest : FunSpec({ + val hasher = BouncyCastleArgon2idPasswordHasher( + Argon2idConfig(memoryKb = 1_024, iterations = 2, parallelism = 1), + ) + + test("Argon2id hashes verify the correct password and use unique salts") { + val password = "correct horse battery staple".toCharArray() + val first = hasher.hash(password) + val second = hasher.hash(password) + + first.shouldStartWith("\$argon2id\$v=19\$") + (first != second) shouldBe true + hasher.verify(password, first) shouldBe true + hasher.verify("wrong password".toCharArray(), first) shouldBe false + } + + test("verification rejects malformed and excessive work factors") { + hasher.verify("password".toCharArray(), "not-a-phc-hash") shouldBe false + hasher.verify( + "password".toCharArray(), + "\$argon2id\$v=19\$m=999999999,t=3,p=1\$MTIzNDU2Nzg5MDEyMzQ1Ng\$" + + "MTIzNDU2Nzg5MDEyMzQ1Njc4OTAxMjM0NTY", + ) shouldBe false + } + + test("hashing rejects empty and oversized passwords") { + shouldThrow { hasher.hash(charArrayOf()) } + shouldThrow { hasher.hash(CharArray(1_025) { 'a' }) } + } +}) diff --git a/src/test/kotlin/com/osglab/account/features/admin/security/AdminTotpTest.kt b/src/test/kotlin/com/osglab/account/features/admin/security/AdminTotpTest.kt new file mode 100644 index 0000000..939d1fc --- /dev/null +++ b/src/test/kotlin/com/osglab/account/features/admin/security/AdminTotpTest.kt @@ -0,0 +1,54 @@ +package com.osglab.account.features.admin.security + +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.core.spec.style.FunSpec +import io.kotest.matchers.shouldBe +import io.kotest.matchers.string.shouldContain +import io.kotest.matchers.string.shouldNotContain +import java.time.Instant + +class AdminTotpTest : FunSpec({ + val secret = "12345678901234567890".toByteArray() + + test("TOTP generation matches RFC 6238 SHA-1 vectors") { + val verifier = HmacTotpVerifier(digits = 8, allowedWindow = 0) + + verifier.generate(secret, Instant.ofEpochSecond(59)) shouldBe "94287082" + verifier.generate(secret, Instant.ofEpochSecond(1_111_111_109)) shouldBe "07081804" + } + + test("verification accepts only the configured adjacent time window") { + val verifier = HmacTotpVerifier(digits = 6, allowedWindow = 1) + val previousStep = Instant.ofEpochSecond(1_700_000_010) + val currentStep = previousStep.plusSeconds(30) + val code = verifier.generate(secret, previousStep) + + verifier.verify(secret, code, currentStep) shouldBe previousStep.epochSecond / 30 + verifier.verify(secret, code, currentStep.plusSeconds(30)) shouldBe null + } + + test("verification rejects malformed codes and secrets") { + val verifier = HmacTotpVerifier() + + verifier.verify(secret, "12345x", Instant.EPOCH.plusSeconds(60)) shouldBe null + verifier.verify(ByteArray(15), "123456", Instant.EPOCH.plusSeconds(60)) shouldBe null + } + + test("Base32 decoder accepts canonical secrets and rejects trailing bits") { + Base32TotpSecret.decode("GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ") + .contentEquals(secret) shouldBe true + shouldThrow { + Base32TotpSecret.decode("GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQB") + } + } + + test("provisioning generator returns a 160-bit secret without exposing it in logs") { + val provisioning = SecureAdminTotpSecretGenerator().generate("support.agent") + + provisioning.secretBase32.length shouldBe 32 + Base32TotpSecret.decode(provisioning.secretBase32).size shouldBe 20 + provisioning.otpauthUri shouldContain "otpauth://totp/OSGKeyboard%3Asupport.agent" + provisioning.otpauthUri shouldContain "secret=${provisioning.secretBase32}" + provisioning.toString() shouldNotContain provisioning.secretBase32 + } +}) diff --git a/src/test/kotlin/com/osglab/account/features/admin/services/AdminAuditServiceTest.kt b/src/test/kotlin/com/osglab/account/features/admin/services/AdminAuditServiceTest.kt new file mode 100644 index 0000000..99ae823 --- /dev/null +++ b/src/test/kotlin/com/osglab/account/features/admin/services/AdminAuditServiceTest.kt @@ -0,0 +1,87 @@ +package com.osglab.account.features.admin.services + +import com.osglab.account.features.admin.models.AdminAuditAction +import com.osglab.account.features.admin.models.AdminAuditOutcome +import com.osglab.account.features.admin.models.AdminAuditRecord +import com.osglab.account.features.admin.models.AdminPrincipal +import com.osglab.account.features.admin.models.AdminRole +import com.osglab.account.features.admin.repositories.AdminRepository +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.core.spec.style.FunSpec +import io.kotest.matchers.shouldBe +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.mockk +import java.time.Instant +import java.util.UUID + +class AdminAuditServiceTest : FunSpec({ + test("page cursor continues after the final visible audit record") { + val repository = mockk() + val service = AdminAuditService(repository) + val records = listOf( + auditRecord("2026-08-17T00:00:03Z"), + auditRecord("2026-08-17T00:00:02Z"), + auditRecord("2026-08-17T00:00:01Z"), + ) + coEvery { repository.listAudit(3, null) } returns records + coEvery { repository.listOperators() } returns emptyList() + + val firstPage = service.list(superAdministrator(), null, limit = 2) + + firstPage.items.size shouldBe 2 + firstPage.nextCursor.isNullOrBlank() shouldBe false + + coEvery { repository.listAudit(3, any()) } returns emptyList() + service.list(superAdministrator(), firstPage.nextCursor, limit = 2) + + coVerify { + repository.listAudit( + 3, + match { + it.occurredAt == records[1].occurredAt && + it.id == records[1].id + }, + ) + } + } + + test("malformed cursor is rejected before querying audit records") { + val repository = mockk() + val service = AdminAuditService(repository) + + shouldThrow { + service.list(superAdministrator(), "not-a-valid-cursor") + } + + coVerify(exactly = 0) { repository.listAudit(any(), any()) } + } + + test("non-super administrator cannot list audit records") { + val repository = mockk() + val service = AdminAuditService(repository) + val support = superAdministrator().copy(role = AdminRole.SUPPORT) + + shouldThrow { + service.list(support, null) + }.code shouldBe AdminOperatorErrorCode.INSUFFICIENT_PERMISSION + } +}) + +private fun auditRecord(occurredAt: String) = AdminAuditRecord( + id = UUID.randomUUID(), + actorOperatorId = UUID.randomUUID(), + action = AdminAuditAction.LOGIN_SUCCEEDED, + outcome = AdminAuditOutcome.SUCCESS, + targetType = "ADMIN_OPERATOR", + targetId = "target", + requestId = "request-1", + occurredAt = Instant.parse(occurredAt), +) + +private fun superAdministrator() = AdminPrincipal( + operatorId = UUID.randomUUID(), + sessionId = UUID.randomUUID(), + normalizedUsername = "owner", + role = AdminRole.SUPER_ADMIN, +) diff --git a/src/test/kotlin/com/osglab/account/features/admin/services/AdminAuthServiceTest.kt b/src/test/kotlin/com/osglab/account/features/admin/services/AdminAuthServiceTest.kt new file mode 100644 index 0000000..c998f21 --- /dev/null +++ b/src/test/kotlin/com/osglab/account/features/admin/services/AdminAuthServiceTest.kt @@ -0,0 +1,226 @@ +package com.osglab.account.features.admin.services + +import com.osglab.account.common.security.FieldEncryptor +import com.osglab.account.common.security.SecureTokenGenerator +import com.osglab.account.common.security.TokenHash +import com.osglab.account.features.admin.InMemoryAdminRepository +import com.osglab.account.features.admin.models.AdminAuditAction +import com.osglab.account.features.admin.models.AdminAuditOutcome +import com.osglab.account.features.admin.models.AdminLockState +import com.osglab.account.features.admin.models.AdminLoginResult +import com.osglab.account.features.admin.security.AdminPasswordHasher +import com.osglab.account.features.admin.security.HmacTotpVerifier +import io.kotest.core.spec.style.FunSpec +import io.kotest.matchers.shouldBe +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.coroutineScope +import java.time.Clock +import java.time.Duration +import java.time.Instant +import java.time.ZoneId +import java.time.ZoneOffset +import java.util.UUID +import java.util.concurrent.atomic.AtomicInteger + +class AdminAuthServiceTest : FunSpec({ + test("successful login persists only token hashes and records an audit event") { + val fixture = authFixture() + val code = fixture.totp.generate(TOTP_SECRET_BYTES, fixture.clock.instant()) + + val result = fixture.service.login( + username = " ADMIN@EXAMPLE.COM ", + password = CORRECT_PASSWORD.toCharArray(), + totpCode = code, + requestId = "request-1", + ) as AdminLoginResult.Authenticated + + val sessionHash = TokenHash.sha256(result.credentials.sessionToken) + fixture.repository.sessions.containsKey(sessionHash) shouldBe true + fixture.repository.sessions.containsKey(result.credentials.sessionToken) shouldBe false + TokenHash.matches( + result.credentials.csrfToken, + fixture.repository.sessions.getValue(sessionHash).csrfTokenHash, + ) shouldBe true + fixture.repository.audits.single().action shouldBe AdminAuditAction.LOGIN_SUCCEEDED + fixture.repository.audits.single().outcome shouldBe AdminAuditOutcome.SUCCESS + result.credentials.toString().contains(result.credentials.sessionToken) shouldBe false + } + + test("same TOTP counter is accepted once under concurrent login") { + val fixture = authFixture() + val code = fixture.totp.generate(TOTP_SECRET_BYTES, fixture.clock.instant()) + + val results = coroutineScope { + List(2) { + async { + fixture.service.login( + username = "admin@example.com", + password = CORRECT_PASSWORD.toCharArray(), + totpCode = code, + ) + } + }.awaitAll() + } + + results.count { it is AdminLoginResult.Authenticated } shouldBe 1 + results.count { it is AdminLoginResult.InvalidCredentials } shouldBe 1 + fixture.repository.sessions.size shouldBe 1 + } + + test("failed credentials lock at the threshold") { + val fixture = authFixture( + lockPolicy = AdminLoginLockPolicy( + maxFailedAttempts = 3, + lockDuration = Duration.ofMinutes(10), + ), + ) + + repeat(2) { + fixture.service.login( + "admin@example.com", + "wrong".toCharArray(), + "000000", + ) shouldBe AdminLoginResult.InvalidCredentials + } + val third = fixture.service.login( + "admin@example.com", + "wrong".toCharArray(), + "000000", + ) + + third shouldBe AdminLoginResult.Locked(fixture.clock.instant().plusSeconds(600)) + fixture.repository.lockState.failedLoginCount shouldBe 0 + } + + test("lock expiry boundary starts a fresh failure sequence") { + val policy = AdminLoginLockPolicy( + maxFailedAttempts = 3, + lockDuration = Duration.ofMinutes(10), + ) + val lockEndsAt = Instant.parse("2026-08-16T01:00:00Z") + val next = policy.afterFailure( + AdminLockState(failedLoginCount = 0, lockedUntil = lockEndsAt), + lockEndsAt, + ) + + next shouldBe AdminLockState(failedLoginCount = 1, lockedUntil = null) + } + + test("unknown users execute dummy password verification") { + val fixture = authFixture() + var verifiedHash: String? = null + val service = fixture.serviceWithHasher( + object : AdminPasswordHasher { + override fun hash(password: CharArray): String = error("Not used") + + override fun verify(password: CharArray, encodedHash: String): Boolean { + verifiedHash = encodedHash + return false + } + }, + ) + + service.login("unknown@example.com", "guess".toCharArray(), "000000") shouldBe + AdminLoginResult.InvalidCredentials + verifiedHash shouldBe DUMMY_HASH + } + + test("invalid external request ID is omitted instead of failing login") { + val fixture = authFixture() + val code = fixture.totp.generate(TOTP_SECRET_BYTES, fixture.clock.instant()) + + val result = fixture.service.login( + username = "admin@example.com", + password = CORRECT_PASSWORD.toCharArray(), + totpCode = code, + requestId = "invalid request id with spaces", + ) + + (result is AdminLoginResult.Authenticated) shouldBe true + fixture.repository.audits.single().requestId shouldBe null + } +}) + +private data class AuthFixture( + val repository: InMemoryAdminRepository, + val service: AdminAuthService, + val totp: HmacTotpVerifier, + val encryptor: FieldEncryptor, + val clock: MutableClock, + val lockPolicy: AdminLoginLockPolicy, + val tokenGenerator: SecureTokenGenerator, +) { + fun serviceWithHasher(hasher: AdminPasswordHasher) = AdminAuthService( + repository = repository, + passwordHasher = hasher, + dummyPasswordHash = DUMMY_HASH, + totpVerifier = totp, + fieldEncryptor = encryptor, + sessionTtl = Duration.ofHours(1), + lockPolicy = lockPolicy, + tokenGenerator = tokenGenerator, + clock = clock, + ) +} + +private fun authFixture( + lockPolicy: AdminLoginLockPolicy = AdminLoginLockPolicy(), +): AuthFixture { + val operatorId = UUID.randomUUID() + val encryptor = FieldEncryptor(ByteArray(32) { 7 }) + val encryptedSecret = encryptor.encrypt(TOTP_SECRET_BASE32, adminTotpContext(operatorId)) + val repository = InMemoryAdminRepository( + operatorId = operatorId, + encryptedTotpSecret = encryptedSecret, + ) + val totp = HmacTotpVerifier() + val clock = MutableClock(Instant.parse("2026-08-16T00:00:10Z")) + val tokenGenerator = CountingTokenGenerator() + val fixture = AuthFixture( + repository = repository, + service = AdminAuthService( + repository = repository, + passwordHasher = TestPasswordHasher, + dummyPasswordHash = DUMMY_HASH, + totpVerifier = totp, + fieldEncryptor = encryptor, + sessionTtl = Duration.ofHours(1), + lockPolicy = lockPolicy, + tokenGenerator = tokenGenerator, + clock = clock, + ), + totp = totp, + encryptor = encryptor, + clock = clock, + lockPolicy = lockPolicy, + tokenGenerator = tokenGenerator, + ) + return fixture +} + +private data object TestPasswordHasher : AdminPasswordHasher { + override fun hash(password: CharArray): String = error("Not used") + + override fun verify(password: CharArray, encodedHash: String): Boolean = + encodedHash == "valid-password-hash" && password.concatToString() == CORRECT_PASSWORD +} + +private class CountingTokenGenerator : SecureTokenGenerator { + private val counter = AtomicInteger() + + override fun newRefreshToken(): String = "test-token-${counter.incrementAndGet()}" +} + +private class MutableClock( + var current: Instant, +) : Clock() { + override fun getZone(): ZoneId = ZoneOffset.UTC + override fun withZone(zone: ZoneId): Clock = this + override fun instant(): Instant = current +} + +private const val CORRECT_PASSWORD = "correct-password" +private const val DUMMY_HASH = "dummy-password-hash" +private const val TOTP_SECRET_BASE32 = "GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ" +private val TOTP_SECRET_BYTES = "12345678901234567890".toByteArray() diff --git a/src/test/kotlin/com/osglab/account/features/admin/services/AdminBootstrapServiceTest.kt b/src/test/kotlin/com/osglab/account/features/admin/services/AdminBootstrapServiceTest.kt new file mode 100644 index 0000000..f78969a --- /dev/null +++ b/src/test/kotlin/com/osglab/account/features/admin/services/AdminBootstrapServiceTest.kt @@ -0,0 +1,54 @@ +package com.osglab.account.features.admin.services + +import com.osglab.account.common.security.FieldEncryptor +import com.osglab.account.features.admin.models.NewAdminOperator +import com.osglab.account.features.admin.repositories.AdminRepository +import io.kotest.matchers.shouldBe +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.mockk +import io.mockk.slot +import kotlinx.coroutines.runBlocking +import java.time.Clock +import java.time.Instant +import java.time.ZoneOffset +import java.util.UUID +import kotlin.test.Test + +class AdminBootstrapServiceTest { + @Test + fun `bootstrap encrypts TOTP secret and creates owner idempotently`() = runBlocking { + val repository = mockk() + val operator = slot() + coEvery { repository.createOperatorIfAbsent(capture(operator)) } returns true + val encryptor = FieldEncryptor(ByteArray(32) { 9 }) + val service = AdminBootstrapService( + repository, + encryptor, + Clock.fixed(Instant.parse("2026-08-16T00:00:00Z"), ZoneOffset.UTC), + ) + val operatorId = UUID.fromString("2c031def-4517-4fde-b592-5db3a3eefdf6") + + service.initialize( + AdminBootstrapConfig( + enabled = true, + operatorId = operatorId, + username = "Owner", + passwordHash = VALID_PASSWORD_HASH, + totpSecretBase32 = TOTP_SECRET, + ), + ) shouldBe true + + operator.captured.normalizedUsername shouldBe "owner" + operator.captured.passwordHash shouldBe VALID_PASSWORD_HASH + encryptor.decrypt( + operator.captured.encryptedTotpSecret, + adminTotpContext(operatorId), + ) shouldBe TOTP_SECRET + coVerify(exactly = 1) { repository.createOperatorIfAbsent(any()) } + } +} + +private const val TOTP_SECRET = "JBSWY3DPEHPK3PXPJBSWY3DPEHPK3PXP" +private const val VALID_PASSWORD_HASH = + "\$argon2id\$v=19\$m=65536,t=3,p=1\$c2FsdA\$aGFzaA" diff --git a/src/test/kotlin/com/osglab/account/features/admin/services/AdminOperatorServiceTest.kt b/src/test/kotlin/com/osglab/account/features/admin/services/AdminOperatorServiceTest.kt new file mode 100644 index 0000000..c5fe29f --- /dev/null +++ b/src/test/kotlin/com/osglab/account/features/admin/services/AdminOperatorServiceTest.kt @@ -0,0 +1,284 @@ +package com.osglab.account.features.admin.services + +import com.osglab.account.common.security.FieldEncryptor +import com.osglab.account.features.admin.InMemoryAdminRepository +import com.osglab.account.features.admin.models.AdminAuditAction +import com.osglab.account.features.admin.models.AdminAuditOutcome +import com.osglab.account.features.admin.models.AdminLockState +import com.osglab.account.features.admin.models.AdminOperatorRecord +import com.osglab.account.features.admin.models.AdminPrincipal +import com.osglab.account.features.admin.models.AdminRole +import com.osglab.account.features.admin.models.AdminSessionRecord +import com.osglab.account.features.admin.models.NewAdminOperator +import com.osglab.account.features.admin.security.AdminPasswordHasher +import com.osglab.account.features.admin.security.AdminTotpProvisioning +import com.osglab.account.features.admin.security.AdminTotpSecretGenerator +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.core.spec.style.FunSpec +import io.kotest.matchers.collections.shouldHaveSize +import io.kotest.matchers.collections.shouldContainExactly +import io.kotest.matchers.shouldBe +import io.kotest.matchers.string.shouldNotContain +import java.time.Clock +import java.time.Duration +import java.time.Instant +import java.time.ZoneOffset +import java.util.UUID + +class AdminOperatorServiceTest : FunSpec({ + test("super administrator creates normalized operator with one-time encrypted TOTP") { + val fixture = operatorFixture() + val password = "long-enough-password".toCharArray() + + val created = fixture.service.create( + fixture.owner, + " Support.Agent ", + password, + "SUPPORT", + "operator-create-1", + ) + + created.operator?.normalizedUsername shouldBe "support.agent" + created.operator?.role shouldBe AdminRole.SUPPORT + created.totpSecret shouldBe TOTP_SECRET + created.toString() shouldNotContain TOTP_SECRET + password.all { it == '\u0000' } shouldBe true + val stored = fixture.repository.findOperatorForAuthentication("support.agent") + fixture.encryptor.decrypt( + requireNotNull(stored).encryptedTotpSecret, + adminTotpContext(stored.id), + ) shouldBe TOTP_SECRET + fixture.repository.audits.last().run { + action shouldBe AdminAuditAction.OPERATOR_CREATED + outcome shouldBe AdminAuditOutcome.SUCCESS + } + } + + test("duplicate normalized username returns stable conflict and denied audit") { + val fixture = operatorFixture() + fixture.service.create( + fixture.owner, + "duplicate", + "long-enough-password".toCharArray(), + "ANALYST", + ) + + shouldThrow { + fixture.service.create( + fixture.owner, + " DUPLICATE ", + "another-long-password".toCharArray(), + "SUPPORT", + ) + }.code shouldBe AdminOperatorErrorCode.ADMIN_USERNAME_CONFLICT + fixture.repository.audits.last().outcome shouldBe AdminAuditOutcome.DENIED + } + + test("self disable is rejected and audited") { + val fixture = operatorFixture() + + shouldThrow { + fixture.service.setEnabled(fixture.owner, fixture.owner.operatorId, enabled = false) + }.code shouldBe AdminOperatorErrorCode.CANNOT_DISABLE_SELF + + fixture.repository.disabledAt shouldBe null + fixture.repository.audits.single().run { + action shouldBe AdminAuditAction.OPERATOR_DISABLED + outcome shouldBe AdminAuditOutcome.DENIED + } + } + + test("repository rejects disabling the final enabled super administrator") { + val fixture = operatorFixture() + val secondSuper = fixture.seedOperator("second-owner", AdminRole.SUPER_ADMIN) + fixture.service.setEnabled(fixture.owner, secondSuper, enabled = false) + val disabledPrincipal = fixture.owner.copy(operatorId = secondSuper) + + shouldThrow { + fixture.service.setEnabled(disabledPrincipal, fixture.owner.operatorId, enabled = false) + }.code shouldBe AdminOperatorErrorCode.LAST_SUPER_ADMIN_REQUIRED + + fixture.repository.disabledAt shouldBe null + fixture.repository.audits.last().outcome shouldBe AdminAuditOutcome.DENIED + } + + test("credential reset clears lock state and revokes every target session") { + val fixture = operatorFixture() + val targetId = fixture.seedOperator("reset-target", AdminRole.SUPPORT) + val session = AdminSessionRecord( + id = UUID.randomUUID(), + operatorId = targetId, + normalizedUsername = "reset-target", + role = AdminRole.SUPPORT, + csrfTokenHash = "csrf-hash", + expiresAt = fixture.clock.instant().plus(Duration.ofHours(1)), + ) + fixture.repository.seedSession("target-session-hash", session) + val password = "replacement-password".toCharArray() + + val credentials = fixture.service.resetCredentials( + fixture.owner, + targetId, + password, + "credentials-reset-1", + ) + + credentials.totpSecret shouldBe TOTP_SECRET + password.all { it == '\u0000' } shouldBe true + fixture.repository.revokedTokenHashes shouldBe setOf("target-session-hash") + fixture.repository.findOperatorForAuthentication("reset-target")?.lockState shouldBe + AdminLockState(0, null) + fixture.repository.audits.last().run { + action shouldBe AdminAuditAction.OPERATOR_CREDENTIALS_RESET + outcome shouldBe AdminAuditOutcome.SUCCESS + } + } + + test("security summary counts enabled operators and active sessions") { + val fixture = operatorFixture() + val targetId = fixture.seedOperator("support-summary", AdminRole.SUPPORT) + fixture.repository.seedSession( + "summary-session-hash", + AdminSessionRecord( + id = UUID.randomUUID(), + operatorId = targetId, + normalizedUsername = "support-summary", + role = AdminRole.SUPPORT, + csrfTokenHash = "csrf-hash", + expiresAt = fixture.clock.instant().plus(Duration.ofHours(1)), + ), + ) + + val summary = fixture.service.summary(fixture.owner) + + summary.enabledOperators shouldBe 2 + summary.lockedOperators shouldBe 0 + summary.activeSessions shouldBe 1 + } + + test("non-super administrator cannot mutate operators and denial is audited") { + val fixture = operatorFixture() + val support = fixture.owner.copy(role = AdminRole.SUPPORT) + + shouldThrow { + fixture.service.unlock(support, fixture.owner.operatorId) + }.code shouldBe AdminOperatorErrorCode.INSUFFICIENT_PERMISSION + + fixture.repository.audits.single().outcome shouldBe AdminAuditOutcome.DENIED + } + + test("username role and minimum password boundaries are validated") { + val fixture = operatorFixture() + val invalidRequests = listOf( + Triple("ab", "long-enough-password", "SUPPORT"), + Triple("valid-name", "elevenchars", "SUPPORT"), + Triple("valid-name", "long-enough-password", "OWNER"), + ) + + invalidRequests.forEach { (username, password, role) -> + shouldThrow { + fixture.service.create( + fixture.owner, + username, + password.toCharArray(), + role, + ) + }.code shouldBe AdminOperatorErrorCode.VALIDATION_ERROR + } + + fixture.repository.listOperators() shouldHaveSize 1 + fixture.repository.audits.all { it.outcome == AdminAuditOutcome.DENIED } shouldBe true + } + + test("operator cursor pagination is stable across equal timestamps") { + val fixture = operatorFixture() + fixture.seedOperator("operator-a", AdminRole.SUPPORT) + fixture.seedOperator("operator-b", AdminRole.ANALYST) + fixture.seedOperator("operator-c", AdminRole.SUPPORT) + val expected = fixture.repository.listOperators() + .sortedWith(compareBy { it.createdAt }.thenBy { it.id.toString() }) + + val first = fixture.service.listPage(fixture.owner, cursor = null, limit = 2) + val second = fixture.service.listPage( + fixture.owner, + cursor = requireNotNull(first.nextCursor), + limit = 2, + ) + + (first.items + second.items).map { it.id } shouldContainExactly expected.map { it.id } + second.nextCursor shouldBe null + } + + test("invalid operator cursor returns a stable cursor error") { + val fixture = operatorFixture() + + shouldThrow { + fixture.service.listPage(fixture.owner, cursor = "not-a-cursor") + } + } +}) + +private data class OperatorFixture( + val repository: InMemoryAdminRepository, + val service: AdminOperatorService, + val owner: AdminPrincipal, + val encryptor: FieldEncryptor, + val clock: Clock, +) { + suspend fun seedOperator(username: String, role: AdminRole): UUID { + val id = UUID.randomUUID() + repository.createOperatorIfAbsent( + NewAdminOperator( + id = id, + normalizedUsername = username, + passwordHash = "seed-hash", + encryptedTotpSecret = "seed-encrypted-secret", + role = role, + createdAt = clock.instant(), + ), + ) + return id + } +} + +private fun operatorFixture(): OperatorFixture { + val ownerId = UUID.randomUUID() + val encryptor = FieldEncryptor(ByteArray(32) { 4 }) + val repository = InMemoryAdminRepository( + operatorId = ownerId, + encryptedTotpSecret = "owner-encrypted-secret", + ) + val clock = Clock.fixed(Instant.parse("2026-08-17T00:00:00Z"), ZoneOffset.UTC) + val generator = AdminTotpSecretGenerator { + AdminTotpProvisioning( + secretBase32 = TOTP_SECRET, + otpauthUri = "otpauth://totp/OSGKeyboard:test?secret=$TOTP_SECRET", + ) + } + return OperatorFixture( + repository = repository, + service = AdminOperatorService( + repository = repository, + passwordHasher = RecordingPasswordHasher, + fieldEncryptor = encryptor, + totpSecretGenerator = generator, + clock = clock, + ), + owner = AdminPrincipal( + operatorId = ownerId, + sessionId = UUID.randomUUID(), + normalizedUsername = "owner", + role = AdminRole.SUPER_ADMIN, + ), + encryptor = encryptor, + clock = clock, + ) +} + +private data object RecordingPasswordHasher : AdminPasswordHasher { + override fun hash(password: CharArray): String = "hash:${password.concatToString()}" + + override fun verify(password: CharArray, encodedHash: String): Boolean = false +} + +private const val TOTP_SECRET = "JBSWY3DPEHPK3PXPJBSWY3DPEHPK3PXP" diff --git a/src/test/kotlin/com/osglab/account/features/admin/services/AdminSessionServiceTest.kt b/src/test/kotlin/com/osglab/account/features/admin/services/AdminSessionServiceTest.kt new file mode 100644 index 0000000..ebffb71 --- /dev/null +++ b/src/test/kotlin/com/osglab/account/features/admin/services/AdminSessionServiceTest.kt @@ -0,0 +1,131 @@ +package com.osglab.account.features.admin.services + +import com.osglab.account.common.security.TokenHash +import com.osglab.account.features.admin.InMemoryAdminRepository +import com.osglab.account.features.admin.models.AdminAuditAction +import com.osglab.account.features.admin.models.AdminRole +import com.osglab.account.features.admin.models.AdminSessionRecord +import io.kotest.core.spec.style.FunSpec +import io.kotest.matchers.shouldBe +import java.time.Clock +import java.time.Duration +import java.time.Instant +import java.time.ZoneId +import java.time.ZoneOffset +import java.util.UUID + +class AdminSessionServiceTest : FunSpec({ + test("active session authenticates and mutation requires matching CSRF token") { + val fixture = sessionFixture() + + fixture.service.authenticate(SESSION_TOKEN)?.sessionId shouldBe fixture.sessionId + fixture.service.authenticateMutation(SESSION_TOKEN, CSRF_TOKEN)?.sessionId shouldBe + fixture.sessionId + fixture.service.authenticateMutation(SESSION_TOKEN, "wrong-csrf") shouldBe null + } + + test("session is expired at the exact expiry boundary") { + val fixture = sessionFixture() + fixture.clock.current = fixture.expiresAt + + fixture.service.authenticate(SESSION_TOKEN) shouldBe null + } + + test("revocation is idempotently denied after the first success") { + val fixture = sessionFixture() + + fixture.service.revoke(SESSION_TOKEN, "wrong-csrf", "request-2") shouldBe false + fixture.service.revoke(SESSION_TOKEN, CSRF_TOKEN, "request-2") shouldBe true + fixture.service.authenticate(SESSION_TOKEN) shouldBe null + fixture.service.revoke(SESSION_TOKEN, CSRF_TOKEN, "request-2") shouldBe false + fixture.repository.audits.single().action shouldBe AdminAuditAction.SESSION_REVOKED + } + + test("malformed token is rejected before repository lookup") { + val fixture = sessionFixture() + + fixture.service.authenticate("") shouldBe null + fixture.service.authenticate("x".repeat(513)) shouldBe null + } + + test("invalid external request ID is omitted when revoking a session") { + val fixture = sessionFixture() + + fixture.service.revoke( + SESSION_TOKEN, + CSRF_TOKEN, + "invalid request id with spaces", + ) shouldBe true + + fixture.repository.audits.single().requestId shouldBe null + } + + test("cleanup removes only sessions inactive beyond retention") { + val fixture = sessionFixture() + val oldToken = "expired-session-token" + fixture.repository.seedSession( + TokenHash.sha256(oldToken), + AdminSessionRecord( + id = UUID.randomUUID(), + operatorId = fixture.repository.operatorId, + normalizedUsername = "admin@example.com", + role = AdminRole.SUPPORT, + csrfTokenHash = TokenHash.sha256(CSRF_TOKEN), + expiresAt = fixture.clock.instant().minus(Duration.ofDays(8)), + ), + ) + + fixture.service.cleanupInactive(Duration.ofDays(7), limit = 10) shouldBe 1 + fixture.repository.sessions.size shouldBe 1 + fixture.service.authenticate(SESSION_TOKEN)?.sessionId shouldBe fixture.sessionId + } +}) + +private data class SessionFixture( + val repository: InMemoryAdminRepository, + val service: AdminSessionService, + val clock: SessionTestClock, + val sessionId: UUID, + val expiresAt: Instant, +) + +private suspend fun sessionFixture(): SessionFixture { + val operatorId = UUID.randomUUID() + val repository = InMemoryAdminRepository( + operatorId = operatorId, + encryptedTotpSecret = "not-used", + ) + val now = Instant.parse("2026-08-16T00:00:00Z") + val clock = SessionTestClock(now) + val sessionId = UUID.randomUUID() + val expiresAt = now.plusSeconds(60) + repository.seedSession( + TokenHash.sha256(SESSION_TOKEN), + AdminSessionRecord( + id = sessionId, + operatorId = operatorId, + normalizedUsername = "admin@example.com", + role = AdminRole.SUPPORT, + csrfTokenHash = TokenHash.sha256(CSRF_TOKEN), + expiresAt = expiresAt, + ), + ) + return SessionFixture( + repository = repository, + service = AdminSessionService(repository, clock = clock), + clock = clock, + sessionId = sessionId, + expiresAt = expiresAt, + ) +} + +private class SessionTestClock( + var current: Instant, +) : Clock() { + override fun getZone(): ZoneId = ZoneOffset.UTC + override fun withZone(zone: ZoneId): Clock = this + override fun instant(): Instant = current +} + +private const val SESSION_TOKEN = "opaque-session-token" +private const val CSRF_TOKEN = "opaque-csrf-token" diff --git a/src/test/kotlin/com/osglab/account/features/admin/stats/AdminStatsRepositoryIntegrationTest.kt b/src/test/kotlin/com/osglab/account/features/admin/stats/AdminStatsRepositoryIntegrationTest.kt new file mode 100644 index 0000000..caf5a3a --- /dev/null +++ b/src/test/kotlin/com/osglab/account/features/admin/stats/AdminStatsRepositoryIntegrationTest.kt @@ -0,0 +1,71 @@ +package com.osglab.account.features.admin.stats + +import com.osglab.account.config.DatabaseConfig +import com.osglab.account.config.DatabaseFactory +import com.osglab.account.features.admin.stats.repositories.AdminStatsRange +import com.osglab.account.features.admin.stats.repositories.ExposedAdminStatsRepository +import io.kotest.core.spec.style.FunSpec +import io.kotest.matchers.collections.shouldBeEmpty +import io.kotest.matchers.longs.shouldBeExactly +import io.kotest.matchers.shouldBe +import org.opentest4j.TestAbortedException +import org.testcontainers.DockerClientFactory +import org.testcontainers.containers.MySQLContainer +import java.time.Instant + +class AdminStatsRepositoryIntegrationTest : FunSpec({ + test("MySQL executes every aggregate query without loading entity rows") { + val externalJdbcUrl = System.getenv("TEST_MYSQL_JDBC_URL")?.takeIf(String::isNotBlank) + if (externalJdbcUrl == null && !DockerClientFactory.instance().isDockerAvailable) { + throw TestAbortedException("Docker is unavailable; MySQL integration test skipped") + } + + val mysql = if (externalJdbcUrl == null) { + StatsMySqlContainer("mysql:8.4") + .withDatabaseName("osg_admin_stats_test") + .withUsername("test") + .withPassword("test") + .also(StatsMySqlContainer::start) + } else { + null + } + val factory = DatabaseFactory( + DatabaseConfig( + jdbcUrl = externalJdbcUrl ?: requireNotNull(mysql).jdbcUrl, + username = System.getenv("TEST_MYSQL_USER")?.takeIf(String::isNotBlank) + ?: mysql?.username + ?: "root", + password = System.getenv("TEST_MYSQL_PASSWORD") ?: mysql?.password ?: "", + maximumPoolSize = 2, + ), + ) + + try { + factory.database + val snapshot = ExposedAdminStatsRepository(factory).load( + AdminStatsRange( + from = Instant.parse("2026-08-10T12:00:00Z"), + until = Instant.parse("2026-08-17T12:00:00Z"), + ), + ) + + // A dedicated container starts empty, so every scalar and grouped aggregate is explicit. + if (mysql != null) { + snapshot.overview.totalUsers shouldBeExactly 0 + snapshot.overview.totalCreditBalance shouldBeExactly 0 + snapshot.overview.activeUsers shouldBeExactly 0 + snapshot.referralFunnel.pendingBindings shouldBeExactly 0 + snapshot.referralFunnel.ineligibleBindings shouldBeExactly 0 + snapshot.registrationsByDate shouldBe emptyMap() + snapshot.referralRanking.shouldBeEmpty() + snapshot.usage.shouldBeEmpty() + } + } finally { + factory.close() + mysql?.stop() + } + } +}) + +private class StatsMySqlContainer(image: String) : + MySQLContainer(image) diff --git a/src/test/kotlin/com/osglab/account/features/admin/stats/AdminStatsRepositoryTest.kt b/src/test/kotlin/com/osglab/account/features/admin/stats/AdminStatsRepositoryTest.kt new file mode 100644 index 0000000..79ef488 --- /dev/null +++ b/src/test/kotlin/com/osglab/account/features/admin/stats/AdminStatsRepositoryTest.kt @@ -0,0 +1,162 @@ +package com.osglab.account.features.admin.stats + +import com.osglab.account.features.admin.stats.models.AdminOverviewDto +import com.osglab.account.features.admin.stats.models.AdminReferralFunnelDto +import com.osglab.account.features.admin.stats.models.AdminUsageAggregateDto +import com.osglab.account.features.admin.stats.repositories.AdminStatsAggregates +import com.osglab.account.features.admin.stats.repositories.AdminStatsRange +import com.osglab.account.features.admin.stats.repositories.AdminStatsRepository +import com.osglab.account.features.admin.stats.repositories.AdminStatsSnapshot +import com.osglab.account.features.admin.stats.repositories.ReferralBindingAggregateRow +import com.osglab.account.features.admin.stats.repositories.assembleAdminStats +import com.osglab.account.features.admin.stats.repositories.toExactLong +import com.osglab.account.features.admin.stats.services.AdminStatsService +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.core.spec.style.FunSpec +import io.kotest.matchers.collections.shouldHaveSize +import io.kotest.matchers.longs.shouldBeExactly +import io.kotest.matchers.shouldBe +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.encodeToJsonElement +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import java.math.BigDecimal +import java.time.Duration +import java.time.Instant +import java.time.LocalDate + +class AdminStatsRepositoryTest : FunSpec({ + val from = Instant.parse("2026-08-15T00:00:00Z") + val until = Instant.parse("2026-08-17T00:00:00Z") + + test("aggregated rows preserve referral ranking and usage output") { + val snapshot = assembleAdminStats( + AdminStatsAggregates( + overview = AdminOverviewDto( + totalUsers = 20, + registrations = 4, + activeUsers = 3, + totalCreditBalance = 500, + issuedCredits = 130, + consumedCredits = 25, + ), + registrationsByDate = mapOf(LocalDate.parse("2026-08-15") to 4), + issuedCreditsByDate = mapOf(LocalDate.parse("2026-08-15") to 130), + consumedCreditsByDate = mapOf(LocalDate.parse("2026-08-16") to 25), + referralFunnel = AdminReferralFunnelDto( + codesCreated = 5, + bindings = 7, + rewardedBindings = 4, + pendingBindings = 2, + ineligibleBindings = 1, + ), + referralBindingsByInviter = listOf( + ReferralBindingAggregateRow("user-b", invitedUsers = 3, rewardedUsers = 1), + ReferralBindingAggregateRow("user-c", invitedUsers = 0, rewardedUsers = 1), + ReferralBindingAggregateRow("user-a", invitedUsers = 3, rewardedUsers = 2), + ), + referralCreditsByInviter = mapOf( + "user-a" to 60, + "user-b" to 30, + "ledger-only-user" to 90, + ), + usage = listOf( + AdminUsageAggregateDto( + kind = "LLM", + requests = 2, + chargedCredits = 20, + asrMillis = 0, + inputTokens = 100, + outputTokens = 50, + ), + AdminUsageAggregateDto( + kind = "ASR", + requests = 1, + chargedCredits = 5, + asrMillis = 500, + inputTokens = 0, + outputTokens = 0, + ), + ), + ), + ) + + snapshot.referralFunnel.pendingBindings shouldBeExactly 2 + snapshot.referralFunnel.ineligibleBindings shouldBeExactly 1 + snapshot.referralRanking.map { it.userId } shouldBe listOf("user-a", "user-b", "user-c") + snapshot.referralRanking.first().earnedCredits shouldBeExactly 60 + snapshot.referralRanking.last().let { + it.invitedUsers shouldBeExactly 0 + it.rewardedUsers shouldBeExactly 1 + it.earnedCredits shouldBeExactly 0 + } + snapshot.referralRanking.none { it.userId == "ledger-only-user" } shouldBe true + snapshot.usage.map { it.kind } shouldBe listOf("ASR", "LLM") + snapshot.usage.first().asrMillis shouldBeExactly 500 + snapshot.usage.last().inputTokens shouldBeExactly 100 + } + + test("service keeps exact 7 30 and 90 day buckets and serializes new outputs") { + val snapshot = AdminStatsSnapshot( + overview = AdminOverviewDto(0, 0, 0, 0, 0, 0), + registrationsByDate = emptyMap(), + issuedCreditsByDate = emptyMap(), + consumedCreditsByDate = emptyMap(), + referralFunnel = AdminReferralFunnelDto( + codesCreated = 0, + bindings = 0, + rewardedBindings = 0, + pendingBindings = 3, + ineligibleBindings = 2, + ), + referralRanking = emptyList(), + usage = listOf( + AdminUsageAggregateDto( + kind = "ASR", + requests = 4, + chargedCredits = 12, + asrMillis = 1_200, + inputTokens = 0, + outputTokens = 0, + ), + ), + ) + val capturedRanges = mutableListOf() + val service = AdminStatsService( + AdminStatsRepository { range -> + capturedRanges += range + snapshot + }, + ) + + listOf(7L, 30L, 90L).forEach { days -> + val result = service.get(until.minus(Duration.ofDays(days)), until) + + result.registrationTrend shouldHaveSize days.toInt() + result.creditFlow shouldHaveSize days.toInt() + capturedRanges.last() shouldBe AdminStatsRange( + until.minus(Duration.ofDays(days)), + until, + ) + } + + val result = service.get(from, until) + val json = Json.encodeToJsonElement(result).jsonObject + json["referralFunnel"]!!.jsonObject["pendingBindings"]!!.jsonPrimitive.content shouldBe "3" + json["referralFunnel"]!!.jsonObject["ineligibleBindings"]!!.jsonPrimitive.content shouldBe "2" + json["usage"]!!.jsonArray.single().jsonObject["requests"]!!.jsonPrimitive.content shouldBe "4" + } + + test("database decimal aggregates require an exact Long representation") { + BigDecimal.valueOf(Long.MAX_VALUE).toExactLong() shouldBeExactly Long.MAX_VALUE + BigDecimal.valueOf(Long.MIN_VALUE).toExactLong() shouldBeExactly Long.MIN_VALUE + + shouldThrow { + BigDecimal.valueOf(Long.MAX_VALUE).add(BigDecimal.ONE).toExactLong() + } + shouldThrow { + BigDecimal("1.5").toExactLong() + } + } +}) diff --git a/src/test/kotlin/com/osglab/account/features/admin/users/AdminUsersServiceTest.kt b/src/test/kotlin/com/osglab/account/features/admin/users/AdminUsersServiceTest.kt new file mode 100644 index 0000000..2977651 --- /dev/null +++ b/src/test/kotlin/com/osglab/account/features/admin/users/AdminUsersServiceTest.kt @@ -0,0 +1,229 @@ +package com.osglab.account.features.admin.users + +import com.osglab.account.features.admin.users.models.AdminUserDetailDto +import com.osglab.account.features.admin.users.models.AdminUserLedgerEntryDto +import com.osglab.account.features.admin.users.models.AdminUserReferralDto +import com.osglab.account.features.admin.users.models.AdminUserSummaryDto +import com.osglab.account.features.admin.stats.models.AdminUsageAggregateDto +import com.osglab.account.features.admin.users.repositories.AdminUserCursor +import com.osglab.account.features.admin.users.repositories.AdminUserLedgerCursor +import com.osglab.account.features.admin.users.repositories.AdminUsersRepository +import com.osglab.account.features.admin.users.services.AdminUserNotFoundException +import com.osglab.account.features.admin.users.services.AdminUsersService +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.core.spec.style.FunSpec +import io.kotest.matchers.collections.shouldHaveSize +import io.kotest.matchers.nulls.shouldNotBeNull +import io.kotest.matchers.shouldBe +import java.time.Instant +import java.util.UUID + +class AdminUsersServiceTest : FunSpec({ + test("detail preserves referral code usage referral and recent ledger data") { + val userId = UUID.randomUUID() + val detail = AdminUserDetailDto( + summary = summary(userId, Instant.parse("2026-08-15T00:00:00Z")), + referralCode = "OSG-REFERRAL", + usage = listOf( + AdminUsageAggregateDto( + kind = "LLM", + requests = 2, + chargedCredits = 8, + asrMillis = 0, + inputTokens = 20, + outputTokens = 10, + ), + ), + referral = AdminUserReferralDto( + inviterUserId = UUID.randomUUID().toString(), + invitedUsers = 3, + rewardedInvites = 2, + ), + recentLedger = listOf( + ledgerEntry( + id = UUID.randomUUID(), + createdAt = Instant.parse("2026-08-15T01:00:00Z"), + ), + ), + ) + val service = AdminUsersService( + PagingUsersRepository( + users = emptyList(), + details = mapOf(userId to detail), + ), + ) + + service.detail(userId) shouldBe detail + } + + test("ledger returns a page and continuation cursor") { + val userId = UUID.randomUUID() + val entries = listOf( + ledgerEntry( + UUID.fromString("ffffffff-ffff-ffff-ffff-ffffffffffff"), + Instant.parse("2026-08-15T03:00:00Z"), + ), + ledgerEntry( + UUID.fromString("eeeeeeee-eeee-eeee-eeee-eeeeeeeeeeee"), + Instant.parse("2026-08-15T02:00:00Z"), + ), + ledgerEntry( + UUID.fromString("dddddddd-dddd-dddd-dddd-dddddddddddd"), + Instant.parse("2026-08-15T01:00:00Z"), + ), + ) + val service = AdminUsersService( + PagingUsersRepository(emptyList(), ledger = mapOf(userId to entries)), + ) + + val page = service.ledger(userId, limit = 2) + + page.items shouldBe entries.take(2) + page.nextCursor.shouldNotBeNull() + } + + test("ledger cursor pagination is stable for equal timestamps") { + val createdAt = Instant.parse("2026-08-15T00:00:00Z") + val ids = listOf( + UUID.fromString("ffffffff-ffff-ffff-ffff-ffffffffffff"), + UUID.fromString("eeeeeeee-eeee-eeee-eeee-eeeeeeeeeeee"), + UUID.fromString("dddddddd-dddd-dddd-dddd-dddddddddddd"), + ) + val userId = UUID.randomUUID() + val entries = ids.map { ledgerEntry(it, createdAt) } + val repository = PagingUsersRepository( + users = emptyList(), + ledger = mapOf(userId to entries), + ) + val service = AdminUsersService(repository) + + val first = service.ledger(userId, limit = 2) + val second = service.ledger( + userId, + limit = 2, + cursor = first.nextCursor.shouldNotBeNull(), + ) + + first.items.map { it.id } shouldBe ids.take(2).map(UUID::toString) + second.items.map { it.id } shouldBe listOf(ids.last().toString()) + second.nextCursor shouldBe null + } + + test("invalid ledger cursor throws a stable illegal argument exception") { + val service = AdminUsersService(PagingUsersRepository(emptyList())) + + val failure = shouldThrow { + service.ledger(UUID.randomUUID(), cursor = "not-a-cursor") + } + + failure.message shouldBe "User ledger cursor is invalid" + } + + test("ledger last page has no continuation cursor") { + val userId = UUID.randomUUID() + val entries = listOf( + ledgerEntry(UUID.randomUUID(), Instant.parse("2026-08-15T02:00:00Z")), + ledgerEntry(UUID.randomUUID(), Instant.parse("2026-08-15T01:00:00Z")), + ) + val service = AdminUsersService( + PagingUsersRepository(emptyList(), ledger = mapOf(userId to entries)), + ) + + val page = service.ledger(userId, limit = 2) + + page.items shouldHaveSize 2 + page.nextCursor shouldBe null + } + + test("ledger rejects a missing user instead of returning an empty page") { + val service = AdminUsersService(PagingUsersRepository(emptyList())) + + shouldThrow { + service.ledger(UUID.randomUUID()) + } + } + + test("invalid user cursor and missing detail fail without returning user data") { + val service = AdminUsersService(PagingUsersRepository(emptyList())) + + shouldThrow { + service.list(cursor = "not-a-cursor") + } + shouldThrow { + service.detail(UUID.randomUUID()) + } + } +}) + +private class PagingUsersRepository( + private val users: List, + private val details: Map = emptyMap(), + private val ledger: Map> = emptyMap(), +) : AdminUsersRepository { + override suspend fun list( + limit: Int, + cursor: AdminUserCursor?, + ): List = + users.filter { + cursor == null || + Instant.parse(it.createdAt) < cursor.createdAt || + ( + Instant.parse(it.createdAt) == cursor.createdAt && + UUID.fromString(it.id).toString() < cursor.userId.toString() + ) + }.take(limit) + + override suspend fun findDetail( + userId: UUID, + ledgerLimit: Int, + ): AdminUserDetailDto? = details[userId] + + override suspend fun exists(userId: UUID): Boolean = + userId in details || userId in ledger || users.any { it.id == userId.toString() } + + override suspend fun listLedger( + userId: UUID, + limit: Int, + cursor: AdminUserLedgerCursor?, + ): List = + ledger[userId].orEmpty() + .filter { + val createdAt = Instant.parse(it.createdAt) + cursor == null || + createdAt < cursor.createdAt || + ( + createdAt == cursor.createdAt && + it.id < cursor.ledgerEntryId.toString() + ) + } + .sortedWith( + compareByDescending { Instant.parse(it.createdAt) } + .thenByDescending(AdminUserLedgerEntryDto::id), + ) + .take(limit) +} + +private fun summary(id: UUID, createdAt: Instant) = AdminUserSummaryDto( + id = id.toString(), + createdAt = createdAt.toString(), + antiAbuseRestricted = false, + creditBalance = 0, + consumedCredits = 0, + manualGrantedCredits = 0, + usageRequests = 0, + lastActiveAt = null, + invitedUsers = 0, + rewardedInvites = 0, +) + +private fun ledgerEntry( + id: UUID, + createdAt: Instant, +) = AdminUserLedgerEntryDto( + id = id.toString(), + type = "MANUAL_GRANT", + amountDelta = 10, + balanceAfter = 10, + referenceId = null, + createdAt = createdAt.toString(), +) diff --git a/src/test/kotlin/com/osglab/account/features/credits/CreditServiceTest.kt b/src/test/kotlin/com/osglab/account/features/credits/CreditServiceTest.kt index 2f3c312..9c8ad7e 100644 --- a/src/test/kotlin/com/osglab/account/features/credits/CreditServiceTest.kt +++ b/src/test/kotlin/com/osglab/account/features/credits/CreditServiceTest.kt @@ -80,6 +80,161 @@ class CreditServiceTest : FunSpec({ store.ledger.filter { it.type == LedgerEntryType.SIGNUP_TRIAL } shouldHaveSize 1 } + test("manual grant appends one linked audit and ledger entry") { + val store = storeWithRates(now) + val service = service(store, now) + val userId = UUID.randomUUID() + store.registeredUsers += userId + + val result = service.grantManual( + operatorId = TEST_OPERATOR_ID, + userId = userId, + credits = 250, + reason = "Customer support adjustment", + requestId = "support-ticket-1042", + idempotencyKey = "manual-grant-key-001", + ) + + result.replayed shouldBe false + result.balanceAfter shouldBeExactly 250 + store.balance(userId) shouldBeExactly 250 + store.manualGrants.single() shouldBe result.grant + store.adminAudits.single().id shouldBe result.grant.auditLogId + store.ledger.single { it.type == LedgerEntryType.MANUAL_GRANT }.let { ledger -> + ledger.referenceId shouldBe result.grant.id + ledger.id shouldBe result.grant.ledgerEntryId + } + } + + test("manual grant replay is stable and rejects changed parameters") { + val store = storeWithRates(now) + val service = service(store, now) + val userId = UUID.randomUUID() + store.registeredUsers += userId + val first = service.grantManual( + TEST_OPERATOR_ID, + userId, + 100, + "Retention credit", + "support-ticket-1043", + "manual-grant-key-002", + ) + val replay = service.grantManual( + TEST_OPERATOR_ID, + userId, + 100, + "Retention credit", + "support-ticket-1043", + "manual-grant-key-002", + ) + + replay.grant shouldBe first.grant + replay.replayed shouldBe true + shouldThrow { + service.grantManual( + TEST_OPERATOR_ID, + userId, + 101, + "Retention credit", + "support-ticket-1043", + "manual-grant-key-002", + ) + } + store.balance(userId) shouldBeExactly 100 + store.manualGrants shouldHaveSize 1 + store.ledger.filter { it.type == LedgerEntryType.MANUAL_GRANT } shouldHaveSize 1 + } + + test("manual grant validates positive amount and rolls back an audit failure") { + val store = storeWithRates(now) + val service = service(store, now) + val userId = UUID.randomUUID() + store.registeredUsers += userId + + shouldThrow { + service.grantManual( + TEST_OPERATOR_ID, + userId, + 0, + "Invalid adjustment", + "support-ticket-1044", + "manual-grant-key-003", + ) + } + store.failNextManualGrantInsert = true + shouldThrow { + service.grantManual( + TEST_OPERATOR_ID, + userId, + 50, + "Rollback adjustment", + "support-ticket-1045", + "manual-grant-key-004", + ) + } + + store.balance(userId) shouldBeExactly 0 + store.manualGrants shouldHaveSize 0 + store.adminAudits shouldHaveSize 0 + store.ledger.filter { it.type == LedgerEntryType.MANUAL_GRANT } shouldHaveSize 0 + } + + test("concurrent manual grant replay credits exactly once") { + val store = storeWithRates(now) + val service = service(store, now) + val userId = UUID.randomUUID() + store.registeredUsers += userId + + val results = coroutineScope { + List(8) { + async(Dispatchers.Default) { + service.grantManual( + TEST_OPERATOR_ID, + userId, + 75, + "Concurrent adjustment", + "support-ticket-1046", + "manual-grant-key-005", + ) + } + }.awaitAll() + } + + results.map { it.grant.id }.distinct() shouldHaveSize 1 + store.balance(userId) shouldBeExactly 75 + store.manualGrants shouldHaveSize 1 + store.ledger.filter { it.type == LedgerEntryType.MANUAL_GRANT } shouldHaveSize 1 + } + + test("manual grant supports the maximum balance and rejects overflow") { + val store = storeWithRates(now) + val service = service(store, now) + val userId = UUID.randomUUID() + store.registeredUsers += userId + service.grantManual( + TEST_OPERATOR_ID, + userId, + Long.MAX_VALUE, + "Maximum supported adjustment", + "support-ticket-1047", + "manual-grant-key-006", + ) + + shouldThrow { + service.grantManual( + TEST_OPERATOR_ID, + userId, + 1, + "Overflow adjustment", + "support-ticket-1048", + "manual-grant-key-007", + ) + } + + store.balance(userId) shouldBeExactly Long.MAX_VALUE + store.manualGrants shouldHaveSize 1 + } + test("balance overflow rolls back without appending a ledger entry") { val store = storeWithRates(now) val service = service(store, now) @@ -574,3 +729,5 @@ private fun llmRate(now: Instant) = CreditRateVersion( outputCreditsNumerator = 3, outputTokensDenominator = 1_000, ) + +private val TEST_OPERATOR_ID = UUID.fromString("11111111-1111-1111-1111-111111111111") diff --git a/src/test/kotlin/com/osglab/account/features/credits/TestBillingStore.kt b/src/test/kotlin/com/osglab/account/features/credits/TestBillingStore.kt index 00e559a..1d1fcf1 100644 --- a/src/test/kotlin/com/osglab/account/features/credits/TestBillingStore.kt +++ b/src/test/kotlin/com/osglab/account/features/credits/TestBillingStore.kt @@ -1,11 +1,14 @@ package com.osglab.account.features.credits +import com.osglab.account.features.admin.grants.repositories.AdminCreditGrantRepository +import com.osglab.account.features.admin.models.NewAdminAuditEvent import com.osglab.account.features.credits.domain.CreditAccount import com.osglab.account.features.credits.domain.CreditNotFound import com.osglab.account.features.credits.domain.CreditRateVersion import com.osglab.account.features.credits.domain.CreditReservation import com.osglab.account.features.credits.domain.CreditUsageRecord import com.osglab.account.features.credits.domain.LedgerEntry +import com.osglab.account.features.credits.domain.ManualCreditGrant import com.osglab.account.features.credits.domain.UsageKind import com.osglab.account.features.credits.repositories.BillingTransactionRunner import com.osglab.account.features.credits.repositories.BillingUnitOfWork @@ -25,7 +28,10 @@ import kotlin.concurrent.withLock class TestBillingStore : BillingTransactionRunner, BillingUnitOfWork { private val lock = ReentrantLock() private val accounts = mutableMapOf() + val registeredUsers = mutableSetOf() val ledger = mutableListOf() + val manualGrants = mutableListOf() + val adminAudits = mutableListOf() val usageRecords = mutableListOf() val reservations = mutableMapOf() val rates = mutableMapOf() @@ -56,11 +62,17 @@ class TestBillingStore : BillingTransactionRunner, BillingUnitOfWork { override val credits: CreditsRepository = Credits() override val referrals: ReferralsRepository = Referrals() + override val adminCreditGrants: AdminCreditGrantRepository = AdminCreditGrants() + + var failNextManualGrantInsert = false override suspend fun inTransaction(block: (BillingUnitOfWork) -> T): T = lock.withLock { val accountSnapshot = accounts.toMap() + val registeredUserSnapshot = registeredUsers.toSet() val ledgerSnapshot = ledger.toList() + val manualGrantSnapshot = manualGrants.toList() + val adminAuditSnapshot = adminAudits.toList() val usageSnapshot = usageRecords.toList() val reservationSnapshot = reservations.toMap() val codeSnapshot = codes.toMap() @@ -70,7 +82,10 @@ class TestBillingStore : BillingTransactionRunner, BillingUnitOfWork { block(this) } catch (failure: Throwable) { accounts.replaceWith(accountSnapshot) + registeredUsers.replaceWith(registeredUserSnapshot) ledger.replaceWith(ledgerSnapshot) + manualGrants.replaceWith(manualGrantSnapshot) + adminAudits.replaceWith(adminAuditSnapshot) usageRecords.replaceWith(usageSnapshot) reservations.replaceWith(reservationSnapshot) codes.replaceWith(codeSnapshot) @@ -83,6 +98,9 @@ class TestBillingStore : BillingTransactionRunner, BillingUnitOfWork { fun balance(userId: UUID): Long = lock.withLock { accounts[userId]?.balance ?: 0 } private inner class Credits : CreditsRepository { + override fun accountExists(userId: UUID): Boolean = + userId in registeredUsers || userId in accounts + override fun createAccountIfAbsent(userId: UUID, now: Instant) { accounts.putIfAbsent(userId, CreditAccount(userId, 0, now)) } @@ -157,6 +175,28 @@ class TestBillingStore : BillingTransactionRunner, BillingUnitOfWork { } } + private inner class AdminCreditGrants : AdminCreditGrantRepository { + override fun findByIdempotencyKey( + idempotencyKey: String, + ): ManualCreditGrant? = + manualGrants.singleOrNull { it.idempotencyKey == idempotencyKey } + + override fun insertAudit(event: NewAdminAuditEvent) { + check(adminAudits.none { it.id == event.id }) + adminAudits += event + } + + override fun insert(grant: ManualCreditGrant) { + if (failNextManualGrantInsert) { + failNextManualGrantInsert = false + error("Simulated manual grant audit failure") + } + check(findByIdempotencyKey(grant.idempotencyKey) == null) + check(manualGrants.none { it.id == grant.id || it.ledgerEntryId == grant.ledgerEntryId }) + manualGrants += grant + } + } + private inner class Referrals : ReferralsRepository { override fun findCodeByOwner(ownerUserId: UUID, campaignId: UUID?): ReferralCode? = codes.values @@ -238,6 +278,11 @@ private fun MutableMap.replaceWith(snapshot: Map) { putAll(snapshot) } +private fun MutableSet.replaceWith(snapshot: Set) { + clear() + addAll(snapshot) +} + private fun MutableList.replaceWith(snapshot: List) { clear() addAll(snapshot) diff --git a/src/test/kotlin/com/osglab/account/integration/MySqlSecurityIntegrationTest.kt b/src/test/kotlin/com/osglab/account/integration/MySqlSecurityIntegrationTest.kt index 24c0cc2..abb148f 100644 --- a/src/test/kotlin/com/osglab/account/integration/MySqlSecurityIntegrationTest.kt +++ b/src/test/kotlin/com/osglab/account/integration/MySqlSecurityIntegrationTest.kt @@ -20,6 +20,7 @@ import com.osglab.account.features.referrals.services.UserRegistrationTimeProvid import io.kotest.assertions.throwables.shouldThrow import io.kotest.assertions.withClue import io.kotest.core.spec.style.FunSpec +import io.kotest.matchers.collections.shouldHaveSize import io.kotest.matchers.ints.shouldBeExactly import io.kotest.matchers.longs.shouldBeExactly import io.kotest.matchers.nulls.shouldBeNull @@ -208,8 +209,21 @@ class MySqlSecurityIntegrationTest : FunSpec({ ReferralRewardConfig(inviterCredits = 30, inviteeCredits = 30), ) val concurrentUser = UUID.randomUUID() + val adminOperator = UUID.randomUUID() connection().use { insertAccount(it, concurrentUser, "concurrent-sub", identity.ofAppleSubject("concurrent-sub")) + it.createStatement().use { statement -> + statement.executeUpdate( + """ + INSERT INTO admin_operators ( + id, username, password_hash, encrypted_totp_secret, role + ) VALUES ( + '$adminOperator', 'integration-admin', 'unused-hash', + 'unused-secret', 'SUPER_ADMIN' + ) + """.trimIndent(), + ) + } } credits.grantSignupTrial(concurrentUser, 100, "integration-signup-concurrent") val reservationResults = coroutineScope { @@ -236,6 +250,34 @@ class MySqlSecurityIntegrationTest : FunSpec({ reservationResults.count { it.isSuccess } shouldBeExactly 1 } credits.getAccount(concurrentUser).balance shouldBeExactly 40 + val manualGrantResults = coroutineScope { + List(4) { + async(Dispatchers.Default) { + credits.grantManual( + operatorId = adminOperator, + userId = concurrentUser, + credits = 25, + reason = "Integration support adjustment", + requestId = "integration-audit-reference", + idempotencyKey = "integration-manual-grant", + ) + } + }.awaitAll() + } + manualGrantResults.map { it.grant.id }.distinct() shouldHaveSize 1 + credits.getAccount(concurrentUser).balance shouldBeExactly 65 + connection().use { connection -> + count( + connection, + "admin_credit_grants", + "account_id = '$concurrentUser'", + ) shouldBeExactly 1 + count( + connection, + "credit_ledger", + "user_id = '$concurrentUser' AND entry_type = 'MANUAL_GRANT'", + ) shouldBeExactly 1 + } val inviter = UUID.randomUUID() val invitee = UUID.randomUUID() diff --git a/src/test/kotlin/com/osglab/account/tools/AdminCredentialGeneratorTest.kt b/src/test/kotlin/com/osglab/account/tools/AdminCredentialGeneratorTest.kt new file mode 100644 index 0000000..b06f05d --- /dev/null +++ b/src/test/kotlin/com/osglab/account/tools/AdminCredentialGeneratorTest.kt @@ -0,0 +1,29 @@ +package com.osglab.account.tools + +import io.kotest.matchers.string.shouldContain +import java.nio.file.Files +import kotlin.test.Test +import kotlin.test.assertFailsWith + +class AdminCredentialGeneratorTest { + @Test + fun `generator creates separate runtime and operator files without overwriting`() { + val directory = Files.createTempDirectory("admin-credentials-test") + val runtime = directory.resolve("runtime.env") + val handoff = directory.resolve("handoff.txt") + + AdminCredentialGenerator.main( + arrayOf("Owner", runtime.toString(), handoff.toString()), + ) + + Files.readString(runtime) shouldContain "ADMIN_BOOTSTRAP_ENABLED=true" + Files.readString(runtime) shouldContain "ADMIN_BOOTSTRAP_USERNAME=owner" + Files.readString(runtime) shouldContain "ADMIN_BOOTSTRAP_PASSWORD_HASH='\$argon2id\$" + Files.readString(handoff) shouldContain "认证器 URI:otpauth://totp/" + assertFailsWith { + AdminCredentialGenerator.main( + arrayOf("owner", runtime.toString(), handoff.toString()), + ) + } + } +}