From f43e506c51f07c68d06bf9f08ae2823284aa1356 Mon Sep 17 00:00:00 2001 From: nguyenhung Date: Sun, 3 May 2026 15:46:26 +0700 Subject: [PATCH] submit problem 4-6 --- src/problem4/.keep | 0 src/problem4/README.md | 35 + src/problem4/package.json | 19 + src/problem4/pnpm-lock.yaml | 1099 +++++++ src/problem4/sum_to_n.test.ts | 65 + src/problem4/sum_to_n.ts | 97 + src/problem4/tsconfig.json | 19 + src/problem5/.env.example | 17 + src/problem5/.gitignore | 7 + src/problem5/.keep | 0 src/problem5/README.md | 308 ++ src/problem5/package.json | 48 + src/problem5/pnpm-lock.yaml | 2816 +++++++++++++++++ src/problem5/src/app.ts | 110 + src/problem5/src/config.ts | 49 + src/problem5/src/db/connection.ts | 21 + src/problem5/src/db/migrate.ts | 31 + src/problem5/src/errors.ts | 74 + src/problem5/src/logger.ts | 27 + src/problem5/src/middleware/error-handler.ts | 72 + src/problem5/src/middleware/not-found.ts | 15 + src/problem5/src/middleware/request-id.ts | 22 + .../src/resources/resource.controller.ts | 68 + src/problem5/src/resources/resource.repo.ts | 203 ++ src/problem5/src/resources/resource.routes.ts | 22 + src/problem5/src/resources/resource.schema.ts | 54 + .../src/resources/resource.service.ts | 66 + src/problem5/src/server.ts | 59 + src/problem5/src/types/express.d.ts | 10 + src/problem5/tests/resources.test.ts | 279 ++ src/problem5/tsconfig.build.json | 9 + src/problem5/tsconfig.json | 20 + src/problem6/FLOW.md | 256 ++ src/problem6/IMPROVEMENTS.md | 316 ++ src/problem6/README.md | 757 +++++ 35 files changed, 7070 insertions(+) delete mode 100644 src/problem4/.keep create mode 100644 src/problem4/README.md create mode 100644 src/problem4/package.json create mode 100644 src/problem4/pnpm-lock.yaml create mode 100644 src/problem4/sum_to_n.test.ts create mode 100644 src/problem4/sum_to_n.ts create mode 100644 src/problem4/tsconfig.json create mode 100644 src/problem5/.env.example create mode 100644 src/problem5/.gitignore delete mode 100644 src/problem5/.keep create mode 100644 src/problem5/README.md create mode 100644 src/problem5/package.json create mode 100644 src/problem5/pnpm-lock.yaml create mode 100644 src/problem5/src/app.ts create mode 100644 src/problem5/src/config.ts create mode 100644 src/problem5/src/db/connection.ts create mode 100644 src/problem5/src/db/migrate.ts create mode 100644 src/problem5/src/errors.ts create mode 100644 src/problem5/src/logger.ts create mode 100644 src/problem5/src/middleware/error-handler.ts create mode 100644 src/problem5/src/middleware/not-found.ts create mode 100644 src/problem5/src/middleware/request-id.ts create mode 100644 src/problem5/src/resources/resource.controller.ts create mode 100644 src/problem5/src/resources/resource.repo.ts create mode 100644 src/problem5/src/resources/resource.routes.ts create mode 100644 src/problem5/src/resources/resource.schema.ts create mode 100644 src/problem5/src/resources/resource.service.ts create mode 100644 src/problem5/src/server.ts create mode 100644 src/problem5/src/types/express.d.ts create mode 100644 src/problem5/tests/resources.test.ts create mode 100644 src/problem5/tsconfig.build.json create mode 100644 src/problem5/tsconfig.json create mode 100644 src/problem6/FLOW.md create mode 100644 src/problem6/IMPROVEMENTS.md create mode 100644 src/problem6/README.md diff --git a/src/problem4/.keep b/src/problem4/.keep deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/src/problem4/README.md b/src/problem4/README.md new file mode 100644 index 0000000000..81df62f347 --- /dev/null +++ b/src/problem4/README.md @@ -0,0 +1,35 @@ +# Problem 4 — Three Ways to Sum to N (TypeScript) + +Three idiomatic implementations of `sum_to_n(n)`. + +| File | Approach | Time | Space | When to use | +| ---------------- | ----------------------------------- | -------- | -------- | ---------------------------------------------------- | +| `sumToN_iter` | iterative accumulator | **O(n)** | **O(1)** | clearest implementation; fine for any reasonable `n` | +| `sumToN_formula` | closed-form Gauss `n*(n+1)/2` | **O(1)** | **O(1)** | best — always prefer in production | +| `sumToN_reduce` | functional `Array.from(...).reduce` | **O(n)** | **O(n)** | composable but allocates; demonstrative | + +All three handle: + +- `n = 0` → `0` +- positive `n` +- **negative `n`** (sums `-1 + -2 + … + n`) +- non-integer / `NaN` input → `TypeError` + +The closed-form implementation also guards against `n` large enough that +`n * (n+1)` would exceed `Number.MAX_SAFE_INTEGER`, throwing `RangeError` +instead of returning a silently wrong number. + +## Run + +```bash +pnpm install +pnpm test # vitest run +pnpm typecheck # tsc --noEmit +``` + +## Notes + +- Source: `sum_to_n.ts` +- Tests : `sum_to_n.test.ts` — parametric, asserts all three impls agree on a battery of inputs +- The three impls are exported as named exports so a consumer can pick whichever + suits their constraints. diff --git a/src/problem4/package.json b/src/problem4/package.json new file mode 100644 index 0000000000..bf6f9e9496 --- /dev/null +++ b/src/problem4/package.json @@ -0,0 +1,19 @@ +{ + "name": "problem4-sum-to-n", + "version": "1.0.0", + "private": true, + "description": "Three TypeScript implementations of sum_to_n with complexity analysis.", + "type": "module", + "scripts": { + "test": "vitest run", + "test:watch": "vitest", + "typecheck": "tsc --noEmit" + }, + "devDependencies": { + "typescript": "^5.4.5", + "vitest": "^1.6.0" + }, + "engines": { + "node": ">=20" + } +} diff --git a/src/problem4/pnpm-lock.yaml b/src/problem4/pnpm-lock.yaml new file mode 100644 index 0000000000..e67639899e --- /dev/null +++ b/src/problem4/pnpm-lock.yaml @@ -0,0 +1,1099 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + devDependencies: + typescript: + specifier: ^5.4.5 + version: 5.9.3 + vitest: + specifier: ^1.6.0 + version: 1.6.1 + +packages: + + '@esbuild/aix-ppc64@0.21.5': + resolution: {integrity: sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==} + engines: {node: '>=12'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.21.5': + resolution: {integrity: sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==} + engines: {node: '>=12'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.21.5': + resolution: {integrity: sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==} + engines: {node: '>=12'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.21.5': + resolution: {integrity: sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==} + engines: {node: '>=12'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.21.5': + resolution: {integrity: sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==} + engines: {node: '>=12'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.21.5': + resolution: {integrity: sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==} + engines: {node: '>=12'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.21.5': + resolution: {integrity: sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==} + engines: {node: '>=12'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.21.5': + resolution: {integrity: sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.21.5': + resolution: {integrity: sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==} + engines: {node: '>=12'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.21.5': + resolution: {integrity: sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==} + engines: {node: '>=12'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.21.5': + resolution: {integrity: sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==} + engines: {node: '>=12'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.21.5': + resolution: {integrity: sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==} + engines: {node: '>=12'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.21.5': + resolution: {integrity: sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==} + engines: {node: '>=12'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.21.5': + resolution: {integrity: sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==} + engines: {node: '>=12'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.21.5': + resolution: {integrity: sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==} + engines: {node: '>=12'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.21.5': + resolution: {integrity: sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==} + engines: {node: '>=12'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.21.5': + resolution: {integrity: sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-x64@0.21.5': + resolution: {integrity: sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==} + engines: {node: '>=12'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-x64@0.21.5': + resolution: {integrity: sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==} + engines: {node: '>=12'} + cpu: [x64] + os: [openbsd] + + '@esbuild/sunos-x64@0.21.5': + resolution: {integrity: sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==} + engines: {node: '>=12'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.21.5': + resolution: {integrity: sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==} + engines: {node: '>=12'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.21.5': + resolution: {integrity: sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==} + engines: {node: '>=12'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.21.5': + resolution: {integrity: sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==} + engines: {node: '>=12'} + cpu: [x64] + os: [win32] + + '@jest/schemas@29.6.3': + resolution: {integrity: sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + + '@rollup/rollup-android-arm-eabi@4.60.2': + resolution: {integrity: sha512-dnlp69efPPg6Uaw2dVqzWRfAWRnYVb1XJ8CyyhIbZeaq4CA5/mLeZ1IEt9QqQxmbdvagjLIm2ZL8BxXv5lH4Yw==} + cpu: [arm] + os: [android] + + '@rollup/rollup-android-arm64@4.60.2': + resolution: {integrity: sha512-OqZTwDRDchGRHHm/hwLOL7uVPB9aUvI0am/eQuWMNyFHf5PSEQmyEeYYheA0EPPKUO/l0uigCp+iaTjoLjVoHg==} + cpu: [arm64] + os: [android] + + '@rollup/rollup-darwin-arm64@4.60.2': + resolution: {integrity: sha512-UwRE7CGpvSVEQS8gUMBe1uADWjNnVgP3Iusyda1nSRwNDCsRjnGc7w6El6WLQsXmZTbLZx9cecegumcitNfpmA==} + cpu: [arm64] + os: [darwin] + + '@rollup/rollup-darwin-x64@4.60.2': + resolution: {integrity: sha512-gjEtURKLCC5VXm1I+2i1u9OhxFsKAQJKTVB8WvDAHF+oZlq0GTVFOlTlO1q3AlCTE/DF32c16ESvfgqR7343/g==} + cpu: [x64] + os: [darwin] + + '@rollup/rollup-freebsd-arm64@4.60.2': + resolution: {integrity: sha512-Bcl6CYDeAgE70cqZaMojOi/eK63h5Me97ZqAQoh77VPjMysA/4ORQBRGo3rRy45x4MzVlU9uZxs8Uwy7ZaKnBw==} + cpu: [arm64] + os: [freebsd] + + '@rollup/rollup-freebsd-x64@4.60.2': + resolution: {integrity: sha512-LU+TPda3mAE2QB0/Hp5VyeKJivpC6+tlOXd1VMoXV/YFMvk/MNk5iXeBfB4MQGRWyOYVJ01625vjkr0Az98OJQ==} + cpu: [x64] + os: [freebsd] + + '@rollup/rollup-linux-arm-gnueabihf@4.60.2': + resolution: {integrity: sha512-2QxQrM+KQ7DAW4o22j+XZ6RKdxjLD7BOWTP0Bv0tmjdyhXSsr2Ul1oJDQqh9Zf5qOwTuTc7Ek83mOFaKnodPjg==} + cpu: [arm] + os: [linux] + + '@rollup/rollup-linux-arm-musleabihf@4.60.2': + resolution: {integrity: sha512-TbziEu2DVsTEOPif2mKWkMeDMLoYjx95oESa9fkQQK7r/Orta0gnkcDpzwufEcAO2BLBsD7mZkXGFqEdMRRwfw==} + cpu: [arm] + os: [linux] + + '@rollup/rollup-linux-arm64-gnu@4.60.2': + resolution: {integrity: sha512-bO/rVDiDUuM2YfuCUwZ1t1cP+/yqjqz+Xf2VtkdppefuOFS2OSeAfgafaHNkFn0t02hEyXngZkxtGqXcXwO8Rg==} + cpu: [arm64] + os: [linux] + + '@rollup/rollup-linux-arm64-musl@4.60.2': + resolution: {integrity: sha512-hr26p7e93Rl0Za+JwW7EAnwAvKkehh12BU1Llm9Ykiibg4uIr2rbpxG9WCf56GuvidlTG9KiiQT/TXT1yAWxTA==} + cpu: [arm64] + os: [linux] + + '@rollup/rollup-linux-loong64-gnu@4.60.2': + resolution: {integrity: sha512-pOjB/uSIyDt+ow3k/RcLvUAOGpysT2phDn7TTUB3n75SlIgZzM6NKAqlErPhoFU+npgY3/n+2HYIQVbF70P9/A==} + cpu: [loong64] + os: [linux] + + '@rollup/rollup-linux-loong64-musl@4.60.2': + resolution: {integrity: sha512-2/w+q8jszv9Ww1c+6uJT3OwqhdmGP2/4T17cu8WuwyUuuaCDDJ2ojdyYwZzCxx0GcsZBhzi3HmH+J5pZNXnd+Q==} + cpu: [loong64] + os: [linux] + + '@rollup/rollup-linux-ppc64-gnu@4.60.2': + resolution: {integrity: sha512-11+aL5vKheYgczxtPVVRhdptAM2H7fcDR5Gw4/bTcteuZBlH4oP9f5s9zYO9aGZvoGeBpqXI/9TZZihZ609wKw==} + cpu: [ppc64] + os: [linux] + + '@rollup/rollup-linux-ppc64-musl@4.60.2': + resolution: {integrity: sha512-i16fokAGK46IVZuV8LIIwMdtqhin9hfYkCh8pf8iC3QU3LpwL+1FSFGej+O7l3E/AoknL6Dclh2oTdnRMpTzFQ==} + cpu: [ppc64] + os: [linux] + + '@rollup/rollup-linux-riscv64-gnu@4.60.2': + resolution: {integrity: sha512-49FkKS6RGQoriDSK/6E2GkAsAuU5kETFCh7pG4yD/ylj9rKhTmO3elsnmBvRD4PgJPds5W2PkhC82aVwmUcJ7A==} + cpu: [riscv64] + os: [linux] + + '@rollup/rollup-linux-riscv64-musl@4.60.2': + resolution: {integrity: sha512-mjYNkHPfGpUR00DuM1ZZIgs64Hpf4bWcz9Z41+4Q+pgDx73UwWdAYyf6EG/lRFldmdHHzgrYyge5akFUW0D3mQ==} + cpu: [riscv64] + os: [linux] + + '@rollup/rollup-linux-s390x-gnu@4.60.2': + resolution: {integrity: sha512-ALyvJz965BQk8E9Al/JDKKDLH2kfKFLTGMlgkAbbYtZuJt9LU8DW3ZoDMCtQpXAltZxwBHevXz5u+gf0yA0YoA==} + cpu: [s390x] + os: [linux] + + '@rollup/rollup-linux-x64-gnu@4.60.2': + resolution: {integrity: sha512-UQjrkIdWrKI626Du8lCQ6MJp/6V1LAo2bOK9OTu4mSn8GGXIkPXk/Vsp4bLHCd9Z9Iz2OTEaokUE90VweJgIYQ==} + cpu: [x64] + os: [linux] + + '@rollup/rollup-linux-x64-musl@4.60.2': + resolution: {integrity: sha512-bTsRGj6VlSdn/XD4CGyzMnzaBs9bsRxy79eTqTCBsA8TMIEky7qg48aPkvJvFe1HyzQ5oMZdg7AnVlWQSKLTnw==} + cpu: [x64] + os: [linux] + + '@rollup/rollup-openbsd-x64@4.60.2': + resolution: {integrity: sha512-6d4Z3534xitaA1FcMWP7mQPq5zGwBmGbhphh2DwaA1aNIXUu3KTOfwrWpbwI4/Gr0uANo7NTtaykFyO2hPuFLg==} + cpu: [x64] + os: [openbsd] + + '@rollup/rollup-openharmony-arm64@4.60.2': + resolution: {integrity: sha512-NetAg5iO2uN7eB8zE5qrZ3CSil+7IJt4WDFLcC75Ymywq1VZVD6qJ6EvNLjZ3rEm6gB7XW5JdT60c6MN35Z85Q==} + cpu: [arm64] + os: [openharmony] + + '@rollup/rollup-win32-arm64-msvc@4.60.2': + resolution: {integrity: sha512-NCYhOotpgWZ5kdxCZsv6Iudx0wX8980Q/oW4pNFNihpBKsDbEA1zpkfxJGC0yugsUuyDZ7gL37dbzwhR0VI7pQ==} + cpu: [arm64] + os: [win32] + + '@rollup/rollup-win32-ia32-msvc@4.60.2': + resolution: {integrity: sha512-RXsaOqXxfoUBQoOgvmmijVxJnW2IGB0eoMO7F8FAjaj0UTywUO/luSqimWBJn04WNgUkeNhh7fs7pESXajWmkg==} + cpu: [ia32] + os: [win32] + + '@rollup/rollup-win32-x64-gnu@4.60.2': + resolution: {integrity: sha512-qdAzEULD+/hzObedtmV6iBpdL5TIbKVztGiK7O3/KYSf+HIzU257+MX1EXJcyIiDbMAqmbwaufcYPvyRryeZtA==} + cpu: [x64] + os: [win32] + + '@rollup/rollup-win32-x64-msvc@4.60.2': + resolution: {integrity: sha512-Nd/SgG27WoA9e+/TdK74KnHz852TLa94ovOYySo/yMPuTmpckK/jIF2jSwS3g7ELSKXK13/cVdmg1Z/DaCWKxA==} + cpu: [x64] + os: [win32] + + '@sinclair/typebox@0.27.10': + resolution: {integrity: sha512-MTBk/3jGLNB2tVxv6uLlFh1iu64iYOQ2PbdOSK3NW8JZsmlaOh2q6sdtKowBhfw8QFLmYNzTW4/oK4uATIi6ZA==} + + '@types/estree@1.0.8': + resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} + + '@vitest/expect@1.6.1': + resolution: {integrity: sha512-jXL+9+ZNIJKruofqXuuTClf44eSpcHlgj3CiuNihUF3Ioujtmc0zIa3UJOW5RjDK1YLBJZnWBlPuqhYycLioog==} + + '@vitest/runner@1.6.1': + resolution: {integrity: sha512-3nSnYXkVkf3mXFfE7vVyPmi3Sazhb/2cfZGGs0JRzFsPFvAMBEcrweV1V1GsrstdXeKCTXlJbvnQwGWgEIHmOA==} + + '@vitest/snapshot@1.6.1': + resolution: {integrity: sha512-WvidQuWAzU2p95u8GAKlRMqMyN1yOJkGHnx3M1PL9Raf7AQ1kwLKg04ADlCa3+OXUZE7BceOhVZiuWAbzCKcUQ==} + + '@vitest/spy@1.6.1': + resolution: {integrity: sha512-MGcMmpGkZebsMZhbQKkAf9CX5zGvjkBTqf8Zx3ApYWXr3wG+QvEu2eXWfnIIWYSJExIp4V9FCKDEeygzkYrXMw==} + + '@vitest/utils@1.6.1': + resolution: {integrity: sha512-jOrrUvXM4Av9ZWiG1EajNto0u96kWAhJ1LmPmJhXXQx/32MecEKd10pOLYgS2BQx1TgkGhloPU1ArDW2vvaY6g==} + + acorn-walk@8.3.5: + resolution: {integrity: sha512-HEHNfbars9v4pgpW6SO1KSPkfoS0xVOM/9UzkJltjlsHZmJasxg8aXkuZa7SMf8vKGIBhpUsPluQSqhJFCqebw==} + engines: {node: '>=0.4.0'} + + acorn@8.16.0: + resolution: {integrity: sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==} + engines: {node: '>=0.4.0'} + hasBin: true + + ansi-styles@5.2.0: + resolution: {integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==} + engines: {node: '>=10'} + + assertion-error@1.1.0: + resolution: {integrity: sha512-jgsaNduz+ndvGyFt3uSuWqvy4lCnIJiovtouQN5JZHOKCS2QuhEdbcQHFhVksz2N2U9hXJo8odG7ETyWlEeuDw==} + + cac@6.7.14: + resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==} + engines: {node: '>=8'} + + chai@4.5.0: + resolution: {integrity: sha512-RITGBfijLkBddZvnn8jdqoTypxvqbOLYQkGGxXzeFjVHvudaPw0HNFD9x928/eUwYWd2dPCugVqspGALTZZQKw==} + engines: {node: '>=4'} + + check-error@1.0.3: + resolution: {integrity: sha512-iKEoDYaRmd1mxM90a2OEfWhjsjPpYPuQ+lMYsoxB126+t8fw7ySEO48nmDg5COTjxDI65/Y2OWpeEHk3ZOe8zg==} + + confbox@0.1.8: + resolution: {integrity: sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==} + + cross-spawn@7.0.6: + resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} + engines: {node: '>= 8'} + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + deep-eql@4.1.4: + resolution: {integrity: sha512-SUwdGfqdKOwxCPeVYjwSyRpJ7Z+fhpwIAtmCUdZIWZ/YP5R9WAsyuSgpLVDi9bjWoN2LXHNss/dk3urXtdQxGg==} + engines: {node: '>=6'} + + diff-sequences@29.6.3: + resolution: {integrity: sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + esbuild@0.21.5: + resolution: {integrity: sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==} + engines: {node: '>=12'} + hasBin: true + + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + + execa@8.0.1: + resolution: {integrity: sha512-VyhnebXciFV2DESc+p6B+y0LjSm0krU4OgJN44qFAhBY0TJ+1V61tYD2+wHusZ6F9n5K+vl8k0sTy7PEfV4qpg==} + engines: {node: '>=16.17'} + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + get-func-name@2.0.2: + resolution: {integrity: sha512-8vXOvuE167CtIc3OyItco7N/dpRtBbYOsPsXCz7X/PMnlGjYjSGuZJgM1Y7mmew7BKf9BqvLX2tnOVy1BBUsxQ==} + + get-stream@8.0.1: + resolution: {integrity: sha512-VaUJspBffn/LMCJVoMvSAdmscJyS1auj5Zulnn5UoYcY531UWmdwhRWkcGKnGU93m5HSXP9LP2usOryrBtQowA==} + engines: {node: '>=16'} + + human-signals@5.0.0: + resolution: {integrity: sha512-AXcZb6vzzrFAUE61HnN4mpLqd/cSIwNQjtNWR0euPm6y0iqx3G4gOXaIDdtdDwZmhwe82LA6+zinmW4UBWVePQ==} + engines: {node: '>=16.17.0'} + + is-stream@3.0.0: + resolution: {integrity: sha512-LnQR4bZ9IADDRSkvpqMGvt/tEJWclzklNgSw48V5EAaAeDd6qGvN8ei6k5p0tvxSR171VmGyHuTiAOfxAbr8kA==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + + isexe@2.0.0: + resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + + js-tokens@9.0.1: + resolution: {integrity: sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==} + + local-pkg@0.5.1: + resolution: {integrity: sha512-9rrA30MRRP3gBD3HTGnC6cDFpaE1kVDWxWgqWJUN0RvDNAo+Nz/9GxB+nHOH0ifbVFy0hSA1V6vFDvnx54lTEQ==} + engines: {node: '>=14'} + + loupe@2.3.7: + resolution: {integrity: sha512-zSMINGVYkdpYSOBmLi0D1Uo7JU9nVdQKrHxC8eYlV+9YKK9WePqAlL7lSlorG/U2Fw1w0hTBmaa/jrQ3UbPHtA==} + + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + + merge-stream@2.0.0: + resolution: {integrity: sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==} + + mimic-fn@4.0.0: + resolution: {integrity: sha512-vqiC06CuhBTUdZH+RYl8sFrL096vA45Ok5ISO6sE/Mr1jRbGH4Csnhi8f3wKVl7x8mO4Au7Ir9D3Oyv1VYMFJw==} + engines: {node: '>=12'} + + mlly@1.8.2: + resolution: {integrity: sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA==} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + nanoid@3.3.12: + resolution: {integrity: sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + npm-run-path@5.3.0: + resolution: {integrity: sha512-ppwTtiJZq0O/ai0z7yfudtBpWIoxM8yE6nHi1X47eFR2EWORqfbu6CnPlNsjeN683eT0qG6H/Pyf9fCcvjnnnQ==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + + onetime@6.0.0: + resolution: {integrity: sha512-1FlR+gjXK7X+AsAHso35MnyN5KqGwJRi/31ft6x0M194ht7S+rWAvd7PHss9xSKMzE0asv1pyIHaJYq+BbacAQ==} + engines: {node: '>=12'} + + p-limit@5.0.0: + resolution: {integrity: sha512-/Eaoq+QyLSiXQ4lyYV23f14mZRQcXnxfHrN0vCai+ak9G0pp9iEQukIIZq5NccEvwRB8PUnZT0KsOoDCINS1qQ==} + engines: {node: '>=18'} + + path-key@3.1.1: + resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} + engines: {node: '>=8'} + + path-key@4.0.0: + resolution: {integrity: sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==} + engines: {node: '>=12'} + + pathe@1.1.2: + resolution: {integrity: sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==} + + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + + pathval@1.1.1: + resolution: {integrity: sha512-Dp6zGqpTdETdR63lehJYPeIOqpiNBNtc7BpWSLrOje7UaIsE5aY92r/AunQA7rsXvet3lrJ3JnZX29UPTKXyKQ==} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + pkg-types@1.3.1: + resolution: {integrity: sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==} + + postcss@8.5.13: + resolution: {integrity: sha512-qif0+jGGZoLWdHey3UFHHWP0H7Gbmsk8T5VEqyYFbWqPr1XqvLGBbk/sl8V5exGmcYJklJOhOQq1pV9IcsiFag==} + engines: {node: ^10 || ^12 || >=14} + + pretty-format@29.7.0: + resolution: {integrity: sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + react-is@18.3.1: + resolution: {integrity: sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==} + + rollup@4.60.2: + resolution: {integrity: sha512-J9qZyW++QK/09NyN/zeO0dG/1GdGfyp9lV8ajHnRVLfo/uFsbji5mHnDgn/qYdUHyCkM2N+8VyspgZclfAh0eQ==} + engines: {node: '>=18.0.0', npm: '>=8.0.0'} + hasBin: true + + shebang-command@2.0.0: + resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} + engines: {node: '>=8'} + + shebang-regex@3.0.0: + resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} + engines: {node: '>=8'} + + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + + signal-exit@4.1.0: + resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} + engines: {node: '>=14'} + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + + std-env@3.10.0: + resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} + + strip-final-newline@3.0.0: + resolution: {integrity: sha512-dOESqjYr96iWYylGObzd39EuNTa5VJxyvVAEm5Jnh7KGo75V43Hk1odPQkNDyXNmUR6k+gEiDVXnjB8HJ3crXw==} + engines: {node: '>=12'} + + strip-literal@2.1.1: + resolution: {integrity: sha512-631UJ6O00eNGfMiWG78ck80dfBab8X6IVFB51jZK5Icd7XAs60Z5y7QdSd/wGIklnWvRbUNloVzhOKKmutxQ6Q==} + + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + + tinypool@0.8.4: + resolution: {integrity: sha512-i11VH5gS6IFeLY3gMBQ00/MmLncVP7JLXOw1vlgkytLmJK7QnEr7NXf0LBdxfmNPAeyetukOk0bOYrJrFGjYJQ==} + engines: {node: '>=14.0.0'} + + tinyspy@2.2.1: + resolution: {integrity: sha512-KYad6Vy5VDWV4GH3fjpseMQ/XU2BhIYP7Vzd0LG44qRWm/Yt2WCOTicFdvmgo6gWaqooMQCawTtILVQJupKu7A==} + engines: {node: '>=14.0.0'} + + type-detect@4.1.0: + resolution: {integrity: sha512-Acylog8/luQ8L7il+geoSxhEkazvkslg7PSNKOX59mbB9cOveP5aq9h74Y7YU8yDpJwetzQQrfIwtf4Wp4LKcw==} + engines: {node: '>=4'} + + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true + + ufo@1.6.4: + resolution: {integrity: sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA==} + + vite-node@1.6.1: + resolution: {integrity: sha512-YAXkfvGtuTzwWbDSACdJSg4A4DZiAqckWe90Zapc/sEX3XvHcw1NdurM/6od8J207tSDqNbSsgdCacBgvJKFuA==} + engines: {node: ^18.0.0 || >=20.0.0} + hasBin: true + + vite@5.4.21: + resolution: {integrity: sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==} + engines: {node: ^18.0.0 || >=20.0.0} + hasBin: true + peerDependencies: + '@types/node': ^18.0.0 || >=20.0.0 + less: '*' + lightningcss: ^1.21.0 + sass: '*' + sass-embedded: '*' + stylus: '*' + sugarss: '*' + terser: ^5.4.0 + peerDependenciesMeta: + '@types/node': + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + + vitest@1.6.1: + resolution: {integrity: sha512-Ljb1cnSJSivGN0LqXd/zmDbWEM0RNNg2t1QW/XUhYl/qPqyu7CsqeWtqQXHVaJsecLPuDoak2oJcZN2QoRIOag==} + engines: {node: ^18.0.0 || >=20.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@types/node': ^18.0.0 || >=20.0.0 + '@vitest/browser': 1.6.1 + '@vitest/ui': 1.6.1 + happy-dom: '*' + jsdom: '*' + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@types/node': + optional: true + '@vitest/browser': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + + which@2.0.2: + resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} + engines: {node: '>= 8'} + hasBin: true + + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + + yocto-queue@1.2.2: + resolution: {integrity: sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ==} + engines: {node: '>=12.20'} + +snapshots: + + '@esbuild/aix-ppc64@0.21.5': + optional: true + + '@esbuild/android-arm64@0.21.5': + optional: true + + '@esbuild/android-arm@0.21.5': + optional: true + + '@esbuild/android-x64@0.21.5': + optional: true + + '@esbuild/darwin-arm64@0.21.5': + optional: true + + '@esbuild/darwin-x64@0.21.5': + optional: true + + '@esbuild/freebsd-arm64@0.21.5': + optional: true + + '@esbuild/freebsd-x64@0.21.5': + optional: true + + '@esbuild/linux-arm64@0.21.5': + optional: true + + '@esbuild/linux-arm@0.21.5': + optional: true + + '@esbuild/linux-ia32@0.21.5': + optional: true + + '@esbuild/linux-loong64@0.21.5': + optional: true + + '@esbuild/linux-mips64el@0.21.5': + optional: true + + '@esbuild/linux-ppc64@0.21.5': + optional: true + + '@esbuild/linux-riscv64@0.21.5': + optional: true + + '@esbuild/linux-s390x@0.21.5': + optional: true + + '@esbuild/linux-x64@0.21.5': + optional: true + + '@esbuild/netbsd-x64@0.21.5': + optional: true + + '@esbuild/openbsd-x64@0.21.5': + optional: true + + '@esbuild/sunos-x64@0.21.5': + optional: true + + '@esbuild/win32-arm64@0.21.5': + optional: true + + '@esbuild/win32-ia32@0.21.5': + optional: true + + '@esbuild/win32-x64@0.21.5': + optional: true + + '@jest/schemas@29.6.3': + dependencies: + '@sinclair/typebox': 0.27.10 + + '@jridgewell/sourcemap-codec@1.5.5': {} + + '@rollup/rollup-android-arm-eabi@4.60.2': + optional: true + + '@rollup/rollup-android-arm64@4.60.2': + optional: true + + '@rollup/rollup-darwin-arm64@4.60.2': + optional: true + + '@rollup/rollup-darwin-x64@4.60.2': + optional: true + + '@rollup/rollup-freebsd-arm64@4.60.2': + optional: true + + '@rollup/rollup-freebsd-x64@4.60.2': + optional: true + + '@rollup/rollup-linux-arm-gnueabihf@4.60.2': + optional: true + + '@rollup/rollup-linux-arm-musleabihf@4.60.2': + optional: true + + '@rollup/rollup-linux-arm64-gnu@4.60.2': + optional: true + + '@rollup/rollup-linux-arm64-musl@4.60.2': + optional: true + + '@rollup/rollup-linux-loong64-gnu@4.60.2': + optional: true + + '@rollup/rollup-linux-loong64-musl@4.60.2': + optional: true + + '@rollup/rollup-linux-ppc64-gnu@4.60.2': + optional: true + + '@rollup/rollup-linux-ppc64-musl@4.60.2': + optional: true + + '@rollup/rollup-linux-riscv64-gnu@4.60.2': + optional: true + + '@rollup/rollup-linux-riscv64-musl@4.60.2': + optional: true + + '@rollup/rollup-linux-s390x-gnu@4.60.2': + optional: true + + '@rollup/rollup-linux-x64-gnu@4.60.2': + optional: true + + '@rollup/rollup-linux-x64-musl@4.60.2': + optional: true + + '@rollup/rollup-openbsd-x64@4.60.2': + optional: true + + '@rollup/rollup-openharmony-arm64@4.60.2': + optional: true + + '@rollup/rollup-win32-arm64-msvc@4.60.2': + optional: true + + '@rollup/rollup-win32-ia32-msvc@4.60.2': + optional: true + + '@rollup/rollup-win32-x64-gnu@4.60.2': + optional: true + + '@rollup/rollup-win32-x64-msvc@4.60.2': + optional: true + + '@sinclair/typebox@0.27.10': {} + + '@types/estree@1.0.8': {} + + '@vitest/expect@1.6.1': + dependencies: + '@vitest/spy': 1.6.1 + '@vitest/utils': 1.6.1 + chai: 4.5.0 + + '@vitest/runner@1.6.1': + dependencies: + '@vitest/utils': 1.6.1 + p-limit: 5.0.0 + pathe: 1.1.2 + + '@vitest/snapshot@1.6.1': + dependencies: + magic-string: 0.30.21 + pathe: 1.1.2 + pretty-format: 29.7.0 + + '@vitest/spy@1.6.1': + dependencies: + tinyspy: 2.2.1 + + '@vitest/utils@1.6.1': + dependencies: + diff-sequences: 29.6.3 + estree-walker: 3.0.3 + loupe: 2.3.7 + pretty-format: 29.7.0 + + acorn-walk@8.3.5: + dependencies: + acorn: 8.16.0 + + acorn@8.16.0: {} + + ansi-styles@5.2.0: {} + + assertion-error@1.1.0: {} + + cac@6.7.14: {} + + chai@4.5.0: + dependencies: + assertion-error: 1.1.0 + check-error: 1.0.3 + deep-eql: 4.1.4 + get-func-name: 2.0.2 + loupe: 2.3.7 + pathval: 1.1.1 + type-detect: 4.1.0 + + check-error@1.0.3: + dependencies: + get-func-name: 2.0.2 + + confbox@0.1.8: {} + + cross-spawn@7.0.6: + dependencies: + path-key: 3.1.1 + shebang-command: 2.0.0 + which: 2.0.2 + + debug@4.4.3: + dependencies: + ms: 2.1.3 + + deep-eql@4.1.4: + dependencies: + type-detect: 4.1.0 + + diff-sequences@29.6.3: {} + + esbuild@0.21.5: + optionalDependencies: + '@esbuild/aix-ppc64': 0.21.5 + '@esbuild/android-arm': 0.21.5 + '@esbuild/android-arm64': 0.21.5 + '@esbuild/android-x64': 0.21.5 + '@esbuild/darwin-arm64': 0.21.5 + '@esbuild/darwin-x64': 0.21.5 + '@esbuild/freebsd-arm64': 0.21.5 + '@esbuild/freebsd-x64': 0.21.5 + '@esbuild/linux-arm': 0.21.5 + '@esbuild/linux-arm64': 0.21.5 + '@esbuild/linux-ia32': 0.21.5 + '@esbuild/linux-loong64': 0.21.5 + '@esbuild/linux-mips64el': 0.21.5 + '@esbuild/linux-ppc64': 0.21.5 + '@esbuild/linux-riscv64': 0.21.5 + '@esbuild/linux-s390x': 0.21.5 + '@esbuild/linux-x64': 0.21.5 + '@esbuild/netbsd-x64': 0.21.5 + '@esbuild/openbsd-x64': 0.21.5 + '@esbuild/sunos-x64': 0.21.5 + '@esbuild/win32-arm64': 0.21.5 + '@esbuild/win32-ia32': 0.21.5 + '@esbuild/win32-x64': 0.21.5 + + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.8 + + execa@8.0.1: + dependencies: + cross-spawn: 7.0.6 + get-stream: 8.0.1 + human-signals: 5.0.0 + is-stream: 3.0.0 + merge-stream: 2.0.0 + npm-run-path: 5.3.0 + onetime: 6.0.0 + signal-exit: 4.1.0 + strip-final-newline: 3.0.0 + + fsevents@2.3.3: + optional: true + + get-func-name@2.0.2: {} + + get-stream@8.0.1: {} + + human-signals@5.0.0: {} + + is-stream@3.0.0: {} + + isexe@2.0.0: {} + + js-tokens@9.0.1: {} + + local-pkg@0.5.1: + dependencies: + mlly: 1.8.2 + pkg-types: 1.3.1 + + loupe@2.3.7: + dependencies: + get-func-name: 2.0.2 + + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + + merge-stream@2.0.0: {} + + mimic-fn@4.0.0: {} + + mlly@1.8.2: + dependencies: + acorn: 8.16.0 + pathe: 2.0.3 + pkg-types: 1.3.1 + ufo: 1.6.4 + + ms@2.1.3: {} + + nanoid@3.3.12: {} + + npm-run-path@5.3.0: + dependencies: + path-key: 4.0.0 + + onetime@6.0.0: + dependencies: + mimic-fn: 4.0.0 + + p-limit@5.0.0: + dependencies: + yocto-queue: 1.2.2 + + path-key@3.1.1: {} + + path-key@4.0.0: {} + + pathe@1.1.2: {} + + pathe@2.0.3: {} + + pathval@1.1.1: {} + + picocolors@1.1.1: {} + + pkg-types@1.3.1: + dependencies: + confbox: 0.1.8 + mlly: 1.8.2 + pathe: 2.0.3 + + postcss@8.5.13: + dependencies: + nanoid: 3.3.12 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + pretty-format@29.7.0: + dependencies: + '@jest/schemas': 29.6.3 + ansi-styles: 5.2.0 + react-is: 18.3.1 + + react-is@18.3.1: {} + + rollup@4.60.2: + dependencies: + '@types/estree': 1.0.8 + optionalDependencies: + '@rollup/rollup-android-arm-eabi': 4.60.2 + '@rollup/rollup-android-arm64': 4.60.2 + '@rollup/rollup-darwin-arm64': 4.60.2 + '@rollup/rollup-darwin-x64': 4.60.2 + '@rollup/rollup-freebsd-arm64': 4.60.2 + '@rollup/rollup-freebsd-x64': 4.60.2 + '@rollup/rollup-linux-arm-gnueabihf': 4.60.2 + '@rollup/rollup-linux-arm-musleabihf': 4.60.2 + '@rollup/rollup-linux-arm64-gnu': 4.60.2 + '@rollup/rollup-linux-arm64-musl': 4.60.2 + '@rollup/rollup-linux-loong64-gnu': 4.60.2 + '@rollup/rollup-linux-loong64-musl': 4.60.2 + '@rollup/rollup-linux-ppc64-gnu': 4.60.2 + '@rollup/rollup-linux-ppc64-musl': 4.60.2 + '@rollup/rollup-linux-riscv64-gnu': 4.60.2 + '@rollup/rollup-linux-riscv64-musl': 4.60.2 + '@rollup/rollup-linux-s390x-gnu': 4.60.2 + '@rollup/rollup-linux-x64-gnu': 4.60.2 + '@rollup/rollup-linux-x64-musl': 4.60.2 + '@rollup/rollup-openbsd-x64': 4.60.2 + '@rollup/rollup-openharmony-arm64': 4.60.2 + '@rollup/rollup-win32-arm64-msvc': 4.60.2 + '@rollup/rollup-win32-ia32-msvc': 4.60.2 + '@rollup/rollup-win32-x64-gnu': 4.60.2 + '@rollup/rollup-win32-x64-msvc': 4.60.2 + fsevents: 2.3.3 + + shebang-command@2.0.0: + dependencies: + shebang-regex: 3.0.0 + + shebang-regex@3.0.0: {} + + siginfo@2.0.0: {} + + signal-exit@4.1.0: {} + + source-map-js@1.2.1: {} + + stackback@0.0.2: {} + + std-env@3.10.0: {} + + strip-final-newline@3.0.0: {} + + strip-literal@2.1.1: + dependencies: + js-tokens: 9.0.1 + + tinybench@2.9.0: {} + + tinypool@0.8.4: {} + + tinyspy@2.2.1: {} + + type-detect@4.1.0: {} + + typescript@5.9.3: {} + + ufo@1.6.4: {} + + vite-node@1.6.1: + dependencies: + cac: 6.7.14 + debug: 4.4.3 + pathe: 1.1.2 + picocolors: 1.1.1 + vite: 5.4.21 + transitivePeerDependencies: + - '@types/node' + - less + - lightningcss + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + + vite@5.4.21: + dependencies: + esbuild: 0.21.5 + postcss: 8.5.13 + rollup: 4.60.2 + optionalDependencies: + fsevents: 2.3.3 + + vitest@1.6.1: + dependencies: + '@vitest/expect': 1.6.1 + '@vitest/runner': 1.6.1 + '@vitest/snapshot': 1.6.1 + '@vitest/spy': 1.6.1 + '@vitest/utils': 1.6.1 + acorn-walk: 8.3.5 + chai: 4.5.0 + debug: 4.4.3 + execa: 8.0.1 + local-pkg: 0.5.1 + magic-string: 0.30.21 + pathe: 1.1.2 + picocolors: 1.1.1 + std-env: 3.10.0 + strip-literal: 2.1.1 + tinybench: 2.9.0 + tinypool: 0.8.4 + vite: 5.4.21 + vite-node: 1.6.1 + why-is-node-running: 2.3.0 + transitivePeerDependencies: + - less + - lightningcss + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + + which@2.0.2: + dependencies: + isexe: 2.0.0 + + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 + + yocto-queue@1.2.2: {} diff --git a/src/problem4/sum_to_n.test.ts b/src/problem4/sum_to_n.test.ts new file mode 100644 index 0000000000..2f43f6be58 --- /dev/null +++ b/src/problem4/sum_to_n.test.ts @@ -0,0 +1,65 @@ +import { describe, it, expect } from "vitest"; +import { sumToN_iter, sumToN_formula, sumToN_reduce } from "./sum_to_n"; + +const impls = [ + ["iter", sumToN_iter], + ["formula", sumToN_formula], + ["reduce", sumToN_reduce], +] as const; + +describe("sum_to_n — contract", () => { + for (const [label, fn] of impls) { + describe(label, () => { + it.each([ + [0, 0], + [1, 1], + [5, 15], + [10, 55], + [100, 5050], + [-1, -1], + [-3, -6], + [-100, -5050], + ])("sum_to_n(%i) === %i", (input, expected) => { + expect(fn(input)).toBe(expected); + }); + + it("rejects non-integers", () => { + expect(() => fn(1.5)).toThrow(TypeError); + expect(() => fn(Number.NaN)).toThrow(TypeError); + }); + }); + } +}); + +describe("sum_to_n — implementations agree", () => { + it.each([0, 1, 7, 42, 1000, -1, -42, -1000])( + "all three impls agree for n=%i", + (n) => { + const a = sumToN_iter(n); + const b = sumToN_formula(n); + const c = sumToN_reduce(n); + expect(b).toBe(a); + expect(c).toBe(a); + }, + ); +}); + +describe("sum_to_n — formula overflow guard", () => { + // Threshold: m_max = floor((sqrt(8*MAX_SAFE + 1) - 1) / 2) = 134_217_727 + const M_MAX = Math.floor( + (Math.sqrt(8 * Number.MAX_SAFE_INTEGER + 1) - 1) / 2, + ); + + it("accepts the largest safe |n|", () => { + const result = sumToN_formula(M_MAX); + expect(Number.isSafeInteger(result)).toBe(true); + }); + + it("rejects one above the safe threshold", () => { + expect(() => sumToN_formula(M_MAX + 1)).toThrow(RangeError); + }); + + it("rejects clearly oversized input", () => { + expect(() => sumToN_formula(2 ** 30)).toThrow(RangeError); + }); +}); diff --git a/src/problem4/sum_to_n.ts b/src/problem4/sum_to_n.ts new file mode 100644 index 0000000000..822d053328 --- /dev/null +++ b/src/problem4/sum_to_n.ts @@ -0,0 +1,97 @@ +/** + * Three implementations of `sum_to_n`. + * + * Contract: + * Input : n — any integer (positive, zero, or negative). + * Output : sum of integers from 1..n inclusive when n > 0, + * 0 when n === 0, + * sum of integers from n..-1 inclusive (i.e. -1 + -2 + ... + n) when n < 0. + * Assumption: |result| < Number.MAX_SAFE_INTEGER (per the brief). + * + * The three implementations differ in algorithmic approach so the trade-offs + * are visible side-by-side. All three return identical values for the same + * input — see `sum_to_n.test.ts` for property-style verification. + */ + +const MAX_SAFE = Number.MAX_SAFE_INTEGER; + +function assertInteger(n: number): void { + if (!Number.isInteger(n)) { + throw new TypeError(`sum_to_n: n must be an integer, got ${n}`); + } +} + +/** + * Approach A — iterative accumulator. + * + * Time : O(n) — one addition per step. + * Space : O(1) — single accumulator variable. + * + * Pros : easiest to read; trivially correct; no allocation. + * Cons : linear in |n|; slow for very large inputs. + * + * Negative `n` is handled by iterating from -1 down to n. + */ +export function sumToN_iter(n: number): number { + assertInteger(n); + let total = 0; + if (n >= 0) { + for (let i = 1; i <= n; i++) total += i; + } else { + for (let i = -1; i >= n; i--) total += i; + } + return total; +} + +/** + * Approach B — closed-form (Gauss) formula. + * + * Time : O(1). + * Space : O(1). + * + * Pros : optimal; constant work regardless of n. + * Cons : relies on integer arithmetic staying inside Number.MAX_SAFE_INTEGER. + * We validate the bound up front so a violation is loud, not silent. + * + * Identity: 1 + 2 + ... + n = n * (n + 1) / 2. + * The same identity flips sign cleanly for negative n: e.g. n=-3 gives -3*-2/2 = 3, + * but the *signed* sum we want is (-1) + (-2) + (-3) = -6. We therefore compute + * over |n| and re-apply the sign. + */ +export function sumToN_formula(n: number): number { + assertInteger(n); + const sign = Math.sign(n); + const m = Math.abs(n); + // Overflow check: we need m*(m+1) <= 2*MAX_SAFE for the result (after /2) + // to remain a safe integer. Solve m*(m+1) <= 2*MAX_SAFE for the largest m: + // m_max = floor((sqrt(8*MAX_SAFE + 1) - 1) / 2) + // Computed once at module load. + if (m > MAX_M) { + throw new RangeError(`sum_to_n: |n|=${m} would overflow MAX_SAFE_INTEGER`); + } + return (sign * (m * (m + 1))) / 2; +} + +const MAX_M = Math.floor((Math.sqrt(8 * MAX_SAFE + 1) - 1) / 2); + +/** + * Approach C — functional reduce over a generated range. + * + * Time : O(n). + * Space : O(n) — Array.from materialises the full range. + * + * Pros : declarative; reads as "sum of the range"; useful when you want to + * compose other transforms (filter, map) on the same range. + * Cons : allocates an n-element array; worst space profile of the three. + * Demonstrative only — for production prefer Approach B. + */ +export function sumToN_reduce(n: number): number { + assertInteger(n); + if (n === 0) return 0; + const length = Math.abs(n); + const direction = Math.sign(n); + return Array.from({ length }, (_, i) => (i + 1) * direction).reduce( + (acc, x) => acc + x, + 0, + ); +} diff --git a/src/problem4/tsconfig.json b/src/problem4/tsconfig.json new file mode 100644 index 0000000000..b3840609fd --- /dev/null +++ b/src/problem4/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "Bundler", + "strict": true, + "noUncheckedIndexedAccess": true, + "exactOptionalPropertyTypes": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + "esModuleInterop": true, + "skipLibCheck": true, + "isolatedModules": true, + "verbatimModuleSyntax": false, + "resolveJsonModule": true + }, + "include": ["**/*.ts"], + "exclude": ["node_modules"] +} diff --git a/src/problem5/.env.example b/src/problem5/.env.example new file mode 100644 index 0000000000..03a316bd87 --- /dev/null +++ b/src/problem5/.env.example @@ -0,0 +1,17 @@ +# Port the HTTP server listens on. +PORT=3000 + +# Bind address. Use 0.0.0.0 in containers, 127.0.0.1 for local dev. +HOST=127.0.0.1 + +# 'development' | 'production' | 'test' +NODE_ENV=development + +# Path to SQLite database file. Use ':memory:' for an ephemeral DB (tests). +DATABASE_PATH=./data/resources.db + +# Pino log level: trace | debug | info | warn | error | fatal +LOG_LEVEL=info + +# Comma-separated list of allowed CORS origins. Empty disables CORS. +CORS_ORIGINS=http://localhost:5173,http://localhost:3000 diff --git a/src/problem5/.gitignore b/src/problem5/.gitignore new file mode 100644 index 0000000000..e466d34208 --- /dev/null +++ b/src/problem5/.gitignore @@ -0,0 +1,7 @@ +node_modules +dist +data +.env +.env.local +*.log +coverage diff --git a/src/problem5/.keep b/src/problem5/.keep deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/src/problem5/README.md b/src/problem5/README.md new file mode 100644 index 0000000000..c3bcd3455f --- /dev/null +++ b/src/problem5/README.md @@ -0,0 +1,308 @@ +# Problem 5 — A Crude Server + +A small but production-flavoured **Express + TypeScript** CRUD service over a +**SQLite** datastore. Built to be cloned, installed, and run in under two +minutes; reviewed as if it were day-1 of a real backend project. + +--- + +## Stack + +| Concern | Choice | Why | +| ----------- | ---------------------- | ----------------------------------------------------------- | +| Runtime | Node.js ≥ 20 (ESM) | Modern; native `fetch`, `node:` prefix imports | +| HTTP | `express@4` | Required by the brief | +| Validation | `zod` | Single source of truth → derived TS types + runtime schemas | +| Persistence | `better-sqlite3` | Zero-install, file-backed, `:memory:` for tests | +| Logging | `pino` + `pino-http` | JSON in prod, pretty in dev, request-scoped child logger | +| Security | `helmet`, `cors` | Sensible defaults out of the box | +| IDs | RFC 4122 UUIDv4 | Opaque, unguessable | +| Tests | `vitest` + `supertest` | Fast, ESM-first; integration over the real Express stack | + +--- + +## Quick start + +```bash +# from /src/problem5 +pnpm install # or npm/yarn +cp .env.example .env # tweak as needed +pnpm dev # http://127.0.0.1:3000 +``` + +That's it. The DB file is created on first boot at `./data/resources.db` +(SQLite WAL journaling). Use `DATABASE_PATH=:memory:` for an ephemeral run. + +> **better-sqlite3 is a native module.** On the very first install pnpm asks +> you to approve build scripts. Either run `pnpm approve-builds` (interactive) +> or trust the prebuilt binary that ships in the package — both work. + +### Other scripts + +```bash +pnpm test # run vitest once (22 tests) +pnpm test:watch # vitest watch +pnpm typecheck # tsc --noEmit +pnpm build # emit ./dist +pnpm start # node dist/server.js +``` + +--- + +## Configuration + +All config flows through `src/config.ts` and is **validated at boot** (zod). +Missing or bad values fail fast — there's no scenario where a request lands +in code reading `process.env` directly. + +| Var | Default | Notes | +| --------------- | --------------------- | ---------------------------------------------- | +| `NODE_ENV` | `development` | `development` / `production` / `test` | +| `PORT` | `3000` | listens on `HOST:PORT` | +| `HOST` | `127.0.0.1` | use `0.0.0.0` in containers | +| `DATABASE_PATH` | `./data/resources.db` | path or `:memory:` | +| `LOG_LEVEL` | `info` | `trace`-`fatal`, or `silent` | +| `CORS_ORIGINS` | `""` | comma-separated allowlist; empty disables CORS | + +--- + +## API + +All endpoints respond with a uniform envelope so clients have a single shape +to handle: + +```json +// success +{ "ok": true, "data": ... } + +// error +{ "ok": false, "error": { "code": "VALIDATION_ERROR", "message": "...", "details": [...] } } +``` + +### `Resource` shape + +```jsonc +{ + "id": "9b6ad1a8-…", // server-generated uuid + "name": "Compute Cluster A", + "category": "infrastructure", + "status": "active", // "active" | "archived" | "draft" + "createdAt": "2026-05-02T22:30:00.000Z", + "updatedAt": "2026-05-02T22:30:00.000Z", +} +``` + +### Endpoints + +| # | Method & Path | Description | +| --- | ----------------------- | --------------------------------------------------------------------------------- | +| 1 | `POST /resources` | Create — required: `name`, `category`; optional: `status` (defaults to `active`). | +| 2 | `GET /resources` | List with filters & cursor pagination. | +| 3 | `GET /resources/:id` | Fetch one. | +| 4 | `PATCH /resources/:id` | Partial update. ≥ 1 field required. | +| 5 | `DELETE /resources/:id` | Delete. | +| | `GET /health` | Liveness — always 200. | +| | `GET /health/ready` | Readiness — verifies DB. | + +### List query parameters + +| Param | Type | Notes | +| ---------- | --------- | --------------------------------- | +| `category` | string | exact match | +| `status` | enum | `active` / `archived` / `draft` | +| `q` | string | substring match on `name` | +| `limit` | int 1-100 | default `20` | +| `cursor` | ISO-8601 | from previous page's `nextCursor` | + +Pagination block on every list response: + +```json +"pagination": { "limit": 20, "nextCursor": "2026-…Z", "hasMore": true } +``` + +--- + +## Curl recipes + +```bash +# Create +curl -s -X POST http://127.0.0.1:3000/resources \ + -H 'Content-Type: application/json' \ + -d '{"name":"Compute Cluster A","category":"infrastructure"}' + +# List with filter +curl -s 'http://127.0.0.1:3000/resources?category=infrastructure&limit=5' + +# Read one +curl -s http://127.0.0.1:3000/resources/ + +# Patch +curl -s -X PATCH http://127.0.0.1:3000/resources/ \ + -H 'Content-Type: application/json' \ + -d '{"status":"archived"}' + +# Delete +curl -s -X DELETE -w '%{http_code}\n' http://127.0.0.1:3000/resources/ +``` + +--- + +## Architecture + +``` +HTTP request + │ + ▼ +┌──────────────────────────┐ +│ middleware (request-id, │ ← cross-cutting concerns +│ pino-http, helmet, cors,│ each in its own file +│ json body parser) │ +└──────────────────────────┘ + │ + ▼ +┌──────────────────────────┐ +│ routes/resource.routes │ ← URL ↔ controller +└──────────────────────────┘ + │ + ▼ +┌──────────────────────────┐ +│ resource.controller │ ← zod parse → call service +└──────────────────────────┘ + │ + ▼ +┌──────────────────────────┐ +│ resource.service │ ← business logic, errors +└──────────────────────────┘ + │ + ▼ +┌──────────────────────────┐ +│ resource.repo │ ← parametrised SQL only +└──────────────────────────┘ + │ + ▼ +┌──────────────────────────┐ +│ better-sqlite3 │ +└──────────────────────────┘ + +errors ──► middleware/error-handler ──► envelope +``` + +**Why four layers** instead of fat handlers: + +- Service is reusable from a CLI / job / queue consumer. +- Repo is the only file that touches SQL — swappable to Postgres without + changing routes or controllers. +- Validation lives at the boundary; service & repo trust their inputs. + +--- + +## Cross-cutting concerns + +- **Request ID** — accepts inbound `X-Request-Id` (LB friendly), mints UUIDv4 + otherwise. Attached to `req.requestId` and echoed on the response. +- **Structured logs** — every line is JSON in production, pretty in dev. Each + log carries the `requestId` so traces stitch together. `Authorization`, + `Cookie`, and any `*.password` / `*.token` fields are redacted at the + logger layer (rule: data-privacy.md). +- **Validation** — every controller starts with a `zod.parse()` on body / query + / params. Failure → 422 with field-level details. +- **Errors** — operational errors (`AppError` subclasses) are mapped to the + intended HTTP status; programmer errors surface as a generic 500 with a + full server-side stack trace, never leaked to the client. +- **Health checks** — `/health` is liveness (cheap, always 200), `/health/ready` + validates DB connectivity for k8s readiness probes. +- **Graceful shutdown** — SIGTERM/SIGINT → stop accepting connections, drain + in-flight (10 s budget), close SQLite handle, exit 0. Matches the + Kubernetes preStop → terminationGracePeriod model. +- **Helmet + CORS** — sensible HTTP-security headers; CORS allowlist driven + by `CORS_ORIGINS`. The wildcard `*` is rejected at boot — the service + sends `credentials: true`, and the spec forbids that pairing. +- **Rate limit** — `express-rate-limit` applies per-IP quotas. Reads get + 300/min; writes get 60/min (writes serialise on the SQLite writer, so + it's the right axis to clamp). Disabled in `NODE_ENV=test` so the test + suite can fire 25+ requests in one tick. +- **JSON body limit** — capped at 100 KB to deter slow-loris / DoS. +- **`x-powered-by` disabled** — small but standard hardening. + +> ⚠ **No auth layer.** This challenge brief asks for a CRUD service, not +> an authenticated one. The endpoints are intentionally open. In a real +> deployment, drop a JWT or session middleware between `helmet()` and the +> rate-limiter. The error envelope, request-id, and structured logs are +> already shaped to make that addition a one-file change. + +--- + +## Tests + +```bash +pnpm test +``` + +22 integration tests (real Express app, in-memory SQLite) cover: + +- happy path for every endpoint +- 422 (zod) on missing / invalid / empty / over-limit fields +- 404 on unknown UUID and non-UUID id +- pagination across multiple pages +- multi-filter `(category, status, q)` queries +- request-id propagation +- health & readiness + +--- + +## What I'd add for production + +> Out of scope for this challenge but flagged here so reviewers see the trail. + +- **AuthN/AuthZ** — JWT bearer or session cookie + RBAC. Right now the API is + open; trivially front it with `add-jwt-auth`. +- **Rate limiting** — `express-rate-limit` + Redis on auth + write paths. +- **Migrations tool** — replace inline `CREATE TABLE IF NOT EXISTS` with a + versioned migration directory (e.g. `umzug`, `sqlx migrate`). +- **OpenAPI / Swagger** — generate from the zod schemas (`zod-to-openapi`). +- **Observability** — Prometheus `/metrics`, OpenTelemetry traces, error rate + alerting against an SLO. +- **Container** — multi-stage Dockerfile + non-root user, healthcheck baked in. +- **CI** — GitHub Actions running `pnpm test`, `pnpm typecheck`, audit scan. +- **Postgres swap** — replace `better-sqlite3` repository with `pg` / + `drizzle` — the rest of the stack is unaffected because the repo interface + is the only seam. +- **Idempotency-Key** for `POST /resources` to make retries safe (especially + if a future create costs money or sends notifications). + +--- + +## File map + +``` +src/problem5/ +├── src/ +│ ├── server.ts # process entrypoint + graceful shutdown +│ ├── app.ts # express factory (used by server + tests) +│ ├── config.ts # zod-validated env loader +│ ├── logger.ts # pino + redaction +│ ├── errors.ts # AppError hierarchy +│ ├── middleware/ +│ │ ├── request-id.ts +│ │ ├── error-handler.ts +│ │ └── not-found.ts +│ ├── db/ +│ │ ├── connection.ts # better-sqlite3 + pragmas +│ │ └── migrate.ts # idempotent schema bootstrap +│ ├── resources/ +│ │ ├── resource.schema.ts # zod DTOs (single source of truth) +│ │ ├── resource.repo.ts # SQL only +│ │ ├── resource.service.ts # business logic +│ │ ├── resource.controller.ts # HTTP ↔ service +│ │ └── resource.routes.ts # URL ↔ controller +│ └── types/ +│ └── express.d.ts # Request augmentation (requestId) +├── tests/ +│ └── resources.test.ts # 22 integration tests +├── package.json +├── tsconfig.json +├── tsconfig.build.json +├── .env.example +├── .gitignore +└── README.md +``` diff --git a/src/problem5/package.json b/src/problem5/package.json new file mode 100644 index 0000000000..7b2c2862d6 --- /dev/null +++ b/src/problem5/package.json @@ -0,0 +1,48 @@ +{ + "name": "problem5-crude-server", + "version": "1.0.0", + "private": true, + "description": "Express + TypeScript CRUD server with SQLite persistence.", + "type": "module", + "main": "dist/server.js", + "scripts": { + "dev": "tsx watch src/server.ts", + "build": "tsc -p tsconfig.build.json", + "start": "node dist/server.js", + "test": "vitest run", + "test:watch": "vitest", + "typecheck": "tsc --noEmit", + "lint": "tsc --noEmit" + }, + "dependencies": { + "better-sqlite3": "^11.3.0", + "cors": "^2.8.5", + "dotenv": "^16.4.5", + "express": "^4.21.0", + "express-rate-limit": "^7.4.0", + "helmet": "^7.1.0", + "pino": "^9.4.0", + "pino-http": "^10.3.0", + "pino-pretty": "^11.2.2", + "uuid": "^10.0.0", + "zod": "^3.23.8" + }, + "devDependencies": { + "@types/better-sqlite3": "^7.6.11", + "@types/cors": "^2.8.17", + "@types/express": "^4.17.21", + "@types/node": "^20.16.5", + "@types/supertest": "^6.0.2", + "@types/uuid": "^10.0.0", + "supertest": "^7.0.0", + "tsx": "^4.19.1", + "typescript": "^5.4.5", + "vitest": "^1.6.0" + }, + "engines": { + "node": ">=20" + }, + "pnpm": { + "onlyBuiltDependencies": ["better-sqlite3", "esbuild"] + } +} diff --git a/src/problem5/pnpm-lock.yaml b/src/problem5/pnpm-lock.yaml new file mode 100644 index 0000000000..34466350ef --- /dev/null +++ b/src/problem5/pnpm-lock.yaml @@ -0,0 +1,2816 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + better-sqlite3: + specifier: ^11.3.0 + version: 11.10.0 + cors: + specifier: ^2.8.5 + version: 2.8.6 + dotenv: + specifier: ^16.4.5 + version: 16.6.1 + express: + specifier: ^4.21.0 + version: 4.22.1 + express-rate-limit: + specifier: ^7.4.0 + version: 7.5.1(express@4.22.1) + helmet: + specifier: ^7.1.0 + version: 7.2.0 + pino: + specifier: ^9.4.0 + version: 9.14.0 + pino-http: + specifier: ^10.3.0 + version: 10.5.0 + pino-pretty: + specifier: ^11.2.2 + version: 11.3.0 + uuid: + specifier: ^10.0.0 + version: 10.0.0 + zod: + specifier: ^3.23.8 + version: 3.25.76 + devDependencies: + '@types/better-sqlite3': + specifier: ^7.6.11 + version: 7.6.13 + '@types/cors': + specifier: ^2.8.17 + version: 2.8.19 + '@types/express': + specifier: ^4.17.21 + version: 4.17.25 + '@types/node': + specifier: ^20.16.5 + version: 20.19.39 + '@types/supertest': + specifier: ^6.0.2 + version: 6.0.3 + '@types/uuid': + specifier: ^10.0.0 + version: 10.0.0 + supertest: + specifier: ^7.0.0 + version: 7.2.2 + tsx: + specifier: ^4.19.1 + version: 4.21.0 + typescript: + specifier: ^5.4.5 + version: 5.9.3 + vitest: + specifier: ^1.6.0 + version: 1.6.1(@types/node@20.19.39) + +packages: + + '@esbuild/aix-ppc64@0.21.5': + resolution: {integrity: sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==} + engines: {node: '>=12'} + cpu: [ppc64] + os: [aix] + + '@esbuild/aix-ppc64@0.27.7': + resolution: {integrity: sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.21.5': + resolution: {integrity: sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==} + engines: {node: '>=12'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm64@0.27.7': + resolution: {integrity: sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.21.5': + resolution: {integrity: sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==} + engines: {node: '>=12'} + cpu: [arm] + os: [android] + + '@esbuild/android-arm@0.27.7': + resolution: {integrity: sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.21.5': + resolution: {integrity: sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==} + engines: {node: '>=12'} + cpu: [x64] + os: [android] + + '@esbuild/android-x64@0.27.7': + resolution: {integrity: sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.21.5': + resolution: {integrity: sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==} + engines: {node: '>=12'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-arm64@0.27.7': + resolution: {integrity: sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.21.5': + resolution: {integrity: sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==} + engines: {node: '>=12'} + cpu: [x64] + os: [darwin] + + '@esbuild/darwin-x64@0.27.7': + resolution: {integrity: sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.21.5': + resolution: {integrity: sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==} + engines: {node: '>=12'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-arm64@0.27.7': + resolution: {integrity: sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.21.5': + resolution: {integrity: sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.27.7': + resolution: {integrity: sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.21.5': + resolution: {integrity: sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==} + engines: {node: '>=12'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm64@0.27.7': + resolution: {integrity: sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.21.5': + resolution: {integrity: sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==} + engines: {node: '>=12'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-arm@0.27.7': + resolution: {integrity: sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.21.5': + resolution: {integrity: sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==} + engines: {node: '>=12'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-ia32@0.27.7': + resolution: {integrity: sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.21.5': + resolution: {integrity: sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==} + engines: {node: '>=12'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-loong64@0.27.7': + resolution: {integrity: sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.21.5': + resolution: {integrity: sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==} + engines: {node: '>=12'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-mips64el@0.27.7': + resolution: {integrity: sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.21.5': + resolution: {integrity: sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==} + engines: {node: '>=12'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-ppc64@0.27.7': + resolution: {integrity: sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.21.5': + resolution: {integrity: sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==} + engines: {node: '>=12'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-riscv64@0.27.7': + resolution: {integrity: sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.21.5': + resolution: {integrity: sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==} + engines: {node: '>=12'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-s390x@0.27.7': + resolution: {integrity: sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.21.5': + resolution: {integrity: sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [linux] + + '@esbuild/linux-x64@0.27.7': + resolution: {integrity: sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.27.7': + resolution: {integrity: sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.21.5': + resolution: {integrity: sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==} + engines: {node: '>=12'} + cpu: [x64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.27.7': + resolution: {integrity: sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.27.7': + resolution: {integrity: sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.21.5': + resolution: {integrity: sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==} + engines: {node: '>=12'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.27.7': + resolution: {integrity: sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.27.7': + resolution: {integrity: sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.21.5': + resolution: {integrity: sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==} + engines: {node: '>=12'} + cpu: [x64] + os: [sunos] + + '@esbuild/sunos-x64@0.27.7': + resolution: {integrity: sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.21.5': + resolution: {integrity: sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==} + engines: {node: '>=12'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-arm64@0.27.7': + resolution: {integrity: sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.21.5': + resolution: {integrity: sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==} + engines: {node: '>=12'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-ia32@0.27.7': + resolution: {integrity: sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.21.5': + resolution: {integrity: sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==} + engines: {node: '>=12'} + cpu: [x64] + os: [win32] + + '@esbuild/win32-x64@0.27.7': + resolution: {integrity: sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@jest/schemas@29.6.3': + resolution: {integrity: sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + + '@noble/hashes@1.8.0': + resolution: {integrity: sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==} + engines: {node: ^14.21.3 || >=16} + + '@paralleldrive/cuid2@2.3.1': + resolution: {integrity: sha512-XO7cAxhnTZl0Yggq6jOgjiOHhbgcO4NqFqwSmQpjK3b6TEE6Uj/jfSk6wzYyemh3+I0sHirKSetjQwn5cZktFw==} + + '@pinojs/redact@0.4.0': + resolution: {integrity: sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==} + + '@rollup/rollup-android-arm-eabi@4.60.2': + resolution: {integrity: sha512-dnlp69efPPg6Uaw2dVqzWRfAWRnYVb1XJ8CyyhIbZeaq4CA5/mLeZ1IEt9QqQxmbdvagjLIm2ZL8BxXv5lH4Yw==} + cpu: [arm] + os: [android] + + '@rollup/rollup-android-arm64@4.60.2': + resolution: {integrity: sha512-OqZTwDRDchGRHHm/hwLOL7uVPB9aUvI0am/eQuWMNyFHf5PSEQmyEeYYheA0EPPKUO/l0uigCp+iaTjoLjVoHg==} + cpu: [arm64] + os: [android] + + '@rollup/rollup-darwin-arm64@4.60.2': + resolution: {integrity: sha512-UwRE7CGpvSVEQS8gUMBe1uADWjNnVgP3Iusyda1nSRwNDCsRjnGc7w6El6WLQsXmZTbLZx9cecegumcitNfpmA==} + cpu: [arm64] + os: [darwin] + + '@rollup/rollup-darwin-x64@4.60.2': + resolution: {integrity: sha512-gjEtURKLCC5VXm1I+2i1u9OhxFsKAQJKTVB8WvDAHF+oZlq0GTVFOlTlO1q3AlCTE/DF32c16ESvfgqR7343/g==} + cpu: [x64] + os: [darwin] + + '@rollup/rollup-freebsd-arm64@4.60.2': + resolution: {integrity: sha512-Bcl6CYDeAgE70cqZaMojOi/eK63h5Me97ZqAQoh77VPjMysA/4ORQBRGo3rRy45x4MzVlU9uZxs8Uwy7ZaKnBw==} + cpu: [arm64] + os: [freebsd] + + '@rollup/rollup-freebsd-x64@4.60.2': + resolution: {integrity: sha512-LU+TPda3mAE2QB0/Hp5VyeKJivpC6+tlOXd1VMoXV/YFMvk/MNk5iXeBfB4MQGRWyOYVJ01625vjkr0Az98OJQ==} + cpu: [x64] + os: [freebsd] + + '@rollup/rollup-linux-arm-gnueabihf@4.60.2': + resolution: {integrity: sha512-2QxQrM+KQ7DAW4o22j+XZ6RKdxjLD7BOWTP0Bv0tmjdyhXSsr2Ul1oJDQqh9Zf5qOwTuTc7Ek83mOFaKnodPjg==} + cpu: [arm] + os: [linux] + + '@rollup/rollup-linux-arm-musleabihf@4.60.2': + resolution: {integrity: sha512-TbziEu2DVsTEOPif2mKWkMeDMLoYjx95oESa9fkQQK7r/Orta0gnkcDpzwufEcAO2BLBsD7mZkXGFqEdMRRwfw==} + cpu: [arm] + os: [linux] + + '@rollup/rollup-linux-arm64-gnu@4.60.2': + resolution: {integrity: sha512-bO/rVDiDUuM2YfuCUwZ1t1cP+/yqjqz+Xf2VtkdppefuOFS2OSeAfgafaHNkFn0t02hEyXngZkxtGqXcXwO8Rg==} + cpu: [arm64] + os: [linux] + + '@rollup/rollup-linux-arm64-musl@4.60.2': + resolution: {integrity: sha512-hr26p7e93Rl0Za+JwW7EAnwAvKkehh12BU1Llm9Ykiibg4uIr2rbpxG9WCf56GuvidlTG9KiiQT/TXT1yAWxTA==} + cpu: [arm64] + os: [linux] + + '@rollup/rollup-linux-loong64-gnu@4.60.2': + resolution: {integrity: sha512-pOjB/uSIyDt+ow3k/RcLvUAOGpysT2phDn7TTUB3n75SlIgZzM6NKAqlErPhoFU+npgY3/n+2HYIQVbF70P9/A==} + cpu: [loong64] + os: [linux] + + '@rollup/rollup-linux-loong64-musl@4.60.2': + resolution: {integrity: sha512-2/w+q8jszv9Ww1c+6uJT3OwqhdmGP2/4T17cu8WuwyUuuaCDDJ2ojdyYwZzCxx0GcsZBhzi3HmH+J5pZNXnd+Q==} + cpu: [loong64] + os: [linux] + + '@rollup/rollup-linux-ppc64-gnu@4.60.2': + resolution: {integrity: sha512-11+aL5vKheYgczxtPVVRhdptAM2H7fcDR5Gw4/bTcteuZBlH4oP9f5s9zYO9aGZvoGeBpqXI/9TZZihZ609wKw==} + cpu: [ppc64] + os: [linux] + + '@rollup/rollup-linux-ppc64-musl@4.60.2': + resolution: {integrity: sha512-i16fokAGK46IVZuV8LIIwMdtqhin9hfYkCh8pf8iC3QU3LpwL+1FSFGej+O7l3E/AoknL6Dclh2oTdnRMpTzFQ==} + cpu: [ppc64] + os: [linux] + + '@rollup/rollup-linux-riscv64-gnu@4.60.2': + resolution: {integrity: sha512-49FkKS6RGQoriDSK/6E2GkAsAuU5kETFCh7pG4yD/ylj9rKhTmO3elsnmBvRD4PgJPds5W2PkhC82aVwmUcJ7A==} + cpu: [riscv64] + os: [linux] + + '@rollup/rollup-linux-riscv64-musl@4.60.2': + resolution: {integrity: sha512-mjYNkHPfGpUR00DuM1ZZIgs64Hpf4bWcz9Z41+4Q+pgDx73UwWdAYyf6EG/lRFldmdHHzgrYyge5akFUW0D3mQ==} + cpu: [riscv64] + os: [linux] + + '@rollup/rollup-linux-s390x-gnu@4.60.2': + resolution: {integrity: sha512-ALyvJz965BQk8E9Al/JDKKDLH2kfKFLTGMlgkAbbYtZuJt9LU8DW3ZoDMCtQpXAltZxwBHevXz5u+gf0yA0YoA==} + cpu: [s390x] + os: [linux] + + '@rollup/rollup-linux-x64-gnu@4.60.2': + resolution: {integrity: sha512-UQjrkIdWrKI626Du8lCQ6MJp/6V1LAo2bOK9OTu4mSn8GGXIkPXk/Vsp4bLHCd9Z9Iz2OTEaokUE90VweJgIYQ==} + cpu: [x64] + os: [linux] + + '@rollup/rollup-linux-x64-musl@4.60.2': + resolution: {integrity: sha512-bTsRGj6VlSdn/XD4CGyzMnzaBs9bsRxy79eTqTCBsA8TMIEky7qg48aPkvJvFe1HyzQ5oMZdg7AnVlWQSKLTnw==} + cpu: [x64] + os: [linux] + + '@rollup/rollup-openbsd-x64@4.60.2': + resolution: {integrity: sha512-6d4Z3534xitaA1FcMWP7mQPq5zGwBmGbhphh2DwaA1aNIXUu3KTOfwrWpbwI4/Gr0uANo7NTtaykFyO2hPuFLg==} + cpu: [x64] + os: [openbsd] + + '@rollup/rollup-openharmony-arm64@4.60.2': + resolution: {integrity: sha512-NetAg5iO2uN7eB8zE5qrZ3CSil+7IJt4WDFLcC75Ymywq1VZVD6qJ6EvNLjZ3rEm6gB7XW5JdT60c6MN35Z85Q==} + cpu: [arm64] + os: [openharmony] + + '@rollup/rollup-win32-arm64-msvc@4.60.2': + resolution: {integrity: sha512-NCYhOotpgWZ5kdxCZsv6Iudx0wX8980Q/oW4pNFNihpBKsDbEA1zpkfxJGC0yugsUuyDZ7gL37dbzwhR0VI7pQ==} + cpu: [arm64] + os: [win32] + + '@rollup/rollup-win32-ia32-msvc@4.60.2': + resolution: {integrity: sha512-RXsaOqXxfoUBQoOgvmmijVxJnW2IGB0eoMO7F8FAjaj0UTywUO/luSqimWBJn04WNgUkeNhh7fs7pESXajWmkg==} + cpu: [ia32] + os: [win32] + + '@rollup/rollup-win32-x64-gnu@4.60.2': + resolution: {integrity: sha512-qdAzEULD+/hzObedtmV6iBpdL5TIbKVztGiK7O3/KYSf+HIzU257+MX1EXJcyIiDbMAqmbwaufcYPvyRryeZtA==} + cpu: [x64] + os: [win32] + + '@rollup/rollup-win32-x64-msvc@4.60.2': + resolution: {integrity: sha512-Nd/SgG27WoA9e+/TdK74KnHz852TLa94ovOYySo/yMPuTmpckK/jIF2jSwS3g7ELSKXK13/cVdmg1Z/DaCWKxA==} + cpu: [x64] + os: [win32] + + '@sinclair/typebox@0.27.10': + resolution: {integrity: sha512-MTBk/3jGLNB2tVxv6uLlFh1iu64iYOQ2PbdOSK3NW8JZsmlaOh2q6sdtKowBhfw8QFLmYNzTW4/oK4uATIi6ZA==} + + '@types/better-sqlite3@7.6.13': + resolution: {integrity: sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA==} + + '@types/body-parser@1.19.6': + resolution: {integrity: sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==} + + '@types/connect@3.4.38': + resolution: {integrity: sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==} + + '@types/cookiejar@2.1.5': + resolution: {integrity: sha512-he+DHOWReW0nghN24E1WUqM0efK4kI9oTqDm6XmK8ZPe2djZ90BSNdGnIyCLzCPw7/pogPlGbzI2wHGGmi4O/Q==} + + '@types/cors@2.8.19': + resolution: {integrity: sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg==} + + '@types/estree@1.0.8': + resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} + + '@types/express-serve-static-core@4.19.8': + resolution: {integrity: sha512-02S5fmqeoKzVZCHPZid4b8JH2eM5HzQLZWN2FohQEy/0eXTq8VXZfSN6Pcr3F6N9R/vNrj7cpgbhjie6m/1tCA==} + + '@types/express@4.17.25': + resolution: {integrity: sha512-dVd04UKsfpINUnK0yBoYHDF3xu7xVH4BuDotC/xGuycx4CgbP48X/KF/586bcObxT0HENHXEU8Nqtu6NR+eKhw==} + + '@types/http-errors@2.0.5': + resolution: {integrity: sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==} + + '@types/methods@1.1.4': + resolution: {integrity: sha512-ymXWVrDiCxTBE3+RIrrP533E70eA+9qu7zdWoHuOmGujkYtzf4HQF96b8nwHLqhuf4ykX61IGRIB38CC6/sImQ==} + + '@types/mime@1.3.5': + resolution: {integrity: sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==} + + '@types/node@20.19.39': + resolution: {integrity: sha512-orrrD74MBUyK8jOAD/r0+lfa1I2MO6I+vAkmAWzMYbCcgrN4lCrmK52gRFQq/JRxfYPfonkr4b0jcY7Olqdqbw==} + + '@types/qs@6.15.0': + resolution: {integrity: sha512-JawvT8iBVWpzTrz3EGw9BTQFg3BQNmwERdKE22vlTxawwtbyUSlMppvZYKLZzB5zgACXdXxbD3m1bXaMqP/9ow==} + + '@types/range-parser@1.2.7': + resolution: {integrity: sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==} + + '@types/send@0.17.6': + resolution: {integrity: sha512-Uqt8rPBE8SY0RK8JB1EzVOIZ32uqy8HwdxCnoCOsYrvnswqmFZ/k+9Ikidlk/ImhsdvBsloHbAlewb2IEBV/Og==} + + '@types/send@1.2.1': + resolution: {integrity: sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==} + + '@types/serve-static@1.15.10': + resolution: {integrity: sha512-tRs1dB+g8Itk72rlSI2ZrW6vZg0YrLI81iQSTkMmOqnqCaNr/8Ek4VwWcN5vZgCYWbg/JJSGBlUaYGAOP73qBw==} + + '@types/superagent@8.1.9': + resolution: {integrity: sha512-pTVjI73witn+9ILmoJdajHGW2jkSaOzhiFYF1Rd3EQ94kymLqB9PjD9ISg7WaALC7+dCHT0FGe9T2LktLq/3GQ==} + + '@types/supertest@6.0.3': + resolution: {integrity: sha512-8WzXq62EXFhJ7QsH3Ocb/iKQ/Ty9ZVWnVzoTKc9tyyFRRF3a74Tk2+TLFgaFFw364Ere+npzHKEJ6ga2LzIL7w==} + + '@types/uuid@10.0.0': + resolution: {integrity: sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ==} + + '@vitest/expect@1.6.1': + resolution: {integrity: sha512-jXL+9+ZNIJKruofqXuuTClf44eSpcHlgj3CiuNihUF3Ioujtmc0zIa3UJOW5RjDK1YLBJZnWBlPuqhYycLioog==} + + '@vitest/runner@1.6.1': + resolution: {integrity: sha512-3nSnYXkVkf3mXFfE7vVyPmi3Sazhb/2cfZGGs0JRzFsPFvAMBEcrweV1V1GsrstdXeKCTXlJbvnQwGWgEIHmOA==} + + '@vitest/snapshot@1.6.1': + resolution: {integrity: sha512-WvidQuWAzU2p95u8GAKlRMqMyN1yOJkGHnx3M1PL9Raf7AQ1kwLKg04ADlCa3+OXUZE7BceOhVZiuWAbzCKcUQ==} + + '@vitest/spy@1.6.1': + resolution: {integrity: sha512-MGcMmpGkZebsMZhbQKkAf9CX5zGvjkBTqf8Zx3ApYWXr3wG+QvEu2eXWfnIIWYSJExIp4V9FCKDEeygzkYrXMw==} + + '@vitest/utils@1.6.1': + resolution: {integrity: sha512-jOrrUvXM4Av9ZWiG1EajNto0u96kWAhJ1LmPmJhXXQx/32MecEKd10pOLYgS2BQx1TgkGhloPU1ArDW2vvaY6g==} + + abort-controller@3.0.0: + resolution: {integrity: sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==} + engines: {node: '>=6.5'} + + accepts@1.3.8: + resolution: {integrity: sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==} + engines: {node: '>= 0.6'} + + acorn-walk@8.3.5: + resolution: {integrity: sha512-HEHNfbars9v4pgpW6SO1KSPkfoS0xVOM/9UzkJltjlsHZmJasxg8aXkuZa7SMf8vKGIBhpUsPluQSqhJFCqebw==} + engines: {node: '>=0.4.0'} + + acorn@8.16.0: + resolution: {integrity: sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==} + engines: {node: '>=0.4.0'} + hasBin: true + + ansi-styles@5.2.0: + resolution: {integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==} + engines: {node: '>=10'} + + array-flatten@1.1.1: + resolution: {integrity: sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==} + + asap@2.0.6: + resolution: {integrity: sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==} + + assertion-error@1.1.0: + resolution: {integrity: sha512-jgsaNduz+ndvGyFt3uSuWqvy4lCnIJiovtouQN5JZHOKCS2QuhEdbcQHFhVksz2N2U9hXJo8odG7ETyWlEeuDw==} + + asynckit@0.4.0: + resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} + + atomic-sleep@1.0.0: + resolution: {integrity: sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==} + engines: {node: '>=8.0.0'} + + base64-js@1.5.1: + resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} + + better-sqlite3@11.10.0: + resolution: {integrity: sha512-EwhOpyXiOEL/lKzHz9AW1msWFNzGc/z+LzeB3/jnFJpxu+th2yqvzsSWas1v9jgs9+xiXJcD5A8CJxAG2TaghQ==} + + bindings@1.5.0: + resolution: {integrity: sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==} + + bl@4.1.0: + resolution: {integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==} + + body-parser@1.20.5: + resolution: {integrity: sha512-3grm+/2tUOvu2cjJkvsIxrv/wVpfXQW4PsQHYm7yk4vfpu7Ekl6nEsYBoJUL6qDwZUx8wUhQ8tR2qz+ad9c9OA==} + engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16} + + buffer@5.7.1: + resolution: {integrity: sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==} + + buffer@6.0.3: + resolution: {integrity: sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==} + + bytes@3.1.2: + resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==} + engines: {node: '>= 0.8'} + + cac@6.7.14: + resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==} + engines: {node: '>=8'} + + call-bind-apply-helpers@1.0.2: + resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} + engines: {node: '>= 0.4'} + + call-bound@1.0.4: + resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==} + engines: {node: '>= 0.4'} + + chai@4.5.0: + resolution: {integrity: sha512-RITGBfijLkBddZvnn8jdqoTypxvqbOLYQkGGxXzeFjVHvudaPw0HNFD9x928/eUwYWd2dPCugVqspGALTZZQKw==} + engines: {node: '>=4'} + + check-error@1.0.3: + resolution: {integrity: sha512-iKEoDYaRmd1mxM90a2OEfWhjsjPpYPuQ+lMYsoxB126+t8fw7ySEO48nmDg5COTjxDI65/Y2OWpeEHk3ZOe8zg==} + + chownr@1.1.4: + resolution: {integrity: sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==} + + colorette@2.0.20: + resolution: {integrity: sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==} + + combined-stream@1.0.8: + resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} + engines: {node: '>= 0.8'} + + component-emitter@1.3.1: + resolution: {integrity: sha512-T0+barUSQRTUQASh8bx02dl+DhF54GtIDY13Y3m9oWTklKbb3Wv974meRpeZ3lp1JpLVECWWNHC4vaG2XHXouQ==} + + confbox@0.1.8: + resolution: {integrity: sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==} + + content-disposition@0.5.4: + resolution: {integrity: sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==} + engines: {node: '>= 0.6'} + + content-type@1.0.5: + resolution: {integrity: sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==} + engines: {node: '>= 0.6'} + + cookie-signature@1.0.7: + resolution: {integrity: sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==} + + cookie-signature@1.2.2: + resolution: {integrity: sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==} + engines: {node: '>=6.6.0'} + + cookie@0.7.2: + resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==} + engines: {node: '>= 0.6'} + + cookiejar@2.1.4: + resolution: {integrity: sha512-LDx6oHrK+PhzLKJU9j5S7/Y3jM/mUHvD/DeI1WQmJn652iPC5Y4TBzC9l+5OMOXlyTTA+SmVUPm0HQUwpD5Jqw==} + + cors@2.8.6: + resolution: {integrity: sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==} + engines: {node: '>= 0.10'} + + cross-spawn@7.0.6: + resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} + engines: {node: '>= 8'} + + dateformat@4.6.3: + resolution: {integrity: sha512-2P0p0pFGzHS5EMnhdxQi7aJN+iMheud0UhG4dlE1DLAlvL8JHjJJTX/CSm4JXwV0Ka5nGk3zC5mcb5bUQUxxMA==} + + debug@2.6.9: + resolution: {integrity: sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + decompress-response@6.0.0: + resolution: {integrity: sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==} + engines: {node: '>=10'} + + deep-eql@4.1.4: + resolution: {integrity: sha512-SUwdGfqdKOwxCPeVYjwSyRpJ7Z+fhpwIAtmCUdZIWZ/YP5R9WAsyuSgpLVDi9bjWoN2LXHNss/dk3urXtdQxGg==} + engines: {node: '>=6'} + + deep-extend@0.6.0: + resolution: {integrity: sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==} + engines: {node: '>=4.0.0'} + + delayed-stream@1.0.0: + resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} + engines: {node: '>=0.4.0'} + + depd@2.0.0: + resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} + engines: {node: '>= 0.8'} + + destroy@1.2.0: + resolution: {integrity: sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==} + engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16} + + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + + dezalgo@1.0.4: + resolution: {integrity: sha512-rXSP0bf+5n0Qonsb+SVVfNfIsimO4HEtmnIpPHY8Q1UCzKlQrDMfdobr8nJOOsRgWCyMRqeSBQzmWUMq7zvVig==} + + diff-sequences@29.6.3: + resolution: {integrity: sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + dotenv@16.6.1: + resolution: {integrity: sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==} + engines: {node: '>=12'} + + dunder-proto@1.0.1: + resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} + engines: {node: '>= 0.4'} + + ee-first@1.1.1: + resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} + + encodeurl@2.0.0: + resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==} + engines: {node: '>= 0.8'} + + end-of-stream@1.4.5: + resolution: {integrity: sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==} + + es-define-property@1.0.1: + resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} + engines: {node: '>= 0.4'} + + es-errors@1.3.0: + resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} + engines: {node: '>= 0.4'} + + es-object-atoms@1.1.1: + resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==} + engines: {node: '>= 0.4'} + + es-set-tostringtag@2.1.0: + resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==} + engines: {node: '>= 0.4'} + + esbuild@0.21.5: + resolution: {integrity: sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==} + engines: {node: '>=12'} + hasBin: true + + esbuild@0.27.7: + resolution: {integrity: sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==} + engines: {node: '>=18'} + hasBin: true + + escape-html@1.0.3: + resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==} + + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + + etag@1.8.1: + resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==} + engines: {node: '>= 0.6'} + + event-target-shim@5.0.1: + resolution: {integrity: sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==} + engines: {node: '>=6'} + + events@3.3.0: + resolution: {integrity: sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==} + engines: {node: '>=0.8.x'} + + execa@8.0.1: + resolution: {integrity: sha512-VyhnebXciFV2DESc+p6B+y0LjSm0krU4OgJN44qFAhBY0TJ+1V61tYD2+wHusZ6F9n5K+vl8k0sTy7PEfV4qpg==} + engines: {node: '>=16.17'} + + expand-template@2.0.3: + resolution: {integrity: sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==} + engines: {node: '>=6'} + + express-rate-limit@7.5.1: + resolution: {integrity: sha512-7iN8iPMDzOMHPUYllBEsQdWVB6fPDMPqwjBaFrgr4Jgr/+okjvzAy+UHlYYL/Vs0OsOrMkwS6PJDkFlJwoxUnw==} + engines: {node: '>= 16'} + peerDependencies: + express: '>= 4.11' + + express@4.22.1: + resolution: {integrity: sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==} + engines: {node: '>= 0.10.0'} + + fast-copy@3.0.2: + resolution: {integrity: sha512-dl0O9Vhju8IrcLndv2eU4ldt1ftXMqqfgN4H1cpmGV7P6jeB9FwpN9a2c8DPGE1Ys88rNUJVYDHq73CGAGOPfQ==} + + fast-safe-stringify@2.1.1: + resolution: {integrity: sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==} + + file-uri-to-path@1.0.0: + resolution: {integrity: sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==} + + finalhandler@1.3.2: + resolution: {integrity: sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==} + engines: {node: '>= 0.8'} + + form-data@4.0.5: + resolution: {integrity: sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==} + engines: {node: '>= 6'} + + formidable@3.5.4: + resolution: {integrity: sha512-YikH+7CUTOtP44ZTnUhR7Ic2UASBPOqmaRkRKxRbywPTe5VxF7RRCck4af9wutiZ/QKM5nME9Bie2fFaPz5Gug==} + engines: {node: '>=14.0.0'} + + forwarded@0.2.0: + resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==} + engines: {node: '>= 0.6'} + + fresh@0.5.2: + resolution: {integrity: sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==} + engines: {node: '>= 0.6'} + + fs-constants@1.0.0: + resolution: {integrity: sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==} + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + function-bind@1.1.2: + resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + + get-caller-file@2.0.5: + resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} + engines: {node: 6.* || 8.* || >= 10.*} + + get-func-name@2.0.2: + resolution: {integrity: sha512-8vXOvuE167CtIc3OyItco7N/dpRtBbYOsPsXCz7X/PMnlGjYjSGuZJgM1Y7mmew7BKf9BqvLX2tnOVy1BBUsxQ==} + + get-intrinsic@1.3.0: + resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} + engines: {node: '>= 0.4'} + + get-proto@1.0.1: + resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} + engines: {node: '>= 0.4'} + + get-stream@8.0.1: + resolution: {integrity: sha512-VaUJspBffn/LMCJVoMvSAdmscJyS1auj5Zulnn5UoYcY531UWmdwhRWkcGKnGU93m5HSXP9LP2usOryrBtQowA==} + engines: {node: '>=16'} + + get-tsconfig@4.14.0: + resolution: {integrity: sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA==} + + github-from-package@0.0.0: + resolution: {integrity: sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==} + + gopd@1.2.0: + resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} + engines: {node: '>= 0.4'} + + has-symbols@1.1.0: + resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} + engines: {node: '>= 0.4'} + + has-tostringtag@1.0.2: + resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==} + engines: {node: '>= 0.4'} + + hasown@2.0.3: + resolution: {integrity: sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==} + engines: {node: '>= 0.4'} + + helmet@7.2.0: + resolution: {integrity: sha512-ZRiwvN089JfMXokizgqEPXsl2Guk094yExfoDXR0cBYWxtBbaSww/w+vT4WEJsBW2iTUi1GgZ6swmoug3Oy4Xw==} + engines: {node: '>=16.0.0'} + + help-me@5.0.0: + resolution: {integrity: sha512-7xgomUX6ADmcYzFik0HzAxh/73YlKR9bmFzf51CZwR+b6YtzU2m0u49hQCqV6SvlqIqsaxovfwdvbnsw3b/zpg==} + + http-errors@2.0.1: + resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==} + engines: {node: '>= 0.8'} + + human-signals@5.0.0: + resolution: {integrity: sha512-AXcZb6vzzrFAUE61HnN4mpLqd/cSIwNQjtNWR0euPm6y0iqx3G4gOXaIDdtdDwZmhwe82LA6+zinmW4UBWVePQ==} + engines: {node: '>=16.17.0'} + + iconv-lite@0.4.24: + resolution: {integrity: sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==} + engines: {node: '>=0.10.0'} + + ieee754@1.2.1: + resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} + + inherits@2.0.4: + resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + + ini@1.3.8: + resolution: {integrity: sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==} + + ipaddr.js@1.9.1: + resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==} + engines: {node: '>= 0.10'} + + is-stream@3.0.0: + resolution: {integrity: sha512-LnQR4bZ9IADDRSkvpqMGvt/tEJWclzklNgSw48V5EAaAeDd6qGvN8ei6k5p0tvxSR171VmGyHuTiAOfxAbr8kA==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + + isexe@2.0.0: + resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + + joycon@3.1.1: + resolution: {integrity: sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==} + engines: {node: '>=10'} + + js-tokens@9.0.1: + resolution: {integrity: sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==} + + local-pkg@0.5.1: + resolution: {integrity: sha512-9rrA30MRRP3gBD3HTGnC6cDFpaE1kVDWxWgqWJUN0RvDNAo+Nz/9GxB+nHOH0ifbVFy0hSA1V6vFDvnx54lTEQ==} + engines: {node: '>=14'} + + loupe@2.3.7: + resolution: {integrity: sha512-zSMINGVYkdpYSOBmLi0D1Uo7JU9nVdQKrHxC8eYlV+9YKK9WePqAlL7lSlorG/U2Fw1w0hTBmaa/jrQ3UbPHtA==} + + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + + math-intrinsics@1.1.0: + resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} + engines: {node: '>= 0.4'} + + media-typer@0.3.0: + resolution: {integrity: sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==} + engines: {node: '>= 0.6'} + + merge-descriptors@1.0.3: + resolution: {integrity: sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==} + + merge-stream@2.0.0: + resolution: {integrity: sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==} + + methods@1.1.2: + resolution: {integrity: sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==} + engines: {node: '>= 0.6'} + + mime-db@1.52.0: + resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} + engines: {node: '>= 0.6'} + + mime-types@2.1.35: + resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} + engines: {node: '>= 0.6'} + + mime@1.6.0: + resolution: {integrity: sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==} + engines: {node: '>=4'} + hasBin: true + + mime@2.6.0: + resolution: {integrity: sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==} + engines: {node: '>=4.0.0'} + hasBin: true + + mimic-fn@4.0.0: + resolution: {integrity: sha512-vqiC06CuhBTUdZH+RYl8sFrL096vA45Ok5ISO6sE/Mr1jRbGH4Csnhi8f3wKVl7x8mO4Au7Ir9D3Oyv1VYMFJw==} + engines: {node: '>=12'} + + mimic-response@3.1.0: + resolution: {integrity: sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==} + engines: {node: '>=10'} + + minimist@1.2.8: + resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} + + mkdirp-classic@0.5.3: + resolution: {integrity: sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==} + + mlly@1.8.2: + resolution: {integrity: sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA==} + + ms@2.0.0: + resolution: {integrity: sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + nanoid@3.3.12: + resolution: {integrity: sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + napi-build-utils@2.0.0: + resolution: {integrity: sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==} + + negotiator@0.6.3: + resolution: {integrity: sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==} + engines: {node: '>= 0.6'} + + node-abi@3.90.0: + resolution: {integrity: sha512-pZNQT7UnYlMwMBy5N1lV5X/YLTbZM5ncytN3xL7CHEzhDN8uVe0u55yaPUJICIJjaCW8NrM5BFdqr7HLweStNA==} + engines: {node: '>=10'} + + npm-run-path@5.3.0: + resolution: {integrity: sha512-ppwTtiJZq0O/ai0z7yfudtBpWIoxM8yE6nHi1X47eFR2EWORqfbu6CnPlNsjeN683eT0qG6H/Pyf9fCcvjnnnQ==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + + object-assign@4.1.1: + resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} + engines: {node: '>=0.10.0'} + + object-inspect@1.13.4: + resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} + engines: {node: '>= 0.4'} + + on-exit-leak-free@2.1.2: + resolution: {integrity: sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==} + engines: {node: '>=14.0.0'} + + on-finished@2.4.1: + resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==} + engines: {node: '>= 0.8'} + + once@1.4.0: + resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} + + onetime@6.0.0: + resolution: {integrity: sha512-1FlR+gjXK7X+AsAHso35MnyN5KqGwJRi/31ft6x0M194ht7S+rWAvd7PHss9xSKMzE0asv1pyIHaJYq+BbacAQ==} + engines: {node: '>=12'} + + p-limit@5.0.0: + resolution: {integrity: sha512-/Eaoq+QyLSiXQ4lyYV23f14mZRQcXnxfHrN0vCai+ak9G0pp9iEQukIIZq5NccEvwRB8PUnZT0KsOoDCINS1qQ==} + engines: {node: '>=18'} + + parseurl@1.3.3: + resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} + engines: {node: '>= 0.8'} + + path-key@3.1.1: + resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} + engines: {node: '>=8'} + + path-key@4.0.0: + resolution: {integrity: sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==} + engines: {node: '>=12'} + + path-to-regexp@0.1.13: + resolution: {integrity: sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==} + + pathe@1.1.2: + resolution: {integrity: sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==} + + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + + pathval@1.1.1: + resolution: {integrity: sha512-Dp6zGqpTdETdR63lehJYPeIOqpiNBNtc7BpWSLrOje7UaIsE5aY92r/AunQA7rsXvet3lrJ3JnZX29UPTKXyKQ==} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + pino-abstract-transport@2.0.0: + resolution: {integrity: sha512-F63x5tizV6WCh4R6RHyi2Ml+M70DNRXt/+HANowMflpgGFMAym/VKm6G7ZOQRjqN7XbGxK1Lg9t6ZrtzOaivMw==} + + pino-http@10.5.0: + resolution: {integrity: sha512-hD91XjgaKkSsdn8P7LaebrNzhGTdB086W3pyPihX0EzGPjq5uBJBXo4N5guqNaK6mUjg9aubMF7wDViYek9dRA==} + + pino-pretty@11.3.0: + resolution: {integrity: sha512-oXwn7ICywaZPHmu3epHGU2oJX4nPmKvHvB/bwrJHlGcbEWaVcotkpyVHMKLKmiVryWYByNp0jpgAcXpFJDXJzA==} + hasBin: true + + pino-std-serializers@7.1.0: + resolution: {integrity: sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==} + + pino@9.14.0: + resolution: {integrity: sha512-8OEwKp5juEvb/MjpIc4hjqfgCNysrS94RIOMXYvpYCdm/jglrKEiAYmiumbmGhCvs+IcInsphYDFwqrjr7398w==} + hasBin: true + + pkg-types@1.3.1: + resolution: {integrity: sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==} + + postcss@8.5.13: + resolution: {integrity: sha512-qif0+jGGZoLWdHey3UFHHWP0H7Gbmsk8T5VEqyYFbWqPr1XqvLGBbk/sl8V5exGmcYJklJOhOQq1pV9IcsiFag==} + engines: {node: ^10 || ^12 || >=14} + + prebuild-install@7.1.3: + resolution: {integrity: sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==} + engines: {node: '>=10'} + deprecated: No longer maintained. Please contact the author of the relevant native addon; alternatives are available. + hasBin: true + + pretty-format@29.7.0: + resolution: {integrity: sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + process-warning@5.0.0: + resolution: {integrity: sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==} + + process@0.11.10: + resolution: {integrity: sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==} + engines: {node: '>= 0.6.0'} + + proxy-addr@2.0.7: + resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} + engines: {node: '>= 0.10'} + + pump@3.0.4: + resolution: {integrity: sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==} + + qs@6.14.2: + resolution: {integrity: sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q==} + engines: {node: '>=0.6'} + + qs@6.15.1: + resolution: {integrity: sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg==} + engines: {node: '>=0.6'} + + quick-format-unescaped@4.0.4: + resolution: {integrity: sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==} + + range-parser@1.2.1: + resolution: {integrity: sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==} + engines: {node: '>= 0.6'} + + raw-body@2.5.3: + resolution: {integrity: sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==} + engines: {node: '>= 0.8'} + + rc@1.2.8: + resolution: {integrity: sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==} + hasBin: true + + react-is@18.3.1: + resolution: {integrity: sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==} + + readable-stream@3.6.2: + resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==} + engines: {node: '>= 6'} + + readable-stream@4.7.0: + resolution: {integrity: sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + + real-require@0.2.0: + resolution: {integrity: sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==} + engines: {node: '>= 12.13.0'} + + resolve-pkg-maps@1.0.0: + resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} + + rollup@4.60.2: + resolution: {integrity: sha512-J9qZyW++QK/09NyN/zeO0dG/1GdGfyp9lV8ajHnRVLfo/uFsbji5mHnDgn/qYdUHyCkM2N+8VyspgZclfAh0eQ==} + engines: {node: '>=18.0.0', npm: '>=8.0.0'} + hasBin: true + + safe-buffer@5.2.1: + resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} + + safe-stable-stringify@2.5.0: + resolution: {integrity: sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==} + engines: {node: '>=10'} + + safer-buffer@2.1.2: + resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + + secure-json-parse@2.7.0: + resolution: {integrity: sha512-6aU+Rwsezw7VR8/nyvKTx8QpWH9FrcYiXXlqC4z5d5XQBDRqtbfsRjnwGyqbi3gddNtWHuEk9OANUotL26qKUw==} + + semver@7.7.4: + resolution: {integrity: sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==} + engines: {node: '>=10'} + hasBin: true + + send@0.19.2: + resolution: {integrity: sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==} + engines: {node: '>= 0.8.0'} + + serve-static@1.16.3: + resolution: {integrity: sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==} + engines: {node: '>= 0.8.0'} + + setprototypeof@1.2.0: + resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} + + shebang-command@2.0.0: + resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} + engines: {node: '>=8'} + + shebang-regex@3.0.0: + resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} + engines: {node: '>=8'} + + side-channel-list@1.0.1: + resolution: {integrity: sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==} + engines: {node: '>= 0.4'} + + side-channel-map@1.0.1: + resolution: {integrity: sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==} + engines: {node: '>= 0.4'} + + side-channel-weakmap@1.0.2: + resolution: {integrity: sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==} + engines: {node: '>= 0.4'} + + side-channel@1.1.0: + resolution: {integrity: sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==} + engines: {node: '>= 0.4'} + + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + + signal-exit@4.1.0: + resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} + engines: {node: '>=14'} + + simple-concat@1.0.1: + resolution: {integrity: sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==} + + simple-get@4.0.1: + resolution: {integrity: sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==} + + sonic-boom@4.2.1: + resolution: {integrity: sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==} + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + split2@4.2.0: + resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==} + engines: {node: '>= 10.x'} + + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + + statuses@2.0.2: + resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} + engines: {node: '>= 0.8'} + + std-env@3.10.0: + resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} + + string_decoder@1.3.0: + resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==} + + strip-final-newline@3.0.0: + resolution: {integrity: sha512-dOESqjYr96iWYylGObzd39EuNTa5VJxyvVAEm5Jnh7KGo75V43Hk1odPQkNDyXNmUR6k+gEiDVXnjB8HJ3crXw==} + engines: {node: '>=12'} + + strip-json-comments@2.0.1: + resolution: {integrity: sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==} + engines: {node: '>=0.10.0'} + + strip-json-comments@3.1.1: + resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} + engines: {node: '>=8'} + + strip-literal@2.1.1: + resolution: {integrity: sha512-631UJ6O00eNGfMiWG78ck80dfBab8X6IVFB51jZK5Icd7XAs60Z5y7QdSd/wGIklnWvRbUNloVzhOKKmutxQ6Q==} + + superagent@10.3.0: + resolution: {integrity: sha512-B+4Ik7ROgVKrQsXTV0Jwp2u+PXYLSlqtDAhYnkkD+zn3yg8s/zjA2MeGayPoY/KICrbitwneDHrjSotxKL+0XQ==} + engines: {node: '>=14.18.0'} + + supertest@7.2.2: + resolution: {integrity: sha512-oK8WG9diS3DlhdUkcFn4tkNIiIbBx9lI2ClF8K+b2/m8Eyv47LSawxUzZQSNKUrVb2KsqeTDCcjAAVPYaSLVTA==} + engines: {node: '>=14.18.0'} + + tar-fs@2.1.4: + resolution: {integrity: sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==} + + tar-stream@2.2.0: + resolution: {integrity: sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==} + engines: {node: '>=6'} + + thread-stream@3.1.0: + resolution: {integrity: sha512-OqyPZ9u96VohAyMfJykzmivOrY2wfMSf3C5TtFJVgN+Hm6aj+voFhlK+kZEIv2FBh1X6Xp3DlnCOfEQ3B2J86A==} + + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + + tinypool@0.8.4: + resolution: {integrity: sha512-i11VH5gS6IFeLY3gMBQ00/MmLncVP7JLXOw1vlgkytLmJK7QnEr7NXf0LBdxfmNPAeyetukOk0bOYrJrFGjYJQ==} + engines: {node: '>=14.0.0'} + + tinyspy@2.2.1: + resolution: {integrity: sha512-KYad6Vy5VDWV4GH3fjpseMQ/XU2BhIYP7Vzd0LG44qRWm/Yt2WCOTicFdvmgo6gWaqooMQCawTtILVQJupKu7A==} + engines: {node: '>=14.0.0'} + + toidentifier@1.0.1: + resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} + engines: {node: '>=0.6'} + + tsx@4.21.0: + resolution: {integrity: sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==} + engines: {node: '>=18.0.0'} + hasBin: true + + tunnel-agent@0.6.0: + resolution: {integrity: sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==} + + type-detect@4.1.0: + resolution: {integrity: sha512-Acylog8/luQ8L7il+geoSxhEkazvkslg7PSNKOX59mbB9cOveP5aq9h74Y7YU8yDpJwetzQQrfIwtf4Wp4LKcw==} + engines: {node: '>=4'} + + type-is@1.6.18: + resolution: {integrity: sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==} + engines: {node: '>= 0.6'} + + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true + + ufo@1.6.4: + resolution: {integrity: sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA==} + + undici-types@6.21.0: + resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + + unpipe@1.0.0: + resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==} + engines: {node: '>= 0.8'} + + util-deprecate@1.0.2: + resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + + utils-merge@1.0.1: + resolution: {integrity: sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==} + engines: {node: '>= 0.4.0'} + + uuid@10.0.0: + resolution: {integrity: sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==} + deprecated: uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028). + hasBin: true + + vary@1.1.2: + resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} + engines: {node: '>= 0.8'} + + vite-node@1.6.1: + resolution: {integrity: sha512-YAXkfvGtuTzwWbDSACdJSg4A4DZiAqckWe90Zapc/sEX3XvHcw1NdurM/6od8J207tSDqNbSsgdCacBgvJKFuA==} + engines: {node: ^18.0.0 || >=20.0.0} + hasBin: true + + vite@5.4.21: + resolution: {integrity: sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==} + engines: {node: ^18.0.0 || >=20.0.0} + hasBin: true + peerDependencies: + '@types/node': ^18.0.0 || >=20.0.0 + less: '*' + lightningcss: ^1.21.0 + sass: '*' + sass-embedded: '*' + stylus: '*' + sugarss: '*' + terser: ^5.4.0 + peerDependenciesMeta: + '@types/node': + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + + vitest@1.6.1: + resolution: {integrity: sha512-Ljb1cnSJSivGN0LqXd/zmDbWEM0RNNg2t1QW/XUhYl/qPqyu7CsqeWtqQXHVaJsecLPuDoak2oJcZN2QoRIOag==} + engines: {node: ^18.0.0 || >=20.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@types/node': ^18.0.0 || >=20.0.0 + '@vitest/browser': 1.6.1 + '@vitest/ui': 1.6.1 + happy-dom: '*' + jsdom: '*' + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@types/node': + optional: true + '@vitest/browser': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + + which@2.0.2: + resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} + engines: {node: '>= 8'} + hasBin: true + + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + + wrappy@1.0.2: + resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + + yocto-queue@1.2.2: + resolution: {integrity: sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ==} + engines: {node: '>=12.20'} + + zod@3.25.76: + resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==} + +snapshots: + + '@esbuild/aix-ppc64@0.21.5': + optional: true + + '@esbuild/aix-ppc64@0.27.7': + optional: true + + '@esbuild/android-arm64@0.21.5': + optional: true + + '@esbuild/android-arm64@0.27.7': + optional: true + + '@esbuild/android-arm@0.21.5': + optional: true + + '@esbuild/android-arm@0.27.7': + optional: true + + '@esbuild/android-x64@0.21.5': + optional: true + + '@esbuild/android-x64@0.27.7': + optional: true + + '@esbuild/darwin-arm64@0.21.5': + optional: true + + '@esbuild/darwin-arm64@0.27.7': + optional: true + + '@esbuild/darwin-x64@0.21.5': + optional: true + + '@esbuild/darwin-x64@0.27.7': + optional: true + + '@esbuild/freebsd-arm64@0.21.5': + optional: true + + '@esbuild/freebsd-arm64@0.27.7': + optional: true + + '@esbuild/freebsd-x64@0.21.5': + optional: true + + '@esbuild/freebsd-x64@0.27.7': + optional: true + + '@esbuild/linux-arm64@0.21.5': + optional: true + + '@esbuild/linux-arm64@0.27.7': + optional: true + + '@esbuild/linux-arm@0.21.5': + optional: true + + '@esbuild/linux-arm@0.27.7': + optional: true + + '@esbuild/linux-ia32@0.21.5': + optional: true + + '@esbuild/linux-ia32@0.27.7': + optional: true + + '@esbuild/linux-loong64@0.21.5': + optional: true + + '@esbuild/linux-loong64@0.27.7': + optional: true + + '@esbuild/linux-mips64el@0.21.5': + optional: true + + '@esbuild/linux-mips64el@0.27.7': + optional: true + + '@esbuild/linux-ppc64@0.21.5': + optional: true + + '@esbuild/linux-ppc64@0.27.7': + optional: true + + '@esbuild/linux-riscv64@0.21.5': + optional: true + + '@esbuild/linux-riscv64@0.27.7': + optional: true + + '@esbuild/linux-s390x@0.21.5': + optional: true + + '@esbuild/linux-s390x@0.27.7': + optional: true + + '@esbuild/linux-x64@0.21.5': + optional: true + + '@esbuild/linux-x64@0.27.7': + optional: true + + '@esbuild/netbsd-arm64@0.27.7': + optional: true + + '@esbuild/netbsd-x64@0.21.5': + optional: true + + '@esbuild/netbsd-x64@0.27.7': + optional: true + + '@esbuild/openbsd-arm64@0.27.7': + optional: true + + '@esbuild/openbsd-x64@0.21.5': + optional: true + + '@esbuild/openbsd-x64@0.27.7': + optional: true + + '@esbuild/openharmony-arm64@0.27.7': + optional: true + + '@esbuild/sunos-x64@0.21.5': + optional: true + + '@esbuild/sunos-x64@0.27.7': + optional: true + + '@esbuild/win32-arm64@0.21.5': + optional: true + + '@esbuild/win32-arm64@0.27.7': + optional: true + + '@esbuild/win32-ia32@0.21.5': + optional: true + + '@esbuild/win32-ia32@0.27.7': + optional: true + + '@esbuild/win32-x64@0.21.5': + optional: true + + '@esbuild/win32-x64@0.27.7': + optional: true + + '@jest/schemas@29.6.3': + dependencies: + '@sinclair/typebox': 0.27.10 + + '@jridgewell/sourcemap-codec@1.5.5': {} + + '@noble/hashes@1.8.0': {} + + '@paralleldrive/cuid2@2.3.1': + dependencies: + '@noble/hashes': 1.8.0 + + '@pinojs/redact@0.4.0': {} + + '@rollup/rollup-android-arm-eabi@4.60.2': + optional: true + + '@rollup/rollup-android-arm64@4.60.2': + optional: true + + '@rollup/rollup-darwin-arm64@4.60.2': + optional: true + + '@rollup/rollup-darwin-x64@4.60.2': + optional: true + + '@rollup/rollup-freebsd-arm64@4.60.2': + optional: true + + '@rollup/rollup-freebsd-x64@4.60.2': + optional: true + + '@rollup/rollup-linux-arm-gnueabihf@4.60.2': + optional: true + + '@rollup/rollup-linux-arm-musleabihf@4.60.2': + optional: true + + '@rollup/rollup-linux-arm64-gnu@4.60.2': + optional: true + + '@rollup/rollup-linux-arm64-musl@4.60.2': + optional: true + + '@rollup/rollup-linux-loong64-gnu@4.60.2': + optional: true + + '@rollup/rollup-linux-loong64-musl@4.60.2': + optional: true + + '@rollup/rollup-linux-ppc64-gnu@4.60.2': + optional: true + + '@rollup/rollup-linux-ppc64-musl@4.60.2': + optional: true + + '@rollup/rollup-linux-riscv64-gnu@4.60.2': + optional: true + + '@rollup/rollup-linux-riscv64-musl@4.60.2': + optional: true + + '@rollup/rollup-linux-s390x-gnu@4.60.2': + optional: true + + '@rollup/rollup-linux-x64-gnu@4.60.2': + optional: true + + '@rollup/rollup-linux-x64-musl@4.60.2': + optional: true + + '@rollup/rollup-openbsd-x64@4.60.2': + optional: true + + '@rollup/rollup-openharmony-arm64@4.60.2': + optional: true + + '@rollup/rollup-win32-arm64-msvc@4.60.2': + optional: true + + '@rollup/rollup-win32-ia32-msvc@4.60.2': + optional: true + + '@rollup/rollup-win32-x64-gnu@4.60.2': + optional: true + + '@rollup/rollup-win32-x64-msvc@4.60.2': + optional: true + + '@sinclair/typebox@0.27.10': {} + + '@types/better-sqlite3@7.6.13': + dependencies: + '@types/node': 20.19.39 + + '@types/body-parser@1.19.6': + dependencies: + '@types/connect': 3.4.38 + '@types/node': 20.19.39 + + '@types/connect@3.4.38': + dependencies: + '@types/node': 20.19.39 + + '@types/cookiejar@2.1.5': {} + + '@types/cors@2.8.19': + dependencies: + '@types/node': 20.19.39 + + '@types/estree@1.0.8': {} + + '@types/express-serve-static-core@4.19.8': + dependencies: + '@types/node': 20.19.39 + '@types/qs': 6.15.0 + '@types/range-parser': 1.2.7 + '@types/send': 1.2.1 + + '@types/express@4.17.25': + dependencies: + '@types/body-parser': 1.19.6 + '@types/express-serve-static-core': 4.19.8 + '@types/qs': 6.15.0 + '@types/serve-static': 1.15.10 + + '@types/http-errors@2.0.5': {} + + '@types/methods@1.1.4': {} + + '@types/mime@1.3.5': {} + + '@types/node@20.19.39': + dependencies: + undici-types: 6.21.0 + + '@types/qs@6.15.0': {} + + '@types/range-parser@1.2.7': {} + + '@types/send@0.17.6': + dependencies: + '@types/mime': 1.3.5 + '@types/node': 20.19.39 + + '@types/send@1.2.1': + dependencies: + '@types/node': 20.19.39 + + '@types/serve-static@1.15.10': + dependencies: + '@types/http-errors': 2.0.5 + '@types/node': 20.19.39 + '@types/send': 0.17.6 + + '@types/superagent@8.1.9': + dependencies: + '@types/cookiejar': 2.1.5 + '@types/methods': 1.1.4 + '@types/node': 20.19.39 + form-data: 4.0.5 + + '@types/supertest@6.0.3': + dependencies: + '@types/methods': 1.1.4 + '@types/superagent': 8.1.9 + + '@types/uuid@10.0.0': {} + + '@vitest/expect@1.6.1': + dependencies: + '@vitest/spy': 1.6.1 + '@vitest/utils': 1.6.1 + chai: 4.5.0 + + '@vitest/runner@1.6.1': + dependencies: + '@vitest/utils': 1.6.1 + p-limit: 5.0.0 + pathe: 1.1.2 + + '@vitest/snapshot@1.6.1': + dependencies: + magic-string: 0.30.21 + pathe: 1.1.2 + pretty-format: 29.7.0 + + '@vitest/spy@1.6.1': + dependencies: + tinyspy: 2.2.1 + + '@vitest/utils@1.6.1': + dependencies: + diff-sequences: 29.6.3 + estree-walker: 3.0.3 + loupe: 2.3.7 + pretty-format: 29.7.0 + + abort-controller@3.0.0: + dependencies: + event-target-shim: 5.0.1 + + accepts@1.3.8: + dependencies: + mime-types: 2.1.35 + negotiator: 0.6.3 + + acorn-walk@8.3.5: + dependencies: + acorn: 8.16.0 + + acorn@8.16.0: {} + + ansi-styles@5.2.0: {} + + array-flatten@1.1.1: {} + + asap@2.0.6: {} + + assertion-error@1.1.0: {} + + asynckit@0.4.0: {} + + atomic-sleep@1.0.0: {} + + base64-js@1.5.1: {} + + better-sqlite3@11.10.0: + dependencies: + bindings: 1.5.0 + prebuild-install: 7.1.3 + + bindings@1.5.0: + dependencies: + file-uri-to-path: 1.0.0 + + bl@4.1.0: + dependencies: + buffer: 5.7.1 + inherits: 2.0.4 + readable-stream: 3.6.2 + + body-parser@1.20.5: + dependencies: + bytes: 3.1.2 + content-type: 1.0.5 + debug: 2.6.9 + depd: 2.0.0 + destroy: 1.2.0 + http-errors: 2.0.1 + iconv-lite: 0.4.24 + on-finished: 2.4.1 + qs: 6.15.1 + raw-body: 2.5.3 + type-is: 1.6.18 + unpipe: 1.0.0 + transitivePeerDependencies: + - supports-color + + buffer@5.7.1: + dependencies: + base64-js: 1.5.1 + ieee754: 1.2.1 + + buffer@6.0.3: + dependencies: + base64-js: 1.5.1 + ieee754: 1.2.1 + + bytes@3.1.2: {} + + cac@6.7.14: {} + + call-bind-apply-helpers@1.0.2: + dependencies: + es-errors: 1.3.0 + function-bind: 1.1.2 + + call-bound@1.0.4: + dependencies: + call-bind-apply-helpers: 1.0.2 + get-intrinsic: 1.3.0 + + chai@4.5.0: + dependencies: + assertion-error: 1.1.0 + check-error: 1.0.3 + deep-eql: 4.1.4 + get-func-name: 2.0.2 + loupe: 2.3.7 + pathval: 1.1.1 + type-detect: 4.1.0 + + check-error@1.0.3: + dependencies: + get-func-name: 2.0.2 + + chownr@1.1.4: {} + + colorette@2.0.20: {} + + combined-stream@1.0.8: + dependencies: + delayed-stream: 1.0.0 + + component-emitter@1.3.1: {} + + confbox@0.1.8: {} + + content-disposition@0.5.4: + dependencies: + safe-buffer: 5.2.1 + + content-type@1.0.5: {} + + cookie-signature@1.0.7: {} + + cookie-signature@1.2.2: {} + + cookie@0.7.2: {} + + cookiejar@2.1.4: {} + + cors@2.8.6: + dependencies: + object-assign: 4.1.1 + vary: 1.1.2 + + cross-spawn@7.0.6: + dependencies: + path-key: 3.1.1 + shebang-command: 2.0.0 + which: 2.0.2 + + dateformat@4.6.3: {} + + debug@2.6.9: + dependencies: + ms: 2.0.0 + + debug@4.4.3: + dependencies: + ms: 2.1.3 + + decompress-response@6.0.0: + dependencies: + mimic-response: 3.1.0 + + deep-eql@4.1.4: + dependencies: + type-detect: 4.1.0 + + deep-extend@0.6.0: {} + + delayed-stream@1.0.0: {} + + depd@2.0.0: {} + + destroy@1.2.0: {} + + detect-libc@2.1.2: {} + + dezalgo@1.0.4: + dependencies: + asap: 2.0.6 + wrappy: 1.0.2 + + diff-sequences@29.6.3: {} + + dotenv@16.6.1: {} + + dunder-proto@1.0.1: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-errors: 1.3.0 + gopd: 1.2.0 + + ee-first@1.1.1: {} + + encodeurl@2.0.0: {} + + end-of-stream@1.4.5: + dependencies: + once: 1.4.0 + + es-define-property@1.0.1: {} + + es-errors@1.3.0: {} + + es-object-atoms@1.1.1: + dependencies: + es-errors: 1.3.0 + + es-set-tostringtag@2.1.0: + dependencies: + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + has-tostringtag: 1.0.2 + hasown: 2.0.3 + + esbuild@0.21.5: + optionalDependencies: + '@esbuild/aix-ppc64': 0.21.5 + '@esbuild/android-arm': 0.21.5 + '@esbuild/android-arm64': 0.21.5 + '@esbuild/android-x64': 0.21.5 + '@esbuild/darwin-arm64': 0.21.5 + '@esbuild/darwin-x64': 0.21.5 + '@esbuild/freebsd-arm64': 0.21.5 + '@esbuild/freebsd-x64': 0.21.5 + '@esbuild/linux-arm': 0.21.5 + '@esbuild/linux-arm64': 0.21.5 + '@esbuild/linux-ia32': 0.21.5 + '@esbuild/linux-loong64': 0.21.5 + '@esbuild/linux-mips64el': 0.21.5 + '@esbuild/linux-ppc64': 0.21.5 + '@esbuild/linux-riscv64': 0.21.5 + '@esbuild/linux-s390x': 0.21.5 + '@esbuild/linux-x64': 0.21.5 + '@esbuild/netbsd-x64': 0.21.5 + '@esbuild/openbsd-x64': 0.21.5 + '@esbuild/sunos-x64': 0.21.5 + '@esbuild/win32-arm64': 0.21.5 + '@esbuild/win32-ia32': 0.21.5 + '@esbuild/win32-x64': 0.21.5 + + esbuild@0.27.7: + optionalDependencies: + '@esbuild/aix-ppc64': 0.27.7 + '@esbuild/android-arm': 0.27.7 + '@esbuild/android-arm64': 0.27.7 + '@esbuild/android-x64': 0.27.7 + '@esbuild/darwin-arm64': 0.27.7 + '@esbuild/darwin-x64': 0.27.7 + '@esbuild/freebsd-arm64': 0.27.7 + '@esbuild/freebsd-x64': 0.27.7 + '@esbuild/linux-arm': 0.27.7 + '@esbuild/linux-arm64': 0.27.7 + '@esbuild/linux-ia32': 0.27.7 + '@esbuild/linux-loong64': 0.27.7 + '@esbuild/linux-mips64el': 0.27.7 + '@esbuild/linux-ppc64': 0.27.7 + '@esbuild/linux-riscv64': 0.27.7 + '@esbuild/linux-s390x': 0.27.7 + '@esbuild/linux-x64': 0.27.7 + '@esbuild/netbsd-arm64': 0.27.7 + '@esbuild/netbsd-x64': 0.27.7 + '@esbuild/openbsd-arm64': 0.27.7 + '@esbuild/openbsd-x64': 0.27.7 + '@esbuild/openharmony-arm64': 0.27.7 + '@esbuild/sunos-x64': 0.27.7 + '@esbuild/win32-arm64': 0.27.7 + '@esbuild/win32-ia32': 0.27.7 + '@esbuild/win32-x64': 0.27.7 + + escape-html@1.0.3: {} + + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.8 + + etag@1.8.1: {} + + event-target-shim@5.0.1: {} + + events@3.3.0: {} + + execa@8.0.1: + dependencies: + cross-spawn: 7.0.6 + get-stream: 8.0.1 + human-signals: 5.0.0 + is-stream: 3.0.0 + merge-stream: 2.0.0 + npm-run-path: 5.3.0 + onetime: 6.0.0 + signal-exit: 4.1.0 + strip-final-newline: 3.0.0 + + expand-template@2.0.3: {} + + express-rate-limit@7.5.1(express@4.22.1): + dependencies: + express: 4.22.1 + + express@4.22.1: + dependencies: + accepts: 1.3.8 + array-flatten: 1.1.1 + body-parser: 1.20.5 + content-disposition: 0.5.4 + content-type: 1.0.5 + cookie: 0.7.2 + cookie-signature: 1.0.7 + debug: 2.6.9 + depd: 2.0.0 + encodeurl: 2.0.0 + escape-html: 1.0.3 + etag: 1.8.1 + finalhandler: 1.3.2 + fresh: 0.5.2 + http-errors: 2.0.1 + merge-descriptors: 1.0.3 + methods: 1.1.2 + on-finished: 2.4.1 + parseurl: 1.3.3 + path-to-regexp: 0.1.13 + proxy-addr: 2.0.7 + qs: 6.14.2 + range-parser: 1.2.1 + safe-buffer: 5.2.1 + send: 0.19.2 + serve-static: 1.16.3 + setprototypeof: 1.2.0 + statuses: 2.0.2 + type-is: 1.6.18 + utils-merge: 1.0.1 + vary: 1.1.2 + transitivePeerDependencies: + - supports-color + + fast-copy@3.0.2: {} + + fast-safe-stringify@2.1.1: {} + + file-uri-to-path@1.0.0: {} + + finalhandler@1.3.2: + dependencies: + debug: 2.6.9 + encodeurl: 2.0.0 + escape-html: 1.0.3 + on-finished: 2.4.1 + parseurl: 1.3.3 + statuses: 2.0.2 + unpipe: 1.0.0 + transitivePeerDependencies: + - supports-color + + form-data@4.0.5: + dependencies: + asynckit: 0.4.0 + combined-stream: 1.0.8 + es-set-tostringtag: 2.1.0 + hasown: 2.0.3 + mime-types: 2.1.35 + + formidable@3.5.4: + dependencies: + '@paralleldrive/cuid2': 2.3.1 + dezalgo: 1.0.4 + once: 1.4.0 + + forwarded@0.2.0: {} + + fresh@0.5.2: {} + + fs-constants@1.0.0: {} + + fsevents@2.3.3: + optional: true + + function-bind@1.1.2: {} + + get-caller-file@2.0.5: {} + + get-func-name@2.0.2: {} + + get-intrinsic@1.3.0: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-define-property: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.1 + function-bind: 1.1.2 + get-proto: 1.0.1 + gopd: 1.2.0 + has-symbols: 1.1.0 + hasown: 2.0.3 + math-intrinsics: 1.1.0 + + get-proto@1.0.1: + dependencies: + dunder-proto: 1.0.1 + es-object-atoms: 1.1.1 + + get-stream@8.0.1: {} + + get-tsconfig@4.14.0: + dependencies: + resolve-pkg-maps: 1.0.0 + + github-from-package@0.0.0: {} + + gopd@1.2.0: {} + + has-symbols@1.1.0: {} + + has-tostringtag@1.0.2: + dependencies: + has-symbols: 1.1.0 + + hasown@2.0.3: + dependencies: + function-bind: 1.1.2 + + helmet@7.2.0: {} + + help-me@5.0.0: {} + + http-errors@2.0.1: + dependencies: + depd: 2.0.0 + inherits: 2.0.4 + setprototypeof: 1.2.0 + statuses: 2.0.2 + toidentifier: 1.0.1 + + human-signals@5.0.0: {} + + iconv-lite@0.4.24: + dependencies: + safer-buffer: 2.1.2 + + ieee754@1.2.1: {} + + inherits@2.0.4: {} + + ini@1.3.8: {} + + ipaddr.js@1.9.1: {} + + is-stream@3.0.0: {} + + isexe@2.0.0: {} + + joycon@3.1.1: {} + + js-tokens@9.0.1: {} + + local-pkg@0.5.1: + dependencies: + mlly: 1.8.2 + pkg-types: 1.3.1 + + loupe@2.3.7: + dependencies: + get-func-name: 2.0.2 + + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + + math-intrinsics@1.1.0: {} + + media-typer@0.3.0: {} + + merge-descriptors@1.0.3: {} + + merge-stream@2.0.0: {} + + methods@1.1.2: {} + + mime-db@1.52.0: {} + + mime-types@2.1.35: + dependencies: + mime-db: 1.52.0 + + mime@1.6.0: {} + + mime@2.6.0: {} + + mimic-fn@4.0.0: {} + + mimic-response@3.1.0: {} + + minimist@1.2.8: {} + + mkdirp-classic@0.5.3: {} + + mlly@1.8.2: + dependencies: + acorn: 8.16.0 + pathe: 2.0.3 + pkg-types: 1.3.1 + ufo: 1.6.4 + + ms@2.0.0: {} + + ms@2.1.3: {} + + nanoid@3.3.12: {} + + napi-build-utils@2.0.0: {} + + negotiator@0.6.3: {} + + node-abi@3.90.0: + dependencies: + semver: 7.7.4 + + npm-run-path@5.3.0: + dependencies: + path-key: 4.0.0 + + object-assign@4.1.1: {} + + object-inspect@1.13.4: {} + + on-exit-leak-free@2.1.2: {} + + on-finished@2.4.1: + dependencies: + ee-first: 1.1.1 + + once@1.4.0: + dependencies: + wrappy: 1.0.2 + + onetime@6.0.0: + dependencies: + mimic-fn: 4.0.0 + + p-limit@5.0.0: + dependencies: + yocto-queue: 1.2.2 + + parseurl@1.3.3: {} + + path-key@3.1.1: {} + + path-key@4.0.0: {} + + path-to-regexp@0.1.13: {} + + pathe@1.1.2: {} + + pathe@2.0.3: {} + + pathval@1.1.1: {} + + picocolors@1.1.1: {} + + pino-abstract-transport@2.0.0: + dependencies: + split2: 4.2.0 + + pino-http@10.5.0: + dependencies: + get-caller-file: 2.0.5 + pino: 9.14.0 + pino-std-serializers: 7.1.0 + process-warning: 5.0.0 + + pino-pretty@11.3.0: + dependencies: + colorette: 2.0.20 + dateformat: 4.6.3 + fast-copy: 3.0.2 + fast-safe-stringify: 2.1.1 + help-me: 5.0.0 + joycon: 3.1.1 + minimist: 1.2.8 + on-exit-leak-free: 2.1.2 + pino-abstract-transport: 2.0.0 + pump: 3.0.4 + readable-stream: 4.7.0 + secure-json-parse: 2.7.0 + sonic-boom: 4.2.1 + strip-json-comments: 3.1.1 + + pino-std-serializers@7.1.0: {} + + pino@9.14.0: + dependencies: + '@pinojs/redact': 0.4.0 + atomic-sleep: 1.0.0 + on-exit-leak-free: 2.1.2 + pino-abstract-transport: 2.0.0 + pino-std-serializers: 7.1.0 + process-warning: 5.0.0 + quick-format-unescaped: 4.0.4 + real-require: 0.2.0 + safe-stable-stringify: 2.5.0 + sonic-boom: 4.2.1 + thread-stream: 3.1.0 + + pkg-types@1.3.1: + dependencies: + confbox: 0.1.8 + mlly: 1.8.2 + pathe: 2.0.3 + + postcss@8.5.13: + dependencies: + nanoid: 3.3.12 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + prebuild-install@7.1.3: + dependencies: + detect-libc: 2.1.2 + expand-template: 2.0.3 + github-from-package: 0.0.0 + minimist: 1.2.8 + mkdirp-classic: 0.5.3 + napi-build-utils: 2.0.0 + node-abi: 3.90.0 + pump: 3.0.4 + rc: 1.2.8 + simple-get: 4.0.1 + tar-fs: 2.1.4 + tunnel-agent: 0.6.0 + + pretty-format@29.7.0: + dependencies: + '@jest/schemas': 29.6.3 + ansi-styles: 5.2.0 + react-is: 18.3.1 + + process-warning@5.0.0: {} + + process@0.11.10: {} + + proxy-addr@2.0.7: + dependencies: + forwarded: 0.2.0 + ipaddr.js: 1.9.1 + + pump@3.0.4: + dependencies: + end-of-stream: 1.4.5 + once: 1.4.0 + + qs@6.14.2: + dependencies: + side-channel: 1.1.0 + + qs@6.15.1: + dependencies: + side-channel: 1.1.0 + + quick-format-unescaped@4.0.4: {} + + range-parser@1.2.1: {} + + raw-body@2.5.3: + dependencies: + bytes: 3.1.2 + http-errors: 2.0.1 + iconv-lite: 0.4.24 + unpipe: 1.0.0 + + rc@1.2.8: + dependencies: + deep-extend: 0.6.0 + ini: 1.3.8 + minimist: 1.2.8 + strip-json-comments: 2.0.1 + + react-is@18.3.1: {} + + readable-stream@3.6.2: + dependencies: + inherits: 2.0.4 + string_decoder: 1.3.0 + util-deprecate: 1.0.2 + + readable-stream@4.7.0: + dependencies: + abort-controller: 3.0.0 + buffer: 6.0.3 + events: 3.3.0 + process: 0.11.10 + string_decoder: 1.3.0 + + real-require@0.2.0: {} + + resolve-pkg-maps@1.0.0: {} + + rollup@4.60.2: + dependencies: + '@types/estree': 1.0.8 + optionalDependencies: + '@rollup/rollup-android-arm-eabi': 4.60.2 + '@rollup/rollup-android-arm64': 4.60.2 + '@rollup/rollup-darwin-arm64': 4.60.2 + '@rollup/rollup-darwin-x64': 4.60.2 + '@rollup/rollup-freebsd-arm64': 4.60.2 + '@rollup/rollup-freebsd-x64': 4.60.2 + '@rollup/rollup-linux-arm-gnueabihf': 4.60.2 + '@rollup/rollup-linux-arm-musleabihf': 4.60.2 + '@rollup/rollup-linux-arm64-gnu': 4.60.2 + '@rollup/rollup-linux-arm64-musl': 4.60.2 + '@rollup/rollup-linux-loong64-gnu': 4.60.2 + '@rollup/rollup-linux-loong64-musl': 4.60.2 + '@rollup/rollup-linux-ppc64-gnu': 4.60.2 + '@rollup/rollup-linux-ppc64-musl': 4.60.2 + '@rollup/rollup-linux-riscv64-gnu': 4.60.2 + '@rollup/rollup-linux-riscv64-musl': 4.60.2 + '@rollup/rollup-linux-s390x-gnu': 4.60.2 + '@rollup/rollup-linux-x64-gnu': 4.60.2 + '@rollup/rollup-linux-x64-musl': 4.60.2 + '@rollup/rollup-openbsd-x64': 4.60.2 + '@rollup/rollup-openharmony-arm64': 4.60.2 + '@rollup/rollup-win32-arm64-msvc': 4.60.2 + '@rollup/rollup-win32-ia32-msvc': 4.60.2 + '@rollup/rollup-win32-x64-gnu': 4.60.2 + '@rollup/rollup-win32-x64-msvc': 4.60.2 + fsevents: 2.3.3 + + safe-buffer@5.2.1: {} + + safe-stable-stringify@2.5.0: {} + + safer-buffer@2.1.2: {} + + secure-json-parse@2.7.0: {} + + semver@7.7.4: {} + + send@0.19.2: + dependencies: + debug: 2.6.9 + depd: 2.0.0 + destroy: 1.2.0 + encodeurl: 2.0.0 + escape-html: 1.0.3 + etag: 1.8.1 + fresh: 0.5.2 + http-errors: 2.0.1 + mime: 1.6.0 + ms: 2.1.3 + on-finished: 2.4.1 + range-parser: 1.2.1 + statuses: 2.0.2 + transitivePeerDependencies: + - supports-color + + serve-static@1.16.3: + dependencies: + encodeurl: 2.0.0 + escape-html: 1.0.3 + parseurl: 1.3.3 + send: 0.19.2 + transitivePeerDependencies: + - supports-color + + setprototypeof@1.2.0: {} + + shebang-command@2.0.0: + dependencies: + shebang-regex: 3.0.0 + + shebang-regex@3.0.0: {} + + side-channel-list@1.0.1: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + + side-channel-map@1.0.1: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + + side-channel-weakmap@1.0.2: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + side-channel-map: 1.0.1 + + side-channel@1.1.0: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + side-channel-list: 1.0.1 + side-channel-map: 1.0.1 + side-channel-weakmap: 1.0.2 + + siginfo@2.0.0: {} + + signal-exit@4.1.0: {} + + simple-concat@1.0.1: {} + + simple-get@4.0.1: + dependencies: + decompress-response: 6.0.0 + once: 1.4.0 + simple-concat: 1.0.1 + + sonic-boom@4.2.1: + dependencies: + atomic-sleep: 1.0.0 + + source-map-js@1.2.1: {} + + split2@4.2.0: {} + + stackback@0.0.2: {} + + statuses@2.0.2: {} + + std-env@3.10.0: {} + + string_decoder@1.3.0: + dependencies: + safe-buffer: 5.2.1 + + strip-final-newline@3.0.0: {} + + strip-json-comments@2.0.1: {} + + strip-json-comments@3.1.1: {} + + strip-literal@2.1.1: + dependencies: + js-tokens: 9.0.1 + + superagent@10.3.0: + dependencies: + component-emitter: 1.3.1 + cookiejar: 2.1.4 + debug: 4.4.3 + fast-safe-stringify: 2.1.1 + form-data: 4.0.5 + formidable: 3.5.4 + methods: 1.1.2 + mime: 2.6.0 + qs: 6.15.1 + transitivePeerDependencies: + - supports-color + + supertest@7.2.2: + dependencies: + cookie-signature: 1.2.2 + methods: 1.1.2 + superagent: 10.3.0 + transitivePeerDependencies: + - supports-color + + tar-fs@2.1.4: + dependencies: + chownr: 1.1.4 + mkdirp-classic: 0.5.3 + pump: 3.0.4 + tar-stream: 2.2.0 + + tar-stream@2.2.0: + dependencies: + bl: 4.1.0 + end-of-stream: 1.4.5 + fs-constants: 1.0.0 + inherits: 2.0.4 + readable-stream: 3.6.2 + + thread-stream@3.1.0: + dependencies: + real-require: 0.2.0 + + tinybench@2.9.0: {} + + tinypool@0.8.4: {} + + tinyspy@2.2.1: {} + + toidentifier@1.0.1: {} + + tsx@4.21.0: + dependencies: + esbuild: 0.27.7 + get-tsconfig: 4.14.0 + optionalDependencies: + fsevents: 2.3.3 + + tunnel-agent@0.6.0: + dependencies: + safe-buffer: 5.2.1 + + type-detect@4.1.0: {} + + type-is@1.6.18: + dependencies: + media-typer: 0.3.0 + mime-types: 2.1.35 + + typescript@5.9.3: {} + + ufo@1.6.4: {} + + undici-types@6.21.0: {} + + unpipe@1.0.0: {} + + util-deprecate@1.0.2: {} + + utils-merge@1.0.1: {} + + uuid@10.0.0: {} + + vary@1.1.2: {} + + vite-node@1.6.1(@types/node@20.19.39): + dependencies: + cac: 6.7.14 + debug: 4.4.3 + pathe: 1.1.2 + picocolors: 1.1.1 + vite: 5.4.21(@types/node@20.19.39) + transitivePeerDependencies: + - '@types/node' + - less + - lightningcss + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + + vite@5.4.21(@types/node@20.19.39): + dependencies: + esbuild: 0.21.5 + postcss: 8.5.13 + rollup: 4.60.2 + optionalDependencies: + '@types/node': 20.19.39 + fsevents: 2.3.3 + + vitest@1.6.1(@types/node@20.19.39): + dependencies: + '@vitest/expect': 1.6.1 + '@vitest/runner': 1.6.1 + '@vitest/snapshot': 1.6.1 + '@vitest/spy': 1.6.1 + '@vitest/utils': 1.6.1 + acorn-walk: 8.3.5 + chai: 4.5.0 + debug: 4.4.3 + execa: 8.0.1 + local-pkg: 0.5.1 + magic-string: 0.30.21 + pathe: 1.1.2 + picocolors: 1.1.1 + std-env: 3.10.0 + strip-literal: 2.1.1 + tinybench: 2.9.0 + tinypool: 0.8.4 + vite: 5.4.21(@types/node@20.19.39) + vite-node: 1.6.1(@types/node@20.19.39) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 20.19.39 + transitivePeerDependencies: + - less + - lightningcss + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + + which@2.0.2: + dependencies: + isexe: 2.0.0 + + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 + + wrappy@1.0.2: {} + + yocto-queue@1.2.2: {} + + zod@3.25.76: {} diff --git a/src/problem5/src/app.ts b/src/problem5/src/app.ts new file mode 100644 index 0000000000..95bdeee24f --- /dev/null +++ b/src/problem5/src/app.ts @@ -0,0 +1,110 @@ +import cors from "cors"; +import express, { type Application } from "express"; +import { rateLimit } from "express-rate-limit"; +import helmet from "helmet"; +import { pinoHttp } from "pino-http"; +import { config } from "./config.js"; +import { openDatabase, type DB } from "./db/connection.js"; +import { migrate } from "./db/migrate.js"; +import { logger } from "./logger.js"; +import { errorHandler } from "./middleware/error-handler.js"; +import { notFound } from "./middleware/not-found.js"; +import { requestId } from "./middleware/request-id.js"; +import { createResourceController } from "./resources/resource.controller.js"; +import { createResourceRepository } from "./resources/resource.repo.js"; +import { createResourceRouter } from "./resources/resource.routes.js"; +import { createResourceService } from "./resources/resource.service.js"; + +/** + * Express app factory. + * + * Returns the application + an explicit teardown so tests can spin up an + * isolated app per file (pointing at `:memory:` SQLite) and close cleanly. + */ +export interface AppHandle { + app: Application; + db: DB; + close: () => void; +} + +export function createApp(opts?: { databasePath?: string }): AppHandle { + const db = openDatabase(opts?.databasePath ?? config.DATABASE_PATH); + migrate(db); + + // Wire the layers — DI by hand keeps the dependency graph explicit and + // avoids reaching for a container framework on a single-bounded-context app. + const repo = createResourceRepository(db); + const service = createResourceService(repo); + const controller = createResourceController(service); + + const app = express(); + + if (config.NODE_ENV === "production") app.set("trust proxy", 1); + + // Security & infra middleware ordered correctly: request-id first so every + // log line carries it; pino-http binds the logger before any handler runs. + app.use(requestId()); + app.use( + pinoHttp({ + logger, + customProps: (req) => ({ requestId: req.requestId }), + // Health checks would otherwise drown the logs. + autoLogging: { ignore: (req) => req.url?.startsWith("/health") ?? false }, + }), + ); + app.use(helmet()); + if (config.CORS_ORIGINS.length > 0) { + app.use(cors({ origin: config.CORS_ORIGINS, credentials: true })); + } + app.use(express.json({ limit: "100kb" })); // bound payload to deter DoS + app.disable("x-powered-by"); + + // Coarse global rate limit. Two layers: + // - Read traffic (`GET`) gets a generous quota — readers don't strain the + // single SQLite writer and we don't want to kneecap legit clients. + // - Write traffic (`POST`/`PATCH`/`DELETE`) is tighter, both because + // SQLite serialises writes (one busy writer holds everyone up) and + // because writes are how an attacker would amplify disk usage. + // Disabled in test so suite can run hundreds of requests in one tick. + if (config.NODE_ENV !== "test") { + const reads = rateLimit({ + windowMs: 60_000, + limit: 300, + standardHeaders: "draft-7", + legacyHeaders: false, + message: { ok: false, error: { code: "RATE_LIMITED", message: "Too many requests" } }, + skip: (req) => req.method !== "GET", + }); + const writes = rateLimit({ + windowMs: 60_000, + limit: 60, + standardHeaders: "draft-7", + legacyHeaders: false, + message: { ok: false, error: { code: "RATE_LIMITED", message: "Too many requests" } }, + skip: (req) => req.method === "GET", + }); + app.use(reads, writes); + } + + // Health endpoints — see rules/observability.md + app.get("/health", (_req, res) => res.status(200).json({ status: "ok" })); + app.get("/health/ready", (_req, res) => { + try { + db.prepare("SELECT 1").get(); + res.status(200).json({ status: "ready", db: "ok" }); + } catch (err) { + logger.error({ err }, "readiness check failed"); + res.status(503).json({ status: "not_ready", db: "unreachable" }); + } + }); + + app.use("/resources", createResourceRouter(controller)); + app.use(notFound()); + app.use(errorHandler); + + return { + app, + db, + close: () => db.close(), + }; +} diff --git a/src/problem5/src/config.ts b/src/problem5/src/config.ts new file mode 100644 index 0000000000..fb27a994fd --- /dev/null +++ b/src/problem5/src/config.ts @@ -0,0 +1,49 @@ +import "dotenv/config"; +import { z } from "zod"; + +/** + * Centralised, validated runtime configuration. + */ +const ConfigSchema = z.object({ + NODE_ENV: z + .enum(["development", "production", "test"]) + .default("development"), + PORT: z.coerce.number().int().positive().max(65535).default(3000), + HOST: z.string().min(1).default("127.0.0.1"), + DATABASE_PATH: z.string().min(1).default("./data/resources.db"), + LOG_LEVEL: z + .enum(["trace", "debug", "info", "warn", "error", "fatal", "silent"]) + .default("info"), + CORS_ORIGINS: z + .string() + .default("") + .transform((s) => + s + .split(",") + .map((o) => o.trim()) + .filter(Boolean), + ) + // Reject the dangerous wildcard. We send `credentials: true`, and the + // pairing of `Access-Control-Allow-Origin: *` with credentials is + // explicitly forbidden by the CORS spec — and an exfiltration risk if a + // browser ever did honour it. Force operators to enumerate origins. + .refine( + (origins) => !origins.includes("*"), + { message: "CORS_ORIGINS must not contain '*' — list explicit origins" }, + ), +}); + +export type AppConfig = z.infer; + +function loadConfig(): AppConfig { + const parsed = ConfigSchema.safeParse(process.env); + if (!parsed.success) { + const issues = parsed.error.issues.map( + (i) => `${i.path.join(".")}: ${i.message}`, + ); + throw new Error(`Invalid configuration:\n - ${issues.join("\n - ")}`); + } + return parsed.data; +} + +export const config: AppConfig = loadConfig(); diff --git a/src/problem5/src/db/connection.ts b/src/problem5/src/db/connection.ts new file mode 100644 index 0000000000..723b2cfae1 --- /dev/null +++ b/src/problem5/src/db/connection.ts @@ -0,0 +1,21 @@ +import Database, { type Database as SqliteDB } from "better-sqlite3"; +import { mkdirSync } from "node:fs"; +import { dirname } from "node:path"; +import { logger } from "../logger.js"; + +/** + * SQLite connection factory. + */ +export type DB = SqliteDB; + +export function openDatabase(filePath: string): DB { + if (filePath !== ":memory:") { + mkdirSync(dirname(filePath), { recursive: true }); + } + const db = new Database(filePath); + db.pragma("journal_mode = WAL"); + db.pragma("foreign_keys = ON"); + db.pragma("busy_timeout = 5000"); + logger.debug({ filePath }, "sqlite connection opened"); + return db; +} diff --git a/src/problem5/src/db/migrate.ts b/src/problem5/src/db/migrate.ts new file mode 100644 index 0000000000..d6c818ce72 --- /dev/null +++ b/src/problem5/src/db/migrate.ts @@ -0,0 +1,31 @@ +import { logger } from "../logger.js"; +import type { DB } from "./connection.js"; + +/** + * Idempotent schema bootstrap. Runs on every boot. + * + * Schema rationale: + * - id : UUID stored as TEXT — opaque, unguessable, no integer enum + * problems with multiple writers. + * - status : CHECK constraint enforces the enum at the engine layer + * (defense in depth alongside zod at the API layer). + * - created_at : ISO-8601 string for portability + lexicographic sortability. + * - updated_at : updated by service layer on every mutation. + * - Indexes : (category) and (status) for the documented filter queries. + */ +export function migrate(db: DB): void { + db.exec(` + CREATE TABLE IF NOT EXISTS resources ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + category TEXT NOT NULL, + status TEXT NOT NULL CHECK (status IN ('active','archived','draft')), + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + ); + CREATE INDEX IF NOT EXISTS idx_resources_category ON resources(category); + CREATE INDEX IF NOT EXISTS idx_resources_status ON resources(status); + CREATE INDEX IF NOT EXISTS idx_resources_created ON resources(created_at); + `); + logger.debug("schema migration complete"); +} diff --git a/src/problem5/src/errors.ts b/src/problem5/src/errors.ts new file mode 100644 index 0000000000..925b10ab50 --- /dev/null +++ b/src/problem5/src/errors.ts @@ -0,0 +1,74 @@ +/** + * Domain error hierarchy. + * + * Every operational error thrown by the application is an `AppError` so the + * error-handler middleware can map it to a consistent HTTP envelope without + * leaking stack traces. Programmer errors (TypeError, ReferenceError) are not + * AppError — they bubble to the default Express handler and surface as 500. + * + * Public vs internal messages: + * - `message` — internal, includes context, may name the resource id. + * Logged server-side; never sent to the client. + * - `publicMessage` — what the client is allowed to see. Defaults to a + * generic, code-correlated string; subclasses override + * when a specific message is safe to expose. + */ +export class AppError extends Error { + public readonly status: number; + public readonly code: string; + public readonly publicMessage: string; + public readonly details?: unknown; + + constructor(args: { + message: string; + publicMessage: string; + code: string; + status: number; + details?: unknown; + }) { + super(args.message); + this.name = "AppError"; + this.code = args.code; + this.status = args.status; + this.publicMessage = args.publicMessage; + this.details = args.details; + } +} + +export class NotFoundError extends AppError { + constructor(resource: string, id: string) { + super({ + message: `${resource} '${id}' not found`, + // Deliberately omits the id — clients already know it (they sent it). + publicMessage: `${resource} not found`, + code: "NOT_FOUND", + status: 404, + }); + this.name = "NotFoundError"; + } +} + +export class ValidationError extends AppError { + constructor(message: string, details?: unknown) { + super({ + message, + publicMessage: message, + code: "VALIDATION_ERROR", + status: 422, + details, + }); + this.name = "ValidationError"; + } +} + +export class ConflictError extends AppError { + constructor(message: string) { + super({ + message, + publicMessage: message, + code: "CONFLICT", + status: 409, + }); + this.name = "ConflictError"; + } +} diff --git a/src/problem5/src/logger.ts b/src/problem5/src/logger.ts new file mode 100644 index 0000000000..0887efb17a --- /dev/null +++ b/src/problem5/src/logger.ts @@ -0,0 +1,27 @@ +import pino, { type Logger } from "pino"; +import { config } from "./config.js"; + +/** + * Process-wide logger. + * + * - JSON output in production for log aggregator ingestion (Loki, ELK, Datadog). + * - Pretty output in development for human readability. + * - Silent in test by default to keep test runner output clean (override with LOG_LEVEL). + */ +export const logger: Logger = + config.NODE_ENV === "development" + ? pino({ + level: config.LOG_LEVEL, + transport: { + target: "pino-pretty", + options: { colorize: true, translateTime: "SYS:HH:MM:ss.l" }, + }, + }) + : pino({ + level: config.NODE_ENV === "test" ? "silent" : config.LOG_LEVEL, + // PII redaction at the logger layer — see rules/data-privacy.md + redact: { + paths: ["req.headers.authorization", "req.headers.cookie", "*.password", "*.token"], + remove: true, + }, + }); diff --git a/src/problem5/src/middleware/error-handler.ts b/src/problem5/src/middleware/error-handler.ts new file mode 100644 index 0000000000..e9809b14a5 --- /dev/null +++ b/src/problem5/src/middleware/error-handler.ts @@ -0,0 +1,72 @@ +import type { ErrorRequestHandler } from "express"; +import { ZodError } from "zod"; +import { AppError } from "../errors.js"; +import { logger } from "../logger.js"; + +/** + * Terminal error handler. Translates any thrown error into the standard + * response envelope and logs full context server-side. + * + * Envelope: { ok: false, error: { code, message, details? } } + * + * Never leaks stack traces or internal error messages to the client. + */ +export const errorHandler: ErrorRequestHandler = (err, req, res, _next) => { + // Handle zod errors specifically — they originate from req.body / req.query + // validation. Translate to 422 with field-level details. + if (err instanceof ZodError) { + const details = err.issues.map((i) => ({ + path: i.path.join("."), + message: i.message, + })); + logger.warn({ requestId: req.requestId, details }, "validation failed"); + res.status(422).json({ + ok: false, + error: { + code: "VALIDATION_ERROR", + message: "Request validation failed", + details, + }, + }); + return; + } + + if (err instanceof AppError) { + // Operational error — log at warn (expected category, not a bug) + logger.warn( + { + requestId: req.requestId, + code: err.code, + status: err.status, + details: err.details, + }, + err.message, + ); + res.status(err.status).json({ + ok: false, + error: { + code: err.code, + message: err.publicMessage, + ...(err.details !== undefined ? { details: err.details } : {}), + }, + }); + return; + } + + // Programmer error / unknown — log full stack, surface generic message + logger.error( + { + requestId: req.requestId, + err: { + name: (err as Error).name, + message: (err as Error).message, + stack: (err as Error).stack, + }, + }, + "unhandled error", + ); + res.status(500).json({ + ok: false, + error: { code: "INTERNAL_ERROR", message: "Internal server error" }, + }); +}; diff --git a/src/problem5/src/middleware/not-found.ts b/src/problem5/src/middleware/not-found.ts new file mode 100644 index 0000000000..b122d1816b --- /dev/null +++ b/src/problem5/src/middleware/not-found.ts @@ -0,0 +1,15 @@ +import type { RequestHandler } from "express"; + +/** + * Catch-all for unmatched routes. Returns a JSON 404 in the standard envelope + * so clients see a uniform shape rather than Express's default HTML. + */ +export const notFound = (): RequestHandler => (req, res) => { + res.status(404).json({ + ok: false, + error: { + code: "ROUTE_NOT_FOUND", + message: `Route ${req.method} ${req.originalUrl} does not exist`, + }, + }); +}; diff --git a/src/problem5/src/middleware/request-id.ts b/src/problem5/src/middleware/request-id.ts new file mode 100644 index 0000000000..ccd680f3fe --- /dev/null +++ b/src/problem5/src/middleware/request-id.ts @@ -0,0 +1,22 @@ +import type { RequestHandler } from "express"; +import { randomUUID } from "node:crypto"; + +const HEADER = "x-request-id"; + +// Allow opaque-but-safe values: alnum + a small set of separators, max 128 chars. +// Rejects CRLF and other control chars so a forged header cannot inject log +// fields or response splits downstream. +const SAFE_RE = /^[A-Za-z0-9_\-.:=+]{1,128}$/; + +/** + * Honour an inbound `X-Request-Id` if present (typical when behind an LB that + * generates it), otherwise mint a UUIDv4. Always echo back so the client can + * correlate with server-side logs. + */ +export const requestId = (): RequestHandler => (req, res, next) => { + const incoming = req.header(HEADER); + const id = incoming && SAFE_RE.test(incoming) ? incoming : randomUUID(); + req.requestId = id; + res.setHeader(HEADER, id); + next(); +}; diff --git a/src/problem5/src/resources/resource.controller.ts b/src/problem5/src/resources/resource.controller.ts new file mode 100644 index 0000000000..59a462aad2 --- /dev/null +++ b/src/problem5/src/resources/resource.controller.ts @@ -0,0 +1,68 @@ +import type { RequestHandler } from "express"; +import { + CreateResourceSchema, + IdParamSchema, + ListResourcesQuerySchema, + UpdateResourceSchema, +} from "./resource.schema.js"; +import type { ResourceService } from "./resource.service.js"; + +/** + * Controllers — translate HTTP ↔ service. + * + * Pattern: parse with zod, call the service, respond with the standard + * envelope `{ ok, data }`. Errors thrown by zod or the service propagate to + * the global error-handler middleware, which converts them to error envelopes. + */ +export interface ResourceController { + create: RequestHandler; + list: RequestHandler; + getById: RequestHandler; + update: RequestHandler; + delete: RequestHandler; +} + +export function createResourceController( + service: ResourceService, +): ResourceController { + return { + create: (req, res) => { + const body = CreateResourceSchema.parse(req.body); + const created = service.create(body); + res.status(201).json({ ok: true, data: created }); + }, + + list: (req, res) => { + const query = ListResourcesQuerySchema.parse(req.query); + const { items, nextCursor } = service.list(query); + res.status(200).json({ + ok: true, + data: items, + pagination: { + limit: query.limit, + nextCursor, + hasMore: nextCursor !== null, + }, + }); + }, + + getById: (req, res) => { + const { id } = IdParamSchema.parse(req.params); + const resource = service.getById(id); + res.status(200).json({ ok: true, data: resource }); + }, + + update: (req, res) => { + const { id } = IdParamSchema.parse(req.params); + const patch = UpdateResourceSchema.parse(req.body); + const updated = service.update(id, patch); + res.status(200).json({ ok: true, data: updated }); + }, + + delete: (req, res) => { + const { id } = IdParamSchema.parse(req.params); + service.delete(id); + res.status(204).end(); + }, + }; +} diff --git a/src/problem5/src/resources/resource.repo.ts b/src/problem5/src/resources/resource.repo.ts new file mode 100644 index 0000000000..38746684d1 --- /dev/null +++ b/src/problem5/src/resources/resource.repo.ts @@ -0,0 +1,203 @@ +import type { DB } from "../db/connection.js"; +import { ValidationError } from "../errors.js"; +import type { + CreateResourceInput, + ListResourcesQuery, + Resource, + UpdateResourceInput, +} from "./resource.schema.js"; + +/** + * Resource repository — the only module that speaks SQL. + * + * Why parameterised statements only: + * - No string interpolation into SQL — defense against injection. All + * user-derived values flow through `?` / `@named` placeholders. + * + * Why prepared statements: + * - better-sqlite3 caches the parsed plan, halving CRUD latency for hot + * queries vs constructing a fresh statement each call. + */ + +interface ResourceRow { + id: string; + name: string; + category: string; + status: Resource["status"]; + created_at: string; + updated_at: string; +} + +const rowToResource = (row: ResourceRow): Resource => ({ + id: row.id, + name: row.name, + category: row.category, + status: row.status, + createdAt: row.created_at, + updatedAt: row.updated_at, +}); + +/** + * Cursor format: base64url(`|`). + * + * Composite cursor `(created_at, id)` resolves the timestamp-collision + * problem: two rows inserted in the same millisecond are still uniquely + * orderable. Without this, a cursor of `created_at < @ts` could skip rows + * that share the boundary timestamp. + */ +interface DecodedCursor { + ts: string; + id: string; +} + +function encodeCursor(ts: string, id: string): string { + return Buffer.from(`${ts}|${id}`, "utf8").toString("base64url"); +} + +function decodeCursor(raw: string): DecodedCursor { + let decoded: string; + try { + decoded = Buffer.from(raw, "base64url").toString("utf8"); + } catch { + throw new ValidationError("Invalid cursor", { cursor: raw }); + } + const [ts, id] = decoded.split("|", 2); + if (!ts || !id) throw new ValidationError("Invalid cursor", { cursor: raw }); + return { ts, id }; +} + +/** + * Escape SQL `LIKE` metacharacters in user-supplied search terms. + * Without this, a query of `q=%` would match every row (DoS) and `q=_` + * would match every single character. Pair with `ESCAPE '\'` in the SQL. + */ +function escapeLikePattern(q: string): string { + return q.replace(/[\\%_]/g, (c) => `\\${c}`); +} + +export interface ResourceRepository { + insert( + input: CreateResourceInput & { + id: string; + createdAt: string; + updatedAt: string; + }, + ): Resource; + findById(id: string): Resource | null; + list(query: ListResourcesQuery): { items: Resource[]; nextCursor: string | null }; + update( + id: string, + patch: UpdateResourceInput & { updatedAt: string }, + ): Resource | null; + delete(id: string): boolean; +} + +export function createResourceRepository(db: DB): ResourceRepository { + const insertStmt = db.prepare(` + INSERT INTO resources (id, name, category, status, created_at, updated_at) + VALUES (@id, @name, @category, @status, @createdAt, @updatedAt) + `); + const findByIdStmt = db.prepare<[string], ResourceRow>(` + SELECT id, name, category, status, created_at, updated_at + FROM resources WHERE id = ? + `); + const deleteStmt = db.prepare(`DELETE FROM resources WHERE id = ?`); + + // Cache prepared list-query variants by filter shape. SQLite caches by + // statement-string identity, so we deduplicate the cache key explicitly to + // avoid statement-table growth under varied query shapes. + const listStmtCache = new Map>(); + function getListStmt(sql: string): ReturnType { + const cached = listStmtCache.get(sql); + if (cached) return cached; + const stmt = db.prepare(sql); + listStmtCache.set(sql, stmt); + return stmt; + } + + return { + insert(input) { + insertStmt.run(input); + const row = findByIdStmt.get(input.id); + if (!row) throw new Error("insert succeeded but row not found"); + return rowToResource(row); + }, + + findById(id) { + const row = findByIdStmt.get(id); + return row ? rowToResource(row) : null; + }, + + list(query) { + const where: string[] = []; + const params: Record = { limit: query.limit + 1 }; + if (query.category) { + where.push("category = @category"); + params.category = query.category; + } + if (query.status) { + where.push("status = @status"); + params.status = query.status; + } + if (query.q) { + // ESCAPE clause + manual escape of `%` and `_` so user-supplied + // wildcards are matched literally, not interpreted by SQLite. + where.push("name LIKE @q ESCAPE '\\'"); + params.q = `%${escapeLikePattern(query.q)}%`; + } + if (query.cursor) { + const { ts, id } = decodeCursor(query.cursor); + // Composite ordering: (created_at, id) DESC. + // (a, b) < (x, y) ≡ a < x OR (a = x AND b < y) + where.push( + "(created_at < @cursor_ts OR (created_at = @cursor_ts AND id < @cursor_id))", + ); + params.cursor_ts = ts; + params.cursor_id = id; + } + + const sql = ` + SELECT id, name, category, status, created_at, updated_at + FROM resources + ${where.length ? "WHERE " + where.join(" AND ") : ""} + ORDER BY created_at DESC, id DESC + LIMIT @limit + `; + const rows = (getListStmt(sql).all(params) as ResourceRow[]); + + const hasMore = rows.length > query.limit; + const items = (hasMore ? rows.slice(0, query.limit) : rows).map(rowToResource); + const last = items[items.length - 1]; + const nextCursor = hasMore && last ? encodeCursor(last.createdAt, last.id) : null; + return { items, nextCursor }; + }, + + update(id, patch) { + const sets: string[] = []; + const params: Record = { id, updatedAt: patch.updatedAt }; + if (patch.name !== undefined) { + sets.push("name = @name"); + params.name = patch.name; + } + if (patch.category !== undefined) { + sets.push("category = @category"); + params.category = patch.category; + } + if (patch.status !== undefined) { + sets.push("status = @status"); + params.status = patch.status; + } + sets.push("updated_at = @updatedAt"); + + const sql = `UPDATE resources SET ${sets.join(", ")} WHERE id = @id`; + const result = db.prepare(sql).run(params); + if (result.changes === 0) return null; + const row = findByIdStmt.get(id); + return row ? rowToResource(row) : null; + }, + + delete(id) { + return deleteStmt.run(id).changes > 0; + }, + }; +} diff --git a/src/problem5/src/resources/resource.routes.ts b/src/problem5/src/resources/resource.routes.ts new file mode 100644 index 0000000000..92013cf212 --- /dev/null +++ b/src/problem5/src/resources/resource.routes.ts @@ -0,0 +1,22 @@ +import { Router } from "express"; +import type { ResourceController } from "./resource.controller.js"; + +/** + * REST routes for the Resource aggregate. + * + * Conventions: + * - Plural resource noun (`/resources`) + * - HTTP verbs map 1:1 to CRUD + * - Path-only id; no `/by-id/{id}` boilerplate + * + * Every endpoint here is documented in README.md with curl examples. + */ +export function createResourceRouter(controller: ResourceController): Router { + const router = Router(); + router.post("/", controller.create); + router.get("/", controller.list); + router.get("/:id", controller.getById); + router.patch("/:id", controller.update); + router.delete("/:id", controller.delete); + return router; +} diff --git a/src/problem5/src/resources/resource.schema.ts b/src/problem5/src/resources/resource.schema.ts new file mode 100644 index 0000000000..1a17626e36 --- /dev/null +++ b/src/problem5/src/resources/resource.schema.ts @@ -0,0 +1,54 @@ +import { z } from "zod"; + +/** + * Single source of truth for resource shape. + * The DTO schemas (Create / Update / List) are derived from a base schema + */ + +export const ResourceStatus = z.enum(["active", "archived", "draft"]); +export type ResourceStatus = z.infer; + +const NameSchema = z.string().trim().min(1).max(120); +const CategorySchema = z.string().trim().min(1).max(60); + +export const ResourceSchema = z.object({ + id: z.string().uuid(), + name: NameSchema, + category: CategorySchema, + status: ResourceStatus, + createdAt: z.string().datetime(), + updatedAt: z.string().datetime(), +}); +export type Resource = z.infer; + +export const CreateResourceSchema = z.object({ + name: NameSchema, + category: CategorySchema, + status: ResourceStatus.default("active"), +}); +export type CreateResourceInput = z.infer; + +export const UpdateResourceSchema = z + .object({ + name: NameSchema.optional(), + category: CategorySchema.optional(), + status: ResourceStatus.optional(), + }) + .refine((v) => Object.keys(v).length > 0, { + message: "At least one field must be provided", + }); +export type UpdateResourceInput = z.infer; + +export const ListResourcesQuerySchema = z.object({ + category: CategorySchema.optional(), + status: ResourceStatus.optional(), + q: z.string().trim().min(1).max(120).optional(), + limit: z.coerce.number().int().min(1).max(100).default(20), + // Opaque base64 cursor — shape validated by the repository layer. + // Defined here only to bound length and disallow whitespace. + cursor: z.string().regex(/^[A-Za-z0-9_=-]{1,256}$/).optional(), +}); +export type ListResourcesQuery = z.infer; + +/** UUID path-parameter schema, reused on every `/resources/:id` endpoint. */ +export const IdParamSchema = z.object({ id: z.string().uuid() }); diff --git a/src/problem5/src/resources/resource.service.ts b/src/problem5/src/resources/resource.service.ts new file mode 100644 index 0000000000..b5c5a96025 --- /dev/null +++ b/src/problem5/src/resources/resource.service.ts @@ -0,0 +1,66 @@ +import { randomUUID } from "node:crypto"; +import { NotFoundError } from "../errors.js"; +import type { ResourceRepository } from "./resource.repo.js"; +import type { + CreateResourceInput, + ListResourcesQuery, + Resource, + UpdateResourceInput, +} from "./resource.schema.js"; + +/** + * Resource service — owns business logic. + * The service generates UUIDs and timestamps so the repo stays a pure SQL + * layer with no opinion about clock or ID strategy. + */ + +export interface ResourceService { + create(input: CreateResourceInput): Resource; + list(query: ListResourcesQuery): { + items: Resource[]; + nextCursor: string | null; + }; + getById(id: string): Resource; + update(id: string, patch: UpdateResourceInput): Resource; + delete(id: string): void; +} + +export function createResourceService( + repo: ResourceRepository, +): ResourceService { + return { + create(input) { + const now = new Date().toISOString(); + return repo.insert({ + ...input, + id: randomUUID(), + createdAt: now, + updatedAt: now, + }); + }, + + list(query) { + return repo.list(query); + }, + + getById(id) { + const found = repo.findById(id); + if (!found) throw new NotFoundError("Resource", id); + return found; + }, + + update(id, patch) { + const updated = repo.update(id, { + ...patch, + updatedAt: new Date().toISOString(), + }); + if (!updated) throw new NotFoundError("Resource", id); + return updated; + }, + + delete(id) { + const deleted = repo.delete(id); + if (!deleted) throw new NotFoundError("Resource", id); + }, + }; +} diff --git a/src/problem5/src/server.ts b/src/problem5/src/server.ts new file mode 100644 index 0000000000..1243b8b595 --- /dev/null +++ b/src/problem5/src/server.ts @@ -0,0 +1,59 @@ +import { createApp } from "./app.js"; +import { config } from "./config.js"; +import { logger } from "./logger.js"; + +/** + * Process entrypoint — starts the HTTP server and wires graceful shutdown. + * + * Graceful shutdown semantics: + * - On SIGTERM / SIGINT, stop accepting new connections. + * - Allow in-flight requests up to `SHUTDOWN_TIMEOUT_MS` to finish. + * - Close the SQLite handle to flush WAL. + * - Exit 0 on clean shutdown, 1 on timeout. + */ +const SHUTDOWN_TIMEOUT_MS = 10_000; + +const handle = createApp(); +const server = handle.app.listen(config.PORT, config.HOST, () => { + logger.info( + { port: config.PORT, host: config.HOST, env: config.NODE_ENV }, + "server listening", + ); +}); + +let shuttingDown = false; +function shutdown(signal: string): void { + if (shuttingDown) return; + shuttingDown = true; + logger.info({ signal }, "shutdown signal received, draining"); + + const timer = setTimeout(() => { + logger.error("shutdown timed out, forcing exit"); + process.exit(1); + }, SHUTDOWN_TIMEOUT_MS); + timer.unref(); + + server.close((err) => { + if (err) { + logger.error({ err }, "error during server close"); + process.exit(1); + } + handle.close(); + logger.info("shutdown complete"); + process.exit(0); + }); +} + +process.on("SIGTERM", () => shutdown("SIGTERM")); +process.on("SIGINT", () => shutdown("SIGINT")); + +// Last-resort safety nets — log and let the process die so the orchestrator +// restarts a fresh instance. Never swallow. +process.on("uncaughtException", (err) => { + logger.fatal({ err }, "uncaughtException"); + process.exit(1); +}); +process.on("unhandledRejection", (reason) => { + logger.fatal({ reason }, "unhandledRejection"); + process.exit(1); +}); diff --git a/src/problem5/src/types/express.d.ts b/src/problem5/src/types/express.d.ts new file mode 100644 index 0000000000..12cea42b7d --- /dev/null +++ b/src/problem5/src/types/express.d.ts @@ -0,0 +1,10 @@ +import "express"; + +declare global { + namespace Express { + interface Request { + /** Per-request correlation ID. Echoed in the X-Request-Id response header. */ + requestId: string; + } + } +} diff --git a/src/problem5/tests/resources.test.ts b/src/problem5/tests/resources.test.ts new file mode 100644 index 0000000000..49dcf77d18 --- /dev/null +++ b/src/problem5/tests/resources.test.ts @@ -0,0 +1,279 @@ +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import request from "supertest"; +import { createApp, type AppHandle } from "../src/app.js"; + +/** + * Integration tests for the resources CRUD surface. + * + * Each suite spins up a fresh in-memory SQLite via the createApp factory so + * tests are fully isolated and parallel-safe. We hit the real Express stack + * (middleware + zod + service + repo) — only the network is virtualised by + * supertest. + */ + +let handle: AppHandle; +const app = () => handle.app; + +beforeEach(() => { + handle = createApp({ databasePath: ":memory:" }); +}); +afterEach(() => { + handle.close(); +}); + +const sample = (overrides: Record = {}) => ({ + name: "Compute Cluster A", + category: "infrastructure", + status: "active", + ...overrides, +}); + +describe("POST /resources", () => { + it("creates a resource and returns 201 with the standard envelope", async () => { + const res = await request(app()).post("/resources").send(sample()); + expect(res.status).toBe(201); + expect(res.body.ok).toBe(true); + expect(res.body.data).toMatchObject({ + name: "Compute Cluster A", + category: "infrastructure", + status: "active", + }); + expect(res.body.data.id).toMatch(/^[0-9a-f-]{36}$/); + expect(typeof res.body.data.createdAt).toBe("string"); + expect(res.body.data.updatedAt).toBe(res.body.data.createdAt); + }); + + it("defaults status to 'active' when omitted", async () => { + const res = await request(app()) + .post("/resources") + .send({ name: "X", category: "y" }); + expect(res.status).toBe(201); + expect(res.body.data.status).toBe("active"); + }); + + it("rejects missing fields with 422 + field details", async () => { + const res = await request(app()).post("/resources").send({ name: "" }); + expect(res.status).toBe(422); + expect(res.body.ok).toBe(false); + expect(res.body.error.code).toBe("VALIDATION_ERROR"); + expect(res.body.error.details).toEqual( + expect.arrayContaining([ + expect.objectContaining({ path: "name" }), + expect.objectContaining({ path: "category" }), + ]), + ); + }); + + it("rejects invalid status enum value", async () => { + const res = await request(app()) + .post("/resources") + .send(sample({ status: "exploded" })); + expect(res.status).toBe(422); + expect(res.body.error.details[0].path).toBe("status"); + }); + + it("trims whitespace and rejects strings that become empty", async () => { + const res = await request(app()) + .post("/resources") + .send(sample({ name: " " })); + expect(res.status).toBe(422); + }); +}); + +describe("GET /resources", () => { + it("returns empty list with no pagination on a fresh DB", async () => { + const res = await request(app()).get("/resources"); + expect(res.status).toBe(200); + expect(res.body.data).toEqual([]); + expect(res.body.pagination).toEqual({ + limit: 20, + nextCursor: null, + hasMore: false, + }); + }); + + it("filters by category and status", async () => { + await request(app()).post("/resources").send(sample({ name: "A", category: "infra" })); + await request(app()).post("/resources").send(sample({ name: "B", category: "infra", status: "draft" })); + await request(app()).post("/resources").send(sample({ name: "C", category: "media" })); + + const r1 = await request(app()).get("/resources?category=infra"); + expect(r1.status).toBe(200); + expect(r1.body.data).toHaveLength(2); + expect(r1.body.data.every((r: { category: string }) => r.category === "infra")).toBe(true); + + const r2 = await request(app()).get("/resources?status=draft"); + expect(r2.body.data).toHaveLength(1); + expect(r2.body.data[0].name).toBe("B"); + + const r3 = await request(app()).get("/resources?q=B"); + expect(r3.body.data).toHaveLength(1); + expect(r3.body.data[0].name).toBe("B"); + }); + + it("paginates with cursor", async () => { + for (const name of ["A", "B", "C"]) { + await request(app()).post("/resources").send(sample({ name })); + await new Promise((r) => setTimeout(r, 5)); + } + const page1 = await request(app()).get("/resources?limit=2"); + expect(page1.body.data).toHaveLength(2); + expect(page1.body.pagination.hasMore).toBe(true); + expect(page1.body.pagination.nextCursor).toBeTruthy(); + + const cursor = encodeURIComponent(page1.body.pagination.nextCursor as string); + const page2 = await request(app()).get(`/resources?limit=2&cursor=${cursor}`); + expect(page2.body.data).toHaveLength(1); + expect(page2.body.pagination.hasMore).toBe(false); + }); + + it("paginates correctly even when rows share a created_at", async () => { + // Insert without delay so timestamps may collide. The composite cursor + // (created_at, id) must still produce a stable, complete walk. + const inserts = await Promise.all( + ["A", "B", "C", "D", "E"].map((name) => + request(app()).post("/resources").send(sample({ name })), + ), + ); + expect(inserts.every((r) => r.status === 201)).toBe(true); + + const seen = new Set(); + let cursor: string | null = null; + for (let page = 0; page < 5; page++) { + const url = `/resources?limit=2${cursor ? `&cursor=${encodeURIComponent(cursor)}` : ""}`; + const res = await request(app()).get(url); + expect(res.status).toBe(200); + for (const row of res.body.data) seen.add(row.id); + cursor = res.body.pagination.nextCursor; + if (!cursor) break; + } + expect(seen.size).toBe(5); + }); + + it("rejects an invalid cursor with 422", async () => { + const res = await request(app()).get("/resources?cursor=" + encodeURIComponent("@@@bad@@@")); + expect(res.status).toBe(422); + }); + + it("treats LIKE wildcards in `q` as literal characters", async () => { + await request(app()).post("/resources").send(sample({ name: "alpha" })); + await request(app()).post("/resources").send(sample({ name: "beta" })); + await request(app()).post("/resources").send(sample({ name: "100% off" })); + + // q='%' must NOT match every row — it should match only the literal '%'. + const r1 = await request(app()).get("/resources?q=%25"); // url-encoded '%' + expect(r1.body.data).toHaveLength(1); + expect(r1.body.data[0].name).toBe("100% off"); + + // q='_' must NOT match every single-char row. + const r2 = await request(app()).get("/resources?q=_"); + expect(r2.body.data).toHaveLength(0); + }); + + it("rejects limit > 100", async () => { + const res = await request(app()).get("/resources?limit=999"); + expect(res.status).toBe(422); + }); +}); + +describe("GET /resources/:id", () => { + it("returns 200 + the resource for an existing id", async () => { + const created = await request(app()).post("/resources").send(sample()); + const id = created.body.data.id as string; + const res = await request(app()).get(`/resources/${id}`); + expect(res.status).toBe(200); + expect(res.body.data.id).toBe(id); + }); + + it("returns 404 for a valid-but-unknown UUID", async () => { + const res = await request(app()).get("/resources/00000000-0000-4000-8000-000000000000"); + expect(res.status).toBe(404); + expect(res.body.error.code).toBe("NOT_FOUND"); + }); + + it("returns 422 for a non-UUID id", async () => { + const res = await request(app()).get("/resources/not-a-uuid"); + expect(res.status).toBe(422); + }); +}); + +describe("PATCH /resources/:id", () => { + it("updates only the supplied fields and bumps updatedAt", async () => { + const created = await request(app()).post("/resources").send(sample()); + const id = created.body.data.id as string; + const before = created.body.data.updatedAt as string; + + await new Promise((r) => setTimeout(r, 5)); + const res = await request(app()) + .patch(`/resources/${id}`) + .send({ status: "archived" }); + expect(res.status).toBe(200); + expect(res.body.data.status).toBe("archived"); + expect(res.body.data.name).toBe("Compute Cluster A"); // unchanged + expect(res.body.data.updatedAt > before).toBe(true); + }); + + it("rejects empty body with 422", async () => { + const created = await request(app()).post("/resources").send(sample()); + const id = created.body.data.id as string; + const res = await request(app()).patch(`/resources/${id}`).send({}); + expect(res.status).toBe(422); + }); + + it("returns 404 when the id is unknown", async () => { + const res = await request(app()) + .patch("/resources/00000000-0000-4000-8000-000000000000") + .send({ status: "archived" }); + expect(res.status).toBe(404); + }); +}); + +describe("DELETE /resources/:id", () => { + it("returns 204 and the resource is gone afterwards", async () => { + const created = await request(app()).post("/resources").send(sample()); + const id = created.body.data.id as string; + + const del = await request(app()).delete(`/resources/${id}`); + expect(del.status).toBe(204); + + const get = await request(app()).get(`/resources/${id}`); + expect(get.status).toBe(404); + }); + + it("returns 404 when the id is unknown", async () => { + const res = await request(app()).delete("/resources/00000000-0000-4000-8000-000000000000"); + expect(res.status).toBe(404); + }); +}); + +describe("infrastructure", () => { + it("/health returns 200 ok", async () => { + const res = await request(app()).get("/health"); + expect(res.status).toBe(200); + expect(res.body.status).toBe("ok"); + }); + + it("/health/ready confirms DB connectivity", async () => { + const res = await request(app()).get("/health/ready"); + expect(res.status).toBe(200); + expect(res.body.db).toBe("ok"); + }); + + it("echoes a supplied X-Request-Id", async () => { + const res = await request(app()) + .get("/health") + .set("X-Request-Id", "abc-test-123"); + expect(res.headers["x-request-id"]).toBe("abc-test-123"); + }); + + it("generates an X-Request-Id when none is supplied", async () => { + const res = await request(app()).get("/health"); + expect(res.headers["x-request-id"]).toMatch(/^[0-9a-f-]{36}$/); + }); + + it("404s unknown routes with the standard envelope", async () => { + const res = await request(app()).get("/no-such-thing"); + expect(res.status).toBe(404); + expect(res.body.error.code).toBe("ROUTE_NOT_FOUND"); + }); +}); diff --git a/src/problem5/tsconfig.build.json b/src/problem5/tsconfig.build.json new file mode 100644 index 0000000000..bbb22a6734 --- /dev/null +++ b/src/problem5/tsconfig.build.json @@ -0,0 +1,9 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "noEmit": false, + "rootDir": "src" + }, + "include": ["src/**/*.ts"], + "exclude": ["node_modules", "dist", "tests/**/*"] +} diff --git a/src/problem5/tsconfig.json b/src/problem5/tsconfig.json new file mode 100644 index 0000000000..3414e0b1bb --- /dev/null +++ b/src/problem5/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "Bundler", + "strict": true, + "noUncheckedIndexedAccess": true, + "exactOptionalPropertyTypes": false, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + "esModuleInterop": true, + "skipLibCheck": true, + "isolatedModules": true, + "resolveJsonModule": true, + "outDir": "dist", + "types": ["node"] + }, + "include": ["src/**/*.ts", "tests/**/*.ts"], + "exclude": ["node_modules", "dist"] +} diff --git a/src/problem6/FLOW.md b/src/problem6/FLOW.md new file mode 100644 index 0000000000..06a6cc4579 --- /dev/null +++ b/src/problem6/FLOW.md @@ -0,0 +1,256 @@ +# Problem 6 — Flow of Execution + +This document illustrates the flow of execution for the Live Scoreboard +module. It complements `README.md` (the full specification) by isolating +the runtime flows so an implementing engineer can keep the diagrams open +on a second screen while writing code. + +The diagrams use [Mermaid](https://mermaid.js.org). Render in any +Mermaid-aware viewer (GitHub, GitLab, VS Code preview). + +--- + +## 1. End-to-end: action start → score credited → live update + +```mermaid +sequenceDiagram + autonumber + actor U as User + participant W as Web client + participant LB as Load balancer + participant API as API service + participant R as Redis + participant PG as Postgres + participant OB as Outbox publisher + participant WSG as WebSocket gateway + participant V as Other viewers + + Note over U,W: --- Phase 1 — start the action --- + U->>W: starts action + W->>LB: POST /v1/actions/start (Bearer + Idempotency-Key) + LB->>API: forward + API->>API: verify session JWT (sig, exp, sub) + API->>R: GET score:idem:{userId}:{key} + alt cached response present + R-->>API: cached body + API-->>W: 200 (replayed) + else fresh request + API->>R: rate-check (sliding window) + API->>API: mint action token
{sub, act, pts, jti, exp=now+60s, kid} + API->>R: SETEX score:idem:{userId}:{key} 24h + API-->>W: 200 { actionToken, expiresAt } + end + + Note over U,W: --- Phase 2 — complete the action --- + U->>W: completes action + W->>LB: POST /v1/scores/complete (Bearer + Idempotency-Key, actionToken) + LB->>API: forward + API->>API: verify session JWT + verify action token
(sig, kid, exp, sub == bearer) + API->>R: SETNX score:redeemed:{jti} (TTL ~ tokenExp + 30s) + alt jti already redeemed + R-->>API: 0 + API-->>W: 409 TOKEN_REPLAYED + else first redemption + R-->>API: 1 + API->>R: rate-check (sliding window) + API->>PG: BEGIN + API->>PG: INSERT INTO actions (id=jti, user_id, action_type, points, idempotency_key)
-- UNIQUE(user_id, idempotency_key) defends double-credit + API->>PG: UPDATE user_scores
SET score=score+pts, updated_at=now()
WHERE user_id=$1
RETURNING score AS new_total + API->>PG: INSERT INTO scoreboard_outbox (user_id, points, new_total) + API->>PG: COMMIT + API->>R: SETEX score:idem:{userId}:{key} 24h + API-->>W: 200 { newTotal, rank } + end + + Note over OB,V: --- Phase 3 — broadcast (asynchronous, ≤ 1 s after commit) --- + loop every 200 ms + OB->>PG: SELECT seq, user_id, new_total
FROM scoreboard_outbox
WHERE published_at IS NULL
ORDER BY seq LIMIT N + PG-->>OB: rows + OB->>R: pipeline:
ZADD scores:global new_total userId (idempotent — absolute score)
INCR scoreboard:version
PUBLISH pub:scoreboard {userId, new_total, version} + R-->>WSG: pub:scoreboard delta + WSG->>WSG: diff prev vs current top-10 + WSG-->>V: WS push { type: "delta", changes: [...] } + OB->>PG: UPDATE scoreboard_outbox
SET published_at=now()
WHERE seq IN (...) + end +``` + +### Crash safety summary + +| Crash point | Effect | +| ---------------------------------------------------- | ------------------------------------------------------------------------------------------ | +| Before PG `COMMIT` | Nothing happened. Client retries with the same Idempotency-Key — safe. | +| After PG `COMMIT`, before publisher reads | Outbox row is durable. Publisher picks it up on next tick (≤ 1 s). | +| Publisher wrote Redis but didn't flag `published_at` | Re-publish on next tick. `ZADD ` is idempotent (absolute, not increment). Safe. | + +--- + +## 2. Live channel lifecycle (WebSocket) + +```mermaid +sequenceDiagram + autonumber + actor U as Viewer + participant W as Web client + participant API as API service + participant WSG as WebSocket gateway + participant R as Redis + participant PS as Redis Pub/Sub + + U->>W: opens scoreboard page + W->>API: POST /v1/scoreboard/ws-ticket (Bearer) + API->>API: mint single-use ticket JWT (TTL 30s) + API-->>W: 200 { ticket } + + W->>WSG: WS upgrade /v1/scoreboard/live?ticket=... + WSG->>WSG: verify ticket, mark consumed + WSG-->>W: 101 Switching Protocols + + W->>WSG: { "type": "subscribe", "since": } + WSG->>R: ZRANGE scores:global REV LIMIT 0 10 + GET scoreboard:version + WSG-->>W: { "type": "snapshot", version, top10 } + + WSG->>PS: SUBSCRIBE pub:scoreboard + + loop while connected + PS-->>WSG: delta {userId, new_total, version} + WSG->>WSG: project to top-10 diff (enter / leave / reorder) + alt affects top-10 + WSG-->>W: { "type": "delta", version, changes: [...] } + else no top-10 change + WSG->>WSG: drop (client doesn't need re-render) + end + end + + loop every 30s + WSG-->>W: { "type": "ping", ts } + W-->>WSG: pong + end + + alt missed pong > 60s + WSG-xW: close 1011 + W->>WSG: reconnect with last seen `version` + end +``` + +--- + +## 3. Component interaction (deployment view) + +```mermaid +flowchart TB + subgraph "Client tier" + Web[Web app
React/Vue/Vanilla] + end + + subgraph "Edge / Ingress" + LB[L7 LB / TLS termination] + CDN[CDN
cacheable GETs only] + end + + subgraph "API tier (stateless, HPA on RPS)" + API1[API pod 1] + API2[API pod 2] + APIn[API pod N] + end + + subgraph "Realtime tier (stateless, sticky on userId)" + WS1[WS gateway 1] + WS2[WS gateway 2] + end + + subgraph "Async workers" + OB[Outbox publisher
leader-elected] + Recon[Reconciler
cold-start / drift repair] + end + + subgraph "Stateful tier" + R[(Redis primary + replica
scores:global ZSET
idem / replay / rate keys)] + PG[(Postgres primary + replicas
users, actions,
user_scores, scoreboard_outbox)] + end + + subgraph "Observability" + Prom[Prometheus] + OTel[OpenTelemetry collector] + Logs[Log pipeline] + end + + Web -->|HTTPS| LB + Web -.->|WSS| LB + LB --> API1 & API2 & APIn + LB --> WS1 & WS2 + CDN -.-> Web + + API1 & API2 & APIn --> R + API1 & API2 & APIn --> PG + + OB --> PG + OB --> R + Recon --> PG + Recon --> R + + WS1 & WS2 -->|SUBSCRIBE pub:scoreboard| R + WS1 & WS2 --> R + + API1 & API2 & APIn -.->|metrics, traces, logs| Prom & OTel & Logs + WS1 & WS2 -.-> Prom & OTel & Logs + OB -.-> Prom & OTel & Logs +``` + +--- + +## 4. Authentication & anti-cheat decision flow + +```mermaid +flowchart TD + Start([POST /v1/scores/complete]) --> A{Bearer JWT
valid?} + A -- no --> R401([401 UNAUTHENTICATED]) + A -- yes --> B{Idempotency-Key
cached?} + B -- yes --> Replay([200 IDEMPOTENT_REPLAY
return cached body]) + B -- no --> C{Action token
signature + kid valid?} + C -- no --> R401b([401 TOKEN_INVALID]) + C -- yes --> D{Token
not expired?} + D -- no --> R401c([401 TOKEN_EXPIRED]) + D -- yes --> E{token.sub == bearer.sub?} + E -- no --> R401d([401 TOKEN_INVALID]) + E -- yes --> F{SETNX
score:redeemed:jti} + F -- 0 already set --> R409([409 TOKEN_REPLAYED]) + F -- 1 fresh --> G{Sliding-window
rate limit ok?} + G -- no --> R429([429 RATE_LIMITED]) + G -- yes --> H[PG transaction:
INSERT actions
UPDATE user_scores RETURNING
INSERT scoreboard_outbox] + H --> I[Cache idempotency response 24h] + I --> Ok([200 OK
newTotal, rank]) + + classDef err fill:#f99,stroke:#900; + classDef ok fill:#9f9,stroke:#090; + class R401,R401b,R401c,R401d,R409,R429 err + class Ok,Replay ok +``` + +--- + +## 5. Outbox publisher state machine + +```mermaid +stateDiagram-v2 + [*] --> Idle + Idle --> Polling: tick (every 200ms) + Polling --> Idle: no rows + Polling --> Publishing: rows fetched + Publishing --> Marking: Redis pipeline succeeded + Publishing --> Backoff: Redis error + Marking --> Idle: published_at set + Marking --> Backoff: PG error + Backoff --> Polling: exponential backoff (1s → 30s cap) + Backoff --> Alerting: backlog > 1000 rows OR lag > 5s + Alerting --> Polling: keep trying; humans paged +``` + +Key invariants: + +- The publisher is **leader-elected** (e.g. via Postgres advisory lock or + Kubernetes Lease). Exactly one publisher writes Redis at a time so + `INCR scoreboard:version` produces a strict monotonic sequence. +- Re-publishing a row before `published_at` is set is **always safe** — + the Redis side uses absolute `ZADD`, not `ZINCRBY`. +- The publisher never blocks the API write path. API latency is decoupled + from Redis health. diff --git a/src/problem6/IMPROVEMENTS.md b/src/problem6/IMPROVEMENTS.md new file mode 100644 index 0000000000..53b83dfb77 --- /dev/null +++ b/src/problem6/IMPROVEMENTS.md @@ -0,0 +1,316 @@ +# Problem 6 — Improvements & Comments for the Implementing Team + +This file expands on the "improvements" section of `README.md` with +suggestions, open questions, and review-time comments. None of these are +blockers — the spec in `README.md` is implementable as-is. They are +prioritised so the implementing team can tackle the highest-value items +first. + +--- + +## A. Tighten the security model + +### A.1 Switch action tokens from HS256 to RS256/EdDSA when verifiers diverge + +**Current.** Tokens signed `HS256` with a shared HMAC secret. + +**Why change.** The moment a verifier other than the issuer (e.g. a separate analytics service or a downstream microservice that wants to +cross-check) needs to validate tokens, every additional verifier holds +the _signing_ secret. A leak in any one of them lets an attacker forge +tokens. + +**Suggestion.** Move to `EdDSA` (preferred) or `RS256`. Verifiers hold +only the public key. JWKS endpoint at `/.well-known/jwks.json` for +rotation. Keep `kid` so multiple keys can be live during rotation. + +**Effort.** ~1 day including key rotation runbook. + +--- + +### A.2 Per-action signing key via HKDF + +**Current.** Single HMAC key signs every action token regardless of +`act` claim. + +**Why change.** A leak of the signing key compromises _all_ actions. For +high-value actions (e.g. championship-final completions worth 10× normal +points) you want blast-radius isolation. + +**Suggestion.** + +``` +key_for(action_type) = HKDF(master_key, + salt = "scoreboard-v1", + info = action_type) +``` + +Issuer derives the key on the fly per action; verifier does the same. +Master key never leaves the issuer. + +**Effort.** Half a day. No data-model change. + +--- + +### A.3 Bound `pts` at the database layer + +**Current.** `actions.points CHECK (points > 0)`. + +**Why change.** If the signing key is ever compromised, an attacker can +mint tokens with arbitrarily large `pts`. A DB-level cap turns a key +compromise from "leaderboard owned" into "leaderboard inflated by at +most MAX_PTS per action". + +**Suggestion.** + +```sql +ALTER TABLE actions + ADD CONSTRAINT actions_points_sane + CHECK (points > 0 AND points <= 10000); +``` + +Set the constant generously (10× highest legitimate action) so it never +fires in practice. It's defence in depth, not throttling. + +**Effort.** A migration. 30 minutes. + +--- + +### A.4 Anomaly-detection feedback loop into the rate limiter + +**Current.** Rate limiter is static (e.g. 30/min). Anomaly detection (§7.5 +of `README.md`) shadow-bans outliers separately. + +**Why change.** Static limits are tuned for honest users. Bots probe just +under the limit. A dynamic limit that _tightens_ for users with +suspicious cadence catches probing attacks earlier. + +**Suggestion.** Anomaly detector emits a `risk_score ∈ [0, 1]` per user; +the rate limiter divides its quota by `1 + 9·risk_score`. A user at +risk 1.0 gets 1/10 the normal quota — enough to tell them apart from +the noise without locking them out hard. + +**Effort.** 1 sprint (model + plumbing). + +--- + +## B. Sharpen the consistency model + +### B.1 Move from leader-elected outbox publisher to per-shard publishers + +**Current.** Single leader-elected publisher drains the outbox. + +**Why change.** Single publisher is a bottleneck at ≥ 5 000 updates/sec +sustained. Failover takes seconds during which lag grows. + +**Suggestion.** Shard `scoreboard_outbox` by `hash(user_id) % N`. Run N +publishers, each owning one shard. A shard publisher lock is held per +shard, not per service. `INCR scoreboard:version` becomes a sharded +counter (`scoreboard:version:{shard}`); WS gateway concatenates per +delta. + +**Trade-off.** More moving parts. Only worth it if the load +projections in §1.2 are firm. + +**Effort.** 1–2 sprints. + +--- + +### B.2 Replace the `scoreboard:version` counter with vector-style versioning + +**Current.** Single monotonic counter. + +**Why change.** Couples deltas to a single writer. Doesn't survive a +multi-region active/active topology cleanly. + +**Suggestion.** Use `(region, lsn)` pairs and let the WS gateway present +each region's stream independently to the client. The client merges by +`(region, lsn)`. Painful to add later — easier if you start with it. + +**Effort.** ~1 sprint, mostly client code. + +--- + +### B.3 Materialise the top-10 separately from the full ZSET + +**Current.** WS gateway computes top-10 on every delta from +`ZRANGE scores:global REV LIMIT 0 10`. + +**Why change.** At scale, this hits Redis on every score change +regardless of whether top-10 actually moved. + +**Suggestion.** Maintain a parallel `scores:top10` list that the publisher +updates only when the rank-displacement check fires. WS gateway reads +that list directly. Trade-off: extra Redis write on top-10 transitions +in exchange for skipping a `ZRANGE` on every delta. + +**Effort.** Half a day. + +--- + +## C. Operational hygiene + +### C.1 Action-token TTL is not configurable per action + +**Current.** All action tokens expire 60 s after issuance. + +**Why change.** A puzzle that takes 5 minutes to complete needs longer +than 60 s. A click-to-redeem reward needs much less. + +**Suggestion.** Make `exp` a function of `action_type`, controlled by a +config table. Default 60 s; the action-issuance handler looks up +`action_types.token_ttl_seconds` at mint time. Document a cap (e.g. +≤ 15 min) so operators can't accidentally weaken the replay window. + +**Effort.** Half a day plus DB migration. + +--- + +### C.2 Idempotency-Key cache eviction policy + +**Current.** TTL 24 h on `score:idem:*`. + +**Why change.** Memory grows linearly with traffic. At 5 000 RPS × 86 400 +s × ~200 B/key ≈ 90 GB if every request is unique. We need an explicit +maxmemory policy. + +**Suggestion.** + +- Set Redis `maxmemory-policy allkeys-lru` for the idempotency + namespace (use a dedicated logical DB or instance to avoid evicting + hotter ZSET data). +- Document that "idempotency window" is "≥ 1 h, best-effort up to 24 h" — + not a hard contract. + +**Effort.** Configuration only. + +--- + +### C.3 `ZADD scores:global new_total userId` requires a strict invariant + +**Comment.** The outbox publisher must read `new_total` from the outbox +row, NOT recompute by adding to the existing ZSET score. Violating this +breaks idempotency under retry. Spell it out in the implementation +guide; add a unit test that asserts a duplicate publish leaves the ZSET +unchanged. + +**Effort.** Test only — no code change. + +--- + +### C.4 Reconciler should be passive, not periodic + +**Current.** Reconciler ticks every 60 s comparing PG `user_scores` to +the Redis ZSET. + +**Why change.** A 60 s tick wastes CPU when nothing has drifted, and +catches drift slowly when something has. + +**Suggestion.** Make the reconciler **alarm-driven**: it runs when +`outbox_lag_seconds > 5` or after a Redis failover event. Otherwise it +sleeps. Keep a daily scheduled run as a fallback for slow drift. + +**Effort.** Half a day. + +--- + +## D. Developer & operator experience + +### D.1 Provide a "verify scoreboard integrity" CLI + +A small command that: + +``` +score-tool verify --since 1h +``` + +Recomputes the expected ZSET from `actions` for users whose scores +changed in the last hour, diffs against the live ZSET, and reports any +drift. Useful in incidents. + +**Effort.** Half a day. + +--- + +### D.2 Public OpenAPI spec for `/v1/actions/start`, `/v1/scores/complete`, `/v1/scoreboard` + +Generate from the same zod / pydantic schemas the service uses. Lets +client teams generate typed SDKs without coordination. + +**Effort.** 1 day; pays itself back the first time a frontend dev wires +up a new SDK. + +--- + +### D.3 Game-day playbook entries + +Add runbook entries for the four most likely failure modes: + +1. Outbox publisher down → backlog grows. RTO 5 min. +2. Redis primary lost → failover; during failover writes queue, reads + degrade. +3. Action-token signing key compromise → rotate `kid`, invalidate all + live tokens older than now, force re-issuance. +4. WS gateway saturated → autoscale + temporarily fall back to + long-polling at `/v1/scoreboard?since=…`. + +**Effort.** 1 day to write; tested on a quarterly game day. + +--- + +## E. Open questions to decide before implementation + +These are intentionally left for the team and product owner: + +1. **Score reset / seasons.** Does the scoreboard ever reset? If yes, do + we archive `actions` per season or keep them all and filter by + `redeemed_at`? +2. **Scoreboard scope.** Global only, or do we want regional / friend / + team boards as a v1.1? +3. **Display name source.** Cached in Redis (§5.2) — what is the cache + invalidation when a user changes their name? Today the spec is silent. +4. **Negative score adjustments.** Cheaters caught after the fact — do + we credit a negative `actions` row, or "soft-delete" the offending + actions and rebuild the ZSET? The spec assumes only positive `pts`. +5. **Tie-breaking in the ZSET.** Two users at score 9850 — alphabetical + by `userId`? Earliest-to-reach? Document and pin. +6. **Privacy.** Do we expose `userId` to other clients in the WS deltas, + or only an opaque rank-display id? Affects the `delta` event shape. + +--- + +## F. Comments on the existing spec (`README.md`) + +Notes for reviewers / future maintainers reading `README.md`: + +- **§4.1 `expiresAt` example timestamp.** Hard-coded to a 2026 date. When + copying the spec into the implementation, ensure code uses + `Date.now() + ttl`, not a literal. +- **§5.2 `score:idem` is per-user but `score:redeemed` is per-jti.** Keep + these distinct: the former is for client retries (the same logical + request), the latter is for token redemption (one-time use of a + signed permission). Conflating them would break either replay defence + or idempotency depending on which side wins. +- **§6 sequence diagram.** Reviewers consistently miss step 6 — the + `INSERT INTO scoreboard_outbox` _inside_ the same PG transaction. If + that line is ever pulled out into a separate transaction, the entire + consistency story collapses. Add a code-review checklist item. +- **§8.4 alerts.** `Replay attempts spike > 100 / 5 min` is intentionally + low — a healthy system should never replay. If you see noise, look for + a buggy client retrying without honoring the `409 TOKEN_REPLAYED` + response, not a bot. + +--- + +## G. Quick wins (≤ 1 day each, do these first) + +| # | Item | Section | +| --- | ------------------------------------------------------------ | ------- | +| 1 | Bound `pts` at DB level (`CHECK <= MAX_PTS`) | A.3 | +| 2 | Per-action signing key via HKDF | A.2 | +| 3 | Materialise top-10 separately from full ZSET | B.3 | +| 4 | Configurable per-action token TTL | C.1 | +| 5 | OpenAPI spec generation | D.2 | +| 6 | Add code-review checklist for "outbox INSERT inside same tx" | F | + +Tackling all six closes the gap between "spec on paper" and "production +service" for an experienced backend engineer in roughly one week. diff --git a/src/problem6/README.md b/src/problem6/README.md new file mode 100644 index 0000000000..a110002f9a --- /dev/null +++ b/src/problem6/README.md @@ -0,0 +1,757 @@ +# Problem 6 — Live Scoreboard Module + +--- + +## 1. Context & Requirements + +### 1.1 Functional requirements (verbatim from the brief) + +1. We have a website with a score board showing the **top 10 user scores**. +2. The score board must update **live**. +3. A user performs an action (we don't care what); completing it **increases** their score. +4. Action completion dispatches an API call to update the score. +5. We must **prevent unauthorised users from increasing scores**. + +### 1.2 Non-functional requirements (derived) + +| NFR | Target | +| ----------------------------------------- | ---------------------------------------------------------- | +| Scoreboard read latency (p99) | < 100 ms | +| Score-update propagation to clients (p99) | < 500 ms | +| API write throughput | 5 000 score updates / sec sustained, 20 000/s burst | +| Connected scoreboard viewers | 50 000 concurrent | +| Availability | 99.9 % (SLO) | +| Score replay/forge resistance | Mathematically prevent forging without the server's secret | +| Audit | Every score change is reconstructible from a durable log | + +### 1.3 Out of scope (explicitly) + +- The action mechanic itself (game logic, completion detection). +- Personalised leaderboards / friend boards / regional boards (see §10 for + how to extend). +- Mobile push notifications (separate concern; the WS channel suffices). + +--- + +## 2. Glossary + +| Term | Meaning | +| ------------------- | ---------------------------------------------------------------------- | +| **Action** | A unit of work the user performs that yields score points | +| **Action Token** | A short-lived, server-signed JWT that authorises _one_ score increment | +| **Idempotency Key** | Client-supplied UUID to make a write retry-safe | +| **Top-N** | The current top-10 scoreboard, sourced from Redis ZSET | +| **Live channel** | The WebSocket room (`scoreboard:global`) that broadcasts changes | + +--- + +## 3. Architecture + +The system is split into five tiers: **client**, **edge**, **API**, +**realtime**, and **state**, plus a small **async-workers** plane for +the outbox publisher and the reconciler. Each tier is independently +deployable and independently scaled. + +```mermaid +flowchart LR + subgraph Client["Client tier"] + Web[Web app
SPA] + end + + subgraph Edge["Edge / ingress"] + CDN[CDN
static assets] + LB[L7 LB
TLS 1.3 termination
WAF] + end + + subgraph API["API tier — stateless, HPA on RPS"] + direction TB + AuthN["Auth middleware
(session JWT verify)"] + RL["Rate limiter
(sliding window, Redis)"] + Idem["Idempotency cache
(Redis SETEX)"] + ActionSvc["Action issuance
POST /v1/actions/start"] + ScoreSvc["Score-update handler
POST /v1/scores/complete"] + Board["Scoreboard reader
GET /v1/scoreboard"] + Tickets["WS ticket issuer
POST /v1/scoreboard/ws-ticket"] + end + + subgraph Realtime["Realtime tier — stateless, sticky on userId"] + direction TB + WSGW["WS gateway
handshake auth
top-10 diff"] + Fan["Pub/Sub fan-out
per-pod"] + end + + subgraph Workers["Async workers"] + direction TB + OB["Outbox publisher
leader-elected"] + Recon["Reconciler
alarm-driven"] + end + + subgraph State["State tier"] + direction TB + RedisHot[("Redis
scores:global ZSET
scoreboard:version
pub:scoreboard")] + RedisAux[("Redis (logical DB 1)
idempotency
replay-guard
rate-limit
ws-tickets")] + PG[("Postgres primary
users · actions
user_scores
scoreboard_outbox")] + PGRO[("Postgres read replicas
cold-start rebuild")] + end + + subgraph Obs["Observability"] + Prom[Prometheus] + OTel[OTel collector] + Logs[Log pipeline] + end + + Web -.->|static| CDN + Web -->|HTTPS| LB + Web <-->|WSS| LB + + LB --> AuthN + AuthN --> RL --> Idem + Idem --> ActionSvc & ScoreSvc & Board & Tickets + + LB -->|upgrade ?ticket=| WSGW + + ActionSvc -. signs JWT .-> Web + ActionSvc --> RedisAux + ScoreSvc --> RedisAux + ScoreSvc --> PG + Board --> RedisHot + + OB --> PG + OB --> RedisHot + Recon --> PG + Recon --> RedisHot + Recon -. cold start .- PGRO + + WSGW --> Tickets + WSGW --> RedisHot + WSGW <-->|SUBSCRIBE pub:scoreboard| RedisHot + + AuthN & ScoreSvc & WSGW & OB -.->|metrics| Prom + AuthN & ScoreSvc & WSGW & OB -.->|traces| OTel + AuthN & ScoreSvc & WSGW & OB -.->|logs| Logs +``` + +### 3.1 Tiers + +| Tier | Responsibility | Stateless? | Scale axis | +| --------------------- | ----------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------- | ------------------------------------------- | +| **Client** | UI, optimistic rendering, reconnect logic | n/a | n/a | +| **Edge** | TLS, WAF, rate-limit at the IP layer, request-id stamp | yes | LB capacity | +| **API** | All synchronous request handling: auth → rate limit → idempotency → business logic. Reads from Redis ZSET. Writes to Postgres only. | yes | HPA on CPU + RPS | +| **Realtime** | Long-lived WS connections; subscribes to `pub:scoreboard`; computes top-10 diff; pushes to clients. | yes (per connection) | HPA on connection count + CPU | +| **Async workers** | Drain outbox → Redis. Repair drift. | leader-elected (one writer at a time) | sharded by `hash(user_id)` if scale demands | +| **State — hot** | Redis cluster: ZSET, version counter, Pub/Sub channel. | no | shard on userId | +| **State — auxiliary** | Redis cluster (separate logical DB or instance): idempotency, replay guard, rate-limit, WS tickets. Eviction-friendly. | no | independent of hot | +| **State — durable** | Postgres: users, actions (audit), user_scores (materialised total), scoreboard_outbox (transactional handoff). Source of truth. | no | primary + read replicas | +| **Observability** | Metrics, traces, logs. Out-of-band. | yes | n/a | + +### 3.2 Component responsibilities + +**API tier — module-level** + +- `AuthN` — verify session JWT (signature, exp, sub, kid). Strip and + attach `userId` to the request context. Reject on any failure. +- `RL` — sliding-window rate limit using `ZADD + ZREMRANGEBYSCORE + +ZCARD` against the auxiliary Redis. Key per `(userId, action_type)` + for `/scores/complete`; per `userId` for everything else. +- `Idem` — checks `score:idem:{userId}:{key}` before any mutation. Hit → + return cached body verbatim. Miss → continue, cache the response after + the handler succeeds. +- `ActionSvc` — mints the signed action token (`HS256`/`EdDSA`, 60 s TTL, + per-action `pts` from `action_types` config). Pure compute; no PG. +- `ScoreSvc` — verifies the action token, redeems `score:redeemed:{jti}` + via `SETNX`, runs the PG transaction (`actions` + `user_scores` + + `scoreboard_outbox` — see §6), returns `(newTotal, rank)`. Never + writes Redis ZSET directly; that's the publisher's job. +- `Board` — single Redis call: `ZRANGE scores:global REV LIMIT 0 N +WITHSCORES`. Joins `displayName` from `user:{id}:meta` cache; falls + back to PG on cache miss. +- `Tickets` — issues 30 s single-use JWTs for the WS handshake. Stored + in auxiliary Redis with `SETEX … 30 EX NX` so the same ticket cannot + be reused. + +**Realtime tier** + +- `WSGW` — accepts the HTTP `Upgrade`, validates the ticket, opens the + socket. On `subscribe`, sends a snapshot, then forwards diffed deltas + while the connection lives. Maintains `ping`/`pong` for + detection. Subscribes once per pod to `pub:scoreboard`; broadcasts + fan-out is per pod. + +**Async workers** + +- `OB` (outbox publisher) — polls `scoreboard_outbox WHERE +published_at IS NULL`. For each row, runs a Redis pipeline: + `ZADD scores:global new_total userId` (idempotent absolute write) + + `INCR scoreboard:version` + `PUBLISH pub:scoreboard {…}`. Marks the + outbox row published. Leader-elected via Postgres advisory lock or + Kubernetes Lease. +- `Recon` (reconciler) — alarm-driven (triggers on + `outbox_lag_seconds > 5` or after a Redis failover). Recomputes the + ZSET from `user_scores`, diffs, repairs. + +### 3.3 Data ownership & flow direction + +The most important rule: **only the outbox publisher writes the +`scores:global` ZSET.** API pods write only Postgres. This single-writer +discipline is what makes the broadcast idempotent — there is exactly one +party that decides the order of `INCR scoreboard:version` and the +content of every `pub:scoreboard` message. + +``` +write path: API pod → Postgres tx → outbox publisher → Redis ZSET +read path: API pod → Redis ZSET (top-10) + API pod → Postgres (audit, user history) +live path: Outbox publisher → Redis Pub/Sub → WS gateway → client +``` + +API write latency is **decoupled from Redis health**. If Redis goes +down, the API still writes Postgres (so audits and idempotency from PG +are intact); the broadcast lags until the publisher catches up. The +client experiences "scoreboard not updating" rather than "actions not +working". + +### 3.4 Inter-component contracts + +| From → To | Protocol | Auth | Notes | +| ---------------- | ------------------- | --------------------------------------------------- | --------------------------------- | +| Web → LB (HTTP) | HTTPS / TLS 1.3 | Session JWT (Bearer) | HSTS preload | +| Web → LB (WS) | WSS | Single-use ticket query param | 30 s TTL, `SETNX`-redeemed | +| LB → API | HTTP (mTLS in mesh) | request-id pass-through | trust-proxy hop = 1 | +| LB → WSGW | HTTP upgrade | ticket validated by WSGW | sticky on `userId` hash | +| API → Redis hot | RESP3 | ACL: read+ZRANGE only | no `ZADD`/`ZINCRBY` from API pods | +| API → Redis aux | RESP3 | ACL: read+write idem/replay/rate | logical DB 1 | +| API → PG | TLS, conn pool | role: `api` (rw on actions, user_scores, outbox) | LISTEN/NOTIFY unused | +| OB → PG | TLS, conn pool | role: `publisher` (rw on outbox; ro on user_scores) | leader-only | +| OB → Redis hot | RESP3 | ACL: write to `scores:global` and `pub:scoreboard` | sole writer | +| WSGW → Redis hot | RESP3 | ACL: read + SUBSCRIBE | no writes | + +ACL separation matters: a compromised API pod cannot directly poison the +ZSET because its Redis credentials don't carry `ZADD` permission on +`scores:global`. The outbox publisher's credentials do — and the +publisher only ever runs trusted code. + +### 3.5 Failure-mode summary + +| Failure | Effect on API | Effect on scoreboard | +| ------------------------------------ | ----------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------- | +| Hot Redis down | reads degrade (Board returns stale snapshot from PG fallback) | live updates pause; outbox grows; clients show "reconnecting" | +| Auxiliary Redis down | rate limit + idempotency cache unavailable → API fails closed (503) on writes | unaffected once API recovers | +| Postgres primary down | writes fail (5xx); reads hit replicas | outbox doesn't drain new rows; existing top-10 still served from Redis | +| Outbox publisher down | unaffected | live updates pause; alarm fires within 5 s on `outbox_lag_seconds` | +| WS gateway pod lost | unaffected | clients on that pod reconnect to a sibling; another pod has the same Pub/Sub subscription | +| Action-token signing key compromised | rotate `kid`, force re-issuance | scoreboard inflated by at most `MAX_PTS · token_window` (see DB cap) | + +Each row maps 1:1 to a runbook entry — see §11 hand-off checklist. + +### 3.6 Why this shape + +- **Redis ZSET + Pub/Sub** is the canonical low-latency leaderboard + pattern. `ZRANGE … REV LIMIT 0 10` is sub-millisecond at any + reasonable cardinality. +- **Postgres as source of truth** gives durable audit (`actions`), + idempotency (`UNIQUE(user_id, idempotency_key)`), and a clean rebuild + path on Redis loss. +- **Transactional outbox** removes the dual-write window that would + otherwise exist between PG and Redis (see §6.1 for the proof). +- **Single-writer ZSET** + **idempotent broadcast** (`ZADD `, + not `ZINCRBY`) means duplicate publishes are safe — important because + the outbox publisher is at-least-once. +- **Stateless API + WS tier** scales horizontally. State is fully + externalised. New pods start cold and serve immediately. +- **Auxiliary Redis separated from hot Redis** lets each be tuned + independently — `allkeys-lru` on the aux instance for idempotency + eviction; `noeviction` on the hot instance because losing scores is + unacceptable. + +--- + +## 4. API contract + +All endpoints sit behind the authenticated API gateway. Bearer token (JWT) +required on every call. + +### 4.1 `POST /v1/actions/start` — issue an action token + +> Called when the user **begins** the action, not when they finish it. This +> is the linchpin of the anti-cheat design: the score increment is +> server-decided, not client-supplied. + +Request + +```http +POST /v1/actions/start +Authorization: Bearer +Content-Type: application/json +Idempotency-Key: 0a3f8d9e-... +{ + "actionType": "puzzle_complete" +} +``` + +Response `200` + +```json +{ + "ok": true, + "data": { + "actionToken": "eyJhbGciOiJIUzI1NiIsImtpZCI6...", + "expiresAt": "2026-05-02T10:35:00Z" + } +} +``` + +The action token is a short-lived (60 s) JWT signed with `HS256` and the +service's rotating HMAC secret. Claims: + +```jsonc +{ + "sub": "user_8e1f...", // user id (must match Bearer) + "act": "puzzle_complete", // action type + "pts": 50, // server-decided point value + "jti": "1f5c…", // unique id — used for replay defence + "iat": 1700000000, + "exp": 1700000060, // 60 s window + "kid": "v3", // signing-key version +} +``` + +### 4.2 `POST /v1/scores/complete` — submit completion + +Request + +```http +POST /v1/scores/complete +Authorization: Bearer +Content-Type: application/json +Idempotency-Key: 0a3f8d9e-... +{ + "actionToken": "eyJ…" +} +``` + +Response `200` + +```json +{ + "ok": true, + "data": { + "userId": "user_8e1f…", + "pointsAdded": 50, + "newTotal": 1240, + "rank": 7 + } +} +``` + +Errors + +| Code | Status | Cause | +| ------------------- | ------ | ---------------------------------------------------- | +| `TOKEN_EXPIRED` | 401 | `actionToken` past `exp` | +| `TOKEN_INVALID` | 401 | bad signature, unknown `kid`, or `sub` mismatch | +| `TOKEN_REPLAYED` | 409 | `jti` already redeemed | +| `IDEMPOTENT_REPLAY` | 200 | same `Idempotency-Key` → returns the original result | +| `RATE_LIMITED` | 429 | per-user/min cap exceeded | + +### 4.3 `GET /v1/scoreboard` — top-N snapshot + +```http +GET /v1/scoreboard?limit=10 +``` + +```json +{ + "ok": true, + "data": [ + { "rank": 1, "userId": "user_…", "displayName": "Anya", "score": 9850 }, + ... + ], + "version": 18432 +} +``` + +`version` is the monotonically increasing scoreboard revision. Clients +include it in WS subscribe to receive only newer deltas. + +### 4.4 `WS /v1/scoreboard/live` — live updates + +WebSocket auth uses a **short-lived ticket** rather than the long-lived +session token. The client first calls `POST /v1/scoreboard/ws-ticket` +(authed via the normal Bearer header) to receive a single-use, 30-second +JWT. It then opens the WSS connection at `/v1/scoreboard/live?ticket=…`. +The server validates the ticket on `upgrade` and rejects the handshake +on failure. This avoids putting bearer tokens in URLs (logs, browser +history) and avoids the `Sec-WebSocket-Protocol`-as-auth anti-pattern. + +Client → server + +```json +{ "type": "subscribe", "since": 18432 } +``` + +Server → client (event types) + +```json +{ "type": "snapshot", "version": 18432, "top10": [ ... ] } + +{ "type": "delta", "version": 18433, "changes": [ + { "userId": "user_…", "score": 1240, "rank": 7, "previousRank": 9 } +] } + +{ "type": "ping", "ts": 1700000000 } +``` + +The server pushes a `delta` event whenever an accepted score change +**enters, leaves, or reorders the top-10**. The publisher computes this +by reading the previous and current top-10 from the ZSET in a single +pipelined call and diffing them. Score changes that don't affect the +top-10 are silently absorbed — clients only ever see updates that +require re-render. If the user's connection drifts > 60 s without a +`pong`, the server closes it; the client reconnects with the latest known +`version`. + +--- + +## 5. Data model + +### 5.1 Postgres (source of truth, durable) + +```sql +CREATE TABLE users ( + id UUID PRIMARY KEY, + display_name TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE TABLE actions ( + -- Append-only audit log of every credited score change. + id UUID PRIMARY KEY, -- = jti from action token + user_id UUID NOT NULL REFERENCES users(id), + action_type TEXT NOT NULL, + points INTEGER NOT NULL CHECK (points > 0), + idempotency_key UUID NOT NULL, + redeemed_at TIMESTAMPTZ NOT NULL DEFAULT now(), + UNIQUE (user_id, idempotency_key) -- idempotency +); + +CREATE INDEX idx_actions_user_redeemed ON actions(user_id, redeemed_at DESC); + +CREATE TABLE user_scores ( + -- Materialised total. Source of truth on cold start; rebuildable from `actions`. + user_id UUID PRIMARY KEY REFERENCES users(id), + score BIGINT NOT NULL DEFAULT 0, + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE TABLE scoreboard_outbox ( + -- Transactional outbox. Every accepted score change writes one row in + -- the *same* PG transaction as the actions/user_scores update. A + -- separate worker (the "outbox publisher") drains it to Redis + Pub/Sub. + -- This eliminates the dual-write window that would otherwise exist + -- between Postgres and Redis. + seq BIGSERIAL PRIMARY KEY, + user_id UUID NOT NULL REFERENCES users(id), + points INTEGER NOT NULL, + new_total BIGINT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + published_at TIMESTAMPTZ +); +CREATE INDEX idx_outbox_unpublished ON scoreboard_outbox (seq) + WHERE published_at IS NULL; +``` + +### 5.2 Redis (hot path) + +| Key | Type | Purpose | +| --------------------------- | ------------------ | ----------------------------------------------------------------------------------- | +| `scores:global` | ZSET | `(score → userId)`. `ZRANGE … REV WITHSCORES LIMIT 0 10` is the entire top-10 read. | +| `user:{id}:meta` | HASH | `displayName`, `lastUpdated` — cached to avoid PG hit on read | +| `score:redeemed:{jti}` | STRING (TTL 5 min) | replay defence — `SETNX` returns 0 if already redeemed | +| `score:idem:{userId}:{key}` | STRING (TTL 24 h) | idempotency cache — value is the cached response | +| `score:rate:{userId}` | STRING + EXPIRE | per-user rate-limit counter | +| `scoreboard:version` | INTEGER (counter) | monotonic version, bumped on every accepted update | +| `pub:scoreboard` | Pub/Sub channel | publishes JSON deltas; WS gateways subscribe | + +Redis is the **read-only-fast-path**: any client request for the top-10 +hits Redis (sub-ms). Postgres is touched only on writes (inside a +transaction, alongside the Redis write — see §6) and on cold-start rebuild. + +--- + +## 6. Score-update flow (the critical path) + +The flow uses a **transactional outbox** so that the score change in +Postgres and the broadcast to Redis are linked through a single durable +write. This avoids the dual-write window where a process crash between +"PG commit" and "Redis ZINCRBY" would leave the scoreboard out of sync. + +```mermaid +sequenceDiagram + autonumber + participant U as User + participant W as Web client + participant API as API + participant R as Redis + participant PG as Postgres + participant OB as Outbox publisher + participant WSG as WS gateway + participant V as Other viewers + + U->>W: starts action + W->>API: POST /v1/actions/start (Idempotency-Key) + API->>API: validate session, mint signed action token (60 s TTL) + API-->>W: { actionToken, exp } + + U->>W: completes action + W->>API: POST /v1/scores/complete (Idempotency-Key, actionToken) + API->>API: verify JWT signature & exp & sub + API->>R: SETNX score:redeemed:{jti} (replay guard, TTL = exp + 30s) + alt jti already set + API-->>W: 409 TOKEN_REPLAYED + else + API->>R: token-bucket rate check + API->>PG: BEGIN + API->>PG: INSERT INTO actions (jti, user_id, pts, idem_key) — UNIQUE on (user_id, idem_key) + API->>PG: UPDATE user_scores SET score=score+pts, updated_at=now()
WHERE user_id=$1
RETURNING score AS new_total + API->>PG: INSERT INTO scoreboard_outbox (user_id, points, new_total) + API->>PG: COMMIT + API-->>W: 200 { newTotal, rank } + end + + Note over OB,R: continuously, in parallel + OB->>PG: SELECT * FROM scoreboard_outbox WHERE published_at IS NULL ORDER BY seq LIMIT N + OB->>R: ZADD scores:global new_total userId + OB->>R: INCR scoreboard:version + OB->>R: PUBLISH pub:scoreboard {delta} + R-->>WSG: delta event + WSG-->>V: WS push + OB->>PG: UPDATE scoreboard_outbox SET published_at=now() WHERE seq=… +``` + +### 6.1 Why the outbox eliminates the dual-write problem + +The transaction commits **both** the score change _and_ the outbox row +atomically. Three crash scenarios: + +| Crash point | Effect | +| ---------------------------------------------------------- | ------------------------------------------------------------------------------------------------- | +| Before PG commit | Nothing happened; client retries with same `Idempotency-Key`. Safe. | +| After PG commit, before publisher reads | Row is in outbox; publisher picks it up on next tick (≤ 1 s). | +| After publisher writes Redis, before `published_at` update | Row gets republished; ZSET write is idempotent (`ZADD` with absolute score, not `ZINCRBY`). Safe. | + +Note we use `ZADD ` (absolute), not `ZINCRBY `. That +is the second crucial change: an idempotent broadcast, so duplicate +delivery cannot double-credit the leaderboard. + +### 6.2 Concurrency on `user_scores` + +Concurrent updates to the same `user_id` could race. We rely on +Postgres's row-level lock taken implicitly by `UPDATE … WHERE user_id = +$1` — under the default isolation (`READ COMMITTED`), the second +transaction blocks until the first commits, then reads the updated value. +The `RETURNING score` clause gives the caller the post-update total +without a second read. The outbox row is therefore always consistent +with `user_scores.score` at the moment of commit. + +### 6.3 Idempotency vs replay defence — two layers + +- **Replay defence (`SETNX score:redeemed:{jti}`)** prevents a stolen + action token from being redeemed twice. TTL slightly exceeds the + token's `exp` (e.g. 90 s) so it covers in-flight retries plus clock + skew without bloating Redis memory. +- **Idempotency-Key** prevents _legitimate_ network retries from + double-writing. Cached for 24 h. The `actions` table also enforces it + at the engine level via `UNIQUE (user_id, idempotency_key)` — a + defence-in-depth final guard. + +--- + +## 7. Security & anti-cheat + +This is the most important section of the document. The five layers below +each defend against a different attack class. + +### 7.1 Authentication + +- All HTTP endpoints require a session JWT (`Bearer `). +- WebSocket auth happens on the upgrade request; tokens older than the + session TTL are refused. +- Tokens are HS256-signed with a key rotated **monthly**; `kid` claim + carries the version so we can run two valid keys during rotation. + +### 7.2 Action-token model (the linchpin) + +The client never tells the server _how many points_ to add. The server +issues a **signed action token** at action start that fixes: + +1. The _user_ (`sub`). +2. The _action type_ (`act`). +3. The _point value_ (`pts`) — server-decided, non-negotiable. +4. A unique `jti` and 60-second `exp`. + +When the action completes, the client returns the **same** token. The +server verifies signature → not-expired → `sub` matches the bearer → +`jti` not previously redeemed (Redis `SETNX`). Any failure rejects the +write. + +**This defeats:** + +- Forging a score (no signing key → no valid token). +- Choosing a higher point value (`pts` is signed, can't be edited). +- Replaying a successful completion (Redis `jti` guard, 5-min window). +- Cross-account theft (`sub` is bound; mismatch → 401). + +### 7.3 Idempotency + +Every write accepts an `Idempotency-Key` (UUID). The first call writes the +result to `score:idem:{userId}:{key}` (TTL 24 h). Subsequent calls with the +same key short-circuit and return the cached response. This makes network +retries safe without the server having to reason about partial state. + +### 7.4 Rate limiting + +Token-bucket per `(userId, action_type)`: + +| Action | Limit | +| -------------------------- | -------------------- | +| `*` | 30 / min, 200 / hour | +| Specific high-value action | 5 / min | + +Implemented as **sliding-window** in Redis: `ZADD score:rate:{userId} + ` followed by `ZREMRANGEBYSCORE … 0 (now-windowMs)` then +`ZCARD`. This avoids the fixed-window edge-pile-up bug +(naïve `INCR + EXPIRE` lets a user fire 60 calls across the boundary +of a 60-second window). The added cost is one extra Redis round-trip +per request — acceptable given how cheap it is vs the cost of +abuse. + +### 7.5 Anomaly detection + +- Track `actions_per_minute_per_user` as a metric. +- Alert when a user exceeds 3× their 7-day rolling p95 → automatic shadow- + ban (writes accepted into PG, _not_ into the ZSET) until manual review. +- Track inter-arrival time variance. Bots tend to have suspiciously regular + cadence; humans don't. + +### 7.6 Defence-in-depth + +- TLS 1.3 everywhere. HSTS preload. +- Helmet + strict CSP on the web client. +- WAF rule for `Authorization` length, `Idempotency-Key` shape, JSON depth. +- Audit log (`actions` table) is the ground truth for forensics. + +--- + +## 8. Observability + +### 8.1 Metrics + +| Metric | Type | Purpose | +| -------------------------------------- | --------- | ------------------------------------------------------- | +| `score_update_total{action,result}` | counter | accepted vs rejected | +| `score_update_latency_seconds` | histogram | `/scores/complete` p50/p95/p99 | +| `auth_failure_total{reason}` | counter | invalid / expired / replayed | +| `replay_attempts_total` | counter | `jti` collisions | +| `rate_limit_hits_total{userId_bucket}` | counter | bucket-anonymised | +| `ws_connected_clients` | gauge | live WS connections | +| `outbox_unpublished_rows` | gauge | depth of `scoreboard_outbox WHERE published_at IS NULL` | +| `outbox_lag_seconds` | gauge | `now() - min(created_at) WHERE published_at IS NULL` | + +### 8.2 Logs + +Structured JSON. Every line carries `requestId`, `userId`, `action`, +`result`, `latencyMs`. PII redaction at the logger layer. + +### 8.3 Traces + +OpenTelemetry spans: +`http.request → auth.verify → token.verify → redis.replayCheck → +pg.tx → redis.zincrby → redis.publish`. Each span tagged with +`requestId` so cross-service traces stitch together. + +### 8.4 Alerts + +| Alert | Condition | +| ----------------------- | ----------------------------------------------------------- | +| `Score updates failing` | `score_update_total{result="error"}` rate > 0.5 % for 5 min | +| `Replay attempts spike` | `replay_attempts_total` > 100 / 5 min | +| `Outbox lagging` | `outbox_lag_seconds` > 5 (publisher unhealthy) | +| `Outbox backlog` | `outbox_unpublished_rows` > 1 000 | +| `WS gateway saturation` | `ws_connected_clients` > 80 % capacity | + +--- + +## 9. Capacity & scaling + +| Layer | Strategy | +| ---------- | ------------------------------------------------------------------------------------------------------------------------ | +| API | Stateless, horizontally scaled behind LB. HPA on CPU + RPS. | +| WS gateway | Stateless. Sticky sessions or consistent hashing on `userId`. Redis Pub/Sub for cross-pod fan-out. | +| Redis | Single-shard fits 50k users easily. Move to Redis Cluster when ZSET memory > 50 % node RAM, sharding by user-prefix. | +| Postgres | Single primary + read replicas. Writes are append-mostly; partition `actions` by month if cardinality becomes a problem. | +| Pub/Sub | At ~5 000 deltas/sec a single Redis instance is fine. Above that, consider Kafka/NATS for durable fan-out. | + +**Back-of-envelope**: 50 000 concurrent WS clients × 1 delta/sec average += 50k messages/sec server-side outbound — comfortably within a single WS +gateway pod's capacity (Node + uWebSockets benchmarks ~250k msg/sec). + +--- + +## 10. Improvements & open questions + +> Listed deliberately as suggestions for the implementing team, not +> blockers. + +1. **CRDT-based ZSET** — for multi-region active/active, replace the + ZSET with a CRDT counter per user. Higher write cost, no central + region. +2. **Personal/regional boards** — keyed ZSETs (`scores:region:{region}`) + updated alongside the global one. Same broadcast model. +3. **Action-token point ranges** — for actions where the point value + depends on completion time / quality, sign the _bounds_ `(pts_min, +pts_max)` and let the server clamp the client-supplied value. Still + safer than a raw client-side number. +4. **Public-key signing (RS256)** — if action tokens are ever consumed by + a different service from the issuer, switch to asymmetric signing so + verifiers don't need the secret. +5. **Fraud-detection ML** — feed `actions_per_minute`, inter-arrival + variance, IP/device clustering into an offline model; promote + suspected accounts to shadow-ban automatically. +6. **WebSocket → SSE fallback** — corporate networks often block WS. + `/v1/scoreboard/live/sse` gives a no-fuss alternative; same JSON delta + shape. +7. **Score decay** — does score live forever? If a season concept exists, + archive `actions` table by season and reset the ZSET. +8. **Top-N vs Top-N±buffer** — fetching `LIMIT 12` instead of 10 lets the + client display "rising" indicators without a second round-trip. +9. **Per-action signing key** — for ultra-high-value actions, derive a + per-action key from `HKDF(master_key, action_type)` so a leak of one + action's key doesn't compromise others. + +--- + +## 11. Implementation hand-off checklist + +- [ ] Postgres migrations for `users`, `actions`, `user_scores`. +- [ ] Redis configuration: WAL persistence, `maxmemory-policy allkeys-lru`, + ACL with separate users for hot path vs reconciler. +- [ ] Action-token issuer module (issuance, rotation, key versioning). +- [ ] `/v1/actions/start`, `/v1/scores/complete`, `/v1/scoreboard`. +- [ ] WS gateway service — auth on upgrade, Pub/Sub fan-out, ping/pong. +- [ ] Reconciler worker — 60 s tick, repair Redis from PG. +- [ ] Idempotency middleware (24 h cache). +- [ ] Rate-limiter middleware (per user, per action). +- [ ] Metrics + traces + structured logs (§8). +- [ ] Runbook: replay-attack response, secret rotation, Redis failover, + reconciler lag mitigation. +- [ ] Load test (k6) hitting the throughput targets in §1.2. +- [ ] Game-day: simulate Redis outage and verify WS gateway behaviour. + +--- + +## 12. Diagrams summary + +The two Mermaid diagrams above (component flow §3 and sequence §6) are +the canonical reference. Render them in any Mermaid-aware viewer +(GitHub, GitLab, VS Code preview).